From bb74a055625584c40b10001cacc2a6811e0856b9 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 17:37:15 +0800 Subject: [PATCH] feat(lastleg): harden container receiving contract --- deploy/sql/海外区域统一增量-MySQL8.sql | 20 +++- .../ReceivingContainerController.java | 2 + .../service/InboundPdaWorkflowService.java | 4 +- .../service/InboundPlanService.java | 3 +- .../src/main/resources/db/lastleg.sql | 29 ++++- .../openapi/overseas-business-v1.yaml | 101 ++++++++++++++++++ .../pda/ReceivingContainerContractTest.java | 82 ++++++++++++++ .../ReceivingContainerControllerTest.java | 54 ++++++++++ 8 files changed, 288 insertions(+), 7 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 7adb6153f..bbce2f48d 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -338,11 +338,29 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab 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 ''客户账号标识''', + 'SELECT 1', '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.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND index_name='idx_ll_inbound_line_customer_plan'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD KEY idx_ll_inbound_line_customer_plan(customer_id,plan_id)')); +PREPARE phase4_receiving_scope_stmt FROM @ddl; EXECUTE phase4_receiving_scope_stmt; DEALLOCATE PREPARE phase4_receiving_scope_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='customer_id'), + 'SELECT 1', + 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id')); +PREPARE phase4_receiving_scope_stmt FROM @ddl; EXECUTE phase4_receiving_scope_stmt; DEALLOCATE PREPARE phase4_receiving_scope_stmt; +UPDATE ll_inbound_plan_pallet v JOIN ll_inbound_plan p ON p.id=v.plan_id SET v.customer_id=p.customer_id WHERE v.customer_id=''; +ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND index_name='idx_ll_inbound_pallet_customer_plan'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD KEY idx_ll_inbound_pallet_customer_plan(customer_id,plan_id)')); +PREPARE phase4_receiving_scope_stmt FROM @ddl; EXECUTE phase4_receiving_scope_stmt; DEALLOCATE PREPARE phase4_receiving_scope_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='customer_id'), + 'SELECT 1', + 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id')); +PREPARE phase4_receiving_scope_stmt FROM @ddl; EXECUTE phase4_receiving_scope_stmt; DEALLOCATE PREPARE phase4_receiving_scope_stmt; +UPDATE ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan p ON p.id=a.plan_id SET a.customer_id=p.customer_id WHERE a.customer_id=''; +ALTER TABLE ll_inbound_plan_pallet_allocation MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND index_name='idx_ll_inbound_allocation_customer_plan'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD KEY idx_ll_inbound_allocation_customer_plan(customer_id,plan_id)')); +PREPARE phase4_receiving_scope_stmt FROM @ddl; EXECUTE phase4_receiving_scope_stmt; DEALLOCATE PREPARE phase4_receiving_scope_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='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')); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java index cfa3b3f66..47ba2aae9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java @@ -12,6 +12,7 @@ import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -27,6 +28,7 @@ import java.util.List; @RestController @RequestMapping("/api/pda/v1/last-leg/receiving-containers") @Tag(name = "Overseas PDA container receiving") +@PreAuthorize("hasAuthority('lastleg:pda:operate')") public class ReceivingContainerController { public static final String OPERATE_PERMISSION = "lastleg:pda:operate"; @Resource private ReceivingContainerService service; 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 fde44b04a..85427550c 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 @@ -300,7 +300,8 @@ public class InboundPdaWorkflowService { } InboundPlanPalletAllocation allocation = repository.lockAllocation(plan.getId(), line.getId(), pallet.getId()); if (allocation == null) { - allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId()); allocation.setPlanId(plan.getId()); + 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("PICKING"); allocation.setVersion(0); repository.insert(allocation); @@ -542,6 +543,7 @@ public class InboundPdaWorkflowService { if (moved == null) { moved = new InboundPlanPalletAllocation(); moved.setId(IdWorker.getId()); + moved.setCustomerId(source.getCustomerId()); moved.setPlanId(planId); moved.setLineId(source.getLineId()); moved.setPalletId(targetPalletId); 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 6ae72a34e..5dd754553 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 @@ -141,7 +141,8 @@ public class InboundPlanService { .eq(InboundPlanPallet::getPlanId, plan.getId())).stream().map(InboundPlanPallet::getSequenceNo) .filter(Objects::nonNull).max(Integer::compareTo).orElse(0) + 1; InboundPlanPallet pallet = new InboundPlanPallet(); - pallet.setId(IdWorker.getId()); pallet.setPlanId(plan.getId()); pallet.setPalletNo(plan.getPlanNo() + "-P" + String.format("%03d", sequence)); + pallet.setId(IdWorker.getId()); pallet.setCustomerId(plan.getCustomerId()); pallet.setPlanId(plan.getId()); + pallet.setPalletNo(plan.getPlanNo() + "-P" + String.format("%03d", sequence)); pallet.setDestinationWarehouseId(request.destinationWarehouseId()); pallet.setDestinationWarehouseCode(request.destinationWarehouseCode()); pallet.setDestinationWarehouseName(request.destinationWarehouseName()); pallet.setCapacityCbm(PalletAllocator.PALLET_CAPACITY_CBM); pallet.setAllocatedVolumeCbm(BigDecimal.ZERO); pallet.setSequenceNo(sequence); pallet.setAssignmentStatus("UNASSIGNED"); diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 3b01747ad..0f3c1503e 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -137,7 +137,7 @@ CREATE TABLE IF NOT EXISTS ll_event_inbox ( ) ENGINE=InnoDB COMMENT='通用事件接收幂等箱'; CREATE TABLE IF NOT EXISTS ll_inbound_order ( - id BIGINT NOT NULL COMMENT '入仓单主键', customer_id BIGINT NOT NULL COMMENT '租户 ID', source_region VARCHAR(32) NOT NULL COMMENT '来源区域', + id BIGINT NOT NULL COMMENT '入仓单主键', customer_id BIGINT NOT NULL COMMENT '客户账号标识', source_region VARCHAR(32) NOT NULL COMMENT '来源区域', source_inbound_id VARCHAR(128) NOT NULL COMMENT '来源入仓分片 ID', root_inbound_id VARCHAR(128) NULL COMMENT '根入仓单 ID', 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', @@ -378,7 +378,8 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_line ( planned_pallet_count INT NOT NULL DEFAULT 0 COMMENT '该明细分配的托盘数量', PRIMARY KEY (id), UNIQUE KEY uk_ll_inbound_line_order (plan_id, order_id), - KEY idx_ll_inbound_line_plan (plan_id) + KEY idx_ll_inbound_line_plan (plan_id), + KEY idx_ll_inbound_line_customer_plan (customer_id, plan_id) ) ENGINE=InnoDB COMMENT='尾程入库计划明细表:按入仓码和尾程订单维度记录'; CREATE TABLE IF NOT EXISTS ll_inbound_plan_carton ( @@ -399,6 +400,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_carton ( CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet ( id BIGINT NOT NULL COMMENT '计划托盘ID(雪花ID)', + customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', plan_id BIGINT NOT NULL COMMENT '入库计划ID', pallet_no VARCHAR(64) NOT NULL COMMENT '计划托盘编号', destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', @@ -410,11 +412,13 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet ( PRIMARY KEY (id), UNIQUE KEY uk_ll_inbound_pallet_no (pallet_no), UNIQUE KEY uk_ll_inbound_pallet_sequence (plan_id, sequence_no), - KEY idx_ll_inbound_pallet_plan (plan_id) + KEY idx_ll_inbound_pallet_plan (plan_id), + KEY idx_ll_inbound_pallet_customer_plan (customer_id, plan_id) ) ENGINE=InnoDB COMMENT='尾程入库计划托盘表:按目的仓隔离合托'; CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet_allocation ( id BIGINT NOT NULL COMMENT '托盘分配记录ID(雪花ID)', + customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', plan_id BIGINT NOT NULL COMMENT '入库计划ID', line_id BIGINT NOT NULL COMMENT '入库计划明细ID', pallet_id BIGINT NOT NULL COMMENT '计划托盘ID', @@ -422,6 +426,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet_allocation ( PRIMARY KEY (id), UNIQUE KEY uk_ll_inbound_allocation (line_id, pallet_id), KEY idx_ll_inbound_allocation_plan (plan_id), + KEY idx_ll_inbound_allocation_customer_plan (customer_id, plan_id), KEY idx_ll_inbound_allocation_pallet (pallet_id) ) ENGINE=InnoDB COMMENT='尾程入库计划托盘分配表:记录入仓码明细与计划托盘的分配关系'; @@ -445,9 +450,11 @@ ALTER TABLE ll_inbound_plan ALTER TABLE ll_inbound_plan_line COMMENT='尾程入库计划明细表:按入仓码和尾程订单维度记录'; ALTER TABLE ll_inbound_plan_line MODIFY COLUMN id BIGINT NOT NULL COMMENT '入库计划明细ID(雪花ID)', + MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', MODIFY COLUMN plan_id BIGINT NOT NULL COMMENT '入库计划ID', MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '尾程订单ID', MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL COMMENT '头程入仓码快照', + MODIFY COLUMN destination_type VARCHAR(32) NOT NULL DEFAULT 'OVERSEAS' COMMENT '目的类型:FBA按箱数接收、OVERSEAS逐箱扫码', MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', @@ -631,10 +638,24 @@ 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')); +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'), 'SELECT 1', '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.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND index_name='idx_ll_inbound_line_customer_plan'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD KEY idx_ll_inbound_line_customer_plan(customer_id,plan_id)')); +PREPARE inbound_customer_scope_stmt FROM @ddl; EXECUTE inbound_customer_scope_stmt; DEALLOCATE PREPARE inbound_customer_scope_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='customer_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id')); +PREPARE inbound_customer_scope_stmt FROM @ddl; EXECUTE inbound_customer_scope_stmt; DEALLOCATE PREPARE inbound_customer_scope_stmt; +UPDATE ll_inbound_plan_pallet v JOIN ll_inbound_plan p ON p.id=v.plan_id SET v.customer_id=p.customer_id WHERE v.customer_id=''; +ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND index_name='idx_ll_inbound_pallet_customer_plan'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD KEY idx_ll_inbound_pallet_customer_plan(customer_id,plan_id)')); +PREPARE inbound_customer_scope_stmt FROM @ddl; EXECUTE inbound_customer_scope_stmt; DEALLOCATE PREPARE inbound_customer_scope_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='customer_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id')); +PREPARE inbound_customer_scope_stmt FROM @ddl; EXECUTE inbound_customer_scope_stmt; DEALLOCATE PREPARE inbound_customer_scope_stmt; +UPDATE ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan p ON p.id=a.plan_id SET a.customer_id=p.customer_id WHERE a.customer_id=''; +ALTER TABLE ll_inbound_plan_pallet_allocation MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND index_name='idx_ll_inbound_allocation_customer_plan'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD KEY idx_ll_inbound_allocation_customer_plan(customer_id,plan_id)')); +PREPARE inbound_customer_scope_stmt FROM @ddl; EXECUTE inbound_customer_scope_stmt; DEALLOCATE PREPARE inbound_customer_scope_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 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 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 856aff935..a485c55f7 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -3,6 +3,82 @@ info: title: Lastleg Overseas Business API version: 1.0.0 paths: + /api/pda/v1/last-leg/receiving-containers/candidates: + get: + summary: 查询当前客户待签收柜号 + description: 权限 lastleg:pda:operate;客户与操作账号均从 PDA Token 获取,不接收 customerId。 + security: [{ bearerAuth: [] }] + responses: { '200': { description: 柜号候选及箱数进度 }, '403': { description: 缺少 PDA 操作权限 } } + /api/pda/v1/last-leg/receiving-containers/{containerNo}: + get: + summary: 查询待签收柜号详情 + description: 权限 lastleg:pda:operate;返回柜内全部入仓码、目的类型、目的仓、托数和实收进度。 + security: [{ bearerAuth: [] }] + parameters: [{ name: containerNo, in: path, required: true, schema: { type: string } }] + responses: { '200': { description: 柜号详情 }, '409': { description: 当前账号已锁定其他柜号 } } + /api/pda/v1/last-leg/receiving-containers/{containerNo}/prepare: + post: + summary: 选择当前待签收柜号 + description: 权限 lastleg:pda:operate;建立 WAIT_SIGN 会话并以账号、柜号双唯一键防止切柜或多人同时处理。 + security: [{ bearerAuth: [] }] + parameters: [{ name: containerNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingAction' } } } } + responses: { '200': { description: 当前柜会话 }, '409': { description: 账号或柜号已有未完成会话 } } + /api/pda/v1/last-leg/receiving-containers/current/unload-photos/files: + post: + summary: 上传当前柜签收照片 + description: 权限 lastleg:pda:operate;multipart 文件归属当前 WAIT_SIGN 会话。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { multipart/form-data: { schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } } } } } + responses: { '200': { description: 文件标识 }, '409': { description: 当前柜已签收 } } + /api/pda/v1/last-leg/receiving-containers/current/sign: + post: + summary: 签收当前柜 + description: 权限 lastleg:pda:operate;校验非空照片归属后由 WAIT_SIGN 推进 SIGNED。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSign' } } } } + responses: { '200': { description: 已签收会话 }, '409': { description: 状态或照片校验失败 } } + /api/pda/v1/last-leg/receiving-containers/current: + get: + summary: 恢复当前账号已签柜 + description: 权限 lastleg:pda:operate;返回 SIGNED 或 RECEIVING 当前柜,不依赖 planNo。 + security: [{ bearerAuth: [] }] + responses: { '200': { description: 当前柜完整进度 }, '404': { description: 无当前柜 } } + /api/pda/v1/last-leg/receiving-containers/current/cartons/resolve: + post: + summary: 解析当前柜箱唛 + description: 权限 lastleg:pda:operate;仅 OVERSEAS 目的类型可逐箱收货,返回该箱所属入仓码完整信息。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { type: object, required: [cartonMark], properties: { cartonMark: { type: string } } } } } } + responses: { '200': { description: 箱唛与入仓码信息 }, '409': { description: 跨柜、重复箱或目的类型不符 } } + /api/pda/v1/last-leg/receiving-containers/current/pallets/resolve: + post: + summary: 解析当前柜实物托盘码 + description: 权限 lastleg:pda:operate;托盘固定从 Warehouse Overseas 公共资源作用域解析并校验实际仓与目的仓。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { type: object, required: [palletCode, inboundCode], properties: { palletCode: { type: string }, inboundCode: { type: string } } } } } } + responses: { '200': { description: 可用托盘与目的匹配结果 }, '409': { description: 仓库、目的仓或托盘状态不符 } } + /api/pda/v1/last-leg/receiving-containers/current/receipts/bulk: + post: + summary: FBA 入仓码按箱数收货 + description: 权限 lastleg:pda:operate;只允许 destinationType=FBA,OVERSEAS 在服务端硬拒绝;不接收 customerId、planNo、allocationId、warehouseId、locationId。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingBulk' } } } } + responses: { '200': { description: 本次与累计实收进度 }, '409': { description: 海外仓批量、超收或托盘不匹配 } } + /api/pda/v1/last-leg/receiving-containers/current/receipts/carton: + post: + summary: 海外仓入仓码逐箱收货 + description: 权限 lastleg:pda:operate;只允许 destinationType=OVERSEAS,拒绝跨柜、重复箱和不匹配托盘。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCarton' } } } } + responses: { '200': { description: 本次与累计实收进度 }, '409': { description: FBA逐箱、跨柜、重复箱或托盘不匹配 } } + /api/pda/v1/last-leg/receiving-containers/current/complete: + post: + summary: 完成当前柜收货并入库存 + description: 权限 lastleg:pda:operate;仅柜内每个入仓码实收总数精确相等后完成,库存、计划、尾程状态与卸柜 Outbox 同事务落地。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingAction' } } } } + responses: { '200': { description: COMPLETED 柜号会话 }, '409': { description: 数量未核平或状态冲突 } } /api/platform/v1/last-leg/orders: get: summary: 分页查询尾程订单 @@ -340,6 +416,31 @@ paths: responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '409': { description: 非法状态迁移或关联尾程单缺失 } } components: schemas: + ReceivingAction: + type: object + required: [idempotencyKey] + properties: { idempotencyKey: { type: string, minLength: 1, maxLength: 255 } } + ReceivingSign: + type: object + required: [photoFileIds, idempotencyKey] + properties: + photoFileIds: { type: array, minItems: 1, uniqueItems: true, items: { type: integer, format: int64 } } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + ReceivingBulk: + type: object + required: [inboundCode, palletCode, packageCount, idempotencyKey] + properties: + inboundCode: { type: string, minLength: 1 } + palletCode: { type: string, minLength: 1 } + packageCount: { type: integer, minimum: 1 } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + ReceivingCarton: + type: object + required: [cartonMark, palletCode, idempotencyKey] + properties: + cartonMark: { type: string, minLength: 1 } + palletCode: { type: string, minLength: 1 } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } SmartDispatchCandidate: type: object properties: diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java new file mode 100644 index 000000000..6d81dc32b --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java @@ -0,0 +1,82 @@ +package com.baiyue.lastleg.inboundplan.pda; + +import com.baiyue.lastleg.inboundplan.pda.controller.ReceivingContainerController; +import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; + +import java.io.IOException; +import java.lang.reflect.RecordComponent; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ReceivingContainerContractTest { + private static final Set FORBIDDEN_CALLER_SCOPE = Set.of( + "customerId", "planNo", "allocationId", "warehouseId", "locationId"); + + @Test + void pdaRequestsNeverAcceptServerOwnedScopeFields() { + for (Class type : ReceivingContainerRequests.class.getDeclaredClasses()) { + if (!type.isRecord()) continue; + Set fields = Arrays.stream(type.getRecordComponents()) + .map(RecordComponent::getName).collect(Collectors.toSet()); + assertTrue(java.util.Collections.disjoint(fields, FORBIDDEN_CALLER_SCOPE), type.getSimpleName()); + } + } + + @Test + void everyBrowserReachableReceivingMethodUsesOnlyPdaOperatePermission() { + PreAuthorize permission = ReceivingContainerController.class.getAnnotation(PreAuthorize.class); + assertTrue(permission != null); + assertTrue("hasAuthority('lastleg:pda:operate')".equals(permission.value())); + } + + @Test + void schemaContainsCustomerScopedPlanChildrenAndContainerSessionLocks() throws IOException { + String schema = resource("db/lastleg.sql").toLowerCase(); + String unified = java.nio.file.Files.readString( + java.nio.file.Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"), StandardCharsets.UTF_8).toLowerCase(); + for (String table : Set.of("ll_inbound_plan_line", "ll_inbound_plan_pallet", + "ll_inbound_plan_pallet_allocation")) { + assertTrue(tableBlock(schema, table).contains("customer_id varchar(128) not null"), table); + assertTrue(unified.contains("table_name='" + table + "' and column_name='customer_id'"), table); + assertTrue(unified.contains("update " + table), table); + } + assertFalse(schema.contains("tenant_id varchar(64)")); + assertFalse(unified.contains("column_name='tenant_id'")); + assertTrue(schema.contains("create table if not exists ll_receiving_container_session")); + assertTrue(schema.contains("unique key uk_ll_receiving_active_operator(active_operator_key)")); + assertTrue(schema.contains("unique key uk_ll_receiving_active_container(active_container_key)")); + assertTrue(schema.contains("create table if not exists ll_receiving_container_operation")); + assertTrue(schema.contains("wait_sign、signed、receiving、completed")); + } + + @Test + void openApiPublishesOnlyStableContainerPaths() throws IOException { + String yaml = resource("openapi/overseas-business-v1.yaml"); + String prefix = "/api/pda/v1/last-leg/receiving-containers"; + assertTrue(yaml.contains(prefix + "/candidates:")); + assertTrue(yaml.contains(prefix + "/current/receipts/bulk:")); + assertTrue(yaml.contains(prefix + "/current/receipts/carton:")); + assertTrue(yaml.contains(prefix + "/current/complete:")); + assertFalse(yaml.contains("/api/pda/v1/last-leg/receiving-containers/{planNo}")); + } + + private String resource(String name) throws IOException { + try (var input = getClass().getClassLoader().getResourceAsStream(name)) { + assertTrue(input != null, name); + return new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + } + + private static String tableBlock(String schema, String table) { + int start = schema.indexOf("create table if not exists " + table + " ("); + int end = schema.indexOf(") engine=innodb", start); + return start < 0 || end < 0 ? "" : schema.substring(start, end); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java new file mode 100644 index 000000000..d049e9171 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java @@ -0,0 +1,54 @@ +package com.baiyue.lastleg.inboundplan.pda.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses; +import com.baiyue.lastleg.inboundplan.pda.service.ReceivingContainerService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ReceivingContainerControllerTest { + @Mock ReceivingContainerService service; + private ReceivingContainerController controller; + + @BeforeEach + void setUp() { + controller = new ReceivingContainerController(); + ReflectionTestUtils.setField(controller, "service", service); + } + + @Test + void candidatesDerivesCustomerAndOperatorOnlyFromPdaIdentity() { + when(service.candidates("100", "42")).thenReturn(List.of(new ReceivingContainerResponses.Candidate( + "CONT-1", "WAIT_SIGN", 2, 2, 20, 0, false))); + + controller.candidates(pda()); + + verify(service).candidates("100", "42"); + } + + @Test + void platformIdentityCannotEnterPdaReceivingApi() { + AuthenticatedUser platform = new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB", + Set.of("lastleg:pda:operate")); + assertThatThrownBy(() -> controller.candidates(platform)).isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("401"); + } + + private static AuthenticatedUser pda() { + return new AuthenticatedUser(42L, 100L, 100L, 8L, "receiver", "PDA", "PDA", + Set.of("lastleg:pda:operate")); + } +}