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`;