From 6fecc262b1182d66d79fcc36d34d117bef02f7a9 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Tue, 13 Oct 2026 13:54:51 +0800 Subject: [PATCH 001/108] =?UTF-8?q?:sparkles:=20=E5=8F=B8=E6=9C=BA?= =?UTF-8?q?=E7=AB=AF=E8=BF=90=E5=8D=95=E5=8D=A1=E7=89=87=E8=A1=A5=E8=BF=90?= =?UTF-8?q?=E8=BE=93=E7=BB=84=E7=BB=87=E7=B1=BB=E5=9E=8B=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DriverWaybillCardVO 加 transportOrgType,toCard 按运单是否带配载单号判定 common/load;司机端详情、首页当前任务与列表共用该出口 --- .../springblade/transport/pojo/vo/DriverWaybillCardVO.java | 3 +++ .../transport/service/impl/DriverWaybillServiceImpl.java | 5 +++++ 2 files changed, 8 insertions(+) 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 57e394d1..323db157 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 @@ -150,6 +150,9 @@ public class DriverWaybillCardVO implements Serializable { @Schema(description = "运输方式文案:公路运输等") private String transportType; + @Schema(description = "运输组织:common普通 / load配载") + private String transportOrgType; + @Schema(description = "计划发货时间") private String planShipTime; 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 9158ce0d..0a06a6b0 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 @@ -102,6 +102,10 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { private static final String STATUS_COMPLETED = "completed"; private static final String STATUS_CANCELLED = "cancelled"; + /** 运输组织类型:运单有配载单号即配载(load),否则普通(common) */ + private static final String TRANSPORT_COMMON = "common"; + private static final String TRANSPORT_LOAD = "load"; + /** 小程序「待接单」对应的后端业务状态 */ private static final List PENDING_STATUSES = Arrays.asList( STATUS_PENDING, STATUS_WAITING_DISPATCH, STATUS_DISPATCHING @@ -734,6 +738,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { card.setCargoQuantity(weightText); card.setTotalWeight(weightText); card.setTransportType(toTransportTypeLabel(waybill.getTransportType())); + card.setTransportOrgType(Func.isNotBlank(waybill.getLoadingNo()) ? TRANSPORT_LOAD : TRANSPORT_COMMON); card.setPlanShipTime(formatLocalDateYmd(waybill.getEstimatedStartTime())); card.setPlanFinishTime(formatLocalDateYmd(waybill.getEstimatedEndTime())); card.setRemark(Func.toStr(waybill.getRemark(), "")); From 8f11bc70ec6e98694ec8ef66fd342303d04e34bf Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Tue, 13 Oct 2026 14:12:31 +0800 Subject: [PATCH 002/108] =?UTF-8?q?:bug:=20=E6=81=A2=E5=A4=8D=E5=90=88?= =?UTF-8?q?=E5=B9=B6=E6=97=B6=E4=B8=A2=E5=A4=B1=E7=9A=84=E5=8F=B8=E6=9C=BA?= =?UTF-8?q?=E7=AB=AF=E8=87=AA=E5=8A=A8=E5=AE=8C=E6=88=90=E3=80=81=E5=9C=B0?= =?UTF-8?q?=E7=82=B9=E5=9B=9E=E9=80=80=E4=B8=8E=E6=8B=92=E5=8D=95=E8=84=B1?= =?UTF-8?q?=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 合并 websoft/dev 解冲突时 DriverWaybillServiceImpl 整段退回旧版,丢掉三处功能,按原提交补回: - 末位打卡是到货/卸货/签收且回单无需确认时自动完成运单 - 导入运单只有详细地址、没有地点名称时回退显示地址 - 司机拒单后该运单对其列表/计数/详情/打卡/接单全部不可达 归属校验测试原按手机号匹配运单,与打卡、接单的车牌口径冲突,改为按司机档案绑定的车牌断言。 --- .../impl/DriverWaybillServiceImpl.java | 85 +++++++++++++++---- .../impl/DriverWaybillOwnershipTest.java | 41 ++++----- 2 files changed, 90 insertions(+), 36 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 0a06a6b0..967738bd 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 @@ -114,6 +114,10 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { private static final List TAB_ALL_STATUSES = Arrays.asList( STATUS_PENDING, STATUS_WAITING_DISPATCH, STATUS_DISPATCHING, STATUS_RUNNING, STATUS_COMPLETED ); + /** 允许完成运单的业务状态 */ + private static final List COMPLETABLE_STATUSES = Arrays.asList( + STATUS_RUNNING, STATUS_PENDING, STATUS_WAITING_DISPATCH, STATUS_DISPATCHING + ); private static final int DEFAULT_PREVIEW_SIZE = 2; private static final int MAX_PREVIEW_SIZE = 20; @@ -313,7 +317,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { throw new ServiceException("运单ID不能为空"); } Driver driver = requireCurrentDriver(); - Waybill waybill = loadDriverWaybill(dto.getWaybillId(), currentBoundPlates(driver)); + // 在途打卡单独保留原取单判据,不排除已拒单(见 scopedQuery 的重载说明) + Waybill waybill = loadDriverWaybill(dto.getWaybillId(), currentBoundPlates(driver), true); waybill = normalizeAcceptStatus(waybill); String processJson = resolveProcessJson(waybill); @@ -415,7 +420,20 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { nodePunchMapper.insert(punch); advanceCurrentProcessNode(waybill, nodeCfg); - return toNodePunchVO(punch); + // 末位打卡节点是到货/卸货/签收、且回单无需确认完成时,本次打卡一并把运单置为完成; + // isLastDriverPunchNode 与 returnNeedsNoConfirmation 传的就是上面那份 processJson,未再调 resolveProcessJson 取过程配置。 + String nodeKey = WaybillProcessSupport.nodeKey(nodeCfg); + String nodeName = WaybillProcessSupport.nodeName(nodeCfg); + boolean completed = false; + if (WaybillProcessSupport.isLastDriverPunchNode(processJson, nodeKey) + && WaybillProcessSupport.isDeliveryNode(nodeKey, nodeName) + && WaybillProcessSupport.returnNeedsNoConfirmation(processJson) + && canAutoCompleteWaybill(waybill)) { + completed = waybillService.completeWithoutDeptCheck(waybill.getId()); + } + DriverNodePunchVO vo = toNodePunchVO(punch); + vo.setWaybillCompleted(completed); + return vo; } @Override @@ -502,10 +520,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { Driver driver = requireCurrentDriver(); Waybill waybill = loadDriverWaybill(id, currentBoundPlates(driver)); waybill = normalizeAcceptStatus(waybill); - if (!STATUS_RUNNING.equals(waybill.getBusinessStatus()) - && !STATUS_PENDING.equals(waybill.getBusinessStatus()) - && !STATUS_WAITING_DISPATCH.equals(waybill.getBusinessStatus()) - && !STATUS_DISPATCHING.equals(waybill.getBusinessStatus())) { + if (!COMPLETABLE_STATUSES.contains(waybill.getBusinessStatus())) { throw new ServiceException("当前运单状态不允许完成"); } // 有未了结异常时不允许完成——完成接口不要求节点全部打卡,不挡住就能绕过打卡门禁 @@ -524,6 +539,10 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { if (WaybillProcessSupport.isAccepted(waybill.getDriverAcceptStatus())) { throw new ServiceException("该运单已接单"); } + // 拒单后运单已对本人脱离,不给自己接回来的入口;要改主意只能由调度重新派单 + if (WaybillProcessSupport.isRejected(waybill.getDriverAcceptStatus())) { + throw new ServiceException("该运单已拒单,请联系调度重新派单"); + } } private void assertRejectable(Waybill waybill) { @@ -539,13 +558,19 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } private Waybill loadDriverWaybill(Long id, List plates) { + return loadDriverWaybill(id, plates, false); + } + + /** @param includeRejected 仅 submitEnroute 传 true,见 scopedQuery 的重载说明 */ + private Waybill loadDriverWaybill(Long id, List plates, boolean includeRejected) { if (id == null) { throw new ServiceException("运单ID不能为空"); } if (plates.isEmpty()) { throw new ServiceException("当前司机未绑定车辆"); } - Waybill waybill = waybillService.getOne(scopedQuery(plates).eq(Waybill::getId, id).last("LIMIT 1")); + Waybill waybill = waybillService.getOne(scopedQuery(plates, includeRejected) + .eq(Waybill::getId, id).last("LIMIT 1")); if (waybill == null) { throw new ServiceException("运单不存在或无权操作"); } @@ -577,8 +602,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } switch (status) { case 0 -> wrapper.in(Waybill::getBusinessStatus, PENDING_STATUSES); - case 1 -> wrapper.in(Waybill::getBusinessStatus, List.of(STATUS_RUNNING, STATUS_PENDING, - STATUS_WAITING_DISPATCH, STATUS_DISPATCHING)); + case 1 -> wrapper.in(Waybill::getBusinessStatus, COMPLETABLE_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); @@ -602,11 +626,23 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } /** - * 司机可见运单范围:运单车牌(主车/挂车)落在当前司机绑定车牌内。 - * 绑定来源:blade_transport_driver.driving_vehicle + * 司机可见运单范围:运单车牌(主车/挂车)落在当前司机绑定车牌内, + * 且该运单未被这个司机拒单。绑定来源:blade_transport_driver.driving_vehicle + *

+ * 拒单后运单对司机彻底脱离——列表、计数、详情、打卡、接单一律不可达,等管理员重新派单。 + * 运单上的司机姓名/手机/车牌不清空,调度重新派单时要知道原来派给谁。 */ private LambdaQueryWrapper scopedQuery(List plates) { - return Wrappers.lambdaQuery().and(w -> { + return scopedQuery(plates, false); + } + + /** + * @param includeRejected true 时不排除已拒单的运单,仅限在途打卡使用: + * 拒单的运单不可能处于运输中,收口拦不到合法操作, + * 而保留原判据可避免「已拒单 + 进行中」的历史脏数据被误伤 + */ + private LambdaQueryWrapper scopedQuery(List plates, boolean includeRejected) { + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery().and(w -> { w.in(Waybill::getVehicleNo, plates) .or().in(Waybill::getTrailerVehicleNo, plates); for (String plate : plates) { @@ -620,6 +656,11 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { ); } }); + if (!includeRejected) { + wrapper.and(w -> w.isNull(Waybill::getDriverAcceptStatus) + .or().ne(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED)); + } + return wrapper; } private void applyKeyword(LambdaQueryWrapper wrapper, String keyword) { @@ -707,10 +748,12 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { DriverWaybillCardVO card = new DriverWaybillCardVO(); card.setId(waybill.getId()); card.setWaybillNo(waybill.getWaybillNo()); - card.setFromName(Func.toStr(waybill.getDepartureName(), "")); - card.setToName(Func.toStr(waybill.getArrivalName(), "")); - card.setFromAddress(Func.toStr(waybill.getDepartureAddress(), card.getFromName())); - card.setToAddress(Func.toStr(waybill.getArrivalAddress(), card.getToName())); + // 批量导入的运单只有发货/到货地址、没有地点名称,名称为空时回退到地址, + // 否则司机端首页、运单列表与打卡页的地点位置都是空的 + card.setFromName(Func.toStrWithEmpty(waybill.getDepartureName(), Func.toStr(waybill.getDepartureAddress(), ""))); + card.setToName(Func.toStrWithEmpty(waybill.getArrivalName(), Func.toStr(waybill.getArrivalAddress(), ""))); + card.setFromAddress(Func.toStrWithEmpty(waybill.getDepartureAddress(), card.getFromName())); + card.setToAddress(Func.toStrWithEmpty(waybill.getArrivalAddress(), card.getToName())); card.setCargoNames(splitCargoNames(waybill.getCargoName())); card.setCargoCategory(Func.toStr(waybill.getCargoType(), "")); card.setWeight(formatWeight(waybill.getQuantity(), waybill.getQuantityUnit())); @@ -1096,6 +1139,16 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { return countByType; } + /** + * 自动完成的前置:与司机端「完成运单」同一套——业务状态允许完成,且没有未了结异常。 + *

+ * 不满足返回 false,由调用方静默跳过,打卡不受影响。 + */ + private boolean canAutoCompleteWaybill(Waybill waybill) { + return COMPLETABLE_STATUSES.contains(waybill.getBusinessStatus()) + && unresolvedExceptionNodeCodeList(waybill.getId()).isEmpty(); + } + /** * 断言该运单没有未了结的异常。 *

diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java index 035a2db1..0a1a5df6 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java @@ -38,14 +38,14 @@ import static org.mockito.Mockito.when; /** * 司机对运单的归属校验(异常上报等按运单操作的前置条件)。 *

- * 口径:当前用户手机号匹配运单 {@code driverPhone}。 - * 管理端与调度员账号没有司机档案,会停在这里,这是刻意的:不提供代报。 + * 口径与司机打卡、司机接单一致:当前登录司机档案自述绑定的车牌({@code driving_vehicle}) + * 覆盖运单主车或挂车。管理端与调度员账号没有司机档案,会停在这里,这是刻意的:不提供代报。 */ class DriverWaybillOwnershipTest { private static final Long WAYBILL_ID = 5301L; private static final Long USER_ID = 7001L; - private static final String PHONE = "13800138000"; + private static final String PLATE = "沪AE31312"; private IDriverService driverService; private IWaybillService waybillService; @@ -88,21 +88,21 @@ class DriverWaybillOwnershipTest { } @Test - @DisplayName("司机档案与登录态都没有手机号时被拒") - void withoutPhoneIsRejected() { + @DisplayName("司机档案没有绑定车辆时被拒") + void withoutBoundVehicleIsRejected() { Driver driver = driver(null); when(driverService.getOne(any())).thenReturn(driver); ServiceException exception = assertThrows(ServiceException.class, () -> runOwnershipCheck(WAYBILL_ID)); - assertEquals("当前用户手机号为空,无法查找运单", exception.getMessage()); + assertEquals("当前司机未绑定车辆", exception.getMessage()); } @Test - @DisplayName("运单司机手机号与当前用户不一致时被拒") - void waybillOfAnotherPhoneIsRejected() { - when(driverService.getOne(any())).thenReturn(driver(PHONE)); + @DisplayName("运单车牌不在当前司机绑定车牌内时被拒") + void waybillOfAnotherVehicleIsRejected() { + when(driverService.getOne(any())).thenReturn(driver(PLATE)); when(waybillService.getOne(any())).thenReturn(null); ServiceException exception = assertThrows(ServiceException.class, @@ -112,9 +112,9 @@ class DriverWaybillOwnershipTest { } @Test - @DisplayName("本人手机号运单放行,且查询确实按司机手机号收敛") + @DisplayName("本人绑定车牌的运单放行,且查询确实按车牌收敛") void ownWaybillPasses() { - when(driverService.getOne(any())).thenReturn(driver(PHONE)); + when(driverService.getOne(any())).thenReturn(driver(PLATE)); when(waybillService.getOne(any())).thenReturn(waybill()); assertDoesNotThrow(() -> runOwnershipCheck(WAYBILL_ID)); @@ -122,17 +122,17 @@ class DriverWaybillOwnershipTest { ArgumentCaptor> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); verify(waybillService).getOne(captor.capture()); LambdaQueryWrapper wrapper = captor.getValue(); - assertTrue(wrapper.getTargetSql().contains("driver_phone"), - "归属查询必须落在运单司机手机号上:" + wrapper.getTargetSql()); + assertTrue(wrapper.getTargetSql().contains("vehicle_no"), + "归属查询必须落在运单主车/挂车车牌上:" + wrapper.getTargetSql()); assertTrue(wrapper.getParamNameValuePairs().values().stream() - .anyMatch(PHONE::equals), - "归属查询必须带上当前用户手机号:" + wrapper.getParamNameValuePairs()); + .anyMatch(PLATE::equals), + "归属查询必须带上当前司机绑定的车牌:" + wrapper.getParamNameValuePairs()); } @Test @DisplayName("运单ID为空时被拒") void withoutWaybillIdIsRejected() { - when(driverService.getOne(any())).thenReturn(driver(PHONE)); + when(driverService.getOne(any())).thenReturn(driver(PLATE)); ServiceException exception = assertThrows(ServiceException.class, () -> runOwnershipCheck(null)); @@ -145,17 +145,18 @@ class DriverWaybillOwnershipTest { private void runOwnershipCheck(Long waybillId) { try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); - authUtil.when(AuthUtil::getUserAccount).thenReturn(PHONE); + authUtil.when(AuthUtil::getUserAccount).thenReturn("13800138000"); service.assertCurrentDriverOwnsWaybill(waybillId); } } - private Driver driver(String mobile) { + private Driver driver(String drivingVehicle) { Driver driver = new Driver(); driver.setId(9001L); driver.setUserId(USER_ID); driver.setDriverName("韦司机"); - driver.setMobile(mobile); + driver.setMobile("13800138000"); + driver.setDrivingVehicle(drivingVehicle); return driver; } @@ -163,7 +164,7 @@ class DriverWaybillOwnershipTest { Waybill waybill = new Waybill(); waybill.setId(WAYBILL_ID); waybill.setWaybillNo("YD202610070001"); - waybill.setDriverPhone(PHONE); + waybill.setVehicleNo(PLATE); waybill.setIsDeleted(0); return waybill; } From 0eb5cc88a8e43793b864d63297090f67f2e8086d Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Tue, 13 Oct 2026 14:58:50 +0800 Subject: [PATCH 003/108] =?UTF-8?q?:zap:=20=E5=8F=B8=E6=9C=BA=E7=AB=AF?= =?UTF-8?q?=E8=BF=90=E5=8D=95=E5=BD=92=E5=B1=9E=E6=94=B9=E5=9B=9E=E6=89=8B?= =?UTF-8?q?=E6=9C=BA=E5=8F=B7=E5=8F=A3=E5=BE=84=EF=BC=8C=E5=8F=96=E6=B6=88?= =?UTF-8?q?=E8=BD=A6=E7=89=8C=E8=BF=87=E6=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 归属与取单范围按运单 driver_phone 匹配当前用户手机号(司机档案 mobile 优先,其次小程序账号),主车/挂车车牌模糊匹配整段删除。认证门槛仍按司机档案绑定车牌判定,拒单排除条件不变。 --- .../service/IDriverWaybillService.java | 4 +- .../impl/DriverWaybillServiceImpl.java | 116 +++++++++--------- .../impl/DriverNodePunchPhotoTest.java | 3 + .../impl/DriverWaybillAutoCompleteTest.java | 3 + .../impl/DriverWaybillOwnershipTest.java | 41 +++---- .../impl/DriverWaybillRejectDetachTest.java | 7 +- 6 files changed, 93 insertions(+), 81 deletions(-) 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 0d9d3f15..8f9cd52d 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 @@ -130,9 +130,9 @@ public interface IDriverWaybillService { boolean isNodePunched(Long waybillId, String nodeCode); /** - * 断言当前登录司机对该运单有归属:运单车牌须落在司机档案自述绑定的车牌内。 + * 断言当前登录司机对该运单有归属:运单司机手机号须等于当前用户手机号。 *

- * 无司机档案、未绑定车辆、车牌不匹配都会抛异常。管理端与调度员账号没有司机档案, + * 无司机档案、无手机号、手机号不匹配都会抛异常。管理端与调度员账号没有司机档案, * 会停在「未找到当前登录司机档案」——这是刻意的,不提供代报。 */ void assertCurrentDriverOwnsWaybill(Long waybillId); 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 967738bd..57c8ceb4 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 @@ -142,12 +142,12 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { @Override public DriverWaybillCardVO currentTask() { - List plates = currentBoundPlates(); - if (plates.isEmpty()) { + String phone = currentUserPhone(); + if (Func.isEmpty(phone)) { return null; } // 进行中:running,或无需确认接单但仍为 pending 的历史数据 - List candidates = waybillService.list(scopedQuery(plates) + List candidates = waybillService.list(scopedQuery(phone) .in(Waybill::getBusinessStatus, List.of(STATUS_RUNNING, STATUS_PENDING)) .orderByDesc(Waybill::getUpdateTime) .last("LIMIT 20")); @@ -163,12 +163,12 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { @Override public DriverWaybillPreviewVO pendingPreview(Integer size) { DriverWaybillPreviewVO preview = new DriverWaybillPreviewVO(); - List plates = currentBoundPlates(); - if (plates.isEmpty()) { + String phone = currentUserPhone(); + if (Func.isEmpty(phone)) { return preview; } int limit = normalizePreviewSize(size); - List pendingList = waybillService.list(scopedQuery(plates) + List pendingList = waybillService.list(scopedQuery(phone) .in(Waybill::getBusinessStatus, PENDING_STATUSES) .orderByDesc(Waybill::getCreateTime)); List needAccept = pendingList.stream() @@ -183,11 +183,11 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { @Override public DriverWaybillTabCountsVO tabCounts() { DriverWaybillTabCountsVO vo = new DriverWaybillTabCountsVO(); - List plates = currentBoundPlates(); - if (plates.isEmpty()) { + String phone = currentUserPhone(); + if (Func.isEmpty(phone)) { return vo; } - List waybills = waybillService.list(scopedQuery(plates) + List waybills = waybillService.list(scopedQuery(phone) .in(Waybill::getBusinessStatus, TAB_ALL_STATUSES)); long pending = 0; long doing = 0; @@ -219,12 +219,12 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { int pageSize = size == null || size < 1 ? DEFAULT_PAGE_SIZE : Math.min(size, MAX_PAGE_SIZE); Page empty = new Page<>(pageNo, pageSize); - List plates = currentBoundPlates(); - if (plates.isEmpty()) { + String phone = currentUserPhone(); + if (Func.isEmpty(phone)) { return empty; } - LambdaQueryWrapper wrapper = scopedQuery(plates); + LambdaQueryWrapper wrapper = scopedQuery(phone); // 先按 Tab 口径拉候选,再按过程配置校正 pending→running 后内存分页 applyStatusFilterForQuery(wrapper, status); applyKeyword(wrapper, keyword); @@ -247,15 +247,15 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { @Override public DriverWaybillCardVO detail(Long id) { - Driver driver = requireCurrentDriver(); - Waybill waybill = loadDriverWaybill(id, currentBoundPlates(driver)); + requireCurrentDriver(); + Waybill waybill = loadDriverWaybill(id); return toCard(normalizeAcceptStatus(waybill), true); } @Override public WaybillTrackVO track(Long id) { - Driver driver = requireCurrentDriver(); - Waybill waybill = loadDriverWaybill(id, currentBoundPlates(driver)); + requireCurrentDriver(); + Waybill waybill = loadDriverWaybill(id); LocalDate[] range = resolveTrackDateRange(waybill); return waybillService.trackVehicle(waybill.getId(), range[0].toString(), range[1].toString()); } @@ -318,7 +318,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } Driver driver = requireCurrentDriver(); // 在途打卡单独保留原取单判据,不排除已拒单(见 scopedQuery 的重载说明) - Waybill waybill = loadDriverWaybill(dto.getWaybillId(), currentBoundPlates(driver), true); + Waybill waybill = loadDriverWaybill(dto.getWaybillId(), true); waybill = normalizeAcceptStatus(waybill); String processJson = resolveProcessJson(waybill); @@ -367,7 +367,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { throw new ServiceException("节点编码不能为空"); } Driver driver = requireCurrentDriver(); - Waybill waybill = loadDriverWaybill(dto.getWaybillId(), currentBoundPlates(driver)); + Waybill waybill = loadDriverWaybill(dto.getWaybillId()); waybill = normalizeAcceptStatus(waybill); String processJson = resolveProcessJson(waybill); @@ -443,7 +443,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { // 认证门槛:司机本人须已认证,且承运车辆须已认证。 // 判据是各自持有的生效快照指针,故「已认证的车重新提交认证」不会拦下接单。 assertCertified(driver); - Waybill waybill = loadDriverWaybill(id, currentBoundPlates(driver)); + Waybill waybill = loadDriverWaybill(id); waybillService.syncDriverAcceptState(waybill); assertAcceptable(waybill); Date now = new Date(); @@ -493,8 +493,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { @Override @Transactional(rollbackFor = Exception.class) public boolean reject(Long id, String reason) { - Driver driver = requireCurrentDriver(); - Waybill waybill = loadDriverWaybill(id, currentBoundPlates(driver)); + requireCurrentDriver(); + Waybill waybill = loadDriverWaybill(id); waybillService.syncDriverAcceptState(waybill); assertRejectable(waybill); String rejectReason = Func.isEmpty(reason) ? null : reason.trim(); @@ -517,8 +517,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { if (id == null) { throw new ServiceException("运单ID不能为空"); } - Driver driver = requireCurrentDriver(); - Waybill waybill = loadDriverWaybill(id, currentBoundPlates(driver)); + requireCurrentDriver(); + Waybill waybill = loadDriverWaybill(id); waybill = normalizeAcceptStatus(waybill); if (!COMPLETABLE_STATUSES.contains(waybill.getBusinessStatus())) { throw new ServiceException("当前运单状态不允许完成"); @@ -557,19 +557,20 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } } - private Waybill loadDriverWaybill(Long id, List plates) { - return loadDriverWaybill(id, plates, false); + private Waybill loadDriverWaybill(Long id) { + return loadDriverWaybill(id, false); } /** @param includeRejected 仅 submitEnroute 传 true,见 scopedQuery 的重载说明 */ - private Waybill loadDriverWaybill(Long id, List plates, boolean includeRejected) { + private Waybill loadDriverWaybill(Long id, boolean includeRejected) { if (id == null) { throw new ServiceException("运单ID不能为空"); } - if (plates.isEmpty()) { - throw new ServiceException("当前司机未绑定车辆"); + String phone = currentUserPhone(); + if (Func.isEmpty(phone)) { + throw new ServiceException("当前用户手机号为空,无法查找运单"); } - Waybill waybill = waybillService.getOne(scopedQuery(plates, includeRejected) + Waybill waybill = waybillService.getOne(scopedQuery(phone, includeRejected) .eq(Waybill::getId, id).last("LIMIT 1")); if (waybill == null) { throw new ServiceException("运单不存在或无权操作"); @@ -626,14 +627,14 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } /** - * 司机可见运单范围:运单车牌(主车/挂车)落在当前司机绑定车牌内, - * 且该运单未被这个司机拒单。绑定来源:blade_transport_driver.driving_vehicle + * 司机可见运单范围:运单司机手机号(driver_phone)等于当前用户手机号, + * 且该运单未被这个司机拒单。 *

* 拒单后运单对司机彻底脱离——列表、计数、详情、打卡、接单一律不可达,等管理员重新派单。 * 运单上的司机姓名/手机/车牌不清空,调度重新派单时要知道原来派给谁。 */ - private LambdaQueryWrapper scopedQuery(List plates) { - return scopedQuery(plates, false); + private LambdaQueryWrapper scopedQuery(String phone) { + return scopedQuery(phone, false); } /** @@ -641,21 +642,11 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { * 拒单的运单不可能处于运输中,收口拦不到合法操作, * 而保留原判据可避免「已拒单 + 进行中」的历史脏数据被误伤 */ - private LambdaQueryWrapper scopedQuery(List plates, boolean includeRejected) { - LambdaQueryWrapper wrapper = Wrappers.lambdaQuery().and(w -> { - w.in(Waybill::getVehicleNo, plates) - .or().in(Waybill::getTrailerVehicleNo, plates); - for (String plate : plates) { - w.or().apply( - "REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(UPPER(IFNULL(vehicle_no,'')),'·',''),'•',''),'.',''),'-',''),' ','') = {0}", - plate - ); - w.or().apply( - "REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(UPPER(IFNULL(trailer_vehicle_no,'')),'·',''),'•',''),'.',''),'-',''),' ','') = {0}", - plate - ); - } - }); + private LambdaQueryWrapper scopedQuery(String phone, boolean includeRejected) { + String normalizedPhone = Func.toStr(phone, "").trim(); + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery().and(w -> w + .eq(Waybill::getDriverPhone, normalizedPhone) + .or().apply("REPLACE(IFNULL(driver_phone,''),' ','') = {0}", normalizedPhone)); if (!includeRejected) { wrapper.and(w -> w.isNull(Waybill::getDriverAcceptStatus) .or().ne(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED)); @@ -676,13 +667,26 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { .or().like(Waybill::getVehicleNo, kw)); } - /** 当前登录司机绑定的规范化车牌列表;无司机或无绑定车牌则空 */ - private List currentBoundPlates() { + /** + * 当前用户手机号:司机档案 mobile 优先,其次小程序用户账号。 + *

+ * 派单时运单 driver_phone 取自司机档案 mobile,故档案有手机号就以它为准。 + */ + private String currentUserPhone() { Driver driver = currentDriver(); - if (driver == null || Func.isEmpty(driver.getDrivingVehicle())) { - return List.of(); + if (driver != null && isMobileLike(driver.getMobile())) { + return driver.getMobile().trim(); } - return splitPlates(driver.getDrivingVehicle()); + Long userId = AuthUtil.getUserId(); + if (userId == null || userId <= 0) { + return null; + } + R result = mpUserClient.userById(userId); + if (result != null && R.isSuccess(result) && result.getData() != null + && isMobileLike(result.getData().getAccount())) { + return result.getData().getAccount().trim(); + } + return null; } /** @@ -928,9 +932,9 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { @Override public void assertCurrentDriverOwnsWaybill(Long waybillId) { - // 归属口径与司机打卡、司机接单一致:都按司机档案自述绑定的车牌判定 - Driver driver = requireCurrentDriver(); - loadDriverWaybill(waybillId, currentBoundPlates(driver)); + // 归属口径与司机打卡、司机接单一致:按当前用户手机号匹配运单司机手机号 + requireCurrentDriver(); + loadDriverWaybill(waybillId); } /** diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverNodePunchPhotoTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverNodePunchPhotoTest.java index 86b9a3e7..12d00939 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverNodePunchPhotoTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverNodePunchPhotoTest.java @@ -58,6 +58,7 @@ class DriverNodePunchPhotoTest { private static final Long WAYBILL_ID = 5601L; private static final Long USER_ID = 7001L; private static final String PLATE = "桂AAW3255"; + private static final String PHONE = "13800138003"; private static final String CLOCK_PHOTO = "https://oss/clock.jpg"; private static final String WAYBILL_PHOTO = "https://oss/waybill.jpg"; private static final String RECEIPT_PHOTO = "https://oss/receipt.jpg"; @@ -308,6 +309,7 @@ class DriverNodePunchPhotoTest { driver.setId(9001L); driver.setUserId(USER_ID); driver.setDrivingVehicle(PLATE); + driver.setMobile(PHONE); return driver; } @@ -316,6 +318,7 @@ class DriverNodePunchPhotoTest { waybill.setId(WAYBILL_ID); waybill.setWaybillNo("YD202610070001"); waybill.setVehicleNo(PLATE); + waybill.setDriverPhone(PHONE); waybill.setProcessJson(PROCESS_JSON); waybill.setBusinessStatus("running"); waybill.setIsDeleted(0); 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 1aef3f7c..91ecf020 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 @@ -49,6 +49,7 @@ class DriverWaybillAutoCompleteTest { private static final Long WAYBILL_ID = 5801L; private static final Long USER_ID = 7001L; private static final String PLATE = "桂AAW3255"; + private static final String PHONE = "13800138001"; private static final String CLOCK_PHOTO = "https://oss/clock.jpg"; /** 末位打卡节点是「签收」,没有回单节点 */ @@ -219,6 +220,7 @@ class DriverWaybillAutoCompleteTest { waybill.setId(WAYBILL_ID); waybill.setWaybillNo("YD202610080001"); waybill.setVehicleNo(PLATE); + waybill.setDriverPhone(PHONE); waybill.setProcessJson(processJson); waybill.setBusinessStatus(businessStatus); waybill.setIsDeleted(0); @@ -237,6 +239,7 @@ class DriverWaybillAutoCompleteTest { 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/DriverWaybillOwnershipTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java index 0a1a5df6..035a2db1 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java @@ -38,14 +38,14 @@ import static org.mockito.Mockito.when; /** * 司机对运单的归属校验(异常上报等按运单操作的前置条件)。 *

- * 口径与司机打卡、司机接单一致:当前登录司机档案自述绑定的车牌({@code driving_vehicle}) - * 覆盖运单主车或挂车。管理端与调度员账号没有司机档案,会停在这里,这是刻意的:不提供代报。 + * 口径:当前用户手机号匹配运单 {@code driverPhone}。 + * 管理端与调度员账号没有司机档案,会停在这里,这是刻意的:不提供代报。 */ class DriverWaybillOwnershipTest { private static final Long WAYBILL_ID = 5301L; private static final Long USER_ID = 7001L; - private static final String PLATE = "沪AE31312"; + private static final String PHONE = "13800138000"; private IDriverService driverService; private IWaybillService waybillService; @@ -88,21 +88,21 @@ class DriverWaybillOwnershipTest { } @Test - @DisplayName("司机档案没有绑定车辆时被拒") - void withoutBoundVehicleIsRejected() { + @DisplayName("司机档案与登录态都没有手机号时被拒") + void withoutPhoneIsRejected() { Driver driver = driver(null); when(driverService.getOne(any())).thenReturn(driver); ServiceException exception = assertThrows(ServiceException.class, () -> runOwnershipCheck(WAYBILL_ID)); - assertEquals("当前司机未绑定车辆", exception.getMessage()); + assertEquals("当前用户手机号为空,无法查找运单", exception.getMessage()); } @Test - @DisplayName("运单车牌不在当前司机绑定车牌内时被拒") - void waybillOfAnotherVehicleIsRejected() { - when(driverService.getOne(any())).thenReturn(driver(PLATE)); + @DisplayName("运单司机手机号与当前用户不一致时被拒") + void waybillOfAnotherPhoneIsRejected() { + when(driverService.getOne(any())).thenReturn(driver(PHONE)); when(waybillService.getOne(any())).thenReturn(null); ServiceException exception = assertThrows(ServiceException.class, @@ -112,9 +112,9 @@ class DriverWaybillOwnershipTest { } @Test - @DisplayName("本人绑定车牌的运单放行,且查询确实按车牌收敛") + @DisplayName("本人手机号运单放行,且查询确实按司机手机号收敛") void ownWaybillPasses() { - when(driverService.getOne(any())).thenReturn(driver(PLATE)); + when(driverService.getOne(any())).thenReturn(driver(PHONE)); when(waybillService.getOne(any())).thenReturn(waybill()); assertDoesNotThrow(() -> runOwnershipCheck(WAYBILL_ID)); @@ -122,17 +122,17 @@ class DriverWaybillOwnershipTest { ArgumentCaptor> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); verify(waybillService).getOne(captor.capture()); LambdaQueryWrapper wrapper = captor.getValue(); - assertTrue(wrapper.getTargetSql().contains("vehicle_no"), - "归属查询必须落在运单主车/挂车车牌上:" + wrapper.getTargetSql()); + assertTrue(wrapper.getTargetSql().contains("driver_phone"), + "归属查询必须落在运单司机手机号上:" + wrapper.getTargetSql()); assertTrue(wrapper.getParamNameValuePairs().values().stream() - .anyMatch(PLATE::equals), - "归属查询必须带上当前司机绑定的车牌:" + wrapper.getParamNameValuePairs()); + .anyMatch(PHONE::equals), + "归属查询必须带上当前用户手机号:" + wrapper.getParamNameValuePairs()); } @Test @DisplayName("运单ID为空时被拒") void withoutWaybillIdIsRejected() { - when(driverService.getOne(any())).thenReturn(driver(PLATE)); + when(driverService.getOne(any())).thenReturn(driver(PHONE)); ServiceException exception = assertThrows(ServiceException.class, () -> runOwnershipCheck(null)); @@ -145,18 +145,17 @@ class DriverWaybillOwnershipTest { private void runOwnershipCheck(Long waybillId) { try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); - authUtil.when(AuthUtil::getUserAccount).thenReturn("13800138000"); + authUtil.when(AuthUtil::getUserAccount).thenReturn(PHONE); service.assertCurrentDriverOwnsWaybill(waybillId); } } - private Driver driver(String drivingVehicle) { + private Driver driver(String mobile) { Driver driver = new Driver(); driver.setId(9001L); driver.setUserId(USER_ID); driver.setDriverName("韦司机"); - driver.setMobile("13800138000"); - driver.setDrivingVehicle(drivingVehicle); + driver.setMobile(mobile); return driver; } @@ -164,7 +163,7 @@ class DriverWaybillOwnershipTest { Waybill waybill = new Waybill(); waybill.setId(WAYBILL_ID); waybill.setWaybillNo("YD202610070001"); - waybill.setVehicleNo(PLATE); + waybill.setDriverPhone(PHONE); waybill.setIsDeleted(0); return waybill; } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java index 3553c9c3..10f6ccbb 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java @@ -57,6 +57,7 @@ class DriverWaybillRejectDetachTest { private static final Long USER_ID = 7401L; private static final Long DRIVER_ID = 9001L; private static final String PLATE = "沪AE31312"; + private static final String PHONE = "13800138002"; private static final Long REJECTED_ID = 7201L; private IDriverService driverService; @@ -97,7 +98,7 @@ class DriverWaybillRejectDetachTest { } @Test - @DisplayName("首页待接预览的取单条件排除已拒单,且排除子句与车牌条件并列") + @DisplayName("首页待接预览的取单条件排除已拒单,且排除子句与取单条件并列") void pendingPreviewQueryExcludesRejected() { when(waybillService.list(ArgumentMatchers.>any())).thenReturn(List.of()); @@ -107,7 +108,7 @@ class DriverWaybillRejectDetachTest { assertTrue(sql.contains("driver_accept_status"), "取单条件必须排除已拒单:" + sql); assertTrue(sql.toUpperCase().contains("AND"), - "排除子句不能与车牌条件「或」在一起,否则等于没排除:" + sql); + "排除子句不能与取单条件「或」在一起,否则等于没排除:" + sql); } @Test @@ -212,6 +213,7 @@ class DriverWaybillRejectDetachTest { driver.setUserId(USER_ID); driver.setDriverName("黄大富"); driver.setDrivingVehicle(PLATE); + driver.setMobile(PHONE); return driver; } @@ -224,6 +226,7 @@ class DriverWaybillRejectDetachTest { waybill.setId(id); waybill.setWaybillNo(no); waybill.setVehicleNo(PLATE); + waybill.setDriverPhone(PHONE); waybill.setBusinessStatus(businessStatus); waybill.setDriverAcceptStatus(acceptStatus); waybill.setIsDeleted(0); From 4b52eb28d89af384f5d1b8f04cd157027ffa13a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Thu, 15 Oct 2026 09:13:26 +0800 Subject: [PATCH 004/108] =?UTF-8?q?fix:=20=E9=87=91=E8=9D=B6=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E6=96=B0=E5=A2=9E=E5=8F=91=E7=A5=A8=E8=8F=9C=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../img.png | Bin 0 -> 251721 bytes ...0-14-金蝶(星瀚)BFI对接操作手册(纯操作版).md | 2 ++ 2 files changed, 2 insertions(+) create mode 100644 doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/img.png diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/img.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/img.png new file mode 100644 index 0000000000000000000000000000000000000000..dde2579ca9d821a2a8a93e0564da7d048b6390b0 GIT binary patch literal 251721 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%0AOG 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�o# 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 Date: Thu, 15 Oct 2026 09:29:04 +0800 Subject: [PATCH 005/108] =?UTF-8?q?fix:=20=E9=87=91=E8=9D=B6=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...š)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).md | 7 ++++--- .../2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).md | 9 ++++++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).md b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).md index 97ddcdd2..6eeac324 100644 --- a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).md +++ b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).md @@ -419,7 +419,7 @@ iPaaS 网关(/gwzh/BFI,不直连金蝶) - 推送闸门:已推送(pushed)流水跳过不重推;`billid`(认领公告 id)缺失拦截「请先同步金蝶收款流水」;结算单金蝶内码缺失拦截不推。 - 幂等:`awrp_src_code` 重试复用同一编码;作废认领不再生成本地冲单(金蝶单据状态保留原值)。 -- A301-3 字段待金蝶确认项仍在:`recpaytype_number`(示例 101)、`paymenttype`、`recpayer`(金蝶付款人 master id)。 +- A301-3 字段待金蝶确认项仍在:`recpaytype_number`(常量当前值 "999",注释示例 101 待确认)、`paymenttype`、`recpayer`(金蝶付款人 master id)。 --- @@ -621,7 +621,7 @@ iPaaS 网关(/gwzh/BFI,不直连金蝶) | # | 事项 | 证据/说明 | |---|---|---| | 1 | **A103-1 勾稽键口径**:代码传本地组装的应付防重键,`PaymentApplicationServiceImpl:1431-1443` 注释仍写「A102-4 反查 awrp_entry_code_p、禁止自行拼接」——注释与代码矛盾,金蝶侧付款计划分录标识是否等于防重键需联调实测 | 高优先 | -| 2 | **项目编码 999 残留**:A102-2/A302-2/A103-1/开票申请仍传测试值 999(仅 A103-3 已用真实项目编码) | 上线前处理 | +| 2 | **项目编码 999 残留**:全部推送链路(应付/应收/付款/预付款/开票申请)仍传测试值 999;A103-3「取项目档案真实编码」的逻辑已注释停用(详见入参映射表 14-#2) | 上线前处理 | | 3 | **计量单位不送**:分录 DTO 有字段无赋值——需与金蝶确认不送计量单位是否影响单据校验 | 联调确认 | | 4 | **A301-3 字段待金蝶确认**:`recpaytype_number`(101)/`paymenttype`/`recpayer` 取值口径 | 联调确认 | | 5 | **A301-2 claimusers 未配置风险**:缺省空串调 A301-2 查不到数据(无报错),需在 Nacos 必配 | 配置项 | @@ -636,5 +636,6 @@ iPaaS 网关(/gwzh/BFI,不直连金蝶) ## 附:文档关系 -- 本文为新总纲(基线 2026-10-13 `805ca9d3`),替代 09-27 版;单链路细节仍以 `doc/docs/业务流程/` 对应专项文档为深入参考,冲突处以本文 + 当前代码为准。 +- 本文为新总纲(基线 2026-10-13 `805ca9d3`,2026-10-15 增补),替代 09-27 版;单链路细节仍以 `doc/docs/业务流程/` 对应专项文档为深入参考,冲突处以本文 + 当前代码为准。 +- **各推送接口的逐字段入参取值映射(页面字段→表字段/配置对照)见《2026-10-15-金蝶(星瀚)BFI推送入参取值映射表(页面字段-表字段-配置对照).md》**,其第 14 节含与本文记载不一致的代码级核对结论。 - 09-27 版中被本文修订的关键结论:A801-3 回写缺陷、票池「实时回源无定时」架构、收款认领「全手动无定时」、分录调试覆盖、物料映射常量、failed 落库口径、A801-5/A801-2/A902「未实现」状态。 diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).md b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).md index eab0cbec..d5097a95 100644 --- a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).md +++ b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).md @@ -8,6 +8,13 @@ --- ## 1. 全局须知 +金蝶测试环境地址:https://cqcs-test.gxwuzi.com:8022/ierp/login.html + +账号密码: + +​ 13545909224 liuquanjia@123 + +​ 13737128880 websoft5386339 ### 1.1 金蝶单据状态含义(列表里看到的字段) @@ -177,7 +184,7 @@ 1. 选票:表单里点发票号放大镜 → 弹出「查询金蝶票据池」→ 勾选 → 确认(带出票面信息)。 ![查询金蝶票据池弹窗:按发票号/日期/税号/票种等筛选;每行可「查看/下载」发票原件(PDF 优先);点「选择」带出到表单](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/07-收票登记-金蝶票池选票.png) - + ![img.png](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/img.png) - 票池数据来自定时同步(默认每 30 分钟);票号精确搜索时若本地没有会**自动去金蝶现查**。 - 查不到票 → 等同步或让运维检查票种白名单配置/手动触发同步(见 4.2)。 From f23e24e80858cceee68b92d0d7fd4b1cf62f92b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Thu, 15 Oct 2026 11:29:11 +0800 Subject: [PATCH 006/108] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=E9=87=91?= =?UTF-8?q?=E8=9D=B6=E8=A7=A3=E9=94=81=E5=9B=9E=E8=B0=83=E5=8D=95=E5=8F=B7?= =?UTF-8?q?=E5=AE=9A=E4=BD=8D=E5=B9=B6=E6=96=B0=E5=A2=9E=E8=A7=A3=E9=94=81?= =?UTF-8?q?=E7=BB=88=E6=80=81=E9=A1=B5=E9=9D=A2=E6=A0=87=E8=AF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 回调方向与锁定相反:按金蝶单号+我方单号双条件反查最新locked锁记录(targetBillType回调为空不参与匹配) - 回调处理批量化:一次IN查询+updateBatchById批量落库,消除逐张查询/更新N+1 - 新增KingdeeUnlockMark展示契约与fillUnlockMarks通用填充,付款申请/结算单/开票申请VO回填解锁时间、操作人、动作名称 - 更新金蝶单据锁定与解锁业务流程文档(回调方向、解锁后数据变化、事务语义、联调实测结论) --- .../transport/pojo/vo/FormalSettlementVO.java | 9 +- .../pojo/vo/InvoiceApplicationVO.java | 9 +- .../transport/pojo/vo/KingdeeUnlockMark.java | 36 +++++++ .../pojo/vo/PaymentApplicationVO.java | 9 +- .../service/IKingdeeBillLockService.java | 31 +++++- .../impl/FormalSettlementServiceImpl.java | 2 + .../impl/InvoiceApplicationServiceImpl.java | 2 + .../impl/KingdeeBillLockServiceImpl.java | 86 +++++++++++++---- .../impl/PaymentApplicationServiceImpl.java | 2 + ...BusiData锁定-unLockNotifyBusiData回调-失败补偿-删单还原).md | 94 ++++++++++++++----- 10 files changed, 233 insertions(+), 47 deletions(-) create mode 100644 blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/KingdeeUnlockMark.java diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/FormalSettlementVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/FormalSettlementVO.java index 5929669a..58805d53 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/FormalSettlementVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/FormalSettlementVO.java @@ -24,6 +24,7 @@ import org.springframework.format.annotation.DateTimeFormat; import java.io.Serial; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; /** @@ -33,7 +34,7 @@ import java.util.List; */ @Data @EqualsAndHashCode(callSuper = true) -public class FormalSettlementVO extends FormalSettlement { +public class FormalSettlementVO extends FormalSettlement implements KingdeeUnlockMark { @Serial private static final long serialVersionUID = 1L; @TableField(exist = false) @DateTimeFormat(pattern = "yyyy-MM-dd") private LocalDate createStartDate; @TableField(exist = false) @DateTimeFormat(pattern = "yyyy-MM-dd") private LocalDate createEndDate; @@ -50,6 +51,12 @@ public class FormalSettlementVO extends FormalSettlement { private String kingdeeLockFailReason; @TableField(exist = false) @Schema(description = "金蝶锁重试次数(达上限5后不再自动补偿,需重推同步金蝶)") private Integer kingdeeTryCount; + @TableField(exist = false) @Schema(description = "金蝶解锁时间(非空代表该单曾被金蝶解锁/删单)") + private LocalDateTime kingdeeUnlockTime; + @TableField(exist = false) @Schema(description = "金蝶侧解锁操作人(回调报文 operator)") + private String kingdeeUnlockOperator; + @TableField(exist = false) @Schema(description = "解锁动作名称:金蝶删单/金蝶解锁") + private String kingdeeUnlockOperationName; @TableField(exist = false) private String preSettlementNos; @TableField(exist = false) private String preSettlementNo; @TableField(exist = false) private List sources; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceApplicationVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceApplicationVO.java index c54945aa..71a38aba 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceApplicationVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceApplicationVO.java @@ -35,6 +35,7 @@ import org.springblade.transport.pojo.entity.InvoiceApplicationRecord; import org.springblade.transport.pojo.entity.InvoiceApplicationSettlement; import java.io.Serial; +import java.time.LocalDateTime; import java.util.List; /** @@ -44,7 +45,7 @@ import java.util.List; */ @Data @EqualsAndHashCode(callSuper = true) -public class InvoiceApplicationVO extends InvoiceApplication { +public class InvoiceApplicationVO extends InvoiceApplication implements KingdeeUnlockMark { @Serial private static final long serialVersionUID = 1L; @TableField(exist = false) private String settlementNos; @TableField(exist = false) private String approvalStatusName; @@ -55,6 +56,12 @@ public class InvoiceApplicationVO extends InvoiceApplication { @TableField(exist = false) private String kingdeeLockStatus; @TableField(exist = false) private String kingdeeLockFailReason; @TableField(exist = false) private Integer kingdeeTryCount; + /** 金蝶解锁终态标识(unlocked 锁记录回填):解锁时间非空即代表该单曾被金蝶解锁/删单 */ + @TableField(exist = false) private LocalDateTime kingdeeUnlockTime; + /** 金蝶侧解锁操作人(回调报文 operator) */ + @TableField(exist = false) private String kingdeeUnlockOperator; + /** 解锁动作名称:金蝶删单/金蝶解锁 */ + @TableField(exist = false) private String kingdeeUnlockOperationName; @TableField(exist = false) private String createUserName; @TableField(exist = false) private List settlements; @TableField(exist = false) private List sheets; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/KingdeeUnlockMark.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/KingdeeUnlockMark.java new file mode 100644 index 00000000..ee81225d --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/KingdeeUnlockMark.java @@ -0,0 +1,36 @@ +/** + * 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. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.vo; + +import java.time.LocalDateTime; + +/** + * 金蝶解锁终态标识展示契约:需在页面展示「金蝶删单/解锁」标识的 VO 实现之, + * 由 {@code KingdeeBillLockService#fillUnlockMarks} 统一回填(列表/详情共用) + * + * @author Chill + */ +public interface KingdeeUnlockMark { + + /** + * 金蝶解锁时间(unlocked 锁记录回填),非空即代表该单曾被金蝶解锁/删单 + */ + void setKingdeeUnlockTime(LocalDateTime kingdeeUnlockTime); + + /** + * 金蝶侧解锁操作人(回调报文 operator) + */ + void setKingdeeUnlockOperator(String kingdeeUnlockOperator); + + /** + * 解锁动作名称:金蝶删单/金蝶解锁 + */ + void setKingdeeUnlockOperationName(String kingdeeUnlockOperationName); +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/PaymentApplicationVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/PaymentApplicationVO.java index d8bc998f..aa74d1f5 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/PaymentApplicationVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/PaymentApplicationVO.java @@ -29,12 +29,13 @@ import org.springblade.transport.pojo.entity.PaymentApplicationSettlement; import java.io.Serial; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; /** 付款申请视图。 @author Chill */ @Data @EqualsAndHashCode(callSuper = true) -public class PaymentApplicationVO extends PaymentApplication { +public class PaymentApplicationVO extends PaymentApplication implements KingdeeUnlockMark { @Serial private static final long serialVersionUID = 1L; @TableField(exist = false) private LocalDate applyStartDate; @TableField(exist = false) private LocalDate applyEndDate; @@ -49,6 +50,12 @@ public class PaymentApplicationVO extends PaymentApplication { @TableField(exist = false) private String kingdeeLockStatus; @TableField(exist = false) private String kingdeeLockFailReason; @TableField(exist = false) private Integer kingdeeTryCount; + /** 金蝶解锁终态标识(unlocked 锁记录回填):解锁时间非空即代表该单曾被金蝶解锁/删单,供页面展示明显标识 */ + @TableField(exist = false) private LocalDateTime kingdeeUnlockTime; + /** 金蝶侧解锁操作人(回调报文 operator) */ + @TableField(exist = false) private String kingdeeUnlockOperator; + /** 解锁动作名称:金蝶删单/金蝶解锁 */ + @TableField(exist = false) private String kingdeeUnlockOperationName; @TableField(exist = false) private List invoices; @TableField(exist = false) private List paymentRecords; @TableField(exist = false) private List settlements; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IKingdeeBillLockService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IKingdeeBillLockService.java index 7af9adfc..6438f026 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IKingdeeBillLockService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IKingdeeBillLockService.java @@ -9,8 +9,10 @@ package org.springblade.transport.service; import com.baomidou.mybatisplus.extension.service.IService; +import org.springblade.core.mp.base.BaseEntity; import org.springblade.thirdparty.bfi.pojo.dto.BfiBillLockDTO; import org.springblade.transport.pojo.entity.KingdeeBillLock; +import org.springblade.transport.pojo.vo.KingdeeUnlockMark; import java.util.List; import java.util.Map; @@ -39,8 +41,10 @@ public interface IKingdeeBillLockService extends IService { String lockAfterPush(String sourceBillType, Long sourceBillId, String sourceBillNo, String targetBillType, List targetBillNos); /** - * 金蝶解锁回调(unLockNotifyBusiData):校验来源/目标系统与 lockAction, - * targetBillNo 支持逗号拼接批量,逐张按 targetBillType+targetBillNo 定位最新 locked 锁记录; + * 金蝶解锁回调(unLockNotifyBusiData):校验来源/目标系统与 lockAction。 + * 注意回调方向与锁定相反:回调 sourceBillNo=金蝶单号(即锁记录 targetBillNo,逗号拼接批量)、 + * targetBillNo=WLTMS 单号(即锁记录 sourceBillNo)、targetBillType 可能为空, + * 故按金蝶单号+我方单号双条件反查最新 locked 锁记录; * operationType=DELETE 时按单据类型分发废除源单并还原关联数据 * * @param param 金蝶回调参数 @@ -66,6 +70,29 @@ public interface IKingdeeBillLockService extends IService { */ Map mapActiveLocks(String sourceBillType, List sourceBillIds); + /** + * 批量查源单解锁终态留痕(供列表/详情页填充解锁标识): + * 每源单取最新一条 unlocked 锁记录(付款申请解锁后不作废,页面据此区分「金蝶删单/解锁」与普通推送失败) + * + * @param sourceBillType WLTMS 单据类型 + * @param sourceBillIds 源单主键集合 + * @return sourceBillId -> 最新 unlocked 锁记录,无解锁留痕的源单不在 Map 中 + */ + Map mapUnlockedLocks(String sourceBillType, List sourceBillIds); + + /** + * 批量填充金蝶解锁终态标识(通用):对无活跃锁的行反查最新 unlocked 锁记录并回填解锁标识 + * (解锁时间/操作人/动作名称),供前端展示明显解锁标识;有活跃锁的单必非解锁终态,跳过。 + * 与 mapActiveLocks 配对在各单据 fillKingdeeLockStatus 中调用,列表/详情共用; + * 后续单据(如收票单)接入锁单时,VO 实现 KingdeeUnlockMark 即可复用 + * + * @param sourceBillType WLTMS 单据类型 + * @param rows 已填充活跃锁标识的 VO 行 + * @param activeLockMap 活跃锁 Map(mapActiveLocks 返回值) + * @param <T> 实现 KingdeeUnlockMark 的 VO 类型(须继承 BaseEntity 以取主键) + */ + void fillUnlockMarks(String sourceBillType, List rows, Map activeLockMap); + /** * 锁定补偿:扫 failed 且 try_count 未达重试上限(RETRY_MAX_COUNT)的锁记录,逐张重调 lockBusiData * (接口 1对1:单次一个源单号对一个金蝶单号,单张成败互不影响;定时任务定时触发,亦可人工触发); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java index d3f7a922..cc72d33b 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java @@ -233,6 +233,8 @@ public class FormalSettlementServiceImpl extends BaseServiceImpllambdaQuery() - .eq(KingdeeBillLock::getTargetBillType, param.getTargetBillType()) - .eq(KingdeeBillLock::getTargetBillNo, targetBillNo) - .orderByDesc(KingdeeBillLock::getId) - .last("limit 1"), false); + // 回调方向与锁定相反:回调 sourceBillNo=金蝶单号(即锁记录 targetBillNo)、targetBillNo=WLTMS 单号(即锁记录 sourceBillNo)、 + // targetBillType 可能为空,故按金蝶单号+我方单号双条件反查;未匹配锁定中记录的幂等跳过(重复通知防金蝶重试风暴) + // 一次 IN 查询取回候选记录、内存取各单号最新一条,批量更新解锁状态,避免逐张查询/更新的 N+1 + List kingdeeBillNos = Func.toStrList(param.getSourceBillNo()); + Map latestLockMap = this.list(Wrappers.lambdaQuery() + .in(KingdeeBillLock::getTargetBillNo, kingdeeBillNos) + .eq(KingdeeBillLock::getSourceBillNo, param.getTargetBillNo()) + .orderByDesc(KingdeeBillLock::getId)) + .stream() + .collect(Collectors.toMap(KingdeeBillLock::getTargetBillNo, Function.identity(), (first, second) -> first)); + List unlockRecords = new ArrayList<>(); + for (String kingdeeBillNo : kingdeeBillNos) { + KingdeeBillLock lockRecord = latestLockMap.get(kingdeeBillNo); if (lockRecord == null || !KingdeeLockConstant.STATUS_LOCKED.equals(lockRecord.getLockStatus())) { - log.warn("金蝶解锁回调未匹配锁定中的锁记录(幂等跳过):targetBillType={},targetBillNo={}", - param.getTargetBillType(), targetBillNo); + log.warn("金蝶解锁回调未匹配锁定中的锁记录(幂等跳过):金蝶单号={},回调targetBillNo={}", + kingdeeBillNo, param.getTargetBillNo()); continue; } lockRecord.setLockStatus(KingdeeLockConstant.STATUS_UNLOCKED); lockRecord.setOperationType(param.getOperationType()); lockRecord.setUnlockTime(LocalDateTime.now()); lockRecord.setNotifyOperator(param.getOperator()); - this.updateById(lockRecord); - processed++; + unlockRecords.add(lockRecord); log.info("金蝶单据[{}:{}]解锁回调落库:operationType={},操作人={}", lockRecord.getSourceBillType(), lockRecord.getSourceBillNo(), param.getOperationType(), param.getOperator()); - if (KingdeeLockConstant.OPERATION_DELETE.equals(param.getOperationType())) { - this.restoreOnKingdeeDelete(lockRecord, param.getOperator()); - } } - return processed; + this.updateBatchById(unlockRecords); + // DELETE 分发废除还原:按源单逐张执行(各单据类型还原动作为独立业务事务,无法合并) + if (KingdeeLockConstant.OPERATION_DELETE.equals(param.getOperationType())) { + unlockRecords.forEach(lockRecord -> this.restoreOnKingdeeDelete(lockRecord, param.getOperator())); + } + return unlockRecords.size(); } /** @@ -268,6 +276,44 @@ public class KingdeeBillLockServiceImpl extends ServiceImpl second)); } + @Override + public Map mapUnlockedLocks(String sourceBillType, List sourceBillIds) { + if (Func.isEmpty(sourceBillIds)) { + return Map.of(); + } + List lockRecords = this.list(Wrappers.lambdaQuery() + .eq(KingdeeBillLock::getSourceBillType, sourceBillType) + .in(KingdeeBillLock::getSourceBillId, sourceBillIds) + .eq(KingdeeBillLock::getLockStatus, KingdeeLockConstant.STATUS_UNLOCKED) + .orderByDesc(KingdeeBillLock::getId)); + // 降序取回后首个生效:每源单保留最新一条解锁留痕 + return lockRecords.stream().collect(Collectors.toMap(KingdeeBillLock::getSourceBillId, Function.identity(), (first, second) -> first)); + } + + @Override + public void fillUnlockMarks(String sourceBillType, List rows, Map activeLockMap) { + if (Func.isEmpty(rows)) { + return; + } + List unlockedIds = rows.stream() + .filter(row -> !activeLockMap.containsKey(row.getId())) + .map(BaseEntity::getId).toList(); + if (Func.isEmpty(unlockedIds)) { + return; + } + Map unlockedMap = this.mapUnlockedLocks(sourceBillType, unlockedIds); + for (T row : rows) { + KingdeeBillLock unlockRecord = unlockedMap.get(row.getId()); + if (unlockRecord == null) { + continue; + } + row.setKingdeeUnlockTime(unlockRecord.getUnlockTime()); + row.setKingdeeUnlockOperator(unlockRecord.getNotifyOperator()); + row.setKingdeeUnlockOperationName(KingdeeLockConstant.OPERATION_DELETE.equals(unlockRecord.getOperationType()) + ? "金蝶删单" : "金蝶解锁"); + } + } + /** * 锁定补偿:扫 failed 且 try_count 未达重试上限(RETRY_MAX_COUNT)的锁记录,逐张重调 lockBusiData * (接口 1对1:单次一个源单号对一个金蝶单号,单张成败互不影响,由定时任务定时触发,亦可人工触发)。 diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java index ccac6a62..742b5931 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java @@ -240,6 +240,8 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl Date: Thu, 15 Oct 2026 11:44:37 +0800 Subject: [PATCH 007/108] =?UTF-8?q?fix:=20=E8=A7=A3=E9=94=81=E6=A0=87?= =?UTF-8?q?=E8=AF=86=E6=9C=AF=E8=AF=AD=E7=BB=9F=E4=B8=80=E4=B8=BA=E9=87=91?= =?UTF-8?q?=E8=9D=B6=E9=94=81=E5=8D=95/=E9=87=91=E8=9D=B6=E5=88=A0?= =?UTF-8?q?=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 回调操作类型展示口径统一:SAVE=金蝶锁单(保存触发解锁,单据仍在)、DELETE=金蝶删单 - 同步更新解锁标识标签、VO 注释与业务流程文档 --- .../springblade/transport/pojo/vo/FormalSettlementVO.java | 2 +- .../springblade/transport/pojo/vo/InvoiceApplicationVO.java | 2 +- .../springblade/transport/pojo/vo/KingdeeUnlockMark.java | 2 +- .../springblade/transport/pojo/vo/PaymentApplicationVO.java | 2 +- .../transport/service/impl/KingdeeBillLockServiceImpl.java | 2 +- ...¡流程(lockBusiData锁定-unLockNotifyBusiData回调-失败补偿-删单还原).md | 6 +++--- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/FormalSettlementVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/FormalSettlementVO.java index 58805d53..2702fbb5 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/FormalSettlementVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/FormalSettlementVO.java @@ -55,7 +55,7 @@ public class FormalSettlementVO extends FormalSettlement implements KingdeeUnloc private LocalDateTime kingdeeUnlockTime; @TableField(exist = false) @Schema(description = "金蝶侧解锁操作人(回调报文 operator)") private String kingdeeUnlockOperator; - @TableField(exist = false) @Schema(description = "解锁动作名称:金蝶删单/金蝶解锁") + @TableField(exist = false) @Schema(description = "解锁动作名称:金蝶删单/金蝶锁单") private String kingdeeUnlockOperationName; @TableField(exist = false) private String preSettlementNos; @TableField(exist = false) private String preSettlementNo; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceApplicationVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceApplicationVO.java index 71a38aba..d195bbc5 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceApplicationVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceApplicationVO.java @@ -60,7 +60,7 @@ public class InvoiceApplicationVO extends InvoiceApplication implements KingdeeU @TableField(exist = false) private LocalDateTime kingdeeUnlockTime; /** 金蝶侧解锁操作人(回调报文 operator) */ @TableField(exist = false) private String kingdeeUnlockOperator; - /** 解锁动作名称:金蝶删单/金蝶解锁 */ + /** 解锁动作名称:金蝶删单/金蝶锁单 */ @TableField(exist = false) private String kingdeeUnlockOperationName; @TableField(exist = false) private String createUserName; @TableField(exist = false) private List settlements; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/KingdeeUnlockMark.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/KingdeeUnlockMark.java index ee81225d..ca15b000 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/KingdeeUnlockMark.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/KingdeeUnlockMark.java @@ -30,7 +30,7 @@ public interface KingdeeUnlockMark { void setKingdeeUnlockOperator(String kingdeeUnlockOperator); /** - * 解锁动作名称:金蝶删单/金蝶解锁 + * 解锁动作名称:金蝶删单/金蝶锁单 */ void setKingdeeUnlockOperationName(String kingdeeUnlockOperationName); } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/PaymentApplicationVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/PaymentApplicationVO.java index aa74d1f5..8c079128 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/PaymentApplicationVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/PaymentApplicationVO.java @@ -54,7 +54,7 @@ public class PaymentApplicationVO extends PaymentApplication implements KingdeeU @TableField(exist = false) private LocalDateTime kingdeeUnlockTime; /** 金蝶侧解锁操作人(回调报文 operator) */ @TableField(exist = false) private String kingdeeUnlockOperator; - /** 解锁动作名称:金蝶删单/金蝶解锁 */ + /** 解锁动作名称:金蝶删单/金蝶锁单 */ @TableField(exist = false) private String kingdeeUnlockOperationName; @TableField(exist = false) private List invoices; @TableField(exist = false) private List paymentRecords; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/KingdeeBillLockServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/KingdeeBillLockServiceImpl.java index caa6a1c0..c39e563c 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/KingdeeBillLockServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/KingdeeBillLockServiceImpl.java @@ -310,7 +310,7 @@ public class KingdeeBillLockServiceImpl extends ServiceImpl Date: Thu, 15 Oct 2026 12:47:11 +0800 Subject: [PATCH 008/108] =?UTF-8?q?:sparkles:=20=E7=BB=84=E7=BB=87?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E6=94=B6=E6=95=9B=E4=B8=BA=E5=8F=8C=E9=A1=B6?= =?UTF-8?q?=E7=BA=A7=E4=B8=8E=E4=BA=8C=E7=BA=A7=E5=85=AC=E5=8F=B8=E7=99=BD?= =?UTF-8?q?=E5=90=8D=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 集团顶级改为 id=1/广西现代物流集团有限公司,仅同步 OA 公司 2、135 及其子公司与部门,并同步客商固定所属组织名称。 Co-authored-by: Cursor --- .../system/pojo/vo/OaOrgSyncPageVO.java | 3 + .../system/pojo/vo/OaPersonSyncPageVO.java | 3 + .../system/service/IDeptService.java | 6 +- .../system/service/impl/DeptServiceImpl.java | 74 +++- .../service/impl/OASyncServiceImpl.java | 152 ++++++- .../service/impl/OaSyncRawDumpHelper.java | 379 ++++++++++++++++++ .../service/impl/OaUserListSyncHelper.java | 2 +- .../impl/CustomerArchiveServiceImpl.java | 4 +- .../impl/CertAuditServiceImplTest.java | 4 +- .../impl/DriverCertServiceImplTest.java | 8 +- .../support/OcrFieldSupportTest.java | 8 +- .../oa/constant/OAConvertConstant.java | 18 +- 12 files changed, 631 insertions(+), 30 deletions(-) create mode 100644 blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaSyncRawDumpHelper.java diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaOrgSyncPageVO.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaOrgSyncPageVO.java index d8390cf5..bd1bd7a4 100644 --- a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaOrgSyncPageVO.java +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaOrgSyncPageVO.java @@ -69,4 +69,7 @@ public class OaOrgSyncPageVO implements Serializable { @Schema(description = "增量同步使用的 OA modified 起始时间,后续分页需原样回传") private String modifiedSince; + + @Schema(description = "全量同步结束后原始数据层级 JSON 文件路径") + private String dumpFilePath; } 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 b57811da..d92f413b 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 @@ -66,4 +66,7 @@ public class OaPersonSyncPageVO implements Serializable { @Schema(description = "增量同步使用的 OA modified 起始时间,后续分页需原样回传") private String modifiedSince; + + @Schema(description = "全量同步结束后原始数据层级 JSON 文件路径") + private String dumpFilePath; } diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/IDeptService.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/IDeptService.java index b2afe5f0..d9094bf0 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/IDeptService.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/IDeptService.java @@ -149,16 +149,16 @@ public interface IDeptService extends IService { boolean removeDept(String ids); /** - * 清除非保留顶级外的组织;保留 id=135 集团锚点,以及名称为「外部组织」的顶级 + * 清除非保留顶级外的组织;保留 id=1 集团锚点,以及名称为「外部组织」的顶级 * * @return 删除数量 */ int clearNonTopDepts(); /** - * 确保双顶级:id=135 集团(编码 GWWL)+ 独立「外部组织」顶级 + * 确保双顶级:id=1 集团(编码 GWWL)+ 独立「外部组织」顶级 * - * @return 集团顶级组织(id=135) + * @return 集团顶级组织(id=1) */ Dept ensureOaSyncRootDept(); diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/DeptServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/DeptServiceImpl.java index c0a7c966..d55f05e6 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/DeptServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/DeptServiceImpl.java @@ -34,6 +34,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springblade.common.constant.CommonConstant; import org.springblade.common.constant.DataStatusEnum; import org.springblade.core.cache.utils.CacheUtil; import org.springblade.core.log.exception.ServiceException; @@ -390,7 +391,7 @@ public class DeptServiceImpl extends ServiceImpl implements ID @Override @Transactional(rollbackFor = Exception.class) public int clearNonTopDepts() { - // 同步前清理:保留集团锚点 id=135,以及「外部组织」顶级;其余删除 + // 同步前清理:保留集团锚点 id=1,以及「外部组织」顶级;其余删除 Dept groupRoot = this.ensureOaSyncRootDept(); Dept externalRoot = this.ensureExternalOrgTopDept(); Set keepIds = new HashSet<>(); @@ -416,6 +417,7 @@ public class DeptServiceImpl extends ServiceImpl implements ID public Dept ensureOaSyncRootDept() { this.migrateLegacyOaDeptCategory(); this.removeLegacyOaSyncRoots(); + this.migrateLegacyGroupRootToIdOne(); Dept rootDept = this.getById(OAConvertConstant.OA_SYNC_ROOT_DEPT_ID); String tenantId = AuthUtil.getTenantId(); if (StringUtil.isBlank(tenantId)) { @@ -447,14 +449,17 @@ public class DeptServiceImpl extends ServiceImpl implements ID OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE); } else { boolean wronglyNamedAsExternal = OAConvertConstant.EXTERNAL_ORG_NAME.equals(rootDept.getDeptName()); - boolean needUpdate = wronglyNamedAsExternal + boolean legacyNamed = OAConvertConstant.LEGACY_ROOT_DEPT_NAME.equals(rootDept.getDeptName()); + boolean needRename = wronglyNamedAsExternal || legacyNamed || StringUtil.isBlank(rootDept.getDeptName()) + || !OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME.equals(rootDept.getDeptName()); + boolean needUpdate = needRename || !OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE.equals(rootDept.getDeptCode()) || !DeptCategory.GROUP.getCode().equals(rootDept.getDeptCategory()) || !Objects.equals(BladeConstant.TOP_PARENT_ID, rootDept.getParentId()); if (needUpdate) { Dept updateParam = new Dept(); updateParam.setId(rootDept.getId()); - if (wronglyNamedAsExternal || StringUtil.isBlank(rootDept.getDeptName())) { + if (needRename) { updateParam.setDeptName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME); updateParam.setFullName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME); updateParam.setShortName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME); @@ -468,6 +473,7 @@ public class DeptServiceImpl extends ServiceImpl implements ID updateParam.setParentId(BladeConstant.TOP_PARENT_ID); updateParam.setAncestors(String.valueOf(BladeConstant.TOP_PARENT_ID)); updateParam.setParentCode(String.valueOf(BladeConstant.TOP_PARENT_ID)); + updateParam.setIsOa(0); this.updateById(updateParam); rootDept.setDeptCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE); rootDept.setBelongCompanyCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE); @@ -475,6 +481,7 @@ public class DeptServiceImpl extends ServiceImpl implements ID rootDept.setParentId(BladeConstant.TOP_PARENT_ID); rootDept.setAncestors(String.valueOf(BladeConstant.TOP_PARENT_ID)); rootDept.setParentCode(String.valueOf(BladeConstant.TOP_PARENT_ID)); + rootDept.setIsOa(0); log.info("已校正 OA 同步集团顶级 id={} name={} code={} category={}", rootDept.getId(), rootDept.getDeptName(), rootDept.getDeptCode(), rootDept.getDeptCategory()); } @@ -484,7 +491,63 @@ public class DeptServiceImpl extends ServiceImpl implements ID } /** - * 确保存在独立的「外部组织」顶级(与 id=135 集团并存,仅保留一个同名顶级) + * 将历史集团伪顶级 id=135 迁移到 id=1(子节点改挂,再删除旧锚点) + */ + private void migrateLegacyGroupRootToIdOne() { + Long legacyRootId = OAConvertConstant.LEGACY_OA_SYNC_ROOT_DEPT_ID; + Long newRootId = OAConvertConstant.OA_SYNC_ROOT_DEPT_ID; + if (Objects.equals(legacyRootId, newRootId)) { + return; + } + Dept legacyRoot = this.getById(legacyRootId); + if (legacyRoot == null) { + return; + } + // 仅迁移历史伪顶级;若 id=135 已是真实 OA 公司记录则跳过删除 + boolean isOaCompanyRecord = StringUtil.isNotBlank(legacyRoot.getOaId()) + && DeptCategory.COMPANY.getCode().equals(legacyRoot.getDeptCategory()) + && (Objects.equals(legacyRoot.getIsOa(), CommonConstant.YES) || Objects.equals(legacyRoot.getIsOa(), 1)); + if (isOaCompanyRecord) { + log.warn("历史 id={} 已是 OA 同步公司(oaId={}),跳过伪顶级迁移", legacyRootId, legacyRoot.getOaId()); + return; + } + Dept newRoot = this.getById(newRootId); + String tenantId = AuthUtil.getTenantId(); + if (StringUtil.isBlank(tenantId)) { + tenantId = BladeConstant.ADMIN_TENANT_ID; + } + if (newRoot == null) { + newRoot = new Dept(); + newRoot.setId(newRootId); + newRoot.setTenantId(StringUtil.isNotBlank(legacyRoot.getTenantId()) ? legacyRoot.getTenantId() : tenantId); + newRoot.setParentId(BladeConstant.TOP_PARENT_ID); + newRoot.setAncestors(String.valueOf(BladeConstant.TOP_PARENT_ID)); + newRoot.setDeptName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME); + newRoot.setFullName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME); + newRoot.setShortName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME); + newRoot.setDeptCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE); + newRoot.setParentCode(String.valueOf(BladeConstant.TOP_PARENT_ID)); + newRoot.setBelongCompanyCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE); + newRoot.setDeptCategory(DeptCategory.GROUP.getCode()); + newRoot.setSort(0); + newRoot.setStatus(DataStatusEnum.ENABLE.getCode()); + newRoot.setIsDeleted(BladeConstant.DB_NOT_DELETED); + newRoot.setIsOa(0); + newRoot.setIsPlatformCompany(0); + newRoot.setSyncTime(new Date()); + this.save(newRoot); + log.info("迁移创建新集团顶级 id={}(来自历史 id={})", newRootId, legacyRootId); + } + this.update(Wrappers.lambdaUpdate() + .set(Dept::getParentId, newRootId) + .set(Dept::getParentCode, OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE) + .eq(Dept::getParentId, legacyRootId)); + this.removeById(legacyRootId); + log.info("已将历史集团顶级 id={} 迁移到 id={},并删除旧锚点", legacyRootId, newRootId); + } + + /** + * 确保存在独立的「外部组织」顶级(与 id=1 集团并存,仅保留一个同名顶级) */ private Dept ensureExternalOrgTopDept() { List externalTops = this.list(Wrappers.lambdaQuery() @@ -540,11 +603,12 @@ public class DeptServiceImpl extends ServiceImpl implements ID } /** - * 清理历史伪顶级(OACROOT);同名「桂物物流集团」仅清理非 id=135 的顶级 + * 清理历史伪顶级(OACROOT);同名旧集团仅清理非 id=1 的顶级 */ private void removeLegacyOaSyncRoots() { List legacyRoots = this.list(Wrappers.lambdaQuery() .ne(Dept::getId, OAConvertConstant.OA_SYNC_ROOT_DEPT_ID) + .ne(Dept::getId, OAConvertConstant.LEGACY_OA_SYNC_ROOT_DEPT_ID) .and(wrapper -> wrapper .eq(Dept::getDeptCode, OAConvertConstant.LEGACY_ROOT_DEPT_CODE) .or(nameWrapper -> nameWrapper 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 2156b11f..4cb3487f 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 @@ -66,6 +66,7 @@ public class OASyncServiceImpl implements IOASyncService { private final IMKPushService mkPushService; private final IDataSyncRecordService dataSyncRecordService; private final OaUserListSyncHelper oaUserListSyncHelper; + private final OaSyncRawDumpHelper oaSyncRawDumpHelper; @Transactional(rollbackFor = Exception.class) @Override @@ -74,16 +75,18 @@ public class OASyncServiceImpl implements IOASyncService { ComposeLogUtil.addLog(log); // 1. 同步公司和部门并记录 Date startTime = this.syncAndRecord(DataSyncRecordUtils::createOAOrgFetch, queryStartTime -> { - // 0. 确保双顶级:id=135 集团(GWWL) + 独立「外部组织」 + // 0. 确保双顶级:id=1 集团(GWWL) + 独立「外部组织」 deptService.ensureOaSyncRootDept(); // 1. 同步公司 this.syncCompany(queryStartTime); - // 公司分批入库后,重建层级并清理无法挂载的公司 + // 公司分批入库后:清理白名单外公司、重建层级并清理无法挂载的公司 + this.removeOutsideWhitelistOrgs(); deptService.rebuildOaHierarchy(); deptService.removeOaOrphansWithoutParent(); // 2. 同步部门 this.syncDept(queryStartTime); // 3. 分批写入完成后,按 OA 主键重建组织、部门层级 + this.removeOutsideWhitelistOrgs(); deptService.rebuildOaHierarchy(); deptService.removeOaOrphansWithoutParent(); // 4. 更新部门祖级列表 @@ -293,6 +296,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(); + } IncrementalStart incrementalStart = resolvePersonIncrementalStart(incremental, modifiedSince); OAPersonSearch personSearch = buildPersonSearch(incrementalStart.getStartTime()); personSearch.setCurPage(pageNo); @@ -306,6 +313,9 @@ public class OASyncServiceImpl implements IOASyncService { List oaPersons = responseData.getDataList() == null ? Collections.emptyList() : responseData.getDataList(); long totalSize = responseData.getTotalSize() == null ? 0L : responseData.getTotalSize(); + if (!incremental) { + oaSyncRawDumpHelper.collectPersons(oaPersons); + } OaUserListSyncHelper.OaOrgIndex orgIndex = oaUserListSyncHelper.syncOrgsFromPersons(oaPersons); OaUserListSyncHelper.PersonSyncCount personSyncCount = oaUserListSyncHelper.handlePerson(oaPersons, orgIndex); CacheUtil.clear(USER_CACHE); @@ -323,6 +333,9 @@ public class OASyncServiceImpl implements IOASyncService { || oaPersons.size() < pageSize || (long) pageNo * pageSize >= totalSize; pageVO.setFinished(finished); + if (!incremental && finished) { + pageVO.setDumpFilePath(oaSyncRawDumpHelper.finishFullDump()); + } ComposeLogUtil.getLastLog().info("OA人员分页同步完成 mode={} {}/{},成功{},跳过{}", incremental ? "incremental" : "full", pageNo, totalSize, personSyncCount.getSyncedCount(), personSyncCount.getSkippedCount()); @@ -341,9 +354,12 @@ public class OASyncServiceImpl implements IOASyncService { private OaOrgSyncPageVO syncCompanyFromOaPage(int current, int size, boolean incremental, String modifiedSince) { int pageNo = current < 1 ? 1 : current; int pageSize = size < 1 ? 20 : Math.min(size, 200); - // 首屏确保双顶级:id=135 集团(GWWL) + 独立「外部组织」 + // 首屏确保双顶级:id=1 集团(GWWL) + 独立「外部组织」 if (pageNo == 1) { deptService.ensureOaSyncRootDept(); + if (!incremental) { + oaSyncRawDumpHelper.beginOrgFullDump(); + } } IncrementalStart incrementalStart = resolveIncrementalStart(incremental, modifiedSince, DeptCategory.COMPANY); OACompanySearch companySearch = buildCompanySearch(incrementalStart.getStartTime()); @@ -358,6 +374,9 @@ public class OASyncServiceImpl implements IOASyncService { List oaCompanies = responseData.getDataList() == null ? Collections.emptyList() : responseData.getDataList(); long totalSize = responseData.getTotalSize() == null ? 0L : responseData.getTotalSize(); + if (!incremental) { + oaSyncRawDumpHelper.collectCompanies(oaCompanies); + } OrgSyncCount syncCount = handleCompany(oaCompanies, true); if (CollectionUtil.isNotEmpty(syncCount.getNotHandledList())) { ComposeLogUtil.getLastLog().warn("同步公司,本页未处理数据:{}", JSON.toJSONString(syncCount.getNotHandledList())); @@ -365,7 +384,8 @@ public class OASyncServiceImpl implements IOASyncService { OaOrgSyncPageVO pageVO = buildOrgSyncPageVO("company", pageNo, pageSize, totalSize, oaCompanies.size(), syncCount); pageVO.setModifiedSince(incrementalStart.getModifiedSince()); if (Boolean.TRUE.equals(pageVO.getFinished())) { - // 公司全部分批入库后:重建层级,并删除上级不存在的数据 + // 公司全部分批入库后:清理白名单外公司、重建层级,并删除上级不存在的数据 + this.removeOutsideWhitelistOrgs(); deptService.rebuildOaHierarchy(); deptService.removeOaOrphansWithoutParent(); } @@ -394,6 +414,9 @@ public class OASyncServiceImpl implements IOASyncService { ComposeLogUtil.getLastLog().warn("未查询到已同步的OA公司,跳过部门同步"); OaOrgSyncPageVO emptyPageVO = buildOrgSyncPageVO("department", pageNo, pageSize, 0L, 0, OrgSyncCount.empty()); emptyPageVO.setFinished(true); + if (!incremental) { + emptyPageVO.setDumpFilePath(oaSyncRawDumpHelper.finishOrgFullDump()); + } return emptyPageVO; } IncrementalStart incrementalStart = resolveIncrementalStart(incremental, modifiedSince, DeptCategory.DEPT); @@ -409,6 +432,9 @@ public class OASyncServiceImpl implements IOASyncService { List oaDepartments = responseData.getDataList() == null ? Collections.emptyList() : responseData.getDataList(); long totalSize = responseData.getTotalSize() == null ? 0L : responseData.getTotalSize(); + if (!incremental) { + oaSyncRawDumpHelper.collectDepartments(oaDepartments); + } OrgSyncCount syncCount = handleDept(oaDepartments, true); if (CollectionUtil.isNotEmpty(syncCount.getNotHandledList())) { ComposeLogUtil.getLastLog().warn("同步部门,本页未处理数据:{}", JSON.toJSONString(syncCount.getNotHandledList())); @@ -416,10 +442,14 @@ public class OASyncServiceImpl implements IOASyncService { OaOrgSyncPageVO pageVO = buildOrgSyncPageVO("department", pageNo, pageSize, totalSize, oaDepartments.size(), syncCount); pageVO.setModifiedSince(incrementalStart.getModifiedSince()); if (Boolean.TRUE.equals(pageVO.getFinished())) { - // 部门分批同步完成后,重建层级、清理无法挂载数据并刷新祖级列表 + // 部门分批同步完成后,清理白名单外组织、重建层级、清理无法挂载数据并刷新祖级列表 + this.removeOutsideWhitelistOrgs(); deptService.rebuildOaHierarchy(); deptService.removeOaOrphansWithoutParent(); deptService.updateAncestors(null); + if (!incremental) { + pageVO.setDumpFilePath(oaSyncRawDumpHelper.finishOrgFullDump()); + } } CacheUtil.clear(SYS_CACHE); CacheUtil.clear(SYS_CACHE, Boolean.FALSE); @@ -623,6 +653,7 @@ public class OASyncServiceImpl implements IOASyncService { } Set companyNames = syncAll ? Collections.emptySet() : getCompanyNames(); List allParam = oaCompanies.stream() + .filter(this::isAllowedOaCompany) .filter(company -> syncAll || companyNames.contains(company.getSubcompanyname())) .map(deptConvert::company2dept) .toList(); @@ -679,12 +710,14 @@ public class OASyncServiceImpl implements IOASyncService { return OrgSyncCount.empty(); } Map companyByOaId = loadCompanyByOaId(); + Set allowedCompanyOaIds = resolveAllowedCompanyOaIds(companyByOaId); String rootCompanyId = getRootCompanyId(); if (!syncAll && rootCompanyId == null) { return new OrgSyncCount(0, oaDepts.size(), Collections.emptyList()); } Set rootCompanyDeptNames = syncAll ? Collections.emptySet() : getRootCompanyDeptNames(); List allParam = oaDepts.stream() + .filter(oaDept -> allowedCompanyOaIds.contains(oaDept.getSubcompanyid1())) .filter(oaDept -> syncAll || rootCompanyId == null || !rootCompanyId.equals(oaDept.getSubcompanyid1()) @@ -1011,6 +1044,106 @@ public class OASyncServiceImpl implements IOASyncService { } } + /** + * 是否允许同步的 OA 公司:二级白名单,或其上级为二级白名单(三级子公司) + */ + private boolean isAllowedOaCompany(OACompanyResponse company) { + if (company == null || StringUtils.isBlank(company.getId())) { + return false; + } + if (OAConvertConstant.OA_SYNC_KEEP_SECOND_LEVEL_COMPANY_IDS.contains(company.getId())) { + return true; + } + return OAConvertConstant.OA_SYNC_KEEP_SECOND_LEVEL_COMPANY_IDS.contains(company.getSupsubcomid()); + } + + /** + * 解析允许同步部门的所属公司 OA 主键:二级白名单 ∪ 已同步的三级子公司 + */ + private Set resolveAllowedCompanyOaIds(Map companyByOaId) { + Set allowed = new HashSet<>(OAConvertConstant.OA_SYNC_KEEP_SECOND_LEVEL_COMPANY_IDS); + if (companyByOaId == null || companyByOaId.isEmpty()) { + return allowed; + } + for (Dept company : companyByOaId.values()) { + if (company == null || StringUtils.isBlank(company.getOaId())) { + continue; + } + if (OAConvertConstant.OA_SYNC_KEEP_SECOND_LEVEL_COMPANY_IDS.contains(company.getOaId()) + || OAConvertConstant.OA_SYNC_KEEP_SECOND_LEVEL_COMPANY_IDS.contains(company.getOaSupSubComId())) { + allowed.add(company.getOaId()); + } + } + return allowed; + } + + /** + * 清理不在白名单树内的 OA 公司及其子树(含部门) + */ + private int removeOutsideWhitelistOrgs() { + List oaCompanyList = deptService.list(Wrappers.lambdaQuery() + .eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode()) + .isNotNull(Dept::getOaId) + .ne(Dept::getOaId, "")); + Map companyByOaId = new HashMap<>(); + for (Dept company : oaCompanyList) { + if (StringUtils.isNotBlank(company.getOaId())) { + companyByOaId.put(company.getOaId(), company); + } + } + Set allowedCompanyOaIds = resolveAllowedCompanyOaIds(companyByOaId); + Set deleteIds = new HashSet<>(); + for (Dept company : oaCompanyList) { + if (!allowedCompanyOaIds.contains(company.getOaId())) { + deleteIds.add(company.getId()); + } + } + Map companyCodeToOaId = new HashMap<>(); + for (Dept company : oaCompanyList) { + if (StringUtils.isNotBlank(company.getDeptCode()) && StringUtils.isNotBlank(company.getOaId())) { + companyCodeToOaId.put(company.getDeptCode(), company.getOaId()); + } + } + List oaDeptList = deptService.list(Wrappers.lambdaQuery() + .eq(Dept::getDeptCategory, DeptCategory.DEPT.getCode()) + .isNotNull(Dept::getOaId) + .ne(Dept::getOaId, "")); + for (Dept dept : oaDeptList) { + String belongOaId = null; + if (StringUtils.isNotBlank(dept.getBelongCompanyCode())) { + belongOaId = companyCodeToOaId.get(dept.getBelongCompanyCode()); + } + if (StringUtils.isBlank(belongOaId) && StringUtils.isNotBlank(dept.getOaSupSubComId()) + && companyByOaId.containsKey(dept.getOaSupSubComId())) { + // 根部门上级可能直接是公司 OA 主键 + belongOaId = dept.getOaSupSubComId(); + } + if (StringUtils.isBlank(belongOaId) || !allowedCompanyOaIds.contains(belongOaId)) { + deleteIds.add(dept.getId()); + } + } + if (CollectionUtil.isEmpty(deleteIds)) { + return 0; + } + List allDeptList = deptService.list(); + boolean cascadeChanged = true; + while (cascadeChanged) { + cascadeChanged = false; + for (Dept dept : allDeptList) { + if (deleteIds.contains(dept.getId())) { + continue; + } + if (dept.getParentId() != null && deleteIds.contains(dept.getParentId())) { + deleteIds.add(dept.getId()); + cascadeChanged = true; + } + } + } + deptService.removeByIds(deleteIds); + ComposeLogUtil.getLastLog().info("清理白名单外 OA 组织{}条", deleteIds.size()); + return deleteIds.size(); + } + /** * 获取 OA 部门查询使用的子公司 id * @@ -1037,9 +1170,18 @@ public class OASyncServiceImpl implements IOASyncService { .ne(Dept::getOaId, "") ); } + Map companyByOaId = new HashMap<>(); + for (Dept company : companyList) { + String oaId = this.resolveOaCompanyId(company); + if (StringUtils.isNotBlank(oaId)) { + companyByOaId.put(oaId, company); + } + } + Set allowedCompanyOaIds = resolveAllowedCompanyOaIds(companyByOaId); return companyList.stream() .map(this::resolveOaCompanyId) .filter(StringUtils::isNotBlank) + .filter(allowedCompanyOaIds::contains) .distinct() .collect(Collectors.joining(",")); } diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaSyncRawDumpHelper.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaSyncRawDumpHelper.java new file mode 100644 index 00000000..0674f00f --- /dev/null +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaSyncRawDumpHelper.java @@ -0,0 +1,379 @@ +/** + * 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.service.impl; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONWriter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.tool.utils.DateUtil; +import org.springblade.system.log.ComposeLogUtil; +import org.springblade.thirdparty.oa.feign.IOAClient; +import org.springblade.thirdparty.oa.pojo.response.OACompanyResponse; +import org.springblade.thirdparty.oa.pojo.response.OADepartmentResponse; +import org.springblade.thirdparty.oa.pojo.response.OAPersonResponse; +import org.springblade.thirdparty.oa.pojo.search.OACompanySearch; +import org.springblade.thirdparty.oa.pojo.search.OADepartmentSearch; +import org.springblade.thirdparty.oa.pojo.search.OASearch; +import org.springblade.thirdparty.oa.util.OAUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; + +/** + * OA 全量同步原始数据落盘:按父级 id 组装层级 JSON + * + * @author Chill + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class OaSyncRawDumpHelper { + + private static final String ORPHAN_COMPANY_ID = "_orphan_company"; + private static final String ORPHAN_DEPT_ID = "_orphan_dept"; + + private final IOAClient oaClient; + + @Value("${third-party.oa.sync-dump-dir:./data/oa-sync}") + private String syncDumpDir; + + /** + * 当前全量同步会话收集到的人员原始数据 + */ + private final List collectingPersons = Collections.synchronizedList(new ArrayList<>()); + private volatile boolean collecting; + + /** + * 机构全量同步会话收集的公司/部门原始数据 + */ + private final List collectingCompanies = Collections.synchronizedList(new ArrayList<>()); + private final List collectingDepartments = Collections.synchronizedList(new ArrayList<>()); + private volatile boolean collectingOrg; + + /** + * 全量同步首页启动收集 + */ + public synchronized void beginFullDump() { + collectingPersons.clear(); + collecting = true; + ComposeLogUtil.getLastLog().info("已开启 OA 全量同步原始数据收集,目录={}", resolveDumpDir()); + } + + /** + * 机构全量同步首页启动收集公司/部门原始数据 + */ + public synchronized void beginOrgFullDump() { + collectingCompanies.clear(); + collectingDepartments.clear(); + collectingOrg = true; + ComposeLogUtil.getLastLog().info("已开启 OA 机构全量同步原始数据收集,目录={}", resolveDumpDir()); + } + + /** + * 收集本页人员原始数据 + */ + public void collectPersons(List oaPersons) { + if (!collecting || oaPersons == null || oaPersons.isEmpty()) { + return; + } + collectingPersons.addAll(oaPersons); + } + + /** + * 收集本页公司原始数据 + */ + public void collectCompanies(List oaCompanies) { + if (!collectingOrg || oaCompanies == null || oaCompanies.isEmpty()) { + return; + } + collectingCompanies.addAll(oaCompanies); + } + + /** + * 收集本页部门原始数据 + */ + public void collectDepartments(List oaDepartments) { + if (!collectingOrg || oaDepartments == null || oaDepartments.isEmpty()) { + return; + } + collectingDepartments.addAll(oaDepartments); + } + + /** + * 全量同步结束:先写入人员层级 JSON,再异步补齐公司/部门接口原始层级数据 + * + * @return 人员原始层级 JSON 文件路径,失败时返回 null + */ + public synchronized String finishFullDump() { + if (!collecting) { + return null; + } + List persons = new ArrayList<>(collectingPersons); + collecting = false; + collectingPersons.clear(); + try { + String stamp = DateUtil.format(new Date(), "yyyyMMddHHmmss"); + Map personPayload = new LinkedHashMap<>(); + personPayload.put("exportTime", DateUtil.format(new Date(), DateUtil.PATTERN_DATETIME)); + personPayload.put("personCount", persons.size()); + personPayload.put("personTree", buildPersonTree(persons)); + personPayload.put("personsRaw", persons); + Path personFile = writeDumpFile("oa-full-sync-person-raw-" + stamp + ".json", personPayload); + ComposeLogUtil.getLastLog().info("OA 人员原始层级数据已写入 {}", personFile.toAbsolutePath()); + CompletableFuture.runAsync(() -> dumpOrgRawAsync(stamp)); + return personFile.toAbsolutePath().toString(); + } catch (Exception exception) { + log.error("写入 OA 全量同步人员原始 JSON 失败", exception); + ComposeLogUtil.getLastLog().error("写入 OA 全量同步人员原始 JSON 失败:{}", exception.getMessage()); + return null; + } + } + + /** + * 机构全量同步结束:写入公司/部门接口原始数据(按父级 id 层级) + * + * @return JSON 文件路径,失败时返回 null + */ + public synchronized String finishOrgFullDump() { + if (!collectingOrg) { + return null; + } + List companies = new ArrayList<>(collectingCompanies); + List departments = new ArrayList<>(collectingDepartments); + collectingOrg = false; + collectingCompanies.clear(); + collectingDepartments.clear(); + try { + String stamp = DateUtil.format(new Date(), "yyyyMMddHHmmss"); + Path orgFile = writeOrgDumpFile(stamp, companies, departments); + ComposeLogUtil.getLastLog().info("OA 机构原始层级数据已写入 {}", orgFile.toAbsolutePath()); + return orgFile.toAbsolutePath().toString(); + } catch (Exception exception) { + log.error("写入 OA 机构全量同步原始 JSON 失败", exception); + ComposeLogUtil.getLastLog().error("写入 OA 机构全量同步原始 JSON 失败:{}", exception.getMessage()); + return null; + } + } + + private void dumpOrgRawAsync(String stamp) { + try { + List companies = fetchAllCompanies(); + List departments = fetchAllDepartments(companies); + Path orgFile = writeOrgDumpFile(stamp, companies, departments); + log.info("OA 公司/部门原始层级数据已写入 {}", orgFile.toAbsolutePath()); + } catch (Exception exception) { + log.error("异步写入 OA 公司/部门原始 JSON 失败", exception); + } + } + + private Path writeOrgDumpFile(String stamp, List companies, + List departments) throws IOException { + Map orgPayload = new LinkedHashMap<>(); + orgPayload.put("exportTime", DateUtil.format(new Date(), DateUtil.PATTERN_DATETIME)); + orgPayload.put("companyCount", companies.size()); + orgPayload.put("departmentCount", departments.size()); + orgPayload.put("companyTree", buildCompanyTree(companies)); + orgPayload.put("departmentTree", buildDepartmentTree(departments)); + orgPayload.put("companiesRaw", companies); + orgPayload.put("departmentsRaw", departments); + return writeDumpFile("oa-full-sync-org-raw-" + stamp + ".json", orgPayload); + } + + private List fetchAllCompanies() { + List companies = new ArrayList<>(); + OACompanySearch companySearch = new OACompanySearch(); + companySearch.setCurPage(1); + companySearch.setPageSize(100); + OAUtils.pageSyncHandler(companySearch, param -> oaClient.queryCompanyPage(new OASearch<>(param)), response -> { + log.error("导出公司原始数据失败 {}", JSON.toJSONString(response)); + return new ServiceException("导出公司原始数据失败"); + }, 10000, log::info).accept(companies::addAll); + return companies; + } + + private List fetchAllDepartments(List companies) { + List departments = new ArrayList<>(); + String subCompanyIds = companies.stream() + .map(OACompanyResponse::getId) + .filter(StringUtils::isNotBlank) + .distinct() + .reduce((left, right) -> left + "," + right) + .orElse(""); + OADepartmentSearch departmentSearch = new OADepartmentSearch(); + departmentSearch.setCurPage(1); + departmentSearch.setPageSize(100); + departmentSearch.setSubcompanyid1(subCompanyIds); + OAUtils.pageSyncHandler(departmentSearch, param -> oaClient.queryDepartmentPage(new OASearch<>(param)), response -> { + log.error("导出部门原始数据失败 {}", JSON.toJSONString(response)); + return new ServiceException("导出部门原始数据失败"); + }, 10000, log::info).accept(departments::addAll); + return departments; + } + + private List> buildCompanyTree(List companies) { + Map> nodeMap = new LinkedHashMap<>(); + for (OACompanyResponse company : companies) { + if (company == null || StringUtils.isBlank(company.getId())) { + continue; + } + Map node = new LinkedHashMap<>(); + node.put("id", company.getId()); + node.put("parentId", blankToRoot(company.getSupsubcomid())); + node.put("type", "company"); + node.put("name", company.getSubcompanyname()); + node.put("raw", company); + node.put("children", new ArrayList>()); + nodeMap.put(company.getId(), node); + } + return linkChildren(nodeMap); + } + + private List> buildDepartmentTree(List departments) { + Map> nodeMap = new LinkedHashMap<>(); + for (OADepartmentResponse department : departments) { + if (department == null || StringUtils.isBlank(department.getId())) { + continue; + } + Map node = new LinkedHashMap<>(); + node.put("id", department.getId()); + node.put("parentId", blankToRoot(department.getSupdepid())); + node.put("companyId", department.getSubcompanyid1()); + node.put("type", "department"); + node.put("name", department.getDepartmentname()); + node.put("raw", department); + node.put("children", new ArrayList>()); + nodeMap.put(department.getId(), node); + } + return linkChildren(nodeMap); + } + + private List> buildPersonTree(List persons) { + Map> companyMap = new LinkedHashMap<>(); + Map> deptMap = new LinkedHashMap<>(); + for (OAPersonResponse person : persons) { + if (person == null) { + continue; + } + String companyId = StringUtils.defaultIfBlank(person.getSubcompanyid1(), ORPHAN_COMPANY_ID); + String deptId = StringUtils.defaultIfBlank(person.getDepartmentid(), ORPHAN_DEPT_ID + "@" + companyId); + Map companyNode = companyMap.computeIfAbsent(companyId, key -> { + Map node = new LinkedHashMap<>(); + node.put("id", key); + node.put("parentId", "0"); + node.put("type", "company"); + node.put("name", StringUtils.defaultIfBlank(person.getSubcompanyname(), key)); + Map raw = new LinkedHashMap<>(); + raw.put("subcompanyid1", person.getSubcompanyid1()); + raw.put("subcompanyname", person.getSubcompanyname()); + raw.put("subcompanycode", person.getSubcompanycode()); + node.put("raw", raw); + node.put("children", new ArrayList>()); + return node; + }); + @SuppressWarnings("unchecked") + List> companyChildren = (List>) companyNode.get("children"); + Map deptNode = deptMap.computeIfAbsent(deptId, key -> { + Map node = new LinkedHashMap<>(); + node.put("id", key); + node.put("parentId", companyId); + node.put("type", "department"); + node.put("name", StringUtils.defaultIfBlank(person.getDepartmentname(), key)); + Map raw = new LinkedHashMap<>(); + raw.put("departmentid", person.getDepartmentid()); + raw.put("departmentname", person.getDepartmentname()); + raw.put("departmentcode", person.getDepartmentcode()); + raw.put("subcompanyid1", person.getSubcompanyid1()); + node.put("raw", raw); + node.put("children", new ArrayList>()); + companyChildren.add(node); + return node; + }); + @SuppressWarnings("unchecked") + List> deptChildren = (List>) deptNode.get("children"); + Map personNode = new LinkedHashMap<>(); + personNode.put("id", StringUtils.defaultIfBlank(person.getId(), person.getLoginid())); + personNode.put("parentId", deptId); + personNode.put("type", "person"); + personNode.put("name", person.getLastname()); + personNode.put("raw", person); + personNode.put("children", Collections.emptyList()); + deptChildren.add(personNode); + } + return new ArrayList<>(companyMap.values()); + } + + @SuppressWarnings("unchecked") + private List> linkChildren(Map> nodeMap) { + List> roots = new ArrayList<>(); + for (Map node : nodeMap.values()) { + String parentId = Objects.toString(node.get("parentId"), "0"); + Map parent = nodeMap.get(parentId); + if (parent != null && !Objects.equals(parent.get("id"), node.get("id"))) { + ((List>) parent.get("children")).add(node); + } else { + roots.add(node); + } + } + return roots; + } + + private String blankToRoot(String parentId) { + if (StringUtils.isBlank(parentId) || "0".equals(parentId) || "-1".equals(parentId)) { + return "0"; + } + return parentId; + } + + private Path writeDumpFile(String fileName, Map payload) throws IOException { + Path dir = resolveDumpDir(); + Files.createDirectories(dir); + Path dumpFile = dir.resolve(fileName); + String json = JSON.toJSONString(payload, JSONWriter.Feature.PrettyFormat, JSONWriter.Feature.WriteMapNullValue); + Files.writeString(dumpFile, json, StandardCharsets.UTF_8); + return dumpFile; + } + + private Path resolveDumpDir() { + return Paths.get(StringUtils.defaultIfBlank(syncDumpDir, "./data/oa-sync")).toAbsolutePath().normalize(); + } + +} 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 9a6da9c0..77ba09d0 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 @@ -86,7 +86,7 @@ public class OaUserListSyncHelper { private final IRoleService roleService; /** - * 从人员数据提取二级公司、三级部门,并挂到集团顶级(id=135/GWWL) 下 + * 从人员数据提取二级公司、三级部门,并挂到集团顶级(id=1/GWWL) 下 * * @param oaPersons OA人员 * @return 组织索引 diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerArchiveServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerArchiveServiceImpl.java index f3545757..6a305b09 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerArchiveServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerArchiveServiceImpl.java @@ -166,7 +166,7 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl captor = ArgumentCaptor.forClass(TransportVehicle.class); verify(transportVehicleService).updateById(captor.capture()); TransportVehicle patched = captor.getValue(); - assertEquals("桂物物流集团", patched.getOwnerName(), "所有人要跟着生效值更新"); + assertEquals("广西现代物流集团有限公司", patched.getOwnerName(), "所有人要跟着生效值更新"); assertEquals("管理挂靠", patched.getBusinessRelation()); assertEquals(18000, patched.getApprovedLoadKg()); assertEquals(RECORD_ID, patched.getEffectiveCertRecordId(), "内容与指针必须是同一次 UPDATE"); @@ -441,7 +441,7 @@ class CertAuditServiceImplTest { */ static String completeVehicleSnapshot() { return "{\"drivingLicenseNo\":\"450100123456\",\"approvedLoadKg\":18000," - + "\"ownerName\":\"桂物物流集团\",\"businessRelation\":\"管理挂靠\"," + + "\"ownerName\":\"广西现代物流集团有限公司\",\"businessRelation\":\"管理挂靠\"," + "\"vehicleType\":\"重型货车\",\"driveLicenseImage\":\"http://oss/xsz.jpg\"}"; } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverCertServiceImplTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverCertServiceImplTest.java index 505f57a1..44d58724 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverCertServiceImplTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverCertServiceImplTest.java @@ -464,13 +464,13 @@ class DriverCertServiceImplTest { assertEquals("重型货车", vehicleCaptor.getValue().getVehicleType()); assertNull(vehicleCaptor.getValue().getEffectiveCertRecordId(), "新建的车不得自带生效快照——是否已认证只能由审核通过决定"); - assertEquals("桂物物流集团", vehicleCaptor.getValue().getOrganizationName(), + assertEquals("广西现代物流集团有限公司", vehicleCaptor.getValue().getOrganizationName(), "所属组织必须从司机档案带出:该列 NOT NULL 且无默认值,空着这条 INSERT 直接报错"); assertEquals("管理挂靠", vehicleCaptor.getValue().getBusinessRelation(), "业务关系由司机在表单里选,服务端照实落库、不替用户猜"); assertNotNull(vehicleCaptor.getValue().getRegistrationNo(), "机动车登记编号该列 NOT NULL:司机端选填,缺值要写空串而不是 null"); - assertEquals("桂物物流集团", vehicleCaptor.getValue().getOwnerName(), + assertEquals("广西现代物流集团有限公司", vehicleCaptor.getValue().getOwnerName(), "所有人是行驶证上的登记主体,与所属组织是两个字段"); // 绑定:司机的 driving_vehicle 被追加该车牌 @@ -663,7 +663,7 @@ class DriverCertServiceImplTest { VehicleCertSubmitRequest request = new VehicleCertSubmitRequest(); request.setPlateNo(plateNo); request.setVehicleType("重型货车"); - request.setOwnerName("桂物物流集团"); + request.setOwnerName("广西现代物流集团有限公司"); request.setPlateColor("黄牌"); request.setEnergyType("柴油"); request.setBusinessRelation("管理挂靠"); @@ -691,7 +691,7 @@ class DriverCertServiceImplTest { driver.setDriverName("张三"); // 车辆表的 organization_name 是 NOT NULL 且无默认值:司机缺组织时建车必然失败, // 故建车链路要求它非空,fixture 也必须给 - driver.setOrganizationName("桂物物流集团"); + driver.setOrganizationName("广西现代物流集团有限公司"); driver.setDrivingVehicle(drivingVehicle); return driver; } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/OcrFieldSupportTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/OcrFieldSupportTest.java index 52530a56..f20619d8 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/OcrFieldSupportTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/OcrFieldSupportTest.java @@ -32,7 +32,7 @@ class OcrFieldSupportTest { "words_result": { "号牌号码": {"words": "桂A32523"}, "车辆类型": {"words": "重型厢式货车"}, - "所有人": {"words": "桂物物流集团"}, + "所有人": {"words": "广西现代物流集团有限公司"}, "车辆识别代码": {"words": "LGAX2A130J1001234"}, "档案编号": {"words": "450100123456"}, "核定载质量": {"words": "18000kg"}, @@ -48,7 +48,7 @@ class OcrFieldSupportTest { assertEquals("桂A32523", fields.get("plateNo")); assertEquals("重型厢式货车", fields.get("vehicleType")); - assertEquals("桂物物流集团", fields.get("ownerName")); + assertEquals("广西现代物流集团有限公司", fields.get("ownerName")); assertEquals("LGAX2A130J1001234", fields.get("vin")); assertEquals("450100123456", fields.get("drivingLicenseNo")); assertEquals(18000, fields.get("approvedLoadKg"), "带 kg 单位也要能取到数字"); @@ -160,7 +160,7 @@ class OcrFieldSupportTest { { "words_result": { "道路运输证号": {"words": "桂交运管桂字450100123456号"}, - "业户名称": {"words": "桂物物流集团"}, + "业户名称": {"words": "广西现代物流集团有限公司"}, "有效期": {"words": "2020-01-01 至 2030-01-01"} } } @@ -169,7 +169,7 @@ class OcrFieldSupportTest { Map fields = OcrFieldSupport.extract(BaiduOcrType.ROAD_TRANSPORT_CERTIFICATE, null, result); assertEquals("桂交运管桂字450100123456号", fields.get("roadTransportCertNo")); - assertEquals("桂物物流集团", fields.get("ownerName")); + assertEquals("广西现代物流集团有限公司", fields.get("ownerName")); assertEquals("2030-01-01", fields.get("roadTransportCertEndDate"), "止期字段取区间的最后一段,取第一段会把有效期起填成止"); assertEquals("2020-01-01", fields.get("roadTransportCertStartDate"), "区间要同时带出起止,否则用户得照图再敲一遍"); } diff --git a/blade-third-party-api/blade-oa-api/src/main/java/org/springblade/thirdparty/oa/constant/OAConvertConstant.java b/blade-third-party-api/blade-oa-api/src/main/java/org/springblade/thirdparty/oa/constant/OAConvertConstant.java index e157641b..10144bb0 100644 --- a/blade-third-party-api/blade-oa-api/src/main/java/org/springblade/thirdparty/oa/constant/OAConvertConstant.java +++ b/blade-third-party-api/blade-oa-api/src/main/java/org/springblade/thirdparty/oa/constant/OAConvertConstant.java @@ -1,5 +1,7 @@ package org.springblade.thirdparty.oa.constant; +import java.util.Set; + /** * OA转换常量类 * @author bfhuange @@ -33,11 +35,15 @@ public class OAConvertConstant { /** * OA 同步挂载的本地顶级组织主键(集团) */ - public static final Long OA_SYNC_ROOT_DEPT_ID = 135L; + public static final Long OA_SYNC_ROOT_DEPT_ID = 1L; /** - * OA 同步挂载的本地顶级组织默认名称(集团;已存在时不覆盖原名称,除非误写成外部组织) + * 历史集团顶级主键(迁移前为 id=135) */ - public static final String OA_SYNC_ROOT_DEPT_NAME = "桂物物流集团"; + public static final Long LEGACY_OA_SYNC_ROOT_DEPT_ID = 135L; + /** + * OA 同步挂载的本地顶级组织默认名称(集团) + */ + public static final String OA_SYNC_ROOT_DEPT_NAME = "广西现代物流集团有限公司"; /** * OA 同步挂载的本地顶级组织编码(集团) */ @@ -55,11 +61,15 @@ public class OAConvertConstant { */ public static final String LEGACY_ROOT_DEPT_CODE = "OACROOT"; /** - * 历史人员同步创建的伪顶级名称(与集团同名时仅清理非 id=135 的记录) + * 历史集团顶级名称(迁移/清理旧名时使用) */ public static final String LEGACY_ROOT_DEPT_NAME = "桂物物流集团"; /** * 同步人员时挂载的顶级组织名称(兼容旧引用) */ public static final String ROOT_COMPANY_NAME = OA_SYNC_ROOT_DEPT_NAME; + /** + * OA 同步保留的二级公司主键(OA 侧 id) + */ + public static final Set OA_SYNC_KEEP_SECOND_LEVEL_COMPANY_IDS = Set.of("2", "135"); } From e6868304a3cfa086926477d3cd1d8e440b69900b Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Thu, 15 Oct 2026 15:30:57 +0800 Subject: [PATCH 009/108] =?UTF-8?q?:bug:=20=E4=BF=AE=E5=A4=8D=E8=BD=AC?= =?UTF-8?q?=E7=BB=93=E7=AE=97=E5=80=99=E9=80=89=E6=98=8E=E7=BB=86=E7=BC=BA?= =?UTF-8?q?=E5=B0=91=E6=9D=A5=E6=BA=90=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/impl/ReceivablePayableDetailServiceImpl.java | 1 + 1 file changed, 1 insertion(+) 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 9edd897e..e9063abe 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 @@ -2862,6 +2862,7 @@ public class ReceivablePayableDetailServiceImpl map.put("contractId", detail.getContractId()); map.put("contractNo", detail.getContractNo()); map.put("contractName", detail.getContractName()); + map.put("sourceType", detail.getSourceType()); map.put("preSettlementNo", detail.getPreSettlementNo()); map.put("formalSettlementNo", detail.getFormalSettlementNo()); map.put("waybillNo", detail.getWaybillNo()); From 5f7735ec238fca57a8485eb77cb28bc3e5c18fd1 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Thu, 15 Oct 2026 16:56:46 +0800 Subject: [PATCH 010/108] =?UTF-8?q?:sparkles:=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E9=A2=84=E7=BB=93=E7=AE=97=E5=8D=95=E5=8F=B7=E9=A2=84=E8=A7=88?= =?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/controller/PreSettlementController.java | 7 +++++++ .../transport/service/IPreSettlementService.java | 5 +++++ .../transport/service/impl/PreSettlementServiceImpl.java | 5 +++++ 3 files changed, 17 insertions(+) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/PreSettlementController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/PreSettlementController.java index 0892b50e..e337b9a1 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/PreSettlementController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/PreSettlementController.java @@ -94,6 +94,13 @@ public class PreSettlementController extends BladeController { return R.data(preSettlementService.contractOptions(keyword)); } + @GetMapping("/next-no") + @ApiOperationSupport(order = 4) + @Operation(summary = "获取最新预结算单号") + public R nextNo() { + return R.data(preSettlementService.nextNo()); + } + @GetMapping("/fee-options") @ApiOperationSupport(order = 4) @Operation(summary = "费用类型及费用项") diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IPreSettlementService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IPreSettlementService.java index 28af8ada..c24b7240 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IPreSettlementService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IPreSettlementService.java @@ -33,6 +33,11 @@ public interface IPreSettlementService extends BaseService { PreSettlementVO detail(Long id); + /** + * 预览下一条预结算单号。仅供新增页面展示,真正落库的单号仍由保存时生成。 + */ + String nextNo(); + default List> contractOptions(String keyword) { return contractOptions(keyword, null); } 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 86f5eafe..6e9dd27e 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,11 @@ public class PreSettlementServiceImpl extends BaseServiceImpl> contractOptions(String keyword, Long projectId) { LambdaQueryWrapper wrapper = Wrappers.lambdaQuery() From fe35b39f2c3486886784ff79054240ff98a163ca Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Thu, 15 Oct 2026 20:50:18 +0800 Subject: [PATCH 011/108] =?UTF-8?q?:bug:=20=E8=A1=A5=E6=94=B6=E7=A5=A8?= =?UTF-8?q?=E7=99=BB=E8=AE=B0=E9=87=91=E8=9D=B6=E5=8D=95=E6=8D=AE=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E8=BF=81=E7=A7=BB=E8=84=9A=E6=9C=AC=EF=BC=8C=E5=AD=98?= =?UTF-8?q?=E9=87=8F=E5=BA=93=E7=BC=BA=20kingdee=5Fbill=5Fno=E3=80=81kingd?= =?UTF-8?q?ee=5Fstatus=20=E4=B8=A4=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...nvoice_receipt_kingdee_fields_20261015.sql | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 doc/sql/transport/blade_invoice_receipt_kingdee_fields_20261015.sql diff --git a/doc/sql/transport/blade_invoice_receipt_kingdee_fields_20261015.sql b/doc/sql/transport/blade_invoice_receipt_kingdee_fields_20261015.sql new file mode 100644 index 00000000..a38feff0 --- /dev/null +++ b/doc/sql/transport/blade_invoice_receipt_kingdee_fields_20261015.sql @@ -0,0 +1,42 @@ +-- 收票登记:补金蝶单据字段 +-- 已有环境里的 blade_invoice_receipt 由更早一版的建表语句创建,缺 kingdee_bill_no / +-- kingdee_status;建表语句用的是 CREATE TABLE IF NOT EXISTS,对已存在的表是空操作, +-- 所以只跑建表脚本补不上这两列,必须走 ALTER。 +-- 以下语句可重复执行。 +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_receipt` ADD COLUMN `kingdee_bill_no` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶单据编号'' AFTER `current_processor`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_invoice_receipt' AND COLUMN_NAME = 'kingdee_bill_no' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 存量行取默认值 unsynced;收票记录只有 synced / failed / unsynced 三种取值 +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_receipt` ADD COLUMN `kingdee_status` varchar(30) COLLATE utf8mb4_general_ci NOT NULL DEFAULT ''unsynced'' COMMENT ''金蝶同步状态:unsynced未同步、synced已同步、failed同步失败'' AFTER `kingdee_bill_no`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_invoice_receipt' AND COLUMN_NAME = 'kingdee_status' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_receipt` ADD KEY `idx_invoice_receipt_kingdee` (`kingdee_status`)', + 'SELECT 1') + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_invoice_receipt' AND INDEX_NAME = 'idx_invoice_receipt_kingdee' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 旧列 kingdee_received_invoice_no / kingdee_received_invoice_id / kingdee_push_status / +-- kingdee_push_msg / kingdee_push_time 在代码里已无任何引用,且取值集合与新模型对不上 +-- (旧值 pushed / assigned 在新模型里不存在),故不搬运数据,保持原样不动。 From 164ef5f8e23a9f2f1da6b47111834bf9eec4771c Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Thu, 15 Oct 2026 21:02:41 +0800 Subject: [PATCH 012/108] =?UTF-8?q?:bug:=20=E4=BF=AE=E5=A4=8D=E5=90=AF?= =?UTF-8?q?=E7=94=A8/=E5=81=9C=E7=94=A8=E5=8F=B8=E6=9C=BA=E4=B8=8E?= =?UTF-8?q?=E5=85=B3=E8=81=94=E8=B4=A6=E5=8F=B7=E6=97=B6=E6=B8=85=E7=A9=BA?= =?UTF-8?q?=E9=A9=BE=E9=A9=B6=E8=BD=A6=E8=BE=86=E7=BB=91=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit blade_transport_driver.driving_vehicle 标了 FieldStrategy.ALWAYS,更新时忽略 null 直接写。changeStatus 与同步小程序账号的 linkDriverUserId 都是只带少数 几列的 patch,没带这一列就会把司机的车辆绑定清成 NULL,司机端车辆列表随即 变成「暂无绑定车辆」,司机在界面上无法自行恢复。 两处改为按库中原值一并带上(与认证缓存、审核写回两处的既有做法一致), 并补 DriverServiceImplTest 四个用例:撤下修复后两条断言即变红。 --- .../service/impl/DriverServiceImpl.java | 19 ++ .../service/impl/DriverServiceImplTest.java | 189 ++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverServiceImplTest.java 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 97e7186b..825c3bc0 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 @@ -113,6 +113,8 @@ public class DriverServiceImpl extends BaseServiceImpl imp Driver driver = new Driver(); driver.setId(id); driver.setStatus(status); + // 只改 status 也必须带上 driving_vehicle 原值,理由见 carryDrivingVehicle + carryDrivingVehicle(id, driver); return updateById(driver); } @@ -446,9 +448,26 @@ public class DriverServiceImpl extends BaseServiceImpl imp Driver patch = new Driver(); patch.setId(driver.getId()); patch.setUserId(userId); + // 同上:这次只改 user_id,不带 driving_vehicle 原值同样会把车辆绑定清掉 + carryDrivingVehicle(driver.getId(), patch); updateById(patch); } + /** + * 把库里的 driving_vehicle 原值带进 patch。 + *

+ * 该列标了 {@code FieldStrategy.ALWAYS},更新时忽略 null 直接写:patch 里不带这一列, + * 一次只改别的列的更新就会把司机的车辆绑定清成 NULL,而司机端车辆列表正是按这一列 + * 的车牌匹配出来的(清空即「暂无绑定车辆」)。读不到该司机时保持原样, + * 让更新自己去失败。 + */ + private void carryDrivingVehicle(Long driverId, Driver patch) { + Driver current = getById(driverId); + if (current != null) { + patch.setDrivingVehicle(current.getDrivingVehicle()); + } + } + private Long defaultZero(Long value) { return value == null ? 0L : value; } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverServiceImplTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverServiceImplTest.java new file mode 100644 index 00000000..878fa815 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverServiceImplTest.java @@ -0,0 +1,189 @@ +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.core.tool.api.R; +import org.springblade.system.feign.IMpUserClient; +import org.springblade.system.feign.ISysClient; +import org.springblade.system.pojo.entity.MpUser; +import org.springblade.transport.mapper.DriverMapper; +import org.springblade.transport.pojo.entity.Driver; + +import java.lang.reflect.Field; +import java.time.LocalDate; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 司机管理保存与状态变更测试。 + *

+ * 守住的是一条不变式:司机档案的行更新一律不得把「驾驶车辆」绑定写掉。 + * 该列标了 {@code FieldStrategy.ALWAYS},patch 里不带它就写成 NULL, + * 司机端车辆列表随即变成「暂无绑定车辆」,而司机在界面上无法自行恢复。 + */ +class DriverServiceImplTest { + + private static final Long DRIVER_ID = 9001L; + private static final Long USER_ID = 7001L; + private static final String TENANT_ID = "000000"; + private static final String PLATE = "桂A32523"; + private static final String MOBILE = "15975348524"; + + private DriverMapper driverMapper; + private IMpUserClient mpUserClient; + private ISysClient sysClient; + private DriverServiceImpl service; + + @BeforeAll + static void initTableInfo() { + // saveOrUpdate 需要 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); + mpUserClient = mock(IMpUserClient.class); + sysClient = mock(ISysClient.class); + service = new DriverServiceImpl(mpUserClient, sysClient); + inject("baseMapper", driverMapper); + } + + // ------------------------------------------------------------ 启用 / 停用 + + @Test + @DisplayName("启用/停用司机:patch 必须带上驾驶车辆原值,否则这次更新就把车辆绑定清成 NULL") + void changeStatusKeepsDrivingVehicle() { + when(driverMapper.selectById(DRIVER_ID)).thenReturn(driver(PLATE)); + when(driverMapper.updateById(any(Driver.class))).thenReturn(1); + + assertTrue(service.changeStatus(DRIVER_ID, 2)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Driver.class); + verify(driverMapper).updateById(captor.capture()); + assertEquals(2, captor.getValue().getStatus()); + assertEquals(PLATE, captor.getValue().getDrivingVehicle(), + "只改 status 的 patch 不带 driving_vehicle 会把司机的车辆绑定写掉"); + } + + @Test + @DisplayName("启用/停用司机:状态取值不合法时拒绝,且不写库") + void changeStatusRejectsInvalidStatus() { + assertThrows(ServiceException.class, () -> service.changeStatus(DRIVER_ID, 9)); + verify(driverMapper, never()).updateById(any(Driver.class)); + } + + // ------------------------------------------------------------ 保存司机 + + @Test + @DisplayName("保存司机:关联小程序账号的那次 patch 也必须带上驾驶车辆原值") + void submitKeepsDrivingVehicleWhenLinkingMpUser() { + Driver driver = driver(PLATE); + // 提交前校验按身份证号与主键各查一次 + when(driverMapper.selectByIdIncludingDeleted(DRIVER_ID)).thenReturn(driver); + when(driverMapper.selectByIdCardNoIncludingDeleted(driver.getIdCardNo())).thenReturn(null); + // 完整对象更新:saveOrUpdate 判存在走更新 + when(driverMapper.selectById(DRIVER_ID)).thenReturn(driver); + when(driverMapper.updateById(any(Driver.class))).thenReturn(1); + // 先查不到小程序用户 → 建号 → 回查拿到新建用户 + MpUser created = new MpUser(); + created.setId(USER_ID); + when(mpUserClient.userByPhone(TENANT_ID, MOBILE)).thenReturn(null, R.data(created)); + when(mpUserClient.saveUser(any(MpUser.class))).thenReturn(R.data(true)); + + try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { + authUtil.when(AuthUtil::getTenantId).thenReturn(TENANT_ID); + assertTrue(service.submit(driver)); + } + + ArgumentCaptor captor = ArgumentCaptor.forClass(Driver.class); + verify(driverMapper, times(2)).updateById(captor.capture()); + // captor 存的是引用:完整对象更新带走的是 driver 本身,而它随后会被就地写上 userId, + // 所以只能用对象身份把「只带少数几列的 patch」摘出来,不能按 userId 认 + Driver linkPatch = captor.getAllValues().stream() + .filter(patch -> patch != driver) + .findFirst() + .orElseThrow(() -> new AssertionError("没有发出关联小程序账号的那次 patch")); + assertEquals(USER_ID, linkPatch.getUserId()); + assertEquals(PLATE, linkPatch.getDrivingVehicle(), + "关联账号的 patch 不带 driving_vehicle 会把司机的车辆绑定写掉"); + } + + @Test + @DisplayName("保存司机:司机不存在时拒绝,不写库") + void submitRejectsUnknownDriver() { + Driver driver = driver(PLATE); + when(driverMapper.selectByIdIncludingDeleted(DRIVER_ID)).thenReturn(null); + + assertThrows(ServiceException.class, () -> service.submit(driver)); + verify(driverMapper, never()).updateById(any(Driver.class)); + } + + // ------------------------------------------------------------ 夹具 + + private Driver driver(String drivingVehicle) { + Driver driver = new Driver(); + driver.setId(DRIVER_ID); + driver.setIsDeleted(0); + driver.setDriverName("张三"); + driver.setIdCardNo("110101199001011234"); + driver.setGender("男"); + driver.setPosts("司机"); + driver.setDrivingType("A2"); + driver.setDrivingLicenseNo("110101199001011234"); + driver.setDrivingLicenseStartDate(LocalDate.of(2020, 1, 1)); + driver.setDrivingLicenseLongTerm(1); + driver.setQualificationType("道路运输"); + driver.setQualificationNo("Q123456"); + driver.setQualificationLongTerm(1); + driver.setDriverType("自有"); + driver.setMobile(MOBILE); + driver.setOrganizationName("测试组织"); + driver.setEmergencyContactName("李四"); + driver.setEmergencyContactMobile("13800000000"); + driver.setIdCardFront("https://oss/id-front.jpg"); + driver.setIdCardBack("https://oss/id-back.jpg"); + driver.setDrivingLicenseFront("https://oss/dl-front.jpg"); + driver.setDrivingLicenseBack("https://oss/dl-back.jpg"); + 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 2872edaeb4c7e78e9c49b67c4c048f7bee11c9c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Thu, 15 Oct 2026 21:03:27 +0800 Subject: [PATCH 013/108] =?UTF-8?q?:fix:=20=E4=BF=AE=E5=A4=8D=E5=AE=A2?= =?UTF-8?q?=E5=95=86=E5=AE=A1=E6=A0=B8=E9=80=9A=E8=BF=87=E6=8E=A8=E9=80=81?= =?UTF-8?q?=E9=87=91=E8=9D=B6=E5=A4=B1=E8=B4=A5=E8=A2=AB=E5=90=9E=E5=BC=82?= =?UTF-8?q?=E5=B8=B8=E4=B8=94=E5=A4=B1=E8=B4=A5=E7=8A=B6=E6=80=81=E6=9C=AA?= =?UTF-8?q?=E8=90=BD=E5=BA=93=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/CustomerArchiveController.java | 20 ++++--- .../service/ICustomerArchiveService.java | 8 +++ .../impl/CustomerArchiveServiceImpl.java | 56 +++++++++++++++++-- 3 files changed, 72 insertions(+), 12 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/CustomerArchiveController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/CustomerArchiveController.java index d0998aa3..b0580c5a 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/CustomerArchiveController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/CustomerArchiveController.java @@ -136,21 +136,25 @@ public class CustomerArchiveController extends BladeController { } /** - * 审核通过(通过后自动推送金蝶,推送失败不影响审批结果) + * 审核通过(通过后自动推送金蝶,推送失败不影响审批结果,失败原因以警告文案返回) */ @PostMapping("/approve") @ApiOperationSupport(order = 6) @Operation(summary = "审核通过", description = "传入id") public R approve(@Parameter(description = "主键", required = true) @RequestParam Long id) { boolean result = customerArchiveService.approve(id); - if (result) { - try { - customerArchiveService.syncKingdee(id); - } catch (Exception exception) { - log.error("客商审批通过后推送金蝶异常,id={}", id, exception); - } + if (!result) { + return R.status(false); + } + try { + customerArchiveService.syncKingdee(id); + return R.success("审核通过"); + } catch (Exception exception) { + log.error("客商审批通过后推送金蝶异常,id={}", id, exception); + String reason = exception.getMessage() == null ? "金蝶接口调用异常" : exception.getMessage(); + // 与结算/付款/开票审批口径一致:审批已生效,推送失败原因以 data=false 警告文案返回 + return R.data(Boolean.FALSE, "审核通过,但推送金蝶失败:" + reason); } - return R.status(result); } /** diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ICustomerArchiveService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ICustomerArchiveService.java index 459e35cd..e50f1a47 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ICustomerArchiveService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ICustomerArchiveService.java @@ -196,6 +196,14 @@ public interface ICustomerArchiveService extends BaseService { */ CustomerArchive syncKingdee(Long id); + /** + * 推送失败状态落库(独立事务):推送主事务已回滚,失败状态与原因单独落库供页面展示与手动重推 + * + * @param id 主键 + * @param reason 失败原因 + */ + void markKingdeeSyncFailed(Long id, String reason); + /** * 定时同步金蝶客商数据:待推送客商自动推送 + 已同步客商使用状态回写 * diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerArchiveServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerArchiveServiceImpl.java index f3545757..af8f84c2 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerArchiveServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerArchiveServiceImpl.java @@ -34,6 +34,7 @@ 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; +import org.springblade.core.tool.utils.SpringUtil; import org.springblade.transport.mk.MkProcessNodeHelper; import org.springblade.transport.support.TransportBusinessSupport; import org.springblade.core.tenant.annotation.TenantIgnore; @@ -96,6 +97,7 @@ import org.springblade.transport.pojo.vo.CustomerReceiptAccountVO; import org.springblade.transport.service.ICustomerArchiveService; import org.springblade.transport.support.KingdeeSyncLockSupport; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; @@ -528,14 +530,52 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpllambdaUpdate() + .eq(CustomerArchive::getId, id) + .eq(CustomerArchive::getApprovalStatus, APPROVAL_APPROVED) + .and(wrapper -> wrapper.isNull(CustomerArchive::getKingdeeStatus) + .or() + .notIn(CustomerArchive::getKingdeeStatus, List.of(KINGDEE_STATUS_SYNCED, KINGDEE_STATUS_EXIST))) + .set(CustomerArchive::getKingdeeStatus, KINGDEE_STATUS_FAILED) + .set(CustomerArchive::getKingdeeFailReason, failReason)); + if (updated > 0 && before != null) { + // 变更记录留档(推送主事务已回滚,此处独立事务落状态与记录) + CustomerArchive after = copyCustomer(before); + after.setKingdeeStatus(KINGDEE_STATUS_FAILED); + after.setKingdeeFailReason(failReason); + addChangeRecord(id, "推送金蝶失败:" + failReason, before, after); + } + } + /** * 组装 A902-2 报文推送金蝶并回写协同状态;detail 由调用方准备, - * 定时同步批量组装报文数据,避免逐条 detail 查询的 N+1 问题 + * 定时同步批量组装报文数据,避免逐条 detail 查询的 N+1 问题; + * 推送失败先落 failed 状态与变更记录,再抛出异常由调用方提示前端(与结算/付款/开票口径一致) */ private void pushCustomerToKingdee(Long id, CustomerArchive customer, CustomerArchive after, CustomerArchiveVO detail) { try { @@ -549,12 +589,18 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl())); - CustomerArchive after = copyCustomer(customer); - after.setKingdeeSyncTime(LocalDateTime.now()); pushCustomerToKingdee(customer.getId(), customer, after, detail); updateList.add(after); if (Objects.equals(after.getKingdeeStatus(), KINGDEE_STATUS_SYNCED)) { @@ -735,6 +781,8 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl Date: Fri, 16 Oct 2026 00:15:40 +0800 Subject: [PATCH 014/108] =?UTF-8?q?feat:=20=E4=BB=98=E6=AC=BE=E7=94=B3?= =?UTF-8?q?=E8=AF=B7=E9=87=91=E8=9D=B6=E7=94=9F=E6=88=90=E5=8D=B3=E5=9B=9E?= =?UTF-8?q?=E6=98=BE=E9=A2=84=E7=BB=93=E7=AE=97=E9=A2=84=E4=BB=98=E4=BF=A1?= =?UTF-8?q?=E6=81=AF=EF=BC=8C=E4=BD=9C=E5=BA=9F/=E8=A7=A3=E9=94=81?= =?UTF-8?q?=E8=81=94=E5=8A=A8=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/PaymentApplicationServiceImpl.java | 43 ++++++++++++++++--- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java index 742b5931..eb8e1d83 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java @@ -421,6 +421,8 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpllambdaUpdate() + .eq(PreSettlementAdvance::getPreSettlementId, entity.getPreSettlementId()) + .eq(PreSettlementAdvance::getAdvanceNo, entity.getPaymentNo()) + .eq(PreSettlementAdvance::getIsDeleted, 0) + .ne(PreSettlementAdvance::getBillStatus, "paid") + .set(PreSettlementAdvance::getBillStatus, TransportBusiConstant.VOIDED)); + refreshPreSettlementAdvanceSummary(entity.getPreSettlementId()); + } + private BigDecimal findPreSettlementPaid(Long preSettlementId) { return preSettlementAdvanceMapper.selectList(Wrappers.lambdaQuery() .eq(PreSettlementAdvance::getPreSettlementId, preSettlementId) From 591b93f1573442d63db4ea508f37636364acf8d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Fri, 16 Oct 2026 00:15:49 +0800 Subject: [PATCH 015/108] =?UTF-8?q?docs:=20=E6=96=B0=E5=A2=9E=E4=BB=98?= =?UTF-8?q?=E6=AC=BE=E7=94=B3=E8=AF=B7=E7=94=9F=E6=88=90=E5=8D=B3=E5=9B=9E?= =?UTF-8?q?=E6=98=BE=E9=A2=84=E4=BB=98=E4=BF=A1=E6=81=AF=E6=94=B9=E9=80=A0?= =?UTF-8?q?=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...即回显预结算预付信息(synced提前建档-作废联动清理).md | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 doc/docs/业务流程/2026-10-16-付款申请金蝶生成即回显预结算预付信息(synced提前建档-作废联动清理).md diff --git a/doc/docs/业务流程/2026-10-16-付款申请金蝶生成即回显预结算预付信息(synced提前建档-作废联动清理).md b/doc/docs/业务流程/2026-10-16-付款申请金蝶生成即回显预结算预付信息(synced提前建档-作废联动清理).md new file mode 100644 index 00000000..1cd0cae7 --- /dev/null +++ b/doc/docs/业务流程/2026-10-16-付款申请金蝶生成即回显预结算预付信息(synced提前建档-作废联动清理).md @@ -0,0 +1,53 @@ +# 付款申请金蝶生成即回显预结算预付信息:synced 提前建档 + 作废联动清理 + +> 日期:2026-10-16 +> 关联文档: +> - `doc/docs/业务流程/2026-09-29-预付款申请A103-3金蝶链路补齐方案(e_bebank_number统一-消失解锁-回写默认开启).md`(A103-3 推送与 A103-2 消失解锁) +> - `doc/docs/业务流程/2026-09-17-付款管理金蝶付款结果定时回写(A103-2付款申请单查询+A103-4付款处理查询)技术文档.md`(回写任务与 `applyPayApplyResult`) +> - `doc/docs/业务流程/2026-09-20-应付申请与付款申请同步处理全链路业务流程(A102-2下推-A102-4查询-A103-1生成-A103-2回写).md` + +## 1. 背景与问题 + +排查预结算单 YJ2026101500001(已完成预结算审批、付款申请 FKD2026101500001 审批、金蝶付款申请单生成 FKSQ-00000229):预结算单详情页"预付信息"卡片始终空白。 + +根因:`blade_pre_settlement_advance`(预付记录表,详情接口 `advances` 字段的数据源)此前**只在金蝶回写"付款完成"(`kingdeeStatus=paid`)时才写入**(`applyPayApplyResult` → `syncPreSettlementAdvance`)。付款申请推进到"已生成"(synced)阶段不落记录,页面预付信息必须等付款完成才可见;且基本信息区的预付申请/已付金额来自付款申请实时汇总(`fillPaymentApplicationAmounts`),金额有值、明细卡片空白,容易误判。 + +## 2. 改造内容(`PaymentApplicationServiceImpl`) + +| # | 变更点 | 变更前 | 变更后 | +|---|---|---|---| +| 1 | `syncPreSettlementAdvance` | 无参方法,写死 `billStatus="paid"`、已付=申请全额 | 参数化 `(entity, billStatus, paidAmount)`,建档与付款完成共用;按付款申请单号查重幂等 | +| 2 | `doSyncKingdee` synced 落库处 | 仅更新付款申请状态 | **生成成功即建档**:`approved` / 已付 0,同事务落库,预付信息提前回显 | +| 3 | `applyPayApplyResult` paid 分支 | 调原方法 | `paid` / 申请全额(语义不变),已建档时更新为已付款 | +| 4 | `applyPayApplyResult` 勾稽分支(新增) | failed 单据经 A103-2 勾稽确认 synced/paying 时不落预付记录 | 与推送成功同语义,`approved` / 已付 0 建档 | +| 5 | 新增 `voidPreSettlementAdvance` | 无 | 按付款申请单号将未付预付记录置 `voided`(`paid` 记录不联动,付款完成事实不回退),并刷新预结算汇总 | +| 6 | `voidBill`(付款申请作废) | 不清理预付记录 | 联动作废,避免残留记录阻断预结算作废(`PreSettlementServiceImpl#voidBill` 的"存在关联预付申请"拦截) | +| 7 | `unlockBill`(金蝶反审核解锁,含 A103-2 连续两轮消失判定) | 不清理预付记录 | 联动作废,重推生成新单后重新建档 | + +建档/作废后的联动刷新(`refreshPreSettlementAdvanceSummary` 预结算 advance_no/预付金额列、`formal_settlement_source` 预付金额、正式结算付款汇总)复用原有尾部逻辑,无需额外改动。转正式结算时来源表预付金额不再恒为 0。 + +## 3. 状态与联动时序 + +| 付款申请阶段 | 预付记录(blade_pre_settlement_advance) | 页面预付信息 | +|---|---|---| +| 审批通过、未推送 | 不存在 | 空白 | +| 金蝶付款申请单生成成功(synced) | `approved` / 已付 0(提前建档) | **回显**(状态"审批通过") | +| 付款中(paying)/ 勾稽确认已生成 | `approved`(补建或维持) | 回显 | +| 付款完成(paid 回写) | `paid` / 已付全额 | 回显(状态"已付款") | +| 付款申请作废 / 金蝶反审核解锁 | 未付记录置 `voided`(`paid` 不动) | 消失(汇总同步扣减) | + +前端零改动:`pre-settlement-editor.vue` 的 `advanceStatusName` 已含 `approved→审批通过` 映射。 + +## 4. 定时任务影响 + +- **付款结果回写任务(`syncKingdeePaymentResult`)**:零改动。轮询条件基于付款申请自身状态,不依赖预付记录表;paid 回写时命中已有记录更新,不产生重复。 +- **失败重推(`retryKingdeeFailed`)**:failed 阶段未建档,重推成功经 synced 建档一次,幂等查重兜底。 +- **A103-4 付款记录回填、金蝶单据锁补偿**:不涉及预付记录表,无影响。 + +## 5. 行为变化与存量数据 + +| 项 | 说明 | +|---|---| +| 预结算作废拦截提前 | "存在关联预付申请,请先作废预付申请"从付款完成后提前到金蝶生成即生效——作废预结算前需先作废付款申请(预期收紧) | +| 存量单据不自动补 | 改造上线前已 synced 的付款申请(如 FKD2026101500001)不会重推(状态门禁跳过 synced),预付记录要等 paid 回写补建;需立即回显的,手工补一条 `blade_pre_settlement_advance`(`advance_no=付款申请单号`、`bill_status='approved'`、已付 0) | +| 金额模糊匹配触发提前 | synced 建档时同额未付记录的兜底匹配与 paid 阶段同一逻辑,仅触发时点提前;如出现误绑定,可将模糊匹配收敛到 paid 分支 | From a76bd01482fbf712ab060aed32d0c6a94a9b9e99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Fri, 16 Oct 2026 00:33:19 +0800 Subject: [PATCH 016/108] =?UTF-8?q?:feat:=20=E9=87=91=E8=9D=B6=E7=A5=A8?= =?UTF-8?q?=E6=B1=A0=E6=96=B0=E5=A2=9E=E5=AE=A2=E5=95=86=E5=8D=95=E4=BD=8D?= =?UTF-8?q?=E8=BF=87=E6=BB=A4=E5=BC=80=E5=85=B3=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit thirdParty.bfi.invoiceReceipt.customerUnitsOnly(默认 false)控制是否只同步开票单位与受票单位税号均已登记在客商发票信息中的发票,前端票池展示同用此开关 --- .../org/springblade/transport/config/BfiProperties.java | 6 ++++++ doc/nacos/third-party-api.yaml | 2 ++ 2 files changed, 8 insertions(+) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/config/BfiProperties.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/config/BfiProperties.java index f2e38a9d..760d80ec 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/config/BfiProperties.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/config/BfiProperties.java @@ -307,6 +307,12 @@ public class BfiProperties { */ private String invoiceTypes = ""; + /** + * 客商单位过滤开关:开启后只同步开票单位(销方)与受票单位(购方)税号均已登记在 + * 客商发票信息(blade_customer_invoice_info)中的发票,前端票池展示查询同用此开关过滤,默认关闭 + */ + private boolean customerUnitsOnly = false; + /** * 同步 single-flight 锁租约秒数,须大于最坏一轮耗时;进程异常退出时锁在租约到期后自动释放 */ diff --git a/doc/nacos/third-party-api.yaml b/doc/nacos/third-party-api.yaml index caa5f604..bdd092fd 100644 --- a/doc/nacos/third-party-api.yaml +++ b/doc/nacos/third-party-api.yaml @@ -118,6 +118,8 @@ thirdParty: incrementDays: ${BFI_INVOICE_RECEIPT_INCREMENT_DAYS:30} # 同步发票种类白名单(逗号分隔票易通编码,如 26,27 数电普票/专票),为空不限制 invoiceTypes: ${BFI_INVOICE_RECEIPT_INVOICE_TYPES:26,27} + # 客商单位过滤开关:开启后只同步开票单位(销方)与受票单位(购方)税号均已登记在客商发票信息中的发票,前端票池展示查询同用此开关过滤 + customerUnitsOnly: ${BFI_INVOICE_RECEIPT_CUSTOMER_UNITS_ONLY:false} # 补偿绑定单轮限量(未绑定登记按发票号码精确补查条数上限) compensateLimit: ${BFI_INVOICE_RECEIPT_COMPENSATE_LIMIT:200} # single-flight 分布式锁租约秒数,须大于最坏一轮耗时;进程异常退出时锁在租约到期后自动释放 From e8a933cf9ebe21b706bfd3b60e11fb25aeb0b037 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Fri, 16 Oct 2026 00:33:28 +0800 Subject: [PATCH 017/108] =?UTF-8?q?:feat:=20=E9=87=91=E8=9D=B6=E7=A5=A8?= =?UTF-8?q?=E6=B1=A0=E5=90=8C=E6=AD=A5=E4=B8=8E=E5=B1=95=E7=A4=BA=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E6=8C=89=E5=AE=A2=E5=95=86=E5=8D=95=E4=BD=8D=E8=BF=87?= =?UTF-8?q?=E6=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 定时同步拉取后按客商发票信息税号集过滤,销/购方税号未同时登记的发票不入镜像(短页判定按原始条数,过滤不影响切片续扫);票池统一分页查询增加双 EXISTS 过滤与同步同开关同口径,收票登记选票与结算单认领两处展示自动生效 --- .../mapper/KingdeeInvoicePoolMapper.java | 10 +-- .../mapper/KingdeeInvoicePoolMapper.xml | 12 ++++ .../impl/InvoiceReceiptServiceImpl.java | 71 +++++++++++++++---- 3 files changed, 74 insertions(+), 19 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeInvoicePoolMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeInvoicePoolMapper.java index 29ddf75d..abdb5c3f 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeInvoicePoolMapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeInvoicePoolMapper.java @@ -44,11 +44,13 @@ public interface KingdeeInvoicePoolMapper extends BaseMapper /** * 金蝶票据池镜像统一分页查询:仅出可用票(剩余可用金额>0 且未使用),认领/登记标记由 EXISTS 子查询直出 * - * @param page 分页参数 - * @param query 统一查询条件(已在上层完成 trim 与日期格式校验) - * @param tenantId 当前租户 + * @param page 分页参数 + * @param query 统一查询条件(已在上层完成 trim 与日期格式校验) + * @param tenantId 当前租户 + * @param customerUnitsOnly 客商单位过滤开关:开启时只出销/购方税号均登记在客商发票信息中的发票(与定时同步同开关) * @return 统一视图分页 */ IPage selectInvoicePoolPage(IPage page, - @Param("query") KingdeeInvoicePoolQueryDTO query, @Param("tenantId") String tenantId); + @Param("query") KingdeeInvoicePoolQueryDTO query, @Param("tenantId") String tenantId, + @Param("customerUnitsOnly") boolean customerUnitsOnly); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeInvoicePoolMapper.xml b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeInvoicePoolMapper.xml index b25b0ddc..0c1e7969 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeInvoicePoolMapper.xml +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeInvoicePoolMapper.xml @@ -39,12 +39,24 @@ AND p.available_amount > 0 AND p.amount_use_status = '0' + + + AND EXISTS (SELECT 1 FROM blade_customer_invoice_info ci + WHERE ci.is_deleted = 0 AND ci.status = 1 AND ci.tenant_id = p.tenant_id AND ci.tax_no = p.saler_tax_no) + AND EXISTS (SELECT 1 FROM blade_customer_invoice_info cj + WHERE cj.is_deleted = 0 AND cj.status = 1 AND cj.tenant_id = p.tenant_id AND cj.tax_no = p.buyer_tax_no) + + + SELECT * + FROM blade_measurement_unit + WHERE unit_code = #{unitCode} + LIMIT 1 + + + + + + UPDATE blade_measurement_unit + SET is_deleted = 0 + WHERE id = #{id} + AND is_deleted = 1 + + + + UPDATE blade_measurement_unit + SET unit_name = #{unitName} + WHERE id = #{id} + AND is_deleted = 1 + + + SELECT COALESCE(SUM(w.write_off_amount), 0) + FROM blade_settlement_project_advance_write_off w + LEFT JOIN blade_pre_settlement p + ON w.settlement_type = 'pre' + AND w.settlement_id = p.id + AND p.is_deleted = 0 + LEFT JOIN blade_formal_settlement f + ON w.settlement_type = 'formal' + AND w.settlement_id = f.id + AND f.is_deleted = 0 + WHERE w.is_deleted = 0 + AND w.payment_application_id = #{paymentApplicationId} + AND ( + (w.settlement_type = 'pre' AND p.id IS NOT NULL AND IFNULL(p.approval_status, '') <> 'voided') + OR (w.settlement_type = 'formal' AND f.id IS NOT NULL AND IFNULL(f.approval_status, '') <> 'voided') + ) + + AND NOT (w.settlement_type = #{excludeSettlementType} AND w.settlement_id = #{excludeSettlementId}) + + + + + + diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IPaymentApplicationService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IPaymentApplicationService.java index 6d4807af..e68ec0c0 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IPaymentApplicationService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IPaymentApplicationService.java @@ -28,6 +28,7 @@ import org.springblade.transport.pojo.dto.PaymentApplicationStatusRequest; import org.springblade.transport.pojo.entity.PaymentApplication; import org.springblade.transport.pojo.vo.PaymentApplicationReferenceAmountVO; import org.springblade.transport.pojo.vo.PaymentApplicationVO; +import org.springblade.transport.pojo.vo.ProjectAdvanceWriteOffOptionVO; import java.util.List; @@ -36,6 +37,11 @@ public interface IPaymentApplicationService extends BaseService selectPage(IPage page, PaymentApplicationVO query); PaymentApplicationVO detail(Long id); PaymentApplicationReferenceAmountVO referenceAmount(String paymentType, Long referenceId, Long excludeId); + + /** 按合同查询可选项目预付核销列表。 */ + List projectAdvanceWriteOffOptions(Long contractId, + String excludeSettlementType, + Long excludeSettlementId); Long saveDraft(PaymentApplicationSaveRequest request); void removeDraft(Long id); void submit(PaymentApplicationStatusRequest request); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementProjectAdvanceWriteOffService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementProjectAdvanceWriteOffService.java new file mode 100644 index 00000000..9c1d7368 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementProjectAdvanceWriteOffService.java @@ -0,0 +1,53 @@ +/** + * 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. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.service; + +import org.springblade.transport.pojo.dto.SettlementProjectAdvanceWriteOffRequest; +import org.springblade.transport.pojo.entity.SettlementProjectAdvanceWriteOff; +import org.springblade.transport.pojo.vo.ProjectAdvanceWriteOffOptionVO; + +import java.math.BigDecimal; +import java.util.List; + +/** + * 结算单项目预付核销服务 + * + * @author Chill + */ +public interface ISettlementProjectAdvanceWriteOffService { + + /** + * 按合同查询可选项目预付(审批通过且仍有可核销余额)。 + */ + List listOptions(Long contractId, String excludeSettlementType, + Long excludeSettlementId); + + /** + * 查询结算单已保存的核销明细(含动态可核销金额)。 + */ + List listBySettlement(String settlementType, Long settlementId); + + /** + * 结算单核销金额合计。 + */ + BigDecimal sumBySettlement(String settlementType, Long settlementId); + + /** + * 覆盖保存核销明细,并校验不超过可核销金额与结算单金额。 + */ + void replaceWriteOffs(String settlementType, Long settlementId, Long contractId, + BigDecimal settlementAmount, List writeOffs); + + /** + * 物理清理结算单核销明细(草稿删除时)。 + */ + void deleteBySettlement(String settlementType, Long settlementId); + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CommonRouteServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CommonRouteServiceImpl.java index b2b2da65..2c8ec740 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CommonRouteServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CommonRouteServiceImpl.java @@ -30,6 +30,7 @@ import org.springblade.core.log.exception.ServiceException; import org.springblade.core.mp.base.BaseServiceImpl; import org.springblade.core.tool.utils.BeanUtil; import org.springblade.core.tool.utils.Func; +import org.springblade.system.cache.SysCache; import org.springblade.system.cache.UserCache; import org.springblade.system.pojo.entity.Dept; import org.springblade.transport.excel.CommonRouteExportExcel; @@ -217,12 +218,32 @@ public class CommonRouteServiceImpl extends BaseServiceImpl relationApplicationIds = paymentApplicationSettlementMapper.selectList( Wrappers.lambdaQuery() .select(PaymentApplicationSettlement::getPaymentApplicationId) @@ -541,6 +545,12 @@ public class FormalSettlementServiceImpl extends BaseServiceImpllambdaQuery() .eq(FormalSettlementInvoice::getFormalSettlementId, id)); + projectAdvanceWriteOffService.deleteBySettlement(TransportBusiConstant.SETTLEMENT_BILL_FORMAL, id); changeRecordMapper.delete(Wrappers.lambdaQuery() .eq(FormalSettlementChangeRecord::getFormalSettlementId, id)); removeById(id); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java index 742b5931..38057c1a 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java @@ -98,7 +98,9 @@ import org.springblade.transport.pojo.vo.PaymentApplicationVO; import org.springblade.transport.service.IFormalSettlementService; import org.springblade.transport.service.IKingdeeBillLockService; import org.springblade.transport.service.IPaymentApplicationService; +import org.springblade.transport.service.ISettlementProjectAdvanceWriteOffService; import org.springblade.transport.service.KingdeeSyncContext; +import org.springblade.transport.pojo.vo.ProjectAdvanceWriteOffOptionVO; import org.springblade.transport.support.KingdeeApBillSyncSupport; import org.springblade.transport.support.KingdeeAttachmentSyncSupport; import org.springblade.transport.support.KingdeeSyncLockSupport; @@ -159,6 +161,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl projectAdvanceWriteOffOptions(Long contractId, + String excludeSettlementType, + Long excludeSettlementId) { + return projectAdvanceWriteOffService.listOptions(contractId, excludeSettlementType, excludeSettlementId); + } + @Override @Transactional(rollbackFor = Exception.class) public Long saveDraft(PaymentApplicationSaveRequest request) { @@ -1741,12 +1755,12 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl money(item.getSettlementAmount()).subtract(cumulativeFormalSettlementApplied(item.getId(), entity.getId())).max(BigDecimal.ZERO)) + .map(item -> payableOfFormalSettlement(item.getId(), item.getSettlementAmount(), entity.getId())) .reduce(BigDecimal.ZERO, BigDecimal::add); if (money(entity.getAppliedAmount()).compareTo(payableAmount) > 0) throw new ServiceException("申请付款金额不能超过所选结算单可付款金额合计"); FormalSettlement first = settlements.get(0); @@ -1796,15 +1810,14 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpllambdaQuery() .eq(PaymentApplicationSettlement::getPaymentApplicationId, entity.getId())); - BigDecimal totalBase = settlements.stream().map(item -> money(item.getSettlementAmount()).subtract( - cumulativeFormalSettlementApplied(item.getId(), entity.getId())).max(BigDecimal.ZERO)) + BigDecimal totalBase = settlements.stream() + .map(item -> payableOfFormalSettlement(item.getId(), item.getSettlementAmount(), entity.getId())) .reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal remainingApplied = money(entity.getAppliedAmount()); BigDecimal remainingPaid = money(entity.getPaidAmount()); for (int index = 0; index < settlements.size(); index++) { FormalSettlement settlement = settlements.get(index); - BigDecimal base = money(settlement.getSettlementAmount()).subtract( - cumulativeFormalSettlementApplied(settlement.getId(), entity.getId())).max(BigDecimal.ZERO); + BigDecimal base = payableOfFormalSettlement(settlement.getId(), settlement.getSettlementAmount(), entity.getId()); BigDecimal applied = index == settlements.size() - 1 ? remainingApplied : money(entity.getAppliedAmount()).multiply(base).divide(totalBase, 2, RoundingMode.DOWN); BigDecimal paid = index == settlements.size() - 1 ? remainingPaid @@ -1881,6 +1894,24 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl selectPage(IPage page, PreSettlementVO query) { @@ -198,6 +201,8 @@ public class PreSettlementServiceImpl extends BaseServiceImpl advances = listAdvances(id); advances.forEach(advance -> advance.setCreateUserName(UserCache.getUserRealName(advance.getCreateUser()))); vo.setAdvances(advances); + vo.setProjectAdvanceWriteOffs(projectAdvanceWriteOffService.listBySettlement( + TransportBusiConstant.SETTLEMENT_BILL_PRE, id)); vo.setChangeRecords(changeRecordMapper.selectList(Wrappers.lambdaQuery() .eq(PreSettlementChangeRecord::getPreSettlementId, id) .eq(PreSettlementChangeRecord::getIsDeleted, 0) @@ -346,6 +351,12 @@ public class PreSettlementServiceImpl extends BaseServiceImpl + * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import lombok.RequiredArgsConstructor; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.tool.utils.Func; +import org.springblade.system.cache.UserCache; +import org.springblade.transport.constant.TransportBusiConstant; +import org.springblade.transport.mapper.PaymentApplicationMapper; +import org.springblade.transport.mapper.SettlementProjectAdvanceWriteOffMapper; +import org.springblade.transport.pojo.dto.SettlementProjectAdvanceWriteOffRequest; +import org.springblade.transport.pojo.entity.PaymentApplication; +import org.springblade.transport.pojo.entity.SettlementProjectAdvanceWriteOff; +import org.springblade.transport.pojo.vo.ProjectAdvanceWriteOffOptionVO; +import org.springblade.transport.service.ISettlementProjectAdvanceWriteOffService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * 结算单项目预付核销服务实现 + * + * @author Chill + */ +@Service +@RequiredArgsConstructor +public class SettlementProjectAdvanceWriteOffServiceImpl implements ISettlementProjectAdvanceWriteOffService { + + private final SettlementProjectAdvanceWriteOffMapper writeOffMapper; + private final PaymentApplicationMapper paymentApplicationMapper; + + @Override + public List listOptions(Long contractId, String excludeSettlementType, + Long excludeSettlementId) { + if (contractId == null) { + throw new ServiceException("请先选择合同"); + } + List applications = paymentApplicationMapper.selectList( + Wrappers.lambdaQuery() + .eq(PaymentApplication::getContractId, contractId) + .eq(PaymentApplication::getPaymentType, TransportBusiConstant.PAYMENT_TYPE_PROJECT_ADVANCE) + .eq(PaymentApplication::getApprovalStatus, TransportBusiConstant.APPROVED) + .eq(PaymentApplication::getIsDeleted, 0) + .orderByDesc(PaymentApplication::getCreateTime)); + List options = new ArrayList<>(); + for (PaymentApplication application : applications) { + BigDecimal projectAdvanceAmount = money(application.getAppliedAmount()); + BigDecimal usedAmount = money(writeOffMapper.sumWriteOffByPaymentApplication( + application.getId(), excludeSettlementType, excludeSettlementId)); + BigDecimal availableAmount = projectAdvanceAmount.subtract(usedAmount).max(BigDecimal.ZERO); + if (availableAmount.compareTo(BigDecimal.ZERO) <= 0) { + continue; + } + ProjectAdvanceWriteOffOptionVO option = new ProjectAdvanceWriteOffOptionVO(); + option.setPaymentApplicationId(application.getId()); + option.setPaymentNo(application.getPaymentNo()); + option.setProjectAdvanceAmount(projectAdvanceAmount); + option.setAvailableWriteOffAmount(availableAmount); + option.setKingdeeBillNo(application.getKingdeeBillNo()); + option.setCreateUserName(UserCache.getUserRealName(application.getCreateUser())); + option.setCreateTime(application.getCreateTime()); + options.add(option); + } + return options; + } + + @Override + public List listBySettlement(String settlementType, Long settlementId) { + if (Func.isEmpty(settlementType) || settlementId == null) { + return List.of(); + } + List rows = writeOffMapper.selectList( + Wrappers.lambdaQuery() + .eq(SettlementProjectAdvanceWriteOff::getSettlementType, settlementType) + .eq(SettlementProjectAdvanceWriteOff::getSettlementId, settlementId) + .eq(SettlementProjectAdvanceWriteOff::getIsDeleted, 0) + .orderByAsc(SettlementProjectAdvanceWriteOff::getCreateTime) + .orderByAsc(SettlementProjectAdvanceWriteOff::getId)); + for (SettlementProjectAdvanceWriteOff row : rows) { + PaymentApplication application = paymentApplicationMapper.selectById(row.getPaymentApplicationId()); + BigDecimal projectAdvanceAmount = application == null + ? money(row.getProjectAdvanceAmount()) : money(application.getAppliedAmount()); + BigDecimal usedAmount = money(writeOffMapper.sumWriteOffByPaymentApplication( + row.getPaymentApplicationId(), settlementType, settlementId)); + row.setProjectAdvanceAmount(projectAdvanceAmount); + row.setAvailableWriteOffAmount(projectAdvanceAmount.subtract(usedAmount).max(BigDecimal.ZERO)); + if (application != null) { + row.setPaymentNo(application.getPaymentNo()); + row.setKingdeeBillNo(application.getKingdeeBillNo()); + row.setCreateUserName(UserCache.getUserRealName(application.getCreateUser())); + row.setPaymentCreateTime(application.getCreateTime()); + } else { + row.setCreateUserName(UserCache.getUserRealName(row.getCreateUser())); + row.setPaymentCreateTime(row.getCreateTime()); + } + } + return rows; + } + + @Override + public BigDecimal sumBySettlement(String settlementType, Long settlementId) { + if (Func.isEmpty(settlementType) || settlementId == null) { + return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); + } + return money(writeOffMapper.sumWriteOffBySettlement(settlementType, settlementId)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void replaceWriteOffs(String settlementType, Long settlementId, Long contractId, + BigDecimal settlementAmount, List writeOffs) { + if (Func.isEmpty(settlementType) || settlementId == null) { + throw new ServiceException("结算单不能为空"); + } + if (contractId == null) { + throw new ServiceException("合同不能为空"); + } + List requestRows = writeOffs == null ? List.of() : writeOffs; + BigDecimal settlementLimit = money(settlementAmount); + BigDecimal totalWriteOff = BigDecimal.ZERO; + Set paymentIds = new HashSet<>(); + List entities = new ArrayList<>(); + for (SettlementProjectAdvanceWriteOffRequest request : requestRows) { + if (request == null || request.getPaymentApplicationId() == null) { + throw new ServiceException("请选择项目预付单"); + } + if (!paymentIds.add(request.getPaymentApplicationId())) { + throw new ServiceException("同一项目预付单不可重复核销"); + } + BigDecimal writeOffAmount = money(request.getWriteOffAmount()); + if (writeOffAmount.compareTo(BigDecimal.ZERO) <= 0) { + throw new ServiceException("本次核销金额必须大于0"); + } + PaymentApplication application = paymentApplicationMapper.selectById(request.getPaymentApplicationId()); + if (application == null || Objects.equals(application.getIsDeleted(), 1)) { + throw new ServiceException("项目预付单不存在"); + } + if (!TransportBusiConstant.PAYMENT_TYPE_PROJECT_ADVANCE.equals(application.getPaymentType())) { + throw new ServiceException("仅可核销项目预付单"); + } + if (!TransportBusiConstant.APPROVED.equals(application.getApprovalStatus())) { + throw new ServiceException("仅可选择审核通过的项目预付单"); + } + if (!Objects.equals(contractId, application.getContractId())) { + throw new ServiceException("项目预付单与结算单合同不一致"); + } + BigDecimal projectAdvanceAmount = money(application.getAppliedAmount()); + BigDecimal usedAmount = money(writeOffMapper.sumWriteOffByPaymentApplication( + application.getId(), settlementType, settlementId)); + BigDecimal availableAmount = projectAdvanceAmount.subtract(usedAmount).max(BigDecimal.ZERO); + if (writeOffAmount.compareTo(availableAmount) > 0) { + throw new ServiceException("预付单" + Func.toStr(application.getPaymentNo(), "") + + "本次核销金额不能超过可核销金额"); + } + totalWriteOff = totalWriteOff.add(writeOffAmount); + SettlementProjectAdvanceWriteOff entity = new SettlementProjectAdvanceWriteOff(); + entity.setSettlementType(settlementType); + entity.setSettlementId(settlementId); + entity.setPaymentApplicationId(application.getId()); + entity.setPaymentNo(application.getPaymentNo()); + entity.setProjectAdvanceAmount(projectAdvanceAmount); + entity.setWriteOffAmount(writeOffAmount); + entity.setKingdeeBillNo(application.getKingdeeBillNo()); + entity.setStatus(1); + entity.setIsDeleted(0); + entities.add(entity); + } + if (totalWriteOff.compareTo(settlementLimit) > 0) { + throw new ServiceException("项目预付核销合计不能超过结算单金额"); + } + writeOffMapper.delete(Wrappers.lambdaQuery() + .eq(SettlementProjectAdvanceWriteOff::getSettlementType, settlementType) + .eq(SettlementProjectAdvanceWriteOff::getSettlementId, settlementId)); + for (SettlementProjectAdvanceWriteOff entity : entities) { + writeOffMapper.insert(entity); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteBySettlement(String settlementType, Long settlementId) { + if (Func.isEmpty(settlementType) || settlementId == null) { + return; + } + writeOffMapper.delete(Wrappers.lambdaQuery() + .eq(SettlementProjectAdvanceWriteOff::getSettlementType, settlementType) + .eq(SettlementProjectAdvanceWriteOff::getSettlementId, settlementId)); + } + + private BigDecimal money(BigDecimal value) { + return value == null ? BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP) + : value.setScale(2, RoundingMode.HALF_UP); + } + +} diff --git a/doc/sql/transport/blade_project_apply_undertake_dept_multi_20261014.sql b/doc/sql/transport/blade_project_apply_undertake_dept_multi_20261014.sql index b2d5d222..2b96a193 100644 --- a/doc/sql/transport/blade_project_apply_undertake_dept_multi_20261014.sql +++ b/doc/sql/transport/blade_project_apply_undertake_dept_multi_20261014.sql @@ -1,4 +1,64 @@ --- 平台公司支持多选:ID 逗号分隔,名称顿号分隔 -ALTER TABLE `blade_project_apply` - MODIFY COLUMN `undertake_dept_id` varchar(500) DEFAULT NULL COMMENT '平台公司ID,多个逗号分隔', - MODIFY COLUMN `undertake_dept_name` varchar(500) DEFAULT NULL COMMENT '平台公司,多个顿号分隔'; +-- 项目立项平台公司多选:ID 逗号分隔,名称顿号分隔 +-- 原字段:undertake_dept_id bigint、undertake_dept_name varchar(100) +-- 选择 2 个及以上平台公司时,若未扩容会报 Incorrect integer value / Data truncation +-- 整列索引挂在 varchar(500) 上可能因键长超限导致 ALTER 失败,故先删索引再改前缀索引 + +DELIMITER $$ + +DROP PROCEDURE IF EXISTS `upgrade_project_apply_undertake_dept_multi_20261014`$$ +CREATE PROCEDURE `upgrade_project_apply_undertake_dept_multi_20261014`() +BEGIN + DECLARE db_name varchar(128); + DECLARE need_modify tinyint DEFAULT 0; + SET db_name = DATABASE(); + + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = db_name + AND table_name = 'blade_project_apply' + AND column_name = 'undertake_dept_id' + AND ( + data_type IN ('bigint', 'int', 'integer') + OR (data_type IN ('varchar', 'char') AND IFNULL(character_maximum_length, 0) < 500) + ) + ) OR EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = db_name + AND table_name = 'blade_project_apply' + AND column_name = 'undertake_dept_name' + AND data_type IN ('varchar', 'char') + AND IFNULL(character_maximum_length, 0) < 500 + ) THEN + SET need_modify = 1; + END IF; + + IF need_modify = 1 THEN + IF EXISTS ( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = db_name + AND table_name = 'blade_project_apply' + AND index_name = 'idx_project_apply_dept' + ) THEN + ALTER TABLE `blade_project_apply` DROP INDEX `idx_project_apply_dept`; + END IF; + + ALTER TABLE `blade_project_apply` + MODIFY COLUMN `undertake_dept_id` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '平台公司ID,多个逗号分隔', + MODIFY COLUMN `undertake_dept_name` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '平台公司,多个顿号分隔'; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = db_name + AND table_name = 'blade_project_apply' + AND index_name = 'idx_project_apply_dept' + ) THEN + ALTER TABLE `blade_project_apply` + ADD INDEX `idx_project_apply_dept` (`undertake_dept_id`(191)) USING BTREE; + END IF; +END$$ + +CALL `upgrade_project_apply_undertake_dept_multi_20261014`()$$ +DROP PROCEDURE `upgrade_project_apply_undertake_dept_multi_20261014`$$ + +DELIMITER ; diff --git a/doc/sql/transport/blade_project_contract_management.sql b/doc/sql/transport/blade_project_contract_management.sql index 59890b1b..62a9ecd2 100644 --- a/doc/sql/transport/blade_project_contract_management.sql +++ b/doc/sql/transport/blade_project_contract_management.sql @@ -60,7 +60,7 @@ CREATE TABLE `blade_project_apply` ( `void_reason` varchar(500) DEFAULT NULL COMMENT '作废原因', PRIMARY KEY (`id`) USING BTREE, UNIQUE KEY `uk_project_apply_name` (`project_name`, `is_deleted`) USING BTREE, - KEY `idx_project_apply_dept` (`undertake_dept_id`) USING BTREE, + KEY `idx_project_apply_dept` (`undertake_dept_id`(191)) USING BTREE, KEY `idx_project_apply_status` (`approval_status`) USING BTREE, KEY `idx_project_apply_create_time` (`create_time`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目立项'; @@ -80,8 +80,8 @@ CREATE TABLE `blade_temporary_credit_limit` ( `project_id` bigint(20) DEFAULT NULL COMMENT '项目ID', `project_code` varchar(100) DEFAULT NULL COMMENT '项目编号', `project_name` varchar(100) DEFAULT NULL COMMENT '项目名称', - `undertake_dept_id` varchar(500) DEFAULT NULL COMMENT '平台公司ID,多个逗号分隔', - `undertake_dept_name` varchar(500) DEFAULT NULL COMMENT '平台公司,多个顿号分隔', + `undertake_dept_id` bigint(20) DEFAULT NULL COMMENT '承办部门ID', + `undertake_dept_name` varchar(100) DEFAULT NULL COMMENT '承办部门', `project_fund_limit` decimal(18,2) DEFAULT NULL COMMENT '项目资金使用额度(万元)', `used_fund_limit` decimal(18,2) DEFAULT NULL COMMENT '已使用项目资金额度(万元)', `remaining_fund_limit` decimal(18,2) DEFAULT NULL COMMENT '剩余项目资金使用额度(万元)', diff --git a/doc/sql/transport/blade_settlement_project_advance_write_off_20261016.sql b/doc/sql/transport/blade_settlement_project_advance_write_off_20261016.sql new file mode 100644 index 00000000..7aba7877 --- /dev/null +++ b/doc/sql/transport/blade_settlement_project_advance_write_off_20261016.sql @@ -0,0 +1,23 @@ +-- 结算单项目预付核销明细 +CREATE TABLE IF NOT EXISTS `blade_settlement_project_advance_write_off` ( + `id` bigint NOT NULL COMMENT '主键', + `tenant_id` varchar(12) NOT NULL DEFAULT '000000' COMMENT '租户ID', + `settlement_type` varchar(20) NOT NULL COMMENT '结算单类型 pre/formal', + `settlement_id` bigint NOT NULL COMMENT '预结算/正式结算单ID', + `payment_application_id` bigint NOT NULL COMMENT '项目预付付款申请ID', + `payment_no` varchar(64) DEFAULT NULL COMMENT '预付单号', + `project_advance_amount` decimal(18,2) DEFAULT 0.00 COMMENT '项目预付金额', + `write_off_amount` decimal(18,2) NOT NULL DEFAULT 0.00 COMMENT '本次核销金额', + `kingdee_bill_no` varchar(100) DEFAULT NULL COMMENT '金蝶预付单号', + `create_user` bigint DEFAULT NULL COMMENT '创建人', + `create_dept` bigint DEFAULT NULL COMMENT '创建部门', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_user` bigint DEFAULT NULL COMMENT '更新人', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + `status` int DEFAULT 1 COMMENT '业务状态', + `is_deleted` int DEFAULT 0 COMMENT '是否已删除', + PRIMARY KEY (`id`), + KEY `idx_spa_write_off_settlement` (`settlement_type`,`settlement_id`), + KEY `idx_spa_write_off_payment` (`payment_application_id`), + KEY `idx_spa_write_off_create_time` (`create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='结算单项目预付核销'; From 1bc4eb0c03cf7d2d2d5bd12a6efaa8bf75397444 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Fri, 16 Oct 2026 12:42:43 +0800 Subject: [PATCH 020/108] =?UTF-8?q?:bug:=20=E8=AE=A4=E9=A2=86=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E5=88=97=E8=A1=A8=E6=94=B9=E4=B8=BA=E6=8C=89=E8=AE=A4?= =?UTF-8?q?=E9=A2=86=E6=97=A5=E6=9C=9F=E5=80=92=E5=BA=8F=EF=BC=8C=E5=90=8C?= =?UTF-8?q?=E6=97=A5=E6=8C=89=E8=AE=B0=E5=BD=95=E5=85=88=E5=90=8E=E5=80=92?= =?UTF-8?q?=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/springblade/transport/mapper/ReceiptClaimMapper.xml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/ReceiptClaimMapper.xml b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/ReceiptClaimMapper.xml index 07a25531..e28a8299 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/ReceiptClaimMapper.xml +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/ReceiptClaimMapper.xml @@ -98,7 +98,8 @@ FROM blade_receipt_claim c INNER JOIN blade_kingdee_receipt_flow f ON f.id = c.receipt_flow_id WHERE - ORDER BY c.create_time DESC + + ORDER BY c.claim_date DESC, c.id DESC + + diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementProjectAdvanceWriteOffService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementProjectAdvanceWriteOffService.java index 9c1d7368..3c8886d8 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementProjectAdvanceWriteOffService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementProjectAdvanceWriteOffService.java @@ -14,7 +14,9 @@ import org.springblade.transport.pojo.entity.SettlementProjectAdvanceWriteOff; import org.springblade.transport.pojo.vo.ProjectAdvanceWriteOffOptionVO; import java.math.BigDecimal; +import java.util.Collection; import java.util.List; +import java.util.Map; /** * 结算单项目预付核销服务 @@ -24,7 +26,7 @@ import java.util.List; public interface ISettlementProjectAdvanceWriteOffService { /** - * 按合同查询可选项目预付(审批通过且仍有可核销余额)。 + * 按合同查询可选项目预付(审批通过、金蝶已付款且仍有可核销余额)。 */ List listOptions(Long contractId, String excludeSettlementType, Long excludeSettlementId); @@ -39,6 +41,11 @@ public interface ISettlementProjectAdvanceWriteOffService { */ BigDecimal sumBySettlement(String settlementType, Long settlementId); + /** + * 按结算单集合分组统计核销合计(一次 group-by,供批量刷新与列表页使用)。 + */ + Map sumBySettlementIds(String settlementType, Collection settlementIds); + /** * 覆盖保存核销明细,并校验不超过可核销金额与结算单金额。 */ @@ -50,4 +57,14 @@ public interface ISettlementProjectAdvanceWriteOffService { */ void deleteBySettlement(String settlementType, Long settlementId); + /** + * 删除指定结算单集合上的核销明细(核销行转移/结算单作废联动),返回删除行数。 + */ + int deleteBySettlementIds(String settlementType, Collection settlementIds); + + /** + * 按付款申请删除其核销明细(付款申请作废联动),返回受影响的正式结算单id。 + */ + List deleteByPaymentApplicationIds(Collection paymentApplicationIds); + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java index 7e37cd0d..ba94adfd 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java @@ -33,12 +33,14 @@ import org.springblade.transport.mapper.InvoiceApplicationMapper; import org.springblade.transport.mapper.InvoiceApplicationSettlementMapper; import org.springblade.transport.mapper.PreSettlementDetailMapper; import org.springblade.transport.mapper.PreSettlementDetailFeeMapper; +import org.springblade.transport.mapper.PreSettlementAdvanceMapper; import org.springblade.transport.mapper.PreSettlementMapper; import org.springblade.transport.mapper.PreSettlementSummaryFeeMapper; import org.springblade.transport.mapper.PaymentApplicationMapper; import org.springblade.transport.mapper.PaymentApplicationInvoiceMapper; import org.springblade.transport.mapper.PaymentApplicationSettlementMapper; import org.springblade.transport.mapper.ReceiptClaimSettlementMapper; +import org.springblade.transport.mapper.SettlementProjectAdvanceWriteOffMapper; import org.springblade.transport.constant.KingdeeAttachmentConstant; import org.springblade.transport.config.BfiProperties; import org.springblade.transport.constant.KingdeeBfiConstant; @@ -69,6 +71,7 @@ import org.springblade.transport.pojo.entity.InvoiceApplicationInvoice; import org.springblade.transport.pojo.entity.InvoiceApplicationSettlement; import org.springblade.transport.pojo.entity.ContractManage; import org.springblade.transport.pojo.entity.PreSettlement; +import org.springblade.transport.pojo.entity.PreSettlementAdvance; import org.springblade.transport.pojo.entity.PreSettlementDetail; import org.springblade.transport.pojo.entity.PreSettlementDetailFee; import org.springblade.transport.pojo.entity.PreSettlementSummaryFee; @@ -79,9 +82,11 @@ import org.springblade.transport.pojo.entity.ReceiptClaimSettlement; import org.springblade.transport.pojo.entity.ReceivablePayableDetail; import org.springblade.transport.pojo.entity.ReceivablePayableCargoFee; import org.springblade.transport.pojo.entity.SettlementAdjustment; +import org.springblade.transport.pojo.entity.SettlementProjectAdvanceWriteOff; import org.springblade.transport.pojo.entity.Waybill; import org.springblade.transport.pojo.vo.FormalSettlementVO; import org.springblade.transport.pojo.vo.PreSettlementVO; +import org.springblade.transport.pojo.vo.SettlementAdvancePaymentVO; import org.springblade.transport.pojo.vo.SettlementAdjustmentVO; import org.springblade.transport.service.IFormalSettlementService; import org.springblade.transport.service.IInvoiceReceiptService; @@ -113,6 +118,8 @@ import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Collection; +import java.util.Comparator; +import java.util.Date; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.LinkedHashSet; @@ -162,6 +169,8 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl result = page(page, wrapper.orderByDesc(FormalSettlement::getCreateTime)); - IPage voPage = result.convert(this::toVO); + // 本页应付结算单的项目预付核销合计一次查询,避免逐行 toVO 重算汇总时的 N+1 + Map writeOffAmounts = this.projectAdvanceWriteOffService.sumBySettlementIds( + TransportBusiConstant.SETTLEMENT_BILL_FORMAL, + result.getRecords().stream() + .filter(item -> "payable".equals(item.getSettlementType())) + .map(FormalSettlement::getId).toList()); + IPage voPage = result.convert(item -> this.toVO(item, writeOffAmounts)); fillAttachmentSyncStatus(voPage.getRecords()); fillKingdeeLockStatus(voPage.getRecords()); return voPage; @@ -322,6 +337,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl relationApplicationIds = paymentApplicationSettlementMapper.selectList( Wrappers.lambdaQuery() .select(PaymentApplicationSettlement::getPaymentApplicationId) @@ -545,12 +561,17 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl 新单[{}](明细{}条)", - source.getFormalSettlementNo(), clone.getFormalSettlementNo(), details.size()); + // 核销行迁移:克隆到新单并删除原单行,避免预付可核销额度双占(原单为调整存档,收付口径以新单为准) + List writeOffRows = this.projectAdvanceWriteOffMapper.selectList( + Wrappers.lambdaQuery() + .eq(SettlementProjectAdvanceWriteOff::getSettlementType, TransportBusiConstant.SETTLEMENT_BILL_FORMAL) + .eq(SettlementProjectAdvanceWriteOff::getSettlementId, sourceSettlementId) + .eq(SettlementProjectAdvanceWriteOff::getIsDeleted, 0)); + for (SettlementProjectAdvanceWriteOff writeOffRow : writeOffRows) { + SettlementProjectAdvanceWriteOff cloneWriteOff = Objects.requireNonNull( + BeanUtil.copyProperties(writeOffRow, SettlementProjectAdvanceWriteOff.class)); + resetAuditFields(cloneWriteOff); + cloneWriteOff.setSettlementId(clone.getId()); + this.projectAdvanceWriteOffMapper.insert(cloneWriteOff); + } + this.projectAdvanceWriteOffService.deleteBySettlementIds( + TransportBusiConstant.SETTLEMENT_BILL_FORMAL, List.of(sourceSettlementId)); + this.refreshPaymentSummaries(List.of(sourceSettlementId, clone.getId())); + log.info("结算调整重推克隆正式结算单:原单[{}] -> 新单[{}](明细{}条,核销迁移{}条)", + source.getFormalSettlementNo(), clone.getFormalSettlementNo(), details.size(), writeOffRows.size()); return new CloneResult(clone.getId(), detailIdMap, feeIdMap); } @@ -1601,6 +1644,11 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl money(item.getAllocatedReceiptAmount()), BigDecimal::add))) : Map.of(); + // 项目预付核销合计(仅应付结算单需要,一次 group-by 避免逐单查询) + Map writeOffAmountsBySettlement = hasPayable + ? this.projectAdvanceWriteOffService.sumBySettlementIds( + TransportBusiConstant.SETTLEMENT_BILL_FORMAL, settledIds) + : Map.of(); for (FormalSettlement settlement : settlements) { List relations = relationsBySettlement @@ -1616,7 +1664,8 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl writeOffAmounts) { FormalSettlementVO vo = FormalSettlementWrapper.build().entityVO(entity); vo.setInvoiceStatus(invoiceStatus(entity.getInvoiceAmount(), entity.getSettlementAmount())); - PaymentSummary summary = calculatePaymentSummary(entity); + PaymentSummary summary = this.calculatePaymentSummary(entity, writeOffAmounts); vo.setAppliedPaymentAmount(summary.appliedAmount()); vo.setPaidAmount(summary.paidAmount()); vo.setRemainingPayableAmount(money(entity.getSettlementAmount()).subtract(summary.paidAmount()) @@ -1837,6 +1893,10 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl) null); + } + + private PaymentSummary calculatePaymentSummary(FormalSettlement settlement, Map writeOffAmounts) { List relations = paymentApplicationSettlementMapper.selectList( Wrappers.lambdaQuery() .eq(PaymentApplicationSettlement::getFormalSettlementId, settlement.getId()) @@ -1854,7 +1914,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl relations, List directApplications, Map relationStatuses, - List sources, BigDecimal receiptClaimPaid) { + List sources, BigDecimal receiptClaimPaid, + Map writeOffAmounts) { } private SettlementAdjustmentVO toAdjustmentVO(SettlementAdjustment entity) { @@ -1937,6 +2006,74 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl listAdvancePayments(List sources, + List writeOffs) { + List records = new ArrayList<>(); + List preSettlementIds = sources.stream().map(FormalSettlementSource::getPreSettlementId) + .filter(Objects::nonNull).distinct().toList(); + if (!preSettlementIds.isEmpty()) { + List advances = this.preSettlementAdvanceMapper.selectList( + Wrappers.lambdaQuery() + .eq(PreSettlementAdvance::getIsDeleted, 0) + .ne(PreSettlementAdvance::getBillStatus, TransportBusiConstant.VOIDED) + .in(PreSettlementAdvance::getPreSettlementId, preSettlementIds)); + // 进度预付行的展示时间取底层付款申请创建时间(advanceNo 即付款申请单号) + List advanceNos = advances.stream().map(PreSettlementAdvance::getAdvanceNo) + .filter(Func::isNotEmpty).distinct().toList(); + Map applicationTimes = advanceNos.isEmpty() ? Map.of() + : this.paymentApplicationMapper.selectList(Wrappers.lambdaQuery() + .select(PaymentApplication::getPaymentNo, PaymentApplication::getCreateTime) + .eq(PaymentApplication::getIsDeleted, 0) + .in(PaymentApplication::getPaymentNo, advanceNos)) + .stream().filter(item -> item.getCreateTime() != null) + .collect(Collectors.toMap(PaymentApplication::getPaymentNo, + PaymentApplication::getCreateTime, (first, second) -> first)); + for (PreSettlementAdvance advance : advances) { + SettlementAdvancePaymentVO record = new SettlementAdvancePaymentVO(); + record.setRecordType(TransportBusiConstant.PAYMENT_TYPE_PROGRESS_ADVANCE); + record.setPaymentNo(advance.getAdvanceNo()); + record.setAppliedAmount(advance.getAppliedAmount()); + record.setPaidAmount(advance.getPaidAmount()); + record.setBillStatus(advance.getBillStatus()); + record.setKingdeeBillNo(advance.getKingdeeAdvanceNo()); + record.setCreateUserName(UserCache.getUserRealName(advance.getCreateUser())); + record.setCreateTime(applicationTimes.getOrDefault(advance.getAdvanceNo(), advance.getCreateTime())); + records.add(record); + } + } + if (Func.isNotEmpty(writeOffs)) { + List applicationIds = writeOffs.stream() + .map(SettlementProjectAdvanceWriteOff::getPaymentApplicationId) + .filter(Objects::nonNull).distinct().toList(); + Map applications = applicationIds.isEmpty() ? Map.of() + : this.paymentApplicationMapper.selectBatchIds(applicationIds).stream() + .collect(Collectors.toMap(PaymentApplication::getId, Function.identity(), + (first, second) -> first)); + for (SettlementProjectAdvanceWriteOff writeOff : writeOffs) { + PaymentApplication application = applications.get(writeOff.getPaymentApplicationId()); + SettlementAdvancePaymentVO record = new SettlementAdvancePaymentVO(); + record.setRecordType(TransportBusiConstant.PAYMENT_TYPE_PROJECT_ADVANCE); + record.setPaymentNo(writeOff.getPaymentNo()); + record.setAppliedAmount(writeOff.getWriteOffAmount()); + record.setPaidAmount(application == null ? null : application.getPaidAmount()); + record.setBillStatus(application == null ? null : application.getApprovalStatus()); + record.setKingdeeBillNo(writeOff.getKingdeeBillNo()); + record.setCreateUserName(UserCache.getUserRealName(application == null + ? writeOff.getCreateUser() : application.getCreateUser())); + record.setCreateTime(writeOff.getPaymentCreateTime() != null + ? writeOff.getPaymentCreateTime() : writeOff.getCreateTime()); + records.add(record); + } + } + records.sort(Comparator.comparing(SettlementAdvancePaymentVO::getCreateTime, + Comparator.nullsLast(Comparator.reverseOrder()))); + return records; + } + private String approvalStatusName(String value) { if (value == null) { return ""; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java index 6650afd0..f8860776 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java @@ -456,6 +456,11 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl writeOffSettlementIds = this.projectAdvanceWriteOffService.deleteByPaymentApplicationIds(List.of(entity.getId())); + if (!writeOffSettlementIds.isEmpty()) { + formalSettlementService.refreshPaymentSummaries(writeOffSettlementIds); + } refreshFormalSettlement(entity); } 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 cdf17b02..d832ea60 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 @@ -461,6 +461,9 @@ public class PreSettlementServiceImpl extends BaseServiceImpl 0) { + throw new ServiceException("调整后结算金额不能低于项目预付核销合计" + money(writeOffTotal) + ",请先调减项目预付核销"); + } formal.setSettlementAmount(amount); formal.setLocalSettlementAmount(amount.multiply(formal.getExchangeRate() == null ? BigDecimal.ONE : formal.getExchangeRate())); formal.setRemainingPayableAmount(amount.subtract(money(formal.getPaidAmount())).max(BigDecimal.ZERO)); formalMapper.updateById(formal); + // 重算收付汇总(含核销计入),保持付款状态与剩余可付准确 + formalSettlementService.refreshPaymentSummary(formal.getId()); } /** 涉事事务的自代理:跨方法调用确保 {@link #cloneAndApplyForRepush} 的事务注解生效 */ diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementProjectAdvanceWriteOffServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementProjectAdvanceWriteOffServiceImpl.java index 52e7dbc4..473ee3d3 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementProjectAdvanceWriteOffServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementProjectAdvanceWriteOffServiceImpl.java @@ -14,6 +14,7 @@ import lombok.RequiredArgsConstructor; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.tool.utils.Func; import org.springblade.system.cache.UserCache; +import org.springblade.transport.constant.KingdeeBfiConstant; import org.springblade.transport.constant.TransportBusiConstant; import org.springblade.transport.mapper.PaymentApplicationMapper; import org.springblade.transport.mapper.SettlementProjectAdvanceWriteOffMapper; @@ -28,8 +29,11 @@ import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.math.RoundingMode; import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Set; @@ -56,6 +60,7 @@ public class SettlementProjectAdvanceWriteOffServiceImpl implements ISettlementP .eq(PaymentApplication::getContractId, contractId) .eq(PaymentApplication::getPaymentType, TransportBusiConstant.PAYMENT_TYPE_PROJECT_ADVANCE) .eq(PaymentApplication::getApprovalStatus, TransportBusiConstant.APPROVED) + .eq(PaymentApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_PAID) .eq(PaymentApplication::getIsDeleted, 0) .orderByDesc(PaymentApplication::getCreateTime)); List options = new ArrayList<>(); @@ -121,6 +126,20 @@ public class SettlementProjectAdvanceWriteOffServiceImpl implements ISettlementP return money(writeOffMapper.sumWriteOffBySettlement(settlementType, settlementId)); } + @Override + public Map sumBySettlementIds(String settlementType, Collection settlementIds) { + if (Func.isEmpty(settlementType) || Func.isEmpty(settlementIds)) { + return Map.of(); + } + Map result = new HashMap<>(); + for (Map row : writeOffMapper.sumWriteOffBySettlementIds(settlementType, settlementIds)) { + if (row.get("settlementId") instanceof Number settlementId && row.get("totalAmount") instanceof BigDecimal totalAmount) { + result.put(settlementId.longValue(), money(totalAmount)); + } + } + return result; + } + @Override @Transactional(rollbackFor = Exception.class) public void replaceWriteOffs(String settlementType, Long settlementId, Long contractId, @@ -157,6 +176,9 @@ public class SettlementProjectAdvanceWriteOffServiceImpl implements ISettlementP if (!TransportBusiConstant.APPROVED.equals(application.getApprovalStatus())) { throw new ServiceException("仅可选择审核通过的项目预付单"); } + if (!KingdeeBfiConstant.KINGDEE_STATUS_PAID.equals(application.getKingdeeStatus())) { + throw new ServiceException("预付单" + Func.toStr(application.getPaymentNo(), "") + "尚未付款,项目预付须金蝶付款完成后方可核销"); + } if (!Objects.equals(contractId, application.getContractId())) { throw new ServiceException("项目预付单与结算单合同不一致"); } @@ -203,6 +225,40 @@ public class SettlementProjectAdvanceWriteOffServiceImpl implements ISettlementP .eq(SettlementProjectAdvanceWriteOff::getSettlementId, settlementId)); } + @Override + @Transactional(rollbackFor = Exception.class) + public int deleteBySettlementIds(String settlementType, Collection settlementIds) { + if (Func.isEmpty(settlementType) || Func.isEmpty(settlementIds)) { + return 0; + } + return writeOffMapper.delete(Wrappers.lambdaQuery() + .eq(SettlementProjectAdvanceWriteOff::getSettlementType, settlementType) + .in(SettlementProjectAdvanceWriteOff::getSettlementId, settlementIds)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public List deleteByPaymentApplicationIds(Collection paymentApplicationIds) { + if (Func.isEmpty(paymentApplicationIds)) { + return List.of(); + } + List rows = writeOffMapper.selectList( + Wrappers.lambdaQuery() + .eq(SettlementProjectAdvanceWriteOff::getIsDeleted, 0) + .in(SettlementProjectAdvanceWriteOff::getPaymentApplicationId, paymentApplicationIds)); + if (rows.isEmpty()) { + return List.of(); + } + writeOffMapper.delete(Wrappers.lambdaQuery() + .in(SettlementProjectAdvanceWriteOff::getPaymentApplicationId, paymentApplicationIds)); + return rows.stream() + .filter(row -> TransportBusiConstant.SETTLEMENT_BILL_FORMAL.equals(row.getSettlementType())) + .map(SettlementProjectAdvanceWriteOff::getSettlementId) + .filter(Objects::nonNull) + .distinct() + .toList(); + } + private BigDecimal money(BigDecimal value) { return value == null ? BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP) : value.setScale(2, RoundingMode.HALF_UP); From 1c9471df8902efd9304f37ede4fc9d0353ac7f50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Sun, 18 Oct 2026 14:31:46 +0800 Subject: [PATCH 061/108] =?UTF-8?q?fix:=20=E4=BB=98=E6=AC=BE=E7=94=B3?= =?UTF-8?q?=E8=AF=B7=E5=8F=91=E5=8F=B7=E5=90=AB=E9=80=BB=E8=BE=91=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E5=8D=A0=E4=BD=8D=E5=B9=B6=E5=8B=BE=E7=A8=BD=E9=87=91?= =?UTF-8?q?=E9=A2=9D=E6=A0=A1=E9=AA=8C=EF=BC=8C=E9=98=B2=E5=8D=95=E5=8F=B7?= =?UTF-8?q?=E5=A4=8D=E7=94=A8=E6=92=9E=E9=87=91=E8=9D=B6=E9=98=B2=E9=87=8D?= =?UTF-8?q?=E9=94=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nextNo 改为含逻辑删除单据取当天最大序号+1,删除后单号不再复用(此前复用同号推送金蝶时按防重键匹配到旧单转更新,报已提交状态下的单据不允许修改);A103-2 勾稽回填前校验金蝶同号单金额与本单一致,不一致拦截并提示财务核实 --- .../mapper/PaymentApplicationMapper.java | 14 ++++++++++++++ .../impl/PaymentApplicationServiceImpl.java | 19 ++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PaymentApplicationMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PaymentApplicationMapper.java index 770dc202..2f8af3b1 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PaymentApplicationMapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PaymentApplicationMapper.java @@ -21,9 +21,23 @@ package org.springblade.transport.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; import org.springblade.transport.pojo.entity.PaymentApplication; +import java.util.List; + /** 付款申请 Mapper。 @author Chill */ @Mapper public interface PaymentApplicationMapper extends BaseMapper { + + /** + * 按前缀查询单号(含逻辑删除单据):发号占位用,删除后的单号不复用, + * 避免 A103-3/A103-1 推送时防重键(awrp_src_code)撞金蝶已有单据(已提交单据不允许修改)。 + * + * @param pattern 单号匹配模式(如 'FKD20261018%') + */ + @Select("SELECT payment_no FROM blade_payment_application WHERE payment_no LIKE #{pattern}") + List selectPaymentNosIncludingDeleted(@Param("pattern") String pattern); + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java index f8860776..4b387c48 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java @@ -1040,6 +1040,13 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpllambdaQuery().likeRight(PaymentApplication::getPaymentNo, prefix)) + 1); } + private String nextNo() { + String prefix = "FKD" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); + // 单号占用含逻辑删除单据:删除后不复用单号,避免推送时防重键撞金蝶已有单据(已提交单据不允许修改) + int max = this.baseMapper.selectPaymentNosIncludingDeleted(prefix + "%").stream() + .map(no -> no.length() > prefix.length() ? no.substring(prefix.length()) : "") + .filter(no -> no.matches("\\d+")) + .map(Integer::valueOf) + .max(Integer::compareTo) + .orElse(0); + return prefix + String.format("%05d", max + 1); + } private BigDecimal nonNegative(BigDecimal value, String name) { if (value == null || value.compareTo(BigDecimal.ZERO) < 0) throw new ServiceException(name + "不能小于0"); return value; } private BigDecimal money(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; } private String limit(String value, int length) { if (value != null && value.length() > length) throw new ServiceException("备注不能超过" + length + "个字符"); return value; } From d9379150431941d7e56362d9b7546614b25f1476 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Sun, 18 Oct 2026 14:32:01 +0800 Subject: [PATCH 062/108] =?UTF-8?q?docs:=20=E6=96=B0=E5=A2=9E=E9=A2=84?= =?UTF-8?q?=E4=BB=98=E5=AE=8C=E6=95=B4=E6=B5=81=E7=A8=8B=E6=A2=B3=E7=90=86?= =?UTF-8?q?-=E6=AD=A3=E5=BC=8F=E7=BB=93=E7=AE=97=E5=8D=95=E9=A2=84?= =?UTF-8?q?=E4=BB=98=E6=95=B4=E5=90=88=E8=AE=BE=E8=AE=A1=E4=B8=8E=E8=90=BD?= =?UTF-8?q?=E5=9C=B0=E5=90=8E=E5=85=A8=E9=93=BE=E8=B7=AF=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...´合设计(进度预付+项目预付核销·金额口径与记录流转).md | 284 ++++++++++++++++++ ...¾路逻辑梳理(进度预付-项目预付核销·前端到后端各环节).md | 129 ++++++++ ...¢³理(进度预付-项目预付-正式结算单-付款-金蝶回写).md | 160 ++++++++++ 3 files changed, 573 insertions(+) create mode 100644 doc/docs/业务流程/2026-10-18-正式结算单预付整合设计(进度预付+项目预付核销·金额口径与记录流转).md create mode 100644 doc/docs/业务流程/2026-10-18-预付与正式结算单全链路逻辑梳理(进度预付-项目预付核销·前端到后端各环节).md create mode 100644 doc/docs/业务流程/2026-10-18-预付完整流程梳理(进度预付-项目预付-正式结算单-付款-金蝶回写).md diff --git a/doc/docs/业务流程/2026-10-18-正式结算单预付整合设计(进度预付+项目预付核销·金额口径与记录流转).md b/doc/docs/业务流程/2026-10-18-正式结算单预付整合设计(进度预付+项目预付核销·金额口径与记录流转).md new file mode 100644 index 00000000..6c3805c7 --- /dev/null +++ b/doc/docs/业务流程/2026-10-18-正式结算单预付整合设计(进度预付+项目预付核销·金额口径与记录流转).md @@ -0,0 +1,284 @@ +# 正式结算单预付整合设计(进度预付 + 项目预付核销) + +> 关联文档:同目录《2026-10-18-预付完整流程梳理(进度预付-项目预付-正式结算单-付款-金蝶回写).md》(改造前现状梳理) +> 设计日期:2026-10-18。范围:正式结算单(FormalSettlement)对两类预付的金额整合与付款记录关联显示。 +> **落地后全链路逻辑见同目录《2026-10-18-预付与正式结算单全链路逻辑梳理(进度预付-项目预付核销·前端到后端各环节).md》。** +> **v2 修订**:纳入评审意见——核销计入口径改为"仅已付款预付可核销"(待业务确认)、新增核销行迁移设计(预结算转正式 / 调整重推)、作废联动删行补全(含结算单作废/金蝶删单回调/预结算作废)、调整单校验与刷新、列表页 N+1 规避、对账与调整完成判定影响面、细节修正。见文末修订记录。 + +## 一、背景与目标 + +两条预付线的"单据流"已经打通(进度预付经预结算单继承、项目预付经核销组件落库),但**金额与记录**没有整合进正式结算单: + +| # | 差距 | 现状锚点 | +|---|---|---| +| 1 | 结算合计区域看不到预付抵扣:原金额、已付金额(含预付/核销)、剩余未付金额 | `formal-settlement-editor.vue` 结算合计区只渲染费用明细合计(L99-180) | +| 2 | 项目预付核销金额不计入结算单 `appliedPaymentAmount / paidAmount / remainingPayableAmount / paymentStatus` | `calculatePaymentSummary` payable 分支只加来源预结算的预付快照(`FormalSettlementServiceImpl` L1881-1890) | +| 3 | 付款记录只显示尾款(`settlement_payment`)申请,进度预付/项目预付的已付款记录不关联显示 | `detail()` 查询付款申请固定 `paymentType=结算`(L339) | +| 4 | 核销行没有"流转"设计:预结算转正式、调整重推时核销行丢失或额度双占 | `cloneFormalSettlement` L641-708 不克隆核销行;编辑器新建固定重置 `[]`(L1410) | +| 5 | 各类作废不清理核销行,详情残留、批量刷新会把作废单核销计入已付 | `FormalSettlementServiceImpl.voidBill` L600-609、`voidByKingdee` L613-637、`PreSettlementServiceImpl.voidBill` L443 均无删行 | + +**零表结构变更**:所需字段均已存在(`FormalSettlementSource` 预付快照、`SettlementProjectAdvanceWriteOff` 核销行、`PreSettlementAdvance` 预付台账),本设计只做计算口径、记录流转与展示整合。 + +## 二、已确认决策 + +| 决策 | 结论 | 状态 | +|---|---|---| +| D1 结算合计"扣除"的呈现 | `settlementAmount` **保持全额**(费用合计,开票匹配上限、金蝶 A102-2 应付单金额、结算调整单基线都不变)。结算合计卡片下增加汇总栏:**结算合计(原金额)/ 已付金额(含预付、核销)/ 剩余未付金额**,编辑态与只读态都显示。 | ✅ 已确认 | +| D2 核销计入口径(修订) | **仅已付款的项目预付可核销**:`listOptions` 选单过滤与 `replaceWriteOffs` 保存校验都增加 `kingdeeStatus='paid'` 条件(当前只要求 `approved`,L157)。核销时点钱已付出,核销金额同时计入 `appliedPaymentAmount` 与 `paidAmount` 属实付口径,不存在"审批未付却推成 paid"的失真,与付款记录行展示的底层实付一致。 | ✅ 已确认(方案②) | +| D3 付款记录关联显示 | 后端 detail 新增统一预付付款记录列表 `advancePayments`;前端在只读"付款信息"表格中**合并成一张表**,以"付款类型"列区分:进度预付 / 项目预付核销 / 结算尾款。 | ✅ 已确认 | +| D4 范围 | 只做正式结算单(FORMAL)侧汇总;金蝶预付抵扣字段(`y_billtype/y_billid/y_settleamt`)维持本期不传;应收(receivable)侧不动。**但 PRE→FORMAL 的核销行搬运在范围内**(见 D5)。 | ✅ 已确认 | +| D5 核销行流转原则 | **同一笔预付的核销在链路上只存在一份**:预结算转正式时来源 PRE 核销行转移为本单 FORMAL 行;调整重推时原单核销行迁移到新单。转移 = 删源行 + 落新行,同一事务。 | ✅ 已确认 | +| D6 作废联动删行 | 付款申请作废删其核销行(既有"作废联动"模式);正式结算单作废(`voidBill`/`voidByKingdee`)与预结算单作废(`voidBill`)补 `deleteBySettlement`,消除详情残留与汇总污染。 | ✅ 已确认 | +| D7 结算调整单兜底 | 调整落库(`SettlementAdjustmentServiceImpl` L384-388)后:①校验**核销合计 ≤ 新结算金额**,超出则拒绝并提示先调减核销;②调用 `refreshPaymentSummary` 重算汇总(含核销)。 | ✅ 已确认 | +| D8 影响面确认点 | ① 对账单 `copyHeader` 复制 `paidAmount`(`TransportReconciliationServiceImpl` L1325)→ 对账单已付口径将含核销,**按可接受设计**;② `isSettlementCompleted`(`SettlementAdjustmentServiceImpl` L468-471)判定 → 全额核销的结算单 `paymentStatus=paid`,将**不能再发起结算调整**,按"全额抵扣即结清"设计。历史数据问题明确不考虑。 | ✅ 已确认 | + +## 三、金额口径 + +前提(D2 修订):核销行的底层付款申请必为 `kingdeeStatus='paid'`。 + +``` +结算金额 settlementAmount = Σ 结算合计费用项 settlementAmount(不变) +已付金额 paidAmount = 尾款已付(直挂+关系表, approved) + + Σ 来源预结算 advancePaidAmount(进度预付,已有) + + Σ 本单核销行 writeOffAmount(项目预付,本次新增;行必对应已付款预付) +已申请付款 appliedPaymentAmount = 尾款申请中(直挂+关系表, reviewing) + + Σ 来源预结算 advanceAppliedAmount(已有) + + Σ 本单核销行 writeOffAmount(本次新增) +剩余可付 remainingPayableAmount = max(settlementAmount − paidAmount, 0)(已有公式,输入变了) +付款状态 paymentStatus = unpaid / partial / paid(已有公式,输入变了) +``` + +> 与进度预付口径的准确表述:进度预付在结算单上是"申请(advanceAppliedAmount)/ 实付(advancePaidAmount)"两条快照分开跟踪;项目预付在核销时点已完成实付(D2),故核销额计入实付。付款记录行的"已付款金额"(底层实付)与汇总栏"已付金额"必然一致,不存在同屏矛盾。 + +## 四、后端改动(tms-api / blade-transport) + +### 4.1 汇总计入核销金额 + +**`FormalSettlementServiceImpl.calculatePaymentSummary(settlement, data)`**(L1864-1901): + +`payable` 分支(L1881-1890)在累加来源预付快照之后追加: + +```java +// 项目预付核销计入已付/已申请(D2:核销行底层预付必已付款,核销额即实付抵扣) +BigDecimal writeOffAmount = data.writeOffAmounts() != null + ? data.writeOffAmounts().getOrDefault(settlement.getId(), BigDecimal.ZERO) + : this.projectAdvanceWriteOffService.sumBySettlement( + TransportBusiConstant.SETTLEMENT_BILL_FORMAL, settlement.getId()); +appliedAmount = appliedAmount.add(this.money(writeOffAmount)); +paidAmount = paidAmount.add(this.money(writeOffAmount)); +``` + +**`PaymentSummaryData` record**(L1926-1929)新增组件: + +```java +@param writeOffAmounts 结算单id → 核销金额合计(仅应付结算单;null 表示由纯计算按需查询) +..., BigDecimal receiptClaimPaid, Map writeOffAmounts +``` + +同步修改两处构造调用: +- 批量版 `refreshPaymentSummaries`(L1544-1627):`hasPayable` 时按 `settledIds` **一次**查询核销行分组求和(4.5 新增方法),按结算单分发,避免 N+1; +- 单笔版包装方法(L1839-1858):传 `null` 走按需查询。 + +> 说明:`saveDraft` 保存核销后由 `refreshSettlementAmount`(内部 L1524 刷新)+ 新增的一次 `refreshPaymentSummary` **连续两次完整刷新是有意为之**——核销校验依赖 `refreshSettlementAmount` 重算后的最终结算金额,故 `replaceWriteOffs` 只能排在其后,二次刷新不能合并省略。后续优化前先读此注释。 + +### 4.2 保存核销后刷新汇总 + +**`FormalSettlementServiceImpl.saveDraft`**(L546-553):`replaceWriteOffs` 保持在 `refreshSettlementAmount`(L546)之后(校验用最终结算金额),其后追加: + +```java +projectAdvanceWriteOffService.replaceWriteOffs(...); // L548-553 原位置不动 +this.refreshPaymentSummary(settlement.getId()); // 新增:核销行计入汇总 +``` + +### 4.3 核销行流转(D5) + +**通用删行方法**(`ISettlementProjectAdvanceWriteOffService` 新增,事务内逻辑删除): + +```java +/** 删除指定结算单上的核销行(配合转移/作废场景),返回被删行数 */ +int deleteBySettlementIds(String settlementType, Collection settlementIds); +/** 按付款申请删除核销行,返回受影响的正式结算单id(settlementType=formal,供汇总刷新) */ +List deleteByPaymentApplicationIds(Collection paymentApplicationIds); +``` + +**预结算转正式(`saveDraft` 完整建单路径)**: +1. 前端:编辑器 `confirmCandidates` 选定来源预结算后,对每个新选 `preSettlementId` 复用**既有** `preSettlement.getDetail(id)` 取 `projectAdvanceWriteOffs` 预填进本单核销组件(去重、金额 Number 化),用户可在弹窗中增删改; +2. 后端 `saveDraft`:在 `replaceWriteOffs`(L548)**之前**执行 `deleteBySettlementIds("pre", request.getSourcePreSettlementIds())`——先释放来源占用,`replaceWriteOffs` 的"不超可核销余额"校验才能通过预填金额;随后 `replaceWriteOffs` 将预填行落为本单 FORMAL 行。同一事务,失败整体回滚。 +3. 轻量标记路径(`PreSettlementServiceImpl.formalSettlement(id)`,L550):不产生正式单实体,PRE 核销行原地保留(预结算实体仍是载体),待后续真正建正式单时按上述规则转移。 + +**调整重推(`cloneFormalSettlement`,L641-708)**:与既有"新单付款口径归零"哲学(L658 注释)冲突时以**额度正确性优先**—— +1. 在来源克隆循环后,克隆原单活跃核销行到新单(`settlementId` 换新、`resetAuditFields`); +2. `deleteBySettlementIds("formal", List.of(sourceSettlementId))` 删原单核销行(原单 `adjustStatus=adjusted` 为历史存档,已付归零以新单为准,文档明示); +3. `refreshPaymentSummaries(List.of(原单id, 新单id))` 一次批量刷新两单(新单头 L662-664 置零的 applied/paid 由重算纠正为核销额)。 + +> 新单由克隆直接 `approved`(L647),无草稿编辑机会,故必须迁移而非"让用户重新核销"。 + +### 4.4 作废联动删行(D6) + +| 触发点 | 改动 | +|---|---| +| `PaymentApplicationServiceImpl.voidBill`(L446-460,L458 `voidPreSettlementAdvance` 后) | `deleteByPaymentApplicationIds(List.of(entity.getId()))` → 返回的 formal ids 非空则 `formalSettlementService.refreshPaymentSummaries(ids)` | +| `FormalSettlementServiceImpl.voidBill`(L600-609) | `deleteBySettlementIds("formal", List.of(id))` | +| `FormalSettlementServiceImpl.voidByKingdee`(L613-637) | 同上 | +| `PreSettlementServiceImpl.voidBill`(L443) | `deleteBySettlementIds("pre", List.of(id))`(预结算作废前置"先作废预付"校验不受影响,核销行不阻断) | + +金蝶反审核自动解锁置 `failed`(未作废)的场景**不删行**:预付申请仍是 `approved`,已核销行继续计入;人工作废后经上表路径回落。 + +存量数据提示:v2 之前保存的核销行可能对应"approved 未 paid"的预付,本次不做数据修正,如需口径清洗另出脚本(`replaceWriteOffs` 校验只约束新保存)。 + +### 4.5 核销服务与 Mapper + +**`ISettlementProjectAdvanceWriteOffService` / `SettlementProjectAdvanceWriteOffServiceImpl`**: +- 4.3 两个删除方法(见上); +- `Map sumBySettlementIds(String settlementType, Collection settlementIds)`——一次 group-by,供批量刷新与列表页(4.7)使用。 + +**`SettlementProjectAdvanceWriteOffMapper.java`(接口)+ `SettlementProjectAdvanceWriteOffMapper.xml`**:新增 `sumWriteOffBySettlementIds`(`is_deleted=0` + `settlement_type` + `settlement_id in (...)`,`GROUP BY settlement_id`),与既有 `sumWriteOffBySettlement`(XML L27-33)口径一致。 + +**选单与校验加 paid 条件(D2)**: +- `listOptions`(ServiceImpl L48-81):查询条件追加 `kingdeeStatus='paid'`; +- `replaceWriteOffs`(L124-193):在 `approvalStatus=approved` 校验(L157-159)后追加 `kingdeeStatus='paid'` 校验,提示文案"项目预付已付款后方可核销"。 + +### 4.6 detail 返回统一预付付款记录 + +**新 VO `SettlementAdvancePaymentVO`**(`blade-transport-api/pojo/vo/`,仿 `ProjectAdvanceWriteOffOptionVO` 风格,含 BladeX 许可头): + +| 字段 | 说明 | +|---|---| +| `recordType` | `progress_advance` 进度预付 / `project_advance` 项目预付核销 | +| `paymentNo` | 进度预付 = `PreSettlementAdvance.advanceNo`(FKD 单号);项目预付 = 核销行 `paymentNo` | +| `appliedAmount` | 进度预付 = `appliedAmount`;项目预付 = 核销行 `writeOffAmount`(**本次核销金额**,非付款申请金额) | +| `paidAmount` | 进度预付 = `paidAmount`;项目预付 = 底层付款申请 `paidAmount`(D2 下 = 核销额,同屏一致) | +| `billStatus` | 进度预付 = 台账 `billStatus`;项目预付 = 付款申请 `approvalStatus` | +| `kingdeeBillNo` | 进度预付 = `kingdeeAdvanceNo`;项目预付 = 核销行 `kingdeeBillNo` | +| `createUserName` / `createTime` | `UserCache.getUserRealName(createUser)`(AGENTS.md 6.9);**createTime 统一取底层付款申请创建时间**:项目行用核销行已回填的 `paymentCreateTime`(`listBySettlement` 现成),进度行按 `advanceNo in (...)` 批量反查 `PaymentApplication.createTime`(一次 in 查询)。列表排序与展示均用该时间 | + +**`FormalSettlementVO`** 新增 `@TableField(exist = false) private List advancePayments;`(L74 `paymentApplications` 旁)。 + +**`FormalSettlementServiceImpl.detail`**(L307-352)在加载 `projectAdvanceWriteOffs`(L323-324)后组装: + +```java +vo.setAdvancePayments(this.listAdvancePayments(sources, vo.getProjectAdvanceWriteOffs())); +``` + +`listAdvancePayments` 私有方法: +1. **进度预付**:`PreSettlementAdvance` 按 `preSettlementId in 来源预结算ids`、`billStatus != 'voided'` 查询(注入 `PreSettlementAdvanceMapper`,与同类 `sourceMapper` 直连模式一致); +2. **项目预付**:遍历核销行,`paymentApplicationMapper.selectBatchIds` 批量取底层付款申请回填已付金额/状态; +3. 合并后按"付款申请创建时间"降序(列表默认排序规范)。 + +尾款记录(`paymentApplications`)查询不动。 + +### 4.7 列表页 N+1 规避(D8/评审#5) + +`selectPage` L209-210 逐行 `toVO`,而 `toVO`(L1827-1837)内部 `calculatePaymentSummary` 已是每行 3-4 次查询,**不能再叠加每行一次核销求和**: + +- 新增重载 `toVO(FormalSettlement entity, Map writeOffAmounts)`,把 map 置入 `PaymentSummaryData`;单参版委托 `toVO(entity, null)`(按需查询,`detail` 等单笔场景不受影响); +- `selectPage`:本页 `payable` 结算单 ids 一次 `sumBySettlementIds` 得 map,`result.convert(item -> this.toVO(item, writeOffMap))`。 + +### 4.8 结算调整单兜底(D7) + +**`SettlementAdjustmentServiceImpl`**(L384-388 直改金额处): +1. 落库前校验:`projectAdvanceWriteOffService.sumBySettlement("formal", formal.getId())` ≤ 新 `settlementAmount`,超出抛"核销合计超过调整后结算金额,请先调减项目预付核销"; +2. 落库后:`formalSettlementService.refreshPaymentSummary(formal.getId())`(注入 `IFormalSettlementService`,当前仅有 `formalMapper`)。 + +### 4.9 后端改动文件清单 + +| 文件 | 改动 | +|---|---| +| `service/impl/FormalSettlementServiceImpl.java` | `calculatePaymentSummary` 计入核销;`PaymentSummaryData` 加组件;`saveDraft` 补刷新 + 转移来源 PRE 行;`cloneFormalSettlement` 克隆/迁移核销行;`voidBill`/`voidByKingdee` 删行;`detail` 补 `advancePayments` + 新私有 `listAdvancePayments`;`toVO` 加 map 重载;`selectPage` 批量供数;注入 `PreSettlementAdvanceMapper` | +| `service/ISettlementProjectAdvanceWriteOffService.java` + `impl` | 新增 `deleteBySettlementIds`、`deleteByPaymentApplicationIds`、`sumBySettlementIds`;`listOptions`/`replaceWriteOffs` 加 `kingdeeStatus='paid'` 条件 | +| `mapper/SettlementProjectAdvanceWriteOffMapper.java` + `.xml` | 新增 `sumWriteOffBySettlementIds`(接口 + XML 成对) | +| `service/impl/PaymentApplicationServiceImpl.java` | `voidBill` 联动删核销行 + 刷新汇总 | +| `service/impl/PreSettlementServiceImpl.java` | `voidBill` 删 PRE 核销行 | +| `service/impl/SettlementAdjustmentServiceImpl.java` | 调整落库前核销合计校验 + 落库后刷新汇总;注入 `IFormalSettlementService` | +| `pojo/vo/SettlementAdvancePaymentVO.java`(新增) | 统一预付付款记录 VO | +| `pojo/vo/FormalSettlementVO.java` | 加 `advancePayments` | + +## 五、前端改动(tms-erp-web) + +### 5.1 结算合计汇总栏 + +**`formal-settlement-editor.vue`**:结算合计 section-card(L99-180)表格下方加汇总条(仅 `form.settlementType === 'payable'` 显示): + +``` +结算合计(原金额):form.settlementAmount +已付金额(含预付、核销):见下 +剩余未付金额:max(结算合计 − 已付, 0) +``` + +- 编辑态:`Σ sources.advancePaidAmount + Σ projectAdvanceWriteOffs.writeOffAmount`(选预结算 `confirmCandidates` 与勾选核销行时实时联动;草稿态无尾款申请,公式完备;D2 下核销行必对应已付预付,计入实付口径成立); +- 只读态:`form.paidAmount`(已含尾款 + 进度预付 + 核销的落库值)。 + +### 5.2 付款记录合并表 + +只读"付款信息"应付表格(L580-612)数据源改为合并计算属性: + +```js +paymentRecords() { + const advance = (this.advancePayments || []).map(r => ({ ...r, typeName: this.recordTypeName(r.recordType) })); + const tail = (this.paymentApplications || []).map(r => ({ ...r, typeName: r.paymentTypeName })); + return [...advance, ...tail]; +} +// recordTypeName: progress_advance→'进度预付',project_advance→'项目预付核销' +``` + +- 列:付款类型(`typeName`,尾款行兼容原 `paymentTypeName`)/ 单据号 / **申请/核销金额**(预付行的"申请金额"实为核销金额,列名合并以免误导)/ 已付款金额 / 单据状态 / 金蝶单据号 / 创建人 / 创建时间(= 付款申请创建时间,见 4.6)。原"付款方式"列预付行无值,显示 `-`; +- 单据状态名映射**提取公共工具**(与 `pre-settlement-editor.vue` L2879 `advanceStatusName` 去重):新建 `src/utils/settlementStatus.js` 导出 `settlementStatusName(value)`(reviewing 审核中、approved 已审批、paid 已付款、returned 已驳回、voided 已作废、draft 草稿),两个编辑器共用,避免状态名不一致; +- `loadFormalDetail`(L1360-1379)回填 `this.advancePayments = data.advancePayments || []`,`readonly` 重置时清空。 + +### 5.3 转正式预填核销行 + +`confirmCandidates`(L1729-1752)选定来源预结算后:对每个新选 `preSettlementId` 调用既有 `preSettlement` detail 接口,取 `projectAdvanceWriteOffs` 预填进本单核销组件(与已有行按 `paymentApplicationId` 去重、金额 Number 化);保存时随 payload 提交,后端按 4.3 转移来源行。 + +### 5.4 前端不动的部分 + +- `project-advance-write-off.vue`:选单过滤、本次核销金额校验链路已完整(L198-288),"已付款"过滤条件由后端 `listOptions` 落实,组件不改; +- 列表页 `formal-settlement.vue`:"申请付款金额(含预付)/已收已付合计(含预付)/剩余可付金额"列直接受益于后端口径修正,不改; +- 尾款申请默认可付金额 `payableOfFormalSettlement` 已扣核销额(`PaymentApplicationServiceImpl` L2115-2122),不改。 + +## 六、边界场景 + +| 场景 | 行为 | +|---|---| +| 草稿保存核销 | 已付/已申请/剩余可付/付款状态立即生效(D2,行必对应已付预付),列表可见 | +| 底层项目预付申请作废 | 联动删核销行 + 批量刷新 → 已付回落(4.4) | +| 底层预付金蝶推送 `failed` / 反审核解锁 | 不删行,核销继续计入(预付申请仍 approved);人工作废后回落 | +| 金蝶付款完成回写(进度预付) | `syncOnePreSettlementAdvance` 刷新来源快照 → `refreshPaymentSummariesForPreSettlement` → 批量版自动计入核销(4.1 供数) | +| 正式结算单作废 / 金蝶删单回调 | 删除本单核销行(4.4),额度释放、详情无残留 | +| 预结算单作废 | 删除 PRE 核销行(4.4) | +| 预结算转正式(尾款结算) | 前端预填 + 后端先删来源 PRE 行再落 FORMAL 行(4.3),额度单份占用 | +| 轻量标记转正式(无在途预付) | PRE 核销行原地保留,待建正式单时转移(4.3) | +| 结算调整重推 | 核销行迁移到新单,原单删行并以新单为准(4.3) | +| 结算调整单调低金额致核销超限 | 落库前拦截,提示先调减核销(4.8) | +| 全额核销后发起调整 | `isSettlementCompleted` 拦截(现状逻辑),业务含义=已结清(D8 待确认) | +| 对账单生成 | 复制的 `paidAmount` 含核销口径(D8 待确认) | +| 核销合计 + 尾款申请超结算金额 | 双向封顶:核销合计 ≤ 结算金额(`replaceWriteOffs` L184-186);尾款默认额已扣核销额;`remainingPayableAmount` min 0 | +| 开票 | `settlementAmount` 全额不变,发票匹配上限(≤结算金额)不受影响(D1) | +| 应收(receivable)结算单 | 走收款认领分支,payable 分支的新增逻辑不触达 | + +## 七、测试要点 + +1. `saveDraft` 带核销行 → `appliedPaymentAmount / paidAmount / remainingPayableAmount / paymentStatus` 正确;编辑重复保存不重复累计(全删重插)。 +2. D2:`approved` 未 `paid` 的项目预付不出现在选单(`listOptions`),手工提交被 `replaceWriteOffs` 拒绝。 +3. 底层预付作废 → 核销行删除、汇总回落、列表金额一致;作废的正式结算单不再出现在批量刷新的已付统计中。 +4. 预结算转正式:预填行保存成功(先删 PRE 行再校验余额的顺序);同一预付在 PRE/FORMAL 侧无双份核销。 +5. 调整重推:新单含核销行且汇总正确(头字段从置零被重算纠正),原单行删除、汇总归零。 +6. 结算调整单:调低金额低于核销合计被拒;合法调整后 `refreshPaymentSummary` 生效。 +7. `detail` 返回 `advancePayments`:进度预付行(排除 voided)+ 项目预付行(回填底层实付),按付款申请创建时间倒序。 +8. 列表页 SQL 次数:本页核销求和恒定 1 次(不随行数增长)。 +9. 应收结算单、无预付结算单回归:汇总与现状一致。 +10. 前端:编辑态汇总栏随选预结算/核销行实时变化;只读态合并付款记录表三类型展示、状态名与预结算页一致;应收侧页面不出现汇总栏。 + +## 八、范围外(明确不做) + +- 预结算(PRE)侧自身收付汇总计入核销(本次仅 FORMAL); +- 金蝶预付抵扣字段 `y_billtype / y_billid / y_settleamt`(文档 TODO,本期不传); +- 应收侧预收/收款认领汇总; +- 存量"approved 未 paid 核销行"的数据修正脚本(如需另出); +- 遗留接口 `/pre-settlement/apply-advance` 等下线评估(见《2026-10-18-预付完整流程梳理》第五节)。 + +## 九、修订记录 + +| 版本 | 日期 | 内容 | +|---|---|---| +| v1 | 2026-10-18 | 初版:汇总计入、作废联动(仅付款申请侧)、detail 预付记录、前后端展示。 | +| v2 | 2026-10-18 | 评审修订:D2 改为"仅已付款可核销"(待确认);新增 D5 核销行流转(预结算转正式预填+转移、调整重推迁移)、D6 作废删行补全(结算单作废/金蝶回调/预结算作废)、D7 调整单校验+刷新、D8 对账与调整完成判定影响面(待确认);列表页 N+1 规避;文件清单补 Mapper 接口;合并表列名"申请/核销金额";状态名公共工具;createTime 统一为付款申请创建时间;点明 saveDraft 双刷新为有意设计。 | diff --git a/doc/docs/业务流程/2026-10-18-预付与正式结算单全链路逻辑梳理(进度预付-项目预付核销·前端到后端各环节).md b/doc/docs/业务流程/2026-10-18-预付与正式结算单全链路逻辑梳理(进度预付-项目预付核销·前端到后端各环节).md new file mode 100644 index 00000000..25cf41f8 --- /dev/null +++ b/doc/docs/业务流程/2026-10-18-预付与正式结算单全链路逻辑梳理(进度预付-项目预付核销·前端到后端各环节).md @@ -0,0 +1,129 @@ +# 改造后全链路逻辑梳理(预付 × 正式结算单) + +> 关联文档:同目录《2026-10-18-预付完整流程梳理(进度预付-项目预付-正式结算单-付款-金蝶回写).md》(改造前现状梳理)、同目录《2026-10-18-正式结算单预付整合设计(进度预付+项目预付核销·金额口径与记录流转).md》(v2 设计与决策) +> 梳理日期:2026-10-18。本文描述**改造落地后**的完整逻辑,前端到后端各环节。 + +## 一、全景 + +``` +【线A 进度预付】付款申请(progress_advance) ─A103-3→ 金蝶付款 ─回写→ 预结算预付汇总 + └→ 尾款结算建正式单:继承预付快照 → 结算合计(全额)+汇总栏(已付含预付) → 审批 ─A102-2→ 财务应付单 + └→ 尾款付款(默认额已扣核销) ─A103-1→ 回写paid → 付款记录合并表 + +【线B 项目预付】付款申请(project_advance) ─A103-3→ 金蝶付款 ─回写→ kingdeeStatus=paid + └→ 正式结算单「核销」(仅paid可选) → 核销计入已付 → 审批 ─A102-2→ 财务应付单(全额) + └→ 尾款付剩余 → 后续与正式结算单流程完全一致 +``` + +**核心口径**(`FormalSettlementServiceImpl.calculatePaymentSummary` payable 分支,全系统唯一): + +``` +结算金额 settlementAmount = Σ 结算合计费用项(全额,不扣预付/核销——开票与金蝶应付基线) +已申请 appliedPaymentAmount = 尾款reviewing(直挂+关系表) + Σ来源预付快照 + Σ核销 +已付 paidAmount = 尾款approved实付 + Σ来源预付快照 + Σ核销 +剩余可付 remainingPayableAmount = max(结算金额 − 已付, 0) +付款状态 paymentStatus = unpaid / partial / paid +``` + +D2 决策:仅**金蝶已付款**(`kingdeeStatus='paid'`)的项目预付可核销,核销额计入已付属实付口径。 + +--- + +## 二、线A:进度预付(挂预结算单) + +### A1 预付与付款(改造前已有) + +- 前端 `pre-settlement.vue`「预付申请」→ `payment-application-form?paymentType=progress_advance` +- 后端 `PaymentApplicationServiceImpl`:审批 → AFTER_COMMIT `KingdeeApprovalSyncListener` → A103-3 直推金蝶 → 提前建档 `PreSettlementAdvance`(advanceNo=FKD 单号) +- 金蝶付款后 `PaymentKingdeeResultTask`(A103-2/A103-4)回写 paid +- 联动:`syncOnePreSettlementAdvance` 刷新预结算预付汇总(`advanceAppliedAmount/advancePaidAmount`)→ 同步刷新引用它的 `FormalSettlementSource` 快照 → `refreshPaymentSummariesForPreSettlement`(正式单已付跟着涨) + +### A2 尾款结算 → 正式结算单表单(前端) + +- `applyInitialSources`(列表跳转带入)/ `confirmCandidates`(弹窗追加):拉每张预结算 detail,合并明细与费用项 → `summaryTotal` → `form.settlementAmount`(**全额费用合计,不扣预付**) +- **汇总栏(新增)**:仅应付显示;编辑态实时 `已付 = Σ来源预结算.advancePaidAmount + Σ核销行`,`剩余 = 原金额 − 已付`;只读态 `已付 = form.paidAmount`(落库值,含尾款) + +### A3 保存 `saveDraft`(后端,同一事务) + +1. 来源合并(同合同同币种校验)→ `FormalSettlementSource` 快照(含预付汇总) +2. `refreshSettlementAmount`:settlementAmount=Σ费用项 → 内部刷一次汇总 +3. **新增**:`deleteBySettlementIds(PRE, 来源预结算ids)`——先转移来源预结算的项目预付核销行(释放额度) +4. `replaceWriteOffs`(服务端复校验:paid、同合同、≤可核销余额、合计≤结算金额)→ **新增** `refreshPaymentSummary`(核销落库后必须再刷一次,第 2 步的刷新覆盖不到核销行) + +### A4 审批 → 同步金蝶 + +- `approve` → `KingdeeApprovalSyncListener` → **A102-2 财务应付单,金额=全额结算金额** → lockBusiData 锁单 + A902 附件 + +### A5 只读详情(付款记录关联显示,本次核心新增) + +- 后端 `detail` 新增 `advancePayments`:进度预付行 = 来源预结算的 `PreSettlementAdvance` 台账(排除 voided;appliedAmount=申请预付、paidAmount=实付;createTime 统一取**付款申请创建时间**,按 advanceNo 反查) +- 前端 `paymentRecords` 合并表:**进度预付 / 项目预付核销 / 结算尾款** 三类型一张表;状态名走公共 `src/utils/settlementStatus.js` + +### A6 尾款付款 + +- 前端 `formal-settlement.vue`「付款申请」→ `referenceAmount` 默认可付 = **结算金额 − 累计尾款申请 − 核销** +- `settlement_payment` → 前置校验已有财务应付单 → A103-1 应付下推 → 回写 paid → `FormalSettlementPayment(final)` + 汇总刷新 → `paymentStatus=paid` + +--- + +## 三、线B:项目预付(挂项目+承运商合同) + +| 环节 | 逻辑 | +|---|---| +| B1 申请 | 付款申请 `project_advance`:选项目+承运商合同、不挂结算单;`validateQuota` 校验项目额度(fundLimit+临时额度)与客户额度(maxCreditLimit) | +| B2 付款 | 审批 → A103-3 直推 → 金蝶付款 → A103-2 回写 **`kingdeeStatus='paid'`**(核销前置条件) | +| B3 核销·选单(编辑器) | 全新单:核销组件「选择预付单」→ `project-advance-write-off-options`(后端过滤:同合同 + `approved` + **`paid`** + 有余额);**预结算转正式入口(新增)**:`confirmCandidates`/`applyInitialSources` 选定来源后自动预填来源预结算已保存的核销行,可增删改 | +| B3 核销·录额(前端) | 手录本次核销金额:>0、≤可核销余额、合计≤结算金额(`project-advance-write-off.vue` 既有校验链) | +| B4 保存 | 同 A3 第 3-4 步:先转移源 PRE 行 → `replaceWriteOffs` 复校验落 FORMAL 行 → 刷新汇总,核销计入已付 | +| B5 审批 | A102-2 按全额结算金额生成财务应付单(金蝶预付抵扣字段 `y_billtype/y_billid/y_settleamt` 本期不传) | +| B6 付款记录 | 合并表显示项目预付核销行:`申请/核销金额`=本次核销额,`已付款金额`=底层付款申请实付(paid 前置下两者相等,同屏一致) | +| B7 尾款 | 默认可付已扣核销 → 已核销部分不会被重复支付;剩余走正常尾款流程 | +| B8 差异 | 后续金额差异按既有规则走**结算调整单**(见横切 5) | + +--- + +## 四、横切环节 + +| # | 环节 | 逻辑 | +|---|---|---| +| 1 | 列表页 | `selectPage` 本页 payable 一次 `sumWriteOffBySettlementIds`(group-by)→ `toVO(map)` 重算 → 列「申请付款金额(含预付)/已收已付合计(含预付)/剩余可付/收付款状态」直接反映核销;SQL 不随行数增长 | +| 2 | 汇总刷新触发点 | 保存、审批、金蝶付款回写、预结算预付快照联动、作废、调整——全部收敛到 `calculatePaymentSummary`,口径唯一 | +| 3 | 作废联动(新增) | 付款申请作废 → 删其核销行 + 刷新受影响正式单汇总(已付回落);正式单作废(`voidBill`)与金蝶删单回调(`voidByKingdee`)→ 删本单核销行(额度释放);预结算作废 → 删 PRE 核销行 | +| 4 | 核销行流转(新增) | 预结算转正式 = 先删源 PRE 行再落 FORMAL 行(同事务,同一预付的核销全链路只存在一份);调整重推 = 核销行克隆到新单 + 删原单行 + `refreshPaymentSummaries(两单)`(原单为调整存档,收付口径以新单为准) | +| 5 | 结算调整单(新增兜底) | 调整落库前校验**核销合计 ≤ 新结算金额**(超出拒绝并提示先调减核销);落库后 `refreshPaymentSummary` 重算 | +| 6 | 对账单 | `copyHeader` 复制 `paidAmount` → 对账单已付口径含核销(已确认接受) | +| 7 | 全额核销=结清 | `paymentStatus=paid` → `isSettlementCompleted` 拦截,不允许再发起结算调整(已确认接受) | +| 8 | 开票 | 始终按**全额 settlementAmount** 匹配(Σ匹配 ≤ 结算金额),预付/核销不影响发票 | +| 9 | 金蝶预付抵扣 | `y_billtype / y_billid / y_settleamt` 本期不传(见《2026-10-18-预付完整流程梳理》TODO) | +| 10 | 历史数据 | 本次不考虑存量"approved 未 paid"核销行的清洗 | + +--- + +## 五、改动文件索引 + +**后端(tms-api)** + +| 文件 | 内容 | +|---|---| +| `FormalSettlementServiceImpl` | 汇总计入核销(单笔/批量/toVO 重载)、saveDraft 转移+刷新、cloneFormalSettlement 迁移、voidBill/voidByKingdee 删行、detail.advancePayments、selectPage 批量供数 | +| `SettlementProjectAdvanceWriteOffServiceImpl` | listOptions/replaceWriteOffs 加 paid 条件;sumBySettlementIds / deleteBySettlementIds / deleteByPaymentApplicationIds | +| `SettlementProjectAdvanceWriteOffMapper.java/.xml` | sumWriteOffBySettlementIds | +| `PaymentApplicationServiceImpl` | voidBill 联动删核销行+刷新 | +| `PreSettlementServiceImpl` | voidBill 删 PRE 核销行 | +| `SettlementAdjustmentServiceImpl` | 调整前核销合计校验 + 调整后刷新汇总 | +| `SettlementAdvancePaymentVO`(新)/ `FormalSettlementVO` | 统一预付付款记录 VO / advancePayments 字段 | + +**前端(tms-erp-web)** + +| 文件 | 内容 | +|---|---| +| `formal-settlement-editor.vue` | 结算合计汇总栏;付款记录合并表;confirmCandidates/applyInitialSources 预填核销行 | +| `src/utils/settlementStatus.js`(新) | 状态名/记录类型名公共映射(预结算编辑器同步使用) | + +--- + +## 六、已知口径与风险 + +1. 尾款默认可付额(`payableOfFormalSettlement`)= 结算金额 − 累计尾款申请 − 核销,**不扣进度预付已付部分**(改造前既有口径,本次未动);进度预付占大头时默认额可能偏大,提交后由审批环节把关。 +2. 正式结算单 `paidAmount` 现含核销,下游消费该字段的报表/对账口径随之变化(对账已确认接受)。 +3. 金蝶侧预付抵扣字段未传,金蝶报表与本系统"已付含核销"存在时间差。 diff --git a/doc/docs/业务流程/2026-10-18-预付完整流程梳理(进度预付-项目预付-正式结算单-付款-金蝶回写).md b/doc/docs/业务流程/2026-10-18-预付完整流程梳理(进度预付-项目预付-正式结算单-付款-金蝶回写).md new file mode 100644 index 00000000..63ac715b --- /dev/null +++ b/doc/docs/业务流程/2026-10-18-预付完整流程梳理(进度预付-项目预付-正式结算单-付款-金蝶回写).md @@ -0,0 +1,160 @@ +# 预付完整流程梳理 + +> 范围:预付(进度预付 / 项目预付)→ 正式结算单 → 付款 → 金蝶回写等后续流程,前端到后端全链路。 +> 依据代码现状梳理(tms-api / tms-erp-web),梳理日期:2026-10-18。 + +## 一、总览 + +预付有**两条线**,最终在金蝶(星瀚 BFI)汇合: + +``` +【进度预付线】 【项目预付线】 +预结算单(审批通过,应付) 付款申请-项目预付(挂项目/合同,不挂结算单) + └─「预付申请」→ 付款申请(progress_advance) └─ 校验项目额度/客户额度 → A103-3 → 金蝶付款 + └─ 审批 → A103-3 直推金蝶付款申请单 └─ 事后在预结算/正式结算单上「项目预付核销」 + └─ 提前建档 PreSettlementAdvance(approved) + └─ 定时回写 A103-2/A103-4 → paid + └─ 预付记录置 paid + 预结算单预付汇总刷新 + └─「尾款结算」→ 正式结算单(继承预付汇总) → 审批 → A102-2 财务应付单 + └─「申请付款」→ 付款申请(settlement_payment) → A103-1 应付下推 + └─ 回写 paid → FormalSettlementPayment + 结算单付款汇总(unpaid→partial→paid) +``` + +### 金蝶接口速查 + +| 接口 | 用途 | 触发方 | +|---|---|---| +| A103-3 | 预付款直接保存金蝶付款申请单(不绑应付单) | 付款申请(progress_advance / project_advance)同步金蝶 | +| A103-1 | 应付下推生成金蝶付款申请单 | 付款申请(settlement_payment)同步金蝶 | +| A103-2 | 付款申请单查询(幂等预检 + 结果回写) | 推送前预检、定时回写任务 | +| A103-4 | 付款处理查询(回填实付款记录) | 定时回写任务 | +| A102-2 | 财务应付单保存 | 正式结算单「同步金蝶」 | +| A102-4 | 财务应付单查询(防重预检 / 反查) | 推送前预检、下推反查付款计划分录 | +| A902 | 附件上传 | 主单推送成功后联带上传 | +| lockBusiData | 金蝶单据锁定 | 推送成功事务提交后 | + +## 二、涉及的主要代码与数据表 + +### 后端(tms-api / blade-transport) + +| 内容 | 位置 | +|---|---| +| 预结算单 | `controller/PreSettlementController.java`、`service/impl/PreSettlementServiceImpl.java` | +| 付款申请 | `controller/PaymentApplicationController.java`、`service/impl/PaymentApplicationServiceImpl.java` | +| 正式结算单 | `controller/FormalSettlementController.java`、`service/impl/FormalSettlementServiceImpl.java` | +| 项目预付核销 | `service/impl/SettlementProjectAdvanceWriteOffServiceImpl.java` | +| 金蝶同步支撑 | `support/KingdeeApBillSyncSupport.java`(A102-2/A102-4)、`service/impl/KingdeeBillLockServiceImpl.java`(锁单)、`KingdeeAttachmentSyncServiceImpl.java`(附件) | +| 审批后自动推送 | `listener/KingdeeApprovalSyncListener.java`(AFTER_COMMIT)+ `event/KingdeeBillApprovedEvent.java` | +| 定时任务 | `task/PaymentKingdeeResultTask.java`(付款结果回写,每 10 分钟)、`task/KingdeeAutoSyncRetryTask.java`(失败重推,每 10 分钟)、`task/KingdeeBillLockRetryTask.java`(补锁) | + +### 前端(tms-erp-web) + +| 页面 | 位置 | +|---|---| +| 预结算单列表/表单 | `src/views/settlement/pre-settlement.vue`、`pre-settlement-form.vue`、`components/pre-settlement-editor.vue` | +| 正式结算单列表/表单 | `src/views/settlement/formal-settlement.vue`、`formal-settlement-form.vue`、`components/formal-settlement-editor.vue` | +| 项目预付核销组件 | `src/views/settlement/components/project-advance-write-off.vue` | +| 付款申请列表/表单 | `src/views/payment/payment-application.vue`、`payment-application-form.vue` | +| API 层 | `src/api/settlement/preSettlement.js`、`src/api/settlement/formalSettlement.js`、`src/api/payment/paymentApplication.js` | + +### 核心数据表(实体) + +| 表/实体 | 作用 | +|---|---| +| `PreSettlement`(预结算单) | 含预付汇总字段 `advanceNo / advanceAppliedAmount / advancePaidAmount`、`formalSettlementNo` | +| `PreSettlementAdvance`(预结算预付记录) | 预付台账:`advanceNo`(=付款申请单号)、`appliedAmount / paidAmount / billStatus(reviewing/approved/paid/voided) / kingdeeAdvanceNo` | +| `PaymentApplication`(付款申请) | `paymentType`:`progress_advance` 进度预付 / `project_advance` 项目预付 / `settlement_payment` 尾款付款;`kingdeeStatus`:`unsynced/synced/paying/paid/closed/failed` | +| `PaymentApplicationSettlement` | 付款申请 ↔ 结算单(正式/预结算)多选关系及分摊金额 | +| `PaymentApplicationRecord` | 付款记录(金蝶 A103-4 回填,`voucherJson` 留档凭证号/银行流水/电子回单) | +| `FormalSettlement`(正式结算单) | 含 `appliedPaymentAmount / paidAmount / remainingPayableAmount / paymentStatus / kingdeeSyncStatus` | +| `FormalSettlementSource` | 正式结算单来源预结算单/明细快照,冗余预付汇总 | +| `FormalSettlementPayment` | 正式结算单付款记录(尾款 `paymentType=final`) | +| `SettlementProjectAdvanceWriteOff` | 项目预付核销记录(`settlementType`:PRE/FORMAL) | + +## 三、阶段详解 + +### 阶段一:前置 —— 预结算单审批通过 + +- 前端:预结算列表新建/编辑 → 保存草稿 → 提交 → 审批通过(`pre-settlement.vue`,API `/blade-transport/pre-settlement/save|submit|approve`)。 +- 后端:`PreSettlementController` → `PreSettlementServiceImpl.saveDraft / submit / approve`。 +- 审批通过(`approved`)后,**应付(payable)**预结算单即可发起预付。 + +### 阶段二:发起预付(进度预付) + +1. **前端入口**:预结算列表勾选 →「预付申请」按钮。前端校验:同一合同、`approved`、`payable`、未转正式结算(`pre-settlement.vue` `handleAdvanceApplication`)。 +2. **跳转付款申请表单** `/payment/payment-application/form?paymentType=progress_advance&preSettlementIds=...`(并非直接建预付单)。 +3. **表单**(`payment-application-form.vue`): + - 进度预付仅可关联预结算单;不展示发票信息与付款记录; + - 可付金额实时调 `/payment-application/reference-amount` = 结算金额 − 累计已申请 − 项目预付核销额。 +4. **后端**(`PaymentApplicationServiceImpl`): + - `saveDraft`:生成 `FKD+日期+序号` 单号;`fillReference` 回填预结算关联;保存 `PaymentApplicationSettlement` 关系表; + - `submit`:`draft/returned → reviewing`(财务审核); + - `approve`:`→ approved`,刷新关联结算单付款汇总,**发布 `KingdeeBillApprovedEvent`**。 + +> 说明:`PreSettlementController` 上的 `/apply-advance`、`/update-advance-paid`、`/void-advance` 为遗留接口,前端已不再调用;`PreSettlementAdvance` 台账现由付款申请流程联动维护(见阶段三/四)。 + +### 阶段三:同步金蝶(A103-3 预付款直推) + +- 审批事务 **AFTER_COMMIT** → `KingdeeApprovalSyncListener.pushPayment`(Nacos 开关 `thirdParty.bfi.autoSync.payment`)→ `paymentApplicationService.syncKingdee`。 +- `doSyncKingdee`:预付款走 **A103-3 直接保存金蝶付款申请单(不绑应付单)**;推送前 A103-2 幂等预检防重复;成功后落 `kingdeeStatus=synced` + `kingdeeBillId` → **lockBusiData 锁单** → A902 附件上传。 +- **提前建档**:`syncPreSettlementAdvance(entity, approved)` —— 按付款申请号在 `PreSettlementAdvance` 生成预付记录(`advanceNo = FKD 单号`),刷新预结算单预付汇总(`advanceNo / advanceAppliedAmount / advancePaidAmount`),并联动刷新关联正式结算单付款汇总(预付信息两边页面即时可见)。 +- **失败兜底**:`markKingdeeSyncFailed` 落 `failed`;`KingdeeAutoSyncRetryTask` 每 10 分钟自动重推;页面「同步金蝶」按钮手动兜底。审批接口通过 `pollSyncFailure()` 将推送失败原因以警告文案带回前端(审批结果不受推送影响)。 + +### 阶段四:金蝶付款 + 结果回写(付款之后) + +定时任务 `PaymentKingdeeResultTask`(每 10 分钟,分布式锁 single-flight)→ `syncKingdeePaymentResult`: + +1. **A103-2 付款申请单查询**:按 `paymentNo`(防重键)分批查询,回填金蝶单号、状态(`synced → paying → paid/closed`)、已付金额(分录 `e_paidamt` 汇总)。 +2. **付款完成(paid)联动**:`syncPreSettlementAdvance(paid, 全额)` 置预付记录为已付。 +3. **A103-4 付款处理查询**:回填付款记录 `PaymentApplicationRecord`——实付金额、付款日期、金蝶付款单号;凭证号/银行流水/电子回单整体存 `voucherJson` 留档追溯。 +4. **异常自愈**:A103-2 连续两轮查不到已推送单据(Redis 计数)→ 判定金蝶侧已反审核删除 → `unlockBill` 置 `failed`、清空金蝶单号/内码、**联动作废未付的预付记录**(已付事实不回退);重推后重新建档。 +5. 前端可手动触发:付款申请页「回写金蝶付款结果」按钮 `/sync-kingdee-result`。 + +### 阶段五:预结算 → 正式结算单 + +- **前端**:预结算列表「尾款结算」→ 校验同项目、`approved` → 跳 `/settlement/formal-settlement/form` 携带预结算数据(`handleFormalSettlement`)。 +- **后端两条路**: + 1. **完整建单**:`FormalSettlementServiceImpl.saveDraft` —— 合并多张预结算单(`FormalSettlementSource`)或应收应付明细;**继承预付汇总**(`advanceAppliedAmount / advancePaidAmount`);支持**项目预付核销** `replaceWriteOffs`(校验:同合同、审批通过、不超可核销余额、合计不超结算单金额);单号 `ZJS...`。 + 2. **轻量标记**:`PreSettlementController /formal-settlement` → `formalSettlement(id)` —— 预结算单回写 `formalSettlementNo`、源明细置 `formal_settled`;**前提:无在途预付申请**(存在 `reviewing/approved` 预付则拦截)。 +- **正式结算单审批**:草稿 → 提交 → `approve` → 事件 → `syncKingdee` → **A102-2 财务应付单**(A102-4 预检/反查防重,一张结算单一张应付单)→ 回写 `kingdeeSyncStatus` → 锁单 + 附件。 +- 列表展示「申请付款金额(含预付)/ 已收已付合计(含预付)」。 + +### 阶段六:尾款付款(正式结算单之后) + +1. **前端**:正式结算单列表勾选(`approved`、`payable`、同合同)→「申请付款」→ 付款申请表单 `paymentType=settlement_payment`,默认取剩余可付金额(`openPaymentDialog`)。 +2. **后端**:`saveDraft` 支持一单挂多张正式结算单(同合同,按可付金额比例分摊);`submit` 时 `validateNoTailPayment` 防重复尾款申请。 +3. **同步金蝶路由不同**:结算付款走 **A103-1 应付下推**——前置校验每张正式结算单已在金蝶生成财务应付单(A102-4 预检,未生成则提示"请先在正式付款单页面同步金蝶"),再下推生成金蝶付款申请单;之后锁单、附件同前。 +4. **回写后**:paid 时 `syncFormalSettlementPayments` 按关系表分摊生成 `FormalSettlementPayment(paymentType=final)` → `refreshPaymentSummary` 重算结算单 `appliedPaymentAmount / paidAmount / remainingPayableAmount / paymentStatus`(unpaid → partial → paid)。金额后续差异走**结算调整单**。 + +### 项目预付线(project_advance) + +- 付款申请页「预付申请」按钮 → `project_advance`:选**项目 + 承运商合同**(仅承运商合同),不挂结算单、无发票/付款记录。 +- `validateQuota` 校验:项目额度(`fundLimit` + 有效期内审批通过的临时额度)、客户额度(`maxCreditLimit`)。 +- 审批 → A103-3 直推 → 金蝶付款 → 回写 paid。 +- **后续核销**:预结算单/正式结算单表单中「项目预付核销」组件 → 保存时 `replaceWriteOffs` 写 `SettlementProjectAdvanceWriteOff`;可付金额计算统一扣减核销额(`referenceAmount`)。 +- 金蝶侧预付抵扣字段(`y_billtype / y_billid / y_settleamt`)**TODO 本期不传**(`KingdeeApBillSyncSupport`,待业务确认后迭代)。 + +## 四、逆向流程 + +| 操作 | 规则 | +|---|---| +| 付款申请作废(仅 approved) | 金蝶锁单中禁止作废(`checkEditable`);汇票付款释放票据余额;**联动作废未付的预付记录**(已付事实不回退);刷新结算单汇总 | +| 预结算单作废 | 存在关联预付申请需先作废预付 | +| 预付记录作废 | 已付款的不能作废 | +| 金蝶反审核(解锁) | A103-2 连续两轮消失自动解锁置 `failed`,人工核实后重推;解锁时原金蝶单号/内码留档清空,重推生成新单后重新关联附件与预付记录 | + +## 五、遗留接口说明 + +`PreSettlementController` 的以下接口前端已不再调用(前端统一走付款申请单流程),`PreSettlementAdvance` 表改由付款申请联动维护,保留兼容: + +- `POST /pre-settlement/apply-advance`(发起预付申请) +- `POST /pre-settlement/update-advance-paid`(回写预付付款金额) +- `POST /pre-settlement/void-advance`(作废预付申请) + +如需下线,需另行评估权限与存量数据兼容性。 + +## 六、风险与未覆盖范围 + +- 应收侧预收/收款认领(ReceiptClaim)流程未在本篇展开。 +- 汇票付款(BillPayment)仅提及票据余额占用/释放联动。 +- 金蝶 A102/A103 接口报文字段细节未逐项列出,参见 `KingdeeApBillSyncSupport`、`PaymentApplicationServiceImpl.pushBfiPayApply / pushBfiPayApplyPush` 源码注释。 From 47d5d6c2265c3ab7dc2321d1c6b8892d36b8aaf4 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Sun, 18 Oct 2026 14:33:16 +0800 Subject: [PATCH 063/108] =?UTF-8?q?:sparkles:=20=E9=85=8D=E8=BD=BD?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E4=B8=8E=E9=87=8D=E6=96=B0=E6=B4=BE=E5=8D=95?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E6=A0=A1=E9=AA=8C=E8=BF=90=E5=8D=95=E5=8F=AF?= =?UTF-8?q?=E9=85=8D=E8=BD=BD=E4=B8=8E=E5=90=8C=E9=A1=B9=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/LoadingManageServiceImpl.java | 98 ++++- .../support/LoadableAdmissionSupport.java | 18 + .../impl/LoadingManageAdmissionTest.java | 349 ++++++++++++++++++ 3 files changed, 452 insertions(+), 13 deletions(-) create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageAdmissionTest.java diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java index 7393aa55..588fc3a5 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java @@ -29,6 +29,7 @@ import org.springblade.transport.pojo.vo.LoadingManageVO; import org.springblade.transport.service.IContractManageService; import org.springblade.transport.service.ILoadingManageService; import org.springblade.transport.service.IReceivablePayableDetailService; +import org.springblade.transport.support.LoadableAdmissionSupport; import org.springblade.transport.support.TransportBusinessSupport; import org.springblade.transport.support.WaybillProcessSupport; import org.springblade.transport.wrapper.LoadingManageWrapper; @@ -745,16 +746,61 @@ public class LoadingManageServiceImpl extends BaseServiceImpl + * 查询条件表达不了跨行约束,只能整批取回后在内存里比。 + * + * @param waybills 本次要写入的子运单全集 + */ + private void validateSameProject(List waybills) { + Long projectId = waybills.get(0).getProjectId(); + List others = waybills.stream() + .filter(waybill -> !Objects.equals(waybill.getProjectId(), projectId)) + .map(Waybill::getWaybillNo) + .toList(); + if (Func.isNotEmpty(others)) { + throw new ServiceException("配载单的子运单必须属于同一项目,运单" + + String.join("、", others) + "与其它子运单不属于同一项目"); + } } private void validateCarrierContract(LoadingManage loadingManage, boolean required) { @@ -772,10 +818,14 @@ public class LoadingManageServiceImpl extends BaseServiceImpl Objects.equals(contract.getId(), loadingManage.getCarrierContractId())) - .findFirst() - .orElseThrow(() -> new ServiceException("所选承运商合同不存在、未审核通过或已失效")); + ContractManage carrierContract = availableCarrierContractById(loadingManage.getCarrierContractId()); + if (carrierContract == null) { + throw new ServiceException("所选承运商合同不存在、未审核通过或已失效"); + } + // 合同自带项目与乙方:合同的项目不属于本单子运单的项目,等于换了项目却没换乙方 + if (!projectIdsByWaybills(loadingManage).contains(carrierContract.getProjectId())) { + throw new ServiceException("所选承运商合同不属于本项目"); + } loadingManage.setCarrierName(carrierContract.getPartyB()); } @@ -808,15 +858,37 @@ public class LoadingManageServiceImpl extends BaseServiceImpllambdaQuery() + return contractManageService.list(availableCarrierContractQuery() + .in(ContractManage::getProjectId, projectIds) + .orderByDesc(ContractManage::getCreateTime)); + } + + /** + * 按主键取一张可用的承运商合同。 + *

+ * 不按项目过滤,才能把「合同不可用」与「合同不属于本项目」分成两种提示。 + * + * @param contractId 合同主键 + * @return 可用的合同;不存在或不可用返回 null + */ + private ContractManage availableCarrierContractById(Long contractId) { + if (Func.isEmpty(contractId)) { + return null; + } + return contractManageService.list(availableCarrierContractQuery() + .eq(ContractManage::getId, contractId) + .orderByDesc(ContractManage::getCreateTime)) + .stream().findFirst().orElse(null); + } + + private LambdaQueryWrapper availableCarrierContractQuery() { + return Wrappers.lambdaQuery() .eq(ContractManage::getIsDeleted, 0) .eq(ContractManage::getStatus, 1) .eq(ContractManage::getContractCategory, "承运商合同") - .in(ContractManage::getProjectId, projectIds) .in(ContractManage::getApprovalStatus, "approved", "change_approved") .and(wrapper -> wrapper.isNull(ContractManage::getContractStage) - .or().ne(ContractManage::getContractStage, "terminated")) - .orderByDesc(ContractManage::getCreateTime)); + .or().ne(ContractManage::getContractStage, "terminated")); } private void syncAssociatedWaybills(LoadingManage loadingManage, String status, List oldWaybillIds) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/LoadableAdmissionSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/LoadableAdmissionSupport.java index 202c2644..ea148e4d 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/LoadableAdmissionSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/LoadableAdmissionSupport.java @@ -107,6 +107,24 @@ public final class LoadableAdmissionSupport { return loadingNo.isEmpty() || loadingNo.equals(text(ownLoadingNo)); } + /** + * 该运单是否已挂在本配载单上。 + *

+ * 本单既有子运单在先前提交时已过准入判据,重新提交/编辑时不再重复判: + * 不需接单项目的票提交后即为进行中,重复判会让本单再也保存不了,重新派单也无从下手。 + * + * @param waybill 运单 + * @param ownLoadingNo 本配载单号;新建时传 null + * @return true-已是本单子运单 + */ + public static boolean isOwnChild(Waybill waybill, String ownLoadingNo) { + if (waybill == null) { + return false; + } + String own = text(ownLoadingNo); + return !own.isEmpty() && own.equals(text(waybill.getLoadingNo())); + } + /** * 把整条可配载判据加到运单查询上:公路 + 待执行 + 未配载 + 非拒单。 * diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageAdmissionTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageAdmissionTest.java new file mode 100644 index 00000000..0eebe79c --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageAdmissionTest.java @@ -0,0 +1,349 @@ +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.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.mockito.ArgumentMatchers; +import org.mockito.MockedStatic; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.transport.mapper.LoadingManageMapper; +import org.springblade.transport.mapper.WaybillMapper; +import org.springblade.transport.pojo.entity.ContractManage; +import org.springblade.transport.pojo.entity.LoadingManage; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.service.IContractManageService; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +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 LoadingManageAdmissionTest { + + private static final Long DEPT_ID = 1001L; + private static final Long PROJECT_A = 2001L; + private static final Long PROJECT_B = 2002L; + private static final Long CONTRACT_ID = 3001L; + private static final Long OTHER_CONTRACT_ID = 3002L; + private static final Long OWN_LOADING_ID = 9001L; + private static final String OWN_LOADING_NO = "PZ20261018-001"; + private static final String WAYBILL_NO_A = "YD202610180001"; + private static final String WAYBILL_NO_B = "YD202610180002"; + + private LoadingManageServiceImpl service; + private WaybillMapper waybillMapper; + private LoadingManageMapper loadingManageMapper; + private IContractManageService contractManageService; + private MockedStatic authUtil; + + @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, LoadingManage.class); + TableInfoHelper.initTableInfo(assistant, ContractManage.class); + } + + @BeforeEach + void setUp() throws Exception { + waybillMapper = mock(WaybillMapper.class); + loadingManageMapper = mock(LoadingManageMapper.class); + contractManageService = mock(IContractManageService.class); + service = new LoadingManageServiceImpl(); + inject("baseMapper", loadingManageMapper); + inject("waybillMapper", waybillMapper); + inject("contractManageService", contractManageService); + authUtil = mockStatic(AuthUtil.class); + authUtil.when(AuthUtil::getDeptId).thenReturn(String.valueOf(DEPT_ID)); + } + + @AfterEach + void tearDown() { + authUtil.close(); + } + + // ------------------------------------------------------------ 逐张准入 + + @Test + @DisplayName("非公路运单被拒,错误含运单号与原因") + void nonRoadWaybillRejected() { + stubWaybills(waybill(1L, WAYBILL_NO_A, "railway", "pending", null, PROJECT_A)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.submit(submitPayload(List.of(1L)))); + + assertEquals("运单" + WAYBILL_NO_A + "运输方式不是公路,不可配载", exception.getMessage()); + } + + @Test + @DisplayName("状态不是待执行一律被拒:进行中、已完成、已取消、草稿") + void nonPendingStatusRejected() { + for (String status : List.of("running", "completed", "cancelled", "draft")) { + stubWaybills(waybill(1L, WAYBILL_NO_A, "road", status, null, PROJECT_A)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.submit(submitPayload(List.of(1L))), status); + + assertEquals("运单" + WAYBILL_NO_A + "当前状态不是待执行,不可配载", exception.getMessage(), status); + } + } + + @Test + @DisplayName("已挂在其它的配载单上的运单被拒") + void waybillOnAnotherManifestRejected() { + stubWaybills(waybill(1L, WAYBILL_NO_A, "road", "pending", "PZ20261018-999", PROJECT_A)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.submit(submitPayload(List.of(1L)))); + + assertEquals("运单" + WAYBILL_NO_A + "已关联其他配载单", exception.getMessage()); + } + + @Test + @DisplayName("已拒单的运单被拒:接单事实由配载写入路径清除,不靠放行拒单票") + void rejectedWaybillRejected() { + Waybill rejected = waybill(1L, WAYBILL_NO_A, "road", "pending", null, PROJECT_A); + rejected.setDriverAcceptStatus("rejected"); + stubWaybills(rejected); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.submit(submitPayload(List.of(1L)))); + + assertEquals("运单" + WAYBILL_NO_A + "已被司机拒绝接单,不可配载", exception.getMessage()); + } + + // ------------------------------------------------------------ 本单既有子运单 + + @Test + @DisplayName("编辑本单:子运单挂的就是本单号,不算冲突") + void editingOwnChildrenPasses() throws Exception { + stubExistingManifest(); + stubWaybills(waybill(1L, WAYBILL_NO_A, "road", "pending", OWN_LOADING_NO, PROJECT_A)); + stubContract(CONTRACT_ID, PROJECT_A); + + LoadingManage payload = submitPayload(List.of(1L)); + payload.setId(OWN_LOADING_ID); + + assertDoesNotThrow(() -> service.submit(payload)); + } + + @Test + @DisplayName("不需接单项目的子运单提交后即为进行中;重新提交本单不得因此被挡") + void editingOwnRunningChildrenPasses() throws Exception { + stubExistingManifest(); + stubWaybills(waybill(1L, WAYBILL_NO_A, "road", "running", OWN_LOADING_NO, PROJECT_A)); + stubContract(CONTRACT_ID, PROJECT_A); + + LoadingManage payload = submitPayload(List.of(1L)); + payload.setId(OWN_LOADING_ID); + + assertDoesNotThrow(() -> service.submit(payload)); + } + + @Test + @DisplayName("本单既有子运单放行,但同一次请求新塞进来的进行中运单仍被拒") + void newlyAddedRunningWaybillIsStillRejected() throws Exception { + stubExistingManifest(); + stubWaybills( + waybill(1L, WAYBILL_NO_A, "road", "running", OWN_LOADING_NO, PROJECT_A), + waybill(2L, WAYBILL_NO_B, "road", "running", null, PROJECT_A)); + + LoadingManage payload = submitPayload(List.of(1L, 2L)); + payload.setId(OWN_LOADING_ID); + + ServiceException exception = assertThrows(ServiceException.class, () -> service.submit(payload)); + + assertEquals("运单" + WAYBILL_NO_B + "当前状态不是待执行,不可配载", exception.getMessage()); + } + + @Test + @DisplayName("重新派单:本单被拒的子运单仍挂在本单号上,不因拒单被挡——重新派单本就是为它而设") + void reassignKeepsRejectedOwnChild() throws Exception { + stubExistingManifest(); + when(waybillMapper.selectCount(anyWaybillWrapper())).thenReturn(1L); + Waybill rejected = waybill(1L, WAYBILL_NO_A, "road", "pending", OWN_LOADING_NO, PROJECT_A); + rejected.setDriverAcceptStatus("rejected"); + stubWaybills(rejected); + stubContract(CONTRACT_ID, PROJECT_A); + + LoadingManage payload = submitPayload(List.of(1L)); + payload.setId(OWN_LOADING_ID); + + assertDoesNotThrow(() -> service.reassign(payload)); + } + + // ------------------------------------------------------------ 跨行判据 + + @Test + @DisplayName("跨项目的子运单集合被拒,错误点名与其它子运单不同项目的运单号") + void crossProjectChildrenRejected() { + stubWaybills( + waybill(1L, WAYBILL_NO_A, "road", "pending", null, PROJECT_A), + waybill(2L, WAYBILL_NO_B, "road", "pending", null, PROJECT_B)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.submit(submitPayload(List.of(1L, 2L)))); + + assertEquals("配载单的子运单必须属于同一项目,运单" + WAYBILL_NO_B + "与其它子运单不属于同一项目", + exception.getMessage()); + } + + @Test + @DisplayName("合同不属于本项目:与本项目无可选合同区分开") + void contractOfAnotherProjectRejected() { + stubWaybills(waybill(1L, WAYBILL_NO_A, "road", "pending", null, PROJECT_A)); + stubContract(CONTRACT_ID, PROJECT_B); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.submit(submitPayload(List.of(1L)))); + + assertEquals("所选承运商合同不属于本项目", exception.getMessage()); + } + + @Test + @DisplayName("合同本身不可用(未审核通过或已失效)仍报原有文案,不与「不属于本项目」混同") + void unavailableContractKeepsExistingMessage() { + stubWaybills(waybill(1L, WAYBILL_NO_A, "road", "pending", null, PROJECT_A)); + when(contractManageService.list(anyContractWrapper())).thenReturn(List.of()); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.submit(submitPayload(List.of(1L)))); + + assertEquals("所选承运商合同不存在、未审核通过或已失效", exception.getMessage()); + } + + // ------------------------------------------------------------ 放行 + + @Test + @DisplayName("公路 + 待执行 + 未配载 + 同项目 + 合同属于本项目:通过并带出合同乙方") + void legalRequestPasses() { + stubWaybills( + waybill(1L, WAYBILL_NO_A, "road", "pending", null, PROJECT_A), + waybill(2L, WAYBILL_NO_B, "公路运输", "pending", "", PROJECT_A)); + stubContract(CONTRACT_ID, PROJECT_A); + + LoadingManage payload = submitPayload(List.of(1L, 2L)); + + assertDoesNotThrow(() -> service.submit(payload)); + assertEquals("甲承运商", payload.getCarrierName()); + } + + @Test + @DisplayName("暂存不受准入校验影响:跨项目、非公路、进行中的票照旧落草稿") + void draftIsNotGated() { + LoadingManage draft = new LoadingManage(); + draft.setDeptId(DEPT_ID); + draft.setLoadingSubNos(WAYBILL_NO_A); + draft.setCarrierType("自运"); + draft.setWaybillIdsJson(waybillIdsJson(List.of(1L, 2L))); + + assertDoesNotThrow(() -> service.saveDraft(draft)); + verify(waybillMapper, never()).selectList(anyWaybillWrapper()); + } + + // ------------------------------------------------------------ 夹具 + + private Waybill waybill(Long id, String waybillNo, String transportType, String businessStatus, + String loadingNo, Long projectId) { + Waybill waybill = new Waybill(); + waybill.setId(id); + waybill.setWaybillNo(waybillNo); + waybill.setTransportType(transportType); + waybill.setBusinessStatus(businessStatus); + waybill.setLoadingNo(loadingNo); + waybill.setProjectId(projectId); + waybill.setDeptId(DEPT_ID); + waybill.setIsDeleted(0); + return waybill; + } + + private LoadingManage submitPayload(List waybillIdList) { + LoadingManage loadingManage = new LoadingManage(); + loadingManage.setDeptId(DEPT_ID); + loadingManage.setLoadingSubNos(WAYBILL_NO_A); + loadingManage.setTransportType("road"); + loadingManage.setCarrierType("承运商"); + loadingManage.setCarrierContractId(CONTRACT_ID); + loadingManage.setVehicleNo("苏A12345"); + loadingManage.setDepartureAddress("南京"); + loadingManage.setArrivalAddress("上海"); + loadingManage.setWaybillIdsJson(waybillIdsJson(waybillIdList)); + return loadingManage; + } + + private static String waybillIdsJson(List waybillIdList) { + return waybillIdList.stream().map(String::valueOf).collect(Collectors.joining(",", "[", "]")); + } + + private void stubExistingManifest() { + LoadingManage existing = new LoadingManage(); + existing.setId(OWN_LOADING_ID); + existing.setLoadingNo(OWN_LOADING_NO); + existing.setDeptId(DEPT_ID); + existing.setBusinessStatus("pending"); + when(loadingManageMapper.selectById(OWN_LOADING_ID)).thenReturn(existing); + } + + private void stubWaybills(Waybill... waybills) { + when(waybillMapper.selectList(anyWaybillWrapper())).thenReturn(List.of(waybills)); + } + + private void stubContract(Long contractId, Long projectId) { + ContractManage contract = new ContractManage(); + contract.setId(contractId); + contract.setProjectId(projectId); + contract.setPartyB(contractId.equals(OTHER_CONTRACT_ID) ? "乙承运商" : "甲承运商"); + when(contractManageService.list(anyContractWrapper())).thenReturn(List.of(contract)); + } + + private static Wrapper anyWaybillWrapper() { + return ArgumentMatchers.>any(); + } + + private static Wrapper anyContractWrapper() { + return 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); + } + +} From cc2564c6ee4a6421f3652d2f65818cd2d8b1650d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Sun, 18 Oct 2026 14:38:07 +0800 Subject: [PATCH 064/108] =?UTF-8?q?Revert=20"fix:=20=E4=BB=98=E6=AC=BE?= =?UTF-8?q?=E7=94=B3=E8=AF=B7=E5=8F=91=E5=8F=B7=E5=90=AB=E9=80=BB=E8=BE=91?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E5=8D=A0=E4=BD=8D=E5=B9=B6=E5=8B=BE=E7=A8=BD?= =?UTF-8?q?=E9=87=91=E9=A2=9D=E6=A0=A1=E9=AA=8C=EF=BC=8C=E9=98=B2=E5=8D=95?= =?UTF-8?q?=E5=8F=B7=E5=A4=8D=E7=94=A8=E6=92=9E=E9=87=91=E8=9D=B6=E9=98=B2?= =?UTF-8?q?=E9=87=8D=E9=94=AE"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 1c9471df8902efd9304f37ede4fc9d0353ac7f50. --- .../mapper/PaymentApplicationMapper.java | 14 -------------- .../impl/PaymentApplicationServiceImpl.java | 19 +------------------ 2 files changed, 1 insertion(+), 32 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PaymentApplicationMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PaymentApplicationMapper.java index 2f8af3b1..770dc202 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PaymentApplicationMapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PaymentApplicationMapper.java @@ -21,23 +21,9 @@ package org.springblade.transport.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; -import org.apache.ibatis.annotations.Param; -import org.apache.ibatis.annotations.Select; import org.springblade.transport.pojo.entity.PaymentApplication; -import java.util.List; - /** 付款申请 Mapper。 @author Chill */ @Mapper public interface PaymentApplicationMapper extends BaseMapper { - - /** - * 按前缀查询单号(含逻辑删除单据):发号占位用,删除后的单号不复用, - * 避免 A103-3/A103-1 推送时防重键(awrp_src_code)撞金蝶已有单据(已提交单据不允许修改)。 - * - * @param pattern 单号匹配模式(如 'FKD20261018%') - */ - @Select("SELECT payment_no FROM blade_payment_application WHERE payment_no LIKE #{pattern}") - List selectPaymentNosIncludingDeleted(@Param("pattern") String pattern); - } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java index 4b387c48..f8860776 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java @@ -1040,13 +1040,6 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl no.length() > prefix.length() ? no.substring(prefix.length()) : "") - .filter(no -> no.matches("\\d+")) - .map(Integer::valueOf) - .max(Integer::compareTo) - .orElse(0); - return prefix + String.format("%05d", max + 1); - } + private String nextNo() { String prefix = "FKD" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); return prefix + String.format("%05d", count(Wrappers.lambdaQuery().likeRight(PaymentApplication::getPaymentNo, prefix)) + 1); } private BigDecimal nonNegative(BigDecimal value, String name) { if (value == null || value.compareTo(BigDecimal.ZERO) < 0) throw new ServiceException(name + "不能小于0"); return value; } private BigDecimal money(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; } private String limit(String value, int length) { if (value != null && value.length() > length) throw new ServiceException("备注不能超过" + length + "个字符"); return value; } From ca92288cf4d80c4ef6b10ab3b1afe945030a704b Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Sun, 18 Oct 2026 14:40:16 +0800 Subject: [PATCH 065/108] =?UTF-8?q?:white=5Fcheck=5Fmark:=20=E5=AD=90?= =?UTF-8?q?=E8=BF=90=E5=8D=95=E7=8A=B6=E6=80=81=E5=86=99=E7=94=A8=E4=BE=8B?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=86=99=E4=BE=A7=E5=87=86=E5=85=A5=E5=89=8D?= =?UTF-8?q?=E6=8F=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 逐张准入已要求公路 + 待执行 + 未配载 + 未拒单,夹具运单补运输方式; 同一配载单的子单必须同项目,跨项目推导改为直接测推导函数。 --- .../LoadingManageChildStatusWriteTest.java | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageChildStatusWriteTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageChildStatusWriteTest.java index 5b90e689..d1c6ee4e 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageChildStatusWriteTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageChildStatusWriteTest.java @@ -24,6 +24,7 @@ import org.springblade.transport.service.IProcessConfigService; import org.springblade.transport.service.IReceivablePayableDetailService; import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.util.Arrays; import java.util.LinkedHashMap; import java.util.List; @@ -163,8 +164,8 @@ class LoadingManageChildStatusWriteTest { } @Test - @DisplayName("跨项目运次:两张子单各自按自己项目推导,互不牵连,配置只查一次") - void submitDerivesEachWaybillFromItsOwnProject() { + @DisplayName("跨项目的子单逐张按各自项目推导,互不牵连,配置只查一次") + void deriveEachWaybillStatusFromItsOwnProject() throws Exception { stubWaybills( waybill(NEED_ACCEPT_WAYBILL_ID, NEED_ACCEPT_PROJECT_ID), waybill(NO_ACCEPT_WAYBILL_ID, NO_ACCEPT_PROJECT_ID)); @@ -172,11 +173,11 @@ class LoadingManageChildStatusWriteTest { config(NEED_ACCEPT_PROJECT_ID, NEED_ACCEPT_JSON), config(NO_ACCEPT_PROJECT_ID, NO_ACCEPT_JSON)); - assertTrue(service.submit(loadingForm(NEED_ACCEPT_WAYBILL_ID, NO_ACCEPT_WAYBILL_ID))); - + // 提交与重新派单在准入阶段就拦下跨项目的子单,故直接测逐张推导本身 assertEquals(Map.of( NEED_ACCEPT_WAYBILL_ID, "pending", - NO_ACCEPT_WAYBILL_ID, "running"), statusByWaybillId()); + NO_ACCEPT_WAYBILL_ID, "running"), + deriveBusinessStatuses(NEED_ACCEPT_WAYBILL_ID, NO_ACCEPT_WAYBILL_ID)); verify(processConfigService, times(1)).list(ArgumentMatchers.>any()); } @@ -216,12 +217,11 @@ class LoadingManageChildStatusWriteTest { @Test @DisplayName("提交清除本次合进来子单的接单事实:置为待接单,接单/拒单痕迹一并清空") void submitClearsAcceptRecordOfWaybillsTakenIn() { + // 同一配载单的子单必须同项目,故两张票同项目,接单事实按整批清 stubWaybills( waybill(NEED_ACCEPT_WAYBILL_ID, NEED_ACCEPT_PROJECT_ID), - waybill(NO_ACCEPT_WAYBILL_ID, NO_ACCEPT_PROJECT_ID)); - stubConfigs( - config(NEED_ACCEPT_PROJECT_ID, NEED_ACCEPT_JSON), - config(NO_ACCEPT_PROJECT_ID, NO_ACCEPT_JSON)); + waybill(NO_ACCEPT_WAYBILL_ID, NEED_ACCEPT_PROJECT_ID)); + stubConfigs(config(NEED_ACCEPT_PROJECT_ID, NEED_ACCEPT_JSON)); assertTrue(service.submit(loadingForm(NEED_ACCEPT_WAYBILL_ID, NO_ACCEPT_WAYBILL_ID))); @@ -368,6 +368,8 @@ class LoadingManageChildStatusWriteTest { waybill.setProjectId(projectId); waybill.setProcessJson(processJson); waybill.setDeptId(DEPT_ID_LONG); + // 可配载只放行公路 + 待执行 + 未配载 + 未拒单,夹具的票都是这种票 + waybill.setTransportType("公路运输"); waybill.setBusinessStatus("pending"); return waybill; } @@ -406,6 +408,16 @@ class LoadingManageChildStatusWriteTest { return statusByWaybillId; } + /** 逐张推导的结果:提交/重新派单在准入阶段就拦下跨项目子单,跨项目推导只能直接测这个函数 */ + private Map deriveBusinessStatuses(Long... waybillIds) throws Exception { + Method method = LoadingManageServiceImpl.class + .getDeclaredMethod("deriveWaybillBusinessStatuses", List.class); + method.setAccessible(true); + @SuppressWarnings("unchecked") + Map statusByWaybillId = (Map) method.invoke(service, List.of(waybillIds)); + return statusByWaybillId; + } + private List acceptClearUpdates() { return waybillUpdates().stream() .filter(update -> update.columns().contains("driver_accept_status")) From f9453e4facc4254b8546337ecf913ffa92795382 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Sun, 18 Oct 2026 14:48:17 +0800 Subject: [PATCH 066/108] =?UTF-8?q?:bug:=20=E6=8B=92=E5=8D=95=E7=A5=A8?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E8=A2=AB=E6=8E=92=E9=99=A4=E5=9C=A8=E5=8F=AF?= =?UTF-8?q?=E9=85=8D=E8=BD=BD=E4=B9=8B=E5=A4=96=EF=BC=8C=E9=9A=8F=E9=85=8D?= =?UTF-8?q?=E8=BD=BD=E6=8F=90=E4=BA=A4=E6=B8=85=E9=99=A4=E6=8E=A5=E5=8D=95?= =?UTF-8?q?=E4=BA=8B=E5=AE=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/impl/LoadingManageServiceImpl.java | 3 --- .../transport/support/LoadableAdmissionSupport.java | 13 ++++++------- .../service/impl/LoadingManageAdmissionTest.java | 10 ++++------ .../impl/LoadingManageChildStatusWriteTest.java | 2 +- .../service/impl/WaybillLoadableQueryTest.java | 8 +++----- .../support/LoadableAdmissionSupportTest.java | 13 +++++++++---- 6 files changed, 23 insertions(+), 26 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java index e90dfe99..96dd0fcb 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java @@ -784,9 +784,6 @@ public class LoadingManageServiceImpl extends BaseServiceImpl + * 接单状态不进判据:拒单票照样可配载。配载提交会把它的接单事实清成待接单, + * 否则新司机看不到这张票——司机端入口一律排除已拒绝的运单。 *

* 候选列表取数与配载提交/编辑/重新派单的校验共用这一份定义。 */ @@ -99,8 +102,7 @@ public final class LoadableAdmissionSupport { return false; } if (!isRoadTransport(waybill.getTransportType()) - || !LOADABLE_BUSINESS_STATUS.equals(text(waybill.getBusinessStatus())) - || WaybillProcessSupport.isRejected(waybill.getDriverAcceptStatus())) { + || !LOADABLE_BUSINESS_STATUS.equals(text(waybill.getBusinessStatus()))) { return false; } String loadingNo = text(waybill.getLoadingNo()); @@ -126,7 +128,7 @@ public final class LoadableAdmissionSupport { } /** - * 把整条可配载判据加到运单查询上:公路 + 待执行 + 未配载 + 非拒单。 + * 把整条可配载判据加到运单查询上:公路 + 待执行 + 未配载。 * * @param wrapper 运单列表查询条件 */ @@ -134,9 +136,6 @@ public final class LoadableAdmissionSupport { applyRoadTransportCondition(wrapper); wrapper.eq(Waybill::getBusinessStatus, LOADABLE_BUSINESS_STATUS); wrapper.and(nested -> nested.isNull(Waybill::getLoadingNo).or().eq(Waybill::getLoadingNo, "")); - // 接单状态绝大多数为 NULL,而 NULL <> 'rejected' 不成立,只写不等于会把它们一起滤掉 - wrapper.and(nested -> nested.isNull(Waybill::getDriverAcceptStatus) - .or().ne(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED)); } private static void applyRoadTransportCondition(LambdaQueryWrapper wrapper) { diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageAdmissionTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageAdmissionTest.java index 0eebe79c..aceee927 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageAdmissionTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageAdmissionTest.java @@ -127,16 +127,14 @@ class LoadingManageAdmissionTest { } @Test - @DisplayName("已拒单的运单被拒:接单事实由配载写入路径清除,不靠放行拒单票") - void rejectedWaybillRejected() { + @DisplayName("已拒单但未配载的公路待执行运单可通过提交:接单事实由配载写入路径清除,新司机由此看到它") + void rejectedWaybillIsAdmitted() { Waybill rejected = waybill(1L, WAYBILL_NO_A, "road", "pending", null, PROJECT_A); rejected.setDriverAcceptStatus("rejected"); stubWaybills(rejected); + stubContract(CONTRACT_ID, PROJECT_A); - ServiceException exception = assertThrows(ServiceException.class, - () -> service.submit(submitPayload(List.of(1L)))); - - assertEquals("运单" + WAYBILL_NO_A + "已被司机拒绝接单,不可配载", exception.getMessage()); + assertDoesNotThrow(() -> service.submit(submitPayload(List.of(1L)))); } // ------------------------------------------------------------ 本单既有子运单 diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageChildStatusWriteTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageChildStatusWriteTest.java index d1c6ee4e..8ebcbee3 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageChildStatusWriteTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageChildStatusWriteTest.java @@ -368,7 +368,7 @@ class LoadingManageChildStatusWriteTest { waybill.setProjectId(projectId); waybill.setProcessJson(processJson); waybill.setDeptId(DEPT_ID_LONG); - // 可配载只放行公路 + 待执行 + 未配载 + 未拒单,夹具的票都是这种票 + // 可配载只放行公路 + 待执行 + 未配载,夹具的票都是这种票 waybill.setTransportType("公路运输"); waybill.setBusinessStatus("pending"); return waybill; diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillLoadableQueryTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillLoadableQueryTest.java index 3b588be4..54e6eacf 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillLoadableQueryTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillLoadableQueryTest.java @@ -68,7 +68,7 @@ class WaybillLoadableQueryTest { } @Test - @DisplayName("开关开启:公路词表、待执行、未配载、非拒单一次全部进 wrapper") + @DisplayName("开关开启:公路词表、待执行、未配载一次全部进 wrapper,接单状态不参与") void loadableSwitchAppliesWholePredicate() { LambdaQueryWrapper wrapper = capturedQuery(loadableQuery()); String sql = String.valueOf(wrapper.getTargetSql()); @@ -85,10 +85,8 @@ class WaybillLoadableQueryTest { "进行中一律不可配载,不能沿用「待执行与进行中都可配载」的旧口径:" + params); assertTrue(sql.contains("loading_no IS NULL") && sql.contains("loading_no ="), "未配载要同时覆盖 NULL 与空串:" + sql); - assertTrue(sql.contains("driver_accept_status <>") && params.contains("rejected"), - "接单状态为已拒绝的要排除:" + sql); - assertTrue(sql.contains("driver_accept_status IS NULL"), - "接单状态大多是 NULL,只写不等于 rejected 会把它们一起滤掉:" + sql); + assertFalse(sql.contains("driver_accept_status"), + "接单状态不进可配载判据:拒单票照样可被重新配载:" + sql); } @Test diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/LoadableAdmissionSupportTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/LoadableAdmissionSupportTest.java index e73bd74e..92d8382f 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/LoadableAdmissionSupportTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/LoadableAdmissionSupportTest.java @@ -48,14 +48,21 @@ class LoadableAdmissionSupportTest { // ------------------------------------------------------------ 单张运单准入 @Test - @DisplayName("公路 + 待执行 + 未配载 + 未拒单才可配载") + @DisplayName("公路 + 待执行 + 未配载才可配载") void loadableOnlyWhenAllConditionsHold() { assertTrue(LoadableAdmissionSupport.isLoadable(waybill("road", "pending", null, "pending"))); assertTrue(LoadableAdmissionSupport.isLoadable(waybill("公路运输", "pending", "", null))); } @Test - @DisplayName("非公路、非待执行、已关联配载单、已拒单各自单独即可拒绝") + @DisplayName("已拒单的票照样可配载:接单事实由配载提交清除,不靠把拒单票挡在候选之外") + void rejectedWaybillIsLoadable() { + assertTrue(LoadableAdmissionSupport.isLoadable(waybill("road", "pending", null, "rejected"))); + assertTrue(LoadableAdmissionSupport.isLoadable(waybill("road", "pending", null, "rejected"), "PZ20260101-001")); + } + + @Test + @DisplayName("非公路、非待执行、已关联配载单各自单独即可拒绝") void eachConditionRejectsOnItsOwn() { assertFalse(LoadableAdmissionSupport.isLoadable(waybill("railway", "pending", null, "pending")), "非公路不可配载"); @@ -66,8 +73,6 @@ class LoadableAdmissionSupportTest { assertFalse(LoadableAdmissionSupport.isLoadable(waybill("road", "cancelled", null, null))); assertFalse(LoadableAdmissionSupport.isLoadable(waybill("road", "pending", "PZ20260101-001", null)), "已挂在其它配载单上的票不可再配载"); - assertFalse(LoadableAdmissionSupport.isLoadable(waybill("road", "pending", null, "rejected")), - "已拒单的票不可配载"); assertFalse(LoadableAdmissionSupport.isLoadable(null)); } From 6e685d3440dccb1ed238dbc4e44c3921470480c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Sun, 18 Oct 2026 15:23:12 +0800 Subject: [PATCH 067/108] =?UTF-8?q?docs:=20=E6=96=B0=E5=A2=9E=E9=A2=84?= =?UTF-8?q?=E4=BB=98=E8=BD=AC=E6=AD=A3=E5=BC=8F=E7=BB=93=E7=AE=97=E5=8D=95?= =?UTF-8?q?=E4=B8=8E=E9=A1=B9=E7=9B=AE=E9=A2=84=E4=BB=98=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E7=A8=8B=E8=AF=B4=E6=98=8E=EF=BC=88=E5=88=86=E5=9C=BA?= =?UTF-8?q?=E6=99=AF=C2=B7=E5=89=8D=E7=AB=AF=E5=90=8E=E7=AB=AF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...�结算单与项目预付操作流程说明(分场景·前端后端).md | 363 ++++++++++++++++++ 1 file changed, 363 insertions(+) create mode 100644 doc/docs/业务流程/2026-10-18-预付转正式结算单与项目预付操作流程说明(分场景·前端后端).md diff --git a/doc/docs/业务流程/2026-10-18-预付转正式结算单与项目预付操作流程说明(分场景·前端后端).md b/doc/docs/业务流程/2026-10-18-预付转正式结算单与项目预付操作流程说明(分场景·前端后端).md new file mode 100644 index 00000000..a61724d0 --- /dev/null +++ b/doc/docs/业务流程/2026-10-18-预付转正式结算单与项目预付操作流程说明(分场景·前端后端).md @@ -0,0 +1,363 @@ +# 预付转正式结算单与项目预付操作流程说明(分场景·前端后端) + +> 整理日期:2026-10-18 +> 定位:面向操作者与实施/联调人员的**分场景操作手册 + 逻辑说明**。讲清「谁在哪个页面点什么 → 后端发生什么 → 数据表/金蝶变成什么 → 怎么校验成功」。 +> 依据代码现状梳理(`tms-api` / `tms-erp-web`)。关联文档(同目录): +> - 《2026-10-18-预付完整流程梳理(进度预付-项目预付-正式结算单-付款-金蝶回写).md》——改造前现状 +> - 《2026-10-18-正式结算单预付整合设计(进度预付+项目预付核销·金额口径与记录流转).md》——v2 设计与决策 +> - 《2026-10-18-预付与正式结算单全链路逻辑梳理(进度预付-项目预付核销·前端到后端各环节).md》——改造后全链路 +> - 《2026-09-21-应收结算与开票申请同步金蝶全链路业务流程(A302-2保存-A302-4勾稽-A303-1新增-A303-4预检-A303-5回写).md》——开票链 +> - 《2026-09-29-预结算开票(预开票)金蝶推送方案.md》——预开票(**方案,尚未落地**) + +--- + +## 〇、先看全局:两条预付线 + 一条开票线 + +### 0.1 术语 + +| 术语 | 含义 | +|---|---| +| 预结算单(PreSettlement) | 结算前的费用单据,审批通过后作为预付/尾款结算的依据。有 **应付(payable)/应收(receivable)** 两种。 | +| 进度预付(progress_advance) | 挂在**预结算单**上的预付,属**应付**侧(我方付出去)。 | +| 项目预付(project_advance) | 挂在**项目 + 承运商合同**上的预付,不挂结算单,属**应付**侧。 | +| 核销 | 项目预付金在后续结算单上"抵扣",落 `SettlementProjectAdvanceWriteOff` 行。**仅金蝶已付款(paid)的项目预付可核销**。 | +| 容纳 / 纳入 | 预结算单转正式结算单时,其**预付台账快照**(进度预付)与**核销行**(项目预付)被正式结算单继承,计入正式单的"已付/已申请/剩余可付"。 | +| 尾款 | 正式结算单扣除已付(含预付、核销)后的剩余部分,走单独付款申请(settlement_payment)。 | +| 开票 | **应收**侧动作:正式结算单 → 开票申请 → 金蝶开票单。**应付**侧对应的是**收票**,两者共用 `formal_settlement.invoice_status` 字段,名称按结算方向区分。 | + +### 0.2 全景图 + +``` +【应付线 · 预付】 【应收线 · 开票】 + +预结算单(应付) approved 预结算单(应收) approved + │ │ + ├─ 预付申请(进度预付 progress_advance) └─ 尾款结算 + │ └ 审批 → A103-3 直推金蝶付款 → 回写 paid └→ 应收正式结算单 + │ └ 提前建档 PreSettlementAdvance ├ 审批 → 同步金蝶 A302-2 财务应收单 + │ └ 开票申请(选应收正式结算单) +项目预付(project_advance) ← 付款申请页「预付申请」 └ 审批 → A303-1 开票申请单 + ├ 选 项目 + 承运商合同(校验项目额度/客户额度) └ A303-5 回写发票 → 开票完成 + ├ 审批 → A103-3 → 金蝶付款 → 回写 paid + └ 后续在结算单上「项目预付核销」 + + ▼ 双线在「正式结算单」汇合 + 预结算单(应付) ──「尾款结算」──→ 应付正式结算单(容纳预付快照 + 核销行) + ├ 审批 → 同步金蝶 A102-2 财务应付单(全额 settlementAmount) + └「付款申请」→ 尾款付款 → A103-1 应付下推 +``` + +### 0.3 金蝶接口速查 + +| 接口 | 用途 | 触发方 | +|---|---|---| +| A103-3 | 预付款直接保存金蝶付款申请单(不绑应付单) | 付款申请(progress_advance / project_advance)同步金蝶 | +| A103-1 | 应付下推生成金蝶付款申请单 | 付款申请(settlement_payment)同步金蝶 | +| A103-2 / A103-4 | 付款申请单查询 / 付款处理查询 | 推送前幂等预检、定时回写(每 10 分钟) | +| A102-2 / A102-4 | 财务应付单保存 / 查询(防重预检) | 应付正式结算单「同步金蝶」 | +| A302-2 / A302-4 | 财务应收单保存 / 查询(勾稽) | 应收正式结算单「同步金蝶」 | +| A303-1 / A303-4 / A303-5 | 开票申请单保存 / 幂等预检 / 开票结果查询 | 开票申请「同步金蝶」与定时回写 | +| lockBusiData | 金蝶单据锁定 | 推送成功事务提交后 | +| A902 | 附件上传 | 主单推送成功后联带 | + +--- + +## 一、应付侧:预付转正式结算单(被"容纳") + +### 场景 A1:发起进度预付(挂预结算单) + +**前提**:预结算单 `approvalStatus=approved`、`settlementType=payable`、未转正式结算(`formalSettlementNo` 为空)。 + +**前端操作** +1. 菜单进「预结算单」列表 → 勾选 1 条或多条预结算单(**必须同一合同**)。 +2. 点工具栏「**预付申请**」。 + - 校验不通过时的提示:缺合同 ID / 多合同 / 非 payable / 非 approved / 已转正式。 +3. 跳转「付款申请」表单,页面类型已锁定为**进度预付**:结算单只读、结算金额与可付款金额自动带出。 +4. 填「申请付款金额」「付款方式」→「保存」→ 详情页「提交」→ 列表「通过」。 + +**后端动作** + +| 步骤 | 接口/方法 | 结果 | +|---|---|---| +| 可付金额 | `GET /payment-application/reference-amount` | `可付 = 预结算金额 − 累计已申请 − 项目预付核销额` | +| 保存 | `POST /payment-application/save` → `saveDraft` | 生成 `FKD+日期+序号` 单号;写 `PaymentApplicationSettlement` 关系表 | +| 提交 | `POST /payment-application/submit` | `draft/returned → reviewing` | +| 审批 | `POST /payment-application/approve` | `→ approved`,刷新预结算付款汇总,发布 `KingdeeBillApprovedEvent` | +| 自动推送 | `KingdeeApprovalSyncListener`(AFTER_COMMIT,开关 `thirdParty.bfi.autoSync.payment`) | A103-3 直推金蝶付款申请单;**提前建档** `PreSettlementAdvance`(`advanceNo = FKD 单号`);刷新预结算预付汇总并联动正式结算单 | + +> 说明:`PreSettlementController` 的 `/apply-advance`、`/update-advance-paid`、`/void-advance` 为**遗留接口**,前端已不再调用,台账改由付款申请流程联动维护。 + +**结果校验**:预结算单列表「申请预付金额 / 已预付金额」变化;付款申请 `kingdeeStatus` 从 `unsynced → synced`。 + +### 场景 A2:发起项目预付(挂项目 + 承运商合同) + +**前提**:项目已建、承运商合同已建;不超过项目额度与客户额度。 + +**前端操作** +1. 菜单进「付款申请」列表 → 点工具栏「**预付申请**」(注意:与 A1 的入口不同,本入口从付款申请页进)。 +2. 表单页类型为**项目预付**,填写: + - **所属项目**(必选,下拉) + - **合同名称**(必选,**仅承运商合同**,选项目后才可选) + - 申请付款金额、付款方式、付款方/收款方等 + - 不挂结算单,不展示发票信息与付款记录 +3. 「保存」→「提交」→「通过」。 + +**后端动作** + +| 步骤 | 接口/方法 | 结果 | +|---|---|---| +| 保存 | `saveDraft` → `validateQuota` | 项目额度、客户额度双重校验(见下) | +| 提交/审批 | `submit` / `approve` | 同上,审批后发事件推 A103-3 | +| 推送 | A103-3 | 金蝶生成付款申请单,`kingdeeStatus=synced` | +| 回写 | `PaymentKingdeeResultTask`(A103-2/A103-4,每 10 分钟) | `synced → paying → paid`;回填实付金额与付款记录 | + +**额度校验口径**(`PaymentApplicationServiceImpl.validateQuota`,悲观锁 `FOR UPDATE`) + +``` +项目额度 = (项目 fundLimit + Σ有效期内 approved 临时额度) × 10000 +客户额度 = 客商 maxCreditLimit × 10000 +申请付款金额 > 额度 → 分别报"申请付款金额不能超过项目额度 / 客户额度" +``` + +**结果校验**:付款申请列表 `kingdeeStatus=已付款(paid)`,金蝶单据号已回填。**只有 paid 才能进入后续核销**。 + +### 场景 A3:项目预付核销(在结算单上选单 + 录额) + +**前提**:存在 `approvalStatus=approved` 且 `kingdeeStatus=paid` 的项目预付,与结算单**同一合同**,且有剩余可核销余额。 + +**前端操作** +1. 打开**预结算单编辑器**(或后续的**正式结算单编辑器**)→「**项目预付核销**」分区 → 点「**选择**」。 +2. 弹窗「选择项目预付单」列出可核销单(同合同 + 已审批 + **已付款** + 有余额),已选行不可重复勾选。 +3. 勾选「确定」→ 默认按"可核销金额"预填「本次核销金额」,可手工改小。 +4. 「保存」(或保存草稿)。 + +**校验链(前端 → 后端一致)** + +| 层级 | 规则 | 提示 | +|---|---|---| +| 前端 | 金额 > 0 | 预付单 X 本次核销金额必须大于 0 | +| 前端 | 金额 ≤ 可核销金额 | 本次核销金额不能超过可核销金额 | +| 前端 | 核销合计 ≤ 结算单金额 | 项目预付核销合计不能超过结算单金额 | +| 后端 | 合同一致 | 项目预付单与结算单合同不一致 | +| 后端 | 仅项目预付 | 仅可核销项目预付单 | +| 后端 | approved | 仅可选择审核通过的项目预付单 | +| 后端 | **paid**(本次新增) | 预付单 X 尚未付款,项目预付须金蝶付款完成后方可核销 | +| 后端 | 同一预付单不重复 | 同一项目预付单不可重复核销 | +| 后端 | 有余额 / 合计 ≤ 结算金额 | 超出可核销金额 / 核销合计不能超过结算单金额 | + +**结果校验**:正式/预结算单「已付金额」立即包含核销额(核销行必对应已付款预付,计入实付口径),列表「申请付款金额(含预付)/已收已付合计(含预付)/剩余可付」同步变化。 + +### 场景 A4:预结算转正式结算单(容纳预付) + +**前提**:预结算单 `approved`、**同一项目**;此时其预付(进度预付台账、项目预付核销行)将被正式单继承。 + +**前端操作(两条路径)** +1. 「预结算单」列表勾选 → 工具栏「**尾款结算**」→ 跳「正式结算单」表单,来源预结算明细与费用项自动合并。 +2. 或在正式结算单表单里通过弹窗「追加来源预结算」。 + +**表单上的三处关键呈现(应付侧)** +- **结算合计(原金额)**:Σ 费用项 = `settlementAmount`,**始终全额,不扣预付/核销**(开票匹配上限、金蝶应付单金额基线)。 +- **汇总栏**「已付金额(含预付、核销)/ 剩余未付金额」: + - 编辑态实时算:`已付 = Σ来源预结算.advancePaidAmount + Σ核销行.writeOffAmount`; + - 只读态取落库值 `form.paidAmount`(已含尾款 + 进度预付 + 核销)。 +- **项目预付核销**:从预结算来源带入的核销行会**自动预填**,可增删改。 + +**后端动作(`FormalSettlementServiceImpl.saveDraft`,同一事务)** +1. 来源合并校验(同合同、同币种)→ 写 `FormalSettlementSource` 快照(含预付汇总 `advanceAppliedAmount / advancePaidAmount`)。 +2. `refreshSettlementAmount`:以费用项重算 `settlementAmount`,并刷一次汇总。 +3. **转移来源核销行**:`deleteBySettlementIds(PRE, 来源预结算ids)` —— 先释放来源占用,后续"不超可核销余额"校验才能通过预填金额。 +4. `replaceWriteOffs`:服务端复校验后落 **FORMAL** 核销行。 +5. `refreshPaymentSummary` 再刷一次(核销落库后必须再算,第 2 步覆盖不到核销行)。 + +> 原则:**同一笔预付的核销在链路上只存在一份** —— 预结算转正式 = 删源 PRE 行 + 落 FORMAL 行。 +> 轻量标记路径(`PreSettlementController /formal-settlement`):仅回写正式结算单号,无正式单实体,PRE 核销行原地保留,待真正建单时再转移。**前提:无在途预付申请**(存在 reviewing/approved 预付会拦截)。 + +**结果校验**:正式结算单「申请付款金额(含预付)」「已收已付合计(含预付)」「剩余可付」「收付款状态」四列直接反映预付与核销;同一预付在 PRE / FORMAL 侧无双份核销。 + +### 场景 A5:正式结算单同步金蝶(A102-2 财务应付单) + +**前端操作**:正式结算单列表 → **应付** tab → 勾选 1 条(按钮要求单选)→ 工具栏「**同步金蝶**」。 + +**后端动作**:`POST /formal-settlement/sync-kingdee` → 按结算方向路由: +- 应付 → **A102-2 财务应付单**(A102-4 幂等预检 / 反查,一张结算单一张应付单),金额 = **全额 settlementAmount**; +- 成功后回写 `kingdeeSyncStatus=synced` + 金蝶单据号/内码 → `lockBusiData` 锁单 → A902 附件上传。 + +**结果校验**:列表「金蝶同步状态」= 已同步、「金蝶锁定状态」= 已锁定 / 锁定中;附件未全部上传时页面追加提示"可重推补传"。 + +**审批时自动推送**:正式结算单「通过」后也会 AFTER_COMMIT 自动推;若推送失败,审批结果为 `false` + 具体原因以 warning 提示,审批本身不失败,走「同步金蝶」手动兜底或定时重推。 + +### 场景 A6:尾款付款(正式结算单之后) + +**前提**:正式结算单 `approved`、`payable`、同一合同、有剩余可付金额。 + +**前端操作**:正式结算单(应付)列表勾选 1 条或多条 → 工具栏「**付款申请**」→ 弹窗确认各单申请金额(默认 = 剩余可付,已扣核销)→「提交」。 + +**后端动作** +1. `saveDraft`:可挂多张同合同正式结算单,按可付金额比例分摊;`submit` 时 `validateNoTailPayment` 防重复尾款申请。 +2. **推送路由不同**:尾款走 **A103-1 应付下推**,前置对每张正式结算单做 A102-4 预检——**未在金蝶生成财务应付单则拦截**(提示先在正式结算单页同步金蝶);下推生成金蝶付款申请单,随后锁单 + 附件。 +3. 回写 `paid` → 生成 `FormalSettlementPayment(paymentType=final)` → `refreshPaymentSummary` 重算 `appliedPaymentAmount / paidAmount / remainingPayableAmount / paymentStatus`(unpaid → partial → paid)。 + +**金额口径(全系统唯一定义)** + +``` +settlementAmount = Σ 结算合计费用项(全额,不扣预付/核销) +appliedPaymentAmount = 尾款申请中(reviewing) + Σ来源预结算 advanceAppliedAmount + Σ核销行 +paidAmount = 尾款已付(approved 实付) + Σ来源预结算 advancePaidAmount + Σ核销行 +remainingPayableAmount = max(settlementAmount − paidAmount, 0) +paymentStatus = unpaid / partial / paid +``` + +--- + +## 二、应收侧:正式结算单推送开票 + +> 开票是**应收**方向:给客户开票 → 金蝶开票单。与应付侧的"收票"共用 `invoice_status` 字段,文案按方向区分(未开票 / 部分开票 / 已开票,未收票 / 部分收票 / 已收票)。 + +### 场景 B1:应收正式结算单同步金蝶(A302-2 财务应收单) + +**前端操作**:正式结算单列表切到「**应收**」tab → 勾选 1 条 → 工具栏「**同步金蝶**」。 + +**后端动作**:`POST /formal-settlement/sync-kingdee` → **A302-2 财务应收单**(防重键 `正式结算单号_结算明细单据号`)→ 回写单据号/内码 + `synced` → 锁单 + A902 附件。 +另有定时勾稽 `syncKingdeeArResult`(A302-4):对已同步/生成失败的应收结算单批量反查金蝶应收单并纠偏回填,可手动触发 `POST /formal-settlement/sync-kingdee-ar-result`。 + +**结果校验**:应收正式结算单 `kingdeeSyncStatus=已同步`,列表可见金蝶单据号。 + +> **开票前置**:正式结算开票要求**先有财务应收单**。开票申请推送时按 A302-4 逐结算单预检,未生成则拦截并提示"请先在正式结算单页面同步金蝶"。 + +### 场景 B2:开票申请创建与推送(A303-1 / A303-5) + +**前提**:存在 `approved`、未作废、**未收票/未开票(invoiceStatus=unreceived)** 的**应收**正式结算单。 + +**前端操作** +1. 菜单进「**开票申请**」列表 → 点「**新增**」。 +2. 「开票基本信息」区点「结算单」右侧「**选择**」→ 弹窗「选择应收正式结算单」(可按结算单号、项目、合同搜索)→ 勾选 → 「确定」。 + - 项目、开票单位(= 结算单收款方)自动带出;「**可开票金额**」= Σ 各结算单剩余可开票额;受票方开票信息按受票方客商档案带出。 +3. 填「发票类型」(电子专票时纳税人识别号/开户行/开户账号/注册地址必填)、受票方、邮箱、「开票内容」(发票张次与商品行)→ 系统自动算「本次开票金额」= 行合计。 +4. 「结算信息」表中填各结算单的**分摊开票金额**(≤ 该单剩余可开票金额)。 +5. 「开票明细」勾选明细 → 「保存」(草稿)→ 「提交」→ 列表「通过」。 + +**后端动作** + +| 步骤 | 接口/方法 | 规则 | +|---|---|---| +| 保存 | `POST /invoice-application/save` → `saveDraft` | ① 仅可选择**应收、审批通过、未作废、未收票**的正式结算单;② 结算单需兼容(同项目/开票方等);③ 分摊金额 ≤ 剩余可开票金额;④ 行合计 > 0;⑤ 受票方须有客商档案与有效开票信息;⑥ 电子专票四要素必填;⑦ 保存后刷新来源结算单开票状态,写变更记录 | +| 提交 | `/invoice-application/submit` | `draft/returned → reviewing`,写申请日期 | +| 审批 | `/invoice-application/approve` | `→ approved`,发布事件 → AFTER_COMMIT 推金蝶 | +| 推送 | `syncKingdee` → `doSyncKingdee` | ① **必须关联正式结算单**;② 逐结算单 A302-4 预检应收单已生成;③ A303-4 幂等预检(已生成的张次跳过并回填);④ 按发票张次拆分 `billno = 单号-张次` 逐张推 A303-1;⑤ 成功后锁单 + A902 附件 | +| 回写 | 定时 `syncKingdeeInvoiceResult`(A303-5,按 `serialNo=billno` 分批 ≤50)或手动 `POST /invoice-application/sync-kingdee-invoice-result` | 查到发票 → 落 `InvoiceApplicationInvoice` 发票记录并推进 `invoiced` 终态;同时刷新来源结算单 `invoice_status`(部分开票 → 已开票) | + +**结果校验**:开票申请 `kingdeeStatus=已同步 / 开票完成`,详情页可见发票结果(发票号、开票日期、价税合计、文件);来源正式结算单「发票状态」从未开票 → 部分开票 → 已开票。 + +**边界与限制(现状)** +- 已推送(`synced/invoiced`)**锁单不可改**;仅审批通过且未锁定时可「作废」。 +- 数电票已开出只能红冲(不走本系统作废)。 +- **预付/核销不影响开票**:开票按**全额 `settlementAmount`** 匹配,预付与核销只影响应付侧的已付/剩余可付。 + +### 场景 B3:预开票(预结算单直接开票)——**方案,尚未落地** + +按《2026-09-29-预结算开票(预开票)金蝶推送方案》,预结算(应收)可**只推 A303-1 开票申请单**、不校验应收单,新增 `invoice_source=pre` 与预结算关联表区分。 + +**当前代码状态**:`InvoiceApplicationServiceImpl.doSyncKingdee` 仍硬校验"必须关联正式结算单",创建入口也只允许选应收正式结算单 ⇒ **预开票目前推不出去**,需按上述方案改造后才能操作。本文不列为可执行场景。 + +--- + +## 三、状态机与字段口径 + +### 3.1 付款申请(PaymentApplication) + +| 字段 | 取值 | 说明 | +|---|---|---| +| `paymentType` | `progress_advance` / `project_advance` / `settlement_payment` | 进度预付 / 项目预付 / 尾款付款 | +| `approvalStatus` | `draft / reviewing / approved / returned / voided` | 提交后 reviewing;仅 approved 可作废 | +| `kingdeeStatus` | `unsynced / synced / paying / paid / closed / failed` | **paid 是项目预付可核销的前置** | +| `kingdeeLockStatus` | `locked / …` | 已锁定的单据禁止编辑/作废 | + +### 3.2 正式结算单(FormalSettlement) + +| 字段 | 取值 | +|---|---| +| `approvalStatus` | `draft / reviewing / approved / returned / voided` | +| `paymentStatus` | `unpaid / partial / paid` | +| `kingdeeSyncStatus` | `unsynced / synced / failed` | +| `invoiceStatus` | 应付=收票状态,应收=开票状态:`unreceived / partial / completed` | + +### 3.3 预付记录单据状态名(`src/utils/settlementStatus.js`,两个编辑器共用) + +| 值 | 名称 | +|---|---| +| `reviewing` | 审批中 | +| `approved` | 审批通过 | +| `paid` | 已付款 | +| `returned` | 已驳回 | +| `voided` | 已作废 | +| `draft` | 草稿 | + +### 3.4 正式结算单付款记录合并表(只读) + +一张表三类型,按「付款类型」列区分:**进度预付**(来源预结算 `PreSettlementAdvance` 台账)/ **项目预付核销**(核销行,金额=本次核销额)/ **结算尾款**。列名统一为「**申请/核销金额**」;创建时间统一取**底层付款申请创建时间**。 + +--- + +## 四、逆向与异常场景 + +| 场景 | 触发 | 系统行为 | +|---|---|---| +| 付款申请作废(仅 approved) | 付款申请列表「作废」(非金蝶锁定) | 金蝶锁单中禁止作废;汇票付款释放票据余额;**联动作废未付预付记录 + 删除其核销行并刷新受影响正式结算单汇总**(已付事实不回退) | +| 正式结算单作废 | 结算单列表「作废」 | 删本单 FORMAL 核销行,额度释放、详情无残留 | +| 金蝶删单回调(`voidByKingdee`) | 金蝶侧反审核删除 | 同上,删本单核销行 | +| 预结算单作废 | 预结算列表「作废」 | 存在关联预付申请需先作废预付;删 PRE 核销行 | +| 预付记录作废 | 预付台账 | 已付款的不能作废 | +| 金蝶反审核(解锁) | A103-2 连续两轮查不到已推单据 | 判定金蝶侧已删 → `unlockBill` 置 `failed`、清空金蝶单号/内码、**联动作废未付预付记录**(已付不回退);重推后重新建档。**此场景不删核销行**(预付申请仍 approved,核销继续计入),人工作废后才回落 | +| 推送失败 | A103-2/A102-2/A302-2/A303-1 报错 | 落 `failed` → `KingdeeAutoSyncRetryTask` 每 10 分钟自动重推;页面「同步金蝶」/「批量同步」手动兜底;审批接口把失败原因带回前端 warning,审批结果不受影响 | +| 结算调整(金额变更) | 「结算调整单」 | 调整落库前校验**核销合计 ≤ 新结算金额**,超出拒绝并提示先调减核销;落库后 `refreshPaymentSummary` 重算。调整重推:核销行**迁移到新单**(克隆 + 删原单行 + 批量刷新两单),原单作历史存档,收付口径以新单为准 | +| 全额核销 / 已结清 | `paymentStatus=paid` | `isSettlementCompleted` 拦截,不允许再发起结算调整(已确认"全额抵扣即结清") | + +--- + +## 五、接口与数据表索引 + +### 5.1 后端接口 + +| 模块 | 主要接口(`/blade-transport` 网关前缀) | +|---|---| +| 预结算单 | `GET /pre-settlement/list|detail`;`POST /pre-settlement/save|submit|approve|return|void`;`POST /pre-settlement/formal-settlement`(轻量标记转正式) | +| 付款申请 | `GET /payment-application/list|detail|reference-amount|project-advance-write-off-options`;`POST /payment-application/save|submit|approve|return|void|sync-kingdee|sync-kingdee-batch|sync-kingdee-result` | +| 正式结算单 | `GET /formal-settlement/list|detail|candidate-pre-settlements|contract-options`;`POST /formal-settlement/save|submit|approve|return|void|sync-kingdee|sync-kingdee-ar-result|retry-kingdee-bill-lock|apply-payment|apply-payments|claim-invoices` | +| 开票申请 | `GET /invoice-application/list|detail|settlement-candidates|settlement-details|receiver-information`;`POST /invoice-application/save|remove|submit|approve|return|void|sync-kingdee|sync-kingdee-invoice-result` | + +### 5.2 核心数据表 + +| 表/实体 | 作用 | +|---|---| +| `PreSettlement` | 预结算单;预付汇总 `advanceNo / advanceAppliedAmount / advancePaidAmount`、`formalSettlementNo` | +| `PreSettlementAdvance` | 预结算预付台账;`advanceNo`(=FKD 单号)、`billStatus`、`kingdeeAdvanceNo` | +| `PaymentApplication` | 付款申请(三种 paymentType) | +| `PaymentApplicationSettlement` | 付款申请 ↔ 结算单关系及分摊 | +| `PaymentApplicationRecord` | 付款记录(A103-4 回填,`voucherJson` 留档) | +| `FormalSettlement` | 正式结算单;`settlementAmount / appliedPaymentAmount / paidAmount / remainingPayableAmount / paymentStatus / kingdeeSyncStatus / invoiceStatus` | +| `FormalSettlementSource` | 来源预结算/明细快照,冗余预付汇总 | +| `FormalSettlementPayment` | 正式结算单尾款付款记录(`paymentType=final`) | +| `SettlementProjectAdvanceWriteOff` | 项目预付核销行(`settlementType`:PRE / FORMAL) | +| `InvoiceApplication` / `InvoiceApplicationSettlement` / `InvoiceApplicationSheet` / `InvoiceApplicationLine` / `InvoiceApplicationInvoice` | 开票申请主单 / 结算单关联与分摊 / 发票张次 / 商品行 / 金蝶回写发票 | + +### 5.3 前端页面 + +| 页面 | 路径 | +|---|---| +| 预结算单列表/表单 | `src/views/settlement/pre-settlement.vue`、`pre-settlement-form.vue`、`components/pre-settlement-editor.vue` | +| 正式结算单列表/表单 | `src/views/settlement/formal-settlement.vue`、`formal-settlement-form.vue`、`components/formal-settlement-editor.vue` | +| 项目预付核销组件 | `src/views/settlement/components/project-advance-write-off.vue` | +| 付款申请列表/表单 | `src/views/payment/payment-application.vue`、`payment-application-form.vue` | +| 开票申请列表/表单 | `src/views/payment/invoice-application.vue`、`invoice-application-form.vue` | +| 状态名公共工具 | `src/utils/settlementStatus.js`、`src/option/settlement/formalSettlementSearch.js` | + +--- + +## 六、已知口径与风险 + +1. **尾款默认可付额** = 结算金额 − 累计尾款申请 − 核销,**不扣进度预付已付部分**(既有口径)。进度预付占大头时默认额可能偏大,由审批环节把关。 +2. **正式结算单 `paidAmount` 现含核销**,下游消费该字段的报表/对账单口径随之变化(对账已确认接受)。 +3. **金蝶侧预付抵扣字段**(`y_billtype / y_billid / y_settleamt`)本期**不传**,金蝶报表与本系统"已付含核销"存在时间差(待业务确认后迭代)。 +4. **预开票未落地**:预结算直接开票需先按方案改造 `doSyncKingdee` 门禁与数据模型。 +5. 存量"approved 未 paid"的核销行未做数据修正(`replaceWriteOffs` 只约束新保存),如需清洗另出脚本。 +6. 应收侧预收/收款认领(ReceiptClaim)与收票流程未在本文展开。 From ed6346e7a10aca99f114ab2da6d248b2bdabd387 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Sun, 18 Oct 2026 15:50:07 +0800 Subject: [PATCH 068/108] =?UTF-8?q?docs:=20=E6=96=B0=E5=A2=9E=E9=A2=84?= =?UTF-8?q?=E4=BB=98=E8=BD=AC=E6=AD=A3=E5=BC=8F=E7=BB=93=E7=AE=97=E5=8D=95?= =?UTF-8?q?=E4=B8=8E=E9=A1=B9=E7=9B=AE=E9=A2=84=E4=BB=98=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E6=89=8B=E5=86=8C=EF=BC=88=E7=BA=AF=E6=93=8D=E4=BD=9C=C2=B7?= =?UTF-8?q?=E5=B8=A6=E7=8A=B6=E6=80=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...¬正式结算单与项目预付操作手册(纯操作·带状态).md | 279 ++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 doc/docs/业务流程/2026-10-18-预付转正式结算单与项目预付操作手册(纯操作·带状态).md diff --git a/doc/docs/业务流程/2026-10-18-预付转正式结算单与项目预付操作手册(纯操作·带状态).md b/doc/docs/业务流程/2026-10-18-预付转正式结算单与项目预付操作手册(纯操作·带状态).md new file mode 100644 index 00000000..2dbb4807 --- /dev/null +++ b/doc/docs/业务流程/2026-10-18-预付转正式结算单与项目预付操作手册(纯操作·带状态).md @@ -0,0 +1,279 @@ +# 预付转正式结算单与项目预付操作手册(纯操作·带状态) + +> 整理日期:2026-10-18 +> 定位:**给操作者和实施/培训用的纯操作手册**。只讲"在哪个页面、点哪个按钮、填什么、会看到什么状态",不含接口、表名、代码。 +> 配套逻辑说明见同目录《2026-10-18-预付转正式结算单与项目预付操作流程说明(分场景·前端后端).md》。 + +--- + +## 一、先认状态(每个场景都对着这几张表看) + +### 1.1 单据审批状态(所有单据通用) + +``` +新建/保存 ──→ 草稿 ──提交──→ 审批中 ──通过──→ 审批通过 + ↑ │ + │ 驳回 + └── 已驳回 ←────┘ (改完再提交) + +草稿 / 审批中 / 审批通过 ──(可作废时)──→ 已作废(终态) +``` + +| 状态 | 能做什么 | +|---|---| +| 草稿 | 编辑 / 删除 / 提交 | +| 已驳回 | 编辑(保存后回到草稿)/ 提交 | +| 审批中 | 通过 / 驳回(审批人操作) | +| 审批通过 | 看后续:同步金蝶、付款申请、开票申请、作废(未锁定时) | +| 已作废 | 终态,只读 | + +### 1.2 付款申请的金蝶状态 + +``` +未同步 ──推送──→ 已同步 ──→ 付款中 ──→ 已付款 + │ │ + └────────── 生成失败(可重推) └──→ 已关闭 +``` + +- **已付款**:钱已付出。**项目预付只有到"已付款"才能在结算单上核销**。 +- **生成失败**:系统每 10 分钟自动重推;也可在列表勾选后点「批量同步」手动重推。 + +### 1.3 正式结算单上的四个状态 + +| 状态位 | 取值 | 看什么 | +|---|---|---| +| 审批状态 | 草稿 / 审批中 / 审批通过 / 已驳回 / 已作废 | 单据走到哪一步 | +| 金蝶同步状态 | 未同步 / 已同步 / 同步失败 | 有没有在金蝶生成应付单(应付)/应收单(应收) | +| 收付款状态 | 未收/付款 / 部分收/付款 / 已收/付款 | 收付进度(**已含预付、核销**) | +| 发票状态 | 应付=未收票/部分收票/已收票;应收=未开票/部分开票/已开票 | 票开/收到哪一步 | + +### 1.4 金蝶锁定状态 + +| 显示 | 含义 | 影响 | +|---|---|---| +| 锁定中 | 正在向金蝶加锁 | 稍等 | +| 已锁定 | 金蝶已锁 | **不可编辑、不可作废** | +| 锁定失败·补偿中 / 已达上限 | 加锁未成功 | 定时自动补偿;必要时联系管理员 | + +### 1.5 开票申请的金蝶状态 + +``` +未同步 ──推送──→ 已同步 ──金蝶回写──→ 开票完成 + │ + └── 生成失败(可重推) +``` + +--- + +## 二、应付侧操作(钱付出去) + +### 场景 1:预结算单直接发起「进度预付」 + +**前提**:预结算单是**应付**、状态**审批通过**、还没转正式结算。 + +1. 进「**预结算单**」列表,勾选预结算单(**同一合同**可多选)。 +2. 点工具栏「**预付申请**」。 +3. 自动跳到「付款申请」页面(类型已固定为进度预付):结算单只读,"结算金额/可付款金额"自动带出。 +4. 填「申请付款金额」(不超过可付款金额)、「付款方式」→「保存」。 +5. 回到「付款申请」列表 → 找到该单 →「**提交**」。 +6. 审批人点「**通过**」。 + +**状态变化**:付款申请 `草稿 → 审批中 → 审批通过`;随后系统自动推金蝶,金蝶状态 `未同步 → 已同步 → 已付款`。 +**同时发生**:预结算单上出现「申请预付金额 / 已预付金额」;预结算单仍然可以继续「尾款结算」。 + +> 若提交/通过后看到黄色警告写着金蝶推送失败:审批结果**不受影响**,稍后自动重推,或到付款申请列表点「批量同步」。 + +--- + +### 场景 2:发起「项目预付」(不挂结算单) + +**前提**:项目已建、**承运商合同**已建;金额不超过项目额度与客户额度。 + +1. 进「**付款申请**」列表 → 点工具栏「**预付申请**」(注意:这个入口是从付款申请页进的,和场景 1 从预结算单进不同)。 +2. 页面类型已固定为项目预付,填写: + - **所属项目**(必选) + - **合同名称**(必选,**只能选承运商合同**,选了项目后才能选) + - 申请付款金额、付款方式、付款方/收款方等 + - 本类型不挂结算单,不显示发票信息与付款记录 +3. 「保存」→ 列表「提交」→ 审批人「通过」。 + +**会被拦截的情况**: +- "申请付款金额不能超过项目额度"(项目额度 = 项目额度 + 有效期内的临时额度) +- "申请付款金额不能超过客户额度" + +**状态变化**:同场景 1。**务必等到金蝶状态「已付款」**,否则下一步核销做不了。 + +--- + +### 场景 3:结算单上「项目预付核销」 + +**前提**:有一笔**已付款**的项目预付(金蝶状态=已付款),且与本次结算单**同一合同**,还有剩余可核销金额。 + +1. 打开结算单(**预结算单**或**正式结算单**都行)的编辑页。 +2. 找到「**项目预付核销**」分区 → 点「**选择**」。 +3. 弹窗「选择项目预付单」里勾选要抵扣的预付单(已选的会灰掉,不能重复选)→「确定」。 +4. 在「本次核销金额」列:默认已按"可核销金额"填满,可以改小。 +5. 「保存」。 + +**会被拦截的情况(提示文案)**: +- "预付单 XXX 本次核销金额必须大于 0" +- "本次核销金额不能超过可核销金额" +- "项目预付核销合计不能超过结算单金额" +- "仅可选择审核通过的项目预付单" +- "预付单 XXX 尚未付款,项目预付须金蝶付款完成后方可核销" +- "同一项目预付单不可重复核销" +- "项目预付单与结算单合同不一致" + +**状态变化**:结算单「**已付金额(含预付、核销)**」立刻增大,「剩余未付金额」减小;列表上"申请付款金额(含预付)/已收已付合计(含预付)/剩余可付/收付款状态"同步变化。 + +--- + +### 场景 4:预结算单转「正式结算单」(容纳预付) + +**前提**:预结算单**审批通过**,且**同一项目**。 + +1. 进「**预结算单**」列表,勾选(可多选,须同项目)→ 点工具栏「**尾款结算**」。 +2. 系统跳到「正式结算单」新增页,把所选预结算单的明细、费用项合并进来。 +3. 检查三处金额(**应付结算单才有**): + - **结算合计(原金额)**:费用合计,**始终全额**,不会因为预付/核销而变小; + - **已付金额(含预付、核销)**:把来源预结算的已付预付 + 本次核销都算进去; + - **剩余未付金额** = 结算合计 − 已付。 +4. 「项目预付核销」区会**自动带出来源预结算上已核销的行**,可增/删/改金额。 +5. 「保存」(草稿)→「提交」→ 审批人「通过」。 + +**状态变化**:正式结算单 `草稿 → 审批中 → 审批通过`;预结算单会被标记为已转正式结算(来源被锁定)。 +**注意**:如果预结算单上还有**审批中或已审批但没结束的预付申请**,走"轻量转正式"会被拦住,需要先把预付处理完。 + +--- + +### 场景 5:正式结算单「同步金蝶」 + +**前提**:结算单**审批通过**。 + +1. 进「**正式结算单**」列表,切到「**应付**」页签。 +2. 勾选 **1 条**(按钮只允许选一条)→ 点工具栏「**同步金蝶**」。 +3. 成功后会提示"同步成功,金蝶单据号:XXX";列表「金蝶同步状态」变为**已同步**,「金蝶锁定状态」变为**已锁定/锁定中**。 + +**生成失败怎么办**:列表金蝶状态显示"同步失败",重新点「同步金蝶」即可。 +**提示"该单附件未全部上传,可重推补传"**:单据已同步成功,附件稍后重推会自动补传。 + +> 审批通过时系统也会自动推一次;若失败,只弹黄色警告,审批照样通过,按上面手动补推。 + +--- + +### 场景 6:正式结算单「付款申请」(付尾款) + +**前提**:正式结算单是**应付**、**审批通过**、**同一合同**、还有剩余可付金额。 + +1. 「正式结算单」列表(应付)→ 勾选 1 条或多条(同合同)→ 工具栏「**付款申请**」。 +2. 弹窗里确认各单的「申请付款金额」(默认已填=剩余可申请金额,**已扣掉核销**)→「提交」。 +3. 到「付款申请」列表「提交」→ 审批人「通过」。 + +**会被拦截的情况**: +- "仅审批通过的应付正式结算单允许发起付款申请" +- "付款申请必须选择同一个合同的正式结算单" +- 提示需要先在正式结算单页面同步金蝶(说明该单在金蝶还没有应付单,尾款是从应付单下推的,**必须先做场景 5**) + +**状态变化**:正式结算单「收付款状态」`未付款 → 部分付款 → 已付款`,金额按付款结果自动重算。 +**金额错了怎么办**:正式结算单**审批通过后不能直接改金额**,走「**结算调整单**」(见《2026-10-17-结算调整单操作流程说明(分场景).md》)。 + +--- + +## 三、应收侧操作(票开出去) + +### 场景 7:应收正式结算单「同步金蝶」 + +1. 「正式结算单」列表切到「**应收**」页签 → 勾选 1 条 → 工具栏「**同步金蝶**」。 +2. 列表「金蝶同步状态」变**已同步**,可见金蝶单据号。 + +**必须做这一步**:正式结算单开票要求先有金蝶应收单,没做这步,开票申请推不出去。 + +--- + +### 场景 8:创建并推送「开票申请」 + +**前提**:有一张**应收**正式结算单,**审批通过**、未作废、发票状态还是「**未开票**」,且已完成场景 7。 + +1. 进「**开票申请**」列表 → 点「**新增**」。 +2. 「开票基本信息」里点「结算单」右侧的「**选择**」→ 弹窗「选择应收正式结算单」→ 搜索并勾选 →「确定」。 + - 「项目」「开票单位」自动带出;「**可开票金额**」= 所选结算单剩余可开票额之和。 + - 受票方开票信息按客商档案自动带出。 +3. 填「发票类型」等;选**电子专票**时,纳税人识别号、开户行、开户账号、注册地址**必填**。 +4. 填「开票内容」(发票张次 + 商品行),系统自动算「**本次开票金额**」。 +5. 在「结算信息」表里给每张结算单填「**分摊开票金额**」(不超过该单剩余可开票金额)。 +6. 「开票明细」里勾选明细 →「保存」→「提交」→ 审批人「通过」。 + +**会被拦截的情况**: +- "只能选择审批通过、未作废、未收票的应收正式结算单" +- "结算单 XXX 的分摊金额超过剩余可开票金额" +- "本次开票金额必须大于 0" +- "请选择受票方有效的开票信息" +- "未找到正式结算单受票方对应的客商档案" + +**状态变化** +| 对象 | 状态变化 | +|---|---| +| 开票申请 | 审批 `草稿 → 审批中 → 审批通过`;金蝶 `未同步 → 已同步 → 开票完成` | +| 来源正式结算单 | 发票状态 `未开票 → 部分开票 → 已开票`(按分摊与回写自动算) | + +**看发票结果**:开票申请详情页的发票信息里能看到发票号、开票日期、价税合计、发票文件。金蝶回写是定时的,没出来可以先等,或在开票申请列表点「**批量同步**」推一把。 + +**限制**:开票申请一旦推到金蝶(已同步/开票完成)就**锁定不能改**;数电票已经开出来的只能走红冲,不能在本系统作废。**预付和核销不影响开票金额**,开票始终按结算单的**全额**来匹配。 + +--- + +## 四、作废与撤销:能做什么、状态怎么变 + +| 我想…… | 前提 | 操作位置 | 状态变化 | +|---|---|---|---| +| 作废付款申请 | 状态=审批通过,且**未被金蝶锁定** | 付款申请列表「作废」 | 单据→已作废;**未付的预付记录联动作废,相关核销行被删除**,受影响结算单的已付金额回落(已付出去的钱不会回退) | +| 作废正式结算单 | 未审批通过,或审批通过但**未锁定** | 正式结算单列表「作废」 | 单据→已作废;本单的核销行被删除,可核销额度释放 | +| 作废预结算单 | 无关联预付申请(有则先作废预付) | 预结算单列表「作废」 | 单据→已作废;预结算上的核销行被删除 | +| 作废开票申请 | 状态=审批通过,且**未锁定**(即还没推金蝶/还没开票) | 开票申请列表「作废」 | 单据→已作废 | +| 删除 | 状态=草稿 | 各列表「删除」 | 草稿被删除 | +| 金蝶侧反审核删单 | — | 金蝶操作后由系统处理 | 付款申请金蝶状态→生成失败,金蝶单号被清空;**未付的预付记录联动作废**;人工核实后重推,重推成功会重新建档;**已核销的行不会自动删**,需要人工作废该预付款才回落 | + +> 一句话记住:**预付一旦真付出去(已付款),作废不会把钱退回**,只会把台账和核销额度清掉。 + +--- + +## 五、操作者常见疑问 + +| 问题 | 答案 | +|---|---| +| 为什么选不到某张预结算单发起预付? | 需要**应付**、**审批通过**、**未转正式结算**、且所选单据**同一合同**。 | +| 为什么核销弹窗里找不到那笔项目预付? | 它必须已**金蝶付款完成(已付款)**,且与结算单**同一合同**、还有剩余可核销额。 | +| 为什么新增开票申请时选不到某张应收结算单? | 需要**审批通过、未作废、发票状态=未开票**;已被别的开票申请占用的会扣掉可开票额。 | +| 开票提示要先去同步金蝶? | 正式结算开票前,必须先在该应收结算单上点「同步金蝶」生成金蝶应收单。 | +| 付款申请审批通过了,但金蝶那边没有单? | 看金蝶状态:若"生成失败",点「批量同步」重推或等自动重推。 | +| 结算单已付金额比我算的多/少? | 已付金额**含**进度预付、项目预付核销;核销金额同时计入"已付"和"剩余可付"的计算,属正常口径。 | +| 结算单金额写错了怎么改? | 审批通过后不能直接改,走「结算调整单」。 | +| 单据上的「作废」是灰的? | 可能已被金蝶锁定,或状态不满足(只有审批通过且未锁定才能作废)。 | + +--- + +## 六、一眼看懂整个流程(操作版) + +``` +【应付·进度预付】 +预结算单(审批通过) →「预付申请」→ 付款申请(提交→通过) → 金蝶付款(已付款) + └ 预结算单出现"已预付金额" + +【应付·项目预付】 +付款申请页「预付申请」→ 选项目+承运商合同 → 提交→通过 → 金蝶付款(已付款) + +【预结算 → 正式结算(容纳预付)】 +预结算单(审批通过·同项目) →「尾款结算」→ 正式结算单 + 结算合计(全额) / 已付(含预付、核销) / 剩余未付 + 项目预付核销自动带入 → 保存 → 提交 → 通过 + └ 「同步金蝶」(应付单已同步) + +【应付·尾款】 +正式结算单(应付·审批通过) →「付款申请」→ 付款申请 → 金蝶应付下推 → 已付款 + └ 结算单收付款状态:未付款 → 部分付款 → 已付款 + +【应收·开票】 +应收正式结算单(审批通过·未开票) →「同步金蝶」(应收单已同步) + →「开票申请」新增 → 选结算单 → 填开票内容与分摊 → 提交 → 通过 + → 金蝶开票(已同步 → 开票完成) → 结算单发票状态:未开票 → 部分开票 → 已开票 +``` From ecb15ca7668d36903ca03855a7b5b74a2ece4a23 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Sun, 18 Oct 2026 15:52:07 +0800 Subject: [PATCH 069/108] =?UTF-8?q?:bug:=20=E9=85=8D=E8=BD=BD=E5=87=86?= =?UTF-8?q?=E5=85=A5=E7=9A=84=E6=9C=AC=E5=8D=95=E8=B1=81=E5=85=8D=E5=8F=AA?= =?UTF-8?q?=E5=AF=B9=E6=AD=A3=E5=BC=8F=E5=8D=95=E7=94=9F=E6=95=88=EF=BC=8C?= =?UTF-8?q?=E5=90=88=E5=90=8C=E4=B8=8D=E5=B1=9E=E4=BA=8E=E6=9C=AC=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E7=82=B9=E5=90=8D=E8=BF=90=E5=8D=95=E4=B8=8E=E5=85=B6?= =?UTF-8?q?=E5=8E=9F=E5=9B=A0=E5=8D=95=E4=B8=80=E6=9D=A5=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/LoadingManageServiceImpl.java | 106 +++++++++--------- .../support/LoadableAdmissionSupport.java | 52 ++++++--- .../impl/LoadingManageAdmissionTest.java | 69 +++++++++++- .../LoadingManageChildStatusWriteTest.java | 11 +- .../support/LoadableAdmissionSupportTest.java | 39 +++++-- 5 files changed, 188 insertions(+), 89 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java index 96dd0fcb..ed88df34 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java @@ -155,9 +155,12 @@ public class LoadingManageServiceImpl extends BaseServiceImpl oldWaybillIds = new ArrayList<>(); + boolean ownChildrenAdmitted = false; if (Func.isNotEmpty(loadingManage.getId())) { LoadingManage oldRecord = loadEditable(loadingManage.getId(), true); oldWaybillIds = waybillIds(oldRecord.getWaybillIdsJson()); + // 草稿只预占运单号、未过判据,草稿转正式不得沿用本单豁免 + ownChildrenAdmitted = !STATUS_DRAFT.equals(oldRecord.getBusinessStatus()); loadingManage.setLoadingNo(oldRecord.getLoadingNo()); loadingManage.setDeptId(oldRecord.getDeptId()); loadingManage.setDeptName(oldRecord.getDeptName()); @@ -168,10 +171,10 @@ public class LoadingManageServiceImpl extends BaseServiceImpl waybills = validateWaybillsAvailable(loadingManage, waybillIds(loadingManage.getWaybillIdsJson()), ownChildrenAdmitted); + validateCarrierContract(loadingManage, true, waybills); boolean result = saveOrUpdate(loadingManage); - writeWaybillsForDriver(loadingManage, oldWaybillIds); + writeWaybillsForDriver(loadingManage, oldWaybillIds, waybills); return result; } @@ -417,10 +420,10 @@ public class LoadingManageServiceImpl extends BaseServiceImpl waybills = validateWaybillsAvailable(loadingManage, waybillIds(loadingManage.getWaybillIdsJson()), true); + validateCarrierContract(loadingManage, true, waybills); boolean result = updateById(loadingManage); - writeWaybillsForDriver(loadingManage, oldWaybillIds); + writeWaybillsForDriver(loadingManage, oldWaybillIds, waybills); return result; } @@ -742,57 +745,46 @@ public class LoadingManageServiceImpl extends BaseServiceImpl waybillIds) { + private List validateWaybillsAvailable(LoadingManage loadingManage, List waybillIds, boolean ownChildrenAdmitted) { if (Func.isEmpty(waybillIds)) { throw new ServiceException("请选择待配载运单"); } - List waybills = waybillMapper.selectList(Wrappers.lambdaQuery() - .eq(Waybill::getIsDeleted, 0) - .in(Waybill::getId, waybillIds)); + List waybills = loadWaybills(waybillIds); if (waybills.size() != waybillIds.size()) { throw new ServiceException("待配载运单不存在或已删除"); } String ownLoadingNo = loadingManage.getLoadingNo(); for (Waybill waybill : waybills) { TransportBusinessSupport.assertCurrentDept(waybill.getDeptId(), "运单管理"); - // 本单既有子运单在先前提交时已过准入判据,重新提交/编辑不再判: - // 不需接单项目的票提交后即为进行中,重复判会让本单再也保存不了 - if (LoadableAdmissionSupport.isOwnChild(waybill, ownLoadingNo)) { + if (ownChildrenAdmitted && LoadableAdmissionSupport.isOwnChild(waybill, ownLoadingNo)) { continue; } - if (!LoadableAdmissionSupport.isLoadable(waybill, ownLoadingNo)) { - throw new ServiceException(unloadableReason(waybill)); + LoadableAdmissionSupport.UnloadableReason reason = + LoadableAdmissionSupport.unloadableReason(waybill, ownLoadingNo); + if (reason != null) { + throw new ServiceException(unloadableMessage(waybill, reason)); } } validateSameProject(waybills); + return waybills; } /** - * 逐张准入失败的可读原因。判据本身在可配载支持类里,这里只把命中的那一条翻成文案。 - * - * @param waybill 未通过准入判据的运单 - * @return 含运单号与原因的提示 + * 逐张准入失败的可读原因:判据只给原因枚举,运单号与文案在这里拼。 */ - private String unloadableReason(Waybill waybill) { + private String unloadableMessage(Waybill waybill, LoadableAdmissionSupport.UnloadableReason reason) { String prefix = "运单" + waybill.getWaybillNo(); - if (Func.isNotEmpty(waybill.getLoadingNo())) { - return prefix + "已关联其他配载单"; - } - if (!LoadableAdmissionSupport.isRoadTransport(waybill.getTransportType())) { - return prefix + "运输方式不是公路,不可配载"; - } - if (!LoadableAdmissionSupport.LOADABLE_BUSINESS_STATUS.equals(waybill.getBusinessStatus())) { - return prefix + "当前状态不是待执行,不可配载"; - } - return prefix + "不可配载"; + return prefix + switch (reason) { + case NOT_ROAD -> "运输方式不是公路,不可配载"; + case NOT_PENDING -> "当前状态不是待执行,不可配载"; + case ON_OTHER_MANIFEST -> "已关联其他配载单"; + }; } /** * 一张配载单的全部子运单必须属于同一个项目,否则点名与其它子运单项目不同的运单。 *

* 查询条件表达不了跨行约束,只能整批取回后在内存里比。 - * - * @param waybills 本次要写入的子运单全集 */ private void validateSameProject(List waybills) { Long projectId = waybills.get(0).getProjectId(); @@ -807,6 +799,10 @@ public class LoadingManageServiceImpl extends BaseServiceImpl waybills) { if (!Objects.equals(loadingManage.getCarrierType(), "承运商")) { loadingManage.setCarrierContractId(null); if (!Objects.equals(loadingManage.getCarrierType(), CARRIER_SELF)) { @@ -825,9 +821,19 @@ public class LoadingManageServiceImpl extends BaseServiceImpl childWaybills = waybills == null + ? loadWaybills(waybillIds(loadingManage.getWaybillIdsJson())) + : waybills; // 合同自带项目与乙方:合同的项目不属于本单子运单的项目,等于换了项目却没换乙方 - if (!projectIdsByWaybills(loadingManage).contains(carrierContract.getProjectId())) { - throw new ServiceException("所选承运商合同不属于本项目"); + if (!projectIdsOf(childWaybills).contains(carrierContract.getProjectId())) { + String waybillNos = childWaybills.stream() + .filter(waybill -> !Objects.equals(waybill.getProjectId(), carrierContract.getProjectId())) + .map(Waybill::getWaybillNo) + .filter(Func::isNotEmpty) + .collect(Collectors.joining("、")); + throw new ServiceException(Func.isEmpty(waybillNos) + ? "所选承运商合同不属于本项目" + : "所选承运商合同不属于运单" + waybillNos + "的项目"); } loadingManage.setCarrierName(carrierContract.getPartyB()); } @@ -845,16 +851,17 @@ public class LoadingManageServiceImpl extends BaseServiceImpl projectIdsByWaybills(LoadingManage loadingManage) { - List waybillIds = waybillIds(loadingManage.getWaybillIdsJson()); + private List loadWaybills(List waybillIds) { if (Func.isEmpty(waybillIds)) { return List.of(); } return waybillMapper.selectList(Wrappers.lambdaQuery() - .select(Waybill::getProjectId) .eq(Waybill::getIsDeleted, 0) - .in(Waybill::getId, waybillIds)) - .stream().map(Waybill::getProjectId).filter(Objects::nonNull).distinct().toList(); + .in(Waybill::getId, waybillIds)); + } + + private List projectIdsOf(List waybills) { + return waybills.stream().map(Waybill::getProjectId).filter(Objects::nonNull).distinct().toList(); } private List availableCarrierContracts(List projectIds) { @@ -870,9 +877,7 @@ public class LoadingManageServiceImpl extends BaseServiceImpl * 不按项目过滤,才能把「合同不可用」与「合同不属于本项目」分成两种提示。 - * - * @param contractId 合同主键 - * @return 可用的合同;不存在或不可用返回 null + * 取不到可用的合同返回 null。 */ private ContractManage availableCarrierContractById(Long contractId) { if (Func.isEmpty(contractId)) { @@ -899,9 +904,8 @@ public class LoadingManageServiceImpl extends BaseServiceImpl * 推导按「接单事实已清、尚未接单」的口径取值,故清接单事实放在状态写入之后,两者口径一致。 */ - private void writeWaybillsForDriver(LoadingManage loadingManage, List oldWaybillIds) { - syncAssociatedWaybills(loadingManage, - deriveWaybillBusinessStatuses(waybillIds(loadingManage.getWaybillIdsJson())), oldWaybillIds); + private void writeWaybillsForDriver(LoadingManage loadingManage, List oldWaybillIds, List waybills) { + syncAssociatedWaybills(loadingManage, deriveWaybillBusinessStatuses(waybills), oldWaybillIds); // 接单事实不清,被拒的票对新司机不可见——司机端一切入口都排除已拒绝的运单 clearAssociatedWaybillAcceptRecords(loadingManage); } @@ -913,16 +917,11 @@ public class LoadingManageServiceImpl extends BaseServiceImpl deriveWaybillBusinessStatuses(List waybillIds) { + private Map deriveWaybillBusinessStatuses(List waybills) { Map businessStatusByWaybillId = new LinkedHashMap<>(); - if (Func.isEmpty(waybillIds)) { + if (Func.isEmpty(waybills)) { return businessStatusByWaybillId; } - List waybills = waybillMapper.selectList(Wrappers.lambdaQuery() - .select(Waybill::getId, Waybill::getProjectId, Waybill::getProcessJson) - .eq(Waybill::getIsDeleted, 0) - .in(Waybill::getId, waybillIds)); - // 一次查全所有项目的生效配置,不逐张回查 Map liveConfigByProject = LiveProcessConfigSupport.byProjects(processConfigService, waybills.stream().map(Waybill::getProjectId).toList()); for (Waybill waybill : waybills) { @@ -943,9 +942,6 @@ public class LoadingManageServiceImpl extends BaseServiceImpl statusByWaybillId, List oldWaybillIds) { List newWaybillIds = waybillIds(loadingManage.getWaybillIdsJson()); if (Func.isNotEmpty(oldWaybillIds)) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/LoadableAdmissionSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/LoadableAdmissionSupport.java index 66f5d834..eb7d9a43 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/LoadableAdmissionSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/LoadableAdmissionSupport.java @@ -55,6 +55,23 @@ public final class LoadableAdmissionSupport { private LoadableAdmissionSupport() { } + /** + * 不可配载的原因,按判定顺序排列:公路 → 待执行 → 未关联其它配载单。 + *

+ * 判据只产出原因,中文文案由调用方映射——面向用户的措辞不进这一层。 + */ + public enum UnloadableReason { + + /** 运输方式不是公路 */ + NOT_ROAD, + + /** 业务状态不是待执行 */ + NOT_PENDING, + + /** 已关联其它配载单 */ + ON_OTHER_MANIFEST + } + /** * 是否公路运输。未知或为空一律按非公路处理。 * @@ -77,16 +94,6 @@ public final class LoadableAdmissionSupport { return false; } - /** - * 该运单此刻是否可被配载:要求未关联任何配载单。 - * - * @param waybill 运单 - * @return true-可配载 - */ - public static boolean isLoadable(Waybill waybill) { - return isLoadable(waybill, null); - } - /** * 该运单此刻是否可被配载。 *

@@ -98,15 +105,28 @@ public final class LoadableAdmissionSupport { * @return true-可配载 */ public static boolean isLoadable(Waybill waybill, String ownLoadingNo) { - if (waybill == null) { - return false; + return unloadableReason(waybill, ownLoadingNo) == null; + } + + /** + * 该运单此刻不可配载的原因。 + * + * @param waybill 运单 + * @param ownLoadingNo 本配载单号;新建时传 null + * @return 命中的第一条原因;可配载时返回 null + */ + public static UnloadableReason unloadableReason(Waybill waybill, String ownLoadingNo) { + if (waybill == null || !isRoadTransport(waybill.getTransportType())) { + return UnloadableReason.NOT_ROAD; } - if (!isRoadTransport(waybill.getTransportType()) - || !LOADABLE_BUSINESS_STATUS.equals(text(waybill.getBusinessStatus()))) { - return false; + if (!LOADABLE_BUSINESS_STATUS.equals(text(waybill.getBusinessStatus()))) { + return UnloadableReason.NOT_PENDING; } String loadingNo = text(waybill.getLoadingNo()); - return loadingNo.isEmpty() || loadingNo.equals(text(ownLoadingNo)); + if (loadingNo.isEmpty() || loadingNo.equals(text(ownLoadingNo))) { + return null; + } + return UnloadableReason.ON_OTHER_MANIFEST; } /** diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageAdmissionTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageAdmissionTest.java index aceee927..a8eccbcd 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageAdmissionTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageAdmissionTest.java @@ -39,6 +39,7 @@ import static org.mockito.Mockito.when; *

* 逐张准入用的是读侧候选列表的同一份判据;已挂在本单号上的子运单在先前提交时已过判据, * 重新提交不再重复判——不需接单项目的票提交后即为进行中,重复判会让本单再也保存不了。 + * 暂存只预占运单号、未过判据,故草稿转正式时本单号不构成豁免。 *

* 跨行判据(同项目、合同属于本项目)只对提交/编辑/重新派单生效,暂存草稿不受影响。 */ @@ -197,6 +198,60 @@ class LoadingManageAdmissionTest { assertDoesNotThrow(() -> service.reassign(payload)); } + @Test + @DisplayName("交叉判据同时命中时按 公路 → 待执行 → 已关联其它配载单 取第一条") + void firstFailingConditionWins() { + stubWaybills(waybill(1L, WAYBILL_NO_A, "railway", "pending", "PZ20261018-999", PROJECT_A)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.submit(submitPayload(List.of(1L)))); + + assertEquals("运单" + WAYBILL_NO_A + "运输方式不是公路,不可配载", exception.getMessage()); + } + + // ------------------------------------------------------------ 草稿预占 + + @Test + @DisplayName("暂存只预占运单号:草稿里那张进行中的票在草稿转正式时被拒") + void draftReservedRunningWaybillRejected() { + stubManifest("draft"); + stubWaybills(waybill(1L, WAYBILL_NO_A, "road", "running", OWN_LOADING_NO, PROJECT_A)); + stubContract(CONTRACT_ID, PROJECT_A); + + assertDoesNotThrow(() -> service.saveDraft(existingPayload(List.of(1L)))); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.submit(existingPayload(List.of(1L)))); + + assertEquals("运单" + WAYBILL_NO_A + "当前状态不是待执行,不可配载", exception.getMessage()); + } + + @Test + @DisplayName("暂存只预占运单号:草稿里那张非公路的票在草稿转正式时被拒") + void draftReservedNonRoadWaybillRejected() { + stubManifest("draft"); + stubWaybills(waybill(1L, WAYBILL_NO_A, "railway", "pending", OWN_LOADING_NO, PROJECT_A)); + stubContract(CONTRACT_ID, PROJECT_A); + + assertDoesNotThrow(() -> service.saveDraft(existingPayload(List.of(1L)))); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.submit(existingPayload(List.of(1L)))); + + assertEquals("运单" + WAYBILL_NO_A + "运输方式不是公路,不可配载", exception.getMessage()); + } + + @Test + @DisplayName("暂存预占的票本身合格时,草稿转正式照常通过") + void draftReservationStaysAdmissibleOnSubmit() { + stubManifest("draft"); + stubWaybills(waybill(1L, WAYBILL_NO_A, "road", "pending", OWN_LOADING_NO, PROJECT_A)); + stubContract(CONTRACT_ID, PROJECT_A); + + assertDoesNotThrow(() -> service.saveDraft(existingPayload(List.of(1L)))); + assertDoesNotThrow(() -> service.submit(existingPayload(List.of(1L)))); + } + // ------------------------------------------------------------ 跨行判据 @Test @@ -222,7 +277,7 @@ class LoadingManageAdmissionTest { ServiceException exception = assertThrows(ServiceException.class, () -> service.submit(submitPayload(List.of(1L)))); - assertEquals("所选承运商合同不属于本项目", exception.getMessage()); + assertEquals("所选承运商合同不属于运单" + WAYBILL_NO_A + "的项目", exception.getMessage()); } @Test @@ -296,16 +351,26 @@ class LoadingManageAdmissionTest { return loadingManage; } + private LoadingManage existingPayload(List waybillIdList) { + LoadingManage loadingManage = submitPayload(waybillIdList); + loadingManage.setId(OWN_LOADING_ID); + return loadingManage; + } + private static String waybillIdsJson(List waybillIdList) { return waybillIdList.stream().map(String::valueOf).collect(Collectors.joining(",", "[", "]")); } private void stubExistingManifest() { + stubManifest("pending"); + } + + private void stubManifest(String businessStatus) { LoadingManage existing = new LoadingManage(); existing.setId(OWN_LOADING_ID); existing.setLoadingNo(OWN_LOADING_NO); existing.setDeptId(DEPT_ID); - existing.setBusinessStatus("pending"); + existing.setBusinessStatus(businessStatus); when(loadingManageMapper.selectById(OWN_LOADING_ID)).thenReturn(existing); } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageChildStatusWriteTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageChildStatusWriteTest.java index 8ebcbee3..52ab19f9 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageChildStatusWriteTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageChildStatusWriteTest.java @@ -166,9 +166,6 @@ class LoadingManageChildStatusWriteTest { @Test @DisplayName("跨项目的子单逐张按各自项目推导,互不牵连,配置只查一次") void deriveEachWaybillStatusFromItsOwnProject() throws Exception { - stubWaybills( - waybill(NEED_ACCEPT_WAYBILL_ID, NEED_ACCEPT_PROJECT_ID), - waybill(NO_ACCEPT_WAYBILL_ID, NO_ACCEPT_PROJECT_ID)); stubConfigs( config(NEED_ACCEPT_PROJECT_ID, NEED_ACCEPT_JSON), config(NO_ACCEPT_PROJECT_ID, NO_ACCEPT_JSON)); @@ -177,7 +174,9 @@ class LoadingManageChildStatusWriteTest { assertEquals(Map.of( NEED_ACCEPT_WAYBILL_ID, "pending", NO_ACCEPT_WAYBILL_ID, "running"), - deriveBusinessStatuses(NEED_ACCEPT_WAYBILL_ID, NO_ACCEPT_WAYBILL_ID)); + deriveBusinessStatuses( + waybill(NEED_ACCEPT_WAYBILL_ID, NEED_ACCEPT_PROJECT_ID), + waybill(NO_ACCEPT_WAYBILL_ID, NO_ACCEPT_PROJECT_ID))); verify(processConfigService, times(1)).list(ArgumentMatchers.>any()); } @@ -409,12 +408,12 @@ class LoadingManageChildStatusWriteTest { } /** 逐张推导的结果:提交/重新派单在准入阶段就拦下跨项目子单,跨项目推导只能直接测这个函数 */ - private Map deriveBusinessStatuses(Long... waybillIds) throws Exception { + private Map deriveBusinessStatuses(Waybill... waybills) throws Exception { Method method = LoadingManageServiceImpl.class .getDeclaredMethod("deriveWaybillBusinessStatuses", List.class); method.setAccessible(true); @SuppressWarnings("unchecked") - Map statusByWaybillId = (Map) method.invoke(service, List.of(waybillIds)); + Map statusByWaybillId = (Map) method.invoke(service, List.of(waybills)); return statusByWaybillId; } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/LoadableAdmissionSupportTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/LoadableAdmissionSupportTest.java index 92d8382f..9b5c72ad 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/LoadableAdmissionSupportTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/LoadableAdmissionSupportTest.java @@ -4,7 +4,9 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springblade.transport.pojo.entity.Waybill; +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; /** @@ -50,30 +52,47 @@ class LoadableAdmissionSupportTest { @Test @DisplayName("公路 + 待执行 + 未配载才可配载") void loadableOnlyWhenAllConditionsHold() { - assertTrue(LoadableAdmissionSupport.isLoadable(waybill("road", "pending", null, "pending"))); - assertTrue(LoadableAdmissionSupport.isLoadable(waybill("公路运输", "pending", "", null))); + assertTrue(LoadableAdmissionSupport.isLoadable(waybill("road", "pending", null, "pending"), null)); + assertTrue(LoadableAdmissionSupport.isLoadable(waybill("公路运输", "pending", "", null), null)); } @Test @DisplayName("已拒单的票照样可配载:接单事实由配载提交清除,不靠把拒单票挡在候选之外") void rejectedWaybillIsLoadable() { - assertTrue(LoadableAdmissionSupport.isLoadable(waybill("road", "pending", null, "rejected"))); + assertTrue(LoadableAdmissionSupport.isLoadable(waybill("road", "pending", null, "rejected"), null)); assertTrue(LoadableAdmissionSupport.isLoadable(waybill("road", "pending", null, "rejected"), "PZ20260101-001")); } @Test @DisplayName("非公路、非待执行、已关联配载单各自单独即可拒绝") void eachConditionRejectsOnItsOwn() { - assertFalse(LoadableAdmissionSupport.isLoadable(waybill("railway", "pending", null, "pending")), + assertFalse(LoadableAdmissionSupport.isLoadable(waybill("railway", "pending", null, "pending"), null), "非公路不可配载"); - assertFalse(LoadableAdmissionSupport.isLoadable(waybill("road", "running", null, null)), + assertFalse(LoadableAdmissionSupport.isLoadable(waybill("road", "running", null, null), null), "进行中不可配载:不需要接单的项目保存后即为进行中,那是过程配置的产物,不代表可配载"); - assertFalse(LoadableAdmissionSupport.isLoadable(waybill("road", "draft", null, null))); - assertFalse(LoadableAdmissionSupport.isLoadable(waybill("road", "completed", null, null))); - assertFalse(LoadableAdmissionSupport.isLoadable(waybill("road", "cancelled", null, null))); - assertFalse(LoadableAdmissionSupport.isLoadable(waybill("road", "pending", "PZ20260101-001", null)), + assertFalse(LoadableAdmissionSupport.isLoadable(waybill("road", "draft", null, null), null)); + assertFalse(LoadableAdmissionSupport.isLoadable(waybill("road", "completed", null, null), null)); + assertFalse(LoadableAdmissionSupport.isLoadable(waybill("road", "cancelled", null, null), null)); + assertFalse(LoadableAdmissionSupport.isLoadable(waybill("road", "pending", "PZ20260101-001", null), null), "已挂在其它配载单上的票不可再配载"); - assertFalse(LoadableAdmissionSupport.isLoadable(null)); + assertFalse(LoadableAdmissionSupport.isLoadable(null, null)); + } + + @Test + @DisplayName("不可配载原因按 公路 → 待执行 → 未关联其它配载单 取第一条") + void unloadableReasonFollowsPredicateOrder() { + assertEquals(LoadableAdmissionSupport.UnloadableReason.NOT_ROAD, + LoadableAdmissionSupport.unloadableReason( + waybill("railway", "running", "PZ20260101-002", null), "PZ20260101-001")); + assertEquals(LoadableAdmissionSupport.UnloadableReason.NOT_PENDING, + LoadableAdmissionSupport.unloadableReason( + waybill("road", "running", "PZ20260101-002", null), "PZ20260101-001")); + assertEquals(LoadableAdmissionSupport.UnloadableReason.ON_OTHER_MANIFEST, + LoadableAdmissionSupport.unloadableReason( + waybill("road", "pending", "PZ20260101-002", null), "PZ20260101-001")); + assertNull(LoadableAdmissionSupport.unloadableReason(waybill("road", "pending", null, null), null)); + assertNull(LoadableAdmissionSupport.unloadableReason( + waybill("road", "pending", "PZ20260101-001", null), "PZ20260101-001")); } @Test From 28e19517fbe2303971f86439f3a2af4c2f65ff27 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Sun, 18 Oct 2026 16:55:25 +0800 Subject: [PATCH 070/108] =?UTF-8?q?:bug:=20=E9=A1=BA=E5=BA=8F=E9=97=A8?= =?UTF-8?q?=E7=A6=81=E5=8F=AA=E7=82=B9=E5=90=8D=E6=9C=80=E5=85=88=E6=9C=AA?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E7=9A=84=E5=89=8D=E7=BD=AE=E8=8A=82=E7=82=B9?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E5=86=8D=E6=8A=8A=E6=B2=A1=E6=89=93=E7=9A=84?= =?UTF-8?q?=E5=89=8D=E7=BD=AE=E5=85=A8=E5=88=97=E5=87=BA=E6=9D=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/DriverWaybillServiceImpl.java | 18 ++++----------- .../support/WaybillProcessSupport.java | 22 +++++++++++++++++++ .../support/WaybillProcessSupportTest.java | 18 +++++++++++++++ 3 files changed, 44 insertions(+), 14 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 4abe0be3..e0f09fdf 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 @@ -1326,7 +1326,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { Set unfinished = WaybillProcessSupport.unfinishedPrerequisiteKeys( processJson, punched, WaybillProcessSupport.nodeKey(nodeCfg)); if (!unfinished.isEmpty()) { - throw new ServiceException("请先完成「" + prerequisiteNames(processJson, unfinished) + "」打卡"); + throw new ServiceException("请先完成「" + + WaybillProcessSupport.firstUnfinishedPrerequisiteName(processJson, unfinished) + "」打卡"); } } @@ -1598,7 +1599,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } vo.setBlockedByOrder(true); vo.setActionable(false); - vo.setOrderLockReason("请先完成「" + prerequisiteNames(processJson, unfinished) + "」打卡"); + vo.setOrderLockReason("请先完成「" + + WaybillProcessSupport.firstUnfinishedPrerequisiteName(processJson, unfinished) + "」打卡"); } // 是否允许展开:顺序门禁不允许展开(还没轮到,展开也没用); @@ -1647,18 +1649,6 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { return nodes; } - /** 把前置节点 key 拼成给人看的名称列表,如「装货、发货」 */ - private String prerequisiteNames(String processJson, Set nodeKeys) { - List names = new ArrayList<>(); - for (Map node : WaybillProcessSupport.listDriverPunchNodes(processJson)) { - String key = WaybillProcessSupport.nodeKey(node); - if (nodeKeys.contains(key)) { - names.add(WaybillProcessSupport.nodeName(node)); - } - } - return names.isEmpty() ? "" : String.join("、", names); - } - private Map findPunchNodeConfig(String processJson, String nodeCode) { List> punchConfigs = WaybillProcessSupport.listDriverPunchNodes(processJson); for (Map cfg : punchConfigs) { 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 bb508f05..1b273c0b 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 @@ -420,6 +420,28 @@ public final class WaybillProcessSupport { return Collections.emptySet(); } + /** + * 顺序门禁点名的那一个前置节点名称:取**最先未完成**的。 + *

+ * 它才是司机现在能去打卡的那一步——后面那些未完成的前置节点自己也还打不了, + * 一起列出来既长,又不是一个可执行的下一步。 + * + * @param processJson 过程配置 + * @param nodeKeys 未完成的前置节点 key,即 {@link #unfinishedPrerequisiteKeys} 的返回 + */ + public static String firstUnfinishedPrerequisiteName(String processJson, Collection nodeKeys) { + if (nodeKeys == null || nodeKeys.isEmpty()) { + return ""; + } + for (Map node : listDriverPunchNodes(processJson)) { + String key = nodeKey(node); + if (nodeKeys.contains(key)) { + return nodeName(node); + } + } + return ""; + } + /** * 两个节点编码是否指同一个节点:两侧去空白、忽略大小写。 *

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 fa3334c2..5583f975 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 @@ -123,6 +123,24 @@ class WaybillProcessSupportTest { "「到货」还没打,所以卸货的前置是到货;在途不是前置(它没有完成含义)"); } + @Test + @DisplayName("顺序门禁只点名最先未完成的那一个前置节点,不把没打的前置全列出来") + void namesOnlyTheEarliestUnfinishedPrerequisite() { + Set unfinished = WaybillProcessSupport.unfinishedPrerequisiteKeys( + TRANSPORT_JSON, Collections.emptySet(), "arrival"); + + assertEquals(keys("arrive_scene", "load", "dispatch"), unfinished, "到货前面三个打卡节点都没打"); + assertEquals("到场", WaybillProcessSupport.firstUnfinishedPrerequisiteName(TRANSPORT_JSON, unfinished), + "只报司机现在能去打卡的那一个:装货、发货自己也还打不了,列出来不是可执行的下一步"); + } + + @Test + @DisplayName("没有未完成的前置节点时不点名") + void namesNothingWhenNothingIsUnfinished() { + assertEquals("", + WaybillProcessSupport.firstUnfinishedPrerequisiteName(TRANSPORT_JSON, Collections.emptySet())); + } + @Test @DisplayName("顺序门禁:在途豁免,前置一个都没打也返回空集") void transitIsExemptFromOrderGate() { From 04389cd9d5c9fc94f86af01f0c5932e354de5c5c Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Sun, 18 Oct 2026 19:40:19 +0800 Subject: [PATCH 071/108] =?UTF-8?q?:bug:=20=E6=9C=AA=E9=85=8D=E8=BD=BD?= =?UTF-8?q?=E8=BF=90=E6=AC=A1=E7=9A=84=E8=AE=A1=E5=88=92=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E5=8F=96=E8=BF=90=E5=8D=95=E8=87=AA=E8=BA=AB=E7=9A=84=E5=80=BC?= =?UTF-8?q?=EF=BC=8C=E8=AF=A6=E6=83=85=E4=B8=8E=E5=88=97=E8=A1=A8=E5=90=8C?= =?UTF-8?q?=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 运次读服务拼未配载运次时从不给 estimatedStartDate/estimatedEndDate 赋值, 而配载运次取配载单、是有值的。于是所有按运次读的出口里,普通运输一律没有计划时间: 调度端详情页「计划时间」显示 —,司机端列表卡片同理,而同一张运单在列表卡片上有值。 - 段一 select 补 estimated_start_time/estimated_end_time(不取这两列则仍为空) - waybillTrip 从运单补计划时间,与 loadingTrip 取配载单同一口径 - 调度端、司机端卡片改为直接取运次字段,删掉未配载运单独走一次批量补查的分叉 - 司机端卡片的计划时间不再以「有配载单」为前提;运次级为空时不覆盖卡片已有值 库里本就为空的配载单(如未填日期的草稿)仍不显示,不编造值。 --- .../impl/DriverWaybillServiceImpl.java | 10 ++++- .../impl/ManageWaybillServiceImpl.java | 40 ++++--------------- .../service/impl/TripQueryServiceImpl.java | 7 +++- .../service/impl/DriverTripCardTest.java | 4 ++ .../impl/TripQueryServiceImplTest.java | 25 ++++++++++++ 5 files changed, 50 insertions(+), 36 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 e0f09fdf..350c0e92 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 @@ -1086,10 +1086,16 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { card.setVehicleNo(Func.toStr(trip.getVehicleNo(), "")); card.setCreateTime(formatDateTime(trip.getSortTime())); card.setPublishTime(card.getCreateTime()); - // 计划时间与备注:配载运次取配载单,未配载的照运单卡片自己的值,不回填空 - if (Func.isNotEmpty(card.getLoadingNo())) { + // 计划时间两条路同源:配载运次取配载单,未配载运次取那张运单自己的。 + // 未配载详情进来时卡片已照运单填过,运次级为空就保留它,不覆盖成空 + if (trip.getEstimatedStartDate() != null) { card.setPlanShipTime(formatLocalDateYmd(trip.getEstimatedStartDate())); + } + if (trip.getEstimatedEndDate() != null) { card.setPlanFinishTime(formatLocalDateYmd(trip.getEstimatedEndDate())); + } + // 备注只有配载运次有运次级的值,未配载的保留运单卡片自己带来的备注 + if (Func.isNotEmpty(card.getLoadingNo())) { card.setRemark(Func.toStr(trip.getTaskRemark(), "")); } card.setFromName(Func.toStrWithEmpty(trip.getDepartureName(), Func.toStr(trip.getDepartureAddress(), ""))); 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 41ab9cc2..7cab655b 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 @@ -246,7 +246,7 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { } /** - * 运次卡片页:一次算清这一页的重派标记、异常与未配载运次的计划时间,再逐条拼卡片 + * 运次卡片页:一次算清这一页的重派标记与异常,再逐条拼卡片 */ private Page tripCardPage(List trips, int pageNo, int pageSize, long total, Set exceptionWaybillIds) { @@ -254,9 +254,8 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { if (!trips.isEmpty()) { Set reassignKeys = loadReassignTripKeys( trips.stream().map(this::tripKeyOf).collect(Collectors.toCollection(LinkedHashSet::new))); - Map loneWaybills = loadLoneWaybills(trips); for (TripVO trip : trips) { - cards.add(toTripCard(trip, exceptionWaybillIds, reassignKeys, loneWaybills)); + cards.add(toTripCard(trip, exceptionWaybillIds, reassignKeys)); } } Page page = new Page<>(pageNo, pageSize, total); @@ -265,12 +264,12 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { } /** - * 运次卡片:展示单号、整趟车起/经/终与站点数、货物汇总都取运次字段。 + * 运次卡片:展示单号、整趟车起/经/终与站点数、货物汇总、计划时间都取运次字段。 * 旧字段 id / waybillNo 保留运单语义(未配载就是这张运单,配载取第一张可见子运单), * 打卡订正、重新派单等按运单进入的入口仍靠它们。 */ private AdminWaybillCardVO toTripCard(TripVO trip, Set exceptionWaybillIds, - Set reassignTripIds, Map loneWaybills) { + Set reassignTripIds) { AdminWaybillCardVO card = new AdminWaybillCardVO(); List children = trip.getWaybills() == null ? List.of() : trip.getWaybills(); Long firstChildId = children.isEmpty() ? null : parseId(children.get(0).getId()); @@ -293,15 +292,9 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { String weight = formatWeight(trip.getTotalQuantity(), trip.getQuantityUnit()); card.setWeight(weight); card.setTotalWeight(weight); - if (loaded) { - card.setPlanTime(formatLocalDate(trip.getEstimatedStartDate())); - card.setPlanTimeEnd(formatLocalDate(trip.getEstimatedEndDate())); - } else { - // 未配载运次不带计划时间,按运单主键补一次;缺失时留空,不与其他运次串值 - Waybill waybill = loneWaybills.get(parseId(trip.getId())); - card.setPlanTime(formatLocalDate(waybill == null ? null : waybill.getEstimatedStartTime())); - card.setPlanTimeEnd(formatLocalDate(waybill == null ? null : waybill.getEstimatedEndTime())); - } + // 计划时间两条路同源:配载运次取配载单,未配载运次取那张运单自己的 + card.setPlanTime(formatLocalDate(trip.getEstimatedStartDate())); + card.setPlanTimeEnd(formatLocalDate(trip.getEstimatedEndDate())); card.setStatus(trip.getStatus()); card.setDriverName(Func.toStr(trip.getDriverName(), "")); card.setVehicleNo(Func.toStr(trip.getVehicleNo(), "")); @@ -312,25 +305,6 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { return card; } - /** - * 未配载运次(每张运单自成运次)的计划时间:运次读服务不下发它,一页一次批量补查 - */ - private Map loadLoneWaybills(List trips) { - Set loneIds = trips.stream() - .filter(trip -> Func.isEmpty(trip.getLoadingNo())) - .map(trip -> parseId(trip.getId())) - .filter(Objects::nonNull) - .collect(Collectors.toCollection(LinkedHashSet::new)); - if (loneIds.isEmpty()) { - return Map.of(); - } - return waybillService.list(Wrappers.lambdaQuery() - .select(Waybill::getId, Waybill::getEstimatedStartTime, Waybill::getEstimatedEndTime) - .in(Waybill::getId, loneIds)) - .stream() - .collect(Collectors.toMap(Waybill::getId, Function.identity(), (first, ignored) -> first)); - } - /** 这一页里带「子单被拒」标记的运次主键:判据与重派待办一致(已拒单 + 未取消未完成) */ private Set loadReassignTripKeys(Set tripIds) { if (Func.isEmpty(tripIds)) { 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 7af9b79f..d4c45658 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 @@ -231,7 +231,9 @@ public class TripQueryServiceImpl implements ITripQueryService { Waybill::getCargoName, Waybill::getCargoType, Waybill::getQuantity, Waybill::getQuantityUnit, Waybill::getDepartureName, Waybill::getDepartureAddress, Waybill::getArrivalName, Waybill::getArrivalAddress, Waybill::getDriverName, Waybill::getDriverPhone, Waybill::getVehicleNo, - Waybill::getCustomerName, Waybill::getTransportType, Waybill::getTaskRemark, Waybill::getCreateTime) + Waybill::getCustomerName, Waybill::getTransportType, Waybill::getTaskRemark, + // 未配载运次没有配载单可依,计划时间要取运单自己的,故必须随行取回 + Waybill::getEstimatedStartTime, Waybill::getEstimatedEndTime, Waybill::getCreateTime) .in(Waybill::getBusinessStatus, TripAggregation.CHILD_STATUSES) .and(visible -> visible.isNull(Waybill::getDriverAcceptStatus) .or().ne(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED)); @@ -545,6 +547,9 @@ public class TripQueryServiceImpl implements ITripQueryService { trip.setCargoType(Func.toStr(waybill.getCargoType(), "")); trip.setDepartureAddress(Func.toStr(waybill.getDepartureAddress(), "")); trip.setArrivalAddress(Func.toStr(waybill.getArrivalAddress(), "")); + // 未配载没有配载单可依,计划时间只能取这张运单自己的 + trip.setEstimatedStartDate(waybill.getEstimatedStartTime()); + trip.setEstimatedEndDate(waybill.getEstimatedEndTime()); trip.setTaskRemark(Func.toStr(waybill.getTaskRemark(), "")); if (withPayload) { trip.setRouteJson(waybill.getRouteJson()); 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 612bb192..966ac370 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 @@ -191,6 +191,8 @@ class DriverTripCardTest { assertEquals("", card.getLoadingNo()); assertEquals("YD-20260920-0003", card.getTripNo()); assertEquals("common", card.getTransportOrgType()); + assertEquals("2026-09-20", card.getPlanShipTime(), "未配载运次的卡片也要带计划时间"); + assertEquals("2026-09-22", card.getPlanFinishTime()); } @Test @@ -400,6 +402,8 @@ class DriverTripCardTest { trip.setRequireAccept(true); trip.setAcceptStatus("pending"); trip.setSortTime(new Date(1787629629000L)); + trip.setEstimatedStartDate(java.time.LocalDate.of(2026, 9, 20)); + trip.setEstimatedEndDate(java.time.LocalDate.of(2026, 9, 22)); trip.setDepartureName("南宁武鸣"); trip.setArrivalName("钦州港"); trip.setStationCount(2); 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 e981e71e..7c1a9420 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 @@ -23,6 +23,7 @@ import org.springblade.transport.service.ILoadingManageService; import org.springblade.transport.service.IProcessConfigService; import org.springblade.transport.service.IWaybillService; +import java.time.LocalDate; import java.util.Date; import java.util.HashSet; import java.util.List; @@ -112,6 +113,8 @@ class TripQueryServiceImplTest { String select = String.valueOf(capturedRowQuery().getSqlSelect()); assertTrue(select.contains("process_json"), "分档要按过程配置推导,必须取:" + select); assertTrue(select.contains("create_time"), "排序键要用:" + select); + assertTrue(select.contains("estimated_start_time"), "未配载运次的计划时间取自运单,必须取:" + select); + assertTrue(select.contains("estimated_end_time"), select); assertFalse(select.contains("route_json"), "运单表上的大 JSON 列不必拖回:" + select); assertFalse(select.contains("goods_json"), select); assertFalse(select.contains("task_info_json"), select); @@ -258,6 +261,28 @@ class TripQueryServiceImplTest { assertEquals("YD-20260920-0003", trip.getTripNo()); } + @Test + @DisplayName("未配载运次的计划时间取自这张运单:没有配载单可依,不能留空") + void unloadedTripPlanTimeComesFromWaybill() { + Waybill row = waybill(WAYBILL_ID, "YD-20260920-0003", null, "running", null, at(1000L), ACCEPT_NO); + row.setEstimatedStartTime(LocalDate.of(2026, 8, 7)); + row.setEstimatedEndTime(LocalDate.of(2026, 8, 24)); + stubRows(row); + + TripVO listed = service.page(TripQueryVO.forDriver(PHONE)).getRecords().get(0); + + assertEquals(LocalDate.of(2026, 8, 7), listed.getEstimatedStartDate(), "列表卡片与详情同源,不能只在列表补"); + assertEquals(LocalDate.of(2026, 8, 24), listed.getEstimatedEndDate()); + + when(loadingManageService.getById(WAYBILL_ID)).thenReturn(null); + when(waybillService.getById(WAYBILL_ID)).thenReturn(row); + + TripVO detailed = service.detail(tripQuery(String.valueOf(WAYBILL_ID))); + + assertEquals(LocalDate.of(2026, 8, 7), detailed.getEstimatedStartDate(), "管理端详情走这条路,不留空"); + assertEquals(LocalDate.of(2026, 8, 24), detailed.getEstimatedEndDate()); + } + @Test @DisplayName("配载单在两次查询之间被删掉时,不给出没有主键的运次") void loadingMissingIsDropped() { From ddaa6f2c45ac8fa1b16cb9533a2466e3d6d13a73 Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Sun, 18 Oct 2026 20:35:28 +0800 Subject: [PATCH 072/108] =?UTF-8?q?:sparkles:=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E7=BB=93=E7=AE=97=E5=8D=95=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E3=80=81=E8=8D=89=E7=A8=BF=E6=98=8E=E7=BB=86?= =?UTF-8?q?=E4=B8=8E=E5=8E=86=E5=8F=B2=E8=BF=90=E5=8D=95=E9=80=89=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../pojo/entity/SettlementImportBatch.java | 3 + .../pojo/vo/SettlementImportBatchVO.java | 4 + .../SettlementImportController.java | 15 ++ .../excel/SettlementImportFeeReader.java | 100 +++++++- .../SettlementImportTemplateExporter.java | 211 ++++++++++++++++ .../ISettlementImportBatchService.java | 8 + .../SettlementImportBatchServiceImpl.java | 239 ++++++++++++++++-- ...blade_settlement_import_batch_20261015.sql | 10 +- ...settlement_import_batch_alter_20261018.sql | 7 + 9 files changed, 573 insertions(+), 24 deletions(-) create mode 100644 blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExporter.java create mode 100644 doc/sql/transport/blade_settlement_import_batch_alter_20261018.sql diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/SettlementImportBatch.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/SettlementImportBatch.java index 88a3bcd4..9cecf4af 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/SettlementImportBatch.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/SettlementImportBatch.java @@ -107,6 +107,9 @@ public class SettlementImportBatch extends TenantEntity { @Schema(description = "动态费用列名JSON") private String feeItemNamesJson; + @Schema(description = "导入明细行JSON") + private String rowsJson; + @Schema(description = "备注") private String remark; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/SettlementImportBatchVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/SettlementImportBatchVO.java index 3f856a62..6887da79 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/SettlementImportBatchVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/SettlementImportBatchVO.java @@ -32,6 +32,7 @@ import org.springblade.transport.pojo.entity.SettlementImportBatch; import java.io.Serial; import java.util.List; +import java.util.Map; /** * 导入结算单批次视图 @@ -66,4 +67,7 @@ public class SettlementImportBatchVO extends SettlementImportBatch { @Schema(description = "动态费用列名") private List feeItemNames; + + @Schema(description = "导入明细行") + private List> rows; } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/SettlementImportController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/SettlementImportController.java index cfc6fe69..3fac3e24 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/SettlementImportController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/SettlementImportController.java @@ -144,4 +144,19 @@ public class SettlementImportController extends BladeController { settlementImportBatchService.voidBatch(request); return R.success("作废成功"); } + + @PostMapping("/remove") + @ApiOperationSupport(order = 12) + @Operation(summary = "删除草稿批次") + public R remove(@RequestParam Long id) { + settlementImportBatchService.removeDraft(id); + return R.success("删除成功"); + } + + @GetMapping("/history-waybills") + @ApiOperationSupport(order = 13) + @Operation(summary = "历史运单可选列表") + public R>> historyWaybills(SettlementImportBatchRequest request) { + return R.data(settlementImportBatchService.historyWaybills(request)); + } } 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 be771e3c..992db31c 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 @@ -26,7 +26,9 @@ package org.springblade.transport.excel; import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellType; import org.apache.poi.ss.usermodel.DataFormatter; +import org.apache.poi.ss.usermodel.DateUtil; import org.apache.poi.ss.usermodel.Row; import org.apache.poi.ss.usermodel.Sheet; import org.apache.poi.ss.usermodel.Workbook; @@ -38,11 +40,16 @@ import org.springframework.web.multipart.MultipartFile; import java.io.InputStream; import java.math.BigDecimal; import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.Date; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; /** * 导入结算单 Excel 读取器(Apache POI)。 @@ -54,6 +61,11 @@ import java.util.Map; public final class SettlementImportFeeReader { private static final DataFormatter DATA_FORMATTER = new DataFormatter(Locale.CHINA); + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + private static final Set DATE_FIELDS = Set.of( + "actualStartDate", "actualEndDate", "planStartDate", "planEndDate" + ); + private static final Set OPTIONAL_DECIMAL_FIELDS = Set.of("mileage", "unitPrice"); private SettlementImportFeeReader() { } @@ -193,23 +205,26 @@ public final class SettlementImportFeeReader { for (Map.Entry entry : headers.entrySet()) { int column = entry.getKey(); String trimmedHeader = entry.getValue(); - String cell = cellString(row.getCell(column)); + Cell poiCell = row.getCell(column); if (feeColumns.contains(column)) { - feeItems.put(trimmedHeader, decimal(cell, trimmedHeader, excelRowIndex)); + feeItems.put(trimmedHeader, decimal(cellString(poiCell), trimmedHeader, excelRowIndex)); continue; } String field = FIXED_HEADER_MAP.get(trimmedHeader); if (field == null) { continue; } - if ("transportFee".equals(field) || "unitPrice".equals(field) - || "quantity".equals(field) || "mileage".equals(field) + if (DATE_FIELDS.contains(field)) { + mapped.put(field, dateCellString(poiCell)); + } else if (OPTIONAL_DECIMAL_FIELDS.contains(field)) { + mapped.put(field, optionalDecimal(cellString(poiCell), trimmedHeader, excelRowIndex)); + } else if ("transportFee".equals(field) || "quantity".equals(field) || "settlementAmount".equals(field)) { - mapped.put(field, decimal(cell, trimmedHeader, excelRowIndex)); + mapped.put(field, decimal(cellString(poiCell), trimmedHeader, excelRowIndex)); } else if ("serialNumber".equals(field)) { - mapped.put(field, intValue(cell)); + mapped.put(field, intValue(cellString(poiCell))); } else { - mapped.put(field, blankToNull(cell)); + mapped.put(field, blankToNull(cellString(poiCell))); } mapped.put(trimmedHeader, mapped.get(field)); } @@ -278,6 +293,69 @@ public final class SettlementImportFeeReader { return text.isEmpty() ? null : text; } + /** + * 读取日期单元格:Excel 日期序列优先格式化为 yyyy-MM-dd; + * 文本短日期(如 1/1/26、10/10/2026)归一为四位年,供后续 LenientDateParser 识别。 + */ + private static String dateCellString(Cell cell) { + if (cell == null) { + return null; + } + CellType cellType = cell.getCellType(); + if (cellType == CellType.FORMULA) { + cellType = cell.getCachedFormulaResultType(); + } + if (cellType == CellType.NUMERIC && DateUtil.isCellDateFormatted(cell)) { + Date date = cell.getDateCellValue(); + if (date == null) { + return null; + } + LocalDate localDate = date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate(); + return DATE_FORMATTER.format(localDate); + } + String text = cellString(cell); + return normalizeExcelShortDate(text); + } + + /** 将 Excel 常见短日期文本归一为 yyyy-MM-dd;无法识别时原样返回交由校验层报错。 */ + private static String normalizeExcelShortDate(String text) { + if (text == null || text.isBlank()) { + return null; + } + String normalized = text.trim().replace('.', '/').replace('-', '/'); + String[] parts = normalized.split("[/\\s]+"); + if (parts.length < 3) { + return text.trim(); + } + try { + int first = Integer.parseInt(parts[0]); + int second = Integer.parseInt(parts[1]); + int third = Integer.parseInt(parts[2].replaceAll("[^0-9].*$", "")); + int year; + int month; + int day; + if (parts[0].length() == 4) { + year = first; + month = second; + day = third; + } else if (parts[2].length() == 4 || parts[2].length() == 2) { + // M/d/yy 或 M/d/yyyy(Excel 中文本/短日期常见) + month = first; + day = second; + year = parts[2].length() == 2 ? 2000 + third : third; + if (parts[2].length() == 2 && third >= 100) { + return text.trim(); + } + } else { + return text.trim(); + } + LocalDate localDate = LocalDate.of(year, month, day); + return DATE_FORMATTER.format(localDate); + } catch (Exception exception) { + return text.trim(); + } + } + private static BigDecimal decimal(String cell, String columnName, Integer excelRowIndex) { if (cell == null || cell.isBlank()) { return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); @@ -290,6 +368,14 @@ public final class SettlementImportFeeReader { } } + /** 可选数值:空白返回 null,避免空里程被当成 0 触发「必须为正数」。 */ + private static BigDecimal optionalDecimal(String cell, String columnName, Integer excelRowIndex) { + if (cell == null || cell.isBlank()) { + return null; + } + return decimal(cell, columnName, excelRowIndex); + } + private static Integer intValue(String cell) { if (cell == null || cell.isBlank()) { return null; 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 new file mode 100644 index 00000000..a33dbb6c --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExporter.java @@ -0,0 +1,211 @@ +/** + * 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.excel; + +import jakarta.servlet.http.HttpServletResponse; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellStyle; +import org.apache.poi.ss.usermodel.DataValidation; +import org.apache.poi.ss.usermodel.DataValidationConstraint; +import org.apache.poi.ss.usermodel.DataValidationHelper; +import org.apache.poi.ss.usermodel.FillPatternType; +import org.apache.poi.ss.usermodel.Font; +import org.apache.poi.ss.usermodel.HorizontalAlignment; +import org.apache.poi.ss.usermodel.IndexedColors; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.VerticalAlignment; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.util.CellRangeAddressList; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.springblade.core.log.exception.ServiceException; + +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 + */ +public final class SettlementImportTemplateExporter { + + private static final List FIXED_HEADERS_BEFORE_FEE = List.of( + "序号", + "原始单号", + "配载标识号", + "*车牌号/航班号/船号/班列号", + "*运输方式", + "司机/船长姓名", + "司机/船长手机号", + "*发货地址", + "发货联系人", + "发货联系人电话", + "*到货地址", + "收货联系人", + "收货联系人电话", + "*货物名称", + "*货物类型", + "包装", + "*数量", + "*数量单位", + "规格", + "型号", + "里程(km)", + "运输单价", + "*运输费" + ); + + private static final List FIXED_HEADERS_AFTER_FEE = List.of( + "结算金额", + "*实际发货时间", + "*实际完成时间", + "预计发货时间", + "预计完成时间", + "备注", + "同一运单标识号" + ); + + 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); + try (Workbook workbook = new XSSFWorkbook()) { + writeInstructionSheet(workbook, transportOptions, feeItemNames); + Sheet dataSheet = workbook.createSheet("导入结算单模板"); + CellStyle headerStyle = createHeaderStyle(workbook); + Row headerRow = dataSheet.createRow(0); + for (int index = 0; index < headers.size(); index++) { + Cell cell = headerRow.createCell(index); + cell.setCellValue(headers.get(index)); + cell.setCellStyle(headerStyle); + dataSheet.setColumnWidth(index, Math.min(28, Math.max(12, headers.get(index).length() + 4)) * 256); + } + int transportTypeColumn = headers.indexOf("*运输方式"); + if (transportTypeColumn >= 0 && !transportOptions.isEmpty()) { + addDropdown(dataSheet, transportTypeColumn, transportOptions); + } + response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.setHeader("Content-disposition", + "attachment;filename=" + URLEncoder.encode("导入结算单模板", StandardCharsets.UTF_8) + ".xlsx"); + workbook.write(response.getOutputStream()); + } catch (IOException exception) { + throw new ServiceException("导出导入结算单模板失败:" + exception.getMessage()); + } + } + + private static List buildHeaders(List feeItemNames) { + 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; + } + + private static void writeInstructionSheet(Workbook workbook, List transportOptions, + List feeItemNames) { + Sheet sheet = workbook.createSheet("填写说明"); + CellStyle titleStyle = workbook.createCellStyle(); + Font titleFont = workbook.createFont(); + titleFont.setBold(true); + titleStyle.setFont(titleFont); + List lines = new ArrayList<>(); + lines.add("导入结算单填写说明"); + lines.add("1. 请不要修改「导入结算单模板」工作表的表头;带 * 号为必填项。"); + 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("7. 当前可选运输方式:" + String.join("、", transportOptions) + "。"); + if (feeItemNames != null && !feeItemNames.isEmpty()) { + lines.add("8. 当前启用费用项示例:" + String.join("、", feeItemNames.stream().limit(30).toList()) + + (feeItemNames.size() > 30 ? " 等" : "") + "。"); + } else { + lines.add("8. 模板默认包含装卸费、过路费列,可按系统费用项主数据增删费用列。"); + } + for (int index = 0; index < lines.size(); index++) { + Row row = sheet.createRow(index); + Cell cell = row.createCell(0); + cell.setCellValue(lines.get(index)); + if (index == 0) { + cell.setCellStyle(titleStyle); + } + } + sheet.setColumnWidth(0, 120 * 256); + } + + private static void addDropdown(Sheet sheet, int columnIndex, List options) { + DataValidationHelper helper = sheet.getDataValidationHelper(); + String[] values = options.toArray(new String[0]); + DataValidationConstraint constraint = helper.createExplicitListConstraint(values); + CellRangeAddressList addressList = new CellRangeAddressList(1, 2000, columnIndex, columnIndex); + DataValidation validation = helper.createValidation(constraint, addressList); + validation.setEmptyCellAllowed(true); + validation.setShowErrorBox(true); + sheet.addValidationData(validation); + } + + private static CellStyle createHeaderStyle(Workbook workbook) { + CellStyle style = workbook.createCellStyle(); + style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); + style.setFillPattern(FillPatternType.SOLID_FOREGROUND); + style.setAlignment(HorizontalAlignment.CENTER); + style.setVerticalAlignment(VerticalAlignment.CENTER); + Font font = workbook.createFont(); + font.setBold(true); + style.setFont(font); + 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/ISettlementImportBatchService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementImportBatchService.java index f045a015..5d81be02 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementImportBatchService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementImportBatchService.java @@ -66,4 +66,12 @@ public interface ISettlementImportBatchService extends BaseService> historyWaybills(SettlementImportBatchRequest request); } 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 634814ab..9e21cddc 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 @@ -32,7 +32,6 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springblade.common.excel.ImportFailureExcelUtil; -import org.springblade.core.excel.util.ExcelUtil; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.mp.base.BaseServiceImpl; import org.springblade.core.secure.utils.AuthUtil; @@ -41,12 +40,16 @@ import org.springblade.core.tool.jackson.JsonUtil; 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; import org.springblade.system.pojo.entity.FeeItem; import org.springblade.transport.constant.TransportBusiConstant; import org.springblade.transport.excel.SettlementImportFeeReader; import org.springblade.transport.excel.SettlementImportTemplateExcel; +import org.springblade.transport.excel.SettlementImportTemplateExporter; import org.springblade.transport.mapper.SettlementImportBatchMapper; import org.springblade.transport.pojo.dto.FormalSettlementSaveRequest; import org.springblade.transport.pojo.dto.FormalSettlementStatusRequest; @@ -186,6 +189,7 @@ public class SettlementImportBatchServiceImpl .eq(SettlementImportBatch::getIsDeleted, 0) .like(Func.isNotEmpty(request.getBatchNo()), SettlementImportBatch::getBatchNo, request.getBatchNo()) .eq(Func.isNotEmpty(request.getSettlementType()), SettlementImportBatch::getSettlementType, request.getSettlementType()) + .eq(Func.isNotEmpty(request.getImportStatus()), SettlementImportBatch::getImportStatus, request.getImportStatus()) .like(Func.isNotEmpty(request.getProjectName()), SettlementImportBatch::getProjectName, request.getProjectName()) .like(Func.isNotEmpty(request.getCustomerName()), SettlementImportBatch::getCustomerName, request.getCustomerName()) .like(Func.isNotEmpty(request.getCarrierName()), SettlementImportBatch::getCarrierName, request.getCarrierName()) @@ -204,13 +208,18 @@ public class SettlementImportBatchServiceImpl if (batch == null || Objects.equals(batch.getIsDeleted(), 1)) { throw new ServiceException("导入结算单批次不存在"); } - return toVO(batch); + SettlementImportBatchVO vo = toVO(batch); + List> rows = parseRowsJson(batch.getRowsJson()); + if (Func.isEmpty(rows) && !STATUS_DRAFT.equals(batch.getImportStatus())) { + rows = buildRowsFromWaybills(listBatchWaybills(batch)); + } + vo.setRows(rows); + return vo; } @Override public void exportTemplate(HttpServletResponse response) { - List sample = new ArrayList<>(); - ExcelUtil.export(response, "导入结算单模板", "导入结算单模板", sample, SettlementImportTemplateExcel.class); + SettlementImportTemplateExporter.export(response, loadTransportTypeLabels(), loadEnabledFeeItemNames()); } @Override @@ -270,6 +279,7 @@ public class SettlementImportBatchServiceImpl @Override @Transactional(rollbackFor = Exception.class) public SettlementImportBatchVO saveDraft(SettlementImportBatchRequest request) { + prepareSettlementRows(request); SettlementImportBatch batch = buildDraftBatch(request); if (Func.isNotEmpty(request.getId())) { SettlementImportBatch oldBatch = getById(request.getId()); @@ -282,10 +292,14 @@ public class SettlementImportBatchServiceImpl batch.setId(oldBatch.getId()); batch.setBatchNo(oldBatch.getBatchNo()); } - batch.setWaybillCount(0); - batch.setDetailCount(0); + List> draftRows = sanitizeRowsForStorage(request.getRows()); + batch.setRowsJson(JsonUtil.toJson(draftRows)); + batch.setWaybillCount(primaryRows(draftRows).size()); + batch.setDetailCount(draftRows.size()); saveOrUpdate(batch); - return toVO(batch); + SettlementImportBatchVO vo = toVO(batch); + vo.setRows(draftRows); + return vo; } @Override @@ -399,8 +413,73 @@ public class SettlementImportBatchServiceImpl } batch.setImportStatus(STATUS_COMPLETED); batch.setFeeItemNamesJson(JsonUtil.toJson(resolveFeeItemNames(request))); + batch.setRowsJson(JsonUtil.toJson(sanitizeRowsForStorage(request.getRows()))); updateById(batch); - return toVO(batch); + SettlementImportBatchVO vo = toVO(batch); + vo.setRows(parseRowsJson(batch.getRowsJson())); + return vo; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void removeDraft(Long id) { + if (id == null) { + throw new ServiceException("请选择需要删除的批次"); + } + SettlementImportBatch batch = getById(id); + if (batch == null || Objects.equals(batch.getIsDeleted(), 1)) { + throw new ServiceException("导入结算单批次不存在"); + } + if (!STATUS_DRAFT.equals(batch.getImportStatus())) { + throw new ServiceException("仅草稿状态的批次允许删除"); + } + deleteLogic(List.of(id)); + } + + @Override + public List> historyWaybills(SettlementImportBatchRequest request) { + if (request == null || Func.isEmpty(request.getProjectId()) + || Func.isEmpty(request.getCustomerContractId()) + || Func.isEmpty(request.getCarrierContractId())) { + throw new ServiceException("请先选择项目、客户合同和承运商合同"); + } + String settlementType = Func.isEmpty(request.getSettlementType()) + ? TYPE_RECEIVABLE : request.getSettlementType(); + if (!TYPE_RECEIVABLE.equals(settlementType) && !TYPE_PAYABLE.equals(settlementType)) { + throw new ServiceException("结算类型不正确"); + } + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery() + .eq(Waybill::getIsDeleted, 0) + .eq(Waybill::getProjectId, request.getProjectId()) + .eq(Waybill::getContractId, request.getCustomerContractId()) + .eq(Waybill::getCarrierContractId, request.getCarrierContractId()) + .apply("not exists (select 1 from blade_receivable_payable_detail d" + + " where d.waybill_id = blade_waybill.id and d.settlement_type = {0}" + + " and d.is_deleted = 0)", settlementType) + .orderByDesc(Waybill::getCreateTime) + .last("limit 500"); + List waybills = waybillService.list(wrapper); + List> result = new ArrayList<>(); + for (Waybill waybill : waybills) { + Map item = new LinkedHashMap<>(); + item.put("id", waybill.getId()); + item.put("waybillNo", waybill.getWaybillNo()); + item.put("vehicleNo", waybill.getVehicleNo()); + item.put("transportType", waybill.getTransportType()); + item.put("departureAddress", waybill.getDepartureAddress()); + item.put("arrivalAddress", waybill.getArrivalAddress()); + item.put("cargoName", waybill.getCargoName()); + item.put("cargoType", waybill.getCargoType()); + item.put("quantity", waybill.getQuantity()); + item.put("quantityUnit", waybill.getQuantityUnit()); + item.put("model", waybill.getModel()); + item.put("mileage", waybill.getMileage()); + item.put("actualStartDate", waybill.getStartDate()); + item.put("actualEndDate", waybill.getEndDate()); + item.put("driverName", waybill.getDriverName()); + result.add(item); + } + return result; } @Override @@ -608,9 +687,9 @@ public class SettlementImportBatchServiceImpl .eq(Waybill::getEndDate, endDate) .eq(Waybill::getDepartureAddress, departureAddress) .eq(Waybill::getArrivalAddress, arrivalAddress) - .eq(Waybill::getCargoName, cargoName) - .orderByDesc(Waybill::getCreateTime) - .last("limit 3"); + .eq(Waybill::getCargoName, cargoName); + applyUnsettledWaybillFilter(wrapper, request.getSettlementType()); + wrapper.orderByDesc(Waybill::getCreateTime).last("limit 3"); List matches = waybillService.list(wrapper); if (Func.isEmpty(matches)) { throw new ServiceException("未匹配到系统已有运单(项目+车号+运输完成日期+客户+承运商+起运地+目的地+货物名称)"); @@ -623,16 +702,17 @@ public class SettlementImportBatchServiceImpl /** 手工指定运单号时,在当前项目/客户/承运商范围内精确匹配。 */ private Waybill matchHistoryWaybillByNo(SettlementImportBatchRequest request, String waybillNo) { - List matches = waybillService.list(Wrappers.lambdaQuery() + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery() .eq(Waybill::getIsDeleted, 0) .eq(Waybill::getProjectId, request.getProjectId()) .eq(Waybill::getContractId, request.getCustomerContractId()) .eq(Waybill::getCarrierContractId, request.getCarrierContractId()) - .eq(Waybill::getWaybillNo, waybillNo) - .orderByDesc(Waybill::getCreateTime) - .last("limit 2")); + .eq(Waybill::getWaybillNo, waybillNo); + applyUnsettledWaybillFilter(wrapper, request.getSettlementType()); + wrapper.orderByDesc(Waybill::getCreateTime).last("limit 2"); + List matches = waybillService.list(wrapper); if (Func.isEmpty(matches)) { - throw new ServiceException("未匹配到运单号「" + waybillNo + "」对应的系统已有运单"); + throw new ServiceException("未匹配到运单号「" + waybillNo + "」对应的未结算运单"); } if (matches.size() > 1) { throw new ServiceException("运单号「" + waybillNo + "」匹配到多条运单,请核对后重试"); @@ -640,6 +720,14 @@ public class SettlementImportBatchServiceImpl return matches.get(0); } + /** 仅保留尚未生成对应结算类型应收/应付明细的运单。 */ + private void applyUnsettledWaybillFilter(LambdaQueryWrapper wrapper, String settlementType) { + String type = Func.isEmpty(settlementType) ? TYPE_RECEIVABLE : settlementType; + wrapper.apply("not exists (select 1 from blade_receivable_payable_detail d" + + " where d.waybill_id = blade_waybill.id and d.settlement_type = {0}" + + " and d.is_deleted = 0)", type); + } + private void prepareSettlementRows(SettlementImportBatchRequest request) { if (request == null || Func.isEmpty(request.getRows())) { return; @@ -1161,4 +1249,123 @@ public class SettlementImportBatchServiceImpl String merged = Func.isEmpty(original) ? "作废原因:" + reason : original + ";作废原因:" + reason; return limit(merged, 200); } + + @SuppressWarnings("unchecked") + private List> parseRowsJson(String rowsJson) { + if (Func.isEmpty(rowsJson)) { + return List.of(); + } + List rawList = JsonUtil.parseArray(rowsJson, Map.class); + if (rawList == null || rawList.isEmpty()) { + return List.of(); + } + List> rows = new ArrayList<>(); + for (Map raw : rawList) { + if (raw == null) { + continue; + } + rows.add(new LinkedHashMap<>(raw)); + } + return rows; + } + + private List> sanitizeRowsForStorage(List> rows) { + if (Func.isEmpty(rows)) { + return List.of(); + } + List> sanitized = new ArrayList<>(); + for (Map row : rows) { + if (row == null) { + continue; + } + Map copy = new LinkedHashMap<>(); + for (Map.Entry entry : row.entrySet()) { + String key = entry.getKey(); + if (key == null || key.startsWith("_")) { + continue; + } + copy.put(key, entry.getValue()); + } + sanitized.add(copy); + } + return sanitized; + } + + private List loadTransportTypeLabels() { + LinkedHashSet labels = new LinkedHashSet<>(); + try { + List dictBizList = DictBizCache.getList("transport_type"); + if (Func.isNotEmpty(dictBizList)) { + dictBizList.stream() + .map(DictBiz::getDictValue) + .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) { + // 忽略 + } + if (labels.isEmpty()) { + return List.of("公路运输", "铁路运输", "水路运输", "航空运输"); + } + return new ArrayList<>(labels); + } + + private List loadEnabledFeeItemNames() { + R> response = sysClient.getFeeItems(false); + if (response == null || !response.isSuccess() || response.getData() == null) { + return List.of(); + } + return response.getData().stream() + .filter(item -> item != null && (item.getStatus() == null || Objects.equals(item.getStatus(), 1))) + .map(FeeItem::getName) + .filter(Func::isNotEmpty) + .map(String::trim) + .distinct() + .toList(); + } + + /** 历史已完成批次无 rows_json 时,按关联运单回填查看明细。 */ + private List> buildRowsFromWaybills(List waybills) { + if (Func.isEmpty(waybills)) { + return List.of(); + } + List> rows = new ArrayList<>(); + int serial = 1; + for (Waybill waybill : waybills) { + Map row = new LinkedHashMap<>(); + row.put("serialNumber", serial++); + row.put("waybillNo", waybill.getWaybillNo()); + row.put("vehicleNo", waybill.getVehicleNo()); + row.put("transportType", waybill.getTransportType()); + row.put("driverName", waybill.getDriverName()); + row.put("departureAddress", waybill.getDepartureAddress()); + row.put("arrivalAddress", waybill.getArrivalAddress()); + row.put("cargoName", waybill.getCargoName()); + row.put("cargoType", waybill.getCargoType()); + row.put("quantity", waybill.getQuantity()); + row.put("quantityUnit", waybill.getQuantityUnit()); + row.put("specification", waybill.getSpecification()); + row.put("model", waybill.getModel()); + row.put("mileage", waybill.getMileage()); + row.put("unitPrice", waybill.getUnitPrice()); + row.put("actualStartDate", waybill.getStartDate()); + row.put("actualEndDate", waybill.getEndDate()); + row.put("remark", waybill.getRemark()); + rows.add(row); + } + return rows; + } } diff --git a/doc/sql/transport/blade_settlement_import_batch_20261015.sql b/doc/sql/transport/blade_settlement_import_batch_20261015.sql index 6578be14..89c7b872 100644 --- a/doc/sql/transport/blade_settlement_import_batch_20261015.sql +++ b/doc/sql/transport/blade_settlement_import_batch_20261015.sql @@ -22,6 +22,7 @@ CREATE TABLE IF NOT EXISTS `blade_settlement_import_batch` ( `pre_settlement_id` bigint DEFAULT NULL COMMENT '预结算单ID', `pre_settlement_no` varchar(100) DEFAULT NULL COMMENT '预结算单号', `fee_item_names_json` longtext COMMENT '动态费用列名JSON数组', + `rows_json` longtext COMMENT '导入明细行JSON数组', `remark` varchar(200) DEFAULT NULL COMMENT '备注', `waybill_import_batch_id` bigint DEFAULT NULL COMMENT '关联底层运单导入批次,可空', `create_user` bigint DEFAULT NULL COMMENT '创建人', @@ -45,6 +46,7 @@ ALTER TABLE `blade_settlement_import_batch` ADD COLUMN `settlement_bill_type` va ALTER TABLE `blade_settlement_import_batch` ADD COLUMN `pre_settlement_id` bigint DEFAULT NULL COMMENT '预结算单ID' AFTER `formal_settlement_no`; ALTER TABLE `blade_settlement_import_batch` ADD COLUMN `pre_settlement_no` varchar(100) DEFAULT NULL COMMENT '预结算单号' AFTER `pre_settlement_id`; ALTER TABLE `blade_settlement_import_batch` ADD KEY `idx_settlement_import_pre` (`pre_settlement_id`); +ALTER TABLE `blade_settlement_import_batch` ADD COLUMN `rows_json` longtext COMMENT '导入明细行JSON数组' AFTER `fee_item_names_json`; -- 运单关联导入结算批次(可选,若列已存在可忽略报错) ALTER TABLE `blade_waybill` ADD COLUMN `settlement_import_batch_id` bigint DEFAULT NULL COMMENT '导入结算单批次ID'; @@ -56,5 +58,11 @@ INSERT INTO `blade_menu` (`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source` (2090000000000002082,2090000000000002080,'settlement_import_add','新增','settlement_import_add','','',2,2,0,1,NULL,'',0), (2090000000000002083,2090000000000002080,'settlement_import_detail','详情','settlement_import_detail','','',3,2,0,1,NULL,'',0), (2090000000000002084,2090000000000002080,'settlement_import_void','作废','settlement_import_void','','',4,2,0,1,NULL,'',0), -(2090000000000002085,2090000000000002080,'settlement_import_template','下载模板','settlement_import_template','','',5,2,0,1,NULL,'',0) +(2090000000000002085,2090000000000002080,'settlement_import_template','下载模板','settlement_import_template','','',5,2,0,1,NULL,'',0), +(2090000000000002086,2090000000000002080,'settlement_import_delete','删除','settlement_import_delete','','',6,2,0,1,NULL,'',0) +ON DUPLICATE KEY UPDATE `name`=VALUES(`name`),`path`=VALUES(`path`),`is_deleted`=0; + +-- 已建表环境增量菜单(已存在可忽略) +INSERT INTO `blade_menu` (`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) VALUES +(2090000000000002086,2090000000000002080,'settlement_import_delete','删除','settlement_import_delete','','',6,2,0,1,NULL,'',0) ON DUPLICATE KEY UPDATE `name`=VALUES(`name`),`path`=VALUES(`path`),`is_deleted`=0; diff --git a/doc/sql/transport/blade_settlement_import_batch_alter_20261018.sql b/doc/sql/transport/blade_settlement_import_batch_alter_20261018.sql new file mode 100644 index 00000000..218ed51e --- /dev/null +++ b/doc/sql/transport/blade_settlement_import_batch_alter_20261018.sql @@ -0,0 +1,7 @@ +-- 导入结算单:草稿明细落库 + 删除菜单 +ALTER TABLE `blade_settlement_import_batch` + ADD COLUMN `rows_json` longtext COMMENT '导入明细行JSON数组' AFTER `fee_item_names_json`; + +INSERT INTO `blade_menu` (`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) VALUES +(2090000000000002086,2090000000000002080,'settlement_import_delete','删除','settlement_import_delete','','',6,2,0,1,NULL,'',0) +ON DUPLICATE KEY UPDATE `name`=VALUES(`name`),`path`=VALUES(`path`),`is_deleted`=0; From 320f4720435e7155abad7146bb6c7229827b2b4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Sun, 18 Oct 2026 21:30:07 +0800 Subject: [PATCH 073/108] =?UTF-8?q?fix:=20=E5=BC=80=E7=A5=A8=E7=94=B3?= =?UTF-8?q?=E8=AF=B7=E4=B8=8E=E6=AD=A3=E5=BC=8F=E7=BB=93=E7=AE=97=E5=8D=95?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E9=87=91=E8=9D=B6=E5=85=A5=E5=8F=A3=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E8=A1=8C=E9=94=81=E4=BA=92=E6=96=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - doSyncKingdee 首行改为 lockedExisting 行锁读(FOR UPDATE),审批自动推/手动按钮/定时重推三入口在同一单据上串行化 - 后到请求等锁后读到已提交状态,命中已同步幂等分支直接返回金蝶单号 - 修复并发推送触发金蝶「已提交状态下的单据不允许修改」的假失败与重复建单风险 --- .../service/impl/FormalSettlementServiceImpl.java | 10 +++++++++- .../service/impl/InvoiceApplicationServiceImpl.java | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java index 4e58963f..f3b6ac7e 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java @@ -981,7 +981,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpllambdaQuery() + .eq(FormalSettlement::getId, id).last("FOR UPDATE")); + if (entity == null || Objects.equals(entity.getIsDeleted(), 1)) throw new ServiceException("正式结算单不存在"); + return entity; + } + private FormalSettlement editable(Long id) { FormalSettlement entity = existing(id); if (!TransportBusiConstant.DRAFT.equals(entity.getApprovalStatus()) && !TransportBusiConstant.RETURNED.equals(entity.getApprovalStatus())) throw new ServiceException("仅草稿或已驳回的正式结算单允许编辑"); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceApplicationServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceApplicationServiceImpl.java index 10b197a8..ab2d6bad 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceApplicationServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceApplicationServiceImpl.java @@ -701,7 +701,7 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpllambdaQuery() + .eq(InvoiceApplication::getId, id).last("FOR UPDATE")); + if (entity == null || Objects.equals(entity.getIsDeleted(), 1)) throw new ServiceException("开票申请不存在"); + return entity; + } + private InvoiceApplication editable(Long id) { InvoiceApplication entity = existing(id); if (!List.of(TransportBusiConstant.DRAFT, TransportBusiConstant.RETURNED).contains(entity.getApprovalStatus())) throw new ServiceException("当前状态不可编辑"); From d1be16755f078dea9f0fc63fba75edaa59b9e62f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Sun, 18 Oct 2026 21:30:37 +0800 Subject: [PATCH 074/108] =?UTF-8?q?fix:=20=E6=94=B6=E7=A5=A8=E7=99=BB?= =?UTF-8?q?=E8=AE=B0=E6=8E=A8=E9=80=81=E9=87=91=E8=9D=B6=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E9=80=90=E5=8D=95=E4=BA=8B=E5=8A=A1=E8=A1=8C=E9=94=81=E4=B8=B2?= =?UTF-8?q?=E8=A1=8C=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 批量编排逐单经 TransactionTemplate 开单据级短事务,推送首行 FOR UPDATE 行锁读并重查推送状态,已推送幂等跳过 - 同一登记的审批自动推/手动批量/定时重推并发时串行化,不再重复调金蝶保存 - 单笔失败只回滚自身不中断整批,失败状态由事务外独立补落,纳入定时重推 --- .../impl/InvoiceReceiptServiceImpl.java | 44 ++++++++++++++++++- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceReceiptServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceReceiptServiceImpl.java index 9a1580fd..bb136e21 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceReceiptServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceReceiptServiceImpl.java @@ -90,6 +90,7 @@ import org.springblade.transport.wrapper.InvoiceReceiptWrapper; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; import java.io.IOException; import java.io.InputStream; @@ -151,6 +152,8 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl failures = new ArrayList<>(); for (InvoiceReceipt receipt : receipts) { try { - pushOneReceivedInvoice(planByReceipt.get(receipt.getId()), kingdeeRowByInvoiceNo, apBillNoBySrcCode, context, autoSync); + // 逐单单据级事务:推送方法首行 FOR UPDATE 串行化同一登记的并发推送,单笔失败只回滚自身不中断整批 + transactionTemplate.executeWithoutResult(status -> pushOneReceivedInvoice( + planByReceipt.get(receipt.getId()), kingdeeRowByInvoiceNo, apBillNoBySrcCode, context, autoSync)); success++; } catch (RuntimeException pushException) { failures.add("发票" + Func.toStrWithEmpty(receipt.getInvoiceNo(), String.valueOf(receipt.getId())) + ":" + pushException.getMessage()); log.warn("推送金蝶收票单失败,收票登记id={}", receipt.getId(), pushException); + // 单笔事务已回滚,事务外独立补落"推送失败"状态,纳入定时重推(与付款/开票失败口径统一) + markReceivedInvoicePushFailed(receipt, planByReceipt.get(receipt.getId()), pushException, autoSync); } } String summary = "推送金蝶收票单完成:本批 " + receipts.size() + " 笔,成功 " + success + " 笔"; @@ -674,7 +681,21 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl kingdeeRows, Map apBillNoBySrcCode, KingdeeSyncContext context, boolean autoSync) { - InvoiceReceipt receipt = plan.receipt(); + // 行锁读:同一登记并发推送串行化(锁随本单事务提交释放,事务由批量编排的 TransactionTemplate 开启) + InvoiceReceipt receipt = baseMapper.selectOne(Wrappers.lambdaQuery() + .eq(InvoiceReceipt::getId, plan.receipt().getId()).last("FOR UPDATE")); + if (receipt == null || Objects.equals(receipt.getIsDeleted(), 1)) { + throw new ServiceException("收票登记不存在"); + } + // 锁后重查推送状态:并发场景另一事务已完成推送则幂等跳过,不再重复调金蝶 + if (KingdeeBfiConstant.RECEIVED_PUSH_PUSHED.equals(receipt.getKingdeePushStatus()) + || KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED.equals(receipt.getKingdeePushStatus())) { + log.info("收票登记[{}]金蝶收票单已推送(状态[{}]),幂等跳过", receipt.getInvoiceNo(), receipt.getKingdeePushStatus()); + return "发票" + Func.toStrWithEmpty(receipt.getInvoiceNo(), String.valueOf(receipt.getId())) + "已推送金蝶,幂等跳过"; + } + // 计划内登记实体以行锁后最新数据替换(并发下推送状态可能已被其他事务改写) + plan = new ReceivedInvoicePushPlan(receipt, plan.mirror(), plan.settlement(), plan.details(), + plan.apBillSrcCode(), plan.blockedReason()); log.info("收票登记[{}]推送金蝶收票单开始:id={},推送状态={},应付单防重键={}", receipt.getInvoiceNo(), receipt.getId(), receipt.getKingdeePushStatus(), plan.apBillSrcCode()); if (Func.isNotBlank(plan.blockedReason())) { @@ -765,6 +786,25 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl Date: Sun, 18 Oct 2026 21:31:10 +0800 Subject: [PATCH 075/108] =?UTF-8?q?feat:=20=E9=87=91=E8=9D=B6=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E5=A4=B1=E8=B4=A5=E5=BC=82=E5=B8=B8=E4=BF=A1=E6=81=AF?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E9=87=91=E8=9D=B6=E5=8D=95=E6=8D=AE=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BfiBillSaveResultItemVO 新增 getFailMark 定位信息(金蝶单据号,@JsonIgnore 不影响序列化) - commonCall 失败分支按失败分录拼入金蝶单据号,「已提交状态下的单据不允许修改」等场景可直接定位星瀚侧单据 - requireBfiSaveSuccess 单据级失败提示同步拼入 --- .../support/KingdeeApBillSyncSupport.java | 2 +- .../bfi/pojo/vo/BfiBillSaveResultItemVO.java | 13 ++++++++++ .../bfi/service/impl/BfiServiceImpl.java | 24 ++++++++++++++++++- 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeApBillSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeApBillSyncSupport.java index d120ff59..38225ca5 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeApBillSyncSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeApBillSyncSupport.java @@ -887,7 +887,7 @@ public class KingdeeApBillSyncSupport { BfiBillSaveResultItemVO item = saveResult.getResult().get(0); if (!Boolean.TRUE.equals(item.getBillStatus())) { String errorText = Func.isBlank(item.getErrorText()) ? "未知错误" : item.getErrorText(); - throw new ServiceException(action + "失败:" + errorText); + throw new ServiceException(action + "失败:" + errorText + item.getFailMark()); } } diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBillSaveResultItemVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBillSaveResultItemVO.java index 341cdca8..11424966 100644 --- a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBillSaveResultItemVO.java +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBillSaveResultItemVO.java @@ -25,6 +25,7 @@ */ package org.springblade.thirdparty.bfi.pojo.vo; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; @@ -91,4 +92,16 @@ public class BfiBillSaveResultItemVO implements Serializable { .filter(text -> !text.isEmpty()) .collect(Collectors.joining(";")); } + + /** + * 失败定位信息:金蝶单据号,供异常提示展示, + * 「已提交状态下的单据不允许修改」等场景可凭金蝶单据号直接定位星瀚侧单据 + * + * @return 定位信息文本,形如(金蝶单据:xxx),无可用信息返回空串 + */ + @JsonIgnore + public String getFailMark() { + String billNo = keys == null ? null : keys.get("billno"); + return billNo == null || billNo.isBlank() ? "" : "(金蝶单据:" + billNo + ")"; + } } diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/impl/BfiServiceImpl.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/impl/BfiServiceImpl.java index 0cc4b18c..de5f8b77 100644 --- a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/impl/BfiServiceImpl.java +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/impl/BfiServiceImpl.java @@ -79,6 +79,7 @@ import org.springblade.thirdparty.bfi.pojo.vo.BfiApBillVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiArBillVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiAttachmentUploadVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiBillLockResultVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultItemVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiBlacklistVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiClaimAnnounceVO; @@ -112,6 +113,7 @@ import java.util.List; import java.util.Optional; import java.util.UUID; import java.util.function.Supplier; +import java.util.stream.Collectors; /** * 业财融合(BFI)服务实现 @@ -655,7 +657,7 @@ public class BfiServiceImpl implements IBfiService { String message = Optional.ofNullable(result).map(BfiResultVO::getMessage).filter(StringUtil::isNotBlank) .orElseGet(() -> Optional.ofNullable(result).map(BfiResultVO::getErrorCode) .map(errorCode -> "错误码 " + errorCode).orElse("响应为空或解析失败")); - throw new BfiException("调用BFI接口" + type + "异常 " + message); + throw new BfiException("调用BFI接口" + type + "异常 " + message + billSaveFailDetail(result)); } catch (BfiException e) { log.error("调用BFI接口 {},BfiException异常", type, e); throw e; @@ -665,6 +667,26 @@ public class BfiServiceImpl implements IBfiService { } } + /** + * 单据保存失败定位后缀:取失败分录的金蝶单据号,非单据保存类调用返回空串 + * + * @param result BFI 响应 + * @param 响应数据类型 + * @return 定位后缀,形如 (金蝶单据:xxx) + */ + private String billSaveFailDetail(BfiResultVO result) { + if (result == null || !(result.getData() instanceof BfiBillSaveResultVO billSave) + || billSave.getResult() == null || billSave.getResult().isEmpty()) { + return ""; + } + String detail = billSave.getResult().stream() + .filter(item -> !Boolean.TRUE.equals(item.getBillStatus())) + .map(BfiBillSaveResultItemVO::getFailMark) + .filter(mark -> !mark.isEmpty()) + .collect(Collectors.joining(";")); + return detail.isEmpty() ? "" : " " + detail; + } + /** * 收票 rim/message 契约公共调用方法,成功错误码兼容 0 与 0000 * From 6dc6932f3960dbcb9b301e5a45c93611a38879e6 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Sun, 18 Oct 2026 22:06:59 +0800 Subject: [PATCH 076/108] =?UTF-8?q?:bug:=20=E5=A4=9A=E8=B4=A7=E7=89=A9?= =?UTF-8?q?=E8=BF=90=E5=8D=95=E7=9A=84=E8=B4=A7=E5=90=8D=E3=80=81=E6=B1=87?= =?UTF-8?q?=E6=80=BB=E4=B8=8E=E5=90=88=E8=AE=A1=E9=87=8D=E9=87=8F=E6=8C=89?= =?UTF-8?q?=E8=B4=A7=E7=89=A9=E6=98=8E=E7=BB=86=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一张运单可以录多条货物,明细落在 goods_json,主表 cargo_name / cargo_type / quantity_unit 只是首行、quantity 是各行合计。列表与详情原先只读主表字段,第二种及以后的货物整条丢失 (如 YD-20261018-0005 只显示「阿什顿」,漏掉「显卡050」),并把「20堆 + 50方」算成「70堆」。 货物明细的取法收进 WaybillCargoSupport 一处:goods_json 能解析出货物就以它为准, 解析不出再回退主表(一格多名的历史写法按同一口径摊平)。 - 运次读服务的段一补取 goods_json:卡片货名与汇总都靠它,少取就只能拿首行 - 货名、货物汇总、合计重量改按货物行算;单位不一致时合计留空,不把汇总串冒充成重量 - 站点货物条目一张子单可出多条,并带上各自的货名与数量单位 - 运次级与子单级都补 cargoNames / cargoTypes 清单,供小程序缩写与展示 --- .../pojo/vo/AdminWaybillDetailVO.java | 5 +- .../springblade/transport/pojo/vo/TripVO.java | 20 +- .../impl/DriverWaybillServiceImpl.java | 94 +++++--- .../impl/ManageWaybillServiceImpl.java | 38 ++- .../service/impl/TripQueryServiceImpl.java | 81 +++++-- .../service/impl/TripStationAssembler.java | 52 +++-- .../support/WaybillCargoSupport.java | 219 ++++++++++++++++++ .../impl/TripQueryServiceImplTest.java | 2 +- .../impl/TripReadServiceBoundaryTest.java | 22 ++ 9 files changed, 467 insertions(+), 66 deletions(-) create mode 100644 blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillCargoSupport.java 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 b799765c..ff601f14 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 @@ -66,9 +66,12 @@ public class AdminWaybillDetailVO implements Serializable { @Schema(description = "卸货地址") private String unloadAddress; - @Schema(description = "货物名称") + @Schema(description = "货物名称:运单上录入的全部货名以「、」连接") private String cargoName; + @Schema(description = "货物名称清单:运单上录入的全部货物,同名的只留一次") + private List cargoNames; + @Schema(description = "货物数量(带单位)") private String cargoQuantity; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripVO.java index 9e52e08d..e3c388a2 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripVO.java @@ -93,12 +93,18 @@ public class TripVO implements Serializable { @Schema(description = "运输类型") private String transportType; - @Schema(description = "货物名称") + @Schema(description = "货物名称:整趟车全部货名以「、」连接,一格多名的历史写法已摊平") private String cargoName; - @Schema(description = "货物类型") + @Schema(description = "货物名称清单:整趟车全部货物,同名的只留一次,保持子单与录入顺序") + private List cargoNames; + + @Schema(description = "货物类型:整趟车全部类型以「、」连接") private String cargoType; + @Schema(description = "货物类型清单:整趟车全部货物类型,同类型的只留一次") + private List cargoTypes; + @Schema(description = "发货地址") private String departureAddress; @@ -183,12 +189,18 @@ public class TripVO implements Serializable { @Schema(description = "项目名称") private String projectName; - @Schema(description = "货物名称") + @Schema(description = "货物名称:运单上录入的全部货名以「、」连接,一格多名的历史写法已摊平") private String cargoName; - @Schema(description = "货物类型") + @Schema(description = "货物名称清单:运单上录入的全部货物,同名的只留一次") + private List cargoNames; + + @Schema(description = "货物类型:主表首行的类型,完整清单见 cargoTypes") private String cargoType; + @Schema(description = "货物类型清单:运单上录入的全部货物类型,同类型的只留一次") + private List cargoTypes; + @Schema(description = "数量") private BigDecimal quantity; 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 350c0e92..59e02bb0 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 @@ -46,13 +46,14 @@ import org.springblade.transport.pojo.dto.PunchPhotoDTO; import org.springblade.transport.pojo.entity.Driver; import org.springblade.transport.pojo.entity.ExceptionDisposal; import org.springblade.transport.pojo.entity.TransportVehicle; +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.service.ITransportVehicleService; import org.springblade.transport.support.CertificationGate; import org.springblade.transport.support.LiveProcessConfigSupport; import org.springblade.transport.support.VehiclePlateSupport; -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.WaybillCargoSupport; import org.springblade.transport.pojo.vo.DriverEnrouteRecordVO; import org.springblade.transport.pojo.vo.DriverNodePunchVO; import org.springblade.transport.pojo.vo.DriverPunchNodeVO; @@ -973,9 +974,16 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { card.setToName(Func.toStrWithEmpty(waybill.getArrivalName(), Func.toStr(waybill.getArrivalAddress(), ""))); card.setFromAddress(Func.toStrWithEmpty(waybill.getDepartureAddress(), card.getFromName())); card.setToAddress(Func.toStrWithEmpty(waybill.getArrivalAddress(), card.getToName())); - card.setCargoNames(splitCargoNames(waybill.getCargoName())); - card.setCargoCategory(Func.toStr(waybill.getCargoType(), "")); - card.setWeight(formatWeight(waybill.getQuantity(), waybill.getQuantityUnit())); + // 货名取运单上录入的全部货物:主表 cargo_name 只落了第一行,直接拿它会漏掉这张票的其余货物 + List cargos = WaybillCargoSupport.of(waybill); + List cargoNames = WaybillCargoSupport.cargoNames(cargos); + card.setCargoNames(cargoNames); + card.setCargoCategory(cargos.stream() + .map(WaybillCargoSupport.Cargo::cargoType) + .filter(Func::isNotEmpty) + .distinct() + .collect(Collectors.joining("、"))); + card.setWeight(formatCargoWeight(cargos, formatWeight(waybill.getQuantity(), waybill.getQuantityUnit()))); card.setStatus(toAppStatus(waybill.getBusinessStatus())); String createTime = formatDateTime(waybill.getCreateTime()); card.setCreateTime(createTime); @@ -992,7 +1000,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { card.setRejectReason(waybill.getDriverRejectReason()); // 详情页字段(列表也可带上,体积很小) - String cargoName = Func.toStr(waybill.getCargoName(), ""); + String cargoName = String.join("、", cargoNames); String weightText = card.getWeight(); card.setCargoName(cargoName); card.setPickupAddress(card.getFromAddress()); @@ -1106,21 +1114,31 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { card.setStationCount(trip.getStationCount()); card.setPickupAddress(card.getFromAddress()); card.setUnloadAddress(card.getToAddress()); - // 货物名可能是一格里的多个名字,按既有拆分口径摊平后再去重 - List cargoNames = children.stream() - .flatMap(child -> splitCargoNames(child.getCargoName()).stream()) - .distinct() - .collect(Collectors.toList()); + // 货名取运次上算好的整趟车清单:它按子单录入的货物明细摊平去重,主表 cargo_name 只是首行。 + // 运次清单为空时按子单卡片自己的货名兜底,不让卡片上的货物行整个消失 + List cargoNames = trip.getCargoNames() == null ? List.of() : trip.getCargoNames(); + if (cargoNames.isEmpty()) { + cargoNames = children.stream() + .flatMap(child -> splitCargoNames(child.getCargoName()).stream()) + .distinct() + .collect(Collectors.toList()); + } card.setCargoNames(cargoNames); card.setCargoName(String.join("、", cargoNames)); - card.setCargoCategory(children.stream() - .map(TripVO.TripWaybillVO::getCargoType) - .filter(Func::isNotEmpty) - .distinct() - .collect(Collectors.joining("、"))); + List cargoTypes = trip.getCargoTypes() == null ? List.of() : trip.getCargoTypes(); + if (cargoTypes.isEmpty()) { + cargoTypes = children.stream() + .map(TripVO.TripWaybillVO::getCargoType) + .filter(Func::isNotEmpty) + .distinct() + .collect(Collectors.toList()); + } + card.setCargoCategory(String.join("、", cargoTypes)); card.setCargoSummary(Func.toStr(trip.getCargoSummary(), "")); + // 合计重量只在全部货物同单位时才有值;单位不一致时留空,卡片上已经由货物汇总逐条列出, + // 拿汇总串冒充重量会让「货名 + 重量」两栏把同一段文字打印两遍 String weight = trip.getTotalQuantity() == null - ? card.getCargoSummary() + ? "" : formatWeight(trip.getTotalQuantity(), trip.getQuantityUnit()); card.setWeight(weight); card.setCargoQuantity(weight); @@ -1895,16 +1913,40 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { }; } + /** + * 一格多名的历史写法(顿号、逗号分隔)摊平成货名清单,与货物明细同一个拆分口径 + */ private List splitCargoNames(String cargoName) { - if (Func.isEmpty(cargoName)) { - return Collections.emptyList(); + List names = WaybillCargoSupport.splitCargoNames(cargoName); + return names.isEmpty() ? List.of() : names; + } + + /** + * 重量文案:全部货物同单位时按货物行相加,单位不一致时不出合计 + *

+ * 主表 quantity 是各行数量的合计、quantity_unit 只是首行的单位,「20堆 + 50方」会变成「70堆」。 + * 拿 goodsJson 算不出合计(有货物没填数量或单位)时,退回主表那份汇总值——宁可显示一个来源不明的数, + * 也不要让卡片上的重量凭空消失。 + */ + private String formatCargoWeight(List cargos, String fallback) { + if (cargos.isEmpty()) { + return fallback; } - String normalized = cargoName.replace(",", ",").replace("、", ","); - List names = Func.toStrList(",", normalized).stream() - .map(String::trim) - .filter(Func::isNotEmpty) - .collect(Collectors.toList()); - return names.isEmpty() ? List.of(cargoName.trim()) : names; + String unit = null; + BigDecimal total = BigDecimal.ZERO; + for (WaybillCargoSupport.Cargo cargo : cargos) { + String cargoUnit = Func.toStr(cargo.quantityUnit(), ""); + if (cargo.quantity() == null || Func.isEmpty(cargoUnit)) { + return fallback; + } + if (unit == null) { + unit = cargoUnit; + } else if (!unit.equals(cargoUnit)) { + return ""; + } + total = total.add(cargo.quantity()); + } + return formatWeight(total, unit); } private String formatWeight(BigDecimal quantity, String unit) { 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 7cab655b..f8701641 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 @@ -60,6 +60,7 @@ import org.springblade.transport.service.IRiskDisposalService; import org.springblade.transport.service.ITripQueryService; import org.springblade.transport.service.IWaybillService; import org.springblade.transport.support.TransportBusinessSupport; +import org.springblade.transport.support.WaybillCargoSupport; import org.springblade.transport.support.WaybillProcessSupport; import org.springframework.stereotype.Service; @@ -428,6 +429,7 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { detail.setCargoSummary(Func.toStr(trip.getCargoSummary(), "")); String weight = formatWeight(trip.getTotalQuantity(), trip.getQuantityUnit()); detail.setCargoName(Func.toStr(trip.getCargoName(), "")); + detail.setCargoNames(trip.getCargoNames() == null ? List.of() : trip.getCargoNames()); detail.setCargoQuantity(weight); detail.setWeight(weight); detail.setTotalWeight(weight); @@ -757,9 +759,12 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { detail.setPickupAddress(fromAddr); detail.setUnloadAddress(toAddr); - String cargo = Func.toStr(waybill.getCargoName(), ""); - String weight = formatWeight(waybill.getQuantity(), waybill.getQuantityUnit()); - detail.setCargoName(cargo); + // 货名与合计重量取运单上录入的全部货物:主表 cargo_name 只是首行、quantity 是各行合计而单位只是首行的 + List cargos = WaybillCargoSupport.of(waybill); + List cargoNames = WaybillCargoSupport.cargoNames(cargos); + detail.setCargoName(String.join("、", cargoNames)); + detail.setCargoNames(cargoNames); + String weight = cargoWeight(cargos, formatWeight(waybill.getQuantity(), waybill.getQuantityUnit())); detail.setCargoQuantity(weight); detail.setWeight(weight); detail.setTotalWeight(weight); @@ -997,6 +1002,33 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { return Func.isBlank(unit) ? qty : qty + unit; } + /** + * 重量文案:全部货物同单位时按货物行相加,单位不一致时不出合计 + *

+ * 主表 quantity 是各行数量的合计、quantity_unit 只是首行的单位,「20堆 + 50方」会变成「70堆」。 + * 拿明细算不出合计(有货物没填数量或单位)时退回主表那份汇总值,不让重量凭空消失。 + */ + private String cargoWeight(List cargos, String fallback) { + if (cargos.isEmpty()) { + return fallback; + } + String unit = null; + BigDecimal total = BigDecimal.ZERO; + for (WaybillCargoSupport.Cargo cargo : cargos) { + String cargoUnit = Func.toStr(cargo.quantityUnit(), ""); + if (cargo.quantity() == null || Func.isEmpty(cargoUnit)) { + return fallback; + } + if (unit == null) { + unit = cargoUnit; + } else if (!unit.equals(cargoUnit)) { + return ""; + } + total = total.add(cargo.quantity()); + } + return formatWeight(total, unit); + } + private String formatLocalDate(LocalDate date) { if (date == null) { return ""; 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 d4c45658..8a831a4e 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 @@ -40,6 +40,7 @@ import org.springblade.transport.service.IProcessConfigService; import org.springblade.transport.service.ITripQueryService; import org.springblade.transport.service.IWaybillService; import org.springblade.transport.support.LiveProcessConfigSupport; +import org.springblade.transport.support.WaybillCargoSupport; import org.springblade.transport.support.WaybillProcessSupport; import org.springframework.stereotype.Service; @@ -222,13 +223,16 @@ public class TripQueryServiceImpl implements ITripQueryService { * 段一:一次取回该收口与行级筛选命中的可见运单行 *

* 只 select 拼装与分档用得上的列:运单表上有几张大 JSON 列,整行拖回不值得。 - * 可见口径与运单列表同一条:状态进运次的才取,被该司机拒单的不取。 + * goods_json 必须随行取回——卡片上的货名、货物汇总与合计重量都以它为准,一张运单可以录多条货物, + * 主表 cargo_name 只落了第一行。可见口径与运单列表同一条:状态进运次的才取,被该司机拒单的不取。 */ private List visibleRows(TripQueryVO condition) { LambdaQueryWrapper wrapper = Wrappers.lambdaQuery() .select(Waybill::getId, Waybill::getLoadingNo, Waybill::getWaybillNo, Waybill::getBusinessStatus, Waybill::getDriverAcceptStatus, Waybill::getProcessJson, Waybill::getProjectId, Waybill::getProjectName, Waybill::getCargoName, Waybill::getCargoType, Waybill::getQuantity, Waybill::getQuantityUnit, + // 多货物运单的主表字段只有首行,货物明细只能从 goods_json 拿 + Waybill::getGoodsJson, Waybill::getDepartureName, Waybill::getDepartureAddress, Waybill::getArrivalName, Waybill::getArrivalAddress, Waybill::getDriverName, Waybill::getDriverPhone, Waybill::getVehicleNo, Waybill::getCustomerName, Waybill::getTransportType, Waybill::getTaskRemark, @@ -587,21 +591,47 @@ public class TripQueryServiceImpl implements ITripQueryService { if (withPayload) { trip.setStations(stations); } + List cargos = children.stream() + .flatMap(child -> WaybillCargoSupport.of(child).stream()) + .toList(); + List cargoNames = WaybillCargoSupport.cargoNames(cargos); + // 运次级货名覆盖整趟车:主表 cargo_name 只是首行,配载单的 cargo_name 也是派单时的快照 + trip.setCargoNames(cargoNames); + if (!cargoNames.isEmpty()) { + trip.setCargoName(String.join("、", cargoNames)); + } + List cargoTypes = cargos.stream() + .map(WaybillCargoSupport.Cargo::cargoType) + .filter(Func::isNotEmpty) + .distinct() + .toList(); + trip.setCargoTypes(cargoTypes); + if (!cargoTypes.isEmpty()) { + trip.setCargoType(String.join("、", cargoTypes)); + } trip.setCargoSummary(cargoSummary(children)); fillTotalQuantity(trip, children); } /** * 货物汇总:同名货物合并且单位一致时取数量合计,没有数量的只列货名 + *

+ * 一条运单可以有多条货物,明细在 goods_json;主表 cargo_name 只落了第一行,拿它汇总等于把其余货物丢掉。 */ private String cargoSummary(List children) { Map> grouped = new LinkedHashMap<>(); for (Waybill child : children) { - String name = Func.toStrWithEmpty(child.getCargoName(), "货物"); - String unit = Func.toStr(child.getQuantityUnit(), ""); - Map byUnit = grouped.computeIfAbsent(name, key -> new LinkedHashMap<>()); - BigDecimal quantity = child.getQuantity() == null ? BigDecimal.ZERO : child.getQuantity(); - byUnit.merge(unit, quantity, BigDecimal::add); + List cargos = WaybillCargoSupport.of(child); + if (cargos.isEmpty()) { + continue; + } + for (WaybillCargoSupport.Cargo cargo : cargos) { + String name = cargo.displayName(); + String unit = Func.toStr(cargo.quantityUnit(), ""); + Map byUnit = grouped.computeIfAbsent(name, key -> new LinkedHashMap<>()); + BigDecimal quantity = cargo.quantity() == null ? BigDecimal.ZERO : cargo.quantity(); + byUnit.merge(unit, quantity, BigDecimal::add); + } } List parts = new ArrayList<>(); grouped.forEach((name, byUnit) -> byUnit.forEach((unit, quantity) -> @@ -610,24 +640,31 @@ public class TripQueryServiceImpl implements ITripQueryService { } /** - * 合计重量:只有全部子运单同单位时才能相加,单位不一致时留空(汇总文案里仍分单位列出) + * 合计重量:只有全部货物同单位时才能相加,单位不一致时留空(汇总文案里仍分单位列出) + *

+ * 按货物行算而不是按运单行算:一张运单里的多条货物可能是不同的单位与数量, + * 主表 quantity 是这些行的合计、quantity_unit 只是首行的单位,拿它当合计会把「20堆 + 50方」说成「70堆」。 */ private void fillTotalQuantity(TripVO trip, List children) { String unit = null; BigDecimal total = BigDecimal.ZERO; + boolean any = false; for (Waybill child : children) { - String childUnit = Func.toStr(child.getQuantityUnit(), ""); - if (child.getQuantity() == null || Func.isEmpty(childUnit)) { - return; + for (WaybillCargoSupport.Cargo cargo : WaybillCargoSupport.of(child)) { + String cargoUnit = Func.toStr(cargo.quantityUnit(), ""); + if (cargo.quantity() == null || Func.isEmpty(cargoUnit)) { + return; + } + if (unit == null) { + unit = cargoUnit; + } else if (!unit.equals(cargoUnit)) { + return; + } + total = total.add(cargo.quantity()); + any = true; } - if (unit == null) { - unit = childUnit; - } else if (!unit.equals(childUnit)) { - return; - } - total = total.add(child.getQuantity()); } - if (unit == null) { + if (!any || unit == null) { return; } trip.setTotalQuantity(total); @@ -648,7 +685,15 @@ public class TripQueryServiceImpl implements ITripQueryService { child.setRequireAccept(WaybillProcessSupport.requiresDriverAcceptConfirmation(waybill.getProcessJson())); child.setProjectId(waybill.getProjectId()); child.setProjectName(waybill.getProjectName()); - child.setCargoName(waybill.getCargoName()); + // 货名按运单上实际录入的货物给全:主表 cargo_name 只是首行,多货物运单会漏掉其余货物 + List cargos = WaybillCargoSupport.of(waybill); + child.setCargoName(String.join("、", WaybillCargoSupport.cargoNames(cargos))); + child.setCargoNames(WaybillCargoSupport.cargoNames(cargos)); + child.setCargoTypes(cargos.stream() + .map(WaybillCargoSupport.Cargo::cargoType) + .filter(Func::isNotEmpty) + .distinct() + .toList()); child.setCargoType(waybill.getCargoType()); child.setQuantity(waybill.getQuantity()); child.setQuantityUnit(waybill.getQuantityUnit()); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripStationAssembler.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripStationAssembler.java index 62735c22..134e2a34 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripStationAssembler.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripStationAssembler.java @@ -27,6 +27,7 @@ import org.springblade.core.tool.utils.Func; import org.springblade.transport.pojo.entity.LoadingManage; import org.springblade.transport.pojo.entity.Waybill; import org.springblade.transport.pojo.vo.TripStationVO; +import org.springblade.transport.support.WaybillCargoSupport; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -238,26 +239,51 @@ final class TripStationAssembler { return byId.get(waybillIds.get(order - 1)); } + /** + * 一条子运单在该站的货物条目:运单上录了几条货物就出几条,同一张运单的多条货物不能被折成一行, + * 否则站点上看不出这票到底装/卸了哪几种货。去重键含货名,站点标记重复出现时不会重复列出。 + *

+ * 一条货物都没录的运单仍出一行(货名为空):站点上的「这站有这张票」由这一行承载, + * 只按货名判断有没有货物会让没填货名的单在任务安排里凭空消失。 + */ private static void addCargo(List cargos, Waybill child, String operation) { if (child == null) { return; } String waybillId = child.getId() == null ? null : String.valueOf(child.getId()); - boolean exists = cargos.stream().anyMatch(cargo -> - Objects.equals(cargo.getWaybillId(), waybillId) && Objects.equals(cargo.getOperation(), operation)); - if (exists) { + List items = WaybillCargoSupport.of(child); + if (items.isEmpty()) { + // 一条货物都没录:站点条目仍要有这一行,否则任务安排里这票凭空消失 + TripStationVO.TripCargoVO cargo = new TripStationVO.TripCargoVO(); + cargo.setWaybillId(waybillId); + cargo.setWaybillNo(child.getWaybillNo()); + cargo.setCargoName(""); + cargo.setCustomerName(child.getCustomerName()); + cargo.setOperation(operation); + cargo.setProjectName(child.getProjectName()); + cargos.add(cargo); return; } - TripStationVO.TripCargoVO cargo = new TripStationVO.TripCargoVO(); - cargo.setWaybillId(waybillId); - cargo.setWaybillNo(child.getWaybillNo()); - cargo.setCargoName(child.getCargoName()); - cargo.setCustomerName(child.getCustomerName()); - cargo.setQuantity(child.getQuantity()); - cargo.setQuantityUnit(child.getQuantityUnit()); - cargo.setOperation(operation); - cargo.setProjectName(child.getProjectName()); - cargos.add(cargo); + for (WaybillCargoSupport.Cargo item : items) { + String cargoName = item.displayName(); + boolean exists = cargos.stream().anyMatch(cargo -> + Objects.equals(cargo.getWaybillId(), waybillId) + && Objects.equals(cargo.getOperation(), operation) + && Objects.equals(cargo.getCargoName(), cargoName)); + if (exists) { + continue; + } + TripStationVO.TripCargoVO cargo = new TripStationVO.TripCargoVO(); + cargo.setWaybillId(waybillId); + cargo.setWaybillNo(child.getWaybillNo()); + cargo.setCargoName(cargoName); + cargo.setCustomerName(child.getCustomerName()); + cargo.setQuantity(item.quantity()); + cargo.setQuantityUnit(item.quantityUnit()); + cargo.setOperation(operation); + cargo.setProjectName(child.getProjectName()); + cargos.add(cargo); + } } private static void fillOperation(TripStationVO station, boolean load, boolean unload) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillCargoSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillCargoSupport.java new file mode 100644 index 00000000..afe71bc6 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillCargoSupport.java @@ -0,0 +1,219 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

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

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

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

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

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

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.support; + +import org.springblade.core.tool.jackson.JsonUtil; +import org.springblade.core.tool.utils.Func; +import org.springblade.transport.pojo.entity.Waybill; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * 运单货物明细的取法,只此一处。 + *

+ * 一张运单可以录多条货物,明细落在 {@code goods_json};主表的 {@code cargo_name} / {@code cargo_type} / + * {@code quantity} 只是给列表与报表用的首行汇总(数量是各行合计),拿它当货物清单会把其余货物丢掉。 + * 所以「这张运单装了什么」一律以 goods_json 为准,主表字段只在 goods_json 缺失或解析不出任何货名时兜底。 + *

+ * 货名也可能是主表里一格多名的历史写法(顿号、逗号分隔),按同一口径摊平。 + */ +public final class WaybillCargoSupport { + + /** 货物没有填名称时的展示名:宁可让司机看到「货物」,也不要出现空行 */ + private static final String FALLBACK_NAME = "货物"; + + /** 货名分隔符:历史数据里顿号、中英文逗号都出现过 */ + private static final String NAME_SEPARATOR_REGEX = "[、,,]"; + + /** 明细里可能承载数量的字段,按优先级取第一个有值的 */ + private static final String[] QUANTITY_KEYS = {"quantity", "cargoQuantity", "goodsQuantity"}; + + /** 明细里可能承载单位的字段,按优先级取第一个有值的 */ + private static final String[] UNIT_KEYS = {"quantityUnit", "goodsQuantityUnit", "unit"}; + + /** 明细里可能承载名称的字段,按优先级取第一个有值的 */ + private static final String[] NAME_KEYS = {"cargoName", "goodsName"}; + + /** 明细里可能承载类型的字段,按优先级取第一个有值的 */ + private static final String[] TYPE_KEYS = {"cargoType", "goodsType"}; + + private WaybillCargoSupport() { + } + + /** + * 一张运单的货物条目:goods_json 能解析出货物就用它,否则回退到主表的货物字段。 + *

+ * 每条明细至少出一条:货名、数量都为空的行也保留,让「这张票录了一条空货物」与「这条货物没填数量」可区分。 + */ + public static List of(Waybill waybill) { + if (waybill == null) { + return List.of(); + } + List detail = parseDetail(waybill.getGoodsJson()); + if (!detail.isEmpty()) { + return detail; + } + return fallback(waybill); + } + + /** + * 货名清单:同名的只留一次,保持录入顺序;档口与报表需要横向摊平时用它 + */ + public static List cargoNames(List cargos) { + List names = new ArrayList<>(); + for (Cargo cargo : cargos) { + String name = Func.toStr(cargo.cargoName(), "").trim(); + if (Func.isNotEmpty(name) && !names.contains(name)) { + names.add(name); + } + } + return names; + } + + /** + * 解析 goods_json 为货物条目;字段缺失、JSON 非法或解析不出任何条目都返回空集合,由调用方兜底 + */ + private static List parseDetail(String goodsJson) { + if (Func.isEmpty(goodsJson)) { + return List.of(); + } + List rows; + try { + rows = JsonUtil.parseArray(goodsJson, Map.class); + } catch (Exception exception) { + // 历史脏数据:JSON 坏了就当没有明细,回退主表字段,不让列表与详情因一条脏数据打不开 + return List.of(); + } + if (Func.isEmpty(rows)) { + return List.of(); + } + List cargos = new ArrayList<>(rows.size()); + for (Map row : rows) { + if (row == null) { + continue; + } + cargos.add(new Cargo( + firstText(row, NAME_KEYS), + firstText(row, TYPE_KEYS), + quantityOf(row), + firstText(row, UNIT_KEYS))); + } + return cargos; + } + + /** + * 回退:主表货名按历史分隔符摊平,每一段配同一个数量与单位(主表数量是各行合计,摊到哪一段都不准, + * 故只给一段配数量;多段说明主表数量不可拆分,宁可只展示货名也不重复同一份合计) + */ + private static List fallback(Waybill waybill) { + List names = splitCargoNames(waybill.getCargoName()); + if (names.isEmpty()) { + return List.of(); + } + List cargos = new ArrayList<>(names.size()); + boolean first = true; + for (String name : names) { + cargos.add(new Cargo( + name, + Func.toStr(waybill.getCargoType(), ""), + first ? waybill.getQuantity() : null, + first ? Func.toStr(waybill.getQuantityUnit(), "") : "")); + first = false; + } + return cargos; + } + + /** + * 主表货名按顿号、逗号摊平;空格与空段去掉,全空时给空集合 + */ + public static List splitCargoNames(String cargoName) { + if (Func.isEmpty(cargoName)) { + return List.of(); + } + List names = new ArrayList<>(); + for (String part : cargoName.split(NAME_SEPARATOR_REGEX)) { + String name = part.trim(); + if (Func.isNotEmpty(name)) { + names.add(name); + } + } + return names; + } + + private static String firstText(Map row, String... keys) { + for (String key : keys) { + Object value = row.get(key); + if (value == null) { + continue; + } + String text = String.valueOf(value).trim(); + if (Func.isNotEmpty(text)) { + return text; + } + } + return ""; + } + + private static BigDecimal quantityOf(Map row) { + for (String key : QUANTITY_KEYS) { + Object value = row.get(key); + if (value == null) { + continue; + } + String text = String.valueOf(value).trim(); + if (Func.isEmpty(text)) { + continue; + } + try { + BigDecimal quantity = new BigDecimal(text); + // 录入端用 -1 表示「未填」,落到汇总里会把合计算成负数 + if (quantity.compareTo(BigDecimal.ONE.negate()) == 0) { + continue; + } + return quantity; + } catch (NumberFormatException exception) { + // 这一字段不是数字:继续试下一个候选字段 + } + } + return null; + } + + /** + * 运单上的一条货物 + *

+ * 数量与单位可能为空:录货物时允许只填名称。展示端要把「没填数量」与「数量为 0」区分开。 + */ + public record Cargo(String cargoName, String cargoType, BigDecimal quantity, String quantityUnit) { + + /** + * 展示用货名:空名称回退成「货物」 + */ + public String displayName() { + return Func.toStrWithEmpty(cargoName, FALLBACK_NAME); + } + } + +} 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 7c1a9420..8679be08 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 @@ -116,7 +116,7 @@ class TripQueryServiceImplTest { assertTrue(select.contains("estimated_start_time"), "未配载运次的计划时间取自运单,必须取:" + select); assertTrue(select.contains("estimated_end_time"), select); assertFalse(select.contains("route_json"), "运单表上的大 JSON 列不必拖回:" + select); - assertFalse(select.contains("goods_json"), select); + assertTrue(select.contains("goods_json"), "货物明细只能从 goods_json 拿,必须随行取回:" + select); assertFalse(select.contains("task_info_json"), select); assertFalse(select.contains(String.valueOf((Object) null)) && select.contains("*"), "不得整行拖回:" + select); } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripReadServiceBoundaryTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripReadServiceBoundaryTest.java index 0f194a94..b4aac8d2 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripReadServiceBoundaryTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripReadServiceBoundaryTest.java @@ -287,6 +287,28 @@ class TripReadServiceBoundaryTest { assertEquals("吨", trip.getQuantityUnit()); } + @Test + @DisplayName("一张运单录了多条货物时,货名与汇总都要全带上,不能只取主表首行") + void multiCargoWaybillKeepsEveryCargo() { + Waybill multi = child(CHILD_A, "running", null, ACCEPT_NO, at(1787657033000L)); + // 主表只落了首行:货名与单位都是第一行的,数量却是两行的合计 + multi.setCargoName("阿什顿"); + multi.setCargoType("服饰"); + multi.setQuantity(new BigDecimal("70.00")); + multi.setQuantityUnit("堆"); + multi.setGoodsJson("[{\"cargoName\":\"阿什顿\",\"cargoType\":\"服饰\",\"quantity\":\"20\",\"quantityUnit\":\"堆\"}," + + "{\"cargoName\":\"显卡050\",\"cargoType\":\"显卡\",\"quantity\":\"50\",\"quantityUnit\":\"方\"}]"); + stubLoadedTrip(multi); + + TripVO trip = onlyTrip(); + + assertEquals(List.of("阿什顿", "显卡050"), trip.getCargoNames(), "两种货物都要在,不能只剩主表首行"); + assertEquals(List.of("阿什顿", "显卡050"), trip.getWaybills().get(0).getCargoNames(), "子单也要带上完整货名"); + assertEquals("阿什顿 20堆、显卡050 50方", trip.getCargoSummary()); + assertNull(trip.getTotalQuantity(), "两种货物单位不同,不能把 20堆+50方 说成 70堆"); + assertNull(trip.getQuantityUnit()); + } + // ------------------------------------------------------------ 夹具 @SuppressWarnings("unchecked") From 74bc8c844d02d219fe6f11e903b9fdbcaea1f227 Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Sun, 18 Oct 2026 22:20:17 +0800 Subject: [PATCH 077/108] =?UTF-8?q?:bug:=20=E4=BF=AE=E5=A4=8D=E5=8D=95?= =?UTF-8?q?=E6=8D=AE=E5=8F=B7=E5=8F=96=E5=8F=B7=E5=BF=BD=E7=95=A5=E9=80=BB?= =?UTF-8?q?=E8=BE=91=E5=88=A0=E9=99=A4=EF=BC=8C=E5=B9=B6=E4=BC=98=E5=8C=96?= =?UTF-8?q?OA=E4=BA=BA=E5=91=98=E7=BB=84=E7=BB=87=E7=BB=91=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 人员同步改为绑定已有组织并归并旁路存量;付款/开票/结算/对账等取号按最大流水含已删数据;运单导入数量单位改读计量单位主数据。 Co-authored-by: Cursor --- .../service/impl/OASyncServiceImpl.java | 4 +- .../service/impl/OaUserListSyncHelper.java | 518 +++++++++++------- .../transport/mapper/BillPaymentMapper.java | 11 + .../mapper/FormalSettlementMapper.java | 9 + .../mapper/FormalSettlementPaymentMapper.java | 11 + .../mapper/InvoiceApplicationMapper.java | 11 + .../mapper/PaymentApplicationMapper.java | 11 + .../mapper/PreSettlementAdvanceMapper.java | 10 + .../transport/mapper/PreSettlementMapper.java | 18 + .../mapper/SettlementAdjustmentMapper.java | 14 +- .../mapper/TransportReconciliationMapper.java | 9 + .../service/impl/BillPaymentServiceImpl.java | 5 +- .../impl/FormalSettlementServiceImpl.java | 23 +- .../impl/InvoiceApplicationServiceImpl.java | 7 +- .../impl/PaymentApplicationServiceImpl.java | 9 +- .../impl/PreSettlementServiceImpl.java | 42 +- .../impl/SettlementAdjustmentServiceImpl.java | 7 +- .../TransportReconciliationServiceImpl.java | 18 +- .../impl/WaybillImportBatchServiceImpl.java | 33 +- 19 files changed, 499 insertions(+), 271 deletions(-) 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 6f3143a4..e133b6c5 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 @@ -263,7 +263,7 @@ public class OASyncServiceImpl implements IOASyncService { } /** - * 从 OA 人员列表同步组织与人员 + * 从 OA 人员列表同步人员并绑定已有组织(不维护组织树,须先同步组织架构) * * @param startTime 增量查询开始时间,为空则全量 * @return 处理的人员数量 @@ -284,7 +284,7 @@ public class OASyncServiceImpl implements IOASyncService { } /** - * 按页从 OA 人员列表同步组织与人员 + * 按页从 OA 人员列表同步人员并绑定已有组织(不维护组织树,须先同步组织架构) * * @param current 当前页 * @param size 每页条数 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 77ba09d0..87e3cd50 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,7 +30,6 @@ 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.DataStatusEnum; import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.constant.BladeConstant; import org.springblade.core.tool.utils.DigestUtil; @@ -53,7 +52,6 @@ import org.springframework.stereotype.Component; import java.util.ArrayList; import java.util.Collection; -import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; @@ -66,10 +64,9 @@ import java.util.stream.Collectors; import static org.springblade.common.constant.CommonConstant.DEFAULT_PARAM_PASSWORD; import static org.springblade.common.constant.CommonConstant.DEFAULT_PARAM_ROLE; import static org.springblade.common.constant.CommonConstant.DEFAULT_ROLE; -import static org.springblade.common.constant.CommonConstant.YES; /** - * 从 OA 人员列表提取组织并同步人员 + * OA 人员同步:绑定已有组织机构,不维护组织树 * * @author Chill */ @@ -86,104 +83,312 @@ public class OaUserListSyncHelper { private final IRoleService roleService; /** - * 从人员数据提取二级公司、三级部门,并挂到集团顶级(id=1/GWWL) 下 + * 加载已有组织索引,并将人员旁路产生的 OAC/OAD 存量归并到正式组织。 + *

人员同步不再新建/更新组织树,须先执行组织架构同步。

* * @param oaPersons OA人员 - * @return 组织索引 + * @return 组织索引(仅含已有组织) */ public OaOrgIndex syncOrgsFromPersons(List oaPersons) { OaOrgIndex orgIndex = new OaOrgIndex(); if (CollectionUtil.isEmpty(oaPersons)) { return orgIndex; } - Date orgSyncStart = new Date(); - Dept rootCompany = this.getOrCreateRootCompany(); - String tenantId = resolveTenantId(); - Map companyPersonMap = new LinkedHashMap<>(); - Map departmentPersonMap = new LinkedHashMap<>(); - for (OAPersonResponse oaPerson : oaPersons) { - String companyKey = resolveCompanyKey(oaPerson); - if (StringUtils.isNotBlank(companyKey) && StringUtils.isNotBlank(oaPerson.getSubcompanyname())) { - companyPersonMap.putIfAbsent(companyKey, oaPerson); - } - String departmentKey = resolveDepartmentKey(oaPerson); - if (StringUtils.isNotBlank(departmentKey) && StringUtils.isNotBlank(oaPerson.getDepartmentname()) - && StringUtils.isNotBlank(oaPerson.getSubcompanyname())) { - departmentPersonMap.putIfAbsent(departmentKey, oaPerson); - } - } - - Map existingCompanyByCode = new HashMap<>(); - Map existingCompanyByName = new HashMap<>(); - deptService.list(Wrappers.lambdaQuery() - .eq(Dept::getParentId, rootCompany.getId()) - .eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode()) - ).forEach(dept -> { - if (StringUtils.isNotBlank(dept.getDeptCode())) { - existingCompanyByCode.put(dept.getDeptCode(), dept); - } - if (StringUtils.isNotBlank(dept.getDeptName())) { - existingCompanyByName.put(dept.getDeptName(), dept); - } - }); - List addCompanies = new ArrayList<>(); - List updateCompanies = new ArrayList<>(); - companyPersonMap.forEach((companyKey, oaPerson) -> { - String oaCode = buildCompanyCode(oaPerson, companyKey); - Dept existing = existingCompanyByCode.get(oaCode); - if (existing == null) { - existing = existingCompanyByName.get(oaPerson.getSubcompanyname()); - } - Dept company = this.upsertOrg(oaPerson.getSubcompanyname(), oaCode, existing, rootCompany, tenantId, - DeptCategory.COMPANY, addCompanies, updateCompanies); - orgIndex.companyByOaId.put(companyKey, company); - }); - this.saveOrgs(addCompanies, updateCompanies, DeptCategory.COMPANY); - - Map existingDeptByCode = new HashMap<>(); - Map existingDeptByParentAndName = new HashMap<>(); - List companyIds = orgIndex.companyByOaId.values().stream() - .map(Dept::getId) - .filter(Objects::nonNull) - .distinct() - .toList(); - if (CollectionUtil.isNotEmpty(companyIds)) { - deptService.list(Wrappers.lambdaQuery() - .in(Dept::getParentId, companyIds) - .eq(Dept::getDeptCategory, DeptCategory.DEPT.getCode()) - ).forEach(dept -> { - if (StringUtils.isNotBlank(dept.getDeptCode())) { - existingDeptByCode.put(dept.getDeptCode(), dept); - } - if (dept.getParentId() != null && StringUtils.isNotBlank(dept.getDeptName())) { - existingDeptByParentAndName.put(dept.getParentId() + "#" + dept.getDeptName(), dept); - } - }); - } - List addDepartments = new ArrayList<>(); - List updateDepartments = new ArrayList<>(); - departmentPersonMap.forEach((departmentKey, oaPerson) -> { - Dept parentCompany = orgIndex.findCompany(oaPerson); - if (parentCompany == null || parentCompany.getId() == null) { - return; - } - String oaCode = buildDepartmentCode(oaPerson, departmentKey); - Dept existing = existingDeptByCode.get(oaCode); - if (existing == null) { - existing = existingDeptByParentAndName.get(parentCompany.getId() + "#" + oaPerson.getDepartmentname()); - } - Dept department = this.upsertOrg(oaPerson.getDepartmentname(), oaCode, existing, parentCompany, tenantId, - DeptCategory.DEPT, addDepartments, updateDepartments); - orgIndex.deptByOaId.put(departmentKey, department); - orgIndex.deptByCompanyAndName.put(resolveCompanyKey(oaPerson) + "#" + oaPerson.getDepartmentname(), department); - }); - this.saveOrgs(addDepartments, updateDepartments, DeptCategory.DEPT); - deptService.updateAncestors(orgSyncStart); - ComposeLogUtil.getLastLog().info("同步人员提取组织完成,二级公司{}个,三级部门{}个", + this.repairPersonBypassOrgs(); + this.loadExistingOrgIndex(orgIndex); + ComposeLogUtil.getLastLog().info("人员同步绑定已有组织,二级公司{}个,三级部门{}个", orgIndex.companyByOaId.size(), orgIndex.deptByOaId.size()); return orgIndex; } + /** + * 将 OAC/OAD 旁路节点上的人员关系迁移到同 oa_id 的正式组织,并逻辑删除旁路节点 + */ + private void repairPersonBypassOrgs() { + List companyList = deptService.list(Wrappers.lambdaQuery() + .eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode())); + List departmentList = deptService.list(Wrappers.lambdaQuery() + .eq(Dept::getDeptCategory, DeptCategory.DEPT.getCode())); + Map officialCompanyByOaId = new HashMap<>(); + Map officialDeptByOaId = new HashMap<>(); + Map companyById = new HashMap<>(); + for (Dept company : companyList) { + if (company.getId() != null) { + companyById.put(company.getId(), company); + } + if (!isPersonBypassCode(company.getDeptCode(), DeptCategory.COMPANY)) { + this.putPreferredByOaId(officialCompanyByOaId, company, DeptCategory.COMPANY); + } + } + for (Dept department : departmentList) { + if (!isPersonBypassCode(department.getDeptCode(), DeptCategory.DEPT)) { + this.putPreferredByOaId(officialDeptByOaId, department, DeptCategory.DEPT); + } + } + + Set softDeleteIds = new HashSet<>(); + Set affectedUserIds = new HashSet<>(); + int migratedDeptBindingCount = 0; + int migratedCompanyBindingCount = 0; + + for (Dept bypass : departmentList) { + if (!isPersonBypassCode(bypass.getDeptCode(), DeptCategory.DEPT) || bypass.getId() == null) { + continue; + } + String oaId = StringUtils.trimToNull(bypass.getOaId()); + if (oaId == null) { + oaId = extractOaIdFromBypassCode(bypass.getDeptCode(), DeptCategory.DEPT); + } + if (oaId == null) { + continue; + } + Dept official = officialDeptByOaId.get(oaId); + if (official == null || official.getId() == null || Objects.equals(official.getId(), bypass.getId()) + || isPersonBypassCode(official.getDeptCode(), DeptCategory.DEPT)) { + continue; + } + Dept officialCompany = resolveBelongCompany(official, companyById); + migratedDeptBindingCount += this.migrateUserDeptBindings(bypass, official, officialCompany, affectedUserIds); + softDeleteIds.add(bypass.getId()); + } + + for (Dept bypass : companyList) { + if (!isPersonBypassCode(bypass.getDeptCode(), DeptCategory.COMPANY) || bypass.getId() == null) { + continue; + } + String oaId = StringUtils.trimToNull(bypass.getOaId()); + if (oaId == null) { + oaId = extractOaIdFromBypassCode(bypass.getDeptCode(), DeptCategory.COMPANY); + } + if (oaId == null) { + continue; + } + Dept official = officialCompanyByOaId.get(oaId); + if (official == null || official.getId() == null || Objects.equals(official.getId(), bypass.getId()) + || isPersonBypassCode(official.getDeptCode(), DeptCategory.COMPANY)) { + continue; + } + migratedCompanyBindingCount += this.migrateUserCompanyBindings(bypass, official, affectedUserIds); + long childCount = deptService.count(Wrappers.lambdaQuery().eq(Dept::getParentId, bypass.getId())); + if (childCount == 0) { + softDeleteIds.add(bypass.getId()); + } else { + ComposeLogUtil.getLastLog().warn("旁路公司仍有子节点,暂不删除 companyId={} deptCode={} childCount={}", + bypass.getId(), bypass.getDeptCode(), childCount); + } + } + + if (CollectionUtil.isNotEmpty(softDeleteIds)) { + deptService.removeByIds(softDeleteIds); + ComposeLogUtil.getLastLog().info("人员同步归并旁路组织完成,迁移部门关系{}条,迁移公司关系{}条,逻辑删除旁路节点{}个", + migratedDeptBindingCount, migratedCompanyBindingCount, softDeleteIds.size()); + } else if (migratedDeptBindingCount > 0 || migratedCompanyBindingCount > 0) { + ComposeLogUtil.getLastLog().info("人员同步归并旁路组织完成,迁移部门关系{}条,迁移公司关系{}条", + migratedDeptBindingCount, migratedCompanyBindingCount); + } + this.refreshUserDeptFields(affectedUserIds); + } + + /** + * 仅加载已有组织,按 oa_id / 名称建立索引供人员绑定 + */ + private void loadExistingOrgIndex(OaOrgIndex orgIndex) { + Map companyByOaId = new HashMap<>(); + Map companyByName = new HashMap<>(); + Map companyById = new HashMap<>(); + deptService.list(Wrappers.lambdaQuery() + .eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode()) + ).forEach(dept -> { + if (dept.getId() != null) { + companyById.put(dept.getId(), dept); + } + this.putPreferredByOaKey(companyByOaId, resolveOrgOaKey(dept, DeptCategory.COMPANY), dept, DeptCategory.COMPANY); + if (StringUtils.isNotBlank(dept.getDeptName())) { + Dept previous = companyByName.get(dept.getDeptName()); + if (previous == null + || (isPersonBypassCode(previous.getDeptCode(), DeptCategory.COMPANY) + && !isPersonBypassCode(dept.getDeptCode(), DeptCategory.COMPANY))) { + companyByName.put(dept.getDeptName(), dept); + } + } + }); + companyByOaId.forEach((oaId, dept) -> orgIndex.companyByOaId.put(oaId, dept)); + companyByName.forEach((name, dept) -> orgIndex.companyByOaId.putIfAbsent("NAME:" + name, dept)); + + Map deptByOaId = new HashMap<>(); + deptService.list(Wrappers.lambdaQuery() + .eq(Dept::getDeptCategory, DeptCategory.DEPT.getCode()) + ).forEach(dept -> { + this.putPreferredByOaKey(deptByOaId, resolveOrgOaKey(dept, DeptCategory.DEPT), dept, DeptCategory.DEPT); + Dept parentCompany = resolveBelongCompany(dept, companyById); + if (parentCompany == null || StringUtils.isBlank(dept.getDeptName())) { + return; + } + String companyKey = resolveOrgOaKey(parentCompany, DeptCategory.COMPANY); + if (companyKey == null && StringUtils.isNotBlank(parentCompany.getDeptName())) { + companyKey = "NAME:" + parentCompany.getDeptName(); + } + if (companyKey == null) { + return; + } + String nameKey = companyKey + "#" + dept.getDeptName(); + Dept previous = orgIndex.deptByCompanyAndName.get(nameKey); + if (previous == null + || (isPersonBypassCode(previous.getDeptCode(), DeptCategory.DEPT) + && !isPersonBypassCode(dept.getDeptCode(), DeptCategory.DEPT))) { + orgIndex.deptByCompanyAndName.put(nameKey, dept); + } + }); + orgIndex.deptByOaId.putAll(deptByOaId); + } + + /** 优先取 oa_id;旁路空 oa_id 时从 OAC/OAD 编码后缀解析 */ + private String resolveOrgOaKey(Dept dept, DeptCategory deptCategory) { + if (dept == null) { + return null; + } + String oaId = StringUtils.trimToNull(dept.getOaId()); + if (oaId != null) { + return oaId; + } + return extractOaIdFromBypassCode(dept.getDeptCode(), deptCategory); + } + + private int migrateUserDeptBindings(Dept bypass, Dept official, Dept officialCompany, Set affectedUserIds) { + List bindingList = userDeptService.list(Wrappers.lambdaQuery() + .and(wrapper -> wrapper.eq(UserDept::getDeptId, bypass.getId()) + .or().eq(UserDept::getDeptCode, bypass.getDeptCode()))); + if (CollectionUtil.isEmpty(bindingList)) { + return 0; + } + Set userIds = bindingList.stream() + .map(UserDept::getUserId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + Map existingByKey = userDeptService.list(Wrappers.lambdaQuery() + .in(UserDept::getUserId, userIds) + ).stream() + .collect(Collectors.toMap(this::getUserDeptKey, userDept -> userDept, (first, second) -> first)); + List updateList = new ArrayList<>(); + List removeIds = new ArrayList<>(); + for (UserDept binding : bindingList) { + if (binding.getUserId() == null) { + continue; + } + affectedUserIds.add(binding.getUserId()); + String companyCode = officialCompany != null ? officialCompany.getDeptCode() : binding.getCompanyCode(); + String companyName = officialCompany != null ? officialCompany.getDeptName() : binding.getCompanyName(); + String newKey = binding.getUserId() + StringUtils.defaultString(companyCode) + + StringUtils.defaultString(official.getDeptCode()); + UserDept alreadyOfficial = existingByKey.get(newKey); + if (alreadyOfficial != null && !Objects.equals(alreadyOfficial.getId(), binding.getId())) { + removeIds.add(binding.getId()); + continue; + } + binding.setDeptId(official.getId()); + binding.setDeptCode(official.getDeptCode()); + binding.setDeptName(official.getDeptName()); + if (officialCompany != null) { + binding.setCompanyCode(companyCode); + binding.setCompanyName(companyName); + } + updateList.add(binding); + existingByKey.put(newKey, binding); + } + if (CollectionUtil.isNotEmpty(updateList)) { + userDeptService.updateBatchById(updateList); + } + if (CollectionUtil.isNotEmpty(removeIds)) { + userDeptService.removeByIds(removeIds); + } + return updateList.size() + removeIds.size(); + } + + private int migrateUserCompanyBindings(Dept bypass, Dept official, Set affectedUserIds) { + if (StringUtils.isBlank(bypass.getDeptCode())) { + return 0; + } + List bindingList = userDeptService.list(Wrappers.lambdaQuery() + .eq(UserDept::getCompanyCode, bypass.getDeptCode())); + if (CollectionUtil.isEmpty(bindingList)) { + return 0; + } + List updateList = new ArrayList<>(); + for (UserDept binding : bindingList) { + if (binding.getUserId() != null) { + affectedUserIds.add(binding.getUserId()); + } + binding.setCompanyCode(official.getDeptCode()); + binding.setCompanyName(official.getDeptName()); + updateList.add(binding); + } + userDeptService.updateBatchById(updateList); + return updateList.size(); + } + + private void refreshUserDeptFields(Set userIds) { + if (CollectionUtil.isEmpty(userIds)) { + return; + } + List userDeptIdsList = userDeptService.queryUserDeptIds(userIds); + List updateUserParams = userDeptIdsList.stream() + .filter(userDeptIds -> StringUtils.isNotBlank(userDeptIds.getDeptIds())) + .filter(userDeptIds -> { + int deptIdLength = userDeptIds.getDeptIds().length(); + int deptCodesLength = StringUtils.length(userDeptIds.getDeptCodes()); + return deptIdLength <= MAX_DEPT_ID_LENGTH && deptCodesLength <= MAX_DEPT_ID_LENGTH; + }) + .map(userDeptIds -> { + User user = new User(); + user.setId(userDeptIds.getUserId()); + user.setDeptId(userDeptIds.getDeptIds()); + user.setDeptCodes(userDeptIds.getDeptCodes()); + return user; + }).toList(); + if (CollectionUtil.isNotEmpty(updateUserParams)) { + userService.updateBatchById(updateUserParams); + } + } + + private Dept resolveBelongCompany(Dept department, Map companyById) { + if (department == null) { + return null; + } + if (department.getParentId() != null) { + Dept parent = companyById.get(department.getParentId()); + if (parent != null) { + return parent; + } + } + if (StringUtils.isNotBlank(department.getBelongCompanyCode())) { + return companyById.values().stream() + .filter(company -> department.getBelongCompanyCode().equals(company.getDeptCode())) + .findFirst() + .orElse(null); + } + return null; + } + + /** + * 从 OAC37 / OAD37 旁路编码解析 OA 主键;哈希旁路(OACNxxx)返回 null + */ + private String extractOaIdFromBypassCode(String deptCode, DeptCategory deptCategory) { + if (StringUtils.isBlank(deptCode) || !isPersonBypassCode(deptCode, deptCategory)) { + return null; + } + String prefix = DeptCategory.COMPANY.equals(deptCategory) + ? OAConvertConstant.COMPANY_OA_PREFIX + : OAConvertConstant.DEPARTMENT_OA_PREFIX; + if (deptCode.length() <= prefix.length()) { + return null; + } + String suffix = deptCode.substring(prefix.length()); + if (suffix.isEmpty() || suffix.startsWith("N")) { + return null; + } + return suffix; + } + /** * 同步人员并绑定到三级部门 * @@ -395,73 +600,47 @@ public class OaUserListSyncHelper { return localModified.equals(remoteModified); } - private Dept getOrCreateRootCompany() { - return deptService.ensureOaSyncRootDept(); + private boolean isPersonBypassCode(String deptCode, DeptCategory deptCategory) { + if (StringUtils.isBlank(deptCode)) { + return true; + } + if (DeptCategory.COMPANY.equals(deptCategory)) { + return deptCode.startsWith(OAConvertConstant.COMPANY_OA_PREFIX); + } + return deptCode.startsWith(OAConvertConstant.DEPARTMENT_OA_PREFIX); } - private Dept upsertOrg(String name, String oaCode, Dept existing, Dept parent, String tenantId, - DeptCategory deptCategory, List addList, List updateList) { - if (existing != null) { - Dept updateParam = new Dept(); - updateParam.setId(existing.getId()); - updateParam.setDeptName(name); - updateParam.setFullName(name); - updateParam.setShortName(name); - updateParam.setParentId(parent.getId()); - updateParam.setParentCode(parent.getDeptCode()); - updateParam.setAncestors(buildAncestors(parent)); - updateParam.setIsOa(YES); - updateParam.setSyncTime(new Date()); - updateList.add(updateParam); - existing.setDeptName(name); - existing.setFullName(name); - existing.setShortName(name); - existing.setParentId(parent.getId()); - existing.setParentCode(parent.getDeptCode()); - existing.setAncestors(updateParam.getAncestors()); - return existing; + /** 同一 oa_id 多条时优先正式编码,避免人员挂到 OAC/OAD 旁路重复节点。 */ + private void putPreferredByOaId(Map byOaId, Dept dept, DeptCategory deptCategory) { + if (dept == null || StringUtils.isBlank(dept.getOaId())) { + return; } - Dept dept = this.buildOrgDept(name, oaCode, parent, tenantId, deptCategory); - dept.setId(DefaultIdentifierGenerator.getInstance().nextId(null)); - addList.add(dept); - return dept; + this.putPreferredByOaKey(byOaId, dept.getOaId().trim(), dept, deptCategory); } - private void saveOrgs(List addList, List updateList, DeptCategory deptCategory) { - if (CollectionUtil.isNotEmpty(addList)) { - ComposeLogUtil.getLastLog().info("批量新增{}:{}", deptCategory.getName(), addList.size()); - deptService.saveBatch(addList); + private void putPreferredByOaKey(Map byOaId, String oaKey, Dept dept, DeptCategory deptCategory) { + if (dept == null || StringUtils.isBlank(oaKey)) { + return; } - if (CollectionUtil.isNotEmpty(updateList)) { - ComposeLogUtil.getLastLog().info("批量修改{}:{}", deptCategory.getName(), updateList.size()); - deptService.updateBatchById(updateList); + Dept previous = byOaId.get(oaKey); + if (previous == null) { + byOaId.put(oaKey, dept); + return; + } + boolean previousBypass = isPersonBypassCode(previous.getDeptCode(), deptCategory); + boolean currentBypass = isPersonBypassCode(dept.getDeptCode(), deptCategory); + if (previousBypass && !currentBypass) { + byOaId.put(oaKey, dept); } - } - - private Dept buildOrgDept(String name, String deptCode, Dept parent, String tenantId, DeptCategory deptCategory) { - Dept dept = new Dept(); - dept.setTenantId(tenantId); - dept.setParentId(parent.getId()); - dept.setParentCode(parent.getDeptCode()); - dept.setAncestors(this.buildAncestors(parent)); - dept.setDeptName(name); - dept.setFullName(name); - dept.setShortName(name); - dept.setDeptCode(deptCode); - dept.setBelongCompanyCode(DeptCategory.COMPANY.equals(deptCategory) ? deptCode : parent.getBelongCompanyCode()); - dept.setDeptCategory(deptCategory.getCode()); - dept.setSort(0); - dept.setStatus(DataStatusEnum.ENABLE.getCode()); - dept.setIsDeleted(BladeConstant.DB_NOT_DELETED); - dept.setIsOa(YES); - dept.setIsPlatformCompany(0); - dept.setSyncTime(new Date()); - return dept; } private UserDept buildUserDept(OAPersonResponse oaPerson, Map userMap, OaOrgIndex orgIndex) { Dept department = orgIndex.findDept(oaPerson); if (department == null || department.getId() == null) { + ComposeLogUtil.getLastLog().warn( + "人员未匹配到已有部门,请先同步组织架构 account={} departmentid={} departmentname={} subcompanyid1={}", + userConvert.resolveAccount(oaPerson), oaPerson.getDepartmentid(), oaPerson.getDepartmentname(), + oaPerson.getSubcompanyid1()); return null; } UserDept userDept = userConvert.person2userDept(oaPerson, userMap); @@ -493,55 +672,6 @@ public class OaUserListSyncHelper { return roleList.get(0).getId().toString(); } - private String resolveCompanyKey(OAPersonResponse oaPerson) { - if (oaPerson == null) { - return null; - } - if (StringUtils.isNotBlank(oaPerson.getSubcompanyid1())) { - return oaPerson.getSubcompanyid1().trim(); - } - if (StringUtils.isNotBlank(oaPerson.getSubcompanyname())) { - return "NAME:" + oaPerson.getSubcompanyname().trim(); - } - return null; - } - - private String resolveDepartmentKey(OAPersonResponse oaPerson) { - if (oaPerson == null) { - return null; - } - if (StringUtils.isNotBlank(oaPerson.getDepartmentid())) { - return oaPerson.getDepartmentid().trim(); - } - String companyKey = resolveCompanyKey(oaPerson); - if (StringUtils.isNotBlank(companyKey) && StringUtils.isNotBlank(oaPerson.getDepartmentname())) { - return companyKey + ":" + oaPerson.getDepartmentname().trim(); - } - return null; - } - - private String buildCompanyCode(OAPersonResponse oaPerson, String companyKey) { - if (StringUtils.isNotBlank(oaPerson.getSubcompanyid1())) { - return OAConvertConstant.COMPANY_OA_PREFIX + oaPerson.getSubcompanyid1().trim(); - } - return OAConvertConstant.COMPANY_OA_PREFIX + "N" + Math.abs(companyKey.hashCode()); - } - - private String buildDepartmentCode(OAPersonResponse oaPerson, String departmentKey) { - if (StringUtils.isNotBlank(oaPerson.getDepartmentid())) { - return OAConvertConstant.DEPARTMENT_OA_PREFIX + oaPerson.getDepartmentid().trim(); - } - return OAConvertConstant.DEPARTMENT_OA_PREFIX + "N" + Math.abs(departmentKey.hashCode()); - } - - private String buildAncestors(Dept parent) { - String ancestors = parent.getAncestors(); - if (StringUtils.isBlank(ancestors)) { - ancestors = String.valueOf(BladeConstant.TOP_PARENT_ID); - } - return ancestors + "," + parent.getId(); - } - private String resolveTenantId() { String tenantId = AuthUtil.getTenantId(); if (StringUtils.isBlank(tenantId)) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/BillPaymentMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/BillPaymentMapper.java index 2b2db978..ca20187c 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/BillPaymentMapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/BillPaymentMapper.java @@ -27,9 +27,20 @@ package org.springblade.transport.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; import org.springblade.transport.pojo.entity.BillPayment; /** 汇票付款 Mapper。 @author Chill */ @Mapper public interface BillPaymentMapper extends BaseMapper { + + /** + * 查询指定租户、指定日期前缀下已使用的最大汇票付款流水号。 + * 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。 + */ + @Select("SELECT COALESCE(MAX(CAST(SUBSTRING(payment_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) " + + "FROM blade_bill_payment WHERE tenant_id = #{tenantId} " + + "AND payment_no LIKE CONCAT(#{prefix}, '%')") + Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/FormalSettlementMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/FormalSettlementMapper.java index 36f22485..2c01c226 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/FormalSettlementMapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/FormalSettlementMapper.java @@ -29,4 +29,13 @@ public interface FormalSettlementMapper extends BaseMapper { /** 恢复逻辑删除的正式结算单主记录。 */ @Update("UPDATE blade_formal_settlement SET is_deleted = 0 WHERE tenant_id = #{tenantId} AND id = #{id} AND is_deleted = 1") int restoreByIdIncludingDeleted(@Param("tenantId") String tenantId, @Param("id") Long id); + + /** + * 查询指定租户、指定日期前缀下已使用的最大正式结算单流水号。 + * 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。 + */ + @Select("SELECT COALESCE(MAX(CAST(SUBSTRING(formal_settlement_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) " + + "FROM blade_formal_settlement WHERE tenant_id = #{tenantId} " + + "AND formal_settlement_no LIKE CONCAT(#{prefix}, '%')") + Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/FormalSettlementPaymentMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/FormalSettlementPaymentMapper.java index fe41e31b..2e0e61a1 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/FormalSettlementPaymentMapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/FormalSettlementPaymentMapper.java @@ -10,9 +10,20 @@ package org.springblade.transport.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; import org.springblade.transport.pojo.entity.FormalSettlementPayment; /** 正式结算付款申请 Mapper。 @author Chill */ @Mapper public interface FormalSettlementPaymentMapper extends BaseMapper { + + /** + * 查询指定租户、指定日期前缀下已使用的最大付款申请流水号。 + * 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。 + */ + @Select("SELECT COALESCE(MAX(CAST(SUBSTRING(payment_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) " + + "FROM blade_formal_settlement_payment WHERE tenant_id = #{tenantId} " + + "AND payment_no LIKE CONCAT(#{prefix}, '%')") + Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/InvoiceApplicationMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/InvoiceApplicationMapper.java index 9e9db78c..4bcca990 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/InvoiceApplicationMapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/InvoiceApplicationMapper.java @@ -27,6 +27,8 @@ package org.springblade.transport.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; import org.springblade.transport.pojo.entity.InvoiceApplication; /** @@ -36,4 +38,13 @@ import org.springblade.transport.pojo.entity.InvoiceApplication; */ @Mapper public interface InvoiceApplicationMapper extends BaseMapper { + + /** + * 查询指定租户、指定日期前缀下已使用的最大开票申请流水号。 + * 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。 + */ + @Select("SELECT COALESCE(MAX(CAST(SUBSTRING(application_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) " + + "FROM blade_invoice_application WHERE tenant_id = #{tenantId} " + + "AND application_no LIKE CONCAT(#{prefix}, '%')") + Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PaymentApplicationMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PaymentApplicationMapper.java index 770dc202..f1507f68 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PaymentApplicationMapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PaymentApplicationMapper.java @@ -21,9 +21,20 @@ package org.springblade.transport.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; import org.springblade.transport.pojo.entity.PaymentApplication; /** 付款申请 Mapper。 @author Chill */ @Mapper public interface PaymentApplicationMapper extends BaseMapper { + + /** + * 查询指定租户、指定日期前缀下已使用的最大付款申请流水号。 + * 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。 + */ + @Select("SELECT COALESCE(MAX(CAST(SUBSTRING(payment_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) " + + "FROM blade_payment_application WHERE tenant_id = #{tenantId} " + + "AND payment_no LIKE CONCAT(#{prefix}, '%')") + Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PreSettlementAdvanceMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PreSettlementAdvanceMapper.java index c8bb1558..734364da 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PreSettlementAdvanceMapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PreSettlementAdvanceMapper.java @@ -10,6 +10,8 @@ package org.springblade.transport.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; import org.springblade.transport.pojo.entity.PreSettlementAdvance; /** @@ -19,4 +21,12 @@ import org.springblade.transport.pojo.entity.PreSettlementAdvance; */ @Mapper public interface PreSettlementAdvanceMapper extends BaseMapper { + + /** + * 查询预付单号最大流水(含逻辑删除)。 + */ + @Select("SELECT COALESCE(MAX(CAST(SUBSTRING(advance_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) " + + "FROM blade_pre_settlement_advance WHERE tenant_id = #{tenantId} " + + "AND advance_no LIKE CONCAT(#{prefix}, '%')") + Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PreSettlementMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PreSettlementMapper.java index c2b86484..2fbe0db4 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PreSettlementMapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PreSettlementMapper.java @@ -10,6 +10,8 @@ package org.springblade.transport.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; import org.springblade.transport.pojo.entity.PreSettlement; /** @@ -19,4 +21,20 @@ import org.springblade.transport.pojo.entity.PreSettlement; */ @Mapper public interface PreSettlementMapper extends BaseMapper { + + /** + * 查询预结算单号最大流水(含逻辑删除)。 + */ + @Select("SELECT COALESCE(MAX(CAST(SUBSTRING(pre_settlement_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) " + + "FROM blade_pre_settlement WHERE tenant_id = #{tenantId} " + + "AND pre_settlement_no LIKE CONCAT(#{prefix}, '%')") + Long selectMaxPreSettlementDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix); + + /** + * 查询预结算转正式结算单号最大流水(含逻辑删除)。 + */ + @Select("SELECT COALESCE(MAX(CAST(SUBSTRING(formal_settlement_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) " + + "FROM blade_pre_settlement WHERE tenant_id = #{tenantId} " + + "AND formal_settlement_no LIKE CONCAT(#{prefix}, '%')") + Long selectMaxFormalSettlementDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/SettlementAdjustmentMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/SettlementAdjustmentMapper.java index 1a2b5629..9a7b7e64 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/SettlementAdjustmentMapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/SettlementAdjustmentMapper.java @@ -2,7 +2,19 @@ package org.springblade.transport.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; import org.springblade.transport.pojo.entity.SettlementAdjustment; @Mapper -public interface SettlementAdjustmentMapper extends BaseMapper {} +public interface SettlementAdjustmentMapper extends BaseMapper { + + /** + * 查询指定租户、指定日期前缀下已使用的最大结算调整单流水号。 + * 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。 + */ + @Select("SELECT COALESCE(MAX(CAST(SUBSTRING(adjustment_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) " + + "FROM blade_settlement_adjustment WHERE tenant_id = #{tenantId} " + + "AND adjustment_no LIKE CONCAT(#{prefix}, '%')") + Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix); +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/TransportReconciliationMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/TransportReconciliationMapper.java index e808e682..db46b800 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/TransportReconciliationMapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/TransportReconciliationMapper.java @@ -22,4 +22,13 @@ public interface TransportReconciliationMapper extends BaseMapperlambdaQuery() - .likeRight(BillPayment::getPaymentNo, prefix)) + 1); + Long maxSequence = baseMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix); + return prefix + String.format("%05d", (maxSequence == null ? 0L : maxSequence) + 1); } private BigDecimal positive(BigDecimal value, String name) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java index cbfdb8e0..83462702 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java @@ -286,21 +286,8 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl throw new ServiceException("结算类型不正确"); }; String prefix = code + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); - List numbers = list(Wrappers.lambdaQuery() - .select(FormalSettlement::getFormalSettlementNo) - .likeRight(FormalSettlement::getFormalSettlementNo, prefix)) - .stream().map(FormalSettlement::getFormalSettlementNo).toList(); - int sequence = 1; - if (!numbers.isEmpty()) { - String latest = numbers.stream().max(String::compareTo).orElse(prefix); - try { - sequence = Integer.parseInt(latest.substring(prefix.length())) + 1; - } catch (RuntimeException ignored) { - sequence = Math.toIntExact(count(Wrappers.lambdaQuery() - .likeRight(FormalSettlement::getFormalSettlementNo, prefix))) + 1; - } - } - return prefix + String.format("%05d", sequence); + Long maxSequence = baseMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix); + return prefix + String.format("%05d", (maxSequence == null ? 0L : maxSequence) + 1); } @Override @@ -2190,11 +2177,11 @@ public class FormalSettlementServiceImpl extends BaseServiceImpllambdaQuery() - .likeRight(FormalSettlementPayment::getPaymentNo, prefix)); - return prefix + String.format("%04d", count + 1); + Long maxSequence = paymentMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix); + return prefix + String.format("%04d", (maxSequence == null ? 0L : maxSequence) + 1); } private BigDecimal money(BigDecimal value) { return (value == null ? BigDecimal.ZERO : value).setScale(2, RoundingMode.HALF_UP); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceApplicationServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceApplicationServiceImpl.java index 10b197a8..6ca7eaef 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceApplicationServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceApplicationServiceImpl.java @@ -1679,10 +1679,11 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpllambdaQuery() - .likeRight(InvoiceApplication::getApplicationNo, prefix)) + 1); + Long maxSequence = baseMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix); + return prefix + String.format("%05d", (maxSequence == null ? 0L : maxSequence) + 1); } private String normalizeDepartmentEmails(String value, List invoiceInfos) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java index 6b827dd5..9a3af520 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java @@ -2455,7 +2455,14 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpllambdaQuery().likeRight(PaymentApplication::getPaymentNo, prefix)) + 1); } + /** + * 生成当日付款申请单号;按最大流水号 +1,且计入逻辑删除数据,避免撞 uk_payment_application_no + */ + private synchronized String nextNo() { + String prefix = "FKD" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); + Long maxSequence = baseMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix); + return prefix + String.format("%05d", (maxSequence == null ? 0L : maxSequence) + 1); + } private BigDecimal nonNegative(BigDecimal value, String name) { if (value == null || value.compareTo(BigDecimal.ZERO) < 0) throw new ServiceException(name + "不能小于0"); return value; } private BigDecimal money(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; } private String limit(String value, int length) { if (value != null && value.length() > length) throw new ServiceException("备注不能超过" + length + "个字符"); return value; } 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 354ec65f..ff913107 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 @@ -85,7 +85,6 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; -import java.util.Comparator; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.LinkedHashSet; @@ -1780,44 +1779,25 @@ public class PreSettlementServiceImpl extends BaseServiceImpllambdaQuery() - .likeRight(PreSettlementAdvance::getAdvanceNo, prefix) - .orderByDesc(PreSettlementAdvance::getAdvanceNo) - .last("limit 1")); - return prefix + String.format("%05d", nextSequence(latest == null ? null : latest.getAdvanceNo(), prefix)); - } - - private String nextDailyCode(String code, Function getter) { - String prefix = code + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); - PreSettlement latest = list(Wrappers.lambdaQuery() - .and(query -> query.likeRight(PreSettlement::getPreSettlementNo, prefix) - .or().likeRight(PreSettlement::getFormalSettlementNo, prefix)) - .orderByDesc(PreSettlement::getCreateTime) - .last("limit 1")).stream().max(Comparator.comparing(item -> { - String value = getter.apply(item); - return value == null ? "" : value; - })).orElse(null); - String latestCode = latest == null ? null : getter.apply(latest); - return prefix + String.format("%05d", nextSequence(latestCode, prefix)); - } - - private int nextSequence(String latestCode, String prefix) { - if (latestCode == null || !latestCode.startsWith(prefix)) return 1; - try { - return Integer.parseInt(latestCode.substring(prefix.length())) + 1; - } catch (NumberFormatException exception) { - return 1; - } + Long maxSequence = advanceMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix); + return prefix + String.format("%05d", (maxSequence == null ? 0L : maxSequence) + 1); } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementAdjustmentServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementAdjustmentServiceImpl.java index 8dda1c1c..d6dd4a95 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementAdjustmentServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementAdjustmentServiceImpl.java @@ -392,7 +392,12 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpllambdaQuery().likeRight(SettlementAdjustment::getAdjustmentNo, prefix)); return prefix + String.format("%04d", count + 1); } + /** 生成当日结算调整单号;按最大流水号 +1,且计入逻辑删除数据 */ + private synchronized String nextNo() { + String prefix = "TZ" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); + Long maxSequence = baseMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix); + return prefix + String.format("%04d", (maxSequence == null ? 0L : maxSequence) + 1); + } private BigDecimal money(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; } private String requiredText(String value, String field, int max) { if (Func.isEmpty(value)) throw new ServiceException(field + "不能为空"); return limit(value.trim(), max); } private String limit(String value, int max) { if (value != null && value.length() > max) throw new ServiceException("内容不能超过" + max + "个字"); return value; } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportReconciliationServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportReconciliationServiceImpl.java index dff1f39b..065ec3b9 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportReconciliationServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportReconciliationServiceImpl.java @@ -1512,20 +1512,14 @@ public class TransportReconciliationServiceImpl private BigDecimal sumExternalQuantity(List rows) { return rows.stream().map(TransportReconciliationExternal::getTransportQuantity).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add); } private BigDecimal sumInternalAmount(List rows) { return rows.stream().map(TransportReconciliationInternal::getSettlementAmount).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add); } private BigDecimal sumExternalAmount(List rows) { return rows.stream().map(TransportReconciliationExternal::getSettlementAmount).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add); } + /** 生成当日运输对账单号;按最大流水号 +1,且计入逻辑删除数据 */ private synchronized String nextNo() { String prefix = "DZD" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); - int sequence = list(Wrappers.lambdaQuery() - .select(TransportReconciliation::getReconciliationNo) - .likeRight(TransportReconciliation::getReconciliationNo, prefix)) - .stream() - .map(TransportReconciliation::getReconciliationNo) - .filter(number -> number != null && number.length() == prefix.length() + 5) - .map(number -> number.substring(prefix.length())) - .filter(suffix -> suffix.chars().allMatch(Character::isDigit)) - .mapToInt(Integer::parseInt) - .max() - .orElse(0) + 1; - if (sequence > 99999) throw new ServiceException("当日运输对账单号流水已用完"); + Long maxSequence = baseMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix); + long sequence = (maxSequence == null ? 0L : maxSequence) + 1; + if (sequence > 99999) { + throw new ServiceException("当日运输对账单号流水已用完"); + } return prefix + String.format("%05d", sequence); } } 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 0f92b9b4..2d52773c 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 @@ -25,6 +25,7 @@ import org.springblade.system.cache.UserCache; import org.springblade.system.feign.ISysClient; import org.springblade.system.pojo.entity.DictBiz; import org.springblade.system.pojo.entity.CargoType; +import org.springblade.system.pojo.entity.MeasurementUnit; import org.springblade.transport.excel.WaybillImportBatchExcel; import org.springblade.transport.mapper.WaybillImportBatchMapper; import org.springblade.transport.pojo.dto.WaybillImportBatchRequest; @@ -919,13 +920,14 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl row, List quantityUnitOptions, List errors) { - String quantityUnit = stringValue(row, "quantityUnit", "数量单位"); + String quantityUnit = stringValue(row, "quantityUnit", "数量单位", "*数量单位"); if (Func.isEmpty(quantityUnit)) { return; } - - if (!quantityUnitOptions.contains(quantityUnit)) { - errors.add("数量单位必须为系统枚举值之一"); + String normalized = quantityUnit.trim(); + boolean matched = quantityUnitOptions.stream().anyMatch(option -> option.equalsIgnoreCase(normalized)); + if (!matched) { + errors.add("数量单位「" + normalized + "」未在计量单位主数据中匹配"); } } @@ -1186,9 +1188,28 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl loadQuantityUnitOptions() { - // 常见数量单位 - return List.of("吨", "千克", "立方米", "件", "箱", "台", "个", "升", "米", "平方米"); + R> response = sysClient.getMeasurementUnits(); + if (response == null || !response.isSuccess() || response.getData() == null) { + throw new ServiceException("计量单位基础数据读取失败,请稍后重试"); + } + Set options = new HashSet<>(); + for (MeasurementUnit unit : response.getData()) { + if (unit == null || (unit.getStatus() != null && !Objects.equals(unit.getStatus(), 1))) { + continue; + } + if (Func.isNotEmpty(unit.getUnitName())) { + options.add(unit.getUnitName().trim()); + } + if (Func.isNotEmpty(unit.getUnitCode())) { + options.add(unit.getUnitCode().trim()); + } + } + if (options.isEmpty()) { + throw new ServiceException("计量单位主数据为空,请先在基础数据中配置计量单位"); + } + return new ArrayList<>(options); } /** From 6d3c0bfef49f536263fc995cba008663eefeaf7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Mon, 19 Oct 2026 02:08:02 +0800 Subject: [PATCH 078/108] =?UTF-8?q?docs:=20=E8=A1=A5=E5=85=85=E9=87=91?= =?UTF-8?q?=E8=9D=B6BFI=E6=93=8D=E4=BD=9C=E6=89=8B=E5=86=8C=E5=AE=9A?= =?UTF-8?q?=E6=97=B6=E4=BB=BB=E5=8A=A1=E8=BF=90=E8=BD=AC=E9=80=BB=E8=BE=91?= =?UTF-8?q?=E4=B8=8E=E7=8A=B6=E6=80=81=E4=B8=AD=E6=96=87=E9=87=8A=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...-14-金蝶(星瀚)BFI对接操作手册(纯操作版).md | 63 +++++++++++++++---- 1 file changed, 52 insertions(+), 11 deletions(-) diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).md b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).md index d5097a95..270be3fa 100644 --- a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).md +++ b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).md @@ -248,21 +248,62 @@ ### 4.1 任务清单(Nacos `thirdparty.bfi.*` 配置) -| 任务 | 干什么 | 开关键 | cron 键(默认频率) | 手动替代按钮 | +| 任务 | 干什么 | 开关键 | cron 键(默认值) | 手动替代按钮 | |---|---|---|---|---| -| 付款结果回写 | 付款状态(paid/closed)+ 付款记录/凭证回写;顺带应收勾稽纠偏、开票结果回写 | `thirdParty.bfi.payResult.syncEnabled`(样例已开) | `…payResult.cron`(10 分钟) | 付款「同步付款结果」、开票结果手动端点、结算勾稽手动端点 | -| 收票结果回写 | 收票单状态回写、pushed 断点补发指定应付 | `…receivedInvoiceResult.syncEnabled` | 每 10 分钟 | 收票「同步金蝶结果」 | -| 客商同步 | 金蝶审核状态复查(synced→exist)、停用联动、failed 重推 | `…customer.syncEnabled` | 每天 02:00 | 客商「同步金蝶」 | -| 收款认领推送 | 自动推送认领完成的流水到金蝶 | `…receiptClaimPush.syncEnabled` | 每 10 分钟 | 流水行内「推送金蝶」 | -| 全票池同步 | 拉金蝶全票池发票到本地镜像、补绑定 | `…invoiceReceipt.syncEnabled` | 每 30 分钟 | 手动端点 `POST /invoice-receipt/sync-invoice-pool` | -| failed 重推 | 自动重推所有推送失败的结算/付款/开票/收票单 | `…autoSync.retryEnabled` | 每 10 分钟 | 各页面「同步金蝶」 | -| 锁单补偿 | 推送成功但金蝶锁定失败的自动补锁(≤5 次) | `…billLock.retryEnabled` | 每 5 分钟 | 手动端点 `POST /formal-settlement/retry-kingdee-bill-lock` | +| 付款结果回写 | 付款状态(paid/closed)+ 付款记录/凭证回写;顺带应收勾稽纠偏、开票结果回写 | `thirdParty.bfi.payResult.syncEnabled`(样例已开) | `…payResult.cron`(默认 `0 1/10 * * * ?`) | 付款「同步付款结果」、开票结果手动端点、结算勾稽手动端点 | +| 收票结果回写 | 收票单状态回写、pushed 断点补发指定应付 | `…receivedInvoiceResult.syncEnabled` | `…receivedInvoiceResult.cron`(默认 `0 8/10 * * * ?`) | 收票「同步金蝶结果」 | +| 客商同步 | 金蝶审核状态复查(synced→exist)、停用联动、failed 重推 | `…customer.syncEnabled` | `…customer.cron`(默认 `0 0 2 * * ?`) | 客商「同步金蝶」 | +| 收款认领推送 | 自动推送认领完成的流水到金蝶 | `…receiptClaimPush.syncEnabled` | `…receiptClaimPush.cron`(默认 `0 4/10 * * * ?`) | 流水行内「推送金蝶」 | +| 全票池同步 | 拉金蝶全票池发票到本地镜像、补绑定 | `…invoiceReceipt.syncEnabled` | `…invoiceReceipt.cron`(默认 `0 16/30 * * * ?`) | 手动端点 `POST /invoice-receipt/sync-invoice-pool` | +| failed 重推 | 自动重推所有推送失败的结算/付款/开票/收票单 | `…autoSync.retryEnabled` | `…autoSync.retryCron`(默认 `0 5/10 * * * ?`) | 各页面「同步金蝶」 | +| 锁单补偿 | 推送成功但金蝶锁定失败的自动补锁(≤5 次) | `…billLock.retryEnabled` | `…billLock.retryCron`(默认 `0 2/5 * * * ?`) | 手动端点 `POST /formal-settlement/retry-kingdee-bill-lock` | -### 4.2 审批自动同步开关(按业务域独立) +### 4.2 各任务的运转逻辑 + +**共性规则(先读这个)** + +- **终态不轮询**:付款 paid(已付款)/closed(已关闭)、开票 invoiced(已开票)、收票 assigned(已指定应付)、客商 exist(金蝶已审核)、认领流水 pushed(已推送金蝶)后自动退出扫描范围——任务只处理「还没走完」的单据,越跑越轻,不会重复处理。 +- **定时与手动同一套逻辑**:每个任务与它的「手动替代按钮」调用同一个服务方法,效果完全一致;区别只是触发方式。 +- **单条失败不拖批次**:一轮里某条单据失败只记录原因,同批其余照常处理;失败的下轮自动再试(兜底见 ⑥ failed 重推)。 +- **默认执行时间错峰**(付款 :01、认领 :04、failed 重推 :05、收票 :08、锁补偿每 5 分钟的 :02、票池每 30 分钟的 :16),互不挤占;自行改 cron 时建议保留错峰。 + +**① 付款结果回写(10 分钟)** +扫「审批通过且已推送 / 付款中 / 推送失败」的付款申请 → 到金蝶查付款申请单和付款处理单 → 推进 synced(已推送受理)→ paying(金蝶付款中)→ **paid(已付款)/ closed(已关闭)**,paid 时回写付款记录/凭证,并联动刷新预结算垫资、结算付款记录(3.4 第 3 步的「正常等定时回写」就是它)。 +- 自愈一(应收勾稽):已推送但失败、或缺金蝶单号的**应收结算单**,查金蝶有则自动补单号、纠回 synced——「实际推成功但本地显示失败」的场景自动痊愈; +- 自愈二(开票结果):已受理的开票申请自动落发票信息并推进 **invoiced(已开票,终态)**(3.5 第 3 步的定时回写就是它); +- 防呆(金蝶删单):synced(已推送受理)/paying(金蝶付款中)的单据**连续 2 轮(约 20 分钟)在金蝶查不到** → 判定金蝶侧已删,自动解锁置 failed(推送失败)并清空金蝶单号,等人工核实后重推;重推带幂等预检,金蝶单据其实还在时会自动回填,不会重复生成。 + +**② 收票结果回写(10 分钟)** +扫 pushed(金蝶收票单已生成)/ assigned(已指定应付)的收票登记,按近 **30 天**回看窗口查金蝶收票单,一轮处理三种情况: +- 票在金蝶被作废/红冲 → 置 failed(推送失败)并提示人工处理(**不要重推**,见 3.6); +- 卡在 pushed → 自动补发「指定应付」推进 **assigned(已指定应付,终态)**(3.6 说的「卡在 pushed 不用管」就是它); +- assigned 但缺金蝶收票单号 → 自动补登单号。 +窗口内查不到的保持现状不动(不判删除),由人工重推兜底。 + +**③ 客商同步(每天 02:00)** +一次扫全部审批通过客商,干三件事: +- **未推送/推送失败** → 先复查金蝶:已有且已审核的直接置 **exist(金蝶已有该客商且审核通过,终态)**(不重复建档);金蝶侧审核中则置 failed(推送失败)提示稍后;确实没有才推送; +- **已推送(synced,已推送受理)** → 金蝶审核通过自动升级 **exist(金蝶已审核,终态)**(3.1 第 5 步的定时升级就是它); +- **金蝶侧停用(enable=0)** → TMS 档案联动停用;金蝶恢复可用**不会**自动启用(单向联动,需人工启用)。 + +**④ 收款认领推送(10 分钟)** +扫「认领完成(claimed)且未推送/推送失败」的收款流水 → 按认领人逐人拉最新认领公告**对账**(本地认领金额与公告未认领金额一致才推)→ 推送成功回写金蝶收款单号、流水置 **pushed(已推送金蝶)**(3.7 第 3 步的「自动」就是它)。失败的下一轮自动重试,防重码按分摊结算单生成、重试不变,不会在金蝶重复生成收款单。 + +**⑤ 全票池同步(30 分钟)** +按近 **30 天**增量窗口分页拉取金蝶全票池发票到本地镜像表,收票选票、票池查询全部走本地镜像——所以**刚在金蝶开的票要等同步后才能选到**;急用可让运维手动触发同步端点,或用票号精确搜索(会自动去金蝶现查,见 3.6)。 + +**⑥ failed 重推(10 分钟)** +只重推 **failed(推送失败)** 单据(结算 → 付款 → 开票 → 收票四个域依次处理);**unsynced(审批通过但从未推送)不会自动推**——历史存量单不会被上线后批量补推,仍走手动按钮。前置没就绪(如上游应付单还没生成)会再次 failed、下轮继续;前置就绪后自动续推成功。 +⚠️ 重推**没有次数上限**:根因不修(联行号缺失、组织编码缺失等)会每 10 分钟失败一次刷日志,修复根因是唯一正解(呼应 4.4)。 + +**⑦ 锁单补偿(5 分钟)** +扫「推送成功但金蝶锁定失败」的锁记录,重试加锁,**最多 5 次**;超过 5 次放弃,需运维调手动补偿端点 `POST /formal-settlement/retry-kingdee-bill-lock`(对应 FAQ 的「锁定失败」标签)。 + +### 4.3 审批自动同步开关(按业务域独立) `thirdParty.bfi.autoSync.settlement`(结算单)/ `…payment`(付款)/ `…invoiceApply`(开票)/ `…receipt`(收票),默认全关。**代码默认全部关闭,必须在 Nacos 显式开启**;客商档案审批推送不受此开关控制(始终自动)。 -### 4.3 运维注意 +### 4.4 运维注意 - 所有定时任务带**分布式锁**:多实例部署只跑一份;手动按钮和定时共用锁,定时运行中手动触发会直接跳过(属正常)。 - 开了「failed 重推」后,持续失败的单据每 10 分钟会重试一次,**修复根因**(配置/数据)比反复观察更重要。 @@ -324,4 +365,4 @@ --- -> 本文基于 2026-10-14 代码现状整理;系统升级后按钮与提示如有出入,以页面实际为准,并核对全链路总纲文档的更新说明。 +> 本文基于 2026-10-14 代码现状整理;2026-10-19 依据当前代码核对并补充 4.2 定时任务运转逻辑(cron 默认值、错峰、终态不轮询、防呆与自愈行为)。系统升级后按钮与提示如有出入,以页面实际为准,并核对全链路总纲文档的更新说明。 From d9d405cfe4ac115ff2d197d49ecf5e6344a98c2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Mon, 19 Oct 2026 11:10:56 +0800 Subject: [PATCH 079/108] =?UTF-8?q?fix:=20=E9=87=91=E8=9D=B6=E4=BB=98?= =?UTF-8?q?=E6=AC=BE=E7=94=B3=E8=AF=B7=E9=83=A8=E9=97=A8=E7=BC=96=E7=A0=81?= =?UTF-8?q?=E6=94=B9=E4=BC=A0=E9=A1=B9=E7=9B=AE=E4=B8=9A=E5=8A=A1=E9=83=A8?= =?UTF-8?q?=E9=97=A8=E8=87=AA=E8=BA=AB=E9=87=91=E8=9D=B6=E7=BC=96=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A103-3 付款申请保存 awrp_department_number 原与结算/申请/付款组织同传上溯平台公司编码,现拆分:组织三字段仍走 kingdeeOrgNumber(平台公司),部门编码新增 kingdeeDeptNumber 取业务部门自身 blade_dept.kingdee_dept_code(不上溯,未维护拦截) --- .../impl/PaymentApplicationServiceImpl.java | 4 +- .../support/KingdeeApBillSyncSupport.java | 43 ++++++++++++++----- 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java index c63ac3eb..8789361f 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java @@ -1223,8 +1223,8 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl Date: Mon, 19 Oct 2026 11:11:19 +0800 Subject: [PATCH 080/108] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0BFI=E6=8E=A8?= =?UTF-8?q?=E9=80=81=E6=98=A0=E5=B0=84=E8=A1=A8=E7=BB=84=E7=BB=87/?= =?UTF-8?q?=E9=83=A8=E9=97=A8=E7=BC=96=E7=A0=81=E5=8F=A3=E5=BE=84=E5=8F=8A?= =?UTF-8?q?=E4=BB=98=E6=AC=BE=E7=94=B3=E8=AF=B7=E6=8A=80=E6=9C=AF=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 映射表:组织类字段统一为公司级平台公司编码口径,A103-3 拆分 awrp_department_number(部门级 kingdeeDeptNumber)与结算/申请/付款组织(kingdeeOrgNumber),补充组织编码层级说明;付款申请技术文档:组织字段来源改为 kingdeeOrgNumber/kingdeeDeptNumber,未传字段清单同步 --- ...€š)BFI推送入参取值映射表(页面字段-表字段-配置对照).md | 14 +++++++++----- .../业务流程/付款申请同步金蝶(星瀚)技术文档.md | 5 +++-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/doc/docs/业务流程/2026-10-15-金蝶(星瀚)BFI推送入参取值映射表(页面字段-表字段-配置对照).md b/doc/docs/业务流程/2026-10-15-金蝶(星瀚)BFI推送入参取值映射表(页面字段-表字段-配置对照).md index 80c1b141..21f883f3 100644 --- a/doc/docs/业务流程/2026-10-15-金蝶(星瀚)BFI推送入参取值映射表(页面字段-表字段-配置对照).md +++ b/doc/docs/业务流程/2026-10-15-金蝶(星瀚)BFI推送入参取值映射表(页面字段-表字段-配置对照).md @@ -41,7 +41,8 @@ | 项 | 规则 | 代码 | |---|---|---| | awrp_src_sys / systemsource / sourceSystem | Nacos `thirdparty.bfi.srcSys`,默认常量 `WLTMS` | BfiProperties(srcSys) | -| 组织 org/结算/付款/收款/申请组织 | 合同/申请单所属组织 → `SysCache.getDept()` → **部门金蝶组织编码**,空拦截 | `blade_contract_manage.organization_id` / 申请单 dept_id → `blade_dept.kingdee_dept_code`(KingdeeApBillSyncSupport.bfiOrgNumber L933-944) | +| 组织 org/结算/付款/收款组织(核算/资金类) | **统一取公司级编码**:项目业务部门沿部门树向上反查平台公司(`is_platform_company=1`)→ 平台公司 `blade_dept.kingdee_dept_code`;未归属平台公司/未维护编码直接拦截 | `kingdeeOrgNumber`(KingdeeApBillSyncSupport L976-999),项目经 `blade_project_apply.business_dept_id` | +| 申请部门类字段(A303-1 awrp_orgfield) | 申请部门自身 `kingdee_dept_code`(部门级业务单元编码,星瀚允许) | 申请单 dept_id → `blade_dept.kingdee_dept_code` | | 币种 | 合同结算币种,`RMB→CNY` | `blade_contract_manage.settlement_currency`(bfiCurrency L952-956) | | 往来户 | 名称 → `blade_customer_archive` 按 `full_name/short_name` 匹配(limit 1)取 `customer_code`,查不到报错 | queryCustomerCode L966-984 | | 日期文本 | 统一 `yyyy-MM-dd HH:mm:ss` | bfiDateTimeText L922-925 | @@ -133,7 +134,7 @@ | awrp_src_sys【必】 | 外部系统编号 | 配置 | `thirdparty.bfi.srcSys`(WLTMS) | | awrp_src_type【必】 | 外部单据类型 | — | 常量 `TMS财务应付单` | | billtypeid_number【必】 | 单据类型.编码 | — | 常量字典「服务采购应付」→ `ApFin_service_BT_S` | -| org_number / payorg_number【必】 | 结算/付款组织.编码 | 合同所属组织 | blade_contract_manage.organization_id → blade_dept.kingdee_dept_code | +| org_number / payorg_number【必】 | 结算/付款组织.编码 | 合同关联项目业务部门上溯平台公司 | kingdeeOrgNumber → 平台公司 `blade_dept.kingdee_dept_code`(公司级核算组织编码) | | bizdate【必】 | 单据日期 | 同步当日 | LocalDate.now() | | asstacttype【必】 | 往来类型 | — | 常量 `bd_customer`(固定,供应商分支已注释) | | asstact_number【必】 | 往来户.编码 | 合同乙方 | blade_contract_manage.party_b → blade_customer_archive.customer_code | @@ -205,7 +206,8 @@ | 金蝶入参 | 含义 | 来源 | 表.字段 / 配置 / 常量 | |---|---|---|---| | billtype_number【必】 | 单据类型 | 配置 | `thirdparty.bfi.payApply.billtype`(默认 `ap_payapply_oth_BT_S`) | -| settleorg/awrp_department/applyorg/payorg_number【必】 | 组织/部门 | 合同所属组织 | blade_contract_manage.organization_id → blade_dept.kingdee_dept_code | +| settleorg/applyorg/payorg_number【必】 | 结算/申请/付款组织.编码 | 合同关联项目业务部门上溯平台公司 | kingdeeOrgNumber → 平台公司 `blade_dept.kingdee_dept_code`(公司级核算组织编码) | +| awrp_department_number【必】 | 部门.编码 | 项目业务部门自身 | kingdeeDeptNumber → 业务部门 `blade_dept.kingdee_dept_code`(部门级编码,不上溯平台公司;未维护拦截) | | paycurrency/settlecurrency_number【必】 | 币种 | 合同结算币种(RMB→CNY) | blade_contract_manage.settlement_currency | | applycause | 请款事由 | 按付款类型映射文案:项目垫资→「项目预付款申请」、进度垫资→「进度预付款申请」、结算付款→「结算付款申请」 | blade_payment_application.payment_type | | exratetable_number【必】 | 汇率表 | 配置(**此接口实际送**) | `thirdparty.bfi.payApply.exratetable`(默认 `01`) | @@ -228,7 +230,8 @@ | 金蝶入参 | 含义 | 页面字段 | 表.字段 / 配置 / 常量 | |---|---|---|---| | billno【必·候选键】 | 单据编号 | 开票申请单号(多张加张次序号) | blade_invoice_application.application_no(多张 + "-" + blade_invoice_application_sheet.sheet_no) | -| orgid_number / awrp_orgfield_number【必】 | 组织/申请部门 | 申请部门 | blade_invoice_application.dept_id → blade_dept.kingdee_dept_code | +| orgid_number【必】 | 组织.编码 | 申请关联项目业务部门上溯平台公司 | kingdeeOrgNumber → 平台公司金蝶组织编码(绑定税号的核算组织) | +| awrp_orgfield_number【必】 | 申请部门.编码 | 申请部门 | blade_invoice_application.dept_id → blade_dept.kingdee_dept_code(部门级业务单元编码) | | billdate【必】 | 单据日期 | 申请日期(空取当天) | blade_invoice_application.application_date | | billproperties / taxationstyle / buyerproperty | 单据性质/征税方式/购方类型 | 固定 | `"1"` / `"0"` / `"0"` | | invoicetype【必】 | 发票种类 | 发票类型:数电专票→`08xdp`,其余→`10xdp` | blade_invoice_application.invoice_type | @@ -293,7 +296,7 @@ | invoicetypef7_number【必】 | 发票类型.编码 | 票易通原始编码(缺失拦截并按票号回源 A801-1 补填) | blade_kingdee_invoice_pool.invoice_type_code | | paymenttype【必】 | 付款方式 | 固定现购 | `CASH` | | asstacttype【必】 | 往来类型 | 统一固定客户(与应付单/付款申请同口径) | `bd_customer` | -| org_number / currency_number【必】 | 组织/币种 | 分摊应付结算单关联合同 | blade_contract_manage.organization_id / settlement_currency | +| org_number / currency_number【必】 | 组织/币种 | 分摊应付结算单关联合同 → 项目业务部门上溯平台公司 | kingdeeOrgNumber → 平台公司编码 / settlement_currency | | receivablessupp_number【必】 | 往来户.编码 | 合同乙方→客商编码 | blade_contract_manage.party_b → customer_code | | exratetable_number【必】 | 汇率表 | 固定 | `"01"` | | asstactname / buyertin / sellertin【必】 | 销方名称/购销方税号 | 票池镜像票面 | blade_kingdee_invoice_pool.issuer_name / buyer_tax_no / saler_tax_no | @@ -401,5 +404,6 @@ multipart 上传:`file`=文件流,`args`=JSON `{entityNumber, billPkId, cont | 4 | A301-2 claimusers | 只读 Nacos 配置,方法入参(当前登录人姓名)已不参与取值;billstatus 字段存在但不传 | | 5 | A801-1 expenseStatus | 同步**不下推**(常量已定义未使用),「在用/已用票随窗口刷新」,可选票过滤在本地 SQL | | 6 | 应付 asstact_number | 取**合同乙方 party_b**(payer_name 仅应收侧使用) | +| 7 | 组织编码层级 | 核算/结算/付款/收款/开票**组织类字段已统一改为公司级编码**:`kingdeeOrgNumber`(L976-999)按项目业务部门沿部门树上溯 `is_platform_company=1` 平台公司取其 `kingdee_dept_code`;仅 A303-1 申请部门(awrp_orgfield)保留部门级编码。部门级编码(如工业物流中心 01.02.05.06.01.015)不再直接作为结算/付款组织送出,自动替换为其平台公司编码(如国安运输 01.02.05.06.01);前置数据要求见《2026-10-16-金蝶组织编码部门数据维护清单》 | > 校验提醒:以上配置键的**环境实际值**以 Nacos 为准(本文只列键名与代码默认值);blade_* 表列名按 Entity 驼峰↔下划线约定标注,个别快照列以建表 SQL 为准。 diff --git a/doc/docs/业务流程/付款申请同步金蝶(星瀚)技术文档.md b/doc/docs/业务流程/付款申请同步金蝶(星瀚)技术文档.md index 4e922bfd..13b52fd4 100644 --- a/doc/docs/业务流程/付款申请同步金蝶(星瀚)技术文档.md +++ b/doc/docs/业务流程/付款申请同步金蝶(星瀚)技术文档.md @@ -131,7 +131,8 @@ TMS 付款申请审核通过 | --- | --- | --- | --- | | awrp_src_code | 来源单据号(防重键) | `paymentNo` | 付款申请单号 | | awrp_src_sys | 外部系统编号(防重键) | 配置 `payApply.srcSys` | 默认 `WLTMS` | -| settleorg_number / applyorg_number / payorg_number / purorg_number | 结算/申请/付款/采购组织编码 | 合同所属组织 | `organizationId` → 组织编码 `deptCode`,为空回退组织名称;缺失报错 | +| settleorg_number / applyorg_number / payorg_number | 结算/申请/付款组织编码 | 合同关联项目业务部门上溯平台公司 | `kingdeeOrgNumber` → 平台公司 `blade_dept.kingdee_dept_code`;缺失报错 | +| awrp_department_number | 部门编码 | 项目业务部门自身 | `kingdeeDeptNumber` → 业务部门 `blade_dept.kingdee_dept_code`(部门级编码,不上溯平台公司);未维护报错 | | billtype_number | 单据类型编码 | 配置 `payApply.billtype` | 默认 `ap_payapply_oth_BT_S` 其他付款申请 | | applydate | 申请日期 | `applyDate` | yyyy-MM-dd,空取当天 | | applycause | 请款事由 | paymentType 映射 | 见 5.1 | @@ -158,7 +159,7 @@ TMS 付款申请审核通过 | awrp_entry_code | 对方唯一行 id(防重) | `paymentNo + "-1"` | 不得重复 | | e_remark | 备注 | `remark` | 可选 | -未传的可选字段:purdept_number、awrp_department_number、e_duedate、e_settlementtype_number、e_costcenter_number、awrp_src_con_eno、awrp_splb_number。 +未传的可选字段:purorg_number、purdept_number、e_duedate、e_settlementtype_number、e_costcenter_number、awrp_src_con_eno、awrp_splb_number。 > 前置校验:推送前按 `contractId` 加载合同,未关联合同、合同不存在、所属组织编码为空、结算币种为空时,同步失败并标记 `kingdeeStatus = failed`。 From 7858ba30aca9d02e2fff7e2418b8b170adcdc181 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Mon, 19 Oct 2026 11:19:03 +0800 Subject: [PATCH 081/108] =?UTF-8?q?docs:=20=E4=BB=98=E6=AC=BE=E7=94=B3?= =?UTF-8?q?=E8=AF=B7=E5=90=8C=E6=AD=A5=E9=87=91=E8=9D=B6=E6=8A=80=E6=9C=AF?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E6=8C=89=E7=9B=AE=E5=BD=95=E8=A7=84=E8=8C=83?= =?UTF-8?q?=E8=A1=A5=E5=85=85=E6=97=A5=E6=9C=9F=E5=89=8D=E7=BC=80=EF=BC=88?= =?UTF-8?q?2026-09-16=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...(星瀚)技术文档.md => 2026-09-16-付款申请同步金蝶(星瀚)技术文档.md} | 0 ...付款管理正式付款同步金蝶(财务应付单+付款申请下推)技术文档.md | 4 ++-- ...˜款结果定时回写(A103-2付款申请单查询+A103-4付款处理查询)技术文档.md | 2 +- ...3-3金蝶链路补齐方案(e_bebank_number统一-消失解锁-回写默认开启).md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) rename doc/docs/业务流程/{付款申请同步金蝶(星瀚)技术文档.md => 2026-09-16-付款申请同步金蝶(星瀚)技术文档.md} (100%) diff --git a/doc/docs/业务流程/付款申请同步金蝶(星瀚)技术文档.md b/doc/docs/业务流程/2026-09-16-付款申请同步金蝶(星瀚)技术文档.md similarity index 100% rename from doc/docs/业务流程/付款申请同步金蝶(星瀚)技术文档.md rename to doc/docs/业务流程/2026-09-16-付款申请同步金蝶(星瀚)技术文档.md diff --git a/doc/docs/业务流程/2026-09-16-付款管理正式付款同步金蝶(财务应付单+付款申请下推)技术文档.md b/doc/docs/业务流程/2026-09-16-付款管理正式付款同步金蝶(财务应付单+付款申请下推)技术文档.md index fc8fd06a..a7e9df35 100644 --- a/doc/docs/业务流程/2026-09-16-付款管理正式付款同步金蝶(财务应付单+付款申请下推)技术文档.md +++ b/doc/docs/业务流程/2026-09-16-付款管理正式付款同步金蝶(财务应付单+付款申请下推)技术文档.md @@ -2,7 +2,7 @@ > 适用系统:TMS(BladeX 微服务平台,服务:blade-transport) > 对接系统:金蝶云·星瀚(业财融合 BFI,经 iPaaS 网关) -> 关联文档:`doc/docs/TMS业财融合-集成流程图文字版与业务流程梳理.md`、`doc/docs/业财融合系统接口文档.xlsx`、`doc/docs/业务流程/付款申请同步金蝶(星瀚)技术文档.md`(A103-3 预付款路径) +> 关联文档:`doc/docs/TMS业财融合-集成流程图文字版与业务流程梳理.md`、`doc/docs/业财融合系统接口文档.xlsx`、`doc/docs/业务流程/2026-09-16-付款申请同步金蝶(星瀚)技术文档.md`(A103-3 预付款路径) > 最后更新:2026-09-18(字段映射与联调默认值按当前代码重新梳理) --- @@ -15,7 +15,7 @@ - 对接接口: - **A102-2 财务应付单保存** `POST /ierp/kapi/v2/awrp/ap/ap/ap_finapbill/save` - **A103-1 付款申请保存(应付下推)** `POST /ierp/kapi/v2/awrp/ap/ap/ap_finapbill/push` -- 路径划分:**正式付款**(settlement_payment)走本文档 A102-2 → A103-1 两步链路;**预付款**(project_advance / progress_advance)维持原 A103-3 直推付款申请单,见《付款申请同步金蝶(星瀚)技术文档》 +- 路径划分:**正式付款**(settlement_payment)走本文档 A102-2 → A103-1 两步链路;**预付款**(project_advance / progress_advance)维持原 A103-3 直推付款申请单,见《2026-09-16-付款申请同步金蝶(星瀚)技术文档》 - 调用方式:前端 → TMS 后端转发 → 星瀚(OAuth2 凭证仅存后端,前端不持有) - 落库结果:A103-1 返回的付款申请单 id 写入 `kingdee_bill_no`(既有字段);**A102-2 返回的应付单 id 不落库**,仅校验保存成功,后续如需可经 A102-4 按防重键(`awrp_src_sys` + `awrp_src_code`)查询(`BfiServiceImpl.queryApBill` 已封装),本次无表结构变更 diff --git a/doc/docs/业务流程/2026-09-17-付款管理金蝶付款结果定时回写(A103-2付款申请单查询+A103-4付款处理查询)技术文档.md b/doc/docs/业务流程/2026-09-17-付款管理金蝶付款结果定时回写(A103-2付款申请单查询+A103-4付款处理查询)技术文档.md index 46d7f4d1..4f80f684 100644 --- a/doc/docs/业务流程/2026-09-17-付款管理金蝶付款结果定时回写(A103-2付款申请单查询+A103-4付款处理查询)技术文档.md +++ b/doc/docs/业务流程/2026-09-17-付款管理金蝶付款结果定时回写(A103-2付款申请单查询+A103-4付款处理查询)技术文档.md @@ -2,7 +2,7 @@ > 适用系统:TMS(BladeX 微服务平台,服务:blade-transport) > 对接系统:金蝶云·星瀚(业财融合 BFI,经 iPaaS 网关) -> 关联文档:`doc/docs/TMS业财融合-集成流程图文字版与业务流程梳理.md`(4.3 节步骤 11)、`付款申请同步金蝶(星瀚)技术文档.md`、`2026-09-16-付款管理正式付款同步金蝶(财务应付单+付款申请下推)技术文档.md`、`2026-09-17-BFI分页查询通用翻页工具(BfiUtil-queryAllPages)技术文档.md` +> 关联文档:`doc/docs/TMS业财融合-集成流程图文字版与业务流程梳理.md`(4.3 节步骤 11)、`2026-09-16-付款申请同步金蝶(星瀚)技术文档.md`、`2026-09-16-付款管理正式付款同步金蝶(财务应付单+付款申请下推)技术文档.md`、`2026-09-17-BFI分页查询通用翻页工具(BfiUtil-queryAllPages)技术文档.md` > 最后更新:2026-09-17 --- diff --git a/doc/docs/业务流程/2026-09-29-预付款申请A103-3金蝶链路补齐方案(e_bebank_number统一-消失解锁-回写默认开启).md b/doc/docs/业务流程/2026-09-29-预付款申请A103-3金蝶链路补齐方案(e_bebank_number统一-消失解锁-回写默认开启).md index 130d952c..c72838ed 100644 --- a/doc/docs/业务流程/2026-09-29-预付款申请A103-3金蝶链路补齐方案(e_bebank_number统一-消失解锁-回写默认开启).md +++ b/doc/docs/业务流程/2026-09-29-预付款申请A103-3金蝶链路补齐方案(e_bebank_number统一-消失解锁-回写默认开启).md @@ -4,7 +4,7 @@ > 关联文档: > - `doc/docs/TMS业财融合-集成流程图文字版与业务流程梳理.md`(图03 财务应付及付款,步骤4 推送金蝶 / 步骤13 异常回收) > - `doc/adr/0005-B901事件驱动双表锁单模型.md`(UNLOCK 置失败态不自动重推;A103-2 连续两轮查询消失兜底复用 `unlockBill()`) -> - `doc/docs/业务流程/付款申请同步金蝶(星瀚)技术文档.md`(A103-3 接口定义) +> - `doc/docs/业务流程/2026-09-16-付款申请同步金蝶(星瀚)技术文档.md`(A103-3 接口定义) > - `doc/docs/业务流程/2026-09-17-付款管理金蝶付款结果定时回写(A103-2付款申请单查询+A103-4付款处理查询)技术文档.md` > - `doc/docs/业务流程/2026-09-18-付款管理金蝶同步全链路逻辑梳理(推送链路与回写链路连贯性).md` From c5d5687999a7689e2818963ce9fc15a36cc19b76 Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Mon, 19 Oct 2026 12:06:49 +0800 Subject: [PATCH 082/108] =?UTF-8?q?:sparkles:=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E4=BB=98=E6=AC=BE=E7=94=B3=E8=AF=B7=E5=A4=9A=E7=BB=93=E7=AE=97?= =?UTF-8?q?=E8=A1=8C=E3=80=81=E7=BB=93=E7=AE=97=E5=AF=BC=E5=85=A5=E4=B8=8E?= =?UTF-8?q?=E8=AE=A1=E8=B4=B9=E6=96=B9=E5=BC=8F=E5=9B=9E=E5=A1=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../dto/PaymentApplicationSaveRequest.java | 2 + ...ymentApplicationSettlementLineRequest.java | 43 ++++++ .../ReceivablePayableFeeCalculateRequest.java | 6 + .../pojo/entity/PreSettlementDetail.java | 5 + .../pojo/vo/ReceivablePayableDetailVO.java | 4 + .../springblade/system/mapper/UserMapper.java | 12 +- .../springblade/system/mapper/UserMapper.xml | 23 +-- .../system/service/impl/DeptServiceImpl.java | 4 +- .../system/service/impl/UserServiceImpl.java | 4 +- .../excel/SettlementImportFeeReader.java | 21 ++- .../impl/FormalSettlementServiceImpl.java | 2 +- .../impl/PaymentApplicationServiceImpl.java | 131 +++++++++++++++- .../impl/PreSettlementServiceImpl.java | 31 +++- .../ReceivablePayableDetailServiceImpl.java | 145 +++++++++++++++--- .../SettlementImportBatchServiceImpl.java | 50 +++++- .../impl/WaybillImportBatchServiceImpl.java | 22 ++- doc/sql/bladex/bladex.mysql.all.create.sql | 4 +- .../blade_user_dept_index_20261019.sql | 6 + doc/sql/transport/transport.sql | 4 +- 19 files changed, 449 insertions(+), 70 deletions(-) create mode 100644 blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PaymentApplicationSettlementLineRequest.java create mode 100644 doc/sql/transport/blade_user_dept_index_20261019.sql 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 df7740a0..d608d7cb 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 @@ -61,4 +61,6 @@ public class PaymentApplicationSaveRequest implements Serializable { private String remark; private List invoices; private List paymentRecords; + /** 多张结算单时按行提交的付款金额 */ + private List settlementLines; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PaymentApplicationSettlementLineRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PaymentApplicationSettlementLineRequest.java new file mode 100644 index 00000000..78f28a11 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PaymentApplicationSettlementLineRequest.java @@ -0,0 +1,43 @@ +/** + * 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 lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; + +/** 付款申请多结算单付款明细。 @author Chill */ +@Data +public class PaymentApplicationSettlementLineRequest implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + private Long formalSettlementId; + private Long preSettlementId; + private BigDecimal paymentRatio; + private BigDecimal appliedAmount; +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ReceivablePayableFeeCalculateRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ReceivablePayableFeeCalculateRequest.java index 640ac839..04f1cd42 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ReceivablePayableFeeCalculateRequest.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ReceivablePayableFeeCalculateRequest.java @@ -63,4 +63,10 @@ public class ReceivablePayableFeeCalculateRequest implements Serializable { @Schema(description = "动态费用项目") private Map feeItems; + @Schema(description = "运输单价") + private BigDecimal unitPrice; + + @Schema(description = "本次调整字段:transportQuantity、mileage、unitPrice 或费用项名称") + private String changedField; + } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/PreSettlementDetail.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/PreSettlementDetail.java index 2539ca07..76c5653c 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/PreSettlementDetail.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/PreSettlementDetail.java @@ -9,6 +9,7 @@ */ package org.springblade.transport.pojo.entity; +import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @@ -99,6 +100,10 @@ public class PreSettlementDetail extends TenantEntity { @Schema(description = "批次号") private String batchNo; + @TableField(exist = false) + @Schema(description = "计费方式") + private String billingType; + @Schema(description = "运输单价") private BigDecimal unitPrice; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ReceivablePayableDetailVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ReceivablePayableDetailVO.java index 44857626..5fe0ab5d 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ReceivablePayableDetailVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ReceivablePayableDetailVO.java @@ -73,6 +73,10 @@ public class ReceivablePayableDetailVO extends ReceivablePayableDetail { @Schema(description = "状态名称") private String settlementStatusName; + @TableField(exist = false) + @Schema(description = "计费方式") + private String billingType; + @TableField(exist = false) @Schema(description = "费用项目") private Map feeItems; diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserMapper.java b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserMapper.java index 76a4be9f..a252c387 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserMapper.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserMapper.java @@ -45,13 +45,13 @@ public interface UserMapper extends BaseMapper { /** * 自定义分页 * - * @param page - * @param user - * @param deptIdList - * @param tenantId - * @return + * @param page 分页 + * @param user 查询条件 + * @param deptId 部门 ID(含自身及子孙,由 SQL FIND_IN_SET 过滤;为空则不过滤部门) + * @param tenantId 租户 ID + * @return 用户列表 */ - List selectUserPage(IPage page, @Param("user") User user, @Param("deptIdList") List deptIdList, @Param("tenantId") String tenantId); + List selectUserPage(IPage page, @Param("user") User user, @Param("deptId") Long deptId, @Param("tenantId") String tenantId); /** * 获取用户 diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserMapper.xml b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserMapper.xml index 23fff0b7..1cdec59f 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserMapper.xml +++ b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserMapper.xml @@ -61,20 +61,21 @@ and bu.status = #{user.status} - + and bu.id in ( - SELECT - user_id - FROM - blade_user_dept - WHERE - status = 1 and is_deleted = 0 and dept_id IN - - #{item} - + select distinct bud.user_id + from blade_user_dept bud + where bud.status = 1 + and bud.is_deleted = 0 + and bud.dept_id in ( + select bd.id + from blade_dept bd + where bd.is_deleted = 0 + and (bd.id = #{deptId} or find_in_set(#{deptId}, bd.ancestors) > 0) + ) ) - ORDER BY bu.id + ORDER BY bu.create_time DESC, bu.id DESC