diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/entity/Dept.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/entity/Dept.java index 15a3e94a..e5958c76 100644 --- a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/entity/Dept.java +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/entity/Dept.java @@ -150,6 +150,13 @@ public class Dept extends TenantEntity { @Schema(description = "合同编码前缀") private String contractCodePrefix; + /** + * 合同初始流水号(该部门当年首个合同时从此号起编,范围1-999) + */ + @Schema(description = "合同初始流水号") + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private Integer contractInitialSerial; + /** * 组织简称 */ diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CargoLineVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CargoLineVO.java new file mode 100644 index 00000000..831e6313 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CargoLineVO.java @@ -0,0 +1,48 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 货物明细行:一条货物一行 + *

+ * 详情页逐行展示「货物名称 + 数量单位」,名称与数量分开给,前端不需要再从汇总串里拆。 + */ +@Data +@Schema(description = "货物明细行") +public class CargoLineVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "货物名称") + private String cargoName; + + @Schema(description = "数量(含单位,如 1000吨);录入时只填了名称则为空") + private String quantity; +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java index d7860f6e..eaeec543 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java @@ -67,6 +67,9 @@ public class DriverPunchNodeVO implements Serializable { @Schema(description = "不可打卡的原因,供司机端展示") private String lockReason; + @Schema(description = "运单已结束(完成/取消):卡片仅展示、不可提交") + private Boolean ended; + @Schema(description = "是否展示该卡(在途可能因频次/时段隐藏)") private Boolean visible; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverWaybillCardVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverWaybillCardVO.java index cf2c7652..23432c6a 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverWaybillCardVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverWaybillCardVO.java @@ -67,6 +67,9 @@ public class DriverWaybillCardVO implements Serializable { @Schema(description = "货物类别") private String cargoCategory; + @Schema(description = "货物明细(详情返回):一条一行,同名同单位的货物已合并数量") + private List cargos; + @Schema(description = "重量(带单位)") private String weight; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripVO.java index e3c388a2..a6523dfb 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripVO.java @@ -129,12 +129,21 @@ public class TripVO implements Serializable { @Schema(description = "货物汇总:按货物名合并数量,同名同单位取合计") private String cargoSummary; + @Schema(description = "货物明细:一条货物一行,同名同单位的货物已合并数量") + private List cargos; + @Schema(description = "合计重量:全部子运单同单位时为数量合计,单位不一致时为空") private BigDecimal totalQuantity; @Schema(description = "合计重量的单位,与 totalQuantity 同时为空或同时有值") private String quantityUnit; + @Schema(description = "计重合计:全部货物同单位时取该单位合计,单位不一致时只合计「吨」项(其余单位不参与);无可用合计时为空") + private BigDecimal weighingQuantity; + + @Schema(description = "计重合计的单位,与 weighingQuantity 同时为空或同时有值") + private String weighingUnit; + @Schema(description = "计划发货日期(配载单,未配载为空)") private LocalDate estimatedStartDate; diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/convert/UserConvert.java b/blade-service/blade-system/src/main/java/org/springblade/system/convert/UserConvert.java index abf59acf..2dabbf04 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/convert/UserConvert.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/convert/UserConvert.java @@ -148,7 +148,7 @@ public interface UserConvert { /** * oa人员转本系统用户部门 * @param person - * @param userMap 所有用户手机号与id map + * @param userMap OA登录名与用户id * @return */ default UserDept person2userDept(OAPersonResponse person, Map userMap) { @@ -162,8 +162,9 @@ public interface UserConvert { userDept.setStatus(status); // 排序 userDept.setSort(OAUtils.parseInt(person.getDsporder())); - // 用户id - userDept.setUserId(userMap.get(resolveAccount(person))); + // 用户id,按 OA 登录名关联本批已新增或更新的用户 + String oaLoginId = person == null ? null : StringUtils.trimToNull(person.getLoginid()); + userDept.setUserId(oaLoginId == null ? null : userMap.get(oaLoginId)); userDept.setSyncTime(new Date()); return userDept; } diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/DeptMapper.xml b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/DeptMapper.xml index f45626dd..bfbaf61f 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/DeptMapper.xml +++ b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/DeptMapper.xml @@ -14,6 +14,7 @@ + @@ -36,6 +37,7 @@ + diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/DeptServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/DeptServiceImpl.java index 3dda7348..dd24564f 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/DeptServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/DeptServiceImpl.java @@ -687,6 +687,7 @@ public class DeptServiceImpl extends ServiceImpl implements ID } validateDeptCategory(dept); validateDeptCode(dept, parent); + validateContractInitialSerial(dept); return saveOrUpdate(dept); } @@ -874,6 +875,16 @@ public class DeptServiceImpl extends ServiceImpl implements ID } } + private void validateContractInitialSerial(Dept dept) { + Integer serial = dept.getContractInitialSerial(); + if (serial == null) { + return; + } + if (serial < 1 || serial > 999) { + throw new ServiceException("合同初始流水号范围为1到999"); + } + } + private void validateDeptCode(Dept dept, Dept parent) { String deptCode = dept.getDeptCode(); // 组织编码非必填,为空时存 null,避免唯一索引冲突 diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaUserListSyncHelper.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaUserListSyncHelper.java index 87e3cd50..46f8af2b 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaUserListSyncHelper.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaUserListSyncHelper.java @@ -403,70 +403,37 @@ public class OaUserListSyncHelper { Map uniquePersonMap = new LinkedHashMap<>(); int skippedCount = 0; for (OAPersonResponse oaPerson : oaPersons) { - String account = userConvert.resolveAccount(oaPerson); - if (StringUtils.isBlank(account)) { + String oaLoginId = resolveOaLoginId(oaPerson); + if (StringUtils.isBlank(oaLoginId)) { skippedCount++; continue; } - uniquePersonMap.putIfAbsent(account, oaPerson); + uniquePersonMap.put(oaLoginId, oaPerson); } if (uniquePersonMap.isEmpty()) { - ComposeLogUtil.getLastLog().warn("OA人员均缺少loginid/工号/手机号,跳过人员同步"); + ComposeLogUtil.getLastLog().warn("OA人员均缺少登录名,跳过人员同步"); return new PersonSyncCount(0, skippedCount); } String tenantId = resolveTenantId(); String defaultPassword = DigestUtil.encrypt(ParamCache.getValue(DEFAULT_PARAM_PASSWORD)); - List users = uniquePersonMap.values().stream() - .map(person -> { - User user = userConvert.person2user(person, defaultPassword); + List users = uniquePersonMap.entrySet().stream() + .map(entry -> { + User user = userConvert.person2user(entry.getValue(), defaultPassword); user.setTenantId(tenantId); + user.setOaLoginId(entry.getKey()); + user.setAccount(entry.getKey()); return user; }) .toList(); - List accounts = users.stream() - .map(User::getAccount) - .filter(StringUtils::isNotBlank) - .distinct() - .toList(); - List phones = users.stream() - .map(User::getPhone) - .filter(StringUtils::isNotBlank) - .distinct() - .toList(); - Map existingByAccount = new HashMap<>(); - Map existingByPhone = new HashMap<>(); - userService.list(Wrappers.lambdaQuery() - .and(wrapper -> { - wrapper.in(User::getAccount, accounts); - if (CollectionUtil.isNotEmpty(phones)) { - wrapper.or().in(User::getPhone, phones); - } - }) - ).stream() - .peek(userService::decryptPhone) - .forEach(user -> { - if (StringUtils.isNotBlank(user.getAccount())) { - existingByAccount.put(user.getAccount(), user); - } - if (StringUtils.isNotBlank(user.getPhone())) { - existingByPhone.put(user.getPhone(), user); - } - }); + Map existingByOaLoginId = this.loadUsersByOaLoginId(tenantId, uniquePersonMap.keySet()); Map userMap = new HashMap<>(); Set existsUserIds = new HashSet<>(); - Set unchangedUserIds = new HashSet<>(); users.forEach(user -> { - User existingUser = existingByAccount.get(user.getAccount()); - if (existingUser == null && StringUtils.isNotBlank(user.getPhone())) { - existingUser = existingByPhone.get(user.getPhone()); - } + User existingUser = existingByOaLoginId.get(user.getOaLoginId()); if (existingUser != null) { user.setId(existingUser.getId()); user.setPassword(null); existsUserIds.add(existingUser.getId()); - if (shouldSkipOaUpdate(existingUser, user)) { - unchangedUserIds.add(existingUser.getId()); - } } else { user.setId(DefaultIdentifierGenerator.getInstance().nextId(null)); user.setPostId("-1"); @@ -474,9 +441,9 @@ public class OaUserListSyncHelper { user.setDataScopeRange(1); user.setDataLevelRange(1); user.setIncludeNewCustomer(0); - userService.encryptPhone(user); } - userMap.put(user.getAccount(), user.getId()); + userService.encryptPhone(user); + userMap.put(user.getOaLoginId(), user.getId()); }); List addUsers = users.stream() @@ -488,18 +455,13 @@ public class OaUserListSyncHelper { } List updateUsers = users.stream() .filter(user -> existsUserIds.contains(user.getId())) - .filter(user -> !unchangedUserIds.contains(user.getId())) .toList(); if (CollectionUtil.isNotEmpty(updateUsers)) { ComposeLogUtil.getLastLog().info("批量修改用户:{}", updateUsers.size()); userService.updateBatchById(updateUsers); } - if (!unchangedUserIds.isEmpty()) { - ComposeLogUtil.getLastLog().info("跳过未变更用户:{}", unchangedUserIds.size()); - } - ComposeLogUtil.getLastLog().info("缺少账号已跳过的人员:{}", skippedCount); + ComposeLogUtil.getLastLog().info("缺少OA登录名已跳过的人员:{}", skippedCount); int syncedCount = addUsers.size() + updateUsers.size(); - skippedCount += unchangedUserIds.size(); if (userMap.isEmpty()) { return new PersonSyncCount(0, skippedCount); } @@ -511,11 +473,8 @@ public class OaUserListSyncHelper { Set existsUserDeptIds = new HashSet<>(userDeptMap.values()); List userDeptList = oaPersons.stream() .filter(oaPerson -> { - String account = userConvert.resolveAccount(oaPerson); - if (!userMap.containsKey(account)) { - return false; - } - return !unchangedUserIds.contains(userMap.get(account)); + String oaLoginId = resolveOaLoginId(oaPerson); + return StringUtils.isNotBlank(oaLoginId) && userMap.containsKey(oaLoginId); }) .map(oaPerson -> this.buildUserDept(oaPerson, userMap, orgIndex)) .filter(Objects::nonNull) @@ -554,7 +513,6 @@ public class OaUserListSyncHelper { .collect(Collectors.toSet()); String defaultRoleId = getDefaultRoleId(); List updateUserParams = list.stream() - .filter(userDeptIds -> !unchangedUserIds.contains(userDeptIds.getUserId())) .filter(userDeptIds -> { if (StringUtils.isBlank(userDeptIds.getDeptIds())) { return false; @@ -586,18 +544,51 @@ public class OaUserListSyncHelper { } /** - * oaModified 一致则跳过更新;任一侧为空无法确认一致,执行更新以补齐字段 + * 按 OA 登录名加载已有用户。同一登录名有多条时保留较早的一条,后续同步都更新这一条。 */ - private boolean shouldSkipOaUpdate(User existing, User incoming) { - if (existing == null) { - return false; + private Map loadUsersByOaLoginId(String tenantId, Collection oaLoginIds) { + Map existingByOaLoginId = new HashMap<>(); + if (CollectionUtil.isEmpty(oaLoginIds)) { + return existingByOaLoginId; } - String localModified = StringUtils.trimToEmpty(existing.getOaModified()); - String remoteModified = StringUtils.trimToEmpty(incoming == null ? null : incoming.getOaModified()); - if (localModified.isEmpty() || remoteModified.isEmpty()) { - return false; + List oaLoginIdList = oaLoginIds.stream() + .filter(StringUtils::isNotBlank) + .distinct() + .toList(); + int batchSize = 500; + for (int fromIndex = 0; fromIndex < oaLoginIdList.size(); fromIndex += batchSize) { + List oaLoginIdBatch = oaLoginIdList.subList(fromIndex, Math.min(fromIndex + batchSize, oaLoginIdList.size())); + userService.list(Wrappers.lambdaQuery() + .eq(User::getTenantId, tenantId) + .in(User::getOaLoginId, oaLoginIdBatch)) + .forEach(user -> { + String oaLoginId = StringUtils.trimToNull(user.getOaLoginId()); + if (oaLoginId == null) { + return; + } + User previous = existingByOaLoginId.get(oaLoginId); + if (previous == null) { + existingByOaLoginId.put(oaLoginId, user); + return; + } + if (user.getId() != null && previous.getId() != null && user.getId() < previous.getId()) { + ComposeLogUtil.getLastLog().warn("OA登录名重复,保留较早用户 oaLoginId={} keepUserId={} dropUserId={}", + oaLoginId, user.getId(), previous.getId()); + existingByOaLoginId.put(oaLoginId, user); + return; + } + ComposeLogUtil.getLastLog().warn("OA登录名重复,保留较早用户 oaLoginId={} keepUserId={} dropUserId={}", + oaLoginId, previous.getId(), user.getId()); + }); } - return localModified.equals(remoteModified); + return existingByOaLoginId; + } + + private String resolveOaLoginId(OAPersonResponse oaPerson) { + if (oaPerson == null) { + return null; + } + return StringUtils.trimToNull(oaPerson.getLoginid()); } private boolean isPersonBypassCode(String deptCode, DeptCategory deptCategory) { @@ -638,8 +629,8 @@ public class OaUserListSyncHelper { Dept department = orgIndex.findDept(oaPerson); if (department == null || department.getId() == null) { ComposeLogUtil.getLastLog().warn( - "人员未匹配到已有部门,请先同步组织架构 account={} departmentid={} departmentname={} subcompanyid1={}", - userConvert.resolveAccount(oaPerson), oaPerson.getDepartmentid(), oaPerson.getDepartmentname(), + "人员未匹配到已有部门,请先同步组织架构 oaLoginId={} departmentid={} departmentname={} subcompanyid1={}", + resolveOaLoginId(oaPerson), oaPerson.getDepartmentid(), oaPerson.getDepartmentname(), oaPerson.getSubcompanyid1()); return null; } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractManageServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractManageServiceImpl.java index 1ec56fd9..9d36d811 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractManageServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractManageServiceImpl.java @@ -298,6 +298,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl beforeData = buildContractChangeSnapshot(source); + applyContractNo(source, request.getContractNo(), false); source.setContractName(request.getContractName()); source.setPartyB(request.getPartyB()); source.setStartDate(request.getStartDate()); source.setEndDate(request.getEndDate()); source.setContractFormat(request.getContractFormat()); source.setSettlementMode(request.getSettlementMode()); source.setLegalSealFlag(request.getLegalSealFlag()); source.setCopyCount(request.getCopyCount()); @@ -507,8 +508,7 @@ public class ContractManageServiceImpl extends BaseServiceImpllambdaQuery() + .eq(ContractManage::getIsDeleted, 0) + .eq(ContractManage::getContractNo, normalizedContractNo) + .ne(Func.isNotEmpty(contractManage.getId()), ContractManage::getId, contractManage.getId())); + if (duplicatedCount != null && duplicatedCount > 0) { + throw new ServiceException("合同编号已存在"); + } + contractManage.setContractNo(normalizedContractNo); + } + @Override public synchronized String nextContractNo(Long organizationId) { if (Func.isEmpty(organizationId)) { @@ -558,7 +580,8 @@ public class ContractManageServiceImpl extends BaseServiceImpl 999) { + throw new ServiceException("组织机构中配置的合同初始流水号不能超过999"); + } + return serial; + } + private void refreshArchiveStatusOnApprove(ContractManage contractManage) { if (hasSealArchiveFile(contractManage.getContractFileJson())) { contractManage.setArchiveStatus(ARCHIVE_STATUS_ARCHIVED); @@ -1015,6 +1049,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl buildContractChangeSnapshot(ContractManage contractManage) { Map data = new LinkedHashMap<>(); + data.put("contractNo", contractManage.getContractNo()); data.put("contractName", contractManage.getContractName()); data.put("partyB", contractManage.getPartyB()); data.put("startDate", contractManage.getStartDate()); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java index 534f043d..ae825828 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java @@ -58,6 +58,7 @@ import org.springblade.transport.pojo.vo.DriverEnrouteRecordVO; import org.springblade.transport.pojo.vo.DriverNodePunchVO; import org.springblade.transport.pojo.vo.DriverPunchNodeVO; import org.springblade.transport.pojo.vo.DriverPunchPhotoVO; +import org.springblade.transport.pojo.vo.CargoLineVO; import org.springblade.transport.pojo.vo.DriverWaybillCardVO; import org.springblade.transport.pojo.vo.DriverWaybillPreviewVO; import org.springblade.transport.pojo.vo.DriverWaybillTabCountsVO; @@ -411,17 +412,21 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } Driver driver = requireCurrentDriver(); // 在途打卡单独保留原取单判据,不排除已拒单(见 scopedQuery 的重载说明) - Waybill waybill = loadDriverWaybill(dto.getWaybillId(), true); + // 加行锁:重复打卡是先查后插,并发请求读不到对方未提交的流水,不锁会各插一条 + Waybill waybill = loadDriverWaybill(dto.getWaybillId(), true, true); waybill = normalizeAcceptStatus(waybill); + assertPunchNotEnded(waybill.getBusinessStatus()); + assertPunchStarted(waybill.getBusinessStatus()); String processJson = resolveProcessJson(waybill); - Date lastPunchAt = findLastPunchTime(waybill.getId()); + Date lastPunchAt = findLastPunchTime(waybill.getId(), true); WaybillProcessSupport.TransitCheckinDecision decision = WaybillProcessSupport.evaluateTransitCheckin( processJson, waybill.getBusinessStatus(), lastPunchAt, LocalDateTime.now()); if (!decision.punchEnabled()) { throw new ServiceException("该运单未启用在途打卡"); } - if (decision.doneToday()) { + // 重复校验按「自然日是否打过」独立判:decision.doneToday() 只在进行中分支计算 + if (WaybillProcessSupport.isPunchedOn(lastPunchAt, null)) { throw new ServiceException("今日已完成在途打卡,不可重复打卡"); } // 在途也是过程节点之一,占过程配置顺序里的位置,同样受异常门禁约束 @@ -462,6 +467,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { Driver driver = requireCurrentDriver(); Waybill waybill = loadDriverWaybill(dto.getWaybillId()); waybill = normalizeAcceptStatus(waybill); + assertPunchNotEnded(waybill.getBusinessStatus()); + assertPunchStarted(waybill.getBusinessStatus()); String processJson = resolveProcessJson(waybill); Map nodeCfg = findPunchNodeConfig(processJson, nodeCode); @@ -785,6 +792,15 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { /** @param includeRejected 仅 submitEnroute 传 true,见 scopedQuery 的重载说明 */ private Waybill loadDriverWaybill(Long id, boolean includeRejected) { + return loadDriverWaybill(id, includeRejected, false); + } + + /** + * @param forUpdate 加行锁,仅 submitEnroute 使用:「同一天是否打过」是查完再插, + * 不加锁时并发的两个请求都读不到对方未提交的流水,会各插一条。 + * 锁的是运单行,同一运单的打卡提交因此串行。 + */ + private Waybill loadDriverWaybill(Long id, boolean includeRejected, boolean forUpdate) { if (id == null) { throw new ServiceException("运单ID不能为空"); } @@ -792,8 +808,10 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { if (Func.isEmpty(phone)) { throw new ServiceException("当前用户手机号为空,无法查找运单"); } + // MySQL 的 FOR UPDATE 必须跟在 LIMIT 之后 + String tail = forUpdate ? "LIMIT 1 FOR UPDATE" : "LIMIT 1"; Waybill waybill = waybillService.getOne(scopedQuery(phone, includeRejected) - .eq(Waybill::getId, id).last("LIMIT 1")); + .eq(Waybill::getId, id).last(tail)); if (waybill == null) { throw new ServiceException("运单不存在或无权操作"); } @@ -970,7 +988,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { return toCard(waybill, false); } - private DriverWaybillCardVO toCard(Waybill waybill, boolean withEnrouteRecords) { + private DriverWaybillCardVO toCard(Waybill waybill, boolean withDetail) { DriverWaybillCardVO card = new DriverWaybillCardVO(); card.setId(waybill.getId()); card.setWaybillNo(waybill.getWaybillNo()); @@ -989,7 +1007,9 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { .filter(Func::isNotEmpty) .distinct() .collect(Collectors.joining("、"))); - card.setWeight(formatCargoWeight(cargos, formatWeight(waybill.getQuantity(), waybill.getQuantityUnit()))); + // 计重合计:全部货物同单位时就是该单位合计,单位不一致时只有吨项参与(口径见 WaybillCargoSupport#weighing) + WaybillCargoSupport.Quantity weighing = WaybillCargoSupport.weighing(cargos); + card.setWeight(weighing == null ? "" : formatWeight(weighing.value(), weighing.unit())); card.setStatus(toAppStatus(waybill.getBusinessStatus())); String createTime = formatDateTime(waybill.getCreateTime()); card.setCreateTime(createTime); @@ -1013,13 +1033,14 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { card.setUnloadAddress(card.getToAddress()); card.setCargoQuantity(weightText); card.setTotalWeight(weightText); + card.setCargos(withDetail ? cargoLines(cargos) : null); card.setTransportType(toTransportTypeLabel(waybill.getTransportType())); card.setTransportOrgType(Func.isNotBlank(waybill.getLoadingNo()) ? TRANSPORT_LOAD : TRANSPORT_COMMON); card.setPlanShipTime(formatLocalDateYmd(waybill.getEstimatedStartTime())); card.setPlanFinishTime(formatLocalDateYmd(waybill.getEstimatedEndTime())); card.setRemark(Func.toStr(waybill.getRemark(), "")); - if (withEnrouteRecords) { + if (withDetail) { Date lastPunchAt = findLastPunchTime(waybill.getId()); WaybillProcessSupport.TransitCheckinDecision transit = WaybillProcessSupport.evaluateTransitCheckin( processJson, waybill.getBusinessStatus(), lastPunchAt, LocalDateTime.now()); @@ -1141,17 +1162,18 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } card.setCargoCategory(String.join("、", cargoTypes)); card.setCargoSummary(Func.toStr(trip.getCargoSummary(), "")); - // 合计重量只在全部货物同单位时才有值;单位不一致时留空,卡片上已经由货物汇总逐条列出, - // 拿汇总串冒充重量会让「货名 + 重量」两栏把同一段文字打印两遍 - String weight = trip.getTotalQuantity() == null + // 计重合计:同单位时就是该单位合计,单位不一致时只有吨项参与(见 TripQueryServiceImpl#fillWeighing); + // 都没有就留空,卡片上只剩货名,不拿汇总串冒充重量 + String weight = trip.getWeighingQuantity() == null ? "" - : formatWeight(trip.getTotalQuantity(), trip.getQuantityUnit()); + : formatWeight(trip.getWeighingQuantity(), trip.getWeighingUnit()); card.setWeight(weight); card.setCargoQuantity(weight); card.setTotalWeight(weight); if (withDetail) { card.setStations(trip.getStations()); card.setRoutePoints(toTripRoutePoints(trip.getStations())); + card.setCargos(trip.getCargos()); } } @@ -1324,6 +1346,32 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { .orElse(null); } + /** + * 断言运单未结束。 + *

+ * 运单已完成/已取消后不再接受司机打卡:在途与节点都拦,且先于其它门禁。 + * 结束不可逆,没有解除途径——处理完异常、等到第二天都还是打不了。 + */ + private void assertPunchNotEnded(String businessStatus) { + String reason = WaybillProcessSupport.punchEndedReason(businessStatus); + if (reason != null) { + throw new ServiceException(reason); + } + } + + /** + * 断言运单已开始运输。 + *

+ * 只有进行中的运单能打:草稿、待执行(待接单)、待调度、调度中都还没上路。 + * 与「运单已结束」分开判:结束没有解除途径,未开始会随接单/调度自行变为进行中。 + */ + private void assertPunchStarted(String businessStatus) { + String reason = WaybillProcessSupport.punchNotStartedReason(businessStatus); + if (reason != null) { + throw new ServiceException(reason); + } + } + /** * 断言指定节点未被异常门禁阻断。 *

@@ -1518,6 +1566,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { latestEnroute = enroutes.get(enroutes.size() - 1); } Map latestNodePunchByCode = latestNodePunchMap(waybill.getId()); + // 未开始运输的运单在途卡仍下发,但不可提交:司机要看得到这张卡,才知道为什么不能打 + String notStartedReason = WaybillProcessSupport.punchNotStartedReason(waybill.getBusinessStatus()); for (Map cfg : punchConfigs) { boolean isTransit = WaybillProcessSupport.isTransitNodePublic(cfg); @@ -1545,7 +1595,10 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { vo.setCargoTypes(Collections.emptyList()); vo.setVisible(true); vo.setDone(done); - vo.setActionable(!done); + vo.setActionable(!done && notStartedReason == null); + if (notStartedReason != null) { + vo.setLockReason(notStartedReason); + } if (done && latestEnroute != null) { vo.setCheckinTime(latestEnroute.getTime()); vo.setCheckinPlace(latestEnroute.getAddress()); @@ -1566,7 +1619,10 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { boolean done = punched != null; vo.setVisible(true); vo.setDone(done); - vo.setActionable(!done); + vo.setActionable(!done && notStartedReason == null); + if (notStartedReason != null && !done) { + vo.setLockReason(notStartedReason); + } // 末位卡上打一下就完成这张运单:判据与下方 submitNode 的自动完成同一份 // (末位可打卡节点 + 到货/卸货/签收 + 回单不要求业务员确认),客户端只按它把按钮文案改成「完成运单」。 // 两处判据必须同源,否则会出现「按钮写着完成运单、打下去这张运单没完成」。 @@ -1601,6 +1657,11 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { if (Boolean.TRUE.equals(vo.getDone()) || !blockedKeys.contains(vo.getKey())) { continue; } + // 未开始运输是更根本的拒绝:运单状态不变,处理完异常也打不了。 + // 这里不能再改写成异常文案,否则会暗示「处理完就能打」。 + if (notStartedReason != null) { + continue; + } vo.setBlocked(true); vo.setActionable(false); if (blocker != null) { @@ -1633,11 +1694,32 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { + WaybillProcessSupport.firstUnfinishedPrerequisiteName(processJson, unfinished) + "」打卡"); } + // 已结束的运单不再接受打卡:卡片留着给司机回看,未打卡的节点一律不可提交。 + // 文案压过异常与顺序门禁——结束没有解除途径,说「处理完就能打」是错的。 + // 已打卡的节点只置 ended:它们要继续可展开回看照片与时间,原因文案也不再适用。 + String endedReason = WaybillProcessSupport.punchEndedReason(waybill.getBusinessStatus()); + if (endedReason != null) { + for (DriverPunchNodeVO vo : nodes) { + vo.setEnded(true); + if (!Boolean.TRUE.equals(vo.getDone())) { + vo.setActionable(false); + vo.setLockReason(endedReason); + } + } + } + // 是否允许展开:顺序门禁不允许展开(还没轮到,展开也没用); // 异常门禁允许展开——那里出过事,司机需要看到回填内容与「异常详情」。 + // 已结束的运单:在途卡一律不可展开(结束之后没有在途打卡可做,展开也无内容); + // 未打卡的节点展开同样一无所有;已打卡的普通节点保留展开,供司机回看时间与照片。 for (DriverPunchNodeVO vo : nodes) { boolean blockedByOrder = Boolean.TRUE.equals(vo.getBlockedByOrder()); - vo.setExpandable(!blockedByOrder); + boolean ended = Boolean.TRUE.equals(vo.getEnded()); + boolean done = Boolean.TRUE.equals(vo.getDone()); + boolean transitCard = Boolean.TRUE.equals(vo.getTransit()); + // 未开始运输的未打卡卡片:打不了也展不开——展开是空表单,状态由锁文案说明 + boolean notStartedLocked = !done && notStartedReason != null; + vo.setExpandable(!blockedByOrder && !notStartedLocked && (!ended || (done && !transitCard))); } // 「异常」标签:只挂**存在未处理完成异常**的节点(与异常门禁同一口径,结案即消失)。 @@ -1670,6 +1752,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { for (DriverPunchNodeVO vo : nodes) { if (!Boolean.TRUE.equals(vo.getDone()) + && !Boolean.TRUE.equals(vo.getEnded()) && !Boolean.TRUE.equals(vo.getBlocked()) && !Boolean.TRUE.equals(vo.getBlockedByOrder())) { vo.setDefaultExpanded(true); @@ -1870,13 +1953,21 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } private Date findLastPunchTime(Long waybillId) { + return findLastPunchTime(waybillId, false); + } + + /** + * @param forUpdate 仅 submitEnroute:与运单行锁配对。不加锁读走的是事务快照, + * 并发提交的第一笔已提交后,第二笔可能仍读到旧快照而看不到它。 + */ + private Date findLastPunchTime(Long waybillId, boolean forUpdate) { if (waybillId == null) { return null; } WaybillEnroutePunch latest = enroutePunchMapper.selectOne(Wrappers.lambdaQuery() .eq(WaybillEnroutePunch::getWaybillId, waybillId) .orderByDesc(WaybillEnroutePunch::getPunchTime) - .last("LIMIT 1")); + .last(forUpdate ? "LIMIT 1 FOR UPDATE" : "LIMIT 1")); return latest == null ? null : latest.getPunchTime(); } @@ -1928,31 +2019,17 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } /** - * 重量文案:全部货物同单位时按货物行相加,单位不一致时不出合计 - *

- * 主表 quantity 是各行数量的合计、quantity_unit 只是首行的单位,「20堆 + 50方」会变成「70堆」。 - * 拿 goodsJson 算不出合计(有货物没填数量或单位)时,退回主表那份汇总值——宁可显示一个来源不明的数, - * 也不要让卡片上的重量凭空消失。 + * 货物明细行:一条货物一行,数量带单位 */ - private String formatCargoWeight(List cargos, String fallback) { - if (cargos.isEmpty()) { - return fallback; + private List cargoLines(List cargos) { + List lines = new ArrayList<>(); + for (WaybillCargoSupport.Line line : WaybillCargoSupport.lines(cargos)) { + CargoLineVO vo = new CargoLineVO(); + vo.setCargoName(line.name()); + vo.setQuantity(line.quantity() == null ? "" : formatWeight(line.quantity(), line.unit())); + lines.add(vo); } - String unit = null; - BigDecimal total = BigDecimal.ZERO; - for (WaybillCargoSupport.Cargo cargo : cargos) { - String cargoUnit = Func.toStr(cargo.quantityUnit(), ""); - if (cargo.quantity() == null || Func.isEmpty(cargoUnit)) { - return fallback; - } - if (unit == null) { - unit = cargoUnit; - } else if (!unit.equals(cargoUnit)) { - return ""; - } - total = total.add(cargo.quantity()); - } - return formatWeight(total, unit); + return lines; } private String formatWeight(BigDecimal quantity, String unit) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java index d86e008c..575ce831 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java @@ -144,6 +144,17 @@ public class ExceptionDisposalServiceImpl // 整单异常同样受这条约束:没有节点不等于没有上报人。 driverWaybillService.assertCurrentDriverOwnsWaybill(disposal.getWaybillId()); + // 上报门禁之零:运单得先在路上,且不能已经结束。 + // 判据与司机打卡同一来源;归属校验会顺手把「无需接单」的待执行运单校正为进行中, + // 所以状态要在它之后重新读,不能用 fillFromWaybill 里校正前的那份对象。 + Waybill reportWaybill = waybillService.getById(disposal.getWaybillId()); + String blockedReason = reportWaybill == null + ? null + : WaybillProcessSupport.reportBlockedReason(reportWaybill.getBusinessStatus()); + if (blockedReason != null) { + throw new ServiceException(blockedReason); + } + // 节点可以为空:全局入口(首页快捷格、打卡页底部按钮)上报的异常不指定环节, // 它无从定位阻断区间,由门禁按「阻断该运单全部未打卡节点」处理。 // 节点非空时才要求它确属该运单;节点名取自过程配置而非请求体,避免落进一个该节点并不叫的名字。 diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripQueryServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripQueryServiceImpl.java index 8a831a4e..3a3de257 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripQueryServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripQueryServiceImpl.java @@ -31,6 +31,7 @@ import org.springblade.core.log.exception.ServiceException; import org.springblade.core.tool.utils.Func; import org.springblade.transport.pojo.entity.LoadingManage; import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.vo.CargoLineVO; import org.springblade.transport.pojo.vo.TripQueryVO; import org.springblade.transport.pojo.vo.TripStationVO; import org.springblade.transport.pojo.vo.TripTabCountsVO; @@ -610,7 +611,9 @@ public class TripQueryServiceImpl implements ITripQueryService { trip.setCargoType(String.join("、", cargoTypes)); } trip.setCargoSummary(cargoSummary(children)); + trip.setCargos(cargoLines(cargos)); fillTotalQuantity(trip, children); + fillWeighing(trip, cargos); } /** @@ -639,6 +642,20 @@ public class TripQueryServiceImpl implements ITripQueryService { return String.join("、", parts); } + /** + * 货物明细行:与 cargoSummary 同一套合并口径,但数量与单位分开留,供详情页逐行展示 + */ + private List cargoLines(List cargos) { + List lines = new ArrayList<>(); + for (WaybillCargoSupport.Line line : WaybillCargoSupport.lines(cargos)) { + CargoLineVO vo = new CargoLineVO(); + vo.setCargoName(line.name()); + vo.setQuantity(line.quantity() == null ? "" : formatQuantity(line.quantity(), line.unit())); + lines.add(vo); + } + return lines; + } + /** * 合计重量:只有全部货物同单位时才能相加,单位不一致时留空(汇总文案里仍分单位列出) *

@@ -671,6 +688,18 @@ public class TripQueryServiceImpl implements ITripQueryService { trip.setQuantityUnit(unit); } + /** + * 计重合计:卡片上那个「货物重量」,口径见 WaybillCargoSupport#weighing + */ + private void fillWeighing(TripVO trip, List cargos) { + WaybillCargoSupport.Quantity weighing = WaybillCargoSupport.weighing(cargos); + if (weighing == null) { + return; + } + trip.setWeighingQuantity(weighing.value()); + trip.setWeighingUnit(weighing.unit()); + } + private String formatQuantity(BigDecimal quantity, String unit) { String value = quantity.stripTrailingZeros().toPlainString(); return Func.isEmpty(unit) ? value : value + unit; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillCargoSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillCargoSupport.java index afe71bc6..c486b558 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillCargoSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillCargoSupport.java @@ -28,8 +28,11 @@ import org.springblade.transport.pojo.entity.Waybill; import java.math.BigDecimal; import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; /** * 运单货物明细的取法,只此一处。 @@ -42,6 +45,9 @@ import java.util.Map; */ public final class WaybillCargoSupport { + /** 计重时唯一允许跨货物相加的单位:其他单位只在「全部货物同单位」时才合计 */ + private static final String UNIT_TON = "吨"; + /** 货物没有填名称时的展示名:宁可让司机看到「货物」,也不要出现空行 */ private static final String FALLBACK_NAME = "货物"; @@ -146,6 +152,83 @@ public final class WaybillCargoSupport { return cargos; } + /** + * 计重合计:卡片上那个「货物重量」 + *

+ * 全部货物同单位时就是该单位合计;单位不一致时只合计「吨」项,其余单位不参与—— + * 「100吨 + 20堆」报 100 吨,「20堆 + 50方」没有吨项就不出合计。 + * 有货物缺数量时该单位合计不完整,宁可不报也不报一个偏小的数。 + * + * @return 有可用合计时返回数量与单位,否则 null + */ + public static Quantity weighing(List cargos) { + Map totals = new LinkedHashMap<>(); + Set incomplete = new HashSet<>(); + for (Cargo cargo : cargos) { + String unit = Func.toStr(cargo.quantityUnit(), ""); + if (Func.isEmpty(unit)) { + continue; + } + if (cargo.quantity() == null) { + incomplete.add(unit); + continue; + } + totals.merge(unit, cargo.quantity(), BigDecimal::add); + } + if (totals.isEmpty()) { + return null; + } + String unit; + if (totals.size() == 1) { + unit = totals.keySet().iterator().next(); + } else { + unit = totals.containsKey(UNIT_TON) ? UNIT_TON : null; + } + if (unit == null || incomplete.contains(unit)) { + return null; + } + return new Quantity(totals.get(unit), unit); + } + + /** 计重合计的数量与单位 */ + public record Quantity(BigDecimal value, String unit) { + } + + /** + * 货物明细行:一条货物一行,同名同单位的合并数量,按录入顺序 + *

+ * 与 {@link #cargoSummary(List)} 的合并口径一致,但数量与单位保持分开,供详情页逐行展示。 + * 数量为空的货物只留名称,不按 0 参与合并。 + */ + public static List lines(List cargos) { + Map index = new LinkedHashMap<>(); + List lines = new ArrayList<>(); + for (Cargo cargo : cargos) { + String name = cargo.displayName(); + String unit = Func.toStr(cargo.quantityUnit(), ""); + String key = name + '\u0000' + unit; + Integer at = index.get(key); + if (at == null) { + index.put(key, lines.size()); + lines.add(new Line(name, cargo.quantity(), unit)); + continue; + } + Line merged = lines.get(at); + if (cargo.quantity() == null) { + continue; + } + BigDecimal quantity = merged.quantity() == null + ? cargo.quantity() + : merged.quantity().add(cargo.quantity()); + lines.set(at, new Line(name, quantity, unit)); + } + return lines; + } + + /** 一条货物明细:数量可能为空(录入时只填了名称) */ + public record Line(String name, BigDecimal quantity, String unit) { + } + /** * 主表货名按顿号、逗号摊平;空格与空段去掉,全空时给空集合 */ diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java index 1b273c0b..4462c3a3 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java @@ -133,6 +133,81 @@ public final class WaybillProcessSupport { return ACCEPT_REJECTED.equalsIgnoreCase(stringVal(driverAcceptStatus)); } + /** + * 运单已结束:已完成或已取消。此后不再接受司机打卡。 + *

+ * 只认这两个状态,大小写不敏感。草稿、待执行、待调度、调度中的运单司机仍要能打卡, + * 所以不能按 {@link #isTerminalBusinessStatus} 的「终态或调度中间态」口径判定。 + */ + public static boolean isPunchEnded(String businessStatus) { + String status = stringVal(businessStatus); + return "completed".equalsIgnoreCase(status) || "cancelled".equalsIgnoreCase(status); + } + + /** + * 结束态下的打卡拒绝文案;非结束态返回 null。 + */ + public static String punchEndedReason(String businessStatus) { + String status = stringVal(businessStatus); + if ("completed".equalsIgnoreCase(status)) { + return "运单已完成"; + } + if ("cancelled".equalsIgnoreCase(status)) { + return "运单已取消"; + } + return null; + } + + /** + * 运单已开始运输:只有进行中可以打卡(在途与普通节点同一判据)。 + *

+ * 草稿、待执行(待接单)、待调度、调度中都还没上路,任何打卡都没有承载物。 + * 判据是校正后落库的状态:无需接单的待执行运单在 {@link #normalizeBusinessStatus} + * 里已被提升为进行中,未提升的待执行就是真的还没接单。 + */ + public static boolean isTransitPunchStarted(String businessStatus) { + return STATUS_RUNNING.equalsIgnoreCase(stringVal(businessStatus)); + } + + /** + * 未开始运输时的打卡拒绝文案;进行中与已结束返回 null(结束文案由 {@link #punchEndedReason} 给)。 + */ + public static String punchNotStartedReason(String businessStatus) { + if (isTransitPunchStarted(businessStatus) || isPunchEnded(businessStatus)) { + return null; + } + return "运单尚未开始运输,暂不可打卡"; + } + + /** + * 指定自然日是否已打过在途卡。 + *

+ * 与运单状态无关,只回答「这一天打过没有」。{@link TransitCheckinDecision#doneToday()} + * 只在进行中分支计算,状态一非进行中就回到 false,两者不可互相替代。 + */ + public static boolean isPunchedOn(Date lastPunchAt, LocalDateTime now) { + LocalDate day = (now == null ? LocalDateTime.now() : now).toLocalDate(); + LocalDate last = toLocalDate(lastPunchAt); + return last != null && last.equals(day); + } + + /** + * 上报异常前的运单状态门禁原因;可上报时返回 null。 + *

+ * 与打卡共用「已开始运输」判据,但文案说的是能不能报,不是能不能打。 + * 结束优先于未开始:已完成/已取消的运单先说结束,不再提「等你上路」。 + */ + public static String reportBlockedReason(String businessStatus) { + String endedReason = punchEndedReason(businessStatus); + if (endedReason != null) { + return endedReason + ",不能上报异常"; + } + if (!isTransitPunchStarted(businessStatus)) { + return "运单尚未开始运输,暂不可上报异常"; + } + return null; + } + public static boolean isTerminalBusinessStatus(String businessStatus) { return "draft".equals(businessStatus) || "completed".equals(businessStatus) @@ -198,15 +273,20 @@ public final class WaybillProcessSupport { int frequencyDays = parsePositiveInt(transit.get("frequencyDays"), 1); String timeStart = normalizeHm(stringVal(transit.get("timeStart")), "00:00"); String timeEnd = normalizeHm(stringVal(transit.get("timeEnd")), "23:59"); - // punch=是即展示卡片;非进行中仅不可作为「今日待打」 - if (!STATUS_RUNNING.equals(businessStatus)) { - return new TransitCheckinDecision(true, true, false, false, frequencyDays, timeStart, timeEnd); + // 结束不可逆,没有解除途径:卡片仍下发,但不可提交,也不算今日待打 + if (isPunchEnded(businessStatus)) { + return new TransitCheckinDecision(false, true, false, false, frequencyDays, timeStart, timeEnd); + } + // punch=是即展示卡片;未开始运输(草稿/待执行/待调度/调度中)的卡片仍下发, + // 但不可提交——打卡的前提是运单已上路(判据同时用于节点打卡)。 + if (!isTransitPunchStarted(businessStatus)) { + return new TransitCheckinDecision(false, true, false, false, frequencyDays, timeStart, timeEnd); } LocalDateTime current = now == null ? LocalDateTime.now() : now; LocalDate today = current.toLocalDate(); LocalDate lastDate = toLocalDate(lastPunchAt); - boolean doneToday = lastDate != null && lastDate.equals(today); + boolean doneToday = isPunchedOn(lastPunchAt, current); boolean dueByFrequency; if (lastDate == null) { dueByFrequency = true; diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java index 67c127fb..2b35117f 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java @@ -184,6 +184,45 @@ class DriverPunchNodeCardTest { assertTrue(json.contains("\"9007199254740993\""), json); } + @Test + @DisplayName("运单已完成:在途卡不可展开,已打卡的普通节点仍可展开回看") + void completedWaybillTransitNodeIsNotExpandable() { + Waybill completed = waybill(); + completed.setBusinessStatus("completed"); + when(waybillService.getById(WAYBILL_ID)).thenReturn(completed); + + Map nodes = punchNodes(); + + DriverPunchNodeVO transit = nodes.get("transit"); + assertTrue(Boolean.TRUE.equals(transit.getEnded()), "已完成运单的在途卡要进结束态"); + assertFalse(Boolean.TRUE.equals(transit.getExpandable()), "已完成运单的在途卡不允许展开"); + assertFalse(Boolean.TRUE.equals(transit.getActionable())); + assertTrue(Boolean.TRUE.equals(nodes.get("load").getExpandable()), + "已打卡的普通节点仍可展开回看时间与照片"); + assertFalse(Boolean.TRUE.equals(nodes.get("sign").getExpandable()), + "未打卡节点展开没有内容,也不可展开"); + } + + @Test + @DisplayName("运单未开始运输:未打卡的在途与普通节点都不可打、不可展开,文案说明是状态原因") + void notStartedWaybillLocksEveryUnpunchedCard() { + Waybill dispatching = waybill(); + dispatching.setBusinessStatus("dispatching"); + when(waybillService.getById(WAYBILL_ID)).thenReturn(dispatching); + + Map nodes = punchNodes(); + + assertTrue(Boolean.TRUE.equals(nodes.get("load").getExpandable()), + "已打卡的装货仍可展开回看,未开始运输不影响历史记录"); + for (String key : List.of("sign", "transit")) { + DriverPunchNodeVO node = nodes.get(key); + assertFalse(Boolean.TRUE.equals(node.getDone()), key + " 未打卡"); + assertFalse(Boolean.TRUE.equals(node.getActionable()), key + " 未开始运输不可打"); + assertEquals("运单尚未开始运输,暂不可打卡", node.getLockReason(), key); + assertFalse(Boolean.TRUE.equals(node.getExpandable()), key + " 不可展开"); + } + } + // ------------------------------------------------------------ 夹具 private void stubUnresolved(ExceptionDisposal... disposals) { diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripCardTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripCardTest.java index 966ac370..7bdcbba1 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripCardTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripCardTest.java @@ -383,6 +383,8 @@ class DriverTripCardTest { trip.setCargoSummary("石英砂A 131.84吨、技嘉4070显卡 30吨"); trip.setTotalQuantity(new BigDecimal("161.84")); trip.setQuantityUnit("吨"); + trip.setWeighingQuantity(new BigDecimal("161.84")); + trip.setWeighingUnit("吨"); trip.setDriverName("黄宇富"); trip.setDriverPhone(PHONE); trip.setVehicleNo("桂A32523"); @@ -410,6 +412,8 @@ class DriverTripCardTest { trip.setCargoSummary("石灰石 30吨"); trip.setTotalQuantity(new BigDecimal("30")); trip.setQuantityUnit("吨"); + trip.setWeighingQuantity(new BigDecimal("30")); + trip.setWeighingUnit("吨"); trip.setDriverName("黄宇富"); trip.setDriverPhone(PHONE); trip.setVehicleNo("桂A32523"); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java index e83ef7ba..f2eeac76 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java @@ -8,6 +8,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; +import org.springblade.core.log.exception.ServiceException; import org.springblade.core.secure.utils.AuthUtil; import org.springblade.system.feign.IMpUserClient; import org.springblade.transport.mapper.ExceptionDisposalMapper; @@ -29,7 +30,9 @@ import org.springblade.transport.support.CertificationGate; import java.util.List; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; @@ -167,28 +170,29 @@ class DriverWaybillAutoCompleteTest { // ------------------------------------------------------------ 前置:运单本身能否完成 @Test - @DisplayName("业务状态为 draft 时只打卡不完成,打卡照常成功") - void draftWaybillIsPunchedButNotCompleted() { + @DisplayName("草稿运单未开始运输:打卡被拒,不落流水也不自动完成") + void draftWaybillCannotBePunched() { givenWaybill(SIGN_LAST_JSON, "draft"); - DriverNodePunchVO vo = submit("sign"); + ServiceException exception = assertThrows(ServiceException.class, () -> submit("sign")); + assertEquals("运单尚未开始运输,暂不可打卡", exception.getMessage()); + verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class)); verify(waybillService, never()).completeWithoutDeptCheck(any()); - verify(nodePunchMapper).insert(any(WaybillNodePunch.class)); - assertFalse(vo.getWaybillCompleted()); } @Test - @DisplayName("待执行与调度中间态同样允许自动完成") - void dispatchableStatusesStillComplete() { + @DisplayName("待执行、待调度、调度中都未开始运输:节点打卡一律被拒") + void dispatchableStatusesCannotPunch() { for (String status : List.of("pending", "waiting_dispatch", "dispatching")) { givenWaybill(SIGN_LAST_JSON, status); - when(waybillService.completeWithoutDeptCheck(WAYBILL_ID)).thenReturn(true); - DriverNodePunchVO vo = submit("sign"); + ServiceException exception = assertThrows(ServiceException.class, () -> submit("sign"), status); - assertTrue(vo.getWaybillCompleted(), status + " 属于可完成状态"); + assertEquals("运单尚未开始运输,暂不可打卡", exception.getMessage(), status); } + verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class)); + verify(waybillService, never()).completeWithoutDeptCheck(any()); } @Test diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java index 6fb9e1c5..b11451aa 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java @@ -182,7 +182,9 @@ class DriverWaybillRejectDetachTest { void enrouteSubmitKeepsUnfilteredScope() { EnrouteSubmitDTO dto = new EnrouteSubmitDTO(); dto.setWaybillId(REJECTED_ID); - when(waybillService.getOne(any())).thenReturn(rejectedWaybill()); + // 已拒单 + 进行中:历史脏数据,取单不能被拒单条件挡住,收口交给后面的过程配置检查 + when(waybillService.getOne(any())) + .thenReturn(waybill(REJECTED_ID, "YD-20261010-0001", "running", "rejected")); // 断言的是「取单这一步没有被拒单条件拦住」:若被拦住会抛「运单不存在或无权操作」, // 而后面因过程配置为空会走「该运单未启用在途打卡」,两者可区分 diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java index be77a57c..4a64ae2a 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java @@ -245,6 +245,51 @@ class ExceptionDisposalReportGateTest { assertEquals("韦司机", savedDisposal().getReporterName()); } + // ------------------------------------------------------------ 运单状态门禁 + + @Test + @DisplayName("未开始运输的运单不能上报:整单异常同样被拒") + void notStartedWaybillRejectsWaybillLevelReport() { + stubWaybillStatus("waiting_dispatch"); + + ServiceException exception = submitExpectRejected(null); + + assertEquals("运单尚未开始运输,暂不可上报异常", exception.getMessage()); + } + + @Test + @DisplayName("已完成的运单不能再上报") + void completedWaybillRejectsReport() { + stubWaybillStatus("completed"); + + ServiceException exception = submitExpectRejected("load"); + + assertEquals("运单已完成,不能上报异常", exception.getMessage()); + } + + @Test + @DisplayName("已取消的运单不能再上报") + void cancelledWaybillRejectsReport() { + stubWaybillStatus("cancelled"); + + ServiceException exception = submitExpectRejected(null); + + assertEquals("运单已取消,不能上报异常", exception.getMessage()); + } + + @Test + @DisplayName("运单状态门禁在节点门禁之前:未开始运输时先说没上路,不报节点已打卡") + void statusGateBeatsNodeGate() { + stubWaybillStatus("pending"); + stubNode("load", "装货", "cargo"); + when(driverWaybillService.isNodePunched(WAYBILL_ID, "load")).thenReturn(true); + + ServiceException exception = submitExpectRejected("load"); + + assertEquals("运单尚未开始运输,暂不可上报异常", exception.getMessage()); + verify(driverWaybillService, never()).findPunchNode(any(), any()); + } + // ------------------------------------------------------------ 上报人归属 @Test @@ -273,6 +318,13 @@ class ExceptionDisposalReportGateTest { // ------------------------------------------------------------ 夹具 + /** 改写夹具运单的业务状态,供状态门禁用例复写 getById 的桩 */ + private void stubWaybillStatus(String businessStatus) { + Waybill waybill = waybill(); + waybill.setBusinessStatus(businessStatus); + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill); + } + private void stubNode(String key, String name, String type) { when(driverWaybillService.findPunchNode(WAYBILL_ID, key)).thenReturn(node(key, name, type)); when(driverWaybillService.isNodePunched(WAYBILL_ID, key)).thenReturn(false); @@ -358,6 +410,8 @@ class ExceptionDisposalReportGateTest { waybill.setWaybillNo("YD202610070001"); waybill.setVehicleNo("桂AAW3255"); waybill.setIsDeleted(0); + // 状态门禁:夹具默认进行中,状态类用例自己改 + waybill.setBusinessStatus("running"); return waybill; } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripReadServiceBoundaryTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripReadServiceBoundaryTest.java index b4aac8d2..ec37714d 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripReadServiceBoundaryTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripReadServiceBoundaryTest.java @@ -309,6 +309,41 @@ class TripReadServiceBoundaryTest { assertNull(trip.getQuantityUnit()); } + @Test + @DisplayName("计重合计:单位不一致时只合计吨项,堆方不参与;全是吨时就是吨合计") + void weighingOnlyCountsTonsWhenUnitsDiffer() { + Waybill mixed = child(CHILD_A, "running", null, ACCEPT_NO, at(1787657033000L)); + mixed.setCargoName("阿什顿"); + mixed.setQuantity(new BigDecimal("100")); + mixed.setQuantityUnit("吨"); + mixed.setGoodsJson("[{\"cargoName\":\"阿什顿\",\"quantity\":\"100\",\"quantityUnit\":\"吨\"}," + + "{\"cargoName\":\"显卡050\",\"quantity\":\"20\",\"quantityUnit\":\"堆\"}]"); + stubLoadedTrip(mixed); + + TripVO trip = onlyTrip(); + + assertNull(trip.getTotalQuantity(), "单位不一致,合计重量仍然为空"); + assertEquals(0, new BigDecimal("100").compareTo(trip.getWeighingQuantity()), "计重合计只算吨项"); + assertEquals("吨", trip.getWeighingUnit()); + } + + @Test + @DisplayName("计重合计:没有吨项且单位不一致时不出合计,合计重量同样为空") + void weighingEmptyWhenNoTonAndUnitsDiffer() { + Waybill mixed = child(CHILD_A, "running", null, ACCEPT_NO, at(1787657033000L)); + mixed.setCargoName("阿什顿"); + mixed.setQuantity(new BigDecimal("70.00")); + mixed.setQuantityUnit("堆"); + mixed.setGoodsJson("[{\"cargoName\":\"阿什顿\",\"quantity\":\"20\",\"quantityUnit\":\"堆\"}," + + "{\"cargoName\":\"显卡050\",\"quantity\":\"50\",\"quantityUnit\":\"方\"}]"); + stubLoadedTrip(mixed); + + TripVO trip = onlyTrip(); + + assertNull(trip.getWeighingQuantity(), "没有吨项就不出计重合计"); + assertNull(trip.getWeighingUnit()); + } + // ------------------------------------------------------------ 夹具 @SuppressWarnings("unchecked") diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java index 5583f975..b09c82c4 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java @@ -3,14 +3,18 @@ package org.springblade.transport.support; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.Arrays; import java.util.Collections; +import java.util.Date; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -330,4 +334,130 @@ class WaybillProcessSupportTest { "TRANSPORT_JSON 的回单启用且没有 confirmMode:缺字段当没配「无需确认完成」,否则会跳过本该人工确认的回单"); } + // ------------------------------------------------------------ 结束门禁 + + @Test + @DisplayName("结束门禁:只认已完成与已取消,大小写不敏感") + void punchEndedOnlyForCompletedAndCancelled() { + assertTrue(WaybillProcessSupport.isPunchEnded("completed")); + assertTrue(WaybillProcessSupport.isPunchEnded("COMPLETED")); + assertTrue(WaybillProcessSupport.isPunchEnded("cancelled")); + assertTrue(WaybillProcessSupport.isPunchEnded("Cancelled")); + } + + @Test + @DisplayName("结束门禁:草稿、待执行、待调度、调度中都不算结束") + void punchEndedExcludesStatusesThatStillAllowPunching() { + for (String status : List.of("draft", "pending", "waiting_dispatch", "dispatching", "running")) { + assertFalse(WaybillProcessSupport.isPunchEnded(status), status + " 不是结束态"); + assertNull(WaybillProcessSupport.punchEndedReason(status), status + " 不该有结束文案"); + } + assertFalse(WaybillProcessSupport.isPunchEnded(null)); + assertFalse(WaybillProcessSupport.isPunchEnded("")); + assertFalse(WaybillProcessSupport.isPunchEnded(" ")); + } + + @Test + @DisplayName("结束门禁文案:完成与取消各一句,非结束态没有文案") + void punchEndedReasonOnlyForEndedStatuses() { + assertEquals("运单已完成", WaybillProcessSupport.punchEndedReason("completed")); + assertEquals("运单已完成", WaybillProcessSupport.punchEndedReason(" COMPLETED ")); + assertEquals("运单已取消", WaybillProcessSupport.punchEndedReason("cancelled")); + assertNull(WaybillProcessSupport.punchEndedReason(null)); + assertNull(WaybillProcessSupport.punchEndedReason("")); + assertNull(WaybillProcessSupport.punchEndedReason(" ")); + } + + @Test + @DisplayName("在途卡:已完成的运单今天打过卡也不可再打,且不算今日已打") + void transitCardOnEndedWaybillIsVisibleButNotPunchable() { + LocalDateTime now = LocalDateTime.of(2026, 10, 20, 12, 0); + // 打卡时间就落在 now 所在的那一天 + Date lastPunchAt = Date.from(now.toLocalDate().atStartOfDay(ZoneId.systemDefault()).plusHours(8).toInstant()); + + WaybillProcessSupport.TransitCheckinDecision decision = + WaybillProcessSupport.evaluateTransitCheckin(TRANSPORT_JSON, "completed", lastPunchAt, now); + + assertTrue(decision.visible(), "卡片仍要下发,司机能回看今天打过没有"); + assertFalse(decision.punchEnabled(), "运单已结束,不能提交打卡"); + assertFalse(decision.doneToday(), "结束不是「今日已打卡」,两者的后续动作不同"); + assertFalse(decision.dueToday()); + } + + @Test + @DisplayName("在途卡回归:进行中且今天没打卡时仍可打") + void transitCardOnRunningWaybillStaysPunchable() { + LocalDateTime now = LocalDateTime.of(2026, 10, 20, 12, 0); + + WaybillProcessSupport.TransitCheckinDecision decision = + WaybillProcessSupport.evaluateTransitCheckin(TRANSPORT_JSON, "running", null, now); + + assertTrue(decision.visible()); + assertTrue(decision.punchEnabled()); + assertTrue(decision.dueToday(), "frequencyDays 默认为 1、无历史打卡,且 noon 落在默认时段内"); + assertFalse(decision.doneToday()); + } + + // ------------------------------------------------------------ 开始运输门禁 + + @Test + @DisplayName("开始门禁:只有进行中算已开始运输") + void transitPunchStartedOnlyRunning() { + assertTrue(WaybillProcessSupport.isTransitPunchStarted("running")); + assertTrue(WaybillProcessSupport.isTransitPunchStarted(" RUNNING ")); + for (String status : List.of("draft", "pending", "waiting_dispatch", "dispatching", "completed", "cancelled")) { + assertFalse(WaybillProcessSupport.isTransitPunchStarted(status), status + " 还没开始运输"); + } + assertFalse(WaybillProcessSupport.isTransitPunchStarted(null)); + assertFalse(WaybillProcessSupport.isTransitPunchStarted("")); + } + + @Test + @DisplayName("开始门禁文案:未开始一句话,进行中与已结束都交给别的门禁") + void punchNotStartedReasonOnlyForNotStartedStatuses() { + assertEquals("运单尚未开始运输,暂不可打卡", WaybillProcessSupport.punchNotStartedReason("pending")); + assertEquals("运单尚未开始运输,暂不可打卡", WaybillProcessSupport.punchNotStartedReason("dispatching")); + assertNull(WaybillProcessSupport.punchNotStartedReason("running")); + // 结束态由结束文案负责,两句话不能抢 + assertNull(WaybillProcessSupport.punchNotStartedReason("completed")); + assertNull(WaybillProcessSupport.punchNotStartedReason("cancelled")); + } + + @Test + @DisplayName("在途卡:未开始运输的运单仍下发卡片,但不可打卡") + void transitCardOnNotStartedWaybillIsVisibleButNotPunchable() { + LocalDateTime now = LocalDateTime.of(2026, 10, 20, 12, 0); + + WaybillProcessSupport.TransitCheckinDecision decision = + WaybillProcessSupport.evaluateTransitCheckin(TRANSPORT_JSON, "dispatching", null, now); + + assertTrue(decision.visible(), "卡片仍要下发,司机要看得到这张卡为什么不能打"); + assertFalse(decision.punchEnabled(), "未开始运输不能提交打卡"); + assertFalse(decision.doneToday()); + assertFalse(decision.dueToday()); + } + + @Test + @DisplayName("同日判重:只认同一个自然日,跨日与空值都不算") + void isPunchedOnComparesByNaturalDay() { + LocalDateTime now = LocalDateTime.of(2026, 10, 20, 12, 0); + Date today = Date.from(LocalDateTime.of(2026, 10, 20, 8, 0).atZone(ZoneId.systemDefault()).toInstant()); + Date yesterday = Date.from(LocalDateTime.of(2026, 10, 19, 23, 0).atZone(ZoneId.systemDefault()).toInstant()); + + assertTrue(WaybillProcessSupport.isPunchedOn(today, now)); + assertFalse(WaybillProcessSupport.isPunchedOn(yesterday, now)); + assertFalse(WaybillProcessSupport.isPunchedOn(null, now)); + } + + @Test + @DisplayName("上报状态门禁:只有进行中可报,未开始与已结束各有各的话") + void reportBlockedReasonOnlyForRunningWaybill() { + assertNull(WaybillProcessSupport.reportBlockedReason("running")); + assertEquals("运单尚未开始运输,暂不可上报异常", WaybillProcessSupport.reportBlockedReason("pending")); + assertEquals("运单尚未开始运输,暂不可上报异常", WaybillProcessSupport.reportBlockedReason("dispatching")); + // 结束优先:已完成/已取消先说结束,不提「等你上路」 + assertEquals("运单已完成,不能上报异常", WaybillProcessSupport.reportBlockedReason("completed")); + assertEquals("运单已取消,不能上报异常", WaybillProcessSupport.reportBlockedReason("cancelled")); + } + } diff --git a/doc/sql/bladex/bladex.mysql.all.create.sql b/doc/sql/bladex/bladex.mysql.all.create.sql index 7a6e69cc..fed3b1b5 100644 --- a/doc/sql/bladex/bladex.mysql.all.create.sql +++ b/doc/sql/bladex/bladex.mysql.all.create.sql @@ -305,6 +305,7 @@ CREATE TABLE `blade_dept` ( `dept_code` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '组织编码', `kingdee_dept_code` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '金蝶部门代码', `contract_code_prefix` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '合同编码前缀', + `contract_initial_serial` int NULL DEFAULT NULL COMMENT '合同初始流水号', `short_name` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '组织简称', `pinyin_mnemonic` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '拼音助记码', `mnemonic_code` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '助记码', diff --git a/doc/sql/transport/blade_dept_contract_initial_serial_20261020.sql b/doc/sql/transport/blade_dept_contract_initial_serial_20261020.sql new file mode 100644 index 00000000..7b4ac000 --- /dev/null +++ b/doc/sql/transport/blade_dept_contract_initial_serial_20261020.sql @@ -0,0 +1,3 @@ +-- 组织机构新增合同初始流水号:该部门当年首个合同从此流水号起编,后续递增 +ALTER TABLE `blade_dept` + ADD COLUMN `contract_initial_serial` int(11) DEFAULT NULL COMMENT '合同初始流水号' AFTER `contract_code_prefix`;