修改提交

This commit is contained in:
2026-09-08 17:58:42 +08:00
parent f0939c91c3
commit 2a27e28f26
67 changed files with 2590 additions and 110 deletions
+2 -2
View File
@@ -57,8 +57,8 @@
| 海外排车执行、PDA 出库、派送与一件代发 | `lastleg-service/` | `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`OutboundPdaController.java`、`MerchantDeliveryController.java`,一件代发库存归 `dropship/`;统一经 `gateway-overseas/`,设备端正式入口继续查 `../apps/pda-overseas-uniapp/AGENTS.md` | | 海外排车执行、PDA 出库、派送与一件代发 | `lastleg-service/` | `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`OutboundPdaController.java`、`MerchantDeliveryController.java`,一件代发库存归 `dropship/`;统一经 `gateway-overseas/`,设备端正式入口继续查 `../apps/pda-overseas-uniapp/AGENTS.md` |
| 海外异常退回与客户回访 | `lastleg-service/` | `exceptionorder/`、`customervisit/`;平台异常建档和关系解析、派送退回入仓、派送完成/状态、回访资料与附件均以 Lastleg 自身客户范围和状态为准 | | 海外异常退回与客户回访 | `lastleg-service/` | `exceptionorder/`、`customervisit/`;平台异常建档和关系解析、派送退回入仓、派送完成/状态、回访资料与附件均以 Lastleg 自身客户范围和状态为准 |
| 一件代发 SKU/箱唛与暂扣跨区协同 | `order/`、`firstleg/`、`common-event-contract/`、`lastleg-service/` | Order `InternalOrderCargoController` 提供强制 SKU;Firstleg `InboundCartonLabelService` 冻结 SKU、`LoadingWorkPalletService` 维护装柜工作托盘并消费 `OverseasInboundOrderDetainedChangedV1`;Lastleg `dropship/` 建立库存并发布暂扣变更 | | 一件代发 SKU/箱唛与暂扣跨区协同 | `order/`、`firstleg/`、`common-event-contract/`、`lastleg-service/` | Order `InternalOrderCargoController` 提供强制 SKU;Firstleg `InboundCartonLabelService` 冻结 SKU、`LoadingWorkPalletService` 维护装柜工作托盘并消费 `OverseasInboundOrderDetainedChangedV1`;Lastleg `dropship/` 建立库存并发布暂扣变更 |
| 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`transfer/`、`discard/` 的 Merchant Controller/Service;Client 消费端 `../apps/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` | | 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`relabel/pda/`、`transfer/`、`discard/` 的 Merchant/PDA Controller 和 Service;换标下单按“托盘中的入仓单库存”选择,合托后的同一托盘允许包含多张入仓单但冻结明细必须保持原入仓单归属。每个我方箱唛只形成一条箱任务,支持箱唛、SKU 和组合换标,SKU 子项不重复冻结;PDA 扫箱唛、照片复核完成后释放原库存,再沿既有排车出库。Client 消费端 `../apps/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` |
| 海外仓 PDA 盘点、移库、托盘、换标与查询 | `lastleg-service/`、`warehouse-overseas-service/` | Lastleg `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`palletops/`、`relabel/pda/`;契约 `lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml`;物理托盘流转与补偿进入 Warehouse `InternalPdaPalletTransitionController`,设备端入口是否开放以 `../apps/pda-overseas-uniapp/AGENTS.md` 为准 | | 海外仓 PDA 盘点、移库、托盘、换标与查询 | `lastleg-service/`、`warehouse-overseas-service/` | Lastleg `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`palletops/`、`relabel/pda/`;托盘作业保留托盘-入仓单组成明细,合托通过入仓计划托盘分配和合托记录留痕,出库可扫描托盘但后台按托盘下各入仓单箱数核平;契约 `lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml`;物理托盘流转与补偿进入 Warehouse `InternalPdaPalletTransitionController`,设备端入口是否开放以 `../apps/pda-overseas-uniapp/AGENTS.md` 为准 |
| 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` | Firstleg `portschedule/service/CarrierScheduleNodeOutboxService.java` 发布 `FirstLegContainerShippingNodeRecordedV1`;Lastleg `shipping/service/ContainerShippingNodeConsumer.java` 单调消费,只有实际 `PICKUP_READY` 及以后节点允许海外 PDA 候选进入可收货 | | 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` | Firstleg `portschedule/service/CarrierScheduleNodeOutboxService.java` 发布 `FirstLegContainerShippingNodeRecordedV1`;Lastleg `shipping/service/ContainerShippingNodeConsumer.java` 单调消费,只有实际 `PICKUP_READY` 及以后节点允许海外 PDA 候选进入可收货 |
| 海外仓库目录、海外库位、海外托盘、资源锁 | 国内仓库目录归 `warehouse/`,海外资源归 `warehouse-overseas-service/` | `warehouse/AGENTS.md`、`warehouse-overseas-service/AGENTS.md`;海外库只保存国内 warehouseId 引用 | | 海外仓库目录、海外库位、海外托盘、资源锁 | 国内仓库目录归 `warehouse/`,海外资源归 `warehouse-overseas-service/` | `warehouse/AGENTS.md`、`warehouse-overseas-service/AGENTS.md`;海外库只保存国内 warehouseId 引用 |
| Lastleg 内部模块化与依赖基线 | `lastleg-service/` | `lastleg-service/docs/lastleg-internal-modularization-baseline.md`、`lastleg-service/scripts/analyze-lastleg-dependencies.mjs`;只用于约束服务内部依赖,不改变服务和数据库边界 | | Lastleg 内部模块化与依赖基线 | `lastleg-service/` | `lastleg-service/docs/lastleg-internal-modularization-baseline.md`、`lastleg-service/scripts/analyze-lastleg-dependencies.mjs`;只用于约束服务内部依赖,不改变服务和数据库边界 |
@@ -18019,6 +18019,104 @@ DELIMITER ;
CALL assert_wallet_event_schema(); CALL assert_wallet_event_schema();
DROP PROCEDURE IF EXISTS assert_wallet_event_schema; DROP PROCEDURE IF EXISTS assert_wallet_event_schema;
-- 退货库存菜单:企业管理员和已具备海外入库订单查看权的活跃商户角色均可查看。
USE baiyue_iam;
-- 平台端海外仓退货库存与退货箱号菜单。
INSERT INTO iam_permission
(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted)
VALUES
(5107190,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:return-inventory:list','退货库存管理','/overseas-warehouse/return-inventory','GET',890,'ACTIVE',1,0),
(5107191,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:return-cartons:list','退货箱号管理','/overseas-warehouse/return-cartons','GET',891,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),route_path=VALUES(route_path),http_method=VALUES(http_method),
sort_no=VALUES(sort_no),status='ACTIVE',built_in=1,deleted=0;
INSERT IGNORE INTO iam_role_permission (tenant_id,role_id,permission_id,created_at)
SELECT 0,role.id,permission.id,CURRENT_TIMESTAMP(3)
FROM iam_role role JOIN iam_permission permission ON permission.id IN(5107190,5107191) AND permission.tenant_id=0
WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND UPPER(role.role_code) IN('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN')
AND role.status='ACTIVE' AND role.deleted=0;
DROP PROCEDURE IF EXISTS migrate_merchant_return_inventory_permission;
-- 退货箱号管理菜单,复用退货库存查看授权。
DROP PROCEDURE IF EXISTS migrate_merchant_return_cartons_permission;
DELIMITER $$
CREATE PROCEDURE migrate_merchant_return_cartons_permission()
BEGIN
INSERT INTO iam_permission
(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted)
VALUES
(5306141,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:return-cartons:list','退货箱号管理','/overseas-warehouse/return-cartons','GET',96,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), route_path=VALUES(route_path), http_method=VALUES(http_method),
sort_no=VALUES(sort_no), status='ACTIVE', built_in=1, deleted=0;
INSERT IGNORE INTO iam_role_permission (tenant_id,role_id,permission_id,created_at)
SELECT role.tenant_id, role.id, permission.id, CURRENT_TIMESTAMP(3)
FROM iam_role role JOIN iam_permission permission ON permission.id=5306141
WHERE role.app_code='MERCHANT' AND role.status='ACTIVE' AND role.deleted=0
AND (role.role_code='MERCHANT_OWNER' OR EXISTS (
SELECT 1 FROM iam_role_permission prior_grant JOIN iam_permission prior_permission ON prior_permission.id=prior_grant.permission_id
WHERE prior_grant.tenant_id=role.tenant_id AND prior_grant.role_id=role.id
AND prior_permission.permission_code='merchant:overseas-warehouse:return-inventory:list'));
END$$
DELIMITER ;
CALL migrate_merchant_return_cartons_permission();
DROP PROCEDURE IF EXISTS migrate_merchant_return_cartons_permission;
DELIMITER $$
CREATE PROCEDURE migrate_merchant_return_inventory_permission()
BEGIN
IF EXISTS (
SELECT 1 FROM iam_permission
WHERE (id=5306140 AND (tenant_id<>0 OR app_code<>'MERCHANT' OR permission_type<>'MENU'
OR permission_code<>'merchant:overseas-warehouse:return-inventory:list'))
OR (tenant_id=0 AND app_code='MERCHANT'
AND permission_code='merchant:overseas-warehouse:return-inventory:list' AND id<>5306140)
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='退货库存权限初始化失败:权限ID或编码冲突';
END IF;
INSERT INTO iam_permission
(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted)
VALUES
(5306140,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:return-inventory:list','退货库存','/overseas-warehouse/return-inventory','GET',95,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), route_path=VALUES(route_path), http_method=VALUES(http_method),
sort_no=VALUES(sort_no), status='ACTIVE', built_in=1, deleted=0;
INSERT IGNORE INTO iam_role_permission (tenant_id,role_id,permission_id,created_at)
SELECT role.tenant_id, role.id, permission.id, CURRENT_TIMESTAMP(3)
FROM iam_role role
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='MERCHANT'
AND permission.permission_code='merchant:overseas-warehouse:return-inventory:list'
WHERE role.app_code='MERCHANT' AND role.status='ACTIVE' AND role.deleted=0
AND permission.status='ACTIVE' AND permission.deleted=0
AND (role.role_code='MERCHANT_OWNER' OR EXISTS (
SELECT 1 FROM iam_role_permission prior_grant
JOIN iam_permission prior_permission ON prior_permission.id=prior_grant.permission_id
AND prior_permission.tenant_id=0 AND prior_permission.app_code='MERCHANT'
WHERE prior_grant.tenant_id=role.tenant_id AND prior_grant.role_id=role.id
AND prior_permission.permission_code='merchant:warehouse:inbound:list'
AND prior_permission.status='ACTIVE' AND prior_permission.deleted=0
));
IF (SELECT COUNT(*) FROM iam_permission
WHERE id=5306140 AND tenant_id=0 AND app_code='MERCHANT' AND permission_type='MENU' AND parent_id=0
AND permission_code='merchant:overseas-warehouse:return-inventory:list' AND route_path='/overseas-warehouse/return-inventory'
AND http_method='GET' AND status='ACTIVE' AND built_in=1 AND deleted=0) <> 1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='退货库存权限初始化失败:菜单资源不完整';
END IF;
IF EXISTS (SELECT 1 FROM iam_role role
WHERE role.app_code='MERCHANT' AND role.role_code='MERCHANT_OWNER' AND role.status='ACTIVE' AND role.deleted=0
AND NOT EXISTS (SELECT 1 FROM iam_role_permission role_permission
JOIN iam_permission permission ON permission.id=role_permission.permission_id
AND permission.tenant_id=0 AND permission.app_code='MERCHANT'
WHERE role_permission.tenant_id=role.tenant_id AND role_permission.role_id=role.id
AND permission.permission_code='merchant:overseas-warehouse:return-inventory:list'
AND permission.status='ACTIVE' AND permission.deleted=0)) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='退货库存权限初始化失败:企业管理员授权不完整';
END IF;
END$$
DELIMITER ;
CALL migrate_merchant_return_inventory_permission();
DROP PROCEDURE IF EXISTS migrate_merchant_return_inventory_permission;
CALL baiyue_firstleg.assert_unified_final_schema(); CALL baiyue_firstleg.assert_unified_final_schema();
DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema; DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema;
SELECT 'PASS' AS migration_status, SELECT 'PASS' AS migration_status,
@@ -1769,3 +1769,20 @@ CREATE TABLE IF NOT EXISTS ll_destuff_pallet_cost_allocation (
PRIMARY KEY(id), UNIQUE KEY uk_ll_destuff_customer(cost_id,customer_id), UNIQUE KEY uk_ll_destuff_fee_source(fee_source_id), PRIMARY KEY(id), UNIQUE KEY uk_ll_destuff_customer(cost_id,customer_id), UNIQUE KEY uk_ll_destuff_fee_source(fee_source_id),
CONSTRAINT chk_ll_destuff_allocation CHECK(actual_volume_cbm>0 AND allocated_amount>=0) CONSTRAINT chk_ll_destuff_allocation CHECK(actual_volume_cbm>0 AND allocated_amount>=0)
) ENGINE=InnoDB COMMENT='拆柜打托成本按客户实收体积分摊'; ) ENGINE=InnoDB COMMENT='拆柜打托成本按客户实收体积分摊';
USE baiyue_lastleg;
SET @ddl = (SELECT IF(
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='destination_fba_warehouse_code'),
'SELECT 1',
'ALTER TABLE ll_relabel_item ADD COLUMN destination_fba_warehouse_code VARCHAR(128) NULL COMMENT ''FBA目的仓编码(与海外操作仓分离)'''));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='operation_type'),
'SELECT 1',
'ALTER TABLE ll_relabel_item ADD COLUMN operation_type VARCHAR(32) NOT NULL DEFAULT ''CARTON_LABEL'' COMMENT ''换标模式:箱唛、SKU或组合换标'''));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='sku_relabel_items_json'),
'SELECT 1',
'ALTER TABLE ll_relabel_item ADD COLUMN sku_relabel_items_json JSON NULL COMMENT ''同箱SKU换标子明细快照(数量按件)'''));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+3 -1
View File
@@ -32,6 +32,7 @@
| 尾程暂扣状态回传 | `service/InboundOrderDetainedOutboxService.java`;暂扣与解除暂扣在订单更新事务内按 `sourceInboundId` 写入海外到国内 Outbox,国内按来源入仓单绑定更新 | | 尾程暂扣状态回传 | `service/InboundOrderDetainedOutboxService.java`;暂扣与解除暂扣在订单更新事务内按 `sourceInboundId` 写入海外到国内 Outbox,国内按来源入仓单绑定更新 |
| 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 | | 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 |
| 海外PDA盘点、同仓整托移库、托盘组拆换绑与换标现场作业 | `pda/stocktake/`、`pda/move/`、`palletops/`、`relabel/pda/`;版本化契约 `src/main/resources/openapi/overseas-pda-operations-v1.yaml`;全部范围取自PDA Token,排车和出库使用独立 PDA Controller | | 海外PDA盘点、同仓整托移库、托盘组拆换绑与换标现场作业 | `pda/stocktake/`、`pda/move/`、`palletops/`、`relabel/pda/`;版本化契约 `src/main/resources/openapi/overseas-pda-operations-v1.yaml`;全部范围取自PDA Token,排车和出库使用独立 PDA Controller |
| 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/`、`relabel/pda/`;一条 `ll_relabel_item` 对应一个我方箱唛任务,`CARTON_LABEL`、`SKU_LABEL`、`CARTON_AND_SKU` 分别保存箱唛、SKU 或组合需求,SKU 子项 JSON 数量按件;库存只按箱任务冻结一次,PDA 可仅扫我方箱唛定位任务并返回 FBA 箱唛/SKU 子项,照片完成复核通过才释放到原入仓库存,后续排车出库链路不变 |
| PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java`、`pda/warehouse/service/PdaWarehouseService.java`;解析库存、库位和托盘码并查询操作历史,固定要求 `lastleg:pda:operate` 与 `ROLE_WAREHOUSE_PICKER`,仓库范围只能取自 PDA Token | | PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java`、`pda/warehouse/service/PdaWarehouseService.java`;解析库存、库位和托盘码并查询操作历史,固定要求 `lastleg:pda:operate` 与 `ROLE_WAREHOUSE_PICKER`,仓库范围只能取自 PDA Token |
| 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java`、`shipping/entity/ContainerShippingGate.java`、`shipping/mapper/ContainerShippingGateMapper.java`;只单调接受实际标准节点,`PICKUP_READY` 及以后才允许对应柜进入 PDA 收货候选 | | 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java`、`shipping/entity/ContainerShippingGate.java`、`shipping/mapper/ContainerShippingGateMapper.java`;只单调接受实际标准节点,`PICKUP_READY` 及以后才允许对应柜进入 PDA 收货候选 |
| 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`、`dispatch/mapper/DispatchCandidateMapper.java`、`dispatch/service/DispatchService.java`;草稿柜 `entity/DispatchDraftContainer.java`、`mapper/DispatchDraftContainerMapper.java`,托盘锁 `DispatchPalletLock.java`;入口 `/api/platform/v1/last-leg/dispatches`,查询、预排与执行边界见“海外仓全流程开发路由” | | 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`、`dispatch/mapper/DispatchCandidateMapper.java`、`dispatch/service/DispatchService.java`;草稿柜 `entity/DispatchDraftContainer.java`、`mapper/DispatchDraftContainerMapper.java`,托盘锁 `DispatchPalletLock.java`;入口 `/api/platform/v1/last-leg/dispatches`,查询、预排与执行边界见“海外仓全流程开发路由” |
@@ -67,7 +68,7 @@
|---|---|---| |---|---|---|
| 核心订单与事件 | `ll_loading_*`、`ll_event_*`、`ll_order*`、`ll_inbound_order*` | 事件收件/发件、尾程订单、明细及状态历史 | | 核心订单与事件 | `ll_loading_*`、`ll_event_*`、`ll_order*`、`ll_inbound_order*` | 事件收件/发件、尾程订单、明细及状态历史 |
| 入仓计划与作业 | `ll_inbound_plan*`、`ll_inbound_pda*`、`ll_inbound_pallet_merge`、`ll_inbound_fba_appointment*` | 入仓计划、托盘分配、PDA 作业、合托及 FBA 预约 | | 入仓计划与作业 | `ll_inbound_plan*`、`ll_inbound_pda*`、`ll_inbound_pallet_merge`、`ll_inbound_fba_appointment*` | 入仓计划、托盘分配、PDA 作业、合托及 FBA 预约 |
| 库存 | `ll_inventory_*`、`ll_inbound_stock_receipt` | 库存余额、仓位、流水、命令幂等及实收快照 | | 库存 | `ll_inventory_*`、`ll_inbound_stock_receipt` | 库存余额、按托盘/入仓单拆分的仓位、流水、命令幂等及实收快照;同一托盘可有多条入仓单仓位,汇总数量必须与各明细核平 |
| 派车与出库 | `ll_dispatch_*`、`ll_outbound_*`、`ll_delivery_order` | 派车、PDA 拣货、出库及派送 | | 派车与出库 | `ll_dispatch_*`、`ll_outbound_*`、`ll_delivery_order` | 派车、PDA 拣货、出库及派送 |
| 转仓与换标 | `ll_warehouse_transfer_*`、`ll_relabel_*` | 转仓单、库存转移、目标收货及换标 | | 转仓与换标 | `ll_warehouse_transfer_*`、`ll_relabel_*` | 转仓单、库存转移、目标收货及换标 |
| 一件代发与废弃 | `ll_dropship_*`、`ll_cargo_discard_*` | 托盘/箱唛库存、流水和废弃审批 | | 一件代发与废弃 | `ll_dropship_*`、`ll_cargo_discard_*` | 托盘/箱唛库存、流水和废弃审批 |
@@ -100,6 +101,7 @@
- 平台查询范围由可信身份/平台权限决定;商户查询只能使用认证身份中的客户 ID。 - 平台查询范围由可信身份/平台权限决定;商户查询只能使用认证身份中的客户 ID。
- 尾程平台只读查询接口的 `customerId` 为可选筛选条件:平台身份省略时查询全部客户数据,传入时按指定客户过滤;商户身份忽略客户端传入值并始终按 Token 中的自身客户隔离。创建、编辑、审核等需要明确目标客户的写接口仍必须传入 `customerId`。 - 尾程平台只读查询接口的 `customerId` 为可选筛选条件:平台身份省略时查询全部客户数据,传入时按指定客户过滤;商户身份忽略客户端传入值并始终按 Token 中的自身客户隔离。创建、编辑、审核等需要明确目标客户的写接口仍必须传入 `customerId`。
- 常规入仓库存余额以客户、海外仓、目的仓和入仓单为唯一业务维度并汇总箱数;SKU 继续保存在箱唛、实收和流水快照中,余额 SKU 仅作单 SKU 展示或以 `MIXED` 标识多 SKU,不参与当前唯一维度。 - 常规入仓库存余额以客户、海外仓、目的仓和入仓单为唯一业务维度并汇总箱数;SKU 继续保存在箱唛、实收和流水快照中,余额 SKU 仅作单 SKU 展示或以 `MIXED` 标识多 SKU,不参与当前唯一维度。
- 托盘是 PDA 出库的作业单位,但不是库存归属单位:`ll_inventory_position` 通过余额和托盘保存“托盘包含哪些入仓单、各多少箱”的组成明细。合托后的同一实物托盘允许对应多个入仓单,换标冻结和后续出库核算必须按这些明细分别记账;整托出库可扫描托盘一次完成,部分出库先拆托或按明细核平,不得用 FIFO 猜测来源入仓单。贴标任务以我方箱唛为主键,同箱多 SKU 只存为子明细且不重复冻结。
- 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。 - 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。
- 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。 - 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。
- 货物废弃只使用可核对 SKU 和箱唛的一件代发库存。创建申请只能将箱从 `AVAILABLE` 锁定为 `DISCARD_PENDING`,不得扣减 SKU 箱数;实际扣库存节点固定为具备 `lastleg:cargo-discard:approve` 权限的仓库/PDA 审核人确认废弃时,并且必须在审核事务内调用专用 discard adjustment。该 adjustment 必须将箱改为 `DISCARDED`、写逐箱业务流水及唯一幂等键,并校验 `扣减前箱数 - 废弃箱数 = 扣减后箱数` 且逐箱数等于申请总箱数;任一环节失败须整体回滚。拒绝申请只释放锁箱,不得扣库存。 - 货物废弃只使用可核对 SKU 和箱唛的一件代发库存。创建申请只能将箱从 `AVAILABLE` 锁定为 `DISCARD_PENDING`,不得扣减 SKU 箱数;实际扣库存节点固定为具备 `lastleg:cargo-discard:approve` 权限的仓库/PDA 审核人确认废弃时,并且必须在审核事务内调用专用 discard adjustment。该 adjustment 必须将箱改为 `DISCARDED`、写逐箱业务流水及唯一幂等键,并校验 `扣减前箱数 - 废弃箱数 = 扣减后箱数` 且逐箱数等于申请总箱数;任一环节失败须整体回滚。拒绝申请只释放锁箱,不得扣库存。
@@ -7,6 +7,8 @@ import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Collections;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -28,7 +30,19 @@ public final class DeliveryRequests {
@Size(max = 128) String appointmentAccount, @Size(max = 128) String appointmentAccount,
@Size(max = 128) String sellerName, @Size(max = 128) String sellerName,
@Size(max = 1000) String remark, @Size(max = 1000) String remark,
@NotNull @Valid @Size(min = 1, max = 20) List<FeeItem> fees) {} @NotNull @Valid @Size(min = 1, max = 20) List<FeeItem> fees,
Map<String, List<@NotBlank @Size(max = 128) String>> palletCartonMarks) {
public Transfer(String idempotencyKey, LocalDateTime plannedDeliveryAt, String deliveryMethod, String waybillNo,
String carrierName, String pickupMethod, String loadingType, String vehicleType, String vehicleSize,
String driverName, String driverPhone, String vehiclePlate, String appointmentAccount,
String sellerName, String remark, List<FeeItem> fees) {
this(idempotencyKey, plannedDeliveryAt, deliveryMethod, waybillNo, carrierName, pickupMethod, loadingType,
vehicleType, vehicleSize, driverName, driverPhone, vehiclePlate, appointmentAccount, sellerName, remark, fees, Map.of());
}
}
public record PalletCartonSelection(@NotBlank @Size(max = 128) String palletNo,
@NotNull @Size(min = 1, max = 500) List<@NotBlank @Size(max = 128) String> cartonMarks) {}
public record FeeItem(@NotBlank @Size(max = 64) String feeType, public record FeeItem(@NotBlank @Size(max = 64) String feeType,
@NotBlank @Size(max = 8) String currency, @NotBlank @Size(max = 8) String currency,
@@ -41,12 +55,6 @@ public final class DeliveryRequests {
String driverName, String deliveryMethod, String driverName, String deliveryMethod,
LocalDateTime plannedFrom, LocalDateTime plannedTo) { LocalDateTime plannedFrom, LocalDateTime plannedTo) {
public Query { if (page == 0) page = 1; if (size == 0) size = 20; } public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
public Query(long page, long size, String documentNo, String warehouseId, String status,
String dispatchNo, String waybillNo, String vehiclePlate, String driverName,
String deliveryMethod, LocalDateTime plannedFrom, LocalDateTime plannedTo) {
this(page, size, documentNo, null, warehouseId, status, dispatchNo, waybillNo, vehiclePlate,
driverName, deliveryMethod, plannedFrom, plannedTo);
}
} }
public record PalletQuery(@Min(1) long page, @Min(1) @Max(200) long size, public record PalletQuery(@Min(1) long page, @Min(1) @Max(200) long size,
@@ -59,7 +67,12 @@ public final class DeliveryRequests {
@Size(max=1000) String remark, @Size(max=1000) String remark,
@NotBlank @Size(max=255) String idempotencyKey) {} @NotBlank @Size(max=255) String idempotencyKey) {}
public record OneClickItem(@NotNull Long inventoryPositionId, @NotNull @Min(1) Long packages, public record OneClickItem(@NotNull Long inventoryPositionId, @NotNull @Min(1) Long packages,
@NotBlank @Size(max=128) String sku) {} @NotBlank @Size(max=128) String sku,
@Size(max=200) List<@NotBlank @Size(max=128) String> cartonMarks) {
public OneClickItem(Long inventoryPositionId, Long packages, String sku) {
this(inventoryPositionId, packages, sku, Collections.emptyList());
}
}
public record PdaPick(@NotNull Long outboundPalletItemId, @NotNull Long inventoryPositionId, public record PdaPick(@NotNull Long outboundPalletItemId, @NotNull Long inventoryPositionId,
@NotNull @Min(1) Long packages, @NotBlank @Size(max=255) String idempotencyKey) {} @NotNull @Min(1) Long packages, @NotBlank @Size(max=255) String idempotencyKey) {}
public record PdaComplete(@NotBlank @Size(max=255) String idempotencyKey) {} public record PdaComplete(@NotBlank @Size(max=255) String idempotencyKey) {}
@@ -40,7 +40,9 @@ public final class DeliveryResponses {
public record DeliveryDetail(DeliveryRow order, List<DeliverySku> skus, public record DeliveryDetail(DeliveryRow order, List<DeliverySku> skus,
List<DeliveryFeeRow> fees, List<DeliveryOperationRow> operations) {} List<DeliveryFeeRow> fees, List<DeliveryOperationRow> operations) {}
public record OutboundDetail(OutboundRow outbound, DeliveryRow delivery) {} public record OutboundPalletRow(Long id, String palletNo, String cartonMarks, String skuCode, String inboundCode,
Integer packageCount, Integer pickedPackageCount, String status) {}
public record OutboundDetail(OutboundRow outbound, DeliveryRow delivery, List<OutboundPalletRow> pallets) {}
public record PdaTask(Long id, String taskNo, String status, String outboundNo, LocalDateTime pushedAt, LocalDateTime completedAt) {} public record PdaTask(Long id, String taskNo, String status, String outboundNo, LocalDateTime pushedAt, LocalDateTime completedAt) {}
public record PdaPickPosition(Long inventoryPositionId, String palletNo, String cartonMark, public record PdaPickPosition(Long inventoryPositionId, String palletNo, String cartonMark,
String skuCode, String inboundCode, long availablePackages, String skuCode, String inboundCode, long availablePackages,
@@ -24,6 +24,7 @@ public class OutboundPalletItem {
private Integer pickedPackageCount; private Integer pickedPackageCount;
private String status; private String status;
private String skuCode; private String skuCode;
private String cartonMarks;
private String orderRefs; private String orderRefs;
private String inboundCode; private String inboundCode;
private LocalDateTime createdAt; private LocalDateTime createdAt;
@@ -26,6 +26,7 @@ import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper;
import com.baiyue.lastleg.delivery.entity.OutboundPdaTask; import com.baiyue.lastleg.delivery.entity.OutboundPdaTask;
import com.baiyue.lastleg.delivery.entity.OutboundPdaPick; import com.baiyue.lastleg.delivery.entity.OutboundPdaPick;
@@ -78,6 +79,7 @@ public class DeliveryTransferService {
@Resource private DropshipSkuInventoryMapper dropshipSkuInventory; @Resource private DropshipSkuInventoryMapper dropshipSkuInventory;
@Resource private InboundPlanPalletAllocationMapper planAllocations; @Resource private InboundPlanPalletAllocationMapper planAllocations;
@Resource private InboundPlanLineMapper planLines; @Resource private InboundPlanLineMapper planLines;
@Resource private InboundPlanCartonMapper inboundCartons;
@Resource private LastlegOrderMapper tailOrders; @Resource private LastlegOrderMapper tailOrders;
@Resource private LastlegOrderStatusHistoryMapper histories; @Resource private LastlegOrderStatusHistoryMapper histories;
@Resource private LastlegOrderDetailMapper tailOrderDetails; @Resource private LastlegOrderDetailMapper tailOrderDetails;
@@ -156,6 +158,15 @@ public class DeliveryTransferService {
item.setDestinationWarehouseCode(source.getDestinationWarehouseCode()); item.setDestinationWarehouseCode(source.getDestinationWarehouseCode());
item.setDestinationWarehouseName(source.getDestinationWarehouseName()); item.setDestinationWarehouseName(source.getDestinationWarehouseName());
item.setPackageCount(Objects.requireNonNullElse(source.getPackageCount(), 0)); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK"); item.setPackageCount(Objects.requireNonNullElse(source.getPackageCount(), 0)); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK");
List<String> requestedMarks = request.palletCartonMarks() == null ? List.of() : request.palletCartonMarks().getOrDefault(source.getPalletNo(), List.of());
if (!requestedMarks.isEmpty()) {
if (requestedMarks.size() != Objects.requireNonNullElse(source.getPackageCount(), 0)) throw conflict("Selected carton mark count must equal pallet package count");
item.setCartonMarks(String.join(",", requestedMarks.stream().distinct().toList()));
} else if (source.getWarehousePalletId() != null) {
String marks = inboundCartons.listByPallet(customerId, source.getWarehousePalletId()).stream()
.map(c -> c.getCartonMark()).filter(StringUtils::hasText).distinct().collect(java.util.stream.Collectors.joining(","));
item.setCartonMarks(StringUtils.hasText(marks) ? marks : null);
}
item.setOrderRefs(source.getOrderRefs()); item.setInboundCode(source.getInboundCode()); item.setCreatedAt(now); item.setOrderRefs(source.getOrderRefs()); item.setInboundCode(source.getInboundCode()); item.setCreatedAt(now);
outboundPallets.insert(item); outboundPallets.insert(item);
} }
@@ -230,7 +241,7 @@ public class DeliveryTransferService {
InventoryPosition position = inventoryPositions.selectById(input.inventoryPositionId()); InventoryPosition position = inventoryPositions.selectById(input.inventoryPositionId());
if (position == null || !customerId.equals(position.getCustomerId())) throw notFound("Inventory position not found"); if (position == null || !customerId.equals(position.getCustomerId())) throw notFound("Inventory position not found");
OutboundPalletItem item = new OutboundPalletItem(); item.setId(IdWorker.getId()); item.setCustomerId(customerId); item.setOutboundOrderId(outbound.getId()); OutboundPalletItem item = new OutboundPalletItem(); item.setId(IdWorker.getId()); item.setCustomerId(customerId); item.setOutboundOrderId(outbound.getId());
item.setWarehousePalletId(position.getPalletId()); item.setPalletNo(position.getPalletNo()); item.setPackageCount(input.packages().intValue()); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK"); item.setSkuCode(input.sku().trim()); item.setCreatedAt(now); item.setWarehousePalletId(position.getPalletId()); item.setPalletNo(position.getPalletNo()); item.setPackageCount(input.packages().intValue()); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK"); item.setSkuCode(input.sku().trim()); item.setCartonMarks(String.join(",", input.cartonMarks() == null ? List.of() : input.cartonMarks())); item.setCreatedAt(now);
outboundPallets.insert(item); outboundPallets.insert(item);
} }
outbound.setOverseasWarehouseId(selectedWarehouseId); outboundOrders.updateById(outbound); outbound.setOverseasWarehouseId(selectedWarehouseId); outboundOrders.updateById(outbound);
@@ -258,6 +269,15 @@ public class DeliveryTransferService {
String key = profile.getId() + "\u0000" + input.sku().trim(); String key = profile.getId() + "\u0000" + input.sku().trim();
requestedPackages.merge(key, input.packages(), Long::sum); requestedPackages.merge(key, input.packages(), Long::sum);
profiles.put(key, profile); profiles.put(key, profile);
if (input.cartonMarks() != null && !input.cartonMarks().isEmpty()) {
if (input.cartonMarks().size() != input.packages() || new HashSet<>(input.cartonMarks()).size() != input.cartonMarks().size()) {
throw conflict("Specified carton marks must be unique and equal the package count");
}
List<DropshipCarton> selected = dropshipCartons.listProfile(customerId, profile.getId()).stream()
.filter(carton -> "AVAILABLE".equals(carton.getStatus()) && input.sku().trim().equals(carton.getSkuCode())
&& input.cartonMarks().contains(carton.getCartonMark())).toList();
if (selected.size() != input.cartonMarks().size()) throw conflict("Specified carton marks are unavailable");
}
} }
for (Map.Entry<String, Long> requested : requestedPackages.entrySet()) { for (Map.Entry<String, Long> requested : requestedPackages.entrySet()) {
String sku = requested.getKey().substring(requested.getKey().indexOf('\u0000') + 1); String sku = requested.getKey().substring(requested.getKey().indexOf('\u0000') + 1);
@@ -305,7 +325,11 @@ public class DeliveryTransferService {
} }
DropshipOrderProfile profile = dropshipProfiles.findPosition(customerId, request.inventoryPositionId()); DropshipOrderProfile profile = dropshipProfiles.findPosition(customerId, request.inventoryPositionId());
if (profile == null) throw conflict("Scanned pallet is not one-click dropship inventory"); if (profile == null) throw conflict("Scanned pallet is not one-click dropship inventory");
List<DropshipCarton> cartons = dropshipCartons.lockAvailable(customerId, profile.getId(), item.getSkuCode(), request.packages()); List<DropshipCarton> cartons = StringUtils.hasText(item.getCartonMarks())
? Arrays.stream(item.getCartonMarks().split(",")).map(String::trim).filter(StringUtils::hasText)
.map(mark -> dropshipCartons.findByProfileAndMark(customerId, profile.getId(), mark)).filter(Objects::nonNull)
.filter(carton -> "AVAILABLE".equals(carton.getStatus()) && item.getSkuCode().equals(carton.getSkuCode())).toList()
: dropshipCartons.lockAvailable(customerId, profile.getId(), item.getSkuCode(), request.packages());
if (cartons.size() != request.packages()) throw conflict("Insufficient available one-click dropship cartons"); if (cartons.size() != request.packages()) throw conflict("Insufficient available one-click dropship cartons");
InventoryCommandRequests.Move move = new InventoryCommandRequests.Move(request.inventoryPositionId(), request.packages(), outbound.getOutboundNo(), outbound.getOutboundNo(), null, request.idempotencyKey() + ":ALLOCATE", null); InventoryCommandRequests.Move move = new InventoryCommandRequests.Move(request.inventoryPositionId(), request.packages(), outbound.getOutboundNo(), outbound.getOutboundNo(), null, request.idempotencyKey() + ":ALLOCATE", null);
inventoryCommands.allocate(customerId, operatorId, move); inventoryCommands.allocate(customerId, operatorId, move);
@@ -481,7 +505,9 @@ public class DeliveryTransferService {
if (outbound == null) throw notFound("Outbound order not found"); if (outbound == null) throw notFound("Outbound order not found");
String customerId = outbound.getCustomerId(); String customerId = outbound.getCustomerId();
DeliveryOrder delivery = requireDelivery(customerId, outbound.getId()); DeliveryOrder delivery = requireDelivery(customerId, outbound.getId());
return new DeliveryResponses.OutboundDetail(outboundRow(outbound), deliveryRow(delivery)); List<DeliveryResponses.OutboundPalletRow> pallets = outboundPallets.findByOutbound(customerId, outbound.getId()).stream()
.map(item -> new DeliveryResponses.OutboundPalletRow(item.getId(), item.getPalletNo(), item.getCartonMarks(), item.getSkuCode(), item.getInboundCode(), item.getPackageCount(), item.getPickedPackageCount(), item.getStatus())).toList();
return new DeliveryResponses.OutboundDetail(outboundRow(outbound), deliveryRow(delivery), pallets);
} }
public PageResult<DeliveryResponses.DeliveryRow> deliveryPage(String customerId, DeliveryRequests.Query query) { public PageResult<DeliveryResponses.DeliveryRow> deliveryPage(String customerId, DeliveryRequests.Query query) {
@@ -9,6 +9,8 @@ import java.util.List;
public interface DropshipCartonMapper extends BaseMapper<DropshipCarton> { public interface DropshipCartonMapper extends BaseMapper<DropshipCarton> {
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} AND carton_mark=#{cartonMark} LIMIT 1") @Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} AND carton_mark=#{cartonMark} LIMIT 1")
DropshipCarton find(@Param("customerId") String customerId, @Param("orderId") Long orderId, @Param("cartonMark") String cartonMark); DropshipCarton find(@Param("customerId") String customerId, @Param("orderId") Long orderId, @Param("cartonMark") String cartonMark);
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND profile_id=#{profileId} AND carton_mark=#{cartonMark} LIMIT 1 FOR UPDATE")
DropshipCarton findByProfileAndMark(@Param("customerId") String customerId, @Param("profileId") Long profileId, @Param("cartonMark") String cartonMark);
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND profile_id=#{profileId} ORDER BY carton_sequence") @Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND profile_id=#{profileId} ORDER BY carton_sequence")
List<DropshipCarton> listProfile(@Param("customerId") String customerId, @Param("profileId") Long profileId); List<DropshipCarton> listProfile(@Param("customerId") String customerId, @Param("profileId") Long profileId);
@@ -28,15 +28,6 @@ public final class InboundPlanRequests {
LocalDateTime createdFrom, LocalDateTime createdFrom,
LocalDateTime createdTo LocalDateTime createdTo
) { ) {
public Query(long page, long size, String planNo, String inboundCode, String status,
String overseasWarehouseId, String destinationWarehouseCode, String warehouse,
String fbaWarehouseCode, String appointmentStatus, String dispatchStatus,
LocalDateTime createdFrom, LocalDateTime createdTo) {
this(page, size, planNo, inboundCode, null, status, overseasWarehouseId,
destinationWarehouseCode, warehouse, fbaWarehouseCode, appointmentStatus,
dispatchStatus, createdFrom, createdTo);
}
public Query { public Query {
if (page == 0) page = 1; if (page == 0) page = 1;
if (size == 0) size = 20; if (size == 0) size = 20;
@@ -10,6 +10,8 @@ import java.util.List;
@Mapper @Mapper
public interface InboundPlanCartonMapper extends BaseMapper<InboundPlanCarton> { public interface InboundPlanCartonMapper extends BaseMapper<InboundPlanCarton> {
@Select("SELECT * FROM ll_inbound_plan_carton WHERE customer_id=#{customerId} AND (warehouse_pallet_id=#{palletId} OR plan_pallet_id=#{palletId}) ORDER BY id")
List<InboundPlanCarton> listByPallet(@Param("customerId") String customerId, @Param("palletId") Long palletId);
@Select(""" @Select("""
SELECT l.* FROM ll_inbound_plan_line l SELECT l.* FROM ll_inbound_plan_line l
JOIN ll_order_detail d ON d.order_id=l.order_id JOIN ll_order_detail d ON d.order_id=l.order_id
@@ -16,6 +16,10 @@ public interface InventoryPositionMapper extends BaseMapper<InventoryPosition> {
List<InventoryPosition> listBalance(@Param("customerId") String customerId, List<InventoryPosition> listBalance(@Param("customerId") String customerId,
@Param("balanceId") Long balanceId); @Param("balanceId") Long balanceId);
@Select("SELECT * FROM ll_inventory_position WHERE id=#{positionId} AND customer_id=#{customerId} LIMIT 1")
InventoryPosition findTenantPosition(@Param("customerId") String customerId,
@Param("positionId") Long positionId);
@Select("SELECT * FROM ll_inventory_position WHERE customer_id=#{customerId} AND pallet_id=#{palletId} " + @Select("SELECT * FROM ll_inventory_position WHERE customer_id=#{customerId} AND pallet_id=#{palletId} " +
"ORDER BY balance_id,batch_no") "ORDER BY balance_id,batch_no")
List<InventoryPosition> listPallet(@Param("customerId") String customerId, List<InventoryPosition> listPallet(@Param("customerId") String customerId,
@@ -12,11 +12,19 @@ import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@RestController @RestController
@RequestMapping("/api/merchant/v1/last-leg/relabel-applications") @RequestMapping("/api/merchant/v1/last-leg/relabel-applications")
public class MerchantRelabelApplicationController { public class MerchantRelabelApplicationController {
@Resource private RelabelApplicationService service; @Resource private RelabelApplicationService service;
@GetMapping("/inventory-candidates")
public ApiResponse<List<RelabelResponses.InventoryCandidate>> candidates(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String operationWarehouseId,
@RequestParam(required = false) String inboundCode) {
return ApiResponse.success(service.candidates(customer(user), operationWarehouseId, inboundCode));
}
@PostMapping @PostMapping
public ApiResponse<RelabelResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<RelabelResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody RelabelRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); } @Valid @RequestBody RelabelRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); }
@@ -19,7 +19,7 @@ public final class RelabelRequests {
public record Create( public record Create(
@NotBlank @Size(max = 128) String customerOrderNo, @NotBlank @Size(max = 128) String customerOrderNo,
@Size(max = 128) String overseasWarehouseId, @NotBlank @Size(max = 128) String operationWarehouseId,
@NotNull LocalDateTime labelDeadline, @NotNull LocalDateTime labelDeadline,
@NotBlank @Size(max = 128) String contactName, @NotBlank @Size(max = 128) String contactName,
@NotBlank @Size(max = 64) String contactPhone, @NotBlank @Size(max = 64) String contactPhone,
@@ -30,6 +30,9 @@ public final class RelabelRequests {
) {} ) {}
public record Item( public record Item(
@NotNull Long sourceInventoryBalanceId,
@NotNull Long sourceInventoryPositionId,
@NotNull Long sourceInboundOrderId,
@Size(max = 128) String skuCode, @Size(max = 128) String skuCode,
@Size(max = 128) String productImageFileId, @Size(max = 128) String productImageFileId,
@Size(max = 255) String productImageFileName, @Size(max = 255) String productImageFileName,
@@ -39,26 +42,61 @@ public final class RelabelRequests {
@Positive int notifiedPackageCount, @Positive int notifiedPackageCount,
@NotBlank @Size(max = 128) String shipmentNo, @NotBlank @Size(max = 128) String shipmentNo,
@Size(max = 255) String trackingCode, @Size(max = 255) String trackingCode,
@NotBlank @Size(max = 128) String newSkuCode, @Size(max = 128) String newSkuCode,
@Size(max = 1000) String labelRequirement, @Size(max = 1000) String labelRequirement,
@Size(max = 1000) String remark, @Size(max = 1000) String remark,
@Size(max = 128) String relabelFileId, @Size(max = 128) String relabelFileId,
@Size(max = 255) String relabelFileName, @Size(max = 255) String relabelFileName,
@Size(max = 1000) String relabelFileUrl, @Size(max = 1000) String relabelFileUrl,
@NotBlank @Size(max = 255) String cartonMark, @NotBlank @Size(max = 255) String cartonMark,
@NotBlank @Size(max = 255) String fbaCartonMark, @Size(max = 255) String fbaCartonMark,
@Size(max = 128) String destinationWarehouseId @Size(max = 128) String destinationFbaWarehouseCode,
@Size(max = 32) String operationType,
@Size(max = 100) List<@Valid SkuItem> skuItems
) { ) {
/** 保持既有箱唛换标调用方的二进制参数形态。 */
public Item(Long sourceInventoryBalanceId, Long sourceInventoryPositionId, Long sourceInboundOrderId,
String skuCode, String productImageFileId, String productImageFileName, String productImageUrl,
String productNameZh, int totalPackageCount, int notifiedPackageCount, String shipmentNo,
String trackingCode, String newSkuCode, String labelRequirement, String remark,
String relabelFileId, String relabelFileName, String relabelFileUrl, String cartonMark,
String fbaCartonMark, String destinationFbaWarehouseCode) {
this(sourceInventoryBalanceId, sourceInventoryPositionId, sourceInboundOrderId, skuCode,
productImageFileId, productImageFileName, productImageUrl, productNameZh, totalPackageCount,
notifiedPackageCount, shipmentNo, trackingCode, newSkuCode, labelRequirement, remark,
relabelFileId, relabelFileName, relabelFileUrl, cartonMark, fbaCartonMark,
destinationFbaWarehouseCode, "CARTON_LABEL", List.of());
}
public Item(String skuCode, String productImageFileId, String productImageFileName,
String productImageUrl, String productNameZh, int totalPackageCount,
int notifiedPackageCount, String shipmentNo, String trackingCode, String newSkuCode,
String labelRequirement, String remark, String relabelFileId, String relabelFileName,
String relabelFileUrl, String cartonMark, String fbaCartonMark,
String destinationFbaWarehouseCode) {
this(101L, 201L, 301L, skuCode, productImageFileId, productImageFileName, productImageUrl,
productNameZh, totalPackageCount, notifiedPackageCount, shipmentNo, trackingCode,
newSkuCode, labelRequirement, remark, relabelFileId, relabelFileName, relabelFileUrl,
cartonMark, fbaCartonMark, destinationFbaWarehouseCode, "CARTON_LABEL", List.of());
}
public Item(String skuCode, String productImageFileId, String productImageFileName, public Item(String skuCode, String productImageFileId, String productImageFileName,
String productImageUrl, String productNameZh, int totalPackageCount, String productImageUrl, String productNameZh, int totalPackageCount,
int notifiedPackageCount, String shipmentNo, String trackingCode, String newSkuCode, int notifiedPackageCount, String shipmentNo, String trackingCode, String newSkuCode,
String labelRequirement, String relabelFileId, String relabelFileName, String relabelFileUrl) { String labelRequirement, String relabelFileId, String relabelFileName, String relabelFileUrl) {
this(skuCode, productImageFileId, productImageFileName, productImageUrl, productNameZh, this(101L, 201L, 301L, skuCode, productImageFileId, productImageFileName, productImageUrl, productNameZh,
totalPackageCount, notifiedPackageCount, shipmentNo, trackingCode, newSkuCode, totalPackageCount, notifiedPackageCount, shipmentNo, trackingCode, newSkuCode,
labelRequirement, null, relabelFileId, relabelFileName, relabelFileUrl, skuCode, shipmentNo, null); labelRequirement, null, relabelFileId, relabelFileName, relabelFileUrl, skuCode, shipmentNo, "ONT8",
"CARTON_LABEL", List.of());
} }
} }
/** 同一我方箱唛内可同时更换的多个 SKU;数量以件为单位。 */
public record SkuItem(
@NotBlank @Size(max = 128) String skuCode,
@NotBlank @Size(max = 128) String newSkuCode,
@Positive int quantity,
@Size(max = 1000) String remark
) {}
public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String relabelNo, public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String relabelNo,
String customerOrderNo, String skuCode, String status, String customerOrderNo, String skuCode, String status,
String overseasWarehouseId, LocalDateTime createdFrom, LocalDateTime createdTo) { String overseasWarehouseId, LocalDateTime createdFrom, LocalDateTime createdTo) {
@@ -19,7 +19,19 @@ public final class RelabelResponses {
int notifiedPackageCount, String shipmentNo, String trackingCode, int notifiedPackageCount, String shipmentNo, String trackingCode,
String newSkuCode, String labelRequirement, String remark, String relabelFileId, String newSkuCode, String labelRequirement, String remark, String relabelFileId,
String relabelFileName, String relabelFileUrl, String relabelFileName, String relabelFileUrl,
String cartonMark, String fbaCartonMark, String destinationWarehouseId) {} String cartonMark, String fbaCartonMark, String destinationFbaWarehouseCode,
String operationType, List<SkuItem> skuItems,
Long sourceInventoryBalanceId, Long sourceInventoryPositionId, Long sourceInboundOrderId,
String sourceInboundOrderNo, String sourceInboundCode, String sourceWarehouseId,
String sourceWarehouseCode, String sourceWarehouseName) {}
public record SkuItem(String skuCode, String newSkuCode, int quantity, String remark) {}
public record InventoryPosition(Long positionId, String palletNo, String batchNo,
String warehouseLocationCode, long availablePackages) {}
public record InventoryCandidate(Long inventoryBalanceId, Long inboundOrderId, String inboundOrderNo,
String inboundCode, String overseasWarehouseId, String overseasWarehouseCode,
String overseasWarehouseName, String destinationFbaWarehouseCode,
String destinationFbaWarehouseName, String skuCode, String productNameZh,
long availablePackages, long version, List<InventoryPosition> positions) {}
public record TailOrder(Long id, String orderNo, String overseasWarehouseId, int packageCount, public record TailOrder(Long id, String orderNo, String overseasWarehouseId, int packageCount,
String status) {} String status) {}
public record History(String fromStatus, String toStatus, String operatorId, public record History(String fromStatus, String toStatus, String operatorId,
@@ -31,5 +31,16 @@ public class RelabelItem {
private String cartonMark; private String cartonMark;
private String fbaCartonMark; private String fbaCartonMark;
private String destinationWarehouseId; private String destinationWarehouseId;
private String destinationFbaWarehouseCode;
private String operationType;
private String skuRelabelItemsJson;
private Long sourceInventoryBalanceId;
private Long sourceInventoryPositionId;
private Long sourceInboundOrderId;
private String sourceInboundOrderNo;
private String sourceInboundCode;
private String sourceWarehouseId;
private String sourceWarehouseCode;
private String sourceWarehouseName;
private LocalDateTime createdAt; private LocalDateTime createdAt;
} }
@@ -14,9 +14,9 @@ public final class PdaRelabelRequests {
public record Claim(@NotBlank @Size(max = 255) String idempotencyKey) { } public record Claim(@NotBlank @Size(max = 255) String idempotencyKey) { }
public record Scan(@NotNull @Positive Long itemId, public record Scan(@Positive Long itemId,
@NotBlank @Size(max = 255) String oldLabel, @NotBlank @Size(max = 255) String oldLabel,
@NotBlank @Size(max = 255) String newLabel, @Size(max = 255) String newLabel,
@NotNull @Min(0) Integer expectedVersion, @NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 255) String idempotencyKey) { } @NotBlank @Size(max = 255) String idempotencyKey) { }
@@ -23,8 +23,11 @@ public final class PdaRelabelResponses {
public record Item(Long itemId, String skuCode, String productNameZh, String shipmentNo, public record Item(Long itemId, String skuCode, String productNameZh, String shipmentNo,
String trackingCode, String newSkuCode, String labelRequirement, String trackingCode, String newSkuCode, String labelRequirement,
String relabelFileId, String relabelFileName, String relabelFileUrl, String relabelFileId, String relabelFileName, String relabelFileUrl,
String cartonMark, String fbaCartonMark, String operationType,
List<SkuItem> skuItems,
int expectedPackages, int relabelledPackages, int skippedPackages, int expectedPackages, int relabelledPackages, int skippedPackages,
int exceptionPackages) { } int exceptionPackages) { }
public record SkuItem(String skuCode, String newSkuCode, int quantity, String remark) { }
public record UnitFact(Long factId, Long itemId, String oldLabel, String newLabel, public record UnitFact(Long factId, Long itemId, String oldLabel, String newLabel,
String result, String exceptionCode, String reason, String result, String exceptionCode, String reason,
@@ -135,13 +135,23 @@ public class PdaRelabelService {
@Transactional @Transactional
public PdaRelabelResponses.Detail scan(String operatorKey, String operatorName, long overseasWarehouseId, public PdaRelabelResponses.Detail scan(String operatorKey, String operatorName, long overseasWarehouseId,
String relabelNo, PdaRelabelRequests.Scan request) { String relabelNo, PdaRelabelRequests.Scan request) {
Long itemId = request.itemId();
if (itemId == null) {
RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, false);
itemId = requireItemByCartonMark(application, request.oldLabel()).getId();
}
return unitAction(operatorKey, operatorName, overseasWarehouseId, relabelNo, "RELABEL", request.idempotencyKey(), return unitAction(operatorKey, operatorName, overseasWarehouseId, relabelNo, "RELABEL", request.idempotencyKey(),
request, request.itemId(), request.oldLabel(), request.newLabel(), "RELABELLED", null, null, request, itemId, request.oldLabel(), request.newLabel(), "RELABELLED", null, null,
request.expectedVersion(), item -> { request.expectedVersion(), item -> {
if (!item.getNewSkuCode().equalsIgnoreCase(request.newLabel().trim())) { if (StringUtils.hasText(item.getCartonMark()) && !item.getCartonMark().trim().equalsIgnoreCase(request.oldLabel().trim())) {
throw conflict("Scanned new label does not match the approved new SKU"); throw conflict("Scanned old carton mark does not match the relabel item");
} }
if (request.oldLabel().trim().equalsIgnoreCase(request.newLabel().trim())) { String expectedFbaLabel = trim(item.getFbaCartonMark());
if (StringUtils.hasText(request.newLabel()) && StringUtils.hasText(expectedFbaLabel)
&& !expectedFbaLabel.equalsIgnoreCase(request.newLabel().trim())) {
throw conflict("Scanned new label does not match the approved FBA carton mark");
}
if (StringUtils.hasText(expectedFbaLabel) && request.oldLabel().trim().equalsIgnoreCase(expectedFbaLabel)) {
throw conflict("Old and new labels must be different"); throw conflict("Old and new labels must be different");
} }
}); });
@@ -277,7 +287,11 @@ public class PdaRelabelService {
PdaRelabelUnitFact fact = new PdaRelabelUnitFact(); PdaRelabelUnitFact fact = new PdaRelabelUnitFact();
fact.setId(IdWorker.getId()); fact.setTaskId(task.getId()); fact.setApplicationId(application.getId()); fact.setId(IdWorker.getId()); fact.setTaskId(task.getId()); fact.setApplicationId(application.getId());
fact.setItemId(itemId); fact.setCustomerId(application.getCustomerId()); fact.setItemId(itemId); fact.setCustomerId(application.getCustomerId());
fact.setOldLabel(normalizedOldLabel); fact.setNewLabel(trim(newLabel)); fact.setResult(resultCode); String resolvedNewLabel = trim(newLabel);
if ("RELABELLED".equals(resultCode) && !StringUtils.hasText(resolvedNewLabel)) {
resolvedNewLabel = trim(item.getFbaCartonMark());
}
fact.setOldLabel(normalizedOldLabel); fact.setNewLabel(resolvedNewLabel); fact.setResult(resultCode);
fact.setExceptionCode(exceptionCode); fact.setReason(trim(reason)); fact.setExceptionCode(exceptionCode); fact.setReason(trim(reason));
fact.setOperatorKey(operatorKey); fact.setOperatorName(operatorName); fact.setOccurredAt(LocalDateTime.now()); fact.setOperatorKey(operatorKey); fact.setOperatorName(operatorName); fact.setOccurredAt(LocalDateTime.now());
try { unitFacts.insert(fact); } try { unitFacts.insert(fact); }
@@ -328,6 +342,14 @@ public class PdaRelabelService {
if (item == null) throw notFound("Relabel item does not belong to the current application"); if (item == null) throw notFound("Relabel item does not belong to the current application");
return item; return item;
} }
private RelabelItem requireItemByCartonMark(RelabelApplication application, String cartonMark) {
if (!StringUtils.hasText(cartonMark)) throw badRequest("Carton mark is required when itemId is omitted");
List<RelabelItem> matches = items.selectList(new LambdaQueryWrapper<RelabelItem>()
.eq(RelabelItem::getApplicationId, application.getId()).eq(RelabelItem::getCustomerId, application.getCustomerId())
.eq(RelabelItem::getCartonMark, cartonMark.trim()));
if (matches.size() != 1) throw notFound("Scanned carton mark does not belong to the current relabel application");
return matches.get(0);
}
private void requireVersion(PdaRelabelTask task, Integer expectedVersion) { private void requireVersion(PdaRelabelTask task, Integer expectedVersion) {
if (!Objects.equals(task.getVersion(), expectedVersion)) { if (!Objects.equals(task.getVersion(), expectedVersion)) {
@@ -380,6 +402,7 @@ public class PdaRelabelService {
return new PdaRelabelResponses.Item(item.getId(), item.getSkuCode(), item.getProductNameZh(), return new PdaRelabelResponses.Item(item.getId(), item.getSkuCode(), item.getProductNameZh(),
item.getShipmentNo(), item.getTrackingCode(), item.getNewSkuCode(), item.getLabelRequirement(), item.getShipmentNo(), item.getTrackingCode(), item.getNewSkuCode(), item.getLabelRequirement(),
item.getRelabelFileId(), item.getRelabelFileName(), item.getRelabelFileUrl(), item.getRelabelFileId(), item.getRelabelFileName(), item.getRelabelFileUrl(),
item.getCartonMark(), item.getFbaCartonMark(), operationType(item), readSkuItems(item.getSkuRelabelItemsJson()),
value(item.getNotifiedPackageCount()), count(itemFacts, "RELABELLED"), count(itemFacts, "SKIPPED"), value(item.getNotifiedPackageCount()), count(itemFacts, "RELABELLED"), count(itemFacts, "SKIPPED"),
count(itemFacts, "EXCEPTION")); count(itemFacts, "EXCEPTION"));
}).toList(); }).toList();
@@ -462,6 +485,16 @@ public class PdaRelabelService {
return objectMapper.readValue(value, objectMapper.getTypeFactory().constructCollectionType(List.class, Long.class)); return objectMapper.readValue(value, objectMapper.getTypeFactory().constructCollectionType(List.class, Long.class));
} catch (JsonProcessingException exception) { throw new IllegalStateException("Cannot read relabel photo IDs", exception); } } catch (JsonProcessingException exception) { throw new IllegalStateException("Cannot read relabel photo IDs", exception); }
} }
private List<PdaRelabelResponses.SkuItem> readSkuItems(String value) {
if (!StringUtils.hasText(value)) return List.of();
try {
return objectMapper.readValue(value, objectMapper.getTypeFactory().constructCollectionType(
List.class, PdaRelabelResponses.SkuItem.class));
} catch (JsonProcessingException exception) { throw new IllegalStateException("Cannot read relabel SKU details", exception); }
}
private static String operationType(RelabelItem item) {
return StringUtils.hasText(item.getOperationType()) ? item.getOperationType() : "CARTON_LABEL";
}
private static int count(List<PdaRelabelUnitFact> facts, String result) { private static int count(List<PdaRelabelUnitFact> facts, String result) {
return (int) facts.stream().filter(fact -> result.equals(fact.getResult())).count(); return (int) facts.stream().filter(fact -> result.equals(fact.getResult())).count();
@@ -8,6 +8,12 @@ import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.relabel.dto.RelabelRequests; import com.baiyue.lastleg.relabel.dto.RelabelRequests;
import com.baiyue.lastleg.relabel.dto.RelabelResponses; import com.baiyue.lastleg.relabel.dto.RelabelResponses;
import com.baiyue.lastleg.relabel.entity.RelabelApplication; import com.baiyue.lastleg.relabel.entity.RelabelApplication;
@@ -38,6 +44,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.HashSet;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Service @Service
@@ -53,35 +60,90 @@ public class RelabelApplicationService implements WalletResultHandler {
@Resource private LastlegOrderNoGenerator orderNoGenerator; @Resource private LastlegOrderNoGenerator orderNoGenerator;
@Resource private ObjectMapper objectMapper; @Resource private ObjectMapper objectMapper;
@Resource private WalletCommandOutboxService walletCommands; @Resource private WalletCommandOutboxService walletCommands;
@Resource private InventoryCommandService inventoryCommands;
@Resource private InventoryBalanceMapper inventoryBalances;
@Resource private InventoryPositionMapper inventoryPositions;
@Transactional(readOnly = true)
public List<RelabelResponses.InventoryCandidate> candidates(String customerId, String operationWarehouseId,
String inboundCode) {
if (!StringUtils.hasText(operationWarehouseId)) throw badRequest("Operation warehouse is required");
List<InventoryBalance> balances = inventoryBalances.selectList(new LambdaQueryWrapper<InventoryBalance>()
.eq(InventoryBalance::getCustomerId, customerId)
.eq(InventoryBalance::getOverseasWarehouseId, operationWarehouseId.trim())
.gt(InventoryBalance::getAvailablePackages, 0)
.like(StringUtils.hasText(inboundCode), InventoryBalance::getInboundCode, trim(inboundCode))
.orderByDesc(InventoryBalance::getUpdatedAt));
return balances.stream().map(balance -> {
List<RelabelResponses.InventoryPosition> positions = inventoryPositions.listBalance(customerId, balance.getId())
.stream().filter(position -> value(position.getAvailablePackages()) > 0)
.map(position -> new RelabelResponses.InventoryPosition(position.getId(), position.getPalletNo(),
position.getBatchNo(), position.getWarehouseLocationCode(), value(position.getAvailablePackages())))
.toList();
long available = positions.stream().mapToLong(RelabelResponses.InventoryPosition::availablePackages).sum();
return new RelabelResponses.InventoryCandidate(balance.getId(), balance.getInboundOrderId(),
balance.getInboundOrderNo(), balance.getInboundCode(), balance.getOverseasWarehouseId(),
balance.getOverseasWarehouseCode(), balance.getOverseasWarehouseName(),
balance.getDestinationWarehouseCode(), balance.getDestinationWarehouseName(), balance.getSkuCode(),
balance.getProductNameZh(), available, value(balance.getVersion()), positions);
}).filter(candidate -> candidate.availablePackages() > 0).toList();
}
@Transactional @Transactional
public RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request) { public RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request) {
int total = 0; if (!StringUtils.hasText(request.operationWarehouseId())) throw badRequest("Operation warehouse is required");
int notified = 0;
Map<String, Integer> packagesByWarehouse = new LinkedHashMap<>();
for (RelabelRequests.Item item : request.items()) {
if (item.notifiedPackageCount() > item.totalPackageCount()) {
throw badRequest("Notified relocation packages cannot exceed total packages for SKU " + item.skuCode());
}
total = Math.addExact(total, item.totalPackageCount());
notified = Math.addExact(notified, item.notifiedPackageCount());
String warehouseId = destination(item, request.overseasWarehouseId());
packagesByWarehouse.merge(warehouseId, item.totalPackageCount(), Math::addExact);
}
String requestHash = hash(request); String requestHash = hash(request);
RelabelApplication replay = applications.findKey(customerId, request.idempotencyKey().trim()); RelabelApplication replay = applications.findKey(customerId, request.idempotencyKey().trim());
if (replay != null) { if (replay != null) {
if (!requestHash.equals(replay.getRequestHash())) throw conflict("Idempotency key was reused with different relabel data"); if (!requestHash.equals(replay.getRequestHash())) throw conflict("Idempotency key was reused with different relabel data");
return detail(customerId, replay.getRelabelNo()); return detail(customerId, replay.getRelabelNo());
} }
int total = 0;
int notified = 0;
List<SourceInventory> sources = new java.util.ArrayList<>();
Set<Long> selectedPositions = new HashSet<>();
Set<String> selectedCartonMarks = new HashSet<>();
for (RelabelRequests.Item item : request.items()) {
validateOperation(item);
if (item.notifiedPackageCount() > item.totalPackageCount()) {
throw badRequest("Notified relocation packages cannot exceed total packages for SKU " + item.skuCode());
}
total = Math.addExact(total, item.totalPackageCount());
notified = Math.addExact(notified, item.notifiedPackageCount());
if (!selectedPositions.add(item.sourceInventoryPositionId())) {
throw badRequest("The same inventory position cannot be selected more than once");
}
if (!selectedCartonMarks.add(item.cartonMark().trim().toUpperCase())) {
throw badRequest("The same carton mark cannot create more than one relabel task");
}
InventoryPosition position = inventoryPositions.findTenantPosition(customerId, item.sourceInventoryPositionId());
if (position == null || !Objects.equals(position.getBalanceId(), item.sourceInventoryBalanceId())) {
throw conflict("Selected inventory position is no longer available");
}
InventoryBalance balance = inventoryBalances.selectById(item.sourceInventoryBalanceId());
if (balance == null || !customerId.equals(balance.getCustomerId())
|| !request.operationWarehouseId().trim().equals(balance.getOverseasWarehouseId())
|| !Objects.equals(balance.getInboundOrderId(), item.sourceInboundOrderId())) {
throw conflict("Selected inventory does not belong to this customer, operation warehouse and inbound order");
}
if (value(position.getAvailablePackages()) < item.totalPackageCount()) {
throw conflict("Available inventory changed for inbound " + balance.getInboundOrderNo());
}
sources.add(new SourceInventory(item, balance));
}
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
long applicationId = IdWorker.getId(); long applicationId = IdWorker.getId();
String primaryWarehouseId = packagesByWarehouse.keySet().iterator().next(); String operationWarehouseId = request.operationWarehouseId().trim();
String relabelNo = orderNoGenerator.next("RELABEL", primaryWarehouseId, applicationId); String relabelNo = orderNoGenerator.next("RELABEL", operationWarehouseId, applicationId);
List<LastlegOrder> tailOrders = packagesByWarehouse.entrySet().stream() for (SourceInventory source : sources) {
.map(entry -> insertTailOrder(customerId, request, requestHash, relabelNo, inventoryCommands.hold(customerId, operatorId, new InventoryCommandRequests.Move(
entry.getKey(), entry.getValue(), now)) source.request().sourceInventoryPositionId(), source.request().totalPackageCount(), relabelNo,
.toList(); null, "RELABEL:" + relabelNo, request.idempotencyKey().trim() + ":HOLD:"
+ source.request().sourceInventoryPositionId(), "贴标申请冻结原入仓单库存"));
}
LastlegOrder tailOrder = insertTailOrder(customerId, request, requestHash, relabelNo,
operationWarehouseId, total, now);
List<LastlegOrder> tailOrders = List.of(tailOrder);
RelabelApplication application = new RelabelApplication(); RelabelApplication application = new RelabelApplication();
application.setId(applicationId); application.setId(applicationId);
@@ -89,7 +151,7 @@ public class RelabelApplicationService implements WalletResultHandler {
application.setRelabelNo(relabelNo); application.setRelabelNo(relabelNo);
application.setLastlegOrderId(tailOrders.get(0).getId()); application.setLastlegOrderId(tailOrders.get(0).getId());
application.setCustomerOrderNo(request.customerOrderNo().trim()); application.setCustomerOrderNo(request.customerOrderNo().trim());
application.setOverseasWarehouseId(primaryWarehouseId); application.setOverseasWarehouseId(operationWarehouseId);
application.setLabelDeadline(request.labelDeadline()); application.setLabelDeadline(request.labelDeadline());
application.setContactName(request.contactName().trim()); application.setContactName(request.contactName().trim());
application.setContactPhone(request.contactPhone().trim()); application.setContactPhone(request.contactPhone().trim());
@@ -113,8 +175,8 @@ public class RelabelApplicationService implements WalletResultHandler {
application.setWalletCommandEventId(walletEventId); application.setWalletCommandEventId(walletEventId);
applications.updateById(application); applications.updateById(application);
for (RelabelRequests.Item source : request.items()) { for (SourceInventory source : sources) {
insertItem(customerId, applicationId, source, request.overseasWarehouseId(), now); insertItem(customerId, applicationId, source, now);
} }
insertHistory(customerId, applicationId, null, PENDING_PROCESSING, operatorId, "Relabel application submitted", now); insertHistory(customerId, applicationId, null, PENDING_PROCESSING, operatorId, "Relabel application submitted", now);
tailOrders.forEach(order -> insertOrderHistory(customerId, order.getId(), null, tailOrders.forEach(order -> insertOrderHistory(customerId, order.getId(), null,
@@ -132,9 +194,8 @@ public class RelabelApplicationService implements WalletResultHandler {
.like(StringUtils.hasText(query.relabelNo()), RelabelApplication::getRelabelNo, query.relabelNo()) .like(StringUtils.hasText(query.relabelNo()), RelabelApplication::getRelabelNo, query.relabelNo())
.like(StringUtils.hasText(query.customerOrderNo()), RelabelApplication::getCustomerOrderNo, query.customerOrderNo()) .like(StringUtils.hasText(query.customerOrderNo()), RelabelApplication::getCustomerOrderNo, query.customerOrderNo())
.eq(StringUtils.hasText(query.status()), RelabelApplication::getStatus, query.status()) .eq(StringUtils.hasText(query.status()), RelabelApplication::getStatus, query.status())
.apply(StringUtils.hasText(query.overseasWarehouseId()), .eq(StringUtils.hasText(query.overseasWarehouseId()), RelabelApplication::getOverseasWarehouseId,
"EXISTS (SELECT 1 FROM ll_relabel_item rw WHERE rw.application_id=ll_relabel_application.id AND rw.customer_id=ll_relabel_application.customer_id AND rw.destination_warehouse_id={0})", trim(query.overseasWarehouseId()))
query.overseasWarehouseId())
.ge(query.createdFrom() != null, RelabelApplication::getCreatedAt, query.createdFrom()) .ge(query.createdFrom() != null, RelabelApplication::getCreatedAt, query.createdFrom())
.le(query.createdTo() != null, RelabelApplication::getCreatedAt, query.createdTo()) .le(query.createdTo() != null, RelabelApplication::getCreatedAt, query.createdTo())
.apply(StringUtils.hasText(query.skuCode()), .apply(StringUtils.hasText(query.skuCode()),
@@ -185,13 +246,17 @@ public class RelabelApplicationService implements WalletResultHandler {
application.setUpdatedAt(now); application.setUpdatedAt(now);
applications.updateById(application); applications.updateById(application);
if ("COMPLETED".equals(next)) { if ("COMPLETED".equals(next)) {
releaseInventory(customerId, operatorId, application);
walletCommands.publish("SETTLE", Long.parseLong(customerId), "RELABEL_ORDER", relabelNo, null, walletCommands.publish("SETTLE", Long.parseLong(customerId), "RELABEL_ORDER", relabelNo, null,
"换标完成,结算费用", 2L); "换标完成,结算费用", 2L);
application.setWalletStatus("SETTLE_PENDING"); applications.updateById(application); application.setWalletStatus("SETTLE_PENDING"); applications.updateById(application);
} else if (Set.of("REJECTED", "CANCELLED").contains(next) && "FROZEN".equals(application.getWalletStatus())) { } else if (Set.of("REJECTED", "CANCELLED").contains(next)) {
walletCommands.publish("RELEASE", Long.parseLong(customerId), "RELABEL_ORDER", relabelNo, null, releaseInventory(customerId, operatorId, application);
"换标取消,释放冻结", 2L); if ("FROZEN".equals(application.getWalletStatus())) {
application.setWalletStatus("RELEASE_PENDING"); applications.updateById(application); walletCommands.publish("RELEASE", Long.parseLong(customerId), "RELABEL_ORDER", relabelNo, null,
"换标取消,释放冻结", 2L);
application.setWalletStatus("RELEASE_PENDING"); applications.updateById(application);
}
} }
List<LastlegOrder> linkedOrders = tailOrders(application); List<LastlegOrder> linkedOrders = tailOrders(application);
if (linkedOrders.isEmpty()) throw conflict("Linked tail-leg order is missing"); if (linkedOrders.isEmpty()) throw conflict("Linked tail-leg order is missing");
@@ -217,30 +282,41 @@ public class RelabelApplicationService implements WalletResultHandler {
return detail(customerId, relabelNo); return detail(customerId, relabelNo);
} }
private void insertItem(String customerId, long applicationId, RelabelRequests.Item source, private void insertItem(String customerId, long applicationId, SourceInventory sourceInventory,
String defaultWarehouseId, LocalDateTime now) { LocalDateTime now) {
RelabelRequests.Item source = sourceInventory.request();
InventoryBalance balance = sourceInventory.balance();
RelabelItem item = new RelabelItem(); RelabelItem item = new RelabelItem();
item.setId(IdWorker.getId()); item.setApplicationId(applicationId); item.setCustomerId(customerId); item.setId(IdWorker.getId()); item.setApplicationId(applicationId); item.setCustomerId(customerId);
item.setSkuCode(trim(source.skuCode())); item.setProductImageFileId(trim(source.productImageFileId())); item.setSkuCode(trim(source.skuCode())); item.setProductImageFileId(trim(source.productImageFileId()));
item.setProductImageFileName(trim(source.productImageFileName())); item.setProductImageUrl(trim(source.productImageUrl())); item.setProductImageFileName(trim(source.productImageFileName())); item.setProductImageUrl(trim(source.productImageUrl()));
item.setProductNameZh(source.productNameZh().trim()); item.setTotalPackageCount(source.totalPackageCount()); item.setProductNameZh(source.productNameZh().trim()); item.setTotalPackageCount(source.totalPackageCount());
item.setNotifiedPackageCount(source.notifiedPackageCount()); item.setShipmentNo(source.shipmentNo().trim()); item.setNotifiedPackageCount(source.notifiedPackageCount()); item.setShipmentNo(source.shipmentNo().trim());
item.setTrackingCode(trim(source.trackingCode())); item.setNewSkuCode(source.newSkuCode().trim()); item.setTrackingCode(trim(source.trackingCode())); item.setNewSkuCode(trim(source.newSkuCode()));
item.setLabelRequirement(trim(source.labelRequirement())); item.setRemark(trim(source.remark())); item.setRelabelFileId(trim(source.relabelFileId())); item.setLabelRequirement(trim(source.labelRequirement())); item.setRemark(trim(source.remark())); item.setRelabelFileId(trim(source.relabelFileId()));
item.setRelabelFileName(trim(source.relabelFileName())); item.setRelabelFileUrl(trim(source.relabelFileUrl())); item.setRelabelFileName(trim(source.relabelFileName())); item.setRelabelFileUrl(trim(source.relabelFileUrl()));
item.setCartonMark(source.cartonMark().trim()); item.setFbaCartonMark(source.fbaCartonMark().trim()); item.setCartonMark(source.cartonMark().trim()); item.setFbaCartonMark(trim(source.fbaCartonMark()));
item.setDestinationWarehouseId(destination(source, defaultWarehouseId)); item.setDestinationWarehouseId(trim(source.destinationFbaWarehouseCode()));
item.setDestinationFbaWarehouseCode(trim(source.destinationFbaWarehouseCode()));
item.setOperationType(operationType(source));
item.setSkuRelabelItemsJson(writeSkuItems(source.skuItems()));
item.setSourceInventoryBalanceId(balance.getId());
item.setSourceInventoryPositionId(source.sourceInventoryPositionId());
item.setSourceInboundOrderId(balance.getInboundOrderId());
item.setSourceInboundOrderNo(balance.getInboundOrderNo());
item.setSourceInboundCode(balance.getInboundCode());
item.setSourceWarehouseId(balance.getOverseasWarehouseId());
item.setSourceWarehouseCode(balance.getOverseasWarehouseCode());
item.setSourceWarehouseName(balance.getOverseasWarehouseName());
item.setCreatedAt(now); items.insert(item); item.setCreatedAt(now); items.insert(item);
} }
private RelabelResponses.Summary summary(RelabelApplication value, List<RelabelItem> detailItems) { private RelabelResponses.Summary summary(RelabelApplication value, List<RelabelItem> detailItems) {
LastlegOrder order = orders.selectById(value.getLastlegOrderId()); LastlegOrder order = orders.selectById(value.getLastlegOrderId());
String skuCodes = detailItems.stream().map(RelabelItem::getSkuCode).filter(Objects::nonNull).distinct().collect(Collectors.joining(",")); String skuCodes = detailItems.stream().map(RelabelItem::getSkuCode).filter(Objects::nonNull).distinct().collect(Collectors.joining(","));
String warehouses = detailItems.stream().map(RelabelItem::getDestinationWarehouseId)
.filter(StringUtils::hasText).distinct().collect(Collectors.joining(","));
return new RelabelResponses.Summary(value.getId(), value.getRelabelNo(), value.getLastlegOrderId(), return new RelabelResponses.Summary(value.getId(), value.getRelabelNo(), value.getLastlegOrderId(),
order == null ? null : order.getOrderNo(), value.getCustomerOrderNo(), order == null ? null : order.getOrderNo(), value.getCustomerOrderNo(),
StringUtils.hasText(warehouses) ? warehouses : value.getOverseasWarehouseId(), value.getOverseasWarehouseId(),
value.getLabelDeadline(), value.getContactName(), value.getContactPhone(), value.getStatus(), value.getLabelDeadline(), value.getContactName(), value.getContactPhone(), value.getStatus(),
Objects.requireNonNullElse(value.getItemCount(), detailItems.size()), Objects.requireNonNullElse(value.getItemCount(), detailItems.size()),
Objects.requireNonNullElse(value.getTotalPackageCount(), 0), Objects.requireNonNullElse(value.getTotalPackageCount(), 0),
@@ -267,6 +343,9 @@ public class RelabelApplicationService implements WalletResultHandler {
} else { } else {
value.setWalletStatus(result.operation() + "_FAILED"); value.setWalletErrorCode(result.errorCode()); value.setWalletStatus(result.operation() + "_FAILED"); value.setWalletErrorCode(result.errorCode());
value.setWalletErrorMessage(result.errorMessage()); value.setWalletErrorMessage(result.errorMessage());
if ("FREEZE".equals(result.operation())) {
releaseInventory(value.getCustomerId(), "WALLET_CALLBACK", value);
}
} }
value.setUpdatedAt(LocalDateTime.now()); applications.updateById(value); value.setUpdatedAt(LocalDateTime.now()); applications.updateById(value);
} }
@@ -277,7 +356,11 @@ public class RelabelApplicationService implements WalletResultHandler {
value.getTotalPackageCount(), value.getNotifiedPackageCount(), value.getShipmentNo(), value.getTotalPackageCount(), value.getNotifiedPackageCount(), value.getShipmentNo(),
value.getTrackingCode(), value.getNewSkuCode(), value.getLabelRequirement(), value.getRemark(), value.getRelabelFileId(), value.getTrackingCode(), value.getNewSkuCode(), value.getLabelRequirement(), value.getRemark(), value.getRelabelFileId(),
value.getRelabelFileName(), value.getRelabelFileUrl(), value.getCartonMark(), value.getFbaCartonMark(), value.getRelabelFileName(), value.getRelabelFileUrl(), value.getCartonMark(), value.getFbaCartonMark(),
value.getDestinationWarehouseId()); StringUtils.hasText(value.getDestinationFbaWarehouseCode()) ? value.getDestinationFbaWarehouseCode() : value.getDestinationWarehouseId(),
operationType(value), readSkuItems(value.getSkuRelabelItemsJson()),
value.getSourceInventoryBalanceId(), value.getSourceInventoryPositionId(), value.getSourceInboundOrderId(),
value.getSourceInboundOrderNo(), value.getSourceInboundCode(), value.getSourceWarehouseId(),
value.getSourceWarehouseCode(), value.getSourceWarehouseName());
} }
private LastlegOrder insertTailOrder(String customerId, RelabelRequests.Create request, String requestHash, private LastlegOrder insertTailOrder(String customerId, RelabelRequests.Create request, String requestHash,
@@ -310,18 +393,61 @@ public class RelabelApplicationService implements WalletResultHandler {
return legacy == null ? List.of() : List.of(legacy); return legacy == null ? List.of() : List.of(legacy);
} }
private static String destination(RelabelRequests.Item item, String fallback) { private record SourceInventory(RelabelRequests.Item request, InventoryBalance balance) {}
String value = StringUtils.hasText(item.destinationWarehouseId()) private static long value(Long value) { return value == null ? 0L : value; }
? item.destinationWarehouseId().trim()
: trim(fallback); private void releaseInventory(String customerId, String operatorId, RelabelApplication application) {
if (!StringUtils.hasText(value)) throw badRequest("Destination warehouse is required for every carton"); for (RelabelItem item : itemList(application.getId())) {
return value; if (item.getSourceInventoryPositionId() == null || item.getTotalPackageCount() == null) continue;
inventoryCommands.release(customerId, operatorId, new InventoryCommandRequests.Move(
item.getSourceInventoryPositionId(), item.getTotalPackageCount(), application.getRelabelNo(), null,
"RELABEL:" + application.getRelabelNo(), application.getIdempotencyKey() + ":RELEASE:"
+ item.getSourceInventoryPositionId(), "贴标结束释放库存至原入仓单"));
}
} }
private List<RelabelItem> itemList(Long applicationId) { private List<RelabelItem> itemList(Long applicationId) {
return items.selectList(new LambdaQueryWrapper<RelabelItem>().eq(RelabelItem::getApplicationId, applicationId) return items.selectList(new LambdaQueryWrapper<RelabelItem>().eq(RelabelItem::getApplicationId, applicationId)
.orderByAsc(RelabelItem::getId)); .orderByAsc(RelabelItem::getId));
} }
private void validateOperation(RelabelRequests.Item item) {
String type = operationType(item);
if (!Set.of("CARTON_LABEL", "SKU_LABEL", "CARTON_AND_SKU").contains(type)) {
throw badRequest("Unsupported relabel operation type");
}
if (item.notifiedPackageCount() > item.totalPackageCount()) {
throw badRequest("Notified relocation packages cannot exceed total packages for carton " + item.cartonMark());
}
if (("CARTON_LABEL".equals(type) || "CARTON_AND_SKU".equals(type))
&& (!StringUtils.hasText(item.fbaCartonMark()) || !StringUtils.hasText(item.destinationFbaWarehouseCode()))) {
throw badRequest("Carton label changes require both FBA carton mark and destination warehouse");
}
if (("SKU_LABEL".equals(type) || "CARTON_AND_SKU".equals(type))
&& (item.skuItems() == null || item.skuItems().isEmpty())) {
throw badRequest("SKU label changes require at least one SKU detail");
}
if ("CARTON_LABEL".equals(type) && !StringUtils.hasText(item.newSkuCode())) {
throw badRequest("Carton label changes require the new SKU");
}
}
private static String operationType(RelabelRequests.Item item) {
return StringUtils.hasText(item.operationType()) ? item.operationType().trim().toUpperCase() : "CARTON_LABEL";
}
private static String operationType(RelabelItem item) {
return StringUtils.hasText(item.getOperationType()) ? item.getOperationType() : "CARTON_LABEL";
}
private String writeSkuItems(List<RelabelRequests.SkuItem> values) {
if (values == null || values.isEmpty()) return null;
try { return objectMapper.writeValueAsString(values); }
catch (Exception exception) { throw new IllegalStateException("Cannot save relabel SKU details", exception); }
}
private List<RelabelResponses.SkuItem> readSkuItems(String value) {
if (!StringUtils.hasText(value)) return List.of();
try {
return objectMapper.readValue(value, objectMapper.getTypeFactory().constructCollectionType(
List.class, RelabelResponses.SkuItem.class));
} catch (Exception exception) { throw new IllegalStateException("Cannot read relabel SKU details", exception); }
}
private RelabelApplication require(String customerId, String relabelNo) { private RelabelApplication require(String customerId, String relabelNo) {
RelabelApplication value = StringUtils.hasText(customerId) RelabelApplication value = StringUtils.hasText(customerId)
? applications.findNo(customerId, relabelNo) ? applications.findNo(customerId, relabelNo)
@@ -0,0 +1,59 @@
package com.baiyue.lastleg.returnreceipt.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests;
import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses;
import com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/merchant/v1/last-leg/return-inventory")
public class MerchantReturnInventoryController {
public static final String LIST_PERMISSION = "merchant:overseas-warehouse:return-inventory:list";
@Resource private ReturnInventoryService service;
@GetMapping
@PreAuthorize("hasAuthority('" + LIST_PERMISSION + "')")
public ApiResponse<PageResult<ReturnInventoryResponses.InventoryItem>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid ReturnInventoryRequests.Query request) {
return ApiResponse.success(service.page(customer(user), request));
}
@GetMapping("/summary")
@PreAuthorize("hasAuthority('" + LIST_PERMISSION + "')")
public ApiResponse<PageResult<ReturnInventoryResponses.SummaryItem>> summary(@AuthenticationPrincipal AuthenticatedUser user,
@Valid ReturnInventoryRequests.SummaryQuery request) {
return ApiResponse.success(service.summaryPage(customer(user), request));
}
@GetMapping("/cartons/{cartonMark}")
@PreAuthorize("hasAuthority('" + LIST_PERMISSION + "')")
public ApiResponse<ReturnInventoryResponses.CartonDetail> carton(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String cartonMark) {
return ApiResponse.success(service.carton(customer(user), cartonMark));
}
@GetMapping("/cartons/{cartonMark}/label")
@PreAuthorize("hasAuthority('" + LIST_PERMISSION + "')")
public ApiResponse<ReturnInventoryResponses.Label> label(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String cartonMark) {
return ApiResponse.success(service.label(customer(user), cartonMark));
}
private String customer(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required");
return String.valueOf(user.tenantId());
}
}
@@ -0,0 +1,32 @@
package com.baiyue.lastleg.returnreceipt.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.returnreceipt.dto.ReturnOrderRequests;
import com.baiyue.lastleg.returnreceipt.dto.ReturnOrderResponses;
import com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/merchant/v1/last-leg/return-orders")
public class MerchantReturnOrderController {
@Resource private ReturnReceiptService service;
@PostMapping public ApiResponse<ReturnOrderResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReturnOrderRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); }
@GetMapping public ApiResponse<PageResult<ReturnOrderResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid ReturnOrderRequests.Query query) { return ApiResponse.success(service.page(customer(user), query)); }
@GetMapping("/{returnNo}") public ApiResponse<ReturnOrderResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String returnNo) { return ApiResponse.success(service.detail(customer(user), returnNo)); }
private String customer(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required");
return String.valueOf(user.tenantId());
}
private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); }
}
@@ -0,0 +1,43 @@
package com.baiyue.lastleg.returnreceipt.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests;
import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses;
import com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/pda/v1/last-leg/return-inventory")
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
public class PdaReturnInventoryController {
@Resource private ReturnInventoryService service;
@GetMapping("/packing/orders")
public ApiResponse<ReturnInventoryResponses.PackingPreview> packingOrders(@AuthenticationPrincipal AuthenticatedUser user,
@Valid ReturnInventoryRequests.PackingQuery request) {
return ApiResponse.success(service.packingPreview(PdaWarehouseIdentity.require(user), request));
}
@PostMapping("/cartons/merge")
public ApiResponse<ReturnInventoryResponses.MergeResult> merge(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReturnInventoryRequests.Merge request) {
return ApiResponse.success(service.merge(PdaWarehouseIdentity.require(user), request));
}
@GetMapping("/cartons/{cartonMark}/label")
public ApiResponse<ReturnInventoryResponses.Label> label(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String cartonMark) {
return ApiResponse.success(service.label(PdaWarehouseIdentity.require(user), cartonMark));
}
}
@@ -0,0 +1,33 @@
package com.baiyue.lastleg.returnreceipt.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.returnreceipt.dto.PdaReturnReceiptRequests;
import com.baiyue.lastleg.returnreceipt.dto.PdaReturnReceiptResponses;
import com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/pda/v1/last-leg/return-receipts")
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
public class PdaReturnReceiptController {
@Resource private ReturnReceiptService service;
@PostMapping("/start") public ApiResponse<PdaReturnReceiptResponses.Start> start(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PdaReturnReceiptRequests.Start request) {
return ApiResponse.success(service.start(PdaWarehouseIdentity.require(user), request));
}
@PostMapping("/sku-candidates") public ApiResponse<PdaReturnReceiptResponses.SkuCandidates> skuCandidates(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PdaReturnReceiptRequests.SkuLookup request) {
return ApiResponse.success(service.skuCandidates(PdaWarehouseIdentity.require(user), request));
}
@PostMapping("/confirm") public ApiResponse<PdaReturnReceiptResponses.Confirmed> confirm(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PdaReturnReceiptRequests.Confirm request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.confirm(identity, request));
}
}
@@ -0,0 +1,44 @@
package com.baiyue.lastleg.returnreceipt.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests;
import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses;
import com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/platform/v1/last-leg/return-inventory")
public class PlatformReturnInventoryController {
@Resource private ReturnInventoryService service;
@GetMapping
@PreAuthorize("hasAuthority('platform:overseas-warehouse:return-inventory:list')")
public ApiResponse<PageResult<ReturnInventoryResponses.PlatformStock>> stock(@Valid ReturnInventoryRequests.PlatformQuery request) {
return ApiResponse.success(service.platformStockPage(request));
}
@GetMapping("/cartons")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:return-cartons:list')")
public ApiResponse<PageResult<ReturnInventoryResponses.PlatformCarton>> cartons(@Valid ReturnInventoryRequests.PlatformQuery request) {
return ApiResponse.success(service.platformCartonPage(request));
}
@GetMapping("/cartons/{cartonMark}")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:return-cartons:list')")
public ApiResponse<ReturnInventoryResponses.CartonDetail> carton(@PathVariable String cartonMark) {
return ApiResponse.success(service.platformCarton(cartonMark));
}
@GetMapping("/cartons/{cartonMark}/label")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:return-cartons:list')")
public ApiResponse<ReturnInventoryResponses.Label> label(@PathVariable String cartonMark) {
return ApiResponse.success(service.platformLabel(cartonMark));
}
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.returnreceipt.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.util.List;
public final class PdaReturnReceiptRequests {
private PdaReturnReceiptRequests() { }
public record Start(@NotBlank @Size(max = 128) String returnNo) { }
public record SkuLookup(@NotBlank @Size(max = 128) String skuCode) { }
public record Confirm(@NotBlank @Size(max = 128) String returnNo,
@Size(max = 128) String trackingNo,
@NotBlank @Size(max = 16) String startedBy,
@Size(max = 128) String cartonMark,
@NotEmpty @Size(max = 500) List<@Valid Item> items,
@NotBlank @Size(max = 255) String idempotencyKey) { }
public record Item(@NotBlank @Size(max = 128) String skuCode, @Positive long receivedQuantity) { }
}
@@ -0,0 +1,17 @@
package com.baiyue.lastleg.returnreceipt.dto;
import java.time.LocalDateTime;
import java.util.List;
public final class PdaReturnReceiptResponses {
private PdaReturnReceiptResponses() { }
public record Start(String returnNo, String status, List<Item> expectedItems) { }
public record SkuCandidates(String skuCode, List<Candidate> candidates) { }
public record Candidate(String returnNo, String customerId, String customerReferenceNo, String status,
long skuExpectedQuantity, long skuReceivedQuantity, long orderExpectedQuantity,
long orderReceivedQuantity) { }
public record Item(String skuCode, String productNameZh, long expectedQuantity, long receivedQuantity) { }
public record Confirmed(String returnNo, String receiptNo, String receiptBatchNo, String cartonMark, String orderStatus,
boolean fullyMatched, long expectedQuantity, long receivedQuantity,
LocalDateTime signedAt) { }
}
@@ -0,0 +1,45 @@
package com.baiyue.lastleg.returnreceipt.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import jakarta.validation.Valid;
import java.math.BigDecimal;
import java.util.List;
public final class ReturnInventoryRequests {
private ReturnInventoryRequests() { }
public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String overseasWarehouseId,
String skuCode, String cartonMark, String status, String returnNo) {
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
}
public record SummaryQuery(@Min(1) long page, @Min(1) @Max(200) long size, String overseasWarehouseId,
String skuCode) {
public SummaryQuery { if (page == 0) page = 1; if (size == 0) size = 20; }
}
public record PlatformQuery(@Min(1) long page, @Min(1) @Max(200) long size, String customerId,
String overseasWarehouseId, String skuCode, String cartonMark,
String returnNo, String status) {
public PlatformQuery { if (page == 0) page = 1; if (size == 0) size = 20; }
}
public record PackingQuery(@Size(max = 128) String returnNo) { }
public record Merge(@NotEmpty @Size(min = 1, max = 100) List<@NotBlank @Size(max = 128) String> returnNos,
@NotEmpty @Size(max = 500) List<@Valid ValidItem> items,
@NotNull @Positive BigDecimal length,
@NotNull @Positive BigDecimal width,
@NotNull @Positive BigDecimal height,
@NotNull @Positive BigDecimal weight,
@NotBlank @Size(max = 255) String idempotencyKey) { }
public record ValidItem(@NotBlank @Size(max = 128) String skuCode, @Positive long quantity) { }
}
@@ -0,0 +1,32 @@
package com.baiyue.lastleg.returnreceipt.dto;
import java.time.LocalDateTime;
import java.math.BigDecimal;
import java.util.List;
public final class ReturnInventoryResponses {
private ReturnInventoryResponses() { }
public record InventoryItem(String overseasWarehouseId, String cartonMark, String sourceBatchNo, String status, String skuCode,
String productNameZh, long quantity, int cartonSkuTypes, long cartonTotalQuantity,
LocalDateTime receivedAt) { }
public record SummaryItem(String overseasWarehouseId, String skuCode, String productNameZh,
long availableQuantity, long cartonQuantity, LocalDateTime updatedAt) { }
public record CartonDetail(String cartonMark, String overseasWarehouseId, String sourceBatchNo, String sourceReturnNo, String status,
int skuTypes, long totalQuantity, BigDecimal length, BigDecimal width, BigDecimal height,
BigDecimal weight, LocalDateTime receivedAt, List<CartonItem> items) { }
public record CartonItem(String skuCode, String productNameZh, long quantity) { }
public record Label(String cartonMark, String overseasWarehouseId, String customerId, String sourceBatchNo, String sourceReturnNo,
long totalQuantity, BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight,
List<CartonItem> items, LocalDateTime generatedAt, String printableHtml) { }
public record MergeResult(String cartonMark, String skuCode, long totalQuantity, Label label) { }
public record PackingOrder(String returnNo, String customerId, String customerReferenceNo, String status) { }
public record PackingSku(String skuCode, String productNameZh, long availableQuantity) { }
public record PackingPreview(List<PackingOrder> orders, String customerId, List<PackingSku> skus) { }
public record PlatformStock(String customerId, String overseasWarehouseId, String skuCode, String productNameZh,
long availableQuantity, long cartonQuantity, LocalDateTime updatedAt) { }
public record PlatformCarton(String customerId, String overseasWarehouseId, String cartonMark, String sourceReturnNo,
String sourceBatchNo, String status, int skuTypes, long totalQuantity,
BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight,
LocalDateTime createdAt) { }
}
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.returnreceipt.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.util.List;
public final class ReturnOrderRequests {
private ReturnOrderRequests() { }
public record Create(@NotBlank @Size(max = 128) String overseasWarehouseId,
@Size(max = 128) String customerReferenceNo,
@Size(max = 1000) String remark,
@NotBlank @Size(max = 255) String idempotencyKey,
@NotEmpty @Size(max = 500) List<@Valid Item> items) { }
public record Item(@NotBlank @Size(max = 128) String skuCode,
@Size(max = 255) String productNameZh,
@Positive long expectedQuantity) { }
public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String returnNo,
String trackingNo, String skuCode, String status) {
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
}
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.returnreceipt.dto;
import java.time.LocalDateTime;
import java.util.List;
public final class ReturnOrderResponses {
private ReturnOrderResponses() { }
public record Summary(String returnNo, String overseasWarehouseId, String customerReferenceNo, String status,
int skuTypes, long expectedQuantity, long receivedQuantity, LocalDateTime createdAt) { }
public record Item(String skuCode, String productNameZh, long expectedQuantity,
long receivedQuantity) { }
public record Detail(Summary order, String remark, List<Item> items, List<Receipt> receipts) { }
public record Receipt(String receiptNo, String receiptBatchNo, String cartonMark, String trackingNo, String startedBy,
long receivedQuantity, String operatorName, LocalDateTime signedAt) { }
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.returnreceipt.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;
/** Customer-facing available return-inventory balance. */
@Data
@TableName("ll_return_inventory_balance")
public class ReturnInventoryBalance {
@TableId(type = IdType.INPUT) private Long id;
private String customerId;
private String overseasWarehouseId;
private String skuCode;
private String productNameZh;
private Long availableQuantity;
private Long cartonQuantity;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,32 @@
package com.baiyue.lastleg.returnreceipt.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;
import java.math.BigDecimal;
/** Physical carton that holds return inventory. */
@Data
@TableName("ll_return_inventory_carton")
public class ReturnInventoryCarton {
@TableId(type = IdType.INPUT) private Long id;
private String customerId;
private String overseasWarehouseId;
private Long sourceReceiptId;
private String sourceBatchNo;
private String sourceReturnNo;
private String cartonMark;
private String status;
private Integer skuTypes;
private Long totalQuantity;
private BigDecimal length;
private BigDecimal width;
private BigDecimal height;
private BigDecimal weight;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private LocalDateTime mergedAt;
}
@@ -0,0 +1,17 @@
package com.baiyue.lastleg.returnreceipt.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@Data
@TableName("ll_return_inventory_carton_item")
public class ReturnInventoryCartonItem {
@TableId(type = IdType.INPUT) private Long id;
private Long cartonId;
private String customerId;
private String skuCode;
private String productNameZh;
private Long quantity;
}
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.returnreceipt.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_return_inventory_operation")
public class ReturnInventoryOperation {
@TableId(type = IdType.INPUT) private Long id;
private String operatorId;
private String idempotencyKey;
private String requestHash;
private String operationType;
private Long resultCartonId;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,28 @@
package com.baiyue.lastleg.returnreceipt.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_return_order")
public class ReturnOrder {
@TableId(type = IdType.INPUT) private Long id;
private String customerId;
private String returnNo;
private String overseasWarehouseId;
private String customerReferenceNo;
private String status;
private Integer expectedSkuTypes;
private Long expectedQuantity;
private Long receivedQuantity;
private String remark;
private String idempotencyKey;
private String requestHash;
private String createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.returnreceipt.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_return_order_item")
public class ReturnOrderItem {
@TableId(type = IdType.INPUT) private Long id;
private Long returnOrderId;
private String customerId;
private String skuCode;
private String productNameZh;
private Long expectedQuantity;
private Long receivedQuantity;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,29 @@
package com.baiyue.lastleg.returnreceipt.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_return_receipt")
public class ReturnReceipt {
@TableId(type = IdType.INPUT) private Long id;
private Long returnOrderId;
private String customerId;
private String receiptNo;
private String receiptBatchNo;
private String cartonMark;
private String trackingNo;
private String boundBy;
private String status;
private Long receivedQuantity;
private String operatorId;
private String operatorName;
private String idempotencyKey;
private String requestHash;
private LocalDateTime signedAt;
private LocalDateTime createdAt;
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.returnreceipt.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;
/** A completed receipt starts the storage-billing clock, even when the whole return order remains partial. */
@Data
@TableName("ll_return_receipt_batch")
public class ReturnReceiptBatch {
@TableId(type = IdType.INPUT) private Long id;
private Long returnOrderId;
private Long receiptId;
private String customerId;
private String batchNo;
private String overseasWarehouseId;
private String storageBillingStatus;
private LocalDateTime storageStartedAt;
private LocalDateTime createdAt;
}
@@ -0,0 +1,17 @@
package com.baiyue.lastleg.returnreceipt.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@Data
@TableName("ll_return_receipt_item")
public class ReturnReceiptItem {
@TableId(type = IdType.INPUT) private Long id;
private Long receiptId;
private Long returnOrderItemId;
private String customerId;
private String skuCode;
private Long receivedQuantity;
}
@@ -0,0 +1,48 @@
package com.baiyue.lastleg.returnreceipt.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryBalance;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface ReturnInventoryBalanceMapper extends BaseMapper<ReturnInventoryBalance> {
@Insert("INSERT INTO ll_return_inventory_balance (id,customer_id,overseas_warehouse_id,sku_code,product_name_zh,available_quantity,carton_quantity,created_at,updated_at) " +
"VALUES (#{id},#{customerId},#{warehouseId},#{skuCode},#{productNameZh},#{quantity},#{cartons},NOW(3),NOW(3)) " +
"ON DUPLICATE KEY UPDATE available_quantity=available_quantity+VALUES(available_quantity),carton_quantity=carton_quantity+VALUES(carton_quantity), " +
"product_name_zh=COALESCE(NULLIF(VALUES(product_name_zh),''),product_name_zh),updated_at=NOW(3)")
int adjust(@Param("id") long id, @Param("customerId") String customerId, @Param("warehouseId") String warehouseId,
@Param("skuCode") String skuCode, @Param("productNameZh") String productNameZh,
@Param("quantity") long quantity, @Param("cartons") long cartons);
@Select("<script>SELECT COUNT(*) FROM ll_return_inventory_balance WHERE customer_id=#{customerId} " +
"<if test='warehouseId != null and warehouseId != \"\"'>AND overseas_warehouse_id=#{warehouseId} </if>" +
"<if test='skuCode != null and skuCode != \"\"'>AND sku_code LIKE CONCAT('%',#{skuCode},'%') </if></script>")
long countPage(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId,
@Param("skuCode") String skuCode);
@Select("<script>SELECT * FROM ll_return_inventory_balance WHERE customer_id=#{customerId} " +
"<if test='warehouseId != null and warehouseId != \"\"'>AND overseas_warehouse_id=#{warehouseId} </if>" +
"<if test='skuCode != null and skuCode != \"\"'>AND sku_code LIKE CONCAT('%',#{skuCode},'%') </if>" +
"ORDER BY updated_at DESC,id DESC LIMIT #{offset},#{size}</script>")
List<ReturnInventoryBalance> page(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId,
@Param("skuCode") String skuCode, @Param("offset") long offset, @Param("size") long size);
@Select("<script>SELECT COUNT(*) FROM ll_return_inventory_balance WHERE 1=1 " +
"<if test='customerId != null and customerId != \"\"'>AND customer_id LIKE CONCAT('%',#{customerId},'%') </if>" +
"<if test='warehouseId != null and warehouseId != \"\"'>AND overseas_warehouse_id=#{warehouseId} </if>" +
"<if test='skuCode != null and skuCode != \"\"'>AND sku_code LIKE CONCAT('%',#{skuCode},'%') </if></script>")
long platformCount(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId, @Param("skuCode") String skuCode);
@Select("<script>SELECT * FROM ll_return_inventory_balance WHERE 1=1 " +
"<if test='customerId != null and customerId != \"\"'>AND customer_id LIKE CONCAT('%',#{customerId},'%') </if>" +
"<if test='warehouseId != null and warehouseId != \"\"'>AND overseas_warehouse_id=#{warehouseId} </if>" +
"<if test='skuCode != null and skuCode != \"\"'>AND sku_code LIKE CONCAT('%',#{skuCode},'%') </if>" +
"ORDER BY updated_at DESC,id DESC LIMIT #{offset},#{size}</script>")
List<ReturnInventoryBalance> platformPage(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId,
@Param("skuCode") String skuCode, @Param("offset") long offset, @Param("size") long size);
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.returnreceipt.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface ReturnInventoryCartonItemMapper extends BaseMapper<ReturnInventoryCartonItem> {
@Select("SELECT * FROM ll_return_inventory_carton_item WHERE carton_id=#{cartonId} ORDER BY sku_code")
List<ReturnInventoryCartonItem> listCarton(@Param("cartonId") long cartonId);
}
@@ -0,0 +1,45 @@
package com.baiyue.lastleg.returnreceipt.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface ReturnInventoryCartonMapper extends BaseMapper<ReturnInventoryCarton> {
@Select("SELECT * FROM ll_return_inventory_carton WHERE carton_mark=#{cartonMark} LIMIT 1")
ReturnInventoryCarton findMark(@Param("cartonMark") String cartonMark);
@Select("SELECT * FROM ll_return_inventory_carton WHERE carton_mark=#{cartonMark} LIMIT 1 FOR UPDATE")
ReturnInventoryCarton lockMark(@Param("cartonMark") String cartonMark);
@Select("SELECT * FROM ll_return_inventory_carton WHERE source_receipt_id=#{receiptId} LIMIT 1")
ReturnInventoryCarton findReceipt(@Param("receiptId") long receiptId);
@Select("SELECT * FROM ll_return_inventory_carton WHERE customer_id=#{customerId} AND carton_mark=#{cartonMark} LIMIT 1")
ReturnInventoryCarton findCustomerMark(@Param("customerId") String customerId, @Param("cartonMark") String cartonMark);
@Select("<script>SELECT COUNT(*) FROM ll_return_inventory_carton c WHERE 1=1 " +
"<if test='customerId != null and customerId != \"\"'>AND c.customer_id LIKE CONCAT('%',#{customerId},'%') </if>" +
"<if test='warehouseId != null and warehouseId != \"\"'>AND c.overseas_warehouse_id=#{warehouseId} </if>" +
"<if test='cartonMark != null and cartonMark != \"\"'>AND c.carton_mark LIKE CONCAT('%',#{cartonMark},'%') </if>" +
"<if test='returnNo != null and returnNo != \"\"'>AND c.source_return_no LIKE CONCAT('%',#{returnNo},'%') </if>" +
"<if test='status != null and status != \"\"'>AND c.status=#{status} </if>" +
"<if test='skuCode != null and skuCode != \"\"'>AND EXISTS(SELECT 1 FROM ll_return_inventory_carton_item i WHERE i.carton_id=c.id AND i.sku_code LIKE CONCAT('%',#{skuCode},'%')) </if></script>")
long platformCount(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId,
@Param("cartonMark") String cartonMark, @Param("returnNo") String returnNo, @Param("status") String status, @Param("skuCode") String skuCode);
@Select("<script>SELECT c.* FROM ll_return_inventory_carton c WHERE 1=1 " +
"<if test='customerId != null and customerId != \"\"'>AND c.customer_id LIKE CONCAT('%',#{customerId},'%') </if>" +
"<if test='warehouseId != null and warehouseId != \"\"'>AND c.overseas_warehouse_id=#{warehouseId} </if>" +
"<if test='cartonMark != null and cartonMark != \"\"'>AND c.carton_mark LIKE CONCAT('%',#{cartonMark},'%') </if>" +
"<if test='returnNo != null and returnNo != \"\"'>AND c.source_return_no LIKE CONCAT('%',#{returnNo},'%') </if>" +
"<if test='status != null and status != \"\"'>AND c.status=#{status} </if>" +
"<if test='skuCode != null and skuCode != \"\"'>AND EXISTS(SELECT 1 FROM ll_return_inventory_carton_item i WHERE i.carton_id=c.id AND i.sku_code LIKE CONCAT('%',#{skuCode},'%')) </if>" +
"ORDER BY c.created_at DESC,c.id DESC LIMIT #{offset},#{size}</script>")
List<ReturnInventoryCarton> platformPage(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId,
@Param("cartonMark") String cartonMark, @Param("returnNo") String returnNo, @Param("status") String status,
@Param("skuCode") String skuCode, @Param("offset") long offset, @Param("size") long size);
}
@@ -0,0 +1,13 @@
package com.baiyue.lastleg.returnreceipt.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryOperation;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface ReturnInventoryOperationMapper extends BaseMapper<ReturnInventoryOperation> {
@Select("SELECT * FROM ll_return_inventory_operation WHERE operator_id=#{operatorId} AND idempotency_key=#{key} LIMIT 1")
ReturnInventoryOperation findKey(@Param("operatorId") String operatorId, @Param("key") String key);
}
@@ -0,0 +1,36 @@
package com.baiyue.lastleg.returnreceipt.mapper;
import com.baiyue.lastleg.returnreceipt.model.ReturnInventoryRow;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface ReturnInventoryQueryMapper {
@Select("<script>SELECT COUNT(*) FROM ll_return_inventory_carton c JOIN ll_return_inventory_carton_item i ON i.carton_id=c.id " +
"WHERE c.customer_id=#{customerId} " +
"<if test='status != null and status != \"\"'>AND c.status=#{status} </if>" +
"<if test='returnNo != null and returnNo != \"\"'>AND (c.source_return_no LIKE CONCAT('%',#{returnNo},'%') OR EXISTS (SELECT 1 FROM ll_return_receipt r JOIN ll_return_order o ON o.id=r.return_order_id WHERE r.id=c.source_receipt_id AND o.return_no LIKE CONCAT('%',#{returnNo},'%'))) </if>" +
"<if test='warehouseId != null and warehouseId != \"\"'>AND c.overseas_warehouse_id=#{warehouseId} </if>" +
"<if test='skuCode != null and skuCode != \"\"'>AND i.sku_code LIKE CONCAT('%',#{skuCode},'%') </if>" +
"<if test='cartonMark != null and cartonMark != \"\"'>AND c.carton_mark LIKE CONCAT('%',#{cartonMark},'%') </if></script>")
long count(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId,
@Param("skuCode") String skuCode, @Param("cartonMark") String cartonMark,
@Param("status") String status, @Param("returnNo") String returnNo);
@Select("<script>SELECT c.id carton_id,c.overseas_warehouse_id,c.carton_mark,c.source_batch_no,c.status,c.sku_types,c.total_quantity,c.created_at, " +
"i.sku_code,i.product_name_zh,i.quantity FROM ll_return_inventory_carton c JOIN ll_return_inventory_carton_item i ON i.carton_id=c.id " +
"WHERE c.customer_id=#{customerId} " +
"<if test='status != null and status != \"\"'>AND c.status=#{status} </if>" +
"<if test='returnNo != null and returnNo != \"\"'>AND (c.source_return_no LIKE CONCAT('%',#{returnNo},'%') OR EXISTS (SELECT 1 FROM ll_return_receipt r JOIN ll_return_order o ON o.id=r.return_order_id WHERE r.id=c.source_receipt_id AND o.return_no LIKE CONCAT('%',#{returnNo},'%'))) </if>" +
"<if test='warehouseId != null and warehouseId != \"\"'>AND c.overseas_warehouse_id=#{warehouseId} </if>" +
"<if test='skuCode != null and skuCode != \"\"'>AND i.sku_code LIKE CONCAT('%',#{skuCode},'%') </if>" +
"<if test='cartonMark != null and cartonMark != \"\"'>AND c.carton_mark LIKE CONCAT('%',#{cartonMark},'%') </if>" +
"ORDER BY c.created_at DESC,c.id DESC,i.sku_code LIMIT #{offset},#{size}</script>")
List<ReturnInventoryRow> page(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId,
@Param("skuCode") String skuCode, @Param("cartonMark") String cartonMark,
@Param("status") String status, @Param("returnNo") String returnNo,
@Param("offset") long offset, @Param("size") long size);
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.returnreceipt.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.returnreceipt.entity.ReturnOrderItem;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface ReturnOrderItemMapper extends BaseMapper<ReturnOrderItem> {
@Select("SELECT * FROM ll_return_order_item WHERE return_order_id=#{orderId} ORDER BY sku_code,id")
List<ReturnOrderItem> listOrder(@Param("orderId") Long orderId);
@Select("SELECT * FROM ll_return_order_item WHERE return_order_id=#{orderId} ORDER BY sku_code,id FOR UPDATE")
List<ReturnOrderItem> lockOrder(@Param("orderId") Long orderId);
@Select("SELECT i.* FROM ll_return_order_item i JOIN ll_return_order o ON o.id=i.return_order_id " +
"WHERE o.overseas_warehouse_id=#{warehouseId} AND o.status IN ('PENDING_RECEIPT','PARTIALLY_RECEIVED') " +
"AND i.sku_code=#{skuCode} AND i.received_quantity<i.expected_quantity ORDER BY o.created_at,o.id LIMIT 50")
List<ReturnOrderItem> activeSkuCandidates(@Param("warehouseId") String warehouseId, @Param("skuCode") String skuCode);
}
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.returnreceipt.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.returnreceipt.entity.ReturnOrder;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface ReturnOrderMapper extends BaseMapper<ReturnOrder> {
@Select("SELECT * FROM ll_return_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1")
ReturnOrder findKey(@Param("customerId") String customerId, @Param("key") String key);
@Select("SELECT * FROM ll_return_order WHERE customer_id=#{customerId} AND return_no=#{returnNo} LIMIT 1")
ReturnOrder findNo(@Param("customerId") String customerId, @Param("returnNo") String returnNo);
@Select("SELECT * FROM ll_return_order WHERE return_no=#{returnNo} LIMIT 1 FOR UPDATE")
ReturnOrder lockNo(@Param("returnNo") String returnNo);
@Select("SELECT * FROM ll_return_order WHERE return_no=#{returnNo} AND overseas_warehouse_id=#{warehouseId} " +
"AND status IN ('PENDING_RECEIPT','PARTIALLY_RECEIVED') LIMIT 1")
ReturnOrder findActiveNo(@Param("warehouseId") String warehouseId, @Param("returnNo") String returnNo);
@Select("SELECT * FROM ll_return_order WHERE overseas_warehouse_id=#{warehouseId} AND status='COMPLETED' " +
"AND return_no LIKE CONCAT('%',#{returnNo},'%') ORDER BY created_at DESC,id DESC LIMIT 100")
List<ReturnOrder> findCompletedLike(@Param("warehouseId") String warehouseId, @Param("returnNo") String returnNo);
@Select("<script>SELECT * FROM ll_return_order WHERE overseas_warehouse_id=#{warehouseId} AND return_no IN " +
"<foreach collection='returnNos' item='returnNo' open='(' separator=',' close=')'>#{returnNo}</foreach></script>")
List<ReturnOrder> findByNos(@Param("warehouseId") String warehouseId, @Param("returnNos") List<String> returnNos);
}
@@ -0,0 +1,8 @@
package com.baiyue.lastleg.returnreceipt.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.returnreceipt.entity.ReturnReceiptBatch;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ReturnReceiptBatchMapper extends BaseMapper<ReturnReceiptBatch> { }
@@ -0,0 +1,8 @@
package com.baiyue.lastleg.returnreceipt.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.returnreceipt.entity.ReturnReceiptItem;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ReturnReceiptItemMapper extends BaseMapper<ReturnReceiptItem> { }
@@ -0,0 +1,17 @@
package com.baiyue.lastleg.returnreceipt.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.returnreceipt.entity.ReturnReceipt;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface ReturnReceiptMapper extends BaseMapper<ReturnReceipt> {
@Select("SELECT * FROM ll_return_receipt WHERE operator_id=#{operatorId} AND idempotency_key=#{key} LIMIT 1")
ReturnReceipt findKey(@Param("operatorId") String operatorId, @Param("key") String key);
@Select("SELECT * FROM ll_return_receipt WHERE return_order_id=#{orderId} ORDER BY signed_at DESC,id DESC")
List<ReturnReceipt> listOrder(@Param("orderId") Long orderId);
}
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.returnreceipt.model;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class ReturnInventoryRow {
private Long cartonId;
private String overseasWarehouseId;
private String cartonMark;
private String sourceBatchNo;
private String status;
private Integer skuTypes;
private Long totalQuantity;
private LocalDateTime createdAt;
private String skuCode;
private String productNameZh;
private Long quantity;
}
@@ -0,0 +1,320 @@
package com.baiyue.lastleg.returnreceipt.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests;
import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryOperation;
import com.baiyue.lastleg.returnreceipt.entity.ReturnOrder;
import com.baiyue.lastleg.returnreceipt.entity.ReturnReceipt;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryBalanceMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryOperationMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryQueryMapper;
import com.baiyue.lastleg.returnreceipt.model.ReturnInventoryRow;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
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.util.StringUtils;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
/** Return stock is deliberately separate from normal inbound stock because it has no putaway location yet. */
@Service
public class ReturnInventoryService {
public static final String AVAILABLE = "AVAILABLE";
public static final String MERGED = "MERGED";
@Resource private ReturnInventoryCartonMapper cartons;
@Resource private ReturnInventoryCartonItemMapper cartonItems;
@Resource private ReturnInventoryBalanceMapper balances;
@Resource private ReturnInventoryOperationMapper operations;
@Resource private ReturnInventoryQueryMapper queries;
@Resource private LastlegOrderNoGenerator numbers;
@Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper orders;
@Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptMapper receiptMapper;
@Transactional
public ReturnInventoryCarton createReceiptCarton(ReturnOrder order, ReturnReceipt receipt,
String requestedMark, List<ReceiptLine> lines) {
if (lines.isEmpty()) throw new IllegalArgumentException("退货签收库存明细不能为空");
String cartonMark = text(requestedMark);
long cartonId = IdWorker.getId();
if (cartonMark == null) cartonMark = numbers.next("RETURN_CARTON", order.getOverseasWarehouseId(), cartonId);
if (cartons.findMark(cartonMark) != null) throw conflict("箱号已存在: " + cartonMark);
LocalDateTime now = LocalDateTime.now();
long total = lines.stream().mapToLong(ReceiptLine::quantity).sum();
ReturnInventoryCarton carton = new ReturnInventoryCarton();
carton.setId(cartonId); carton.setCustomerId(order.getCustomerId()); carton.setOverseasWarehouseId(order.getOverseasWarehouseId());
carton.setSourceReceiptId(receipt.getId()); carton.setSourceBatchNo(receipt.getReceiptBatchNo()); carton.setCartonMark(cartonMark);
carton.setStatus(AVAILABLE); carton.setSkuTypes(lines.size()); carton.setTotalQuantity(total); carton.setCreatedAt(now); carton.setUpdatedAt(now);
cartons.insert(carton);
for (ReceiptLine line : lines) {
ReturnInventoryCartonItem item = new ReturnInventoryCartonItem();
item.setId(IdWorker.getId()); item.setCartonId(cartonId); item.setCustomerId(order.getCustomerId());
item.setSkuCode(line.skuCode()); item.setProductNameZh(text(line.productNameZh())); item.setQuantity(line.quantity());
cartonItems.insert(item);
balances.adjust(IdWorker.getId(), order.getCustomerId(), order.getOverseasWarehouseId(), line.skuCode(),
text(line.productNameZh()), line.quantity(), 1);
}
return carton;
}
@Transactional(readOnly = true)
public PageResult<ReturnInventoryResponses.InventoryItem> page(String customerId, ReturnInventoryRequests.Query request) {
String warehouseId = text(request.overseasWarehouseId());
String sku = text(request.skuCode());
String carton = text(request.cartonMark());
long total = queries.count(customerId, warehouseId, sku, carton, text(request.status()), text(request.returnNo()));
List<ReturnInventoryResponses.InventoryItem> records = total == 0 ? List.of() : queries.page(customerId, warehouseId, sku,
carton, text(request.status()), text(request.returnNo()), (request.page() - 1) * request.size(), request.size()).stream().map(this::item).toList();
return new PageResult<>(records, request.page(), request.size(), total, (total + request.size() - 1) / request.size());
}
@Transactional(readOnly = true)
public PageResult<ReturnInventoryResponses.SummaryItem> summaryPage(String customerId, ReturnInventoryRequests.SummaryQuery request) {
String warehouseId = text(request.overseasWarehouseId());
String sku = text(request.skuCode());
long total = balances.countPage(customerId, warehouseId, sku);
List<ReturnInventoryResponses.SummaryItem> records = total == 0 ? List.of() : balances.page(customerId, warehouseId, sku,
(request.page() - 1) * request.size(), request.size()).stream().map(this::summary).toList();
return new PageResult<>(records, request.page(), request.size(), total, (total + request.size() - 1) / request.size());
}
@Transactional(readOnly = true)
public PageResult<ReturnInventoryResponses.PlatformStock> platformStockPage(ReturnInventoryRequests.PlatformQuery request) {
String customerId = text(request.customerId()); String warehouseId = text(request.overseasWarehouseId()); String sku = text(request.skuCode());
long total = balances.platformCount(customerId, warehouseId, sku);
List<ReturnInventoryResponses.PlatformStock> records = total == 0 ? List.of() : balances.platformPage(customerId, warehouseId, sku,
(request.page() - 1) * request.size(), request.size()).stream().map(balance -> new ReturnInventoryResponses.PlatformStock(
balance.getCustomerId(), balance.getOverseasWarehouseId(), balance.getSkuCode(), balance.getProductNameZh(),
value(balance.getAvailableQuantity()), value(balance.getCartonQuantity()), balance.getUpdatedAt())).toList();
return new PageResult<>(records, request.page(), request.size(), total, (total + request.size() - 1) / request.size());
}
@Transactional(readOnly = true)
public PageResult<ReturnInventoryResponses.PlatformCarton> platformCartonPage(ReturnInventoryRequests.PlatformQuery request) {
String customerId = text(request.customerId()); String warehouseId = text(request.overseasWarehouseId());
String cartonMark = text(request.cartonMark()); String returnNo = text(request.returnNo()); String status = normStatus(request.status()); String sku = text(request.skuCode());
long total = cartons.platformCount(customerId, warehouseId, cartonMark, returnNo, status, sku);
List<ReturnInventoryResponses.PlatformCarton> records = total == 0 ? List.of() : cartons.platformPage(customerId, warehouseId, cartonMark,
returnNo, status, sku, (request.page() - 1) * request.size(), request.size()).stream().map(carton -> new ReturnInventoryResponses.PlatformCarton(
carton.getCustomerId(), carton.getOverseasWarehouseId(), carton.getCartonMark(), carton.getSourceReturnNo(), carton.getSourceBatchNo(),
carton.getStatus(), carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()), carton.getLength(),
carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt())).toList();
return new PageResult<>(records, request.page(), request.size(), total, (total + request.size() - 1) / request.size());
}
@Transactional(readOnly = true)
public ReturnInventoryResponses.CartonDetail platformCarton(String cartonMark) {
ReturnInventoryCarton carton = cartons.findMark(required(cartonMark, "箱号不能为空"));
if (carton == null) throw notFound("退货箱不存在");
return cartonDetail(carton);
}
@Transactional(readOnly = true)
public ReturnInventoryResponses.Label platformLabel(String cartonMark) {
ReturnInventoryCarton carton = cartons.findMark(required(cartonMark, "箱号不能为空"));
if (carton == null) throw notFound("退货箱不存在");
return label(carton);
}
@Transactional(readOnly = true)
public ReturnInventoryResponses.CartonDetail carton(String customerId, String cartonMark) {
ReturnInventoryCarton carton = cartons.findCustomerMark(customerId, required(cartonMark, "箱号不能为空"));
if (carton == null) throw notFound("退货箱不存在");
return cartonDetail(carton);
}
@Transactional(readOnly = true)
public ReturnInventoryResponses.Label label(String customerId, String cartonMark) {
ReturnInventoryCarton carton = cartons.findCustomerMark(customerId, required(cartonMark, "箱号不能为空"));
if (carton == null) throw notFound("退货箱不存在");
return label(carton);
}
@Transactional(readOnly = true)
public ReturnInventoryResponses.Label label(PdaWarehouseIdentity identity, String cartonMark) {
ReturnInventoryCarton carton = cartons.findMark(required(cartonMark, "箱号不能为空"));
if (carton == null || !String.valueOf(identity.overseasWarehouseId()).equals(carton.getOverseasWarehouseId())) throw notFound("当前仓没有该退货箱");
return label(carton);
}
@Transactional(readOnly = true)
public ReturnInventoryResponses.PackingPreview packingPreview(PdaWarehouseIdentity identity, ReturnInventoryRequests.PackingQuery request) {
List<ReturnOrder> candidates = orders.findCompletedLike(String.valueOf(identity.overseasWarehouseId()), text(request.returnNo()) == null ? "" : text(request.returnNo()));
Map<String, ReturnOrder> unique = new LinkedHashMap<>();
candidates.forEach(order -> unique.put(order.getReturnNo(), order));
List<ReturnOrder> selected = unique.values().stream().limit(100).toList();
String customerId = selected.size() == 1 ? selected.get(0).getCustomerId() : null;
Map<String, Long> quantities = new LinkedHashMap<>();
Map<String, String> names = new LinkedHashMap<>();
for (ReturnOrder order : selected) {
for (ReturnReceipt receipt : receiptMapper.listOrder(order.getId())) {
ReturnInventoryCarton carton = cartons.findReceipt(receipt.getId());
if (carton == null || !AVAILABLE.equals(carton.getStatus())) continue;
for (ReturnInventoryCartonItem item : cartonItems.listCarton(carton.getId())) {
quantities.merge(item.getSkuCode(), value(item.getQuantity()), Math::addExact);
names.putIfAbsent(item.getSkuCode(), item.getProductNameZh());
}
}
}
return new ReturnInventoryResponses.PackingPreview(selected.stream().map(order ->
new ReturnInventoryResponses.PackingOrder(order.getReturnNo(), order.getCustomerId(), order.getCustomerReferenceNo(), order.getStatus())).toList(),
customerId, quantities.entrySet().stream().map(entry -> new ReturnInventoryResponses.PackingSku(entry.getKey(), names.get(entry.getKey()), entry.getValue())).toList());
}
@Transactional
public ReturnInventoryResponses.MergeResult merge(PdaWarehouseIdentity identity, ReturnInventoryRequests.Merge request) {
String requestHash = hash(request);
String key = text(request.idempotencyKey());
ReturnInventoryOperation replay = operations.findKey(identity.operatorKey(), key);
if (replay != null) return replayMerge(replay, requestHash);
LocalDateTime now = LocalDateTime.now();
ReturnInventoryOperation operation = new ReturnInventoryOperation();
operation.setId(IdWorker.getId()); operation.setOperatorId(identity.operatorKey()); operation.setIdempotencyKey(key);
operation.setRequestHash(requestHash); operation.setOperationType("MERGE"); operation.setCreatedAt(now); operation.setUpdatedAt(now);
try { operations.insert(operation); }
catch (DuplicateKeyException concurrent) {
ReturnInventoryOperation existing = operations.findKey(identity.operatorKey(), key);
if (existing != null) return replayMerge(existing, requestHash);
throw concurrent;
}
List<String> returnNos = request.returnNos().stream().map(this::requiredReturnNo).distinct().toList();
List<ReturnOrder> selectedOrders = orders.findByNos(String.valueOf(identity.overseasWarehouseId()), returnNos);
if (selectedOrders.size() != returnNos.size()) throw notFound("存在不属于当前仓或不存在的退货单");
String customerId = selectedOrders.get(0).getCustomerId();
if (selectedOrders.stream().anyMatch(order -> !"COMPLETED".equals(order.getStatus()))) throw conflict("只能合并已完成签收的退货单");
if (selectedOrders.stream().anyMatch(order -> !customerId.equals(order.getCustomerId()))) throw conflict("不同客户的退货单禁止合箱");
List<ReturnInventoryCarton> sources = new ArrayList<>();
for (ReturnOrder order : selectedOrders) for (ReturnReceipt receipt : receiptMapper.listOrder(order.getId())) {
ReturnInventoryCarton carton = cartons.findReceipt(receipt.getId());
if (carton != null && AVAILABLE.equals(carton.getStatus())) sources.add(cartons.lockMark(carton.getCartonMark()));
}
Map<String, Long> requested = new LinkedHashMap<>();
Map<String, String> productNames = new LinkedHashMap<>();
for (ReturnInventoryRequests.ValidItem item : request.items()) {
String sku = required(item.skuCode(), "SKU 不能为空").toUpperCase(java.util.Locale.ROOT);
if (requested.put(sku, item.quantity()) != null) throw badRequest("合箱 SKU 不能重复");
}
Map<String, Long> remaining = new LinkedHashMap<>(requested);
Map<String, Integer> emptiedBySku = new LinkedHashMap<>();
long total = 0;
for (ReturnInventoryCarton source : sources) {
List<ReturnInventoryCartonItem> sourceItems = cartonItems.listCarton(source.getId());
List<String> originalSkus = sourceItems.stream().map(item -> item.getSkuCode().toUpperCase(java.util.Locale.ROOT)).toList();
long sourceTotal = 0; int sourceTypes = 0;
for (ReturnInventoryCartonItem item : sourceItems) {
String sku = item.getSkuCode().toUpperCase(java.util.Locale.ROOT);
long take = Math.min(value(remaining.get(sku)), value(item.getQuantity()));
if (take > 0) {
remaining.put(sku, remaining.get(sku) - take); total = Math.addExact(total, take);
productNames.putIfAbsent(sku, item.getProductNameZh());
item.setQuantity(item.getQuantity() - take);
if (item.getQuantity() == 0) cartonItems.deleteById(item.getId()); else cartonItems.updateById(item);
}
if (item.getQuantity() > 0) { sourceTotal = Math.addExact(sourceTotal, item.getQuantity()); sourceTypes++; }
}
if (sourceTotal == 0) {
source.setStatus(MERGED); source.setMergedAt(now);
originalSkus.forEach(sku -> emptiedBySku.merge(sku, 1, Integer::sum));
}
source.setTotalQuantity(sourceTotal); source.setSkuTypes(sourceTypes); source.setUpdatedAt(now); cartons.updateById(source);
}
if (remaining.values().stream().anyMatch(value -> value > 0)) throw conflict("合箱数量超过对应退货单的可用库存");
if (total <= 0) throw badRequest("至少填写一个大于 0 的 SKU 合箱数量");
long targetId = IdWorker.getId();
ReturnInventoryCarton target = new ReturnInventoryCarton();
target.setId(targetId); target.setCustomerId(customerId); target.setOverseasWarehouseId(sources.get(0).getOverseasWarehouseId());
target.setCartonMark(numbers.next("RETURN_CARTON", target.getOverseasWarehouseId(), targetId)); target.setSourceBatchNo("MERGED"); target.setSourceReturnNo(String.join(",", returnNos)); target.setStatus(AVAILABLE);
target.setSkuTypes(requested.size()); target.setTotalQuantity(total); target.setLength(request.length()); target.setWidth(request.width()); target.setHeight(request.height()); target.setWeight(request.weight()); target.setCreatedAt(now); target.setUpdatedAt(now); cartons.insert(target);
for (Map.Entry<String, Long> entry : requested.entrySet()) {
ReturnInventoryCartonItem targetItem = new ReturnInventoryCartonItem();
targetItem.setId(IdWorker.getId()); targetItem.setCartonId(targetId); targetItem.setCustomerId(customerId); targetItem.setSkuCode(entry.getKey());
targetItem.setProductNameZh(productNames.get(entry.getKey())); targetItem.setQuantity(entry.getValue()); cartonItems.insert(targetItem);
balances.adjust(IdWorker.getId(), customerId, target.getOverseasWarehouseId(), entry.getKey(), productNames.get(entry.getKey()), 0,
1L - emptiedBySku.getOrDefault(entry.getKey(), 0));
}
operation.setResultCartonId(targetId); operation.setUpdatedAt(now); operations.updateById(operation);
return new ReturnInventoryResponses.MergeResult(target.getCartonMark(), requested.keySet().iterator().next(), total, label(target));
}
private ReturnInventoryResponses.MergeResult replayMerge(ReturnInventoryOperation operation, String requestHash) {
if (!requestHash.equals(operation.getRequestHash())) throw conflict("幂等键已用于不同的合箱请求");
if (operation.getResultCartonId() == null) throw conflict("合箱请求正在处理");
ReturnInventoryCarton carton = cartons.selectById(operation.getResultCartonId());
if (carton == null) throw new IllegalStateException("合箱结果箱不存在");
List<ReturnInventoryCartonItem> items = cartonItems.listCarton(carton.getId());
return new ReturnInventoryResponses.MergeResult(carton.getCartonMark(), items.get(0).getSkuCode(), carton.getTotalQuantity(), label(carton));
}
private ReturnInventoryResponses.Label label(ReturnInventoryCarton carton) {
List<ReturnInventoryResponses.CartonItem> items = cartonItems.listCarton(carton.getId()).stream()
.map(item -> new ReturnInventoryResponses.CartonItem(item.getSkuCode(), item.getProductNameZh(), item.getQuantity())).toList();
return new ReturnInventoryResponses.Label(carton.getCartonMark(), carton.getOverseasWarehouseId(), carton.getCustomerId(),
carton.getSourceBatchNo(), carton.getSourceReturnNo(), value(carton.getTotalQuantity()), carton.getLength(), carton.getWidth(), carton.getHeight(), carton.getWeight(), items, LocalDateTime.now(), printableHtml(carton, items));
}
private ReturnInventoryResponses.InventoryItem item(ReturnInventoryRow row) {
return new ReturnInventoryResponses.InventoryItem(row.getOverseasWarehouseId(), row.getCartonMark(), row.getSourceBatchNo(), row.getStatus(),
row.getSkuCode(), row.getProductNameZh(), value(row.getQuantity()), row.getSkuTypes() == null ? 0 : row.getSkuTypes(),
value(row.getTotalQuantity()), row.getCreatedAt());
}
private ReturnInventoryResponses.SummaryItem summary(com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryBalance balance) {
return new ReturnInventoryResponses.SummaryItem(balance.getOverseasWarehouseId(), balance.getSkuCode(), balance.getProductNameZh(),
value(balance.getAvailableQuantity()), value(balance.getCartonQuantity()), balance.getUpdatedAt());
}
private ReturnInventoryResponses.CartonDetail cartonDetail(ReturnInventoryCarton carton) {
List<ReturnInventoryResponses.CartonItem> items = cartonItems.listCarton(carton.getId()).stream()
.map(item -> new ReturnInventoryResponses.CartonItem(item.getSkuCode(), item.getProductNameZh(), item.getQuantity())).toList();
return new ReturnInventoryResponses.CartonDetail(carton.getCartonMark(), carton.getOverseasWarehouseId(), carton.getSourceBatchNo(), carton.getSourceReturnNo(),
carton.getStatus(), carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()), carton.getLength(), carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt(), items);
}
private static String printableHtml(ReturnInventoryCarton carton, List<ReturnInventoryResponses.CartonItem> items) {
StringBuilder rows = new StringBuilder();
for (ReturnInventoryResponses.CartonItem item : items) rows.append("<tr><td>").append(escape(item.skuCode())).append("</td><td>")
.append(escape(item.productNameZh())).append("</td><td class=\"qty\">").append(item.quantity()).append("</td></tr>");
return "<!doctype html><html><head><meta charset=\"utf-8\"><title>退货箱唛</title><style>"
+ "@page{size:100mm 150mm;margin:5mm}body{font-family:Arial,'Microsoft YaHei',sans-serif;color:#111;margin:0}.label{border:2px solid #111;padding:5mm;min-height:136mm}.brand{font-size:16px;font-weight:700}.title{font-size:22px;font-weight:700;margin:3mm 0 5mm}.mark{font:700 18px monospace;letter-spacing:1px;word-break:break-all}.meta{font-size:11px;margin:4mm 0;border-top:1px solid #111;border-bottom:1px solid #111;padding:3mm 0}.barcode{height:11mm;margin:3mm 0;background:repeating-linear-gradient(90deg,#111 0 2px,#fff 2px 4px,#111 4px 5px,#fff 5px 8px)}table{width:100%;border-collapse:collapse;font-size:11px}th,td{border:1px solid #111;padding:2mm;text-align:left}.qty{text-align:right;font-weight:700}.total{font-size:14px;font-weight:700;text-align:right;margin-top:4mm}</style></head><body><div class=\"label\"><div class=\"brand\">BAIYUE OVERSEAS WAREHOUSE</div><div class=\"title\">RETURN CARTON LABEL / 退货箱唛</div><div class=\"mark\">"
+ escape(carton.getCartonMark()) + "</div><div class=\"barcode\" aria-label=\"" + escape(carton.getCartonMark()) + "\"></div>"
+ "<div class=\"meta\">仓库:" + escape(carton.getOverseasWarehouseId()) + "<br>客户:" + escape(carton.getCustomerId())
+ "<br>箱规:" + escape(String.valueOf(carton.getLength())) + " × " + escape(String.valueOf(carton.getWidth())) + " × " + escape(String.valueOf(carton.getHeight())) + " cm / " + escape(String.valueOf(carton.getWeight())) + " kg"
+ "<br>签收批次:" + escape(carton.getSourceBatchNo()) + "</div><table><thead><tr><th>SKU</th><th>品名</th><th>数量</th></tr></thead><tbody>"
+ rows + "</tbody></table><div class=\"total\">合计:" + carton.getTotalQuantity() + " 件 / " + carton.getSkuTypes() + " 种 SKU</div></div></body></html>";
}
private String hash(Object request) { try { return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(String.valueOf(request).getBytes(StandardCharsets.UTF_8))); }
catch (Exception exception) { throw new IllegalStateException("无法生成请求哈希", exception); } }
private static String escape(String value) { return text(value) == null ? "" : text(value).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;"); }
private static long value(Long number) { return number == null ? 0 : number; }
private static String required(String value, String message) { String result = text(value); if (result == null) throw badRequest(message); return result; }
private String requiredReturnNo(String value) { return required(value, "退货单号不能为空"); }
private static String text(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static String normStatus(String value) { String result = text(value); return result == null ? null : result.toUpperCase(java.util.Locale.ROOT); }
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
public record ReceiptLine(String skuCode, String productNameZh, long quantity) { }
}
@@ -0,0 +1,292 @@
package com.baiyue.lastleg.returnreceipt.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.returnreceipt.dto.PdaReturnReceiptRequests;
import com.baiyue.lastleg.returnreceipt.dto.PdaReturnReceiptResponses;
import com.baiyue.lastleg.returnreceipt.dto.ReturnOrderRequests;
import com.baiyue.lastleg.returnreceipt.dto.ReturnOrderResponses;
import com.baiyue.lastleg.returnreceipt.entity.ReturnOrder;
import com.baiyue.lastleg.returnreceipt.entity.ReturnOrderItem;
import com.baiyue.lastleg.returnreceipt.entity.ReturnReceipt;
import com.baiyue.lastleg.returnreceipt.entity.ReturnReceiptBatch;
import com.baiyue.lastleg.returnreceipt.entity.ReturnReceiptItem;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderItemMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptBatchMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptItemMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptMapper;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
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.util.StringUtils;
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.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/** Customer return forecasts and PDA receipt facts. A receipt batch is the storage-fee start fact. */
@Service
public class ReturnReceiptService {
public static final String PENDING_RECEIPT = "PENDING_RECEIPT";
public static final String PARTIALLY_RECEIVED = "PARTIALLY_RECEIVED";
public static final String COMPLETED = "COMPLETED";
@Resource private ReturnOrderMapper orders;
@Resource private ReturnOrderItemMapper orderItems;
@Resource private ReturnReceiptMapper receipts;
@Resource private ReturnReceiptItemMapper receiptItems;
@Resource private ReturnReceiptBatchMapper batches;
@Resource private ReturnInventoryService inventory;
@Resource private LastlegOrderNoGenerator numbers;
@Resource private ObjectMapper objectMapper;
@Transactional
public ReturnOrderResponses.Detail create(String customerId, String operatorId, ReturnOrderRequests.Create request) {
String key = text(request.idempotencyKey());
String requestHash = hash(request);
ReturnOrder replay = orders.findKey(customerId, key);
if (replay != null) return replayOrder(customerId, replay, requestHash);
Set<String> skuCodes = new LinkedHashSet<>();
long expected = 0;
for (ReturnOrderRequests.Item item : request.items()) {
String skuCode = text(item.skuCode());
if (!skuCodes.add(norm(skuCode))) throw badRequest("同一退货单不能重复预报相同 SKU: " + skuCode);
expected = Math.addExact(expected, item.expectedQuantity());
}
LocalDateTime now = LocalDateTime.now();
ReturnOrder order = new ReturnOrder();
order.setId(IdWorker.getId()); order.setCustomerId(customerId);
order.setOverseasWarehouseId(text(request.overseasWarehouseId()));
order.setReturnNo(numbers.next("RETURN", order.getOverseasWarehouseId(), order.getId()));
order.setCustomerReferenceNo(text(request.customerReferenceNo())); order.setStatus(PENDING_RECEIPT);
order.setExpectedSkuTypes(skuCodes.size()); order.setExpectedQuantity(expected); order.setReceivedQuantity(0L);
order.setRemark(text(request.remark())); order.setIdempotencyKey(key); order.setRequestHash(requestHash);
order.setCreatedBy(text(operatorId)); order.setCreatedAt(now); order.setUpdatedAt(now);
try { orders.insert(order); }
catch (DuplicateKeyException concurrent) {
ReturnOrder existing = orders.findKey(customerId, key);
if (existing != null) return replayOrder(customerId, existing, requestHash);
throw concurrent;
}
for (ReturnOrderRequests.Item input : request.items()) {
ReturnOrderItem item = new ReturnOrderItem();
item.setId(IdWorker.getId()); item.setReturnOrderId(order.getId()); item.setCustomerId(customerId);
item.setSkuCode(text(input.skuCode()));
item.setProductNameZh(text(input.productNameZh())); item.setExpectedQuantity(input.expectedQuantity());
item.setReceivedQuantity(0L); item.setCreatedAt(now); item.setUpdatedAt(now); orderItems.insert(item);
}
return detail(customerId, order.getReturnNo());
}
@Transactional(readOnly = true)
public PageResult<ReturnOrderResponses.Summary> page(String customerId, ReturnOrderRequests.Query query) {
LambdaQueryWrapper<ReturnOrder> w = new LambdaQueryWrapper<ReturnOrder>()
.eq(ReturnOrder::getCustomerId, customerId)
.like(StringUtils.hasText(query.returnNo()), ReturnOrder::getReturnNo, text(query.returnNo()))
.eq(StringUtils.hasText(query.status()), ReturnOrder::getStatus, norm(query.status()))
.orderByDesc(ReturnOrder::getCreatedAt).orderByDesc(ReturnOrder::getId);
if (StringUtils.hasText(query.trackingNo()) || StringUtils.hasText(query.skuCode())) {
Set<Long> ids = null;
if (StringUtils.hasText(query.skuCode())) {
ids = new LinkedHashSet<>(orderItems.selectList(new LambdaQueryWrapper<ReturnOrderItem>()
.eq(ReturnOrderItem::getCustomerId, customerId)
.like(ReturnOrderItem::getSkuCode, text(query.skuCode())))
.stream().map(ReturnOrderItem::getReturnOrderId).toList());
}
if (StringUtils.hasText(query.trackingNo())) {
Set<Long> trackingIds = new LinkedHashSet<>(receipts.selectList(new LambdaQueryWrapper<ReturnReceipt>()
.eq(ReturnReceipt::getCustomerId, customerId)
.like(ReturnReceipt::getTrackingNo, text(query.trackingNo())))
.stream().map(ReturnReceipt::getReturnOrderId).toList());
if (ids == null) ids = trackingIds; else ids.retainAll(trackingIds);
}
if (ids.isEmpty()) return new PageResult<>(List.of(), query.page(), query.size(), 0, 0);
w.in(ReturnOrder::getId, ids);
}
Page<ReturnOrder> result = orders.selectPage(Page.of(query.page(), query.size()), w);
return new PageResult<>(result.getRecords().stream().map(this::summary).toList(), result.getCurrent(),
result.getSize(), result.getTotal(), result.getPages());
}
@Transactional(readOnly = true)
public ReturnOrderResponses.Detail detail(String customerId, String returnNo) {
ReturnOrder order = orders.findNo(customerId, text(returnNo));
if (order == null) throw notFound("退货单不存在");
List<ReturnOrderResponses.Item> items = orderItems.listOrder(order.getId()).stream().map(this::item).toList();
List<ReturnOrderResponses.Receipt> receiptViews = receipts.listOrder(order.getId()).stream().map(receipt ->
new ReturnOrderResponses.Receipt(receipt.getReceiptNo(), receipt.getReceiptBatchNo(), receipt.getCartonMark(), receipt.getTrackingNo(),
receipt.getBoundBy(), value(receipt.getReceivedQuantity()), receipt.getOperatorName(), receipt.getSignedAt())).toList();
return new ReturnOrderResponses.Detail(summary(order), order.getRemark(), items, receiptViews);
}
@Transactional(readOnly = true)
public PdaReturnReceiptResponses.Start start(PdaWarehouseIdentity identity, PdaReturnReceiptRequests.Start request) {
ReturnOrder order = activeOrder(String.valueOf(identity.overseasWarehouseId()), request.returnNo());
return new PdaReturnReceiptResponses.Start(order.getReturnNo(), order.getStatus(), orderItems.listOrder(order.getId()).stream()
.filter(item -> value(item.getReceivedQuantity()) < value(item.getExpectedQuantity())).map(this::pdaItem).toList());
}
@Transactional(readOnly = true)
public PdaReturnReceiptResponses.SkuCandidates skuCandidates(PdaWarehouseIdentity identity, PdaReturnReceiptRequests.SkuLookup request) {
String skuCode = text(request.skuCode());
List<ReturnOrderItem> matchingItems = orderItems.activeSkuCandidates(String.valueOf(identity.overseasWarehouseId()), skuCode);
Map<Long, ReturnOrder> candidateOrders = new LinkedHashMap<>();
if (!matchingItems.isEmpty()) {
for (ReturnOrder order : orders.selectBatchIds(matchingItems.stream().map(ReturnOrderItem::getReturnOrderId).distinct().toList())) {
candidateOrders.put(order.getId(), order);
}
}
List<PdaReturnReceiptResponses.Candidate> candidates = matchingItems.stream().map(item -> {
ReturnOrder order = candidateOrders.get(item.getReturnOrderId());
if (order == null) throw new IllegalStateException("SKU 候选关联退货单不存在");
return new PdaReturnReceiptResponses.Candidate(order.getReturnNo(), order.getCustomerId(), order.getCustomerReferenceNo(),
order.getStatus(), value(item.getExpectedQuantity()), value(item.getReceivedQuantity()),
value(order.getExpectedQuantity()), value(order.getReceivedQuantity()));
}).toList();
return new PdaReturnReceiptResponses.SkuCandidates(skuCode, candidates);
}
@Transactional
public PdaReturnReceiptResponses.Confirmed confirm(PdaWarehouseIdentity identity, PdaReturnReceiptRequests.Confirm request) {
String key = text(request.idempotencyKey());
String requestHash = hash(request);
ReturnReceipt replay = receipts.findKey(identity.operatorKey(), key);
if (replay != null) return replayReceipt(replay, requestHash);
String startedBy = norm(request.startedBy());
if (!Set.of("RETURN_NO", "SKU").contains(startedBy)) throw badRequest("startedBy 必须为 RETURN_NO 或 SKU");
String trackingNo = text(request.trackingNo());
ReturnOrder order = orders.lockNo(text(request.returnNo()));
if (order == null || !String.valueOf(identity.overseasWarehouseId()).equals(order.getOverseasWarehouseId())) {
throw notFound("当前绑定仓没有该退货单");
}
if (!Set.of(PENDING_RECEIPT, PARTIALLY_RECEIVED).contains(order.getStatus())) {
throw conflict("仅待签收或签收中的退货单允许 PDA 签收");
}
List<ReturnOrderItem> lockedItems = orderItems.lockOrder(order.getId());
Map<String, Long> receivedBySku = aggregate(request.items());
List<Allocation> allocations = allocate(lockedItems, receivedBySku);
long receiptQuantity = allocations.stream().mapToLong(Allocation::quantity).sum();
LocalDateTime now = LocalDateTime.now();
long receiptId = IdWorker.getId();
ReturnReceipt receipt = new ReturnReceipt();
receipt.setId(receiptId); receipt.setReturnOrderId(order.getId()); receipt.setCustomerId(order.getCustomerId());
receipt.setReceiptNo(numbers.next("RETURN_RECEIPT", order.getOverseasWarehouseId(), receiptId));
receipt.setReceiptBatchNo(numbers.next("RETURN_BATCH", order.getOverseasWarehouseId(), receiptId));
receipt.setTrackingNo(trackingNo); receipt.setBoundBy(startedBy); receipt.setStatus("SIGNED");
receipt.setReceivedQuantity(receiptQuantity); receipt.setOperatorId(identity.operatorKey());
receipt.setOperatorName(identity.operatorName()); receipt.setIdempotencyKey(key); receipt.setRequestHash(requestHash);
receipt.setSignedAt(now); receipt.setCreatedAt(now);
try { receipts.insert(receipt); }
catch (DuplicateKeyException concurrent) {
ReturnReceipt existing = receipts.findKey(identity.operatorKey(), key);
if (existing != null) return replayReceipt(existing, requestHash);
throw concurrent;
}
for (Allocation allocation : allocations) {
ReturnOrderItem item = allocation.item();
item.setReceivedQuantity(value(item.getReceivedQuantity()) + allocation.quantity()); item.setUpdatedAt(now);
orderItems.updateById(item);
ReturnReceiptItem receiptItem = new ReturnReceiptItem();
receiptItem.setId(IdWorker.getId()); receiptItem.setReceiptId(receiptId); receiptItem.setReturnOrderItemId(item.getId());
receiptItem.setCustomerId(order.getCustomerId()); receiptItem.setSkuCode(item.getSkuCode());
receiptItem.setReceivedQuantity(allocation.quantity()); receiptItems.insert(receiptItem);
}
Map<String, ReturnInventoryService.ReceiptLine> inventoryLines = new LinkedHashMap<>();
for (Allocation allocation : allocations) {
ReturnOrderItem item = allocation.item();
inventoryLines.merge(item.getSkuCode(), new ReturnInventoryService.ReceiptLine(item.getSkuCode(),
item.getProductNameZh(), allocation.quantity()), (left, right) -> new ReturnInventoryService.ReceiptLine(
left.skuCode(), left.productNameZh() == null ? right.productNameZh() : left.productNameZh(),
Math.addExact(left.quantity(), right.quantity())));
}
receipt.setCartonMark(inventory.createReceiptCarton(order, receipt, request.cartonMark(),
new ArrayList<>(inventoryLines.values())).getCartonMark());
receipts.updateById(receipt);
List<ReturnOrderItem> finalItems = orderItems.listOrder(order.getId());
long received = finalItems.stream().mapToLong(item -> value(item.getReceivedQuantity())).sum();
boolean completed = finalItems.stream().allMatch(item -> value(item.getReceivedQuantity()) == value(item.getExpectedQuantity()));
order.setReceivedQuantity(received); order.setStatus(completed ? COMPLETED : PARTIALLY_RECEIVED); order.setUpdatedAt(now); orders.updateById(order);
ReturnReceiptBatch batch = new ReturnReceiptBatch();
batch.setId(IdWorker.getId()); batch.setReturnOrderId(order.getId()); batch.setReceiptId(receiptId);
batch.setCustomerId(order.getCustomerId()); batch.setBatchNo(receipt.getReceiptBatchNo());
batch.setOverseasWarehouseId(order.getOverseasWarehouseId()); batch.setStorageBillingStatus("PENDING_PRICING");
batch.setStorageStartedAt(now); batch.setCreatedAt(now); batches.insert(batch);
return confirmed(order, receipt, completed);
}
private ReturnOrder activeOrder(String warehouseId, String returnNo) {
ReturnOrder order = orders.findActiveNo(warehouseId, text(returnNo));
if (order == null) throw notFound("当前仓没有待签收或签收中的退货单");
return order;
}
private List<Allocation> allocate(List<ReturnOrderItem> source, Map<String, Long> requested) {
List<Allocation> allocations = new ArrayList<>();
for (Map.Entry<String, Long> entry : requested.entrySet()) {
long remaining = entry.getValue();
for (ReturnOrderItem item : source) {
if (!entry.getKey().equals(item.getSkuCode())) continue;
long capacity = value(item.getExpectedQuantity()) - value(item.getReceivedQuantity());
long accepted = Math.min(remaining, capacity);
if (accepted > 0) { allocations.add(new Allocation(item, accepted)); remaining -= accepted; }
if (remaining == 0) break;
}
if (remaining > 0) throw conflict("SKU 未预报或超出预报可签收数量: " + entry.getKey());
}
return allocations;
}
private Map<String, Long> aggregate(List<PdaReturnReceiptRequests.Item> inputs) {
Map<String, Long> result = new LinkedHashMap<>();
for (PdaReturnReceiptRequests.Item item : inputs) result.merge(text(item.skuCode()), item.receivedQuantity(), Math::addExact);
return result;
}
private ReturnOrderResponses.Detail replayOrder(String customerId, ReturnOrder order, String requestHash) {
if (!requestHash.equals(order.getRequestHash())) throw conflict("幂等键已用于不同的退货预报");
return detail(customerId, order.getReturnNo());
}
private PdaReturnReceiptResponses.Confirmed replayReceipt(ReturnReceipt receipt, String requestHash) {
if (!requestHash.equals(receipt.getRequestHash())) throw conflict("幂等键已用于不同的签收请求");
ReturnOrder order = orders.selectById(receipt.getReturnOrderId());
if (order == null) throw new IllegalStateException("签收单关联退货单不存在");
return confirmed(order, receipt, COMPLETED.equals(order.getStatus()));
}
private PdaReturnReceiptResponses.Confirmed confirmed(ReturnOrder order, ReturnReceipt receipt, boolean completed) {
return new PdaReturnReceiptResponses.Confirmed(order.getReturnNo(), receipt.getReceiptNo(), receipt.getReceiptBatchNo(), receipt.getCartonMark(),
order.getStatus(), completed, value(order.getExpectedQuantity()), value(order.getReceivedQuantity()), receipt.getSignedAt());
}
private ReturnOrderResponses.Summary summary(ReturnOrder order) { return new ReturnOrderResponses.Summary(order.getReturnNo(),
order.getOverseasWarehouseId(), order.getCustomerReferenceNo(), order.getStatus(),
order.getExpectedSkuTypes() == null ? 0 : order.getExpectedSkuTypes(), value(order.getExpectedQuantity()),
value(order.getReceivedQuantity()), order.getCreatedAt()); }
private ReturnOrderResponses.Item item(ReturnOrderItem item) { return new ReturnOrderResponses.Item(item.getSkuCode(),
item.getProductNameZh(), value(item.getExpectedQuantity()), value(item.getReceivedQuantity())); }
private PdaReturnReceiptResponses.Item pdaItem(ReturnOrderItem item) { return new PdaReturnReceiptResponses.Item(item.getSkuCode(),
item.getProductNameZh(), value(item.getExpectedQuantity()), value(item.getReceivedQuantity())); }
private static long value(Long value) { return value == null ? 0 : value; }
private static String text(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static String norm(String value) { return text(value) == null ? null : text(value).toUpperCase(java.util.Locale.ROOT); }
private String hash(Object value) { try { return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(objectMapper.writeValueAsString(value).getBytes(StandardCharsets.UTF_8))); }
catch (Exception exception) { throw new IllegalStateException("无法生成请求哈希", exception); } }
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
private record Allocation(ReturnOrderItem item, long quantity) { }
}
@@ -54,6 +54,10 @@ public class LastlegOrderNoGenerator {
case "DISCARD" -> "CD"; case "DISCARD" -> "CD";
case "PROCUREMENT" -> "PR"; case "PROCUREMENT" -> "PR";
case "INBOUND_PLAN" -> "IP"; case "INBOUND_PLAN" -> "IP";
case "RETURN" -> "RT";
case "RETURN_RECEIPT" -> "RR";
case "RETURN_BATCH" -> "RB";
case "RETURN_CARTON" -> "RC";
default -> { default -> {
String normalized = code.replaceAll("[^A-Z0-9]", ""); String normalized = code.replaceAll("[^A-Z0-9]", "");
yield normalized.isEmpty() ? "LMO" : normalized.substring(0, Math.min(4, normalized.length())); yield normalized.isEmpty() ? "LMO" : normalized.substring(0, Math.min(4, normalized.length()));
@@ -1271,7 +1271,7 @@ CREATE TABLE IF NOT EXISTS ll_outbound_pallet_item (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', outbound_order_id BIGINT NOT NULL COMMENT '出库单ID', id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', outbound_order_id BIGINT NOT NULL COMMENT '出库单ID',
warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号', warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号',
destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照',
package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', picked_package_count INT NOT NULL DEFAULT 0 COMMENT '已拣箱数', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_PICK' COMMENT '拣货状态', sku_code VARCHAR(128) NULL COMMENT '一件代发SKU', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', picked_package_count INT NOT NULL DEFAULT 0 COMMENT '已拣箱数', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_PICK' COMMENT '拣货状态', sku_code VARCHAR(128) NULL COMMENT '一件代发SKU', carton_marks VARCHAR(4000) NULL COMMENT '本次出库指定箱唛,逗号分隔', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id),
UNIQUE KEY uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id), UNIQUE KEY uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id),
KEY idx_ll_outbound_pallet_list (customer_id,outbound_order_id), KEY idx_ll_outbound_pallet_destination (customer_id,destination_warehouse_code) KEY idx_ll_outbound_pallet_list (customer_id,outbound_order_id), KEY idx_ll_outbound_pallet_destination (customer_id,destination_warehouse_code)
@@ -1466,10 +1466,22 @@ CREATE TABLE IF NOT EXISTS ll_relabel_item (
carton_mark VARCHAR(255) NULL COMMENT '客户自有箱唛', carton_mark VARCHAR(255) NULL COMMENT '客户自有箱唛',
fba_carton_mark VARCHAR(255) NULL COMMENT '关联FBA箱唛', fba_carton_mark VARCHAR(255) NULL COMMENT '关联FBA箱唛',
destination_warehouse_id VARCHAR(128) NOT NULL COMMENT '该箱目的海外仓ID', destination_warehouse_id VARCHAR(128) NOT NULL COMMENT '该箱目的海外仓ID',
destination_fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA目的仓编码(与海外操作仓分离)',
operation_type VARCHAR(32) NOT NULL DEFAULT 'CARTON_LABEL' COMMENT '换标模式:箱唛、SKU或组合换标',
sku_relabel_items_json JSON NULL COMMENT '同箱SKU换标子明细快照(数量按件)',
source_inventory_balance_id BIGINT NOT NULL COMMENT '原库存余额ID',
source_inventory_position_id BIGINT NOT NULL COMMENT '原库存库位ID',
source_inbound_order_id BIGINT NOT NULL COMMENT '原入仓单ID',
source_inbound_order_no VARCHAR(128) NULL COMMENT '原入仓单号快照',
source_inbound_code VARCHAR(128) NULL COMMENT '原进仓编码快照',
source_warehouse_id VARCHAR(128) NOT NULL COMMENT '原海外中转仓ID',
source_warehouse_code VARCHAR(128) NULL COMMENT '原海外中转仓编码快照',
source_warehouse_name VARCHAR(255) NULL COMMENT '原海外中转仓名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id), PRIMARY KEY (id),
KEY idx_ll_relabel_item_application (customer_id,application_id), KEY idx_ll_relabel_item_application (customer_id,application_id),
KEY idx_ll_relabel_item_sku (customer_id,sku_code) KEY idx_ll_relabel_item_sku (customer_id,sku_code),
KEY idx_ll_relabel_item_source_position (customer_id,source_inventory_position_id)
) ENGINE=InnoDB COMMENT='换标货物冻结明细'; ) ENGINE=InnoDB COMMENT='换标货物冻结明细';
CREATE TABLE IF NOT EXISTS ll_relabel_status_history ( CREATE TABLE IF NOT EXISTS ll_relabel_status_history (
@@ -1885,6 +1897,25 @@ CREATE TABLE IF NOT EXISTS ll_security_projection (
KEY idx_security_projection_permission (aggregate_id,permission_version) KEY idx_security_projection_permission (aggregate_id,permission_version)
) ENGINE=InnoDB COMMENT='海外IAM安全状态与权限版本投影'; ) ENGINE=InnoDB COMMENT='海外IAM安全状态与权限版本投影';
-- 兼容已有的旧版投影表:CREATE TABLE IF NOT EXISTS 不会补充后续新增字段。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='ll_security_projection' AND column_name='roles_json'), 'SELECT 1',
'ALTER TABLE ll_security_projection ADD COLUMN roles_json JSON NULL COMMENT ''IAM投影的当前角色快照'' AFTER permission_version'));
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='ll_security_projection' AND column_name='authorities_json'), 'SELECT 1',
'ALTER TABLE ll_security_projection ADD COLUMN authorities_json JSON NULL COMMENT ''IAM投影的当前海外权限快照'' AFTER roles_json'));
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
UPDATE ll_security_projection SET roles_json=JSON_ARRAY() WHERE roles_json IS NULL;
UPDATE ll_security_projection SET authorities_json=JSON_ARRAY() WHERE authorities_json IS NULL;
ALTER TABLE ll_security_projection
MODIFY COLUMN roles_json JSON NOT NULL COMMENT 'IAM投影的当前角色快照',
MODIFY COLUMN authorities_json JSON NOT NULL COMMENT 'IAM投影的当前海外权限快照';
-- 开发基线固定管理员;后续真实 IAM 事件从 aggregate_version=1 单调覆盖该 version=0 投影。 -- 开发基线固定管理员;后续真实 IAM 事件从 aggregate_version=1 单调覆盖该 version=0 投影。
INSERT IGNORE INTO ll_security_projection ( INSERT IGNORE INTO ll_security_projection (
aggregate_type,aggregate_id,tenant_id,security_status,permission_version,roles_json,authorities_json,aggregate_version, aggregate_type,aggregate_id,tenant_id,security_status,permission_version,roles_json,authorities_json,aggregate_version,
@@ -1956,3 +1987,82 @@ CREATE TABLE IF NOT EXISTS ll_destuff_pallet_cost_allocation (
PRIMARY KEY(id), UNIQUE KEY uk_ll_destuff_customer(cost_id,customer_id), UNIQUE KEY uk_ll_destuff_fee_source(fee_source_id), PRIMARY KEY(id), UNIQUE KEY uk_ll_destuff_customer(cost_id,customer_id), UNIQUE KEY uk_ll_destuff_fee_source(fee_source_id),
CONSTRAINT chk_ll_destuff_allocation CHECK(actual_volume_cbm>0 AND allocated_amount>=0) CONSTRAINT chk_ll_destuff_allocation CHECK(actual_volume_cbm>0 AND allocated_amount>=0)
) ENGINE=InnoDB COMMENT='拆柜打托成本按客户实收体积分摊'; ) ENGINE=InnoDB COMMENT='拆柜打托成本按客户实收体积分摊';
-- 客户退货预报、PDA 签收与仓储费起算批次
CREATE TABLE IF NOT EXISTS ll_return_order (
id BIGINT NOT NULL COMMENT '退货订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', return_no VARCHAR(128) NOT NULL COMMENT '退货订单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '操作海外仓ID', customer_reference_no VARCHAR(128) NULL COMMENT '客户参考号', status VARCHAR(32) NOT NULL COMMENT 'PENDING_RECEIPT、PARTIALLY_RECEIVED或COMPLETED', expected_sku_types INT NOT NULL COMMENT '预报SKU种类数', expected_quantity BIGINT NOT NULL COMMENT '预报总数量', received_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '累计实收总数量', remark VARCHAR(1000) NULL COMMENT '客户备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键', request_hash CHAR(64) NOT NULL COMMENT '创建请求SHA-256', created_by VARCHAR(128) NULL COMMENT '创建操作人ID', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_order_customer_no(customer_id,return_no), UNIQUE KEY uk_ll_return_order_customer_key(customer_id,idempotency_key), KEY idx_ll_return_order_warehouse_status(overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_order_quantity CHECK(expected_sku_types>0 AND expected_quantity>0 AND received_quantity>=0 AND received_quantity<=expected_quantity)
) ENGINE=InnoDB COMMENT='客户退货订单及SKU实收汇总';
CREATE TABLE IF NOT EXISTS ll_return_order_item (
id BIGINT NOT NULL COMMENT '退货预报明细ID', return_order_id BIGINT NOT NULL COMMENT '退货订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', expected_quantity BIGINT NOT NULL COMMENT 'SKU预报数量', received_quantity BIGINT NOT NULL DEFAULT 0 COMMENT 'SKU累计实收数量', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_item_order_sku(return_order_id,sku_code), KEY idx_ll_return_item_sku(sku_code,received_quantity), CONSTRAINT chk_ll_return_item_quantity CHECK(expected_quantity>0 AND received_quantity>=0 AND received_quantity<=expected_quantity)
) ENGINE=InnoDB COMMENT='客户退货订单的SKU预报与实收明细';
CREATE TABLE IF NOT EXISTS ll_return_receipt (
id BIGINT NOT NULL COMMENT 'PDA退货签收单ID', return_order_id BIGINT NOT NULL COMMENT '退货订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', receipt_no VARCHAR(128) NOT NULL COMMENT '签收单号', receipt_batch_no VARCHAR(128) NOT NULL COMMENT '签收批次号', tracking_no VARCHAR(128) NULL COMMENT '本次签收包裹的可选快递单号', bound_by VARCHAR(16) NOT NULL COMMENT '开始签收方式,RETURN_NO退货单号或SKU候选', status VARCHAR(32) NOT NULL COMMENT '签收状态,当前SIGNED', received_quantity BIGINT NOT NULL COMMENT '本次实收数量', operator_id VARCHAR(128) NOT NULL COMMENT 'PDA操作人ID', operator_name VARCHAR(128) NULL COMMENT 'PDA操作人名称', idempotency_key VARCHAR(255) NOT NULL COMMENT 'PDA确认幂等键', request_hash CHAR(64) NOT NULL COMMENT 'PDA确认请求SHA-256', signed_at DATETIME(3) NOT NULL COMMENT '签收时间', created_at DATETIME(3) NOT NULL COMMENT '创建时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_receipt_no(receipt_no), UNIQUE KEY uk_ll_return_receipt_batch(receipt_batch_no), UNIQUE KEY uk_ll_return_receipt_operator_key(operator_id,idempotency_key), KEY idx_ll_return_receipt_order(return_order_id,signed_at), CONSTRAINT chk_ll_return_receipt_quantity CHECK(received_quantity>0), CONSTRAINT chk_ll_return_receipt_bound CHECK(bound_by IN('RETURN_NO','SKU'))
) ENGINE=InnoDB COMMENT='PDA退货实收签收事实';
-- 已创建开发库升级为“客户只预报 SKU,快递单号仅属于签收批次”的最终模型。
SET @ll_return_item_drop_tracking_order_sku = (SELECT IF(COUNT(*) > 0, 'ALTER TABLE ll_return_order_item DROP INDEX uk_ll_return_item_order_tracking_sku', 'SELECT 1') FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='uk_ll_return_item_order_tracking_sku');
PREPARE ll_return_item_drop_tracking_order_sku_stmt FROM @ll_return_item_drop_tracking_order_sku;
EXECUTE ll_return_item_drop_tracking_order_sku_stmt;
DEALLOCATE PREPARE ll_return_item_drop_tracking_order_sku_stmt;
SET @ll_return_item_drop_tracking = (SELECT IF(COUNT(*) > 0, 'ALTER TABLE ll_return_order_item DROP INDEX idx_ll_return_item_tracking', 'SELECT 1') FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='idx_ll_return_item_tracking');
PREPARE ll_return_item_drop_tracking_stmt FROM @ll_return_item_drop_tracking;
EXECUTE ll_return_item_drop_tracking_stmt;
DEALLOCATE PREPARE ll_return_item_drop_tracking_stmt;
SET @ll_return_item_drop_order = (SELECT IF(COUNT(*) > 0, 'ALTER TABLE ll_return_order_item DROP INDEX idx_ll_return_item_order', 'SELECT 1') FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='idx_ll_return_item_order');
PREPARE ll_return_item_drop_order_stmt FROM @ll_return_item_drop_order;
EXECUTE ll_return_item_drop_order_stmt;
DEALLOCATE PREPARE ll_return_item_drop_order_stmt;
SET @ll_return_item_drop_tracking_no = (SELECT IF(COUNT(*) > 0, 'ALTER TABLE ll_return_order_item DROP COLUMN tracking_no', 'SELECT 1') FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND column_name='tracking_no');
PREPARE ll_return_item_drop_tracking_no_stmt FROM @ll_return_item_drop_tracking_no;
EXECUTE ll_return_item_drop_tracking_no_stmt;
DEALLOCATE PREPARE ll_return_item_drop_tracking_no_stmt;
SET @ll_return_item_add_order_sku = (SELECT IF(COUNT(*) = 0, 'ALTER TABLE ll_return_order_item ADD UNIQUE KEY uk_ll_return_item_order_sku(return_order_id,sku_code)', 'SELECT 1') FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='uk_ll_return_item_order_sku');
PREPARE ll_return_item_add_order_sku_stmt FROM @ll_return_item_add_order_sku;
EXECUTE ll_return_item_add_order_sku_stmt;
DEALLOCATE PREPARE ll_return_item_add_order_sku_stmt;
SET @ll_return_item_add_sku_idx = (SELECT IF(COUNT(*) = 0, 'ALTER TABLE ll_return_order_item ADD KEY idx_ll_return_item_sku(sku_code,received_quantity)', 'SELECT 1') FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='idx_ll_return_item_sku');
PREPARE ll_return_item_add_sku_idx_stmt FROM @ll_return_item_add_sku_idx;
EXECUTE ll_return_item_add_sku_idx_stmt;
DEALLOCATE PREPARE ll_return_item_add_sku_idx_stmt;
UPDATE ll_return_receipt SET bound_by='RETURN_NO' WHERE bound_by='TRACKING';
SET @ll_return_receipt_drop_bound_check = (SELECT IF(COUNT(*) > 0, 'ALTER TABLE ll_return_receipt DROP CHECK chk_ll_return_receipt_bound', 'SELECT 1') FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_receipt' AND constraint_name='chk_ll_return_receipt_bound');
PREPARE ll_return_receipt_drop_bound_check_stmt FROM @ll_return_receipt_drop_bound_check;
EXECUTE ll_return_receipt_drop_bound_check_stmt;
DEALLOCATE PREPARE ll_return_receipt_drop_bound_check_stmt;
SET @ll_return_receipt_alter = 'ALTER TABLE ll_return_receipt MODIFY COLUMN tracking_no VARCHAR(128) NULL COMMENT ''本次签收包裹的可选快递单号'', MODIFY COLUMN bound_by VARCHAR(16) NOT NULL COMMENT ''开始签收方式,RETURN_NO退货单号或SKU候选'', ADD CONSTRAINT chk_ll_return_receipt_bound CHECK(bound_by IN(''RETURN_NO'',''SKU''))';
PREPARE ll_return_receipt_alter_stmt FROM @ll_return_receipt_alter;
EXECUTE ll_return_receipt_alter_stmt;
DEALLOCATE PREPARE ll_return_receipt_alter_stmt;
CREATE TABLE IF NOT EXISTS ll_return_receipt_item (
id BIGINT NOT NULL COMMENT '签收SKU明细ID', receipt_id BIGINT NOT NULL COMMENT 'PDA退货签收单ID', return_order_item_id BIGINT NOT NULL COMMENT '退货预报明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', received_quantity BIGINT NOT NULL COMMENT '本次SKU实收数量',
PRIMARY KEY(id), KEY idx_ll_return_receipt_item_receipt(receipt_id), KEY idx_ll_return_receipt_item_order_item(return_order_item_id), CONSTRAINT chk_ll_return_receipt_item_quantity CHECK(received_quantity>0)
) ENGINE=InnoDB COMMENT='PDA退货签收SKU实际数量明细';
CREATE TABLE IF NOT EXISTS ll_return_receipt_batch (
id BIGINT NOT NULL COMMENT '退货仓储计费批次ID', return_order_id BIGINT NOT NULL COMMENT '退货订单ID', receipt_id BIGINT NOT NULL COMMENT 'PDA退货签收单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', batch_no VARCHAR(128) NOT NULL COMMENT '签收批次号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '操作海外仓ID', storage_billing_status VARCHAR(32) NOT NULL COMMENT '仓储计费状态,初始PENDING_PRICING', storage_started_at DATETIME(3) NOT NULL COMMENT '仓储计费起算时间', created_at DATETIME(3) NOT NULL COMMENT '创建时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_batch_no(batch_no), UNIQUE KEY uk_ll_return_batch_receipt(receipt_id), KEY idx_ll_return_batch_billing(customer_id,overseas_warehouse_id,storage_billing_status,storage_started_at)
) ENGINE=InnoDB COMMENT='每次退货签收生成的仓储费起算批次';
-- 退货库存:签收即生成箱号,库存按客户、海外仓和SKU汇总;箱内明细保留给合箱与箱唛。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='carton_mark'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN carton_mark VARCHAR(128) NULL COMMENT ''签收生成或绑定的退货箱号'' AFTER receipt_batch_no'));
PREPARE return_receipt_carton_mark_stmt FROM @ddl; EXECUTE return_receipt_carton_mark_stmt; DEALLOCATE PREPARE return_receipt_carton_mark_stmt;
CREATE TABLE IF NOT EXISTS ll_return_inventory_carton (
id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', source_receipt_id BIGINT NULL COMMENT '来源PDA签收单ID', source_batch_no VARCHAR(128) NULL COMMENT '来源签收批次号', carton_mark VARCHAR(128) NOT NULL COMMENT '退货箱号和箱唛编码', status VARCHAR(32) NOT NULL COMMENT 'AVAILABLE可用或MERGED已合箱', sku_types INT NOT NULL COMMENT '箱内SKU种类数', total_quantity BIGINT NOT NULL COMMENT '箱内总件数', length DECIMAL(12,3) NULL COMMENT '箱长CM', width DECIMAL(12,3) NULL COMMENT '箱宽CM', height DECIMAL(12,3) NULL COMMENT '箱高CM', weight DECIMAL(12,3) NULL COMMENT '箱重KG', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', merged_at DATETIME(3) NULL COMMENT '合箱完成时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_mark(carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt(source_receipt_id), KEY idx_ll_return_inventory_carton_customer(customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK(sku_types>0 AND total_quantity>0), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED'))
) ENGINE=InnoDB COMMENT='退货实物库存箱及箱唛';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='source_return_no'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN source_return_no VARCHAR(2000) NULL COMMENT ''来源退货单号'' AFTER source_batch_no'));
PREPARE return_inventory_carton_return_no_stmt FROM @ddl; EXECUTE return_inventory_carton_return_no_stmt; DEALLOCATE PREPARE return_inventory_carton_return_no_stmt;
CREATE TABLE IF NOT EXISTS ll_return_inventory_carton_item (
id BIGINT NOT NULL COMMENT '退货库存箱SKU明细ID', carton_id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', quantity BIGINT NOT NULL COMMENT '该SKU箱内数量',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_item(carton_id,sku_code), KEY idx_ll_return_inventory_carton_item_customer(customer_id,sku_code), CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>0)
) ENGINE=InnoDB COMMENT='退货库存箱内SKU和数量';
CREATE TABLE IF NOT EXISTS ll_return_inventory_balance (
id BIGINT NOT NULL COMMENT '退货库存余额ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', available_quantity BIGINT NOT NULL COMMENT '可用上架数量', carton_quantity BIGINT NOT NULL COMMENT '包含该SKU的可用箱数', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_balance(customer_id,overseas_warehouse_id,sku_code), CONSTRAINT chk_ll_return_inventory_balance_quantity CHECK(available_quantity>=0 AND carton_quantity>=0)
) ENGINE=InnoDB COMMENT='退货库存按客户海外仓SKU汇总余额';
CREATE TABLE IF NOT EXISTS ll_return_inventory_operation (
id BIGINT NOT NULL COMMENT '退货库存PDA操作ID', operator_id VARCHAR(128) NOT NULL COMMENT 'PDA操作人ID', idempotency_key VARCHAR(255) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容SHA-256', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型,当前MERGE', result_carton_id BIGINT NULL COMMENT '合箱结果退货库存箱ID', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_operation_operator_key(operator_id,idempotency_key), KEY idx_ll_return_inventory_operation_result(result_carton_id)
) ENGINE=InnoDB COMMENT='退货库存PDA合箱幂等操作审计';
@@ -1018,8 +1018,174 @@ paths:
- { name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } } - { name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } }
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelStatusChange' } } } } requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelStatusChange' } } } }
responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '409': { description: 非法状态迁移或关联尾程单缺失 } } responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '409': { description: 非法状态迁移或关联尾程单缺失 } }
/api/merchant/v1/last-leg/return-orders:
get:
summary: 客户分页查询退货签收订单
description: 客户范围只取认证 Token。可按退货单号、快递单号、SKU 和签收状态过滤;实收数量以仓库 PDA 签收为准。
security: [{ bearerAuth: [] }]
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
- { name: returnNo, in: query, schema: { type: string } }
- { name: trackingNo, in: query, schema: { type: string } }
- { name: skuCode, in: query, schema: { type: string } }
- { name: status, in: query, schema: { type: string, enum: [PENDING_RECEIPT, PARTIALLY_RECEIVED, COMPLETED] } }
responses: { '200': { description: 当前客户退货订单分页 } }
post:
summary: 客户创建退货预报订单
description: 客户只预报不重复的 SKU 和预计数量,不要求提前知道快递单号;仓库 PDA 实收数量才是实际入库事实。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReturnOrderCreate' } } } }
responses: { '200': { description: 创建后的退货订单详情 }, '409': { description: 幂等键冲突 } }
/api/merchant/v1/last-leg/return-orders/{returnNo}:
get:
summary: 客户查询退货订单与签收批次
description: 返回各 SKU 的预报和实收数量,以及每次 PDA 签收产生的批次号和可选快递单号。
security: [{ bearerAuth: [] }]
parameters: [{ name: returnNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 当前客户退货订单详情 }, '404': { description: 当前客户下退货订单不存在 } }
/api/pda/v1/last-leg/return-receipts/start:
post:
summary: PDA 以退件单号开始签收
description: 仓库先从包裹收件信息取得客户填写的退件单号。仅当前绑定仓的 PENDING_RECEIPT(待签收)和 PARTIALLY_RECEIVED(签收中)退货单可开始签收,并返回该单尚未签收的 SKU 明细。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { type: object, required: [returnNo], properties: { returnNo: { type: string, maxLength: 128 } } } } } }
responses: { '200': { description: 可开始签收的退货单及未签收 SKU 明细 }, '404': { description: 当前仓没有待签收或签收中的退货单 } }
/api/pda/v1/last-leg/return-receipts/sku-candidates:
post:
summary: PDA 扫描 SKU 查询候选退货单
description: 仅查询当前 PDA 绑定仓内待签收或签收中的退货单,最多返回 50 个候选。仓库人员根据退货单号、客户和数量选择后,再调用 start 进入统一签收流程。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { type: object, required: [skuCode], properties: { skuCode: { type: string, maxLength: 128 } } } } } }
responses: { '200': { description: SKU 及候选退货单列表;没有匹配时 candidates 为空数组 } }
/api/pda/v1/last-leg/return-receipts/confirm:
post:
summary: PDA 确认退货签收
description: PDA 仅能操作绑定海外仓。startedBy 记录通过退货单号或 SKU 候选进入;trackingNo 为本次包裹可选快递单号。按预报可签收数量校验、禁止超收;每次成功确认创建唯一签收单及仓储计费批次。全部预报 SKU 数量吻合时自动 COMPLETED。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReturnReceiptConfirm' } } } }
responses: { '200': { description: 签收结果及自动完成状态 }, '409': { description: 超收、错绑或幂等键冲突 } }
/api/merchant/v1/last-leg/return-inventory:
get:
summary: 客户查询退货库存
description: 客户范围只取认证 Token。库存按客户、海外仓、SKU 和箱号查询,数量以仓库 PDA 实收上架数量为准。
security: [{ bearerAuth: [] }]
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
- { name: overseasWarehouseId, in: query, schema: { type: string, maxLength: 128 } }
- { name: skuCode, in: query, schema: { type: string, maxLength: 128 } }
- { name: cartonMark, in: query, schema: { type: string, maxLength: 128 } }
responses: { '200': { description: 当前客户退货库存分页 } }
/api/merchant/v1/last-leg/return-inventory/summary:
get:
summary: 客户查询退货库存 SKU 汇总
description: 客户范围只取认证 Token。按海外仓和 SKU 汇总 PDA 实收上架数量及可用箱数。
security: [{ bearerAuth: [] }]
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
- { name: overseasWarehouseId, in: query, schema: { type: string, maxLength: 128 } }
- { name: skuCode, in: query, schema: { type: string, maxLength: 128 } }
responses: { '200': { description: 当前客户 SKU 汇总库存分页 } }
/api/merchant/v1/last-leg/return-inventory/cartons/{cartonMark}:
get:
summary: 客户查询退货箱明细
description: 仅返回当前客户的箱号、签收批次和箱内 SKU 数量。
security: [{ bearerAuth: [] }]
parameters: [{ name: cartonMark, in: path, required: true, schema: { type: string, maxLength: 128 } }]
responses: { '200': { description: 当前客户退货箱明细 }, '404': { description: 当前客户下退货箱不存在 } }
/api/merchant/v1/last-leg/return-inventory/cartons/{cartonMark}/label:
get:
summary: 客户获取退货箱唛
description: 返回当前客户退货箱的可打印标签及 SKU 数量,客户可据此打印或复核箱唛。
security: [{ bearerAuth: [] }]
parameters: [{ name: cartonMark, in: path, required: true, schema: { type: string, maxLength: 128 } }]
responses: { '200': { description: 当前客户退货箱唛 }, '404': { description: 当前客户下退货箱不存在 } }
/api/pda/v1/last-leg/return-inventory/cartons/merge:
post:
summary: PDA 合并退货箱
description: 仅当前 PDA 绑定海外仓可操作。选择同一客户的已完成退货单,按查询出的 SKU 填写本箱数量并录入箱规;系统扣减可用库存并生成合箱箱号。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReturnInventoryMerge' } } } }
responses: { '200': { description: 新箱号、SKU 汇总数量及可打印箱唛 }, '409': { description: 箱状态、客户或 SKU 不满足合箱条件,或幂等键冲突 } }
/api/pda/v1/last-leg/return-inventory/packing/orders:
get:
summary: PDA 查询可合箱退货单和 SKU 库存
security: [{ bearerAuth: [] }]
parameters: [{ name: returnNo, in: query, required: false, schema: { type: string, maxLength: 128 } }]
responses: { '200': { description: 当前仓已完成签收退货单及可用 SKU 数量 } }
/api/pda/v1/last-leg/return-inventory/cartons/{cartonMark}/label:
get:
summary: PDA 获取退货箱唛
description: 返回当前绑定仓退货箱的结构化数据及 100mm x 150mm 可打印 HTML,包含箱号、仓库、客户、签收批次及 SKU 数量。
security: [{ bearerAuth: [] }]
parameters: [{ name: cartonMark, in: path, required: true, schema: { type: string, maxLength: 128 } }]
responses: { '200': { description: 退货箱唛打印数据 }, '404': { description: 当前仓没有该退货箱 } }
components: components:
schemas: schemas:
ReturnOrderCreate:
type: object
required: [overseasWarehouseId, idempotencyKey, items]
properties:
overseasWarehouseId: { type: string, maxLength: 128 }
customerReferenceNo: { type: string, maxLength: 128 }
remark: { type: string, maxLength: 1000 }
idempotencyKey: { type: string, maxLength: 255 }
items:
type: array
minItems: 1
maxItems: 500
items:
type: object
required: [skuCode, expectedQuantity]
properties:
skuCode: { type: string, maxLength: 128 }
productNameZh: { type: string, maxLength: 255 }
expectedQuantity: { type: integer, minimum: 1 }
ReturnReceiptConfirm:
type: object
required: [returnNo, startedBy, items, idempotencyKey]
properties:
returnNo: { type: string, maxLength: 128 }
trackingNo: { type: string, nullable: true, maxLength: 128, description: '本次签收包裹有快递面单时扫描绑定;没有时为空' }
startedBy: { type: string, enum: [RETURN_NO, SKU], description: '本次通过退货单号查询或 SKU 候选开始签收' }
cartonMark: { type: string, nullable: true, maxLength: 128, description: PDA 实际绑定的箱号;不传时由系统生成 }
idempotencyKey: { type: string, maxLength: 255 }
items:
type: array
minItems: 1
maxItems: 500
items:
type: object
required: [skuCode, receivedQuantity]
properties:
skuCode: { type: string, maxLength: 128 }
receivedQuantity: { type: integer, minimum: 1 }
ReturnInventoryMerge:
type: object
required: [returnNos, items, length, width, height, weight, idempotencyKey]
properties:
returnNos:
type: array
minItems: 1
maxItems: 100
items: { type: string, minLength: 1, maxLength: 128 }
items:
type: array
minItems: 1
maxItems: 500
items:
type: object
required: [skuCode, quantity]
properties:
skuCode: { type: string, maxLength: 128 }
quantity: { type: integer, minimum: 1 }
length: { type: number, exclusiveMinimum: 0, description: '箱长,CM' }
width: { type: number, exclusiveMinimum: 0, description: '箱宽,CM' }
height: { type: number, exclusiveMinimum: 0, description: '箱高,CM' }
weight: { type: number, exclusiveMinimum: 0, description: '箱重,KG' }
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
InboundCodeRelationship: InboundCodeRelationship:
type: object type: object
required: [inboundCode, found, taillegOrders, inboundPlans, palletCodes, inventory] required: [inboundCode, found, taillegOrders, inboundPlans, palletCodes, inventory]
@@ -1645,7 +1811,7 @@ components:
required: [customerOrderNo, labelDeadline, contactName, contactPhone, chargeAmount, idempotencyKey, items] required: [customerOrderNo, labelDeadline, contactName, contactPhone, chargeAmount, idempotencyKey, items]
properties: properties:
customerOrderNo: { type: string, maxLength: 128 } customerOrderNo: { type: string, maxLength: 128 }
overseasWarehouseId: { type: string, nullable: true, maxLength: 128, description: 统一目的海外仓;明细未填写目的仓时使用 } operationWarehouseId: { type: string, maxLength: 128, description: 换标操作所在海外中转仓,和FBA目的仓分离 }
labelDeadline: { type: string, format: date-time } labelDeadline: { type: string, format: date-time }
contactName: { type: string, maxLength: 128 } contactName: { type: string, maxLength: 128 }
contactPhone: { type: string, maxLength: 64 } contactPhone: { type: string, maxLength: 64 }
@@ -1655,8 +1821,11 @@ components:
items: { type: array, minItems: 1, maxItems: 500, items: { $ref: '#/components/schemas/RelabelItem' } } items: { type: array, minItems: 1, maxItems: 500, items: { $ref: '#/components/schemas/RelabelItem' } }
RelabelItem: RelabelItem:
type: object type: object
required: [productNameZh, totalPackageCount, notifiedPackageCount, shipmentNo, newSkuCode, cartonMark, fbaCartonMark] required: [sourceInventoryBalanceId, sourceInventoryPositionId, sourceInboundOrderId, productNameZh, totalPackageCount, notifiedPackageCount, shipmentNo, cartonMark]
properties: properties:
sourceInventoryBalanceId: { type: string, pattern: '^[0-9]+$', description: 来源库存余额ID }
sourceInventoryPositionId: { type: string, pattern: '^[0-9]+$', description: 来源库存仓位ID;同箱任务唯一冻结依据 }
sourceInboundOrderId: { type: string, pattern: '^[0-9]+$', description: 来源历史入仓单ID }
skuCode: { type: string, nullable: true, maxLength: 128, description: 商品 SKU(选填) } skuCode: { type: string, nullable: true, maxLength: 128, description: 商品 SKU(选填) }
productImageFileId: { type: string, nullable: true, maxLength: 128 } productImageFileId: { type: string, nullable: true, maxLength: 128 }
productImageFileName: { type: string, nullable: true, maxLength: 255 } productImageFileName: { type: string, nullable: true, maxLength: 255 }
@@ -1666,14 +1835,24 @@ components:
notifiedPackageCount: { type: integer, minimum: 1 } notifiedPackageCount: { type: integer, minimum: 1 }
shipmentNo: { type: string, maxLength: 128 } shipmentNo: { type: string, maxLength: 128 }
trackingCode: { type: string, nullable: true, maxLength: 255 } trackingCode: { type: string, nullable: true, maxLength: 255 }
newSkuCode: { type: string, maxLength: 128 } newSkuCode: { type: string, nullable: true, maxLength: 128, description: 箱唛换标时的单个新SKU兼容字段 }
labelRequirement: { type: string, nullable: true, maxLength: 1000 } labelRequirement: { type: string, nullable: true, maxLength: 1000 }
relabelFileId: { type: string, nullable: true, maxLength: 128 } relabelFileId: { type: string, nullable: true, maxLength: 128 }
relabelFileName: { type: string, nullable: true, maxLength: 255 } relabelFileName: { type: string, nullable: true, maxLength: 255 }
relabelFileUrl: { type: string, nullable: true, maxLength: 1000 } relabelFileUrl: { type: string, nullable: true, maxLength: 1000 }
cartonMark: { type: string, maxLength: 255, description: 客户自有箱唛(不在编辑列表展示) } cartonMark: { type: string, maxLength: 255, description: 我方箱唛;同一申请内不可重复,整箱库存只冻结一次 }
fbaCartonMark: { type: string, maxLength: 255, description: 关联 FBA 箱唛 } fbaCartonMark: { type: string, nullable: true, maxLength: 255, description: 新FBA箱唛;箱唛或组合换标必填 }
destinationWarehouseId: { type: string, nullable: true, maxLength: 128, description: 该箱目的海外仓;为空时使用申请统一目的仓 } destinationFbaWarehouseCode: { type: string, nullable: true, maxLength: 128, description: FBA目的仓编码;箱唛或组合换标必填 }
operationType: { type: string, enum: [CARTON_LABEL, SKU_LABEL, CARTON_AND_SKU], description: 箱唛换标、SKU换标或组合换标 }
skuItems: { type: array, maxItems: 100, description: 同一箱唛内SKU换标子明细,数量按件;SKU或组合换标必填, items: { $ref: '#/components/schemas/RelabelSkuItem' } }
RelabelSkuItem:
type: object
required: [skuCode, newSkuCode, quantity]
properties:
skuCode: { type: string, maxLength: 128, description: 原SKU }
newSkuCode: { type: string, maxLength: 128, description: 换标后SKU }
quantity: { type: integer, minimum: 1, description: 换标件数 }
remark: { type: string, nullable: true, maxLength: 1000 }
RelabelStatusChange: RelabelStatusChange:
type: object type: object
required: [status] required: [status]
@@ -185,7 +185,7 @@ paths:
responses: { '200': { description: 已认领任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } }, '409': { description: 仓库、状态或幂等冲突 } } responses: { '200': { description: 已认领任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } }, '409': { description: 仓库、状态或幂等冲突 } }
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/units: /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/units:
post: post:
summary: 登记一件正常换标事实 summary: 扫描我方箱唛并登记整箱正常换标事实;可只传oldLabel由服务端定位箱唛任务
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelUnit' } } } } requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelUnit' } } } }
responses: { '200': { description: 更新后的任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } }, '409': { description: 旧标重复、新标不符或版本冲突 } } responses: { '200': { description: 更新后的任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } }, '409': { description: 旧标重复、新标不符或版本冲突 } }
@@ -485,10 +485,22 @@ components:
relabelFileId: { type: string, nullable: true } relabelFileId: { type: string, nullable: true }
relabelFileName: { type: string, nullable: true } relabelFileName: { type: string, nullable: true }
relabelFileUrl: { type: string, nullable: true } relabelFileUrl: { type: string, nullable: true }
cartonMark: { type: string, nullable: true, description: 我方箱唛 }
fbaCartonMark: { type: string, nullable: true, description: 新FBA箱唛 }
operationType: { type: string, enum: [CARTON_LABEL, SKU_LABEL, CARTON_AND_SKU] }
skuItems: { type: array, items: { $ref: '#/components/schemas/RelabelSkuItem' } }
expectedPackages: { type: integer } expectedPackages: { type: integer }
relabelledPackages: { type: integer } relabelledPackages: { type: integer }
skippedPackages: { type: integer } skippedPackages: { type: integer }
exceptionPackages: { type: integer } exceptionPackages: { type: integer }
RelabelSkuItem:
type: object
required: [skuCode, newSkuCode, quantity]
properties:
skuCode: { type: string }
newSkuCode: { type: string }
quantity: { type: integer, description: 换标件数 }
remark: { type: string, nullable: true }
RelabelUnitFact: RelabelUnitFact:
type: object type: object
properties: properties:
@@ -34,7 +34,7 @@ class MerchantDeliveryControllerTest {
private final DeliveryTransferService service = mock(DeliveryTransferService.class); private final DeliveryTransferService service = mock(DeliveryTransferService.class);
private final MerchantDeliveryController controller = controller(); private final MerchantDeliveryController controller = controller();
private final DeliveryRequests.Query query = new DeliveryRequests.Query( private final DeliveryRequests.Query query = new DeliveryRequests.Query(
1, 20, null, null, null, null, null, null, null, null, null, null); 1, 20, null, null, null, null, null, null, null, null, null, null, null);
@Test @Test
void fixedRoutesUseIndependentListAndCreatePermissionsWithoutCustomerParameter() throws Exception { void fixedRoutesUseIndependentListAndCreatePermissionsWithoutCustomerParameter() throws Exception {
@@ -40,7 +40,7 @@ class InboundPlanControllerTest {
@Test @Test
void platformPageAllowsAllCustomersWhenCustomerIdIsOmitted() { void platformPageAllowsAllCustomersWhenCustomerIdIsOmitted() {
InboundPlanRequests.Query query = new InboundPlanRequests.Query(1, 20, null, null, null, InboundPlanRequests.Query query = new InboundPlanRequests.Query(1, 20, null, null, null, null,
null, null, null, null, null, null, null, null); null, null, null, null, null, null, null, null);
when(service.page(null, query)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); when(service.page(null, query)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
@@ -45,7 +45,7 @@ class InboundPlanPaginationTest {
@Test @Test
void pagesAtLineGranularityAndPassesTenantAndAllFiltersToDatabase() { void pagesAtLineGranularityAndPassesTenantAndAllFiltersToDatabase() {
InboundPlanRequests.Query query = new InboundPlanRequests.Query(2, 10, "LIP", "IN-1", InboundPlanRequests.Query query = new InboundPlanRequests.Query(2, 10, "LIP", "IN-1",
"PENDING_ASSIGNMENT", null, null, "ONT", "ONT8", "UNAPPOINTED", "UNSCHEDULED", null, "PENDING_ASSIGNMENT", null, null, "ONT", "ONT8", "UNAPPOINTED", "UNSCHEDULED",
LocalDateTime.of(2026, 8, 1, 0, 0), LocalDateTime.of(2026, 8, 31, 23, 59)); LocalDateTime.of(2026, 8, 1, 0, 0), LocalDateTime.of(2026, 8, 31, 23, 59));
InboundPlanListResponse row = new InboundPlanListResponse(9007199254740993L, 2L, "LIP2", "100", InboundPlanListResponse row = new InboundPlanListResponse(9007199254740993L, 2L, "LIP2", "100",
3L, "IN-1", "FIRST_LEG", "W1", "ONT", "安大略仓", "ONT8", "客户A", 10, 3L, "IN-1", "FIRST_LEG", "W1", "ONT", "安大略仓", "ONT8", "客户A", 10,
@@ -78,7 +78,7 @@ class InboundPlanPaginationTest {
} }
private static InboundPlanRequests.Query query(long page, long size) { private static InboundPlanRequests.Query query(long page, long size) {
return new InboundPlanRequests.Query(page, size, null, null, null, null, null, return new InboundPlanRequests.Query(page, size, null, null, null, null, null, null,
null, null, null, null, null, null); null, null, null, null, null, null);
} }
} }
@@ -150,7 +150,7 @@ class InboundPlanServiceTest {
@Test @Test
void rejectsReversedInboundPlanDateRange() { void rejectsReversedInboundPlanDateRange() {
InboundPlanRequests.Query query = new InboundPlanRequests.Query(1, 20, null, null, null, null, InboundPlanRequests.Query query = new InboundPlanRequests.Query(1, 20, null, null, null, null, null,
null, null, null, null, null, LocalDateTime.of(2026, 8, 20, 0, 0), null, null, null, null, null, LocalDateTime.of(2026, 8, 20, 0, 0),
LocalDateTime.of(2026, 8, 19, 0, 0)); LocalDateTime.of(2026, 8, 19, 0, 0));
@@ -59,7 +59,7 @@ class MerchantOverseasCustomerApiContractTest {
AuthenticatedUser merchant = user(101L, "MERCHANT"); AuthenticatedUser merchant = user(101L, "MERCHANT");
LocalLooseboardRequests.MerchantQuery looseboardQuery = new LocalLooseboardRequests.MerchantQuery(1, 20, LocalLooseboardRequests.MerchantQuery looseboardQuery = new LocalLooseboardRequests.MerchantQuery(1, 20,
null, null, null, null, null, null); null, null, null, null, null, null);
InboundPlanRequests.Query inboundQuery = new InboundPlanRequests.Query(1, 20, null, null, null, InboundPlanRequests.Query inboundQuery = new InboundPlanRequests.Query(1, 20, null, null, null, null,
null, null, null, null, null, null, null, null); null, null, null, null, null, null, null, null);
looseboard.page(merchant, looseboardQuery); looseboard.page(merchant, looseboardQuery);
@@ -16,6 +16,11 @@ import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper; import com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator; import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.baiyue.lastleg.wallet.WalletCommandOutboxService; import com.baiyue.lastleg.wallet.WalletCommandOutboxService;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -50,6 +55,9 @@ class RelabelApplicationServiceTest {
@Mock LastlegOrderMapper orders; @Mock LastlegOrderMapper orders;
@Mock LastlegOrderStatusHistoryMapper orderHistories; @Mock LastlegOrderStatusHistoryMapper orderHistories;
@Mock WalletCommandOutboxService walletCommands; @Mock WalletCommandOutboxService walletCommands;
@Mock InventoryCommandService inventoryCommands;
@Mock InventoryBalanceMapper inventoryBalances;
@Mock InventoryPositionMapper inventoryPositions;
@Spy LastlegOrderNoGenerator orderNoGenerator = new LastlegOrderNoGenerator(); @Spy LastlegOrderNoGenerator orderNoGenerator = new LastlegOrderNoGenerator();
private RelabelApplicationService service; private RelabelApplicationService service;
@@ -64,8 +72,19 @@ class RelabelApplicationServiceTest {
ReflectionTestUtils.setField(service, "orderHistories", orderHistories); ReflectionTestUtils.setField(service, "orderHistories", orderHistories);
ReflectionTestUtils.setField(service, "orderNoGenerator", orderNoGenerator); ReflectionTestUtils.setField(service, "orderNoGenerator", orderNoGenerator);
ReflectionTestUtils.setField(service, "walletCommands", walletCommands); ReflectionTestUtils.setField(service, "walletCommands", walletCommands);
ReflectionTestUtils.setField(service, "inventoryCommands", inventoryCommands);
ReflectionTestUtils.setField(service, "inventoryBalances", inventoryBalances);
ReflectionTestUtils.setField(service, "inventoryPositions", inventoryPositions);
ReflectionTestUtils.setField(service, "objectMapper", mapper()); ReflectionTestUtils.setField(service, "objectMapper", mapper());
lenient().when(walletCommands.publish(any(), any(Long.class), any(), any(), any(), any(), any(Long.class))).thenReturn("wallet-event-1"); lenient().when(walletCommands.publish(any(), any(Long.class), any(), any(), any(), any(), any(Long.class))).thenReturn("wallet-event-1");
lenient().when(inventoryPositions.findTenantPosition(any(), any())).thenAnswer(invocation -> {
InventoryPosition value = new InventoryPosition(); value.setId(invocation.getArgument(1));
value.setBalanceId(101L); value.setAvailablePackages(100L); return value;
});
InventoryBalance balance = new InventoryBalance(); balance.setId(101L); balance.setCustomerId("10001");
balance.setOverseasWarehouseId("WH-1"); balance.setInboundOrderId(301L); balance.setInboundOrderNo("IN-1");
balance.setInboundCode("CODE-1"); balance.setSkuCode("SKU-A"); balance.setProductNameZh("Chinese name");
lenient().when(inventoryBalances.selectById(any(Long.class))).thenReturn(balance);
} }
@Test @Test
@@ -129,17 +148,17 @@ class RelabelApplicationServiceTest {
RelabelRequests.Create request = new RelabelRequests.Create("CUSTOMER-1", "WH-1", RelabelRequests.Create request = new RelabelRequests.Create("CUSTOMER-1", "WH-1",
LocalDateTime.of(2026, 8, 20, 10, 0), "Li", "13800000000", BigDecimal.ONE, null, "key-marks", LocalDateTime.of(2026, 8, 20, 10, 0), "Li", "13800000000", BigDecimal.ONE, null, "key-marks",
List.of(attachedItem)); List.of(attachedItem));
RelabelApplication persisted = application(11L, "FBR-1", "100", "key-files", null, RelabelApplication persisted = application(11L, "FBR-1", "10001", "key-files", null,
RelabelApplicationService.PENDING_PROCESSING); RelabelApplicationService.PENDING_PROCESSING);
persisted.setLastlegOrderId(21L); persisted.setItemCount(1); persisted.setTotalPackageCount(2); persisted.setLastlegOrderId(21L); persisted.setItemCount(1); persisted.setTotalPackageCount(2);
persisted.setNotifiedPackageCount(2); persisted.setNotifiedPackageCount(2);
when(applications.findNo(eq("100"), any())).thenReturn(persisted); when(applications.findNo(eq("10001"), any())).thenReturn(persisted);
when(items.selectList(any())).thenReturn(List.of(item(31L, 11L, "SKU-A", 2, 2))); when(items.selectList(any())).thenReturn(List.of(item(31L, 11L, "SKU-A", 2, 2)));
when(histories.selectList(any())).thenReturn(List.of()); when(histories.selectList(any())).thenReturn(List.of());
when(orders.selectById(21L)).thenReturn(order(21L, "100", "LMO-RELABEL-1", when(orders.selectById(21L)).thenReturn(order(21L, "10001", "LMO-RELABEL-1",
RelabelApplicationService.PENDING_PROCESSING)); RelabelApplicationService.PENDING_PROCESSING));
service.create("100", "operator-1", request); service.create("10001", "operator-1", request);
ArgumentCaptor<RelabelItem> saved = ArgumentCaptor.forClass(RelabelItem.class); ArgumentCaptor<RelabelItem> saved = ArgumentCaptor.forClass(RelabelItem.class);
verify(items).insert(saved.capture()); verify(items).insert(saved.capture());
@@ -149,10 +168,10 @@ class RelabelApplicationServiceTest {
} }
@Test @Test
void createGroupsCartonsIntoOneTailOrderPerDestinationWarehouse() { void createKeepsOneOperationWarehouseWhenItemsHaveDifferentFbaDestinations() {
RelabelRequests.Item west = carton("SKU-A", "BOX-1", "FBA-1", "ONT8"); RelabelRequests.Item west = carton("SKU-A", "BOX-1", "FBA-1", "ONT8");
RelabelRequests.Item east = carton("SKU-B", "BOX-2", "FBA-2", "ABE8"); RelabelRequests.Item east = carton("SKU-B", "BOX-2", "FBA-2", "ABE8");
RelabelRequests.Create request = new RelabelRequests.Create("CUSTOMER-1", null, RelabelRequests.Create request = new RelabelRequests.Create("CUSTOMER-1", "WH-1",
LocalDateTime.of(2026, 8, 20, 10, 0), "Li", "13800000000", BigDecimal.ONE, null, "key-multi", LocalDateTime.of(2026, 8, 20, 10, 0), "Li", "13800000000", BigDecimal.ONE, null, "key-multi",
List.of(west, east)); List.of(west, east));
RelabelApplication persisted = application(11L, "FBR-1", "10001", "key-multi", null, RelabelApplication persisted = application(11L, "FBR-1", "10001", "key-multi", null,
@@ -167,10 +186,9 @@ class RelabelApplicationServiceTest {
service.create("10001", "operator-1", request); service.create("10001", "operator-1", request);
ArgumentCaptor<LastlegOrder> created = ArgumentCaptor.forClass(LastlegOrder.class); ArgumentCaptor<LastlegOrder> created = ArgumentCaptor.forClass(LastlegOrder.class);
verify(orders, times(2)).insert(created.capture()); verify(orders).insert(created.capture());
assertEquals(List.of("ONT8", "ABE8"), created.getAllValues().stream() assertEquals("WH-1", created.getValue().getOverseasWarehouseId());
.map(LastlegOrder::getOverseasWarehouseId).toList()); assertEquals(2, created.getValue().getPackageCount());
assertTrue(created.getAllValues().stream().allMatch(value -> value.getPackageCount() == 1));
} }
@Test @Test
@@ -189,7 +207,7 @@ class RelabelApplicationServiceTest {
@Test @Test
void createAllowsCartonWithoutSku() { void createAllowsCartonWithoutSku() {
RelabelRequests.Item withoutSku = carton(null, "BOX-NO-SKU", "FBA-NO-SKU", "ONT8"); RelabelRequests.Item withoutSku = carton(null, "BOX-NO-SKU", "FBA-NO-SKU", "ONT8");
RelabelRequests.Create request = new RelabelRequests.Create("CUSTOMER-NO-SKU", null, RelabelRequests.Create request = new RelabelRequests.Create("CUSTOMER-NO-SKU", "WH-1",
LocalDateTime.of(2026, 9, 10, 10, 0), "Li", "13800000000", BigDecimal.ONE, LocalDateTime.of(2026, 9, 10, 10, 0), "Li", "13800000000", BigDecimal.ONE,
null, "key-no-sku", List.of(withoutSku)); null, "key-no-sku", List.of(withoutSku));
RelabelApplication persisted = application(11L, "FBR-1", "10001", "key-no-sku", null, RelabelApplication persisted = application(11L, "FBR-1", "10001", "key-no-sku", null,
@@ -314,9 +332,10 @@ class RelabelApplicationServiceTest {
private static RelabelRequests.Item carton(String sku, String cartonMark, String fbaCartonMark, private static RelabelRequests.Item carton(String sku, String cartonMark, String fbaCartonMark,
String destinationWarehouseId) { String destinationWarehouseId) {
return new RelabelRequests.Item(sku, null, null, null, "Chinese name", 1, 1, long positionId = "ABE8".equals(destinationWarehouseId) ? 202L : 201L;
"SHP-1", null, sku + "-NEW", null, null, null, null, null, return new RelabelRequests.Item(101L, positionId, 301L, sku, null, null, null,
cartonMark, fbaCartonMark, destinationWarehouseId); "Chinese name", 1, 1, "SHP-1", null, sku + "-NEW", null, null,
null, null, null, cartonMark, fbaCartonMark, destinationWarehouseId);
} }
private static RelabelApplication application(long id, String no, String customerId, String key, String requestHash, private static RelabelApplication application(long id, String no, String customerId, String key, String requestHash,
@@ -22,6 +22,10 @@ class LastlegSecurityContractTest {
"CREATE TABLE IF NOT EXISTS ll_security_projection", "CREATE TABLE IF NOT EXISTS ll_security_projection",
"PRIMARY KEY (aggregate_type,aggregate_id)", "aggregate_version BIGINT", "PRIMARY KEY (aggregate_type,aggregate_id)", "aggregate_version BIGINT",
"permission_version BIGINT", "roles_json JSON", "authorities_json JSON"); "permission_version BIGINT", "roles_json JSON", "authorities_json JSON");
assertThat(baseline).contains("table_name='ll_security_projection' AND column_name='roles_json'",
"table_name='ll_security_projection' AND column_name='authorities_json'",
"UPDATE ll_security_projection SET roles_json=JSON_ARRAY() WHERE roles_json IS NULL",
"UPDATE ll_security_projection SET authorities_json=JSON_ARRAY() WHERE authorities_json IS NULL");
for (String sql : new String[]{baseline, migration}) { for (String sql : new String[]{baseline, migration}) {
assertThat(sql).contains("('USER','1000001',0,'ACTIVE',0,JSON_ARRAY(),JSON_ARRAY(),0", assertThat(sql).contains("('USER','1000001',0,'ACTIVE',0,JSON_ARRAY(),JSON_ARRAY(),0",
"('AUTHORIZATION','1000001',0,NULL,0,JSON_ARRAY(),JSON_ARRAY(),0", "('AUTHORIZATION','1000001',0,NULL,0,JSON_ARRAY(),JSON_ARRAY(),0",
+109
View File
@@ -3834,6 +3834,34 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
'SELECT 1', 'ALTER TABLE ll_cargo_discard_application ADD COLUMN review_idempotency_key VARCHAR(255) NULL COMMENT ''审核幂等键'' AFTER request_hash')); 'SELECT 1', 'ALTER TABLE ll_cargo_discard_application ADD COLUMN review_idempotency_key VARCHAR(255) NULL COMMENT ''审核幂等键'' AFTER request_hash'));
PREPARE phase4_discard_review_stmt FROM @ddl; EXECUTE phase4_discard_review_stmt; DEALLOCATE PREPARE phase4_discard_review_stmt; PREPARE phase4_discard_review_stmt FROM @ddl; EXECUTE phase4_discard_review_stmt; DEALLOCATE PREPARE phase4_discard_review_stmt;
-- ============================================================================
-- 2026-09-07 / 贴标申请绑定原入仓库存并拆分FBA目的仓
-- ============================================================================
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='destination_fba_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN destination_fba_warehouse_code VARCHAR(128) NULL COMMENT ''FBA目的仓编码(与海外操作仓分离)'''));
PREPARE relabel_inventory_stmt FROM @ddl; EXECUTE relabel_inventory_stmt; DEALLOCATE PREPARE relabel_inventory_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='operation_type'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN operation_type VARCHAR(32) NOT NULL DEFAULT ''CARTON_LABEL'' COMMENT ''换标模式:箱唛、SKU或组合换标'''));
PREPARE relabel_inventory_stmt FROM @ddl; EXECUTE relabel_inventory_stmt; DEALLOCATE PREPARE relabel_inventory_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='sku_relabel_items_json'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN sku_relabel_items_json JSON NULL COMMENT ''同箱SKU换标子明细快照(数量按件)'''));
PREPARE relabel_inventory_stmt FROM @ddl; EXECUTE relabel_inventory_stmt; DEALLOCATE PREPARE relabel_inventory_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_inventory_balance_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_inventory_balance_id BIGINT NULL COMMENT ''原库存余额ID'' AFTER destination_fba_warehouse_code'));
PREPARE relabel_inventory_stmt FROM @ddl; EXECUTE relabel_inventory_stmt; DEALLOCATE PREPARE relabel_inventory_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_inventory_position_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_inventory_position_id BIGINT NULL COMMENT ''原库存库位ID'' AFTER source_inventory_balance_id'));
PREPARE relabel_inventory_stmt FROM @ddl; EXECUTE relabel_inventory_stmt; DEALLOCATE PREPARE relabel_inventory_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_inbound_order_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_inbound_order_id BIGINT NULL COMMENT ''原入仓单ID'' AFTER source_inventory_position_id'));
PREPARE relabel_inventory_stmt FROM @ddl; EXECUTE relabel_inventory_stmt; DEALLOCATE PREPARE relabel_inventory_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_inbound_order_no'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_inbound_order_no VARCHAR(128) NULL COMMENT ''原入仓单号快照'' AFTER source_inbound_order_id'));
PREPARE relabel_inventory_stmt FROM @ddl; EXECUTE relabel_inventory_stmt; DEALLOCATE PREPARE relabel_inventory_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_inbound_code'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_inbound_code VARCHAR(128) NULL COMMENT ''原进仓编码快照'' AFTER source_inbound_order_no'));
PREPARE relabel_inventory_stmt FROM @ddl; EXECUTE relabel_inventory_stmt; DEALLOCATE PREPARE relabel_inventory_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_warehouse_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_warehouse_id VARCHAR(128) NULL COMMENT ''原海外中转仓ID'' AFTER source_inbound_code'));
PREPARE relabel_inventory_stmt FROM @ddl; EXECUTE relabel_inventory_stmt; DEALLOCATE PREPARE relabel_inventory_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_warehouse_code VARCHAR(128) NULL COMMENT ''原海外中转仓编码快照'' AFTER source_warehouse_id'));
PREPARE relabel_inventory_stmt FROM @ddl; EXECUTE relabel_inventory_stmt; DEALLOCATE PREPARE relabel_inventory_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_warehouse_name'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_warehouse_name VARCHAR(255) NULL COMMENT ''原海外中转仓名称快照'' AFTER source_warehouse_code'));
PREPARE relabel_inventory_stmt FROM @ddl; EXECUTE relabel_inventory_stmt; DEALLOCATE PREPARE relabel_inventory_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND index_name='idx_ll_relabel_item_source_position'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD KEY idx_ll_relabel_item_source_position (customer_id,source_inventory_position_id)'));
PREPARE relabel_inventory_stmt FROM @ddl; EXECUTE relabel_inventory_stmt; DEALLOCATE PREPARE relabel_inventory_stmt;
USE baiyue_lastleg; USE baiyue_lastleg;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='lx_codes'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN lx_codes VARCHAR(1000) NULL COMMENT ''关联一件代发领星入库单代码汇总'' AFTER outbound_type')); SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='lx_codes'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN lx_codes VARCHAR(1000) NULL COMMENT ''关联一件代发领星入库单代码汇总'' AFTER outbound_type'));
PREPARE ll_outbound_lx_codes_stmt FROM @ddl; EXECUTE ll_outbound_lx_codes_stmt; DEALLOCATE PREPARE ll_outbound_lx_codes_stmt; PREPARE ll_outbound_lx_codes_stmt FROM @ddl; EXECUTE ll_outbound_lx_codes_stmt; DEALLOCATE PREPARE ll_outbound_lx_codes_stmt;
@@ -3886,3 +3914,84 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
AND table_name='ll_cargo_discard_application' AND column_name='review_request_hash'), AND table_name='ll_cargo_discard_application' AND column_name='review_request_hash'),
'SELECT 1', 'ALTER TABLE ll_cargo_discard_application ADD COLUMN review_request_hash CHAR(64) NULL COMMENT ''审核请求内容哈希'' AFTER review_idempotency_key')); 'SELECT 1', 'ALTER TABLE ll_cargo_discard_application ADD COLUMN review_request_hash CHAR(64) NULL COMMENT ''审核请求内容哈希'' AFTER review_idempotency_key'));
PREPARE phase4_discard_review_stmt FROM @ddl; EXECUTE phase4_discard_review_stmt; DEALLOCATE PREPARE phase4_discard_review_stmt; PREPARE phase4_discard_review_stmt FROM @ddl; EXECUTE phase4_discard_review_stmt; DEALLOCATE PREPARE phase4_discard_review_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='carton_marks'), 'SELECT 1', 'ALTER TABLE ll_outbound_pallet_item ADD COLUMN carton_marks VARCHAR(4000) NULL COMMENT ''本次出库指定箱唛,逗号分隔'' AFTER sku_code'));
PREPARE outbound_carton_marks_stmt FROM @ddl; EXECUTE outbound_carton_marks_stmt; DEALLOCATE PREPARE outbound_carton_marks_stmt;
-- 2026-09-07 / 032 客户退货预报、PDA 签收及仓储费起算批次。
USE baiyue_lastleg;
CREATE TABLE IF NOT EXISTS ll_return_order (
id BIGINT NOT NULL COMMENT '退货订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', return_no VARCHAR(128) NOT NULL COMMENT '退货订单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '操作海外仓ID', customer_reference_no VARCHAR(128) NULL COMMENT '客户参考号', status VARCHAR(32) NOT NULL COMMENT 'PENDING_RECEIPT、PARTIALLY_RECEIVED或COMPLETED', expected_sku_types INT NOT NULL COMMENT '预报SKU种类数', expected_quantity BIGINT NOT NULL COMMENT '预报总数量', received_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '累计实收总数量', remark VARCHAR(1000) NULL COMMENT '客户备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键', request_hash CHAR(64) NOT NULL COMMENT '创建请求SHA-256', created_by VARCHAR(128) NULL COMMENT '创建操作人ID', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_return_order_customer_no(customer_id,return_no), UNIQUE KEY uk_ll_return_order_customer_key(customer_id,idempotency_key), KEY idx_ll_return_order_warehouse_status(overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_order_quantity CHECK(expected_sku_types>0 AND expected_quantity>0 AND received_quantity>=0 AND received_quantity<=expected_quantity)
) ENGINE=InnoDB COMMENT='客户退货订单及SKU实收汇总';
CREATE TABLE IF NOT EXISTS ll_return_order_item (
id BIGINT NOT NULL COMMENT '退货预报明细ID', return_order_id BIGINT NOT NULL COMMENT '退货订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', expected_quantity BIGINT NOT NULL COMMENT 'SKU预报数量', received_quantity BIGINT NOT NULL DEFAULT 0 COMMENT 'SKU累计实收数量', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_return_item_order_sku(return_order_id,sku_code), KEY idx_ll_return_item_sku(sku_code,received_quantity), CONSTRAINT chk_ll_return_item_quantity CHECK(expected_quantity>0 AND received_quantity>=0 AND received_quantity<=expected_quantity)
) ENGINE=InnoDB COMMENT='客户退货订单的SKU预报与实收明细';
CREATE TABLE IF NOT EXISTS ll_return_receipt (
id BIGINT NOT NULL COMMENT 'PDA退货签收单ID', return_order_id BIGINT NOT NULL COMMENT '退货订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', receipt_no VARCHAR(128) NOT NULL COMMENT '签收单号', receipt_batch_no VARCHAR(128) NOT NULL COMMENT '签收批次号', tracking_no VARCHAR(128) NULL COMMENT '本次签收包裹的可选快递单号', bound_by VARCHAR(16) NOT NULL COMMENT '开始签收方式,RETURN_NO退货单号或SKU候选', status VARCHAR(32) NOT NULL COMMENT '签收状态,当前SIGNED', received_quantity BIGINT NOT NULL COMMENT '本次实收数量', operator_id VARCHAR(128) NOT NULL COMMENT 'PDA操作人ID', operator_name VARCHAR(128) NULL COMMENT 'PDA操作人名称', idempotency_key VARCHAR(255) NOT NULL COMMENT 'PDA确认幂等键', request_hash CHAR(64) NOT NULL COMMENT 'PDA确认请求SHA-256', signed_at DATETIME(3) NOT NULL COMMENT '签收时间', created_at DATETIME(3) NOT NULL COMMENT '创建时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_return_receipt_no(receipt_no), UNIQUE KEY uk_ll_return_receipt_batch(receipt_batch_no), UNIQUE KEY uk_ll_return_receipt_operator_key(operator_id,idempotency_key), KEY idx_ll_return_receipt_order(return_order_id,signed_at), CONSTRAINT chk_ll_return_receipt_quantity CHECK(received_quantity>0), CONSTRAINT chk_ll_return_receipt_bound CHECK(bound_by IN('RETURN_NO','SKU'))
) ENGINE=InnoDB COMMENT='PDA退货实收签收事实';
-- 已创建开发库升级为“客户只预报 SKU,快递单号仅属于签收批次”的最终模型。
DROP PROCEDURE IF EXISTS migrate_return_forecast_sku_only;
DELIMITER $$
CREATE PROCEDURE migrate_return_forecast_sku_only()
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='uk_ll_return_item_order_tracking_sku') THEN
ALTER TABLE ll_return_order_item DROP INDEX uk_ll_return_item_order_tracking_sku;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='idx_ll_return_item_tracking') THEN
ALTER TABLE ll_return_order_item DROP INDEX idx_ll_return_item_tracking;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='idx_ll_return_item_order') THEN
ALTER TABLE ll_return_order_item DROP INDEX idx_ll_return_item_order;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND column_name='tracking_no') THEN
ALTER TABLE ll_return_order_item DROP COLUMN tracking_no;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='uk_ll_return_item_order_sku') THEN
ALTER TABLE ll_return_order_item ADD UNIQUE KEY uk_ll_return_item_order_sku(return_order_id,sku_code);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='idx_ll_return_item_sku') THEN
ALTER TABLE ll_return_order_item ADD KEY idx_ll_return_item_sku(sku_code,received_quantity);
END IF;
UPDATE ll_return_receipt SET bound_by='RETURN_NO' WHERE bound_by='TRACKING';
IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_receipt' AND constraint_name='chk_ll_return_receipt_bound') THEN
ALTER TABLE ll_return_receipt DROP CHECK chk_ll_return_receipt_bound;
END IF;
ALTER TABLE ll_return_receipt
MODIFY COLUMN tracking_no VARCHAR(128) NULL COMMENT '本次签收包裹的可选快递单号',
MODIFY COLUMN bound_by VARCHAR(16) NOT NULL COMMENT '开始签收方式,RETURN_NO退货单号或SKU候选',
ADD CONSTRAINT chk_ll_return_receipt_bound CHECK(bound_by IN('RETURN_NO','SKU'));
END$$
DELIMITER ;
CALL migrate_return_forecast_sku_only();
DROP PROCEDURE IF EXISTS migrate_return_forecast_sku_only;
CREATE TABLE IF NOT EXISTS ll_return_receipt_item (
id BIGINT NOT NULL COMMENT '签收SKU明细ID', receipt_id BIGINT NOT NULL COMMENT 'PDA退货签收单ID', return_order_item_id BIGINT NOT NULL COMMENT '退货预报明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', received_quantity BIGINT NOT NULL COMMENT '本次SKU实收数量', PRIMARY KEY(id), KEY idx_ll_return_receipt_item_receipt(receipt_id), KEY idx_ll_return_receipt_item_order_item(return_order_item_id), CONSTRAINT chk_ll_return_receipt_item_quantity CHECK(received_quantity>0)
) ENGINE=InnoDB COMMENT='PDA退货签收SKU实际数量明细';
CREATE TABLE IF NOT EXISTS ll_return_receipt_batch (
id BIGINT NOT NULL COMMENT '退货仓储计费批次ID', return_order_id BIGINT NOT NULL COMMENT '退货订单ID', receipt_id BIGINT NOT NULL COMMENT 'PDA退货签收单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', batch_no VARCHAR(128) NOT NULL COMMENT '签收批次号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '操作海外仓ID', storage_billing_status VARCHAR(32) NOT NULL COMMENT '仓储计费状态,初始PENDING_PRICING', storage_started_at DATETIME(3) NOT NULL COMMENT '仓储计费起算时间', created_at DATETIME(3) NOT NULL COMMENT '创建时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_return_batch_no(batch_no), UNIQUE KEY uk_ll_return_batch_receipt(receipt_id), KEY idx_ll_return_batch_billing(customer_id,overseas_warehouse_id,storage_billing_status,storage_started_at)
) ENGINE=InnoDB COMMENT='每次退货签收生成的仓储费起算批次';
-- 2026-09-07 / 033 退货库存、退货箱唛和PDA同客户同SKU合箱。
USE baiyue_lastleg;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='carton_mark'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN carton_mark VARCHAR(128) NULL COMMENT ''签收生成或绑定的退货箱号'' AFTER receipt_batch_no'));
PREPARE return_receipt_carton_mark_incremental_stmt FROM @ddl; EXECUTE return_receipt_carton_mark_incremental_stmt; DEALLOCATE PREPARE return_receipt_carton_mark_incremental_stmt;
CREATE TABLE IF NOT EXISTS ll_return_inventory_carton (
id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', source_receipt_id BIGINT NULL COMMENT '来源PDA签收单ID', source_batch_no VARCHAR(128) NULL COMMENT '来源签收批次号', carton_mark VARCHAR(128) NOT NULL COMMENT '退货箱号和箱唛编码', status VARCHAR(32) NOT NULL COMMENT 'AVAILABLE可用或MERGED已合箱', sku_types INT NOT NULL COMMENT '箱内SKU种类数', total_quantity BIGINT NOT NULL COMMENT '箱内总件数', length DECIMAL(12,3) NULL COMMENT '箱长CM', width DECIMAL(12,3) NULL COMMENT '箱宽CM', height DECIMAL(12,3) NULL COMMENT '箱高CM', weight DECIMAL(12,3) NULL COMMENT '箱重KG', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', merged_at DATETIME(3) NULL COMMENT '合箱完成时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_mark(carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt(source_receipt_id), KEY idx_ll_return_inventory_carton_customer(customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK(sku_types>0 AND total_quantity>0), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED'))
) ENGINE=InnoDB COMMENT='退货实物库存箱及箱唛';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='source_return_no'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN source_return_no VARCHAR(2000) NULL COMMENT ''来源退货单号'' AFTER source_batch_no'));
PREPARE return_inventory_carton_return_no_stmt FROM @ddl; EXECUTE return_inventory_carton_return_no_stmt; DEALLOCATE PREPARE return_inventory_carton_return_no_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='length'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN length DECIMAL(12,3) NULL COMMENT ''箱长CM'', ADD COLUMN width DECIMAL(12,3) NULL COMMENT ''箱宽CM'', ADD COLUMN height DECIMAL(12,3) NULL COMMENT ''箱高CM'', ADD COLUMN weight DECIMAL(12,3) NULL COMMENT ''箱重KG'''));
PREPARE return_inventory_carton_dimension_stmt FROM @ddl; EXECUTE return_inventory_carton_dimension_stmt; DEALLOCATE PREPARE return_inventory_carton_dimension_stmt;
CREATE TABLE IF NOT EXISTS ll_return_inventory_carton_item (
id BIGINT NOT NULL COMMENT '退货库存箱SKU明细ID', carton_id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', quantity BIGINT NOT NULL COMMENT '该SKU箱内数量',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_item(carton_id,sku_code), KEY idx_ll_return_inventory_carton_item_customer(customer_id,sku_code), CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>0)
) ENGINE=InnoDB COMMENT='退货库存箱内SKU和数量';
CREATE TABLE IF NOT EXISTS ll_return_inventory_balance (
id BIGINT NOT NULL COMMENT '退货库存余额ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', available_quantity BIGINT NOT NULL COMMENT '可用上架数量', carton_quantity BIGINT NOT NULL COMMENT '包含该SKU的可用箱数', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_balance(customer_id,overseas_warehouse_id,sku_code), CONSTRAINT chk_ll_return_inventory_balance_quantity CHECK(available_quantity>=0 AND carton_quantity>=0)
) ENGINE=InnoDB COMMENT='退货库存按客户海外仓SKU汇总余额';
CREATE TABLE IF NOT EXISTS ll_return_inventory_operation (
id BIGINT NOT NULL COMMENT '退货库存PDA操作ID', operator_id VARCHAR(128) NOT NULL COMMENT 'PDA操作人ID', idempotency_key VARCHAR(255) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容SHA-256', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型,当前MERGE', result_carton_id BIGINT NULL COMMENT '合箱结果退货库存箱ID', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_operation_operator_key(operator_id,idempotency_key), KEY idx_ll_return_inventory_operation_result(result_carton_id)
) ENGINE=InnoDB COMMENT='退货库存PDA合箱幂等操作审计';
+80
View File
@@ -0,0 +1,80 @@
-- 海外仓换标字段增量(MySQL 8.0)
-- 目标数据库:baiyue_lastleg
-- 用途:为已有 ll_relabel_item 表补齐 FBA目的仓、箱唛/SKU/组合换标字段。
-- 说明:脚本可重复执行;不会修改或删除业务数据。
CREATE DATABASE IF NOT EXISTS baiyue_lastleg
DEFAULT CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE baiyue_lastleg;
DROP PROCEDURE IF EXISTS _tmp_add_relabel_fields;
DELIMITER $$
CREATE PROCEDURE _tmp_add_relabel_fields()
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name = 'll_relabel_item'
) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'll_relabel_item 不存在,请先初始化 baiyue_lastleg 基线表';
END IF;
IF NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'll_relabel_item'
AND column_name = 'destination_fba_warehouse_code'
) THEN
ALTER TABLE ll_relabel_item
ADD COLUMN destination_fba_warehouse_code VARCHAR(128) NULL
COMMENT 'FBA目的仓编码(与海外操作仓分离)';
END IF;
IF NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'll_relabel_item'
AND column_name = 'operation_type'
) THEN
ALTER TABLE ll_relabel_item
ADD COLUMN operation_type VARCHAR(32) NOT NULL DEFAULT 'CARTON_LABEL'
COMMENT '换标模式:箱唛、SKU或组合换标';
END IF;
IF NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'll_relabel_item'
AND column_name = 'sku_relabel_items_json'
) THEN
ALTER TABLE ll_relabel_item
ADD COLUMN sku_relabel_items_json JSON NULL
COMMENT '同箱SKU换标子明细快照(数量按件)';
END IF;
END$$
DELIMITER ;
CALL _tmp_add_relabel_fields();
DROP PROCEDURE IF EXISTS _tmp_add_relabel_fields;
SELECT
table_schema,
table_name,
column_name,
column_type,
is_nullable,
column_default,
ordinal_position,
column_comment
FROM information_schema.columns
WHERE table_schema = 'baiyue_lastleg'
AND table_name = 'll_relabel_item'
AND column_name IN ('destination_fba_warehouse_code', 'operation_type', 'sku_relabel_items_json')
ORDER BY ordinal_position;
+122
View File
@@ -0,0 +1,122 @@
-- 修复当前海外环境平台管理员的安全投影。
-- 适用数据库:baiyue_iam、baiyue_lastleg;脚本可重复执行。
USE baiyue_lastleg;
-- 旧环境可能缺少角色或权限快照列;先保证两列都存在。
SET @ddl = (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE ll_security_projection ADD COLUMN roles_json JSON NULL COMMENT ''IAM投影的当前角色快照'' AFTER permission_version',
'SELECT 1')
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'll_security_projection'
AND column_name = 'roles_json'
);
PREPARE security_projection_roles_ddl FROM @ddl;
EXECUTE security_projection_roles_ddl;
DEALLOCATE PREPARE security_projection_roles_ddl;
SET @ddl = (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE ll_security_projection ADD COLUMN authorities_json JSON NULL AFTER roles_json',
'SELECT 1')
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'll_security_projection'
AND column_name = 'authorities_json'
);
PREPARE security_projection_ddl FROM @ddl;
EXECUTE security_projection_ddl;
DEALLOCATE PREPARE security_projection_ddl;
UPDATE ll_security_projection
SET roles_json = JSON_ARRAY()
WHERE roles_json IS NULL;
UPDATE ll_security_projection
SET authorities_json = JSON_ARRAY()
WHERE authorities_json IS NULL;
-- 9000000001 是本地平台自定义管理员账号;权限来源以 IAM 当前角色授权为准。
INSERT INTO ll_security_projection (
aggregate_type, aggregate_id, tenant_id, security_status,
permission_version, roles_json, authorities_json, aggregate_version,
payload_hash, last_event_id, changed_at, updated_at
)
SELECT
'AUTHORIZATION', '9000000001', 0, NULL,
COALESCE((SELECT MAX(permission_version)
FROM baiyue_iam.iam_security_subject_version
WHERE auth_user_id = 9000000001), 0),
(SELECT JSON_ARRAYAGG(role_code)
FROM (
SELECT DISTINCT r.role_code
FROM baiyue_iam.iam_user u
JOIN baiyue_iam.iam_membership m ON m.user_id = u.id
JOIN baiyue_iam.iam_membership_role mr
ON mr.membership_id = m.id AND mr.tenant_id = m.tenant_id
JOIN baiyue_iam.iam_role r ON r.id = mr.role_id
WHERE u.auth_user_id = 9000000001
AND m.id = 9000000003 AND m.tenant_id = 0
AND m.status = 'ACTIVE' AND m.deleted = 0
AND r.app_code = 'PLATFORM'
AND (r.tenant_id = 0 OR r.tenant_id = m.tenant_id)
AND r.status = 'ACTIVE' AND r.deleted = 0
ORDER BY r.role_code
) roles),
(SELECT JSON_ARRAYAGG(permission_code)
FROM (
SELECT DISTINCT p.permission_code
FROM baiyue_iam.iam_user u
JOIN baiyue_iam.iam_membership m ON m.user_id = u.id
JOIN baiyue_iam.iam_membership_role mr
ON mr.membership_id = m.id AND mr.tenant_id = m.tenant_id
JOIN baiyue_iam.iam_role r ON r.id = mr.role_id
JOIN baiyue_iam.iam_role_permission rp
ON rp.role_id = r.id AND rp.tenant_id = r.tenant_id
JOIN baiyue_iam.iam_permission p
ON p.id = rp.permission_id AND p.tenant_id = rp.tenant_id
WHERE u.auth_user_id = 9000000001
AND m.id = 9000000003 AND m.tenant_id = 0
AND m.status = 'ACTIVE' AND m.deleted = 0
AND r.app_code = 'PLATFORM'
AND (r.tenant_id = 0 OR r.tenant_id = m.tenant_id)
AND r.status = 'ACTIVE' AND r.deleted = 0
AND p.app_code = 'PLATFORM'
AND (p.tenant_id = 0 OR p.tenant_id = m.tenant_id)
AND p.status = 'ACTIVE' AND p.deleted = 0
ORDER BY p.permission_code
) permissions),
COALESCE((SELECT MAX(permission_version)
FROM baiyue_iam.iam_security_subject_version
WHERE auth_user_id = 9000000001), 0),
SHA2('local-repair:9000000001:authorization', 256),
CONCAT('local-repair-9000000001-', DATE_FORMAT(NOW(3), '%Y%m%d%H%i%s%f')),
NOW(3), NOW(3)
ON DUPLICATE KEY UPDATE
tenant_id = VALUES(tenant_id),
permission_version = VALUES(permission_version),
roles_json = VALUES(roles_json),
authorities_json = VALUES(authorities_json),
aggregate_version = VALUES(aggregate_version),
payload_hash = VALUES(payload_hash),
last_event_id = VALUES(last_event_id),
changed_at = VALUES(changed_at),
updated_at = NOW(3);
-- 若未来 IAM 为该账号创建了版本记录,按当前版本同步投影版本号。
UPDATE ll_security_projection p
JOIN baiyue_iam.iam_security_subject_version v
ON v.auth_user_id = CAST(p.aggregate_id AS UNSIGNED)
SET p.permission_version = v.permission_version,
p.aggregate_version = v.permission_version,
p.updated_at = NOW(3)
WHERE p.aggregate_type = 'AUTHORIZATION'
AND p.aggregate_id = '9000000001';
SELECT aggregate_type, aggregate_id, tenant_id, permission_version,
JSON_LENGTH(roles_json) AS role_count,
JSON_LENGTH(authorities_json) AS authority_count,
updated_at
FROM ll_security_projection
WHERE aggregate_type = 'AUTHORIZATION' AND aggregate_id = '9000000001';