🔀 合并 dev 到 uat

This commit is contained in:
2026-10-21 11:52:16 +08:00
24 changed files with 791 additions and 129 deletions
@@ -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;
/**
* 组织简称
*/
@@ -0,0 +1,48 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* 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;
/**
* 货物明细行:一条货物一行
* <p>
* 详情页逐行展示「货物名称 + 数量单位」,名称与数量分开给,前端不需要再从汇总串里拆。
*/
@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;
}
@@ -67,6 +67,9 @@ public class DriverPunchNodeVO implements Serializable {
@Schema(description = "不可打卡的原因,供司机端展示")
private String lockReason;
@Schema(description = "运单已结束(完成/取消):卡片仅展示、不可提交")
private Boolean ended;
@Schema(description = "是否展示该卡(在途可能因频次/时段隐藏)")
private Boolean visible;
@@ -67,6 +67,9 @@ public class DriverWaybillCardVO implements Serializable {
@Schema(description = "货物类别")
private String cargoCategory;
@Schema(description = "货物明细(详情返回):一条一行,同名同单位的货物已合并数量")
private List<CargoLineVO> cargos;
@Schema(description = "重量(带单位)")
private String weight;
@@ -129,12 +129,21 @@ public class TripVO implements Serializable {
@Schema(description = "货物汇总:按货物名合并数量,同名同单位取合计")
private String cargoSummary;
@Schema(description = "货物明细:一条货物一行,同名同单位的货物已合并数量")
private List<CargoLineVO> 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;
@@ -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<String, Long> 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;
}
@@ -14,6 +14,7 @@
<result column="dept_code" property="deptCode"/>
<result column="kingdee_dept_code" property="kingdeeDeptCode"/>
<result column="contract_code_prefix" property="contractCodePrefix"/>
<result column="contract_initial_serial" property="contractInitialSerial"/>
<result column="short_name" property="shortName"/>
<result column="pinyin_mnemonic" property="pinyinMnemonic"/>
<result column="mnemonic_code" property="mnemonicCode"/>
@@ -36,6 +37,7 @@
<result column="dept_code" property="deptCode"/>
<result column="kingdee_dept_code" property="kingdeeDeptCode"/>
<result column="contract_code_prefix" property="contractCodePrefix"/>
<result column="contract_initial_serial" property="contractInitialSerial"/>
<result column="short_name" property="shortName"/>
<result column="pinyin_mnemonic" property="pinyinMnemonic"/>
<result column="mnemonic_code" property="mnemonicCode"/>
@@ -687,6 +687,7 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
}
validateDeptCategory(dept);
validateDeptCode(dept, parent);
validateContractInitialSerial(dept);
return saveOrUpdate(dept);
}
@@ -874,6 +875,16 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> 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,避免唯一索引冲突
@@ -403,70 +403,37 @@ public class OaUserListSyncHelper {
Map<String, OAPersonResponse> 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<User> users = uniquePersonMap.values().stream()
.map(person -> {
User user = userConvert.person2user(person, defaultPassword);
List<User> 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<String> accounts = users.stream()
.map(User::getAccount)
.filter(StringUtils::isNotBlank)
.distinct()
.toList();
List<String> phones = users.stream()
.map(User::getPhone)
.filter(StringUtils::isNotBlank)
.distinct()
.toList();
Map<String, User> existingByAccount = new HashMap<>();
Map<String, User> existingByPhone = new HashMap<>();
userService.list(Wrappers.<User>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<String, User> existingByOaLoginId = this.loadUsersByOaLoginId(tenantId, uniquePersonMap.keySet());
Map<String, Long> userMap = new HashMap<>();
Set<Long> existsUserIds = new HashSet<>();
Set<Long> 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<User> addUsers = users.stream()
@@ -488,18 +455,13 @@ public class OaUserListSyncHelper {
}
List<User> 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<Long> existsUserDeptIds = new HashSet<>(userDeptMap.values());
List<UserDept> 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<User> 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<String, User> loadUsersByOaLoginId(String tenantId, Collection<String> oaLoginIds) {
Map<String, User> 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<String> oaLoginIdList = oaLoginIds.stream()
.filter(StringUtils::isNotBlank)
.distinct()
.toList();
int batchSize = 500;
for (int fromIndex = 0; fromIndex < oaLoginIdList.size(); fromIndex += batchSize) {
List<String> oaLoginIdBatch = oaLoginIdList.subList(fromIndex, Math.min(fromIndex + batchSize, oaLoginIdList.size()));
userService.list(Wrappers.<User>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;
}
@@ -298,6 +298,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
validateBillingPlans(request.getBillingPlanJson());
validatePaymentRatios(request.getPaymentRatioJson());
Map<String, Object> 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 BaseServiceImpl<ContractManageMap
contractManage.setOrganizationId(TransportBusinessSupport.currentDeptId("合同管理"));
contractManage.setOrganizationName(TransportBusinessSupport.currentDept("合同管理").getDeptName());
}
// 新增始终按部门规则生成合同编号,避免前端预览号并发冲突
contractManage.setContractNo(nextContractNo(contractManage.getOrganizationId()));
applyContractNo(contractManage, contractManage.getContractNo(), true);
if (Func.isEmpty(contractManage.getHandlerUserId())) {
contractManage.setHandlerUserId(AuthUtil.getUserId());
contractManage.setHandlerUserName(AuthUtil.getUserName());
@@ -516,7 +516,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
contractManage.setArchiveStatus(ARCHIVE_STATUS_PENDING);
} else {
ContractManage oldRecord = loadExists(contractManage.getId());
contractManage.setContractNo(oldRecord.getContractNo());
applyContractNo(contractManage, contractManage.getContractNo(), false);
if (Func.isEmpty(contractManage.getOrganizationId())) {
contractManage.setOrganizationId(oldRecord.getOrganizationId());
contractManage.setOrganizationName(oldRecord.getOrganizationName());
@@ -535,6 +535,28 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
}
}
/**
* 保存合同编号。新增未填写时按部门规则生成,已填写或编辑时保留手工编号并校验唯一。
*/
private void applyContractNo(ContractManage contractManage, String contractNo, boolean generateWhenEmpty) {
String normalizedContractNo = TransportBusinessSupport.trimToNull(contractNo);
if (Func.isEmpty(normalizedContractNo)) {
if (!generateWhenEmpty) {
throw new ServiceException("请输入合同编号");
}
normalizedContractNo = nextContractNo(contractManage.getOrganizationId());
}
TransportBusinessSupport.validateLength(normalizedContractNo, 100, "合同编号不能超过100字");
Long duplicatedCount = count(Wrappers.<ContractManage>lambdaQuery()
.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<ContractManageMap
.orderByDesc(ContractManage::getContractNo)
.last("LIMIT 50"));
Pattern pattern = Pattern.compile("^" + Pattern.quote(prefix) + "(" + year + ")(\\d{3})号$");
int next = 1;
// 当年尚无匹配编号时,从组织配置的初始流水号起编;否则在已有最大流水号上加1
int next = resolveContractInitialSerial(dept);
for (ContractManage item : latestList) {
String contractNo = TransportBusinessSupport.trimToNull(item.getContractNo());
if (Func.isEmpty(contractNo)) {
@@ -577,6 +600,17 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
return head + String.format("%03d", next) + "号";
}
private int resolveContractInitialSerial(Dept dept) {
Integer serial = dept.getContractInitialSerial();
if (serial == null || serial < 1) {
return 1;
}
if (serial > 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<ContractManageMap
private Map<String, Object> buildContractChangeSnapshot(ContractManage contractManage) {
Map<String, Object> data = new LinkedHashMap<>();
data.put("contractNo", contractManage.getContractNo());
data.put("contractName", contractManage.getContractName());
data.put("partyB", contractManage.getPartyB());
data.put("startDate", contractManage.getStartDate());
@@ -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<String, Object> 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);
}
/**
* 断言运单未结束。
* <p>
* 运单已完成/已取消后不再接受司机打卡:在途与节点都拦,且先于其它门禁。
* 结束不可逆,没有解除途径——处理完异常、等到第二天都还是打不了。
*/
private void assertPunchNotEnded(String businessStatus) {
String reason = WaybillProcessSupport.punchEndedReason(businessStatus);
if (reason != null) {
throw new ServiceException(reason);
}
}
/**
* 断言运单已开始运输。
* <p>
* 只有进行中的运单能打:草稿、待执行(待接单)、待调度、调度中都还没上路。
* 与「运单已结束」分开判:结束没有解除途径,未开始会随接单/调度自行变为进行中。
*/
private void assertPunchStarted(String businessStatus) {
String reason = WaybillProcessSupport.punchNotStartedReason(businessStatus);
if (reason != null) {
throw new ServiceException(reason);
}
}
/**
* 断言指定节点未被异常门禁阻断。
* <p>
@@ -1518,6 +1566,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
latestEnroute = enroutes.get(enroutes.size() - 1);
}
Map<String, WaybillNodePunch> latestNodePunchByCode = latestNodePunchMap(waybill.getId());
// 未开始运输的运单在途卡仍下发,但不可提交:司机要看得到这张卡,才知道为什么不能打
String notStartedReason = WaybillProcessSupport.punchNotStartedReason(waybill.getBusinessStatus());
for (Map<String, Object> 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.<WaybillEnroutePunch>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 {
}
/**
* 重量文案:全部货物同单位时按货物行相加,单位不一致时不出合计
* <p>
* 主表 quantity 是各行数量的合计、quantity_unit 只是首行的单位,「20堆 + 50方」会变成「70堆」。
* 拿 goodsJson 算不出合计(有货物没填数量或单位)时,退回主表那份汇总值——宁可显示一个来源不明的数,
* 也不要让卡片上的重量凭空消失。
* 货物明细行:一条货物一行,数量带单位
*/
private String formatCargoWeight(List<WaybillCargoSupport.Cargo> cargos, String fallback) {
if (cargos.isEmpty()) {
return fallback;
private List<CargoLineVO> cargoLines(List<WaybillCargoSupport.Cargo> cargos) {
List<CargoLineVO> 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) {
@@ -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);
}
// 节点可以为空:全局入口(首页快捷格、打卡页底部按钮)上报的异常不指定环节,
// 它无从定位阻断区间,由门禁按「阻断该运单全部未打卡节点」处理。
// 节点非空时才要求它确属该运单;节点名取自过程配置而非请求体,避免落进一个该节点并不叫的名字。
@@ -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<CargoLineVO> cargoLines(List<WaybillCargoSupport.Cargo> cargos) {
List<CargoLineVO> 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;
}
/**
* 合计重量:只有全部货物同单位时才能相加,单位不一致时留空(汇总文案里仍分单位列出)
* <p>
@@ -671,6 +688,18 @@ public class TripQueryServiceImpl implements ITripQueryService {
trip.setQuantityUnit(unit);
}
/**
* 计重合计:卡片上那个「货物重量」,口径见 WaybillCargoSupport#weighing
*/
private void fillWeighing(TripVO trip, List<WaybillCargoSupport.Cargo> 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;
@@ -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;
}
/**
* 计重合计:卡片上那个「货物重量」
* <p>
* 全部货物同单位时就是该单位合计;单位不一致时只合计「吨」项,其余单位不参与——
* 「100吨 + 20堆」报 100 吨,「20堆 + 50方」没有吨项就不出合计。
* 有货物缺数量时该单位合计不完整,宁可不报也不报一个偏小的数。
*
* @return 有可用合计时返回数量与单位,否则 null
*/
public static Quantity weighing(List<Cargo> cargos) {
Map<String, BigDecimal> totals = new LinkedHashMap<>();
Set<String> 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) {
}
/**
* 货物明细行:一条货物一行,同名同单位的合并数量,按录入顺序
* <p>
* 与 {@link #cargoSummary(List)} 的合并口径一致,但数量与单位保持分开,供详情页逐行展示。
* 数量为空的货物只留名称,不按 0 参与合并。
*/
public static List<Line> lines(List<Cargo> cargos) {
Map<String, Integer> index = new LinkedHashMap<>();
List<Line> 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) {
}
/**
* 主表货名按顿号、逗号摊平;空格与空段去掉,全空时给空集合
*/
@@ -133,6 +133,81 @@ public final class WaybillProcessSupport {
return ACCEPT_REJECTED.equalsIgnoreCase(stringVal(driverAcceptStatus));
}
/**
* 运单已结束:已完成或已取消。此后不再接受司机打卡。
* <p>
* 只认这两个状态,大小写不敏感。草稿、待执行、待调度、调度中的运单司机仍要能打卡,
* 所以不能按 {@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;
}
/**
* 运单已开始运输:只有进行中可以打卡(在途与普通节点同一判据)。
* <p>
* 草稿、待执行(待接单)、待调度、调度中都还没上路,任何打卡都没有承载物。
* 判据是校正后落库的状态:无需接单的待执行运单在 {@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 "运单尚未开始运输,暂不可打卡";
}
/**
* 指定自然日是否已打过在途卡。
* <p>
* 与运单状态无关,只回答「这一天打过没有」。{@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。
* <p>
* 与打卡共用「已开始运输」判据,但文案说的是能不能报,不是能不能打。
* 结束优先于未开始:已完成/已取消的运单先说结束,不再提「等你上路」。
*/
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;
@@ -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<String, DriverPunchNodeVO> 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<String, DriverPunchNodeVO> 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) {
@@ -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");
@@ -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
@@ -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"));
// 断言的是「取单这一步没有被拒单条件拦住」:若被拦住会抛「运单不存在或无权操作」,
// 而后面因过程配置为空会走「该运单未启用在途打卡」,两者可区分
@@ -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;
}
@@ -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")
@@ -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"));
}
}
@@ -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 '助记码',
@@ -0,0 +1,3 @@
-- 组织机构新增合同初始流水号:该部门当年首个合同从此流水号起编,后续递增
ALTER TABLE `blade_dept`
ADD COLUMN `contract_initial_serial` int(11) DEFAULT NULL COMMENT '合同初始流水号' AFTER `contract_code_prefix`;