From 21a9ce7d271fac864bbd6680e89fc6ac1aac2946 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Mon, 2 Nov 2026 17:14:02 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/README.md | 2 + ...V7__looseboard_demand_cargo_permission.sql | 13 ++ .../V37__looseboard_receiving_workflow.sql | 10 + deploy/sql/头程数据库统一增量-MySQL8.sql | 15 ++ deploy/sql/海外区域统一增量-MySQL8.sql | 9 + .../file/service/BusinessFileCategory.java | 5 +- .../LooseboardTransportPhotoFileTest.java | 19 ++ .../fleet-capacity-offer-cargo-permission.sql | 13 ++ ...astleg-internal-modularization-baseline.md | 2 + .../AppointmentWorkDispatchPort.java | 5 + ...DispatchLooseboardExecutionController.java | 42 ++++ .../controller/LooseboardCargoController.java | 31 +++ .../dispatch/fleet/DispatchFleetAdapter.java | 18 +- .../gateway/DispatchEvidenceGateway.java | 2 +- .../gateway/HttpDispatchEvidenceGateway.java | 2 +- .../mapper/DispatchCandidateMapper.java | 12 +- .../DispatchLooseboardEvidenceMapper.java | 29 +++ .../dispatch/mapper/DispatchSourceMapper.java | 8 +- .../mapper/LooseboardCargoMapper.java | 45 +++++ .../port/LooseboardTransportPort.java | 7 + .../DispatchLooseboardEvidenceRepository.java | 17 ++ .../repository/LooseboardCargoRepository.java | 18 ++ .../service/DispatchAggregateService.java | 90 +++++++-- .../DispatchAppointmentAllocations.java | 2 + .../service/DispatchCompletionService.java | 10 +- .../service/DispatchDepartureService.java | 9 +- .../DispatchLooseboardDisplayService.java | 45 +++++ .../DispatchLooseboardExecutionService.java | 162 +++++++++++++++ .../service/DispatchLooseboardFacts.java | 81 ++++++++ .../service/DispatchPdaQueryService.java | 7 +- .../service/DispatchPickingService.java | 4 +- .../dispatch/service/DispatchService.java | 2 +- .../service/LooseboardCargoService.java | 44 +++++ .../controller/ExceptionOrderController.java | 12 ++ .../dto/ExceptionReturnPlanRequest.java | 10 + .../mapper/ExceptionWorkflowMapper.java | 15 +- .../ExceptionReturnPlanRepository.java | 51 +++++ .../ExceptionWorkflowRepository.java | 8 +- .../service/ExceptionOrderService.java | 2 +- .../ExceptionReturnInboundService.java | 2 +- .../service/ExceptionReturnPlanService.java | 106 ++++++++++ .../fleet/trip/FleetTripExecutionService.java | 18 +- .../service/InboundPdaWorkflowService.java | 3 + .../work/AppointmentWorkDispatchBinding.java | 16 +- .../work/AppointmentWorkDispatchMapper.java | 10 +- .../PlatformLocalLooseboardController.java | 8 + .../dto/LocalLooseboardRequests.java | 43 +++- .../dto/LocalLooseboardResponses.java | 4 +- .../entity/LocalLooseboardOrder.java | 9 + .../mapper/LocalLooseboardOrderMapper.java | 4 + .../looseboard/port/LooseboardReceiving.java | 10 + .../repository/LocalLooseboardRepository.java | 4 + .../service/LocalLooseboardService.java | 81 ++++++-- .../LooseboardCommercialSourceAdapter.java | 14 ++ .../mapper/DeliveryReturnCargoMapper.java | 43 ++++ .../lastleg/model/LastlegOrderType.java | 2 + .../DeliveryReturnCargoRepository.java | 20 ++ .../lastleg/service/LastlegOrderService.java | 15 +- .../LooseboardPlannedCargoRepository.java | 43 ++++ .../LooseboardPlannedCargoService.java | 108 ++++++++++ .../LooseboardReceivingAdapter.java | 37 ++++ .../port/TransferCommercialSource.java | 8 + .../service/WarehouseTransferService.java | 33 +++- .../trucking/service/UsTruckOrderService.java | 3 + .../wallet/budget/OrderBudgetService.java | 2 +- .../src/main/resources/db/lastleg.sql | 9 + .../dispatch-looseboard-execution-v1.yaml | 68 +++++++ .../openapi/exception-return-inbound-v1.yaml | 30 +++ .../openapi/overseas-business-v1.yaml | 73 ++++++- .../DispatchCandidateMapperContractTest.java | 24 ++- .../DispatchAggregatePersistenceTest.java | 16 ++ .../DispatchCompletionPersistenceTest.java | 18 +- ...DispatchInboundReceiptPersistenceTest.java | 11 ++ .../DispatchLooseboardDisplayServiceTest.java | 69 +++++++ ...ispatchLooseboardExecutionServiceTest.java | 178 +++++++++++++++++ ...ispatchLooseboardPhotoPersistenceTest.java | 64 ++++++ .../DispatchMergedRelabelPersistenceTest.java | 2 +- .../DispatchRegionalCandidatesTest.java | 1 + .../dispatch/service/DispatchServiceTest.java | 12 ++ .../DeliveryReturnCargoMapperTest.java | 46 +++++ .../service/ExceptionOrderLifecycleTest.java | 15 ++ .../ExceptionReturnInboundServiceTest.java | 2 +- .../ExceptionReturnPlanServiceTest.java | 73 +++++++ .../InboundPdaWorkflowServiceTest.java | 11 ++ .../AppointmentWorkDispatchBindingTest.java | 16 ++ ...ppointmentWorkDispatchPersistenceTest.java | 4 +- .../AppointmentWorkReceivedPhysicalTest.java | 12 +- .../LocalLooseboardRequestsTest.java | 6 +- ...PlatformLocalLooseboardControllerTest.java | 4 +- .../service/LocalLooseboardServiceTest.java | 4 +- .../TaillegRealConsumerAcceptanceTest.java | 2 +- .../LooseboardPlannedCargoServiceTest.java | 57 ++++++ .../service/WarehouseTransferServiceTest.java | 17 ++ .../service/UsTruckOrderServiceTest.java | 7 + .../budget/OrderBudgetPersistenceTest.java | 69 ++++++- .../lastleg-slice-dependencies.json | 30 +++ scripts/README.md | 2 + .../local-looseboard-receiving-acceptance.mjs | 186 ++++++++++++++++++ 98 files changed, 2558 insertions(+), 104 deletions(-) create mode 100644 deploy/sql/migrations/cn/baiyue_iam/V7__looseboard_demand_cargo_permission.sql create mode 100644 deploy/sql/migrations/overseas/baiyue_lastleg/V37__looseboard_receiving_workflow.sql create mode 100644 file/src/test/java/com/baiyue/file/service/LooseboardTransportPhotoFileTest.java create mode 100644 iam/src/main/resources/db/fleet-capacity-offer-cargo-permission.sql create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchLooseboardExecutionController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/LooseboardCargoController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchLooseboardEvidenceMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/LooseboardCargoMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/LooseboardTransportPort.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchLooseboardEvidenceRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/LooseboardCargoRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLooseboardDisplayService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLooseboardExecutionService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLooseboardFacts.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/LooseboardCargoService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionReturnPlanRequest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/repository/ExceptionReturnPlanRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnPlanService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/port/LooseboardReceiving.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LooseboardCommercialSourceAdapter.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/mapper/DeliveryReturnCargoMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/repository/DeliveryReturnCargoRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/looseboard/LooseboardPlannedCargoRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/looseboard/LooseboardPlannedCargoService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/looseboard/LooseboardReceivingAdapter.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/port/TransferCommercialSource.java create mode 100644 lastleg-service/src/main/resources/openapi/dispatch-looseboard-execution-v1.yaml create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLooseboardDisplayServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLooseboardExecutionServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLooseboardPhotoPersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/DeliveryReturnCargoMapperTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnPlanServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/LooseboardPlannedCargoServiceTest.java create mode 100644 scripts/local-looseboard-receiving-acceptance.mjs diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 54761b447..c50bc26d6 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -1,5 +1,7 @@ # 单测试环境数据库空库初始化 +散板收货闭环:`migrations/overseas/baiyue_lastleg/V37__looseboard_receiving_workflow.sql` 为已有 Lastleg 库增加入仓方式、收货仓、联系人、预计到仓、物流号及转运关联字段;新库使用同步后的海外统一基线。IAM 使用 `migrations/cn/baiyue_iam/V7__looseboard_demand_cargo_permission.sql` 补齐“散板需求”菜单及“加入散板货物”按钮,并为既有平台管理员补取货排车创建权限;权限源同步于 `iam/src/main/resources/db/fleet-capacity-offer-cargo-permission.sql` 和国内统一基线。两项已在本机应用并通过两场景HTTP验收,未据此认定TEST或生产已部署;已有库不可重放空库基线,V37执行前须核对九个新增字段尚不存在。 + `migrations/overseas/baiyue_lastleg/V35__widen_finance_fee_command_action.sql` 修复海外人工费用资金重试报 S0500:仅将 `baiyue_lastleg.ll_finance_fee_command.action` 扩为 `VARCHAR(32)`,容纳 20 字符的 `MANUAL_FUNDING_RETRY`,保留中文备注、非空及原幂等唯一键。Lastleg 源基线和海外空库基线同步。已有环境先核对目标库及列定义,经授权执行 V35、回查列长度和记录总数,再通过原费用重试入口验证;可重复执行,不修改费用、审核、余额或资金记录,不重放空库基线。DDL 隐式提交,遇错停止;本次仅准备修复文件,尚未执行测试环境 SQL 或部署,文件存在不表示已经执行。 `overseas-fee-menu-finance-parent-repair.sql` 为已有 `baiyue_iam` 的定向权限修复:将菜单 `platform:overseas-fee:list` 改名为“海外费用明细”,父菜单设为 `platform:finance`(财务管理),保留原路由和 PLATFORM_ADMIN 授权;可重复执行,目标菜单缺失或权限身份冲突时停止。 diff --git a/deploy/sql/migrations/cn/baiyue_iam/V7__looseboard_demand_cargo_permission.sql b/deploy/sql/migrations/cn/baiyue_iam/V7__looseboard_demand_cargo_permission.sql new file mode 100644 index 000000000..9cebe5712 --- /dev/null +++ b/deploy/sql/migrations/cn/baiyue_iam/V7__looseboard_demand_cargo_permission.sql @@ -0,0 +1,13 @@ +-- 散板需求沿用既有菜单权限,补全当前名称和添加货物独立按钮。 +INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) +SELECT 5107410,0,'PLATFORM','MENU',0,'platform:fleet:capacity-offer:list','散板需求','/overseas-master-data/capacity-offers','GET',1040,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0 +WHERE NOT EXISTS (SELECT 1 FROM iam_permission WHERE permission_code='platform:fleet:capacity-offer:list'); +UPDATE iam_permission SET permission_name='散板需求',status='ACTIVE',deleted=0,updated_at=CURRENT_TIMESTAMP(3) WHERE permission_code='platform:fleet:capacity-offer:list'; +INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) +SELECT 5107415,0,'PLATFORM','BUTTON',p.id,'platform:fleet:capacity-offer:cargo-add','加入散板货物',NULL,'POST',1045,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0 FROM iam_permission p +WHERE p.permission_code='platform:fleet:capacity-offer:list' AND NOT EXISTS (SELECT 1 FROM iam_permission WHERE permission_code='platform:fleet:capacity-offer:cargo-add'); +INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) +SELECT 4000001,p.id,0,CURRENT_TIMESTAMP(3) FROM iam_permission p WHERE p.permission_code IN ('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:cargo-add') AND NOT EXISTS (SELECT 1 FROM iam_role_permission rp WHERE rp.role_id=4000001 AND rp.permission_id=p.id); +-- 上门取货复用正式排车创建入口,仅补齐既有平台管理员角色的必要权限。 +INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) +SELECT 4000001,p.id,0,CURRENT_TIMESTAMP(3) FROM iam_permission p WHERE p.permission_code='platform:overseas-warehouse:dispatch:create' AND p.status='ACTIVE' AND p.deleted=0 AND NOT EXISTS (SELECT 1 FROM iam_role_permission rp WHERE rp.role_id=4000001 AND rp.permission_id=p.id); diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V37__looseboard_receiving_workflow.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V37__looseboard_receiving_workflow.sql new file mode 100644 index 000000000..9eab53bbb --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V37__looseboard_receiving_workflow.sql @@ -0,0 +1,10 @@ +ALTER TABLE ll_local_looseboard_order + ADD COLUMN delivery_mode VARCHAR(32) NULL COMMENT '入仓方式:送货到仓或仓库上门取货', + ADD COLUMN operating_warehouse_id VARCHAR(128) NULL COMMENT '实际收货海外仓ID', + ADD COLUMN destination_type VARCHAR(16) NULL COMMENT '目的地类型:FBA或私人地址', + ADD COLUMN destination_postal_code VARCHAR(32) NULL COMMENT '私人目的地邮编', + ADD COLUMN delivery_contact_name VARCHAR(128) NULL COMMENT '送货到仓联系人', + ADD COLUMN delivery_contact_phone VARCHAR(64) NULL COMMENT '送货到仓联系电话', + ADD COLUMN estimated_arrival_at DATETIME(3) NULL COMMENT '预计到仓时间', + ADD COLUMN tracking_no VARCHAR(255) NULL COMMENT '物流跟踪号', + ADD COLUMN transfer_no VARCHAR(64) NULL COMMENT '关联的仓储转运收货单号'; diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 610c824f0..17eb40168 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -8474,3 +8474,18 @@ SET @discard_actual_marks_ddl = IF(EXISTS(SELECT 1 FROM information_schema.colum PREPARE discard_actual_marks_stmt FROM @discard_actual_marks_ddl; EXECUTE discard_actual_marks_stmt; DEALLOCATE PREPARE discard_actual_marks_stmt; + +USE baiyue_iam; +-- 散板需求沿用既有菜单权限,补全当前名称和添加货物独立按钮。 +INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) +SELECT 5107410,0,'PLATFORM','MENU',0,'platform:fleet:capacity-offer:list','散板需求','/overseas-master-data/capacity-offers','GET',1040,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0 +WHERE NOT EXISTS (SELECT 1 FROM iam_permission WHERE permission_code='platform:fleet:capacity-offer:list'); +UPDATE iam_permission SET permission_name='散板需求',status='ACTIVE',deleted=0,updated_at=CURRENT_TIMESTAMP(3) WHERE permission_code='platform:fleet:capacity-offer:list'; +INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) +SELECT 5107415,0,'PLATFORM','BUTTON',p.id,'platform:fleet:capacity-offer:cargo-add','加入散板货物',NULL,'POST',1045,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0 FROM iam_permission p +WHERE p.permission_code='platform:fleet:capacity-offer:list' AND NOT EXISTS (SELECT 1 FROM iam_permission WHERE permission_code='platform:fleet:capacity-offer:cargo-add'); +INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) +SELECT 4000001,p.id,0,CURRENT_TIMESTAMP(3) FROM iam_permission p WHERE p.permission_code IN ('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:cargo-add') AND NOT EXISTS (SELECT 1 FROM iam_role_permission rp WHERE rp.role_id=4000001 AND rp.permission_id=p.id); +-- 上门取货复用正式排车创建入口,仅补齐既有平台管理员角色的必要权限。 +INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) +SELECT 4000001,p.id,0,CURRENT_TIMESTAMP(3) FROM iam_permission p WHERE p.permission_code='platform:overseas-warehouse:dispatch:create' AND p.status='ACTIVE' AND p.deleted=0 AND NOT EXISTS (SELECT 1 FROM iam_role_permission rp WHERE rp.role_id=4000001 AND rp.permission_id=p.id); diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index e183b1cbb..2ae038db0 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -1599,6 +1599,15 @@ CREATE TABLE ll_local_looseboard_order ( id bigint NOT NULL COMMENT '主键ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', lastleg_order_id bigint DEFAULT NULL COMMENT '关联尾程主订单ID', + delivery_mode VARCHAR(32) NULL COMMENT '入仓方式:送货到仓或仓库上门取货', + operating_warehouse_id VARCHAR(128) NULL COMMENT '实际收货海外仓ID', + destination_type VARCHAR(16) NULL COMMENT '目的地类型:FBA或私人地址', + destination_postal_code VARCHAR(32) NULL COMMENT '私人目的地邮编', + delivery_contact_name VARCHAR(128) NULL COMMENT '送货到仓联系人', + delivery_contact_phone VARCHAR(64) NULL COMMENT '送货到仓联系电话', + estimated_arrival_at DATETIME(3) NULL COMMENT '预计到仓时间', + tracking_no VARCHAR(255) NULL COMMENT '物流跟踪号', + transfer_no VARCHAR(64) NULL COMMENT '关联的仓储转运收货单号', order_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本土散板订单号', pallet_required varchar(8) COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '是否需要打盘:YES或NO;拼车来源未提供时为空', transport_type VARCHAR(32) NULL DEFAULT NULL COMMENT '接单运输安排;下单不预设', diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java index fe85e01e9..96dc4e0fd 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -14,6 +14,8 @@ enum BusinessFileCategory { OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_DISPATCH_LOOSEBOARD_PICKUP_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_DISPATCH_LOOSEBOARD_ARRIVAL_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_LOADING_PHOTO(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_FUEL_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), @@ -82,7 +84,8 @@ enum BusinessFileCategory { && tenantId != 0) { throw new BizException(ResultCode.BAD_REQUEST, "排车运营照片必须归属平台"); } - if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE || this == OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE) + if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE || this == OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE + || this == OVERSEAS_DISPATCH_LOOSEBOARD_PICKUP_EVIDENCE || this == OVERSEAS_DISPATCH_LOOSEBOARD_ARRIVAL_EVIDENCE) && tenantId != 0) { throw new BizException(ResultCode.BAD_REQUEST, "排车证据必须归属平台"); } diff --git a/file/src/test/java/com/baiyue/file/service/LooseboardTransportPhotoFileTest.java b/file/src/test/java/com/baiyue/file/service/LooseboardTransportPhotoFileTest.java new file mode 100644 index 000000000..db7497d6d --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/LooseboardTransportPhotoFileTest.java @@ -0,0 +1,19 @@ +package com.baiyue.file.service; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class LooseboardTransportPhotoFileTest { + /** 两节点文件独立归属原排车,客户文件及PDF不能充当现场照片。 */ + @Test void photosHaveDistinctPlatformOwnershipAndImagePolicy(){ + var pickup=BusinessFileCategory.require("OVERSEAS_DISPATCH_LOOSEBOARD_PICKUP_EVIDENCE"); + var arrival=BusinessFileCategory.require("OVERSEAS_DISPATCH_LOOSEBOARD_ARRIVAL_EVIDENCE"); + assertThat(pickup).isNotEqualTo(arrival); + for(var category:new BusinessFileCategory[]{pickup,arrival}){ + category.validateOwner(0);assertThatThrownBy(()->category.validateOwner(100)).hasMessageContaining("平台"); + assertThat(category.formatPolicy()).isEqualTo(BusinessFileCategory.FormatPolicy.IMAGE); + assertThat(category.storageBusinessId("100")).isEqualTo("100"); + assertThatThrownBy(()->category.storageBusinessId("../100")).isInstanceOf(RuntimeException.class); + } + } +} diff --git a/iam/src/main/resources/db/fleet-capacity-offer-cargo-permission.sql b/iam/src/main/resources/db/fleet-capacity-offer-cargo-permission.sql new file mode 100644 index 000000000..9cebe5712 --- /dev/null +++ b/iam/src/main/resources/db/fleet-capacity-offer-cargo-permission.sql @@ -0,0 +1,13 @@ +-- 散板需求沿用既有菜单权限,补全当前名称和添加货物独立按钮。 +INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) +SELECT 5107410,0,'PLATFORM','MENU',0,'platform:fleet:capacity-offer:list','散板需求','/overseas-master-data/capacity-offers','GET',1040,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0 +WHERE NOT EXISTS (SELECT 1 FROM iam_permission WHERE permission_code='platform:fleet:capacity-offer:list'); +UPDATE iam_permission SET permission_name='散板需求',status='ACTIVE',deleted=0,updated_at=CURRENT_TIMESTAMP(3) WHERE permission_code='platform:fleet:capacity-offer:list'; +INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) +SELECT 5107415,0,'PLATFORM','BUTTON',p.id,'platform:fleet:capacity-offer:cargo-add','加入散板货物',NULL,'POST',1045,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0 FROM iam_permission p +WHERE p.permission_code='platform:fleet:capacity-offer:list' AND NOT EXISTS (SELECT 1 FROM iam_permission WHERE permission_code='platform:fleet:capacity-offer:cargo-add'); +INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) +SELECT 4000001,p.id,0,CURRENT_TIMESTAMP(3) FROM iam_permission p WHERE p.permission_code IN ('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:cargo-add') AND NOT EXISTS (SELECT 1 FROM iam_role_permission rp WHERE rp.role_id=4000001 AND rp.permission_id=p.id); +-- 上门取货复用正式排车创建入口,仅补齐既有平台管理员角色的必要权限。 +INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) +SELECT 4000001,p.id,0,CURRENT_TIMESTAMP(3) FROM iam_permission p WHERE p.permission_code='platform:overseas-warehouse:dispatch:create' AND p.status='ACTIVE' AND p.deleted=0 AND NOT EXISTS (SELECT 1 FROM iam_role_permission rp WHERE rp.role_id=4000001 AND rp.permission_id=p.id); diff --git a/lastleg-service/docs/lastleg-internal-modularization-baseline.md b/lastleg-service/docs/lastleg-internal-modularization-baseline.md index 77e3cffa7..fd1ca3f46 100644 --- a/lastleg-service/docs/lastleg-internal-modularization-baseline.md +++ b/lastleg-service/docs/lastleg-internal-modularization-baseline.md @@ -4,6 +4,8 @@ ### 当前维护入口(2026-10-02) +散板照片运输(2026-11-02):`dispatch.port.LooseboardTransportPort` 由排车声明、`LocalLooseboardService` 实现,排车只输出已经保存照片的运输进度,原单域校验同客户关联及累计送达箱数;不从排车直接访问散板持久化,不生成库存或PDA流水。该依赖按精确类引用登记,不授权其他跨切片实现引用。 + 现行门禁为 `scripts/analyze-lastleg-dependencies.mjs`、对应 `.test.mjs` 和 `src/test/resources/architecture/lastleg-slice-dependencies.json`。下文第2至8节的规模、原安全投影和首切片说明保留为早期对比,不覆盖本节及当前正式统一认证契约。此次校正不修改既有跨业务流程,也不以新增模块级通配许可接受未知依赖。 - 统一认证使用common-security及Auth权威会话。旧6个LastlegSecurity投影类型不再要求存在,改为禁止恢复该切片及引用其退役类型;不存在的退役类被重新import也会失败。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java index f11b6cfa8..cbef9b644 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java @@ -30,6 +30,11 @@ public interface AppointmentWorkDispatchPort { /** 仅替换已核实的受影响实物分配,保留同车其他客户及其他实物。 */ void bindItems(long dispatchId,List touchedItems,List allocations); void requireDeparture(String dispatchNo); + /** 纯散板照片发运锁定原预约并核对计划或实收来源,不要求仓内拣货。 */ + void requirePhotoDeparture(String dispatchNo); + /** 混排仅指定权威异地取货明细按计划核验,其余明细仍核验实际拣货。 */ + void requireMixedDeparture(String dispatchNo,java.util.Set remoteItemIds); + String mixedDepartureBlockReason(String dispatchNo,java.util.Set remoteItemIds); /** Read-only explanation; departure must repeat the same authority checks while holding batch locks. */ String departureBlockReason(String dispatchNo); record ReceivingWindow(String batchKey,String batchNo,String customerId,String destinationKey,String confirmationNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchLooseboardExecutionController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchLooseboardExecutionController.java new file mode 100644 index 000000000..90dd8f5a8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchLooseboardExecutionController.java @@ -0,0 +1,42 @@ +package com.baiyue.lastleg.dispatch.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.dispatch.service.DispatchLooseboardExecutionService; +import com.baiyue.lastleg.dispatch.service.DispatchLooseboardExecutionService.*; +import com.baiyue.lastleg.dispatch.service.DispatchLooseboardFacts.Phase; +import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +/** 散板取货/送达照片复用排车list/edit权限,前端不能提交商业归属。 */ +@RestController +@Validated +@RequestMapping("/api/platform/v1/last-leg/dispatches/{number}/looseboard-execution") +public class DispatchLooseboardExecutionController { + private final DispatchLooseboardExecutionService service; + /** 入口只映射认证主体和原车。 */ + public DispatchLooseboardExecutionController(DispatchLooseboardExecutionService service){this.service=service;} + /** 返回逐票照片及服务端节点资格。 */ + @GetMapping @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')") + public ApiResponse get(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number){return ApiResponse.success(service.get(DispatchActorFactory.platform(user),number));} + /** 上传不提前推进业务状态。 */ + @PostMapping(value="/{itemId}/{phase}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@PathVariable @Positive long itemId,@PathVariable Phase phase,@RequestPart("file") MultipartFile file){return ApiResponse.success(service.upload(DispatchActorFactory.platform(user),number,itemId,phase,file));} + /** 照片保存与节点推进同事务,公共版本防止并发覆盖。 */ + @PutMapping("/{itemId}/{phase}") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse save(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@PathVariable @Positive long itemId,@PathVariable Phase phase,@Valid @RequestBody Save request){return ApiResponse.success(service.save(DispatchActorFactory.platform(user),number,itemId,phase,request));} + /** 当前绑定照片按原车、明细、类别读取,无公共下载地址。 */ + @GetMapping("/{itemId}/{phase}/files/{fileId}/content") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')") + public ResponseEntity content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@PathVariable @Positive long itemId,@PathVariable Phase phase,@PathVariable @Positive long fileId){ + var file=service.content(DispatchActorFactory.platform(user),number,itemId,phase,fileId); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(file.file().contentType())).header("X-Content-Type-Options","nosniff").body(file.bytes()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/LooseboardCargoController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/LooseboardCargoController.java new file mode 100644 index 000000000..021801424 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/LooseboardCargoController.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.dispatch.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.service.LooseboardCargoService; +import org.springframework.web.bind.annotation.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import jakarta.validation.constraints.*; +import org.springframework.validation.annotation.Validated; + +/** 散板需求加货需同时具备需求池读取和排车维护权限。 */ +@RestController @Validated +@RequestMapping("/api/platform/v1/last-leg/dispatches/capacity-demands/{dispatchNo}/cargo") +@PreAuthorize("hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage') and hasAuthority('platform:overseas-warehouse:dispatch:list')") +public class LooseboardCargoController { + private final LooseboardCargoService service; + public LooseboardCargoController(LooseboardCargoService service) { this.service=service; } + @GetMapping + public ApiResponse candidates(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo, + @RequestParam(defaultValue="1") @Min(1) int page,@RequestParam(defaultValue="20") @Min(1) @Max(50) int size) { + return ApiResponse.success(service.candidates(DispatchActorFactory.platform(user),dispatchNo,page,size)); + } + @PostMapping + @PreAuthorize("hasAnyAuthority('platform:fleet:capacity-offer:cargo-add','platform:fleet:capacity-offer:manage') and hasAuthority('platform:overseas-warehouse:dispatch:edit') and hasAuthority('platform:overseas-warehouse:dispatch:list')") + public ApiResponse add(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.AddCargo request) { + return ApiResponse.success(service.add(DispatchActorFactory.platform(user),dispatchNo,request)); + } +} + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/DispatchFleetAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/DispatchFleetAdapter.java index a5fd6f89b..e00157c0f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/DispatchFleetAdapter.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/DispatchFleetAdapter.java @@ -25,6 +25,7 @@ import java.util.stream.Collectors; @Component @Transactional(propagation=Propagation.MANDATORY) public class DispatchFleetAdapter implements FleetDispatchGateway { + @jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.service.DispatchLooseboardFacts looseboardFacts; private final DispatchAggregateRepository aggregates; private final OutboundOrderMapper outbounds; private final DeliveryOrderMapper deliveries; @@ -79,7 +80,7 @@ public class DispatchFleetAdapter implements FleetDispatchGateway { } @Override public FleetExecutionModels.Manifest manifest(String number,boolean lock) { var context=workflow.fleetContext(number,lock);var row=required(number,false); - var cargo=aggregates.cargo(row.getId()).stream().map(i->new FleetExecutionModels.Cargo(number,i.getCustomerId(),i.getId().toString(),i.getWarehousePalletId()==null?null:i.getWarehousePalletId().toString(),i.getPalletNo(),i.getPackageCount(), + var cargo=warehouseItems(row).stream().map(i->new FleetExecutionModels.Cargo(number,i.getCustomerId(),i.getId().toString(),i.getWarehousePalletId()==null?null:i.getWarehousePalletId().toString(),i.getPalletNo(),i.getPackageCount(), destination(i.getDestinationType(),i.getDestinationWarehouseCode(),i.getFbaWarehouseCode(),i.getDeliveryAddress(),i.getPostalCode()),"PRIVATE".equals(i.getDestinationType())?i.getDeliveryAddress():first(i.getDestinationWarehouseCode(),i.getFbaWarehouseCode()))).toList(); String reason=!"ACTIVE".equals(row.getStatus())?"排车已取消":!Set.of("AFTER_LOADING","DELIVERING","DELIVERED").contains(row.getStage())?"请先核平扫码并确认装车完成":!context.pickingReconciled()?"本车逐来源扫码未核平":null; var vehicle=context.resources().vehicle();var driver=context.resources().driver(); @@ -90,10 +91,11 @@ public class DispatchFleetAdapter implements FleetDispatchGateway { var row=required(number,lock); var outboundRows=(lock?outbounds.lockAllByDispatch(row.getId()):outbounds.findAllByDispatch(row.getId())).stream().filter(o->!"CANCELLED".equals(o.getStatus())).toList(); var deliveryRows=(lock?deliveries.lockAllByDispatch(row.getId()):deliveries.findAllByDispatch(row.getId())).stream().filter(d->!"CANCELLED".equals(d.getStatus())).toList(); - var expected=aggregates.cargo(row.getId()).stream().collect(Collectors.groupingBy(i->i.getCustomerId(),Collectors.summingLong(i->i.getPackageCount()))); + var expected=warehouseItems(row).stream().collect(Collectors.groupingBy(i->i.getCustomerId(),Collectors.summingLong(i->i.getPackageCount()))); var byCustomer=new HashMap(); for(var outbound:outboundRows)if(byCustomer.put(outbound.getCustomerId(),outbound)!=null || !Objects.equals(outbound.getDispatchOrderId(),row.getId()) || !Objects.equals(outbound.getOverseasWarehouseId(),row.getOverseasWarehouseId()) || !"OUTBOUNDED".equals(outbound.getStatus()) || !Objects.equals(expected.get(outbound.getCustomerId()),Long.valueOf(outbound.getPackageCount())))throw conflict("客户出库归属或数量与本车发运不一致"); - if(!byCustomer.keySet().equals(expected.keySet()) || expected.values().stream().mapToLong(Long::longValue).sum()!=row.getShippedPackageCount())throw conflict("客户出库集合未完整覆盖本车实发货物"); + long remotePicked=looseboardFacts==null?0:looseboardFacts.pickedRemotePackages(row); + if(!byCustomer.keySet().equals(expected.keySet()) || expected.values().stream().mapToLong(Long::longValue).sum()+remotePicked!=row.getShippedPackageCount())throw conflict("客户出库集合未完整覆盖本车实发货物"); var counts=new HashMap();var numbers=new HashSet(); for(var delivery:deliveryRows) { var outbound=byCustomer.get(delivery.getCustomerId()); @@ -107,12 +109,20 @@ public class DispatchFleetAdapter implements FleetDispatchGateway { @Override public FleetCompletionModels.DispatchCompletion completion(String number,boolean lock) { var row=required(number,lock); if(!"ACTIVE".equals(row.getStatus()) || !Set.of("DELIVERING","DELIVERED").contains(row.getStage()) || row.getDepartedAt()==null)return new FleetCompletionModels.DispatchCompletion(number,row.getOverseasWarehouseId(),List.of(),"本车实际发运尚未完成"); + if(looseboardFacts!=null && !looseboardFacts.cargo(row.getId()).isEmpty()) { + boolean ready=looseboardFacts.complete(row,com.baiyue.lastleg.dispatch.service.DispatchLooseboardFacts.Phase.PICKUP) + && looseboardFacts.complete(row,com.baiyue.lastleg.dispatch.service.DispatchLooseboardFacts.Phase.ARRIVAL); + if(looseboardFacts.direct(row))return new FleetCompletionModels.DispatchCompletion(number,row.getOverseasWarehouseId(),looseboardFacts.outcomes(row),ready?null:"散板逐票取货和送达照片尚未齐全"); + if(!ready)return new FleetCompletionModels.DispatchCompletion(number,row.getOverseasWarehouseId(),List.of(),"混排散板逐票取货和送达照片尚未齐全"); + } var facts=facts(number,lock);var outcomes=new ArrayList(); for(var delivery:facts.deliveries()) { var receipts=operations.returnReceipts(delivery.getCustomerId(),delivery.getId());var quantities=quantities(delivery,receipts); Instant terminal=originallySigned(delivery)?instant(delivery.getSignedAt()):"RETURNED".equals(delivery.getStatus())?instant(operations.returnedAt(delivery.getCustomerId(),delivery.getId())):null; outcomes.add(new FleetCompletionModels.Outcome(number,delivery.getDeliveryNo(),delivery.getCustomerId(),"PRIVATE".equals(delivery.getDestinationType())?delivery.getDeliveryAddress():first(delivery.getDestinationWarehouseCode(),delivery.getDestinationWarehouseName()),originallySigned(delivery)?"DELIVERED":delivery.getStatus(),delivery.getPackageCount(),terminal,quantities.signed(),quantities.returned(),quantities.remaining())); } + if(looseboardFacts!=null){var remote=looseboardFacts.remoteItemIds(row.getId());var orders=looseboardFacts.cargo(row.getId()).stream().filter(c->remote.contains(c.item().getId())).map(c->c.item().getCustomerId()+":"+c.orderNo()).collect(Collectors.toSet()); + outcomes.addAll(looseboardFacts.outcomes(row).stream().filter(o->orders.contains(o.customerId()+":"+o.deliveryNo())).toList());} String reason=outcomes.stream().anyMatch(o->o.remainingPackages()!=0 || o.signedPackages()+o.returnedPackages()!=o.packages() || o.terminalAt()==null || !Set.of("DELIVERED","RETURNED").contains(o.status()))?"本车货物须实际签收或完成真实退回入仓":null; return new FleetCompletionModels.DispatchCompletion(number,row.getOverseasWarehouseId(),List.copyOf(outcomes),reason); } @@ -140,6 +150,8 @@ public class DispatchFleetAdapter implements FleetDispatchGateway { if(deliveries.update(null,new LambdaUpdateWrapper().eq(DeliveryOrder::getId,row.getId()).eq(DeliveryOrder::getCustomerId,fact.cargo().customerId()).eq(DeliveryOrder::getVersion,fact.recordVersion()).eq(DeliveryOrder::getStatus,fact.status()).isNull(DeliveryOrder::getSignedAt).set(DeliveryOrder::getVehiclePlate,assignment.vehiclePlate()).set(DeliveryOrder::getDriverName,assignment.driverName()).set(DeliveryOrder::getDriverPhone,assignment.driverPhone()).set(DeliveryOrder::getVersion,fact.recordVersion()+1).set(DeliveryOrder::getUpdatedAt,LocalDateTime.now()))!=1)throw conflict("派送交接保存失败"); } } + /** 混排照片取货不产生客户出库单,历史实托和其他来源仍按原链核对。 */ + private List warehouseItems(DispatchAggregate row){return looseboardFacts==null?aggregates.cargo(row.getId()):looseboardFacts.warehouseItems(row.getId());} private DispatchAggregate required(String number,boolean lock) {var row=lock?aggregates.lock(number):aggregates.find(number);if(row==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"排车单不存在");return row;} private static String destination(String type,String code,String fba,String address,String postal){return FleetDispatchGateway.destinationKey(type,code,fba,address,postal);} /** 派送签收/退回生产者保存服务器本地LocalDateTime,按原提供者约定还原真实时刻。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/DispatchEvidenceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/DispatchEvidenceGateway.java index 5d9dd30ab..c38c4ae92 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/DispatchEvidenceGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/DispatchEvidenceGateway.java @@ -4,7 +4,7 @@ import org.springframework.web.multipart.MultipartFile; /** 车辆、司机及审核补充证据属于本张整车,不能借司机主档或客户文件授权。 */ public interface DispatchEvidenceGateway { - enum Kind { VEHICLE, DRIVER, DRIVER_PHOTO, EXCEPTION } + enum Kind { VEHICLE, DRIVER, DRIVER_PHOTO, EXCEPTION, LOOSEBOARD_PICKUP, LOOSEBOARD_ARRIVAL } record File(Long fileId,String fileName,String contentType,long sizeBytes) { } /** 上传只写本车、本类证据。 */ File upload(long dispatchId,Kind kind,String operator,MultipartFile file); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGateway.java index 579473683..38158b040 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGateway.java @@ -85,7 +85,7 @@ public class HttpDispatchEvidenceGateway implements DispatchEvidenceGateway { } /** 司机本人照片与异常证据只接受图片;车辆和驾驶证沿现有PDF/图片证据规则。 */ private static boolean supportedType(Kind kind,String contentType) { - return TYPES.contains(Objects.toString(contentType,"")) && ((kind!=Kind.DRIVER_PHOTO && kind!=Kind.EXCEPTION) || !"application/pdf".equals(contentType)); + return TYPES.contains(Objects.toString(contentType,"")) && ((kind!=Kind.DRIVER_PHOTO && kind!=Kind.EXCEPTION && kind!=Kind.LOOSEBOARD_PICKUP && kind!=Kind.LOOSEBOARD_ARRIVAL) || !"application/pdf".equals(contentType)); } /** 无效证据不能作为已核实资料。 */ private static ResponseStatusException invalid(){return new ResponseStatusException(HttpStatus.BAD_REQUEST,"证据须为本车有效PDF、JPG或PNG,最大10MiB");} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java index f145bd43f..b02876349 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java @@ -26,6 +26,14 @@ public interface DispatchCandidateMapper { "AND dn.node_code='ACTUAL_DEPARTURE' AND dn.node_at IS NOT NULL) ELSE EXISTS (" + "SELECT 1 FROM ll_container_shipping_gate dg WHERE dg.container_booking_id=departure_order.source_container_booking_id " + "AND dg.node_sequence>=40 AND dg.actual_at IS NOT NULL) END)))"; + /** 散板审核后的同仓申报可预排;取消、未审和普通转运不取得此资格。 */ + String APPROVED_LOOSEBOARD = "EXISTS (SELECT 1 FROM ll_local_looseboard_order lb JOIN ll_warehouse_transfer_order lt " + + "ON lt.transfer_no=lb.transfer_no AND lt.customer_id=lb.customer_id " + + "WHERE lb.lastleg_order_id=ip.source_order_id AND lb.customer_id=ip.customer_id " + + "AND lb.status IN ('WAITING_INBOUND','RECEIVING','WAITING_CONSOLIDATION','PENDING_DISPATCH','DISPATCHED') " + + "AND lt.lastleg_order_id=ip.source_order_id AND CAST(lt.operating_warehouse_id AS DECIMAL(20,0))=ip.overseas_warehouse_id " + + "AND lt.status IN ('WAIT_PDA_RECEIVING','WAIT_CUSTOMER_CONFIRMATION','READY_FOR_DISPATCH') " + + "AND p.source_pallet_no LIKE 'LB-PLAN:%')"; // Only the receiving party's confirmed appointment is a transport reference; it is not a loading plan. String CONFIRMED_APPOINTMENT_AT = "(SELECT CASE WHEN COUNT(DISTINCT fa.id)=1 THEN MAX(fa.appointment_at) END FROM ll_inbound_plan_pallet_allocation pa " + @@ -180,7 +188,7 @@ public interface DispatchCandidateMapper { "JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " + "JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " + "WHERE ha.pallet_id=p.id AND ho.detained=1) " + - "AND (NOT " + PLAN_ONLY + " OR (" + OPEN_PLAN + " AND " + DEPARTED_PLAN + ")) " + + "AND (NOT " + PLAN_ONLY + " OR (" + OPEN_PLAN + " AND " + "(" + DEPARTED_PLAN + " OR " + APPROVED_LOOSEBOARD + "))) " + "AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " + "WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " + "AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " + @@ -285,7 +293,7 @@ public interface DispatchCandidateMapper { "JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " + "JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " + "WHERE ha.pallet_id=p.id AND ho.detained=1) " + - "AND (NOT " + PLAN_ONLY + " OR (" + OPEN_PLAN + " AND " + DEPARTED_PLAN + ")) " + + "AND (NOT " + PLAN_ONLY + " OR (" + OPEN_PLAN + " AND " + "(" + DEPARTED_PLAN + " OR " + APPROVED_LOOSEBOARD + "))) " + "AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " + "WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " + "AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchLooseboardEvidenceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchLooseboardEvidenceMapper.java new file mode 100644 index 000000000..b4c9ebd11 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchLooseboardEvidenceMapper.java @@ -0,0 +1,29 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import org.apache.ibatis.annotations.*; +import java.util.List; + +/** 散板照片履约事实追加保存在排车历史,不生成库存或拣货流水。 */ +@Mapper +public interface DispatchLooseboardEvidenceMapper { + /** 按原车读取不可变照片命令,版本顺序用于恢复和审计。 */ + @Select("SELECT snapshot_json FROM ll_dispatch_history WHERE dispatch_id=#{id} AND action='LOOSEBOARD_PHOTO' ORDER BY record_version,id") + List snapshots(long id); + /** 同客户原单逐排车明细去重汇总送达量,分车派送不能提前完成原单。 */ + @Select(""" + SELECT COALESCE(SUM(done.packages),0) FROM ( + SELECT i.id,MAX(i.package_count) packages FROM ll_dispatch_history h + JOIN ll_dispatch_order d ON d.id=h.dispatch_id AND d.status='ACTIVE' + JOIN ll_dispatch_item i ON i.dispatch_order_id=d.id AND CAST(i.id AS CHAR)=JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.itemId')) + WHERE h.action='LOOSEBOARD_PHOTO' AND i.customer_id=#{customer} + AND JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.customerId'))=#{customer} + AND JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.orderNo'))=#{orderNo} + AND JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.phase'))='ARRIVAL' + AND JSON_LENGTH(JSON_EXTRACT(h.snapshot_json,'$.files'))>0 + AND JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.cargoHash'))=d.cargo_hash + AND CAST(JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.reviewRound')) AS UNSIGNED)=d.review_round + GROUP BY i.id + ) done + """) + long deliveredPackages(@Param("customer") String customer,@Param("orderNo") String orderNo); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java index 6e6863d6f..25ecb0d4b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java @@ -28,13 +28,17 @@ public interface DispatchSourceMapper { CAST(ip.overseas_warehouse_id AS CHAR(128)) AS overseasWarehouseId, MIN(l.destination_type) AS destinationType,MIN(l.fba_warehouse_code) AS fbaWarehouseCode, p.delivery_address AS deliveryAddress,SUM(a.counted_package_count) AS packageCount,'RECEIVED' AS sourceStage, - """ + DispatchCandidateMapper.RECEIVED_GROSS_WEIGHT + " AS grossWeightKg," + """ + "COALESCE((SELECT CASE WHEN COUNT(*)=1 AND MAX(m.gross_weight_kg)>0 THEN MAX(m.gross_weight_kg) END " + + "FROM ll_warehouse_transfer_pallet m JOIN ll_warehouse_transfer_order t ON t.id=m.transfer_order_id AND t.customer_id=m.customer_id " + + "WHERE m.customer_id=p.customer_id AND m.warehouse_pallet_id=p.warehouse_pallet_id AND t.lastleg_order_id=ip.source_order_id " + + "AND m.status='COMPLETED' AND m.completed_at IS NOT NULL AND m.carton_count=p.actual_package_count)," + + DispatchCandidateMapper.RECEIVED_GROSS_WEIGHT + ") AS grossWeightKg," + DispatchCandidateMapper.RECEIVED_VOLUME + " AS volumeCbm " + """ FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND a.customer_id=p.customer_id JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status IN ('COMPLETED','PARTIAL_INBOUND') AND p.warehouse_pallet_id IS NOT NULL - GROUP BY p.id,p.plan_id,p.customer_id,p.warehouse_pallet_id,p.pallet_no,ip.overseas_warehouse_id,p.delivery_address + GROUP BY p.id,p.plan_id,p.customer_id,p.warehouse_pallet_id,p.pallet_no,ip.overseas_warehouse_id,ip.source_order_id,p.delivery_address,p.actual_package_count HAVING COUNT(*)=COUNT(a.counted_package_count) AND COUNT(DISTINCT l.destination_type)=1 AND COUNT(DISTINCT COALESCE(l.fba_warehouse_code,''))=1 """ + "") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/LooseboardCargoMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/LooseboardCargoMapper.java new file mode 100644 index 000000000..61ee8d26c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/LooseboardCargoMapper.java @@ -0,0 +1,45 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import org.apache.ibatis.annotations.*; +import java.util.List; + +/** 读取已审核散板的申报计划及实收来源,不制造已收货库存。 */ +@Mapper +public interface LooseboardCargoMapper { + record Reference(String customerId,String fulfillmentNo,Long planPalletId) { } + record BusinessSource(String customerId,String fulfillmentNo,String orderNo,Long planPalletId,String deliveryMode) { + @AutomapConstructor public BusinessSource { } + /** 历史展示夹具不包含取货方式,不能据此放宽仓内作业。 */ + public BusinessSource(String customerId,String fulfillmentNo,String orderNo,Long planPalletId){this(customerId,fulfillmentNo,orderNo,planPalletId,null);} + } + /** 仅按同客户的真实散板关联取原单号,不从编号或备注推断业务。 */ + @Select("") + List businessSources(@Param("references") List references); + @Select(""" + SELECT p.id FROM ll_inbound_plan p + JOIN ll_local_looseboard_order o ON o.lastleg_order_id=p.source_order_id AND o.customer_id=p.customer_id + JOIN ll_warehouse_transfer_order t ON t.transfer_no=o.transfer_no AND t.customer_id=o.customer_id + WHERE t.status IN ('WAIT_PDA_RECEIVING','WAIT_CUSTOMER_CONFIRMATION','READY_FOR_DISPATCH') AND p.status IN ('PDA_PENDING','COMPLETED') AND o.status IN ('WAITING_INBOUND','RECEIVING','WAITING_CONSOLIDATION','PENDING_DISPATCH','DISPATCHED') + AND t.operating_warehouse_id=#{warehouseId} AND CAST(p.overseas_warehouse_id AS CHAR(128))=#{warehouseId} AND t.lastleg_order_id=p.source_order_id + ORDER BY p.id + """) + List readyPlans(String warehouseId); + @Select(""" + SELECT pp.id FROM ll_inbound_plan_pallet pp JOIN ll_inbound_plan p ON p.id=pp.plan_id + JOIN ll_local_looseboard_order o ON o.lastleg_order_id=p.source_order_id AND o.customer_id=p.customer_id + JOIN ll_warehouse_transfer_order t ON t.transfer_no=o.transfer_no AND t.customer_id=o.customer_id + WHERE t.status IN ('WAIT_PDA_RECEIVING','WAIT_CUSTOMER_CONFIRMATION','READY_FOR_DISPATCH') AND p.status IN ('PDA_PENDING','COMPLETED') AND o.status IN ('WAITING_INBOUND','RECEIVING','WAITING_CONSOLIDATION','PENDING_DISPATCH','DISPATCHED') + AND t.operating_warehouse_id=#{warehouseId} AND CAST(p.overseas_warehouse_id AS CHAR(128))=#{warehouseId} AND t.lastleg_order_id=p.source_order_id + """) + List readyPallets(String warehouseId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/LooseboardTransportPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/LooseboardTransportPort.java new file mode 100644 index 000000000..2e3d99d62 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/LooseboardTransportPort.java @@ -0,0 +1,7 @@ +package com.baiyue.lastleg.dispatch.port; + +/** 排车照片证明的商业进度输出,由散板原单域实现;不写入库存状态。 */ +public interface LooseboardTransportPort { + /** 分车送达按去重累计箱数完成,零箱表示仅已取货进入运输。 */ + void progress(String customerId,String orderNo,String operatorId,long deliveredPackages); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchLooseboardEvidenceRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchLooseboardEvidenceRepository.java new file mode 100644 index 000000000..59932d595 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchLooseboardEvidenceRepository.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.dispatch.repository; + +import com.baiyue.lastleg.dispatch.mapper.DispatchLooseboardEvidenceMapper; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 排车照片履约事实只经本域Mapper读取。 */ +@Repository +public class DispatchLooseboardEvidenceRepository { + private final DispatchLooseboardEvidenceMapper mapper; + /** 注入照片历史读取适配器。 */ + public DispatchLooseboardEvidenceRepository(DispatchLooseboardEvidenceMapper mapper){this.mapper=mapper;} + /** 历史始终按公共版本递增返回。 */ + public List snapshots(long id){return mapper.snapshots(id);} + /** 返回同客户原单已经有送达照片的真实运输箱数。 */ + public long deliveredPackages(String customer,String number){return mapper.deliveredPackages(customer,number);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/LooseboardCargoRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/LooseboardCargoRepository.java new file mode 100644 index 000000000..c8e9a13d1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/LooseboardCargoRepository.java @@ -0,0 +1,18 @@ +package com.baiyue.lastleg.dispatch.repository; + +import com.baiyue.lastleg.dispatch.mapper.LooseboardCargoMapper; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 散板候选只读取同仓已确认的真实收货来源。 */ +@Repository +public class LooseboardCargoRepository { + private final LooseboardCargoMapper mapper; + public LooseboardCargoRepository(LooseboardCargoMapper mapper) { this.mapper=mapper; } + public List readyPlans(String warehouseId) { return mapper.readyPlans(warehouseId); } + public List readyPallets(String warehouseId) { return mapper.readyPallets(warehouseId); } + /** 批量读取真实商业原单,保留履约来源身份供命令继续使用。 */ + public List businessSources(List references) { + return references.isEmpty()?List.of():mapper.businessSources(references); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java index 39a85ce2a..0446793a4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -32,6 +32,8 @@ import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; /** 真整车创建、加货与查询;客户只属于明细,不属于整车头。 */ @Service public class DispatchAggregateService implements DispatchExecutionPort,DispatchAppointmentAmendmentPort { + @jakarta.annotation.Resource private DispatchLooseboardFacts looseboardFacts; + @jakarta.annotation.Resource private DispatchLooseboardDisplayService looseboardDisplay; @jakarta.annotation.Resource private DispatchOutboundPort outboundWorkflow; @jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.port.DispatchExceptionPort exceptionWorkflow; @jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent; @@ -200,7 +202,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA "dispatch_candidates_slow count={} candidatesMs={} groupsMs={} appointmentsMs={} assembleMs={} totalMs={}", rows.size(),(candidatesRead-started)/1_000_000L,(groupsRead-candidatesRead)/1_000_000L, (batchesRead-groupsRead)/1_000_000L,(finished-batchesRead)/1_000_000L,(finished-started)/1_000_000L); - return new DispatchPlanningModels.CandidateList(List.copyOf(result),Instant.now().truncatedTo(ChronoUnit.MILLIS), + return new DispatchPlanningModels.CandidateList(looseboardDisplay.candidates(result),Instant.now().truncatedTo(ChronoUnit.MILLIS), normalized.page()==1 && page.total()<=normalized.size(),page.total(),normalized.page(),normalized.size(), (page.total()+normalized.size()-1)/normalized.size()); } @@ -211,13 +213,16 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA requirePlatform(actor);var row=required(actor,number,false); var picked=executionReads.itemPicked(row.getId()).stream().collect(Collectors.toMap(p->p.itemId(),p->p.packages())); var gate=actions(actor,row).get(Action.REMOVE_CARGO);var result=new ArrayList(); - for(var item:repository.cargo(row.getId())) { + var cargo=repository.cargo(row.getId()); + var businessSources=looseboardDisplay.sources(cargo.stream().map(i->new com.baiyue.lastleg.dispatch.mapper.LooseboardCargoMapper.Reference(i.getCustomerId(),i.getOrderNo(),i.getPlanPalletId())).toList()); + for(var item:cargo) { + var business=looseboardDisplay.find(businessSources,item.getCustomerId(),item.getOrderNo(),item.getPlanPalletId()); boolean relabel="RELABEL_ORDER".equals(item.getSourceBusinessType()); var remove=relabel?new Gate(false,"RELABEL_WHOLE_SOURCE","换标按整单排车,请取消整单后重排"):gate; if(picked.getOrDefault(item.getId(),0L)>0)remove=new Gate(false,"ALREADY_PICKED","本来源已经真实扫码"); result.add(new DispatchWorkModels.CargoItem(item.getId(),item.getCustomerId(),item.getCustomerName(),item.getSourceKey(),item.getSourceVersion(), relabel?SourceType.RELABEL:item.getReturnReceiptId()!=null?SourceType.RETURN_RECEIPT:SourceType.INBOUND_PLAN,item.getPlanPalletId(),item.getReturnReceiptId(), - relabel?item.getSourceBusinessNo():null,item.getWarehousePalletId(),item.getPalletNo(),item.getOrderNo(),item.getInboundCode(),destination(item),n(item.getPackageCount()),picked.getOrDefault(item.getId(),0L),remove)); + relabel?item.getSourceBusinessNo():null,item.getWarehousePalletId(),item.getPalletNo(),business==null?item.getOrderNo():business.orderNo(),business==null?item.getInboundCode():null,destination(item),n(item.getPackageCount()),picked.getOrDefault(item.getId(),0L),remove)); } return new DispatchWorkModels.Items(number,row.getRecordVersion(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),List.copyOf(result)); } @@ -523,7 +528,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA row.setStage(Stage.LOADING.name()); if(!Set.of("PICKUP","LOCAL_TRUCK").contains(row.getTaskType())) { masterPhotos.populateLoading(row.getId(),actor.operatorId(),read(row.getResourcesJson(),Resources.class)); - if(outboundWorkflow!=null) outboundWorkflow.prepare(plannedOutboundContext(actor,row),actor); + if(outboundWorkflow!=null && !photoOnly(row)) outboundWorkflow.prepare(plannedOutboundContext(actor,row),actor); } } } else { @@ -603,7 +608,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA /** 两端读取同一实载清单;已知实测自动带出,建议值不能填入实际字段。 */ @Override @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) public DispatchPlanningModels.LoadingPreview loading(Actor actor,String number) { - var row=required(actor,number,false);var context=context(actor,row);var items=repository.cargo(row.getId()); + var row=required(actor,number,false);var context=context(actor,row);var items=warehouseItems(row); var picked=executionReads.itemPicked(row.getId()).stream().collect(Collectors.toMap(p->p.itemId(),p->p.packages())); var measured=executionReads.actualMeasurements(row.getId()).stream().collect(Collectors.groupingBy(p->p.palletId())); var stops=fleet.dispatchStops(number);var pallets=new ArrayList();var missing=new ArrayList(); @@ -639,19 +644,20 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA var identity=identity(actor,number,Action.LOADING_COMPLETE,request.recordVersion(),request.idempotencyKey(),request); var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get(); if(row.getRecordVersion()!=request.recordVersion().longValue() || !Status.ACTIVE.name().equals(row.getStatus()) || !Stage.LOADING.name().equals(row.getStage()) || !ReviewState.APPROVED.name().equals(row.getReviewState()))throw conflict("当前版本或审核阶段不能确认装车"); - var items=repository.cargo(row.getId()); - // 未实收/未首拣的PLAN尚未物化实托,先按未核平拒绝,不能将空实托集合传给执行锁。 + var items=warehouseItems(row); + // 仓内来源必须实收;异地散板取货不进入本次仓库出库。 if(items.isEmpty() || items.stream().anyMatch(i->i.getWarehousePalletId()==null))throw conflict("逐项真实拣货尚未核平"); lockExecutionSources(row,items); var preview=loading(actor,number);if(!preview.pickingReconciled())throw conflict("逐项真实拣货尚未核平"); String appointmentBlock=reviewAppointmentBlock(row); - if(appointmentBlock==null)appointmentBlock=appointments.departureBlockReason(number); + if(appointmentBlock==null)appointmentBlock=appointments.departureBlockReason(number,remoteItemIds(row)); if(appointmentBlock!=null)throw conflict(appointmentBlock); if(!Objects.equals(preview.manifestHash(),request.manifestHash()))throw conflict("真实装载清单已变化,请重新核对"); if(preview.pallets().stream().anyMatch(p->p.deliveryIndex()==null))throw conflict("托盘卸货站尚未匹配,请调度核对路线"); // 现场仅确认已扫码装车事实;每个实托计一托,已有实测自动留存,未知值不伪造为零。 var measurements=preview.pallets().stream().map(p->new LoadingMeasurement(p.warehousePalletId(),1, p.weightKg(),p.volumeCbm(),p.deliveryIndex())).sorted(Comparator.comparing(LoadingMeasurement::warehousePalletId)).toList(); + requireRemoteCapacity(row,measurements); verifyReviewResources(row);var context=context(actor,row); var cargo=context.cargo().stream().map(c->new FleetExecutionModels.Cargo(number,c.customerId(),c.dispatchItemId().toString(),c.warehousePalletId().toString(),c.palletNo(),Math.toIntExact(c.packages()),c.destination().key(),"PRIVATE".equals(c.destination().type())?c.destination().address():c.destination().warehouseCode())).toList(); fleet.confirmDispatchLoad(number,actor.operatorId(),preview.manifestHash(),cargo,measurements.stream().map(m->new FleetExecutionModels.Measurement(m.warehousePalletId().toString(),m.palletSpaces(),m.weightKg(),m.volumeCbm(),m.deliveryIndex())).toList(),request.remark()); @@ -676,7 +682,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA private Context plannedOutboundContext(Actor actor,DispatchAggregate row) { var sources=repository.outboundSources(row.getId()).stream().collect(Collectors.toMap(DispatchOutboundPort.PlannedSource::itemId,s->s)); var cargo=new ArrayList(); - for(var item:repository.cargo(row.getId())) { + for(var item:warehouseItems(row)) { var source=sources.get(item.getId()); if(source==null || source.orderId()==null || !Objects.equals(source.customerId(),item.getCustomerId()))throw conflict("排车货物缺少真实来源订单"); cargo.add(new CargoLine(item.getId(),item.getCustomerId(),item.getCustomerName(),source.orderId(),item.getOrderNo(),"ORDER:"+source.orderId(),item.getSourceStage(), @@ -686,13 +692,13 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA } return new Context(row.getId(),row.getDispatchNo(),row.getTripId(),null,row.getOverseasWarehouseId(),row.getOverseasWarehouseCode(),row.getOverseasWarehouseName(), Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),row.getCargoRevision(),row.getCargoHash(),null,null,null, - read(row.getResourcesJson(),Resources.class),counts(row),reviews(row),false,List.copyOf(cargo)); + read(row.getResourcesJson(),Resources.class),warehouseCounts(row),reviews(row),false,List.copyOf(cargo)); } /** 执行上下文仅包含真实扫码,不以审核计划补齐缺失拣货。 */ private Context context(Actor actor,DispatchAggregate row) { var identity=fleet.dispatchIdentity(row.getDispatchNo());if(identity.tripId()!=row.getTripId())throw conflict("整车资源关系已变化"); - var items=repository.cargo(row.getId());var byId=items.stream().collect(Collectors.toMap(DispatchItem::getId,i->i)); + var items=warehouseItems(row);var byId=items.stream().collect(Collectors.toMap(DispatchItem::getId,i->i)); var batches=repository.batches(row.getId());var cargo=new ArrayList();var actualSources=executionReads.sources(row.getId()); var origins=actualSources.stream().anyMatch(p->p.dispatchItemSourceId()!=null)?sourceOrigins.all(row.getId()).stream().collect(Collectors.toMap(s->s.getId(),s->s)):Map.of(); var remaining=new LinkedHashMap();batches.forEach(b->remaining.put(b,(long)b.packageCount())); @@ -712,10 +718,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA } var picked=executionReads.itemPicked(row.getId()).stream().collect(Collectors.toMap(p->p.itemId(),p->p.packages())); boolean complete=reconciled(row) && !items.isEmpty() && items.stream().allMatch(i->picked.getOrDefault(i.getId(),0L)==n(i.getPackageCount())) - && executionReads.inconsistentPicks(row.getId())==0 && cargo.stream().mapToLong(CargoLine::packages).sum()==n(row.getPackageCount()); + && executionReads.inconsistentPicks(row.getId())==0 && cargo.stream().mapToLong(CargoLine::packages).sum()==warehouseCounts(row).packageCount(); return new Context(row.getId(),row.getDispatchNo(),row.getTripId(),identity.tripNo(),row.getOverseasWarehouseId(),row.getOverseasWarehouseCode(),row.getOverseasWarehouseName(), Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),row.getCargoRevision(),row.getCargoHash(),row.getLoadingCompletionHash(),instant(row.getLoadingCompletedAt()),instant(row.getDepartedAt()), - read(row.getResourcesJson(),Resources.class),counts(row),reviews(row),complete,List.copyOf(cargo)); + read(row.getResourcesJson(),Resources.class),warehouseCounts(row),reviews(row),complete,List.copyOf(cargo)); } /** 普通换标出库沿本次冻结贡献的新箱标,不从退货箱表或原箱唛生成快照。 */ @@ -862,6 +868,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA String reason=reviewAppointmentBlock(row); if(reason!=null)throw conflict(reason); } + /** 纯散板照片发运仍校验真实预约资格,不要求虚构仓库出库。 */ + void requirePhotoDeparture(DispatchAggregate row) { + requireReviewAppointment(row); + } + /** 已产生实际扫码的历史车继续沿原出库链,不抹除真实执行。 */ + boolean photoOnly(DispatchAggregate row){return looseboardFacts!=null && looseboardFacts.direct(row);} private String reviewAppointmentBlock(DispatchAggregate row) { String reason=DispatchDestinationAppointments.blockReason(reviewAppointments(row),instant(row.getPlannedDepartureAt()),instant(row.getEstimatedArrivalAt()),appointments.reviewEvidence(row.getDispatchNo())); if(reason==null)reason=appointments.reviewBlockReason(row.getDispatchNo(),instant(row.getPlannedDepartureAt()),instant(row.getEstimatedArrivalAt())); @@ -1007,7 +1019,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA } var current=repository.cargo(row.getId()); var keys=current.stream().map(DispatchItem::getSourceKey).collect(Collectors.toSet()); var resolved=current.isEmpty()?sourceRules.lockWholeSources(row.getOverseasWarehouseId(),selections):sourceRules.lockAdditionalSources(row.getOverseasWarehouseId(),selections,current); - if(!current.isEmpty())lockExecutionSources(row,current); + // 计划阶段追加未到仓货物只核对原订单和计划归属,不要求尚不存在的实托库存。 + if(!current.isEmpty()) { + if(current.stream().allMatch(i->i.getWarehousePalletId()==null && i.getPlanPalletId()!=null)) + sourceRules.lockAppointmentSources(row.getOverseasWarehouseId(),current); + else lockExecutionSources(row,current); + } var at=now();var owner=new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),row.getId(),null,null,actor.operatorId(),null); var physical=physicalClaims.reserve(owner,java.util.stream.Stream.concat(current.stream().map(DispatchItem::getWarehousePalletId),resolved.stream().map(s->s.candidate().warehousePalletId())).filter(Objects::nonNull).toList(),at); var saved=new ArrayList(); @@ -1138,10 +1155,14 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA gates.put(action,new Gate(rejectionAllowed,rejectionAllowed?"APPROVAL_BLOCKED":"APPOINTMENT_NOT_READY",reviewBlock)); } if(action==Action.LOADING_COMPLETE && state && permitted) { - String reason=appointments.departureBlockReason(row.getDispatchNo()); + String reason=appointments.departureBlockReason(row.getDispatchNo(),remoteItemIds(row)); if(reason!=null)gates.put(action,new Gate(false,"APPOINTMENT_NOT_READY",reason)); } } + if(photoOnly(row)) { + gates.put(Action.PICK,new Gate(false,"LOOSEBOARD_PHOTOS","纯散板无需PDA拣货,请在管理资料上传取货照片")); + gates.put(Action.LOADING_COMPLETE,new Gate(false,"LOOSEBOARD_PHOTOS","纯散板上传取货照片后直接进入派送中")); + } return Collections.unmodifiableMap(gates); } @@ -1200,12 +1221,16 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA /** 混托多订单无法真实分摊重量体积时保持未知,不能给每票复制整托总量。 */ private List orderDetails(List cargo) { var result=new ArrayList(); + var businessSources=looseboardDisplay.sources(cargo.stream().map(i->new com.baiyue.lastleg.dispatch.mapper.LooseboardCargoMapper.Reference(i.getCustomerId(),i.getOrderNo(),i.getPlanPalletId())).toList()); for(var item:cargo) { var source=read(item.getSourceSnapshotJson(),SourceDisplay.class); var orders=Arrays.stream(Objects.requireNonNullElse(item.getOrderRefs(),Objects.requireNonNullElse(item.getOrderNo(),"")).split(",")).map(String::trim).filter(s->!s.isBlank()).distinct().toList(); if(orders.isEmpty()) orders=Collections.singletonList(null); - for(var order:orders) result.add(new DispatchViews.OrderDetail(item.getCustomerId(),item.getCustomerName(),order,item.getInboundCode(),orders.size()==1?item.getGrossWeightKg():null, - orders.size()==1?1:null,item.getFbaWarehouseCode(),orders.size()==1?item.getVolumeCbm():null,source.containerNo(),item.getSourceKey(),item.getSourceBusinessType(),item.getSourceBusinessNo())); + for(var order:orders) { + var business=looseboardDisplay.find(businessSources,item.getCustomerId(),order,item.getPlanPalletId()); + result.add(new DispatchViews.OrderDetail(item.getCustomerId(),item.getCustomerName(),business==null?order:business.orderNo(),business==null?item.getInboundCode():null,orders.size()==1?item.getGrossWeightKg():null, + orders.size()==1?1:null,item.getFbaWarehouseCode(),orders.size()==1?item.getVolumeCbm():null,business==null?source.containerNo():null,item.getSourceKey(),business==null?item.getSourceBusinessType():"LOCAL_LOOSEBOARD",business==null?item.getSourceBusinessNo():business.orderNo())); + } } return List.copyOf(result); } @@ -1261,7 +1286,36 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA /** 整车真实统计。 */ private static Counts counts(DispatchAggregate row) { return new Counts(n(row.getItemCount()),n(row.getPalletCount()),n(row.getPackageCount()),n(row.getPickedPackageCount()),n(row.getShippedPackageCount())); } /** 空单永远不能核平为装车完成。 */ - private static boolean reconciled(DispatchAggregate row) { return n(row.getPackageCount())>0 && Objects.equals(row.getPackageCount(),row.getPickedPackageCount()); } + /** 混排仅核平仓内应拣数量,不用异地散板申报量补造扫码。 */ + private boolean reconciled(DispatchAggregate row) { long expected=warehouseCounts(row).packageCount();return expected>0 && expected==n(row.getPickedPackageCount()); } + /** 异地散板身份只从本车权威关联读取,不由请求提供。 */ + public Set remoteItemIds(DispatchAggregate row){return looseboardFacts==null?Set.of():looseboardFacts.remoteItemIds(row.getId());} + /** 已保存取货照片的异地货量与仓库实发分开核对,正式发运不得覆盖前者。 */ + public long pickedRemotePackages(DispatchAggregate row){return looseboardFacts==null?0:looseboardFacts.pickedRemotePackages(row);} + /** 仓内实物作业范围,普通未收货计划仍保留并按原规则阻断。 */ + public List warehouseItems(DispatchAggregate row){return looseboardFacts==null?repository.cargo(row.getId()):looseboardFacts.warehouseItems(row.getId());} + /** 异地取货仍占用本趟容量;使用仓内实测加散板申报,不补造实托或实测。 */ + public void requireRemoteCapacity(DispatchAggregate row,List measured){ + var ids=remoteItemIds(row);if(ids.isEmpty())return; + var remote=repository.cargo(row.getId()).stream().filter(i->ids.contains(i.getId())).toList(); + var capacity=read(row.getResourcesJson(),Resources.class).vehicle().capacity(); + if(remote.stream().anyMatch(i->i.getGrossWeightKg()==null || i.getVolumeCbm()==null) || measured.stream().anyMatch(m->m.weightKg()==null || m.volumeCbm()==null))throw conflict("混排实测与异地取货申报货量未明确"); + var weight=remote.stream().map(DispatchItem::getGrossWeightKg).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add).add(measured.stream().map(LoadingMeasurement::weightKg).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add)); + var volume=remote.stream().map(DispatchItem::getVolumeCbm).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add).add(measured.stream().map(LoadingMeasurement::volumeCbm).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add)); + if(remote.size()+measured.stream().mapToInt(LoadingMeasurement::palletSpaces).sum()>capacity.palletSpaces() || weight.compareTo(capacity.maxLoadKg())>0 || volume.compareTo(capacity.maxVolumeCbm())>0)throw conflict("仓内装载与异地取货合计超过本车容量"); + } + /** 取货照片保存事务复验已确认仓内实载与当前异地申报。 */ + public void requireRemotePickupCapacity(DispatchAggregate row){ + if(row.getLoadingConfirmationJson()==null)throw conflict("混排尚未确认仓内装载"); + requireRemoteCapacity(row,read(row.getLoadingConfirmationJson(),ConfirmedLoad.class).pallets()); + } + /** 派送端口仅承接仓库真实出库;整车页面总量仍使用原主单统计。 */ + private Counts warehouseCounts(DispatchAggregate row){ + if(remoteItemIds(row).isEmpty())return counts(row); + var items=warehouseItems(row);long packages=items.stream().mapToLong(i->n(i.getPackageCount())).sum(); + return new Counts(items.size(),items.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).distinct().count(),packages, + n(row.getPickedPackageCount()),row.getDepartedAt()==null?0:packages); + } /** 目的地业务标签仅来源于真实目的仓或实际地址。 */ private String destinationLabel(DispatchItem i) { return "PRIVATE".equals(i.getDestinationType())?i.getDeliveryAddress():trim(i.getDestinationWarehouseCode())==null?i.getFbaWarehouseCode():i.getDestinationWarehouseCode(); } /** 目的地稳定业务键复用既有地址规范,不信任用户指定路线。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocations.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocations.java index d5223c1d1..8c1e4a21b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocations.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocations.java @@ -19,6 +19,8 @@ public class DispatchAppointmentAllocations { /** 装车按钮提前展示原预约的真实阻塞,提交仍由发车事务加锁复验。 */ public String departureBlockReason(String dispatchNo){return provider.departureBlockReason(dispatchNo);} + /** 异地取货只用原计划事实,仓内出库继续逐票核对实拣。 */ + public String departureBlockReason(String dispatchNo,Set remote){return remote.isEmpty()?provider.departureBlockReason(dispatchNo):provider.mixedDepartureBlockReason(dispatchNo,remote);} /** 预排与执行门禁分开;审核之前不要求已有拣货记录。 */ public String reviewBlockReason(String dispatchNo,java.time.Instant departure,java.time.Instant arrival){return provider.reviewBlockReason(dispatchNo,departure,arrival);} public List reviewEvidence(String dispatchNo){return provider.reviewEvidence(dispatchNo);} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCompletionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCompletionService.java index 78daacf14..04efce3b8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCompletionService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCompletionService.java @@ -19,6 +19,7 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; /** 提交后的正常签收汇总;父CAS、唯一完成历史和指定信号确认必须共同提交。 */ @Service public class DispatchCompletionService implements DispatchCompletionPort { + @jakarta.annotation.Resource private DispatchLooseboardFacts looseboardFacts; private static final Actor SYSTEM=new Actor("SYSTEM_DELIVERY_RECONCILE",ActorMode.PLATFORM,null); private final DispatchAggregateRepository repository; private final DispatchExecutionRepository reads; @@ -45,7 +46,10 @@ public class DispatchCompletionService implements DispatchCompletionPort { if(facts==null || !Objects.equals(facts.dispatchNo(),row.getDispatchNo()) || facts.expectedDeliveryNos()==null || !facts.expectedDeliveryNos().contains(signal.deliveryNo()))throw new IllegalStateException("签收信号不属于本车已安排派送"); if(!Status.ACTIVE.name().equals(row.getStatus()) || !Set.of(Stage.DELIVERING.name(),Stage.DELIVERED.name()).contains(row.getStage()))throw new IllegalStateException("本车尚无合法在途运输事实"); boolean changed=false;var now=LocalDateTime.ofInstant(Instant.now(),ZoneOffset.UTC); - if(Stage.DELIVERING.name().equals(row.getStage()) && facts.allNormallySigned()) { + boolean photosReady=looseboardFacts==null || looseboardFacts.cargo(row.getId()).isEmpty() + || looseboardFacts.complete(row,DispatchLooseboardFacts.Phase.PICKUP) && looseboardFacts.complete(row,DispatchLooseboardFacts.Phase.ARRIVAL); + boolean complete=facts.allNormallySigned() && photosReady; + if(Stage.DELIVERING.name().equals(row.getStage()) && complete) { if(facts.completedAt()==null || facts.factsHash()==null || facts.factsHash().isBlank() || row.getDepartedAt()==null)throw new IllegalStateException("缺少正常签收完成依据"); long version=row.getRecordVersion();row.setStage(Stage.DELIVERED.name());row.setDeliveryCompletedAt(LocalDateTime.ofInstant(facts.completedAt(),ZoneOffset.UTC)); row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(now); @@ -54,10 +58,10 @@ public class DispatchCompletionService implements DispatchCompletionPort { changed=true; } // 全部正常签收后直接结案释放,不再等待人工二次确认。 - if (facts.allNormallySigned()) completion.completeDelivered(identity.tripNo(), SYSTEM.operatorId()); + if (complete) completion.completeDelivered(identity.tripNo(), SYSTEM.operatorId()); // 不完整的合法部分签收也只确认本版本通知,未来签收产生自己的新信号。 signals.acknowledge(signal,Instant.now()); - return new CompletionResult(row.getDispatchNo(),Stage.valueOf(row.getStage()),row.getRecordVersion(),changed,facts.allNormallySigned(), + return new CompletionResult(row.getDispatchNo(),Stage.valueOf(row.getStage()),row.getRecordVersion(),changed,complete, row.getDeliveryCompletedAt()==null?null:row.getDeliveryCompletedAt().toInstant(ZoneOffset.UTC)); } /** 普通读候选不持子锁跨入汇总或重建事务。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDepartureService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDepartureService.java index 572b62d86..d9501319f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDepartureService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDepartureService.java @@ -41,16 +41,17 @@ public class DispatchDepartureService { if(facts==null || facts.depart()==null || !facts.depart().allowed())throw conflict(facts==null || facts.depart()==null?"本车派送安排尚未核齐":facts.depart().blockReason()); if(!Objects.equals(facts.arrangementKey(),row.getArrangementKey()) || !Objects.equals(facts.arrangementBusinessHash(),row.getArrangementBusinessHash()) || !Objects.equals(facts.cargoHash(),context.cargoHash()) || !Objects.equals(facts.loadingCompletionHash(),context.loadingCompletionHash()))throw conflict("本车实际货物或派送安排已变化"); - fleet.requireDispatchDeparture(number,confirmed.manifestHash()); - workflow.lockExecutionSources(row,repository.cargo(row.getId())); + var remote=workflow.remoteItemIds(row); + if(remote.isEmpty())fleet.requireDispatchDeparture(number,confirmed.manifestHash());else fleet.requireDispatchDeparture(number,confirmed.manifestHash(),remote); + workflow.lockExecutionSources(row,workflow.warehouseItems(row)); var orderOwners=new TreeMap();for(var cargo:context.cargo())orderOwners.put(cargo.orderId(),cargo.customerId()); for(var source:orderOwners.entrySet())if(sources.lockOrder(source.getValue(),source.getKey())==null)throw conflict("来源订单缺失或已暂扣,不能发车"); var at=Instant.now().truncatedTo(ChronoUnit.MILLIS);var departed=delivery.markDeparted(context,actor,at,request.idempotencyKey()); if(departed.shippedPackages()!=context.counts().packageCount())throw conflict("实际发运单据与本车箱数未核平"); sourceWorkflow.departWholeSources(context,actor,departed,at); - workflow.releaseSources(row,actor,repository.cargo(row.getId()),LocalDateTime.ofInstant(at,ZoneOffset.UTC)); + workflow.releaseSources(row,actor,workflow.warehouseItems(row),LocalDateTime.ofInstant(at,ZoneOffset.UTC)); fleet.departDispatchShell(number,actor.operatorId(),at,request.remark());long version=row.getRecordVersion(); - row.setStage(Stage.DELIVERING.name());row.setDepartedAt(LocalDateTime.ofInstant(at,ZoneOffset.UTC));row.setShippedPackageCount(Math.toIntExact(departed.shippedPackages()));row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(LocalDateTime.ofInstant(at,ZoneOffset.UTC)); + row.setStage(Stage.DELIVERING.name());row.setDepartedAt(LocalDateTime.ofInstant(at,ZoneOffset.UTC));row.setShippedPackageCount(Math.toIntExact(Math.addExact(departed.shippedPackages(),workflow.pickedRemotePackages(row))));row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(LocalDateTime.ofInstant(at,ZoneOffset.UTC)); if(repository.saveState(row,version)!=1)throw conflict("本车发车版本竞争失败"); if(repository.history(IdWorker.getId(),row,Action.DEPART.name(),actor.operatorId(),request.remark(),write(Map.of("dispatch",row,"departure",departed)),row.getUpdatedAt())!=1)throw conflict("发车历史未保存"); var result=new CommandResult(number,Action.DEPART,request.idempotencyKey(),row.getRecordVersion(),Status.ACTIVE,Stage.DELIVERING,at,Effect.APPLIED, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLooseboardDisplayService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLooseboardDisplayService.java new file mode 100644 index 000000000..0eb027e0a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLooseboardDisplayService.java @@ -0,0 +1,45 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.mapper.LooseboardCargoMapper.Reference; +import com.baiyue.lastleg.dispatch.mapper.LooseboardCargoMapper.BusinessSource; +import com.baiyue.lastleg.dispatch.repository.LooseboardCargoRepository; +import com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.*; +import org.springframework.stereotype.Service; +import java.util.*; + +/** 排车展示商业原单,履约编号不冒充订单号、入仓码或集装箱号。 */ +@Service +public class DispatchLooseboardDisplayService { + private final LooseboardCargoRepository repository; + /** 展示关联只读取同客户的权威业务原单。 */ + public DispatchLooseboardDisplayService(LooseboardCargoRepository repository) { this.repository=repository; } + /** 一批读取已有散板来源,历史排车也能显示原单,无需改写运输事实。 */ + public List sources(List references) { return repository.businessSources(references.stream().distinct().toList()); } + /** 计划ID和履约单号均限定同客户,不按编号前缀猜测身份。 */ + public BusinessSource find(List rows,String customer,String number,Long pallet) { + return rows.stream().filter(r->Objects.equals(customer,r.customerId()) && + ((number!=null && Objects.equals(number,r.fulfillmentNo())) || (pallet!=null && Objects.equals(pallet,r.planPalletId())))).findFirst().orElse(null); + } + /** 只替换候选展示字段,原selection、版本、占用和来源键原样保留。 */ + public List candidates(List rows) { + var references=new ArrayList(); + for(var row:rows) { + references.add(new Reference(row.customerId(),row.orderNo(),row.selection().planPalletId())); + if(row.selectionGroup()!=null)for(var member:row.selectionGroup().sources()) + references.add(new Reference(member.customerId(),null,member.selection().planPalletId())); + } + var identities=sources(references);var result=new ArrayList(); + for(var row:rows) { + var business=find(identities,row.customerId(),row.orderNo(),row.selection().planPalletId()); + var group=row.selectionGroup(); + if(group!=null)group=new SelectionGroup(group.groupKey(),group.groupVersion(),group.sources().stream().map(member->{ + var source=find(identities,member.customerId(),null,member.selection().planPalletId()); + return source==null?member:new SelectionGroupSource(member.selection(),member.sourceKey(),member.customerId(),member.customerName(),"LOCAL_LOOSEBOARD",source.orderNo(),member.packages()); + }).toList(),group.pallets(),group.complete(),group.blockReasons()); + result.add(new Candidate(row.selection(),row.sourceKey(),row.sourceVersion(),row.customerId(),row.customerName(),row.warehouseId(),row.warehouseCode(),row.warehouseName(), + business==null?row.orderNo():business.orderNo(),business==null?row.inboundCode():null,row.planNo(),row.palletNo(),business==null?row.containerNo():null,row.destination(),row.availablePackages(), + row.suggestedWeightKg(),row.suggestedVolumeCbm(),row.measurementSource(),row.appointmentBatches(),row.blockReasons(),group)); + } + return List.copyOf(result); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLooseboardExecutionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLooseboardExecutionService.java new file mode 100644 index 000000000..04dd8b690 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLooseboardExecutionService.java @@ -0,0 +1,162 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; +import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway; +import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository; +import com.baiyue.lastleg.fleet.trip.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.validation.constraints.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import java.time.*; +import java.time.temporal.ChronoUnit; +import java.util.*; +import static com.baiyue.lastleg.dispatch.service.DispatchLooseboardFacts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; + +/** 纯散板直接以取货和送达照片履约;混排只补散板证据,不代替其他货物出库签收。 */ +@Service +public class DispatchLooseboardExecutionService { + public record Save(@NotNull @Min(0) Long recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey, + @NotNull @Size(max=20) List<@NotNull @Positive Long> fileIds) {} + public record Item(String itemId,String customerId,String orderNo,String destination,int packages,List pickupPhotos, + List arrivalPhotos,boolean canPickup,boolean canArrive) {} + public record Detail(String dispatchNo,long recordVersion,String stage,boolean direct,List items) {} + public record Download(File file,byte[] bytes) {} + private final DispatchAggregateService workflow; + private final DispatchAggregateRepository repository; + private final DispatchLooseboardFacts facts; + private final DispatchEvidenceGateway files; + private final DispatchOutboundPort outbounds; + private final FleetTripExecutionService fleet; + private final FleetTripCompletionService completion; + private final DispatchDeliveryPort deliveries; + private final LooseboardTransportPort orders; + private final DispatchCommandHasher hashes; + private final ObjectMapper json; + /** 只调用各域正式入口,照片不会生成库存、实托或拣货事实。 */ + public DispatchLooseboardExecutionService(DispatchAggregateService workflow,DispatchAggregateRepository repository,DispatchLooseboardFacts facts, + DispatchEvidenceGateway files,DispatchOutboundPort outbounds,FleetTripExecutionService fleet,FleetTripCompletionService completion, + DispatchDeliveryPort deliveries,LooseboardTransportPort orders,DispatchCommandHasher hashes,ObjectMapper json){ + this.workflow=workflow;this.repository=repository;this.facts=facts;this.files=files;this.outbounds=outbounds;this.fleet=fleet; + this.completion=completion;this.deliveries=deliveries;this.orders=orders;this.hashes=hashes;this.json=json; + } + /** 当前客户明细由原车读取,前端不传客户或原单号。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public Detail get(Actor actor,String number){workflow.preview(actor,number);return detail(required(number));} + /** 上传不等于保存或运输完成,只接受本车对应节点图片。 */ + public File upload(Actor actor,String number,long itemId,Phase phase,MultipartFile file){ + var value=get(actor,number);var item=value.items().stream().filter(i->i.itemId().equals(Long.toString(itemId))).findFirst().orElseThrow(()->conflict("散板明细不属于本车")); + if(!(phase==Phase.PICKUP?item.canPickup():item.canArrive()))throw conflict("当前阶段不能上传该节点照片"); + return files.upload(required(number).getId(),kind(phase),actor.operatorId(),file); + } + /** 父锁、公共版本、不可变命令和照片推进在同一事务保存;失败不留下假发车。 */ + @Transactional(isolation=Isolation.READ_COMMITTED) + public Detail save(Actor actor,String number,long itemId,Phase phase,Save request){ + workflow.lockForArrangement(actor,number);var row=required(number); + String hash=hashes.source(Arrays.asList(number,itemId,phase,request)); + var history=facts.history(row.getId());var replay=history.stream().filter(e->e.operatorId().equals(actor.operatorId()) && e.idempotencyKey().equals(request.idempotencyKey())).findFirst(); + if(replay.isPresent()){ + if(!replay.get().requestHash().equals(hash))throw conflict("相同命令标识不能修改散板照片内容"); + return detail(row); + } + if(row.getRecordVersion().longValue()!=request.recordVersion())throw conflict("排车版本已变化,请刷新后重试"); + var cargo=facts.cargo(row.getId()).stream().filter(c->c.item().getId()==itemId).findFirst().orElseThrow(()->conflict("散板明细不属于本车")); + var current=detail(row);var item=current.items().stream().filter(i->i.itemId().equals(Long.toString(itemId))).findFirst().orElseThrow(); + if(!(phase==Phase.PICKUP?item.canPickup():item.canArrive()))throw conflict("当前阶段不能保存该节点照片"); + if(request.fileIds().size()>20 || new HashSet<>(request.fileIds()).size()!=request.fileIds().size())throw conflict("每个节点最多保存20张不重复照片"); + var previous=phase==Phase.PICKUP?item.pickupPhotos():item.arrivalPhotos(); + if(request.fileIds().isEmpty() && previous.isEmpty())throw conflict("请先选择照片"); + if(request.fileIds().isEmpty() && ("DELIVERED".equals(row.getStage()) || phase==Phase.PICKUP && !item.arrivalPhotos().isEmpty()))throw conflict("已送达或已有送达照片的节点至少保留一张凭证,可先追加新照片再删除旧照片"); + var old=facts.history(row.getId()); + boolean pickupRecorded=old.stream().anyMatch(e->e.phase()==Phase.PICKUP && matches(e,cargo) && Objects.equals(e.cargoHash(),row.getCargoHash()) && e.reviewRound()==row.getReviewRound() && !e.files().isEmpty()); + boolean arrivalRecorded=old.stream().anyMatch(e->e.phase()==Phase.ARRIVAL && matches(e,cargo) && Objects.equals(e.cargoHash(),row.getCargoHash()) && e.reviewRound()==row.getReviewRound() && !e.files().isEmpty()); + if(old.stream().filter(e->!matches(e,cargo) || e.phase()!=phase || !Objects.equals(e.cargoHash(),row.getCargoHash()) || e.reviewRound()!=row.getReviewRound()).flatMap(e->e.files().stream()).anyMatch(f->request.fileIds().contains(f.fileId())))throw conflict("照片已绑定其他节点或明细,不能重复使用"); + var photos=request.fileIds().stream().map(id->files.metadata(row.getId(),kind(phase),id)).toList(); + if(photos.stream().anyMatch(f->!Set.of("image/jpeg","image/png").contains(f.contentType())))throw conflict("取货和送达凭证必须为照片"); + var at=Instant.now().truncatedTo(ChronoUnit.MILLIS); + var evidence=new Evidence(Long.toString(itemId),cargo.item().getCustomerId(),cargo.orderNo(),phase,row.getCargoHash(),row.getReviewRound(),photos,at,actor.operatorId(),request.idempotencyKey(),hash); + long original=row.getRecordVersion();row.setRecordVersion(original+1);row.setUpdatedAt(LocalDateTime.ofInstant(at,ZoneOffset.UTC)); + if(repository.saveState(row,original)!=1)throw conflict("排车版本竞争失败"); + if(repository.history(IdWorker.getId(),row,"LOOSEBOARD_PHOTO",actor.operatorId(),phase.name(),write(evidence),row.getUpdatedAt())!=1)throw conflict("散板照片保存失败"); + if(current.direct() && "LOADING".equals(row.getStage()) && phase==Phase.PICKUP && facts.complete(row,Phase.PICKUP))depart(actor,row,at); + if(!current.direct() && phase==Phase.PICKUP && !pickupRecorded && !photos.isEmpty() && facts.remoteItemIds(row.getId()).contains(itemId)){ + workflow.requireRemotePickupCapacity(row); + row.setShippedPackageCount(Math.addExact(row.getShippedPackageCount(),cargo.item().getPackageCount())); + orders.progress(cargo.item().getCustomerId(),cargo.orderNo(),actor.operatorId(),0); + workflow.releaseSources(row,actor,List.of(cargo.item()),row.getUpdatedAt()); + transition(row,current.stage(),at,actor,"混排异地散板实际取货照片已保存"); + } + if(phase==Phase.ARRIVAL && !photos.isEmpty()){ + if(!arrivalRecorded)orders.progress(cargo.item().getCustomerId(),cargo.orderNo(),actor.operatorId(),facts.deliveredPackages(cargo.item().getCustomerId(),cargo.orderNo())); + if(current.direct() && "DELIVERING".equals(row.getStage()) && facts.complete(row,Phase.PICKUP) && facts.complete(row,Phase.ARRIVAL)){ + transition(row,"DELIVERED",at,actor,"散板全部送达照片已保存"); + workflow.releaseSources(row,actor,repository.cargo(row.getId()),row.getUpdatedAt()); + completion.completeDelivered(fleet.dispatchIdentity(number).tripNo(),actor.operatorId()); + } + } + // 已持整车父锁,可普通读已提交签收事实;先签收后补照片也能完成,不伪造新派送版本信号。 + if(!current.direct() && "DELIVERING".equals(row.getStage()) && facts.complete(row,Phase.PICKUP) && facts.complete(row,Phase.ARRIVAL)){ + var normal=deliveries.normalDeliveryFacts(workflow.preview(actor,number)); + if(normal.allNormallySigned()){ + transition(row,"DELIVERED",at,actor,"混排全部货物签收且散板取货、送达照片齐全"); + completion.completeDelivered(fleet.dispatchIdentity(number).tripNo(),actor.operatorId()); + } + } + return detail(row); + } + /** 下载只允许当前明细已保存的节点照片,未保存上传不授权读取。 */ + public Download content(Actor actor,String number,long itemId,Phase phase,long fileId){ + var value=get(actor,number);var item=value.items().stream().filter(i->i.itemId().equals(Long.toString(itemId))).findFirst().orElseThrow(()->conflict("散板明细不存在")); + var bound=phase==Phase.PICKUP?item.pickupPhotos():item.arrivalPhotos(); + if(bound.stream().noneMatch(f->f.fileId()==fileId))throw conflict("照片未绑定本明细节点"); + return new Download(bound.stream().filter(f->f.fileId()==fileId).findFirst().orElseThrow(),files.download(required(number).getId(),kind(phase),fileId)); + } + /** 纯散板保持审核/司机/车辆和预约校验,撤销旧版本预生成但从未执行的出库单。 */ + private void depart(Actor actor,DispatchAggregate row,Instant at){ + if(!"LOADING".equals(row.getStage()) || !"APPROVED".equals(row.getReviewState()))throw conflict("审核通过后才能完成取货"); + workflow.verifyReviewResources(row);workflow.requirePhotoDeparture(row); + var cargo=facts.cargo(row.getId());var resources=workflow.preview(actor,row.getDispatchNo()).resources(); + if(row.getPalletCount()>resources.vehicle().capacity().palletSpaces())throw conflict("散板申报托数超过本车托位"); + var weight=cargo.stream().map(c->c.item().getGrossWeightKg()).filter(Objects::nonNull).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add); + var volume=cargo.stream().map(c->c.item().getVolumeCbm()).filter(Objects::nonNull).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add); + if(weight.compareTo(resources.vehicle().capacity().maxLoadKg())>0 || volume.compareTo(resources.vehicle().capacity().maxVolumeCbm())>0)throw conflict("散板申报货量超过本车载重或容积"); + outbounds.revoke(row.getId()); + fleet.departPhotoDispatchShell(row.getDispatchNo(),actor.operatorId(),at,"纯散板取货照片齐全,直接派送"); + row.setShippedPackageCount(row.getPackageCount());row.setDepartedAt(LocalDateTime.ofInstant(at,ZoneOffset.UTC)); + transition(row,"DELIVERING",at,actor,"纯散板取货完成,无仓内拣货出库"); + for(var c:cargo)orders.progress(c.item().getCustomerId(),c.orderNo(),actor.operatorId(),0); + } + /** 照片履约阶段独立记历史,不伪造装车、扫码或库存扣减事实。 */ + private void transition(DispatchAggregate row,String stage,Instant at,Actor actor,String reason){ + long old=row.getRecordVersion();row.setStage(stage);if("DELIVERED".equals(stage))row.setDeliveryCompletedAt(LocalDateTime.ofInstant(at,ZoneOffset.UTC)); + row.setRecordVersion(old+1);row.setUpdatedAt(LocalDateTime.ofInstant(at,ZoneOffset.UTC)); + if(repository.saveState(row,old)!=1 || repository.history(IdWorker.getId(),row,"LOOSEBOARD_"+stage,actor.operatorId(),reason,write(row),row.getUpdatedAt())!=1)throw conflict("散板运输状态保存失败"); + } + /** 混排装车确认后即可逐票取送;照片不代替仓内装车资料齐全后的正式发运。 */ + private Detail detail(DispatchAggregate row){ + boolean direct=facts.direct(row);var evidence=facts.current(row);boolean active="ACTIVE".equals(row.getStatus()); + var items=facts.cargo(row.getId()).stream().map(c->{ + var pickup=evidence.stream().filter(e->e.phase()==Phase.PICKUP && matches(e,c)).flatMap(e->e.files().stream()).toList(); + var arrival=evidence.stream().filter(e->e.phase()==Phase.ARRIVAL && matches(e,c)).flatMap(e->e.files().stream()).toList(); + boolean mixedPhotoStage=!direct && Set.of("AFTER_LOADING","DELIVERING").contains(row.getStage()); + boolean canPickup=active && (Set.of("DELIVERING","DELIVERED").contains(row.getStage()) || (direct?"LOADING".equals(row.getStage()) && "APPROVED".equals(row.getReviewState()):mixedPhotoStage)); + boolean canArrive=active && (mixedPhotoStage || Set.of("DELIVERING","DELIVERED").contains(row.getStage())) && !pickup.isEmpty(); + return new Item(c.item().getId().toString(),c.item().getCustomerId(),c.orderNo(),Objects.toString(c.item().getDestinationWarehouseCode(),c.item().getDeliveryAddress()),c.item().getPackageCount(),pickup,arrival,canPickup,canArrive); + }).toList();return new Detail(row.getDispatchNo(),row.getRecordVersion(),row.getStage(),direct,items); + } + /** 业务原单范围取主流程认证后的本车,不接受客户端指定归属。 */ + private DispatchAggregate required(String number){var row=repository.find(number);if(row==null)throw conflict("排车单不存在");return row;} + /** 节点文件严格使用独立业务类别。 */ + private static DispatchEvidenceGateway.Kind kind(Phase phase){return phase==Phase.PICKUP?DispatchEvidenceGateway.Kind.LOOSEBOARD_PICKUP:DispatchEvidenceGateway.Kind.LOOSEBOARD_ARRIVAL;} + /** 序列化失败随业务状态回滚。 */ + private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException("散板履约快照保存失败",e);}} + /** 并发与业务条件不满足统一拒绝。 */ + private static BizException conflict(String message){return new BizException(ResultCode.CONFLICT,message);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLooseboardFacts.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLooseboardFacts.java new file mode 100644 index 000000000..d269cf0a9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLooseboardFacts.java @@ -0,0 +1,81 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.entity.*; +import com.baiyue.lastleg.dispatch.mapper.LooseboardCargoMapper.Reference; +import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File; +import com.baiyue.lastleg.dispatch.repository.*; +import com.baiyue.lastleg.fleet.trip.FleetCompletionModels; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; +import java.time.Instant; +import java.util.*; + +/** 独立散板按真实关联识别;纯散板照片履约与混排仓内履约分别核对。 */ +@Service +public class DispatchLooseboardFacts { + public enum Phase { PICKUP, ARRIVAL } + public record Cargo(DispatchItem item,String orderNo) {} + public record Evidence(String itemId,String customerId,String orderNo,Phase phase,String cargoHash,long reviewRound, + List files,Instant occurredAt,String operatorId,String idempotencyKey,String requestHash) {} + private final DispatchAggregateRepository aggregates; + private final DispatchLooseboardDisplayService display; + private final DispatchLooseboardEvidenceRepository repository; + private final ObjectMapper json; + /** 事实读取不依赖执行服务,避免车队完成回调形成循环。 */ + public DispatchLooseboardFacts(DispatchAggregateRepository aggregates,DispatchLooseboardDisplayService display, + DispatchLooseboardEvidenceRepository repository,ObjectMapper json){this.aggregates=aggregates;this.display=display;this.repository=repository;this.json=json;} + /** 仅精确关联的本土散板进入照片流程,其他货物不按编号前缀判断。 */ + public List cargo(long id){ + var items=aggregates.cargo(id);if(items.isEmpty())return List.of(); + var identities=display.sources(items.stream().map(i->new Reference(i.getCustomerId(),i.getOrderNo(),i.getPlanPalletId())).toList()); + var result=new ArrayList(); + for(var item:items){var source=display.find(identities,item.getCustomerId(),item.getOrderNo(),item.getPlanPalletId());if(source!=null)result.add(new Cargo(item,source.orderNo()));} + return List.copyOf(result); + } + /** 全车确为散板且没有真实扫码才可跳过仓库出库;历史已出库事实不能抹除。 */ + public boolean direct(DispatchAggregate row){ + var cargo=cargo(row.getId());return !cargo.isEmpty() && cargo.size()==aggregates.cargo(row.getId()).size() && cargo.stream().allMatch(c->c.item().getWarehousePalletId()==null) + && Objects.requireNonNullElse(row.getPickedPackageCount(),0)==0 && aggregates.realPicked(row.getId())==0; + } + /** 尚无实托的本土散板在异地取货,不进入起运仓PDA和仓库出库数量。 */ + public Set remoteItemIds(long dispatchId){ + var items=aggregates.cargo(dispatchId);var sources=display.sources(items.stream().map(i->new Reference(i.getCustomerId(),i.getOrderNo(),i.getPlanPalletId())).toList()); + return items.stream().filter(i->i.getWarehousePalletId()==null).filter(i->{var source=display.find(sources,i.getCustomerId(),i.getOrderNo(),i.getPlanPalletId());return source!=null && "WAREHOUSE_PICKUP".equals(source.deliveryMode());}) + .map(DispatchItem::getId).collect(java.util.stream.Collectors.toUnmodifiableSet()); + } + /** 仓内作业保留全部普通来源及已存在真实实托的历史散板。 */ + public List warehouseItems(long dispatchId){var remote=remoteItemIds(dispatchId);return aggregates.cargo(dispatchId).stream().filter(i->!remote.contains(i.getId())).toList();} + /** 混排实际发运量只在保存本票取货照片后包含异地货物,不提前登记已装车。 */ + public long pickedRemotePackages(DispatchAggregate row){var remote=remoteItemIds(row.getId());var evidence=history(row.getId()).stream().filter(e->Objects.equals(e.cargoHash(),row.getCargoHash()) && e.reviewRound()==row.getReviewRound()).toList();return cargo(row.getId()).stream() + .filter(c->remote.contains(c.item().getId()) && evidence.stream().anyMatch(e->e.phase()==Phase.PICKUP && matches(e,c) && !e.files().isEmpty())) + .mapToLong(c->c.item().getPackageCount()).sum();} + /** 命令历史只读,JSON损坏明确失败,不静默当作照片缺失。 */ + public List history(long id){return repository.snapshots(id).stream().map(value->{ + try{return json.readValue(value,Evidence.class);}catch(Exception error){throw new IllegalStateException("散板照片履约历史读取失败",error);} + }).toList();} + /** 退回重审或换货后,旧照片仍在历史但不再代替本轮执行证据。 */ + public List current(DispatchAggregate row){ + var latest=new LinkedHashMap(); + history(row.getId()).stream().filter(e->Objects.equals(e.cargoHash(),row.getCargoHash()) && e.reviewRound()==row.getReviewRound()) + .forEach(e->latest.put(e.itemId()+":"+e.phase(),e)); + return List.copyOf(latest.values()); + } + /** 每一票都须有自己的有效照片,不能用全车总数代替。 */ + public boolean complete(DispatchAggregate row,Phase phase){ + var cargo=cargo(row.getId());var evidence=current(row); + return !cargo.isEmpty() && cargo.stream().allMatch(c->evidence.stream().anyMatch(e->e.phase()==phase && matches(e,c) && !e.files().isEmpty())); + } + /** 精确校验客户、明细和商业原单三者,不允许跨票复用照片。 */ + public static boolean matches(Evidence e,Cargo c){return e.itemId().equals(c.item().getId().toString()) && e.customerId().equals(c.item().getCustomerId()) && e.orderNo().equals(c.orderNo());} + /** 车队结案使用散板送达照片事实,无需制造客户出库或派送子单。 */ + public List outcomes(DispatchAggregate row){ + var evidence=current(row);return cargo(row.getId()).stream().map(c->{ + var at=evidence.stream().filter(e->e.phase()==Phase.ARRIVAL && matches(e,c) && !e.files().isEmpty()).map(Evidence::occurredAt).max(Instant::compareTo).orElse(null); + int packages=c.item().getPackageCount(); + return new FleetCompletionModels.Outcome(row.getDispatchNo(),c.orderNo(),c.item().getCustomerId(), + Objects.toString(c.item().getDestinationWarehouseCode(),c.item().getDeliveryAddress()),at==null?"DELIVERING":"DELIVERED",packages,at,at==null?0:packages,0,at==null?packages:0); + }).toList(); + } + /** 分车运输按每条有照片的明细去重累计。 */ + public long deliveredPackages(String customer,String order){return repository.deliveredPackages(customer,order);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java index e32cfe195..1d7cadcdc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java @@ -41,7 +41,7 @@ public class DispatchPdaQueryService { var byItem=frozen.stream().collect(Collectors.groupingBy(DispatchItemSource::getDispatchItemId)); var consumed=hasRelabel?sourceOrigins.pickedAll(ids).stream().collect(Collectors.toMap(p->p.sourceId(),p->p.packages())):Map.of(); for(var root:roots) { - var items=cargo.getOrDefault(root.getId(),List.of());var detail=new ArrayList(); + var remote=workflow.remoteItemIds(root);var items=cargo.getOrDefault(root.getId(),List.of()).stream().filter(i->!remote.contains(i.getId())).toList();var detail=new ArrayList(); boolean sourceValid=true;var expectedHold=new HashMap(); for(var source:frozen)if(Objects.equals(source.getDispatchOrderId(),root.getId())) { long used=consumed.getOrDefault(source.getId(),0L),left=n(source.getPackageCount())-used; @@ -77,13 +77,14 @@ public class DispatchPdaQueryService { .map(c->new DispatchViews.ReturnCarton(c.cartonId(),c.cartonMark(),c.status(),c.productQuantity())).toList(); detail.add(new DispatchViews.PdaItem(item.getId(),item.getCustomerId(),item.getCustomerName(),item.getPalletNo(),workflow.destination(item),n(item.getPackageCount()),picked.getOrDefault(item.getId(),0L),sourcePositions,sourceCartons,item.getSourceKey(),item.getSourceBusinessType(),item.getSourceBusinessNo())); } - boolean complete=sourceValid && n(root.getPackageCount())>0 && n(root.getPickedPackageCount())==n(root.getPackageCount()) && invalid.getOrDefault(root.getId(),0L)==0 + long warehousePackages=items.stream().mapToLong(i->n(i.getPackageCount())).sum(); + boolean complete=sourceValid && warehousePackages>0 && n(root.getPickedPackageCount())==warehousePackages && invalid.getOrDefault(root.getId(),0L)==0 && !items.isEmpty() && items.stream().allMatch(i->picked.getOrDefault(i.getId(),0L)==n(i.getPackageCount())); var actions=new EnumMap(workflow.actions(actor,root)); if(!sourceValid)actions.put(Action.PICK,new Gate(false,"SOURCE_COMPOSITION_CHANGED","换标来源实际位置或累计剩余额度已变化,请回平台核对")); if(!complete)actions.put(Action.LOADING_COMPLETE,new Gate(false,"PICKING_INCOMPLETE","逐项真实拣货来源尚未核平")); else actions.put(Action.PICK,new Gate(false,"PICKING_RECONCILED","本车全部来源已核平,请显式确认装车")); - var counts=new Counts(n(root.getItemCount()),n(root.getPalletCount()),detail.stream().mapToLong(DispatchViews.PdaItem::scheduledPackages).sum(),n(root.getPickedPackageCount()),n(root.getShippedPackageCount())); + var counts=new Counts(items.size(),items.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).distinct().count(),warehousePackages,n(root.getPickedPackageCount()),root.getDepartedAt()==null?0:warehousePackages); // 整车作业直接以排车业务号识别;真实客户子taskId只在服务端审计保存。 result.add(new DispatchViews.PdaTask(root.getDispatchNo(),root.getDispatchNo(),root.getOverseasWarehouseId(),Status.valueOf(root.getStatus()),Stage.valueOf(root.getStage()),root.getRecordVersion(),workflow.reviews(root),workflow.resources(root),counts,complete,List.copyOf(detail),Collections.unmodifiableMap(actions), root.getPlannedDepartureAt()==null?null:root.getPlannedDepartureAt().toInstant(java.time.ZoneOffset.UTC), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java index 37693d30a..ed66683d4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java @@ -44,6 +44,7 @@ public class DispatchPickingService { var context=workflow.lockForArrangement(actor,number);var row=repository.lock(number); var identity=new CommandIdentity(Action.PICK,request.idempotencyKey(),request.recordVersion(),hashes.command(actor,Action.PICK,number,request.recordVersion(),Arrays.asList(itemId,request))); var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get(); + if(workflow.photoOnly(row))throw conflict("纯散板无需PDA拣货,请在排车管理资料上传取货照片"); if(context.recordVersion()!=request.recordVersion() || context.status()!=Status.ACTIVE || context.stage()!=Stage.LOADING || context.reviews().dispatchReview().status()!=ReviewStatus.APPROVED || context.reviews().loadingReview().status()!=ReviewStatus.APPROVED)throw conflict("当前版本或两级审核不允许扫码"); workflow.requireReviewAppointment(row); @@ -53,7 +54,8 @@ public class DispatchPickingService { var item=currentItems.stream().filter(i->Objects.equals(i.getId(),itemId)).findFirst().orElseThrow(()->new BizException(ResultCode.NOT_FOUND,"本车货物明细不存在")); if(!Objects.equals(item.getOverseasWarehouseId(),actor.boundWarehouseId()))throw conflict("本货物不属于当前绑定仓"); if(item.getWarehousePalletId()==null)throw conflict("该托盘尚未完成收货交接,请先确认入库后再拣货"); - relabelSources.validateFrozen(row,currentItems,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),currentItems)); + var warehouseItems=workflow.warehouseItems(row); + relabelSources.validateFrozen(row,warehouseItems,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),warehouseItems)); var task=picking.task(row.getId());var at=LocalDateTime.ofInstant(Instant.now().truncatedTo(ChronoUnit.MILLIS),ZoneOffset.UTC); if(task==null) { task=new DispatchPdaTask();task.setId(IdWorker.getId());task.setDispatchOrderId(row.getId()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java index 5b1527594..e9c336569 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -400,7 +400,7 @@ public class DispatchService { var pallets=items.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).collect(Collectors.toCollection(TreeSet::new)); var orders=executionOrders(warehouse,items,pallets); orders.forEach((id,customer)->{if(wholeSources.lockOrder(customer,id)==null)throw new ResponseStatusException(HttpStatus.CONFLICT,"本车真实来源订单已缺失或暂扣");}); - palletSerial.lock(List.copyOf(pallets)); + if(!pallets.isEmpty())palletSerial.lock(List.copyOf(pallets)); if(!orders.equals(executionOrders(warehouse,items,pallets)))throw new ResponseStatusException(HttpStatus.CONFLICT,"本车逐票来源已变化,请重新核对"); for(var item:items.stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) if(wholeSources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw new ResponseStatusException(HttpStatus.CONFLICT,"本车计划托盘已缺失或归属变化"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/LooseboardCargoService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/LooseboardCargoService.java new file mode 100644 index 000000000..56eab8c6d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/LooseboardCargoService.java @@ -0,0 +1,44 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.repository.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import java.time.Instant; +import java.util.List; +import java.util.Set; + +/** 已发布日常派送排车复用正式加货命令,不创建第二车次或库存。 */ +@Service +public class LooseboardCargoService { + private final LooseboardCargoRepository cargo; + private final DispatchAggregateRepository dispatches; + private final DispatchAggregateService service; + public LooseboardCargoService(LooseboardCargoRepository cargo, DispatchAggregateRepository dispatches, DispatchAggregateService service) { + this.cargo=cargo; this.dispatches=dispatches; this.service=service; + } + private String warehouse(String number) { + var row=dispatches.find(number); + if(row==null || !Boolean.TRUE.equals(row.getPublishLooseboardDemand()) || !"DELIVERY".equals(row.getTaskType()) || !"ACTIVE".equals(row.getStatus()) || row.getDepartedAt()!=null) + throw new ResponseStatusException(HttpStatus.CONFLICT,"该排车已退出散板需求池,请刷新列表"); + return row.getOverseasWarehouseId(); + } + @Transactional(readOnly=true) + public DispatchPlanningModels.CandidateList candidates(DispatchContracts.Actor actor,String number,int page,int size) { + String warehouse=warehouse(number); var plans=cargo.readyPlans(warehouse); + if(plans.isEmpty())return new DispatchPlanningModels.CandidateList(List.of(),Instant.now(),true,0,page,size,0); + if(plans.size()>500)throw new ResponseStatusException(HttpStatus.CONFLICT,"同仓待排散板过多,请先在排车工作台处理"); + return service.candidates(actor,new DispatchPlanningModels.CandidateQuery(warehouse,plans,null,null,null,null,page,size,"ALL")); + } + @Transactional + public DispatchContracts.CommandResult add(DispatchContracts.Actor actor,String number,DispatchCommands.AddCargo request) { + String warehouse=warehouse(number); Set allowed=Set.copyOf(cargo.readyPallets(warehouse)); + if(request.cargo()==null || request.cargo().isEmpty() || request.cargo().stream().anyMatch(s->s==null || s.sourceType()!=DispatchTypes.SourceType.INBOUND_PLAN || s.planPalletId()==null)) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"请选择已审核散板的申报或实收货物"); + // 正式来源解析再次校验计划、客户、库存、整托闭包和来源版本。 + if(request.cargo().stream().anyMatch(s->!allowed.contains(s.planPalletId())))throw new ResponseStatusException(HttpStatus.CONFLICT,"散板审核、收货或占用状态已变化,请重新选货"); + return service.addCargo(actor,number,request); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/controller/ExceptionOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/controller/ExceptionOrderController.java index 27250379d..66e948190 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/controller/ExceptionOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/controller/ExceptionOrderController.java @@ -24,6 +24,18 @@ import java.util.List; public class ExceptionOrderController { @Resource private ExceptionOrderService service; @Resource private com.baiyue.lastleg.exceptionorder.service.ExceptionReturnInboundService returns; + @Resource private com.baiyue.lastleg.exceptionorder.service.ExceptionReturnPlanService returnPlans; + + /** 异常退回无来源柜,按本次到仓时间建立关联尾程的独立计划。 */ + @PostMapping("/exception-orders/return-inbound-plans") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_CREATE + "')") + public ApiResponse createReturnPlan( + @AuthenticationPrincipal AuthenticatedUser user,@RequestHeader("Idempotency-Key") String key, + @Valid @RequestBody com.baiyue.lastleg.exceptionorder.dto.ExceptionReturnPlanRequest request) { + requiredScope(user,request.plan().customerId()); + if(user.tenantId()!=0L||!"PLATFORM".equals(user.appCode()))throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台账号可创建异常入库计划"); + return ApiResponse.success(returnPlans.create(request,key,operatorId(user))); + } @GetMapping("/exception-orders") @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_LIST + "')") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionReturnPlanRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionReturnPlanRequest.java new file mode 100644 index 000000000..f32dab4e2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionReturnPlanRequest.java @@ -0,0 +1,10 @@ +package com.baiyue.lastleg.exceptionorder.dto; + +import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDateTime; + +/** 异常退回按本单填写到仓时间,不继承原头程柜的日期。 */ +public record ExceptionReturnPlanRequest(@NotNull @Valid ManualInboundPlanRequest plan, + @NotNull LocalDateTime expectedArrivalAt) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionWorkflowMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionWorkflowMapper.java index c6c99bb3d..0f0307e7a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionWorkflowMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionWorkflowMapper.java @@ -36,12 +36,13 @@ public interface ExceptionWorkflowMapper { """) List tasks(String warehouse); - /** 新尾程单按冻结退回货物展示明细,库存仍归原出库来源。 */ - @Insert(""" - INSERT INTO ll_order_detail(customer_id,order_id,source_inbound_detail_id,sku_code,package_count) - SELECT customer_id,#{tail},CAST(id AS CHAR),sku_code,approved_return_packages-received_packages - FROM ll_exception_return_item WHERE exception_order_id=#{exception} AND customer_id=#{customer} - AND approved_return_packages>received_packages + /** 退回计划只跟随原异常真实实收结果,不触发另一笔库存入账。 */ + @Update(""" + UPDATE ll_inbound_plan p JOIN ll_order o ON o.id=p.source_order_id AND o.customer_id=p.customer_id + SET p.status=CASE WHEN o.current_status='INBOUND_COMPLETED' THEN 'COMPLETED' ELSE 'PARTIAL_INBOUND' END, + p.updated_at=o.updated_at,p.version=p.version+1 + WHERE o.customer_id=#{customer} AND o.id=#{tail} AND p.creation_mode='EXCEPTION_RETURN' + AND o.received_package_count>0 """) - void copyReturnDetails(@Param("tail") long tail,@Param("exception") long exception,@Param("customer") String customer); + void syncReturnPlan(@Param("customer") String customer,@Param("tail") long tail); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/repository/ExceptionReturnPlanRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/repository/ExceptionReturnPlanRepository.java new file mode 100644 index 000000000..624e059af --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/repository/ExceptionReturnPlanRepository.java @@ -0,0 +1,51 @@ +package com.baiyue.lastleg.exceptionorder.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderCommandRecord; +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderCommandRecordMapper; +import org.springframework.stereotype.Repository; + +/** 异常退回计划与尾程关联在同一服务库、同一事务保存。 */ +@Repository +public class ExceptionReturnPlanRepository { + @jakarta.annotation.Resource private LastlegOrderMapper orders; + @jakarta.annotation.Resource private LastlegOrderCommandRecordMapper commands; + @jakarta.annotation.Resource private InboundPlanMapper plans; + @jakarta.annotation.Resource private InboundPlanLineMapper lines; + + @jakarta.annotation.Resource private com.baiyue.lastleg.repository.DeliveryReturnCargoRepository cargo; + @jakarta.annotation.Resource private com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper histories; + + /** 读取真实退回货物供计划汇总体积,未知体积不猜测。 */ + public java.util.List cargo(String customer,long order) { return cargo.cargo(customer,order); } + /** 保存生成计划的状态轨迹,便于尾程详情追溯。 */ + public void history(com.baiyue.lastleg.entity.LastlegOrderStatusHistory history) { histories.insert(history); } + /** 先读取异常身份,写入时按异常再尾程的统一锁顺序重新核验。 */ + public LastlegOrder find(String customer,long id) { + return orders.selectOne(new LambdaQueryWrapper().eq(LastlegOrder::getCustomerId,customer).eq(LastlegOrder::getId,id)); + } + + /** 在客户范围锁定退回单,防止并发重复计划和跨客户访问。 */ + public LastlegOrder lock(String customer, long id) { + return orders.selectOne(new LambdaQueryWrapper().eq(LastlegOrder::getCustomerId,customer) + .eq(LastlegOrder::getId,id).last("FOR UPDATE")); + } + /** 原键只允许恢复原请求结果。 */ + public LastlegOrderCommandRecord command(String customer,long order,String operator,String key) { + return commands.find(customer,order,"EXCEPTION_RETURN_PLAN",operator,key); + } + /** 判断当前退回单是否已有独立计划。 */ + public InboundPlan existing(String customer,long order) { return plans.findBySourceOrder(customer,order); } + /** 保存计划主单及尾程关联行,不在建计划时增加库存。 */ + public void insert(InboundPlan plan,InboundPlanLine line) { plans.insert(plan);lines.insert(line); } + /** 保存到仓时间和入库单号,保留原尾程来源身份。 */ + public void update(LastlegOrder order) { orders.updateById(order); } + /** 保存命令请求摘要以处理重复点击。 */ + public void insert(LastlegOrderCommandRecord command) { commands.insert(command); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/repository/ExceptionWorkflowRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/repository/ExceptionWorkflowRepository.java index 2b083f3d0..fb224fd81 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/repository/ExceptionWorkflowRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/repository/ExceptionWorkflowRepository.java @@ -13,6 +13,7 @@ import java.util.List; /** 异常退回尾程关系及仓库任务的数据访问边界。 */ @Repository public class ExceptionWorkflowRepository { + @jakarta.annotation.Resource private com.baiyue.lastleg.repository.DeliveryReturnCargoRepository cargo; @jakarta.annotation.Resource private ExceptionOrderCommandMapper commands; private final ExceptionWorkflowMapper workflow; private final ExceptionOrderMapper exceptions; @@ -42,9 +43,12 @@ public class ExceptionWorkflowRepository { /** 保存新退回尾程单。 */ public void insert(LastlegOrder order) { orders.insert(order); } /** 将异常冻结明细投影到退回尾程单,不改变原单与库存。 */ - public void copyDetails(LastlegOrder tail,ExceptionOrder exception) { workflow.copyReturnDetails(tail.getId(),exception.getId(),exception.getCustomerId()); } + public void copyDetails(LastlegOrder tail,ExceptionOrder exception) { cargo.copy(exception.getCustomerId(),tail.getId()); } /** 更新退回单的真实实收进度。 */ - public void update(LastlegOrder order) { orders.updateById(order); } + public void update(LastlegOrder order) { + orders.updateById(order); + if("DELIVERY_RETURN".equals(order.getOrderType()))workflow.syncReturnPlan(order.getCustomerId(),order.getId()); + } /** 读取当前绑定仓待验收任务。 */ public List tasks(String warehouse) { return workflow.tasks(warehouse); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java index ae252e375..2e6dc93e7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java @@ -414,7 +414,7 @@ public class ExceptionOrderService { var returnTail=workflow.returnOrder(order); if(returnTail!=null) { returnTail.setReceivedPackageCount(Math.toIntExact(stored.stream().mapToLong(ReturnReceiptPolicy::received).sum())); - returnTail.setCurrentStatus(completed?"INBOUND_COMPLETED":"PENDING_INBOUND"); + returnTail.setCurrentStatus(completed?"INBOUND_COMPLETED":(returnTail.getInboundOrderNo()==null?"PENDING_INBOUND":"PARTIAL_INBOUND")); returnTail.setVersion(Objects.requireNonNullElse(returnTail.getVersion(),0)+1); returnTail.setUpdatedAt(now); workflow.update(returnTail); } closeReturnedDelivery(order, operatorId, now); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnInboundService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnInboundService.java index fc0c94fb5..415ee3c8d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnInboundService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnInboundService.java @@ -57,7 +57,7 @@ public class ExceptionReturnInboundService { tail.setSourceRegion("EXCEPTION_RETURN"); tail.setSourceInboundId(String.valueOf(order.getId())); tail.setSourceOrderId(String.valueOf(original.getId())); tail.setSourceOrderNo(original.getOrderNo()); tail.setRootOrderId(original.getRootOrderId()); // 原入仓码由异常冻结来源保留,新尾程单不重复占用原入仓关系。 - tail.setInboundCode(null); tail.setRootInboundId(original.getRootInboundId()); + tail.setInboundCode(tail.getOrderNo()); tail.setRootInboundId(original.getRootInboundId()); tail.setOverseasWarehouseId(order.getOverseasWarehouseId()); tail.setOverseasWarehouseName(order.getOverseasWarehouseName()); tail.setOverseasWarehouseCode(original.getOverseasWarehouseCode()); tail.setCurrentStatus("PENDING_INBOUND"); tail.setSubOrderStatus("EXCEPTION:"+number); tail.setPackageCount(Math.toIntExact(sources.stream().mapToLong(s->s.maxReturnPackages()).sum())); tail.setReceivedPackageCount(0); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnPlanService.java new file mode 100644 index 000000000..d99c3259c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnPlanService.java @@ -0,0 +1,106 @@ +package com.baiyue.lastleg.exceptionorder.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.entity.LastlegOrderCommandRecord; +import com.baiyue.lastleg.exceptionorder.dto.ExceptionReturnPlanRequest; +import com.baiyue.lastleg.exceptionorder.repository.ExceptionReturnPlanRepository; +import com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; +import com.baiyue.lastleg.inboundplan.entity.*; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.inboundplan.service.InboundPlanService; +import com.baiyue.lastleg.service.LastlegOrderNoGenerator; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.http.HttpStatus; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.util.HexFormat; +import java.util.Objects; + +/** 派送退回单独建计划,沿用异常验收恢复原库存,不能转为普通头程再次入账。 */ +@Service +public class ExceptionReturnPlanService { + private final ExceptionReturnPlanRepository repository; + private final ExceptionWorkflowRepository exceptions; + private final WarehouseResourceGateway warehouse; + private final InboundPlanService plans; + private final LastlegOrderNoGenerator numbers; + private final ObjectMapper json; + /** 装配计划、异常冻结来源及仓库目录边界。 */ + public ExceptionReturnPlanService(ExceptionReturnPlanRepository repository,ExceptionWorkflowRepository exceptions, + WarehouseResourceGateway warehouse,InboundPlanService plans,LastlegOrderNoGenerator numbers,ObjectMapper json) { + this.repository=repository;this.exceptions=exceptions;this.warehouse=warehouse;this.plans=plans;this.numbers=numbers;this.json=json; + } + /** 同事务锁定本单和异常,校验日期、版本与原键后生成独立入库计划。 */ + @Transactional + public InboundPlanResponse create(ExceptionReturnPlanRequest request,String key,String operator) { + var input=request.plan(); + if(key==null||key.isBlank()||key.length()>128||request.expectedArrivalAt()==null) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"请填写预计到仓时间和有效幂等键"); + var order=repository.find(input.customerId(),input.orderId()); + if(order==null||!"DELIVERY_RETURN".equals(order.getOrderType())||!"EXCEPTION_RETURN".equals(order.getSourceRegion())) + throw new ResponseStatusException(HttpStatus.NOT_FOUND,"异常退回尾程订单不存在"); + String hash=hash(request); + var replay=repository.command(input.customerId(),input.orderId(),operator,key); + if(replay!=null) { + if(!hash.equals(replay.getRequestHash()))throw conflict("同一幂等键不能用于不同请求"); + try { return json.readValue(replay.getResultJson(),InboundPlanResponse.class); } + catch(Exception error) { throw new IllegalStateException("原计划响应读取失败",error); } + } + var exception=exceptions.lock(input.customerId(),order.getSourceEventId()); + order=repository.lock(input.customerId(),input.orderId()); + if(order==null)throw conflict("退回尾程单已变化"); + if(exception==null||!"PROCESSING".equals(exception.getStatus())||!"OVERSEAS_WAREHOUSE".equals(exception.getReturnDirection()) + ||!String.valueOf(exception.getId()).equals(order.getSourceInboundId()) + ||!Objects.equals(order.getOverseasWarehouseId(),exception.getOverseasWarehouseId()))throw conflict("原异常退回状态或目标仓已变化"); + if(!"PENDING_INBOUND".equals(order.getCurrentStatus())||!Objects.equals(order.getVersion(),input.expectedOrderVersion()) + ||order.getInboundOrderNo()!=null||repository.existing(input.customerId(),input.orderId())!=null) + throw conflict("订单已生成入库计划或版本已变化,请刷新"); + if(order.getPackageCount()==null||order.getPackageCount()<=0)throw conflict("异常退回货物数量无效"); + var receiving=warehouse.requireActiveSharedWarehouse(Long.parseLong(order.getOverseasWarehouseId())); + if(receiving==null||receiving.warehouseId()!=Long.parseLong(order.getOverseasWarehouseId())||!"ACTIVE".equals(receiving.status()))throw conflict("退回海外仓不可用"); + var cargo=repository.cargo(input.customerId(),input.orderId()); + if(!cargo.isEmpty()&&cargo.stream().allMatch(item->item.getVolumeCbm()!=null)) + order.setVolumeCbm(cargo.stream().map(com.baiyue.lastleg.entity.LastlegOrderDetail::getVolumeCbm).reduce(BigDecimal.ZERO,BigDecimal::add)); + var now=LocalDateTime.now();var plan=new InboundPlan(); + plan.setId(IdWorker.getId());plan.setPlanNo(numbers.next("INBOUND_PLAN"));plan.setCustomerId(input.customerId()); + plan.setContainerNo(""); + plan.setSourceOrderId(order.getId());plan.setOverseasWarehouseId(receiving.warehouseId()); + plan.setPlannedArrivalDate(request.expectedArrivalAt().toLocalDate());plan.setPalletType(input.palletType()); + plan.setAssignedFlag(input.assigned());plan.setMergeRequired(input.mergeRequired());plan.setUrgentFlag(input.urgent()); + plan.setCreationMode("EXCEPTION_RETURN");plan.setStatus("PENDING_RECEIVING");plan.setLineCount(1); + plan.setPalletCount(input.palletCount());plan.setTotalVolumeCbm(Objects.requireNonNullElse(order.getVolumeCbm(),BigDecimal.ZERO));plan.setPlanRemark(input.remark()); + plan.setVersion(0);plan.setCreatedAt(now);plan.setUpdatedAt(now); + // 新退回入仓码使用本次尾程单号,原入仓码只保留在冻结退回来源中。 + var line=new InboundPlanLine();line.setId(IdWorker.getId());line.setCustomerId(input.customerId());line.setPlanId(plan.getId()); + line.setOrderId(order.getId());line.setInboundCode(order.getOrderNo());line.setDestinationType("OVERSEAS"); + line.setDestinationWarehouseId(order.getOverseasWarehouseId());line.setDestinationWarehouseCode(receiving.warehouseCode()); + line.setDestinationWarehouseName(receiving.warehouseName());line.setRequiredPackageCount(order.getPackageCount()); + line.setPlannedPalletCount(input.palletCount());line.setVolumeCbm(order.getVolumeCbm());repository.insert(plan,line); + // 这里只登记计划托数;真实托盘和库位在异常验收时选择并验证,不生成占位托盘码。 + order.setInboundCode(order.getOrderNo());order.setInboundOrderNo(plan.getPlanNo());order.setEstimatedWarehouseAt(request.expectedArrivalAt()); + order.setCurrentStatus("INBOUND_PLANNED");order.setPalletCount(input.palletCount());order.setVersion(order.getVersion()+1);order.setUpdatedAt(now);repository.update(order); + var history=new com.baiyue.lastleg.entity.LastlegOrderStatusHistory(); + history.setCustomerId(order.getCustomerId());history.setOrderId(order.getId());history.setFromStatus("PENDING_INBOUND"); + history.setToStatus("INBOUND_PLANNED");history.setEventId(plan.getPlanNo());history.setRemark("异常退回生成入库计划:"+plan.getPlanNo());history.setCreatedAt(now);repository.history(history); + var command=new LastlegOrderCommandRecord();command.setId(IdWorker.getId());command.setCustomerId(input.customerId());command.setOrderId(order.getId()); + command.setAction("EXCEPTION_RETURN_PLAN");command.setOperatorId(operator);command.setIdempotencyKey(key);command.setRequestHash(hash); + var result=plans.detailByPlanNo(input.customerId(),plan.getPlanNo()); + try { command.setResultJson(json.writeValueAsString(result)); } + catch(Exception error) { throw new IllegalStateException("计划响应保存失败",error); } + command.setResultRef(plan.getPlanNo());command.setResultVersion(order.getVersion());command.setCreatedAt(now);repository.insert(command); + return result; + } + /** 对完整请求计算稳定摘要,日期也参与原键一致性检查。 */ + private String hash(Object value) { + try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.writeValueAsString(value).getBytes(StandardCharsets.UTF_8)));} + catch(Exception error){throw new IllegalStateException("异常入库计划请求摘要失败",error);} + } + /** 状态变化按业务冲突返回,调用方保留表单后刷新。 */ + private ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java index eb23768ce..9c6d4142d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java @@ -98,12 +98,17 @@ public class FleetTripExecutionService { /** 已锁父级后复验已确认实载及原预约资格,不在此扣库存或锁客户单据。 */ @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) public void requireDispatchDeparture(String no,String manifestHash) { + requireDispatchDeparture(no,manifestHash,Set.of()); + } + /** 混排按权威异地明细区分计划取货和仓内已拣,其余发车条件完全相同。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void requireDispatchDeparture(String no,String manifestHash,Set remoteItemIds) { var identity=history.dispatchIdentity(no);var trip=requireTrip(identity.tripNo(),true); var load=load(trip);if(!"PLANNED".equals(trip.getStatus()) || load==null || !Objects.equals(load.manifestHash(),manifestHash))throw conflict("本车实载确认已变化,请重新核对"); if(trip.getVehicleId()!=null)requireResource("VEHICLE",trip.getVehicleId(),trip.getId()); if(trip.getDriverId()!=null)requireResource("DRIVER",trip.getDriverId(),trip.getId()); bookingReservations.actualSegments(trip,load.pallets()); - appointmentAllocations.requireDeparture(no); + if(remoteItemIds.isEmpty())appointmentAllocations.requireDeparture(no);else appointmentAllocations.requireMixedDeparture(no,remoteItemIds); } /** 客户单据和来源均已核对后记录实际运输状态,资源占用保留到原任务结束入口。 */ @@ -115,6 +120,17 @@ public class FleetTripExecutionService { history.recordExecution(trip,trip.getRecordVersion(),"DEPART",reason,operator); } + /** 纯散板按取货照片发运;仅核验原占用和预约,不制造实托测量或仓库出库。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void departPhotoDispatchShell(String no,String operator,Instant at,String reason) { + var identity=history.dispatchIdentity(no);var trip=requireTrip(identity.tripNo(),true); + if(trip.getVehicleId()!=null)requireResource("VEHICLE",trip.getVehicleId(),trip.getId()); + if(trip.getDriverId()!=null)requireResource("DRIVER",trip.getDriverId(),trip.getId()); + if(bookingReservations.hasConfirmed(trip.getId()))throw conflict("本车仍有未接入排车的外接预订,请先核对全部货物"); + appointmentAllocations.requirePhotoDeparture(no); + departDispatchShell(no,operator,at,reason); + } + /** 编辑先锁齐全部新旧身份,保持全局资源锁序。 */ @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) public FleetExecutionModels.DispatchShellLock lockDispatchShell(String no,Long vehicle,Long driver) { return history.lockDispatchShell(no,vehicle,driver); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java index 7b6d6bf6e..38ab2823b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java @@ -1054,9 +1054,12 @@ public class InboundPdaWorkflowService { repository.update(plan); } + /** 异常退回必须沿原出库来源验收,禁止通过普通入仓流程重复增加库存。 */ + /** 锁定客户计划,异常退回只能由原异常验收恢复库存。 */ private InboundPlan requirePlan(String customerId, String planNo) { InboundPlan plan = repository.lockPlan(customerId, planNo); if (plan == null) throw notFound("Inbound plan not found"); + if ("EXCEPTION_RETURN".equals(plan.getCreationMode())) throw conflict("派送异常退回请从异常退回验收入口收货"); return plan; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBinding.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBinding.java index f12cdc5dd..864f57404 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBinding.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBinding.java @@ -150,6 +150,15 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo if(item.packages()!=null && selected.stream().mapToLong(Allocation::packageCount).sum()!=item.packages()) throw conflict("逐票分配与所选托盘箱数不一致"); } public void requireDeparture(String dispatchNo) { checkDeparture(dispatchNo,true,true); } + /** 照片直送仍锁定并复验原预约分配,只跳过不存在的仓内拣货事实。 */ + public void requirePhotoDeparture(String dispatchNo) { checkDeparture(dispatchNo,true,false); } + /** 调用方从商业原单关联提供异地明细,仓内明细不放宽扫码要求。 */ + public void requireMixedDeparture(String dispatchNo,Set remoteItemIds){checkDeparture(dispatchNo,true,true,remoteItemIds);} + /** 只读预览与锁内发车复用逐明细校验。 */ + public String mixedDepartureBlockReason(String dispatchNo,Set remoteItemIds){ + try{checkDeparture(dispatchNo,false,true,remoteItemIds);return null;} + catch(org.springframework.web.server.ResponseStatusException error){if(error.getStatusCode().value()!=409)throw error;return error.getReason();} + } public String departureBlockReason(String dispatchNo) { try { checkDeparture(dispatchNo,false,true);return null; } catch(org.springframework.web.server.ResponseStatusException error) { @@ -196,8 +205,13 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo } catch(java.time.DateTimeException error) {throw conflict("预约收货时间的时区或偏移无效");} } private void checkDeparture(String dispatchNo,boolean locked,boolean picked) { + checkDeparture(dispatchNo,locked,picked,Set.of()); + } + /** 混排异地散板不从仓内库存取货,仍核对同车客户、计划和预约数量。 */ + private void checkDeparture(String dispatchNo,boolean locked,boolean picked,Set remoteItemIds) { var rows=mapper.bindings(dispatchNo); var physical=mapper.dispatchPhysical(dispatchNo); + if(!physical.stream().map(AppointmentWorkDispatchMapper.DispatchPhysical::itemId).collect(Collectors.toSet()).containsAll(remoteItemIds))throw conflict("异地取货明细不属于本车"); for(var row:rows) if(physical.stream().noneMatch(i->i.itemId()==row.itemId() && Objects.equals(i.planPalletId(),row.palletId()) && row.customerId().equals(i.customerId()))) throw conflict("预约分配与本车真实客户实物不一致"); var batches=rows.stream().map(AppointmentWorkDispatchMapper.Bound::batchKey).filter(b->!b.startsWith("DISPATCH:")).distinct().sorted().toList(); @@ -221,7 +235,7 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo var first=group.get(0); var item=physical.stream().filter(i->i.itemId()==first.itemId()).findFirst().orElseThrow(); validatePhysical(first.customerId(),new PhysicalItem(first.itemId(),first.customerId(),first.palletId(),item.sourceStage(),item.packages()), - group.stream().map(a->allocation(a,null)).toList(),picked || "RECEIVED".equals(item.sourceStage()),picked); + group.stream().map(a->allocation(a,null)).toList(),picked && !remoteItemIds.contains(item.itemId()) || "RECEIVED".equals(item.sourceStage()),picked && !remoteItemIds.contains(item.itemId())); } } private void requireCurrentAllocation(com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse appointment,String customer,long dispatch,List allocations) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java index 394c2285a..d40e9bbfe 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java @@ -122,19 +122,23 @@ public interface AppointmentWorkDispatchMapper { List candidates(long id); @Select("SELECT l.order_id,l.customer_id,CASE WHEN l.destination_type='PRIVATE' THEN CONCAT('CUSTOMER:',l.order_id) ELSE CONCAT('FBA:',l.fba_warehouse_code) END destination_key,CASE WHEN COUNT(a.planned_package_count)=COUNT(a.line_id) THEN SUM(a.planned_package_count) END planned_packages,CASE WHEN COUNT(a.counted_package_count)=COUNT(a.line_id) THEN SUM(a.counted_package_count) END counted_packages FROM ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id WHERE a.pallet_id=#{pallet} AND a.customer_id=#{customer} GROUP BY l.order_id,l.customer_id,l.destination_type,l.fba_warehouse_code") List physical(@Param("pallet") long pallet,@Param("customer") String customer); - /** 已收货托盘按当前库位逐票归属读取;移托后的库存不能继续使用原收货分配箱数。 */ + /** 已收货按当前库位逐票读取;转运来源目的地取转运单权威字段,移托不改变来源归属。 */ @Select(""" SELECT o.id order_id,o.customer_id, - CASE WHEN o.destination_type='PRIVATE' THEN CONCAT('CUSTOMER:',o.id) ELSE CONCAT('FBA:',o.fba_warehouse_code) END destination_key, + CASE WHEN o.order_type='WAREHOUSE_TRANSFER' THEN + CASE WHEN t.destination_type='PRIVATE' THEN CONCAT('CUSTOMER:',o.id) + WHEN t.destination_type='FBA' THEN CONCAT('FBA:',t.destination_warehouse_code) END + ELSE CASE WHEN o.destination_type='PRIVATE' THEN CONCAT('CUSTOMER:',o.id) ELSE CONCAT('FBA:',o.fba_warehouse_code) END END destination_key, NULL planned_packages, SUM(COALESCE(pos.available_packages,0)+COALESCE(pos.allocated_packages,0)+COALESCE(pos.picked_packages,0)) counted_packages FROM ll_inbound_plan_pallet pp JOIN ll_inventory_position pos ON pos.pallet_id=pp.warehouse_pallet_id AND pos.customer_id=pp.customer_id JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id JOIN ll_order o ON o.customer_id=b.customer_id AND o.inbound_code=b.inbound_code + LEFT JOIN ll_warehouse_transfer_order t ON o.order_type='WAREHOUSE_TRANSFER' AND t.lastleg_order_id=o.id AND t.customer_id=o.customer_id WHERE pp.id=#{pallet} AND pp.customer_id=#{customer} AND COALESCE(pos.available_packages,0)+COALESCE(pos.allocated_packages,0)+COALESCE(pos.picked_packages,0)>0 - GROUP BY o.id,o.customer_id,o.destination_type,o.fba_warehouse_code + GROUP BY o.id,o.customer_id,o.order_type,o.destination_type,o.fba_warehouse_code,t.destination_type,t.destination_warehouse_code """) List receivedPhysical(@Param("pallet") long pallet,@Param("customer") String customer); @Select("SELECT a.order_id,a.customer_id,CASE WHEN i.destination_type='PRIVATE' THEN CONCAT('CUSTOMER:',a.order_id) ELSE CONCAT('FBA:',i.fba_warehouse_code) END destination_key,NULL planned_packages,SUM(a.packages) counted_packages FROM ll_dispatch_pda_pick_order a JOIN ll_dispatch_pda_pick p ON p.id=a.pick_id AND p.customer_id=a.customer_id JOIN ll_dispatch_item i ON i.id=p.dispatch_item_id AND i.customer_id=p.customer_id WHERE i.id=#{item} AND a.customer_id=#{customer} GROUP BY a.order_id,a.customer_id,i.destination_type,i.fba_warehouse_code") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/controller/PlatformLocalLooseboardController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/controller/PlatformLocalLooseboardController.java index a201c41a0..72db0e353 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/controller/PlatformLocalLooseboardController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/controller/PlatformLocalLooseboardController.java @@ -70,6 +70,14 @@ public class PlatformLocalLooseboardController { return ApiResponse.success(service.changeStatus(requiredCustomer(customerId), orderNo, operator(user), request)); } + @PostMapping("/{orderNo}/review") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LOOSEBOARD_STATUS + "')") + public ApiResponse review(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam String customerId, @PathVariable String orderNo, @Valid @RequestBody LocalLooseboardRequests.Review request) { + requirePermission(user, OverseasPermissions.LOOSEBOARD_STATUS); + return ApiResponse.success(service.review(requiredCustomer(customerId), orderNo, operator(user), request)); + } + private void requirePlatform(AuthenticatedUser user) { if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardRequests.java index 427f5081e..f80e62da7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardRequests.java @@ -19,7 +19,15 @@ public final class LocalLooseboardRequests { private LocalLooseboardRequests() { } public record Create( - @NotBlank @Size(max = 255) String origin, + @NotBlank @Size(max=128) String operatingWarehouseId, + @Pattern(regexp="FBA|PRIVATE") String destinationType, + @Size(max=32) String destinationPostalCode, + @NotBlank @Pattern(regexp = "DELIVERY_TO_WAREHOUSE|WAREHOUSE_PICKUP") String deliveryMode, + @Size(max = 128) String deliveryContactName, + @Size(max = 64) String deliveryContactPhone, + LocalDateTime estimatedArrivalAt, + @Size(max = 255) String trackingNo, + @Size(max = 255) String origin, @Size(max = 500) String originAddress, @NotBlank @Size(max = 255) String destination, @Size(max = 500) String destinationAddress, @@ -30,14 +38,17 @@ public final class LocalLooseboardRequests { @NotNull @DecimalMin("0.00") BigDecimal totalVolumeCbm, @DecimalMin("0.00") BigDecimal budgetAmount, @Pattern(regexp = "USD|CNY") String currency, - @NotBlank @Size(max = 128) String originContactName, - @NotBlank @Size(max = 128) String originContactPhone, + @Size(max = 128) String originContactName, + @Size(max = 128) String originContactPhone, @NotBlank @Size(max = 128) String destinationContactName, @NotBlank @Size(max = 128) String destinationContactPhone, @NotNull LocalDateTime pickupAt, @NotNull LocalDateTime deliveryAt, @Size(max = 1000) String remark, @NotBlank @Size(max = 255) String idempotencyKey) { + /** 只有上门取货需要客户提供完整起运资料。 */ + @JsonIgnore @AssertTrue(message = "上门取货须填写起运地、详细地址、联系人和联系电话") + public boolean isOriginValid() { return validOrigin(deliveryMode, origin, originAddress, originContactName, originContactPhone); } /** 取送时间均须填写,送货不能早于取货。 */ @JsonIgnore @AssertTrue(message = "送货时间不能早于取货时间") public boolean isScheduleValid() { @@ -46,8 +57,16 @@ public final class LocalLooseboardRequests { } public record Update( + @NotBlank @Size(max=128) String operatingWarehouseId, + @Pattern(regexp="FBA|PRIVATE") String destinationType, + @Size(max=32) String destinationPostalCode, + @NotBlank @Pattern(regexp = "DELIVERY_TO_WAREHOUSE|WAREHOUSE_PICKUP") String deliveryMode, + @Size(max = 128) String deliveryContactName, + @Size(max = 64) String deliveryContactPhone, + LocalDateTime estimatedArrivalAt, + @Size(max = 255) String trackingNo, @NotNull @Min(0) Long version, - @NotBlank @Size(max = 255) String origin, + @Size(max = 255) String origin, @Size(max = 500) String originAddress, @NotBlank @Size(max = 255) String destination, @Size(max = 500) String destinationAddress, @@ -58,13 +77,16 @@ public final class LocalLooseboardRequests { @NotNull @DecimalMin("0.00") BigDecimal totalVolumeCbm, @DecimalMin("0.00") BigDecimal budgetAmount, @Pattern(regexp = "USD|CNY") String currency, - @NotBlank @Size(max = 128) String originContactName, - @NotBlank @Size(max = 128) String originContactPhone, + @Size(max = 128) String originContactName, + @Size(max = 128) String originContactPhone, @NotBlank @Size(max = 128) String destinationContactName, @NotBlank @Size(max = 128) String destinationContactPhone, @NotNull LocalDateTime pickupAt, @NotNull LocalDateTime deliveryAt, @Size(max = 1000) String remark) { + /** 编辑切换为送货到仓时不保留起运地必填门禁。 */ + @JsonIgnore @AssertTrue(message = "上门取货须填写起运地、详细地址、联系人和联系电话") + public boolean isOriginValid() { return validOrigin(deliveryMode, origin, originAddress, originContactName, originContactPhone); } /** 取送时间均须填写,送货不能早于取货。 */ @JsonIgnore @AssertTrue(message = "送货时间不能早于取货时间") public boolean isScheduleValid() { @@ -77,6 +99,11 @@ public final class LocalLooseboardRequests { return pickup != null && delivery != null && !delivery.isBefore(pickup); } + /** 起运信息仅约束仓库上门取货,送货到仓不采集。 */ + private static boolean validOrigin(String mode, String origin, String address, String name, String phone) { + return !"WAREHOUSE_PICKUP".equals(mode) || java.util.stream.Stream.of(origin,address,name,phone).allMatch(org.springframework.util.StringUtils::hasText); + } + public record Query( @Min(1) long page, @Min(1) @Max(200) long size, @@ -101,4 +128,8 @@ public final class LocalLooseboardRequests { @NotBlank @Size(max = 32) String status, @NotBlank @Size(max = 255) String idempotencyKey, @Size(max = 1000) String remark) { } + + public record Review(@NotNull @Min(0) Long version, + @NotBlank @Pattern(regexp="APPROVE|REJECT") String decision, + @NotBlank @Size(max=255) String idempotencyKey, @Size(max=1000) String remark) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardResponses.java index 19bfe8057..013902c91 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardResponses.java @@ -10,7 +10,9 @@ public final class LocalLooseboardResponses { /** 需求与内部安排分别返回;未知联系人、商品及时间不补造。 */ public record Summary( - Long id, String customerId, String orderNo, String palletRequired, + Long id, String customerId, String orderNo, String palletRequired, String deliveryMode, + String operatingWarehouseId, String destinationType, String destinationPostalCode, String transferNo, + String deliveryContactName, String deliveryContactPhone, LocalDateTime estimatedArrivalAt, String trackingNo, String transportType, String origin, String destination, String productName, Integer estimatedPalletCount, int packageCount, BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, BigDecimal budgetAmount, String currency, String originContactName, String originContactPhone, String destinationContactName, String destinationContactPhone, LocalDateTime pickupAt, LocalDateTime deliveryAt, String status, long version, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/entity/LocalLooseboardOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/entity/LocalLooseboardOrder.java index cba5b1ee9..15ff90bf2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/entity/LocalLooseboardOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/entity/LocalLooseboardOrder.java @@ -16,6 +16,15 @@ public class LocalLooseboardOrder { @TableId(type = IdType.INPUT) private Long id; private String customerId; private Long lastlegOrderId; + private String deliveryMode; + private String operatingWarehouseId; + private String destinationType; + private String destinationPostalCode; + private String transferNo; + private String deliveryContactName; + private String deliveryContactPhone; + private LocalDateTime estimatedArrivalAt; + private String trackingNo; private String orderNo; private String palletRequired; private String transportType; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/mapper/LocalLooseboardOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/mapper/LocalLooseboardOrderMapper.java index dee9ef276..20b76305e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/mapper/LocalLooseboardOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/mapper/LocalLooseboardOrderMapper.java @@ -8,6 +8,10 @@ import org.apache.ibatis.annotations.Select; @Mapper public interface LocalLooseboardOrderMapper extends BaseMapper { + @Select("SELECT COUNT(*) FROM ll_local_looseboard_order WHERE customer_id=#{customerId} AND transfer_no=#{transferNo} AND lastleg_order_id IS NOT NULL") + int linkedReceiving(@Param("customerId") String customerId,@Param("transferNo") String transferNo); + @org.apache.ibatis.annotations.Update("UPDATE ll_local_looseboard_order SET status='PENDING_DISPATCH',updated_at=CURRENT_TIMESTAMP(3),version=version+1 WHERE customer_id=#{customerId} AND transfer_no=#{transferNo} AND status='WAITING_INBOUND'") + int receivingReady(@Param("customerId") String customerId,@Param("transferNo") String transferNo); @Select("SELECT * FROM ll_local_looseboard_order WHERE customer_id=#{customerId} AND order_no=#{orderNo} LIMIT 1") LocalLooseboardOrder findNo(@Param("customerId") String customerId, @Param("orderNo") String orderNo); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/port/LooseboardReceiving.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/port/LooseboardReceiving.java new file mode 100644 index 000000000..05b126535 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/port/LooseboardReceiving.java @@ -0,0 +1,10 @@ +package com.baiyue.lastleg.looseboard.port; + +import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder; + +/** 散板复用正式转运收货和取货履约,不按预计托数创建实物库存。 */ +public interface LooseboardReceiving { + record Created(String transferNo, Long lastlegOrderId) { } + /** 原单锁内建立并审核唯一收货来源。 */ + Created approve(LocalLooseboardOrder order, String operator); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/repository/LocalLooseboardRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/repository/LocalLooseboardRepository.java index e62d7e8e6..cd9a46a30 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/repository/LocalLooseboardRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/repository/LocalLooseboardRepository.java @@ -21,6 +21,10 @@ public class LocalLooseboardRepository { @Resource private LocalLooseboardOperationMapper operations; @Resource private LastlegOrderMapper lastlegOrders; @Resource private LastlegOrderStatusHistoryMapper lastlegHistories; + /** 查询正式关联,不通过备注推断是否需要散板收货确认。 */ + public boolean linkedReceiving(String customer,String number) { return orders.linkedReceiving(customer,number)>0; } + /** 完成收货和测量确认后推进原单,条件更新防止重复增加版本。 */ + public void receivingReady(String customer,String number) { orders.receivingReady(customer,number); } /** 按客户和创建幂等键读取订单。 */ public LocalLooseboardOrder findKey(String customer, String key) { return orders.findKey(customer, key); } /** 按客户和业务单号读取订单。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java index f1c5d0359..d3fd0cad9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java @@ -34,7 +34,20 @@ import java.util.Objects; import java.util.Set; @Service -public class LocalLooseboardService { +public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.LooseboardTransportPort { + /** 正式取货/送达照片驱动商业状态,分车派送未全部送达不提前完结。 */ + @Override @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void progress(String customerId,String orderNo,String operatorId,long deliveredPackages) { + var order=lock(customerId,orderNo); + if(CANCELLED.equals(order.getStatus()))throw conflict("已取消散板单不能继续运输"); + if(deliveredPackages<0 || deliveredPackages>order.getPackageCount())throw conflict("散板累计送达货量与原单不一致"); + String next=deliveredPackages==order.getPackageCount()?COMPLETED:IN_TRANSIT; + if(COMPLETED.equals(order.getStatus()) || next.equals(order.getStatus()))return; + String from=order.getStatus();var now=LocalDateTime.now();order.setStatus(next);touch(order,now); + repository.updateOrder(order);syncLastlegOrder(order,next,now); + history(order,"TRANSPORT_PHOTO",from,next,operatorId,null,null,"按排车取货/送达照片更新,累计送达"+deliveredPackages+"箱",now); + } + @Resource private com.baiyue.lastleg.looseboard.port.LooseboardReceiving receiving; @Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries fleetPool; public static final String PENDING_ACCEPTANCE = "PENDING_ACCEPTANCE"; public static final String DISPATCHED = "DISPATCHED"; @@ -64,7 +77,7 @@ public class LocalLooseboardService { order.setId(IdWorker.getId()); order.setCustomerId(customerId); order.setOrderNo(lastlegOrderNoGenerator.next("LOOSEBOARD")); - apply(order, request.origin(), + apply(order, request.operatingWarehouseId(), request.destinationType(), request.destinationPostalCode(), request.deliveryMode(), request.deliveryContactName(), request.deliveryContactPhone(), request.estimatedArrivalAt(), request.trackingNo(), request.origin(), request.originAddress(), request.destination(), request.destinationAddress(), request.productName(), request.estimatedPalletCount(), request.packageCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(), request.currency(), request.originContactName(), request.originContactPhone(), request.destinationContactName(), request.destinationContactPhone(), request.pickupAt(), request.deliveryAt(), request.remark()); @@ -126,7 +139,7 @@ public class LocalLooseboardService { throw conflict("Only pending-acceptance looseboard orders can be edited"); } Map before = snapshot(order); - apply(order, request.origin(), + apply(order, request.operatingWarehouseId(), request.destinationType(), request.destinationPostalCode(), request.deliveryMode(), request.deliveryContactName(), request.deliveryContactPhone(), request.estimatedArrivalAt(), request.trackingNo(), request.origin(), request.originAddress(), request.destination(), request.destinationAddress(), request.productName(), request.estimatedPalletCount(), request.packageCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(), request.currency(), request.originContactName(), request.originContactPhone(), request.destinationContactName(), request.destinationContactPhone(), request.pickupAt(), request.deliveryAt(), request.remark()); @@ -178,23 +191,63 @@ public class LocalLooseboardService { return response(order); } + /** 审核通过后建立唯一转运来源;取货方式由转运服务决定是否进入上门取货排车。 */ + @Transactional + public LocalLooseboardResponses.Detail review(String customerId, String orderNo, String operatorId, + LocalLooseboardRequests.Review request) { + LocalLooseboardOrder order = lock(customerId, orderNo); + String requestHash = hash(request); + var previous = repository.findOperation(customerId, order.getId(), request.idempotencyKey()); + if (previous != null) { + if (!"REVIEW".equals(previous.getOperationType()) || !requestHash.equals(previous.getRequestHash())) throw conflict("审核幂等键已用于不同请求"); + return response(order); + } + if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry"); + String decision = request.decision().trim().toUpperCase(); + if (!PENDING_ACCEPTANCE.equals(order.getStatus())) throw conflict("散板订单已审核,请刷新后重试"); + if ("REJECT".equals(decision)) { + if (!StringUtils.hasText(request.remark())) throw badRequest("驳回须填写原因"); + return changeStatus(customerId, orderNo, operatorId, new LocalLooseboardRequests.ChangeStatus(order.getVersion(), "CANCELLED", request.idempotencyKey(), request.remark())); + } + if (!"APPROVE".equals(decision)) throw badRequest("审核结果无效"); + if (order.getOperatingWarehouseId()==null || order.getDeliveryMode()==null) throw conflict("旧散板订单缺少收货安排,请先编辑补齐"); + var created = receiving.approve(order, operatorId); + order.setTransferNo(created.transferNo()); order.setLastlegOrderId(created.lastlegOrderId()); order.setStatus("WAITING_INBOUND"); + touch(order, LocalDateTime.now()); repository.updateOrder(order); + history(order, "REVIEW", PENDING_ACCEPTANCE, order.getStatus(), operatorId, request.idempotencyKey(), null, request.remark(), LocalDateTime.now()); + var operation=new LocalLooseboardOperation(); operation.setId(IdWorker.getId()); operation.setOrderId(order.getId()); operation.setCustomerId(customerId); + operation.setOperationType("REVIEW"); operation.setIdempotencyKey(request.idempotencyKey()); operation.setRequestHash(requestHash); operation.setResultVersion(order.getVersion()); operation.setCreatedBy(operatorId); operation.setCreatedAt(LocalDateTime.now()); repository.insertOperation(operation); + return response(order); + } + /** 保存申报货物与当地取送时间,不预设内部安排或实际收货。 */ private void apply(LocalLooseboardOrder order, + String operatingWarehouseId, String destinationType, String destinationPostalCode, String deliveryMode, String deliveryContactName, String deliveryContactPhone, LocalDateTime estimatedArrivalAt, String trackingNo, String origin, String originAddress, String destination, String destinationAddress, String productName, Integer estimatedPalletCount, Integer packageCount, java.math.BigDecimal totalWeightKg, java.math.BigDecimal totalVolumeCbm, java.math.BigDecimal budgetAmount, String currency, String originContactName, String originContactPhone, String destinationContactName, String destinationContactPhone, LocalDateTime pickupAt, LocalDateTime deliveryAt, String remark) { - if (!StringUtils.hasText(originContactName) || !StringUtils.hasText(originContactPhone) - || !StringUtils.hasText(destinationContactName) || !StringUtils.hasText(destinationContactPhone)) { - throw badRequest("请填写起运地和目的地联系人及联系电话"); - } + destinationType = StringUtils.hasText(destinationType) ? norm(destinationType) : "PRIVATE"; + if (!StringUtils.hasText(operatingWarehouseId) || !Set.of("FBA","PRIVATE").contains(Objects.toString(destinationType,""))) throw badRequest("请选择收货仓和有效目的地"); + if ("PRIVATE".equals(destinationType) && (!StringUtils.hasText(destinationAddress) || !StringUtils.hasText(destinationPostalCode))) throw badRequest("私人地址须填写详细地址和邮编"); + order.setOperatingWarehouseId(operatingWarehouseId.trim()); order.setDestinationType(destinationType); order.setDestinationPostalCode(trim(destinationPostalCode)); + if (deliveryMode == null || !Set.of("DELIVERY_TO_WAREHOUSE", "WAREHOUSE_PICKUP").contains(norm(deliveryMode))) throw badRequest("请选择送货方式"); + if ("DELIVERY_TO_WAREHOUSE".equals(norm(deliveryMode)) && (!StringUtils.hasText(deliveryContactName) || !StringUtils.hasText(deliveryContactPhone) || estimatedArrivalAt == null)) throw badRequest("送货到仓须填写仓库联系人和预计到货时间"); + order.setDeliveryMode(norm(deliveryMode)); order.setDeliveryContactName(trim(deliveryContactName)); order.setDeliveryContactPhone(trim(deliveryContactPhone)); + order.setEstimatedArrivalAt(estimatedArrivalAt); order.setTrackingNo(trim(trackingNo)); + boolean pickup = "WAREHOUSE_PICKUP".equals(norm(deliveryMode)); + if (pickup && (!StringUtils.hasText(origin) || !StringUtils.hasText(originAddress) + || !StringUtils.hasText(originContactName) || !StringUtils.hasText(originContactPhone))) + throw badRequest("上门取货须填写起运地、详细地址、联系人和联系电话"); + if (!StringUtils.hasText(destinationContactName) || !StringUtils.hasText(destinationContactPhone)) + throw badRequest("请填写目的地联系人及联系电话"); if (!LocalLooseboardRequests.validSchedule(pickupAt, deliveryAt)) { throw badRequest("取货和送货时间必填,送货时间不能早于取货时间"); } order.setProductName(trim(productName)); order.setEstimatedPalletCount(estimatedPalletCount); - order.setOrigin(origin.trim()); - order.setOriginAddress(trim(originAddress)); + order.setOrigin(pickup ? origin.trim() : ""); + order.setOriginAddress(pickup ? trim(originAddress) : null); order.setDestination(destination.trim()); order.setDestinationAddress(trim(destinationAddress)); order.setPackageCount(packageCount); @@ -202,8 +255,8 @@ public class LocalLooseboardService { order.setTotalVolumeCbm(totalVolumeCbm); order.setBudgetAmount(budgetAmount); order.setCurrency(StringUtils.hasText(currency) ? norm(currency) : "USD"); - order.setOriginContactName(originContactName.trim()); - order.setOriginContactPhone(originContactPhone.trim()); + order.setOriginContactName(pickup ? originContactName.trim() : ""); + order.setOriginContactPhone(pickup ? originContactPhone.trim() : ""); order.setDestinationContactName(destinationContactName.trim()); order.setDestinationContactPhone(destinationContactPhone.trim()); order.setPickupAt(pickupAt); @@ -247,7 +300,7 @@ public class LocalLooseboardService { /** 装配原值需求摘要,预计托数与实际履约保持独立。 */ private LocalLooseboardResponses.Summary summary(LocalLooseboardOrder order, com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Arrangement arrangement) { return new LocalLooseboardResponses.Summary(order.getId(), order.getCustomerId(), order.getOrderNo(), - order.getPalletRequired(), order.getTransportType(), order.getOrigin(), + order.getPalletRequired(), order.getDeliveryMode(), order.getOperatingWarehouseId(), order.getDestinationType(), order.getDestinationPostalCode(), order.getTransferNo(), order.getDeliveryContactName(), order.getDeliveryContactPhone(), order.getEstimatedArrivalAt(), order.getTrackingNo(), order.getTransportType(), order.getOrigin(), order.getDestination(), order.getProductName(), order.getEstimatedPalletCount(), Objects.requireNonNullElse(order.getPackageCount(), 0), order.getTotalWeightKg(), order.getTotalVolumeCbm(), order.getBudgetAmount(), order.getCurrency(), order.getOriginContactName(), order.getOriginContactPhone(), order.getDestinationContactName(), order.getDestinationContactPhone(), order.getPickupAt(), order.getDeliveryAt(), order.getStatus(), @@ -343,6 +396,10 @@ public class LocalLooseboardService { Map value = new LinkedHashMap<>(); value.put("palletRequired", order.getPalletRequired()); value.put("transportType", order.getTransportType()); + value.put("deliveryMode",order.getDeliveryMode()); value.put("operatingWarehouseId",order.getOperatingWarehouseId()); + value.put("destinationType",order.getDestinationType()); value.put("destinationPostalCode",order.getDestinationPostalCode()); + value.put("deliveryContactName",order.getDeliveryContactName()); value.put("deliveryContactPhone",order.getDeliveryContactPhone()); + value.put("estimatedArrivalAt",order.getEstimatedArrivalAt()); value.put("trackingNo",order.getTrackingNo()); value.put("origin", order.getOrigin()); value.put("originAddress", order.getOriginAddress()); value.put("destination", order.getDestination()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LooseboardCommercialSourceAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LooseboardCommercialSourceAdapter.java new file mode 100644 index 000000000..689b7470a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LooseboardCommercialSourceAdapter.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.looseboard.service; + +import com.baiyue.lastleg.transfer.port.TransferCommercialSource; +import com.baiyue.lastleg.looseboard.repository.LocalLooseboardRepository; +import org.springframework.stereotype.Component; + +/** 通过不可变转运关联识别散板来源,禁止通过备注猜测来源。 */ +@Component +public class LooseboardCommercialSourceAdapter implements TransferCommercialSource { + private final LocalLooseboardRepository repository; + public LooseboardCommercialSourceAdapter(LocalLooseboardRepository repository) { this.repository=repository; } + public boolean isLooseboard(String customerId,String transferNo) { return repository.linkedReceiving(customerId,transferNo); } + public void ready(String customerId,String transferNo) { repository.receivingReady(customerId,transferNo); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/DeliveryReturnCargoMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/DeliveryReturnCargoMapper.java new file mode 100644 index 000000000..4c120fef8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/DeliveryReturnCargoMapper.java @@ -0,0 +1,43 @@ +package com.baiyue.lastleg.mapper; + +import com.baiyue.lastleg.entity.LastlegOrderDetail; +import org.apache.ibatis.annotations.*; +import java.util.List; + +/** 只沿客户隔离的异常冻结仓位读取原货物资料,不按整票总数猜测混合SKU。 */ +@Mapper +public interface DeliveryReturnCargoMapper { + /** 来源明细唯一时带出品名及可精确分摊的件数、重量和体积;不明数据保留为空。 */ + @Select(""" + SELECT r.id,tail.customer_id,tail.id order_id,CAST(r.id AS CHAR) source_inbound_detail_id, + COALESCE(r.sku_code,b.sku_code,d.sku_code) sku_code, + COALESCE(b.product_name_zh,d.product_name_zh) product_name_zh, + COALESCE(b.product_name_en,d.product_name_en) product_name_en, + COALESCE(saved.package_count,r.approved_return_packages-r.received_packages) package_count, + CASE WHEN d.package_count>0 AND MOD(d.product_quantity*COALESCE(saved.package_count,r.approved_return_packages-r.received_packages),d.package_count)=0 + THEN d.product_quantity*COALESCE(saved.package_count,r.approved_return_packages-r.received_packages)/d.package_count END product_quantity, + CASE WHEN d.package_count>0 THEN d.gross_weight_kg*COALESCE(saved.package_count,r.approved_return_packages-r.received_packages)/d.package_count END gross_weight_kg, + CASE WHEN d.package_count>0 THEN d.net_weight_kg*COALESCE(saved.package_count,r.approved_return_packages-r.received_packages)/d.package_count END net_weight_kg, + CASE WHEN d.package_count>0 THEN d.volume_cbm*COALESCE(saved.package_count,r.approved_return_packages-r.received_packages)/d.package_count END volume_cbm, + d.shipping_mark,d.shipment_no,d.fba_tracking_no + FROM ll_order tail + JOIN ll_exception_order e ON e.customer_id=tail.customer_id AND CAST(e.id AS CHAR)=tail.source_inbound_id + JOIN ll_exception_return_item r ON r.exception_order_id=e.id AND r.customer_id=e.customer_id + LEFT JOIN ll_order_detail saved ON saved.id=( + SELECT CASE WHEN COUNT(*)=1 THEN MIN(snapshot.id) END FROM ll_order_detail snapshot + WHERE snapshot.customer_id=tail.customer_id AND snapshot.order_id=tail.id + AND snapshot.source_inbound_detail_id=CAST(r.id AS CHAR)) + LEFT JOIN ll_inventory_position pos ON pos.id=r.inventory_position_id AND pos.customer_id=r.customer_id + LEFT JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id + LEFT JOIN ll_order src ON src.id=(SELECT CASE WHEN COUNT(*)=1 THEN MIN(o.id) END FROM ll_order o + WHERE o.customer_id=e.customer_id AND o.inbound_code=b.inbound_code) + LEFT JOIN ll_order_detail d ON d.id=( + SELECT CASE WHEN COUNT(*)=1 THEN MIN(x.id) END FROM ll_order_detail x + WHERE x.customer_id=e.customer_id AND x.order_id=COALESCE(src.id,e.last_mile_order_id) + AND (COALESCE(r.sku_code,b.sku_code) IS NULL OR x.sku_code=COALESCE(r.sku_code,b.sku_code))) + WHERE tail.customer_id=#{customer} AND tail.id=#{order} AND tail.order_type='DELIVERY_RETURN' + AND tail.source_region='EXCEPTION_RETURN' AND COALESCE(saved.package_count,r.approved_return_packages-r.received_packages)>0 + ORDER BY r.id + """) + List cargo(@Param("customer") String customer,@Param("order") long order); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderType.java b/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderType.java index fce09ab63..1b794bc34 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderType.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderType.java @@ -12,6 +12,8 @@ public enum LastlegOrderType { LOCAL_LOOSEBOARD("本土散板"), /** Return shipment that also requires relabeling. */ RETURN_RELABEL("退货换标"), + /** 派送异常后重新入仓的退回尾程单。 */ + DELIVERY_RETURN("派送异常退回"), /** One-click dropship order, retained for existing data and workflows. */ ONE_CLICK_DROPSHIP("一件代发"), /** Cargo discard order created from an approved cargo discard application. */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/repository/DeliveryReturnCargoRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/DeliveryReturnCargoRepository.java new file mode 100644 index 000000000..f92c14b28 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/DeliveryReturnCargoRepository.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.repository; + +import com.baiyue.lastleg.entity.LastlegOrderDetail; +import com.baiyue.lastleg.mapper.DeliveryReturnCargoMapper; +import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 异常退回展示与生成共用唯一真实来源查询。 */ +@Repository +public class DeliveryReturnCargoRepository { + private final DeliveryReturnCargoMapper source; + private final LastlegOrderDetailMapper details; + /** 装配来源查询与本单明细持久化映射器。 */ + public DeliveryReturnCargoRepository(DeliveryReturnCargoMapper source,LastlegOrderDetailMapper details){this.source=source;this.details=details;} + /** 按真实异常及客户关系查询来源信息。 */ + public List cargo(String customer,long order){return source.cargo(customer,order);} + /** 新生成单冻结可追溯的货物信息,保留来源ID而不复用来源明细主键。 */ + public void copy(String customer,long order){for(var detail:cargo(customer,order)){detail.setId(null);details.insert(detail);}} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java index 9c807d0ac..fca81e758 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java @@ -64,6 +64,7 @@ import java.util.stream.Collectors; @Service public class LastlegOrderService { + @jakarta.annotation.Resource private com.baiyue.lastleg.repository.DeliveryReturnCargoRepository deliveryReturnCargo; public static final String PENDING_ASSIGNMENT = "PENDING_ASSIGNMENT"; public static final String DESTINATION_ASSIGNED = "DESTINATION_ASSIGNED"; public static final String INBOUND_PLANNED = "INBOUND_PLANNED"; @@ -402,6 +403,7 @@ public class LastlegOrderService { } /** 返回订单详情及统一费用审核、余额锁扣状态。 */ + /** 读取客户尾程详情,异常退回货物沿冻结来源带出真实资料。 */ public LastlegOrderDetailResponse detail(String customerId, long orderId) { LastlegOrder order = findOwned(customerId, orderId); returnOrderReferences.populateForRead(List.of(order)); @@ -409,6 +411,17 @@ public class LastlegOrderService { List cargo = detailMapper.selectList(new LambdaQueryWrapper() .eq(LastlegOrderDetail::getCustomerId, order.getCustomerId()) .eq(LastlegOrderDetail::getOrderId, orderId).orderByAsc(LastlegOrderDetail::getId)); + // 异常退回可从冻结原仓位取得真实货物资料,混合来源不猜测SKU或件数。 + if ("DELIVERY_RETURN".equals(order.getOrderType())) { + cargo = deliveryReturnCargo.cargo(order.getCustomerId(),orderId); + if (!cargo.isEmpty() && cargo.stream().allMatch(item -> item.getVolumeCbm()!=null)) + order.setVolumeCbm(cargo.stream().map(LastlegOrderDetail::getVolumeCbm).reduce(BigDecimal.ZERO,BigDecimal::add)); + } + var detailWeight = "DELIVERY_RETURN".equals(order.getOrderType()) + ? new LastlegOrderMapper.OrderWeightRow(orderId, + cargo.isEmpty() || cargo.stream().anyMatch(item -> item.getGrossWeightKg()==null) ? null : cargo.stream().map(LastlegOrderDetail::getGrossWeightKg).reduce(BigDecimal.ZERO,BigDecimal::add), + cargo.isEmpty() || cargo.stream().anyMatch(item -> item.getNetWeightKg()==null) ? null : cargo.stream().map(LastlegOrderDetail::getNetWeightKg).reduce(BigDecimal.ZERO,BigDecimal::add)) + : orderWeights(List.of(order)).get(order.getId()); List history = historyMapper.selectList( new LambdaQueryWrapper() .eq(LastlegOrderStatusHistory::getCustomerId, order.getCustomerId()) @@ -435,7 +448,7 @@ public class LastlegOrderService { .sorted(java.util.Comparator.comparingInt(value -> MANUAL_NODE_CODES.indexOf(value.nodeCode()))).toList(); return new LastlegOrderDetailResponse(summary(order, palletMergeRelations(List.of(order)).get(order.getId()), containerNode(order), dispatchStatuses(List.of(order)).getOrDefault(order.getId(), "UNSCHEDULED"), - orderWeights(List.of(order)).get(order.getId()), relabelSubOrders(List.of(order)).getOrDefault(order.getId(), List.of()), + detailWeight, relabelSubOrders(List.of(order)).getOrDefault(order.getId(), List.of()), businessSources(List.of(order)).get(order.getId()), transferQuantities.forOrders(List.of(order)).get(order.getId())), manualNodes, cargo, history.stream() .map(value -> new LastlegOrderDetailResponse.History(value.getId(), value.getFromStatus(), value.getToStatus(), value.getRemark(), value.getCreatedAt())).toList(), fees, operations.stream() diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/looseboard/LooseboardPlannedCargoRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/looseboard/LooseboardPlannedCargoRepository.java new file mode 100644 index 000000000..50d227a5c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/looseboard/LooseboardPlannedCargoRepository.java @@ -0,0 +1,43 @@ +package com.baiyue.lastleg.transfer.looseboard; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.lastleg.inboundplan.entity.*; +import com.baiyue.lastleg.inboundplan.mapper.*; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 散板申报计划持久化;不写实物资源和库存。 */ +@Repository +public class LooseboardPlannedCargoRepository { + private final InboundPlanMapper plans; + private final InboundPlanLineMapper lines; + private final InboundPlanPalletMapper pallets; + private final InboundPlanPalletAllocationMapper allocations; + private final WarehouseTransferOrderMapper transfers; + + /** 只组合现有计划及转运的同库持久化入口。 */ + public LooseboardPlannedCargoRepository(InboundPlanMapper plans, InboundPlanLineMapper lines, + InboundPlanPalletMapper pallets, InboundPlanPalletAllocationMapper allocations, WarehouseTransferOrderMapper transfers) { + this.plans=plans; this.lines=lines; this.pallets=pallets; this.allocations=allocations; this.transfers=transfers; + } + /** 按客户和转运号读取审核生成的履约。 */ + public WarehouseTransferOrder transfer(String customer,String number) { return transfers.findNo(customer,number); } + /** 收货前只读仓库归属,供排车父锁先行。 */ + public WarehouseTransferOrder transferAt(String number,String warehouse) { return transfers.findNoByWarehouse(number,warehouse); } + /** 沿真实尾程来源查询唯一计划。 */ + public InboundPlan plan(WarehouseTransferOrder transfer) { return plans.findBySourceOrder(transfer.getCustomerId(),transfer.getLastlegOrderId()); } + /** 读取当前计划的原始逐票行。 */ + public InboundPlanLine line(InboundPlan plan) { return lines.selectOne(new LambdaQueryWrapper().eq(InboundPlanLine::getCustomerId,plan.getCustomerId()).eq(InboundPlanLine::getPlanId,plan.getId())); } + /** 稳定顺序保留预排身份,用于实收交接。 */ + public List pallets(InboundPlan plan) { return pallets.selectList(new LambdaQueryWrapper().eq(InboundPlanPallet::getCustomerId,plan.getCustomerId()).eq(InboundPlanPallet::getPlanId,plan.getId()).orderByAsc(InboundPlanPallet::getSequenceNo,InboundPlanPallet::getId)); } + /** 读取单托唯一原分配,不按新实物重建来源身份。 */ + public InboundPlanPalletAllocation allocation(InboundPlanPallet pallet) { return allocations.selectOne(new LambdaQueryWrapper().eq(InboundPlanPalletAllocation::getCustomerId,pallet.getCustomerId()).eq(InboundPlanPalletAllocation::getPalletId,pallet.getId())); } + /** 新增无实物的逻辑托盘及分配。 */ + public void insert(InboundPlanPallet pallet,InboundPlanPalletAllocation allocation) { pallets.insert(pallet); allocations.insert(allocation); } + /** 保存已核对的实际托盘,乐观锁失败由事务回滚。 */ + public boolean update(InboundPlanPallet pallet,InboundPlanPalletAllocation allocation) { return pallets.updateById(pallet)==1 && allocations.updateById(allocation)==1; } + /** 保存申报或实收计划汇总。 */ + public void save(InboundPlan plan,InboundPlanLine line) { lines.updateById(line); if(plans.updateById(plan)!=1)throw new IllegalStateException("散板计划版本已变化"); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/looseboard/LooseboardPlannedCargoService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/looseboard/LooseboardPlannedCargoService.java new file mode 100644 index 000000000..7d12a1a04 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/looseboard/LooseboardPlannedCargoService.java @@ -0,0 +1,108 @@ +package com.baiyue.lastleg.transfer.looseboard; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder; +import com.baiyue.lastleg.inboundplan.entity.*; +import com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import java.math.*; +import java.time.LocalDateTime; +import java.util.*; + +/** 审核后按申报预排,实收时保留原计划身份并交接数量。 */ +@Service +@Transactional(propagation=Propagation.MANDATORY) +public class LooseboardPlannedCargoService { + private final LooseboardPlannedCargoRepository repository; + private final InboundDispatchReceiptGateway dispatch; + /** 复用原排车实收交接端口,不产生入库或出库事实。 */ + public LooseboardPlannedCargoService(LooseboardPlannedCargoRepository repository,InboundDispatchReceiptGateway dispatch) { + this.repository=repository; this.dispatch=dispatch; + } + /** 审核事务内生成纯逻辑计划,托数为申报估计且不申请仓储资源。 */ + public void prepare(LocalLooseboardOrder order,String transferNo) { + var transfer=repository.transfer(order.getCustomerId(),transferNo); + var plan=transfer==null?null:repository.plan(transfer); + if(plan==null)throw conflict("散板收货计划尚未建立"); + if(!repository.pallets(plan).isEmpty())return; + var line=repository.line(plan); + int count=Math.max(1,Math.min(order.getEstimatedPalletCount(),order.getPackageCount())); + BigDecimal remaining=order.getTotalVolumeCbm(); + for(int i=0;i actual,String operator,LocalDateTime now) { + var plan=repository.plan(transfer); + if(plan==null || !planned(plan))return false; + var line=repository.line(plan); var original=repository.pallets(plan); + var sorted=actual.stream().sorted(Comparator.comparing(WarehouseTransferPallet::getSequenceNo).thenComparing(WarehouseTransferPallet::getId)).toList(); + for(int i=0;ip.getSourcePalletNo()!=null && p.getSourcePalletNo().startsWith("LB-PLAN:")); } + /** 无实物ID和实收数量的逻辑托盘。 */ + private InboundPlanPallet logical(InboundPlan plan,InboundPlanLine line,int sequence,BigDecimal volume) { + var p=new InboundPlanPallet(); p.setId(IdWorker.getId());p.setCustomerId(plan.getCustomerId());p.setPlanId(plan.getId()); + p.setPalletNo("预排-"+plan.getPlanNo()+"-"+sequence);p.setSourcePalletNo("LB-PLAN:"+plan.getId()+":"+sequence); + p.setPalletType("STANDARD");p.setSequenceNo(sequence);p.setVersion(0);p.setAssignmentStatus("UNASSIGNED");p.setAllocatedVolumeCbm(volume); + p.setDestinationType(line.getDestinationType());p.setDestinationWarehouseId(line.getDestinationWarehouseId()); + p.setDestinationWarehouseCode(line.getDestinationWarehouseCode());p.setDestinationWarehouseName(line.getDestinationWarehouseName()); + p.setDeliveryAddress(line.getDeliveryAddress());p.setPostalCode(line.getPostalCode());p.setContactName(line.getContactName());p.setContactPhone(line.getContactPhone()); + return p; + } + /** 申报分配只记录计划数量,不填写实收和出库拣货。 */ + private InboundPlanPalletAllocation allocation(InboundPlan plan,InboundPlanLine line,InboundPlanPallet pallet,int packages,BigDecimal volume) { + var a=new InboundPlanPalletAllocation(); a.setId(IdWorker.getId());a.setCustomerId(plan.getCustomerId());a.setPlanId(plan.getId());a.setLineId(line.getId());a.setPalletId(pallet.getId()); + a.setAllocatedVolumeCbm(volume);a.setPlannedPackageCount(packages);a.setStatus("PLANNED");a.setVersion(0);return a; + } + /** 计划变化须回滚整个审核或收货事务。 */ + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT,message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/looseboard/LooseboardReceivingAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/looseboard/LooseboardReceivingAdapter.java new file mode 100644 index 000000000..c884979ce --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/looseboard/LooseboardReceivingAdapter.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.transfer.looseboard; + +import com.baiyue.lastleg.looseboard.port.LooseboardReceiving; +import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder; +import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests; +import com.baiyue.lastleg.transfer.service.WarehouseTransferService; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.annotation.Propagation; +import java.util.List; + +/** 保留散板内部货源关联;实际取货与送达沿承运排车照片履约。 */ +@Component +public class LooseboardReceivingAdapter implements LooseboardReceiving { + private final WarehouseTransferService transfers; + private final LooseboardPlannedCargoService planned; + /** 只通过转运应用服务创建履约。 */ + public LooseboardReceivingAdapter(WarehouseTransferService transfers, LooseboardPlannedCargoService planned) { this.transfers=transfers; this.planned=planned; } + @Override @Transactional(propagation=Propagation.MANDATORY) + public Created approve(LocalLooseboardOrder o, String operator) { + boolean pickup="WAREHOUSE_PICKUP".equals(o.getDeliveryMode()); + boolean fba="FBA".equals(o.getDestinationType()); + String address=pickup ? o.getOrigin()+" "+o.getOriginAddress() : "送货到仓"; + var request=new WarehouseTransferRequests.Create(null,List.of(),o.getOperatingWarehouseId(),fba?o.getDestination():null, + o.getDestinationType(),null,fba?o.getDestination():null,fba?o.getDestination():null, + fba?null:o.getDestinationAddress(),fba?null:o.getDestinationPostalCode(),fba?null:o.getDestinationContactName(),fba?null:o.getDestinationContactPhone(), + address,pickup?o.getOriginContactName():o.getDeliveryContactName(),pickup?o.getOriginContactPhone():o.getDeliveryContactPhone(), + o.getPackageCount(),o.getTotalVolumeCbm(),pickup?"WAREHOUSE_PICKUP":"CUSTOMER_DELIVERY", + pickup?o.getPickupAt():o.getEstimatedArrivalAt(),o.getDeliveryAt(),null,pickup?null:o.getTrackingNo(),null, + "本土散板 "+o.getOrderNo(),false,null,null,null,null,null,"LOOSEBOARD:"+o.getId()+":RECEIVING", + List.of(new WarehouseTransferRequests.Item(null,o.getProductName(),o.getPackageCount(),null)),null); + var created=transfers.createAndSubmit(o.getCustomerId(),operator,request); + var approved=transfers.reviewLooseboardSource(o.getCustomerId(),created.transferNo(),operator,new WarehouseTransferRequests.Review("APPROVE","散板订单审核通过")); + planned.prepare(o, approved.transferNo()); + return new Created(approved.transferNo(),approved.lastlegOrderId()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/port/TransferCommercialSource.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/port/TransferCommercialSource.java new file mode 100644 index 000000000..0542d1636 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/port/TransferCommercialSource.java @@ -0,0 +1,8 @@ +package com.baiyue.lastleg.transfer.port; + +/** 转运收货只查询商业来源关联,不读取商业服务的内部状态实现。 */ +public interface TransferCommercialSource { + /** 本土散板在PDA完成真实收货打托后直接进入可排车状态。 */ + boolean isLooseboard(String customerId, String transferNo); + void ready(String customerId, String transferNo); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java index c191ac70c..a0d625e58 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java @@ -92,8 +92,10 @@ import java.util.stream.Collectors; @Service public class WarehouseTransferService { + @jakarta.annotation.Resource private com.baiyue.lastleg.transfer.port.TransferCommercialSource commercialSource; @jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent; + @Resource private com.baiyue.lastleg.transfer.looseboard.LooseboardPlannedCargoService looseboardPlannedCargo; @Resource private TransferQuantityService transferQuantityService; @Resource private com.baiyue.lastleg.fleet.pickup.FleetPickupService pickupService; @Resource private com.baiyue.lastleg.dispatch.transport.TransportTaskService transportTaskService; @@ -415,6 +417,7 @@ public class WarehouseTransferService { transfers.updateById(transfer); order.setOverseasWarehouseId(request.operatingWarehouseId().trim()); + order.setDestinationType(transfer.getDestinationType()); order.setFbaWarehouseCode(destinationCode(request)); order.setPackageCount(request.packageCount()); order.setVolumeCbm(request.volumeCbm()); @@ -675,6 +678,7 @@ public class WarehouseTransferService { transfers.updateById(transfer); LastlegOrder order = requireTailOrder(customerId, transfer); + order.setDestinationType(transfer.getDestinationType()); order.setFbaWarehouseCode("FBA".equals(transfer.getDestinationType()) ? transfer.getDestinationWarehouseCode() : null); order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + READY_FOR_DISPATCH + ";RELABEL:" + Objects.toString(order.getRelabelStatus(), "NOT_REQUIRED")); @@ -825,6 +829,17 @@ public class WarehouseTransferService { @Transactional public WarehouseTransferResponses.Detail review(String customerId, String transferNo, String operatorId, WarehouseTransferRequests.Review request) { + return reviewInternal(customerId,transferNo,operatorId,request,commercialSource==null || !commercialSource.isLooseboard(customerId,transferNo)); + } + + /** 散板仅审核内部货源;取货在实际承运排车记录照片,不再另建到仓取货车。 */ + @Transactional + public WarehouseTransferResponses.Detail reviewLooseboardSource(String customerId,String transferNo,String operatorId,WarehouseTransferRequests.Review request) { + return reviewInternal(customerId,transferNo,operatorId,request,false); + } + + /** 公共审核规则只在内部区分是否需要独立仓库上门取货任务。 */ + private WarehouseTransferResponses.Detail reviewInternal(String customerId,String transferNo,String operatorId,WarehouseTransferRequests.Review request,boolean arrangeWarehousePickup) { String decision = request.decision().trim().toUpperCase(); String next = switch (decision) { case "APPROVE", APPROVED -> APPROVED; @@ -844,7 +859,7 @@ public class WarehouseTransferService { .contains(transfer.getStatus())) { if (APPROVED.equals(next) && transfer.getTransferNo().equals(transfer.getInboundCode())) { activateTransferInbound(customerId, transfer, LocalDateTime.now()); - transportTaskService.ensureApprovedPickup(customerId, transferNo, operatorId); + if(arrangeWarehousePickup)transportTaskService.ensureApprovedPickup(customerId, transferNo, operatorId); } return detail(customerId, transferNo); } @@ -874,7 +889,7 @@ public class WarehouseTransferService { "Warehouse transfer reviewed: " + next + (StringUtils.hasText(request.remark()) ? "; " + request.remark().trim() : ""), now); } - if (APPROVED.equals(next) && incomingTransit) { + if (APPROVED.equals(next) && incomingTransit && arrangeWarehousePickup) { transportTaskService.ensureApprovedPickup(customerId, transferNo, operatorId); } return detail(customerId, transferNo); @@ -1153,6 +1168,7 @@ public class WarehouseTransferService { public WarehouseTransferResponses.Detail palletize(String operatorId, long overseasWarehouseId, String transferNo, WarehouseTransferRequests.Palletize request) { + looseboardPlannedCargo.lockReceipt(transferNo, String.valueOf(overseasWarehouseId)); WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(overseasWarehouseId)); if (transfer == null) throw notFound("Warehouse transfer order not found"); if ("WAREHOUSE_PICKUP".equals(transfer.getDeliveryMethod())) pickupService.requireArrived(transfer.getId(), transfer.getDeliveryMethod(), transfer.getTransferNo(), transfer.getInboundCode()); @@ -1227,7 +1243,8 @@ public class WarehouseTransferService { stockInMeasuredPallets(customerId, operatorId, transfer, items, persistedPallets, completedPalletItems, warehouse, now); completeTransferInboundSnapshot(customerId, transfer, persistedPallets, warehouse, now); - completeTransferInboundPlan(customerId, transfer, persistedPallets, completedPalletItems, now); + if (!looseboardPlannedCargo.complete(transfer, persistedPallets, operatorId, now)) + completeTransferInboundPlan(customerId, transfer, persistedPallets, completedPalletItems, now); transfer.setStatus(WAIT_CUSTOMER_CONFIRMATION); transfer.setPalletizeIdempotencyKey(request.idempotencyKey().trim()); transfer.setPalletizeRequestHash(requestHash); transfer.setUpdatedAt(now); transfers.updateById(transfer); LastlegOrder order = requireTailOrder(customerId, transfer); String before = order.getCurrentStatus(); @@ -1239,6 +1256,13 @@ public class WarehouseTransferService { insertOrderHistory(customerId, order.getId(), before, order.getCurrentStatus(), transferNo, "PDA receiving measurement and palletization completed", now); fleetPool.receiving(customerId, transferNo, operatorId); + // 散板客户委托按PDA实收打托结果排车;普通仓储转运继续保留客户实测确认。 + if (commercialSource != null && commercialSource.isLooseboard(customerId, transferNo)) { + var confirmed = confirmMeasurement(customerId, transferNo, operatorId, + new WarehouseTransferRequests.CustomerConfirm("LOOSEBOARD:" + transfer.getId() + ":MEASURED", "散板PDA收货打托完成")); + commercialSource.ready(customerId, transferNo); + return confirmed; + } return detail(customerId, transferNo); } @@ -2223,6 +2247,7 @@ public class WarehouseTransferService { return inbound.getId(); } + /** 创建转运尾程来源时同步目的地类型,供逐票实物与派送归属核对。 */ private LastlegOrder createTailOrder(String customerId, String customerName, Long inboundOrderId, String inboundCode, String transferNo, WarehouseTransferRequests.Create request, String requestHash, LocalDateTime now) { LastlegOrder order = new LastlegOrder(); @@ -2232,6 +2257,8 @@ public class WarehouseTransferService { order.setInboundCode(inboundCode); order.setSourceRegion("CUSTOMER"); order.setSourceInboundId("WAREHOUSE_TRANSFER:" + transferNo); order.setRootInboundId(String.valueOf(inboundOrderId)); order.setSourceOrderNo(transferNo); order.setOverseasWarehouseId(request.operatingWarehouseId().trim()); + order.setDestinationType("PRIVATE".equalsIgnoreCase(request.destinationType()) ? "PRIVATE" + : StringUtils.hasText(destinationCode(request)) ? "FBA" : null); order.setFbaWarehouseCode(destinationCode(request)); order.setPackageCount(request.packageCount()); order.setVolumeCbm(request.volumeCbm()); order.setContainerNo(transferNo); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java index 01bb6ed6b..d9c8a7f81 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java @@ -61,6 +61,7 @@ public class UsTruckOrderService { @Resource private com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository repository; @Resource private ObjectMapper objectMapper; @Resource private com.baiyue.lastleg.dispatch.transport.TransportTaskService transportTasks; + @Resource private com.baiyue.lastleg.wallet.budget.OrderBudgetService orderBudget; /** 创建卡车需求,校验两端资料并保留创建幂等结果。 */ @Transactional @@ -189,6 +190,8 @@ public class UsTruckOrderService { // 审核通过即按真实 A 仓生成本土卡车排车;仓库缺失时保留待排车状态,供补建入口完成。 if (approve) { arrangeApproved(order, operatorId, request.warehouseId(), "truck-review:" + request.idempotencyKey()); + // 审核通过后释放下单阶段预占,后续实际运输费用按正常资金流程处理。 + if (orderBudget != null) orderBudget.releaseAfterReview(customerId, "trucking", orderNo); result = detail(customerId, orderNo); } return result; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/OrderBudgetService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/OrderBudgetService.java index 735b2257a..a6a63b045 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/OrderBudgetService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/OrderBudgetService.java @@ -115,7 +115,7 @@ public class OrderBudgetService implements WalletResultHandler { return change.get(); } - /** 审核通过后立即释放采购预估冻结,后续实际费用按正常余额/授信扣除。 */ + /** 审核通过后立即释放采购或卡车预估冻结,后续实际费用按正常余额/授信扣除。 */ @Transactional public void releaseAfterReview(String customer, String kind, String orderNo) { requireKind(kind); diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 0c3fc064a..e54ebf5b2 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -3048,6 +3048,15 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order_status_history ( CREATE TABLE IF NOT EXISTS ll_local_looseboard_order ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', lastleg_order_id BIGINT NULL COMMENT '关联尾程主订单ID', + delivery_mode VARCHAR(32) NULL COMMENT '入仓方式:送货到仓或仓库上门取货', + operating_warehouse_id VARCHAR(128) NULL COMMENT '实际收货海外仓ID', + destination_type VARCHAR(16) NULL COMMENT '目的地类型:FBA或私人地址', + destination_postal_code VARCHAR(32) NULL COMMENT '私人目的地邮编', + delivery_contact_name VARCHAR(128) NULL COMMENT '送货到仓联系人', + delivery_contact_phone VARCHAR(64) NULL COMMENT '送货到仓联系电话', + estimated_arrival_at DATETIME(3) NULL COMMENT '预计到仓时间', + tracking_no VARCHAR(255) NULL COMMENT '物流跟踪号', + transfer_no VARCHAR(64) NULL COMMENT '关联的仓储转运收货单号', order_no VARCHAR(64) NOT NULL COMMENT '本土散板订单号', pallet_required VARCHAR(8) NULL DEFAULT NULL COMMENT '是否需要打盘:YES或NO;拼车来源未提供时为空', transport_type VARCHAR(32) NULL DEFAULT NULL COMMENT '接单运输安排;下单不预设', origin VARCHAR(255) NOT NULL COMMENT '起运地', origin_address VARCHAR(500) NULL COMMENT '起运详细地址', diff --git a/lastleg-service/src/main/resources/openapi/dispatch-looseboard-execution-v1.yaml b/lastleg-service/src/main/resources/openapi/dispatch-looseboard-execution-v1.yaml new file mode 100644 index 000000000..2c52b6d08 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/dispatch-looseboard-execution-v1.yaml @@ -0,0 +1,68 @@ +openapi: 3.0.3 +info: + title: 本土散板排车照片履约 + version: 1.0.0 + description: >- + 仅同客户权威关联的本土散板明细进入照片节点。全车为散板且尚无真实扫码时,审核通过后逐票保存取货照片, + 自动从LOADING进入DELIVERING;不生成库存、PDA拣货或仓库出库事实。逐票保存送达照片后自动DELIVERED并结案释放车辆司机。 + 混排从AFTER_LOADING或DELIVERING可逐票保存取货及送达照片;同票须先取货再送达,不同票不限制取送顺序。 + 装车后保存照片保持原阶段,保留原PDA出库及装车资料齐全后进入派送的链路;全部仓内货签收且散板照片齐全才可整车完结。既有真实扫码不可抹除或倒改。 + 图片只接受10MiB以内JPG或PNG,每票每节点最多20张。保存提交该节点完整保留列表,支持追加和逐张删除;空列表代表删除最后一张。 + 整车已完结的节点及已有送达照片的取货节点至少保留一张凭证,可先追加再删除;修改照片不撤销已执行的取货、发运或送达,不重复累计货量。 + 上传不等于保存,保存用公共版本和幂等键,失败同事务回滚。历史照片列表保留审计,仅最新列表授权预览。 + 本土散板号只用LB原单,不生成入仓码、柜号或虚构派送单号。分车运输按已保存送达照片的明细去重累计箱数,整票送达后才完成原单。 +paths: + /api/platform/v1/last-leg/dispatches/{number}/looseboard-execution: + parameters: + - { name: number, in: path, required: true, schema: { type: string } } + get: + summary: 读取散板逐票照片与节点资格 + description: 需要platform:overseas-warehouse:dispatch:list。data包含dispatchNo、recordVersion、stage、direct和items;items包含itemId、customerId、orderNo、destination、packages、pickupPhotos、arrivalPhotos、canPickup、canArrive。 + responses: { '200': { description: 统一ApiResponse } } + /api/platform/v1/last-leg/dispatches/{number}/looseboard-execution/{itemId}/{phase}/uploads: + parameters: + - { name: number, in: path, required: true, schema: { type: string } } + - { name: itemId, in: path, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + - { name: phase, in: path, required: true, schema: { type: string, enum: [PICKUP, ARRIVAL] } } + post: + summary: 上传本车本节点照片,尚不作为保存或发运事实 + description: 需要platform:overseas-warehouse:dispatch:edit。文件业务归属为平台、本车ID、独立节点类别。 + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: { file: { type: string, format: binary } } + responses: { '200': { description: data为fileId、fileName、contentType、sizeBytes } } + /api/platform/v1/last-leg/dispatches/{number}/looseboard-execution/{itemId}/{phase}: + parameters: + - { name: number, in: path, required: true, schema: { type: string } } + - { name: itemId, in: path, required: true, schema: { type: string } } + - { name: phase, in: path, required: true, schema: { type: string, enum: [PICKUP, ARRIVAL] } } + put: + summary: 保存逐票节点照片并原子核对运输进度 + description: 需要platform:overseas-warehouse:dispatch:edit;不得跨车、跨节点或在不同票之间重复绑定照片。重审轮次变化后旧照片保留历史但不代替本轮履约。 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [recordVersion, idempotencyKey, fileIds] + properties: + recordVersion: { type: integer, format: int64, minimum: 0 } + idempotencyKey: { type: string, minLength: 8, maxLength: 128 } + fileIds: { type: array, maxItems: 20, uniqueItems: true, description: 本节点完整保留照片列表,空列表用于删除最后一张且受已履约凭证约束, items: { type: string, pattern: '^[1-9][0-9]*$' } } + responses: { '200': { description: 返回当前整车照片状态;重复原命令不重复推进运输或生成作业事实 } } + /api/platform/v1/last-leg/dispatches/{number}/looseboard-execution/{itemId}/{phase}/files/{fileId}/content: + parameters: + - { name: number, in: path, required: true, schema: { type: string } } + - { name: itemId, in: path, required: true, schema: { type: string } } + - { name: phase, in: path, required: true, schema: { type: string, enum: [PICKUP, ARRIVAL] } } + - { name: fileId, in: path, required: true, schema: { type: string } } + get: + summary: 读取当前明细节点已保存照片 + description: 需要platform:overseas-warehouse:dispatch:list;未保存上传和其他票照片不可读取。 + responses: { '200': { description: JPG或PNG,Cache-Control no-store } } diff --git a/lastleg-service/src/main/resources/openapi/exception-return-inbound-v1.yaml b/lastleg-service/src/main/resources/openapi/exception-return-inbound-v1.yaml index 598808e81..865b5000d 100644 --- a/lastleg-service/src/main/resources/openapi/exception-return-inbound-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/exception-return-inbound-v1.yaml @@ -4,6 +4,36 @@ info: version: 1.0.0 description: 排车异常冻结原出库货物,异常单生成退回尾程单后进入PDA退货入库列表。现场展示异常单号、车牌和待收箱数,点击开始作业,不需要回单号或扫码找单。建单不增加库存,分批实收调用原来源RETURN_STOCK_IN或原箱恢复,异常和退回尾程单收齐后完成。 paths: + /api/platform/v1/last-leg/exception-orders/return-inbound-plans: + post: + summary: 异常退回尾程单生成入库计划 + description: 仅平台账号,权限platform:overseas-warehouse:inbound-order:create。无来源柜,必须填写本次预计到仓时间。服务端核验客户、尾程版本、异常状态及冻结退回仓;当前尾程单号作为新入仓码,保留来源尾程关联。建计划不入账,后续通过异常退回验收恢复原库存并同步计划完成状态,不可走普通PDA入库重复入账。同键同载荷返回首次响应,异载荷拒绝。 + parameters: + - { name: Idempotency-Key, in: header, required: true, schema: { type: string, maxLength: 128 } } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [plan, expectedArrivalAt] + properties: + expectedArrivalAt: { type: string, format: date-time, description: 本次退回预计到仓当地时间,不带时区 } + plan: + type: object + required: [customerId, orderId, expectedOrderVersion, palletCount, palletType, assigned, mergeRequired, urgent] + properties: + customerId: { type: string, maxLength: 128 } + orderId: { type: string, description: 当前异常退回尾程订单ID } + expectedOrderVersion: { type: integer, minimum: 0 } + palletCount: { type: integer, minimum: 1, maximum: 999 } + palletType: { type: string, maxLength: 32 } + assigned: { type: boolean } + mergeRequired: { type: boolean } + urgent: { type: boolean } + remark: { type: string, maxLength: 1000 } + responses: + '200': { description: ApiResponse,data为InboundPlanResponse;creationMode为EXCEPTION_RETURN。 } /api/platform/v1/last-leg/dispatches/{dispatchNo}/exception-return-options: get: summary: 当前排车的原派送货物候选 diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 2cb0037f5..906a18d52 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -1,9 +1,51 @@ openapi: 3.0.3 info: title: Lastleg Overseas Business API - description: 派送资料完整契约见同目录 delivery-documents-v1.yaml;派送列表/详情行返回 bolNo、documentSummary(三类状态与x/3),状态仍使用 version 和 allowedActions。 + description: 派送资料完整契约见同目录 delivery-documents-v1.yaml;散板取货和送达照片履约见 dispatch-looseboard-execution-v1.yaml。派送列表/详情行返回 bolNo、documentSummary(三类状态与x/3),状态仍使用 version 和 allowedActions。 version: 1.0.0 paths: + /api/platform/v1/last-leg/dispatches/capacity-demands/{dispatchNo}/cargo: + parameters: + - { name: dispatchNo, in: path, required: true, schema: { type: string } } + get: + summary: 读取同仓已审核散板的申报与实收候选 + description: 需capacity-offer:list或manage以及dispatch:list权限;仅已发布且未发车的有效派送排车,候选展示真实本土散板原单号,本土散板无入仓码和集装箱号时均返回空,不以履约单号代填。审核后申报货物可先排车,无需以PDA打托为前置;不生成实收库存,实际到仓后核对实托和货量,按仓库及真实客户来源隔离。 + security: [{ bearerAuth: [] }] + parameters: + - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } + - { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 50, default: 20 } } + responses: { '200': { description: 统一ApiResponse;data为正式排车CandidateList,含候选来源版本、目的地及资格原因 } } + post: + summary: 将已收货散板加入需求排车 + description: 需capacity-offer:cargo-add或manage以及dispatch:edit、dispatch:list权限;复用正式AddCargo命令,校验版本、幂等、同仓及整托占用,自动刷新目的站和货量并失效审核,不执行出库拣货。 + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/DispatchAddCargo' } + responses: { '200': { description: 统一ApiResponse;data为正式排车CommandResult,冲突返回业务码A0409 } } + /api/platform/v1/last-leg/local-looseboard-orders/{orderNo}/review: + post: + summary: 审核散板并创建唯一入仓履约单 + description: 权限platform:overseas-business:looseboard:status;通过后送货到仓进入PDA收货,仓库上门取货进入取货排车;审核后即可从散板需求添加申报货物;上门取货沿取货排车上传取货与到仓凭证,实收到仓后原单进入PENDING_DISPATCH并交接原排车货量。客户及幂等键严格校验。 + security: [{ bearerAuth: [] }] + parameters: + - { name: orderNo, in: path, required: true, schema: { type: string } } + - { name: customerId, in: query, required: true, schema: { type: string } } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [version, decision, idempotencyKey] + properties: + version: { type: integer, minimum: 0 } + decision: { type: string, enum: [APPROVE, REJECT] } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + remark: { type: string, maxLength: 1000 } + responses: { '200': { description: 统一ApiResponse包装的散板详情,summary.transferNo关联转运履约 } } /api/platform/v1/last-leg/dispatches/capacity-demands: get: summary: 读取有效派送排车的内部散板余位需求 @@ -4817,6 +4859,15 @@ components: description: 散板商业主单摘要;池来源附带同客户fleetArrangement,独立订单该字段为null。池来源不创建第二张占位尾程来源。 required: [id, customerId, orderNo, origin, destination, estimatedPalletCount, packageCount, totalWeightKg, totalVolumeCbm, status, version, createdAt, updatedAt] properties: + deliveryMode: { type: string, enum: [DELIVERY_TO_WAREHOUSE, WAREHOUSE_PICKUP], description: 送货到仓或仓库上门取货 } + operatingWarehouseId: { type: string, description: 实际收货海外仓ID } + destinationType: { type: string, enum: [FBA, PRIVATE] } + destinationPostalCode: { type: string, maxLength: 32, nullable: true } + deliveryContactName: { type: string, maxLength: 128, nullable: true, description: 送货到仓必填 } + deliveryContactPhone: { type: string, maxLength: 64, nullable: true, description: 送货到仓必填 } + estimatedArrivalAt: { type: string, format: date-time, nullable: true, description: 送货到仓必填 } + trackingNo: { type: string, maxLength: 255, nullable: true, description: 可选物流跟踪号 } + transferNo: { type: string, nullable: true, description: 审核后生成的仓储转运履约单号 } id: { type: string, pattern: '^[0-9]+$' } customerId: { type: string } orderNo: { type: string } @@ -5242,8 +5293,16 @@ components: remark: { type: string, nullable: true, maxLength: 1000 } LooseboardCreate: type: object - required: [originContactName, originContactPhone, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, origin, destination, estimatedPalletCount, packageCount, totalWeightKg, totalVolumeCbm, idempotencyKey] + required: [operatingWarehouseId, deliveryMode, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, destination, estimatedPalletCount, packageCount, totalWeightKg, totalVolumeCbm, idempotencyKey] properties: + deliveryMode: { type: string, enum: [DELIVERY_TO_WAREHOUSE, WAREHOUSE_PICKUP], description: 送货到仓或仓库上门取货 } + operatingWarehouseId: { type: string, description: 实际收货海外仓ID } + destinationType: { type: string, enum: [FBA, PRIVATE] } + destinationPostalCode: { type: string, maxLength: 32, nullable: true } + deliveryContactName: { type: string, maxLength: 128, nullable: true, description: 送货到仓必填 } + deliveryContactPhone: { type: string, maxLength: 64, nullable: true, description: 送货到仓必填 } + estimatedArrivalAt: { type: string, format: date-time, nullable: true, description: 送货到仓必填 } + trackingNo: { type: string, maxLength: 255, nullable: true, description: 可选物流跟踪号 } origin: { type: string, maxLength: 255 } originAddress: { type: string, nullable: true, maxLength: 500 } destination: { type: string, maxLength: 255 } @@ -5265,8 +5324,16 @@ components: idempotencyKey: { type: string, maxLength: 255 } LooseboardUpdate: type: object - required: [originContactName, originContactPhone, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, version, origin, destination, estimatedPalletCount, packageCount, totalWeightKg, totalVolumeCbm] + required: [operatingWarehouseId, deliveryMode, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, version, destination, estimatedPalletCount, packageCount, totalWeightKg, totalVolumeCbm] properties: + deliveryMode: { type: string, enum: [DELIVERY_TO_WAREHOUSE, WAREHOUSE_PICKUP], description: 送货到仓或仓库上门取货 } + operatingWarehouseId: { type: string, description: 实际收货海外仓ID } + destinationType: { type: string, enum: [FBA, PRIVATE] } + destinationPostalCode: { type: string, maxLength: 32, nullable: true } + deliveryContactName: { type: string, maxLength: 128, nullable: true, description: 送货到仓必填 } + deliveryContactPhone: { type: string, maxLength: 64, nullable: true, description: 送货到仓必填 } + estimatedArrivalAt: { type: string, format: date-time, nullable: true, description: 送货到仓必填 } + trackingNo: { type: string, maxLength: 255, nullable: true, description: 可选物流跟踪号 } version: { type: integer, format: int64, minimum: 0 } origin: { type: string, maxLength: 255 } originAddress: { type: string, nullable: true, maxLength: 500 } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java index 5a8e83e91..c6f8c02db 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java @@ -21,8 +21,10 @@ class DispatchCandidateMapperContractTest { void reservedPalletsCanBePlannedAfterDepartureWithoutInventingReceivedStock() throws Exception { try (var connection = DriverManager.getConnection("jdbc:h2:mem:dispatch-prearrival;MODE=MySQL")) { var db = connection.createStatement(); - db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR,status VARCHAR DEFAULT 'PDA_PENDING')"); - db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,warehouse_pallet_id BIGINT,assignment_status VARCHAR,actual_package_count INT)"); + db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR,source_order_id BIGINT,overseas_warehouse_id BIGINT,status VARCHAR DEFAULT 'PDA_PENDING')"); + db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,warehouse_pallet_id BIGINT,assignment_status VARCHAR,actual_package_count INT,source_pallet_no VARCHAR)"); + db.execute("CREATE TABLE ll_local_looseboard_order(customer_id VARCHAR,lastleg_order_id BIGINT,transfer_no VARCHAR,status VARCHAR)"); + db.execute("CREATE TABLE ll_warehouse_transfer_order(customer_id VARCHAR,lastleg_order_id BIGINT,transfer_no VARCHAR,operating_warehouse_id VARCHAR,status VARCHAR)"); db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,customer_id VARCHAR,order_id BIGINT)"); db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT)"); db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,order_type VARCHAR,manual_node_override BOOLEAN,source_container_booking_id BIGINT)"); @@ -31,7 +33,7 @@ class DispatchCandidateMapperContractTest { db.execute("CREATE TABLE ll_inventory_position(customer_id VARCHAR,pallet_id BIGINT,available_packages INT)"); for (int id = 1; id <= 14; id++) { db.execute("INSERT INTO ll_inbound_plan(id,customer_id) VALUES (" + id + ",'A')"); - db.execute("INSERT INTO ll_inbound_plan_pallet VALUES (" + id + "," + id + "," + (100 + id) + ",'RESERVED',0)"); + db.execute("INSERT INTO ll_inbound_plan_pallet VALUES (" + id + "," + id + "," + (100 + id) + ",'RESERVED',0,NULL)"); db.execute("INSERT INTO ll_inbound_plan_line VALUES (" + id + ",'A'," + id + ")"); db.execute("INSERT INTO ll_inbound_plan_pallet_allocation VALUES (" + id + "," + id + ")"); db.execute("INSERT INTO ll_order VALUES (" + id + ",'A','FIRST_LEG',FALSE," + id + ")"); @@ -51,6 +53,18 @@ class DispatchCandidateMapperContractTest { db.execute("UPDATE ll_inbound_plan SET status='PARTIAL_INBOUND' WHERE id=12"); db.execute("UPDATE ll_inbound_plan_pallet SET warehouse_pallet_id=NULL WHERE id=13"); db.execute("UPDATE ll_inbound_plan_pallet SET assignment_status='ASSIGNED' WHERE id=14"); + // 散板无需头程离港和实收库存;资格必须沿同客户、同仓审核履约核对。 + for (int id=15;id<=20;id++) { + db.execute("INSERT INTO ll_inbound_plan(id,customer_id,source_order_id,overseas_warehouse_id) VALUES ("+id+",'A',"+id+",9)"); + db.execute("INSERT INTO ll_inbound_plan_pallet VALUES ("+id+","+id+",NULL,'UNASSIGNED',NULL,'LB-PLAN:"+id+"')"); + db.execute("INSERT INTO ll_local_looseboard_order VALUES ('A',"+id+",'ZY"+id+"','WAITING_INBOUND')"); + db.execute("INSERT INTO ll_warehouse_transfer_order VALUES ('A',"+id+",'ZY"+id+"','9','WAIT_PDA_RECEIVING')"); + } + db.execute("UPDATE ll_local_looseboard_order SET status='CANCELLED' WHERE lastleg_order_id=16"); + db.execute("UPDATE ll_warehouse_transfer_order SET customer_id='B' WHERE lastleg_order_id=17"); + db.execute("UPDATE ll_warehouse_transfer_order SET operating_warehouse_id='10' WHERE lastleg_order_id=18"); + db.execute("DELETE FROM ll_local_looseboard_order WHERE lastleg_order_id=19"); + db.execute("UPDATE ll_warehouse_transfer_order SET status='PENDING_REVIEW' WHERE lastleg_order_id=20"); String plan = DispatchCandidateMapper.PLAN_ONLY; // 直接执行两条生产SQL中的资格片段,避免仅验证测试自行拼出的规则。 for (String sql : List.of(DispatchCandidateMapper.AVAILABLE_PLAN_BODY, DispatchCandidateMapper.SELECTED_PLAN_SQL)) { @@ -71,7 +85,7 @@ class DispatchCandidateMapperContractTest { } } // 完成入库或已确认短少后,空预留托盘不可排车,真实实收托盘继续按库存校验。 - assertEquals(List.of(1L, 3L, 7L, 11L), ids); + assertEquals(List.of(1L, 3L, 7L, 11L, 15L), ids); assertTrue(sql.contains("CASE WHEN " + plan + " THEN NULL ELSE p.warehouse_pallet_id END AS warehousePalletId")); } } @@ -134,6 +148,8 @@ class DispatchCandidateMapperContractTest { String sql = String.join(" ", DispatchCandidateMapper.class .getMethod(method, String.class, String.class, String.class).getAnnotation(Select.class).value()); String eligibility = sql.substring(sql.indexOf("WHERE a.delivery_method"), sql.indexOf(" result=ReflectionTestUtils.invokeMethod(aggregate,"orderDetails",List.of(looseboard,other)); + assertThat(result.get(0).orderNo()).isEqualTo("LB1");assertThat(result.get(0).sourceBusinessNo()).isEqualTo("LB1"); + assertThat(result.get(0).sourceBusinessType()).isEqualTo("LOCAL_LOOSEBOARD");assertThat(result.get(0).containerNo()).isNull(); + assertThat(result.get(0).inboundCode()).isNull();assertThat(result.get(1).inboundCode()).isEqualTo("ZY1"); + assertThat(result.get(1).orderNo()).isEqualTo("ZY1");assertThat(result.get(1).containerNo()).isEqualTo("REAL-CONTAINER"); + assertThat(looseboard.getOrderNo()).isEqualTo("ZY1");assertThat(looseboard.getSourceSnapshotJson()).contains("ZY1"); + } + /** 展示替换不得更改加货的来源键、版本或原计划身份。 */ + @Test void candidateRetainsCommandIdentityWithoutInventedInboundCode() { + var repository=mock(LooseboardCargoRepository.class); + when(repository.businessSources(any())).thenReturn(List.of(new BusinessSource("A","ZY1","LB1",11L))); + var display=new DispatchLooseboardDisplayService(repository); + var selection=new DispatchCommands.CargoSelection(DispatchTypes.SourceType.INBOUND_PLAN,11L,null,null,null,"version"); + var group=new DispatchPlanningModels.SelectionGroup("group","group-version",List.of(new DispatchPlanningModels.SelectionGroupSource(selection,"PLAN:11","A","customer","INBOUND_PLAN","WT-ZY1",2)),List.of(),true,List.of()); + var original=new DispatchPlanningModels.Candidate(selection,"PLAN:11","version","A","customer","9",null,null,"ZY1","ZY1","WT-ZY1",null,"ZY1",null,2,null,null,null,List.of(),List.of(),group); + var result=display.candidates(List.of(original)).get(0); + assertThat(result.orderNo()).isEqualTo("LB1");assertThat(result.containerNo()).isNull();assertThat(result.inboundCode()).isNull(); + assertThat(result.selection()).isSameAs(selection);assertThat(result.sourceVersion()).isEqualTo("version");assertThat(result.sourceKey()).isEqualTo("PLAN:11"); + assertThat(result.selectionGroup().sources().get(0).sourceBusinessType()).isEqualTo("LOCAL_LOOSEBOARD"); + assertThat(result.selectionGroup().sources().get(0).sourceBusinessNo()).isEqualTo("LB1"); + } + /** 商业关联查询必须同客户、同履约单且同尾程身份,避免同号跨客户串单。 */ + @Test void mapperRequiresTheAuthoritativeLinkAndCustomer() throws Exception { + var ds=new org.springframework.jdbc.datasource.DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); + var db=new org.springframework.jdbc.core.JdbcTemplate(ds); + db.execute("CREATE TABLE ll_local_looseboard_order(customer_id VARCHAR,order_no VARCHAR,lastleg_order_id BIGINT,transfer_no VARCHAR,delivery_mode VARCHAR DEFAULT 'WAREHOUSE_PICKUP')"); + db.execute("CREATE TABLE ll_warehouse_transfer_order(customer_id VARCHAR,transfer_no VARCHAR,lastleg_order_id BIGINT)"); + db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,source_order_id BIGINT,customer_id VARCHAR)"); + db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,customer_id VARCHAR)"); + db.update("INSERT INTO ll_local_looseboard_order(customer_id,order_no,lastleg_order_id,transfer_no) VALUES('A','LB1',1,'ZY1'),('B','LB2',2,'ZY1'),('A','LB-BROKEN',3,'ZY3')"); + db.update("INSERT INTO ll_warehouse_transfer_order VALUES('A','ZY1',1),('B','ZY1',2),('B','ZY3',3)"); + db.update("INSERT INTO ll_inbound_plan VALUES(10,1,'A')");db.update("INSERT INTO ll_inbound_plan_pallet VALUES(11,10,'A')"); + var config=new org.apache.ibatis.session.Configuration();config.setMapUnderscoreToCamelCase(true);config.addMapper(LooseboardCargoMapper.class); + var factory=new org.mybatis.spring.SqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);factory.afterPropertiesSet(); + var mapper=new org.mybatis.spring.SqlSessionTemplate(factory.getObject()).getMapper(LooseboardCargoMapper.class); + assertThat(mapper.businessSources(List.of(new Reference("A","ZY1",null)))).extracting(BusinessSource::orderNo).containsExactly("LB1"); + assertThat(mapper.businessSources(List.of(new Reference("B",null,11L)))).isEmpty(); + assertThat(mapper.businessSources(List.of(new Reference("A","ZY3",null)))).isEmpty(); + } + /** 构造旧排车保存的履约快照,验证读取时纠正且不改原来源。 */ + private DispatchItem item(String customer,String number,long pallet,String container) { + var item=new DispatchItem();item.setCustomerId(customer);item.setOrderNo(number);item.setOrderRefs(number);item.setPlanPalletId(pallet);item.setInboundCode(number); + item.setSourceSnapshotJson("{\"containerNo\":\""+container+"\",\"planNo\":\"plan\"}");return item; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLooseboardExecutionServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLooseboardExecutionServiceTest.java new file mode 100644 index 000000000..22b757d78 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLooseboardExecutionServiceTest.java @@ -0,0 +1,178 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.entity.*; +import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway; +import com.baiyue.lastleg.dispatch.mapper.LooseboardCargoMapper.BusinessSource; +import com.baiyue.lastleg.dispatch.repository.*; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.fleet.trip.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import java.time.*; +import java.util.*; +import static com.baiyue.lastleg.dispatch.service.DispatchLooseboardFacts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class DispatchLooseboardExecutionServiceTest { + /** 追加、逐张删除及清空后重传不重复计入取货箱数;删除历史保留但不再授权下载。 */ + @Test void editsPhotoListsWithoutDuplicatingRemotePickup() { + var f=new Fixture(true);f.row.setStage("AFTER_LOADING");f.row.setPickedPackageCount(2); + f.save(101,Phase.PICKUP,1); + var appended=f.replace(101,Phase.PICKUP,1L,2L); + assertThat(appended.items().get(0).pickupPhotos()).extracting(DispatchEvidenceGateway.File::fileId).containsExactly(1L,2L); + f.replace(101,Phase.PICKUP,2L); + assertThatThrownBy(()->f.service.content(f.actor,"PC1",101,Phase.PICKUP,1)).hasMessageContaining("未绑定"); + f.replace(101,Phase.PICKUP); + assertThat(f.facts.current(f.row).get(0).files()).isEmpty(); + assertThat(f.facts.complete(f.row,Phase.PICKUP)).isFalse(); + f.replace(101,Phase.PICKUP,3L); + assertThat(f.row.getShippedPackageCount()).isEqualTo(2); + assertThat(f.facts.pickedRemotePackages(f.row)).isEqualTo(2); + assertThat(f.facts.history(1)).hasSize(5); + verify(f.workflow,times(1)).requireRemotePickupCapacity(f.row); + verify(f.orders,times(1)).progress("A","LB1","operator",0); + } + /** 送达后可替换及追加照片,但最后一张完成凭证不可删,修改不重复触发结案。 */ + @Test void completedPhotosCanBeAppendedAndDeletedWhileRetainingEvidence() { + var f=new Fixture(false);f.save(101,Phase.PICKUP,1);f.save(102,Phase.PICKUP,2); + f.save(101,Phase.ARRIVAL,3); + assertThatThrownBy(()->f.replace(101,Phase.PICKUP)).hasMessageContaining("至少保留一张"); + f.save(102,Phase.ARRIVAL,4); + f.replace(101,Phase.ARRIVAL,3L,5L);f.replace(101,Phase.ARRIVAL,5L); + assertThat(f.service.get(f.actor,"PC1").items().get(0).arrivalPhotos()).extracting(DispatchEvidenceGateway.File::fileId).containsExactly(5L); + assertThatThrownBy(()->f.replace(101,Phase.ARRIVAL)).hasMessageContaining("至少保留一张"); + assertThat(f.row.getStage()).isEqualTo("DELIVERED"); + verify(f.completion,times(1)).completeDelivered("TRIP1","operator"); + verify(f.fleet,times(1)).departPhotoDispatchShell(eq("PC1"),eq("operator"),any(),anyString()); + verify(f.orders,times(1)).progress("A","LB1","operator",2); + } + /** 两票散板无库存出库,取货缺一票不发运,送达缺一票不结案;重复命令不重复发车。 */ + @Test void pureLooseboardNeedsEveryPickupAndArrivalButNeverPicks() { + var f=new Fixture(false);var first=f.save(101,Phase.PICKUP,1); + assertThat(first.direct()).isTrue();assertThat(first.stage()).isEqualTo("LOADING");verifyNoInteractions(f.fleet); + var request=new DispatchLooseboardExecutionService.Save(f.row.getRecordVersion(),"pickup-original",List.of(2L)); + var departed=f.service.save(f.actor,"PC1",102,Phase.PICKUP,request); + assertThat(departed.stage()).isEqualTo("DELIVERING");assertThat(f.row.getPickedPackageCount()).isZero();assertThat(f.row.getShippedPackageCount()).isEqualTo(4); + f.service.save(f.actor,"PC1",102,Phase.PICKUP,request); + verify(f.fleet,times(1)).departPhotoDispatchShell(eq("PC1"),eq("operator"),any(),anyString()); + verify(f.outbounds,times(1)).revoke(1);verifyNoMoreInteractions(f.outbounds); + f.save(101,Phase.ARRIVAL,3);assertThat(f.row.getStage()).isEqualTo("DELIVERING");verifyNoInteractions(f.completion); + f.save(102,Phase.ARRIVAL,4);assertThat(f.row.getStage()).isEqualTo("DELIVERED"); + verify(f.completion).completeDelivered("TRIP1","operator"); + assertThat(f.facts.outcomes(f.row)).hasSize(2).allMatch(o->o.remainingPackages()==0 && o.signedPackages()==2); + verify(f.workflow).releaseSources(eq(f.row),eq(f.actor),anyList(),any()); + } + /** 混排仍保留原出库链,散板照片不能单独结束其他客户的派送。 */ + @Test void mixedCargoOnlyAddsEvidenceAndWakesNormalCompletion() { + var f=new Fixture(true);f.row.setStage("DELIVERING");f.row.setPickedPackageCount(2);f.row.setShippedPackageCount(2); + f.save(101,Phase.PICKUP,1);f.save(102,Phase.PICKUP,2);f.save(101,Phase.ARRIVAL,3);f.save(102,Phase.ARRIVAL,4); + assertThat(f.row.getStage()).isEqualTo("DELIVERING");assertThat(f.facts.direct(f.row)).isFalse(); + assertThat(f.facts.complete(f.row,Phase.PICKUP)).isTrue();assertThat(f.facts.complete(f.row,Phase.ARRIVAL)).isTrue(); + verifyNoInteractions(f.fleet,f.outbounds,f.completion); + verify(f.deliveries).normalDeliveryFacts(any()); + assertThat(f.row.getShippedPackageCount()).isEqualTo(6);assertThat(f.row.getPickedPackageCount()).isEqualTo(2); + } + /** 仓内货先送达不阻塞后续异地取货;各票散板可交错取送,最后一票送达才整车完结。 */ + @Test void mixedWarehouseDeliveryMayFinishBeforeRemotePickupsAndInterleavedArrivals() { + var f=new Fixture(true);f.row.setStage("DELIVERING");f.row.setPickedPackageCount(2);f.row.setShippedPackageCount(2); + when(f.deliveries.normalDeliveryFacts(any())).thenReturn(new NormalDeliveryFacts("PC1","signed",true,Instant.now(),List.of("DL1"),List.of(),List.of())); + var initial=f.service.get(f.actor,"PC1"); + assertThat(initial.stage()).isEqualTo("DELIVERING"); + assertThat(initial.items()).allMatch(i->i.canPickup() && !i.canArrive()); + f.save(101,Phase.PICKUP,1); + var partial=f.save(101,Phase.ARRIVAL,2); + assertThat(partial.stage()).isEqualTo("DELIVERING"); + assertThat(partial.items().stream().filter(i->i.itemId().equals("102")).findFirst().orElseThrow().canPickup()).isTrue(); + verifyNoInteractions(f.completion); + f.save(102,Phase.PICKUP,3); + assertThat(f.row.getStage()).isEqualTo("DELIVERING"); + f.save(102,Phase.ARRIVAL,4); + assertThat(f.row.getStage()).isEqualTo("DELIVERED"); + assertThat(f.row.getPickedPackageCount()).isEqualTo(2);assertThat(f.row.getShippedPackageCount()).isEqualTo(6); + verify(f.completion).completeDelivered("TRIP1","operator"); + verify(f.fleet,never()).departPhotoDispatchShell(anyString(),anyString(),any(),anyString()); + verifyNoInteractions(f.outbounds); + } + /** 装车后即可保存散板取送凭证,但不能代替仓内正式发车或提前结案。 */ + @Test void mixedAfterLoadingAcceptsPhotosWithoutPrematureDepartureOrCompletion() { + var f=new Fixture(true);f.row.setStage("AFTER_LOADING");f.row.setPickedPackageCount(2); + var initial=f.service.get(f.actor,"PC1"); + assertThat(initial.items()).allMatch(i->i.canPickup() && !i.canArrive()); + var picked=f.save(101,Phase.PICKUP,1); + assertThat(picked.stage()).isEqualTo("AFTER_LOADING"); + assertThat(picked.items().stream().filter(i->i.itemId().equals("101")).findFirst().orElseThrow().canArrive()).isTrue(); + f.save(101,Phase.ARRIVAL,2);f.save(102,Phase.PICKUP,3);f.save(102,Phase.ARRIVAL,4); + assertThat(f.row.getStage()).isEqualTo("AFTER_LOADING"); + assertThat(f.row.getDepartedAt()).isNull();assertThat(f.row.getDeliveryCompletedAt()).isNull(); + assertThat(f.row.getPickedPackageCount()).isEqualTo(2);assertThat(f.row.getShippedPackageCount()).isEqualTo(4); + assertThat(f.facts.pickedRemotePackages(f.row)).isEqualTo(4); + verify(f.workflow,times(2)).requireRemotePickupCapacity(f.row); + verifyNoInteractions(f.fleet,f.outbounds,f.deliveries,f.completion); + } + /** 未确认装车的混排不允许提前上传,取消后同样不能上传。 */ + @Test void mixedLoadingAndCancelledDispatchRejectPhotos() { + var f=new Fixture(true); + assertThat(f.service.get(f.actor,"PC1").items()).allMatch(i->!i.canPickup() && !i.canArrive()); + assertThatThrownBy(()->f.save(101,Phase.PICKUP,1)).hasMessageContaining("阶段"); + f.row.setStage("AFTER_LOADING");f.row.setStatus("CANCELLED"); + assertThat(f.service.get(f.actor,"PC1").items()).allMatch(i->!i.canPickup() && !i.canArrive()); + } + /** 入仓实托必须参与拣货,只有未入仓的上门取货明细排除。 */ + @Test void receivedLooseboardRemainsInWarehousePicking(){ + var f=new Fixture(true);f.repository.cargo(1).get(0).setWarehousePalletId(501L); + assertThat(f.facts.remoteItemIds(1)).containsExactly(102L); + assertThat(f.facts.warehouseItems(1)).extracting(DispatchItem::getId).containsExactly(101L,103L); + assertThat(f.facts.direct(f.row)).isFalse(); + } + /** 送达必须晚于取货和发运;公共版本、其他票身份及照片复用均拒绝。 */ + @Test void rejectsWrongNodeStaleVersionForeignItemAndFileReuse() { + var f=new Fixture(false); + assertThatThrownBy(()->f.save(101,Phase.ARRIVAL,1)).hasMessageContaining("阶段"); + assertThatThrownBy(()->f.service.save(f.actor,"PC1",101,Phase.PICKUP,new DispatchLooseboardExecutionService.Save(99L,"stale-version",List.of(1L)))).hasMessageContaining("版本"); + assertThatThrownBy(()->f.save(999,Phase.PICKUP,1)).hasMessageContaining("不属于本车"); + f.save(101,Phase.PICKUP,1); + assertThatThrownBy(()->f.save(102,Phase.PICKUP,1)).hasMessageContaining("重复使用"); + assertThatThrownBy(()->f.service.content(f.actor,"PC1",102,Phase.PICKUP,1)).hasMessageContaining("未绑定"); + verifyNoInteractions(f.fleet,f.outbounds,f.completion); + } + /** 退回重审后旧证据只留历史;有实际扫码的旧车不得跳过库存链。 */ + @Test void oldReviewPhotosDoNotCountAndActualPicksDisableDirectMode() { + var f=new Fixture(false);f.save(101,Phase.PICKUP,1);f.row.setReviewRound(2L); + assertThat(f.facts.current(f.row)).isEmpty();assertThat(f.facts.history(1)).hasSize(1); + when(f.repository.realPicked(1)).thenReturn(1L);assertThat(f.facts.direct(f.row)).isFalse(); + } + static class Fixture { + final ObjectMapper json=new ObjectMapper().findAndRegisterModules();final Actor actor=new Actor("operator",DispatchTypes.ActorMode.PLATFORM,null); + final DispatchAggregate row=new DispatchAggregate();final List history=new ArrayList<>(); + final DispatchAggregateRepository repository=mock(DispatchAggregateRepository.class); + final DispatchLooseboardEvidenceRepository evidence=mock(DispatchLooseboardEvidenceRepository.class); + final DispatchAggregateService workflow=mock(DispatchAggregateService.class);final DispatchEvidenceGateway files=mock(DispatchEvidenceGateway.class); + final DispatchOutboundPort outbounds=mock(DispatchOutboundPort.class);final FleetTripExecutionService fleet=mock(FleetTripExecutionService.class); + final FleetTripCompletionService completion=mock(FleetTripCompletionService.class);final DispatchDeliveryPort deliveries=mock(DispatchDeliveryPort.class); + final LooseboardTransportPort orders=mock(LooseboardTransportPort.class); + final DispatchLooseboardFacts facts;final DispatchLooseboardExecutionService service; + Fixture(boolean mixed){ + row.setId(1L);row.setDispatchNo("PC1");row.setStage("LOADING");row.setStatus("ACTIVE");row.setReviewState("APPROVED");row.setRecordVersion(0L); + row.setReviewRound(1L);row.setCargoHash("cargo");row.setPackageCount(mixed?6:4);row.setPickedPackageCount(0);row.setPalletCount(mixed?3:2);row.setShippedPackageCount(0); + var cargo=new ArrayList<>(List.of(item(101,"A","ZY1"),item(102,"B","ZY2")));if(mixed)cargo.add(item(103,"C","OTHER")); + when(repository.find("PC1")).thenReturn(row);when(repository.cargo(1)).thenReturn(cargo);when(repository.saveState(eq(row),anyLong())).thenReturn(1); + when(repository.history(anyLong(),eq(row),anyString(),anyString(),any(),anyString(),any())).thenAnswer(call->{if("LOOSEBOARD_PHOTO".equals(call.getArgument(2)))history.add(call.getArgument(5));return 1;}); + when(evidence.snapshots(1)).thenAnswer(call->List.copyOf(history));when(evidence.deliveredPackages(anyString(),anyString())).thenReturn(2L); + var sourceRepo=mock(LooseboardCargoRepository.class);when(sourceRepo.businessSources(anyList())).thenReturn(List.of(new BusinessSource("A","ZY1","LB1",101L,"WAREHOUSE_PICKUP"),new BusinessSource("B","ZY2","LB2",102L,"WAREHOUSE_PICKUP"))); + facts=new DispatchLooseboardFacts(repository,new DispatchLooseboardDisplayService(sourceRepo),evidence,json); + var context=mock(Context.class);var resources=new Resources(new VehicleSnapshot(DispatchTypes.ResourceMode.MASTER,1L,"TEST",null,"IN_SERVICE",new Capacity(10,new java.math.BigDecimal("1000"),new java.math.BigDecimal("50")),List.of()),null,null); + when(context.resources()).thenReturn(resources);when(workflow.preview(actor,"PC1")).thenReturn(context);when(workflow.lockForArrangement(actor,"PC1")).thenReturn(context); + when(fleet.dispatchIdentity("PC1")).thenReturn(new FleetExecutionModels.DispatchShellLock(1L,"TRIP1","9")); + when(files.metadata(eq(1L),any(),anyLong())).thenAnswer(call->new DispatchEvidenceGateway.File(call.getArgument(2),"test.png","image/png",10)); + when(deliveries.normalDeliveryFacts(context)).thenReturn(new NormalDeliveryFacts("PC1","hash",false,null,List.of("DL1"),List.of(new NormalDeliveryFact("A","group","OUT1","DL1",5,6,0,0,0,"DELIVERING",true,null)),List.of())); + service=new DispatchLooseboardExecutionService(workflow,repository,facts,files,outbounds,fleet,completion,deliveries,orders,new DispatchCommandHasher(json),json); + } + DispatchLooseboardExecutionService.Detail save(long item,Phase phase,long file){return service.save(actor,"PC1",item,phase,new DispatchLooseboardExecutionService.Save(row.getRecordVersion(),UUID.randomUUID().toString(),List.of(file)));} + DispatchLooseboardExecutionService.Detail replace(long item,Phase phase,Long... files){return service.save(actor,"PC1",item,phase,new DispatchLooseboardExecutionService.Save(row.getRecordVersion(),UUID.randomUUID().toString(),List.of(files)));} + DispatchItem item(long id,String customer,String number){var item=new DispatchItem();item.setId(id);item.setCustomerId(customer);item.setOrderNo(number);item.setPlanPalletId(id);item.setPackageCount(2);item.setDeliveryAddress("TEST DESTINATION");return item;} + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLooseboardPhotoPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLooseboardPhotoPersistenceTest.java new file mode 100644 index 000000000..7bb1e3de9 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLooseboardPhotoPersistenceTest.java @@ -0,0 +1,64 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.mapper.*; +import com.baiyue.lastleg.dispatch.repository.*; +import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.fleet.trip.*; +import org.junit.jupiter.api.Test; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import java.util.*; +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class DispatchLooseboardPhotoPersistenceTest { + /** 最后一票取货照片、公共版本及发车同事务;车队失败时不能留下已保存照片或假发运。 */ + @Test void pickupPhotoAndDispatchProgressRollbackTogetherThenRetry() throws Exception { + var f=new DispatchAggregatePersistenceTest.Fixture();var selection=f.source(1,"101",501,"ONT8",3); + var created=f.tx.execute(s->f.service.create(f.actor,f.configured("photo-rollback-create",List.of(selection))));var number=created.dispatchNo(); + f.db.update("UPDATE ll_dispatch_order SET stage='LOADING',review_state='APPROVED',review_round=1"); + f.db.update("UPDATE ll_dispatch_item SET warehouse_pallet_id=NULL"); + var root=f.repository.find(number);var item=f.repository.cargo(root.getId()).get(0); + f.session.getConfiguration().addMapper(DispatchLooseboardEvidenceMapper.class); + var evidence=new DispatchLooseboardEvidenceRepository(f.session.getMapper(DispatchLooseboardEvidenceMapper.class)); + var linked=mock(LooseboardCargoRepository.class);when(linked.businessSources(anyList())).thenReturn(List.of(new LooseboardCargoMapper.BusinessSource(item.getCustomerId(),item.getOrderNo(),"LB1",item.getPlanPalletId()))); + var facts=new DispatchLooseboardFacts(f.repository,new DispatchLooseboardDisplayService(linked),evidence,f.json); + var workflow=mock(DispatchAggregateService.class);var context=mock(Context.class); + when(workflow.preview(eq(f.actor),eq(number))).thenReturn(context);when(workflow.lockForArrangement(eq(f.actor),eq(number))).thenReturn(context); + when(context.resources()).thenReturn(new Resources(new VehicleSnapshot(DispatchTypes.ResourceMode.MASTER,1L,"TEST",null,"IN_SERVICE",new Capacity(10,new java.math.BigDecimal("999999"),new java.math.BigDecimal("999999")),List.of()),null,null)); + var files=mock(DispatchEvidenceGateway.class);when(files.metadata(anyLong(),any(),eq(77L))).thenReturn(new DispatchEvidenceGateway.File(77L,"pickup.png","image/png",10)); + var fleet=mock(FleetTripExecutionService.class);var outbounds=mock(DispatchOutboundPort.class); + var target=new DispatchLooseboardExecutionService(workflow,f.repository,facts,files,outbounds,fleet,mock(FleetTripCompletionService.class),mock(DispatchDeliveryPort.class),mock(LooseboardTransportPort.class),new DispatchCommandHasher(f.json),f.json); + var proxy=new ProxyFactory(target);proxy.setProxyTargetClass(true);proxy.addAdvice(new TransactionInterceptor(f.transactions,new AnnotationTransactionAttributeSource())); + var service=(DispatchLooseboardExecutionService)proxy.getProxy(); + var request=new DispatchLooseboardExecutionService.Save(root.getRecordVersion(),"pickup-rollback-original",List.of(77L)); + doThrow(new IllegalStateException("模拟发车故障")).when(fleet).departPhotoDispatchShell(eq(number),anyString(),any(),anyString()); + assertThatThrownBy(()->service.save(f.actor,number,item.getId(),DispatchLooseboardFacts.Phase.PICKUP,request)).hasMessageContaining("模拟发车故障"); + assertThat(evidence.snapshots(root.getId())).isEmpty();assertThat(f.repository.find(number).getRecordVersion()).isEqualTo(root.getRecordVersion()); + assertThat(f.repository.find(number).getStage()).isEqualTo("LOADING");assertThat(f.repository.find(number).getShippedPackageCount()).isZero(); + doNothing().when(fleet).departPhotoDispatchShell(eq(number),anyString(),any(),anyString()); + assertThat(service.save(f.actor,number,item.getId(),DispatchLooseboardFacts.Phase.PICKUP,request).stage()).isEqualTo("DELIVERING"); + assertThat(evidence.snapshots(root.getId())).hasSize(1);assertThat(f.repository.find(number).getPickedPackageCount()).isZero(); + service.save(f.actor,number,item.getId(),DispatchLooseboardFacts.Phase.PICKUP,request); + assertThat(evidence.snapshots(root.getId())).hasSize(1); + // 追加与删除采用新的不可变列表快照;删除失败回滚时,原两张照片仍可读取。 + when(files.metadata(anyLong(),any(),eq(78L))).thenReturn(new DispatchEvidenceGateway.File(78L,"extra.png","image/png",10)); + long version=f.repository.find(number).getRecordVersion(); + service.save(f.actor,number,item.getId(),DispatchLooseboardFacts.Phase.PICKUP,new DispatchLooseboardExecutionService.Save(version,"pickup-append-photos",List.of(77L,78L))); + long appended=f.repository.find(number).getRecordVersion(); + var deletion=new DispatchLooseboardExecutionService.Save(appended,"pickup-delete-photo",List.of(78L)); + assertThatThrownBy(()->f.tx.execute(s->{service.save(f.actor,number,item.getId(),DispatchLooseboardFacts.Phase.PICKUP,deletion);throw new IllegalStateException("模拟删除后故障");})).hasMessageContaining("模拟删除后故障"); + assertThat(service.get(f.actor,number).items().get(0).pickupPhotos()).extracting(DispatchEvidenceGateway.File::fileId).containsExactly(77L,78L); + assertThat(f.repository.find(number).getRecordVersion()).isEqualTo(appended); + service.save(f.actor,number,item.getId(),DispatchLooseboardFacts.Phase.PICKUP,deletion); + assertThat(service.get(f.actor,number).items().get(0).pickupPhotos()).extracting(DispatchEvidenceGateway.File::fileId).containsExactly(78L); + assertThat(evidence.snapshots(root.getId())).hasSize(3); + assertThatThrownBy(()->service.content(f.actor,number,item.getId(),DispatchLooseboardFacts.Phase.PICKUP,77)).hasMessageContaining("未绑定"); + assertThat(f.repository.find(number).getStage()).isEqualTo("DELIVERING"); + assertThat(f.repository.find(number).getShippedPackageCount()).isEqualTo(3); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java index 4dd90d938..b5a84ace9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java @@ -229,7 +229,7 @@ class DispatchMergedRelabelPersistenceTest { var schema=Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql")); for(var table:List.of("ll_order","ll_inventory_position","ll_inventory_balance","ll_inventory_ledger","ll_inventory_operation","ll_physical_pallet_guard", "ll_relabel_application","ll_relabel_item","ll_relabel_pda_task","ll_relabel_pda_history","ll_relabel_pda_unit_fact","ll_inbound_plan","ll_inbound_plan_pallet", - "ll_inbound_stock_receipt","ll_dispatch_item_source","ll_dispatch_physical_pallet_lock","ll_dispatch_pallet_lock","ll_dispatch_pda_task","ll_dispatch_pda_pick","ll_dispatch_pda_pick_order","ll_cargo_discard_carton","ll_warehouse_transfer_inventory_allocation","ll_warehouse_transfer_order","ll_warehouse_transfer_pallet", + "ll_inbound_stock_receipt","ll_dispatch_item_source","ll_dispatch_physical_pallet_lock","ll_dispatch_pallet_lock","ll_dispatch_pda_task","ll_dispatch_pda_pick","ll_dispatch_pda_pick_order","ll_cargo_discard_carton","ll_warehouse_transfer_inventory_allocation","ll_warehouse_transfer_order","ll_local_looseboard_order","ll_warehouse_transfer_pallet", "ll_inbound_plan_line","ll_inbound_plan_pallet_allocation","ll_inbound_plan_carton","ll_order_detail","ll_inbound_order","ll_inbound_fba_appointment","ll_inbound_fba_appointment_item","ll_order_manual_node","ll_container_shipping_gate", "ll_return_inventory_carton","ll_return_receipt","ll_return_stock_receipt","ll_exception_order","ll_delivery_order")) { f.db.execute("DROP TABLE IF EXISTS "+table);int start=schema.indexOf("CREATE TABLE "+table+" (");int end=schema.indexOf(") ENGINE=",start); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRegionalCandidatesTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRegionalCandidatesTest.java index e970d4f15..49a3f81e4 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRegionalCandidatesTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRegionalCandidatesTest.java @@ -19,6 +19,7 @@ class DispatchRegionalCandidatesTest { var service=new DispatchAggregateService(null,null,sources,null,null,null,new com.fasterxml.jackson.databind.ObjectMapper()); org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class)); ReflectionTestUtils.setField(service,"appointments",appointments); + ReflectionTestUtils.setField(service,"looseboardDisplay",new DispatchLooseboardDisplayService(mock(com.baiyue.lastleg.dispatch.repository.LooseboardCargoRepository.class))); var actor=new Actor("7",ActorMode.PLATFORM,null); when(sources.candidatePage(eq("7"),any())).thenReturn(new DispatchService.CandidatePage(List.of(),41)); when(sources.selectionGroups(List.of())).thenReturn(Map.of());when(appointments.candidates(List.of())).thenReturn(Map.of()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java index 4db465dd5..7504f3a3a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java @@ -117,6 +117,18 @@ class DispatchServiceTest { @Test void sourceIdentityCannotCombinePlanAndReturnReceipt(){ assertThatThrownBy(()->DispatchService.wholeSourceKey(new DispatchCommands.CargoSelection(DispatchTypes.SourceType.INBOUND_PLAN,1L,2L,null,501L,"v"))).hasMessageContaining("来源字段"); } + /** 未到仓计划只锁真实订单和客户计划;空实托集合不进入库存串行器。 */ + @Test void plannedSourceLockRequiresOwnershipWithoutPhysicalStock(){ + var item=new com.baiyue.lastleg.dispatch.entity.DispatchItem(); + item.setCustomerId("101");item.setOrderId(901L);item.setPlanPalletId(11L); + when(sources.lockOrder("101",901L)).thenReturn(901L); + when(sources.lockPlan("101",11L)).thenReturn(11L); + service.lockAppointmentSources("9",List.of(item)); + verify(sources).lockOrder("101",901L);verify(sources).lockPlan("101",11L); + verifyNoInteractions(palletSerial,facts); + when(sources.lockPlan("101",11L)).thenReturn(null); + assertThatThrownBy(()->service.lockAppointmentSources("9",List.of(item))).hasMessageContaining("归属变化"); + } private static DispatchPalletCandidate row(long plan,long pallet,String customer){return new DispatchPalletCandidate().planPalletId(plan).warehousePalletId(pallet).customerId(customer).overseasWarehouseId("9").sourceStage("RECEIVED").packageCount(3).orderNo("ORDER-"+plan).palletNo("PALLET-"+pallet);} /** 换标生产者没有计划托盘别名,始终提供真实申请业务号与实托。 */ private static DispatchPalletCandidate relabelRow(long pallet){return row(0,pallet,"101").planPalletId(null).sourceStage("RELABEL").orderRefs("RL-1");} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/DeliveryReturnCargoMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/DeliveryReturnCargoMapperTest.java new file mode 100644 index 000000000..92004b1a5 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/DeliveryReturnCargoMapperTest.java @@ -0,0 +1,46 @@ +package com.baiyue.lastleg.exceptionorder.service; + +import com.baiyue.lastleg.mapper.DeliveryReturnCargoMapper; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.*; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import java.util.UUID; +import static org.assertj.core.api.Assertions.*; + +/** 使用真实Mapper及隔离数据库核对来源关联、部分箱数与客户隔离。 */ +class DeliveryReturnCargoMapperTest { + /** 单一来源带出SKU品名及精确数量;多个不明SKU不得复制整票数据。 */ + @Test void realMapperReadsUniqueSourceAndDoesNotGuessMixedSku(){ + var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");var db=new JdbcTemplate(ds); + db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(20),order_type VARCHAR(32),source_region VARCHAR(32),source_inbound_id VARCHAR(32),inbound_code VARCHAR(50))"); + db.execute("CREATE TABLE ll_exception_order(id BIGINT,customer_id VARCHAR(20),last_mile_order_id BIGINT)"); + db.execute("CREATE TABLE ll_exception_return_item(id BIGINT,customer_id VARCHAR(20),exception_order_id BIGINT,inventory_position_id BIGINT,sku_code VARCHAR(50),approved_return_packages BIGINT,received_packages BIGINT)"); + db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(20),balance_id BIGINT)"); + db.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR(20),inbound_code VARCHAR(50),sku_code VARCHAR(50),product_name_zh VARCHAR(50),product_name_en VARCHAR(50))"); + db.execute("CREATE TABLE ll_order_detail(id BIGINT,customer_id VARCHAR(20),order_id BIGINT,sku_code VARCHAR(50),product_name_zh VARCHAR(50),product_name_en VARCHAR(50),package_count INT,product_quantity INT,gross_weight_kg DECIMAL(18,3),net_weight_kg DECIMAL(18,3),volume_cbm DECIMAL(18,6),shipping_mark VARCHAR(50),shipment_no VARCHAR(50),fba_tracking_no VARCHAR(50),source_inbound_detail_id VARCHAR(50))"); + db.update("INSERT INTO ll_order VALUES(10,'C1','DELIVERY_RETURN','EXCEPTION_RETURN','1',NULL),(9,'C1','WAREHOUSE_TRANSFER','CUSTOMER',NULL,'IN9')"); + db.update("INSERT INTO ll_exception_order VALUES(1,'C1',9)");db.update("INSERT INTO ll_exception_return_item VALUES(2,'C1',1,3,NULL,1,0)"); + db.update("INSERT INTO ll_inventory_position VALUES(3,'C1',4)");db.update("INSERT INTO ll_inventory_balance VALUES(4,'C1','IN9',NULL,NULL,NULL)"); + db.update("INSERT INTO ll_order_detail VALUES(5,'C1',9,'SKU1','测试货物',NULL,2,4,4,3,0.02,NULL,NULL,NULL,NULL)"); + var config=new Configuration(new Environment("test",new JdbcTransactionFactory(),ds));config.setMapUnderscoreToCamelCase(true);config.addMapper(DeliveryReturnCargoMapper.class); + try(var session=new SqlSessionFactoryBuilder().build(config).openSession()){ + var mapper=session.getMapper(DeliveryReturnCargoMapper.class);var rows=mapper.cargo("C1",10); + assertThat(rows).hasSize(1);assertThat(rows.get(0).getSkuCode()).isEqualTo("SKU1");assertThat(rows.get(0).getProductNameZh()).isEqualTo("测试货物"); + assertThat(rows.get(0).getPackageCount()).isEqualTo(1);assertThat(rows.get(0).getProductQuantity()).isEqualTo(2); + assertThat(rows.get(0).getGrossWeightKg()).isEqualByComparingTo("2");assertThat(rows.get(0).getVolumeCbm()).isEqualByComparingTo("0.01"); + assertThat(mapper.cargo("C2",10)).isEmpty(); + // 生成时保存待退箱数,后续实收不能使详情数量随剩余量缩水。 + db.update("INSERT INTO ll_order_detail VALUES(7,'C1',10,NULL,NULL,NULL,1,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'2')"); + db.update("UPDATE ll_exception_return_item SET received_packages=1 WHERE id=2");session.clearCache(); + assertThat(mapper.cargo("C1",10).get(0).getPackageCount()).isEqualTo(1); + // 多张来源订单共用旧入仓码时,仍只能返回一条冻结退回货物。 + db.update("INSERT INTO ll_order VALUES(8,'C1','FIRST_LEG','CN',NULL,'IN9')");session.clearCache(); + assertThat(mapper.cargo("C1",10)).hasSize(1); + db.update("INSERT INTO ll_order_detail VALUES(6,'C1',9,'SKU2','另一货物',NULL,2,8,6,5,0.03,NULL,NULL,NULL,NULL)");session.clearCache(); + var mixed=mapper.cargo("C1",10).get(0);assertThat(mixed.getSkuCode()).isNull();assertThat(mixed.getProductNameZh()).isNull();assertThat(mixed.getProductQuantity()).isNull(); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderLifecycleTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderLifecycleTest.java index 66d0bd779..705ba6643 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderLifecycleTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderLifecycleTest.java @@ -164,6 +164,21 @@ class ExceptionOrderLifecycleTest { assertThat(item.getValue().getReceivedPackages()).isEqualTo(1); } + /** 已建计划的退回单按真实实收推进,分批不能退回未计划状态。 */ + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(ints = {1, 4}) + void linkedPlanTracksActualPartialOrCompleteReceipt(int amount) { + partialReturn(4, 10, amount); + var workflow=(com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository)ReflectionTestUtils.getField(service,"workflow"); + var tail=new com.baiyue.lastleg.entity.LastlegOrder();tail.setId(99L);tail.setVersion(2);tail.setInboundOrderNo("IP1"); + when(workflow.returnOrder(any())).thenReturn(tail); + service.complete("1001", "9001", "EX-1", receipt(amount)); + assertThat(tail.getCurrentStatus()).isEqualTo(amount==4?"INBOUND_COMPLETED":"PARTIAL_INBOUND"); + assertThat(tail.getReceivedPackageCount()).isEqualTo(amount); + assertThat(tail.getInboundOrderNo()).isEqualTo("IP1"); + verify(workflow).update(tail); + } + @Test void fullyReceivingAPartialReturnCaseDoesNotCloseTheRemainingDeliveryCargo() { var delivery = partialReturn(2, 10, 2); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnInboundServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnInboundServiceTest.java index bdb489da1..79e454408 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnInboundServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnInboundServiceTest.java @@ -38,7 +38,7 @@ class ExceptionReturnInboundServiceTest { var generated=service.generate("C1","7","EX1",new ExceptionOrderRequests.StartProcessing(0,"key",null)); assertThat(generated.getSourceOrderNo()).isEqualTo("WC9");assertThat(generated.getSourceInboundId()).isEqualTo("1"); assertThat(generated.getPackageCount()).isEqualTo(2);assertThat(generated.getReceivedPackageCount()).isZero(); - assertThat(generated.getCurrentStatus()).isEqualTo("PENDING_INBOUND");assertThat(generated.getInboundCode()).isNull(); + assertThat(generated.getCurrentStatus()).isEqualTo("PENDING_INBOUND");assertThat(generated.getInboundCode()).isEqualTo("WC10"); when(repository.returnOrder(exception)).thenReturn(generated); assertThat(service.generate("C1","7","EX1",new ExceptionOrderRequests.StartProcessing(0,"retry",null))).isSameAs(generated); verify(repository,times(1)).insert(any()); verify(repository).copyDetails(generated,exception); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnPlanServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnPlanServiceTest.java new file mode 100644 index 000000000..d1c8d13ee --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnPlanServiceTest.java @@ -0,0 +1,73 @@ +package com.baiyue.lastleg.exceptionorder.service; + +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderCommandRecord; +import com.baiyue.lastleg.exceptionorder.dto.ExceptionReturnPlanRequest; +import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrder; +import com.baiyue.lastleg.exceptionorder.repository.*; +import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest; +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.inboundplan.service.InboundPlanService; +import com.baiyue.lastleg.service.LastlegOrderNoGenerator; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import java.time.LocalDateTime; +import static org.mockito.Mockito.*; +import static org.assertj.core.api.Assertions.*; + +/** 异常退回计划不依赖来源柜,并保持版本、客户及原键边界。 */ +class ExceptionReturnPlanServiceTest { + final ExceptionReturnPlanRepository repository=mock(ExceptionReturnPlanRepository.class); + final ExceptionWorkflowRepository exceptions=mock(ExceptionWorkflowRepository.class); + final WarehouseResourceGateway warehouse=mock(WarehouseResourceGateway.class); + final InboundPlanService plans=mock(InboundPlanService.class); + final LastlegOrderNoGenerator numbers=mock(LastlegOrderNoGenerator.class); + final ExceptionReturnPlanService service=new ExceptionReturnPlanService(repository,exceptions,warehouse,plans,numbers,new ObjectMapper().findAndRegisterModules()); + final LocalDateTime arrival=LocalDateTime.of(2026,11,5,14,30); + /** 请求只带本单、版本和到仓时间,不构造不存在的柜号。 */ + ExceptionReturnPlanRequest request(){return new ExceptionReturnPlanRequest(new ManualInboundPlanRequest("C1",10L,0,1,"WOOD",false,false,false,"退回计划"),arrival);} + /** 准备合法且尚未收货的异常及退回单。 */ + LastlegOrder ready(){ + var order=new LastlegOrder();order.setId(10L);order.setCustomerId("C1");order.setOrderNo("WC10"); + order.setOrderType("DELIVERY_RETURN");order.setSourceRegion("EXCEPTION_RETURN");order.setSourceEventId("EX1");order.setSourceInboundId("1"); + order.setCurrentStatus("PENDING_INBOUND");order.setOverseasWarehouseId("11");order.setVersion(0);order.setPackageCount(1); + when(repository.find("C1",10)).thenReturn(order);when(repository.lock("C1",10)).thenReturn(order); + var exception=new ExceptionOrder();exception.setId(1L);exception.setStatus("PROCESSING");exception.setReturnDirection("OVERSEAS_WAREHOUSE");exception.setOverseasWarehouseId("11"); + when(exceptions.lock("C1","EX1")).thenReturn(exception); + when(warehouse.requireActiveSharedWarehouse(11)).thenReturn(new WarehouseResourceGateway.WarehouseReference(11,"LAX","洛杉矶仓","ACTIVE")); + when(numbers.next("INBOUND_PLAN")).thenReturn("IP1");return order; + } + /** 无来源柜仍生成计划,保留新旧订单关系且不调用任何库存接口。 */ + @Test void standaloneReturnCreatesLinkedPlanWithExplicitArrival(){ + var order=ready();service.create(request(),"key","7"); + var plan=ArgumentCaptor.forClass(InboundPlan.class);verify(repository).insert(plan.capture(),any()); + assertThat(plan.getValue().getSourceOrderId()).isEqualTo(10L);assertThat(plan.getValue().getContainerNo()).isEmpty(); + assertThat(plan.getValue().getPlannedArrivalDate()).isEqualTo(arrival.toLocalDate());assertThat(plan.getValue().getCreationMode()).isEqualTo("EXCEPTION_RETURN"); + assertThat(plan.getValue().getStatus()).isEqualTo("PENDING_RECEIVING"); + assertThat(order.getEstimatedWarehouseAt()).isEqualTo(arrival);assertThat(order.getInboundCode()).isEqualTo("WC10"); + assertThat(order.getInboundOrderNo()).isEqualTo("IP1");assertThat(order.getCurrentStatus()).isEqualTo("INBOUND_PLANNED"); + assertThat(order.getReceivedPackageCount()).isNull();verify(warehouse).requireActiveSharedWarehouse(11);verifyNoMoreInteractions(warehouse); + } + /** 预计到仓为空不能建单,跨客户目标在查不到订单时失败关闭。 */ + @Test void missingArrivalAndForeignOrderNeverWrite(){ + assertThatThrownBy(()->service.create(new ExceptionReturnPlanRequest(request().plan(),null),"key","7")).hasMessageContaining("预计到仓"); + assertThatThrownBy(()->service.create(request(),"key","7")).hasMessageContaining("不存在");verify(repository,never()).insert(any(InboundPlan.class),any()); + } + /** 本单版本或异常目标仓变化均不能写计划。 */ + @Test void staleVersionAndChangedWarehouseNeverWrite(){ + var order=ready();order.setVersion(1); + assertThatThrownBy(()->service.create(request(),"key","7")).hasMessageContaining("版本"); + order.setVersion(0);order.setOverseasWarehouseId("12"); + assertThatThrownBy(()->service.create(request(),"key","7")).hasMessageContaining("目标仓");verify(repository,never()).insert(any(InboundPlan.class),any()); + } + /** 原键重放只恢复计划;变更到仓时间必须拒绝,不能多建单。 */ + @Test void replayRestoresOnlyOriginalRequest(){ + ready();service.create(request(),"key","7");var command=ArgumentCaptor.forClass(LastlegOrderCommandRecord.class);verify(repository).insert(command.capture()); + when(repository.command("C1",10,"7","key")).thenReturn(command.getValue()); + service.create(request(),"key","7"); + assertThatThrownBy(()->service.create(new ExceptionReturnPlanRequest(request().plan(),arrival.plusDays(1)),"key","7")).hasMessageContaining("幂等键"); + verify(repository,times(1)).insert(any(InboundPlan.class),any()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java index dc8fe4a36..9570fcd6f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java @@ -98,6 +98,17 @@ class InboundPdaWorkflowServiceTest { lenient().when(orders.selectById(1001L)).thenReturn(sourceOrder); } + /** 异常退回计划只允许原异常验收,不能产生第二笔普通入库库存。 */ + @Test + void exceptionReturnCannotPushOrdinaryInboundTask() { + plan.setCreationMode("EXCEPTION_RETURN"); + var error=assertThrows(ResponseStatusException.class, + () -> service.push("100", "LIP1", new PdaRequests.Push("return-plan", "7", "worker"))); + assertTrue(error.getReason().contains("异常")); + verify(repository, never()).insert(any(InboundPdaTask.class)); + org.mockito.Mockito.verifyNoInteractions(stockReceipts, warehouse); + } + @Test void pushRequiresEveryPalletToBeAssignedAndLocked() { targetPallet.setWarehouseResourceLockId(null); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBindingTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBindingTest.java index f651e6a98..4fe95cbe2 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBindingTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBindingTest.java @@ -125,6 +125,22 @@ class AppointmentWorkDispatchBindingTest { when(mapper.pickedPhysical(30,"C")).thenReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"C","CUSTOMER:1",null,4))); assertDoesNotThrow(()->service.requireDeparture("D-20"));verifyNoInteractions(appointments); } + /** 照片直送核对计划逐票数量,普通发车仍要求实拣且不能借其他客户补足。 */ + @Test void photoDepartureUsesOriginalPlannedSourcesWithoutInventingPicking() { + var mapper=mock(AppointmentWorkDispatchMapper.class);var sources=mock(AppointmentSourceService.class); + var service=new AppointmentWorkDispatchBinding(mapper,mock(InboundFbaAppointmentService.class),sources,mock(AppointmentWorkChanges.class),mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class),mock(AppointmentWorkDispatchCandidates.class)); + when(mapper.dispatchPhysical("D")).thenReturn(List.of(new AppointmentWorkDispatchMapper.DispatchPhysical(30,10L,"C","PLAN",4))); + when(mapper.bindings("D")).thenReturn(List.of(new AppointmentWorkDispatchMapper.Bound(20,30,10,"DISPATCH:20","C","ORDER:1","CUSTOMER:1",4))); + when(mapper.physical(10,"C")).thenReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"C","CUSTOMER:1",4,null))); + when(sources.canonicalOrders(anyList())).thenReturn(List.of(new AppointmentCoverageService.SourceRef("C","ORDER:1",null))); + assertDoesNotThrow(()->service.requirePhotoDeparture("D")); + verify(mapper,never()).pickedPhysical(anyLong(),anyString()); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D")); + when(mapper.physical(10,"C")).thenReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"C","CUSTOMER:1",3,null))); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requirePhotoDeparture("D")); + when(mapper.bindings("D")).thenReturn(List.of(new AppointmentWorkDispatchMapper.Bound(20,30,10,"DISPATCH:20","OTHER","ORDER:1","CUSTOMER:1",4))); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requirePhotoDeparture("D")); + } @Test void partialPickingKeepsReviewEligibleButDepartureStillRequiresAllOriginalSources() { var mapper=mock(AppointmentWorkDispatchMapper.class);var sources=mock(AppointmentSourceService.class); var service=new AppointmentWorkDispatchBinding(mapper,mock(InboundFbaAppointmentService.class),sources,mock(AppointmentWorkChanges.class),mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class),mock(AppointmentWorkDispatchCandidates.class)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchPersistenceTest.java index 2b19e1100..b161c5776 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchPersistenceTest.java @@ -64,6 +64,8 @@ class AppointmentWorkDispatchPersistenceTest { jdbc.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(40),pallet_id BIGINT,balance_id BIGINT,available_packages INT,allocated_packages INT,picked_packages INT)"); jdbc.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR(40),inbound_code VARCHAR(128))"); jdbc.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(40),inbound_code VARCHAR(128),destination_type VARCHAR(30),fba_warehouse_code VARCHAR(40),order_no VARCHAR(40))"); + jdbc.execute("ALTER TABLE ll_order ADD order_type VARCHAR(40)"); + jdbc.execute("CREATE TABLE ll_warehouse_transfer_order(lastleg_order_id BIGINT,customer_id VARCHAR(40),destination_type VARCHAR(30),destination_warehouse_code VARCHAR(40))"); jdbc.execute("CREATE TABLE ll_dispatch_pda_pick(id BIGINT,customer_id VARCHAR(40),dispatch_item_id BIGINT)"); jdbc.execute("CREATE TABLE ll_dispatch_pda_pick_order(pick_id BIGINT,customer_id VARCHAR(40),order_id BIGINT,packages INT)"); jdbc.execute("CREATE TABLE ll_dispatch_appointment_allocation(dispatch_id BIGINT,dispatch_item_id BIGINT,plan_pallet_id BIGINT,batch_key VARCHAR(60),customer_id VARCHAR(40),source_key VARCHAR(40),destination_key VARCHAR(40),package_count INT)"); @@ -107,7 +109,7 @@ class AppointmentWorkDispatchPersistenceTest { jdbc.update("INSERT INTO ll_inventory_position VALUES(?,?,?,?,?,0,0)",item,customer,item+1000,item,count); jdbc.update("INSERT INTO ll_inventory_balance VALUES(?,?,?)",item,customer,"IN-"+order); if(jdbc.queryForObject("SELECT COUNT(*) FROM ll_order WHERE id=?",Integer.class,order)==0) - jdbc.update("INSERT INTO ll_order VALUES(?,?,?,?,'ONT8',?)",order,customer,"IN-"+order,destination,"WC-"+order); + jdbc.update("INSERT INTO ll_order(id,customer_id,inbound_code,destination_type,fba_warehouse_code,order_no) VALUES(?,?,?,?,'ONT8',?)",order,customer,"IN-"+order,destination,"WC-"+order); jdbc.update("INSERT INTO ll_inbound_plan_pallet(id,pallet_no,plan_id,customer_id,warehouse_pallet_id) VALUES(?,?,1,?,?)",item+100,"P-"+item,customer,item+1000); jdbc.update("INSERT INTO ll_inbound_plan_line VALUES(?,?,?,?, 'ONT8')",item,order,customer,destination); jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(?,?,?,?,?)",item+100,item,customer,count,count); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivedPhysicalTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivedPhysicalTest.java index ebedd553a..f883a04c9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivedPhysicalTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivedPhysicalTest.java @@ -18,9 +18,11 @@ class AppointmentWorkReceivedPhysicalTest { db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR,pallet_id BIGINT,balance_id BIGINT,available_packages INT,allocated_packages INT,picked_packages INT,hold_packages INT)"); db.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR,inbound_code VARCHAR)"); db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,inbound_code VARCHAR,destination_type VARCHAR,fba_warehouse_code VARCHAR)"); + db.execute("ALTER TABLE ll_order ADD order_type VARCHAR"); + db.execute("CREATE TABLE ll_warehouse_transfer_order(lastleg_order_id BIGINT,customer_id VARCHAR,destination_type VARCHAR,destination_warehouse_code VARCHAR)"); db.update("INSERT INTO ll_inbound_plan_pallet VALUES(10,'C',99)"); db.update("INSERT INTO ll_inventory_balance VALUES(1,'C','IN1'),(2,'C','IN2'),(3,'OTHER','IN1')"); - db.update("INSERT INTO ll_order VALUES(101,'C','IN1','FBA','ONT8'),(102,'C','IN2','PRIVATE',NULL),(201,'OTHER','IN1','FBA','ONT8')"); + db.update("INSERT INTO ll_order(id,customer_id,inbound_code,destination_type,fba_warehouse_code) VALUES(101,'C','IN1','FBA','ONT8'),(102,'C','IN2','PRIVATE',NULL),(201,'OTHER','IN1','FBA','ONT8')"); db.update("INSERT INTO ll_inventory_position VALUES(1,'C',99,1,0,1,0,5),(2,'C',99,2,1,0,1,0),(3,'OTHER',99,3,99,0,0,0),(4,'C',100,1,99,0,0,0)"); var config = new Configuration(new Environment("test", new JdbcTransactionFactory(), ds)); config.setMapUnderscoreToCamelCase(true); config.addMapper(AppointmentWorkDispatchMapper.class); @@ -33,6 +35,14 @@ class AppointmentWorkReceivedPhysicalTest { assertEquals("CUSTOMER:102",rows.stream().filter(r->r.orderId()==102).findFirst().orElseThrow().destinationKey()); assertTrue(rows.stream().allMatch(r->r.plannedPackages()==null && r.customerId().equals("C"))); assertTrue(mapper.receivedPhysical(10,"OTHER").isEmpty()); + // 转运来源以已审核转运目的地为准;缺少权威目的地继续拒绝,不能猜测。 + db.update("UPDATE ll_order SET order_type='WAREHOUSE_TRANSFER',destination_type=NULL WHERE id=102"); + db.update("INSERT INTO ll_warehouse_transfer_order VALUES(102,'OTHER','FBA','WRONG'),(102,'C','PRIVATE',NULL)"); + session.clearCache(); + assertEquals("CUSTOMER:102",mapper.receivedPhysical(10,"C").stream().filter(r->r.orderId()==102).findFirst().orElseThrow().destinationKey()); + db.update("UPDATE ll_warehouse_transfer_order SET destination_type=NULL WHERE customer_id='C'"); + session.clearCache(); + assertNull(mapper.receivedPhysical(10,"C").stream().filter(r->r.orderId()==102).findFirst().orElseThrow().destinationKey()); db.update("UPDATE ll_inventory_position SET available_packages=0,picked_packages=0 WHERE id=2"); session.clearCache(); rows=mapper.receivedPhysical(10,"C"); assertEquals(1,rows.size()); assertEquals(1,rows.get(0).countedPackages()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardRequestsTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardRequestsTest.java index 69bdddd0a..289c5a7c9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardRequestsTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardRequestsTest.java @@ -11,14 +11,14 @@ import java.util.List; import static org.junit.jupiter.api.Assertions.*; class LocalLooseboardRequestsTest { - /** 验证真实 Bean Validation:新增和编辑均要求四项联系人及两个时间。 */ + /** 验证真实 Bean Validation:新增和编辑均要求取货模式要求起运资料、目的地联系人及两个时间。 */ @Test void requiresContactsAndBothLocalTimes() throws Exception { try (var factory = Validation.buildDefaultValidatorFactory()) { var validator = factory.getValidator(); var json = new ObjectMapper().findAndRegisterModules(); ObjectNode valid = json.valueToTree(request()); assertTrue(validator.validate(request()).isEmpty()); - for (String field : List.of("originContactName", "originContactPhone", "destinationContactName", "destinationContactPhone", "pickupAt", "deliveryAt")) { + for (String field : List.of("origin", "originAddress", "originContactName", "originContactPhone", "destinationContactName", "destinationContactPhone", "pickupAt", "deliveryAt")) { ObjectNode missing = valid.deepCopy(); missing.remove(field); assertFalse(validator.validate(json.treeToValue(missing, LocalLooseboardRequests.Create.class)).isEmpty(), field); missing.remove("idempotencyKey"); missing.put("version", 0); @@ -44,7 +44,7 @@ class LocalLooseboardRequestsTest { /** 创建具有独立预计托数和件数的最小完整需求。 */ private LocalLooseboardRequests.Create request() { - return new LocalLooseboardRequests.Create("LA", null, "ONT", null, "货物", 3, 8, + return new LocalLooseboardRequests.Create("9", "FBA", null, "WAREHOUSE_PICKUP", null, null, null, null, "LA", "123 Test Road", "ONT", null, "货物", 3, 8, BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", "发货人", "1", "收货人", "2", LocalDateTime.of(2030, 1, 2, 10, 30), LocalDateTime.of(2030, 1, 2, 15, 45), null, "create-1"); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/controller/PlatformLocalLooseboardControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/controller/PlatformLocalLooseboardControllerTest.java index f153bae1c..c10e26d2d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/controller/PlatformLocalLooseboardControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/controller/PlatformLocalLooseboardControllerTest.java @@ -41,14 +41,14 @@ class PlatformLocalLooseboardControllerTest { @Test void updateRequiresIndependentEditPermission() { var v = create(); - var request = new LocalLooseboardRequests.Update(0L, v.origin(), v.originAddress(), v.destination(), v.destinationAddress(), v.productName(), v.estimatedPalletCount(), v.packageCount(), v.totalWeightKg(), v.totalVolumeCbm(), v.budgetAmount(), v.currency(), v.originContactName(), v.originContactPhone(), v.destinationContactName(), v.destinationContactPhone(), v.pickupAt(), v.deliveryAt(), v.remark()); + var request = new LocalLooseboardRequests.Update("9", "FBA", null, "WAREHOUSE_PICKUP", null, null, null, null, 0L, v.origin(), v.originAddress(), v.destination(), v.destinationAddress(), v.productName(), v.estimatedPalletCount(), v.packageCount(), v.totalWeightKg(), v.totalVolumeCbm(), v.budgetAmount(), v.currency(), v.originContactName(), v.originContactPhone(), v.destinationContactName(), v.destinationContactPhone(), v.pickupAt(), v.deliveryAt(), v.remark()); ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> controller.update(user(Set.of(OverseasPermissions.LOOSEBOARD_CREATE)), "100", "LB1", request)); assertEquals(403, error.getStatusCode().value()); } private LocalLooseboardRequests.Create create() { - return new LocalLooseboardRequests.Create("LA", null, "Ontario", null, "货物", 1, 1, BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", "A", "1", "B", "2", java.time.LocalDateTime.of(2030, 1, 2, 9, 30), java.time.LocalDateTime.of(2030, 1, 2, 15, 30), null, "key-1"); + return new LocalLooseboardRequests.Create("9", "FBA", null, "WAREHOUSE_PICKUP", null, null, null, null, "LA", null, "Ontario", null, "货物", 1, 1, BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", "A", "1", "B", "2", java.time.LocalDateTime.of(2030, 1, 2, 9, 30), java.time.LocalDateTime.of(2030, 1, 2, 15, 30), null, "key-1"); } private AuthenticatedUser user(Set permissions) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java index 6d46b625a..6a6ec0b89 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java @@ -192,14 +192,14 @@ class LocalLooseboardServiceTest { } private LocalLooseboardRequests.Create create(String key) { - return new LocalLooseboardRequests.Create("Los Angeles", "Address A", "Ontario", "Address B", + return new LocalLooseboardRequests.Create("9", "FBA", null, "WAREHOUSE_PICKUP", null, null, null, null, "Los Angeles", "Address A", "Ontario", "Address B", "家居用品", 3, 12, new BigDecimal("100.000"), new BigDecimal("2.500000"), new BigDecimal("500.00"), "USD", "Alice", "+1-555-0100", "Bob", "+1-555-0101", LocalDateTime.of(2030, 1, 2, 9, 30), LocalDateTime.of(2030, 1, 2, 10, 30), null, key); } private LocalLooseboardRequests.Update update(long version) { var v = create("unused"); - return new LocalLooseboardRequests.Update(version, v.origin(), v.originAddress(), v.destination(), v.destinationAddress(), + return new LocalLooseboardRequests.Update("9", "FBA", null, "WAREHOUSE_PICKUP", null, null, null, null, version, v.origin(), v.originAddress(), v.destination(), v.destinationAddress(), v.productName(), v.estimatedPalletCount(), v.packageCount(), v.totalWeightKg(), v.totalVolumeCbm(), v.budgetAmount(), v.currency(), v.originContactName(), v.originContactPhone(), v.destinationContactName(), v.destinationContactPhone(), v.pickupAt(), v.deliveryAt(), v.remark()); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java index b0f77f613..ea68f5904 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java @@ -116,7 +116,7 @@ class TaillegRealConsumerAcceptanceTest { createTransfer(sourceOrderId, inboundCode, true, "RETURN-RELABEL"); if (!exists("ll_local_looseboard_order", MARKER + "-LOOSEBOARD-KEY")) { - looseboard.create(CUSTOMER, "real-acceptance", new LocalLooseboardRequests.Create( + looseboard.create(CUSTOMER, "real-acceptance", new LocalLooseboardRequests.Create("9", "FBA", null, "WAREHOUSE_PICKUP", null, null, null, null, "洛杉矶海外仓", "Los Angeles", "ONT8", "FBA ONT8", "验收货物", 2, 3, new BigDecimal("30.00"), new BigDecimal("0.40"), new BigDecimal("120.00"), "USD", "验收联系人", "13800000000", "验收收货人", "13800000001", REQUESTED_DATE.atTime(9, 30), REQUESTED_DATE.atTime(15, 30), diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/LooseboardPlannedCargoServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/LooseboardPlannedCargoServiceTest.java new file mode 100644 index 000000000..8010371df --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/LooseboardPlannedCargoServiceTest.java @@ -0,0 +1,57 @@ +package com.baiyue.lastleg.transfer.service; + +import com.baiyue.lastleg.transfer.looseboard.*; +import com.baiyue.lastleg.transfer.entity.*; +import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder; +import com.baiyue.lastleg.inboundplan.entity.*; +import com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway; +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import static org.assertj.core.api.Assertions.*; + +class LooseboardPlannedCargoServiceTest { + /** 预排不绑定实托、不填写实收数量;重试不重复创建。 */ + @Test void approvalCreatesOnlyLogicalCargo() { + var f=new Fixture(); var order=new LocalLooseboardOrder();order.setCustomerId("A");order.setEstimatedPalletCount(2);order.setPackageCount(5);order.setTotalVolumeCbm(new BigDecimal("1.0001")); + f.service.prepare(order,"ZY1"); + assertThat(f.pallets).hasSize(2).allSatisfy(p->{assertThat(p.getWarehousePalletId()).isNull();assertThat(p.getActualPackageCount()).isNull();}); + assertThat(f.allocations.values()).extracting(InboundPlanPalletAllocation::getPlannedPackageCount).containsExactlyInAnyOrder(3,2); + assertThat(f.allocations.values()).allSatisfy(a->{assertThat(a.getCountedPackageCount()).isNull();assertThat(a.getPickedPackageCount()).isNull();}); + assertThat(f.pallets.stream().map(InboundPlanPallet::getAllocatedVolumeCbm).reduce(BigDecimal.ZERO,BigDecimal::add)).isEqualByComparingTo("1.0001"); + f.service.prepare(order,"ZY1"); assertThat(f.pallets).hasSize(2);verifyNoInteractions(f.dispatch); + } + /** 实收少一托时原ID保留,明确清零的空来源由正式交接释放。 */ + @Test void receiptKeepsOriginalSourceIdsAndReconcilesShortage() { + var f=new Fixture();var order=new LocalLooseboardOrder();order.setCustomerId("A");order.setEstimatedPalletCount(2);order.setPackageCount(5);order.setTotalVolumeCbm(BigDecimal.ONE); + f.service.prepare(order,"ZY1");var ids=f.pallets.stream().map(InboundPlanPallet::getId).toList(); + var actual=new WarehouseTransferPallet();actual.setId(77L);actual.setSequenceNo(1);actual.setPalletNo("ACTUAL");actual.setWarehousePalletId(900L);actual.setCartonCount(4);actual.setVolumeCbm(new BigDecimal("0.8")); + var now=LocalDateTime.now();f.service.lockReceipt("ZY1","9");assertThat(f.service.complete(f.transfer,List.of(actual),"operator",now)).isTrue(); + assertThat(f.pallets.stream().map(InboundPlanPallet::getId).toList()).isEqualTo(ids); + assertThat(f.pallets.get(0).getWarehousePalletId()).isEqualTo(900L);assertThat(f.pallets.get(0).getActualPackageCount()).isEqualTo(4); + assertThat(f.pallets.get(1).getWarehousePalletId()).isNull();assertThat(f.pallets.get(1).getActualPackageCount()).isZero(); + assertThat(f.allocations.get(ids.get(1)).getCountedPackageCount()).isZero();assertThat(f.plan.getStatus()).isEqualTo("COMPLETED"); + var sequence=inOrder(f.dispatch);sequence.verify(f.dispatch).lock(List.of(1L));sequence.verify(f.dispatch).complete(List.of(1L),"operator",now); + } + /** 纯普通转运没有散板预排时继续走原收货链。 */ + @Test void ordinaryTransferDoesNotInvokePlannedHandoff() { + var f=new Fixture();assertThat(f.service.complete(f.transfer,List.of(),"operator",LocalDateTime.now())).isFalse();verifyNoInteractions(f.dispatch); + } + /** 测试仅隔离持久化,验证真实服务的计划及实收行为。 */ + static class Fixture { + final LooseboardPlannedCargoRepository repository=mock(LooseboardPlannedCargoRepository.class); + final InboundDispatchReceiptGateway dispatch=mock(InboundDispatchReceiptGateway.class); + final LooseboardPlannedCargoService service=new LooseboardPlannedCargoService(repository,dispatch); + final WarehouseTransferOrder transfer=new WarehouseTransferOrder();final InboundPlan plan=new InboundPlan();final InboundPlanLine line=new InboundPlanLine(); + final List pallets=new ArrayList<>(); final Map allocations=new LinkedHashMap<>(); + Fixture() { + transfer.setCustomerId("A");transfer.setLastlegOrderId(3L);plan.setId(1L);plan.setPlanNo("WT-ZY1");plan.setCustomerId("A");line.setId(2L);line.setDestinationType("PRIVATE");line.setDeliveryAddress("Test"); + when(repository.transfer("A","ZY1")).thenReturn(transfer);when(repository.transferAt("ZY1","9")).thenReturn(transfer);when(repository.plan(transfer)).thenReturn(plan);when(repository.line(plan)).thenReturn(line); + when(repository.pallets(plan)).thenAnswer(a->List.copyOf(pallets));when(repository.allocation(any())).thenAnswer(a->allocations.get(((InboundPlanPallet)a.getArgument(0)).getId())); + doAnswer(a->{var p=(InboundPlanPallet)a.getArgument(0);pallets.add(p);allocations.put(p.getId(),a.getArgument(1));return null;}).when(repository).insert(any(),any());when(repository.update(any(),any())).thenReturn(true); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java index 9f1d11347..f552a3f42 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java @@ -288,6 +288,7 @@ class WarehouseTransferServiceTest { ReflectionTestUtils.setField(service, "inventory", inventory); ReflectionTestUtils.setField(service, "inventoryCommands", inventoryCommands); ReflectionTestUtils.setField(service, "relabelService", relabelService); + ReflectionTestUtils.setField(service, "looseboardPlannedCargo", mock(com.baiyue.lastleg.transfer.looseboard.LooseboardPlannedCargoService.class)); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); lenient().doReturn("ZY202610200001").when(orderNoGenerator).next(anyString()); lenient().when(inventoryAllocations.selectList(any())).thenReturn(List.of()); @@ -603,6 +604,7 @@ class WarehouseTransferServiceTest { assertEquals("ONT8", transfer.getValue().getFbaWarehouseCode()); assertNull(transfer.getValue().getFbaShipmentNo()); assertEquals("ONT8", tailOrder.getValue().getFbaWarehouseCode()); + assertEquals("FBA", tailOrder.getValue().getDestinationType()); assertEquals("INVENTORY_TRANSFER", transfer.getValue().getDeliveryMethod()); assertNull(transfer.getValue().getEstimatedArrivalAt()); } @@ -639,6 +641,7 @@ class WarehouseTransferServiceTest { "FBA", null, "ONT8", "ONT8", null, null, null, null, "BIND-1")); assertEquals(WarehouseTransferService.READY_FOR_DISPATCH, transfer.getStatus()); + assertEquals("FBA", order.getDestinationType()); assertEquals("WAIT_PRE_DISPATCH", relabel.getFulfillmentStatus()); verify(relabelApplications).updateById(relabel); } @@ -747,6 +750,20 @@ class WarehouseTransferServiceTest { verify(transportTasks).ensureApprovedPickup("TENANT-A", "WTR-1", "USER-1"); } + /** 散板货源审核不另建到仓取货车,实际承运排车以取货照片执行。 */ + @Test void approvedLooseboardSourceDoesNotCreateSecondPickupDispatch() { + var transfer=persistedTransfer();transfer.setInboundCode("WTR-1");transfer.setOperatingWarehouseId("77");transfer.setDeliveryMethod("WAREHOUSE_PICKUP"); + transfer.setEstimatedArrivalAt(LocalDateTime.of(2026,9,17,10,0)); + var tail=tailOrder();tail.setCurrentStatus(WarehouseTransferService.PENDING_REVIEW); + var inbound=new LastlegInboundOrder();inbound.setId(101L);inbound.setCustomerId("TENANT-A");inbound.setStatus(WarehouseTransferService.PENDING_REVIEW); + when(transfers.lockNo("TENANT-A","WTR-1")).thenReturn(transfer);when(transfers.findNo("TENANT-A","WTR-1")).thenReturn(transfer); + when(orders.selectById(31L)).thenReturn(tail);when(inboundOrders.selectById(101L)).thenReturn(inbound); + when(transferItems.selectList(any())).thenReturn(List.of());when(sourceOrders.selectList(any())).thenReturn(List.of()); + service.reviewLooseboardSource("TENANT-A","WTR-1","USER-1",new WarehouseTransferRequests.Review("APPROVE",null)); + assertEquals(WarehouseTransferService.WAIT_PDA_RECEIVING,transfer.getStatus()); + verifyNoInteractions(transportTasks,inventory); + } + @Test void merchantSubmitsDraftForReviewBeforePrintingLabels() { WarehouseTransferOrder transfer = persistedTransfer(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java index 64e682428..e58ff28ae 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java @@ -36,6 +36,7 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.when; import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) class UsTruckOrderServiceTest { @@ -44,6 +45,7 @@ class UsTruckOrderServiceTest { @Mock UsTruckOrderStatusHistoryMapper histories; @Mock UsTruckOrderOperationMapper operations; @Mock LastlegOrderNoGenerator orderNoGenerator; + @Mock com.baiyue.lastleg.wallet.budget.OrderBudgetService orderBudget; private final List palletRows = new ArrayList<>(); private final List historyRows = new ArrayList<>(); private final Map operationRows = new HashMap<>(); @@ -57,6 +59,7 @@ class UsTruckOrderServiceTest { ReflectionTestUtils.setField(service, "repository", new com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository(orders, pallets, histories, operations)); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); ReflectionTestUtils.setField(service, "orderNoGenerator", orderNoGenerator); + ReflectionTestUtils.setField(service, "orderBudget", orderBudget); order = order(UsTruckOrderService.PENDING_REVIEW, 0L, 2); lenient().when(orders.lockNo("100", "TK1")).thenAnswer(call -> order); lenient().when(orders.findNo("100", "TK1")).thenAnswer(call -> order); @@ -106,6 +109,7 @@ class UsTruckOrderServiceTest { } } + /** 审核通过进入排车的同时必须释放下单预算,不能继续占用客户资金。 */ @Test void reviewEntersDispatchWithoutInventingPallets() { order.setPalletCount(0); @@ -113,6 +117,7 @@ class UsTruckOrderServiceTest { assertEquals(UsTruckOrderService.PENDING_DISPATCH, reviewed.summary().status()); assertTrue(palletRows.isEmpty()); assertEquals(0, reviewed.summary().receivedPalletCount()); + verify(orderBudget).releaseAfterReview("100", "trucking", "TK1"); } @Test @@ -156,6 +161,7 @@ class UsTruckOrderServiceTest { && row.getIdempotencyKey() != null)); } + /** 同一审核重放只返回原结果,不能再次提交资金释放。 */ @Test void reviewIsIdempotentAtDispatchBoundary() { var reviewed = service.review("100", "TK1", "operator", new UsTruckOrderRequests.Review( @@ -163,6 +169,7 @@ class UsTruckOrderServiceTest { assertEquals(UsTruckOrderService.PENDING_DISPATCH, reviewed.summary().status()); assertEquals(1L, service.review("100", "TK1", "operator", new UsTruckOrderRequests.Review( 0L, "APPROVE", "review-partial", null, 1L)).summary().version()); + verify(orderBudget).releaseAfterReview("100", "trucking", "TK1"); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetPersistenceTest.java index 99a15dea8..d368642a0 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetPersistenceTest.java @@ -20,6 +20,8 @@ class OrderBudgetPersistenceTest { JdbcTemplate jdbc; OrderBudgetService service; OrderFundingPort funding; + SqlSessionTemplate sessions; + /** 隔离数据库使用真实预算、卡车和事件表,服务事务与生产 Mapper 保持一致。 */ @BeforeEach void setup() throws Exception { var ds = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;INIT=SET TIME ZONE '+08:00'", "sa", ""); jdbc = new JdbcTemplate(ds); @@ -29,11 +31,18 @@ class OrderBudgetPersistenceTest { var outbox = Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS ll_event_outbox \\(.*?;").matcher(sql); assertThat(outbox.find()).isTrue();jdbc.execute(outbox.group()); jdbc.execute("CREATE TABLE ll_overseas_procurement_order(customer_id VARCHAR(64),order_no VARCHAR(64),status VARCHAR(32))"); - jdbc.execute("CREATE TABLE ll_us_truck_order(customer_id VARCHAR(64),order_no VARCHAR(64),status VARCHAR(32))"); + for (String table : new String[]{"ll_us_truck_order", "ll_us_truck_order_pallet", "ll_us_truck_order_operation", "ll_us_truck_order_status_history"}) { + var ddl = Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS " + table + " \\(.*?;").matcher(sql); + assertThat(ddl.find()).isTrue(); jdbc.execute(ddl.group()); + } var config = new MybatisConfiguration();config.addMapper(OrderBudgetMapper.class); config.addMapper(com.baiyue.lastleg.mapper.LastlegEventOutboxMapper.class); + config.addMapper(com.baiyue.lastleg.trucking.mapper.UsTruckOrderMapper.class); + config.addMapper(com.baiyue.lastleg.trucking.mapper.UsTruckOrderPalletMapper.class); + config.addMapper(com.baiyue.lastleg.trucking.mapper.UsTruckOrderOperationMapper.class); + config.addMapper(com.baiyue.lastleg.trucking.mapper.UsTruckOrderStatusHistoryMapper.class); var factory = new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config); - var sessions = new SqlSessionTemplate(factory.getObject()); + sessions = new SqlSessionTemplate(factory.getObject()); repository = new OrderBudgetRepository(sessions.getMapper(OrderBudgetMapper.class)); var wallet = new com.baiyue.lastleg.wallet.WalletCommandOutboxService( sessions.getMapper(com.baiyue.lastleg.mapper.LastlegEventOutboxMapper.class), @@ -54,7 +63,8 @@ class OrderBudgetPersistenceTest { for (String kind : new String[]{"procurement","trucking"}) { String table = "procurement".equals(kind) ? "ll_overseas_procurement_order" : "ll_us_truck_order"; assertThatThrownBy(() -> service.submit("9",kind,"rollback",BigDecimal.TEN,"CNY",() -> { - jdbc.update("INSERT INTO " + table + " VALUES('9','FAIL','FUNDING_PENDING')"); + if ("trucking".equals(kind)) jdbc.update("INSERT INTO ll_us_truck_order(id,customer_id,order_no,origin,destination,status,idempotency_key,request_hash) VALUES(1,'9','FAIL','A','B','FUNDING_PENDING','rollback',?)", "a".repeat(64)); + else jdbc.update("INSERT INTO " + table + " VALUES('9','FAIL','FUNDING_PENDING')"); throw new IllegalStateException("订单明细写入失败"); }, value -> String.valueOf(value))).hasMessageContaining("订单明细写入失败"); assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM " + table,Integer.class)).isZero(); @@ -71,6 +81,59 @@ class OrderBudgetPersistenceTest { } assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isEqualTo(2); } + + /** 本地真实建卡车单,核对冻结、审核释放、回执和重复审核的持久化结果。 */ + @Test void truckCreationAndApprovalReleaseTheOriginalBudget() { + jdbc.execute("CREATE TABLE ll_dispatch_truck_task(customer_id VARCHAR(128),order_no VARCHAR(64),active_order VARCHAR(64))"); + var orders = sessions.getMapper(com.baiyue.lastleg.trucking.mapper.UsTruckOrderMapper.class); + var histories = sessions.getMapper(com.baiyue.lastleg.trucking.mapper.UsTruckOrderStatusHistoryMapper.class); + var trucks = new com.baiyue.lastleg.trucking.service.UsTruckOrderService(); + org.springframework.test.util.ReflectionTestUtils.setField(trucks, "repository", + new com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository(orders, + sessions.getMapper(com.baiyue.lastleg.trucking.mapper.UsTruckOrderPalletMapper.class), histories, + sessions.getMapper(com.baiyue.lastleg.trucking.mapper.UsTruckOrderOperationMapper.class))); + org.springframework.test.util.ReflectionTestUtils.setField(trucks, "objectMapper", new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules()); + org.springframework.test.util.ReflectionTestUtils.setField(trucks, "fleetPool", org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries.class)); + org.springframework.test.util.ReflectionTestUtils.setField(trucks, "orderBudget", service); + var numbers = org.mockito.Mockito.mock(com.baiyue.lastleg.service.LastlegOrderNoGenerator.class); + org.mockito.Mockito.when(numbers.next("TRUCK")).thenReturn("TRUCK-LOCAL-BUDGET-1"); + org.springframework.test.util.ReflectionTestUtils.setField(trucks, "orderNoGenerator", numbers); + var actualFunding = new com.baiyue.lastleg.procurement.funding.OrderFundingService( + new com.baiyue.lastleg.procurement.funding.OrderFundingRepository(null, null, orders, histories)); + org.mockito.Mockito.doAnswer(call -> { + actualFunding.apply(call.getArgument(0), call.getArgument(1), call.getArgument(2)); return null; + }).when(funding).apply(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any()); + var request = new com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests.Create("PALLET_15", "A", null, "B", null, + 3, BigDecimal.ONE, BigDecimal.ONE, new BigDecimal("100.00"), "CNY", "发货人", "10086", "收货人", "10010", + LocalDateTime.of(2026,11,2,9,0), LocalDateTime.of(2026,11,3,9,0), null, "local-budget-order"); + var created = service.submit("9", "trucking", request.idempotencyKey(), request.budgetAmount(), request.currency(), + () -> trucks.createFundingPending("9", "tester", request), value -> value.summary().orderNo()); + String number = created.summary().orderNo(); + assertThat(created.summary().status()).isEqualTo("FUNDING_PENDING"); + var budget = repository.lockOrder("9", "trucking", number); + service.handle(truckReceipt(budget, "FREEZE", "FROZEN")); + var pending = trucks.detail("9", number); + assertThat(pending.summary().status()).isEqualTo("PENDING_REVIEW"); + assertThat(repository.lockOrder("9", "trucking", number).getStatus()).isEqualTo("FROZEN"); + var review = new com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests.Review(pending.summary().version(), "APPROVE", "local-budget-review", null, 1L); + assertThat(trucks.review("9", number, "reviewer", review).summary().status()).isEqualTo("PENDING_DISPATCH"); + budget = repository.lockOrder("9", "trucking", number); + assertThat(budget.getStatus()).isEqualTo("RELEASE_PENDING"); + assertThat(budget.getReleaseRequested()).isTrue(); + assertThat(budget.getAmountCny()).isEqualByComparingTo("100.00"); + trucks.review("9", number, "reviewer", review); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox", Integer.class)).isEqualTo(2); + service.handle(truckReceipt(budget, "RELEASE", "RELEASED")); + assertThat(repository.lockOrder("9", "trucking", number).getStatus()).isEqualTo("RELEASED"); + assertThat(trucks.detail("9", number).summary().status()).isEqualTo("PENDING_DISPATCH"); + } + + /** 模拟 Finance 按原资金身份返回成功事实,不用伪造本地已释放状态。 */ + private com.baiyue.common.event.WalletCommandCompletedV1 truckReceipt(OrderBudget budget, String operation, String status) { + return new com.baiyue.common.event.WalletCommandCompletedV1(budget.getCommandEventId(), operation, 9L, + OrderBudgetService.BUSINESS_TYPE, budget.getId().toString(), "SUCCEEDED", status, + budget.getAmountCny(), null, null, java.time.Instant.now(), "local-hold", null); + } /** 免费样品无需发出冻结指令,直接开放审核门禁。 */ @Test void freeSampleSkipsFinanceAndEntersReview() { service.submit("9","procurement","zero",BigDecimal.ZERO,"CNY",()->"FREE1",v->v); diff --git a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json index e1412d1a5..3e978779b 100644 --- a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json +++ b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json @@ -56,6 +56,11 @@ "com.baiyue.lastleg.security.service.SecurityProjectionMonotonicity" ], "reviewedClassDependencies": [ + { + "source": "com.baiyue.lastleg.looseboard.service.LocalLooseboardService", + "target": "com.baiyue.lastleg.dispatch.port.LooseboardTransportPort", + "reason": "散板原单实现排车域声明的照片运输进度输出端口;排车不依赖散板服务或持久化实现,分车送达按去重箱数核对完成。" + }, { "source": "com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels", "target": "com.baiyue.lastleg.dispatch.port.DispatchViews", @@ -395,6 +400,31 @@ "source": "com.baiyue.lastleg.fleet.business.FleetLooseboardDemandService", "target": "com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder", "reason": "客户原始散板需求匹配已有车次,确认承运复用原单;不跨服务读库、不创建第二商业单" + }, + { + "source": "com.baiyue.lastleg.looseboard.service.LooseboardCommercialSourceAdapter", + "target": "com.baiyue.lastleg.transfer.port.TransferCommercialSource", + "reason": "散板审核后关联仓储转运并在PDA收货打托完成时回写可排车状态,经过稳定端口复用转运流程。" + }, + { + "source": "com.baiyue.lastleg.transfer.looseboard.LooseboardReceivingAdapter", + "target": "com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder", + "reason": "仓库转运适配器读取散板订单的客户、仓库及货物信息以创建既有收货单,不直接暴露持久化实现。" + }, + { + "source": "com.baiyue.lastleg.transfer.looseboard.LooseboardReceivingAdapter", + "target": "com.baiyue.lastleg.looseboard.port.LooseboardReceiving", + "reason": "散板审核通过后通过收货端口复用仓储转运上门取货与送货到仓路径。" + }, + { + "source": "com.baiyue.lastleg.transfer.looseboard.LooseboardPlannedCargoService", + "target": "com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway", + "reason": "散板审核生成纯逻辑申报计划,实际收货沿原排车交接端口核对,不创建库存或出库事实" + }, + { + "source": "com.baiyue.lastleg.transfer.looseboard.LooseboardPlannedCargoService", + "target": "com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder", + "reason": "散板审核生成纯逻辑申报计划,实际收货沿原排车交接端口核对,不创建库存或出库事实" } ], "applicationDependencyRule": { diff --git a/scripts/README.md b/scripts/README.md index 16b59b197..e47f45fbf 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,7 @@ # 后端脚本管理与全量 HTTP 验收索引 +`local-looseboard-receiving-acceptance.mjs`:仅本机 Gateway 的散板两场景 HTTP 验收。显式通过 `node --env-file=<开发配置> --env-file= scripts/local-looseboard-receiving-acceptance.mjs` 提供 `ADMIN_TEST_GATEWAY`、平台账号及 `PDA_USERNAME/PDA_PASSWORD`;复用有效客户和PDA,创建送货到仓、仓库上门取货各一单,审核、取货、PDA清点打托并加入同一散板需求排车,验证4箱2托、零拣货及菜单/按钮权限。会创建本地测试单据、车辆司机和合成照片,不代表现场物流或页面验收;不删除数据,报告默认 `reports/local-looseboard-receiving.json`(可用 `LOOSEBOARD_ACCEPTANCE_OUTPUT` 指定),失败后按已保存单号和幂等键继续,不重复造单。设置 `LOOSEBOARD_ACCEPTANCE_PLANNED=true` 并指定新的报告路径可验证审核后未收货先加货、空起运地送货到仓、物流号可空、取货凭证及实收后沿原预排交接。 + Admin 全量可显式传 `--stop-file <本轮唯一停止文件>`。运行前文件必须不存在;需要中止时创建该文件,脚本等待当前模块或前置准备到达边界,退出后续模块,仍执行入口的故障配置恢复与健康核验并保存失败报告。中止结果不计为全量通过。不要强杀含故障注入的进程;同批次不得覆盖重跑,后继运行改用新报告及停止文件。 装柜事件 ACK 丢失代理保留同一 Lastleg 服务上的两类只读请求:`POST /internal/lastleg/v1/order-workflow-facts` 和 `GET /internal/lastleg/appointment-work/containers/{id}/route`。它们按原正文和服务身份透传,不进入事件选取、故障计数或事件证据;未列出的路径和方法继续拒绝,避免故障验收阻断正常尾程查询。对应离线行为由 `event-delivery-fault-proxy.test.mjs` 验证。 diff --git a/scripts/local-looseboard-receiving-acceptance.mjs b/scripts/local-looseboard-receiving-acceptance.mjs new file mode 100644 index 000000000..ccd31e176 --- /dev/null +++ b/scripts/local-looseboard-receiving-acceptance.mjs @@ -0,0 +1,186 @@ +import { readFile, writeFile, mkdir } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { randomUUID } from 'node:crypto' + +// 两种入仓方式均走正式 HTTP;照片和箱规是本地验收样本,不代表真实物流作业。 +const gateway = process.env.ADMIN_TEST_GATEWAY?.replace(/\/$/, '') +if (!gateway || !['localhost', '127.0.0.1'].includes(new URL(gateway).hostname)) throw Error('必须显式指定本机 Gateway') +const planned = process.env.LOOSEBOARD_ACCEPTANCE_PLANNED === 'true' +const output = resolve(process.env.LOOSEBOARD_ACCEPTANCE_OUTPUT || 'reports/local-looseboard-receiving.json') +let report +try { report = JSON.parse(await readFile(output, 'utf8')) } catch (error) { if (error.code !== 'ENOENT') throw error } +report ||= { marker: `LB-${Date.now()}`, environment: 'LOCAL', evidence: 'SYNTHETIC_TEST_MEASUREMENTS_AND_PHOTO', orders: [], requests: {}, checks: [] } +if (report.passed) { console.log(JSON.stringify({ passed: true, orders: report.orders, dispatchNo: report.dispatchNo })); process.exit(0) } +let admin, pda +const base = '/api/platform/v1/last-leg', key = name => `${report.marker}-${name}` +async function save() { await mkdir(dirname(output), { recursive: true }); await writeFile(output, JSON.stringify(report, null, 2)) } +function check(value, label) { report.checks.push({ label, passed: Boolean(value) }); if (!value) throw Error(label) } +async function api(token, method, path, body) { + const form = body instanceof FormData + const response = await fetch(`${gateway}${path}`, { method, signal: AbortSignal.timeout(45000), headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body !== undefined && !form ? { 'Content-Type': 'application/json' } : {}) }, body: body === undefined ? undefined : form ? body : JSON.stringify(body) }) + const value = await response.json(), passed = response.ok && String(value.code) === '200' + report.checks.push({ method, path, http: response.status, code: value.code, passed, requestId: value.requestId || response.headers.get('x-request-id'), message: passed ? undefined : value.message }); await save() + if (!passed) throw Error(`${method} ${path}: ${value.code} ${value.message}`) + return value.data +} +const call = (method, path, body) => api(admin, method, path, body) +function image(name) { const f = new FormData(); f.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')], { type: 'image/png' }), `${key(name)}-TEST.png`); return f } +async function resource(warehouseId) { + if (report.resources) return report.resources + const drivers = `${base}/fleet/drivers` + let driver + if (report.driverId) driver = await call('GET', `${drivers}/${report.driverId}`) + else { + driver = await call('POST', drivers, { name: `${report.marker}验收司机`, phone: '0000000000', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: report.marker, licenseType: 'Class A', licenseZone: 'UTC', licenseExpiresOn: '2028-12-31', remark: '本地模拟资质', profile: { warehouseId, vehicleModels: '26ft Box Truck', languagesAndArea: 'Ontario TEST' } }) + report.driverId = driver.id; await save() + } + driver = await call('POST', `${drivers}/${driver.id}/license?recordVersion=${driver.recordVersion}`, image('licence')) + driver = await call('POST', `${drivers}/${driver.id}/review`, { recordVersion: driver.recordVersion, decision: 'APPROVED', reason: '本地模拟资质验收' }) + const vehicle = await call('POST', `${base}/master-data/vehicles`, { plateNumber: `LB-${report.marker.slice(-10)}`, model: '26ft Box Truck', fuelConsumptionPer100Km: 22, mileageKm: 18000, driverOrDispatchCompany: report.marker, phone: '0000000000', region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: '本地两场景验收', maxPalletSpaces: 12, maxLoadKg: 8000, maxVolumeCbm: 45 }) + report.resources = { driverId: driver.id, vehicleId: vehicle.id }; await save(); return report.resources +} +async function pickup(order) { + const path = `${base}/dispatches/transport-tasks` + const read = () => call('GET', `${path}/${order.pickupDispatchNo}`) + // 审核已经自动建立取货排车时复用原车,不能重复创建。 + if (!order.pickupDispatchNo) { + const page = await call('GET', `${base}/dispatches?page=1&size=50&customerId=${report.customerId}&warehouseId=${report.warehouseId}`) + for (const row of page.records.filter(r => r.taskType === 'PICKUP')) { + const d = await call('GET', `${path}/${row.dispatchNo}`) + if (d.pickups.some(r => r.transferNo === order.transferNo)) { order.pickupDispatchNo = row.dispatchNo; await save(); break } + } + } + if (!order.pickupDispatchNo) { + report.requests.pickup ||= { sources: [{ customerId: report.customerId, transferNo: order.transferNo }], plannedDepartureAt: new Date(Date.now() - 300000).toISOString(), expectedDriverEndAt: new Date(Date.now() + 86400000).toISOString(), remark: report.marker, idempotencyKey: key('pickup') }; await save() + const d = await call('POST', `${path}/pickup`, report.requests.pickup); order.pickupDispatchNo = d.dispatchNo; await save() + } + let d = await read() + const act = async (suffix, extra = {}) => { d = await read(); return call('POST', `${path}/${order.pickupDispatchNo}/${suffix}`, { recordVersion: d.execution.trip.recordVersion, idempotencyKey: randomUUID(), reason: report.marker, ...extra }) } + if (!d.execution.trip.vehicleId) { + const r = await resource(report.warehouseId) + const vehicle = await api(admin, 'POST', `${base}/dispatches/${order.pickupDispatchNo}/evidence/VEHICLE`, image('vehicle')) + const driver = await api(admin, 'POST', `${base}/dispatches/${order.pickupDispatchNo}/evidence/DRIVER_PHOTO`, image('driver')) + d = await read() + await act('assign', { ...r, dispatchRecordVersion: d.dispatchRecordVersion, reviewPhotos: { vehicleFileIds: [vehicle.fileId], driverPhotoFileIds: [driver.fileId], licenseFileIds: [] } }) + } + for (const [action, state] of [['submit-review', 'DRAFT'], ['dispatch-review', 'DISPATCH_PENDING'], ['loading-review', 'LOADING_PENDING']]) { + const root = await call('GET', `${base}/dispatches/${order.pickupDispatchNo}`) + if (root.reviewState === state) await call('POST', `${base}/dispatches/${order.pickupDispatchNo}/${action}`, { recordVersion: root.recordVersion, idempotencyKey: key(`pickup-${action}`), ...(action === 'submit-review' ? { remark: report.marker } : { decision: 'APPROVED', reason: report.marker }) }) + } + d = await read() + if (d.execution.trip.status === 'PLANNED') await act('pickup/depart') + d = await read() + let task = d.pickups.find(r => r.transferNo === order.transferNo) + const suffix = `pickup/${report.customerId}/${order.transferNo}` + if (task.status === 'ASSIGNED') { + const f = await call('POST', `${path}/${order.pickupDispatchNo}/${suffix}/files`, image('pickup')) + await act(`${suffix}/picked`, { actualPackages: 2, evidenceFileIds: [f.fileId] }) + } + d = await read(); task = d.pickups.find(r => r.transferNo === order.transferNo) + if (task.status === 'PICKED') { + const f = await call('POST', `${path}/${order.pickupDispatchNo}/${suffix}/files`, image('arrival')) + await act(`${suffix}/arrive`, { evidenceFileIds: [f.fileId] }) + } + d = await read() + if (d.execution.trip.status === 'IN_TRANSIT') await act('pickup/complete') + for (const kind of ['VEHICLE', 'DRIVER']) { + d = await read() + if (!d.execution.trip[kind === 'VEHICLE' ? 'vehicleRelease' : 'driverRelease']) await act('release', { resourceKind: kind, actualEndAt: new Date().toISOString(), endLocation: '本地验收收货仓', vehicleStatus: kind === 'VEHICLE' ? 'IN_SERVICE' : null }) + } +} +async function receive(order) { + const path = `/api/pda/v1/last-leg/warehouse-transfers/${order.transferNo}` + let d = await api(pda, 'GET', path) + if (d.status === 'READY_FOR_DISPATCH') return + if (!d.pallets.length) d = await api(pda, 'POST', `${path}/pallets/drafts`, { idempotencyKey: key(`${order.mode}-draft`) }) + let p = d.pallets[0] + if (p.status === 'DRAFT') d = await api(pda, 'POST', `${path}/pallets/${p.id}/generate`, { idempotencyKey: key(`${order.mode}-generate`) }) + p = d.pallets.find(r => r.id === p.id) + if (p.status === 'GENERATED') d = await api(pda, 'POST', `${path}/pallets/${p.id}/bind`, { idempotencyKey: key(`${order.mode}-bind`), palletCode: p.palletNo }) + if (!d.cartons.some(r => r.countedAt)) d = await api(pda, 'POST', `${path}/cartons/count`, { idempotencyKey: key(`${order.mode}-count`), forecastPackageCount: d.packageCount, boxes: d.cartons.map(box => ({ cartonMark: box.cartonMark, transferPalletId: p.id, length: 40, width: 30, height: 25, weight: 4, items: [{ skuCode: d.items[0].skuCode, quantity: 1 }] })) }) + const f = await api(pda, 'POST', `${path}/measurement-photos/files`, image(order.mode)) + d = await api(pda, 'POST', `${path}/palletize`, { idempotencyKey: key(`${order.mode}-palletize`), photoFileIds: [f.fileId] }) + check(d.status === 'READY_FOR_DISPATCH', `${order.mode}打托完成后可派车`) + order.palletNo = p.palletNo; await save() +} +try { + const login = await api('', 'POST', '/api/auth/login', { loginId: process.env.ADMIN_TEST_USERNAME, password: process.env.ADMIN_TEST_PASSWORD }); admin = login.accessToken + check(login.authorities?.includes('platform:fleet:capacity-offer:list'), '散板需求菜单权限已下发') + check(login.authorities?.includes('platform:fleet:capacity-offer:cargo-add'), '加入散板货物按钮权限已下发') + pda = (await api('', 'POST', '/api/auth/login', { loginId: process.env.PDA_USERNAME, password: process.env.PDA_PASSWORD })).accessToken + const customers = (await call('GET', '/api/platform/v1/iam/tenants?page=1&size=200')).records + report.customerId ||= customers.find(r => r.status === 'ACTIVE' && /测试/.test(r.tenantName || r.name))?.id || customers.find(r => r.status === 'ACTIVE')?.id + const warehouses = (await call('GET', '/api/platform/v1/warehouse-admin?page=1&size=100&warehouseType=OVERSEAS&status=ACTIVE')).records + report.warehouseId ||= warehouses.find(r => r.warehouseCode === 'US-LAX-01')?.id; await save() + check(report.customerId && report.warehouseId, '有效测试客户及洛杉矶仓存在') + const ticket = await call('POST', `/api/platform/v1/iam/tenants/${report.customerId}/impersonation`, { reason: `${report.marker}散板两场景验收` }) + const merchant = (await api('', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code })).accessToken + if (planned && !report.dispatchNo) { + report.requests.dispatch ||= { idempotencyKey: key('delivery'), warehouseId: report.warehouseId, plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), publishLooseboardDemand: true, cargo: [], batchAllocations: [], remark: key('申报先排车') }; await save() + report.dispatchNo = (await call('POST', base + '/dispatches', report.requests.dispatch)).dispatchNo; await save() + } + for (const mode of ['DELIVERY_TO_WAREHOUSE', 'WAREHOUSE_PICKUP']) { + let order = report.orders.find(r => r.mode === mode) + if (!order) { + report.requests[mode] ||= { operatingWarehouseId: report.warehouseId, destinationType: 'PRIVATE', destinationPostalCode: '91761', deliveryMode: mode, deliveryContactName: '验收仓库联系人', deliveryContactPhone: '0000000000', estimatedArrivalAt: '2026-11-03T09:00:00', trackingNo: key(mode), origin: 'Los Angeles', originAddress: '100 TEST Pickup Road', destination: 'Ontario', destinationAddress: '200 TEST Destination Road', productName: `验收散板-${mode}`, estimatedPalletCount: 1, packageCount: 2, totalWeightKg: 8, totalVolumeCbm: 0.06, budgetAmount: 25, currency: 'USD', originContactName: '验收取货员', originContactPhone: '0000000000', destinationContactName: '验收收件人', destinationContactPhone: '0000000000', pickupAt: '2026-11-03T09:00:00', deliveryAt: '2026-11-04T09:00:00', remark: report.marker, idempotencyKey: key(mode) }; await save() + if (planned && mode === 'DELIVERY_TO_WAREHOUSE') for (const field of ['origin', 'originAddress', 'originContactName', 'originContactPhone', 'trackingNo', 'destinationType']) delete report.requests[mode][field] + await save() + const d = await api(merchant, 'POST', '/api/merchant/v1/last-leg/local-looseboard-orders', report.requests[mode]) + order = { mode, orderNo: d.summary.orderNo }; report.orders.push(order); await save() + } + const orderPath = `${base}/local-looseboard-orders/${order.orderNo}`, query = `?customerId=${report.customerId}` + let d = await call('GET', orderPath + query) + if (d.summary.status === 'PENDING_ACCEPTANCE') d = await call('POST', `${orderPath}/review${query}`, { version: d.summary.version, decision: 'APPROVE', idempotencyKey: key(`${mode}-review`), remark: report.marker }) + order.transferNo = d.summary.transferNo; await save(); check(order.transferNo, '审核生成唯一收货履约单') + if (planned && !order.plannedAdded) { + const path = base + '/dispatches/' + report.dispatchNo, cargoPath = base + '/dispatches/capacity-demands/' + report.dispatchNo + '/cargo' + const root = await call('GET', path) + // 失败后回查原排车,不重复创建或添加已存在的来源。 + const existing = (await call('GET', path + '/items')).items + if (!existing.some(r => r.inboundCode === order.transferNo || r.orderNo === order.transferNo)) { + const result = await call('GET', cargoPath + '?page=1&size=50') + const selected = result.candidates.filter(r => r.inboundCode === order.transferNo || r.orderNo === order.transferNo) + check(selected.length === 1 && selected.every(r => !r.selection.warehousePalletId && !r.blockReasons.length), mode + '审核后无实托也可预排') + order.plannedPalletId = selected[0].selection.planPalletId + const cargo = [...new Map(selected.flatMap(r => r.selectionGroup.sources).map(r => [r.sourceKey, r.selection])).values()] + const request = { recordVersion: root.recordVersion, idempotencyKey: key(mode + '-planned-add'), cargo, batchAllocations: [] } + await call('POST', cargoPath, request); await call('POST', cargoPath, request) + } + const pre = await call('GET', path), preItems = (await call('GET', path + '/items')).items + check(preItems.some(r => r.planPalletId === order.plannedPalletId) && preItems.filter(r => r.planPalletId === order.plannedPalletId).every(r => !r.warehousePalletId) && Number(pre.counts.pickedPackageCount) === 0, '预排没有虚构实物和出库拣货') + order.plannedAdded = true; await save() + } + if (mode === 'WAREHOUSE_PICKUP') await pickup(order) + await receive(order) + d = await call('GET', orderPath + query); check(d.summary.status === 'PENDING_DISPATCH', '原散板单已同步待派车') + if (planned) { + const root = await call('GET', base + '/dispatches/' + report.dispatchNo + '/items') + const item = root.items.find(r => r.planPalletId === order.plannedPalletId) + check(item && item.warehousePalletId && Number(item.scheduledPackages) === 2, mode + '实收沿原预排来源更新实托和箱数') + } + order.status = d.summary.status; await save() + } + if (!report.dispatchNo) { + report.requests.dispatch ||= { idempotencyKey: key('delivery'), warehouseId: report.warehouseId, plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), publishLooseboardDemand: true, cargo: [], batchAllocations: [], remark: `${report.marker}两场景散板需求车` }; await save() + const d = await call('POST', `${base}/dispatches`, report.requests.dispatch); report.dispatchNo = d.dispatchNo; await save() + } + const dispatchPath = `${base}/dispatches/${report.dispatchNo}`, cargoPath = `${base}/dispatches/capacity-demands/${report.dispatchNo}/cargo` + const pool = await call('GET', `${base}/dispatches/capacity-demands?page=1&size=100`) + check(pool.records.some(r => r.dispatchNo === report.dispatchNo), '验收车辆出现在散板需求列表') + let detail = await call('GET', dispatchPath) + if (Number(detail.counts.packageCount) === 0) { + const candidates = await call('GET', `${cargoPath}?page=1&size=50`) + const selected = candidates.candidates.filter(r => report.orders.some(o => r.inboundCode === o.transferNo || r.orderNo === o.transferNo || r.palletNo === o.palletNo)) + check(selected.length === 2, '两个场景均出现在添加货物候选') + check(selected.every(r => !r.blockReasons.length), '两票散板均具备可排车资格') + const sources = new Map(selected.flatMap(r => r.selectionGroup.sources).map(r => [r.sourceKey, r.selection])) + report.requests.add = { recordVersion: detail.recordVersion, idempotencyKey: key('add'), cargo: [...sources.values()], batchAllocations: [] }; await save() + await call('POST', cargoPath, report.requests.add) + await call('POST', cargoPath, report.requests.add) + } + detail = await call('GET', dispatchPath) + check(Number(detail.counts.packageCount) === 4 && Number(detail.counts.palletCount) === 2 && Number(detail.counts.pickedPackageCount) === 0, '两票共四箱两托已加入原车,尚未进行出库拣货;重放未重复加入') + report.finalCounts = detail.counts; report.passed = true; delete report.error; await save() + console.log(JSON.stringify({ passed: true, orders: report.orders, dispatchNo: report.dispatchNo, counts: report.finalCounts }, null, 2)) +} catch (error) { report.error = error.message; await save(); console.error(error.message); process.exitCode = 1 } +