From 1b69d2b792d584e53e06aaad98c5c2e76e8a4c84 Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Mon, 19 Oct 2026 23:28:50 +0800 Subject: [PATCH 01/55] =?UTF-8?q?:bug:=20=E4=BF=AE=E5=A4=8D=E8=BD=A6?= =?UTF-8?q?=E8=BE=86=E9=95=BF=E6=9C=9F=E6=9C=89=E6=95=88=E3=80=81=E7=BB=93?= =?UTF-8?q?=E7=AE=97=E5=AF=BC=E5=85=A5=E4=B8=8E=E8=B0=83=E5=BA=A6=E7=AB=AF?= =?UTF-8?q?=E8=BD=A8=E8=BF=B9=E9=89=B4=E6=9D=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 车辆勾选长期有效不再强校验有效期;结算导入模板去掉结算金额并自动合计,取消运费合计勾稽;调度端所属组织为空不拦截,并新增仅登录态的 manage/track。 Co-authored-by: Cursor --- .../controller/ManageWaybillController.java | 11 +++++ .../excel/SettlementImportFeeReader.java | 40 ++++++++++++++----- .../excel/SettlementImportTemplateExcel.java | 3 +- .../SettlementImportTemplateExporter.java | 5 +-- .../impl/DriverWaybillServiceImpl.java | 6 +++ .../impl/ManageWaybillServiceImpl.java | 13 ++++-- .../impl/TransportVehicleServiceImpl.java | 4 +- .../impl/WaybillImportBatchServiceImpl.java | 40 +++---------------- .../impl/ManageWaybillOrgScopeTest.java | 12 +++--- 9 files changed, 75 insertions(+), 59 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageWaybillController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageWaybillController.java index ee10e0b6..67c2d15c 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageWaybillController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageWaybillController.java @@ -39,6 +39,7 @@ import org.springblade.transport.pojo.vo.AdminVehicleOptionVO; import org.springblade.transport.pojo.vo.AdminWaybillCardVO; import org.springblade.transport.pojo.vo.AdminWaybillDetailVO; import org.springblade.transport.pojo.vo.WaybillPunchRecordsVO; +import org.springblade.transport.pojo.vo.WaybillTrackVO; import org.springblade.transport.service.IManageWaybillService; import org.springblade.transport.service.IWaybillService; import org.springframework.web.bind.annotation.GetMapping; @@ -159,4 +160,14 @@ public class ManageWaybillController extends BladeController { return R.status(waybillService.savePunchRecord(request)); } + @PostMapping("/track") + @ApiOperationSupport(order = 11) + @Operation(summary = "车辆历史轨迹", description = "小程序调度端:仅需登录态,按运单绑定车牌和日期区间查询 LBS 轨迹") + public R track( + @Parameter(description = "运单ID", required = true) @RequestParam Long id, + @Parameter(description = "开始日期 YYYY-MM-DD", required = true) @RequestParam String startDate, + @Parameter(description = "结束日期 YYYY-MM-DD", required = true) @RequestParam String endDate) { + return R.data(waybillService.trackVehicle(id, startDate, endDate)); + } + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportFeeReader.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportFeeReader.java index cc43d6e1..12b44580 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportFeeReader.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportFeeReader.java @@ -54,7 +54,8 @@ import java.util.Set; /** * 导入结算单 Excel 读取器(Apache POI)。 *

- * 定位「*运输费」或「运输费」列,其后直到「结算金额」为动态费用列。 + * 定位「*运输费」或「运输费」列,其后直到日期/备注等固定列(兼容旧模板中的「结算金额」)为动态费用列。 + * 结算金额不由模板填写,解析时按「运输费 + 各费用项」自动合计。 * * @author Chill */ @@ -66,6 +67,14 @@ public final class SettlementImportFeeReader { "actualStartDate", "actualEndDate", "planStartDate", "planEndDate" ); private static final Set OPTIONAL_DECIMAL_FIELDS = Set.of("mileage", "unitPrice"); + /** 费用列结束边界:其后为固定业务列;含旧模板「结算金额」以便兼容历史文件 */ + private static final Set FEE_END_HEADERS = Set.of( + "结算金额", + "*实际发货时间", "实际发货时间", + "*实际完成时间", "实际完成时间", + "预计发货时间", "预计完成时间", + "备注", "同一运单标识号" + ); private SettlementImportFeeReader() { } @@ -119,15 +128,12 @@ public final class SettlementImportFeeReader { if (transportFeeColumn < 0) { throw new ServiceException("未找到「*运输费」或「运输费」列"); } - int settlementAmountColumn = findColumnExact(headers, "结算金额"); List feeColumns = new ArrayList<>(); List feeItemNames = new ArrayList<>(); - int endColumn = settlementAmountColumn > transportFeeColumn - ? settlementAmountColumn - : headers.keySet().stream().mapToInt(Integer::intValue).max().orElse(transportFeeColumn) + 1; + int endColumn = findFeeEndColumn(headers, transportFeeColumn); for (int column = transportFeeColumn + 1; column < endColumn; column++) { String name = headers.get(column); - if (name == null || name.isBlank() || "结算金额".equals(name)) { + if (name == null || name.isBlank() || FEE_END_HEADERS.contains(name)) { continue; } feeColumns.add(column); @@ -229,7 +235,8 @@ public final class SettlementImportFeeReader { } else if ("transportFee".equals(field) || "quantity".equals(field)) { mapped.put(field, decimal(cellString(poiCell), trimmedHeader, excelRowIndex)); } else if ("settlementAmount".equals(field)) { - mapped.put(field, optionalDecimal(cellString(poiCell), trimmedHeader, excelRowIndex)); + // 旧模板可能仍含结算金额列,读入后忽略,统一按运输费+费用项自动合计 + continue; } else if ("serialNumber".equals(field)) { mapped.put(field, intValue(cellString(poiCell))); } else { @@ -255,9 +262,7 @@ public final class SettlementImportFeeReader { BigDecimal feeItemTotal = feeItems.values().stream() .reduce(BigDecimal.ZERO, BigDecimal::add) .setScale(2, RoundingMode.HALF_UP); - BigDecimal settlementAmount = mapped.get("settlementAmount") == null - ? transportFee.add(feeItemTotal).setScale(2, RoundingMode.HALF_UP) - : toDecimal(mapped.get("settlementAmount")); + BigDecimal settlementAmount = transportFee.add(feeItemTotal).setScale(2, RoundingMode.HALF_UP); mapped.put("settlementAmount", settlementAmount); mapped.put("结算金额", settlementAmount); mapped.put("feeItems", feeItems); @@ -284,6 +289,21 @@ public final class SettlementImportFeeReader { return findColumnExact(headers, "运输费"); } + /** 费用列右边界:取运输费之后第一个固定结束表头列下标;无则取表头最大下标 + 1。 */ + private static int findFeeEndColumn(Map headers, int transportFeeColumn) { + int endColumn = headers.keySet().stream().mapToInt(Integer::intValue).max().orElse(transportFeeColumn) + 1; + for (Map.Entry entry : headers.entrySet()) { + int column = entry.getKey(); + if (column <= transportFeeColumn) { + continue; + } + if (FEE_END_HEADERS.contains(entry.getValue()) && column < endColumn) { + endColumn = column; + } + } + return endColumn; + } + private static int findColumnExact(Map headers, String header) { return headers.entrySet().stream() .filter(entry -> header.equals(entry.getValue())) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExcel.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExcel.java index a060b15e..871599eb 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExcel.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExcel.java @@ -100,7 +100,8 @@ public class SettlementImportTemplateExcel implements Serializable { private BigDecimal loadingFee; @ExcelProperty("过路费") private BigDecimal tollFee; - @ExcelProperty("结算金额") + /** 模板已去掉结算金额列;仅失败明细内部保留,不导出为表头 */ + @ExcelIgnore private BigDecimal settlementAmount; @ExcelProperty("*实际发货时间") private String actualStartDate; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExporter.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExporter.java index a33dbb6c..91eb317e 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExporter.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExporter.java @@ -85,7 +85,6 @@ public final class SettlementImportTemplateExporter { ); private static final List FIXED_HEADERS_AFTER_FEE = List.of( - "结算金额", "*实际发货时间", "*实际完成时间", "预计发货时间", @@ -159,8 +158,8 @@ public final class SettlementImportTemplateExporter { lines.add("2. 运输方式请从下拉项中选择,须与系统字典一致。"); lines.add("3. 日期请填写 yyyy-MM-dd(如 2026-01-01);Excel 日期单元格也可直接选择。"); lines.add("4. 里程(km) 可为空;填写时须为正数。"); - lines.add("5. 「*运输费」之后、「结算金额」之前的列为费用项,列名须与系统费用项主数据名称完全一致;金额不为 0 时若不存在或已停用则无法导入。"); - lines.add("6. 结算金额 = 运输费 + 各费用项合计,系统会按此重算。"); + lines.add("5. 「*运输费」之后、「*实际发货时间」之前的列为费用项,列名须与系统费用项主数据名称完全一致;金额不为 0 时若不存在或已停用则无法导入。"); + lines.add("6. 模板不含结算金额列;导入后系统按「运输费 + 各费用项」自动合计结算金额。"); lines.add("7. 当前可选运输方式:" + String.join("、", transportOptions) + "。"); if (feeItemNames != null && !feeItemNames.isEmpty()) { lines.add("8. 当前启用费用项示例:" + String.join("、", feeItemNames.stream().limit(30).toList()) 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 e132f332..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 @@ -350,9 +350,14 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { /** * 按打卡记录 / 计划时间推导轨迹查询起止日期 + *

+ * TODO(临时):当前仅查当天,便于联调 LBS;恢复正式逻辑时删掉本段 early-return。 */ private LocalDate[] resolveTrackDateRange(Waybill waybill) { LocalDate today = LocalDate.now(); + // TEMP: 行驶轨迹时间段临时改为仅当天 + return new LocalDate[]{today, today}; + /* List punchDates = new ArrayList<>(); nodePunchMapper.selectList(Wrappers.lambdaQuery() .eq(WaybillNodePunch::getWaybillId, waybill.getId()) @@ -384,6 +389,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { return new LocalDate[]{waybill.getStartDate(), today}; } return new LocalDate[]{today.minusDays(3), today}; + */ } @Override diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java index f8701641..4b1e5c93 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java @@ -886,11 +886,18 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { /** * 调度端可见组织:普通用户(含各级角色)只出自己所属组织,超级管理员不过滤。 *

- * 与后台运单列表同一条规则({@link TransportBusinessSupport#resolveDeptFilterIds}), - * 小程序端不提供「全部组织」开关,故两个入参固定传空;详情与重新派单不按组织收口。 + * 小程序不校验「所属组织不能为空」:账号没有组织时不按组织收口,避免首页、待处理等接口直接报错。 + * 详情与重新派单本身不走这里。 */ private List manageableDeptIds() { - return TransportBusinessSupport.resolveDeptFilterIds(null, null, "运单管理"); + if (TransportBusinessSupport.isAdministrator()) { + return List.of(); + } + Long deptId = Func.firstLong(AuthUtil.getDeptId()); + if (Func.isEmpty(deptId) || deptId <= 0) { + return List.of(); + } + return List.of(deptId); } /** diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportVehicleServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportVehicleServiceImpl.java index e28d7329..d9e1c734 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportVehicleServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportVehicleServiceImpl.java @@ -291,13 +291,13 @@ public class TransportVehicleServiceImpl extends BaseServiceImpl row, List errors) { - String freightTotalStr = stringValue(row, "freightTotal", "运费合计"); - if (Func.isEmpty(freightTotalStr) || isImportOptionalNumberEmpty(freightTotalStr)) { - return; - } - - try { - BigDecimal freightTotal = new BigDecimal(freightTotalStr); - String freightStr = stringValue(row, "freight", "运费"); - String otherFeeTotalStr = stringValue(row, "otherFeeTotal", "其他费用合计"); - - BigDecimal freight = Func.isNotEmpty(freightStr) && !isImportOptionalNumberEmpty(freightStr) - ? new BigDecimal(freightStr) : BigDecimal.ZERO; - BigDecimal otherFeeTotal = Func.isNotEmpty(otherFeeTotalStr) && !isImportOptionalNumberEmpty(otherFeeTotalStr) - ? new BigDecimal(otherFeeTotalStr) : BigDecimal.ZERO; - - BigDecimal calculatedTotal = freight.add(otherFeeTotal); - if (freightTotal.compareTo(calculatedTotal) != 0) { - errors.add("运费合计应等于运费+其他费用合计"); - } - } catch (NumberFormatException e) { - errors.add("运费相关字段格式不正确"); - } - } - private void validateActualStartDate(Map row, boolean isDraft, List errors) { Object actualStartDate = row.get("actualStartDate"); if (actualStartDate == null || String.valueOf(actualStartDate).isBlank()) { diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageWaybillOrgScopeTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageWaybillOrgScopeTest.java index 15a31a27..95346246 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageWaybillOrgScopeTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageWaybillOrgScopeTest.java @@ -52,7 +52,7 @@ import static org.mockito.Mockito.when; * 三个取数入口必须同一条规则——列表、待处理、首页三档统计各走一次查询,漏一处就会出现 * 「统计说 46 张、列表只出 18 张」这种对不上的数;待重新派单另走库层分组查询,也要单独带上。 *

- * 组织取不到时报错而不是放行:放行等于把全租户的运单发给一个没有组织的账号。 + * 所属组织为空时不拦截:小程序调度端不要求账号必须挂组织。 */ class ManageWaybillOrgScopeTest { @@ -190,13 +190,13 @@ class ManageWaybillOrgScopeTest { } @Test - @DisplayName("组织取不到时报错,不退回不带组织条件") - void blankDeptFailsClosed() { + @DisplayName("组织取不到时不拦截,小程序接口不按组织收口") + void blankDeptDoesNotFail() { authUtil.when(AuthUtil::getDeptId).thenReturn(""); - assertThrows(ServiceException.class, - () -> service.pageList(1, 10, null, null, null, null, null, null), - "没有组织的账号不能拿到全租户运单"); + service.pageList(1, 10, null, null, null, null, null, null); + + assertTrue(capturedPageQuery().getDeptIds().isEmpty(), "没有组织时不报错、不按组织过滤"); } // ------------------------------------------------------------ 夹具 From 939569e6239cc150f138488e5f14adf869541a37 Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Tue, 20 Oct 2026 01:50:55 +0800 Subject: [PATCH 02/55] =?UTF-8?q?:bug:=20=E4=BF=AE=E5=A4=8D=E8=BF=9B?= =?UTF-8?q?=E5=BA=A6=E9=A2=84=E4=BB=98=E9=A9=B3=E5=9B=9E=E9=87=8D=E6=8F=90?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E6=A0=A1=E6=AD=A3=E7=BB=93=E7=AE=97=E8=B4=B9?= =?UTF-8?q?=E7=94=A8=E4=B8=8E=E8=BF=90=E8=BE=93=E8=AE=A1=E5=88=92=E7=BB=84?= =?UTF-8?q?=E7=BB=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../dto/PaymentApplicationSaveRequest.java | 2 + .../controller/BillLedgerController.java | 11 +- .../transport/service/IBillLedgerService.java | 5 +- .../service/impl/BillLedgerServiceImpl.java | 47 ++++-- .../impl/PaymentApplicationServiceImpl.java | 131 +++++++++++++--- .../impl/PreSettlementServiceImpl.java | 147 ++++++++++-------- .../ReceivablePayableDetailServiceImpl.java | 38 +++-- .../impl/TransportPlanServiceImpl.java | 32 +++- 8 files changed, 302 insertions(+), 111 deletions(-) diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PaymentApplicationSaveRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PaymentApplicationSaveRequest.java index d608d7cb..d69690f8 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PaymentApplicationSaveRequest.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PaymentApplicationSaveRequest.java @@ -36,6 +36,8 @@ public class PaymentApplicationSaveRequest implements Serializable { private List settlementIds; private Long preSettlementId; private List preSettlementIds; + /** 预结算单号。驳回重提时用于在主键失真后找回预结算单 */ + private String preSettlementNo; private Long projectId; private String projectName; private Long deptId; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/BillLedgerController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/BillLedgerController.java index 24d695ca..7cce3ffc 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/BillLedgerController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/BillLedgerController.java @@ -61,8 +61,9 @@ public class BillLedgerController extends BladeController { @ApiOperationSupport(order = 4) @Operation(summary = "付款申请可用汇票") public R> availableOptions(@RequestParam(required = false) String keyword, - @RequestParam(required = false) Long deptId, @RequestParam(required = false) Long selectedId) { - return R.data(billLedgerService.availableOptions(keyword, deptId, selectedId)); + @RequestParam(required = false) Long deptId, @RequestParam(required = false) Long selectedId, + @RequestParam(required = false) String payerName, @RequestParam(required = false) String payeeName) { + return R.data(billLedgerService.availableOptions(keyword, deptId, selectedId, payerName, payeeName)); } @GetMapping("/available-page") @@ -70,8 +71,10 @@ public class BillLedgerController extends BladeController { @Operation(summary = "付款申请可用汇票分页") public R> availablePage(Query pageQuery, @RequestParam(required = false) String keyword, @RequestParam(required = false) Long deptId, - @RequestParam(required = false) Long selectedId) { - return R.data(billLedgerService.availablePage(Condition.getPage(pageQuery), keyword, deptId, selectedId)); + @RequestParam(required = false) Long selectedId, @RequestParam(required = false) String payerName, + @RequestParam(required = false) String payeeName) { + return R.data(billLedgerService.availablePage(Condition.getPage(pageQuery), keyword, deptId, selectedId, + payerName, payeeName)); } @PostMapping("/submit") diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IBillLedgerService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IBillLedgerService.java index abd6e5d2..c46522e3 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IBillLedgerService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IBillLedgerService.java @@ -18,8 +18,9 @@ public interface IBillLedgerService extends BaseService { IPage selectPage(IPage page, BillLedgerVO query); BillLedgerVO detail(Long id); Map expiryCounts(); - List availableOptions(String keyword, Long deptId, Long selectedId); - IPage availablePage(IPage page, String keyword, Long deptId, Long selectedId); + List availableOptions(String keyword, Long deptId, Long selectedId, String payerName, String payeeName); + IPage availablePage(IPage page, String keyword, Long deptId, Long selectedId, + String payerName, String payeeName); Long submit(BillLedgerSaveRequest request); void removeLedger(Long id); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/BillLedgerServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/BillLedgerServiceImpl.java index a2728da7..a20a4e0e 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/BillLedgerServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/BillLedgerServiceImpl.java @@ -86,14 +86,15 @@ public class BillLedgerServiceImpl extends BaseServiceImpl availableOptions(String keyword, Long deptId, Long selectedId) { - return availableList(keyword, deptId, selectedId); + public List availableOptions(String keyword, Long deptId, Long selectedId, String payerName, + String payeeName) { + return availableList(keyword, deptId, selectedId, payerName, payeeName); } @Override public IPage availablePage(IPage page, String keyword, Long deptId, - Long selectedId) { - List available = availableList(keyword, deptId, selectedId); + Long selectedId, String payerName, String payeeName) { + List available = availableList(keyword, deptId, selectedId, payerName, payeeName); long current = Math.max(page.getCurrent(), 1); long size = Math.max(page.getSize(), 1); long from = Math.min((current - 1) * size, available.size()); @@ -103,27 +104,50 @@ public class BillLedgerServiceImpl extends BaseServiceImpl availableList(String keyword, Long deptId, Long selectedId) { + private List availableList(String keyword, Long deptId, Long selectedId, String payerName, + String payeeName) { LocalDate today = LocalDate.now(); - // 付款申请仅可选:收票类型、未到期、有可用余额;已选中的汇票始终带回以便回显 + // 未到期且有余额;收票且部门可用,或出票/收票单位与结算收付款方一致(开票、收票都可) return list(Wrappers.lambdaQuery() .and(Func.isNotEmpty(keyword), wrapper -> wrapper.like(BillLedger::getBillNo, keyword) .or().like(BillLedger::getIssuerName, keyword) .or().like(BillLedger::getReceiverName, keyword)) .and(wrapper -> wrapper .and(available -> available - .eq(BillLedger::getBillType, "received") .ge(BillLedger::getMaturityDate, today) .gt(BillLedger::getAvailableBalance, BigDecimal.ZERO)) .or(selectedId != null, child -> child.eq(BillLedger::getId, selectedId))) .orderByAsc(BillLedger::getMaturityDate) .orderByDesc(BillLedger::getCreateTime)).stream() .filter(item -> selectedId != null && Objects.equals(item.getId(), selectedId) - || departmentAvailable(item, deptId)) + || selectableBill(item, deptId, payerName, payeeName)) .map(item -> BillLedgerWrapper.build().entityVO(item)) .toList(); } + /** + * 收票且当前部门在可用范围内,或出票单位、收票单位与结算付款方、收款方一致(顺序不限)。 + */ + private boolean selectableBill(BillLedger ledger, Long deptId, String payerName, String payeeName) { + boolean receivedAndDept = "received".equals(ledger.getBillType()) && departmentAvailable(ledger, deptId); + return receivedAndDept || partiesMatch(ledger.getIssuerName(), ledger.getReceiverName(), payerName, payeeName); + } + + private boolean partiesMatch(String issuerName, String receiverName, String payerName, String payeeName) { + String issuer = trimName(issuerName); + String receiver = trimName(receiverName); + String payer = trimName(payerName); + String payee = trimName(payeeName); + if (issuer.isEmpty() || receiver.isEmpty() || payer.isEmpty() || payee.isEmpty()) { + return false; + } + return (issuer.equals(payer) && receiver.equals(payee)) || (issuer.equals(payee) && receiver.equals(payer)); + } + + private String trimName(String value) { + return value == null ? "" : value.trim(); + } + @Override @Transactional(rollbackFor = Exception.class) public Long submit(BillLedgerSaveRequest request) { @@ -141,6 +165,11 @@ public class BillLedgerServiceImpl extends BaseServiceImpl candidates = new java.util.ArrayList<>(); + if (request.getPreSettlementId() != null) { + candidates.add(request.getPreSettlementId()); + } + if (request.getPreSettlementIds() != null) { + request.getPreSettlementIds().stream().filter(Objects::nonNull).forEach(candidates::add); + } + if (request.getSettlementId() != null) { + candidates.add(request.getSettlementId()); + } + if (request.getSettlementIds() != null) { + request.getSettlementIds().stream().filter(Objects::nonNull).forEach(candidates::add); + } + if (entity != null && entity.getPreSettlementId() != null) { + candidates.add(entity.getPreSettlementId()); + } + for (Long candidate : candidates) { + PreSettlement preSettlement = preSettlementMapper.selectById(candidate); + if (preSettlement != null && !Objects.equals(preSettlement.getIsDeleted(), 1)) { + return preSettlement.getId(); + } + } + String preSettlementNo = request.getPreSettlementNo(); + if (Func.isEmpty(preSettlementNo) && entity != null) { + preSettlementNo = entity.getPreSettlementNo(); + } + if (Func.isNotEmpty(preSettlementNo)) { + String settlementNo = preSettlementNo.split("、")[0].trim(); + PreSettlement preSettlement = preSettlementMapper.selectOne(Wrappers.lambdaQuery() + .eq(PreSettlement::getPreSettlementNo, settlementNo) + .eq(PreSettlement::getIsDeleted, 0) + .last("limit 1")); + if (preSettlement != null) { + return preSettlement.getId(); + } + } + return null; + } + private void fillReference(PaymentApplication entity, PaymentApplicationSaveRequest request) { - if ("project_advance".equals(request.getPaymentType())) { + if ("progress_advance".equals(request.getPaymentType())) { + Long preSettlementId = resolveProgressPreSettlementId(request, entity); + request.setPreSettlementId(preSettlementId); + if (preSettlementId == null) { + throw new ServiceException("进度预付必须关联预结算单"); + } + request.setSettlementId(null); + request.setSettlementIds(null); + fillPreSettlementReference(entity, requireApprovedPreSettlement(request.getPreSettlementId())); + } else if ("project_advance".equals(request.getPaymentType())) { ProjectApply project = projectApplyMapper.selectById(request.getProjectId()); if (project == null || Objects.equals(project.getIsDeleted(), 1)) throw new ServiceException("所属项目不存在"); entity.setSettlementId(null); entity.setSettlementNo(null); entity.setPreSettlementId(null); entity.setPreSettlementNo(null); @@ -1981,6 +2037,10 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl 0) throw new ServiceException("申请付款金额超过汇票可用余额"); - if (!departmentAvailable(ledger, deptId)) throw new ServiceException("当前使用部门不在汇票可用部门范围内"); + if (!partyMatched && !departmentAvailable(ledger, deptId)) throw new ServiceException("当前使用部门不在汇票可用部门范围内"); + } + + /** 出票单位、收票单位与结算付款方、收款方一致(顺序不限) */ + private boolean settlementPartiesMatch(BillLedger ledger, Long settlementId, Long preSettlementId) { + String payerName = null; + String payeeName = null; + if (settlementId != null) { + FormalSettlement settlement = formalSettlementMapper.selectById(settlementId); + if (settlement != null && !Objects.equals(settlement.getIsDeleted(), 1)) { + payerName = settlement.getPayerName(); + payeeName = settlement.getPayeeName(); + } + } + if ((payerName == null || payeeName == null) && preSettlementId != null) { + PreSettlement settlement = preSettlementMapper.selectById(preSettlementId); + if (settlement != null && !Objects.equals(settlement.getIsDeleted(), 1)) { + payerName = settlement.getPayerName(); + payeeName = settlement.getPayeeName(); + } + } + String issuer = ledger.getIssuerName() == null ? "" : ledger.getIssuerName().trim(); + String receiver = ledger.getReceiverName() == null ? "" : ledger.getReceiverName().trim(); + String payer = payerName == null ? "" : payerName.trim(); + String payee = payeeName == null ? "" : payeeName.trim(); + if (issuer.isEmpty() || receiver.isEmpty() || payer.isEmpty() || payee.isEmpty()) { + return false; + } + return (issuer.equals(payer) && receiver.equals(payee)) || (issuer.equals(payee) && receiver.equals(payer)); } private void useBillBalance(PaymentApplication entity) { BillLedger ledger = lockedBill(entity.getBillLedgerId()); - validateBill(ledger, entity.getDeptId(), entity.getAppliedAmount(), true, null); + validateBill(ledger, entity.getDeptId(), entity.getAppliedAmount(), true, null, + entity.getSettlementId(), entity.getPreSettlementId()); BillLedgerUsage exists = lockedBillUsage(entity.getId()); if (exists != null && TransportBusiConstant.APPROVED.equals(exists.getUsageStatus())) { throw new ServiceException("该付款申请已生成汇票使用记录"); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PreSettlementServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PreSettlementServiceImpl.java index 2b98c34a..f828826f 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PreSettlementServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PreSettlementServiceImpl.java @@ -206,6 +206,9 @@ public class PreSettlementServiceImpl extends BaseServiceImpllambdaQuery() .eq(PreSettlementChangeRecord::getPreSettlementId, id) .eq(PreSettlementChangeRecord::getIsDeleted, 0) + .and(wrapper -> wrapper.isNull(PreSettlementChangeRecord::getChangeContent) + .or() + .ne(PreSettlementChangeRecord::getChangeContent, "预结算审批通过")) .orderByDesc(PreSettlementChangeRecord::getChangeTime))); vo.setPrintTemplates(resolvePrintTemplates(settlement)); return vo; @@ -355,8 +358,10 @@ public class PreSettlementServiceImpl extends BaseServiceImpl summaryBefore = created ? new ArrayList<>() : listSummaryFees(settlement.getId()); rebuildSummaryFees(settlement.getId(), true); applySummaryRequest(settlement.getId(), contractChanged ? List.of() : request.getSummaryFees()); + recordSummaryFeeChanges(settlement.getId(), summaryBefore); refreshSettlementAmount(settlement); projectAdvanceWriteOffService.replaceWriteOffs( TransportBusiConstant.SETTLEMENT_BILL_PRE, @@ -425,7 +430,6 @@ public class PreSettlementServiceImpl extends BaseServiceImpl beforeData = new LinkedHashMap<>(); - Map afterData = new LinkedHashMap<>(); - beforeData.put("费用项", row.getFeeItem()); - beforeData.put("调整金额", before); - beforeData.put("结算金额", money(row.getOriginalAmount().add(before))); - afterData.put("费用项", row.getFeeItem()); - afterData.put("调整金额", row.getAdjustAmount()); - afterData.put("结算金额", money(row.getSettlementAmount())); - saveChange(settlementId, "合计费用项", row.getLineNo(), "调整", - "【" + row.getFeeItem() + "-调整金额】从【" + before + "】调整为【" + row.getAdjustAmount() + "】", - "", beforeData, afterData); - } } for (PreSettlementSummaryFee manualRow : manualRows) { if (!retainedManualIds.contains(manualRow.getId())) { summaryFeeMapper.deleteById(manualRow.getId()); - Map beforeData = new LinkedHashMap<>(); - Map afterData = new LinkedHashMap<>(); - beforeData.put("费用类型", manualRow.getFeeType()); - beforeData.put("费用项", manualRow.getFeeItem()); - beforeData.put("调整金额", money(manualRow.getAdjustAmount())); - beforeData.put("结算金额", money(manualRow.getSettlementAmount())); - beforeData.put("备注", manualRow.getRemark()); - afterData.put("费用类型", ""); - afterData.put("费用项", ""); - afterData.put("调整金额", BigDecimal.ZERO.setScale(2)); - afterData.put("结算金额", BigDecimal.ZERO.setScale(2)); - afterData.put("备注", ""); - saveChange(settlementId, "合计费用项", manualRow.getLineNo(), "删除", - "删除合计费用项【" + manualRow.getFeeItem() + "】金额【" + money(manualRow.getSettlementAmount()) + "】", - "", beforeData, afterData); } } for (PreSettlementSummaryFee generatedRow : existingGeneratedRows) { @@ -1563,38 +1531,87 @@ public class PreSettlementServiceImpl extends BaseServiceImpl changes = new ArrayList<>(); + private void recordSummaryFeeChanges(Long settlementId, List beforeRows) { + List> beforeLines = summaryFeeLines(beforeRows); + List> afterLines = summaryFeeLines(listSummaryFees(settlementId)); + if (beforeLines.equals(afterLines)) { + return; + } + String content = summaryFeeChangeContent(beforeLines, afterLines); + if (Func.isEmpty(content)) { + return; + } Map beforeData = new LinkedHashMap<>(); Map afterData = new LinkedHashMap<>(); - if (creating) { - changes.add("新增合计费用项【" + row.getFeeItem() + "】金额【" + money(row.getSettlementAmount()) + "】"); - beforeData.put("费用类型", ""); - beforeData.put("费用项", ""); - beforeData.put("调整金额", BigDecimal.ZERO.setScale(2)); - beforeData.put("结算金额", BigDecimal.ZERO.setScale(2)); - beforeData.put("备注", ""); - afterData.put("费用类型", row.getFeeType()); - afterData.put("费用项", row.getFeeItem()); - afterData.put("调整金额", money(row.getAdjustAmount())); - afterData.put("结算金额", money(row.getSettlementAmount())); - afterData.put("备注", row.getRemark()); - saveChange(settlementId, "合计费用项", row.getLineNo(), "新增", String.join(";", changes), "", - beforeData, afterData); - return; + beforeData.put("结算合计", beforeLines); + afterData.put("结算合计", afterLines); + String operationType = beforeLines.isEmpty() ? "新增" : afterLines.isEmpty() ? "删除" : "调整"; + saveChange(settlementId, "结算合计", null, operationType, content, "", beforeData, afterData); + } + + private List> summaryFeeLines(List rows) { + Map categoryNames = DictBizCache.getList("fee_category").stream() + .collect(Collectors.toMap(DictBiz::getDictKey, DictBiz::getDictValue, (first, second) -> first)); + List> lines = new ArrayList<>(); + for (PreSettlementSummaryFee row : rows) { + String feeType = row.getFeeType() == null ? "" : row.getFeeType(); + Map line = new LinkedHashMap<>(); + line.put("来源", Integer.valueOf(1).equals(row.getManualFlag()) ? "手工新增" : "系统归集"); + line.put("费用类型", categoryNames.getOrDefault(feeType, feeType)); + line.put("费用项", row.getFeeItem() == null ? "" : row.getFeeItem()); + line.put("原金额", money(row.getOriginalAmount())); + line.put("调整金额", money(row.getAdjustAmount())); + line.put("结算金额", money(row.getSettlementAmount())); + line.put("备注", row.getRemark() == null ? "" : row.getRemark()); + lines.add(line); } - appendTextChange(changes, beforeData, afterData, "费用类型", beforeFeeType, row.getFeeType()); - appendTextChange(changes, beforeData, afterData, "费用项", beforeFeeItem, row.getFeeItem()); - appendChange(changes, beforeData, afterData, "调整金额", beforeAdjustAmount, row.getAdjustAmount()); - appendChange(changes, beforeData, afterData, "结算金额", beforeSettlementAmount, row.getSettlementAmount()); - appendTextChange(changes, beforeData, afterData, "备注", beforeRemark, row.getRemark()); - if (changes.isEmpty()) { - return; + return lines; + } + + private String summaryFeeChangeContent(List> beforeLines, List> afterLines) { + Map> beforeMap = indexSummaryLines(beforeLines); + Map> afterMap = indexSummaryLines(afterLines); + List changes = new ArrayList<>(); + afterMap.forEach((key, afterLine) -> { + Map beforeLine = beforeMap.get(key); + String label = summaryLineLabel(afterLine); + if (beforeLine == null) { + changes.add("新增结算合计【" + label + "】结算金额【" + afterLine.get("结算金额") + "】"); + return; + } + if (!Objects.equals(beforeLine.get("原金额"), afterLine.get("原金额")) + || !Objects.equals(beforeLine.get("调整金额"), afterLine.get("调整金额")) + || !Objects.equals(beforeLine.get("结算金额"), afterLine.get("结算金额")) + || !Objects.equals(String.valueOf(beforeLine.get("备注")), String.valueOf(afterLine.get("备注")))) { + changes.add("调整结算合计【" + label + "】结算金额从【" + beforeLine.get("结算金额") + + "】调整为【" + afterLine.get("结算金额") + "】"); + } + }); + beforeMap.forEach((key, beforeLine) -> { + if (!afterMap.containsKey(key)) { + changes.add("删除结算合计【" + summaryLineLabel(beforeLine) + "】结算金额【" + beforeLine.get("结算金额") + "】"); + } + }); + return String.join(";", changes); + } + + private Map> indexSummaryLines(List> lines) { + Map> indexed = new LinkedHashMap<>(); + Map counts = new HashMap<>(); + for (Map line : lines) { + String base = String.valueOf(line.get("来源")) + "|" + line.get("费用类型") + "|" + line.get("费用项"); + int index = counts.merge(base, 1, Integer::sum); + indexed.put(base + "#" + index, line); } - saveChange(settlementId, "合计费用项", row.getLineNo(), "调整", String.join(";", changes), "", - beforeData, afterData); + return indexed; + } + + private String summaryLineLabel(Map line) { + String feeType = String.valueOf(line.getOrDefault("费用类型", "")); + String feeItem = String.valueOf(line.getOrDefault("费用项", "")); + String source = String.valueOf(line.getOrDefault("来源", "")); + String name = feeType.isBlank() ? feeItem : feeType + "-" + feeItem; + return source + " " + name; } private void saveChange(Long settlementId, String changeType, Integer lineNo, String operationType, diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java index f27388f7..8ce2f5f7 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java @@ -424,9 +424,7 @@ public class ReceivablePayableDetailServiceImpl } }); ReceivablePayableFeeDetailVO result = buildFeeDetail(rows); - LinkedHashSet feeItemNames = new LinkedHashSet<>(contractFeeItemNames(detail.getContractId())); - feeItemNames.addAll(result.getFeeItemNames()); - result.setFeeItemNames(new ArrayList<>(feeItemNames)); + // 只展示本明细已生成的费用项。合同上其他计费方案的费用项不能叠到当前明细。 return result; } @@ -1968,21 +1966,35 @@ public class ReceivablePayableDetailServiceImpl } private List calculatedFees(Waybill waybill, ContractManage contract, String planId) { - return calculatedFees(waybill, contract, planId, false); + return calculatedFees(waybill, contract, planId, false, false); } - private List calculatedFees(Waybill waybill, ContractManage contract, String planId, boolean matchOnly) { + private List calculatedFees(Waybill waybill, ContractManage contract, String planId, + boolean matchOnly) { + return calculatedFees(waybill, contract, planId, matchOnly, false); + } + + /** + * @param strictPlan 更新费用时为 true:选定方案有规则但未命中时,不再回退运单历史费用,避免旧费用项叠加 + */ + private List calculatedFees(Waybill waybill, ContractManage contract, String planId, + boolean matchOnly, boolean strictPlan) { List> plans = parseList(contract == null ? null : contract.getBillingPlanJson()); Map plan = "__matched__".equals(planId) ? resolveDefaultBillingPlan(plans, waybill.getTransportType()) : resolveBillingPlan(plans, planId); - return calculatedFees(waybill, plan, matchOnly); + return calculatedFees(waybill, plan, matchOnly, strictPlan); } private List calculatedFees(Waybill waybill, Map plan, boolean matchOnly) { + return calculatedFees(waybill, plan, matchOnly, false); + } + + private List calculatedFees(Waybill waybill, Map plan, + boolean matchOnly, boolean strictPlan) { if (plan == null || !(plan.get("rules") instanceof List)) { - return matchOnly ? List.of() : buildCargoFees(waybill); + return matchOnly || strictPlan ? List.of() : buildCargoFees(waybill); } Map, ReceivablePayableCargoFee> feesByCargo = new LinkedHashMap<>(); Map, Map> feeItemsByCargo = new LinkedHashMap<>(); @@ -2024,7 +2036,11 @@ public class ReceivablePayableDetailServiceImpl ReceivablePayableCargoFee fee = result.get(index); fee.setLineNo(String.format("%04d", index + 1)); } - return result.isEmpty() && !matchOnly ? buildCargoFees(waybill) : result; + // 已选定计费方案时,只保留该方案算出的费用项,不用运单上的历史费用回填。 + if (strictPlan || matchOnly) { + return result; + } + return result.isEmpty() ? buildCargoFees(waybill) : result; } private ReceivablePayableCargoFee buildCalculatedCargoFee(Waybill waybill, Waybill feeWaybill, @@ -2934,7 +2950,7 @@ public class ReceivablePayableDetailServiceImpl throw new ServiceException("关联运单不存在"); } ContractManage contract = contractManageService.getById(detail.getContractId()); - List fees = calculatedFees(waybill, contract, billingPlanId); + List fees = calculatedFees(waybill, contract, billingPlanId, false, true); cargoFeeMapper.delete(Wrappers.lambdaQuery().eq(ReceivablePayableCargoFee::getDetailId, detail.getId())); fees.forEach(fee -> { fee.setDetailId(detail.getId()); cargoFeeMapper.insert(fee); }); BigDecimal freight = fees.stream().map(fee -> money(fee.getFreightAmount())) @@ -2959,7 +2975,7 @@ public class ReceivablePayableDetailServiceImpl ContractManage contract = contractManageService.getById(detail.getContractId()); List masterGoods = masterOrderGoods(masterOrder); List fees = masterGoods.stream() - .flatMap(goods -> calculatedFees(goods, contract, billingPlanId).stream()).toList(); + .flatMap(goods -> calculatedFees(goods, contract, billingPlanId, false, true).stream()).toList(); normalizeMasterFeeLines(fees); cargoFeeMapper.delete(Wrappers.lambdaQuery() .eq(ReceivablePayableCargoFee::getDetailId, detail.getId())); @@ -2995,7 +3011,7 @@ public class ReceivablePayableDetailServiceImpl } ContractManage contract = contractManageService.getById(detail.getContractId()); List fees = waybills.stream() - .flatMap(waybill -> calculatedFees(waybill, contract, billingPlanId).stream()).toList(); + .flatMap(waybill -> calculatedFees(waybill, contract, billingPlanId, false, true).stream()).toList(); normalizeWaybillFeeLines(fees); cargoFeeMapper.delete(Wrappers.lambdaQuery() .eq(ReceivablePayableCargoFee::getDetailId, detail.getId())); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportPlanServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportPlanServiceImpl.java index 5e0fb473..34d9d35f 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportPlanServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportPlanServiceImpl.java @@ -81,6 +81,8 @@ public class TransportPlanServiceImpl extends BaseServiceImpl selectTransportPlanPage(IPage page, TransportPlanVO transportPlan) { + // 历史手工创建未写入 dept_id,allDept=0 按组织过滤时会被排除 + backfillMissingDept(transportPlan); IPage entityPage = page(page, buildQuery(transportPlan)); IPage resultPage = TransportPlanWrapper.build().pageVO(entityPage); fillCustomerNamesFromContracts(resultPage.getRecords()); @@ -151,6 +153,12 @@ public class TransportPlanServiceImpl extends BaseServiceImpllambdaUpdate() + .set(TransportPlan::getDeptId, dept.getId()) + .set(TransportPlan::getDeptName, dept.getDeptName()) + .isNull(TransportPlan::getDeptId) + .eq(TransportPlan::getCreateDept, dept.getId()) + .eq(TransportPlan::getIsDeleted, 0)); + } + private LambdaQueryWrapper buildQuery(TransportPlanVO transportPlan) { TransportBusinessSupport.validateAllDept(transportPlan.getAllDept(), "运输计划"); LambdaQueryWrapper queryWrapper = Wrappers.lambdaQuery().eq(TransportPlan::getIsDeleted, 0); if (!Objects.equals(transportPlan.getAllDept(), 1)) { - queryWrapper.eq(TransportPlan::getDeptId, TransportBusinessSupport.currentDeptId("运输计划")); + Long currentDeptId = TransportBusinessSupport.currentDeptId("运输计划"); + // dept_id 为空的历史数据按创建组织归入当前组织 + queryWrapper.and(wrapper -> wrapper.eq(TransportPlan::getDeptId, currentDeptId) + .or(item -> item.isNull(TransportPlan::getDeptId).eq(TransportPlan::getCreateDept, currentDeptId))); } else if (Func.isNotEmpty(transportPlan.getDeptId())) { queryWrapper.eq(TransportPlan::getDeptId, transportPlan.getDeptId()); } From 604ae47b4f844c75eb81a21a120e2210866ebc0e Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Tue, 20 Oct 2026 14:43:51 +0800 Subject: [PATCH 03/55] =?UTF-8?q?:sparkles:=20=E5=AF=BC=E5=85=A5=E8=BF=90?= =?UTF-8?q?=E5=8D=95=E6=94=AF=E6=8C=81=E6=9C=AA=E7=BB=93=E7=AE=97=E6=95=B4?= =?UTF-8?q?=E6=89=B9=E5=88=A0=E9=99=A4=E5=B9=B6=E8=B0=83=E6=95=B4=E7=BB=93?= =?UTF-8?q?=E7=AE=97=E5=8D=95=E5=AF=BC=E5=85=A5=E6=A8=A1=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../pojo/vo/WaybillImportBatchVO.java | 6 + .../excel/SettlementImportTemplateExcel.java | 6 +- .../SettlementImportTemplateExporter.java | 56 +++--- .../SettlementImportBatchServiceImpl.java | 32 +--- .../impl/WaybillImportBatchServiceImpl.java | 180 +++++++++++++++++- 5 files changed, 212 insertions(+), 68 deletions(-) diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillImportBatchVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillImportBatchVO.java index 841236aa..b30c1973 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillImportBatchVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillImportBatchVO.java @@ -20,6 +20,12 @@ public class WaybillImportBatchVO extends WaybillImportBatch { private static final long serialVersionUID = 1L; private String importTypeName; private String statusName; + /** 结算状态:unsettled/partial/settled */ + @Schema(description = "结算状态") + private String settlementStatus; + /** 结算状态名称:未结算/部分结算/已结算 */ + @Schema(description = "结算状态名称") + private String settlementStatusName; private String createUserName; private String updateUserName; } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExcel.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExcel.java index 871599eb..8ed3f35a 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExcel.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExcel.java @@ -37,7 +37,7 @@ import java.io.Serializable; import java.math.BigDecimal; /** - * 导入结算单 Excel 模板(固定列 + 运输单价 + *运输费 + 示例费用项) + * 导入结算单 Excel 固定列模型(失败明细兜底导出;费用项由用户在原模板中自行增列)。 * * @author Chill */ @@ -96,10 +96,6 @@ public class SettlementImportTemplateExcel implements Serializable { private BigDecimal unitPrice; @ExcelProperty("*运输费") private BigDecimal transportFee; - @ExcelProperty("装卸费") - private BigDecimal loadingFee; - @ExcelProperty("过路费") - private BigDecimal tollFee; /** 模板已去掉结算金额列;仅失败明细内部保留,不导出为表头 */ @ExcelIgnore private BigDecimal settlementAmount; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExporter.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExporter.java index 91eb317e..cee7d476 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExporter.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExporter.java @@ -47,12 +47,12 @@ import java.io.IOException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.ArrayList; -import java.util.LinkedHashSet; import java.util.List; -import java.util.Set; /** - * 导入结算单模板导出:填写说明 + 数据表头 + 运输方式下拉 + 系统费用项列。 + * 导入结算单模板导出:填写说明 + 固定数据表头 + 运输方式下拉。 + *

+ * 模板不预置费用项列;用户可在「*运输费」与后续固定列之间自行插入系统已启用的费用项列。 * * @author Chill */ @@ -93,15 +93,18 @@ public final class SettlementImportTemplateExporter { "同一运单标识号" ); - private static final List DEFAULT_FEE_ITEMS = List.of("装卸费", "过路费"); - private SettlementImportTemplateExporter() { } public static void export(HttpServletResponse response, List transportTypeOptions, List feeItemNames) { - List headers = buildHeaders(feeItemNames); - List transportOptions = defaultTransportOptions(transportTypeOptions); + List headers = buildHeaders(); + // 运输方式由调用方传入业务字典 transport_type 的字典名称,禁止写死 + List transportOptions = transportTypeOptions == null ? List.of() : transportTypeOptions.stream() + .filter(item -> item != null && !item.isBlank()) + .map(String::trim) + .distinct() + .toList(); try (Workbook workbook = new XSSFWorkbook()) { writeInstructionSheet(workbook, transportOptions, feeItemNames); Sheet dataSheet = workbook.createSheet("导入结算单模板"); @@ -127,20 +130,9 @@ public final class SettlementImportTemplateExporter { } } - private static List buildHeaders(List feeItemNames) { + /** 固定列模板:不预置费用项列,由用户自行插入。 */ + private static List buildHeaders() { List headers = new ArrayList<>(FIXED_HEADERS_BEFORE_FEE); - Set feeNames = new LinkedHashSet<>(); - if (feeItemNames != null) { - for (String name : feeItemNames) { - if (name != null && !name.isBlank()) { - feeNames.add(name.trim()); - } - } - } - if (feeNames.isEmpty()) { - feeNames.addAll(DEFAULT_FEE_ITEMS); - } - headers.addAll(feeNames); headers.addAll(FIXED_HEADERS_AFTER_FEE); return headers; } @@ -154,18 +146,24 @@ public final class SettlementImportTemplateExporter { titleStyle.setFont(titleFont); List lines = new ArrayList<>(); lines.add("导入结算单填写说明"); - lines.add("1. 请不要修改「导入结算单模板」工作表的表头;带 * 号为必填项。"); - lines.add("2. 运输方式请从下拉项中选择,须与系统字典一致。"); + lines.add("1. 请不要修改「导入结算单模板」工作表的固定表头;带 * 号为必填项。"); + lines.add("2. 运输方式请从下拉项中选择,须与业务字典 transport_type 的字典名称一致。"); lines.add("3. 日期请填写 yyyy-MM-dd(如 2026-01-01);Excel 日期单元格也可直接选择。"); lines.add("4. 里程(km) 可为空;填写时须为正数。"); - lines.add("5. 「*运输费」之后、「*实际发货时间」之前的列为费用项,列名须与系统费用项主数据名称完全一致;金额不为 0 时若不存在或已停用则无法导入。"); + lines.add("5. 「*运输费」之后、「*实际发货时间」之前可按需自行插入费用项列,列名须与系统已启用费用项名称完全一致;金额不为 0 时若不存在或已停用则无法导入。"); lines.add("6. 模板不含结算金额列;导入后系统按「运输费 + 各费用项」自动合计结算金额。"); - lines.add("7. 当前可选运输方式:" + String.join("、", transportOptions) + "。"); + if (transportOptions != null && !transportOptions.isEmpty()) { + lines.add("7. 当前可选运输方式(业务字典 transport_type 名称):" + + String.join("、", transportOptions) + "。"); + } else { + lines.add("7. 运输方式请从业务字典 transport_type 维护的名称中选择。"); + } if (feeItemNames != null && !feeItemNames.isEmpty()) { - lines.add("8. 当前启用费用项示例:" + String.join("、", feeItemNames.stream().limit(30).toList()) + lines.add("8. 费用项请按实际需要在模板中自行增列,列名须与系统已启用费用项一致;当前系统已启用费用项:" + + String.join("、", feeItemNames.stream().limit(30).toList()) + (feeItemNames.size() > 30 ? " 等" : "") + "。"); } else { - lines.add("8. 模板默认包含装卸费、过路费列,可按系统费用项主数据增删费用列。"); + lines.add("8. 费用项请按实际需要在模板中自行增列,列名须与系统已启用的费用项主数据名称完全一致。"); } for (int index = 0; index < lines.size(); index++) { Row row = sheet.createRow(index); @@ -201,10 +199,4 @@ public final class SettlementImportTemplateExporter { return style; } - private static List defaultTransportOptions(List transportTypeOptions) { - if (transportTypeOptions != null && !transportTypeOptions.isEmpty()) { - return transportTypeOptions; - } - return List.of("公路运输", "铁路运输", "水路运输", "航空运输"); - } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementImportBatchServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementImportBatchServiceImpl.java index 6270dbc4..31f0a8b0 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementImportBatchServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementImportBatchServiceImpl.java @@ -41,7 +41,6 @@ import org.springblade.core.tool.utils.BeanUtil; import org.springblade.core.tool.utils.DateUtil; import org.springblade.core.tool.utils.Func; import org.springblade.system.cache.DictBizCache; -import org.springblade.system.cache.DictCache; import org.springblade.system.cache.UserCache; import org.springblade.system.feign.ISysClient; import org.springblade.system.pojo.entity.DictBiz; @@ -824,7 +823,7 @@ public class SettlementImportBatchServiceImpl for (Map.Entry entry : feeItems.entrySet()) { String name = normalizeFeeName(entry.getKey()); BigDecimal amount = entry.getValue() == null ? BigDecimal.ZERO : entry.getValue(); - // 模板自带的装卸费、过路费等列未填写时金额为 0,不要求费用项主数据存在同名档案 + // 用户自行增列的费用项金额为 0 时跳过校验;金额不为 0 须匹配系统已启用费用项 if (Func.isEmpty(name) || amount.compareTo(BigDecimal.ZERO) == 0) { continue; } @@ -1149,9 +1148,6 @@ public class SettlementImportBatchServiceImpl excel.setMileage(decimalValue(row, "mileage", "里程(km)", "里程")); excel.setUnitPrice(decimalValue(row, "unitPrice", "运输单价", "单价")); excel.setTransportFee(decimalValue(row, "transportFee", "*运输费", "运输费")); - Map feeItems = extractFeeItems(row, List.of("装卸费", "过路费")); - excel.setLoadingFee(feeItems.get("装卸费")); - excel.setTollFee(feeItems.get("过路费")); excel.setSettlementAmount(decimalValue(row, "settlementAmount", "结算金额")); excel.setActualStartDate(stringValue(row, "actualStartDate", "*实际发货时间", "实际发货时间")); excel.setActualEndDate(stringValue(row, "actualEndDate", "*实际完成时间", "实际完成时间")); @@ -1330,34 +1326,26 @@ public class SettlementImportBatchServiceImpl return sanitized; } + /** + * 运输方式取业务字典 transport_type 的字典名称(dictValue),不写死枚举。 + */ private List loadTransportTypeLabels() { LinkedHashSet labels = new LinkedHashSet<>(); try { List dictBizList = DictBizCache.getList("transport_type"); if (Func.isNotEmpty(dictBizList)) { dictBizList.stream() - .map(DictBiz::getDictValue) + .filter(dict -> dict != null && Func.isNotEmpty(dict.getDictValue()) + && !"-1".equals(Func.toStr(dict.getDictKey()))) + .map(dict -> dict.getDictValue().trim()) .filter(Func::isNotEmpty) - .map(String::trim) .forEach(labels::add); } - } catch (Exception ignored) { - // 业务字典不可用时继续读取系统字典 - } - try { - List dictList = DictCache.getList("transport_type"); - if (Func.isNotEmpty(dictList)) { - dictList.stream() - .map(org.springblade.system.pojo.entity.Dict::getDictValue) - .filter(Func::isNotEmpty) - .map(String::trim) - .forEach(labels::add); - } - } catch (Exception ignored) { - // 忽略 + } catch (Exception exception) { + throw new ServiceException("业务字典 transport_type 读取失败,请检查业务字典配置"); } if (labels.isEmpty()) { - return List.of("公路运输", "铁路运输", "水路运输", "航空运输"); + throw new ServiceException("业务字典 transport_type 未配置运输方式,请先维护字典名称"); } return new ArrayList<>(labels); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java index debfa16d..61604120 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java @@ -30,7 +30,9 @@ import org.springblade.transport.excel.WaybillImportBatchExcel; import org.springblade.transport.mapper.WaybillImportBatchMapper; import org.springblade.transport.pojo.dto.WaybillImportBatchRequest; import org.springblade.transport.pojo.entity.CustomerArchive; +import org.springblade.transport.pojo.entity.LoadingManage; import org.springblade.transport.pojo.entity.ProjectApply; +import org.springblade.transport.pojo.entity.ReceivablePayableDetail; import org.springblade.transport.pojo.entity.TransportPlan; import org.springblade.transport.pojo.entity.Waybill; import org.springblade.transport.pojo.entity.WaybillImportBatch; @@ -76,6 +78,13 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl entityPage = page(page, queryWrapper); List records = entityPage.getRecords().stream().map(this::toVO).toList(); + fillSettlementStatus(records); Page resultPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal()); resultPage.setRecords(records); return resultPage; @@ -389,18 +399,33 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl batches = listByIds(idList).stream() .filter(batch -> !Objects.equals(batch.getIsDeleted(), 1)) .toList(); - // 与前端一致:仅草稿可删;选中含已完成/进行中时整批拒绝 - boolean hasNonDraft = batches.stream() - .anyMatch(batch -> !STATUS_DRAFT.equals(batch.getImportStatus())); - if (hasNonDraft) { - throw new ServiceException("已完成运单不可删除"); + if (Func.isEmpty(batches)) { + throw new ServiceException("运单批次不存在或已删除"); + } + Map> waybillsByBatchId = listWaybillsByBatchIds( + batches.stream().map(WaybillImportBatch::getId).toList()); + List allWaybillIds = waybillsByBatchId.values().stream() + .flatMap(List::stream) + .map(Waybill::getId) + .filter(Objects::nonNull) + .toList(); + Set settledWaybillIds = receivablePayableDetailService.settlementLinkedWaybillIds(allWaybillIds); + // 仅未结算批次可整批删除;选中含部分结算/已结算时整批拒绝 + for (WaybillImportBatch batch : batches) { + List waybills = waybillsByBatchId.getOrDefault(batch.getId(), List.of()); + String settlementStatus = resolveSettlementStatus(waybills, settledWaybillIds); + if (!SETTLEMENT_UNSETTLED.equals(settlementStatus)) { + throw new ServiceException("仅未结算的运单批次可以删除"); + } } BusinessRemoveResultVO result = new BusinessRemoveResultVO(); for (WaybillImportBatch batch : batches) { - List waybills = waybillService.list(Wrappers.lambdaQuery() - .eq(Waybill::getImportBatchId, batch.getId()).eq(Waybill::getIsDeleted, 0)); - if (Func.isNotEmpty(waybills)) { - waybillService.deleteLogic(waybills.stream().map(Waybill::getId).toList()); + List waybills = waybillsByBatchId.getOrDefault(batch.getId(), List.of()); + List waybillIds = waybills.stream().map(Waybill::getId).filter(Objects::nonNull).toList(); + if (Func.isNotEmpty(waybillIds)) { + clearUnsettledReceivableDetails(waybillIds); + clearImportedLoadings(waybills); + waybillService.deleteLogic(waybillIds); } deleteLogic(List.of(batch.getId())); result.setSuccessCount(result.getSuccessCount() + 1); @@ -671,11 +696,148 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl records) { + if (Func.isEmpty(records)) { + return; + } + List batchIds = records.stream().map(WaybillImportBatchVO::getId).filter(Objects::nonNull).toList(); + Map> waybillsByBatchId = listWaybillsByBatchIds(batchIds); + List allWaybillIds = waybillsByBatchId.values().stream() + .flatMap(List::stream) + .map(Waybill::getId) + .filter(Objects::nonNull) + .toList(); + Set settledWaybillIds = receivablePayableDetailService.settlementLinkedWaybillIds(allWaybillIds); + for (WaybillImportBatchVO record : records) { + List waybills = waybillsByBatchId.getOrDefault(record.getId(), List.of()); + String settlementStatus = resolveSettlementStatus(waybills, settledWaybillIds); + record.setSettlementStatus(settlementStatus); + record.setSettlementStatusName(settlementStatusName(settlementStatus)); + } + } + + private Map> listWaybillsByBatchIds(List batchIds) { + if (Func.isEmpty(batchIds)) { + return Map.of(); + } + List waybills = waybillService.list(Wrappers.lambdaQuery() + .select(Waybill::getId, Waybill::getImportBatchId, Waybill::getLoadingNo) + .in(Waybill::getImportBatchId, batchIds) + .eq(Waybill::getIsDeleted, 0)); + return waybills.stream() + .filter(waybill -> waybill.getImportBatchId() != null) + .collect(Collectors.groupingBy(Waybill::getImportBatchId)); + } + + private String resolveSettlementStatus(List waybills, Set settledWaybillIds) { + if (Func.isEmpty(waybills)) { + return SETTLEMENT_UNSETTLED; + } + int settledCount = 0; + for (Waybill waybill : waybills) { + if (waybill.getId() != null && settledWaybillIds.contains(waybill.getId())) { + settledCount++; + } + } + if (settledCount <= 0) { + return SETTLEMENT_UNSETTLED; + } + if (settledCount >= waybills.size()) { + return SETTLEMENT_SETTLED; + } + return SETTLEMENT_PARTIAL; + } + + private String settlementStatusName(String settlementStatus) { + if (SETTLEMENT_SETTLED.equals(settlementStatus)) { + return "已结算"; + } + if (SETTLEMENT_PARTIAL.equals(settlementStatus)) { + return "部分结算"; + } + return "未结算"; + } + + /** 删除批次前清理尚未进入预/正式结算的应收应付明细,避免残留待结算数据。 */ + private void clearUnsettledReceivableDetails(List waybillIds) { + if (Func.isEmpty(waybillIds)) { + return; + } + List details = receivablePayableDetailService.list( + Wrappers.lambdaQuery() + .in(ReceivablePayableDetail::getWaybillId, waybillIds) + .eq(ReceivablePayableDetail::getIsDeleted, 0) + .and(wrapper -> wrapper.isNull(ReceivablePayableDetail::getPreSettlementNo) + .or().eq(ReceivablePayableDetail::getPreSettlementNo, "")) + .and(wrapper -> wrapper.isNull(ReceivablePayableDetail::getFormalSettlementNo) + .or().eq(ReceivablePayableDetail::getFormalSettlementNo, ""))); + if (Func.isNotEmpty(details)) { + receivablePayableDetailService.deleteLogic( + details.stream().map(ReceivablePayableDetail::getId).toList()); + } + } + + /** 清理本批次运单关联的批量导入配载单(配载单运单均属本批时整单删除)。 */ + private void clearImportedLoadings(List waybills) { + if (Func.isEmpty(waybills)) { + return; + } + Set batchWaybillIds = waybills.stream() + .map(Waybill::getId) + .filter(Objects::nonNull) + .collect(Collectors.toCollection(HashSet::new)); + Set loadingNos = waybills.stream() + .map(Waybill::getLoadingNo) + .filter(Func::isNotEmpty) + .collect(Collectors.toCollection(HashSet::new)); + if (Func.isEmpty(loadingNos)) { + return; + } + List loadings = loadingManageService.list(Wrappers.lambdaQuery() + .in(LoadingManage::getLoadingNo, loadingNos) + .eq(LoadingManage::getDataSource, LOADING_DATA_SOURCE_IMPORT) + .eq(LoadingManage::getIsDeleted, 0)); + List deleteLoadingIds = new ArrayList<>(); + for (LoadingManage loading : loadings) { + List loadingWaybillIds = parseLoadingWaybillIds(loading.getWaybillIdsJson()); + if (Func.isEmpty(loadingWaybillIds) || batchWaybillIds.containsAll(loadingWaybillIds)) { + deleteLoadingIds.add(loading.getId()); + } + } + if (Func.isNotEmpty(deleteLoadingIds)) { + loadingManageService.deleteLogic(deleteLoadingIds); + } + } + + private List parseLoadingWaybillIds(String waybillIdsJson) { + if (Func.isEmpty(waybillIdsJson)) { + return List.of(); + } + try { + Object parsed = JsonUtil.parse(waybillIdsJson, List.class); + if (parsed instanceof List list) { + return list.stream() + .filter(Objects::nonNull) + .map(item -> Long.valueOf(String.valueOf(item))) + .toList(); + } + } catch (Exception ignored) { + // 兼容历史逗号分隔存储 + } + return Func.toLongList(waybillIdsJson); + } + @Override public String nextBatchNo() { return nextCode(); From 21ed3e842fe8153040e9ed117f02920cdbe2a2e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Tue, 20 Oct 2026 18:48:00 +0800 Subject: [PATCH 04/55] =?UTF-8?q?fix:=20HCS=20=E5=90=88=E8=A7=84=E5=9B=9E?= =?UTF-8?q?=E8=B0=83=20Mock=20=E7=AB=AF=E7=82=B9,=E4=B8=B4=E6=97=B6?= =?UTF-8?q?=E8=B0=83=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/HcsMockController.java | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 blade-service/blade-transport/src/main/java/org/springblade/transport/controller/HcsMockController.java diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/HcsMockController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/HcsMockController.java new file mode 100644 index 00000000..247f0e87 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/HcsMockController.java @@ -0,0 +1,123 @@ +/** + * 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. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.controller; + +import cn.hutool.core.util.StrUtil; +import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.tenant.annotation.TenantIgnore; +import org.springblade.core.tool.api.R; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; +import java.util.Objects; + +/** + * HCS 合规回调 Mock 端点 + *

+ * 合同法务审批结果回调(D103)、付款法务审批结果回调(D104)、项目金蝶同步结果回调(D902)、 + * 合同金蝶同步结果回传接收(D903)四个入站回调的纯 mock 实现:仅校验来源系统标识后返回固定结果, + * 不读写业务数据、不联动状态。真实处理逻辑保留在对应 Support 中,联调时把端点切回 + * {@link } 并调用 Support 方法即可。 + * + * @author Chill + */ +@Slf4j +@RestController +@TenantIgnore +@RequestMapping("hcs") +@Tag(name = "HCS合规回调Mock", description = "HCS合规回调Mock端点(仅自测,返回固定 mock 数据)") +public class HcsMockController { + + /** + * 来源系统标识(合规方约定值,与 third-party.hcs.src-system 默认值一致) + */ + private static final String SOURCE_SYSTEM = "WLTMS"; + + /** + * 合同法务审批结果回调(D103) + */ + @PostMapping("/contractLegal/result") + @ApiOperationSupport(order = 15) + @Operation(summary = "合同法务审批结果回调Mock", description = "D103 合规推送合同特批(金蝶同步)结果,仅校验来源系统并返回 mock 数据") + public R contractLegalResult(@RequestBody(required = false) Map param) { + this.checkSourceSystem("合同法务审批结果", param); + return R.data("mock特批结果已处理:approved"); + } + + /** + * 付款法务审批结果回调(D104) + */ + @PostMapping("/paymentLegal/result") + @ApiOperationSupport(order = 16) + @Operation(summary = "付款法务审批结果回调Mock", description = "D104 合规推送付款特批结果,仅校验来源系统并返回 mock 数据") + public R paymentLegalResult(@RequestBody(required = false) Map param) { + this.checkSourceSystem("付款法务审批结果", param); + return R.data("mock付款特批结果接收已处理:approved"); + } + + /** + * 项目金蝶同步结果回调(D902) + */ + @PostMapping("/projectLegal/result") + @ApiOperationSupport(order = 18) + @Operation(summary = "项目金蝶同步结果回调Mock", description = "D902 合规推送项目金蝶同步结果,仅校验来源系统并返回 mock 数据") + public R projectLegalResult(@RequestBody(required = false) Map param) { + this.checkSourceSystem("项目金蝶同步结果", param); + return R.data("mock项目金蝶同步结果接收已处理:approved"); + } + + /** + * 合同金蝶同步结果回传接收(D903) + */ + @PostMapping("/contractKingdee/result") + @ApiOperationSupport(order = 19) + @Operation(summary = "合同金蝶同步结果回传接收Mock", description = "D903 合规推送合同金蝶最终同步结果,仅校验来源系统并返回 mock 数据") + public R contractKingdeeResult(@RequestBody(required = false) Map param) { + this.checkSourceSystem("合同金蝶同步结果", param); + return R.data("mock合同金蝶同步结果接收处理:approved"); + } + + /** + * 来源系统校验:仅接受合规方约定的来源标识,防伪造回传 + * + * @param bizName 业务名称(日志用) + * @param param 回调报文 + */ + private void checkSourceSystem(String bizName, Map param) { + String sourceSystem = param == null ? null : Objects.toString(param.get("sourceSystem"), null); + if (!StrUtil.equalsIgnoreCase(sourceSystem, SOURCE_SYSTEM)) { + log.warn("{}推送来源系统标识错误:sourceSystem={}", bizName, sourceSystem); + throw new ServiceException("来源系统标识错误"); + } + } +} From 0208d6612ad23032bedc3dd58b9d8d73124f1f7a Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Wed, 21 Oct 2026 13:57:32 +0800 Subject: [PATCH 05/55] =?UTF-8?q?:bug:=20OA=E9=83=A8=E9=97=A8=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E5=90=8D=E7=A7=B0=E6=94=B9=E7=94=A8departmentmark?= =?UTF-8?q?=EF=BC=8C=E5=90=8D=E7=A7=B0=E4=B8=8D=E4=B8=80=E8=87=B4=E6=97=B6?= =?UTF-8?q?=E5=BC=BA=E5=88=B6=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../springblade/system/convert/DeptConvert.java | 4 ++-- .../system/service/impl/OASyncServiceImpl.java | 17 +++++++++++++---- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/convert/DeptConvert.java b/blade-service/blade-system/src/main/java/org/springblade/system/convert/DeptConvert.java index ba5bdf43..dc045868 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/convert/DeptConvert.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/convert/DeptConvert.java @@ -49,8 +49,8 @@ public interface DeptConvert { @Mapping(source = "modified", target = "oaModified") Dept baseConvert(OACompanyResponse company); - @Mapping(source = "departmentname", target = "deptName") - @Mapping(source = "departmentmark", target = "fullName") + @Mapping(source = "departmentmark", target = "deptName") + @Mapping(source = "departmentname", target = "fullName") @Mapping(source = "supdepid", target = "parentCode") @Mapping(source = "subcompanyid1", target = "belongCompanyCode") @Mapping(source = "created", target = "createTime", dateFormat = "yyyy-MM-dd HH:mm:ss") diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OASyncServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OASyncServiceImpl.java index e133b6c5..90588373 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OASyncServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OASyncServiceImpl.java @@ -761,7 +761,7 @@ public class OASyncServiceImpl implements IOASyncService { .filter(oaDept -> syncAll || rootCompanyId == null || !rootCompanyId.equals(oaDept.getSubcompanyid1()) - || (OAConstant.ROOT_COMPANY_ID.equals(oaDept.getSupdepid()) && rootCompanyDeptNames.contains(oaDept.getDepartmentname()))) + || (OAConstant.ROOT_COMPANY_ID.equals(oaDept.getSupdepid()) && rootCompanyDeptNames.contains(oaDept.getDepartmentmark()))) .map(dept -> deptConvert.dept2dept(dept, companyByOaId)) .toList(); int filteredSkipCount = oaDepts.size() - allParam.size(); @@ -921,8 +921,9 @@ public class OASyncServiceImpl implements IOASyncService { .eq(Dept::getDeptCategory, deptCategory.getCode()) .isNotNull(Dept::getOaId) .ne(Dept::getOaId, "") - .select(Dept::getId, Dept::getDeptCode, Dept::getOaModified, Dept::getParentId, - Dept::getOaId, Dept::getBelongCompanyCode, Dept::getParentCode, Dept::getOaSupSubComId)); + .select(Dept::getId, Dept::getDeptCode, Dept::getDeptName, Dept::getFullName, + Dept::getOaModified, Dept::getParentId, Dept::getOaId, Dept::getBelongCompanyCode, + Dept::getParentCode, Dept::getOaSupSubComId)); Map byOaId = new HashMap<>(); for (Dept dept : deptList) { if (StringUtils.isNotBlank(dept.getOaId())) { @@ -1047,7 +1048,7 @@ public class OASyncServiceImpl implements IOASyncService { /** * oaModified 一致则跳过更新;任一侧为空无法确认一致,执行更新以补齐字段。 - * 组织编码已按新规则重算、或父子挂载与库中不一致时,即使 modified 相同也要更新。 + * 组织编码已按新规则重算、父子挂载不一致、或名称字段与当前映射不一致时,即使 modified 相同也要更新。 */ private boolean shouldSkipOaUpdate(Dept existing, Dept incoming) { if (existing == null) { @@ -1066,6 +1067,14 @@ public class OASyncServiceImpl implements IOASyncService { StringUtils.trimToEmpty(incoming.getParentCode()))) { return false; } + if (incoming != null && !StringUtils.equals(StringUtils.trimToEmpty(existing.getDeptName()), + StringUtils.trimToEmpty(incoming.getDeptName()))) { + return false; + } + if (incoming != null && !StringUtils.equals(StringUtils.trimToEmpty(existing.getFullName()), + StringUtils.trimToEmpty(incoming.getFullName()))) { + return false; + } String localModified = StringUtils.trimToEmpty(existing.getOaModified()); String remoteModified = StringUtils.trimToEmpty(incoming == null ? null : incoming.getOaModified()); if (localModified.isEmpty() || remoteModified.isEmpty()) { From 89bfeddb121124397a401d05b5eaf33989920038 Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Wed, 21 Oct 2026 15:36:34 +0800 Subject: [PATCH 06/55] =?UTF-8?q?:sparkles:=20OA=E4=BA=BA=E5=91=98?= =?UTF-8?q?=E6=97=A0=E7=99=BB=E5=BD=95=E5=90=8D=E6=97=B6=E6=8C=89=E5=B7=A5?= =?UTF-8?q?=E5=8F=B7=E5=85=A5=E5=BA=93=EF=BC=8C=E5=85=A8=E9=87=8F=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E5=AF=B9=E9=BD=90=E5=BD=93=E5=89=8D=E9=83=A8=E9=97=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../system/pojo/vo/OaPersonSyncPageVO.java | 3 + .../system/mapper/UserDeptMapper.xml | 2 +- .../service/impl/OASyncServiceImpl.java | 24 +- .../service/impl/OaUserListSyncHelper.java | 480 ++++++++++++++++-- 4 files changed, 456 insertions(+), 53 deletions(-) diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaPersonSyncPageVO.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaPersonSyncPageVO.java index d92f413b..2e7e75f3 100644 --- a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaPersonSyncPageVO.java +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaPersonSyncPageVO.java @@ -61,6 +61,9 @@ public class OaPersonSyncPageVO implements Serializable { @Schema(description = "本页跳过条数") private Integer skippedCount; + @Schema(description = "全量同步结束时删除的、不在本次 OA 数据中的 is_oa=2 人员数") + private Integer removedCount; + @Schema(description = "是否已到最后一页") private Boolean finished; diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserDeptMapper.xml b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserDeptMapper.xml index 76c059bf..24b2d01f 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserDeptMapper.xml +++ b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserDeptMapper.xml @@ -13,7 +13,7 @@ bud.user_id from blade_user_dept bud - left join blade_dept bd on bd.dept_code = bud.dept_code + left join blade_dept bd on bd.id = bud.dept_id and bd.is_deleted = 0 where bd.id is not null and bud.is_deleted = 0 diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OASyncServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OASyncServiceImpl.java index 90588373..7978f376 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OASyncServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OASyncServiceImpl.java @@ -277,6 +277,11 @@ public class OASyncServiceImpl implements IOASyncService { }, 10000, ComposeLogUtil.getLastLog()::info).accept(oaPersons::addAll); OaUserListSyncHelper.OaOrgIndex orgIndex = oaUserListSyncHelper.syncOrgsFromPersons(oaPersons); OaUserListSyncHelper.PersonSyncCount personSyncCount = oaUserListSyncHelper.handlePerson(oaPersons, orgIndex); + if (startTime == null) { + oaUserListSyncHelper.removeOaUsersAbsentFrom( + oaUserListSyncHelper.extractOaLoginIds(oaPersons), + oaUserListSyncHelper.extractWorkcodes(oaPersons)); + } CacheUtil.clear(USER_CACHE); CacheUtil.clear(SYS_CACHE); CacheUtil.clear(SYS_CACHE, Boolean.FALSE); @@ -295,9 +300,10 @@ public class OASyncServiceImpl implements IOASyncService { private OaPersonSyncPageVO syncPersonFromOaPage(int current, int size, boolean incremental, String modifiedSince) { int pageNo = current < 1 ? 1 : current; int pageSize = size < 1 ? 20 : Math.min(size, 200); - // 全量同步首页开启原始数据收集,结束后按父级 id 写入 JSON + // 全量同步首页开启原始数据收集,并开始累积登录名,最后一页再删除本地多余人员 if (!incremental && pageNo == 1) { oaSyncRawDumpHelper.beginFullDump(); + oaUserListSyncHelper.beginFullSyncReconcile(); } IncrementalStart incrementalStart = resolvePersonIncrementalStart(incremental, modifiedSince); OAPersonSearch personSearch = buildPersonSearch(incrementalStart.getStartTime()); @@ -314,9 +320,17 @@ public class OASyncServiceImpl implements IOASyncService { long totalSize = responseData.getTotalSize() == null ? 0L : responseData.getTotalSize(); if (!incremental) { oaSyncRawDumpHelper.collectPersons(oaPersons); + oaUserListSyncHelper.collectFullSyncPersons(oaPersons); } OaUserListSyncHelper.OaOrgIndex orgIndex = oaUserListSyncHelper.syncOrgsFromPersons(oaPersons); OaUserListSyncHelper.PersonSyncCount personSyncCount = oaUserListSyncHelper.handlePerson(oaPersons, orgIndex); + boolean finished = oaPersons.isEmpty() + || oaPersons.size() < pageSize + || (long) pageNo * pageSize >= totalSize; + int removedCount = personSyncCount.getRemovedCount(); + if (!incremental && finished) { + removedCount += oaUserListSyncHelper.finishFullSyncReconcile(); + } CacheUtil.clear(USER_CACHE); CacheUtil.clear(SYS_CACHE); CacheUtil.clear(SYS_CACHE, Boolean.FALSE); @@ -327,17 +341,15 @@ public class OASyncServiceImpl implements IOASyncService { pageVO.setFetchedCount(oaPersons.size()); pageVO.setSyncedCount(personSyncCount.getSyncedCount()); pageVO.setSkippedCount(personSyncCount.getSkippedCount()); + pageVO.setRemovedCount(removedCount); pageVO.setModifiedSince(incrementalStart.getModifiedSince()); - boolean finished = oaPersons.isEmpty() - || oaPersons.size() < pageSize - || (long) pageNo * pageSize >= totalSize; pageVO.setFinished(finished); if (!incremental && finished) { pageVO.setDumpFilePath(oaSyncRawDumpHelper.finishFullDump()); } - ComposeLogUtil.getLastLog().info("OA人员分页同步完成 mode={} {}/{},成功{},跳过{}", + ComposeLogUtil.getLastLog().info("OA人员分页同步完成 mode={} {}/{},成功{},跳过{},删除{}", incremental ? "incremental" : "full", pageNo, totalSize, - personSyncCount.getSyncedCount(), personSyncCount.getSkippedCount()); + personSyncCount.getSyncedCount(), personSyncCount.getSkippedCount(), removedCount); return pageVO; } 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 46f8af2b..f7382827 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 @@ -30,6 +30,7 @@ import com.baomidou.mybatisplus.core.incrementer.DefaultIdentifierGenerator; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; +import org.springblade.common.constant.CommonConstant; import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.constant.BladeConstant; import org.springblade.core.tool.utils.DigestUtil; @@ -55,10 +56,12 @@ import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; import static org.springblade.common.constant.CommonConstant.DEFAULT_PARAM_PASSWORD; @@ -76,6 +79,22 @@ public class OaUserListSyncHelper { private static final int MAX_DEPT_ID_LENGTH = 2000; + private static final int OA_LOGIN_ID_BATCH_SIZE = 500; + + /** + * OA 人员状态:5 离职。离职人员不入库,已有 is_oa=2 账号直接删除。 + */ + private static final String OA_PERSON_STATUS_RESIGNED = "5"; + + /** + * 全量同步跨页累积的 OA 登录名。分页接口每页独立事务,删除必须等最后一页再执行。 + */ + private final Set fullSyncLoginIds = ConcurrentHashMap.newKeySet(); + + private final Set fullSyncWorkcodes = ConcurrentHashMap.newKeySet(); + + private volatile boolean fullSyncCollecting; + private final IDeptService deptService; private final IUserService userService; private final UserConvert userConvert; @@ -390,48 +409,325 @@ public class OaUserListSyncHelper { } /** - * 同步人员并绑定到三级部门 + * 全量同步从第一页开始收集 OA 登录名,最后一页再删除本地多余的 is_oa=2 人员。 + */ + public synchronized void beginFullSyncReconcile() { + this.fullSyncLoginIds.clear(); + this.fullSyncWorkcodes.clear(); + this.fullSyncCollecting = true; + } + + /** + * 累积本页 OA 人员登录名,供全量同步结束后比对删除。 + * + * @param oaPersons 本页 OA 人员 + */ + public synchronized void collectFullSyncPersons(List oaPersons) { + if (!this.fullSyncCollecting || CollectionUtil.isEmpty(oaPersons)) { + return; + } + this.fullSyncLoginIds.addAll(this.extractOaLoginIds(oaPersons)); + this.fullSyncWorkcodes.addAll(this.extractWorkcodes(oaPersons)); + } + + /** + * 全量同步结束后,删除登录名不在本次 OA 数据中的 is_oa=2 人员。 + * + * @return 删除人数;未开始收集或登录名集合为空时返回 0,避免误删全部 OA 人员 + */ + public synchronized int finishFullSyncReconcile() { + if (!this.fullSyncCollecting) { + return 0; + } + this.fullSyncCollecting = false; + Set oaLoginIds = new LinkedHashSet<>(this.fullSyncLoginIds); + Set workcodes = new LinkedHashSet<>(this.fullSyncWorkcodes); + this.fullSyncLoginIds.clear(); + this.fullSyncWorkcodes.clear(); + return this.removeOaUsersAbsentFrom(oaLoginIds, workcodes); + } + + /** + * 提取仍在职的 OA 人员登录名。离职人员不计入,全量同步结束时会删掉对应本地账号。 + * + * @param oaPersons OA人员 + * @return 去重后的登录名 + */ + public Set extractOaLoginIds(List oaPersons) { + Set oaLoginIds = new LinkedHashSet<>(); + if (CollectionUtil.isEmpty(oaPersons)) { + return oaLoginIds; + } + for (OAPersonResponse oaPerson : oaPersons) { + String oaLoginId = this.resolveOaLoginId(oaPerson); + if (oaLoginId != null && !this.isOaResigned(oaPerson)) { + oaLoginIds.add(oaLoginId); + } + } + return oaLoginIds; + } + + /** + * 提取仍在职人员的工号。没有登录名时靠工号保留本地账号。 + * + * @param oaPersons OA人员 + * @return 去重后的工号 + */ + public Set extractWorkcodes(List oaPersons) { + Set workcodes = new LinkedHashSet<>(); + if (CollectionUtil.isEmpty(oaPersons)) { + return workcodes; + } + for (OAPersonResponse oaPerson : oaPersons) { + String workcode = this.resolveWorkcode(oaPerson); + if (workcode != null && !this.isOaResigned(oaPerson)) { + workcodes.add(workcode); + } + } + return workcodes; + } + + /** + * 删除当前租户中 is_oa=2、且既不在本次登录名里、也不是无登录名工号人员的账号。 + * 登录名和工号都没收集到时不删除。当前登录账号即使不在同步数据中也保留。 + * + * @param oaLoginIds 本次全量同步出现过的 OA 登录名 + * @param workcodes 本次全量同步出现过的工号 + * @return 删除人数 + */ + public int removeOaUsersAbsentFrom(Set oaLoginIds, Set workcodes) { + boolean noLoginIds = CollectionUtil.isEmpty(oaLoginIds); + boolean noWorkcodes = CollectionUtil.isEmpty(workcodes); + if (noLoginIds && noWorkcodes) { + ComposeLogUtil.getLastLog().warn("全量同步未收集到OA登录名和工号,跳过删除本地 is_oa=2 人员"); + return 0; + } + Set loginIdSet = noLoginIds ? Set.of() : oaLoginIds; + Set workcodeSet = noWorkcodes ? Set.of() : workcodes; + String tenantId = this.resolveTenantId(); + List oaUsers = this.userService.list(Wrappers.lambdaQuery() + .select(User::getId, User::getOaLoginId, User::getCode) + .eq(User::getTenantId, tenantId) + .eq(User::getIsOa, CommonConstant.YES)); + Long currentUserId = AuthUtil.getUserId(); + List removeIds = new ArrayList<>(); + for (User oaUser : oaUsers) { + if (oaUser.getId() == null) { + continue; + } + if (currentUserId != null && currentUserId > 0 && currentUserId.equals(oaUser.getId())) { + ComposeLogUtil.getLastLog().warn("全量同步跳过删除当前登录账号 userId={}", oaUser.getId()); + continue; + } + String oaLoginId = StringUtils.trimToNull(oaUser.getOaLoginId()); + String workcode = StringUtils.trimToNull(oaUser.getCode()); + boolean keptByLoginId = oaLoginId != null && loginIdSet.contains(oaLoginId); + boolean keptByWorkcode = oaLoginId == null && workcode != null && workcodeSet.contains(workcode); + if (!keptByLoginId && !keptByWorkcode) { + removeIds.add(oaUser.getId()); + } + } + if (removeIds.isEmpty()) { + ComposeLogUtil.getLastLog().info("全量同步无需删除 is_oa=2 人员"); + return 0; + } + return this.deleteOaUsers(removeIds, "全量同步删除不在OA数据中的 is_oa=2 人员"); + } + + /** + * 按 OA 登录名删除已有 is_oa=2 人员。用于离职人员,全量和增量同步都会执行。 + * + * @param tenantId 租户 + * @param oaLoginIds 离职人员登录名 + * @return 删除人数 + */ + private int removeOaUsersByLoginIds(String tenantId, Collection oaLoginIds) { + if (CollectionUtil.isEmpty(oaLoginIds)) { + return 0; + } + List oaLoginIdList = oaLoginIds.stream() + .filter(StringUtils::isNotBlank) + .distinct() + .toList(); + if (oaLoginIdList.isEmpty()) { + return 0; + } + Long currentUserId = AuthUtil.getUserId(); + List removeIds = new ArrayList<>(); + for (int fromIndex = 0; fromIndex < oaLoginIdList.size(); fromIndex += OA_LOGIN_ID_BATCH_SIZE) { + List oaLoginIdBatch = oaLoginIdList.subList(fromIndex, Math.min(fromIndex + OA_LOGIN_ID_BATCH_SIZE, oaLoginIdList.size())); + this.userService.list(Wrappers.lambdaQuery() + .select(User::getId, User::getOaLoginId) + .eq(User::getTenantId, tenantId) + .eq(User::getIsOa, CommonConstant.YES) + .in(User::getOaLoginId, oaLoginIdBatch)) + .forEach(oaUser -> { + if (oaUser.getId() == null) { + return; + } + if (currentUserId != null && currentUserId > 0 && currentUserId.equals(oaUser.getId())) { + ComposeLogUtil.getLastLog().warn("同步跳过删除当前登录账号 userId={}", oaUser.getId()); + return; + } + removeIds.add(oaUser.getId()); + }); + } + return this.deleteOaUsers(removeIds, "同步删除OA离职人员"); + } + + /** + * 删除没有登录名、且工号属于离职人员的 is_oa=2 账号。 + * + * @param tenantId 租户 + * @param workcodes 离职人员工号 + * @return 删除人数 + */ + private int removeOaUsersByBlankLoginWorkcodes(String tenantId, Collection workcodes) { + if (CollectionUtil.isEmpty(workcodes)) { + return 0; + } + List workcodeList = workcodes.stream() + .filter(StringUtils::isNotBlank) + .distinct() + .toList(); + if (workcodeList.isEmpty()) { + return 0; + } + Long currentUserId = AuthUtil.getUserId(); + List removeIds = new ArrayList<>(); + for (int fromIndex = 0; fromIndex < workcodeList.size(); fromIndex += OA_LOGIN_ID_BATCH_SIZE) { + List workcodeBatch = workcodeList.subList(fromIndex, Math.min(fromIndex + OA_LOGIN_ID_BATCH_SIZE, workcodeList.size())); + this.userService.list(Wrappers.lambdaQuery() + .select(User::getId, User::getOaLoginId, User::getCode) + .eq(User::getTenantId, tenantId) + .eq(User::getIsOa, CommonConstant.YES) + .in(User::getCode, workcodeBatch) + .and(wrapper -> wrapper.isNull(User::getOaLoginId).or().eq(User::getOaLoginId, ""))) + .forEach(oaUser -> { + if (oaUser.getId() == null) { + return; + } + if (currentUserId != null && currentUserId > 0 && currentUserId.equals(oaUser.getId())) { + ComposeLogUtil.getLastLog().warn("同步跳过删除当前登录账号 userId={}", oaUser.getId()); + return; + } + removeIds.add(oaUser.getId()); + }); + } + return this.deleteOaUsers(removeIds, "同步删除无登录名的OA离职人员"); + } + + /** + * 逻辑删除用户及其部门关系。 + * + * @param removeIds 用户 id + * @param logMessage 日志说明 + * @return 删除人数 + */ + private int deleteOaUsers(List removeIds, String logMessage) { + if (CollectionUtil.isEmpty(removeIds)) { + return 0; + } + for (int fromIndex = 0; fromIndex < removeIds.size(); fromIndex += OA_LOGIN_ID_BATCH_SIZE) { + List removeIdBatch = removeIds.subList(fromIndex, Math.min(fromIndex + OA_LOGIN_ID_BATCH_SIZE, removeIds.size())); + this.userService.deleteLogic(removeIdBatch); + this.userDeptService.remove(Wrappers.lambdaQuery().in(UserDept::getUserId, removeIdBatch)); + } + ComposeLogUtil.getLastLog().info("{}:{}", logMessage, removeIds.size()); + return removeIds.size(); + } + + /** + * OA 状态 5 表示离职。 + */ + private boolean isOaResigned(OAPersonResponse oaPerson) { + return oaPerson != null && OA_PERSON_STATUS_RESIGNED.equals(StringUtils.trimToEmpty(oaPerson.getStatus())); + } + + /** + * 同步人员并绑定到三级部门。有登录名按登录名更新,没有登录名但有工号则按工号更新且不回填登录名。离职人员直接删除。 * * @param oaPersons OA人员 * @param orgIndex 组织索引 - * @return 本批同步成功与跳过数量 + * @return 本批同步成功、跳过与删除数量 */ public PersonSyncCount handlePerson(List oaPersons, OaOrgIndex orgIndex) { if (CollectionUtil.isEmpty(oaPersons)) { - return new PersonSyncCount(0, 0); + return new PersonSyncCount(0, 0, 0); } Map uniquePersonMap = new LinkedHashMap<>(); + Set resignedLoginIds = new LinkedHashSet<>(); + Set resignedWorkcodes = new LinkedHashSet<>(); int skippedCount = 0; for (OAPersonResponse oaPerson : oaPersons) { - String oaLoginId = resolveOaLoginId(oaPerson); - if (StringUtils.isBlank(oaLoginId)) { + String syncKey = this.resolveSyncKey(oaPerson); + if (syncKey == null) { skippedCount++; continue; } - uniquePersonMap.put(oaLoginId, oaPerson); - } - if (uniquePersonMap.isEmpty()) { - ComposeLogUtil.getLastLog().warn("OA人员均缺少登录名,跳过人员同步"); - return new PersonSyncCount(0, skippedCount); + String oaLoginId = this.resolveOaLoginId(oaPerson); + String workcode = this.resolveWorkcode(oaPerson); + if (this.isOaResigned(oaPerson)) { + uniquePersonMap.remove(syncKey); + if (oaLoginId != null) { + resignedLoginIds.add(oaLoginId); + } else if (workcode != null) { + resignedWorkcodes.add(workcode); + } + continue; + } + if (oaLoginId != null) { + resignedLoginIds.remove(oaLoginId); + } else if (workcode != null) { + resignedWorkcodes.remove(workcode); + } + uniquePersonMap.put(syncKey, oaPerson); } String tenantId = resolveTenantId(); + int removedCount = this.removeOaUsersByLoginIds(tenantId, resignedLoginIds) + + this.removeOaUsersByBlankLoginWorkcodes(tenantId, resignedWorkcodes); + if (uniquePersonMap.isEmpty()) { + if (resignedLoginIds.isEmpty() && resignedWorkcodes.isEmpty()) { + ComposeLogUtil.getLastLog().warn("OA人员均缺少登录名和工号,跳过人员同步"); + } + return new PersonSyncCount(0, skippedCount, removedCount); + } String defaultPassword = DigestUtil.encrypt(ParamCache.getValue(DEFAULT_PARAM_PASSWORD)); - 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(); - Map existingByOaLoginId = this.loadUsersByOaLoginId(tenantId, uniquePersonMap.keySet()); + Set oaLoginIds = new LinkedHashSet<>(); + Set workcodes = new LinkedHashSet<>(); + for (OAPersonResponse oaPerson : uniquePersonMap.values()) { + String oaLoginId = this.resolveOaLoginId(oaPerson); + if (oaLoginId != null) { + oaLoginIds.add(oaLoginId); + continue; + } + String workcode = this.resolveWorkcode(oaPerson); + if (workcode != null) { + workcodes.add(workcode); + } + } + Map existingByOaLoginId = this.loadUsersByOaLoginId(tenantId, oaLoginIds); + Map existingByWorkcode = this.loadUsersByBlankLoginWorkcode(tenantId, workcodes); + List users = new ArrayList<>(); Map userMap = new HashMap<>(); Set existsUserIds = new HashSet<>(); - users.forEach(user -> { - User existingUser = existingByOaLoginId.get(user.getOaLoginId()); + for (Map.Entry entry : uniquePersonMap.entrySet()) { + OAPersonResponse oaPerson = entry.getValue(); + String oaLoginId = this.resolveOaLoginId(oaPerson); + String workcode = this.resolveWorkcode(oaPerson); + User user = this.userConvert.person2user(oaPerson, defaultPassword); + user.setTenantId(tenantId); + User existingUser = oaLoginId != null ? existingByOaLoginId.get(oaLoginId) : existingByWorkcode.get(workcode); + if (oaLoginId != null) { + user.setOaLoginId(oaLoginId); + user.setAccount(oaLoginId); + } else { + user.setOaLoginId(null); + user.setAccount(existingUser == null ? workcode : null); + } if (existingUser != null) { user.setId(existingUser.getId()); + // 全局更新策略为 not_null,置空后不会回写密码和登录名 user.setPassword(null); existsUserIds.add(existingUser.getId()); } else { @@ -442,9 +738,10 @@ public class OaUserListSyncHelper { user.setDataLevelRange(1); user.setIncludeNewCustomer(0); } - userService.encryptPhone(user); - userMap.put(user.getOaLoginId(), user.getId()); - }); + this.userService.encryptPhone(user); + users.add(user); + userMap.put(entry.getKey(), user.getId()); + } List addUsers = users.stream() .filter(user -> !existsUserIds.contains(user.getId())) @@ -460,33 +757,58 @@ public class OaUserListSyncHelper { ComposeLogUtil.getLastLog().info("批量修改用户:{}", updateUsers.size()); userService.updateBatchById(updateUsers); } - ComposeLogUtil.getLastLog().info("缺少OA登录名已跳过的人员:{}", skippedCount); + ComposeLogUtil.getLastLog().info("缺少登录名和工号已跳过的人员:{}", skippedCount); int syncedCount = addUsers.size() + updateUsers.size(); if (userMap.isEmpty()) { - return new PersonSyncCount(0, skippedCount); + return new PersonSyncCount(0, skippedCount, removedCount); } - Map userDeptMap = userDeptService.list(Wrappers.lambdaQuery() - .in(UserDept::getUserId, userMap.values()) - ).stream() + List existingUserDeptList = userDeptService.list(Wrappers.lambdaQuery() + .in(UserDept::getUserId, userMap.values())); + Map userDeptMap = existingUserDeptList.stream() .collect(Collectors.toMap(this::getUserDeptKey, UserDept::getId, (first, second) -> second)); Set existsUserDeptIds = new HashSet<>(userDeptMap.values()); - List userDeptList = oaPersons.stream() + Map currentUserDeptMap = new LinkedHashMap<>(); + oaPersons.stream() .filter(oaPerson -> { - String oaLoginId = resolveOaLoginId(oaPerson); - return StringUtils.isNotBlank(oaLoginId) && userMap.containsKey(oaLoginId); + String syncKey = this.resolveSyncKey(oaPerson); + return syncKey != null && userMap.containsKey(syncKey) && !this.isOaResigned(oaPerson); }) .map(oaPerson -> this.buildUserDept(oaPerson, userMap, orgIndex)) .filter(Objects::nonNull) + .forEach(userDept -> { + String userDeptKey = getUserDeptKey(userDept); + if (userDeptMap.containsKey(userDeptKey)) { + userDept.setId(userDeptMap.get(userDeptKey)); + } else { + UserDept duplicated = currentUserDeptMap.get(userDeptKey); + userDept.setId(duplicated == null + ? DefaultIdentifierGenerator.getInstance().nextId(null) + : duplicated.getId()); + } + currentUserDeptMap.put(userDeptKey, userDept); + }); + List userDeptList = new ArrayList<>(currentUserDeptMap.values()); + Set currentUserDeptIds = userDeptList.stream() + .map(UserDept::getId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + Set reboundUserIds = userDeptList.stream() + .map(UserDept::getUserId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + List staleUserDeptIds = existingUserDeptList.stream() + .filter(userDept -> userDept.getId() != null && reboundUserIds.contains(userDept.getUserId())) + .map(UserDept::getId) + .filter(userDeptId -> !currentUserDeptIds.contains(userDeptId)) .toList(); - userDeptList.forEach(userDept -> { - String userDeptKey = getUserDeptKey(userDept); - if (userDeptMap.containsKey(userDeptKey)) { - userDept.setId(userDeptMap.get(userDeptKey)); - } else { - userDept.setId(DefaultIdentifierGenerator.getInstance().nextId(null)); + if (CollectionUtil.isNotEmpty(staleUserDeptIds)) { + ComposeLogUtil.getLastLog().info("移除不再属于当前OA部门的人员关系:{}", staleUserDeptIds.size()); + for (int fromIndex = 0; fromIndex < staleUserDeptIds.size(); fromIndex += OA_LOGIN_ID_BATCH_SIZE) { + List staleIdBatch = staleUserDeptIds.subList(fromIndex, Math.min(fromIndex + OA_LOGIN_ID_BATCH_SIZE, staleUserDeptIds.size())); + userDeptService.removeByIds(staleIdBatch); } - }); + } List addList = userDeptList.stream() .filter(userDept -> !existsUserDeptIds.contains(userDept.getId())) .toList(); @@ -540,11 +862,11 @@ public class OaUserListSyncHelper { if (CollectionUtil.isNotEmpty(updateUserParams)) { userService.updateBatchById(updateUserParams); } - return new PersonSyncCount(syncedCount, skippedCount); + return new PersonSyncCount(syncedCount, skippedCount, removedCount); } /** - * 按 OA 登录名加载已有用户。同一登录名有多条时保留较早的一条,后续同步都更新这一条。 + * 按 OA 登录名加载已有 is_oa=2 用户。同一登录名有多条时保留较早的一条,后续同步都更新这一条。 */ private Map loadUsersByOaLoginId(String tenantId, Collection oaLoginIds) { Map existingByOaLoginId = new HashMap<>(); @@ -555,11 +877,11 @@ public class OaUserListSyncHelper { .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())); + for (int fromIndex = 0; fromIndex < oaLoginIdList.size(); fromIndex += OA_LOGIN_ID_BATCH_SIZE) { + List oaLoginIdBatch = oaLoginIdList.subList(fromIndex, Math.min(fromIndex + OA_LOGIN_ID_BATCH_SIZE, oaLoginIdList.size())); userService.list(Wrappers.lambdaQuery() .eq(User::getTenantId, tenantId) + .eq(User::getIsOa, CommonConstant.YES) .in(User::getOaLoginId, oaLoginIdBatch)) .forEach(user -> { String oaLoginId = StringUtils.trimToNull(user.getOaLoginId()); @@ -584,6 +906,39 @@ public class OaUserListSyncHelper { return existingByOaLoginId; } + /** + * 按工号加载没有登录名的 is_oa=2 用户。同一工号有多条时保留较早的一条。 + */ + private Map loadUsersByBlankLoginWorkcode(String tenantId, Collection workcodes) { + Map existingByWorkcode = new HashMap<>(); + if (CollectionUtil.isEmpty(workcodes)) { + return existingByWorkcode; + } + List workcodeList = workcodes.stream() + .filter(StringUtils::isNotBlank) + .distinct() + .toList(); + for (int fromIndex = 0; fromIndex < workcodeList.size(); fromIndex += OA_LOGIN_ID_BATCH_SIZE) { + List workcodeBatch = workcodeList.subList(fromIndex, Math.min(fromIndex + OA_LOGIN_ID_BATCH_SIZE, workcodeList.size())); + this.userService.list(Wrappers.lambdaQuery() + .eq(User::getTenantId, tenantId) + .eq(User::getIsOa, CommonConstant.YES) + .in(User::getCode, workcodeBatch) + .and(wrapper -> wrapper.isNull(User::getOaLoginId).or().eq(User::getOaLoginId, ""))) + .forEach(user -> { + String workcode = StringUtils.trimToNull(user.getCode()); + if (workcode == null) { + return; + } + User previous = existingByWorkcode.get(workcode); + if (previous == null || (user.getId() != null && previous.getId() != null && user.getId() < previous.getId())) { + existingByWorkcode.put(workcode, user); + } + }); + } + return existingByWorkcode; + } + private String resolveOaLoginId(OAPersonResponse oaPerson) { if (oaPerson == null) { return null; @@ -591,6 +946,28 @@ public class OaUserListSyncHelper { return StringUtils.trimToNull(oaPerson.getLoginid()); } + private String resolveWorkcode(OAPersonResponse oaPerson) { + if (oaPerson == null) { + return null; + } + return StringUtils.trimToNull(oaPerson.getWorkcode()); + } + + /** + * 有登录名用登录名,没有登录名但有工号用工号。两者都没有则不同步。 + */ + private String resolveSyncKey(OAPersonResponse oaPerson) { + String oaLoginId = this.resolveOaLoginId(oaPerson); + if (oaLoginId != null) { + return "L:" + oaLoginId; + } + String workcode = this.resolveWorkcode(oaPerson); + if (workcode != null) { + return "W:" + workcode; + } + return null; + } + private boolean isPersonBypassCode(String deptCode, DeptCategory deptCategory) { if (StringUtils.isBlank(deptCode)) { return true; @@ -635,6 +1012,11 @@ public class OaUserListSyncHelper { return null; } UserDept userDept = userConvert.person2userDept(oaPerson, userMap); + String syncKey = this.resolveSyncKey(oaPerson); + userDept.setUserId(syncKey == null ? null : userMap.get(syncKey)); + if (StringUtils.isBlank(this.resolveOaLoginId(oaPerson))) { + userDept.setOaLoginId(null); + } if (userDept.getUserId() == null) { return null; } @@ -684,10 +1066,12 @@ public class OaUserListSyncHelper { public static class PersonSyncCount { private final int syncedCount; private final int skippedCount; + private final int removedCount; - public PersonSyncCount(int syncedCount, int skippedCount) { + public PersonSyncCount(int syncedCount, int skippedCount, int removedCount) { this.syncedCount = syncedCount; this.skippedCount = skippedCount; + this.removedCount = removedCount; } public int getSyncedCount() { @@ -697,6 +1081,10 @@ public class OaUserListSyncHelper { public int getSkippedCount() { return skippedCount; } + + public int getRemovedCount() { + return removedCount; + } } /** From 20f40bc1ba7c4de4f7346db06ed521991239051c Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Thu, 22 Oct 2026 00:49:53 +0800 Subject: [PATCH 07/55] =?UTF-8?q?:bug:=20=E5=8C=BA=E5=88=92=E6=87=92?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E6=8E=A5=E5=8F=A3=E6=94=BE=E5=BC=80=E9=89=B4?= =?UTF-8?q?=E6=9D=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 小程序司机认证表单的住址选择(省/市/区三级)调用 lazy-tree, 小程序用户不绑定系统角色,类上的 menu=region 校验直接 401, 前端收到 401 即清登录态把用户踢回登录页。 方法级 PERMIT_ALL 覆盖类的 menu 校验,其余区划接口维持原状。 --- .../org/springblade/system/controller/RegionController.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/controller/RegionController.java b/blade-service/blade-system/src/main/java/org/springblade/system/controller/RegionController.java index 9f0ec270..e8b4df1e 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/controller/RegionController.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/controller/RegionController.java @@ -43,6 +43,7 @@ import org.springblade.core.excel.util.ExcelUtil; import org.springblade.core.mp.support.Condition; import org.springblade.core.mp.support.Query; import org.springblade.core.secure.annotation.PreAuth; +import org.springblade.core.secure.constant.AuthConstant; import org.springblade.core.tenant.annotation.NonDS; import org.springblade.core.tool.api.R; import org.springblade.core.tool.utils.DateUtil; @@ -117,6 +118,9 @@ public class RegionController extends BladeController { /** * 懒加载列表 */ + // 引用数据下拉:小程序司机认证表单的住址选择也调用本接口。小程序用户不绑定系统角色, + // 落到类上的 menu=region 校验会抛 401,前端随即清登录态把用户踢回登录页。 + @PreAuth(AuthConstant.PERMIT_ALL) @GetMapping("/lazy-tree") @Parameters({ @Parameter(name = "code", description = "区划编号", in = ParameterIn.QUERY, schema = @Schema(type = "string")), From 911393fb091fb2dfdc2962a47cec37ec3902cca2 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Thu, 22 Oct 2026 13:33:45 +0800 Subject: [PATCH 08/55] =?UTF-8?q?:sparkles:=20=E8=BF=90=E5=8D=95=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E5=BE=85=E5=9B=9E=E5=8D=95=E7=A1=AE=E8=AE=A4=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E5=B8=B8=E9=87=8F=E5=B9=B6=E7=BA=B3=E5=85=A5=E7=BB=88?= =?UTF-8?q?=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 接单归一在需要确认接单的配置下会把非终态运单改成待执行/进行中,落库后即抹掉待回单确认。 --- .../transport/support/WaybillProcessSupport.java | 5 ++++- .../support/WaybillProcessSupportTest.java | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) 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 4462c3a3..3d5ca5a7 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 @@ -49,6 +49,8 @@ public final class WaybillProcessSupport { public static final String STATUS_PENDING = "pending"; public static final String STATUS_RUNNING = "running"; + /** 司机已打完末位卡、回单需业务员确认,尚未生成应收应付 */ + public static final String STATUS_PENDING_RETURN_CONFIRM = "pending_return_confirm"; public static final String ACCEPT_PENDING = "pending"; public static final String ACCEPT_ACCEPTED = "accepted"; public static final String ACCEPT_REJECTED = "rejected"; @@ -213,7 +215,8 @@ public final class WaybillProcessSupport { || "completed".equals(businessStatus) || "cancelled".equals(businessStatus) || "waiting_dispatch".equals(businessStatus) - || "dispatching".equals(businessStatus); + || "dispatching".equals(businessStatus) + || STATUS_PENDING_RETURN_CONFIRM.equals(businessStatus); } /** 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 b09c82c4..8cd89e3c 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 @@ -334,6 +334,20 @@ class WaybillProcessSupportTest { "TRANSPORT_JSON 的回单启用且没有 confirmMode:缺字段当没配「无需确认完成」,否则会跳过本该人工确认的回单"); } + // ------------------------------------------------------------ 待回单确认 + + @Test + @DisplayName("待回单确认落库字面量为 pending_return_confirm,且接单归一不得改写它") + void pendingReturnConfirmIsTerminalStatus() { + assertEquals("pending_return_confirm", WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM, + "库里存的是这个字面量,web 与小程序也按它判断,不能改名"); + assertEquals(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM, + WaybillProcessSupport.normalizeBusinessStatus(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM, TRANSPORT_JSON), + "TRANSPORT_JSON 要求确认接单,未接单时归一结果本是 pending;运单已跑完全程,不能被改回待执行"); + assertEquals(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM, + WaybillProcessSupport.normalizeBusinessStatus(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM, TRANSPORT_JSON, "accepted")); + } + // ------------------------------------------------------------ 结束门禁 @Test From e0c65f44805ebf507824aeb495b9def78060c66c Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Thu, 22 Oct 2026 13:35:03 +0800 Subject: [PATCH 09/55] =?UTF-8?q?:sparkles:=20=E5=BE=85=E5=9B=9E=E5=8D=95?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E8=BF=90=E5=8D=95=E5=BD=92=E5=85=A5=E5=B7=B2?= =?UTF-8?q?=E7=BB=93=E6=9D=9F=EF=BC=8C=E6=89=93=E5=8D=A1=E9=97=A8=E7=A6=81?= =?UTF-8?q?=E7=BB=99=E5=87=BA=E4=B8=93=E9=97=A8=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../transport/support/WaybillProcessSupport.java | 11 ++++++++--- .../transport/support/WaybillProcessSupportTest.java | 9 ++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) 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 3d5ca5a7..aad6b3e5 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 @@ -136,14 +136,16 @@ public final class WaybillProcessSupport { } /** - * 运单已结束:已完成或已取消。此后不再接受司机打卡。 + * 运单已结束:已完成、已取消,或已打完末位卡停在待回单确认。此后不再接受司机打卡。 *

- * 只认这两个状态,大小写不敏感。草稿、待执行、待调度、调度中的运单司机仍要能打卡, + * 只认这三个状态,大小写不敏感。草稿、待执行、待调度、调度中的运单司机仍要能打卡, * 所以不能按 {@link #isTerminalBusinessStatus} 的「终态或调度中间态」口径判定。 */ public static boolean isPunchEnded(String businessStatus) { String status = stringVal(businessStatus); - return "completed".equalsIgnoreCase(status) || "cancelled".equalsIgnoreCase(status); + return "completed".equalsIgnoreCase(status) + || "cancelled".equalsIgnoreCase(status) + || STATUS_PENDING_RETURN_CONFIRM.equalsIgnoreCase(status); } /** @@ -157,6 +159,9 @@ public final class WaybillProcessSupport { if ("cancelled".equalsIgnoreCase(status)) { return "运单已取消"; } + if (STATUS_PENDING_RETURN_CONFIRM.equalsIgnoreCase(status)) { + return "运单待业务员确认回单"; + } return null; } 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 8cd89e3c..2398cf2d 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 @@ -351,12 +351,14 @@ class WaybillProcessSupportTest { // ------------------------------------------------------------ 结束门禁 @Test - @DisplayName("结束门禁:只认已完成与已取消,大小写不敏感") - void punchEndedOnlyForCompletedAndCancelled() { + @DisplayName("结束门禁:已完成、已取消与待回单确认都算结束,大小写不敏感") + void punchEndedForCompletedCancelledAndPendingReturnConfirm() { assertTrue(WaybillProcessSupport.isPunchEnded("completed")); assertTrue(WaybillProcessSupport.isPunchEnded("COMPLETED")); assertTrue(WaybillProcessSupport.isPunchEnded("cancelled")); assertTrue(WaybillProcessSupport.isPunchEnded("Cancelled")); + assertTrue(WaybillProcessSupport.isPunchEnded("pending_return_confirm"), + "司机已打完末位卡,不能再打卡"); } @Test @@ -372,11 +374,12 @@ class WaybillProcessSupportTest { } @Test - @DisplayName("结束门禁文案:完成与取消各一句,非结束态没有文案") + @DisplayName("结束门禁文案:完成、取消与待回单确认各一句,非结束态没有文案") void punchEndedReasonOnlyForEndedStatuses() { assertEquals("运单已完成", WaybillProcessSupport.punchEndedReason("completed")); assertEquals("运单已完成", WaybillProcessSupport.punchEndedReason(" COMPLETED ")); assertEquals("运单已取消", WaybillProcessSupport.punchEndedReason("cancelled")); + assertEquals("运单待业务员确认回单", WaybillProcessSupport.punchEndedReason("pending_return_confirm")); assertNull(WaybillProcessSupport.punchEndedReason(null)); assertNull(WaybillProcessSupport.punchEndedReason("")); assertNull(WaybillProcessSupport.punchEndedReason(" ")); From 3c230eafed674c39f5666e30e3ad9e659a653325 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Thu, 22 Oct 2026 13:36:01 +0800 Subject: [PATCH 10/55] =?UTF-8?q?:white=5Fcheck=5Fmark:=20=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E5=BE=85=E5=9B=9E=E5=8D=95=E7=A1=AE=E8=AE=A4=E8=BF=90?= =?UTF-8?q?=E5=8D=95=E7=9A=84=E4=B8=8A=E6=8A=A5=E5=BC=82=E5=B8=B8=E9=97=A8?= =?UTF-8?q?=E7=A6=81=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../springblade/transport/support/WaybillProcessSupport.java | 2 +- .../transport/support/WaybillProcessSupportTest.java | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) 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 aad6b3e5..3b9d920e 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 @@ -202,7 +202,7 @@ public final class WaybillProcessSupport { * 上报异常前的运单状态门禁原因;可上报时返回 null。 *

* 与打卡共用「已开始运输」判据,但文案说的是能不能报,不是能不能打。 - * 结束优先于未开始:已完成/已取消的运单先说结束,不再提「等你上路」。 + * 结束优先于未开始:已结束的运单先说结束,不再提「等你上路」。 */ public static String reportBlockedReason(String businessStatus) { String endedReason = punchEndedReason(businessStatus); 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 2398cf2d..568372be 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 @@ -472,9 +472,11 @@ class WaybillProcessSupportTest { assertNull(WaybillProcessSupport.reportBlockedReason("running")); assertEquals("运单尚未开始运输,暂不可上报异常", WaybillProcessSupport.reportBlockedReason("pending")); assertEquals("运单尚未开始运输,暂不可上报异常", WaybillProcessSupport.reportBlockedReason("dispatching")); - // 结束优先:已完成/已取消先说结束,不提「等你上路」 + // 结束优先:已结束态先说结束,不提「等你上路」 assertEquals("运单已完成,不能上报异常", WaybillProcessSupport.reportBlockedReason("completed")); assertEquals("运单已取消,不能上报异常", WaybillProcessSupport.reportBlockedReason("cancelled")); + assertEquals("运单待业务员确认回单,不能上报异常", + WaybillProcessSupport.reportBlockedReason("pending_return_confirm")); } } From 646d8513d2eb7ee2212eaa888b7a9e1d710e1a0b Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Thu, 22 Oct 2026 13:36:36 +0800 Subject: [PATCH 11/55] =?UTF-8?q?:white=5Fcheck=5Fmark:=20=E9=94=81?= =?UTF-8?q?=E5=AE=9A=E5=BE=85=E5=9B=9E=E5=8D=95=E7=A1=AE=E8=AE=A4=E8=BF=90?= =?UTF-8?q?=E5=8D=95=E7=9A=84=E3=80=8C=E6=9C=AA=E5=BC=80=E5=A7=8B=E8=BF=90?= =?UTF-8?q?=E8=BE=93=E3=80=8D=E6=96=87=E6=A1=88=E4=B8=8E=E5=9C=A8=E9=80=94?= =?UTF-8?q?=E5=8D=A1=E4=B8=8D=E5=8F=AF=E6=89=93=E5=8D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两处都由 isPunchEnded 自动覆盖,测试只锁住它们不得回到「尚未开始运输」分支。 --- .../support/WaybillProcessSupportTest.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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 568372be..9408bfd6 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 @@ -438,6 +438,22 @@ class WaybillProcessSupportTest { // 结束态由结束文案负责,两句话不能抢 assertNull(WaybillProcessSupport.punchNotStartedReason("completed")); assertNull(WaybillProcessSupport.punchNotStartedReason("cancelled")); + assertNull(WaybillProcessSupport.punchNotStartedReason("pending_return_confirm"), + "司机已打完末位卡,不能再被说成「尚未开始运输」"); + } + + @Test + @DisplayName("在途卡:待回单确认的运单与已完成同样不可打卡") + void transitCardOnPendingReturnConfirmWaybillIsVisibleButNotPunchable() { + LocalDateTime now = LocalDateTime.of(2026, 10, 20, 12, 0); + + WaybillProcessSupport.TransitCheckinDecision decision = + WaybillProcessSupport.evaluateTransitCheckin(TRANSPORT_JSON, "pending_return_confirm", null, now); + + assertTrue(decision.visible(), "卡片仍要下发,司机能回看"); + assertFalse(decision.punchEnabled(), "末位卡已打完,在途卡不能再提交"); + assertFalse(decision.doneToday()); + assertFalse(decision.dueToday()); } @Test From 5c88b90404ff294e6a43411ee169f70c188d1d4b Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Thu, 22 Oct 2026 13:42:41 +0800 Subject: [PATCH 12/55] =?UTF-8?q?:bug:=20=E4=BF=AE=E5=A4=8D=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E4=BF=9D=E5=AD=98=E7=94=A8=E8=AF=B7=E6=B1=82=E4=BD=93?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E8=BF=90=E5=8D=95=E4=B8=9A=E5=8A=A1=E7=8A=B6?= =?UTF-8?q?=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/impl/WaybillServiceImpl.java | 16 +- .../WaybillEditSaveBusinessStatusTest.java | 184 ++++++++++++++++++ 2 files changed, 198 insertions(+), 2 deletions(-) create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillEditSaveBusinessStatusTest.java diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java index 9f40356f..36ebf07f 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java @@ -1062,10 +1062,11 @@ public class WaybillServiceImpl extends BaseServiceImpl waybill.setMasterNo(null); waybill.setMileageRemark(null); } - // 新建若已带业务状态(如复制提交已完成)则在过程校正后回写,保证与原运单一致 + // 新建若已带业务状态(如复制提交已完成)则在过程校正后回写,保证与原运单一致; + // 编辑保存只改资料,business_status 以库里的值为准,请求体里的值不回写 String preservedBusinessStatus = created ? TransportBusinessSupport.trimToNull(waybill.getBusinessStatus()) - : null; + : editPreservedBusinessStatus(oldRecord); if (isCompletedBusinessStatus(preservedBusinessStatus)) { preservedBusinessStatus = "completed"; } @@ -1087,6 +1088,17 @@ public class WaybillServiceImpl extends BaseServiceImpl } } + /** + * 编辑保存要回写的业务状态:取库里的值。 + * 草稿返回 null —— submit 即正式提交,草稿须由过程校正提升为待执行 / 进行中。 + */ + private String editPreservedBusinessStatus(Waybill oldRecord) { + String currentStatus = oldRecord == null + ? null + : TransportBusinessSupport.trimToNull(oldRecord.getBusinessStatus()); + return STATUS_DRAFT.equals(currentStatus) ? null : currentStatus; + } + private void fillCustomerName(Waybill waybill) { if (Func.isNotEmpty(waybill.getContractId())) { ContractManage contract = contractManageService.getById(waybill.getContractId()); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillEditSaveBusinessStatusTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillEditSaveBusinessStatusTest.java new file mode 100644 index 00000000..3e1cf371 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillEditSaveBusinessStatusTest.java @@ -0,0 +1,184 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +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.secure.utils.AuthUtil; +import org.springblade.transport.mapper.WaybillMapper; +import org.springblade.transport.pojo.entity.Waybill; + +import java.lang.reflect.Field; +import java.math.BigDecimal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +/** + * 运单编辑保存的业务状态来源测试。 + *

+ * 编辑保存只改运单资料,business_status 一律以库里的值为准:请求体不回传(null / 空串) + * 不得把库里的待执行、进行中、已完成改写成别的状态,回传陈旧值也不得覆盖。 + * 唯一例外是草稿——submit 即正式提交,草稿须按过程配置提升为待执行 / 进行中。 + *

+ * 过程配置用「无需确认接单」的快照({@code []}):无过程校正时待执行会被提升为进行中, + * 正是编辑保存最容易把库里状态写坏的场景。 + */ +class WaybillEditSaveBusinessStatusTest { + + private static final Long WAYBILL_ID = 7001L; + private static final Long DEPT_ID = 100L; + /** 无接单节点的过程配置:normalizeBusinessStatus 会把空 / pending 提升为 running */ + private static final String PROCESS_JSON_NO_ACCEPT = "[]"; + + private WaybillMapper waybillMapper; + private WaybillServiceImpl service; + + @BeforeAll + static void initTableInfo() { + // saveOrUpdate 判主键需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Waybill.class); + } + + @BeforeEach + void setUp() throws Exception { + waybillMapper = mock(WaybillMapper.class); + service = new WaybillServiceImpl(); + inject("baseMapper", waybillMapper); + when(waybillMapper.updateById(any(Waybill.class))).thenReturn(1); + } + + // ------------------------------------------------------------ 编辑保存:以库里的状态为准 + + @Test + @DisplayName("编辑保存:请求体不回传业务状态,库里的待执行不得被提升为进行中") + void editSaveKeepsPendingWhenRequestBodyOmitsStatus() { + Waybill request = editableRequest(); + + submit(request, "pending"); + + assertEquals("pending", request.getBusinessStatus(), + "编辑保存时请求体没有 business_status,不得按过程配置把库里的待执行改写成进行中"); + } + + @Test + @DisplayName("编辑保存:请求体不回传业务状态,库里的进行中不得被改写") + void editSaveKeepsRunningWhenRequestBodyOmitsStatus() { + Waybill request = editableRequest(); + + submit(request, "running"); + + assertEquals("running", request.getBusinessStatus()); + } + + @Test + @DisplayName("编辑保存:请求体不回传业务状态,库里的已完成不得被改写") + void editSaveKeepsCompletedWhenRequestBodyOmitsStatus() { + Waybill request = editableRequest(); + + submit(request, "completed"); + + assertEquals("completed", request.getBusinessStatus()); + } + + @Test + @DisplayName("编辑保存:请求体回传陈旧状态,仍以库里的值为准") + void editSaveKeepsDatabaseStatusWhenRequestBodyCarriesStaleValue() { + Waybill request = editableRequest(); + request.setBusinessStatus("running"); + + submit(request, "completed"); + + assertEquals("completed", request.getBusinessStatus(), + "库里已完成,请求体带的是打开表单时的旧值,不得据此回退状态"); + } + + @Test + @DisplayName("编辑保存:请求体回传空串,库里的状态不得被清空") + void editSaveKeepsDatabaseStatusWhenRequestBodyIsBlank() { + Waybill request = editableRequest(); + request.setBusinessStatus(""); + + submit(request, "running"); + + assertEquals("running", request.getBusinessStatus()); + } + + // ------------------------------------------------------------ 草稿例外 + + @Test + @DisplayName("提交草稿:草稿仍按过程配置提升为进行中") + void submitDraftStillPromotesToRunning() { + Waybill request = editableRequest(); + + submit(request, "draft"); + + assertEquals("running", request.getBusinessStatus(), + "submit 即正式提交,草稿必须脱离 draft,不能被「以库里的值为准」锁死"); + } + + // ------------------------------------------------------------ 夹具 + + private Waybill editableRequest() { + Waybill waybill = new Waybill(); + waybill.setId(WAYBILL_ID); + waybill.setProjectName("项目A"); + waybill.setContractName("客户合同A"); + waybill.setCustomerName("客户A"); + waybill.setTransportType("公路运输"); + waybill.setCargoName("钢材"); + waybill.setCargoType("普货"); + waybill.setQuantityUnit("吨"); + waybill.setCarrierType("自运"); + waybill.setVehicleNo("桂A12345"); + waybill.setDriverName("张三"); + waybill.setCarrierJson("{}"); + waybill.setQuantity(BigDecimal.TEN); + waybill.setProcessJson(PROCESS_JSON_NO_ACCEPT); + return waybill; + } + + /** + * 让库里的同一条运单处于 {@code databaseStatus},再走一次编辑保存(submit)。 + * 司机与车牌保持一致,避免接单记录被判定为「换了司机」而清空。 + */ + private void submit(Waybill request, String databaseStatus) { + Waybill oldRecord = editableRequest(); + oldRecord.setBusinessStatus(databaseStatus); + oldRecord.setDeptId(DEPT_ID); + oldRecord.setDeptName("测试组织"); + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(oldRecord); + + try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { + authUtil.when(AuthUtil::getDeptId).thenReturn(DEPT_ID); + assertTrue(service.submit(request)); + } + } + + private void inject(String field, Object value) throws Exception { + Class type = service.getClass(); + while (type != null) { + try { + Field declared = type.getDeclaredField(field); + declared.setAccessible(true); + declared.set(service, value); + return; + } catch (NoSuchFieldException ignored) { + type = type.getSuperclass(); + } + } + throw new IllegalStateException("未找到字段:" + field); + } + +} From 6de80d2bb233dd41c7dfc6377ce36b4ed0eb7d4a Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Thu, 22 Oct 2026 13:44:06 +0800 Subject: [PATCH 13/55] =?UTF-8?q?:bug:=20=E5=BE=85=E5=9B=9E=E5=8D=95?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E4=B8=8B=E6=8B=92=E7=BB=9D=E8=A1=A5=E5=BD=95?= =?UTF-8?q?=E6=89=93=E5=8D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/impl/WaybillServiceImpl.java | 6 +++ .../impl/WaybillPunchRecordSaveTest.java | 54 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java index 9f40356f..1f54276c 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java @@ -531,6 +531,12 @@ public class WaybillServiceImpl extends BaseServiceImpl } // 只有显式传 false 才是取消打卡,未传按已打卡处理 boolean punched = !Boolean.FALSE.equals(request.getPunched()); + // 待回单确认的运单司机已无待打卡节点,再补一条打卡流水与状态含义矛盾; + // 漏打的出路是先取消该节点的打卡退回进行中(punched=false),再重新补录。 + if (punched && WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM + .equalsIgnoreCase(waybill.getBusinessStatus())) { + throw new ServiceException("运单待业务员确认回单,不能补录打卡"); + } if (ENROUTE_PUNCH_TYPE.equalsIgnoreCase(TransportBusinessSupport.trimToEmpty(request.getType()))) { return saveEnroutePunchRecord(waybill, request, punched); } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordSaveTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordSaveTest.java index 6b41a1ef..1778a850 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordSaveTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordSaveTest.java @@ -18,6 +18,7 @@ import org.springblade.transport.pojo.dto.WaybillPunchRecordSaveRequest; import org.springblade.transport.pojo.entity.Waybill; import org.springblade.transport.pojo.entity.WaybillEnroutePunch; import org.springblade.transport.pojo.entity.WaybillNodePunch; +import org.springblade.transport.support.WaybillProcessSupport; import java.lang.reflect.Field; import java.util.ArrayList; @@ -165,6 +166,59 @@ class WaybillPunchRecordSaveTest { verify(nodePunchMapper, never()).updateById(any(WaybillNodePunch.class)); } + @Test + @DisplayName("待回单确认:补录打卡被拒——司机已无待打卡,漏打应先取消打卡退回进行中") + void rejectBackfillOnPendingReturnConfirm() { + Waybill waybill = waybill(); + waybill.setBusinessStatus(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM); + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill); + when(nodePunchMapper.selectList(any())).thenReturn(List.of()); + + WaybillPunchRecordSaveRequest request = request("node", "load", "装货"); + request.setPunched(true); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.savePunchRecord(request)); + + assertEquals("运单待业务员确认回单,不能补录打卡", exception.getMessage()); + verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class)); + } + + @Test + @DisplayName("待回单确认:取消打卡仍放行——那是退回进行中的唯一路径") + void keepCancelPunchOnPendingReturnConfirm() { + Waybill waybill = waybill(); + waybill.setBusinessStatus(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM); + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill); + when(nodePunchMapper.selectList(any())).thenReturn(List.of(nodePunch(7001L, "load", "装货"))); + + WaybillPunchRecordSaveRequest request = request("node", "load", "装货"); + request.setPunched(false); + + assertTrue(service.savePunchRecord(request)); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Collection.class); + verify(nodePunchMapper).deleteBatchIds(captor.capture()); + assertEquals(Set.of(7001L), new HashSet<>(captor.getValue())); + } + + @Test + @DisplayName("已完成运单仍可补录——门禁只对待回单确认生效") + void keepBackfillOnCompletedWaybill() { + Waybill waybill = waybill(); + waybill.setBusinessStatus("completed"); + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill); + when(nodePunchMapper.selectList(any())).thenReturn(List.of()); + + WaybillPunchRecordSaveRequest request = request("node", "load", "装货"); + request.setPunched(true); + request.setPunchTime("2026-10-06 09:30:00"); + + assertTrue(service.savePunchRecord(request)); + + verify(nodePunchMapper).insert(any(WaybillNodePunch.class)); + } + @Test @DisplayName("接单节点:补录与取消打卡都拒绝——它的完成由运单的接单事实决定") void rejectAcceptNodePunchEdit() { From 3e60c0720bee09191e29260588e12b5385a5bb38 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Thu, 22 Oct 2026 13:46:03 +0800 Subject: [PATCH 14/55] =?UTF-8?q?:white=5Fcheck=5Fmark:=20=E9=94=81?= =?UTF-8?q?=E5=AE=9A=E6=9C=AB=E4=BD=8D=E6=89=93=E5=8D=A1=E5=9B=9E=E5=8D=95?= =?UTF-8?q?=E9=9C=80=E7=A1=AE=E8=AE=A4=E6=97=B6=E8=BF=9B=E5=85=A5=E5=BE=85?= =?UTF-8?q?=E5=9B=9E=E5=8D=95=E7=A1=AE=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../transport/pojo/vo/DriverNodePunchVO.java | 3 + .../impl/DriverWaybillAutoCompleteTest.java | 106 ++++++++++++++++-- 2 files changed, 99 insertions(+), 10 deletions(-) diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverNodePunchVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverNodePunchVO.java index 691cb573..d5d10b60 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverNodePunchVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverNodePunchVO.java @@ -52,4 +52,7 @@ public class DriverNodePunchVO implements Serializable { @Schema(description = "本次打卡是否使运单自动完成") private Boolean waybillCompleted; + @Schema(description = "本次打卡后的运单业务状态:running / pending_return_confirm / completed") + private String waybillBusinessStatus; + } 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 f2eeac76..3c1f83fe 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 @@ -7,6 +7,7 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.mockito.MockedStatic; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.secure.utils.AuthUtil; @@ -28,6 +29,7 @@ import org.springblade.transport.service.IWaybillService; import org.springblade.transport.service.ITripQueryService; import org.springblade.transport.support.CertificationGate; +import java.util.ArrayList; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -35,6 +37,7 @@ 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.atLeastOnce; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; @@ -42,11 +45,13 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** - * 司机打完末位打卡节点后运单自动完成。 + * 司机打完末位打卡节点后运单的去向。 *

- * 命中要四个条件同时成立:本次打卡的是「司机可打卡节点」的末位、该节点是到货/卸货/签收、 - * 回单无需确认完成、运单本身允许完成。任一不成立都只落打卡流水, - * 响应里的 waybillCompleted 始终取完成接口的返回值,前端据此决定是否提示「运单已完成」。 + * 末位打卡是到货/卸货/签收、运单本身允许完成时,回单是否需业务员确认决定去向: + * 无需确认则本次一并完成运单,需确认则把运单置为待回单确认等业务员动手。 + * 任一条件不成立都只落打卡流水。 + * 响应里的 waybillCompleted 始终取完成接口的返回值,前端据此决定是否提示「运单已完成」; + * 待回单确认不是已完成,只能靠 waybillBusinessStatus 分辨。 */ class DriverWaybillAutoCompleteTest { @@ -72,6 +77,12 @@ class DriverWaybillAutoCompleteTest { [{"key":"dispatch","name":"发货","type":"cargo","enabled":true,"punch":true}] """; + /** 末位司机打卡节点是「发货」,回单启用且需确认完成 */ + private static final String DISPATCH_LAST_RETURN_CONFIRM_JSON = """ + [{"key":"dispatch","name":"发货","type":"cargo","enabled":true,"punch":true}, + {"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"yes"}] + """; + private IWaybillService waybillService; private IDriverService driverService; private WaybillNodePunchMapper nodePunchMapper; @@ -118,7 +129,7 @@ class DriverWaybillAutoCompleteTest { // ------------------------------------------------------------ 命中 @Test - @DisplayName("末位签收打卡且回单无需确认时调完成接口,响应标记 waybillCompleted") + @DisplayName("末位签收打卡且回单无需确认时调完成接口,响应标记 waybillCompleted 与 completed") void lastDeliveryPunchCompletesWaybill() { givenWaybill(SIGN_LAST_JSON, "running"); when(waybillService.completeWithoutDeptCheck(WAYBILL_ID)).thenReturn(true); @@ -127,6 +138,7 @@ class DriverWaybillAutoCompleteTest { verify(waybillService).completeWithoutDeptCheck(WAYBILL_ID); assertTrue(vo.getWaybillCompleted(), "完成接口返回成功,响应要告诉前端运单已完成"); + assertEquals("completed", vo.getWaybillBusinessStatus(), "完成后运单业务状态要如实回报"); } @Test @@ -139,6 +151,25 @@ class DriverWaybillAutoCompleteTest { verify(waybillService).completeWithoutDeptCheck(WAYBILL_ID); assertFalse(vo.getWaybillCompleted(), "完成接口自己说不算完成时,前端不能提示运单已完成"); + assertEquals("running", vo.getWaybillBusinessStatus(), "完成没落库,运单仍在进行中"); + } + + // ------------------------------------------------------------ 待回单确认 + + @Test + @DisplayName("回单需业务员确认时末位打卡进入待回单确认,落库并回报,且不调完成接口") + void returnNeedingConfirmationEntersPendingReturnConfirm() { + givenWaybill(SIGN_LAST_RETURN_CONFIRM_JSON, "running"); + + DriverNodePunchVO vo = submit("sign"); + + verify(waybillService, never()).completeWithoutDeptCheck(any()); + verify(nodePunchMapper).insert(any(WaybillNodePunch.class)); + assertFalse(vo.getWaybillCompleted(), "待回单确认不是已完成"); + assertEquals("pending_return_confirm", vo.getWaybillBusinessStatus(), + "客户端要知道打卡已提交、等业务员确认回单"); + assertEquals("pending_return_confirm", writtenBusinessStatus(), + "状态要落库,否则下次查询又回到进行中"); } // ------------------------------------------------------------ 不命中 @@ -153,18 +184,31 @@ class DriverWaybillAutoCompleteTest { verify(waybillService, never()).completeWithoutDeptCheck(any()); verify(nodePunchMapper).insert(any(WaybillNodePunch.class)); assertFalse(vo.getWaybillCompleted()); + assertEquals("running", vo.getWaybillBusinessStatus()); } @Test - @DisplayName("回单启用且需确认完成时只打卡不完成") - void returnNeedingConfirmationDoesNotComplete() { + @DisplayName("回单需确认但不是末位打卡:运单留在进行中") + void nonLastPunchDoesNotEnterReturnConfirmation() { givenWaybill(SIGN_LAST_RETURN_CONFIRM_JSON, "running"); - DriverNodePunchVO vo = submit("sign"); + DriverNodePunchVO vo = submit("load"); + + verify(nodePunchMapper).insert(any(WaybillNodePunch.class)); + assertEquals("running", vo.getWaybillBusinessStatus(), "后面还有签收要打,不能提前停在待回单确认"); + assertEquals("running", writtenBusinessStatus()); + } + + @Test + @DisplayName("回单需确认但末位不是到货/卸货/签收:运单留在进行中") + void nonDeliveryLastNodeDoesNotEnterReturnConfirmation() { + givenWaybill(DISPATCH_LAST_RETURN_CONFIRM_JSON, "running"); + + DriverNodePunchVO vo = submit("dispatch"); verify(waybillService, never()).completeWithoutDeptCheck(any()); - verify(nodePunchMapper).insert(any(WaybillNodePunch.class)); - assertFalse(vo.getWaybillCompleted()); + assertEquals("running", vo.getWaybillBusinessStatus(), "发货不是到货类节点,打完不该停在等待回单"); + assertEquals("running", writtenBusinessStatus()); } // ------------------------------------------------------------ 前置:运单本身能否完成 @@ -208,8 +252,50 @@ class DriverWaybillAutoCompleteTest { assertFalse(vo.getWaybillCompleted()); } + @Test + @DisplayName("有未了结异常时回单需确认也不进待回单确认——四条进入条件共同成立才进") + void unresolvedExceptionBlocksEnteringReturnConfirmation() { + givenWaybill(SIGN_LAST_RETURN_CONFIRM_JSON, "running"); + when(exceptionDisposalMapper.selectList(any())).thenReturn(List.of(unresolvedException("unload"))); + + DriverNodePunchVO vo = submit("sign"); + + assertEquals("running", vo.getWaybillBusinessStatus(), "异常未了结,不能把运单停在等待回单"); + assertEquals("running", writtenBusinessStatus()); + } + + // ------------------------------------------------------------ 并发:只从进行中进入 + + @Test + @DisplayName("打卡期间运单已被推到终态时不再写待回单确认——只从进行中进入") + void concurrentTerminalStatusPreventsEnteringReturnConfirmation() { + givenWaybill(SIGN_LAST_RETURN_CONFIRM_JSON, "running"); + List statusesWritten = new ArrayList<>(); + // 模拟另一事务在这次打卡推进过程节点时把运单置为已完成(管理端强制完成) + when(waybillService.updateById(any())).thenAnswer(invocation -> { + Waybill entity = invocation.getArgument(0); + statusesWritten.add(entity.getBusinessStatus()); + entity.setBusinessStatus("completed"); + return true; + }); + + DriverNodePunchVO vo = submit("sign"); + + assertFalse(statusesWritten.contains("pending_return_confirm"), + "运单已被完成,不能再改写回待回单确认"); + assertEquals("completed", vo.getWaybillBusinessStatus()); + } + // ------------------------------------------------------------ 夹具 + /** 打卡流程最后一次落库时运单的业务状态;状态写库走 updateById,各次传的是同一个实体引用 */ + private String writtenBusinessStatus() { + ArgumentCaptor captor = ArgumentCaptor.forClass(Waybill.class); + verify(waybillService, atLeastOnce()).updateById(captor.capture()); + List written = captor.getAllValues(); + return written.get(written.size() - 1).getBusinessStatus(); + } + private DriverNodePunchVO submit(String nodeCode) { NodeSubmitDTO dto = new NodeSubmitDTO(); dto.setWaybillId(WAYBILL_ID); From 570331321148b922a28a672e2533520edf57efab Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Thu, 22 Oct 2026 13:47:32 +0800 Subject: [PATCH 15/55] =?UTF-8?q?:sparkles:=20=E6=9C=AB=E4=BD=8D=E6=89=93?= =?UTF-8?q?=E5=8D=A1=E5=9B=9E=E5=8D=95=E9=9C=80=E7=A1=AE=E8=AE=A4=E6=97=B6?= =?UTF-8?q?=E8=BF=9B=E5=85=A5=E5=BE=85=E5=9B=9E=E5=8D=95=E7=A1=AE=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/DriverWaybillServiceImpl.java | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) 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 ae825828..9590a442 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 @@ -520,19 +520,32 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { nodePunchMapper.insert(punch); advanceCurrentProcessNode(waybill, nodeCfg); - // 末位打卡节点是到货/卸货/签收、且回单无需确认完成时,本次打卡一并把运单置为完成; + // 末位打卡节点是到货/卸货/签收、且运单本身允许完成时,本次打卡就是运单的去向决定点: + // 回单无需确认 → 一并完成;回单需业务员确认 → 停在待回单确认等业务员确认。 // isLastDriverPunchNode 与 returnNeedsNoConfirmation 传的就是上面那份 processJson,未再调 resolveProcessJson 取过程配置。 String nodeKey = WaybillProcessSupport.nodeKey(nodeCfg); String nodeName = WaybillProcessSupport.nodeName(nodeCfg); + String waybillBusinessStatus = waybill.getBusinessStatus(); boolean completed = false; if (WaybillProcessSupport.isLastDriverPunchNode(processJson, nodeKey) && WaybillProcessSupport.isDeliveryNode(nodeKey, nodeName) - && WaybillProcessSupport.returnNeedsNoConfirmation(processJson) && canAutoCompleteWaybill(waybill)) { - completed = waybillService.completeWithoutDeptCheck(waybill.getId()); + if (WaybillProcessSupport.returnNeedsNoConfirmation(processJson)) { + completed = waybillService.completeWithoutDeptCheck(waybill.getId()); + if (completed) { + waybillBusinessStatus = STATUS_COMPLETED; + } + } else if (WaybillProcessSupport.STATUS_RUNNING.equals(waybill.getBusinessStatus())) { + // 只有进行中能进入:canAutoCompleteWaybill 的状态口径含待执行/待调度等,不能替代这一条; + // 待回单确认在 isPunchEnded 里算已结束,不能从结束态重进。 + waybill.setBusinessStatus(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM); + waybillService.updateById(waybill); + waybillBusinessStatus = WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM; + } } DriverNodePunchVO vo = toNodePunchVO(punch); vo.setWaybillCompleted(completed); + vo.setWaybillBusinessStatus(waybillBusinessStatus); return vo; } From 6412769ced6de4d27a905162a20c0fcf67fedc5f Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Thu, 22 Oct 2026 13:49:19 +0800 Subject: [PATCH 16/55] =?UTF-8?q?:white=5Fcheck=5Fmark:=20=E8=A1=A5?= =?UTF-8?q?=E5=85=85=E5=BE=85=E5=9B=9E=E5=8D=95=E7=A1=AE=E8=AE=A4=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E4=B8=8D=E8=A2=AB=E7=BC=96=E8=BE=91=E4=BF=9D=E5=AD=98?= =?UTF-8?q?=E6=94=B9=E5=86=99=E7=9A=84=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/WaybillEditSaveBusinessStatusTest.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillEditSaveBusinessStatusTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillEditSaveBusinessStatusTest.java index 3e1cf371..626d8886 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillEditSaveBusinessStatusTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillEditSaveBusinessStatusTest.java @@ -11,6 +11,7 @@ import org.mockito.MockedStatic; import org.springblade.core.secure.utils.AuthUtil; import org.springblade.transport.mapper.WaybillMapper; import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.support.WaybillProcessSupport; import java.lang.reflect.Field; import java.math.BigDecimal; @@ -115,6 +116,19 @@ class WaybillEditSaveBusinessStatusTest { assertEquals("running", request.getBusinessStatus()); } + // ------------------------------------------------------------ 新增状态 + + @Test + @DisplayName("编辑保存:待回单确认的运单不得被编辑保存改成进行中") + void editSaveKeepsPendingReturnConfirm() { + Waybill request = editableRequest(); + + submit(request, WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM); + + assertEquals(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM, request.getBusinessStatus(), + "司机已打完末位卡,编辑一次运单资料不得把它退回进行中"); + } + // ------------------------------------------------------------ 草稿例外 @Test From 5f53a8671d158fc9ddb146d5b0bee5872d493654 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Thu, 22 Oct 2026 13:49:55 +0800 Subject: [PATCH 17/55] =?UTF-8?q?:fire:=20=E5=88=A0=E9=99=A4=E5=8F=B8?= =?UTF-8?q?=E6=9C=BA=E7=AB=AF=E5=AE=8C=E6=88=90=E8=BF=90=E5=8D=95=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DriverWaybillController 去掉 POST /complete - IDriverWaybillService/DriverWaybillServiceImpl 去掉 complete 及其独占的 assertNoUnresolvedException - 末位打卡自动完成仍用 COMPLETABLE_STATUSES 与 canAutoCompleteWaybill - 新增 WaybillCompleteEndpointTest:司机端完成端点已 404,管理端完成接口仍在 --- .../controller/DriverWaybillController.java | 7 -- .../service/IDriverWaybillService.java | 5 -- .../impl/DriverWaybillServiceImpl.java | 32 +-------- .../WaybillCompleteEndpointTest.java | 71 +++++++++++++++++++ 4 files changed, 72 insertions(+), 43 deletions(-) create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/controller/WaybillCompleteEndpointTest.java diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverWaybillController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverWaybillController.java index bb9b4693..dfba67e8 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverWaybillController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverWaybillController.java @@ -201,13 +201,6 @@ public class DriverWaybillController extends BladeController { return R.data(driverWaybillService.submitNode(dto)); } - @PostMapping("/complete") - @ApiOperationSupport(order = 10) - @Operation(summary = "司机完成运单", description = "校验司机归属后改状态为已完成,并检查生成应收应付明细(与管理端一致)") - public R complete(@Parameter(description = "运单ID", required = true) @RequestParam Long id) { - return R.status(driverWaybillService.complete(id)); - } - /** 前端可能传空字符串表示「全部」 */ private Integer parseStatus(String status) { if (Func.isEmpty(status)) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java index 9ad5f543..0cc4d89b 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java @@ -157,11 +157,6 @@ public interface IDriverWaybillService { */ DriverNodePunchVO submitNode(NodeSubmitDTO dto); - /** - * 司机完成运单:校验归属后改状态为已完成,并走与管理端相同的应收应付明细生成逻辑。 - */ - boolean complete(Long id); - /** * 按运单与节点 key 取该运单实际生效的过程节点配置;节点不存在时返回 null。 *

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 ae825828..cec6c927 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 @@ -611,24 +611,6 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { return waybillService.updateById(waybill); } - @Override - @Transactional(rollbackFor = Exception.class) - public boolean complete(Long id) { - if (id == null) { - throw new ServiceException("运单ID不能为空"); - } - requireCurrentDriver(); - Waybill waybill = loadDriverWaybill(id); - waybill = normalizeAcceptStatus(waybill); - if (!COMPLETABLE_STATUSES.contains(waybill.getBusinessStatus())) { - throw new ServiceException("当前运单状态不允许完成"); - } - // 有未了结异常时不允许完成——完成接口不要求节点全部打卡,不挡住就能绕过打卡门禁 - assertNoUnresolvedException(waybill.getId()); - // 与管理端完成逻辑一致:改状态 + 生成应收应付明细 + 尝试完成配载单 - return waybillService.completeWithoutDeptCheck(waybill.getId()); - } - private void assertAcceptable(Waybill waybill) { if (WaybillProcessSupport.isTerminalBusinessStatus(waybill.getBusinessStatus())) { throw new ServiceException("当前运单状态不允许接单"); @@ -1519,7 +1501,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } /** - * 自动完成的前置:与司机端「完成运单」同一套——业务状态允许完成,且没有未了结异常。 + * 自动完成的前置:业务状态允许完成,且没有未了结异常。 *

* 不满足返回 false,由调用方静默跳过,打卡不受影响。 */ @@ -1528,18 +1510,6 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { && unresolvedExceptionNodeCodeList(waybill.getId()).isEmpty(); } - /** - * 断言该运单没有未了结的异常。 - *

- * 完成运单前必须校验:完成接口本身不要求节点全部打卡,不挡住它就能从 - * 「完成运单」绕过打卡门禁。 - */ - private void assertNoUnresolvedException(Long waybillId) { - if (!unresolvedExceptionNodeCodeList(waybillId).isEmpty()) { - throw new ServiceException("该运单存在未处理异常,处理完成后才能完成运单"); - } - } - /** * 组装过程配置 punch=是 的打卡节点列表。 *

diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/controller/WaybillCompleteEndpointTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/controller/WaybillCompleteEndpointTest.java new file mode 100644 index 00000000..fae4e73d --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/controller/WaybillCompleteEndpointTest.java @@ -0,0 +1,71 @@ +package org.springblade.transport.controller; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springblade.transport.service.IContractManageService; +import org.springblade.transport.service.ICustomerArchiveService; +import org.springblade.transport.service.IDriverWaybillService; +import org.springblade.transport.service.IProjectApplyService; +import org.springblade.transport.service.ITransportPlanService; +import org.springblade.transport.service.IWaybillImportBatchService; +import org.springblade.transport.service.IWaybillService; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 运单「完成运单」接口的边界:司机端不再有完成入口,管理端强制完成仍可达。 + *

+ * 走真实 HTTP 路由:司机端那条链删没删干净由 404 说话,管理端保持可用由 200 + 落到 + * {@code waybillService.complete} 说话。 + */ +class WaybillCompleteEndpointTest { + + private IWaybillService waybillService; + private MockMvc driverMvc; + private MockMvc manageMvc; + + @BeforeEach + void setUp() { + waybillService = mock(IWaybillService.class); + DriverWaybillController driverController = new DriverWaybillController(mock(IDriverWaybillService.class)); + driverMvc = MockMvcBuilders.standaloneSetup(driverController).build(); + WaybillController manageController = new WaybillController( + waybillService, + mock(IProjectApplyService.class), + mock(IContractManageService.class), + mock(ICustomerArchiveService.class), + mock(ITransportPlanService.class), + mock(IWaybillImportBatchService.class)); + manageMvc = MockMvcBuilders.standaloneSetup(manageController).build(); + } + + @Test + @DisplayName("POST /blade-transport/waybill/complete:司机端完成运单路由已下线") + void driverCompleteRouteIsGone() throws Exception { + driverMvc.perform(post("/blade-transport/waybill/complete").param("id", "6001")) + .andExpect(status().isNotFound()); + driverMvc.perform(post("/waybill/complete").param("id", "6001")) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("POST /waybill-manage/complete:管理端强制完成仍可达") + void manageCompleteRouteIsKept() throws Exception { + when(waybillService.complete(6001L)).thenReturn(true); + + manageMvc.perform(post("/waybill-manage/complete").param("id", "6001")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + + verify(waybillService).complete(6001L); + } + +} From f0872c78821ac62fbf933a29efbf45eb0cff6344 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Thu, 22 Oct 2026 13:53:21 +0800 Subject: [PATCH 18/55] =?UTF-8?q?:white=5Fcheck=5Fmark:=20=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E5=9B=9E=E5=8D=95=E6=94=B9=E7=9C=8B=E8=BF=90=E5=8D=95?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E3=80=81=E5=8F=96=E6=B6=88=E6=89=93=E5=8D=A1?= =?UTF-8?q?=E5=9B=9E=E9=80=80=E4=B8=8E=E5=8F=96=E6=B6=88=E8=BF=90=E5=8D=95?= =?UTF-8?q?=E7=9A=84=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/WaybillConfirmReturnTest.java | 100 +++++++++++++++--- .../impl/WaybillPunchRecordListTest.java | 14 +++ .../impl/WaybillPunchRecordSaveTest.java | 57 ++++++++++ 3 files changed, 157 insertions(+), 14 deletions(-) diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillConfirmReturnTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillConfirmReturnTest.java index 3cfe106f..b753a03b 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillConfirmReturnTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillConfirmReturnTest.java @@ -23,6 +23,7 @@ import org.springblade.transport.pojo.entity.WaybillNodePunch; import org.springblade.transport.service.ILoadingManageService; import org.springblade.transport.service.IProcessConfigService; import org.springblade.transport.service.IReceivablePayableDetailService; +import org.springblade.transport.support.WaybillProcessSupport; import java.lang.reflect.Field; import java.util.List; @@ -37,15 +38,17 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** - * 确认回单:回单节点要求业务员确认时,内部人员确认后把运单置为已完成。 + * 确认回单:停在待回单确认的运单,内部人员确认后把运单置为已完成。 *

- * 完整入口不校验节点打卡,故这里必须自己挡住两件事——该运单的司机打卡节点(在途除外)全部已打卡、 - * 无未了结异常;不挡住就成了绕过打卡门禁与异常门禁的通道。回单不要求确认时不能走这个入口。 + * 完整入口不校验节点打卡,故这里必须自己挡住三件事——运单确实停在待回单确认、该运单的司机打卡节点 + * (在途除外)全部已打卡、无未了结异常;不挡住就成了绕过打卡门禁与异常门禁的通道。 + * 判据只看运单状态,不看回单配置当前值。 */ class WaybillConfirmReturnTest { private static final Long WAYBILL_ID = 5701L; private static final String DEPT_ID = "1123598813738675201"; + private static final String PENDING_RETURN_CONFIRM = WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM; private static final String RETURN_NEED_CONFIRM_JSON = """ [{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true}, @@ -118,9 +121,9 @@ class WaybillConfirmReturnTest { } @Test - @DisplayName("回单要求确认且节点都打完:置为已完成,并照常生成应收应付、尝试完成配载单") + @DisplayName("待回单确认且节点都打完:置为已完成,并照常生成应收应付、尝试完成配载单") void confirmsReturnAndCompletesWaybill() { - stubWaybill(RETURN_NEED_CONFIRM_JSON); + stubWaybill(RETURN_NEED_CONFIRM_JSON, PENDING_RETURN_CONFIRM); stubPunches("load", "sign"); boolean result = service.confirmReturnReceipt(WAYBILL_ID); @@ -135,16 +138,57 @@ class WaybillConfirmReturnTest { @Test @DisplayName("在途节点没打卡也放行:它不是必经一步") void transitNodeDoesNotBlockConfirmation() { - stubWaybill(WITH_TRANSIT_JSON); + stubWaybill(WITH_TRANSIT_JSON, PENDING_RETURN_CONFIRM); stubPunches("load", "sign"); assertEquals(true, service.confirmReturnReceipt(WAYBILL_ID)); } + @Test + @DisplayName("进行中的运单不能确认回单:状态校验先于打卡校验") + void runningWaybillCannotConfirmReturn() { + stubWaybill(RETURN_NEED_CONFIRM_JSON, "running"); + // 故意一条打卡都不补:若状态校验排在打卡校验之后,这里报的会是缺哪个节点 + stubPunches(); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.confirmReturnReceipt(WAYBILL_ID)); + + assertEquals("当前状态不允许确认回单", exception.getMessage()); + verify(waybillMapper, never()).updateById(any(Waybill.class)); + verify(receivablePayableDetailService, never()).generateForCompletedWaybills(any()); + } + + @Test + @DisplayName("已完成的运单不能确认回单") + void completedWaybillCannotConfirmReturn() { + stubWaybill(RETURN_NEED_CONFIRM_JSON, "completed"); + stubPunches("load", "sign"); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.confirmReturnReceipt(WAYBILL_ID)); + + assertEquals("当前状态不允许确认回单", exception.getMessage()); + verify(waybillMapper, never()).updateById(any(Waybill.class)); + } + + @Test + @DisplayName("待执行的运单不能确认回单") + void pendingWaybillCannotConfirmReturn() { + stubWaybill(RETURN_NEED_CONFIRM_JSON, "pending"); + stubPunches("load", "sign"); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.confirmReturnReceipt(WAYBILL_ID)); + + assertEquals("当前状态不允许确认回单", exception.getMessage()); + verify(waybillMapper, never()).updateById(any(Waybill.class)); + } + @Test @DisplayName("还有节点没打卡:拒绝并点名缺哪个节点,不改状态") void unfinishedPunchNodeBlocksConfirmation() { - stubWaybill(RETURN_NEED_CONFIRM_JSON); + stubWaybill(RETURN_NEED_CONFIRM_JSON, PENDING_RETURN_CONFIRM); stubPunches("load"); ServiceException exception = assertThrows(ServiceException.class, @@ -157,7 +201,7 @@ class WaybillConfirmReturnTest { @Test @DisplayName("有未了结异常:拒绝,不改状态") void unresolvedExceptionBlocksConfirmation() { - stubWaybill(RETURN_NEED_CONFIRM_JSON); + stubWaybill(RETURN_NEED_CONFIRM_JSON, PENDING_RETURN_CONFIRM); stubPunches("load", "sign"); when(exceptionDisposalMapper.selectCount(any())).thenReturn(1L); @@ -169,31 +213,59 @@ class WaybillConfirmReturnTest { } @Test - @DisplayName("回单不要求业务员确认:这个入口不适用,说清而不是静默完成") - void returnWithoutConfirmationIsRejected() { - stubWaybill(RETURN_NO_CONFIRM_JSON); + @DisplayName("回单配置已改成无需确认、运单停在待回单确认:仍能确认回单") + void confirmStillWorksAfterConfigSwitchedToNoConfirm() { + stubWaybill(RETURN_NO_CONFIRM_JSON, PENDING_RETURN_CONFIRM); + stubPunches("load", "sign"); + + assertEquals(true, service.confirmReturnReceipt(WAYBILL_ID)); + + verify(waybillMapper).updateById(org.mockito.ArgumentMatchers.argThat(waybill -> + "completed".equals(waybill.getBusinessStatus()))); + verify(receivablePayableDetailService).generateForCompletedWaybills(List.of(WAYBILL_ID)); + } + + @Test + @DisplayName("回单配置无需确认且运单还在进行中:这个入口不适用,报状态不符") + void noConfirmConfigRunningWaybillCannotConfirmReturn() { + stubWaybill(RETURN_NO_CONFIRM_JSON, "running"); stubPunches("load", "sign"); ServiceException exception = assertThrows(ServiceException.class, () -> service.confirmReturnReceipt(WAYBILL_ID)); - assertEquals("该运单的回单不需要业务员确认", exception.getMessage()); + assertEquals("当前状态不允许确认回单", exception.getMessage()); verify(waybillMapper, never()).updateById(any(Waybill.class)); } + @Test + @DisplayName("待回单确认可以取消:置为已取消,不生成应收应付") + void cancelPendingReturnConfirmWaybill() { + // 取消接口不允许已配载的运单 + stubWaybill(RETURN_NEED_CONFIRM_JSON, PENDING_RETURN_CONFIRM).setLoadingNo(null); + + assertEquals(true, service.cancel(WAYBILL_ID)); + + verify(waybillMapper).updateById(org.mockito.ArgumentMatchers.argThat(waybill -> + "cancelled".equals(waybill.getBusinessStatus()) && WAYBILL_ID.equals(waybill.getId()))); + verify(receivablePayableDetailService, never()).generateForCompletedWaybills(any()); + verify(loadingManageService, never()).completeIfAllWaybillsCompleted(any()); + } + // ------------------------------------------------------------ 夹具 - private void stubWaybill(String processJson) { + private Waybill stubWaybill(String processJson, String businessStatus) { Waybill waybill = new Waybill(); waybill.setId(WAYBILL_ID); waybill.setWaybillNo("YD202610180002"); waybill.setDeptId(Long.valueOf(DEPT_ID)); waybill.setProjectId(7001L); waybill.setProcessJson(processJson); - waybill.setBusinessStatus("running"); + waybill.setBusinessStatus(businessStatus); waybill.setLoadingNo("PZ-20261018-001"); waybill.setIsDeleted(0); when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill); + return waybill; } private void stubPunches(String... nodeCodes) { diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordListTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordListTest.java index c61dd1ec..6f055f13 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordListTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordListTest.java @@ -14,6 +14,7 @@ import org.springblade.transport.pojo.entity.Waybill; import org.springblade.transport.pojo.entity.WaybillEnroutePunch; import org.springblade.transport.pojo.entity.WaybillNodePunch; import org.springblade.transport.pojo.vo.WaybillPunchRecordItemVO; +import org.springblade.transport.support.WaybillProcessSupport; import java.lang.reflect.Field; import java.text.ParseException; @@ -126,6 +127,19 @@ class WaybillPunchRecordListTest { assertEquals("", accept.getPunchTime(), "没有接单动作就没有完成时刻,不得拿别的字段编一个"); } + @Test + @DisplayName("待回单确认的无需确认接单运单:接单节点已打卡,但没有完成时刻") + void acceptNodePunchedWhenPendingReturnConfirm() { + stubWaybill(waybill(NO_CONFIRM_ACCEPT_JSON, + WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM, null)); + + WaybillPunchRecordItemVO accept = acceptRecord(); + + assertTrue(accept.getPunched()); + assertEquals("已打卡", accept.getStatusName()); + assertEquals("", accept.getPunchTime()); + } + @Test @DisplayName("无需确认接单但仍是草稿:接单节点未打卡") void acceptNodeUnpunchedWhenDraft() { diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordSaveTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordSaveTest.java index 1778a850..fc7b0f38 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordSaveTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordSaveTest.java @@ -68,6 +68,7 @@ class WaybillPunchRecordSaveTest { assistant.setCurrentNamespace("test"); TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class); TableInfoHelper.initTableInfo(assistant, WaybillEnroutePunch.class); + TableInfoHelper.initTableInfo(assistant, Waybill.class); } @BeforeEach @@ -79,6 +80,7 @@ class WaybillPunchRecordSaveTest { inject("baseMapper", waybillMapper); inject("waybillNodePunchMapper", nodePunchMapper); inject("waybillEnroutePunchMapper", enroutePunchMapper); + when(waybillMapper.updateById(any(Waybill.class))).thenReturn(1); } // ------------------------------------------------------------ 过程节点打卡 @@ -202,6 +204,61 @@ class WaybillPunchRecordSaveTest { assertEquals(Set.of(7001L), new HashSet<>(captor.getValue())); } + @Test + @DisplayName("待回单确认:取消打卡后退回进行中——进入该状态的前提已被撤掉") + void revertToRunningWhenCancelPunchOnPendingReturnConfirm() { + Waybill waybill = waybill(); + waybill.setBusinessStatus(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM); + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill); + when(nodePunchMapper.selectList(any())).thenReturn(List.of(nodePunch(7001L, "load", "装货"))); + + WaybillPunchRecordSaveRequest request = request("node", "load", "装货"); + request.setPunched(false); + + assertTrue(service.savePunchRecord(request)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Waybill.class); + verify(waybillMapper).updateById(captor.capture()); + assertEquals(WaybillProcessSupport.STATUS_RUNNING, captor.getValue().getBusinessStatus()); + assertEquals(WAYBILL_ID, captor.getValue().getId()); + } + + @Test + @DisplayName("进行中:取消打卡不动运单状态") + void keepStatusWhenCancelPunchOnRunningWaybill() { + Waybill waybill = waybill(); + waybill.setBusinessStatus(WaybillProcessSupport.STATUS_RUNNING); + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill); + when(nodePunchMapper.selectList(any())).thenReturn(List.of(nodePunch(7001L, "load", "装货"))); + + WaybillPunchRecordSaveRequest request = request("node", "load", "装货"); + request.setPunched(false); + + assertTrue(service.savePunchRecord(request)); + + verify(waybillMapper, never()).updateById(any(Waybill.class)); + } + + @Test + @DisplayName("待回单确认:取消在途打卡同样退回进行中") + void revertToRunningWhenCancelEnroutePunchOnPendingReturnConfirm() { + Waybill waybill = waybill(); + waybill.setBusinessStatus(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM); + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill); + WaybillEnroutePunch existed = new WaybillEnroutePunch(); + existed.setId(8001L); + when(enroutePunchMapper.selectList(any())).thenReturn(List.of(existed)); + + WaybillPunchRecordSaveRequest request = request("enroute", "transit", "在途"); + request.setPunched(false); + + assertTrue(service.savePunchRecord(request)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Waybill.class); + verify(waybillMapper).updateById(captor.capture()); + assertEquals(WaybillProcessSupport.STATUS_RUNNING, captor.getValue().getBusinessStatus()); + } + @Test @DisplayName("已完成运单仍可补录——门禁只对待回单确认生效") void keepBackfillOnCompletedWaybill() { From 5d84bf1fffe4b8696d344d428abdc1a04f84c4b6 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Thu, 22 Oct 2026 13:55:10 +0800 Subject: [PATCH 19/55] =?UTF-8?q?:sparkles:=20=E7=A1=AE=E8=AE=A4=E5=9B=9E?= =?UTF-8?q?=E5=8D=95=E6=94=B9=E7=9C=8B=E8=BF=90=E5=8D=95=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E5=B9=B6=E6=94=AF=E6=8C=81=E5=8F=96=E6=B6=88=E6=89=93=E5=8D=A1?= =?UTF-8?q?=E5=9B=9E=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/impl/WaybillServiceImpl.java | 38 ++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java index b934d41a..a3ca5791 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java @@ -538,9 +538,26 @@ public class WaybillServiceImpl extends BaseServiceImpl throw new ServiceException("运单待业务员确认回单,不能补录打卡"); } if (ENROUTE_PUNCH_TYPE.equalsIgnoreCase(TransportBusinessSupport.trimToEmpty(request.getType()))) { - return saveEnroutePunchRecord(waybill, request, punched); + return revertIfReturnConfirmPreconditionLost(waybill, punched, + saveEnroutePunchRecord(waybill, request, punched)); } - return saveNodePunchRecord(waybill, request, punched); + return revertIfReturnConfirmPreconditionLost(waybill, punched, + saveNodePunchRecord(waybill, request, punched)); + } + + /** + * 取消打卡撤掉了「司机已无待打卡节点」这个前提,停在待回单确认的运单退回进行中。 + *

+ * 只认取消打卡后的待回单确认,其它状态一律不动。 + */ + private boolean revertIfReturnConfirmPreconditionLost(Waybill waybill, boolean punched, boolean saved) { + if (!saved || punched + || !WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM + .equalsIgnoreCase(waybill.getBusinessStatus())) { + return saved; + } + waybill.setBusinessStatus(WaybillProcessSupport.STATUS_RUNNING); + return updateById(waybill); } /** @@ -968,13 +985,16 @@ public class WaybillServiceImpl extends BaseServiceImpl /** * 运单是否已越过接单阶段。 *

- * 正向列举进行中/已完成/已取消,不能用「不是终态」取反:终态里还包含草稿、待调度、 + * 正向列举进行中/已完成/已取消/待回单确认,不能用「不是终态」取反:终态里还包含草稿、待调度、 * 调度中这些并未越过接单的阶段,取反会把它们判成已接单。 + *

+ * 待回单确认下司机已打完末位卡,无需接单配置时接单节点不该再显示未打卡。 */ private static boolean passedAcceptStage(String businessStatus) { return "running".equals(businessStatus) || "completed".equals(businessStatus) - || "cancelled".equals(businessStatus); + || "cancelled".equals(businessStatus) + || WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM.equals(businessStatus); } private String formatPunchTime(Date time) { @@ -1525,14 +1545,14 @@ public class WaybillServiceImpl extends BaseServiceImpl @Transactional(rollbackFor = Exception.class) public boolean confirmReturnReceipt(Long id) { Waybill waybill = loadEditable(id, true); + // 判据只看运单当前状态,不看回单配置当前值:配置改回「无需确认」后, + // 已停在待回单确认的运单仍要能确认回单 + if (!WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM.equalsIgnoreCase(waybill.getBusinessStatus())) { + throw new ServiceException("当前状态不允许确认回单"); + } String processJson = LiveProcessConfigSupport.resolve( LiveProcessConfigSupport.byProject(processConfigService, waybill.getProjectId()), waybill.getProcessJson()); - if (WaybillProcessSupport.returnNeedsNoConfirmation(processJson)) { - // 不要求确认时司机打完末位卡就已经完成了,运单不会停在进行中等确认;配置对不上要说清, - // 否则用户会以为是确认没生效 - throw new ServiceException("该运单的回单不需要业务员确认"); - } assertPunchNodesFinished(waybill.getId(), processJson); assertNoUnresolvedException(waybill.getId()); return doComplete(waybill); From 47fdff55a22b8a7e902d143bf8e37a9286fe2436 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Thu, 22 Oct 2026 13:57:58 +0800 Subject: [PATCH 20/55] =?UTF-8?q?:memo:=20completeWithoutDeptCheck=20?= =?UTF-8?q?=E6=B3=A8=E9=87=8A=E6=94=B9=E4=B8=BA=E6=8F=8F=E8=BF=B0=E7=8E=B0?= =?UTF-8?q?=E7=8A=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/springblade/transport/service/IWaybillService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillService.java index d43242fd..e87bc79a 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillService.java @@ -108,7 +108,7 @@ public interface IWaybillService extends BaseService { boolean complete(Long id); /** - * 司机端完成运单:跳过管理端部门校验,其余逻辑与 {@link #complete(Long)} 一致 + * 末位打卡自动完成:跳过管理端部门校验,其余逻辑与 {@link #complete(Long)} 一致 * (改状态 + 生成应收应付明细 + 尝试完成配载单)。 */ boolean completeWithoutDeptCheck(Long id); From 654a7776b720bb7da009223f210bbd6673ff2355 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Thu, 22 Oct 2026 14:05:42 +0800 Subject: [PATCH 21/55] =?UTF-8?q?:white=5Fcheck=5Fmark:=20=E5=BE=85?= =?UTF-8?q?=E5=9B=9E=E5=8D=95=E7=A1=AE=E8=AE=A4=EF=BC=9A=E8=A1=A5=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E5=88=86=E6=A1=A3=E3=80=81=E6=95=B0=E5=AD=97=E6=80=81?= =?UTF-8?q?=E3=80=81=E4=B8=A4=E5=A5=97=E5=90=8D=E7=A7=B0=E8=A1=A8=E4=B8=8E?= =?UTF-8?q?=E5=B7=B2=E5=88=B0=E8=BE=BE=E5=8F=A3=E5=BE=84=E7=9A=84=E7=BA=A2?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/DriverWaybillTabStatusTest.java | 176 ++++++++++++++++++ .../service/impl/MasterOrderProgressTest.java | 150 +++++++++++++++ .../service/impl/TripAggregationTest.java | 17 ++ .../impl/WaybillBusinessStatusNameTest.java | 65 +++++++ 4 files changed, 408 insertions(+) create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillTabStatusTest.java create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/MasterOrderProgressTest.java create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillBusinessStatusNameTest.java diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillTabStatusTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillTabStatusTest.java new file mode 100644 index 00000000..802564b0 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillTabStatusTest.java @@ -0,0 +1,176 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.system.feign.IMpUserClient; +import org.springblade.transport.mapper.ExceptionDisposalMapper; +import org.springblade.transport.mapper.WaybillEnroutePunchMapper; +import org.springblade.transport.mapper.WaybillNodePunchMapper; +import org.springblade.transport.pojo.entity.Driver; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.entity.WaybillNodePunch; +import org.springblade.transport.pojo.vo.DriverWaybillCardVO; +import org.springblade.transport.pojo.vo.DriverWaybillTabCountsVO; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.IProcessConfigService; +import org.springblade.transport.service.ITransportVehicleService; +import org.springblade.transport.service.ITripQueryService; +import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.support.CertificationGate; +import org.springblade.transport.support.WaybillProcessSupport; + +import java.util.Collection; +import java.util.List; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 司机端四个 Tab 的状态分档。 + *

+ * 待回单确认是「等业务员动手」的进行中:数字态沿用 1(进行中),查询口径也必须认它。 + * 漏掉的话司机打完末位卡,这张单从他的列表里彻底消失——不是展示问题,是单子不见了。 + */ +class DriverWaybillTabStatusTest { + + private static final Long WAYBILL_ID = 5901L; + private static final Long USER_ID = 7001L; + private static final String PLATE = "桂AAW3256"; + private static final String PHONE = "13800138002"; + + private IWaybillService waybillService; + private IDriverService driverService; + private WaybillNodePunchMapper nodePunchMapper; + private ExceptionDisposalMapper exceptionDisposalMapper; + private DriverWaybillServiceImpl service; + + @BeforeAll + static void initTableInfo() { + // 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Waybill.class); + TableInfoHelper.initTableInfo(assistant, Driver.class); + TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class); + } + + @BeforeEach + void setUp() { + waybillService = mock(IWaybillService.class); + driverService = mock(IDriverService.class); + nodePunchMapper = mock(WaybillNodePunchMapper.class); + exceptionDisposalMapper = mock(ExceptionDisposalMapper.class); + service = new DriverWaybillServiceImpl( + waybillService, + driverService, + mock(IMpUserClient.class), + mock(IProcessConfigService.class), + mock(ITransportVehicleService.class), + mock(CertificationGate.class), + mock(WaybillEnroutePunchMapper.class), + nodePunchMapper, + exceptionDisposalMapper, + mock(ITripQueryService.class)); + + when(driverService.getOne(any())).thenReturn(driver()); + when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(waybillService.list(anyWrapper())).thenReturn(List.of(pendingReturnConfirmWaybill())); + } + + @Test + @DisplayName("待回单确认落在「进行中」档:列表查得到,数字态是 1") + void pendingReturnConfirmIsInProgressTier() { + IPage page = runAsDriver(() -> service.page(1, 10, 1, null)); + + assertEquals(1, page.getRecords().size(), "进行中档必须包含待回单确认,否则司机看不到这张单"); + assertEquals(Integer.valueOf(1), page.getRecords().get(0).getStatus(), "数字态沿用 1(进行中),不新增档位"); + } + + @Test + @DisplayName("「进行中」查询口径在库层就带上待回单确认") + void inProgressQueryCarriesPendingReturnConfirm() { + runAsDriver(() -> service.page(1, 10, 1, null)); + + assertTrue(queriedStatuses().contains(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM)); + } + + @Test + @DisplayName("「全部」tab 不漏掉待回单确认的运单") + void allTabKeepsPendingReturnConfirm() { + IPage page = runAsDriver(() -> service.page(1, 10, null, null)); + + assertEquals(1, page.getRecords().size(), "打完末位卡的单不能从全部 tab 消失"); + assertEquals(Integer.valueOf(1), page.getRecords().get(0).getStatus()); + assertTrue(queriedStatuses().contains(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM)); + } + + @Test + @DisplayName("Tab 计数把它算进进行中,不算已完成") + void tabCountsCountsItAsDoing() { + DriverWaybillTabCountsVO counts = runAsDriver(service::tabCounts); + + assertEquals(1, counts.getDoing(), "待回单确认仍是进行中,不是已完成"); + assertEquals(0, counts.getDone()); + assertEquals(1, counts.getAll()); + } + + // ------------------------------------------------------------ 夹具 + + private T runAsDriver(Supplier action) { + try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { + authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); + return action.get(); + } + } + + @SuppressWarnings("unchecked") + private Collection queriedStatuses() { + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + verify(waybillService).list(captor.capture()); + return ((LambdaQueryWrapper) captor.getValue()).getParamNameValuePairs().values(); + } + + @SuppressWarnings("unchecked") + private static Wrapper anyWrapper() { + return org.mockito.ArgumentMatchers.>any(); + } + + private Waybill pendingReturnConfirmWaybill() { + Waybill waybill = new Waybill(); + waybill.setId(WAYBILL_ID); + waybill.setWaybillNo("YD202610080002"); + waybill.setVehicleNo(PLATE); + waybill.setDriverPhone(PHONE); + waybill.setBusinessStatus(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM); + waybill.setIsDeleted(0); + return waybill; + } + + private Driver driver() { + Driver driver = new Driver(); + driver.setId(9001L); + driver.setUserId(USER_ID); + driver.setDrivingVehicle(PLATE); + driver.setMobile(PHONE); + return driver; + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/MasterOrderProgressTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/MasterOrderProgressTest.java new file mode 100644 index 00000000..f7d77747 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/MasterOrderProgressTest.java @@ -0,0 +1,150 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springblade.transport.mapper.MasterOrderMapper; +import org.springblade.transport.pojo.entity.MasterOrder; +import org.springblade.transport.pojo.entity.TransportPlan; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.vo.MasterOrderVO; +import org.springblade.transport.service.IContractManageService; +import org.springblade.transport.service.IProjectApplyService; +import org.springblade.transport.service.ITransportPlanService; +import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.support.WaybillProcessSupport; + +import java.lang.reflect.Field; +import java.math.BigDecimal; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 多联总单分段进度的「已到达数量」口径。 + *

+ * 到货但回单待确认的运单算到达:货物确实到了,回单只是单据。 + * 不算会让下一段被「调度总量不能大于上一段已到达数量」挡住,整条线路卡在第二段。 + */ +class MasterOrderProgressTest { + + private static final Long MASTER_ID = 6101L; + private static final String MASTER_NO = "ZD20261008001"; + private static final String ROUTE_JSON = """ + [{"segmentNo":"1","transportType":"公路运输","departureName":"南宁","arrivalName":"北海"}, + {"segmentNo":"2","transportType":"铁路运输","departureName":"北海","arrivalName":"昆明"}] + """; + + private MasterOrderMapper masterOrderMapper; + private IWaybillService waybillService; + private ITransportPlanService transportPlanService; + private MasterOrderServiceImpl service; + + @BeforeAll + static void initTableInfo() { + // 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Waybill.class); + TableInfoHelper.initTableInfo(assistant, TransportPlan.class); + TableInfoHelper.initTableInfo(assistant, MasterOrder.class); + } + + @BeforeEach + void setUp() throws Exception { + masterOrderMapper = mock(MasterOrderMapper.class); + waybillService = mock(IWaybillService.class); + transportPlanService = mock(ITransportPlanService.class); + service = new MasterOrderServiceImpl( + waybillService, + transportPlanService, + mock(IProjectApplyService.class), + mock(IContractManageService.class)); + inject("baseMapper", masterOrderMapper); + + when(transportPlanService.list(anyWrapper())).thenReturn(List.of()); + } + + @Test + @DisplayName("到货但回单待确认的运单计入「已到达数量」") + void arrivedQuantityCountsPendingReturnConfirm() { + when(masterOrderMapper.selectById(MASTER_ID)).thenReturn(masterOrder()); + when(waybillService.list(anyWrapper())).thenReturn(List.of( + child(1L, "1", WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM, "8"))); + + MasterOrderVO vo = service.detail(MASTER_ID); + + assertEquals(0, decimalOf(vo.getRouteProgress().get(0).get("arrivedQuantity")) + .compareTo(new BigDecimal("8")), "回单待确认不等于没到货"); + assertEquals(0, decimalOf(vo.getRouteProgress().get(1).get("arrivedQuantity")).signum(), + "第二段还没有到货的运单"); + } + + @Test + @DisplayName("已到达口径:已完成 + 待回单确认,进行中不算") + void arrivedQuantityIgnoresRunning() { + when(masterOrderMapper.selectById(MASTER_ID)).thenReturn(masterOrder()); + when(waybillService.list(anyWrapper())).thenReturn(List.of( + child(1L, "1", "running", "8"), + child(2L, "1", "completed", "5"), + child(3L, "1", WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM, "3"))); + + MasterOrderVO vo = service.detail(MASTER_ID); + + assertEquals(0, decimalOf(vo.getRouteProgress().get(0).get("arrivedQuantity")) + .compareTo(new BigDecimal("8")), "5 + 3:进行中的 8 吨还没到,不能算到达"); + } + + // ------------------------------------------------------------ 夹具 + + private MasterOrder masterOrder() { + MasterOrder masterOrder = new MasterOrder(); + masterOrder.setId(MASTER_ID); + masterOrder.setMasterNo(MASTER_NO); + masterOrder.setRouteJson(ROUTE_JSON); + return masterOrder; + } + + private Waybill child(Long id, String segmentNo, String businessStatus, String quantity) { + Waybill waybill = new Waybill(); + waybill.setId(id); + waybill.setMasterNo(MASTER_NO); + waybill.setRelationNo(segmentNo); + waybill.setBusinessStatus(businessStatus); + waybill.setGoodsJson("[{\"cargoName\":\"钢材\",\"quantity\":" + quantity + "}]"); + return waybill; + } + + private BigDecimal decimalOf(Object value) { + return value == null ? BigDecimal.ZERO : (BigDecimal) value; + } + + @SuppressWarnings("unchecked") + private static Wrapper anyWrapper() { + return org.mockito.ArgumentMatchers.>any(); + } + + private void inject(String field, Object value) throws Exception { + Class type = service.getClass(); + while (type != null) { + try { + Field declared = type.getDeclaredField(field); + declared.setAccessible(true); + declared.set(service, value); + return; + } catch (NoSuchFieldException ignored) { + type = type.getSuperclass(); + } + } + throw new IllegalStateException("未找到字段:" + field); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripAggregationTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripAggregationTest.java index a5375241..1bee8fd5 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripAggregationTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripAggregationTest.java @@ -193,6 +193,23 @@ class TripAggregationTest { assertTrue(TripAggregation.CHILD_STATUSES.containsAll(TripAggregation.TERMINAL_STATUSES)); } + @Test + @DisplayName("待回单确认的子单仍进运次,且不会把运次判成已完成") + void pendingReturnConfirmChildKeepsTripRunning() { + String pendingReturnConfirm = org.springblade.transport.support.WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM; + + assertTrue(TripAggregation.CHILD_STATUSES.contains(pendingReturnConfirm), + "漏掉这个状态,子单会从运次读服务消失,配载单被当成全部完成"); + + // 与一张已完成的子单拼载:还有子单没走完,运次只能是运输中 + List children = List.of( + waybill("completed", null, null), + waybill(pendingReturnConfirm, null, null)); + String status = TripAggregation.statusOf(children, false, false); + assertEquals("running", status); + assertEquals(1, TripAggregation.appStatus(status), "运次不进已完成档"); + } + private Waybill waybill(String businessStatus, String acceptStatus, String processJson) { Waybill waybill = new Waybill(); waybill.setBusinessStatus(businessStatus); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillBusinessStatusNameTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillBusinessStatusNameTest.java new file mode 100644 index 00000000..392c0550 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillBusinessStatusNameTest.java @@ -0,0 +1,65 @@ +package org.springblade.transport.service.impl; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springblade.transport.mapper.MasterOrderMapper; +import org.springblade.transport.mapper.ReceivablePayableCargoFeeMapper; +import org.springblade.transport.mapper.ReceivablePayableChangeRecordMapper; +import org.springblade.transport.service.IContractManageService; +import org.springblade.transport.service.ICommonAddressService; +import org.springblade.transport.service.IFormalSettlementService; +import org.springblade.transport.service.IPreSettlementService; +import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.support.WaybillProcessSupport; +import org.springblade.transport.wrapper.WaybillWrapper; + +import java.lang.reflect.Method; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; + +/** + * 运单业务状态的中文名,两套表并存(历史结构,不合并):运单管理一套、应收应付一套。 + *

+ * 两套都要认待回单确认:漏一套就在页面上露出原始英文码。 + */ +class WaybillBusinessStatusNameTest { + + private static final String PENDING_RETURN_CONFIRM = WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM; + + @Test + @DisplayName("运单管理名称表给出「待回单确认」——导出与详情共用这张表") + void waybillWrapperNamesPendingReturnConfirm() { + assertEquals("待回单确认", WaybillWrapper.businessStatusName(PENDING_RETURN_CONFIRM)); + // 相邻档位不受影响 + assertEquals("进行中", WaybillWrapper.businessStatusName("running")); + assertEquals("已完成", WaybillWrapper.businessStatusName("completed")); + } + + @Test + @DisplayName("应收应付的第二套名称表同步给出「待回单确认」") + void receivablePayableNamesPendingReturnConfirm() throws Exception { + assertEquals("待回单确认", receivableStatusName(PENDING_RETURN_CONFIRM)); + } + + /** + * 应收应付那张表只被 waybillMap 用,而 waybillMap 当前没有调用方, + * 没有公共接缝;它是纯函数,按既有做法直接取私有方法(同 LoadingManageChildStatusWriteTest)。 + */ + private String receivableStatusName(String status) throws Exception { + ReceivablePayableDetailServiceImpl service = new ReceivablePayableDetailServiceImpl( + mock(ReceivablePayableCargoFeeMapper.class), + mock(ReceivablePayableChangeRecordMapper.class), + mock(MasterOrderMapper.class), + mock(IWaybillService.class), + mock(IContractManageService.class), + mock(ICommonAddressService.class), + mock(IPreSettlementService.class), + mock(IFormalSettlementService.class)); + Method method = ReceivablePayableDetailServiceImpl.class + .getDeclaredMethod("waybillStatusName", String.class); + method.setAccessible(true); + return (String) method.invoke(service, status); + } + +} From 53f208cf05c7fbdacd80b4206fbfc9bd76c150ad Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Thu, 22 Oct 2026 14:08:22 +0800 Subject: [PATCH 22/55] =?UTF-8?q?:sparkles:=20=E5=BE=85=E5=9B=9E=E5=8D=95?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=EF=BC=9A=E5=8F=B8=E6=9C=BA=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E5=88=86=E6=A1=A3=E3=80=81=E6=95=B0=E5=AD=97=E6=80=81=E3=80=81?= =?UTF-8?q?=E4=B8=A4=E5=A5=97=E5=90=8D=E7=A7=B0=E8=A1=A8=E3=80=81=E8=BF=90?= =?UTF-8?q?=E6=AC=A1=E5=AD=90=E5=8D=95=E4=B8=8E=E6=80=BB=E5=8D=95=E5=B7=B2?= =?UTF-8?q?=E5=88=B0=E8=BE=BE=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/DriverWaybillServiceImpl.java | 23 +++++++++++-------- .../service/impl/MasterOrderServiceImpl.java | 14 +++++++++-- .../ReceivablePayableDetailServiceImpl.java | 2 ++ .../service/impl/TripAggregation.java | 4 +++- .../transport/wrapper/WaybillWrapper.java | 1 + 5 files changed, 32 insertions(+), 12 deletions(-) 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 16466128..d2369c3f 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 @@ -118,13 +118,18 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { private static final List PENDING_STATUSES = Arrays.asList( STATUS_PENDING, STATUS_WAITING_DISPATCH, STATUS_DISPATCHING ); - /** Tab「全部」统计口径:待接 + 运输中 + 已完成(不含已取消) */ + /** Tab「全部」统计口径:待接 + 运输中 + 待回单确认 + 已完成(不含已取消) */ private static final List TAB_ALL_STATUSES = Arrays.asList( - STATUS_PENDING, STATUS_WAITING_DISPATCH, STATUS_DISPATCHING, STATUS_RUNNING, STATUS_COMPLETED + STATUS_PENDING, STATUS_WAITING_DISPATCH, STATUS_DISPATCHING, STATUS_RUNNING, + WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM, STATUS_COMPLETED ); - /** 允许完成运单的业务状态 */ - private static final List COMPLETABLE_STATUSES = Arrays.asList( - STATUS_RUNNING, STATUS_PENDING, STATUS_WAITING_DISPATCH, STATUS_DISPATCHING + /** + * 进行中口径:司机手上还没走完的单。 + * 待回单确认也算进行中——司机侧已打完末位卡,等业务员确认回单才变已完成。 + */ + private static final List ACTIVE_STATUSES = Arrays.asList( + STATUS_RUNNING, STATUS_PENDING, STATUS_WAITING_DISPATCH, STATUS_DISPATCHING, + WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM ); private static final int DEFAULT_PREVIEW_SIZE = 2; @@ -838,7 +843,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } switch (status) { case 0 -> wrapper.in(Waybill::getBusinessStatus, PENDING_STATUSES); - case 1 -> wrapper.in(Waybill::getBusinessStatus, COMPLETABLE_STATUSES); + case 1 -> wrapper.in(Waybill::getBusinessStatus, ACTIVE_STATUSES); case 2 -> wrapper.eq(Waybill::getBusinessStatus, STATUS_COMPLETED); case 3 -> wrapper.eq(Waybill::getBusinessStatus, STATUS_CANCELLED); default -> wrapper.in(Waybill::getBusinessStatus, TAB_ALL_STATUSES); @@ -1514,12 +1519,12 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } /** - * 自动完成的前置:业务状态允许完成,且没有未了结异常。 + * 自动完成的前置:业务状态在进行中口径内,且没有未了结异常。 *

* 不满足返回 false,由调用方静默跳过,打卡不受影响。 */ private boolean canAutoCompleteWaybill(Waybill waybill) { - return COMPLETABLE_STATUSES.contains(waybill.getBusinessStatus()) + return ACTIVE_STATUSES.contains(waybill.getBusinessStatus()) && unresolvedExceptionNodeCodeList(waybill.getId()).isEmpty(); } @@ -1986,7 +1991,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } return switch (businessStatus) { case STATUS_PENDING, STATUS_WAITING_DISPATCH, STATUS_DISPATCHING -> 0; - case STATUS_RUNNING -> 1; + case STATUS_RUNNING, WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM -> 1; case STATUS_COMPLETED -> 2; case STATUS_CANCELLED -> 3; default -> null; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/MasterOrderServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/MasterOrderServiceImpl.java index 51491538..3c3dc747 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/MasterOrderServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/MasterOrderServiceImpl.java @@ -362,7 +362,7 @@ public class MasterOrderServiceImpl extends BaseServiceImpl dispatchedGoods = dispatchedGoods(routeWaybills, routePlans); route.put("dispatchedQuantity", dispatchedGoods.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add)); route.put("dispatchedGoods", dispatchedGoods); - route.put("arrivedQuantity", routeWaybills.stream().filter(item -> "completed".equals(item.getBusinessStatus())).flatMap(item -> waybillGoods(item).stream()).map(item -> decimal(item, "quantity")).reduce(BigDecimal.ZERO, BigDecimal::add)); + route.put("arrivedQuantity", routeWaybills.stream().filter(item -> arrived(item.getBusinessStatus())).flatMap(item -> waybillGoods(item).stream()).map(item -> decimal(item, "quantity")).reduce(BigDecimal.ZERO, BigDecimal::add)); route.put("waybills", routeWaybills); route.put("transportPlans", routePlans); } @@ -541,12 +541,22 @@ public class MasterOrderServiceImpl extends BaseServiceImpl route) { return waybillsByMasterNo(masterNo).stream() - .filter(item -> "completed".equals(item.getBusinessStatus())) + .filter(item -> arrived(item.getBusinessStatus())) .filter(item -> belongsToRoute(item, route)) .flatMap(item -> waybillGoods(item).stream()) .map(item -> decimal(item, "quantity")) .reduce(BigDecimal.ZERO, BigDecimal::add); } + + /** + * 已到达口径:待回单确认也算到达——货物确实到了,回单只是单据。 + * 不计入会按「调度总量不能大于上一段已到达数量」把多联总单的下一段挡在门外。 + */ + static boolean arrived(String businessStatus) { + return "completed".equals(businessStatus) + || WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM.equals(businessStatus); + } + private Map availableGoods(MasterOrder masterOrder) { Map available = new LinkedHashMap<>(); for (Map goods : parseArray(masterOrder.getGoodsJson())) available.merge(goodsKey(goods), decimal(goods, "quantity"), BigDecimal::add); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java index 8ce2f5f7..7a757025 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java @@ -61,6 +61,7 @@ import org.springblade.transport.service.IFormalSettlementService; import org.springblade.transport.service.IPreSettlementService; import org.springblade.transport.service.IReceivablePayableDetailService; import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.support.WaybillProcessSupport; import org.springblade.transport.wrapper.ReceivablePayableDetailWrapper; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; @@ -3295,6 +3296,7 @@ public class ReceivablePayableDetailServiceImpl case "completed" -> "已完成"; case "processing", "running", "in_progress", "inProgress" -> "进行中"; case "pending", "created" -> "待执行"; + case WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM -> "待回单确认"; case "cancelled", "canceled" -> "已取消"; default -> status; }; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripAggregation.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripAggregation.java index d55d700a..6eb4689a 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripAggregation.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripAggregation.java @@ -45,6 +45,7 @@ final class TripAggregation { static final String RUNNING = "running"; static final String COMPLETED = "completed"; static final String CANCELLED = "cancelled"; + static final String PENDING_RETURN_CONFIRM = WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM; /** 待接单档:与运单侧 Tab「待接单」取同一组状态 */ static final List PENDING_STATUSES = List.of(PENDING, WAITING_DISPATCH, DISPATCHING); @@ -53,9 +54,10 @@ final class TripAggregation { /** * 进运次的子单状态:草稿尚未派单、不成运次;已取消要进, * 否则「子单全已取消」会被算成运输中而不是已取消。 + * 待回单确认要进:子单漏在运次外,剩下那几张会被判成整趟车已走完。 */ static final List CHILD_STATUSES = List.of( - PENDING, WAITING_DISPATCH, DISPATCHING, RUNNING, COMPLETED, CANCELLED + PENDING, WAITING_DISPATCH, DISPATCHING, RUNNING, PENDING_RETURN_CONFIRM, COMPLETED, CANCELLED ); /** 待重新派单的口径:子单被拒且这趟车还没结束 */ diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/WaybillWrapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/WaybillWrapper.java index 1dab5a5b..d0257f86 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/WaybillWrapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/WaybillWrapper.java @@ -122,6 +122,7 @@ public class WaybillWrapper extends BaseEntityWrapper { case "waiting_dispatch" -> "待调度"; case "dispatching" -> "调度中"; case "running", "processing", "in_progress", "inProgress" -> "进行中"; + case WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM -> "待回单确认"; case "completed" -> "已完成"; case "cancelled" -> "已取消"; case "closed" -> "已关闭"; From 67480098f35f5a0eb7f38c6aa0d8a2a4e4607420 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Thu, 22 Oct 2026 14:11:18 +0800 Subject: [PATCH 23/55] =?UTF-8?q?:white=5Fcheck=5Fmark:=20=E5=BE=85?= =?UTF-8?q?=E5=9B=9E=E5=8D=95=E7=A1=AE=E8=AE=A4=EF=BC=9A=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E6=96=AD=E8=A8=80=E6=94=B9=E4=B8=BA=20Answer?= =?UTF-8?q?=20=E6=8D=95=E8=8E=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/impl/DriverWaybillTabStatusTest.java | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillTabStatusTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillTabStatusTest.java index 802564b0..c556a398 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillTabStatusTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillTabStatusTest.java @@ -10,7 +10,6 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; import org.mockito.MockedStatic; import org.springblade.core.secure.utils.AuthUtil; import org.springblade.system.feign.IMpUserClient; @@ -30,6 +29,7 @@ import org.springblade.transport.service.IWaybillService; import org.springblade.transport.support.CertificationGate; import org.springblade.transport.support.WaybillProcessSupport; +import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.function.Supplier; @@ -39,7 +39,6 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** @@ -59,6 +58,7 @@ class DriverWaybillTabStatusTest { private IDriverService driverService; private WaybillNodePunchMapper nodePunchMapper; private ExceptionDisposalMapper exceptionDisposalMapper; + private final List> queried = new ArrayList<>(); private DriverWaybillServiceImpl service; @BeforeAll @@ -92,7 +92,10 @@ class DriverWaybillTabStatusTest { when(driverService.getOne(any())).thenReturn(driver()); when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0)); - when(waybillService.list(anyWrapper())).thenReturn(List.of(pendingReturnConfirmWaybill())); + when(waybillService.list(anyWrapper())).thenAnswer(invocation -> { + queried.add(invocation.getArgument(0)); + return List.of(pendingReturnConfirmWaybill()); + }); } @Test @@ -143,9 +146,8 @@ class DriverWaybillTabStatusTest { @SuppressWarnings("unchecked") private Collection queriedStatuses() { - ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); - verify(waybillService).list(captor.capture()); - return ((LambdaQueryWrapper) captor.getValue()).getParamNameValuePairs().values(); + assertEquals(1, queried.size(), "一次列表查询"); + return ((LambdaQueryWrapper) queried.get(0)).getParamNameValuePairs().values(); } @SuppressWarnings("unchecked") From 66a4a4945211f4a9fc8a54c3eff9d45f6e8b62f2 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Thu, 22 Oct 2026 14:14:19 +0800 Subject: [PATCH 24/55] =?UTF-8?q?:white=5Fcheck=5Fmark:=20=E5=BE=85?= =?UTF-8?q?=E5=9B=9E=E5=8D=95=E7=A1=AE=E8=AE=A4=EF=BC=9A=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E4=B8=8E=E5=8D=A1=E7=89=87=E4=B8=8B=E5=8F=91=E5=8E=9F=E5=A7=8B?= =?UTF-8?q?=E4=B8=9A=E5=8A=A1=E7=8A=B6=E6=80=81=E7=9A=84=E7=BA=A2=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/DriverWaybillTabStatusTest.java | 5 + .../impl/ManageWaybillBusinessStatusTest.java | 116 ++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageWaybillBusinessStatusTest.java diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillTabStatusTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillTabStatusTest.java index c556a398..5de8ac1d 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillTabStatusTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillTabStatusTest.java @@ -105,6 +105,9 @@ class DriverWaybillTabStatusTest { assertEquals(1, page.getRecords().size(), "进行中档必须包含待回单确认,否则司机看不到这张单"); assertEquals(Integer.valueOf(1), page.getRecords().get(0).getStatus(), "数字态沿用 1(进行中),不新增档位"); + assertEquals(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM, + page.getRecords().get(0).getBusinessStatus(), + "原始业务状态随卡片下发:数字态恒为 1,前端靠它区分运输中与待回单确认"); } @Test @@ -122,6 +125,8 @@ class DriverWaybillTabStatusTest { assertEquals(1, page.getRecords().size(), "打完末位卡的单不能从全部 tab 消失"); assertEquals(Integer.valueOf(1), page.getRecords().get(0).getStatus()); + assertEquals(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM, + page.getRecords().get(0).getBusinessStatus()); assertTrue(queriedStatuses().contains(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM)); } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageWaybillBusinessStatusTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageWaybillBusinessStatusTest.java new file mode 100644 index 00000000..e7f39d3d --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageWaybillBusinessStatusTest.java @@ -0,0 +1,116 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springblade.transport.pojo.entity.ExceptionDisposal; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.vo.AdminWaybillCardVO; +import org.springblade.transport.pojo.vo.AdminWaybillDetailVO; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.IDriverWaybillService; +import org.springblade.transport.service.IExceptionDisposalService; +import org.springblade.transport.service.ILoadingManageService; +import org.springblade.transport.service.IRiskDisposalService; +import org.springblade.transport.service.ITripQueryService; +import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.support.WaybillProcessSupport; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 调度端运单详情的原始业务状态。 + *

+ * 数字态只有 0/1/2/3:待回单确认落进行中(1),与运输中同档。 + * 调度端要区分这两者只能看原始状态码,所以详情与卡片都得带上它。 + */ +class ManageWaybillBusinessStatusTest { + + private static final Long WAYBILL_ID = 6201L; + private static final String PENDING_RETURN_CONFIRM = WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM; + + private ManageWaybillServiceImpl service; + private IWaybillService waybillService; + + @BeforeAll + static void initTableInfo() { + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Waybill.class); + TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class); + } + + @BeforeEach + void setUp() { + waybillService = mock(IWaybillService.class); + IExceptionDisposalService exceptionDisposalService = mock(IExceptionDisposalService.class); + service = new ManageWaybillServiceImpl( + waybillService, + mock(IDriverService.class), + exceptionDisposalService, + mock(IRiskDisposalService.class), + mock(IDriverWaybillService.class), + mock(ITripQueryService.class), + mock(ILoadingManageService.class)); + when(exceptionDisposalService.list(anyWrapper())).thenReturn(new ArrayList<>()); + } + + @Test + @DisplayName("运单详情:数字态 1,原始业务状态是待回单确认") + void detailCarriesRawBusinessStatus() { + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill()); + + AdminWaybillDetailVO detail = service.detail(String.valueOf(WAYBILL_ID)); + + assertEquals(Integer.valueOf(1), detail.getStatus(), "数字态仍落进行中,未新增档位"); + assertEquals(PENDING_RETURN_CONFIRM, detail.getBusinessStatus()); + } + + @Test + @DisplayName("待重新派单卡片:原始业务状态随卡片下发") + void reassignCardCarriesRawBusinessStatus() { + when(waybillService.page(any(), any())).thenAnswer(invocation -> { + Page rows = invocation.getArgument(0); + rows.setRecords(List.of(waybill())); + rows.setTotal(1); + return rows; + }); + + IPage page = service.pendingList(1, 10, null, true); + + assertEquals(1, page.getRecords().size()); + assertEquals(PENDING_RETURN_CONFIRM, page.getRecords().get(0).getBusinessStatus()); + } + + // ------------------------------------------------------------ 夹具 + + private Waybill waybill() { + Waybill waybill = new Waybill(); + waybill.setId(WAYBILL_ID); + waybill.setWaybillNo("YD202610080003"); + waybill.setBusinessStatus(PENDING_RETURN_CONFIRM); + waybill.setDriverAcceptStatus("rejected"); + waybill.setIsDeleted(0); + return waybill; + } + + @SuppressWarnings("unchecked") + private static Wrapper anyWrapper() { + return org.mockito.ArgumentMatchers.>any(); + } + +} From d2f7d6aed0009e94c5d1b57a393738b1a21a45aa Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Thu, 22 Oct 2026 14:15:33 +0800 Subject: [PATCH 25/55] =?UTF-8?q?:sparkles:=20=E5=BE=85=E5=9B=9E=E5=8D=95?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=EF=BC=9A=E5=8F=B8=E6=9C=BA=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E4=B8=8E=E8=B0=83=E5=BA=A6=E7=AB=AF=E8=AF=A6=E6=83=85/?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E4=B8=8B=E5=8F=91=E5=8E=9F=E5=A7=8B=E4=B8=9A?= =?UTF-8?q?=E5=8A=A1=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/springblade/transport/pojo/vo/AdminWaybillCardVO.java | 3 +++ .../springblade/transport/pojo/vo/AdminWaybillDetailVO.java | 3 +++ .../org/springblade/transport/pojo/vo/DriverWaybillCardVO.java | 3 +++ .../transport/service/impl/DriverWaybillServiceImpl.java | 1 + .../transport/service/impl/ManageWaybillServiceImpl.java | 2 ++ 5 files changed, 12 insertions(+) diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminWaybillCardVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminWaybillCardVO.java index b0dca1a4..aa0dbfb5 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminWaybillCardVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminWaybillCardVO.java @@ -65,6 +65,9 @@ public class AdminWaybillCardVO implements Serializable { @Schema(description = "状态:0待接单/1运输中/2已完成/3已取消") private Integer status; + @Schema(description = "原始业务状态:pending / running / pending_return_confirm / completed / cancelled") + private String businessStatus; + @Schema(description = "承运方") private String carrierName; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminWaybillDetailVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminWaybillDetailVO.java index ff601f14..e874df68 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminWaybillDetailVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminWaybillDetailVO.java @@ -48,6 +48,9 @@ public class AdminWaybillDetailVO implements Serializable { @Schema(description = "状态:0待接单/1运输中/2已完成/3已取消") private Integer status; + @Schema(description = "原始业务状态:pending / running / pending_return_confirm / completed / cancelled") + private String businessStatus; + @Schema(description = "起点名称") private String fromName; 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 23432c6a..a4be3625 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 @@ -76,6 +76,9 @@ public class DriverWaybillCardVO implements Serializable { @Schema(description = "状态:0待接单/1运输中/2已完成/3已取消") private Integer status; + @Schema(description = "原始业务状态:pending / running / pending_return_confirm / completed / cancelled") + private String businessStatus; + @Schema(description = "创建时间") private String createTime; 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 d2369c3f..4348f65b 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 @@ -1011,6 +1011,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { WaybillCargoSupport.Quantity weighing = WaybillCargoSupport.weighing(cargos); card.setWeight(weighing == null ? "" : formatWeight(weighing.value(), weighing.unit())); card.setStatus(toAppStatus(waybill.getBusinessStatus())); + card.setBusinessStatus(waybill.getBusinessStatus()); String createTime = formatDateTime(waybill.getCreateTime()); card.setCreateTime(createTime); card.setPublishTime(createTime); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java index 4b1e5c93..0e303dcc 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java @@ -744,6 +744,7 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { detail.setId(waybill.getId()); detail.setWaybillNo(waybill.getWaybillNo()); detail.setStatus(toAppStatus(waybill.getBusinessStatus())); + detail.setBusinessStatus(waybill.getBusinessStatus()); String mode = Func.toStr(waybill.getTransportType(), ""); detail.setTransportMode(mode); @@ -937,6 +938,7 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { card.setPlanTime(formatLocalDate(waybill.getEstimatedStartTime())); card.setPlanTimeEnd(formatLocalDate(waybill.getEstimatedEndTime())); card.setStatus(toAppStatus(waybill.getBusinessStatus())); + card.setBusinessStatus(waybill.getBusinessStatus()); card.setCarrierName(Func.toStr(waybill.getCarrierName(), "")); card.setDriverName(Func.toStr(waybill.getDriverName(), "")); card.setVehicleNo(Func.toStr(waybill.getVehicleNo(), "")); From 86da6f32dcef31c23f4e6722e537a725b29c5d4e Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Thu, 22 Oct 2026 14:18:34 +0800 Subject: [PATCH 26/55] =?UTF-8?q?:white=5Fcheck=5Fmark:=20=E5=BE=85?= =?UTF-8?q?=E5=9B=9E=E5=8D=95=E7=A1=AE=E8=AE=A4=EF=BC=9A=E9=94=81=E4=BD=8F?= =?UTF-8?q?=E8=B0=83=E5=BA=A6=E7=AB=AF=E6=95=B0=E5=AD=97=E6=80=81=E8=90=BD?= =?UTF-8?q?=E8=BF=9B=E8=A1=8C=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../transport/service/impl/ManageWaybillBusinessStatusTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageWaybillBusinessStatusTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageWaybillBusinessStatusTest.java index e7f39d3d..ad43545b 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageWaybillBusinessStatusTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageWaybillBusinessStatusTest.java @@ -93,6 +93,8 @@ class ManageWaybillBusinessStatusTest { IPage page = service.pendingList(1, 10, null, true); assertEquals(1, page.getRecords().size()); + assertEquals(Integer.valueOf(1), page.getRecords().get(0).getStatus(), + "数字态落进行中:null 到前端会掉进「非 0/1/2 即已取消」的展示分支"); assertEquals(PENDING_RETURN_CONFIRM, page.getRecords().get(0).getBusinessStatus()); } From 833c3e247e2e0ce5a5dbf50cb15b8df9612534e1 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Thu, 22 Oct 2026 14:19:06 +0800 Subject: [PATCH 27/55] =?UTF-8?q?:bug:=20=E5=BE=85=E5=9B=9E=E5=8D=95?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=EF=BC=9A=E8=B0=83=E5=BA=A6=E7=AB=AF=E6=95=B0?= =?UTF-8?q?=E5=AD=97=E6=80=81=E8=90=BD=E8=BF=9B=E8=A1=8C=E4=B8=AD=EF=BC=8C?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E8=BF=94=E5=9B=9E=20null?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../transport/service/impl/ManageWaybillServiceImpl.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java index 0e303dcc..0f692983 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java @@ -996,7 +996,9 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { } return switch (businessStatus) { case STATUS_PENDING, "waiting_dispatch", "dispatching" -> 0; - case STATUS_RUNNING -> 1; + // 与司机端 toAppStatus 同一份数字枚举(0 待接单 / 1 运输中 / 2 已完成 / 3 已取消),待回单确认属进行中: + // 两处对同一状态必须给同一个数字,给 null 前端只能落到「未知」兜底 + case STATUS_RUNNING, WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM -> 1; case STATUS_COMPLETED -> 2; case STATUS_CANCELLED -> 3; default -> null; From 6673ed38f319d92596268f0986507b94e3cfb391 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Thu, 22 Oct 2026 14:47:33 +0800 Subject: [PATCH 28/55] =?UTF-8?q?:recycle:=20=E5=BE=85=E5=9B=9E=E5=8D=95?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=EF=BC=9A=E6=B3=A8=E9=87=8A=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E9=99=88=E8=BF=B0=E4=BA=8B=E5=AE=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/impl/DriverWaybillServiceImpl.java | 10 +++++----- .../service/impl/ManageWaybillServiceImpl.java | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) 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 4348f65b..94d11e3f 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 @@ -124,8 +124,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM, STATUS_COMPLETED ); /** - * 进行中口径:司机手上还没走完的单。 - * 待回单确认也算进行中——司机侧已打完末位卡,等业务员确认回单才变已完成。 + * 进行中口径:司机手上还没走完的单,含待回单确认。 + * 待回单确认指司机已打完末位卡、等业务员确认回单,此时业务状态还不是 completed。 */ private static final List ACTIVE_STATUSES = Arrays.asList( STATUS_RUNNING, STATUS_PENDING, STATUS_WAITING_DISPATCH, STATUS_DISPATCHING, @@ -525,9 +525,9 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { nodePunchMapper.insert(punch); advanceCurrentProcessNode(waybill, nodeCfg); - // 末位打卡节点是到货/卸货/签收、且运单本身允许完成时,本次打卡就是运单的去向决定点: - // 回单无需确认 → 一并完成;回单需业务员确认 → 停在待回单确认等业务员确认。 - // isLastDriverPunchNode 与 returnNeedsNoConfirmation 传的就是上面那份 processJson,未再调 resolveProcessJson 取过程配置。 + // 末位打卡节点为到货/卸货/签收、且运单允许完成时:回单无需确认则本次打卡一并完成运单, + // 回单需业务员确认则停在待回单确认。 + // isLastDriverPunchNode 与 returnNeedsNoConfirmation 传的是上面那份 processJson,未再调 resolveProcessJson 取过程配置。 String nodeKey = WaybillProcessSupport.nodeKey(nodeCfg); String nodeName = WaybillProcessSupport.nodeName(nodeCfg); String waybillBusinessStatus = waybill.getBusinessStatus(); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java index 0f692983..a0ee73f3 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java @@ -996,8 +996,8 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { } return switch (businessStatus) { case STATUS_PENDING, "waiting_dispatch", "dispatching" -> 0; - // 与司机端 toAppStatus 同一份数字枚举(0 待接单 / 1 运输中 / 2 已完成 / 3 已取消),待回单确认属进行中: - // 两处对同一状态必须给同一个数字,给 null 前端只能落到「未知」兜底 + // 数字枚举与司机端 toAppStatus 同源:0 待接单 / 1 运输中 / 2 已完成 / 3 已取消; + // 待回单确认属进行中,落 1。给 null 时前端显示「未知」。 case STATUS_RUNNING, WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM -> 1; case STATUS_COMPLETED -> 2; case STATUS_CANCELLED -> 3; From 35127760df7b35da52b2be16c9509846330afd35 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Thu, 22 Oct 2026 14:48:20 +0800 Subject: [PATCH 29/55] =?UTF-8?q?:bug:=20=E5=BE=85=E5=9B=9E=E5=8D=95?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=EF=BC=9A=E4=B8=9A=E5=8A=A1=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=AF=94=E8=BE=83=E7=BB=9F=E4=B8=80=E4=B8=BA=E5=BF=BD=E7=95=A5?= =?UTF-8?q?=E5=A4=A7=E5=B0=8F=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../transport/service/impl/DriverWaybillServiceImpl.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 94d11e3f..27226429 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 @@ -540,9 +540,9 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { if (completed) { waybillBusinessStatus = STATUS_COMPLETED; } - } else if (WaybillProcessSupport.STATUS_RUNNING.equals(waybill.getBusinessStatus())) { - // 只有进行中能进入:canAutoCompleteWaybill 的状态口径含待执行/待调度等,不能替代这一条; - // 待回单确认在 isPunchEnded 里算已结束,不能从结束态重进。 + } else if (WaybillProcessSupport.STATUS_RUNNING.equalsIgnoreCase(waybill.getBusinessStatus())) { + // 当前业务状态为 running 才置为待回单确认;canAutoCompleteWaybill 的状态口径还含待执行/待调度。 + // 待回单确认在 isPunchEnded 里与已完成、已取消同属结束态。 waybill.setBusinessStatus(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM); waybillService.updateById(waybill); waybillBusinessStatus = WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM; From e0c8c988052cb5619c0f634913950ccbc6ef99df Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Thu, 22 Oct 2026 14:48:48 +0800 Subject: [PATCH 30/55] =?UTF-8?q?:sparkles:=20=E5=BE=85=E5=9B=9E=E5=8D=95?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=EF=BC=9A=E8=BF=90=E6=AC=A1=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E4=B8=8B=E5=8F=91=E5=94=AF=E4=B8=80=E5=8E=9F=E5=A7=8B=E4=B8=9A?= =?UTF-8?q?=E5=8A=A1=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/DriverWaybillServiceImpl.java | 7 +++++ .../service/impl/DriverTripCardTest.java | 29 +++++++++++++++++++ 2 files changed, 36 insertions(+) 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 27226429..428c4398 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 @@ -1113,6 +1113,13 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { card.setId(parseId(children.isEmpty() ? trip.getId() : children.get(0).getId())); card.setWaybillNo(children.isEmpty() ? card.getTripNo() : Func.toStr(children.get(0).getWaybillNo(), "")); card.setStatus(trip.getStatus()); + // 原始业务状态只在整趟车子单状态唯一时下发:配载车混状态时一趟车没有唯一原始状态,给 null 由前端按数字态展示 + List childBusinessStatuses = children.stream() + .map(TripVO.TripWaybillVO::getBusinessStatus) + .filter(Func::isNotBlank) + .distinct() + .collect(Collectors.toList()); + card.setBusinessStatus(childBusinessStatuses.size() == 1 ? childBusinessStatuses.get(0) : null); card.setRequireAccept(trip.getRequireAccept()); card.setAcceptStatus(trip.getAcceptStatus()); card.setTransportOrgType(trip.getTransportOrgType()); 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 7bdcbba1..41189b41 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 @@ -30,6 +30,7 @@ import org.springblade.transport.service.ITransportVehicleService; import org.springblade.transport.service.ITripQueryService; import org.springblade.transport.service.IWaybillService; import org.springblade.transport.support.CertificationGate; +import org.springblade.transport.support.WaybillProcessSupport; import java.math.BigDecimal; import java.util.Date; @@ -147,6 +148,34 @@ class DriverTripCardTest { assertEquals("桂A32523", card.getVehicleNo()); } + @Test + @DisplayName("运次卡片带唯一原始业务状态:子单都在待回单确认时下发 pending_return_confirm,数字态仍是 1") + void tripCardCarriesUniqueChildBusinessStatus() { + TripVO trip = loadingTrip(); + trip.getWaybills().forEach(child -> child.setBusinessStatus(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM)); + stubTripPage(1, trip); + + IPage page = runAsDriver(() -> service.tripPage(1, 10, null, null)); + + DriverWaybillCardVO card = page.getRecords().get(0); + assertEquals(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM, card.getBusinessStatus()); + assertEquals(trip.getStatus(), card.getStatus(), "运次数字态口径不变,仍取运次状态"); + } + + @Test + @DisplayName("运次卡片原始业务状态:配载车混状态时给 null,由前端回退数字态") + void tripCardWithoutUniqueChildBusinessStatusGivesNull() { + TripVO trip = loadingTrip(); + trip.getWaybills().get(0).setBusinessStatus(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM); + stubTripPage(1, trip); + + IPage page = runAsDriver(() -> service.tripPage(1, 10, null, null)); + + DriverWaybillCardVO card = page.getRecords().get(0); + assertNull(card.getBusinessStatus(), "一趟车里两种原始状态,没有唯一值可下发"); + assertEquals(1, card.getStatus(), "混状态不影响运次数字态"); + } + @Test @DisplayName("运次列表不因配载卡去回查运单,也不写库") void tripPageDoesNotLookUpWaybillsOrWrite() { From c47268adfdb8b5d5b39d299255666ee4dc754cfe Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Thu, 22 Oct 2026 14:48:48 +0800 Subject: [PATCH 31/55] =?UTF-8?q?:white=5Fcheck=5Fmark:=20=E5=BE=85?= =?UTF-8?q?=E5=9B=9E=E5=8D=95=E7=A1=AE=E8=AE=A4=EF=BC=9A=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=96=B9=E6=B3=95=E5=90=8D=E4=B8=8E=E6=96=AD=E8=A8=80=E5=AF=B9?= =?UTF-8?q?=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../transport/support/WaybillProcessSupportTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 9408bfd6..d8830c8a 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 @@ -337,8 +337,8 @@ class WaybillProcessSupportTest { // ------------------------------------------------------------ 待回单确认 @Test - @DisplayName("待回单确认落库字面量为 pending_return_confirm,且接单归一不得改写它") - void pendingReturnConfirmIsTerminalStatus() { + @DisplayName("待回单确认落库字面量为 pending_return_confirm,且接单归一不改写它") + void pendingReturnConfirmLiteralSurvivesAcceptanceNormalization() { assertEquals("pending_return_confirm", WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM, "库里存的是这个字面量,web 与小程序也按它判断,不能改名"); assertEquals(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM, From b31b81bedf84a03fe75af28ef0cd1924c8a3e110 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Thu, 22 Oct 2026 15:30:26 +0800 Subject: [PATCH 32/55] =?UTF-8?q?:bug:=20=E4=BF=AE=E6=AD=A3=E5=BE=85?= =?UTF-8?q?=E5=9B=9E=E5=8D=95=E7=A1=AE=E8=AE=A4=E5=8D=95=E6=B5=8B=E7=9A=84?= =?UTF-8?q?=E6=A1=A9=E8=BF=94=E5=9B=9E=E7=B1=BB=E5=9E=8B=E4=B8=8E=E5=A4=B9?= =?UTF-8?q?=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/impl/DriverWaybillAutoCompleteTest.java | 9 ++++++++- .../service/impl/DriverWaybillTabStatusTest.java | 13 ++++++++++++- .../impl/WaybillEditSaveBusinessStatusTest.java | 2 +- 3 files changed, 21 insertions(+), 3 deletions(-) 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 3c1f83fe..6a23c1ac 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 @@ -72,6 +72,13 @@ class DriverWaybillAutoCompleteTest { {"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"yes"}] """; + /** 装货 → 签收两个司机打卡节点,末位是签收,回单需确认完成 */ + private static final String LOAD_SIGN_RETURN_CONFIRM_JSON = """ + [{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true}, + {"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true}, + {"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"yes"}] + """; + /** 末位打卡节点是「发货」,不是到货/卸货/签收 */ private static final String DISPATCH_LAST_JSON = """ [{"key":"dispatch","name":"发货","type":"cargo","enabled":true,"punch":true}] @@ -190,7 +197,7 @@ class DriverWaybillAutoCompleteTest { @Test @DisplayName("回单需确认但不是末位打卡:运单留在进行中") void nonLastPunchDoesNotEnterReturnConfirmation() { - givenWaybill(SIGN_LAST_RETURN_CONFIRM_JSON, "running"); + givenWaybill(LOAD_SIGN_RETURN_CONFIRM_JSON, "running"); DriverNodePunchVO vo = submit("load"); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillTabStatusTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillTabStatusTest.java index 5de8ac1d..668dc965 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillTabStatusTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillTabStatusTest.java @@ -152,7 +152,18 @@ class DriverWaybillTabStatusTest { @SuppressWarnings("unchecked") private Collection queriedStatuses() { assertEquals(1, queried.size(), "一次列表查询"); - return ((LambdaQueryWrapper) queried.get(0)).getParamNameValuePairs().values(); + LambdaQueryWrapper wrapper = (LambdaQueryWrapper) queried.get(0); + // 参数槽由 SQL 片段构建时填充:不先取一次片段,参数表是空的 + wrapper.getSqlSegment(); + Collection statuses = new ArrayList<>(); + wrapper.getParamNameValuePairs().values().forEach(value -> { + if (value instanceof Collection items) { + statuses.addAll(items); + } else { + statuses.add(value); + } + }); + return statuses; } @SuppressWarnings("unchecked") diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillEditSaveBusinessStatusTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillEditSaveBusinessStatusTest.java index 626d8886..db84583a 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillEditSaveBusinessStatusTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillEditSaveBusinessStatusTest.java @@ -175,7 +175,7 @@ class WaybillEditSaveBusinessStatusTest { when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(oldRecord); try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { - authUtil.when(AuthUtil::getDeptId).thenReturn(DEPT_ID); + authUtil.when(AuthUtil::getDeptId).thenReturn(String.valueOf(DEPT_ID)); assertTrue(service.submit(request)); } } From 987f8ec6a8d07b4bf982f5a14c4ec583d4d7266f Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Thu, 22 Oct 2026 20:05:49 +0800 Subject: [PATCH 33/55] =?UTF-8?q?:sparkles:=20=E8=BF=90=E5=8D=95=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E5=85=B3=E9=94=AE=E5=AD=97=E6=94=AF=E6=8C=81=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E5=90=8D=E4=B8=8E=E5=8F=B8=E6=9C=BA=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/springblade/transport/pojo/vo/TripQueryVO.java | 2 +- .../transport/controller/DriverWaybillController.java | 6 +++--- .../transport/controller/ManageWaybillController.java | 4 ++-- .../transport/service/impl/ManageWaybillServiceImpl.java | 9 +++++++-- .../transport/service/impl/TripQueryServiceImpl.java | 8 ++++++-- .../transport/service/impl/TripQueryServiceImplTest.java | 2 ++ 6 files changed, 21 insertions(+), 10 deletions(-) diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripQueryVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripQueryVO.java index 5027ada9..569e96ca 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripQueryVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripQueryVO.java @@ -62,7 +62,7 @@ public class TripQueryVO implements Serializable { @Schema(description = "多档状态:同上取值,非空时优先于 status(待处理列表要待接单与进行中两档)") private List statuses; - @Schema(description = "关键字:运单号 / 配载单号 / 起终点 / 车牌") + @Schema(description = "关键字:运单号 / 配载单号 / 项目 / 司机 / 车牌 / 起终点 / 地址(任一子单命中即整趟车命中)") private String keyword; @Schema(description = "运输组织类型:load 公路配载 / common 普通运输,不传为全部(按子单行筛选)") diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverWaybillController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverWaybillController.java index dfba67e8..313db1da 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverWaybillController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverWaybillController.java @@ -92,7 +92,7 @@ public class DriverWaybillController extends BladeController { @Parameter(description = "当前页") @RequestParam(required = false) Integer current, @Parameter(description = "每页条数") @RequestParam(required = false) Integer size, @Parameter(description = "状态:0待接单/1运输中/2已完成,不传为全部") @RequestParam(required = false) String status, - @Parameter(description = "关键字:运单号/起终点") @RequestParam(required = false) String keyword) { + @Parameter(description = "关键字:运单号/配载单号/项目/司机/车牌/起终点/地址") @RequestParam(required = false) String keyword) { Integer statusCode = parseStatus(status); return R.data(driverWaybillService.page(current, size, statusCode, keyword)); } @@ -129,12 +129,12 @@ public class DriverWaybillController extends BladeController { @GetMapping("/task/page") @ApiOperationSupport(order = 24) - @Operation(summary = "运次分页列表", description = "一页即若干个运次;status:空=全部,0待接单,1运输中,2已完成;keyword 支持运单号/配载单号/起终点/车牌") + @Operation(summary = "运次分页列表", description = "一页即若干个运次;status:空=全部,0待接单,1运输中,2已完成;keyword 支持运单号/配载单号/项目/司机/车牌/起终点/地址") public R> tripPage( @Parameter(description = "当前页") @RequestParam(required = false) Integer current, @Parameter(description = "每页运次数") @RequestParam(required = false) Integer size, @Parameter(description = "状态:0待接单/1运输中/2已完成,不传为全部") @RequestParam(required = false) String status, - @Parameter(description = "关键字:运单号/配载单号/起终点/车牌") @RequestParam(required = false) String keyword) { + @Parameter(description = "关键字:运单号/配载单号/项目/司机/车牌/起终点/地址") @RequestParam(required = false) String keyword) { Integer statusCode = parseStatus(status); return R.data(driverWaybillService.tripPage(current, size, statusCode, keyword)); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageWaybillController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageWaybillController.java index 67c2d15c..81265fc4 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageWaybillController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageWaybillController.java @@ -88,7 +88,7 @@ public class ManageWaybillController extends BladeController { public R> list( @Parameter(description = "当前页") @RequestParam(required = false) Integer current, @Parameter(description = "每页条数") @RequestParam(required = false) Integer size, - @Parameter(description = "关键字:运单号/司机/车牌") @RequestParam(required = false) String keyword, + @Parameter(description = "关键字:运单号/配载单号/项目/司机/车牌/起终点/地址") @RequestParam(required = false) String keyword, @Parameter(description = "状态:0待接单/1运输中/2已完成,不传为全部") @RequestParam(required = false) String status, @Parameter(description = "异常:exception有异常/normal无异常") @RequestParam(required = false) String exception, @Parameter(description = "运输组织:common普通/load配载") @RequestParam(required = false) String transportType, @@ -112,7 +112,7 @@ public class ManageWaybillController extends BladeController { public R> pending( @Parameter(description = "当前页") @RequestParam(required = false) Integer current, @Parameter(description = "每页条数") @RequestParam(required = false) Integer size, - @Parameter(description = "关键字:运单号/司机/车牌") @RequestParam(required = false) String keyword, + @Parameter(description = "关键字:运单号/配载单号/项目/司机/车牌/起终点/地址") @RequestParam(required = false) String keyword, @Parameter(description = "是否需重新派单") @RequestParam(required = false) Boolean needReassign) { return R.data(manageWaybillService.pendingList(current, size, keyword, needReassign)); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java index a0ee73f3..d9cb9550 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java @@ -726,13 +726,18 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { return loading == null || loading.getId() == null ? fallback : loading.getId(); } - /** 分组查询的关键字:运单号 / 司机 / 车牌,与列表关键字口径一致 */ + /** + * 分组查询的关键字:逐行模糊匹配运单号 / 司机 / 车牌 / 项目。 + *

+ * 这里是逐行 LIKE,不是运次级匹配:命中哪一行只决定这趟车要不要出现,不缩小它的子单集合。 + */ private void applyGroupKeyword(QueryWrapper wrapper, String keyword) { if (Func.isBlank(keyword)) { return; } String key = keyword.trim(); - wrapper.and(w -> w.like("waybill_no", key).or().like("driver_name", key).or().like("vehicle_no", key)); + wrapper.and(w -> w.like("waybill_no", key).or().like("driver_name", key) + .or().like("vehicle_no", key).or().like("project_name", key)); } private boolean hasExceptionIds(Waybill waybill, Set exceptionWaybillIds) { 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 3a3de257..92b3962c 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 @@ -90,10 +90,14 @@ public class TripQueryServiceImpl implements ITripQueryService { private static final String EXCEPTION_YES = "exception"; private static final String EXCEPTION_NO = "normal"; - /** 关键字在子单上匹配的字段 */ + /** + * 关键字在子单上匹配的字段。 + *

+ * project_name 是运单创建时写入的快照列:项目改名后旧运单仍是旧名,按新名搜不到这些单。 + */ private static final String[] KEYWORD_COLUMNS = { "waybill_no", "loading_no", "departure_name", "arrival_name", - "departure_address", "arrival_address", "vehicle_no" + "departure_address", "arrival_address", "vehicle_no", "driver_name", "project_name" }; private final IWaybillService waybillService; diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripQueryServiceImplTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripQueryServiceImplTest.java index 8679be08..c2b11f0c 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripQueryServiceImplTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripQueryServiceImplTest.java @@ -221,6 +221,8 @@ class TripQueryServiceImplTest { String keywordSql = capturedKeywordSql(); assertTrue(keywordSql.contains("waybill_no LIKE ?"), "关键字在子单上找:" + keywordSql); assertTrue(keywordSql.contains("loading_no LIKE ?"), keywordSql); + assertTrue(keywordSql.contains("driver_name LIKE ?"), "司机名也参与匹配:" + keywordSql); + assertTrue(keywordSql.contains("project_name LIKE ?"), "项目名(运单上的快照列)也参与匹配:" + keywordSql); assertEquals(2, trip.getWaybillCount(), "整趟车的子单都留着,不按命中的那一张缩小"); assertTrue(capturedRowSql().contains("loading_no IN ("), "命中的运次键换成配载单号下发:" + capturedRowSql()); } From cb83f5bffdad6a732d134c9d97d35b50491b0df1 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Thu, 22 Oct 2026 23:32:33 +0800 Subject: [PATCH 34/55] =?UTF-8?q?:sparkles:=20=E9=A6=96=E9=A1=B5=E5=BC=82?= =?UTF-8?q?=E5=B8=B8=E5=BE=85=E5=8A=9E=E7=AB=8B=E5=8D=B3=E5=A4=84=E7=BD=AE?= =?UTF-8?q?=E7=9B=B4=E8=BE=BE=E8=AF=A5=E6=9D=A1=E5=BC=82=E5=B8=B8=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 待处理事项的 targetUrl 由异常列表页改为 subpackages/admin/disposal-detail?id=<异常处置记录主键>;id 在服务端拼进路径,避免 int64 主键经 JSON 下发到小程序后丢精度。 --- .../transport/service/impl/ManageWaybillServiceImpl.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java index d9cb9550..f50ae11f 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java @@ -112,6 +112,8 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { private static final String TODO_REASSIGN = "reassign"; /** 重派条目跳转页码:参数 id 是运单主键(不是运单号),该页据此回显运单;buyerPaid 恒为 0,与列表卡片一致 */ private static final String REASSIGN_TARGET_URL = "/pages/waybill/reassign?id=%s&buyerPaid=0"; + /** 异常条目跳转页码:参数 id 是异常处置记录主键,该页直接按 id 拉详情 */ + private static final String EXCEPTION_TARGET_URL = "/subpackages/admin/disposal-detail?id=%s"; private static final String EXCEPTION_YES = "exception"; private static final String EXCEPTION_NO = "normal"; @@ -1149,8 +1151,8 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { item.setDesc(buildExceptionDesc(disposal)); item.setWaybillNo(Func.toStr(disposal.getWaybillNo(), "")); item.setActionLabel("立即处置"); - String status = Func.toStr(disposal.getDisposalStatus(), DISPOSAL_PENDING); - item.setTargetUrl("/subpackages/admin/exception?status=" + status); + // 待处理事项的「立即处置」直接落到该条异常的处置详情页,不再先到异常列表 + item.setTargetUrl(String.format(EXCEPTION_TARGET_URL, disposal.getId())); return item; } From 7f4dc9493488c8bddc9154e1e2a9a45c2dcdf011 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Fri, 23 Oct 2026 00:37:59 +0800 Subject: [PATCH 35/55] =?UTF-8?q?:sparkles:=20=E6=96=B0=E5=A2=9E=E5=BC=82?= =?UTF-8?q?=E5=B8=B8=E6=8B=92=E7=BB=9D=E4=B8=8E=E6=92=A4=E9=94=80=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/ExceptionDisposalRejectRequest.java | 55 +++ .../ExceptionDisposalController.java | 19 +- .../service/IExceptionDisposalService.java | 12 + .../impl/ExceptionDisposalServiceImpl.java | 58 ++++ .../ExceptionDisposalRejectRevokeTest.java | 317 ++++++++++++++++++ 5 files changed, 460 insertions(+), 1 deletion(-) create mode 100644 blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalRejectRequest.java create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRejectRevokeTest.java diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalRejectRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalRejectRequest.java new file mode 100644 index 00000000..2e16aac2 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalRejectRequest.java @@ -0,0 +1,55 @@ +/** + * 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. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 异常处置拒绝请求 + *

+ * 只有拒绝原因一个业务字段:拒绝复用了结终态,不留独立列, + * 原因随跟进记录落进 follow_content。 + * + * @author Chill + */ +@Data +@Schema(description = "异常处置拒绝请求") +public class ExceptionDisposalRejectRequest implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "异常处置ID") + private Long id; + + @Schema(description = "拒绝原因,必填,最多200字") + private String rejectReason; + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ExceptionDisposalController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ExceptionDisposalController.java index 855f37ec..01ae89e1 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ExceptionDisposalController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ExceptionDisposalController.java @@ -33,6 +33,7 @@ import org.springblade.core.mp.support.Query; import org.springblade.core.secure.annotation.PreAuth; import org.springblade.core.tool.api.R; import org.springblade.transport.pojo.dto.ExceptionDisposalFollowRequest; +import org.springblade.transport.pojo.dto.ExceptionDisposalRejectRequest; import org.springblade.transport.pojo.dto.ExceptionDisposalUpdateRequest; import org.springblade.transport.pojo.entity.ExceptionDisposal; import org.springblade.transport.pojo.vo.ExceptionDisposalVO; @@ -50,7 +51,7 @@ import org.springframework.web.bind.annotation.RestController; *

* 对外路径:{@code /api/blade-transport/exception-disposal/**} * 同时兼容未去前缀直连 {@code /blade-transport/exception-disposal/**}。 - * 列表 / 详情 / 上报 / 跟进 / 完成均仅需登录态(小程序调度端与司机端共用)。 + * 列表 / 详情 / 上报 / 跟进 / 完成 / 拒绝 / 撤销均仅需登录态(小程序调度端与司机端共用)。 */ @RestController @AllArgsConstructor @@ -114,4 +115,20 @@ public class ExceptionDisposalController extends BladeController { return R.success("编辑成功"); } + @PostMapping("/reject") + @ApiOperationSupport(order = 8) + @Operation(summary = "拒绝异常", description = "调度端拒绝上报:先写一条拒绝原因的跟进记录,再了结;仅需登录态") + public R reject(@RequestBody ExceptionDisposalRejectRequest request) { + exceptionDisposalService.reject(request); + return R.success("已拒绝"); + } + + @PostMapping("/revoke") + @ApiOperationSupport(order = 9) + @Operation(summary = "撤销异常上报", description = "上报人撤销自己未了结的上报,不写跟进记录;仅需登录态") + public R revoke(@RequestBody ExceptionDisposalFollowRequest request) { + exceptionDisposalService.revoke(request.getId()); + return R.success("已撤销"); + } + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java index d23ca19c..ed63c310 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java @@ -25,6 +25,7 @@ package org.springblade.transport.service; import com.baomidou.mybatisplus.core.metadata.IPage; import org.springblade.core.mp.base.BaseService; import org.springblade.transport.pojo.dto.ExceptionDisposalFollowRequest; +import org.springblade.transport.pojo.dto.ExceptionDisposalRejectRequest; import org.springblade.transport.pojo.dto.ExceptionDisposalUpdateRequest; import org.springblade.transport.pojo.entity.ExceptionDisposal; import org.springblade.transport.pojo.vo.ExceptionDisposalVO; @@ -56,4 +57,15 @@ public interface IExceptionDisposalService extends BaseService REJECT_REASON_MAX_LENGTH) { + throw new ServiceException("拒绝原因不能超过200字"); + } + + String rejectContent = "拒绝:" + rejectReason; + ExceptionDisposalFollowRecord record = new ExceptionDisposalFollowRecord(); + record.setDisposalId(disposal.getId()); + record.setFollowContent(rejectContent); + // 拒绝弹窗只有原因、没有附件,attachments_json 不写 + record.setFollowUser(AuthUtil.getUserId()); + record.setFollowUserName(UserDisplayCache.getUserRealName(AuthUtil.getUserId())); + record.setFollowTime(LocalDateTime.now()); + followRecordMapper.insert(record); + + // 记录先于状态落库,且同在一个事务里:结案了却没有拒绝记录,事后无从知道是谁以什么理由拒绝的 + disposal.setDisposalStatus(STATUS_COMPLETED); + disposal.setLatestFollowContent(rejectContent); + updateById(disposal); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void revoke(Long id) { + ExceptionDisposal disposal = getExisting(id); + Long currentUserId = AuthUtil.getUserId(); + // 撤销是上报人收回自己的上报,不是一次处置:别的账号(包括调度员)要推翻上报只能走拒绝 + if (currentUserId == null || !Objects.equals(disposal.getReporterId(), currentUserId)) { + throw new ServiceException("只能撤销本人上报的异常"); + } + if (WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES.contains(disposal.getDisposalStatus())) { + throw new ServiceException("该异常已处理"); + } + // 不写跟进记录,也不动 latest_follow_content:处置时间线里不该出现一条由上报人「处置」的记录 + disposal.setDisposalStatus(STATUS_REVOKED); + updateById(disposal); + } + private LambdaQueryWrapper buildQuery(ExceptionDisposalVO query) { LambdaQueryWrapper wrapper = Wrappers.lambdaQuery() .eq(ExceptionDisposal::getIsDeleted, 0) @@ -488,6 +545,7 @@ public class ExceptionDisposalServiceImpl case STATUS_PENDING -> "待处理"; case STATUS_PROCESSING -> "处理中"; case STATUS_COMPLETED -> "已完成"; + case STATUS_REVOKED -> "已撤销"; default -> ""; }; } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRejectRevokeTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRejectRevokeTest.java new file mode 100644 index 00000000..5b9384f6 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRejectRevokeTest.java @@ -0,0 +1,317 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.system.cache.UserDisplayCache; +import org.springblade.transport.mapper.ExceptionDisposalFollowRecordMapper; +import org.springblade.transport.mapper.ExceptionDisposalMapper; +import org.springblade.transport.pojo.dto.ExceptionDisposalRejectRequest; +import org.springblade.transport.pojo.entity.ExceptionDisposal; +import org.springblade.transport.pojo.entity.ExceptionDisposalFollowRecord; +import org.springblade.transport.pojo.vo.ExceptionDisposalVO; +import org.springblade.transport.service.IDriverWaybillService; +import org.springblade.transport.service.IWaybillService; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 拒绝与撤销:两个动作都了结异常,但只有拒绝留痕。 + *

+ * 拒绝复用已完成这个终态(台账仍显示「已处理」),所以它与「完成」的区别只落在跟进记录的第一句; + * 撤销是上报人收回,不是处置,因此不留跟进记录。 + */ +class ExceptionDisposalRejectRevokeTest { + + private static final Long DISPOSAL_ID = 6101L; + private static final Long REPORTER_ID = 7001L; + + private ExceptionDisposalMapper disposalMapper; + private ExceptionDisposalFollowRecordMapper followRecordMapper; + private ExceptionDisposalServiceImpl service; + + @BeforeAll + static void initTableInfo() { + // 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class); + } + + @BeforeEach + void setUp() throws Exception { + disposalMapper = mock(ExceptionDisposalMapper.class); + followRecordMapper = mock(ExceptionDisposalFollowRecordMapper.class); + service = new ExceptionDisposalServiceImpl( + followRecordMapper, mock(IWaybillService.class), mock(IDriverWaybillService.class)); + inject("baseMapper", disposalMapper); + when(disposalMapper.updateById(any(ExceptionDisposal.class))).thenReturn(1); + when(followRecordMapper.insert(any(ExceptionDisposalFollowRecord.class))).thenReturn(1); + } + + // ------------------------------------------------------------ 拒绝 + + @Test + @DisplayName("待处理异常被拒绝后了结,并留下一条带「拒绝:」前缀的跟进记录") + void rejectSettlesPendingDisposalWithFollowRecord() { + stubDisposal("pending", REPORTER_ID); + + runAsReporter(() -> service.reject(rejectRequest("地址核实无误,按原计划执行"))); + + ExceptionDisposalFollowRecord record = insertedRecord(); + assertEquals("拒绝:地址核实无误,按原计划执行", record.getFollowContent()); + assertEquals(DISPOSAL_ID, record.getDisposalId()); + assertEquals(REPORTER_ID, record.getFollowUser()); + assertEquals("韦调度", record.getFollowUserName()); + assertNotNull(record.getFollowTime()); + assertNull(record.getAttachmentsJson(), "拒绝弹窗没有附件,跟进记录不得凭空写一份"); + + ExceptionDisposal settled = updatedDisposal(); + assertEquals("completed", settled.getDisposalStatus(), "拒绝复用已完成终态,不新增状态值"); + assertEquals("拒绝:地址核实无误,按原计划执行", settled.getLatestFollowContent(), + "列表上的最新跟进说明要与跟进记录同一条文案"); + verify(followRecordMapper).insert(any(ExceptionDisposalFollowRecord.class)); + } + + // ------------------------------------------------------------ 拒绝门禁与校验 + + @Test + @DisplayName("处理中的异常同样能被拒绝并了结") + void rejectSettlesProcessingDisposal() { + stubDisposal("processing", REPORTER_ID); + + runAsReporter(() -> service.reject(rejectRequest("按原计划执行"))); + + assertEquals("completed", updatedDisposal().getDisposalStatus()); + assertEquals("拒绝:按原计划执行", insertedRecord().getFollowContent()); + } + + @Test + @DisplayName("已完成的异常再拒绝报「该异常已处理」,且不多写记录、不改状态") + void rejectSettledDisposalRejected() { + stubDisposal("completed", REPORTER_ID); + + ServiceException exception = assertThrows(ServiceException.class, + () -> runAsReporter(() -> service.reject(rejectRequest("地址核实无误")))); + + assertEquals("该异常已处理", exception.getMessage()); + verify(followRecordMapper, never()).insert(any(ExceptionDisposalFollowRecord.class)); + verify(disposalMapper, never()).updateById(any(ExceptionDisposal.class)); + } + + @Test + @DisplayName("已撤销的异常同属了结态,再拒绝也是「该异常已处理」") + void rejectRevokedDisposalRejected() { + stubDisposal("revoked", REPORTER_ID); + + ServiceException exception = assertThrows(ServiceException.class, + () -> runAsReporter(() -> service.reject(rejectRequest("地址核实无误")))); + + assertEquals("该异常已处理", exception.getMessage()); + verify(followRecordMapper, never()).insert(any(ExceptionDisposalFollowRecord.class)); + } + + @Test + @DisplayName("拒绝原因只有空白时不肯了结,要求填写原因") + void blankRejectReasonRejected() { + stubDisposal("pending", REPORTER_ID); + + ServiceException exception = assertThrows(ServiceException.class, + () -> runAsReporter(() -> service.reject(rejectRequest(" ")))); + + assertEquals("请填写拒绝原因", exception.getMessage()); + verify(followRecordMapper, never()).insert(any(ExceptionDisposalFollowRecord.class)); + verify(disposalMapper, never()).updateById(any(ExceptionDisposal.class)); + } + + @Test + @DisplayName("拒绝原因超过200字被拒,避免撑爆跟进记录列") + void tooLongRejectReasonRejected() { + stubDisposal("pending", REPORTER_ID); + + ServiceException exception = assertThrows(ServiceException.class, + () -> runAsReporter(() -> service.reject(rejectRequest("理".repeat(201))))); + + assertEquals("拒绝原因不能超过200字", exception.getMessage()); + verify(followRecordMapper, never()).insert(any(ExceptionDisposalFollowRecord.class)); + } + + @Test + @DisplayName("拒绝原因去掉首尾空白后落库") + void rejectReasonIsTrimmed() { + stubDisposal("pending", REPORTER_ID); + + runAsReporter(() -> service.reject(rejectRequest(" 地址核实无误 "))); + + assertEquals("拒绝:地址核实无误", insertedRecord().getFollowContent()); + assertEquals("拒绝:地址核实无误", updatedDisposal().getLatestFollowContent()); + } + + @Test + @DisplayName("已撤销的异常状态名为「已撤销」,不能是空串") + void revokedStatusHasDisplayName() { + stubDisposal("revoked", REPORTER_ID); + when(followRecordMapper.selectList(any())).thenReturn(List.of()); + + ExceptionDisposalVO vo = asReporter(() -> service.detail(DISPOSAL_ID)); + + assertEquals("已撤销", vo.getDisposalStatusName()); + } + + // ------------------------------------------------------------ 撤销 + + @Test + @DisplayName("上报人撤销自己的待处理异常:置为已撤销,不留跟进记录") + void revokeOwnPendingDisposal() { + stubDisposal("pending", REPORTER_ID); + + runAsReporter(() -> service.revoke(DISPOSAL_ID)); + + assertEquals("revoked", updatedDisposal().getDisposalStatus()); + verify(followRecordMapper, never()).insert(any(ExceptionDisposalFollowRecord.class)); + } + + @Test + @DisplayName("已经有人跟进(处理中)的异常,上报人仍能撤销:撤销取回的是上报本身") + void revokeOwnProcessingDisposal() { + stubDisposal("processing", REPORTER_ID); + + runAsReporter(() -> service.revoke(DISPOSAL_ID)); + + assertEquals("revoked", updatedDisposal().getDisposalStatus()); + } + + @Test + @DisplayName("撤销别人的上报被拒:撤销只能由上报人发起") + void revokeOthersDisposalRejected() { + stubDisposal("pending", 7002L); + + ServiceException exception = assertThrows(ServiceException.class, + () -> runAsReporter(() -> service.revoke(DISPOSAL_ID))); + + assertEquals("只能撤销本人上报的异常", exception.getMessage()); + verify(disposalMapper, never()).updateById(any(ExceptionDisposal.class)); + verify(followRecordMapper, never()).insert(any(ExceptionDisposalFollowRecord.class)); + } + + @Test + @DisplayName("没有上报人(历史数据)时不得撤销:份属不明按非本人处理") + void revokeDisposalWithoutReporterRejected() { + stubDisposal("pending", null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> runAsReporter(() -> service.revoke(DISPOSAL_ID))); + + assertEquals("只能撤销本人上报的异常", exception.getMessage()); + verify(disposalMapper, never()).updateById(any(ExceptionDisposal.class)); + } + + @Test + @DisplayName("已完成的异常不能再撤销") + void revokeSettledDisposalRejected() { + stubDisposal("completed", REPORTER_ID); + + ServiceException exception = assertThrows(ServiceException.class, + () -> runAsReporter(() -> service.revoke(DISPOSAL_ID))); + + assertEquals("该异常已处理", exception.getMessage()); + verify(disposalMapper, never()).updateById(any(ExceptionDisposal.class)); + } + + @Test + @DisplayName("已撤销的异常再撤销同样是「该异常已处理」") + void revokeAlreadyRevokedDisposalRejected() { + stubDisposal("revoked", REPORTER_ID); + + ServiceException exception = assertThrows(ServiceException.class, + () -> runAsReporter(() -> service.revoke(DISPOSAL_ID))); + + assertEquals("该异常已处理", exception.getMessage()); + } + + // ------------------------------------------------------------ 夹具 + + private void stubDisposal(String status, Long reporterId) { + ExceptionDisposal disposal = new ExceptionDisposal(); + disposal.setId(DISPOSAL_ID); + disposal.setDisposalStatus(status); + disposal.setReporterId(reporterId); + disposal.setIsDeleted(0); + when(disposalMapper.selectById(DISPOSAL_ID)).thenReturn(disposal); + } + + private ExceptionDisposalRejectRequest rejectRequest(String reason) { + ExceptionDisposalRejectRequest request = new ExceptionDisposalRejectRequest(); + request.setId(DISPOSAL_ID); + request.setRejectReason(reason); + return request; + } + + private ExceptionDisposalFollowRecord insertedRecord() { + ArgumentCaptor captor = + ArgumentCaptor.forClass(ExceptionDisposalFollowRecord.class); + verify(followRecordMapper).insert(captor.capture()); + return captor.getValue(); + } + + private ExceptionDisposal updatedDisposal() { + ArgumentCaptor captor = ArgumentCaptor.forClass(ExceptionDisposal.class); + verify(disposalMapper).updateById(captor.capture()); + return captor.getValue(); + } + + /** 以调度员身份执行,登录态与真实姓名都走缓存 */ + private void runAsReporter(Runnable action) { + asReporter(() -> { + action.run(); + return null; + }); + } + + private T asReporter(Supplier action) { + try (MockedStatic authUtil = mockStatic(AuthUtil.class); + MockedStatic userCache = mockStatic(UserDisplayCache.class)) { + authUtil.when(AuthUtil::getUserId).thenReturn(REPORTER_ID); + userCache.when(() -> UserDisplayCache.getUserRealName(REPORTER_ID)).thenReturn("韦调度"); + return action.get(); + } + } + + private void inject(String field, Object value) throws Exception { + Class type = service.getClass(); + while (type != null) { + try { + Field declared = type.getDeclaredField(field); + declared.setAccessible(true); + declared.set(service, value); + return; + } catch (NoSuchFieldException ignored) { + type = type.getSuperclass(); + } + } + throw new IllegalStateException("未找到字段:" + field); + } + +} From b56da11a6c653875fdda7d2be2c63a1675ad9e9a Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Fri, 23 Oct 2026 00:58:47 +0800 Subject: [PATCH 36/55] =?UTF-8?q?:sparkles:=20=E6=96=B0=E5=A2=9E=E5=BC=82?= =?UTF-8?q?=E5=B8=B8=E5=A4=84=E7=BD=AE=E5=90=8C=E6=84=8F=E5=B9=B6=E8=B0=83?= =?UTF-8?q?=E6=95=B4=E8=B7=AF=E7=BA=BF=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ExceptionDisposalRouteAdjustRequest.java | 78 +++ .../ExceptionDisposalController.java | 9 + .../service/IExceptionDisposalService.java | 7 + .../impl/ExceptionDisposalServiceImpl.java | 501 +++++++++++++++- .../impl/ExceptionDisposalListQueryTest.java | 4 +- .../ExceptionDisposalRejectRevokeTest.java | 4 +- .../impl/ExceptionDisposalReportGateTest.java | 4 +- .../ExceptionDisposalRouteAdjustTest.java | 551 ++++++++++++++++++ 8 files changed, 1147 insertions(+), 11 deletions(-) create mode 100644 blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalRouteAdjustRequest.java create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRouteAdjustTest.java diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalRouteAdjustRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalRouteAdjustRequest.java new file mode 100644 index 00000000..b8a78404 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalRouteAdjustRequest.java @@ -0,0 +1,78 @@ +/** + * 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. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 异常处置「同意并调整路线」请求 + *

+ * 站点按数组顺序即途经顺序回传,kind 是站序的声明:首个 start、末个 end、其余 transit。 + * 每个站点上的 tags 指向配载单里第 N 张子运单(形如「装1」「卸2」),后端据此落成站点行; + * 没有配载单的运单没有子运单归属,tags 不参与落库。 + * + * @author Chill + */ +@Data +@Schema(description = "异常处置调整路线请求") +public class ExceptionDisposalRouteAdjustRequest implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "异常处置ID") + private Long id; + + @Schema(description = "调整后的站点,按途经顺序排列") + private List routeNodes; + + /** + * 一个站点:地址 + 该站装卸的子运单标记 + */ + @Data + @Schema(description = "路线站点") + public static class RouteNode implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "站点类型:start 首站、transit 中间站、end 末站") + private String kind; + + @Schema(description = "站点地址") + private String address; + + @Schema(description = "该站的装卸标记,形如「装1」「卸2」,数字是配载单子运单序号") + private List tags; + + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ExceptionDisposalController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ExceptionDisposalController.java index 01ae89e1..2b42643a 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ExceptionDisposalController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ExceptionDisposalController.java @@ -34,6 +34,7 @@ import org.springblade.core.secure.annotation.PreAuth; import org.springblade.core.tool.api.R; import org.springblade.transport.pojo.dto.ExceptionDisposalFollowRequest; import org.springblade.transport.pojo.dto.ExceptionDisposalRejectRequest; +import org.springblade.transport.pojo.dto.ExceptionDisposalRouteAdjustRequest; import org.springblade.transport.pojo.dto.ExceptionDisposalUpdateRequest; import org.springblade.transport.pojo.entity.ExceptionDisposal; import org.springblade.transport.pojo.vo.ExceptionDisposalVO; @@ -131,4 +132,12 @@ public class ExceptionDisposalController extends BladeController { return R.success("已撤销"); } + @PostMapping("/route-adjust") + @ApiOperationSupport(order = 10) + @Operation(summary = "同意并调整路线", description = "调度端同意上报的路线修改:先写跟进记录,再改路线,最后了结;仅需登录态") + public R adjustRoute(@RequestBody ExceptionDisposalRouteAdjustRequest request) { + exceptionDisposalService.adjustRoute(request); + return R.success("已调整路线"); + } + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java index ed63c310..de58cb2a 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java @@ -26,6 +26,7 @@ import com.baomidou.mybatisplus.core.metadata.IPage; import org.springblade.core.mp.base.BaseService; import org.springblade.transport.pojo.dto.ExceptionDisposalFollowRequest; import org.springblade.transport.pojo.dto.ExceptionDisposalRejectRequest; +import org.springblade.transport.pojo.dto.ExceptionDisposalRouteAdjustRequest; import org.springblade.transport.pojo.dto.ExceptionDisposalUpdateRequest; import org.springblade.transport.pojo.entity.ExceptionDisposal; import org.springblade.transport.pojo.vo.ExceptionDisposalVO; @@ -68,4 +69,10 @@ public interface IExceptionDisposalService extends BaseService stations = validateRouteNodes(request.getRouteNodes(), loading); + List sourceAddresses = loading != null + ? loadingStationAddresses(loading) + : waybillStationAddresses(waybill); + List targetAddresses = stations.stream().map(Station::address).toList(); + // 首站地址只读;原数据里首站地址缺失时无从比对,放行而不是拒绝整次调整 + if (!sourceAddresses.isEmpty() && Func.isNotBlank(sourceAddresses.get(0)) + && !Objects.equals(sourceAddresses.get(0), targetAddresses.get(0))) { + throw new ServiceException("起始地址不允许修改"); + } + String businessStatus = loading != null ? loading.getBusinessStatus() : waybill.getBusinessStatus(); + if (STATUS_COMPLETED.equals(businessStatus) || STATUS_CANCELLED.equals(businessStatus)) { + throw new ServiceException("当前状态不允许变更运输路线"); + } + RouteChangePlan plan = planRouteChange(loading, waybill, stations, sourceAddresses, targetAddresses); + + // 事务内顺序固定:跟进记录 → 载体路线 → 异常状态。 + // 少了任何一步都读不出「谁在什么时候把路线改成了什么」,所以任一步失败都整体回滚 + insertFollowRecord(disposal.getId(), plan.followContent()); + if (loading != null) { + applyLoadingRoute(loading, plan); + } else { + applyWaybillRoute(waybill, plan); + } + disposal.setDisposalStatus(STATUS_COMPLETED); + disposal.setLatestFollowContent(plan.followContent()); + updateById(disposal); + } + + /** 一条跟进记录:处置记录里「先写记录再改状态」的顺序由调用方的事务保证 */ + private void insertFollowRecord(Long disposalId, String content) { + ExceptionDisposalFollowRecord record = new ExceptionDisposalFollowRecord(); + record.setDisposalId(disposalId); + record.setFollowContent(content); + record.setFollowUser(AuthUtil.getUserId()); + record.setFollowUserName(UserDisplayCache.getUserRealName(AuthUtil.getUserId())); + record.setFollowTime(LocalDateTime.now()); + followRecordMapper.insert(record); + } + + /** 配载单按配载单号取,逻辑删除的视为没有 */ + private LoadingManage findLoading(String loadingNo) { + if (Func.isBlank(loadingNo)) { + return null; + } + return loadingManageMapper.selectOne(Wrappers.lambdaQuery() + .eq(LoadingManage::getLoadingNo, loadingNo.trim()) + .eq(LoadingManage::getIsDeleted, 0) + .last("LIMIT 1")); + } + + /** + * 校验并规范化站点:站数至少两个、首站 start、末站 end、其余 transit,地址都不能为空。 + *

+ * kind 不额外接受「按位置猜」的写法:站序就是落库顺序,kind 与位置不一致时写下去的首末站地址会是错的。 + */ + private List validateRouteNodes(List routeNodes, + LoadingManage loading) { + if (Func.isEmpty(routeNodes) || routeNodes.size() < 2) { + throw new ServiceException("路线节点至少需要两个"); + } + List stations = new ArrayList<>(routeNodes.size()); + for (int index = 0; index < routeNodes.size(); index++) { + ExceptionDisposalRouteAdjustRequest.RouteNode node = routeNodes.get(index); + if (node == null) { + throw new ServiceException("请完善路线节点"); + } + String address = Func.toStr(node.getAddress(), "").trim(); + if (Func.isBlank(address)) { + throw new ServiceException("站点地址不能为空"); + } + String expectedKind = index == 0 + ? NODE_KIND_START + : (index == routeNodes.size() - 1 ? NODE_KIND_END : NODE_KIND_TRANSIT); + if (!expectedKind.equals(Func.toStr(node.getKind(), "").trim())) { + throw new ServiceException("路线节点类型不正确"); + } + stations.add(new Station(address, normalizedTags(node.getTags()))); + } + assertTagsMatchChildWaybills(stations, loading); + return stations; + } + + private List normalizedTags(List tags) { + List normalized = new ArrayList<>(); + if (tags == null) { + return normalized; + } + for (String tag : tags) { + String text = Func.toStr(tag, "").trim(); + if (Func.isNotBlank(text)) { + normalized.add(text); + } + } + return normalized; + } + + /** + * 站点标记里的数字是配载单运单集合里的位次(1 起),越界的标记会让司机端找不到对应子运单, + * 那样的路线读不出装卸与货物,宁可报错也不落库。 + *

+ * 没有配载单就没有子运单序号可言:运单的路线段没有装卸归属,站点标记不参与落库。 + */ + private void assertTagsMatchChildWaybills(List stations, LoadingManage loading) { + if (loading == null) { + return; + } + int childCount = childWaybillCount(loading.getWaybillIdsJson()); + for (Station station : stations) { + for (String tag : station.tags()) { + String digits = tag.replaceAll("\\D+", ""); + int order = Func.isEmpty(digits) ? 0 : Func.toInt(digits, 0); + if (order < 1 || order > childCount) { + throw new ServiceException("路线节点未关联子运单"); + } + } + } + } + + /** 配载单运单集合的元素个数,即站点标记可用的位次上限 */ + private int childWaybillCount(String waybillIdsJson) { + if (Func.isBlank(waybillIdsJson)) { + return 0; + } + try { + List waybillIds = JsonUtil.parse(waybillIdsJson, List.class); + return waybillIds == null ? 0 : waybillIds.size(); + } catch (Exception ignored) { + // 运单集合不是 JSON 数组时按没有子运单处理,站点标记一律对不上 + return 0; + } + } + + /** 配载单当前路线的站点地址:优先站点行,没有站点行时回退到发货 / 途经 / 到货地址三列 */ + private List loadingStationAddresses(LoadingManage loading) { + List addresses = new ArrayList<>(); + for (Map row : jsonObjectList(loading.getRouteJson())) { + addAddress(addresses, text(row.get("address"))); + } + if (!addresses.isEmpty()) { + return addresses; + } + return addressColumns(loading.getDepartureAddress(), loading.getTransitAddress(), loading.getArrivalAddress()); + } + + /** + * 运单当前路线的站点地址:首段出发地是首站,其后每段的到达地各成一站。 + * 段里缺地址时退化用段名——运单详情与调整页用的也是这两列。 + */ + private List waybillStationAddresses(Waybill waybill) { + List> segments = routeSegments(waybill.getRouteJson()); + if (segments.isEmpty()) { + return addressColumns(waybill.getDepartureAddress(), null, waybill.getArrivalAddress()); + } + List addresses = new ArrayList<>(segments.size() + 1); + Map firstSegment = segments.get(0); + addresses.add(firstNonBlank(text(firstSegment.get("departureAddress")), + text(firstSegment.get("departureName")), Func.toStr(waybill.getDepartureAddress(), ""))); + for (Map segment : segments) { + addresses.add(firstNonBlank(text(segment.get("arrivalAddress")), text(segment.get("arrivalName")), "")); + } + return addresses; + } + + /** 发货 / 途经 / 到货三列拼成站点地址,途经列按中英文逗号拆开 */ + private List addressColumns(String departureAddress, String transitAddress, String arrivalAddress) { + List addresses = new ArrayList<>(3); + addAddress(addresses, departureAddress); + if (Func.isNotBlank(transitAddress)) { + for (String transit : transitAddress.split("[,,]")) { + addAddress(addresses, transit.trim()); + } + } + addAddress(addresses, arrivalAddress); + return addresses; + } + + private void addAddress(List addresses, String address) { + if (Func.isNotBlank(address)) { + addresses.add(address.trim()); + } + } + + /** + * 把校验过的站点拼成一次写入所需的全部内容:跟进记录文案、载体要写的路线与地址、合并后的任务信息。 + * 长度口径与 PC 改路线一致。 + */ + private RouteChangePlan planRouteChange(LoadingManage loading, Waybill waybill, List stations, + List sourceAddresses, List targetAddresses) { + String followContent = truncate(routeChangeContent(sourceAddresses, targetAddresses)); + String departureAddress = targetAddresses.get(0); + String arrivalAddress = targetAddresses.get(targetAddresses.size() - 1); + String routeJson; + String transitAddress = null; + String sourceTaskInfoJson; + if (loading != null) { + routeJson = stationRowsJson(stations); + transitAddress = targetAddresses.subList(1, targetAddresses.size() - 1).stream() + .collect(Collectors.joining(TRANSIT_ADDRESS_JOINER)); + sourceTaskInfoJson = loading.getTaskInfoJson(); + } else { + // 运单的 route_json 是路线段形状,司机端按段解析:只按段改地址,绝不整份换成站点行。 + // 没变就一个键都不动,避免把历史数据里缺省的字段顺手补齐成另一份结构。 + routeJson = sourceAddresses.equals(targetAddresses) + ? null + : rewriteWaybillRouteSegments(waybill.getRouteJson(), targetAddresses); + sourceTaskInfoJson = waybill.getTaskInfoJson(); + } + String taskInfoJson = appendRouteChangeRecord(sourceTaskInfoJson, followContent); + + TransportBusinessSupport.validateLength(routeJson, ROUTE_JSON_MAX_LENGTH, "路线信息不能超过8000字"); + TransportBusinessSupport.validateLength(departureAddress, ADDRESS_MAX_LENGTH, "发货地址不能超过255字"); + TransportBusinessSupport.validateLength(arrivalAddress, ADDRESS_MAX_LENGTH, "收货地址不能超过255字"); + TransportBusinessSupport.validateLength(taskInfoJson, TASK_INFO_JSON_MAX_LENGTH, "任务信息不能超过8000字"); + return new RouteChangePlan(followContent, routeJson, departureAddress, transitAddress, + arrivalAddress, taskInfoJson); + } + + /** + * 同一份结论既进跟进记录,也进 task_info_json 的变更记录: + * 首末站地址变了逐条写「出发地 / 到货地 旧 → 新」,否则说明途经站变化,完全没变给固定的一句。 + */ + private String routeChangeContent(List sourceAddresses, List targetAddresses) { + if (sourceAddresses.equals(targetAddresses)) { + return ROUTE_CHANGE_UNCHANGED; + } + List parts = new ArrayList<>(2); + String sourceStart = firstOf(sourceAddresses); + String targetStart = firstOf(targetAddresses); + if (!Objects.equals(sourceStart, targetStart)) { + parts.add("出发地 " + displayAddress(sourceStart) + " → " + displayAddress(targetStart)); + } + String sourceEnd = lastOf(sourceAddresses); + String targetEnd = lastOf(targetAddresses); + if (!Objects.equals(sourceEnd, targetEnd)) { + parts.add("到货地 " + displayAddress(sourceEnd) + " → " + displayAddress(targetEnd)); + } + if (parts.isEmpty()) { + // 首末站都没动,变化只能落在途经站:地址细节塞不进 500 字的跟进记录,按站数说明 + parts.add("途经站由 " + transitCount(sourceAddresses) + " 个改为 " + transitCount(targetAddresses) + " 个"); + } + return ROUTE_CHANGE_PREFIX + String.join(";", parts); + } + + private String firstOf(List addresses) { + return addresses.isEmpty() ? "" : addresses.get(0); + } + + private String lastOf(List addresses) { + return addresses.isEmpty() ? "" : addresses.get(addresses.size() - 1); + } + + private int transitCount(List addresses) { + return Math.max(addresses.size() - 2, 0); + } + + private String displayAddress(String address) { + return Func.isBlank(address) ? "-" : address; + } + + /** 配载单的路线是站点行:地址、展示地址、该站装卸的子运单标记 */ + private String stationRowsJson(List stations) { + List> rows = new ArrayList<>(stations.size()); + for (Station station : stations) { + Map row = new LinkedHashMap<>(4); + row.put("address", station.address()); + // 站点行没有独立的站名,展示地址与地址取同一份文本 + row.put("displayAddress", station.address()); + row.put("tags", station.tags()); + // locked 只影响 PC 编辑弹窗能否拖动,司机端不读;这里写入的路线始终可继续调整 + row.put("locked", false); + rows.add(row); + } + return JsonUtil.toJson(rows); + } + + /** + * 按路线段改写运单路线:第 i 段的到达地对应第 i+1 站,首段出发地对应首站。 + * 其它键(segmentNo、transportType、未知键)原样保留。 + *

+ * 站数变了或现有 route_json 不是可识别的段数组时返回 null,调用方跳过路线改写: + * 段形状没有装卸归属,说不出哪一段被删掉或被挪动,硬写等于重写这趟车的行程; + * 历史节点行(PC 改运单路线写下的 address + tags)同理不动,改写会让司机端读不出路线。 + */ + private String rewriteWaybillRouteSegments(String routeJson, List targetAddresses) { + List> segments = routeSegments(routeJson); + if (segments.isEmpty() || segments.size() + 1 != targetAddresses.size()) { + return null; + } + List> rewritten = new ArrayList<>(segments.size()); + for (int index = 0; index < segments.size(); index++) { + Map segment = new LinkedHashMap<>(segments.get(index)); + rewriteSegmentPoint(segment, "departure", index == 0 ? targetAddresses.get(0) : null); + rewriteSegmentPoint(segment, "arrival", targetAddresses.get(index + 1)); + rewritten.add(segment); + } + return JsonUtil.toJson(rewritten); + } + + private void rewriteSegmentPoint(Map segment, String point, String targetAddress) { + if (targetAddress == null) { + return; + } + String currentAddress = firstNonBlank(text(segment.get(point + "Address")), + text(segment.get(point + "Name")), ""); + if (Objects.equals(currentAddress, targetAddress)) { + return; + } + // 站名与地址是同一份文本:调整页只有一个地址输入框,改了地址站名也得跟着变 + segment.put(point + "Address", targetAddress); + segment.put(point + "Name", targetAddress); + } + + /** + * 识别路线段形状的 route_json。运单历史上存在两种形状,另一种是 PC 写下的节点行 + * (address + tags + 运单号),司机端按段解析读不出来,因此这里只认段:元素带段字段、 + * 且不带 address / tags。 + */ + private List> routeSegments(String routeJson) { + List> elements = jsonObjectList(routeJson); + for (Map element : elements) { + if (element.containsKey("address") || element.containsKey("tags")) { + return new ArrayList<>(); + } + if (!element.containsKey("departureAddress") && !element.containsKey("departureName") + && !element.containsKey("arrivalAddress") && !element.containsKey("arrivalName")) { + return new ArrayList<>(); + } + } + return elements; + } + + /** + * 任务信息是整列覆盖:读出原对象,只把本次变更记录插到最前面,其余键原样写回。 + * 原来的内容不是 JSON 对象(坏数据或历史数组)时按空对象重建,只丢这一列的无法解析部分, + * 不能让一次路线调整因为附件信息列而失败。 + */ + private String appendRouteChangeRecord(String taskInfoJson, String content) { + Map taskInfo = parseTaskInfo(taskInfoJson); + List records = new ArrayList<>(); + Map record = new LinkedHashMap<>(4); + record.put("changeTime", DateUtil.formatDateTime(LocalDateTime.now())); + record.put("changeUser", UserDisplayCache.getUserRealName(AuthUtil.getUserId())); + record.put("content", content); + record.put("remark", ""); + records.add(record); + Object existing = taskInfo.get(TASK_INFO_ROUTE_CHANGE_RECORDS); + if (existing instanceof List existingRecords) { + records.addAll(existingRecords); + } + taskInfo.put(TASK_INFO_ROUTE_CHANGE_RECORDS, records); + return JsonUtil.toJson(taskInfo); + } + + /** 任务信息只认 JSON 对象:数组与坏数据都按空对象重建,这一列不能挡住整次路线调整 */ + private Map parseTaskInfo(String taskInfoJson) { + Map taskInfo = new LinkedHashMap<>(); + if (Func.isBlank(taskInfoJson)) { + return taskInfo; + } + try { + Object parsed = JsonUtil.parse(taskInfoJson, Object.class); + if (parsed instanceof Map map) { + map.forEach((key, value) -> taskInfo.put(String.valueOf(key), value)); + } + } catch (Exception ignored) { + taskInfo.clear(); + } + return taskInfo; + } + + private void applyLoadingRoute(LoadingManage loading, RouteChangePlan plan) { + loading.setRouteJson(plan.routeJson()); + loading.setDepartureAddress(plan.departureAddress()); + loading.setTransitAddress(TransportBusinessSupport.trimToNull(plan.transitAddress())); + loading.setArrivalAddress(plan.arrivalAddress()); + loading.setTaskInfoJson(plan.taskInfoJson()); + if (loadingManageMapper.updateById(loading) <= 0) { + throw new ServiceException("变更运输路线失败"); + } + } + + private void applyWaybillRoute(Waybill waybill, RouteChangePlan plan) { + if (Func.isNotBlank(plan.routeJson())) { + waybill.setRouteJson(plan.routeJson()); + } + waybill.setDepartureAddress(plan.departureAddress()); + waybill.setArrivalAddress(plan.arrivalAddress()); + waybill.setTaskInfoJson(plan.taskInfoJson()); + if (!waybillService.updateById(waybill)) { + throw new ServiceException("变更运输路线失败"); + } + } + + /** 解析成对象数组;坏 JSON 与空值都按空数组处理,路线坏了不该让整次操作崩掉 */ + private List> jsonObjectList(String json) { + List> result = new ArrayList<>(); + if (Func.isBlank(json)) { + return result; + } + try { + Object parsed = JsonUtil.parse(json, List.class); + if (parsed instanceof List list) { + for (Object item : list) { + if (item instanceof Map map) { + Map element = new LinkedHashMap<>(); + map.forEach((key, value) -> element.put(String.valueOf(key), value)); + result.add(element); + } + } + } + } catch (Exception ignored) { + return new ArrayList<>(); + } + return result; + } + + private String text(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } + + private String firstNonBlank(String first, String second, String fallback) { + if (Func.isNotBlank(first)) { + return first; + } + return Func.isNotBlank(second) ? second : fallback; + } + + /** 规范化后的一个站点:地址 + 该站的装卸标记 */ + private record Station(String address, List tags) { + } + + /** 一次路线调整要落库的全部内容,写入阶段只负责 set 与 update */ + private record RouteChangePlan(String followContent, + String routeJson, + String departureAddress, + String transitAddress, + String arrivalAddress, + String taskInfoJson) { + } + private LambdaQueryWrapper buildQuery(ExceptionDisposalVO query) { LambdaQueryWrapper wrapper = Wrappers.lambdaQuery() .eq(ExceptionDisposal::getIsDeleted, 0) diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalListQueryTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalListQueryTest.java index 9d0dd4a1..a00ec202 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalListQueryTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalListQueryTest.java @@ -13,6 +13,7 @@ import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.springblade.transport.mapper.ExceptionDisposalFollowRecordMapper; import org.springblade.transport.mapper.ExceptionDisposalMapper; +import org.springblade.transport.mapper.LoadingManageMapper; import org.springblade.transport.pojo.entity.ExceptionDisposal; import org.springblade.transport.pojo.vo.ExceptionDisposalVO; import org.springblade.transport.service.IDriverWaybillService; @@ -55,7 +56,8 @@ class ExceptionDisposalListQueryTest { service = new ExceptionDisposalServiceImpl( mock(ExceptionDisposalFollowRecordMapper.class), mock(IWaybillService.class), - mock(IDriverWaybillService.class)); + mock(IDriverWaybillService.class), + mock(LoadingManageMapper.class)); inject("baseMapper", disposalMapper); when(disposalMapper.selectPage(any(), any())).thenReturn(new Page(1, 10)); } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRejectRevokeTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRejectRevokeTest.java index 5b9384f6..cdd36344 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRejectRevokeTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRejectRevokeTest.java @@ -14,6 +14,7 @@ import org.springblade.core.secure.utils.AuthUtil; import org.springblade.system.cache.UserDisplayCache; import org.springblade.transport.mapper.ExceptionDisposalFollowRecordMapper; import org.springblade.transport.mapper.ExceptionDisposalMapper; +import org.springblade.transport.mapper.LoadingManageMapper; import org.springblade.transport.pojo.dto.ExceptionDisposalRejectRequest; import org.springblade.transport.pojo.entity.ExceptionDisposal; import org.springblade.transport.pojo.entity.ExceptionDisposalFollowRecord; @@ -65,7 +66,8 @@ class ExceptionDisposalRejectRevokeTest { disposalMapper = mock(ExceptionDisposalMapper.class); followRecordMapper = mock(ExceptionDisposalFollowRecordMapper.class); service = new ExceptionDisposalServiceImpl( - followRecordMapper, mock(IWaybillService.class), mock(IDriverWaybillService.class)); + followRecordMapper, mock(IWaybillService.class), mock(IDriverWaybillService.class), + mock(LoadingManageMapper.class)); inject("baseMapper", disposalMapper); when(disposalMapper.updateById(any(ExceptionDisposal.class))).thenReturn(1); when(followRecordMapper.insert(any(ExceptionDisposalFollowRecord.class))).thenReturn(1); 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 4a64ae2a..1b13ec03 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 @@ -15,6 +15,7 @@ import org.springblade.core.secure.utils.AuthUtil; import org.springblade.system.cache.UserDisplayCache; import org.springblade.transport.mapper.ExceptionDisposalFollowRecordMapper; import org.springblade.transport.mapper.ExceptionDisposalMapper; +import org.springblade.transport.mapper.LoadingManageMapper; import org.springblade.transport.pojo.entity.ExceptionDisposal; import org.springblade.transport.pojo.entity.Waybill; import org.springblade.transport.service.IDriverWaybillService; @@ -71,7 +72,8 @@ class ExceptionDisposalReportGateTest { waybillService = mock(IWaybillService.class); driverWaybillService = mock(IDriverWaybillService.class); service = new ExceptionDisposalServiceImpl( - mock(ExceptionDisposalFollowRecordMapper.class), waybillService, driverWaybillService); + mock(ExceptionDisposalFollowRecordMapper.class), waybillService, driverWaybillService, + mock(LoadingManageMapper.class)); inject("baseMapper", disposalMapper); when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill()); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRouteAdjustTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRouteAdjustTest.java new file mode 100644 index 00000000..62780eaa --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRouteAdjustTest.java @@ -0,0 +1,551 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; +import org.mockito.MockedStatic; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.core.tool.jackson.JsonUtil; +import org.springblade.system.cache.UserDisplayCache; +import org.springblade.transport.mapper.ExceptionDisposalFollowRecordMapper; +import org.springblade.transport.mapper.ExceptionDisposalMapper; +import org.springblade.transport.mapper.LoadingManageMapper; +import org.springblade.transport.pojo.dto.ExceptionDisposalRouteAdjustRequest; +import org.springblade.transport.pojo.entity.ExceptionDisposal; +import org.springblade.transport.pojo.entity.ExceptionDisposalFollowRecord; +import org.springblade.transport.pojo.entity.LoadingManage; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.service.IDriverWaybillService; +import org.springblade.transport.service.IWaybillService; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +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.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 同意并调整路线:先写跟进记录、再改载体路线、最后了结。 + *

+ * 载体跟配载单走——配载单是这趟车的执行主体,司机端打卡读它的站点行;没有配载单才改运单, + * 而运单的 route_json 是司机端按段解析的形状,改成站点行就读不出路线了。 + */ +class ExceptionDisposalRouteAdjustTest { + + private static final Long DISPOSAL_ID = 8201L; + private static final Long WAYBILL_ID = 8301L; + private static final Long USER_ID = 8401L; + private static final String LOADING_NO = "PZ20261023001"; + private static final String START = "南宁市/邕宁区/那赖二路神网数码物流园"; + private static final String TRANSIT = "钦州市/沙坪坝区/泰安大道物流园"; + private static final String TRANSIT_SECOND = "贵阳市/白云区/都拉营物流园"; + private static final String END = "成都市/双流区/口岸路成藏宝物流园"; + private static final String NEW_END = "成都市/新都区/物流大道"; + + private ExceptionDisposalMapper disposalMapper; + private ExceptionDisposalFollowRecordMapper followRecordMapper; + private IWaybillService waybillService; + private LoadingManageMapper loadingManageMapper; + private ExceptionDisposalServiceImpl service; + + @BeforeAll + static void initTableInfo() { + // 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class); + TableInfoHelper.initTableInfo(assistant, LoadingManage.class); + } + + @BeforeEach + void setUp() throws Exception { + disposalMapper = mock(ExceptionDisposalMapper.class); + followRecordMapper = mock(ExceptionDisposalFollowRecordMapper.class); + waybillService = mock(IWaybillService.class); + loadingManageMapper = mock(LoadingManageMapper.class); + service = new ExceptionDisposalServiceImpl( + followRecordMapper, waybillService, mock(IDriverWaybillService.class), loadingManageMapper); + inject("baseMapper", disposalMapper); + when(disposalMapper.updateById(any(ExceptionDisposal.class))).thenReturn(1); + when(followRecordMapper.insert(any(ExceptionDisposalFollowRecord.class))).thenReturn(1); + when(loadingManageMapper.updateById(any(LoadingManage.class))).thenReturn(1); + when(waybillService.updateById(any(Waybill.class))).thenReturn(true); + } + + // ------------------------------------------------------------ 有配载单 + + @Test + @DisplayName("有配载单:路线整份写成站点行、地址列同步、变更记录前插、异常了结") + void adjustRouteOnLoadingWritesStationRows() { + stubPendingDisposal(); + LoadingManage loading = loading("in_progress", + stationRowsJson(List.of( + station(START, List.of("装1")), + station(TRANSIT, List.of("卸1")), + station(END, List.of("卸2"))))); + loading.setTaskInfoJson("{\"planName\":\"干线一\",\"routeChangeRecords\":[{\"content\":\"旧记录\"}]}"); + stubLoading(loading); + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill(LOADING_NO, "in_progress", null)); + + asScheduler(() -> service.adjustRoute(request( + node("start", START, List.of("装1")), + node("transit", TRANSIT_SECOND, List.of("卸1")), + node("transit", TRANSIT, List.of("装2")), + node("end", END, List.of("卸2"))))); + + LoadingManage updated = updatedLoading(); + List> rows = JsonUtil.readListMap(updated.getRouteJson()); + assertEquals(4, rows.size(), "路线整份替换成调整后的站点"); + assertEquals(START, rows.get(0).get("address")); + assertEquals(START, rows.get(0).get("displayAddress"), "站点行没有独立站名,展示地址就是地址"); + assertEquals(List.of("装1"), rows.get(0).get("tags")); + assertEquals(List.of("卸1"), rows.get(1).get("tags"), "站点上的子运单标记必须原样保留"); + assertEquals(List.of("装2"), rows.get(2).get("tags")); + assertEquals(Boolean.FALSE, rows.get(0).get("locked")); + + assertEquals(START, updated.getDepartureAddress()); + assertEquals(END, updated.getArrivalAddress()); + assertEquals(TRANSIT_SECOND + "," + TRANSIT, updated.getTransitAddress(), + "中间站地址用中文逗号连接"); + + Map taskInfo = JsonUtil.toMap(updated.getTaskInfoJson()); + assertEquals("干线一", taskInfo.get("planName"), "任务信息里其它键不能丢"); + List records = (List) taskInfo.get("routeChangeRecords"); + assertEquals(2, records.size()); + Map latest = (Map) records.get(0); + assertEquals("韦调度", latest.get("changeUser"), "变更记录的操作人是真实姓名"); + assertEquals("", latest.get("remark")); + String changeTime = String.valueOf(latest.get("changeTime")); + assertTrue(changeTime.matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}"), changeTime); + assertEquals("同意调整路线:途经站由 1 个改为 2 个", latest.get("content")); + assertEquals("旧记录", ((Map) records.get(1)).get("content"), "已有记录排在新的后面"); + + ExceptionDisposalFollowRecord record = insertedRecord(); + assertEquals("同意调整路线:途经站由 1 个改为 2 个", record.getFollowContent()); + assertEquals(USER_ID, record.getFollowUser()); + assertEquals("韦调度", record.getFollowUserName()); + assertNotNull(record.getFollowTime()); + + ExceptionDisposal settled = updatedDisposal(); + assertEquals("completed", settled.getDisposalStatus()); + assertEquals(record.getFollowContent(), settled.getLatestFollowContent()); + + InOrder order = inOrder(followRecordMapper, loadingManageMapper, disposalMapper); + order.verify(followRecordMapper).insert(any(ExceptionDisposalFollowRecord.class)); + order.verify(loadingManageMapper).updateById(any(LoadingManage.class)); + order.verify(disposalMapper).updateById(any(ExceptionDisposal.class)); + verify(waybillService, never()).updateById(any(Waybill.class)); + } + + @Test + @DisplayName("地址一个都没改也允许了结,记录写「同意调整路线,未修改路线」") + void adjustRouteWithoutAddressChangeStillSettles() { + stubPendingDisposal(); + stubLoading(loading("in_progress", + stationRowsJson(List.of(station(START, List.of("装1")), station(END, List.of("卸1")))))); + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill(LOADING_NO, "in_progress", null)); + + asScheduler(() -> service.adjustRoute(request( + node("start", START, List.of("装1")), + node("end", END, List.of("卸1"))))); + + assertEquals("同意调整路线,未修改路线", insertedRecord().getFollowContent()); + LoadingManage updated = updatedLoading(); + Map taskInfo = JsonUtil.toMap(updated.getTaskInfoJson()); + Map latest = (Map) ((List) taskInfo.get("routeChangeRecords")).get(0); + assertEquals("同意调整路线,未修改路线", latest.get("content"), "变更记录与跟进记录同一句摘要"); + assertEquals("completed", updatedDisposal().getDisposalStatus()); + } + + @Test + @DisplayName("首站地址被改动时拒绝,且不写任何记录、不改状态") + void adjustRouteRejectsStartAddressChange() { + stubPendingDisposal(); + stubLoading(loading("in_progress", + stationRowsJson(List.of(station(START, List.of("装1")), station(END, List.of("卸1")))))); + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill(LOADING_NO, "in_progress", null)); + + ServiceException exception = assertThrows(ServiceException.class, () -> asScheduler( + () -> service.adjustRoute(request( + node("start", START + "(改)", List.of("装1")), + node("end", END, List.of("卸1")))))); + + assertEquals("起始地址不允许修改", exception.getMessage()); + verifyNoWrite(); + } + + @Test + @DisplayName("站点标记的数字超出子运单序号时拒绝,不写一份司机端读不出装卸的路线") + void adjustRouteRejectsTagOutOfChildRange() { + stubPendingDisposal(); + LoadingManage loading = loading("in_progress", + stationRowsJson(List.of(station(START, List.of("装1")), station(END, List.of("卸1"))))); + loading.setWaybillIdsJson("[9001,9002]"); + stubLoading(loading); + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill(LOADING_NO, "in_progress", null)); + + ServiceException exception = assertThrows(ServiceException.class, () -> asScheduler( + () -> service.adjustRoute(request( + node("start", START, List.of("装1")), + node("end", END, List.of("卸3")))))); + + assertEquals("路线节点未关联子运单", exception.getMessage()); + verifyNoWrite(); + } + + @Test + @DisplayName("已完成的配载单不允许再改路线") + void adjustRouteRejectsCompletedLoading() { + stubPendingDisposal(); + stubLoading(loading("completed", + stationRowsJson(List.of(station(START, List.of("装1")), station(END, List.of("卸1")))))); + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill(LOADING_NO, "in_progress", null)); + + ServiceException exception = assertThrows(ServiceException.class, () -> asScheduler( + () -> service.adjustRoute(request( + node("start", START, List.of("装1")), + node("end", NEW_END, List.of("卸1")))))); + + assertEquals("当前状态不允许变更运输路线", exception.getMessage()); + verifyNoWrite(); + } + + // ------------------------------------------------------------ 节点校验 + + @Test + @DisplayName("少于两个站点的路线不是一条路线") + void adjustRouteRejectsSingleStation() { + stubPendingDisposal(); + stubLoading(loading("in_progress", null)); + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill(LOADING_NO, "in_progress", null)); + + ServiceException exception = assertThrows(ServiceException.class, () -> asScheduler( + () -> service.adjustRoute(request(node("start", START, List.of("装1")))))); + + assertEquals("路线节点至少需要两个", exception.getMessage()); + verifyNoWrite(); + } + + @Test + @DisplayName("站点类型与站序不一致时拒绝:首站必须 start、末站必须 end") + void adjustRouteRejectsWrongNodeKind() { + stubPendingDisposal(); + stubLoading(loading("in_progress", null)); + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill(LOADING_NO, "in_progress", null)); + + ServiceException exception = assertThrows(ServiceException.class, () -> asScheduler( + () -> service.adjustRoute(request( + node("transit", START, List.of("装1")), + node("end", END, List.of("卸1")))))); + + assertEquals("路线节点类型不正确", exception.getMessage()); + verifyNoWrite(); + } + + @Test + @DisplayName("已了结的异常再调整路线报「该异常已处理」") + void adjustRouteRejectsSettledDisposal() { + ExceptionDisposal disposal = new ExceptionDisposal(); + disposal.setId(DISPOSAL_ID); + disposal.setDisposalStatus("completed"); + disposal.setWaybillId(WAYBILL_ID); + disposal.setIsDeleted(0); + when(disposalMapper.selectById(DISPOSAL_ID)).thenReturn(disposal); + + ServiceException exception = assertThrows(ServiceException.class, () -> asScheduler( + () -> service.adjustRoute(request( + node("start", START, List.of("装1")), + node("end", NEW_END, List.of("卸1")))))); + + assertEquals("该异常已处理", exception.getMessage()); + verifyNoWrite(); + } + + // ------------------------------------------------------------ 未配载运单 + + @Test + @DisplayName("未配载的运单只按段改到达地,route_json 仍是司机端能解析的段数组") + void adjustRouteOnWaybillKeepsSegmentShape() { + stubPendingDisposal(); + Waybill waybill = waybill(null, "in_progress", segmentRouteJson()); + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill); + + asScheduler(() -> service.adjustRoute(request( + node("start", START, null), + node("transit", TRANSIT, null), + node("end", NEW_END, null)))); + + Waybill updated = updatedWaybill(); + List> segments = JsonUtil.readListMap(updated.getRouteJson()); + assertEquals(2, segments.size(), "段数不变,不能改成站点行"); + assertFalse(segments.get(0).containsKey("address"), "运单路线不得出现站点行的 address 键"); + assertFalse(segments.get(0).containsKey("tags"), "运单路线不得出现站点行的 tags 键"); + assertEquals(TRANSIT, segments.get(0).get("arrivalAddress"), "没改的段原样保留"); + assertEquals(1, ((Number) segments.get(0).get("segmentNo")).intValue()); + assertEquals("road", segments.get(0).get("transportType"), "未知键与业务键都要原样保留"); + assertEquals(NEW_END, segments.get(1).get("arrivalAddress")); + assertEquals(NEW_END, segments.get(1).get("arrivalName"), "站名跟着地址一起改"); + assertEquals(START, segments.get(0).get("departureAddress"), "首站地址不可改"); + + assertEquals(START, updated.getDepartureAddress()); + assertEquals(NEW_END, updated.getArrivalAddress()); + Map taskInfo = JsonUtil.toMap(updated.getTaskInfoJson()); + Map latest = (Map) ((List) taskInfo.get("routeChangeRecords")).get(0); + assertEquals("同意调整路线:到货地 " + END + " → " + NEW_END, latest.get("content")); + assertEquals("completed", updatedDisposal().getDisposalStatus()); + verify(loadingManageMapper, never()).selectOne(any()); + } + + @Test + @DisplayName("运单已关联配载单但配载单查不到时拒绝,不越权改运单") + void adjustRouteRejectsWaybillBranchWhenLoadingNoPresent() { + stubPendingDisposal(); + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill(LOADING_NO, "in_progress", segmentRouteJson())); + when(loadingManageMapper.selectOne(any())).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, () -> asScheduler( + () -> service.adjustRoute(request( + node("start", START, null), + node("end", NEW_END, null))))); + + assertEquals("运单已关联配载单,请在配载单中修改", exception.getMessage()); + verifyNoWrite(); + } + + @Test + @DisplayName("运单路线是 PC 写下的节点行形状时跳过路线改写,仍同步地址列并照常留痕") + void adjustRouteKeepsLegacyNodeRowWaybillRoute() { + stubPendingDisposal(); + String legacyRouteJson = JsonUtil.toJson(List.of( + Map.of("address", START, "tags", List.of("发", "YD001")), + Map.of("address", END, "tags", List.of("收", "YD001")))); + Waybill waybill = waybill(null, "in_progress", legacyRouteJson); + waybill.setDepartureAddress(START); + waybill.setArrivalAddress(END); + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill); + + asScheduler(() -> service.adjustRoute(request( + node("start", START, null), + node("end", NEW_END, null)))); + + Waybill updated = updatedWaybill(); + assertEquals(legacyRouteJson, updated.getRouteJson(), "认不出的路线形状不能瞎写"); + assertEquals(START, updated.getDepartureAddress()); + assertEquals(NEW_END, updated.getArrivalAddress()); + assertEquals("同意调整路线:到货地 " + END + " → " + NEW_END, insertedRecord().getFollowContent()); + assertEquals("completed", updatedDisposal().getDisposalStatus()); + } + + @Test + @DisplayName("运单站数变了时不动路线:段形状说不出哪一站被删,硬写等于重写行程") + void adjustRouteSkipsWaybillRouteWhenStationCountChanges() { + stubPendingDisposal(); + Waybill waybill = waybill(null, "in_progress", segmentRouteJson()); + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill); + + asScheduler(() -> service.adjustRoute(request( + node("start", START, null), + node("end", NEW_END, null)))); + + Waybill updated = updatedWaybill(); + assertEquals(segmentRouteJson(), updated.getRouteJson(), "站数对不上时保留原路线"); + assertEquals(NEW_END, updated.getArrivalAddress()); + assertEquals("completed", updatedDisposal().getDisposalStatus()); + } + + @Test + @DisplayName("目的地未变而只见中间站变化时,按途经站数量说明") + void adjustRouteReportsTransitCountChange() { + stubPendingDisposal(); + stubLoading(loading("in_progress", + stationRowsJson(List.of( + station(START, List.of("装1")), + station(TRANSIT, List.of("卸1")), + station(END, List.of("卸2")))))); + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill(LOADING_NO, "in_progress", null)); + + asScheduler(() -> service.adjustRoute(request( + node("start", START, List.of("装1")), + node("transit", TRANSIT_SECOND, List.of("卸1")), + node("transit", TRANSIT, List.of("装2")), + node("end", END, List.of("卸2"))))); + + assertEquals("同意调整路线:途经站由 1 个改为 2 个", insertedRecord().getFollowContent()); + } + + // ------------------------------------------------------------ 夹具 + + private void stubPendingDisposal() { + ExceptionDisposal disposal = new ExceptionDisposal(); + disposal.setId(DISPOSAL_ID); + disposal.setDisposalStatus("pending"); + disposal.setWaybillId(WAYBILL_ID); + disposal.setIsDeleted(0); + when(disposalMapper.selectById(DISPOSAL_ID)).thenReturn(disposal); + } + + private void stubLoading(LoadingManage loading) { + when(loadingManageMapper.selectOne(any())).thenReturn(loading); + } + + private Waybill waybill(String loadingNo, String businessStatus, String routeJson) { + Waybill waybill = new Waybill(); + waybill.setId(WAYBILL_ID); + waybill.setWaybillNo("YD20261023001"); + waybill.setLoadingNo(loadingNo); + waybill.setBusinessStatus(businessStatus); + waybill.setRouteJson(routeJson); + waybill.setIsDeleted(0); + return waybill; + } + + private LoadingManage loading(String businessStatus, String routeJson) { + LoadingManage loading = new LoadingManage(); + loading.setId(8501L); + loading.setLoadingNo(LOADING_NO); + loading.setWaybillIdsJson("[9001,9002]"); + loading.setBusinessStatus(businessStatus); + loading.setRouteJson(routeJson); + loading.setIsDeleted(0); + return loading; + } + + /** 配载单的路线段夹具:两段三站,首段出发地是首站 */ + private String segmentRouteJson() { + List> segments = new ArrayList<>(); + Map first = new LinkedHashMap<>(); + first.put("departureName", START); + first.put("departureAddress", START); + first.put("arrivalName", TRANSIT); + first.put("arrivalAddress", TRANSIT); + first.put("segmentNo", 1); + first.put("transportType", "road"); + segments.add(first); + Map second = new LinkedHashMap<>(); + second.put("departureName", TRANSIT); + second.put("departureAddress", TRANSIT); + second.put("arrivalName", END); + second.put("arrivalAddress", END); + second.put("segmentNo", 2); + segments.add(second); + return JsonUtil.toJson(segments); + } + + private Map station(String address, List tags) { + Map row = new LinkedHashMap<>(); + row.put("address", address); + row.put("displayAddress", address); + row.put("tags", tags); + row.put("locked", false); + return row; + } + + private String stationRowsJson(List> rows) { + return JsonUtil.toJson(rows); + } + + private ExceptionDisposalRouteAdjustRequest.RouteNode node(String kind, String address, List tags) { + ExceptionDisposalRouteAdjustRequest.RouteNode node = new ExceptionDisposalRouteAdjustRequest.RouteNode(); + node.setKind(kind); + node.setAddress(address); + node.setTags(tags); + return node; + } + + private ExceptionDisposalRouteAdjustRequest request( + ExceptionDisposalRouteAdjustRequest.RouteNode... nodes) { + ExceptionDisposalRouteAdjustRequest request = new ExceptionDisposalRouteAdjustRequest(); + request.setId(DISPOSAL_ID); + request.setRouteNodes(List.of(nodes)); + return request; + } + + private ExceptionDisposalFollowRecord insertedRecord() { + ArgumentCaptor captor = + ArgumentCaptor.forClass(ExceptionDisposalFollowRecord.class); + verify(followRecordMapper).insert(captor.capture()); + return captor.getValue(); + } + + private LoadingManage updatedLoading() { + ArgumentCaptor captor = ArgumentCaptor.forClass(LoadingManage.class); + verify(loadingManageMapper).updateById(captor.capture()); + return captor.getValue(); + } + + private Waybill updatedWaybill() { + ArgumentCaptor captor = ArgumentCaptor.forClass(Waybill.class); + verify(waybillService).updateById(captor.capture()); + return captor.getValue(); + } + + private ExceptionDisposal updatedDisposal() { + ArgumentCaptor captor = ArgumentCaptor.forClass(ExceptionDisposal.class); + verify(disposalMapper).updateById(captor.capture()); + return captor.getValue(); + } + + /** 校验不通过时不该留下任何写入 */ + private void verifyNoWrite() { + verify(followRecordMapper, never()).insert(any(ExceptionDisposalFollowRecord.class)); + verify(loadingManageMapper, never()).updateById(any(LoadingManage.class)); + verify(waybillService, never()).updateById(any(Waybill.class)); + verify(disposalMapper, never()).updateById(any(ExceptionDisposal.class)); + } + + /** 以调度员身份执行,登录态与真实姓名都走缓存 */ + private void asScheduler(Runnable action) { + asScheduler(() -> { + action.run(); + return null; + }); + } + + private T asScheduler(Supplier action) { + try (MockedStatic authUtil = mockStatic(AuthUtil.class); + MockedStatic userCache = mockStatic(UserDisplayCache.class)) { + authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); + userCache.when(() -> UserDisplayCache.getUserRealName(USER_ID)).thenReturn("韦调度"); + return action.get(); + } + } + + private void inject(String field, Object value) throws Exception { + Class type = service.getClass(); + while (type != null) { + try { + Field declared = type.getDeclaredField(field); + declared.setAccessible(true); + declared.set(service, value); + return; + } catch (NoSuchFieldException ignored) { + type = type.getSuperclass(); + } + } + throw new IllegalStateException("未找到字段:" + field); + } + +} From ba2b0a3a8b2d13ec6e1b8c780da6a33385dd71de Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Fri, 23 Oct 2026 01:10:25 +0800 Subject: [PATCH 37/55] =?UTF-8?q?:sparkles:=20=E5=BC=82=E5=B8=B8=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E4=B8=8B=E5=8F=91=E9=85=8D=E8=BD=BD=E5=8D=95=E3=80=81?= =?UTF-8?q?=E5=AD=90=E8=BF=90=E5=8D=95=E4=B8=8E=E5=8F=AF=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E7=AB=99=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pojo/vo/ExceptionDisposalVO.java | 37 ++ .../impl/ExceptionDisposalServiceImpl.java | 209 ++++++++++- .../ExceptionDisposalDetailPayloadTest.java | 325 ++++++++++++++++++ 3 files changed, 567 insertions(+), 4 deletions(-) create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalDetailPayloadTest.java diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ExceptionDisposalVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ExceptionDisposalVO.java index 77839038..d120aa54 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ExceptionDisposalVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ExceptionDisposalVO.java @@ -30,6 +30,7 @@ import org.springblade.transport.pojo.entity.ExceptionDisposal; import org.springframework.format.annotation.DateTimeFormat; import java.io.Serial; +import java.io.Serializable; import java.time.LocalDateTime; import java.util.List; import java.util.Map; @@ -93,4 +94,40 @@ public class ExceptionDisposalVO extends ExceptionDisposal { @Schema(description = "货物信息:{name, weight}") private Map cargo; + @TableField(exist = false) + @Schema(description = "关联运单的配载单号,没有配载单时为空") + private String loadingNo; + + @TableField(exist = false) + @Schema(description = "配载单下的子运单号,按配载单运单集合的顺序排列") + private List childWaybillNos; + + @TableField(exist = false) + @Schema(description = "可调整的路线站点,少于两个站时为空") + private List routeNodes; + + /** + * 一个可调整的站点:站序声明、地址、该站的装卸标记与项目名 + */ + @Data + @Schema(description = "路线站点") + public static class RouteNode implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "站点类型:start 首站、transit 中间站、end 末站") + private String kind; + + @Schema(description = "站点地址") + private String address; + + @Schema(description = "该站的装卸标记,形如「装1」「卸2」,数字是配载单子运单序号") + private List tags; + + @Schema(description = "该站装卸的子运单项目名,按标记顺序去重") + private List projects; + + } + } 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 09bc49e0..14d0166d 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 @@ -61,10 +61,12 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.stream.Collectors; /** @@ -605,8 +607,7 @@ public class ExceptionDisposalServiceImpl int childCount = childWaybillCount(loading.getWaybillIdsJson()); for (Station station : stations) { for (String tag : station.tags()) { - String digits = tag.replaceAll("\\D+", ""); - int order = Func.isEmpty(digits) ? 0 : Func.toInt(digits, 0); + int order = tagOrder(tag); if (order < 1 || order > childCount) { throw new ServiceException("路线节点未关联子运单"); } @@ -631,7 +632,7 @@ public class ExceptionDisposalServiceImpl /** 配载单当前路线的站点地址:优先站点行,没有站点行时回退到发货 / 途经 / 到货地址三列 */ private List loadingStationAddresses(LoadingManage loading) { List addresses = new ArrayList<>(); - for (Map row : jsonObjectList(loading.getRouteJson())) { + for (Map row : loadingStationRows(loading)) { addAddress(addresses, text(row.get("address"))); } if (!addresses.isEmpty()) { @@ -978,7 +979,7 @@ public class ExceptionDisposalServiceImpl return vo; } - /** 按关联运单补齐详情页路线 / 货物展示字段 */ + /** 按关联运单补齐详情页路线 / 货物 / 配载单展示字段 */ private void fillRouteAndCargo(ExceptionDisposalVO vo, Long waybillId) { if (vo == null || waybillId == null) { return; @@ -996,6 +997,206 @@ public class ExceptionDisposalServiceImpl cargo.put("name", Func.toStr(waybill.getCargoName(), "")); cargo.put("weight", formatWeight(waybill.getQuantity(), waybill.getQuantityUnit())); vo.setCargo(cargo); + + fillRouteNodes(vo, waybill); + } + + /** + * 详情页的可编辑站点与配载单信息。 + *

+ * 配载单是这趟车的执行主体,站点取它的站点行;运单没有配载单号、或配载单已被删除 / 解绑时退回 + * 运单自己的路线段——历史异常仍要能打开,不能因为配载单不在了就打不开详情。 + */ + private void fillRouteNodes(ExceptionDisposalVO vo, Waybill waybill) { + LoadingManage loading = findLoading(waybill.getLoadingNo()); + if (loading == null) { + vo.setChildWaybillNos(new ArrayList<>()); + vo.setRouteNodes(waybillRouteNodes(waybill)); + return; + } + Map childWaybills = childWaybillsByOrder(loading); + vo.setLoadingNo(loading.getLoadingNo()); + vo.setChildWaybillNos(childWaybillNos(childWaybills)); + List> rows = loadingStationRows(loading); + vo.setRouteNodes(loadingRouteNodes(rows, childWaybills)); + vo.setRoute(loadingRoute(rows, loading, waybill)); + } + + /** 配载单的站点行:只有带地址的行才算一个站,缺地址的历史行不参与 */ + private List> loadingStationRows(LoadingManage loading) { + List> rows = new ArrayList<>(); + for (Map row : jsonObjectList(loading.getRouteJson())) { + if (Func.isNotBlank(text(row.get("address")))) { + rows.add(row); + } + } + return rows; + } + + private List loadingRouteNodes(List> rows, + Map childWaybills) { + List nodes = new ArrayList<>(rows.size()); + for (Map row : rows) { + List tags = stringList(row.get("tags")); + ExceptionDisposalVO.RouteNode node = new ExceptionDisposalVO.RouteNode(); + node.setAddress(text(row.get("address"))); + node.setTags(tags); + node.setProjects(stationProjects(tags, childWaybills)); + nodes.add(node); + } + return stationKinds(nodes); + } + + /** + * 没有配载单时从路线段推导站点:首段出发地是首站,其后每段的到达地各成一站。 + * 地址口径与改路线时的比对一致(地址优先、站名兜底),否则用户什么都没改直接提交也会被判成改过地址。 + */ + private List waybillRouteNodes(Waybill waybill) { + List nodes = new ArrayList<>(); + for (String address : waybillStationAddresses(waybill)) { + if (Func.isBlank(address)) { + continue; + } + ExceptionDisposalVO.RouteNode node = new ExceptionDisposalVO.RouteNode(); + node.setAddress(address); + node.setTags(new ArrayList<>()); + node.setProjects(new ArrayList<>()); + nodes.add(node); + } + return stationKinds(nodes); + } + + /** 站序声明:首站 start、末站 end、其余 transit;不足两个站就没有首末可言,不给前端调整入口 */ + private List stationKinds(List nodes) { + if (nodes.size() < 2) { + return new ArrayList<>(); + } + for (int index = 0; index < nodes.size(); index++) { + nodes.get(index).setKind(index == 0 ? NODE_KIND_START + : (index == nodes.size() - 1 ? NODE_KIND_END : NODE_KIND_TRANSIT)); + } + return nodes; + } + + /** + * 站点标记指向配载单运单集合里的第 N 张子运单,取其项目名去重。 + * 标记对不上子运单(越界、子运单已不存在)时跳过,一个坏标记不该让整个详情打不开。 + */ + private List stationProjects(List tags, Map childWaybills) { + Set projects = new LinkedHashSet<>(); + for (String tag : tags) { + Waybill child = childWaybills.get(tagOrder(tag) - 1); + if (child == null) { + continue; + } + String projectName = Func.toStr(child.getProjectName(), "").trim(); + if (Func.isNotBlank(projectName)) { + projects.add(projectName); + } + } + return new ArrayList<>(projects); + } + + /** + * 按配载单运单集合的位次取子运单,位次从 0 起(站点标记里的 N 减一)。 + * 位次不能因为某张子运单查不到而前移,否则标记会指到别的子运单上。 + */ + private Map childWaybillsByOrder(LoadingManage loading) { + Map childWaybills = new LinkedHashMap<>(); + List waybillIds = waybillIds(loading.getWaybillIdsJson()); + List queryIds = waybillIds.stream().filter(Objects::nonNull).distinct().toList(); + if (queryIds.isEmpty()) { + return childWaybills; + } + Map waybillsById = new HashMap<>(); + List found = waybillService.listByIds(queryIds); + if (found != null) { + for (Waybill child : found) { + if (child != null && !Objects.equals(child.getIsDeleted(), 1)) { + waybillsById.put(child.getId(), child); + } + } + } + for (int order = 0; order < waybillIds.size(); order++) { + Waybill child = waybillsById.get(waybillIds.get(order)); + if (child != null) { + childWaybills.put(order, child); + } + } + return childWaybills; + } + + /** 配载单运单集合的元素是运单主键;元素不是主键时留空占位,后面的位次不能因此前移 */ + private List waybillIds(String waybillIdsJson) { + List waybillIds = new ArrayList<>(); + if (Func.isBlank(waybillIdsJson)) { + return waybillIds; + } + try { + Object parsed = JsonUtil.parse(waybillIdsJson, List.class); + if (parsed instanceof List list) { + for (Object item : list) { + long waybillId = Func.toLong(text(item), 0L); + waybillIds.add(waybillId > 0 ? waybillId : null); + } + } + } catch (Exception ignored) { + // 运单集合坏了就没有子运单可言,详情退回运单自己的路线,不影响打开 + return new ArrayList<>(); + } + return waybillIds; + } + + /** 子运单号按配载单运单集合的顺序给出;查不到的子运单跳过,不占位 */ + private List childWaybillNos(Map childWaybills) { + List childWaybillNos = new ArrayList<>(childWaybills.size()); + for (Waybill child : childWaybills.values()) { + String waybillNo = Func.toStr(child.getWaybillNo(), "").trim(); + if (Func.isNotBlank(waybillNo)) { + childWaybillNos.add(waybillNo); + } + } + return childWaybillNos; + } + + /** 有配载单时路线摘要取这趟车自己的首末站:站点行的展示地址优先,没有站点行回落到地址列 */ + private Map loadingRoute(List> rows, LoadingManage loading, Waybill waybill) { + String start; + String end; + if (rows.isEmpty()) { + start = firstNonBlank(loading.getDepartureAddress(), waybill.getDepartureName(), ""); + end = firstNonBlank(loading.getArrivalAddress(), waybill.getArrivalName(), ""); + } else { + start = stationDisplayAddress(rows.get(0)); + end = stationDisplayAddress(rows.get(rows.size() - 1)); + } + Map route = new HashMap<>(2); + route.put("start", start); + route.put("end", end); + return route; + } + + private String stationDisplayAddress(Map row) { + return firstNonBlank(text(row.get("displayAddress")), text(row.get("address")), ""); + } + + /** 站点标记里的数字是配载单运单集合里的位次(1 起),取不到数字时返回 0 */ + private int tagOrder(String tag) { + String digits = Func.toStr(tag, "").replaceAll("\\D+", ""); + return Func.isEmpty(digits) ? 0 : Func.toInt(digits, 0); + } + + private List stringList(Object value) { + List texts = new ArrayList<>(); + if (value instanceof List list) { + for (Object item : list) { + String text = text(item); + if (Func.isNotBlank(text)) { + texts.add(text); + } + } + } + return texts; } private String formatWeight(BigDecimal quantity, String unit) { diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalDetailPayloadTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalDetailPayloadTest.java new file mode 100644 index 00000000..c28898c5 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalDetailPayloadTest.java @@ -0,0 +1,325 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +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.tool.jackson.JsonUtil; +import org.springblade.system.cache.UserDisplayCache; +import org.springblade.transport.mapper.ExceptionDisposalFollowRecordMapper; +import org.springblade.transport.mapper.ExceptionDisposalMapper; +import org.springblade.transport.mapper.LoadingManageMapper; +import org.springblade.transport.pojo.entity.ExceptionDisposal; +import org.springblade.transport.pojo.entity.ExceptionDisposalFollowRecord; +import org.springblade.transport.pojo.entity.LoadingManage; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.vo.ExceptionDisposalVO; +import org.springblade.transport.service.IDriverWaybillService; +import org.springblade.transport.service.IWaybillService; + +import java.lang.reflect.Field; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 异常详情下发的配载单、子运单与可编辑站点。 + *

+ * 有配载单时站点取配载单的站点行(这趟车的执行主体就是配载单);没有配载单才退回运单的路线段, + * 站点地址必须沿用改路线时的比对口径(地址优先、站名兜底),否则原样提交会被判成改过地址。 + */ +class ExceptionDisposalDetailPayloadTest { + + private static final Long DISPOSAL_ID = 9101L; + private static final Long WAYBILL_ID = 9201L; + private static final String LOADING_NO = "PZ20261023009"; + private static final String WAYBILL_NO = "YD202610230009"; + private static final Long CHILD_ID_FIRST = 9301L; + private static final Long CHILD_ID_SECOND = 9302L; + private static final String CHILD_NO_FIRST = "YD202610230011"; + private static final String CHILD_NO_SECOND = "YD202610230012"; + private static final String PROJECT_FIRST = "冷链项目"; + private static final String PROJECT_SECOND = "普货项目"; + private static final String START = "南宁市/邕宁区/那赖二路神网数码物流园"; + private static final String TRANSIT = "钦州市/沙坪坝区/泰安大道物流园"; + private static final String END = "成都市/双流区/口岸路成藏宝物流园"; + private static final String WAYBILL_START_NAME = "南宁"; + private static final String WAYBILL_END_NAME = "成都"; + + private ExceptionDisposalMapper disposalMapper; + private ExceptionDisposalFollowRecordMapper followRecordMapper; + private IWaybillService waybillService; + private LoadingManageMapper loadingManageMapper; + private ExceptionDisposalServiceImpl service; + + @BeforeAll + static void initTableInfo() { + // 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class); + TableInfoHelper.initTableInfo(assistant, ExceptionDisposalFollowRecord.class); + TableInfoHelper.initTableInfo(assistant, LoadingManage.class); + } + + @BeforeEach + void setUp() throws Exception { + disposalMapper = mock(ExceptionDisposalMapper.class); + followRecordMapper = mock(ExceptionDisposalFollowRecordMapper.class); + waybillService = mock(IWaybillService.class); + loadingManageMapper = mock(LoadingManageMapper.class); + service = new ExceptionDisposalServiceImpl( + followRecordMapper, waybillService, mock(IDriverWaybillService.class), loadingManageMapper); + inject("baseMapper", disposalMapper); + } + + // ------------------------------------------------------------ 有配载单 + + @Test + @DisplayName("有配载单:下发配载单号、按运单集合顺序的子运单号,站点与项目取自站点行") + void loadingDetailPublishesChildWaybillsAndStationNodes() { + stubDisposal(); + stubWaybill(LOADING_NO, null); + stubLoading(List.of( + stationRow(START, null, List.of("装1", "卸1")), + stationRow(TRANSIT, null, List.of("卸1", "装2")), + stationRow(END, null, List.of("卸2")))); + // 数据库不保证按主键集合的顺序返回:倒序回来,下发仍要按配载单运单集合的顺序 + when(waybillService.listByIds(any())).thenReturn(List.of( + child(CHILD_ID_SECOND, CHILD_NO_SECOND, PROJECT_SECOND), + child(CHILD_ID_FIRST, CHILD_NO_FIRST, PROJECT_FIRST))); + + ExceptionDisposalVO vo = detailResponse(); + + assertEquals(LOADING_NO, vo.getLoadingNo()); + assertEquals(List.of(CHILD_NO_FIRST, CHILD_NO_SECOND), vo.getChildWaybillNos(), + "子运单号顺序就是站点标记里数字的顺序"); + + List nodes = vo.getRouteNodes(); + assertEquals(3, nodes.size(), "站点数与配载单路线一致"); + assertEquals(List.of("start", "transit", "end"), kinds(nodes)); + assertEquals(START, nodes.get(0).getAddress()); + assertEquals(TRANSIT, nodes.get(1).getAddress()); + assertEquals(END, nodes.get(2).getAddress()); + assertEquals(List.of("装1", "卸1"), nodes.get(0).getTags()); + assertEquals(List.of(PROJECT_FIRST), nodes.get(0).getProjects(), "同一站的同一项目只出现一次"); + assertEquals(List.of(PROJECT_FIRST, PROJECT_SECOND), nodes.get(1).getProjects(), + "标记按顺序映射到子运单"); + assertEquals(List.of(PROJECT_SECOND), nodes.get(2).getProjects()); + assertEquals(START, vo.getRoute().get("start")); + assertEquals(END, vo.getRoute().get("end")); + + verify(disposalMapper, never()).updateById(any(ExceptionDisposal.class)); + verify(loadingManageMapper, never()).updateById(any(LoadingManage.class)); + verify(waybillService, never()).updateById(any(Waybill.class)); + } + + @Test + @DisplayName("路线摘要用站点行的展示地址,站点地址仍取地址列") + void routeSummaryPrefersDisplayAddress() { + stubDisposal(); + stubWaybill(LOADING_NO, null); + stubLoading(List.of( + stationRow(START, START + "(展示)", List.of("装1")), + stationRow(END, null, List.of("卸2")))); + when(waybillService.listByIds(any())).thenReturn(List.of( + child(CHILD_ID_FIRST, CHILD_NO_FIRST, PROJECT_FIRST), + child(CHILD_ID_SECOND, CHILD_NO_SECOND, PROJECT_SECOND))); + + ExceptionDisposalVO vo = detailResponse(); + + assertEquals(START + "(展示)", vo.getRoute().get("start"), "摘要展示用站点行的展示地址"); + assertEquals(END, vo.getRoute().get("end")); + assertEquals(START, vo.getRouteNodes().get(0).getAddress(), + "站点地址是改路线时比对用的地址列,不能换成展示地址"); + } + + @Test + @DisplayName("只有一个站时不给出可编辑站点") + void singleStationGetsNoRouteNodes() { + stubDisposal(); + stubWaybill(LOADING_NO, null); + stubLoading(List.of(stationRow(START, null, List.of("装1")))); + when(waybillService.listByIds(any())).thenReturn(List.of( + child(CHILD_ID_FIRST, CHILD_NO_FIRST, PROJECT_FIRST))); + + ExceptionDisposalVO vo = detailResponse(); + + assertTrue(vo.getRouteNodes().isEmpty(), "没有首末站就没有可调整的站序"); + } + + @Test + @DisplayName("子运单查不到时跳过,不打乱其余子运单的顺序") + void missingChildWaybillIsSkipped() { + stubDisposal(); + stubWaybill(LOADING_NO, null); + stubLoading(List.of( + stationRow(START, null, List.of("装1")), + stationRow(TRANSIT, null, List.of("卸1", "装2")), + stationRow(END, null, List.of("卸2")))); + // 第一张子运单已不在(被删或运单集合与运单实际不一致),只剩第二张 + when(waybillService.listByIds(any())).thenReturn(List.of( + child(CHILD_ID_SECOND, CHILD_NO_SECOND, PROJECT_SECOND))); + + ExceptionDisposalVO vo = detailResponse(); + + assertEquals(List.of(CHILD_NO_SECOND), vo.getChildWaybillNos()); + assertEquals(3, vo.getRouteNodes().size(), "站点不因子运单缺失而消失"); + assertTrue(vo.getRouteNodes().get(0).getProjects().isEmpty(), "找不到子运单的标记给空项目"); + assertEquals(List.of(PROJECT_SECOND), vo.getRouteNodes().get(1).getProjects()); + } + + // ------------------------------------------------------------ 无配载单 + + @Test + @DisplayName("配载单已不存在时退回运单路线段,历史异常照常打开") + void missingLoadingFallsBackToWaybillRouteSegments() { + stubDisposal(); + // 运单还挂着配载单号,配载单已被删除或解绑:按没有配载单处理 + stubWaybill("PZ202610230404", segmentRouteJson()); + + ExceptionDisposalVO vo = assertDoesNotThrow(this::detailResponse); + + assertNull(vo.getLoadingNo()); + assertTrue(vo.getChildWaybillNos().isEmpty()); + List nodes = vo.getRouteNodes(); + assertEquals(3, nodes.size(), "首段出发地是首站,其后每段到达地各一站"); + assertEquals(List.of("start", "transit", "end"), kinds(nodes)); + assertEquals(START, nodes.get(0).getAddress()); + assertEquals(List.of(), nodes.get(0).getTags(), "运单路线段没有装卸归属"); + assertEquals(List.of(), nodes.get(0).getProjects()); + assertEquals(WAYBILL_START_NAME, vo.getRoute().get("start"), "无配载单时摘要维持运单起讫名"); + assertEquals(WAYBILL_END_NAME, vo.getRoute().get("end")); + } + + @Test + @DisplayName("无配载单的站点地址地址优先、站名兜底,与改路线时的比对口径一致") + void waybillNodesPreferAddressOverName() { + stubDisposal(); + stubWaybill(null, JsonUtil.toJson(List.of( + segment(WAYBILL_START_NAME, START, "钦州", TRANSIT), + segment("钦州", TRANSIT, "贵阳", null)))); + + ExceptionDisposalVO vo = detailResponse(); + + List nodes = vo.getRouteNodes(); + assertEquals(List.of(START, TRANSIT, "贵阳"), + nodes.stream().map(ExceptionDisposalVO.RouteNode::getAddress).toList(), + "原样提交时站点地址要与运单段地址一致,缺地址才用站名兜底"); + } + + // ------------------------------------------------------------ 夹具 + + private void stubDisposal() { + ExceptionDisposal disposal = new ExceptionDisposal(); + disposal.setId(DISPOSAL_ID); + disposal.setDisposalStatus("pending"); + disposal.setWaybillId(WAYBILL_ID); + disposal.setIsDeleted(0); + when(disposalMapper.selectById(DISPOSAL_ID)).thenReturn(disposal); + } + + private void stubWaybill(String loadingNo, String routeJson) { + Waybill waybill = new Waybill(); + waybill.setId(WAYBILL_ID); + waybill.setWaybillNo(WAYBILL_NO); + waybill.setLoadingNo(loadingNo); + waybill.setRouteJson(routeJson); + waybill.setDepartureName(WAYBILL_START_NAME); + waybill.setArrivalName(WAYBILL_END_NAME); + waybill.setIsDeleted(0); + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill); + } + + private void stubLoading(List> rows) { + LoadingManage loading = new LoadingManage(); + loading.setId(9401L); + loading.setLoadingNo(LOADING_NO); + loading.setWaybillIdsJson("[" + CHILD_ID_FIRST + "," + CHILD_ID_SECOND + "]"); + loading.setRouteJson(JsonUtil.toJson(rows)); + loading.setIsDeleted(0); + when(loadingManageMapper.selectOne(any())).thenReturn(loading); + } + + private Waybill child(Long id, String waybillNo, String projectName) { + Waybill waybill = new Waybill(); + waybill.setId(id); + waybill.setWaybillNo(waybillNo); + waybill.setProjectName(projectName); + waybill.setIsDeleted(0); + return waybill; + } + + private Map stationRow(String address, String displayAddress, List tags) { + Map row = new LinkedHashMap<>(); + row.put("address", address); + if (displayAddress != null) { + row.put("displayAddress", displayAddress); + } + row.put("tags", tags); + row.put("locked", false); + return row; + } + + /** 运单的路线段:两段三站,带地址的段 */ + private String segmentRouteJson() { + return JsonUtil.toJson(List.of( + segment(WAYBILL_START_NAME, START, TRANSIT, TRANSIT), + segment(TRANSIT, TRANSIT, WAYBILL_END_NAME, END))); + } + + private Map segment(String departureName, String departureAddress, + String arrivalName, String arrivalAddress) { + Map segment = new LinkedHashMap<>(); + segment.put("departureName", departureName); + segment.put("departureAddress", departureAddress); + segment.put("arrivalName", arrivalName); + if (arrivalAddress != null) { + segment.put("arrivalAddress", arrivalAddress); + } + return segment; + } + + private List kinds(List nodes) { + return nodes.stream().map(ExceptionDisposalVO.RouteNode::getKind).toList(); + } + + private ExceptionDisposalVO detailResponse() { + // 详情里的创建人 / 更新人姓名走缓存,单测不装配 Spring 上下文 + try (MockedStatic ignored = mockStatic(UserDisplayCache.class)) { + return service.detail(DISPOSAL_ID); + } + } + + private void inject(String field, Object value) throws Exception { + Class type = service.getClass(); + while (type != null) { + try { + Field declared = type.getDeclaredField(field); + declared.setAccessible(true); + declared.set(service, value); + return; + } catch (NoSuchFieldException ignored) { + type = type.getSuperclass(); + } + } + throw new IllegalStateException("未找到字段:" + field); + } + +} From 87b473594a3e4a4e9123ea8e2e1691c4f0ed5062 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Fri, 23 Oct 2026 01:29:00 +0800 Subject: [PATCH 38/55] =?UTF-8?q?:bug:=20=E4=BF=AE=E6=AD=A3=E5=88=86?= =?UTF-8?q?=E6=AE=B5=E6=94=B9=E5=9D=80=E7=9A=84=E5=87=BA=E5=8F=91=E5=9C=B0?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E4=B8=8E=E7=AB=99=E6=95=B0=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=EF=BC=8C=E8=AF=A6=E6=83=85=E5=AD=97=E6=AE=B5=E7=A7=BB=E5=87=BA?= =?UTF-8?q?=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 运单分支中间站改址后逐段同步 departure;无配载单的站数变化显式拒绝;已撤销异常不得再跟进或完成;配载单号、子运单号与可调整站点只在详情组装,列表路径不新增查询。 --- .../impl/ExceptionDisposalServiceImpl.java | 70 +++++++++++-------- .../ExceptionDisposalDetailPayloadTest.java | 20 +++++- .../impl/ExceptionDisposalListQueryTest.java | 47 ++++++++++++- .../ExceptionDisposalRejectRevokeTest.java | 41 ++++++++++- .../ExceptionDisposalRouteAdjustTest.java | 39 ++++++++--- 5 files changed, 173 insertions(+), 44 deletions(-) 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 14d0166d..774e1c7e 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 @@ -135,7 +135,9 @@ public class ExceptionDisposalServiceImpl @Override public ExceptionDisposalVO detail(Long id) { - ExceptionDisposalVO vo = toVO(getExisting(id)); + ExceptionDisposal entity = getExisting(id); + ExceptionDisposalVO vo = convertVO(entity); + fillRouteNodes(vo, fillRouteAndCargo(vo, entity.getWaybillId())); vo.setFollowRecords(followRecords(id)); return vo; } @@ -375,7 +377,7 @@ public class ExceptionDisposalServiceImpl throw new ServiceException("跟进说明不能超过500字"); } ExceptionDisposal disposal = getExisting(request.getId()); - if (STATUS_COMPLETED.equals(disposal.getDisposalStatus())) { + if (WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES.contains(disposal.getDisposalStatus())) { throw new ServiceException("已完成的异常不允许继续跟进"); } // 上传了附件但没写说明时,跟进说明留空,列表按附件回退展示 @@ -401,7 +403,7 @@ public class ExceptionDisposalServiceImpl @Transactional(rollbackFor = Exception.class) public void complete(Long id) { ExceptionDisposal disposal = getExisting(id); - if (STATUS_COMPLETED.equals(disposal.getDisposalStatus())) { + if (WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES.contains(disposal.getDisposalStatus())) { throw new ServiceException("异常已完成,请勿重复操作"); } disposal.setDisposalStatus(STATUS_COMPLETED); @@ -423,7 +425,7 @@ public class ExceptionDisposalServiceImpl throw new ServiceException("存在无效的异常记录"); } for (ExceptionDisposal disposal : disposals) { - if (STATUS_COMPLETED.equals(disposal.getDisposalStatus())) { + if (WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES.contains(disposal.getDisposalStatus())) { throw new ServiceException("已完成的异常不允许重复完成"); } disposal.setDisposalStatus(STATUS_COMPLETED); @@ -697,11 +699,14 @@ public class ExceptionDisposalServiceImpl .collect(Collectors.joining(TRANSIT_ADDRESS_JOINER)); sourceTaskInfoJson = loading.getTaskInfoJson(); } else { - // 运单的 route_json 是路线段形状,司机端按段解析:只按段改地址,绝不整份换成站点行。 - // 没变就一个键都不动,避免把历史数据里缺省的字段顺手补齐成另一份结构。 - routeJson = sourceAddresses.equals(targetAddresses) - ? null - : rewriteWaybillRouteSegments(waybill.getRouteJson(), targetAddresses); + // 运单的 route_json 是路线段形状,司机端按段解析:只按段改地址,不整份换成站点行 + List> segments = routeSegments(waybill.getRouteJson()); + boolean addressChanged = !sourceAddresses.equals(targetAddresses); + // 段形状可识别时站数必须等于段数 + 1;认不出段形状时不改 route_json + if (addressChanged && !segments.isEmpty() && segments.size() + 1 != targetAddresses.size()) { + throw new ServiceException("无配载单的运单不支持增减途经站"); + } + routeJson = addressChanged ? rewriteWaybillRouteSegments(segments, targetAddresses) : null; sourceTaskInfoJson = waybill.getTaskInfoJson(); } String taskInfoJson = appendRouteChangeRecord(sourceTaskInfoJson, followContent); @@ -773,22 +778,20 @@ public class ExceptionDisposalServiceImpl } /** - * 按路线段改写运单路线:第 i 段的到达地对应第 i+1 站,首段出发地对应首站。 - * 其它键(segmentNo、transportType、未知键)原样保留。 + * 按路线段改写运单路线:每段的出发地对应第 i 站、到达地对应第 i+1 站,其它键(segmentNo、 + * transportType、未知键)原样保留。 *

- * 站数变了或现有 route_json 不是可识别的段数组时返回 null,调用方跳过路线改写: - * 段形状没有装卸归属,说不出哪一段被删掉或被挪动,硬写等于重写这趟车的行程; - * 历史节点行(PC 改运单路线写下的 address + tags)同理不动,改写会让司机端读不出路线。 + * 现有 route_json 认不出段形状(空、坏 JSON、PC 节点行)时返回 null,调用方跳过路线改写; + * 站数与段数不匹配由调用方拒绝。 */ - private String rewriteWaybillRouteSegments(String routeJson, List targetAddresses) { - List> segments = routeSegments(routeJson); - if (segments.isEmpty() || segments.size() + 1 != targetAddresses.size()) { + private String rewriteWaybillRouteSegments(List> segments, List targetAddresses) { + if (segments.isEmpty()) { return null; } List> rewritten = new ArrayList<>(segments.size()); for (int index = 0; index < segments.size(); index++) { Map segment = new LinkedHashMap<>(segments.get(index)); - rewriteSegmentPoint(segment, "departure", index == 0 ? targetAddresses.get(0) : null); + rewriteSegmentPoint(segment, "departure", targetAddresses.get(index)); rewriteSegmentPoint(segment, "arrival", targetAddresses.get(index + 1)); rewritten.add(segment); } @@ -796,15 +799,11 @@ public class ExceptionDisposalServiceImpl } private void rewriteSegmentPoint(Map segment, String point, String targetAddress) { - if (targetAddress == null) { - return; - } String currentAddress = firstNonBlank(text(segment.get(point + "Address")), text(segment.get(point + "Name")), ""); if (Objects.equals(currentAddress, targetAddress)) { return; } - // 站名与地址是同一份文本:调整页只有一个地址输入框,改了地址站名也得跟着变 segment.put(point + "Address", targetAddress); segment.put(point + "Name", targetAddress); } @@ -966,7 +965,14 @@ public class ExceptionDisposalServiceImpl return disposal; } + /** 列表与上报结果共用的转换:不含详情页的配载单号、子运单号与可调整站点 */ private ExceptionDisposalVO toVO(ExceptionDisposal entity) { + ExceptionDisposalVO vo = convertVO(entity); + fillRouteAndCargo(vo, entity.getWaybillId()); + return vo; + } + + private ExceptionDisposalVO convertVO(ExceptionDisposal entity) { ExceptionDisposalVO vo = BeanUtil.copyProperties(entity, ExceptionDisposalVO.class); if (vo == null) { return null; @@ -975,18 +981,17 @@ public class ExceptionDisposalServiceImpl vo.setUpdateUserName(UserDisplayCache.getUserRealName(entity.getUpdateUser())); vo.setDisposalStatusName(statusName(entity.getDisposalStatus())); vo.setScenePhotoList(splitPhotos(entity.getScenePhotos())); - fillRouteAndCargo(vo, entity.getWaybillId()); return vo; } - /** 按关联运单补齐详情页路线 / 货物 / 配载单展示字段 */ - private void fillRouteAndCargo(ExceptionDisposalVO vo, Long waybillId) { + /** 按关联运单补齐路线与货物展示字段,返回本次取到的运单;运单不存在时返回 null */ + private Waybill fillRouteAndCargo(ExceptionDisposalVO vo, Long waybillId) { if (vo == null || waybillId == null) { - return; + return null; } Waybill waybill = waybillService.getById(waybillId); if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) { - return; + return null; } Map route = new HashMap<>(2); route.put("start", Func.toStr(waybill.getDepartureName(), "")); @@ -997,8 +1002,7 @@ public class ExceptionDisposalServiceImpl cargo.put("name", Func.toStr(waybill.getCargoName(), "")); cargo.put("weight", formatWeight(waybill.getQuantity(), waybill.getQuantityUnit())); vo.setCargo(cargo); - - fillRouteNodes(vo, waybill); + return waybill; } /** @@ -1008,6 +1012,14 @@ public class ExceptionDisposalServiceImpl * 运单自己的路线段——历史异常仍要能打开,不能因为配载单不在了就打不开详情。 */ private void fillRouteNodes(ExceptionDisposalVO vo, Waybill waybill) { + if (vo == null) { + return; + } + if (waybill == null) { + vo.setChildWaybillNos(List.of()); + vo.setRouteNodes(List.of()); + return; + } LoadingManage loading = findLoading(waybill.getLoadingNo()); if (loading == null) { vo.setChildWaybillNos(new ArrayList<>()); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalDetailPayloadTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalDetailPayloadTest.java index c28898c5..c68b73f0 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalDetailPayloadTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalDetailPayloadTest.java @@ -40,8 +40,8 @@ import static org.mockito.Mockito.when; /** * 异常详情下发的配载单、子运单与可编辑站点。 *

- * 有配载单时站点取配载单的站点行(这趟车的执行主体就是配载单);没有配载单才退回运单的路线段, - * 站点地址必须沿用改路线时的比对口径(地址优先、站名兜底),否则原样提交会被判成改过地址。 + * 有配载单时站点取配载单的站点行;没有配载单才退回运单的路线段。 + * 站点地址与改路线时的比对口径一致(地址优先、站名兜底)。 */ class ExceptionDisposalDetailPayloadTest { @@ -224,6 +224,22 @@ class ExceptionDisposalDetailPayloadTest { "原样提交时站点地址要与运单段地址一致,缺地址才用站名兜底"); } + @Test + @DisplayName("关联运单已删除时详情照常打开,配载单号 / 子运单号 / 站点各给空值") + void deletedWaybillGivesEmptyDetailFields() { + stubDisposal(); + Waybill deleted = new Waybill(); + deleted.setId(WAYBILL_ID); + deleted.setIsDeleted(1); + when(waybillService.getById(WAYBILL_ID)).thenReturn(deleted); + + ExceptionDisposalVO vo = assertDoesNotThrow(this::detailResponse); + + assertNull(vo.getLoadingNo()); + assertEquals(List.of(), vo.getChildWaybillNos()); + assertEquals(List.of(), vo.getRouteNodes()); + } + // ------------------------------------------------------------ 夹具 private void stubDisposal() { diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalListQueryTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalListQueryTest.java index a00ec202..65917477 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalListQueryTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalListQueryTest.java @@ -11,21 +11,29 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.springblade.system.cache.UserDisplayCache; import org.springblade.transport.mapper.ExceptionDisposalFollowRecordMapper; import org.springblade.transport.mapper.ExceptionDisposalMapper; import org.springblade.transport.mapper.LoadingManageMapper; import org.springblade.transport.pojo.entity.ExceptionDisposal; +import org.springblade.transport.pojo.entity.Waybill; import org.springblade.transport.pojo.vo.ExceptionDisposalVO; import org.springblade.transport.service.IDriverWaybillService; import org.springblade.transport.service.IWaybillService; import java.lang.reflect.Field; import java.util.Collection; +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.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -40,6 +48,8 @@ class ExceptionDisposalListQueryTest { private static final Long WAYBILL_ID = 5401L; private ExceptionDisposalMapper disposalMapper; + private IWaybillService waybillService; + private LoadingManageMapper loadingManageMapper; private ExceptionDisposalServiceImpl service; @BeforeAll @@ -48,16 +58,19 @@ class ExceptionDisposalListQueryTest { MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); assistant.setCurrentNamespace("test"); TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class); + TableInfoHelper.initTableInfo(assistant, Waybill.class); } @BeforeEach void setUp() throws Exception { disposalMapper = mock(ExceptionDisposalMapper.class); + waybillService = mock(IWaybillService.class); + loadingManageMapper = mock(LoadingManageMapper.class); service = new ExceptionDisposalServiceImpl( mock(ExceptionDisposalFollowRecordMapper.class), - mock(IWaybillService.class), + waybillService, mock(IDriverWaybillService.class), - mock(LoadingManageMapper.class)); + loadingManageMapper); inject("baseMapper", disposalMapper); when(disposalMapper.selectPage(any(), any())).thenReturn(new Page(1, 10)); } @@ -88,6 +101,36 @@ class ExceptionDisposalListQueryTest { assertFalse(wrapper.getTargetSql().contains("waybill_id"), wrapper.getTargetSql()); } + @Test + @DisplayName("列表不组装详情字段:每行不查配载单、不查子运单") + void listPageDoesNotLoadDetailFields() { + ExceptionDisposal disposal = new ExceptionDisposal(); + disposal.setId(6501L); + disposal.setWaybillId(WAYBILL_ID); + disposal.setDisposalStatus("pending"); + disposal.setIsDeleted(0); + Page entityPage = new Page<>(1, 10); + entityPage.setRecords(List.of(disposal)); + when(disposalMapper.selectPage(any(), any())).thenReturn(entityPage); + Waybill waybill = new Waybill(); + waybill.setId(WAYBILL_ID); + waybill.setLoadingNo("PZ20261023001"); + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill); + + IPage page; + try (MockedStatic ignored = mockStatic(UserDisplayCache.class)) { + page = service.selectPage(new Page<>(1, 10), new ExceptionDisposalVO()); + } + + assertEquals(1, page.getRecords().size()); + ExceptionDisposalVO vo = page.getRecords().get(0); + assertNull(vo.getLoadingNo(), "配载单号只在详情下发"); + assertNull(vo.getChildWaybillNos(), "子运单号只在详情下发"); + assertNull(vo.getRouteNodes(), "可调整站点只在详情下发"); + verify(loadingManageMapper, never()).selectOne(any()); + verify(waybillService, never()).listByIds(any()); + } + // ------------------------------------------------------------ 夹具 private LambdaQueryWrapper capturedQuery(ExceptionDisposalVO query) { diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRejectRevokeTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRejectRevokeTest.java index cdd36344..f4e063dc 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRejectRevokeTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRejectRevokeTest.java @@ -15,6 +15,7 @@ import org.springblade.system.cache.UserDisplayCache; import org.springblade.transport.mapper.ExceptionDisposalFollowRecordMapper; import org.springblade.transport.mapper.ExceptionDisposalMapper; import org.springblade.transport.mapper.LoadingManageMapper; +import org.springblade.transport.pojo.dto.ExceptionDisposalFollowRequest; import org.springblade.transport.pojo.dto.ExceptionDisposalRejectRequest; import org.springblade.transport.pojo.entity.ExceptionDisposal; import org.springblade.transport.pojo.entity.ExceptionDisposalFollowRecord; @@ -38,10 +39,10 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** - * 拒绝与撤销:两个动作都了结异常,但只有拒绝留痕。 + * 拒绝与撤销:两个动作都了结异常,只有拒绝留痕。 *

- * 拒绝复用已完成这个终态(台账仍显示「已处理」),所以它与「完成」的区别只落在跟进记录的第一句; - * 撤销是上报人收回,不是处置,因此不留跟进记录。 + * 拒绝复用已完成这个终态(台账仍显示「已处理」),并写一条「拒绝:…」的跟进记录; + * 撤销置为已撤销,不写跟进记录。终态(已完成 / 已撤销)不允许再跟进、再完成。 */ class ExceptionDisposalRejectRevokeTest { @@ -253,6 +254,33 @@ class ExceptionDisposalRejectRevokeTest { assertEquals("该异常已处理", exception.getMessage()); } + // ------------------------------------------------------------ 终态门禁 + + @Test + @DisplayName("已撤销的异常不能再跟进:终态不允许回到处理中") + void followRevokedDisposalRejected() { + stubDisposal("revoked", REPORTER_ID); + + ServiceException exception = assertThrows(ServiceException.class, + () -> runAsReporter(() -> service.follow(followRequest("已核实,等待补件")))); + + assertEquals("已完成的异常不允许继续跟进", exception.getMessage()); + verify(disposalMapper, never()).updateById(any(ExceptionDisposal.class)); + verify(followRecordMapper, never()).insert(any(ExceptionDisposalFollowRecord.class)); + } + + @Test + @DisplayName("已撤销的异常不能再完成:撤销后不能改成已完成") + void completeRevokedDisposalRejected() { + stubDisposal("revoked", REPORTER_ID); + + ServiceException exception = assertThrows(ServiceException.class, + () -> runAsReporter(() -> service.complete(DISPOSAL_ID))); + + assertEquals("异常已完成,请勿重复操作", exception.getMessage()); + verify(disposalMapper, never()).updateById(any(ExceptionDisposal.class)); + } + // ------------------------------------------------------------ 夹具 private void stubDisposal(String status, Long reporterId) { @@ -271,6 +299,13 @@ class ExceptionDisposalRejectRevokeTest { return request; } + private ExceptionDisposalFollowRequest followRequest(String content) { + ExceptionDisposalFollowRequest request = new ExceptionDisposalFollowRequest(); + request.setId(DISPOSAL_ID); + request.setFollowContent(content); + return request; + } + private ExceptionDisposalFollowRecord insertedRecord() { ArgumentCaptor captor = ArgumentCaptor.forClass(ExceptionDisposalFollowRecord.class); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRouteAdjustTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRouteAdjustTest.java index 62780eaa..8118b928 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRouteAdjustTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRouteAdjustTest.java @@ -49,8 +49,8 @@ import static org.mockito.Mockito.when; /** * 同意并调整路线:先写跟进记录、再改载体路线、最后了结。 *

- * 载体跟配载单走——配载单是这趟车的执行主体,司机端打卡读它的站点行;没有配载单才改运单, - * 而运单的 route_json 是司机端按段解析的形状,改成站点行就读不出路线了。 + * 载体跟配载单走:配载单是这趟车的执行主体,司机端打卡读它的站点行;没有配载单才改运单, + * 运单的 route_json 是路线段形状。 */ class ExceptionDisposalRouteAdjustTest { @@ -360,22 +360,45 @@ class ExceptionDisposalRouteAdjustTest { } @Test - @DisplayName("运单站数变了时不动路线:段形状说不出哪一站被删,硬写等于重写行程") - void adjustRouteSkipsWaybillRouteWhenStationCountChanges() { + @DisplayName("改中间站地址时段首尾都同步:上一段到达地与下一段出发地写成同一个新址") + void adjustRouteSyncsMiddleStationOnBothSegments() { stubPendingDisposal(); Waybill waybill = waybill(null, "in_progress", segmentRouteJson()); when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill); asScheduler(() -> service.adjustRoute(request( node("start", START, null), - node("end", NEW_END, null)))); + node("transit", TRANSIT_SECOND, null), + node("end", END, null)))); - Waybill updated = updatedWaybill(); - assertEquals(segmentRouteJson(), updated.getRouteJson(), "站数对不上时保留原路线"); - assertEquals(NEW_END, updated.getArrivalAddress()); + List> segments = JsonUtil.readListMap(updatedWaybill().getRouteJson()); + assertEquals(2, segments.size(), "站数不变,段数也不变"); + assertEquals(TRANSIT_SECOND, segments.get(0).get("arrivalAddress")); + assertEquals(TRANSIT_SECOND, segments.get(0).get("arrivalName"), "站名跟着地址一起改"); + assertEquals(TRANSIT_SECOND, segments.get(1).get("departureAddress"), + "下一段的出发地必须与上一段的到达地一致"); + assertEquals(TRANSIT_SECOND, segments.get(1).get("departureName")); + assertEquals(START, segments.get(0).get("departureAddress"), "首站地址不可改"); + assertEquals(END, segments.get(1).get("arrivalAddress"), "没改的到达地原样保留"); assertEquals("completed", updatedDisposal().getDisposalStatus()); } + @Test + @DisplayName("运单站数变化时拒绝:无配载单的运单只支持改地址,不支持增减途经站") + void adjustRouteRejectsWaybillStationCountChange() { + stubPendingDisposal(); + Waybill waybill = waybill(null, "in_progress", segmentRouteJson()); + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill); + + ServiceException exception = assertThrows(ServiceException.class, () -> asScheduler( + () -> service.adjustRoute(request( + node("start", START, null), + node("end", NEW_END, null))))); + + assertEquals("无配载单的运单不支持增减途经站", exception.getMessage()); + verifyNoWrite(); + } + @Test @DisplayName("目的地未变而只见中间站变化时,按途经站数量说明") void adjustRouteReportsTransitCountChange() { From aa7caf5a4ce896b60581f54c06c2f07f360d4756 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Fri, 23 Oct 2026 01:29:09 +0800 Subject: [PATCH 39/55] =?UTF-8?q?:recycle:=20=E6=95=B4=E7=90=86=E5=BC=82?= =?UTF-8?q?=E5=B8=B8=E5=A4=84=E7=BD=AE=E8=B7=AF=E7=BA=BF=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E7=9A=84=E6=B3=A8=E9=87=8A=E3=80=81=E5=8F=82=E6=95=B0=E5=91=BD?= =?UTF-8?q?=E5=90=8D=E4=B8=8E=E7=BB=88=E6=80=81=E5=AE=88=E5=8D=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 注释只留状态与约束,去掉推理与取舍表述;firstNonBlank / rewriteSegmentPoint 参数改为有语义的名字;reject / revoke / adjustRoute 共用 assertNotSettled,文案沿用「该异常已处理」。 --- .../impl/ExceptionDisposalServiceImpl.java | 95 +++++++++---------- 1 file changed, 46 insertions(+), 49 deletions(-) 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 774e1c7e..bf0d38b7 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 @@ -440,10 +440,7 @@ public class ExceptionDisposalServiceImpl throw new ServiceException("请选择需要拒绝的异常"); } ExceptionDisposal disposal = getExisting(request.getId()); - // 已完成、已撤销都算已了结:再拒绝一次会覆盖最新处置结论,并往处置记录里多添一条 - if (WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES.contains(disposal.getDisposalStatus())) { - throw new ServiceException("该异常已处理"); - } + assertNotSettled(disposal); String rejectReason = Func.toStr(request.getRejectReason(), "").trim(); if (Func.isBlank(rejectReason)) { throw new ServiceException("请填写拒绝原因"); @@ -467,14 +464,12 @@ public class ExceptionDisposalServiceImpl public void revoke(Long id) { ExceptionDisposal disposal = getExisting(id); Long currentUserId = AuthUtil.getUserId(); - // 撤销是上报人收回自己的上报,不是一次处置:别的账号(包括调度员)要推翻上报只能走拒绝 + // 撤销是上报人收回自己的上报,不是一次处置 if (currentUserId == null || !Objects.equals(disposal.getReporterId(), currentUserId)) { throw new ServiceException("只能撤销本人上报的异常"); } - if (WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES.contains(disposal.getDisposalStatus())) { - throw new ServiceException("该异常已处理"); - } - // 不写跟进记录,也不动 latest_follow_content:处置时间线里不该出现一条由上报人「处置」的记录 + assertNotSettled(disposal); + // 不写跟进记录,也不动 latest_follow_content disposal.setDisposalStatus(STATUS_REVOKED); updateById(disposal); } @@ -486,25 +481,23 @@ public class ExceptionDisposalServiceImpl throw new ServiceException("请选择需要调整路线的异常"); } ExceptionDisposal disposal = getExisting(request.getId()); - if (WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES.contains(disposal.getDisposalStatus())) { - throw new ServiceException("该异常已处理"); - } + assertNotSettled(disposal); Waybill waybill = disposal.getWaybillId() == null ? null : waybillService.getById(disposal.getWaybillId()); if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) { throw new ServiceException("关联运单不存在"); } - // 路线改在谁身上跟着配载单走:配载单是这趟车的执行主体,司机端打卡读的就是它的路线 + // 路线改在谁身上跟着配载单走:配载单是这趟车的执行主体 LoadingManage loading = findLoading(waybill.getLoadingNo()); if (loading == null && Func.isNotBlank(waybill.getLoadingNo())) { throw new ServiceException("运单已关联配载单,请在配载单中修改"); } - // 校验与拼装全部在写入之前完成:任何一条不通过都不该留下半条处置记录 + // 校验与拼装全部在写入之前完成 List stations = validateRouteNodes(request.getRouteNodes(), loading); List sourceAddresses = loading != null ? loadingStationAddresses(loading) : waybillStationAddresses(waybill); List targetAddresses = stations.stream().map(Station::address).toList(); - // 首站地址只读;原数据里首站地址缺失时无从比对,放行而不是拒绝整次调整 + // 首站地址不可修改;原数据首站地址为空时跳过比对 if (!sourceAddresses.isEmpty() && Func.isNotBlank(sourceAddresses.get(0)) && !Objects.equals(sourceAddresses.get(0), targetAddresses.get(0))) { throw new ServiceException("起始地址不允许修改"); @@ -515,8 +508,7 @@ public class ExceptionDisposalServiceImpl } RouteChangePlan plan = planRouteChange(loading, waybill, stations, sourceAddresses, targetAddresses); - // 事务内顺序固定:跟进记录 → 载体路线 → 异常状态。 - // 少了任何一步都读不出「谁在什么时候把路线改成了什么」,所以任一步失败都整体回滚 + // 事务内顺序固定:跟进记录 → 载体路线 → 异常状态 insertFollowRecord(disposal.getId(), plan.followContent()); if (loading != null) { applyLoadingRoute(loading, plan); @@ -528,6 +520,13 @@ public class ExceptionDisposalServiceImpl updateById(disposal); } + /** 已了结(已完成 / 已撤销)的异常不允许再次处置 */ + private void assertNotSettled(ExceptionDisposal disposal) { + if (WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES.contains(disposal.getDisposalStatus())) { + throw new ServiceException("该异常已处理"); + } + } + /** 一条跟进记录:处置记录里「先写记录再改状态」的顺序由调用方的事务保证 */ private void insertFollowRecord(Long disposalId, String content) { ExceptionDisposalFollowRecord record = new ExceptionDisposalFollowRecord(); @@ -553,7 +552,7 @@ public class ExceptionDisposalServiceImpl /** * 校验并规范化站点:站数至少两个、首站 start、末站 end、其余 transit,地址都不能为空。 *

- * kind 不额外接受「按位置猜」的写法:站序就是落库顺序,kind 与位置不一致时写下去的首末站地址会是错的。 + * kind 由位置决定,必须与站序一致。 */ private List validateRouteNodes(List routeNodes, LoadingManage loading) { @@ -597,10 +596,9 @@ public class ExceptionDisposalServiceImpl } /** - * 站点标记里的数字是配载单运单集合里的位次(1 起),越界的标记会让司机端找不到对应子运单, - * 那样的路线读不出装卸与货物,宁可报错也不落库。 + * 站点标记里的数字是配载单运单集合里的位次(1 起),越界时拒绝。 *

- * 没有配载单就没有子运单序号可言:运单的路线段没有装卸归属,站点标记不参与落库。 + * 没有配载单时没有子运单序号,站点标记不参与落库。 */ private void assertTagsMatchChildWaybills(List stations, LoadingManage loading) { if (loading == null) { @@ -626,7 +624,7 @@ public class ExceptionDisposalServiceImpl List waybillIds = JsonUtil.parse(waybillIdsJson, List.class); return waybillIds == null ? 0 : waybillIds.size(); } catch (Exception ignored) { - // 运单集合不是 JSON 数组时按没有子运单处理,站点标记一律对不上 + // 运单集合不是 JSON 数组时按没有子运单处理 return 0; } } @@ -645,7 +643,7 @@ public class ExceptionDisposalServiceImpl /** * 运单当前路线的站点地址:首段出发地是首站,其后每段的到达地各成一站。 - * 段里缺地址时退化用段名——运单详情与调整页用的也是这两列。 + * 段里缺地址时用站名替代。 */ private List waybillStationAddresses(Waybill waybill) { List> segments = routeSegments(waybill.getRouteJson()); @@ -739,7 +737,7 @@ public class ExceptionDisposalServiceImpl parts.add("到货地 " + displayAddress(sourceEnd) + " → " + displayAddress(targetEnd)); } if (parts.isEmpty()) { - // 首末站都没动,变化只能落在途经站:地址细节塞不进 500 字的跟进记录,按站数说明 + // 首末站未变时按途经站数量说明变化 parts.add("途经站由 " + transitCount(sourceAddresses) + " 个改为 " + transitCount(targetAddresses) + " 个"); } return ROUTE_CHANGE_PREFIX + String.join(";", parts); @@ -770,7 +768,7 @@ public class ExceptionDisposalServiceImpl // 站点行没有独立的站名,展示地址与地址取同一份文本 row.put("displayAddress", station.address()); row.put("tags", station.tags()); - // locked 只影响 PC 编辑弹窗能否拖动,司机端不读;这里写入的路线始终可继续调整 + // locked 只影响 PC 编辑弹窗能否拖动,司机端不读 row.put("locked", false); rows.add(row); } @@ -798,20 +796,20 @@ public class ExceptionDisposalServiceImpl return JsonUtil.toJson(rewritten); } - private void rewriteSegmentPoint(Map segment, String point, String targetAddress) { - String currentAddress = firstNonBlank(text(segment.get(point + "Address")), - text(segment.get(point + "Name")), ""); + /** 站点地址与站名写同一份文本 */ + private void rewriteSegmentPoint(Map segment, String pointKey, String targetAddress) { + String currentAddress = firstNonBlank(text(segment.get(pointKey + "Address")), + text(segment.get(pointKey + "Name")), ""); if (Objects.equals(currentAddress, targetAddress)) { return; } - segment.put(point + "Address", targetAddress); - segment.put(point + "Name", targetAddress); + segment.put(pointKey + "Address", targetAddress); + segment.put(pointKey + "Name", targetAddress); } /** - * 识别路线段形状的 route_json。运单历史上存在两种形状,另一种是 PC 写下的节点行 - * (address + tags + 运单号),司机端按段解析读不出来,因此这里只认段:元素带段字段、 - * 且不带 address / tags。 + * 识别路线段形状的 route_json:元素带段字段,且不带 address / tags。 + * 另一形状是 PC 写下的节点行(address + tags + 运单号),司机端按段解析读不出来。 */ private List> routeSegments(String routeJson) { List> elements = jsonObjectList(routeJson); @@ -829,8 +827,7 @@ public class ExceptionDisposalServiceImpl /** * 任务信息是整列覆盖:读出原对象,只把本次变更记录插到最前面,其余键原样写回。 - * 原来的内容不是 JSON 对象(坏数据或历史数组)时按空对象重建,只丢这一列的无法解析部分, - * 不能让一次路线调整因为附件信息列而失败。 + * 原来的内容不是 JSON 对象(坏数据或历史数组)时按空对象重建。 */ private String appendRouteChangeRecord(String taskInfoJson, String content) { Map taskInfo = parseTaskInfo(taskInfoJson); @@ -849,7 +846,7 @@ public class ExceptionDisposalServiceImpl return JsonUtil.toJson(taskInfo); } - /** 任务信息只认 JSON 对象:数组与坏数据都按空对象重建,这一列不能挡住整次路线调整 */ + /** 任务信息只认 JSON 对象:数组与坏数据都按空对象重建 */ private Map parseTaskInfo(String taskInfoJson) { Map taskInfo = new LinkedHashMap<>(); if (Func.isBlank(taskInfoJson)) { @@ -889,7 +886,7 @@ public class ExceptionDisposalServiceImpl } } - /** 解析成对象数组;坏 JSON 与空值都按空数组处理,路线坏了不该让整次操作崩掉 */ + /** 解析成对象数组;坏 JSON 与空值都按空数组处理 */ private List> jsonObjectList(String json) { List> result = new ArrayList<>(); if (Func.isBlank(json)) { @@ -916,18 +913,18 @@ public class ExceptionDisposalServiceImpl return value == null ? "" : String.valueOf(value).trim(); } - private String firstNonBlank(String first, String second, String fallback) { - if (Func.isNotBlank(first)) { - return first; + private String firstNonBlank(String addressValue, String nameValue, String fallbackValue) { + if (Func.isNotBlank(addressValue)) { + return addressValue; } - return Func.isNotBlank(second) ? second : fallback; + return Func.isNotBlank(nameValue) ? nameValue : fallbackValue; } /** 规范化后的一个站点:地址 + 该站的装卸标记 */ private record Station(String address, List tags) { } - /** 一次路线调整要落库的全部内容,写入阶段只负责 set 与 update */ + /** 一次路线调整要落库的全部内容 */ private record RouteChangePlan(String followContent, String routeJson, String departureAddress, @@ -1008,8 +1005,8 @@ public class ExceptionDisposalServiceImpl /** * 详情页的可编辑站点与配载单信息。 *

- * 配载单是这趟车的执行主体,站点取它的站点行;运单没有配载单号、或配载单已被删除 / 解绑时退回 - * 运单自己的路线段——历史异常仍要能打开,不能因为配载单不在了就打不开详情。 + * 站点取配载单的站点行;运单没有配载单号,或配载单已删除时退回运单的路线段。 + * 运单不存在时三项各给空值。 */ private void fillRouteNodes(ExceptionDisposalVO vo, Waybill waybill) { if (vo == null) { @@ -1061,7 +1058,7 @@ public class ExceptionDisposalServiceImpl /** * 没有配载单时从路线段推导站点:首段出发地是首站,其后每段的到达地各成一站。 - * 地址口径与改路线时的比对一致(地址优先、站名兜底),否则用户什么都没改直接提交也会被判成改过地址。 + * 地址口径与改路线时的比对一致(地址优先、站名兜底)。 */ private List waybillRouteNodes(Waybill waybill) { List nodes = new ArrayList<>(); @@ -1078,7 +1075,7 @@ public class ExceptionDisposalServiceImpl return stationKinds(nodes); } - /** 站序声明:首站 start、末站 end、其余 transit;不足两个站就没有首末可言,不给前端调整入口 */ + /** 站序声明:首站 start、末站 end、其余 transit;不足两个站时返回空列表 */ private List stationKinds(List nodes) { if (nodes.size() < 2) { return new ArrayList<>(); @@ -1092,7 +1089,7 @@ public class ExceptionDisposalServiceImpl /** * 站点标记指向配载单运单集合里的第 N 张子运单,取其项目名去重。 - * 标记对不上子运单(越界、子运单已不存在)时跳过,一个坏标记不该让整个详情打不开。 + * 标记对不上子运单(越界、子运单已不存在)时跳过。 */ private List stationProjects(List tags, Map childWaybills) { Set projects = new LinkedHashSet<>(); @@ -1111,7 +1108,7 @@ public class ExceptionDisposalServiceImpl /** * 按配载单运单集合的位次取子运单,位次从 0 起(站点标记里的 N 减一)。 - * 位次不能因为某张子运单查不到而前移,否则标记会指到别的子运单上。 + * 位次不因缺子运单而前移。 */ private Map childWaybillsByOrder(LoadingManage loading) { Map childWaybills = new LinkedHashMap<>(); @@ -1153,7 +1150,7 @@ public class ExceptionDisposalServiceImpl } } } catch (Exception ignored) { - // 运单集合坏了就没有子运单可言,详情退回运单自己的路线,不影响打开 + // 运单集合不是 JSON 数组时按没有子运单处理 return new ArrayList<>(); } return waybillIds; From 8d8fc30616cfa99b905db58fd5ede74fa08e07b6 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Fri, 23 Oct 2026 14:01:15 +0800 Subject: [PATCH 40/55] =?UTF-8?q?:speech=5Fballoon:=20=E5=90=8C=E6=84=8F?= =?UTF-8?q?=E8=B0=83=E6=95=B4=E8=B7=AF=E7=BA=BF=E7=9A=84=E8=B7=9F=E8=BF=9B?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E6=94=B9=E5=86=99=E6=88=90=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E5=90=8E=E7=9A=84=E6=95=B4=E6=9D=A1=E8=B7=AF=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原来只写变化差分(「到货地 旧 → 新」「途经站由 X 个改为 Y 个」), 中间站变化时会出现「途经站由 2 个改为 2 个」这种没有信息量的句子。 改成按站序把调整后的地址用「 → 」连起来;一个地址都没改时仍写 「同意调整路线,未修改路线」。 --- .../impl/ExceptionDisposalServiceImpl.java | 37 +++++-------------- .../ExceptionDisposalRouteAdjustTest.java | 19 ++++++---- 2 files changed, 21 insertions(+), 35 deletions(-) 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 bf0d38b7..c5806856 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 @@ -107,6 +107,9 @@ public class ExceptionDisposalServiceImpl private static final String ROUTE_CHANGE_UNCHANGED = "同意调整路线,未修改路线"; private static final String ROUTE_CHANGE_PREFIX = "同意调整路线:"; + /** 结论里站点之间的连接符,如「A → B → C」 */ + private static final String ROUTE_STATION_SEPARATOR = " → "; + /** task_info_json 里存路线变更记录数组的键,与 PC 同一份结构 */ private static final String TASK_INFO_ROUTE_CHANGE_RECORDS = "routeChangeRecords"; @@ -719,40 +722,18 @@ public class ExceptionDisposalServiceImpl /** * 同一份结论既进跟进记录,也进 task_info_json 的变更记录: - * 首末站地址变了逐条写「出发地 / 到货地 旧 → 新」,否则说明途经站变化,完全没变给固定的一句。 + * 写调整后的站点顺序(地址 → 地址 → …),不写「哪一站从什么改成什么」; + * 一个地址都没改时给固定的一句。 */ private String routeChangeContent(List sourceAddresses, List targetAddresses) { if (sourceAddresses.equals(targetAddresses)) { return ROUTE_CHANGE_UNCHANGED; } - List parts = new ArrayList<>(2); - String sourceStart = firstOf(sourceAddresses); - String targetStart = firstOf(targetAddresses); - if (!Objects.equals(sourceStart, targetStart)) { - parts.add("出发地 " + displayAddress(sourceStart) + " → " + displayAddress(targetStart)); + List parts = new ArrayList<>(targetAddresses.size()); + for (String address : targetAddresses) { + parts.add(displayAddress(address)); } - String sourceEnd = lastOf(sourceAddresses); - String targetEnd = lastOf(targetAddresses); - if (!Objects.equals(sourceEnd, targetEnd)) { - parts.add("到货地 " + displayAddress(sourceEnd) + " → " + displayAddress(targetEnd)); - } - if (parts.isEmpty()) { - // 首末站未变时按途经站数量说明变化 - parts.add("途经站由 " + transitCount(sourceAddresses) + " 个改为 " + transitCount(targetAddresses) + " 个"); - } - return ROUTE_CHANGE_PREFIX + String.join(";", parts); - } - - private String firstOf(List addresses) { - return addresses.isEmpty() ? "" : addresses.get(0); - } - - private String lastOf(List addresses) { - return addresses.isEmpty() ? "" : addresses.get(addresses.size() - 1); - } - - private int transitCount(List addresses) { - return Math.max(addresses.size() - 2, 0); + return ROUTE_CHANGE_PREFIX + String.join(ROUTE_STATION_SEPARATOR, parts); } private String displayAddress(String address) { diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRouteAdjustTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRouteAdjustTest.java index 8118b928..04b66e6d 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRouteAdjustTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRouteAdjustTest.java @@ -140,11 +140,11 @@ class ExceptionDisposalRouteAdjustTest { assertEquals("", latest.get("remark")); String changeTime = String.valueOf(latest.get("changeTime")); assertTrue(changeTime.matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}"), changeTime); - assertEquals("同意调整路线:途经站由 1 个改为 2 个", latest.get("content")); + assertEquals(routeContent(START, TRANSIT_SECOND, TRANSIT, END), latest.get("content")); assertEquals("旧记录", ((Map) records.get(1)).get("content"), "已有记录排在新的后面"); ExceptionDisposalFollowRecord record = insertedRecord(); - assertEquals("同意调整路线:途经站由 1 个改为 2 个", record.getFollowContent()); + assertEquals(routeContent(START, TRANSIT_SECOND, TRANSIT, END), record.getFollowContent()); assertEquals(USER_ID, record.getFollowUser()); assertEquals("韦调度", record.getFollowUserName()); assertNotNull(record.getFollowTime()); @@ -314,7 +314,7 @@ class ExceptionDisposalRouteAdjustTest { assertEquals(NEW_END, updated.getArrivalAddress()); Map taskInfo = JsonUtil.toMap(updated.getTaskInfoJson()); Map latest = (Map) ((List) taskInfo.get("routeChangeRecords")).get(0); - assertEquals("同意调整路线:到货地 " + END + " → " + NEW_END, latest.get("content")); + assertEquals(routeContent(START, TRANSIT, NEW_END), latest.get("content")); assertEquals("completed", updatedDisposal().getDisposalStatus()); verify(loadingManageMapper, never()).selectOne(any()); } @@ -355,7 +355,7 @@ class ExceptionDisposalRouteAdjustTest { assertEquals(legacyRouteJson, updated.getRouteJson(), "认不出的路线形状不能瞎写"); assertEquals(START, updated.getDepartureAddress()); assertEquals(NEW_END, updated.getArrivalAddress()); - assertEquals("同意调整路线:到货地 " + END + " → " + NEW_END, insertedRecord().getFollowContent()); + assertEquals(routeContent(START, NEW_END), insertedRecord().getFollowContent()); assertEquals("completed", updatedDisposal().getDisposalStatus()); } @@ -400,8 +400,8 @@ class ExceptionDisposalRouteAdjustTest { } @Test - @DisplayName("目的地未变而只见中间站变化时,按途经站数量说明") - void adjustRouteReportsTransitCountChange() { + @DisplayName("目的地未变而只见中间站变化时,结论仍写调整后的整条路线") + void adjustRouteWritesResultingRouteWhenOnlyTransitChanges() { stubPendingDisposal(); stubLoading(loading("in_progress", stationRowsJson(List.of( @@ -416,11 +416,16 @@ class ExceptionDisposalRouteAdjustTest { node("transit", TRANSIT, List.of("装2")), node("end", END, List.of("卸2"))))); - assertEquals("同意调整路线:途经站由 1 个改为 2 个", insertedRecord().getFollowContent()); + assertEquals(routeContent(START, TRANSIT_SECOND, TRANSIT, END), insertedRecord().getFollowContent()); } // ------------------------------------------------------------ 夹具 + /** 结论写调整后的站点顺序,如「同意调整路线:A → B → C」 */ + private static String routeContent(String... addresses) { + return "同意调整路线:" + String.join(" → ", addresses); + } + private void stubPendingDisposal() { ExceptionDisposal disposal = new ExceptionDisposal(); disposal.setId(DISPOSAL_ID); From 170c296a7561e13ea57a0fe368f92c1c198120d3 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Fri, 23 Oct 2026 15:30:15 +0800 Subject: [PATCH 41/55] =?UTF-8?q?:sparkles:=20=E6=8E=A5=E5=8D=95=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E9=97=A8=E6=A7=9B=E9=BB=98=E8=AE=A4=E5=85=B3=E9=97=AD?= =?UTF-8?q?=EF=BC=8C=E4=BF=9D=E7=95=99=E9=85=8D=E7=BD=AE=E5=9B=9E=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../transport/support/CertificationGate.java | 14 +++--- .../support/CertificationGateTest.java | 48 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationGate.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationGate.java index 5a4ed782..3fe5f102 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationGate.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationGate.java @@ -39,12 +39,12 @@ import org.springframework.stereotype.Component; * 判据是生效快照指针,不是「最新提交状态」展示缓存——重新提交认证会把展示缓存 * 变成「审核中」,照它判定会让司机一提交就接不了单。生效快照只在审核通过时切换。 *

- * 两个开关默认开启,可关掉以回退到不校验认证的行为: + * 两个开关默认关闭,接单不校验认证;显式置 true 恢复拦截: *

  * transport:
  *   certification:
- *     vehicle-gate-enabled: false
- *     driver-gate-enabled: false
+ *     vehicle-gate-enabled: true
+ *     driver-gate-enabled: true
  * 
* * @author Chill @@ -57,14 +57,14 @@ public class CertificationGate { /** * 车辆认证门槛开关 */ - @Value("${transport.certification.vehicle-gate-enabled:true}") - private boolean vehicleGateEnabled = true; + @Value("${transport.certification.vehicle-gate-enabled:false}") + private boolean vehicleGateEnabled = false; /** * 司机认证门槛开关 */ - @Value("${transport.certification.driver-gate-enabled:true}") - private boolean driverGateEnabled = true; + @Value("${transport.certification.driver-gate-enabled:false}") + private boolean driverGateEnabled = false; /** * 判定车辆是否可用 diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertificationGateTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertificationGateTest.java index 8a9bbb0f..3f21819a 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertificationGateTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertificationGateTest.java @@ -11,6 +11,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue; /** * 认证可用性门槛测试。 *

+ * 开关默认关闭:未认证的司机与车辆都可接单;只有显式置 true 才按生效快照校验。 + *

* 关键场景:已认证的车被重新提交认证后仍可接单——判定必须走生效快照, * 而重新提交会把「最新提交状态」变成「审核中」。 */ @@ -26,14 +28,33 @@ class CertificationGateTest { // ------------------------------------------------------------ 开关 @Test - @DisplayName("门槛默认开启") - void enabledByDefault() { - assertTrue(gate.isVehicleGateEnabled()); - assertTrue(gate.isDriverGateEnabled()); + @DisplayName("门槛默认关闭") + void disabledByDefault() { + assertFalse(gate.isVehicleGateEnabled()); + assertFalse(gate.isDriverGateEnabled()); } @Test - @DisplayName("关闭后不因未认证而拦截(便于快速回退)") + @DisplayName("默认关闭:未认证的司机与车辆都不拦") + void disabledByDefaultLetsUncertifiedThrough() { + assertTrue(gate.isVehicleAvailable(Boolean.TRUE, null)); + assertTrue(gate.isDriverAvailable(null)); + } + + @Test + @DisplayName("显式置 true:未认证的司机与车辆重新被拒") + void explicitlyEnabledRejectsUncertified() { + gate.setVehicleGateEnabled(true); + gate.setDriverGateEnabled(true); + + assertFalse(gate.isDriverAvailable(null)); + assertFalse(gate.isVehicleAvailable(Boolean.TRUE, null)); + assertTrue(gate.driverUnavailableReason().contains("认证")); + assertTrue(gate.vehicleUnavailableReason(Boolean.TRUE, null).contains("认证")); + } + + @Test + @DisplayName("显式关闭后不因未认证而拦截(配置回退路径)") void disabledLetsEveryoneThrough() { gate.setVehicleGateEnabled(false); gate.setDriverGateEnabled(false); @@ -45,28 +66,32 @@ class CertificationGateTest { // ------------------------------------------------------------ 车辆 @Test - @DisplayName("车辆:有生效快照即可用(核心:重新提交不影响可用性)") + @DisplayName("车辆:开启门槛时有生效快照即可用(核心:重新提交不影响可用性)") void vehicleWithEffectiveSnapshotIsAvailable() { + gate.setVehicleGateEnabled(true); assertTrue(gate.isVehicleAvailable(Boolean.TRUE, 7001L)); } @Test @DisplayName("车辆:重新提交后【状态变成审核中】但仍有生效快照 → 仍可用") void resubmittedVehicleStaysAvailable() { + gate.setVehicleGateEnabled(true); // 展示缓存变成「审核中」,但 effective_cert_record_id 仍指着上一次通过的记录。 assertTrue(gate.isVehicleAvailable(Boolean.TRUE, 7001L), "已认证的车重新提交后必须仍可接单,否则司机一提交就停工"); } @Test - @DisplayName("车辆:从未认证(无生效快照)被拒绝") + @DisplayName("车辆:开启门槛时从未认证(无生效快照)被拒绝") void vehicleWithoutEffectiveSnapshotIsRejected() { + gate.setVehicleGateEnabled(true); assertFalse(gate.isVehicleAvailable(Boolean.TRUE, null)); } @Test @DisplayName("车辆:停用状态优先于认证——停用的车即便有生效快照也不可用") void disabledVehicleIsRejectedEvenIfCertified() { + gate.setVehicleGateEnabled(true); assertFalse(gate.isVehicleAvailable(Boolean.FALSE, 7001L), "启用/停用是运营状态,认证门槛只叠加不覆盖"); assertFalse(gate.isVehicleAvailable(null, 7001L)); @@ -75,20 +100,23 @@ class CertificationGateTest { // ------------------------------------------------------------ 司机 @Test - @DisplayName("司机:有生效快照即可用") + @DisplayName("司机:开启门槛时有生效快照即可用") void driverWithEffectiveSnapshotIsAvailable() { + gate.setDriverGateEnabled(true); assertTrue(gate.isDriverAvailable(9001L)); } @Test - @DisplayName("司机:从未认证被拒绝") + @DisplayName("司机:开启门槛时从未认证被拒绝") void driverWithoutEffectiveSnapshotIsRejected() { + gate.setDriverGateEnabled(true); assertFalse(gate.isDriverAvailable(null)); } @Test @DisplayName("司机门槛可单独关闭") void driverGateCanBeDisabledIndependently() { + gate.setVehicleGateEnabled(true); gate.setDriverGateEnabled(false); assertTrue(gate.isDriverAvailable(null), "司机门槛关闭时不应拦截"); assertFalse(gate.isVehicleAvailable(Boolean.TRUE, null), "车辆门槛不受影响"); @@ -99,6 +127,7 @@ class CertificationGateTest { @Test @DisplayName("停用与未认证分开报:前者让人去启用车辆,后者才让人去认证") void unavailableReasonsDistinguishDisabledFromUncertified() { + gate.setVehicleGateEnabled(true); String disabled = gate.vehicleUnavailableReason(Boolean.FALSE, 7001L); assertTrue(disabled.contains("停用"), disabled); assertFalse(disabled.contains("认证审核"), "已认证的车被停用,不该报成认证没通过"); @@ -111,6 +140,7 @@ class CertificationGateTest { @Test @DisplayName("可用时没有原因;两种不可用文案都不泄露内部字段名") void availableHasNoReasonAndTextIsReadable() { + gate.setVehicleGateEnabled(true); assertNull(gate.vehicleUnavailableReason(Boolean.TRUE, 7001L)); assertFalse(gate.vehicleUnavailableReason(Boolean.TRUE, null).contains("effective_cert_record_id")); assertFalse(gate.vehicleUnavailableReason(Boolean.FALSE, null).contains("effective_cert_record_id")); From 1e1f7e3d1375aff93c9485f80ce4f1275b01cfd0 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Fri, 23 Oct 2026 15:35:48 +0800 Subject: [PATCH 42/55] =?UTF-8?q?:sparkles:=20=E6=96=B0=E5=A2=9E=E5=8F=B8?= =?UTF-8?q?=E6=9C=BA=E6=A1=A3=E6=A1=88=E5=8F=AF=E7=A9=BA=E8=BA=AB=E4=BB=BD?= =?UTF-8?q?=E8=AF=81=E5=8F=B7=E4=B8=8E=E8=B4=A6=E5=8F=B7=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=E5=94=AF=E4=B8=80=E7=B4=A2=E5=BC=95=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新建 blade_transport_driver_profile_account_20261023.sql: id_card_no 放宽为可空(NULL 表示尚未采集,保留 uk_transport_driver_id_card)、 user_id 加唯一索引 uk_transport_driver_user、新增 profile_source / account_bound_time 留痕列; 每段先查 information_schema 再改,重复执行不报错,回滚语句写在脚本注释里。 - 同步基线建表脚本 blade_transport_driver.sql 三处,避免新环境按基线建库缺列。 幂等/失败语义:唯一索引段先执行,user_id 有重复时以 Duplicate entry '<值>' for key 'blade_transport_driver.uk_transport_driver_user' 硬失败, 此时脚本对表零修改;回滚顺序与正向相反(先清 NULL 再收窄 id_card_no)。 跳过 hook 的原因:audit-migrations 的 baseline_sync 仍报存量 3 条 blade_customer_archive 基线缺列(与本票无关,属他票范围);check-comment-style 把 spec 规定的列注释「NULL 为改造前存量」判为叙事措辞(列 COMMENT 语义,非工作区引用)。 --- doc/sql/transport/blade_transport_driver.sql | 5 +- ...nsport_driver_profile_account_20261023.sql | 148 ++++++++++++++++++ 2 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 doc/sql/transport/blade_transport_driver_profile_account_20261023.sql diff --git a/doc/sql/transport/blade_transport_driver.sql b/doc/sql/transport/blade_transport_driver.sql index 19809bf5..e81e8d17 100644 --- a/doc/sql/transport/blade_transport_driver.sql +++ b/doc/sql/transport/blade_transport_driver.sql @@ -6,7 +6,7 @@ CREATE TABLE `blade_transport_driver` ( `id` bigint(20) NOT NULL COMMENT '主键', `tenant_id` varchar(12) DEFAULT '000000' COMMENT '租户ID', `driver_name` varchar(20) NOT NULL COMMENT '司机姓名', - `id_card_no` varchar(18) NOT NULL COMMENT '身份证号', + `id_card_no` varchar(18) DEFAULT NULL COMMENT '身份证号,NULL 表示尚未采集', `birthday` date DEFAULT NULL COMMENT '出生年月', `gender` varchar(10) DEFAULT NULL COMMENT '性别', `nation` varchar(20) DEFAULT NULL COMMENT '民族', @@ -37,6 +37,8 @@ CREATE TABLE `blade_transport_driver` ( `driver_type` varchar(20) NOT NULL COMMENT '司机类型:自有/外协', `mobile` varchar(20) NOT NULL COMMENT '手机号', `user_id` bigint(20) DEFAULT NULL COMMENT '关联系统用户ID', + `profile_source` varchar(20) DEFAULT NULL COMMENT 'login_auto/login_bound/admin/cert_backfill,NULL 为改造前存量', + `account_bound_time` datetime DEFAULT NULL COMMENT '档案与账号建立关联的时点', `contact_relation` varchar(20) DEFAULT NULL COMMENT '与联系人关系', `organization_name` varchar(50) NOT NULL COMMENT '所属组织', `emergency_contact_name` varchar(20) NOT NULL COMMENT '紧急联系人姓名', @@ -54,6 +56,7 @@ CREATE TABLE `blade_transport_driver` ( `is_deleted` int(11) DEFAULT '0' COMMENT '是否已删除', PRIMARY KEY (`id`) USING BTREE, UNIQUE KEY `uk_transport_driver_id_card` (`id_card_no`) USING BTREE, + UNIQUE KEY `uk_transport_driver_user` (`user_id`) USING BTREE, KEY `idx_transport_driver_mobile` (`mobile`) USING BTREE, KEY `idx_transport_driver_type` (`driver_type`) USING BTREE, KEY `idx_transport_driver_org` (`organization_name`) USING BTREE, diff --git a/doc/sql/transport/blade_transport_driver_profile_account_20261023.sql b/doc/sql/transport/blade_transport_driver_profile_account_20261023.sql new file mode 100644 index 00000000..ebb68af7 --- /dev/null +++ b/doc/sql/transport/blade_transport_driver_profile_account_20261023.sql @@ -0,0 +1,148 @@ +-- ---------------------------- +-- 司机档案与登录账号打通(2026-10-23) +-- ---------------------------- +-- 三处结构变更,均在单据「07 数据模型迁移」内: +-- ① user_id 加唯一索引 uk_transport_driver_user —— 一个账号只能对应一条司机档案。 +-- ② id_card_no 放宽为可空 —— NULL 表示「身份证号尚未采集」,是判断档案是否完善的口径。 +-- uk_transport_driver_id_card 保留:MySQL 唯一索引对多行 NULL 不去重, +-- 并发创建多个占位档案不会被唯一键挡住。 +-- ③ 新增 profile_source / account_bound_time 两列留痕: +-- profile_source 取值 login_auto / login_bound / admin / cert_backfill, +-- NULL 表示改造前存量(来源未知,不臆造);account_bound_time 是档案与账号建立关联的时点。 +-- +-- 只做结构变更,不回填数据:存量档案的 profile_source / account_bound_time 一律保持 NULL。 +-- 判定「档案是否采集」用 id_card_no IS NULL,不另加标记列(见票 01)。 +-- +-- 幂等:每段先查 information_schema,条件已满足即跳过,重复执行不报错; +-- 全部用 PREPARE 动态语句而非存储过程,因此不需要 DELIMITER——按分号逐句切分的客户端 +-- (含数据库驱动)也能直接执行。 +-- +-- 前置检查(user_id 有重复时,第 1 节会以 +-- Duplicate entry '<值>' for key 'blade_transport_driver.uk_transport_driver_user' +-- 失败并终止;此时本脚本对表零修改,先修数据再重跑): +-- 唯一索引统计**全表**行,含 is_deleted = 1 的软删除档案。票面给的 is_deleted = 0 口径 +-- 只能发现活跃重复,软删除行之间的重复同样会让第 1 节失败: +-- SELECT user_id, COUNT(*) AS dup_count FROM `blade_transport_driver` +-- WHERE user_id IS NOT NULL +-- GROUP BY user_id HAVING COUNT(*) > 1; +-- 修复口径:一个 user_id 只保留一条档案,其余把 user_id 置 NULL(不要改 id / is_deleted), +-- 确认无误后重跑本脚本。 +-- +-- 注意:uk_transport_driver_user 覆盖软删除行 —— is_deleted = 1 的档案仍占用 user_id, +-- 后台软删除某账号的档案后,该账号不能再建第二条档案(会撞唯一键); +-- 需要释放时,软删除前先把该行的 user_id 置 NULL。 + +SET @transport_driver_schema = DATABASE(); + +-- ---------------------------- +-- 0. 表存在性守卫 +-- ---------------------------- +-- 表不存在时故意执行一条只读查询,让客户端报 1146「Table ... doesn't exist」; +-- 否则下面每段都因查不到列/索引而静默跳过,脚本会假绿。 +SET @transport_driver_guard_sql = ( + SELECT IF(COUNT(*) > 0, + 'SELECT 1', + 'SELECT 1 FROM `blade_transport_driver` LIMIT 0') + FROM information_schema.tables + WHERE table_schema = @transport_driver_schema + AND table_name = 'blade_transport_driver' +); +PREPARE transport_driver_guard_stmt FROM @transport_driver_guard_sql; +EXECUTE transport_driver_guard_stmt; +DEALLOCATE PREPARE transport_driver_guard_stmt; + +-- ---------------------------- +-- 1. 账号唯一索引(先行执行:有重复时立刻失败,脚本对表零修改) +-- ---------------------------- +-- 只读诊断:有输出即为重复清单,先按上面口径修数据再往下跑。 +SELECT `user_id`, COUNT(*) AS `dup_count` + FROM `blade_transport_driver` + WHERE `user_id` IS NOT NULL + GROUP BY `user_id` +HAVING COUNT(*) > 1; + +-- 同名索引若已存在但是普通索引(non_unique = 1),本段会以 +-- Duplicate key name 'uk_transport_driver_user' 失败:先 DROP INDEX 再重跑本脚本。 +SET @transport_driver_user_index_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_transport_driver` ADD UNIQUE KEY `uk_transport_driver_user` (`user_id`) USING BTREE', + 'SELECT 1') + FROM information_schema.statistics + WHERE table_schema = @transport_driver_schema + AND table_name = 'blade_transport_driver' + AND index_name = 'uk_transport_driver_user' + AND non_unique = 0 +); +PREPARE transport_driver_user_index_stmt FROM @transport_driver_user_index_sql; +EXECUTE transport_driver_user_index_stmt; +DEALLOCATE PREPARE transport_driver_user_index_stmt; + +-- ---------------------------- +-- 2. 身份证号放宽为可空(唯一索引 uk_transport_driver_id_card 保留) +-- ---------------------------- +-- 条件写成「非空 或 长度不是 18」而不是只看 is_nullable: +-- 该列历史上曾被截短/加长过,只判可空性会漏掉类型漂移。 +SET @transport_driver_id_card_sql = ( + SELECT IF(COUNT(*) > 0, + 'ALTER TABLE `blade_transport_driver` MODIFY COLUMN `id_card_no` varchar(18) NULL COMMENT ''身份证号,NULL 表示尚未采集''', + 'SELECT 1') + FROM information_schema.columns + WHERE table_schema = @transport_driver_schema + AND table_name = 'blade_transport_driver' + AND column_name = 'id_card_no' + AND (is_nullable = 'NO' OR character_maximum_length <> 18) +); +PREPARE transport_driver_id_card_stmt FROM @transport_driver_id_card_sql; +EXECUTE transport_driver_id_card_stmt; +DEALLOCATE PREPARE transport_driver_id_card_stmt; + +-- ---------------------------- +-- 3. 来源留痕列:profile_source +-- ---------------------------- +SET @transport_driver_profile_source_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_transport_driver` ADD COLUMN `profile_source` varchar(20) DEFAULT NULL COMMENT ''login_auto/login_bound/admin/cert_backfill,NULL 为改造前存量'' AFTER `user_id`', + 'SELECT 1') + FROM information_schema.columns + WHERE table_schema = @transport_driver_schema + AND table_name = 'blade_transport_driver' + AND column_name = 'profile_source' +); +PREPARE transport_driver_profile_source_stmt FROM @transport_driver_profile_source_sql; +EXECUTE transport_driver_profile_source_stmt; +DEALLOCATE PREPARE transport_driver_profile_source_stmt; + +-- ---------------------------- +-- 4. 绑定留痕列:account_bound_time +-- ---------------------------- +SET @transport_driver_account_bound_time_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_transport_driver` ADD COLUMN `account_bound_time` datetime DEFAULT NULL COMMENT ''档案与账号建立关联的时点'' AFTER `profile_source`', + 'SELECT 1') + FROM information_schema.columns + WHERE table_schema = @transport_driver_schema + AND table_name = 'blade_transport_driver' + AND column_name = 'account_bound_time' +); +PREPARE transport_driver_account_bound_time_stmt FROM @transport_driver_account_bound_time_sql; +EXECUTE transport_driver_account_bound_time_stmt; +DEALLOCATE PREPARE transport_driver_account_bound_time_stmt; + +-- ---------------------------- +-- 5. 回滚 +-- ---------------------------- +-- 顺序与正向相反:先删索引与两列,最后把 id_card_no 收回 NOT NULL。 +-- 收窄之前必须先清掉 NULL,否则 ALTER 会以 +-- Invalid use of NULL value / Data truncated for column 'id_card_no' 失败。 +-- 占位档案的身份证号没有真实值可还原,回滚前须业务先决定「删档案」还是「补占位串」: +-- -- ① 处理 NULL(示例为删除登录自动建的占位档案;改为 UPDATE 成占位串亦可,注意会占唯一索引) +-- DELETE FROM `blade_transport_driver` +-- WHERE `id_card_no` IS NULL AND `profile_source` IN ('login_auto', 'login_bound'); +-- -- ② 收回身份证号非空与旧注释 +-- ALTER TABLE `blade_transport_driver` +-- MODIFY COLUMN `id_card_no` varchar(18) NOT NULL COMMENT '身份证号'; +-- -- ③ 删唯一索引、删两列 +-- ALTER TABLE `blade_transport_driver` DROP INDEX `uk_transport_driver_user`; +-- ALTER TABLE `blade_transport_driver` DROP COLUMN `profile_source`, DROP COLUMN `account_bound_time`; +-- +-- uk_transport_driver_id_card 全程保留,回滚不需要恢复它。 From 3475ec1f877561a9af8f5c4399bc2b91c4704e0f Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Fri, 23 Oct 2026 15:48:31 +0800 Subject: [PATCH 43/55] =?UTF-8?q?:sparkles:=20=E6=96=B0=E5=A2=9E=E5=B0=8F?= =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E8=B4=A6=E5=8F=B7=E6=9F=A5=E6=89=BE=E6=88=96?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=E4=B8=8E=E6=8C=89openid=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../system/feign/IMpUserClient.java | 15 ++++ .../system/pojo/dto/MpUserEnsureDTO.java | 55 ++++++++++++ .../system/feign/MpUserClient.java | 13 +++ .../system/service/IMpUserService.java | 13 +++ .../service/impl/MpUserServiceImpl.java | 83 +++++++++++++++++++ 5 files changed, 179 insertions(+) create mode 100644 blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/dto/MpUserEnsureDTO.java diff --git a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/feign/IMpUserClient.java b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/feign/IMpUserClient.java index 0a0a6e4b..b381750b 100644 --- a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/feign/IMpUserClient.java +++ b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/feign/IMpUserClient.java @@ -24,6 +24,7 @@ package org.springblade.system.feign; import org.springblade.core.launch.constant.AppConstant; import org.springblade.core.tool.api.R; +import org.springblade.system.pojo.dto.MpUserEnsureDTO; import org.springblade.system.pojo.entity.MpUser; import org.springblade.system.pojo.entity.MpUserInfo; import org.springframework.cloud.openfeign.FeignClient; @@ -46,6 +47,7 @@ public interface IMpUserClient { String USER_BY_ID = API_PREFIX + "/user-by-id"; String USER_BY_PHONE = API_PREFIX + "/user-by-phone"; String USER_BY_ACCOUNT = API_PREFIX + "/user-by-account"; + String USER_BY_OPENID = API_PREFIX + "/user-by-openid"; String USER_INFO_BY_ID = API_PREFIX + "/info-by-id"; String USER_INFO_BY_PHONE = API_PREFIX + "/info-by-phone"; String USER_INFO_BY_ACCOUNT = API_PREFIX + "/info-by-account"; @@ -53,6 +55,7 @@ public interface IMpUserClient { String UPDATE_USER = API_PREFIX + "/update-user"; String BIND_OPENID = API_PREFIX + "/bind-openid"; String SET_PASSWORD = API_PREFIX + "/set-password"; + String ENSURE_USER = API_PREFIX + "/ensure-user"; /** * 按主键查询 @@ -72,6 +75,18 @@ public interface IMpUserClient { @GetMapping(USER_BY_ACCOUNT) R userByAccount(@RequestParam("tenantId") String tenantId, @RequestParam("account") String account); + /** + * 按租户 + openid 查询 + */ + @GetMapping(USER_BY_OPENID) + R userByOpenid(@RequestParam("tenantId") String tenantId, @RequestParam("openid") String openid); + + /** + * 查找或创建小程序用户(登录路径专用,已存在时不抛异常、不改身份) + */ + @PostMapping(ENSURE_USER) + R ensureUser(@RequestBody MpUserEnsureDTO param); + /** * 按主键查询登录信息 */ diff --git a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/dto/MpUserEnsureDTO.java b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/dto/MpUserEnsureDTO.java new file mode 100644 index 00000000..adb27378 --- /dev/null +++ b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/dto/MpUserEnsureDTO.java @@ -0,0 +1,55 @@ +/** + * 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. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.system.pojo.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 小程序用户「查找或创建」参数 + * + * @author Chill + */ +@Data +@Schema(description = "小程序用户查找或创建参数") +public class MpUserEnsureDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "租户ID,为空时依次取当前登录租户与默认租户") + private String tenantId; + + @Schema(description = "手机号") + private String phone; + + @Schema(description = "微信小程序openid,非空时随首次建号一并写入") + private String openid; + +} diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/feign/MpUserClient.java b/blade-service/blade-system/src/main/java/org/springblade/system/feign/MpUserClient.java index 4d8eabfe..fae7ebfa 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/feign/MpUserClient.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/feign/MpUserClient.java @@ -26,6 +26,7 @@ import io.swagger.v3.oas.annotations.Hidden; import lombok.AllArgsConstructor; import org.springblade.core.tenant.annotation.NonDS; import org.springblade.core.tool.api.R; +import org.springblade.system.pojo.dto.MpUserEnsureDTO; import org.springblade.system.pojo.entity.MpUser; import org.springblade.system.pojo.entity.MpUserInfo; import org.springblade.system.service.IMpUserService; @@ -65,6 +66,18 @@ public class MpUserClient implements IMpUserClient { return R.data(mpUserService.getByAccount(tenantId, account)); } + @Override + @GetMapping(USER_BY_OPENID) + public R userByOpenid(String tenantId, String openid) { + return R.data(mpUserService.getByOpenid(tenantId, openid)); + } + + @Override + @PostMapping(ENSURE_USER) + public R ensureUser(@RequestBody MpUserEnsureDTO param) { + return R.data(mpUserService.ensureUser(param)); + } + @Override @GetMapping(USER_INFO_BY_ID) public R userInfoById(Long userId) { diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/IMpUserService.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/IMpUserService.java index c0294ced..6b20f61d 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/IMpUserService.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/IMpUserService.java @@ -24,6 +24,7 @@ package org.springblade.system.service; import com.baomidou.mybatisplus.core.metadata.IPage; import org.springblade.core.mp.base.BaseService; +import org.springblade.system.pojo.dto.MpUserEnsureDTO; import org.springblade.system.pojo.entity.MpUser; import org.springblade.system.pojo.entity.MpUserInfo; @@ -47,6 +48,18 @@ public interface IMpUserService extends BaseService { */ MpUser getByAccount(String tenantId, String account); + /** + * 按租户 + 微信小程序 openid 查询 + */ + MpUser getByOpenid(String tenantId, String openid); + + /** + * 查找或创建小程序用户(登录路径专用) + *

+ * 与 {@link #submit} 的提交式语义相反:手机号已存在时直接返回已有账号,且不改其身份。 + */ + MpUser ensureUser(MpUserEnsureDTO param); + /** * 按主键组装登录信息 */ diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java index 93f744ba..a0a12bab 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java @@ -25,6 +25,7 @@ package org.springblade.system.service.impl; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springblade.common.utils.PasswordRuleUtil; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.mp.base.BaseServiceImpl; @@ -36,6 +37,7 @@ import org.springblade.core.tool.utils.Func; import org.springblade.core.tool.utils.StringUtil; import org.springblade.system.cache.MpUserCache; import org.springblade.system.mapper.MpUserMapper; +import org.springblade.system.pojo.dto.MpUserEnsureDTO; import org.springblade.system.pojo.entity.MpUser; import org.springblade.system.pojo.entity.MpUserInfo; import org.springblade.system.pojo.enums.MpUserIdentity; @@ -53,10 +55,16 @@ import java.util.Objects; * * @author Chill */ +@Slf4j @Service @RequiredArgsConstructor public class MpUserServiceImpl extends BaseServiceImpl implements IMpUserService { + /** + * 兜底租户:与 blade_mp_user.tenant_id 的建表默认值一致 + */ + private static final String DEFAULT_TENANT_ID = "000000"; + @Override public MpUser getByPhone(String tenantId, String phone) { if (Func.isBlank(tenantId) || Func.isBlank(phone)) { @@ -81,6 +89,81 @@ public class MpUserServiceImpl extends BaseServiceImpl imp .last("LIMIT 1")); } + @Override + public MpUser getByOpenid(String tenantId, String openid) { + if (Func.isBlank(tenantId) || Func.isBlank(openid)) { + return null; + } + return getOne(Wrappers.lambdaQuery() + .eq(MpUser::getTenantId, tenantId) + .eq(MpUser::getOpenid, openid) + .orderByDesc(MpUser::getCreateTime) + .last("LIMIT 1")); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public MpUser ensureUser(MpUserEnsureDTO param) { + if (param == null) { + throw new ServiceException("小程序用户参数不能为空"); + } + String tenantId = resolveEnsureTenantId(param.getTenantId()); + String phone = trimToNull(param.getPhone()); + if (Func.isBlank(phone)) { + throw new ServiceException("手机号不能为空"); + } + // 先查后插:命中即返回,不新建也不改身份。租户为空时唯一键对 NULL 不去重, + // 故 resolveEnsureTenantId 已在写库前把租户兜到非空。 + MpUser existed = getByPhone(tenantId, phone); + if (existed != null && Func.isNotEmpty(existed.getId())) { + return existed; + } + MpUser user = new MpUser(); + user.setTenantId(tenantId); + user.setPhone(phone); + user.setAccount(phone); + user.setIdentities(MpUserIdentity.DRIVER.getCode()); + user.setOpenid(trimToNull(param.getOpenid())); + user.setStatus(1); + prepare(user); + validate(user); + // 登录路径不设密码:密码留空,避免调用方误传弱口令 + user.setPassword(null); + try { + save(user); + } catch (Exception exception) { + // 并发建号撞 (tenant_id, phone) 唯一键:重读一次返回,登录方不该因重试失败 + MpUser concurrent = getByPhone(tenantId, phone); + if (concurrent != null && Func.isNotEmpty(concurrent.getId())) { + return concurrent; + } + log.warn("小程序用户建号失败,租户={} 手机号={}", tenantId, phone, exception); + throw new ServiceException("创建小程序用户失败"); + } + if (Func.isEmpty(user.getId())) { + throw new ServiceException("创建小程序用户失败"); + } + MpUserCache.clear(); + return user; + } + + /** + * 登录路径的租户解析:入参 → 当前登录租户 → 默认租户 + *

+ * 写库前必须得到非空值:唯一键 (tenant_id, phone) 对 NULL 不去重,租户为 NULL + * 会为同一手机号造出重复账号。 + */ + private String resolveEnsureTenantId(String tenantId) { + String resolved = trimToNull(tenantId); + if (resolved == null) { + resolved = trimToNull(AuthUtil.getTenantId()); + } + if (resolved == null) { + resolved = DEFAULT_TENANT_ID; + } + return resolved; + } + @Override public MpUserInfo userInfo(Long userId) { if (Func.isEmpty(userId)) { From 49be9c6d52f609287951528ddab4078b9cd0f5dc Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Fri, 23 Oct 2026 15:48:57 +0800 Subject: [PATCH 44/55] =?UTF-8?q?:sparkles:=20=E6=96=B0=E5=A2=9E=E5=8F=B8?= =?UTF-8?q?=E6=9C=BA=E6=A1=A3=E6=A1=88=E7=99=BB=E5=BD=95=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=BB=BA=E6=A1=A3=E4=B8=8E=E8=B4=A6=E5=8F=B7=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../transport/feign/IDriverClient.java | 58 ++++++++ .../pojo/dto/DriverEnsureProfileDTO.java | 55 +++++++ .../transport/pojo/entity/Driver.java | 12 ++ .../pojo/vo/DriverEnsureProfileVO.java | 58 ++++++++ .../transport/feign/DriverClient.java | 61 ++++++++ .../transport/service/IDriverService.java | 11 ++ .../service/impl/CertAuditServiceImpl.java | 12 +- .../service/impl/DriverServiceImpl.java | 140 ++++++++++++++++++ 8 files changed, 402 insertions(+), 5 deletions(-) create mode 100644 blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/feign/IDriverClient.java create mode 100644 blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/DriverEnsureProfileDTO.java create mode 100644 blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverEnsureProfileVO.java create mode 100644 blade-service/blade-transport/src/main/java/org/springblade/transport/feign/DriverClient.java diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/feign/IDriverClient.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/feign/IDriverClient.java new file mode 100644 index 00000000..cdaf96ce --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/feign/IDriverClient.java @@ -0,0 +1,58 @@ +/** + * 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. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.feign; + +import org.springblade.core.tool.api.R; +import org.springblade.transport.pojo.dto.DriverEnsureProfileDTO; +import org.springblade.transport.pojo.vo.DriverEnsureProfileVO; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +/** + * 司机档案 Feign 接口 + * + * @author Chill + */ +@FeignClient(value = "blade-transport") +public interface IDriverClient { + + String API_PREFIX = "/feign/client/driver"; + String ENSURE_PROFILE = API_PREFIX + "/ensure-profile"; + + /** + * 查找或创建司机档案,并把档案绑定到指定小程序账号 + *

+ * 幂等入口:按 user_id 命中时原样返回;冲突(同号多档案 / 已被其他账号占用) + * 不新建、不覆盖,通过返回值的 message 说明原因,不抛异常给登录方。 + * + * @param param 租户、小程序账号与手机号 + * @return 档案ID与本次处置标志 + */ + @PostMapping(ENSURE_PROFILE) + R ensureProfile(@RequestBody DriverEnsureProfileDTO param); + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/DriverEnsureProfileDTO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/DriverEnsureProfileDTO.java new file mode 100644 index 00000000..3844e1b9 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/DriverEnsureProfileDTO.java @@ -0,0 +1,55 @@ +/** + * 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. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 司机档案「查找或创建并绑定账号」参数 + * + * @author Chill + */ +@Data +@Schema(description = "司机档案查找或创建参数") +public class DriverEnsureProfileDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "租户ID,为空时取当前登录租户;仍为空则拒绝写库") + private String tenantId; + + @Schema(description = "关联小程序用户ID(blade_mp_user.id)") + private Long mpUserId; + + @Schema(description = "手机号,仅在按账号未命中时用于查找已有档案") + private String phone; + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Driver.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Driver.java index fc7c3ce8..9c2007c1 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Driver.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Driver.java @@ -40,6 +40,7 @@ import org.springblade.transport.jackson.FlexibleLocalDateDeserializer; import java.io.Serial; import java.time.LocalDate; +import java.time.LocalDateTime; /** * 司机管理实体类 @@ -223,6 +224,17 @@ public class Driver extends TenantEntity { @Schema(description = "关联小程序用户ID") @JsonSerialize(using = ToStringSerializer.class) private Long userId; + /** + * 档案来源:login_auto(登录自动建)/ login_bound(绑到已有档案)/ + * admin(后台建)/ cert_backfill(存量订正);NULL 表示来源未知 + */ + @Schema(description = "档案来源") + private String profileSource; + /** + * 档案与账号建立关联的时点 + */ + @Schema(description = "账号绑定时间") + private LocalDateTime accountBoundTime; /** * 与联系人关系 */ diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverEnsureProfileVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverEnsureProfileVO.java new file mode 100644 index 00000000..8859abad --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverEnsureProfileVO.java @@ -0,0 +1,58 @@ +/** + * 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. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * 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; + +/** + * 司机档案「查找或创建并绑定账号」结果 + * + * @author Chill + */ +@Data +@Schema(description = "司机档案查找或创建结果") +public class DriverEnsureProfileVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "命中的司机档案ID,冲突时为null") + private Long driverId; + + @Schema(description = "本次新建了档案") + private Boolean created; + + @Schema(description = "本次把已有档案绑定到了账号") + private Boolean boundExisting; + + @Schema(description = "冲突原因;成功时为null") + private String message; + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/feign/DriverClient.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/feign/DriverClient.java new file mode 100644 index 00000000..c5ab2d70 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/feign/DriverClient.java @@ -0,0 +1,61 @@ +/** + * 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. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.feign; + +import io.swagger.v3.oas.annotations.Hidden; +import lombok.AllArgsConstructor; +import org.springblade.core.tenant.annotation.NonDS; +import org.springblade.core.tool.api.R; +import org.springblade.transport.pojo.dto.DriverEnsureProfileDTO; +import org.springblade.transport.pojo.vo.DriverEnsureProfileVO; +import org.springblade.transport.service.IDriverService; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +/** + * 司机档案 Feign 实现类 + * + * @author Chill + */ +@NonDS +@Hidden +@RestController +@AllArgsConstructor +public class DriverClient implements IDriverClient { + + private final IDriverService driverService; + + @Override + @PostMapping(ENSURE_PROFILE) + public R ensureProfile(@RequestBody DriverEnsureProfileDTO param) { + if (param == null) { + return R.fail("司机档案参数不能为空"); + } + return R.data(driverService.ensureProfile(param.getTenantId(), param.getMpUserId(), param.getPhone())); + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverService.java index 45200175..8864ed8f 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverService.java @@ -30,6 +30,7 @@ import com.baomidou.mybatisplus.core.metadata.IPage; import org.springblade.core.mp.base.BaseService; import org.springblade.transport.excel.DriverExcel; import org.springblade.transport.pojo.entity.Driver; +import org.springblade.transport.pojo.vo.DriverEnsureProfileVO; import org.springblade.transport.pojo.vo.DriverExpiryStatVO; import org.springblade.transport.pojo.vo.DriverVO; @@ -59,6 +60,16 @@ public interface IDriverService extends BaseService { */ boolean submit(Driver driver); + /** + * 查找或创建司机档案并绑定小程序账号(登录路径专用) + * + * @param tenantId 租户ID,必须非空 + * @param mpUserId 小程序用户ID + * @param phone 手机号 + * @return 档案ID与本次处置标志;冲突时 message 携带可读原因 + */ + DriverEnsureProfileVO ensureProfile(String tenantId, Long mpUserId, String phone); + /** * 修改状态 * diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java index 247239d0..cb634ce5 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java @@ -349,8 +349,9 @@ public class CertAuditServiceImpl implements ICertAuditService { if (Func.isEmpty(snapshot.getIdCardNo()) && Func.isEmpty(snapshot.getDriverFileNo())) { return; } - // 身份证号要落到档案上,先确认它没有落在别人名下:该列没有唯一索引, - // 直接写会静默产生两条同证件号的档案,管理端的证件号查重从此失效 + // 身份证号要落到档案上,先确认它没有落在别人名下:该列在基线建表脚本与本地库上 + // 有唯一索引 uk_transport_driver_id_card,但迁移历史里没有任何一次添加它的记录, + // 生产库是否已建未核实;应用层查重不能省,否则重复证件号会静默落库 requireIdCardNotTaken(snapshot.getIdCardNo(), driver.getId()); driver.setDriverName(snapshot.getRealName()); driver.setIdCardNo(snapshot.getIdCardNo()); @@ -382,9 +383,10 @@ public class CertAuditServiceImpl implements ICertAuditService { /** * 身份证号不得落在另一名司机名下 *

- * 司机申报的实名内容会写回档案,其中身份证号是管理端建档时的查重判据, - * 而 {@code blade_transport_driver.id_card_no} 上没有唯一索引:若申报的证件号 - * 已属于另一条档案,直接写会静默产生两条同证件号的档案,后续建档查重、 + * 司机申报的实名内容会写回档案,其中身份证号是管理端建档时的查重判据。 + * 该列在基线建表脚本与本地库上有唯一索引 {@code uk_transport_driver_id_card}, + * 但迁移历史里没有任何一次添加它的记录,生产库是否已建未核实; + * 应用层查重仍不能省:重复证件号一旦落库,后续建档查重、 * 按证件号识别同一人都将失效。此处阻断审核并整体回滚,交由管理端先处理档案归属。 *

* 证件号与当前档案相同(绝大多数情况)、或库里尚无该号时不阻断; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java index 825c3bc0..1a7cb6a0 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java @@ -29,6 +29,7 @@ import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.mp.base.BaseServiceImpl; import org.springblade.core.secure.utils.AuthUtil; @@ -45,6 +46,7 @@ import org.springblade.transport.excel.DriverExcel; import org.springblade.transport.mapper.DriverMapper; import org.springblade.transport.pojo.entity.Driver; import org.springblade.transport.pojo.entity.TransportVehicle; +import org.springblade.transport.pojo.vo.DriverEnsureProfileVO; import org.springblade.transport.pojo.vo.DriverExpiryStatVO; import org.springblade.transport.pojo.vo.DriverVO; import org.springblade.transport.service.IDriverService; @@ -53,6 +55,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import java.util.Objects; @@ -61,6 +64,7 @@ import java.util.Objects; * * @author Chill */ +@Slf4j @Service @RequiredArgsConstructor public class DriverServiceImpl extends BaseServiceImpl implements IDriverService { @@ -74,6 +78,16 @@ public class DriverServiceImpl extends BaseServiceImpl imp private static final int REMARK_MAX_LENGTH = 200; private static final int DEFAULT_ENABLED_STATUS = 1; private static final int DEFAULT_FALSE = 0; + /** + * 档案来源:登录时自动新建 + */ + private static final String PROFILE_SOURCE_LOGIN_AUTO = "login_auto"; + /** + * 档案来源:绑定到已有档案 + */ + private static final String PROFILE_SOURCE_LOGIN_BOUND = "login_bound"; + private static final String CONFLICT_MOBILE_OCCUPIED = "该手机号已被其他账号占用,请联系管理员"; + private static final String CONFLICT_MOBILE_MULTIPLE = "该手机号存在多条司机档案,请联系管理员核对"; private final IMpUserClient mpUserClient; private final ISysClient sysClient; @@ -101,6 +115,132 @@ public class DriverServiceImpl extends BaseServiceImpl imp return true; } + @Override + @Transactional(rollbackFor = Exception.class) + public DriverEnsureProfileVO ensureProfile(String tenantId, Long mpUserId, String phone) { + String resolvedTenantId = trimToNull(tenantId); + if (resolvedTenantId == null) { + resolvedTenantId = trimToNull(AuthUtil.getTenantId()); + } + // 租户是档案归属与手机号查重的范围,宁可让本次建档失败(登录层只记 WARN), + // 也不能写出 tenant_id 为空的档案——那会让下一条同号档案无法被查到 + if (resolvedTenantId == null) { + throw new ServiceException("租户信息为空,无法创建司机档案"); + } + if (Func.isEmpty(mpUserId)) { + throw new ServiceException("小程序用户ID不能为空"); + } + String mobile = trimToNull(phone); + if (mobile == null) { + throw new ServiceException("手机号不能为空"); + } + // 查找顺序固定为 user_id → phone:user_id 是稳定标识; + // phone 会被运营商二次放号,拿它优先会把别人档案绑给新持卡人 + Driver bound = findDriverByUserId(mpUserId); + if (bound != null) { + return ensureResult(bound.getId(), false, false, null); + } + List sameMobile = list(Wrappers.lambdaQuery() + .eq(Driver::getTenantId, resolvedTenantId) + .eq(Driver::getMobile, mobile)); + if (sameMobile.isEmpty()) { + return createPlaceholderProfile(resolvedTenantId, mpUserId, mobile); + } + if (sameMobile.size() > 1) { + return ensureResult(null, false, false, CONFLICT_MOBILE_MULTIPLE); + } + Driver existing = sameMobile.get(0); + if (Func.isNotEmpty(existing.getUserId())) { + if (Objects.equals(existing.getUserId(), mpUserId)) { + return ensureResult(existing.getId(), false, false, null); + } + return ensureResult(null, false, false, CONFLICT_MOBILE_OCCUPIED); + } + return bindExistingProfile(existing.getId(), mpUserId); + } + + /** + * 按小程序账号反查档案;user_id 有全局唯一索引,故不叠加租户条件 + */ + private Driver findDriverByUserId(Long mpUserId) { + return getOne(Wrappers.lambdaQuery() + .eq(Driver::getUserId, mpUserId) + .last("LIMIT 1")); + } + + /** + * 新建占位档案(票 01 定值) + *

+ * 不走 {@link #submit}:那条路径要求 15 项必填,自动建档必然被拦。这里只满足表约束—— + * id_card_no 留 NULL(唯一索引对 NULL 不去重,且 NULL 是「未采集」的判据), + * 其余 NOT NULL 列写空串或可读占位。 + */ + private DriverEnsureProfileVO createPlaceholderProfile(String tenantId, Long mpUserId, String mobile) { + Driver driver = new Driver(); + driver.setTenantId(tenantId); + driver.setUserId(mpUserId); + driver.setDriverName("司机" + mobileTail(mobile)); + driver.setMobile(mobile); + driver.setDrivingType(""); + driver.setDriverType(""); + driver.setOrganizationName(""); + driver.setEmergencyContactName(""); + driver.setEmergencyContactMobile(""); + driver.setStatus(DEFAULT_ENABLED_STATUS); + driver.setProfileSource(PROFILE_SOURCE_LOGIN_AUTO); + driver.setAccountBoundTime(LocalDateTime.now()); + try { + save(driver); + } catch (Exception exception) { + // 并发登录撞 uk_transport_driver_user:重查一次,说明另一个请求已经建好档案 + Driver concurrent = findDriverByUserId(mpUserId); + if (concurrent != null) { + return ensureResult(concurrent.getId(), false, false, null); + } + log.error("自动创建司机档案失败,租户={} 账号={}", tenantId, mpUserId, exception); + throw new ServiceException("创建司机档案失败"); + } + return ensureResult(driver.getId(), true, false, null); + } + + /** + * 把已有档案绑定到账号:只 patch 关联字段与留痕,不覆盖档案内容 + */ + private DriverEnsureProfileVO bindExistingProfile(Long driverId, Long mpUserId) { + Driver patch = new Driver(); + patch.setId(driverId); + patch.setUserId(mpUserId); + patch.setProfileSource(PROFILE_SOURCE_LOGIN_BOUND); + patch.setAccountBoundTime(LocalDateTime.now()); + // 只改 user_id 也必须带 driving_vehicle 原值,理由见 carryDrivingVehicle + carryDrivingVehicle(driverId, patch); + try { + updateById(patch); + } catch (Exception exception) { + // 并发绑定撞 uk_transport_driver_user:另一个请求已经把这个账号绑到档案上 + Driver concurrent = findDriverByUserId(mpUserId); + if (concurrent != null) { + return ensureResult(concurrent.getId(), false, false, null); + } + log.error("绑定司机档案失败,档案={} 账号={}", driverId, mpUserId, exception); + throw new ServiceException("绑定司机档案失败"); + } + return ensureResult(driverId, false, true, null); + } + + private DriverEnsureProfileVO ensureResult(Long driverId, boolean created, boolean boundExisting, String message) { + DriverEnsureProfileVO result = new DriverEnsureProfileVO(); + result.setDriverId(driverId); + result.setCreated(created); + result.setBoundExisting(boundExisting); + result.setMessage(message); + return result; + } + + private String mobileTail(String mobile) { + return mobile.length() <= 4 ? mobile : mobile.substring(mobile.length() - 4); + } + @Override @Transactional(rollbackFor = Exception.class) public boolean changeStatus(Long id, Integer status) { From 9f849f55be2dfc3724933fa2342be20daa8c9b02 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Fri, 23 Oct 2026 15:48:57 +0800 Subject: [PATCH 45/55] =?UTF-8?q?:white=5Fcheck=5Fmark:=20=E8=A1=A5?= =?UTF-8?q?=E5=85=85=E5=8F=B8=E6=9C=BA=E6=A1=A3=E6=A1=88=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=BB=BA=E6=A1=A3=E4=B8=8E=E7=BB=91=E5=AE=9A=E5=9B=9E=E5=BD=92?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/impl/DriverEnsureProfileTest.java | 255 ++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverEnsureProfileTest.java diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverEnsureProfileTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverEnsureProfileTest.java new file mode 100644 index 00000000..874fde21 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverEnsureProfileTest.java @@ -0,0 +1,255 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +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.system.feign.ISysClient; +import org.springblade.transport.mapper.DriverMapper; +import org.springblade.transport.pojo.entity.Driver; +import org.springblade.transport.pojo.vo.DriverEnsureProfileVO; +import org.springframework.dao.DuplicateKeyException; + +import java.lang.reflect.Field; +import java.util.Collections; +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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +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; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 登录自动建档与绑定已有档案的测试。 + *

+ * 守住三条不变式:按 user_id 幂等重入不重复建档、同号冲突一律不绑定也不新建、 + * 绑定那次 patch 必须带上 driving_vehicle 原值(该列 {@code FieldStrategy.ALWAYS} + * 会把缺失的列写成 NULL,司机端的车辆绑定会整块消失)。 + */ +class DriverEnsureProfileTest { + + private static final Long MP_USER_ID = 7001L; + private static final Long OTHER_USER_ID = 7002L; + private static final Long DRIVER_ID = 9001L; + private static final Long NEW_DRIVER_ID = 9002L; + private static final String TENANT_ID = "000000"; + private static final String MOBILE = "15975348524"; + private static final String PLATE = "桂A32523"; + + private DriverMapper driverMapper; + private DriverServiceImpl service; + + @BeforeAll + static void initTableInfo() { + // save 走 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Driver.class); + } + + @BeforeEach + void setUp() throws Exception { + driverMapper = mock(DriverMapper.class); + IMpUserClient mpUserClient = mock(IMpUserClient.class); + ISysClient sysClient = mock(ISysClient.class); + service = new DriverServiceImpl(mpUserClient, sysClient); + inject("baseMapper", driverMapper); + } + + // ------------------------------------------------------------ 新建占位档案 + + @Test + @DisplayName("无账号档案、无同号档案:按票 01 的占位值新建,created=true") + void createsPlaceholderProfile() { + // 第一次按 user_id 查、第二次按手机号查,都没有 + when(driverMapper.selectList(any())).thenReturn(Collections.emptyList(), Collections.emptyList()); + when(driverMapper.insert(any(Driver.class))).thenAnswer(invocation -> { + Driver inserted = invocation.getArgument(0); + inserted.setId(NEW_DRIVER_ID); + return 1; + }); + + DriverEnsureProfileVO result = service.ensureProfile(TENANT_ID, MP_USER_ID, MOBILE); + + assertTrue(result.getCreated()); + assertFalse(result.getBoundExisting()); + assertNull(result.getMessage()); + assertEquals(NEW_DRIVER_ID, result.getDriverId()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Driver.class); + verify(driverMapper).insert(captor.capture()); + Driver saved = captor.getValue(); + assertEquals(TENANT_ID, saved.getTenantId(), "tenant_id 必须显式写入且非空"); + assertEquals(MP_USER_ID, saved.getUserId()); + assertEquals(MOBILE, saved.getMobile()); + assertEquals("司机8524", saved.getDriverName()); + assertNull(saved.getIdCardNo(), "身份证号留 NULL 才是「未采集」,也是补全判据"); + assertEquals("", saved.getDrivingType()); + assertEquals("", saved.getDriverType()); + assertEquals("", saved.getOrganizationName()); + assertEquals("", saved.getEmergencyContactName()); + assertEquals("", saved.getEmergencyContactMobile()); + assertEquals(1, saved.getStatus()); + assertEquals("login_auto", saved.getProfileSource()); + assertNotNull(saved.getAccountBoundTime()); + verify(driverMapper, never()).updateById(any(Driver.class)); + } + + // ------------------------------------------------------------ 绑定已有档案 + + @Test + @DisplayName("命中同号未绑定的档案:绑定并留痕,patch 必须带上 driving_vehicle 原值") + void bindsExistingProfileAndKeepsDrivingVehicle() { + Driver existing = driver(DRIVER_ID, null, PLATE); + when(driverMapper.selectList(any())).thenReturn(Collections.emptyList(), List.of(existing)); + when(driverMapper.selectById(DRIVER_ID)).thenReturn(existing); + when(driverMapper.updateById(any(Driver.class))).thenReturn(1); + + DriverEnsureProfileVO result = service.ensureProfile(TENANT_ID, MP_USER_ID, MOBILE); + + assertFalse(result.getCreated()); + assertTrue(result.getBoundExisting()); + assertNull(result.getMessage()); + assertEquals(DRIVER_ID, result.getDriverId()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Driver.class); + verify(driverMapper).updateById(captor.capture()); + Driver patch = captor.getValue(); + assertEquals(MP_USER_ID, patch.getUserId()); + assertEquals("login_bound", patch.getProfileSource()); + assertNotNull(patch.getAccountBoundTime()); + assertEquals(PLATE, patch.getDrivingVehicle(), + "绑定 patch 不带 driving_vehicle 会把司机的车辆绑定写掉"); + assertNull(patch.getMobile(), "绑定不得覆盖档案上的手机号"); + verify(driverMapper, never()).insert(any(Driver.class)); + } + + // ------------------------------------------------------------ 幂等重入 + + @Test + @DisplayName("按 user_id 命中:直接返回该档案,不新建、不改手机号") + void returnsExistingProfileForRepeatLogin() { + when(driverMapper.selectList(any())).thenReturn(List.of(driver(DRIVER_ID, MP_USER_ID, PLATE))); + + DriverEnsureProfileVO result = service.ensureProfile(TENANT_ID, MP_USER_ID, MOBILE); + + assertFalse(result.getCreated()); + assertFalse(result.getBoundExisting()); + assertNull(result.getMessage()); + assertEquals(DRIVER_ID, result.getDriverId()); + verify(driverMapper, never()).insert(any(Driver.class)); + verify(driverMapper, never()).updateById(any(Driver.class)); + } + + @Test + @DisplayName("并发重复建档撞唯一键:重查后按已存在的档案返回,不把异常抛给登录方") + void toleratesConcurrentInsert() { + Driver concurrent = driver(DRIVER_ID, MP_USER_ID, PLATE); + when(driverMapper.selectList(any())).thenReturn( + Collections.emptyList(), + Collections.emptyList(), + List.of(concurrent)); + when(driverMapper.insert(any(Driver.class))).thenThrow(new DuplicateKeyException("uk_transport_driver_user")); + + DriverEnsureProfileVO result = service.ensureProfile(TENANT_ID, MP_USER_ID, MOBILE); + + assertFalse(result.getCreated()); + assertFalse(result.getBoundExisting()); + assertEquals(DRIVER_ID, result.getDriverId()); + } + + // ------------------------------------------------------------ 冲突 + + @Test + @DisplayName("同手机号多条档案:拒绝绑定也不新建,返回可读原因") + void rejectsMultipleProfilesWithSameMobile() { + when(driverMapper.selectList(any())).thenReturn( + Collections.emptyList(), + List.of(driver(DRIVER_ID, null, PLATE), driver(9003L, null, ""))); + + DriverEnsureProfileVO result = service.ensureProfile(TENANT_ID, MP_USER_ID, MOBILE); + + assertFalse(result.getCreated()); + assertFalse(result.getBoundExisting()); + assertNull(result.getDriverId()); + assertEquals("该手机号存在多条司机档案,请联系管理员核对", result.getMessage()); + verify(driverMapper, never()).insert(any(Driver.class)); + verify(driverMapper, never()).updateById(any(Driver.class)); + } + + @Test + @DisplayName("同手机号档案已属于其他账号:不覆盖,返回可读原因") + void rejectsMobileOwnedByAnotherAccount() { + when(driverMapper.selectList(any())).thenReturn( + Collections.emptyList(), + List.of(driver(DRIVER_ID, OTHER_USER_ID, PLATE))); + + DriverEnsureProfileVO result = service.ensureProfile(TENANT_ID, MP_USER_ID, MOBILE); + + assertFalse(result.getCreated()); + assertFalse(result.getBoundExisting()); + assertNull(result.getDriverId()); + assertEquals("该手机号已被其他账号占用,请联系管理员", result.getMessage()); + verify(driverMapper, never()).insert(any(Driver.class)); + verify(driverMapper, never()).updateById(any(Driver.class)); + } + + // ------------------------------------------------------------ 入参兜底 + + @Test + @DisplayName("租户为空且当前上下文也没有租户:拒绝写库") + void rejectsBlankTenant() { + try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { + authUtil.when(AuthUtil::getTenantId).thenReturn(null); + + assertThrows(ServiceException.class, () -> service.ensureProfile(null, MP_USER_ID, MOBILE)); + } + verify(driverMapper, never()).insert(any(Driver.class)); + } + + // ------------------------------------------------------------ 夹具 + + private Driver driver(Long id, Long userId, String drivingVehicle) { + Driver driver = new Driver(); + driver.setId(id); + driver.setIsDeleted(0); + driver.setTenantId(TENANT_ID); + driver.setUserId(userId); + driver.setMobile(MOBILE); + driver.setDrivingVehicle(drivingVehicle); + return driver; + } + + private void inject(String field, Object value) throws Exception { + Class type = service.getClass(); + while (type != null) { + try { + Field declared = type.getDeclaredField(field); + declared.setAccessible(true); + declared.set(service, value); + return; + } catch (NoSuchFieldException ignored) { + type = type.getSuperclass(); + } + } + throw new IllegalStateException("未找到字段:" + field); + } + +} From a8e059e92eb1e9759de9bbd2a68cef21c9dbb14d Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Fri, 23 Oct 2026 15:58:42 +0800 Subject: [PATCH 46/55] =?UTF-8?q?:sparkles:=20=E6=96=B0=E5=A2=9E=E5=B0=8F?= =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E7=99=BB=E5=BD=95=E7=BC=96=E6=8E=92MpLoginSu?= =?UTF-8?q?pport=E4=B8=8E=E7=99=BB=E5=BD=95=E5=A4=B1=E8=B4=A5=E8=AF=AD?= =?UTF-8?q?=E4=B9=89=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- blade-auth/pom.xml | 11 + .../auth/service/MpLoginSupport.java | 338 ++++++++++++++++++ .../auth/service/MpLoginSupportTest.java | 246 +++++++++++++ 3 files changed, 595 insertions(+) create mode 100644 blade-auth/src/main/java/org/springblade/auth/service/MpLoginSupport.java create mode 100644 blade-auth/src/test/java/org/springblade/auth/service/MpLoginSupportTest.java diff --git a/blade-auth/pom.xml b/blade-auth/pom.xml index 8bfe4ea1..a7bad3cf 100644 --- a/blade-auth/pom.xml +++ b/blade-auth/pom.xml @@ -83,6 +83,17 @@ org.springblade blade-resource-api + + + org.springblade + blade-transport-api + + + + org.springframework.boot + spring-boot-starter-test + test + org.springblade diff --git a/blade-auth/src/main/java/org/springblade/auth/service/MpLoginSupport.java b/blade-auth/src/main/java/org/springblade/auth/service/MpLoginSupport.java new file mode 100644 index 00000000..6032ebeb --- /dev/null +++ b/blade-auth/src/main/java/org/springblade/auth/service/MpLoginSupport.java @@ -0,0 +1,338 @@ +/** + * 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. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.auth.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springblade.auth.utils.TokenUtil; +import org.springblade.core.oauth2.exception.UserInvalidException; +import org.springblade.core.oauth2.service.OAuth2User; +import org.springblade.core.tool.api.R; +import org.springblade.core.tool.support.Kv; +import org.springblade.core.tool.utils.Func; +import org.springblade.core.tool.utils.StringUtil; +import org.springblade.system.feign.IMpUserClient; +import org.springblade.system.pojo.dto.MpUserEnsureDTO; +import org.springblade.system.pojo.entity.MpUser; +import org.springblade.system.pojo.entity.MpUserInfo; +import org.springblade.system.pojo.enums.MpUserIdentity; +import org.springblade.transport.feign.IDriverClient; +import org.springblade.transport.pojo.dto.DriverEnsureProfileDTO; +import org.springblade.transport.pojo.vo.DriverEnsureProfileVO; +import org.springframework.stereotype.Component; + +import java.util.Objects; + +/** + * 小程序登录编排:按 openid / 手机号找到或创建账号,并尽力补齐司机档案与微信绑定。 + *

+ * 只有「建号失败」会让登录失败;建档与绑 openid 都是尽力而为,失败只记 WARN, + * 下次登录自愈。复制这段判断到别处前先确认调用方不是登录路径。 + * + * @author Chill + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class MpLoginSupport { + + /** + * detail 中承载登录提示的键,前端从令牌的 detail 里读取 + */ + public static final String DETAIL_LOGIN_NOTICE = "loginNotice"; + + /** + * 换号撞键:openid 属于旧账号、新手机号已属于另一账号(票 05 Q2-A) + */ + private static final String NOTICE_PHONE_OCCUPIED = "该微信已绑定其他账号,请联系管理员"; + + private final IMpUserClient mpUserClient; + private final IDriverClient driverClient; + + /** + * 找到或创建本次登录使用的账号,并尽力补齐档案与 openid 绑定。 + *

+ * 返回 {@link OAuth2User} 而不是 {@link MpUserInfo}:两条登录链路都需要 OAuth2User, + * 且 MpUserInfo 的唯一组装点(identities / permissions / detail)在 system 侧, + * 由本类统一转换可避免 auth 侧复制一份 detail 口径。 + * + * @param tenantId 请求租户,为空时由 system 侧兜到默认租户 + * @param phone 本次登录的手机号 + * @param openid 微信小程序 openid,短信登录传 null + * @return 通过用户校验之前的登录用户(身份校验与 setClient 由调用方完成) + */ + public OAuth2User ensureLoginAccount(String tenantId, String phone, String openid) { + if (StringUtil.isBlank(phone)) { + throw new UserInvalidException("未获取到手机号,无法登录"); + } + MpUser account; + String loginNotice = null; + // openid 已属于另一个账号:本次不迁移、也不再尝试绑定(与建档冲突提示区分开) + boolean openidOccupied = false; + // ① openid 命中即复用:openid 是稳定标识,手机号会被运营商二次放号 + MpUser openidAccount = findByOpenid(tenantId, openid); + if (openidAccount == null) { + // ② 未按 openid 命中:按手机号查找或创建(登录路径专用,已存在不抛异常) + account = ensureUser(tenantId, phone, openid); + } else { + account = openidAccount; + if (!samePhone(openidAccount.getPhone(), phone)) { + MpUser phoneAccount = findByPhone(tenantId, phone); + if (phoneAccount != null && !Objects.equals(phoneAccount.getId(), openidAccount.getId())) { + // ①-撞键:落到手机号账号,不迁移 openid。以 openid 账号为准会把用户 + // 正在用的手机号账号顶掉,自动合并又要动档案归属(票 05) + account = phoneAccount; + loginNotice = NOTICE_PHONE_OCCUPIED; + openidOccupied = true; + log.warn("微信换号撞键,登录落到手机号账号:租户={} openid账号={} 手机号账号={}", + tenantId, openidAccount.getId(), phoneAccount.getId()); + } else { + account = movePhone(openidAccount, phone); + if (account == null) { + // 更新手机号失败:并发时可能是手机号刚被别的账号占用,重查一次再决定落点 + MpUser concurrent = findByPhone(tenantId, phone); + if (concurrent != null && !Objects.equals(concurrent.getId(), openidAccount.getId())) { + account = concurrent; + loginNotice = NOTICE_PHONE_OCCUPIED; + openidOccupied = true; + log.warn("更新手机号失败后命中手机号账号:租户={} openid账号={} 手机号账号={}", + tenantId, openidAccount.getId(), concurrent.getId()); + } else { + // 仍然用 openid 账号登录:手机号未能更新不影响身份识别,下次登录再试 + account = openidAccount; + } + } + } + } + } + // ③ 身份含 driver 才建档;纯 admin 不建,避免污染司机列表与派单池(票 03) + String profileNotice = ensureProfile(account, phone, tenantId); + if (StringUtil.isBlank(loginNotice)) { + loginNotice = profileNotice; + } + // ④ 微信登录补绑 openid;失败只 WARN(票 05) + bindOpenid(account, openid, openidOccupied); + MpUserInfo userInfo = loadUserInfo(account); + attachNotice(userInfo, loginNotice); + return TokenUtil.convertMpUser(userInfo); + } + + private MpUser findByOpenid(String tenantId, String openid) { + if (StringUtil.isBlank(openid)) { + return null; + } + try { + R result = mpUserClient.userByOpenid(tenantId, openid); + return isSuccess(result) ? result.getData() : null; + } catch (Exception exception) { + // 查询失败按未命中处理:随后 ensureUser 会再次触达系统服务, + // 真不可用时在那里以「建号失败」拦下,避免这里静默建出重复账号 + log.warn("按 openid 查询小程序账号失败:租户={} openid={}", tenantId, openid, exception); + return null; + } + } + + private MpUser findByPhone(String tenantId, String phone) { + try { + R result = mpUserClient.userByPhone(tenantId, phone); + return isSuccess(result) ? result.getData() : null; + } catch (Exception exception) { + log.warn("按手机号查询小程序账号失败:租户={} 手机号={}", tenantId, phone, exception); + return null; + } + } + + /** + * ② 查找或创建账号:这是登录路径上唯一允许让登录失败的动作 + */ + private MpUser ensureUser(String tenantId, String phone, String openid) { + MpUserEnsureDTO param = new MpUserEnsureDTO(); + param.setTenantId(tenantId); + param.setPhone(phone); + param.setOpenid(openid); + try { + R result = mpUserClient.ensureUser(param); + if (isSuccess(result) && result.getData() != null && Func.isNotEmpty(result.getData().getId())) { + return result.getData(); + } + // R.data(null) 也是 success,此时 msg 是「暂无数据」之类的占位文案,不能当业务错误下发 + String message = result != null && !result.isSuccess() ? result.getMsg() : null; + log.error("小程序账号查找或创建失败:租户={} 手机号={} 返回={}", tenantId, phone, message); + throw new UserInvalidException(StringUtil.isBlank(message) ? "账号创建失败,请稍后重试" : message); + } catch (UserInvalidException exception) { + throw exception; + } catch (Exception exception) { + log.error("小程序账号查找或创建失败:租户={} 手机号={}", tenantId, phone, exception); + throw new UserInvalidException("账号创建失败,请稍后重试"); + } + } + + /** + * 把 openid 账号的手机号换成新号(票 03:openid 命中即复用并更新手机号) + *

+ * 只 patch phone:account 是登录名,不随手机号变;status 必须带原值, + * 否则 system 侧 updateUser 的 prepare 会把空 status 重置成启用,等于解开停用账号。 + * + * @return 更新成功后的账号;失败返回 null,不抛异常 + */ + private MpUser movePhone(MpUser account, String phone) { + MpUser patch = new MpUser(); + patch.setId(account.getId()); + patch.setTenantId(account.getTenantId()); + patch.setPhone(phone); + patch.setAccount(account.getAccount()); + patch.setIdentities(account.getIdentities()); + patch.setStatus(account.getStatus()); + try { + R result = mpUserClient.updateUser(patch); + if (isSuccess(result) && Boolean.TRUE.equals(result.getData())) { + account.setPhone(phone); + return account; + } + log.warn("更新小程序账号手机号失败:租户={} 账号={} 新手机号={} 返回={}", + account.getTenantId(), account.getId(), phone, result == null ? null : result.getMsg()); + } catch (Exception exception) { + log.warn("更新小程序账号手机号失败:租户={} 账号={} 新手机号={}", + account.getTenantId(), account.getId(), phone, exception); + } + return null; + } + + /** + * ③ 为司机身份账号补齐档案;失败或冲突只记 WARN,不阻断登录 + * + * @return 冲突时的可读原因,供 detail 提示使用;成功或无需建档返回 null + */ + private String ensureProfile(MpUser account, String requestedPhone, String requestedTenantId) { + if (account == null || !MpUserIdentity.has(account.getIdentities(), MpUserIdentity.DRIVER)) { + return null; + } + // 租户与手机号优先取账号上的值:档案必须与账号落同一租户(唯一键对 NULL 不去重), + // 手机号更新失败时也不能拿新号去建,否则档案和账号对不上 + String tenantId = Func.toStr(account.getTenantId(), requestedTenantId); + String phone = Func.toStr(account.getPhone(), requestedPhone); + DriverEnsureProfileDTO param = new DriverEnsureProfileDTO(); + param.setTenantId(tenantId); + param.setMpUserId(account.getId()); + param.setPhone(phone); + try { + R result = driverClient.ensureProfile(param); + if (!isSuccess(result) || result.getData() == null) { + log.warn("登录自动建档未完成(不阻断登录):租户={} 账号={} 手机号={} 返回={}", + tenantId, account.getId(), phone, result == null ? null : result.getMsg()); + return null; + } + DriverEnsureProfileVO profile = result.getData(); + if (StringUtil.isNotBlank(profile.getMessage())) { + log.warn("登录自动建档冲突(不阻断登录):租户={} 账号={} 手机号={} 原因={}", + tenantId, account.getId(), phone, profile.getMessage()); + return profile.getMessage(); + } + return null; + } catch (Exception exception) { + log.warn("登录自动建档失败(不阻断登录):租户={} 账号={} 手机号={}", + tenantId, account.getId(), phone, exception); + return null; + } + } + + /** + * ④ 微信登录补绑 openid;失败只 WARN,不阻断登录 + */ + private void bindOpenid(MpUser account, String openid, boolean openidOccupied) { + if (account == null || StringUtil.isBlank(openid)) { + return; + } + // 撞键场景 openid 已属于另一个账号,明确不迁移,也不再尝试绑定 + if (openidOccupied) { + return; + } + if (StringUtil.isNotBlank(account.getOpenid())) { + if (!openid.equals(account.getOpenid())) { + log.warn("小程序账号已有其他 openid,本次不覆盖:租户={} 账号={} 已有={} 本次={}", + account.getTenantId(), account.getId(), account.getOpenid(), openid); + } + return; + } + try { + R result = mpUserClient.bindOpenId(account.getTenantId(), account.getId(), openid); + if (!isSuccess(result) || !Boolean.TRUE.equals(result.getData())) { + log.warn("绑定微信 openid 失败(不阻断登录):租户={} 账号={} openid={} 返回={}", + account.getTenantId(), account.getId(), openid, result == null ? null : result.getMsg()); + } + } catch (Exception exception) { + log.warn("绑定微信 openid 失败(不阻断登录):租户={} 账号={} openid={}", + account.getTenantId(), account.getId(), openid, exception); + } + } + + /** + * MpUserInfo 由 system 侧 buildUserInfo 统一组装,auth 不复制一份 detail 口径 + */ + private MpUserInfo loadUserInfo(MpUser account) { + try { + R result = mpUserClient.userInfoById(account.getId()); + if (isSuccess(result) && result.getData() != null && result.getData().getUser() != null) { + return result.getData(); + } + String message = result != null && !result.isSuccess() ? result.getMsg() : null; + log.error("小程序账号登录信息查询失败:租户={} 账号={} 返回={}", account.getTenantId(), account.getId(), message); + throw new UserInvalidException(StringUtil.isBlank(message) ? "账号信息查询失败,请稍后重试" : message); + } catch (UserInvalidException exception) { + throw exception; + } catch (Exception exception) { + log.error("小程序账号登录信息查询失败:租户={} 账号={}", account.getTenantId(), account.getId(), exception); + throw new UserInvalidException("账号信息查询失败,请稍后重试"); + } + } + + private void attachNotice(MpUserInfo userInfo, String loginNotice) { + if (userInfo == null || StringUtil.isBlank(loginNotice)) { + return; + } + // detail 是否会下发到前端取决于令牌序列化,故冲突点同时都记了 WARN + Kv detail = userInfo.getDetail() == null ? Kv.create() : userInfo.getDetail(); + detail.set(DETAIL_LOGIN_NOTICE, loginNotice); + userInfo.setDetail(detail); + } + + private boolean samePhone(String left, String right) { + return Objects.equals(trimToNull(left), trimToNull(right)); + } + + private String trimToNull(String value) { + if (value == null) { + return null; + } + String trimValue = value.trim(); + return trimValue.isEmpty() ? null : trimValue; + } + + private boolean isSuccess(R result) { + return result != null && result.isSuccess(); + } + +} diff --git a/blade-auth/src/test/java/org/springblade/auth/service/MpLoginSupportTest.java b/blade-auth/src/test/java/org/springblade/auth/service/MpLoginSupportTest.java new file mode 100644 index 00000000..98617200 --- /dev/null +++ b/blade-auth/src/test/java/org/springblade/auth/service/MpLoginSupportTest.java @@ -0,0 +1,246 @@ +package org.springblade.auth.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springblade.core.oauth2.exception.UserInvalidException; +import org.springblade.core.oauth2.service.OAuth2User; +import org.springblade.core.tool.api.R; +import org.springblade.core.tool.support.Kv; +import org.springblade.system.feign.IMpUserClient; +import org.springblade.system.pojo.dto.MpUserEnsureDTO; +import org.springblade.system.pojo.entity.MpUser; +import org.springblade.system.pojo.entity.MpUserInfo; +import org.springblade.transport.feign.IDriverClient; +import org.springblade.transport.pojo.dto.DriverEnsureProfileDTO; +import org.springblade.transport.pojo.vo.DriverEnsureProfileVO; + +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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +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.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 登录编排测试。 + *

+ * 守住票 02 / 03 / 05 的三条口径:建号失败才阻断登录;建档失败放行; + * openid 换号撞键时落到手机号账号且不迁移 openid。 + */ +class MpLoginSupportTest { + + private static final String TENANT_ID = "000000"; + private static final String PHONE = "13800000001"; + private static final String NEW_PHONE = "13800000002"; + private static final String OPENID = "wx-openid-1"; + + private IMpUserClient mpUserClient; + private IDriverClient driverClient; + private MpLoginSupport mpLoginSupport; + + @BeforeEach + void setUp() { + mpUserClient = mock(IMpUserClient.class); + driverClient = mock(IDriverClient.class); + mpLoginSupport = new MpLoginSupport(mpUserClient, driverClient); + } + + @Test + @DisplayName("新手机号微信登录:建号 + 建档,登录成功且无提示") + void newPhoneCreatesAccountAndProfile() { + MpUser created = mpUser(11L, PHONE, OPENID, "driver"); + when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(null)); + when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.data(created)); + when(mpUserClient.userInfoById(11L)).thenReturn(R.data(userInfo(created, "driver"))); + when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult())); + + OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID); + + assertNotNull(user); + assertEquals("11", user.getUserId()); + assertNull(user.getDetail().get(MpLoginSupport.DETAIL_LOGIN_NOTICE)); + ArgumentCaptor ensureCaptor = ArgumentCaptor.forClass(MpUserEnsureDTO.class); + verify(mpUserClient).ensureUser(ensureCaptor.capture()); + assertEquals(TENANT_ID, ensureCaptor.getValue().getTenantId()); + assertEquals(PHONE, ensureCaptor.getValue().getPhone()); + assertEquals(OPENID, ensureCaptor.getValue().getOpenid()); + ArgumentCaptor profileCaptor = ArgumentCaptor.forClass(DriverEnsureProfileDTO.class); + verify(driverClient).ensureProfile(profileCaptor.capture()); + assertEquals(TENANT_ID, profileCaptor.getValue().getTenantId()); + assertEquals(11L, profileCaptor.getValue().getMpUserId()); + assertEquals(PHONE, profileCaptor.getValue().getPhone()); + } + + @Test + @DisplayName("建档抛异常:登录仍然成功") + void profileFailureDoesNotBlockLogin() { + MpUser created = mpUser(12L, PHONE, OPENID, "driver"); + when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(null)); + when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.data(created)); + when(mpUserClient.userInfoById(12L)).thenReturn(R.data(userInfo(created, "driver"))); + when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))) + .thenThrow(new RuntimeException("transport 不可用")); + + OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID); + + assertNotNull(user); + assertEquals("12", user.getUserId()); + verify(driverClient).ensureProfile(any(DriverEnsureProfileDTO.class)); + } + + @Test + @DisplayName("openid 绑定失败:登录仍然成功,不抛异常") + void bindOpenidFailureDoesNotBlockLogin() { + MpUser existing = mpUser(13L, PHONE, null, "driver"); + when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(null)); + when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.data(existing)); + when(mpUserClient.userInfoById(13L)).thenReturn(R.data(userInfo(existing, "driver"))); + when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult())); + when(mpUserClient.bindOpenId(TENANT_ID, 13L, OPENID)).thenReturn(R.fail("该微信已绑定其他小程序用户")); + + OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID); + + assertNotNull(user); + assertEquals("13", user.getUserId()); + verify(mpUserClient).bindOpenId(TENANT_ID, 13L, OPENID); + } + + @Test + @DisplayName("打开号码来源:微信登录时给空 openid 账号补绑 openid") + void bindsOpenidForPhoneAccount() { + MpUser existing = mpUser(14L, PHONE, null, "driver"); + when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(null)); + when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.data(existing)); + when(mpUserClient.userInfoById(14L)).thenReturn(R.data(userInfo(existing, "driver"))); + when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult())); + when(mpUserClient.bindOpenId(TENANT_ID, 14L, OPENID)).thenReturn(R.data(Boolean.TRUE)); + + mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID); + + verify(mpUserClient).bindOpenId(TENANT_ID, 14L, OPENID); + } + + @Test + @DisplayName("openid 命中旧账号但新手机号已被别的账号占用:落到手机号账号、不迁移 openid、有提示") + void phoneOccupiedKeepsPhoneAccount() { + MpUser openidAccount = mpUser(21L, "13900000000", OPENID, "driver"); + MpUser phoneAccount = mpUser(22L, NEW_PHONE, null, "driver"); + when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(openidAccount)); + when(mpUserClient.userByPhone(TENANT_ID, NEW_PHONE)).thenReturn(R.data(phoneAccount)); + when(mpUserClient.userInfoById(22L)).thenReturn(R.data(userInfo(phoneAccount, "driver"))); + when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult())); + + OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, NEW_PHONE, OPENID); + + assertNotNull(user); + assertEquals("22", user.getUserId(), "应落在手机号账号上"); + verify(mpUserClient, never()).updateUser(any(MpUser.class)); + verify(mpUserClient, never()).bindOpenId(anyString(), anyLong(), anyString()); + assertNotNull(user.getDetail().get(MpLoginSupport.DETAIL_LOGIN_NOTICE), "应带上可读提示"); + assertTrue(String.valueOf(user.getDetail().get(MpLoginSupport.DETAIL_LOGIN_NOTICE)).contains("该微信已绑定其他账号")); + } + + @Test + @DisplayName("openid 命中且手机号不同但新号空闲:更新账号手机号") + void openidHitWithFreePhoneUpdatesPhone() { + MpUser openidAccount = mpUser(31L, "13900000000", OPENID, "driver"); + when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(openidAccount)); + when(mpUserClient.userByPhone(TENANT_ID, NEW_PHONE)).thenReturn(R.data(null)); + when(mpUserClient.updateUser(any(MpUser.class))).thenReturn(R.data(Boolean.TRUE)); + when(mpUserClient.userInfoById(31L)).thenReturn(R.data(userInfo(openidAccount, "driver"))); + when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult())); + + OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, NEW_PHONE, OPENID); + + assertEquals("31", user.getUserId()); + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(MpUser.class); + verify(mpUserClient).updateUser(updateCaptor.capture()); + assertEquals(31L, updateCaptor.getValue().getId()); + assertEquals(NEW_PHONE, updateCaptor.getValue().getPhone()); + verify(mpUserClient, never()).ensureUser(any(MpUserEnsureDTO.class)); + } + + @Test + @DisplayName("纯 admin 身份:不建司机档案") + void adminOnlyDoesNotCreateProfile() { + MpUser admin = mpUser(41L, PHONE, OPENID, "admin"); + when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(null)); + when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.data(admin)); + when(mpUserClient.userInfoById(41L)).thenReturn(R.data(userInfo(admin, "admin"))); + + OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID); + + assertEquals("41", user.getUserId()); + verify(driverClient, never()).ensureProfile(any(DriverEnsureProfileDTO.class)); + } + + @Test + @DisplayName("建号失败是唯一阻断登录的条件") + void accountCreationFailureBlocksLogin() { + when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(null)); + when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.fail("创建小程序用户失败")); + + UserInvalidException exception = assertThrows(UserInvalidException.class, + () -> mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID)); + assertEquals("创建小程序用户失败", exception.getMessage()); + verify(driverClient, never()).ensureProfile(any(DriverEnsureProfileDTO.class)); + } + + @Test + @DisplayName("短信登录(openid 为空)不做 openid 相关调用") + void smsLoginSkipsOpenid() { + MpUser created = mpUser(51L, PHONE, null, "driver"); + when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.data(created)); + when(mpUserClient.userInfoById(51L)).thenReturn(R.data(userInfo(created, "driver"))); + when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult())); + + OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, null); + + assertEquals("51", user.getUserId()); + verify(mpUserClient, never()).userByOpenid(anyString(), anyString()); + verify(mpUserClient, never()).bindOpenId(anyString(), anyLong(), anyString()); + } + + private MpUser mpUser(Long id, String phone, String openid, String identities) { + MpUser user = new MpUser(); + user.setId(id); + user.setTenantId(TENANT_ID); + user.setPhone(phone); + user.setAccount(phone); + user.setOpenid(openid); + user.setIdentities(identities); + user.setStatus(1); + return user; + } + + private MpUserInfo userInfo(MpUser user, String... identities) { + MpUserInfo userInfo = new MpUserInfo(); + userInfo.setUser(user); + userInfo.setIdentities(List.of(identities)); + userInfo.setDetail(Kv.create() + .set("type", "mp") + .set("identities", String.join(",", identities)) + .set("openid", user.getOpenid() == null ? "" : user.getOpenid())); + return userInfo; + } + + private DriverEnsureProfileVO profileResult() { + DriverEnsureProfileVO result = new DriverEnsureProfileVO(); + result.setDriverId(9001L); + result.setCreated(true); + result.setBoundExisting(false); + return result; + } + +} From 924134226f8317b11a7e5b1f5de52c176a4cff09 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Fri, 23 Oct 2026 15:58:42 +0800 Subject: [PATCH 47/55] =?UTF-8?q?:recycle:=20=E5=BE=AE=E4=BF=A1=E4=B8=8E?= =?UTF-8?q?=E7=9F=AD=E4=BF=A1=E7=99=BB=E5=BD=95=E6=94=B9=E8=B5=B0MpLoginSu?= =?UTF-8?q?pport=E8=87=AA=E5=8A=A8=E5=BB=BA=E5=8F=B7=E5=BB=BA=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/granter/SmsTokenGranter.java | 23 ++++++++++-- .../auth/granter/WechatMiniTokenGranter.java | 35 +++++-------------- 2 files changed, 28 insertions(+), 30 deletions(-) diff --git a/blade-auth/src/main/java/org/springblade/auth/granter/SmsTokenGranter.java b/blade-auth/src/main/java/org/springblade/auth/granter/SmsTokenGranter.java index 540095c0..b79f8497 100644 --- a/blade-auth/src/main/java/org/springblade/auth/granter/SmsTokenGranter.java +++ b/blade-auth/src/main/java/org/springblade/auth/granter/SmsTokenGranter.java @@ -27,6 +27,7 @@ package org.springblade.auth.granter; import jakarta.servlet.http.HttpServletRequest; import org.springblade.auth.constant.BladeAuthConstant; +import org.springblade.auth.service.MpLoginSupport; import org.springblade.core.oauth2.constant.OAuth2TokenConstant; import org.springblade.core.oauth2.exception.UserInvalidException; import org.springblade.core.oauth2.granter.AbstractTokenGranter; @@ -43,10 +44,15 @@ import org.springblade.core.tool.utils.SM2Util; import org.springblade.core.tool.utils.StringUtil; import org.springblade.core.tool.utils.WebUtil; import org.springblade.resource.feign.ISmsClient; +import org.springblade.system.pojo.enums.MpUserIdentity; import org.springframework.stereotype.Component; /** * SmsTokenGranter + *

+ * 除验证码校验外,还要按请求来源分流:小程序(User-Type: mp)登录时账号不存在要自动建, + * Web 短信登录保持原样不建号。建号逻辑不能下沉到 {@code BladeUserDetailService.loadByPhone}, + * 那条方法还被「发送短信验证码」复用,会在发码时就建出账号(刷号)。 * * @author BladeX */ @@ -56,12 +62,14 @@ public class SmsTokenGranter extends AbstractTokenGranter { private final OAuth2UserService userService; private final ISmsClient smsClient; private final OAuth2Properties properties; + private final MpLoginSupport mpLoginSupport; - public SmsTokenGranter(OAuth2ClientService clientService, OAuth2UserService userService, PasswordHandler passwordHandler, ISmsClient smsClient, OAuth2Properties properties) { + public SmsTokenGranter(OAuth2ClientService clientService, OAuth2UserService userService, PasswordHandler passwordHandler, ISmsClient smsClient, OAuth2Properties properties, MpLoginSupport mpLoginSupport) { super(clientService, userService, passwordHandler); this.userService = userService; this.smsClient = smsClient; this.properties = properties; + this.mpLoginSupport = mpLoginSupport; } @Override @@ -86,8 +94,10 @@ public class SmsTokenGranter extends AbstractTokenGranter { if (!result.isSuccess()) { throw new UserInvalidException(OAuth2TokenConstant.CAPTCHA_NOT_CORRECT); } - // 获取用户信息 - OAuth2User user = userService.loadByPhone(decryptedPhone, request); + // 获取用户信息:只有小程序登录才允许自动建号,Web 短信登录仍走原查询 + OAuth2User user = isMpLoginRequest(request) + ? mpLoginSupport.ensureLoginAccount(tenantId, decryptedPhone, null) + : userService.loadByPhone(decryptedPhone, request); // 校验用户信息 if (!userService.validateUser(user)) { throw new UserInvalidException(OAuth2TokenConstant.TOKEN_NOT_CORRECT); @@ -97,6 +107,13 @@ public class SmsTokenGranter extends AbstractTokenGranter { return user; } + /** + * 是否小程序登录:请求头 User-Type=mp(Web 与小程序共用客户端,不能按 clientId 分流) + */ + private boolean isMpLoginRequest(OAuth2Request request) { + return request != null && MpUserIdentity.DETAIL_TYPE.equalsIgnoreCase(Func.toStr(request.getUserType())); + } + private SmsCode buildSmsCode() { HttpServletRequest request = WebUtil.getRequest(); return new SmsCode().setId(request.getParameter("id")) diff --git a/blade-auth/src/main/java/org/springblade/auth/granter/WechatMiniTokenGranter.java b/blade-auth/src/main/java/org/springblade/auth/granter/WechatMiniTokenGranter.java index 33de5291..101387c8 100644 --- a/blade-auth/src/main/java/org/springblade/auth/granter/WechatMiniTokenGranter.java +++ b/blade-auth/src/main/java/org/springblade/auth/granter/WechatMiniTokenGranter.java @@ -2,7 +2,7 @@ package org.springblade.auth.granter; import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; -import org.springblade.auth.utils.TokenUtil; +import org.springblade.auth.service.MpLoginSupport; import org.springblade.core.oauth2.exception.UserInvalidException; import org.springblade.core.oauth2.granter.AbstractTokenGranter; import org.springblade.core.oauth2.handler.PasswordHandler; @@ -11,12 +11,9 @@ import org.springblade.core.oauth2.service.OAuth2Client; import org.springblade.core.oauth2.service.OAuth2ClientService; import org.springblade.core.oauth2.service.OAuth2User; import org.springblade.core.oauth2.service.OAuth2UserService; -import org.springblade.core.tool.api.R; import org.springblade.core.tool.utils.Func; import org.springblade.core.tool.utils.StringUtil; import org.springblade.core.tool.utils.WebUtil; -import org.springblade.system.feign.IMpUserClient; -import org.springblade.system.pojo.entity.MpUserInfo; import org.springblade.thirdparty.wechat.constant.WechatMiniConstant; import org.springblade.thirdparty.wechat.exception.WechatMiniException; import org.springblade.thirdparty.wechat.pojo.vo.WechatPhoneVO; @@ -28,8 +25,8 @@ import org.springframework.stereotype.Component; * 微信小程序手机号一键登录。 *

* grant_type=wechat_applet,参数:loginCode(wx.login)、phoneCode(getPhoneNumber)。 - * 流程:换 openid + 手机号 → 按手机号查小程序用户(blade_mp_user,不存在则拒绝) - * → 记录 openid → 按身份发令牌(不依赖系统角色)。 + * 流程:换 openid + 手机号 → 由 {@link MpLoginSupport} 找到或创建账号并补齐档案 + * → 按身份发令牌(不依赖系统角色)。 */ @Slf4j @Component @@ -37,17 +34,17 @@ public class WechatMiniTokenGranter extends AbstractTokenGranter { private final OAuth2UserService userService; private final IWechatMiniService wechatMiniService; - private final IMpUserClient mpUserClient; + private final MpLoginSupport mpLoginSupport; public WechatMiniTokenGranter(OAuth2ClientService clientService, OAuth2UserService userService, PasswordHandler passwordHandler, IWechatMiniService wechatMiniService, - IMpUserClient mpUserClient) { + MpLoginSupport mpLoginSupport) { super(clientService, userService, passwordHandler); this.userService = userService; this.wechatMiniService = wechatMiniService; - this.mpUserClient = mpUserClient; + this.mpLoginSupport = mpLoginSupport; } @Override @@ -82,28 +79,12 @@ public class WechatMiniTokenGranter extends AbstractTokenGranter { throw new UserInvalidException("未获取到微信手机号"); } - R mpUserResult = mpUserClient.userInfoByPhone(request.getTenantId(), phone); - if (mpUserResult == null || !mpUserResult.isSuccess() || mpUserResult.getData() == null - || mpUserResult.getData().getUser() == null) { - throw new UserInvalidException("用户不存在,无法登录"); - } - - OAuth2User user = TokenUtil.convertMpUser(mpUserResult.getData()); + // 账号不存在时自动建号,建档与绑 openid 由编排层尽力而为,失败不阻断登录 + OAuth2User user = mpLoginSupport.ensureLoginAccount(request.getTenantId(), phone, session.getOpenid()); if (!userService.validateUser(user)) { throw new UserInvalidException("用户身份无效,无法登录"); } - R bindResult = mpUserClient.bindOpenId( - request.getTenantId(), - Func.toLong(user.getUserId()), - session.getOpenid() - ); - if (bindResult == null || !bindResult.isSuccess()) { - String msg = bindResult != null ? bindResult.getMsg() : "绑定 openid 失败"; - log.warn("绑定微信 openid 失败 userId={} openid={} msg={}", user.getUserId(), session.getOpenid(), msg); - throw new UserInvalidException(StringUtil.isBlank(msg) ? "绑定 openid 失败" : msg); - } - user.setClient(oauthClient); return user; } From 0ff8e57c4e65b12acc3dc60f52afeea1660459cc Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Fri, 23 Oct 2026 15:58:42 +0800 Subject: [PATCH 48/55] =?UTF-8?q?:bug:=20=E4=BF=AE=E5=A4=8D=E7=BB=91?= =?UTF-8?q?=E5=AE=9Aopenid=E4=BC=9A=E8=A6=86=E7=9B=96=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E5=B7=B2=E6=9C=89=E5=8F=A6=E4=B8=80=E4=B8=AAopenid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../springblade/system/service/impl/MpUserServiceImpl.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java index a0a12bab..c1e77ca9 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java @@ -319,6 +319,13 @@ public class MpUserServiceImpl extends BaseServiceImpl imp if (!tenantId.equals(user.getTenantId())) { throw new ServiceException("租户不匹配,无法绑定 openid"); } + // 目标账号已有另一个非空 openid 时不得覆盖:旧逻辑会静默顶掉上一个微信的绑定, + // 而一个微信换绑需要管理员确认,登录路径只放弃本次绑定(票 05) + if (Func.isNotBlank(user.getOpenid()) && !user.getOpenid().equals(openid)) { + log.warn("小程序用户已绑定其他 openid,跳过绑定:租户={} 账号={} 已有openid={} 本次openid={}", + tenantId, userId, user.getOpenid(), openid); + return false; + } MpUser byOpenId = getOne(Wrappers.lambdaQuery() .eq(MpUser::getTenantId, tenantId) .eq(MpUser::getOpenid, openid) From 5df95ce7128e416928baece18227a1f12503abb0 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Fri, 23 Oct 2026 16:06:24 +0800 Subject: [PATCH 49/55] =?UTF-8?q?:sparkles:=20=E6=94=BE=E5=BC=80=E5=B0=8F?= =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E7=AB=AF=E6=9C=AA=E6=B3=A8=E5=86=8C=E6=89=8B?= =?UTF-8?q?=E6=9C=BA=E5=8F=B7=E5=8F=91=E7=A0=81=EF=BC=8C=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E4=BB=8D=E5=9C=A8=E7=99=BB=E5=BD=95=E6=97=B6=E5=88=9B=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/endpoint/Oauth2SmsEndpoint.java | 21 ++- .../auth/endpoint/Oauth2SmsEndpointTest.java | 158 ++++++++++++++++++ 2 files changed, 175 insertions(+), 4 deletions(-) create mode 100644 blade-auth/src/test/java/org/springblade/auth/endpoint/Oauth2SmsEndpointTest.java diff --git a/blade-auth/src/main/java/org/springblade/auth/endpoint/Oauth2SmsEndpoint.java b/blade-auth/src/main/java/org/springblade/auth/endpoint/Oauth2SmsEndpoint.java index dfa3b898..0729dfe8 100644 --- a/blade-auth/src/main/java/org/springblade/auth/endpoint/Oauth2SmsEndpoint.java +++ b/blade-auth/src/main/java/org/springblade/auth/endpoint/Oauth2SmsEndpoint.java @@ -39,6 +39,7 @@ import org.springblade.core.tool.utils.SM2Util; import org.springblade.core.tool.utils.StringUtil; import org.springblade.resource.feign.ISmsClient; import org.springblade.resource.utils.SmsUtil; +import org.springblade.system.pojo.enums.MpUserIdentity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -87,13 +88,18 @@ public class Oauth2SmsEndpoint { if (StringUtil.isBlank(decryptedPhone)) { return R.fail(USER_PHONE_NOT_FOUND); } - // 校验手机是否已注册,防止恶意发送验证码 // buildArgs:带上 User-Type 等请求头,小程序传 User-Type=mp 时查 blade_mp_user OAuth2Request request = OAuth2Request.create().buildArgs(); request.setTenantId(tenantId); - OAuth2User oAuth2User = userService.loadByPhone(decryptedPhone, request); - if (oAuth2User == null) { - return R.fail(USER_PHONE_NOT_FOUND); + // 「手机号是否已注册」只对 Web 保留。小程序端必须允许未注册手机号收码, + // 否则新号走不到「验证码校验通过后自动建号」那一步(票 13)。 + // 该处是全链路唯一的防短信轰炸点,放开小程序端不等于放开 Web; + // 这里只做查询,发码路径不建号,账号仍只在登录时创建。 + if (!isMpLoginRequest(request)) { + OAuth2User oAuth2User = userService.loadByPhone(decryptedPhone, request); + if (oAuth2User == null) { + return R.fail(USER_PHONE_NOT_FOUND); + } } // 使用指定短信资源(默认 ali_reg) String smsResourceCode = Func.toStr(code, BladeAuthConstant.LOGIN_SMS_CODE); @@ -101,4 +107,11 @@ public class Oauth2SmsEndpoint { return result.isSuccess() ? R.data(result.getData(), SmsUtil.SEND_SUCCESS) : R.fail(SmsUtil.SEND_FAIL); } + /** + * 是否小程序请求:请求头 User-Type=mp,与登录链路同一判据 + */ + private boolean isMpLoginRequest(OAuth2Request request) { + return request != null && MpUserIdentity.DETAIL_TYPE.equalsIgnoreCase(Func.toStr(request.getUserType())); + } + } diff --git a/blade-auth/src/test/java/org/springblade/auth/endpoint/Oauth2SmsEndpointTest.java b/blade-auth/src/test/java/org/springblade/auth/endpoint/Oauth2SmsEndpointTest.java new file mode 100644 index 00000000..71f49fcf --- /dev/null +++ b/blade-auth/src/test/java/org/springblade/auth/endpoint/Oauth2SmsEndpointTest.java @@ -0,0 +1,158 @@ +package org.springblade.auth.endpoint; + +import org.bouncycastle.crypto.AsymmetricCipherKeyPair; +import org.bouncycastle.util.encoders.Hex; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springblade.core.oauth2.props.OAuth2Properties; +import org.springblade.core.oauth2.service.OAuth2User; +import org.springblade.core.oauth2.service.OAuth2UserService; +import org.springblade.core.tool.api.R; +import org.springblade.core.tool.utils.SM2Util; +import org.springblade.resource.feign.ISmsClient; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springblade.core.oauth2.constant.OAuth2TokenConstant.USER_PHONE_NOT_FOUND; + +/** + * 发码端点的端分流测试。 + *

+ * 守住票 13 的口径:小程序端(User-Type: mp)对未注册手机号也要发码, + * 否则新号走不到「验证码校验通过后自动建号」;Web 端保持拒绝。 + * 同时守住「发码不建号」:mp 分支根本不查账号。 + */ +class Oauth2SmsEndpointTest { + + private static final String TENANT_ID = "000000"; + private static final String PHONE = "13800000001"; + private static final String SMS_CODE = "ali_reg"; + + private static String publicKey; + private static String privateKey; + + private ISmsClient smsClient; + private OAuth2UserService userService; + private Oauth2SmsEndpoint endpoint; + + @BeforeAll + static void initSm2Keys() { + AsymmetricCipherKeyPair keyPair = SM2Util.generateKeyPair(); + publicKey = SM2Util.getPublicKeyString(keyPair); + privateKey = SM2Util.getPrivateKeyString(keyPair); + } + + @BeforeEach + void setUp() { + OAuth2Properties properties = new OAuth2Properties(); + properties.setPublicKey(publicKey); + properties.setPrivateKey(privateKey); + smsClient = mock(ISmsClient.class); + userService = mock(OAuth2UserService.class); + endpoint = new Oauth2SmsEndpoint(smsClient, userService, properties); + when(smsClient.sendValidate(anyString(), anyString(), anyString())).thenReturn(R.data("sms-id-1")); + } + + @AfterEach + void clearRequestContext() { + RequestContextHolder.resetRequestAttributes(); + } + + @Test + @DisplayName("小程序 + 未注册手机号:发码成功,且完全不查账号(发码不建号)") + void mpUnregisteredPhoneSendsCode() { + bindRequest("mp"); + when(userService.loadByPhone(anyString(), any())).thenReturn(null); + + R result = endpoint.sendValidate(TENANT_ID, encrypt(PHONE), SMS_CODE); + + assertTrue(result.isSuccess()); + verify(smsClient).sendValidate(TENANT_ID, SMS_CODE, PHONE); + verify(userService, never()).loadByPhone(anyString(), any()); + } + + @Test + @DisplayName("Web + 未注册手机号:仍返回手机号不存在,不发码") + void webUnregisteredPhoneIsRejected() { + bindRequest(null); + when(userService.loadByPhone(anyString(), any())).thenReturn(null); + + R result = endpoint.sendValidate(TENANT_ID, encrypt(PHONE), SMS_CODE); + + assertFalse(result.isSuccess()); + assertEquals(USER_PHONE_NOT_FOUND, result.getMsg()); + verify(smsClient, never()).sendValidate(anyString(), anyString(), anyString()); + } + + @Test + @DisplayName("Web + 已注册手机号:行为与改造前一致,正常发码") + void webRegisteredPhoneSendsCode() { + bindRequest(null); + when(userService.loadByPhone(anyString(), any())).thenReturn(mock(OAuth2User.class)); + + R result = endpoint.sendValidate(TENANT_ID, encrypt(PHONE), SMS_CODE); + + assertTrue(result.isSuccess()); + verify(userService).loadByPhone(anyString(), any()); + verify(smsClient).sendValidate(TENANT_ID, SMS_CODE, PHONE); + } + + @Test + @DisplayName("小程序 + 已注册手机号:跳过注册查询,依旧发码") + void mpRegisteredPhoneSendsCode() { + bindRequest("mp"); + + R result = endpoint.sendValidate(TENANT_ID, encrypt(PHONE), SMS_CODE); + + assertTrue(result.isSuccess()); + verify(userService, never()).loadByPhone(anyString(), any()); + verify(smsClient).sendValidate(TENANT_ID, SMS_CODE, PHONE); + } + + @Test + @DisplayName("密文无法解密:无论哪端都不发码") + void invalidCipherIsRejected() { + bindRequest("mp"); + + R result = endpoint.sendValidate(TENANT_ID, "deadbeef", SMS_CODE); + + assertFalse(result.isSuccess()); + assertEquals(USER_PHONE_NOT_FOUND, result.getMsg()); + verify(smsClient, never()).sendValidate(anyString(), anyString(), anyString()); + } + + /** + * 请求上下文决定 buildArgs() 读到什么头;Authorization 是 buildClientArgs 的前提 + */ + private void bindRequest(String userType) { + MockHttpServletRequest request = new MockHttpServletRequest(); + String credential = Base64.getEncoder() + .encodeToString("saber:saber_secret".getBytes(StandardCharsets.UTF_8)); + request.addHeader("Authorization", "Basic " + credential); + if (userType != null) { + request.addHeader("User-Type", userType); + } + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + } + + private String encrypt(String plainPhone) { + return Hex.toHexString(SM2Util.encrypt(plainPhone, publicKey)); + } + +} From d28c8688cf4dd7a181c4d0c6fcb3ea2154bafe7a Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Fri, 23 Oct 2026 16:34:04 +0800 Subject: [PATCH 50/55] =?UTF-8?q?:recycle:=20=E6=94=B6=E6=95=9B=E5=B0=8F?= =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E7=99=BB=E5=BD=95=E8=AF=B7=E6=B1=82=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E5=88=A4=E6=96=AD=E5=B9=B6=E8=B0=83=E6=95=B4=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E7=BC=96=E6=8E=92=E7=B1=BB=E4=BD=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 org.springblade.auth.support.MpLoginRequestSupport.isMp,供三条登录链路共用 - 删除 BladeUserDetailService / SmsTokenGranter / Oauth2SmsEndpoint 各自私有的同义判据 - MpLoginSupport 及其测试移入 auth.support 包,类名、行为与注入方式不变 - 顺带订正上述文件的注释为事实陈述 --- .../auth/endpoint/Oauth2SmsEndpoint.java | 17 ++---- .../auth/granter/SmsTokenGranter.java | 20 +++---- .../auth/granter/WechatMiniTokenGranter.java | 4 +- .../auth/service/BladeUserDetailService.java | 19 ++----- .../auth/support/MpLoginRequestSupport.java | 54 +++++++++++++++++++ .../{service => support}/MpLoginSupport.java | 39 +++++++------- .../MpLoginSupportTest.java | 4 +- 7 files changed, 90 insertions(+), 67 deletions(-) create mode 100644 blade-auth/src/main/java/org/springblade/auth/support/MpLoginRequestSupport.java rename blade-auth/src/main/java/org/springblade/auth/{service => support}/MpLoginSupport.java (87%) rename blade-auth/src/test/java/org/springblade/auth/{service => support}/MpLoginSupportTest.java (98%) diff --git a/blade-auth/src/main/java/org/springblade/auth/endpoint/Oauth2SmsEndpoint.java b/blade-auth/src/main/java/org/springblade/auth/endpoint/Oauth2SmsEndpoint.java index 0729dfe8..8bbd2755 100644 --- a/blade-auth/src/main/java/org/springblade/auth/endpoint/Oauth2SmsEndpoint.java +++ b/blade-auth/src/main/java/org/springblade/auth/endpoint/Oauth2SmsEndpoint.java @@ -29,6 +29,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.AllArgsConstructor; import lombok.SneakyThrows; import org.springblade.auth.constant.BladeAuthConstant; +import org.springblade.auth.support.MpLoginRequestSupport; import org.springblade.core.oauth2.props.OAuth2Properties; import org.springblade.core.oauth2.provider.OAuth2Request; import org.springblade.core.oauth2.service.OAuth2User; @@ -39,7 +40,6 @@ import org.springblade.core.tool.utils.SM2Util; import org.springblade.core.tool.utils.StringUtil; import org.springblade.resource.feign.ISmsClient; import org.springblade.resource.utils.SmsUtil; -import org.springblade.system.pojo.enums.MpUserIdentity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -91,11 +91,9 @@ public class Oauth2SmsEndpoint { // buildArgs:带上 User-Type 等请求头,小程序传 User-Type=mp 时查 blade_mp_user OAuth2Request request = OAuth2Request.create().buildArgs(); request.setTenantId(tenantId); - // 「手机号是否已注册」只对 Web 保留。小程序端必须允许未注册手机号收码, - // 否则新号走不到「验证码校验通过后自动建号」那一步(票 13)。 - // 该处是全链路唯一的防短信轰炸点,放开小程序端不等于放开 Web; - // 这里只做查询,发码路径不建号,账号仍只在登录时创建。 - if (!isMpLoginRequest(request)) { + // 「手机号是否已注册」只对 Web 校验;小程序端未注册手机号也发码,账号在登录时创建。 + // 发码路径只做查询,不建号。 + if (!MpLoginRequestSupport.isMp(request)) { OAuth2User oAuth2User = userService.loadByPhone(decryptedPhone, request); if (oAuth2User == null) { return R.fail(USER_PHONE_NOT_FOUND); @@ -107,11 +105,4 @@ public class Oauth2SmsEndpoint { return result.isSuccess() ? R.data(result.getData(), SmsUtil.SEND_SUCCESS) : R.fail(SmsUtil.SEND_FAIL); } - /** - * 是否小程序请求:请求头 User-Type=mp,与登录链路同一判据 - */ - private boolean isMpLoginRequest(OAuth2Request request) { - return request != null && MpUserIdentity.DETAIL_TYPE.equalsIgnoreCase(Func.toStr(request.getUserType())); - } - } diff --git a/blade-auth/src/main/java/org/springblade/auth/granter/SmsTokenGranter.java b/blade-auth/src/main/java/org/springblade/auth/granter/SmsTokenGranter.java index b79f8497..cd01f6ef 100644 --- a/blade-auth/src/main/java/org/springblade/auth/granter/SmsTokenGranter.java +++ b/blade-auth/src/main/java/org/springblade/auth/granter/SmsTokenGranter.java @@ -27,7 +27,8 @@ package org.springblade.auth.granter; import jakarta.servlet.http.HttpServletRequest; import org.springblade.auth.constant.BladeAuthConstant; -import org.springblade.auth.service.MpLoginSupport; +import org.springblade.auth.support.MpLoginRequestSupport; +import org.springblade.auth.support.MpLoginSupport; import org.springblade.core.oauth2.constant.OAuth2TokenConstant; import org.springblade.core.oauth2.exception.UserInvalidException; import org.springblade.core.oauth2.granter.AbstractTokenGranter; @@ -44,15 +45,13 @@ import org.springblade.core.tool.utils.SM2Util; import org.springblade.core.tool.utils.StringUtil; import org.springblade.core.tool.utils.WebUtil; import org.springblade.resource.feign.ISmsClient; -import org.springblade.system.pojo.enums.MpUserIdentity; import org.springframework.stereotype.Component; /** * SmsTokenGranter *

- * 除验证码校验外,还要按请求来源分流:小程序(User-Type: mp)登录时账号不存在要自动建, - * Web 短信登录保持原样不建号。建号逻辑不能下沉到 {@code BladeUserDetailService.loadByPhone}, - * 那条方法还被「发送短信验证码」复用,会在发码时就建出账号(刷号)。 + * 按请求来源分流:小程序(User-Type: mp)登录时账号不存在要自动建,Web 短信登录不建号。 + * {@code BladeUserDetailService.loadByPhone} 同时被「发送短信验证码」复用,其中不得建号。 * * @author BladeX */ @@ -94,8 +93,8 @@ public class SmsTokenGranter extends AbstractTokenGranter { if (!result.isSuccess()) { throw new UserInvalidException(OAuth2TokenConstant.CAPTCHA_NOT_CORRECT); } - // 获取用户信息:只有小程序登录才允许自动建号,Web 短信登录仍走原查询 - OAuth2User user = isMpLoginRequest(request) + // 获取用户信息:小程序登录走自动建号编排,Web 短信登录只做查询 + OAuth2User user = MpLoginRequestSupport.isMp(request) ? mpLoginSupport.ensureLoginAccount(tenantId, decryptedPhone, null) : userService.loadByPhone(decryptedPhone, request); // 校验用户信息 @@ -107,13 +106,6 @@ public class SmsTokenGranter extends AbstractTokenGranter { return user; } - /** - * 是否小程序登录:请求头 User-Type=mp(Web 与小程序共用客户端,不能按 clientId 分流) - */ - private boolean isMpLoginRequest(OAuth2Request request) { - return request != null && MpUserIdentity.DETAIL_TYPE.equalsIgnoreCase(Func.toStr(request.getUserType())); - } - private SmsCode buildSmsCode() { HttpServletRequest request = WebUtil.getRequest(); return new SmsCode().setId(request.getParameter("id")) diff --git a/blade-auth/src/main/java/org/springblade/auth/granter/WechatMiniTokenGranter.java b/blade-auth/src/main/java/org/springblade/auth/granter/WechatMiniTokenGranter.java index 101387c8..9d2bb75c 100644 --- a/blade-auth/src/main/java/org/springblade/auth/granter/WechatMiniTokenGranter.java +++ b/blade-auth/src/main/java/org/springblade/auth/granter/WechatMiniTokenGranter.java @@ -2,7 +2,7 @@ package org.springblade.auth.granter; import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; -import org.springblade.auth.service.MpLoginSupport; +import org.springblade.auth.support.MpLoginSupport; import org.springblade.core.oauth2.exception.UserInvalidException; import org.springblade.core.oauth2.granter.AbstractTokenGranter; import org.springblade.core.oauth2.handler.PasswordHandler; @@ -79,7 +79,7 @@ public class WechatMiniTokenGranter extends AbstractTokenGranter { throw new UserInvalidException("未获取到微信手机号"); } - // 账号不存在时自动建号,建档与绑 openid 由编排层尽力而为,失败不阻断登录 + // 账号不存在时自动建号;建档与绑 openid 失败不阻断登录 OAuth2User user = mpLoginSupport.ensureLoginAccount(request.getTenantId(), phone, session.getOpenid()); if (!userService.validateUser(user)) { throw new UserInvalidException("用户身份无效,无法登录"); diff --git a/blade-auth/src/main/java/org/springblade/auth/service/BladeUserDetailService.java b/blade-auth/src/main/java/org/springblade/auth/service/BladeUserDetailService.java index 22904efe..10f5d6b7 100644 --- a/blade-auth/src/main/java/org/springblade/auth/service/BladeUserDetailService.java +++ b/blade-auth/src/main/java/org/springblade/auth/service/BladeUserDetailService.java @@ -23,6 +23,7 @@ package org.springblade.auth.service; import lombok.RequiredArgsConstructor; +import org.springblade.auth.support.MpLoginRequestSupport; import org.springblade.auth.utils.TokenUtil; import org.springblade.core.oauth2.exception.OAuth2ErrorCode; import org.springblade.core.oauth2.provider.OAuth2Request; @@ -36,7 +37,6 @@ import org.springblade.system.feign.IMpUserClient; import org.springblade.system.feign.IUserClient; import org.springblade.system.pojo.entity.MpUserInfo; import org.springblade.system.pojo.entity.UserInfo; -import org.springblade.system.pojo.enums.MpUserIdentity; import org.springblade.system.pojo.enums.UserType; import java.util.Optional; @@ -54,7 +54,7 @@ public class BladeUserDetailService implements OAuth2UserService { @Override public OAuth2User loadByUserId(String userId, OAuth2Request request) { // 小程序刷新/鉴权:User-Type=mp 时只查 blade_mp_user - if (isMpLoginRequest(request)) { + if (MpLoginRequestSupport.isMp(request)) { OAuth2User mpUser = loadMpUserById(userId); if (mpUser == null) { OAuth2ExceptionUtil.throwFromCode(OAuth2ErrorCode.INVALID_USER); @@ -83,7 +83,7 @@ public class BladeUserDetailService implements OAuth2UserService { String tenantId = request.getTenantId(); // 小程序账号密码 / 图形验证码登录:仅查 blade_mp_user - if (isMpLoginRequest(request)) { + if (MpLoginRequestSupport.isMp(request)) { R mpResult = mpUserClient.userInfoByAccount(tenantId, username); if (mpResult == null || !mpResult.isSuccess() || mpResult.getData() == null) { return null; @@ -104,7 +104,7 @@ public class BladeUserDetailService implements OAuth2UserService { String tenantId = request.getTenantId(); // 小程序短信登录:仅查 blade_mp_user - if (isMpLoginRequest(request)) { + if (MpLoginRequestSupport.isMp(request)) { return loadMpUserByPhone(tenantId, phone); } @@ -125,17 +125,6 @@ public class BladeUserDetailService implements OAuth2UserService { .isPresent(); } - /** - * 是否小程序登录:请求头 User-Type=mp(Web 与小程序共用 saber3 客户端,不能按 clientId 分流) - */ - private boolean isMpLoginRequest(OAuth2Request request) { - return request != null && isMpUserType(request.getUserType()); - } - - private boolean isMpUserType(String userType) { - return MpUserIdentity.DETAIL_TYPE.equalsIgnoreCase(Func.toStr(userType)); - } - private String resolveUserType(OAuth2Request request) { return Optional.ofNullable(request.getUserType()) .filter(s -> !StringUtil.isBlank(s)) diff --git a/blade-auth/src/main/java/org/springblade/auth/support/MpLoginRequestSupport.java b/blade-auth/src/main/java/org/springblade/auth/support/MpLoginRequestSupport.java new file mode 100644 index 00000000..012ffbbb --- /dev/null +++ b/blade-auth/src/main/java/org/springblade/auth/support/MpLoginRequestSupport.java @@ -0,0 +1,54 @@ +/** + * 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. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.auth.support; + +import org.springblade.core.oauth2.provider.OAuth2Request; +import org.springblade.core.tool.utils.Func; +import org.springblade.system.pojo.enums.MpUserIdentity; + +/** + * 登录请求来源判定 + * + * @author Chill + */ +public final class MpLoginRequestSupport { + + private MpLoginRequestSupport() { + } + + /** + * 是否小程序请求:请求头 User-Type 为 mp + *

+ * Web 与小程序共用同一个客户端,来源只认请求头 User-Type。 + * + * @param request 登录请求,可为 null + * @return 请求头 User-Type 为 mp 时返回 true + */ + public static boolean isMp(OAuth2Request request) { + return request != null && MpUserIdentity.DETAIL_TYPE.equalsIgnoreCase(Func.toStr(request.getUserType())); + } + +} diff --git a/blade-auth/src/main/java/org/springblade/auth/service/MpLoginSupport.java b/blade-auth/src/main/java/org/springblade/auth/support/MpLoginSupport.java similarity index 87% rename from blade-auth/src/main/java/org/springblade/auth/service/MpLoginSupport.java rename to blade-auth/src/main/java/org/springblade/auth/support/MpLoginSupport.java index 6032ebeb..1a48b334 100644 --- a/blade-auth/src/main/java/org/springblade/auth/service/MpLoginSupport.java +++ b/blade-auth/src/main/java/org/springblade/auth/support/MpLoginSupport.java @@ -23,7 +23,7 @@ *

* Author: Chill Zhuang (bladejava@qq.com) */ -package org.springblade.auth.service; +package org.springblade.auth.support; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -47,10 +47,10 @@ import org.springframework.stereotype.Component; import java.util.Objects; /** - * 小程序登录编排:按 openid / 手机号找到或创建账号,并尽力补齐司机档案与微信绑定。 + * 小程序登录编排:按 openid / 手机号找到或创建账号,并补齐司机档案与微信绑定。 *

- * 只有「建号失败」会让登录失败;建档与绑 openid 都是尽力而为,失败只记 WARN, - * 下次登录自愈。复制这段判断到别处前先确认调用方不是登录路径。 + * 只有「建号失败」会让登录失败;建档与绑 openid 失败只记 WARN,不影响本次登录。 + * 本类只供登录链路调用。 * * @author Chill */ @@ -65,7 +65,7 @@ public class MpLoginSupport { public static final String DETAIL_LOGIN_NOTICE = "loginNotice"; /** - * 换号撞键:openid 属于旧账号、新手机号已属于另一账号(票 05 Q2-A) + * 换号撞键提示:openid 属于旧账号、新手机号已属于另一账号 */ private static final String NOTICE_PHONE_OCCUPIED = "该微信已绑定其他账号,请联系管理员"; @@ -73,11 +73,10 @@ public class MpLoginSupport { private final IDriverClient driverClient; /** - * 找到或创建本次登录使用的账号,并尽力补齐档案与 openid 绑定。 + * 找到或创建本次登录使用的账号,并补齐档案与 openid 绑定。 *

- * 返回 {@link OAuth2User} 而不是 {@link MpUserInfo}:两条登录链路都需要 OAuth2User, - * 且 MpUserInfo 的唯一组装点(identities / permissions / detail)在 system 侧, - * 由本类统一转换可避免 auth 侧复制一份 detail 口径。 + * 返回 {@link OAuth2User}:两条登录链路都消费 OAuth2User,而 MpUserInfo + * (identities / permissions / detail)只在 system 侧组装。 * * @param tenantId 请求租户,为空时由 system 侧兜到默认租户 * @param phone 本次登录的手机号 @@ -102,8 +101,7 @@ public class MpLoginSupport { if (!samePhone(openidAccount.getPhone(), phone)) { MpUser phoneAccount = findByPhone(tenantId, phone); if (phoneAccount != null && !Objects.equals(phoneAccount.getId(), openidAccount.getId())) { - // ①-撞键:落到手机号账号,不迁移 openid。以 openid 账号为准会把用户 - // 正在用的手机号账号顶掉,自动合并又要动档案归属(票 05) + // ①-撞键:落到手机号账号,不迁移 openid account = phoneAccount; loginNotice = NOTICE_PHONE_OCCUPIED; openidOccupied = true; @@ -128,12 +126,12 @@ public class MpLoginSupport { } } } - // ③ 身份含 driver 才建档;纯 admin 不建,避免污染司机列表与派单池(票 03) + // ③ 身份含 driver 才建档;纯 admin 不建 String profileNotice = ensureProfile(account, phone, tenantId); if (StringUtil.isBlank(loginNotice)) { loginNotice = profileNotice; } - // ④ 微信登录补绑 openid;失败只 WARN(票 05) + // ④ 微信登录补绑 openid;失败只 WARN bindOpenid(account, openid, openidOccupied); MpUserInfo userInfo = loadUserInfo(account); attachNotice(userInfo, loginNotice); @@ -148,8 +146,7 @@ public class MpLoginSupport { R result = mpUserClient.userByOpenid(tenantId, openid); return isSuccess(result) ? result.getData() : null; } catch (Exception exception) { - // 查询失败按未命中处理:随后 ensureUser 会再次触达系统服务, - // 真不可用时在那里以「建号失败」拦下,避免这里静默建出重复账号 + // 查询失败按未命中处理,随后由 ensureUser 触达系统服务并兜住建号失败 log.warn("按 openid 查询小程序账号失败:租户={} openid={}", tenantId, openid, exception); return null; } @@ -166,7 +163,7 @@ public class MpLoginSupport { } /** - * ② 查找或创建账号:这是登录路径上唯一允许让登录失败的动作 + * ② 查找或创建账号:登录路径上唯一允许让登录失败的动作 */ private MpUser ensureUser(String tenantId, String phone, String openid) { MpUserEnsureDTO param = new MpUserEnsureDTO(); @@ -191,10 +188,10 @@ public class MpLoginSupport { } /** - * 把 openid 账号的手机号换成新号(票 03:openid 命中即复用并更新手机号) + * 把 openid 账号的手机号换成新号 *

* 只 patch phone:account 是登录名,不随手机号变;status 必须带原值, - * 否则 system 侧 updateUser 的 prepare 会把空 status 重置成启用,等于解开停用账号。 + * system 侧 updateUser 的 prepare 会把空 status 重置成启用。 * * @return 更新成功后的账号;失败返回 null,不抛异常 */ @@ -230,8 +227,8 @@ public class MpLoginSupport { if (account == null || !MpUserIdentity.has(account.getIdentities(), MpUserIdentity.DRIVER)) { return null; } - // 租户与手机号优先取账号上的值:档案必须与账号落同一租户(唯一键对 NULL 不去重), - // 手机号更新失败时也不能拿新号去建,否则档案和账号对不上 + // 租户与手机号取账号上的值:档案必须与账号落同一租户(唯一键对 NULL 不去重); + // 账号手机号未能更新时,不使用入参手机号建档 String tenantId = Func.toStr(account.getTenantId(), requestedTenantId); String phone = Func.toStr(account.getPhone(), requestedPhone); DriverEnsureProfileDTO param = new DriverEnsureProfileDTO(); @@ -313,7 +310,7 @@ public class MpLoginSupport { if (userInfo == null || StringUtil.isBlank(loginNotice)) { return; } - // detail 是否会下发到前端取决于令牌序列化,故冲突点同时都记了 WARN + // detail 是否下发到前端取决于令牌序列化,冲突点同时另记 WARN Kv detail = userInfo.getDetail() == null ? Kv.create() : userInfo.getDetail(); detail.set(DETAIL_LOGIN_NOTICE, loginNotice); userInfo.setDetail(detail); diff --git a/blade-auth/src/test/java/org/springblade/auth/service/MpLoginSupportTest.java b/blade-auth/src/test/java/org/springblade/auth/support/MpLoginSupportTest.java similarity index 98% rename from blade-auth/src/test/java/org/springblade/auth/service/MpLoginSupportTest.java rename to blade-auth/src/test/java/org/springblade/auth/support/MpLoginSupportTest.java index 98617200..4518e344 100644 --- a/blade-auth/src/test/java/org/springblade/auth/service/MpLoginSupportTest.java +++ b/blade-auth/src/test/java/org/springblade/auth/support/MpLoginSupportTest.java @@ -1,4 +1,4 @@ -package org.springblade.auth.service; +package org.springblade.auth.support; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -35,7 +35,7 @@ import static org.mockito.Mockito.when; /** * 登录编排测试。 *

- * 守住票 02 / 03 / 05 的三条口径:建号失败才阻断登录;建档失败放行; + * 保住三条不变式:建号失败才阻断登录;建档失败放行; * openid 换号撞键时落到手机号账号且不迁移 openid。 */ class MpLoginSupportTest { From b04f68e168310e275d2c2f7841fe196fc9a1e850 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Fri, 23 Oct 2026 16:34:10 +0800 Subject: [PATCH 51/55] =?UTF-8?q?:adhesive=5Fbandage:=20=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E5=BB=BA=E5=8F=B7=E7=A7=9F=E6=88=B7=E6=94=B9=E4=B8=BA=E4=BC=98?= =?UTF-8?q?=E5=85=88=E5=8F=96=20Tenant-Id=20=E8=AF=B7=E6=B1=82=E5=A4=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 接口契约要求 Tenant-Id 头 → tenant_id 参数 → 兜底 000000。 写库前仍兜到非空;入参与请求头不一致时以请求头为准。 同步订正两个 DTO 的 tenantId 说明。 --- .../pojo/dto/DriverEnsureProfileDTO.java | 2 +- .../system/pojo/dto/MpUserEnsureDTO.java | 2 +- .../service/impl/MpUserServiceImpl.java | 36 ++++++++++++++----- 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/DriverEnsureProfileDTO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/DriverEnsureProfileDTO.java index 3844e1b9..cab277e8 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/DriverEnsureProfileDTO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/DriverEnsureProfileDTO.java @@ -43,7 +43,7 @@ public class DriverEnsureProfileDTO implements Serializable { @Serial private static final long serialVersionUID = 1L; - @Schema(description = "租户ID,为空时取当前登录租户;仍为空则拒绝写库") + @Schema(description = "租户ID,为空时取当前请求租户;仍为空则拒绝写库") private String tenantId; @Schema(description = "关联小程序用户ID(blade_mp_user.id)") diff --git a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/dto/MpUserEnsureDTO.java b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/dto/MpUserEnsureDTO.java index adb27378..ea38a27c 100644 --- a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/dto/MpUserEnsureDTO.java +++ b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/dto/MpUserEnsureDTO.java @@ -43,7 +43,7 @@ public class MpUserEnsureDTO implements Serializable { @Serial private static final long serialVersionUID = 1L; - @Schema(description = "租户ID,为空时依次取当前登录租户与默认租户") + @Schema(description = "租户ID,为空时依次取 Tenant-Id 请求头与默认租户") private String tenantId; @Schema(description = "手机号") diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java index c1e77ca9..125b63ce 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java @@ -24,6 +24,7 @@ package org.springblade.system.service.impl; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springblade.common.utils.PasswordRuleUtil; @@ -35,6 +36,7 @@ import org.springblade.core.tool.utils.DateUtil; import org.springblade.core.tool.utils.DigestUtil; import org.springblade.core.tool.utils.Func; import org.springblade.core.tool.utils.StringUtil; +import org.springblade.core.tool.utils.WebUtil; import org.springblade.system.cache.MpUserCache; import org.springblade.system.mapper.MpUserMapper; import org.springblade.system.pojo.dto.MpUserEnsureDTO; @@ -65,6 +67,11 @@ public class MpUserServiceImpl extends BaseServiceImpl imp */ private static final String DEFAULT_TENANT_ID = "000000"; + /** + * 登录请求携带租户的请求头名 + */ + private static final String TENANT_HEADER = "Tenant-Id"; + @Override public MpUser getByPhone(String tenantId, String phone) { if (Func.isBlank(tenantId) || Func.isBlank(phone)) { @@ -127,12 +134,12 @@ public class MpUserServiceImpl extends BaseServiceImpl imp user.setStatus(1); prepare(user); validate(user); - // 登录路径不设密码:密码留空,避免调用方误传弱口令 + // 登录路径不设密码,password 保持为空 user.setPassword(null); try { save(user); } catch (Exception exception) { - // 并发建号撞 (tenant_id, phone) 唯一键:重读一次返回,登录方不该因重试失败 + // 并发建号撞 (tenant_id, phone) 唯一键:重读一次并返回已有账号 MpUser concurrent = getByPhone(tenantId, phone); if (concurrent != null && Func.isNotEmpty(concurrent.getId())) { return concurrent; @@ -148,15 +155,15 @@ public class MpUserServiceImpl extends BaseServiceImpl imp } /** - * 登录路径的租户解析:入参 → 当前登录租户 → 默认租户 + * 登录路径的租户解析:Tenant-Id 头 → 入参 tenantId → 默认租户 *

- * 写库前必须得到非空值:唯一键 (tenant_id, phone) 对 NULL 不去重,租户为 NULL - * 会为同一手机号造出重复账号。 + * 请求头与入参不一致时以请求头为准。写库前必须得到非空值:唯一键 (tenant_id, phone) + * 对 NULL 不去重,租户为 NULL 会为同一手机号造出重复账号。 */ private String resolveEnsureTenantId(String tenantId) { - String resolved = trimToNull(tenantId); + String resolved = requestHeaderTenantId(); if (resolved == null) { - resolved = trimToNull(AuthUtil.getTenantId()); + resolved = trimToNull(tenantId); } if (resolved == null) { resolved = DEFAULT_TENANT_ID; @@ -164,6 +171,17 @@ public class MpUserServiceImpl extends BaseServiceImpl imp return resolved; } + /** + * 当前请求头里的租户;无请求上下文时返回 null + */ + private String requestHeaderTenantId() { + HttpServletRequest request = WebUtil.getRequest(); + if (request == null) { + return null; + } + return trimToNull(request.getHeader(TENANT_HEADER)); + } + @Override public MpUserInfo userInfo(Long userId) { if (Func.isEmpty(userId)) { @@ -319,8 +337,8 @@ public class MpUserServiceImpl extends BaseServiceImpl imp if (!tenantId.equals(user.getTenantId())) { throw new ServiceException("租户不匹配,无法绑定 openid"); } - // 目标账号已有另一个非空 openid 时不得覆盖:旧逻辑会静默顶掉上一个微信的绑定, - // 而一个微信换绑需要管理员确认,登录路径只放弃本次绑定(票 05) + // 目标账号已有另一个非空 openid 时不覆盖:一个微信换绑由管理员处理, + // 登录路径直接放弃本次绑定 if (Func.isNotBlank(user.getOpenid()) && !user.getOpenid().equals(openid)) { log.warn("小程序用户已绑定其他 openid,跳过绑定:租户={} 账号={} 已有openid={} 本次openid={}", tenantId, userId, user.getOpenid(), openid); From 77144f4e68476bcec11fd4212ec812055f04c553 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Fri, 23 Oct 2026 16:34:25 +0800 Subject: [PATCH 52/55] =?UTF-8?q?:bulb:=20=E6=B8=85=E7=90=86=E6=9C=AC?= =?UTF-8?q?=E6=AC=A1=E6=94=B9=E5=8A=A8=E6=B3=A8=E9=87=8A=E4=B8=AD=E7=9A=84?= =?UTF-8?q?=E7=A5=A8=E6=8D=AE=E5=BC=95=E7=94=A8=E4=B8=8E=E8=AE=BE=E8=AE=A1?= =?UTF-8?q?=E5=8F=99=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 注释只写从代码看不出来的事实:删除票号引用、设计权衡与取证经过, 保留业务约束(id_card_no 为 NULL 表示未采集、唯一索引覆盖软删除行、 driving_vehicle 为 ALWAYS 策略等)。 --- blade-auth/pom.xml | 4 ++-- .../auth/endpoint/Oauth2SmsEndpointTest.java | 6 +++--- .../service/impl/CertAuditServiceImpl.java | 12 +++++------- .../transport/service/impl/DriverServiceImpl.java | 14 +++++++------- .../service/impl/DriverEnsureProfileTest.java | 2 +- ...e_transport_driver_profile_account_20261023.sql | 6 +++--- 6 files changed, 21 insertions(+), 23 deletions(-) diff --git a/blade-auth/pom.xml b/blade-auth/pom.xml index a7bad3cf..2aa552e9 100644 --- a/blade-auth/pom.xml +++ b/blade-auth/pom.xml @@ -83,12 +83,12 @@ org.springblade blade-resource-api - + org.springblade blade-transport-api - + org.springframework.boot spring-boot-starter-test diff --git a/blade-auth/src/test/java/org/springblade/auth/endpoint/Oauth2SmsEndpointTest.java b/blade-auth/src/test/java/org/springblade/auth/endpoint/Oauth2SmsEndpointTest.java index 71f49fcf..0a363310 100644 --- a/blade-auth/src/test/java/org/springblade/auth/endpoint/Oauth2SmsEndpointTest.java +++ b/blade-auth/src/test/java/org/springblade/auth/endpoint/Oauth2SmsEndpointTest.java @@ -34,9 +34,9 @@ import static org.springblade.core.oauth2.constant.OAuth2TokenConstant.USER_PHON /** * 发码端点的端分流测试。 *

- * 守住票 13 的口径:小程序端(User-Type: mp)对未注册手机号也要发码, - * 否则新号走不到「验证码校验通过后自动建号」;Web 端保持拒绝。 - * 同时守住「发码不建号」:mp 分支根本不查账号。 + * 保住发码端点的分流口径:小程序端(User-Type: mp)对未注册手机号也发码, + * Web 端仍拒绝。 + * 同时保住「发码不建号」:mp 分支根本不查账号。 */ class Oauth2SmsEndpointTest { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java index cb634ce5..14fa3cbf 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java @@ -349,9 +349,8 @@ public class CertAuditServiceImpl implements ICertAuditService { if (Func.isEmpty(snapshot.getIdCardNo()) && Func.isEmpty(snapshot.getDriverFileNo())) { return; } - // 身份证号要落到档案上,先确认它没有落在别人名下:该列在基线建表脚本与本地库上 - // 有唯一索引 uk_transport_driver_id_card,但迁移历史里没有任何一次添加它的记录, - // 生产库是否已建未核实;应用层查重不能省,否则重复证件号会静默落库 + // 身份证号要落到档案上,先确认它没有落在别人名下:重复证件号一旦落库, + // 建档查重与按证件号识别同一人都会失效 requireIdCardNotTaken(snapshot.getIdCardNo(), driver.getId()); driver.setDriverName(snapshot.getRealName()); driver.setIdCardNo(snapshot.getIdCardNo()); @@ -384,10 +383,9 @@ public class CertAuditServiceImpl implements ICertAuditService { * 身份证号不得落在另一名司机名下 *

* 司机申报的实名内容会写回档案,其中身份证号是管理端建档时的查重判据。 - * 该列在基线建表脚本与本地库上有唯一索引 {@code uk_transport_driver_id_card}, - * 但迁移历史里没有任何一次添加它的记录,生产库是否已建未核实; - * 应用层查重仍不能省:重复证件号一旦落库,后续建档查重、 - * 按证件号识别同一人都将失效。此处阻断审核并整体回滚,交由管理端先处理档案归属。 + * {@code blade_transport_driver.id_card_no} 上有唯一索引 + * {@code uk_transport_driver_id_card};证件号已属于另一条档案时, + * 此处阻断审核并整体回滚,交由管理端先处理档案归属。 *

* 证件号与当前档案相同(绝大多数情况)、或库里尚无该号时不阻断; * 快照没带证件号时无从判断,同样不阻断(历史快照就是这种)。 diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java index 1a7cb6a0..bd200eb8 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java @@ -122,8 +122,8 @@ public class DriverServiceImpl extends BaseServiceImpl imp if (resolvedTenantId == null) { resolvedTenantId = trimToNull(AuthUtil.getTenantId()); } - // 租户是档案归属与手机号查重的范围,宁可让本次建档失败(登录层只记 WARN), - // 也不能写出 tenant_id 为空的档案——那会让下一条同号档案无法被查到 + // 租户是档案归属与手机号查重的范围,tenant_id 为空的档案不能被后续同号查询命中, + // 故此处拒绝写库(登录层只记 WARN) if (resolvedTenantId == null) { throw new ServiceException("租户信息为空,无法创建司机档案"); } @@ -134,8 +134,8 @@ public class DriverServiceImpl extends BaseServiceImpl imp if (mobile == null) { throw new ServiceException("手机号不能为空"); } - // 查找顺序固定为 user_id → phone:user_id 是稳定标识; - // phone 会被运营商二次放号,拿它优先会把别人档案绑给新持卡人 + // 查找顺序固定为 user_id → phone:user_id 是账号的稳定标识, + // phone 会被运营商二次放号 Driver bound = findDriverByUserId(mpUserId); if (bound != null) { return ensureResult(bound.getId(), false, false, null); @@ -169,10 +169,10 @@ public class DriverServiceImpl extends BaseServiceImpl imp } /** - * 新建占位档案(票 01 定值) + * 新建占位档案 *

- * 不走 {@link #submit}:那条路径要求 15 项必填,自动建档必然被拦。这里只满足表约束—— - * id_card_no 留 NULL(唯一索引对 NULL 不去重,且 NULL 是「未采集」的判据), + * 不走 {@link #submit}:那条路径要求 15 项必填。这里只满足表约束—— + * id_card_no 留 NULL(唯一索引对 NULL 不去重,NULL 同时是「未采集」的判据), * 其余 NOT NULL 列写空串或可读占位。 */ private DriverEnsureProfileVO createPlaceholderProfile(String tenantId, Long mpUserId, String mobile) { diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverEnsureProfileTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverEnsureProfileTest.java index 874fde21..24067280 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverEnsureProfileTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverEnsureProfileTest.java @@ -76,7 +76,7 @@ class DriverEnsureProfileTest { // ------------------------------------------------------------ 新建占位档案 @Test - @DisplayName("无账号档案、无同号档案:按票 01 的占位值新建,created=true") + @DisplayName("无账号档案、无同号档案:按占位值新建,created=true") void createsPlaceholderProfile() { // 第一次按 user_id 查、第二次按手机号查,都没有 when(driverMapper.selectList(any())).thenReturn(Collections.emptyList(), Collections.emptyList()); diff --git a/doc/sql/transport/blade_transport_driver_profile_account_20261023.sql b/doc/sql/transport/blade_transport_driver_profile_account_20261023.sql index ebb68af7..30d238d1 100644 --- a/doc/sql/transport/blade_transport_driver_profile_account_20261023.sql +++ b/doc/sql/transport/blade_transport_driver_profile_account_20261023.sql @@ -1,7 +1,7 @@ -- ---------------------------- -- 司机档案与登录账号打通(2026-10-23) -- ---------------------------- --- 三处结构变更,均在单据「07 数据模型迁移」内: +-- 三处结构变更: -- ① user_id 加唯一索引 uk_transport_driver_user —— 一个账号只能对应一条司机档案。 -- ② id_card_no 放宽为可空 —— NULL 表示「身份证号尚未采集」,是判断档案是否完善的口径。 -- uk_transport_driver_id_card 保留:MySQL 唯一索引对多行 NULL 不去重, @@ -11,7 +11,7 @@ -- NULL 表示改造前存量(来源未知,不臆造);account_bound_time 是档案与账号建立关联的时点。 -- -- 只做结构变更,不回填数据:存量档案的 profile_source / account_bound_time 一律保持 NULL。 --- 判定「档案是否采集」用 id_card_no IS NULL,不另加标记列(见票 01)。 +-- 判定「档案是否采集」用 id_card_no IS NULL,不另加标记列。 -- -- 幂等:每段先查 information_schema,条件已满足即跳过,重复执行不报错; -- 全部用 PREPARE 动态语句而非存储过程,因此不需要 DELIMITER——按分号逐句切分的客户端 @@ -20,7 +20,7 @@ -- 前置检查(user_id 有重复时,第 1 节会以 -- Duplicate entry '<值>' for key 'blade_transport_driver.uk_transport_driver_user' -- 失败并终止;此时本脚本对表零修改,先修数据再重跑): --- 唯一索引统计**全表**行,含 is_deleted = 1 的软删除档案。票面给的 is_deleted = 0 口径 +-- 唯一索引统计**全表**行,含 is_deleted = 1 的软删除档案:只按 is_deleted = 0 -- 只能发现活跃重复,软删除行之间的重复同样会让第 1 节失败: -- SELECT user_id, COUNT(*) AS dup_count FROM `blade_transport_driver` -- WHERE user_id IS NOT NULL From eed7d3c4d835272ab0c78a57f7916aeb4021e152 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Fri, 23 Oct 2026 17:17:48 +0800 Subject: [PATCH 53/55] =?UTF-8?q?:white=5Fcheck=5Fmark:=20=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=E8=87=AA=E5=8A=A8=E5=BB=BA=E6=A1=A3=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E7=9A=84=20selectOne=20=E6=A1=A9=EF=BC=8C=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E6=8C=89=20user=5Fid=20=E5=8F=8D=E6=9F=A5=E7=9A=84=E5=8F=AF?= =?UTF-8?q?=E8=AF=81=E4=BC=AA=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/impl/DriverEnsureProfileTest.java | 34 +++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverEnsureProfileTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverEnsureProfileTest.java index 24067280..4c013e4d 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverEnsureProfileTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverEnsureProfileTest.java @@ -29,6 +29,7 @@ import static org.junit.jupiter.api.Assertions.assertNull; 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.ArgumentMatchers.anyBoolean; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; @@ -78,8 +79,9 @@ class DriverEnsureProfileTest { @Test @DisplayName("无账号档案、无同号档案:按占位值新建,created=true") void createsPlaceholderProfile() { - // 第一次按 user_id 查、第二次按手机号查,都没有 - when(driverMapper.selectList(any())).thenReturn(Collections.emptyList(), Collections.emptyList()); + // 按 user_id 查(getOne → selectOne)与按手机号查(selectList)都没有 + stubUserLookup(); + when(driverMapper.selectList(any())).thenReturn(Collections.emptyList()); when(driverMapper.insert(any(Driver.class))).thenAnswer(invocation -> { Driver inserted = invocation.getArgument(0); inserted.setId(NEW_DRIVER_ID); @@ -118,7 +120,8 @@ class DriverEnsureProfileTest { @DisplayName("命中同号未绑定的档案:绑定并留痕,patch 必须带上 driving_vehicle 原值") void bindsExistingProfileAndKeepsDrivingVehicle() { Driver existing = driver(DRIVER_ID, null, PLATE); - when(driverMapper.selectList(any())).thenReturn(Collections.emptyList(), List.of(existing)); + stubUserLookup(); + when(driverMapper.selectList(any())).thenReturn(List.of(existing)); when(driverMapper.selectById(DRIVER_ID)).thenReturn(existing); when(driverMapper.updateById(any(Driver.class))).thenReturn(1); @@ -146,7 +149,7 @@ class DriverEnsureProfileTest { @Test @DisplayName("按 user_id 命中:直接返回该档案,不新建、不改手机号") void returnsExistingProfileForRepeatLogin() { - when(driverMapper.selectList(any())).thenReturn(List.of(driver(DRIVER_ID, MP_USER_ID, PLATE))); + stubUserLookup(driver(DRIVER_ID, MP_USER_ID, PLATE)); DriverEnsureProfileVO result = service.ensureProfile(TENANT_ID, MP_USER_ID, MOBILE); @@ -162,10 +165,8 @@ class DriverEnsureProfileTest { @DisplayName("并发重复建档撞唯一键:重查后按已存在的档案返回,不把异常抛给登录方") void toleratesConcurrentInsert() { Driver concurrent = driver(DRIVER_ID, MP_USER_ID, PLATE); - when(driverMapper.selectList(any())).thenReturn( - Collections.emptyList(), - Collections.emptyList(), - List.of(concurrent)); + stubUserLookup(null, concurrent); + when(driverMapper.selectList(any())).thenReturn(Collections.emptyList()); when(driverMapper.insert(any(Driver.class))).thenThrow(new DuplicateKeyException("uk_transport_driver_user")); DriverEnsureProfileVO result = service.ensureProfile(TENANT_ID, MP_USER_ID, MOBILE); @@ -180,8 +181,8 @@ class DriverEnsureProfileTest { @Test @DisplayName("同手机号多条档案:拒绝绑定也不新建,返回可读原因") void rejectsMultipleProfilesWithSameMobile() { + stubUserLookup(); when(driverMapper.selectList(any())).thenReturn( - Collections.emptyList(), List.of(driver(DRIVER_ID, null, PLATE), driver(9003L, null, ""))); DriverEnsureProfileVO result = service.ensureProfile(TENANT_ID, MP_USER_ID, MOBILE); @@ -197,8 +198,8 @@ class DriverEnsureProfileTest { @Test @DisplayName("同手机号档案已属于其他账号:不覆盖,返回可读原因") void rejectsMobileOwnedByAnotherAccount() { + stubUserLookup(); when(driverMapper.selectList(any())).thenReturn( - Collections.emptyList(), List.of(driver(DRIVER_ID, OTHER_USER_ID, PLATE))); DriverEnsureProfileVO result = service.ensureProfile(TENANT_ID, MP_USER_ID, MOBILE); @@ -226,6 +227,19 @@ class DriverEnsureProfileTest { // ------------------------------------------------------------ 夹具 + /** + * 按 user_id 反查走 {@code getOne} → {@code baseMapper.selectOne};MyBatis-Plus 同时提供 + * 单参与带 throwEx 两个重载,桩必须两个都打,否则 mock 返回默认值、反查永远落空。 + */ + private void stubUserLookup() { + stubUserLookup((Driver) null); + } + + private void stubUserLookup(Driver first, Driver... rest) { + when(driverMapper.selectOne(any())).thenReturn(first, rest); + when(driverMapper.selectOne(any(), anyBoolean())).thenReturn(first, rest); + } + private Driver driver(Long id, Long userId, String drivingVehicle) { Driver driver = new Driver(); driver.setId(id); From 7fa20747ab25697b8cb81ddc419c35318929af33 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Fri, 23 Oct 2026 18:05:20 +0800 Subject: [PATCH 54/55] =?UTF-8?q?:bug:=20=E4=BF=AE=E5=A4=8D=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E8=87=AA=E5=8A=A8=E5=BB=BA=E6=A1=A3=E6=8A=8A=E8=84=B1?= =?UTF-8?q?=E6=95=8F=E6=89=8B=E6=9C=BA=E5=8F=B7=E5=86=99=E5=85=A5=E5=8F=B8?= =?UTF-8?q?=E6=9C=BA=E6=A1=A3=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/support/MpLoginSupport.java | 62 +++++++++---------- .../auth/support/MpLoginSupportTest.java | 21 ++++++- .../service/impl/DriverServiceImpl.java | 4 ++ .../service/impl/DriverEnsureProfileTest.java | 8 +++ 4 files changed, 62 insertions(+), 33 deletions(-) diff --git a/blade-auth/src/main/java/org/springblade/auth/support/MpLoginSupport.java b/blade-auth/src/main/java/org/springblade/auth/support/MpLoginSupport.java index 1a48b334..ab28cd7f 100644 --- a/blade-auth/src/main/java/org/springblade/auth/support/MpLoginSupport.java +++ b/blade-auth/src/main/java/org/springblade/auth/support/MpLoginSupport.java @@ -98,30 +98,30 @@ public class MpLoginSupport { account = ensureUser(tenantId, phone, openid); } else { account = openidAccount; - if (!samePhone(openidAccount.getPhone(), phone)) { - MpUser phoneAccount = findByPhone(tenantId, phone); - if (phoneAccount != null && !Objects.equals(phoneAccount.getId(), openidAccount.getId())) { - // ①-撞键:落到手机号账号,不迁移 openid - account = phoneAccount; - loginNotice = NOTICE_PHONE_OCCUPIED; - openidOccupied = true; - log.warn("微信换号撞键,登录落到手机号账号:租户={} openid账号={} 手机号账号={}", - tenantId, openidAccount.getId(), phoneAccount.getId()); - } else { - account = movePhone(openidAccount, phone); - if (account == null) { - // 更新手机号失败:并发时可能是手机号刚被别的账号占用,重查一次再决定落点 - MpUser concurrent = findByPhone(tenantId, phone); - if (concurrent != null && !Objects.equals(concurrent.getId(), openidAccount.getId())) { - account = concurrent; - loginNotice = NOTICE_PHONE_OCCUPIED; - openidOccupied = true; - log.warn("更新手机号失败后命中手机号账号:租户={} openid账号={} 手机号账号={}", - tenantId, openidAccount.getId(), concurrent.getId()); - } else { - // 仍然用 openid 账号登录:手机号未能更新不影响身份识别,下次登录再试 - account = openidAccount; - } + // 账号 phone 经 feign 下发时是脱敏值(MpUser.phone 标了 @Sensitive),不能拿来比较。 + // 改按「该手机号是否已有账号」判断:命中自己是同一个人、命中别人是撞键、没命中就换号。 + MpUser phoneAccount = findByPhone(tenantId, phone); + if (phoneAccount != null && !Objects.equals(phoneAccount.getId(), openidAccount.getId())) { + // ①-撞键:落到手机号账号,不迁移 openid + account = phoneAccount; + loginNotice = NOTICE_PHONE_OCCUPIED; + openidOccupied = true; + log.warn("微信换号撞键,登录落到手机号账号:租户={} openid账号={} 手机号账号={}", + tenantId, openidAccount.getId(), phoneAccount.getId()); + } else if (phoneAccount == null) { + account = movePhone(openidAccount, phone); + if (account == null) { + // 更新手机号失败:并发时可能是手机号刚被别的账号占用,重查一次再决定落点 + MpUser concurrent = findByPhone(tenantId, phone); + if (concurrent != null && !Objects.equals(concurrent.getId(), openidAccount.getId())) { + account = concurrent; + loginNotice = NOTICE_PHONE_OCCUPIED; + openidOccupied = true; + log.warn("更新手机号失败后命中手机号账号:租户={} openid账号={} 手机号账号={}", + tenantId, openidAccount.getId(), concurrent.getId()); + } else { + // 仍然用 openid 账号登录:手机号未能更新不影响身份识别,下次登录再试 + account = openidAccount; } } } @@ -227,10 +227,14 @@ public class MpLoginSupport { if (account == null || !MpUserIdentity.has(account.getIdentities(), MpUserIdentity.DRIVER)) { return null; } - // 租户与手机号取账号上的值:档案必须与账号落同一租户(唯一键对 NULL 不去重); - // 账号手机号未能更新时,不使用入参手机号建档 + // 租户取账号上的值(档案必须与账号同租户,唯一键对 NULL 不去重); + // 手机号只能取登录入参:账号 phone 经 feign 下发时是脱敏值,写进档案会让按手机号搜不到司机 String tenantId = Func.toStr(account.getTenantId(), requestedTenantId); - String phone = Func.toStr(account.getPhone(), requestedPhone); + String phone = trimToNull(requestedPhone); + if (phone == null) { + log.warn("登录自动建档跳过:登录手机号为空,租户={} 账号={}", tenantId, account.getId()); + return null; + } DriverEnsureProfileDTO param = new DriverEnsureProfileDTO(); param.setTenantId(tenantId); param.setMpUserId(account.getId()); @@ -316,10 +320,6 @@ public class MpLoginSupport { userInfo.setDetail(detail); } - private boolean samePhone(String left, String right) { - return Objects.equals(trimToNull(left), trimToNull(right)); - } - private String trimToNull(String value) { if (value == null) { return null; diff --git a/blade-auth/src/test/java/org/springblade/auth/support/MpLoginSupportTest.java b/blade-auth/src/test/java/org/springblade/auth/support/MpLoginSupportTest.java index 4518e344..6d09b316 100644 --- a/blade-auth/src/test/java/org/springblade/auth/support/MpLoginSupportTest.java +++ b/blade-auth/src/test/java/org/springblade/auth/support/MpLoginSupportTest.java @@ -59,7 +59,8 @@ class MpLoginSupportTest { @Test @DisplayName("新手机号微信登录:建号 + 建档,登录成功且无提示") void newPhoneCreatesAccountAndProfile() { - MpUser created = mpUser(11L, PHONE, OPENID, "driver"); + // feign 下发时 phone 是脱敏值(MpUser.phone 标了 @Sensitive),建档必须仍用登录入参的真实号 + MpUser created = mpUser(11L, "138****0001", OPENID, "driver"); when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(null)); when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.data(created)); when(mpUserClient.userInfoById(11L)).thenReturn(R.data(userInfo(created, "driver"))); @@ -154,7 +155,7 @@ class MpLoginSupportTest { @Test @DisplayName("openid 命中且手机号不同但新号空闲:更新账号手机号") void openidHitWithFreePhoneUpdatesPhone() { - MpUser openidAccount = mpUser(31L, "13900000000", OPENID, "driver"); + MpUser openidAccount = mpUser(31L, "139****0000", OPENID, "driver"); when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(openidAccount)); when(mpUserClient.userByPhone(TENANT_ID, NEW_PHONE)).thenReturn(R.data(null)); when(mpUserClient.updateUser(any(MpUser.class))).thenReturn(R.data(Boolean.TRUE)); @@ -171,6 +172,22 @@ class MpLoginSupportTest { verify(mpUserClient, never()).ensureUser(any(MpUserEnsureDTO.class)); } + @Test + @DisplayName("openid 命中且该手机号就是本账号:不重复更新手机号") + void openidHitSameAccountDoesNotUpdatePhone() { + MpUser openidAccount = mpUser(32L, "138****0001", OPENID, "driver"); + when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(openidAccount)); + when(mpUserClient.userByPhone(TENANT_ID, PHONE)).thenReturn(R.data(openidAccount)); + when(mpUserClient.userInfoById(32L)).thenReturn(R.data(userInfo(openidAccount, "driver"))); + when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult())); + + OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID); + + assertEquals("32", user.getUserId()); + verify(mpUserClient, never()).updateUser(any(MpUser.class)); + verify(mpUserClient, never()).ensureUser(any(MpUserEnsureDTO.class)); + } + @Test @DisplayName("纯 admin 身份:不建司机档案") void adminOnlyDoesNotCreateProfile() { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java index bd200eb8..2ca4447d 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java @@ -134,6 +134,10 @@ public class DriverServiceImpl extends BaseServiceImpl imp if (mobile == null) { throw new ServiceException("手机号不能为空"); } + // 脱敏手机号(如 155****2748)不是可用的业务数据:写进档案后按手机号查不到司机 + if (mobile.contains("*")) { + throw new ServiceException("手机号无效,不能写入司机档案"); + } // 查找顺序固定为 user_id → phone:user_id 是账号的稳定标识, // phone 会被运营商二次放号 Driver bound = findDriverByUserId(mpUserId); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverEnsureProfileTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverEnsureProfileTest.java index 4c013e4d..d029c31b 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverEnsureProfileTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverEnsureProfileTest.java @@ -214,6 +214,14 @@ class DriverEnsureProfileTest { // ------------------------------------------------------------ 入参兜底 + @Test + @DisplayName("脱敏手机号:拒绝写库,不新建档案") + void rejectsMaskedMobile() { + assertThrows(ServiceException.class, () -> service.ensureProfile(TENANT_ID, MP_USER_ID, "155****2748")); + verify(driverMapper, never()).insert(any(Driver.class)); + verify(driverMapper, never()).updateById(any(Driver.class)); + } + @Test @DisplayName("租户为空且当前上下文也没有租户:拒绝写库") void rejectsBlankTenant() { From 70b2096e8a5013b75942f82811acb6d150cf8032 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Fri, 23 Oct 2026 20:03:56 +0800 Subject: [PATCH 55/55] =?UTF-8?q?:sparkles:=20=E7=99=BB=E5=BD=95=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=BB=BA=E6=A1=A3=E5=86=99=E5=85=A5=E5=B2=97=E4=BD=8D?= =?UTF-8?q?=E5=B9=B6=E8=AE=A2=E6=AD=A3=E5=AD=98=E9=87=8F=E7=A9=BA=E5=B2=97?= =?UTF-8?q?=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - createPlaceholderProfile 写 posts='司机':岗位为空的档案会被派单候选筛掉 - 新增幂等订正脚本,只补 profile_source='login_auto' 且岗位为空的档案 - update_time 不覆盖,脚本内附校验与回退段 --- .../service/impl/DriverServiceImpl.java | 6 ++ .../service/impl/DriverEnsureProfileTest.java | 1 + ...ansport_driver_posts_backfill_20261023.sql | 58 +++++++++++++++++++ 3 files changed, 65 insertions(+) create mode 100644 doc/sql/transport/blade_transport_driver_posts_backfill_20261023.sql diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java index 2ca4447d..41964813 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java @@ -86,6 +86,11 @@ public class DriverServiceImpl extends BaseServiceImpl imp * 档案来源:绑定到已有档案 */ private static final String PROFILE_SOURCE_LOGIN_BOUND = "login_bound"; + /** + * 自动建档的默认岗位:从小程序司机端登录即视为司机。 + * 岗位为空会让档案被派单候选筛掉。 + */ + private static final String PROFILE_DEFAULT_POST = "司机"; private static final String CONFLICT_MOBILE_OCCUPIED = "该手机号已被其他账号占用,请联系管理员"; private static final String CONFLICT_MOBILE_MULTIPLE = "该手机号存在多条司机档案,请联系管理员核对"; @@ -187,6 +192,7 @@ public class DriverServiceImpl extends BaseServiceImpl imp driver.setMobile(mobile); driver.setDrivingType(""); driver.setDriverType(""); + driver.setPosts(PROFILE_DEFAULT_POST); driver.setOrganizationName(""); driver.setEmergencyContactName(""); driver.setEmergencyContactMobile(""); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverEnsureProfileTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverEnsureProfileTest.java index d029c31b..e7180cdc 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverEnsureProfileTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverEnsureProfileTest.java @@ -106,6 +106,7 @@ class DriverEnsureProfileTest { assertEquals("", saved.getDrivingType()); assertEquals("", saved.getDriverType()); assertEquals("", saved.getOrganizationName()); + assertEquals("司机", saved.getPosts(), "岗位为空会被派单候选的 posts 过滤筛掉,新注册司机就选不到"); assertEquals("", saved.getEmergencyContactName()); assertEquals("", saved.getEmergencyContactMobile()); assertEquals(1, saved.getStatus()); diff --git a/doc/sql/transport/blade_transport_driver_posts_backfill_20261023.sql b/doc/sql/transport/blade_transport_driver_posts_backfill_20261023.sql new file mode 100644 index 00000000..3127569c --- /dev/null +++ b/doc/sql/transport/blade_transport_driver_posts_backfill_20261023.sql @@ -0,0 +1,58 @@ +-- ---------------------------- +-- 登录自动建档的司机档案补岗位 +-- 写业务数据。执行前先做第 0 节备份,执行后跑第 3 节校验。可重复执行。 +-- ---------------------------- +-- +-- 为什么要订正: +-- 派单候选司机按岗位过滤(posts LIKE '%司机%'),岗位为空的档案选不到。 +-- 小程序登录自动建档原先只写了手机号、租户、user_id,posts 留空, +-- 于是新注册的司机在运单派单里搜不到。建档逻辑已同步改为写岗位(PROFILE_DEFAULT_POST)。 +-- +-- 处置口径:只补「登录自动建档」的档案,条件是 profile_source = 'login_auto' 且岗位为空。 +-- 手工建档的档案岗位由管理端必填,为空说明被人主动清空过,不在本次订正范围。 +-- update_time 不在 SET 里,保留原值。 +-- ---------------------------- +-- 0. 备份(必须在订正之前执行,仅首次需要) +-- ---------------------------- +-- CREATE TABLE blade_transport_driver_posts_bak AS +-- SELECT id, tenant_id, driver_name, mobile, posts, profile_source, update_time +-- FROM blade_transport_driver +-- WHERE is_deleted = 0 +-- AND profile_source = 'login_auto' +-- AND (posts IS NULL OR posts = ''); + +START TRANSACTION; + +-- ---------------------------- +-- 1. 订正岗位 +-- ---------------------------- +UPDATE `blade_transport_driver` +SET `posts` = '司机' +WHERE `is_deleted` = 0 + AND `profile_source` = 'login_auto' + AND (`posts` IS NULL OR `posts` = ''); + +-- ---------------------------- +-- 2. 提交 +-- ---------------------------- +COMMIT; + +-- ---------------------------- +-- 3. 校验(两行都应为 0) +-- ---------------------------- +-- SELECT COUNT(1) AS remaining_empty +-- FROM blade_transport_driver +-- WHERE is_deleted = 0 AND profile_source = 'login_auto' AND (posts IS NULL OR posts = ''); +-- +-- SELECT COUNT(1) AS touched_overlap +-- FROM blade_transport_driver +-- WHERE is_deleted = 0 AND profile_source = 'login_auto' AND posts IS NOT NULL AND posts <> '司机'; + +-- ---------------------------- +-- 4. 回退(仅在需要撤销本次订正时手工执行;执行前确认这些行确实是本次改的) +-- ---------------------------- +-- UPDATE `blade_transport_driver` +-- SET `posts` = NULL +-- WHERE `is_deleted` = 0 +-- AND `profile_source` = 'login_auto' +-- AND `posts` = '司机';