diff --git a/Jenkinsfile b/Jenkinsfile index e80ce973e..af1e3f804 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -69,7 +69,7 @@ pipeline { booleanParam(name: 'SERVICE_LASTLEG', defaultValue: false, description: '海外尾程与入仓服务(lastleg-service)') booleanParam(name: 'SERVICE_WAREHOUSE_OVERSEAS', defaultValue: false, description: '海外仓资源服务(warehouse-overseas-service)') booleanParam(name: 'SERVICE_EVENT_BRIDGE', defaultValue: false, description: '跨区域事件桥(event-bridge-service;由 EVENT_BRIDGE_REGION 选择实例)') - choice(name: 'EVENT_BRIDGE_REGION', choices: ['CN', 'OVERSEAS'], description: '仅部署 event-bridge-service 时生效;明确选择国内或海外实例') + choice(name: 'EVENT_BRIDGE_REGION', choices: ['SELECT', 'CN', 'OVERSEAS'], description: '部署 event-bridge-service 时必须明确选择实例;SELECT 会阻止部署') booleanParam(name: 'SERVICE_AUDIT', defaultValue: false, description: '审计服务(audit-service)') choice(name: 'ENVIRONMENT', choices: environmentChoicesForJob(env.JOB_BASE_NAME), description: '部署环境') booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署') @@ -149,6 +149,10 @@ pipeline { } selected = selected.unique() + if (params.DEPLOY && selected.contains('event-bridge-service') + && !['CN', 'OVERSEAS'].contains(params.EVENT_BRIDGE_REGION)) { + error('部署 event-bridge-service 必须明确选择 EVENT_BRIDGE_REGION=CN 或 OVERSEAS') + } env.SELECTED_SERVICES = selected.join(',') env.NO_SERVICE_CHANGE = selected ? 'false' : 'true' if (!selected) { diff --git a/Jenkinsfile.service b/Jenkinsfile.service index ed42f05bf..3b43dae52 100644 --- a/Jenkinsfile.service +++ b/Jenkinsfile.service @@ -38,7 +38,7 @@ pipeline { choice(name: 'ENVIRONMENT', choices: ['test', 'development', 'staging', 'production'], description: '部署环境') booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务') booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试') - choice(name: 'EVENT_BRIDGE_REGION', choices: ['CN', 'OVERSEAS'], description: '仅 event-bridge-service 使用;通常由总控 Job 传入') + choice(name: 'EVENT_BRIDGE_REGION', choices: ['SELECT', 'CN', 'OVERSEAS'], description: '仅 event-bridge-service 部署使用;必须由总控明确传入 CN 或 OVERSEAS') string(name: 'IMAGE_REPOSITORY', defaultValue: '172.31.198.7:10082/java-project/baiyue-firstleg', description: '内网共享镜像仓库基础路径,不包含服务名和标签') string(name: 'DOCKER_BASE_IMAGE', defaultValue: '172.31.198.7:10082/java-project/eclipse-temurin:17-jre-alpine', description: '内网 Java 17 基础镜像') } diff --git a/common-event-contract/AGENTS.md b/common-event-contract/AGENTS.md index d7c0f6212..0a675effd 100644 --- a/common-event-contract/AGENTS.md +++ b/common-event-contract/AGENTS.md @@ -14,7 +14,7 @@ | 事件 ID 与业务键 | `EventIdentifiers.java` | | Payload 规范化与哈希 | `EventPayloadCanonicalizer.java`、`EventPayloadHash.java` | | 事件类型与区域 | `EventTypes.java`、`RegionCode.java` | -| 头程装柜完成 V2(当前生产) | `FirstLegLoadingPlanCompletedV2.java`;冻结来源/根订单与入仓标识、客户、业务类型、逐明细目的仓/备注/托盘号/实装箱号及一键代发托盘 SKU 快照;国内生产者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java`,海外消费者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java` | +| 头程装柜完成 V2(当前生产) | `FirstLegLoadingPlanCompletedV2.java`;订单级冻结 `destinationType=FBA|OVERSEAS`,FBA 可按箱数接收,OVERSEAS 逐明细冻结与实装箱数一致的 SKU/箱号;同时冻结来源/根订单与入仓标识、客户、业务类型、目的仓/备注/托盘及一键代发托盘 SKU 快照;国内生产者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java`,海外消费者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java` | | 头程装柜完成 V1 解析模型 | `FirstLegLoadingPlanCompletedV1.java`;用于消费端冻结字段映射,新增生产不得回退为 V1 | | 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` | | IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 | diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java index 1b4978c53..7d8dd65bd 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java @@ -16,12 +16,25 @@ public record FirstLegLoadingPlanCompletedV1( String destinationWarehouseCode, String destinationWarehouseName, int packageCount, Integer productQuantity, Integer palletCount, BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, - List items, String customerId, String orderType, List pallets) { + List items, String customerId, String orderType, List pallets, + String destinationType) { public Order { items = items == null ? List.of() : items; orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType; pallets = pallets == null ? List.of() : pallets; } + public Order(String sourceInboundId, String rootInboundId, + String sourceOrderId, String sourceOrderNo, String rootOrderId, + String inboundCode, String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, + int packageCount, Integer productQuantity, Integer palletCount, + BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, + List items, String customerId, String orderType, List pallets) { + this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, + inboundCode, destinationWarehouseId, destinationWarehouseCode, + destinationWarehouseName, packageCount, productQuantity, palletCount, + grossWeightKg, netWeightKg, volumeCbm, items, customerId, orderType, pallets, null); + } public Order(String sourceInboundId, String rootInboundId, String sourceOrderId, String sourceOrderNo, String rootOrderId, String inboundCode, String destinationWarehouseId, @@ -32,7 +45,7 @@ public record FirstLegLoadingPlanCompletedV1( this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, inboundCode, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName, packageCount, productQuantity, palletCount, - grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of()); + grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of(), null); } } diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java index cb0d36542..786ebae4c 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java @@ -15,13 +15,28 @@ public record FirstLegLoadingPlanCompletedV2( String destinationWarehouseCode, String destinationWarehouseName, int packageCount, Integer productQuantity, Integer palletCount, BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, - List items, String customerId, String orderType, List pallets) { + List items, String customerId, String orderType, List pallets, + /** 目的类型:FBA 可按箱数收货,OVERSEAS 必须逐箱扫码。 */ + String destinationType) { public Order { items = items == null ? List.of() : items; orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType; pallets = pallets == null ? List.of() : pallets; } + public Order(String sourceInboundId, String rootInboundId, + String sourceOrderId, String sourceOrderNo, String rootOrderId, + String inboundCode, String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, + int packageCount, Integer productQuantity, Integer palletCount, + BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, + List items, String customerId, String orderType, List pallets) { + this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, + inboundCode, destinationWarehouseId, destinationWarehouseCode, + destinationWarehouseName, packageCount, productQuantity, palletCount, + grossWeightKg, netWeightKg, volumeCbm, items, customerId, orderType, pallets, null); + } + public Order(String sourceInboundId, String rootInboundId, String sourceOrderId, String sourceOrderNo, String rootOrderId, String inboundCode, String destinationWarehouseId, @@ -32,7 +47,7 @@ public record FirstLegLoadingPlanCompletedV2( this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, inboundCode, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName, packageCount, productQuantity, palletCount, - grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of()); + grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of(), null); } } diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1Test.java index 3b5b72757..95a6b394b 100644 --- a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1Test.java +++ b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1Test.java @@ -47,4 +47,17 @@ class FirstLegLoadingPlanCompletedV1Test { assertThat(restored).isEqualTo(payload); assertThat(json).contains("ONE_CLICK_DROPSHIP", "PLT-01", "BOX-003", "customerId"); } + + @Test + void legacyParserRetainsDestinationTypeWhenV2OrderCarriesIt() throws Exception { + String json = """ + {"loadingPlanId":"1","planNo":"LP-1","containerBookingId":"2","containerNo":"CONT-1", + "warehouseId":"3","warehouseName":"海外仓","orders":[{"sourceInboundId":"100", + "destinationWarehouseId":"3","packageCount":1,"items":[],"destinationType":"OVERSEAS"}]} + """; + + FirstLegLoadingPlanCompletedV1 restored = mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class); + + assertThat(restored.orders().get(0).destinationType()).isEqualTo("OVERSEAS"); + } } diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java index b5bb212f4..d15900021 100644 --- a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java +++ b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java @@ -40,6 +40,18 @@ class FirstLegLoadingPlanCompletedV2Test { assertThat(json.at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2); } + @Test + void carriesFrozenDestinationType() { + var order = new FirstLegLoadingPlanCompletedV2.Order( + "2", "2", "3", "ORD", "3", "RK", null, "ONT8", "FBA ONT8", + 2, 20, 0, null, null, null, List.of(), "11", "FIRST_LEG", List.of(), "FBA"); + + var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2( + "4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order))); + + assertThat(json.at("/orders/0/destinationType").asText()).isEqualTo("FBA"); + } + @Test void mapsEachOrdinaryCartonNumberToItsSku() { var item = new FirstLegLoadingPlanCompletedV2.Item("1", "SKU-PSC2", "产品", "Product", 2, diff --git a/deploy/scripts/deploy-built-service.sh b/deploy/scripts/deploy-built-service.sh index 1479c8193..458834cd4 100644 --- a/deploy/scripts/deploy-built-service.sh +++ b/deploy/scripts/deploy-built-service.sh @@ -246,6 +246,11 @@ deploy_local() { new_started=false fi if [ "$new_started" = "true" ] && wait_for_local_health; then + running_image="$(local_container_image || true)" + if [ "$running_image" != "$IMAGE" ]; then + echo "运行容器镜像与请求镜像不一致: expected=$IMAGE actual=${running_image:-missing}" >&2 + return 1 + fi echo "新镜像健康检查通过: $IMAGE" local_compose ps "$SERVICE" local_registry_logout @@ -441,6 +446,11 @@ if ! compose up -d --no-deps --pull never "$service"; then new_started=false fi if [ "$new_started" = "true" ] && wait_for_health; then + running_image="$(container_image || true)" + if [ "$running_image" != "$new_image" ]; then + echo "运行容器镜像与请求镜像不一致: expected=$new_image actual=${running_image:-missing}" >&2 + exit 1 + fi echo "新镜像健康检查通过: $new_image" compose ps "$service" exit 0 diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 508e02ba8..7adb6153f 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -319,7 +319,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_carton ( destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', plan_id BIGINT NULL COMMENT '海外入库计划ID', line_id BIGINT NULL COMMENT '入库计划明细ID', plan_pallet_id BIGINT NULL COMMENT '海外计划托盘ID', warehouse_pallet_id BIGINT NULL COMMENT '海外实物托盘ID', warehouse_location_id BIGINT NULL COMMENT '海外库位ID', pda_task_id BIGINT NULL COMMENT '首次扫码PDA任务ID', - status VARCHAR(32) NOT NULL COMMENT '箱唛状态:UNASSIGNED、ON_PALLET、AVAILABLE', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + status VARCHAR(32) NOT NULL COMMENT '箱唛状态:UNASSIGNED、ON_PALLET、BULK_RECEIVED、AVAILABLE', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', bound_at DATETIME(3) NULL COMMENT '绑定托盘时间', received_at DATETIME(3) NULL COMMENT '完成入库时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_inbound_carton_mark(customer_id,carton_mark), @@ -331,6 +331,53 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab 'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id')); PREPARE phase4_event_stmt FROM @ddl; EXECUTE phase4_event_stmt; DEALLOCATE PREPARE phase4_event_stmt; +-- 头程 V2 事件冻结目的类型;只补结构,不由部署脚本自动执行历史数据回填。 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='destination_type'), + 'ALTER TABLE ll_order MODIFY COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码''', + 'ALTER TABLE ll_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER order_type')); +PREPARE phase4_destination_type_stmt FROM @ddl; EXECUTE phase4_destination_type_stmt; DEALLOCATE PREPARE phase4_destination_type_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='customer_id'), + 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户账号标识''', + 'ALTER TABLE ll_inbound_plan_line ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id')); +PREPARE phase4_receiving_line_stmt FROM @ddl; EXECUTE phase4_receiving_line_stmt; DEALLOCATE PREPARE phase4_receiving_line_stmt; +UPDATE ll_inbound_plan_line l JOIN ll_inbound_plan p ON p.id=l.plan_id SET l.customer_id=p.customer_id WHERE l.customer_id=''; +ALTER TABLE ll_inbound_plan_line MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识'; +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='fba_warehouse_code'), + 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''FBA仓库编码''', + 'ALTER TABLE ll_inbound_plan_line ADD COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''FBA仓库编码'' AFTER inbound_code')); +PREPARE phase4_receiving_line_stmt FROM @ddl; EXECUTE phase4_receiving_line_stmt; DEALLOCATE PREPARE phase4_receiving_line_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='destination_type'), + 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN destination_type VARCHAR(32) NOT NULL DEFAULT ''OVERSEAS'' COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码''', + 'ALTER TABLE ll_inbound_plan_line ADD COLUMN destination_type VARCHAR(32) NOT NULL DEFAULT ''OVERSEAS'' COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER inbound_code')); +PREPARE phase4_receiving_line_stmt FROM @ddl; EXECUTE phase4_receiving_line_stmt; DEALLOCATE PREPARE phase4_receiving_line_stmt; +UPDATE ll_inbound_plan_line l JOIN ll_order o ON o.id=l.order_id +SET l.destination_type=COALESCE(NULLIF(o.destination_type,''),CASE WHEN NULLIF(l.fba_warehouse_code,'') IS NULL THEN 'OVERSEAS' ELSE 'FBA' END) +WHERE l.destination_type IS NULL OR l.destination_type='' OR l.destination_type='OVERSEAS'; + +CREATE TABLE IF NOT EXISTS ll_receiving_container_session ( + id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', + operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话账号唯一锁键', + active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED', + photo_file_ids JSON NULL COMMENT '签收卸柜照片文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间', signed_at DATETIME(3) NULL COMMENT '签收时间', + receiving_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 '更新时间', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', PRIMARY KEY(id), + UNIQUE KEY uk_ll_receiving_active_operator(active_operator_key), UNIQUE KEY uk_ll_receiving_active_container(active_container_key), + KEY idx_ll_receiving_container(customer_id,container_no,status), KEY idx_ll_receiving_operator(customer_id,operator_key,created_at) +) ENGINE=InnoDB COMMENT='海外PDA柜号签收收货会话'; + +CREATE TABLE IF NOT EXISTS ll_receiving_container_operation ( + id BIGINT NOT NULL COMMENT '柜号收货操作ID', session_id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', + operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键', + request_hash VARCHAR(128) NOT NULL COMMENT '请求内容哈希', request_json LONGTEXT NOT NULL COMMENT '请求内容快照', result_json LONGTEXT NOT NULL COMMENT '响应内容快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_receiving_operation(customer_id,operator_key,idempotency_key), KEY idx_ll_receiving_operation_session(session_id,created_at) +) ENGINE=InnoDB COMMENT='海外PDA柜号收货幂等审计记录'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='destination_type'), + 'ALTER TABLE ll_inbound_order MODIFY COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码''', + 'ALTER TABLE ll_inbound_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER destination_warehouse_name')); +PREPARE phase4_destination_type_stmt FROM @ddl; EXECUTE phase4_destination_type_stmt; DEALLOCATE PREPARE phase4_destination_type_stmt; + SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='sub_order_status'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN sub_order_status VARCHAR(255) NULL COMMENT ''子单据状态汇总'' AFTER current_status')); diff --git a/event-bridge-service/AGENTS.md b/event-bridge-service/AGENTS.md index aa40eee17..75fe1f706 100644 --- a/event-bridge-service/AGENTS.md +++ b/event-bridge-service/AGENTS.md @@ -15,7 +15,7 @@ | 按目标区域调度与重试 | `service/OverseasEventDispatcher.java`;`targetRegion=OVERSEAS` 投 Lastleg,`targetRegion=CN` 回投 Firstleg | | 尾程 HTTP 客户端 | `service/LastlegEventClient.java`、`HttpLastlegEventClient.java` | | 国内头程 HTTP 客户端 | `service/FirstlegEventClient.java`、`HttpFirstlegEventClient.java`;默认内部入口 `/internal/firstleg/v1/container-events` | -| 人工补投 | `service/EventReplayService.java` | +| 人工补投 | `service/EventReplayService.java`;只允许 `DEAD` 转回 `RETRY`,记录操作人与原因,并保留原 `eventId`、payload 与 `payloadHash` | | Inbox 与投递记录 | `domain/BridgeInbox.java`、`BridgeDeliveryAttempt.java` 及对应 Mapper | | 内部服务令牌 | `security/InternalServiceTokenGuard.java` | | 数据库 | `src/main/resources/db/event-bridge.sql` | diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java index 575bd2867..7a2b939f0 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java @@ -51,7 +51,8 @@ public class EventBridgeController { return ApiResponse.success(eventInboxService.receive(envelope)); } - @Operation(summary = "人工补投死信事件") + @Operation(summary = "人工补投死信事件", + description = "仅允许 DEAD 事件转回 RETRY;保留原 eventId、payload 与 payloadHash,必须记录操作人与补投原因") @PostMapping("/events/{eventId}/replay") public ApiResponse replay( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventReplayServiceTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventReplayServiceTest.java index 66b740ddf..9bd52210b 100644 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventReplayServiceTest.java +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventReplayServiceTest.java @@ -3,8 +3,10 @@ package com.baiyue.eventbridge.service; import com.baiyue.eventbridge.mapper.BridgeInboxMapper; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; +import org.apache.ibatis.annotations.Update; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.*; class EventReplayServiceTest { @@ -28,6 +30,16 @@ class EventReplayServiceTest { .isInstanceOf(IllegalStateException.class); } + @Test + void replayOnlyRequeuesDeadStateAndDoesNotRewriteFrozenEvent() throws Exception { + String sql = String.join(" ", BridgeInboxMapper.class + .getMethod("replayDead", String.class, String.class) + .getAnnotation(Update.class).value()).replaceAll("\\s+", " "); + + assertThat(sql).contains("status='RETRY'", "retry_count=0", "status='DEAD'") + .doesNotContain("payload_json=", "payload_hash="); + } + private EventReplayService service() { EventReplayService service = new EventReplayService(); ReflectionTestUtils.setField(service, "inboxMapper", mapper); diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 14106f6cf..72b9200eb 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -28,7 +28,7 @@ | 分柜与预装 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java` | | 货柜与智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`loading/`;按入仓码候选与箱数分配分别从 `/inbound-candidates`、`/plans/{planId}/inbound-assignments` 进入,明细规格拆分留在服务端 | | PDA 拣货与装柜 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java` | -| 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`client/EventBridgeClient.java`;按最终实装与稳定箱码冻结 V2,真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` | +| 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`client/EventBridgeClient.java`;从 `fl_inbound_order.destination_type` 冻结 V2 `orders[].destinationType=FBA|OVERSEAS`,OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` | | 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` | | 三期海运订舱与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`portschedule/controller/PlatformPortScheduleController.java`;订舱契约见 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` | | 四期国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/四期/船节点标准化契约.md` | diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java index b613660dd..c11e662f4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java @@ -19,7 +19,7 @@ public interface LoadingCompletedEventMapper { COALESCE(o.root_inbound_id, o.id) AS root_inbound_id, o.order_id AS source_order_id, o.order_no AS source_order_no, COALESCE(root_order.order_id, o.order_id) AS root_order_id, - o.inbound_code, o.business_type, o.destination_warehouse_id, o.destination_code AS destination_warehouse_code, + o.inbound_code, o.business_type, o.destination_type, o.destination_warehouse_id, o.destination_code AS destination_warehouse_code, o.destination_name AS destination_warehouse_name, i.id AS loading_plan_item_id, i.inbound_detail_id AS source_inbound_detail_id, i.sku_code, i.product_name_zh, i.product_name_en, i.package_count, i.product_quantity, @@ -73,7 +73,7 @@ public interface LoadingCompletedEventMapper { private Integer productQuantity; private Integer palletCount; private BigDecimal grossWeightKg; private BigDecimal netWeightKg; private BigDecimal dimensionalWeightKg; private BigDecimal volumeCbm; private String fbaTrackingNo; private String shipmentNo; private String shippingMark; - private String businessType; + private String businessType; private String destinationType; private String customerRemark; private String internalRemark; private String warehouseRemark; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java index 102e4ca39..f5879a22c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java @@ -103,13 +103,15 @@ public class LoadingCompletedOutboxService { first.getDestinationWarehouseCode(), first.getDestinationWarehouseName(), sumInt(sorted, Row::getPackageCount), sumNullableInt(sorted, Row::getProductQuantity), "ONE_CLICK_DROPSHIP".equals(orderType) ? pallets.size() : 0, sum(sorted, Row::getGrossWeightKg), sum(sorted, Row::getNetWeightKg), - sum(sorted, Row::getVolumeCbm), items, string(first.getAccountId()), orderType, pallets); + sum(sorted, Row::getVolumeCbm), items, string(first.getAccountId()), orderType, pallets, + destinationType(sorted)); } /** 国内按实装箱数从可用标签池稳定随机抽取箱唛,重复发布同一计划时结果不变。 */ private Map> cartonLabelPools(List rows) { Map> result = new java.util.LinkedHashMap<>(); Map> byInbound = rows.stream().filter(row -> !dropship(row.getBusinessType())) + .filter(row -> "OVERSEAS".equals(destinationType(List.of(row)))) .collect(java.util.stream.Collectors.groupingBy( row -> new LabelPoolKey(row.getAccountId(), row.getSourceInboundId()), LinkedHashMap::new, java.util.stream.Collectors.toList())); @@ -155,6 +157,17 @@ public class LoadingCompletedOutboxService { return result; } + private String destinationType(List rows) { + Set values = rows.stream().map(Row::getDestinationType) + .map(value -> value == null ? "" : value.trim().toUpperCase(java.util.Locale.ROOT)) + .collect(java.util.stream.Collectors.toSet()); + if (values.size() != 1 || (!values.contains("FBA") && !values.contains("OVERSEAS"))) { + throw new BizException(ResultCode.CONFLICT, + "装柜入仓单目的类型必须唯一且为 FBA 或 OVERSEAS,不能生成海外交接事件"); + } + return values.iterator().next(); + } + private record LabelPoolKey(long accountId, long inboundId) { } private List palletSnapshots(List values, List source) { 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 457052c45..acc2a91ef 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -58,6 +58,7 @@ class LoadingCompletedOutboxServiceTest { assertThat(first.payload().at("/orders/0/items/0/sourceInboundDetailId").asText()).isEqualTo("101"); assertThat(first.payload().at("/orders/0/items/0/skuCode").asText()).isEqualTo("SKU101"); assertThat(first.payload().at("/orders/0/items/0/cartonMarks/0").asText()).isEqualTo("BOX-101"); + assertThat(first.payload().at("/orders/0/destinationType").asText()).isEqualTo("OVERSEAS"); assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:2"); assertThat(first.eventVersion()).isEqualTo(2); } @@ -71,6 +72,31 @@ class LoadingCompletedOutboxServiceTest { .hasMessageContaining("缺少 SKU"); } + @Test void fbaFreezesPackageCountWithoutRequiringCartonLabels() throws Exception { + Row value = row(11, 100, 101); + value.setDestinationType("FBA"); + when(snapshots.rows(90L)).thenReturn(List.of(value)); + + 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/destinationType").asText()).isEqualTo("FBA"); + assertThat(event.payload().at("/orders/0/items/0/cartonMarks")).isEmpty(); + verify(manifests, org.mockito.Mockito.never()).labelPool(11L, 100L); + } + + @Test void rejectsUnfrozenDestinationType() { + Row value = row(11, 100, 101); + value.setDestinationType(null); + when(snapshots.rows(90L)).thenReturn(List.of(value)); + + assertThatThrownBy(() -> service.publish(90L)) + .hasMessageContaining("目的类型必须唯一且为 FBA 或 OVERSEAS"); + verify(outbox, org.mockito.Mockito.never()).insert(org.mockito.ArgumentMatchers.any()); + } + @Test void freezesExactlyLoadedPackagesFromLabelPoolDeterministically() throws Exception { Row value = row(11, 100, 101); value.setPackageCount(2); @@ -148,6 +174,7 @@ class LoadingCompletedOutboxServiceTest { r.setSourceOrderId(inbound+1000); r.setSourceOrderNo("FL"+inbound); r.setRootOrderId(1099L); r.setInboundCode("RK"+inbound); r.setLoadingPlanItemId(detail); r.setDestinationWarehouseId(93L); r.setDestinationWarehouseCode("ONT8"); r.setDestinationWarehouseName("ONT8"); r.setSourceInboundDetailId(detail); r.setSkuCode("SKU"+detail); + r.setDestinationType("OVERSEAS"); r.setPackageCount(1); r.setProductQuantity(10); r.setPalletCount(1); r.setGrossWeightKg(BigDecimal.ONE); r.setNetWeightKg(BigDecimal.ONE); r.setVolumeCbm(BigDecimal.ONE); return r; } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java index 6f49e1202..df13829c5 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java @@ -88,9 +88,9 @@ class TaillegRealProducerAcceptanceTest { private void seedInbound(long id, long orderId, String code, String type, String status, String destination) { jdbc.update("INSERT IGNORE INTO fl_inbound_order(id,tenant_id,order_id,order_no,inbound_code,process_status," + - "order_status_snapshot,customer_name,warehouse_id,warehouse_name,business_type,destination_warehouse_id," + + "order_status_snapshot,customer_name,warehouse_id,warehouse_name,business_type,destination_type,destination_warehouse_id," + "destination_code,destination_name,package_count,actual_volume_cbm,created_by,updated_by) " + - "VALUES(?,?,?,?,?,?, 'IN_TRANSIT',?,92,'真实验收海外仓',?,9001,?,?,?,0.4000,1,1)", + "VALUES(?,?,?,?,?,?, 'IN_TRANSIT',?,92,'真实验收海外仓',?,'FBA',9001,?,?,?,0.4000,1,1)", id, CUSTOMER, orderId, MARKER + "-ORDER-" + id, code, status, "真实验收客户", type, destination, "FBA " + destination, type.equals("ONE_CLICK_DROPSHIP") ? 2 : 3); } diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 4fef09376..3ea28089c 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -12,7 +12,7 @@ |---|---| | 事件接收、平台订单列表/详情/导出、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java` | | 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` | -| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V2,消费端按冻结字段校验后建单 | +| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V2,消费端冻结 `destinationType`:FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;历史缺字段事件仅按明确 FBA 单号或目的仓与接柜仓一致性安全推断,歧义直接拒绝 | | 尾程单号生成 | `service/LastlegOrderNoGenerator.java` | | 订单查询与更新 | `service/LastlegOrderService.java` | | 业务 Inbox | `entity/LastlegEventInbox.java`、`mapper/LastlegEventInboxMapper.java` | diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java index 0769880ca..9d8fc653d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java @@ -41,7 +41,8 @@ public class LastlegOrderController { @Resource private InternalServiceTokenGuard tokenGuard; @PostMapping("/internal/lastleg/events") - @Operation(summary = "Receive a first-leg loading-completed event") + @Operation(summary = "Receive a first-leg loading-completed event", + description = "V2 orders[].destinationType is FBA or OVERSEAS. FBA may use package counts; OVERSEAS requires one carton mark per loaded package. Legacy events are accepted only when the destination type can be inferred safely.") public ApiResponse receive( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @RequestBody EventEnvelope event) { 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 bfe09c241..fb5b95346 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 @@ -11,6 +11,8 @@ public record LastlegOrderSummary( String orderNo, String orderType, String orderTypeName, + @Schema(description = "跨区冻结目的类型", allowableValues = {"FBA", "OVERSEAS"}) + String destinationType, String sourceInboundId, String inboundCode, String sourceOrderNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegInboundOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegInboundOrder.java index 5c9927d88..3bacefe47 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegInboundOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegInboundOrder.java @@ -25,6 +25,7 @@ public class LastlegInboundOrder { private String destinationWarehouseId; private String destinationWarehouseCode; private String destinationWarehouseName; + private String destinationType; private Integer packageCount; private Integer productQuantity; private Integer palletCount; 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 903ab3afb..57229f0e4 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 @@ -15,6 +15,7 @@ public class LastlegOrder { private String orderNo; private String customerId; private String orderType; + private String destinationType; private String inboundCode; private String sourceRegion; private String sourceInboundId; 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 21026bf8d..6e8cd25d4 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 @@ -16,6 +16,7 @@ public class InboundPlanLine { private Long planId; private Long orderId; private String inboundCode; + private String destinationType; private String fbaWarehouseCode; private String destinationWarehouseId; private String destinationWarehouseCode; 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 new file mode 100644 index 000000000..cfa3b3f66 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java @@ -0,0 +1,129 @@ +package com.baiyue.lastleg.inboundplan.pda.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests; +import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses; +import com.baiyue.lastleg.inboundplan.pda.service.ReceivingContainerService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +@RestController +@RequestMapping("/api/pda/v1/last-leg/receiving-containers") +@Tag(name = "Overseas PDA container receiving") +public class ReceivingContainerController { + public static final String OPERATE_PERMISSION = "lastleg:pda:operate"; + @Resource private ReceivingContainerService service; + + @GetMapping("/candidates") + @Operation(summary = "List pending receiving containers for the authenticated customer") + public ApiResponse> candidates( + @AuthenticationPrincipal AuthenticatedUser user) { + Identity identity = identity(user); + return ApiResponse.success(service.candidates(identity.customerId(), identity.operatorKey())); + } + + @GetMapping("/{containerNo}") + @Operation(summary = "Get one receiving container without exposing plan or allocation IDs") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String containerNo) { + Identity identity = identity(user); + return ApiResponse.success(service.detail(identity.customerId(), identity.operatorKey(), containerNo)); + } + + @PostMapping("/{containerNo}/prepare") + public ApiResponse prepare(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String containerNo, @Valid @RequestBody ReceivingContainerRequests.Action request) { + Identity identity = identity(user); + return ApiResponse.success(service.prepare(identity.customerId(), identity.operatorKey(), identity.operatorName(), + containerNo, request)); + } + + @PostMapping(value = "/current/unload-photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse uploadPhoto(@AuthenticationPrincipal AuthenticatedUser user, + @RequestPart("file") MultipartFile file) { + Identity identity = identity(user); + if (user.userId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "PDA user ID is required"); + return ApiResponse.success(service.uploadPhoto(identity.customerId(), identity.operatorKey(), user.userId(), file)); + } + + @PostMapping("/current/sign") + public ApiResponse sign(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody ReceivingContainerRequests.Sign request) { + Identity identity = identity(user); + return ApiResponse.success(service.sign(identity.customerId(), identity.operatorKey(), identity.operatorName(), request)); + } + + @GetMapping("/current") + public ApiResponse current(@AuthenticationPrincipal AuthenticatedUser user) { + Identity identity = identity(user); + return ApiResponse.success(service.current(identity.customerId(), identity.operatorKey())); + } + + @PostMapping("/current/cartons/resolve") + public ApiResponse resolveCarton( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody ReceivingContainerRequests.CartonResolve request) { + Identity identity = identity(user); + return ApiResponse.success(service.resolveCarton(identity.customerId(), identity.operatorKey(), request)); + } + + @PostMapping("/current/pallets/resolve") + public ApiResponse resolvePallet( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody ReceivingContainerRequests.PalletResolve request) { + Identity identity = identity(user); + return ApiResponse.success(service.resolvePallet(identity.customerId(), identity.operatorKey(), request)); + } + + @PostMapping("/current/receipts/bulk") + public ApiResponse receiveBulk(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody ReceivingContainerRequests.BulkReceive request) { + Identity identity = identity(user); + return ApiResponse.success(service.receiveBulk(identity.customerId(), identity.operatorKey(), identity.operatorName(), request)); + } + + @PostMapping("/current/receipts/carton") + public ApiResponse receiveCarton(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody ReceivingContainerRequests.CartonReceive request) { + Identity identity = identity(user); + return ApiResponse.success(service.receiveCarton(identity.customerId(), identity.operatorKey(), identity.operatorName(), request)); + } + + @PostMapping("/current/complete") + public ApiResponse complete(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody ReceivingContainerRequests.Action request) { + Identity identity = identity(user); + return ApiResponse.success(service.complete(identity.customerId(), identity.operatorKey(), request)); + } + + private static Identity identity(AuthenticatedUser user) { + if (user == null || user.tenantId() == null || user.tenantId() <= 0 || user.userId() == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authenticated PDA customer identity is required"); + } + if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required"); + } + String operator = String.valueOf(user.userId()); + return new Identity(String.valueOf(user.tenantId()), operator, + user.username() == null || user.username().isBlank() ? operator : user.username()); + } + + private record Identity(String customerId, String operatorKey, String operatorName) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java new file mode 100644 index 000000000..191cad025 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.inboundplan.pda.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Positive; + +import java.util.List; + +public final class ReceivingContainerRequests { + private ReceivingContainerRequests() { } + + public record Action(@NotBlank String idempotencyKey) { } + public record Sign(@NotEmpty List<@Positive Long> photoFileIds, @NotBlank String idempotencyKey) { } + public record CartonResolve(@NotBlank String cartonMark) { } + public record PalletResolve(@NotBlank String palletCode, @NotBlank String inboundCode) { } + public record BulkReceive(@NotBlank String inboundCode, @NotBlank String palletCode, + @Positive int packageCount, @NotBlank String idempotencyKey) { } + public record CartonReceive(@NotBlank String cartonMark, @NotBlank String palletCode, + @NotBlank String idempotencyKey) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java new file mode 100644 index 000000000..7a1acf4cb --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java @@ -0,0 +1,38 @@ +package com.baiyue.lastleg.inboundplan.pda.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public final class ReceivingContainerResponses { + private ReceivingContainerResponses() { } + + public record Candidate(String containerNo, String status, int planCount, int inboundCodeCount, + int totalPackages, int receivedPackages, boolean currentAccount) { } + + public record Session(String containerNo, String status, String operatorName, int version, + List photoFileIds, LocalDateTime preparedAt, LocalDateTime signedAt, + LocalDateTime receivingAt, LocalDateTime completedAt, + int totalPackages, int receivedPackages, int pendingPackages, + List inbounds) { } + + public record Inbound(String inboundCode, String destinationType, String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, + String fbaWarehouseCode, int plannedPalletCount, int totalPackages, + int receivedPackages, int pendingPackages) { } + + public record CartonResolved(String cartonMark, String skuCode, String inboundCode, + String destinationType, String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, + String fbaWarehouseCode, int plannedPalletCount, int totalPackages, + int receivedPackages, int pendingPackages) { } + + public record PalletResolved(String palletCode, long warehouseId, String status, + String fbaWarehouseCode, String inboundCode, + String destinationType, boolean compatible) { } + + public record Receipt(String result, String containerNo, String inboundCode, + String destinationType, String palletCode, int acceptedPackages, + int receivedPackages, int pendingPackages, String sessionStatus) { } + + public record Photo(long fileId, String fileName, String contentType, long sizeBytes) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerOperation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerOperation.java new file mode 100644 index 000000000..18d2bfcac --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerOperation.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.inboundplan.pda.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_receiving_container_operation") +public class ReceivingContainerOperation { + @TableId(type = IdType.INPUT) + private Long id; + private Long sessionId; + private String customerId; + private String operatorKey; + private String action; + private String idempotencyKey; + private String requestHash; + private String requestJson; + private String resultJson; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java new file mode 100644 index 000000000..e2362227a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.inboundplan.pda.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.annotation.Version; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_receiving_container_session") +public class ReceivingContainerSession { + @TableId(type = IdType.INPUT) + private Long id; + private String customerId; + private String containerNo; + private String operatorKey; + private String operatorName; + private String activeOperatorKey; + private String activeContainerKey; + private String status; + private String photoFileIds; + private LocalDateTime preparedAt; + private LocalDateTime signedAt; + private LocalDateTime receivingAt; + private LocalDateTime completedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + @Version + private Integer version; +} 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 1a5ebcf0c..c23317f40 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 @@ -35,7 +35,9 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { throw new IllegalStateException("Warehouse rejected dropship pallet: HTTP " + result.getStatusCode()); }).body(WarehousePalletResponse.class); if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned an empty pallet response"); - return new WarehousePalletReference(response.data().id(), response.data().palletCode(), response.data().locationId()); + return new WarehousePalletReference(response.data().id(), response.data().palletCode(), + response.data().warehouseId(), response.data().locationId(), response.data().status(), + response.data().fbaWarehouseCode(), response.data().version()); } @Override @@ -82,8 +84,23 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { .toBodilessEntity(); } + @Override + public WarehousePalletReference resolvePallet(String palletCode) { + WarehousePalletResponse response = client.get() + .uri("/internal/v1/warehouse-pallets/resolve/{palletCode}", palletCode) + .header("X-Internal-Service-Token", internalToken) + .retrieve().onStatus(HttpStatusCode::isError, (request, result) -> { + throw new IllegalArgumentException("Warehouse rejected pallet code: HTTP " + result.getStatusCode()); + }).body(WarehousePalletResponse.class); + if (response == null || response.data() == null) throw new IllegalArgumentException("Warehouse returned an empty pallet response"); + return new WarehousePalletReference(response.data().id(), response.data().palletCode(), + response.data().warehouseId(), response.data().locationId(), response.data().status(), + response.data().fbaWarehouseCode(), response.data().version()); + } + private record WarehousePalletResponse(WarehousePalletData data) {} - private record WarehousePalletData(long id, String palletCode, long locationId) {} + private record WarehousePalletData(long id, String palletCode, long warehouseId, long locationId, + String status, String fbaWarehouseCode, int version) {} private record ReserveEnvelope(ReserveData data) {} private record ReserveData(long lockId) {} } 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 ada468ece..a1f5f2419 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 @@ -6,6 +6,12 @@ public interface WarehouseResourceGateway { String businessType, String businessId, String reservationKey); void scrap(String customerId, long resourceLockId); void scrapPallet(String customerId, long palletId); + WarehousePalletReference resolvePallet(String palletCode); - record WarehousePalletReference(long palletId, String palletNo, long locationId) {} + record WarehousePalletReference(long palletId, String palletNo, long warehouseId, long locationId, + String status, String fbaWarehouseCode, int version) { + public WarehousePalletReference(long palletId, String palletNo, long locationId) { + this(palletId, palletNo, 0L, locationId, null, null, 0); + } + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerOperationMapper.java new file mode 100644 index 000000000..c07beb72f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerOperationMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.inboundplan.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerOperation; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface ReceivingContainerOperationMapper extends BaseMapper { + @Select("SELECT * FROM ll_receiving_container_operation WHERE customer_id=#{customerId} " + + "AND operator_key=#{operatorKey} AND idempotency_key=#{key} LIMIT 1") + ReceivingContainerOperation find(@Param("customerId") String customerId, + @Param("operatorKey") String operatorKey, + @Param("key") String key); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerSessionMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerSessionMapper.java new file mode 100644 index 000000000..49485e0f6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerSessionMapper.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.inboundplan.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface ReceivingContainerSessionMapper extends BaseMapper { + @Select("SELECT * FROM ll_receiving_container_session WHERE customer_id=#{customerId} " + + "AND active_operator_key=#{activeOperatorKey} LIMIT 1 FOR UPDATE") + ReceivingContainerSession lockActiveByOperator(@Param("customerId") String customerId, + @Param("activeOperatorKey") String activeOperatorKey); + + @Select("SELECT * FROM ll_receiving_container_session WHERE customer_id=#{customerId} " + + "AND active_operator_key=#{activeOperatorKey} LIMIT 1") + ReceivingContainerSession findActiveByOperator(@Param("customerId") String customerId, + @Param("activeOperatorKey") String activeOperatorKey); + + @Select("SELECT * FROM ll_receiving_container_session WHERE customer_id=#{customerId} " + + "AND active_container_key=#{activeContainerKey} LIMIT 1 FOR UPDATE") + ReceivingContainerSession lockActiveByContainer(@Param("customerId") String customerId, + @Param("activeContainerKey") String activeContainerKey); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java index e34ab20bd..fde44b04a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java @@ -135,6 +135,74 @@ public class InboundPdaWorkflowService { return task(task, planNo); } + @Transactional + public void ensureSignedTask(String customerId, InboundPlan plan, String operatorId, String operatorName, + List photoFileIds, LocalDateTime signedAt) { + InboundPdaTask task = repository.lockTask(customerId, plan.getId()); + if (task == null) { + task = new InboundPdaTask(); + task.setId(IdWorker.getId()); task.setPlanId(plan.getId()); task.setCustomerId(customerId); + task.setTaskNo("PDA" + task.getId()); task.setCreatedAt(signedAt); task.setVersion(0); + repository.insert(task); + } + if ("COMPLETED".equals(task.getStatus())) return; + task.setStatus("SIGNED"); task.setOperatorId(operatorId); task.setOperatorName(operatorName); + task.setUnloadPhotoFileIds(write(photoFileIds)); task.setUnloadPhotoUploadedAt(signedAt); + task.setEstimatedEmptyReturnAt(signedAt.plusDays(2)); task.setUpdatedAt(signedAt); + task.setVersion(value(task.getVersion()) + 1); repository.update(task); + plan.setStatus("SIGNED"); plan.setUpdatedAt(signedAt); repository.update(plan); + } + + @Transactional + public void assignResolvedPallet(String customerId, InboundPlan plan, InboundPlanPallet pallet, + WarehouseResourceGateway.WarehousePalletReference resource) { + if (pallet.getWarehouseResourceLockId() != null) { + if (Objects.equals(pallet.getWarehousePalletId(), resource.palletId())) return; + throw conflict("Destination pallet is already bound to another physical pallet"); + } + String destinationKey = destinationKey(pallet); + String businessId = plan.getContainerNo() + ":" + destinationKey; + String reservationKey = "INBOUND_CONTAINER_DESTINATION_PALLET:" + businessId + ":" + resource.palletId(); + long lockId = warehouse.reserve(customerId, resource.warehouseId(), resource.palletId(), resource.locationId(), + "INBOUND_CONTAINER_DESTINATION_PALLET", businessId, reservationKey); + pallet.setWarehousePalletId(resource.palletId()); pallet.setWarehouseLocationId(resource.locationId()); + pallet.setWarehouseResourceLockId(lockId); pallet.setAssignmentStatus("ASSIGNED"); + pallet.setLockedAt(LocalDateTime.now()); repository.update(pallet); + } + + @Transactional + public int receiveBulkResolved(String customerId, InboundPlan plan, InboundPlanLine line, + InboundPlanPallet pallet, int packageCount, + String operatorId, String operatorName) { + InboundPdaTask task = requireActiveTask(customerId, plan); + int received = repository.allocations(plan.getId()).stream() + .filter(value -> Objects.equals(value.getLineId(), line.getId())) + .mapToInt(value -> value(value.getPickedPackageCount())).sum(); + int pending = value(line.getRequiredPackageCount()) - received; + if (packageCount > pending) throw conflict("Received package count exceeds the inbound pending total"); + InboundPlanPalletAllocation allocation = repository.lockAllocation(plan.getId(), line.getId(), pallet.getId()); + if (allocation == null) { + allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId()); + allocation.setCustomerId(customerId); allocation.setPlanId(plan.getId()); allocation.setLineId(line.getId()); + allocation.setPalletId(pallet.getId()); allocation.setAllocatedVolumeCbm(ZERO_VOLUME); + allocation.setPlannedPackageCount(0); allocation.setCountedPackageCount(0); + allocation.setPickedPackageCount(0); allocation.setActualVolumeCbm(ZERO_VOLUME); + allocation.setStatus("RECEIVING"); allocation.setVersion(0); repository.insert(allocation); + } + BigDecimal perPackage = perPackageVolume(line); + allocation.setCountedPackageCount(value(allocation.getCountedPackageCount()) + packageCount); + allocation.setPickedPackageCount(value(allocation.getPickedPackageCount()) + packageCount); + allocation.setActualVolumeCbm(volume(allocation.getActualVolumeCbm()) + .add(perPackage.multiply(BigDecimal.valueOf(packageCount))).setScale(4, RoundingMode.HALF_UP)); + allocation.setStatus(allocation.getPickedPackageCount() == value(line.getRequiredPackageCount()) + ? "RECEIVED" : "RECEIVING"); bump(allocation); repository.update(allocation); + pallet.setActualPackageCount(value(pallet.getActualPackageCount()) + packageCount); + pallet.setActualVolumeCbm(volume(pallet.getActualVolumeCbm()) + .add(perPackage.multiply(BigDecimal.valueOf(packageCount))).setScale(4, RoundingMode.HALF_UP)); + repository.update(pallet); startPicking(task, plan, operatorId, operatorName); + return packageCount; + } + @Transactional public PdaResponses.Operation count(String customerId, String planNo, long allocationId, PdaRequests.PackageOperation request) { @@ -395,14 +463,18 @@ public class InboundPdaWorkflowService { throw conflict("All line picked package totals must exactly equal required package totals"); } } + Map destinationTypes = lines.stream().collect(java.util.stream.Collectors.toMap( + InboundPlanLine::getId, line -> destinationType(line))); List cartons = repository.cartons(plan.getId()); - if (!cartons.isEmpty() && cartons.stream().anyMatch(carton -> !"ON_PALLET".equals(carton.getStatus()))) { + if (cartons.stream().anyMatch(carton -> "OVERSEAS".equals(destinationTypes.get(carton.getLineId())) + && !"ON_PALLET".equals(carton.getStatus()))) { throw conflict("All expected carton marks must be scanned onto a pallet before stock-in"); } LocalDateTime now = LocalDateTime.now(); stockReceipts.stockIn(customerId, plan, task, lines, allocations, repository.pallets(plan.getId()), now); for (InboundPlanCarton carton : cartons) { - carton.setStatus("AVAILABLE"); carton.setReceivedAt(now); carton.setUpdatedAt(now); + carton.setStatus("ON_PALLET".equals(carton.getStatus()) ? "AVAILABLE" : "BULK_RECEIVED"); + carton.setReceivedAt(now); carton.setUpdatedAt(now); carton.setVersion(value(carton.getVersion()) + 1); repository.update(carton); } task.setStatus("COMPLETED"); @@ -705,6 +777,13 @@ public class InboundPdaWorkflowService { return volume(line.getVolumeCbm()).divide(BigDecimal.valueOf(required), 8, RoundingMode.HALF_UP); } + private static String destinationType(InboundPlanLine line) { + if (line.getDestinationType() != null && !line.getDestinationType().isBlank()) { + return line.getDestinationType().trim().toUpperCase(); + } + return line.getFbaWarehouseCode() == null || line.getFbaWarehouseCode().isBlank() ? "OVERSEAS" : "FBA"; + } + private InboundPdaOperation replay(String customerId, String action, String key, Object request) { InboundPdaOperation operation = repository.operation(customerId, key); if (operation == null) return null; 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 new file mode 100644 index 000000000..5f92fb9d6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -0,0 +1,469 @@ +package com.baiyue.lastleg.inboundplan.pda.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper; +import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests; +import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests; +import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerOperation; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession; +import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerOperationMapper; +import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerSessionMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +@Service +public class ReceivingContainerService { + @Resource private InboundPlanMapper plans; + @Resource private InboundPlanLineMapper lines; + @Resource private InboundPlanPalletMapper pallets; + @Resource private InboundPlanPalletAllocationMapper allocations; + @Resource private InboundPlanCartonMapper cartons; + @Resource private LastlegOrderMapper orders; + @Resource private ReceivingContainerSessionMapper sessions; + @Resource private ReceivingContainerOperationMapper operations; + @Resource private WarehouseResourceGateway warehouse; + @Resource private UnloadPhotoFileGateway unloadPhotos; + @Resource private InboundPdaWorkflowService workflow; + @Resource private ObjectMapper sourceObjectMapper; + + private ObjectMapper objectMapper; + + @PostConstruct + void initializeObjectMapper() { + objectMapper = sourceObjectMapper.copy() + .configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true) + .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true); + } + + public List candidates(String customerId, String operatorKey) { + ReceivingContainerSession current = sessions.findActiveByOperator(customerId, activeOperatorKey(customerId, operatorKey)); + Map> grouped = new LinkedHashMap<>(); + plans.selectList(new LambdaQueryWrapper().eq(InboundPlan::getCustomerId, customerId) + .ne(InboundPlan::getStatus, "COMPLETED").orderByAsc(InboundPlan::getCreatedAt)).forEach(plan -> { + if (plan.getContainerNo() != null && !plan.getContainerNo().isBlank()) { + grouped.computeIfAbsent(plan.getContainerNo(), ignored -> new ArrayList<>()).add(plan); + } + }); + return grouped.entrySet().stream().map(entry -> { + Totals totals = totals(entry.getValue()); + boolean active = current != null && entry.getKey().equals(current.getContainerNo()); + return new ReceivingContainerResponses.Candidate(entry.getKey(), active ? current.getStatus() : "WAIT_SIGN", + entry.getValue().size(), totals.inboundCount(), totals.total(), totals.received(), active); + }).toList(); + } + + public ReceivingContainerResponses.Session detail(String customerId, String operatorKey, String containerNo) { + List containerPlans = requirePlans(customerId, containerNo); + ReceivingContainerSession current = sessions.findActiveByOperator(customerId, activeOperatorKey(customerId, operatorKey)); + if (current != null && !containerNo.equals(current.getContainerNo()) && !"WAIT_SIGN".equals(current.getStatus())) { + throw conflict("Current account is locked to another signed container"); + } + return snapshot(current != null && containerNo.equals(current.getContainerNo()) ? current : waitingView( + customerId, operatorKey, containerNo), containerPlans); + } + + @Transactional + public ReceivingContainerResponses.Session prepare(String customerId, String operatorKey, String operatorName, + String containerNo, ReceivingContainerRequests.Action request) { + ReceivingContainerOperation replay = replay(customerId, operatorKey, "PREPARE", request.idempotencyKey(), request); + if (replay != null) return read(replay, ReceivingContainerResponses.Session.class); + List containerPlans = requirePlans(customerId, containerNo); + String activeOperatorKey = activeOperatorKey(customerId, operatorKey); + ReceivingContainerSession active = sessions.lockActiveByOperator(customerId, activeOperatorKey); + if (active != null) { + if (!containerNo.equals(active.getContainerNo())) throw conflict("Finish the current container before selecting another one"); + ReceivingContainerResponses.Session result = snapshot(active, containerPlans); + save(active, "PREPARE", request.idempotencyKey(), request, result); return result; + } + if (sessions.lockActiveByContainer(customerId, activeContainerKey(customerId, containerNo)) != null) { + throw conflict("Container is already signed or prepared by another account"); + } + LocalDateTime now = LocalDateTime.now(); + ReceivingContainerSession session = new ReceivingContainerSession(); + session.setId(IdWorker.getId()); session.setCustomerId(customerId); session.setContainerNo(containerNo); + session.setOperatorKey(operatorKey); session.setOperatorName(operatorName); + session.setActiveOperatorKey(activeOperatorKey); session.setActiveContainerKey(activeContainerKey(customerId, containerNo)); + session.setStatus("WAIT_SIGN"); session.setPreparedAt(now); session.setCreatedAt(now); session.setUpdatedAt(now); + session.setVersion(0); + try { sessions.insert(session); } + catch (DuplicateKeyException duplicate) { throw conflict("Account or container already has an active receiving session"); } + ReceivingContainerResponses.Session result = snapshot(session, containerPlans); + save(session, "PREPARE", request.idempotencyKey(), request, result); return result; + } + + public ReceivingContainerResponses.Photo uploadPhoto(String customerId, String operatorKey, long operatorId, + MultipartFile file) { + ReceivingContainerSession session = requireCurrent(customerId, operatorKey, true); + if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Unload photos can only be uploaded before signing"); + UnloadPhotoFileGateway.UploadedFile uploaded = unloadPhotos.upload(customerId, session.getId(), operatorId, file); + return new ReceivingContainerResponses.Photo(uploaded.fileId(), uploaded.fileName(), uploaded.contentType(), uploaded.sizeBytes()); + } + + @Transactional + public ReceivingContainerResponses.Session sign(String customerId, String operatorKey, String operatorName, + ReceivingContainerRequests.Sign request) { + ReceivingContainerOperation replay = replay(customerId, operatorKey, "SIGN", request.idempotencyKey(), request); + if (replay != null) return read(replay, ReceivingContainerResponses.Session.class); + ReceivingContainerSession session = requireCurrent(customerId, operatorKey, true); + if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Container has already been signed"); + List photos = request.photoFileIds().stream().distinct().sorted().toList(); + if (photos.size() != request.photoFileIds().size()) throw conflict("Unload photo file IDs cannot be duplicated"); + photos.forEach(fileId -> unloadPhotos.validate(customerId, session.getId(), fileId)); + LocalDateTime now = LocalDateTime.now(); + session.setPhotoFileIds(write(photos)); session.setStatus("SIGNED"); session.setSignedAt(now); + session.setOperatorName(operatorName); session.setUpdatedAt(now); update(session); + for (InboundPlan plan : requirePlans(customerId, session.getContainerNo())) { + workflow.ensureSignedTask(customerId, plan, operatorKey, operatorName, photos, now); + } + ReceivingContainerResponses.Session result = snapshot(session, requirePlans(customerId, session.getContainerNo())); + save(session, "SIGN", request.idempotencyKey(), request, result); return result; + } + + public ReceivingContainerResponses.Session current(String customerId, String operatorKey) { + ReceivingContainerSession session = requireCurrent(customerId, operatorKey, false); + return snapshot(session, requirePlans(customerId, session.getContainerNo())); + } + + public ReceivingContainerResponses.CartonResolved resolveCarton(String customerId, String operatorKey, + ReceivingContainerRequests.CartonResolve request) { + ReceivingContainerSession session = requireSigned(customerId, operatorKey); + InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper() + .eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, request.cartonMark())); + if (carton == null) throw notFound("Carton mark was not found"); + InboundRef ref = refs(customerId, session.getContainerNo()).stream() + .filter(value -> Objects.equals(value.line().getOrderId(), carton.getOrderId())).findFirst() + .orElseThrow(() -> conflict("Carton mark belongs to another container")); + if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) { + throw conflict("FBA inbound must use bulk count instead of carton scan"); + } + ReceivingContainerResponses.Inbound inbound = inbound(ref); + return new ReceivingContainerResponses.CartonResolved(carton.getCartonMark(), carton.getSkuCode(), + inbound.inboundCode(), inbound.destinationType(), inbound.destinationWarehouseId(), + inbound.destinationWarehouseCode(), inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(), + inbound.plannedPalletCount(), inbound.totalPackages(), inbound.receivedPackages(), inbound.pendingPackages()); + } + + public ReceivingContainerResponses.PalletResolved resolvePallet(String customerId, String operatorKey, + ReceivingContainerRequests.PalletResolve request) { + ReceivingContainerSession session = requireSigned(customerId, operatorKey); + InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode()); + WarehouseResourceGateway.WarehousePalletReference pallet = warehouse.resolvePallet(request.palletCode()); + validatePallet(ref, pallet); + return new ReceivingContainerResponses.PalletResolved(pallet.palletNo(), pallet.warehouseId(), pallet.status(), + pallet.fbaWarehouseCode(), ref.line().getInboundCode(), destinationType(ref.line(), ref.order()), true); + } + + @Transactional + public ReceivingContainerResponses.Receipt receiveBulk(String customerId, String operatorKey, String operatorName, + ReceivingContainerRequests.BulkReceive request) { + ReceivingContainerOperation replay = replay(customerId, operatorKey, "BULK_RECEIVE", request.idempotencyKey(), request); + if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class); + ReceivingContainerSession session = requireSigned(customerId, operatorKey); + InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode()); + if (!"FBA".equals(destinationType(ref.line(), ref.order()))) { + throw conflict("OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden"); + } + WarehouseResourceGateway.WarehousePalletReference resource = warehouse.resolvePallet(request.palletCode()); + validatePallet(ref, resource); + InboundPlanPallet pallet = choosePallet(ref, resource); + workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource); + int accepted = workflow.receiveBulkResolved(customerId, ref.plan(), ref.line(), pallet, + request.packageCount(), operatorKey, operatorName); + markReceiving(session); + ReceivingContainerResponses.Inbound inbound = inbound(ref); + ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED", + session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), resource.palletNo(), accepted, + inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus()); + save(session, "BULK_RECEIVE", request.idempotencyKey(), request, result); return result; + } + + @Transactional + public ReceivingContainerResponses.Receipt receiveCarton(String customerId, String operatorKey, String operatorName, + ReceivingContainerRequests.CartonReceive request) { + ReceivingContainerOperation replay = replay(customerId, operatorKey, "CARTON_RECEIVE", request.idempotencyKey(), request); + if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class); + ReceivingContainerSession session = requireSigned(customerId, operatorKey); + InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper() + .eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, request.cartonMark())); + if (carton == null) throw notFound("Carton mark was not found"); + InboundRef ref = refs(customerId, session.getContainerNo()).stream() + .filter(value -> Objects.equals(value.line().getOrderId(), carton.getOrderId())).findFirst() + .orElseThrow(() -> conflict("Carton mark belongs to another container")); + if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) { + throw conflict("FBA destination must use bulk count"); + } + WarehouseResourceGateway.WarehousePalletReference resource = warehouse.resolvePallet(request.palletCode()); + validatePallet(ref, resource); + InboundPlanPallet pallet = choosePallet(ref, resource); + workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource); + workflow.scanCarton(customerId, ref.plan().getPlanNo(), new PdaRequests.ScanCarton( + request.cartonMark(), pallet.getId(), request.idempotencyKey() + ":SCAN", operatorKey, operatorName)); + markReceiving(session); + ReceivingContainerResponses.Inbound inbound = inbound(ref); + ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED", + session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), resource.palletNo(), 1, + inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus()); + save(session, "CARTON_RECEIVE", request.idempotencyKey(), request, result); return result; + } + + @Transactional + public ReceivingContainerResponses.Session complete(String customerId, String operatorKey, + ReceivingContainerRequests.Action request) { + ReceivingContainerOperation replay = replay(customerId, operatorKey, "COMPLETE", request.idempotencyKey(), request); + if (replay != null) return read(replay, ReceivingContainerResponses.Session.class); + ReceivingContainerSession session = requireSigned(customerId, operatorKey); + List containerPlans = requirePlans(customerId, session.getContainerNo()); + Totals totals = totals(containerPlans); + if (totals.total() != totals.received()) throw conflict("Every inbound code must be fully received before completing the container"); + for (InboundPlan plan : containerPlans) workflow.complete(customerId, plan.getPlanNo()); + LocalDateTime now = LocalDateTime.now(); + session.setStatus("COMPLETED"); session.setCompletedAt(now); session.setUpdatedAt(now); + session.setActiveOperatorKey(null); session.setActiveContainerKey(null); update(session); + ReceivingContainerResponses.Session result = snapshot(session, containerPlans); + save(session, "COMPLETE", request.idempotencyKey(), request, result); return result; + } + + private ReceivingContainerSession requireCurrent(String customerId, String operatorKey, boolean lock) { + String key = activeOperatorKey(customerId, operatorKey); + ReceivingContainerSession session = lock ? sessions.lockActiveByOperator(customerId, key) + : sessions.findActiveByOperator(customerId, key); + if (session == null) throw notFound("Current receiving container session was not found"); + return session; + } + + private ReceivingContainerSession requireSigned(String customerId, String operatorKey) { + ReceivingContainerSession session = requireCurrent(customerId, operatorKey, true); + if (!List.of("SIGNED", "RECEIVING").contains(session.getStatus())) { + throw conflict("Sign the receiving container before receiving cargo"); + } + return session; + } + + private List requirePlans(String customerId, String containerNo) { + List values = plansForContainer(customerId, containerNo); + if (values.isEmpty()) throw notFound("Receiving container was not found"); + if (values.stream().allMatch(value -> "COMPLETED".equals(value.getStatus()))) { + throw conflict("Receiving container is already completed"); + } + return values; + } + + private List plansForContainer(String customerId, String containerNo) { + List values = plans.selectList(new LambdaQueryWrapper() + .eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getContainerNo, containerNo) + .orderByAsc(InboundPlan::getId)); + if (values.isEmpty()) throw notFound("Receiving container was not found"); + return values; + } + + private List refs(String customerId, String containerNo) { + List result = new ArrayList<>(); + for (InboundPlan plan : plansForContainer(customerId, containerNo)) { + for (InboundPlanLine line : lines.selectList(new LambdaQueryWrapper() + .eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getPlanId, plan.getId()))) { + LastlegOrder order = orders.selectById(line.getOrderId()); + if (order == null || !customerId.equals(order.getCustomerId())) throw conflict("Inbound line order is missing"); + result.add(new InboundRef(plan, line, order)); + } + } + return result; + } + + private InboundRef requireInbound(String customerId, String containerNo, String inboundCode) { + List matches = refs(customerId, containerNo).stream() + .filter(value -> inboundCode.equals(value.line().getInboundCode())).toList(); + if (matches.isEmpty()) throw notFound("Inbound code is not part of the current container"); + if (matches.size() != 1) throw conflict("Inbound code is duplicated inside the current container"); + return matches.get(0); + } + + private InboundPlanPallet choosePallet(InboundRef ref, WarehouseResourceGateway.WarehousePalletReference resource) { + List candidates = pallets.selectList(new LambdaQueryWrapper() + .eq(InboundPlanPallet::getCustomerId, ref.order().getCustomerId()) + .eq(InboundPlanPallet::getPlanId, ref.plan().getId())).stream() + .filter(value -> sameDestination(ref.line(), value)) + .filter(value -> value.getWarehousePalletId() == null || Objects.equals(value.getWarehousePalletId(), resource.palletId())) + .sorted(Comparator.comparing((InboundPlanPallet value) -> !Objects.equals(value.getWarehousePalletId(), resource.palletId())) + .thenComparing(value -> value.getActualPackageCount() == null ? 0 : value.getActualPackageCount())) + .toList(); + if (candidates.isEmpty()) throw conflict("Pallet does not match an available destination pallet in this inbound code"); + return candidates.get(0); + } + + private void validatePallet(InboundRef ref, WarehouseResourceGateway.WarehousePalletReference pallet) { + if ("SCRAPPED".equals(pallet.status())) throw conflict("Pallet is scrapped"); + long actualWarehouse; + try { actualWarehouse = Long.parseLong(ref.order().getOverseasWarehouseId()); } + catch (Exception exception) { throw conflict("Inbound order has no valid receiving warehouse ID"); } + if (pallet.warehouseId() != actualWarehouse) throw conflict("Pallet belongs to another overseas warehouse"); + String type = destinationType(ref.line(), ref.order()); + if ("FBA".equals(type)) { + if (ref.line().getFbaWarehouseCode() == null || ref.line().getFbaWarehouseCode().isBlank() + || !ref.line().getFbaWarehouseCode().equalsIgnoreCase(String.valueOf(pallet.fbaWarehouseCode()))) { + throw conflict("Pallet FBA destination does not match the inbound code"); + } + } else { + if (pallet.fbaWarehouseCode() != null && !pallet.fbaWarehouseCode().isBlank()) { + throw conflict("FBA-designated pallet cannot receive an OVERSEAS destination carton"); + } + if (ref.line().getDestinationWarehouseId() != null && !ref.line().getDestinationWarehouseId().isBlank()) { + try { + if (Long.parseLong(ref.line().getDestinationWarehouseId()) != pallet.warehouseId()) { + throw conflict("Pallet destination warehouse does not match the inbound code"); + } + } catch (NumberFormatException ignored) { + // Destination codes that are not resource IDs are still protected by the actual overseas warehouse check. + } + } + } + } + + private boolean sameDestination(InboundPlanLine line, InboundPlanPallet pallet) { + if (line.getDestinationWarehouseId() != null && !line.getDestinationWarehouseId().isBlank()) { + return line.getDestinationWarehouseId().equals(pallet.getDestinationWarehouseId()); + } + return Objects.equals(line.getDestinationWarehouseCode(), pallet.getDestinationWarehouseCode()); + } + + private ReceivingContainerResponses.Session snapshot(ReceivingContainerSession session, List containerPlans) { + List inbounds = refs(session.getCustomerId(), 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(); + return new ReceivingContainerResponses.Session(session.getContainerNo(), session.getStatus(), session.getOperatorName(), + value(session.getVersion()), photoIds(session), session.getPreparedAt(), session.getSignedAt(), + session.getReceivingAt(), session.getCompletedAt(), total, received, Math.max(0, total - received), inbounds); + } + + private ReceivingContainerResponses.Inbound inbound(InboundRef ref) { + int received = allocations.selectList(new LambdaQueryWrapper() + .eq(InboundPlanPalletAllocation::getCustomerId, ref.order().getCustomerId()) + .eq(InboundPlanPalletAllocation::getPlanId, ref.plan().getId()) + .eq(InboundPlanPalletAllocation::getLineId, ref.line().getId())).stream() + .mapToInt(value -> value(value.getPickedPackageCount())).sum(); + int total = value(ref.line().getRequiredPackageCount()); + return new ReceivingContainerResponses.Inbound(ref.line().getInboundCode(), destinationType(ref.line(), ref.order()), + ref.line().getDestinationWarehouseId(), ref.line().getDestinationWarehouseCode(), + ref.line().getDestinationWarehouseName(), ref.line().getFbaWarehouseCode(), + value(ref.line().getPlannedPalletCount()), total, received, Math.max(0, total - received)); + } + + private Totals totals(List containerPlans) { + List values = refs(containerPlans.get(0).getCustomerId(), + containerPlans.get(0).getContainerNo()).stream().map(this::inbound).toList(); + return new Totals(values.size(), values.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum(), + values.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum()); + } + + private String destinationType(InboundPlanLine line, LastlegOrder order) { + String value = line.getDestinationType(); + if (value == null || value.isBlank()) value = order.getDestinationType(); + if (value == null || value.isBlank()) value = line.getFbaWarehouseCode() == null || line.getFbaWarehouseCode().isBlank() + ? "OVERSEAS" : "FBA"; + value = value.trim().toUpperCase(); + if (!List.of("FBA", "OVERSEAS").contains(value)) throw conflict("Unsupported destination type"); + return value; + } + + private void markReceiving(ReceivingContainerSession session) { + if (!"RECEIVING".equals(session.getStatus())) { + session.setStatus("RECEIVING"); session.setReceivingAt(LocalDateTime.now()); + } + session.setUpdatedAt(LocalDateTime.now()); update(session); + } + + private void update(ReceivingContainerSession session) { + if (sessions.updateById(session) != 1) throw conflict("Receiving container session changed concurrently"); + } + + private ReceivingContainerSession waitingView(String customerId, String operatorKey, String containerNo) { + ReceivingContainerSession value = new ReceivingContainerSession(); value.setCustomerId(customerId); + value.setOperatorKey(operatorKey); value.setContainerNo(containerNo); value.setStatus("WAIT_SIGN"); value.setVersion(0); + return value; + } + + private ReceivingContainerOperation replay(String customerId, String operatorKey, String action, String key, Object request) { + ReceivingContainerOperation value = operations.find(customerId, operatorKey, key); + if (value == null) return null; + if (!action.equals(value.getAction()) || !hash(request).equals(value.getRequestHash())) { + throw conflict("Idempotency key was already used with a different request"); + } + return value; + } + + private void save(ReceivingContainerSession session, String action, String key, Object request, Object result) { + ReceivingContainerOperation value = new ReceivingContainerOperation(); value.setId(IdWorker.getId()); + value.setSessionId(session.getId()); value.setCustomerId(session.getCustomerId()); + value.setOperatorKey(session.getOperatorKey()); value.setAction(action); value.setIdempotencyKey(key); + value.setRequestHash(hash(request)); value.setRequestJson(write(request)); value.setResultJson(write(result)); + value.setCreatedAt(LocalDateTime.now()); operations.insert(value); + } + + private List photoIds(ReceivingContainerSession session) { + if (session.getPhotoFileIds() == null || session.getPhotoFileIds().isBlank()) return List.of(); + try { return objectMapper.readValue(session.getPhotoFileIds(), new TypeReference>() { }); } + catch (JsonProcessingException exception) { throw new IllegalStateException("Cannot read receiving photo IDs", exception); } + } + + private String hash(Object value) { + try { return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(write(value).getBytes(StandardCharsets.UTF_8))); } + catch (Exception exception) { throw new IllegalStateException(exception); } + } + + private String write(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (JsonProcessingException exception) { throw new IllegalStateException(exception); } + } + + private T read(ReceivingContainerOperation operation, Class type) { + try { return objectMapper.readValue(operation.getResultJson(), type); } + catch (JsonProcessingException exception) { throw new IllegalStateException(exception); } + } + + private static String activeOperatorKey(String customerId, String operatorKey) { return customerId + ":" + operatorKey; } + private static String activeContainerKey(String customerId, String containerNo) { return customerId + ":" + containerNo; } + private static int value(Integer value) { return value == null ? 0 : value; } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } + + private record InboundRef(InboundPlan plan, InboundPlanLine line, LastlegOrder order) { } + private record Totals(int inboundCount, int total, int received) { } +} 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 7ef011637..6ae72a34e 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 @@ -162,6 +162,8 @@ public class InboundPlanService { line.setPlanId(plan.getId()); line.setOrderId(order.orderId()); line.setInboundCode(order.inboundCode()); + line.setDestinationType(order.fbaWarehouseCode() == null || order.fbaWarehouseCode().isBlank() + ? "OVERSEAS" : "FBA"); line.setFbaWarehouseCode(order.fbaWarehouseCode()); line.setDestinationWarehouseId(order.destinationWarehouseId()); line.setDestinationWarehouseCode(order.destinationWarehouseCode()); 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 0dd2de096..8b88d3694 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 @@ -179,7 +179,7 @@ public class LastlegOrderService { private LastlegOrderSummary summary(LastlegOrder o) { return new LastlegOrderSummary(o.getId(), o.getOrderNo(), o.getOrderType(), - LastlegOrderType.labelOf(o.getOrderType()), o.getSourceInboundId(), + LastlegOrderType.labelOf(o.getOrderType()), o.getDestinationType(), o.getSourceInboundId(), o.getInboundCode(), o.getSourceOrderNo(), o.getCustomerId(), o.getCustomerName(), o.getOverseasWarehouseId(), o.getOverseasWarehouseName(), o.getTransferWarehouseId(), o.getTransferWarehouseCode(), o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), o.getContainerStatus(), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java index e7407e43b..e48da2873 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -107,12 +107,27 @@ public class LoadingCompletedConsumer { } continue; } + String destinationType = destinationType(payload, order); for (FirstLegLoadingPlanCompletedV1.Item item : order.items()) { - requireSkuCartonMapping(item.skuCode(), item.packageCount(), item.cartonMarks(), orderCartonMarks); + if ("OVERSEAS".equals(destinationType)) { + requireSkuCartonMapping(item.skuCode(), item.packageCount(), item.cartonMarks(), orderCartonMarks); + } else { + validateOptionalFbaCartonMapping(item.skuCode(), item.packageCount(), item.cartonMarks(), orderCartonMarks); + } } } } + private void validateOptionalFbaCartonMapping(String skuCode, int loadedPackages, List cartonMarks, + Set orderCartonMarks) { + List marks = cartonMarks == null ? List.of() : cartonMarks; + if (skuCode == null || skuCode.isBlank()) { + throw new IllegalArgumentException("V2 尾程交接事件缺少 SKU"); + } + if (marks.isEmpty()) return; + requireSkuCartonMapping(skuCode, loadedPackages, marks, orderCartonMarks); + } + private void requireSkuCartonMapping(String skuCode, int loadedPackages, List cartonMarks, Set orderCartonMarks) { if (skuCode == null || skuCode.isBlank()) { @@ -139,6 +154,7 @@ public class LoadingCompletedConsumer { } LastlegOrder order=new LastlegOrder(); String orderType = source.orderType(); + String destinationType = destinationType(payload, source); String customerId = source.customerId() == null || source.customerId().isBlank() ? event.tenantId() : source.customerId(); if (!event.tenantId().equals(customerId)) { @@ -150,6 +166,7 @@ public class LoadingCompletedConsumer { throw new IllegalArgumentException("One-click dropship event has no pallet snapshot"); } order.setId(IdWorker.getId()); order.setOrderNo(orderNo); order.setCustomerId(customerId); order.setOrderType(orderType); + order.setDestinationType(destinationType); order.setInboundCode(source.inboundCode()); order.setSourceRegion(event.sourceRegion().name()); order.setSourceInboundId(source.sourceInboundId()); order.setRootInboundId(source.rootInboundId()); order.setSourceOrderId(source.sourceOrderId()); order.setSourceOrderNo(source.sourceOrderNo()); order.setRootOrderId(source.rootOrderId()); @@ -186,7 +203,7 @@ public class LoadingCompletedConsumer { detail.setDestinationWarehouseId(item.destinationWarehouseId()); detail.setDestinationWarehouseCode(item.destinationWarehouseCode()); detail.setDestinationWarehouseName(item.destinationWarehouseName()); detail.setFbaTrackingNo(item.fbaTrackingNo()); detail.setShipmentNo(item.shipmentNo()); detail.setShippingMark(item.shippingMark()); detailMapper.insert(detail); - createCartonLabels(customerId, order.getId(), item); + createCartonLabels(customerId, order.getId(), destinationType, item); } LastlegOrderStatusHistory history=new LastlegOrderStatusHistory(); history.setCustomerId(customerId); history.setOrderId(order.getId()); history.setToStatus("PENDING_ASSIGNMENT"); history.setEventId(event.eventId()); @@ -216,6 +233,7 @@ public class LoadingCompletedConsumer { snapshot.setDestinationWarehouseId(source.destinationWarehouseId()); snapshot.setDestinationWarehouseCode(source.destinationWarehouseCode()); snapshot.setDestinationWarehouseName(source.destinationWarehouseName()); + snapshot.setDestinationType(order.getDestinationType()); snapshot.setPackageCount(source.packageCount()); snapshot.setProductQuantity(source.productQuantity()); snapshot.setPalletCount(source.palletCount()); snapshot.setGrossWeightKg(source.grossWeightKg()); snapshot.setNetWeightKg(source.netWeightKg()); snapshot.setVolumeCbm(source.volumeCbm()); @@ -243,9 +261,11 @@ public class LoadingCompletedConsumer { } } - private void createCartonLabels(String customerId, Long orderId, FirstLegLoadingPlanCompletedV1.Item item) { + private void createCartonLabels(String customerId, Long orderId, String destinationType, + FirstLegLoadingPlanCompletedV1.Item item) { + if (!"OVERSEAS".equals(destinationType)) return; List marks = item.cartonMarks() == null ? List.of() : item.cartonMarks(); - if (marks.isEmpty()) return; // 历史 V1 事件保持原有按箱数入库路径。 + if (marks.isEmpty()) return; if (marks.stream().anyMatch(mark -> mark == null || mark.isBlank()) || marks.stream().distinct().count() != marks.size()) { throw new IllegalArgumentException("装柜事件的箱唛池不可包含空值或重复箱唛"); } @@ -261,6 +281,40 @@ public class LoadingCompletedConsumer { } } + private String destinationType(FirstLegLoadingPlanCompletedV1 payload, + FirstLegLoadingPlanCompletedV1.Order order) { + String explicit = normalizeDestinationType(order.destinationType()); + if (explicit != null) return explicit; + + boolean fbaEvidence = order.items().stream().anyMatch(item -> hasText(item.fbaTrackingNo()) + || hasText(item.shipmentNo()) || startsWithFba(item.destinationWarehouseName())); + boolean overseasWarehouseIdentity = hasText(payload.warehouseId()) + && Objects.equals(payload.warehouseId(), order.destinationWarehouseId()); + if (fbaEvidence && overseasWarehouseIdentity) { + throw new IllegalArgumentException("历史尾程事件目的类型证据冲突: " + order.sourceInboundId()); + } + if (fbaEvidence) return "FBA"; + if (overseasWarehouseIdentity) return "OVERSEAS"; + throw new IllegalArgumentException("历史尾程事件缺少 destinationType 且无法安全推断: " + order.sourceInboundId()); + } + + private String normalizeDestinationType(String value) { + if (!hasText(value)) return null; + String normalized = value.trim().toUpperCase(java.util.Locale.ROOT); + if (!"FBA".equals(normalized) && !"OVERSEAS".equals(normalized)) { + throw new IllegalArgumentException("尾程事件 destinationType 必须为 FBA 或 OVERSEAS"); + } + return normalized; + } + + private boolean startsWithFba(String value) { + return hasText(value) && value.trim().toUpperCase(java.util.Locale.ROOT).startsWith("FBA"); + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } + private List pallets(FirstLegLoadingPlanCompletedV1.Order source, JsonNode rawSource) { if (source.pallets() != null && !source.pallets().isEmpty()) { return source.pallets().stream().map(pallet -> new DropshipRequests.Pallet(pallet.palletNo(), diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 3c2f5c587..3b01747ad 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -23,6 +23,7 @@ CREATE TABLE IF NOT EXISTS ll_order ( order_no VARCHAR(32) NOT NULL COMMENT '尾程单号', customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', order_type VARCHAR(32) NOT NULL DEFAULT 'FIRST_LEG' COMMENT '订单类型:FIRST_LEG头程订单、RELABEL_APPLICATION换标下单、WAREHOUSE_TRANSFER仓储转运、LOCAL_LOOSEBOARD本土散板、RETURN_RELABEL退货换标、ONE_CLICK_DROPSHIP一件代发', + destination_type VARCHAR(32) NULL COMMENT '目的类型:FBA按箱数接收、OVERSEAS逐箱扫码', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓码', source_region VARCHAR(32) NOT NULL COMMENT '来源区域/系统', source_inbound_id VARCHAR(128) NOT NULL COMMENT '最终实装 LOADED 分片 ID,建单幂等键', @@ -141,6 +142,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_order ( source_order_id VARCHAR(128) NULL COMMENT '来源订单 ID', source_order_no VARCHAR(128) NULL COMMENT '来源订单号', root_order_id VARCHAR(128) NULL COMMENT '根订单 ID', inbound_code 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 '目的仓名称', + destination_type VARCHAR(32) NULL COMMENT '目的类型:FBA按箱数接收、OVERSEAS逐箱扫码', package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', product_quantity INT NULL COMMENT '数量', pallet_count INT NULL COMMENT '托数', gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克)', net_weight_kg DECIMAL(18,3) NULL COMMENT '净重(千克)', volume_cbm DECIMAL(18,6) NULL COMMENT '体积(立方米)', source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID', source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '装柜计划 ID', @@ -171,6 +173,10 @@ ALTER TABLE ll_loading_event_inbox COMMENT='头程装柜完成事件接收幂等 ALTER TABLE ll_order COMMENT='尾程订单主表'; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_container_booking_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id')); PREPARE lastleg_order_container_booking_stmt FROM @ddl; EXECUTE lastleg_order_container_booking_stmt; DEALLOCATE PREPARE lastleg_order_container_booking_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER order_type')); +PREPARE lastleg_order_destination_type_stmt FROM @ddl; EXECUTE lastleg_order_destination_type_stmt; DEALLOCATE PREPARE lastleg_order_destination_type_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='destination_type'), 'SELECT 1', 'ALTER TABLE ll_inbound_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER destination_warehouse_name')); +PREPARE lastleg_inbound_destination_type_stmt FROM @ddl; EXECUTE lastleg_inbound_destination_type_stmt; DEALLOCATE PREPARE lastleg_inbound_destination_type_stmt; ALTER TABLE ll_order_detail COMMENT='尾程订单实装商品冻结明细'; ALTER TABLE ll_loading_order_status_history COMMENT='尾程订单状态变更历史'; ALTER TABLE ll_event_conflict COMMENT='事件冲突记录'; @@ -360,9 +366,11 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan ( CREATE TABLE IF NOT EXISTS ll_inbound_plan_line ( id BIGINT NOT NULL COMMENT '入库计划明细ID(雪花ID)', + customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', plan_id BIGINT NOT NULL COMMENT '入库计划ID', order_id BIGINT NOT NULL COMMENT '尾程订单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '头程入仓码快照', + destination_type VARCHAR(32) NOT NULL DEFAULT 'OVERSEAS' COMMENT '目的类型:FBA按箱数接收、OVERSEAS逐箱扫码', destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', @@ -381,7 +389,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_carton ( destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', plan_id BIGINT NULL COMMENT '海外入库计划ID', line_id BIGINT NULL COMMENT '入库计划明细ID', plan_pallet_id BIGINT NULL COMMENT '海外计划托盘ID', warehouse_pallet_id BIGINT NULL COMMENT '海外实物托盘ID', warehouse_location_id BIGINT NULL COMMENT '海外库位ID', pda_task_id BIGINT NULL COMMENT '首次扫码PDA任务ID', - status VARCHAR(32) NOT NULL COMMENT '箱唛状态:UNASSIGNED、ON_PALLET、AVAILABLE', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + status VARCHAR(32) NOT NULL COMMENT '箱唛状态:UNASSIGNED、ON_PALLET、BULK_RECEIVED、AVAILABLE', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', bound_at DATETIME(3) NULL COMMENT '绑定托盘时间', received_at DATETIME(3) NULL COMMENT '完成入库时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_inbound_carton_mark(customer_id,carton_mark), @@ -623,9 +631,38 @@ CREATE TABLE IF NOT EXISTS ll_inbound_pallet_merge ( 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='fba_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN fba_warehouse_code VARCHAR(128) NULL AFTER inbound_code')); PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_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='customer_id'), 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户账号标识''', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id')); +PREPARE receiving_container_line_stmt FROM @ddl; EXECUTE receiving_container_line_stmt; DEALLOCATE PREPARE receiving_container_line_stmt; +UPDATE ll_inbound_plan_line l JOIN ll_inbound_plan p ON p.id=l.plan_id SET l.customer_id=p.customer_id WHERE l.customer_id=''; +ALTER TABLE ll_inbound_plan_line MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识'; +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='destination_type'), 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN destination_type VARCHAR(32) NOT NULL DEFAULT ''OVERSEAS'' COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码''', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN destination_type VARCHAR(32) NOT NULL DEFAULT ''OVERSEAS'' COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER inbound_code')); +PREPARE receiving_container_line_stmt FROM @ddl; EXECUTE receiving_container_line_stmt; DEALLOCATE PREPARE receiving_container_line_stmt; UPDATE ll_inbound_plan_line l JOIN ll_order o ON o.id=l.order_id -SET l.fba_warehouse_code=o.fba_warehouse_code -WHERE l.fba_warehouse_code IS NULL AND o.fba_warehouse_code IS NOT NULL; +SET l.fba_warehouse_code=o.fba_warehouse_code, + l.destination_type=COALESCE(NULLIF(o.destination_type,''),CASE WHEN NULLIF(o.fba_warehouse_code,'') IS NULL THEN 'OVERSEAS' ELSE 'FBA' END) +WHERE (l.fba_warehouse_code IS NULL AND o.fba_warehouse_code IS NOT NULL) + OR l.destination_type IS NULL OR l.destination_type='' OR l.destination_type='OVERSEAS'; + +CREATE TABLE IF NOT EXISTS ll_receiving_container_session ( + id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', + container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', + operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话账号唯一锁键', + active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED', + photo_file_ids JSON NULL COMMENT '签收卸柜照片文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间', + signed_at DATETIME(3) NULL COMMENT '签收时间', receiving_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 '更新时间', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + PRIMARY KEY(id), UNIQUE KEY uk_ll_receiving_active_operator(active_operator_key), UNIQUE KEY uk_ll_receiving_active_container(active_container_key), + KEY idx_ll_receiving_container(customer_id,container_no,status), KEY idx_ll_receiving_operator(customer_id,operator_key,created_at) +) ENGINE=InnoDB COMMENT='海外PDA柜号签收收货会话'; + +CREATE TABLE IF NOT EXISTS ll_receiving_container_operation ( + id BIGINT NOT NULL COMMENT '柜号收货操作ID', session_id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', + operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键', + request_hash VARCHAR(128) NOT NULL COMMENT '请求内容哈希', request_json LONGTEXT NOT NULL COMMENT '请求内容快照', result_json LONGTEXT NOT NULL COMMENT '响应内容快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_receiving_operation(customer_id,operator_key,idempotency_key), KEY idx_ll_receiving_operation_session(session_id,created_at) +) ENGINE=InnoDB COMMENT='海外PDA柜号收货幂等审计记录'; CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment ( id BIGINT NOT NULL COMMENT '主键ID', 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 eae0eca93..856aff935 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -23,7 +23,7 @@ paths: - { name: detained, in: query, schema: { type: boolean } } - { name: createdFrom, in: query, schema: { type: string, format: date-time } } - { name: createdTo, in: query, schema: { type: string, format: date-time } } - responses: { '200': { description: 统一 ApiResponse 包装的尾程订单分页 } } + responses: { '200': { description: 统一 ApiResponse 包装的尾程订单分页;列表行 destinationType 为 FBA 或 OVERSEAS } } /api/platform/v1/last-leg/dispatches: get: summary: 分页查询排车单 diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java index 34dae31ba..d3ff15dbb 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java @@ -4,18 +4,18 @@ import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com class LoadingCompletedConsumerTest { @Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InboundPlanService inboundPlans; @Mock InventoryCommandService inventory; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service; @BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inboundPlans",inboundPlans); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"objectMapper",json); ReflectionTestUtils.setField(service,"orderNoGenerator",new LastlegOrderNoGenerator()); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(inboundPlans.convert(anyString(),anyLong())).thenReturn(new InboundPlanResponse(1L,"LIP-1","100",1L,"MSCU1","PENDING_ASSIGNMENT",1,1,BigDecimal.ONE,List.of(),List.of(),List.of())); } - @Test void createsOneOrderAndOneInboundPlanPerInbound(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); verify(orders,times(2)).insert(any(LastlegOrder.class)); verify(inboundPlans,times(2)).convert(eq("100"),anyLong()); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),any(InventoryCommandRequests.InTransit.class)); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); } + @Test void createsOneOrderAndOneInboundPlanPerInbound(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); verify(orders,times(2)).insert(any(LastlegOrder.class)); verify(inboundPlans,times(2)).convert(eq("100"),anyLong()); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),any(InventoryCommandRequests.InTransit.class)); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); } @Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU1")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU1"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); } @Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); } @Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); } - @Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","ONE_CLICK_DROPSHIP",List.of(pallet)); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","CONT-D","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); assertEquals("100",captor.getValue().customerId()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); } + @Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","CONT-D","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); assertEquals("100",captor.getValue().customerId()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); } @Test void oneDropshipInboundWithMultipleSkusCreatesOneOrderAndMultipleSkuDetails(){ var itemA=new FirstLegLoadingPlanCompletedV1.Item("d-a","SKU-A","产品A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-A","MARK-A",null,List.of("BOX-A1","BOX-A2")); var itemB=new FirstLegLoadingPlanCompletedV1.Item("d-b","SKU-B","产品B","Product B",3,15,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-B","MARK-B",null,List.of("BOX-B1","BOX-B2","BOX-B3")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of( new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","产品A",2,20,List.of("BOX-A1","BOX-A2")), new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-B","产品B",3,15,List.of("BOX-B1","BOX-B2","BOX-B3")))); - var order=new FirstLegLoadingPlanCompletedV1.Order("in-multi","root-multi","ord-multi","FL-MULTI","root-ord-multi","RK-MULTI","wh-1","ONT8","ONT8",5,35,1,BigDecimal.valueOf(2),BigDecimal.valueOf(2),BigDecimal.valueOf(2),List.of(itemA,itemB),"100","ONE_CLICK_DROPSHIP",List.of(pallet)); + var order=new FirstLegLoadingPlanCompletedV1.Order("in-multi","root-multi","ord-multi","FL-MULTI","root-ord-multi","RK-MULTI","wh-1","ONT8","ONT8",5,35,1,BigDecimal.valueOf(2),BigDecimal.valueOf(2),BigDecimal.valueOf(2),List.of(itemA,itemB),"100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); EventEnvelope event=eventForPayload(new FirstLegLoadingPlanCompletedV1("plan-multi","LP-MULTI","book-multi","CONT-MULTI","CAB-MULTI","92","Warehouse",List.of(order)),2); assertEquals(1,service.consume(event)); @@ -31,6 +31,8 @@ class LoadingCompletedConsumerTest { @Test void consumesV2LoadingEventAndKeepsDropshipSnapshot(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); + ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0)).put("destinationType","OVERSEAS"); + ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(1)).put("destinationType","OVERSEAS"); ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0).path("items").get(0)).put("customerRemark","fragile"); ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0).path("items").get(0)) .set("cartonMarks", json.valueToTree(List.of("BOX-1","BOX-2","BOX-3","BOX-4","BOX-5"))); @@ -51,6 +53,8 @@ class LoadingCompletedConsumerTest { } @Test void rejectsV2EventWithoutSkuCartonMapping(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); + ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0)).put("destinationType","OVERSEAS"); + ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(1)).put("destinationType","OVERSEAS"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(), source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(), @@ -59,6 +63,46 @@ class LoadingCompletedConsumerTest { assertTrue(error.getMessage().contains("箱唛数量必须等于实装箱数")); verify(orders,never()).insert(any(LastlegOrder.class)); } + @Test void legacyV2FbaWithCompleteFrozenCartonsPassesTheMappingGate(){ + EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); + ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0).path("items").get(0)) + .set("cartonMarks",json.valueToTree(List.of("ABE3-BOX-1","ABE3-BOX-2","ABE3-BOX-3","ABE3-BOX-4","ABE3-BOX-5"))); + ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(1).path("items").get(0)) + .set("cartonMarks",json.valueToTree(List.of("LAX9-BOX-1","LAX9-BOX-2","LAX9-BOX-3"))); + String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); + EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(), + source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(), + 2L,source.traceId(),source.correlationId(),source.causationId(),node,hash); + assertEquals(2,service.consume(v2)); + verify(cartons,never()).insert(any()); + } + @Test void acceptsV2FbaByPackageCountWithoutCartonFacts(){ + EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); + ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0)).put("destinationType","FBA"); + ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(1)).put("destinationType","FBA"); + String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); + EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(), + source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(), + 2L,source.traceId(),source.correlationId(),source.causationId(),node,hash); + assertEquals(2,service.consume(v2)); + verify(cartons,never()).insert(any()); + } + @Test void safelyInfersLegacyOverseasDestinationFromReceivingWarehouseIdentity(){ + EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); + for(JsonNode order:node.path("orders")){ + ((com.fasterxml.jackson.databind.node.ObjectNode)order).put("destinationWarehouseId","ow-1"); + JsonNode item=order.path("items").get(0); + ((com.fasterxml.jackson.databind.node.ObjectNode)item).putNull("fbaTrackingNo").putNull("shipmentNo").put("destinationWarehouseName","洛杉矶海外仓"); + } + ((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0).path("items").get(0)).set("cartonMarks",json.valueToTree(List.of("BOX-1","BOX-2","BOX-3","BOX-4","BOX-5"))); + ((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(1).path("items").get(0)).set("cartonMarks",json.valueToTree(List.of("BOX-6","BOX-7","BOX-8"))); + String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); + EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),2L,source.traceId(),source.correlationId(),source.causationId(),node,hash); + assertEquals(2,service.consume(v2)); + ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(captured.capture()); + assertTrue(captured.getAllValues().stream().allMatch(order->"OVERSEAS".equals(order.getDestinationType()))); + verify(cartons,times(8)).insert(any()); + } private EventEnvelope event(){ var item1=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-1","产品","Product",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("12"),new BigDecimal("1.2"),"wh-1","ONT8","FBA ONT8","FBA-1","SHIP-1","MARK-1"); var item2=new FirstLegLoadingPlanCompletedV1.Item("d-2","SKU-2","产品2","Product2",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("9"),new BigDecimal("0.8"),"wh-2","LAX9","FBA LAX9","FBA-2","SHIP-2","MARK-2"); var p=new FirstLegLoadingPlanCompletedV1("plan-1","LP1","book-1","MSCU1","CAB1","ow-1","洛杉矶海外仓",List.of(new FirstLegLoadingPlanCompletedV1.Order("in-1","root-1","ord-1","FL1","root-o1","RK1","wh-1","ONT8","FBA ONT8",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("1.2"),List.of(item1)),new FirstLegLoadingPlanCompletedV1.Order("in-2","root-2","ord-2","FL2","root-o2","RK2","wh-2","LAX9","FBA LAX9",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("0.8"),List.of(item2)))); JsonNode node=json.valueToTree(p); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-1",2,1L,"123e4567-e89b-12d3-a456-426614174001","FIRSTLEG_LOADING_COMPLETED:plan-1:100:1",null,node,hash); } private EventEnvelope eventForPayload(FirstLegLoadingPlanCompletedV1 payload, long version){ JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174099",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN",payload.loadingPlanId(),2,version,"123e4567-e89b-12d3-a456-426614174098","FIRSTLEG_LOADING_COMPLETED:"+payload.loadingPlanId()+":100:1",null,node,hash); } } diff --git a/scripts/overseas-deployment-contract.test.mjs b/scripts/overseas-deployment-contract.test.mjs index 575df9e41..5d636bf86 100644 --- a/scripts/overseas-deployment-contract.test.mjs +++ b/scripts/overseas-deployment-contract.test.mjs @@ -20,6 +20,9 @@ test('Jenkins pipelines recognize all overseas deployable modules', async () => assert.match(service, /warehouse-overseas-service\/target\/warehouse-overseas-service-1\.0\.0-SNAPSHOT\.jar/) assert.match(controller, /EVENT_BRIDGE_REGION/) assert.match(service, /EVENT_BRIDGE_REGION_OVERRIDE=\$\{params\.EVENT_BRIDGE_REGION\}/) + assert.match(controller, /choices: \['SELECT', 'CN', 'OVERSEAS'\]/) + assert.match(service, /choices: \['SELECT', 'CN', 'OVERSEAS'\]/) + assert.match(controller, /部署 event-bridge-service 必须明确选择 EVENT_BRIDGE_REGION=CN 或 OVERSEAS/) }) test('service map freezes unique internal ports and moves event bridge away from 9300', async () => { @@ -72,6 +75,7 @@ test('CN and overseas event bridges have explicit opposite-region consumers', as assert.match(scripts, /FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL/) assert.match(scripts, /COMPOSE_FILE="deploy\/docker-compose\.overseas\.yml"/) assert.match(scripts, /PROJECT_NAME="firstleg-\$DEPLOY_ENV-overseas"/) + assert.match(scripts, /运行容器镜像与请求镜像不一致/) assert.match(scripts, /REMOTE_ENV_NAME="\.env\.\$PROJECT_NAME"/) assert.match(scripts, /REMOTE_COMPOSE_NAME="docker-compose\.\$PROJECT_NAME\.yml"/) for (const key of [ 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 5cceb91f5..a5e66727a 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 @@ -3,6 +3,7 @@ package com.baiyue.warehouse.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.warehouse.dto.DropshipPalletRequests; 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 jakarta.annotation.Resource; @@ -22,4 +23,12 @@ public class InternalWarehousePalletController { tokenGuard.verify(token); return ApiResponse.success(service.ensureDropshipPallet(request.customerId(), request.warehouseId(), request.businessId())); } + + @GetMapping("/resolve/{palletCode}") + public ApiResponse resolve( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable String palletCode) { + tokenGuard.verify(token); + return ApiResponse.success(service.resolvePublicPallet(palletCode)); + } } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletResolveResponse.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletResolveResponse.java new file mode 100644 index 000000000..be3e2610c --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletResolveResponse.java @@ -0,0 +1,12 @@ +package com.baiyue.warehouse.dto; + +public record WarehousePalletResolveResponse( + long palletId, + String palletCode, + long warehouseId, + long locationId, + String status, + String fbaWarehouseCode, + int version +) { +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java index bfe2400f8..7603cd9e6 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java @@ -17,6 +17,8 @@ import org.springframework.stereotype.Repository; import org.springframework.util.StringUtils; import jakarta.annotation.Resource; +import java.util.List; + @Repository public class WarehouseResourceRepository { @Resource private WarehouseMapper warehouses; @@ -135,6 +137,13 @@ public class WarehouseResourceRepository { .eq(WarehousePalletEntity::getId, id)); } + public List findPublicPalletsByCode(String palletCode) { + return pallets.selectList(new LambdaQueryWrapper() + .eq(WarehousePalletEntity::getCustomerId, 0L) + .eq(WarehousePalletEntity::getPalletCode, palletCode) + .orderByAsc(WarehousePalletEntity::getId)); + } + public boolean palletCodeExists(long customerId, long warehouseId, String code, Long excludedId) { return pallets.selectCount(new LambdaQueryWrapper() .eq(WarehousePalletEntity::getCustomerId, customerId) 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 a4355d2d2..ec8fef579 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 @@ -5,6 +5,7 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.warehouse.dto.LocationRequests; import com.baiyue.warehouse.dto.PalletRequests; +import com.baiyue.warehouse.dto.WarehousePalletResolveResponse; import com.baiyue.warehouse.entity.WarehouseLocationEntity; import com.baiyue.warehouse.entity.WarehousePalletEntity; import com.baiyue.warehouse.entity.WarehouseEntity; @@ -175,6 +176,21 @@ public class WarehouseResourceService { return requirePallet(customerId, warehouseId, id); } + public WarehousePalletResolveResponse resolvePublicPallet(String palletCode) { + if (!StringUtils.hasText(palletCode)) { + throw new BizException(ResultCode.BAD_REQUEST, "托盘码不能为空"); + } + var matches = repository.findPublicPalletsByCode(palletCode.trim()); + if (matches.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "托盘码不存在"); + if (matches.size() != 1) throw new BizException(ResultCode.CONFLICT, "托盘码在多个仓库重复,无法解析"); + WarehousePalletEntity pallet = matches.get(0); + if (pallet.getLocationId() == null) throw new BizException(ResultCode.CONFLICT, "托盘未绑定库位"); + if ("SCRAPPED".equals(pallet.getStatus())) throw new BizException(ResultCode.CONFLICT, "托盘已报废"); + return new WarehousePalletResolveResponse(pallet.getId(), pallet.getPalletCode(), pallet.getWarehouseId(), + pallet.getLocationId(), pallet.getStatus(), pallet.getFbaWarehouseCode(), + pallet.getVersion() == null ? 0 : pallet.getVersion()); + } + public PageResult pallets(long customerId, long warehouseId, PalletRequests.QueryRequest query) { requireWarehouse(customerId, warehouseId);