From 29f05b7b094b5d47d662c9abaa2b8834d24beaaa Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Thu, 17 Sep 2026 17:37:29 +0800 Subject: [PATCH 01/16] =?UTF-8?q?=E8=B4=B4=E6=A0=87=E7=94=B3=E8=AF=B7?= =?UTF-8?q?=EF=BC=8C=E4=BB=93=E5=82=A8=E8=BD=AC=E8=BF=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/README.md | 4 +- deploy/sql/海外区域统一增量-MySQL8.sql | 78 +- file/AGENTS.md | 2 + .../file/service/BusinessFileCategory.java | 3 + file/src/main/resources/db/file.sql | 2 +- .../openapi/overseas-relabel-document-v1.yaml | 6 +- ...OverseasExceptionEvidenceContractTest.java | 4 +- .../file/service/RelabelDocumentFileTest.java | 4 +- .../TransferMeasurementPhotoContractTest.java | 15 + lastleg-service/AGENTS.md | 14 +- .../DeliveryTransferController.java | 21 + .../delivery/dto/DeliveryRequests.java | 14 + .../delivery/dto/DeliveryResponses.java | 13 +- .../delivery/entity/DeliveryOrder.java | 5 + .../DeliveryCompletedOutboxService.java | 1 + .../service/DeliveryTransferService.java | 244 +++- .../controller/SmartDispatchController.java | 2 +- .../lastleg/dispatch/entity/DispatchItem.java | 5 + .../mapper/DispatchCandidateMapper.java | 22 +- .../model/DispatchPalletCandidate.java | 11 +- .../dispatch/service/DispatchService.java | 37 +- .../lastleg/dto/LastlegOrderSummary.java | 5 + .../baiyue/lastleg/entity/LastlegOrder.java | 1 + .../inboundplan/entity/InboundPlanLine.java | 4 + .../inboundplan/entity/InboundPlanPallet.java | 5 + .../gateway/HttpWarehouseResourceGateway.java | 23 + .../pda/gateway/WarehouseResourceGateway.java | 4 + .../service/InventoryCommandService.java | 3 - .../lastleg/model/LastlegOrderStatus.java | 13 + .../PdaPalletOperationController.java | 7 + .../dto/PalletOperationRequests.java | 11 +- .../dto/PalletOperationResponses.java | 3 + .../mapper/PdaPalletOperationMapper.java | 6 + .../service/PdaPalletOperationService.java | 226 +++- .../RelabelApplicationController.java | 10 + .../lastleg/relabel/dto/RelabelRequests.java | 28 +- .../lastleg/relabel/dto/RelabelResponses.java | 4 +- .../HttpRelabelDocumentFileGateway.java | 2 +- .../relabel/mapper/RelabelItemMapper.java | 10 + .../model/RelabelApplicationStatus.java | 26 + .../pda/controller/PdaRelabelController.java | 8 + .../relabel/pda/dto/PdaRelabelRequests.java | 4 + .../relabel/pda/dto/PdaRelabelResponses.java | 3 +- .../pda/mapper/PdaRelabelTaskMapper.java | 14 +- .../pda/service/PdaRelabelService.java | 61 +- .../service/RelabelApplicationService.java | 222 +++- .../relabel/service/RelabelBindingGuard.java | 52 + .../lastleg/service/LastlegOrderService.java | 13 +- .../MerchantWarehouseTransferController.java | 55 + .../PdaWarehouseTransferController.java | 90 ++ .../WarehouseTransferController.java | 15 +- .../dto/WarehouseTransferRequests.java | 64 +- .../dto/WarehouseTransferResponses.java | 21 +- .../WarehouseTransferMeasurementPhoto.java | 23 + .../entity/WarehouseTransferOrder.java | 12 + .../entity/WarehouseTransferPallet.java | 5 + ...tpTransferMeasurementPhotoFileGateway.java | 106 ++ .../TransferMeasurementPhotoFileGateway.java | 12 + ...rehouseTransferMeasurementPhotoMapper.java | 33 + .../mapper/WarehouseTransferOrderMapper.java | 2 + .../mapper/WarehouseTransferPalletMapper.java | 5 + .../model/WarehouseTransferStatus.java | 37 + .../service/WarehouseTransferService.java | 1037 ++++++++++++++--- .../src/main/resources/application.yml | 4 +- .../src/main/resources/db/lastleg.sql | 94 +- .../openapi/overseas-business-v1.yaml | 23 +- .../openapi/overseas-pda-operations-v1.yaml | 259 +++- .../DeliveryLongIdJsonContractTest.java | 3 +- .../DeliveryCompletedOutboxServiceTest.java | 18 + .../DispatchCandidateMapperContractTest.java | 26 +- .../dispatch/service/DispatchServiceTest.java | 24 + .../InboundPlanSchemaContractTest.java | 3 +- ...PalletOperationControllerContractTest.java | 2 +- .../PdaPalletOperationServiceTest.java | 108 +- .../pda/service/PdaRelabelServiceTest.java | 33 +- .../RelabelApplicationServiceTest.java | 156 ++- .../service/RelabelBindingGuardTest.java | 48 + .../RelabelReturnCartonCandidateTest.java | 51 + .../WarehouseTransferSchemaContractTest.java | 22 + ...rchantWarehouseTransferControllerTest.java | 19 + ...rehouseTransferControllerContractTest.java | 62 + .../WarehouseTransferCargoValidationTest.java | 31 + ...WarehouseTransferPalletValidationTest.java | 26 +- ...asurementPhotoFileGatewayContractTest.java | 16 + ...WarehouseTransferPalletAllocationTest.java | 94 ++ .../service/WarehouseTransferServiceTest.java | 405 ++++++- scripts/README.md | 11 +- .../initialization-baseline-contract.test.mjs | 4 +- ...nt-session-overseas-relabel-acceptance.mjs | 75 +- ...dev-lastleg-relabel-return-incremental.sql | 27 + ...-transfer-pallet-lifecycle-incremental.sql | 25 + scripts/db/incremental.sql | 89 +- scripts/db/relabel-file-scope-incremental.sql | 3 + .../db/relabel-wait-status-incremental.sql | 16 + .../db/seed-local-relabel-ready-orders.sql | 114 ++ ...arehouse-transfer-outbound-incremental.sql | 326 ++++++ ...verseas-relabel-fulfillment-acceptance.mjs | 149 ++- scripts/overseas-relabel-real-acceptance.mjs | 93 +- scripts/relabel-closure-local-acceptance.mjs | 116 ++ scripts/relabel-pdf-local-acceptance.mjs | 42 + .../InternalWarehousePalletController.java | 13 + .../service/PalletLabelPrintService.java | 15 +- .../service/WarehouseResourceService.java | 8 +- ...ernalWarehousePalletLabelContractTest.java | 20 + .../service/PalletLabelPrintServiceTest.java | 20 + .../service/WarehouseResourceServiceTest.java | 5 +- 106 files changed, 5019 insertions(+), 471 deletions(-) create mode 100644 file/src/test/java/com/baiyue/file/service/TransferMeasurementPhotoContractTest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/model/RelabelApplicationStatus.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuard.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferMeasurementPhoto.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/gateway/HttpTransferMeasurementPhotoFileGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/gateway/TransferMeasurementPhotoFileGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferMeasurementPhotoMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/model/WarehouseTransferStatus.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuardTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelReturnCartonCandidateTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/PdaWarehouseTransferControllerContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferCargoValidationTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/transfer/gateway/HttpTransferMeasurementPhotoFileGatewayContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferPalletAllocationTest.java create mode 100644 scripts/db/dev-warehouse-transfer-pallet-lifecycle-incremental.sql create mode 100644 scripts/db/relabel-wait-status-incremental.sql create mode 100644 scripts/db/seed-local-relabel-ready-orders.sql create mode 100644 scripts/db/warehouse-transfer-outbound-incremental.sql create mode 100644 scripts/relabel-closure-local-acceptance.mjs create mode 100644 scripts/relabel-pdf-local-acceptance.mjs create mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/InternalWarehousePalletLabelContractTest.java diff --git a/deploy/sql/README.md b/deploy/sql/README.md index d5fc094b2..7fb118e07 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -7,7 +7,7 @@ | 区域 | 完整入口 | 服务 schema | 表数 | |---|---|---|---:| | 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`、`baiyue_finance` | 208 | -| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse`、`baiyue_event_bridge_overseas` | 136 | +| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse`、`baiyue_event_bridge_overseas` | 138 | - 国内和海外是两个部署区域,不是把微服务合并为两个 schema。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 - 两区域可分别在独立空实例执行,没有跨区域 SQL 依赖。海外不访问国内 IAM/Warehouse;国内不创建、查询或修改海外业务表。 @@ -24,7 +24,7 @@ 5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。 6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。 -成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=208/136`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 +成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=208/138`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 ## 平台管理员密码 diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 6e50d87f2..e40fd061b 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -311,14 +311,19 @@ CREATE TABLE ll_delivery_order ( delivery_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '派送单号', outbound_order_id bigint NOT NULL COMMENT '关联出库单ID', outbound_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '关联出库单号', - dispatch_order_id bigint NOT NULL COMMENT '关联派车单ID', - dispatch_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '关联派车单号', + dispatch_order_id bigint DEFAULT NULL COMMENT '关联派车单ID,仓储转运直接出库时为空', + dispatch_no varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '关联派车单号,仓储转运直接出库时为空', overseas_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外仓ID', overseas_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外仓名称快照', customer_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户名称快照', destination_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本派送单目的FBA仓ID快照', - destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本派送单目的FBA仓编码快照', + destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本派送单目的FBA仓编码快照,私人仓为空', destination_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本派送单目的FBA仓名称快照', + destination_type varchar(16) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的地类型:FBA或PRIVATE', + delivery_address varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓派送地址', + postal_code varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓邮编', + contact_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系人', + contact_phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系电话', delivery_group_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '同一出库单内按目的FBA仓拆单的稳定分组键', status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'WAIT_DELIVERY' COMMENT '派送状态:WAIT_DELIVERY待派送及后续正式状态', waybill_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '承运运单号', @@ -433,6 +438,11 @@ CREATE TABLE ll_dispatch_item ( destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码快照', destination_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称快照', fba_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'FBA仓库编码', + destination_type varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的类型:FBA或PRIVATE', + delivery_address varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓目的地址快照', + postal_code varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓邮编快照', + contact_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系人快照', + contact_phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系电话快照', package_count int NOT NULL DEFAULT '0' COMMENT '箱数', volume_cbm decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '体积(立方米)', source_stage varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '数据来源阶段', @@ -1091,6 +1101,10 @@ CREATE TABLE ll_inbound_plan_line ( destination_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓ID快照', destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码快照', destination_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称快照', + delivery_address varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓目的地址快照', + postal_code varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓邮编快照', + contact_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系人快照', + contact_phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系电话快照', volume_cbm decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '入仓码体积(立方米)', planned_pallet_count int NOT NULL DEFAULT '0' COMMENT '该明细分配的托盘数量', required_package_count int NOT NULL DEFAULT '0' COMMENT '计划明细应收箱数', @@ -1110,6 +1124,11 @@ CREATE TABLE ll_inbound_plan_pallet ( destination_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓ID快照', destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码快照', destination_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称快照', + destination_type varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的类型:FBA或PRIVATE', + delivery_address varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓目的地址快照', + postal_code varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓邮编快照', + contact_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系人快照', + contact_phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系电话快照', capacity_cbm decimal(18,4) NOT NULL DEFAULT '2.0000' COMMENT '托盘容量,固定为2立方米', allocated_volume_cbm decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '已分配体积(立方米)', sequence_no int NOT NULL COMMENT '计划内托盘序号', @@ -1455,6 +1474,7 @@ CREATE TABLE ll_order ( version int NOT NULL DEFAULT '0' COMMENT '尾程订单命令乐观锁版本', pallet_count int NOT NULL DEFAULT '0' COMMENT '托数', package_count int NOT NULL DEFAULT '0' COMMENT '箱数', + received_package_count int DEFAULT NULL COMMENT 'PDA实际入库箱数', picked_package_count int NOT NULL DEFAULT '0' COMMENT '累计已拣箱数', shipped_package_count int NOT NULL DEFAULT '0' COMMENT '累计已出库箱数', volume_cbm decimal(18,4) DEFAULT NULL COMMENT '体积(立方米)', @@ -2704,7 +2724,17 @@ CREATE TABLE ll_warehouse_transfer_order ( lastleg_order_no varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '尾程订单号', operating_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '入库中转仓ID', fba_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '可选FBA目的仓编码', + destination_type varchar(16) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的类型:FBA或PRIVATE', + destination_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓ID快照', + destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码快照', + destination_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称快照', + private_destination_address varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓完整地址', + private_destination_postal_code varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓邮编', + private_destination_contact_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系人', + private_destination_contact_phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系电话', delivery_address varchar(1000) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '提货地址或中转仓收货地址', + pickup_contact_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '提货联系人', + pickup_contact_phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '提货联系电话', package_count int NOT NULL COMMENT '箱数', delivery_method varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '入仓方式', estimated_arrival_at datetime(3) NOT NULL COMMENT '预计入仓时间', @@ -2718,6 +2748,10 @@ CREATE TABLE ll_warehouse_transfer_order ( relabel_no varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标申请单号', relabel_completed_at datetime(3) DEFAULT NULL COMMENT '关联贴标完成时间', status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', + review_decision varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核决定', + review_remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核备注或驳回原因', + reviewed_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核人ID', + reviewed_at datetime(3) DEFAULT NULL COMMENT '审核时间', idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务幂等键', allocation_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '库存分配幂等键', allocation_request_hash char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '库存分配请求哈希', @@ -2738,6 +2772,8 @@ CREATE TABLE ll_warehouse_transfer_order ( confirmation_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户测量确认幂等键', confirmation_request_hash char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户测量确认请求哈希', customer_confirmed_at datetime(3) DEFAULT NULL COMMENT '客户确认测量时间', + destination_binding_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的地绑定幂等键', + destination_binding_request_hash char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的地绑定请求哈希', PRIMARY KEY (id), UNIQUE KEY uk_ll_wh_transfer_no (customer_id,transfer_no), UNIQUE KEY uk_ll_wh_transfer_key (customer_id,idempotency_key), @@ -2751,19 +2787,24 @@ CREATE TABLE ll_warehouse_transfer_pallet ( transfer_order_id bigint NOT NULL COMMENT '转运单ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', sequence_no int NOT NULL COMMENT '托序号', - warehouse_pallet_id bigint NOT NULL COMMENT '海外仓真实托盘ID', - pallet_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '真实托盘码', - warehouse_location_id bigint NOT NULL COMMENT '真实库位ID', + warehouse_pallet_id bigint DEFAULT NULL COMMENT '生成后的海外仓真实托盘ID', + pallet_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '生成后的真实托盘码', + warehouse_location_id bigint DEFAULT NULL COMMENT '生成后的真实库位ID', warehouse_location_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '真实库位码', - warehouse_resource_lock_id bigint NOT NULL COMMENT '仓储资源锁ID', + warehouse_resource_lock_id bigint DEFAULT NULL COMMENT '生成后的仓储资源锁ID', carton_count int NOT NULL COMMENT '本托实收箱数', length_cm decimal(12,2) NOT NULL COMMENT '实测长cm', width_cm decimal(12,2) NOT NULL COMMENT '实测宽cm', height_cm decimal(12,2) NOT NULL COMMENT '实测高cm', gross_weight_kg decimal(12,3) NOT NULL COMMENT '实测毛重kg', volume_cbm decimal(18,6) NOT NULL COMMENT '实测体积CBM', - status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '打托状态', - completed_at datetime(3) NOT NULL COMMENT 'PDA打托完成时间', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '逐托状态DRAFT/GENERATED/BOUND/COMPLETED', + draft_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '草稿保存幂等键', + draft_request_hash char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '草稿请求摘要', + generate_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '新托盘生成幂等键', + bind_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '扫码绑定幂等键', + bound_at datetime(3) DEFAULT NULL COMMENT '扫码确认绑定时间', + completed_at datetime(3) DEFAULT NULL COMMENT '整单打托完成时间', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY (id), @@ -2785,6 +2826,22 @@ CREATE TABLE ll_warehouse_transfer_pallet_item ( KEY idx_ll_transfer_pallet_item_order (customer_id,transfer_order_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓储转运每托SKU箱数回填'; +CREATE TABLE ll_warehouse_transfer_measurement_photo ( + id bigint NOT NULL COMMENT '测量照片引用ID', + transfer_order_id bigint NOT NULL COMMENT '仓储转运单ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + file_id bigint NOT NULL COMMENT '文件服务文件ID', + file_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '文件名快照', + content_type varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '文件媒体类型快照', + size_bytes bigint NOT NULL DEFAULT '0' COMMENT '文件字节数快照', + uploaded_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'PDA上传操作人', + uploaded_at datetime(3) NOT NULL COMMENT '上传时间', + submitted_at datetime(3) DEFAULT NULL COMMENT '随测量打托提交时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_transfer_measurement_photo_file (customer_id,transfer_order_id,file_id), + KEY idx_ll_transfer_measurement_photo_order (customer_id,transfer_order_id,uploaded_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓储转运PDA测量现场照片引用'; + CREATE TABLE ll_warehouse_transfer_receipt ( id bigint NOT NULL COMMENT '雪花主键', transfer_order_id bigint NOT NULL COMMENT '转仓单ID', @@ -2995,4 +3052,5 @@ USE baiyue_lastleg; INSERT INTO ll_security_projection (aggregate_type, aggregate_id, tenant_id, security_status, permission_version, roles_json, authorities_json, aggregate_version, payload_hash, last_event_id, changed_at, updated_at) VALUES ('AUTHORIZATION','1000001',0,NULL,0,'[\"PLATFORM_ADMIN\"]','[\"platform:overseas-warehouse:warehouse-transfer:approve\", \"platform:finance:bill:adjust\", \"platform:finance:bill:update\", \"platform:finance:bill:void\", \"platform:finance:cost:create\", \"platform:finance:cost:view\", \"platform:finance:customer-account:adjust\", \"platform:finance:customer-account:export\", \"platform:finance:customer-account:list\", \"platform:finance:customer-arap:export\", \"platform:finance:customer-arap:list\", \"platform:finance:customer-statement:export\", \"platform:finance:customer-statement:list\", \"platform:finance:customer-terms:update\", \"platform:finance:customer-terms:view\", \"platform:finance:fee-source:edit\", \"platform:finance:fee-source:review\", \"platform:finance:payment:create\", \"platform:finance:receipt:create\", \"platform:finance:receipt:view\", \"platform:finance:receivable-statistics:export\", \"platform:finance:receivable-statistics:list\", \"platform:finance:recharge:review\", \"platform:finance:reconciliation:adjust\", \"platform:finance:reconciliation:create\", \"platform:finance:reconciliation:issue\", \"platform:finance:reconciliation:review\", \"platform:finance:reconciliation:void\", \"platform:finance:settlement-ap:create\", \"platform:finance:settlement-ap:export\", \"platform:finance:settlement-ap:list\", \"platform:finance:settlement-ar:create\", \"platform:finance:settlement-ar:export\", \"platform:finance:settlement-ar:list\", \"platform:finance:settlement:void\", \"platform:overseas-business:looseboard:create\", \"platform:overseas-business:looseboard:edit\", \"platform:overseas-business:looseboard:list\", \"platform:overseas-business:looseboard:status\", \"platform:overseas-business:purchase:edit\", \"platform:overseas-business:purchase:list\", \"platform:overseas-business:purchase:review\", \"platform:overseas-business:truck:create\", \"platform:overseas-business:truck:edit\", \"platform:overseas-business:truck:list\", \"platform:overseas-business:truck:status\", \"platform:overseas-master-data:exchange-rate:create\", \"platform:overseas-master-data:exchange-rate:edit\", \"platform:overseas-master-data:exchange-rate:export\", \"platform:overseas-master-data:exchange-rate:list\", \"platform:overseas-master-data:exchange-rate:status\", \"platform:overseas-master-data:quote:create\", \"platform:overseas-master-data:quote:edit\", \"platform:overseas-master-data:quote:export\", \"platform:overseas-master-data:quote:list\", \"platform:overseas-master-data:quote:status\", \"platform:overseas-master-data:vehicle:create\", \"platform:overseas-master-data:vehicle:edit\", \"platform:overseas-master-data:vehicle:export\", \"platform:overseas-master-data:vehicle:list\", \"platform:overseas-master-data:vehicle:status\", \"platform:overseas-warehouse:customer-visit:edit\", \"platform:overseas-warehouse:customer-visit:list\", \"platform:overseas-warehouse:delivery:documents\", \"platform:overseas-warehouse:delivery:list\", \"platform:overseas-warehouse:delivery:return\", \"platform:overseas-warehouse:delivery:status\", \"platform:overseas-warehouse:dispatch:cancel\", \"platform:overseas-warehouse:dispatch:edit\", \"platform:overseas-warehouse:dispatch:list\", \"platform:overseas-warehouse:dispatch:transfer-delivery\", \"platform:overseas-warehouse:exception-order:create\", \"platform:overseas-warehouse:exception-order:list\", \"platform:overseas-warehouse:exception-order:process\", \"platform:overseas-warehouse:inbound-order:create\", \"platform:overseas-warehouse:inbound-order:edit\", \"platform:overseas-warehouse:inbound-order:fba-appointment\", \"platform:overseas-warehouse:inbound-order:list\", \"platform:overseas-warehouse:inbound-order:smart-dispatch\", \"platform:overseas-warehouse:inventory:adjust\", \"platform:overseas-warehouse:inventory:edit\", \"platform:overseas-warehouse:inventory:export\", \"platform:overseas-warehouse:inventory:list\", \"platform:overseas-warehouse:last-mile-order:edit\", \"platform:overseas-warehouse:last-mile-order:export\", \"platform:overseas-warehouse:last-mile-order:list\", \"platform:overseas-warehouse:location:create\", \"platform:overseas-warehouse:location:delete\", \"platform:overseas-warehouse:location:edit\", \"platform:overseas-warehouse:location:list\", \"platform:overseas-warehouse:outbound:cancel\", \"platform:overseas-warehouse:outbound:create\", \"platform:overseas-warehouse:outbound:list\", \"platform:overseas-warehouse:pallet:create\", \"platform:overseas-warehouse:pallet:delete\", \"platform:overseas-warehouse:pallet:edit\", \"platform:overseas-warehouse:pallet:list\", \"platform:overseas-warehouse:relabel-order:create\", \"platform:overseas-warehouse:relabel-order:list\", \"platform:overseas-warehouse:relabel-order:status\", \"platform:overseas-warehouse:return-cartons:list\", \"platform:overseas-warehouse:return-inventory:list\", \"platform:overseas-warehouse:warehouse:create\", \"platform:overseas-warehouse:warehouse:edit\", \"platform:overseas-warehouse:warehouse:list\", \"platform:overseas-warehouse:warehouse:status\"]',0,'f45d2c4b22e46dd866045caf4c2050dafae456a58065367b06c180032df65c22','baseline-platform-admin-authorization-v0','1970-01-01 00:00:00.000',CURRENT_TIMESTAMP(3)); INSERT INTO ll_security_projection (aggregate_type, aggregate_id, tenant_id, security_status, permission_version, roles_json, authorities_json, aggregate_version, payload_hash, last_event_id, changed_at, updated_at) VALUES ('USER','1000001',0,'ACTIVE',0,'[]','[]',0,'c7522473b3b940c94397b23fa08bd8f3f56ca646e395314393853bd599c300c7','baseline-platform-admin-user-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'); -SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 137 AS initialized_tables; +-- assert_relabel_transfer_latest_schema_ready: 贴标申请与仓储转运最新表结构已完整部署。 +SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 138 AS initialized_tables; diff --git a/file/AGENTS.md b/file/AGENTS.md index fe8603673..063fc77f7 100644 --- a/file/AGENTS.md +++ b/file/AGENTS.md @@ -30,6 +30,8 @@ ## 修改约束 +- 贴标箱唛总文件和 SKU 总文件分别使用 `OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT`、`OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT`,与明细 PDF 采用同一客户复合归属和 10MiB PDF 校验。`file_object.business_type` 为 VARCHAR(64),存量扩容见海外统一增量和 `scripts/db/relabel-file-scope-incremental.sql`。Print 只负责生成 PDF,不保存上传文件。 + - 上传先写真实文件再登记 IAM 元数据;异常结果未知时保留文件供重试,禁止伪造 URL。 - 客户单文件资质入口只接受 `LEGAL_PERSON_ID/FREIGHT_FORWARDER_CERTIFICATE/BANK_ACCOUNT_LICENSE`,客户范围只取登录 Token;营业执照不得绕过 `required-package`。同类当前资质已存在时必须显式 `replace=true`,成功后旧元数据标记为已替代;上传不会自动提交审核。 - 客户业务文件按客户、业务 ID 和文件类型校验归属;重量图片可 OCR,第三方尺寸证据不得触发重量 OCR。装柜四步照片与铅封是跨客户物理柜证据,所有者固定为平台(`tenantId=0`),业务 ID 固定为 `containerBookingId`,File 按柜与五类 `LOADING_*` 精确校验且不得跨柜复用;PDA `taskId` 只由 Firstleg 保存为来源/审计,不进入 File 所有权。国内 PDA 禁止直连 File 平台接口,必须经 Firstleg 校验任务、仓库和操作者后,由 Firstleg 使用内部服务令牌访问 File。 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 f2603ec75..10c944048 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -24,7 +24,10 @@ enum BusinessFileCategory { CONTAINER_PICKUP_ORDER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_UNLOAD_PHOTO(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_RELABEL_PHOTO(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_TRANSFER_MEASUREMENT_PHOTO(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_RELABEL_TOTAL_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_RELABEL_ITEM_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_EXCEPTION_EVIDENCE(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), LOADING_LIST_CUSTOMS_SOURCE(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), diff --git a/file/src/main/resources/db/file.sql b/file/src/main/resources/db/file.sql index 2a4a61cb0..17c8d2f4d 100644 --- a/file/src/main/resources/db/file.sql +++ b/file/src/main/resources/db/file.sql @@ -6,7 +6,7 @@ USE baiyue_file; CREATE TABLE IF NOT EXISTS file_object ( id BIGINT UNSIGNED NOT NULL COMMENT '文件ID(雪花ID)', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '所有者范围键:0为平台,正整数为客户ID', - business_type VARCHAR(32) NULL COMMENT '关联业务类型,海外异常证据固定为OVERSEAS_EXCEPTION_EVIDENCE', + business_type VARCHAR(64) NULL COMMENT '关联业务类型,海外异常证据固定为OVERSEAS_EXCEPTION_EVIDENCE', business_id VARCHAR(64) NULL COMMENT '关联业务逻辑ID;装柜证据固定为物理containerBookingId', original_name VARCHAR(255) NOT NULL COMMENT '原始文件名', object_key VARCHAR(512) NOT NULL COMMENT '对象存储键', diff --git a/file/src/main/resources/openapi/overseas-relabel-document-v1.yaml b/file/src/main/resources/openapi/overseas-relabel-document-v1.yaml index 9444e9a4d..1b0d87045 100644 --- a/file/src/main/resources/openapi/overseas-relabel-document-v1.yaml +++ b/file/src/main/resources/openapi/overseas-relabel-document-v1.yaml @@ -4,7 +4,7 @@ info: version: 1.0.0 description: >- File 只保存文件并强制客户、业务类型、业务ID与文件ID复合归属。 - OVERSEAS_RELABEL_TOTAL_DOCUMENT 的 businessId 为 application.id; + OVERSEAS_RELABEL_TOTAL_DOCUMENT、OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT、OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT 的 businessId 为 application.id; OVERSEAS_RELABEL_ITEM_DOCUMENT 的 businessId 为 item.id。 Lastleg 必须先校验申请与明细存在、明细所属申请及其客户归属、业务状态和权限。 客户归属未知时失败关闭,不得使用平台归属或替代业务ID。 @@ -88,7 +88,7 @@ components: fileId: {type: string, pattern: '^[1-9][0-9]*$'} ownerScope: {type: string, enum: [CUSTOMER]} tenantId: {type: string, pattern: '^[1-9][0-9]*$'} - businessType: {type: string, enum: [OVERSEAS_RELABEL_TOTAL_DOCUMENT, OVERSEAS_RELABEL_ITEM_DOCUMENT]} + businessType: {type: string, enum: [OVERSEAS_RELABEL_TOTAL_DOCUMENT, OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT, OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT, OVERSEAS_RELABEL_ITEM_DOCUMENT]} businessId: {type: string, pattern: '^[1-9][0-9]*$'} fileName: {type: string} sha256: {type: string, pattern: '^[0-9a-f]{64}$'} @@ -112,7 +112,7 @@ components: required: true schema: type: string - enum: [OVERSEAS_RELABEL_TOTAL_DOCUMENT, OVERSEAS_RELABEL_ITEM_DOCUMENT] + enum: [OVERSEAS_RELABEL_TOTAL_DOCUMENT, OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT, OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT, OVERSEAS_RELABEL_ITEM_DOCUMENT] BusinessId: name: businessId in: path diff --git a/file/src/test/java/com/baiyue/file/service/OverseasExceptionEvidenceContractTest.java b/file/src/test/java/com/baiyue/file/service/OverseasExceptionEvidenceContractTest.java index f2f826bf9..cdc850eba 100644 --- a/file/src/test/java/com/baiyue/file/service/OverseasExceptionEvidenceContractTest.java +++ b/file/src/test/java/com/baiyue/file/service/OverseasExceptionEvidenceContractTest.java @@ -30,8 +30,8 @@ class OverseasExceptionEvidenceContractTest { try (var stream = getClass().getResourceAsStream("/db/file.sql")) { assertThat(stream).isNotNull(); String sql = new String(stream.readAllBytes(), StandardCharsets.UTF_8); - assertThat(sql).contains("business_type VARCHAR(32)", "OVERSEAS_EXCEPTION_EVIDENCE"); - assertThat("OVERSEAS_EXCEPTION_EVIDENCE").hasSizeLessThanOrEqualTo(32); + assertThat(sql).contains("business_type VARCHAR(64)", "OVERSEAS_EXCEPTION_EVIDENCE"); + assertThat("OVERSEAS_EXCEPTION_EVIDENCE").hasSizeLessThanOrEqualTo(64); } } } diff --git a/file/src/test/java/com/baiyue/file/service/RelabelDocumentFileTest.java b/file/src/test/java/com/baiyue/file/service/RelabelDocumentFileTest.java index 1b8c009ed..380d6feda 100644 --- a/file/src/test/java/com/baiyue/file/service/RelabelDocumentFileTest.java +++ b/file/src/test/java/com/baiyue/file/service/RelabelDocumentFileTest.java @@ -25,7 +25,7 @@ class RelabelDocumentFileTest { @TempDir Path root; @ParameterizedTest - @EnumSource(value = BusinessFileCategory.class, names = {"OVERSEAS_RELABEL_TOTAL_DOCUMENT", "OVERSEAS_RELABEL_ITEM_DOCUMENT"}) + @EnumSource(value = BusinessFileCategory.class, names = {"OVERSEAS_RELABEL_TOTAL_DOCUMENT", "OVERSEAS_RELABEL_ITEM_DOCUMENT", "OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT", "OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT"}) void pdfPolicyChecksContentTypeSignatureAndSize(BusinessFileCategory category) { BusinessFileStorage storage = storage(); byte[] exactLimit = new byte[10 * 1024 * 1024]; @@ -52,7 +52,7 @@ class RelabelDocumentFileTest { } @ParameterizedTest - @EnumSource(value = BusinessFileCategory.class, names = {"OVERSEAS_RELABEL_TOTAL_DOCUMENT", "OVERSEAS_RELABEL_ITEM_DOCUMENT"}) + @EnumSource(value = BusinessFileCategory.class, names = {"OVERSEAS_RELABEL_TOTAL_DOCUMENT", "OVERSEAS_RELABEL_ITEM_DOCUMENT", "OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT", "OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT"}) void uploadAndReadRequireExactCustomerBusinessTypeBusinessAndFile(BusinessFileCategory category) throws Exception { TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "relabel-test"), FileObjectEntity.class); FileObjectMapper files = mock(FileObjectMapper.class); diff --git a/file/src/test/java/com/baiyue/file/service/TransferMeasurementPhotoContractTest.java b/file/src/test/java/com/baiyue/file/service/TransferMeasurementPhotoContractTest.java new file mode 100644 index 000000000..671602bab --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/TransferMeasurementPhotoContractTest.java @@ -0,0 +1,15 @@ +package com.baiyue.file.service; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class TransferMeasurementPhotoContractTest { + @Test + void transferMeasurementPhotoIsCustomerOwnedStandardBusinessFile() { + BusinessFileCategory category = BusinessFileCategory.require("OVERSEAS_TRANSFER_MEASUREMENT_PHOTO"); + category.validateOwner(10001L); + assertThat(category.formatPolicy()).isEqualTo(BusinessFileCategory.FormatPolicy.STANDARD); + assertThat(category.storageBusinessId("77")).isEqualTo("77"); + } +} diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 4a7c20eff..47e5d1c8c 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -49,7 +49,7 @@ | 派送完成回传 | `delivery/service/DeliveryCompletedOutboxService.java`;签收资料保存并自动完成时在业务事务内按关联 `sourceInboundId` 分别写 `OVERSEAS_DELIVERY_COMPLETED` Outbox,同车不同 FBA 派送单互不代完成;不保留人工完成入口 | | 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 | | 海外PDA盘点、同仓整托移库、托盘组拆换绑与换标现场作业 | `pda/stocktake/`、`pda/move/`、`palletops/`、`relabel/pda/`;版本化契约 `src/main/resources/openapi/overseas-pda-operations-v1.yaml`;全部范围取自PDA Token,排车和出库使用独立 PDA Controller | -| 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/`、`relabel/pda/`;一条 `ll_relabel_item` 对应一个客户填写的我方箱唛任务,`CARTON_LABEL`、`SKU_LABEL`、`CARTON_AND_SKU` 分别保存箱唛、SKU 或组合需求,SKU 子项 JSON 数量按件;普通尾程入库没有箱唛到实物托盘的精确快照,历史入仓库存仓位只能作为冻结记账锚点和现场大概位置,禁止把该仓位解释为箱唛已绑定具体托盘。库存只按箱任务冻结一次。涉及箱唛换标时申请必须选择 `TRUCK/EXPRESS/TRUCK_PICKUP/EXPRESS_PICKUP`,两种快递方式可要求打印并上传面单;PDA 扫我方箱唛完成逐箱事实和照片,仓库复核后等待客户查看照片确认。对外换标状态只投影为 `PENDING_PROCESSING/COMPLETED`(待处理/换标完成),`PROCESSING`、`CUSTOMER_CONFIRM_PENDING`、`READY_FOR_OUTBOUND` 只属于内部作业节点且不得直接出现在业务列表;客户确认后仅卡车派送进入 `WAIT_PRE_DISPATCH`,快递派送和两种自提直接创建换标出库单。客户确认前不得进入出库 | +| 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/`、`relabel/pda/`;一条 `ll_relabel_item` 对应一个客户填写的我方箱唛任务,`CARTON_LABEL`、`SKU_LABEL`、`CARTON_AND_SKU` 分别保存箱唛、SKU 或组合需求,SKU 子项 JSON 数量按件;涉及箱唛换标时整张申请只能有一个 FBA 目的仓,服务端拒绝明细目的仓不一致的请求。普通尾程入库没有箱唛到实物托盘的精确快照,历史入仓库存仓位只能作为冻结记账锚点和现场大概位置,禁止把该仓位解释为箱唛已绑定具体托盘。库存只按箱任务冻结一次。涉及箱唛换标时申请必须选择 `TRUCK/EXPRESS/TRUCK_PICKUP/EXPRESS_PICKUP`,两种快递方式可要求打印并上传面单;PDA 扫我方箱唛完成逐箱事实和照片,仓库复核后等待客户查看照片确认。对外换标状态只投影为 `PENDING_PROCESSING/COMPLETED`(待处理/换标完成),`PROCESSING`、`CUSTOMER_CONFIRM_PENDING`、`READY_FOR_OUTBOUND` 只属于内部作业节点且不得直接出现在业务列表;客户确认后仅卡车派送进入 `WAIT_PRE_DISPATCH`,快递派送和两种自提直接创建换标出库单。客户确认前不得进入出库 | | PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java`、`pda/warehouse/service/PdaWarehouseService.java`;解析库存、库位和托盘码并查询操作历史,固定要求 `lastleg:pda:operate` 与 `ROLE_WAREHOUSE_PICKER`,仓库范围只能取自 PDA Token | | 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java`、`shipping/entity/ContainerShippingGate.java`、`shipping/mapper/ContainerShippingGateMapper.java`;按 `containerBookingId` 柜级接收,信封使用 `tenantId=0` 且门禁不存客户;只单调接受实际标准节点,`PICKUP_READY` 及以后才允许对应柜进入 PDA 收货候选 | | 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`、`dispatch/mapper/DispatchCandidateMapper.java`、`dispatch/service/DispatchService.java`;草稿柜 `entity/DispatchDraftContainer.java`、`mapper/DispatchDraftContainerMapper.java`,托盘锁 `DispatchPalletLock.java`;入口 `/api/platform/v1/last-leg/dispatches`,路线由当前已排托盘的 FBA 仓编码去重后按稳定顺序以英文逗号连接,预排与普通编辑请求均不接收路线;查询、预排与执行边界见“海外仓全流程开发路由” | @@ -132,6 +132,18 @@ ## 修改与验证 +- `RelabelBindingGuard` 校验已保存组托历史与当前库存托盘一致,箱唛/混合换标正常完工、客户确认与排车提交均复验;纯 SKU 不要求移托,异常手动完工仍须在客户确认前补齐绑定。创建申请按库存余额串行检查箱唛既有申请,拒绝重复申请;历史扫码全局唯一约束尚未改为多轮复用。 + +- 已确认箱唛改绑复用 `PdaPalletOperationService.relabelDisassemble` 不带 `actualSource` 的精确来源分支,按现有绑定移动 HOLD 库存,不重新分配冻结锚点;成功同样记录 `PALLET_BIND` 以展示最新目标。`requireUnallocatedRelabel` 在入口与事务锁单后限制排车/出库占用,已创建出库单也不可直接改绑。回归 `PdaPalletOperationServiceTest`。 + +- PDA旧SKU单扫:`PdaRelabelService.scanSku` 和 `/relabel-tasks/{relabelNo}/skus/scan`,按箱itemId+skuIndex核对旧SKU,服务端取新SKU/数量,逐条记录现有 `SKU_SCAN` 历史,版本、幂等、认领和仓库校验不变;同箱全部SKU完成再推进SKU阶段,混合单需两阶段才生成一条箱事实。无新增字段;前端按历史当前轮次隐藏已完成子项,重复旧SKU须由操作员选实际箱。 + +- 数量拆托成功在现有 `ll_relabel_pda_history` 写 `PALLET_BIND`,保存本轮、箱唛和目标托盘;同轮已组托箱从候选排除并在事务内防重复,返工使用新的 reviewedAt 轮次。PDA 通过详情 history 展示组托记录;不需要新增库存表字段。 + +- 换标按数量拆合托:`palletops/PdaPalletOperationService.relabelSources` 返回原入仓库存池的托盘组成,不能视为箱唛定位;`relabel-disassemble` 的 `actualSource` 携带仓位版本及现场箱数,`cartonMarks` 为本批目标绑定确认。实际托盘与冻结锚点不同时,在同余额/批次内仅重新分配本单冻结桶后合托,库存余额及原入仓单归属不变,保留逐仓位流水、物理资源补偿及幂等。每次一条实际来源仓位,可分批多托;已生成出库单禁止此重分配。认领人处理中即可新增托盘,不需客户确认;此能力不等于已具备真机打印或整单强制组托门禁。 + +- 换标申请生成的 `RELABEL_APPLICATION` 尾程主单使用 `WAIT_RELABEL`(待换标),不进入普通入库计划池;子状态仍区分处理中/待客户确认。客户确认后统一为 `WAIT_OUTBOUND`(待出库),卡车分支按换标申请的 `WAIT_PRE_DISPATCH` 资格参与排车,不重复入库。存量修正脚本 `../scripts/db/relabel-wait-status-incremental.sql` 仅调整尚未生成入库单的遗留待分配换标主单。 + - 混合换标 `CARTON_AND_SKU` 使用现有 PDA 历史保存 `MIXED_CARTON/MIXED_SKU` 分项事实(itemId + 退回轮次);同一项防重复,两项全部完成才写一条箱事实并增加整箱计数。详情返回 `cartonProcessedPackages/skuProcessedPackages`;不得将单项完成当作整箱完成,返工后旧轮次不计入当前分项进度。 - 换标复核闭环:`PdaRelabelService.complete` 直接提交待客户确认,`RelabelApplicationService.confirmCompletion` 原子完成 PDA 复核;平台 `RelabelApplicationController` 的 `/review-task`、`/rework` 按状态权限提供带版本的退回。退回备注必填,上一轮事实及照片引用写入 `REWORK_ARCHIVE` 历史,原事实标记 `REWORK_ARCHIVED` 并释放扫描唯一键,当前计数归零;客户已确认不可退回。PDA 不再自行批准复核。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java index e8ab8e2ff..7397ca9b0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java @@ -70,6 +70,27 @@ public class DeliveryTransferController { return ApiResponse.success(service.createReturnOutbound(requiredCustomer(user, customerId), operator(user), request)); } + @GetMapping("/outbound-orders/warehouse-transfer/candidates") + @PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CREATE + "')") + @Operation(summary = "List warehouse-transfer pallets that can create an outbound order") + public ApiResponse> warehouseTransferCandidates( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @RequestParam(required = false) String warehouseId) { + requirePlatform(user); + return ApiResponse.success(service.warehouseTransferOutboundCandidates(customerId, warehouseId)); + } + + @PostMapping("/outbound-orders/warehouse-transfer") + @PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CREATE + "')") + @Operation(summary = "Create a warehouse-transfer outbound, delivery order and PDA task") + public ApiResponse createWarehouseTransferOutbound( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, + @Valid @RequestBody DeliveryRequests.WarehouseTransferOutbound request) { + return ApiResponse.success(service.createWarehouseTransferOutbound( + requiredCustomer(user, customerId), operator(user), request)); + } + @GetMapping("/outbound-orders") @PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_LIST + "')") public ApiResponse> outboundPage( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java index 659777745..c1b595225 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java @@ -75,6 +75,20 @@ public final class DeliveryRequests { @NotNull @Size(min=1, max=200) List<@NotBlank @Size(max=128) String> cartonMarks, @Size(max=1000) String remark, @NotBlank @Size(max=255) String idempotencyKey) {} + public record WarehouseTransferOutbound( + @NotBlank @Size(max=64) String transferNo, + @NotNull @Size(min=1, max=100) List<@NotNull Long> palletIds, + @NotBlank @jakarta.validation.constraints.Pattern(regexp = "FBA|PRIVATE") String destinationType, + @Size(max=128) String destinationWarehouseId, + @Size(max=128) String destinationWarehouseCode, + @Size(max=255) String destinationWarehouseName, + @Size(max=1000) String deliveryAddress, + @Size(max=128) String contactName, + @Size(max=64) String contactPhone, + @Size(max=32) String deliveryMethod, + LocalDateTime plannedDeliveryAt, + @Size(max=1000) String remark, + @NotBlank @Size(max=255) String idempotencyKey) {} public record OneClickItem(@NotNull Long inventoryPositionId, @NotNull @Min(1) Long packages, @NotBlank @Size(max=128) String sku, @Size(max=200) List<@NotBlank @Size(max=128) String> cartonMarks) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java index df4ae6169..8949e7696 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java @@ -31,7 +31,8 @@ public final class DeliveryResponses { String remark, LocalDateTime createdAt, long version, List allowedActions, String bolNo, DeliveryDocumentDtos.Summary documentSummary, String destinationWarehouseId, String destinationWarehouseCode, - String destinationWarehouseName) {} + String destinationWarehouseName, String destinationType, + String deliveryAddress, String contactName, String contactPhone, String postalCode) {} public record DeliverySku(String inboundCode, String customerName, String destinationWarehouseCode, String receivingAddress, String shipmentNo, String fbaTrackingNo, @@ -59,6 +60,16 @@ public final class DeliveryResponses { String cartonMark, String fbaCartonMark, String destinationWarehouseCode, String destinationWarehouseName, long totalQuantity, String status) {} + public record WarehouseTransferOutboundCandidate(String customerId, String customerName, + String transferNo, String overseasWarehouseId, + String transferStatus, boolean needRelabel, + String relabelNo, String destinationType, + String destinationWarehouseCode, + String privateDestinationAddress, + String privateDestinationPostalCode, + Long palletId, Long warehousePalletId, String palletNo, + int packageCount, BigDecimal grossWeightKg, + BigDecimal volumeCbm) {} public record PdaTask(Long id, String taskNo, String status, String outboundNo, LocalDateTime pushedAt, LocalDateTime completedAt) {} public record PdaTaskResolution(String workflow, String taskNo, String outboundNo, String dispatchNo) {} public record PdaPickPosition(Long inventoryPositionId, String palletNo, String cartonMark, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java index 54ff1a699..2197f002c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java @@ -26,6 +26,11 @@ public class DeliveryOrder { private String destinationWarehouseId; private String destinationWarehouseCode; private String destinationWarehouseName; + private String destinationType; + private String deliveryAddress; + private String postalCode; + private String contactName; + private String contactPhone; private String deliveryGroupKey; private String status; private String waybillNo; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java index 26a7db8fd..0b4a089f4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java @@ -39,6 +39,7 @@ public class DeliveryCompletedOutboxService { Objects.requireNonNull(completedAt, "completedAt"); Map bySourceInbound = new LinkedHashMap<>(); for (LastlegOrder order : orders == null ? List.of() : orders) { + if ("RELABEL_APPLICATION".equals(order.getOrderType())) continue; if (!StringUtils.hasText(order.getSourceInboundId())) continue; requireText(order.getSourceOrderId(), "sourceOrderId"); requireText(order.getInboundCode(), "inboundCode"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index 45b3fe641..157f915ae 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -60,7 +60,9 @@ import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; import com.baiyue.lastleg.relabel.service.RelabelApplicationService; import com.baiyue.lastleg.relabel.dto.RelabelRequests; import com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper; import jakarta.annotation.Resource; import org.springframework.dao.DuplicateKeyException; import org.springframework.http.HttpStatus; @@ -97,6 +99,7 @@ public class DeliveryTransferService { @Resource private InventoryBalanceMapper inventoryBalances; @Resource private InventoryCommandService inventoryCommands; @Resource private WarehouseTransferPalletMapper warehouseTransferPallets; + @Resource private WarehouseTransferOrderMapper warehouseTransfers; @Resource private DropshipOrderProfileMapper dropshipProfiles; @Resource private DropshipCartonMapper dropshipCartons; @Resource private DropshipCartonSkuMapper dropshipCartonSkus; @@ -217,6 +220,11 @@ public class DeliveryTransferService { firstText(destination.getDestinationWarehouseCode(), destination.getFbaWarehouseCode()), destination.getDestinationWarehouseId())); delivery.setDestinationWarehouseName(trim(destination.getDestinationWarehouseName())); + delivery.setDestinationType(destination.getDestinationType()); + delivery.setDeliveryAddress(destination.getDeliveryAddress()); + delivery.setPostalCode(destination.getPostalCode()); + delivery.setContactName(destination.getContactName()); + delivery.setContactPhone(destination.getContactPhone()); delivery.setDeliveryGroupKey(entry.getKey()); LastlegOrder firstOrder = firstTailOrder(customerId, groupItems); delivery.setCustomerName(firstOrder == null ? null : firstOrder.getCustomerName()); delivery.setStatus("WAIT_DELIVERY"); delivery.setVersion(0L); @@ -395,6 +403,155 @@ public class DeliveryTransferService { return taskResult(outbound, task); } + @Transactional(readOnly = true) + public List warehouseTransferOutboundCandidates( + String customerId, String warehouseId) { + List transfers = warehouseTransfers.selectList( + new LambdaQueryWrapper() + .eq(StringUtils.hasText(customerId), WarehouseTransferOrder::getCustomerId, trim(customerId)) + .eq(StringUtils.hasText(warehouseId), WarehouseTransferOrder::getOperatingWarehouseId, trim(warehouseId)) + .eq(WarehouseTransferOrder::getStatus, "WAIT_FBA_DESTINATION") + .orderByDesc(WarehouseTransferOrder::getUpdatedAt)); + List result = new ArrayList<>(); + for (WarehouseTransferOrder transfer : transfers) { + LastlegOrder order = tailOrders.selectById(transfer.getLastlegOrderId()); + List completedPallets = warehouseTransferPallets.listOrder( + transfer.getCustomerId(), transfer.getId()).stream() + .filter(pallet -> "COMPLETED".equals(pallet.getStatus()) && pallet.getWarehousePalletId() != null) + .toList(); + long available = 0; + for (WarehouseTransferPallet pallet : completedPallets) { + long palletAvailable = inventoryPositions.listPallet(transfer.getCustomerId(), pallet.getWarehousePalletId()) + .stream().filter(position -> transfer.getOperatingWarehouseId().equals(position.getOverseasWarehouseId())) + .mapToLong(position -> value(position.getAvailablePackages())).sum(); + available += palletAvailable; + } + if (available <= 0 || completedPallets.isEmpty()) continue; + result.add(new DeliveryResponses.WarehouseTransferOutboundCandidate( + transfer.getCustomerId(), order == null ? null : order.getCustomerName(), transfer.getTransferNo(), + transfer.getOperatingWarehouseId(), transfer.getStatus(), Boolean.TRUE.equals(transfer.getNeedRelabel()), + transfer.getRelabelNo(), transfer.getDestinationType(), transfer.getDestinationWarehouseCode(), + transfer.getPrivateDestinationAddress(), transfer.getPrivateDestinationPostalCode(), + null, null, null, Math.toIntExact(available), + completedPallets.stream().map(WarehouseTransferPallet::getGrossWeightKg).filter(Objects::nonNull) + .reduce(BigDecimal.ZERO, BigDecimal::add), + completedPallets.stream().map(WarehouseTransferPallet::getVolumeCbm).filter(Objects::nonNull) + .reduce(BigDecimal.ZERO, BigDecimal::add))); + } + return result; + } + + @Transactional + public DeliveryResponses.OutboundDetail createWarehouseTransferOutbound(String customerId, String operatorId, + DeliveryRequests.WarehouseTransferOutbound request) { + String key = request.idempotencyKey().trim(); + String requestHash = hash(request); + OutboundOrder replay = outboundOrders.findByIdempotency(customerId, key); + if (replay != null) { + if (!"WAREHOUSE_TRANSFER_OUTBOUND".equals(replay.getOutboundType()) + || !requestHash.equals(replay.getRequestHash())) { + throw conflict("相同幂等键不能用于不同的仓储转运出库请求"); + } + return outboundDetail(replay.getOutboundNo()); + } + WarehouseTransferOrder transfer = warehouseTransfers.lockNo(customerId, request.transferNo().trim()); + if (transfer == null) throw notFound("仓储转运单不存在"); + if (!Set.of("READY_FOR_DISPATCH", "WAIT_FBA_DESTINATION").contains(transfer.getStatus())) { + throw conflict("仓储转运单尚未完成换标或已经生成出库单"); + } + String destinationType = request.destinationType().trim().toUpperCase(Locale.ROOT); + if ("FBA".equals(destinationType) && !StringUtils.hasText(request.destinationWarehouseCode())) { + throw conflict("FBA出库必须选择目的仓"); + } + if ("PRIVATE".equals(destinationType) && (!StringUtils.hasText(request.deliveryAddress()) + || !StringUtils.hasText(request.contactName()) || !StringUtils.hasText(request.contactPhone()))) { + throw conflict("私人仓出库必须填写地址、联系人和联系电话"); + } + List palletIds = request.palletIds().stream().distinct().toList(); + if (palletIds.size() != request.palletIds().size()) throw conflict("出库托盘不可重复"); + List selectedPallets = new ArrayList<>(); + for (Long palletId : palletIds) { + WarehouseTransferPallet pallet = warehouseTransferPallets.lockOne(customerId, transfer.getId(), palletId); + if (pallet == null || !"COMPLETED".equals(pallet.getStatus()) || pallet.getWarehousePalletId() == null) { + throw conflict("所选托盘不属于该仓储转运单或尚未完成入库:" + palletId); + } + selectedPallets.add(pallet); + } + LocalDateTime now = LocalDateTime.now(); + OutboundOrder outbound = new OutboundOrder(); + outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId); + outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND", transfer.getOperatingWarehouseId(), outbound.getId())); + outbound.setOverseasWarehouseId(transfer.getOperatingWarehouseId()); + outbound.setOutboundType("WAREHOUSE_TRANSFER_OUTBOUND"); outbound.setStatus("WAIT_PICK"); + outbound.setRecipientInfo("PRIVATE".equals(destinationType) + ? request.contactName().trim() + " / " + request.contactPhone().trim() + " / " + request.deliveryAddress().trim() + : request.destinationWarehouseCode().trim()); + outbound.setPalletCount(selectedPallets.size()); outbound.setIdempotencyKey(key); + outbound.setRequestHash(requestHash); outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); + outboundOrders.insert(outbound); + + int packageCount = 0; + for (WarehouseTransferPallet pallet : selectedPallets) { + List positions = inventoryPositions.listPallet(customerId, pallet.getWarehousePalletId()) + .stream().filter(position -> transfer.getOperatingWarehouseId().equals(position.getOverseasWarehouseId())) + .filter(position -> value(position.getAvailablePackages()) > 0).toList(); + if (positions.isEmpty()) throw conflict("所选托盘没有可出库库存:" + pallet.getPalletNo()); + for (InventoryPosition position : positions) { + InventoryBalance balance = inventoryBalances.selectById(position.getBalanceId()); + if (balance == null || !customerId.equals(balance.getCustomerId())) throw conflict("托盘库存余额不存在"); + int packages = Math.toIntExact(value(position.getAvailablePackages())); + OutboundPalletItem item = new OutboundPalletItem(); + item.setId(IdWorker.getId()); item.setCustomerId(customerId); item.setOutboundOrderId(outbound.getId()); + item.setWarehousePalletId(pallet.getWarehousePalletId()); item.setPalletNo(pallet.getPalletNo()); + item.setDestinationWarehouseId(trim(request.destinationWarehouseId())); + item.setDestinationWarehouseCode(trim(request.destinationWarehouseCode())); + item.setDestinationWarehouseName(trim(request.destinationWarehouseName())); + item.setPackageCount(packages); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK"); + item.setSkuCode(balance.getSkuCode()); item.setInboundCode(balance.getInboundCode()); + item.setOrderRefs(transfer.getTransferNo()); item.setCreatedAt(now); outboundPallets.insert(item); + packageCount = Math.addExact(packageCount, packages); + } + } + outbound.setPackageCount(packageCount); outboundOrders.updateById(outbound); + + LastlegOrder tailOrder = tailOrders.selectById(transfer.getLastlegOrderId()); + DeliveryOrder delivery = new DeliveryOrder(); + delivery.setId(IdWorker.getId()); delivery.setCustomerId(customerId); + delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY", transfer.getOperatingWarehouseId(), delivery.getId())); + delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo()); + delivery.setOverseasWarehouseId(transfer.getOperatingWarehouseId()); + delivery.setCustomerName(tailOrder == null ? null : tailOrder.getCustomerName()); + delivery.setDestinationType(destinationType); delivery.setDestinationWarehouseId(trim(request.destinationWarehouseId())); + delivery.setDestinationWarehouseCode(trim(request.destinationWarehouseCode())); + delivery.setDestinationWarehouseName(trim(request.destinationWarehouseName())); + delivery.setDeliveryAddress(trim(request.deliveryAddress())); delivery.setContactName(trim(request.contactName())); + delivery.setContactPhone(trim(request.contactPhone())); delivery.setDeliveryGroupKey(destinationType + ":" + + firstText(request.destinationWarehouseCode(), request.deliveryAddress())); + delivery.setStatus("WAIT_DELIVERY"); delivery.setDeliveryMethod(firstText(request.deliveryMethod(), "TRUCK")); + delivery.setPackageCount(packageCount); delivery.setPalletCount(selectedPallets.size()); + delivery.setTotalWeightKg(selectedPallets.stream().map(WarehouseTransferPallet::getGrossWeightKg) + .filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add)); + delivery.setTotalVolumeCbm(selectedPallets.stream().map(WarehouseTransferPallet::getVolumeCbm) + .filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add)); + delivery.setPlannedDeliveryAt(request.plannedDeliveryAt()); delivery.setRemark(trim(request.remark())); + delivery.setIdempotencyKey(childKey(key, "DELIVERY")); delivery.setCreatedBy(operatorId); delivery.setVersion(0L); + delivery.setCreatedAt(now); delivery.setUpdatedAt(now); deliveryOrders.insert(delivery); + recordOperation(customerId, delivery.getId(), operatorId, "CREATE_DELIVERY", "仓储转运出库自动生成派送单", now); + + OutboundPdaTask task = new OutboundPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); + task.setOutboundOrderId(outbound.getId()); task.setTaskNo("PDA" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + task.getId()); + task.setStatus("PENDING"); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task); + transfer.setStatus("WAIT_OUTBOUND"); + if ("FBA".equals(destinationType)) transfer.setFbaWarehouseCode(request.destinationWarehouseCode().trim()); + transfer.setUpdatedAt(now); warehouseTransfers.updateById(transfer); + if (tailOrder != null && customerId.equals(tailOrder.getCustomerId())) { + tailOrder.setCurrentStatus("WAIT_OUTBOUND"); tailOrder.setOutboundOrderNo(outbound.getOutboundNo()); + tailOrder.setDeliveryOrderNo(delivery.getDeliveryNo()); tailOrder.setFbaWarehouseCode(trim(request.destinationWarehouseCode())); + tailOrder.setSubOrderStatus("WAREHOUSE_TRANSFER:WAIT_OUTBOUND"); tailOrder.setUpdatedAt(now); tailOrders.updateById(tailOrder); + } + return outboundDetail(outbound.getOutboundNo()); + } + private String validateOneClickItems(String customerId, List items) { String warehouseId = null; Map requestedPackages = new LinkedHashMap<>(); @@ -541,6 +698,40 @@ public class DeliveryTransferService { task.setStatus("IN_PROGRESS"); task.setUpdatedAt(LocalDateTime.now()); pdaTasks.updateById(task); return taskResult(outbound, task); } + if ("WAREHOUSE_TRANSFER_OUTBOUND".equals(outbound.getOutboundType())) { + if (request.inventoryPositionId() == null) throw conflict("仓储转运出库必须确认实际库存位置"); + InventoryPosition selectedPosition = inventoryPositions.selectById(request.inventoryPositionId()); + if (selectedPosition == null || !customerId.equals(selectedPosition.getCustomerId()) + || !warehouseId.equals(selectedPosition.getOverseasWarehouseId()) + || !Objects.equals(selectedPosition.getPalletId(), item.getWarehousePalletId())) { + throw conflict("确认的库存位置不属于该仓储转运出库托盘"); + } + InventoryBalance balance = inventoryBalances.selectById(selectedPosition.getBalanceId()); + if (!matchesOutboundItem(customerId, warehouseId, item, balance)) { + throw conflict("确认的库存SKU或入仓码与出库明细不一致"); + } + if (request.packages() > value(item.getPackageCount()) - value(item.getPickedPackageCount())) { + throw conflict("出库数量超过仓储转运出库明细余量"); + } + inventoryCommands.allocate(customerId, operatorId, new InventoryCommandRequests.Move( + selectedPosition.getId(), request.packages(), item.getOrderRefs(), outbound.getOutboundNo(), null, + request.idempotencyKey() + ":ALLOCATE", "仓储转运出库分配")); + inventoryCommands.pick(customerId, operatorId, new InventoryCommandRequests.Move( + selectedPosition.getId(), request.packages(), item.getOrderRefs(), outbound.getOutboundNo(), null, + request.idempotencyKey() + ":PICK", "仓储转运出库拣货")); + inventoryCommands.ship(customerId, operatorId, new InventoryCommandRequests.Move( + selectedPosition.getId(), request.packages(), item.getOrderRefs(), outbound.getOutboundNo(), null, + request.idempotencyKey() + ":SHIP", "仓储转运出库")); + OutboundPdaPick pick = new OutboundPdaPick(); pick.setId(IdWorker.getId()); pick.setCustomerId(customerId); + pick.setTaskId(task.getId()); pick.setOutboundPalletItemId(item.getId()); + pick.setInventoryPositionId(selectedPosition.getId()); pick.setPackages(request.packages()); + pick.setIdempotencyKey(request.idempotencyKey().trim()); pick.setPickedAt(LocalDateTime.now()); pdaPicks.insert(pick); + item.setPickedPackageCount(value(item.getPickedPackageCount()) + request.packages().intValue()); + item.setStatus(item.getPickedPackageCount() >= item.getPackageCount() ? "PICKED" : "PARTIAL_PICKED"); + outboundPallets.updateById(item); updateOutboundPickingStatus(customerId, outbound); + task.setStatus("IN_PROGRESS"); task.setUpdatedAt(LocalDateTime.now()); pdaTasks.updateById(task); + return taskResult(outbound, task); + } InventoryPosition selectedPosition = inventoryPositions.selectById(request.inventoryPositionId()); if (selectedPosition == null || !customerId.equals(selectedPosition.getCustomerId()) || !Objects.equals(selectedPosition.getPalletId(), item.getWarehousePalletId())) throw conflict("Scanned inventory position does not belong to the outbound pallet"); if (!warehouseId.equals(selectedPosition.getOverseasWarehouseId())) { @@ -612,8 +803,8 @@ public class DeliveryTransferService { if (outbound == null || !boundWarehouseId.equals(outbound.getOverseasWarehouseId())) { throw notFound("Outbound order not found in the bound warehouse"); } - if (!"ONE_CLICK_DROPSHIP".equals(outbound.getOutboundType())) { - throw conflict("整托扫码仅适用于一件代发出库"); + if (!Set.of("ONE_CLICK_DROPSHIP", "RELABEL_OUTBOUND", "WAREHOUSE_TRANSFER_OUTBOUND").contains(outbound.getOutboundType())) { + throw conflict("整托扫码仅适用于一件代发或换标出库"); } if (!Set.of("PENDING", "IN_PROGRESS").contains(task.getStatus())) { throw conflict("PDA task is not available for picking"); @@ -695,6 +886,23 @@ public class DeliveryTransferService { new RelabelRequests.ChangeStatus("COMPLETED", "换标出库PDA作业完成")); } } + if ("WAREHOUSE_TRANSFER_OUTBOUND".equals(outbound.getOutboundType())) { + List transferNos = items.stream().map(OutboundPalletItem::getOrderRefs) + .filter(StringUtils::hasText).distinct().toList(); + for (String transferNo : transferNos) { + WarehouseTransferOrder transfer = warehouseTransfers.lockNo(customerId, transferNo); + if (transfer != null && "WAIT_OUTBOUND".equals(transfer.getStatus())) { + transfer.setStatus("OUTBOUNDED"); transfer.setOutboundAt(now); transfer.setUpdatedAt(now); + warehouseTransfers.updateById(transfer); + LastlegOrder order = tailOrders.selectById(transfer.getLastlegOrderId()); + if (order != null && customerId.equals(order.getCustomerId())) { + order.setCurrentStatus("IN_TRANSIT"); + order.setSubOrderStatus("WAREHOUSE_TRANSFER:OUTBOUNDED"); order.setUpdatedAt(now); + tailOrders.updateById(order); + } + } + } + } return taskResult(outbound, task); } @@ -724,6 +932,23 @@ public class DeliveryTransferService { if (task != null) { task.setStatus("CANCELLED"); task.setUpdatedAt(now); pdaTasks.updateById(task); } outbound.setStatus("CANCELLED"); outbound.setUpdatedAt(now); outboundOrders.updateById(outbound); for (OutboundPalletItem item : outboundItems) { item.setStatus("CANCELLED"); outboundPallets.updateById(item); } + if ("WAREHOUSE_TRANSFER_OUTBOUND".equals(outbound.getOutboundType())) { + for (String transferNo : outboundItems.stream().map(OutboundPalletItem::getOrderRefs) + .filter(StringUtils::hasText).distinct().toList()) { + WarehouseTransferOrder transfer = warehouseTransfers.lockNo(customerId, transferNo); + if (transfer == null || !"WAIT_OUTBOUND".equals(transfer.getStatus())) continue; + String restored = StringUtils.hasText(transfer.getFbaWarehouseCode()) + ? "READY_FOR_DISPATCH" : "WAIT_FBA_DESTINATION"; + transfer.setStatus(restored); transfer.setUpdatedAt(now); warehouseTransfers.updateById(transfer); + LastlegOrder order = tailOrders.selectById(transfer.getLastlegOrderId()); + if (order != null && customerId.equals(order.getCustomerId())) { + order.setCurrentStatus(restored); order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + restored); + order.setOutboundOrderNo(null); order.setDeliveryOrderNo(null); order.setUpdatedAt(now); + tailOrders.updateById(order); + } + } + return; + } if (!existingDeliveries.isEmpty()) { for (LastlegOrder order : tailOrders.selectList(new LambdaQueryWrapper().eq(LastlegOrder::getCustomerId, customerId).eq(LastlegOrder::getOutboundOrderNo, outboundNo))) { String from = order.getCurrentStatus(); order.setCurrentStatus("RECEIVED"); order.setSubOrderStatus(null); order.setOutboundOrderNo(null); order.setDeliveryOrderNo(null); order.setUpdatedAt(now); tailOrders.updateById(order); @@ -958,17 +1183,20 @@ public class DeliveryTransferService { candidate.getInboundCode(), candidate.getSkuCode(), candidate.getProductName(), value(candidate.getAvailablePackages())); } - private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName()); } + private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName(), d.getDestinationType(), d.getDeliveryAddress(), d.getContactName(), d.getContactPhone(), d.getPostalCode()); } private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), null, d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); } private DeliveryResponses.DeliveryFeeRow feeRow(OverseasFeeSource f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getDirection(), f.getFeeItemCode(), f.getOriginalCurrency(), f.getOriginalAmount(), f.getReviewReason()); } private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); } private void recordOperation(String customerId, Long deliveryId, String operatorId, String node, String result, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(deliveryId); operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); } private LastlegOrder firstTailOrder(String customerId, List items) { return items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).map(tailOrders::selectById).filter(Objects::nonNull).filter(order -> customerId.equals(order.getCustomerId())).findFirst().orElse(null); } private String deliveryGroupKey(DispatchItem item) { + if ("PRIVATE".equals(item.getDestinationType()) && StringUtils.hasText(item.getDeliveryAddress())) { + return "PRIVATE:" + item.getDeliveryAddress().trim() + ":" + Objects.toString(item.getPostalCode(), "").trim(); + } String code = firstText(item.getDestinationWarehouseCode(), item.getFbaWarehouseCode()); if (StringUtils.hasText(code)) return "CODE:" + code.toUpperCase(Locale.ROOT); if (StringUtils.hasText(item.getDestinationWarehouseId())) return "ID:" + item.getDestinationWarehouseId().trim(); - throw conflict("Dispatch pallet is missing its destination FBA warehouse"); + throw conflict("Dispatch pallet is missing its destination"); } private String resolveLxCodes(String customerId, List items) { List inboundCodes = items.stream().map(DispatchItem::getInboundCode) @@ -1029,6 +1257,14 @@ public class DeliveryTransferService { return measuredWeight.add(orderWeight); } private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } + private String hash(Object value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(objectMapper.writeValueAsString(value).getBytes(StandardCharsets.UTF_8))); + } catch (JsonProcessingException | NoSuchAlgorithmException error) { + throw new IllegalStateException("无法生成请求摘要", error); + } + } private static Long parseLong(String value) { try { return StringUtils.hasText(value) ? Long.parseLong(value.trim()) : null; } catch (NumberFormatException ignored) { return null; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java index 97a78fe5f..6f111fd19 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java @@ -46,7 +46,7 @@ public class SmartDispatchController { @RequestParam(required = false) String sourceStage, @RequestParam(required = false) String draftToken) { requirePlatform(user); - if (planIds == null || planIds.isEmpty()) { + if ("RELABEL".equals(sourceStage) || planIds == null || planIds.isEmpty()) { return ApiResponse.success(service.platformCandidates(orderNo, fbaWarehouseCode, sourceStage, draftToken)); } return ApiResponse.success(service.candidates(service.resolveCustomerScopeForPlans(planIds), planIds, orderNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java index 974f39ea4..d3e4a1505 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java @@ -30,6 +30,11 @@ public class DispatchItem { private String destinationWarehouseCode; private String destinationWarehouseName; private String fbaWarehouseCode; + private String destinationType; + private String deliveryAddress; + private String postalCode; + private String contactName; + private String contactPhone; private Integer packageCount; private BigDecimal volumeCbm; /** PLAN for planned pallets; RECEIVED for warehouse/entity pallets. */ 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 e0c93edf6..6a097c582 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 @@ -64,16 +64,20 @@ public interface DispatchCandidateMapper { "JOIN ll_relabel_application ra ON ra.id=ri.application_id AND ra.customer_id=ri.customer_id " + "WHERE ri.customer_id=ip.customer_id AND rp.pallet_id=p.warehouse_pallet_id " + "AND ri.fba_carton_mark IS NOT NULL AND ri.fba_carton_mark != '' " + - "AND ra.status NOT IN ('REJECTED','CANCELLED')) AS relabelCartonMarks " + + "AND ra.status NOT IN ('REJECTED','CANCELLED')) AS relabelCartonMarks," + + "MIN(l.destination_type) AS destinationType,MIN(l.delivery_address) AS deliveryAddress," + + "MIN(l.postal_code) AS postalCode,MIN(l.contact_name) AS contactName,MIN(l.contact_phone) AS contactPhone " + "FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " + "LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " + "LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id " + "LEFT JOIN ll_order o ON o.id=l.order_id " + - "WHERE 1=1 " + + "WHERE ((l.destination_type='FBA' AND NULLIF(TRIM(l.fba_warehouse_code),'') IS NOT NULL) OR " + + "(l.destination_type='PRIVATE' AND NULLIF(TRIM(l.delivery_address),'') IS NOT NULL AND NULLIF(TRIM(l.postal_code),'') IS NOT NULL " + + "AND NULLIF(TRIM(l.contact_name),'') IS NOT NULL AND NULLIF(TRIM(l.contact_phone),'') IS NOT NULL)) " + " AND ip.customer_id=#{customerId} " + " AND p.plan_id IN " + "#{id} " + - " AND o.order_no LIKE CONCAT('%',#{orderNo},'%') " + + " AND (o.order_no LIKE CONCAT('%',#{orderNo},'%') OR l.inbound_code LIKE CONCAT('%',#{orderNo},'%')) " + " AND l.fba_warehouse_code=#{fbaWarehouseCode} " + " AND " + "CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END=#{sourceStage} " + @@ -146,13 +150,18 @@ public interface DispatchCandidateMapper { "JOIN ll_relabel_application ra ON ra.id=ri.application_id AND ra.customer_id=ri.customer_id " + "WHERE ri.customer_id=ip.customer_id AND rp.pallet_id=p.warehouse_pallet_id " + "AND ri.fba_carton_mark IS NOT NULL AND ri.fba_carton_mark != '' " + - "AND ra.status NOT IN ('REJECTED','CANCELLED')) AS relabelCartonMarks " + + "AND ra.status NOT IN ('REJECTED','CANCELLED')) AS relabelCartonMarks," + + "MIN(l.destination_type) AS destinationType,MIN(l.delivery_address) AS deliveryAddress," + + "MIN(l.postal_code) AS postalCode,MIN(l.contact_name) AS contactName,MIN(l.contact_phone) AS contactPhone " + "FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " + "LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " + "LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id " + "LEFT JOIN ll_order o ON o.id=l.order_id " + "WHERE ip.customer_id=#{customerId} AND p.id IN " + "#{id} " + + "AND ((l.destination_type='FBA' AND NULLIF(TRIM(l.fba_warehouse_code),'') IS NOT NULL) OR " + + "(l.destination_type='PRIVATE' AND NULLIF(TRIM(l.delivery_address),'') IS NOT NULL AND NULLIF(TRIM(l.postal_code),'') IS NOT NULL " + + "AND NULLIF(TRIM(l.contact_name),'') IS NOT NULL AND NULLIF(TRIM(l.contact_phone),'') IS NOT NULL)) " + "AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation ha " + "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 " + @@ -184,7 +193,8 @@ public interface DispatchCandidateMapper { "GROUP_CONCAT(DISTINCT ri.destination_fba_warehouse_code ORDER BY ri.destination_fba_warehouse_code SEPARATOR ',') AS fbaWarehouseCode," + "SUM(ri.notified_package_count) AS packageCount,MAX(wtp.volume_cbm) AS volumeCbm,'RELABEL' AS sourceStage," + "NULL AS cartonMarks,GROUP_CONCAT(DISTINCT COALESCE(NULLIF(ri.fba_carton_mark,''),ri.carton_mark) " + - "ORDER BY COALESCE(NULLIF(ri.fba_carton_mark,''),ri.carton_mark) SEPARATOR ',') AS relabelCartonMarks " + + "ORDER BY COALESCE(NULLIF(ri.fba_carton_mark,''),ri.carton_mark) SEPARATOR ',') AS relabelCartonMarks," + + "'FBA' AS destinationType,NULL AS deliveryAddress,NULL AS postalCode,NULL AS contactName,NULL AS contactPhone " + "FROM ll_relabel_application a JOIN ll_relabel_item ri ON ri.application_id=a.id AND ri.customer_id=a.customer_id " + "JOIN ll_inventory_position pos ON pos.id=ri.source_inventory_position_id AND pos.customer_id=a.customer_id " + "LEFT JOIN ll_warehouse_transfer_order wto ON wto.relabel_no=a.relabel_no AND wto.customer_id=a.customer_id " + @@ -193,7 +203,7 @@ public interface DispatchCandidateMapper { "WHERE a.delivery_method='TRUCK' AND a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' " + "AND a.customer_id=#{customerId} " + "AND a.relabel_no=#{relabelNo} " + - "AND a.relabel_no LIKE CONCAT('%',#{orderNo},'%') " + + "AND (a.relabel_no LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_code LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_order_no LIKE CONCAT('%',#{orderNo},'%')) " + "AND pos.hold_packages > 0 " + "AND NOT EXISTS (SELECT 1 FROM ll_dispatch_item di JOIN ll_dispatch_order existing_dispatch " + "ON existing_dispatch.id=di.dispatch_order_id AND existing_dispatch.customer_id=di.customer_id " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java index de1133d08..7e706809a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java @@ -30,7 +30,12 @@ public record DispatchPalletCandidate( BigDecimal volumeCbm, String sourceStage, String cartonMarks, - String relabelCartonMarks + String relabelCartonMarks, + String destinationType, + String deliveryAddress, + String postalCode, + String contactName, + String contactPhone ) { public DispatchPalletCandidate(Long planId, Long planPalletId, Long warehousePalletId, Long orderId, String overseasWarehouseId, String overseasWarehouseName, String orderNo, @@ -41,7 +46,7 @@ public record DispatchPalletCandidate( this(planId, planPalletId, warehousePalletId, orderId, null, null, null, null, null, null, null, null, overseasWarehouseId, overseasWarehouseName, orderNo, orderRefs, inboundCode, palletNo, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName, fbaWarehouseCode, packageCount, volumeCbm, - sourceStage, null, null); + sourceStage, null, null, null, null, null, null, null); } public DispatchPalletCandidate(Long planId, Long planPalletId, Long warehousePalletId, Long orderId, @@ -51,7 +56,7 @@ public record DispatchPalletCandidate( BigDecimal volumeCbm, String sourceStage) { this(planId, planPalletId, warehousePalletId, orderId, null, null, null, null, null, null, null, null, null, null, orderNo, orderRefs, inboundCode, palletNo, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName, - fbaWarehouseCode, packageCount, volumeCbm, sourceStage, null, null); + fbaWarehouseCode, packageCount, volumeCbm, sourceStage, null, null, null, null, null, null, null); } public String sourcePalletKey() { // Once received, the physical pallet is the shared resource even when it contains 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 684d3a5e3..580a2c042 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 @@ -66,6 +66,7 @@ public class DispatchService { @Resource private LastlegOrderMapper tailOrders; @Resource private OutboundOrderMapper outboundOrders; @Resource private RelabelApplicationMapper relabelApplications; + @Resource private com.baiyue.lastleg.relabel.service.RelabelBindingGuard relabelBindingGuard; public String resolveCustomerScopeForPlans(List planIds) { return requireSingleCustomerScope(candidates.findCustomerScopesByPlanIds(normalizeIds(planIds, "Plan IDs")), @@ -107,12 +108,14 @@ public class DispatchService { public List platformCandidates(String orderNo, String fbaWarehouseCode, String sourceStage, String draftToken) { validateSourceStage(sourceStage); - List result = candidates.available(null, null, trimToNull(orderNo), trimToNull(fbaWarehouseCode), + List result = "RELABEL".equals(sourceStage) ? List.of() : candidates.available(null, null, trimToNull(orderNo), trimToNull(fbaWarehouseCode), trimToNull(sourceStage), trimToNull(draftToken)); - if (!StringUtils.hasText(sourceStage) || "RECEIVED".equals(sourceStage)) { + if (!StringUtils.hasText(sourceStage) || Set.of("RECEIVED", "RELABEL").contains(sourceStage)) { List relabel = candidates.relabelAvailable(null, trimToNull(orderNo), null); if (relabel != null && !relabel.isEmpty()) { - result = java.util.stream.Stream.concat(result.stream(), relabel.stream()).toList(); + result = java.util.stream.Stream.concat(result.stream(), relabel.stream().filter(item -> + !StringUtils.hasText(fbaWarehouseCode) || Objects.toString(item.fbaWarehouseCode(), "") + .toUpperCase(java.util.Locale.ROOT).contains(fbaWarehouseCode.trim().toUpperCase(java.util.Locale.ROOT)))).toList(); } } return result; @@ -270,6 +273,11 @@ public class DispatchService { if (selected.size() != palletIds.size()) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more selected pallets are not in this tenant"); } + if (selected.stream().anyMatch(candidate -> !hasValidDestination(candidate))) { + boolean legacyMissingFba = selected.stream().allMatch(candidate -> !"PRIVATE".equals(candidate.destinationType())); + throw new ResponseStatusException(HttpStatus.CONFLICT, legacyMissingFba + ? "所选托盘尚未设置 FBA 仓,不能智能排车" : "所选托盘尚未设置有效目的地,不能智能排车"); + } assertNoDetainedOrders(customerId, selected); boolean missingWarehouse = selected.stream().map(DispatchPalletCandidate::overseasWarehouseId) .anyMatch(value -> value == null || value.isBlank()); @@ -396,6 +404,11 @@ public class DispatchService { item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); item.setDestinationWarehouseName(candidate.destinationWarehouseName()); item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); + item.setDestinationType(candidate.destinationType()); + item.setDeliveryAddress(candidate.deliveryAddress()); + item.setPostalCode(candidate.postalCode()); + item.setContactName(candidate.contactName()); + item.setContactPhone(candidate.contactPhone()); item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); item.setSourceStage(candidate.sourceStage()); @@ -433,6 +446,7 @@ public class DispatchService { if (application == null || !RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) { throw new ResponseStatusException(HttpStatus.CONFLICT, "换标尚未完成或客户尚未确认,无法进行排车"); } + relabelBindingGuard.requireBound(application); List selected = candidates.relabelAvailable(customerId, null, request.relabelNo()); Set requested = request.planPalletIds().stream().map(Long::valueOf).collect(Collectors.toSet()); selected = selected.stream().filter(c -> requested.contains(c.warehousePalletId())).toList(); @@ -653,10 +667,23 @@ public class DispatchService { } private static String candidateFbaCode(DispatchPalletCandidate candidate) { + if ("PRIVATE".equals(candidate.destinationType())) { + return java.util.stream.Stream.of(candidate.deliveryAddress(), candidate.postalCode()) + .filter(StringUtils::hasText).map(String::trim).collect(Collectors.joining(" ")); + } return StringUtils.hasText(candidate.fbaWarehouseCode()) ? candidate.fbaWarehouseCode().trim() : trimToNull(candidate.destinationWarehouseCode()); } + private static boolean hasValidDestination(DispatchPalletCandidate candidate) { + if ("PRIVATE".equals(candidate.destinationType())) { + return StringUtils.hasText(candidate.deliveryAddress()) && StringUtils.hasText(candidate.postalCode()) + && StringUtils.hasText(candidate.contactName()) && StringUtils.hasText(candidate.contactPhone()); + } + return StringUtils.hasText(candidate.fbaWarehouseCode()) + || StringUtils.hasText(candidate.destinationWarehouseCode()); + } + private static String itemFbaCode(DispatchItem item) { return StringUtils.hasText(item.getFbaWarehouseCode()) ? item.getFbaWarehouseCode().trim() : trimToNull(item.getDestinationWarehouseCode()); @@ -716,8 +743,8 @@ public class DispatchService { } private static void validateSourceStage(String sourceStage) { - if (StringUtils.hasText(sourceStage) && !Set.of("PLAN", "RECEIVED").contains(sourceStage)) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "sourceStage must be PLAN or RECEIVED"); + if (StringUtils.hasText(sourceStage) && !Set.of("PLAN", "RECEIVED", "RELABEL").contains(sourceStage)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "sourceStage must be PLAN, RECEIVED or RELABEL"); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java index 1fb910d2b..ee0c3a2da 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java @@ -41,7 +41,12 @@ public record LastlegOrderSummary( String nodeSource, int version, Integer palletCount, + @Schema(description = "客户预报箱数") Integer packageCount, + @Schema(description = "PDA 实收箱数;尚未完成收货时为空") + Integer receivedPackageCount, + @Schema(description = "实收减预报箱数;尚未完成收货时为空") + Integer packageDifference, BigDecimal volumeCbm, Integer productQuantity, BigDecimal volumeWeightKg, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java index b56ab8473..b88f50112 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java @@ -44,6 +44,7 @@ public class LastlegOrder { private Integer version; private Integer palletCount; private Integer packageCount; + private Integer receivedPackageCount; private Integer pickedPackageCount; private Integer shippedPackageCount; private BigDecimal volumeCbm; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanLine.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanLine.java index 6e8cd25d4..1851a8c51 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanLine.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanLine.java @@ -21,6 +21,10 @@ public class InboundPlanLine { private String destinationWarehouseId; private String destinationWarehouseCode; private String destinationWarehouseName; + private String deliveryAddress; + private String postalCode; + private String contactName; + private String contactPhone; private BigDecimal volumeCbm; private Integer requiredPackageCount; private Integer plannedPalletCount; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPallet.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPallet.java index 409059fca..6b2a6a53e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPallet.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPallet.java @@ -22,6 +22,11 @@ public class InboundPlanPallet { private String destinationWarehouseId; private String destinationWarehouseCode; private String destinationWarehouseName; + private String destinationType; + private String deliveryAddress; + private String postalCode; + private String contactName; + private String contactPhone; private BigDecimal capacityCbm; private BigDecimal allocatedVolumeCbm; private Integer sequenceNo; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java index 86caa1c1c..4983eda0d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java @@ -6,6 +6,8 @@ import com.fasterxml.jackson.annotation.JsonAlias; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatusCode; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; import org.springframework.web.server.ResponseStatusException; @@ -293,6 +295,27 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { return palletBatch("/internal/v1/warehouse-pallet-batches/compensate", request); } + @Override + public PrintableFile printPalletLabel(long warehouseId, long palletId, String transferNo, String requestId) { + ResponseEntity response = client.get() + .uri(builder -> builder.path("/internal/v1/warehouse-pallets/{warehouseId}/{palletId}/label") + .queryParam("transferNo", transferNo).build(warehouseId, palletId)) + .header("X-Internal-Service-Token", internalToken) + .header("X-Request-Id", requestId == null ? "" : requestId) + .retrieve().onStatus(HttpStatusCode::isError, (request, result) -> { + throw new ResponseStatusException(result.getStatusCode().is4xxClientError() + ? HttpStatus.NOT_FOUND : HttpStatus.SERVICE_UNAVAILABLE, + "Warehouse pallet label printing failed"); + }).toEntity(byte[].class); + byte[] content = response.getBody() == null ? new byte[0] : response.getBody(); + if (content.length == 0) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "Warehouse pallet label printing returned an empty PDF"); + String fileName = response.getHeaders().getContentDisposition().getFilename(); + String contentType = response.getHeaders().getContentType() == null + ? MediaType.APPLICATION_PDF_VALUE : response.getHeaders().getContentType().toString(); + return new PrintableFile(content, fileName == null ? "pallet-label.pdf" : fileName, contentType); + } + private PalletBatchResponse palletBatch(String path, Object body) { PalletBatchEnvelope response = client.post().uri(path) .header("X-Internal-Service-Token", internalToken).body(body).retrieve() diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java index 1d1944f95..fa7170875 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java @@ -39,6 +39,9 @@ public interface WarehouseResourceGateway { default PalletBatchResponse compensatePalletBatch(PalletBatchCompensation request) { throw new UnsupportedOperationException("Warehouse pallet batch compensation is not configured"); } + default PrintableFile printPalletLabel(long warehouseId, long palletId, String transferNo, String requestId) { + throw new UnsupportedOperationException("Warehouse pallet label printing is not configured"); + } record WarehousePalletReference(long palletId, String palletNo, long warehouseId, long locationId, String status, String fbaWarehouseCode, java.math.BigDecimal unitPrice, int version) { @@ -67,4 +70,5 @@ public interface WarehouseResourceGateway { record PalletBatchItem(long customerId, String businessKey, String inboundPlanNo, int sequenceNo, long palletId, String palletCode, long locationId, long resourceLockId, String status) { } + record PrintableFile(byte[] content, String fileName, String contentType) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java index e4f703b6b..513eb40e4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java @@ -511,9 +511,6 @@ public class InventoryCommandService { String skuCode = sku(requestedSkuCode); String destinationId = trim(destinationWarehouseId); String destinationCode = trim(destinationWarehouseCode); - if (destinationId == null && destinationCode == null) { - throw badRequest("Destination warehouse ID or code is required"); - } if (destinationCode == null) destinationCode = ""; InventoryBalance balance = balances.lockDimension(customerId, frozenWarehouseId, destinationCode, inboundOrderId, inboundCode); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderStatus.java b/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderStatus.java index a3e5b145f..639555181 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderStatus.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderStatus.java @@ -2,9 +2,22 @@ package com.baiyue.lastleg.model; /** Standard main-process statuses for a last-leg order. */ public enum LastlegOrderStatus { + PENDING_REVIEW("待审核"), + INBOUND_MEASURING("入库测量中"), PENDING_ASSIGNMENT("待分配"), PENDING_INBOUND("待入库"), INBOUND_COMPLETED("入库完成"), + WAIT_RELABEL("待换标"), + WAIT_FBA_DESTINATION("待确认目的仓"), + READY_FOR_DISPATCH("待创建出库单"), + WAIT_OUTBOUND("待出库"), + ALLOCATED("已分配库存"), + OUTBOUNDED("已出库"), + PARTIALLY_RECEIVED("部分收货"), + RECEIVED("已收货"), + IN_TRANSIT("运输中"), + COMPLETED("已完成"), + CANCELLED("已取消"), PENDING_DELIVERY("待派送"), DELIVERING("派送中"), DELIVERED("派送完成"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java index 069b72eb2..74886139e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java @@ -28,6 +28,13 @@ public class PdaPalletOperationController { return ApiResponse.success(service.resolve(PdaWarehouseIdentity.require(user), request)); } + @PostMapping("/relabel-sources") + public ApiResponse> relabelSources( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PalletOperationRequests.RelabelSources request) { + return ApiResponse.success(service.relabelSources(PdaWarehouseIdentity.require(user), request)); + } + @PostMapping("/relabel-pallet-label") public ApiResponse createRelabelPallet( @AuthenticationPrincipal AuthenticatedUser user, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationRequests.java index 2a2922edd..3194e89d6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationRequests.java @@ -13,6 +13,7 @@ public final class PalletOperationRequests { private PalletOperationRequests() { } public record Scan(@NotBlank @Size(max = 128) String code) { } + public record RelabelSources(@NotBlank @Size(max = 64) String relabelNo) { } public record NewRelabelPallet(@NotBlank @Size(max = 64) String relabelNo, @NotBlank @Size(max = 128) String idempotencyKey) { } public record PalletExpectation(@NotBlank @Size(max = 128) String palletCode, @@ -33,13 +34,19 @@ public final class PalletOperationRequests { @NotEmpty @Size(max = 20) List<@Valid TargetMove> targets, @Size(max = 500) String reason) { } - /** 换标场景按箱唛拆托并绑定到一个目标托盘。箱唛位置由服务端从换标单快照解析。 */ + /** actualSource为现场确认的原入仓库存行;cartonMarks确认本批目标绑定,不推断历史箱唛位置。 */ public record RelabelDisassemble(@NotBlank @Size(max = 128) String idempotencyKey, @NotBlank @Size(max = 64) String relabelNo, @Valid PalletExpectation source, @Valid PalletExpectation target, @NotEmpty @Size(max = 500) List<@NotBlank @Size(max = 255) String> cartonMarks, - @Size(max = 500) String reason) { } + @Size(max = 500) String reason, + @Valid MoveLine actualSource) { + public RelabelDisassemble(String idempotencyKey, String relabelNo, PalletExpectation source, + PalletExpectation target, List cartonMarks, String reason) { + this(idempotencyKey, relabelNo, source, target, cartonMarks, reason, null); + } + } public record Swap(@NotBlank @Size(max = 128) String idempotencyKey, @Valid PalletExpectation source, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationResponses.java index 462c13fab..1017dd630 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationResponses.java @@ -5,6 +5,9 @@ import java.util.List; public final class PalletOperationResponses { private PalletOperationResponses() { } + public record RelabelSource(long positionId, long positionVersion, String palletCode, + String locationCode, String inboundCode, long stockPackages, + long movablePackages, List cartonMarks) { } public record InventoryLine(String customerId, String customerName, long balanceId, long positionId, String inboundCode, String skuCode, String productName, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java index f28f4c05a..d53b0c2fd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java @@ -23,6 +23,12 @@ public interface PdaPalletOperationMapper extends BaseMapper PdaPalletOperation findOperation(@Param("operatorKey") String operatorKey, @Param("idempotencyKey") String idempotencyKey); + @Select(CONTENT_SELECT + "WHERE b.customer_id=#{customerId} AND b.overseas_warehouse_id=#{warehouseId} " + + "AND b.id=#{balanceId} AND p.pallet_id>0 " + + "AND (p.available_packages+p.hold_packages)>0 ORDER BY p.pallet_no,p.id") + List relabelSources(@Param("customerId") String customerId, + @Param("warehouseId") String warehouseId, @Param("balanceId") long balanceId); + @Select(CONTENT_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} AND p.pallet_id=#{palletId} " + "AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " + "ORDER BY b.customer_id,b.inbound_code,p.id") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java index d399282b4..ccbe09d05 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java @@ -78,13 +78,42 @@ public class PdaPalletOperationService { return content(identity.overseasWarehouseId(), pallet); } + public List relabelSources(PdaWarehouseIdentity identity, + PalletOperationRequests.RelabelSources request) { + RelabelApplication app = relabelApplications.findAnyByRelabelNo(request.relabelNo()); + requireRelabelWork(identity, app); + Set boundMarks = boundRelabelMarks(app); + List items = relabelItems.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper() + .eq("application_id", app.getId()).eq("customer_id", app.getCustomerId())); + Map> byBalance = new LinkedHashMap<>(); + for (RelabelItem item : items) { + InventoryPosition anchor = positions.selectById(item.getSourceInventoryPositionId()); + if (anchor == null || !Objects.equals(anchor.getCustomerId(), app.getCustomerId()) + || !Objects.equals(anchor.getOverseasWarehouseId(), app.getOverseasWarehouseId())) + throw conflict("换标单库存来源缺失,请核对原入仓单"); + byBalance.computeIfAbsent(anchor.getBalanceId(), ignored -> new ArrayList<>()).add(item); + } + List result = new ArrayList<>(); + byBalance.forEach((balanceId, group) -> { + for (PalletInventoryRow row : mapper.relabelSources(app.getCustomerId(), app.getOverseasWarehouseId(), balanceId)) { + long ownHold = group.stream().filter(item -> Objects.equals(item.getSourceInventoryPositionId(), row.getPositionId())) + .mapToLong(item -> value(item.getNotifiedPackageCount())).sum(); + result.add(new PalletOperationResponses.RelabelSource(row.getPositionId(), value(row.getVersion()), + row.getPalletNo(), row.getWarehouseLocationCode(), row.getInboundCode(), + value(row.getAvailablePackages()) + value(row.getHoldPackages()) + value(row.getAllocatedPackages()) + + value(row.getPickedPackages()), + value(row.getAvailablePackages()) + Math.min(ownHold, value(row.getHoldPackages())), + group.stream().map(RelabelItem::getCartonMark).filter(mark -> !boundMarks.contains(mark)).toList())); + } + }); + return result; + } + public PalletOperationResponses.PrintablePallet createRelabelPallet(PdaWarehouseIdentity identity, PalletOperationRequests.NewRelabelPallet request) { String relabelNo = required(request.relabelNo(), "换标单号不能为空"); RelabelApplication application = relabelApplications.findAnyByRelabelNo(relabelNo); - if (application == null || !"READY_FOR_OUTBOUND".equals(application.getStatus())) { - throw conflict("换标单尚未完成客户确认,不能生成新托盘"); - } + requireRelabelWork(identity, application); if (!Objects.equals(application.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) { throw conflict("换标单不属于 Token 冻结的海外仓"); } @@ -153,9 +182,7 @@ public class PdaPalletOperationService { if (replay != null) return replay; String relabelNo = required(request.relabelNo(), "换标单号不能为空"); RelabelApplication application = relabelApplications.findAnyByRelabelNo(relabelNo); - if (application == null || !"READY_FOR_OUTBOUND".equals(application.getStatus())) { - throw conflict("换标单尚未完成客户确认,不能执行拆托"); - } + requireRelabelWork(identity, application); if (!Objects.equals(application.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) { throw conflict("换标单不属于 Token 冻结的海外仓"); } @@ -177,29 +204,61 @@ public class PdaPalletOperationService { } Map packagesByPosition = new LinkedHashMap<>(); Map> itemsByPosition = new LinkedHashMap<>(); + Map anchors = new LinkedHashMap<>(); + InventoryPosition actual = request.actualSource() == null ? null : positions.selectById(request.actualSource().positionId()); + if (request.actualSource() != null && (actual == null + || !Objects.equals(actual.getCustomerId(), application.getCustomerId()) + || !Objects.equals(actual.getPalletId(), source.palletId()) + || !Objects.equals(actual.getOverseasWarehouseId(), application.getOverseasWarehouseId()) + || value(actual.getVersion()) != request.actualSource().expectedPositionVersion())) + throw conflict("实际来源托盘库存已变化,请刷新后确认"); + if (actual != null && application.getOutboundOrderNo() != null && !application.getOutboundOrderNo().isBlank()) + throw conflict("已生成出库单,请先处理出库单,不能重新分配冻结来源"); + if (actual != null && marks.stream().anyMatch(boundRelabelMarks(application)::contains)) + throw conflict("本批包含本轮已组托的换标箱,请刷新后选择未组托的箱"); + if ("PROCESSING".equals(application.getStatus())) { + var workTask = relabelWorkTasks.find(application.getCustomerId(), application.getId()); + for (RelabelItem item : selected) { + Long completed = relabelWorkFacts.selectCount(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper() + .eq("task_id", workTask.getId()).eq("customer_id", application.getCustomerId()) + .eq("item_id", item.getId()).eq("result", "RELABELLED")); + if (completed == null || completed < value(item.getNotifiedPackageCount())) + throw conflict("请先完成本箱全部换标作业,再进行组托"); + } + } for (RelabelItem item : selected) { if (item.getSourceInventoryPositionId() == null || item.getNotifiedPackageCount() == null || item.getNotifiedPackageCount() <= 0) throw conflict("换标箱唛缺少库存位置或箱数快照"); InventoryPosition position = positions.selectById(item.getSourceInventoryPositionId()); if (position == null || !Objects.equals(position.getCustomerId(), application.getCustomerId()) - || !Objects.equals(position.getPalletId(), source.palletId()) + || (actual == null && !Objects.equals(position.getPalletId(), source.palletId())) + || (actual != null && (!Objects.equals(position.getBalanceId(), actual.getBalanceId()) + || !Objects.equals(position.getBatchNo(), actual.getBatchNo()))) || !Objects.equals(position.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) { - throw conflict("扫描的原托盘与换标箱唛实际库存位置不一致"); + throw conflict("来源托盘必须属于本次拆托的原入仓单和库存批次"); } - packagesByPosition.merge(position.getId(), item.getNotifiedPackageCount().longValue(), Long::sum); - itemsByPosition.computeIfAbsent(position.getId(), ignored -> new ArrayList<>()).add(item); + anchors.put(position.getId(), position); + Long sourcePosition = actual == null ? position.getId() : actual.getId(); + packagesByPosition.merge(sourcePosition, item.getNotifiedPackageCount().longValue(), Long::sum); + itemsByPosition.computeIfAbsent(sourcePosition, ignored -> new ArrayList<>()).add(item); } + if (actual != null && request.actualSource().packages() != packagesByPosition.get(actual.getId())) + throw conflict("拆出箱数必须等于本次确认绑定的换标箱数"); List moves = packagesByPosition.entrySet().stream().map(entry -> { InventoryPosition position = positions.selectById(entry.getKey()); - if (value(position.getHoldPackages()) < entry.getValue()) throw conflict("换标冻结库存箱数不足"); + long own = selected.stream().filter(item -> Objects.equals(item.getSourceInventoryPositionId(), position.getId())) + .mapToLong(item -> value(item.getNotifiedPackageCount())).sum(); + if (actual == null ? value(position.getHoldPackages()) < entry.getValue() + : value(position.getHoldPackages()) < own || value(position.getAvailablePackages()) < entry.getValue() - own) + throw conflict("来源托盘可拆箱数不足,不能占用其他单据冻结或已分配库存"); return new PlannedMove(entry.getKey(), entry.getValue(), value(position.getVersion()), source.palletId(), source.palletCode(), target.palletId(), target.palletCode(), target.locationId(), locationCode(identity.overseasWarehouseId(), target.locationId()), InventoryBucket.HOLD); }).toList(); - requireCommonDestination(identity.overseasWarehouseId(), moves, List.of(target)); + requireRelabelDestination(identity.overseasWarehouseId(), selected, target); Plan base = plan(identity.overseasWarehouseId(), pallets, moves, Set.of(target.palletId()), false); Plan relabelPlan = new Plan(base.pallets(), base.moves(), base.changes(), - new RelabelMutation(application, itemsByPosition)); + new RelabelMutation(application, itemsByPosition, actual == null ? Map.of() : anchors)); String reason = "换标拆托" + (request.reason() == null || request.reason().isBlank() ? "" : ":" + request.reason().trim()); return execute(identity, "RELABEL_PALLET_DISASSEMBLE", request.idempotencyKey(), request, @@ -288,6 +347,10 @@ public class PdaPalletOperationService { throw conflict("库存所在托盘已变化,请重新扫码"); } if (value(locked.getVersion()) != move.expectedVersion()) throw conflict("库存版本已变化,请重新扫码"); + if (plan.relabelMutation() != null && !plan.relabelMutation().anchors().isEmpty()) { + reconcileRelabelHold(identity, operation, plan.relabelMutation(), move, key); + locked = mapper.lockPosition(String.valueOf(identity.overseasWarehouseId()), move.positionId()); + } if (move.bucket() == InventoryBucket.HOLD) requireHeld(locked, move.packages()); else requireMovable(locked, move.packages()); customers.add(locked.getCustomerId()); @@ -296,6 +359,8 @@ public class PdaPalletOperationService { } if (plan.relabelMutation() != null) relabelPalletMutations.moveSelectedItems( plan.relabelMutation().application(), plan.relabelMutation().itemsByPosition(), relabelTargets); + if (plan.relabelMutation() != null) + recordRelabelBinding(identity, plan.relabelMutation(), relabelTargets); for (PalletChange change : plan.changes()) { long actual = mapper.palletPackages(String.valueOf(identity.overseasWarehouseId()), change.pallet().palletId()); boolean shouldBeEmpty = "IDLE".equals(change.targetStatus()); @@ -320,7 +385,8 @@ public class PdaPalletOperationService { private void lockRelabelMutation(RelabelMutation mutation, long warehouseId) { RelabelApplication locked = relabelApplications.lockAnyByRelabelNo(mutation.application().getRelabelNo()); - if (locked == null || !"READY_FOR_OUTBOUND".equals(locked.getStatus()) + if (locked != null) requireUnallocatedRelabel(locked); + if (locked == null || !Objects.equals(locked.getStatus(), mutation.application().getStatus()) || !Objects.equals(locked.getCustomerId(), mutation.application().getCustomerId()) || !Objects.equals(locked.getOverseasWarehouseId(), String.valueOf(warehouseId)) || !Objects.equals(locked.getOutboundOrderNo(), mutation.application().getOutboundOrderNo())) { @@ -328,6 +394,52 @@ public class PdaPalletOperationService { } } + /** Redistributes only this application's hold within the same inbound balance; physical totals stay unchanged. */ + private void reconcileRelabelHold(PdaWarehouseIdentity identity, PdaPalletOperation operation, + RelabelMutation mutation, PlannedMove move, String key) { + requireRelabelWork(identity, mutation.application()); + List selected = mutation.itemsByPosition().get(move.positionId()); + Set alreadyBound = boundRelabelMarks(mutation.application()); + if (selected.stream().anyMatch(item -> alreadyBound.contains(item.getCartonMark()))) + throw conflict("换标箱已在本轮组托,请刷新后重试"); + requireRelabelDestination(identity.overseasWarehouseId(), selected, physical.resolve(move.targetPalletCode())); + Map release = new LinkedHashMap<>(); + for (RelabelItem item : selected) { + RelabelItem current = relabelItems.selectById(item.getId()); + if (current == null || !Objects.equals(current.getSourceInventoryPositionId(), item.getSourceInventoryPositionId()) + || !Objects.equals(current.getNotifiedPackageCount(), item.getNotifiedPackageCount())) + throw conflict("换标箱的绑定已变化,请刷新后重试"); + if (!Objects.equals(item.getSourceInventoryPositionId(), move.positionId())) + release.merge(item.getSourceInventoryPositionId(), (long) value(item.getNotifiedPackageCount()), Long::sum); + } + InventoryPosition actual = positions.selectById(move.positionId()); + long required = release.values().stream().mapToLong(Long::longValue).sum(); + if (value(actual.getAvailablePackages()) < required) throw conflict("实际来源托盘可用箱数不足"); + InventoryBalance balance = balances.selectById(actual.getBalanceId()); + for (var entry : release.entrySet()) { + PalletInventoryRow locked = mapper.lockPosition(mutation.application().getOverseasWarehouseId(), entry.getKey()); + InventoryPosition snapshot = mutation.anchors().get(entry.getKey()); + if (locked == null || value(locked.getVersion()) != value(snapshot.getVersion()) + || !Objects.equals(locked.getBalanceId(), actual.getBalanceId()) + || value(locked.getHoldPackages()) < entry.getValue()) + throw conflict("原冻结库存已变化,请刷新后重试"); + InventoryPosition anchor = positions.selectById(entry.getKey()); + adjustRelabelHold(identity, operation, balance, anchor, -entry.getValue(), key + ":RELEASE:" + anchor.getId()); + } + if (required > 0) adjustRelabelHold(identity, operation, balance, actual, required, key + ":FREEZE:" + actual.getId()); + } + + private void adjustRelabelHold(PdaWarehouseIdentity identity, PdaPalletOperation operation, + InventoryBalance balance, InventoryPosition position, long delta, String key) { + long before = value(position.getHoldPackages()); + position.setHoldPackages(before + delta); + position.setAvailablePackages(value(position.getAvailablePackages()) - delta); + position.setVersion(value(position.getVersion()) + 1); position.setUpdatedAt(LocalDateTime.now()); + positions.updateById(position); + ledger(identity, operation, balance, position, key, before, delta, before + delta, + position.getPalletNo(), position.getPalletNo(), "现场按原入仓单确认拆托来源:冻结桶调整,可用桶反向调整,实物总数不变"); + } + private InventoryPosition moveInventory(PdaWarehouseIdentity identity, PdaPalletOperation operation, PalletInventoryRow sourceRow, PlannedMove move, String key, String reason) { InventoryPosition source = positions.selectById(sourceRow.getPositionId()); @@ -458,6 +570,89 @@ public class PdaPalletOperationService { if (destinations.size() > 1) throw conflict("不同目的仓货物不能组到同一托盘"); } + @jakarta.annotation.Resource + private com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper relabelWorkTasks; + @jakarta.annotation.Resource + private com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelUnitFactMapper relabelWorkFacts; + @jakarta.annotation.Resource + private com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper relabelWorkHistory; + + private Set boundRelabelMarks(RelabelApplication app) { + var task = relabelWorkTasks.find(app.getCustomerId(), app.getId()); + if (task == null) return Set.of(); + String round = Objects.toString(task.getReviewedAt(), "INITIAL"); + Set marks = new HashSet<>(); + var records = relabelWorkHistory.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper() + .eq("customer_id", app.getCustomerId()).eq("task_id", task.getId()).eq("action", "PALLET_BIND")); + for (var record : records) { + try { + var data = objectMapper.readTree(record.getDetailJson()); + if (round.equals(data.path("round").asText())) data.path("cartonMarks").forEach(mark -> marks.add(mark.asText())); + } catch (JsonProcessingException failure) { throw conflict("换标组托记录无法读取,请核对作业历史"); } + } + return marks; + } + + private void recordRelabelBinding(PdaWarehouseIdentity identity, RelabelMutation mutation, + Map targets) { + var app = mutation.application(); + var task = relabelWorkTasks.find(app.getCustomerId(), app.getId()); + if (task == null) throw conflict("换标作业任务不存在,不能记录组托"); + var record = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory(); + record.setId(IdWorker.getId()); record.setTaskId(task.getId()); record.setApplicationId(app.getId()); + record.setCustomerId(app.getCustomerId()); record.setAction("PALLET_BIND"); + record.setFromStatus(task.getStatus()); record.setToStatus(task.getStatus()); + record.setOperatorKey(identity.operatorKey()); record.setOperatorName(identity.operatorName()); + record.setCreatedAt(LocalDateTime.now()); + record.setDetailJson(write(Map.of("round", Objects.toString(task.getReviewedAt(), "INITIAL"), + "cartonMarks", mutation.itemsByPosition().values().stream().flatMap(List::stream).map(RelabelItem::getCartonMark).toList(), + "targetPallets", targets.values().stream().map(InventoryPosition::getPalletNo).distinct().toList()))); + relabelWorkHistory.insert(record); + } + + private void requireRelabelWork(PdaWarehouseIdentity identity, RelabelApplication application) { + if (application == null || !Set.of("PROCESSING", "READY_FOR_OUTBOUND").contains(application.getStatus())) + throw conflict("当前换标状态不能组托"); + if (!Objects.equals(application.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) + throw conflict("换标单不属于当前海外仓"); + requireUnallocatedRelabel(application); + if ("PROCESSING".equals(application.getStatus())) { + var task = relabelWorkTasks.find(application.getCustomerId(), application.getId()); + if (task == null || !"PROCESSING".equals(task.getStatus()) || !identity.operatorKey().equals(task.getOperatorKey())) + throw conflict("请先认领当前换标任务"); + } + } + + private void requireUnallocatedRelabel(RelabelApplication application) { + String fulfillment = application.getFulfillmentStatus(); + if (org.springframework.util.StringUtils.hasText(application.getOutboundOrderNo()) + || (org.springframework.util.StringUtils.hasText(fulfillment) + && !Set.of("WAIT_RELABEL", "WAIT_PRE_DISPATCH").contains(fulfillment))) + throw conflict("换标单已进入排车或出库流程,请先解除占用后再改绑托盘"); + } + + private void requireRelabelDestination(long warehouseId, List selected, + PalletPhysicalGateway.PalletReference target) { + Set destinations = new HashSet<>(); + selected.forEach(item -> destinations.add(text(item.getDestinationFbaWarehouseCode()))); + if (destinations.size() != 1 || destinations.contains("")) throw conflict("请按已维护的新目的仓分批组托"); + for (PalletInventoryRow row : mapper.content(String.valueOf(warehouseId), target.palletId())) { + var bound = relabelItems.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper() + .eq("customer_id", row.getCustomerId()).eq("source_inventory_position_id", row.getPositionId())); + long held = 0; + for (RelabelItem item : bound) { + var app = relabelApplications.selectById(item.getApplicationId()); + if (app != null && Set.of("PROCESSING", "CUSTOMER_CONFIRM_PENDING", "READY_FOR_OUTBOUND").contains(app.getStatus())) { + held += value(item.getNotifiedPackageCount()); destinations.add(text(item.getDestinationFbaWarehouseCode())); + } + } + if (value(row.getAvailablePackages()) > 0 || value(row.getAllocatedPackages()) > 0 + || value(row.getPickedPackages()) > 0 || value(row.getHoldPackages()) > held) + destinations.add(text(row.getDestinationWarehouseCode())); + } + if (destinations.size() != 1) throw conflict("目标托盘内货物与本次换标的新目的仓不同,不能合托"); + } + private Map resolvePallets(long warehouseId, PalletOperationRequests.PalletExpectation first, List rest) { List values = new ArrayList<>(); values.add(first); values.addAll(rest); @@ -658,6 +853,7 @@ public class PdaPalletOperationService { List moves, List changes, RelabelMutation relabelMutation) { } private record RelabelMutation(RelabelApplication application, - Map> itemsByPosition) { } + Map> itemsByPosition, + Map anchors) { } private enum InventoryBucket { AVAILABLE, HOLD } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java index 88a51cd6f..c3cab46c9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java @@ -36,6 +36,16 @@ public class RelabelApplicationController { return ApiResponse.success(pdaService.returnForRework(operator(user), user.username(), relabelNo, request)); } + @PostMapping("/{relabelNo}/confirm-completion") + @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_STATUS + "')") + public ApiResponse confirmCompletion( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String relabelNo, + @Valid @RequestBody RelabelRequests.CustomerConfirm request) { + requirePlatform(user); + String customerId = service.customerId(relabelNo); + return ApiResponse.success(service.confirmCompletion(customerId, relabelNo, operator(user), request)); + } + @GetMapping("/{relabelNo}/review-task") @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')") public ApiResponse reviewTask( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java index d0d73eacc..f43b27f0e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java @@ -39,9 +39,9 @@ public final class RelabelRequests { } public record Item( - @NotNull Long sourceInventoryBalanceId, - @NotNull Long sourceInventoryPositionId, - @NotNull Long sourceInboundOrderId, + Long sourceInventoryBalanceId, + Long sourceInventoryPositionId, + Long sourceInboundOrderId, @Size(max = 128) String skuCode, @Size(max = 128) String productImageFileId, @Size(max = 255) String productImageFileName, @@ -61,8 +61,22 @@ public final class RelabelRequests { @Size(max = 255) String fbaCartonMark, @Size(max = 128) String destinationFbaWarehouseCode, @Size(max = 32) String operationType, - @Size(max = 100) List<@Valid SkuItem> skuItems + @Size(max = 100) List<@Valid SkuItem> skuItems, + Long returnCartonId ) { + public Item(Long sourceInventoryBalanceId, Long sourceInventoryPositionId, Long sourceInboundOrderId, + String skuCode, String productImageFileId, String productImageFileName, String productImageUrl, + String productNameZh, int totalPackageCount, int notifiedPackageCount, String shipmentNo, + String trackingCode, String newSkuCode, String labelRequirement, String remark, + String relabelFileId, String relabelFileName, String relabelFileUrl, String cartonMark, + String fbaCartonMark, String destinationFbaWarehouseCode, String operationType, + List skuItems) { + this(sourceInventoryBalanceId, sourceInventoryPositionId, sourceInboundOrderId, skuCode, + productImageFileId, productImageFileName, productImageUrl, productNameZh, totalPackageCount, + notifiedPackageCount, shipmentNo, trackingCode, newSkuCode, labelRequirement, remark, + relabelFileId, relabelFileName, relabelFileUrl, cartonMark, fbaCartonMark, + destinationFbaWarehouseCode, operationType, skuItems, null); + } /** 保持既有箱唛换标调用方的二进制参数形态。 */ public Item(Long sourceInventoryBalanceId, Long sourceInventoryPositionId, Long sourceInboundOrderId, String skuCode, String productImageFileId, String productImageFileName, String productImageUrl, @@ -74,7 +88,7 @@ public final class RelabelRequests { productImageFileId, productImageFileName, productImageUrl, productNameZh, totalPackageCount, notifiedPackageCount, shipmentNo, trackingCode, newSkuCode, labelRequirement, remark, relabelFileId, relabelFileName, relabelFileUrl, cartonMark, fbaCartonMark, - destinationFbaWarehouseCode, "CARTON_LABEL", List.of()); + destinationFbaWarehouseCode, "CARTON_LABEL", List.of(), null); } public Item(String skuCode, String productImageFileId, String productImageFileName, String productImageUrl, String productNameZh, int totalPackageCount, @@ -85,7 +99,7 @@ public final class RelabelRequests { this(101L, 201L, 301L, skuCode, productImageFileId, productImageFileName, productImageUrl, productNameZh, totalPackageCount, notifiedPackageCount, shipmentNo, trackingCode, newSkuCode, labelRequirement, remark, relabelFileId, relabelFileName, relabelFileUrl, - cartonMark, fbaCartonMark, destinationFbaWarehouseCode, "CARTON_LABEL", List.of()); + cartonMark, fbaCartonMark, destinationFbaWarehouseCode, "CARTON_LABEL", List.of(), null); } public Item(String skuCode, String productImageFileId, String productImageFileName, String productImageUrl, String productNameZh, int totalPackageCount, @@ -94,7 +108,7 @@ public final class RelabelRequests { this(101L, 201L, 301L, skuCode, productImageFileId, productImageFileName, productImageUrl, productNameZh, totalPackageCount, notifiedPackageCount, shipmentNo, trackingCode, newSkuCode, labelRequirement, null, relabelFileId, relabelFileName, relabelFileUrl, skuCode, shipmentNo, "ONT8", - "CARTON_LABEL", List.of()); + "CARTON_LABEL", List.of(), null); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java index 66f7fff17..8e6052f4a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java @@ -36,7 +36,9 @@ public final class RelabelResponses { String inboundCode, String overseasWarehouseId, String overseasWarehouseCode, String overseasWarehouseName, String destinationFbaWarehouseCode, String destinationFbaWarehouseName, String skuCode, String productNameZh, - long availablePackages, long version, List positions) {} + long availablePackages, long version, List positions, + String sourceType, Long returnCartonId, String cartonMark, + List skuItems) {} public record HistoricalOrder(Long id, String orderNo, String sourceOrderNo, String outboundOrderNo, String relabelOrderNo, String orderType, String status, Integer packageCount, LocalDateTime createdAt) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/gateway/HttpRelabelDocumentFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/gateway/HttpRelabelDocumentFileGateway.java index 66c40bd81..ede1a60ef 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/gateway/HttpRelabelDocumentFileGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/gateway/HttpRelabelDocumentFileGateway.java @@ -20,7 +20,7 @@ public class HttpRelabelDocumentFileGateway implements RelabelDocumentFileGatewa @Resource private RestClient.Builder restClientBuilder; @Resource private LastlegRestClientFactory restClientFactory; @Resource private LastlegRestClientProperties restClientProperties; - @Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl; + @Value("${baiyue.clients.file.url:http://127.0.0.1:9206}") private String baseUrl; @Value("${baiyue.security.internal-service-token:}") private String internalToken; private RestClient client; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java index 1e9b91c39..871f82830 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java @@ -6,4 +6,14 @@ import org.apache.ibatis.annotations.Mapper; @Mapper public interface RelabelItemMapper extends BaseMapper { + @org.apache.ibatis.annotations.Select("SELECT id FROM ll_inventory_balance WHERE id=#{balanceId} AND customer_id=#{customerId} FOR UPDATE") + Long lockSourceBalance(@org.apache.ibatis.annotations.Param("customerId") String customerId, + @org.apache.ibatis.annotations.Param("balanceId") Long balanceId); + + @org.apache.ibatis.annotations.Select("SELECT a.relabel_no FROM ll_relabel_item i JOIN ll_relabel_application a ON a.id=i.application_id " + + "WHERE i.customer_id=#{customerId} AND a.customer_id=#{customerId} AND i.source_inventory_balance_id=#{balanceId} " + + "AND UPPER(TRIM(i.carton_mark))=UPPER(TRIM(#{mark})) AND a.status NOT IN ('CANCELLED','REJECTED') LIMIT 1") + String findExistingCarton(@org.apache.ibatis.annotations.Param("customerId") String customerId, + @org.apache.ibatis.annotations.Param("balanceId") Long balanceId, + @org.apache.ibatis.annotations.Param("mark") String mark); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/model/RelabelApplicationStatus.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/model/RelabelApplicationStatus.java new file mode 100644 index 000000000..f05f065cb --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/model/RelabelApplicationStatus.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.relabel.model; + +import java.util.Arrays; + +/** 换标申请业务状态。 */ +public enum RelabelApplicationStatus { + PENDING_PROCESSING("待处理"), + PROCESSING("处理中"), + CUSTOMER_CONFIRM_PENDING("待客户确认"), + READY_FOR_OUTBOUND("换标完成,待出库"), + COMPLETED("已完成"), + REJECTED("已拒绝"), + CANCELLED("已取消"); + + private final String label; + + RelabelApplicationStatus(String label) { this.label = label; } + + public String code() { return name(); } + public String label() { return label; } + + public static RelabelApplicationStatus fromCode(String code) { + return Arrays.stream(values()).filter(value -> value.name().equals(code)).findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unsupported relabel application status: " + code)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java index 7bc4d4595..34011a428 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java @@ -70,6 +70,14 @@ public class PdaRelabelController { identity.overseasWarehouseId(), relabelNo, request)); } + @PostMapping("/{relabelNo}/skus/scan") + public ApiResponse scanSku(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.SkuScan request) { + Identity identity = identity(user); + return ApiResponse.success(service.scanSku(identity.operatorKey(), identity.operatorName(), + identity.overseasWarehouseId(), relabelNo, request)); + } + @GetMapping("/{relabelNo}/documents") public ApiResponse> documents(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String relabelNo) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java index e402ee3fb..c28d56bbe 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java @@ -14,6 +14,10 @@ public final class PdaRelabelRequests { private PdaRelabelRequests() { } public record Claim(@NotBlank @Size(max = 255) String idempotencyKey) { } + public record SkuScan(@NotNull @Positive Long itemId, @Min(0) int skuIndex, + @NotBlank @Size(max = 255) String oldSku, + @NotNull @Min(0) Integer expectedVersion, + @NotBlank @Size(max = 255) String idempotencyKey) { } public record CartonScan(@NotBlank @Size(max = 255) String cartonMark, @NotNull @Min(0) Integer expectedVersion, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java index 92b660d53..e00d48949 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java @@ -26,7 +26,8 @@ public final class PdaRelabelResponses { String cartonMark, String fbaCartonMark, String operationType, List skuItems, int expectedPackages, int relabelledPackages, int skippedPackages, - int exceptionPackages, int cartonProcessedPackages, int skuProcessedPackages) { } + int exceptionPackages, int cartonProcessedPackages, int skuProcessedPackages, + String destinationFbaWarehouseCode) { } public record SkuItem(String skuCode, String newSkuCode, int quantity, String remark) { } public record UnitFact(Long factId, Long itemId, String oldLabel, String newLabel, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelTaskMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelTaskMapper.java index 66f504d4b..93c912595 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelTaskMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelTaskMapper.java @@ -15,8 +15,18 @@ public interface PdaRelabelTaskMapper extends BaseMapper { @Select("SELECT * FROM ll_relabel_pda_task WHERE customer_id=#{customerId} AND application_id=#{applicationId} LIMIT 1 FOR UPDATE") PdaRelabelTask lock(@Param("customerId") String customerId, @Param("applicationId") Long applicationId); - @Select("SELECT * FROM ll_relabel_pda_task WHERE operator_key=#{operatorKey} AND status<>'COMPLETED' ORDER BY claimed_at ASC LIMIT 1 FOR UPDATE") - PdaRelabelTask lockUnfinishedByOperator(@Param("operatorKey") String operatorKey); + @Select(""" + SELECT task.* + FROM ll_relabel_pda_task task + JOIN ll_relabel_application application + ON application.id=task.application_id AND application.customer_id=task.customer_id + WHERE task.operator_key=#{operatorKey} + AND task.status='PROCESSING' + AND application.status='PROCESSING' + ORDER BY task.claimed_at ASC + LIMIT 1 FOR UPDATE + """) + PdaRelabelTask lockProcessingByOperator(@Param("operatorKey") String operatorKey); @Update(""" UPDATE ll_relabel_pda_task diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java index a4a5c9f73..b6a15ee2e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java @@ -51,6 +51,7 @@ import java.util.stream.Collectors; @Service public class PdaRelabelService { + @Resource private com.baiyue.lastleg.relabel.service.RelabelBindingGuard bindingGuard; public static final String PROCESSING = "PROCESSING"; public static final String WAIT_REVIEW = "WAIT_REVIEW"; public static final String COMPLETED = "COMPLETED"; @@ -109,7 +110,7 @@ public class PdaRelabelService { throw conflict("Relabel application is not executable"); } PdaRelabelTask task = tasks.lock(application.getCustomerId(), application.getId()); - PdaRelabelTask activeTask = tasks.lockUnfinishedByOperator(operatorKey); + PdaRelabelTask activeTask = tasks.lockProcessingByOperator(operatorKey); if (activeTask != null && !Objects.equals(activeTask.getApplicationId(), application.getId())) { throw conflict("当前账号正在执行换标单 " + activeTask.getRelabelNo() + ",请先完成后再操作其他换标单"); } @@ -194,6 +195,49 @@ public class PdaRelabelService { public record Document(String scope, String fileId, String fileName, Long itemId) { } + @Transactional + public PdaRelabelResponses.Detail scanSku(String operatorKey, String operatorName, long warehouseId, + String relabelNo, PdaRelabelRequests.SkuScan request) { + UnitCommand command = new UnitCommand(relabelNo, "SCAN_SKU", request); + var replayed = replay(operatorKey, warehouseId, relabelNo, "SCAN_SKU", request.idempotencyKey(), command); + if (replayed != null) return replayed; + var app = requireApplication(relabelNo, warehouseId, true); + var task = requireOwnedTask(app, operatorKey, true); + requireVersion(task, request.expectedVersion()); + var item = requireItem(app, request.itemId()); + if (!Set.of("SKU_LABEL", "CARTON_AND_SKU").contains(operationType(item))) throw conflict("该箱没有SKU换标任务"); + if (value(item.getNotifiedPackageCount()) != 1) throw conflict("SKU逐条作业要求每个仓库唛头独立一箱"); + var skus = readSkuItems(item.getSkuRelabelItemsJson()); + if (request.skuIndex() < 0 || request.skuIndex() >= skus.size()) throw conflict("SKU明细不存在"); + var sku = skus.get(request.skuIndex()); + if (!Objects.equals(sku.skuCode(), request.oldSku().trim()) || !StringUtils.hasText(sku.newSkuCode()) || sku.quantity() <= 0) + throw conflict("旧SKU或绑定的新标签、数量不正确"); + if (factList(task).stream().anyMatch(fact -> item.getId().equals(fact.getItemId())) + || mixedStageDone(task, item.getId(), "SKU")) throw conflict("本箱SKU作业已完成"); + Set done = new java.util.HashSet<>(); + for (var history : histories.selectList(new LambdaQueryWrapper() + .eq(PdaRelabelHistory::getTaskId, task.getId()).eq(PdaRelabelHistory::getCustomerId, app.getCustomerId()) + .eq(PdaRelabelHistory::getAction, "SKU_SCAN"))) { + if (!"SKU_SCAN".equals(history.getAction())) continue; + try { + var data = objectMapper.readTree(history.getDetailJson()); + if (mixedRound(task).equals(data.path("round").asText()) && item.getId().toString().equals(data.path("itemId").asText())) + done.add(data.path("skuIndex").asInt(-1)); + } catch (Exception failure) { throw conflict("SKU扫描历史无法读取"); } + } + if (!done.add(request.skuIndex())) throw conflict("该SKU明细已处理,请勿重复扫描"); + insertHistory(task, "SKU_SCAN", PROCESSING, PROCESSING, operatorKey, operatorName, + Map.of("round", mixedRound(task), "itemId", item.getId().toString(), "skuIndex", request.skuIndex(), + "oldSku", sku.skuCode(), "newSku", sku.newSkuCode(), "quantity", sku.quantity())); + if (done.size() == skus.size()) return unitAction(operatorKey, operatorName, warehouseId, relabelNo, + "SCAN_SKU", request.idempotencyKey(), request, item.getId(), item.getCartonMark(), null, + "RELABELLED", null, null, request.expectedVersion(), ignored -> { }); + updateTask(task, request.expectedVersion()); + var result = detail(app, task); + saveOperation(task, operatorKey, "SCAN_SKU", request.idempotencyKey(), command, result); + return result; + } + @Transactional(readOnly = true) public List documents(long warehouseId, String relabelNo) { RelabelApplication app = requireApplication(relabelNo, warehouseId, false); @@ -264,6 +308,7 @@ public class PdaRelabelService { RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, true); PdaRelabelTask task = requireOwnedTask(application, operatorKey, true); requireVersion(task, request.expectedVersion()); + if (!request.manualCompletion()) bindingGuard.requireBound(application); List detailItems = itemList(application); List facts = factList(task); boolean incomplete = detailItems.stream().anyMatch(item -> facts.stream() @@ -558,7 +603,7 @@ public class PdaRelabelService { private PdaRelabelResponses.Candidate candidate(RelabelApplication application, PdaRelabelTask task, String operatorKey) { return new PdaRelabelResponses.Candidate(application.getRelabelNo(), application.getCustomerOrderNo(), - application.getLabelDeadline(), application.getStatus(), task == null ? "AVAILABLE" : task.getStatus(), + application.getLabelDeadline(), application.getStatus(), effectiveTaskStatus(application, task), operationType(application), value(application.getItemCount()), task == null ? value(application.getNotifiedPackageCount()) : value(task.getExpectedPackages()), task == null ? 0 : value(task.getRelabelledPackages()), task == null ? 0 : value(task.getSkippedPackages()), @@ -580,7 +625,8 @@ public class PdaRelabelService { value(item.getNotifiedPackageCount()), count(itemFacts, "RELABELLED"), count(itemFacts, "SKIPPED"), count(itemFacts, "EXCEPTION"), count(itemFacts, "RELABELLED") + (itemFacts.isEmpty() && mixedStageDone(task, item.getId(), "CARTON") ? 1 : 0), - count(itemFacts, "RELABELLED") + (itemFacts.isEmpty() && mixedStageDone(task, item.getId(), "SKU") ? 1 : 0)); + count(itemFacts, "RELABELLED") + (itemFacts.isEmpty() && mixedStageDone(task, item.getId(), "SKU") ? 1 : 0), + item.getDestinationFbaWarehouseCode()); }).toList(); List factResponses = facts.stream().map(fact -> new PdaRelabelResponses.UnitFact(fact.getId(), fact.getItemId(), fact.getOldLabel(), fact.getNewLabel(), @@ -593,7 +639,7 @@ public class PdaRelabelService { history.getToStatus(), history.getOperatorName(), history.getDetailJson(), history.getCreatedAt())).toList(); return new PdaRelabelResponses.Detail(application.getRelabelNo(), application.getCustomerOrderNo(), application.getLabelDeadline(), application.getContactName(), application.getContactPhone(), - application.getStatus(), operationType(application), task == null ? "AVAILABLE" : task.getStatus(), + application.getStatus(), operationType(application), effectiveTaskStatus(application, task), task == null ? value(application.getNotifiedPackageCount()) : value(task.getExpectedPackages()), task == null ? 0 : value(task.getRelabelledPackages()), task == null ? 0 : value(task.getSkippedPackages()), task == null ? 0 : value(task.getExceptionPackages()), task == null ? 0 : value(task.getVersion()), @@ -605,6 +651,13 @@ public class PdaRelabelService { itemResponses, factResponses, historyResponses); } + private String effectiveTaskStatus(RelabelApplication application, PdaRelabelTask task) { + if (task == null) return "AVAILABLE"; + if (RelabelApplicationService.CUSTOMER_CONFIRM_PENDING.equals(application.getStatus()) + && PROCESSING.equals(task.getStatus())) return WAIT_REVIEW; + return task.getStatus(); + } + private void insertHistory(PdaRelabelTask task, String action, String from, String to, String operatorKey, String operatorName, Object detail) { PdaRelabelHistory history = new PdaRelabelHistory(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java index 6bf25b776..0aa38d523 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java @@ -29,6 +29,10 @@ import com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper; import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask; import com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway; import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; import com.baiyue.lastleg.delivery.entity.OutboundOrder; import com.baiyue.lastleg.delivery.entity.OutboundPalletItem; import com.baiyue.lastleg.delivery.entity.OutboundPdaTask; @@ -84,6 +88,8 @@ public class RelabelApplicationService implements WalletResultHandler { @Resource private InventoryCommandService inventoryCommands; @Resource private InventoryBalanceMapper inventoryBalances; @Resource private InventoryPositionMapper inventoryPositions; + @Resource private ReturnInventoryCartonMapper returnCartons; + @Resource private ReturnInventoryCartonItemMapper returnCartonItems; @Resource private RelabelDocumentFileGateway documentFiles; @Resource private PdaRelabelTaskMapper pdaTasks; @Resource private RelabelPhotoFileGateway photoFiles; @@ -238,7 +244,7 @@ public class RelabelApplicationService implements WalletResultHandler { .gt(InventoryBalance::getAvailablePackages, 0) .like(StringUtils.hasText(inboundCode), InventoryBalance::getInboundCode, trim(inboundCode)) .orderByDesc(InventoryBalance::getUpdatedAt)); - return balances.stream().map(balance -> { + List result = new java.util.ArrayList<>(balances.stream().map(balance -> { List positions = inventoryPositions.listBalance(customerId, balance.getId()) .stream().filter(position -> value(position.getAvailablePackages()) > 0) .map(position -> new RelabelResponses.InventoryPosition(position.getId(), position.getPalletNo(), @@ -249,8 +255,27 @@ public class RelabelApplicationService implements WalletResultHandler { balance.getInboundOrderNo(), balance.getInboundCode(), balance.getOverseasWarehouseId(), balance.getOverseasWarehouseCode(), balance.getOverseasWarehouseName(), balance.getDestinationWarehouseCode(), balance.getDestinationWarehouseName(), balance.getSkuCode(), - balance.getProductNameZh(), available, value(balance.getVersion()), positions); - }).filter(candidate -> candidate.availablePackages() > 0).toList(); + balance.getProductNameZh(), available, value(balance.getVersion()), positions, + "INBOUND", null, null, List.of()); + }).filter(candidate -> candidate.availablePackages() > 0).toList()); + for (ReturnInventoryCarton carton : returnCartons.findAvailable(customerId, operationWarehouseId.trim())) { + String keyword = trim(inboundCode); + if (StringUtils.hasText(keyword) + && !containsIgnoreCase(carton.getCartonMark(), keyword) + && !containsIgnoreCase(carton.getSourceReturnNo(), keyword)) continue; + List cartonSkus = returnCartonItems.listCarton(carton.getId()).stream() + .filter(item -> value(item.getQuantity()) > 0) + .map(item -> new RelabelResponses.SkuItem(item.getSkuCode(), null, + Math.toIntExact(item.getQuantity()), null)).toList(); + if (cartonSkus.isEmpty()) continue; + String skuCodes = cartonSkus.stream().map(RelabelResponses.SkuItem::skuCode) + .collect(Collectors.joining(",")); + result.add(new RelabelResponses.InventoryCandidate(null, null, carton.getSourceReturnNo(), + null, carton.getOverseasWarehouseId(), null, null, carton.getDestinationWarehouseCode(), + carton.getDestinationWarehouseName(), skuCodes, "退货合箱库存", 1, 0, List.of(), + "RETURN_CARTON", carton.getId(), carton.getCartonMark(), cartonSkus)); + } + return result; } @Transactional(readOnly = true) @@ -280,6 +305,19 @@ public class RelabelApplicationService implements WalletResultHandler { } @Transactional public RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request) { + return create(customerId, operatorId, request, null); + } + + @Transactional + public RelabelResponses.Detail createForExistingTailOrder(String customerId, String operatorId, + Long existingTailOrderId, + RelabelRequests.Create request) { + if (existingTailOrderId == null) throw badRequest("Existing tail order is required"); + return create(customerId, operatorId, request, existingTailOrderId); + } + + private RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request, + Long existingTailOrderId) { if (!StringUtils.hasText(request.operationWarehouseId())) throw badRequest("Operation warehouse is required"); boolean requiresOutbound = request.items().stream().anyMatch(item -> !"SKU_LABEL".equals(operationType(item))); String deliveryMethod = trim(request.deliveryMethod()); @@ -298,6 +336,13 @@ public class RelabelApplicationService implements WalletResultHandler { if (!requestHash.equals(replay.getRequestHash())) throw conflict("Idempotency key was reused with different relabel data"); return detail(customerId, replay.getRelabelNo()); } + Set requestedDestinations = request.items().stream() + .filter(item -> !"SKU_LABEL".equals(operationType(item))) + .map(RelabelRequests.Item::destinationFbaWarehouseCode) + .filter(StringUtils::hasText).map(String::trim).collect(Collectors.toSet()); + if (requestedDestinations.size() > 1) { + throw badRequest("同一张贴标申请只能选择一个 FBA 目的仓"); + } int total = 0; int notified = 0; List sources = new java.util.ArrayList<>(); @@ -312,6 +357,25 @@ public class RelabelApplicationService implements WalletResultHandler { if (!selectedCartonMarks.add(item.cartonMark().trim().toUpperCase())) { throw badRequest("The same carton mark cannot create more than one relabel task"); } + if (item.returnCartonId() != null) { + ReturnInventoryCarton carton = returnCartons.lockId(item.returnCartonId()); + if (carton == null || !customerId.equals(carton.getCustomerId()) + || !request.operationWarehouseId().trim().equals(carton.getOverseasWarehouseId())) { + throw conflict("所选退货箱不属于当前客户和操作仓"); + } + if (!"AVAILABLE".equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) { + throw conflict("仓库唛头 " + carton.getCartonMark() + " 已不可用于换标"); + } + if (!carton.getCartonMark().equalsIgnoreCase(item.cartonMark().trim())) { + throw conflict("退货箱仓库唛头已变化,请重新查询"); + } + sources.add(new SourceInventory(item, null, carton)); + continue; + } + if (item.sourceInventoryBalanceId() == null || item.sourceInventoryPositionId() == null + || item.sourceInboundOrderId() == null) { + throw badRequest("正常入仓库存必须提供余额、仓位和入仓单"); + } InventoryPosition position = inventoryPositions.findTenantPosition(customerId, item.sourceInventoryPositionId()); if (position == null || !Objects.equals(position.getBalanceId(), item.sourceInventoryBalanceId())) { throw conflict("Selected inventory position is no longer available"); @@ -325,7 +389,7 @@ public class RelabelApplicationService implements WalletResultHandler { if (value(position.getAvailablePackages()) < item.notifiedPackageCount()) { throw conflict("Available inventory changed for inbound " + balance.getInboundOrderNo()); } - sources.add(new SourceInventory(item, balance)); + sources.add(new SourceInventory(item, balance, null)); } LocalDateTime now = LocalDateTime.now(); long applicationId = IdWorker.getId(); @@ -335,13 +399,15 @@ public class RelabelApplicationService implements WalletResultHandler { boolean requiresWallet = chargeAmount.signum() > 0; int holdIndex = 0; for (SourceInventory source : sources) { + if (source.returnCarton() != null) continue; inventoryCommands.hold(customerId, operatorId, new InventoryCommandRequests.Move( source.request().sourceInventoryPositionId(), source.request().notifiedPackageCount(), relabelNo, null, "RELABEL:" + relabelNo, "RELABEL:" + applicationId + ":HOLD:" + holdIndex++, "贴标申请冻结原入仓单库存")); } - LastlegOrder tailOrder = insertTailOrder(customerId, request, requestHash, relabelNo, - operationWarehouseId, total, now); + LastlegOrder tailOrder = existingTailOrderId == null + ? insertTailOrder(customerId, request, requestHash, relabelNo, operationWarehouseId, total, now) + : linkExistingTailOrder(customerId, existingTailOrderId, relabelNo, total, now); List tailOrders = List.of(tailOrder); RelabelApplication application = new RelabelApplication(); @@ -372,6 +438,14 @@ public class RelabelApplicationService implements WalletResultHandler { application.setUpdatedAt(now); applications.insert(application); + for (SourceInventory source : sources) { + if (source.returnCarton() == null) continue; + ReturnInventoryCarton carton = source.returnCarton(); + carton.setRelabelApplicationId(applicationId); carton.setRelabelNo(relabelNo); + carton.setRelabelStatus("PENDING"); carton.setStatus("RELABEL_PENDING"); carton.setUpdatedAt(now); + returnCartons.updateById(carton); + } + if (requiresWallet) { String walletEventId = walletCommands.publish("FREEZE", Long.parseLong(customerId), "RELABEL_ORDER", relabelNo, chargeAmount, "换标订单费用冻结", 1L); @@ -383,8 +457,10 @@ public class RelabelApplicationService implements WalletResultHandler { insertItem(customerId, applicationId, source, now); } insertHistory(customerId, applicationId, null, PENDING_PROCESSING, operatorId, "Relabel application submitted", now); - tailOrders.forEach(order -> insertOrderHistory(customerId, order.getId(), null, - order.getCurrentStatus(), "Relabel application submitted", now)); + if (existingTailOrderId == null) { + tailOrders.forEach(order -> insertOrderHistory(customerId, order.getId(), null, + order.getCurrentStatus(), "Relabel application submitted", now)); + } return detail(customerId, relabelNo); } @@ -501,7 +577,8 @@ public class RelabelApplicationService implements WalletResultHandler { } for (LastlegOrder order : linkedOrders) { String orderBefore = order.getCurrentStatus(); - String mainStatus = STATUSES.contains(orderBefore) ? "PENDING_ASSIGNMENT" : orderBefore; + String mainStatus = "RELABEL_APPLICATION".equals(order.getOrderType()) + ? "WAIT_RELABEL" : orderBefore; String orderStatus = switch (next) { case CUSTOMER_CONFIRM_PENDING -> mainStatus; case "COMPLETED" -> "RETURN_RELABEL".equals(order.getOrderType()) @@ -606,23 +683,27 @@ public class RelabelApplicationService implements WalletResultHandler { PdaRelabelTask reviewTask = pdaTasks.lock(customerId, application.getId()); if (reviewTask == null || !Set.of("WAIT_REVIEW", "COMPLETED").contains(reviewTask.getStatus())) throw conflict("仓库尚未提交换标完工复核"); - reviewTask.setStatus("COMPLETED"); reviewTask.setReviewDecision("CUSTOMER_CONFIRMED"); - reviewTask.setReviewerKey(operatorId); reviewTask.setReviewerName("客户确认"); - reviewTask.setReviewRemark(request.remark()); reviewTask.setReviewedAt(LocalDateTime.now()); - reviewTask.setUpdatedAt(LocalDateTime.now()); - if (pdaTasks.updateWithVersion(reviewTask, reviewTask.getVersion()) != 1) throw conflict("复核状态已变化,请刷新"); if (Boolean.TRUE.equals(application.getPrintShippingLabel()) && !StringUtils.hasText(application.getShippingLabelFileId())) { throw conflict("Upload the shipping label before confirming relabel completion"); } + List relabelItems = itemList(application.getId()); + boolean requiresOutbound = relabelItems.stream() + .anyMatch(item -> !"SKU_LABEL".equals(operationType(item))); + reviewTask.setStatus("COMPLETED"); reviewTask.setReviewDecision("CONFIRMED"); + reviewTask.setReviewerKey(operatorId); reviewTask.setReviewerName("客户确认"); + reviewTask.setReviewRemark(request.remark()); reviewTask.setReviewedAt(LocalDateTime.now()); + reviewTask.setUpdatedAt(LocalDateTime.now()); + if (pdaTasks.updateWithVersion(reviewTask, reviewTask.getVersion()) != 1) throw conflict("复核状态已变化,请刷新"); LocalDateTime now = LocalDateTime.now(); String before = application.getStatus(); - application.setStatus(READY_FOR_OUTBOUND); + application.setStatus(requiresOutbound ? READY_FOR_OUTBOUND : "COMPLETED"); application.setCustomerConfirmationKey(key); application.setCustomerConfirmedAt(now); - application.setFulfillmentStatus("TRUCK".equals(application.getDeliveryMethod()) - ? "WAIT_PRE_DISPATCH" : "WAIT_OUTBOUND_CREATION"); - if (!"TRUCK".equals(application.getDeliveryMethod())) { + application.setFulfillmentStatus(requiresOutbound + ? ("TRUCK".equals(application.getDeliveryMethod()) ? "WAIT_PRE_DISPATCH" : "WAIT_OUTBOUND_CREATION") + : "NOT_REQUIRED"); + if (requiresOutbound && !"TRUCK".equals(application.getDeliveryMethod())) { createDirectOutbound(application, operatorId, now); application.setFulfillmentStatus("OUTBOUND_CREATED"); } @@ -630,7 +711,7 @@ public class RelabelApplicationService implements WalletResultHandler { applications.updateById(application); for (LastlegOrder order : tailOrders(application)) { String orderBefore = order.getCurrentStatus(); - order.setCurrentStatus("PENDING_ASSIGNMENT"); + order.setCurrentStatus(requiresOutbound ? "PENDING_ASSIGNMENT" : "COMPLETED"); order.setSubOrderStatus("RELABEL:" + application.getFulfillmentStatus()); order.setRelabelStatus("COMPLETED"); order.setUpdatedAt(now); @@ -640,24 +721,34 @@ public class RelabelApplicationService implements WalletResultHandler { request.remark(), now); } } - insertHistory(customerId, application.getId(), before, READY_FOR_OUTBOUND, operatorId, + insertHistory(customerId, application.getId(), before, application.getStatus(), operatorId, request.remark(), now); // 仓储转运换标在客户确认后立即恢复为可排车,发车时再转为 OUTBOUNDED。 if (warehouseTransfers != null) { WarehouseTransferOrder transfer = warehouseTransfers.lockByRelabelNo(customerId, relabelNo); if (transfer != null && "WAIT_RELABEL".equals(transfer.getStatus())) { - transfer.setStatus("READY_FOR_DISPATCH"); + Set destinations = relabelItems.stream() + .map(RelabelItem::getDestinationFbaWarehouseCode) + .filter(StringUtils::hasText).map(String::trim).collect(Collectors.toSet()); + if (!StringUtils.hasText(transfer.getFbaWarehouseCode()) && destinations.size() == 1) { + transfer.setFbaWarehouseCode(destinations.iterator().next()); + } + String transferStatus = StringUtils.hasText(transfer.getFbaWarehouseCode()) + ? "READY_FOR_DISPATCH" : "WAIT_FBA_DESTINATION"; + transfer.setStatus(transferStatus); transfer.setRelabelCompletedAt(now); transfer.setUpdatedAt(now); warehouseTransfers.updateById(transfer); + application.setFulfillmentStatus(transferStatus); + applications.updateById(application); // 转运单自身的尾程订单与换标申请尾程订单是两条记录,需同步释放原转运单参与排车。 LastlegOrder transferOrder = orders.selectById(transfer.getLastlegOrderId()); if (transferOrder != null && customerId.equals(transferOrder.getCustomerId())) { String transferOrderBefore = transferOrder.getCurrentStatus(); - transferOrder.setCurrentStatus("READY_FOR_DISPATCH"); + transferOrder.setCurrentStatus(transferStatus); + transferOrder.setFbaWarehouseCode(transfer.getFbaWarehouseCode()); transferOrder.setRelabelStatus("COMPLETED"); - transferOrder.setSubOrderStatus("WAREHOUSE_TRANSFER:READY_FOR_DISPATCH;RELABEL:" - + "COMPLETED"); + transferOrder.setSubOrderStatus("WAREHOUSE_TRANSFER:" + transferStatus + ";RELABEL:COMPLETED"); transferOrder.setUpdatedAt(now); orders.updateById(transferOrder); if (!Objects.equals(transferOrderBefore, transferOrder.getCurrentStatus())) { @@ -683,27 +774,28 @@ public class RelabelApplicationService implements WalletResultHandler { outbound.setOutboundNo(orderNoGenerator.next("RELABEL_OUTBOUND", application.getOverseasWarehouseId(), outbound.getId())); outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType("RELABEL_OUTBOUND"); outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(relabelItems.stream().mapToInt(item -> intValue(item.getNotifiedPackageCount())).sum()); - outbound.setPalletCount((int) relabelItems.stream().map(RelabelItem::getSourceInventoryPositionId).filter(Objects::nonNull).distinct().count()); + outbound.setPalletCount((int) relabelItems.stream().map(this::outboundSourceKey).distinct().count()); outbound.setRecipientInfo(application.getDeliveryMethod()); outbound.setIdempotencyKey(key); outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); outboundOrders.insert(outbound); Map> byPosition = relabelItems.stream().collect(Collectors.groupingBy( - RelabelItem::getSourceInventoryPositionId, LinkedHashMap::new, Collectors.toList())); + this::outboundSourceKey, LinkedHashMap::new, Collectors.toList())); for (Map.Entry> entry : byPosition.entrySet()) { - InventoryPosition position = inventoryPositions.selectById(entry.getKey()); - if (position == null || !application.getCustomerId().equals(position.getCustomerId())) { + InventoryPosition position = entry.getKey() > 0 ? inventoryPositions.selectById(entry.getKey()) : null; + if (entry.getKey() > 0 && (position == null || !application.getCustomerId().equals(position.getCustomerId()))) { throw conflict("Relabel source inventory position no longer exists"); } List group = entry.getValue(); OutboundPalletItem item = new OutboundPalletItem(); item.setId(IdWorker.getId()); item.setCustomerId(application.getCustomerId()); item.setOutboundOrderId(outbound.getId()); - item.setWarehousePalletId(position.getPalletId()); item.setPalletNo(position.getPalletNo()); + item.setWarehousePalletId(position == null ? null : position.getPalletId()); + item.setPalletNo(position == null ? null : position.getPalletNo()); item.setPackageCount(group.stream().mapToInt(value -> intValue(value.getNotifiedPackageCount())).sum()); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK"); item.setCartonMarks(group.stream().map(RelabelItem::getCartonMark).filter(StringUtils::hasText).distinct().collect(Collectors.joining(","))); item.setDestinationWarehouseCode(group.stream().map(RelabelItem::getDestinationFbaWarehouseCode) .filter(StringUtils::hasText).distinct().collect(Collectors.joining(","))); item.setInboundCode(group.stream().map(RelabelItem::getSourceInboundCode).filter(StringUtils::hasText).distinct().collect(Collectors.joining(","))); - item.setOrderRefs(writeOutboundRefs(application.getRelabelNo(), entry.getKey(), group)); + item.setOrderRefs(writeOutboundRefs(application.getRelabelNo(), position == null ? null : entry.getKey(), group)); item.setCreatedAt(now); outboundItems.insert(item); } OutboundPdaTask task = new OutboundPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(application.getCustomerId()); @@ -714,11 +806,14 @@ public class RelabelApplicationService implements WalletResultHandler { private String writeOutboundRefs(String relabelNo, Long positionId, List values) { try { - return objectMapper.writeValueAsString(Map.of("sourceType", "RELABEL_ORDER", "relabelNo", relabelNo, - "sourceInventoryPositionId", positionId, "items", values.stream().map(item -> Map.of( + Map refs = new LinkedHashMap<>(); + refs.put("sourceType", "RELABEL_ORDER"); refs.put("relabelNo", relabelNo); + if (positionId != null) refs.put("sourceInventoryPositionId", positionId); + refs.put("items", values.stream().map(item -> Map.of( "itemId", item.getId(), "cartonMark", Objects.toString(item.getCartonMark(), ""), "destination", Objects.toString(item.getDestinationFbaWarehouseCode(), ""), - "packages", intValue(item.getNotifiedPackageCount()))).toList())); + "packages", intValue(item.getNotifiedPackageCount()))).toList()); + return objectMapper.writeValueAsString(refs); } catch (Exception exception) { throw new IllegalStateException("Cannot save relabel outbound snapshot", exception); } } @@ -740,14 +835,21 @@ public class RelabelApplicationService implements WalletResultHandler { item.setDestinationFbaWarehouseCode(trim(source.destinationFbaWarehouseCode())); item.setOperationType(operationType(source)); item.setSkuRelabelItemsJson(writeSkuItems(source.skuItems())); - item.setSourceInventoryBalanceId(balance.getId()); - item.setSourceInventoryPositionId(source.sourceInventoryPositionId()); - item.setSourceInboundOrderId(balance.getInboundOrderId()); - item.setSourceInboundOrderNo(balance.getInboundOrderNo()); - item.setSourceInboundCode(balance.getInboundCode()); - item.setSourceWarehouseId(balance.getOverseasWarehouseId()); - item.setSourceWarehouseCode(balance.getOverseasWarehouseCode()); - item.setSourceWarehouseName(balance.getOverseasWarehouseName()); + if (balance != null) { + item.setSourceInventoryBalanceId(balance.getId()); + item.setSourceInventoryPositionId(source.sourceInventoryPositionId()); + item.setSourceInboundOrderId(balance.getInboundOrderId()); + item.setSourceInboundOrderNo(balance.getInboundOrderNo()); + item.setSourceInboundCode(balance.getInboundCode()); + item.setSourceWarehouseId(balance.getOverseasWarehouseId()); + item.setSourceWarehouseCode(balance.getOverseasWarehouseCode()); + item.setSourceWarehouseName(balance.getOverseasWarehouseName()); + } else { + ReturnInventoryCarton carton = sourceInventory.returnCarton(); + item.setReturnCartonId(carton.getId()); item.setSourceCartonMark(carton.getCartonMark()); + item.setSourceInboundOrderNo(carton.getSourceReturnNo()); + item.setSourceWarehouseId(carton.getOverseasWarehouseId()); + } item.setCreatedAt(now); items.insert(item); } @@ -831,7 +933,7 @@ public class RelabelApplicationService implements WalletResultHandler { order.setSourceInboundId(relabelNo + ":" + warehouseId); order.setSourceOrderNo(request.customerOrderNo().trim()); order.setOverseasWarehouseId(warehouseId); order.setPalletCount(0); order.setPackageCount(packageCount); order.setPickedPackageCount(0); - order.setShippedPackageCount(0); order.setCurrentStatus("PENDING_ASSIGNMENT"); + order.setShippedPackageCount(0); order.setCurrentStatus("WAIT_RELABEL"); order.setSubOrderStatus("RELABEL:" + PENDING_PROCESSING); order.setRelabelOrderNo(relabelNo); order.setRelabelStatus(PENDING_PROCESSING); order.setCustomerRemark(trim(request.remark())); order.setSourceEventId("CUSTOMER_RELABEL:" + relabelNo + ":" + warehouseId); @@ -840,6 +942,17 @@ public class RelabelApplicationService implements WalletResultHandler { return order; } + private LastlegOrder linkExistingTailOrder(String customerId, Long orderId, String relabelNo, + int packageCount, LocalDateTime now) { + LastlegOrder order = orders.selectById(orderId); + if (order == null || !customerId.equals(order.getCustomerId())) { + throw conflict("Existing tail order does not belong to this customer"); + } + order.setRelabelOrderNo(relabelNo); order.setRelabelStatus(PENDING_PROCESSING); + order.setPackageCount(packageCount); order.setUpdatedAt(now); orders.updateById(order); + return order; + } + private List tailOrders(RelabelApplication application) { List linked = orders.selectList(new LambdaQueryWrapper() .eq(LastlegOrder::getCustomerId, application.getCustomerId()) @@ -850,12 +963,27 @@ public class RelabelApplicationService implements WalletResultHandler { return legacy == null ? List.of() : List.of(legacy); } - private record SourceInventory(RelabelRequests.Item request, InventoryBalance balance) {} + private record SourceInventory(RelabelRequests.Item request, InventoryBalance balance, + ReturnInventoryCarton returnCarton) {} private static long value(Long value) { return value == null ? 0L : value; } private static int intValue(Integer value) { return value == null ? 0 : value; } + private static boolean containsIgnoreCase(String value, String fragment) { + return value != null && fragment != null + && value.toLowerCase(java.util.Locale.ROOT).contains(fragment.toLowerCase(java.util.Locale.ROOT)); + } private void releaseInventory(String customerId, String operatorId, RelabelApplication application) { for (RelabelItem item : itemList(application.getId())) { + if (item.getReturnCartonId() != null) { + ReturnInventoryCarton carton = returnCartons.lockId(item.getReturnCartonId()); + if (carton != null && customerId.equals(carton.getCustomerId()) + && Objects.equals(application.getId(), carton.getRelabelApplicationId()) + && "RELABEL_PENDING".equals(carton.getStatus())) { + carton.setRelabelApplicationId(null); carton.setRelabelNo(null); carton.setRelabelStatus(null); + carton.setStatus("AVAILABLE"); carton.setUpdatedAt(LocalDateTime.now()); returnCartons.updateById(carton); + } + continue; + } if (item.getSourceInventoryPositionId() == null || item.getNotifiedPackageCount() == null) continue; inventoryCommands.release(customerId, operatorId, new InventoryCommandRequests.Move( item.getSourceInventoryPositionId(), item.getNotifiedPackageCount(), application.getRelabelNo(), null, @@ -938,9 +1066,15 @@ public class RelabelApplicationService implements WalletResultHandler { private static String businessRelabelStatus(String workflowStatus) { if ("PROCESSING".equals(workflowStatus)) return "PROCESSING"; - return Set.of(READY_FOR_OUTBOUND, "COMPLETED").contains(workflowStatus) + return Set.of(CUSTOMER_CONFIRM_PENDING, READY_FOR_OUTBOUND, "COMPLETED").contains(workflowStatus) ? "COMPLETED" : PENDING_PROCESSING; } + + private long outboundSourceKey(RelabelItem item) { + if (item.getSourceInventoryPositionId() != null) return item.getSourceInventoryPositionId(); + if (item.getReturnCartonId() != null) return -item.getReturnCartonId(); + throw conflict("Relabel outbound item has no physical inventory source"); + } private String hash(Object value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") .digest(objectMapper.copy().findAndRegisterModules().writeValueAsString(value).getBytes(StandardCharsets.UTF_8))); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuard.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuard.java new file mode 100644 index 000000000..63dc446ae --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuard.java @@ -0,0 +1,52 @@ +package com.baiyue.lastleg.relabel.service; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory; +import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import java.util.*; + +/** Requires saved physical bindings, never the original accounting freeze anchor. */ +@Service +public class RelabelBindingGuard { + @Resource private RelabelItemMapper items; + @Resource private PdaRelabelHistoryMapper histories; + @Resource private InventoryPositionMapper positions; + @Resource private ObjectMapper objectMapper; + + public void requireBound(RelabelApplication app) { + List cartons = items.selectList(new QueryWrapper() + .eq("application_id", app.getId()).eq("customer_id", app.getCustomerId())) + .stream().filter(item -> !"SKU_LABEL".equals(item.getOperationType())).toList(); + if (cartons.isEmpty()) return; + Map> bindings = new HashMap<>(); + for (PdaRelabelHistory history : histories.selectList(new QueryWrapper() + .eq("application_id", app.getId()).eq("customer_id", app.getCustomerId()) + .orderByAsc("created_at", "id"))) { + if ("REWORK_ARCHIVE".equals(history.getAction())) bindings.clear(); + if (!"PALLET_BIND".equals(history.getAction())) continue; + try { + var data = objectMapper.readTree(history.getDetailJson()); + Set targets = new HashSet<>(); + data.path("targetPallets").forEach(value -> targets.add(value.asText())); + data.path("cartonMarks").forEach(value -> bindings.put(value.asText(), targets)); + } catch (Exception e) { throw conflict("组托记录无法读取,请核对后重试"); } + } + for (RelabelItem item : cartons) { + var position = item.getSourceInventoryPositionId() == null ? null + : positions.findTenantPosition(app.getCustomerId(), item.getSourceInventoryPositionId()); + if (position == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(position.getPalletNo()) + || position.getHoldPackages() == null || position.getHoldPackages() < item.getNotifiedPackageCount()) + throw conflict("箱唛 " + item.getCartonMark() + " 尚未完成托盘绑定,请先在 PDA 确认组托,不能完成或排车出库"); + } + } + private ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } +} 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 622a55aae..9d4def8ea 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 @@ -569,7 +569,7 @@ public class LastlegOrderService { public void writeExportCsv(String customerId, LastlegOrderRequests.Query query, OutputStream output) throws IOException { - output.write("\uFEFFID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜节点,是否异常,是否暂扣,托数,箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,换标单号,换标状态,合托标记,主流程状态,子单状态,备注(仅自己),备注(客户),备注(仓库)\r\n".getBytes(StandardCharsets.UTF_8)); + output.write("\uFEFFID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜节点,是否异常,是否暂扣,托数,客户预报箱数,实收箱数,差异箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,换标单号,换标状态,合托标记,主流程状态,子单状态,备注(仅自己),备注(客户),备注(仓库)\r\n".getBytes(StandardCharsets.UTF_8)); final int batchSize = 1000; long page = 1; while (true) { @@ -585,7 +585,8 @@ public class LastlegOrderService { csv(order.getTransferWarehouseName()), csv(order.getTransferWarehouseCode()), csv(order.getFbaWarehouseCode()), csv(order.getContainerNo()), csv(StringUtils.hasText(order.getSourceContainerBookingId()) ? containerNodes.get(order.getSourceContainerBookingId()) : null), csv(order.getAbnormal()), csv(order.getDetained()), - csv(order.getPalletCount()), csv(order.getPackageCount()), csv(order.getVolumeCbm()), csv(order.getProductQuantity()), + csv(order.getPalletCount()), csv(order.getPackageCount()), csv(order.getReceivedPackageCount()), + csv(packageDifference(order)), csv(order.getVolumeCbm()), csv(order.getProductQuantity()), csv(order.getVolumeWeightKg()), csv(order.getEstimatedArrivalAt()), csv(order.getActualArrivalAt()), csv(order.getEstimatedWarehouseAt()), csv(order.getInboundOrderNo()), csv(order.getRelabelOrderNo()), csv(relabelBusinessStatus(order.getRelabelStatus())), csv(order.getPalletMergeFlag()), csv(order.getCurrentStatus()), csv(order.getSubOrderStatus()), csv(order.getPrivateRemark()), @@ -677,7 +678,8 @@ public class LastlegOrderService { o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), containerNode, o.getAbnormal(), o.getDetained(), o.getHoldChargeable(), o.getHoldReason(), Boolean.TRUE.equals(o.getManualNodeOverride()), Boolean.TRUE.equals(o.getManualNodeOverride()) ? "MANUAL" : "CONTAINER", - o.getVersion() == null ? 0 : o.getVersion(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(), + o.getVersion() == null ? 0 : o.getVersion(), o.getPalletCount(), o.getPackageCount(), + o.getReceivedPackageCount(), packageDifference(o), o.getVolumeCbm(), o.getProductQuantity(), o.getVolumeWeightKg(), o.getEstimatedArrivalAt(), o.getActualArrivalAt(), o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), dispatchStatus, o.getRelabelOrderNo(), relabelBusinessStatus(o.getRelabelStatus()), o.getPalletMergeFlag(), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::mergeGroupNo).distinct().findFirst().orElse(null), @@ -686,6 +688,11 @@ public class LastlegOrderService { o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt()); } + private static Integer packageDifference(LastlegOrder order) { + if (order.getReceivedPackageCount() == null) return null; + return order.getReceivedPackageCount() - java.util.Objects.requireNonNullElse(order.getPackageCount(), 0); + } + static String displayOrderNo(LastlegOrder order) { return "WAREHOUSE_TRANSFER".equals(order.getOrderType()) && StringUtils.hasText(order.getSourceOrderNo()) ? order.getSourceOrderNo() : order.getOrderNo(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferController.java index 63f93c8c4..a6c56a123 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferController.java @@ -12,6 +12,14 @@ import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; +import org.springframework.http.MediaType; +import org.springframework.http.HttpHeaders; +import org.springframework.http.CacheControl; +import org.springframework.http.ContentDisposition; +import org.springframework.http.ResponseEntity; + +import java.nio.charset.StandardCharsets; +import java.util.List; @RestController @RequestMapping("/api/merchant/v1/last-leg/warehouse-transfers") @@ -24,6 +32,30 @@ public class MerchantWarehouseTransferController { return ApiResponse.success(service.create(customer(user), operator(user), request)); } + @PutMapping("/{transferNo}") + public ApiResponse updateDraft( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, + @Valid @RequestBody WarehouseTransferRequests.Create request) { + return ApiResponse.success(service.updateDraft(customer(user), transferNo, operator(user), request)); + } + + /** + * POST 兼容入口:部分本地网关只放行 POST,草稿保存不能因此阻断提交。 + * 与 PUT 更新草稿使用完全相同的服务逻辑和租户校验。 + */ + @PostMapping("/{transferNo}/save-draft") + public ApiResponse saveDraft( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, + @Valid @RequestBody WarehouseTransferRequests.Create request) { + return ApiResponse.success(service.updateDraft(customer(user), transferNo, operator(user), request)); + } + + @PostMapping("/{transferNo}/submit") + public ApiResponse submit(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String transferNo) { + return ApiResponse.success(service.submit(customer(user), transferNo, operator(user))); + } + @GetMapping public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, @Valid WarehouseTransferRequests.Query query) { @@ -36,6 +68,29 @@ public class MerchantWarehouseTransferController { return ApiResponse.success(service.detail(customer(user), transferNo)); } + @GetMapping("/{transferNo}/carton-label") + public ApiResponse cartonLabel(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String transferNo) { + return ApiResponse.success(service.cartonLabel(customer(user), transferNo)); + } + + @GetMapping("/{transferNo}/measurement-photos/files") + public ApiResponse> measurementPhotos( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo) { + return ApiResponse.success(service.measurementPhotos(customer(user), transferNo)); + } + + @GetMapping("/{transferNo}/measurement-photos/files/{fileId}/download") + public ResponseEntity downloadMeasurementPhoto(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String transferNo, + @PathVariable long fileId) { + var file = service.downloadMeasurementPhoto(customer(user), transferNo, fileId); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .header(HttpHeaders.CONTENT_DISPOSITION, + ContentDisposition.inline().filename(file.fileName(), StandardCharsets.UTF_8).build().toString()) + .contentType(MediaType.parseMediaType(file.contentType())).body(file.content()); + } + @PostMapping("/{transferNo}/confirm-measurement") public ApiResponse confirmMeasurement( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/PdaWarehouseTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/PdaWarehouseTransferController.java index 848f20c01..6bd39c1ce 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/PdaWarehouseTransferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/PdaWarehouseTransferController.java @@ -16,7 +16,16 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.http.MediaType; +import org.springframework.http.HttpHeaders; +import org.springframework.http.CacheControl; +import org.springframework.http.ContentDisposition; +import org.springframework.http.ResponseEntity; +import org.springframework.web.multipart.MultipartFile; +import java.nio.charset.StandardCharsets; import java.util.List; @RestController @@ -32,6 +41,42 @@ public class PdaWarehouseTransferController { return ApiResponse.success(service.pdaCandidates(identity.overseasWarehouseId())); } + @GetMapping("/{transferNo}") + public ApiResponse detail( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.pdaDetail(identity.overseasWarehouseId(), transferNo)); + } + + @PostMapping(value = "/{transferNo}/measurement-photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse uploadMeasurementPhoto( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, + @RequestParam("file") MultipartFile file) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + long operatorUserId = user.userId() == null ? 0L : user.userId(); + return ApiResponse.success(service.uploadMeasurementPhoto(identity.operatorKey(), operatorUserId, + identity.overseasWarehouseId(), transferNo, file)); + } + + @GetMapping("/{transferNo}/measurement-photos/files") + public ApiResponse> measurementPhotos( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.pdaMeasurementPhotos(identity.overseasWarehouseId(), transferNo)); + } + + @GetMapping("/{transferNo}/measurement-photos/files/{fileId}/download") + public ResponseEntity downloadMeasurementPhoto(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String transferNo, + @PathVariable long fileId) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + var file = service.downloadPdaMeasurementPhoto(identity.overseasWarehouseId(), transferNo, fileId); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .header(HttpHeaders.CONTENT_DISPOSITION, + ContentDisposition.inline().filename(file.fileName(), StandardCharsets.UTF_8).build().toString()) + .contentType(MediaType.parseMediaType(file.contentType())).body(file.content()); + } + @PostMapping("/{transferNo}/palletize") public ApiResponse palletize( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, @@ -40,4 +85,49 @@ public class PdaWarehouseTransferController { return ApiResponse.success(service.palletize(identity.operatorKey(), identity.overseasWarehouseId(), transferNo, request)); } + + @PostMapping("/{transferNo}/pallets/drafts") + public ApiResponse savePalletDraft( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, + @Valid @RequestBody WarehouseTransferRequests.PalletDraft request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.savePalletDraft(identity.overseasWarehouseId(), transferNo, request)); + } + + @PostMapping("/{transferNo}/pallets/{palletId}/generate") + public ApiResponse generatePallet( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, @PathVariable long palletId, + @Valid @RequestBody WarehouseTransferRequests.PalletAction request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.generatePallet(identity.overseasWarehouseId(), transferNo, palletId, request)); + } + + @PostMapping("/{transferNo}/pallets/{palletId}/bind") + public ApiResponse bindPallet( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, @PathVariable long palletId, + @Valid @RequestBody WarehouseTransferRequests.PalletBind request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.bindPallet(identity.overseasWarehouseId(), transferNo, palletId, request)); + } + + @org.springframework.web.bind.annotation.DeleteMapping("/{transferNo}/pallets/{palletId}") + public ApiResponse deletePalletDraft( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, @PathVariable long palletId) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.deletePalletDraft(identity.overseasWarehouseId(), transferNo, palletId)); + } + + @GetMapping("/{transferNo}/pallets/{warehousePalletId}/label") + public ResponseEntity palletLabel(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String transferNo, + @PathVariable long warehousePalletId, + @RequestHeader(value = "X-Request-Id", required = false) String requestId) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + var file = service.printPdaPalletLabel(identity.overseasWarehouseId(), transferNo, + warehousePalletId, requestId); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .header(HttpHeaders.CONTENT_DISPOSITION, + ContentDisposition.attachment().filename(file.fileName(), StandardCharsets.UTF_8).build().toString()) + .contentType(MediaType.parseMediaType(file.contentType())).body(file.content()); + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferController.java index 9967c47d5..8d3c9f135 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferController.java @@ -24,14 +24,14 @@ public class WarehouseTransferController { @PostMapping public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @Valid @RequestBody WarehouseTransferRequests.Create request) { - return ApiResponse.success(service.create(scope(user, customerId), operator(user), request)); + return ApiResponse.success(service.createAndSubmit(scope(user, customerId), operator(user), request)); } @GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')") public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @Valid WarehouseTransferRequests.Query query) { - return ApiResponse.success(service.page(scope(user, customerId), query)); + return ApiResponse.success(service.page(scope(user, customerId), query, false)); } @GetMapping("/{transferNo}") @@ -56,6 +56,17 @@ public class WarehouseTransferController { return ApiResponse.success(service.review(scope(user, customerId), transferNo, operator(user), request)); } + @PostMapping("/bind-destination") + @PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CREATE + "')") + public ApiResponse> bindDestination( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody WarehouseTransferRequests.BindDestination request) { + if (user == null || user.tenantId() == null || user.tenantId() != 0L || !"PLATFORM".equals(user.appCode())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform identity is required"); + } + return ApiResponse.success(service.bindDestination(operator(user), request)); + } + @PostMapping("/{transferNo}/allocations") public ApiResponse allocate(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @PathVariable String transferNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java index db1373ca1..42804efba 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java @@ -21,7 +21,17 @@ public final class WarehouseTransferRequests { @Size(max = 200) List<@NotNull Long> sourceLastlegOrderIds, @NotBlank @Size(max = 128) String operatingWarehouseId, @Size(max = 128) String fbaWarehouseCode, + @Size(max = 16) String destinationType, + @Size(max = 128) String destinationWarehouseId, + @Size(max = 128) String destinationWarehouseCode, + @Size(max = 255) String destinationWarehouseName, + @Size(max = 1000) String privateDestinationAddress, + @Size(max = 32) String privateDestinationPostalCode, + @Size(max = 128) String privateDestinationContactName, + @Size(max = 64) String privateDestinationContactPhone, @NotBlank @Size(max = 1000) String deliveryAddress, + @Size(max = 128) String pickupContactName, + @Size(max = 64) String pickupContactPhone, @Positive int packageCount, @NotNull @DecimalMin(value = "0.0") BigDecimal volumeCbm, @NotBlank @Size(max = 64) String deliveryMethod, @@ -46,12 +56,29 @@ public final class WarehouseTransferRequests { String deliveryMethod, LocalDateTime estimatedArrivalAt, String fbaShipmentNo, String trackingNo, String fbaBoxNos, String remark, boolean needRelabel, String idempotencyKey, List items) { - this(inboundCode, sourceLastlegOrderIds, operatingWarehouseId, fbaWarehouseCode, deliveryAddress, - packageCount, volumeCbm, deliveryMethod, estimatedArrivalAt, null, fbaShipmentNo, trackingNo, + this(inboundCode, sourceLastlegOrderIds, operatingWarehouseId, fbaWarehouseCode, + null, null, null, null, null, null, null, null, + deliveryAddress, null, null, packageCount, volumeCbm, deliveryMethod, estimatedArrivalAt, null, fbaShipmentNo, trackingNo, fbaBoxNos, remark, needRelabel, null, null, null, null, null, idempotencyKey, items, null); } } + public record DestinationSelection( + @NotBlank @Size(max = 128) String customerId, + @NotBlank @Size(max = 64) String transferNo) {} + + public record BindDestination( + @NotEmpty @Size(max = 100) List<@Valid DestinationSelection> transfers, + @NotBlank @jakarta.validation.constraints.Pattern(regexp = "FBA|PRIVATE") String destinationType, + @Size(max = 128) String destinationWarehouseId, + @Size(max = 128) String destinationWarehouseCode, + @Size(max = 255) String destinationWarehouseName, + @Size(max = 1000) String privateDestinationAddress, + @Size(max = 32) String privateDestinationPostalCode, + @Size(max = 128) String privateDestinationContactName, + @Size(max = 64) String privateDestinationContactPhone, + @NotBlank @Size(max = 255) String idempotencyKey) {} + /** 审核通过备注选填;驳回必须提供非空原因,由服务在任何状态写入前校验。 */ public record Review(@NotBlank @Size(max = 32) String decision, @Size(max = 1000) String remark) {} @@ -72,19 +99,34 @@ public final class WarehouseTransferRequests { public record Palletize( @NotBlank @Size(max = 255) String idempotencyKey, - @NotEmpty @Size(max = 100) List<@Valid Pallet> pallets + @NotEmpty @Size(max = 20) List<@NotNull Long> photoFileIds ) {} + public record PalletDraft( + Long palletId, + @NotBlank @Size(max = 255) String idempotencyKey, + @Positive int cartonCount, + @NotNull @DecimalMin("0.01") BigDecimal lengthCm, + @NotNull @DecimalMin("0.01") BigDecimal widthCm, + @NotNull @DecimalMin("0.01") BigDecimal heightCm, + @NotNull @DecimalMin("0.000001") BigDecimal volumeCbm, + @NotNull @DecimalMin("0.01") BigDecimal grossWeightKg + ) {} + + public record PalletAction(@NotBlank @Size(max = 255) String idempotencyKey) {} + + public record PalletBind(@NotBlank @Size(max = 128) String palletCode, + @NotBlank @Size(max = 255) String idempotencyKey) {} + public record Pallet( - @Size(max = 128) String palletCode, @Positive int cartonCount, + @NotNull @DecimalMin("0.01") BigDecimal lengthCm, + @NotNull @DecimalMin("0.01") BigDecimal widthCm, + @NotNull @DecimalMin("0.01") BigDecimal heightCm, @NotNull @DecimalMin("0.000001") BigDecimal volumeCbm, - @NotNull @DecimalMin("0.01") BigDecimal grossWeightKg, - @NotEmpty @Size(max = 500) List<@Valid PalletItem> items + @NotNull @DecimalMin("0.01") BigDecimal grossWeightKg ) {} - public record PalletItem(@NotNull Long transferItemId, @Positive int packageCount) {} - public record CustomerConfirm(@NotBlank @Size(max = 255) String idempotencyKey, @Size(max = 1000) String remark) {} @@ -106,8 +148,8 @@ public final class WarehouseTransferRequests { ) {} public record Item( - @NotBlank @Size(max = 128) String skuCode, - @NotBlank @Size(max = 255) String productNameZh, + @Size(max = 128) String skuCode, + @Size(max = 255) String productNameZh, @Positive int packageCount, @Size(max = 255) String shippingMark ) {} @@ -115,7 +157,7 @@ public final class WarehouseTransferRequests { public record RelabelItem( @NotBlank @Size(max = 32) String operationType, @NotEmpty @Size(max = 500) List<@NotBlank @Size(max = 255) String> customerCartonMarks, - @NotBlank @Size(max = 128) String originalSku, + @Size(max = 128) String originalSku, @Size(max = 128) String newSku, @Positive int quantity, @Size(max = 255) String fbaCartonMark, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java index 3abdde3da..140c50ef6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java @@ -9,12 +9,16 @@ public final class WarehouseTransferResponses { public record Item(Long id, String skuCode, String productNameZh, int packageCount, String shippingMark) {} public record Carton(String cartonMark, String customerCartonMark, int cartonIndex, int totalCartons, String status) {} public record Label(String transferNo, List cartons, String printableHtml) {} + public record DestinationResult(String customerId, String transferNo) {} public record PalletItem(Long transferItemId, String skuCode, int packageCount) {} + public record MeasurementPhoto(Long fileId, String fileName, String contentType, long sizeBytes, + LocalDateTime uploadedAt) {} public record Pallet(Long id, int sequenceNo, Long warehousePalletId, String palletNo, Long warehouseLocationId, String warehouseLocationCode, int cartonCount, BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm, BigDecimal grossWeightKg, BigDecimal volumeCbm, String status, - List items, LocalDateTime completedAt) {} + String fbaWarehouseCode, String labelDownloadPath, + List items, LocalDateTime boundAt, LocalDateTime completedAt) {} public record RelabelBinding(String operationType, String customerCartonMark, String originalSku, String newSku, int quantity, String fbaCartonMark, String destinationWarehouseCode, String remark) {} @@ -24,19 +28,28 @@ public final class WarehouseTransferResponses { Long destinationInventoryPositionId) {} public record Summary(Long id, String transferNo, String inboundCode, Long lastlegOrderId, String lastlegOrderNo, String operatingWarehouseId, String fbaWarehouseCode, + String destinationType, String destinationWarehouseCode, String destinationWarehouseName, int packageCount, String deliveryMethod, boolean needRelabel, String relabelNo, String status, LocalDateTime estimatedArrivalAt, LocalDateTime createdAt) {} public record Detail(Long id, String transferNo, Long inboundOrderId, String inboundCode, Long lastlegOrderId, String lastlegOrderNo, String orderType, String orderStatus, String status, boolean needRelabel, Long relabelApplicationId, String relabelNo, String relabelStatus, - String operatingWarehouseId, String fbaWarehouseCode, String deliveryAddress, - int packageCount, String deliveryMethod, LocalDateTime estimatedArrivalAt, + String operatingWarehouseId, String fbaWarehouseCode, String deliveryAddress, + String destinationType, String destinationWarehouseId, String destinationWarehouseCode, + String destinationWarehouseName, String privateDestinationAddress, + String privateDestinationPostalCode, + String privateDestinationContactName, String privateDestinationContactPhone, + String pickupContactName, String pickupContactPhone, + int packageCount, BigDecimal volumeCbm, String deliveryMethod, LocalDateTime estimatedArrivalAt, LocalDateTime estimatedShipAt, String fbaShipmentNo, String trackingNo, String fbaBoxNos, String remark, + LocalDateTime labelDeadline, String relabelContactName, String relabelContactPhone, + String relabelDeliveryMethod, String reviewDecision, String reviewRemark, String reviewedBy, LocalDateTime reviewedAt, List sourceOrders, List items, List cartons, List pallets, List relabelBindings, - List allocations, LocalDateTime customerConfirmedAt, + List allocations, List measurementPhotos, + LocalDateTime customerConfirmedAt, LocalDateTime actualShipAt, LocalDateTime createdAt) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferMeasurementPhoto.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferMeasurementPhoto.java new file mode 100644 index 000000000..52668595a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferMeasurementPhoto.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.transfer.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_warehouse_transfer_measurement_photo") +public class WarehouseTransferMeasurementPhoto { + @TableId(type = IdType.INPUT) private Long id; + private Long transferOrderId; + private String customerId; + private Long fileId; + private String fileName; + private String contentType; + private Long sizeBytes; + private String uploadedBy; + private LocalDateTime uploadedAt; + private LocalDateTime submittedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferOrder.java index 6ce3b354f..a994d1730 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferOrder.java @@ -18,7 +18,17 @@ public class WarehouseTransferOrder { private String lastlegOrderNo; private String operatingWarehouseId; private String fbaWarehouseCode; + private String destinationType; + private String destinationWarehouseId; + private String destinationWarehouseCode; + private String destinationWarehouseName; + private String privateDestinationAddress; + private String privateDestinationPostalCode; + private String privateDestinationContactName; + private String privateDestinationContactPhone; private String deliveryAddress; + private String pickupContactName; + private String pickupContactPhone; private Integer packageCount; private String deliveryMethod; private LocalDateTime estimatedArrivalAt; @@ -50,6 +60,8 @@ public class WarehouseTransferOrder { private String palletizeRequestHash; private String confirmationIdempotencyKey; private String confirmationRequestHash; + private String destinationBindingIdempotencyKey; + private String destinationBindingRequestHash; private LocalDateTime customerConfirmedAt; private String idempotencyKey; private String requestHash; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferPallet.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferPallet.java index 749eeb1af..409910321 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferPallet.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferPallet.java @@ -27,6 +27,11 @@ public class WarehouseTransferPallet { private BigDecimal grossWeightKg; private BigDecimal volumeCbm; private String status; + private String draftIdempotencyKey; + private String draftRequestHash; + private String generateIdempotencyKey; + private String bindIdempotencyKey; + private LocalDateTime boundAt; private LocalDateTime completedAt; private LocalDateTime createdAt; private LocalDateTime updatedAt; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/gateway/HttpTransferMeasurementPhotoFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/gateway/HttpTransferMeasurementPhotoFileGateway.java new file mode 100644 index 000000000..0ee0f88a0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/gateway/HttpTransferMeasurementPhotoFileGateway.java @@ -0,0 +1,106 @@ +package com.baiyue.lastleg.transfer.gateway; + +import com.baiyue.lastleg.config.LastlegRestClientFactory; +import com.baiyue.lastleg.config.LastlegRestClientProperties; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.*; +import org.springframework.http.client.MultipartBodyBuilder; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.io.IOException; + +@Component +public class HttpTransferMeasurementPhotoFileGateway implements TransferMeasurementPhotoFileGateway { + private static final String TYPE = "OVERSEAS_TRANSFER_MEASUREMENT_PHOTO"; + @Resource private RestClient.Builder restClientBuilder; + @Resource private LastlegRestClientFactory restClientFactory; + @Resource private LastlegRestClientProperties restClientProperties; + @Value("${baiyue.clients.file.url:http://127.0.0.1:9206}") private String baseUrl; + @Value("${baiyue.security.internal-service-token:}") private String internalToken; + private RestClient client; + + @PostConstruct void initializeClient() { + client = restClientFactory.build(restClientBuilder, baseUrl, restClientProperties.getRelabelPhotoFile()); + } + + @Override public UploadedFile upload(String customerId, long transferId, long operatorId, MultipartFile file) { + MultipartBodyBuilder body = new MultipartBodyBuilder(); + try { + body.part("file", new ByteArrayResource(file.getBytes()) { + @Override public String getFilename() { return file.getOriginalFilename(); } + }).contentType(file.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM + : MediaType.parseMediaType(file.getContentType())); + } catch (IOException exception) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Cannot read transfer measurement photo", exception); + } + MetadataEnvelope response = client.post().uri("/internal/file/v1/business-files/{tenant}/{type}/{business}", + tenant(customerId), TYPE, transferId) + .header("X-Internal-Service-Token", internalToken).header("X-Operator-Id", String.valueOf(operatorId)) + .contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { throw failure(result.getStatusCode(), "Transfer measurement photo upload failed"); }) + .body(MetadataEnvelope.class); + Metadata data = success(response, "Transfer measurement photo upload failed"); + return new UploadedFile(data.fileId(), data.fileName(), data.contentType(), data.sizeBytes() == null ? 0 : data.sizeBytes()); + } + + @Override public void validate(String customerId, long transferId, long fileId) { + long tenant = tenant(customerId); + MetadataEnvelope response = client.get().uri("/internal/file/v1/business-files/{tenant}/{type}/{business}/{file}", + tenant, TYPE, transferId, fileId).header("X-Internal-Service-Token", internalToken).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { throw failure(result.getStatusCode(), "Transfer measurement photo ownership is invalid"); }) + .body(MetadataEnvelope.class); + Metadata data = success(response, "Transfer measurement photo ownership is invalid"); + if (data.fileId() != fileId || data.tenantId() == null || data.tenantId() != tenant + || !TYPE.equals(data.businessType()) || !String.valueOf(transferId).equals(data.businessId())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Transfer measurement photo ownership is invalid"); + } + } + + @Override public DownloadedFile download(String customerId, long transferId, long fileId) { + validate(customerId, transferId, fileId); + ResponseEntity response = client.get().uri("/internal/file/v1/business-files/{tenant}/{type}/{business}/{file}/content", + tenant(customerId), TYPE, transferId, fileId).header("X-Internal-Service-Token", internalToken).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { throw failure(result.getStatusCode(), "Transfer measurement photo download failed"); }) + .toEntity(byte[].class); + String name = response.getHeaders().getContentDisposition().getFilename(); + String type = response.getHeaders().getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM_VALUE + : response.getHeaders().getContentType().toString(); + return new DownloadedFile(response.getBody() == null ? new byte[0] : response.getBody(), + name == null ? "transfer-measurement-photo" : name, type); + } + + private static long tenant(String value) { + try { return Long.parseLong(value); } + catch (NumberFormatException exception) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Customer ID must be numeric", exception); } + } + private static Metadata success(MetadataEnvelope response, String fallback) { + if (response == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, fallback); + if (!"200".equals(response.code())) { + HttpStatus status = switch (String.valueOf(response.code())) { + case "A0400", "A0422" -> HttpStatus.BAD_REQUEST; + case "A0403" -> HttpStatus.FORBIDDEN; + case "A0404" -> HttpStatus.NOT_FOUND; + default -> HttpStatus.SERVICE_UNAVAILABLE; + }; + throw new ResponseStatusException(status, + response.message() == null || response.message().isBlank() ? fallback : response.message()); + } + if (response.data() == null || response.data().fileId() == null) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "File service returned no transfer measurement photo metadata"); + } + return response.data(); + } + private static ResponseStatusException failure(HttpStatusCode status, String message) { + return new ResponseStatusException(status.is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, message); + } + private record MetadataEnvelope(String code, String message, Metadata data) {} + private record Metadata(Long fileId, Long tenantId, String businessType, String businessId, + String fileName, String contentType, Long sizeBytes) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/gateway/TransferMeasurementPhotoFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/gateway/TransferMeasurementPhotoFileGateway.java new file mode 100644 index 000000000..9324c10c6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/gateway/TransferMeasurementPhotoFileGateway.java @@ -0,0 +1,12 @@ +package com.baiyue.lastleg.transfer.gateway; + +import org.springframework.web.multipart.MultipartFile; + +public interface TransferMeasurementPhotoFileGateway { + UploadedFile upload(String customerId, long transferId, long operatorId, MultipartFile file); + void validate(String customerId, long transferId, long fileId); + DownloadedFile download(String customerId, long transferId, long fileId); + + record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) {} + record DownloadedFile(byte[] content, String fileName, String contentType) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferMeasurementPhotoMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferMeasurementPhotoMapper.java new file mode 100644 index 000000000..829eb0360 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferMeasurementPhotoMapper.java @@ -0,0 +1,33 @@ +package com.baiyue.lastleg.transfer.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferMeasurementPhoto; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.util.List; + +@Mapper +public interface WarehouseTransferMeasurementPhotoMapper extends BaseMapper { + @Select("SELECT * FROM ll_warehouse_transfer_measurement_photo WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} ORDER BY uploaded_at,id") + List listOrder(@Param("customerId") String customerId, + @Param("transferOrderId") Long transferOrderId); + @Select("SELECT * FROM ll_warehouse_transfer_measurement_photo WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} AND submitted_at IS NOT NULL ORDER BY submitted_at,id") + List listSubmitted(@Param("customerId") String customerId, + @Param("transferOrderId") Long transferOrderId); + + @Select("SELECT * FROM ll_warehouse_transfer_measurement_photo WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} AND file_id=#{fileId} LIMIT 1") + WarehouseTransferMeasurementPhoto findFile(@Param("customerId") String customerId, + @Param("transferOrderId") Long transferOrderId, + @Param("fileId") Long fileId); + @Select("SELECT * FROM ll_warehouse_transfer_measurement_photo WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} AND file_id=#{fileId} AND submitted_at IS NOT NULL LIMIT 1") + WarehouseTransferMeasurementPhoto findSubmittedFile(@Param("customerId") String customerId, + @Param("transferOrderId") Long transferOrderId, + @Param("fileId") Long fileId); + @Update("") + int markSubmitted(@Param("customerId") String customerId, @Param("transferOrderId") Long transferOrderId, + @Param("fileIds") java.util.Collection fileIds, + @Param("submittedAt") java.time.LocalDateTime submittedAt); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java index bc86eed62..ceb53edcc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java @@ -14,6 +14,8 @@ import org.apache.ibatis.annotations.Update; WarehouseTransferOrder lockNo(@Param("customerId") String customerId, @Param("transferNo") String transferNo); @Select("SELECT * FROM ll_warehouse_transfer_order WHERE transfer_no=#{transferNo} AND operating_warehouse_id=#{warehouseId} LIMIT 1 FOR UPDATE") WarehouseTransferOrder lockForPda(@Param("transferNo") String transferNo, @Param("warehouseId") String warehouseId); + @Select("SELECT * FROM ll_warehouse_transfer_order WHERE transfer_no=#{transferNo} AND operating_warehouse_id=#{warehouseId} LIMIT 1") + WarehouseTransferOrder findNoByWarehouse(@Param("transferNo") String transferNo, @Param("warehouseId") String warehouseId); @Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} LIMIT 1 FOR UPDATE") WarehouseTransferOrder lockByRelabelNo(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo); @Select("SELECT * FROM ll_warehouse_transfer_order WHERE operating_warehouse_id=#{warehouseId} AND status='WAIT_PDA_RECEIVING' ORDER BY created_at,id") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferPalletMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferPalletMapper.java index 6526636ea..fb9361e0e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferPalletMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferPalletMapper.java @@ -13,4 +13,9 @@ public interface WarehouseTransferPalletMapper extends BaseMapper listOrder(@Param("customerId") String customerId, @Param("transferOrderId") Long transferOrderId); + + @Select("SELECT * FROM ll_warehouse_transfer_pallet WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} AND id=#{id} FOR UPDATE") + WarehouseTransferPallet lockOne(@Param("customerId") String customerId, + @Param("transferOrderId") Long transferOrderId, + @Param("id") Long id); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/model/WarehouseTransferStatus.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/model/WarehouseTransferStatus.java new file mode 100644 index 000000000..271c2287f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/model/WarehouseTransferStatus.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.transfer.model; + +import java.util.Arrays; + +/** 仓储转运单状态。状态编码持久化到数据库,中文名称用于接口和前端展示。 */ +public enum WarehouseTransferStatus { + DRAFT("草稿"), + PENDING_REVIEW("待审核"), + APPROVED("审核通过"), + REJECTED("审核驳回"), + WAIT_PDA_RECEIVING("待PDA收货"), + INBOUND_MEASURING("入库测量中"), + WAIT_CUSTOMER_CONFIRMATION("待客户确认实测"), + WAIT_RELABEL("待换标"), + WAIT_FBA_DESTINATION("待确认目的仓"), + READY_FOR_DISPATCH("待排车"), + WAIT_OUTBOUND("待出库"), + PENDING_ASSIGNMENT("待分配"), + ALLOCATED("已分配库存"), + OUTBOUNDED("已出库"), + PARTIALLY_RECEIVED("部分收货"), + RECEIVED("已收货"), + INBOUND_COMPLETED("入库完成"), + COMPLETED("已完成"); + + private final String label; + + WarehouseTransferStatus(String label) { this.label = label; } + + public String code() { return name(); } + public String label() { return label; } + + public static WarehouseTransferStatus fromCode(String code) { + return Arrays.stream(values()).filter(value -> value.name().equals(code)).findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unsupported warehouse transfer status: " + code)); + } +} 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 e34173b70..2d5cec2a7 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 @@ -46,6 +46,8 @@ import com.baiyue.lastleg.transfer.entity.WarehouseTransferSourceOrder; import com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet; import com.baiyue.lastleg.transfer.entity.WarehouseTransferPalletItem; import com.baiyue.lastleg.transfer.entity.WarehouseTransferRelabelBinding; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferMeasurementPhoto; +import com.baiyue.lastleg.transfer.gateway.TransferMeasurementPhotoFileGateway; import com.baiyue.lastleg.transfer.mapper.InboundSnapshotMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferItemMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferCartonMapper; @@ -56,6 +58,7 @@ import com.baiyue.lastleg.transfer.mapper.WarehouseTransferSourceOrderMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletItemMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferRelabelBindingMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferMeasurementPhotoMapper; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; import org.springframework.http.HttpStatus; @@ -64,6 +67,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.multipart.MultipartFile; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; @@ -85,6 +89,7 @@ import java.util.stream.Collectors; @Service public class WarehouseTransferService { public static final String ORDER_TYPE = "WAREHOUSE_TRANSFER"; + public static final String DRAFT = "DRAFT"; public static final String PENDING_REVIEW = "PENDING_REVIEW"; public static final String APPROVED = "APPROVED"; public static final String REJECTED = "REJECTED"; @@ -96,7 +101,11 @@ public class WarehouseTransferService { public static final String WAIT_PDA_RECEIVING = "WAIT_PDA_RECEIVING"; public static final String WAIT_CUSTOMER_CONFIRMATION = "WAIT_CUSTOMER_CONFIRMATION"; public static final String READY_FOR_DISPATCH = "READY_FOR_DISPATCH"; + /** PDA/customer measurement is complete, but an FBA destination is still required before dispatch. */ + public static final String WAIT_FBA_DESTINATION = "WAIT_FBA_DESTINATION"; public static final String WAIT_RELABEL = "WAIT_RELABEL"; + public static final String INBOUND_MEASURING = "INBOUND_MEASURING"; + public static final String INBOUND_COMPLETED = "INBOUND_COMPLETED"; @Resource private WarehouseTransferOrderMapper transfers; @Resource private WarehouseTransferItemMapper transferItems; @@ -105,6 +114,8 @@ public class WarehouseTransferService { @Resource private WarehouseTransferPalletMapper transferPallets; @Resource private WarehouseTransferPalletItemMapper transferPalletItems; @Resource private WarehouseTransferRelabelBindingMapper relabelBindings; + @Resource private WarehouseTransferMeasurementPhotoMapper measurementPhotos; + @Resource private TransferMeasurementPhotoFileGateway measurementPhotoFiles; @Resource private WarehouseTransferInventoryAllocationMapper inventoryAllocations; @Resource private WarehouseTransferReceiptMapper transferReceipts; @Resource private InboundSnapshotMapper inboundSnapshots; @@ -133,6 +144,9 @@ public class WarehouseTransferService { throw badRequest("Cargo item package total must equal transfer package count"); } validateRelabelRequest(request); + validateDestination(request.destinationType(), request.destinationWarehouseCode(), request.fbaWarehouseCode(), + request.privateDestinationAddress(), request.privateDestinationPostalCode(), + request.privateDestinationContactName(), request.privateDestinationContactPhone()); String key = request.idempotencyKey().trim(); String requestHash = hash(request); WarehouseTransferOrder replay = transfers.findKey(customerId, key); @@ -179,14 +193,17 @@ public class WarehouseTransferService { transfer.setInboundOrderId(inboundOrderId); transfer.setInboundCode(inboundCode); transfer.setLastlegOrderId(order.getId()); transfer.setLastlegOrderNo(order.getOrderNo()); transfer.setOperatingWarehouseId(request.operatingWarehouseId().trim()); - transfer.setFbaWarehouseCode(trim(request.fbaWarehouseCode())); + transfer.setFbaWarehouseCode(destinationCode(request)); + applyDestination(transfer, request); transfer.setDeliveryAddress(request.deliveryAddress().trim()); transfer.setPackageCount(request.packageCount()); + transfer.setPickupContactName(trim(request.pickupContactName())); + transfer.setPickupContactPhone(trim(request.pickupContactPhone())); transfer.setDeliveryMethod(request.deliveryMethod().trim()); transfer.setEstimatedArrivalAt(request.estimatedArrivalAt()); transfer.setEstimatedShipAt(request.estimatedShipAt()); transfer.setFbaShipmentNo(trim(request.fbaShipmentNo())); transfer.setTrackingNo("CUSTOMER_DELIVERY".equals(request.deliveryMethod().trim()) ? trim(request.trackingNo()) : null); transfer.setFbaBoxNos(trim(request.fbaBoxNos())); transfer.setRemark(trim(request.remark())); - transfer.setNeedRelabel(request.needRelabel()); transfer.setStatus(PENDING_REVIEW); + transfer.setNeedRelabel(request.needRelabel()); transfer.setStatus(DRAFT); transfer.setLabelDeadline(request.labelDeadline()); transfer.setRelabelContactName(trim(request.relabelContactName())); transfer.setRelabelContactPhone(trim(request.relabelContactPhone())); @@ -224,23 +241,171 @@ public class WarehouseTransferService { } insertOrderDetail(customerId, order.getId(), transferNo, index, source, request, now); } - insertOrderHistory(customerId, order.getId(), null, PENDING_REVIEW, transferNo, - "Warehouse transfer submitted for review", now); + insertOrderHistory(customerId, order.getId(), null, DRAFT, transferNo, + "Warehouse transfer draft created", now); + return detail(customerId, transferNo); + } + + /** Platform-created transfers keep the existing create-and-submit behavior. */ + @Transactional + public WarehouseTransferResponses.Detail createAndSubmit(String customerId, String operatorId, + WarehouseTransferRequests.Create request) { + WarehouseTransferResponses.Detail draft = create(customerId, operatorId, request); + return submit(customerId, draft.transferNo(), operatorId); + } + + @Transactional + public WarehouseTransferResponses.Detail updateDraft(String customerId, String transferNo, String operatorId, + WarehouseTransferRequests.Create request) { + WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + if (!DRAFT.equals(transfer.getStatus())) { + throw conflict("Only a draft warehouse transfer order can be edited"); + } + int itemPackages = request.items().stream().mapToInt(WarehouseTransferRequests.Item::packageCount).sum(); + if (itemPackages != request.packageCount()) { + throw badRequest("Cargo item package total must equal transfer package count"); + } + validateRelabelRequest(request); + validateDestination(request.destinationType(), request.destinationWarehouseCode(), request.fbaWarehouseCode(), + request.privateDestinationAddress(), request.privateDestinationPostalCode(), + request.privateDestinationContactName(), request.privateDestinationContactPhone()); + + LastlegOrder order = requireTailOrder(customerId, transfer); + LocalDateTime now = LocalDateTime.now(); + String requestHash = hash(request); + transfer.setOperatingWarehouseId(request.operatingWarehouseId().trim()); + transfer.setFbaWarehouseCode(destinationCode(request)); + applyDestination(transfer, request); + transfer.setDeliveryAddress(request.deliveryAddress().trim()); + transfer.setPackageCount(request.packageCount()); + transfer.setPickupContactName(trim(request.pickupContactName())); + transfer.setPickupContactPhone(trim(request.pickupContactPhone())); + transfer.setDeliveryMethod(request.deliveryMethod().trim()); + transfer.setEstimatedArrivalAt(request.estimatedArrivalAt()); + transfer.setEstimatedShipAt(request.estimatedShipAt()); + transfer.setFbaShipmentNo(trim(request.fbaShipmentNo())); + transfer.setTrackingNo("CUSTOMER_DELIVERY".equals(request.deliveryMethod().trim()) + ? trim(request.trackingNo()) : null); + transfer.setFbaBoxNos(trim(request.fbaBoxNos())); + transfer.setRemark(trim(request.remark())); + transfer.setNeedRelabel(request.needRelabel()); + transfer.setLabelDeadline(request.labelDeadline()); + transfer.setRelabelContactName(trim(request.relabelContactName())); + transfer.setRelabelContactPhone(trim(request.relabelContactPhone())); + transfer.setRelabelChargeAmount(request.relabelChargeAmount()); + transfer.setRelabelDeliveryMethod(trim(request.relabelDeliveryMethod())); + transfer.setRequestHash(requestHash); + transfer.setUpdatedAt(now); + transfers.updateById(transfer); + + order.setOverseasWarehouseId(request.operatingWarehouseId().trim()); + order.setFbaWarehouseCode(destinationCode(request)); + order.setPackageCount(request.packageCount()); + order.setVolumeCbm(request.volumeCbm()); + order.setEstimatedWarehouseAt(request.estimatedArrivalAt()); + order.setRelabelStatus(request.needRelabel() ? "PENDING_BINDING" : "NOT_REQUIRED"); + order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + DRAFT + ";RELABEL:" + order.getRelabelStatus()); + order.setCustomerRemark(trim(request.remark())); + order.setSourcePayloadHash("sha256:" + requestHash); + order.setUpdatedAt(now); + orders.updateById(order); + + if (transferNo.equals(transfer.getInboundCode())) { + LastlegInboundOrder inbound = inboundOrders.selectById(transfer.getInboundOrderId()); + if (inbound == null || !customerId.equals(inbound.getCustomerId())) { + throw conflict("Linked inbound order is missing"); + } + inbound.setDestinationWarehouseId(request.operatingWarehouseId().trim()); + inbound.setPackageCount(request.packageCount()); + inbound.setVolumeCbm(request.volumeCbm()); + inbound.setPayloadHash("sha256:" + requestHash); + inbound.setUpdatedAt(now); + inboundOrders.updateById(inbound); + } + + transferCartons.delete(new LambdaQueryWrapper() + .eq(WarehouseTransferCarton::getCustomerId, customerId) + .eq(WarehouseTransferCarton::getTransferOrderId, transfer.getId())); + transferItems.delete(new LambdaQueryWrapper() + .eq(WarehouseTransferItem::getCustomerId, customerId) + .eq(WarehouseTransferItem::getTransferOrderId, transfer.getId())); + relabelBindings.delete(new LambdaQueryWrapper() + .eq(WarehouseTransferRelabelBinding::getCustomerId, customerId) + .eq(WarehouseTransferRelabelBinding::getTransferOrderId, transfer.getId())); + orderDetails.delete(new LambdaQueryWrapper() + .eq(LastlegOrderDetail::getCustomerId, customerId) + .eq(LastlegOrderDetail::getOrderId, order.getId())); + + if (request.needRelabel()) insertRelabelBindings(customerId, transfer.getId(), request.relabelItems(), now); + int cartonIndex = 0; + for (int index = 0; index < request.items().size(); index++) { + WarehouseTransferRequests.Item source = request.items().get(index); + long itemId = insertTransferItem(customerId, transfer.getId(), source, now); + List customerMarks = splitMarks(source.shippingMark()); + for (int itemCarton = 0; itemCarton < source.packageCount(); itemCarton++) { + cartonIndex++; + insertTransferCarton(customerId, transfer.getId(), itemId, transferNo, cartonIndex, + request.packageCount(), itemCarton < customerMarks.size() ? customerMarks.get(itemCarton) : null, now); + } + insertOrderDetail(customerId, order.getId(), transferNo, index, source, request, now); + } + return detail(customerId, transferNo); + } + + @Transactional + public WarehouseTransferResponses.Detail submit(String customerId, String transferNo, String operatorId) { + WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + if (PENDING_REVIEW.equals(transfer.getStatus())) return detail(customerId, transferNo); + if (!DRAFT.equals(transfer.getStatus())) throw conflict("Only a draft warehouse transfer order can be submitted"); + + LastlegOrder order = requireTailOrder(customerId, transfer); + LocalDateTime now = LocalDateTime.now(); + transfer.setStatus(PENDING_REVIEW); + transfer.setUpdatedAt(now); + transfers.updateById(transfer); + + order.setCurrentStatus(PENDING_REVIEW); + order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + PENDING_REVIEW + ";RELABEL:" + order.getRelabelStatus()); + order.setUpdatedAt(now); + orders.updateById(order); + + LastlegInboundOrder inbound = transfer.getTransferNo().equals(transfer.getInboundCode()) + ? inboundOrders.selectById(transfer.getInboundOrderId()) : null; + if (inbound != null && customerId.equals(inbound.getCustomerId()) && DRAFT.equals(inbound.getStatus())) { + inbound.setStatus(PENDING_REVIEW); + inbound.setUpdatedAt(now); + inboundOrders.updateById(inbound); + } + insertOrderHistory(customerId, order.getId(), DRAFT, PENDING_REVIEW, transferNo, + "Warehouse transfer submitted for review by " + operatorId, now); return detail(customerId, transferNo); } @Transactional(readOnly = true) public PageResult page(String customerId, WarehouseTransferRequests.Query query) { + return page(customerId, query, true); + } + + @Transactional(readOnly = true) + public PageResult page(String customerId, WarehouseTransferRequests.Query query, + boolean includeDrafts) { if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) { throw badRequest("createdFrom must not be after createdTo"); } + List groupedStatuses = customerMainStatuses(query.status()); LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(StringUtils.hasText(customerId), WarehouseTransferOrder::getCustomerId, customerId) .like(StringUtils.hasText(query.transferNo()), WarehouseTransferOrder::getTransferNo, query.transferNo()) .like(StringUtils.hasText(query.inboundCode()), WarehouseTransferOrder::getInboundCode, query.inboundCode()) .eq(StringUtils.hasText(query.operatingWarehouseId()), WarehouseTransferOrder::getOperatingWarehouseId, query.operatingWarehouseId()) .like(StringUtils.hasText(query.fbaWarehouseCode()), WarehouseTransferOrder::getFbaWarehouseCode, query.fbaWarehouseCode()) - .eq(StringUtils.hasText(query.status()), WarehouseTransferOrder::getStatus, query.status()) + .in(groupedStatuses != null, WarehouseTransferOrder::getStatus, + groupedStatuses == null ? List.of() : groupedStatuses) + .eq(groupedStatuses == null && StringUtils.hasText(query.status()), + WarehouseTransferOrder::getStatus, query.status()) + .ne(!includeDrafts, WarehouseTransferOrder::getStatus, DRAFT) .eq(query.needRelabel() != null, WarehouseTransferOrder::getNeedRelabel, query.needRelabel()) .ge(query.createdFrom() != null, WarehouseTransferOrder::getCreatedAt, query.createdFrom()) .le(query.createdTo() != null, WarehouseTransferOrder::getCreatedAt, query.createdTo()) @@ -249,12 +414,77 @@ public class WarehouseTransferService { List records = result.getRecords().stream().map(value -> new WarehouseTransferResponses.Summary(value.getId(), value.getTransferNo(), value.getInboundCode(), value.getLastlegOrderId(), value.getLastlegOrderNo(), value.getOperatingWarehouseId(), - value.getFbaWarehouseCode(), value.getPackageCount(), value.getDeliveryMethod(), + value.getFbaWarehouseCode(), value.getDestinationType(), value.getDestinationWarehouseCode(), + value.getDestinationWarehouseName(), value.getPackageCount(), value.getDeliveryMethod(), Boolean.TRUE.equals(value.getNeedRelabel()), value.getRelabelNo(), value.getStatus(), value.getEstimatedArrivalAt(), value.getCreatedAt())).toList(); return new PageResult<>(records, result.getCurrent(), result.getSize(), result.getTotal(), result.getPages()); } + static List customerMainStatuses(String status) { + if (!StringUtils.hasText(status)) return null; + return switch (status.trim()) { + case INBOUND_MEASURING -> List.of(APPROVED, WAIT_PDA_RECEIVING); + case INBOUND_COMPLETED -> List.of(WAIT_CUSTOMER_CONFIRMATION, WAIT_FBA_DESTINATION, + WAIT_RELABEL, READY_FOR_DISPATCH, "WAIT_OUTBOUND", ALLOCATED, OUTBOUNDED, + PARTIALLY_RECEIVED, RECEIVED, "COMPLETED", INBOUND_COMPLETED); + default -> null; + }; + } + + @Transactional + public List bindDestination(String operatorId, + WarehouseTransferRequests.BindDestination request) { + validateDestination(request.destinationType(), request.destinationWarehouseCode(), null, + request.privateDestinationAddress(), request.privateDestinationPostalCode(), request.privateDestinationContactName(), + request.privateDestinationContactPhone()); + String requestHash = hash(request); + Set selected = new HashSet<>(); + List results = new ArrayList<>(); + LocalDateTime now = LocalDateTime.now(); + for (WarehouseTransferRequests.DestinationSelection selection : request.transfers()) { + String customerId = selection.customerId().trim(); + String transferNo = selection.transferNo().trim(); + if (!selected.add(customerId + "\n" + transferNo)) throw badRequest("Warehouse transfer selections must be unique"); + WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo); + if (transfer == null) throw notFound("Warehouse transfer order not found: " + transferNo); + if (READY_FOR_DISPATCH.equals(transfer.getStatus()) + && request.idempotencyKey().equals(transfer.getDestinationBindingIdempotencyKey())) { + if (!requestHash.equals(transfer.getDestinationBindingRequestHash())) { + throw conflict("Destination binding idempotency key was reused with different data"); + } + results.add(new WarehouseTransferResponses.DestinationResult(customerId, transferNo)); + continue; + } + if (!WAIT_FBA_DESTINATION.equals(transfer.getStatus()) || hasValidDestination(transfer)) { + throw conflict("Only an inbound-completed transfer without a destination can be bound: " + transferNo); + } + applyDestination(transfer, request); + transfer.setStatus(READY_FOR_DISPATCH); + transfer.setDestinationBindingIdempotencyKey(request.idempotencyKey().trim()); + transfer.setDestinationBindingRequestHash(requestHash); + transfer.setUpdatedAt(now); + transfers.updateById(transfer); + + LastlegOrder order = requireTailOrder(customerId, transfer); + order.setFbaWarehouseCode("FBA".equals(transfer.getDestinationType()) ? transfer.getDestinationWarehouseCode() : null); + order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + READY_FOR_DISPATCH + ";RELABEL:" + + Objects.toString(order.getRelabelStatus(), "NOT_REQUIRED")); + order.setUpdatedAt(now); + orders.updateById(order); + for (LastlegOrderDetail detail : orderDetails.selectList(new LambdaQueryWrapper() + .eq(LastlegOrderDetail::getCustomerId, customerId).eq(LastlegOrderDetail::getOrderId, order.getId()))) { + detail.setDestinationWarehouseCode(transfer.getDestinationWarehouseCode()); + orderDetails.updateById(detail); + } + syncInboundPlanDestination(customerId, transfer); + insertOrderHistory(customerId, order.getId(), order.getCurrentStatus(), order.getCurrentStatus(), transferNo, + "Warehouse transfer destination bound by " + trim(operatorId), now); + results.add(new WarehouseTransferResponses.DestinationResult(customerId, transferNo)); + } + return results.stream().map(result -> detail(result.customerId(), result.transferNo())).toList(); + } + @Transactional(readOnly = true) public WarehouseTransferResponses.Detail detail(String customerId, String transferNo) { WarehouseTransferOrder transfer = transfers.findNo(customerId, transferNo); @@ -283,11 +513,14 @@ public class WarehouseTransferService { value.getWarehousePalletId(), value.getPalletNo(), value.getWarehouseLocationId(), value.getWarehouseLocationCode(), value.getCartonCount(), value.getLengthCm(), value.getWidthCm(), value.getHeightCm(), value.getGrossWeightKg(), value.getVolumeCbm(), value.getStatus(), + trim(transfer.getFbaWarehouseCode()), value.getWarehousePalletId() == null ? null + : "/api/pda/v1/last-leg/warehouse-transfers/" + transfer.getTransferNo() + + "/pallets/" + value.getWarehousePalletId() + "/label", palletItemsByPallet.getOrDefault(value.getId(), List.of()).stream().map(row -> { WarehouseTransferResponses.Item item = itemById.get(row.getTransferItemId()); return new WarehouseTransferResponses.PalletItem(row.getTransferItemId(), item == null ? null : item.skuCode(), row.getPackageCount()); - }).toList(), value.getCompletedAt())).toList(); + }).toList(), value.getBoundAt(), value.getCompletedAt())).toList(); List bindings = relabelBindings == null ? List.of() : relabelBindings.listOrder(customerId, transfer.getId()) .stream().map(value -> new WarehouseTransferResponses.RelabelBinding(value.getOperationType(), value.getCustomerCartonMark(), value.getOriginalSku(), value.getNewSku(), value.getQuantity(), @@ -307,6 +540,10 @@ public class WarehouseTransferService { value.getInboundCode(), value.getPalletId(), value.getPalletNo(), value.getPackageCount(), value.getStatus(), receipt == null ? null : receipt.getDestinationInventoryPositionId()); }).toList(); + List photos = measurementPhotos.listSubmitted(customerId, transfer.getId()) + .stream().map(value -> new WarehouseTransferResponses.MeasurementPhoto(value.getFileId(), value.getFileName(), + value.getContentType(), value.getSizeBytes() == null ? 0 : value.getSizeBytes(), value.getUploadedAt())) + .toList(); String relabelStatus = null; if (transfer.getRelabelNo() != null) { RelabelApplication relabel = relabelApplications.findNo(customerId, transfer.getRelabelNo()); @@ -321,10 +558,18 @@ public class WarehouseTransferService { Boolean.TRUE.equals(transfer.getNeedRelabel()), transfer.getRelabelApplicationId(), transfer.getRelabelNo(), relabelStatus, transfer.getOperatingWarehouseId(), transfer.getFbaWarehouseCode(), transfer.getDeliveryAddress(), - transfer.getPackageCount(), transfer.getDeliveryMethod(), transfer.getEstimatedArrivalAt(), + transfer.getDestinationType(), transfer.getDestinationWarehouseId(), transfer.getDestinationWarehouseCode(), + transfer.getDestinationWarehouseName(), transfer.getPrivateDestinationAddress(), + transfer.getPrivateDestinationPostalCode(), + transfer.getPrivateDestinationContactName(), transfer.getPrivateDestinationContactPhone(), + transfer.getPickupContactName(), transfer.getPickupContactPhone(), + transfer.getPackageCount(), tailOrder == null ? null : tailOrder.getVolumeCbm(), + transfer.getDeliveryMethod(), transfer.getEstimatedArrivalAt(), transfer.getEstimatedShipAt(), transfer.getFbaShipmentNo(), transfer.getTrackingNo(), transfer.getFbaBoxNos(), - transfer.getRemark(), transfer.getReviewDecision(), transfer.getReviewRemark(), transfer.getReviewedBy(), - transfer.getReviewedAt(), sources, items, cartons, pallets, bindings, allocations, + transfer.getRemark(), transfer.getLabelDeadline(), transfer.getRelabelContactName(), + transfer.getRelabelContactPhone(), transfer.getRelabelDeliveryMethod(), + transfer.getReviewDecision(), transfer.getReviewRemark(), transfer.getReviewedBy(), + transfer.getReviewedAt(), sources, items, cartons, pallets, bindings, allocations, photos, transfer.getCustomerConfirmedAt(), transfer.getOutboundAt(), transfer.getCreatedAt()); } @@ -332,6 +577,7 @@ public class WarehouseTransferService { public WarehouseTransferResponses.Label cartonLabel(String customerId, String transferNo) { WarehouseTransferOrder transfer = transfers.findNo(customerId, transferNo); if (transfer == null) throw notFound("Warehouse transfer order not found"); + if (DRAFT.equals(transfer.getStatus())) throw conflict("Submit the warehouse transfer order before printing carton labels"); List cartons = transferCartons.listOrder(customerId, transfer.getId()).stream() .map(value -> new WarehouseTransferResponses.Carton(value.getCartonMark(), value.getCustomerCartonMark(), value.getCartonIndex(), value.getTotalCartons(), value.getStatus())).toList(); StringBuilder labels = new StringBuilder(); @@ -366,8 +612,14 @@ public class WarehouseTransferService { if (transfer == null) throw notFound("Warehouse transfer order not found"); if (!PENDING_REVIEW.equals(transfer.getStatus())) { if (next.equals(transfer.getStatus()) || APPROVED.equals(next) - && Set.of(WAIT_PDA_RECEIVING, WAIT_CUSTOMER_CONFIRMATION, READY_FOR_DISPATCH, WAIT_RELABEL) - .contains(transfer.getStatus())) return detail(customerId, transferNo); + && Set.of(WAIT_PDA_RECEIVING, WAIT_CUSTOMER_CONFIRMATION, READY_FOR_DISPATCH, + WAIT_FBA_DESTINATION, WAIT_RELABEL) + .contains(transfer.getStatus())) { + if (APPROVED.equals(next) && transfer.getTransferNo().equals(transfer.getInboundCode())) { + activateTransferInbound(customerId, transfer, LocalDateTime.now()); + } + return detail(customerId, transferNo); + } throw conflict("Warehouse transfer order was already reviewed"); } LastlegOrder order = orders.selectById(transfer.getLastlegOrderId()); @@ -383,11 +635,8 @@ public class WarehouseTransferService { transfer.setReviewedAt(now); transfer.setUpdatedAt(now); transfers.updateById(transfer); - String orderStatus = APPROVED.equals(next) - ? (incomingTransit - ? ("WAREHOUSE_PICKUP".equals(transfer.getDeliveryMethod()) ? "WAIT_PICKUP" : "WAIT_DELIVERY") - : PENDING_ASSIGNMENT) - : "CANCELLED"; + if (APPROVED.equals(next) && incomingTransit) activateTransferInbound(customerId, transfer, now); + String orderStatus = APPROVED.equals(next) ? INBOUND_MEASURING : "CANCELLED"; order.setCurrentStatus(orderStatus); order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + next + ";RELABEL:" + (StringUtils.hasText(order.getRelabelStatus()) ? order.getRelabelStatus() : "NOT_REQUIRED")); @@ -400,6 +649,112 @@ public class WarehouseTransferService { return detail(customerId, transferNo); } + @Transactional + public WarehouseTransferResponses.Detail savePalletDraft(long warehouseId, String transferNo, + WarehouseTransferRequests.PalletDraft request) { + WarehouseTransferOrder transfer = requirePdaMeasuringTransfer(warehouseId, transferNo); + String customerId = transfer.getCustomerId(); + validatePalletMeasurement(request.lengthCm(), request.widthCm(), request.heightCm(), request.volumeCbm()); + String requestHash = hash(request); + List existing = transferPallets.listOrder(customerId, transfer.getId()); + WarehouseTransferPallet pallet; + if (request.palletId() == null) { + pallet = existing.stream().filter(value -> request.idempotencyKey().equals(value.getDraftIdempotencyKey())) + .findFirst().orElse(null); + if (pallet != null) { + if (!requestHash.equals(pallet.getDraftRequestHash())) throw conflict("Pallet draft idempotency key was reused"); + return detail(customerId, transferNo); + } + if (existing.size() >= 100) throw badRequest("A transfer supports at most 100 pallets"); + pallet = new WarehouseTransferPallet(); + pallet.setId(IdWorker.getId()); pallet.setTransferOrderId(transfer.getId()); pallet.setCustomerId(customerId); + pallet.setSequenceNo(existing.stream().mapToInt(WarehouseTransferPallet::getSequenceNo).max().orElse(0) + 1); + pallet.setStatus("DRAFT"); pallet.setCreatedAt(LocalDateTime.now()); + } else { + pallet = transferPallets.lockOne(customerId, transfer.getId(), request.palletId()); + if (pallet == null) throw notFound("Warehouse transfer pallet draft not found"); + if (!"DRAFT".equals(pallet.getStatus())) throw conflict("Generated pallet measurements are locked"); + } + pallet.setCartonCount(request.cartonCount()); pallet.setLengthCm(request.lengthCm()); + pallet.setWidthCm(request.widthCm()); pallet.setHeightCm(request.heightCm()); + pallet.setVolumeCbm(request.volumeCbm()); pallet.setGrossWeightKg(request.grossWeightKg()); + pallet.setDraftIdempotencyKey(request.idempotencyKey()); pallet.setDraftRequestHash(requestHash); + pallet.setUpdatedAt(LocalDateTime.now()); transferPallets.insertOrUpdate(pallet); + return detail(customerId, transferNo); + } + + @Transactional + public WarehouseTransferResponses.Detail deletePalletDraft(long warehouseId, String transferNo, long palletId) { + WarehouseTransferOrder transfer = requirePdaMeasuringTransfer(warehouseId, transferNo); + WarehouseTransferPallet pallet = transferPallets.lockOne(transfer.getCustomerId(), transfer.getId(), palletId); + if (pallet == null) return detail(transfer.getCustomerId(), transferNo); + if (!"DRAFT".equals(pallet.getStatus())) throw conflict("Generated pallet cannot be removed"); + transferPallets.deleteById(palletId); + return detail(transfer.getCustomerId(), transferNo); + } + + @Transactional + public WarehouseTransferResponses.Detail generatePallet(long warehouseId, String transferNo, long palletId, + WarehouseTransferRequests.PalletAction request) { + WarehouseTransferOrder transfer = requirePdaMeasuringTransfer(warehouseId, transferNo); + WarehouseTransferPallet pallet = transferPallets.lockOne(transfer.getCustomerId(), transfer.getId(), palletId); + if (pallet == null) throw notFound("Warehouse transfer pallet draft not found"); + if (Set.of("GENERATED", "BOUND").contains(pallet.getStatus()) + && request.idempotencyKey().equals(pallet.getGenerateIdempotencyKey())) return detail(transfer.getCustomerId(), transferNo); + if (!"DRAFT".equals(pallet.getStatus())) throw conflict("Pallet was already generated"); + String batchKey = "WAREHOUSE_TRANSFER:" + transferNo + ":PALLET:" + pallet.getId(); + var item = new WarehouseResourceGateway.PalletBatchItemRequest(Long.parseLong(transfer.getCustomerId()), + transferNo + ":" + pallet.getId(), transferNo, pallet.getSequenceNo(), "STANDARD", + trim(transfer.getFbaWarehouseCode()), null); + warehouseResources.ensurePalletBatch(new WarehouseResourceGateway.PalletBatchEnsure(warehouseId, batchKey, List.of(item))); + var confirmed = warehouseResources.confirmPalletBatch(new WarehouseResourceGateway.PalletBatchCommand( + warehouseId, batchKey, request.idempotencyKey() + ":CONFIRM")); + if (confirmed == null || confirmed.items() == null || confirmed.items().size() != 1) { + throw conflict("Warehouse pallet generation did not return exactly one pallet"); + } + var resource = confirmed.items().get(0); + var location = warehouseResources.resolveLocation(warehouseId, resource.locationId()); + pallet.setWarehousePalletId(resource.palletId()); pallet.setPalletNo(resource.palletCode()); + pallet.setWarehouseLocationId(resource.locationId()); pallet.setWarehouseLocationCode(location.locationCode()); + pallet.setWarehouseResourceLockId(resource.resourceLockId()); pallet.setGenerateIdempotencyKey(request.idempotencyKey()); + pallet.setStatus("GENERATED"); pallet.setUpdatedAt(LocalDateTime.now()); transferPallets.updateById(pallet); + return detail(transfer.getCustomerId(), transferNo); + } + + @Transactional + public WarehouseTransferResponses.Detail bindPallet(long warehouseId, String transferNo, long palletId, + WarehouseTransferRequests.PalletBind request) { + WarehouseTransferOrder transfer = requirePdaMeasuringTransfer(warehouseId, transferNo); + WarehouseTransferPallet pallet = transferPallets.lockOne(transfer.getCustomerId(), transfer.getId(), palletId); + if (pallet == null) throw notFound("Warehouse transfer pallet not found"); + if ("BOUND".equals(pallet.getStatus()) && request.idempotencyKey().equals(pallet.getBindIdempotencyKey()) + && pallet.getPalletNo().equals(request.palletCode().trim())) return detail(transfer.getCustomerId(), transferNo); + if (!"GENERATED".equals(pallet.getStatus())) throw conflict("Only a generated pallet can be scan-bound"); + if (!pallet.getPalletNo().equals(request.palletCode().trim())) throw conflict("Scanned pallet code does not match this generated pallet"); + var physical = warehouseResources.resolvePallet(request.palletCode().trim()); + if (physical.palletId() != pallet.getWarehousePalletId() || physical.warehouseId() != warehouseId) { + throw conflict("Scanned pallet does not belong to this transfer and warehouse"); + } + pallet.setStatus("BOUND"); pallet.setBindIdempotencyKey(request.idempotencyKey()); + pallet.setBoundAt(LocalDateTime.now()); pallet.setUpdatedAt(LocalDateTime.now()); transferPallets.updateById(pallet); + return detail(transfer.getCustomerId(), transferNo); + } + + private WarehouseTransferOrder requirePdaMeasuringTransfer(long warehouseId, String transferNo) { + WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(warehouseId)); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + if (!WAIT_PDA_RECEIVING.equals(transfer.getStatus())) throw conflict("Warehouse transfer is not awaiting PDA receiving"); + return transfer; + } + + private void validatePalletMeasurement(BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal volume) { + BigDecimal calculated = length.multiply(width).multiply(height) + .divide(new BigDecimal("1000000"), 6, RoundingMode.HALF_UP); + if (calculated.subtract(volume).abs().compareTo(new BigDecimal("0.000001")) > 0) { + throw badRequest("Pallet volume must match measured length, width and height"); + } + } + @Transactional public WarehouseTransferResponses.Detail palletize(String operatorId, long overseasWarehouseId, String transferNo, @@ -424,77 +779,53 @@ public class WarehouseTransferService { List items = transferItems.selectList(new LambdaQueryWrapper() .eq(WarehouseTransferItem::getCustomerId, customerId) .eq(WarehouseTransferItem::getTransferOrderId, transfer.getId()).orderByAsc(WarehouseTransferItem::getId)); - Map itemById = items.stream() - .collect(Collectors.toMap(WarehouseTransferItem::getId, Function.identity())); - Map totals = new java.util.HashMap<>(); - int palletCartons = 0; - for (WarehouseTransferRequests.Pallet pallet : request.pallets()) { - int itemTotal = pallet.items().stream().mapToInt(WarehouseTransferRequests.PalletItem::packageCount).sum(); - if (itemTotal != pallet.cartonCount()) throw badRequest("Each pallet carton count must equal its SKU carton total"); - palletCartons = Math.addExact(palletCartons, pallet.cartonCount()); - Set uniqueItems = new HashSet<>(); - for (WarehouseTransferRequests.PalletItem item : pallet.items()) { - if (!uniqueItems.add(item.transferItemId()) || !itemById.containsKey(item.transferItemId())) { - throw badRequest("Pallet SKU rows must be unique and belong to this transfer"); - } - totals.merge(item.transferItemId(), item.packageCount(), Math::addExact); - } + if (items.isEmpty() || items.stream().mapToInt(WarehouseTransferItem::getPackageCount).sum() != transfer.getPackageCount()) { + throw conflict("Warehouse transfer declared cargo is incomplete"); } - if (palletCartons != transfer.getPackageCount()) throw badRequest("Pallet carton total must equal transfer package count"); - for (WarehouseTransferItem item : items) { - if (totals.getOrDefault(item.getId(), 0) != item.getPackageCount()) { - throw badRequest("Palletized carton total does not match declared SKU " + item.getSkuCode()); - } + if (request.photoFileIds() == null || request.photoFileIds().isEmpty() || request.photoFileIds().size() > 20) { + throw conflict("Measurement and palletization require 1 to 20 on-site photos"); } - - String batchKey = "WAREHOUSE_TRANSFER:" + transferNo + ":PALLETIZE"; - List batchItems = new ArrayList<>(); - for (int index = 0; index < request.pallets().size(); index++) { - WarehouseTransferRequests.Pallet pallet = request.pallets().get(index); - batchItems.add(new WarehouseResourceGateway.PalletBatchItemRequest(Long.parseLong(customerId), - transferNo + ":" + (index + 1), transferNo, index + 1, "STANDARD", - trim(transfer.getFbaWarehouseCode()), trim(pallet.palletCode()))); + Set photoIds = new java.util.LinkedHashSet<>(request.photoFileIds()); + if (photoIds.size() != request.photoFileIds().size()) throw badRequest("Measurement photo IDs must be unique"); + for (Long fileId : photoIds) { + WarehouseTransferMeasurementPhoto photo = measurementPhotos.findFile(customerId, transfer.getId(), fileId); + if (photo == null) throw badRequest("Measurement photo does not belong to this transfer: " + fileId); + measurementPhotoFiles.validate(customerId, transfer.getId(), fileId); } - WarehouseResourceGateway.PalletBatchResponse ensured = warehouseResources.ensurePalletBatch( - new WarehouseResourceGateway.PalletBatchEnsure(overseasWarehouseId, batchKey, batchItems)); - WarehouseResourceGateway.PalletBatchResponse confirmed = warehouseResources.confirmPalletBatch( - new WarehouseResourceGateway.PalletBatchCommand(overseasWarehouseId, batchKey, - request.idempotencyKey() + ":CONFIRM")); - if (confirmed == null || confirmed.items() == null || confirmed.items().size() != request.pallets().size()) { - throw conflict("Warehouse pallet batch did not return every pallet"); + List persistedPallets = transferPallets.listOrder(customerId, transfer.getId()); + if (persistedPallets.isEmpty() || persistedPallets.stream().anyMatch(value -> !"BOUND".equals(value.getStatus()))) { + throw conflict("Every pallet must be generated and scan-bound before completing palletization"); } + int palletCartons = persistedPallets.stream().mapToInt(WarehouseTransferPallet::getCartonCount).sum(); + List derivedAllocations = derivePersistedPalletAllocations(items, persistedPallets); LocalDateTime now = LocalDateTime.now(); - for (int index = 0; index < request.pallets().size(); index++) { - int sequenceNo = index + 1; - WarehouseTransferRequests.Pallet source = request.pallets().get(index); - WarehouseResourceGateway.PalletBatchItem resource = confirmed.items().stream() - .filter(value -> value.sequenceNo() == sequenceNo).findFirst() - .orElseThrow(() -> conflict("Warehouse pallet sequence is incomplete")); - WarehouseResourceGateway.WarehouseLocationReference location = warehouseResources.resolveLocation( - overseasWarehouseId, resource.locationId()); - WarehouseTransferPallet pallet = new WarehouseTransferPallet(); - pallet.setId(IdWorker.getId()); pallet.setTransferOrderId(transfer.getId()); pallet.setCustomerId(customerId); - pallet.setSequenceNo(sequenceNo); pallet.setWarehousePalletId(resource.palletId()); - pallet.setPalletNo(resource.palletCode()); pallet.setWarehouseLocationId(resource.locationId()); - pallet.setWarehouseLocationCode(location.locationCode()); pallet.setWarehouseResourceLockId(resource.resourceLockId()); - // 转运测量以打托后的托盘为单位,体积由 PDA 直接录入托盘总体积,不再按单箱尺寸换算。 - // 旧表字段仍为 NOT NULL;按托模式不采集长宽高,以 0 兼容存储且不得参与总体积计算。 - pallet.setLengthCm(BigDecimal.ZERO); pallet.setWidthCm(BigDecimal.ZERO); pallet.setHeightCm(BigDecimal.ZERO); - pallet.setCartonCount(source.cartonCount()); pallet.setGrossWeightKg(source.grossWeightKg()); - pallet.setVolumeCbm(source.volumeCbm().setScale(6, RoundingMode.HALF_UP)); - pallet.setStatus("COMPLETED"); pallet.setCompletedAt(now); pallet.setCreatedAt(now); pallet.setUpdatedAt(now); - transferPallets.insert(pallet); - for (WarehouseTransferRequests.PalletItem sourceItem : source.items()) { + List completedPalletItems = new ArrayList<>(); + for (int index = 0; index < persistedPallets.size(); index++) { + WarehouseTransferPallet pallet = persistedPallets.get(index); + for (DerivedPalletAllocation allocation : derivedAllocations) { + if (allocation.palletIndex() != index) continue; WarehouseTransferPalletItem row = new WarehouseTransferPalletItem(); row.setId(IdWorker.getId()); row.setTransferOrderId(transfer.getId()); row.setTransferPalletId(pallet.getId()); - row.setTransferItemId(sourceItem.transferItemId()); row.setCustomerId(customerId); - row.setPackageCount(sourceItem.packageCount()); row.setCreatedAt(now); transferPalletItems.insert(row); + row.setTransferItemId(allocation.transferItemId()); row.setCustomerId(customerId); + row.setPackageCount(allocation.packageCount()); row.setCreatedAt(now); transferPalletItems.insert(row); + completedPalletItems.add(row); } + pallet.setStatus("COMPLETED"); pallet.setCompletedAt(now); pallet.setUpdatedAt(now); transferPallets.updateById(pallet); } + if (measurementPhotos.markSubmitted(customerId, transfer.getId(), photoIds, now) != photoIds.size()) { + throw conflict("Measurement photo submission is incomplete"); + } + WarehouseResourceGateway.WarehouseReference warehouse = warehouseResources.requireActiveSharedWarehouse( + Long.parseLong(transfer.getOperatingWarehouseId())); + stockInMeasuredPallets(customerId, operatorId, transfer, items, persistedPallets, + completedPalletItems, warehouse, now); + completeTransferInboundSnapshot(customerId, transfer, persistedPallets, warehouse, 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(); - order.setCurrentStatus(WAIT_CUSTOMER_CONFIRMATION); order.setPalletCount(request.pallets().size()); + order.setCurrentStatus(INBOUND_COMPLETED); order.setPalletCount(persistedPallets.size()); + order.setReceivedPackageCount(palletCartons); order.setVolumeCbm(transferPallets.listOrder(customerId, transfer.getId()).stream() .map(WarehouseTransferPallet::getVolumeCbm).reduce(BigDecimal.ZERO, BigDecimal::add)); order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + WAIT_CUSTOMER_CONFIRMATION); order.setUpdatedAt(now); orders.updateById(order); @@ -503,6 +834,18 @@ public class WarehouseTransferService { return detail(customerId, transferNo); } + @Transactional(readOnly = true) + public WarehouseResourceGateway.PrintableFile printPdaPalletLabel(long warehouseId, String transferNo, + long warehousePalletId, + String requestId) { + WarehouseTransferOrder transfer = transfers.findNoByWarehouse(transferNo, String.valueOf(warehouseId)); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + boolean owned = transferPallets.listOrder(transfer.getCustomerId(), transfer.getId()).stream() + .anyMatch(value -> java.util.Objects.equals(value.getWarehousePalletId(), warehousePalletId)); + if (!owned) throw notFound("Warehouse transfer pallet not found"); + return warehouseResources.printPalletLabel(warehouseId, warehousePalletId, transferNo, requestId); + } + @Transactional public WarehouseTransferResponses.Detail confirmMeasurement(String customerId, String transferNo, String operatorId, @@ -510,7 +853,7 @@ public class WarehouseTransferService { WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo); if (transfer == null) throw notFound("Warehouse transfer order not found"); String requestHash = hash(request); - if (Set.of(READY_FOR_DISPATCH, WAIT_RELABEL, OUTBOUNDED).contains(transfer.getStatus()) + if (Set.of(READY_FOR_DISPATCH, WAIT_FBA_DESTINATION, WAIT_RELABEL, OUTBOUNDED).contains(transfer.getStatus()) && request.idempotencyKey().equals(transfer.getConfirmationIdempotencyKey())) { if (!requestHash.equals(transfer.getConfirmationRequestHash())) { throw conflict("Confirmation idempotency key was reused with different data"); @@ -527,64 +870,130 @@ public class WarehouseTransferService { .collect(Collectors.toMap(WarehouseTransferItem::getId, Function.identity())); List pallets = transferPallets.listOrder(customerId, transfer.getId()); List palletItems = transferPalletItems.listOrder(customerId, transfer.getId()); - WarehouseResourceGateway.WarehouseReference warehouse = warehouseResources.requireActiveSharedWarehouse( - Long.parseLong(transfer.getOperatingWarehouseId())); - Map> stockSourcesBySku = new java.util.LinkedHashMap<>(); + Map> stockSourcesBySku = confirmedStockSources( + itemById, stockReceipts.listTask(customerId, transfer.getId())); LocalDateTime now = LocalDateTime.now(); - for (WarehouseTransferPallet pallet : pallets) { - for (WarehouseTransferPalletItem row : palletItems.stream() - .filter(value -> value.getTransferPalletId().equals(pallet.getId())).toList()) { - WarehouseTransferItem item = itemById.get(row.getTransferItemId()); - String operationKey = "WAREHOUSE_TRANSFER_STOCK_IN:" + transferNo + ":" + pallet.getSequenceNo() + ":" + item.getId(); - InventoryCommandResponse result = inventory.stockIn(customerId, operatorId, - new InventoryCommandRequests.StockIn(customerId, "客户-" + customerId, - transfer.getOperatingWarehouseId(), warehouse.warehouseCode(), warehouse.warehouseName(), - null, trim(transfer.getFbaWarehouseCode()), trim(transfer.getFbaWarehouseCode()), - transfer.getInboundOrderId(), transferNo, transferNo, item.getSkuCode(), item.getProductNameZh(), null, - pallet.getWarehousePalletId(), pallet.getPalletNo(), transferNo, pallet.getWarehouseLocationId(), - pallet.getWarehouseLocationCode(), row.getPackageCount(), transferNo, - "WAREHOUSE_TRANSFER_CONFIRMED:" + transferNo, operationKey, trim(request.remark()))); - stockSourcesBySku.computeIfAbsent(item.getSkuCode(), ignored -> new ArrayList<>()) - .add(new ConfirmedStockSource(result.balanceId(), result.positionId(), row.getPackageCount())); - InboundStockReceipt receipt = new InboundStockReceipt(); - receipt.setId(IdWorker.getId()); receipt.setCustomerId(customerId); - // 转运收货没有独立 PDA 入仓任务,使用本业务链的稳定主键填充通用收货快照维度。 - receipt.setPlanId(transfer.getInboundOrderId()); receipt.setTaskId(transfer.getId()); - receipt.setAllocationId(row.getId()); receipt.setLineId(item.getId()); - receipt.setOrderId(transfer.getLastlegOrderId()); - receipt.setInboundCode(transferNo); receipt.setSkuCode(item.getSkuCode()); receipt.setProductNameZh(item.getProductNameZh()); - receipt.setWarehousePalletId(pallet.getWarehousePalletId()); receipt.setPalletNo(pallet.getPalletNo()); - receipt.setWarehouseLocationId(pallet.getWarehouseLocationId()); receipt.setWarehouseLocationCode(pallet.getWarehouseLocationCode()); - receipt.setBatchNo(transferNo); receipt.setReceivedPackages(Long.valueOf(row.getPackageCount())); - receipt.setInventoryBalanceId(result.balanceId()); receipt.setInventoryPositionId(result.positionId()); - receipt.setInventoryOperationKey(operationKey); receipt.setReceivedAt(now); receipt.setCreatedAt(now); stockReceipts.insert(receipt); - } - } if (Boolean.TRUE.equals(transfer.getNeedRelabel())) { RelabelResponses.Detail relabel = createLinkedRelabel(customerId, operatorId, transfer, itemById, stockSourcesBySku); transfer.setRelabelApplicationId(relabel.application().id()); transfer.setRelabelNo(relabel.application().relabelNo()); transfer.setStatus(WAIT_RELABEL); } else { - if (!StringUtils.hasText(transfer.getFbaWarehouseCode())) { - throw conflict("A destination warehouse is required before dispatch"); + if (hasValidDestination(transfer)) { + transfer.setStatus(READY_FOR_DISPATCH); + } else { + // Measurement confirmation is allowed without an FBA destination. Such + // transfers remain visible for destination completion but cannot dispatch. + transfer.setStatus(WAIT_FBA_DESTINATION); } - createDispatchPlan(customerId, transfer, items, pallets, palletItems, now); - transfer.setStatus(READY_FOR_DISPATCH); } transfer.setConfirmationIdempotencyKey(request.idempotencyKey().trim()); transfer.setConfirmationRequestHash(requestHash); transfer.setCustomerConfirmedAt(now); transfer.setUpdatedAt(now); transfers.updateById(transfer); LastlegOrder order = requireTailOrder(customerId, transfer); String before = order.getCurrentStatus(); - order.setCurrentStatus(Boolean.TRUE.equals(transfer.getNeedRelabel()) ? WAIT_RELABEL : READY_FOR_DISPATCH); + order.setCurrentStatus(INBOUND_COMPLETED); order.setRelabelStatus(Boolean.TRUE.equals(transfer.getNeedRelabel()) ? "PENDING_PROCESSING" : "NOT_REQUIRED"); order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + transfer.getStatus() + ";RELABEL:" + order.getRelabelStatus()); order.setUpdatedAt(now); orders.updateById(order); - insertOrderHistory(customerId, order.getId(), before, order.getCurrentStatus(), transferNo, - "Customer confirmed PDA measurements; actual inventory stocked in", now); + if (!java.util.Objects.equals(before, order.getCurrentStatus())) { + insertOrderHistory(customerId, order.getId(), before, order.getCurrentStatus(), transferNo, + "Customer confirmed PDA measurements; actual inventory stocked in", now); + } return detail(customerId, transferNo); } + private void stockInMeasuredPallets(String customerId, String operatorId, WarehouseTransferOrder transfer, + List items, + List pallets, + List palletItems, + WarehouseResourceGateway.WarehouseReference warehouse, + LocalDateTime now) { + Map itemById = items.stream() + .collect(Collectors.toMap(WarehouseTransferItem::getId, Function.identity())); + for (WarehouseTransferPallet pallet : pallets) { + for (WarehouseTransferPalletItem row : palletItems.stream() + .filter(value -> value.getTransferPalletId().equals(pallet.getId())).toList()) { + WarehouseTransferItem item = itemById.get(row.getTransferItemId()); + if (item == null) throw conflict("Pallet allocation references missing transfer cargo"); + String operationKey = "WAREHOUSE_TRANSFER_STOCK_IN:" + transfer.getTransferNo() + ":" + + pallet.getSequenceNo() + ":" + item.getId(); + InventoryCommandResponse result = inventory.stockIn(customerId, operatorId, + new InventoryCommandRequests.StockIn(customerId, "客户-" + customerId, + transfer.getOperatingWarehouseId(), warehouse.warehouseCode(), warehouse.warehouseName(), + null, trim(transfer.getFbaWarehouseCode()), trim(transfer.getFbaWarehouseCode()), + transfer.getInboundOrderId(), transfer.getTransferNo(), transfer.getTransferNo(), + item.getSkuCode(), item.getProductNameZh(), null, + pallet.getWarehousePalletId(), pallet.getPalletNo(), transfer.getTransferNo(), + pallet.getWarehouseLocationId(), pallet.getWarehouseLocationCode(), row.getPackageCount(), + transfer.getTransferNo(), "WAREHOUSE_TRANSFER_PDA_COMPLETED:" + transfer.getTransferNo(), + operationKey, null)); + InboundStockReceipt receipt = new InboundStockReceipt(); + receipt.setId(IdWorker.getId()); receipt.setCustomerId(customerId); + // 转运收货没有独立 PDA 入仓任务,使用本业务链的稳定主键填充通用收货快照维度。 + receipt.setPlanId(transfer.getInboundOrderId()); receipt.setTaskId(transfer.getId()); + receipt.setAllocationId(row.getId()); receipt.setLineId(item.getId()); + receipt.setOrderId(transfer.getLastlegOrderId()); + receipt.setInboundCode(transfer.getTransferNo()); receipt.setSkuCode(item.getSkuCode()); + receipt.setProductNameZh(item.getProductNameZh()); + receipt.setWarehousePalletId(pallet.getWarehousePalletId()); receipt.setPalletNo(pallet.getPalletNo()); + receipt.setWarehouseLocationId(pallet.getWarehouseLocationId()); + receipt.setWarehouseLocationCode(pallet.getWarehouseLocationCode()); + receipt.setBatchNo(transfer.getTransferNo()); receipt.setReceivedPackages(Long.valueOf(row.getPackageCount())); + receipt.setInventoryBalanceId(result.balanceId()); receipt.setInventoryPositionId(result.positionId()); + receipt.setInventoryOperationKey(operationKey); receipt.setReceivedAt(now); receipt.setCreatedAt(now); + stockReceipts.insert(receipt); + } + } + } + + private void completeTransferInboundSnapshot(String customerId, WarehouseTransferOrder transfer, + List pallets, + WarehouseResourceGateway.WarehouseReference warehouse, + LocalDateTime now) { + if (!transfer.getTransferNo().equals(transfer.getInboundCode())) return; + LastlegInboundOrder inbound = inboundOrders.selectById(transfer.getInboundOrderId()); + if (inbound == null || !customerId.equals(inbound.getCustomerId())) { + throw conflict("Linked inbound order is missing"); + } + inbound.setStatus("RECEIVED"); + inbound.setDestinationWarehouseCode(warehouse.warehouseCode()); + inbound.setDestinationWarehouseName(warehouse.warehouseName()); + inbound.setPackageCount(pallets.stream().mapToInt(WarehouseTransferPallet::getCartonCount).sum()); + inbound.setPalletCount(pallets.size()); + inbound.setGrossWeightKg(pallets.stream().map(WarehouseTransferPallet::getGrossWeightKg) + .reduce(BigDecimal.ZERO, BigDecimal::add)); + inbound.setVolumeCbm(pallets.stream().map(WarehouseTransferPallet::getVolumeCbm) + .reduce(BigDecimal.ZERO, BigDecimal::add)); + inbound.setUpdatedAt(now); + inboundOrders.updateById(inbound); + } + + private void activateTransferInbound(String customerId, WarehouseTransferOrder transfer, LocalDateTime now) { + LastlegInboundOrder inbound = inboundOrders.selectById(transfer.getInboundOrderId()); + if (inbound == null || !customerId.equals(inbound.getCustomerId())) { + throw conflict("Linked inbound order is missing"); + } + if (!Set.of(RECEIVED, INBOUND_COMPLETED, "COMPLETED").contains(inbound.getStatus())) { + inbound.setStatus("PENDING_INBOUND"); + inbound.setUpdatedAt(now); + inboundOrders.updateById(inbound); + } + ensureTransferInboundPlan(customerId, transfer, now); + } + + private static Map> confirmedStockSources( + Map itemById, List receipts) { + Map> result = new java.util.LinkedHashMap<>(); + for (InboundStockReceipt receipt : receipts) { + WarehouseTransferItem item = itemById.get(receipt.getLineId()); + if (item == null) continue; + result.computeIfAbsent(item.getSkuCode(), ignored -> new ArrayList<>()) + .add(new ConfirmedStockSource(receipt.getInventoryBalanceId(), receipt.getInventoryPositionId(), + Math.toIntExact(receipt.getReceivedPackages()))); + } + return result; + } + @Transactional public WarehouseTransferResponses.Detail allocate(String customerId, String transferNo, String operatorId, WarehouseTransferRequests.Allocate request) { @@ -680,6 +1089,9 @@ public class WarehouseTransferService { if (!ALLOCATED.equals(transfer.getStatus())) { throw conflict("Warehouse transfer inventory must be allocated before outbound"); } + if (!StringUtils.hasText(transfer.getFbaWarehouseCode())) { + throw conflict("A destination warehouse is required before outbound"); + } List allocations = inventoryAllocations.lockTransfer(customerId, transfer.getId()); if (allocations.isEmpty() || allocations.stream().mapToLong(WarehouseTransferInventoryAllocation::getPackageCount).sum() != transfer.getPackageCount()) { @@ -786,11 +1198,262 @@ public class WarehouseTransferService { return transfers.pdaCandidates(String.valueOf(overseasWarehouseId)).stream().map(value -> new WarehouseTransferResponses.Summary(value.getId(), value.getTransferNo(), value.getInboundCode(), value.getLastlegOrderId(), value.getLastlegOrderNo(), value.getOperatingWarehouseId(), - value.getFbaWarehouseCode(), value.getPackageCount(), value.getDeliveryMethod(), + value.getFbaWarehouseCode(), value.getDestinationType(), value.getDestinationWarehouseCode(), + value.getDestinationWarehouseName(), value.getPackageCount(), value.getDeliveryMethod(), Boolean.TRUE.equals(value.getNeedRelabel()), value.getRelabelNo(), value.getStatus(), value.getEstimatedArrivalAt(), value.getCreatedAt())).toList(); } + @Transactional(readOnly = true) + public WarehouseTransferResponses.Detail pdaDetail(long overseasWarehouseId, String transferNo) { + WarehouseTransferOrder transfer = transfers.findNoByWarehouse(transferNo, String.valueOf(overseasWarehouseId)); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + return detail(transfer.getCustomerId(), transferNo); + } + + @Transactional + public WarehouseTransferResponses.MeasurementPhoto uploadMeasurementPhoto(String operatorId, long operatorUserId, + long overseasWarehouseId, + String transferNo, MultipartFile file) { + WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(overseasWarehouseId)); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + if (!WAIT_PDA_RECEIVING.equals(transfer.getStatus())) { + throw conflict("Measurement photos can only be uploaded before palletization completes"); + } + if (file == null || file.isEmpty()) throw badRequest("Measurement photo is required"); + if (file.getSize() > 10L * 1024 * 1024) throw badRequest("Measurement photo exceeds 10 MB"); + if (file.getContentType() == null || !file.getContentType().toLowerCase(java.util.Locale.ROOT).startsWith("image/")) { + throw badRequest("Measurement photo must be an image"); + } + if (measurementPhotos.listOrder(transfer.getCustomerId(), transfer.getId()).size() >= 20) { + throw badRequest("A warehouse transfer supports at most 20 measurement photos"); + } + TransferMeasurementPhotoFileGateway.UploadedFile uploaded = measurementPhotoFiles.upload( + transfer.getCustomerId(), transfer.getId(), operatorUserId, file); + LocalDateTime now = LocalDateTime.now(); + WarehouseTransferMeasurementPhoto photo = new WarehouseTransferMeasurementPhoto(); + photo.setId(IdWorker.getId()); photo.setTransferOrderId(transfer.getId()); + photo.setCustomerId(transfer.getCustomerId()); photo.setFileId(uploaded.fileId()); + photo.setFileName(uploaded.fileName()); photo.setContentType(uploaded.contentType()); + photo.setSizeBytes(uploaded.sizeBytes()); photo.setUploadedBy(operatorId); photo.setUploadedAt(now); + measurementPhotos.insert(photo); + return new WarehouseTransferResponses.MeasurementPhoto(uploaded.fileId(), uploaded.fileName(), + uploaded.contentType(), uploaded.sizeBytes(), now); + } + + @Transactional(readOnly = true) + public List measurementPhotos(String customerId, String transferNo) { + WarehouseTransferOrder transfer = transfers.findNo(customerId, transferNo); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + return measurementPhotos.listSubmitted(customerId, transfer.getId()).stream() + .map(value -> new WarehouseTransferResponses.MeasurementPhoto(value.getFileId(), value.getFileName(), + value.getContentType(), value.getSizeBytes() == null ? 0 : value.getSizeBytes(), value.getUploadedAt())) + .toList(); + } + + @Transactional(readOnly = true) + public List pdaMeasurementPhotos(long warehouseId, String transferNo) { + WarehouseTransferOrder transfer = transfers.findNoByWarehouse(transferNo, String.valueOf(warehouseId)); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + return measurementPhotos.listOrder(transfer.getCustomerId(), transfer.getId()).stream() + .map(value -> new WarehouseTransferResponses.MeasurementPhoto(value.getFileId(), value.getFileName(), + value.getContentType(), value.getSizeBytes() == null ? 0 : value.getSizeBytes(), value.getUploadedAt())) + .toList(); + } + + @Transactional(readOnly = true) + public TransferMeasurementPhotoFileGateway.DownloadedFile downloadMeasurementPhoto(String customerId, + String transferNo, + long fileId) { + WarehouseTransferOrder transfer = transfers.findNo(customerId, transferNo); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + if (measurementPhotos.findSubmittedFile(customerId, transfer.getId(), fileId) == null) { + throw notFound("Measurement photo not found"); + } + return measurementPhotoFiles.download(customerId, transfer.getId(), fileId); + } + + @Transactional(readOnly = true) + public TransferMeasurementPhotoFileGateway.DownloadedFile downloadPdaMeasurementPhoto(long warehouseId, + String transferNo, + long fileId) { + WarehouseTransferOrder transfer = transfers.findNoByWarehouse(transferNo, String.valueOf(warehouseId)); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + if (measurementPhotos.findFile(transfer.getCustomerId(), transfer.getId(), fileId) == null) { + throw notFound("Measurement photo not found"); + } + return measurementPhotoFiles.download(transfer.getCustomerId(), transfer.getId(), fileId); + } + + static List derivePalletAllocations(List items, + List pallets) { + if (items == null || items.isEmpty()) throw conflict("Warehouse transfer declared cargo is incomplete"); + List result = new ArrayList<>(); + int itemIndex = 0; + int itemRemaining = items.get(0).getPackageCount(); + for (int palletIndex = 0; palletIndex < pallets.size(); palletIndex++) { + int palletRemaining = pallets.get(palletIndex).cartonCount(); + while (palletRemaining > 0) { + WarehouseTransferItem item = items.get(itemIndex); + if (itemIndex + 1 == items.size()) { + result.add(new DerivedPalletAllocation(palletIndex, item.getId(), palletRemaining)); + break; + } + int allocated = Math.min(palletRemaining, itemRemaining); + result.add(new DerivedPalletAllocation(palletIndex, item.getId(), allocated)); + palletRemaining -= allocated; + itemRemaining -= allocated; + if (itemRemaining == 0 && itemIndex + 1 < items.size()) { + itemIndex++; + itemRemaining = items.get(itemIndex).getPackageCount(); + } + } + } + return result; + } + + static List derivePersistedPalletAllocations(List items, + List pallets) { + if (items == null || items.isEmpty()) throw conflict("Warehouse transfer declared cargo is incomplete"); + List result = new ArrayList<>(); + int itemIndex = 0; + int itemRemaining = items.get(0).getPackageCount(); + for (int palletIndex = 0; palletIndex < pallets.size(); palletIndex++) { + int palletRemaining = pallets.get(palletIndex).getCartonCount(); + while (palletRemaining > 0) { + WarehouseTransferItem item = items.get(itemIndex); + if (itemIndex + 1 == items.size()) { + result.add(new DerivedPalletAllocation(palletIndex, item.getId(), palletRemaining)); + break; + } + int allocated = Math.min(palletRemaining, itemRemaining); + result.add(new DerivedPalletAllocation(palletIndex, item.getId(), allocated)); + palletRemaining -= allocated; + itemRemaining -= allocated; + if (itemRemaining == 0 && itemIndex + 1 < items.size()) { + itemIndex++; + itemRemaining = items.get(itemIndex).getPackageCount(); + } + } + } + return result; + } + + record DerivedPalletAllocation(int palletIndex, long transferItemId, int packageCount) {} + + static List newTransferPalletRequests( + long customerId, String transferNo, String fbaWarehouseCode, int palletCount) { + List result = new ArrayList<>(); + for (int index = 0; index < palletCount; index++) { + result.add(new WarehouseResourceGateway.PalletBatchItemRequest(customerId, + transferNo + ":" + (index + 1), transferNo, index + 1, "STANDARD", + trim(fbaWarehouseCode), null)); + } + return result; + } + + private static void validateDestination(String destinationType, String destinationWarehouseCode, + String legacyFbaWarehouseCode, String privateAddress, String privatePostalCode, + String privateContactName, String privateContactPhone) { + String type = trim(destinationType); + if (type == null && StringUtils.hasText(legacyFbaWarehouseCode)) type = "FBA"; + if (type == null || "NONE".equalsIgnoreCase(type)) return; + type = type.toUpperCase(java.util.Locale.ROOT); + if (!Set.of("FBA", "PRIVATE").contains(type)) throw badRequest("Unsupported destination type"); + if ("FBA".equals(type) && !StringUtils.hasText(firstText(destinationWarehouseCode, legacyFbaWarehouseCode))) { + throw badRequest("FBA destination warehouse is required"); + } + if ("PRIVATE".equals(type) && (!StringUtils.hasText(privateAddress) || !StringUtils.hasText(privatePostalCode) + || !StringUtils.hasText(privateContactName) || !StringUtils.hasText(privateContactPhone))) { + throw badRequest("Private warehouse address, contact name and phone are required"); + } + } + + private static void applyDestination(WarehouseTransferOrder transfer, WarehouseTransferRequests.Create request) { + String type = trim(request.destinationType()); + if (type == null && StringUtils.hasText(request.fbaWarehouseCode())) type = "FBA"; + if ("NONE".equalsIgnoreCase(Objects.toString(type, ""))) type = null; + transfer.setDestinationType(type == null ? null : type.toUpperCase(java.util.Locale.ROOT)); + transfer.setDestinationWarehouseId("FBA".equals(transfer.getDestinationType()) ? trim(request.destinationWarehouseId()) : null); + transfer.setDestinationWarehouseCode("FBA".equals(transfer.getDestinationType()) ? destinationCode(request) : null); + transfer.setDestinationWarehouseName("FBA".equals(transfer.getDestinationType()) + ? firstText(request.destinationWarehouseName(), destinationCode(request)) : null); + transfer.setPrivateDestinationAddress("PRIVATE".equals(transfer.getDestinationType()) ? trim(request.privateDestinationAddress()) : null); + transfer.setPrivateDestinationPostalCode("PRIVATE".equals(transfer.getDestinationType()) ? trim(request.privateDestinationPostalCode()) : null); + transfer.setPrivateDestinationContactName("PRIVATE".equals(transfer.getDestinationType()) ? trim(request.privateDestinationContactName()) : null); + transfer.setPrivateDestinationContactPhone("PRIVATE".equals(transfer.getDestinationType()) ? trim(request.privateDestinationContactPhone()) : null); + transfer.setFbaWarehouseCode("FBA".equals(transfer.getDestinationType()) ? transfer.getDestinationWarehouseCode() : null); + } + + private static void applyDestination(WarehouseTransferOrder transfer, WarehouseTransferRequests.BindDestination request) { + String type = request.destinationType().trim().toUpperCase(java.util.Locale.ROOT); + transfer.setDestinationType(type); + transfer.setDestinationWarehouseId("FBA".equals(type) ? trim(request.destinationWarehouseId()) : null); + transfer.setDestinationWarehouseCode("FBA".equals(type) ? trim(request.destinationWarehouseCode()) : null); + transfer.setDestinationWarehouseName("FBA".equals(type) + ? firstText(request.destinationWarehouseName(), request.destinationWarehouseCode()) : null); + transfer.setPrivateDestinationAddress("PRIVATE".equals(type) ? trim(request.privateDestinationAddress()) : null); + transfer.setPrivateDestinationPostalCode("PRIVATE".equals(type) ? trim(request.privateDestinationPostalCode()) : null); + transfer.setPrivateDestinationContactName("PRIVATE".equals(type) ? trim(request.privateDestinationContactName()) : null); + transfer.setPrivateDestinationContactPhone("PRIVATE".equals(type) ? trim(request.privateDestinationContactPhone()) : null); + transfer.setFbaWarehouseCode("FBA".equals(type) ? transfer.getDestinationWarehouseCode() : null); + } + + private static boolean hasValidDestination(WarehouseTransferOrder transfer) { + if ("FBA".equals(transfer.getDestinationType())) return StringUtils.hasText(transfer.getDestinationWarehouseCode()); + if ("PRIVATE".equals(transfer.getDestinationType())) { + return StringUtils.hasText(transfer.getPrivateDestinationAddress()) + && StringUtils.hasText(transfer.getPrivateDestinationPostalCode()) + && StringUtils.hasText(transfer.getPrivateDestinationContactName()) + && StringUtils.hasText(transfer.getPrivateDestinationContactPhone()); + } + return StringUtils.hasText(transfer.getFbaWarehouseCode()); + } + + private static String destinationCode(WarehouseTransferRequests.Create request) { + return firstText(request.destinationWarehouseCode(), request.fbaWarehouseCode()); + } + + private static void copyDestination(WarehouseTransferOrder transfer, InboundPlanLine line) { + String type = hasValidDestination(transfer) ? Objects.toString(transfer.getDestinationType(), "FBA") : null; + line.setDestinationType(type); + line.setFbaWarehouseCode("FBA".equals(type) ? firstText(transfer.getDestinationWarehouseCode(), transfer.getFbaWarehouseCode()) : null); + line.setDestinationWarehouseId(transfer.getDestinationWarehouseId()); + line.setDestinationWarehouseCode(transfer.getDestinationWarehouseCode()); + line.setDestinationWarehouseName("PRIVATE".equals(type) ? transfer.getPrivateDestinationAddress() : transfer.getDestinationWarehouseName()); + line.setDeliveryAddress(transfer.getPrivateDestinationAddress()); + line.setPostalCode(transfer.getPrivateDestinationPostalCode()); + line.setContactName(transfer.getPrivateDestinationContactName()); + line.setContactPhone(transfer.getPrivateDestinationContactPhone()); + } + + private static void copyDestination(WarehouseTransferOrder transfer, InboundPlanPallet pallet) { + String type = hasValidDestination(transfer) ? Objects.toString(transfer.getDestinationType(), "FBA") : null; + pallet.setDestinationType(type); + pallet.setDestinationWarehouseId(transfer.getDestinationWarehouseId()); + pallet.setDestinationWarehouseCode(transfer.getDestinationWarehouseCode()); + pallet.setDestinationWarehouseName("PRIVATE".equals(type) ? transfer.getPrivateDestinationAddress() : transfer.getDestinationWarehouseName()); + pallet.setDeliveryAddress(transfer.getPrivateDestinationAddress()); + pallet.setPostalCode(transfer.getPrivateDestinationPostalCode()); + pallet.setContactName(transfer.getPrivateDestinationContactName()); + pallet.setContactPhone(transfer.getPrivateDestinationContactPhone()); + } + + private void syncInboundPlanDestination(String customerId, WarehouseTransferOrder transfer) { + InboundPlan plan = inboundPlans.findBySourceOrder(customerId, transfer.getLastlegOrderId()); + if (plan == null) throw conflict("Warehouse transfer inbound plan is missing"); + for (InboundPlanLine line : inboundPlanLines.selectList(new LambdaQueryWrapper() + .eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getPlanId, plan.getId()))) { + copyDestination(transfer, line); + inboundPlanLines.updateById(line); + } + for (InboundPlanPallet pallet : inboundPlanPallets.selectList(new LambdaQueryWrapper() + .eq(InboundPlanPallet::getCustomerId, customerId).eq(InboundPlanPallet::getPlanId, plan.getId()))) { + copyDestination(transfer, pallet); + inboundPlanPallets.updateById(pallet); + } + } + private void validateRelabelRequest(WarehouseTransferRequests.Create request) { if (!request.needRelabel()) { if (request.relabelItems() != null && !request.relabelItems().isEmpty()) { @@ -800,30 +1463,45 @@ public class WarehouseTransferService { } if (request.relabelItems() == null || request.relabelItems().isEmpty()) return; if (request.labelDeadline() == null || !StringUtils.hasText(request.relabelContactName()) - || !StringUtils.hasText(request.relabelContactPhone()) || request.relabelChargeAmount() == null) { - throw badRequest("Relabel deadline, contact and charge are required"); + || !StringUtils.hasText(request.relabelContactPhone())) { + throw badRequest("Relabel deadline and contact are required"); } - Set declaredSkus = request.items().stream().map(item -> item.skuCode().trim()).collect(Collectors.toSet()); + Set declaredSkus = request.items().stream().map(item -> trim(item.skuCode())) + .filter(Objects::nonNull).collect(Collectors.toSet()); Set marks = new HashSet<>(); + Set destinations = new HashSet<>(); for (WarehouseTransferRequests.RelabelItem row : request.relabelItems()) { String type = row.operationType().trim().toUpperCase(); if (!Set.of("CARTON_LABEL", "SKU_LABEL", "CARTON_AND_SKU").contains(type)) { throw badRequest("Unsupported relabel operation type"); } - if (!declaredSkus.contains(row.originalSku().trim())) { - throw badRequest("Relabel SKU must exist in transfer cargo: " + row.originalSku()); + String originalSku = trim(row.originalSku()); + boolean changesSku = "SKU_LABEL".equals(type) || "CARTON_AND_SKU".equals(type); + if (changesSku && originalSku == null) { + throw badRequest("SKU relabel requires the original SKU"); } + if (originalSku != null && !declaredSkus.contains(originalSku)) { + throw badRequest("Relabel SKU must exist in transfer cargo: " + originalSku); + } + String destination = trim(row.destinationWarehouseCode()); + if (destination == null) { + throw badRequest("Warehouse transfer relabel requires one destination warehouse for the whole order"); + } + destinations.add(destination.toUpperCase()); if (("CARTON_LABEL".equals(type) || "CARTON_AND_SKU".equals(type)) - && (!StringUtils.hasText(row.fbaCartonMark()) || !StringUtils.hasText(row.destinationWarehouseCode()))) { - throw badRequest("Carton relabel requires new FBA carton mark and destination warehouse"); + && !StringUtils.hasText(row.fbaCartonMark())) { + throw badRequest("Carton relabel requires a new FBA carton mark"); } - if (("SKU_LABEL".equals(type) || "CARTON_AND_SKU".equals(type)) && !StringUtils.hasText(row.newSku())) { + if (changesSku && !StringUtils.hasText(row.newSku())) { throw badRequest("SKU relabel requires a new SKU"); } for (String mark : row.customerCartonMarks()) { if (!marks.add(mark.trim().toUpperCase())) throw badRequest("Customer carton marks must be unique"); } } + if (destinations.size() != 1) { + throw badRequest("Warehouse transfer relabel only supports one destination warehouse per order"); + } if (marks.size() > request.packageCount()) throw badRequest("Relabel carton marks cannot exceed transfer cartons"); } @@ -835,7 +1513,7 @@ public class WarehouseTransferService { WarehouseTransferRelabelBinding row = new WarehouseTransferRelabelBinding(); row.setId(IdWorker.getId()); row.setTransferOrderId(transferId); row.setCustomerId(customerId); row.setOperationType(source.operationType().trim().toUpperCase()); - row.setCustomerCartonMark(mark.trim()); row.setOriginalSku(source.originalSku().trim()); + row.setCustomerCartonMark(mark.trim()); row.setOriginalSku(Objects.toString(trim(source.originalSku()), "")); row.setNewSku(trim(source.newSku())); row.setQuantity(source.quantity()); row.setFbaCartonMark(trim(source.fbaCartonMark())); row.setDestinationWarehouseCode(trim(source.destinationWarehouseCode())); @@ -852,16 +1530,28 @@ public class WarehouseTransferService { .collect(Collectors.toMap(WarehouseTransferItem::getSkuCode, Function.identity(), (left, right) -> left)); List items = new ArrayList<>(); for (WarehouseTransferRelabelBinding binding : relabelBindings.listOrder(customerId, transfer.getId())) { - WarehouseTransferItem source = itemsBySku.get(binding.getOriginalSku()); - ConfirmedStockSource stockSource = nextStockSource(stockSourcesBySku.get(binding.getOriginalSku())); + String effectiveSku = trim(binding.getOriginalSku()); + WarehouseTransferItem source = effectiveSku == null ? null : itemsBySku.get(effectiveSku); + ConfirmedStockSource stockSource = effectiveSku == null ? null : nextStockSource(stockSourcesBySku.get(effectiveSku)); + if (effectiveSku == null && "CARTON_LABEL".equals(binding.getOperationType())) { + for (Map.Entry> entry : stockSourcesBySku.entrySet()) { + ConfirmedStockSource candidate = nextStockSource(entry.getValue()); + if (candidate != null) { + effectiveSku = entry.getKey(); + source = itemsBySku.get(effectiveSku); + stockSource = candidate; + break; + } + } + } if (source == null || stockSource == null) { - throw conflict("Relabel binding cannot find confirmed inventory for SKU " + binding.getOriginalSku()); + throw conflict("Relabel binding cannot find confirmed transfer inventory"); } List skuItems = Set.of("SKU_LABEL", "CARTON_AND_SKU").contains(binding.getOperationType()) - ? List.of(new RelabelRequests.SkuItem(binding.getOriginalSku(), binding.getNewSku(), + ? List.of(new RelabelRequests.SkuItem(effectiveSku, binding.getNewSku(), binding.getQuantity(), binding.getRemark())) : List.of(); items.add(new RelabelRequests.Item(stockSource.balanceId, stockSource.positionId, transfer.getInboundOrderId(), - binding.getOriginalSku(), null, null, null, source.getProductNameZh(), 1, 1, + effectiveSku, null, null, null, source.getProductNameZh(), 1, 1, StringUtils.hasText(transfer.getFbaShipmentNo()) ? transfer.getFbaShipmentNo() : transfer.getTransferNo(), transfer.getTrackingNo(), binding.getNewSku(), "仓储转运自动换标", binding.getRemark(), null, null, null, binding.getCustomerCartonMark(), binding.getFbaCartonMark(), @@ -872,7 +1562,8 @@ public class WarehouseTransferService { transfer.getRelabelContactPhone(), transfer.getRelabelChargeAmount(), "仓储转运确认后自动创建换标单", "WAREHOUSE_TRANSFER:" + transfer.getTransferNo() + ":RELABEL", items, trim(transfer.getRelabelDeliveryMethod()), false); - return relabelService.create(customerId, operatorId, request); + return relabelService.createForExistingTailOrder(customerId, operatorId, + transfer.getLastlegOrderId(), request); } private static ConfirmedStockSource nextStockSource(List sources) { @@ -898,34 +1589,48 @@ public class WarehouseTransferService { } } - private void createDispatchPlan(String customerId, WarehouseTransferOrder transfer, - List items, List pallets, - List palletItems, LocalDateTime now) { + private InboundPlan ensureTransferInboundPlan(String customerId, WarehouseTransferOrder transfer, + LocalDateTime now) { + InboundPlan existing = inboundPlans.findBySourceOrder(customerId, transfer.getLastlegOrderId()); + if (existing != null) return existing; long planId = IdWorker.getId(); InboundPlan plan = new InboundPlan(); plan.setId(planId); plan.setPlanNo("WT-" + transfer.getTransferNo()); plan.setCustomerId(customerId); plan.setSourceOrderId(transfer.getLastlegOrderId()); plan.setContainerNo(transfer.getTransferNo()); plan.setOverseasWarehouseId(Long.parseLong(transfer.getOperatingWarehouseId())); plan.setPlannedArrivalDate(transfer.getEstimatedArrivalAt().toLocalDate()); plan.setPalletType("STANDARD"); - plan.setAssignedFlag(true); plan.setMergeRequired(false); plan.setUrgentFlag(false); - plan.setCreationMode("WAREHOUSE_TRANSFER"); plan.setPlanRemark("Customer-confirmed transfer measurements"); - plan.setStatus("COMPLETED"); plan.setLineCount(items.size()); plan.setPalletCount(pallets.size()); - plan.setTotalVolumeCbm(pallets.stream().map(WarehouseTransferPallet::getVolumeCbm) - .reduce(BigDecimal.ZERO, BigDecimal::add)); plan.setCreatedAt(now); plan.setUpdatedAt(now); plan.setVersion(0); + plan.setAssignedFlag(false); plan.setMergeRequired(false); plan.setUrgentFlag(false); + plan.setCreationMode("WAREHOUSE_TRANSFER"); plan.setPlanRemark("Warehouse transfer awaiting PDA receiving"); + plan.setStatus("PDA_PENDING"); plan.setLineCount(1); plan.setPalletCount(0); + plan.setTotalVolumeCbm(BigDecimal.ZERO); plan.setCreatedAt(now); plan.setUpdatedAt(now); plan.setVersion(0); inboundPlans.insert(plan); - Map lineIds = new java.util.HashMap<>(); - for (WarehouseTransferItem source : items) { - InboundPlanLine line = new InboundPlanLine(); line.setId(IdWorker.getId()); line.setCustomerId(customerId); - line.setPlanId(planId); line.setOrderId(transfer.getLastlegOrderId()); line.setInboundCode(transfer.getTransferNo()); - line.setDestinationType("FBA"); line.setFbaWarehouseCode(transfer.getFbaWarehouseCode()); - line.setDestinationWarehouseCode(transfer.getFbaWarehouseCode()); line.setDestinationWarehouseName(transfer.getFbaWarehouseCode()); - line.setVolumeCbm(BigDecimal.ZERO); line.setRequiredPackageCount(source.getPackageCount()); - line.setPlannedPalletCount((int) palletItems.stream().filter(row -> row.getTransferItemId().equals(source.getId())).count()); - inboundPlanLines.insert(line); lineIds.put(source.getId(), line.getId()); - } + InboundPlanLine line = new InboundPlanLine(); line.setId(IdWorker.getId()); line.setCustomerId(customerId); + line.setPlanId(planId); line.setOrderId(transfer.getLastlegOrderId()); line.setInboundCode(transfer.getTransferNo()); + copyDestination(transfer, line); + line.setVolumeCbm(BigDecimal.ZERO); line.setRequiredPackageCount(transfer.getPackageCount()); + line.setPlannedPalletCount(0); inboundPlanLines.insert(line); + return plan; + } + + private void completeTransferInboundPlan(String customerId, WarehouseTransferOrder transfer, + List pallets, + List palletItems, + LocalDateTime now) { + if (!transfer.getTransferNo().equals(transfer.getInboundCode())) return; + InboundPlan plan = ensureTransferInboundPlan(customerId, transfer, now); + if ("COMPLETED".equals(plan.getStatus())) return; + InboundPlanLine line = inboundPlanLines.selectOne(new LambdaQueryWrapper() + .eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getPlanId, plan.getId()) + .eq(InboundPlanLine::getOrderId, transfer.getLastlegOrderId())); + if (line == null) throw conflict("Warehouse transfer inbound plan line is missing"); + copyDestination(transfer, line); + line.setVolumeCbm(pallets.stream().map(WarehouseTransferPallet::getVolumeCbm) + .reduce(BigDecimal.ZERO, BigDecimal::add)); + line.setPlannedPalletCount(pallets.size()); + inboundPlanLines.updateById(line); for (WarehouseTransferPallet source : pallets) { InboundPlanPallet pallet = new InboundPlanPallet(); pallet.setId(IdWorker.getId()); pallet.setCustomerId(customerId); - pallet.setPlanId(planId); pallet.setPalletNo(source.getPalletNo()); pallet.setPalletType("STANDARD"); - pallet.setDestinationWarehouseCode(transfer.getFbaWarehouseCode()); pallet.setDestinationWarehouseName(transfer.getFbaWarehouseCode()); + pallet.setPlanId(plan.getId()); pallet.setPalletNo(source.getPalletNo()); pallet.setPalletType("STANDARD"); + copyDestination(transfer, pallet); pallet.setAllocatedVolumeCbm(source.getVolumeCbm()); pallet.setSequenceNo(source.getSequenceNo()); pallet.setWarehousePalletId(source.getWarehousePalletId()); pallet.setWarehouseLocationId(source.getWarehouseLocationId()); pallet.setWarehouseResourceLockId(source.getWarehouseResourceLockId()); pallet.setAssignmentStatus("ASSIGNED"); @@ -934,13 +1639,21 @@ public class WarehouseTransferService { for (WarehouseTransferPalletItem sourceItem : palletItems.stream() .filter(row -> row.getTransferPalletId().equals(source.getId())).toList()) { InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId()); - allocation.setCustomerId(customerId); allocation.setPlanId(planId); allocation.setLineId(lineIds.get(sourceItem.getTransferItemId())); + allocation.setCustomerId(customerId); allocation.setPlanId(plan.getId()); allocation.setLineId(line.getId()); allocation.setPalletId(pallet.getId()); allocation.setAllocatedVolumeCbm(BigDecimal.ZERO); allocation.setPlannedPackageCount(sourceItem.getPackageCount()); allocation.setCountedPackageCount(sourceItem.getPackageCount()); - allocation.setPickedPackageCount(0); allocation.setActualVolumeCbm(source.getVolumeCbm()); + // PDA 已完成测量、打托并确认了本托箱数;入仓计划列表的“实际拣货数” + // 直接汇总该字段,不能再保留初始化时的 0。 + allocation.setPickedPackageCount(sourceItem.getPackageCount()); + allocation.setActualVolumeCbm(source.getVolumeCbm()); allocation.setStatus("COMPLETED"); allocation.setVersion(0); inboundPlanAllocations.insert(allocation); } } + plan.setAssignedFlag(true); plan.setStatus("COMPLETED"); plan.setPalletCount(pallets.size()); + plan.setTotalVolumeCbm(pallets.stream().map(WarehouseTransferPallet::getVolumeCbm) + .reduce(BigDecimal.ZERO, BigDecimal::add)); + plan.setPlanRemark("Warehouse transfer PDA receiving completed"); plan.setUpdatedAt(now); + inboundPlans.updateById(plan); } private static List splitMarks(String value) { @@ -970,7 +1683,7 @@ public class WarehouseTransferService { inbound.setPalletCount(0); inbound.setVolumeCbm(request.volumeCbm()); inbound.setSourceEventId(sourceId); inbound.setSourceLoadingPlanId(ORDER_TYPE); inbound.setSourceLoadingPlanVersion(1L); inbound.setHandoverVersion(1L); - inbound.setPayloadHash("sha256:" + requestHash); inbound.setStatus(PENDING_REVIEW); + inbound.setPayloadHash("sha256:" + requestHash); inbound.setStatus(DRAFT); inbound.setExceptionFlag("NONE"); inbound.setCreatedAt(now); inbound.setUpdatedAt(now); inboundOrders.insert(inbound); return inbound.getId(); @@ -984,14 +1697,14 @@ 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.setFbaWarehouseCode(trim(request.fbaWarehouseCode())); order.setPackageCount(request.packageCount()); + order.setFbaWarehouseCode(destinationCode(request)); order.setPackageCount(request.packageCount()); order.setVolumeCbm(request.volumeCbm()); order.setContainerNo(transferNo); order.setPalletCount(0); order.setPickedPackageCount(0); order.setShippedPackageCount(0); order.setEstimatedWarehouseAt(request.estimatedArrivalAt()); - order.setCurrentStatus(PENDING_REVIEW); + order.setCurrentStatus(DRAFT); order.setRelabelStatus(request.needRelabel() ? "PENDING_BINDING" : "NOT_REQUIRED"); - order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + PENDING_REVIEW + ";RELABEL:" + order.getRelabelStatus()); + order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + DRAFT + ";RELABEL:" + order.getRelabelStatus()); order.setCustomerRemark(trim(request.remark())); order.setSourceEventId("WAREHOUSE_TRANSFER:" + transferNo); order.setSourceLoadingPlanId(ORDER_TYPE); order.setSourcePayloadHash("sha256:" + requestHash); order.setCreatedAt(now); order.setUpdatedAt(now); @@ -1003,7 +1716,8 @@ public class WarehouseTransferService { LocalDateTime now) { WarehouseTransferItem item = new WarehouseTransferItem(); item.setId(IdWorker.getId()); item.setTransferOrderId(transferId); item.setCustomerId(customerId); - item.setSkuCode(source.skuCode().trim()); item.setProductNameZh(source.productNameZh().trim()); + item.setSkuCode(Objects.toString(trim(source.skuCode()), "UNSPECIFIED")); + item.setProductNameZh(Objects.toString(trim(source.productNameZh()), "仓储转运货物")); item.setPackageCount(source.packageCount()); item.setShippingMark(trim(source.shippingMark())); item.setCreatedAt(now); transferItems.insert(item); return item.getId(); @@ -1025,8 +1739,8 @@ public class WarehouseTransferService { LastlegOrderDetail detail = new LastlegOrderDetail(); detail.setCustomerId(customerId); detail.setOrderId(orderId); detail.setSourceInboundDetailId(transferNo + ":" + index); - detail.setSkuCode(source.skuCode().trim()); detail.setProductNameZh(source.productNameZh().trim()); - detail.setPackageCount(source.packageCount()); detail.setDestinationWarehouseCode(trim(request.fbaWarehouseCode())); + detail.setSkuCode(trim(source.skuCode())); detail.setProductNameZh(trim(source.productNameZh())); + detail.setPackageCount(source.packageCount()); detail.setDestinationWarehouseCode(destinationCode(request)); detail.setFbaTrackingNo("CUSTOMER_DELIVERY".equals(request.deliveryMethod().trim()) ? trim(request.trackingNo()) : null); detail.setShipmentNo(trim(request.fbaShipmentNo())); @@ -1059,6 +1773,7 @@ public class WarehouseTransferService { } } private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } + private static String firstText(String first, String second) { return StringUtils.hasText(first) ? first.trim() : trim(second); } private static long packages(Long value) { return value == null ? 0L : value; } private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } diff --git a/lastleg-service/src/main/resources/application.yml b/lastleg-service/src/main/resources/application.yml index 01e04f1c7..07e47ba18 100644 --- a/lastleg-service/src/main/resources/application.yml +++ b/lastleg-service/src/main/resources/application.yml @@ -76,9 +76,9 @@ baiyue: url: ${DOMESTIC_WAREHOUSE_URL:} cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m} file: - url: ${FILE_SERVICE_URL:http://127.0.0.1:9205} + url: ${FILE_SERVICE_URL:http://127.0.0.1:9206} domestic-file: - url: ${FILE_SERVICE_URL:http://127.0.0.1:9205} + url: ${FILE_SERVICE_URL:http://127.0.0.1:9206} event-bridge: url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL:}} firstleg: diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index e92e0168c..18fd8543e 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -75,6 +75,7 @@ CREATE TABLE IF NOT EXISTS ll_order ( version INT NOT NULL DEFAULT 0 COMMENT '尾程订单命令乐观锁版本', pallet_count INT NOT NULL DEFAULT 0 COMMENT '托数', package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + received_package_count INT NULL COMMENT 'PDA实际入库箱数', picked_package_count INT NOT NULL DEFAULT 0 COMMENT '累计已拣箱数', shipped_package_count INT NOT NULL DEFAULT 0 COMMENT '累计已出库箱数', volume_cbm DECIMAL(18,4) COMMENT '体积(立方米)', @@ -267,14 +268,19 @@ CREATE TABLE IF NOT EXISTS ll_delivery_order ( delivery_no VARCHAR(64) NOT NULL COMMENT '派送单号', outbound_order_id BIGINT NOT NULL COMMENT '关联出库单ID', outbound_no VARCHAR(64) NOT NULL COMMENT '关联出库单号', - dispatch_order_id BIGINT NOT NULL COMMENT '关联派车单ID', - dispatch_no VARCHAR(64) NOT NULL COMMENT '关联派车单号', + dispatch_order_id BIGINT NULL COMMENT '关联派车单ID,仓储转运直接出库时为空', + dispatch_no VARCHAR(64) NULL COMMENT '关联派车单号,仓储转运直接出库时为空', overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', customer_name VARCHAR(255) NULL COMMENT '客户名称快照', destination_warehouse_id VARCHAR(128) NULL COMMENT '本派送单目的FBA仓ID快照', - destination_warehouse_code VARCHAR(128) NOT NULL COMMENT '本派送单目的FBA仓编码快照', + destination_warehouse_code VARCHAR(128) NULL COMMENT '本派送单目的FBA仓编码快照,私人仓为空', destination_warehouse_name VARCHAR(255) NULL COMMENT '本派送单目的FBA仓名称快照', + destination_type VARCHAR(16) NULL COMMENT '目的地类型:FBA或PRIVATE', + delivery_address VARCHAR(1000) NULL COMMENT '私人仓派送地址', + postal_code VARCHAR(32) NULL COMMENT '私人仓邮编', + contact_name VARCHAR(128) NULL COMMENT '私人仓联系人', + contact_phone VARCHAR(64) NULL COMMENT '私人仓联系电话', delivery_group_key VARCHAR(255) NOT NULL COMMENT '同一出库单内按目的FBA仓拆单的稳定分组键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_DELIVERY' COMMENT '派送状态:WAIT_DELIVERY待派送及后续正式状态', waybill_no VARCHAR(128) NULL COMMENT '承运运单号', @@ -318,6 +324,12 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='destination_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''本派送单目的FBA仓ID快照'' AFTER customer_name, ADD COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''本派送单目的FBA仓编码快照'' AFTER destination_warehouse_id, ADD COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''本派送单目的FBA仓名称快照'' AFTER destination_warehouse_code, ADD COLUMN delivery_group_key VARCHAR(255) NULL COMMENT ''同一出库单内按目的FBA仓拆单的稳定分组键'' AFTER destination_warehouse_name')); PREPARE delivery_destination_stmt FROM @ddl; EXECUTE delivery_destination_stmt; DEALLOCATE PREPARE delivery_destination_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN destination_type VARCHAR(16) NULL COMMENT ''目的地类型:FBA或PRIVATE'' AFTER destination_warehouse_name, ADD COLUMN delivery_address VARCHAR(1000) NULL COMMENT ''私人仓派送地址'' AFTER destination_type, ADD COLUMN contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人'' AFTER delivery_address, ADD COLUMN contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话'' AFTER contact_name')); +PREPARE delivery_private_destination_stmt FROM @ddl; EXECUTE delivery_private_destination_stmt; DEALLOCATE PREPARE delivery_private_destination_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='postal_code'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编'' AFTER delivery_address')); +PREPARE delivery_postal_stmt FROM @ddl; EXECUTE delivery_postal_stmt; DEALLOCATE PREPARE delivery_postal_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='dispatch_order_id' AND is_nullable='YES'), 'SELECT 1', 'ALTER TABLE ll_delivery_order MODIFY COLUMN dispatch_order_id BIGINT NULL COMMENT ''关联派车单ID,仓储转运直接出库时为空'', MODIFY COLUMN dispatch_no VARCHAR(64) NULL COMMENT ''关联派车单号,仓储转运直接出库时为空'', MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''本派送单目的FBA仓编码快照,私人仓为空''')); +PREPARE delivery_transfer_nullable_stmt FROM @ddl; EXECUTE delivery_transfer_nullable_stmt; DEALLOCATE PREPARE delivery_transfer_nullable_stmt; SET @old_delivery_outbound_index = (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND index_name='uk_ll_delivery_outbound'); SET @new_delivery_destination_index = (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND index_name='uk_ll_delivery_outbound_destination'); SET @ddl = IF(@new_delivery_destination_index > 0, 'SELECT 1', IF(@old_delivery_outbound_index > 0, 'ALTER TABLE ll_delivery_order DROP INDEX uk_ll_delivery_outbound, ADD UNIQUE KEY uk_ll_delivery_outbound_destination (customer_id,outbound_order_id,delivery_group_key)', 'ALTER TABLE ll_delivery_order ADD UNIQUE KEY uk_ll_delivery_outbound_destination (customer_id,outbound_order_id,delivery_group_key)')); @@ -599,7 +611,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan ( assigned_flag TINYINT NOT NULL DEFAULT 0 COMMENT '人工计划是否已分配:0否1是', merge_required TINYINT NOT NULL DEFAULT 0 COMMENT '人工计划是否要求合托:0否1是', urgent_flag TINYINT NOT NULL DEFAULT 0 COMMENT '人工计划是否加急:0否1是', - creation_mode VARCHAR(16) NOT NULL DEFAULT 'AUTO' COMMENT '计划生成方式:AUTO柜级自动、MANUAL单票人工', + creation_mode VARCHAR(32) NOT NULL DEFAULT 'AUTO' COMMENT '计划生成方式:AUTO柜级自动、MANUAL单票人工、WAREHOUSE_TRANSFER仓储转运', plan_remark VARCHAR(1000) NULL COMMENT '入仓计划备注', status VARCHAR(32) NOT NULL DEFAULT 'PLANNED' COMMENT '入库计划状态', line_count INT NOT NULL DEFAULT 0 COMMENT '入仓码明细数量', @@ -648,6 +660,10 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_line ( destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + delivery_address VARCHAR(1000) NULL COMMENT '私人仓目的地址快照', + postal_code VARCHAR(32) NULL COMMENT '私人仓邮编快照', + contact_name VARCHAR(128) NULL COMMENT '私人仓联系人快照', + contact_phone VARCHAR(64) NULL COMMENT '私人仓联系电话快照', volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '入仓码体积(立方米)', planned_pallet_count INT NOT NULL DEFAULT 0 COMMENT '该明细分配的托盘数量', PRIMARY KEY (id), @@ -682,6 +698,11 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet ( destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + destination_type VARCHAR(32) NULL COMMENT '目的类型:FBA或PRIVATE', + delivery_address VARCHAR(1000) NULL COMMENT '私人仓目的地址快照', + postal_code VARCHAR(32) NULL COMMENT '私人仓邮编快照', + contact_name VARCHAR(128) NULL COMMENT '私人仓联系人快照', + contact_phone VARCHAR(64) NULL COMMENT '私人仓联系电话快照', capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量,固定为2立方米', allocated_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '已分配体积(立方米)', sequence_no INT NOT NULL COMMENT '计划内托盘序号', @@ -721,8 +742,15 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='urgent_flag'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN urgent_flag TINYINT NOT NULL DEFAULT 0 COMMENT ''人工计划是否加急:0否1是'' AFTER merge_required')); PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='creation_mode'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN creation_mode VARCHAR(16) NOT NULL DEFAULT ''AUTO'' COMMENT ''计划生成方式:AUTO柜级自动、MANUAL单票人工'' AFTER urgent_flag')); +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='creation_mode'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN creation_mode VARCHAR(32) NOT NULL DEFAULT ''AUTO'' COMMENT ''计划生成方式:AUTO柜级自动、MANUAL单票人工、WAREHOUSE_TRANSFER仓储转运'' AFTER urgent_flag')); PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt; +ALTER TABLE ll_inbound_plan MODIFY COLUMN creation_mode VARCHAR(32) NOT NULL DEFAULT 'AUTO' COMMENT '计划生成方式:AUTO柜级自动、MANUAL单票人工、WAREHOUSE_TRANSFER仓储转运'; +UPDATE ll_inbound_plan_pallet_allocation a +JOIN ll_inbound_plan p ON p.id=a.plan_id +SET a.picked_package_count=COALESCE(a.counted_package_count,a.planned_package_count,0) +WHERE p.creation_mode='WAREHOUSE_TRANSFER' AND p.status='COMPLETED' + AND COALESCE(a.picked_package_count,0)=0 + AND COALESCE(a.counted_package_count,a.planned_package_count,0)>0; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='plan_remark'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN plan_remark VARCHAR(1000) NULL COMMENT ''入仓计划备注'' AFTER creation_mode')); PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt; ALTER TABLE ll_inbound_plan @@ -1329,6 +1357,11 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_item ( destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', fba_warehouse_code VARCHAR(128) NULL COMMENT 'FBA仓库编码', + destination_type VARCHAR(32) NULL COMMENT '目的类型:FBA或PRIVATE', + delivery_address VARCHAR(1000) NULL COMMENT '私人仓目的地址快照', + postal_code VARCHAR(32) NULL COMMENT '私人仓邮编快照', + contact_name VARCHAR(128) NULL COMMENT '私人仓联系人快照', + contact_phone VARCHAR(64) NULL COMMENT '私人仓联系电话快照', package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '体积(立方米)', source_stage VARCHAR(32) NOT NULL COMMENT '数据来源阶段', @@ -1346,6 +1379,7 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_item ( SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='carton_marks'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN carton_marks VARCHAR(4000) NULL COMMENT ''换标箱唛映射快照'' AFTER source_stage')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='source_business_type'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN source_business_type VARCHAR(32) NULL COMMENT ''来源业务类型'' AFTER carton_marks')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='source_business_no'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN source_business_no VARCHAR(128) NULL COMMENT ''来源业务单号'' AFTER source_business_type')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA或PRIVATE'' AFTER fba_warehouse_code, ADD COLUMN delivery_address VARCHAR(1000) NULL COMMENT ''私人仓目的地址快照'' AFTER destination_type, ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'' AFTER delivery_address, ADD COLUMN contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人快照'' AFTER postal_code, ADD COLUMN contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话快照'' AFTER contact_name')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; CREATE TABLE IF NOT EXISTS ll_dispatch_pallet_lock ( id BIGINT NOT NULL COMMENT '主键ID', @@ -1521,7 +1555,11 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_order ( transfer_no VARCHAR(64) NOT NULL COMMENT '转仓单号', inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', lastleg_order_id BIGINT NOT NULL COMMENT '尾程订单ID', lastleg_order_no VARCHAR(32) NOT NULL COMMENT '尾程订单号', operating_warehouse_id VARCHAR(128) NOT NULL COMMENT '入库中转仓ID', fba_warehouse_code VARCHAR(128) NULL COMMENT '可选FBA目的仓编码', - delivery_address VARCHAR(1000) NOT NULL COMMENT '提货地址或中转仓收货地址', package_count INT NOT NULL COMMENT '箱数', delivery_method VARCHAR(64) NOT NULL COMMENT '入仓方式', + destination_type VARCHAR(16) NULL COMMENT '目的类型:FBA或PRIVATE', destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', + destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + private_destination_address VARCHAR(1000) NULL COMMENT '私人仓完整地址', private_destination_postal_code VARCHAR(32) NULL COMMENT '私人仓邮编', private_destination_contact_name VARCHAR(128) NULL COMMENT '私人仓联系人', + private_destination_contact_phone VARCHAR(64) NULL COMMENT '私人仓联系电话', + delivery_address VARCHAR(1000) NOT NULL COMMENT '提货地址或中转仓收货地址', pickup_contact_name VARCHAR(128) NULL COMMENT '提货联系人', pickup_contact_phone VARCHAR(64) NULL COMMENT '提货联系电话', package_count INT NOT NULL COMMENT '箱数', delivery_method VARCHAR(64) NOT NULL COMMENT '入仓方式', estimated_arrival_at DATETIME(3) NOT NULL COMMENT '预计入仓时间', fba_shipment_no VARCHAR(128) NULL COMMENT '可选FBA货件编号', tracking_no VARCHAR(255) NULL COMMENT '物流跟踪号', fba_box_nos VARCHAR(2000) NULL COMMENT 'FBA箱号集合', remark VARCHAR(1000) NULL COMMENT '备注', need_relabel TINYINT NOT NULL DEFAULT 0 COMMENT '是否需要换标', relabel_application_id BIGINT NULL COMMENT '换标申请单ID', relabel_no VARCHAR(64) NULL COMMENT '换标申请单号', @@ -1559,6 +1597,10 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE transfer_outbound_at_stmt FROM @ddl; EXECUTE transfer_outbound_at_stmt; DEALLOCATE PREPARE transfer_outbound_at_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='estimated_ship_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN estimated_ship_at DATETIME(3) NULL COMMENT ''预计发货时间'' AFTER estimated_arrival_at')); PREPARE transfer_estimated_ship_stmt FROM @ddl; EXECUTE transfer_estimated_ship_stmt; DEALLOCATE PREPARE transfer_estimated_ship_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='pickup_contact_name'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN pickup_contact_name VARCHAR(128) NULL COMMENT ''提货联系人'' AFTER delivery_address')); +PREPARE transfer_pickup_contact_name_stmt FROM @ddl; EXECUTE transfer_pickup_contact_name_stmt; DEALLOCATE PREPARE transfer_pickup_contact_name_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='pickup_contact_phone'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN pickup_contact_phone VARCHAR(64) NULL COMMENT ''提货联系电话'' AFTER pickup_contact_name')); +PREPARE transfer_pickup_contact_phone_stmt FROM @ddl; EXECUTE transfer_pickup_contact_phone_stmt; DEALLOCATE PREPARE transfer_pickup_contact_phone_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='label_deadline'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN label_deadline DATETIME(3) NULL COMMENT ''换标截止时间'', ADD COLUMN relabel_contact_name VARCHAR(128) NULL COMMENT ''换标联系人'', ADD COLUMN relabel_contact_phone VARCHAR(64) NULL COMMENT ''换标联系电话'', ADD COLUMN relabel_charge_amount DECIMAL(20,2) NULL COMMENT ''换标费用CNY'', ADD COLUMN relabel_delivery_method VARCHAR(32) NULL COMMENT ''换标后派送方式''')); PREPARE transfer_relabel_config_stmt FROM @ddl; EXECUTE transfer_relabel_config_stmt; DEALLOCATE PREPARE transfer_relabel_config_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='relabel_completed_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN relabel_completed_at DATETIME(3) NULL COMMENT ''关联贴标完成时间'' AFTER relabel_no')); @@ -1567,6 +1609,20 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE transfer_fulfillment_stmt FROM @ddl; EXECUTE transfer_fulfillment_stmt; DEALLOCATE PREPARE transfer_fulfillment_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='review_decision'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN review_decision VARCHAR(32) NULL COMMENT ''审核决定'' AFTER status, ADD COLUMN review_remark VARCHAR(1000) NULL COMMENT ''审核备注或驳回原因'' AFTER review_decision, ADD COLUMN reviewed_by VARCHAR(128) NULL COMMENT ''审核人ID'' AFTER review_remark, ADD COLUMN reviewed_at DATETIME(3) NULL COMMENT ''审核时间'' AFTER reviewed_by')); PREPARE transfer_review_fact_stmt FROM @ddl; EXECUTE transfer_review_fact_stmt; DEALLOCATE PREPARE transfer_review_fact_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN destination_type VARCHAR(16) NULL COMMENT ''目的类型:FBA或PRIVATE'' AFTER fba_warehouse_code, ADD COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''目的仓ID快照'' AFTER destination_type, ADD COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''目的仓编码快照'' AFTER destination_warehouse_id, ADD COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''目的仓名称快照'' AFTER destination_warehouse_code, ADD COLUMN private_destination_address VARCHAR(1000) NULL COMMENT ''私人仓完整地址'' AFTER destination_warehouse_name, ADD COLUMN private_destination_postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编'' AFTER private_destination_address, ADD COLUMN private_destination_contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人'' AFTER private_destination_postal_code, ADD COLUMN private_destination_contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话'' AFTER private_destination_contact_name, ADD COLUMN destination_binding_idempotency_key VARCHAR(255) NULL COMMENT ''目的地绑定幂等键'', ADD COLUMN destination_binding_request_hash CHAR(64) NULL COMMENT ''目的地绑定请求哈希''')); +PREPARE transfer_destination_stmt FROM @ddl; EXECUTE transfer_destination_stmt; DEALLOCATE PREPARE transfer_destination_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='delivery_address'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN delivery_address VARCHAR(1000) NULL COMMENT ''私人仓目的地址快照'', ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'', ADD COLUMN contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人快照'', ADD COLUMN contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话快照''')); +PREPARE transfer_line_destination_stmt FROM @ddl; EXECUTE transfer_line_destination_stmt; DEALLOCATE PREPARE transfer_line_destination_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA或PRIVATE'', ADD COLUMN delivery_address VARCHAR(1000) NULL COMMENT ''私人仓目的地址快照'', ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'', ADD COLUMN contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人快照'', ADD COLUMN contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话快照''')); +PREPARE transfer_pallet_destination_stmt FROM @ddl; EXECUTE transfer_pallet_destination_stmt; DEALLOCATE PREPARE transfer_pallet_destination_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='private_destination_postal_code'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN private_destination_postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编'' AFTER private_destination_address')); +PREPARE transfer_postal_stmt FROM @ddl; EXECUTE transfer_postal_stmt; DEALLOCATE PREPARE transfer_postal_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='postal_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'' AFTER delivery_address')); +PREPARE transfer_line_postal_stmt FROM @ddl; EXECUTE transfer_line_postal_stmt; DEALLOCATE PREPARE transfer_line_postal_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='postal_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'' AFTER delivery_address')); +PREPARE transfer_pallet_postal_stmt FROM @ddl; EXECUTE transfer_pallet_postal_stmt; DEALLOCATE PREPARE transfer_pallet_postal_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='postal_code'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'' AFTER delivery_address')); +PREPARE dispatch_item_postal_stmt FROM @ddl; EXECUTE dispatch_item_postal_stmt; DEALLOCATE PREPARE dispatch_item_postal_stmt; CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_source_order ( id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', @@ -1603,18 +1659,22 @@ PREPARE transfer_customer_carton_stmt FROM @ddl; EXECUTE transfer_customer_carto CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet ( id BIGINT NOT NULL COMMENT '打托事实ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', sequence_no INT NOT NULL COMMENT '托序号', - warehouse_pallet_id BIGINT NOT NULL COMMENT '海外仓真实托盘ID', pallet_no VARCHAR(128) NOT NULL COMMENT '真实托盘码', - warehouse_location_id BIGINT NOT NULL COMMENT '真实库位ID', warehouse_location_code VARCHAR(128) NULL COMMENT '真实库位码', - warehouse_resource_lock_id BIGINT NOT NULL COMMENT '仓储资源锁ID', carton_count INT NOT NULL COMMENT '本托实收箱数', + warehouse_pallet_id BIGINT NULL COMMENT '生成后的海外仓真实托盘ID', pallet_no VARCHAR(128) NULL COMMENT '生成后的真实托盘码', + warehouse_location_id BIGINT NULL COMMENT '生成后的真实库位ID', warehouse_location_code VARCHAR(128) NULL COMMENT '真实库位码', + warehouse_resource_lock_id BIGINT NULL COMMENT '生成后的仓储资源锁ID', carton_count INT NOT NULL COMMENT '本托实收箱数', length_cm DECIMAL(12,2) NOT NULL COMMENT '实测长cm', width_cm DECIMAL(12,2) NOT NULL COMMENT '实测宽cm', height_cm DECIMAL(12,2) NOT NULL COMMENT '实测高cm', gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '实测毛重kg', - volume_cbm DECIMAL(18,6) NOT NULL COMMENT '实测体积CBM', status VARCHAR(32) NOT NULL COMMENT '打托状态', - completed_at DATETIME(3) NOT NULL COMMENT 'PDA打托完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + volume_cbm DECIMAL(18,6) NOT NULL COMMENT '实测体积CBM', status VARCHAR(32) NOT NULL COMMENT '逐托状态DRAFT/GENERATED/BOUND/COMPLETED', + draft_idempotency_key VARCHAR(255) NULL COMMENT '草稿保存幂等键', draft_request_hash CHAR(64) NULL COMMENT '草稿请求摘要', + generate_idempotency_key VARCHAR(255) NULL COMMENT '新托盘生成幂等键', bind_idempotency_key VARCHAR(255) NULL COMMENT '扫码绑定幂等键', + bound_at DATETIME(3) NULL COMMENT '扫码确认绑定时间', completed_at DATETIME(3) NULL COMMENT '整单打托完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_transfer_pallet_seq(transfer_order_id,sequence_no), UNIQUE KEY uk_ll_transfer_pallet_physical(customer_id,warehouse_pallet_id), KEY idx_ll_transfer_pallet_order(customer_id,transfer_order_id,status) ) ENGINE=InnoDB COMMENT='仓储转运PDA实测打托事实'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_pallet' AND column_name='draft_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_pallet MODIFY COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''生成后的海外仓真实托盘ID'', MODIFY COLUMN pallet_no VARCHAR(128) NULL COMMENT ''生成后的真实托盘码'', MODIFY COLUMN warehouse_location_id BIGINT NULL COMMENT ''生成后的真实库位ID'', MODIFY COLUMN warehouse_resource_lock_id BIGINT NULL COMMENT ''生成后的仓储资源锁ID'', MODIFY COLUMN completed_at DATETIME(3) NULL COMMENT ''整单打托完成时间'', ADD COLUMN draft_idempotency_key VARCHAR(255) NULL COMMENT ''草稿保存幂等键'' AFTER status, ADD COLUMN draft_request_hash CHAR(64) NULL COMMENT ''草稿请求摘要'' AFTER draft_idempotency_key, ADD COLUMN generate_idempotency_key VARCHAR(255) NULL COMMENT ''新托盘生成幂等键'' AFTER draft_request_hash, ADD COLUMN bind_idempotency_key VARCHAR(255) NULL COMMENT ''扫码绑定幂等键'' AFTER generate_idempotency_key, ADD COLUMN bound_at DATETIME(3) NULL COMMENT ''扫码确认绑定时间'' AFTER bind_idempotency_key')); +PREPARE transfer_pallet_lifecycle_stmt FROM @ddl; EXECUTE transfer_pallet_lifecycle_stmt; DEALLOCATE PREPARE transfer_pallet_lifecycle_stmt; CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet_item ( id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', @@ -1625,6 +1685,18 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet_item ( KEY idx_ll_transfer_pallet_item_order(customer_id,transfer_order_id) ) ENGINE=InnoDB COMMENT='仓储转运每托SKU箱数回填'; +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_measurement_photo ( + id BIGINT NOT NULL COMMENT '测量照片引用ID', transfer_order_id BIGINT NOT NULL COMMENT '仓储转运单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', file_id BIGINT NOT NULL COMMENT '文件服务文件ID', + file_name VARCHAR(255) NULL COMMENT '文件名快照', content_type VARCHAR(128) NULL COMMENT '文件媒体类型快照', + size_bytes BIGINT NOT NULL DEFAULT 0 COMMENT '文件字节数快照', uploaded_by VARCHAR(128) NULL COMMENT 'PDA上传操作人', + uploaded_at DATETIME(3) NOT NULL COMMENT '上传时间', submitted_at DATETIME(3) NULL COMMENT '随测量打托提交时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_transfer_measurement_photo_file(customer_id,transfer_order_id,file_id), + KEY idx_ll_transfer_measurement_photo_order(customer_id,transfer_order_id,uploaded_at) +) ENGINE=InnoDB COMMENT='仓储转运PDA测量现场照片引用'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_measurement_photo' AND column_name='submitted_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_measurement_photo ADD COLUMN submitted_at DATETIME(3) NULL COMMENT ''随测量打托提交时间'' AFTER uploaded_at')); +PREPARE transfer_measurement_photo_submitted_stmt FROM @ddl; EXECUTE transfer_measurement_photo_submitted_stmt; DEALLOCATE PREPARE transfer_measurement_photo_submitted_stmt; + CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_relabel_binding ( id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', operation_type VARCHAR(32) NOT NULL COMMENT '换标类型', 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 16fea794d..ebfcaac98 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -1310,7 +1310,7 @@ paths: /api/merchant/v1/last-leg/relabel-applications/inventory-candidates: get: summary: 查询客户可换标库存候选 - description: 权限 merchant:warehouse:relabel;仅返回当前客户在指定海外中转仓仍有可用箱数的历史入仓单和库位摘要。候选按客户 Token 隔离,不接收 customerId;inboundCode 可选精确筛选。 + description: 权限 merchant:warehouse:relabel;返回当前客户在指定海外中转仓仍有可用箱数的历史入仓库存,以及仓库合箱后生成的可用退货箱库存。候选按客户 Token 隔离,不接收 customerId;inboundCode 可按入仓码、退货单号或仓库唛头模糊筛选。 security: [{ bearerAuth: [] }] parameters: - { name: operationWarehouseId, in: query, required: true, schema: { type: string, maxLength: 128 } } @@ -2531,6 +2531,19 @@ components: availablePackages: { type: integer, format: int64 } version: { type: integer, format: int64 } positions: { type: array, items: { $ref: '#/components/schemas/RelabelInventoryPosition' } } + sourceType: { type: string, enum: [INBOUND, RETURN_CARTON] } + returnCartonId: { type: string, nullable: true, description: 退货合箱库存主键;sourceType=RETURN_CARTON 时提供 } + cartonMark: { type: string, nullable: true, description: 仓库合箱后打印并贴箱的仓库唛头 } + skuItems: + type: array + description: 退货箱内 SKU 与件数 + items: + type: object + properties: + skuCode: { type: string } + newSkuCode: { type: string, nullable: true } + quantity: { type: integer, minimum: 1 } + remark: { type: string, nullable: true } RelabelInventoryPosition: type: object properties: @@ -2559,10 +2572,10 @@ components: remark: { type: string, nullable: true, maxLength: 1000 } RelabelCreate: type: object - required: [customerOrderNo, labelDeadline, contactName, contactPhone, idempotencyKey, items] + required: [customerOrderNo, operationWarehouseId, labelDeadline, contactName, contactPhone, idempotencyKey, items] properties: customerOrderNo: { type: string, maxLength: 128 } - overseasWarehouseId: { type: string, nullable: true, maxLength: 128, description: 统一目的海外仓;明细未填写目的仓时使用 } + operationWarehouseId: { type: string, maxLength: 128, description: 当前库存所在海外操作仓 } labelDeadline: { type: string, format: date-time } contactName: { type: string, maxLength: 128 } contactPhone: { type: string, maxLength: 64 } @@ -2576,6 +2589,10 @@ components: type: object required: [productNameZh, totalPackageCount, notifiedPackageCount, shipmentNo, newSkuCode, cartonMark, fbaCartonMark] properties: + sourceInventoryBalanceId: { type: string, nullable: true, description: 普通入仓库存来源;与 returnCartonId 二选一 } + sourceInventoryPositionId: { type: string, nullable: true, description: 普通入仓库存仓位;与 returnCartonId 二选一 } + sourceInboundOrderId: { type: string, nullable: true, description: 普通入仓单;与 returnCartonId 二选一 } + returnCartonId: { type: string, nullable: true, description: 仓库合箱产生的退货箱库存;与普通入仓库存字段二选一 } skuCode: { type: string, nullable: true, maxLength: 128, description: 商品 SKU(选填) } productImageFileId: { type: string, nullable: true, maxLength: 128 } productImageFileName: { type: string, nullable: true, maxLength: 255 } diff --git a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml index bb904ef34..f1ae643ca 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml @@ -163,12 +163,32 @@ paths: summary: 扫码查询托盘及库存内容 requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ScanCode' } } } } responses: { '200': { description: Token绑定仓托盘内容 }, '404': { description: 托盘不存在(统一响应实际 HTTP 200,body.code=A0404), content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } } } } + /api/pda/v1/last-leg/pallet-operations/relabel-sources: + post: + summary: 查询换标原入仓单在各托盘上的库存数量 + description: 范围取Token冻结仓和换标单客户;返回库存组成而非历史箱唛定位。movablePackages不包括其他单据冻结、已分配或已拣货箱数。cartonMarks为同来源库存池候选,不代表这些箱历史上位于该托盘。 + requestBody: + required: true + content: + application/json: + schema: { type: object, required: [relabelNo], properties: { relabelNo: { type: string, maxLength: 64 } } } + responses: + '200': + description: 原入仓单托盘组成 + content: + application/json: + schema: + type: object + properties: + code: { type: string } + data: { type: array, items: { $ref: '#/components/schemas/RelabelSource' } } + '409': { description: 状态、认领人或仓库不符 } /api/pda/v1/last-leg/pallet-operations/relabel-pallet-label: post: summary: 为换标拆托幂等生成并返回可打印的新托盘标签 description: 返回空托盘编码、初始版本和打印HTML;随后可将该托盘作为relabel-disassemble的target。库存移入后仍继承原库存余额对应的客户和入库单。 requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/NewRelabelPallet' } } } } - responses: { '200': { description: 新托盘及可打印标签 }, '409': { description: 换标单未确认或不属于Token绑定仓 } } + responses: { '200': { description: 新托盘及可打印标签,已认领处理中无需客户确认即可创建 }, '409': { description: 状态、认领人或Token绑定仓不符 } } /api/pda/v1/last-leg/pallet-operations/assemble: post: summary: 将多个来源托盘的指定库存行组到目标托盘 @@ -182,7 +202,7 @@ paths: /api/pda/v1/last-leg/pallet-operations/relabel-disassemble: post: summary: 按换标箱唛拆托并绑定目标托盘 - description: PDA先扫描原托盘,再扫描换标单指定箱唛;服务端按换标单库存快照解析仓位,在同一事务中扣减原托盘并绑定目标托盘。目标托盘须先由仓储服务创建后扫码确认。 + description: PDA选择原入仓单库存行并扫描实际来源托盘,输入拆出箱数,核对本批cartonMarks后扫描目标托盘。传actualSource时以其数量和版本为准,同入仓余额及批次内原子调整本单冻结记账位置,再移动冻结库存和换标明细;不新增入库、不改变库存归属、不占用其他单据冻结量。每次一个来源库存行,可分批处理多个托盘。已有出库单时不允许此数量重分配。未传actualSource的箱唛模式仍要求已有来源仓位一致。 requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelPalletDisassemble' } } } } responses: { '200': { description: 箱唛级拆托绑定结果 }, '409': { description: 箱唛、库存或托盘版本冲突 } } /api/pda/v1/last-leg/pallet-operations/swap: @@ -218,6 +238,27 @@ paths: parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelUnit' } } } } responses: { '200': { description: 更新后的任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } }, '409': { description: 旧标重复、新标不符或版本冲突 } } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/skus/scan: + post: + summary: 仅扫描旧SKU完成当前箱的一条SKU换标明细 + description: itemId与skuIndex精确指定一条明细,服务端验证旧SKU并读取已批准的新SKU及件数;同旧SKU多箱由PDA选择目标,不批量隐式完成。逐条写SKU_SCAN历史并按轮次防重,全部SKU处理后完成SKU阶段,混合单仍须完成箱唛阶段。响应history包含旧新SKU、件数及索引,供提示与隐藏已处理明细。 + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [itemId, skuIndex, oldSku, expectedVersion, idempotencyKey] + properties: + itemId: { type: string, pattern: '^[0-9]+$' } + skuIndex: { type: integer, minimum: 0 } + oldSku: { type: string, maxLength: 255 } + expectedVersion: { type: integer, minimum: 0 } + idempotencyKey: { type: string, maxLength: 255 } + responses: + '200': { description: 更新后的任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } } + '409': { description: 已处理、映射不符、认领人或版本冲突 } /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/units/skip: post: summary: 登记一件有原因的跳过事实 @@ -260,6 +301,107 @@ paths: parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelReview' } } } } responses: { '200': { description: COMPLETED或PROCESSING任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } }, '409': { description: 状态或版本冲突 } } + /api/pda/v1/last-leg/warehouse-transfers/candidates: + get: + summary: 查询Token绑定仓待测量的仓储转运单 + description: 仅返回当前PDA冻结海外仓内已审核且等待测量打托的转运单;不接收customerId或warehouseId。 + responses: + '200': + description: 待测量转运单列表 + content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseTransferSummaryListResponse' } } } + /api/pda/v1/last-leg/warehouse-transfers/{transferNo}: + get: + summary: 查询当前仓仓储转运测量详情 + parameters: [{ name: transferNo, in: path, required: true, schema: { type: string } }] + responses: + '200': + description: 转运申报、已生成的新托盘及测量照片;items仅为服务端自动分摊结果,PDA不提交每托SKU。 + content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseTransferDetailResponse' } } } + '404': { description: 当前Token绑定仓无此转运单 } + /api/pda/v1/last-leg/warehouse-transfers/{transferNo}/measurement-photos/files: + get: + summary: 查询当前转运单已上传的测量现场照片 + parameters: [{ name: transferNo, in: path, required: true, schema: { type: string } }] + responses: + '200': { description: 照片元数据列表 } + post: + summary: 上传仓储转运测量现场照片 + parameters: [{ name: transferNo, in: path, required: true, schema: { type: string } }] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: { file: { type: string, format: binary } } + responses: + '200': { description: 已绑定当前转运单的照片元数据 } + '404': { description: 当前Token绑定仓无此转运单 } + /api/pda/v1/last-leg/warehouse-transfers/{transferNo}/measurement-photos/files/{fileId}/download: + get: + summary: 鉴权下载仓储转运测量现场照片 + parameters: + - { name: transferNo, in: path, required: true, schema: { type: string } } + - { name: fileId, in: path, required: true, schema: { type: integer, format: int64 } } + responses: + '200': { description: 照片内容, content: { 'image/*': { schema: { type: string, format: binary } } } } + '404': { description: 照片不属于当前转运单或绑定仓 } + /api/pda/v1/last-leg/warehouse-transfers/{transferNo}/palletize: + post: + summary: 完成仓储转运整单测量打托 + description: 只提交当前转运单照片和整单幂等键;所有托盘必须已逐托生成并扫码绑定,箱数必须核平。此接口不接收测量字段、托盘码或每托SKU,也绝不再次生成托盘。 + parameters: [{ name: transferNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseTransferPalletize' } } } } + responses: + '200': + description: WAIT_CUSTOMER_CONFIRMATION详情 + content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseTransferDetailResponse' } } } + '409': { description: 状态、照片归属、箱数核平或幂等冲突 } + /api/pda/v1/last-leg/warehouse-transfers/{transferNo}/pallets/drafts: + post: + summary: 新建或更新一个转运测量托盘草稿 + description: palletId为空时创建DRAFT;传入既有DRAFT的palletId时更新。GENERATED及以后测量字段锁定,必须拒绝修改。 + parameters: [{ name: transferNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseTransferPalletDraft' } } } } + responses: { '200': { description: 包含持久化草稿的最新整单详情 }, '409': { description: 草稿已生成、状态或幂等冲突 } } + /api/pda/v1/last-leg/warehouse-transfers/{transferNo}/pallets/{palletId}: + delete: + summary: 删除未生成的转运托盘草稿 + description: 只允许删除DRAFT;GENERATED、BOUND和COMPLETED不得删除。 + parameters: + - { name: transferNo, in: path, required: true, schema: { type: string } } + - { name: palletId, in: path, required: true, schema: { type: integer, format: int64 } } + responses: { '200': { description: 删除后的整单详情 }, '409': { description: 本托已经生成真实托盘 } } + /api/pda/v1/last-leg/warehouse-transfers/{transferNo}/pallets/{palletId}/generate: + post: + summary: 为测量草稿生成一个全新实物托盘 + description: 只允许DRAFT调用一次;服务端始终向Warehouse Overseas传空palletCode,不能复用已有托盘。有FBA则写入目的仓,无FBA则标签显示“目的仓待定”。成功后实测字段锁定且可立即下载PDF标签。 + parameters: + - { name: transferNo, in: path, required: true, schema: { type: string } } + - { name: palletId, in: path, required: true, schema: { type: integer, format: int64 } } + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseTransferPalletAction' } } } } + responses: { '200': { description: 包含GENERATED新托盘号和标签路径的最新详情 }, '409': { description: 已生成或资源生成冲突 } } + /api/pda/v1/last-leg/warehouse-transfers/{transferNo}/pallets/{palletId}/bind: + post: + summary: 扫描并确认本托新生成的托盘码 + description: 扫描码必须精确等于本草稿服务端生成码,同时验证Warehouse Overseas物理托盘ID和Token冻结仓一致;其他已有托盘不得冒充。 + parameters: + - { name: transferNo, in: path, required: true, schema: { type: string } } + - { name: palletId, in: path, required: true, schema: { type: integer, format: int64 } } + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseTransferPalletBind' } } } } + responses: { '200': { description: 包含BOUND事实和boundAt的最新详情 }, '409': { description: 扫描码、归属、仓库或状态不匹配 } } + /api/pda/v1/last-leg/warehouse-transfers/{transferNo}/pallets/{warehousePalletId}/label: + get: + summary: 下载仓储转运实物托盘PDF标签 + description: 校验Token冻结仓、转运单和托盘归属后,代理Warehouse Overseas使用统一托盘标签打印服务生成真实PDF。已知FBA显示目的仓编码,未知FBA显示“目的仓待定”。 + parameters: + - { name: transferNo, in: path, required: true, schema: { type: string } } + - { name: warehousePalletId, in: path, required: true, schema: { type: integer, format: int64 } } + - { name: X-Request-Id, in: header, required: false, schema: { type: string } } + responses: + '200': { description: 托盘标签PDF, content: { application/pdf: { schema: { type: string, format: binary } } } } + '404': { description: 转运单或托盘不属于当前Token绑定仓 } components: securitySchemes: bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } @@ -274,6 +416,107 @@ components: requestId: { type: string, nullable: true } traceId: { type: string, nullable: true } timestamp: { type: string, format: date-time, nullable: true } + WarehouseTransferSummaryListResponse: + allOf: + - { $ref: '#/components/schemas/ApiResponse' } + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/WarehouseTransferSummary' } } + WarehouseTransferDetailResponse: + allOf: + - { $ref: '#/components/schemas/ApiResponse' } + - type: object + properties: { data: { $ref: '#/components/schemas/WarehouseTransferDetail' } } + WarehouseTransferSummary: + type: object + required: [id, transferNo, operatingWarehouseId, packageCount, deliveryMethod, needRelabel, status] + properties: + id: { type: integer, format: int64 } + transferNo: { type: string } + inboundCode: { type: string, nullable: true } + operatingWarehouseId: { type: string } + fbaWarehouseCode: { type: string, nullable: true, description: 为空表示目的仓待定 } + packageCount: { type: integer, minimum: 1 } + deliveryMethod: { type: string } + needRelabel: { type: boolean } + relabelNo: { type: string, nullable: true } + status: { type: string } + estimatedArrivalAt: { type: string, format: date-time, nullable: true } + createdAt: { type: string, format: date-time, nullable: true } + WarehouseTransferDetail: + type: object + required: [id, transferNo, operatingWarehouseId, packageCount, status, pallets, measurementPhotos] + properties: + id: { type: integer, format: int64 } + transferNo: { type: string } + operatingWarehouseId: { type: string } + fbaWarehouseCode: { type: string, nullable: true, description: 为空表示目的仓待定 } + pickupContactName: { type: string, nullable: true } + pickupContactPhone: { type: string, nullable: true } + packageCount: { type: integer, minimum: 1 } + status: { type: string } + pallets: { type: array, items: { $ref: '#/components/schemas/WarehouseTransferPallet' } } + measurementPhotos: { type: array, items: { $ref: '#/components/schemas/WarehouseTransferMeasurementPhoto' } } + WarehouseTransferPalletize: + type: object + required: [idempotencyKey, photoFileIds] + properties: + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + photoFileIds: + type: array + minItems: 1 + maxItems: 20 + uniqueItems: true + items: { type: integer, format: int64, minimum: 1 } + WarehouseTransferPalletDraft: + type: object + required: [idempotencyKey, cartonCount, lengthCm, widthCm, heightCm, volumeCbm, grossWeightKg] + properties: + palletId: { type: integer, format: int64, nullable: true, description: 更新现有DRAFT时传入;创建时不传 } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + cartonCount: { type: integer, minimum: 1 } + lengthCm: { type: number, format: double, minimum: 0.01 } + widthCm: { type: number, format: double, minimum: 0.01 } + heightCm: { type: number, format: double, minimum: 0.01 } + volumeCbm: { type: number, format: double, minimum: 0.000001 } + grossWeightKg: { type: number, format: double, minimum: 0.01 } + WarehouseTransferPalletAction: + type: object + required: [idempotencyKey] + properties: { idempotencyKey: { type: string, minLength: 1, maxLength: 255 } } + WarehouseTransferPalletBind: + type: object + required: [palletCode, idempotencyKey] + properties: + palletCode: { type: string, minLength: 1, maxLength: 128 } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + WarehouseTransferPallet: + type: object + required: [id, sequenceNo, cartonCount, lengthCm, widthCm, heightCm, volumeCbm, grossWeightKg, status] + properties: + id: { type: integer, format: int64 } + sequenceNo: { type: integer, minimum: 1 } + warehousePalletId: { type: integer, format: int64, nullable: true } + palletNo: { type: string, nullable: true, description: GENERATED后由Warehouse Overseas为本次测量新生成 } + cartonCount: { type: integer, minimum: 1 } + lengthCm: { type: number, format: double } + widthCm: { type: number, format: double } + heightCm: { type: number, format: double } + volumeCbm: { type: number, format: double } + grossWeightKg: { type: number, format: double } + status: { type: string, enum: [DRAFT, GENERATED, BOUND, COMPLETED] } + fbaWarehouseCode: { type: string, nullable: true, description: 为空时标签显示目的仓待定 } + labelDownloadPath: { type: string, nullable: true, description: GENERATED后返回当前PDA鉴权下的真实PDF下载路径 } + boundAt: { type: string, format: date-time, nullable: true } + WarehouseTransferMeasurementPhoto: + type: object + required: [fileId, sizeBytes, uploadedAt] + properties: + fileId: { type: integer, format: int64 } + fileName: { type: string, nullable: true } + contentType: { type: string, nullable: true } + sizeBytes: { type: integer, format: int64, minimum: 0 } + uploadedAt: { type: string, format: date-time } StocktakeSummaryPageResponse: allOf: - { $ref: '#/components/schemas/ApiResponse' } @@ -732,7 +975,19 @@ components: source: { $ref: '#/components/schemas/PalletExpectation' } target: { $ref: '#/components/schemas/PalletExpectation' } cartonMarks: { type: array, minItems: 1, maxItems: 500, uniqueItems: true, items: { type: string, maxLength: 255 } } + actualSource: { $ref: '#/components/schemas/PalletMoveLine' } reason: { type: string, maxLength: 500 } + RelabelSource: + type: object + properties: + positionId: { type: string, pattern: '^[0-9]+$' } + positionVersion: { type: integer, format: int64 } + palletCode: { type: string } + locationCode: { type: string, nullable: true } + inboundCode: { type: string } + stockPackages: { type: integer, format: int64 } + movablePackages: { type: integer, format: int64 } + cartonMarks: { type: array, items: { type: string } } PalletSwap: type: object required: [idempotencyKey, source, target] diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java index 0af56242b..d3bd1ed4e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java @@ -67,6 +67,7 @@ class DeliveryLongIdJsonContractTest { null, null, "PS-1", null, null, null, null, "CREATED", null, null, null, null, null, null, null, null, null, null, null, null, 0, 0, null, null, null, null, null, null, null, null, 0, java.util.List.of(), null, - com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(null), null, null, null); + com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(null), null, null, null, + null, null, null, null, null); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java index 1251a4f31..2d119917d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java @@ -19,6 +19,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; class DeliveryCompletedOutboxServiceTest { @Test @@ -48,6 +49,23 @@ class DeliveryCompletedOutboxServiceTest { .containsExactly("ONT8", "ACY2"); } + @Test + void skipsRelabelApplicationWithoutDomesticSourceOrder() { + LastlegEventOutboxMapper mapper = mock(LastlegEventOutboxMapper.class); + DeliveryCompletedOutboxService service = new DeliveryCompletedOutboxService(); + ReflectionTestUtils.setField(service, "outbox", mapper); + ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().registerModule(new JavaTimeModule())); + DeliveryOrder delivery = new DeliveryOrder(); + delivery.setId(1L); delivery.setDeliveryNo("DL-RELABEL"); delivery.setVersion(2L); + LastlegOrder relabel = new LastlegOrder(); + relabel.setId(21L); relabel.setOrderType("RELABEL_APPLICATION"); + relabel.setCustomerId("88"); relabel.setSourceInboundId("RL-1:WH-1"); + + service.publish(delivery, List.of(relabel), Instant.parse("2026-09-15T08:00:00Z")); + + verifyNoInteractions(mapper); + } + private LastlegOrder order(long id, String inboundId, String orderId, String inboundCode, String fba) { LastlegOrder value = new LastlegOrder(); value.setId(id); value.setOrderNo("LM-" + id); value.setCustomerId("88"); 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 7cb4ac1b5..1871214e0 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 @@ -15,7 +15,8 @@ class DispatchCandidateMapperContractTest { "planNo", "orderType", "volumeWeightKg", "containerNo", "appointmentAt", "expectedLoadingAt", "overseasWarehouseId", "overseasWarehouseName", "orderNo", "orderRefs", "inboundCode", "palletNo", "destinationWarehouseId", "destinationWarehouseCode", "destinationWarehouseName", "fbaWarehouseCode", - "packageCount", "volumeCbm", "sourceStage", "cartonMarks", "relabelCartonMarks" + "packageCount", "volumeCbm", "sourceStage", "cartonMarks", "relabelCartonMarks", + "destinationType", "deliveryAddress", "postalCode", "contactName", "contactPhone" ); @Test @@ -37,6 +38,29 @@ class DispatchCandidateMapperContractTest { assertTrue(sql.contains("pos.available_packages > 0")); } + @Test + void candidatesWithoutFbaWarehouseAreExcludedFromReadAndWriteQueries() throws Exception { + for (Method method : List.of( + DispatchCandidateMapper.class.getMethod("available", String.class, List.class, String.class, + String.class, String.class, String.class), + DispatchCandidateMapper.class.getMethod("lockSelected", String.class, List.class))) { + String sql = String.join(" ", method.getAnnotation(Select.class).value()); + assertTrue(sql.contains("NULLIF(TRIM(l.fba_warehouse_code),'') IS NOT NULL")); + } + } + + @Test + void candidateKeywordSearchIncludesSourceInboundCode() throws Exception { + Method available = DispatchCandidateMapper.class.getMethod("available", String.class, List.class, + String.class, String.class, String.class, String.class); + String availableSql = String.join(" ", available.getAnnotation(Select.class).value()); + assertTrue(availableSql.contains("o.order_no LIKE CONCAT('%',#{orderNo},'%') OR l.inbound_code LIKE CONCAT('%',#{orderNo},'%')")); + + Method relabel = DispatchCandidateMapper.class.getMethod("relabelAvailable", String.class, String.class, String.class); + String relabelSql = String.join(" ", relabel.getAnnotation(Select.class).value()); + assertTrue(relabelSql.contains("ri.source_inbound_code LIKE CONCAT('%',#{orderNo},'%')")); + } + @Test void candidateWarehouseComesFromPhysicalInventoryOrFrozenInboundPlan() throws Exception { for (Method method : List.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 f40235374..bb869f860 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 @@ -190,6 +190,22 @@ class DispatchServiceTest { verify(orders, never()).insert(any(DispatchOrder.class)); } + @Test + void preArrangeRejectsPalletWithoutFbaWarehouse() { + DispatchPalletCandidate missingFba = new DispatchPalletCandidate(1L, 10L, 20L, 2L, + "WH-1", "Overseas warehouse", "LMO1", "LMO1", "RK1", "LIP1-P001", + null, null, null, null, 12, new BigDecimal("1.2000"), "RECEIVED"); + when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(missingFba)); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.preArrange("100", "7", + new DispatchRequests.PreArrange(List.of(10L), null, null, null, null))); + + assertEquals(409, exception.getStatusCode().value()); + assertEquals("所选托盘尚未设置 FBA 仓,不能智能排车", exception.getReason()); + verify(orders, never()).insert(any(DispatchOrder.class)); + } + @Test void removingPalletFromOwnedDraftImmediatelyReleasesLock() { when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8"))); @@ -307,6 +323,14 @@ class DispatchServiceTest { verify(candidates).available(null, null, null, null, "RECEIVED", "draft-1"); } + @Test + void relabelSourceDoesNotRequireOrQueryAnInboundPlan() { + when(candidates.relabelAvailable(null, "RL-1", null)).thenReturn(List.of()); + org.junit.jupiter.api.Assertions.assertTrue(service.platformCandidates("RL-1", null, "RELABEL", "draft-1").isEmpty()); + verify(candidates).relabelAvailable(null, "RL-1", null); + verify(candidates, never()).available(any(), any(), any(), any(), any(), any()); + } + @Test void resolvesCustomerFromSelectedPlansAndPalletsInMultiCustomerDeployment() { when(candidates.findCustomerScopesByPlanIds(List.of(1L, 2L))).thenReturn(List.of("100")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java index 0e3609ce5..d3e8d5b0b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java @@ -29,7 +29,8 @@ class InboundPlanSchemaContractTest { assertTrue(schema.contains("unique key uk_ll_fba_appointment_no (customer_id, appointment_no)")); assertTrue(schema.contains("expected_loading_at")); assertTrue(schema.contains("planned_arrival_date")); - assertTrue(schema.contains("creation_mode")); + assertTrue(schema.contains("creation_mode varchar(32)")); + assertTrue(schema.contains("modify column creation_mode varchar(32)")); assertTrue(schema.contains("pallet_type")); assertTrue(schema.contains("create table if not exists ll_order_command_record")); assertTrue(schema.contains("create table if not exists ll_order_hold_history")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java index 496ef1cc2..f074a326d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java @@ -25,7 +25,7 @@ class PdaPalletOperationControllerContractTest { .filter(PostMapping.class::isInstance).map(PostMapping.class::cast) .flatMap(annotation -> Arrays.stream(annotation.value())).collect(Collectors.toSet()); assertEquals(Set.of("/pallets/resolve", "/relabel-pallet-label", "/assemble", "/disassemble", - "/relabel-disassemble", "/swap", "/unbind"), paths); + "/relabel-disassemble", "/relabel-sources", "/swap", "/unbind"), paths); } @Test void palletContentAnnotationUsesNativeGreaterThanOperator() throws Exception { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java index a803f1fbb..7743d3207 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java @@ -42,6 +42,17 @@ import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class PdaPalletOperationServiceTest { + @Test void relabelRebindingRejectsDispatchAndOutboundOccupation() { + RelabelApplication app = new RelabelApplication(); + for (String status : List.of("PRE_DISPATCH_CREATED", "OUTBOUND_CREATED", "OUTBOUNDED")) { + app.setFulfillmentStatus(status); + assertThrows(ResponseStatusException.class, () -> ReflectionTestUtils.invokeMethod(service, "requireUnallocatedRelabel", app)); + } + app.setFulfillmentStatus("WAIT_PRE_DISPATCH"); + assertDoesNotThrow(() -> ReflectionTestUtils.invokeMethod(service, "requireUnallocatedRelabel", app)); + app.setOutboundOrderNo("OUT-1"); + assertThrows(ResponseStatusException.class, () -> ReflectionTestUtils.invokeMethod(service, "requireUnallocatedRelabel", app)); + } @Mock private PdaPalletOperationMapper mapper; @Mock private InventoryPositionMapper positions; @Mock private InventoryBalanceMapper balances; @@ -185,6 +196,13 @@ class PdaPalletOperationServiceTest { } @Test void relabelDisassembleMovesOnlySelectedHeldCartonWithoutRequiringLocations() { + var tasks = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class); + var histories = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper.class); + ReflectionTestUtils.setField(service, "relabelWorkTasks", tasks); + ReflectionTestUtils.setField(service, "relabelWorkHistory", histories); + var task = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask(); + task.setId(900L); task.setStatus("COMPLETED"); + when(tasks.find("100", 600L)).thenReturn(task); var sourcePallet = new PalletPhysicalGateway.PalletReference(20L, "SOURCE", 88L, null, "OCCUPIED", null, 1); var targetPallet = new PalletPhysicalGateway.PalletReference(10L, "TARGET", 88L, null, @@ -196,7 +214,7 @@ class PdaPalletOperationServiceTest { RelabelApplication application = new RelabelApplication(); application.setId(600L); application.setCustomerId("100"); application.setRelabelNo("RL-1"); application.setStatus("READY_FOR_OUTBOUND"); - application.setOverseasWarehouseId("88"); application.setOutboundOrderNo("OUT-1"); + application.setOverseasWarehouseId("88"); application.setFulfillmentStatus("WAIT_PRE_DISPATCH"); when(relabelApplications.findAnyByRelabelNo("RL-1")).thenReturn(application); when(relabelApplications.lockAnyByRelabelNo("RL-1")).thenReturn(application); RelabelItem selected = relabelItem(701L, "CARTON-A", 100L); @@ -266,6 +284,94 @@ class PdaPalletOperationServiceTest { relabelPalletMutations); } + @Test void actualSourceReconcilesOnlySelectedHoldAndKeepsOriginalInboundBalance() { + var tasks = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class); + var histories = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper.class); + ReflectionTestUtils.setField(service, "relabelWorkTasks", tasks); + ReflectionTestUtils.setField(service, "relabelWorkHistory", histories); + var task = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask(); task.setId(900L); task.setStatus("COMPLETED"); + when(tasks.find("100", 600L)).thenReturn(task); + RelabelApplication app = new RelabelApplication(); app.setId(600L); app.setRelabelNo("RL-1"); + app.setCustomerId("100"); app.setOverseasWarehouseId("88"); app.setStatus("READY_FOR_OUTBOUND"); + when(relabelApplications.findAnyByRelabelNo("RL-1")).thenReturn(app); + when(relabelApplications.lockAnyByRelabelNo("RL-1")).thenReturn(app); + RelabelItem item = relabelItem(701L, "CARTON-A", 101L); + when(relabelItems.selectList(any())).thenReturn(List.of(item)); + when(relabelItems.selectById(701L)).thenReturn(item); + InventoryPosition anchor = position(101L, 30L, "ACCOUNTING", 2L, 3L); + InventoryPosition actual = position(100L, 20L, "SOURCE", 0L, 4L); actual.setAvailablePackages(5L); + AtomicReference target = new AtomicReference<>(); + when(positions.selectById(anyLong())).thenAnswer(invocation -> { + long id = invocation.getArgument(0); return id == 101L ? anchor : id == 100L ? actual : target.get(); + }); + doAnswer(invocation -> { target.set(invocation.getArgument(0)); return 1; }).when(positions).insert(any(InventoryPosition.class)); + when(mapper.lockPosition(eq("88"), anyLong())).thenAnswer(invocation -> { + InventoryPosition p = ((Long) invocation.getArgument(1)) == 101L ? anchor : actual; + PalletInventoryRow row = row(p.getId(), p.getPalletId(), p.getVersion(), p.getAvailablePackages()); + row.setHoldPackages(p.getHoldPackages()); return row; + }); + var src = new PalletPhysicalGateway.PalletReference(20L, "SOURCE", 88L, null, "OCCUPIED", null, 1); + var dst = new PalletPhysicalGateway.PalletReference(10L, "TARGET", 88L, null, "IDLE", null, 2); + when(physical.resolve("SOURCE")).thenReturn(src); when(physical.resolve("TARGET")).thenReturn(dst); + when(physical.apply(eq(88L), any(), anyString(), isNull(), anyString(), any())) + .thenAnswer(invocation -> transition(invocation.getArgument(1), invocation.getArgument(2))); + when(mapper.content("88", 10L)).thenReturn(List.of()); + when(mapper.palletPackages("88", 20L)).thenAnswer(invocation -> actual.getAvailablePackages() + actual.getHoldPackages()); + when(mapper.palletPackages("88", 10L)).thenAnswer(invocation -> target.get() == null ? 0L : target.get().getHoldPackages()); + InventoryBalance balance = new InventoryBalance(); balance.setId(200L); balance.setCustomerId("100"); + balance.setOverseasWarehouseId("88"); balance.setInboundCode("ORIGINAL-INBOUND"); + when(balances.selectById(200L)).thenReturn(balance); + AtomicReference op = new AtomicReference<>(); + doAnswer(invocation -> { op.set(invocation.getArgument(0)); return 1; }).when(mapper).insert(any(PdaPalletOperation.class)); + when(mapper.selectById(anyLong())).thenAnswer(invocation -> op.get()); + var result = service.relabelDisassemble(identity(), new PalletOperationRequests.RelabelDisassemble( + "qty-1", "RL-1", new PalletOperationRequests.PalletExpectation("SOURCE", 1), + new PalletOperationRequests.PalletExpectation("TARGET", 2), List.of("CARTON-A"), "quantity", + new PalletOperationRequests.MoveLine(100L, 1L, 4L))); + assertEquals("COMPLETED", result.status()); + assertEquals(1L, anchor.getHoldPackages()); // Other order's hold is not moved. + assertEquals(1L, anchor.getAvailablePackages()); + assertEquals(4L, actual.getAvailablePackages()); assertEquals(0L, actual.getHoldPackages()); + assertEquals(1L, target.get().getHoldPackages()); assertEquals(200L, target.get().getBalanceId()); + assertEquals(7L, anchor.getAvailablePackages() + anchor.getHoldPackages() + actual.getAvailablePackages() + + actual.getHoldPackages() + target.get().getHoldPackages()); + verify(relabelPalletMutations).moveSelectedItems(eq(app), argThat(values -> values.get(100L).equals(List.of(item))), anyMap()); + ArgumentCaptor entries = ArgumentCaptor.forClass(InventoryLedger.class); + verify(ledgers, times(4)).insert(entries.capture()); + assertTrue(entries.getAllValues().stream().allMatch(entry -> "ORIGINAL-INBOUND".equals(entry.getInboundCode()))); + verify(balances, never()).updateById(any(InventoryBalance.class)); + } + + @Test void boundMarksSurviveReloadButDoNotBlockANewReworkRound() { + var tasks = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class); + var histories = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper.class); + ReflectionTestUtils.setField(service, "relabelWorkTasks", tasks); + ReflectionTestUtils.setField(service, "relabelWorkHistory", histories); + RelabelApplication app = new RelabelApplication(); app.setId(600L); app.setCustomerId("100"); + var task = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask(); task.setId(900L); + when(tasks.find("100", 600L)).thenReturn(task); + var history = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory(); + history.setDetailJson("{\"round\":\"INITIAL\",\"cartonMarks\":[\"CARTON-A\"]}"); + when(histories.selectList(any())).thenReturn(List.of(history)); + assertEquals(java.util.Set.of("CARTON-A"), ReflectionTestUtils.invokeMethod(service, "boundRelabelMarks", app)); + task.setReviewedAt(LocalDateTime.of(2026, 9, 15, 12, 0)); + assertEquals(java.util.Set.of(), ReflectionTestUtils.invokeMethod(service, "boundRelabelMarks", app)); + } + + @Test void createsPalletBeforeCustomerConfirmationOnlyForClaimingOperator() { + var tasks = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class); + ReflectionTestUtils.setField(service, "relabelWorkTasks", tasks); + RelabelApplication app = new RelabelApplication(); app.setId(600L); app.setRelabelNo("RL-1"); + app.setCustomerId("100"); app.setOverseasWarehouseId("88"); app.setStatus("PROCESSING"); + when(relabelApplications.findAnyByRelabelNo("RL-1")).thenReturn(app); + var task = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask(); task.setStatus("PROCESSING"); task.setOperatorKey("7"); + when(tasks.find("100", 600L)).thenReturn(task); + when(physical.ensureRelabelPallet(88L, "RL-1:before-confirm")).thenReturn(pallet(30L, "NEW", 88L, 0)); + assertEquals("NEW", service.createRelabelPallet(identity(), new PalletOperationRequests.NewRelabelPallet("RL-1", "before-confirm")).palletCode()); + task.setOperatorKey("other"); + assertThrows(ResponseStatusException.class, () -> service.createRelabelPallet(identity(), new PalletOperationRequests.NewRelabelPallet("RL-1", "different"))); + } + private static PdaWarehouseIdentity identity() { return new PdaWarehouseIdentity("7", "picker", 88L); } private static PalletPhysicalGateway.PalletReference pallet(long id, String code, long warehouse, int version) { return new PalletPhysicalGateway.PalletReference(id, code, warehouse, 1000L + id, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java index 5a0718253..b111d41d9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java @@ -61,6 +61,7 @@ class PdaRelabelServiceTest { void setUp() { service = new PdaRelabelService(); ReflectionTestUtils.setField(service, "applications", applications); + ReflectionTestUtils.setField(service, "bindingGuard", org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.service.RelabelBindingGuard.class)); ReflectionTestUtils.setField(service, "items", items); ReflectionTestUtils.setField(service, "tasks", tasks); ReflectionTestUtils.setField(service, "unitFacts", unitFacts); @@ -103,7 +104,7 @@ class PdaRelabelServiceTest { PdaRelabelTask active = task("PROCESSING", 0); active.setApplicationId(99L); active.setRelabelNo("FBR-ACTIVE"); - when(tasks.lockUnfinishedByOperator("7")).thenReturn(active); + when(tasks.lockProcessingByOperator("7")).thenReturn(active); ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.claim("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.Claim("claim-other"))); @@ -205,6 +206,36 @@ class PdaRelabelServiceTest { assertEquals(1, result.version()); } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"SKU_LABEL", "CARTON_AND_SKU"}) + void oldSkuOnlyScanPersistsEachRowAndCompletesBoxOnlyWhenRequiredStagesFinish(String type) { + application.setStatus("PROCESSING"); + item.setOperationType(type); item.setCartonMark("BOX-1"); item.setFbaCartonMark("FBA-1"); + item.setNotifiedPackageCount(1); + item.setSkuRelabelItemsJson("[{\"skuCode\":\"OLD-A\",\"newSkuCode\":\"NEW-A\",\"quantity\":3},{\"skuCode\":\"OLD-B\",\"newSkuCode\":\"NEW-B\",\"quantity\":2}]"); + var task = task("PROCESSING", 0); + var saved = new ArrayList(); + when(histories.selectList(any())).thenAnswer(call -> List.copyOf(saved)); + when(histories.insert(any(com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory.class))).thenAnswer(call -> { saved.add(call.getArgument(0)); return 1; }); + when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application); + lenient().when(applications.findAnyByRelabelNo("FBR-1")).thenReturn(application); + when(tasks.lock("100", 1L)).thenReturn(task); + when(items.selectOne(any())).thenReturn(item); + var first = service.scanSku("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.SkuScan(10L, 0, "OLD-A", 0, "sku-a")); + assertEquals(0, first.relabelledPackages()); assertEquals(1, first.version()); + assertTrue(saved.stream().anyMatch(entry -> "SKU_SCAN".equals(entry.getAction()) && entry.getDetailJson().contains("NEW-A"))); + assertThrows(ResponseStatusException.class, () -> service.scanSku("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.SkuScan(10L, 0, "OLD-A", 1, "duplicate"))); + assertThrows(ResponseStatusException.class, () -> service.scanSku("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.SkuScan(10L, 1, "WRONG", 1, "wrong"))); + var second = service.scanSku("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.SkuScan(10L, 1, "OLD-B", 1, "sku-b")); + assertEquals(1, second.items().get(0).skuProcessedPackages()); + if ("CARTON_AND_SKU".equals(type)) { + assertEquals(0, second.relabelledPackages()); + var last = service.scanCarton("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.CartonScan("BOX-1", 2, "carton-last")); + assertEquals(1, last.relabelledPackages()); + } else assertEquals(1, second.relabelledPackages()); + assertEquals(1, persistedFacts.size()); + } + @Test void duplicateOldLabelAndStaleVersionFailBeforeWritingAnotherFact() { application.setStatus("PROCESSING"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java index 7e0db4689..3c2cdf225 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java @@ -117,6 +117,7 @@ class RelabelApplicationServiceTest { lenient().when(feeSources.publishApproved(any(), any(), any())).thenReturn(new OverseasFeeSource()); } + @Test void createBuildsPendingRelabelTailOrderItemsAndBothHistories() { RelabelRequests.Create request = request("key-1", 10, 6); @@ -144,7 +145,7 @@ class RelabelApplicationServiceTest { verify(orderHistories).insert(tailHistory.capture()); assertEquals("RELABEL_APPLICATION", tailOrder.getValue().getOrderType()); - assertEquals("PENDING_ASSIGNMENT", tailOrder.getValue().getCurrentStatus()); + assertEquals("WAIT_RELABEL", tailOrder.getValue().getCurrentStatus()); assertEquals("RELABEL:PENDING_PROCESSING", tailOrder.getValue().getSubOrderStatus()); assertEquals("CUSTOMER", tailOrder.getValue().getSourceRegion()); assertTrue(tailOrder.getValue().getOrderNo().matches("RL-WH-\\d{8}-\\d{6}")); @@ -155,10 +156,36 @@ class RelabelApplicationServiceTest { assertEquals(6, application.getValue().getNotifiedPackageCount()); assertEquals("SKU-A", relabelItem.getValue().getSkuCode()); assertEquals("PENDING_PROCESSING", relabelHistory.getValue().getToStatus()); - assertEquals("PENDING_ASSIGNMENT", tailHistory.getValue().getToStatus()); + assertEquals("WAIT_RELABEL", tailHistory.getValue().getToStatus()); assertEquals("FBR-1", result.application().relabelNo()); } + @Test + void warehouseTransferRelabelReusesExistingTailOrder() { + RelabelRequests.Create request = request("key-transfer", 10, 6); + RelabelApplication persisted = application(11L, "FBR-TRANSFER", "10001", "key-transfer", null, + RelabelApplicationService.PENDING_PROCESSING); + persisted.setLastlegOrderId(21L); + LastlegOrder transferOrder = order(21L, "10001", "WT-WH-20260916-560449", "INBOUND_COMPLETED"); + transferOrder.setOrderType("WAREHOUSE_TRANSFER"); + when(applications.findNo(eq("10001"), any())).thenReturn(persisted); + when(items.selectList(any())).thenReturn(List.of(item(31L, 11L, "SKU-A", 10, 6))); + when(histories.selectList(any())).thenReturn(List.of()); + when(orders.selectById(21L)).thenReturn(transferOrder); + + service.createForExistingTailOrder("10001", "operator-1", 21L, request); + + ArgumentCaptor application = ArgumentCaptor.forClass(RelabelApplication.class); + verify(applications).insert(application.capture()); + verify(orders, never()).insert(any(LastlegOrder.class)); + verify(orderHistories, never()).insert(any(LastlegOrderStatusHistory.class)); + assertEquals(21L, application.getValue().getLastlegOrderId()); + assertEquals(application.getValue().getRelabelNo(), transferOrder.getRelabelOrderNo()); + assertEquals(RelabelApplicationService.PENDING_PROCESSING, transferOrder.getRelabelStatus()); + assertEquals("WAREHOUSE_TRANSFER", transferOrder.getOrderType()); + assertEquals("INBOUND_COMPLETED", transferOrder.getCurrentStatus()); + } + @Test void createWithoutChargeSkipsWalletFreeze() { RelabelRequests.Create charged = request("key-free", 10, 6); @@ -242,27 +269,19 @@ class RelabelApplicationServiceTest { } @Test - void createKeepsOneOperationWarehouseWhenItemsHaveDifferentFbaDestinations() { + void createRejectsDifferentFbaDestinationsInOneApplication() { RelabelRequests.Item west = carton("SKU-A", "BOX-1", "FBA-1", "ONT8"); RelabelRequests.Item east = carton("SKU-B", "BOX-2", "FBA-2", "ABE8"); RelabelRequests.Create request = new RelabelRequests.Create("CUSTOMER-1", "WH-1", LocalDateTime.of(2026, 8, 20, 10, 0), "Li", "13800000000", BigDecimal.ONE, null, "key-multi", List.of(west, east)); - RelabelApplication persisted = application(11L, "FBR-1", "10001", "key-multi", null, - RelabelApplicationService.PENDING_PROCESSING); - persisted.setLastlegOrderId(21L); - when(applications.findNo(eq("10001"), any())).thenReturn(persisted); - when(items.selectList(any())).thenReturn(List.of(item(31L, 11L, "SKU-A", 1, 1))); - when(histories.selectList(any())).thenReturn(List.of()); - when(orders.selectById(21L)).thenReturn(order(21L, "10001", "LMO-RELABEL-1", - RelabelApplicationService.PENDING_PROCESSING)); + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.create("10001", "operator-1", request)); - service.create("10001", "operator-1", request); - - ArgumentCaptor created = ArgumentCaptor.forClass(LastlegOrder.class); - verify(orders).insert(created.capture()); - assertEquals("WH-1", created.getValue().getOverseasWarehouseId()); - assertEquals(2, created.getValue().getPackageCount()); + assertEquals(400, exception.getStatusCode().value()); + assertEquals("同一张贴标申请只能选择一个 FBA 目的仓", exception.getReason()); + verify(orders, never()).insert(any(LastlegOrder.class)); + verify(applications, never()).insert(any(RelabelApplication.class)); } @Test @@ -363,6 +382,9 @@ class RelabelApplicationServiceTest { application.setLastlegOrderId(21L); when(applications.lockNo("10001", "FBR-1")).thenReturn(application); stubDetail(application); + LastlegOrder relabelOrder = order(21L, "10001", "LMO-RELABEL-1", "PENDING_ASSIGNMENT"); + relabelOrder.setOrderType("RELABEL_APPLICATION"); + when(orders.selectById(21L)).thenReturn(relabelOrder); RelabelResponses.Detail result = service.changeStatus("10001", "FBR-1", "operator-2", new RelabelRequests.ChangeStatus("processing", "started")); @@ -372,12 +394,33 @@ class RelabelApplicationServiceTest { verify(applications).updateById(application); ArgumentCaptor updatedOrder = ArgumentCaptor.forClass(LastlegOrder.class); verify(orders).updateById(updatedOrder.capture()); - assertEquals("PENDING_ASSIGNMENT", updatedOrder.getValue().getCurrentStatus()); + assertEquals("WAIT_RELABEL", updatedOrder.getValue().getCurrentStatus()); assertEquals("RELABEL:PROCESSING", updatedOrder.getValue().getSubOrderStatus()); verify(histories).insert(any(RelabelStatusHistory.class)); verify(orderHistories).insert(any(LastlegOrderStatusHistory.class)); } + @Test + void reviewStageIsReportedAsCompletedOnLinkedCustomerAndPlatformOrder() { + RelabelApplication application = application(11L, "FBR-REVIEW", "10001", "key-review", "hash", + "PROCESSING"); + application.setLastlegOrderId(21L); + LastlegOrder order = order(21L, "10001", "LMO-RELABEL-1", "PENDING_ASSIGNMENT"); + when(applications.lockNo("10001", "FBR-REVIEW")).thenReturn(application); + when(applications.findNo("10001", "FBR-REVIEW")).thenReturn(application); + when(items.selectList(any())).thenReturn(List.of(item(31L, application.getId(), "SKU-A", 1, 1))); + when(histories.selectList(any())).thenReturn(List.of()); + when(orders.selectById(21L)).thenReturn(order); + + service.changeStatus("10001", "FBR-REVIEW", "operator-2", + new RelabelRequests.ChangeStatus(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, "submitted")); + + assertEquals(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, application.getStatus()); + assertEquals("WAIT_CUSTOMER_CONFIRMATION", application.getFulfillmentStatus()); + assertEquals("COMPLETED", order.getRelabelStatus()); + assertEquals("RELABEL:CUSTOMER_CONFIRM_PENDING", order.getSubOrderStatus()); + } + @Test void completedApplicationCannotReturnToProcessingAndOtherTenantCannotReadIt() { RelabelApplication completed = application(11L, "FBR-1", "10001", "key-1", "hash", "COMPLETED"); @@ -466,7 +509,7 @@ class RelabelApplicationServiceTest { var review = ArgumentCaptor.forClass(com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask.class); verify(pdaTasks).updateWithVersion(review.capture(), eq(1)); assertEquals("COMPLETED", review.getValue().getStatus()); - assertEquals("CUSTOMER_CONFIRMED", review.getValue().getReviewDecision()); + assertEquals("CONFIRMED", review.getValue().getReviewDecision()); assertNull(application.getOutboundOrderNo()); verify(outboundOrders, never()).insert(any(OutboundOrder.class)); @@ -475,12 +518,36 @@ class RelabelApplicationServiceTest { verify(applications, times(1)).updateById(application); } + @Test + void skuOnlyCustomerConfirmationCompletesWithoutPalletOrOutboundFulfillment() { + RelabelApplication application = confirmationApplication(null, false); + RelabelItem skuOnly = item(31L, application.getId(), "SKU-A", 1, 1); + skuOnly.setOperationType("SKU_LABEL"); + LastlegOrder linked = order(21L, "10001", "LMO-RELABEL-1", "WAIT_RELABEL"); + when(applications.lockNo("10001", "FBR-1")).thenReturn(application); + when(applications.findNo("10001", "FBR-1")).thenReturn(application); + when(items.selectList(any())).thenReturn(List.of(skuOnly)); + when(histories.selectList(any())).thenReturn(List.of()); + when(orders.selectById(21L)).thenReturn(linked); + + service.confirmCompletion("10001", "FBR-1", "customer-1", + new RelabelRequests.CustomerConfirm("confirm-sku", "accepted")); + + assertEquals("COMPLETED", application.getStatus()); + assertEquals("NOT_REQUIRED", application.getFulfillmentStatus()); + assertEquals("COMPLETED", linked.getCurrentStatus()); + assertNull(application.getOutboundOrderNo()); + verify(outboundOrders, never()).insert(any(OutboundOrder.class)); + verify(outboundItems, never()).insert(any(OutboundPalletItem.class)); + verify(outboundTasks, never()).insert(any(OutboundPdaTask.class)); + } + @Test void transferRelabelConfirmationReleasesTransferForDispatch() { RelabelApplication application = confirmationApplication("TRUCK", false); WarehouseTransferOrder transfer = new WarehouseTransferOrder(); transfer.setId(77L); transfer.setCustomerId("10001"); transfer.setRelabelNo("FBR-1"); - transfer.setStatus("WAIT_RELABEL"); transfer.setLastlegOrderId(88L); + transfer.setStatus("WAIT_RELABEL"); transfer.setLastlegOrderId(88L); transfer.setFbaWarehouseCode("ONT8"); LastlegOrder transferOrder = order(88L, "10001", "LMO-TRANSFER-1", "WAIT_RELABEL"); when(applications.lockNo("10001", "FBR-1")).thenReturn(application); stubDetail(application); @@ -498,6 +565,55 @@ class RelabelApplicationServiceTest { verify(orders).updateById(transferOrder); } + @Test + void transferRelabelConfirmationWithoutDestinationWaitsForFbaInsteadOfDispatch() { + RelabelApplication application = confirmationApplication("TRUCK", false); + WarehouseTransferOrder transfer = new WarehouseTransferOrder(); + transfer.setId(77L); transfer.setCustomerId("10001"); transfer.setRelabelNo("FBR-1"); + transfer.setStatus("WAIT_RELABEL"); transfer.setLastlegOrderId(88L); + LastlegOrder transferOrder = order(88L, "10001", "LMO-TRANSFER-1", "WAIT_RELABEL"); + when(applications.lockNo("10001", "FBR-1")).thenReturn(application); + stubDetail(application); + when(warehouseTransfers.lockByRelabelNo("10001", "FBR-1")).thenReturn(transfer); + when(orders.selectById(88L)).thenReturn(transferOrder); + + service.confirmCompletion("10001", "FBR-1", "customer-1", + new RelabelRequests.CustomerConfirm("confirm-transfer-no-fba", "accepted")); + + assertEquals("WAIT_FBA_DESTINATION", transfer.getStatus()); + assertEquals("WAIT_FBA_DESTINATION", transferOrder.getCurrentStatus()); + assertEquals("WAREHOUSE_TRANSFER:WAIT_FBA_DESTINATION;RELABEL:COMPLETED", + transferOrder.getSubOrderStatus()); + assertEquals("WAIT_FBA_DESTINATION", application.getFulfillmentStatus()); + } + + @Test + void transferRelabelConfirmationDerivesUniqueDestinationFromRelabelItems() { + RelabelApplication application = confirmationApplication("TRUCK", false); + WarehouseTransferOrder transfer = new WarehouseTransferOrder(); + transfer.setId(77L); transfer.setCustomerId("10001"); transfer.setRelabelNo("FBR-1"); + transfer.setStatus("WAIT_RELABEL"); transfer.setLastlegOrderId(88L); + LastlegOrder transferOrder = order(88L, "10001", "LMO-TRANSFER-1", "WAIT_RELABEL"); + RelabelItem relabelItem = item(31L, application.getId(), "SKU-A", 10, 6); + relabelItem.setDestinationFbaWarehouseCode("ONT8"); + when(applications.lockNo("10001", "FBR-1")).thenReturn(application); + when(applications.findNo("10001", "FBR-1")).thenReturn(application); + when(items.selectList(any())).thenReturn(List.of(relabelItem)); + when(histories.selectList(any())).thenReturn(List.of()); + when(warehouseTransfers.lockByRelabelNo("10001", "FBR-1")).thenReturn(transfer); + when(orders.selectById(88L)).thenReturn(transferOrder); + when(orders.selectById(application.getLastlegOrderId())).thenReturn( + order(application.getLastlegOrderId(), "10001", "LMO-RELABEL-1", application.getStatus())); + + service.confirmCompletion("10001", "FBR-1", "customer-1", + new RelabelRequests.CustomerConfirm("confirm-transfer-derived-fba", "accepted")); + + assertEquals("ONT8", transfer.getFbaWarehouseCode()); + assertEquals("READY_FOR_DISPATCH", transfer.getStatus()); + assertEquals("ONT8", transferOrder.getFbaWarehouseCode()); + assertEquals("READY_FOR_DISPATCH", transferOrder.getCurrentStatus()); + } + @Test void expressCustomerConfirmationRequiresShippingLabelWhenWarehousePrintingIsSelected() { RelabelApplication application = confirmationApplication("EXPRESS", true); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuardTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuardTest.java new file mode 100644 index 000000000..806625f7b --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuardTest.java @@ -0,0 +1,48 @@ +package com.baiyue.lastleg.relabel.service; + +import com.baiyue.lastleg.relabel.entity.*; +import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory; +import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; +import java.util.List; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import static org.junit.jupiter.api.Assertions.*; + +class RelabelBindingGuardTest { + @Test void physicalBindingMustMatchLatestTargetAndCannotSurviveRework() { + var guard = new RelabelBindingGuard(); + var items = mock(RelabelItemMapper.class); + var history = mock(PdaRelabelHistoryMapper.class); + var positions = mock(InventoryPositionMapper.class); + ReflectionTestUtils.setField(guard, "items", items); + ReflectionTestUtils.setField(guard, "histories", history); + ReflectionTestUtils.setField(guard, "positions", positions); + ReflectionTestUtils.setField(guard, "objectMapper", new ObjectMapper()); + var app = new RelabelApplication(); app.setId(1L); app.setCustomerId("2"); + var item = new RelabelItem(); item.setCartonMark("A"); item.setOperationType("CARTON_LABEL"); + item.setSourceInventoryPositionId(3L); item.setNotifiedPackageCount(1); + when(items.selectList(any())).thenReturn(List.of(item)); + var position = new InventoryPosition(); position.setPalletNo("OLD"); position.setHoldPackages(1L); + when(positions.findTenantPosition("2", 3L)).thenReturn(position); + when(history.selectList(any())).thenReturn(List.of()); + assertThrows(ResponseStatusException.class, () -> guard.requireBound(app)); + var binding = new PdaRelabelHistory(); binding.setAction("PALLET_BIND"); + binding.setDetailJson("{\"cartonMarks\":[\"A\"],\"targetPallets\":[\"NEW\"]}"); + when(history.selectList(any())).thenReturn(List.of(binding)); + assertThrows(ResponseStatusException.class, () -> guard.requireBound(app)); + position.setPalletNo("NEW"); + assertDoesNotThrow(() -> guard.requireBound(app)); + var rework = new PdaRelabelHistory(); rework.setAction("REWORK_ARCHIVE"); + when(history.selectList(any())).thenReturn(List.of(binding, rework)); + assertThrows(ResponseStatusException.class, () -> guard.requireBound(app)); + item.setOperationType("SKU_LABEL"); + assertDoesNotThrow(() -> guard.requireBound(app)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelReturnCartonCandidateTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelReturnCartonCandidateTest.java new file mode 100644 index 000000000..36980b2da --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelReturnCartonCandidateTest.java @@ -0,0 +1,51 @@ +package com.baiyue.lastleg.relabel.service; + +import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.relabel.dto.RelabelResponses; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class RelabelReturnCartonCandidateTest { + @Test + void candidatesIncludeAvailableMergedReturnCartonByWarehouseMark() { + RelabelApplicationService service = new RelabelApplicationService(); + InventoryBalanceMapper balances = mock(InventoryBalanceMapper.class); + InventoryPositionMapper positions = mock(InventoryPositionMapper.class); + ReturnInventoryCartonMapper cartons = mock(ReturnInventoryCartonMapper.class); + ReturnInventoryCartonItemMapper cartonItems = mock(ReturnInventoryCartonItemMapper.class); + ReflectionTestUtils.setField(service, "inventoryBalances", balances); + ReflectionTestUtils.setField(service, "inventoryPositions", positions); + ReflectionTestUtils.setField(service, "returnCartons", cartons); + ReflectionTestUtils.setField(service, "returnCartonItems", cartonItems); + + ReturnInventoryCarton carton = new ReturnInventoryCarton(); + carton.setId(9001L); carton.setCustomerId("10001"); carton.setOverseasWarehouseId("WH-1"); + carton.setCartonMark("RC-WH-0001"); carton.setSourceReturnNo("RT-1,RT-2"); + carton.setStatus("AVAILABLE"); carton.setTotalQuantity(12L); + ReturnInventoryCartonItem cartonItem = new ReturnInventoryCartonItem(); + cartonItem.setCartonId(9001L); cartonItem.setSkuCode("SKU-RETURN"); + cartonItem.setProductNameZh("退货商品"); cartonItem.setQuantity(12L); + when(cartons.findAvailable("10001", "WH-1")).thenReturn(List.of(carton)); + when(cartonItems.listCarton(9001L)).thenReturn(List.of(cartonItem)); + + List result = service.candidates("10001", "WH-1", "RC-WH"); + + assertEquals(1, result.size()); + assertEquals("RETURN_CARTON", result.get(0).sourceType()); + assertEquals(9001L, result.get(0).returnCartonId()); + assertEquals("RC-WH-0001", result.get(0).cartonMark()); + assertEquals("SKU-RETURN", result.get(0).skuItems().get(0).skuCode()); + assertEquals(12, result.get(0).skuItems().get(0).quantity()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/WarehouseTransferSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/WarehouseTransferSchemaContractTest.java index 7dcc994a7..984cc730d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/WarehouseTransferSchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/WarehouseTransferSchemaContractTest.java @@ -11,6 +11,8 @@ class WarehouseTransferSchemaContractTest { void baselineAndIncrementalContainTransferAndHistoricalOrderBindingTables() throws Exception { String baseline = Files.readString(Path.of("src", "main", "resources", "db", "lastleg.sql"), StandardCharsets.UTF_8).toLowerCase(); String migration = Files.readString(Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8).toLowerCase(); + String deployment = Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"), + StandardCharsets.UTF_8).toLowerCase(); for (String table : new String[]{"ll_warehouse_transfer_order", "ll_warehouse_transfer_source_order", "ll_warehouse_transfer_item", "ll_warehouse_transfer_inventory_allocation", "ll_warehouse_transfer_receipt"}) { @@ -21,5 +23,25 @@ class WarehouseTransferSchemaContractTest { assertTrue(migration.contains("uk_ll_wh_transfer_source")); assertTrue(migration.contains("uk_ll_wh_transfer_active_position")); assertTrue(migration.contains("uk_ll_wh_transfer_receipt_source")); + assertTrue(baseline.contains("pickup_contact_name varchar(128) null")); + assertTrue(baseline.contains("pickup_contact_phone varchar(64) null")); + assertTrue(migration.contains("column_name='pickup_contact_name'")); + assertTrue(migration.contains("column_name='pickup_contact_phone'")); + assertTrue(baseline.contains("create table if not exists ll_warehouse_transfer_measurement_photo")); + assertTrue(migration.contains("create table if not exists ll_warehouse_transfer_measurement_photo")); + assertTrue(baseline.contains("received_package_count int null comment 'pda实际入库箱数'")); + assertTrue(migration.contains("column_name='received_package_count'")); + assertTrue(migration.contains("o.current_status='inbound_completed'")); + for (String field : new String[]{"pickup_contact_name", "review_decision", "destination_type", + "destination_binding_idempotency_key", "private_destination_address", + "private_destination_postal_code", "draft_idempotency_key", "bind_idempotency_key"}) { + assertTrue(deployment.contains(field), () -> "deployment SQL must include " + field); + } + for (String table : new String[]{"ll_warehouse_transfer_measurement_photo", "ll_inbound_plan_line", + "ll_inbound_plan_pallet", "ll_dispatch_item", "ll_delivery_order"}) { + assertTrue(deployment.contains(table), () -> "deployment SQL must include " + table); + } + assertTrue(deployment.contains("assert_relabel_transfer_latest_schema_ready")); + assertTrue(deployment.contains("贴标申请与仓储转运最新表结构已完整部署")); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferControllerTest.java index b7945c828..ef731e388 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferControllerTest.java @@ -31,6 +31,25 @@ class MerchantWarehouseTransferControllerTest { verify(service).detail("100", "TR001"); } + @Test + void merchantSubmitUsesOnlyAuthenticatedTenant() { + controller.submit(user(100L, "MERCHANT"), "TR001"); + verify(service).submit("100", "TR001", "1"); + } + + @Test + void merchantUpdateUsesOnlyAuthenticatedTenant() { + WarehouseTransferRequests.Create request = mock(WarehouseTransferRequests.Create.class); + controller.updateDraft(user(100L, "MERCHANT"), "TR001", request); + verify(service).updateDraft("100", "TR001", "1", request); + } + + @Test + void merchantPrintsSubmittedCartonLabelsWithinAuthenticatedTenant() { + controller.cartonLabel(user(100L, "MERCHANT"), "TR001"); + verify(service).cartonLabel("100", "TR001"); + } + @Test void merchantEndpointRejectsPlatformIdentity() { assertThrows(ResponseStatusException.class, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/PdaWarehouseTransferControllerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/PdaWarehouseTransferControllerContractTest.java new file mode 100644 index 000000000..0f859dd2a --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/PdaWarehouseTransferControllerContractTest.java @@ -0,0 +1,62 @@ +package com.baiyue.lastleg.transfer.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import org.junit.jupiter.api.Test; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.DeleteMapping; +import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.multipart.MultipartFile; + +import static org.assertj.core.api.Assertions.assertThat; + +class PdaWarehouseTransferControllerContractTest { + @Test + void detailUsesOnlyAuthenticatedPdaWarehouseScopeAndTransferNumber() throws Exception { + var method = PdaWarehouseTransferController.class + .getMethod("detail", AuthenticatedUser.class, String.class); + + assertThat(method.getAnnotation(GetMapping.class).value()).containsExactly("/{transferNo}"); + assertThat(method.getParameterCount()).isEqualTo(2); + } + + @Test + void measurementPhotoUploadUsesThePublicPdaRouteAndFileMultipartPart() throws Exception { + var method = PdaWarehouseTransferController.class.getMethod("uploadMeasurementPhoto", + AuthenticatedUser.class, String.class, MultipartFile.class); + + var mapping = method.getAnnotation(PostMapping.class); + assertThat(mapping.value()).containsExactly("/{transferNo}/measurement-photos/files"); + assertThat(mapping.consumes()).containsExactly("multipart/form-data"); + assertThat(method.getParameters()[2].getAnnotation(RequestParam.class).value()).isEqualTo("file"); + } + + @Test + void palletLabelUsesThePdaScopedTransferAndGeneratedWarehousePallet() throws Exception { + var method = PdaWarehouseTransferController.class.getMethod("palletLabel", + AuthenticatedUser.class, String.class, long.class, String.class); + + assertThat(method.getAnnotation(GetMapping.class).value()) + .containsExactly("/{transferNo}/pallets/{warehousePalletId}/label"); + assertThat(method.getParameters()[3].getAnnotation(RequestHeader.class).value()).isEqualTo("X-Request-Id"); + } + + @Test + void palletLifecycleExposesDraftGenerateBindAndDraftDeleteRoutes() throws Exception { + var draft = PdaWarehouseTransferController.class.getMethod("savePalletDraft", + AuthenticatedUser.class, String.class, WarehouseTransferRequests.PalletDraft.class); + var generate = PdaWarehouseTransferController.class.getMethod("generatePallet", + AuthenticatedUser.class, String.class, long.class, WarehouseTransferRequests.PalletAction.class); + var bind = PdaWarehouseTransferController.class.getMethod("bindPallet", + AuthenticatedUser.class, String.class, long.class, WarehouseTransferRequests.PalletBind.class); + var delete = PdaWarehouseTransferController.class.getMethod("deletePalletDraft", + AuthenticatedUser.class, String.class, long.class); + + assertThat(draft.getAnnotation(PostMapping.class).value()).containsExactly("/{transferNo}/pallets/drafts"); + assertThat(generate.getAnnotation(PostMapping.class).value()).containsExactly("/{transferNo}/pallets/{palletId}/generate"); + assertThat(bind.getAnnotation(PostMapping.class).value()).containsExactly("/{transferNo}/pallets/{palletId}/bind"); + assertThat(delete.getAnnotation(DeleteMapping.class).value()).containsExactly("/{transferNo}/pallets/{palletId}"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferCargoValidationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferCargoValidationTest.java new file mode 100644 index 000000000..3f680f3cb --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferCargoValidationTest.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.transfer.dto; + +import jakarta.validation.Validation; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class WarehouseTransferCargoValidationTest { + @Test + void transferCargoAllowsSkuAndProductNameToBeOmitted() { + try (var factory = Validation.buildDefaultValidatorFactory()) { + var violations = factory.getValidator().validate( + new WarehouseTransferRequests.Item(null, null, 1, null)); + + assertTrue(violations.isEmpty()); + } + } + + @Test + void cartonRelabelAllowsOriginalSkuToBeOmitted() { + try (var factory = Validation.buildDefaultValidatorFactory()) { + var violations = factory.getValidator().validate(new WarehouseTransferRequests.RelabelItem( + "CARTON_LABEL", List.of("CUSTOMER-CARTON-1"), null, null, 1, + "FBA-CARTON-1", "ONT8", null)); + + assertTrue(violations.isEmpty()); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferPalletValidationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferPalletValidationTest.java index 92807e87a..f714d3e35 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferPalletValidationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferPalletValidationTest.java @@ -11,12 +11,12 @@ import static org.junit.jupiter.api.Assertions.assertTrue; class WarehouseTransferPalletValidationTest { @Test - void palletAcceptsDirectTotalVolumeAndWeightWithoutCartonDimensions() { + void palletAcceptsMeasuredDimensionsVolumeAndWeight() { try (var factory = Validation.buildDefaultValidatorFactory()) { var validator = factory.getValidator(); - var pallet = new WarehouseTransferRequests.Pallet("PALLET-1", 5, - new BigDecimal("1.250000"), new BigDecimal("420.50"), - List.of(new WarehouseTransferRequests.PalletItem(101L, 5))); + var pallet = new WarehouseTransferRequests.Pallet(5, + new BigDecimal("100"), new BigDecimal("100"), new BigDecimal("125"), + new BigDecimal("1.250000"), new BigDecimal("420.50")); assertTrue(validator.validate(pallet).isEmpty()); assertEquals(new BigDecimal("1.250000"), pallet.volumeCbm()); @@ -28,11 +28,23 @@ class WarehouseTransferPalletValidationTest { void palletRejectsNonPositiveTotalVolume() { try (var factory = Validation.buildDefaultValidatorFactory()) { var validator = factory.getValidator(); - var pallet = new WarehouseTransferRequests.Pallet("PALLET-1", 5, - BigDecimal.ZERO, new BigDecimal("420.50"), - List.of(new WarehouseTransferRequests.PalletItem(101L, 5))); + var pallet = new WarehouseTransferRequests.Pallet(5, + new BigDecimal("100"), new BigDecimal("100"), new BigDecimal("125"), + BigDecimal.ZERO, new BigDecimal("420.50")); assertEquals(1, validator.validate(pallet).size()); } } + + @Test + void palletRejectsMissingOrNonPositiveDimensions() { + try (var factory = Validation.buildDefaultValidatorFactory()) { + var validator = factory.getValidator(); + var pallet = new WarehouseTransferRequests.Pallet(5, + BigDecimal.ZERO, null, new BigDecimal("125"), + new BigDecimal("1.250000"), new BigDecimal("420.50")); + + assertEquals(2, validator.validate(pallet).size()); + } + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/gateway/HttpTransferMeasurementPhotoFileGatewayContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/gateway/HttpTransferMeasurementPhotoFileGatewayContractTest.java new file mode 100644 index 000000000..b42f74175 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/gateway/HttpTransferMeasurementPhotoFileGatewayContractTest.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.transfer.gateway; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Value; + +import static org.assertj.core.api.Assertions.assertThat; + +class HttpTransferMeasurementPhotoFileGatewayContractTest { + @Test + void defaultsToTheFileServicesActualLocalPort() throws Exception { + var field = HttpTransferMeasurementPhotoFileGateway.class.getDeclaredField("baseUrl"); + + assertThat(field.getAnnotation(Value.class).value()) + .isEqualTo("${baiyue.clients.file.url:http://127.0.0.1:9206}"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferPalletAllocationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferPalletAllocationTest.java new file mode 100644 index 000000000..1177c8a77 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferPalletAllocationTest.java @@ -0,0 +1,94 @@ +package com.baiyue.lastleg.transfer.service; + +import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferItem; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class WarehouseTransferPalletAllocationTest { + @Test + void allocatesMultipleDeclaredSkusAcrossPalletsInStableDeclaredOrder() { + WarehouseTransferItem first = item(11L, 3); + WarehouseTransferItem second = item(12L, 4); + var allocations = WarehouseTransferService.derivePalletAllocations( + List.of(first, second), List.of(pallet(2), pallet(5))); + + assertEquals(List.of( + new WarehouseTransferService.DerivedPalletAllocation(0, 11L, 2), + new WarehouseTransferService.DerivedPalletAllocation(1, 11L, 1), + new WarehouseTransferService.DerivedPalletAllocation(1, 12L, 4)), allocations); + } + + @Test + void underReceiptConsumesDeclaredSkusInStableOrder() { + var allocations = WarehouseTransferService.derivePalletAllocations( + List.of(item(11L, 3), item(12L, 2)), List.of(pallet(4))); + + assertEquals(List.of( + new WarehouseTransferService.DerivedPalletAllocation(0, 11L, 3), + new WarehouseTransferService.DerivedPalletAllocation(0, 12L, 1)), allocations); + assertEquals(4, allocations.stream().mapToInt(value -> value.packageCount()).sum()); + } + + @Test + void overReceiptAssignsExcessToFinalDeclaredSkuAcrossPallets() { + var allocations = WarehouseTransferService.derivePalletAllocations( + List.of(item(11L, 3), item(12L, 2)), List.of(pallet(4), pallet(3))); + + assertEquals(List.of( + new WarehouseTransferService.DerivedPalletAllocation(0, 11L, 3), + new WarehouseTransferService.DerivedPalletAllocation(0, 12L, 1), + new WarehouseTransferService.DerivedPalletAllocation(1, 12L, 3)), allocations); + assertEquals(7, allocations.stream().mapToInt(value -> value.packageCount()).sum()); + } + + @Test + void persistedOverReceiptUsesTheSameStableAllocationRule() { + WarehouseTransferPallet first = persistedPallet(4); + WarehouseTransferPallet second = persistedPallet(3); + + var allocations = WarehouseTransferService.derivePersistedPalletAllocations( + List.of(item(11L, 3), item(12L, 2)), List.of(first, second)); + + assertEquals(7, allocations.stream().mapToInt(value -> value.packageCount()).sum()); + assertEquals(3, allocations.stream().filter(value -> value.transferItemId() == 11L) + .mapToInt(value -> value.packageCount()).sum()); + assertEquals(4, allocations.stream().filter(value -> value.transferItemId() == 12L) + .mapToInt(value -> value.packageCount()).sum()); + assertEquals(List.of(0, 0, 1), allocations.stream().map(value -> value.palletIndex()).toList()); + } + + @Test + void requestsFreshPalletNumbersAndCarriesOnlyTheKnownFbaDestination() { + var known = WarehouseTransferService.newTransferPalletRequests(81L, "WTR-1", "ONT8", 2); + var unknown = WarehouseTransferService.newTransferPalletRequests(81L, "WTR-2", null, 1); + + assertEquals(List.of("WTR-1:1", "WTR-1:2"), known.stream().map(value -> value.businessKey()).toList()); + assertEquals(List.of("ONT8", "ONT8"), known.stream().map(value -> value.fbaWarehouseCode()).toList()); + assertEquals(true, known.stream().allMatch(value -> value.palletCode() == null)); + assertEquals(null, unknown.get(0).fbaWarehouseCode()); + assertEquals(null, unknown.get(0).palletCode()); + } + + private static WarehouseTransferItem item(long id, int packages) { + WarehouseTransferItem item = new WarehouseTransferItem(); + item.setId(id); item.setPackageCount(packages); + return item; + } + + private static WarehouseTransferRequests.Pallet pallet(int cartons) { + return new WarehouseTransferRequests.Pallet(cartons, BigDecimal.ONE, BigDecimal.ONE, + BigDecimal.ONE, new BigDecimal("0.000001"), BigDecimal.ONE); + } + + private static com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet persistedPallet(int cartons) { + var pallet = new com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet(); + pallet.setCartonCount(cartons); + return pallet; + } +} 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 a1dba7df4..0f23c0ae3 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 @@ -1,6 +1,7 @@ package com.baiyue.lastleg.transfer.service; import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderDetail; import com.baiyue.lastleg.entity.LastlegInboundOrder; import com.baiyue.lastleg.relabel.entity.RelabelApplication; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; @@ -19,11 +20,27 @@ import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder; import com.baiyue.lastleg.transfer.entity.WarehouseTransferCarton; import com.baiyue.lastleg.transfer.entity.WarehouseTransferInventoryAllocation; import com.baiyue.lastleg.transfer.entity.WarehouseTransferSourceOrder; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferMeasurementPhoto; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferItem; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletItemMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferRelabelBindingMapper; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.inboundplan.pda.mapper.InboundStockReceiptMapper; +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.inboundplan.mapper.InboundPlanPalletMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper; import com.baiyue.lastleg.transfer.mapper.InboundSnapshotMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferItemMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferCartonMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferInventoryAllocationMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferMeasurementPhotoMapper; +import com.baiyue.lastleg.transfer.gateway.TransferMeasurementPhotoFileGateway; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferReceiptMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferSourceOrderMapper; import com.fasterxml.jackson.databind.ObjectMapper; @@ -62,14 +79,36 @@ import static org.mockito.Mockito.times; @ExtendWith(MockitoExtension.class) class WarehouseTransferServiceTest { + @Test + void customerMainStatusFiltersExpandToInternalWorkflowStates() { + assertEquals(List.of(WarehouseTransferService.APPROVED, WarehouseTransferService.WAIT_PDA_RECEIVING), + WarehouseTransferService.customerMainStatuses(WarehouseTransferService.INBOUND_MEASURING)); + assertTrue(WarehouseTransferService.customerMainStatuses(WarehouseTransferService.INBOUND_COMPLETED) + .containsAll(List.of(WarehouseTransferService.WAIT_CUSTOMER_CONFIRMATION, + WarehouseTransferService.WAIT_FBA_DESTINATION, WarehouseTransferService.WAIT_RELABEL, + WarehouseTransferService.READY_FOR_DISPATCH, WarehouseTransferService.OUTBOUNDED))); + assertNull(WarehouseTransferService.customerMainStatuses(WarehouseTransferService.DRAFT)); + } + @Mock WarehouseTransferOrderMapper transfers; @Mock WarehouseTransferItemMapper transferItems; @Mock WarehouseTransferCartonMapper transferCartons; @Mock WarehouseTransferSourceOrderMapper sourceOrders; @Mock WarehouseTransferInventoryAllocationMapper inventoryAllocations; @Mock WarehouseTransferReceiptMapper transferReceipts; + @Mock WarehouseTransferMeasurementPhotoMapper measurementPhotos; + @Mock WarehouseTransferPalletMapper transferPallets; + @Mock WarehouseTransferPalletItemMapper transferPalletItems; + @Mock InboundStockReceiptMapper stockReceipts; + @Mock WarehouseTransferRelabelBindingMapper relabelBindings; + @Mock WarehouseResourceGateway warehouseResources; + @Mock TransferMeasurementPhotoFileGateway measurementPhotoFiles; @Mock InboundSnapshotMapper inboundSnapshots; @Mock LastlegInboundOrderMapper inboundOrders; + @Mock InboundPlanMapper inboundPlans; + @Mock InboundPlanLineMapper inboundPlanLines; + @Mock InboundPlanPalletMapper inboundPlanPallets; + @Mock InboundPlanPalletAllocationMapper inboundPlanAllocations; @Mock LastlegOrderMapper orders; @Mock LastlegOrderDetailMapper orderDetails; @Mock LastlegOrderStatusHistoryMapper orderHistories; @@ -88,8 +127,19 @@ class WarehouseTransferServiceTest { ReflectionTestUtils.setField(service, "sourceOrders", sourceOrders); ReflectionTestUtils.setField(service, "inventoryAllocations", inventoryAllocations); ReflectionTestUtils.setField(service, "transferReceipts", transferReceipts); + ReflectionTestUtils.setField(service, "measurementPhotos", measurementPhotos); + ReflectionTestUtils.setField(service, "transferPallets", transferPallets); + ReflectionTestUtils.setField(service, "transferPalletItems", transferPalletItems); + ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts); + ReflectionTestUtils.setField(service, "relabelBindings", relabelBindings); + ReflectionTestUtils.setField(service, "warehouseResources", warehouseResources); + ReflectionTestUtils.setField(service, "measurementPhotoFiles", measurementPhotoFiles); ReflectionTestUtils.setField(service, "inboundSnapshots", inboundSnapshots); ReflectionTestUtils.setField(service, "inboundOrders", inboundOrders); + ReflectionTestUtils.setField(service, "inboundPlans", inboundPlans); + ReflectionTestUtils.setField(service, "inboundPlanLines", inboundPlanLines); + ReflectionTestUtils.setField(service, "inboundPlanPallets", inboundPlanPallets); + ReflectionTestUtils.setField(service, "inboundPlanAllocations", inboundPlanAllocations); ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "orderDetails", orderDetails); ReflectionTestUtils.setField(service, "orderHistories", orderHistories); @@ -103,6 +153,116 @@ class WarehouseTransferServiceTest { lenient().when(inventoryAllocations.selectList(any())).thenReturn(List.of()); lenient().when(transferReceipts.selectList(any())).thenReturn(List.of()); lenient().when(transferCartons.listOrder(anyString(), anyLong())).thenReturn(List.of()); + lenient().when(measurementPhotos.listOrder(anyString(), anyLong())).thenReturn(List.of()); + lenient().when(measurementPhotos.listSubmitted(anyString(), anyLong())).thenReturn(List.of()); + lenient().when(transferPalletItems.listOrder(anyString(), anyLong())).thenReturn(List.of()); + lenient().when(relabelBindings.listOrder(anyString(), anyLong())).thenReturn(List.of()); + } + + @Test + void generatedPalletRetryWithSameKeyDoesNotGenerateAnotherPhysicalPallet() { + WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_PDA_RECEIVING); + transfer.setOperatingWarehouseId("77"); + WarehouseTransferPallet pallet = measuredPallet("GENERATED"); + pallet.setGenerateIdempotencyKey("GEN-1"); + when(transfers.lockForPda("WTR-1", "77")).thenReturn(transfer); + when(transferPallets.lockOne("TENANT-A", transfer.getId(), pallet.getId())).thenReturn(pallet); + when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(transferItems.selectList(any())).thenReturn(List.of()); + when(sourceOrders.selectList(any())).thenReturn(List.of()); + when(transferPallets.listOrder("TENANT-A", transfer.getId())).thenReturn(List.of(pallet)); + + service.generatePallet(77L, "WTR-1", pallet.getId(), new WarehouseTransferRequests.PalletAction("GEN-1")); + + verify(warehouseResources, never()).ensurePalletBatch(any()); + verify(warehouseResources, never()).confirmPalletBatch(any()); + } + + @Test + void scanBindingRejectsCodeOtherThanThisGeneratedPallet() { + WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_PDA_RECEIVING); + WarehouseTransferPallet pallet = measuredPallet("GENERATED"); + when(transfers.lockForPda("WTR-1", "77")).thenReturn(transfer); + when(transferPallets.lockOne("TENANT-A", transfer.getId(), pallet.getId())).thenReturn(pallet); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> service.bindPallet( + 77L, "WTR-1", pallet.getId(), new WarehouseTransferRequests.PalletBind("OTHER-PALLET", "BIND-1"))); + + assertEquals(409, exception.getStatusCode().value()); + verify(warehouseResources, never()).resolvePallet(anyString()); + } + + @Test + void palletizeRejectsWhenAnyPersistedPalletIsNotBound() { + WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_PDA_RECEIVING); + transfer.setOperatingWarehouseId("77"); + WarehouseTransferPallet pallet = measuredPallet("GENERATED"); + when(transfers.lockForPda("WTR-1", "77")).thenReturn(transfer); + when(transferItems.selectList(any())).thenReturn(List.of(transferItem(51L, "SKU-1", 5))); + WarehouseTransferMeasurementPhoto photo = new WarehouseTransferMeasurementPhoto(); + photo.setFileId(9001L); + when(measurementPhotos.findFile("TENANT-A", transfer.getId(), 9001L)).thenReturn(photo); + when(transferPallets.listOrder("TENANT-A", transfer.getId())).thenReturn(List.of(pallet)); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> service.palletize( + "OP-1", 77L, "WTR-1", new WarehouseTransferRequests.Palletize("DONE-1", List.of(9001L)))); + + assertEquals(409, exception.getStatusCode().value()); + verify(warehouseResources, never()).ensurePalletBatch(any()); + } + + @Test + void palletizeCompletesAfterEveryPalletIsBoundAndDoesNotGenerateAgain() { + WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_PDA_RECEIVING); + transfer.setOperatingWarehouseId("77"); + transfer.setInboundCode("WTR-1"); + WarehouseTransferPallet pallet = measuredPallet("BOUND"); + WarehouseTransferItem item = transferItem(51L, "SKU-1", 5); + LastlegOrder tail = tailOrder(); + LastlegInboundOrder inbound = new LastlegInboundOrder(); + inbound.setId(transfer.getInboundOrderId()); inbound.setCustomerId("TENANT-A"); + InboundPlan inboundPlan = new InboundPlan(); inboundPlan.setId(201L); inboundPlan.setStatus("PDA_PENDING"); + InboundPlanLine inboundLine = new InboundPlanLine(); inboundLine.setId(202L); + when(transfers.lockForPda("WTR-1", "77")).thenReturn(transfer); + when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(transferItems.selectList(any())).thenReturn(List.of(item)); + when(transferPallets.listOrder("TENANT-A", transfer.getId())).thenReturn(List.of(pallet)); + WarehouseTransferMeasurementPhoto photo = new WarehouseTransferMeasurementPhoto(); + photo.setFileId(9001L); + when(measurementPhotos.findFile("TENANT-A", transfer.getId(), 9001L)).thenReturn(photo); + when(measurementPhotos.markSubmitted(eq("TENANT-A"), eq(transfer.getId()), any(), any())).thenReturn(1); + when(orders.selectById(transfer.getLastlegOrderId())).thenReturn(tail); + when(inboundOrders.selectById(transfer.getInboundOrderId())).thenReturn(inbound); + when(inboundPlans.findBySourceOrder("TENANT-A", transfer.getLastlegOrderId())).thenReturn(inboundPlan); + when(inboundPlanLines.selectOne(any())).thenReturn(inboundLine); + when(warehouseResources.requireActiveSharedWarehouse(77L)).thenReturn( + new WarehouseResourceGateway.WarehouseReference(77L, "WH-77", "Warehouse 77", "ACTIVE")); + when(inventory.stockIn(eq("TENANT-A"), eq("OP-1"), any())).thenReturn( + new InventoryCommandResponse(1L, "STOCK_IN", 801L, 901L, 5, 0, 0, 0, false, false)); + when(sourceOrders.selectList(any())).thenReturn(List.of()); + + var result = service.palletize("OP-1", 77L, "WTR-1", + new WarehouseTransferRequests.Palletize("DONE-1", List.of(9001L))); + + assertEquals(WarehouseTransferService.WAIT_CUSTOMER_CONFIRMATION, result.status()); + assertEquals("COMPLETED", pallet.getStatus()); + assertEquals(WarehouseTransferService.INBOUND_COMPLETED, tail.getCurrentStatus()); + assertEquals(5, tail.getReceivedPackageCount()); + assertEquals("RECEIVED", inbound.getStatus()); + assertEquals(5, inbound.getPackageCount()); + assertEquals("COMPLETED", inboundPlan.getStatus()); + verify(transferPalletItems).insert(any(com.baiyue.lastleg.transfer.entity.WarehouseTransferPalletItem.class)); + verify(inventory).stockIn(eq("TENANT-A"), eq("OP-1"), any()); + verify(stockReceipts).insert(any(com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt.class)); + verify(inboundOrders).updateById(inbound); + verify(inboundPlans).updateById(inboundPlan); + verify(inboundPlanPallets).insert(any(com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet.class)); + ArgumentCaptor allocation = + ArgumentCaptor.forClass(com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation.class); + verify(inboundPlanAllocations).insert(allocation.capture()); + assertEquals(5, allocation.getValue().getPickedPackageCount()); + verify(warehouseResources, never()).ensurePalletBatch(any()); + verify(warehouseResources, never()).confirmPalletBatch(any()); } @Test @@ -111,7 +271,7 @@ class WarehouseTransferServiceTest { LastlegOrder historical = historicalOrder(); when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L); when(orders.selectList(any())).thenReturn(List.of(historical)); - when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> persistedTransfer()); + when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> transfer(WarehouseTransferService.DRAFT)); when(transferItems.selectList(any())).thenReturn(List.of()); when(sourceOrders.selectList(any())).thenReturn(List.of()); @@ -120,23 +280,23 @@ class WarehouseTransferServiceTest { ArgumentCaptor tailOrder = ArgumentCaptor.forClass(LastlegOrder.class); verify(orders).insert(tailOrder.capture()); assertEquals(WarehouseTransferService.ORDER_TYPE, tailOrder.getValue().getOrderType()); - assertEquals(WarehouseTransferService.PENDING_REVIEW, tailOrder.getValue().getCurrentStatus()); - assertEquals("WAREHOUSE_TRANSFER:PENDING_REVIEW;RELABEL:PENDING_BINDING", tailOrder.getValue().getSubOrderStatus()); + assertEquals(WarehouseTransferService.DRAFT, tailOrder.getValue().getCurrentStatus()); + assertEquals("WAREHOUSE_TRANSFER:DRAFT;RELABEL:PENDING_BINDING", tailOrder.getValue().getSubOrderStatus()); assertTrue(tailOrder.getValue().getOrderNo().matches("WT-WH-\\d{8}-\\d{6}")); assertNull(tailOrder.getValue().getTransferWarehouseId()); verify(sourceOrders).insert(any(WarehouseTransferSourceOrder.class)); verify(relabelApplications, never()).insert(any(RelabelApplication.class)); assertNull(tailOrder.getValue().getRelabelOrderNo()); assertEquals("PENDING_BINDING", tailOrder.getValue().getRelabelStatus()); - assertEquals(WarehouseTransferService.PENDING_REVIEW, result.status()); - assertEquals(WarehouseTransferService.PENDING_REVIEW, result.orderStatus()); + assertEquals(WarehouseTransferService.DRAFT, result.status()); + assertEquals(WarehouseTransferService.DRAFT, result.orderStatus()); } @Test void transferWithoutRelabelDoesNotCreateRelabelDocument() { when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L); when(orders.selectList(any())).thenReturn(List.of(historicalOrder())); - when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> persistedTransfer()); + when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> transfer(WarehouseTransferService.DRAFT)); when(transferItems.selectList(any())).thenReturn(List.of()); when(sourceOrders.selectList(any())).thenReturn(List.of()); @@ -146,6 +306,27 @@ class WarehouseTransferServiceTest { assertNull(result.relabelNo()); } + @Test + void transferRelabelRejectsMultipleDestinationWarehouses() { + WarehouseTransferRequests.Create request = new WarehouseTransferRequests.Create( + null, null, "WH-1", null, "NONE", null, null, null, + null, null, null, null, "123 Pickup Road", null, null, 2, + new BigDecimal("0.1000"), "CUSTOMER_DELIVERY", LocalDateTime.of(2026, 9, 18, 10, 0), + null, null, null, "BOX-1,BOX-2", null, true, + LocalDateTime.of(2026, 9, 19, 10, 0), "Contact", "13800000000", null, "TRUCK", + "single-destination-check", List.of(new WarehouseTransferRequests.Item("SKU-1", "Product", 2, null)), + List.of( + new WarehouseTransferRequests.RelabelItem("CARTON_LABEL", List.of("BOX-1"), "SKU-1", null, 1, "FBA-1", "ONT8", null), + new WarehouseTransferRequests.RelabelItem("CARTON_LABEL", List.of("BOX-2"), "SKU-1", null, 1, "FBA-2", "ACY2", null))); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.create("TENANT-A", "USER-1", request)); + + assertEquals(400, exception.getStatusCode().value()); + assertEquals("Warehouse transfer relabel only supports one destination warehouse per order", exception.getReason()); + verifyNoInteractions(transfers); + } + @Test void transferCanEnterTheTransitWarehouseBeforeFbaDestinationIsKnown() { WarehouseTransferRequests.Create request = new WarehouseTransferRequests.Create( @@ -155,7 +336,7 @@ class WarehouseTransferServiceTest { List.of(new WarehouseTransferRequests.Item("SKU-1", "Product", 5, "MARK-1"))); when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L); when(orders.selectList(any())).thenReturn(List.of(historicalOrder())); - when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> persistedTransfer()); + when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> transfer(WarehouseTransferService.DRAFT)); when(transferItems.selectList(any())).thenReturn(List.of()); when(sourceOrders.selectList(any())).thenReturn(List.of()); @@ -179,7 +360,7 @@ class WarehouseTransferServiceTest { null, "TRACK-MUST-NOT-BE-SAVED", null, "Incoming transit cargo", true, "key-incoming", List.of(new WarehouseTransferRequests.Item("SKU-1", "Product A", 2, null), new WarehouseTransferRequests.Item("SKU-2", "Product B", 3, null))); - when(transfers.findNo(eq("TENANT-A"), anyString())).thenAnswer(invocation -> persistedTransfer()); + when(transfers.findNo(eq("TENANT-A"), anyString())).thenAnswer(invocation -> transfer(WarehouseTransferService.DRAFT)); when(transferItems.selectList(any())).thenReturn(List.of()); when(sourceOrders.selectList(any())).thenReturn(List.of()); @@ -199,7 +380,7 @@ class WarehouseTransferServiceTest { assertEquals(transfer.getValue().getTransferNo(), transfer.getValue().getInboundCode()); assertEquals(transfer.getValue().getTransferNo(), inbound.getValue().getInboundCode()); assertEquals("OVERSEAS", inbound.getValue().getDestinationType()); - assertEquals(WarehouseTransferService.PENDING_REVIEW, inbound.getValue().getStatus()); + assertEquals(WarehouseTransferService.DRAFT, inbound.getValue().getStatus()); assertNull(transfer.getValue().getTrackingNo()); assertEquals("PENDING_BINDING", tailOrder.getValue().getRelabelStatus()); assertEquals(List.of(1, 2, 3, 4, 5), cartons.getAllValues().stream() @@ -209,7 +390,7 @@ class WarehouseTransferServiceTest { } @Test - void approvedTransferWithoutRelabelEntersPendingAssignment() { + void approvedTransferWithoutRelabelEntersInboundMeasuring() { WarehouseTransferOrder transfer = persistedTransfer(); transfer.setStatus(WarehouseTransferService.PENDING_REVIEW); transfer.setNeedRelabel(false); @@ -225,14 +406,134 @@ class WarehouseTransferServiceTest { new WarehouseTransferRequests.Review("APPROVE", null)); assertEquals(WarehouseTransferService.APPROVED, transfer.getStatus()); - assertEquals(WarehouseTransferService.PENDING_ASSIGNMENT, tailOrder.getCurrentStatus()); + assertEquals(WarehouseTransferService.INBOUND_MEASURING, tailOrder.getCurrentStatus()); assertEquals(WarehouseTransferService.APPROVED, result.status()); - assertEquals(WarehouseTransferService.PENDING_ASSIGNMENT, result.orderStatus()); + assertEquals(WarehouseTransferService.INBOUND_MEASURING, result.orderStatus()); verify(transfers).updateById(transfer); verify(orders).updateById(tailOrder); verifyNoInteractions(inventory); } + @Test + void approvedIncomingTransferCreatesPendingInboundPlan() { + WarehouseTransferOrder transfer = persistedTransfer(); + transfer.setInboundCode("WTR-1"); transfer.setOperatingWarehouseId("77"); + transfer.setEstimatedArrivalAt(LocalDateTime.of(2026, 9, 17, 10, 0)); + LastlegOrder tailOrder = tailOrder(); tailOrder.setCurrentStatus(WarehouseTransferService.PENDING_REVIEW); + LastlegInboundOrder 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(tailOrder); + when(inboundOrders.selectById(101L)).thenReturn(inbound); + when(transferItems.selectList(any())).thenReturn(List.of()); + when(sourceOrders.selectList(any())).thenReturn(List.of()); + + service.review("TENANT-A", "WTR-1", "USER-1", new WarehouseTransferRequests.Review("APPROVE", null)); + + ArgumentCaptor plan = ArgumentCaptor.forClass(InboundPlan.class); + ArgumentCaptor line = ArgumentCaptor.forClass(InboundPlanLine.class); + verify(inboundPlans).insert(plan.capture()); + verify(inboundPlanLines).insert(line.capture()); + assertEquals("PDA_PENDING", plan.getValue().getStatus()); + assertEquals("WT-WTR-1", plan.getValue().getPlanNo()); + assertEquals("WTR-1", line.getValue().getInboundCode()); + assertEquals(5, line.getValue().getRequiredPackageCount()); + assertEquals("PENDING_INBOUND", inbound.getStatus()); + assertEquals(WarehouseTransferService.WAIT_PDA_RECEIVING, transfer.getStatus()); + } + + @Test + void merchantSubmitsDraftForReviewBeforePrintingLabels() { + WarehouseTransferOrder transfer = persistedTransfer(); + transfer.setStatus(WarehouseTransferService.DRAFT); + transfer.setInboundCode("WTR-1"); + LastlegOrder tailOrder = historicalOrder(); + tailOrder.setId(31L); tailOrder.setCustomerId("TENANT-A"); tailOrder.setRelabelStatus("NOT_REQUIRED"); + LastlegInboundOrder inbound = new LastlegInboundOrder(); + inbound.setId(101L); inbound.setCustomerId("TENANT-A"); inbound.setStatus(WarehouseTransferService.DRAFT); + when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(orders.selectById(31L)).thenReturn(tailOrder); + when(inboundOrders.selectById(101L)).thenReturn(inbound); + when(transferItems.selectList(any())).thenReturn(List.of()); + when(sourceOrders.selectList(any())).thenReturn(List.of()); + + var result = service.submit("TENANT-A", "WTR-1", "USER-1"); + + assertEquals(WarehouseTransferService.PENDING_REVIEW, transfer.getStatus()); + assertEquals(WarehouseTransferService.PENDING_REVIEW, tailOrder.getCurrentStatus()); + assertEquals(WarehouseTransferService.PENDING_REVIEW, inbound.getStatus()); + assertEquals(WarehouseTransferService.PENDING_REVIEW, result.status()); + verify(transfers).updateById(transfer); + verify(orders).updateById(tailOrder); + verify(inboundOrders).updateById(inbound); + } + + @Test + void merchantUpdatesDraftAndRebuildsEditableSnapshots() { + WarehouseTransferOrder transfer = persistedTransfer(); + transfer.setStatus(WarehouseTransferService.DRAFT); + LastlegOrder tailOrder = historicalOrder(); + tailOrder.setId(31L); tailOrder.setCustomerId("TENANT-A"); tailOrder.setCurrentStatus(WarehouseTransferService.DRAFT); + when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(orders.selectById(31L)).thenReturn(tailOrder); + when(transferItems.selectList(any())).thenReturn(List.of()); + when(sourceOrders.selectList(any())).thenReturn(List.of()); + + var result = service.updateDraft("TENANT-A", "WTR-1", "USER-1", request(false)); + + assertEquals(WarehouseTransferService.DRAFT, result.status()); + assertEquals("WH-1", transfer.getOperatingWarehouseId()); + assertEquals(new BigDecimal("0.2500"), tailOrder.getVolumeCbm()); + verify(transfers).updateById(transfer); + verify(orders).updateById(tailOrder); + verify(transferCartons).delete(any()); + verify(transferItems).delete(any()); + verify(relabelBindings).delete(any()); + verify(orderDetails).delete(any()); + verify(transferItems).insert(any(WarehouseTransferItem.class)); + verify(transferCartons, times(5)).insert(any(WarehouseTransferCarton.class)); + verify(orderDetails).insert(any(LastlegOrderDetail.class)); + } + + @Test + void merchantCannotUpdateSubmittedTransfer() { + WarehouseTransferOrder transfer = persistedTransfer(); + when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, + () -> service.updateDraft("TENANT-A", "WTR-1", "USER-1", request(false))); + + assertEquals(409, error.getStatusCode().value()); + verify(transfers, never()).updateById(any(WarehouseTransferOrder.class)); + } + + @Test + void merchantDraftUpdateIsTenantScoped() { + when(transfers.lockNo("TENANT-B", "WTR-1")).thenReturn(null); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, + () -> service.updateDraft("TENANT-B", "WTR-1", "USER-2", request(false))); + + assertEquals(404, error.getStatusCode().value()); + verify(transfers, never()).updateById(any(WarehouseTransferOrder.class)); + } + + @Test + void draftCannotPrintCartonLabelsBeforeSubmission() { + WarehouseTransferOrder transfer = persistedTransfer(); + transfer.setStatus(WarehouseTransferService.DRAFT); + when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, + () -> service.cartonLabel("TENANT-A", "WTR-1")); + + assertEquals(409, error.getStatusCode().value()); + verify(transferCartons, never()).listOrder(anyString(), anyLong()); + } + @Test void rejectionRequiresNonBlankReasonBeforeAnyStateOrInventoryChange() { for (String decision : List.of("REJECT", "REJECTED")) { @@ -378,6 +679,67 @@ class WarehouseTransferServiceTest { assertEquals("RESERVED", allocation.getStatus()); } + @Test + void outboundRejectsTransferWithoutFbaDestinationBeforeInventoryMutation() { + WarehouseTransferOrder transfer = transfer(WarehouseTransferService.ALLOCATED); + transfer.setFbaWarehouseCode(null); + when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> + service.outbound("TENANT-A", "WTR-1", "USER-1", + new WarehouseTransferRequests.Outbound("OUT-NO-FBA", null))); + + assertEquals(409, exception.getStatusCode().value()); + verifyNoInteractions(inventory); + verify(inventoryAllocations, never()).lockTransfer(anyString(), anyLong()); + verify(transfers, never()).updateById(any(WarehouseTransferOrder.class)); + } + + @Test + void merchantMeasurementPhotosExposeOnlySubmittedSelection() { + WarehouseTransferOrder transfer = persistedTransfer(); + WarehouseTransferMeasurementPhoto selected = new WarehouseTransferMeasurementPhoto(); + selected.setFileId(101L); selected.setFileName("selected.jpg"); selected.setContentType("image/jpeg"); + selected.setSizeBytes(12L); selected.setUploadedAt(LocalDateTime.of(2026, 9, 16, 10, 0)); + when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(measurementPhotos.listSubmitted("TENANT-A", 21L)).thenReturn(List.of(selected)); + + var result = service.measurementPhotos("TENANT-A", "WTR-1"); + + assertEquals(List.of(101L), result.stream().map(value -> value.fileId()).toList()); + verify(measurementPhotos, never()).listOrder("TENANT-A", 21L); + } + + @Test + void merchantCannotDownloadUploadedButUnsubmittedMeasurementPhoto() { + WarehouseTransferOrder transfer = persistedTransfer(); + when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(measurementPhotos.findSubmittedFile("TENANT-A", 21L, 102L)).thenReturn(null); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.downloadMeasurementPhoto("TENANT-A", "WTR-1", 102L)); + + assertEquals(404, exception.getStatusCode().value()); + verifyNoInteractions(measurementPhotoFiles); + } + + @Test + void pdaCanDownloadOwnWarehouseUploadedPhotoBeforeSubmission() { + WarehouseTransferOrder transfer = persistedTransfer(); + WarehouseTransferMeasurementPhoto draft = new WarehouseTransferMeasurementPhoto(); + draft.setFileId(102L); + var downloaded = new TransferMeasurementPhotoFileGateway.DownloadedFile( + new byte[]{1}, "draft.jpg", "image/jpeg"); + when(transfers.findNoByWarehouse("WTR-1", "92")).thenReturn(transfer); + when(measurementPhotos.findFile("TENANT-A", 21L, 102L)).thenReturn(draft); + when(measurementPhotoFiles.download("TENANT-A", 21L, 102L)).thenReturn(downloaded); + + var result = service.downloadPdaMeasurementPhoto(92L, "WTR-1", 102L); + + assertEquals("draft.jpg", result.fileName()); + verify(measurementPhotos, never()).findSubmittedFile(anyString(), anyLong(), anyLong()); + } + @Test void allocationRejectsPositionOutsideSelectedInboundOrder() { WarehouseTransferOrder transfer = transfer("APPROVED"); @@ -427,6 +789,7 @@ class WarehouseTransferServiceTest { transfer.setInboundOrderId(101L); transfer.setInboundCode("IN-1"); transfer.setLastlegOrderId(31L); transfer.setLastlegOrderNo("LMO-TRANSFER-1"); transfer.setNeedRelabel(false); transfer.setStatus(WarehouseTransferService.PENDING_REVIEW); transfer.setPackageCount(5); + transfer.setFbaWarehouseCode("ONT8"); return transfer; } @@ -436,6 +799,24 @@ class WarehouseTransferServiceTest { return transfer; } + private static WarehouseTransferPallet measuredPallet(String status) { + WarehouseTransferPallet pallet = new WarehouseTransferPallet(); + pallet.setId(61L); pallet.setTransferOrderId(21L); pallet.setCustomerId("TENANT-A"); + pallet.setSequenceNo(1); pallet.setWarehousePalletId(701L); pallet.setPalletNo("NEW-PALLET-1"); + pallet.setCartonCount(5); pallet.setLengthCm(new BigDecimal("100.00")); + pallet.setWidthCm(new BigDecimal("100.00")); pallet.setHeightCm(new BigDecimal("100.00")); + pallet.setVolumeCbm(new BigDecimal("1.000000")); pallet.setGrossWeightKg(new BigDecimal("50.000")); + pallet.setStatus(status); + return pallet; + } + + private static WarehouseTransferItem transferItem(long id, String sku, int packages) { + WarehouseTransferItem item = new WarehouseTransferItem(); + item.setId(id); item.setTransferOrderId(21L); item.setCustomerId("TENANT-A"); + item.setSkuCode(sku); item.setProductNameZh("Product"); item.setPackageCount(packages); + return item; + } + private static LastlegOrder tailOrder() { LastlegOrder order = historicalOrder(); order.setId(31L); order.setCurrentStatus("PENDING_ASSIGNMENT"); diff --git a/scripts/README.md b/scripts/README.md index 8a5c942b3..817f4a5fa 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,9 @@ # 后端脚本管理与全量 HTTP 验收索引 +`db/relabel-wait-status-incremental.sql`:存量换标主单状态修正,显式使用 `baiyue_lastleg`。由操作者选择已授权数据库连接执行,无内置凭据;只更新未生成入库单、仍为待分配的 `RELABEL_APPLICATION` 主单为待换标/待排车/待出库,不修改库存或删除单据。事务执行、可重复运行,执行后按换标号回查订单状态;无单独报告文件。 + +`db/warehouse-transfer-outbound-incremental.sql`:固定指向 `baiyue_lastleg`,一次补齐仓储转运审核、提货、PDA 测量打托、现场照片、换标、FBA/私人仓目的地、入库计划、智能排车和派送所需表、租户字段及索引,并将换标新 SKU 和转运托盘生命周期字段修正为本地要求的可空结构。无内置凭据;表使用 `CREATE TABLE IF NOT EXISTS`,字段和索引通过 `information_schema` 独立判断,可重复执行,不删除或改写业务单据。完整成功时返回 `WAREHOUSE_TRANSFER_LATEST_SCHEMA_READY`;若历史数据存在同客户重复托盘号,则返回 `WAREHOUSE_TRANSFER_SCHEMA_APPLIED_WITH_INDEX_BLOCKER`,且 `inbound_pallet_unique_index` 为 `BLOCKED_BY_DUPLICATE_CUSTOMER_PALLET_NO`。其余结构仍正常补齐,脚本不会擅自修改历史托盘号。 + `mixed-container-outbound-test-env.mjs` 是经明确授权的测试环境留存混柜出库续跑入口:参数为 `source-report containerNo MIXED-FBA|MIXED-DROPSHIP output-report`,凭据由显式 `node --env-file` 注入。FBA 分支实时校验既有收货数据,创建排车、出库、派送及临时 PDA 账号,执行拣货、发车、测试签收文件保存和国内状态回查;一件代发分支只走专用候选、两托四箱出库、PDA 幂等拣货和完成回查,并明确记录派送未覆盖,不返回全流程成功退出码。finally 停用临时账号,不删除业务数据。输出报告作为检查点,失败后须先核对其中业务单号和实时状态,再用同一输出文件续跑;不得删除报告后盲目重试。它不代表普通海外仓目的地转换或所有分支签收验收通过。 全量验收只认测试环境 HTTP 脚本。它们通过 Gateway 调用已部署服务,按业务模块执行真实登录、权限、状态、幂等、跨服务和数据链路检查;脚本通过后,剩余工作是 Admin/Client 的真实接口调用与浏览器样式验收,以及 PDA 页面/设备验收。 @@ -71,6 +75,7 @@ Client 全量、Client 入库测量与计费、待审核订单和批量待分柜 | 测试环境 HTTP | `client-phase2-test-env-acceptance.mjs` | Client 入库、测量、费用和权限专项;写测试数据 | | 测试环境 HTTP | `create-pending-review-orders.mjs` | 固定客户批量创建并提交待审核订单,不做平台审核和后续履约 | | 测试环境 HTTP | `it-test-wait-split-bulk.mjs` | 固定“IT测试”与“义乌仓”,渠道等随机,逐票推进到 `WAIT_SPLIT`;失败继续并落报告。只复用 `TP-001`/`YW-TP-002`,不新增托盘或库位,库位不足使用公共位 `A0` | +| 本地真实 API | `relabel-closure-local-acceptance.mjs` | 仅调用本机海外 Gateway/Print/File/Lastleg,创建合成换标单并验证重复下单、三类 PDF、PDA 换标、拆托/合托、返工和换标出库;会写入本地业务数据,不代表现场照片或财务验收 | | 测试环境 Excel 造数 | `real-order-xlsx-test-env.mjs` | 从外部源 XLSX 按入仓码归并,严格匹配客户可用渠道、税制、目的仓;`preflight` 只核对,`create` 创建草稿,`pending-review` 批量提交后停在待审核且不使用托盘/库位,`wait-split` 只供少量完整测量验收。参数、限制及重跑说明见 `real-order-xlsx-import.md` | | 测试环境 HTTP | `dimension-ocr-pricing-test-env.ps1` | 尺寸、OCR 和渠道计价专项,上传本地图片并写测试业务数据;包含历史默认参数,只能显式传入当前目标参数后使用 | | 测试环境 HTTP | `export-center-test-env-acceptance.ps1` | 导出中心测试环境验收,会创建导出任务和相关测试数据 | @@ -276,6 +281,10 @@ Dry-run 只校验两个统一 SQL 的入口标记,打印当前 SHA-256、行 ## 海外换标定向验收补充 -- `scripts/current-session-overseas-relabel-acceptance.mjs`:本地开发环境定向入口,从当前 Codex 内置浏览器本地存储读取最新平台/客户 refresh token,在内存中刷新双区域会话,自动选择当前客户具备足量库存的海外仓并临时创建海外 PDA 操作员;随后串行运行换标拆/合托、智能预排、PDA 出库与派送完成。报告写入 `reports/current-session-overseas-relabel/`,不记录 Token/密码;会消耗当前浏览器 refresh token 并保留业务验收单据,临时 PDA 在 `finally` 中停用。只允许本机开发环境且须有当前轮真实写入授权。 +- `db/seed-local-relabel-ready-orders.sql`:仅供明确授权的本机测试库存准备。调用者在同一 MySQL 会话提供客户、共享海外仓、有效虚拟库位、FBA目的仓、唯一批次及ID基数;凭据由本机进程环境提供。以事务插入三单共54箱、六托及原入仓快照、计划、明细、余额、仓位和明确标记 LOCAL_TEST_FIXTURE 的库存流水;不伪造PDA照片、签收操作或HTTP验收通过,不修改原数据。重复批次失败并回滚,不用upsert重置已消耗库存;成功数据保留,结果表输出到调用者日志,不自动清理。 + +- `scripts/current-session-overseas-relabel-acceptance.mjs`:本地开发环境定向入口,从当前 Codex 内置浏览器本地存储读取最新平台/客户 refresh token,在内存中刷新双区域会话,自动选择当前客户具备足量库存的海外仓并临时创建海外 PDA 操作员;随后串行运行换标拆/合托、智能预排、PDA 出库与派送完成。报告写入 `reports/current-session-overseas-relabel/`,不记录 Token/密码;会消耗当前浏览器 refresh token 并保留业务验收单据,临时 PDA 在 `finally` 中停用。履约阶段中断后可显式设置 `OVERSEAS_RESUME_FULFILLMENT=true`,复用目录内已通过的 `relabel.json` 且只续跑出库派送。只允许本机开发环境且须有当前轮真实写入授权。 - `scripts/overseas-relabel-real-acceptance.mjs`:定向真实 HTTP 换标作业,会创建换标、库存及排车记录;从显式环境文件读取平台/PDA账号、网关及客户参数,输出脚本配置的报告,不自动清理。失败保留证据与已创建资源,重跑前核对报告,必须另获指定环境授权。 +- 该脚本现按“扫码→组托→完工→客户确认”执行,`OVERSEAS_RELABEL_CHARGE_AMOUNT=0` 表示省略费用字段、只验仓库链路(不代表财务通过);默认 0.01 验钱包冻结。照片为有效的合成测试 PNG,不是真实现场照片。 +- `scripts/relabel-pdf-local-acceptance.mjs`:仅本机 Print→File 真实 HTTP 验收,显式提供 `INTERNAL_SERVICE_TOKEN`、`OVERSEAS_TEST_CUSTOMER_ID`,可配置 `PRINT_SERVICE_URL`、`FILE_SERVICE_URL`。创建三类测试 PDF 文件元数据,验证下载哈希及跨客户拒绝,报告 `reports/relabel-closure-20260916/pdf.json`;保留文件,不清理,失败非零退出,可重跑生成新 businessId。不代表换标下单/PDA全流程通过。 - `scripts/overseas-relabel-fulfillment-acceptance.mjs`:读取 `OVERSEAS_ACCEPTANCE_SOURCE` 或指定排车/换标编号,执行 PDA 拣货、出库、派送后续真实 HTTP,会推进业务状态;凭据来自显式环境文件,报告默认 `reports/overseas-relabel-fulfillment-acceptance.json`(`OVERSEAS_FULFILLMENT_OUTPUT` 可覆盖)。失败保留资源,重跑前核对业务状态,不自动清理。两个定向脚本不替代 Admin 全量入口,本次合并仅做离线合同。 diff --git a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs index 8b139fde4..271985394 100644 --- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs +++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs @@ -9,7 +9,7 @@ const domesticSchemas = ['baiyue_auth', 'baiyue_iam', 'baiyue_order', 'baiyue_wa const overseasSchemas = ['baiyue_event_bridge_overseas', 'baiyue_lastleg', 'baiyue_lastlag_warehouse'] test('两个入口分别只操作国内九库与海外三库,包括限定名引用', () => { - for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 208], [overseas, overseasSchemas, domesticSchemas, 136]]) { + for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 208], [overseas, overseasSchemas, domesticSchemas, 138]]) { assert.deepEqual([...sql.matchAll(/^CREATE DATABASE IF NOT EXISTS (\w+)/gm)].map(m => m[1]), allowed) assert.deepEqual([...new Set([...sql.matchAll(/^USE (\w+);/gm)].map(m => m[1]))], allowed) const body = sql.replace(/^--.*$/gm, '') @@ -28,7 +28,7 @@ test('纯初始化禁止迁移、数据回填、清理、重复建表和提前 assert.doesNotMatch(body.replace(/^CREATE TRIGGER [\s\S]*?\$\$/gm, ''), /^\s*(?:UPDATE|DELETE FROM|REPLACE INTO)\b/gmi) assert.doesNotMatch(body, /CREATE TABLE IF NOT EXISTS/i) assert.equal((body.match(/AS initialization_status/g) ?? []).length, 1) - assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (208|136) AS initialized_tables;$/) + assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (208|138) AS initialized_tables;$/) } }) diff --git a/scripts/current-session-overseas-relabel-acceptance.mjs b/scripts/current-session-overseas-relabel-acceptance.mjs index df617b1d5..2e43cc6de 100644 --- a/scripts/current-session-overseas-relabel-acceptance.mjs +++ b/scripts/current-session-overseas-relabel-acceptance.mjs @@ -113,6 +113,43 @@ async function createPda(adminToken, warehouseId, onCreated) { throw new Error('Temporary PDA security projection did not converge') } +async function merchantSessionForTenant(adminToken, tenantId) { + const ticket = await api(domestic, adminToken, 'POST', + `/api/platform/v1/iam/tenants/${tenantId}/impersonation`, { reason: `${marker} 海外换标真实验收` }) + return api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code }) +} + +async function findRelabelScope(adminToken, preferredMerchant = null) { + const sessions = [] + if (preferredMerchant?.accessToken && preferredMerchant?.overseasAccessToken) sessions.push(preferredMerchant) + if (!sessions.length) { + const tenants = await api(domestic, adminToken, 'GET', '/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE') + for (const tenant of tenants.records || []) { + try { sessions.push(await merchantSessionForTenant(adminToken, id(tenant.id))) } catch {} + } + } + for (const session of sessions) { + const customerId = jwtId(session.overseasAccessToken, 'tenant_id', 'tenantId') + if (!customerId) continue + let warehouses + try { warehouses = await api(domestic, session.accessToken, 'GET', '/api/merchant/v1/warehouses?warehouseType=OVERSEAS&page=1&size=200') } + catch { continue } + for (const warehouse of warehouses.records || []) { + if (warehouse.status !== 'ACTIVE') continue + let candidates + try { candidates = await api(overseas, session.overseasAccessToken, 'GET', `/api/merchant/v1/last-leg/relabel-applications/inventory-candidates?operationWarehouseId=${encodeURIComponent(id(warehouse.id))}`) } + catch { continue } + if (candidates.some((item) => String(item.inboundOrderNo || '').startsWith('DS-') + && Number(item.availablePackages) >= 5 && item.destinationFbaWarehouseCode + && item.positions?.some((position) => position.palletNo && position.palletNo !== 'UNBOUND' + && Number(position.availablePackages) >= 5))) { + return { merchant: session, customerId, warehouse } + } + } + } + throw new Error('No active customer has a destination warehouse and five cartons on a physical source pallet') +} + await mkdir(outputDir, { recursive: true }) let admin let pda @@ -124,29 +161,22 @@ try { } const storedMerchantToken = storedJwt('cc-client-overseas-access-token') const storedCustomerId = jwtId(storedMerchantToken, 'tenant_id', 'tenantId') - let merchant + let preferredMerchant = null try { - merchant = await refreshStoredSession('cc-client-refresh-token', 'merchant') + preferredMerchant = await refreshStoredSession('cc-client-refresh-token', 'merchant') } catch { - assert(storedCustomerId, 'Current Codex merchant session does not contain a customer id') - const ticket = await api(domestic, admin.accessToken, 'POST', - `/api/platform/v1/iam/tenants/${storedCustomerId}/impersonation`, { reason: `${marker} 当前客户海外API验收` }) - merchant = await api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code }) + if (storedCustomerId) preferredMerchant = await merchantSessionForTenant(admin.accessToken, storedCustomerId) } - assert(admin.accessToken && admin.overseasAccessToken && merchant.accessToken && merchant.overseasAccessToken, 'Refresh response did not contain both regional tokens') - const customerId = jwtId(merchant.overseasAccessToken, 'tenant_id', 'tenantId') - assert(customerId, 'Current merchant customer id is missing from the overseas token') - const warehouses = await api(domestic, merchant.accessToken, 'GET', '/api/merchant/v1/warehouses?warehouseType=OVERSEAS&page=1&size=200') - let selected = null - for (const warehouse of warehouses.records || []) { - if (warehouse.status !== 'ACTIVE') continue - const candidates = await api(overseas, merchant.overseasAccessToken, 'GET', `/api/merchant/v1/last-leg/relabel-applications/inventory-candidates?operationWarehouseId=${encodeURIComponent(id(warehouse.id))}`) - if (candidates.some((item) => String(item.inboundOrderNo || '').startsWith('DS-') && Number(item.availablePackages) >= 5 && item.destinationFbaWarehouseCode && item.positions?.some((position) => Number(position.availablePackages) >= 5))) { - selected = warehouse; break - } - } - assert(selected, 'Current merchant has no active overseas warehouse with a five-carton DS inventory source') - await disableStalePdas(admin.accessToken) + assert(admin.accessToken && admin.overseasAccessToken, 'Platform refresh response did not contain both regional tokens') + const resume = process.env.OVERSEAS_RESUME_FULFILLMENT === 'true' + if (resume && (!process.env.OVERSEAS_TEST_CUSTOMER_ID || !process.env.OVERSEAS_TEST_WAREHOUSE_ID)) throw new Error('Resume requires explicit customer and warehouse from the saved run') + const scope = resume ? { + merchant: await merchantSessionForTenant(admin.accessToken, process.env.OVERSEAS_TEST_CUSTOMER_ID), + customerId: process.env.OVERSEAS_TEST_CUSTOMER_ID, + warehouse: { id: process.env.OVERSEAS_TEST_WAREHOUSE_ID, warehouseCode: process.env.OVERSEAS_TEST_WAREHOUSE_CODE || null }, + } : await findRelabelScope(admin.accessToken, preferredMerchant) + const { merchant, customerId, warehouse: selected } = scope + // Do not disable accounts from another acceptance run; cleanup owns only this run's account. pda = await createPda(admin.accessToken, id(selected.id), (created) => { pda = created }) const relabelReport = join(outputDir, 'relabel.json') @@ -156,7 +186,10 @@ try { PDA_OVERSEAS_ACCESS_TOKEN: pda.token, OVERSEAS_TEST_CUSTOMER_ID: customerId, OVERSEAS_TEST_WAREHOUSE_ID: id(selected.id), OVERSEAS_ACCEPTANCE_OUTPUT: relabelReport, OVERSEAS_ACCEPTANCE_SOURCE: relabelReport, OVERSEAS_FULFILLMENT_OUTPUT: fulfillmentReport } - for (const script of ['overseas-relabel-real-acceptance.mjs', 'overseas-relabel-fulfillment-acceptance.mjs']) { + const scripts = process.env.OVERSEAS_CLOSURE_EXTRA === 'true' ? ['relabel-closure-local-acceptance.mjs'] : process.env.OVERSEAS_RESUME_FULFILLMENT === 'true' + ? ['overseas-relabel-fulfillment-acceptance.mjs'] + : ['overseas-relabel-real-acceptance.mjs', 'overseas-relabel-fulfillment-acceptance.mjs'] + for (const script of scripts) { const result = spawnSync(process.execPath, [resolve('scripts', script)], { cwd: resolve('.'), env: childEnv, stdio: 'inherit' }) if (result.error) throw result.error if (result.status !== 0) throw new Error(`${script} failed with exit code ${result.status}`) diff --git a/scripts/db/dev-lastleg-relabel-return-incremental.sql b/scripts/db/dev-lastleg-relabel-return-incremental.sql index fb1e62737..497d468c1 100644 --- a/scripts/db/dev-lastleg-relabel-return-incremental.sql +++ b/scripts/db/dev-lastleg-relabel-return-incremental.sql @@ -17,6 +17,33 @@ BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少基础表 ll_relabel_item,请先执行海外尾程基线'; END IF; + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order') THEN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='pickup_contact_name') THEN + ALTER TABLE ll_warehouse_transfer_order ADD COLUMN pickup_contact_name VARCHAR(128) NULL COMMENT '提货联系人' AFTER delivery_address; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='pickup_contact_phone') THEN + ALTER TABLE ll_warehouse_transfer_order ADD COLUMN pickup_contact_phone VARCHAR(64) NULL COMMENT '提货联系电话' AFTER pickup_contact_name; + END IF; + END IF; + + CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_measurement_photo ( + id BIGINT NOT NULL COMMENT '测量照片引用ID', transfer_order_id BIGINT NOT NULL COMMENT '仓储转运单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', file_id BIGINT NOT NULL COMMENT '文件服务文件ID', + file_name VARCHAR(255) NULL COMMENT '文件名快照', content_type VARCHAR(128) NULL COMMENT '文件媒体类型快照', + size_bytes BIGINT NOT NULL DEFAULT 0 COMMENT '文件字节数快照', uploaded_by VARCHAR(128) NULL COMMENT 'PDA上传操作人', + uploaded_at DATETIME(3) NOT NULL COMMENT '上传时间', submitted_at DATETIME(3) NULL COMMENT '随测量打托提交时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_transfer_measurement_photo_file(customer_id,transfer_order_id,file_id), + KEY idx_ll_transfer_measurement_photo_order(customer_id,transfer_order_id,uploaded_at) + ) ENGINE=InnoDB COMMENT='仓储转运PDA测量现场照片引用'; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_measurement_photo' AND column_name='submitted_at') THEN + ALTER TABLE ll_warehouse_transfer_measurement_photo ADD COLUMN submitted_at DATETIME(3) NULL COMMENT '随测量打托提交时间' AFTER uploaded_at; + END IF; + + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_outbound_order') + AND NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='request_hash') THEN + ALTER TABLE ll_outbound_order ADD COLUMN request_hash CHAR(64) NULL COMMENT '一件代发创建请求摘要,其他出库类型为空' AFTER recipient_info; + END IF; + -- 贴标申请:FBA目的仓、换标模式、SKU组合明细及原库存快照。 IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='destination_fba_warehouse_code') THEN ALTER TABLE ll_relabel_item ADD COLUMN destination_fba_warehouse_code VARCHAR(128) NULL COMMENT 'FBA目的仓编码(与海外操作仓分离)'; diff --git a/scripts/db/dev-warehouse-transfer-pallet-lifecycle-incremental.sql b/scripts/db/dev-warehouse-transfer-pallet-lifecycle-incremental.sql new file mode 100644 index 000000000..e094c5edc --- /dev/null +++ b/scripts/db/dev-warehouse-transfer-pallet-lifecycle-incremental.sql @@ -0,0 +1,25 @@ +USE baiyue_lastleg; + +SET @ddl = (SELECT IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'll_warehouse_transfer_pallet' + AND column_name = 'draft_idempotency_key' + ), + 'SELECT 1', + 'ALTER TABLE ll_warehouse_transfer_pallet + MODIFY COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''生成后的海外仓真实托盘ID'', + MODIFY COLUMN pallet_no VARCHAR(128) NULL COMMENT ''生成后的真实托盘码'', + MODIFY COLUMN warehouse_location_id BIGINT NULL COMMENT ''生成后的真实库位ID'', + MODIFY COLUMN warehouse_resource_lock_id BIGINT NULL COMMENT ''生成后的仓储资源锁ID'', + MODIFY COLUMN completed_at DATETIME(3) NULL COMMENT ''整单打托完成时间'', + ADD COLUMN draft_idempotency_key VARCHAR(255) NULL COMMENT ''草稿保存幂等键'' AFTER status, + ADD COLUMN draft_request_hash CHAR(64) NULL COMMENT ''草稿请求摘要'' AFTER draft_idempotency_key, + ADD COLUMN generate_idempotency_key VARCHAR(255) NULL COMMENT ''新托盘生成幂等键'' AFTER draft_request_hash, + ADD COLUMN bind_idempotency_key VARCHAR(255) NULL COMMENT ''扫码绑定幂等键'' AFTER generate_idempotency_key, + ADD COLUMN bound_at DATETIME(3) NULL COMMENT ''扫码确认绑定时间'' AFTER bind_idempotency_key' +)); +PREPARE transfer_pallet_lifecycle_stmt FROM @ddl; +EXECUTE transfer_pallet_lifecycle_stmt; +DEALLOCATE PREPARE transfer_pallet_lifecycle_stmt; diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index f705f966d..8945db60e 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -1173,7 +1173,7 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_order ( transfer_no VARCHAR(64) NOT NULL COMMENT '转仓单号', inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', lastleg_order_id BIGINT NOT NULL COMMENT '尾程订单ID', lastleg_order_no VARCHAR(32) NOT NULL COMMENT '尾程订单号', operating_warehouse_id VARCHAR(128) NOT NULL COMMENT '入库中转仓ID', fba_warehouse_code VARCHAR(128) NULL COMMENT '可选FBA目的仓编码', - delivery_address VARCHAR(1000) NOT NULL COMMENT '提货地址或中转仓收货地址', package_count INT NOT NULL COMMENT '箱数', delivery_method VARCHAR(64) NOT NULL COMMENT '入仓方式', + delivery_address VARCHAR(1000) NOT NULL COMMENT '提货地址或中转仓收货地址', pickup_contact_name VARCHAR(128) NULL COMMENT '提货联系人', pickup_contact_phone VARCHAR(64) NULL COMMENT '提货联系电话', package_count INT NOT NULL COMMENT '箱数', delivery_method VARCHAR(64) NOT NULL COMMENT '入仓方式', estimated_arrival_at DATETIME(3) NOT NULL COMMENT '预计入仓时间', fba_shipment_no VARCHAR(128) NULL COMMENT '可选FBA货件编号', tracking_no VARCHAR(255) NULL COMMENT '物流跟踪号', fba_box_nos VARCHAR(2000) NULL COMMENT 'FBA箱号集合', remark VARCHAR(1000) NULL COMMENT '备注', need_relabel TINYINT NOT NULL DEFAULT 0 COMMENT '是否需要换标', relabel_application_id BIGINT NULL COMMENT '换标申请单ID', relabel_no VARCHAR(64) NULL COMMENT '换标申请单号', @@ -3963,6 +3963,8 @@ CREATE TABLE IF NOT EXISTS wh_pallet_batch_item ( USE baiyue_lastleg; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='lx_codes'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN lx_codes VARCHAR(1000) NULL COMMENT ''关联一件代发领星入库单代码汇总'' AFTER outbound_type')); PREPARE ll_outbound_lx_codes_stmt FROM @ddl; EXECUTE ll_outbound_lx_codes_stmt; DEALLOCATE PREPARE ll_outbound_lx_codes_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='request_hash'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN request_hash CHAR(64) NULL COMMENT ''一件代发创建请求摘要,其他出库类型为空'' AFTER recipient_info')); +PREPARE ll_outbound_request_hash_stmt FROM @ddl; EXECUTE ll_outbound_request_hash_stmt; DEALLOCATE PREPARE ll_outbound_request_hash_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='lx_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_order ADD COLUMN lx_code VARCHAR(128) NULL COMMENT ''一件代发领星入库单代码''')); PREPARE ll_inbound_lx_code_stmt FROM @ddl; EXECUTE ll_inbound_lx_code_stmt; DEALLOCATE PREPARE ll_inbound_lx_code_stmt; @@ -4201,14 +4203,16 @@ PREPARE return_order_lastleg_no_stmt FROM @ddl; EXECUTE return_order_lastleg_no_ USE baiyue_lastleg; CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet ( id BIGINT NOT NULL COMMENT '打托事实ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', - sequence_no INT NOT NULL COMMENT '托序号', warehouse_pallet_id BIGINT NOT NULL COMMENT '海外仓真实托盘ID', pallet_no VARCHAR(128) NOT NULL COMMENT '真实托盘码', - warehouse_location_id BIGINT NOT NULL COMMENT '真实库位ID', warehouse_location_code VARCHAR(128) NULL COMMENT '真实库位码', warehouse_resource_lock_id BIGINT NOT NULL COMMENT '仓储资源锁ID', + sequence_no INT NOT NULL COMMENT '托序号', warehouse_pallet_id BIGINT NULL COMMENT '生成后的海外仓真实托盘ID', pallet_no VARCHAR(128) NULL COMMENT '生成后的真实托盘码', + warehouse_location_id BIGINT NULL COMMENT '生成后的真实库位ID', warehouse_location_code VARCHAR(128) NULL COMMENT '真实库位码', warehouse_resource_lock_id BIGINT NULL COMMENT '生成后的仓储资源锁ID', carton_count INT NOT NULL COMMENT '本托实收箱数', length_cm DECIMAL(12,2) NOT NULL COMMENT '实测长cm', width_cm DECIMAL(12,2) NOT NULL COMMENT '实测宽cm', height_cm DECIMAL(12,2) NOT NULL COMMENT '实测高cm', gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '实测毛重kg', volume_cbm DECIMAL(18,6) NOT NULL COMMENT '实测体积CBM', - status VARCHAR(32) NOT NULL COMMENT '打托状态', completed_at DATETIME(3) NOT NULL COMMENT 'PDA打托完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + status VARCHAR(32) NOT NULL COMMENT '逐托状态DRAFT/GENERATED/BOUND/COMPLETED', draft_idempotency_key VARCHAR(255) NULL COMMENT '草稿保存幂等键', draft_request_hash CHAR(64) NULL COMMENT '草稿请求摘要', generate_idempotency_key VARCHAR(255) NULL COMMENT '新托盘生成幂等键', bind_idempotency_key VARCHAR(255) NULL COMMENT '扫码绑定幂等键', bound_at DATETIME(3) NULL COMMENT '扫码确认绑定时间', completed_at DATETIME(3) NULL COMMENT '整单打托完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_transfer_pallet_seq(transfer_order_id,sequence_no), UNIQUE KEY uk_ll_transfer_pallet_physical(customer_id,warehouse_pallet_id), KEY idx_ll_transfer_pallet_order(customer_id,transfer_order_id,status) ) ENGINE=InnoDB COMMENT='仓储转运PDA实测打托事实'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_pallet' AND column_name='draft_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_pallet MODIFY COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''生成后的海外仓真实托盘ID'', MODIFY COLUMN pallet_no VARCHAR(128) NULL COMMENT ''生成后的真实托盘码'', MODIFY COLUMN warehouse_location_id BIGINT NULL COMMENT ''生成后的真实库位ID'', MODIFY COLUMN warehouse_resource_lock_id BIGINT NULL COMMENT ''生成后的仓储资源锁ID'', MODIFY COLUMN completed_at DATETIME(3) NULL COMMENT ''整单打托完成时间'', ADD COLUMN draft_idempotency_key VARCHAR(255) NULL COMMENT ''草稿保存幂等键'' AFTER status, ADD COLUMN draft_request_hash CHAR(64) NULL COMMENT ''草稿请求摘要'' AFTER draft_idempotency_key, ADD COLUMN generate_idempotency_key VARCHAR(255) NULL COMMENT ''新托盘生成幂等键'' AFTER draft_request_hash, ADD COLUMN bind_idempotency_key VARCHAR(255) NULL COMMENT ''扫码绑定幂等键'' AFTER generate_idempotency_key, ADD COLUMN bound_at DATETIME(3) NULL COMMENT ''扫码确认绑定时间'' AFTER bind_idempotency_key')); +PREPARE transfer_pallet_lifecycle_stmt FROM @ddl; EXECUTE transfer_pallet_lifecycle_stmt; DEALLOCATE PREPARE transfer_pallet_lifecycle_stmt; CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet_item ( id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', transfer_pallet_id BIGINT NOT NULL COMMENT '转运托盘事实ID', transfer_item_id BIGINT NOT NULL COMMENT '转运申报明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', package_count INT NOT NULL COMMENT '本托该SKU实收箱数', @@ -4220,3 +4224,80 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_relabel_binding ( fba_carton_mark VARCHAR(255) NULL COMMENT '新FBA箱唛', destination_warehouse_code VARCHAR(128) NULL COMMENT 'FBA目的仓', remark VARCHAR(1000) NULL COMMENT '备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_transfer_relabel_mark(transfer_order_id,customer_carton_mark), KEY idx_ll_transfer_relabel_order(customer_id,transfer_order_id) ) ENGINE=InnoDB COMMENT='仓储转运换标预绑定明细'; + +-- 2026-09-16 / 039 仓储转运提货联系人独立于换标联系人。 +USE baiyue_lastleg; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='pickup_contact_name'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN pickup_contact_name VARCHAR(128) NULL COMMENT ''提货联系人'' AFTER delivery_address')); +PREPARE transfer_pickup_contact_name_stmt FROM @ddl; EXECUTE transfer_pickup_contact_name_stmt; DEALLOCATE PREPARE transfer_pickup_contact_name_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='pickup_contact_phone'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN pickup_contact_phone VARCHAR(64) NULL COMMENT ''提货联系电话'' AFTER pickup_contact_name')); +PREPARE transfer_pickup_contact_phone_stmt FROM @ddl; EXECUTE transfer_pickup_contact_phone_stmt; DEALLOCATE PREPARE transfer_pickup_contact_phone_stmt; + +-- 2026-09-16 / 040 仓储转运PDA测量现场照片引用。 +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_measurement_photo ( + id BIGINT NOT NULL COMMENT '测量照片引用ID', transfer_order_id BIGINT NOT NULL COMMENT '仓储转运单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', file_id BIGINT NOT NULL COMMENT '文件服务文件ID', + file_name VARCHAR(255) NULL COMMENT '文件名快照', content_type VARCHAR(128) NULL COMMENT '文件媒体类型快照', + size_bytes BIGINT NOT NULL DEFAULT 0 COMMENT '文件字节数快照', uploaded_by VARCHAR(128) NULL COMMENT 'PDA上传操作人', + uploaded_at DATETIME(3) NOT NULL COMMENT '上传时间', submitted_at DATETIME(3) NULL COMMENT '随测量打托提交时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_transfer_measurement_photo_file(customer_id,transfer_order_id,file_id), + KEY idx_ll_transfer_measurement_photo_order(customer_id,transfer_order_id,uploaded_at) +) ENGINE=InnoDB COMMENT='仓储转运PDA测量现场照片引用'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_measurement_photo' AND column_name='submitted_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_measurement_photo ADD COLUMN submitted_at DATETIME(3) NULL COMMENT ''随测量打托提交时间'' AFTER uploaded_at')); +PREPARE transfer_measurement_photo_submitted_stmt FROM @ddl; EXECUTE transfer_measurement_photo_submitted_stmt; DEALLOCATE PREPARE transfer_measurement_photo_submitted_stmt; + +-- 2026-09-16 / 041 仓储转运PDA实收箱数及历史完成状态回填。 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='received_package_count'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN received_package_count INT NULL COMMENT ''PDA实际入库箱数'' AFTER package_count')); +PREPARE transfer_received_package_count_stmt FROM @ddl; EXECUTE transfer_received_package_count_stmt; DEALLOCATE PREPARE transfer_received_package_count_stmt; +UPDATE ll_order o +JOIN ll_warehouse_transfer_order t + ON t.customer_id=o.customer_id AND t.lastleg_order_id=o.id AND t.transfer_no=o.source_order_no +JOIN ( + SELECT customer_id,transfer_order_id,SUM(carton_count) AS received_package_count + FROM ll_warehouse_transfer_pallet + WHERE status='COMPLETED' + GROUP BY customer_id,transfer_order_id +) p ON p.customer_id=t.customer_id AND p.transfer_order_id=t.id +SET o.received_package_count=p.received_package_count, + o.current_status='INBOUND_COMPLETED', + o.updated_at=CURRENT_TIMESTAMP(3) +WHERE o.order_type='WAREHOUSE_TRANSFER' + AND t.status IN ('WAIT_CUSTOMER_CONFIRMATION','WAIT_FBA_DESTINATION','WAIT_RELABEL','READY_FOR_DISPATCH','ALLOCATED','OUTBOUNDED','PARTIALLY_RECEIVED','RECEIVED'); +UPDATE ll_inbound_order i +JOIN ll_warehouse_transfer_order t + ON t.customer_id=i.customer_id AND t.inbound_order_id=i.id AND t.inbound_code=t.transfer_no +JOIN ( + SELECT customer_id,transfer_order_id,COUNT(*) AS pallet_count,SUM(carton_count) AS package_count, + SUM(gross_weight_kg) AS gross_weight_kg,SUM(volume_cbm) AS volume_cbm + FROM ll_warehouse_transfer_pallet + WHERE status='COMPLETED' + GROUP BY customer_id,transfer_order_id +) p ON p.customer_id=t.customer_id AND p.transfer_order_id=t.id +SET i.status='RECEIVED',i.package_count=p.package_count,i.pallet_count=p.pallet_count, + i.gross_weight_kg=p.gross_weight_kg,i.volume_cbm=p.volume_cbm,i.updated_at=CURRENT_TIMESTAMP(3) +WHERE t.status IN ('WAIT_CUSTOMER_CONFIRMATION','WAIT_FBA_DESTINATION','WAIT_RELABEL','READY_FOR_DISPATCH','ALLOCATED','OUTBOUNDED','PARTIALLY_RECEIVED','RECEIVED'); + +-- 2026-09-16 / 042 仓储转运直接出库的FBA/私人仓目的地快照。 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN destination_type VARCHAR(16) NULL COMMENT ''目的地类型:FBA或PRIVATE'' AFTER destination_warehouse_name, ADD COLUMN delivery_address VARCHAR(1000) NULL COMMENT ''私人仓派送地址'' AFTER destination_type, ADD COLUMN contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人'' AFTER delivery_address, ADD COLUMN contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话'' AFTER contact_name')); +PREPARE transfer_outbound_destination_stmt FROM @ddl; EXECUTE transfer_outbound_destination_stmt; DEALLOCATE PREPARE transfer_outbound_destination_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='dispatch_order_id' AND is_nullable='YES'), 'SELECT 1', 'ALTER TABLE ll_delivery_order MODIFY COLUMN dispatch_order_id BIGINT NULL COMMENT ''关联派车单ID,仓储转运直接出库时为空'', MODIFY COLUMN dispatch_no VARCHAR(64) NULL COMMENT ''关联派车单号,仓储转运直接出库时为空'', MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''本派送单目的FBA仓编码快照,私人仓为空''')); +PREPARE transfer_outbound_nullable_stmt FROM @ddl; EXECUTE transfer_outbound_nullable_stmt; DEALLOCATE PREPARE transfer_outbound_nullable_stmt; + +-- 2026-09-17 / 043 仓储转运目的地绑定及私人仓邮编快照。 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN destination_type VARCHAR(16) NULL COMMENT ''目的类型:FBA或PRIVATE'' AFTER fba_warehouse_code, ADD COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''目的仓ID快照'' AFTER destination_type, ADD COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''目的仓编码快照'' AFTER destination_warehouse_id, ADD COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''目的仓名称快照'' AFTER destination_warehouse_code, ADD COLUMN private_destination_address VARCHAR(1000) NULL COMMENT ''私人仓完整地址'' AFTER destination_warehouse_name, ADD COLUMN private_destination_postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编'' AFTER private_destination_address, ADD COLUMN private_destination_contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人'' AFTER private_destination_postal_code, ADD COLUMN private_destination_contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话'' AFTER private_destination_contact_name, ADD COLUMN destination_binding_idempotency_key VARCHAR(255) NULL COMMENT ''目的地绑定幂等键'', ADD COLUMN destination_binding_request_hash CHAR(64) NULL COMMENT ''目的地绑定请求哈希''')); +PREPARE transfer_destination_stmt FROM @ddl; EXECUTE transfer_destination_stmt; DEALLOCATE PREPARE transfer_destination_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='private_destination_postal_code'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN private_destination_postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编'' AFTER private_destination_address')); +PREPARE transfer_postal_stmt FROM @ddl; EXECUTE transfer_postal_stmt; DEALLOCATE PREPARE transfer_postal_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='delivery_address'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN delivery_address VARCHAR(1000) NULL COMMENT ''私人仓目的地址快照'', ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'', ADD COLUMN contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人快照'', ADD COLUMN contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话快照''')); +PREPARE transfer_line_destination_stmt FROM @ddl; EXECUTE transfer_line_destination_stmt; DEALLOCATE PREPARE transfer_line_destination_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='postal_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'' AFTER delivery_address')); +PREPARE transfer_line_postal_stmt FROM @ddl; EXECUTE transfer_line_postal_stmt; DEALLOCATE PREPARE transfer_line_postal_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA或PRIVATE'', ADD COLUMN delivery_address VARCHAR(1000) NULL COMMENT ''私人仓目的地址快照'', ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'', ADD COLUMN contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人快照'', ADD COLUMN contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话快照''')); +PREPARE transfer_pallet_destination_stmt FROM @ddl; EXECUTE transfer_pallet_destination_stmt; DEALLOCATE PREPARE transfer_pallet_destination_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='postal_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'' AFTER delivery_address')); +PREPARE transfer_pallet_postal_stmt FROM @ddl; EXECUTE transfer_pallet_postal_stmt; DEALLOCATE PREPARE transfer_pallet_postal_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA或PRIVATE'' AFTER fba_warehouse_code, ADD COLUMN delivery_address VARCHAR(1000) NULL COMMENT ''私人仓目的地址快照'' AFTER destination_type, ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'' AFTER delivery_address, ADD COLUMN contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人快照'' AFTER postal_code, ADD COLUMN contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话快照'' AFTER contact_name')); +PREPARE dispatch_destination_stmt FROM @ddl; EXECUTE dispatch_destination_stmt; DEALLOCATE PREPARE dispatch_destination_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='postal_code'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'' AFTER delivery_address')); +PREPARE dispatch_postal_stmt FROM @ddl; EXECUTE dispatch_postal_stmt; DEALLOCATE PREPARE dispatch_postal_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='postal_code'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编'' AFTER delivery_address')); +PREPARE delivery_postal_stmt FROM @ddl; EXECUTE delivery_postal_stmt; DEALLOCATE PREPARE delivery_postal_stmt; diff --git a/scripts/db/relabel-file-scope-incremental.sql b/scripts/db/relabel-file-scope-incremental.sql index e458716eb..cad82579b 100644 --- a/scripts/db/relabel-file-scope-incremental.sql +++ b/scripts/db/relabel-file-scope-incremental.sql @@ -1,5 +1,8 @@ USE baiyue_lastleg; +-- 当前文件服务数据库;不删除或重建文件元数据。 +ALTER TABLE baiyue_file.file_object MODIFY COLUMN business_type VARCHAR(64) NULL COMMENT '关联业务类型'; + SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='carton_total_file_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN carton_total_file_id VARCHAR(128) NULL COMMENT ''箱唛总文件ID'' AFTER total_file_url')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='carton_total_file_name'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN carton_total_file_name VARCHAR(255) NULL COMMENT ''箱唛总文件名'' AFTER carton_total_file_id')); diff --git a/scripts/db/relabel-wait-status-incremental.sql b/scripts/db/relabel-wait-status-incremental.sql new file mode 100644 index 000000000..d14947c6e --- /dev/null +++ b/scripts/db/relabel-wait-status-incremental.sql @@ -0,0 +1,16 @@ +-- 存量换标尾程主单状态修正:不创建入库单,不增减库存;可重复执行。 +USE baiyue_lastleg; +START TRANSACTION; +UPDATE ll_order o +JOIN ll_relabel_application a ON a.relabel_no=o.relabel_order_no AND a.customer_id=o.customer_id +SET o.current_status=CASE + WHEN a.status IN ('PENDING_PROCESSING','PROCESSING','CUSTOMER_CONFIRM_PENDING') THEN 'WAIT_RELABEL' + WHEN a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' THEN 'WAIT_OUTBOUND' + WHEN a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status IN ('WAIT_OUTBOUND_CREATION','OUTBOUND_CREATED') THEN 'WAIT_OUTBOUND' + ELSE o.current_status END, + o.updated_at=CURRENT_TIMESTAMP(3) +WHERE o.order_type='RELABEL_APPLICATION' AND o.current_status='PENDING_ASSIGNMENT' + AND o.inbound_order_no IS NULL + AND (a.status IN ('PENDING_PROCESSING','PROCESSING','CUSTOMER_CONFIRM_PENDING') + OR (a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status IN ('WAIT_PRE_DISPATCH','WAIT_OUTBOUND_CREATION','OUTBOUND_CREATED'))); +COMMIT; diff --git a/scripts/db/seed-local-relabel-ready-orders.sql b/scripts/db/seed-local-relabel-ready-orders.sql new file mode 100644 index 000000000..095f1d924 --- /dev/null +++ b/scripts/db/seed-local-relabel-ready-orders.sql @@ -0,0 +1,114 @@ +-- LOCAL ONLY: explicit test fixtures, NOT evidence of real PDA receiving. +-- Caller must set @fixture_customer_id, @fixture_warehouse_id, @fixture_location_id, +-- @fixture_destination_id, @fixture_base and @fixture_batch in the same connection. +-- Insert-only: duplicate IDs/numbers fail; never restore or overwrite consumed stock. +SET NAMES utf8mb4; +USE baiyue_lastleg; +CREATE TEMPORARY TABLE fixture_guard (ok INT NOT NULL CHECK (ok=1)); +INSERT INTO fixture_guard SELECT IF(@fixture_customer_id>0 AND @fixture_base>0 + AND @fixture_batch REGEXP '^[A-Z0-9-]{1,16}$',1,0); +SET @fixture_customer_name=(SELECT tenant_name FROM baiyue_iam.iam_tenant + WHERE id=@fixture_customer_id AND deleted=0 AND status='ACTIVE'); +SET @fixture_warehouse_code=(SELECT warehouse_code FROM baiyue_warehouse.wh_warehouse + WHERE id=@fixture_warehouse_id AND warehouse_type='OVERSEAS' AND status='ACTIVE' AND deleted=0); +SET @fixture_warehouse_name=(SELECT warehouse_name FROM baiyue_warehouse.wh_warehouse WHERE id=@fixture_warehouse_id); +SET @fixture_location_code=(SELECT location_code FROM baiyue_lastlag_warehouse.wh_location + WHERE id=@fixture_location_id AND warehouse_id=@fixture_warehouse_id AND location_type='VIRTUAL' AND status='ACTIVE'); +SET @fixture_destination_code=(SELECT warehouse_code FROM baiyue_warehouse.wh_warehouse + WHERE id=@fixture_destination_id AND warehouse_type='FBA' AND status='ACTIVE' AND deleted=0); +INSERT INTO fixture_guard SELECT IF(@fixture_customer_name IS NOT NULL AND @fixture_warehouse_code IS NOT NULL + AND @fixture_location_code IS NOT NULL AND @fixture_destination_code IS NOT NULL,1,0); +CREATE TEMPORARY TABLE fixture_orders (seq INT PRIMARY KEY, boxes INT, order_id BIGINT, plan_id BIGINT, + balance_id BIGINT, inbound_id BIGINT, order_no VARCHAR(32), plan_no VARCHAR(32), inbound_code VARCHAR(128), sku VARCHAR(128)); +INSERT INTO fixture_orders +SELECT n,6+n*6,@fixture_base+n,@fixture_base+100+n,@fixture_base+200+n,@fixture_base+300+n, + CONCAT('TL-',@fixture_batch,'-',n),CONCAT('IP-',@fixture_batch,'-',n), + CONCAT('IN-',@fixture_batch,'-',n),CONCAT('SKU-',@fixture_batch,'-',n) +FROM (SELECT 1 n UNION ALL SELECT 2 UNION ALL SELECT 3) numbers; +CREATE TEMPORARY TABLE fixture_pallets (seq INT, part INT, pallet_id BIGINT, position_id BIGINT, + pallet_code VARCHAR(64), boxes INT, PRIMARY KEY(seq,part)); +INSERT INTO fixture_pallets SELECT f.seq,p.n,@fixture_base+400+f.seq*10+p.n, + @fixture_base+500+f.seq*10+p.n,CONCAT('PAL-',@fixture_batch,'-',f.seq,'-',p.n),f.boxes/2 +FROM fixture_orders f CROSS JOIN (SELECT 1 n UNION ALL SELECT 2) p; +CREATE TEMPORARY TABLE fixture_numbers (n INT PRIMARY KEY); +INSERT INTO fixture_numbers SELECT a.n+b.n*10+1 FROM + (SELECT 0 n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 + UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) a + CROSS JOIN (SELECT 0 n UNION ALL SELECT 1 UNION ALL SELECT 2) b; +START TRANSACTION; +INSERT INTO baiyue_lastlag_warehouse.wh_pallet + (id,customer_id,warehouse_id,location_id,pallet_code,pallet_type,capacity_cbm,status,remark,fba_warehouse_code,version) +SELECT pallet_id,0,@fixture_warehouse_id,@fixture_location_id,pallet_code,'STANDARD',2,'OCCUPIED', + 'LOCAL_TEST_FIXTURE: relabel source inventory; not real PDA acceptance',@fixture_destination_code,0 FROM fixture_pallets; +INSERT INTO ll_inbound_order + (id,customer_id,source_region,source_inbound_id,source_order_id,source_order_no,inbound_code, + destination_warehouse_id,destination_warehouse_code,destination_type,package_count,product_quantity,pallet_count, + source_event_id,source_loading_plan_id,source_loading_plan_version,handover_version,payload_hash,status) +SELECT inbound_id,@fixture_customer_id,'FIRST_LEG',CONCAT('FIXTURE-',inbound_code),CAST(order_id AS CHAR),order_no,inbound_code, + @fixture_destination_id,@fixture_destination_code,'FBA',boxes,boxes*10,2, + CONCAT('FIXTURE-',order_no),CONCAT('FIXTURE-LOAD-',order_no),1,1,SHA2(order_no,256),'RECEIVED' FROM fixture_orders; +INSERT INTO ll_order + (id,order_no,customer_id,order_type,lx_code,destination_type,inbound_code,source_region,source_inbound_id, + source_order_id,source_order_no,customer_name,overseas_warehouse_id,overseas_warehouse_code,overseas_warehouse_name, + fba_warehouse_code,container_no,container_status,pallet_count,package_count,product_quantity, + inbound_order_no,current_status,source_event_id,source_loading_plan_id,source_payload_hash) +SELECT order_id,order_no,@fixture_customer_id,'FIRST_LEG',CONCAT('LX-',order_no),'FBA',inbound_code,'FIRST_LEG', + CONCAT('FIXTURE-',inbound_code),CAST(order_id AS CHAR),order_no,@fixture_customer_name, + @fixture_warehouse_id,@fixture_warehouse_code,@fixture_warehouse_name,@fixture_destination_code, + CONCAT('FIXTURE-',@fixture_batch),'LOCAL_TEST_FIXTURE',2,boxes,boxes*10,plan_no,'INBOUND_COMPLETED', + CONCAT('FIXTURE-',order_no),CONCAT('FIXTURE-LOAD-',order_no),SHA2(order_no,256) FROM fixture_orders; +INSERT INTO ll_order_detail + (id,customer_id,order_id,source_inbound_detail_id,sku_code,product_name_zh,package_count,product_quantity,pallet_count, + destination_warehouse_id,destination_warehouse_code,shipping_mark) +SELECT @fixture_base+10000+f.seq*100+n.n,@fixture_customer_id,f.order_id, + CONCAT('FIXTURE-',f.inbound_code,'-',n.n),f.sku,CONCAT('贴标测试商品-',f.seq),1,10,0, + @fixture_destination_id,@fixture_destination_code,CONCAT('CM-',@fixture_batch,'-',f.seq,'-',LPAD(n.n,3,'0')) +FROM fixture_orders f JOIN fixture_numbers n ON n.n<=f.boxes; +INSERT INTO ll_inbound_plan + (id,plan_no,customer_id,source_order_id,container_no,overseas_warehouse_id,planned_arrival_date, + pallet_type,assigned_flag,creation_mode,plan_remark,status,line_count,pallet_count,total_volume_cbm,version) +SELECT plan_id,plan_no,@fixture_customer_id,order_id,CONCAT('FIXTURE-',@fixture_batch),@fixture_warehouse_id, + CURRENT_DATE,'STANDARD',1,'MANUAL','LOCAL_TEST_FIXTURE: 已入库测试准备,非真实PDA签收验收','COMPLETED',1,2,2,0 FROM fixture_orders; +INSERT INTO ll_inbound_plan_line (id,customer_id,plan_id,order_id,inbound_code,required_package_count, + planned_pallet_count,destination_type,destination_warehouse_code,fba_warehouse_code) +SELECT @fixture_base+700+seq,@fixture_customer_id,plan_id,order_id,inbound_code,boxes,2,'FBA', + @fixture_destination_code,@fixture_destination_code FROM fixture_orders; +INSERT INTO ll_inbound_plan_pallet + (id,customer_id,plan_id,pallet_no,pallet_type,destination_warehouse_code,capacity_cbm,allocated_volume_cbm, + sequence_no,warehouse_pallet_id,assignment_status,actual_volume_cbm,actual_package_count) +SELECT p.pallet_id+1000,@fixture_customer_id,f.plan_id,p.pallet_code,'STANDARD',@fixture_destination_code, + 2,1,p.part,p.pallet_id,'ASSIGNED',1,p.boxes FROM fixture_pallets p JOIN fixture_orders f ON f.seq=p.seq; +INSERT INTO ll_inventory_balance + (id,customer_id,customer_name,overseas_warehouse_id,overseas_warehouse_code,overseas_warehouse_name, + destination_warehouse_code,inbound_order_id,inbound_order_no,inbound_code,sku_code,product_name_zh, + available_packages,available_quantity,version) +SELECT balance_id,@fixture_customer_id,@fixture_customer_name,@fixture_warehouse_id,@fixture_warehouse_code, + @fixture_warehouse_name,@fixture_destination_code,order_id,plan_no,inbound_code,sku,CONCAT('贴标测试商品-',seq),boxes,boxes*10,0 FROM fixture_orders; +INSERT INTO ll_inventory_position + (id,customer_id,balance_id,overseas_warehouse_id,destination_warehouse_code,sku_code,pallet_id,pallet_no,batch_no, + warehouse_location_id,warehouse_location_code,available_packages,product_quantity,status,version) +SELECT p.position_id,@fixture_customer_id,f.balance_id,@fixture_warehouse_id,@fixture_destination_code,f.sku,p.pallet_id, + p.pallet_code,CONCAT('FIXTURE-',@fixture_batch),@fixture_location_id,@fixture_location_code,p.boxes,p.boxes*10,'OCCUPIED',0 +FROM fixture_pallets p JOIN fixture_orders f ON f.seq=p.seq; +INSERT INTO ll_inbound_plan_pallet_allocation + (id,customer_id,plan_id,line_id,pallet_id,allocated_volume_cbm,planned_package_count, + counted_package_count,picked_package_count,actual_volume_cbm,status,version) +SELECT p.pallet_id+3000,@fixture_customer_id,f.plan_id,@fixture_base+700+f.seq,p.pallet_id+1000, + 1,p.boxes,p.boxes,p.boxes,1,'PICKED',0 +FROM fixture_pallets p JOIN fixture_orders f ON f.seq=p.seq; +INSERT INTO ll_inventory_ledger + (id,customer_id,customer_name,balance_id,position_id,overseas_warehouse_id,destination_warehouse_code, + inbound_order_id,sku_code,pallet_id,pallet_no,batch_no,warehouse_location_code,business_type,business_order_no, + inbound_code,inbound_order_no,idempotency_key,status_from,status_to,before_packages,delta_packages,after_packages,remark,occurred_at) +SELECT p.position_id+2000,@fixture_customer_id,@fixture_customer_name,f.balance_id,p.position_id,@fixture_warehouse_id, + @fixture_destination_code,f.order_id,f.sku,p.pallet_id,p.pallet_code,CONCAT('FIXTURE-',@fixture_batch),@fixture_location_code, + 'LOCAL_TEST_FIXTURE',f.order_no,f.inbound_code,f.plan_no,CONCAT('LOCAL_TEST_FIXTURE:',p.pallet_code),'NONE','AVAILABLE', + 0,p.boxes,p.boxes,'Explicit local test fixture; not a real PDA stock-in event',NOW(3) +FROM fixture_pallets p JOIN fixture_orders f ON f.seq=p.seq; +INSERT INTO fixture_guard SELECT IF(COUNT(*)=6 AND SUM(p.available_packages)=54,1,0) +FROM ll_inventory_position p JOIN fixture_pallets f ON f.position_id=p.id; +COMMIT; +SELECT f.order_no,f.plan_no,f.inbound_code,f.sku,p.pallet_code,p.boxes, + CONCAT('CM-',@fixture_batch,'-',f.seq,'-001') first_carton_mark, + CONCAT('CM-',@fixture_batch,'-',f.seq,'-',LPAD(f.boxes,3,'0')) last_carton_mark +FROM fixture_orders f JOIN fixture_pallets p ON p.seq=f.seq ORDER BY f.seq,p.part; diff --git a/scripts/db/warehouse-transfer-outbound-incremental.sql b/scripts/db/warehouse-transfer-outbound-incremental.sql new file mode 100644 index 000000000..aff2665d3 --- /dev/null +++ b/scripts/db/warehouse-transfer-outbound-incremental.sql @@ -0,0 +1,326 @@ +-- 仓储转运审核、PDA测量打托、换标、目的地绑定、智能排车与派送字段。 +-- 目标数据库:baiyue_lastleg +-- 可重复执行:字段和索引独立检查,补充缺失结构并修正必要的可空性;不更新业务数据。 + +USE `baiyue_lastleg`; + +DROP PROCEDURE IF EXISTS `ensure_transfer_column`; +DROP PROCEDURE IF EXISTS `ensure_transfer_nullable_column`; +DROP PROCEDURE IF EXISTS `ensure_transfer_index`; +DROP PROCEDURE IF EXISTS `ensure_transfer_pallet_unique_index`; + +DELIMITER $$ + +CREATE PROCEDURE `ensure_transfer_column`( + IN p_table_name VARCHAR(64), + IN p_column_name VARCHAR(64), + IN p_column_definition TEXT +) +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = p_table_name + AND column_name = p_column_name + ) THEN + SET @ddl = CONCAT( + 'ALTER TABLE `', REPLACE(p_table_name, '`', '``'), + '` ADD COLUMN `', REPLACE(p_column_name, '`', '``'), + '` ', p_column_definition + ); + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END$$ + +CREATE PROCEDURE `ensure_transfer_nullable_column`( + IN p_table_name VARCHAR(64), + IN p_column_name VARCHAR(64), + IN p_column_definition TEXT +) +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = p_table_name + AND column_name = p_column_name + AND is_nullable = 'NO' + ) THEN + SET @ddl = CONCAT( + 'ALTER TABLE `', REPLACE(p_table_name, '`', '``'), + '` MODIFY COLUMN `', REPLACE(p_column_name, '`', '``'), + '` ', p_column_definition + ); + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END$$ + +CREATE PROCEDURE `ensure_transfer_index`( + IN p_table_name VARCHAR(64), + IN p_index_name VARCHAR(64), + IN p_index_definition TEXT +) +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = p_table_name + AND index_name = p_index_name + ) THEN + SET @ddl = CONCAT( + 'ALTER TABLE `', REPLACE(p_table_name, '`', '``'), + '` ADD ', p_index_definition + ); + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END$$ + +CREATE PROCEDURE `ensure_transfer_pallet_unique_index`() +BEGIN + DECLARE v_duplicate_groups BIGINT DEFAULT 0; + + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'll_inbound_plan_pallet' + AND index_name = 'uk_ll_inbound_pallet_no' + ) THEN + SELECT COUNT(*) + INTO v_duplicate_groups + FROM ( + SELECT 1 + FROM `ll_inbound_plan_pallet` + GROUP BY `customer_id`, `pallet_no` + HAVING COUNT(*) > 1 + ) duplicate_groups; + + IF v_duplicate_groups = 0 THEN + ALTER TABLE `ll_inbound_plan_pallet` + ADD UNIQUE KEY `uk_ll_inbound_pallet_no` (`customer_id`,`pallet_no`); + END IF; + END IF; +END$$ + +DELIMITER ; + +-- 相关业务表统一补齐 SaaS 租户字段。默认值只用于兼容历史数据和单租户海外环境。 +CALL ensure_transfer_column('ll_delivery_order', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE'''); +CALL ensure_transfer_column('ll_dispatch_item', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE'''); +CALL ensure_transfer_column('ll_inbound_plan_line', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE'''); +CALL ensure_transfer_column('ll_inbound_plan_pallet', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE'''); +CALL ensure_transfer_column('ll_order', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE'''); +CALL ensure_transfer_column('ll_relabel_application', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE'''); +CALL ensure_transfer_column('ll_relabel_item', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE'''); +CALL ensure_transfer_column('ll_relabel_status_history', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE'''); +CALL ensure_transfer_column('ll_warehouse_transfer_inventory_allocation', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE'''); +CALL ensure_transfer_column('ll_warehouse_transfer_item', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE'''); +CALL ensure_transfer_column('ll_warehouse_transfer_receipt', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE'''); +CALL ensure_transfer_column('ll_warehouse_transfer_source_order', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE'''); + +-- 退货换标允许只更换箱唛,不强制生成新 SKU。 +CALL ensure_transfer_nullable_column('ll_relabel_item', 'new_sku_code', 'VARCHAR(128) NULL COMMENT ''换标后SKU编码'''); + +-- 主单审核、提货、发货、换标和客户确认事实。 +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'pickup_contact_name', 'VARCHAR(128) NULL COMMENT ''提货联系人'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'pickup_contact_phone', 'VARCHAR(64) NULL COMMENT ''提货联系电话'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'estimated_ship_at', 'DATETIME(3) NULL COMMENT ''预计发货时间'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'review_decision', 'VARCHAR(32) NULL COMMENT ''审核决定'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'review_remark', 'VARCHAR(1000) NULL COMMENT ''审核备注或驳回原因'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'reviewed_by', 'VARCHAR(128) NULL COMMENT ''审核人ID'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'reviewed_at', 'DATETIME(3) NULL COMMENT ''审核时间'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'label_deadline', 'DATETIME(3) NULL COMMENT ''换标截止时间'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'relabel_contact_name', 'VARCHAR(128) NULL COMMENT ''换标联系人'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'relabel_contact_phone', 'VARCHAR(64) NULL COMMENT ''换标联系电话'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'relabel_charge_amount', 'DECIMAL(20,2) NULL COMMENT ''换标费用CNY'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'relabel_delivery_method', 'VARCHAR(32) NULL COMMENT ''换标后派送方式'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'relabel_completed_at', 'DATETIME(3) NULL COMMENT ''关联贴标完成时间'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'palletize_idempotency_key', 'VARCHAR(255) NULL COMMENT ''PDA打托幂等键'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'palletize_request_hash', 'CHAR(64) NULL COMMENT ''PDA打托请求哈希'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'confirmation_idempotency_key', 'VARCHAR(255) NULL COMMENT ''客户测量确认幂等键'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'confirmation_request_hash', 'CHAR(64) NULL COMMENT ''客户测量确认请求哈希'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'customer_confirmed_at', 'DATETIME(3) NULL COMMENT ''客户确认测量时间'''); + +-- 仓储转运主单目的地。 +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'destination_type', 'VARCHAR(16) NULL COMMENT ''目的类型:FBA或PRIVATE'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'destination_warehouse_id', 'VARCHAR(128) NULL COMMENT ''目的仓ID快照'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'destination_warehouse_code', 'VARCHAR(128) NULL COMMENT ''目的仓编码快照'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'destination_warehouse_name', 'VARCHAR(255) NULL COMMENT ''目的仓名称快照'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'private_destination_address', 'VARCHAR(1000) NULL COMMENT ''私人仓完整地址'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'private_destination_postal_code', 'VARCHAR(32) NULL COMMENT ''私人仓邮编'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'private_destination_contact_name', 'VARCHAR(128) NULL COMMENT ''私人仓联系人'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'private_destination_contact_phone', 'VARCHAR(64) NULL COMMENT ''私人仓联系电话'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'destination_binding_idempotency_key', 'VARCHAR(255) NULL COMMENT ''目的地绑定幂等键'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'destination_binding_request_hash', 'CHAR(64) NULL COMMENT ''目的地绑定请求哈希'''); + +-- 转仓尾程主单记录PDA实收箱数。 +CALL ensure_transfer_column('ll_order', 'received_package_count', 'INT NULL COMMENT ''PDA实际入库箱数'''); + +-- PDA逐托草稿、生成、扫码绑定和整单完成事实。 +CREATE TABLE IF NOT EXISTS `ll_warehouse_transfer_pallet` ( + `id` BIGINT NOT NULL COMMENT '打托事实ID', + `transfer_order_id` BIGINT NOT NULL COMMENT '转运单ID', + `customer_id` VARCHAR(128) NOT NULL COMMENT '客户租户ID', + `sequence_no` INT NOT NULL COMMENT '托序号', + `warehouse_pallet_id` BIGINT NULL COMMENT '生成后的海外仓真实托盘ID', + `pallet_no` VARCHAR(128) NULL COMMENT '生成后的真实托盘码', + `warehouse_location_id` BIGINT NULL COMMENT '生成后的真实库位ID', + `warehouse_location_code` VARCHAR(128) NULL COMMENT '真实库位码', + `warehouse_resource_lock_id` BIGINT NULL COMMENT '生成后的仓储资源锁ID', + `carton_count` INT NOT NULL COMMENT '本托实收箱数', + `length_cm` DECIMAL(12,2) NOT NULL COMMENT '实测长cm', + `width_cm` DECIMAL(12,2) NOT NULL COMMENT '实测宽cm', + `height_cm` DECIMAL(12,2) NOT NULL COMMENT '实测高cm', + `gross_weight_kg` DECIMAL(12,3) NOT NULL COMMENT '实测毛重kg', + `volume_cbm` DECIMAL(18,6) NOT NULL COMMENT '实测体积CBM', + `status` VARCHAR(32) NOT NULL COMMENT '逐托状态DRAFT/GENERATED/BOUND/COMPLETED', + `draft_idempotency_key` VARCHAR(255) NULL COMMENT '草稿保存幂等键', + `draft_request_hash` CHAR(64) NULL COMMENT '草稿请求摘要', + `generate_idempotency_key` VARCHAR(255) NULL COMMENT '新托盘生成幂等键', + `bind_idempotency_key` VARCHAR(255) NULL COMMENT '扫码绑定幂等键', + `bound_at` DATETIME(3) NULL COMMENT '扫码确认绑定时间', + `completed_at` DATETIME(3) NULL COMMENT '整单打托完成时间', + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_transfer_pallet_seq` (`transfer_order_id`,`sequence_no`), + UNIQUE KEY `uk_ll_transfer_pallet_physical` (`customer_id`,`warehouse_pallet_id`), + KEY `idx_ll_transfer_pallet_order` (`customer_id`,`transfer_order_id`,`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='仓储转运PDA实测打托事实'; + +CREATE TABLE IF NOT EXISTS `ll_warehouse_transfer_pallet_item` ( + `id` BIGINT NOT NULL COMMENT '主键ID', + `transfer_order_id` BIGINT NOT NULL COMMENT '转运单ID', + `transfer_pallet_id` BIGINT NOT NULL COMMENT '转运托盘事实ID', + `transfer_item_id` BIGINT NOT NULL COMMENT '转运申报明细ID', + `customer_id` VARCHAR(128) NOT NULL COMMENT '客户租户ID', + `package_count` INT NOT NULL COMMENT '本托该SKU实收箱数', + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_transfer_pallet_item` (`transfer_pallet_id`,`transfer_item_id`), + KEY `idx_ll_transfer_pallet_item_order` (`customer_id`,`transfer_order_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='仓储转运每托SKU箱数回填'; + +CREATE TABLE IF NOT EXISTS `ll_warehouse_transfer_relabel_binding` ( + `id` BIGINT NOT NULL COMMENT '主键ID', + `transfer_order_id` BIGINT NOT NULL COMMENT '转运单ID', + `customer_id` VARCHAR(128) NOT NULL COMMENT '客户租户ID', + `operation_type` VARCHAR(32) NOT NULL COMMENT '换标类型', + `customer_carton_mark` VARCHAR(255) NOT NULL COMMENT '客户原箱唛', + `original_sku` VARCHAR(128) NOT NULL COMMENT '旧SKU', + `new_sku` VARCHAR(128) NULL COMMENT '新SKU', + `quantity` INT NOT NULL COMMENT '换标件数', + `fba_carton_mark` VARCHAR(255) NULL COMMENT '新FBA箱唛', + `destination_warehouse_code` VARCHAR(128) NULL COMMENT 'FBA目的仓', + `remark` VARCHAR(1000) NULL COMMENT '备注', + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_transfer_relabel_mark` (`transfer_order_id`,`customer_carton_mark`), + KEY `idx_ll_transfer_relabel_order` (`customer_id`,`transfer_order_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='仓储转运换标预绑定明细'; + +CALL ensure_transfer_nullable_column('ll_warehouse_transfer_pallet', 'warehouse_pallet_id', 'BIGINT NULL COMMENT ''生成后的海外仓真实托盘ID'''); +CALL ensure_transfer_nullable_column('ll_warehouse_transfer_pallet', 'pallet_no', 'VARCHAR(128) NULL COMMENT ''生成后的真实托盘码'''); +CALL ensure_transfer_nullable_column('ll_warehouse_transfer_pallet', 'warehouse_location_id', 'BIGINT NULL COMMENT ''生成后的真实库位ID'''); +CALL ensure_transfer_nullable_column('ll_warehouse_transfer_pallet', 'warehouse_resource_lock_id', 'BIGINT NULL COMMENT ''生成后的仓储资源锁ID'''); +CALL ensure_transfer_nullable_column('ll_warehouse_transfer_pallet', 'completed_at', 'DATETIME(3) NULL COMMENT ''整单打托完成时间'''); +CALL ensure_transfer_column('ll_warehouse_transfer_pallet', 'draft_idempotency_key', 'VARCHAR(255) NULL COMMENT ''草稿保存幂等键'''); +CALL ensure_transfer_column('ll_warehouse_transfer_pallet', 'draft_request_hash', 'CHAR(64) NULL COMMENT ''草稿请求摘要'''); +CALL ensure_transfer_column('ll_warehouse_transfer_pallet', 'generate_idempotency_key', 'VARCHAR(255) NULL COMMENT ''新托盘生成幂等键'''); +CALL ensure_transfer_column('ll_warehouse_transfer_pallet', 'bind_idempotency_key', 'VARCHAR(255) NULL COMMENT ''扫码绑定幂等键'''); +CALL ensure_transfer_column('ll_warehouse_transfer_pallet', 'bound_at', 'DATETIME(3) NULL COMMENT ''扫码确认绑定时间'''); + +CREATE TABLE IF NOT EXISTS `ll_warehouse_transfer_measurement_photo` ( + `id` BIGINT NOT NULL COMMENT '测量照片引用ID', + `transfer_order_id` BIGINT NOT NULL COMMENT '仓储转运单ID', + `customer_id` VARCHAR(128) NOT NULL COMMENT '客户租户ID', + `file_id` BIGINT NOT NULL COMMENT '文件服务文件ID', + `file_name` VARCHAR(255) NULL COMMENT '文件名快照', + `content_type` VARCHAR(128) NULL COMMENT '文件媒体类型快照', + `size_bytes` BIGINT NOT NULL DEFAULT 0 COMMENT '文件字节数快照', + `uploaded_by` VARCHAR(128) NULL COMMENT 'PDA上传操作人', + `uploaded_at` DATETIME(3) NOT NULL COMMENT '上传时间', + `submitted_at` DATETIME(3) NULL COMMENT '随测量打托提交时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_transfer_measurement_photo_file` (`customer_id`,`transfer_order_id`,`file_id`), + KEY `idx_ll_transfer_measurement_photo_order` (`customer_id`,`transfer_order_id`,`uploaded_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='仓储转运PDA测量现场照片引用'; +CALL ensure_transfer_column('ll_warehouse_transfer_measurement_photo', 'submitted_at', 'DATETIME(3) NULL COMMENT ''随测量打托提交时间'''); +CALL ensure_transfer_index('ll_warehouse_transfer_measurement_photo', 'uk_ll_transfer_measurement_photo_file', + 'UNIQUE KEY `uk_ll_transfer_measurement_photo_file` (`customer_id`,`transfer_order_id`,`file_id`)'); +CALL ensure_transfer_index('ll_warehouse_transfer_measurement_photo', 'idx_ll_transfer_measurement_photo_order', + 'KEY `idx_ll_transfer_measurement_photo_order` (`customer_id`,`transfer_order_id`,`uploaded_at`)'); + +-- 入库计划行和托盘保存私人仓目的地快照。 +CALL ensure_transfer_column('ll_inbound_plan_line', 'delivery_address', 'VARCHAR(1000) NULL COMMENT ''私人仓目的地址快照'''); +CALL ensure_transfer_column('ll_inbound_plan_line', 'postal_code', 'VARCHAR(32) NULL COMMENT ''私人仓邮编快照'''); +CALL ensure_transfer_column('ll_inbound_plan_line', 'contact_name', 'VARCHAR(128) NULL COMMENT ''私人仓联系人快照'''); +CALL ensure_transfer_column('ll_inbound_plan_line', 'contact_phone', 'VARCHAR(64) NULL COMMENT ''私人仓联系电话快照'''); + +CALL ensure_transfer_column('ll_inbound_plan_pallet', 'destination_type', 'VARCHAR(32) NULL COMMENT ''目的类型:FBA或PRIVATE'''); +CALL ensure_transfer_column('ll_inbound_plan_pallet', 'delivery_address', 'VARCHAR(1000) NULL COMMENT ''私人仓目的地址快照'''); +CALL ensure_transfer_column('ll_inbound_plan_pallet', 'postal_code', 'VARCHAR(32) NULL COMMENT ''私人仓邮编快照'''); +CALL ensure_transfer_column('ll_inbound_plan_pallet', 'contact_name', 'VARCHAR(128) NULL COMMENT ''私人仓联系人快照'''); +CALL ensure_transfer_column('ll_inbound_plan_pallet', 'contact_phone', 'VARCHAR(64) NULL COMMENT ''私人仓联系电话快照'''); +CALL ensure_transfer_index('ll_inbound_plan_line', 'idx_ll_inbound_line_tenant_plan', + 'KEY `idx_ll_inbound_line_tenant_plan` (`customer_id`,`plan_id`)'); +CALL ensure_transfer_index('ll_inbound_plan_pallet', 'idx_ll_inbound_pallet_tenant_plan', + 'KEY `idx_ll_inbound_pallet_tenant_plan` (`customer_id`,`plan_id`)'); +CALL ensure_transfer_pallet_unique_index(); + +-- 智能排车候选和排车明细快照。 +CALL ensure_transfer_column('ll_dispatch_item', 'destination_type', 'VARCHAR(32) NULL COMMENT ''目的类型:FBA或PRIVATE'''); +CALL ensure_transfer_column('ll_dispatch_item', 'delivery_address', 'VARCHAR(1000) NULL COMMENT ''私人仓目的地址快照'''); +CALL ensure_transfer_column('ll_dispatch_item', 'postal_code', 'VARCHAR(32) NULL COMMENT ''私人仓邮编快照'''); +CALL ensure_transfer_column('ll_dispatch_item', 'contact_name', 'VARCHAR(128) NULL COMMENT ''私人仓联系人快照'''); +CALL ensure_transfer_column('ll_dispatch_item', 'contact_phone', 'VARCHAR(64) NULL COMMENT ''私人仓联系电话快照'''); + +-- 派送单允许私人仓目的地,并保留地址、邮编及联系人快照。 +CALL ensure_transfer_column('ll_delivery_order', 'destination_type', 'VARCHAR(16) NULL COMMENT ''目的地类型:FBA或PRIVATE'''); +CALL ensure_transfer_column('ll_delivery_order', 'delivery_address', 'VARCHAR(1000) NULL COMMENT ''私人仓派送地址'''); +CALL ensure_transfer_column('ll_delivery_order', 'postal_code', 'VARCHAR(32) NULL COMMENT ''私人仓邮编'''); +CALL ensure_transfer_column('ll_delivery_order', 'contact_name', 'VARCHAR(128) NULL COMMENT ''私人仓联系人'''); +CALL ensure_transfer_column('ll_delivery_order', 'contact_phone', 'VARCHAR(64) NULL COMMENT ''私人仓联系电话'''); + +-- 仓储转运直接出库历史兼容:这三列允许为空。 +CALL ensure_transfer_nullable_column('ll_delivery_order', 'dispatch_order_id', 'BIGINT NULL COMMENT ''关联派车单ID,仓储转运直接出库时为空'''); +CALL ensure_transfer_nullable_column('ll_delivery_order', 'dispatch_no', 'VARCHAR(64) NULL COMMENT ''关联派车单号,仓储转运直接出库时为空'''); +CALL ensure_transfer_nullable_column('ll_delivery_order', 'destination_warehouse_code', 'VARCHAR(128) NULL COMMENT ''本派送单目的FBA仓编码快照,私人仓为空'''); + +DROP PROCEDURE IF EXISTS `ensure_transfer_column`; +DROP PROCEDURE IF EXISTS `ensure_transfer_nullable_column`; +DROP PROCEDURE IF EXISTS `ensure_transfer_index`; +DROP PROCEDURE IF EXISTS `ensure_transfer_pallet_unique_index`; + +SELECT DATABASE() AS target_database, + IF( + EXISTS ( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'll_inbound_plan_pallet' + AND index_name = 'uk_ll_inbound_pallet_no' + ), + 'WAREHOUSE_TRANSFER_LATEST_SCHEMA_READY', + 'WAREHOUSE_TRANSFER_SCHEMA_APPLIED_WITH_INDEX_BLOCKER' + ) AS migration_result, + IF( + EXISTS ( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'll_inbound_plan_pallet' + AND index_name = 'uk_ll_inbound_pallet_no' + ), + 'READY', + 'BLOCKED_BY_DUPLICATE_CUSTOMER_PALLET_NO' + ) AS inbound_pallet_unique_index; diff --git a/scripts/overseas-relabel-fulfillment-acceptance.mjs b/scripts/overseas-relabel-fulfillment-acceptance.mjs index d2117ed5b..aa0912c22 100644 --- a/scripts/overseas-relabel-fulfillment-acceptance.mjs +++ b/scripts/overseas-relabel-fulfillment-acceptance.mjs @@ -22,8 +22,8 @@ function assert(value, message) { if (!value) throw new Error(message) } async function api(base, token, method, path, body) { const response = await fetch(`${base}${path}`, { method, headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), - ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, - body: body === undefined ? undefined : JSON.stringify(body) }) + ...(body === undefined || body instanceof FormData ? {} : { 'Content-Type': 'application/json' }) }, + body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body) }) const text = await response.text(); let value try { value = text ? JSON.parse(text) : null } catch { value = { raw: text } } if (!response.ok || String(value?.code) !== '200') { @@ -48,12 +48,32 @@ const platformToken = directPlatformToken || platformLogin.overseasAccessToken const merchantToken = directMerchantToken || merchantLogin.overseasAccessToken const pdaToken = directPdaToken || pdaLogin.overseasAccessToken -await api(overseas, platformToken, 'PUT', `/api/platform/v1/last-leg/dispatches/${dispatchNo}`, { - vehiclePlate: `UAT-${marker.slice(-8)}`, driverName: '尾程验收司机', driverPhone: '19900000000', - carrierName: '尾程验收车队', route: `${marker}-ROUTE`, - estimatedArrivalAt: new Date(Date.now() + 86400000).toISOString(), -}) -const transfer = await api(overseas, merchantToken, 'POST', +let task = await api(overseas, pdaToken, 'GET', `/api/pda/v1/last-leg/dispatches/${dispatchNo}`) +let alreadyDeparted = task.status === 'DEPARTED' + || (task.status === 'COMPLETED' && String(task.departBlockReason || '').includes('已发车')) +if (!alreadyDeparted && task.status === 'PENDING') { + await api(overseas, platformToken, 'PUT', `/api/platform/v1/last-leg/dispatches/${dispatchNo}`, { + vehiclePlate: `UAT-${marker.slice(-8)}`, driverName: '尾程验收司机', driverPhone: '19900000000', + carrierName: '尾程验收车队', route: `${marker}-ROUTE`, + estimatedArrivalAt: new Date(Date.now() + 86400000).toISOString(), + }) +} +const existingPage = await api(overseas, platformToken, 'GET', + `/api/platform/v1/last-leg/delivery-orders?customerId=${customerId}&page=1&size=20&dispatchNo=${encodeURIComponent(dispatchNo)}`) +const existingDeliveries = (existingPage.records || []).filter((value) => value.dispatchNo === dispatchNo) +const existingOutboundNos = new Set(existingDeliveries.map((value) => value.outboundNo).filter(Boolean)) +assert(existingOutboundNos.size <= 1, 'Dispatch is linked to multiple outbound orders') +const transfer = existingDeliveries.length > 0 ? { + outboundNo: [...existingOutboundNos][0], + deliveries: existingDeliveries.map((value) => ({ + deliveryNo: value.deliveryNo, deliveryStatus: value.status, + destinationWarehouseId: value.destinationWarehouseId, + destinationWarehouseCode: value.destinationWarehouseCode, + destinationWarehouseName: value.destinationWarehouseName, + palletCount: value.palletCount, packageCount: value.packageCount, + })), + replayed: true, +} : await api(overseas, merchantToken, 'POST', `/api/merchant/v1/last-leg/delivery-orders?dispatchNo=${encodeURIComponent(dispatchNo)}`, { idempotencyKey: key('TRANSFER'), plannedDeliveryAt: new Date(Date.now() + 172800000).toISOString(), deliveryMethod: 'TRUCK_PALLET', waybillNo: `${marker}-WAYBILL`, carrierName: '尾程验收车队', @@ -62,29 +82,53 @@ const transfer = await api(overseas, merchantToken, 'POST', appointmentAccount: 'SELF', remark: marker, fees: [{ feeType: 'DELIVERY_FEE', feeName: '派送费', direction: 'RECEIVABLE', currency: 'CNY', amount: 10, remark: marker }], }) -assert(transfer.outboundNo && transfer.deliveryNo, 'Transfer did not create outbound and delivery') +const transferDeliveries = Array.isArray(transfer.deliveries) ? transfer.deliveries : [] +assert(transfer.outboundNo && transferDeliveries.length > 0, + 'Transfer did not create outbound and delivery') +assert(transferDeliveries.length === 1, + `Expected one destination delivery, got ${transferDeliveries.length}`) +const deliveryNo = transferDeliveries[0].deliveryNo -let task = await api(overseas, pdaToken, 'GET', `/api/pda/v1/last-leg/dispatches/${dispatchNo}`) assert(Array.isArray(task.items) && task.items.length === 2, 'Expected two PDA pallet items') -for (const [itemIndex, item] of task.items.entries()) { - let remaining = Number(item.scheduledPackages) - Number(item.pickedPackages) - for (const [positionIndex, position] of item.positions.entries()) { - if (remaining <= 0) break - const packages = Math.min(remaining, Number(position.availablePackages)) - if (packages <= 0) continue - await api(overseas, pdaToken, 'POST', - `/api/pda/v1/last-leg/dispatches/${dispatchNo}/items/${id(item.dispatchItemId)}/pick`, { - positionId: id(position.id), packages, productQuantity: packages, - idempotencyKey: key(`PICK-${itemIndex}-${positionIndex}`), remark: marker, - }) - remaining -= packages +if (!alreadyDeparted) { + if (task.items.every(item => Number(item.pickedPackages) === 0)) { + await api(overseas, platformToken, 'PUT', `/api/platform/v1/last-leg/dispatches/${dispatchNo}`, { + vehiclePlate: `UAT-${marker.slice(-8)}`, driverName: '尾程验收司机', driverPhone: '19900000000', + carrierName: '尾程验收车队', fbaAppointmentNo: `UAT-${marker.slice(-8)}`, + fbaAppointmentStatus: 'CONFIRMED', vehicleAppointmentStatus: 'CONFIRMED', + estimatedArrivalAt: new Date(Date.now() + 172800000).toISOString().slice(0, 19), + }) } - assert(remaining === 0, `PDA item ${item.dispatchItemId} has insufficient inventory`) + for (const [itemIndex, item] of task.items.entries()) { + let remaining = Number(item.scheduledPackages) - Number(item.pickedPackages) + for (const [positionIndex, position] of item.positions.entries()) { + if (remaining <= 0) break + const packages = Math.min(remaining, Number(position.availablePackages)) + if (packages <= 0) continue + await api(overseas, pdaToken, 'POST', + `/api/pda/v1/last-leg/dispatches/${dispatchNo}/items/${id(item.dispatchItemId)}/pick`, { + positionId: id(position.id), packages, productQuantity: packages, + idempotencyKey: key(`PICK-${itemIndex}-${positionIndex}`), remark: marker, + }) + remaining -= packages + } + assert(remaining === 0, `PDA item ${item.dispatchItemId} has insufficient inventory`) + } + task = await api(overseas, pdaToken, 'GET', `/api/pda/v1/last-leg/dispatches/${dispatchNo}`) + alreadyDeparted = task.status === 'DEPARTED' + || (task.status === 'COMPLETED' && String(task.departBlockReason || '').includes('已发车')) } -const completed = await api(overseas, pdaToken, 'POST', - `/api/pda/v1/last-leg/dispatches/${dispatchNo}/complete`, { idempotencyKey: key('PICK-COMPLETE') }) -assert(completed.status === 'READY_TO_DEPART', 'Dispatch did not reach READY_TO_DEPART') -const departed = await api(overseas, pdaToken, 'POST', +if (!alreadyDeparted && task.status !== 'COMPLETED') { + const pickingCompleted = await api(overseas, pdaToken, 'POST', + `/api/pda/v1/last-leg/dispatches/${dispatchNo}/complete`, { idempotencyKey: key('PICK-COMPLETE') }) + assert(pickingCompleted.status === 'READY_TO_DEPART', 'Dispatch did not reach READY_TO_DEPART') + task = await api(overseas, pdaToken, 'GET', `/api/pda/v1/last-leg/dispatches/${dispatchNo}`) +} +assert(alreadyDeparted || task.status === 'COMPLETED', 'Dispatch did not finish picking') +const departed = alreadyDeparted ? { + status: 'DEPARTED', + shippedPackages: task.items.reduce((total, item) => total + Number(item.pickedPackages), 0), +} : await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/dispatches/${dispatchNo}/depart`, { idempotencyKey: key('DEPART'), remark: marker }) assert(departed.status === 'DEPARTED', 'Dispatch did not reach DEPARTED') @@ -92,24 +136,47 @@ const outbound = await api(overseas, platformToken, 'GET', `/api/platform/v1/last-leg/outbound-orders/${transfer.outboundNo}?page=1&size=20`) assert(outbound.outbound.status === 'OUTBOUNDED', 'Outbound did not reach OUTBOUNDED') let delivery = await api(overseas, platformToken, 'GET', - `/api/platform/v1/last-leg/delivery-orders/${transfer.deliveryNo}?customerId=${customerId}`) -const delivering = await api(overseas, platformToken, 'PUT', - `/api/platform/v1/last-leg/delivery-orders/${transfer.deliveryNo}/status?customerId=${customerId}`, { - status: 'DELIVERING', expectedVersion: Number(delivery.order.version), remark: marker, + `/api/platform/v1/last-leg/delivery-orders/${deliveryNo}?customerId=${customerId}`) +if (delivery.order.status === 'WAIT_DELIVERY') { + const delivering = await api(overseas, platformToken, 'PUT', + `/api/platform/v1/last-leg/delivery-orders/${deliveryNo}/status?customerId=${customerId}`, { + status: 'DELIVERING', expectedVersion: Number(delivery.order.version), remark: marker, + }) + assert(delivering.deliveryStatus === 'DELIVERING', 'Delivery did not reach DELIVERING') + delivery = await api(overseas, platformToken, 'GET', + `/api/platform/v1/last-leg/delivery-orders/${deliveryNo}?customerId=${customerId}`) +} +if (delivery.order.status !== 'DELIVERED') { + assert(delivery.order.status === 'DELIVERING', `Unexpected delivery status ${delivery.order.status}`) + const documentsPath = `/api/platform/v1/last-leg/delivery-orders/${deliveryNo}/documents?customerId=${customerId}` + const documents = await api(overseas, platformToken, 'GET', documentsPath) + const form = new FormData() + form.append('file', new Blob([`${marker} receipt acceptance for ${deliveryNo}`], { type: 'text/plain' }), + `${marker}-${deliveryNo}-receipt.txt`) + const receipt = await api(overseas, platformToken, 'POST', + `/api/platform/v1/last-leg/delivery-orders/${deliveryNo}/documents/RECEIPT/files?customerId=${customerId}`, form) + const existingBindings = (documents.categories || []).map((category) => ({ + category: category.category, + fileIds: (category.files || []).map((file) => id(file.fileId)), + })).filter((binding) => binding.category !== 'RECEIPT' && binding.fileIds.length > 0) + await api(overseas, platformToken, 'PUT', documentsPath, { + expectedVersion: Number(documents.version), idempotencyKey: key('SAVE-RECEIPT'), + bolNo: documents.bolNo || null, + documents: [...existingBindings, { category: 'RECEIPT', fileIds: [id(receipt.fileId)] }], }) -assert(delivering.deliveryStatus === 'DELIVERING', 'Delivery did not reach DELIVERING') -delivery = await api(overseas, platformToken, 'GET', - `/api/platform/v1/last-leg/delivery-orders/${transfer.deliveryNo}?customerId=${customerId}`) -const delivered = await api(overseas, platformToken, 'POST', - `/api/platform/v1/last-leg/delivery-orders/${transfer.deliveryNo}/complete?customerId=${customerId}`, { - status: 'DELIVERED', expectedVersion: Number(delivery.order.version), remark: marker, - }) -assert(delivered.deliveryStatus === 'DELIVERED', 'Delivery did not reach DELIVERED') + delivery = await api(overseas, platformToken, 'GET', + `/api/platform/v1/last-leg/delivery-orders/${deliveryNo}?customerId=${customerId}`) +} +assert(delivery.order.status === 'DELIVERED', 'Receipt did not automatically complete delivery') +const visits = await api(overseas, platformToken, 'GET', + `/api/platform/v1/last-leg/customer-visits?page=1&size=200&customerId=${customerId}&keyword=${encodeURIComponent(deliveryNo)}`) +const visit = (visits.records || []).find((value) => value.deliveryNo === deliveryNo) +assert(visit?.status === 'PENDING', 'Delivery completion did not create a pending customer visit') const report = { marker, scope: 'OVERSEAS_LASTLEG_ONLY', sourceReport, relabelNo: scenario.relabelNo, dispatchNo, dispatchStatus: departed.status, outboundNo: transfer.outboundNo, - outboundStatus: outbound.outbound.status, deliveryNo: transfer.deliveryNo, - deliveryStatus: delivered.deliveryStatus, visitId: id(delivered.visitId), shippedPackages: departed.shippedPackages, + outboundStatus: outbound.outbound.status, deliveryNo, + deliveryStatus: delivery.order.status, visitId: id(visit.id), shippedPackages: departed.shippedPackages, passed: true, finishedAt: new Date().toISOString() } await writeFile(output, JSON.stringify(report, null, 2), 'utf8') console.log(JSON.stringify(report, null, 2)) diff --git a/scripts/overseas-relabel-real-acceptance.mjs b/scripts/overseas-relabel-real-acceptance.mjs index f6b5f1879..329754dc4 100644 --- a/scripts/overseas-relabel-real-acceptance.mjs +++ b/scripts/overseas-relabel-real-acceptance.mjs @@ -13,6 +13,8 @@ const directMerchantToken = process.env.MERCHANT_OVERSEAS_ACCESS_TOKEN const directPdaToken = process.env.PDA_OVERSEAS_ACCESS_TOKEN const output = process.env.OVERSEAS_ACCEPTANCE_OUTPUT || 'reports/overseas-relabel-real-acceptance.json' const marker = `TAIL-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}` +const chargeAmount = Number(process.env.OVERSEAS_RELABEL_CHARGE_AMOUNT ?? '0.01') +if (!Number.isFinite(chargeAmount) || chargeAmount < 0) throw new Error('Invalid charge amount') for (const [name, value] of Object.entries({ customerId, warehouseId })) { if (!value) throw new Error(`Missing environment variable: ${name}`) @@ -21,7 +23,7 @@ if (!directPlatformToken && (!platformUser || !platformPassword)) throw new Erro if (!directMerchantToken && (!platformUser || !platformPassword)) throw new Error('Missing merchant token or platform credentials') if (!directPdaToken && (!pdaUser || !pdaPassword)) throw new Error('Missing PDA credentials or token') -const report = { marker, startedAt: new Date().toISOString(), scope: 'OVERSEAS_LASTLEG_ONLY', scenarios: [], passed: false } +const report = { marker, startedAt: new Date().toISOString(), scope: 'OVERSEAS_LASTLEG_ONLY', chargeAmount, photoEvidence: 'SYNTHETIC_PNG_NOT_PHYSICAL_WORK', scenarios: [], passed: false } function key(value) { return `${marker}-${value}`.slice(0, 200) } function id(value) { return value == null ? '' : String(value) } async function api(base, token, method, path, body, headers = {}) { @@ -70,7 +72,7 @@ async function resolvePallet(palletCode) { return api(overseas, pdaToken, 'POST', '/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: palletCode }) } -async function createAndApprove(name, cartonCount) { +async function createAndConfirm(name, cartonCount, operationType = 'CARTON_AND_SKU', prepare = null) { const suffix = `${name}-${Date.now()}` const items = Array.from({ length: cartonCount }, (_, index) => ({ sourceInventoryBalanceId: id(source.inventoryBalanceId), sourceInventoryPositionId: id(sourcePosition.positionId), @@ -78,49 +80,68 @@ async function createAndApprove(name, cartonCount) { productNameZh: source.productNameZh || source.skuCode, totalPackageCount: 1, notifiedPackageCount: 1, shipmentNo: `SHIP-${suffix}-${index + 1}`, newSkuCode: `${source.skuCode}-NEW-${suffix}-${index + 1}`.slice(0, 128), labelRequirement: marker, cartonMark: `CM-${suffix}-${index + 1}`, - fbaCartonMark: `FBA-${suffix}-${index + 1}`, destinationFbaWarehouseCode: source.destinationFbaWarehouseCode, - operationType: 'CARTON_AND_SKU', skuItems: [{ skuCode: source.skuCode, + fbaCartonMark: operationType === 'CARTON_AND_SKU' ? `FBA-${suffix}-${index + 1}` : null, + destinationFbaWarehouseCode: source.destinationFbaWarehouseCode, + operationType, skuItems: [{ skuCode: source.skuCode, newSkuCode: `${source.skuCode}-NEW-${suffix}-${index + 1}`.slice(0, 128), quantity: 1, remark: marker }], })) const created = await api(overseas, platformToken, 'POST', `/api/platform/v1/last-leg/relabel-applications?customerId=${customerId}`, { customerOrderNo: `TAIL-${suffix}`.slice(0, 128), operationWarehouseId: warehouseId, labelDeadline: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 19), - contactName: '尾程验收', contactPhone: '13800000006', chargeAmount: 0.01, - remark: `${marker}-${name}`, idempotencyKey: key(`${name}-CREATE`), deliveryMethod: 'TRUCK', + contactName: '尾程验收', contactPhone: '13800000006', ...(chargeAmount > 0 ? { chargeAmount } : {}), + remark: `${marker}-${name}`, idempotencyKey: key(`${name}-CREATE`), + deliveryMethod: operationType === 'SKU_LABEL' ? null : 'TRUCK', printShippingLabel: false, items, }) const relabelNo = created.application.relabelNo - await poll(() => api(overseas, platformToken, 'GET', + if (chargeAmount > 0) await poll(() => api(overseas, platformToken, 'GET', `/api/platform/v1/last-leg/relabel-applications/${relabelNo}`), (detail) => detail.application.walletStatus === 'FROZEN') let detail = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/claim`, { idempotencyKey: key(`${name}-CLAIM`) }) for (const [index, item] of detail.items.entries()) { + if (operationType === 'CARTON_AND_SKU') { + detail = await api(overseas, pdaToken, 'POST', + `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/cartons/scan`, { + cartonMark: item.cartonMark, expectedVersion: Number(detail.version), + idempotencyKey: key(`${name}-CARTON-${index}`), + }) + const stageItem = detail.items.find((row) => id(row.itemId) === id(item.itemId)) + assert(Number(stageItem?.cartonProcessedPackages) === 1 + && Number(stageItem?.skuProcessedPackages) === 0, + `${relabelNo} carton stage incorrectly locked or completed SKU stage`) + } detail = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/units`, { - itemId: id(item.itemId), oldLabel: item.cartonMark, newLabel: item.fbaCartonMark, + itemId: id(item.itemId), oldLabel: item.cartonMark, newLabel: null, + skuVerifications: (item.skuItems || []).map((sku) => ({ + skuCode: sku.skuCode, newSkuCode: sku.newSkuCode, quantity: Number(sku.quantity), + })), expectedVersion: Number(detail.version), idempotencyKey: key(`${name}-SCAN-${index}`), }) } + const sourceBefore = Number((await resolvePallet(sourcePosition.palletNo)).totalPackages) + const scenario = { name, operationType, relabelNo, items: detail.items, sourceBefore } + if (prepare) await prepare(scenario) const form = new FormData() - form.append('file', new Blob([Buffer.from('89504e470d0a1a0a', 'hex')], { type: 'image/png' }), `${marker}.png`) + // Valid synthetic PNG for HTTP validation; not a photograph of real warehouse work. + form.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64')], { type: 'image/png' }), `${marker}.png`) const photo = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/photos/files`, form) detail = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/complete`, { photoFileIds: [id(photo.fileId)], expectedVersion: Number(detail.version), idempotencyKey: key(`${name}-COMPLETE`), remark: marker, }) - detail = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/review`, { - decision: 'APPROVE', expectedVersion: Number(detail.version), - idempotencyKey: key(`${name}-APPROVE`), remark: marker, - }) assert(detail.applicationStatus === 'CUSTOMER_CONFIRM_PENDING', `${relabelNo} did not reach customer confirmation`) const confirmed = await api(overseas, merchantToken, 'POST', `/api/merchant/v1/last-leg/relabel-applications/${relabelNo}/confirm-completion`, { idempotencyKey: key(`${name}-CONFIRM`), remark: marker, }) - assert(confirmed.application.status === 'READY_FOR_OUTBOUND', `${relabelNo} did not reach READY_FOR_OUTBOUND`) - return { name, relabelNo, items: confirmed.items, sourceBefore: Number((await resolvePallet(sourcePosition.palletNo)).totalPackages) } + const expectedStatus = operationType === 'SKU_LABEL' ? 'COMPLETED' : 'READY_FOR_OUTBOUND' + assert(confirmed.application.status === expectedStatus, `${relabelNo} did not reach ${expectedStatus}`) + assert(confirmed.items.every((item) => item.destinationFbaWarehouseCode === source.destinationFbaWarehouseCode), + `${relabelNo} did not preserve the destination warehouse`) + return { ...scenario, items: confirmed.items } } async function printPallet(relabelNo, suffix) { @@ -141,26 +162,38 @@ async function moveCarton(scenario, item, target, index) { return result } -const fresh = await createAndApprove('NEW-PALLET', 1) -const sharedTarget = await printPallet(fresh.relabelNo, 'SHARED') -const printReplay = await printPallet(fresh.relabelNo, 'SHARED') -assert(id(sharedTarget.palletId) === id(printReplay.palletId), 'Pallet print replay was not idempotent') -await moveCarton(fresh, fresh.items[0], sharedTarget, 0) -fresh.targets = [sharedTarget] +const skuOnly = await createAndConfirm('SKU-ONLY', 1, 'SKU_LABEL') +skuOnly.sourceAfter = Number((await resolvePallet(sourcePosition.palletNo)).totalPackages) +assert(skuOnly.sourceAfter === skuOnly.sourceBefore, 'SKU-only relabel must not split or regroup pallets') +skuOnly.targetPallets = [] +skuOnly.destinationFbaWarehouseCode = source.destinationFbaWarehouseCode +delete skuOnly.items + +let sharedTarget +const fresh = await createAndConfirm('NEW-PALLET', 1, 'CARTON_AND_SKU', async scenario => { + sharedTarget = await printPallet(scenario.relabelNo, 'SHARED') + const printReplay = await printPallet(scenario.relabelNo, 'SHARED') + assert(id(sharedTarget.palletId) === id(printReplay.palletId), 'Pallet print replay was not idempotent') + await moveCarton(scenario, scenario.items[0], sharedTarget, 0) + scenario.targets = [sharedTarget] +}) -const merged = await createAndApprove('MERGE-EXISTING', 1) const sharedBefore = Number((await resolvePallet(sharedTarget.palletCode)).totalPackages) -await moveCarton(merged, merged.items[0], sharedTarget, 0) +const merged = await createAndConfirm('MERGE-EXISTING', 1, 'CARTON_AND_SKU', async scenario => { + await moveCarton(scenario, scenario.items[0], sharedTarget, 0) + scenario.targets = [sharedTarget] +}) const sharedAfter = Number((await resolvePallet(sharedTarget.palletCode)).totalPackages) assert(sharedAfter === sharedBefore + 1, 'Existing target pallet carton count did not increase') merged.targets = [sharedTarget] -const split = await createAndApprove('SPLIT-TWO-TARGETS', 2) -const splitA = await printPallet(split.relabelNo, 'SPLIT-A') -const splitB = await printPallet(split.relabelNo, 'SPLIT-B') -await moveCarton(split, split.items[0], splitA, 0) -await moveCarton(split, split.items[1], splitB, 1) -split.targets = [splitA, splitB] +const split = await createAndConfirm('SPLIT-TWO-TARGETS', 2, 'CARTON_AND_SKU', async scenario => { + const splitA = await printPallet(scenario.relabelNo, 'SPLIT-A') + const splitB = await printPallet(scenario.relabelNo, 'SPLIT-B') + await moveCarton(scenario, scenario.items[0], splitA, 0) + await moveCarton(scenario, scenario.items[1], splitB, 1) + scenario.targets = [splitA, splitB] +}) for (const scenario of [fresh, merged, split]) { const sourceAfter = Number((await resolvePallet(sourcePosition.palletNo)).totalPackages) @@ -191,7 +224,7 @@ const ledgerRows = ledger.records.filter((row) => String(row.remark || '').inclu assert(ledgerRows.some((row) => Number(row.deltaPackages) < 0), 'Missing negative relabel pallet ledger') assert(ledgerRows.some((row) => Number(row.deltaPackages) > 0), 'Missing positive relabel pallet ledger') assert(ledgerRows.every((row) => String(row.remark).startsWith('换标拆托')), 'Unexpected relabel ledger reason') -report.scenarios = [fresh, merged, split] +report.scenarios = [skuOnly, fresh, merged, split] report.ledger = { rows: ledgerRows.length, deltas: ledgerRows.map((row) => Number(row.deltaPackages)) } report.passed = true report.finishedAt = new Date().toISOString() diff --git a/scripts/relabel-closure-local-acceptance.mjs b/scripts/relabel-closure-local-acceptance.mjs new file mode 100644 index 000000000..b20454678 --- /dev/null +++ b/scripts/relabel-closure-local-acceptance.mjs @@ -0,0 +1,116 @@ +import { mkdir, writeFile, readFile } from 'node:fs/promises' +import { createHash } from 'node:crypto' +const base = process.env.ADMIN_OVERSEAS_GATEWAY || 'http://127.0.0.1:9300' +if (!['localhost', '127.0.0.1'].includes(new URL(base).hostname)) throw new Error('Local acceptance only') +const merchant = process.env.MERCHANT_OVERSEAS_ACCESS_TOKEN, pda = process.env.PDA_OVERSEAS_ACCESS_TOKEN, admin = process.env.ADMIN_OVERSEAS_ACCESS_TOKEN +const warehouse = process.env.OVERSEAS_TEST_WAREHOUSE_ID +if (!merchant || !pda || !admin || !warehouse) throw new Error('Explicit session and warehouse required') +const marker = `CLOSE-${Date.now()}` +const report = { marker, passed: false, scope: 'REAL_HTTP_SYNTHETIC_WAREHOUSE_EVIDENCE_NO_FINANCE', scenarios: [] } +const key = () => `${marker}-${crypto.randomUUID()}` +const root = '/api/merchant/v1/last-leg/relabel-applications' +const work = '/api/pda/v1/last-leg/relabel-tasks' +const pallets = '/api/pda/v1/last-leg/pallet-operations' +const assert = (condition, message) => { if (!condition) throw new Error(message) } +async function raw(token, method, path, body) { + const response = await fetch(base + path, { method, headers: { Authorization: `Bearer ${token}`, ...(body && !(body instanceof FormData) ? { 'Content-Type': 'application/json' } : {}) }, body: body instanceof FormData ? body : body ? JSON.stringify(body) : undefined }) + const result = await response.json() + return { ...result, http: response.status } +} +async function api(token, method, path, body) { + const result = await raw(token, method, path, body) + assert(result.http === 200 && String(result.code) === '200', `${path}: ${result.code} ${result.message}`) + return result.data +} +async function reject(token, path, body, message) { + const result = await raw(token, 'POST', path, body) + assert(String(result.code) !== '200' && message.test(result.message || ''), `Expected rejection at ${path}, got ${result.code} ${result.message}`) +} +const resolve = code => api(pda, 'POST', `${pallets}/pallets/resolve`, { code }) +async function move(no, mark, from, to) { + const source = await resolve(from), target = await resolve(to) + const request = { relabelNo: no, source: { palletCode: from, expectedVersion: source.palletVersion }, target: { palletCode: to, expectedVersion: target.palletVersion }, cartonMarks: [mark], reason: marker, idempotencyKey: key() } + const result = await api(pda, 'POST', `${pallets}/relabel-disassemble`, request) + const replay = await api(pda, 'POST', `${pallets}/relabel-disassemble`, request) + assert(replay.replayed === true, 'Move replay was not idempotent') + const after = await resolve(to) + assert(Number(after.totalPackages) === Number(target.totalPackages) + 1 && after.status === 'OCCUPIED', 'Target stock/status did not update exactly once') + assert(Number((await resolve(from)).totalPackages) === Number(source.totalPackages) - 1, 'Source stock was not reduced once') + return result +} +try { + for (const deliveryMethod of ['TRUCK_PICKUP', 'EXPRESS', 'EXPRESS_PICKUP']) { + const candidates = await api(merchant, 'GET', `${root}/inventory-candidates?operationWarehouseId=${warehouse}`) + const candidate = candidates.find(value => value.destinationFbaWarehouseCode && value.positions?.some(pos => Number(pos.availablePackages) > 0 && pos.palletNo !== 'UNBOUND')) + assert(candidate, 'No available inventory for another scenario') + const position = candidate.positions.find(pos => Number(pos.availablePackages) > 0 && pos.palletNo !== 'UNBOUND') + const mark = `${marker}-${deliveryMethod}` + const request = { customerOrderNo: mark, operationWarehouseId: warehouse, labelDeadline: new Date(Date.now() + 86400000).toISOString().slice(0, 19), contactName: '流程验收', contactPhone: '13800000000', deliveryMethod, printShippingLabel: false, idempotencyKey: key(), items: [{ sourceInventoryBalanceId: candidate.inventoryBalanceId, sourceInventoryPositionId: position.positionId, sourceInboundOrderId: candidate.inboundOrderId, skuCode: candidate.skuCode || 'TEST', productNameZh: '验收测试货物', totalPackageCount: 1, notifiedPackageCount: 1, cartonMark: mark, fbaCartonMark: `FBA-${mark}`, destinationFbaWarehouseCode: candidate.destinationFbaWarehouseCode, operationType: 'CARTON_LABEL', skuItems: [] }] } + request.items[0].shipmentNo = `SHIP-${mark}` + const created = await api(merchant, 'POST', root, request) + const no = created.application.relabelNo + const scenario = { deliveryMethod, relabelNo: no, originalPallet: position.palletNo, checks: [] } + report.scenarios.push(scenario) + await reject(merchant, root, { ...request, idempotencyKey: key() }, /已用于换标单/) + scenario.checks.push('DUPLICATE_APPLICATION_REJECTED') + // Read an actual Print-generated test document from File, then upload it through the merchant API. + const pdfReport = JSON.parse(await readFile('reports/relabel-closure-20260916/pdf.json', 'utf8')) + const doc = pdfReport.files[0] + const pdfResponse = await fetch(`http://127.0.0.1:9206/internal/file/v1/business-files/${process.env.OVERSEAS_TEST_CUSTOMER_ID}/${doc.category}/${pdfReport.businessId}/${doc.fileId}/content`, { headers: { 'X-Internal-Service-Token': process.env.INTERNAL_SERVICE_TOKEN } }) + const pdf = Buffer.from(await pdfResponse.arrayBuffer()) + assert(pdfResponse.ok && pdf.subarray(0, 5).toString() === '%PDF-', 'Test PDF not available') + for (const scope of ['CARTON_TOTAL', 'SKU_TOTAL', 'ITEM']) { + const form = new FormData(); form.append('file', new Blob([pdf], { type: 'application/pdf' }), `${marker}.pdf`) + await api(merchant, 'POST', `${root}/${no}/files?scope=${scope}${scope === 'ITEM' ? `&itemId=${created.items[0].id}` : ''}`, form) + } + let task = await api(pda, 'POST', `${work}/${no}/claim`, { idempotencyKey: key() }) + const documents = await api(pda, 'GET', `${work}/${no}/documents`) + assert(documents.length >= 3, 'PDA is missing uploaded label documents') + for (const file of documents) { + const response = await fetch(`${base}${work}/${no}/documents/${file.fileId}/download?scope=${file.scope}${file.itemId ? `&itemId=${file.itemId}` : ''}`, { headers: { Authorization: `Bearer ${pda}` } }) + const downloaded = Buffer.from(await response.arrayBuffer()) + assert(response.ok && createHash('sha256').update(downloaded).digest('hex') === createHash('sha256').update(pdf).digest('hex'), 'PDA document mismatch') + } + scenario.checks.push('MERCHANT_UPLOAD_PDA_DOWNLOAD_THREE_SCOPES') + task = await api(pda, 'POST', `${work}/${no}/cartons/scan`, { cartonMark: mark, expectedVersion: task.version, idempotencyKey: key() }) + const photoForm = new FormData(); photoForm.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64')], { type: 'image/png' }), 'synthetic.png') + const photo = await api(pda, 'POST', `${work}/${no}/photos/files`, photoForm) + const complete = () => ({ photoFileIds: [photo.fileId], expectedVersion: task.version, remark: marker, idempotencyKey: key() }) + await reject(pda, `${work}/${no}/complete`, complete(), /托盘绑定/) + scenario.checks.push('UNBOUND_COMPLETION_REJECTED') + const first = await api(pda, 'POST', `${pallets}/relabel-pallet-label`, { relabelNo: no, idempotencyKey: key() }) + await move(no, mark, position.palletNo, first.palletCode) + const second = await api(pda, 'POST', `${pallets}/relabel-pallet-label`, { relabelNo: no, idempotencyKey: key() }) + await move(no, mark, first.palletCode, second.palletCode) + scenario.targetPallet = second.palletCode + scenario.checks.push('REBIND_STOCK_CONSERVED_AND_REPLAY_SAFE') + task = await api(pda, 'POST', `${work}/${no}/complete`, complete()) + if (deliveryMethod === 'TRUCK_PICKUP') { + task = await api(admin, 'POST', `/api/platform/v1/last-leg/relabel-applications/${no}/rework`, { decision: 'REWORK', expectedVersion: task.version, idempotencyKey: key(), remark: '验收退回:请核对并重新贴标' }) + assert(task.reviewRemark?.includes('验收退回'), 'Rework remark missing on PDA') + task = await api(pda, 'POST', `${work}/${no}/cartons/scan`, { cartonMark: mark, expectedVersion: task.version, idempotencyKey: key() }) + await reject(pda, `${work}/${no}/complete`, complete(), /托盘绑定/) + await move(no, mark, second.palletCode, first.palletCode) + scenario.targetPallet = first.palletCode + task = await api(pda, 'POST', `${work}/${no}/complete`, complete()) + scenario.checks.push('REWORK_RESCAN_REBIND_AND_RESUBMIT') + } + const confirmed = await api(merchant, 'POST', `${root}/${no}/confirm-completion`, { idempotencyKey: key(), remark: marker }) + scenario.outboundNo = confirmed.application.outboundOrderNo + assert(scenario.outboundNo, 'Customer confirmation did not create direct outbound') + await reject(pda, `${pallets}/relabel-disassemble`, { relabelNo: no, source: { palletCode: scenario.targetPallet, expectedVersion: 0 }, target: { palletCode: position.palletNo, expectedVersion: 0 }, cartonMarks: [mark], idempotencyKey: key() }, /排车或出库/) + const resolved = await api(pda, 'GET', `/api/pda/v1/last-leg/outbound-tasks/resolve/${scenario.outboundNo}`) + const picked = await api(pda, 'POST', `/api/pda/v1/last-leg/outbound-tasks/${resolved.taskNo}/pallets/${scenario.targetPallet}/pick`, { idempotencyKey: key() }) + await api(pda, 'POST', `/api/pda/v1/last-leg/outbound-tasks/${resolved.taskNo}/complete`, { idempotencyKey: key() }) + const outbound = await api(admin, 'GET', `/api/platform/v1/last-leg/outbound-orders/${scenario.outboundNo}?page=1&size=20`) + assert(outbound.outbound.status === 'OUTBOUNDED', 'Direct outbound not completed') + scenario.outboundStatus = outbound.outbound.status + scenario.checks.push('DIRECT_OUTBOUND_COMPLETED', 'OUTBOUND_REBIND_REJECTED') + } + report.passed = true +} catch (error) { report.error = error.message; process.exitCode = 1 } +finally { + await mkdir('reports/relabel-closure-20260916', { recursive: true }) + await writeFile('reports/relabel-closure-20260916/extra.json', JSON.stringify(report, null, 2)) + console.log(JSON.stringify(report, null, 2)) +} diff --git a/scripts/relabel-pdf-local-acceptance.mjs b/scripts/relabel-pdf-local-acceptance.mjs new file mode 100644 index 000000000..37031a4ab --- /dev/null +++ b/scripts/relabel-pdf-local-acceptance.mjs @@ -0,0 +1,42 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { createHash } from 'node:crypto' + +const token = process.env.INTERNAL_SERVICE_TOKEN +if (!token) throw new Error('INTERNAL_SERVICE_TOKEN is required') +const printBase = process.env.PRINT_SERVICE_URL || 'http://127.0.0.1:9207' +const fileBase = process.env.FILE_SERVICE_URL || 'http://127.0.0.1:9206' +for (const url of [printBase, fileBase]) if (!['localhost', '127.0.0.1'].includes(new URL(url).hostname)) throw new Error('Local services only') +const customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID +if (!customerId) throw new Error('OVERSEAS_TEST_CUSTOMER_ID is required') +const businessId = String(Date.now()) +const headers = { 'X-Internal-Service-Token': token, 'X-Operator-Id': '1' } +const report = { scope: 'LOCAL_PRINT_AND_FILE_HTTP_ONLY', businessId, passed: false, files: [] } +const ensure = (ok, message) => { if (!ok) throw new Error(message) } +try { + const rendered = await fetch(`${printBase}/internal/print/v1/pdfs/inbound-marks`, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ templateCode: 'INBOUND_MARK_V1', pageSpec: { widthMm: 100, heightMm: 100 }, downloadFileName: 'relabel-acceptance.pdf', labels: [{ topCode: 'RELABEL-ACCEPTANCE', warehouseText: 'ONT8', cartonText: 'CTNS:1-1', routeText: '验收测试标签', qrContent: `ACCEPTANCE-${businessId}` }] }) }) + const pdf = Buffer.from(await rendered.arrayBuffer()) + ensure(rendered.ok && pdf.subarray(0, 5).toString() === '%PDF-', `Print failed: HTTP ${rendered.status}`) + report.pdfBytes = pdf.length + const hash = value => createHash('sha256').update(value).digest('hex') + for (const category of ['OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT', 'OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT', 'OVERSEAS_RELABEL_ITEM_DOCUMENT']) { + const form = new FormData(); form.append('file', new Blob([pdf], { type: 'application/pdf' }), 'relabel-acceptance.pdf') + const path = `/internal/file/v1/business-files/${customerId}/${category}/${businessId}` + const upload = await fetch(fileBase + path, { method: 'POST', headers, body: form }) + const body = await upload.json() + ensure(upload.ok && String(body.code) === '200', `${category}: ${body.code} ${body.message}`) + const fileId = body.data.fileId + const downloaded = await fetch(`${fileBase}${path}/${fileId}/content`, { headers }) + const bytes = Buffer.from(await downloaded.arrayBuffer()) + ensure(downloaded.ok && hash(bytes) === hash(pdf), 'Downloaded PDF does not match uploaded bytes') + const wrongOwner = await fetch(`${fileBase}/internal/file/v1/business-files/1/${category}/${businessId}/${fileId}`, { headers }) + const denied = await wrongOwner.json() + ensure(!wrongOwner.ok || String(denied.code) !== '200', 'File was visible across customers') + report.files.push({ category, fileId, sha256: hash(bytes), ownershipCheck: 'PASS' }) + } + report.passed = true +} catch (error) { report.error = error.message; process.exitCode = 1 } +finally { + await mkdir('reports/relabel-closure-20260916', { recursive: true }) + await writeFile('reports/relabel-closure-20260916/pdf.json', JSON.stringify(report, null, 2)) + console.log(JSON.stringify(report, null, 2)) +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java index 66c6033af..736dce6a7 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java @@ -7,14 +7,17 @@ import com.baiyue.warehouse.entity.WarehousePalletEntity; import com.baiyue.warehouse.dto.WarehousePalletResolveResponse; import com.baiyue.warehouse.security.InternalServiceTokenGuard; import com.baiyue.warehouse.service.WarehouseResourceService; +import com.baiyue.warehouse.service.PalletLabelPrintService; import jakarta.annotation.Resource; import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/internal/v1/warehouse-pallets") public class InternalWarehousePalletController { @Resource private WarehouseResourceService service; + @Resource private PalletLabelPrintService palletLabels; @Resource private InternalServiceTokenGuard tokenGuard; @PostMapping("/ensure-dropship") @@ -49,4 +52,14 @@ public class InternalWarehousePalletController { return ApiResponse.success(service.resolvePublicPallet(palletId)); } + @GetMapping("/{warehouseId}/{palletId}/label") + public ResponseEntity label( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestHeader(value = "X-Request-Id", required = false) String requestId, + @PathVariable long warehouseId, @PathVariable long palletId, + @RequestParam String transferNo) { + tokenGuard.verify(token); + return palletLabels.printTransfer(warehouseId, palletId, transferNo, requestId); + } + } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/PalletLabelPrintService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/PalletLabelPrintService.java index 2da38bfbe..08bf14e64 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/PalletLabelPrintService.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/PalletLabelPrintService.java @@ -25,6 +25,16 @@ public class PalletLabelPrintService { private String internalToken; public ResponseEntity print(long warehouseId, long palletId, String requestId) { + return print(warehouseId, palletId, requestId, "PALLET"); + } + + public ResponseEntity printTransfer(long warehouseId, long palletId, String transferNo, + String requestId) { + String reference = transferNo == null || transferNo.isBlank() ? "转运单" : "转运单 " + transferNo.trim(); + return print(warehouseId, palletId, requestId, reference); + } + + private ResponseEntity print(long warehouseId, long palletId, String requestId, String reference) { var pallet = resources.pallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, warehouseId, palletId); String code = pallet.getPalletCode(); String fbaCode = pallet.getFbaWarehouseCode(); @@ -37,8 +47,9 @@ public class PalletLabelPrintService { } var warehouse = warehouses.required(warehouseId); String fileName = code.replaceAll("[\\\\/:*?\"<>|]", "-") + "-托盘码.pdf"; - var label = new PrintPdfClient.Label(code, fbaCode == null || fbaCode.isBlank() ? "—" : fbaCode, - "PALLET", warehouse.warehouseName(), code); + var label = new PrintPdfClient.Label(code, + fbaCode == null || fbaCode.isBlank() ? "目的仓待定" : fbaCode, + reference, warehouse.warehouseName(), code); var request = new PrintPdfClient.Request("INBOUND_MARK_V1", new PrintPdfClient.PageSpec(new BigDecimal("100"), new BigDecimal("100")), fileName, List.of(label)); try { diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java index 407991006..d9bcd4a6d 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java @@ -386,9 +386,11 @@ public class WarehouseResourceService { } private static String generatedPalletCode(String warehouseCode, long palletId) { - String boundedWarehouseCode = warehouseCode.length() <= 30 ? warehouseCode : warehouseCode.substring(0, 30); - return "PLT-" + boundedWarehouseCode + "-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) - + "-" + Long.toUnsignedString(palletId); + String compactWarehouseCode = warehouseCode.replaceAll("[^A-Za-z0-9]", "").toUpperCase(java.util.Locale.ROOT); + if (compactWarehouseCode.length() > 9) compactWarehouseCode = compactWarehouseCode.substring(0, 9); + return "PLT-" + compactWarehouseCode + "-" + + LocalDate.now().format(DateTimeFormatter.ofPattern("yyMMdd")) + "-" + + Long.toUnsignedString(palletId, 36).toUpperCase(java.util.Locale.ROOT); } private static String defaultLocationCode(String warehouseCode) { diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/InternalWarehousePalletLabelContractTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/InternalWarehousePalletLabelContractTest.java new file mode 100644 index 000000000..97fa17267 --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/InternalWarehousePalletLabelContractTest.java @@ -0,0 +1,20 @@ +package com.baiyue.warehouse.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; + +import static org.assertj.core.api.Assertions.assertThat; + +class InternalWarehousePalletLabelContractTest { + @Test + void labelEndpointIsInternalAndRequiresTheServiceToken() throws Exception { + var method = InternalWarehousePalletController.class.getMethod("label", + String.class, String.class, long.class, long.class, String.class); + + assertThat(method.getAnnotation(GetMapping.class).value()) + .containsExactly("/{warehouseId}/{palletId}/label"); + assertThat(method.getParameters()[0].getAnnotation(RequestHeader.class).value()) + .isEqualTo("X-Internal-Service-Token"); + } +} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/PalletLabelPrintServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/PalletLabelPrintServiceTest.java index 5035541d1..ee805f0ec 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/PalletLabelPrintServiceTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/PalletLabelPrintServiceTest.java @@ -59,6 +59,26 @@ class PalletLabelPrintServiceTest { verify(print, never()).generateInboundMarks(any(), any(), any()); } + @Test + void transferLabelIncludesTransferNumberAndMakesAnUnknownDestinationExplicit() { + pallet.setPalletCode("ABCD1234567-ABE3-003"); + pallet.setFbaWarehouseCode(null); + byte[] pdf = "%PDF-transfer-label".getBytes(java.nio.charset.StandardCharsets.US_ASCII); + when(print.generateInboundMarks(anyString(), anyString(), any())) + .thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF).body(pdf)); + + service.printTransfer(200L, 9L, "WTR-20260916-1", "request-transfer"); + + var request = ArgumentCaptor.forClass(PrintPdfClient.Request.class); + verify(print).generateInboundMarks(eq("test-token"), eq("request-transfer"), request.capture()); + assertThat(request.getValue().labels()).singleElement().satisfies(label -> { + assertThat(label.topCode()).isEqualTo("ABCD1234567-ABE3-003"); + assertThat(label.cartonText()).isEqualTo("转运单 WTR-20260916-1"); + assertThat(label.warehouseText()).isEqualTo("目的仓待定"); + assertThat(label.routeText()).isEqualTo("洛杉矶仓"); + }); + } + @Test void refusesWrongWarehouseBeforeCallingPrint() { when(resources.pallet(0L, 201L, 9L)).thenThrow(new BizException(ResultCode.NOT_FOUND, "托盘不存在")); diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java index 888a44884..e94c4fe67 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java @@ -262,7 +262,8 @@ class WarehouseResourceServiceTest { ArgumentCaptor captor = ArgumentCaptor.forClass(WarehousePalletEntity.class); verify(repository).insertPallet(captor.capture()); - assertThat(captor.getValue().getPalletCode()).matches("PLT-US-LAX-01-[0-9]{8}-[0-9]+"); + assertThat(captor.getValue().getPalletCode()).matches("PLT-USLAX01-[0-9]{6}-[0-9A-Z]+"); + assertThat(captor.getValue().getPalletCode()).hasSizeLessThanOrEqualTo(34); assertThat(captor.getValue().getLocationId()).isEqualTo(30003L); assertThat(captor.getValue().getId()).isNotNull(); } @@ -283,7 +284,7 @@ class WarehouseResourceServiceTest { ArgumentCaptor captor = ArgumentCaptor.forClass(WarehousePalletEntity.class); verify(repository).insertPallet(captor.capture()); - assertThat(captor.getValue().getPalletCode()).hasSizeLessThanOrEqualTo(64); + assertThat(captor.getValue().getPalletCode()).hasSizeLessThanOrEqualTo(34); } @Test -- 2.54.0 From 27d281d4b371c2da88b88a5b5039992ad638f7f5 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 17 Sep 2026 18:25:05 +0800 Subject: [PATCH 02/16] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/OverseasTokenServiceTest.java | 2 +- deploy/sql/README.md | 2 +- deploy/sql/头程数据库统一增量-MySQL8.sql | 50 ++++++-- gateway-overseas/AGENTS.md | 2 +- .../main/resources/application-overseas.yml | 2 +- .../main/resources/db/lastleg-permissions.sql | 108 ++++++++++++++--- iam/src/main/resources/openapi/iam-v1.yaml | 2 +- .../controller/IamOpenApiContractTest.java | 6 +- ...urnInventoryPermissionSqlContractTest.java | 2 +- ...seasCustomerPermissionSqlContractTest.java | 44 ++++--- .../AuthorizationSnapshotServiceTest.java | 14 ++- lastleg-service/AGENTS.md | 6 +- .../MerchantLastlegOrderController.java | 3 + .../MerchantCargoDiscardController.java | 7 ++ .../MerchantLocalLooseboardController.java | 5 +- ...MerchantOverseasProcurementController.java | 4 +- .../MerchantRelabelApplicationController.java | 12 ++ .../MerchantReturnOrderController.java | 17 ++- .../lastleg/security/OverseasPermissions.java | 26 ++++- .../MerchantWarehouseTransferController.java | 6 + .../MerchantUsTruckOrderController.java | 5 +- .../openapi/overseas-business-v1.yaml | 44 +++---- ...rchantOverseasCustomerApiContractTest.java | 80 +++++++++++-- .../MerchantOverseasOpenApiContractTest.java | 11 +- .../lastleg/relabel/RelabelContractTest.java | 5 +- .../initialization-baseline-contract.test.mjs | 2 +- scripts/agents-index-contract.ps1 | 3 +- warehouse-overseas-service/AGENTS.md | 2 +- .../MerchantWarehouseResourceController.java | 109 ------------------ .../WarehouseResourceControllerTest.java | 18 --- 30 files changed, 370 insertions(+), 229 deletions(-) delete mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseResourceController.java diff --git a/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java index 65c0769af..cda05aa12 100644 --- a/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java @@ -50,7 +50,7 @@ class OverseasTokenServiceTest { account.setDefaultMembershipId(84L); account.setDefaultAppCode("MERCHANT"); String authority = "platform:overseas-master-data:quote:list"; String merchantAuthority = "merchant:warehouse:delivery:list"; - String merchantCrossBorderAuthority = "merchant:cross-border:procurement"; + String merchantCrossBorderAuthority = "merchant:cross-border:procurement:list"; String lastlegAuthority = "lastleg:pda:operate"; String financeAuthority = "platform:finance:fee-source:edit"; String domesticAuthority = "platform:firstleg:order:list"; diff --git a/deploy/sql/README.md b/deploy/sql/README.md index d5fc094b2..5ecbc95e8 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -40,7 +40,7 @@ | 所属库 | 保留内容 | 不包含 | |---|---|---| -| Auth / IAM | 平台主体、唯一平台管理员、8 个系统角色及企业管理员模板、346 个权限、521 条角色权限、7 个数据范围、客户编号起点 | 测试客户、固定客户账号、额外扩权 | +| Auth / IAM | 平台主体、唯一平台管理员、8 个系统角色及企业管理员模板、364 个权限、539 条角色权限、7 个数据范围、客户编号起点 | 测试客户、固定客户账号、额外扩权 | | Warehouse | 既有公共仓目录 318 条及地址;义乌、东莞、洛杉矶、纽约 4 个作业仓收货能力 | 示例供应商、快递、往来单位、托盘、库位、预占、库存 | | Firstleg | 112 个费用项目、15 个渠道目录、7 个始发地别名、4 个公共仓映射 | 客户报价、订单、入库、实测或履约记录 | | File / Finance | 导出互斥键 `GLOBAL_CREATE`、默认平台财务主体 | 文件任务、资金、账单或交易流水 | diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 82ba95643..680ecd368 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -5966,7 +5966,7 @@ INSERT INTO iam_membership_role (membership_id, role_id, tenant_id, created_at) -- 系统基线 iam_number_sequence:1 行。 INSERT INTO iam_number_sequence (sequence_key, next_value, updated_at) VALUES ('CUSTOMER_CODE',1,CURRENT_TIMESTAMP(3)); --- 系统基线 iam_permission:346 行。 +-- 系统基线 iam_permission:364 行。 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) VALUES (5100000,0,'PLATFORM','MENU',0,'platform:customer','客户管理','/customers',NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); 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) VALUES (5100010,0,'PLATFORM','MENU',5100000,'platform:customer:list','企业客户','/customers/list',NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); 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) VALUES (5100011,0,'PLATFORM','BUTTON',5100010,'platform:customer:create','新增企业客户',NULL,NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); @@ -6307,12 +6307,30 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, 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) VALUES (5305021,0,'MERCHANT','BUTTON',5305020,'merchant:finance:recharge:create','提交充值申请',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); 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) VALUES (5306000,0,'MERCHANT','MENU',0,'merchant:warehouse:delivery:list','海外仓派送单','/orders/delivery','GET',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); 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) VALUES (5306001,0,'MERCHANT','BUTTON',5306000,'merchant:warehouse:delivery:create','创建海外仓派送单',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); -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) VALUES (5306100,0,'MERCHANT','MENU',0,'merchant:cross-border:procurement','海外采购','/cross-border/procurement',NULL,70,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); -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) VALUES (5306110,0,'MERCHANT','MENU',0,'merchant:cross-border:domestic-trucking','美国本土卡','/cross-border/domestic-trucking',NULL,80,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); -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) VALUES (5306120,0,'MERCHANT','MENU',0,'merchant:cross-border:pallet-shipping','美本土散板','/cross-border/pallet-shipping',NULL,90,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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) VALUES (5306100,0,'MERCHANT','MENU',0,'merchant:cross-border:procurement:list','海外采购','/cross-border/procurement','GET',70,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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) VALUES (5306101,0,'MERCHANT','BUTTON',5306100,'merchant:cross-border:procurement:create','创建海外采购单',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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) VALUES (5306110,0,'MERCHANT','MENU',0,'merchant:cross-border:domestic-trucking:list','美国本土卡','/cross-border/domestic-trucking','GET',80,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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) VALUES (5306111,0,'MERCHANT','BUTTON',5306110,'merchant:cross-border:domestic-trucking:create','创建美国本土卡订单',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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) VALUES (5306112,0,'MERCHANT','BUTTON',5306110,'merchant:cross-border:domestic-trucking:edit','编辑美国本土卡订单',NULL,'PATCH',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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) VALUES (5306120,0,'MERCHANT','MENU',0,'merchant:cross-border:pallet-shipping:list','美本土散板','/cross-border/pallet-shipping','GET',90,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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) VALUES (5306121,0,'MERCHANT','BUTTON',5306120,'merchant:cross-border:pallet-shipping:create','创建美本土散板订单',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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) VALUES (5306122,0,'MERCHANT','BUTTON',5306120,'merchant:cross-border:pallet-shipping:edit','编辑美本土散板订单',NULL,'PUT',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); 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) VALUES (5306130,0,'MERCHANT','MENU',0,'merchant:warehouse:inbound:list','海外仓入库订单','/orders/inbound',NULL,50,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); 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) VALUES (5306140,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:return-inventory:list','退货库存','/overseas-warehouse/return-inventory','GET',95,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); -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) VALUES (5306141,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:return-cartons:list','退货箱号管理','/overseas-warehouse/return-cartons','GET',96,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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) VALUES (5306150,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:last-mile-order:list','海外尾程订单','/overseas-warehouse/last-mile-orders','GET',100,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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) VALUES (5306160,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:relabel:list','贴标申请','/overseas-warehouse/labeling','GET',110,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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) VALUES (5306161,0,'MERCHANT','BUTTON',5306160,'merchant:overseas-warehouse:relabel:create','创建贴标申请',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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) VALUES (5306162,0,'MERCHANT','BUTTON',5306160,'merchant:overseas-warehouse:relabel:files','管理贴标文件',NULL,'POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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) VALUES (5306163,0,'MERCHANT','BUTTON',5306160,'merchant:overseas-warehouse:relabel:confirm','确认贴标完成',NULL,'POST',30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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) VALUES (5306170,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:transfer:list','仓储转运','/overseas-warehouse/storage-transfer','GET',120,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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) VALUES (5306171,0,'MERCHANT','BUTTON',5306170,'merchant:overseas-warehouse:transfer:create','创建仓储转运单',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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) VALUES (5306172,0,'MERCHANT','BUTTON',5306170,'merchant:overseas-warehouse:transfer:confirm','确认仓储转运实测',NULL,'POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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) VALUES (5306180,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:discard:list','货物废弃','/overseas-warehouse/cargo-disposal','GET',130,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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) VALUES (5306181,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:create','创建货物废弃申请',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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) VALUES (5306182,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:cancel','取消货物废弃申请',NULL,'POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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) VALUES (5306190,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:return-order:list','退货预报','/overseas-warehouse/return-relabel','GET',140,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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) VALUES (5306191,0,'MERCHANT','BUTTON',5306190,'merchant:overseas-warehouse:return-order:create','创建退货预报',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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) VALUES (5306192,0,'MERCHANT','BUTTON',5306190,'merchant:overseas-warehouse:return-order:relabel','退货转换标申请',NULL,'POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); -- 系统基线 iam_role:8 行。 INSERT INTO iam_role (id, tenant_id, app_code, role_code, role_name, role_type, status, description, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (4000001,0,'PLATFORM','PLATFORM_ADMIN','平台超级管理员','SYSTEM','ACTIVE','一期平台全部菜单和按钮',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); @@ -6324,7 +6342,7 @@ INSERT INTO iam_role (id, tenant_id, app_code, role_code, role_name, role_type, INSERT INTO iam_role (id, tenant_id, app_code, role_code, role_name, role_type, status, description, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (4000007,0,'PLATFORM','CN_FIRSTLEG_PDA_OPERATOR','国内头程 PDA 作业员','SYSTEM','ACTIVE','负责使用国内 PDA 执行柜次拣货、现场取证和装柜完成',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); INSERT INTO iam_role (id, tenant_id, app_code, role_code, role_name, role_type, status, description, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (4000100,0,'MERCHANT','MERCHANT_OWNER','企业管理员','SYSTEM','ACTIVE','一期企业全部菜单和操作按钮模板',1000001,CURRENT_TIMESTAMP(3),1000001,CURRENT_TIMESTAMP(3),0,0); --- 系统基线 iam_role_permission:521 行。 +-- 系统基线 iam_role_permission:539 行。 INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100000,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100010,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100011,0,CURRENT_TIMESTAMP(3)); @@ -6841,11 +6859,29 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306000,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306001,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306100,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306101,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306110,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306111,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306112,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306120,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306121,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306122,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306130,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306140,0,CURRENT_TIMESTAMP(3)); -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306141,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306150,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306160,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306161,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306162,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306163,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306170,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306171,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306172,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306180,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306181,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306182,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306190,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306191,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306192,0,CURRENT_TIMESTAMP(3)); -- 系统基线 iam_tenant:1 行。 INSERT INTO iam_tenant (id, tenant_code, tenant_name, customer_nickname, tenant_type, status, dimension_initialization_status, legal_name, legal_representative, unified_social_credit_code, contact_name, contact_phone, contact_email, service_package, contact_wechat, sales_owner_name, payment_term_days, enterprise_type, established_date, registered_capital, registered_capital_currency, registered_address, internal_remark, approved_at, approved_by, reject_reason, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (0,'PLATFORM','虫虫出海平台','平台','PLATFORM','ACTIVE','READY','虫虫出海平台',NULL,NULL,'平台系统管理员',NULL,NULL,'基础版',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),1000001,NULL,1000001,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); diff --git a/gateway-overseas/AGENTS.md b/gateway-overseas/AGENTS.md index 94e97063a..14d35f248 100644 --- a/gateway-overseas/AGENTS.md +++ b/gateway-overseas/AGENTS.md @@ -18,7 +18,7 @@ | 安全配置项 | `config/GatewaySecurityProperties.java` | | 可信身份与授权头传递 | `filter/TrustedIdentityHeaderFilter.java`、`filter/OverseasSecurityProjectionFilter.java`;先清除外部伪造头,再以内部服务令牌保护投影快照 | | 环境配置 | `src/main/resources/application.yml`、`../config/nacos/` | -| 浏览器业务路由 | `src/main/resources/application-overseas.yml`;Lastleg 显式覆盖 `/api/merchant/v1/last-leg/**`、`/api/platform/v1/last-leg/**`,Warehouse 显式覆盖商户仓库与平台 `warehouse-admin` 路径 | +| 浏览器业务路由 | `src/main/resources/application-overseas.yml`;Lastleg 显式覆盖 `/api/merchant/v1/last-leg/**`、`/api/platform/v1/last-leg/**`,Warehouse 仅显式覆盖平台仓库资源路径 | | 海外 PDA 路由 | 设备端 `../../apps/pda-overseas-uniapp/`,服务端入口 `/api/pda/v1/last-leg/**`;必须在 Gateway 路由中显式声明并保持海外 Token/权限校验,不得让设备直连 Lastleg | | 测试 | `src/test/java/com/baiyue/gateway/config/` | diff --git a/gateway-overseas/src/main/resources/application-overseas.yml b/gateway-overseas/src/main/resources/application-overseas.yml index bdcda68d8..0cd871367 100644 --- a/gateway-overseas/src/main/resources/application-overseas.yml +++ b/gateway-overseas/src/main/resources/application-overseas.yml @@ -13,7 +13,7 @@ spring: predicates: ["Path=/api/merchant/v1/last-leg/**,/api/platform/v1/last-leg/**,/api/pda/v1/last-leg/**"] - id: warehouse-overseas-service uri: lb://warehouse-overseas-service - predicates: ["Path=/api/merchant/v1/warehouses/**,/api/platform/v1/warehouses/**,/api/platform/v1/warehouse-admin/**"] + predicates: ["Path=/api/platform/v1/warehouses/**,/api/platform/v1/warehouse-admin/**"] - id: file-service uri: lb://file-service predicates: ["Path=/api/merchant/v1/files/**,/api/platform/v1/files/**"] diff --git a/iam/src/main/resources/db/lastleg-permissions.sql b/iam/src/main/resources/db/lastleg-permissions.sql index 90f805f4f..69ba4426e 100644 --- a/iam/src/main/resources/db/lastleg-permissions.sql +++ b/iam/src/main/resources/db/lastleg-permissions.sql @@ -78,11 +78,30 @@ VALUES (5107222,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:edit','编辑车辆主数据',NULL,'PUT',922,'ACTIVE',1,0), (5107223,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:status','变更车辆状态',NULL,'PATCH',923,'ACTIVE',1,0), (5107224,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:export','导出车辆主数据',NULL,'GET',924,'ACTIVE',1,0), - (5306100,0,'MERCHANT','MENU',0,'merchant:cross-border:procurement','海外采购','/cross-border/procurement',NULL,70,'ACTIVE',1,0), - (5306110,0,'MERCHANT','MENU',0,'merchant:cross-border:domestic-trucking','美国本土卡','/cross-border/domestic-trucking',NULL,80,'ACTIVE',1,0), - (5306120,0,'MERCHANT','MENU',0,'merchant:cross-border:pallet-shipping','美本土散板','/cross-border/pallet-shipping',NULL,90,'ACTIVE',1,0), + (5306100,0,'MERCHANT','MENU',0,'merchant:cross-border:procurement:list','海外采购','/cross-border/procurement','GET',70,'ACTIVE',1,0), + (5306101,0,'MERCHANT','BUTTON',5306100,'merchant:cross-border:procurement:create','创建海外采购单',NULL,'POST',10,'ACTIVE',1,0), + (5306110,0,'MERCHANT','MENU',0,'merchant:cross-border:domestic-trucking:list','美国本土卡','/cross-border/domestic-trucking','GET',80,'ACTIVE',1,0), + (5306111,0,'MERCHANT','BUTTON',5306110,'merchant:cross-border:domestic-trucking:create','创建美国本土卡订单',NULL,'POST',10,'ACTIVE',1,0), + (5306112,0,'MERCHANT','BUTTON',5306110,'merchant:cross-border:domestic-trucking:edit','编辑美国本土卡订单',NULL,'PATCH',20,'ACTIVE',1,0), + (5306120,0,'MERCHANT','MENU',0,'merchant:cross-border:pallet-shipping:list','美本土散板','/cross-border/pallet-shipping','GET',90,'ACTIVE',1,0), + (5306121,0,'MERCHANT','BUTTON',5306120,'merchant:cross-border:pallet-shipping:create','创建美本土散板订单',NULL,'POST',10,'ACTIVE',1,0), + (5306122,0,'MERCHANT','BUTTON',5306120,'merchant:cross-border:pallet-shipping:edit','编辑美本土散板订单',NULL,'PUT',20,'ACTIVE',1,0), (5306130,0,'MERCHANT','MENU',0,'merchant:warehouse:inbound:list','海外仓入库订单','/orders/inbound',NULL,50,'ACTIVE',1,0), - (5306140,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:return-inventory:list','退货库存','/overseas-warehouse/return-inventory','GET',95,'ACTIVE',1,0) + (5306140,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:return-inventory:list','退货库存','/overseas-warehouse/return-inventory','GET',95,'ACTIVE',1,0), + (5306150,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:last-mile-order:list','海外尾程订单','/overseas-warehouse/last-mile-orders','GET',100,'ACTIVE',1,0), + (5306160,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:relabel:list','贴标申请','/overseas-warehouse/labeling','GET',110,'ACTIVE',1,0), + (5306161,0,'MERCHANT','BUTTON',5306160,'merchant:overseas-warehouse:relabel:create','创建贴标申请',NULL,'POST',10,'ACTIVE',1,0), + (5306162,0,'MERCHANT','BUTTON',5306160,'merchant:overseas-warehouse:relabel:files','管理贴标文件',NULL,'POST',20,'ACTIVE',1,0), + (5306163,0,'MERCHANT','BUTTON',5306160,'merchant:overseas-warehouse:relabel:confirm','确认贴标完成',NULL,'POST',30,'ACTIVE',1,0), + (5306170,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:transfer:list','仓储转运','/overseas-warehouse/storage-transfer','GET',120,'ACTIVE',1,0), + (5306171,0,'MERCHANT','BUTTON',5306170,'merchant:overseas-warehouse:transfer:create','创建仓储转运单',NULL,'POST',10,'ACTIVE',1,0), + (5306172,0,'MERCHANT','BUTTON',5306170,'merchant:overseas-warehouse:transfer:confirm','确认仓储转运实测',NULL,'POST',20,'ACTIVE',1,0), + (5306180,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:discard:list','货物废弃','/overseas-warehouse/cargo-disposal','GET',130,'ACTIVE',1,0), + (5306181,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:create','创建货物废弃申请',NULL,'POST',10,'ACTIVE',1,0), + (5306182,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:cancel','取消货物废弃申请',NULL,'POST',20,'ACTIVE',1,0), + (5306190,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:return-order:list','退货预报','/overseas-warehouse/return-relabel','GET',140,'ACTIVE',1,0), + (5306191,0,'MERCHANT','BUTTON',5306190,'merchant:overseas-warehouse:return-order:create','创建退货预报',NULL,'POST',10,'ACTIVE',1,0), + (5306192,0,'MERCHANT','BUTTON',5306190,'merchant:overseas-warehouse:return-order:relabel','退货转换标申请',NULL,'POST',20,'ACTIVE',1,0) ON DUPLICATE KEY UPDATE permission_type=VALUES(permission_type), parent_id=VALUES(parent_id), permission_name=VALUES(permission_name), route_path=VALUES(route_path), http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE', built_in=1, deleted=0; @@ -225,11 +244,30 @@ SELECT 0, role.id, permission.id, NOW(3) FROM iam_role role JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='MERCHANT' AND permission.permission_code IN ( - 'merchant:cross-border:procurement', - 'merchant:cross-border:domestic-trucking', - 'merchant:cross-border:pallet-shipping', + 'merchant:cross-border:procurement:list', + 'merchant:cross-border:procurement:create', + 'merchant:cross-border:domestic-trucking:list', + 'merchant:cross-border:domestic-trucking:create', + 'merchant:cross-border:domestic-trucking:edit', + 'merchant:cross-border:pallet-shipping:list', + 'merchant:cross-border:pallet-shipping:create', + 'merchant:cross-border:pallet-shipping:edit', 'merchant:warehouse:inbound:list', - 'merchant:overseas-warehouse:return-inventory:list' + 'merchant:overseas-warehouse:return-inventory:list', + 'merchant:overseas-warehouse:last-mile-order:list', + 'merchant:overseas-warehouse:relabel:list', + 'merchant:overseas-warehouse:relabel:create', + 'merchant:overseas-warehouse:relabel:files', + 'merchant:overseas-warehouse:relabel:confirm', + 'merchant:overseas-warehouse:transfer:list', + 'merchant:overseas-warehouse:transfer:create', + 'merchant:overseas-warehouse:transfer:confirm', + 'merchant:overseas-warehouse:discard:list', + 'merchant:overseas-warehouse:discard:create', + 'merchant:overseas-warehouse:discard:cancel', + 'merchant:overseas-warehouse:return-order:list', + 'merchant:overseas-warehouse:return-order:create', + 'merchant:overseas-warehouse:return-order:relabel' ) WHERE role.tenant_id=0 AND role.app_code='MERCHANT' AND role.role_code='MERCHANT_OWNER' AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; @@ -241,11 +279,30 @@ BEGIN IF (SELECT COUNT(*) FROM iam_permission WHERE tenant_id=0 AND app_code='MERCHANT' AND status='ACTIVE' AND deleted=0 AND permission_code IN ( - 'merchant:cross-border:procurement', - 'merchant:cross-border:domestic-trucking', - 'merchant:cross-border:pallet-shipping', + 'merchant:cross-border:procurement:list', + 'merchant:cross-border:procurement:create', + 'merchant:cross-border:domestic-trucking:list', + 'merchant:cross-border:domestic-trucking:create', + 'merchant:cross-border:domestic-trucking:edit', + 'merchant:cross-border:pallet-shipping:list', + 'merchant:cross-border:pallet-shipping:create', + 'merchant:cross-border:pallet-shipping:edit', 'merchant:warehouse:inbound:list', - 'merchant:overseas-warehouse:return-inventory:list')) <> 5 THEN + 'merchant:overseas-warehouse:return-inventory:list', + 'merchant:overseas-warehouse:last-mile-order:list', + 'merchant:overseas-warehouse:relabel:list', + 'merchant:overseas-warehouse:relabel:create', + 'merchant:overseas-warehouse:relabel:files', + 'merchant:overseas-warehouse:relabel:confirm', + 'merchant:overseas-warehouse:transfer:list', + 'merchant:overseas-warehouse:transfer:create', + 'merchant:overseas-warehouse:transfer:confirm', + 'merchant:overseas-warehouse:discard:list', + 'merchant:overseas-warehouse:discard:create', + 'merchant:overseas-warehouse:discard:cancel', + 'merchant:overseas-warehouse:return-order:list', + 'merchant:overseas-warehouse:return-order:create', + 'merchant:overseas-warehouse:return-order:relabel')) <> 24 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外客户权限初始化失败:权限资源不完整'; END IF; IF (SELECT COUNT(*) FROM iam_role_permission grant_row @@ -256,11 +313,30 @@ BEGIN AND permission.tenant_id=0 AND permission.app_code='MERCHANT' AND permission.status='ACTIVE' AND permission.deleted=0 AND permission.permission_code IN ( - 'merchant:cross-border:procurement', - 'merchant:cross-border:domestic-trucking', - 'merchant:cross-border:pallet-shipping', + 'merchant:cross-border:procurement:list', + 'merchant:cross-border:procurement:create', + 'merchant:cross-border:domestic-trucking:list', + 'merchant:cross-border:domestic-trucking:create', + 'merchant:cross-border:domestic-trucking:edit', + 'merchant:cross-border:pallet-shipping:list', + 'merchant:cross-border:pallet-shipping:create', + 'merchant:cross-border:pallet-shipping:edit', 'merchant:warehouse:inbound:list', - 'merchant:overseas-warehouse:return-inventory:list')) <> 5 THEN + 'merchant:overseas-warehouse:return-inventory:list', + 'merchant:overseas-warehouse:last-mile-order:list', + 'merchant:overseas-warehouse:relabel:list', + 'merchant:overseas-warehouse:relabel:create', + 'merchant:overseas-warehouse:relabel:files', + 'merchant:overseas-warehouse:relabel:confirm', + 'merchant:overseas-warehouse:transfer:list', + 'merchant:overseas-warehouse:transfer:create', + 'merchant:overseas-warehouse:transfer:confirm', + 'merchant:overseas-warehouse:discard:list', + 'merchant:overseas-warehouse:discard:create', + 'merchant:overseas-warehouse:discard:cancel', + 'merchant:overseas-warehouse:return-order:list', + 'merchant:overseas-warehouse:return-order:create', + 'merchant:overseas-warehouse:return-order:relabel')) <> 24 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外客户权限初始化失败:客户默认管理员授权不完整'; END IF; END$$ diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index 33640c020..69392d990 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -342,7 +342,7 @@ paths: get: tags: [IAM Resources / IAM 资源] summary: 查询当前用户有效权限 - description: 返回当前有效客户成员权限编码。资金账户和流水只读返回 `merchant:finance:account:view`,提交充值申请返回 `merchant:finance:recharge:create`,两者独立授权。海外采购、本土卡、本土散板和海外入库订单分别返回 `merchant:cross-border:procurement`、`merchant:cross-border:domestic-trucking`、`merchant:cross-border:pallet-shipping`、`merchant:warehouse:inbound:list`;客户派送返回 `merchant:warehouse:delivery:list` 和 `merchant:warehouse:delivery:create`。列表权限包含详情只读,创建权限独立授权。 + description: 返回当前有效客户成员权限编码。资金账户和流水只读返回 `merchant:finance:account:view`,提交充值申请返回 `merchant:finance:recharge:create`,两者独立授权。海外采购、本土卡、本土散板分别按 `:list`、`:create`(可编辑业务另有 `:edit`)拆分;海外仓入库、尾程订单、换标、转运、废弃、退货预报和退货库存均返回各自 `merchant:warehouse:*` 或 `merchant:overseas-warehouse:*` 权限。客户派送返回 `merchant:warehouse:delivery:list` 和 `merchant:warehouse:delivery:create`。列表权限包含详情只读,创建、文件、确认、取消和转换操作独立授权。 responses: '200': description: 当前商户成员权限查询成功 diff --git a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java index 99e75072f..d23670498 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java @@ -46,9 +46,9 @@ class IamOpenApiContractTest { .contains("不以 `container:list/edit` 兼容放行国外操作") .contains("merchant:warehouse:delivery:list") .contains("merchant:warehouse:delivery:create") - .contains("merchant:cross-border:procurement") - .contains("merchant:cross-border:domestic-trucking") - .contains("merchant:cross-border:pallet-shipping") + .contains("merchant:cross-border:procurement:list") + .contains("merchant:cross-border:domestic-trucking:list") + .contains("merchant:cross-border:pallet-shipping:list") .contains("merchant:warehouse:inbound:list") .contains("lastleg:pda:operate") .contains("OVERSEAS_WAREHOUSE_PDA_OPERATOR") diff --git a/iam/src/test/java/com/baiyue/iam/repository/MerchantReturnInventoryPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/MerchantReturnInventoryPermissionSqlContractTest.java index 4cff5c013..51c01601b 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/MerchantReturnInventoryPermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/MerchantReturnInventoryPermissionSqlContractTest.java @@ -20,7 +20,7 @@ class MerchantReturnInventoryPermissionSqlContractTest { assertThat(sql).contains( "(5306140,0,'MERCHANT','MENU',0,'" + PERMISSION + "','退货库存'", "role.role_code='MERCHANT_OWNER'", - "'" + PERMISSION + "')) <> 5", + "'" + PERMISSION + "')) <> 24", "客户默认管理员授权不完整"); int grantStart = sql.indexOf("-- 客户默认管理员获得当前客户海外业务入口"); diff --git a/iam/src/test/java/com/baiyue/iam/repository/OverseasCustomerPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/OverseasCustomerPermissionSqlContractTest.java index f65c809ef..fa9a64fc9 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/OverseasCustomerPermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/OverseasCustomerPermissionSqlContractTest.java @@ -15,30 +15,48 @@ import static org.assertj.core.api.Assertions.assertThat; class OverseasCustomerPermissionSqlContractTest { private static final Pattern MERCHANT_CODE = Pattern.compile("merchant:[a-z0-9:-]+"); private static final Set CUSTOMER_OVERSEAS_PERMISSIONS = Set.of( - "merchant:cross-border:procurement", - "merchant:cross-border:domestic-trucking", - "merchant:cross-border:pallet-shipping", + "merchant:cross-border:procurement:list", + "merchant:cross-border:procurement:create", + "merchant:cross-border:domestic-trucking:list", + "merchant:cross-border:domestic-trucking:create", + "merchant:cross-border:domestic-trucking:edit", + "merchant:cross-border:pallet-shipping:list", + "merchant:cross-border:pallet-shipping:create", + "merchant:cross-border:pallet-shipping:edit", "merchant:warehouse:inbound:list", - "merchant:overseas-warehouse:return-inventory:list"); + "merchant:overseas-warehouse:return-inventory:list", + "merchant:overseas-warehouse:last-mile-order:list", + "merchant:overseas-warehouse:relabel:list", + "merchant:overseas-warehouse:relabel:create", + "merchant:overseas-warehouse:relabel:files", + "merchant:overseas-warehouse:relabel:confirm", + "merchant:overseas-warehouse:transfer:list", + "merchant:overseas-warehouse:transfer:create", + "merchant:overseas-warehouse:transfer:confirm", + "merchant:overseas-warehouse:discard:list", + "merchant:overseas-warehouse:discard:create", + "merchant:overseas-warehouse:discard:cancel", + "merchant:overseas-warehouse:return-order:list", + "merchant:overseas-warehouse:return-order:create", + "merchant:overseas-warehouse:return-order:relabel"); @Test void declaresTheFrozenCustomerOverseasPermissionCatalog() throws Exception { String sql = sql(); assertThat(merchantCodes(sql)).containsExactlyInAnyOrderElementsOf(CUSTOMER_OVERSEAS_PERMISSIONS); - for (String permission : CUSTOMER_OVERSEAS_PERMISSIONS) { - assertThat(sql).contains("0,'MERCHANT','MENU'", "'" + permission + "'"); - } assertThat(sql).contains( - "'merchant:cross-border:procurement','海外采购','/cross-border/procurement'", - "'merchant:cross-border:domestic-trucking','美国本土卡','/cross-border/domestic-trucking'", - "'merchant:cross-border:pallet-shipping','美本土散板','/cross-border/pallet-shipping'", + "'merchant:cross-border:procurement:list','海外采购','/cross-border/procurement'", + "'merchant:cross-border:domestic-trucking:list','美国本土卡','/cross-border/domestic-trucking'", + "'merchant:cross-border:pallet-shipping:list','美本土散板','/cross-border/pallet-shipping'", "'merchant:warehouse:inbound:list','海外仓入库订单','/orders/inbound'", - "'merchant:overseas-warehouse:return-inventory:list','退货库存','/overseas-warehouse/return-inventory'"); + "'merchant:overseas-warehouse:return-inventory:list','退货库存','/overseas-warehouse/return-inventory'", + "'merchant:overseas-warehouse:relabel:files','维护换标文件'", + "'merchant:overseas-warehouse:discard:cancel','取消货物废弃申请'"); } @Test - void grantsAllFivePermissionsOnlyToTheDefaultCustomerAdministrator() throws Exception { + void grantsAllPermissionsOnlyToTheDefaultCustomerAdministrator() throws Exception { String sql = sql(); int comment = sql.indexOf("-- 客户默认管理员获得当前客户海外业务入口"); int grantStart = sql.indexOf("INSERT IGNORE INTO iam_role_permission", comment); @@ -66,7 +84,7 @@ class OverseasCustomerPermissionSqlContractTest { "海外客户权限初始化失败:客户默认管理员授权不完整", "permission.app_code='MERCHANT'", "role.role_code='MERCHANT_OWNER'", - ")) <> 5 THEN"); + ")) <> 24 THEN"); } @Test diff --git a/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java index d9ffdae80..1c492f499 100644 --- a/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java @@ -95,18 +95,20 @@ class AuthorizationSnapshotServiceTest { when(mapper.countActiveIdentity(42L, 7L, 9L)).thenReturn(1); when(mapper.findRoles(42L, 7L, 9L, "MERCHANT")).thenReturn(List.of("MERCHANT_OWNER")); when(mapper.findPermissions(42L, 7L, 9L, "MERCHANT")).thenReturn(List.of( - "merchant:cross-border:procurement", - "merchant:cross-border:domestic-trucking", - "merchant:cross-border:pallet-shipping", + "merchant:cross-border:procurement:list", + "merchant:cross-border:procurement:create", + "merchant:cross-border:domestic-trucking:list", + "merchant:cross-border:pallet-shipping:list", "merchant:warehouse:inbound:list")); var result = service.currentPermissions(new AuthenticatedUser(42L, 7L, 7L, 9L, "owner", "MERCHANT", "MERCHANT", java.util.Set.of()), "MERCHANT"); assertThat(result).containsExactlyInAnyOrder( - "merchant:cross-border:procurement", - "merchant:cross-border:domestic-trucking", - "merchant:cross-border:pallet-shipping", + "merchant:cross-border:procurement:list", + "merchant:cross-border:procurement:create", + "merchant:cross-border:domestic-trucking:list", + "merchant:cross-border:pallet-shipping:list", "merchant:warehouse:inbound:list"); } diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 4a7c20eff..d3bd18a4a 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -61,11 +61,11 @@ | 客户拜访、派送状态与签收附件 | `customervisit/controller/CustomerVisitController.java`、`customervisit/service/CustomerVisitService.java`、`delivery/model/DeliveryStatusPolicy.java`;派送单返回服务端 `allowedActions/version`,状态只允许单调迁移,同状态幂等;人工动作不再提供完成派送,签收资料保存后自动完成当前目的FBA仓派送单 | | 派送三类资料、摘要与历史证据 | `delivery/controller/DeliveryDocumentController.java`、`delivery/service/DeliveryDocumentService.java`、`delivery/gateway/DeliveryDocumentFileGateway.java`、`src/main/resources/openapi/delivery-documents-v1.yaml`;固定装车照片 `LOADING_PHOTO`、BOL文件(含签名)`BOL`、签收文件(POD签收/快递签收)`RECEIPT`,按已保存类别计数 `x/3`。每类0至20文件,文件精确绑定客户、按目的FBA仓拆分的派送单和类别;全量保存共用派送版本并记录不可变历史,替换删除仅解绑,未保存上传不计数、不授权下载。`RECEIPT` 成功保存进权威快照时事务内自动完成当前派送单,单纯上传不触发完成 | | 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java`、`exceptionorder/service/ExceptionOrderService.java`、`exceptionorder/gateway/ExceptionEvidenceFileGateway.java`、`inventory/command/service/InventoryCommandService.java`;证据固定按客户、异常单 ID 和 `OVERSEAS_EXCEPTION_EVIDENCE` 校验。退回候选按原出库拣货事实冻结,不接受自由 SKU 或数量;异常按 `PENDING→PROCESSING→COMPLETED`(待处理可取消)、版本与幂等键流转。退回海外仓须把实收按原出库来源恢复到服务端校验的真实托盘/库位并写 `RETURN_STOCK_IN` 流水,库存恢复、异常完成与派送 `RETURNED` 在同一事务完成 | -| 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;客户入口要求 `merchant:cross-border:procurement` 且只取 Token 客户范围;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` | +| 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;客户入口按 `merchant:cross-border:procurement:list/create` 拆分且只取 Token 客户范围;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` | | 海外业务资金指令与结果 | `wallet/WalletCommandOutboxService.java`、`WalletCommandCompletedConsumer.java`、`WalletResultHandler.java`;采购和换标通过 `WALLET_COMMAND_REQUESTED` 请求国内 Finance 冻结、结算或释放,并以 `WALLET_COMMAND_COMPLETED` 幂等回写业务资金状态;不得跨区同步调用 Finance 钱包或在 Lastleg 自建余额账本 | | 海外业务费用来源 | `financefee/controller/PlatformOverseasFeeSourceController.java`、`financefee/service/OverseasFeeSourceService.java`、`OverseasFeeBusinessGuard.java`、`OverseasFeeOutboxService.java`、`OverseasFeeOutboxRelay.java`;海外入仓、采购、美国卡车、本土散板和海外仓服务费必须匹配当前客户的真实业务,入仓记录和入仓码按事实选填;审核通过、调整或作废后以版本化完整快照经 Outbox/Event Bridge 进入独立 Finance,OpenAPI `src/main/resources/openapi/overseas-finance-fees-v1.yaml` | -| 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java`、`MerchantUsTruckOrderController.java` 及同子域 Service/DTO;客户入口要求 `merchant:cross-border:domestic-trucking` | -| 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java`、`MerchantLocalLooseboardController.java` 及同子域 Service/DTO/Entity/Mapper;客户入口要求 `merchant:cross-border:pallet-shipping`;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` | +| 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java`、`MerchantUsTruckOrderController.java` 及同子域 Service/DTO;客户入口按 `merchant:cross-border:domestic-trucking:list/create/edit` 拆分 | +| 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java`、`MerchantLocalLooseboardController.java` 及同子域 Service/DTO/Entity/Mapper;客户入口按 `merchant:cross-border:pallet-shipping:list/create/edit` 拆分;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` | | 海外仓报价、月度汇率、车辆主数据 | 报价 `masterdata/quote/`、汇率 `masterdata/exchange/`;车辆入口 `masterdata/vehicle/adapter/in/web/PlatformVehicleController.java`,应用服务 `application/VehicleApplicationService.java`,持久化端口与适配器 `application/port/out/VehicleRepository.java`、`adapter/out/persistence/`;OpenAPI `src/main/resources/openapi/overseas-master-data-v1.yaml`,车辆档案不含派送履约历史 | | 跨服务 HTTP 客户端装配 | `config/LastlegRestClientFactory.java`、`config/LastlegRestClientProperties.java`;目标地址与内部服务令牌只来自配置,不在业务子域手工创建客户端 | | 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql`、`src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java` | diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/MerchantLastlegOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/MerchantLastlegOrderController.java index 69a04f882..e753e810d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/MerchantLastlegOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/MerchantLastlegOrderController.java @@ -7,9 +7,11 @@ import com.baiyue.lastleg.dto.LastlegOrderDetailResponse; import com.baiyue.lastleg.dto.LastlegOrderRequests; import com.baiyue.lastleg.dto.LastlegOrderSummary; import com.baiyue.lastleg.service.LastlegOrderService; +import com.baiyue.lastleg.security.OverseasPermissions; import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -19,6 +21,7 @@ import org.springframework.web.server.ResponseStatusException; @RestController @RequestMapping("/api/merchant/v1/last-leg/orders") +@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_LAST_MILE_ORDER_LIST + "')") public class MerchantLastlegOrderController { @Resource private LastlegOrderService orderService; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardController.java index c9f493b78..f49e899da 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardController.java @@ -6,9 +6,11 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.discard.dto.CargoDiscardRequests; import com.baiyue.lastleg.discard.dto.CargoDiscardResponses; import com.baiyue.lastleg.discard.service.CargoDiscardService; +import com.baiyue.lastleg.security.OverseasPermissions; import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; @@ -19,18 +21,23 @@ import java.util.List; public class MerchantCargoDiscardController { @Resource private CargoDiscardService service; @GetMapping("/inventory-candidates") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_LIST + "')") public ApiResponse> candidates(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String inboundCode) { return ApiResponse.success(service.candidates(customer(user), inboundCode)); } @PostMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_CREATE + "')") public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody CargoDiscardRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); } @GetMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_LIST + "')") public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, @Valid CargoDiscardRequests.Query query) { return ApiResponse.success(service.page(customer(user), query)); } @GetMapping("/{discardNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_LIST + "')") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String discardNo) { return ApiResponse.success(service.detail(customer(user), discardNo)); } @PostMapping("/{discardNo}/cancel") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_CANCEL + "')") public ApiResponse cancel(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String discardNo) { return ApiResponse.success(service.cancel(customer(user), discardNo, operator(user), false)); } private String customer(AuthenticatedUser user) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/controller/MerchantLocalLooseboardController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/controller/MerchantLocalLooseboardController.java index 97c997a88..0793d2aeb 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/controller/MerchantLocalLooseboardController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/controller/MerchantLocalLooseboardController.java @@ -23,29 +23,32 @@ import org.springframework.web.server.ResponseStatusException; @RestController @RequestMapping("/api/merchant/v1/last-leg/local-looseboard-orders") -@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_LOOSEBOARD + "')") public class MerchantLocalLooseboardController { @Resource private LocalLooseboardService service; @PostMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_LOOSEBOARD_CREATE + "')") public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody LocalLooseboardRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); } @GetMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_LOOSEBOARD_LIST + "')") public ApiResponse> page( @AuthenticationPrincipal AuthenticatedUser user, @Valid LocalLooseboardRequests.MerchantQuery query) { return ApiResponse.success(service.page(customer(user), query)); } @GetMapping("/{orderNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_LOOSEBOARD_LIST + "')") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String orderNo) { return ApiResponse.success(service.detail(customer(user), orderNo)); } @PutMapping("/{orderNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_LOOSEBOARD_EDIT + "')") public ApiResponse update(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String orderNo, @Valid @RequestBody LocalLooseboardRequests.Update request) { return ApiResponse.success(service.update(customer(user), orderNo, operator(user), request)); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantOverseasProcurementController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantOverseasProcurementController.java index e4a2c254a..e674063fd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantOverseasProcurementController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantOverseasProcurementController.java @@ -22,11 +22,11 @@ import org.springframework.web.server.ResponseStatusException; @RestController @RequestMapping("/api/merchant/v1/last-leg/overseas-procurements") -@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_PROCUREMENT + "')") public class MerchantOverseasProcurementController { @Resource private OverseasProcurementService service; @PostMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_PROCUREMENT_CREATE + "')") public ApiResponse create( @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody OverseasProcurementRequests.Create request) { @@ -34,12 +34,14 @@ public class MerchantOverseasProcurementController { } @GetMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_PROCUREMENT_LIST + "')") public ApiResponse> page( @AuthenticationPrincipal AuthenticatedUser user, @Valid OverseasProcurementRequests.MerchantQuery query) { return ApiResponse.success(service.page(customer(user), query.scoped())); } @GetMapping("/{orderNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_PROCUREMENT_LIST + "')") public ApiResponse detail( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String orderNo) { return ApiResponse.success(service.detail(customer(user), orderNo)); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java index 8c15edf5f..56263ab90 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java @@ -6,9 +6,11 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.relabel.dto.RelabelRequests; import com.baiyue.lastleg.relabel.dto.RelabelResponses; import com.baiyue.lastleg.relabel.service.RelabelApplicationService; +import com.baiyue.lastleg.security.OverseasPermissions; import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; @@ -24,6 +26,7 @@ import java.util.List; public class MerchantRelabelApplicationController { @Resource private RelabelApplicationService service; @GetMapping("/inventory-candidates") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')") public ApiResponse> candidates( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String operationWarehouseId, @@ -31,6 +34,7 @@ public class MerchantRelabelApplicationController { return ApiResponse.success(service.candidates(customer(user), operationWarehouseId, inboundCode)); } @GetMapping("/historical-orders") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')") public ApiResponse> historicalOrders( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String operationWarehouseId, @@ -38,20 +42,25 @@ public class MerchantRelabelApplicationController { return ApiResponse.success(service.historicalOrders(customer(user), operationWarehouseId, keyword)); } @PostMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_CREATE + "')") public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody RelabelRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); } @GetMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')") public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, @Valid RelabelRequests.Query query) { return ApiResponse.success(service.page(customer(user), query)); } @GetMapping("/{relabelNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String relabelNo) { return ApiResponse.success(service.detail(customer(user), relabelNo)); } @PostMapping(value = "/{relabelNo}/shipping-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_FILES + "')") public ApiResponse uploadShippingLabel(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String relabelNo, @RequestPart("file") MultipartFile file) { return ApiResponse.success(service.uploadShippingLabel(customer(user), relabelNo, operatorLong(user), file)); } @PostMapping(value = "/{relabelNo}/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_FILES + "')") public ApiResponse uploadFile(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String relabelNo, @RequestParam String scope, @RequestParam(required = false) Long itemId, @RequestPart("file") MultipartFile file) { @@ -60,18 +69,21 @@ public class MerchantRelabelApplicationController { return ApiResponse.success(service.uploadTotalFile(customer(user), relabelNo, operatorLong(user), file, kind)); } @GetMapping("/{relabelNo}/shipping-label/{fileId}/download") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')") public ResponseEntity downloadShippingLabel(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String relabelNo, @PathVariable long fileId) { var file = service.downloadShippingLabel(customer(user), relabelNo, fileId); return download(file.content(), file.fileName(), file.contentType()); } @GetMapping("/{relabelNo}/completion-photos/{fileId}/download") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')") public ResponseEntity downloadCompletionPhoto(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String relabelNo, @PathVariable long fileId) { var file = service.downloadCompletionPhoto(customer(user), relabelNo, fileId); return download(file.content(), file.fileName(), file.contentType()); } @PostMapping("/{relabelNo}/confirm-completion") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_CONFIRM + "')") public ApiResponse confirmCompletion(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String relabelNo, @Valid @RequestBody RelabelRequests.CustomerConfirm request) { return ApiResponse.success(service.confirmCompletion(customer(user), relabelNo, operator(user), request)); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/MerchantReturnOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/MerchantReturnOrderController.java index b014751d4..d5628de57 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/MerchantReturnOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/MerchantReturnOrderController.java @@ -6,9 +6,11 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.returnreceipt.dto.ReturnOrderRequests; import com.baiyue.lastleg.returnreceipt.dto.ReturnOrderResponses; import com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService; +import com.baiyue.lastleg.security.OverseasPermissions; import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; @@ -17,17 +19,22 @@ import org.springframework.web.server.ResponseStatusException; @RequestMapping("/api/merchant/v1/last-leg/return-orders") public class MerchantReturnOrderController { @Resource private ReturnReceiptService service; - @PostMapping public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @PostMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RETURN_ORDER_CREATE + "')") + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReturnOrderRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); } - @GetMapping public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, + @GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RETURN_ORDER_LIST + "')") + public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, @Valid ReturnOrderRequests.Query query) { return ApiResponse.success(service.page(customer(user), query)); } - @GetMapping("/{returnNo}") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @GetMapping("/{returnNo}") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RETURN_ORDER_LIST + "')") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String returnNo) { return ApiResponse.success(service.detail(customer(user), returnNo)); } - @GetMapping("/{returnNo}/relabel-candidates") public ApiResponse> relabelCandidates( + @GetMapping("/{returnNo}/relabel-candidates") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RETURN_ORDER_RELABEL + "')") + public ApiResponse> relabelCandidates( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String returnNo) { return ApiResponse.success(service.relabelCandidates(customer(user), returnNo)); } - @PostMapping("/{returnNo}/relabel") public ApiResponse createRelabel( + @PostMapping("/{returnNo}/relabel") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RETURN_ORDER_RELABEL + "')") + public ApiResponse createRelabel( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String returnNo, @Valid @RequestBody ReturnOrderRequests.RelabelCreate request) { return ApiResponse.success(service.createRelabel(customer(user), operator(user), returnNo, request)); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java index 3d4bf6ae0..609d90308 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java @@ -45,9 +45,29 @@ public final class OverseasPermissions { public static final String MERCHANT_INBOUND_LIST = "merchant:warehouse:inbound:list"; public static final String MERCHANT_RETURN_INVENTORY_LIST = "merchant:overseas-warehouse:return-inventory:list"; - public static final String MERCHANT_PROCUREMENT = "merchant:cross-border:procurement"; - public static final String MERCHANT_TRUCKING = "merchant:cross-border:domestic-trucking"; - public static final String MERCHANT_LOOSEBOARD = "merchant:cross-border:pallet-shipping"; + public static final String MERCHANT_LAST_MILE_ORDER_LIST = + "merchant:overseas-warehouse:last-mile-order:list"; + public static final String MERCHANT_RELABEL_LIST = "merchant:overseas-warehouse:relabel:list"; + public static final String MERCHANT_RELABEL_CREATE = "merchant:overseas-warehouse:relabel:create"; + public static final String MERCHANT_RELABEL_FILES = "merchant:overseas-warehouse:relabel:files"; + public static final String MERCHANT_RELABEL_CONFIRM = "merchant:overseas-warehouse:relabel:confirm"; + public static final String MERCHANT_TRANSFER_LIST = "merchant:overseas-warehouse:transfer:list"; + public static final String MERCHANT_TRANSFER_CREATE = "merchant:overseas-warehouse:transfer:create"; + public static final String MERCHANT_TRANSFER_CONFIRM = "merchant:overseas-warehouse:transfer:confirm"; + public static final String MERCHANT_DISCARD_LIST = "merchant:overseas-warehouse:discard:list"; + public static final String MERCHANT_DISCARD_CREATE = "merchant:overseas-warehouse:discard:create"; + public static final String MERCHANT_DISCARD_CANCEL = "merchant:overseas-warehouse:discard:cancel"; + public static final String MERCHANT_RETURN_ORDER_LIST = "merchant:overseas-warehouse:return-order:list"; + public static final String MERCHANT_RETURN_ORDER_CREATE = "merchant:overseas-warehouse:return-order:create"; + public static final String MERCHANT_RETURN_ORDER_RELABEL = "merchant:overseas-warehouse:return-order:relabel"; + public static final String MERCHANT_PROCUREMENT_LIST = "merchant:cross-border:procurement:list"; + public static final String MERCHANT_PROCUREMENT_CREATE = "merchant:cross-border:procurement:create"; + public static final String MERCHANT_TRUCKING_LIST = "merchant:cross-border:domestic-trucking:list"; + public static final String MERCHANT_TRUCKING_CREATE = "merchant:cross-border:domestic-trucking:create"; + public static final String MERCHANT_TRUCKING_EDIT = "merchant:cross-border:domestic-trucking:edit"; + public static final String MERCHANT_LOOSEBOARD_LIST = "merchant:cross-border:pallet-shipping:list"; + public static final String MERCHANT_LOOSEBOARD_CREATE = "merchant:cross-border:pallet-shipping:create"; + public static final String MERCHANT_LOOSEBOARD_EDIT = "merchant:cross-border:pallet-shipping:edit"; public static final String PURCHASE_LIST = "platform:overseas-business:purchase:list"; public static final String PURCHASE_EDIT = "platform:overseas-business:purchase:edit"; public static final String PURCHASE_REVIEW = "platform:overseas-business:purchase:review"; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferController.java index 63f93c8c4..5bbe9cc43 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferController.java @@ -6,9 +6,11 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests; import com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses; import com.baiyue.lastleg.transfer.service.WarehouseTransferService; +import com.baiyue.lastleg.security.OverseasPermissions; import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; @@ -19,24 +21,28 @@ public class MerchantWarehouseTransferController { @Resource private WarehouseTransferService service; @PostMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRANSFER_CREATE + "')") public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody WarehouseTransferRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); } @GetMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRANSFER_LIST + "')") public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, @Valid WarehouseTransferRequests.Query query) { return ApiResponse.success(service.page(customer(user), query)); } @GetMapping("/{transferNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRANSFER_LIST + "')") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo) { return ApiResponse.success(service.detail(customer(user), transferNo)); } @PostMapping("/{transferNo}/confirm-measurement") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRANSFER_CONFIRM + "')") public ApiResponse confirmMeasurement( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, @Valid @RequestBody WarehouseTransferRequests.CustomerConfirm request) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/MerchantUsTruckOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/MerchantUsTruckOrderController.java index a33e914af..ad70a8558 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/MerchantUsTruckOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/MerchantUsTruckOrderController.java @@ -23,29 +23,32 @@ import org.springframework.web.server.ResponseStatusException; @RestController @RequestMapping("/api/merchant/v1/last-leg/us-truck-orders") -@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRUCKING + "')") public class MerchantUsTruckOrderController { @Resource private UsTruckOrderService service; @PostMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRUCKING_CREATE + "')") public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody UsTruckOrderRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); } @GetMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRUCKING_LIST + "')") public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, @Valid UsTruckOrderRequests.MerchantQuery query) { return ApiResponse.success(service.page(customer(user), query.scoped())); } @GetMapping("/{orderNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRUCKING_LIST + "')") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String orderNo) { return ApiResponse.success(service.detail(customer(user), orderNo)); } @PatchMapping("/{orderNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRUCKING_EDIT + "')") public ApiResponse update(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.Update request) { return ApiResponse.success(service.update(customer(user), orderNo, operator(user), request)); 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 16fea794d..9d749f55a 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -962,13 +962,13 @@ paths: /api/merchant/v1/last-leg/overseas-procurements: post: summary: 客户创建海外采购订单 - description: 权限 merchant:cross-border:procurement;客户 ID 只取认证身份且不接收 customerId;新单固定进入 PENDING_REVIEW。同幂等键同请求返回既有订单,异请求返回 A0409。 + description: 权限 merchant:cross-border:procurement:create;客户 ID 只取认证身份且不接收 customerId;新单固定进入 PENDING_REVIEW。同幂等键同请求返回既有订单,异请求返回 A0409。 security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementCreate' } } } } responses: { '200': { description: 采购订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementDetailResponse' } } } }, '409': { description: 幂等冲突 } } get: summary: 客户分页查询自己的海外采购订单 - description: 权限 merchant:cross-border:procurement;客户范围只取认证 Token,不接收 customerId。 + description: 权限 merchant:cross-border:procurement:list;客户范围只取认证 Token,不接收 customerId。 security: [{ bearerAuth: [] }] parameters: - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } @@ -984,14 +984,14 @@ paths: /api/merchant/v1/last-leg/overseas-procurements/{orderNo}: get: summary: 客户查询自己的海外采购订单详情及流水 - description: 权限 merchant:cross-border:procurement;列表权限包含详情只读,客户范围只取认证 Token。 + description: 权限 merchant:cross-border:procurement:list;列表权限包含详情只读,客户范围只取认证 Token。 security: [{ bearerAuth: [] }] parameters: [{ name: orderNo, in: path, required: true, schema: { type: string } }] responses: { '200': { description: 当前客户采购订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementDetailResponse' } } } }, '404': { description: 当前客户下订单不存在 } } /api/merchant/v1/last-leg/us-truck-orders: get: summary: 客户分页查询自己的美国本土卡订单 - description: 权限 merchant:cross-border:domestic-trucking;客户范围只取认证 Token,不接收 customerId。 + description: 权限 merchant:cross-border:domestic-trucking:list;客户范围只取认证 Token,不接收 customerId。 security: [{ bearerAuth: [] }] parameters: - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } @@ -1005,7 +1005,7 @@ paths: responses: { '200': { description: 当前客户本土卡订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/TruckPageResponse' } } } } } post: summary: 客户创建美国本土卡订单 - description: 权限 merchant:cross-border:domestic-trucking;新单固定进入 PENDING_REVIEW,客户不能调用平台审核或状态推进端点。 + description: 权限 merchant:cross-border:domestic-trucking:create;新单固定进入 PENDING_REVIEW,客户不能调用平台审核或状态推进端点。 security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckCreate' } } } } responses: { '200': { description: 本土卡订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/TruckDetailResponse' } } } }, '409': { description: 幂等冲突 } } @@ -1013,19 +1013,19 @@ paths: parameters: [{ name: orderNo, in: path, required: true, schema: { type: string } }] get: summary: 客户查询自己的美国本土卡订单详情 - description: 权限 merchant:cross-border:domestic-trucking;不泄露其他客户订单。 + description: 权限 merchant:cross-border:domestic-trucking:list;不泄露其他客户订单。 security: [{ bearerAuth: [] }] responses: { '200': { description: 当前客户本土卡订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/TruckDetailResponse' } } } }, '404': { description: 当前客户下订单不存在 } } patch: summary: 客户编辑待审核的美国本土卡订单 - description: 权限 merchant:cross-border:domestic-trucking;仅 PENDING_REVIEW 可编辑,version 必填;不能修改状态。 + description: 权限 merchant:cross-border:domestic-trucking:edit;仅 PENDING_REVIEW 可编辑,version 必填;不能修改状态。 security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckUpdate' } } } } responses: { '200': { description: 更新后的订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/TruckDetailResponse' } } } }, '409': { description: 版本或状态冲突 } } /api/merchant/v1/last-leg/local-looseboard-orders: get: summary: 客户分页查询自己的本土散板订单 - description: 权限 merchant:cross-border:pallet-shipping;客户范围只取认证 Token,不接收 customerId。 + description: 权限 merchant:cross-border:pallet-shipping:list;客户范围只取认证 Token,不接收 customerId。 security: [{ bearerAuth: [] }] parameters: - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } @@ -1039,7 +1039,7 @@ paths: responses: { '200': { description: 当前客户本土散板订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardPageResponse' } } } } } post: summary: 客户创建本土散板订单 - description: 权限 merchant:cross-border:pallet-shipping;新单固定进入 PENDING_ACCEPTANCE,客户不能调用平台状态推进端点。 + description: 权限 merchant:cross-border:pallet-shipping:create;新单固定进入 PENDING_ACCEPTANCE,客户不能调用平台状态推进端点。 security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardCreate' } } } } responses: { '200': { description: 本土散板订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '409': { description: 幂等冲突 } } @@ -1047,12 +1047,12 @@ paths: parameters: [{ name: orderNo, in: path, required: true, schema: { type: string } }] get: summary: 客户查询自己的本土散板订单详情 - description: 权限 merchant:cross-border:pallet-shipping;不泄露其他客户订单。 + description: 权限 merchant:cross-border:pallet-shipping:list;不泄露其他客户订单。 security: [{ bearerAuth: [] }] responses: { '200': { description: 当前客户本土散板订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '404': { description: 当前客户下订单不存在 } } put: summary: 客户编辑待接单的本土散板订单 - description: 权限 merchant:cross-border:pallet-shipping;仅 PENDING_ACCEPTANCE 可编辑,version 必填;不能修改状态。 + description: 权限 merchant:cross-border:pallet-shipping:edit;仅 PENDING_ACCEPTANCE 可编辑,version 必填;不能修改状态。 security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardUpdate' } } } } responses: { '200': { description: 更新后的订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '409': { description: 版本或状态冲突 } } @@ -1310,7 +1310,7 @@ paths: /api/merchant/v1/last-leg/relabel-applications/inventory-candidates: get: summary: 查询客户可换标库存候选 - description: 权限 merchant:warehouse:relabel;仅返回当前客户在指定海外中转仓仍有可用箱数的历史入仓单和库位摘要。候选按客户 Token 隔离,不接收 customerId;inboundCode 可选精确筛选。 + description: 权限 merchant:overseas-warehouse:relabel:list;仅返回当前客户在指定海外中转仓仍有可用箱数的历史入仓单和库位摘要。候选按客户 Token 隔离,不接收 customerId;inboundCode 可选精确筛选。 security: [{ bearerAuth: [] }] parameters: - { name: operationWarehouseId, in: query, required: true, schema: { type: string, maxLength: 128 } } @@ -1321,7 +1321,7 @@ paths: /api/merchant/v1/last-leg/relabel-applications: get: summary: 分页查询客户换标申请 - description: 权限 merchant:warehouse:relabel;客户范围只取认证 Token,不接收 customerId。 + description: 权限 merchant:overseas-warehouse:relabel:list;客户范围只取认证 Token,不接收 customerId。 security: [{ bearerAuth: [] }] parameters: - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } @@ -1337,7 +1337,7 @@ paths: '200': { description: 客户换标申请分页, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelMerchantPageResponse' } } } } post: summary: 创建客户换标申请 - description: 权限 merchant:warehouse:relabel;申请创建即冻结对应库存。SKU_LABEL 不要求派送方式;CARTON_LABEL/CARTON_AND_SKU 必须指定 TRUCK、PICKUP 或 EXPRESS。 + description: 权限 merchant:overseas-warehouse:relabel:create;申请创建即冻结对应库存。SKU_LABEL 不要求派送方式;CARTON_LABEL/CARTON_AND_SKU 必须指定 TRUCK、PICKUP 或 EXPRESS。 security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelCreate' } } } } responses: @@ -1357,7 +1357,7 @@ paths: /api/merchant/v1/last-leg/relabel-applications/{relabelNo}: get: summary: 查询客户换标申请详情 - description: 权限 merchant:warehouse:relabel;客户范围只取认证 Token。 + description: 权限 merchant:overseas-warehouse:relabel:list;客户范围只取认证 Token。 security: [{ bearerAuth: [] }] parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } }] responses: @@ -1366,7 +1366,7 @@ paths: /api/merchant/v1/last-leg/relabel-applications/{relabelNo}/shipping-label: post: summary: 上传换标快递面单 - description: 权限 merchant:warehouse:relabel;仅 EXPRESS 且申请尚未客户确认时允许上传,文件由服务端绑定到申请单。 + description: 权限 merchant:overseas-warehouse:relabel:files;仅 EXPRESS 且申请尚未客户确认时允许上传,文件由服务端绑定到申请单。 security: [{ bearerAuth: [] }] parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } }] requestBody: { required: true, content: { multipart/form-data: { schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } } } } } @@ -1377,7 +1377,7 @@ paths: /api/merchant/v1/last-leg/relabel-applications/{relabelNo}/shipping-label/{fileId}/download: get: summary: 下载换标快递面单 - description: 权限 merchant:warehouse:relabel;仅允许下载当前申请已绑定的面单文件。 + description: 权限 merchant:overseas-warehouse:relabel:list;仅允许下载当前申请已绑定的面单文件。 security: [{ bearerAuth: [] }] parameters: - { name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } } @@ -1388,7 +1388,7 @@ paths: /api/merchant/v1/last-leg/relabel-applications/{relabelNo}/completion-photos/{fileId}/download: get: summary: 下载换标完成照片 - description: 权限 merchant:warehouse:relabel;仅允许下载该申请 PDA 完工证据中的照片。 + description: 权限 merchant:overseas-warehouse:relabel:list;仅允许下载该申请 PDA 完工证据中的照片。 security: [{ bearerAuth: [] }] parameters: - { name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } } @@ -1399,7 +1399,7 @@ paths: /api/merchant/v1/last-leg/relabel-applications/{relabelNo}/confirm-completion: post: summary: 客户确认换标完成 - description: 权限 merchant:warehouse:relabel;仅 CUSTOMER_CONFIRM_PENDING 可确认。TRUCK 进入 WAIT_PRE_DISPATCH,PICKUP/EXPRESS 幂等生成 RELABEL_OUTBOUND 出库单;EXPRESS 且要求打印面单时必须先上传面单。 + description: 权限 merchant:overseas-warehouse:relabel:confirm;仅 CUSTOMER_CONFIRM_PENDING 可确认。TRUCK 进入 WAIT_PRE_DISPATCH,PICKUP/EXPRESS 幂等生成 RELABEL_OUTBOUND 出库单;EXPRESS 且要求打印面单时必须先上传面单。 security: [{ bearerAuth: [] }] parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelCustomerConfirm' } } } } @@ -1409,7 +1409,7 @@ paths: /api/merchant/v1/last-leg/return-orders: get: summary: 客户分页查询退货签收订单 - description: 客户范围只取认证 Token。可按退货单号、快递单号、SKU 和签收状态过滤;实收数量以仓库 PDA 签收为准。 + description: 权限 merchant:overseas-warehouse:return-order:list;客户范围只取认证 Token。可按退货单号、快递单号、SKU 和签收状态过滤;实收数量以仓库 PDA 签收为准。 security: [{ bearerAuth: [] }] parameters: - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } @@ -1421,14 +1421,14 @@ paths: responses: { '200': { description: 当前客户退货订单分页 } } post: summary: 客户创建退货预报订单 - description: 客户只预报不重复的 SKU 和预计数量,不要求提前知道快递单号;仓库 PDA 实收数量才是实际入库事实。系统退货编号由业务前缀和25位大写Base36完整随机UUID编码组成,退货单RT、签收单RR、批次RB、箱唛RC及退货主单RETU均独立生成,不编码内部ID、仓库或日期。幂等重放返回已保存的编号,不重新生成。 + description: 权限 merchant:overseas-warehouse:return-order:create;客户只预报不重复的 SKU 和预计数量,不要求提前知道快递单号;仓库 PDA 实收数量才是实际入库事实。系统退货编号由业务前缀和25位大写Base36完整随机UUID编码组成,退货单RT、签收单RR、批次RB、箱唛RC及退货主单RETU均独立生成,不编码内部ID、仓库或日期。幂等重放返回已保存的编号,不重新生成。 security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReturnOrderCreate' } } } } responses: { '200': { description: 创建后的退货订单详情 }, '409': { description: 幂等键冲突 } } /api/merchant/v1/last-leg/return-orders/{returnNo}: get: summary: 客户查询退货订单与签收批次 - description: 返回各 SKU 的预报和实收数量,以及每次 PDA 签收产生的批次号和可选快递单号。 + description: 权限 merchant:overseas-warehouse:return-order:list;返回各 SKU 的预报和实收数量,以及每次 PDA 签收产生的批次号和可选快递单号。 security: [{ bearerAuth: [] }] parameters: [{ name: returnNo, in: path, required: true, schema: { type: string } }] responses: { '200': { description: 当前客户退货订单详情 }, '404': { description: 当前客户下退货订单不存在 } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasCustomerApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasCustomerApiContractTest.java index affa0f795..c9092655e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasCustomerApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasCustomerApiContractTest.java @@ -1,14 +1,19 @@ package com.baiyue.lastleg.merchant; import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.controller.MerchantLastlegOrderController; import com.baiyue.lastleg.inboundplan.controller.MerchantInboundPlanController; import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests; import com.baiyue.lastleg.inboundplan.service.InboundPlanService; +import com.baiyue.lastleg.discard.controller.MerchantCargoDiscardController; import com.baiyue.lastleg.looseboard.controller.MerchantLocalLooseboardController; import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests; import com.baiyue.lastleg.looseboard.service.LocalLooseboardService; import com.baiyue.lastleg.procurement.controller.MerchantOverseasProcurementController; +import com.baiyue.lastleg.relabel.controller.MerchantRelabelApplicationController; +import com.baiyue.lastleg.returnreceipt.controller.MerchantReturnOrderController; import com.baiyue.lastleg.security.OverseasPermissions; +import com.baiyue.lastleg.transfer.controller.MerchantWarehouseTransferController; import com.baiyue.lastleg.trucking.controller.MerchantUsTruckOrderController; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -21,6 +26,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.server.ResponseStatusException; import java.util.Arrays; +import java.util.Map; import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; @@ -43,15 +49,57 @@ class MerchantOverseasCustomerApiContractTest { } @Test - void merchantControllersUseFrozenPathsAndExistingClientPermissionCodes() { - assertController(MerchantOverseasProcurementController.class, - "/api/merchant/v1/last-leg/overseas-procurements", OverseasPermissions.MERCHANT_PROCUREMENT); - assertController(MerchantUsTruckOrderController.class, - "/api/merchant/v1/last-leg/us-truck-orders", OverseasPermissions.MERCHANT_TRUCKING); - assertController(MerchantLocalLooseboardController.class, - "/api/merchant/v1/last-leg/local-looseboard-orders", OverseasPermissions.MERCHANT_LOOSEBOARD); + void merchantControllersUseFrozenPathsAndOperationPermissions() { + assertPath(MerchantOverseasProcurementController.class, "/api/merchant/v1/last-leg/overseas-procurements"); + assertPermissions(MerchantOverseasProcurementController.class, Map.of( + "create", OverseasPermissions.MERCHANT_PROCUREMENT_CREATE, + "page", OverseasPermissions.MERCHANT_PROCUREMENT_LIST, + "detail", OverseasPermissions.MERCHANT_PROCUREMENT_LIST)); + assertPath(MerchantUsTruckOrderController.class, "/api/merchant/v1/last-leg/us-truck-orders"); + assertPermissions(MerchantUsTruckOrderController.class, Map.of( + "create", OverseasPermissions.MERCHANT_TRUCKING_CREATE, + "page", OverseasPermissions.MERCHANT_TRUCKING_LIST, + "detail", OverseasPermissions.MERCHANT_TRUCKING_LIST, + "update", OverseasPermissions.MERCHANT_TRUCKING_EDIT)); + assertPath(MerchantLocalLooseboardController.class, "/api/merchant/v1/last-leg/local-looseboard-orders"); + assertPermissions(MerchantLocalLooseboardController.class, Map.of( + "create", OverseasPermissions.MERCHANT_LOOSEBOARD_CREATE, + "page", OverseasPermissions.MERCHANT_LOOSEBOARD_LIST, + "detail", OverseasPermissions.MERCHANT_LOOSEBOARD_LIST, + "update", OverseasPermissions.MERCHANT_LOOSEBOARD_EDIT)); assertController(MerchantInboundPlanController.class, "/api/merchant/v1/last-leg/inbound-plans", OverseasPermissions.MERCHANT_INBOUND_LIST); + assertController(MerchantLastlegOrderController.class, + "/api/merchant/v1/last-leg/orders", OverseasPermissions.MERCHANT_LAST_MILE_ORDER_LIST); + + assertPermissions(MerchantWarehouseTransferController.class, Map.of( + "create", OverseasPermissions.MERCHANT_TRANSFER_CREATE, + "page", OverseasPermissions.MERCHANT_TRANSFER_LIST, + "detail", OverseasPermissions.MERCHANT_TRANSFER_LIST, + "confirmMeasurement", OverseasPermissions.MERCHANT_TRANSFER_CONFIRM)); + assertPermissions(MerchantCargoDiscardController.class, Map.of( + "candidates", OverseasPermissions.MERCHANT_DISCARD_LIST, + "create", OverseasPermissions.MERCHANT_DISCARD_CREATE, + "page", OverseasPermissions.MERCHANT_DISCARD_LIST, + "detail", OverseasPermissions.MERCHANT_DISCARD_LIST, + "cancel", OverseasPermissions.MERCHANT_DISCARD_CANCEL)); + assertPermissions(MerchantReturnOrderController.class, Map.of( + "create", OverseasPermissions.MERCHANT_RETURN_ORDER_CREATE, + "page", OverseasPermissions.MERCHANT_RETURN_ORDER_LIST, + "detail", OverseasPermissions.MERCHANT_RETURN_ORDER_LIST, + "relabelCandidates", OverseasPermissions.MERCHANT_RETURN_ORDER_RELABEL, + "createRelabel", OverseasPermissions.MERCHANT_RETURN_ORDER_RELABEL)); + assertPermissions(MerchantRelabelApplicationController.class, Map.of( + "candidates", OverseasPermissions.MERCHANT_RELABEL_LIST, + "historicalOrders", OverseasPermissions.MERCHANT_RELABEL_LIST, + "create", OverseasPermissions.MERCHANT_RELABEL_CREATE, + "page", OverseasPermissions.MERCHANT_RELABEL_LIST, + "detail", OverseasPermissions.MERCHANT_RELABEL_LIST, + "uploadShippingLabel", OverseasPermissions.MERCHANT_RELABEL_FILES, + "uploadFile", OverseasPermissions.MERCHANT_RELABEL_FILES, + "downloadShippingLabel", OverseasPermissions.MERCHANT_RELABEL_LIST, + "downloadCompletionPhoto", OverseasPermissions.MERCHANT_RELABEL_LIST, + "confirmCompletion", OverseasPermissions.MERCHANT_RELABEL_CONFIRM)); } @Test @@ -90,11 +138,27 @@ class MerchantOverseasCustomerApiContractTest { } private static void assertController(Class type, String path, String permission) { - assertThat(type.getAnnotation(RequestMapping.class).value()).containsExactly(path); + assertPath(type, path); assertThat(type.getAnnotation(PreAuthorize.class).value()) .isEqualTo("hasAuthority('" + permission + "')"); } + private static void assertPath(Class type, String path) { + assertThat(type.getAnnotation(RequestMapping.class).value()).containsExactly(path); + } + + private static void assertPermissions(Class type, Map permissions) { + for (var entry : permissions.entrySet()) { + var method = Arrays.stream(type.getDeclaredMethods()) + .filter(candidate -> candidate.getName().equals(entry.getKey())) + .findFirst().orElseThrow(); + assertThat(method.getAnnotation(PreAuthorize.class)) + .as(type.getSimpleName() + "." + entry.getKey()).isNotNull(); + assertThat(method.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('" + entry.getValue() + "')"); + } + } + private static AuthenticatedUser user(long customerId, String appCode) { return new AuthenticatedUser(7L, customerId, customerId, 8L, "operator", appCode, "WEB", Set.of()); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasOpenApiContractTest.java index e89d8d07d..6582c0396 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasOpenApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasOpenApiContractTest.java @@ -27,9 +27,14 @@ class MerchantOverseasOpenApiContractTest { assertThat(yaml).contains(path); assertThat(pathBlock(yaml, path)).doesNotContain("name: customerId"); } - assertThat(yaml).contains("merchant:cross-border:procurement") - .contains("merchant:cross-border:domestic-trucking") - .contains("merchant:cross-border:pallet-shipping") + assertThat(yaml).contains("merchant:cross-border:procurement:list") + .contains("merchant:cross-border:procurement:create") + .contains("merchant:cross-border:domestic-trucking:list") + .contains("merchant:cross-border:domestic-trucking:create") + .contains("merchant:cross-border:domestic-trucking:edit") + .contains("merchant:cross-border:pallet-shipping:list") + .contains("merchant:cross-border:pallet-shipping:create") + .contains("merchant:cross-border:pallet-shipping:edit") .contains("merchant:warehouse:inbound:list") .contains("#/components/schemas/ProcurementDetailResponse") .contains("#/components/schemas/TruckDetailResponse") diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java index f6ab20500..2d9995cf6 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java @@ -96,7 +96,10 @@ class RelabelContractTest { } assertTrue(openApi.contains("RelabelMerchantPageResponse")); assertTrue(openApi.contains("RelabelCustomerConfirm")); - assertTrue(openApi.contains("merchant:warehouse:relabel")); + assertTrue(openApi.contains("merchant:overseas-warehouse:relabel:list")); + assertTrue(openApi.contains("merchant:overseas-warehouse:relabel:create")); + assertTrue(openApi.contains("merchant:overseas-warehouse:relabel:files")); + assertTrue(openApi.contains("merchant:overseas-warehouse:relabel:confirm")); String relabelPaths = openApi.substring(openApi.indexOf("/api/platform/v1/last-leg/relabel-applications:"), openApi.indexOf("components:")); assertTrue(relabelPaths.contains("name: customerId, in: query, required: true")); diff --git a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs index 8b139fde4..c9b861312 100644 --- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs +++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs @@ -47,7 +47,7 @@ test('平台管理员由外部bcrypt引导,强制首登改密且不创建客 }) test('必要系统种子完整保留,但不写入示例资源与业务事实', () => { - for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 346, iam_role_permission: 521, + for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 364, iam_role_permission: 539, iam_data_scope: 7, wh_warehouse: 318, wh_warehouse_address: 318, wh_service_capability: 4, fl_fee_item: 112, fl_price_channel_catalog: 15, fl_price_origin_alias: 7, fl_price_warehouse_mapping: 4, file_export_task_guard: 1, fin_subject: 1 })) assert.equal(seedRows(cn, name).length, count, name) diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index 1b89c166a..ab5303ff7 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -589,7 +589,7 @@ foreach ($gatewayPath in @( '/api/merchant/v1/last-leg/**', '/api/platform/v1/last-leg/**', '/api/pda/v1/last-leg/**', - '/api/merchant/v1/warehouses/**', + '/api/platform/v1/warehouses/**', '/api/platform/v1/warehouse-admin/**' )) { Assert-Contains $overseasGatewayConfig $gatewayPath 'Overseas Gateway 路由' @@ -1136,6 +1136,7 @@ $criticalPaths | ForEach-Object { Assert-Path $_ } foreach ($removedOverseasWarehouseMasterPath in @( 'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java', 'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java', + 'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseResourceController.java', 'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseEntity.java', 'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseAddressEntity.java', 'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/ServiceCapabilityEntity.java' diff --git a/warehouse-overseas-service/AGENTS.md b/warehouse-overseas-service/AGENTS.md index 95a4c29b2..7bfb080eb 100644 --- a/warehouse-overseas-service/AGENTS.md +++ b/warehouse-overseas-service/AGENTS.md @@ -11,7 +11,7 @@ | 功能 | 入口 | |---|---| | 国内仓库只读目录 | `client/DomesticWarehouseDirectoryClient.java`、`service/DomesticWarehouseDirectory.java`;正结果缓存 30 分钟,空值和不存在不得长缓存 | -| 平台/商户库位与托盘 | `controller/PlatformWarehouseResourceController.java`、`MerchantWarehouseResourceController.java` | +| 平台库位与托盘 | `controller/PlatformWarehouseResourceController.java`;企业端不得直接维护物理库位与托盘 | | 内部资源锁定、释放、报废 | `controller/InternalWarehouseResourceLockController.java` | | PDA 托盘状态流转与补偿 | `controller/InternalPdaPalletTransitionController.java`、`service/WarehousePalletTransitionService.java`、`mapper/WarehousePalletTransitionMapper.java`;内部前缀 `/internal/v1/pda-pallet-transitions` | | 托盘解析与业务绑定 | `controller/InternalWarehousePalletController.java`、`dto/WarehousePalletResolveResponse.java`、`entity/WarehousePalletBindingEntity.java` | diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseResourceController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseResourceController.java deleted file mode 100644 index 0ba243967..000000000 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseResourceController.java +++ /dev/null @@ -1,109 +0,0 @@ -package com.baiyue.warehouse.controller; - -import com.baiyue.common.core.api.ApiResponse; -import com.baiyue.common.core.api.PageResult; -import com.baiyue.common.core.api.ResultCode; -import com.baiyue.common.core.exception.BizException; -import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.warehouse.dto.LocationRequests; -import com.baiyue.warehouse.dto.PalletRequests; -import com.baiyue.warehouse.entity.WarehouseLocationEntity; -import com.baiyue.warehouse.entity.WarehousePalletEntity; -import com.baiyue.warehouse.service.WarehouseResourceService; -import jakarta.validation.Valid; -import jakarta.annotation.Resource; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@Validated -@RestController -@RequestMapping("/api/merchant/v1/warehouses/{warehouseId}") -public class MerchantWarehouseResourceController { - @Resource private WarehouseResourceService service; - - @PostMapping("/locations") - public ApiResponse createLocation(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable long warehouseId, - @Valid @RequestBody LocationRequests.SaveRequest request) { - return ApiResponse.success(service.createLocation(tenant(user), warehouseId, request)); - } - - @PutMapping("/locations/{id}") - public ApiResponse updateLocation(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable long warehouseId, @PathVariable long id, - @Valid @RequestBody LocationRequests.SaveRequest request) { - service.updateLocation(tenant(user), warehouseId, id, request); - return ApiResponse.success(null); - } - - @GetMapping("/locations") - public ApiResponse> locations( - @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long warehouseId, - @Valid LocationRequests.QueryRequest query) { - return ApiResponse.success(service.locations(tenant(user), warehouseId, query)); - } - - @GetMapping("/locations/{id}") - public ApiResponse location(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable long warehouseId, - @PathVariable long id) { - return ApiResponse.success(service.location(tenant(user), warehouseId, id)); - } - - @DeleteMapping("/locations/{id}") - public ApiResponse deleteLocation(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable long warehouseId, @PathVariable long id) { - service.deleteLocation(tenant(user), warehouseId, id); - return ApiResponse.success(null); - } - - @PostMapping("/pallets") - public ApiResponse createPallet(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable long warehouseId, - @Valid @RequestBody PalletRequests.SaveRequest request) { - return ApiResponse.success(service.createPallet(tenant(user), warehouseId, request)); - } - - @PutMapping("/pallets/{id}") - public ApiResponse updatePallet(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable long warehouseId, @PathVariable long id, - @Valid @RequestBody PalletRequests.SaveRequest request) { - service.updatePallet(tenant(user), warehouseId, id, request); - return ApiResponse.success(null); - } - - @GetMapping("/pallets") - public ApiResponse> pallets( - @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long warehouseId, - @Valid PalletRequests.QueryRequest query) { - return ApiResponse.success(service.pallets(tenant(user), warehouseId, query)); - } - - @GetMapping("/pallets/{id}") - public ApiResponse pallet(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable long warehouseId, @PathVariable long id) { - return ApiResponse.success(service.pallet(tenant(user), warehouseId, id)); - } - - @DeleteMapping("/pallets/{id}") - public ApiResponse deletePallet(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable long warehouseId, @PathVariable long id) { - service.deletePallet(tenant(user), warehouseId, id); - return ApiResponse.success(null); - } - - private static long tenant(AuthenticatedUser user) { - if (user == null || user.tenantId() == null || user.tenantId() <= 0) { - throw new BizException(ResultCode.UNAUTHORIZED, "认证租户缺失"); - } - return user.tenantId(); - } -} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourceControllerTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourceControllerTest.java index 3fb778ee7..728fd6a5c 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourceControllerTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourceControllerTest.java @@ -32,18 +32,6 @@ class WarehouseResourceControllerTest { verify(labels).print(200L, 9L, "r1"); } - @Test - void merchantTenantAlwaysComesFromAuthenticatedUser() { - MerchantWarehouseResourceController controller = merchantController(); - AuthenticatedUser merchant = user(10001L, "MERCHANT"); - WarehouseLocationEntity expected = new WarehouseLocationEntity(); - when(service.location(10001L, 20001L, 30001L)).thenReturn(expected); - - controller.location(merchant, 20001L, 30001L); - - verify(service).location(10001L, 20001L, 30001L); - } - @Test void platformApiRejectsNonPlatformIdentity() { PlatformWarehouseResourceController controller = platformController(); @@ -79,12 +67,6 @@ class WarehouseResourceControllerTest { "tester", appCode, "WEB", Set.of()); } - private MerchantWarehouseResourceController merchantController() { - MerchantWarehouseResourceController controller = new MerchantWarehouseResourceController(); - ReflectionTestUtils.setField(controller, "service", service); - return controller; - } - private PlatformWarehouseResourceController platformController() { PlatformWarehouseResourceController controller = new PlatformWarehouseResourceController(); ReflectionTestUtils.setField(controller, "service", service); -- 2.54.0 From 6be4e764a92bf3ce14f95d211674a60529eb4ba9 Mon Sep 17 00:00:00 2001 From: wujinliang <2318309077@qq.com> Date: Fri, 18 Sep 2026 09:16:02 +0800 Subject: [PATCH 03/16] 1 --- .gitignore | 1 + .../NotificationRecipientResolver.java | 13 +++-- .../service/NotificationTemplateRenderer.java | 12 +++-- .../iam/service/ProfileResourceService.java | 27 ++++++++--- .../NotificationRecipientResolverTest.java | 40 ++++++++++++++-- .../NotificationTemplateRendererTest.java | 4 +- ...PlatformQualificationRegistrationTest.java | 48 +++++++++++++++++++ .../order/service/CustomerAddressService.java | 21 ++++++++ .../service/UnifiedOrderQueryService.java | 18 ++++--- .../service/CustomerAddressServiceTest.java | 20 ++++++++ .../service/UnifiedOrderQueryServiceTest.java | 34 +++++++++++-- 11 files changed, 211 insertions(+), 27 deletions(-) diff --git a/.gitignore b/.gitignore index f0ffe02b8..8d49f9f98 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ logs/ data/ .env .env.* +.env.development !.env.example !.env.*.example application-local.yml diff --git a/iam/src/main/java/com/baiyue/iam/notification/service/NotificationRecipientResolver.java b/iam/src/main/java/com/baiyue/iam/notification/service/NotificationRecipientResolver.java index b38c2b733..fbf30ea30 100644 --- a/iam/src/main/java/com/baiyue/iam/notification/service/NotificationRecipientResolver.java +++ b/iam/src/main/java/com/baiyue/iam/notification/service/NotificationRecipientResolver.java @@ -52,9 +52,16 @@ public class NotificationRecipientResolver { } private List byRole(CreateNotificationRequest request) { - var role = roles.selectOne(new LambdaQueryWrapper() - .eq(com.baiyue.iam.entity.IamRole::getTenantId, request.tenantId()) - .eq(com.baiyue.iam.entity.IamRole::getRoleCode, request.roleCode()).eq(com.baiyue.iam.entity.IamRole::getStatus,"ACTIVE").last("LIMIT 1")); + String appCode = request.recipientType() == com.baiyue.iam.notification.dto.NotificationDtos.RecipientType.TENANT_ROLE + ? "MERCHANT" : "PLATFORM"; + // Merchant members can be assigned tenant-specific roles or platform-provided merchant roles (tenant 0). + var candidates = roles.selectList(new LambdaQueryWrapper() + .in(com.baiyue.iam.entity.IamRole::getTenantId, request.tenantId(), 0L) + .eq(com.baiyue.iam.entity.IamRole::getAppCode, appCode) + .eq(com.baiyue.iam.entity.IamRole::getRoleCode, request.roleCode()) + .eq(com.baiyue.iam.entity.IamRole::getStatus, "ACTIVE") + .orderByDesc(com.baiyue.iam.entity.IamRole::getTenantId)); + var role = candidates.isEmpty() ? null : candidates.get(0); if (role == null) fail("角色编码不存在,请检查角色编码是否正确"); List ids = membershipRoles.selectList(new LambdaQueryWrapper() .eq(com.baiyue.iam.entity.IamMembershipRole::getTenantId,request.tenantId()) diff --git a/iam/src/main/java/com/baiyue/iam/notification/service/NotificationTemplateRenderer.java b/iam/src/main/java/com/baiyue/iam/notification/service/NotificationTemplateRenderer.java index f05b8f7ea..0427c5c3a 100644 --- a/iam/src/main/java/com/baiyue/iam/notification/service/NotificationTemplateRenderer.java +++ b/iam/src/main/java/com/baiyue/iam/notification/service/NotificationTemplateRenderer.java @@ -12,7 +12,9 @@ import java.util.regex.Pattern; @Component public class NotificationTemplateRenderer { - private static final Pattern VARIABLE = Pattern.compile("\\{\\{([a-z][a-z0-9_]{0,31})}}" ); + // ${name} is the format inserted by the template editor; retain {{name}} for existing templates. + private static final Pattern VARIABLE = Pattern.compile( + "\\$\\{([a-z][a-z0-9_]{0,31})}|\\{\\{([a-z][a-z0-9_]{0,31})}}" ); public String render(String template, Set whitelist, Map variables) { Set used = placeholders(template); @@ -20,7 +22,11 @@ public class NotificationTemplateRenderer { if (!whitelist.containsAll(variables.keySet())) fail("NOTIFICATION_VARIABLE_NOT_ALLOWED"); if (!variables.keySet().containsAll(used)) fail("NOTIFICATION_VARIABLE_MISSING"); String rendered = template; - for (String key : used) rendered = rendered.replace("{{" + key + "}}", variables.get(key)); + for (String key : used) { + String value = variables.get(key); + rendered = rendered.replace("${" + key + "}", value) + .replace("{{" + key + "}}", value); + } if (rendered.length() > 10_000) fail("NOTIFICATION_TEMPLATE_RENDER_TOO_LONG"); return rendered; } @@ -33,7 +39,7 @@ public class NotificationTemplateRenderer { private Set placeholders(String value) { Set keys = new HashSet<>(); Matcher matcher = VARIABLE.matcher(value); - while (matcher.find()) keys.add(matcher.group(1)); + while (matcher.find()) keys.add(matcher.group(1) != null ? matcher.group(1) : matcher.group(2)); return keys; } diff --git a/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java b/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java index bbf988c8e..68f11852d 100644 --- a/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java +++ b/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java @@ -139,8 +139,8 @@ public class ProfileResourceService { if (tenant == null || !"MERCHANT".equals(tenant.getTenantType())) { throw new BizException(ResultCode.NOT_FOUND, "Enterprise not found / 企业客户不存在"); } - if (!"PROFILE_REQUIRED".equals(tenant.getStatus()) && !"REJECTED".equals(tenant.getStatus())) { - throw new BizException(ResultCode.CONFLICT, "Qualifications can only be uploaded before review / 仅待完善或已驳回客户可上传资质"); + if (!platformCanUpdateQualifications(tenant)) { + throw new BizException(ResultCode.CONFLICT, "Qualifications can only be uploaded before review / 仅待完善、已驳回或已审核客户可上传资质"); } requireOptionalQualificationType(request.qualificationType()); String certificateNo = resolveCertificateNo(tenant, request.qualificationType(), request.certificateNo()); @@ -163,6 +163,7 @@ public class ProfileResourceService { IamTenantQualification qualification = createQualification(tenant.getId(), request.qualificationType(), certificateNo, request.documentUrl(), request.originalFileName()); qualification.setValidTo(request.validTo()); + markPlatformQualificationApproved(tenant, qualification); repository.saveQualification(qualification); return new PlatformQualificationRegistrationResponse( qualification.getId(), null, tenant.getStatus(), replacedDocumentUrl); @@ -173,7 +174,7 @@ public class ProfileResourceService { resourceId = "#request.tenantId", description = "上传企业必填资质包", captureAfter = false) public RequiredQualificationPackageResponse submitRequiredQualificationPackage( RequiredQualificationPackageRequest request) { - return registerRequiredQualificationPackage(request, "商户在送审前替换文件"); + return registerRequiredQualificationPackage(request, "商户在送审前替换文件", false); } @Transactional @@ -181,16 +182,17 @@ public class ProfileResourceService { resourceId = "#request.tenantId", description = "平台代客户上传必填资质包", captureAfter = false) public RequiredQualificationPackageResponse registerPlatformRequiredQualificationPackage( RequiredQualificationPackageRequest request) { - return registerRequiredQualificationPackage(request, "平台在送审前替换文件"); + return registerRequiredQualificationPackage(request, "平台在送审前替换文件", true); } private RequiredQualificationPackageResponse registerRequiredQualificationPackage( - RequiredQualificationPackageRequest request, String replacementComment) { + RequiredQualificationPackageRequest request, String replacementComment, boolean platformManaged) { IamTenant tenant = tenantRepository.findTenantForUpdate(request.tenantId()); if (tenant == null || !"MERCHANT".equals(tenant.getTenantType())) { throw new BizException(ResultCode.NOT_FOUND, "Enterprise not found / 企业客户不存在"); } - if (!"PROFILE_REQUIRED".equals(tenant.getStatus()) && !"REJECTED".equals(tenant.getStatus())) { + if (platformManaged ? !platformCanUpdateQualifications(tenant) + : (!"PROFILE_REQUIRED".equals(tenant.getStatus()) && !"REJECTED".equals(tenant.getStatus()))) { throw new BizException(ResultCode.CONFLICT, "Qualifications can only be uploaded before review / 仅待完善或已驳回客户可上传资质"); } @@ -212,12 +214,14 @@ public class ProfileResourceService { IamTenantQualification businessLicense = createQualification(tenant.getId(), "BUSINESS_LICENSE", creditCode, request.businessLicenseDocumentUrl(), request.businessLicenseOriginalFileName()); businessLicense.setValidTo(request.businessLicenseValidTo()); + if (platformManaged) markPlatformQualificationApproved(tenant, businessLicense); repository.saveQualification(businessLicense); IamTenantQualification legalPersonId = null; if (hasLegalPersonId) { legalPersonId = createQualification(tenant.getId(), "LEGAL_PERSON_ID", null, request.legalPersonIdDocumentUrl().trim(), request.legalPersonIdOriginalFileName()); legalPersonId.setValidTo(request.legalPersonIdValidTo()); + if (platformManaged) markPlatformQualificationApproved(tenant, legalPersonId); repository.saveQualification(legalPersonId); } if (!creditCode.equals(tenant.getUnifiedSocialCreditCode())) { @@ -314,6 +318,17 @@ public class ProfileResourceService { && !"SUPERSEDED".equals(qualification.getStatus()); } + private boolean platformCanUpdateQualifications(IamTenant tenant) { + return "PROFILE_REQUIRED".equals(tenant.getStatus()) || "REJECTED".equals(tenant.getStatus()) + || "ACTIVE".equals(tenant.getStatus()) || "DISABLED".equals(tenant.getStatus()); + } + + private void markPlatformQualificationApproved(IamTenant tenant, IamTenantQualification qualification) { + if ("ACTIVE".equals(tenant.getStatus()) || "DISABLED".equals(tenant.getStatus())) { + qualification.setStatus("APPROVED"); + } + } + private void requireMerchant(long tenantId) { IamTenant tenant = tenantRepository.findTenant(tenantId); if (tenant == null || tenant.getId() == null || tenant.getId().longValue() != tenantId diff --git a/iam/src/test/java/com/baiyue/iam/notification/NotificationRecipientResolverTest.java b/iam/src/test/java/com/baiyue/iam/notification/NotificationRecipientResolverTest.java index c388bbd20..3704b47cf 100644 --- a/iam/src/test/java/com/baiyue/iam/notification/NotificationRecipientResolverTest.java +++ b/iam/src/test/java/com/baiyue/iam/notification/NotificationRecipientResolverTest.java @@ -2,6 +2,7 @@ package com.baiyue.iam.notification; import com.baiyue.common.core.exception.BizException; import com.baiyue.iam.entity.IamMembership; +import com.baiyue.iam.entity.IamMembershipRole; import com.baiyue.iam.entity.IamRole; import com.baiyue.iam.entity.IamUser; import com.baiyue.iam.mapper.IamMembershipMapper; @@ -22,7 +23,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; class NotificationRecipientResolverTest { private final IamMembershipMapper memberships = mock(IamMembershipMapper.class); @@ -70,7 +71,7 @@ class NotificationRecipientResolverTest { @Test void explainsWhenRoleCodeDoesNotExist() { - when(roles.selectOne(any())).thenReturn(null); + when(roles.selectList(any())).thenReturn(List.of()); assertThatThrownBy(() -> resolver.resolve(roleRequest(RecipientType.PLATFORM_ROLE, "MISSING_ROLE"))) .isInstanceOf(BizException.class) @@ -81,7 +82,7 @@ class NotificationRecipientResolverTest { void explainsWhenRoleHasNoActiveMembers() { IamRole role = new IamRole(); role.setId(501L); - when(roles.selectOne(any())).thenReturn(role); + when(roles.selectList(any())).thenReturn(List.of(role)); when(membershipRoles.selectList(any())).thenReturn(List.of()); assertThatThrownBy(() -> resolver.resolve(roleRequest(RecipientType.PLATFORM_ROLE, "CUSTOMER_REVIEWER"))) @@ -89,6 +90,39 @@ class NotificationRecipientResolverTest { .hasMessage("该角色下暂无可接收消息的有效用户,请先为角色分配启用中的成员"); } + @Test + void resolvesEnterpriseMemberAssignedToPlatformProvidedMerchantRole() { + IamRole role = new IamRole(); + role.setId(501L); + role.setTenantId(0L); + role.setAppCode("MERCHANT"); + role.setRoleCode("MERCHANT_OWNER"); + IamMembershipRole assignment = new IamMembershipRole(); + assignment.setTenantId(9L); + assignment.setRoleId(role.getId()); + assignment.setMembershipId(301L); + IamMembership membership = new IamMembership(); + membership.setId(301L); + membership.setTenantId(9L); + membership.setUserId(401L); + membership.setStatus("ACTIVE"); + IamUser user = new IamUser(); + user.setId(401L); + user.setDisplayName("企业管理员"); + user.setStatus("ACTIVE"); + when(roles.selectList(any())).thenReturn(List.of(role)); + when(membershipRoles.selectList(any())).thenReturn(List.of(assignment)); + when(memberships.selectBatchIds(List.of(301L))).thenReturn(List.of(membership)); + when(users.selectById(401L)).thenReturn(user); + + var recipients = resolver.resolve(new CreateNotificationRequest(9L, "ORDER_STATUS_CHANGED", + RecipientType.TENANT_ROLE, null, "MERCHANT_OWNER", Map.of(), null, + "notification-tenant-role-key", null, null)); + + assertThat(recipients).containsExactly(new NotificationRecipientResolver.ResolvedRecipient( + 9L, 301L, 401L, "企业管理员", "MERCHANT")); + } + private CreateNotificationRequest request(RecipientType recipientType, List recipientIds) { return new CreateNotificationRequest(9L, "ORDER_STATUS_CHANGED", recipientType, recipientIds, null, Map.of(), null, "notification-test-key", null, null); diff --git a/iam/src/test/java/com/baiyue/iam/notification/NotificationTemplateRendererTest.java b/iam/src/test/java/com/baiyue/iam/notification/NotificationTemplateRendererTest.java index 38ff24dcd..2fde97a4f 100644 --- a/iam/src/test/java/com/baiyue/iam/notification/NotificationTemplateRendererTest.java +++ b/iam/src/test/java/com/baiyue/iam/notification/NotificationTemplateRendererTest.java @@ -7,6 +7,6 @@ import java.util.Set; import static org.assertj.core.api.Assertions.*; class NotificationTemplateRendererTest { private final NotificationTemplateRenderer renderer=new NotificationTemplateRenderer(); - @Test void rendersOnlyWhitelistedVariables(){assertThat(renderer.render("Order {{order_no}} is {{status}}",Set.of("order_no","status"),Map.of("order_no","A1","status","ready"))).isEqualTo("Order A1 is ready");} - @Test void rejectsMissingAndUnknownVariables(){assertThatThrownBy(()->renderer.render("{{order_no}}",Set.of("order_no"),Map.of())).isInstanceOf(BizException.class).hasMessageContaining("NOTIFICATION_VARIABLE_MISSING");assertThatThrownBy(()->renderer.render("ok",Set.of(),Map.of("secret","x"))).isInstanceOf(BizException.class).hasMessageContaining("NOTIFICATION_VARIABLE_NOT_ALLOWED");} + @Test void rendersOnlyWhitelistedVariables(){assertThat(renderer.render("Order ${order_no} is {{status}}",Set.of("order_no","status"),Map.of("order_no","A1","status","ready"))).isEqualTo("Order A1 is ready");} + @Test void rejectsMissingAndUnknownVariables(){assertThatThrownBy(()->renderer.render("${order_no}",Set.of("order_no"),Map.of())).isInstanceOf(BizException.class).hasMessageContaining("NOTIFICATION_VARIABLE_MISSING");assertThatThrownBy(()->renderer.render("ok",Set.of(),Map.of("secret","x"))).isInstanceOf(BizException.class).hasMessageContaining("NOTIFICATION_VARIABLE_NOT_ALLOWED");} } diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformQualificationRegistrationTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformQualificationRegistrationTest.java index 38c2d7ec3..c79a4429e 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformQualificationRegistrationTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformQualificationRegistrationTest.java @@ -1,6 +1,7 @@ package com.baiyue.iam.service; import com.baiyue.iam.dto.OnboardingDtos.PlatformQualificationRegistrationRequest; +import com.baiyue.iam.dto.OnboardingDtos.RequiredQualificationPackageRequest; import com.baiyue.iam.entity.IamTenant; import com.baiyue.iam.entity.IamTenantQualification; import com.baiyue.iam.repository.ProfileResourceRepository; @@ -69,4 +70,51 @@ class PlatformQualificationRegistrationTest { assertEquals(existing.getDocumentUrl(), response.replacedDocumentUrl()); assertEquals("PENDING", captor.getAllValues().get(1).getStatus()); } + + @Test + void platformCanReplaceAnApprovedOptionalQualificationForAnActiveTenant() { + IamTenant tenant = new IamTenant(); + tenant.setId(10001L); tenant.setTenantType("MERCHANT"); tenant.setStatus("ACTIVE"); + IamTenantQualification existing = new IamTenantQualification(); + existing.setId(88L); existing.setTenantId(10001L); existing.setQualificationType("BANK_ACCOUNT_LICENSE"); + existing.setStatus("APPROVED"); + existing.setDocumentUrl("/api/merchant/v1/files/qualifications/old-file.pdf"); + when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant); + when(repository.findQualificationsForUpdate(10001L)).thenReturn(List.of(existing)); + + var response = service.registerPlatformQualification(new PlatformQualificationRegistrationRequest( + 10001L, "BANK_ACCOUNT_LICENSE", null, "/api/merchant/v1/files/qualifications/new-file.pdf", + "new-file.pdf", LocalDate.of(2031, 12, 31), true)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(IamTenantQualification.class); + verify(repository, times(2)).saveQualification(captor.capture()); + assertEquals("SUPERSEDED", existing.getStatus()); + assertEquals("APPROVED", captor.getAllValues().get(1).getStatus()); + assertEquals("ACTIVE", response.tenantStatus()); + assertEquals(existing.getDocumentUrl(), response.replacedDocumentUrl()); + } + + @Test + void platformCanReplaceAnApprovedBusinessLicenseForAnActiveTenant() { + IamTenant tenant = new IamTenant(); + tenant.setId(10001L); tenant.setTenantType("MERCHANT"); tenant.setStatus("ACTIVE"); + tenant.setUnifiedSocialCreditCode("91310101ABCDE12345"); + IamTenantQualification existing = new IamTenantQualification(); + existing.setId(88L); existing.setTenantId(10001L); existing.setQualificationType("BUSINESS_LICENSE"); + existing.setStatus("APPROVED"); + existing.setDocumentUrl("/api/merchant/v1/files/qualifications/old-license.pdf"); + when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant); + when(repository.findQualificationsForUpdate(10001L)).thenReturn(List.of(existing)); + + var response = service.registerPlatformRequiredQualificationPackage(new RequiredQualificationPackageRequest( + 10001L, "91310101ABCDE12345", "/api/merchant/v1/files/qualifications/new-license.pdf", + "new-license.pdf", LocalDate.of(2031, 12, 31), true, null, null, null, null)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(IamTenantQualification.class); + verify(repository, times(2)).saveQualification(captor.capture()); + assertEquals("SUPERSEDED", existing.getStatus()); + assertEquals("APPROVED", captor.getAllValues().get(1).getStatus()); + assertEquals("ACTIVE", response.tenantStatus()); + assertEquals(existing.getDocumentUrl(), response.replacedBusinessLicenseDocumentUrl()); + } } diff --git a/order/src/main/java/com/baiyue/order/service/CustomerAddressService.java b/order/src/main/java/com/baiyue/order/service/CustomerAddressService.java index b6c22abf3..5dae76824 100644 --- a/order/src/main/java/com/baiyue/order/service/CustomerAddressService.java +++ b/order/src/main/java/com/baiyue/order/service/CustomerAddressService.java @@ -33,6 +33,7 @@ public class CustomerAddressService { @Transactional public Long create(long tenantId, long userId, CustomerAddressDtos.SaveRequest request) { + ensureNoDuplicate(tenantId, request, null); CustomerAddressEntity value = new CustomerAddressEntity(); value.setId(IdWorker.getId()); value.setTenantId(tenantId); @@ -51,6 +52,7 @@ public class CustomerAddressService { if (!value.getAddressType().equals(request.addressType())) { throw new BizException(ResultCode.VALIDATION_FAILED, "地址类型创建后不可修改"); } + ensureNoDuplicate(tenantId, request, id); if (request.defaultAddress()) clearDefault(tenantId, request.addressType(), id); apply(value, request); value.setUpdatedBy(userId); @@ -99,6 +101,25 @@ public class CustomerAddressService { .set(CustomerAddressEntity::getDefaultAddress, false)); } + private void ensureNoDuplicate(long tenantId, CustomerAddressDtos.SaveRequest request, Long excludedId) { + boolean duplicate = mapper.selectList(new LambdaQueryWrapper() + .eq(CustomerAddressEntity::getTenantId, tenantId) + .eq(CustomerAddressEntity::getActive, true) + .eq(CustomerAddressEntity::getAddressType, request.addressType()) + .eq(CustomerAddressEntity::getCompanyName, request.companyName().trim()) + .eq(CustomerAddressEntity::getContactName, request.contactName().trim()) + .eq(CustomerAddressEntity::getPhone, request.phone().trim()) + .eq(CustomerAddressEntity::getCountryCode, request.countryCode().trim().toUpperCase()) + .eq(CustomerAddressEntity::getCity, request.city().trim()) + .eq(CustomerAddressEntity::getAddressLine, request.addressLine().trim()) + .ne(excludedId != null, CustomerAddressEntity::getId, excludedId)) + .stream().findAny().isPresent(); + if (duplicate) { + throw new BizException(ResultCode.CONFLICT, "PICKUP".equals(request.addressType()) + ? "相同的企业提货地址已存在" : "相同的企业私人仓地址已存在"); + } + } + private static void apply(CustomerAddressEntity value, CustomerAddressDtos.SaveRequest source) { value.setAddressType(source.addressType()); value.setAddressName(source.addressName().trim()); value.setCompanyName(source.companyName().trim()); value.setContactName(source.contactName().trim()); diff --git a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java index bacfda9e7..71cdef183 100644 --- a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java +++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java @@ -324,11 +324,11 @@ public class UnifiedOrderQueryService { order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), null, null, order.getInboundCode(), order.getExpectedShipDate(), null, null, totalPackageCount(cargo), null, null, - order.getEstimatedVolumeCbm(), volumeCbm(cargo), null, null, + order.getEstimatedVolumeCbm(), volumeCbm(cargo), grossWeightKg(cargo), volumeCbm(cargo), order.getExpectedArrivalAt(), null, null, null, order.getFulfillmentType(), order.getLxCode(), order.getPickupMethod(), order.getTaxMode(), order.getContainerMode(), order.getCustomsInspection(), order.getDestinationType(), order.getAmazonWarehouseCode(), - null, null, null, null, null, null, null, order.getExpressCompany(), order.getExpressTrackingNo(), + null, null, null, null, netWeightKg(cargo), null, null, order.getExpressCompany(), order.getExpressTrackingNo(), StringUtils.hasText(creatorName) ? creatorName : string(order.getCreatedBy()), skuNos(cargo), totalQuantity(cargo), shipmentId(order, cargo), referenceId(order, cargo), null, null, null, null, null, coLoadGroup, order.getCustomerRemark(), latestInternalNote, order.getWarehouseRemark(), order.getCustomerServiceRemark(), @@ -348,13 +348,14 @@ public class UnifiedOrderQueryService { displayedPackageCount(order, cargo, inbound), null, distinctLocationCodes(inbound.locationCodes()), order.getEstimatedVolumeCbm(), volumeCbm(cargo), - inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(), + measuredOrDeclared(inbound.actualGrossWeightKg(), grossWeightKg(cargo)), + measuredOrDeclared(inbound.actualVolumeCbm(), volumeCbm(cargo)), order.getExpectedArrivalAt() != null ? order.getExpectedArrivalAt() : inbound.expectedArrivalAt(), inbound.feeReviewStatus(), inbound.companyName(), inbound.businessType(), inbound.fulfillmentType(), inbound.lxCode(), order.getPickupMethod(), inbound.taxMode(), inbound.containerMode(), inbound.customsInspection(), inbound.destinationType(), inbound.destinationCode(), inbound.palletCount(), inbound.chargeableWeightKg(), inbound.chargeableVolumeCbm(), inbound.volumeWeightKg(), - inbound.actualNetWeightKg(), + measuredOrDeclared(inbound.actualNetWeightKg(), netWeightKg(cargo)), inbound.measureCompletedAt(), inbound.inboundedAt(), inbound.expressCompanyName(), inbound.expressTrackingNo(), StringUtils.hasText(creatorName) ? creatorName : inbound.createdByName(), skuNos(cargo), totalQuantity(cargo), shipmentId(order, cargo), referenceId(order, cargo), measurement(inbound), preload(summary.preload()), loading(summary.loading()), @@ -378,13 +379,14 @@ public class UnifiedOrderQueryService { inbound.id(), inbound.version(), inbound.inboundCode(), null, inbound.holdFlag(), inbound.currentHoldReason(), displayedPackageCount(order, cargo, inbound), null, distinctLocationCodes(inbound.locationCodes()), order == null ? null : order.getEstimatedVolumeCbm(), volumeCbm(cargo), - inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(), + measuredOrDeclared(inbound.actualGrossWeightKg(), grossWeightKg(cargo)), + measuredOrDeclared(inbound.actualVolumeCbm(), volumeCbm(cargo)), order != null && order.getExpectedArrivalAt() != null ? order.getExpectedArrivalAt() : inbound.expectedArrivalAt(), inbound.feeReviewStatus(), inbound.companyName(), inbound.businessType(), inbound.fulfillmentType(), inbound.lxCode(), order == null ? null : order.getPickupMethod(), inbound.taxMode(), inbound.containerMode(), inbound.customsInspection(), inbound.destinationType(), inbound.destinationCode(), inbound.palletCount(), inbound.chargeableWeightKg(), inbound.chargeableVolumeCbm(), inbound.volumeWeightKg(), - inbound.actualNetWeightKg(), + measuredOrDeclared(inbound.actualNetWeightKg(), netWeightKg(cargo)), inbound.measureCompletedAt(), inbound.inboundedAt(), inbound.expressCompanyName(), inbound.expressTrackingNo(), inbound.createdByName(), skuNos(cargo), totalQuantity(cargo), shipmentId(order, cargo), referenceId(order, cargo), measurement(inbound), preload(summary.preload()), loading(summary.loading()), @@ -490,6 +492,10 @@ public class UnifiedOrderQueryService { return cargo == null ? null : cargo.volumeCbm(); } + private BigDecimal measuredOrDeclared(BigDecimal measured, BigDecimal declared) { + return measured == null ? declared : measured; + } + private record CargoSummary(List skuNos, List shipmentIds, List referenceIds, Integer totalQuantity, Integer totalPackageCount, BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm) { diff --git a/order/src/test/java/com/baiyue/order/service/CustomerAddressServiceTest.java b/order/src/test/java/com/baiyue/order/service/CustomerAddressServiceTest.java index 24ece6108..d09a76f54 100644 --- a/order/src/test/java/com/baiyue/order/service/CustomerAddressServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/CustomerAddressServiceTest.java @@ -7,9 +7,12 @@ import com.baiyue.order.mapper.CustomerAddressMapper; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; +import java.util.List; + import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -38,4 +41,21 @@ class CustomerAddressServiceTest { assertThatThrownBy(() -> service.required(10001L, 90001L, "PICKUP")) .isInstanceOf(BizException.class).hasMessage("客户地址不存在或已停用"); } + + @Test + void rejectsDuplicatePickupAddressBeforeCreatingAnotherRecord() { + CustomerAddressMapper mapper = mock(CustomerAddressMapper.class); + CustomerAddressEntity duplicate = new CustomerAddressEntity(); + duplicate.setId(90001L); + when(mapper.selectList(any())).thenReturn(List.of(duplicate)); + CustomerAddressService service = new CustomerAddressService(); + ReflectionTestUtils.setField(service, "mapper", mapper); + var request = new CustomerAddressDtos.SaveRequest("PICKUP", "深圳工厂", "柏悦物流", + "惠万昌", "13229608341", null, "CN", "江西省", "抚州市", null, + "崇仁县三山乡长仁大队廉家村", null, false); + + assertThatThrownBy(() -> service.create(10001L, 20001L, request)) + .isInstanceOf(BizException.class).hasMessage("相同的企业提货地址已存在"); + verify(mapper, never()).insert(any(CustomerAddressEntity.class)); + } } diff --git a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java index 106ff2e31..272ed6f56 100644 --- a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java @@ -127,7 +127,7 @@ class UnifiedOrderQueryServiceTest { } @Test - void newlyCreatedOrderUsesItsOwnCargoPackageCountBeforeInboundExists() { + void newlyCreatedOrderUsesItsOwnCargoTotalsBeforeInboundExists() { UnifiedOrderQuery query = query(null); OrderEntity order = order(OrderStatus.APPROVED); OrderCargoEntity cargo = new OrderCargoEntity(); @@ -144,13 +144,39 @@ class UnifiedOrderQueryServiceTest { assertThat(service.page(10001L, query).records()).singleElement().satisfies(row -> { assertThat(row.totalPackageCount()).isEqualTo(100); assertThat(row.expectedVolumeCbm()).isEqualByComparingTo("0.375"); - assertThat(row.actualGrossWeightKg()).isNull(); - assertThat(row.totalNetWeightKg()).isNull(); - assertThat(row.actualVolumeCbm()).isNull(); + assertThat(row.actualGrossWeightKg()).isEqualByComparingTo("12.5"); + assertThat(row.totalNetWeightKg()).isEqualByComparingTo("11.2"); + assertThat(row.actualVolumeCbm()).isEqualByComparingTo("0.375"); }); verify(orders).findCargoByOrderIds(10001L, List.of(77L)); } + @Test + void unmeasuredInboundFallsBackToDeclaredCargoTotals() { + UnifiedOrderQuery query = query(null); + OrderEntity order = order(OrderStatus.APPROVED); + OrderCargoEntity cargo = new OrderCargoEntity(); + cargo.setOrderId(77L); + cargo.setPackageCount(50); + cargo.setGrossWeightKg(new BigDecimal("1500")); + cargo.setNetWeightKg(new BigDecimal("25")); + cargo.setVolumeCbm(new BigDecimal("7.5")); + var summary = summary("WAIT_MEASURE", null); + when(summary.inbound().actualGrossWeightKg()).thenReturn(null); + when(summary.inbound().actualNetWeightKg()).thenReturn(null); + when(summary.inbound().actualVolumeCbm()).thenReturn(null); + when(orders.unifiedPage(eq(10001L), eq(query), any())) + .thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1)); + when(firstleg.summaries(eq("internal-token"), any())).thenReturn(ApiResponse.success(List.of(summary))); + when(orders.findCargoByOrderIds(10001L, List.of(77L))).thenReturn(List.of(cargo)); + + assertThat(service.page(10001L, query).records()).singleElement().satisfies(row -> { + assertThat(row.actualGrossWeightKg()).isEqualByComparingTo("1500"); + assertThat(row.totalNetWeightKg()).isEqualByComparingTo("25"); + assertThat(row.actualVolumeCbm()).isEqualByComparingTo("7.5"); + }); + } + @Test void dropshipMixedSkuRowsCountSharedCartonVolumeOnlyOnce() { UnifiedOrderQuery query = query(null); -- 2.54.0 From 4f59f7bd286c1aeb04f379af5a431f6bfbdbb6d9 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 18 Sep 2026 09:59:07 +0800 Subject: [PATCH 04/16] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 6 +- firstleg/AGENTS.md | 2 +- .../mapper/ContainerBookingMapper.java | 3 - .../oceanbooking/dto/OceanBookingDtos.java | 2 +- .../mapper/OceanBookingMapper.java | 2 +- .../repository/OceanBookingRepository.java | 9 ++- .../service/OceanBookingService.java | 18 ++++- .../repository/ContainerRepository.java | 3 +- .../firstleg/service/ContainerService.java | 12 ++- .../firstleg/service/LoadingListService.java | 9 ++- .../db/phase3-ocean-booking-schema.sql | 13 ++++ .../db/phase3-smart-loading-schema.sql | 17 ++++- .../main/resources/openapi/firstleg-v1.yaml | 4 +- .../resources/openapi/ocean-bookings-v1.yaml | 5 +- .../ContainerControllerContractTest.java | 2 +- .../OceanBookingMapperContractTest.java | 1 + .../OceanBookingSqlContractTest.java | 12 +++ .../service/OceanBookingServiceTest.java | 14 +++- .../ContainerBookingMapperContractTest.java | 8 +- .../SmartLoadingSqlContractTest.java | 3 +- .../service/ContainerServiceTest.java | 10 +-- .../service/LoadingListServiceTest.java | 9 ++- iam/src/main/resources/openapi/iam-v1.yaml | 2 +- ...urnInventoryPermissionSqlContractTest.java | 2 +- ...seasCustomerPermissionSqlContractTest.java | 2 +- lastleg-service/AGENTS.md | 2 +- .../controller/LastlegOrderController.java | 3 +- .../lastleg/dto/LastlegOrderRequests.java | 6 -- .../mapper/InboundPlanOrderMapper.java | 2 +- .../pda/dto/ReceivingContainerRequests.java | 4 +- .../service/ReceivingContainerService.java | 15 +++- .../service/InboundPlanService.java | 2 - .../mapper/InventoryCommandMapper.java | 6 ++ .../service/InventoryCommandService.java | 11 +++ .../service/LastlegOrderCommandService.java | 12 +++ .../lastleg/service/LastlegOrderService.java | 43 ----------- .../openapi/overseas-business-v1.yaml | 18 ++--- .../pda/ReceivingContainerContractTest.java | 2 + ...ReceivingContainerServiceBehaviorTest.java | 73 +++++++++++++++++++ .../ContainerInboundPlanPreviewTest.java | 16 ++++ .../service/InboundPlanServiceTest.java | 16 ++-- .../command/InventoryCommandServiceTest.java | 17 +++++ .../MerchantOverseasOpenApiContractTest.java | 23 ++++++ .../LastlegOrderCommandServiceTest.java | 37 ++++++++++ .../service/LastlegOrderServiceTest.java | 37 ---------- 45 files changed, 358 insertions(+), 157 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 680ecd368..302100797 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -1438,7 +1438,7 @@ CREATE TABLE fl_consumed_event ( CREATE TABLE fl_container_booking ( id bigint unsigned NOT NULL COMMENT '货柜预订ID(雪花ID)', booking_no varchar(40) NOT NULL COMMENT '平台唯一货柜预订单号', - container_no varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '集装箱号;确认前允许修订', + container_no varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '真实集装箱号权威快照;同一订舱内唯一,不同提单允许复用;确认前允许修订', cabinet_no varchar(64) NOT NULL COMMENT '平台唯一内部柜号', source_type varchar(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜', record_status varchar(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '记录状态:ACTIVE可操作、MERGED已合并历史', @@ -1494,7 +1494,8 @@ CREATE TABLE fl_container_booking ( PRIMARY KEY (id), UNIQUE KEY uk_container_booking_no (booking_no), UNIQUE KEY uk_container_booking_cabinet (cabinet_no), - UNIQUE KEY uk_container_booking_container_no (container_no), + UNIQUE KEY uk_container_booking_ocean_container (ocean_booking_id,container_no), + KEY idx_container_booking_container_no (container_no,updated_at,id), KEY idx_container_booking_merge (record_status,merged_into_container_booking_id), KEY idx_container_booking_warehouse (warehouse_id,updated_at,id), KEY idx_container_booking_voyage (voyage_no,updated_at), @@ -3368,6 +3369,7 @@ CREATE TABLE fl_ocean_booking ( version int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', PRIMARY KEY (id), UNIQUE KEY uk_ocean_booking_no (booking_no), + UNIQUE KEY uk_ocean_booking_bill_of_lading (bill_of_lading_no), KEY idx_ocean_booking_customer (customer_id,status,updated_at,id), KEY idx_ocean_booking_voyage (vessel_name,voyage_no,updated_at), CONSTRAINT chk_ocean_booking_carrier_code CHECK (((carrier_code is null) or (carrier_code in (_utf8mb4'MATSON',_utf8mb4'SML',_utf8mb4'ZIM',_utf8mb4'COSCO',_utf8mb4'EMC',_utf8mb4'HEDE',_utf8mb4'OTHER')))), diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index de489a7d2..1365faa92 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -73,7 +73,7 @@ - Order 渠道变更门禁必须检查装柜或后续状态、PDA task、预装、分柜及同柜组兼容性;任何不确定结果均失败关闭。`ORDER_DECLARATION_UPDATED_V1` 消费必须校验订单号/入仓码归属,永久保留 `eventId + payload hash` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。 - 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。 - 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。 -- 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。 +- 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。提单号在订舱主表全局唯一,柜次表只保存快照;真实集装箱号只在同一订舱内唯一,不同提单允许复用。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。 - 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。 - 拖车预约是平台级订舱聚合,不是客户或柜次资源:请求、实体、查询、响应和附件不得恢复 `customerId/customerName`,公开 DTO 也不得出现 `containerBookingId/loadingPlanId/cabinetNo/containerNo`。创建候选只返回有提单号、ACTIVE `BOOKING_CONFIRMATION` 或 `BILL_OF_LADING` 且没有非终态拖车单的 `RELEASED` 订舱;创建或编辑只冻结订舱仓库/提单与 TRUCKING/DOMESTIC 国内拖车供应商快照。每订舱通过查询锁与唯一约束最多一张非终态预约,重复创建返回现有派车单号;PDA 装柜完成不得改变拖车状态;只有订舱全部柜型需求均已关联且每柜全部入仓绑定确认国内离仓,才自动完成(含草稿/晚提交)。离仓、拖车创建/编辑/操作统一先锁订舱,完成状态、`AUTO_COMPLETE` 历史、按实装体积守恒分摊的柜次应付及 Finance Outbox 同事务落库;取消/异常单不自动恢复,已有有效或完成单不得重复创建。应付拖车费仅人民币 RMB(财务快照为 CNY),固定使用 `FEE_TYPE_108`,关联 ACTIVE/TRUCKING/DOMESTIC 供应商,零金额不生成费用;正金额缺归属、有效体积或费用配置时失败关闭,不丢费、不伪造均分。手工 DEPART/COMPLETE 动作已移除。附件使用 `tenantId=0 + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId`,提单文件继续归订舱。 - 海外仓业务船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/海外仓业务/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 的正常业务查询槽位固定为 `Asia/Shanghai` 每日 09:00、12:30、16:00;每分钟任务只扫描已到期 `next_allowed_at`,不是每分钟请求船司。成功、无变化、需人工或永久失败进入下一固定槽位;仅技术性 `RETRYABLE_FAILURE` 在当前槽内按15/30分钟退避,含首次最多3次且不得跨下一槽,实际 `EMPTY_RETURNED` 后停止。错过的正常槽位直接顺延,不在服务恢复时补跑;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在国内货柜履约另建查询任务。 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java index f503a3a7f..66605e865 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java @@ -168,9 +168,6 @@ public interface ContainerBookingMapper extends BaseMapper { @Select(""" ") List balances(@Param("customerId") String customerId, @Param("q") InventoryQueryRequests.BalanceQuery query, @Param("offset") long offset, @Param("limit") long limit); - @Select("") + @Select("") long countBalances(@Param("customerId") String customerId, @Param("q") InventoryQueryRequests.BalanceQuery query); + @Select("") + List sourceBalances(@Param("customerId") String customerId, + @Param("q") InventoryQueryRequests.BalanceQuery query, + @Param("offset") long offset, + @Param("limit") long limit); + @Select(" + """) + int touchForOrderRemarks(@Param("planIds") List planIds, + @Param("operatorId") long operatorId, + @Param("operatorName") String operatorName); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/OrderDeclarationSyncService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/OrderDeclarationSyncService.java index 1230bc021..0134d90ad 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/OrderDeclarationSyncService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/OrderDeclarationSyncService.java @@ -25,6 +25,7 @@ import java.util.Objects; public class OrderDeclarationSyncService { @Resource private OrderDeclarationSyncMapper mapper; @Resource private ObjectMapper json; + @Resource private OrderRemarksSyncService remarksSync; @Transactional(readOnly = true) public ChannelChangeValidationResponse validateChannelChange(ChannelChangeValidationRequest request) { @@ -86,6 +87,8 @@ public class OrderDeclarationSyncService { normalizeChannel(event.channelCode()), event.channelName(), event.operatorId()) != 1) { throw conflict("Firstleg 入库申报快照已发生变化"); } + remarksSync.syncCustomerRemark(event.tenantId(), event.orderId(), event.customerRemark(), + event.operatorId(), "order-declaration"); } private void recordEvent(UpdatedEvent event, String hash) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/OrderRemarksSyncService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/OrderRemarksSyncService.java new file mode 100644 index 000000000..0e2eb434a --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/OrderRemarksSyncService.java @@ -0,0 +1,57 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.firstleg.dto.OrderRemarksSync; +import com.baiyue.firstleg.mapper.LoadingPlanItemMapper; +import com.baiyue.firstleg.mapper.LoadingPlanMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +public class OrderRemarksSyncService { + @Resource private LoadingPlanItemMapper items; + @Resource private LoadingPlanMapper plans; + + @Transactional + public int sync(OrderRemarksSync.Command command) { + return sync(command, null, null); + } + + @Transactional + public int sync(OrderRemarksSync.Command command, Long alreadyTouchedPlanId) { + return sync(command, alreadyTouchedPlanId, null); + } + + @Transactional + public int sync(OrderRemarksSync.Command command, Long alreadyTouchedPlanId, Long excludedItemId) { + List affectedPlanIds = items.findMutablePlanIdsWithDifferentRemarks( + command.tenantId(), command.orderId(), command.customerRemark(), command.internalRemark(), command.warehouseRemark(), + command.customerServiceRemark(), excludedItemId); + if (affectedPlanIds.isEmpty()) return 0; + int updated = items.syncMutableOrderRemarks(command.tenantId(), command.orderId(), + command.customerRemark(), command.internalRemark(), command.warehouseRemark(), command.customerServiceRemark(), + command.operatorId(), command.operatorName(), excludedItemId); + if (updated > 0) { + List plansToTouch = affectedPlanIds.stream() + .filter(planId -> !planId.equals(alreadyTouchedPlanId)).toList(); + if (!plansToTouch.isEmpty()) { + plans.touchForOrderRemarks(plansToTouch, command.operatorId(), command.operatorName()); + } + } + return updated; + } + + @Transactional + public int syncCustomerRemark(long tenantId, long orderId, String customerRemark, + long operatorId, String operatorName) { + List affectedPlanIds = items.findMutablePlanIdsWithDifferentCustomerRemark( + tenantId, orderId, customerRemark); + if (affectedPlanIds.isEmpty()) return 0; + int updated = items.syncMutableOrderCustomerRemark( + tenantId, orderId, customerRemark, operatorId, operatorName); + if (updated > 0) plans.touchForOrderRemarks(affectedPlanIds, operatorId, operatorName); + return updated; + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index 63791e182..5b9879d81 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -6,6 +6,7 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.exception.BizException; import com.baiyue.common.audit.BusinessAudit; import com.baiyue.firstleg.dto.SmartLoadingDtos.*; +import com.baiyue.firstleg.dto.OrderRemarksSync; import com.baiyue.firstleg.domain.SmartLoadingChannelGroupCatalog; import com.baiyue.firstleg.client.OrderCargoClient; import com.baiyue.firstleg.client.PendingReviewOrderClient; @@ -56,6 +57,7 @@ public class SmartLoadingService { @Resource private FbaPricingService fbaPricingService; @Resource private PriceBookQueryUseCase priceBooks; @Resource private FirstlegDocumentNumberGenerator documentNumbers; + @Resource private OrderRemarksSyncService orderRemarksSync; @Resource private ObjectMapper objectMapper; @Value("${baiyue.security.internal-service-token:}") private String internalToken; public SmartLoadingService() { } @@ -1099,6 +1101,9 @@ public class SmartLoadingService { LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion(), operatorId, operatorName); LoadingPlanItemEntity item = repository.item(planId, itemId); if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在"); + if (item.getVersion() != request.expectedItemVersion()) { + throw conflict("装柜计划或明细已被其他人修改,请刷新后重试"); + } if (Objects.equals(item.getInternalRemark(), internalRemark) && Objects.equals(item.getWarehouseRemark(), warehouseRemark) && Objects.equals(item.getCustomerServiceRemark(), customerServiceRemark)) { @@ -1109,16 +1114,30 @@ public class SmartLoadingService { "version", item.getVersion())); try { touch(plan, operatorId, operatorName); + ApiResponse response = orderCargoClient.updateRemarks( + internalToken, item.getAccountId(), item.getOrderId(), + new OrderCargoClient.RemarksUpdateRequest(internalRemark, warehouseRemark, + customerServiceRemark, operatorId)); + if (response == null || !response.successful() || response.data() == null) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, + response == null ? "订单备注服务暂不可用" : response.message()); + } if (repository.updateRemarks(planId, itemId, request.expectedPlanVersion() + 1, - request.expectedItemVersion(), internalRemark, warehouseRemark, customerServiceRemark, - operatorId, operatorName) != 1) { + request.expectedItemVersion(), response.data().internalRemark(), response.data().warehouseRemark(), + response.data().customerServiceRemark(), operatorId, operatorName) != 1) { throw conflict("装柜计划或明细已被其他人修改,请刷新后重试"); } + orderRemarksSync.sync(new OrderRemarksSync.Command(item.getAccountId(), item.getOrderId(), + response.data().customerRemark(), + response.data().internalRemark(), response.data().warehouseRemark(), + response.data().customerServiceRemark(), operatorId, operatorName), planId, itemId); } catch (DataAccessException exception) { if (hasSqlState(exception, "45000")) { throw conflict("装柜计划或明细状态已变化,备注未保存,请刷新后重试"); } throw exception; + } catch (FeignException exception) { + throw unavailable(); } history(planId, itemId, "REMARKS", plan.getPlanStatus(), plan.getPlanStatus(), operation, before, json(values("internalRemark", internalRemark, "warehouseRemark", warehouseRemark, diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index ec999ea0b..226880cc9 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -292,6 +292,32 @@ paths: schema: {$ref: '#/components/schemas/OrderDeclarationUpdatedEvent'} responses: '200': {description: 成功、幂等重放或旧版本已忽略;冲突时响应体 code=A0409} + /internal/firstleg/v1/inbounds/order-remarks-updated: + post: + summary: 同步 Order 权威协作备注到未冻结预装明细 + description: >- + 仅接受内部服务令牌。按客户与订单关联全部 EDITING/LOADING 且未冻结的预装明细, + 同步内部、仓库和客服备注并递增受影响计划与明细版本;已完成装柜的冻结快照不修改。 + security: + - internalServiceToken: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [tenantId, orderId, operatorId] + properties: + tenantId: {type: integer, format: int64, minimum: 1} + orderId: {type: integer, format: int64, minimum: 1} + customerRemark: {type: string, nullable: true, maxLength: 1000} + internalRemark: {type: string, nullable: true, maxLength: 1000} + warehouseRemark: {type: string, nullable: true, maxLength: 1000} + customerServiceRemark: {type: string, nullable: true, maxLength: 1000} + operatorId: {type: integer, format: int64, minimum: 1} + operatorName: {type: string, nullable: true, maxLength: 128} + responses: + '200': {description: data 返回已同步的预装明细数量} /internal/firstleg/v1/inbounds/unified-orders/query: post: summary: 按统一订单条件分页查询履约摘要(Order 内部) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java index 93c24882d..5cd1aec90 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java @@ -7,6 +7,7 @@ import com.baiyue.firstleg.service.InboundOrderCreationService; import com.baiyue.firstleg.service.InboundCartonLabelService; import com.baiyue.firstleg.service.UnifiedInboundQueryService; import com.baiyue.firstleg.service.OrderDeclarationSyncService; +import com.baiyue.firstleg.service.OrderRemarksSyncService; import com.baiyue.firstleg.dto.UnifiedInboundQuery; import com.baiyue.firstleg.dto.OrderDeclarationSync; import com.baiyue.common.core.api.PageResult; @@ -36,6 +37,8 @@ class InternalInboundOrderReferenceControllerTest { private UnifiedInboundQueryService unifiedQueryService; @MockBean private OrderDeclarationSyncService declarationSync; + @MockBean + private OrderRemarksSyncService remarksSync; @Resource private InternalInboundOrderController controller; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/OrderDeclarationSyncServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/OrderDeclarationSyncServiceTest.java index 98445bcd8..50cf520ef 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/OrderDeclarationSyncServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/OrderDeclarationSyncServiceTest.java @@ -35,6 +35,7 @@ class OrderDeclarationSyncServiceTest { } @MockBean private OrderDeclarationSyncMapper mapper; + @MockBean private OrderRemarksSyncService remarksSync; @Resource private OrderDeclarationSyncService service; @Resource private ObjectMapper json; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/OrderRemarksSyncServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/OrderRemarksSyncServiceTest.java new file mode 100644 index 000000000..01ea81046 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/OrderRemarksSyncServiceTest.java @@ -0,0 +1,50 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.firstleg.dto.OrderRemarksSync; +import com.baiyue.firstleg.mapper.LoadingPlanItemMapper; +import com.baiyue.firstleg.mapper.LoadingPlanMapper; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(OrderRemarksSyncService.class) +class OrderRemarksSyncServiceTest { + @MockBean private LoadingPlanItemMapper items; + @MockBean private LoadingPlanMapper plans; + + @jakarta.annotation.Resource private OrderRemarksSyncService service; + + @Test + void updatesAllMutableOrderItemsAndTouchesAffectedPlans() { + var command = new OrderRemarksSync.Command(10L, 20L, "客户", "内部", "仓库", "客服", 30L, "操作人"); + when(items.findMutablePlanIdsWithDifferentRemarks( + 10L, 20L, "客户", "内部", "仓库", "客服", null)).thenReturn(List.of(101L, 102L)); + when(items.syncMutableOrderRemarks( + 10L, 20L, "客户", "内部", "仓库", "客服", 30L, "操作人", null)).thenReturn(3); + + assertThat(service.sync(command)).isEqualTo(3); + + verify(plans).touchForOrderRemarks(List.of(101L, 102L), 30L, "操作人"); + } + + @Test + void leavesFrozenOrAlreadyMatchingPlansUntouched() { + var command = new OrderRemarksSync.Command(10L, 20L, null, null, null, null, 30L, null); + when(items.findMutablePlanIdsWithDifferentRemarks( + 10L, 20L, null, null, null, null, null)).thenReturn(List.of()); + + assertThat(service.sync(command)).isZero(); + + verify(items, never()).syncMutableOrderRemarks( + 10L, 20L, null, null, null, null, 30L, null, null); + verify(plans, never()).touchForOrderRemarks(org.mockito.ArgumentMatchers.anyList(), + org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.nullable(String.class)); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index 11fb0cca6..e1065048f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -102,6 +102,7 @@ class SmartLoadingServiceTest { @MockBean private PriceBookQueryUseCase priceBooks; @MockBean private FirstlegDocumentNumberGenerator documentNumbers; @MockBean private DropshipPalletizationMapper dropshipPallets; + @MockBean private OrderRemarksSyncService orderRemarksSync; @Resource private SmartLoadingService service; @TestConfiguration @@ -119,6 +120,12 @@ class SmartLoadingServiceTest { when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn( ApiResponse.success(new OrderCargoClient.CargoBinding(20L, 30L, 1, "货物", 5, new BigDecimal("10"), new BigDecimal("2")))); + when(cargoClient.updateRemarks(anyString(), anyLong(), anyLong(), any())) + .thenAnswer(invocation -> { + OrderCargoClient.RemarksUpdateRequest request = invocation.getArgument(3); + return ApiResponse.success(new OrderCargoClient.RemarksUpdateResponse(2, null, + request.internalRemark(), request.warehouseRemark(), request.customerServiceRemark())); + }); when(fbaPricingService.recalculateUnlockedContainerCustomerFees(anyLong(), anyLong(), anyString())) .thenReturn(new FbaPricingService.ContainerFeeRecalculationResult( FbaPricingService.ContainerFeeRecalculationStatus.NOT_READY, 0)); @@ -1955,6 +1962,7 @@ class SmartLoadingServiceTest { private static LoadingPlanItemEntity item(long id, long account, long detail, int boxes) { LoadingPlanItemEntity value = new LoadingPlanItemEntity(); value.setId(id); value.setLoadingPlanId(1L); value.setAccountId(account); + value.setOrderId(30L); value.setOrderNo("O30"); value.setInboundId(10L); value.setInboundDetailId(detail); value.setPackageCount(boxes); value.setVersion(0); value.setDeleted(false); return value; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java index 8949e7696..c150e4407 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java @@ -16,7 +16,9 @@ public final class DeliveryResponses { List deliveries, boolean replayed) {} public record OutboundRow(Long id, String outboundNo, String dispatchNo, String overseasWarehouseId, String lxCodes, - String status, int palletCount, int packageCount, LocalDateTime createdAt, String outboundType) {} + String status, int palletCount, int packageCount, BigDecimal grossWeightKg, + BigDecimal volumeWeightKg, BigDecimal volumeCbm, + LocalDateTime createdAt, String outboundType) {} public record DeliveryRow(Long id, String customerId, String customerName, String deliveryNo, String outboundNo, String dispatchNo, String overseasWarehouseId, @@ -51,7 +53,9 @@ public final class DeliveryResponses { /** palletNo is the warehouse-authoritative physical pallet code after inbound assignment. */ public record OutboundPalletRow(Long id, String palletNo, String cartonMarks, String inboundCode, String destinationWarehouseCode, String destinationWarehouseName, - String skuCode, int packageCount, int pickedPackageCount, String status) {} + String skuCode, int packageCount, BigDecimal grossWeightKg, + BigDecimal volumeWeightKg, BigDecimal volumeCbm, + int pickedPackageCount, String status) {} public record OutboundDetail(OutboundRow outbound, List deliveries, List pallets) {} public record OneClickCandidate(Long inventoryPositionId, String orderNo, String customerId, String customerName, String overseasWarehouseId, String overseasWarehouseName, String palletNo, String inboundCode, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundOrder.java index 0c088baaf..e598fdf53 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundOrder.java @@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; +import java.math.BigDecimal; import java.time.LocalDateTime; /** Outbound document created when a dispatch is transferred to delivery. */ @@ -24,6 +25,9 @@ public class OutboundOrder { private String status; private Integer packageCount; private Integer palletCount; + private BigDecimal grossWeightKg; + private BigDecimal volumeWeightKg; + private BigDecimal volumeCbm; private String idempotencyKey; private String requestHash; private String createdBy; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPalletItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPalletItem.java index 658c97bfa..6c69084c3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPalletItem.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPalletItem.java @@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; +import java.math.BigDecimal; import java.time.LocalDateTime; /** Pallet-level immutable snapshot belonging to one outbound document. */ @@ -21,6 +22,9 @@ public class OutboundPalletItem { private String destinationWarehouseCode; private String destinationWarehouseName; private Integer packageCount; + private BigDecimal grossWeightKg; + private BigDecimal volumeWeightKg; + private BigDecimal volumeCbm; private Integer pickedPackageCount; private String status; private String skuCode; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index 157f915ae..eed41070d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -165,7 +165,11 @@ public class DeliveryTransferService { outbound.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); outbound.setStatus("WAIT_OUTBOUND"); outbound.setOutboundType("DISPATCH_DELIVERY"); outbound.setLxCodes(resolveLxCodes(customerId, items)); outbound.setPackageCount(items.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); - outbound.setPalletCount(items.size()); outbound.setIdempotencyKey(request.idempotencyKey().trim()); + outbound.setPalletCount(items.size()); + outbound.setGrossWeightKg(sum(items, DispatchItem::getGrossWeightKg)); + outbound.setVolumeWeightKg(sum(items, DispatchItem::getVolumeWeightKg)); + outbound.setVolumeCbm(sum(items, DispatchItem::getVolumeCbm)); + outbound.setIdempotencyKey(request.idempotencyKey().trim()); outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); try { outboundOrders.insert(outbound); @@ -190,7 +194,9 @@ public class DeliveryTransferService { item.setDestinationWarehouseId(source.getDestinationWarehouseId()); item.setDestinationWarehouseCode(source.getDestinationWarehouseCode()); item.setDestinationWarehouseName(source.getDestinationWarehouseName()); - item.setPackageCount(Objects.requireNonNullElse(source.getPackageCount(), 0)); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK"); + item.setPackageCount(Objects.requireNonNullElse(source.getPackageCount(), 0)); + item.setGrossWeightKg(source.getGrossWeightKg()); item.setVolumeWeightKg(source.getVolumeWeightKg()); + item.setVolumeCbm(source.getVolumeCbm()); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK"); if (StringUtils.hasText(source.getCartonMarks())) { item.setCartonMarks(source.getCartonMarks()); } else if (source.getWarehousePalletId() != null) { @@ -236,8 +242,8 @@ public class DeliveryTransferService { delivery.setSellerName(trim(request.sellerName())); delivery.setPlannedDeliveryAt(request.plannedDeliveryAt()); delivery.setPackageCount(groupItems.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); delivery.setPalletCount(groupItems.size()); - delivery.setTotalVolumeCbm(groupItems.stream().map(DispatchItem::getVolumeCbm).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add)); - delivery.setTotalWeightKg(totalWeight(customerId, groupItems)); delivery.setExpectedArrivalAt(dispatch.getEstimatedArrivalAt()); delivery.setRemark(trim(request.remark())); + delivery.setTotalVolumeCbm(sum(groupItems, DispatchItem::getVolumeCbm)); + delivery.setTotalWeightKg(sum(groupItems, DispatchItem::getGrossWeightKg)); delivery.setExpectedArrivalAt(dispatch.getEstimatedArrivalAt()); delivery.setRemark(trim(request.remark())); delivery.setIdempotencyKey(childKey(request.idempotencyKey(), "DEST-" + groupIndex++)); delivery.setCreatedBy(operatorId); delivery.setCreatedAt(now); delivery.setUpdatedAt(now); deliveryOrders.insert(delivery); recordOperation(customerId, delivery.getId(), operatorId, "CREATE_DELIVERY", "按目的FBA仓生成派送单", now); @@ -1171,11 +1177,12 @@ public class DeliveryTransferService { d.getDeliveryNo(), d.getStatus(), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName(), value(d.getPalletCount()), value(d.getPackageCount()))).toList(), replayed); } - private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), o.getLxCodes(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getCreatedAt(), o.getOutboundType()); } + private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), o.getLxCodes(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getGrossWeightKg(), o.getVolumeWeightKg(), o.getVolumeCbm(), o.getCreatedAt(), o.getOutboundType()); } private DeliveryResponses.OutboundPalletRow outboundPalletRow(OutboundPalletItem item) { return new DeliveryResponses.OutboundPalletRow(item.getId(), item.getPalletNo(), item.getCartonMarks(), item.getInboundCode(), item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), - item.getSkuCode(), value(item.getPackageCount()), value(item.getPickedPackageCount()), item.getStatus()); + item.getSkuCode(), value(item.getPackageCount()), item.getGrossWeightKg(), item.getVolumeWeightKg(), + item.getVolumeCbm(), value(item.getPickedPackageCount()), item.getStatus()); } private DeliveryResponses.OneClickCandidate oneClickCandidate(DropshipOutboundCandidate candidate) { return new DeliveryResponses.OneClickCandidate(candidate.getInventoryPositionId(), candidate.getOrderNo(), candidate.getCustomerId(), candidate.getCustomerName(), @@ -1235,26 +1242,9 @@ public class DeliveryTransferService { Math.addExact(quantity, other.quantity)); } } - private BigDecimal totalWeight(String customerId, List items) { - List palletIds = items.stream().map(DispatchItem::getWarehousePalletId) - .filter(Objects::nonNull).distinct().toList(); - List transferPallets = palletIds.isEmpty() ? List.of() - : warehouseTransferPallets.selectList(new LambdaQueryWrapper() - .eq(WarehouseTransferPallet::getCustomerId, customerId) - .in(WarehouseTransferPallet::getWarehousePalletId, palletIds)); - Set measuredPalletIds = transferPallets.stream() - .map(WarehouseTransferPallet::getWarehousePalletId).collect(java.util.stream.Collectors.toSet()); - BigDecimal measuredWeight = transferPallets.stream().map(WarehouseTransferPallet::getGrossWeightKg) - .filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add); - List orderIds = items.stream() - .filter(item -> item.getWarehousePalletId() == null || !measuredPalletIds.contains(item.getWarehousePalletId())) - .map(DispatchItem::getOrderId).filter(Objects::nonNull).distinct().toList(); - if (orderIds.isEmpty()) return measuredWeight; - BigDecimal orderWeight = tailOrderDetails.selectList(new LambdaQueryWrapper() - .eq(LastlegOrderDetail::getCustomerId, customerId).in(LastlegOrderDetail::getOrderId, orderIds)) - .stream().map(LastlegOrderDetail::getGrossWeightKg).filter(Objects::nonNull) - .reduce(BigDecimal.ZERO, BigDecimal::add); - return measuredWeight.add(orderWeight); + private static BigDecimal sum(List items, + java.util.function.Function getter) { + return items.stream().map(getter).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add); } private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } private String hash(Object value) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java index 7de25f0a8..b487c19b0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java @@ -17,6 +17,7 @@ public record DispatchResponse(Long id, String dispatchNo, String vehiclePlate, String overseasWarehouseId, String overseasWarehouseName, String orderNo, String orderRefs, String inboundCode, String destinationWarehouseCode, String destinationWarehouseName, String fbaWarehouseCode, int packageCount, + BigDecimal grossWeightKg, BigDecimal volumeWeightKg, BigDecimal volumeCbm, String sourceStage) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java index d3e4a1505..df89323c0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java @@ -36,6 +36,8 @@ public class DispatchItem { private String contactName; private String contactPhone; private Integer packageCount; + private BigDecimal grossWeightKg; + private BigDecimal volumeWeightKg; private BigDecimal volumeCbm; /** PLAN for planned pallets; RECEIVED for warehouse/entity pallets. */ private String sourceStage; 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 23a900d18..8336fa19c 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 @@ -410,6 +410,8 @@ public class DispatchService { item.setContactName(candidate.contactName()); item.setContactPhone(candidate.contactPhone()); item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); + item.setGrossWeightKg(Objects.requireNonNullElse(candidate.grossWeightKg(), BigDecimal.ZERO)); + item.setVolumeWeightKg(Objects.requireNonNullElse(candidate.volumeWeightKg(), BigDecimal.ZERO)); item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); item.setSourceStage(candidate.sourceStage()); item.setCartonMarks(mergeCartonMarks(candidate.cartonMarks(), candidate.relabelCartonMarks())); @@ -457,7 +459,7 @@ public class DispatchService { DispatchOrder dispatch = new DispatchOrder(); dispatch.setId(IdWorker.getId()); dispatch.setCustomerId(customerId); dispatch.setDispatchNo(orderNoGenerator.next("DISPATCH", warehouse, dispatch.getId())); dispatch.setOverseasWarehouseId(warehouse); dispatch.setOverseasWarehouseName(selected.get(0).overseasWarehouseName()); dispatch.setRoute(candidateRoute(selected)); dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); dispatch.setStatus(PENDING_DEPARTURE); dispatch.setItemCount(selected.size()); dispatch.setPalletCount(selected.size()); dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); dispatch.setPickedPackageCount(0); dispatch.setShippedPackageCount(0); dispatch.setCreatedBy(trimToNull(operatorId)); dispatch.setCreatedAt(now); dispatch.setUpdatedAt(now); orders.insert(dispatch); - for (DispatchPalletCandidate candidate : selected) { DispatchItem item = new DispatchItem(); item.setId(IdWorker.getId()); item.setDispatchOrderId(dispatch.getId()); item.setCustomerId(customerId); item.setWarehousePalletId(candidate.warehousePalletId()); item.setOrderId(candidate.orderId()); item.setOverseasWarehouseId(candidate.overseasWarehouseId()); item.setOverseasWarehouseName(candidate.overseasWarehouseName()); item.setOrderNo(candidate.orderNo()); item.setOrderRefs(candidate.orderRefs()); item.setInboundCode(candidate.inboundCode()); item.setPalletNo(candidate.palletNo()); item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); item.setDestinationWarehouseName(candidate.destinationWarehouseName()); item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); item.setSourceStage("RELABEL"); item.setCartonMarks(candidate.relabelCartonMarks()); item.setSourceBusinessType("RELABEL_ORDER"); item.setSourceBusinessNo(request.relabelNo()); item.setCreatedAt(now); items.insert(item); } + for (DispatchPalletCandidate candidate : selected) { DispatchItem item = new DispatchItem(); item.setId(IdWorker.getId()); item.setDispatchOrderId(dispatch.getId()); item.setCustomerId(customerId); item.setWarehousePalletId(candidate.warehousePalletId()); item.setOrderId(candidate.orderId()); item.setOverseasWarehouseId(candidate.overseasWarehouseId()); item.setOverseasWarehouseName(candidate.overseasWarehouseName()); item.setOrderNo(candidate.orderNo()); item.setOrderRefs(candidate.orderRefs()); item.setInboundCode(candidate.inboundCode()); item.setPalletNo(candidate.palletNo()); item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); item.setDestinationWarehouseName(candidate.destinationWarehouseName()); item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); item.setGrossWeightKg(Objects.requireNonNullElse(candidate.grossWeightKg(), BigDecimal.ZERO)); item.setVolumeWeightKg(Objects.requireNonNullElse(candidate.volumeWeightKg(), BigDecimal.ZERO)); item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); item.setSourceStage("RELABEL"); item.setCartonMarks(candidate.relabelCartonMarks()); item.setSourceBusinessType("RELABEL_ORDER"); item.setSourceBusinessNo(request.relabelNo()); item.setCreatedAt(now); items.insert(item); } DispatchPdaTask task = new DispatchPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setDispatchOrderId(dispatch.getId()); task.setTaskNo("DPDA" + task.getId()); task.setStatus("PDA_PENDING"); task.setWarehouseId(warehouse); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task); if (candidates.markRelabelPreDispatched(customerId, request.relabelNo()) != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单已被其他排车占用"); return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId())); @@ -653,7 +655,8 @@ public class DispatchService { item.getWarehousePalletId(), item.getPalletNo(), item.getOverseasWarehouseId(), item.getOverseasWarehouseName(), item.getOrderNo(), item.getOrderRefs(), item.getInboundCode(), item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), item.getFbaWarehouseCode(), - Objects.requireNonNullElse(item.getPackageCount(), 0), item.getVolumeCbm(), item.getSourceStage())).toList(), + Objects.requireNonNullElse(item.getPackageCount(), 0), item.getGrossWeightKg(), + item.getVolumeWeightKg(), item.getVolumeCbm(), item.getSourceStage())).toList(), bolLineList == null ? List.of() : bolLineList); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java index ee0c3a2da..ede78069f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java @@ -47,6 +47,10 @@ public record LastlegOrderSummary( Integer receivedPackageCount, @Schema(description = "实收减预报箱数;尚未完成收货时为空") Integer packageDifference, + @Schema(description = "头程冻结货物毛重(千克)") + BigDecimal grossWeightKg, + @Schema(description = "头程冻结货物净重(千克)") + BigDecimal netWeightKg, BigDecimal volumeCbm, Integer productQuantity, BigDecimal volumeWeightKg, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanListResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanListResponse.java index efb5922a1..4311dbccc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanListResponse.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanListResponse.java @@ -22,6 +22,8 @@ public record InboundPlanListResponse( String customerName, Integer packageCount, Integer pickedPackageCount, + BigDecimal grossWeightKg, + BigDecimal netWeightKg, BigDecimal volumeCbm, Integer palletCount, boolean assigned, @@ -39,7 +41,8 @@ public record InboundPlanListResponse( public InboundPlanListResponse withReceivingWarehouse(String warehouseCode, String warehouseName) { return new InboundPlanListResponse(lineId, planId, planNo, customerId, orderId, inboundCode, lxCode, orderType, overseasWarehouseId, warehouseCode, warehouseName, warehouseId, this.warehouseCode, - this.warehouseName, fbaWarehouseCode, customerName, packageCount, pickedPackageCount, volumeCbm, + this.warehouseName, fbaWarehouseCode, customerName, packageCount, pickedPackageCount, + grossWeightKg, netWeightKg, volumeCbm, palletCount, assigned, mergeAllowed, urgent, status, appointmentNo, appointmentStatus, appointmentAt, expectedLoadingAt, dispatchStatus, createdAt, version); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java index f36ce2706..8bcd713b7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java @@ -19,6 +19,8 @@ public record InboundPlanResponse( String status, int lineCount, int palletCount, + BigDecimal totalGrossWeightKg, + BigDecimal totalNetWeightKg, BigDecimal totalVolumeCbm, int expectedPackageCount, int pickedPackageCount, @@ -38,7 +40,8 @@ public record InboundPlanResponse( ) { public record Line(Long id, Long orderId, String inboundCode, String fbaWarehouseCode, String destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, - BigDecimal volumeCbm, int requiredPackageCount, int plannedPalletCount) { + BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, + int requiredPackageCount, int plannedPalletCount) { } public record Pallet(Long id, String palletNo, String destinationWarehouseId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java index 88fa7dc8f..0569b86d0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java @@ -17,6 +17,8 @@ public interface InboundPlanOrderMapper { record ReceivingWarehouseSnapshot(String overseasWarehouseId, String overseasWarehouseCode, String overseasWarehouseName) { } record DetailOrderSnapshot(String orderNo, String customerName, String orderType) { } + record OrderWeightSnapshot(Long orderId, java.math.BigDecimal grossWeightKg, + java.math.BigDecimal netWeightKg) { } record ContainerWarehouseRef(String customerId, Long orderId, String sourceContainerBookingId, String overseasWarehouseId) { } record ContainerPreviewOrderRow( @@ -116,6 +118,17 @@ public interface InboundPlanOrderMapper { DetailOrderSnapshot findDetailOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId); + @Select("") + List findOrderWeights(@Param("customerId") String customerId, + @Param("orderIds") List orderIds); + @Update("UPDATE ll_order SET inbound_order_no=#{planNo},current_status='INBOUND_PLANNED'," + "pallet_merge_flag='MERGED',pallet_count=#{palletCount},updated_at=#{updatedAt} " + "WHERE customer_id=#{customerId} AND id=#{orderId}") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java index d15879ebf..f428897ce 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java @@ -43,6 +43,10 @@ public interface InboundPlanQueryMapper { "l.destination_warehouse_name warehouseName,l.fba_warehouse_code fbaWarehouseCode," + "o.customer_name customerName,l.required_package_count packageCount," + "COALESCE((SELECT SUM(pa.picked_package_count) FROM ll_inbound_plan_pallet_allocation pa WHERE pa.line_id=l.id),0) pickedPackageCount," + + "COALESCE((SELECT SUM(d.gross_weight_kg) FROM ll_order_detail d WHERE d.order_id=l.order_id AND d.customer_id=p.customer_id)," + + "(SELECT i.gross_weight_kg FROM ll_inbound_order i WHERE i.id=l.order_id AND i.customer_id=p.customer_id),0) grossWeightKg," + + "COALESCE((SELECT SUM(d.net_weight_kg) FROM ll_order_detail d WHERE d.order_id=l.order_id AND d.customer_id=p.customer_id)," + + "(SELECT i.net_weight_kg FROM ll_inbound_order i WHERE i.id=l.order_id AND i.customer_id=p.customer_id),0) netWeightKg," + "l.volume_cbm volumeCbm," + "l.planned_pallet_count palletCount,p.assigned_flag assigned,p.merge_required mergeAllowed," + "p.urgent_flag urgent,p.status status,a.appointment_no appointmentNo," + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java index 1af3955b6..31483e4be 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -1077,6 +1077,16 @@ public class InboundPlanService { } List lines = lineMapper.selectList(new LambdaQueryWrapper() .eq(InboundPlanLine::getPlanId, plan.getId()).orderByAsc(InboundPlanLine::getId)); + Map weights = lines.isEmpty() ? Map.of() + : orderMapper.findOrderWeights(plan.getCustomerId(), lines.stream().map(InboundPlanLine::getOrderId) + .filter(Objects::nonNull).distinct().toList()).stream() + .collect(Collectors.toMap(InboundPlanOrderMapper.OrderWeightSnapshot::orderId, value -> value)); + BigDecimal totalGrossWeightKg = weights.values().stream() + .map(InboundPlanOrderMapper.OrderWeightSnapshot::grossWeightKg).filter(Objects::nonNull) + .reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal totalNetWeightKg = weights.values().stream() + .map(InboundPlanOrderMapper.OrderWeightSnapshot::netWeightKg).filter(Objects::nonNull) + .reduce(BigDecimal.ZERO, BigDecimal::add); List pallets = palletMapper.selectList(new LambdaQueryWrapper() .eq(InboundPlanPallet::getPlanId, plan.getId()).orderByAsc(InboundPlanPallet::getSequenceNo)); List allocations = allocationMapper.selectList( @@ -1116,11 +1126,14 @@ public class InboundPlanService { sourceOrder == null ? null : sourceOrder.customerName(), sourceOrder == null ? null : sourceOrder.orderType(), plan.getContainerNo(), authoritativeWarehouse.warehouseCode(), authoritativeWarehouse.warehouseName(), - plan.getStatus(), plan.getLineCount(), plan.getPalletCount(), plan.getTotalVolumeCbm(), + plan.getStatus(), plan.getLineCount(), plan.getPalletCount(), totalGrossWeightKg, + totalNetWeightKg, plan.getTotalVolumeCbm(), lines.stream().mapToInt(InboundPlanLine::getRequiredPackageCount).sum(), allocations.stream().mapToInt(InboundPlanPalletAllocation::getPickedPackageCount).sum(), lines.stream().map(line -> new InboundPlanResponse.Line(line.getId(), line.getOrderId(), line.getInboundCode(), line.getFbaWarehouseCode(), line.getDestinationWarehouseId(), line.getDestinationWarehouseCode(), line.getDestinationWarehouseName(), + weights.containsKey(line.getOrderId()) ? weights.get(line.getOrderId()).grossWeightKg() : BigDecimal.ZERO, + weights.containsKey(line.getOrderId()) ? weights.get(line.getOrderId()).netWeightKg() : BigDecimal.ZERO, line.getVolumeCbm(), line.getRequiredPackageCount(), line.getPlannedPalletCount())).toList(), pallets.stream().map(pallet -> new InboundPlanResponse.Pallet(pallet.getId(), pallet.getPalletNo(), pallet.getDestinationWarehouseId(), pallet.getDestinationWarehouseCode(), pallet.getDestinationWarehouseName(), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java index 0e9bcf6c7..1d018878d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java @@ -12,6 +12,7 @@ import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; import java.util.List; +import java.math.BigDecimal; @Mapper public interface LastlegOrderMapper extends BaseMapper { @@ -27,6 +28,17 @@ public interface LastlegOrderMapper extends BaseMapper { record DispatchStatusRow(Long orderId, String dispatchStatus) { } + record OrderWeightRow(Long orderId, BigDecimal grossWeightKg, BigDecimal netWeightKg) { } + + @Select("") + List findOrderWeights(@Param("orderIds") List orderIds); + String CONTAINER_PROGRESS_FILTER = " FROM ll_order o LEFT JOIN ll_inbound_plan p " + "ON p.customer_id=o.customer_id AND p.source_order_id=o.id " + "WHERE o.source_container_booking_id IS NOT NULL AND o.source_container_booking_id<>'' " + 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 5ee77cd7b..49812a1c3 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 @@ -48,6 +48,7 @@ import java.nio.charset.StandardCharsets; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; +import java.math.BigDecimal; import java.time.LocalDateTime; import java.security.MessageDigest; import java.util.ArrayList; @@ -94,9 +95,11 @@ public class LastlegOrderService { Map> mergeRelations = palletMergeRelations(page.getRecords()); Map containerNodes = containerNodes(page.getRecords()); Map dispatchStatuses = dispatchStatuses(page.getRecords()); + Map weights = orderWeights(page.getRecords()); return new PageResult<>(page.getRecords().stream().map(order -> summary(order, mergeRelations.get(order.getId()), StringUtils.hasText(order.getSourceContainerBookingId()) - ? containerNodes.get(order.getSourceContainerBookingId()) : null, dispatchStatuses.getOrDefault(order.getId(), "UNSCHEDULED"))).toList(), + ? containerNodes.get(order.getSourceContainerBookingId()) : null, + dispatchStatuses.getOrDefault(order.getId(), "UNSCHEDULED"), weights.get(order.getId()))).toList(), page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); } @@ -349,7 +352,8 @@ public class LastlegOrderService { value.getNodeCode(), value.getNodeAt(), value.getSource())) .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")), manualNodes, cargo, history.stream() + containerNode(order), dispatchStatuses(List.of(order)).getOrDefault(order.getId(), "UNSCHEDULED"), + orderWeights(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() .map(value -> new LastlegOrderDetailResponse.Operation(value.getId(), value.getOperationNode(), @@ -625,8 +629,17 @@ public class LastlegOrderService { LastlegOrderMapper.DispatchStatusRow::dispatchStatus)); } + private Map orderWeights(List orders) { + if (orders.isEmpty()) return Map.of(); + List rows = orderMapper.findOrderWeights( + orders.stream().map(LastlegOrder::getId).toList()); + if (rows == null) return Map.of(); + return rows.stream().collect(Collectors.toMap(LastlegOrderMapper.OrderWeightRow::orderId, row -> row)); + } + private LastlegOrderSummary summary(LastlegOrder o, List relations, - String containerNode, String dispatchStatus) { + String containerNode, String dispatchStatus, + LastlegOrderMapper.OrderWeightRow weight) { List related = relations == null ? List.of() : relations; return new LastlegOrderSummary(o.getId(), displayOrderNo(o), o.getOrderType(), LastlegOrderType.labelOf(o.getOrderType()), o.getDestinationType(), o.getSourceInboundId(), @@ -637,7 +650,8 @@ public class LastlegOrderService { o.getAbnormal(), o.getDetained(), o.getHoldChargeable(), o.getHoldReason(), Boolean.TRUE.equals(o.getManualNodeOverride()), Boolean.TRUE.equals(o.getManualNodeOverride()) ? "MANUAL" : "CONTAINER", o.getVersion() == null ? 0 : o.getVersion(), o.getPalletCount(), o.getPackageCount(), - o.getReceivedPackageCount(), packageDifference(o), o.getVolumeCbm(), + o.getReceivedPackageCount(), packageDifference(o), weight == null ? BigDecimal.ZERO : weight.grossWeightKg(), + weight == null ? BigDecimal.ZERO : weight.netWeightKg(), o.getVolumeCbm(), o.getProductQuantity(), o.getVolumeWeightKg(), o.getEstimatedArrivalAt(), o.getActualArrivalAt(), o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), dispatchStatus, o.getRelabelOrderNo(), relabelBusinessStatus(o.getRelabelStatus()), o.getPalletMergeFlag(), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::mergeGroupNo).distinct().findFirst().orElse(null), diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 3d20c42e9..81285ffcb 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -1389,6 +1389,8 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_item ( contact_name VARCHAR(128) NULL COMMENT '私人仓联系人快照', contact_phone VARCHAR(64) NULL COMMENT '私人仓联系电话快照', package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + gross_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '毛重(千克)', + volume_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '材积重(千克)', volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '体积(立方米)', source_stage VARCHAR(32) NOT NULL COMMENT '数据来源阶段', carton_marks VARCHAR(4000) NULL COMMENT '换标箱唛映射快照', @@ -1405,6 +1407,7 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_item ( SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='carton_marks'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN carton_marks VARCHAR(4000) NULL COMMENT ''换标箱唛映射快照'' AFTER source_stage')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='source_business_type'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN source_business_type VARCHAR(32) NULL COMMENT ''来源业务类型'' AFTER carton_marks')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='source_business_no'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN source_business_no VARCHAR(128) NULL COMMENT ''来源业务单号'' AFTER source_business_type')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='gross_weight_kg'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN gross_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT ''毛重(千克)'' AFTER package_count, ADD COLUMN volume_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT ''材积重(千克)'' AFTER gross_weight_kg')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA或PRIVATE'' AFTER fba_warehouse_code, ADD COLUMN delivery_address VARCHAR(1000) NULL COMMENT ''私人仓目的地址快照'' AFTER destination_type, ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'' AFTER delivery_address, ADD COLUMN contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人快照'' AFTER postal_code, ADD COLUMN contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话快照'' AFTER contact_name')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; CREATE TABLE IF NOT EXISTS ll_dispatch_pallet_lock ( @@ -1507,6 +1510,8 @@ CREATE TABLE IF NOT EXISTS ll_outbound_order ( outbound_no VARCHAR(64) NOT NULL COMMENT '出库单号', dispatch_order_id BIGINT NULL COMMENT '派车单ID,一件代发为空', dispatch_no VARCHAR(64) NULL COMMENT '派车单号,一件代发为空', overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_OUTBOUND' COMMENT '业务状态', package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', pallet_count INT NOT NULL DEFAULT 0 COMMENT '托盘数', + gross_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '毛重(千克)', volume_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '材积重(千克)', + volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '体积(立方米)', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', created_by VARCHAR(128) NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', outbound_type VARCHAR(32) NOT NULL DEFAULT 'DISPATCH_DELIVERY' COMMENT '出库类型:派送转出库、一件代发', lx_codes VARCHAR(1000) NULL COMMENT '关联一件代发领星入库单代码汇总', @@ -1521,7 +1526,7 @@ CREATE TABLE IF NOT EXISTS ll_outbound_pallet_item ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', outbound_order_id BIGINT NOT NULL COMMENT '出库单ID', warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号', destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', - package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', picked_package_count INT NOT NULL DEFAULT 0 COMMENT '已拣箱数', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_PICK' COMMENT '拣货状态', sku_code VARCHAR(128) NULL COMMENT '一件代发SKU', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', gross_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '毛重(千克)', volume_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '材积重(千克)', volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '体积(立方米)', picked_package_count INT NOT NULL DEFAULT 0 COMMENT '已拣箱数', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_PICK' COMMENT '拣货状态', sku_code VARCHAR(128) NULL COMMENT '一件代发SKU', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), UNIQUE KEY uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id,inbound_code,sku_code), KEY idx_ll_outbound_pallet_list (customer_id,outbound_order_id), KEY idx_ll_outbound_pallet_destination (customer_id,destination_warehouse_code) @@ -1562,6 +1567,8 @@ CREATE TABLE IF NOT EXISTS ll_outbound_pda_pick ( SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='outbound_type'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN outbound_type VARCHAR(32) NOT NULL DEFAULT ''DISPATCH_DELIVERY'' COMMENT ''出库类型'' AFTER status')); PREPARE outbound_upgrade_stmt FROM @ddl; EXECUTE outbound_upgrade_stmt; DEALLOCATE PREPARE outbound_upgrade_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='gross_weight_kg'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN gross_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT ''毛重(千克)'' AFTER pallet_count, ADD COLUMN volume_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT ''材积重(千克)'' AFTER gross_weight_kg, ADD COLUMN volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''体积(立方米)'' AFTER volume_weight_kg')); PREPARE outbound_upgrade_stmt FROM @ddl; EXECUTE outbound_upgrade_stmt; DEALLOCATE PREPARE outbound_upgrade_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='gross_weight_kg'), 'SELECT 1', 'ALTER TABLE ll_outbound_pallet_item ADD COLUMN gross_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT ''毛重(千克)'' AFTER package_count, ADD COLUMN volume_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT ''材积重(千克)'' AFTER gross_weight_kg, ADD COLUMN volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''体积(立方米)'' AFTER volume_weight_kg')); PREPARE outbound_upgrade_stmt FROM @ddl; EXECUTE outbound_upgrade_stmt; DEALLOCATE PREPARE outbound_upgrade_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='lx_codes'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN lx_codes VARCHAR(1000) NULL COMMENT ''关联一件代发领星入库单代码汇总'' AFTER outbound_type')); PREPARE outbound_upgrade_stmt FROM @ddl; EXECUTE outbound_upgrade_stmt; DEALLOCATE PREPARE outbound_upgrade_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='sku_code'), 'SELECT 1', 'ALTER TABLE ll_outbound_pallet_item ADD COLUMN sku_code VARCHAR(128) NULL COMMENT ''一件代发SKU''')); 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 791bf1a1d..94b3a393c 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -185,7 +185,7 @@ paths: /api/platform/v1/last-leg/orders: get: summary: 分页查询尾程订单 - description: 权限 platform:overseas-warehouse:last-mile-order:list;平台管理员省略 customerId 时查询全部客户。containerNode 来自稳定 sourceContainerBookingId 对应的柜级船节点门禁。 + description: 权限 platform:overseas-warehouse:last-mile-order:list;平台管理员省略 customerId 时查询全部客户。containerNode 来自稳定 sourceContainerBookingId 对应的柜级船节点门禁;grossWeightKg/netWeightKg 由头程冻结货物明细聚合。 security: [{ bearerAuth: [] }] parameters: - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } @@ -206,7 +206,7 @@ paths: /api/platform/v1/last-leg/orders/{id}: get: summary: 查询尾程订单详情 - description: 权限 platform:overseas-warehouse:last-mile-order:list;返回订单、货物、状态历史、费用明细和操作记录。订单中的 containerNode 来自稳定 sourceContainerBookingId 对应的柜级船节点门禁。 + description: 权限 platform:overseas-warehouse:last-mile-order:list;返回订单、货物、状态历史、费用明细和操作记录。订单中的 containerNode 来自稳定 sourceContainerBookingId 对应的柜级船节点门禁;grossWeightKg/netWeightKg 由头程冻结货物明细聚合。 security: [{ bearerAuth: [] }] parameters: - { name: id, in: path, required: true, schema: { type: integer, format: int64 } } @@ -463,7 +463,7 @@ paths: /api/platform/v1/last-leg/inbound-plans: get: summary: 平台分页查询海外入库订单 - description: 权限 platform:overseas-warehouse:inbound-order:list;列表中的 overseasWarehouseCode/overseasWarehouseName 按入库计划冻结接柜仓ID从海外仓权威目录解析,warehouseCode/warehouseName 仍表示目的仓。 + description: 权限 platform:overseas-warehouse:inbound-order:list;overseasWarehouseCode/overseasWarehouseName 只取尾程订单预分配接柜仓事实,并按入库计划冻结接柜仓ID从海外仓权威目录解析;warehouseCode/warehouseName 仍表示目的仓。 security: [{ bearerAuth: [] }] parameters: - { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } } @@ -2272,6 +2272,8 @@ components: destinationWarehouseName: { type: string } fbaWarehouseCode: { type: string } packageCount: { type: integer } + grossWeightKg: { type: number, description: 排车托盘冻结毛重(千克) } + volumeWeightKg: { type: number, description: 排车托盘冻结材积重(千克) } volumeCbm: { type: number } sourceStage: { type: string, enum: [PLAN, RECEIVED] } cartonMarks: { type: string, nullable: true, description: 该托盘箱号,逗号分隔 } @@ -3006,7 +3008,7 @@ components: properties: { records: { type: array, items: { $ref: '#/components/schemas/InboundPlanSummary' } } } InboundPlanSummary: type: object - required: [lineId, planId, planNo, customerId, orderId, inboundCode, overseasWarehouseCode, overseasWarehouseName, packageCount, pickedPackageCount, palletCount, assigned, mergeAllowed, urgent, status, createdAt, version] + required: [lineId, planId, planNo, customerId, orderId, inboundCode, overseasWarehouseCode, overseasWarehouseName, packageCount, pickedPackageCount, grossWeightKg, netWeightKg, palletCount, assigned, mergeAllowed, urgent, status, createdAt, version] properties: lineId: { type: string, pattern: '^[0-9]+$' } planId: { type: string, pattern: '^[0-9]+$' } @@ -3025,6 +3027,8 @@ components: customerName: { type: string, nullable: true } packageCount: { type: integer } pickedPackageCount: { type: integer } + grossWeightKg: { type: number, description: 头程冻结货物毛重(千克) } + netWeightKg: { type: number, description: 头程冻结货物净重(千克) } volumeCbm: { type: number, nullable: true } palletCount: { type: integer } assigned: { type: boolean, description: 入库计划是否已分配 } @@ -3040,7 +3044,7 @@ components: version: { type: integer } InboundPlanDetail: type: object - required: [id, planNo, customerId, overseasWarehouseCode, overseasWarehouseName, status, lineCount, palletCount, totalVolumeCbm, expectedPackageCount, pickedPackageCount, lines, pallets, allocations, version, createdAt, updatedAt] + required: [id, planNo, customerId, overseasWarehouseCode, overseasWarehouseName, status, lineCount, palletCount, totalGrossWeightKg, totalNetWeightKg, totalVolumeCbm, expectedPackageCount, pickedPackageCount, lines, pallets, allocations, version, createdAt, updatedAt] properties: id: { type: string, pattern: '^[0-9]+$' } planNo: { type: string } @@ -3055,6 +3059,8 @@ components: status: { type: string } lineCount: { type: integer } palletCount: { type: integer } + totalGrossWeightKg: { type: number, description: 入库单关联头程货物毛重合计(千克) } + totalNetWeightKg: { type: number, description: 入库单关联头程货物净重合计(千克) } totalVolumeCbm: { type: number } expectedPackageCount: { type: integer } pickedPackageCount: { type: integer } @@ -3148,6 +3154,8 @@ components: destinationWarehouseId: { type: string, nullable: true } destinationWarehouseCode: { type: string, nullable: true } destinationWarehouseName: { type: string, nullable: true } + grossWeightKg: { type: number, description: 本入仓码头程冻结货物毛重(千克) } + netWeightKg: { type: number, description: 本入仓码头程冻结货物净重(千克) } volumeCbm: { type: number } requiredPackageCount: { type: integer } plannedPalletCount: { type: integer } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java index 64226bf1e..586c284ec 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java @@ -142,6 +142,8 @@ class DeliveryTransferServiceTest { assertEquals("WAIT_OUTBOUND", outbound.getValue().getStatus()); assertEquals(2, outbound.getValue().getPalletCount()); assertEquals(20, outbound.getValue().getPackageCount()); + assertEquals(new BigDecimal("20"), outbound.getValue().getGrossWeightKg()); + assertEquals(new BigDecimal("2.0"), outbound.getValue().getVolumeCbm()); assertEquals("transfer-101", outbound.getValue().getIdempotencyKey()); assertTrue(outbound.getValue().getOutboundNo().matches("OB-WH-\\d{8}-\\d{6}")); assertTrue(delivery.getAllValues().stream().allMatch(value -> outbound.getValue().getId().equals(value.getOutboundOrderId()))); @@ -152,6 +154,10 @@ class DeliveryTransferServiceTest { .map(OutboundPalletItem::getDestinationWarehouseCode).toList()); assertEquals(List.of("IN-A", "IN-B"), pallets.getAllValues().stream() .map(OutboundPalletItem::getInboundCode).toList()); + assertEquals(List.of(new BigDecimal("12"), new BigDecimal("8")), pallets.getAllValues().stream() + .map(OutboundPalletItem::getGrossWeightKg).toList()); + assertEquals(List.of(new BigDecimal("12"), new BigDecimal("8")), delivery.getAllValues().stream() + .map(DeliveryOrder::getTotalWeightKg).toList()); assertEquals(20, result.packageCount()); assertEquals(2, result.palletCount()); assertEquals(2, result.deliveries().size()); @@ -929,6 +935,9 @@ class DeliveryTransferServiceTest { value.setDestinationWarehouseId("DEST-" + destinationCode); value.setDestinationWarehouseCode(destinationCode); value.setDestinationWarehouseName(destinationName); value.setInboundCode(inboundCode); value.setOrderRefs("LMO-1"); value.setPackageCount(packages); + value.setGrossWeightKg(BigDecimal.valueOf(packages)); + value.setVolumeWeightKg(BigDecimal.valueOf(packages * 2L)); + value.setVolumeCbm(BigDecimal.valueOf(packages, 1)); value.setSourceStage("RECEIVED"); return value; } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java index d7f2e7558..fc143f7b2 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java @@ -434,7 +434,7 @@ class ContainerInboundPlanConversionCoordinatorTest { private InboundPlanResponse plan(String planNo, List pallets) { return new InboundPlanResponse(21L, planNo, "100", 11L, "FL-WH-1", "验收客户", "ONE_CLICK_DROPSHIP", "MSCU6639870", "US-LAX-01", - "Los Angeles", "PENDING_ASSIGNMENT", 1, 1, BigDecimal.ONE, 10, 0, + "Los Angeles", "PENDING_ASSIGNMENT", 1, 1, BigDecimal.TEN, new BigDecimal("8"), BigDecimal.ONE, 10, 0, List.of(), pallets, List.of(), 1, LocalDate.of(2026, 9, 10), "WOOD", false, false, false, "MANUAL", "自动生成", null, null); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanPaginationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanPaginationTest.java index 31be95377..3db84f19c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanPaginationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanPaginationTest.java @@ -52,7 +52,8 @@ class InboundPlanPaginationTest { LocalDateTime.of(2026, 8, 1, 0, 0), LocalDateTime.of(2026, 8, 31, 23, 59)); InboundPlanListResponse row = new InboundPlanListResponse(9007199254740993L, 2L, "LIP2", "100", 3L, "IN-1", null, "FIRST_LEG", 193000000000200003L, "ACY2", "错误目的仓", "W1", "ONT", "安大略仓", - "ONT8", "客户A", 10, 0, BigDecimal.ONE, 1, true, true, true, "PENDING_ASSIGNMENT", null, null, null, + "ONT8", "客户A", 10, 0, new BigDecimal("30"), new BigDecimal("22"), BigDecimal.ONE, + 1, true, true, true, "PENDING_ASSIGNMENT", null, null, null, null, "UNSCHEDULED", LocalDateTime.of(2026, 8, 20, 10, 0), 0); when(queryMapper.count("100", query)).thenReturn(11L); when(queryMapper.rows("100", query, 10, 10)).thenReturn(List.of(row)); @@ -87,6 +88,7 @@ class InboundPlanPaginationTest { "p.overseas_warehouse_id=#{q.overseasWarehouseId}"); assertThat(sql).contains("p.assigned_flag assigned", "p.merge_required mergeAllowed", "p.urgent_flag urgent"); + assertThat(sql).contains("grossWeightKg", "netWeightKg", "ll_order_detail"); assertThat(sql).doesNotContain("l.destination_warehouse_id=#{q.overseasWarehouseId}"); assertThat(sql).contains("q.planNo", "q.inboundCode", "q.status", "q.overseasWarehouseId", "q.destinationWarehouseCode", "q.warehouse", diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java index 347c291a7..3ff5b751c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java @@ -94,10 +94,13 @@ class LastlegOrderServiceTest { order.setSourceContainerBookingId("BOOKING-1"); LastlegOrderSummary summary = ReflectionTestUtils.invokeMethod( - service, "summary", order, List.of(), null, "UNSCHEDULED"); + service, "summary", order, List.of(), null, "UNSCHEDULED", + new LastlegOrderMapper.OrderWeightRow(11L, new java.math.BigDecimal("30"), new java.math.BigDecimal("22"))); assertNotNull(summary); assertEquals("BOOKING-1", summary.sourceContainerBookingId()); + assertEquals(new java.math.BigDecimal("30"), summary.grossWeightKg()); + assertEquals(new java.math.BigDecimal("22"), summary.netWeightKg()); } @BeforeEach diff --git a/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java b/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java index 7e72dd35a..739505aa2 100644 --- a/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java +++ b/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java @@ -36,6 +36,10 @@ public interface FirstlegInboundClient { ApiResponse updateDeclaration(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody OrderDeclarationDtos.UpdatedEvent event); + @PostMapping("/order-remarks-updated") + ApiResponse updateRemarks(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody OrderRemarksSyncCommand command); + @PostMapping("/order-channel-changes/validate") ApiResponse validateOrderChannelChange( @RequestHeader("X-Internal-Service-Token") String token, @@ -51,6 +55,9 @@ public interface FirstlegInboundClient { String channelCode) {} record OrderChannelChangeValidationResponse(long orderId, long orderVersion, String channelCode, String mainStatus) {} + record OrderRemarksSyncCommand(long tenantId, long orderId, String customerRemark, String internalRemark, + String warehouseRemark, String customerServiceRemark, + long operatorId, String operatorName) {} record OrderApprovedInboundCommand( long tenantId, long orderId, String orderNo, String inboundCode, Long customerId, String customerName, diff --git a/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java b/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java index fdddfec73..21f720bc3 100644 --- a/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java +++ b/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java @@ -16,6 +16,8 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -32,6 +34,7 @@ import java.util.stream.Collectors; @RequestMapping("/internal/order/v1/cargoes") public class InternalOrderCargoController { @Resource private OrderRepository orders; + @Resource private OrderApplicationService orderApplicationService; @Resource private PrintableInboundSnapshotService printableInboundSnapshots; @Value("${baiyue.security.internal-service-token}") private String internalToken; private byte[] expectedToken; @@ -146,6 +149,21 @@ public class InternalOrderCargoController { .collect(Collectors.joining("、")))); } + @PutMapping("/{tenantId}/{orderId}/remarks") + public ApiResponse updateRemarks( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long tenantId, @PathVariable long orderId, + @RequestBody InternalRemarksUpdateRequest request) { + requireToken(token); + if (tenantId <= 0 || orderId <= 0 || request == null || request.operatorId() <= 0) { + throw new BizException(ResultCode.BAD_REQUEST, "客户、订单和操作人必须大于0"); + } + var result = orderApplicationService.updateRemarksFromFirstleg(tenantId, request.operatorId(), orderId, + request.internalRemark(), request.warehouseRemark(), request.customerServiceRemark()); + return ApiResponse.success(new InternalRemarksUpdateResponse(result.version(), result.customerRemark(), + result.internalRemark(), result.warehouseRemark(), result.customerServiceRemark())); + } + private BigDecimal sum(List cargoes, java.util.function.Function getter) { List values = cargoes.stream().map(getter).filter(Objects::nonNull).toList(); @@ -205,4 +223,10 @@ public class InternalOrderCargoController { public record MeasurementDeclarationSnapshot( String referenceId, BigDecimal originalGrossWeightKg, BigDecimal originalVolumeCbm, String defaultActualGoodsName) {} + + public record InternalRemarksUpdateRequest(String internalRemark, String warehouseRemark, + String customerServiceRemark, long operatorId) {} + public record InternalRemarksUpdateResponse(int version, String customerRemark, String internalRemark, + String warehouseRemark, String customerServiceRemark) {} + } diff --git a/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java b/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java index 7c18df6c5..044907daa 100644 --- a/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java @@ -122,6 +122,7 @@ public class PlatformUnifiedOrderController { @PathVariable long orderId, @Valid @RequestBody OrderRequests.RemarksUpdateRequest request) { long scopedTenant = OrderScopeGuard.platform(user, tenantId); - return ApiResponse.success(orderService.updateRemarks(scopedTenant, user.userId(), orderId, request)); + return ApiResponse.success(orderService.updateRemarks( + scopedTenant, user.userId(), user.username(), orderId, request)); } } diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index 6620f5df0..3b349c2f7 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -271,6 +271,12 @@ public class OrderApplicationService { @Transactional public OrderRequests.RemarksUpdateResponse updateRemarks(long tenantId, long userId, long id, OrderRequests.RemarksUpdateRequest request) { + return updateRemarks(tenantId, userId, null, id, request); + } + + @Transactional + public OrderRequests.RemarksUpdateResponse updateRemarks(long tenantId, long userId, String operatorName, long id, + OrderRequests.RemarksUpdateRequest request) { OrderEntity order = require(tenantId, id); String internalRemark = normalizeRemark(request.internalRemark()); String warehouseRemark = normalizeRemark(request.warehouseRemark()); @@ -284,10 +290,57 @@ public class OrderApplicationService { note.setTenantId(tenantId); note.setOrderId(id); note.setContent(internalRemark); note.setCreatedBy(userId); note.setUpdatedBy(userId); repository.insertNote(note); } + syncRemarksToFirstleg(tenantId, id, order.getCustomerRemark(), internalRemark, warehouseRemark, customerServiceRemark, + userId, operatorName); return new OrderRequests.RemarksUpdateResponse(order.getVersion() + 1, order.getCustomerRemark(), internalRemark, warehouseRemark, customerServiceRemark); } + @Transactional + public OrderRequests.RemarksUpdateResponse updateRemarksFromFirstleg(long tenantId, long userId, long id, + String internalValue, + String warehouseValue, + String customerServiceValue) { + requireRemarkLength(internalValue); + requireRemarkLength(warehouseValue); + requireRemarkLength(customerServiceValue); + OrderEntity order = require(tenantId, id); + String internalRemark = normalizeRemark(internalValue); + String warehouseRemark = normalizeRemark(warehouseValue); + String customerServiceRemark = normalizeRemark(customerServiceValue); + if (repository.updateRemarks(tenantId, id, order.getVersion(), userId, + internalRemark, warehouseRemark, customerServiceRemark) != 1) { + throw new BizException(ResultCode.CONFLICT, "订单备注已被其他人修改,请刷新后重试"); + } + if (!Objects.equals(order.getInternalRemark(), internalRemark) && internalRemark != null) { + OrderInternalNoteEntity note = new OrderInternalNoteEntity(); + note.setTenantId(tenantId); note.setOrderId(id); note.setContent(internalRemark); + note.setCreatedBy(userId); note.setUpdatedBy(userId); repository.insertNote(note); + } + return new OrderRequests.RemarksUpdateResponse(order.getVersion() + 1, order.getCustomerRemark(), + internalRemark, warehouseRemark, customerServiceRemark); + } + + private void syncRemarksToFirstleg(long tenantId, long orderId, String customerRemark, String internalRemark, + String warehouseRemark, String customerServiceRemark, + long operatorId, String operatorName) { + FirstlegInboundClient client = firstlegProvider == null ? null : firstlegProvider.getIfAvailable(); + if (client == null) return; + ApiResponse response = client.updateRemarks(internalToken, + new FirstlegInboundClient.OrderRemarksSyncCommand(tenantId, orderId, customerRemark, internalRemark, + warehouseRemark, customerServiceRemark, operatorId, operatorName)); + if (response == null || !response.successful()) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, + response == null ? "头程备注同步暂不可用" : response.message()); + } + } + + private static void requireRemarkLength(String value) { + if (value != null && value.length() > 1000) { + throw new BizException(ResultCode.BAD_REQUEST, "每项备注不能超过1000个字符"); + } + } + private static String normalizeRemark(String value) { if (value == null || value.trim().isEmpty()) return null; return value.trim(); diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index eb5873cb7..3270cf299 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -59,6 +59,34 @@ paths: application/json: schema: {$ref: '#/components/schemas/CargoLabelBindingListEnvelope'} '403': {description: 内部服务令牌无效} + put: + security: [] + summary: Firstleg 维护订单权威协作备注 + description: >- + 仅接受内部服务令牌。预装清单编辑备注时写回 Order 权威订单记录; + 客户备注保持只读,内部、仓库和客服备注统一按订单版本乐观更新。 + parameters: + - name: X-Internal-Service-Token + in: header + required: true + schema: {type: string} + - {name: tenantId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: orderId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [operatorId] + properties: + internalRemark: {type: string, nullable: true, maxLength: 1000} + warehouseRemark: {type: string, nullable: true, maxLength: 1000} + customerServiceRemark: {type: string, nullable: true, maxLength: 1000} + operatorId: {type: integer, format: int64, minimum: 1} + responses: + '200': {description: 返回订单最新版本和四类备注} + '403': {description: 内部服务令牌无效} /internal/order/v1/cargoes/{tenantId}/{orderId}/printable-inbound-snapshot: post: security: [] diff --git a/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java b/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java index 99608e18e..e144c7c38 100644 --- a/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java +++ b/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java @@ -30,6 +30,7 @@ import org.springframework.web.bind.annotation.PostMapping; @TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") class InternalOrderCargoControllerTest { @MockBean private OrderRepository orders; + @MockBean private OrderApplicationService orderApplicationService; @MockBean private PrintableInboundSnapshotService printableInboundSnapshots; @Resource private InternalOrderCargoController controller; diff --git a/order/src/test/java/com/baiyue/order/service/OrderRemarksSynchronizationTest.java b/order/src/test/java/com/baiyue/order/service/OrderRemarksSynchronizationTest.java new file mode 100644 index 000000000..7c1eb5513 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/OrderRemarksSynchronizationTest.java @@ -0,0 +1,53 @@ +package com.baiyue.order.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.order.client.FirstlegInboundClient; +import com.baiyue.order.dto.OrderRequests; +import com.baiyue.order.entity.OrderEntity; +import com.baiyue.order.repository.OrderRepository; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class OrderRemarksSynchronizationTest { + @Test + void platformUpdateSynchronizesAuthoritativeRemarksToFirstleg() { + OrderRepository repository = mock(OrderRepository.class); + FirstlegInboundClient firstleg = mock(FirstlegInboundClient.class); + @SuppressWarnings("unchecked") + ObjectProvider provider = mock(ObjectProvider.class); + OrderApplicationService service = new OrderApplicationService(); + ReflectionTestUtils.setField(service, "repository", repository); + ReflectionTestUtils.setField(service, "firstlegProvider", provider); + ReflectionTestUtils.setField(service, "internalToken", "internal-token"); + + OrderEntity order = new OrderEntity(); + order.setId(20L); + order.setTenantId(10L); + order.setVersion(7); + order.setCustomerRemark("客户备注"); + when(repository.findOwned(10L, 20L)).thenReturn(order); + when(repository.updateRemarks(10L, 20L, 7, 30L, "内部", "仓库", "客服")).thenReturn(1); + when(provider.getIfAvailable()).thenReturn(firstleg); + when(firstleg.updateRemarks(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(2)); + + var response = service.updateRemarks(10L, 30L, "操作人", 20L, + new OrderRequests.RemarksUpdateRequest(7, " 内部 ", "仓库", "客服")); + + assertThat(response.version()).isEqualTo(8); + ArgumentCaptor command = + ArgumentCaptor.forClass(FirstlegInboundClient.OrderRemarksSyncCommand.class); + verify(firstleg).updateRemarks(org.mockito.ArgumentMatchers.eq("internal-token"), command.capture()); + assertThat(command.getValue().customerRemark()).isEqualTo("客户备注"); + assertThat(command.getValue().internalRemark()).isEqualTo("内部"); + assertThat(command.getValue().warehouseRemark()).isEqualTo("仓库"); + assertThat(command.getValue().customerServiceRemark()).isEqualTo("客服"); + } +} -- 2.54.0 From 98d15c30bbfee60407753dbb0bf170595eb3e274 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 18 Sep 2026 15:23:36 +0800 Subject: [PATCH 12/16] fix: carry partial dropship measurements to outbound --- .../LoadingCompletedOutboxServiceTest.java | 26 +++++++++++ .../delivery/dto/DeliveryResponses.java | 3 +- .../service/DeliveryTransferService.java | 46 ++++++++++++++++--- .../mapper/DropshipOrderProfileMapper.java | 8 +++- .../model/DropshipOutboundCandidate.java | 7 +++ .../openapi/overseas-business-v1.yaml | 5 +- .../service/DeliveryTransferServiceTest.java | 43 ++++++++++++++++- 7 files changed, 127 insertions(+), 11 deletions(-) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java index 139c3101e..221f91864 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -203,6 +203,32 @@ class LoadingCompletedOutboxServiceTest { .isEqualTo(second.payload().at("/orders/0/items/0/cartonMarks")); } + @Test void publishesOnlyTheActuallyLoadedFragmentMeasurements() throws Exception { + Row loadedFragment = row(11, 100, 101); + loadedFragment.setPackageCount(4); + loadedFragment.setGrossWeightKg(new BigDecimal("40")); + loadedFragment.setNetWeightKg(new BigDecimal("36")); + loadedFragment.setDimensionalWeightKg(new BigDecimal("80")); + loadedFragment.setVolumeCbm(new BigDecimal("4")); + when(snapshots.rows(90L)).thenReturn(List.of(loadedFragment)); + when(manifests.labelPool(11L, 100L)).thenReturn(List.of( + label(101, "BOX-001"), label(101, "BOX-002"), + label(101, "BOX-003"), label(101, "BOX-004"))); + + service.publish(90L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InboundOutboxEntity.class); + verify(outbox).insert(captor.capture()); + EventEnvelope event = mapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class); + assertThat(event.payload().at("/orders/0/packageCount").asInt()).isEqualTo(4); + assertThat(event.payload().at("/orders/0/grossWeightKg").decimalValue()).isEqualByComparingTo("40"); + assertThat(event.payload().at("/orders/0/volumeWeightKg").decimalValue()).isEqualByComparingTo("80"); + assertThat(event.payload().at("/orders/0/volumeCbm").decimalValue()).isEqualByComparingTo("4"); + assertThat(event.payload().at("/orders/0/items/0/packageCount").asInt()).isEqualTo(4); + assertThat(event.payload().at("/orders/0/items/0/grossWeightKg").decimalValue()).isEqualByComparingTo("40"); + assertThat(event.payload().at("/orders/0/items/0/volumeCbm").decimalValue()).isEqualByComparingTo("4"); + } + @Test void immutableCustomerNameParticipatesInPayloadHash() { Row value = row(11, 100, 101); when(snapshots.rows(90L)).thenReturn(List.of(value)); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java index c150e4407..3cbd6917a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java @@ -59,7 +59,8 @@ public final class DeliveryResponses { public record OutboundDetail(OutboundRow outbound, List deliveries, List pallets) {} public record OneClickCandidate(Long inventoryPositionId, String orderNo, String customerId, String customerName, String overseasWarehouseId, String overseasWarehouseName, String palletNo, String inboundCode, - String skuCode, String productName, long availablePackages) {} + String skuCode, String productName, long availablePackages, + BigDecimal grossWeightKg, BigDecimal volumeWeightKg, BigDecimal volumeCbm) {} public record ReturnOutboundCandidate(String returnNo, String overseasWarehouseId, String cartonMark, String fbaCartonMark, String destinationWarehouseCode, String destinationWarehouseName, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index eed41070d..5cf0b1f75 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -76,6 +76,7 @@ import java.time.LocalDate; import java.time.format.DateTimeFormatter; import com.baiyue.lastleg.service.LastlegOrderNoGenerator; import java.math.BigDecimal; +import java.math.RoundingMode; import java.util.*; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.core.JsonProcessingException; @@ -322,8 +323,10 @@ public class DeliveryTransferService { if (concurrent == null) throw duplicate; return replayOneClick(concurrent, requestHash); } - selectedWarehouseId = validateOneClickItems(customerId, request.items()); + OneClickValidation validation = validateOneClickItems(customerId, request.items()); + selectedWarehouseId = validation.warehouseId(); Set physicalPalletIds = new HashSet<>(); + List createdMetricItems = new ArrayList<>(); for (DeliveryRequests.OneClickItem input : request.items()) { InventoryPosition position = inventoryPositions.selectById(input.inventoryPositionId()); if (position == null || !customerId.equals(position.getCustomerId())) throw notFound("Inventory position not found"); @@ -334,10 +337,21 @@ public class DeliveryTransferService { OutboundPalletItem item = new OutboundPalletItem(); item.setId(IdWorker.getId()); item.setCustomerId(customerId); item.setOutboundOrderId(outbound.getId()); item.setInboundCode(balance.getInboundCode()); physicalPalletIds.add(position.getPalletId()); - item.setWarehousePalletId(position.getPalletId()); item.setPalletNo(position.getPalletNo()); item.setPackageCount(input.packages().intValue()); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK"); item.setSkuCode(input.sku().trim()); item.setCartonMarks(String.join(",", input.cartonMarks() == null ? List.of() : input.cartonMarks())); item.setCreatedAt(now); + DropshipOutboundCandidate candidate = validation.candidatesByRequest().get( + oneClickCandidateKey(input.inventoryPositionId(), input.sku())); + if (candidate == null) throw conflict("一件代发出库重量体积快照缺失"); + item.setWarehousePalletId(position.getPalletId()); item.setPalletNo(position.getPalletNo()); item.setPackageCount(input.packages().intValue()); + item.setGrossWeightKg(allocateMetric(candidate.getSourceGrossWeightKg(), candidate.getSourcePackageCount(), input.packages(), 3)); + item.setVolumeWeightKg(allocateMetric(candidate.getSourceVolumeWeightKg(), candidate.getSourcePackageCount(), input.packages(), 3)); + item.setVolumeCbm(allocateMetric(candidate.getSourceVolumeCbm(), candidate.getSourcePackageCount(), input.packages(), 4)); + item.setPickedPackageCount(0); item.setStatus("WAIT_PICK"); item.setSkuCode(input.sku().trim()); item.setCartonMarks(String.join(",", input.cartonMarks() == null ? List.of() : input.cartonMarks())); item.setCreatedAt(now); outboundPallets.insert(item); + createdMetricItems.add(item); } outbound.setPalletCount(physicalPalletIds.size()); + outbound.setGrossWeightKg(sum(createdMetricItems, OutboundPalletItem::getGrossWeightKg)); + outbound.setVolumeWeightKg(sum(createdMetricItems, OutboundPalletItem::getVolumeWeightKg)); + outbound.setVolumeCbm(sum(createdMetricItems, OutboundPalletItem::getVolumeCbm)); outbound.setOverseasWarehouseId(selectedWarehouseId); outboundOrders.updateById(outbound); OutboundPdaTask task = new OutboundPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setOutboundOrderId(outbound.getId()); task.setTaskNo("PDA" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + task.getId()); task.setStatus("PENDING"); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task); @@ -558,10 +572,11 @@ public class DeliveryTransferService { return outboundDetail(outbound.getOutboundNo()); } - private String validateOneClickItems(String customerId, List items) { + private OneClickValidation validateOneClickItems(String customerId, List items) { String warehouseId = null; Map requestedPackages = new LinkedHashMap<>(); Map candidates = new HashMap<>(); + Map candidatesByRequest = new HashMap<>(); Set requestedMarks = new HashSet<>(); for (DeliveryRequests.OneClickItem input : items) { String sku = input.sku().trim(); @@ -577,6 +592,7 @@ public class DeliveryTransferService { String key = candidate.getProfileId() + "\u0000" + sku; requestedPackages.merge(key, input.packages(), Long::sum); candidates.put(key, candidate); + candidatesByRequest.put(oneClickCandidateKey(input.inventoryPositionId(), sku), candidate); if (input.cartonMarks() != null && !input.cartonMarks().isEmpty()) { if (input.cartonMarks().size() != input.packages() || new HashSet<>(input.cartonMarks()).size() != input.cartonMarks().size()) { throw conflict("Specified carton marks must be unique and equal the package count"); @@ -602,7 +618,7 @@ public class DeliveryTransferService { throw conflict("一件代发可用箱数不足:" + candidate.getPalletNo() + " / " + sku); } } - return warehouseId; + return new OneClickValidation(warehouseId, Map.copyOf(candidatesByRequest)); } @Transactional @@ -1185,10 +1201,14 @@ public class DeliveryTransferService { item.getVolumeCbm(), value(item.getPickedPackageCount()), item.getStatus()); } private DeliveryResponses.OneClickCandidate oneClickCandidate(DropshipOutboundCandidate candidate) { + long available = value(candidate.getAvailablePackages()); return new DeliveryResponses.OneClickCandidate(candidate.getInventoryPositionId(), candidate.getOrderNo(), candidate.getCustomerId(), candidate.getCustomerName(), candidate.getOverseasWarehouseId(), candidate.getOverseasWarehouseName(), candidate.getPalletNo(), candidate.getInboundCode(), candidate.getSkuCode(), candidate.getProductName(), - value(candidate.getAvailablePackages())); + available, + allocateMetric(candidate.getSourceGrossWeightKg(), candidate.getSourcePackageCount(), available, 3), + allocateMetric(candidate.getSourceVolumeWeightKg(), candidate.getSourcePackageCount(), available, 3), + allocateMetric(candidate.getSourceVolumeCbm(), candidate.getSourcePackageCount(), available, 4)); } private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName(), d.getDestinationType(), d.getDeliveryAddress(), d.getContactName(), d.getContactPhone(), d.getPostalCode()); } private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), null, d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); } @@ -1234,6 +1254,8 @@ public class DeliveryTransferService { throw new IllegalStateException("无法生成整托拣货幂等号", error); } } + private record OneClickValidation(String warehouseId, + Map candidatesByRequest) {} private record PalletPickPlan(Long itemId, Long positionId, long packages, List cartonMarks, int sequence) {} private record SkuShipment(long packages, long quantity) { @@ -1242,10 +1264,20 @@ public class DeliveryTransferService { Math.addExact(quantity, other.quantity)); } } - private static BigDecimal sum(List items, - java.util.function.Function getter) { + private static BigDecimal sum(List items, + java.util.function.Function getter) { return items.stream().map(getter).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add); } + private static BigDecimal allocateMetric(BigDecimal sourceValue, Long sourcePackages, + long selectedPackages, int scale) { + if (sourceValue == null || sourceValue.signum() == 0 || sourcePackages == null + || sourcePackages <= 0 || selectedPackages <= 0) return BigDecimal.ZERO.setScale(scale); + return sourceValue.multiply(BigDecimal.valueOf(selectedPackages)) + .divide(BigDecimal.valueOf(sourcePackages), scale, RoundingMode.HALF_UP); + } + private static String oneClickCandidateKey(Long inventoryPositionId, String sku) { + return inventoryPositionId + "\u0000" + trim(sku); + } private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } private String hash(Object value) { try { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java index 6f434b5d6..19bf1054b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java @@ -17,7 +17,13 @@ public interface DropshipOrderProfileMapper extends BaseMapper item = ArgumentCaptor.forClass(OutboundPalletItem.class); + verify(outboundPallets).insert(item.capture()); + assertEquals(new BigDecimal("20.000"), item.getValue().getGrossWeightKg()); + assertEquals(new BigDecimal("40.000"), item.getValue().getVolumeWeightKg()); + assertEquals(new BigDecimal("2.0000"), item.getValue().getVolumeCbm()); + ArgumentCaptor order = ArgumentCaptor.forClass(OutboundOrder.class); + verify(outboundOrders).updateById(order.capture()); + assertEquals(2, order.getValue().getPackageCount()); + assertEquals(new BigDecimal("20.000"), order.getValue().getGrossWeightKg()); + assertEquals(new BigDecimal("40.000"), order.getValue().getVolumeWeightKg()); + assertEquals(new BigDecimal("2.0000"), order.getValue().getVolumeCbm()); + } + @Test void oneClickOutboundRejectsSummarySkuWithoutAvailableDropshipCartons() { var generator = org.mockito.Mockito.mock(LastlegOrderNoGenerator.class); @@ -563,6 +597,9 @@ class DeliveryTransferServiceTest { assertEquals("DS-A002", result.get(0).palletNo()); assertEquals("SKU-1", result.get(0).skuCode()); assertEquals(3L, result.get(0).availablePackages()); + assertEquals(new BigDecimal("30.000"), result.get(0).grossWeightKg()); + assertEquals(new BigDecimal("60.000"), result.get(0).volumeWeightKg()); + assertEquals(new BigDecimal("3.0000"), result.get(0).volumeCbm()); verify(dropshipProfiles).listOutboundCandidates("TENANT-A", "WH-1"); verify(inventoryPositions, never()).selectById(any()); } @@ -981,7 +1018,11 @@ class DeliveryTransferServiceTest { DropshipOutboundCandidate value = new DropshipOutboundCandidate(); value.setInventoryPositionId(positionId); value.setProfileId(profileId); value.setWarehousePalletId(501L); value.setOverseasWarehouseId(warehouseId); value.setPalletNo(palletNo); value.setSkuCode(sku); - value.setAvailablePackages(packages); return value; + value.setAvailablePackages(packages); value.setSourcePackageCount(packages); + value.setSourceGrossWeightKg(BigDecimal.valueOf(packages * 10L)); + value.setSourceVolumeWeightKg(BigDecimal.valueOf(packages * 20L)); + value.setSourceVolumeCbm(BigDecimal.valueOf(packages)); + return value; } private static DropshipCartonSku cartonSku(String sku, long quantity) { -- 2.54.0 From 7d6ca17c0243b24e59d923daed4170eea26e7a9a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 18 Sep 2026 15:24:09 +0800 Subject: [PATCH 13/16] fix(firstleg): execute order remarks sync SQL --- .../com/baiyue/firstleg/mapper/LoadingPlanItemMapper.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanItemMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanItemMapper.java index 5ad87bae9..024fe6674 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanItemMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanItemMapper.java @@ -36,7 +36,7 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstl FROM fl_loading_plan_item i JOIN fl_loading_plan p ON p.id=i.loading_plan_id AND p.plan_status IN ('EDITING','LOADING') WHERE i.account_id=#{tenantId} AND i.order_id=#{orderId} AND i.deleted=0 AND i.frozen_at IS NULL - AND (#{excludedItemId} IS NULL OR i.id<>#{excludedItemId}) + AND (#{excludedItemId} IS NULL OR i.id <> #{excludedItemId}) AND NOT (i.customer_remark <=> #{customerRemark} AND i.internal_remark <=> #{internalRemark} AND i.warehouse_remark <=> #{warehouseRemark} @@ -57,7 +57,7 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstl i.customer_service_remark=#{customerServiceRemark}, i.updated_by=#{operatorId}, i.updated_by_name=#{operatorName}, i.updated_at=CURRENT_TIMESTAMP(3), i.version=i.version+1 WHERE i.account_id=#{tenantId} AND i.order_id=#{orderId} AND i.deleted=0 AND i.frozen_at IS NULL - AND (#{excludedItemId} IS NULL OR i.id<>#{excludedItemId}) + AND (#{excludedItemId} IS NULL OR i.id <> #{excludedItemId}) AND NOT (i.customer_remark <=> #{customerRemark} AND i.internal_remark <=> #{internalRemark} AND i.warehouse_remark <=> #{warehouseRemark} -- 2.54.0 From fd27a0ed210df9d3a9b556fae65db4e3e7834ece Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 18 Sep 2026 16:48:30 +0800 Subject: [PATCH 14/16] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReceivingContainerController.java | 12 +- .../service/ReceivingContainerService.java | 185 ++++++++++-------- .../openapi/overseas-business-v1.yaml | 12 +- ...ReceivingContainerServiceBehaviorTest.java | 68 +++++-- 4 files changed, 177 insertions(+), 100 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java index 783c55b38..d80e315d5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java @@ -24,6 +24,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; @@ -58,17 +59,20 @@ public class ReceivingContainerController { @GetMapping("/{containerNo}") @Operation(summary = "Get one receiving container without exposing plan or allocation IDs") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String containerNo) { + @PathVariable String containerNo, + @RequestParam String sourceContainerBookingId) { PdaWarehouseIdentity identity = identity(user); - return ApiResponse.success(service.detail(identity.operatorKey(), identity.overseasWarehouseId(), containerNo)); + return ApiResponse.success(service.detail(identity.operatorKey(), identity.overseasWarehouseId(), + containerNo, sourceContainerBookingId)); } @PostMapping("/{containerNo}/prepare") public ApiResponse prepare(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String containerNo, @Valid @RequestBody ReceivingContainerRequests.Action request) { + @PathVariable String containerNo, @RequestParam String sourceContainerBookingId, + @Valid @RequestBody ReceivingContainerRequests.Action request) { PdaWarehouseIdentity identity = identity(user); return ApiResponse.success(service.prepare(identity.operatorKey(), identity.operatorName(), identity.overseasWarehouseId(), - containerNo, request)); + containerNo, sourceContainerBookingId, request)); } @PostMapping(value = "/current/unload-photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index 2670248de..3f1e70f3b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -62,6 +62,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.function.Function; import java.util.stream.Collectors; @Service @@ -142,14 +143,16 @@ public class ReceivingContainerService { .map(value -> candidate(value, current)).toList(); } - public ReceivingContainerResponses.Session detail(String operatorKey, long overseasWarehouseId, String containerNo) { - requireReadyContainer(containerNo, overseasWarehouseId, false); - List containerPlans = requirePlans(containerNo); + public ReceivingContainerResponses.Session detail(String operatorKey, long overseasWarehouseId, String containerNo, + String sourceContainerBookingId) { + requireReadyContainer(containerNo, sourceContainerBookingId, overseasWarehouseId, false); + List containerPlans = requirePlans(sourceContainerBookingId, containerNo); requireMaterializedPlans(containerPlans); if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw conflict("当前柜已分配至其他海外接柜仓,无法在本仓操作"); ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey)); requireSessionWarehouse(current, overseasWarehouseId); - if (current != null && !containerNo.equals(current.getContainerNo())) { + if (current != null && (!containerNo.equals(current.getContainerNo()) + || !sourceContainerBookingId.equals(current.getSourceContainerBookingId()))) { throw conflict("Current account is locked to another selected container"); } ReceivingContainerSession reserved = sessions.findActiveByContainer(activeContainerKey(containerNo)); @@ -158,15 +161,17 @@ public class ReceivingContainerService { } WarehouseResourceGateway.WarehouseReference receivingWarehouse = warehouse.requireActiveSharedWarehouse(overseasWarehouseId); - return snapshot(current != null && containerNo.equals(current.getContainerNo()) ? current : waitingView( - operatorKey, receivingWarehouse, containerNo), containerPlans); + return snapshot(current != null && containerNo.equals(current.getContainerNo()) + && sourceContainerBookingId.equals(current.getSourceContainerBookingId()) ? current : waitingView( + operatorKey, receivingWarehouse, sourceContainerBookingId, containerNo), containerPlans); } @Transactional public ReceivingContainerResponses.Session prepare(String operatorKey, String operatorName, long overseasWarehouseId, String containerNo, + String sourceContainerBookingId, ReceivingContainerRequests.Action request) { - PrepareCommand command = new PrepareCommand(containerNo, request.idempotencyKey()); - ContainerReadiness readiness = requireReadyContainer(containerNo, overseasWarehouseId, true); + PrepareCommand command = new PrepareCommand(sourceContainerBookingId, containerNo, request.idempotencyKey()); + ContainerReadiness readiness = requireReadyContainer(containerNo, sourceContainerBookingId, overseasWarehouseId, true); ReceivingContainerOperation replay = replay(operatorKey, "PREPARE", request.idempotencyKey(), command); if (replay != null) { ReceivingContainerResponses.Session result = read(replay, ReceivingContainerResponses.Session.class); @@ -175,13 +180,14 @@ public class ReceivingContainerService { } return result; } - List containerPlans = requirePlans(containerNo); + List containerPlans = requirePlans(sourceContainerBookingId, containerNo); requireMaterializedPlans(containerPlans); if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw conflict("当前柜已分配至其他海外接柜仓,无法在本仓操作"); String activeOperatorKey = activeOperatorKey(operatorKey); ReceivingContainerSession active = sessions.lockActiveByOperator(activeOperatorKey); requireSessionWarehouse(active, overseasWarehouseId); - if (active != null && !containerNo.equals(active.getContainerNo())) { + if (active != null && (!containerNo.equals(active.getContainerNo()) + || !sourceContainerBookingId.equals(active.getSourceContainerBookingId()))) { throw conflict("Finish the current container before selecting another one"); } if (active == null && sessions.lockActiveByContainer(activeContainerKey(containerNo)) != null) { @@ -229,7 +235,7 @@ public class ReceivingContainerService { List photos = validateSignPhotos(request); List photoFileIds = photos.stream().map(photo -> positiveFileId(photo.fileId())).toList(); photoFileIds.forEach(fileId -> unloadPhotos.validate(PLATFORM_SCOPE, session.getId(), fileId)); - List containerPlans = requirePlans(session.getContainerNo()); + List containerPlans = requirePlans(session.getSourceContainerBookingId(), session.getContainerNo()); requireMaterializedPlans(containerPlans); LocalDateTime now = LocalDateTime.now(); session.setSignPhotos(write(photos)); session.setStatus("SIGNED"); session.setSignedAt(now); @@ -243,7 +249,7 @@ public class ReceivingContainerService { public ReceivingContainerResponses.Session current(String operatorKey) { ReceivingContainerSession session = requireCurrent(operatorKey, false); - return snapshot(session, requirePlans(session.getContainerNo())); + return snapshot(session, requirePlans(session.getSourceContainerBookingId(), session.getContainerNo())); } public UnloadPhotoFileGateway.DownloadedFile downloadCurrentPhoto(String operatorKey, String fileId) { @@ -276,10 +282,11 @@ public class ReceivingContainerService { public ReceivingContainerResponses.CartonResolved resolveCarton(String operatorKey, ReceivingContainerRequests.CartonResolve request) { ReceivingContainerSession session = requireSigned(operatorKey); - List currentRefs = refs(session.getContainerNo()); + List currentRefs = refs(session.getSourceContainerBookingId(), session.getContainerNo()); String sourcePalletNo = dropshipSourcePallet(currentRefs, session.getContainerNo(), request.cartonMark()); if (sourcePalletNo != null) { - List palletRefs = dropshipPalletRefs(session.getContainerNo(), sourcePalletNo); + List palletRefs = dropshipPalletRefs( + session.getSourceContainerBookingId(), session.getContainerNo(), sourcePalletNo); requireCompleteManifest(session.getContainerNo(), sourcePalletNo); List group = palletRefs.stream().map(DropshipPalletRef::ref).distinct().toList(); InboundRef first = group.get(0); @@ -306,14 +313,16 @@ public class ReceivingContainerService { } carton = new CartonRef(matches.get(0)); } else if (matches.size() == 1) { - carton = requireCartonRef(matches.get(0).order().getCustomerId(), session.getContainerNo(), request.cartonMark()); + carton = requireCartonRef(matches.get(0).order().getCustomerId(), + session.getSourceContainerBookingId(), session.getContainerNo(), request.cartonMark()); } else { carton = requireUnmatchedFbaCartonPrefix(inboundCode); } InboundRef ref = carton.ref(); if ("FBA".equals(destinationType(ref.line(), ref.order())) && !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) { - List group = requireFbaPlanRefs(session.getContainerNo(), ref); + List group = requireFbaPlanRefs( + session.getSourceContainerBookingId(), session.getContainerNo(), ref); List inboundCodes = group.stream().map(value -> value.line().getInboundCode()).distinct().sorted().toList(); int total = group.stream().mapToInt(value -> value(value.line().getRequiredPackageCount())).sum(); int received = group.stream().mapToInt(this::receivedLinePackages).sum(); @@ -339,10 +348,11 @@ public class ReceivingContainerService { public ReceivingContainerResponses.PalletResolved resolvePallet(String operatorKey, ReceivingContainerRequests.PalletResolve request) { ReceivingContainerSession session = requireSigned(operatorKey); - InboundRef ref = requireInbound(session.getContainerNo(), request.inboundCode()); + InboundRef ref = requireInbound(session.getSourceContainerBookingId(), + session.getContainerNo(), request.inboundCode()); List group = "FBA".equals(destinationType(ref.line(), ref.order())) && !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType()) - ? requireFbaPlanRefs(session.getContainerNo(), ref) : List.of(ref); + ? requireFbaPlanRefs(session.getSourceContainerBookingId(), session.getContainerNo(), ref) : List.of(ref); PalletSelection selection = group.size() == 1 && !"FBA".equals(destinationType(ref.line(), ref.order())) ? resolveReceivingPallet(ref, request.palletCode(), false) : resolveFbaGroupPallet(group, request.palletCode()); WarehouseResourceGateway.WarehousePalletReference pallet = selection.resource(); @@ -369,14 +379,16 @@ public class ReceivingContainerService { if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class); requirePalletCapacity(request.packageCount()); ReceivingContainerSession session = requireSigned(operatorKey); - InboundRef ref = requireInbound(session.getContainerNo(), request.inboundCode()); + InboundRef ref = requireInbound(session.getSourceContainerBookingId(), + session.getContainerNo(), request.inboundCode()); if ("ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) { throw conflict("One-click dropship inbound must be operated by complete source pallet"); } if (!"FBA".equals(destinationType(ref.line(), ref.order()))) { throw conflict("OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden"); } - List fbaPlanRefs = requireFbaPlanRefs(session.getContainerNo(), ref); + List fbaPlanRefs = requireFbaPlanRefs( + session.getSourceContainerBookingId(), session.getContainerNo(), ref); PalletSelection selection = resolveFbaGroupPallet(fbaPlanRefs, request.palletCode()); WarehouseResourceGateway.WarehousePalletReference resource = selection.resource(); InboundPlanPallet pallet = selection.pallet(); @@ -451,15 +463,16 @@ public class ReceivingContainerService { ReceivingContainerOperation replay = replay(operatorKey, "CARTON_RECEIVE", request.idempotencyKey(), request); if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class); ReceivingContainerSession session = requireSigned(operatorKey); - List currentRefs = refs(session.getContainerNo()); + List currentRefs = refs(session.getSourceContainerBookingId(), session.getContainerNo()); if (currentRefs.stream().noneMatch(value -> "OVERSEAS".equals(destinationType(value.line(), value.order())))) { throw conflict("FBA destination must use bulk count"); } String inboundCode = cartonInboundCode(request.cartonMark()); - InboundRef inboundRef = requireInbound(session.getContainerNo(), inboundCode); + InboundRef inboundRef = requireInbound(session.getSourceContainerBookingId(), session.getContainerNo(), inboundCode); String customerId = inboundRef.order().getCustomerId(); - CartonRef carton = requireCartonRef(customerId, session.getContainerNo(), request.cartonMark()); + CartonRef carton = requireCartonRef(customerId, session.getSourceContainerBookingId(), + session.getContainerNo(), request.cartonMark()); InboundRef ref = carton.ref(); if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) { throw conflict("FBA destination must use bulk count"); @@ -496,10 +509,12 @@ public class ReceivingContainerService { ReceivingContainerOperation replay = replay(operatorKey, "PALLET_RECEIVE", request.idempotencyKey(), request); if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class); ReceivingContainerSession session = requireSigned(operatorKey); - String sourcePalletNo = dropshipSourcePallet(session.getContainerNo(), request.sourcePalletNo()); + String sourcePalletNo = dropshipSourcePallet( + session.getSourceContainerBookingId(), session.getContainerNo(), request.sourcePalletNo()); if (sourcePalletNo == null) throw notFound("DP or carton is not part of the current container"); requireCompleteManifest(session.getContainerNo(), sourcePalletNo); - List matches = dropshipPalletRefs(session.getContainerNo(), sourcePalletNo); + List matches = dropshipPalletRefs( + session.getSourceContainerBookingId(), session.getContainerNo(), sourcePalletNo); int total = matches.stream().mapToInt(value -> value(value.allocation().getPlannedPackageCount())).sum(); requirePalletCapacity(total); boolean alreadyReceived = matches.stream().allMatch(match -> @@ -538,17 +553,17 @@ public class ReceivingContainerService { ReceivingContainerOperation replay = replay(operatorKey, "COMPLETE", request.idempotencyKey(), request); if (replay != null) return read(replay, ReceivingContainerResponses.Session.class); ReceivingContainerSession session = requireSigned(operatorKey); - List containerPlans = requirePlans(session.getContainerNo()); - Totals totals = totals(containerPlans); + List containerPlans = requirePlans(session.getSourceContainerBookingId(), session.getContainerNo()); + Totals totals = totals(session, containerPlans); boolean hasDifference = totals.total() != totals.received() || hasFbaOverReceipt(session, containerPlans); if (hasDifference && !request.allowDifference()) { throw conflict("Container has a receiving difference; confirm onsite completion with difference"); } if (!hasDifference) { - validateFbaSkuBalance(session.getContainerNo()); + validateFbaSkuBalance(session.getSourceContainerBookingId(), session.getContainerNo()); workflow.completeContainer(containerPlans); } else { - workflow.completeContainerWithDifferences(containerPlans, reconciledPlans(containerPlans)); + workflow.completeContainerWithDifferences(containerPlans, reconciledPlans(session, containerPlans)); } LocalDateTime now = LocalDateTime.now(); session.setStatus(hasDifference ? "COMPLETED_WITH_DIFFERENCE" : "COMPLETED"); @@ -558,23 +573,24 @@ public class ReceivingContainerService { save(session, "COMPLETE", request.idempotencyKey(), request, result); return result; } - private List reconciledPlans(List containerPlans) { - Set complete = refs(containerPlans.get(0).getContainerNo()).stream() + private List reconciledPlans(ReceivingContainerSession session, List containerPlans) { + List currentRefs = refs(session.getSourceContainerBookingId(), session.getContainerNo()); + Set complete = currentRefs.stream() .collect(Collectors.groupingBy(value -> value.plan().getId(), LinkedHashMap::new, Collectors.toList())) .entrySet().stream().filter(entry -> entry.getValue().stream().allMatch(ref -> receivedLinePackages(ref) == value(ref.line().getRequiredPackageCount()))) .map(Map.Entry::getKey).collect(Collectors.toSet()); - Set incompleteFbaGroups = refs(containerPlans.get(0).getContainerNo()).stream() + Set incompleteFbaGroups = currentRefs.stream() .filter(ref -> !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) .filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order()))) .filter(ref -> !complete.contains(ref.plan().getId())) .map(ref -> normalizedFba(ref.line().getFbaWarehouseCode())).collect(Collectors.toSet()); - refs(containerPlans.get(0).getContainerNo()).stream() + currentRefs.stream() .filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order()))) .filter(ref -> fbaGroupHasOverReceipt(findSession(containerPlans.get(0).getContainerNo()), ref)) .map(ref -> normalizedFba(ref.line().getFbaWarehouseCode())).forEach(incompleteFbaGroups::add); return containerPlans.stream().filter(plan -> complete.contains(plan.getId())) - .filter(plan -> refs(plan.getContainerNo()).stream().filter(ref -> Objects.equals(ref.plan().getId(), plan.getId())) + .filter(plan -> currentRefs.stream().filter(ref -> Objects.equals(ref.plan().getId(), plan.getId())) .noneMatch(ref -> "FBA".equals(destinationType(ref.line(), ref.order())) && incompleteFbaGroups.contains(normalizedFba(ref.line().getFbaWarehouseCode())))) .toList(); @@ -589,22 +605,23 @@ public class ReceivingContainerService { return session; } - private ContainerReadiness requireReadyContainer(String containerNo, long overseasWarehouseId, boolean lock) { + private ContainerReadiness requireReadyContainer(String containerNo, String sourceContainerBookingId, + long overseasWarehouseId, boolean lock) { + String bookingId = trim(sourceContainerBookingId); + if (bookingId == null) throw conflict("sourceContainerBookingId is required"); LambdaQueryWrapper query = new LambdaQueryWrapper() - .eq(LastlegOrder::getContainerNo, containerNo).orderByAsc(LastlegOrder::getId); + .eq(LastlegOrder::getContainerNo, containerNo) + .eq(LastlegOrder::getSourceContainerBookingId, bookingId) + .orderByAsc(LastlegOrder::getId); if (lock) query.last("FOR UPDATE"); List orderFacts = orders.selectList(query); - Set bookingIds = orderFacts.stream().map(LastlegOrder::getSourceContainerBookingId) - .map(ReceivingContainerService::trim).filter(Objects::nonNull).collect(Collectors.toSet()); - if (orderFacts.isEmpty() || bookingIds.size() != 1) { - throw conflict("Container must resolve exactly one sourceContainerBookingId"); - } + if (orderFacts.isEmpty()) throw notFound("当前柜次来源与集装箱号不匹配"); List planFacts = plans.selectList(new LambdaQueryWrapper() .eq(InboundPlan::getContainerNo, containerNo).orderByAsc(InboundPlan::getId)); List lineFacts = lines.selectList(new LambdaQueryWrapper() .in(InboundPlanLine::getOrderId, orderFacts.stream().map(LastlegOrder::getId).toList()) .orderByAsc(InboundPlanLine::getId)); - ContainerReadiness value = readiness(bookingIds.iterator().next(), orderFacts, planFacts, lineFacts, + ContainerReadiness value = readiness(bookingId, orderFacts, planFacts, lineFacts, overseasWarehouseId); if (value == null || !value.operable()) { throw conflict(value == null ? "当前柜已分配至其他海外接柜仓,无法在本仓操作" : value.blockReason()); @@ -614,7 +631,8 @@ public class ReceivingContainerService { private void requireStableSessionFacts(ReceivingContainerSession session, boolean lock) { if (trim(session.getContainerFactHash()) == null) return; - ContainerReadiness current = requireReadyContainer(session.getContainerNo(), session.getOverseasWarehouseId(), lock); + ContainerReadiness current = requireReadyContainer(session.getContainerNo(), + session.getSourceContainerBookingId(), session.getOverseasWarehouseId(), lock); if (!Objects.equals(session.getSourceContainerBookingId(), current.sourceContainerBookingId()) || !Objects.equals(session.getContainerFactHash(), current.factHash())) { throw conflict("Container orders or inbound plans changed after prepare; restart the receiving session"); @@ -657,10 +675,8 @@ public class ReceivingContainerService { int planCount = matchedPlans.size(); boolean allPlansReady = totalOrders > 0 && inboundCodes == totalOrders && plannedOrders == totalOrders && planCount == totalOrders; - List containerPlans = planFacts.stream() - .filter(plan -> containerNo.equals(plan.getContainerNo())).toList(); - boolean allPlansCompleted = !containerPlans.isEmpty() - && containerPlans.stream().allMatch(plan -> List.of("COMPLETED", "PENDING_RECONCILIATION") + boolean allPlansCompleted = !matchedPlans.isEmpty() + && matchedPlans.stream().allMatch(plan -> List.of("COMPLETED", "PENDING_RECONCILIATION") .contains(plan.getStatus())); boolean shippingReady = shippingGates.receivingAvailable(sourceContainerBookingId, containerNo) == 1; boolean operable = allPlansReady && shippingReady && warehouseSnapshotConsistent; @@ -716,9 +732,12 @@ public class ReceivingContainerService { return session; } - private List requirePlans(String containerNo) { - List values = plans.selectList(new LambdaQueryWrapper() - .eq(InboundPlan::getContainerNo, containerNo).orderByAsc(InboundPlan::getId)); + private List requirePlans(String sourceContainerBookingId, String containerNo) { + List values = refs(sourceContainerBookingId, containerNo).stream() + .map(InboundRef::plan) + .collect(Collectors.toMap(InboundPlan::getId, Function.identity(), (left, right) -> left, + LinkedHashMap::new)) + .values().stream().toList(); if (values.isEmpty()) throw notFound("Receiving container was not found"); if (values.stream().anyMatch(value -> trim(value.getCustomerId()) == null)) { throw conflict("Every inbound plan in the receiving container must have an owning customer"); @@ -730,32 +749,31 @@ public class ReceivingContainerService { return values; } - private List plansForContainer(String containerNo) { - List values = plans.selectList(new LambdaQueryWrapper() - .eq(InboundPlan::getContainerNo, containerNo) - .orderByAsc(InboundPlan::getId)); - if (values.isEmpty()) throw notFound("Receiving container was not found"); - return values; - } - - private List refs(String containerNo) { + private List refs(String sourceContainerBookingId, String containerNo) { List result = new ArrayList<>(); - for (InboundPlan plan : plansForContainer(containerNo)) { + List containerPlans = plans.selectList(new LambdaQueryWrapper() + .eq(InboundPlan::getContainerNo, containerNo).orderByAsc(InboundPlan::getId)); + for (InboundPlan plan : containerPlans) { String customerId = trim(plan.getCustomerId()); if (customerId == null) throw conflict("Inbound plan has no owning customer"); for (InboundPlanLine line : lines.selectList(new LambdaQueryWrapper() .eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getPlanId, plan.getId()) .orderByAsc(InboundPlanLine::getId))) { LastlegOrder order = orders.selectById(line.getOrderId()); - if (order == null || !customerId.equals(order.getCustomerId())) throw conflict("Inbound line order is missing"); + if (order == null || !customerId.equals(order.getCustomerId()) + || !Objects.equals(containerNo, trim(order.getContainerNo()))) { + throw conflict("Inbound line order is missing or belongs to another cabinet occurrence"); + } + if (!Objects.equals(sourceContainerBookingId, trim(order.getSourceContainerBookingId()))) continue; result.add(new InboundRef(plan, line, order)); } } + if (result.isEmpty()) throw notFound("Receiving container was not found"); return result; } - private InboundRef requireInbound(String containerNo, String inboundCode) { - List matches = refs(containerNo).stream() + private InboundRef requireInbound(String sourceContainerBookingId, String containerNo, String inboundCode) { + List matches = refs(sourceContainerBookingId, containerNo).stream() .filter(value -> inboundCode.equals(value.line().getInboundCode())).toList(); if (matches.isEmpty()) throw notFound("Inbound code is not part of the current container"); Set planIds = matches.stream().map(value -> value.plan().getId()).collect(Collectors.toSet()); @@ -763,11 +781,12 @@ public class ReceivingContainerService { return matches.stream().min(Comparator.comparing(value -> value.line().getId())).orElseThrow(); } - private CartonRef requireCartonRef(String customerId, String containerNo, String cartonMark) { + private CartonRef requireCartonRef(String customerId, String sourceContainerBookingId, + String containerNo, String cartonMark) { InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper() .eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, cartonMark)); if (carton == null) throw notFound("Carton mark was not found"); - List matches = refs(containerNo).stream() + List matches = refs(sourceContainerBookingId, containerNo).stream() .filter(value -> customerId.equals(value.order().getCustomerId())) .filter(value -> carton.getLineId() == null || Objects.equals(value.line().getId(), carton.getLineId())) .filter(value -> carton.getPlanId() == null || Objects.equals(value.plan().getId(), carton.getPlanId())) @@ -791,8 +810,9 @@ public class ReceivingContainerService { throw notFound("Carton mark was not found"); } - private List requireFbaPlanRefs(String containerNo, InboundRef selected) { - List values = refs(containerNo).stream() + private List requireFbaPlanRefs(String sourceContainerBookingId, + String containerNo, InboundRef selected) { + List values = refs(sourceContainerBookingId, containerNo).stream() .filter(value -> !"ONE_CLICK_DROPSHIP".equals(value.order().getOrderType())) .filter(value -> "FBA".equals(destinationType(value.line(), value.order()))) .filter(value -> Objects.equals(normalizedFba(value.line().getFbaWarehouseCode()), @@ -940,8 +960,8 @@ public class ReceivingContainerService { return Objects.equals(line.getDestinationWarehouseCode(), pallet.getDestinationWarehouseCode()); } - private String dropshipSourcePallet(String containerNo, String scanCode) { - return dropshipSourcePallet(refs(containerNo), containerNo, scanCode); + private String dropshipSourcePallet(String sourceContainerBookingId, String containerNo, String scanCode) { + return dropshipSourcePallet(refs(sourceContainerBookingId, containerNo), containerNo, scanCode); } private String dropshipSourcePallet(List currentRefs, String containerNo, String scanCode) { @@ -960,9 +980,10 @@ public class ReceivingContainerService { return resolved.isEmpty() ? null : resolved.get(0); } - private List dropshipPalletRefs(String containerNo, String sourcePalletNo) { + private List dropshipPalletRefs(String sourceContainerBookingId, + String containerNo, String sourcePalletNo) { List result = new ArrayList<>(); - for (InboundRef ref : refs(containerNo)) { + for (InboundRef ref : refs(sourceContainerBookingId, containerNo)) { if (!"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) continue; List sourcePallets = pallets.selectList(new LambdaQueryWrapper() .eq(InboundPlanPallet::getCustomerId, ref.order().getCustomerId()) @@ -1009,7 +1030,8 @@ public class ReceivingContainerService { InboundPlanPalletAllocation allocation) { } private ReceivingContainerResponses.Session snapshot(ReceivingContainerSession session, List containerPlans) { - List inbounds = refs(session.getContainerNo()).stream() + List inbounds = refs( + session.getSourceContainerBookingId(), session.getContainerNo()).stream() .map(this::inbound).toList(); int total = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum(); int received = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum(); @@ -1065,7 +1087,7 @@ public class ReceivingContainerService { private boolean hasFbaOverReceipt(ReceivingContainerSession session, List containerPlans) { if (session == null || containerPlans == null || containerPlans.isEmpty()) return false; - return refs(session.getContainerNo()).stream() + return refs(session.getSourceContainerBookingId(), session.getContainerNo()).stream() .filter(ref -> !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) .filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order()))) .anyMatch(ref -> fbaGroupHasOverReceipt(session, ref)); @@ -1073,7 +1095,7 @@ public class ReceivingContainerService { private boolean fbaGroupHasOverReceipt(ReceivingContainerSession session, InboundRef ref) { if (session == null || session.getId() == null) return false; - int planned = requireFbaPlanRefs(session.getContainerNo(), ref).stream() + int planned = requireFbaPlanRefs(session.getSourceContainerBookingId(), session.getContainerNo(), ref).stream() .mapToInt(value -> value(value.line().getRequiredPackageCount())).sum(); return fbaPalletCounts.sumActual(session.getId(), fbaGroupKey(session.getContainerNo(), ref)) > planned; } @@ -1115,8 +1137,9 @@ public class ReceivingContainerService { return mergeAllowed(plan) ? PALLET_MERGE_HINT : null; } - private Totals totals(List containerPlans) { - List values = refs(containerPlans.get(0).getContainerNo()) + private Totals totals(ReceivingContainerSession session, List containerPlans) { + List values = refs( + session.getSourceContainerBookingId(), session.getContainerNo()) .stream().map(this::inbound).toList(); List destinationCodes = values.stream().map(this::destinationCode) .filter(Objects::nonNull).distinct().sorted().toList(); @@ -1246,8 +1269,8 @@ public class ReceivingContainerService { } } - private void validateFbaSkuBalance(String containerNo) { - for (InboundRef ref : refs(containerNo)) { + private void validateFbaSkuBalance(String sourceContainerBookingId, String containerNo) { + for (InboundRef ref : refs(sourceContainerBookingId, containerNo)) { if (!"FBA".equals(destinationType(ref.line(), ref.order()))) continue; if ("ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) continue; String customerId = ref.order().getCustomerId(); @@ -1296,12 +1319,14 @@ public class ReceivingContainerService { private ReceivingContainerSession waitingView(String operatorKey, WarehouseResourceGateway.WarehouseReference warehouse, + String sourceContainerBookingId, String containerNo) { ReceivingContainerSession value = new ReceivingContainerSession(); value.setOverseasWarehouseId(warehouse.warehouseId()); value.setOverseasWarehouseCode(warehouse.warehouseCode()); value.setOverseasWarehouseName(warehouse.warehouseName()); - value.setOperatorKey(operatorKey); value.setContainerNo(containerNo); value.setStatus("WAIT_SIGN"); value.setVersion(0); + value.setOperatorKey(operatorKey); value.setSourceContainerBookingId(sourceContainerBookingId); + value.setContainerNo(containerNo); value.setStatus("WAIT_SIGN"); value.setVersion(0); return value; } @@ -1411,5 +1436,5 @@ public class ReceivingContainerService { int planCount, int plannedInboundCodeCount, int pendingPlanCount, boolean allPlansReady, boolean allPlansCompleted, boolean shippingReady, boolean operable, String blockReason, LocalDateTime plannedArrivalAt, String factHash, Totals totals) { } - private record PrepareCommand(String containerNo, String idempotencyKey) { } + private record PrepareCommand(String sourceContainerBookingId, String containerNo, String idempotencyKey) { } } 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 689745678..c0d00b457 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -42,16 +42,20 @@ paths: /api/pda/v1/last-leg/receiving-containers/{containerNo}: get: summary: 查询待签收柜号详情 - description: 权限 lastleg:pda:operate;按来源柜返回跨客户的全部入仓码、目的类型、目的仓、托数和实收进度;每个入仓码仍按自身客户隔离。 + description: 权限 lastleg:pda:operate;sourceContainerBookingId 与真实集装箱号共同锁定本次柜次,允许物理集装箱号在不同柜次重复使用;按来源柜返回跨客户的全部入仓码、目的类型、目的仓、托数和实收进度。 security: [{ bearerAuth: [] }] - parameters: [{ name: containerNo, in: path, required: true, schema: { type: string } }] + parameters: + - { name: containerNo, in: path, required: true, schema: { type: string } } + - { name: sourceContainerBookingId, in: query, required: true, schema: { type: string, minLength: 1, maxLength: 128 } } responses: { '200': { description: 柜号详情, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 当前账号已锁定其他柜号 } } /api/pda/v1/last-leg/receiving-containers/{containerNo}/prepare: post: summary: 选择当前待签收柜号 - description: 权限 lastleg:pda:operate;仅 RECEIVING_AVAILABLE 柜可建立 WAIT_SIGN 会话,并以平台操作账号、柜号全局双唯一键防止切柜或多人同时处理。 + description: 权限 lastleg:pda:operate;sourceContainerBookingId 与真实集装箱号共同锁定本次柜次;仅 RECEIVING_AVAILABLE 柜可建立 WAIT_SIGN 会话,并以平台操作账号、当前作业柜全局双唯一键防止切柜或多人同时处理。 security: [{ bearerAuth: [] }] - parameters: [{ name: containerNo, in: path, required: true, schema: { type: string } }] + parameters: + - { name: containerNo, in: path, required: true, schema: { type: string } } + - { name: sourceContainerBookingId, in: query, required: true, schema: { type: string, minLength: 1, maxLength: 128 } } requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingAction' } } } } responses: { '200': { description: 当前柜会话, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 账号或柜号已有未完成会话 } } /api/pda/v1/last-leg/receiving-containers/current/unload-photos/files: diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java index 7350daba5..7aa26fc05 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java @@ -117,7 +117,7 @@ class ReceivingContainerServiceBehaviorTest { when(shippingGates.receivingAvailable("BOOKING-A", "CONT-A")).thenReturn(0); assertThat(service.candidates("7", 100L)).isEmpty(); - assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-A", + assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-A", "BOOKING-A", new ReceivingContainerRequests.Action("not-ready"))) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); verifyNoInteractions(warehouse); @@ -133,7 +133,7 @@ class ReceivingContainerServiceBehaviorTest { when(sessions.insert(any(ReceivingContainerSession.class))).thenReturn(1); when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1); when(warehouse.requireActiveSharedWarehouse(100L)).thenReturn(warehouseReference()); - var response = service.prepare("7", "receiver", 100L, "CONT-A", + var response = service.prepare("7", "receiver", 100L, "CONT-A", "BOOKING-A", new ReceivingContainerRequests.Action("same-key")); assertThat(response.overseasWarehouseId()).isEqualTo(100L); assertThat(response.overseasWarehouseName()).isEqualTo("Shared LAX"); @@ -145,7 +145,7 @@ class ReceivingContainerServiceBehaviorTest { verify(operations).insert(saved.capture()); when(operations.find("7", "same-key")).thenReturn(saved.getValue()); - assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-B", + assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-B", "BOOKING-A", new ReceivingContainerRequests.Action("same-key"))).isInstanceOf(ResponseStatusException.class) .hasMessageContaining("409"); } @@ -249,13 +249,44 @@ class ReceivingContainerServiceBehaviorTest { when(warehouse.requireActiveSharedWarehouse(100L)).thenReturn(warehouseReference()); var candidate = service.candidates("7", 100L).get(0); - var detail = service.detail("7", 100L, "TSTU0000204"); + var detail = service.detail("7", 100L, "TSTU0000204", "BOOKING-204"); assertThat(candidate.operable()).isTrue(); assertThat(detail.overseasWarehouseId()).isEqualTo(100L); assertThat(detail.overseasWarehouseCode()).isEqualTo("SHARED-LAX"); } + @Test + void detailUsesBookingOccurrenceWhenPhysicalContainerNumberIsReused() { + InboundPlan historicalPlan = plan("VAXU2748524"); + historicalPlan.setId(2L); historicalPlan.setPlanNo("PLAN-OLD"); historicalPlan.setStatus("COMPLETED"); + InboundPlan currentPlan = plan("VAXU2748524"); + currentPlan.setId(3L); currentPlan.setPlanNo("PLAN-CURRENT"); + InboundPlanLine historicalLine = line("FBA"); + historicalLine.setId(10L); historicalLine.setPlanId(2L); historicalLine.setOrderId(1L); + historicalLine.setInboundCode("IN-OLD"); + InboundPlanLine currentLine = line("FBA"); + currentLine.setId(11L); currentLine.setPlanId(3L); currentLine.setOrderId(2L); + currentLine.setInboundCode("IN-CURRENT"); + LastlegOrder historicalOrder = containerOrder( + 1L, "100", "VAXU2748524", "BOOKING-OLD", "IN-OLD"); + LastlegOrder currentOrder = containerOrder( + 2L, "100", "VAXU2748524", "BOOKING-CURRENT", "IN-CURRENT"); + when(plans.selectList(any())).thenReturn(List.of(historicalPlan, currentPlan)); + when(lines.selectList(any())).thenReturn( + List.of(currentLine), + List.of(historicalLine), List.of(currentLine), + List.of(historicalLine), List.of(currentLine)); + when(orders.selectList(any())).thenReturn(List.of(currentOrder)); + when(orders.selectById(1L)).thenReturn(historicalOrder); + when(orders.selectById(2L)).thenReturn(currentOrder); + when(warehouse.requireActiveSharedWarehouse(100L)).thenReturn(warehouseReference()); + + var detail = service.detail("7", 100L, "VAXU2748524", "BOOKING-CURRENT"); + + assertThat(detail.inbounds()).extracting("inboundCode").containsExactly("IN-CURRENT"); + } + @Test void prepareAtomicallyBindsUnassignedPlansToThePickerWarehouse() { InboundPlan unassigned = plan("TSTU0195472"); @@ -269,7 +300,7 @@ class ReceivingContainerServiceBehaviorTest { when(orders.selectById(1L)).thenReturn(order); when(warehouse.requireActiveSharedWarehouse(100L)).thenReturn(warehouseReference()); - var result = service.prepare("7", "receiver", 100L, "TSTU0195472", + var result = service.prepare("7", "receiver", 100L, "TSTU0195472", "BOOKING-472", new ReceivingContainerRequests.Action("bind-warehouse")); assertThat(result.overseasWarehouseId()).isEqualTo(100L); @@ -285,9 +316,10 @@ class ReceivingContainerServiceBehaviorTest { when(plans.selectList(any())).thenReturn(List.of(plan)); when(lines.selectList(any())).thenReturn(List.of(planLine)); when(orders.selectList(any())).thenReturn(List.of(order)); + when(orders.selectById(1L)).thenReturn(order); when(pallets.selectCount(any())).thenReturn(0L); - assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "TSTU0195472", + assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "TSTU0195472", "BOOKING-472", new ReceivingContainerRequests.Action("missing-real-pallets"))) .isInstanceOf(ResponseStatusException.class) .hasMessageContaining("尚未完整绑定真实托盘"); @@ -339,7 +371,7 @@ class ReceivingContainerServiceBehaviorTest { assertThat(candidate.pendingPlanCount()).isEqualTo(1); assertThat(candidate.operable()).isFalse(); assertThat(candidate.blockReason()).isEqualTo("1个入仓码已生成计划,1个订单未操作/缺入仓码"); - assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-A", + assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-A", "BOOKING-A", new ReceivingContainerRequests.Action("partial"))) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); verify(sessions, never()).insert(any(ReceivingContainerSession.class)); @@ -351,12 +383,14 @@ class ReceivingContainerServiceBehaviorTest { when(lines.selectList(any())).thenReturn(List.of(line("FBA"))); when(orders.selectList(any())).thenReturn(List.of( containerOrder(1L, "100", "CONT-B", "BOOKING-B", "IN-1"))); + when(orders.selectById(1L)).thenReturn( + containerOrder(1L, "100", "CONT-B", "BOOKING-B", "IN-1")); ReceivingContainerSession other = session("CONT-B", "SIGNED"); other.setOperatorKey("8"); other.setActiveOperatorKey("OPERATOR:8"); other.setActiveContainerKey("CONTAINER:CONT-B"); when(sessions.findActiveByContainer("CONTAINER:CONT-B")).thenReturn(other); - assertThatThrownBy(() -> service.detail("7", 100L, "CONT-B")) + assertThatThrownBy(() -> service.detail("7", 100L, "CONT-B", "BOOKING-B")) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); verifyNoInteractions(warehouse); } @@ -367,8 +401,10 @@ class ReceivingContainerServiceBehaviorTest { when(lines.selectList(any())).thenReturn(List.of(line("FBA"))); when(orders.selectList(any())).thenReturn(List.of( containerOrder(1L, "100", "CONT-B", "BOOKING-B", "IN-1"))); + when(orders.selectById(1L)).thenReturn( + containerOrder(1L, "100", "CONT-B", "BOOKING-B", "IN-1")); when(sessions.findActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); - assertThatThrownBy(() -> service.detail("7", 100L, "CONT-B")) + assertThatThrownBy(() -> service.detail("7", 100L, "CONT-B", "BOOKING-B")) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); } @@ -378,14 +414,15 @@ class ReceivingContainerServiceBehaviorTest { when(lines.selectList(any())).thenReturn(List.of(line("FBA"))); LastlegOrder order = containerOrder(1L, "100", "CONT-B", "BOOKING-B", "IN-1"); when(orders.selectList(any())).thenReturn(List.of(order)); + when(orders.selectById(1L)).thenReturn(order); when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); - assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-B", + assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-B", "BOOKING-B", new ReceivingContainerRequests.Action("account-lock"))).isInstanceOf(ResponseStatusException.class); verify(sessions, never()).insert(any(ReceivingContainerSession.class)); org.mockito.Mockito.reset(sessions, operations); when(sessions.lockActiveByContainer("CONTAINER:CONT-B")).thenReturn(session("CONT-B", "WAIT_SIGN")); - assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-B", + assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-B", "BOOKING-B", new ReceivingContainerRequests.Action("container-lock"))).isInstanceOf(ResponseStatusException.class); verify(sessions, never()).insert(any(ReceivingContainerSession.class)); } @@ -675,7 +712,8 @@ class ReceivingContainerServiceBehaviorTest { void signRequiresEachTypedPhotoExactlyOnceAndRestoresRoles() { when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); when(plans.selectList(any())).thenReturn(List.of(plan("CONT-A"))); - when(lines.selectList(any())).thenReturn(List.of()); + when(lines.selectList(any())).thenReturn(List.of(line("FBA"))); + when(orders.selectById(1L)).thenReturn(order("FBA")); when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1); when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1); ReceivingContainerRequests.Sign request = new ReceivingContainerRequests.Sign(List.of( @@ -1046,9 +1084,12 @@ class ReceivingContainerServiceBehaviorTest { InboundPlan plan = plan(containerNo); InboundPlanLine line = line(destinationType); LastlegOrder order = order(destinationType); + order.setContainerNo(containerNo); order.setSourceContainerBookingId("BOOKING-A"); + order.setInboundCode("IN-1"); when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session(containerNo, "SIGNED")); when(plans.selectList(any())).thenReturn(List.of(plan)); when(lines.selectList(any())).thenReturn(List.of(line)); + org.mockito.Mockito.lenient().when(orders.selectList(any())).thenReturn(List.of(order)); when(orders.selectById(1L)).thenReturn(order); } @@ -1076,6 +1117,8 @@ class ReceivingContainerServiceBehaviorTest { private static LastlegOrder order(String destinationType) { LastlegOrder value = new LastlegOrder(); value.setId(1L); value.setCustomerId("100"); value.setDestinationType(destinationType); + value.setContainerNo("CONT-A"); value.setSourceContainerBookingId("BOOKING-A"); + value.setInboundCode("IN-1"); value.setOverseasWarehouseId("100"); value.setOverseasWarehouseCode("SHARED-LAX"); value.setOverseasWarehouseName("Shared LAX"); value.setFbaWarehouseCode("FBA".equals(destinationType) ? "FBA-1" : null); @@ -1100,6 +1143,7 @@ class ReceivingContainerServiceBehaviorTest { private static ReceivingContainerSession session(String containerNo, String status) { ReceivingContainerSession value = new ReceivingContainerSession(); value.setId(8L); value.setContainerNo(containerNo); value.setOperatorKey("7"); + value.setSourceContainerBookingId("BOOKING-A"); value.setOverseasWarehouseId(100L); value.setOverseasWarehouseCode("SHARED-LAX"); value.setOverseasWarehouseName("Shared LAX"); value.setStatus(status); value.setVersion(0); -- 2.54.0 From aedfcc3abfd6491d6380b78f2ee9c38072a47b62 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 18 Sep 2026 16:49:25 +0800 Subject: [PATCH 15/16] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pda/service/ReceivingContainerService.java | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index 3f1e70f3b..2c3b85714 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -754,17 +754,18 @@ public class ReceivingContainerService { List containerPlans = plans.selectList(new LambdaQueryWrapper() .eq(InboundPlan::getContainerNo, containerNo).orderByAsc(InboundPlan::getId)); for (InboundPlan plan : containerPlans) { - String customerId = trim(plan.getCustomerId()); - if (customerId == null) throw conflict("Inbound plan has no owning customer"); for (InboundPlanLine line : lines.selectList(new LambdaQueryWrapper() - .eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getPlanId, plan.getId()) + .eq(InboundPlanLine::getPlanId, plan.getId()) .orderByAsc(InboundPlanLine::getId))) { LastlegOrder order = orders.selectById(line.getOrderId()); - if (order == null || !customerId.equals(order.getCustomerId()) + if (order == null + || !Objects.equals(sourceContainerBookingId, trim(order.getSourceContainerBookingId()))) continue; + String customerId = trim(plan.getCustomerId()); + if (customerId == null || !customerId.equals(order.getCustomerId()) + || !customerId.equals(line.getCustomerId()) || !Objects.equals(containerNo, trim(order.getContainerNo()))) { throw conflict("Inbound line order is missing or belongs to another cabinet occurrence"); } - if (!Objects.equals(sourceContainerBookingId, trim(order.getSourceContainerBookingId()))) continue; result.add(new InboundRef(plan, line, order)); } } -- 2.54.0 From 1fb02db516ab9595ec257ca0089150692b788c74 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 18 Sep 2026 18:02:27 +0800 Subject: [PATCH 16/16] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 102 +++++++++--------- Jenkinsfile | 99 +++++++++++++++++ docs/backend-business-index.md | 58 ++++++++++ lastleg-service/AGENTS.md | 98 ++++++++--------- lastleg-service/docs/business-index.md | 68 ++++++++++++ scripts/agents-index-contract.ps1 | 38 ++++++- scripts/controller-pipeline-contract.test.mjs | 13 +++ 7 files changed, 368 insertions(+), 108 deletions(-) create mode 100644 docs/backend-business-index.md create mode 100644 lastleg-service/docs/business-index.md diff --git a/AGENTS.md b/AGENTS.md index aa72ee8ff..896e86841 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,58 +16,58 @@ ## 服务路由(按关键词定位) -| 任务关键词 | 首选服务 | 先看入口 | -|---|---|---| -| 登录、Token、密码、注册与顶层授权快照 | `auth/` | `auth/AGENTS.md`;海外 Token 只携带稳定身份与版本,角色、权限和冻结海外仓从登录/刷新响应顶层快照读取,海外 Gateway 再按 IAM 安全投影失败关闭校验 | -| 客户档案、成员、审核、RBAC、授权快照 | `iam/` | `iam/AGENTS.md` | -| 客户建档默认箱规与权重系数初始化及补偿 | `iam/`、`firstleg/` | IAM `CustomerDimensionRuleInitializationService` 持久化意图、`CustomerDimensionInitializationRelay` 后台投递;Firstleg 内部入口 `/internal/firstleg/v1/customer-dimension-configs/defaults` 在同一事务生成四档尺寸和默认权重系数规则,未全部完成不得标记 `READY` 或审核通过 | -| 消息中心、站内通知、消息模板、收件箱 | `iam/` | `iam/AGENTS.md`,再按 `src/main/java/com/baiyue/iam/notification/` 定位;业务事件生产者回到所属服务 | -| 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md`;统一列表由 `UnifiedOrderQueryService` 组合交易与履约投影,仍固定显示 `PENDING_REVIEW` 并提供审核动作:Order 为 `SUBMITTED` 时必须优先返回 `PENDING_REVIEW/APPROVE_ORDER`,不得被 Firstleg 准备中状态覆盖 | -| 客户头程订单中文批量导入 | `order/` | `MerchantBatchImportService.java`、`MerchantWarehouseCatalogClient.java`、`MerchantPricingCatalogClient.java`、`order-v1.yaml`;模板和导入都按当前客户读取动态地址、全部有效仓库与渠道,最多 50 行,只创建草稿,支持 `PARTIAL/ATOMIC` | -| 平台修改客户原始申报、履约渠道变更与跨服务同步 | `order/`、`firstleg/` | Order `OrderDeclarationUpdateService.java`、`OrderInboundOutboxPublisher.java`、`order-v1.yaml`;Firstleg `OrderDeclarationSyncService.java`、`OrderDeclarationSyncMapper.java`、`firstleg-v1.yaml`。只更新申报快照;渠道变化先失败关闭校验履约可逆性与重新计价,成功后由版本化 `ORDER_DECLARATION_UPDATED_V1` 幂等同步,不覆盖实测、箱码或已确认费用 | -| 平台订单批量审核预览 | `order/` | `PlatformFirstLegOrderController.java` 的 `/batch-review-preview`、`BatchOrderReviewPreviewService.java`、`BatchOrderReviewPreviewResponse.java`、`order-v1.yaml`;一次请求固定用订单与货物两次批量查询返回同序快照,不允许前端逐票请求详情,逐项资格以 `canApprove/blockReason` 为准 | -| 履约分配、入库、测量、箱规算法、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md`;客户四档箱规与 `ADD/MULTIPLY` 默认算法从 `CustomerDimensionConfigService` 进入,单箱测量和最终计价分别由 `CustomerDimensionWeightingService`、`FbaPricingService` 保存并消费服务端快照 | -| 入库单扣货、解除、凭证与履约门禁 | `firstleg/`、`file/` | Firstleg `InboundHoldService`、`InboundHoldGuard`、`InboundHoldEvidenceService`;平台接口 `/api/platform/inbounds/{inboundId}/hold`、`/release`、`/evidence`。扣货不改变 Order 主状态,命令使用入库聚合版本和幂等键;凭证只按客户、入库单及 `INBOUND_HOLD_EVIDENCE` 精确归属,所有装柜、PDA 拣货和国内出仓写入口统一复用门禁并失败关闭 | -| 称重确认、整票库位块与 PDA 资源结算 | `firstleg/`、`warehouse/` | Firstleg `MeasurementLocationOccupationService.java`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`WarehousePalletLocationClient.java`;Warehouse `LocationBlockService.java` 与 `location-block-v1.yaml`。称重确认尽力建块、提交时再次补齐,库位指引失败不阻断测量主流程;部分拣货整段转移给余量子单,整票拣完才整块释放,A0 共享资源按 `reservationId` 区分 | -| 客户专属价格簿、柜级阶梯运费与受控调价 | `firstleg/` | `PlatformPriceBookService.java`、`ActivePriceBookEstimator.java`、`FbaPricingService.java`、`pricing/ContainerTierAmountAllocator.java`、`InboundFeeDetailService.java`、`fba-pricing-v1.yaml`。`customerId=0` 为公共报价,正数为客户专属版本;计价优先专属 ACTIVE、缺失才回退公共 ACTIVE。同柜区域重量阶梯按实际成员计费量聚合命中并守恒分摊,装柜变化只重算未锁定客户费用;人工计费量调整限基准上下 10%,私人仓尾程派送费只绑定 `PRIVATE` 入库单;一件代发 `WAIT_PALLETIZE` 允许在预装确认前试算,写费用时必须保留托盘化状态 | -| Firstleg 内部模块化与依赖基线 | `firstleg/` | `firstleg/docs/firstleg-internal-modularization-baseline.md`、`firstleg/scripts/analyze-firstleg-dependencies.mjs`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 | -| Mapper 新增、移动、Bean 缺失与扫描遗漏 | 目标持久化服务 | 所有 Mapper 显式 `@Mapper`,启动类按服务根包注解过滤扫描;先运行 `node scripts/mapper-scan-contract.mjs`,再按目标模块执行 `test-compile/test`,不得等部署启动发现 | -| 脚本管理、全量验证、测试造数与诊断 | 仓库根 `scripts/`;服务专属脚本留在 `/scripts/` | 先读 `scripts/AGENTS.md` 和 `scripts/README.md`;Admin HTTP 内部模块继续读 `scripts/admin-test-env-modules/README.md`。新增入口必须登记用途、目标环境、数据副作用和报告位置,并通过 `scripts/script-documentation-contract.test.mjs` | -| 分柜、柜次、智能排柜、预装/装柜清单、真实柜号后补、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位;创建柜次时原子生成唯一预装计划,加入入仓码时同步建立关联,不设推送步骤;预装/正式详情由 `/loading-plans/{planId}/lifecycle` 解析。真实集装箱号不阻塞订舱关联、计划确认或 PDA task 创建,可在 `EDITING/LOADING` 首次补录,但必须在 PDA 最终完成前存在。上传以幂等键绑定包含文件内容的请求哈希,完全重放不得再次调用 File | -| 合柜报关 / 同柜不可拆组、跨客户合柜、整组分配/移除 | `firstleg/` | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`、`src/main/resources/openapi/coload-groups-v1.yaml`;按订单建组并冻结全部可用入仓片段,仅要求同国内仓、同渠道 | -| 全局智能装柜候选、柜次、占位与移除 | `firstleg/`、`order/` | Firstleg `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/containers`、`/plans/{planId}/inbound-assignments`、`/pending-review-assignments`、`/group-assignments`、`/unpicked-items`,业务编排 `service/SmartLoadingService.java`;待审核事实由 `client/PendingReviewOrderClient.java` 经内部令牌查询 Order `/internal/order/v1/pending-review-orders` 与 `PendingReviewOrderQueryService.java` 的权威 `SUBMITTED/PENDING_REVIEW` 行。候选四态只用于筛选展示,资格以 `canAssign` 为准;待审核或待测量可先占位,确认预装和启动 PDA 前仍须进入 `WAIT_SPLIT`;计划明细总量/余量由 Order 原始申报或最新已审核测量补齐,调整时重验权威数量;人工加入允许跨渠道,智能推荐仍按柜次渠道;同柜组整组分页、分配和移除 | -| 客户入库结果与头程费用确认 | `firstleg/` | `controller/MerchantInboundBillingController.java`、`service/MerchantInboundBillingService.java`、`FbaPricingService.java`,接口 `/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing` 与 `/confirm`;客户按登录态范围读取并以费用版本和幂等键确认当前 `SENT` 费用,确认后锁定 | -| 订舱资料/打印、船期、订舱级拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`TruckAppointmentService.java`、`outbound/dispatch/`、`customs/`;拖车只绑定 `RELEASED` 订舱,不携带客户、柜次、装柜计划或真实柜号,每订舱最多一张非终态预约,订舱文件联查 `file/AGENTS.md` | -| 目的港码头拖车与还空预警 | `firstleg/` | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`src/main/resources/openapi/terminal-drayage-v1.yaml`;按 `containerBookingId` 聚合并与订舱级拖车分离,引用 Warehouse `ACTIVE + TRUCKING` 供应商,提柜/还柜复用船节点,服务端按海外卸柜完成时间加 72 小时计算还空预警 | -| 客户账期、费用池、账单、入仓码联查、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;客户账单行 `inboundCode` 由 `FinanceQueryService` 从费用来源快照提供,Client 复用头程入仓关系详情;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` | -| 客户资金账户、充值、授信、冻结、结算与跨区资金指令 | `finance/`、`lastleg-service/`、`common-event-contract/`、`event-bridge-service/` | 国内 `FinanceWalletService.java` 持有唯一 CNY 主账和不可变流水;平台、客户、内部入口见 `PlatformFinanceWalletController.java`、`ClientFinanceWalletController.java`、`InternalFinanceWalletController.java` 与 `finance-wallet-v1.yaml`。海外采购和换标由 Lastleg `wallet/` 发布 `WALLET_COMMAND_REQUESTED`,Finance 冻结、结算或释放后发布 `WALLET_COMMAND_COMPLETED` 回传,不跨区域同步调用钱包 | -| 柜级公共费用跨客户分摊 | `firstleg/`、`finance/` | Firstleg `PlatformContainerFeeDetailController.java`、`ContainerFeeDetailService.java`、`container-fees-v1.yaml` 按柜内真实客户与订单原子创建 `allocation-groups`;金额合计必须等于总额,分组审核、驳回、调整、作废均整组处理,Finance 只消费各客户不可变来源快照 | -| 海外业务费用来源跨区入池 | `lastleg-service/`、`common-event-contract/`、`event-bridge-service/`、`finance/` | Lastleg `financefee/` 审核五类海外费用并发布 `BUSINESS_FEE_SOURCE_CHANGED` 完整快照;Event Bridge 对 `targetRegion=CN` 定向投递 Finance `/internal/finance/v1/business-fee-source-events`;Finance 按事件 ID、Payload 哈希、来源系统/费用 ID/版本幂等入池,`PAYABLE + DIRECT_PAID` 单独形成已确认成本且不进入 AP 待结算 | -| 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` | 业务费用与付款快照见 `firstleg/AGENTS.md` 的 `FeePaymentSnapshotValidator`;`/api/platform/finance/v1/vouchers` 上传凭证,File 按 `FINANCE_VOUCHER` 和客户范围校验 | -| 仓库、往来单位、快递、托盘、库位 | `warehouse/` | `warehouse/AGENTS.md` | -| 供应商、国内整柜库存与出入库 | `warehouse/` | `warehouse/AGENTS.md`;OpenAPI `warehouse/src/main/resources/openapi/supplier-v1.yaml`、`domestic-container-v1.yaml` | -| 客户运营统计、在途柜量 | `firstleg/` | `firstleg/AGENTS.md`;OpenAPI `firstleg/src/main/resources/openapi/customer-statistics-v1.yaml` | -| 平台操作日志与审计查询 | `audit-service/` | `audit-service/AGENTS.md`;平台查询接口 `/api/platform/v1/audit/logs` | -| 资质、导出、导出退避重试、测量图片、文件下载 | `file/` | `file/AGENTS.md`;法人身份证选填,可在已有营业执照后单独补传或替换;`PENDING` 且已有失败尝试的退避任务可人工立即到期并复用当前投递,不创建并发任务 | -| 路由、CORS、JWT 入口、访问域 | `gateway/` | `gateway/AGENTS.md` | -| 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` | -| 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` | -| 海外 IAM 状态与权限版本投影 | `lastleg-service/security/` | `lastleg-service/AGENTS.md`;内部入口 `/internal/lastleg/security/v1/events`、`/validate`;消费三类 IAM 安全事件并为海外 Gateway 提供 fail-closed 校验,投影表归 `baiyue_lastleg` | -| 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,尾程订单控制从 `LastlegOrderController.java`、`LastlegOrderCommandService.java` 进入,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 | -| 尾程订单控制、柜级预览与入仓计划转换 | `lastleg-service/`、`warehouse-overseas-service/` | `orders/container-summaries` 按 `sourceContainerBookingId` 跨客户聚合并分页返回全部稳定来源柜,已完成计划转换的柜仍用于接柜仓查询与修改,生成资格由 `allPlansReady/pendingPlanCount` 表达,详情返回全柜数据。`inbound-plans/container-preview` 由 `InboundPlanService.java` 返回服务端分组、托数、阻断项及快照版本/哈希;`ContainerInboundPlanConversionCoordinator.java` 校验同一快照并以唯一 Saga 先向 `WarehousePalletBatchService.java` 批量预留真实托盘,再本地原子生成剩余独立计划并确认资源,失败只补偿未确认资源,同柜重放不得新建第二批。暂扣/解除和固定八行人工节点进入 `LastlegOrderCommandService.java`;命令按订单版本、客户范围和幂等键执行,暂扣中的订单不得进入排车或出库 | -| 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | 平台入口 `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`;PDA 公网入口 `inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,旧 planNo/allocationId Controller 已移除 | -| 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 | -| 海外排车执行、PDA 出库、派送与一件代发 | `lastleg-service/` | `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`OutboundPdaController.java`、`MerchantDeliveryController.java`,一件代发库存归 `dropship/`;统一经 `gateway-overseas/`,设备端正式入口继续查 `../apps/pda-overseas-uniapp/AGENTS.md` | -| 海外异常退回、库存恢复与客户回访 | `lastleg-service/`、`file/` | `exceptionorder/`、`inventory/command/`、`customervisit/`;异常按版本和幂等键执行 `PENDING → PROCESSING → COMPLETED`,完成证据固定归 `OVERSEAS_EXCEPTION_EVIDENCE`。派送退回明细只能来自原出库拣货事实,退回海外仓时校验真实目标托盘/库位并以 `RETURN_STOCK_IN` 恢复库存后,原子完成异常单与派送 `RETURNED`;回访仍以 Lastleg 客户范围和派送状态为准 | -| 一件代发 SKU/箱唛与暂扣跨区协同 | `order/`、`firstleg/`、`common-event-contract/`、`lastleg-service/` | Order `InternalOrderCargoController` 提供强制 SKU 与每箱数量;Firstleg `InboundCartonLabelService` 冻结箱唛 SKU,`DropshipPalletizationService` 完成清点、组托和封托,智能排柜只按 `SEALED` 整托分配,并消费 `OverseasInboundOrderDetainedChangedV1`;Lastleg `dropship/` 建立库存并发布暂扣变更 | -| 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`relabel/pda/`、`transfer/`、`discard/` 的 Merchant/PDA Controller 和 Service;换标下单按历史入仓库存选择,每个我方箱唛只形成一条箱任务,支持箱唛、SKU 和组合换标,SKU 子项不重复冻结。涉及箱唛换标时冻结派送方式,PDA 逐箱作业并上传完工照片,仓库复核后必须由客户查看证据确认,之后卡车进入预排车,自提/快递进入换标出库待创建;不伪造已入库单。Client 消费端 `../html/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` | -| 海外仓 PDA 盘点、移库、托盘、换标与查询 | `lastleg-service/`、`warehouse-overseas-service/` | Lastleg `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`palletops/`、`relabel/pda/`;托盘作业保留托盘-入仓单组成明细,合托通过入仓计划托盘分配和合托记录留痕;`pallet-operations/relabel-disassemble` 按换标单箱唛快照解析原库存仓位,在一个幂等事务内移动对应冻结箱数并绑定已扫码目标托盘,同时更新换标明细和未开始拣货的出库托盘/箱唛快照;出库可扫描托盘但后台按托盘下各入仓单箱数核平;契约 `lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml`;物理托盘流转与补偿进入 Warehouse `InternalPdaPalletTransitionController`,设备端入口是否开放以 `../apps/pda-overseas-uniapp/AGENTS.md` 为准 | -| 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` | Firstleg `portschedule/service/CarrierScheduleNodeOutboxService.java` 发布 `FirstLegContainerShippingNodeRecordedV1`;Lastleg `shipping/service/ContainerShippingNodeConsumer.java` 单调消费,只有实际 `PICKUP_READY` 及以后节点允许海外 PDA 候选进入可收货 | -| 海外仓库目录、海外库位、海外托盘、资源锁 | 国内仓库目录归 `warehouse/`,海外资源归 `warehouse-overseas-service/` | `warehouse/AGENTS.md`、`warehouse-overseas-service/AGENTS.md`;海外库只保存国内 warehouseId 引用 | -| Lastleg 内部模块化与依赖基线 | `lastleg-service/` | `lastleg-service/docs/lastleg-internal-modularization-baseline.md`、`lastleg-service/scripts/analyze-lastleg-dependencies.mjs`;只用于约束服务内部依赖,不改变服务和数据库边界 | -| PDF 标签、审计、跨区双向事件 | `print/`、`audit-service/`、`event-bridge-service/` | 事件模型先读 `common-event-contract/AGENTS.md`;国内真实生产验收 `firstleg/.../TaillegRealProducerAcceptanceTest.java`、海外真实消费验收 `lastleg-service/.../TaillegRealConsumerAcceptanceTest.java`;可靠投递与 CN/OVERSEAS 目标路由读 `event-bridge-service/AGENTS.md` | +本表只保留定位入口;命中业务后必须用 `rg -n -F '关键词' docs/backend-business-index.md` 读取对应行/段的详细约束,再沿代码引用追踪;不默认全文加载详细索引。已知目标文件时直接定位,并定向核对该业务约束。 -散货托盘码与跨区传递:`firstleg/` 的 `LoadingPalletLabelService.java` 生成冻结标签并调用 Print 输出 PDF,`LoadingCompletedOutboxService.java` 传递装柜快照;`lastleg-service/` 通过 `LastlegSourcePalletMapper.java` 保存来源托盘,`InboundPlanService.java` 和 `ContainerInboundPlanConversionCoordinator.java` 消费。标签与资源预留的细节继续读对应服务指引。 +| 任务关键词 | 首选服务 | +|---|---| +| 登录、Token、密码、注册与顶层授权快照 | `auth/` | +| 客户档案、成员、审核、RBAC、授权快照 | `iam/` | +| 客户建档默认箱规与权重系数初始化及补偿 | `iam/`、`firstleg/` | +| 消息中心、站内通知、消息模板、收件箱 | `iam/` | +| 订单、货物、快照、状态、幂等、Outbox | `order/` | +| 客户头程订单中文批量导入 | `order/` | +| 平台修改客户原始申报、履约渠道变更与跨服务同步 | `order/`、`firstleg/` | +| 平台订单批量审核预览 | `order/` | +| 履约分配、入库、测量、箱规算法、审核、FBA 计费 | `firstleg/` | +| 入库单扣货、解除、凭证与履约门禁 | `firstleg/`、`file/` | +| 称重确认、整票库位块与 PDA 资源结算 | `firstleg/`、`warehouse/` | +| 客户专属价格簿、柜级阶梯运费与受控调价 | `firstleg/` | +| Firstleg 内部模块化与依赖基线 | `firstleg/` | +| Mapper 新增、移动、Bean 缺失与扫描遗漏 | 目标持久化服务 | +| 脚本管理、全量验证、测试造数与诊断 | 仓库根 `scripts/`;服务专属脚本留在 `/scripts/` | +| 分柜、柜次、智能排柜、预装/装柜清单、真实柜号后补、PDA | `firstleg/` | +| 合柜报关 / 同柜不可拆组、跨客户合柜、整组分配/移除 | `firstleg/` | +| 全局智能装柜候选、柜次、占位与移除 | `firstleg/`、`order/` | +| 客户入库结果与头程费用确认 | `firstleg/` | +| 订舱资料/打印、船期、订舱级拖车、报关 | `firstleg/` | +| 目的港码头拖车与还空预警 | `firstleg/` | +| 客户账期、费用池、账单、入仓码联查、收款、成本、核销 | `finance/` | +| 客户资金账户、充值、授信、冻结、结算与跨区资金指令 | `finance/`、`lastleg-service/`、`common-event-contract/`、`event-bridge-service/` | +| 柜级公共费用跨客户分摊 | `firstleg/`、`finance/` | +| 海外业务费用来源跨区入池 | `lastleg-service/`、`common-event-contract/`、`event-bridge-service/`、`finance/` | +| 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` | +| 仓库、往来单位、快递、托盘、库位 | `warehouse/` | +| 供应商、国内整柜库存与出入库 | `warehouse/` | +| 客户运营统计、在途柜量 | `firstleg/` | +| 平台操作日志与审计查询 | `audit-service/` | +| 资质、导出、导出退避重试、测量图片、文件下载 | `file/` | +| 路由、CORS、JWT 入口、访问域 | `gateway/` | +| 百度/Amazon/OCR/第三方适配 | `integration-service/` | +| 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | +| 海外 IAM 状态与权限版本投影 | `lastleg-service/security/` | +| 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | +| 尾程订单控制、柜级预览与入仓计划转换 | `lastleg-service/`、`warehouse-overseas-service/` | +| 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | +| 海外仓排车查询、候选与智能预排 | `lastleg-service/` | +| 海外排车执行、PDA 出库、派送与一件代发 | `lastleg-service/` | +| 海外异常退回、库存恢复与客户回访 | `lastleg-service/`、`file/` | +| 一件代发 SKU/箱唛与暂扣跨区协同 | `order/`、`firstleg/`、`common-event-contract/`、`lastleg-service/` | +| 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | +| 海外仓 PDA 盘点、移库、托盘、换标与查询 | `lastleg-service/`、`warehouse-overseas-service/` | +| 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` | +| 海外仓库目录、海外库位、海外托盘、资源锁 | 国内仓库目录归 `warehouse/`,海外资源归 `warehouse-overseas-service/` | +| Lastleg 内部模块化与依赖基线 | `lastleg-service/` | +| PDF 标签、审计、跨区双向事件 | `print/`、`audit-service/`、`event-bridge-service/` | ## 公共模块快速索引 diff --git a/Jenkinsfile b/Jenkinsfile index b95e6abfa..d12eddd11 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -59,6 +59,42 @@ def databaseMigrationPaths = ['deploy/sql/'] def downstreamResults = [:] +def sendProductionWeComNotification(String status, String color, String detail) { + node('java-builder') { + withCredentials([string(credentialsId: 'wecom-prod-webhook', variable: 'WECOM_WEBHOOK')]) { + String sourceVersion = env.IMAGE_TAG ?: env.SOURCE_COMMIT ?: env.GIT_COMMIT ?: '—' + String content = """## 生产部署通知 +> 项目:${env.JOB_NAME} +> 构建:[#${env.BUILD_NUMBER}](${env.BUILD_URL}) +> 提交:${sourceVersion} +> 状态:${status} +> 详情:${detail} +> 时间:${new Date().format('yyyy-MM-dd HH:mm:ss', TimeZone.getTimeZone('Asia/Shanghai'))} +""" + String payloadFile = 'wecom-production-notification.json' + writeFile( + file: payloadFile, + text: groovy.json.JsonOutput.toJson([ + msgtype: 'markdown', + markdown: [content: content] + ]), + encoding: 'UTF-8' + ) + sh ''' + set -eu + payload_file='wecom-production-notification.json' + trap 'rm -f "$payload_file"' EXIT HUP INT TERM + response="$(curl --fail --silent --show-error \ + -H 'Content-Type: application/json' \ + --data-binary @"$payload_file" \ + "$WECOM_WEBHOOK")" + printf '%s\n' "$response" + printf '%s' "$response" | grep -Eq '"errcode"[[:space:]]*:[[:space:]]*0([[:space:]]*[,}])' + ''' + } + } +} + def parseAutoBuildState(String description, List allowedServices) { if (!description?.startsWith('AUTO ')) return null int markerIndex = description.indexOf(' | ') @@ -418,4 +454,67 @@ pipeline { } } + post { + success { + script { + if (env.JOB_BASE_NAME == 'backend-java-prod' && params.DEPLOY && env.NO_SERVICE_CHANGE != 'true') { + try { + sendProductionWeComNotification( + '部署成功', + 'info', + "服务:${env.SELECTED_SERVICES ?: '—'}" + ) + } catch (Exception error) { + echo "企业微信通知失败,不影响原构建结果:${error.message}" + } + } + } + } + unstable { + script { + if (env.JOB_BASE_NAME == 'backend-java-prod' && params.DEPLOY) { + try { + sendProductionWeComNotification( + '部署部分失败', + 'warning', + currentBuild.description ?: '部分服务部署未成功,请查看 Jenkins 日志' + ) + } catch (Exception error) { + echo "企业微信通知失败,不影响原构建结果:${error.message}" + } + } + } + } + failure { + script { + if (env.JOB_BASE_NAME == 'backend-java-prod' && params.DEPLOY) { + try { + sendProductionWeComNotification( + '部署失败', + 'warning', + '生产发布流程失败,请打开 Jenkins 构建日志查看失败阶段' + ) + } catch (Exception error) { + echo "企业微信通知失败,不影响原构建结果:${error.message}" + } + } + } + } + aborted { + script { + if (env.JOB_BASE_NAME == 'backend-java-prod' && params.DEPLOY) { + try { + sendProductionWeComNotification( + '部署已取消', + 'comment', + '生产部署流程被人工取消或中止' + ) + } catch (Exception error) { + echo "企业微信通知失败,不影响原构建结果:${error.message}" + } + } + } + } + } + } diff --git a/docs/backend-business-index.md b/docs/backend-business-index.md new file mode 100644 index 000000000..c28c57c98 --- /dev/null +++ b/docs/backend-business-index.md @@ -0,0 +1,58 @@ +# 后端业务详细索引 + +本文件是同服务 `AGENTS.md` 的按需补充;命中业务后读取相应行/段,不默认全文加载。路径保持以后端仓库根为基准,各服务简写代码路径进入对应服务定位,不相对于本文件的 `docs/` 目录。 + +## 服务路由(按关键词定位) + +| 任务关键词 | 首选服务 | 先看入口 | +|---|---|---| +| 登录、Token、密码、注册与顶层授权快照 | `auth/` | `auth/AGENTS.md`;海外 Token 只携带稳定身份与版本,角色、权限和冻结海外仓从登录/刷新响应顶层快照读取,海外 Gateway 再按 IAM 安全投影失败关闭校验 | +| 客户档案、成员、审核、RBAC、授权快照 | `iam/` | `iam/AGENTS.md` | +| 客户建档默认箱规与权重系数初始化及补偿 | `iam/`、`firstleg/` | IAM `CustomerDimensionRuleInitializationService` 持久化意图、`CustomerDimensionInitializationRelay` 后台投递;Firstleg 内部入口 `/internal/firstleg/v1/customer-dimension-configs/defaults` 在同一事务生成四档尺寸和默认权重系数规则,未全部完成不得标记 `READY` 或审核通过 | +| 消息中心、站内通知、消息模板、收件箱 | `iam/` | `iam/AGENTS.md`,再按 `src/main/java/com/baiyue/iam/notification/` 定位;业务事件生产者回到所属服务 | +| 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md`;统一列表由 `UnifiedOrderQueryService` 组合交易与履约投影,仍固定显示 `PENDING_REVIEW` 并提供审核动作:Order 为 `SUBMITTED` 时必须优先返回 `PENDING_REVIEW/APPROVE_ORDER`,不得被 Firstleg 准备中状态覆盖 | +| 客户头程订单中文批量导入 | `order/` | `MerchantBatchImportService.java`、`MerchantWarehouseCatalogClient.java`、`MerchantPricingCatalogClient.java`、`order-v1.yaml`;模板和导入都按当前客户读取动态地址、全部有效仓库与渠道,最多 50 行,只创建草稿,支持 `PARTIAL/ATOMIC` | +| 平台修改客户原始申报、履约渠道变更与跨服务同步 | `order/`、`firstleg/` | Order `OrderDeclarationUpdateService.java`、`OrderInboundOutboxPublisher.java`、`order-v1.yaml`;Firstleg `OrderDeclarationSyncService.java`、`OrderDeclarationSyncMapper.java`、`firstleg-v1.yaml`。只更新申报快照;渠道变化先失败关闭校验履约可逆性与重新计价,成功后由版本化 `ORDER_DECLARATION_UPDATED_V1` 幂等同步,不覆盖实测、箱码或已确认费用 | +| 平台订单批量审核预览 | `order/` | `PlatformFirstLegOrderController.java` 的 `/batch-review-preview`、`BatchOrderReviewPreviewService.java`、`BatchOrderReviewPreviewResponse.java`、`order-v1.yaml`;一次请求固定用订单与货物两次批量查询返回同序快照,不允许前端逐票请求详情,逐项资格以 `canApprove/blockReason` 为准 | +| 履约分配、入库、测量、箱规算法、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md`;客户四档箱规与 `ADD/MULTIPLY` 默认算法从 `CustomerDimensionConfigService` 进入,单箱测量和最终计价分别由 `CustomerDimensionWeightingService`、`FbaPricingService` 保存并消费服务端快照 | +| 入库单扣货、解除、凭证与履约门禁 | `firstleg/`、`file/` | Firstleg `InboundHoldService`、`InboundHoldGuard`、`InboundHoldEvidenceService`;平台接口 `/api/platform/inbounds/{inboundId}/hold`、`/release`、`/evidence`。扣货不改变 Order 主状态,命令使用入库聚合版本和幂等键;凭证只按客户、入库单及 `INBOUND_HOLD_EVIDENCE` 精确归属,所有装柜、PDA 拣货和国内出仓写入口统一复用门禁并失败关闭 | +| 称重确认、整票库位块与 PDA 资源结算 | `firstleg/`、`warehouse/` | Firstleg `MeasurementLocationOccupationService.java`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`WarehousePalletLocationClient.java`;Warehouse `LocationBlockService.java` 与 `location-block-v1.yaml`。称重确认尽力建块、提交时再次补齐,库位指引失败不阻断测量主流程;部分拣货整段转移给余量子单,整票拣完才整块释放,A0 共享资源按 `reservationId` 区分 | +| 客户专属价格簿、柜级阶梯运费与受控调价 | `firstleg/` | `PlatformPriceBookService.java`、`ActivePriceBookEstimator.java`、`FbaPricingService.java`、`pricing/ContainerTierAmountAllocator.java`、`InboundFeeDetailService.java`、`fba-pricing-v1.yaml`。`customerId=0` 为公共报价,正数为客户专属版本;计价优先专属 ACTIVE、缺失才回退公共 ACTIVE。同柜区域重量阶梯按实际成员计费量聚合命中并守恒分摊,装柜变化只重算未锁定客户费用;人工计费量调整限基准上下 10%,私人仓尾程派送费只绑定 `PRIVATE` 入库单;一件代发 `WAIT_PALLETIZE` 允许在预装确认前试算,写费用时必须保留托盘化状态 | +| Firstleg 内部模块化与依赖基线 | `firstleg/` | `firstleg/docs/firstleg-internal-modularization-baseline.md`、`firstleg/scripts/analyze-firstleg-dependencies.mjs`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 | +| Mapper 新增、移动、Bean 缺失与扫描遗漏 | 目标持久化服务 | 所有 Mapper 显式 `@Mapper`,启动类按服务根包注解过滤扫描;先运行 `node scripts/mapper-scan-contract.mjs`,再按目标模块执行 `test-compile/test`,不得等部署启动发现 | +| 脚本管理、全量验证、测试造数与诊断 | 仓库根 `scripts/`;服务专属脚本留在 `/scripts/` | 先读 `scripts/AGENTS.md` 和 `scripts/README.md`;Admin HTTP 内部模块继续读 `scripts/admin-test-env-modules/README.md`。新增入口必须登记用途、目标环境、数据副作用和报告位置,并通过 `scripts/script-documentation-contract.test.mjs` | +| 分柜、柜次、智能排柜、预装/装柜清单、真实柜号后补、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位;创建柜次时原子生成唯一预装计划,加入入仓码时同步建立关联,不设推送步骤;预装/正式详情由 `/loading-plans/{planId}/lifecycle` 解析。真实集装箱号不阻塞订舱关联、计划确认或 PDA task 创建,可在 `EDITING/LOADING` 首次补录,但必须在 PDA 最终完成前存在。上传以幂等键绑定包含文件内容的请求哈希,完全重放不得再次调用 File | +| 合柜报关 / 同柜不可拆组、跨客户合柜、整组分配/移除 | `firstleg/` | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`、`src/main/resources/openapi/coload-groups-v1.yaml`;按订单建组并冻结全部可用入仓片段,仅要求同国内仓、同渠道 | +| 全局智能装柜候选、柜次、占位与移除 | `firstleg/`、`order/` | Firstleg `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/containers`、`/plans/{planId}/inbound-assignments`、`/pending-review-assignments`、`/group-assignments`、`/unpicked-items`,业务编排 `service/SmartLoadingService.java`;待审核事实由 `client/PendingReviewOrderClient.java` 经内部令牌查询 Order `/internal/order/v1/pending-review-orders` 与 `PendingReviewOrderQueryService.java` 的权威 `SUBMITTED/PENDING_REVIEW` 行。候选四态只用于筛选展示,资格以 `canAssign` 为准;待审核或待测量可先占位,确认预装和启动 PDA 前仍须进入 `WAIT_SPLIT`;计划明细总量/余量由 Order 原始申报或最新已审核测量补齐,调整时重验权威数量;人工加入允许跨渠道,智能推荐仍按柜次渠道;同柜组整组分页、分配和移除 | +| 客户入库结果与头程费用确认 | `firstleg/` | `controller/MerchantInboundBillingController.java`、`service/MerchantInboundBillingService.java`、`FbaPricingService.java`,接口 `/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing` 与 `/confirm`;客户按登录态范围读取并以费用版本和幂等键确认当前 `SENT` 费用,确认后锁定 | +| 订舱资料/打印、船期、订舱级拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`TruckAppointmentService.java`、`outbound/dispatch/`、`customs/`;拖车只绑定 `RELEASED` 订舱,不携带客户、柜次、装柜计划或真实柜号,每订舱最多一张非终态预约,订舱文件联查 `file/AGENTS.md` | +| 目的港码头拖车与还空预警 | `firstleg/` | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`src/main/resources/openapi/terminal-drayage-v1.yaml`;按 `containerBookingId` 聚合并与订舱级拖车分离,引用 Warehouse `ACTIVE + TRUCKING` 供应商,提柜/还柜复用船节点,服务端按海外卸柜完成时间加 72 小时计算还空预警 | +| 客户账期、费用池、账单、入仓码联查、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;客户账单行 `inboundCode` 由 `FinanceQueryService` 从费用来源快照提供,Client 复用头程入仓关系详情;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` | +| 客户资金账户、充值、授信、冻结、结算与跨区资金指令 | `finance/`、`lastleg-service/`、`common-event-contract/`、`event-bridge-service/` | 国内 `FinanceWalletService.java` 持有唯一 CNY 主账和不可变流水;平台、客户、内部入口见 `PlatformFinanceWalletController.java`、`ClientFinanceWalletController.java`、`InternalFinanceWalletController.java` 与 `finance-wallet-v1.yaml`。海外采购和换标由 Lastleg `wallet/` 发布 `WALLET_COMMAND_REQUESTED`,Finance 冻结、结算或释放后发布 `WALLET_COMMAND_COMPLETED` 回传,不跨区域同步调用钱包 | +| 柜级公共费用跨客户分摊 | `firstleg/`、`finance/` | Firstleg `PlatformContainerFeeDetailController.java`、`ContainerFeeDetailService.java`、`container-fees-v1.yaml` 按柜内真实客户与订单原子创建 `allocation-groups`;金额合计必须等于总额,分组审核、驳回、调整、作废均整组处理,Finance 只消费各客户不可变来源快照 | +| 海外业务费用来源跨区入池 | `lastleg-service/`、`common-event-contract/`、`event-bridge-service/`、`finance/` | Lastleg `financefee/` 审核五类海外费用并发布 `BUSINESS_FEE_SOURCE_CHANGED` 完整快照;Event Bridge 对 `targetRegion=CN` 定向投递 Finance `/internal/finance/v1/business-fee-source-events`;Finance 按事件 ID、Payload 哈希、来源系统/费用 ID/版本幂等入池,`PAYABLE + DIRECT_PAID` 单独形成已确认成本且不进入 AP 待结算 | +| 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` | 业务费用与付款快照见 `firstleg/AGENTS.md` 的 `FeePaymentSnapshotValidator`;`/api/platform/finance/v1/vouchers` 上传凭证,File 按 `FINANCE_VOUCHER` 和客户范围校验 | +| 仓库、往来单位、快递、托盘、库位 | `warehouse/` | `warehouse/AGENTS.md` | +| 供应商、国内整柜库存与出入库 | `warehouse/` | `warehouse/AGENTS.md`;OpenAPI `warehouse/src/main/resources/openapi/supplier-v1.yaml`、`domestic-container-v1.yaml` | +| 客户运营统计、在途柜量 | `firstleg/` | `firstleg/AGENTS.md`;OpenAPI `firstleg/src/main/resources/openapi/customer-statistics-v1.yaml` | +| 平台操作日志与审计查询 | `audit-service/` | `audit-service/AGENTS.md`;平台查询接口 `/api/platform/v1/audit/logs` | +| 资质、导出、导出退避重试、测量图片、文件下载 | `file/` | `file/AGENTS.md`;法人身份证选填,可在已有营业执照后单独补传或替换;`PENDING` 且已有失败尝试的退避任务可人工立即到期并复用当前投递,不创建并发任务 | +| 路由、CORS、JWT 入口、访问域 | `gateway/` | `gateway/AGENTS.md` | +| 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` | +| 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` | +| 海外 IAM 状态与权限版本投影 | `lastleg-service/security/` | `lastleg-service/AGENTS.md`;内部入口 `/internal/lastleg/security/v1/events`、`/validate`;消费三类 IAM 安全事件并为海外 Gateway 提供 fail-closed 校验,投影表归 `baiyue_lastleg` | +| 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,尾程订单控制从 `LastlegOrderController.java`、`LastlegOrderCommandService.java` 进入,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 | +| 尾程订单控制、柜级预览与入仓计划转换 | `lastleg-service/`、`warehouse-overseas-service/` | `orders/container-summaries` 按 `sourceContainerBookingId` 跨客户聚合并分页返回全部稳定来源柜,已完成计划转换的柜仍用于接柜仓查询与修改,生成资格由 `allPlansReady/pendingPlanCount` 表达,详情返回全柜数据。`inbound-plans/container-preview` 由 `InboundPlanService.java` 返回服务端分组、托数、阻断项及快照版本/哈希;`ContainerInboundPlanConversionCoordinator.java` 校验同一快照并以唯一 Saga 先向 `WarehousePalletBatchService.java` 批量预留真实托盘,再本地原子生成剩余独立计划并确认资源,失败只补偿未确认资源,同柜重放不得新建第二批。暂扣/解除和固定八行人工节点进入 `LastlegOrderCommandService.java`;命令按订单版本、客户范围和幂等键执行,暂扣中的订单不得进入排车或出库 | +| 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | 平台入口 `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`;PDA 公网入口 `inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,旧 planNo/allocationId Controller 已移除 | +| 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 | +| 海外排车执行、PDA 出库、派送与一件代发 | `lastleg-service/` | `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`OutboundPdaController.java`、`MerchantDeliveryController.java`,一件代发库存归 `dropship/`;统一经 `gateway-overseas/`,设备端正式入口继续查 `../apps/pda-overseas-uniapp/AGENTS.md` | +| 海外异常退回、库存恢复与客户回访 | `lastleg-service/`、`file/` | `exceptionorder/`、`inventory/command/`、`customervisit/`;异常按版本和幂等键执行 `PENDING → PROCESSING → COMPLETED`,完成证据固定归 `OVERSEAS_EXCEPTION_EVIDENCE`。派送退回明细只能来自原出库拣货事实,退回海外仓时校验真实目标托盘/库位并以 `RETURN_STOCK_IN` 恢复库存后,原子完成异常单与派送 `RETURNED`;回访仍以 Lastleg 客户范围和派送状态为准 | +| 一件代发 SKU/箱唛与暂扣跨区协同 | `order/`、`firstleg/`、`common-event-contract/`、`lastleg-service/` | Order `InternalOrderCargoController` 提供强制 SKU 与每箱数量;Firstleg `InboundCartonLabelService` 冻结箱唛 SKU,`DropshipPalletizationService` 完成清点、组托和封托,智能排柜只按 `SEALED` 整托分配,并消费 `OverseasInboundOrderDetainedChangedV1`;Lastleg `dropship/` 建立库存并发布暂扣变更 | +| 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`relabel/pda/`、`transfer/`、`discard/` 的 Merchant/PDA Controller 和 Service;换标下单按历史入仓库存选择,每个我方箱唛只形成一条箱任务,支持箱唛、SKU 和组合换标,SKU 子项不重复冻结。涉及箱唛换标时冻结派送方式,PDA 逐箱作业并上传完工照片,仓库复核后必须由客户查看证据确认,之后卡车进入预排车,自提/快递进入换标出库待创建;不伪造已入库单。Client 消费端 `../html/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` | +| 海外仓 PDA 盘点、移库、托盘、换标与查询 | `lastleg-service/`、`warehouse-overseas-service/` | Lastleg `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`palletops/`、`relabel/pda/`;托盘作业保留托盘-入仓单组成明细,合托通过入仓计划托盘分配和合托记录留痕;`pallet-operations/relabel-disassemble` 按换标单箱唛快照解析原库存仓位,在一个幂等事务内移动对应冻结箱数并绑定已扫码目标托盘,同时更新换标明细和未开始拣货的出库托盘/箱唛快照;出库可扫描托盘但后台按托盘下各入仓单箱数核平;契约 `lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml`;物理托盘流转与补偿进入 Warehouse `InternalPdaPalletTransitionController`,设备端入口是否开放以 `../apps/pda-overseas-uniapp/AGENTS.md` 为准 | +| 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` | Firstleg `portschedule/service/CarrierScheduleNodeOutboxService.java` 发布 `FirstLegContainerShippingNodeRecordedV1`;Lastleg `shipping/service/ContainerShippingNodeConsumer.java` 单调消费,只有实际 `PICKUP_READY` 及以后节点允许海外 PDA 候选进入可收货 | +| 海外仓库目录、海外库位、海外托盘、资源锁 | 国内仓库目录归 `warehouse/`,海外资源归 `warehouse-overseas-service/` | `warehouse/AGENTS.md`、`warehouse-overseas-service/AGENTS.md`;海外库只保存国内 warehouseId 引用 | +| Lastleg 内部模块化与依赖基线 | `lastleg-service/` | `lastleg-service/docs/lastleg-internal-modularization-baseline.md`、`lastleg-service/scripts/analyze-lastleg-dependencies.mjs`;只用于约束服务内部依赖,不改变服务和数据库边界 | +| PDF 标签、审计、跨区双向事件 | `print/`、`audit-service/`、`event-bridge-service/` | 事件模型先读 `common-event-contract/AGENTS.md`;国内真实生产验收 `firstleg/.../TaillegRealProducerAcceptanceTest.java`、海外真实消费验收 `lastleg-service/.../TaillegRealConsumerAcceptanceTest.java`;可靠投递与 CN/OVERSEAS 目标路由读 `event-bridge-service/AGENTS.md` | + +散货托盘码与跨区传递:`firstleg/` 的 `LoadingPalletLabelService.java` 生成冻结标签并调用 Print 输出 PDF,`LoadingCompletedOutboxService.java` 传递装柜快照;`lastleg-service/` 通过 `LastlegSourcePalletMapper.java` 保存来源托盘,`InboundPlanService.java` 和 `ContainerInboundPlanConversionCoordinator.java` 消费。标签与资源预留的细节继续读对应服务指引。 diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 96e3cf32a..ac68700b8 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -9,68 +9,58 @@ ## 快速索引 -退货预报、签收、库存、合箱与退货出库: +本表只保留定位入口;命中业务后必须用 `rg -n -F '关键词' docs/business-index.md` 读取对应行/段的详细约束,再沿代码引用追踪;不默认全文加载详细索引。已知目标文件时直接定位,并定向核对该业务约束。 -- 客户预报 `returnreceipt/controller/MerchantReturnOrderController.java` → `returnreceipt/service/ReturnReceiptService.java`,路径 `/api/merchant/v1/last-leg/return-orders`;客户范围取认证身份,预报按 SKU 保存期望数量。状态 `PENDING_RECEIPT → PARTIALLY_RECEIVED → COMPLETED` 表示签收进度,不等于换标或出库完成。 -- PDA 后端签收 `returnreceipt/controller/PdaReturnReceiptController.java`,前缀 `/api/pda/v1/last-leg/return-receipts`,依次 `start/sku-candidates/confirm`;按冻结仓和退货单校验实收,不允许未预报 SKU 或超量,幂等重放不得重复入账。确认同时生成签收箱与 SKU 库存;设备页面是否接通另查 PDA 工作台,不能以 Controller 存在代替前端接入。 -- 库存/箱号 `returnreceipt/controller/PlatformReturnInventoryController.java`、`MerchantReturnInventoryController.java` → `returnreceipt/service/ReturnInventoryService.java`,前缀分别为 `/api/platform/v1/last-leg/return-inventory`、`/api/merchant/v1/last-leg/return-inventory`;PDA 合箱入口 `PdaReturnInventoryController.java` 的 `/packing/orders`、`/cartons/merge`、`/cartons/{cartonMark}/label`。同仓同客户选取退货单及可用 SKU,合箱扣减来源箱数量并生成目标箱,空来源箱标为 `MERGED`,不得跨客户或超出可用量。 -- 退货出库继续进入 `delivery/service/DeliveryTransferService.java` 的 `RETURN_OUTBOUND`,按退货库存可用箱唛创建与拣货;不能与 `exceptionorder/` 的派送退回恢复库存混用。契约见 `src/main/resources/openapi/overseas-business-v1.yaml`,表入口 `src/main/resources/db/lastleg.sql` 的 `ll_return_*`。 -- 客户箱号管理使用 `GET /api/merchant/v1/last-leg/return-inventory/cartons` 按实物箱分页,库存箱内 SKU 明细仍使用根查询;两者均按登录客户精确隔离。合箱 `sourceBatchNo/sourceReturnNo` 保存实际消耗来源的去重业务编号(多个以逗号分隔),不得写状态或包含未取货订单。`ReturnCartonBarcode` 按箱号生成 Code 128 SVG,打印内容不得使用内部客户/仓库 ID。 +退货预报、签收、库存、合箱与退货出库:`returnreceipt/`;人工计划自动生成托盘、柜级预览与头程来源托盘快照:`inboundplan/`。这两类入口的补充段落也在详细索引中,按对应短词查询。 -人工计划自动生成托盘与资源重试:`inboundplan/controller/InboundPlanController.java` → `inboundplan/service/ContainerInboundPlanConversionCoordinator.java` 的 `convertManualAndAutoMaterialize`;保存单票后重读全柜预览,全员为 `PLANNED/RESOURCE_PENDING`、预览就绪且存在待绑定资源时自动调用柜级 `confirm`。一票柜立即触发,多票柜在最后一票齐全后触发;资源异常沿 `resourcePendingPlanCount` 和原柜级预览/确认入口重试,不新增独立正常人工步骤。回归入口为 `src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java`。 - -柜级预览同时返回 `sourcePalletCount/supplementPalletCount/targetPalletCount`:头程冻结托盘码原样保留,海外仓最终托数较多时按同柜同 FBA 编码序列补足差额,不因头尾装托密度不同阻断;页面必须分别展示头程已有、海外补充和最终托数,不能把最终总数误称为“本次新增”。 - -头程来源托盘快照:`service/LoadingCompletedConsumer.java`、`mapper/LastlegSourcePalletMapper.java`;来源标签随装柜完成事件落入 `ll_order_source_pallet`,后续由 `inboundplan/service/InboundPlanService.java` 与 `ContainerInboundPlanConversionCoordinator.java` 传递给海外资源批量预留。排查标签跨区丢失时沿此链路核对,不能在尾程页面重新生成头程托盘码。 - -| 功能 | 入口 | +| 功能 | 首个入口 | |---|---| -| 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md`、`scripts/analyze-lastleg-dependencies.mjs`、`scripts/analyze-lastleg-dependencies.test.mjs`、`src/test/resources/architecture/lastleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 | -| 事件接收、平台订单列表/详情/导出、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码、有效计划及托盘资源异常并分页返回全部稳定来源柜,已完成计划转换的柜仍保留用于接柜仓查询与修改;单票手工计划保存后,整柜最后一票齐全时自动生成并绑定全柜托盘码,一票柜立即生成,`resourcePendingPlanCount` 只作为失败重试事实;`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 | -| 尾程订单完整计划、柜级到仓时间、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;Admin 清关提柜列表通过 `/orders/container-arrival-times/query` 批量读取整柜预计/实际到仓,预计时间在任何入库计划生成前按柜级版本与幂等键人工维护并原子同步全柜 `ll_order.estimated_warehouse_at`,实际时间只取海外 PDA 会话首次 `receiving_at`。单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;预计到仓日期不接受计划请求输入,必须锁定来源柜全部订单的 `estimated_warehouse_at`,缺失或同柜不一致时阻断,人工与自动计划统一写其日期;一件代发人工计划必须逐托沿用不可变头程封托的 DP 托盘码和箱数,托数及各托箱数均不得重新分配,禁止保存无法绑定真实资源的半成品计划;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`;新增暂扣只允许完成入库且尚未进入出库流程的订单,解除暂扣不受该状态限制;暂扣只阻断排车和出库,不阻断入库计划或入库作业。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 | -| 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` | -| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V3,消费端除冻结 V2 货物事实外,按 `sourceContainerBookingId` 幂等投影物理柜五类国内装柜照片;FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;V1/V2 仅用于历史事件读取 | +| 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md` | +| 事件接收、平台订单列表/详情/导出、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java` | +| 尾程订单完整计划、柜级到仓时间、暂扣与人工节点 | `controller/LastlegOrderController.java` | +| 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java` | +| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java` | | 尾程单号生成 | `service/LastlegOrderNoGenerator.java` | | 订单查询与更新 | `service/LastlegOrderService.java` | -| 业务 Inbox | `entity/LastlegEventInbox.java`、`mapper/LastlegEventInboxMapper.java` | -| 尾程订单、明细、状态历史 | `entity/LastlegOrder.java`、`LastlegOrderDetail.java`、`LastlegOrderStatusHistory.java` 及对应 Mapper | -| 独立尾程入仓单快照 | `entity/LastlegInboundOrder.java`、`mapper/LastlegInboundOrderMapper.java`;按客户与来源入仓标识冻结 inboundCode、来源/根订单及目的仓,不以尾程订单行代替入仓单聚合 | +| 业务 Inbox | `entity/LastlegEventInbox.java` | +| 尾程订单、明细、状态历史 | `entity/LastlegOrder.java` | +| 独立尾程入仓单快照 | `entity/LastlegInboundOrder.java` | | 内部令牌保护 | `security/InternalServiceTokenGuard.java` | -| IAM 海外安全投影与实时校验 | 内部入口 `security/controller/LastlegSecurityController.java`,固定 `/internal/lastleg/security/v1/events`、`/validate` 且复用 `InternalServiceTokenGuard`;投影/校验 `security/service/`,DTO `security/dto/`,表固定 `ll_security_event_inbox`、`ll_security_projection`;MySQL 投影缺失、落后或不可用一律 fail-closed,Redis 仅为提交后可重建镜像;业务切片不得直接依赖投影内部类,边界由 `SecurityProjectionArchitectureTest` 与 Node 合同锁定 | -| 入仓计划分页、柜级预览、转换与详情 | 平台 `inboundplan/controller/InboundPlanController.java`,客户只读 `inboundplan/controller/MerchantInboundPlanController.java`;共用 `inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`ContainerInboundPlanPreviewResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java`;客户入口固定 `/api/merchant/v1/last-leg/inbound-plans`,范围只取 Token。`/container-preview` 按全柜返回 FBA/一件代发/私人地址分组、托数、就绪、阻断项及 `snapshotVersion/snapshotHash`;`ContainerInboundPlanConversionCoordinator.java` 用唯一柜级 Saga 锁柜重算,先经 `WarehouseResourceGateway` 批量预留真实托盘,再本地原子生成剩余独立计划并确认资源,本地失败只补偿未确认资源,远端确认失败的重放只重试确认,同柜不同幂等键也不得产生第二批资源 | +| IAM 海外安全投影与实时校验 | `security/controller/LastlegSecurityController.java` | +| 入仓计划分页、柜级预览、转换与详情 | `inboundplan/controller/InboundPlanController.java` | | 托盘分配算法 | `inboundplan/service/PalletAllocator.java` | -| FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` | -| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除。多计划同柜返回计划级 `assigned/mergeAllowed/urgent/mergeHint`;普通 FBA/OVERSEAS 分别使用 bulk/carton 收货,一件代发只允许 `/current/receipts/pallet` 按国内冻结来源托盘整托收货。合托只作现场可选提示;全部计划完整绑定真实 pallet/location/lock 前不得查看、准备或签收该柜,首扫只确认已预留绑定,不得重新分配计划资源 | -| PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`/current/unload-photos/files/{fileId}/download`,网关 `inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO`,下载只允许当前会话已签收的 fileId 并返回 `no-store` | -| PDA 国内装柜照片对照 | `ReceivingContainerController.java` 的 `/current/domestic-loading-photos/{evidenceType}/content`、`ContainerLoadingEvidenceMapper.java`、`DomesticLoadingEvidenceGateway.java`;元数据按物理柜投影,不保存客户,下载必须经当前 PDA 会话鉴权并复用 `FILE_SERVICE_URL` 只读访问 File 服务,单图不可用不改变拆柜状态 | -| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` | -| 拆柜打托成本与客户应收分摊 | `inboundplan/service/DestuffingPalletCostJob.java`、`DestuffingPalletCostService.java`;仅全柜所有 PDA 入仓任务完成后执行,按实物托盘单价汇总并按客户实收 CBM 分摊,尾差落最后一位客户;每份分摊发布 `OVERSEAS_CONTAINER_FEE/DESTUFF_PALLET` 应收费用来源,不直接改国内钱包 | -| 尾程暂扣状态回传 | `service/InboundOrderDetainedOutboxService.java`;暂扣与解除暂扣在订单更新事务内按 `sourceInboundId` 写入海外到国内 Outbox,国内按来源入仓单绑定更新 | -| 派送完成回传 | `delivery/service/DeliveryCompletedOutboxService.java`;签收资料保存并自动完成时在业务事务内按关联 `sourceInboundId` 分别写 `OVERSEAS_DELIVERY_COMPLETED` Outbox,同车不同 FBA 派送单互不代完成;不保留人工完成入口 | -| 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 | -| 海外PDA盘点、同仓整托移库、托盘组拆换绑与换标现场作业 | `pda/stocktake/`、`pda/move/`、`palletops/`、`relabel/pda/`;版本化契约 `src/main/resources/openapi/overseas-pda-operations-v1.yaml`;全部范围取自PDA Token,排车和出库使用独立 PDA Controller | -| 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/`、`relabel/pda/`;一条 `ll_relabel_item` 对应一个客户填写的我方箱唛任务,`CARTON_LABEL`、`SKU_LABEL`、`CARTON_AND_SKU` 分别保存箱唛、SKU 或组合需求,SKU 子项 JSON 数量按件;涉及箱唛换标时整张申请只能有一个 FBA 目的仓,服务端拒绝明细目的仓不一致的请求。普通尾程入库没有箱唛到实物托盘的精确快照,历史入仓库存仓位只能作为冻结记账锚点和现场大概位置,禁止把该仓位解释为箱唛已绑定具体托盘。库存只按箱任务冻结一次。涉及箱唛换标时申请必须选择 `TRUCK/EXPRESS/TRUCK_PICKUP/EXPRESS_PICKUP`,两种快递方式可要求打印并上传面单;PDA 扫我方箱唛完成逐箱事实和照片,仓库复核后等待客户查看照片确认。对外换标状态只投影为 `PENDING_PROCESSING/COMPLETED`(待处理/换标完成),`PROCESSING`、`CUSTOMER_CONFIRM_PENDING`、`READY_FOR_OUTBOUND` 只属于内部作业节点且不得直接出现在业务列表;客户确认后仅卡车派送进入 `WAIT_PRE_DISPATCH`,快递派送和两种自提直接创建换标出库单。客户确认前不得进入出库 | -| PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java`、`pda/warehouse/service/PdaWarehouseService.java`;解析库存、库位和托盘码并查询操作历史,固定要求 `lastleg:pda:operate` 与 `ROLE_WAREHOUSE_PICKER`,仓库范围只能取自 PDA Token | -| 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java`、`shipping/entity/ContainerShippingGate.java`、`shipping/mapper/ContainerShippingGateMapper.java`;按 `containerBookingId` 柜级接收,信封使用 `tenantId=0` 且门禁不存客户;只单调接受实际标准节点,`PICKUP_READY` 及以后才允许对应柜进入 PDA 收货候选 | -| 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`、`dispatch/mapper/DispatchCandidateMapper.java`、`dispatch/service/DispatchService.java`;草稿柜 `entity/DispatchDraftContainer.java`、`mapper/DispatchDraftContainerMapper.java`,托盘锁 `DispatchPalletLock.java`;入口 `/api/platform/v1/last-leg/dispatches`,路线由当前已排托盘的 FBA 仓编码去重后按稳定顺序以英文逗号连接,预排与普通编辑请求均不接收路线;查询、预排与执行边界见“海外仓全流程开发路由” | -| 排车编辑、PDA 出库、派送与交接 | `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`MerchantDeliveryController.java`、`OutboundPdaController.java` | -| 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` | -| 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java`、`transfer/service/WarehouseTransferService.java` | -| 一件代发接收、SKU/箱唛库存 | 内部接收入口 `dropship/controller/DropshipOrderController.java` 的 `/internal/v1/lastleg/dropship/receive`,必须经 `InternalServiceTokenGuard`;版本化契约 `src/main/resources/openapi/lastleg-internal-dropship-v1.yaml`;处理 `dropship/service/DropshipOrderService.java`、`dropship/entity/`、`dropship/mapper/` | -| 货物废弃、待审锁箱、审批扣减 | `discard/controller/CargoDiscardController.java`、`discard/service/CargoDiscardService.java` | -| 客户拜访、派送状态与签收附件 | `customervisit/controller/CustomerVisitController.java`、`customervisit/service/CustomerVisitService.java`、`delivery/model/DeliveryStatusPolicy.java`;派送单返回服务端 `allowedActions/version`,状态只允许单调迁移,同状态幂等;人工动作不再提供完成派送,签收资料保存后自动完成当前目的FBA仓派送单 | -| 派送三类资料、摘要与历史证据 | `delivery/controller/DeliveryDocumentController.java`、`delivery/service/DeliveryDocumentService.java`、`delivery/gateway/DeliveryDocumentFileGateway.java`、`src/main/resources/openapi/delivery-documents-v1.yaml`;固定装车照片 `LOADING_PHOTO`、BOL文件(含签名)`BOL`、签收文件(POD签收/快递签收)`RECEIPT`,按已保存类别计数 `x/3`。每类0至20文件,文件精确绑定客户、按目的FBA仓拆分的派送单和类别;全量保存共用派送版本并记录不可变历史,替换删除仅解绑,未保存上传不计数、不授权下载。`RECEIPT` 成功保存进权威快照时事务内自动完成当前派送单,单纯上传不触发完成 | -| 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java`、`exceptionorder/service/ExceptionOrderService.java`、`exceptionorder/gateway/ExceptionEvidenceFileGateway.java`、`inventory/command/service/InventoryCommandService.java`;证据固定按客户、异常单 ID 和 `OVERSEAS_EXCEPTION_EVIDENCE` 校验。退回候选按原出库拣货事实冻结,不接受自由 SKU 或数量;异常按 `PENDING→PROCESSING→COMPLETED`(待处理可取消)、版本与幂等键流转。退回海外仓须把实收按原出库来源恢复到服务端校验的真实托盘/库位并写 `RETURN_STOCK_IN` 流水,库存恢复、异常完成与派送 `RETURNED` 在同一事务完成 | -| 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;客户入口按 `merchant:cross-border:procurement:list/create` 拆分且只取 Token 客户范围;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` | -| 海外业务资金指令与结果 | `wallet/WalletCommandOutboxService.java`、`WalletCommandCompletedConsumer.java`、`WalletResultHandler.java`;采购和换标通过 `WALLET_COMMAND_REQUESTED` 请求国内 Finance 冻结、结算或释放,并以 `WALLET_COMMAND_COMPLETED` 幂等回写业务资金状态;不得跨区同步调用 Finance 钱包或在 Lastleg 自建余额账本 | -| 海外业务费用来源 | `financefee/controller/PlatformOverseasFeeSourceController.java`、`financefee/service/OverseasFeeSourceService.java`、`OverseasFeeBusinessGuard.java`、`OverseasFeeOutboxService.java`、`OverseasFeeOutboxRelay.java`;海外入仓、采购、美国卡车、本土散板和海外仓服务费必须匹配当前客户的真实业务,入仓记录和入仓码按事实选填;审核通过、调整或作废后以版本化完整快照经 Outbox/Event Bridge 进入独立 Finance,OpenAPI `src/main/resources/openapi/overseas-finance-fees-v1.yaml` | -| 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java`、`MerchantUsTruckOrderController.java` 及同子域 Service/DTO;客户入口按 `merchant:cross-border:domestic-trucking:list/create/edit` 拆分 | -| 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java`、`MerchantLocalLooseboardController.java` 及同子域 Service/DTO/Entity/Mapper;客户入口按 `merchant:cross-border:pallet-shipping:list/create/edit` 拆分;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` | -| 海外仓报价、月度汇率、车辆主数据 | 报价 `masterdata/quote/`、汇率 `masterdata/exchange/`;车辆入口 `masterdata/vehicle/adapter/in/web/PlatformVehicleController.java`,应用服务 `application/VehicleApplicationService.java`,持久化端口与适配器 `application/port/out/VehicleRepository.java`、`adapter/out/persistence/`;OpenAPI `src/main/resources/openapi/overseas-master-data-v1.yaml`,车辆档案不含派送履约历史 | -| 跨服务 HTTP 客户端装配 | `config/LastlegRestClientFactory.java`、`config/LastlegRestClientProperties.java`;目标地址与内部服务令牌只来自配置,不在业务子域手工创建客户端 | -| 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql`、`src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java` | +| FBA 预约创建、详情与编辑 | `InboundPlanController.java` | +| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java` | +| PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` | +| PDA 国内装柜照片对照 | `ReceivingContainerController.java` | +| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java` | +| 拆柜打托成本与客户应收分摊 | `inboundplan/service/DestuffingPalletCostJob.java` | +| 尾程暂扣状态回传 | `service/InboundOrderDetainedOutboxService.java` | +| 派送完成回传 | `delivery/service/DeliveryCompletedOutboxService.java` | +| 入仓后 SKU 库存余额、库位和流水 | `inventory/` | +| 海外PDA盘点、同仓整托移库、托盘组拆换绑与换标现场作业 | `pda/stocktake/` | +| 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/` | +| PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java` | +| 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java` | +| 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java` | +| 排车编辑、PDA 出库、派送与交接 | `dispatch/controller/DispatchController.java` | +| 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java` | +| 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java` | +| 一件代发接收、SKU/箱唛库存 | `dropship/controller/DropshipOrderController.java` | +| 货物废弃、待审锁箱、审批扣减 | `discard/controller/CargoDiscardController.java` | +| 客户拜访、派送状态与签收附件 | `customervisit/controller/CustomerVisitController.java` | +| 派送三类资料、摘要与历史证据 | `delivery/controller/DeliveryDocumentController.java` | +| 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java` | +| 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/` | +| 海外业务资金指令与结果 | `wallet/WalletCommandOutboxService.java` | +| 海外业务费用来源 | `financefee/controller/PlatformOverseasFeeSourceController.java` | +| 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java` | +| 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java` | +| 海外仓报价、月度汇率、车辆主数据 | `masterdata/quote/` | +| 跨服务 HTTP 客户端装配 | `config/LastlegRestClientFactory.java` | +| 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql` | | 测试 | `src/test/java/com/baiyue/lastleg/` | -| 真实事件消费验收 | `src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java`;覆盖真实 V2 建单、独立入仓单快照及下游转运/换标可消费性 | +| 真实事件消费验收 | `src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java` | ## 海外仓全流程开发路由 diff --git a/lastleg-service/docs/business-index.md b/lastleg-service/docs/business-index.md new file mode 100644 index 000000000..a962c5162 --- /dev/null +++ b/lastleg-service/docs/business-index.md @@ -0,0 +1,68 @@ +# Lastleg 业务详细索引 + +本文件是同服务 `AGENTS.md` 的按需补充;命中业务后读取相应行/段,不默认全文加载。路径保持以 `lastleg-service/` 为根;Java 简写包路径从 `src/main/java/com/baiyue/lastleg/` 起算,不相对于本文件的 `docs/` 目录。 + +## 快速索引 + +退货预报、签收、库存、合箱与退货出库: + +- 客户预报 `returnreceipt/controller/MerchantReturnOrderController.java` → `returnreceipt/service/ReturnReceiptService.java`,路径 `/api/merchant/v1/last-leg/return-orders`;客户范围取认证身份,预报按 SKU 保存期望数量。状态 `PENDING_RECEIPT → PARTIALLY_RECEIVED → COMPLETED` 表示签收进度,不等于换标或出库完成。 +- PDA 后端签收 `returnreceipt/controller/PdaReturnReceiptController.java`,前缀 `/api/pda/v1/last-leg/return-receipts`,依次 `start/sku-candidates/confirm`;按冻结仓和退货单校验实收,不允许未预报 SKU 或超量,幂等重放不得重复入账。确认同时生成签收箱与 SKU 库存;设备页面是否接通另查 PDA 工作台,不能以 Controller 存在代替前端接入。 +- 库存/箱号 `returnreceipt/controller/PlatformReturnInventoryController.java`、`MerchantReturnInventoryController.java` → `returnreceipt/service/ReturnInventoryService.java`,前缀分别为 `/api/platform/v1/last-leg/return-inventory`、`/api/merchant/v1/last-leg/return-inventory`;PDA 合箱入口 `PdaReturnInventoryController.java` 的 `/packing/orders`、`/cartons/merge`、`/cartons/{cartonMark}/label`。同仓同客户选取退货单及可用 SKU,合箱扣减来源箱数量并生成目标箱,空来源箱标为 `MERGED`,不得跨客户或超出可用量。 +- 退货出库继续进入 `delivery/service/DeliveryTransferService.java` 的 `RETURN_OUTBOUND`,按退货库存可用箱唛创建与拣货;不能与 `exceptionorder/` 的派送退回恢复库存混用。契约见 `src/main/resources/openapi/overseas-business-v1.yaml`,表入口 `src/main/resources/db/lastleg.sql` 的 `ll_return_*`。 +- 客户箱号管理使用 `GET /api/merchant/v1/last-leg/return-inventory/cartons` 按实物箱分页,库存箱内 SKU 明细仍使用根查询;两者均按登录客户精确隔离。合箱 `sourceBatchNo/sourceReturnNo` 保存实际消耗来源的去重业务编号(多个以逗号分隔),不得写状态或包含未取货订单。`ReturnCartonBarcode` 按箱号生成 Code 128 SVG,打印内容不得使用内部客户/仓库 ID。 + +人工计划自动生成托盘与资源重试:`inboundplan/controller/InboundPlanController.java` → `inboundplan/service/ContainerInboundPlanConversionCoordinator.java` 的 `convertManualAndAutoMaterialize`;保存单票后重读全柜预览,全员为 `PLANNED/RESOURCE_PENDING`、预览就绪且存在待绑定资源时自动调用柜级 `confirm`。一票柜立即触发,多票柜在最后一票齐全后触发;资源异常沿 `resourcePendingPlanCount` 和原柜级预览/确认入口重试,不新增独立正常人工步骤。回归入口为 `src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java`。 + +柜级预览同时返回 `sourcePalletCount/supplementPalletCount/targetPalletCount`:头程冻结托盘码原样保留,海外仓最终托数较多时按同柜同 FBA 编码序列补足差额,不因头尾装托密度不同阻断;页面必须分别展示头程已有、海外补充和最终托数,不能把最终总数误称为“本次新增”。 + +头程来源托盘快照:`service/LoadingCompletedConsumer.java`、`mapper/LastlegSourcePalletMapper.java`;来源标签随装柜完成事件落入 `ll_order_source_pallet`,后续由 `inboundplan/service/InboundPlanService.java` 与 `ContainerInboundPlanConversionCoordinator.java` 传递给海外资源批量预留。排查标签跨区丢失时沿此链路核对,不能在尾程页面重新生成头程托盘码。 + +| 功能 | 入口 | +|---|---| +| 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md`、`scripts/analyze-lastleg-dependencies.mjs`、`scripts/analyze-lastleg-dependencies.test.mjs`、`src/test/resources/architecture/lastleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 | +| 事件接收、平台订单列表/详情/导出、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码、有效计划及托盘资源异常并分页返回全部稳定来源柜,已完成计划转换的柜仍保留用于接柜仓查询与修改;单票手工计划保存后,整柜最后一票齐全时自动生成并绑定全柜托盘码,一票柜立即生成,`resourcePendingPlanCount` 只作为失败重试事实;`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 | +| 尾程订单完整计划、柜级到仓时间、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;Admin 清关提柜列表通过 `/orders/container-arrival-times/query` 批量读取整柜预计/实际到仓,预计时间在任何入库计划生成前按柜级版本与幂等键人工维护并原子同步全柜 `ll_order.estimated_warehouse_at`,实际时间只取海外 PDA 会话首次 `receiving_at`。单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;预计到仓日期不接受计划请求输入,必须锁定来源柜全部订单的 `estimated_warehouse_at`,缺失或同柜不一致时阻断,人工与自动计划统一写其日期;一件代发人工计划必须逐托沿用不可变头程封托的 DP 托盘码和箱数,托数及各托箱数均不得重新分配,禁止保存无法绑定真实资源的半成品计划;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`;新增暂扣只允许完成入库且尚未进入出库流程的订单,解除暂扣不受该状态限制;暂扣只阻断排车和出库,不阻断入库计划或入库作业。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 | +| 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` | +| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V3,消费端除冻结 V2 货物事实外,按 `sourceContainerBookingId` 幂等投影物理柜五类国内装柜照片;FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;V1/V2 仅用于历史事件读取 | +| 尾程单号生成 | `service/LastlegOrderNoGenerator.java` | +| 订单查询与更新 | `service/LastlegOrderService.java` | +| 业务 Inbox | `entity/LastlegEventInbox.java`、`mapper/LastlegEventInboxMapper.java` | +| 尾程订单、明细、状态历史 | `entity/LastlegOrder.java`、`LastlegOrderDetail.java`、`LastlegOrderStatusHistory.java` 及对应 Mapper | +| 独立尾程入仓单快照 | `entity/LastlegInboundOrder.java`、`mapper/LastlegInboundOrderMapper.java`;按客户与来源入仓标识冻结 inboundCode、来源/根订单及目的仓,不以尾程订单行代替入仓单聚合 | +| 内部令牌保护 | `security/InternalServiceTokenGuard.java` | +| IAM 海外安全投影与实时校验 | 内部入口 `security/controller/LastlegSecurityController.java`,固定 `/internal/lastleg/security/v1/events`、`/validate` 且复用 `InternalServiceTokenGuard`;投影/校验 `security/service/`,DTO `security/dto/`,表固定 `ll_security_event_inbox`、`ll_security_projection`;MySQL 投影缺失、落后或不可用一律 fail-closed,Redis 仅为提交后可重建镜像;业务切片不得直接依赖投影内部类,边界由 `SecurityProjectionArchitectureTest` 与 Node 合同锁定 | +| 入仓计划分页、柜级预览、转换与详情 | 平台 `inboundplan/controller/InboundPlanController.java`,客户只读 `inboundplan/controller/MerchantInboundPlanController.java`;共用 `inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`ContainerInboundPlanPreviewResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java`;客户入口固定 `/api/merchant/v1/last-leg/inbound-plans`,范围只取 Token。`/container-preview` 按全柜返回 FBA/一件代发/私人地址分组、托数、就绪、阻断项及 `snapshotVersion/snapshotHash`;`ContainerInboundPlanConversionCoordinator.java` 用唯一柜级 Saga 锁柜重算,先经 `WarehouseResourceGateway` 批量预留真实托盘,再本地原子生成剩余独立计划并确认资源,本地失败只补偿未确认资源,远端确认失败的重放只重试确认,同柜不同幂等键也不得产生第二批资源 | +| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` | +| FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` | +| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除。多计划同柜返回计划级 `assigned/mergeAllowed/urgent/mergeHint`;普通 FBA/OVERSEAS 分别使用 bulk/carton 收货,一件代发只允许 `/current/receipts/pallet` 按国内冻结来源托盘整托收货。合托只作现场可选提示;全部计划完整绑定真实 pallet/location/lock 前不得查看、准备或签收该柜,首扫只确认已预留绑定,不得重新分配计划资源 | +| PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`/current/unload-photos/files/{fileId}/download`,网关 `inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO`,下载只允许当前会话已签收的 fileId 并返回 `no-store` | +| PDA 国内装柜照片对照 | `ReceivingContainerController.java` 的 `/current/domestic-loading-photos/{evidenceType}/content`、`ContainerLoadingEvidenceMapper.java`、`DomesticLoadingEvidenceGateway.java`;元数据按物理柜投影,不保存客户,下载必须经当前 PDA 会话鉴权并复用 `FILE_SERVICE_URL` 只读访问 File 服务,单图不可用不改变拆柜状态 | +| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` | +| 拆柜打托成本与客户应收分摊 | `inboundplan/service/DestuffingPalletCostJob.java`、`DestuffingPalletCostService.java`;仅全柜所有 PDA 入仓任务完成后执行,按实物托盘单价汇总并按客户实收 CBM 分摊,尾差落最后一位客户;每份分摊发布 `OVERSEAS_CONTAINER_FEE/DESTUFF_PALLET` 应收费用来源,不直接改国内钱包 | +| 尾程暂扣状态回传 | `service/InboundOrderDetainedOutboxService.java`;暂扣与解除暂扣在订单更新事务内按 `sourceInboundId` 写入海外到国内 Outbox,国内按来源入仓单绑定更新 | +| 派送完成回传 | `delivery/service/DeliveryCompletedOutboxService.java`;签收资料保存并自动完成时在业务事务内按关联 `sourceInboundId` 分别写 `OVERSEAS_DELIVERY_COMPLETED` Outbox,同车不同 FBA 派送单互不代完成;不保留人工完成入口 | +| 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 | +| 海外PDA盘点、同仓整托移库、托盘组拆换绑与换标现场作业 | `pda/stocktake/`、`pda/move/`、`palletops/`、`relabel/pda/`;版本化契约 `src/main/resources/openapi/overseas-pda-operations-v1.yaml`;全部范围取自PDA Token,排车和出库使用独立 PDA Controller | +| 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/`、`relabel/pda/`;一条 `ll_relabel_item` 对应一个客户填写的我方箱唛任务,`CARTON_LABEL`、`SKU_LABEL`、`CARTON_AND_SKU` 分别保存箱唛、SKU 或组合需求,SKU 子项 JSON 数量按件;涉及箱唛换标时整张申请只能有一个 FBA 目的仓,服务端拒绝明细目的仓不一致的请求。普通尾程入库没有箱唛到实物托盘的精确快照,历史入仓库存仓位只能作为冻结记账锚点和现场大概位置,禁止把该仓位解释为箱唛已绑定具体托盘。库存只按箱任务冻结一次。涉及箱唛换标时申请必须选择 `TRUCK/EXPRESS/TRUCK_PICKUP/EXPRESS_PICKUP`,两种快递方式可要求打印并上传面单;PDA 扫我方箱唛完成逐箱事实和照片,仓库复核后等待客户查看照片确认。对外换标状态只投影为 `PENDING_PROCESSING/COMPLETED`(待处理/换标完成),`PROCESSING`、`CUSTOMER_CONFIRM_PENDING`、`READY_FOR_OUTBOUND` 只属于内部作业节点且不得直接出现在业务列表;客户确认后仅卡车派送进入 `WAIT_PRE_DISPATCH`,快递派送和两种自提直接创建换标出库单。客户确认前不得进入出库 | +| PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java`、`pda/warehouse/service/PdaWarehouseService.java`;解析库存、库位和托盘码并查询操作历史,固定要求 `lastleg:pda:operate` 与 `ROLE_WAREHOUSE_PICKER`,仓库范围只能取自 PDA Token | +| 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java`、`shipping/entity/ContainerShippingGate.java`、`shipping/mapper/ContainerShippingGateMapper.java`;按 `containerBookingId` 柜级接收,信封使用 `tenantId=0` 且门禁不存客户;只单调接受实际标准节点,`PICKUP_READY` 及以后才允许对应柜进入 PDA 收货候选 | +| 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`、`dispatch/mapper/DispatchCandidateMapper.java`、`dispatch/service/DispatchService.java`;草稿柜 `entity/DispatchDraftContainer.java`、`mapper/DispatchDraftContainerMapper.java`,托盘锁 `DispatchPalletLock.java`;入口 `/api/platform/v1/last-leg/dispatches`,路线由当前已排托盘的 FBA 仓编码去重后按稳定顺序以英文逗号连接,预排与普通编辑请求均不接收路线;查询、预排与执行边界见“海外仓全流程开发路由” | +| 排车编辑、PDA 出库、派送与交接 | `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`MerchantDeliveryController.java`、`OutboundPdaController.java` | +| 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` | +| 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java`、`transfer/service/WarehouseTransferService.java` | +| 一件代发接收、SKU/箱唛库存 | 内部接收入口 `dropship/controller/DropshipOrderController.java` 的 `/internal/v1/lastleg/dropship/receive`,必须经 `InternalServiceTokenGuard`;版本化契约 `src/main/resources/openapi/lastleg-internal-dropship-v1.yaml`;处理 `dropship/service/DropshipOrderService.java`、`dropship/entity/`、`dropship/mapper/` | +| 货物废弃、待审锁箱、审批扣减 | `discard/controller/CargoDiscardController.java`、`discard/service/CargoDiscardService.java` | +| 客户拜访、派送状态与签收附件 | `customervisit/controller/CustomerVisitController.java`、`customervisit/service/CustomerVisitService.java`、`delivery/model/DeliveryStatusPolicy.java`;派送单返回服务端 `allowedActions/version`,状态只允许单调迁移,同状态幂等;人工动作不再提供完成派送,签收资料保存后自动完成当前目的FBA仓派送单 | +| 派送三类资料、摘要与历史证据 | `delivery/controller/DeliveryDocumentController.java`、`delivery/service/DeliveryDocumentService.java`、`delivery/gateway/DeliveryDocumentFileGateway.java`、`src/main/resources/openapi/delivery-documents-v1.yaml`;固定装车照片 `LOADING_PHOTO`、BOL文件(含签名)`BOL`、签收文件(POD签收/快递签收)`RECEIPT`,按已保存类别计数 `x/3`。每类0至20文件,文件精确绑定客户、按目的FBA仓拆分的派送单和类别;全量保存共用派送版本并记录不可变历史,替换删除仅解绑,未保存上传不计数、不授权下载。`RECEIPT` 成功保存进权威快照时事务内自动完成当前派送单,单纯上传不触发完成 | +| 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java`、`exceptionorder/service/ExceptionOrderService.java`、`exceptionorder/gateway/ExceptionEvidenceFileGateway.java`、`inventory/command/service/InventoryCommandService.java`;证据固定按客户、异常单 ID 和 `OVERSEAS_EXCEPTION_EVIDENCE` 校验。退回候选按原出库拣货事实冻结,不接受自由 SKU 或数量;异常按 `PENDING→PROCESSING→COMPLETED`(待处理可取消)、版本与幂等键流转。退回海外仓须把实收按原出库来源恢复到服务端校验的真实托盘/库位并写 `RETURN_STOCK_IN` 流水,库存恢复、异常完成与派送 `RETURNED` 在同一事务完成 | +| 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;客户入口按 `merchant:cross-border:procurement:list/create` 拆分且只取 Token 客户范围;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` | +| 海外业务资金指令与结果 | `wallet/WalletCommandOutboxService.java`、`WalletCommandCompletedConsumer.java`、`WalletResultHandler.java`;采购和换标通过 `WALLET_COMMAND_REQUESTED` 请求国内 Finance 冻结、结算或释放,并以 `WALLET_COMMAND_COMPLETED` 幂等回写业务资金状态;不得跨区同步调用 Finance 钱包或在 Lastleg 自建余额账本 | +| 海外业务费用来源 | `financefee/controller/PlatformOverseasFeeSourceController.java`、`financefee/service/OverseasFeeSourceService.java`、`OverseasFeeBusinessGuard.java`、`OverseasFeeOutboxService.java`、`OverseasFeeOutboxRelay.java`;海外入仓、采购、美国卡车、本土散板和海外仓服务费必须匹配当前客户的真实业务,入仓记录和入仓码按事实选填;审核通过、调整或作废后以版本化完整快照经 Outbox/Event Bridge 进入独立 Finance,OpenAPI `src/main/resources/openapi/overseas-finance-fees-v1.yaml` | +| 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java`、`MerchantUsTruckOrderController.java` 及同子域 Service/DTO;客户入口按 `merchant:cross-border:domestic-trucking:list/create/edit` 拆分 | +| 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java`、`MerchantLocalLooseboardController.java` 及同子域 Service/DTO/Entity/Mapper;客户入口按 `merchant:cross-border:pallet-shipping:list/create/edit` 拆分;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` | +| 海外仓报价、月度汇率、车辆主数据 | 报价 `masterdata/quote/`、汇率 `masterdata/exchange/`;车辆入口 `masterdata/vehicle/adapter/in/web/PlatformVehicleController.java`,应用服务 `application/VehicleApplicationService.java`,持久化端口与适配器 `application/port/out/VehicleRepository.java`、`adapter/out/persistence/`;OpenAPI `src/main/resources/openapi/overseas-master-data-v1.yaml`,车辆档案不含派送履约历史 | +| 跨服务 HTTP 客户端装配 | `config/LastlegRestClientFactory.java`、`config/LastlegRestClientProperties.java`;目标地址与内部服务令牌只来自配置,不在业务子域手工创建客户端 | +| 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql`、`src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java` | +| 测试 | `src/test/java/com/baiyue/lastleg/` | +| 真实事件消费验收 | `src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java`;覆盖真实 V2 建单、独立入仓单快照及下游转运/换标可消费性 | diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index ab5303ff7..f9d071fde 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -103,6 +103,24 @@ $clientAgents = Read-RequiredText $clientAgentsPath $pdaAgents = Read-RequiredText $pdaAgentsPath $overseasPdaAgents = Read-RequiredText $overseasPdaAgentsPath $backendAgents = Read-RequiredText $backendAgentsPath +$backendIndexPath = Join-Path $backendRoot 'docs/backend-business-index.md' +$lastlegIndexPath = Join-Path $backendRoot 'lastleg-service/docs/business-index.md' +$backendIndex = Read-RequiredText $backendIndexPath +$lastlegIndex = Read-RequiredText $lastlegIndexPath +Assert-Contains $backendAgents 'docs/backend-business-index.md' '后端按需索引入口' +$lastlegEntry = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/AGENTS.md') +Assert-Contains $lastlegEntry 'docs/business-index.md' 'Lastleg 按需索引入口' +foreach ($budget in @( + @{ Name = '后端根'; Text = $backendAgents; Limit = 13000 }, + @{ Name = 'Lastleg'; Text = $lastlegEntry; Limit = 18000 } +)) { + $script:checked++ + if ($budget.Text.Length -gt $budget.Limit) { Add-Failure "$($budget.Name) 指引超出字符预算 $($budget.Limit),请将细节按需下沉" } +} +Assert-MarkdownTableUnique $backendIndexPath '## 服务路由(按关键词定位)' 0 '后端详细索引' +Assert-MarkdownTableUnique $lastlegIndexPath '## 快速索引' 0 'Lastleg 详细索引' +# 保留全部业务语义断言,只将事实读取范围扩展到明确链接的按需文档。 +$backendAgents += "`n" + $backendIndex # 固定样本只检查目标索引行和真实文件,不递归搜索;字符数是读取量指标,不是耗时或 Token 实测。 $lookupSamples = @( @@ -118,6 +136,18 @@ $lookupSamples = @( @{ Key = '退货入库签收与合箱'; Text = $overseasPdaAgents; Entry = 'src/pages/return-packing/return-packing.vue'; Base = 'apps/pda-overseas-uniapp' } ) $lookupCharacters = 0 +foreach ($alias in @( + @{ Term = '转运打托'; Entry = 'apps/pda-overseas-uniapp/src/pages/warehouse-transfer/warehouse-transfer.vue' }, + @{ Term = '转运驳回'; Entry = 'apps/admin-web/src/modules/overseas-warehouse/components/WarehouseTransferReviewDialog.vue' }, + @{ Term = '退税资料'; Entry = 'apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue' } +)) { + $aliasSection = (($businessIndex -split '## 常用说法与歧义分流', 2)[1] -split '\r?\n## ', 2)[0] + $hits = @($aliasSection -split '\r?\n' | Where-Object { $_.Contains($alias.Term) }) + $script:checked++ + if ($hits.Count -ne 1) { Add-Failure "口语入口必须唯一命中: $($alias.Term)"; continue } + Assert-Contains $hits[0] $alias.Entry "口语入口 $($alias.Term)" + Assert-Path $alias.Entry +} foreach ($sample in $lookupSamples) { $hits = @($sample.Text -split '\r?\n' | Where-Object { $_.StartsWith('| ' + $sample.Key) }) $script:checked++ @@ -231,7 +261,7 @@ Assert-Path 'baiyue-firstleg/scripts/script-documentation-contract.test.mjs' Assert-Contains $mapperScanContract 'annotationClass = Mapper.class' 'Mapper 根包注解过滤合同' Assert-Contains $mapperScanContract '禁止分散维护 MapperScan' 'Mapper 单一扫描入口合同' $firstlegAgents = Read-RequiredText (Join-Path $backendRoot 'firstleg/AGENTS.md') -$lastlegAgents = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/AGENTS.md') +$lastlegAgents = $lastlegEntry + "`n" + $lastlegIndex $warehouseAgents = Read-RequiredText (Join-Path $backendRoot 'warehouse/AGENTS.md') $coloadOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/coload-groups-v1.yaml') Assert-Contains $businessIndex 'modules/unified-orders/api/coLoadGroups.ts' '根同柜不可拆组入口' @@ -625,7 +655,7 @@ Assert-Contains $adminOceanBookingViewTest 'without detail N+1' 'Admin 订舱列 # 海外出库、派送、异常和回访必须从根路由贯通到 Admin 与 Lastleg;PDA 后端存在不等于设备端已挂载。 $adminOverseasRoutes = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/routes.ts') -$lastlegAgents = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/AGENTS.md') +$lastlegAgents = $lastlegEntry + "`n" + $lastlegIndex foreach ($overseasFulfillmentRoute in @( 'overseas-warehouse/outbounds', 'overseas-warehouse/delivery-orders', @@ -759,7 +789,7 @@ Assert-Contains $receivingController 'CacheControl.noStore()' '海外 PDA 签收 # 尾程订单按来源柜展示服务端计划进度;整柜确认必须绑定预览快照并编排真实托盘批次。 $lastlegBusinessOpenApi = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml') -$lastlegAgents = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/AGENTS.md') +$lastlegAgents = $lastlegEntry + "`n" + $lastlegIndex $adminLastMileApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/api/lastMileOrders.ts') $containerConversionCoordinator = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java') $warehousePalletBatchService = Read-RequiredText (Join-Path $backendRoot 'warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletBatchService.java') @@ -1153,6 +1183,8 @@ $indexedAgentFiles = @( $pdaAgentsPath, $overseasPdaAgentsPath, $backendAgentsPath, + $backendIndexPath, + $lastlegIndexPath, (Join-Path $backendRoot 'firstleg/AGENTS.md'), (Join-Path $backendRoot 'finance/AGENTS.md'), (Join-Path $backendRoot 'lastleg-service/AGENTS.md'), diff --git a/scripts/controller-pipeline-contract.test.mjs b/scripts/controller-pipeline-contract.test.mjs index aafda41ed..20084f53b 100644 --- a/scripts/controller-pipeline-contract.test.mjs +++ b/scripts/controller-pipeline-contract.test.mjs @@ -4,6 +4,8 @@ import test from 'node:test' const pipeline = (await readFile(new URL('../Jenkinsfile', import.meta.url), 'utf8')) .replaceAll('\r\n', '\n') +const servicePipeline = (await readFile(new URL('../Jenkinsfile.service', import.meta.url), 'utf8')) + .replaceAll('\r\n', '\n') test('coordinator releases its builder while waiting for downstream jobs', () => { assert.match(pipeline, /pipeline \{[\s\S]*?agent none/) @@ -68,3 +70,14 @@ test('production deploy uses DEPLOY as the single explicit release switch', () = assert.doesNotMatch(pipeline, /input message:/) assert.doesNotMatch(pipeline, /\u786e\u8ba4\u90e8\u7f72/) }) + +test('production deploy reports one aggregated result to WeCom', () => { + assert.match(pipeline, /credentialsId: 'wecom-prod-webhook'/) + assert.match(pipeline, /node\('java-builder'\) \{[\s\S]*?WECOM_WEBHOOK/) + assert.match(pipeline, /env\.JOB_BASE_NAME == 'backend-java-prod' && params\.DEPLOY/) + assert.match(pipeline, /unstable \{[\s\S]*?'\u90e8\u7f72\u90e8\u5206\u5931\u8d25'/) + assert.match(pipeline, /failure \{[\s\S]*?'\u90e8\u7f72\u5931\u8d25'/) + assert.match(pipeline, /aborted \{[\s\S]*?'\u90e8\u7f72\u5df2\u53d6\u6d88'/) + assert.match(pipeline, /\u4f01\u4e1a\u5fae\u4fe1\u901a\u77e5\u5931\u8d25\uff0c\u4e0d\u5f71\u54cd\u539f\u6784\u5efa\u7ed3\u679c/) + assert.doesNotMatch(servicePipeline, /wecom-prod-webhook|sendProductionWeComNotification/) +}) -- 2.54.0