feat(lastleg): harden container receiving contract

This commit is contained in:
2026-08-24 17:37:15 +08:00
parent be1eb4f45c
commit bb74a05562
8 changed files with 288 additions and 7 deletions
@@ -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;
@@ -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);
@@ -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");
@@ -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
@@ -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: