feat: complete dropship palletization workflow

This commit is contained in:
2026-09-08 17:24:48 +08:00
parent e9182e8cfe
commit 6c2d169798
61 changed files with 1242 additions and 1358 deletions
+1 -1
View File
@@ -57,7 +57,7 @@
| 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 | | 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 |
| 海外排车执行、PDA 出库、派送与一件代发 | `lastleg-service/` | `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`OutboundPdaController.java`、`MerchantDeliveryController.java`,一件代发库存归 `dropship/`;统一经 `gateway-overseas/`,设备端正式入口继续查 `../apps/pda-overseas-uniapp/AGENTS.md` | | 海外排车执行、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,`DropshipPalletizationService` 完成清点、组托和封托,智能排柜只按 `SEALED` 整托分配,并消费 `OverseasInboundOrderDetainedChangedV1`;Lastleg `dropship/` 建立库存并发布暂扣变更 |
| 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`transfer/`、`discard/` 的 Merchant Controller/Service;Client 消费端 `../apps/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` | | 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`transfer/`、`discard/` 的 Merchant Controller/Service;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 候选进入可收货 |
@@ -2860,6 +2860,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
cargo_item_no INT UNSIGNED NOT NULL COMMENT '订单货物明细序号快照', cargo_item_no INT UNSIGNED NOT NULL COMMENT '订单货物明细序号快照',
cargo_name VARCHAR(255) NULL COMMENT '货物名称快照', cargo_name VARCHAR(255) NULL COMMENT '货物名称快照',
sku_code VARCHAR(128) NULL COMMENT '箱唛绑定的SKU快照;二维码内容不变', sku_code VARCHAR(128) NULL COMMENT '箱唛绑定的SKU快照;二维码内容不变',
units_per_carton INT UNSIGNED NULL COMMENT '每箱SKU数量快照;一件代发必须为正数',
detail_package_count INT UNSIGNED NOT NULL COMMENT '该订单货物明细箱数快照', detail_package_count INT UNSIGNED NOT NULL COMMENT '该订单货物明细箱数快照',
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照', inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标', box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标',
@@ -2877,7 +2878,6 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_carton_label_box_code (tenant_id, box_code), UNIQUE KEY uk_carton_label_box_code (tenant_id, box_code),
UNIQUE KEY uk_carton_label_index (tenant_id, inbound_id, label_version, carton_index), UNIQUE KEY uk_carton_label_index (tenant_id, inbound_id, label_version, carton_index),
KEY idx_carton_label_active (tenant_id, inbound_id, batch_status, label_version),
KEY idx_carton_label_detail (tenant_id, inbound_id, inbound_detail_id), KEY idx_carton_label_detail (tenant_id, inbound_id, inbound_detail_id),
CONSTRAINT fk_carton_label_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) CONSTRAINT fk_carton_label_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='首次打印前冻结的实际箱号与订单货物明细映射'; ) ENGINE=InnoDB COMMENT='首次打印前冻结的实际箱号与订单货物明细映射';
@@ -5190,6 +5190,10 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item (
product_image_url VARCHAR(1000) NULL COMMENT '产品图片访问地址快照', product_image_url VARCHAR(1000) NULL COMMENT '产品图片访问地址快照',
package_count INT UNSIGNED NOT NULL COMMENT '本次装柜箱数', package_count INT UNSIGNED NOT NULL COMMENT '本次装柜箱数',
product_quantity INT UNSIGNED NULL COMMENT '产品总数量', product_quantity INT UNSIGNED NULL COMMENT '产品总数量',
unit_type VARCHAR(24) NOT NULL DEFAULT 'PACKAGE' COMMENT '分配单元:PACKAGE普通箱、SEALED_PALLET一件代发封托',
pallet_id BIGINT UNSIGNED NULL COMMENT '一件代发封托ID;普通箱为空',
pallet_no VARCHAR(32) NULL COMMENT '一件代发系统托盘码快照',
indivisible TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否为不可拆整托单元',
units_per_carton INT UNSIGNED NULL COMMENT '每箱产品数', units_per_carton INT UNSIGNED NULL COMMENT '每箱产品数',
pallet_count INT UNSIGNED NULL COMMENT '托盘数', pallet_count INT UNSIGNED NULL COMMENT '托盘数',
gross_weight_kg DECIMAL(18,3) NULL COMMENT '本次装柜毛重kg', gross_weight_kg DECIMAL(18,3) NULL COMMENT '本次装柜毛重kg',
@@ -5210,24 +5214,23 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item (
removed_by_name VARCHAR(64) NULL COMMENT '推送前逻辑删除操作人名称快照', removed_by_name VARCHAR(64) NULL COMMENT '推送前逻辑删除操作人名称快照',
removed_at DATETIME(3) NULL COMMENT '推送前逻辑删除时间', removed_at DATETIME(3) NULL COMMENT '推送前逻辑删除时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '推送前逻辑删除标记;冻结后不可修改', deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '推送前逻辑删除标记;冻结后不可修改',
active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS ( active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END) STORED COMMENT '未删除明细查询键;删除后返回NULL',
CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END active_assignment_key VARCHAR(96) GENERATED ALWAYS AS (CASE WHEN deleted = 0 THEN CONCAT(CASE WHEN pallet_id IS NULL THEN 'D:0' ELSE CONCAT('P:', pallet_id) END, ':', inbound_detail_id) ELSE NULL END) STORED COMMENT '普通明细或已封托托盘明细的有效唯一锁',
) STORED COMMENT '未删除明细唯一锁;删除后返回NULL释放分配资格',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照', created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID', updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照', updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '装柜明细最后更新时间',
COMMENT '装柜明细最后更新时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_loading_item_plan_detail (loading_plan_id, active_inbound_detail_id), UNIQUE KEY uk_loading_item_plan_assignment (loading_plan_id, active_assignment_key),
KEY idx_loading_item_active_detail (account_id, active_inbound_detail_id), KEY idx_loading_item_active_detail (account_id, active_inbound_detail_id),
KEY idx_loading_item_plan (loading_plan_id, deleted, load_sequence, id), KEY idx_loading_item_plan (loading_plan_id, deleted, load_sequence, id),
KEY idx_loading_item_account (account_id, created_at, id), KEY idx_loading_item_account (account_id, created_at, id),
KEY idx_loading_item_inbound (account_id, inbound_id, deleted), KEY idx_loading_item_inbound (account_id, inbound_id, deleted),
KEY idx_loading_item_candidate (account_id, inbound_id, inbound_detail_id, deleted), KEY idx_loading_item_candidate (account_id, inbound_id, inbound_detail_id, deleted),
KEY idx_loading_item_pallet (pallet_id, deleted),
KEY idx_loading_item_order (account_id, order_id, deleted), KEY idx_loading_item_order (account_id, order_id, deleted),
CONSTRAINT fk_loading_item_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id), CONSTRAINT fk_loading_item_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id),
CONSTRAINT chk_loading_item_account CHECK (account_id > 0), CONSTRAINT chk_loading_item_account CHECK (account_id > 0),
@@ -5555,65 +5558,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_history (
CONSTRAINT fk_loading_pick_history_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id) CONSTRAINT fk_loading_pick_history_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id)
) ENGINE=InnoDB COMMENT='PDA拣货幂等和操作审计历史'; ) ENGINE=InnoDB COMMENT='PDA拣货幂等和操作审计历史';
CREATE TABLE IF NOT EXISTS fl_loading_work_pallet (
id BIGINT UNSIGNED NOT NULL COMMENT '一件代发现场作业托盘ID',
pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA装柜拣货任务ID',
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID快照',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '作业仓库ID',
warehouse_pallet_id BIGINT UNSIGNED NULL COMMENT '仓库实物托盘ID;未绑定仓库资源时为空',
pallet_code VARCHAR(64) NOT NULL COMMENT '现场托盘号',
open_operation_id VARCHAR(128) NOT NULL COMMENT '开托幂等操作号',
seal_operation_id VARCHAR(128) NULL COMMENT '封托幂等操作号',
pallet_status VARCHAR(16) NOT NULL DEFAULT 'OPEN' COMMENT '托盘状态:OPEN打托中、SEALED已封托、CANCELLED已取消',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建操作人名称快照',
sealed_by BIGINT UNSIGNED NULL COMMENT '封托操作人ID',
sealed_by_name VARCHAR(64) NULL COMMENT '封托操作人名称快照',
sealed_at DATETIME(3) NULL COMMENT '封托时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_loading_work_pallet_code (pick_task_id, pallet_code),
UNIQUE KEY uk_loading_work_pallet_open_operation (open_operation_id),
UNIQUE KEY uk_loading_work_pallet_seal_operation (seal_operation_id),
KEY idx_loading_work_pallet_plan (loading_plan_id, pallet_status, id),
CONSTRAINT fk_loading_work_pallet_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id),
CONSTRAINT fk_loading_work_pallet_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id),
CONSTRAINT chk_loading_work_pallet_status CHECK (pallet_status IN ('OPEN','SEALED','CANCELLED'))
) ENGINE=InnoDB COMMENT='一件代发PDA现场实际托盘;允许同托盘承载多个客户订单';
CREATE TABLE IF NOT EXISTS fl_loading_work_pallet_carton (
id BIGINT UNSIGNED NOT NULL COMMENT '一件代发现场托盘箱唛绑定ID',
pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA装柜拣货任务ID',
work_pallet_id BIGINT UNSIGNED NOT NULL COMMENT '现场作业托盘ID',
loading_plan_item_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划货物明细ID',
pick_item_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '箱唛所属客户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '来源入仓单ID',
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '来源入仓货物明细ID',
carton_label_id BIGINT UNSIGNED NOT NULL COMMENT '稳定箱唛记录ID',
box_code VARCHAR(80) NOT NULL COMMENT '稳定箱唛箱码快照',
sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照',
units_per_carton INT UNSIGNED NULL COMMENT '每箱产品数量快照',
bound_by BIGINT UNSIGNED NOT NULL COMMENT '最后绑定操作人ID',
bound_by_name VARCHAR(64) NULL COMMENT '最后绑定操作人名称快照',
operation_id VARCHAR(128) NOT NULL COMMENT '最近一次绑定或移托幂等操作号',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次上托时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后移托时间',
PRIMARY KEY (id),
UNIQUE KEY uk_loading_work_carton_task_label (pick_task_id, carton_label_id),
UNIQUE KEY uk_loading_work_carton_operation (operation_id),
KEY idx_loading_work_carton_pallet (work_pallet_id, sku_code, id),
KEY idx_loading_work_carton_plan_item (loading_plan_item_id, id),
CONSTRAINT fk_loading_work_carton_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id),
CONSTRAINT fk_loading_work_carton_pallet FOREIGN KEY (work_pallet_id) REFERENCES fl_loading_work_pallet (id),
CONSTRAINT fk_loading_work_carton_plan_item FOREIGN KEY (loading_plan_item_id) REFERENCES fl_loading_plan_item (id),
CONSTRAINT fk_loading_work_carton_pick_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id),
CONSTRAINT fk_loading_work_carton_label FOREIGN KEY (carton_label_id) REFERENCES fl_inbound_carton_label (id),
CONSTRAINT chk_loading_work_carton_units CHECK (units_per_carton IS NULL OR units_per_carton > 0)
) ENGINE=InnoDB COMMENT='一件代发PDA逐箱上托事实;客户订单不合并但实际托盘可混托';
DROP PROCEDURE IF EXISTS repair_phase3_loading_pick_task_foreign_keys; DROP PROCEDURE IF EXISTS repair_phase3_loading_pick_task_foreign_keys;
DELIMITER $$ DELIMITER $$
@@ -6276,12 +6221,13 @@ BEGIN
'destination_warehouse_name', 'product_name_zh', 'product_name_en', 'destination_warehouse_name', 'product_name_zh', 'product_name_en',
'product_image_file_id', 'pallet_count', 'net_weight_kg', 'product_image_file_id', 'pallet_count', 'net_weight_kg',
'dimensional_weight_kg', 'product_quantity', 'units_per_carton', 'dimensional_weight_kg', 'product_quantity', 'units_per_carton',
'fba_tracking_no', 'shipment_no', 'shipping_mark', 'version')) <> 17 THEN 'unit_type', 'pallet_id', 'pallet_no', 'indivisible', 'active_assignment_key',
'fba_tracking_no', 'shipment_no', 'shipping_mark', 'version')) <> 22 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:明细归属、清单快照或冻结字段缺失'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:明细归属、清单快照或冻结字段缺失';
END IF; END IF;
IF (SELECT COUNT(*) FROM information_schema.statistics IF (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_plan_item' WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_plan_item'
AND index_name = 'uk_loading_item_plan_detail' AND non_unique = 0) <> 2 THEN AND index_name = 'uk_loading_item_plan_assignment' AND non_unique = 0) <> 2 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:计划内唯一活动明细锁缺失'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:计划内唯一活动明细锁缺失';
END IF; END IF;
IF (SELECT COUNT(*) FROM information_schema.columns IF (SELECT COUNT(*) FROM information_schema.columns
@@ -17393,7 +17339,9 @@ CREATE TABLE IF NOT EXISTS fl_dropship_pallet_task (
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '作业仓库ID', warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '作业仓库ID',
task_status VARCHAR(24) NOT NULL COMMENT '任务状态:PENDING待领取、CLAIMED已领取、COMPLETED已完成', task_status VARCHAR(24) NOT NULL COMMENT '任务状态:PENDING待领取、CLAIMED已领取、COMPLETED已完成',
total_cartons INT UNSIGNED NOT NULL COMMENT '测量确认总箱数', total_cartons INT UNSIGNED NOT NULL COMMENT '测量确认总箱数',
counted_cartons INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已逐箱确认实收数量的箱数',
allocated_cartons INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已上托箱数', allocated_cartons INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已上托箱数',
discrepancy_cartons INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已确认差异箱数',
assignee_id BIGINT UNSIGNED NULL COMMENT '任务领取人ID', assignee_id BIGINT UNSIGNED NULL COMMENT '任务领取人ID',
assignee_name VARCHAR(64) NULL COMMENT '任务领取人姓名快照', assignee_name VARCHAR(64) NULL COMMENT '任务领取人姓名快照',
claimed_at DATETIME(3) NULL COMMENT '任务领取时间', claimed_at DATETIME(3) NULL COMMENT '任务领取时间',
@@ -17406,7 +17354,7 @@ CREATE TABLE IF NOT EXISTS fl_dropship_pallet_task (
KEY idx_dropship_task_operator (task_status,assignee_id,warehouse_id,created_at), KEY idx_dropship_task_operator (task_status,assignee_id,warehouse_id,created_at),
CONSTRAINT fk_dropship_task_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order(id), CONSTRAINT fk_dropship_task_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order(id),
CONSTRAINT chk_dropship_task_status CHECK (task_status IN ('PENDING','CLAIMED','COMPLETED')), CONSTRAINT chk_dropship_task_status CHECK (task_status IN ('PENDING','CLAIMED','COMPLETED')),
CONSTRAINT chk_dropship_task_cartons CHECK (allocated_cartons<=total_cartons) CONSTRAINT chk_dropship_task_cartons CHECK (allocated_cartons<=counted_cartons AND counted_cartons<=total_cartons)
) ENGINE=InnoDB COMMENT='一件代发测量后PDA合托任务'; ) ENGINE=InnoDB COMMENT='一件代发测量后PDA合托任务';
CREATE TABLE IF NOT EXISTS fl_dropship_pallet ( CREATE TABLE IF NOT EXISTS fl_dropship_pallet (
@@ -17415,8 +17363,8 @@ CREATE TABLE IF NOT EXISTS fl_dropship_pallet (
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '一件代发入仓单ID', inbound_id BIGINT UNSIGNED NOT NULL COMMENT '一件代发入仓单ID',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '作业仓库ID', warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '作业仓库ID',
pallet_no VARCHAR(32) NULL COMMENT '封托时生成的DP托盘号', pallet_no VARCHAR(32) NOT NULL COMMENT '现场扫描的系统DP托盘码',
pallet_status VARCHAR(16) NOT NULL COMMENT '托盘状态:OPEN合托中、COMPLETED已封托', pallet_status VARCHAR(16) NOT NULL COMMENT '托盘状态:OPEN组托中、SEALED已封托',
open_operation_id VARCHAR(128) NOT NULL COMMENT '创建托盘幂等操作ID', open_operation_id VARCHAR(128) NOT NULL COMMENT '创建托盘幂等操作ID',
seal_operation_id VARCHAR(128) NULL COMMENT '封托幂等操作ID', seal_operation_id VARCHAR(128) NULL COMMENT '封托幂等操作ID',
created_by BIGINT UNSIGNED NOT NULL COMMENT '托盘创建人ID', created_by BIGINT UNSIGNED NOT NULL COMMENT '托盘创建人ID',
@@ -17433,7 +17381,7 @@ CREATE TABLE IF NOT EXISTS fl_dropship_pallet (
UNIQUE KEY uk_dropship_pallet_seal_op (seal_operation_id), UNIQUE KEY uk_dropship_pallet_seal_op (seal_operation_id),
KEY idx_dropship_pallet_inbound (tenant_id,inbound_id,pallet_status), KEY idx_dropship_pallet_inbound (tenant_id,inbound_id,pallet_status),
CONSTRAINT fk_dropship_pallet_task FOREIGN KEY (task_id) REFERENCES fl_dropship_pallet_task(id), CONSTRAINT fk_dropship_pallet_task FOREIGN KEY (task_id) REFERENCES fl_dropship_pallet_task(id),
CONSTRAINT chk_dropship_pallet_status CHECK (pallet_status IN ('OPEN','COMPLETED')) CONSTRAINT chk_dropship_pallet_status CHECK (pallet_status IN ('OPEN','SEALED'))
) ENGINE=InnoDB COMMENT='一件代发合托形成的可打印托盘'; ) ENGINE=InnoDB COMMENT='一件代发合托形成的可打印托盘';
CALL queue_column_comment('baiyue_firstleg','fl_loading_cabinet_evidence','id','柜次证据记录ID'); CALL queue_column_comment('baiyue_firstleg','fl_loading_cabinet_evidence','id','柜次证据记录ID');
@@ -17500,23 +17448,33 @@ DROP TEMPORARY TABLE IF EXISTS tmp_table_comment_target;
CREATE TABLE IF NOT EXISTS fl_dropship_pallet_carton ( CREATE TABLE IF NOT EXISTS fl_dropship_pallet_carton (
id BIGINT UNSIGNED NOT NULL COMMENT '托盘箱明细ID', id BIGINT UNSIGNED NOT NULL COMMENT '托盘箱明细ID',
task_id BIGINT UNSIGNED NOT NULL COMMENT '所属合托任务ID', task_id BIGINT UNSIGNED NOT NULL COMMENT '所属合托任务ID',
pallet_id BIGINT UNSIGNED NOT NULL COMMENT '所属合托托盘ID', pallet_id BIGINT UNSIGNED NULL COMMENT '所属组托托盘ID;已清点待绑托时为空',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '一件代发入仓单ID', inbound_id BIGINT UNSIGNED NOT NULL COMMENT '一件代发入仓单ID',
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '入仓货物明细ID', inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '入仓货物明细ID',
carton_label_id BIGINT UNSIGNED NOT NULL COMMENT '稳定箱唛记录ID', carton_label_id BIGINT UNSIGNED NOT NULL COMMENT '稳定箱唛记录ID',
sku_code VARCHAR(128) NOT NULL COMMENT '箱唛冻结SKU编码', sku_code VARCHAR(128) NOT NULL COMMENT '箱唛冻结SKU编码',
cargo_name VARCHAR(255) NULL COMMENT '货物名称快照', cargo_name VARCHAR(255) NULL COMMENT '货物名称快照',
box_code VARCHAR(64) NOT NULL COMMENT '稳定箱码', box_code VARCHAR(80) NOT NULL COMMENT '稳定箱码',
operation_id VARCHAR(128) NOT NULL COMMENT '上托幂等操作ID', expected_sku_quantity INT UNSIGNED NOT NULL COMMENT '箱唛冻结的应收SKU数量',
bound_by BIGINT UNSIGNED NOT NULL COMMENT '上托操作人ID', actual_sku_quantity INT UNSIGNED NOT NULL COMMENT 'PDA确认的实收SKU数量',
discrepancy_status VARCHAR(24) NOT NULL COMMENT '差异状态:MATCH或ACCEPTED_VARIANCE',
discrepancy_remark VARCHAR(500) NULL COMMENT '差异接受说明',
count_operation_id VARCHAR(128) NOT NULL COMMENT '逐箱清点幂等操作ID',
bind_operation_id VARCHAR(128) NULL COMMENT '批量绑托幂等操作ID',
last_operation_id VARCHAR(128) NOT NULL COMMENT '最后一次绑托或换托操作幂等号',
counted_by BIGINT UNSIGNED NOT NULL COMMENT '清点操作人ID',
counted_by_name VARCHAR(64) NULL COMMENT '清点操作人姓名快照',
counted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '清点时间',
bound_by BIGINT UNSIGNED NULL COMMENT '上托操作人ID',
bound_by_name VARCHAR(64) NULL COMMENT '上托操作人姓名快照', bound_by_name VARCHAR(64) NULL COMMENT '上托操作人姓名快照',
bound_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上托时间', bound_at DATETIME(3) NULL COMMENT '上托时间',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_dropship_carton_task_label (task_id,carton_label_id), UNIQUE KEY uk_dropship_carton_task_label (task_id,carton_label_id),
KEY idx_dropship_carton_pallet_sku (pallet_id,sku_code,id), KEY idx_dropship_carton_pallet_sku (pallet_id,sku_code,id),
KEY idx_dropship_carton_inbound (tenant_id,inbound_id,pallet_id), KEY idx_dropship_carton_inbound (tenant_id,inbound_id,pallet_id),
KEY idx_dropship_carton_operation (operation_id), UNIQUE KEY uk_dropship_carton_count_operation (count_operation_id),
KEY idx_dropship_carton_bind_operation (task_id,bind_operation_id),
CONSTRAINT fk_dropship_carton_task FOREIGN KEY (task_id) REFERENCES fl_dropship_pallet_task(id), CONSTRAINT fk_dropship_carton_task FOREIGN KEY (task_id) REFERENCES fl_dropship_pallet_task(id),
CONSTRAINT fk_dropship_carton_pallet FOREIGN KEY (pallet_id) REFERENCES fl_dropship_pallet(id), CONSTRAINT fk_dropship_carton_pallet FOREIGN KEY (pallet_id) REFERENCES fl_dropship_pallet(id),
CONSTRAINT fk_dropship_carton_label FOREIGN KEY (carton_label_id) REFERENCES fl_inbound_carton_label(id) CONSTRAINT fk_dropship_carton_label FOREIGN KEY (carton_label_id) REFERENCES fl_inbound_carton_label(id)
@@ -63,12 +63,13 @@ public interface OrderCargoClient {
} }
record CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, record CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
String skuNo, Integer packageCount, BigDecimal lengthCm, BigDecimal widthCm, String skuNo, Integer packageCount, Integer unitsPerCarton,
BigDecimal lengthCm, BigDecimal widthCm,
BigDecimal heightCm) { BigDecimal heightCm) {
public CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, public CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer packageCount, BigDecimal lengthCm, BigDecimal widthCm, Integer packageCount, BigDecimal lengthCm, BigDecimal widthCm,
BigDecimal heightCm) { BigDecimal heightCm) {
this(cargoId, orderId, itemNo, cargoName, null, packageCount, lengthCm, widthCm, heightCm); this(cargoId, orderId, itemNo, cargoName, null, packageCount, null, lengthCm, widthCm, heightCm);
} }
} }
} }
@@ -62,23 +62,44 @@ public class PlatformDropshipPalletizationController {
return ApiResponse.success(service.claim(taskId, request.warehouseId(), user.userId(), user.username())); return ApiResponse.success(service.claim(taskId, request.warehouseId(), user.userId(), user.username()));
} }
@PostMapping("/tasks/{taskId}/pallets/open") @GetMapping("/tasks/{taskId}/cartons/resolve")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')") @PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
public ApiResponse<Pallet> open(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<DropshipPalletizationDtos.Carton> resolveCarton(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId, @PathVariable @Positive long taskId,
@Valid @RequestBody DropshipPalletizationDtos.OpenPalletRequest request) { @RequestParam @Positive long warehouseId,
@RequestParam @Size(max = 80) String boxCode) {
FirstlegScopeGuard.platform(user); FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.open(taskId, request, user.userId(), user.username())); return ApiResponse.success(service.resolveCarton(taskId, warehouseId, boxCode, user.userId()));
} }
@PostMapping("/tasks/{taskId}/pallets/{palletId}/scan") @PostMapping("/tasks/{taskId}/cartons/count")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')") @PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<DropshipPalletizationDtos.ScanResult> scan( public ApiResponse<DropshipPalletizationDtos.CountResult> countCarton(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId, @PathVariable @Positive long palletId, @PathVariable @Positive long taskId,
@Valid @RequestBody DropshipPalletizationDtos.ScanRequest request) { @Valid @RequestBody DropshipPalletizationDtos.CountCartonRequest request) {
FirstlegScopeGuard.platform(user); FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.scan(taskId, palletId, request, user.userId(), user.username())); return ApiResponse.success(service.countCarton(taskId, request, user.userId(), user.username()));
}
@PostMapping("/tasks/{taskId}/pallets/bind-pending")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<Task> bindPending(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@Valid @RequestBody DropshipPalletizationDtos.BindPendingRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.bindPending(taskId, request, user.userId(), user.username()));
}
@PostMapping("/tasks/{taskId}/cartons/{cartonId}/move")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<Task> moveCarton(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@PathVariable @Positive long cartonId,
@Valid @RequestBody DropshipPalletizationDtos.MoveCartonRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.moveCarton(taskId, cartonId, request, user.userId(), user.username()));
} }
@PostMapping("/tasks/{taskId}/pallets/{palletId}/seal") @PostMapping("/tasks/{taskId}/pallets/{palletId}/seal")
@@ -3,6 +3,7 @@ package com.baiyue.firstleg.controller;
import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.PalletPickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.ClaimRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.ClaimRequest;
@@ -24,11 +25,6 @@ import org.springframework.http.ResponseEntity;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import jakarta.validation.constraints.PositiveOrZero; import jakarta.validation.constraints.PositiveOrZero;
import com.baiyue.firstleg.service.LoadingWorkPalletService;
import com.baiyue.firstleg.dto.LoadingPalletDtos.OpenRequest;
import com.baiyue.firstleg.dto.LoadingPalletDtos.ScanRequest;
import com.baiyue.firstleg.dto.LoadingPalletDtos.SealRequest;
import com.baiyue.firstleg.dto.LoadingPalletDtos.Pallet;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
@@ -56,8 +52,6 @@ public class PlatformLoadingPdaController {
private LoadingPickService service; private LoadingPickService service;
@Resource @Resource
private CabinetLoadingEvidenceService evidenceService; private CabinetLoadingEvidenceService evidenceService;
@Resource
private LoadingWorkPalletService palletService;
@GetMapping("/tasks") @GetMapping("/tasks")
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')") @PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
@@ -168,6 +162,15 @@ public class PlatformLoadingPdaController {
return ApiResponse.success(service.pick(taskId, itemId, request, user.userId(), user.username())); return ApiResponse.success(service.pick(taskId, itemId, request, user.userId(), user.username()));
} }
@PostMapping("/tasks/{taskId}/pallets/pick")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<Task> pickPallet(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@Valid @RequestBody PalletPickRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.pickPallet(taskId, request, user.userId(), user.username()));
}
@PostMapping("/tasks/{taskId}/items/{itemId}/partial") @PostMapping("/tasks/{taskId}/items/{itemId}/partial")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')") @PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<Task> partial(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<Task> partial(@AuthenticationPrincipal AuthenticatedUser user,
@@ -226,44 +229,4 @@ public class PlatformLoadingPdaController {
return ApiResponse.success(service.operations(taskId)); return ApiResponse.success(service.operations(taskId));
} }
@GetMapping("/tasks/{taskId}/pallets")
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
public ApiResponse<List<Pallet>> pallets(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@RequestParam @Positive long warehouseId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(palletService.list(taskId, warehouseId));
}
@PostMapping("/tasks/{taskId}/pallets/open")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<Pallet> openPallet(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@Valid @RequestBody OpenRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(palletService.open(taskId, request, user.userId(), user.username()));
}
/** 扫到其他未封托托盘上的箱唛时,同时完成按箱移托。 */
@PostMapping("/tasks/{taskId}/pallets/{palletId}/cartons/scan")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<Pallet> scanCarton(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@PathVariable @Positive long palletId,
@Valid @RequestBody ScanRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(palletService.scan(taskId, palletId, request,
user.userId(), user.username()));
}
@PostMapping("/tasks/{taskId}/pallets/{palletId}/seal")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<Pallet> sealPallet(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@PathVariable @Positive long palletId,
@Valid @RequestBody SealRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(palletService.seal(taskId, palletId, request,
user.userId(), user.username()));
}
} }
@@ -24,6 +24,10 @@ public class PlatformSmartLoadingController {
public ApiResponse<Plan> assign(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.assign(planId,r,u.userId(),u.username()));} public ApiResponse<Plan> assign(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.assign(planId,r,u.userId(),u.username()));}
@PostMapping("/plans/{planId}/inbound-assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") @PostMapping("/plans/{planId}/inbound-assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
public ApiResponse<Plan> assignInbound(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignInboundRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.assignInbound(planId,r,u.userId(),u.username()));} public ApiResponse<Plan> assignInbound(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignInboundRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.assignInbound(planId,r,u.userId(),u.username()));}
@PostMapping("/plans/{planId}/pallet-assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
public ApiResponse<Plan> assignPallet(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignPalletRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.assignPallet(planId,r,u.userId(),u.username()));}
@DeleteMapping("/plans/{planId}/pallet-assignments/{palletId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:delete')")
public ApiResponse<Plan> removePallet(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long palletId,@Valid @RequestBody RemovePalletRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.removePallet(planId,palletId,r,u.userId(),u.username()));}
@PostMapping("/plans/{planId}/pending-review-assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") @PostMapping("/plans/{planId}/pending-review-assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
public ApiResponse<Plan> assignPendingReview(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignPendingReviewRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.assignPendingReview(planId,r,u.userId(),u.username()));} public ApiResponse<Plan> assignPendingReview(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignPendingReviewRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.assignPendingReview(planId,r,u.userId(),u.username()));}
@PostMapping("/plans/{planId}/group-assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") @PostMapping("/plans/{planId}/group-assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
@@ -1,7 +1,8 @@
package com.baiyue.firstleg.dto; package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
@@ -14,13 +15,23 @@ public final class DropshipPalletizationDtos {
public record ClaimRequest(@Positive long warehouseId) {} public record ClaimRequest(@Positive long warehouseId) {}
public record OpenPalletRequest(@Positive long warehouseId, public enum DiscrepancyAction { MATCH, ACCEPT_VARIANCE }
@NotBlank @Size(max = 128) String operationId) {}
public record ScanRequest(@Positive long warehouseId, public record CountCartonRequest(@Positive long warehouseId,
@NotBlank @Size(max = 64) String boxCode, @NotBlank @Size(max = 80) String boxCode,
@Positive @Max(2000) int cartonCount, @Positive int actualSkuQuantity,
@NotBlank @Size(max = 128) String operationId) {} @NotNull DiscrepancyAction discrepancyAction,
@Size(max = 500) String discrepancyRemark,
@Pattern(regexp = "DP-[0-9]{6}-[0-9]{4}") String palletNo,
@NotBlank @Size(max = 128) String operationId) {}
public record BindPendingRequest(@Positive long warehouseId,
@NotBlank @Pattern(regexp = "DP-[0-9]{6}-[0-9]{4}") String palletNo,
@NotBlank @Size(max = 128) String operationId) {}
public record MoveCartonRequest(@Positive long warehouseId,
@Pattern(regexp = "DP-[0-9]{6}-[0-9]{4}") String targetPalletNo,
@NotBlank @Size(max = 128) String operationId) {}
public record SealPalletRequest(@Positive long warehouseId, public record SealPalletRequest(@Positive long warehouseId,
@NotBlank @Size(max = 128) String operationId, @NotBlank @Size(max = 128) String operationId,
@@ -28,24 +39,34 @@ public final class DropshipPalletizationDtos {
public record PendingTask(Long taskId, Long inboundId, String inboundCode, Long tenantId, public record PendingTask(Long taskId, Long inboundId, String inboundCode, Long tenantId,
String customerName, Long warehouseId, String warehouseName, String customerName, Long warehouseId, String warehouseName,
Integer totalCartons, Integer allocatedCartons, String taskStatus, Integer totalCartons, Integer countedCartons, Integer boundCartons,
Integer unboundCartons, Integer discrepancyCartons, String taskStatus,
LocalDateTime createdAt) {} LocalDateTime createdAt) {}
public record Task(Long taskId, Long inboundId, String inboundCode, Long tenantId, public record Task(Long taskId, Long inboundId, String inboundCode, Long tenantId,
String customerName, Long warehouseId, String warehouseName, String customerName, Long warehouseId, String warehouseName,
Integer totalCartons, Integer allocatedCartons, String taskStatus, Integer totalCartons, Integer countedCartons, Integer boundCartons,
Integer unboundCartons, Integer discrepancyCartons, String taskStatus,
Long assigneeId, String assigneeName, Integer version, Long assigneeId, String assigneeName, Integer version,
List<Pallet> pallets) {} List<Pallet> pallets, List<Carton> unbound) {}
public record Pallet(Long palletId, String palletNo, String palletStatus, public record Pallet(Long palletId, String palletNo, String status,
Integer cartonCount, Integer skuCount, Integer version, Integer cartonCount, Integer skuCount, Integer discrepancyCartons,
List<SkuItem> items, LocalDateTime completedAt) {} java.math.BigDecimal weightKg, java.math.BigDecimal volumeCbm,
Integer version, List<SkuItem> items, List<Carton> cartons,
LocalDateTime sealedAt) {}
public record SkuItem(Long inboundId, String inboundCode, Long inboundDetailId, public record SkuItem(Long inboundId, String inboundCode, Long inboundDetailId,
String skuCode, String cargoName, Integer cartonCount, String skuCode, String cargoName, Integer cartonCount, Integer skuQuantity,
List<String> cartonMarks) {} List<String> cartonMarks) {}
public record ScanResult(String skuCode, String cargoName, String inboundCode, public record Carton(Long cartonId, String boxCode, Long inboundId, String inboundCode,
int addedCartons, int allocatedCartons, int remainingCartons, Long inboundDetailId, String skuCode, String cargoName,
Pallet pallet) {} Integer expectedSkuQuantity, Integer actualSkuQuantity,
String discrepancyStatus, String discrepancyRemark,
String countStatus, Long palletId, String palletNo,
Boolean canCount, String blockReason) {}
public record CountResult(Carton carton, Integer countedCartons, Integer boundCartons,
Integer unboundCartons, Integer remainingCartons, Pallet pallet) {}
} }
@@ -188,6 +188,10 @@ public final class LoadingListDtos {
String chineseName, String chineseName,
String englishName, String englishName,
String goodsImageUrl, String goodsImageUrl,
String unitType,
Long palletId,
String palletNo,
Boolean indivisible,
int packageCount, int packageCount,
int palletCount, int palletCount,
BigDecimal grossWeightKg, BigDecimal grossWeightKg,
@@ -1,41 +0,0 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;
/** 一件代发PDA现场打托接口模型。 */
public final class LoadingPalletDtos {
private LoadingPalletDtos() {}
public record OpenRequest(
@NotNull @Min(1) Long warehouseId,
@Min(1) Long warehousePalletId,
@NotBlank @Size(max = 64) String palletCode,
@NotBlank @Size(max = 128) String operationId
) {}
public record ScanRequest(
@NotNull @Min(1) Long warehouseId,
@NotBlank @Size(max = 80) String boxCode,
@NotBlank @Size(max = 128) String operationId
) {}
public record SealRequest(
@NotNull @Min(1) Long warehouseId,
@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 128) String operationId
) {}
public record Carton(Long id, String boxCode, String skuCode, Long tenantId,
Long inboundId, Long inboundDetailId, Integer unitsPerCarton) {}
public record SkuSummary(String skuCode, Integer cartonCount, Long productQuantity) {}
public record Pallet(Long id, String palletCode, Long warehousePalletId, String status,
Integer version, Integer cartonCount, Integer skuCount,
Integer customerCount, List<SkuSummary> skus, List<Carton> cartons) {}
}
@@ -23,6 +23,13 @@ public final class LoadingPickDtos {
@NotBlank @Size(max = 128) String operationId @NotBlank @Size(max = 128) String operationId
) {} ) {}
public record PalletPickRequest(
@NotNull @Min(1) Long warehouseId,
@NotBlank @Pattern(regexp = "DP-[0-9]{6}-[0-9]{4}") String palletNo,
@NotNull @Min(0) Integer expectedTaskVersion,
@NotBlank @Size(max = 128) String operationId
) {}
/** 部分拣货为该 PDA 明细的最终实际拣出箱数,不是增量。 */ /** 部分拣货为该 PDA 明细的最终实际拣出箱数,不是增量。 */
public record PartialPickRequest( public record PartialPickRequest(
@NotNull @Min(1) Long warehouseId, @NotNull @Min(1) Long warehouseId,
@@ -22,11 +22,14 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
public record GroupMemberCandidate(Long accountId, Long inboundId, Long orderId, String orderNo, public record GroupMemberCandidate(Long accountId, Long inboundId, Long orderId, String orderNo,
String inboundCode, String customerName, Integer availablePackageCount, String inboundCode, String customerName, Integer availablePackageCount,
String orderStatusSnapshot, LocalDateTime expectedArrivalAt) {} String orderStatusSnapshot, LocalDateTime expectedArrivalAt) {}
public record PalletCandidate(Long palletId,String palletNo,String unitType,Boolean indivisible,
Integer cartonCount,Integer skuCount,BigDecimal weightKg,BigDecimal volumeCbm) {}
public record InboundCandidate(Long accountId, Long inboundId, Long orderId, String orderNo, String inboundCode, public record InboundCandidate(Long accountId, Long inboundId, Long orderId, String orderNo, String inboundCode,
Long warehouseId, String channelCode, String channelName, Long destinationWarehouseId, Long warehouseId, String channelCode, String channelName, Long destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName, String customerName, String goodsSummary, String destinationWarehouseCode, String destinationWarehouseName, String customerName, String goodsSummary,
Integer availablePackageCount, BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg, Integer availablePackageCount, BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg,
BigDecimal averageUnitVolumeCbm, BigDecimal availableVolumeCbm, BigDecimal averageUnitVolumeCbm, BigDecimal availableVolumeCbm,
String unitType,Long palletId,String palletNo,Boolean indivisible,
Long coLoadGroupId, String groupNo, Integer memberCount, Integer groupTotalBoxes, Long coLoadGroupId, String groupNo, Integer memberCount, Integer groupTotalBoxes,
Boolean locked, Boolean canAssign, String blockReason, Boolean locked, Boolean canAssign, String blockReason,
String orderStatusSnapshot, LocalDateTime expectedArrivalAt, String orderStatusSnapshot, LocalDateTime expectedArrivalAt,
@@ -66,6 +69,10 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
public record AssignGroupRequest(@NotNull @Positive Long inboundId, public record AssignGroupRequest(@NotNull @Positive Long inboundId,
@NotNull @Min(0) Integer expectedVersion, @NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max=128) String operationId) {} @NotBlank @Size(max=128) String operationId) {}
public record AssignPalletRequest(@NotNull @Positive Long palletId,@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max=128) String operationId) {}
public record RemovePalletRequest(@NotNull @Min(0) Integer expectedPlanVersion,
@NotBlank @Size(max=128) String operationId) {}
public record RemoveGroupRequest(@NotNull @Min(0) Integer expectedPlanVersion, public record RemoveGroupRequest(@NotNull @Min(0) Integer expectedPlanVersion,
@NotBlank @Size(max=128) String operationId) {} @NotBlank @Size(max=128) String operationId) {}
public record RecommendRequest(@NotNull @Min(0) Integer expectedVersion, public record RecommendRequest(@NotNull @Min(0) Integer expectedVersion,
@@ -23,6 +23,7 @@ public class InboundCartonLabelEntity {
private Integer cargoItemNo; private Integer cargoItemNo;
private String cargoName; private String cargoName;
private String skuCode; private String skuCode;
private Integer unitsPerCarton;
private Integer detailPackageCount; private Integer detailPackageCount;
private String inboundCode; private String inboundCode;
private String boxCode; private String boxCode;
@@ -6,6 +6,7 @@ public class LoadingPlanItemEntity {
private Long inboundId; private Long inboundDetailId; private Long orderId; private String orderNo; private String inboundCode; private Long destinationWarehouseId; private Long inboundId; private Long inboundDetailId; private Long orderId; private String orderNo; private String inboundCode; private Long destinationWarehouseId;
private String destinationWarehouseCode; private String destinationWarehouseName; private String skuCode; private String productNameZh; private String productNameEn; private String destinationWarehouseCode; private String destinationWarehouseName; private String skuCode; private String productNameZh; private String productNameEn;
private Long productImageFileId; private String productImageUrl; private Integer packageCount; private Integer productQuantity; private Integer unitsPerCarton; private Long productImageFileId; private String productImageUrl; private Integer packageCount; private Integer productQuantity; private Integer unitsPerCarton;
private String unitType; private Long palletId; private String palletNo; private Boolean indivisible;
private Integer palletCount; private BigDecimal grossWeightKg; private BigDecimal netWeightKg; private BigDecimal dimensionalWeightKg; private BigDecimal volumeCbm; private Integer palletCount; private BigDecimal grossWeightKg; private BigDecimal netWeightKg; private BigDecimal dimensionalWeightKg; private BigDecimal volumeCbm;
private String fbaTrackingNo; private String shipmentNo; private String shippingMark; private Integer loadSequence; private String positionSnapshot; private String fbaTrackingNo; private String shipmentNo; private String shippingMark; private Integer loadSequence; private String positionSnapshot;
private String internalRemark; private String customerRemark; private String warehouseRemark; private LocalDateTime frozenAt; private Long frozenBy; private String internalRemark; private String customerRemark; private String warehouseRemark; private LocalDateTime frozenAt; private Long frozenBy;
@@ -1,37 +0,0 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.Version;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/** 一件代发箱唛与现场实际托盘的当前绑定事实。 */
@Getter
@Setter
@TableName("fl_loading_work_pallet_carton")
public class LoadingWorkPalletCartonEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long pickTaskId;
private Long workPalletId;
private Long loadingPlanItemId;
private Long pickItemId;
private Long tenantId;
private Long inboundId;
private Long inboundDetailId;
private Long cartonLabelId;
private String boxCode;
private String skuCode;
private Integer unitsPerCarton;
private Long boundBy;
private String boundByName;
private String operationId;
@Version
private Integer version;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -1,36 +0,0 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.Version;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/** 一件代发装柜现场的实际托盘。 */
@Getter
@Setter
@TableName("fl_loading_work_pallet")
public class LoadingWorkPalletEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long pickTaskId;
private Long loadingPlanId;
private Long warehouseId;
private Long warehousePalletId;
private String palletCode;
private String openOperationId;
private String sealOperationId;
private String palletStatus;
private Long createdBy;
private String createdByName;
private Long sealedBy;
private String sealedByName;
private LocalDateTime sealedAt;
@Version
private Integer version;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -1,203 +1,182 @@
package com.baiyue.firstleg.mapper; package com.baiyue.firstleg.mapper;
import com.baiyue.firstleg.dto.DropshipPalletizationDtos.PendingTask; import com.baiyue.firstleg.dto.DropshipPalletizationDtos.PendingTask;
import com.baiyue.firstleg.dto.SmartLoadingDtos.PalletCandidate;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
import org.apache.ibatis.annotations.Insert; import org.apache.ibatis.annotations.*;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.math.BigDecimal;
import java.util.List; import java.util.List;
/** 一件代发合托持久化;任务、托盘和逐箱绑定均独立于装柜拣货。 */ /** 一件代发逐箱清点、待绑托与封托事实。 */
@Mapper @Mapper
public interface DropshipPalletizationMapper { public interface DropshipPalletizationMapper {
@Insert(""" @Insert("""
INSERT IGNORE INTO fl_dropship_pallet_task INSERT IGNORE INTO fl_dropship_pallet_task
(id,tenant_id,inbound_id,warehouse_id,task_status,total_cartons,allocated_cartons,version,created_at,updated_at) (id,tenant_id,inbound_id,warehouse_id,task_status,total_cartons,counted_cartons,allocated_cartons,discrepancy_cartons,version,created_at,updated_at)
VALUES(#{id},#{tenantId},#{inboundId},#{warehouseId},'PENDING',#{totalCartons},0,1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)) VALUES(#{id},#{tenantId},#{inboundId},#{warehouseId},'PENDING',#{totalCartons},0,0,0,1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))""")
""") int insertTask(@Param("id") long id,@Param("tenantId") long tenantId,@Param("inboundId") long inboundId,
int insertTask(@Param("id") long id, @Param("tenantId") long tenantId, @Param("warehouseId") long warehouseId,@Param("totalCartons") int totalCartons);
@Param("inboundId") long inboundId, @Param("warehouseId") long warehouseId,
@Param("totalCartons") int totalCartons);
@Select(""" @Select("""
SELECT t.id task_id,t.inbound_id,o.inbound_code,t.tenant_id,o.customer_name,t.warehouse_id, SELECT t.id task_id,t.inbound_id,o.inbound_code,t.tenant_id,o.customer_name,t.warehouse_id,
o.warehouse_name,t.total_cartons,t.allocated_cartons,t.task_status,t.created_at o.warehouse_name,t.total_cartons,t.counted_cartons,t.allocated_cartons bound_cartons,
FROM fl_dropship_pallet_task t JOIN fl_inbound_order o ON o.id=t.inbound_id AND o.tenant_id=t.tenant_id t.counted_cartons-t.allocated_cartons unbound_cartons,t.discrepancy_cartons,t.task_status,t.created_at
WHERE t.task_status IN ('PENDING','CLAIMED') FROM fl_dropship_pallet_task t JOIN fl_inbound_order o ON o.id=t.inbound_id AND o.tenant_id=t.tenant_id
AND (t.assignee_id IS NULL OR t.assignee_id=#{operatorId}) WHERE t.task_status IN ('PENDING','CLAIMED') AND (t.assignee_id IS NULL OR t.assignee_id=#{operatorId})
AND (#{keyword} IS NULL OR o.inbound_code LIKE CONCAT('%',#{keyword},'%') AND (#{keyword} IS NULL OR o.inbound_code LIKE CONCAT('%',#{keyword},'%') OR o.customer_name LIKE CONCAT('%',#{keyword},'%'))
OR o.customer_name LIKE CONCAT('%',#{keyword},'%')) ORDER BY t.created_at,t.id""")
ORDER BY t.created_at,t.id List<PendingTask> pending(@Param("operatorId") long operatorId,@Param("keyword") String keyword);
""")
List<PendingTask> pending(@Param("operatorId") long operatorId, @Param("keyword") String keyword);
@Select(""" @Select("""
SELECT t.*,o.inbound_code,o.customer_name,o.warehouse_name,o.process_status,o.fulfillment_type SELECT t.*,o.inbound_code,o.customer_name,o.warehouse_name,o.process_status,o.fulfillment_type
FROM fl_dropship_pallet_task t JOIN fl_inbound_order o ON o.id=t.inbound_id AND o.tenant_id=t.tenant_id FROM fl_dropship_pallet_task t JOIN fl_inbound_order o ON o.id=t.inbound_id AND o.tenant_id=t.tenant_id
WHERE t.id=#{taskId} AND t.warehouse_id=#{warehouseId} WHERE t.id=#{taskId} AND t.warehouse_id=#{warehouseId}""")
""") TaskRow task(@Param("taskId") long taskId,@Param("warehouseId") long warehouseId);
TaskRow task(@Param("taskId") long taskId, @Param("warehouseId") long warehouseId);
@Update(""" @Update("""
UPDATE fl_dropship_pallet_task UPDATE fl_dropship_pallet_task SET task_status='CLAIMED',assignee_id=#{operatorId},assignee_name=#{operatorName},
SET task_status='CLAIMED',assignee_id=#{operatorId},assignee_name=#{operatorName}, claimed_at=COALESCE(claimed_at,CURRENT_TIMESTAMP(3)),version=version+1,updated_at=CURRENT_TIMESTAMP(3)
claimed_at=COALESCE(claimed_at,CURRENT_TIMESTAMP(3)),version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{taskId} AND warehouse_id=#{warehouseId} AND task_status IN ('PENDING','CLAIMED')
WHERE id=#{taskId} AND warehouse_id=#{warehouseId} AND task_status IN ('PENDING','CLAIMED') AND (assignee_id IS NULL OR assignee_id=#{operatorId})""")
AND (assignee_id IS NULL OR assignee_id=#{operatorId}) int claim(@Param("taskId") long taskId,@Param("warehouseId") long warehouseId,
""") @Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
int claim(@Param("taskId") long taskId, @Param("warehouseId") long warehouseId,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Insert(""" @Insert("""
INSERT INTO fl_dropship_pallet INSERT INTO fl_dropship_pallet
(id,task_id,tenant_id,inbound_id,warehouse_id,pallet_status,open_operation_id,version,created_by,created_by_name,created_at,updated_at) (id,task_id,tenant_id,inbound_id,warehouse_id,pallet_no,pallet_status,open_operation_id,version,created_by,created_by_name,created_at,updated_at)
VALUES(#{id},#{taskId},#{tenantId},#{inboundId},#{warehouseId},'OPEN',#{operationId},1, VALUES(#{id},#{taskId},#{tenantId},#{inboundId},#{warehouseId},#{palletNo},'OPEN',#{operationId},1,
#{operatorId},#{operatorName},CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)) #{operatorId},#{operatorName},CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))""")
""") int insertPallet(@Param("id") long id,@Param("taskId") long taskId,@Param("tenantId") long tenantId,
int insertPallet(@Param("id") long id, @Param("taskId") long taskId, @Param("inboundId") long inboundId,@Param("warehouseId") long warehouseId,@Param("palletNo") String palletNo,
@Param("tenantId") long tenantId, @Param("inboundId") long inboundId, @Param("operationId") String operationId,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Param("warehouseId") long warehouseId, @Param("operationId") String operationId,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Select("SELECT * FROM fl_dropship_pallet WHERE open_operation_id=#{operationId}") @Select("SELECT * FROM fl_dropship_pallet WHERE pallet_no=#{palletNo}") PalletRow palletByNo(@Param("palletNo") String palletNo);
PalletRow palletByOpenOperation(@Param("operationId") String operationId); @Select("SELECT * FROM fl_dropship_pallet WHERE seal_operation_id=#{operationId}") PalletRow palletBySealOperation(@Param("operationId") String operationId);
@Select("SELECT * FROM fl_dropship_pallet WHERE id=#{palletId} AND task_id=#{taskId} AND warehouse_id=#{warehouseId}")
PalletRow pallet(@Param("taskId") long taskId,@Param("palletId") long palletId,@Param("warehouseId") long warehouseId);
@Select("SELECT * FROM fl_dropship_pallet WHERE task_id=#{taskId} ORDER BY id") List<PalletRow> pallets(@Param("taskId") long taskId);
@Select("SELECT p.* FROM fl_dropship_pallet p JOIN fl_dropship_pallet_task t ON t.id=p.task_id WHERE t.tenant_id=#{tenantId} AND t.inbound_id=#{inboundId} ORDER BY p.id")
List<PalletRow> palletsByInbound(@Param("tenantId") long tenantId,@Param("inboundId") long inboundId);
@Select("SELECT inbound_code FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0")
String inboundCode(@Param("tenantId") long tenantId,@Param("inboundId") long inboundId);
@Select("SELECT * FROM fl_dropship_pallet WHERE seal_operation_id=#{operationId}") @Select("""
PalletRow palletBySealOperation(@Param("operationId") String operationId); SELECT l.id carton_label_id,l.inbound_detail_id,l.sku_code,l.cargo_name,l.units_per_carton,l.inbound_code,l.box_code
FROM fl_inbound_carton_label l JOIN fl_dropship_pallet_task t ON t.inbound_id=l.inbound_id AND t.tenant_id=l.tenant_id
WHERE t.id=#{taskId} AND l.box_code=#{boxCode} AND l.batch_status='ACTIVE'""")
LabelRow label(@Param("taskId") long taskId,@Param("boxCode") String boxCode);
@Select("SELECT c.*,p.pallet_no FROM fl_dropship_pallet_carton c LEFT JOIN fl_dropship_pallet p ON p.id=c.pallet_id WHERE c.count_operation_id=#{operationId}") CartonRow cartonByCountOperation(@Param("operationId") String operationId);
@Select("SELECT c.*,p.pallet_no FROM fl_dropship_pallet_carton c LEFT JOIN fl_dropship_pallet p ON p.id=c.pallet_id WHERE c.task_id=#{taskId} AND c.id=#{cartonId}") CartonRow carton(@Param("taskId") long taskId,@Param("cartonId") long cartonId);
@Select("SELECT c.*,p.pallet_no FROM fl_dropship_pallet_carton c LEFT JOIN fl_dropship_pallet p ON p.id=c.pallet_id WHERE c.task_id=#{taskId} AND c.carton_label_id=#{labelId}")
CartonRow countedByLabel(@Param("taskId") long taskId,@Param("labelId") long labelId);
@Select("SELECT * FROM fl_dropship_pallet WHERE id=#{palletId} AND task_id=#{taskId} AND warehouse_id=#{warehouseId}") @Insert("""
PalletRow pallet(@Param("taskId") long taskId, @Param("palletId") long palletId, INSERT INTO fl_dropship_pallet_carton
@Param("warehouseId") long warehouseId); (id,task_id,pallet_id,tenant_id,inbound_id,inbound_detail_id,carton_label_id,sku_code,cargo_name,box_code,
expected_sku_quantity,actual_sku_quantity,discrepancy_status,discrepancy_remark,count_operation_id,last_operation_id,
counted_by,counted_by_name,counted_at,bound_by,bound_by_name,bound_at)
VALUES(#{id},#{taskId},#{palletId},#{tenantId},#{inboundId},#{detailId},#{labelId},#{skuCode},#{cargoName},#{boxCode},
#{expectedQuantity},#{actualQuantity},#{discrepancyStatus},#{discrepancyRemark},#{operationId},#{operationId},
#{operatorId},#{operatorName},CURRENT_TIMESTAMP(3),CASE WHEN #{palletId} IS NULL THEN NULL ELSE #{operatorId} END,
CASE WHEN #{palletId} IS NULL THEN NULL ELSE #{operatorName} END,CASE WHEN #{palletId} IS NULL THEN NULL ELSE CURRENT_TIMESTAMP(3) END)""")
int insertCarton(@Param("id") long id,@Param("taskId") long taskId,@Param("palletId") Long palletId,
@Param("tenantId") long tenantId,@Param("inboundId") long inboundId,@Param("detailId") long detailId,
@Param("labelId") long labelId,@Param("skuCode") String skuCode,@Param("cargoName") String cargoName,
@Param("boxCode") String boxCode,@Param("expectedQuantity") int expectedQuantity,@Param("actualQuantity") int actualQuantity,
@Param("discrepancyStatus") String discrepancyStatus,@Param("discrepancyRemark") String discrepancyRemark,
@Param("operationId") String operationId,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Select("SELECT * FROM fl_dropship_pallet WHERE task_id=#{taskId} ORDER BY id") @Update("""
List<PalletRow> pallets(@Param("taskId") long taskId); UPDATE fl_dropship_pallet_task SET counted_cartons=counted_cartons+1,allocated_cartons=allocated_cartons+#{boundCount},
discrepancy_cartons=discrepancy_cartons+#{discrepancyCount},version=version+1,updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{taskId} AND task_status='CLAIMED' AND assignee_id=#{operatorId} AND counted_cartons<total_cartons
AND allocated_cartons+#{boundCount}<=total_cartons""")
int addCounted(@Param("taskId") long taskId,@Param("operatorId") long operatorId,
@Param("boundCount") int boundCount,@Param("discrepancyCount") int discrepancyCount);
@Select("SELECT COUNT(*) FROM fl_dropship_pallet_carton WHERE task_id=#{taskId} AND pallet_id IS NULL") int unboundCount(@Param("taskId") long taskId);
@Select("SELECT COUNT(*) FROM fl_dropship_pallet_carton WHERE task_id=#{taskId} AND bind_operation_id=#{operationId}")
int bindOperationCount(@Param("taskId") long taskId,@Param("operationId") String operationId);
@Update("""
UPDATE fl_dropship_pallet_carton SET pallet_id=#{palletId},bind_operation_id=#{operationId},last_operation_id=#{operationId},
bound_by=#{operatorId},bound_by_name=#{operatorName},bound_at=CURRENT_TIMESTAMP(3) WHERE task_id=#{taskId} AND pallet_id IS NULL""")
int bindPending(@Param("taskId") long taskId,@Param("palletId") long palletId,@Param("operationId") String operationId,
@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Update("UPDATE fl_dropship_pallet_task SET allocated_cartons=allocated_cartons+#{delta},version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{taskId} AND assignee_id=#{operatorId} AND allocated_cartons+#{delta} BETWEEN 0 AND counted_cartons")
int adjustBound(@Param("taskId") long taskId,@Param("operatorId") long operatorId,@Param("delta") int delta);
@Update("""
UPDATE fl_dropship_pallet_carton SET pallet_id=#{targetPalletId},last_operation_id=#{operationId},
bound_by=CASE WHEN #{targetPalletId} IS NULL THEN NULL ELSE #{operatorId} END,
bound_by_name=CASE WHEN #{targetPalletId} IS NULL THEN NULL ELSE #{operatorName} END,
bound_at=CASE WHEN #{targetPalletId} IS NULL THEN NULL ELSE CURRENT_TIMESTAMP(3) END
WHERE id=#{cartonId} AND task_id=#{taskId} AND last_operation_id<>#{operationId}""")
int moveCarton(@Param("taskId") long taskId,@Param("cartonId") long cartonId,@Param("targetPalletId") Long targetPalletId,
@Param("operationId") String operationId,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Select(""" @Update("""
SELECT p.* FROM fl_dropship_pallet p JOIN fl_dropship_pallet_task t ON t.id=p.task_id UPDATE fl_dropship_pallet SET pallet_status='SEALED',seal_operation_id=#{operationId},completed_by=#{operatorId},
WHERE t.tenant_id=#{tenantId} AND t.inbound_id=#{inboundId} ORDER BY p.id completed_by_name=#{operatorName},completed_at=CURRENT_TIMESTAMP(3),version=version+1,updated_at=CURRENT_TIMESTAMP(3)
""") WHERE id=#{palletId} AND task_id=#{taskId} AND pallet_status='OPEN' AND version=#{expectedVersion}
List<PalletRow> palletsByInbound(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId); AND EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.pallet_id=#{palletId})
AND NOT EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.task_id=#{taskId} AND c.pallet_id IS NULL)""")
int seal(@Param("taskId") long taskId,@Param("palletId") long palletId,@Param("expectedVersion") int expectedVersion,
@Param("operationId") String operationId,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Select("SELECT COUNT(*) FROM fl_dropship_pallet WHERE task_id=#{taskId} AND pallet_status='OPEN'") int openPalletCount(@Param("taskId") long taskId);
@Update("""
UPDATE fl_dropship_pallet_task SET task_status='COMPLETED',completed_at=CURRENT_TIMESTAMP(3),version=version+1,updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{taskId} AND task_status='CLAIMED' AND counted_cartons=total_cartons AND allocated_cartons=total_cartons
AND NOT EXISTS(SELECT 1 FROM fl_dropship_pallet p WHERE p.task_id=#{taskId} AND p.pallet_status='OPEN')""") int completeTask(@Param("taskId") long taskId);
@Update("""
UPDATE fl_inbound_order o SET o.process_status='WAIT_SPLIT',o.pallet_count=(SELECT COUNT(*) FROM fl_dropship_pallet p
WHERE p.inbound_id=o.id AND p.pallet_status='SEALED'),o.updated_by=#{operatorId},o.version=o.version+1
WHERE o.id=#{inboundId} AND o.tenant_id=#{tenantId} AND o.process_status='WAIT_PALLETIZE'
AND EXISTS(SELECT 1 FROM fl_dropship_pallet_task t WHERE t.id=#{taskId} AND t.task_status='COMPLETED')""")
int completeInbound(@Param("taskId") long taskId,@Param("tenantId") long tenantId,@Param("inboundId") long inboundId,@Param("operatorId") long operatorId);
@Select("SELECT inbound_code FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0") @Select("SELECT c.*,p.pallet_no FROM fl_dropship_pallet_carton c LEFT JOIN fl_dropship_pallet p ON p.id=c.pallet_id WHERE c.pallet_id=#{palletId} ORDER BY c.sku_code,c.inbound_detail_id,c.id") List<CartonRow> cartons(@Param("palletId") long palletId);
String inboundCode(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId); @Select("SELECT c.*,p.pallet_no FROM fl_dropship_pallet_carton c LEFT JOIN fl_dropship_pallet p ON p.id=c.pallet_id WHERE c.task_id=#{taskId} AND c.pallet_id IS NULL ORDER BY c.counted_at,c.id") List<CartonRow> unboundCartons(@Param("taskId") long taskId);
@Select("""
SELECT COALESCE(o.actual_gross_weight_kg*COUNT(c.id)/NULLIF(o.total_package_count,0),0) weight_kg,
COALESCE(o.actual_volume_cbm*COUNT(c.id)/NULLIF(o.total_package_count,0),0) volume_cbm
FROM fl_dropship_pallet p JOIN fl_inbound_order o ON o.id=p.inbound_id AND o.tenant_id=p.tenant_id
LEFT JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id WHERE p.id=#{palletId}
GROUP BY p.id,o.actual_gross_weight_kg,o.actual_volume_cbm,o.total_package_count""")
PalletMetrics metrics(@Param("palletId") long palletId);
@Select("""
SELECT p.id pallet_id,p.pallet_no,'SEALED_PALLET' unit_type,TRUE indivisible,COUNT(c.id) carton_count,
COUNT(DISTINCT c.sku_code) sku_count,
COALESCE(o.actual_gross_weight_kg*COUNT(c.id)/NULLIF(o.total_package_count,0),0) weight_kg,
COALESCE(o.actual_volume_cbm*COUNT(c.id)/NULLIF(o.total_package_count,0),0) volume_cbm
FROM fl_dropship_pallet p JOIN fl_inbound_order o ON o.id=p.inbound_id AND o.tenant_id=p.tenant_id
JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id
WHERE p.inbound_id=#{inboundId} AND p.pallet_status='SEALED'
AND NOT EXISTS(SELECT 1 FROM fl_loading_plan_item i WHERE i.pallet_id=p.id AND i.deleted=0)
GROUP BY p.id,p.pallet_no,o.actual_gross_weight_kg,o.actual_volume_cbm,o.total_package_count ORDER BY p.id
""") List<PalletCandidate> palletCandidates(@Param("inboundId") long inboundId);
@Select(""" @Select("""
SELECT l.id carton_label_id,l.inbound_detail_id,l.sku_code,l.cargo_name,l.detail_package_count, SELECT p.id pallet_id,p.pallet_no,p.pallet_status,p.tenant_id account_id,p.inbound_id,o.order_id,o.order_no,
l.inbound_code,l.box_code o.inbound_code,o.warehouse_id,o.channel_code,o.channel_name,o.destination_warehouse_id,
FROM fl_inbound_carton_label l o.destination_code destination_warehouse_code,o.destination_name destination_warehouse_name,o.customer_name,
JOIN fl_dropship_pallet_task t ON t.inbound_id=l.inbound_id AND t.tenant_id=l.tenant_id c.inbound_detail_id,c.sku_code,MAX(c.cargo_name) cargo_name,COUNT(c.id) carton_count,
WHERE t.id=#{taskId} AND l.box_code=#{boxCode} AND l.batch_status='ACTIVE' SUM(c.actual_sku_quantity) actual_sku_quantity,
""") COALESCE(o.actual_gross_weight_kg/NULLIF(o.total_package_count,0),0) unit_gross_weight_kg,
LabelRow label(@Param("taskId") long taskId, @Param("boxCode") String boxCode); COALESCE(o.actual_net_weight_kg/NULLIF(o.total_package_count,0),0) unit_net_weight_kg,
COALESCE(o.actual_volume_cbm/NULLIF(o.total_package_count,0),0) unit_volume_cbm
FROM fl_dropship_pallet p JOIN fl_inbound_order o ON o.id=p.inbound_id AND o.tenant_id=p.tenant_id
JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id WHERE p.id=#{palletId}
GROUP BY p.id,p.pallet_no,p.pallet_status,p.tenant_id,p.inbound_id,o.order_id,o.order_no,o.inbound_code,
o.warehouse_id,o.channel_code,o.channel_name,o.destination_warehouse_id,o.destination_code,o.destination_name,
o.customer_name,c.inbound_detail_id,c.sku_code,o.actual_gross_weight_kg,o.actual_net_weight_kg,o.actual_volume_cbm,o.total_package_count
ORDER BY c.inbound_detail_id
""") List<PalletLine> sealedPalletLines(@Param("palletId") long palletId);
@Select(""" @Getter @Setter class TaskRow { private Long id,tenantId,inboundId,warehouseId,assigneeId; private String inboundCode,customerName,warehouseName,taskStatus,assigneeName,processStatus,fulfillmentType; private Integer totalCartons,countedCartons,allocatedCartons,discrepancyCartons,version; }
SELECT l.id carton_label_id,l.inbound_detail_id,l.sku_code,l.cargo_name,l.detail_package_count, @Getter @Setter class PalletRow { private Long id,taskId,tenantId,inboundId,warehouseId; private String palletNo,palletStatus,sealOperationId; private Integer version; private LocalDateTime completedAt; }
l.inbound_code,l.box_code @Getter @Setter class LabelRow { private Long cartonLabelId,inboundDetailId; private String skuCode,cargoName,inboundCode,boxCode; private Integer unitsPerCarton; }
FROM fl_inbound_carton_label l @Getter @Setter class CartonRow { private Long id,taskId,palletId,inboundId,inboundDetailId; private String palletNo,skuCode,cargoName,boxCode,discrepancyStatus,discrepancyRemark; private Integer expectedSkuQuantity,actualSkuQuantity; }
WHERE l.tenant_id=#{tenantId} AND l.inbound_id=#{inboundId} record PalletMetrics(BigDecimal weightKg,BigDecimal volumeCbm) {}
AND l.inbound_detail_id=#{detailId} AND l.batch_status='ACTIVE' @Getter @Setter class PalletLine { private Long palletId,tenantId,accountId,inboundId,orderId,warehouseId,destinationWarehouseId,inboundDetailId; private String palletNo,palletStatus,orderNo,inboundCode,channelCode,channelName,destinationWarehouseCode,destinationWarehouseName,customerName,skuCode,cargoName; private Integer cartonCount,actualSkuQuantity; private BigDecimal unitGrossWeightKg,unitNetWeightKg,unitVolumeCbm; }
AND NOT EXISTS (SELECT 1 FROM fl_dropship_pallet_carton c
WHERE c.task_id=#{taskId} AND c.carton_label_id=l.id)
ORDER BY CASE WHEN l.box_code=#{preferredBoxCode} THEN 0 ELSE 1 END,l.carton_index,l.id
LIMIT #{count}
""")
List<LabelRow> unboundLabels(@Param("taskId") long taskId, @Param("tenantId") long tenantId,
@Param("inboundId") long inboundId, @Param("detailId") long detailId,
@Param("preferredBoxCode") String preferredBoxCode,
@Param("count") int count);
@Select("SELECT COUNT(*) FROM fl_dropship_pallet_carton WHERE operation_id=#{operationId}")
int operationCount(@Param("operationId") String operationId);
@Insert("""
INSERT INTO fl_dropship_pallet_carton
(id,task_id,pallet_id,tenant_id,inbound_id,inbound_detail_id,carton_label_id,sku_code,cargo_name,
box_code,operation_id,bound_by,bound_by_name,bound_at)
VALUES(#{id},#{taskId},#{palletId},#{tenantId},#{inboundId},#{detailId},#{labelId},#{skuCode},#{cargoName},
#{boxCode},#{operationId},#{operatorId},#{operatorName},CURRENT_TIMESTAMP(3))
""")
int insertCarton(@Param("id") long id, @Param("taskId") long taskId,
@Param("palletId") long palletId, @Param("tenantId") long tenantId,
@Param("inboundId") long inboundId, @Param("detailId") long detailId,
@Param("labelId") long labelId, @Param("skuCode") String skuCode,
@Param("cargoName") String cargoName, @Param("boxCode") String boxCode,
@Param("operationId") String operationId, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_dropship_pallet_task
SET allocated_cartons=allocated_cartons+#{count},version=version+1,updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{taskId} AND task_status='CLAIMED' AND assignee_id=#{operatorId}
AND allocated_cartons+#{count}<=total_cartons
""")
int addAllocated(@Param("taskId") long taskId, @Param("operatorId") long operatorId,
@Param("count") int count);
@Update("""
UPDATE fl_dropship_pallet
SET pallet_no=#{palletNo},pallet_status='COMPLETED',seal_operation_id=#{operationId},
completed_by=#{operatorId},completed_by_name=#{operatorName},completed_at=CURRENT_TIMESTAMP(3),
version=version+1,updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{palletId} AND task_id=#{taskId} AND pallet_status='OPEN' AND version=#{expectedVersion}
AND EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.pallet_id=#{palletId})
""")
int seal(@Param("taskId") long taskId, @Param("palletId") long palletId,
@Param("expectedVersion") int expectedVersion, @Param("palletNo") String palletNo,
@Param("operationId") String operationId, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Select("SELECT COUNT(*) FROM fl_dropship_pallet WHERE task_id=#{taskId} AND pallet_status='OPEN'")
int openPalletCount(@Param("taskId") long taskId);
@Update("""
UPDATE fl_dropship_pallet_task
SET task_status='COMPLETED',completed_at=CURRENT_TIMESTAMP(3),version=version+1,updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{taskId} AND task_status='CLAIMED' AND allocated_cartons=total_cartons
AND NOT EXISTS(SELECT 1 FROM fl_dropship_pallet p WHERE p.task_id=#{taskId} AND p.pallet_status='OPEN')
""")
int completeTask(@Param("taskId") long taskId);
@Update("""
UPDATE fl_inbound_order o
SET o.process_status='WAIT_SPLIT',o.pallet_count=(SELECT COUNT(*) FROM fl_dropship_pallet p
WHERE p.inbound_id=o.id AND p.pallet_status='COMPLETED'),o.updated_by=#{operatorId},o.version=o.version+1
WHERE o.id=#{inboundId} AND o.tenant_id=#{tenantId} AND o.process_status='WAIT_PALLETIZE'
AND EXISTS(SELECT 1 FROM fl_dropship_pallet_task t WHERE t.id=#{taskId} AND t.task_status='COMPLETED')
""")
int completeInbound(@Param("taskId") long taskId, @Param("tenantId") long tenantId,
@Param("inboundId") long inboundId, @Param("operatorId") long operatorId);
@Select("""
SELECT c.* FROM fl_dropship_pallet_carton c WHERE c.pallet_id=#{palletId}
ORDER BY c.sku_code,c.inbound_detail_id,c.id
""")
List<CartonRow> cartons(@Param("palletId") long palletId);
@Getter @Setter class TaskRow {
private Long id; private Long tenantId; private Long inboundId; private Long warehouseId;
private String inboundCode; private String customerName; private String warehouseName;
private String taskStatus; private Integer totalCartons; private Integer allocatedCartons;
private Long assigneeId; private String assigneeName; private Integer version;
private String processStatus; private String fulfillmentType;
}
@Getter @Setter class PalletRow {
private Long id; private Long taskId; private Long tenantId; private Long inboundId;
private Long warehouseId; private String palletNo; private String palletStatus;
private String openOperationId; private String sealOperationId; private Integer version;
private LocalDateTime completedAt;
}
@Getter @Setter class LabelRow {
private Long cartonLabelId; private Long inboundDetailId; private String skuCode;
private String cargoName; private Integer detailPackageCount; private String inboundCode;
private String boxCode;
}
@Getter @Setter class CartonRow {
private Long inboundId; private Long inboundDetailId; private String skuCode;
private String cargoName; private String boxCode;
}
} }
@@ -91,7 +91,7 @@ public interface LoadingCompletedEventMapper {
JOIN fl_loading_plan_item item ON item.loading_plan_id=#{planId} JOIN fl_loading_plan_item item ON item.loading_plan_id=#{planId}
AND item.account_id=carton.tenant_id AND item.inbound_id=carton.inbound_id AND item.account_id=carton.tenant_id AND item.inbound_id=carton.inbound_id
AND item.inbound_detail_id=carton.inbound_detail_id AND item.deleted=0 AND item.inbound_detail_id=carton.inbound_detail_id AND item.deleted=0
WHERE pallet.pallet_status='COMPLETED' WHERE pallet.pallet_status='SEALED'
ORDER BY carton.tenant_id,carton.inbound_id,pallet.id,item.inbound_detail_id,carton.id ORDER BY carton.tenant_id,carton.inbound_id,pallet.id,item.inbound_detail_id,carton.id
""") """)
List<PalletRow> actualPalletRows(@Param("planId") long planId); List<PalletRow> actualPalletRows(@Param("planId") long planId);
@@ -99,7 +99,7 @@ public interface LoadingCompletedEventMapper {
@Select(""" @Select("""
SELECT COUNT(DISTINCT p.id) FROM fl_dropship_pallet p SELECT COUNT(DISTINCT p.id) FROM fl_dropship_pallet p
JOIN fl_loading_plan_item i ON i.loading_plan_id=#{planId} AND i.account_id=p.tenant_id JOIN fl_loading_plan_item i ON i.loading_plan_id=#{planId} AND i.account_id=p.tenant_id
AND i.inbound_id=p.inbound_id AND i.deleted=0 WHERE p.pallet_status='COMPLETED' AND i.inbound_id=p.inbound_id AND i.deleted=0 WHERE p.pallet_status='SEALED'
""") """)
int workPalletCount(@Param("planId") long planId); int workPalletCount(@Param("planId") long planId);
@@ -171,7 +171,8 @@ public interface LoadingListQueryMapper {
SELECT i.id AS item_id, i.account_id, i.customer_name, i.order_id, i.order_no, SELECT i.id AS item_id, i.account_id, i.customer_name, i.order_id, i.order_no,
i.inbound_id, i.inbound_detail_id, i.inbound_code, b.container_no, i.inbound_id, i.inbound_detail_id, i.inbound_code, b.container_no,
i.sku_code, i.destination_warehouse_name, i.product_name_zh, i.sku_code, i.destination_warehouse_name, i.product_name_zh,
product_name_en, product_image_url, i.package_count, product_name_en, product_image_url, i.unit_type, i.pallet_id,
i.pallet_no, i.indivisible, i.package_count,
COALESCE(NULLIF(i.pallet_count,0), COALESCE(NULLIF(i.pallet_count,0),
CEIL(COALESCE(NULLIF(inbound.pallet_count,0), CEIL(COALESCE(NULLIF(inbound.pallet_count,0),
(SELECT COUNT(*) FROM fl_inbound_weight_result weight (SELECT COUNT(*) FROM fl_inbound_weight_result weight
@@ -420,6 +421,10 @@ public interface LoadingListQueryMapper {
private String productNameZh; private String productNameZh;
private String productNameEn; private String productNameEn;
private String productImageUrl; private String productImageUrl;
private String unitType;
private Long palletId;
private String palletNo;
private Boolean indivisible;
private Integer packageCount; private Integer packageCount;
private Integer palletCount; private Integer palletCount;
private BigDecimal grossWeightKg; private BigDecimal grossWeightKg;
@@ -1,90 +0,0 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.LoadingWorkPalletCartonEntity;
import lombok.Getter;
import lombok.Setter;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@Mapper
public interface LoadingWorkPalletCartonMapper extends BaseMapper<LoadingWorkPalletCartonEntity> {
@Select("""
SELECT label.id carton_label_id,label.tenant_id,label.inbound_id,label.inbound_detail_id,
label.box_code,plan_item.id loading_plan_item_id,pick_item.id pick_item_id,
plan_item.sku_code,plan_item.units_per_carton
FROM fl_inbound_carton_label label
JOIN fl_loading_plan_item plan_item
ON plan_item.account_id=label.tenant_id AND plan_item.inbound_id=label.inbound_id
AND plan_item.inbound_detail_id=label.inbound_detail_id AND plan_item.deleted=0
JOIN fl_loading_pick_item pick_item ON pick_item.loading_plan_item_id=plan_item.id
JOIN fl_loading_pick_task task ON task.id=pick_item.pick_task_id
AND task.loading_plan_id=plan_item.loading_plan_id
JOIN fl_loading_plan plan ON plan.id=task.loading_plan_id
JOIN fl_container_booking booking ON booking.id=plan.container_booking_id
JOIN fl_inbound_order inbound_order ON inbound_order.id=plan_item.inbound_id
AND inbound_order.tenant_id=plan_item.account_id AND inbound_order.deleted=0
WHERE task.id=#{taskId} AND task.task_status='LOADING' AND booking.warehouse_id=#{warehouseId}
AND label.box_code=#{boxCode} AND label.batch_status='ACTIVE'
AND inbound_order.fulfillment_type='ONE_CLICK_DROPSHIP'
AND pick_item.item_status<>'SKIPPED'
ORDER BY label.tenant_id,label.id
""")
List<CartonCandidate> selectCandidates(@Param("taskId") long taskId,
@Param("warehouseId") long warehouseId,
@Param("boxCode") String boxCode);
@Select("SELECT * FROM fl_loading_work_pallet_carton WHERE pick_task_id=#{taskId} AND carton_label_id=#{labelId}")
LoadingWorkPalletCartonEntity selectBinding(@Param("taskId") long taskId,
@Param("labelId") long labelId);
@Select("SELECT * FROM fl_loading_work_pallet_carton WHERE operation_id=#{operationId}")
LoadingWorkPalletCartonEntity selectByOperation(@Param("operationId") String operationId);
@Select("SELECT * FROM fl_loading_work_pallet_carton WHERE work_pallet_id=#{palletId} ORDER BY id")
List<LoadingWorkPalletCartonEntity> selectPalletCartons(@Param("palletId") long palletId);
@Select("SELECT COUNT(*) FROM fl_loading_work_pallet_carton WHERE work_pallet_id=#{palletId}")
int countPalletCartons(@Param("palletId") long palletId);
@Select("""
SELECT COUNT(*) FROM (
SELECT carton.pick_item_id
FROM fl_loading_work_pallet_carton carton
JOIN fl_loading_pick_item pick_item ON pick_item.id=carton.pick_item_id
WHERE carton.pick_task_id=#{taskId}
GROUP BY carton.pick_item_id,pick_item.picked_package_count
HAVING COUNT(*)>pick_item.picked_package_count
) unreconciled
""")
int countUnreconciled(@Param("taskId") long taskId);
@Update("""
UPDATE fl_loading_work_pallet_carton
SET work_pallet_id=#{targetPalletId},bound_by=#{operatorId},bound_by_name=#{operatorName},
operation_id=#{operationId},version=version+1
WHERE id=#{id} AND version=#{version}
""")
int move(@Param("id") long id, @Param("version") int version,
@Param("targetPalletId") long targetPalletId,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName,
@Param("operationId") String operationId);
@Getter
@Setter
class CartonCandidate {
private Long cartonLabelId;
private Long tenantId;
private Long inboundId;
private Long inboundDetailId;
private String boxCode;
private Long loadingPlanItemId;
private Long pickItemId;
private String skuCode;
private Integer unitsPerCarton;
}
}
@@ -1,77 +0,0 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.LoadingWorkPalletEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@Mapper
public interface LoadingWorkPalletMapper extends BaseMapper<LoadingWorkPalletEntity> {
@Select("""
SELECT task.id task_id,task.loading_plan_id,task.task_status,booking.warehouse_id,
assignment.assignee_id,
SUM(CASE WHEN inbound_order.fulfillment_type='ONE_CLICK_DROPSHIP' THEN 1 ELSE 0 END) dropship_item_count
FROM fl_loading_pick_task task
JOIN fl_loading_plan plan ON plan.id=task.loading_plan_id
JOIN fl_container_booking booking ON booking.id=plan.container_booking_id
JOIN fl_loading_pick_item pick_item ON pick_item.pick_task_id=task.id
JOIN fl_loading_plan_item plan_item ON plan_item.id=pick_item.loading_plan_item_id AND plan_item.deleted=0
JOIN fl_inbound_order inbound_order ON inbound_order.id=plan_item.inbound_id
AND inbound_order.tenant_id=plan_item.account_id AND inbound_order.deleted=0
LEFT JOIN fl_loading_pick_assignment assignment ON assignment.pick_task_id=task.id
WHERE task.id=#{taskId} AND booking.warehouse_id=#{warehouseId}
GROUP BY task.id,task.loading_plan_id,task.task_status,booking.warehouse_id,assignment.assignee_id
""")
TaskContext selectTaskContext(@Param("taskId") long taskId, @Param("warehouseId") long warehouseId);
@Select("SELECT * FROM fl_loading_work_pallet WHERE open_operation_id=#{operationId}")
LoadingWorkPalletEntity selectByOpenOperation(@Param("operationId") String operationId);
@Select("SELECT * FROM fl_loading_work_pallet WHERE seal_operation_id=#{operationId}")
LoadingWorkPalletEntity selectBySealOperation(@Param("operationId") String operationId);
@Select("""
SELECT pallet.*
FROM fl_loading_work_pallet pallet
JOIN fl_loading_pick_task task ON task.id=pallet.pick_task_id
JOIN fl_loading_plan plan ON plan.id=task.loading_plan_id
JOIN fl_container_booking booking ON booking.id=plan.container_booking_id
WHERE pallet.pick_task_id=#{taskId} AND pallet.id=#{palletId}
AND booking.warehouse_id=#{warehouseId}
""")
LoadingWorkPalletEntity selectScoped(@Param("taskId") long taskId,
@Param("palletId") long palletId,
@Param("warehouseId") long warehouseId);
@Select("""
SELECT pallet.*
FROM fl_loading_work_pallet pallet
JOIN fl_loading_pick_task task ON task.id=pallet.pick_task_id
JOIN fl_loading_plan plan ON plan.id=task.loading_plan_id
JOIN fl_container_booking booking ON booking.id=plan.container_booking_id
WHERE pallet.pick_task_id=#{taskId} AND booking.warehouse_id=#{warehouseId}
AND pallet.pallet_status<>'CANCELLED'
ORDER BY pallet.id
""")
List<LoadingWorkPalletEntity> selectTaskPallets(@Param("taskId") long taskId,
@Param("warehouseId") long warehouseId);
@Update("""
UPDATE fl_loading_work_pallet
SET pallet_status='SEALED',seal_operation_id=#{operationId},sealed_by=#{operatorId},
sealed_by_name=#{operatorName},sealed_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{palletId} AND pick_task_id=#{taskId} AND warehouse_id=#{warehouseId}
AND pallet_status='OPEN' AND version=#{expectedVersion}
""")
int seal(@Param("taskId") long taskId, @Param("palletId") long palletId,
@Param("warehouseId") long warehouseId, @Param("expectedVersion") int expectedVersion,
@Param("operationId") String operationId, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
record TaskContext(Long taskId, Long loadingPlanId, String taskStatus, Long warehouseId,
Long assigneeId, Integer dropshipItemCount) {}
}
@@ -112,7 +112,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
SELECT carton.tenant_id,carton.inbound_id,batch.id,carton.inbound_detail_id,COUNT(*), SELECT carton.tenant_id,carton.inbound_id,batch.id,carton.inbound_detail_id,COUNT(*),
COALESCE(batch.total_volume_cbm/NULLIF(source.total_package_count,0),0) COALESCE(batch.total_volume_cbm/NULLIF(source.total_package_count,0),0)
FROM fl_dropship_pallet_carton carton FROM fl_dropship_pallet_carton carton
JOIN fl_dropship_pallet pallet ON pallet.id=carton.pallet_id AND pallet.pallet_status='COMPLETED' JOIN fl_dropship_pallet pallet ON pallet.id=carton.pallet_id AND pallet.pallet_status='SEALED'
JOIN fl_inbound_order source ON source.id=carton.inbound_id AND source.tenant_id=carton.tenant_id JOIN fl_inbound_order source ON source.id=carton.inbound_id AND source.tenant_id=carton.tenant_id
AND source.fulfillment_type='ONE_CLICK_DROPSHIP' AND source.fulfillment_type='ONE_CLICK_DROPSHIP'
JOIN fl_inbound_measurement batch ON batch.inbound_id=source.id AND batch.tenant_id=source.tenant_id JOIN fl_inbound_measurement batch ON batch.inbound_id=source.id AND batch.tenant_id=source.tenant_id
@@ -399,7 +399,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
SELECT carton.tenant_id,carton.inbound_id,batch.id,carton.inbound_detail_id,COUNT(*), SELECT carton.tenant_id,carton.inbound_id,batch.id,carton.inbound_detail_id,COUNT(*),
COALESCE(batch.total_volume_cbm/NULLIF(source.total_package_count,0),0) COALESCE(batch.total_volume_cbm/NULLIF(source.total_package_count,0),0)
FROM fl_dropship_pallet_carton carton FROM fl_dropship_pallet_carton carton
JOIN fl_dropship_pallet pallet ON pallet.id=carton.pallet_id AND pallet.pallet_status='COMPLETED' JOIN fl_dropship_pallet pallet ON pallet.id=carton.pallet_id AND pallet.pallet_status='SEALED'
JOIN fl_inbound_order source ON source.id=carton.inbound_id AND source.tenant_id=carton.tenant_id JOIN fl_inbound_order source ON source.id=carton.inbound_id AND source.tenant_id=carton.tenant_id
AND source.fulfillment_type='ONE_CLICK_DROPSHIP' AND source.fulfillment_type='ONE_CLICK_DROPSHIP'
JOIN fl_inbound_measurement batch ON batch.inbound_id=source.id AND batch.tenant_id=source.tenant_id JOIN fl_inbound_measurement batch ON batch.inbound_id=source.id AND batch.tenant_id=source.tenant_id
@@ -187,7 +187,8 @@ public class LoadingListRepository {
row.getOrderId(), row.getOrderNo(), row.getInboundId(), row.getInboundDetailId(), row.getOrderId(), row.getOrderNo(), row.getInboundId(), row.getInboundDetailId(),
row.getInboundCode(), row.getContainerNo(), row.getSkuCode(), row.getInboundCode(), row.getContainerNo(), row.getSkuCode(),
row.getDestinationWarehouseName(), row.getProductNameZh(), row.getProductNameEn(), row.getDestinationWarehouseName(), row.getProductNameZh(), row.getProductNameEn(),
row.getProductImageUrl(), integer(row.getPackageCount()), integer(row.getPalletCount()), row.getProductImageUrl(), row.getUnitType(), row.getPalletId(), row.getPalletNo(),
Boolean.TRUE.equals(row.getIndivisible()), integer(row.getPackageCount()), integer(row.getPalletCount()),
decimal(row.getGrossWeightKg()), decimal(row.getNetWeightKg()), decimal(row.getVolumeCbm()), decimal(row.getGrossWeightKg()), decimal(row.getNetWeightKg()), decimal(row.getVolumeCbm()),
decimal(row.getDimensionalWeightKg()), value(row.getProductQuantity()), decimal(row.getDimensionalWeightKg()), value(row.getProductQuantity()),
integer(row.getUnitsPerCarton()), row.getFbaTrackingNo(), row.getShipmentNo(), row.getShippingMark(), integer(row.getUnitsPerCarton()), row.getFbaTrackingNo(), row.getShipmentNo(), row.getShippingMark(),
@@ -97,6 +97,11 @@ public class SmartLoadingRepository {
.eq(LoadingPlanItemEntity::getInboundId, inboundId) .eq(LoadingPlanItemEntity::getInboundId, inboundId)
.eq(LoadingPlanItemEntity::getDeleted, false)); .eq(LoadingPlanItemEntity::getDeleted, false));
} }
public List<LoadingPlanItemEntity> activeItemsByPallet(long palletId) {
return items.selectList(new LambdaQueryWrapper<LoadingPlanItemEntity>()
.eq(LoadingPlanItemEntity::getPalletId, palletId)
.eq(LoadingPlanItemEntity::getDeleted, false));
}
public LoadingPlanEntity plan(long planId) { return plans.selectById(planId); } public LoadingPlanEntity plan(long planId) { return plans.selectById(planId); }
public LoadingPlanEntity planByBooking(long bookingId) { public LoadingPlanEntity planByBooking(long bookingId) {
return plans.selectOne(new LambdaQueryWrapper<LoadingPlanEntity>() return plans.selectOne(new LambdaQueryWrapper<LoadingPlanEntity>()
@@ -121,6 +126,13 @@ public class SmartLoadingRepository {
.eq(LoadingPlanItemEntity::getInboundDetailId, detailId) .eq(LoadingPlanItemEntity::getInboundDetailId, detailId)
.eq(LoadingPlanItemEntity::getDeleted, false)); .eq(LoadingPlanItemEntity::getDeleted, false));
} }
public LoadingPlanItemEntity activePalletItem(long planId, long palletId, long detailId) {
return items.selectOne(new LambdaQueryWrapper<LoadingPlanItemEntity>()
.eq(LoadingPlanItemEntity::getLoadingPlanId, planId)
.eq(LoadingPlanItemEntity::getPalletId, palletId)
.eq(LoadingPlanItemEntity::getInboundDetailId, detailId)
.eq(LoadingPlanItemEntity::getDeleted, false));
}
public LoadingPlanHistoryEntity operation(String operationId) { public LoadingPlanHistoryEntity operation(String operationId) {
return histories.selectOne(new LambdaQueryWrapper<LoadingPlanHistoryEntity>() return histories.selectOne(new LambdaQueryWrapper<LoadingPlanHistoryEntity>()
.eq(LoadingPlanHistoryEntity::getOperationId, operationId)); .eq(LoadingPlanHistoryEntity::getOperationId, operationId));
@@ -4,194 +4,193 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.DropshipPalletizationDtos; import com.baiyue.firstleg.dto.DropshipPalletizationDtos;
import com.baiyue.firstleg.dto.DropshipPalletizationDtos.Pallet; import com.baiyue.firstleg.dto.DropshipPalletizationDtos.*;
import com.baiyue.firstleg.dto.DropshipPalletizationDtos.ScanResult;
import com.baiyue.firstleg.dto.DropshipPalletizationDtos.SkuItem;
import com.baiyue.firstleg.dto.DropshipPalletizationDtos.Task;
import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper; import com.baiyue.firstleg.mapper.DropshipPalletizationMapper;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.CartonRow; import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.*;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.LabelRow;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.PalletRow;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.TaskRow;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import java.util.ArrayList; import java.math.BigDecimal;
import java.util.LinkedHashMap; import java.util.*;
import java.util.List;
import java.util.Map;
/** 测量完成即下发的一件代发合托,不创建国内仓库托盘、库位或库存。 */ /** 一件代发测量后的逐箱清点、待绑托和封托,不创建第二套装柜工作托盘。 */
@Service @Service
public class DropshipPalletizationService { public class DropshipPalletizationService {
@Resource private DropshipPalletizationMapper mapper; @Resource private DropshipPalletizationMapper mapper;
@Resource private FirstlegDocumentNumberGenerator numbers;
public void createTask(InboundOrderEntity inbound) { public void createTask(InboundOrderEntity inbound) {
if (!"ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())) return; if (!"ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())) return;
if (inbound.getTotalPackageCount() == null || inbound.getTotalPackageCount() < 1) { if (inbound.getTotalPackageCount()==null || inbound.getTotalPackageCount()<1) throw invalid("一件代发测量完成箱数无效,不能下发清点组托任务");
throw invalid("一件代发测量完成箱数无效,不能下发合托任务"); mapper.insertTask(IdWorker.getId(),inbound.getTenantId(),inbound.getId(),inbound.getWarehouseId(),inbound.getTotalPackageCount());
} }
mapper.insertTask(IdWorker.getId(), inbound.getTenantId(), inbound.getId(),
inbound.getWarehouseId(), inbound.getTotalPackageCount());
}
public List<DropshipPalletizationDtos.PendingTask> pending(long operatorId, String keyword) { public List<PendingTask> pending(long operatorId,String keyword){ return mapper.pending(operatorId,normalize(keyword)); }
return mapper.pending(operatorId, normalize(keyword));
}
@Transactional @Transactional
public Task claim(long taskId, long warehouseId, long operatorId, String operatorName) { public Task claim(long taskId,long warehouseId,long operatorId,String operatorName){
if (mapper.claim(taskId, warehouseId, operatorId, operatorName) != 1) { if(mapper.claim(taskId,warehouseId,operatorId,operatorName)!=1) throw conflict("任务已被其他人员领取或状态已变化");
throw conflict("任务已被其他人员领取或状态已变化"); return task(taskId,warehouseId,operatorId);
} }
return task(taskId, warehouseId, operatorId);
}
public Task task(long taskId, long warehouseId, long operatorId) { public Task task(long taskId,long warehouseId,long operatorId){
TaskRow row = requireTask(taskId, warehouseId); TaskRow row=requireTask(taskId,warehouseId);
if (row.getAssigneeId() != null && row.getAssigneeId() != operatorId) { if(row.getAssigneeId()!=null && row.getAssigneeId()!=operatorId) throw conflict("任务已由其他人员领取");
throw conflict("任务已由其他人员领取"); return view(row);
} }
return view(row);
}
@Transactional public Carton resolveCarton(long taskId,long warehouseId,String boxCode,long operatorId){
public Pallet open(long taskId, DropshipPalletizationDtos.OpenPalletRequest request, TaskRow task=requireTask(taskId,warehouseId);
long operatorId, String operatorName) { if(task.getAssigneeId()!=null && task.getAssigneeId()!=operatorId) throw conflict("任务已由其他人员领取");
TaskRow task = requireWorkingTask(taskId, request.warehouseId(), operatorId); LabelRow label=requireLabel(taskId,boxCode);
String operationId = request.operationId().trim(); CartonRow counted=mapper.countedByLabel(taskId,label.getCartonLabelId());
PalletRow replay = mapper.palletByOpenOperation(operationId); if(counted!=null) return cartonView(counted,task.getInboundCode(),false,"箱码已清点");
if (replay != null) { String block=null;
if (replay.getTaskId() != taskId) throw conflict("开托幂等号已用于其他任务"); if(!"CLAIMED".equals(task.getTaskStatus())) block="请先领取任务";
return palletView(replay, task.getInboundCode()); else if(!Objects.equals(task.getAssigneeId(),operatorId)) block="任务不属于当前操作员";
} else if(!"WAIT_PALLETIZE".equals(task.getProcessStatus())) block="入仓单已不在待清点组托状态";
long id = IdWorker.getId(); return new Carton(null,label.getBoxCode(),task.getInboundId(),label.getInboundCode(),label.getInboundDetailId(),
mapper.insertPallet(id, taskId, task.getTenantId(), task.getInboundId(), task.getWarehouseId(), label.getSkuCode(),label.getCargoName(),label.getUnitsPerCarton(),null,null,null,"UNCOUNTED",null,null,
operationId, operatorId, operatorName); block==null,block);
return palletView(mapper.pallet(taskId, id, request.warehouseId()), task.getInboundCode()); }
}
@Transactional @Transactional
public ScanResult scan(long taskId, long palletId, DropshipPalletizationDtos.ScanRequest request, public CountResult countCarton(long taskId,CountCartonRequest request,long operatorId,String operatorName){
long operatorId, String operatorName) { TaskRow task=requireWorkingTask(taskId,request.warehouseId(),operatorId);
TaskRow task = requireWorkingTask(taskId, request.warehouseId(), operatorId); String operation=operation(request.operationId());
PalletRow pallet = requireOpenPallet(taskId, palletId, request.warehouseId()); CartonRow replay=mapper.cartonByCountOperation(operation);
String operationId = request.operationId().trim(); if(replay!=null){
LabelRow scanned = mapper.label(taskId, request.boxCode().trim()); if(!Objects.equals(replay.getTaskId(),taskId) || !Objects.equals(replay.getBoxCode(),request.boxCode().trim())
if (scanned == null) throw new BizException(ResultCode.NOT_FOUND, "箱唛不属于当前一件代发入仓单"); || !Objects.equals(replay.getActualSkuQuantity(),request.actualSkuQuantity())) throw conflict("清点幂等号已用于不同操作");
if (!StringUtils.hasText(scanned.getSkuCode())) throw conflict("箱唛缺少SKU,不能合托"); return countResult(task,replay);
if (mapper.operationCount(operationId) > 0) { }
return scanResult(scanned, request.cartonCount(), task, pallet); LabelRow label=requireLabel(taskId,request.boxCode());
} if(mapper.countedByLabel(taskId,label.getCartonLabelId())!=null) throw conflict("箱码已清点,不能重复清点");
List<LabelRow> labels = mapper.unboundLabels(taskId, task.getTenantId(), task.getInboundId(), int expected=requireExpectedQuantity(label);
scanned.getInboundDetailId(), scanned.getBoxCode(), request.cartonCount()); boolean mismatch=expected!=request.actualSkuQuantity();
if (labels.size() != request.cartonCount()) { String remark=normalize(request.discrepancyRemark());
throw conflict("本SKU剩余未上托箱数不足,请刷新任务后重试"); if(!mismatch && request.discrepancyAction()!=DiscrepancyAction.MATCH) throw invalid("实收数量一致时差异动作必须为 MATCH");
} if(mismatch && request.discrepancyAction()!=DiscrepancyAction.ACCEPT_VARIANCE) throw conflict("实收数量存在差异,请确认差异处理结果");
for (LabelRow label : labels) { if(mismatch && !StringUtils.hasText(remark)) throw invalid("接受数量差异时必须填写处理说明");
mapper.insertCarton(IdWorker.getId(), taskId, palletId, task.getTenantId(), task.getInboundId(), PalletRow pallet=StringUtils.hasText(request.palletNo())
label.getInboundDetailId(), label.getCartonLabelId(), label.getSkuCode(), label.getCargoName(), ? getOrCreatePallet(task,request.palletNo().trim(),operation,operatorId,operatorName):null;
label.getBoxCode(), operationId, operatorId, operatorName); long cartonId=IdWorker.getId();
} mapper.insertCarton(cartonId,taskId,pallet==null?null:pallet.getId(),task.getTenantId(),task.getInboundId(),
if (mapper.addAllocated(taskId, operatorId, labels.size()) != 1) { label.getInboundDetailId(),label.getCartonLabelId(),label.getSkuCode(),label.getCargoName(),label.getBoxCode(),
throw conflict("任务箱数已变化,请刷新后重试"); expected,request.actualSkuQuantity(),mismatch?"ACCEPTED_VARIANCE":"MATCH",remark,operation,operatorId,operatorName);
} if(mapper.addCounted(taskId,operatorId,pallet==null?0:1,mismatch?1:0)!=1) throw conflict("任务清点数量已变化,请刷新后重试");
TaskRow refreshed = requireTask(taskId, request.warehouseId()); TaskRow refreshed=requireTask(taskId,request.warehouseId());
return scanResult(scanned, labels.size(), refreshed, return countResult(refreshed,mapper.carton(taskId,cartonId));
mapper.pallet(taskId, palletId, request.warehouseId())); }
}
@Transactional @Transactional
public Task seal(long taskId, long palletId, DropshipPalletizationDtos.SealPalletRequest request, public Task bindPending(long taskId,BindPendingRequest request,long operatorId,String operatorName){
long operatorId, String operatorName) { TaskRow task=requireWorkingTask(taskId,request.warehouseId(),operatorId);
TaskRow task = requireWorkingTask(taskId, request.warehouseId(), operatorId); String operation=operation(request.operationId());
PalletRow replay = mapper.palletBySealOperation(request.operationId().trim()); if(mapper.bindOperationCount(taskId,operation)>0) return view(requireTask(taskId,request.warehouseId()));
if (replay != null) { PalletRow pallet=getOrCreatePallet(task,request.palletNo().trim(),operation,operatorId,operatorName);
if (replay.getTaskId() != taskId || replay.getId() != palletId) { int expected=mapper.unboundCount(taskId);
throw conflict("封托幂等号已用于其他托盘"); if(expected==0) throw conflict("当前没有待绑托箱");
} int bound=mapper.bindPending(taskId,pallet.getId(),operation,operatorId,operatorName);
return view(requireTask(taskId, request.warehouseId())); if(bound!=expected || mapper.adjustBound(taskId,operatorId,bound)!=1) throw conflict("待绑托箱已变化,请刷新后重试");
} return view(requireTask(taskId,request.warehouseId()));
requireOpenPallet(taskId, palletId, request.warehouseId()); }
String palletNo = numbers.next("DP");
if (mapper.seal(taskId, palletId, request.expectedVersion(), palletNo, request.operationId().trim(),
operatorId, operatorName) != 1) {
throw conflict("空托盘、托盘状态或版本已变化,请刷新后重试");
}
TaskRow refreshed = requireTask(taskId, request.warehouseId());
if (refreshed.getAllocatedCartons().equals(refreshed.getTotalCartons())
&& mapper.openPalletCount(taskId) == 0) {
if (mapper.completeTask(taskId) != 1
|| mapper.completeInbound(taskId, refreshed.getTenantId(), refreshed.getInboundId(), operatorId) != 1) {
throw conflict("合托完成状态已变化,请刷新后重试");
}
}
return view(requireTask(taskId, request.warehouseId()));
}
public List<Pallet> inboundPallets(long tenantId, long inboundId) { @Transactional
String inboundCode = mapper.inboundCode(tenantId, inboundId); public Task moveCarton(long taskId,long cartonId,MoveCartonRequest request,long operatorId,String operatorName){
if (inboundCode == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在"); TaskRow task=requireWorkingTask(taskId,request.warehouseId(),operatorId);
return mapper.palletsByInbound(tenantId, inboundId).stream() CartonRow carton=mapper.carton(taskId,cartonId);
.map(row -> palletView(row, inboundCode)).toList(); if(carton==null) throw new BizException(ResultCode.NOT_FOUND,"清点箱不存在");
} if(carton.getPalletId()!=null) requireOpenPallet(taskId,carton.getPalletId(),request.warehouseId());
PalletRow target=StringUtils.hasText(request.targetPalletNo())
? getOrCreatePallet(task,request.targetPalletNo().trim(),operation(request.operationId()),operatorId,operatorName):null;
if(Objects.equals(carton.getPalletId(),target==null?null:target.getId())) return view(task);
if(mapper.moveCarton(taskId,cartonId,target==null?null:target.getId(),operation(request.operationId()),operatorId,operatorName)!=1)
throw conflict("清点箱已被其他操作修改,请刷新后重试");
int delta=carton.getPalletId()==null?1:(target==null?-1:0);
if(delta!=0 && mapper.adjustBound(taskId,operatorId,delta)!=1) throw conflict("任务绑托数量已变化,请刷新后重试");
return view(requireTask(taskId,request.warehouseId()));
}
private ScanResult scanResult(LabelRow label, int added, TaskRow task, PalletRow pallet) { @Transactional
return new ScanResult(label.getSkuCode(), label.getCargoName(), label.getInboundCode(), added, public Task seal(long taskId,long palletId,SealPalletRequest request,long operatorId,String operatorName){
task.getAllocatedCartons(), task.getTotalCartons() - task.getAllocatedCartons(), TaskRow task=requireWorkingTask(taskId,request.warehouseId(),operatorId);
palletView(pallet, task.getInboundCode())); PalletRow replay=mapper.palletBySealOperation(operation(request.operationId()));
} if(replay!=null){ if(!Objects.equals(replay.getTaskId(),taskId)||!Objects.equals(replay.getId(),palletId)) throw conflict("封托幂等号已用于其他托盘"); return view(requireTask(taskId,request.warehouseId())); }
requireOpenPallet(taskId,palletId,request.warehouseId());
if(mapper.unboundCount(taskId)>0) throw conflict("仍有已清点待绑托箱,不能封托");
if(mapper.seal(taskId,palletId,request.expectedVersion(),operation(request.operationId()),operatorId,operatorName)!=1)
throw conflict("空托盘、托盘状态或版本已变化,请刷新后重试");
TaskRow refreshed=requireTask(taskId,request.warehouseId());
if(Objects.equals(refreshed.getCountedCartons(),refreshed.getTotalCartons()) &&
Objects.equals(refreshed.getAllocatedCartons(),refreshed.getTotalCartons()) && mapper.openPalletCount(taskId)==0){
if(mapper.completeTask(taskId)!=1 || mapper.completeInbound(taskId,refreshed.getTenantId(),refreshed.getInboundId(),operatorId)!=1)
throw conflict("组托完成状态已变化,请刷新后重试");
}
return view(requireTask(taskId,request.warehouseId()));
}
private Task view(TaskRow row) { public List<Pallet> inboundPallets(long tenantId,long inboundId){
return new Task(row.getId(), row.getInboundId(), row.getInboundCode(), row.getTenantId(), String code=mapper.inboundCode(tenantId,inboundId);
row.getCustomerName(), row.getWarehouseId(), row.getWarehouseName(), row.getTotalCartons(), if(code==null) throw new BizException(ResultCode.NOT_FOUND,"入库单不存在");
row.getAllocatedCartons(), row.getTaskStatus(), row.getAssigneeId(), row.getAssigneeName(), return mapper.palletsByInbound(tenantId,inboundId).stream().map(p->palletView(p,code)).toList();
row.getVersion(), mapper.pallets(row.getId()).stream() }
.map(pallet -> palletView(pallet, row.getInboundCode())).toList());
}
private Pallet palletView(PalletRow row, String inboundCode) { private PalletRow getOrCreatePallet(TaskRow task,String palletNo,String operation,long operatorId,String operatorName){
List<CartonRow> cartons = mapper.cartons(row.getId()); PalletRow existing=mapper.palletByNo(palletNo);
Map<String, List<CartonRow>> groups = new LinkedHashMap<>(); if(existing!=null){
for (CartonRow carton : cartons) { if(!Objects.equals(existing.getTaskId(),task.getId()) || !Objects.equals(existing.getTenantId(),task.getTenantId())) throw conflict("托盘码已被其他客户或入仓任务占用");
groups.computeIfAbsent(carton.getInboundDetailId() + "|" + carton.getSkuCode(), ignored -> new ArrayList<>()) if(!"OPEN".equals(existing.getPalletStatus())) throw conflict("托盘已封托,不能继续绑定箱");
.add(carton); return existing;
} }
List<SkuItem> items = groups.values().stream().map(values -> { try{ mapper.insertPallet(IdWorker.getId(),task.getId(),task.getTenantId(),task.getInboundId(),task.getWarehouseId(),palletNo,operation,operatorId,operatorName); }
CartonRow first = values.get(0); catch(DuplicateKeyException e){ existing=mapper.palletByNo(palletNo); if(existing==null) throw conflict("托盘码已被并发占用"); }
return new SkuItem(first.getInboundId(), inboundCode, first.getInboundDetailId(), first.getSkuCode(), existing=mapper.palletByNo(palletNo);
first.getCargoName(), values.size(), values.stream().map(CartonRow::getBoxCode).toList()); if(existing==null || !Objects.equals(existing.getTaskId(),task.getId())) throw conflict("托盘码已被其他任务占用");
}).toList(); return existing;
return new Pallet(row.getId(), row.getPalletNo(), row.getPalletStatus(), cartons.size(), items.size(), }
row.getVersion(), items, row.getCompletedAt());
}
private TaskRow requireWorkingTask(long taskId, long warehouseId, long operatorId) { private CountResult countResult(TaskRow task,CartonRow carton){
TaskRow task = requireTask(taskId, warehouseId); Pallet p=carton.getPalletId()==null?null:palletView(mapper.pallet(task.getId(),carton.getPalletId(),task.getWarehouseId()),task.getInboundCode());
if (!"CLAIMED".equals(task.getTaskStatus())) throw conflict("请先领取待合托任务"); return new CountResult(cartonView(carton,task.getInboundCode(),false,null),task.getCountedCartons(),task.getAllocatedCartons(),
if (task.getAssigneeId() == null || task.getAssigneeId() != operatorId) throw conflict("任务不属于当前操作员"); task.getCountedCartons()-task.getAllocatedCartons(),task.getTotalCartons()-task.getCountedCartons(),p);
if (!"WAIT_PALLETIZE".equals(task.getProcessStatus())) throw conflict("入仓单已不在待合托状态"); }
return task;
}
private TaskRow requireTask(long taskId, long warehouseId) { private Task view(TaskRow row){
TaskRow task = mapper.task(taskId, warehouseId); return new Task(row.getId(),row.getInboundId(),row.getInboundCode(),row.getTenantId(),row.getCustomerName(),row.getWarehouseId(),row.getWarehouseName(),
if (task == null) throw new BizException(ResultCode.NOT_FOUND, "未找到当前仓库的一件代发合托任务"); row.getTotalCartons(),row.getCountedCartons(),row.getAllocatedCartons(),row.getCountedCartons()-row.getAllocatedCartons(),row.getDiscrepancyCartons(),
return task; row.getTaskStatus(),row.getAssigneeId(),row.getAssigneeName(),row.getVersion(),mapper.pallets(row.getId()).stream().map(p->palletView(p,row.getInboundCode())).toList(),
} mapper.unboundCartons(row.getId()).stream().map(c->cartonView(c,row.getInboundCode(),false,null)).toList());
}
private PalletRow requireOpenPallet(long taskId, long palletId, long warehouseId) { private Pallet palletView(PalletRow row,String inboundCode){
PalletRow pallet = mapper.pallet(taskId, palletId, warehouseId); List<CartonRow> cartons=mapper.cartons(row.getId()); Map<String,List<CartonRow>> groups=new LinkedHashMap<>();
if (pallet == null) throw new BizException(ResultCode.NOT_FOUND, "托盘不存在"); for(CartonRow c:cartons) groups.computeIfAbsent(c.getInboundDetailId()+"|"+c.getSkuCode(),ignored->new ArrayList<>()).add(c);
if (!"OPEN".equals(pallet.getPalletStatus())) throw conflict("托盘已完成,不能继续上托"); List<SkuItem> items=groups.values().stream().map(values->{CartonRow first=values.get(0);return new SkuItem(first.getInboundId(),inboundCode,
return pallet; first.getInboundDetailId(),first.getSkuCode(),first.getCargoName(),values.size(),values.stream().mapToInt(v->v.getActualSkuQuantity()==null?0:v.getActualSkuQuantity()).sum(),
} values.stream().map(CartonRow::getBoxCode).toList());}).toList();
int discrepancies=(int)cartons.stream().filter(c->"ACCEPTED_VARIANCE".equals(c.getDiscrepancyStatus())).count();
PalletMetrics metrics=mapper.metrics(row.getId());
return new Pallet(row.getId(),row.getPalletNo(),row.getPalletStatus(),cartons.size(),items.size(),discrepancies,
metrics==null?BigDecimal.ZERO:metrics.weightKg(),metrics==null?BigDecimal.ZERO:metrics.volumeCbm(),row.getVersion(),items,
cartons.stream().map(c->cartonView(c,inboundCode,false,null)).toList(),row.getCompletedAt());
}
private static String normalize(String value) { return StringUtils.hasText(value) ? value.trim() : null; } private Carton cartonView(CartonRow row,String inboundCode,boolean canCount,String blockReason){
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); } return new Carton(row.getId(),row.getBoxCode(),row.getInboundId(),inboundCode,row.getInboundDetailId(),row.getSkuCode(),row.getCargoName(),
private static BizException invalid(String message) { return new BizException(ResultCode.VALIDATION_FAILED, message); } row.getExpectedSkuQuantity(),row.getActualSkuQuantity(),row.getDiscrepancyStatus(),row.getDiscrepancyRemark(),
row.getPalletId()==null?"COUNTED_UNBOUND":"BOUND",row.getPalletId(),row.getPalletNo(),canCount,blockReason);
}
private LabelRow requireLabel(long taskId,String boxCode){ LabelRow label=mapper.label(taskId,boxCode.trim()); if(label==null) throw new BizException(ResultCode.NOT_FOUND,"箱码不属于当前一件代发入仓单"); if(!StringUtils.hasText(label.getSkuCode())) throw conflict("箱码缺少SKU快照,不能清点"); requireExpectedQuantity(label); return label; }
private int requireExpectedQuantity(LabelRow label){ if(label.getUnitsPerCarton()==null||label.getUnitsPerCarton()<1) throw conflict("箱码缺少每箱SKU数量快照,不能清点"); return label.getUnitsPerCarton(); }
private TaskRow requireWorkingTask(long taskId,long warehouseId,long operatorId){ TaskRow task=requireTask(taskId,warehouseId); if(!"CLAIMED".equals(task.getTaskStatus())) throw conflict("请先领取待清点组托任务"); if(!Objects.equals(task.getAssigneeId(),operatorId)) throw conflict("任务不属于当前操作员"); if(!"WAIT_PALLETIZE".equals(task.getProcessStatus())) throw conflict("入仓单已不在待清点组托状态"); return task; }
private TaskRow requireTask(long taskId,long warehouseId){ TaskRow task=mapper.task(taskId,warehouseId); if(task==null) throw new BizException(ResultCode.NOT_FOUND,"未找到当前仓库的一件代发清点组托任务"); return task; }
private PalletRow requireOpenPallet(long taskId,long palletId,long warehouseId){ PalletRow p=mapper.pallet(taskId,palletId,warehouseId); if(p==null) throw new BizException(ResultCode.NOT_FOUND,"托盘不存在"); if(!"OPEN".equals(p.getPalletStatus())) throw conflict("托盘已封托,不能修改"); return p; }
private static String operation(String v){String n=normalize(v);if(!StringUtils.hasText(n))throw invalid("操作幂等号不能为空");return n;}
private static String normalize(String v){return StringUtils.hasText(v)?v.trim():null;}
private static BizException conflict(String m){return new BizException(ResultCode.CONFLICT,m);}
private static BizException invalid(String m){return new BizException(ResultCode.VALIDATION_FAILED,m);}
} }
@@ -54,6 +54,10 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
&& cargoes.stream().anyMatch(cargo -> cargo.skuNo() == null || cargo.skuNo().isBlank())) { && cargoes.stream().anyMatch(cargo -> cargo.skuNo() == null || cargo.skuNo().isBlank())) {
throw new BizException(ResultCode.VALIDATION_FAILED, "一件代发货物明细缺少SKU,不能生成箱唛"); throw new BizException(ResultCode.VALIDATION_FAILED, "一件代发货物明细缺少SKU,不能生成箱唛");
} }
if ("ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())
&& cargoes.stream().anyMatch(cargo -> cargo.unitsPerCarton() == null || cargo.unitsPerCarton() < 1)) {
throw new BizException(ResultCode.VALIDATION_FAILED, "一件代发货物明细缺少每箱SKU数量,不能生成箱唛");
}
existing.forEach(label -> cargoes.stream() existing.forEach(label -> cargoes.stream()
.filter(cargo -> java.util.Objects.equals(cargo.cargoId(), label.getInboundDetailId())) .filter(cargo -> java.util.Objects.equals(cargo.cargoId(), label.getInboundDetailId()))
.findFirst().ifPresent(cargo -> repository.initializeLegacySku(tenantId, inboundId, .findFirst().ifPresent(cargo -> repository.initializeLegacySku(tenantId, inboundId,
@@ -211,6 +215,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
label.setCargoItemNo(cargo.itemNo()); label.setCargoItemNo(cargo.itemNo());
label.setCargoName(cargo.cargoName()); label.setCargoName(cargo.cargoName());
label.setSkuCode(cargo.skuNo()); label.setSkuCode(cargo.skuNo());
label.setUnitsPerCarton(cargo.unitsPerCarton());
label.setDetailPackageCount(cargo.packageCount()); label.setDetailPackageCount(cargo.packageCount());
label.setInboundCode(inbound.getInboundCode()); label.setInboundCode(inbound.getInboundCode());
label.setBoxCode(inbound.getInboundCode() + "#" + total + "-" + index); label.setBoxCode(inbound.getInboundCode() + "#" + total + "-" + index);
@@ -267,6 +272,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
String source = cargoes.stream().map(cargo -> String.join("|", String.valueOf(cargo.itemNo()), String source = cargoes.stream().map(cargo -> String.join("|", String.valueOf(cargo.itemNo()),
cargo.cargoName() == null ? "" : cargo.cargoName().trim(), cargo.cargoName() == null ? "" : cargo.cargoName().trim(),
cargo.skuNo() == null ? "" : cargo.skuNo().trim(), String.valueOf(cargo.packageCount()), cargo.skuNo() == null ? "" : cargo.skuNo().trim(), String.valueOf(cargo.packageCount()),
String.valueOf(cargo.unitsPerCarton()),
decimalOrEmpty(cargo.lengthCm()), decimalOrEmpty(cargo.widthCm()), decimalOrEmpty(cargo.heightCm()))) decimalOrEmpty(cargo.lengthCm()), decimalOrEmpty(cargo.widthCm()), decimalOrEmpty(cargo.heightCm())))
.collect(java.util.stream.Collectors.joining("\n")); .collect(java.util.stream.Collectors.joining("\n"));
try { try {
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.LoadingPickDtos.PickItem; import com.baiyue.firstleg.dto.LoadingPickDtos.PickItem;
import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.PalletPickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.Task; import com.baiyue.firstleg.dto.LoadingPickDtos.Task;
@@ -106,6 +107,8 @@ public class LoadingPickService {
private InboundPalletAllocationRepository palletAllocations; private InboundPalletAllocationRepository palletAllocations;
@Resource @Resource
private InboundHoldGuard inboundHoldGuard; private InboundHoldGuard inboundHoldGuard;
@Resource
private com.baiyue.firstleg.mapper.DropshipPalletizationMapper dropshipPalletizationMapper;
/** Opens the field task after planning and ocean release; the real container number may be filled later. */ /** Opens the field task after planning and ocean release; the real container number may be filled later. */
@Transactional @Transactional
@@ -326,6 +329,7 @@ public class LoadingPickService {
if (item == null) { if (item == null) {
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在"); throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在");
} }
requirePackageUnit(item);
if (repository.pick(itemId, request.pickedPackageCount(), request.version(), request.warehouseId()) != 1) { if (repository.pick(itemId, request.pickedPackageCount(), request.version(), request.warehouseId()) != 1) {
throw conflict("拣货数量超过计划箱数或明细版本已变化"); throw conflict("拣货数量超过计划箱数或明细版本已变化");
} }
@@ -344,6 +348,41 @@ public class LoadingPickService {
return view(requiredTask(taskId), request.warehouseId()); return view(requiredTask(taskId), request.warehouseId());
} }
/** 一件代发只扫描已封托托盘码,并在同一事务把该托全部计划行结算为已拣。 */
@Transactional
public Task pickPallet(long taskId,PalletPickRequest request,long operatorId,String operatorName){
inboundHoldGuard.requireTaskNotHeld(taskId);
String operationId=operation(request.operationId());
String requestHash=hash("PICK_PALLET|"+taskId+'|'+request.warehouseId()+'|'+request.palletNo()
+'|'+request.expectedTaskVersion());
Task replay=replayTaskOperation(taskId,operationId,requestHash,operatorId); if(replay!=null)return replay;
LoadingPickTaskEntity task=requiredAssignedLoadingTaskForUpdate(taskId,operatorId);
replay=replayTaskOperationForUpdate(taskId,operationId,requestHash,operatorId); if(replay!=null)return replay;
if(!Objects.equals(task.getVersion(),request.expectedTaskVersion()))throw conflict("PDA任务版本已变化,请刷新后重试");
List<LoadingPlanItemEntity> planItems=smartLoadingRepository.items(task.getLoadingPlanId()).stream()
.filter(item->request.palletNo().equals(item.getPalletNo()) && item.getPalletId()!=null
&& Boolean.TRUE.equals(item.getIndivisible())).toList();
if(planItems.isEmpty())throw new BizException(ResultCode.NOT_FOUND,"托盘不属于当前装柜任务");
Long palletId=planItems.get(0).getPalletId();
if(planItems.stream().anyMatch(item->!Objects.equals(item.getPalletId(),palletId)))throw conflict("托盘装柜快照不一致");
var physical=dropshipPalletizationMapper.sealedPalletLines(palletId);
if(physical.isEmpty()||physical.stream().anyMatch(line->!"SEALED".equals(line.getPalletStatus())))throw conflict("托盘已失效或尚未封托");
for(LoadingPlanItemEntity planItem:planItems){
LoadingPickItemEntity pickItem=repository.itemByPlanItem(planItem.getId());
if(pickItem==null||!"PENDING".equals(pickItem.getItemStatus())||count(pickItem.getPickedPackageCount())!=0)
throw conflict("托盘已有部分作业或明细已变化,禁止拆托处理");
if(repository.pick(pickItem.getId(),pickItem.getPlannedPackageCount(),pickItem.getVersion(),request.warehouseId())!=1)
throw conflict("托盘明细版本已变化,请刷新后重试");
insertHistory(taskId,pickItem.getId(),"PAL-"+hash(operationId+'|'+pickItem.getId()).substring(0,48),
requestHash,"PICK_PALLET",pickItem.getPlannedPackageCount(),null,operatorId,operatorName);
}
startPickingIfNeeded(task,operatorId,operatorName);
if(repository.touchTask(taskId,request.expectedTaskVersion())!=1)throw conflict("PDA任务版本已变化,请刷新后重试");
insertOperation(taskId,operationId,requestHash,"PICK_PALLET",operatorId,operatorName);
settleAndFinish(task,operatorId,operatorName); repository.syncInboundProjections(taskId,operatorId,operatorName);
return view(requiredTask(taskId),request.warehouseId());
}
@Transactional @Transactional
public Task partial(long taskId, long itemId, PartialPickRequest request, public Task partial(long taskId, long itemId, PartialPickRequest request,
long operatorId, String operatorName) { long operatorId, String operatorName) {
@@ -364,7 +403,8 @@ public class LoadingPickService {
assertReplay(replay, taskId, itemId, requestHash); assertReplay(replay, taskId, itemId, requestHash);
return view(task, request.warehouseId()); return view(task, request.warehouseId());
} }
requireWarehouseItem(taskId, itemId, request.warehouseId()); LoadingPickItemEntity item = requireWarehouseItem(taskId, itemId, request.warehouseId());
requirePackageUnit(item);
if (repository.partial(itemId, request.finalPickedPackageCount(), request.version(), if (repository.partial(itemId, request.finalPickedPackageCount(), request.version(),
request.warehouseId()) != 1) { request.warehouseId()) != 1) {
throw conflict("部分拣货箱数必须大于已拣箱数且小于计划箱数,或明细版本已变化"); throw conflict("部分拣货箱数必须大于已拣箱数且小于计划箱数,或明细版本已变化");
@@ -397,6 +437,7 @@ public class LoadingPickService {
return view(task, request.warehouseId()); return view(task, request.warehouseId());
} }
LoadingPickItemEntity item = requireWarehouseItem(taskId, itemId, request.warehouseId()); LoadingPickItemEntity item = requireWarehouseItem(taskId, itemId, request.warehouseId());
requirePackageUnit(item);
if (repository.hasPickedOrPartialItem(taskId, item.getInboundId())) { if (repository.hasPickedOrPartialItem(taskId, item.getInboundId())) {
throw conflict("同一入库单已存在已拣或部分拣货明细,不能跳拣并恢复智能装柜候选"); throw conflict("同一入库单已存在已拣或部分拣货明细,不能跳拣并恢复智能装柜候选");
} }
@@ -443,6 +484,7 @@ public class LoadingPickService {
List<BatchLine> lines = validateBatchLines(task, request); List<BatchLine> lines = validateBatchLines(task, request);
validateInventorySnapshot(task, request, lines); validateInventorySnapshot(task, request, lines);
lines.forEach(line -> requirePackageUnit(line.item()));
for (BatchLine line : lines) { for (BatchLine line : lines) {
applyBatchLine(taskId, line, operationId, operatorId, operatorName); applyBatchLine(taskId, line, operationId, operatorId, operatorName);
} }
@@ -1213,6 +1255,16 @@ public class LoadingPickService {
/** /**
* 逐项拣货后仅更新任务版本与投影,不能自动结算或推进装柜计划。 * 逐项拣货后仅更新任务版本与投影,不能自动结算或推进装柜计划。
*/ */
private void requirePackageUnit(LoadingPickItemEntity item) {
if (item.getLoadingPlanItemId() == null) return;
LoadingPlanItemEntity planItem = smartLoadingRepository.item(
requiredTask(item.getPickTaskId()).getLoadingPlanId(), item.getLoadingPlanItemId());
if (planItem != null && (Boolean.TRUE.equals(planItem.getIndivisible())
|| planItem.getPalletId() != null)) {
throw conflict("一件代发已封托货物必须扫描托盘码整体拣货,不能按箱数处理");
}
}
private void touchSettleFinishAndSync(LoadingPickTaskEntity task, long operatorId, String operatorName) { private void touchSettleFinishAndSync(LoadingPickTaskEntity task, long operatorId, String operatorName) {
if (repository.touchTask(task.getId(), task.getVersion()) != 1) { if (repository.touchTask(task.getId(), task.getVersion()) != 1) {
throw conflict("PDA任务版本已变化"); throw conflict("PDA任务版本已变化");
@@ -1,210 +0,0 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.LoadingPalletDtos.Carton;
import com.baiyue.firstleg.dto.LoadingPalletDtos.OpenRequest;
import com.baiyue.firstleg.dto.LoadingPalletDtos.Pallet;
import com.baiyue.firstleg.dto.LoadingPalletDtos.ScanRequest;
import com.baiyue.firstleg.dto.LoadingPalletDtos.SealRequest;
import com.baiyue.firstleg.dto.LoadingPalletDtos.SkuSummary;
import com.baiyue.firstleg.entity.LoadingWorkPalletCartonEntity;
import com.baiyue.firstleg.entity.LoadingWorkPalletEntity;
import com.baiyue.firstleg.mapper.LoadingWorkPalletCartonMapper;
import com.baiyue.firstleg.mapper.LoadingWorkPalletCartonMapper.CartonCandidate;
import com.baiyue.firstleg.mapper.LoadingWorkPalletMapper;
import com.baiyue.firstleg.mapper.LoadingWorkPalletMapper.TaskContext;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/** 一件代发现场打托旁路;不改变既有拣货数量和仓库资源结算。 */
@Service
public class LoadingWorkPalletService {
@Resource private LoadingWorkPalletMapper pallets;
@Resource private LoadingWorkPalletCartonMapper cartons;
@Resource private InboundHoldGuard inboundHoldGuard;
@Transactional
public Pallet open(long taskId, OpenRequest request, long operatorId, String operatorName) {
inboundHoldGuard.requireTaskNotHeld(taskId);
TaskContext context = requireWorkTask(taskId, request.warehouseId(), operatorId);
String code = request.palletCode().trim();
String operationId = request.operationId().trim();
LoadingWorkPalletEntity replay = pallets.selectByOpenOperation(operationId);
if (replay != null) {
if (replay.getPickTaskId() != taskId || !replay.getPalletCode().equals(code)) {
throw conflict("开托幂等号已用于其他请求");
}
return view(replay);
}
LoadingWorkPalletEntity value = new LoadingWorkPalletEntity();
value.setPickTaskId(taskId);
value.setLoadingPlanId(context.loadingPlanId());
value.setWarehouseId(request.warehouseId());
value.setWarehousePalletId(request.warehousePalletId());
value.setPalletCode(code);
value.setOpenOperationId(operationId);
value.setPalletStatus("OPEN");
value.setCreatedBy(operatorId);
value.setCreatedByName(operatorName);
value.setVersion(0);
try {
pallets.insert(value);
} catch (DuplicateKeyException exception) {
replay = pallets.selectByOpenOperation(operationId);
if (replay == null || replay.getPickTaskId() != taskId || !replay.getPalletCode().equals(code)) {
throw conflict("托盘号已存在或开托操作重复");
}
value = replay;
}
return view(value);
}
@Transactional
public Pallet scan(long taskId, long palletId, ScanRequest request,
long operatorId, String operatorName) {
inboundHoldGuard.requireTaskNotHeld(taskId);
requireWorkTask(taskId, request.warehouseId(), operatorId);
LoadingWorkPalletEntity target = requireOpenPallet(taskId, palletId, request.warehouseId());
String operationId = request.operationId().trim();
LoadingWorkPalletCartonEntity replay = cartons.selectByOperation(operationId);
if (replay != null) {
if (replay.getPickTaskId() != taskId || replay.getWorkPalletId() != palletId
|| !replay.getBoxCode().equals(request.boxCode().trim())) {
throw conflict("箱唛操作幂等号已用于其他请求");
}
return view(target);
}
List<CartonCandidate> candidates = cartons.selectCandidates(taskId, request.warehouseId(),
request.boxCode().trim());
if (candidates.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "箱唛不属于当前任务的一件代发货物");
if (candidates.size() > 1) throw conflict("箱唛在当前多客户任务中不唯一,请检查箱唛数据");
CartonCandidate candidate = candidates.get(0);
if (candidate.getSkuCode() == null || candidate.getSkuCode().isBlank()) {
throw conflict("一件代发箱唛缺少SKU,不能上托");
}
LoadingWorkPalletCartonEntity current = cartons.selectBinding(taskId, candidate.getCartonLabelId());
if (current == null) {
current = binding(taskId, palletId, candidate, operationId, operatorId, operatorName);
try {
cartons.insert(current);
} catch (DuplicateKeyException exception) {
LoadingWorkPalletCartonEntity concurrent = cartons.selectBinding(taskId, candidate.getCartonLabelId());
if (concurrent == null || concurrent.getWorkPalletId() != palletId) {
throw conflict("箱唛已被其他托盘绑定");
}
}
return view(target);
}
if (current.getWorkPalletId() == palletId) return view(target);
LoadingWorkPalletEntity source = pallets.selectScoped(taskId, current.getWorkPalletId(), request.warehouseId());
if (source == null || !"OPEN".equals(source.getPalletStatus())) {
throw conflict("来源托盘已封托,不能直接移箱");
}
if (cartons.move(current.getId(), current.getVersion(), palletId, operatorId, operatorName, operationId) != 1) {
throw conflict("箱唛绑定已变化,请刷新后重试");
}
return view(target);
}
@Transactional
public Pallet seal(long taskId, long palletId, SealRequest request,
long operatorId, String operatorName) {
inboundHoldGuard.requireTaskNotHeld(taskId);
requireWorkTask(taskId, request.warehouseId(), operatorId);
LoadingWorkPalletEntity replay = pallets.selectBySealOperation(request.operationId().trim());
if (replay != null) {
if (replay.getId() != palletId || replay.getPickTaskId() != taskId) {
throw conflict("封托幂等号已用于其他请求");
}
return view(replay);
}
requireOpenPallet(taskId, palletId, request.warehouseId());
if (cartons.countPalletCartons(palletId) == 0) throw conflict("空托盘不能封托");
if (cartons.countUnreconciled(taskId) > 0) {
throw conflict("现场上托箱数超过已提交拣货箱数,请先完成原PDA拣货提交");
}
if (pallets.seal(taskId, palletId, request.warehouseId(), request.expectedVersion(),
request.operationId().trim(), operatorId, operatorName) != 1) {
throw conflict("托盘状态或版本已变化,请刷新后重试");
}
return view(pallets.selectScoped(taskId, palletId, request.warehouseId()));
}
@Transactional(readOnly = true)
public List<Pallet> list(long taskId, long warehouseId) {
TaskContext context = pallets.selectTaskContext(taskId, warehouseId);
if (context == null) throw new BizException(ResultCode.NOT_FOUND, "未找到当前仓库的PDA任务");
return pallets.selectTaskPallets(taskId, warehouseId).stream().map(this::view).toList();
}
private TaskContext requireWorkTask(long taskId, long warehouseId, long operatorId) {
TaskContext context = pallets.selectTaskContext(taskId, warehouseId);
if (context == null) throw new BizException(ResultCode.NOT_FOUND, "未找到当前仓库的PDA任务");
if (!"LOADING".equals(context.taskStatus())) throw conflict("PDA任务已完成,不能继续打托");
if (context.dropshipItemCount() == null || context.dropshipItemCount() == 0) {
throw conflict("当前任务没有一件代发货物");
}
if (context.assigneeId() == null || context.assigneeId() != operatorId) {
throw conflict("请先领取PDA任务后再执行打托");
}
return context;
}
private LoadingWorkPalletEntity requireOpenPallet(long taskId, long palletId, long warehouseId) {
LoadingWorkPalletEntity pallet = pallets.selectScoped(taskId, palletId, warehouseId);
if (pallet == null) throw new BizException(ResultCode.NOT_FOUND, "现场托盘不存在");
if (!"OPEN".equals(pallet.getPalletStatus())) throw conflict("托盘不是打托中状态");
return pallet;
}
private static LoadingWorkPalletCartonEntity binding(long taskId, long palletId,
CartonCandidate source, String operationId,
long operatorId, String operatorName) {
LoadingWorkPalletCartonEntity value = new LoadingWorkPalletCartonEntity();
value.setPickTaskId(taskId);
value.setWorkPalletId(palletId);
value.setLoadingPlanItemId(source.getLoadingPlanItemId());
value.setPickItemId(source.getPickItemId());
value.setTenantId(source.getTenantId());
value.setInboundId(source.getInboundId());
value.setInboundDetailId(source.getInboundDetailId());
value.setCartonLabelId(source.getCartonLabelId());
value.setBoxCode(source.getBoxCode());
value.setSkuCode(source.getSkuCode());
value.setUnitsPerCarton(source.getUnitsPerCarton());
value.setBoundBy(operatorId);
value.setBoundByName(operatorName);
value.setOperationId(operationId);
value.setVersion(0);
return value;
}
private Pallet view(LoadingWorkPalletEntity pallet) {
List<LoadingWorkPalletCartonEntity> values = cartons.selectPalletCartons(pallet.getId());
Map<String, List<LoadingWorkPalletCartonEntity>> bySku = values.stream().collect(
java.util.stream.Collectors.groupingBy(LoadingWorkPalletCartonEntity::getSkuCode,
LinkedHashMap::new, java.util.stream.Collectors.toList()));
List<SkuSummary> skus = bySku.entrySet().stream().map(entry -> new SkuSummary(entry.getKey(),
entry.getValue().size(), entry.getValue().stream().map(LoadingWorkPalletCartonEntity::getUnitsPerCarton)
.filter(Objects::nonNull).mapToLong(Integer::longValue).sum())).toList();
List<Carton> cartonViews = values.stream().map(value -> new Carton(value.getId(), value.getBoxCode(),
value.getSkuCode(), value.getTenantId(), value.getInboundId(), value.getInboundDetailId(),
value.getUnitsPerCarton())).toList();
return new Pallet(pallet.getId(), pallet.getPalletCode(), pallet.getWarehousePalletId(),
pallet.getPalletStatus(), pallet.getVersion(), values.size(), bySku.size(),
(int) values.stream().map(LoadingWorkPalletCartonEntity::getTenantId).distinct().count(),
skus, cartonViews);
}
private static BizException conflict(String message) {
return new BizException(ResultCode.CONFLICT, message);
}
}
@@ -23,6 +23,7 @@ import java.math.BigDecimal;
@Service @Service
public class MerchantInboundBillingService { public class MerchantInboundBillingService {
private static final Set<String> PUBLISHED_BILLING_PROCESS_STATUSES = Set.of( private static final Set<String> PUBLISHED_BILLING_PROCESS_STATUSES = Set.of(
InboundProcessStatus.WAIT_PALLETIZE,
InboundProcessStatus.WAIT_SPLIT, InboundProcessStatus.WAIT_SPLIT,
InboundProcessStatus.LOADING, InboundProcessStatus.LOADING,
InboundProcessStatus.LOADED, InboundProcessStatus.LOADED,
@@ -54,6 +54,7 @@ public class SmartLoadingService {
"OA_NEW_YORK_TRUCK", Set.of("OA_NEW_YORK_TRUCK") "OA_NEW_YORK_TRUCK", Set.of("OA_NEW_YORK_TRUCK")
); );
@Resource private SmartLoadingRepository repository; @Resource private SmartLoadingRepository repository;
@Resource private com.baiyue.firstleg.mapper.DropshipPalletizationMapper dropshipPallets;
@Resource private OrderCargoClient orderCargoClient; @Resource private OrderCargoClient orderCargoClient;
@Resource private PendingReviewOrderClient pendingReviewOrders; @Resource private PendingReviewOrderClient pendingReviewOrders;
@Resource private InboundOrderCreationService inboundOrderCreationService; @Resource private InboundOrderCreationService inboundOrderCreationService;
@@ -111,7 +112,7 @@ public class SmartLoadingService {
Map<Long, List<Candidate>> grouped = candidates(bookingId, warehouseId).stream() Map<Long, List<Candidate>> grouped = candidates(bookingId, warehouseId).stream()
.collect(java.util.stream.Collectors.groupingBy(Candidate::inboundId, LinkedHashMap::new, .collect(java.util.stream.Collectors.groupingBy(Candidate::inboundId, LinkedHashMap::new,
java.util.stream.Collectors.toList())); java.util.stream.Collectors.toList()));
return grouped.values().stream().map(this::inboundCandidate).toList(); return grouped.values().stream().flatMap(details -> inboundCandidates(details).stream()).toList();
} }
private List<InboundCandidate> recommendationCandidates(ContainerBookingEntity booking) { private List<InboundCandidate> recommendationCandidates(ContainerBookingEntity booking) {
@@ -121,7 +122,7 @@ public class SmartLoadingService {
repository.planByBooking(booking.getId()), values).stream() repository.planByBooking(booking.getId()), values).stream()
.collect(java.util.stream.Collectors.groupingBy(Candidate::inboundId, LinkedHashMap::new, .collect(java.util.stream.Collectors.groupingBy(Candidate::inboundId, LinkedHashMap::new,
java.util.stream.Collectors.toList())); java.util.stream.Collectors.toList()));
return grouped.values().stream().map(this::inboundCandidate).toList(); return grouped.values().stream().flatMap(details -> inboundCandidates(details).stream()).toList();
} }
/** Global workbench candidates. A co-load group is one paging unit and can never be split across pages. */ /** Global workbench candidates. A co-load group is one paging unit and can never be split across pages. */
@@ -175,7 +176,7 @@ public class SmartLoadingService {
List<InboundCandidate> all = repository.globalCandidatesByUnits(unitPage.records()).stream() List<InboundCandidate> all = repository.globalCandidatesByUnits(unitPage.records()).stream()
.collect(java.util.stream.Collectors.groupingBy(Candidate::inboundId, LinkedHashMap::new, .collect(java.util.stream.Collectors.groupingBy(Candidate::inboundId, LinkedHashMap::new,
java.util.stream.Collectors.toList())) java.util.stream.Collectors.toList()))
.values().stream().map(this::inboundCandidate).toList(); .values().stream().flatMap(details -> inboundCandidates(details).stream()).toList();
Map<String, InboundCandidate> units = new HashMap<>(); Map<String, InboundCandidate> units = new HashMap<>();
Map<Long, List<InboundCandidate>> grouped = all.stream() Map<Long, List<InboundCandidate>> grouped = all.stream()
.filter(candidate -> candidate.coLoadGroupId() != null) .filter(candidate -> candidate.coLoadGroupId() != null)
@@ -216,6 +217,7 @@ public class SmartLoadingService {
new InboundCandidate(row.customerId(), null, row.orderId(), row.orderNo(), row.inboundCode(), new InboundCandidate(row.customerId(), null, row.orderId(), row.orderNo(), row.inboundCode(),
row.warehouseId(), row.channelCode(), row.channelName(), null, null, null, row.warehouseId(), row.channelCode(), row.channelName(), null, null, null,
row.customerName(), null, row.totalPackageCount(), null, null, null, null, row.customerName(), null, row.totalPackageCount(), null, null, null, null,
"PACKAGE", null, null, false,
null, null, null, null, false, true, null, null, null, null, null, false, true, null,
"PENDING_REVIEW", row.expectedArrivalAt(), List.of())).toList(); "PENDING_REVIEW", row.expectedArrivalAt(), List.of())).toList();
return new PageResult<>(records, source.page(), source.size(), source.total(), source.pages()); return new PageResult<>(records, source.page(), source.size(), source.total(), source.pages());
@@ -266,7 +268,7 @@ public class SmartLoadingService {
return repository.workbenchContainers(query, actualChannels(query.channelGroupCodes())); return repository.workbenchContainers(query, actualChannels(query.channelGroupCodes()));
} }
private InboundCandidate inboundCandidate(List<Candidate> details) { private List<InboundCandidate> inboundCandidates(List<Candidate> details) {
Candidate first = details.get(0); Candidate first = details.get(0);
int packages = details.stream().map(Candidate::availablePackageCount) int packages = details.stream().map(Candidate::availablePackageCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum(); .filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
@@ -279,13 +281,23 @@ public class SmartLoadingService {
BigDecimal.valueOf(packages), 6, RoundingMode.HALF_UP); BigDecimal.valueOf(packages), 6, RoundingMode.HALF_UP);
String goods = details.stream().map(Candidate::goodsName).filter(StringUtils::hasText) String goods = details.stream().map(Candidate::goodsName).filter(StringUtils::hasText)
.map(String::trim).distinct().collect(java.util.stream.Collectors.joining("、")); .map(String::trim).distinct().collect(java.util.stream.Collectors.joining("、"));
return new InboundCandidate(first.accountId(), first.inboundId(), first.orderId(), first.orderNo(), List<PalletCandidate> pallets=dropshipPallets.palletCandidates(first.inboundId());
if(!pallets.isEmpty()) return pallets.stream().map(pallet -> new InboundCandidate(first.accountId(), first.inboundId(), first.orderId(), first.orderNo(),
first.inboundCode(), first.warehouseId(), first.channelCode(), first.channelName(),
first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(),
first.customerName(), goods, pallet.cartonCount(),
pallet.cartonCount()==null||pallet.cartonCount()==0?null:pallet.weightKg().divide(BigDecimal.valueOf(pallet.cartonCount()),6,RoundingMode.HALF_UP),
first.unitNetWeightKg(), pallet.cartonCount()==null||pallet.cartonCount()==0?null:pallet.volumeCbm().divide(BigDecimal.valueOf(pallet.cartonCount()),6,RoundingMode.HALF_UP),
pallet.volumeCbm(),"SEALED_PALLET",pallet.palletId(),pallet.palletNo(),true,
first.coLoadGroupId(), first.groupNo(), first.memberCount(),first.groupTotalBoxes(), first.locked(), canAssign(first), capabilityBlock(first),
first.orderStatusSnapshot(),first.expectedArrivalAt(),List.of())).toList();
return List.of(new InboundCandidate(first.accountId(), first.inboundId(), first.orderId(), first.orderNo(),
first.inboundCode(), first.warehouseId(), first.channelCode(), first.channelName(), first.inboundCode(), first.warehouseId(), first.channelCode(), first.channelName(),
first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(), first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(),
first.customerName(), goods, packages, first.unitGrossWeightKg(), first.unitNetWeightKg(), first.customerName(), goods, packages, first.unitGrossWeightKg(), first.unitNetWeightKg(),
averageVolume, volume, first.coLoadGroupId(), first.groupNo(), first.memberCount(), averageVolume, volume,"PACKAGE",null,null,false, first.coLoadGroupId(), first.groupNo(), first.memberCount(),
first.groupTotalBoxes(), first.locked(), canAssign(first), capabilityBlock(first), first.orderStatusSnapshot(), first.groupTotalBoxes(), first.locked(), canAssign(first), capabilityBlock(first), first.orderStatusSnapshot(),
first.expectedArrivalAt(), List.of()); first.expectedArrivalAt(), List.of()));
} }
private InboundCandidate globalGroupCandidate(List<InboundCandidate> availableMembers) { private InboundCandidate globalGroupCandidate(List<InboundCandidate> availableMembers) {
@@ -317,7 +329,7 @@ public class SmartLoadingService {
return new InboundCandidate(first.accountId(), first.inboundId(), first.orderId(), first.orderNo(), return new InboundCandidate(first.accountId(), first.inboundId(), first.orderId(), first.orderNo(),
first.inboundCode(), first.warehouseId(), first.channelCode(), first.channelName(), first.inboundCode(), first.warehouseId(), first.channelCode(), first.channelName(),
first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(), first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(),
customers, goods, packages, gross, net, averageVolume, volume, metadata.coLoadGroupId(), metadata.groupNo(), customers, goods, packages, gross, net, averageVolume, volume,"PACKAGE",null,null,false, metadata.coLoadGroupId(), metadata.groupNo(),
metadata.memberCount(), metadata.groupTotalBoxes(), metadata.memberCount(), metadata.groupTotalBoxes(),
occupied, occupied,
ordered.stream().allMatch(candidate -> Boolean.TRUE.equals(candidate.canAssign())), ordered.stream().allMatch(candidate -> Boolean.TRUE.equals(candidate.canAssign())),
@@ -418,11 +430,52 @@ public class SmartLoadingService {
@BusinessAudit(event = "SMART_LOADING_ITEM_ASSIGN", resourceType = "LOADING_PLAN", @BusinessAudit(event = "SMART_LOADING_ITEM_ASSIGN", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "分配智能装柜明细") resourceId = "#planId", description = "分配智能装柜明细")
public Plan assign(long planId, AssignRequest request, long operatorId, String operatorName) { public Plan assign(long planId, AssignRequest request, long operatorId, String operatorName) {
return assign(planId, request, operatorId, operatorName, false); return assign(planId, request, operatorId, operatorName, false, null, null, null, false, null);
}
@Transactional
@BusinessAudit(event = "SMART_LOADING_PALLET_ASSIGN", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "按已封托托盘分配智能装柜明细")
public Plan assignPallet(long planId, AssignPalletRequest request, long operatorId, String operatorName) {
String operation = operation(request.operationId());
String requestHash = sha256("ASSIGN_PALLET|" + planId + '|' + request.palletId() + '|'
+ request.expectedVersion());
Plan replay = replay(operation, planId, "ADD_PALLET", null, requestHash);
if (replay != null) return replay;
LoadingPlanEntity original = editing(planId, request.expectedVersion(), operatorId, operatorName);
List<com.baiyue.firstleg.mapper.DropshipPalletizationMapper.PalletLine> lines =
dropshipPallets.sealedPalletLines(request.palletId());
if (lines.isEmpty() || lines.stream().anyMatch(line -> !"SEALED".equals(line.getPalletStatus()))) {
throw conflict("托盘不存在或尚未封托");
}
var first = lines.get(0);
inboundHoldGuard.requireInboundNotHeld(first.getAccountId(), first.getInboundId());
if (!repository.activeItemsByPallet(request.palletId()).isEmpty()) throw conflict("托盘已被有效柜次占用");
int sequence = 0;
for (var line : lines) {
LoadingPlanEntity current = repository.plan(planId);
Candidate candidate = new Candidate(line.getAccountId(), line.getInboundId(), line.getInboundDetailId(),
line.getOrderId(), line.getOrderNo(), line.getInboundCode(), line.getWarehouseId(), line.getChannelCode(),
line.getChannelName(), line.getDestinationWarehouseId(), line.getDestinationWarehouseCode(),
line.getDestinationWarehouseName(), line.getCustomerName(), line.getCargoName(), line.getCartonCount(),
line.getUnitGrossWeightKg(), line.getUnitNetWeightKg(), line.getUnitVolumeCbm(), null, null, null, null,
false, null, "WAIT_SPLIT", null);
String childOperation = "PAL-" + sha256(operation + '|' + line.getInboundDetailId()).substring(0, 48);
assign(planId, new AssignRequest(line.getInboundId(), line.getInboundDetailId(), line.getCartonCount(),
current.getVersion(), childOperation), operatorId, operatorName, true, request.palletId(),
line.getPalletNo(), line.getActualSkuQuantity(), sequence++ == 0, candidate);
}
LoadingPlanEntity assigned = repository.plan(planId);
history(planId, null, "ADD_PALLET", original.getPlanStatus(), assigned.getPlanStatus(), operation,
json(values("palletId", request.palletId(), "version", request.expectedVersion())),
json(values("requestHash", requestHash, "palletNo", first.getPalletNo(), "lineCount", lines.size())),
operatorId, operatorName);
return view(assigned, requireBooking(assigned.getContainerBookingId()));
} }
private Plan assign(long planId, AssignRequest request, long operatorId, String operatorName, private Plan assign(long planId, AssignRequest request, long operatorId, String operatorName,
boolean wholeGroupAssignment) { boolean wholeGroupAssignment, Long palletId, String palletNo,
Integer actualSkuQuantity, boolean primaryPalletLine, Candidate frozenCandidate) {
String operation = operation(request.operationId()); String operation = operation(request.operationId());
String requestHash = sha256("ASSIGN|" + planId + '|' String requestHash = sha256("ASSIGN|" + planId + '|'
+ request.inboundId() + '|' + request.inboundDetailId() + '|' + request.packageCount() + request.inboundId() + '|' + request.inboundDetailId() + '|' + request.packageCount()
@@ -431,8 +484,14 @@ public class SmartLoadingService {
if (replay != null) return replay; if (replay != null) return replay;
LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName); LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName);
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
Candidate candidate = repository.candidate(planId, request.inboundId(), request.inboundDetailId()); Candidate candidate = frozenCandidate == null
? repository.candidate(planId, request.inboundId(), request.inboundDetailId()) : frozenCandidate;
if (candidate == null) throw conflict("入库货物不存在或尚未完成入库"); if (candidate == null) throw conflict("入库货物不存在或尚未完成入库");
InboundOrderEntity candidateInbound = repository.inbound(candidate.accountId(), candidate.inboundId());
if (!wholeGroupAssignment && candidateInbound != null
&& "ONE_CLICK_DROPSHIP".equals(candidateInbound.getFulfillmentType())) {
throw conflict("一件代发必须按已封托托盘整体加入,不能按箱数拆分");
}
inboundHoldGuard.requireInboundNotHeld(candidate.accountId(), candidate.inboundId()); inboundHoldGuard.requireInboundNotHeld(candidate.accountId(), candidate.inboundId());
if (!wholeGroupAssignment) requireStandalone(candidate.accountId(), candidate.inboundId()); if (!wholeGroupAssignment) requireStandalone(candidate.accountId(), candidate.inboundId());
requireSameCustomerScope(booking, candidate); requireSameCustomerScope(booking, candidate);
@@ -445,7 +504,9 @@ public class SmartLoadingService {
operatorId, operatorName); operatorId, operatorName);
} }
OrderCargoClient.CargoBinding cargo = loadCargo(candidate); OrderCargoClient.CargoBinding cargo = loadCargo(candidate);
LoadingPlanItemEntity existing = repository.activeItem(planId, candidate.accountId(), request.inboundDetailId()); LoadingPlanItemEntity existing = palletId == null
? repository.activeItem(planId, candidate.accountId(), request.inboundDetailId())
: repository.activePalletItem(planId, palletId, request.inboundDetailId());
int total = request.packageCount() + (existing == null ? 0 : existing.getPackageCount()); int total = request.packageCount() + (existing == null ? 0 : existing.getPackageCount());
int availableIncludingCurrent = candidate.availablePackageCount() int availableIncludingCurrent = candidate.availablePackageCount()
+ (existing == null ? 0 : existing.getPackageCount()); + (existing == null ? 0 : existing.getPackageCount());
@@ -457,6 +518,14 @@ public class SmartLoadingService {
LoadingPlanItemEntity changed; LoadingPlanItemEntity changed;
if (existing == null) { if (existing == null) {
LoadingPlanItemEntity item = snapshot(planId, candidate, cargo, request.packageCount(), operatorId, operatorName, lines.size() + 1); LoadingPlanItemEntity item = snapshot(planId, candidate, cargo, request.packageCount(), operatorId, operatorName, lines.size() + 1);
if (palletId != null) {
item.setUnitType("SEALED_PALLET");
item.setPalletId(palletId);
item.setPalletNo(palletNo);
item.setIndivisible(true);
item.setPalletCount(primaryPalletLine ? 1 : 0);
item.setProductQuantity(actualSkuQuantity);
}
try { repository.insert(item); } catch (DuplicateKeyException e) { throw conflict("货物明细已被并发锁定"); } try { repository.insert(item); } catch (DuplicateKeyException e) { throw conflict("货物明细已被并发锁定"); }
changed = item; changed = item;
} else { } else {
@@ -617,7 +686,7 @@ public class SmartLoadingService {
.substring(0, 48); .substring(0, 48);
assign(planId, new AssignRequest(request.inboundId(), allocation.candidate().inboundDetailId(), assign(planId, new AssignRequest(request.inboundId(), allocation.candidate().inboundDetailId(),
allocation.packageCount(), current.getVersion(), childOperation), operatorId, operatorName, allocation.packageCount(), current.getVersion(), childOperation), operatorId, operatorName,
wholeGroupAssignment); wholeGroupAssignment, null, null, null, false, null);
sequence++; sequence++;
} }
LoadingPlanEntity assigned = repository.plan(planId); LoadingPlanEntity assigned = repository.plan(planId);
@@ -859,6 +928,9 @@ public class SmartLoadingService {
LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion(), operatorId, operatorName); LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion(), operatorId, operatorName);
LoadingPlanItemEntity item = repository.item(planId, itemId); LoadingPlanItemEntity item = repository.item(planId, itemId);
if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在"); if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在");
if (Boolean.TRUE.equals(item.getIndivisible()) || item.getPalletId() != null) {
throw conflict("已封托托盘不可调整箱数,只能整托移除");
}
inboundHoldGuard.requireInboundNotHeld(item.getAccountId(), item.getInboundId()); inboundHoldGuard.requireInboundNotHeld(item.getAccountId(), item.getInboundId());
requireStandalone(item.getAccountId(), item.getInboundId()); requireStandalone(item.getAccountId(), item.getInboundId());
if (!Objects.equals(item.getVersion(), request.expectedItemVersion())) throw conflict("装柜明细版本冲突"); if (!Objects.equals(item.getVersion(), request.expectedItemVersion())) throw conflict("装柜明细版本冲突");
@@ -971,6 +1043,9 @@ public class SmartLoadingService {
LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion(), operatorId, operatorName); LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion(), operatorId, operatorName);
LoadingPlanItemEntity item = repository.item(planId, itemId); LoadingPlanItemEntity item = repository.item(planId, itemId);
if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在"); if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在");
if (Boolean.TRUE.equals(item.getIndivisible()) || item.getPalletId() != null) {
throw conflict("已封托托盘不可删除单条明细,只能整托移除");
}
requireStandalone(item.getAccountId(), item.getInboundId()); requireStandalone(item.getAccountId(), item.getInboundId());
loadingPickService.remove(itemId); loadingPickService.remove(itemId);
touch(plan, operatorId, operatorName); touch(plan, operatorId, operatorName);
@@ -982,6 +1057,34 @@ public class SmartLoadingService {
return view(repository.plan(planId), requireBooking(plan.getContainerBookingId())); return view(repository.plan(planId), requireBooking(plan.getContainerBookingId()));
} }
@Transactional
@BusinessAudit(event = "SMART_LOADING_PALLET_REMOVE", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "整托移除一件代发装柜明细")
public Plan removePallet(long planId,long palletId,RemovePalletRequest request,long operatorId,String operatorName){
String operation=operation(request.operationId());
String requestHash=sha256("REMOVE_PALLET|"+planId+'|'+palletId+'|'+request.expectedPlanVersion());
Plan replay=replay(operation,planId,"REMOVE_PALLET",null,requestHash); if(replay!=null)return replay;
LoadingPlanEntity plan=editing(planId,request.expectedPlanVersion(),operatorId,operatorName);
List<LoadingPlanItemEntity> palletItems=repository.activeItemsByPallet(palletId).stream()
.filter(item->Objects.equals(item.getLoadingPlanId(),planId)).toList();
if(palletItems.isEmpty())throw new BizException(ResultCode.NOT_FOUND,"装柜计划中不存在该托盘");
LoadingPickService.MutationSnapshot picks=loadingPickService.mutationSnapshot(planId);
if(palletItems.stream().anyMatch(item->picks.pickedByPlanItem().getOrDefault(item.getId(),0)>0))
throw conflict("托盘已发生实物拣货,不能移除");
for(LoadingPlanItemEntity item:palletItems)loadingPickService.remove(item.getId());
touch(plan,operatorId,operatorName);
for(LoadingPlanItemEntity item:palletItems){
if(repository.remove(planId,item.getId(),item.getVersion(),operatorId,operatorName)!=1)
throw conflict("整托装柜明细已被并发修改");
}
LoadingPlanItemEntity first=palletItems.get(0);
loadingModelBridge.removeIfUnallocated(planId,first.getAccountId(),first.getInboundId());
history(planId,null,"REMOVE_PALLET",plan.getPlanStatus(),plan.getPlanStatus(),operation,
json(values("palletId",palletId,"version",request.expectedPlanVersion())),
json(values("requestHash",requestHash,"removedLineCount",palletItems.size())),operatorId,operatorName);
return view(repository.plan(planId),requireBooking(plan.getContainerBookingId()));
}
@Transactional @Transactional
@BusinessAudit(event = "SMART_LOADING_UNPICKED_REMOVE", resourceType = "LOADING_PLAN", @BusinessAudit(event = "SMART_LOADING_UNPICKED_REMOVE", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "批量移除智能装柜未拣明细") resourceId = "#planId", description = "批量移除智能装柜未拣明细")
@@ -1461,7 +1564,40 @@ public class SmartLoadingService {
: "COMPLETED".equals(task.taskStatus()) ? "COMPLETED" : "IN_PROGRESS"; : "COMPLETED".equals(task.taskStatus()) ? "COMPLETED" : "IN_PROGRESS";
return new Plan(p.getId(),p.getPlanNo(),p.getPlanStatus(),p.getContainerBookingId(), return new Plan(p.getId(),p.getPlanNo(),p.getPlanStatus(),p.getContainerBookingId(),
b.getContainerNo(),b.getVoyageNo(),p.getVersion(),mutable,mutable,blockedReason,finalStatus, b.getContainerNo(),b.getVoyageNo(),p.getVersion(),mutable,mutable,blockedReason,finalStatus,
task.taskId(),task.taskStatus(),task.taskVersion(),planCoLoadGroups(p.getId()),items); task.taskId(),task.taskStatus(),task.taskVersion(),planCoLoadGroups(p.getId()),collapsePalletUnits(items));
}
private List<LoadingPlanItemEntity> collapsePalletUnits(List<LoadingPlanItemEntity> items) {
List<LoadingPlanItemEntity> result = new ArrayList<>();
Map<Long,List<LoadingPlanItemEntity>> pallets = items.stream().filter(i -> i.getPalletId() != null)
.collect(java.util.stream.Collectors.groupingBy(LoadingPlanItemEntity::getPalletId,
LinkedHashMap::new, java.util.stream.Collectors.toList()));
items.stream().filter(i -> i.getPalletId() == null).forEach(result::add);
for (List<LoadingPlanItemEntity> lines : pallets.values()) {
LoadingPlanItemEntity unit = lines.get(0);
unit.setPackageCount(lines.stream().map(LoadingPlanItemEntity::getPackageCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum());
unit.setProductQuantity(lines.stream().map(LoadingPlanItemEntity::getProductQuantity)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum());
unit.setPalletCount(1);
unit.setGrossWeightKg(sumDecimal(lines, LoadingPlanItemEntity::getGrossWeightKg));
unit.setNetWeightKg(sumDecimal(lines, LoadingPlanItemEntity::getNetWeightKg));
unit.setVolumeCbm(sumDecimal(lines, LoadingPlanItemEntity::getVolumeCbm));
unit.setPickedPackageCount(lines.stream().map(LoadingPlanItemEntity::getPickedPackageCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum());
unit.setTotalPackageCount(unit.getPackageCount());
unit.setRemainingPackageCount(0);
unit.setCanAdjust(false);
unit.setCanRemove(lines.stream().allMatch(i -> Boolean.TRUE.equals(i.getCanRemove())));
result.add(unit);
}
return result.stream().sorted(Comparator
.comparing(LoadingPlanItemEntity::getLoadSequence, Comparator.nullsLast(Integer::compareTo))
.thenComparing(LoadingPlanItemEntity::getId, Comparator.nullsLast(Long::compareTo)))
.toList();
}
private static BigDecimal sumDecimal(List<LoadingPlanItemEntity> lines,
java.util.function.Function<LoadingPlanItemEntity,BigDecimal> getter) {
return lines.stream().map(getter).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add);
} }
private int adjustmentTotal(LoadingPlanItemEntity item) { private int adjustmentTotal(LoadingPlanItemEntity item) {
int current = item.getPackageCount() == null ? 0 : item.getPackageCount(); int current = item.getPackageCount() == null ? 0 : item.getPackageCount();
@@ -11,6 +11,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
cargo_item_no INT UNSIGNED NOT NULL COMMENT '订单货物明细序号快照', cargo_item_no INT UNSIGNED NOT NULL COMMENT '订单货物明细序号快照',
cargo_name VARCHAR(255) NULL COMMENT '货物名称快照', cargo_name VARCHAR(255) NULL COMMENT '货物名称快照',
sku_code VARCHAR(128) NULL COMMENT '箱唛绑定的SKU快照;二维码内容不变', sku_code VARCHAR(128) NULL COMMENT '箱唛绑定的SKU快照;二维码内容不变',
units_per_carton INT UNSIGNED NULL COMMENT '每箱SKU数量快照;一件代发必须为正数',
detail_package_count INT UNSIGNED NOT NULL COMMENT '该订单货物明细箱数快照', detail_package_count INT UNSIGNED NOT NULL COMMENT '该订单货物明细箱数快照',
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照', inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标', box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标',
@@ -209,6 +209,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
cargo_item_no INT UNSIGNED NOT NULL COMMENT '订单货物明细序号快照', cargo_item_no INT UNSIGNED NOT NULL COMMENT '订单货物明细序号快照',
cargo_name VARCHAR(255) NULL COMMENT '货物名称快照', cargo_name VARCHAR(255) NULL COMMENT '货物名称快照',
sku_code VARCHAR(128) NULL COMMENT '箱唛绑定的SKU快照;二维码内容不变', sku_code VARCHAR(128) NULL COMMENT '箱唛绑定的SKU快照;二维码内容不变',
units_per_carton INT UNSIGNED NULL COMMENT '每箱SKU数量快照;一件代发必须为正数',
detail_package_count INT UNSIGNED NOT NULL COMMENT '该订单货物明细箱数快照', detail_package_count INT UNSIGNED NOT NULL COMMENT '该订单货物明细箱数快照',
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照', inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标', box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标',
@@ -920,7 +921,9 @@ CREATE TABLE IF NOT EXISTS fl_dropship_pallet_task (
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '作业仓库ID', warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '作业仓库ID',
task_status VARCHAR(24) NOT NULL COMMENT '任务状态:PENDING待领取、CLAIMED已领取、COMPLETED已完成', task_status VARCHAR(24) NOT NULL COMMENT '任务状态:PENDING待领取、CLAIMED已领取、COMPLETED已完成',
total_cartons INT UNSIGNED NOT NULL COMMENT '测量确认总箱数', total_cartons INT UNSIGNED NOT NULL COMMENT '测量确认总箱数',
counted_cartons INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已逐箱确认实收数量的箱数',
allocated_cartons INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已上托箱数', allocated_cartons INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已上托箱数',
discrepancy_cartons INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已确认差异箱数',
assignee_id BIGINT UNSIGNED NULL COMMENT '任务领取人ID', assignee_id BIGINT UNSIGNED NULL COMMENT '任务领取人ID',
assignee_name VARCHAR(64) NULL COMMENT '任务领取人姓名快照', assignee_name VARCHAR(64) NULL COMMENT '任务领取人姓名快照',
claimed_at DATETIME(3) NULL COMMENT '任务领取时间', claimed_at DATETIME(3) NULL COMMENT '任务领取时间',
@@ -933,7 +936,7 @@ CREATE TABLE IF NOT EXISTS fl_dropship_pallet_task (
KEY idx_dropship_task_operator (task_status,assignee_id,warehouse_id,created_at), KEY idx_dropship_task_operator (task_status,assignee_id,warehouse_id,created_at),
CONSTRAINT fk_dropship_task_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order(id), CONSTRAINT fk_dropship_task_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order(id),
CONSTRAINT chk_dropship_task_status CHECK (task_status IN ('PENDING','CLAIMED','COMPLETED')), CONSTRAINT chk_dropship_task_status CHECK (task_status IN ('PENDING','CLAIMED','COMPLETED')),
CONSTRAINT chk_dropship_task_cartons CHECK (allocated_cartons<=total_cartons) CONSTRAINT chk_dropship_task_cartons CHECK (allocated_cartons<=counted_cartons AND counted_cartons<=total_cartons)
) ENGINE=InnoDB COMMENT='一件代发测量后PDA合托任务'; ) ENGINE=InnoDB COMMENT='一件代发测量后PDA合托任务';
CREATE TABLE IF NOT EXISTS fl_dropship_pallet ( CREATE TABLE IF NOT EXISTS fl_dropship_pallet (
@@ -942,8 +945,8 @@ CREATE TABLE IF NOT EXISTS fl_dropship_pallet (
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '一件代发入仓单ID', inbound_id BIGINT UNSIGNED NOT NULL COMMENT '一件代发入仓单ID',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '作业仓库ID', warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '作业仓库ID',
pallet_no VARCHAR(32) NULL COMMENT '封托时生成的DP托盘号', pallet_no VARCHAR(32) NOT NULL COMMENT '现场扫描的系统DP托盘码',
pallet_status VARCHAR(16) NOT NULL COMMENT '托盘状态:OPEN合托中、COMPLETED已封托', pallet_status VARCHAR(16) NOT NULL COMMENT '托盘状态:OPEN组托中、SEALED已封托',
open_operation_id VARCHAR(128) NOT NULL COMMENT '创建托盘幂等操作ID', open_operation_id VARCHAR(128) NOT NULL COMMENT '创建托盘幂等操作ID',
seal_operation_id VARCHAR(128) NULL COMMENT '封托幂等操作ID', seal_operation_id VARCHAR(128) NULL COMMENT '封托幂等操作ID',
created_by BIGINT UNSIGNED NOT NULL COMMENT '托盘创建人ID', created_by BIGINT UNSIGNED NOT NULL COMMENT '托盘创建人ID',
@@ -960,29 +963,39 @@ CREATE TABLE IF NOT EXISTS fl_dropship_pallet (
UNIQUE KEY uk_dropship_pallet_seal_op (seal_operation_id), UNIQUE KEY uk_dropship_pallet_seal_op (seal_operation_id),
KEY idx_dropship_pallet_inbound (tenant_id,inbound_id,pallet_status), KEY idx_dropship_pallet_inbound (tenant_id,inbound_id,pallet_status),
CONSTRAINT fk_dropship_pallet_task FOREIGN KEY (task_id) REFERENCES fl_dropship_pallet_task(id), CONSTRAINT fk_dropship_pallet_task FOREIGN KEY (task_id) REFERENCES fl_dropship_pallet_task(id),
CONSTRAINT chk_dropship_pallet_status CHECK (pallet_status IN ('OPEN','COMPLETED')) CONSTRAINT chk_dropship_pallet_status CHECK (pallet_status IN ('OPEN','SEALED'))
) ENGINE=InnoDB COMMENT='一件代发合托形成的可打印托盘'; ) ENGINE=InnoDB COMMENT='一件代发合托形成的可打印托盘';
CREATE TABLE IF NOT EXISTS fl_dropship_pallet_carton ( CREATE TABLE IF NOT EXISTS fl_dropship_pallet_carton (
id BIGINT UNSIGNED NOT NULL COMMENT '托盘箱明细ID', id BIGINT UNSIGNED NOT NULL COMMENT '托盘箱明细ID',
task_id BIGINT UNSIGNED NOT NULL COMMENT '所属合托任务ID', task_id BIGINT UNSIGNED NOT NULL COMMENT '所属合托任务ID',
pallet_id BIGINT UNSIGNED NOT NULL COMMENT '所属合托托盘ID', pallet_id BIGINT UNSIGNED NULL COMMENT '所属组托托盘ID;已清点待绑托时为空',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '一件代发入仓单ID', inbound_id BIGINT UNSIGNED NOT NULL COMMENT '一件代发入仓单ID',
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '入仓货物明细ID', inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '入仓货物明细ID',
carton_label_id BIGINT UNSIGNED NOT NULL COMMENT '稳定箱唛记录ID', carton_label_id BIGINT UNSIGNED NOT NULL COMMENT '稳定箱唛记录ID',
sku_code VARCHAR(128) NOT NULL COMMENT '箱唛冻结SKU编码', sku_code VARCHAR(128) NOT NULL COMMENT '箱唛冻结SKU编码',
cargo_name VARCHAR(255) NULL COMMENT '货物名称快照', cargo_name VARCHAR(255) NULL COMMENT '货物名称快照',
box_code VARCHAR(64) NOT NULL COMMENT '稳定箱码', box_code VARCHAR(80) NOT NULL COMMENT '稳定箱码',
operation_id VARCHAR(128) NOT NULL COMMENT '上托幂等操作ID', expected_sku_quantity INT UNSIGNED NOT NULL COMMENT '箱唛冻结的应收SKU数量',
bound_by BIGINT UNSIGNED NOT NULL COMMENT '上托操作人ID', actual_sku_quantity INT UNSIGNED NOT NULL COMMENT 'PDA确认的实收SKU数量',
discrepancy_status VARCHAR(24) NOT NULL COMMENT '差异状态:MATCH或ACCEPTED_VARIANCE',
discrepancy_remark VARCHAR(500) NULL COMMENT '差异接受说明',
count_operation_id VARCHAR(128) NOT NULL COMMENT '逐箱清点幂等操作ID',
bind_operation_id VARCHAR(128) NULL COMMENT '批量绑托幂等操作ID',
last_operation_id VARCHAR(128) NOT NULL COMMENT '最后一次绑托或换托操作幂等号',
counted_by BIGINT UNSIGNED NOT NULL COMMENT '清点操作人ID',
counted_by_name VARCHAR(64) NULL COMMENT '清点操作人姓名快照',
counted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '清点时间',
bound_by BIGINT UNSIGNED NULL COMMENT '上托操作人ID',
bound_by_name VARCHAR(64) NULL COMMENT '上托操作人姓名快照', bound_by_name VARCHAR(64) NULL COMMENT '上托操作人姓名快照',
bound_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上托时间', bound_at DATETIME(3) NULL COMMENT '上托时间',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_dropship_carton_task_label (task_id,carton_label_id), UNIQUE KEY uk_dropship_carton_task_label (task_id,carton_label_id),
KEY idx_dropship_carton_pallet_sku (pallet_id,sku_code,id), KEY idx_dropship_carton_pallet_sku (pallet_id,sku_code,id),
KEY idx_dropship_carton_inbound (tenant_id,inbound_id,pallet_id), KEY idx_dropship_carton_inbound (tenant_id,inbound_id,pallet_id),
KEY idx_dropship_carton_operation (operation_id), UNIQUE KEY uk_dropship_carton_count_operation (count_operation_id),
KEY idx_dropship_carton_bind_operation (task_id,bind_operation_id),
CONSTRAINT fk_dropship_carton_task FOREIGN KEY (task_id) REFERENCES fl_dropship_pallet_task(id), CONSTRAINT fk_dropship_carton_task FOREIGN KEY (task_id) REFERENCES fl_dropship_pallet_task(id),
CONSTRAINT fk_dropship_carton_pallet FOREIGN KEY (pallet_id) REFERENCES fl_dropship_pallet(id), CONSTRAINT fk_dropship_carton_pallet FOREIGN KEY (pallet_id) REFERENCES fl_dropship_pallet(id),
CONSTRAINT fk_dropship_carton_label FOREIGN KEY (carton_label_id) REFERENCES fl_inbound_carton_label(id) CONSTRAINT fk_dropship_carton_label FOREIGN KEY (carton_label_id) REFERENCES fl_inbound_carton_label(id)
@@ -267,6 +267,10 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item (
product_image_url VARCHAR(1000) NULL COMMENT '产品图片访问地址快照', product_image_url VARCHAR(1000) NULL COMMENT '产品图片访问地址快照',
package_count INT UNSIGNED NOT NULL COMMENT '本次装柜箱数', package_count INT UNSIGNED NOT NULL COMMENT '本次装柜箱数',
product_quantity INT UNSIGNED NULL COMMENT '产品总数量', product_quantity INT UNSIGNED NULL COMMENT '产品总数量',
unit_type VARCHAR(24) NOT NULL DEFAULT 'PACKAGE' COMMENT '分配单元:PACKAGE普通箱、SEALED_PALLET一件代发封托',
pallet_id BIGINT UNSIGNED NULL COMMENT '一件代发封托ID;普通箱为空',
pallet_no VARCHAR(32) NULL COMMENT '一件代发系统托盘码快照',
indivisible TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否为不可拆整托单元',
units_per_carton INT UNSIGNED NULL COMMENT '每箱产品数', units_per_carton INT UNSIGNED NULL COMMENT '每箱产品数',
pallet_count INT UNSIGNED NULL COMMENT '托盘数', pallet_count INT UNSIGNED NULL COMMENT '托盘数',
gross_weight_kg DECIMAL(18,3) NULL COMMENT '本次装柜毛重kg', gross_weight_kg DECIMAL(18,3) NULL COMMENT '本次装柜毛重kg',
@@ -287,7 +291,8 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item (
removed_by_name VARCHAR(64) NULL COMMENT '推送前逻辑删除操作人名称快照', removed_by_name VARCHAR(64) NULL COMMENT '推送前逻辑删除操作人名称快照',
removed_at DATETIME(3) NULL COMMENT '推送前逻辑删除时间', removed_at DATETIME(3) NULL COMMENT '推送前逻辑删除时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '推送前逻辑删除标记;冻结后不可修改', deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '推送前逻辑删除标记;冻结后不可修改',
active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END) STORED COMMENT '未删除明细唯一锁;删除后返回NULL释放分配资格', active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END) STORED COMMENT '未删除明细查询键;删除后返回NULL',
active_assignment_key VARCHAR(96) GENERATED ALWAYS AS (CASE WHEN deleted = 0 THEN CONCAT(CASE WHEN pallet_id IS NULL THEN 'D:0' ELSE CONCAT('P:', pallet_id) END, ':', inbound_detail_id) ELSE NULL END) STORED COMMENT '普通明细或已封托托盘明细的有效唯一锁',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照', created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
@@ -296,12 +301,13 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item (
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '装柜明细最后更新时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '装柜明细最后更新时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_loading_item_plan_detail (loading_plan_id, active_inbound_detail_id), UNIQUE KEY uk_loading_item_plan_assignment (loading_plan_id, active_assignment_key),
KEY idx_loading_item_active_detail (account_id, active_inbound_detail_id), KEY idx_loading_item_active_detail (account_id, active_inbound_detail_id),
KEY idx_loading_item_plan (loading_plan_id, deleted, load_sequence, id), KEY idx_loading_item_plan (loading_plan_id, deleted, load_sequence, id),
KEY idx_loading_item_account (account_id, created_at, id), KEY idx_loading_item_account (account_id, created_at, id),
KEY idx_loading_item_inbound (account_id, inbound_id, deleted), KEY idx_loading_item_inbound (account_id, inbound_id, deleted),
KEY idx_loading_item_candidate (account_id, inbound_id, inbound_detail_id, deleted), KEY idx_loading_item_candidate (account_id, inbound_id, inbound_detail_id, deleted),
KEY idx_loading_item_pallet (pallet_id, deleted),
KEY idx_loading_item_order (account_id, order_id, deleted), KEY idx_loading_item_order (account_id, order_id, deleted),
CONSTRAINT fk_loading_item_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id), CONSTRAINT fk_loading_item_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id),
CONSTRAINT chk_loading_item_account CHECK (account_id > 0), CONSTRAINT chk_loading_item_account CHECK (account_id > 0),
@@ -669,66 +675,6 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_history (
CONSTRAINT fk_loading_pick_history_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id) CONSTRAINT fk_loading_pick_history_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id)
) ENGINE=InnoDB COMMENT='PDA拣货幂等和操作审计历史'; ) ENGINE=InnoDB COMMENT='PDA拣货幂等和操作审计历史';
CREATE TABLE IF NOT EXISTS fl_loading_work_pallet (
id BIGINT UNSIGNED NOT NULL COMMENT '一件代发现场作业托盘ID',
pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA装柜拣货任务ID',
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID快照',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '作业仓库ID',
warehouse_pallet_id BIGINT UNSIGNED NULL COMMENT '仓库实物托盘ID;未绑定仓库资源时为空',
pallet_code VARCHAR(64) NOT NULL COMMENT '现场托盘号',
open_operation_id VARCHAR(128) NOT NULL COMMENT '开托幂等操作号',
seal_operation_id VARCHAR(128) NULL COMMENT '封托幂等操作号',
pallet_status VARCHAR(16) NOT NULL DEFAULT 'OPEN' COMMENT '托盘状态:OPEN打托中、SEALED已封托、CANCELLED已取消',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建操作人名称快照',
sealed_by BIGINT UNSIGNED NULL COMMENT '封托操作人ID',
sealed_by_name VARCHAR(64) NULL COMMENT '封托操作人名称快照',
sealed_at DATETIME(3) NULL COMMENT '封托时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_loading_work_pallet_code (pick_task_id, pallet_code),
UNIQUE KEY uk_loading_work_pallet_open_operation (open_operation_id),
UNIQUE KEY uk_loading_work_pallet_seal_operation (seal_operation_id),
KEY idx_loading_work_pallet_plan (loading_plan_id, pallet_status, id),
CONSTRAINT fk_loading_work_pallet_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id),
CONSTRAINT fk_loading_work_pallet_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id),
CONSTRAINT chk_loading_work_pallet_status CHECK (pallet_status IN ('OPEN','SEALED','CANCELLED'))
) ENGINE=InnoDB COMMENT='一件代发PDA现场实际托盘;允许同托盘承载多个客户订单';
CREATE TABLE IF NOT EXISTS fl_loading_work_pallet_carton (
id BIGINT UNSIGNED NOT NULL COMMENT '一件代发现场托盘箱唛绑定ID',
pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA装柜拣货任务ID',
work_pallet_id BIGINT UNSIGNED NOT NULL COMMENT '现场作业托盘ID',
loading_plan_item_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划货物明细ID',
pick_item_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '箱唛所属客户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '来源入仓单ID',
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '来源入仓货物明细ID',
carton_label_id BIGINT UNSIGNED NOT NULL COMMENT '稳定箱唛记录ID',
box_code VARCHAR(80) NOT NULL COMMENT '稳定箱唛箱码快照',
sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照',
units_per_carton INT UNSIGNED NULL COMMENT '每箱产品数量快照',
bound_by BIGINT UNSIGNED NOT NULL COMMENT '最后绑定操作人ID',
bound_by_name VARCHAR(64) NULL COMMENT '最后绑定操作人名称快照',
operation_id VARCHAR(128) NOT NULL COMMENT '最近一次绑定或移托幂等操作号',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次上托时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后移托时间',
PRIMARY KEY (id),
UNIQUE KEY uk_loading_work_carton_task_label (pick_task_id, carton_label_id),
UNIQUE KEY uk_loading_work_carton_operation (operation_id),
KEY idx_loading_work_carton_pallet (work_pallet_id, sku_code, id),
KEY idx_loading_work_carton_plan_item (loading_plan_item_id, id),
CONSTRAINT fk_loading_work_carton_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id),
CONSTRAINT fk_loading_work_carton_pallet FOREIGN KEY (work_pallet_id) REFERENCES fl_loading_work_pallet (id),
CONSTRAINT fk_loading_work_carton_plan_item FOREIGN KEY (loading_plan_item_id) REFERENCES fl_loading_plan_item (id),
CONSTRAINT fk_loading_work_carton_pick_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id),
CONSTRAINT fk_loading_work_carton_label FOREIGN KEY (carton_label_id) REFERENCES fl_inbound_carton_label (id),
CONSTRAINT chk_loading_work_carton_units CHECK (units_per_carton IS NULL OR units_per_carton > 0)
) ENGINE=InnoDB COMMENT='一件代发PDA逐箱上托事实;客户订单不合并但实际托盘可混托';
DROP PROCEDURE IF EXISTS repair_phase3_loading_pick_task_foreign_keys; DROP PROCEDURE IF EXISTS repair_phase3_loading_pick_task_foreign_keys;
DELIMITER $$ DELIMITER $$
CREATE PROCEDURE repair_phase3_loading_pick_task_foreign_keys() CREATE PROCEDURE repair_phase3_loading_pick_task_foreign_keys()
@@ -1478,12 +1424,13 @@ BEGIN
'destination_warehouse_name', 'product_name_zh', 'product_name_en', 'destination_warehouse_name', 'product_name_zh', 'product_name_en',
'product_image_file_id', 'pallet_count', 'net_weight_kg', 'product_image_file_id', 'pallet_count', 'net_weight_kg',
'dimensional_weight_kg', 'product_quantity', 'units_per_carton', 'dimensional_weight_kg', 'product_quantity', 'units_per_carton',
'fba_tracking_no', 'shipment_no', 'shipping_mark', 'version')) <> 17 THEN 'unit_type', 'pallet_id', 'pallet_no', 'indivisible', 'active_assignment_key',
'fba_tracking_no', 'shipment_no', 'shipping_mark', 'version')) <> 22 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:明细归属、清单快照或冻结字段缺失'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:明细归属、清单快照或冻结字段缺失';
END IF; END IF;
IF (SELECT COUNT(*) FROM information_schema.statistics IF (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_plan_item' WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_plan_item'
AND index_name = 'uk_loading_item_plan_detail' AND non_unique = 0) <> 2 THEN AND index_name = 'uk_loading_item_plan_assignment' AND non_unique = 0) <> 2 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:计划内唯一活动明细锁缺失'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:计划内唯一活动明细锁缺失';
END IF; END IF;
IF (SELECT COUNT(*) FROM information_schema.columns IF (SELECT COUNT(*) FROM information_schema.columns
@@ -1,54 +1,153 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: 一件代发 PDA 合托 API title: 一件代发国内清点组托 API
version: 1.0.0 version: 2.0.0
description: 测量完成后独立下发;不创建国内库存。PDA 扫箱唛识别 SKU,并按箱数合托。 description: 测量、计费后独立下发。箱码逐箱确认 SKU 实收数量;托盘码可先扫或后扫,封托后作为不可拆分柜单元。
paths: paths:
/api/platform/v1/first-leg/pda-dropship/tasks/pending: /api/platform/v1/first-leg/pda-dropship/tasks/pending:
get: get:
summary: 查询待领取或本人已领取的一件代发合托任务 summary: 查询待领取或本人已领取任务
responses: { '200': { description: OK } } x-required-authority: platform:firstleg:pda:view
responses: {'200': {description: OK}}
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}: /api/platform/v1/first-leg/pda-dropship/tasks/{taskId}:
get: get:
summary: 查询任务、托盘、入仓单、SKU 和箱数明细 summary: 查询任务、已清点待绑箱及托盘结果
x-required-authority: platform:firstleg:pda:view
parameters: [{$ref: '#/components/parameters/TaskId'}, {$ref: '#/components/parameters/WarehouseId'}]
responses: {'200': {description: OK}}
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/cartons/resolve:
get:
summary: 扫箱码解析入仓码、SKU 和应收数量,不返回全部未清点箱
x-required-authority: platform:firstleg:pda:view
parameters: parameters:
- { name: taskId, in: path, required: true, schema: { type: integer, format: int64 } } - {$ref: '#/components/parameters/TaskId'}
- { name: warehouseId, in: query, required: true, schema: { type: integer, format: int64 } } - {$ref: '#/components/parameters/WarehouseId'}
responses: { '200': { description: OK } } - {name: boxCode, in: query, required: true, schema: {type: string, maxLength: 80}}
responses: {'200': {description: OK}}
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/claim: /api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/claim:
post: post:
summary: 领取合托任务 summary: 领取任务
parameters: [{ name: taskId, in: path, required: true, schema: { type: integer, format: int64 } }] x-required-authority: platform:firstleg:pda:work
responses: { '200': { description: OK } } parameters: [{$ref: '#/components/parameters/TaskId'}]
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/open: requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ClaimRequest'}}}}
responses: {'200': {description: OK}}
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/cartons/count:
post: post:
summary: 开启一个现场托盘 summary: 逐箱确认 SKU 实收数量,可同时绑定当前托盘
parameters: [{ name: taskId, in: path, required: true, schema: { type: integer, format: int64 } }] description: palletNo 为空时进入 COUNTED_UNBOUND;不为空时幂等获取或创建 OPEN 托盘并绑定。数量不一致必须 ACCEPT_VARIANCE 且填写说明。
responses: { '200': { description: OK } } x-required-authority: platform:firstleg:pda:work
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/{palletId}/scan: parameters: [{$ref: '#/components/parameters/TaskId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CountCartonRequest'}}}}
responses: {'200': {description: OK}}
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/bind-pending:
post: post:
summary: 扫箱唛识别 SKU 并填写上托箱数 summary: 扫托盘码并把当前任务全部待绑箱绑定到该托盘
x-required-authority: platform:firstleg:pda:work
parameters: [{$ref: '#/components/parameters/TaskId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/BindPendingRequest'}}}}
responses: {'200': {description: OK}}
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/cartons/{cartonId}/move:
post:
summary: 换托,targetPalletNo 为空时退回待绑区
x-required-authority: platform:firstleg:pda:work
parameters: parameters:
- { name: taskId, in: path, required: true, schema: { type: integer, format: int64 } } - {$ref: '#/components/parameters/TaskId'}
- { name: palletId, in: path, required: true, schema: { type: integer, format: int64 } } - {name: cartonId, in: path, required: true, schema: {type: integer, format: int64}}
responses: { '200': { description: OK } } requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/MoveCartonRequest'}}}}
responses: {'200': {description: OK}}
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/{palletId}/seal: /api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/{palletId}/seal:
post: post:
summary: 完成合托并生成 DP 托盘号 summary: 封托;禁止空托、待绑箱、版本冲突和重复归托
x-required-authority: platform:firstleg:pda:work
parameters: parameters:
- { name: taskId, in: path, required: true, schema: { type: integer, format: int64 } } - {$ref: '#/components/parameters/TaskId'}
- { name: palletId, in: path, required: true, schema: { type: integer, format: int64 } } - {name: palletId, in: path, required: true, schema: {type: integer, format: int64}}
responses: { '200': { description: OK } } requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/SealPalletRequest'}}}}
responses: {'200': {description: OK}}
/api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets: /api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets:
get: get:
summary: 入仓单查看绑定的一个或多个托盘及各自箱数 summary: Admin 查询入仓单权威托盘结果
parameters: [{ name: inboundId, in: path, required: true, schema: { type: integer, format: int64 } }] x-required-authority: firstleg:inbound:list
responses: { '200': { description: OK } } responses: {'200': {description: OK}}
/api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets/{palletId}/print: /api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets/{palletId}/print:
get: get:
summary: 打印托盘号、入仓码和 SKU 箱数托盘唛 summary: 打印托盘码、入仓码和 SKU 实收汇总
parameters: x-required-authority: firstleg:inbound:list
- { name: inboundId, in: path, required: true, schema: { type: integer, format: int64 } } responses: {'200': {description: PDF}}
- { name: palletId, in: path, required: true, schema: { type: integer, format: int64 } } components:
responses: parameters:
'200': { description: PDF, content: { application/pdf: { schema: { type: string, format: binary } } } } TaskId: {name: taskId, in: path, required: true, schema: {type: integer, format: int64}}
WarehouseId: {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64}}
schemas:
Carton:
type: object
properties:
cartonId: {type: integer, format: int64, nullable: true}
boxCode: {type: string}
inboundId: {type: integer, format: int64}
inboundCode: {type: string}
inboundDetailId: {type: integer, format: int64}
skuCode: {type: string}
cargoName: {type: string, nullable: true}
expectedSkuQuantity: {type: integer, minimum: 1}
actualSkuQuantity: {type: integer, minimum: 1, nullable: true}
discrepancyStatus: {type: string, enum: [MATCH, ACCEPTED_VARIANCE], nullable: true}
discrepancyRemark: {type: string, nullable: true}
countStatus: {type: string, enum: [UNCOUNTED, COUNTED_UNBOUND, BOUND]}
palletId: {type: integer, format: int64, nullable: true}
palletNo: {$ref: '#/components/schemas/NullablePalletNo'}
canCount: {type: boolean}
blockReason: {type: string, nullable: true}
Pallet:
type: object
properties:
palletId: {type: integer, format: int64}
palletNo: {$ref: '#/components/schemas/PalletNo'}
status: {type: string, enum: [OPEN, SEALED]}
cartonCount: {type: integer, minimum: 0}
skuCount: {type: integer, minimum: 0}
discrepancyCartons: {type: integer, minimum: 0}
weightKg: {type: number, minimum: 0}
volumeCbm: {type: number, minimum: 0}
version: {type: integer, minimum: 1}
items: {type: array, items: {type: object}}
cartons: {type: array, items: {$ref: '#/components/schemas/Carton'}, description: 含稳定 cartonId,换托必须使用该 ID}
sealedAt: {type: string, format: date-time, nullable: true}
ClaimRequest:
type: object
required: [warehouseId]
properties: {warehouseId: {type: integer, format: int64}}
CountCartonRequest:
type: object
required: [warehouseId, boxCode, actualSkuQuantity, discrepancyAction, operationId]
properties:
warehouseId: {type: integer, format: int64}
boxCode: {type: string, maxLength: 80}
actualSkuQuantity: {type: integer, minimum: 1}
discrepancyAction: {type: string, enum: [MATCH, ACCEPT_VARIANCE]}
discrepancyRemark: {type: string, maxLength: 500, nullable: true}
palletNo: {$ref: '#/components/schemas/NullablePalletNo'}
operationId: {type: string, maxLength: 128}
BindPendingRequest:
type: object
required: [warehouseId, palletNo, operationId]
properties:
warehouseId: {type: integer, format: int64}
palletNo: {$ref: '#/components/schemas/PalletNo'}
operationId: {type: string, maxLength: 128}
MoveCartonRequest:
type: object
required: [warehouseId, operationId]
properties:
warehouseId: {type: integer, format: int64}
targetPalletNo: {$ref: '#/components/schemas/NullablePalletNo'}
operationId: {type: string, maxLength: 128}
SealPalletRequest:
type: object
required: [warehouseId, expectedVersion, operationId]
properties:
warehouseId: {type: integer, format: int64}
expectedVersion: {type: integer, minimum: 1}
operationId: {type: string, maxLength: 128}
PalletNo: {type: string, pattern: '^DP-[0-9]{6}-[0-9]{4}$'}
NullablePalletNo: {type: string, pattern: '^DP-[0-9]{6}-[0-9]{4}$', nullable: true}
@@ -296,7 +296,7 @@ paths:
post: post:
summary: 按统一订单条件分页查询履约摘要(Order 内部) summary: 按统一订单条件分页查询履约摘要(Order 内部)
description: >- description: >-
mainStatus 接受六态产品状态;WAIT_SPLIT 为入库完成后的唯一待分柜状态。 mainStatus 接受七态产品状态;WAIT_PALLETIZE 为一件代发测量计费后的待清点组托状态,WAIT_SPLIT 为待分柜状态。
计费与入库处理中状态在完成前仍归并为 WAIT_MEASURE,不提前开放分柜。 计费与入库处理中状态在完成前仍归并为 WAIT_MEASURE,不提前开放分柜。
security: security:
- internalServiceToken: [] - internalServiceToken: []
@@ -1133,7 +1133,7 @@ paths:
/api/platform/v1/inbounds/{inboundId}/measurements/approve: /api/platform/v1/inbounds/{inboundId}/measurements/approve:
post: post:
summary: 审核通过测量 summary: 审核通过测量
description: 需要 firstleg:inbound:measurement-review 权限。审核通过后立即进入 WAIT_SPLIT,并按该次测量数据尽力生成 CALCULATED 初算费用;价格未覆盖或初算异常只返回非阻断提示,不影响审核结果和待分柜状态。 description: 需要 firstleg:inbound:measurement-review 权限。标准头程审核通过后进入 WAIT_SPLIT;一件代发进入 WAIT_PALLETIZE,完成逐箱清点和封托后再进入 WAIT_SPLIT。审核时按该次测量数据尽力生成 CALCULATED 初算费用;价格未覆盖或初算异常只返回非阻断提示。
parameters: parameters:
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
@@ -1150,7 +1150,7 @@ paths:
expectedVersion: {type: integer, minimum: 0} expectedVersion: {type: integer, minimum: 0}
reason: {type: string, maxLength: 1000} reason: {type: string, maxLength: 1000}
responses: responses:
'200': {description: 成功或业务失败,按响应体 code 判断;审核成功 data.processStatus 固定为 WAIT_SPLIT,initialPricingSucceeded 表示是否同时生成初算费用,初算失败时 feeStatus=UNPRICED 且主流程仍成功} '200': {description: 成功或业务失败,按响应体 code 判断;审核成功后标准头程 data.processStatus=WAIT_SPLIT,一件代发为 WAIT_PALLETIZE;initialPricingSucceeded 表示是否同时生成初算费用,未覆盖时 feeStatus=UNPRICED 且主流程仍成功}
/api/platform/v1/inbounds/{inboundId}/measurements/submit: /api/platform/v1/inbounds/{inboundId}/measurements/submit:
post: post:
summary: 提交测量完成 summary: 提交测量完成
@@ -1164,7 +1164,7 @@ paths:
/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing: /api/merchant/v1/first-leg/orders/{orderId}/inbound-billing:
get: get:
summary: 客户查询已完成入库结果和最新已发布费用 summary: 客户查询已完成入库结果和最新已发布费用
description: 需要 merchant:order:view 权限;客户仅能按登录态客户范围查看。入库流程达到 WAIT_SPLIT 后,费用在 LOADING、LOADED、DELIVERED 仍持续可见;始终要求 measureReviewStatus=APPROVED、feeStatus=SENT 且存在 PUBLISHED 入库运费明细。latestFee.customerConfirmationStatus 表示客户是否已确认当前费用版本。 description: 需要 merchant:order:view 权限;客户仅能按登录态客户范围查看。一件代发在 WAIT_PALLETIZE 清点组托阶段即可查看并确认已发布费用,之后在 WAIT_SPLIT、LOADING、LOADED、DELIVERED 持续可见;始终要求 measureReviewStatus=APPROVED、feeStatus=SENT 且存在 PUBLISHED 入库运费明细。
parameters: parameters:
- {name: orderId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: orderId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
responses: responses:
@@ -1383,8 +1383,8 @@ components:
invalidatedFeeId: {type: integer, format: int64, nullable: true} invalidatedFeeId: {type: integer, format: int64, nullable: true}
UnifiedMainStatus: UnifiedMainStatus:
type: string type: string
enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_SPLIT, LOADING, LOADED, DELIVERED] enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, WAIT_SPLIT, LOADING, LOADED, DELIVERED]
description: 六态产品主状态;WAIT_SPLIT 为入库完成后的唯一待分柜状态 description: 七态产品主状态;WAIT_PALLETIZE 仅表示一件代发待清点组托
UnifiedInboundQuery: UnifiedInboundQuery:
type: object type: object
properties: properties:
@@ -1393,7 +1393,7 @@ components:
orderNo: {type: string, maxLength: 64, nullable: true} orderNo: {type: string, maxLength: 64, nullable: true}
mainStatus: mainStatus:
type: string type: string
enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_SPLIT, LOADING, LOADED, DELIVERED] enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, WAIT_SPLIT, LOADING, LOADED, DELIVERED]
nullable: true nullable: true
description: WAIT_SPLIT 为唯一待分柜筛选值 description: WAIT_SPLIT 为唯一待分柜筛选值
customerId: {type: integer, format: int64, nullable: true} customerId: {type: integer, format: int64, nullable: true}
@@ -1511,6 +1511,7 @@ components:
deliveryWarehouseName: {type: string, nullable: true} deliveryWarehouseName: {type: string, nullable: true}
businessType: {type: string, nullable: true} businessType: {type: string, nullable: true}
fulfillmentType: {type: string, enum: [STANDARD, ONE_CLICK_DROPSHIP], nullable: true} fulfillmentType: {type: string, enum: [STANDARD, ONE_CLICK_DROPSHIP], nullable: true}
lxCode: {type: string, nullable: true, description: 一件代发线路或履约产品编码快照}
channelCode: {type: string, nullable: true} channelCode: {type: string, nullable: true}
channelName: {type: string, nullable: true} channelName: {type: string, nullable: true}
taxMode: {type: string, nullable: true} taxMode: {type: string, nullable: true}
@@ -170,104 +170,11 @@ paths:
expectedVersion: {type: integer, minimum: 0} expectedVersion: {type: integer, minimum: 0}
reason: {type: string, minLength: 1, maxLength: 500} reason: {type: string, minLength: 1, maxLength: 500}
responses: {'200': {description: 作废后柜级证据列表}} responses: {'200': {description: 作废后柜级证据列表}}
/api/platform/v1/first-leg/pda-loading/tasks/{taskId}/pallets:
get:
summary: 查询一件代发现场实际托盘、SKU汇总和逐箱明细
parameters:
- {$ref: '#/components/parameters/TaskId'}
- {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}}
responses:
'200':
description: 当前任务未取消的现场托盘
content: {application/json: {schema: {type: array, items: {$ref: '#/components/schemas/WorkPallet'}}}}
/api/platform/v1/first-leg/pda-loading/tasks/{taskId}/pallets/open:
post:
summary: 为已领取任务开启一个一件代发现场托盘
parameters: [{$ref: '#/components/parameters/TaskId'}]
requestBody:
required: true
content: {application/json: {schema: {$ref: '#/components/schemas/OpenPalletRequest'}}}
responses:
'200': {description: 已开启托盘或相同operationId的幂等结果}
/api/platform/v1/first-leg/pda-loading/tasks/{taskId}/pallets/{palletId}/cartons/scan:
post:
summary: 扫描一件代发箱唛上托;箱唛已在其他未封托托盘时按箱移托
description: 本操作只记录实际箱到托关系,不推进既有拣货数量、订单状态或仓库资源结算。
parameters: [{$ref: '#/components/parameters/TaskId'}, {$ref: '#/components/parameters/PalletId'}]
requestBody:
required: true
content: {application/json: {schema: {$ref: '#/components/schemas/ScanCartonRequest'}}}
responses:
'200': {description: 包含最新SKU与箱唛汇总的目标托盘}
/api/platform/v1/first-leg/pda-loading/tasks/{taskId}/pallets/{palletId}/seal:
post:
summary: 封托并校验现场上托箱数未超过既有PDA已提交拣货箱数
parameters: [{$ref: '#/components/parameters/TaskId'}, {$ref: '#/components/parameters/PalletId'}]
requestBody:
required: true
content: {application/json: {schema: {$ref: '#/components/schemas/SealPalletRequest'}}}
responses:
'200': {description: 已封托托盘或A0409数量/版本冲突}
components: components:
parameters: parameters:
TaskId: {name: taskId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} TaskId: {name: taskId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
ItemId: {name: itemId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} ItemId: {name: itemId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
PalletId: {name: palletId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
schemas: schemas:
OpenPalletRequest:
type: object
required: [warehouseId, palletCode, operationId]
properties:
warehouseId: {type: integer, format: int64, minimum: 1}
warehousePalletId: {type: integer, format: int64, minimum: 1, nullable: true}
palletCode: {type: string, maxLength: 64}
operationId: {type: string, maxLength: 128}
ScanCartonRequest:
type: object
required: [warehouseId, boxCode, operationId]
properties:
warehouseId: {type: integer, format: int64, minimum: 1}
boxCode: {type: string, maxLength: 80}
operationId: {type: string, maxLength: 128}
SealPalletRequest:
type: object
required: [warehouseId, expectedVersion, operationId]
properties:
warehouseId: {type: integer, format: int64, minimum: 1}
expectedVersion: {type: integer, minimum: 0}
operationId: {type: string, maxLength: 128}
WorkPallet:
type: object
required: [id, palletCode, status, version, cartonCount, skuCount, customerCount, skus, cartons]
properties:
id: {type: integer, format: int64}
palletCode: {type: string}
warehousePalletId: {type: integer, format: int64, nullable: true}
status: {type: string, enum: [OPEN, SEALED]}
version: {type: integer, minimum: 0}
cartonCount: {type: integer, minimum: 0}
skuCount: {type: integer, minimum: 0}
customerCount: {type: integer, minimum: 0}
skus: {type: array, items: {$ref: '#/components/schemas/WorkPalletSku'}}
cartons: {type: array, items: {$ref: '#/components/schemas/WorkPalletCarton'}}
WorkPalletSku:
type: object
required: [skuCode, cartonCount, productQuantity]
properties:
skuCode: {type: string}
cartonCount: {type: integer, minimum: 1}
productQuantity: {type: integer, format: int64, minimum: 0}
WorkPalletCarton:
type: object
required: [id, boxCode, skuCode, tenantId, inboundId, inboundDetailId]
properties:
id: {type: integer, format: int64}
boxCode: {type: string}
skuCode: {type: string}
tenantId: {type: integer, format: int64}
inboundId: {type: integer, format: int64}
inboundDetailId: {type: integer, format: int64}
unitsPerCarton: {type: integer, minimum: 1, nullable: true}
Task: Task:
type: object type: object
required: [id, loadingPlanId, containerBookingId, inboundId, inboundVersion, loadingVersion, cabinetNo, sourceType, recordStatus, taskStatus, workflowStatus, version, items] required: [id, loadingPlanId, containerBookingId, inboundId, inboundVersion, loadingVersion, cabinetNo, sourceType, recordStatus, taskStatus, workflowStatus, version, items]
@@ -40,7 +40,8 @@ class InboundMeasurementReviewContractTest {
.contains("/api/platform/v1/inbounds/{inboundId}/measurements/approve:") .contains("/api/platform/v1/inbounds/{inboundId}/measurements/approve:")
.contains("Idempotency-Key") .contains("Idempotency-Key")
.contains("初算异常只返回非阻断提示") .contains("初算异常只返回非阻断提示")
.contains("data.processStatus 固定为 WAIT_SPLIT") .contains("标准头程审核通过后进入 WAIT_SPLIT")
.contains("一件代发进入 WAIT_PALLETIZE")
.contains("feeStatus=UNPRICED 且主流程仍成功") .contains("feeStatus=UNPRICED 且主流程仍成功")
.contains("name: inboundId") .contains("name: inboundId")
.contains("schema: {type: string, pattern:") .contains("schema: {type: string, pattern:")
@@ -24,13 +24,8 @@ class PlatformLoadingPdaControllerContractTest {
authority("review", "platform:firstleg:pda:review", AuthenticatedUser.class, long.class, ReviewRequest.class); authority("review", "platform:firstleg:pda:review", AuthenticatedUser.class, long.class, ReviewRequest.class);
authority("complete", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, CompleteLoadingRequest.class); authority("complete", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, CompleteLoadingRequest.class);
authority("operations", "platform:firstleg:pda:view", AuthenticatedUser.class, long.class); authority("operations", "platform:firstleg:pda:view", AuthenticatedUser.class, long.class);
authority("pallets", "platform:firstleg:pda:view", AuthenticatedUser.class, long.class, long.class); authority("pickPallet", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class,
authority("openPallet", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, PalletPickRequest.class);
com.baiyue.firstleg.dto.LoadingPalletDtos.OpenRequest.class);
authority("scanCarton", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, long.class,
com.baiyue.firstleg.dto.LoadingPalletDtos.ScanRequest.class);
authority("sealPallet", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, long.class,
com.baiyue.firstleg.dto.LoadingPalletDtos.SealRequest.class);
} }
private void authority(String name, String expected, Class<?>... parameterTypes) throws Exception { private void authority(String name, String expected, Class<?>... parameterTypes) throws Exception {
@@ -12,7 +12,7 @@ import static org.assertj.core.api.Assertions.assertThat;
class UnifiedInboundOpenApiContractTest { class UnifiedInboundOpenApiContractTest {
@Test @Test
void documentsInternalUnifiedOrderEndpointsAndSixStatuses() throws Exception { void documentsInternalUnifiedOrderEndpointsAndSevenStatuses() throws Exception {
String yaml = new ClassPathResource("openapi/firstleg-v1.yaml") String yaml = new ClassPathResource("openapi/firstleg-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8); .getContentAsString(StandardCharsets.UTF_8);
Map<String, Object> document = stringMap(new Yaml().load(yaml)); Map<String, Object> document = stringMap(new Yaml().load(yaml));
@@ -25,8 +25,8 @@ class UnifiedInboundOpenApiContractTest {
Map<String, Object> schemas = stringMap(stringMap(document.get("components")).get("schemas")); Map<String, Object> schemas = stringMap(stringMap(document.get("components")).get("schemas"));
Map<String, Object> responseStatus = stringMap(schemas.get("UnifiedMainStatus")); Map<String, Object> responseStatus = stringMap(schemas.get("UnifiedMainStatus"));
assertThat(stringList(responseStatus.get("enum"))).containsExactly( assertThat(stringList(responseStatus.get("enum"))).containsExactly(
"PENDING_REVIEW", "WAIT_MEASURE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"); "PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED");
assertThat(responseStatus.get("description").toString()).contains("WAIT_SPLIT", "唯一待分柜状态"); assertThat(responseStatus.get("description").toString()).contains("WAIT_PALLETIZE", "待清点组托");
Map<String, Object> query = stringMap(schemas.get("UnifiedInboundQuery")); Map<String, Object> query = stringMap(schemas.get("UnifiedInboundQuery"));
Map<String, Object> queryStatus = stringMap(stringMap(query.get("properties")).get("mainStatus")); Map<String, Object> queryStatus = stringMap(stringMap(query.get("properties")).get("mainStatus"));
@@ -0,0 +1,42 @@
package com.baiyue.firstleg.repository;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class DropshipPalletizationSqlContractTest {
@Test
void moduleAndUnifiedSchemasExposeCountingPalletAndWholePalletAssignmentFacts() throws Exception {
String inbound = new ClassPathResource("db/phase2-inbound-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
String loading = new ClassPathResource("db/phase3-smart-loading-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"));
for (String sql : new String[]{inbound, unified}) {
assertThat(sql)
.contains("counted_cartons INT UNSIGNED NOT NULL DEFAULT 0")
.contains("discrepancy_cartons INT UNSIGNED NOT NULL DEFAULT 0")
.contains("pallet_status VARCHAR(16) NOT NULL COMMENT '托盘状态:OPEN组托中、SEALED已封托'")
.contains("expected_sku_quantity INT UNSIGNED NOT NULL")
.contains("actual_sku_quantity INT UNSIGNED NOT NULL")
.contains("count_operation_id VARCHAR(128) NOT NULL")
.contains("bind_operation_id VARCHAR(128) NULL");
}
for (String sql : new String[]{loading, unified}) {
assertThat(sql)
.contains("unit_type VARCHAR(24) NOT NULL DEFAULT 'PACKAGE'")
.contains("pallet_id BIGINT UNSIGNED NULL")
.contains("indivisible TINYINT(1) NOT NULL DEFAULT 0")
.contains("active_assignment_key VARCHAR(96) GENERATED ALWAYS AS")
.contains("UNIQUE KEY uk_loading_item_plan_assignment (loading_plan_id, active_assignment_key)")
.doesNotContain("CREATE TABLE IF NOT EXISTS fl_loading_work_pallet (")
.doesNotContain("CREATE TABLE IF NOT EXISTS fl_loading_work_pallet_carton (");
}
}
}
@@ -18,16 +18,19 @@ class InboundCartonLabelSqlContractTest {
String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql")); String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"));
assertThat(schema).contains("CREATE TABLE IF NOT EXISTS fl_inbound_carton_label") assertThat(schema).contains("CREATE TABLE IF NOT EXISTS fl_inbound_carton_label")
.contains("units_per_carton INT UNSIGNED NULL")
.contains("UNIQUE KEY uk_carton_label_box_code (tenant_id, box_code)") .contains("UNIQUE KEY uk_carton_label_box_code (tenant_id, box_code)")
.contains("UNIQUE KEY uk_carton_label_index (tenant_id, inbound_id, label_version, carton_index)") .contains("UNIQUE KEY uk_carton_label_index (tenant_id, inbound_id, label_version, carton_index)")
.contains("inbound_detail_id BIGINT UNSIGNED NOT NULL"); .contains("inbound_detail_id BIGINT UNSIGNED NOT NULL");
assertThat(migration).contains("CREATE TABLE IF NOT EXISTS fl_inbound_carton_label") assertThat(migration).contains("CREATE TABLE IF NOT EXISTS fl_inbound_carton_label")
.contains("units_per_carton INT UNSIGNED NULL")
.contains("固定二维码内容:入仓码#总箱数-箱下标") .contains("固定二维码内容:入仓码#总箱数-箱下标")
.contains("idx_carton_label_detail (tenant_id, inbound_id, inbound_detail_id)"); .contains("idx_carton_label_detail (tenant_id, inbound_id, inbound_detail_id)");
assertThat(versioningMigration).contains("phase2_carton_label_add_column('label_version'") assertThat(versioningMigration).contains("phase2_carton_label_add_column('label_version'")
.contains("phase2_carton_label_add_column('batch_status'") .contains("phase2_carton_label_add_column('batch_status'")
.contains("idx_carton_label_active (tenant_id, inbound_id, batch_status, label_version)"); .contains("idx_carton_label_active (tenant_id, inbound_id, batch_status, label_version)");
assertThat(unified).contains("CREATE TABLE IF NOT EXISTS fl_inbound_carton_label") assertThat(unified).contains("CREATE TABLE IF NOT EXISTS fl_inbound_carton_label")
.contains("units_per_carton INT UNSIGNED NULL")
.contains("unified_carton_label_add_column('label_version'") .contains("unified_carton_label_add_column('label_version'")
.contains("unified_carton_label_add_column('batch_status'") .contains("unified_carton_label_add_column('batch_status'")
.contains("unified_carton_label_add_column('cargo_fingerprint'") .contains("unified_carton_label_add_column('cargo_fingerprint'")
@@ -53,7 +53,7 @@ class SmartLoadingCandidateMapperContractTest {
.contains("SUM(package_count) package_count") .contains("SUM(package_count) package_count")
.contains("GROUP BY tenant_id,measurement_id,inbound_detail_id") .contains("GROUP BY tenant_id,measurement_id,inbound_detail_id")
.contains("fl_dropship_pallet_carton carton") .contains("fl_dropship_pallet_carton carton")
.contains("pallet.pallet_status='COMPLETED'") .contains("pallet.pallet_status='SEALED'")
.contains("source.fulfillment_type='ONE_CLICK_DROPSHIP'") .contains("source.fulfillment_type='ONE_CLICK_DROPSHIP'")
.contains("i.inbound_id=o.id") .contains("i.inbound_id=o.id")
.contains("o.warehouse_id=#{warehouseId}") .contains("o.warehouse_id=#{warehouseId}")
@@ -67,7 +67,8 @@ class SmartLoadingSqlContractTest {
assertThat(migration()) assertThat(migration())
.contains("active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS") .contains("active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS")
.contains("CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END") .contains("CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END")
.contains("UNIQUE KEY uk_loading_item_plan_detail (loading_plan_id, active_inbound_detail_id)") .contains("active_assignment_key VARCHAR(96) GENERATED ALWAYS AS")
.contains("UNIQUE KEY uk_loading_item_plan_assignment (loading_plan_id, active_assignment_key)")
.contains("KEY idx_loading_item_active_detail (account_id, active_inbound_detail_id)") .contains("KEY idx_loading_item_active_detail (account_id, active_inbound_detail_id)")
.contains("UNIQUE KEY uk_container_booking_no (booking_no)") .contains("UNIQUE KEY uk_container_booking_no (booking_no)")
.contains("UNIQUE KEY uk_container_booking_cabinet (cabinet_no)") .contains("UNIQUE KEY uk_container_booking_cabinet (cabinet_no)")
@@ -0,0 +1,66 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.DropshipPalletizationDtos.CountCartonRequest;
import com.baiyue.firstleg.dto.DropshipPalletizationDtos.DiscrepancyAction;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.CartonRow;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.LabelRow;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.PalletRow;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.TaskRow;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class DropshipPalletizationServiceTest {
private final DropshipPalletizationMapper mapper=mock(DropshipPalletizationMapper.class);
private final DropshipPalletizationService service=new DropshipPalletizationService();
DropshipPalletizationServiceTest(){ ReflectionTestUtils.setField(service,"mapper",mapper); }
@Test void resolvesUncountedCartonBeforeQuantityConfirmation(){
TaskRow task=task(); LabelRow label=label();
when(mapper.task(10L,100L)).thenReturn(task); when(mapper.label(10L,"BOX-1")).thenReturn(label);
var result=service.resolveCarton(10L,100L,"BOX-1",7L);
assertThat(result.inboundCode()).isEqualTo("IN-1");
assertThat(result.skuCode()).isEqualTo("SKU-1");
assertThat(result.expectedSkuQuantity()).isEqualTo(12);
assertThat(result.countStatus()).isEqualTo("UNCOUNTED");
assertThat(result.canCount()).isTrue();
}
@Test void mismatchCannotBeRecordedWithoutAcceptedVarianceAndRemark(){
when(mapper.task(10L,100L)).thenReturn(task()); when(mapper.label(10L,"BOX-1")).thenReturn(label());
var wrongAction=new CountCartonRequest(100L,"BOX-1",11,DiscrepancyAction.MATCH,null,null,"op-1");
assertThatThrownBy(()->service.countCarton(10L,wrongAction,7L,"张三"))
.isInstanceOf(BizException.class).hasMessageContaining("确认差异处理结果");
var noRemark=new CountCartonRequest(100L,"BOX-1",11,DiscrepancyAction.ACCEPT_VARIANCE,null,null,"op-2");
assertThatThrownBy(()->service.countCarton(10L,noRemark,7L,"张三"))
.isInstanceOf(BizException.class).hasMessageContaining("处理说明");
verify(mapper,never()).insertCarton(anyLong(),anyLong(),any(),anyLong(),anyLong(),anyLong(),anyLong(),any(),any(),any(),anyInt(),anyInt(),any(),any(),any(),anyLong(),any());
}
@Test void countsOneCartonDirectlyIntoScannedPallet(){
TaskRow before=task(),after=task(); after.setCountedCartons(1); after.setAllocatedCartons(1);
LabelRow label=label(); PalletRow pallet=pallet(); CartonRow carton=carton();
when(mapper.task(10L,100L)).thenReturn(before,after);
when(mapper.label(10L,"BOX-1")).thenReturn(label); when(mapper.palletByNo("DP-260908-0001")).thenReturn(pallet);
when(mapper.addCounted(10L,7L,1,0)).thenReturn(1); when(mapper.carton(eq(10L),anyLong())).thenReturn(carton);
when(mapper.pallet(10L,20L,100L)).thenReturn(pallet); when(mapper.cartons(20L)).thenReturn(java.util.List.of(carton));
when(mapper.metrics(20L)).thenReturn(new DropshipPalletizationMapper.PalletMetrics(java.math.BigDecimal.ONE,java.math.BigDecimal.TEN));
var request=new CountCartonRequest(100L,"BOX-1",12,DiscrepancyAction.MATCH,null,"DP-260908-0001","op-1");
var result=service.countCarton(10L,request,7L,"张三");
assertThat(result.carton().countStatus()).isEqualTo("BOUND");
assertThat(result.pallet().status()).isEqualTo("OPEN");
verify(mapper).insertCarton(anyLong(),eq(10L),eq(20L),eq(1L),eq(30L),eq(40L),eq(50L),eq("SKU-1"),
eq("货物"),eq("BOX-1"),eq(12),eq(12),eq("MATCH"),isNull(),eq("op-1"),eq(7L),eq("张三"));
}
private static TaskRow task(){ TaskRow t=new TaskRow();t.setId(10L);t.setTenantId(1L);t.setInboundId(30L);t.setWarehouseId(100L);t.setInboundCode("IN-1");t.setTaskStatus("CLAIMED");t.setAssigneeId(7L);t.setProcessStatus("WAIT_PALLETIZE");t.setTotalCartons(2);t.setCountedCartons(0);t.setAllocatedCartons(0);t.setDiscrepancyCartons(0);t.setVersion(1);return t; }
private static LabelRow label(){LabelRow l=new LabelRow();l.setCartonLabelId(50L);l.setInboundDetailId(40L);l.setSkuCode("SKU-1");l.setCargoName("货物");l.setUnitsPerCarton(12);l.setInboundCode("IN-1");l.setBoxCode("BOX-1");return l;}
private static PalletRow pallet(){PalletRow p=new PalletRow();p.setId(20L);p.setTaskId(10L);p.setTenantId(1L);p.setInboundId(30L);p.setWarehouseId(100L);p.setPalletNo("DP-260908-0001");p.setPalletStatus("OPEN");p.setVersion(1);return p;}
private static CartonRow carton(){CartonRow c=new CartonRow();c.setId(60L);c.setTaskId(10L);c.setPalletId(20L);c.setPalletNo("DP-260908-0001");c.setInboundId(30L);c.setInboundDetailId(40L);c.setSkuCode("SKU-1");c.setCargoName("货物");c.setBoxCode("BOX-1");c.setExpectedSkuQuantity(12);c.setActualSkuQuantity(12);c.setDiscrepancyStatus("MATCH");return c;}
}
@@ -30,6 +30,7 @@ import com.baiyue.firstleg.loading.coload.port.ColoadGroupAssignmentPort;
import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.GroupSnapshot; import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.GroupSnapshot;
import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.Member; import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.Member;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper;
import com.baiyue.firstleg.mapper.LoadingPickItemMapper; import com.baiyue.firstleg.mapper.LoadingPickItemMapper;
import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.repository.InboundPalletAllocationRepository; import com.baiyue.firstleg.repository.InboundPalletAllocationRepository;
@@ -103,6 +104,8 @@ class LoadingPickServiceTest {
private InboundPalletAllocationRepository palletAllocations; private InboundPalletAllocationRepository palletAllocations;
@MockBean @MockBean
private InboundHoldGuard inboundHoldGuard; private InboundHoldGuard inboundHoldGuard;
@MockBean
private DropshipPalletizationMapper dropshipPallets;
@Resource @Resource
private LoadingPickService service; private LoadingPickService service;
@@ -1,87 +0,0 @@
package com.baiyue.firstleg.service;
import com.baiyue.firstleg.dto.LoadingPalletDtos.ScanRequest;
import com.baiyue.firstleg.dto.LoadingPalletDtos.SealRequest;
import com.baiyue.firstleg.entity.LoadingWorkPalletCartonEntity;
import com.baiyue.firstleg.entity.LoadingWorkPalletEntity;
import com.baiyue.firstleg.mapper.LoadingWorkPalletCartonMapper;
import com.baiyue.firstleg.mapper.LoadingWorkPalletCartonMapper.CartonCandidate;
import com.baiyue.firstleg.mapper.LoadingWorkPalletMapper;
import com.baiyue.firstleg.mapper.LoadingWorkPalletMapper.TaskContext;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(LoadingWorkPalletService.class)
class LoadingWorkPalletServiceTest {
@MockBean private LoadingWorkPalletMapper pallets;
@MockBean private LoadingWorkPalletCartonMapper cartons;
@MockBean private InboundHoldGuard inboundHoldGuard;
@Resource private LoadingWorkPalletService service;
@Test
void scansDropshipCartonOntoMixedCustomerWorkPalletWithoutChangingPickWorkflow() {
when(pallets.selectTaskContext(10L, 20L)).thenReturn(context());
LoadingWorkPalletEntity pallet = pallet(30L, "OPEN");
when(pallets.selectScoped(10L, 30L, 20L)).thenReturn(pallet);
CartonCandidate candidate = candidate();
when(cartons.selectCandidates(10L, 20L, "BOX-A")).thenReturn(List.of(candidate));
doAnswer(invocation -> {
LoadingWorkPalletCartonEntity value = invocation.getArgument(0);
value.setId(50L);
return 1;
}).when(cartons).insert(any(LoadingWorkPalletCartonEntity.class));
when(cartons.selectPalletCartons(30L)).thenAnswer(invocation -> List.of());
service.scan(10L, 30L, new ScanRequest(20L, "BOX-A", "SCAN-1"), 99L, "作业员");
var captor = org.mockito.ArgumentCaptor.forClass(LoadingWorkPalletCartonEntity.class);
verify(cartons).insert(captor.capture());
assertThat(captor.getValue().getTenantId()).isEqualTo(88L);
assertThat(captor.getValue().getLoadingPlanItemId()).isEqualTo(41L);
assertThat(captor.getValue().getSkuCode()).isEqualTo("SKU-A");
}
@Test
void refusesToSealUntilScannedCartonsAreReconciledByExistingPickSubmission() {
when(pallets.selectTaskContext(10L, 20L)).thenReturn(context());
when(pallets.selectScoped(10L, 30L, 20L)).thenReturn(pallet(30L, "OPEN"));
when(cartons.countPalletCartons(30L)).thenReturn(2);
when(cartons.countUnreconciled(10L)).thenReturn(1);
assertThatThrownBy(() -> service.seal(10L, 30L,
new SealRequest(20L, 0, "SEAL-1"), 99L, "作业员"))
.hasMessageContaining("原PDA拣货提交");
}
private static TaskContext context() {
return new TaskContext(10L, 11L, "LOADING", 20L, 99L, 2);
}
private static LoadingWorkPalletEntity pallet(long id, String status) {
LoadingWorkPalletEntity value = new LoadingWorkPalletEntity();
value.setId(id); value.setPickTaskId(10L); value.setLoadingPlanId(11L);
value.setWarehouseId(20L); value.setPalletCode("PLT-01");
value.setPalletStatus(status); value.setVersion(0);
return value;
}
private static CartonCandidate candidate() {
CartonCandidate value = new CartonCandidate();
value.setCartonLabelId(40L); value.setTenantId(88L); value.setInboundId(81L);
value.setInboundDetailId(82L); value.setBoxCode("BOX-A");
value.setLoadingPlanItemId(41L); value.setPickItemId(42L);
value.setSkuCode("SKU-A"); value.setUnitsPerCarton(20);
return value;
}
}
@@ -34,6 +34,7 @@ import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.Member;
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow;
import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase;
import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.ChannelContainerCountRow; import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.ChannelContainerCountRow;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper;
import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.CandidateUnit; import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.CandidateUnit;
import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.PlanColoadMemberRow; import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.PlanColoadMemberRow;
import com.baiyue.firstleg.split.service.SplitPreloadService; import com.baiyue.firstleg.split.service.SplitPreloadService;
@@ -95,6 +96,7 @@ class SmartLoadingServiceTest {
@MockBean private ColoadGroupAssignmentPort coloadGroups; @MockBean private ColoadGroupAssignmentPort coloadGroups;
@MockBean private PriceBookQueryUseCase priceBooks; @MockBean private PriceBookQueryUseCase priceBooks;
@MockBean private FirstlegDocumentNumberGenerator documentNumbers; @MockBean private FirstlegDocumentNumberGenerator documentNumbers;
@MockBean private DropshipPalletizationMapper dropshipPallets;
@Resource private SmartLoadingService service; @Resource private SmartLoadingService service;
@TestConfiguration @TestConfiguration
@@ -48,7 +48,8 @@ public interface FirstlegUnifiedOrderClient {
Long deliveryWarehouseId, String deliveryWarehouseName, String channelCode, String channelName, Long deliveryWarehouseId, String deliveryWarehouseName, String channelCode, String channelName,
Integer totalPackageCount, String locationCodes, Integer totalPackageCount, String locationCodes,
LocalDateTime expectedArrivalAt, LocalDateTime requestedPickupAt, String feeReviewStatus, LocalDateTime expectedArrivalAt, LocalDateTime requestedPickupAt, String feeReviewStatus,
String companyName, String businessType, String taxMode, String containerMode, String companyName, String businessType, String fulfillmentType, String lxCode,
String taxMode, String containerMode,
Boolean customsInspection, String destinationType, String destinationCode, Boolean customsInspection, String destinationType, String destinationCode,
Boolean holdFlag, String currentHoldReason, Boolean holdFlag, String currentHoldReason,
BigDecimal actualGrossWeightKg, BigDecimal actualNetWeightKg, BigDecimal actualVolumeCbm, BigDecimal actualGrossWeightKg, BigDecimal actualNetWeightKg, BigDecimal actualVolumeCbm,
@@ -72,13 +72,27 @@ public class InternalOrderCargoController {
if (tenantId <= 0 || orderId <= 0) { if (tenantId <= 0 || orderId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "租户和订单ID必须大于0"); throw new BizException(ResultCode.BAD_REQUEST, "租户和订单ID必须大于0");
} }
var order = orders.findOwned(tenantId, orderId);
if (order == null) {
throw new BizException(ResultCode.NOT_FOUND, "订单不存在或不属于当前客户");
}
boolean oneClickDropship = "ONE_CLICK_DROPSHIP".equals(order.getFulfillmentType());
return ApiResponse.success(orders.findCargoInheritedByOrder(tenantId, orderId).stream() return ApiResponse.success(orders.findCargoInheritedByOrder(tenantId, orderId).stream()
.map(cargo -> new CargoLabelBinding(cargo.getId(), orderId, cargo.getItemNo(), .map(cargo -> new CargoLabelBinding(cargo.getId(), orderId, cargo.getItemNo(),
cargo.getCargoName(), cargo.getSkuNo(), cargo.getPackageCount(), cargo.getLengthCm(), cargo.getCargoName(), cargo.getSkuNo(), cargo.getPackageCount(),
cargo.getWidthCm(), cargo.getHeightCm())) unitsPerCarton(cargo, oneClickDropship), cargo.getLengthCm(), cargo.getWidthCm(),
cargo.getHeightCm()))
.toList()); .toList());
} }
private Integer unitsPerCarton(OrderCargoEntity cargo, boolean oneClickDropship) {
Integer value = cargo.getUnitsPerPackage();
if (oneClickDropship && (value == null || value <= 0)) {
throw new BizException(ResultCode.INTERNAL_ERROR, "一件代发货物每箱件数缺失或无效");
}
return value;
}
@GetMapping("/{tenantId}/{orderId}/inbound-snapshot") @GetMapping("/{tenantId}/{orderId}/inbound-snapshot")
public ApiResponse<FirstlegInboundClient.OrderApprovedInboundCommand> inboundSnapshot( public ApiResponse<FirstlegInboundClient.OrderApprovedInboundCommand> inboundSnapshot(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@@ -155,12 +169,20 @@ public class InternalOrderCargoController {
} }
public record CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, public record CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
String skuNo, Integer packageCount, java.math.BigDecimal lengthCm, String skuNo, Integer packageCount, Integer unitsPerCarton,
java.math.BigDecimal lengthCm,
java.math.BigDecimal widthCm, java.math.BigDecimal heightCm) { java.math.BigDecimal widthCm, java.math.BigDecimal heightCm) {
public CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, public CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer packageCount, java.math.BigDecimal lengthCm, Integer packageCount, java.math.BigDecimal lengthCm,
java.math.BigDecimal widthCm, java.math.BigDecimal heightCm) { java.math.BigDecimal widthCm, java.math.BigDecimal heightCm) {
this(cargoId, orderId, itemNo, cargoName, null, packageCount, lengthCm, widthCm, heightCm); this(cargoId, orderId, itemNo, cargoName, null, packageCount, null,
lengthCm, widthCm, heightCm);
}
public CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
String skuNo, Integer packageCount, java.math.BigDecimal lengthCm,
java.math.BigDecimal widthCm, java.math.BigDecimal heightCm) {
this(cargoId, orderId, itemNo, cargoName, skuNo, packageCount, null,
lengthCm, widthCm, heightCm);
} }
} }
@@ -42,7 +42,7 @@ public class PlatformUnifiedOrderController {
@GetMapping @GetMapping
@PreAuthorize("hasAuthority('platform:order:list')") @PreAuthorize("hasAuthority('platform:order:list')")
@Operation(summary = "统一订单列表", description = "返回七态产品主状态及测量、预装、装柜、派送摘要。") @Operation(summary = "统一订单列表", description = "返回八态产品主状态及测量、组托、预装、装柜、派送摘要。")
public ApiResponse<PageResult<UnifiedOrderResponse.Row>> page( public ApiResponse<PageResult<UnifiedOrderResponse.Row>> page(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) Long tenantId, @RequestParam(required = false) Long tenantId,
@@ -12,8 +12,8 @@ import java.time.LocalDateTime;
public record UnifiedOrderQuery( public record UnifiedOrderQuery(
@Size(max = 64) String keyword, @Size(max = 64) String keyword,
@Size(max = 64) String orderNo, @Size(max = 64) String orderNo,
@Schema(allowableValues = {"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", @Schema(allowableValues = {"PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED",
"LOADING", "LOADED", "DELIVERED"}) "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"})
@Size(max = 32) String mainStatus, @Size(max = 32) String mainStatus,
Long customerId, Long customerId,
Long receivingWarehouseId, Long receivingWarehouseId,
@@ -24,7 +24,7 @@ public final class UnifiedOrderResponse {
BigDecimal estimatedVolumeCbm, BigDecimal expectedVolumeCbm, BigDecimal estimatedVolumeCbm, BigDecimal expectedVolumeCbm,
BigDecimal actualGrossWeightKg, BigDecimal actualVolumeCbm, BigDecimal actualGrossWeightKg, BigDecimal actualVolumeCbm,
LocalDateTime expectedArrivalAt, String feeReviewStatus, LocalDateTime expectedArrivalAt, String feeReviewStatus,
String companyName, String businessType, String pickupMethod, String companyName, String businessType, String fulfillmentType, String lxCode, String pickupMethod,
String taxMode, String containerMode, Boolean customsInspection, String taxMode, String containerMode, Boolean customsInspection,
String destinationType, String destinationCode, String destinationType, String destinationCode,
Integer totalPalletCount, BigDecimal chargeableWeightKg, BigDecimal chargeableVolumeCbm, Integer totalPalletCount, BigDecimal chargeableWeightKg, BigDecimal chargeableVolumeCbm,
@@ -38,7 +38,8 @@ import java.util.stream.Collectors;
@Service @Service
public class UnifiedOrderQueryService { public class UnifiedOrderQueryService {
private static final Set<String> MAIN_STATUSES = Set.of( private static final Set<String> MAIN_STATUSES = Set.of(
"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"); "PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED", "WAIT_SPLIT",
"LOADING", "LOADED", "DELIVERED");
private static final Set<String> REVIEW_STATUSES = Set.of("UNRECORDED", "PENDING", "APPROVED", "REJECTED"); private static final Set<String> REVIEW_STATUSES = Set.of("UNRECORDED", "PENDING", "APPROVED", "REJECTED");
private static final List<OrderStatus> ACTIVE_ORDER_STATUSES = List.copyOf(EnumSet.of( private static final List<OrderStatus> ACTIVE_ORDER_STATUSES = List.copyOf(EnumSet.of(
OrderStatus.SUBMITTED, OrderStatus.APPROVED, OrderStatus.ACCEPTED, OrderStatus.SUBMITTED, OrderStatus.APPROVED, OrderStatus.ACCEPTED,
@@ -311,7 +312,8 @@ public class UnifiedOrderQueryService {
order.getInboundCode(), order.getInboundCode(),
order.getExpectedShipDate(), null, null, totalPackageCount(cargo), null, null, order.getExpectedShipDate(), null, null, totalPackageCount(cargo), null, null,
order.getEstimatedVolumeCbm(), volumeCbm(cargo), null, null, order.getEstimatedVolumeCbm(), volumeCbm(cargo), null, null,
order.getExpectedArrivalAt(), null, null, null, order.getPickupMethod(), order.getTaxMode(), order.getContainerMode(), order.getExpectedArrivalAt(), null, null, null, order.getFulfillmentType(), order.getLxCode(),
order.getPickupMethod(), order.getTaxMode(), order.getContainerMode(),
order.getCustomsInspection(), order.getDestinationType(), order.getAmazonWarehouseCode(), order.getCustomsInspection(), order.getDestinationType(), order.getAmazonWarehouseCode(),
null, null, null, null, null, null, null, order.getExpressCompany(), order.getExpressTrackingNo(), null, null, null, null, null, null, null, order.getExpressCompany(), order.getExpressTrackingNo(),
StringUtils.hasText(creatorName) ? creatorName : string(order.getCreatedBy()), skuNos(cargo), totalQuantity(cargo), shipmentId(order, cargo), referenceId(order, cargo), StringUtils.hasText(creatorName) ? creatorName : string(order.getCreatedBy()), skuNos(cargo), totalQuantity(cargo), shipmentId(order, cargo), referenceId(order, cargo),
@@ -334,7 +336,8 @@ public class UnifiedOrderQueryService {
order.getEstimatedVolumeCbm(), volumeCbm(cargo), order.getEstimatedVolumeCbm(), volumeCbm(cargo),
inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(), inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(),
order.getExpectedArrivalAt() != null ? order.getExpectedArrivalAt() : inbound.expectedArrivalAt(), inbound.feeReviewStatus(), order.getExpectedArrivalAt() != null ? order.getExpectedArrivalAt() : inbound.expectedArrivalAt(), inbound.feeReviewStatus(),
inbound.companyName(), inbound.businessType(), order.getPickupMethod(), inbound.taxMode(), inbound.containerMode(), inbound.companyName(), inbound.businessType(), inbound.fulfillmentType(), inbound.lxCode(),
order.getPickupMethod(), inbound.taxMode(), inbound.containerMode(),
inbound.customsInspection(), inbound.destinationType(), inbound.destinationCode(), inbound.palletCount(), inbound.customsInspection(), inbound.destinationType(), inbound.destinationCode(), inbound.palletCount(),
inbound.chargeableWeightKg(), inbound.chargeableVolumeCbm(), inbound.volumeWeightKg(), inbound.chargeableWeightKg(), inbound.chargeableVolumeCbm(), inbound.volumeWeightKg(),
inbound.actualNetWeightKg(), inbound.actualNetWeightKg(),
@@ -361,7 +364,8 @@ public class UnifiedOrderQueryService {
order == null ? null : order.getEstimatedVolumeCbm(), volumeCbm(cargo), order == null ? null : order.getEstimatedVolumeCbm(), volumeCbm(cargo),
inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(), inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(),
order != null && order.getExpectedArrivalAt() != null ? order.getExpectedArrivalAt() : inbound.expectedArrivalAt(), inbound.feeReviewStatus(), inbound.companyName(), order != null && order.getExpectedArrivalAt() != null ? order.getExpectedArrivalAt() : inbound.expectedArrivalAt(), inbound.feeReviewStatus(), inbound.companyName(),
inbound.businessType(), order == null ? null : order.getPickupMethod(), inbound.taxMode(), inbound.containerMode(), inbound.businessType(), inbound.fulfillmentType(), inbound.lxCode(),
order == null ? null : order.getPickupMethod(), inbound.taxMode(), inbound.containerMode(),
inbound.customsInspection(), inbound.destinationType(), inbound.destinationCode(), inbound.palletCount(), inbound.customsInspection(), inbound.destinationType(), inbound.destinationCode(), inbound.palletCount(),
inbound.chargeableWeightKg(), inbound.chargeableVolumeCbm(), inbound.volumeWeightKg(), inbound.chargeableWeightKg(), inbound.chargeableVolumeCbm(), inbound.volumeWeightKg(),
inbound.actualNetWeightKg(), inbound.actualNetWeightKg(),
@@ -502,7 +506,7 @@ public class UnifiedOrderQueryService {
return switch (status) { return switch (status) {
case "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", case "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
"WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING" -> "WAIT_MEASURE"; "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING" -> "WAIT_MEASURE";
case "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED" -> status; case "INBOUNDED", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED" -> status;
default -> null; default -> null;
}; };
} }
+12 -9
View File
@@ -173,7 +173,7 @@ paths:
get: get:
summary: 平台统一订单列表 summary: 平台统一订单列表
description: >- description: >-
统一返回七态产品主状态、下一步动作及测量/预装/装柜/派送摘要,需要 platform:order:list。 统一返回八态产品主状态、下一步动作及测量/组托/预装/装柜/派送摘要,需要 platform:order:list。
入仓码来源由全系统统一模式控制:AUTO 在订单提交时按物流渠道生成,格式为物流前缀+YYMMDD+当天全平台票序; 入仓码来源由全系统统一模式控制:AUTO 在订单提交时按物流渠道生成,格式为物流前缀+YYMMDD+当天全平台票序;
LEGACY_MANUAL 在草稿中录入旧系统入仓码。两种模式下入仓码一经提交均固定;有入库单时同时返回 LEGACY_MANUAL 在草稿中录入旧系统入仓码。两种模式下入仓码一经提交均固定;有入库单时同时返回
inboundId、inboundVersion、loadingVersion、pickingVersion;inboundVersion inboundId、inboundVersion、loadingVersion、pickingVersion;inboundVersion
@@ -617,7 +617,7 @@ components:
tenantId: {type: string, pattern: '^[1-9][0-9]*$'} tenantId: {type: string, pattern: '^[1-9][0-9]*$'}
keyword: {type: string, maxLength: 64} keyword: {type: string, maxLength: 64}
orderNo: {type: string, maxLength: 64} orderNo: {type: string, maxLength: 64}
mainStatus: {type: string, enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED]} mainStatus: {type: string, enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED]}
customerId: {type: string, pattern: '^[1-9][0-9]*$'} customerId: {type: string, pattern: '^[1-9][0-9]*$'}
receivingWarehouseId: {type: string, pattern: '^[1-9][0-9]*$'} receivingWarehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
destinationWarehouseId: {type: string, pattern: '^[1-9][0-9]*$'} destinationWarehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
@@ -693,9 +693,10 @@ components:
asOf: {type: string, format: date-time} asOf: {type: string, format: date-time}
UnifiedMainStatus: UnifiedMainStatus:
type: string type: string
enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED] enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED]
description: >- description: >-
统一订单七态产品主状态。WAIT_SPLIT 为正式待分柜状态,INBOUNDED 仅兼容存量已入库记录。 统一订单八态产品主状态。ONE_CLICK_DROPSHIP 测量计费完成后进入 WAIT_PALLETIZE,封托完成后进入 WAIT_SPLIT;
STANDARD 保持既有流程,INBOUNDED 仅兼容存量已入库记录。
UnifiedOrderPageEnvelope: UnifiedOrderPageEnvelope:
type: object type: object
properties: properties:
@@ -766,6 +767,8 @@ components:
feeReviewStatus: {type: string, nullable: true, description: Firstleg 入仓费用审核快照;财务状态不在本字段表达} feeReviewStatus: {type: string, nullable: true, description: Firstleg 入仓费用审核快照;财务状态不在本字段表达}
companyName: {type: string, nullable: true} companyName: {type: string, nullable: true}
businessType: {type: string, nullable: true} businessType: {type: string, nullable: true}
fulfillmentType: {type: string, enum: [STANDARD, ONE_CLICK_DROPSHIP], nullable: true, description: 独立履约类型,不复用计费业务类型}
lxCode: {type: string, nullable: true, description: 一件代发可选 LX 编码;普通头程或未填写时为 null}
pickupMethod: {type: string, nullable: true} pickupMethod: {type: string, nullable: true}
requestedPickupAt: {type: string, format: date-time, nullable: true, description: Customer-requested pickup appointment from the pickup snapshot} requestedPickupAt: {type: string, format: date-time, nullable: true, description: Customer-requested pickup appointment from the pickup snapshot}
taxMode: {type: string, nullable: true} taxMode: {type: string, nullable: true}
@@ -961,13 +964,13 @@ components:
fulfillmentType: fulfillmentType:
type: string type: string
enum: [STANDARD, ONE_CLICK_DROPSHIP] enum: [STANDARD, ONE_CLICK_DROPSHIP]
default: STANDARD
description: 独立履约类型;省略为普通头程,不复用业务/计费 businessType
lxCode: lxCode:
type: string type: string
nullable: true nullable: true
maxLength: 128 maxLength: 128
description: 一件代发绑定的领星入库单 LX 代码 description: 一件代发绑定的领星入库单 LX 代码
default: STANDARD
description: 独立履约类型;省略为普通头程,不复用业务/计费 businessType
inboundCode: inboundCode:
type: string type: string
maxLength: 40 maxLength: 40
@@ -1194,17 +1197,17 @@ components:
name: mainStatus name: mainStatus
in: query in: query
required: false required: false
description: 统一订单七态主状态筛选;WAIT_SPLIT 与存量兼容状态 INBOUNDED 独立查询 description: 统一订单八态主状态筛选;WAIT_PALLETIZE、WAIT_SPLIT 与存量兼容状态 INBOUNDED 独立查询
schema: schema:
type: string type: string
enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, LOADING, LOADED, DELIVERED, WAIT_SPLIT] enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED]
UnifiedInboundCode: UnifiedInboundCode:
{name: inboundCode, in: query, required: false, description: 订单创建时生成的入仓码,所有订单状态均可查询, schema: {type: string, maxLength: 40}} {name: inboundCode, in: query, required: false, description: 订单创建时生成的入仓码,所有订单状态均可查询, schema: {type: string, maxLength: 40}}
UnifiedInternalOrderStatus: UnifiedInternalOrderStatus:
name: internalOrderStatus name: internalOrderStatus
in: query in: query
required: false required: false
description: Order 内部生命周期状态;不是七态产品主状态 description: Order 内部生命周期状态;不是八态产品主状态
schema: {type: string, enum: [DRAFT, SUBMITTED, APPROVED, ACCEPTED, REJECTED, CANCELLED, PROCESSING, COMPLETED]} schema: {type: string, enum: [DRAFT, SUBMITTED, APPROVED, ACCEPTED, REJECTED, CANCELLED, PROCESSING, COMPLETED]}
UnifiedExpectedArrivalDate: UnifiedExpectedArrivalDate:
{name: expectedArrivalDate, in: query, required: false, schema: {type: string, format: date}} {name: expectedArrivalDate, in: query, required: false, schema: {type: string, format: date}}
@@ -88,7 +88,10 @@ class InternalOrderCargoControllerTest {
@Test @Test
void listsCargoInRepositoryOrderForStableCartonAllocation() { void listsCargoInRepositoryOrderForStableCartonAllocation() {
OrderCargoEntity first = cargo(31L, 1, 3, "60", "40", "30"); OrderCargoEntity first = cargo(31L, 1, 3, "60", "40", "30");
first.setUnitsPerPackage(24);
OrderCargoEntity second = cargo(32L, 2, 2, "50", "30", "20"); OrderCargoEntity second = cargo(32L, 2, 2, "50", "30", "20");
OrderEntity order = new OrderEntity(); order.setId(21L); order.setFulfillmentType("STANDARD");
when(orders.findOwned(11L, 21L)).thenReturn(order);
when(orders.findCargoInheritedByOrder(11L, 21L)).thenReturn(List.of(first, second)); when(orders.findCargoInheritedByOrder(11L, 21L)).thenReturn(List.of(first, second));
var response = controller.listForCartonLabels("internal-token", 11L, 21L); var response = controller.listForCartonLabels("internal-token", 11L, 21L);
@@ -97,10 +100,68 @@ class InternalOrderCargoControllerTest {
assertThat(response.data()).extracting(InternalOrderCargoController.CargoLabelBinding::cargoId) assertThat(response.data()).extracting(InternalOrderCargoController.CargoLabelBinding::cargoId)
.containsExactly(31L, 32L); .containsExactly(31L, 32L);
assertThat(response.data().get(0).packageCount()).isEqualTo(3); assertThat(response.data().get(0).packageCount()).isEqualTo(3);
assertThat(response.data().get(0).unitsPerCarton()).isEqualTo(24);
assertThat(response.data().get(1).unitsPerCarton()).isNull();
assertThat(response.data().get(0).lengthCm()).isEqualByComparingTo("60"); assertThat(response.data().get(0).lengthCm()).isEqualByComparingTo("60");
verify(orders).findOwned(11L, 21L);
verify(orders).findCargoInheritedByOrder(11L, 21L); verify(orders).findCargoInheritedByOrder(11L, 21L);
} }
@Test
void oneClickDropshipReturnsAuthoritativePositiveUnitsPerCarton() {
OrderEntity order = new OrderEntity();
order.setId(21L); order.setFulfillmentType("ONE_CLICK_DROPSHIP");
OrderCargoEntity cargo = cargo(31L, 1, 3, "60", "40", "30");
cargo.setUnitsPerPackage(12);
when(orders.findOwned(11L, 21L)).thenReturn(order);
when(orders.findCargoInheritedByOrder(11L, 21L)).thenReturn(List.of(cargo));
var response = controller.listForCartonLabels("internal-token", 11L, 21L);
assertThat(response.data()).singleElement()
.extracting(InternalOrderCargoController.CargoLabelBinding::unitsPerCarton)
.isEqualTo(12);
}
@Test
void oneClickDropshipFailsClosedWhenUnitsPerCartonIsMissing() {
OrderEntity order = new OrderEntity();
order.setId(21L); order.setFulfillmentType("ONE_CLICK_DROPSHIP");
when(orders.findOwned(11L, 21L)).thenReturn(order);
when(orders.findCargoInheritedByOrder(11L, 21L))
.thenReturn(List.of(cargo(31L, 1, 3, "60", "40", "30")));
assertThatThrownBy(() -> controller.listForCartonLabels("internal-token", 11L, 21L))
.isInstanceOf(BizException.class)
.hasMessageContaining("每箱件数缺失或无效");
}
@Test
void oneClickDropshipFailsClosedWhenUnitsPerCartonIsNotPositive() {
OrderEntity order = new OrderEntity();
order.setId(21L); order.setFulfillmentType("ONE_CLICK_DROPSHIP");
OrderCargoEntity cargo = cargo(31L, 1, 3, "60", "40", "30");
cargo.setUnitsPerPackage(0);
when(orders.findOwned(11L, 21L)).thenReturn(order);
when(orders.findCargoInheritedByOrder(11L, 21L)).thenReturn(List.of(cargo));
assertThatThrownBy(() -> controller.listForCartonLabels("internal-token", 11L, 21L))
.isInstanceOf(BizException.class)
.hasMessageContaining("每箱件数缺失或无效");
}
@Test
void cartonLabelListDoesNotExposeCargoWhenOrderIsOutsideCustomerScope() {
when(orders.findOwned(12L, 21L)).thenReturn(null);
assertThatThrownBy(() -> controller.listForCartonLabels("internal-token", 12L, 21L))
.isInstanceOf(BizException.class)
.hasMessageContaining("订单不存在或不属于当前客户");
verify(orders).findOwned(12L, 21L);
verifyNoMoreInteractions(orders);
}
@Test @Test
void childOrderCanInheritRootCargoBindingWithoutCrossCustomerLeak() { void childOrderCanInheritRootCargoBindingWithoutCrossCustomerLeak() {
OrderCargoEntity cargo = cargo(31L, 1, 3, "60", "40", "30"); OrderCargoEntity cargo = cargo(31L, 1, 3, "60", "40", "30");
@@ -2,6 +2,8 @@ package com.baiyue.order.controller;
import com.baiyue.order.client.FirstlegUnifiedOrderClient; import com.baiyue.order.client.FirstlegUnifiedOrderClient;
import com.baiyue.order.client.FirstlegColoadGroupClient; import com.baiyue.order.client.FirstlegColoadGroupClient;
import com.baiyue.order.dto.UnifiedOrderQuery;
import io.swagger.v3.oas.annotations.media.Schema;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.ClassPathResource;
import org.springframework.cloud.openfeign.FeignClient; import org.springframework.cloud.openfeign.FeignClient;
@@ -26,7 +28,7 @@ class UnifiedOrderApiContractTest {
} }
@Test @Test
void documentsSevenProductMainStatuses() throws Exception { void documentsEightProductMainStatuses() throws Exception {
String yaml = new ClassPathResource("openapi/order-v1.yaml") String yaml = new ClassPathResource("openapi/order-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8); .getContentAsString(StandardCharsets.UTF_8);
Map<String, Object> document = stringMap(new Yaml().load(yaml)); Map<String, Object> document = stringMap(new Yaml().load(yaml));
@@ -40,22 +42,32 @@ class UnifiedOrderApiContractTest {
Map<String, Object> mainStatus = stringMap(parameters.get("UnifiedMainStatus")); Map<String, Object> mainStatus = stringMap(parameters.get("UnifiedMainStatus"));
Map<String, Object> schema = stringMap(mainStatus.get("schema")); Map<String, Object> schema = stringMap(mainStatus.get("schema"));
assertThat(stringList(schema.get("enum"))).containsExactly( assertThat(stringList(schema.get("enum"))).containsExactly(
"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "LOADING", "LOADED", "DELIVERED", "WAIT_SPLIT"); "PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED", "WAIT_SPLIT",
"LOADING", "LOADED", "DELIVERED");
assertThat(mainStatus.get("description").toString()) assertThat(mainStatus.get("description").toString())
.contains("WAIT_SPLIT", "INBOUNDED"); .contains("WAIT_PALLETIZE", "WAIT_SPLIT", "INBOUNDED");
Map<String, Object> schemas = stringMap(components.get("schemas")); Map<String, Object> schemas = stringMap(components.get("schemas"));
Map<String, Object> responseStatus = stringMap(schemas.get("UnifiedMainStatus")); Map<String, Object> responseStatus = stringMap(schemas.get("UnifiedMainStatus"));
assertThat(stringList(responseStatus.get("enum"))).containsExactly( assertThat(stringList(responseStatus.get("enum"))).containsExactly(
"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"); "PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED", "WAIT_SPLIT",
assertThat(responseStatus.get("description").toString()).contains("WAIT_SPLIT", "INBOUNDED"); "LOADING", "LOADED", "DELIVERED");
assertThat(responseStatus.get("description").toString())
.contains("WAIT_PALLETIZE", "WAIT_SPLIT", "INBOUNDED");
Schema queryAnnotation = UnifiedOrderQuery.class.getDeclaredField("mainStatus")
.getAnnotation(Schema.class);
assertThat(queryAnnotation).isNotNull();
assertThat(queryAnnotation.allowableValues()).containsExactly(
"PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED", "WAIT_SPLIT",
"LOADING", "LOADED", "DELIVERED");
Map<String, Object> row = stringMap(schemas.get("UnifiedOrderRow")); Map<String, Object> row = stringMap(schemas.get("UnifiedOrderRow"));
assertThat(stringMap(row.get("properties"))).containsKeys( assertThat(stringMap(row.get("properties"))).containsKeys(
"orderId", "mainStatus", "channelCode", "inboundId", "inboundVersion", "measurement", "preload", "orderId", "mainStatus", "channelCode", "inboundId", "inboundVersion", "measurement", "preload",
"loading", "delivery", "transport", "customerRemark", "latestInternalNote", "loading", "delivery", "transport", "customerRemark", "latestInternalNote",
"holdFlag", "currentHoldReason", "holdFlag", "currentHoldReason",
"expectedArrivalAt", "requestedPickupAt", "feeReviewStatus", "companyName", "businessType", "pickupMethod", "expectedArrivalAt", "requestedPickupAt", "feeReviewStatus", "companyName", "businessType",
"fulfillmentType", "lxCode", "pickupMethod",
"taxMode", "containerMode", "customsInspection", "destinationType", "destinationCode", "taxMode", "containerMode", "customsInspection", "destinationType", "destinationCode",
"totalPalletCount", "chargeableWeightKg", "chargeableVolumeCbm", "volumetricWeightKg", "totalNetWeightKg", "totalPalletCount", "chargeableWeightKg", "chargeableVolumeCbm", "volumetricWeightKg", "totalNetWeightKg",
"measurementCompletedAt", "inboundCompletedAt", "expressCompany", "expressTrackingNo", "measurementCompletedAt", "inboundCompletedAt", "expressCompany", "expressTrackingNo",
@@ -86,7 +98,7 @@ class UnifiedOrderApiContractTest {
assertThat(page.getAnnotation(PostMapping.class).value()).containsExactly("/query"); assertThat(page.getAnnotation(PostMapping.class).value()).containsExactly("/query");
assertThat(List.of(FirstlegUnifiedOrderClient.Inbound.class.getRecordComponents()).stream() assertThat(List.of(FirstlegUnifiedOrderClient.Inbound.class.getRecordComponents()).stream()
.map(component -> component.getName())) .map(component -> component.getName()))
.contains("channelCode", "channelName", "holdFlag", "currentHoldReason") .contains("channelCode", "channelName", "holdFlag", "currentHoldReason", "fulfillmentType", "lxCode")
.doesNotContain("holdCargo", "holdReason"); .doesNotContain("holdCargo", "holdReason");
} }
@@ -549,6 +549,70 @@ class UnifiedOrderQueryServiceTest {
assertThat(captor.getValue().mainStatus()).isEqualTo("WAIT_SPLIT"); assertThat(captor.getValue().mainStatus()).isEqualTo("WAIT_SPLIT");
} }
@Test
void waitPalletizeFilterReturnsDropshipProjectionAndForwardsToFirstleg() {
UnifiedOrderQuery query = query("WAIT_PALLETIZE");
var summary = summary("WAIT_PALLETIZE", "PALLETIZE_DROPSHIP");
when(firstleg.page(eq("internal-token"), any()))
.thenReturn(ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1)));
when(orders.findByIds(eq(10001L), eq(List.of(77L)))).thenReturn(List.of(order(OrderStatus.PROCESSING)));
var result = service.page(10001L, query);
assertThat(result.records()).singleElement().satisfies(row -> {
assertThat(row.mainStatus()).isEqualTo("WAIT_PALLETIZE");
assertThat(row.nextAction()).isEqualTo("PALLETIZE_DROPSHIP");
assertThat(row.fulfillmentType()).isEqualTo("ONE_CLICK_DROPSHIP");
assertThat(row.lxCode()).isEqualTo("LX-20260908-01");
});
ArgumentCaptor<FirstlegUnifiedOrderClient.Query> captor =
ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.Query.class);
verify(firstleg).page(eq("internal-token"), captor.capture());
assertThat(captor.getValue().mainStatus()).isEqualTo("WAIT_PALLETIZE");
}
@Test
void orderOnlyProjectionUsesExistingFulfillmentFieldsWithoutCreatingInboundFacts() {
UnifiedOrderQuery query = query("PENDING_REVIEW");
OrderEntity order = order(OrderStatus.SUBMITTED);
order.setFulfillmentType("ONE_CLICK_DROPSHIP");
order.setLxCode("LX-ORDER-01");
when(orders.unifiedPage(eq(10001L), eq(query), eq(List.of(OrderStatus.SUBMITTED))))
.thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1));
assertThat(service.page(10001L, query).records()).singleElement().satisfies(row -> {
assertThat(row.mainStatus()).isEqualTo("PENDING_REVIEW");
assertThat(row.fulfillmentType()).isEqualTo("ONE_CLICK_DROPSHIP");
assertThat(row.lxCode()).isEqualTo("LX-ORDER-01");
assertThat(row.inboundId()).isNull();
});
}
@Test
void detailMapsWaitPalletizeSummaryAndTimeline() {
OrderEntity order = order(OrderStatus.PROCESSING);
when(orders.findOwned(10001L, 77L)).thenReturn(order);
when(orders.findCargo(10001L, 77L)).thenReturn(List.of());
when(orders.findParties(10001L, 77L)).thenReturn(List.of());
when(orders.findNotes(10001L, 77L)).thenReturn(List.of());
when(orders.findHistory(10001L, 77L)).thenReturn(List.of());
var timeline = new FirstlegUnifiedOrderClient.Timeline(
"FIRSTLEG", "STATUS", "WAIT_MEASURE", "WAIT_PALLETIZE", "MEASUREMENT_APPROVED",
null, 9L, "平台操作员", LocalDateTime.of(2026, 9, 8, 10, 0));
var summary = summary("WAIT_PALLETIZE", "PALLETIZE_DROPSHIP");
when(firstleg.detail(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new FirstlegUnifiedOrderClient.Detail(summary, List.of(timeline))));
var result = service.detail(10001L, 77L, false);
assertThat(result.summary().mainStatus()).isEqualTo("WAIT_PALLETIZE");
assertThat(result.summary().fulfillmentType()).isEqualTo("ONE_CLICK_DROPSHIP");
assertThat(result.summary().lxCode()).isEqualTo("LX-20260908-01");
assertThat(result.timeline()).singleElement()
.extracting(UnifiedOrderResponse.TimelineEvent::mainStatus)
.isEqualTo("WAIT_PALLETIZE");
}
@Test @Test
void listKeepsLoadedFulfillmentAheadOfRawApprovedOrderStatus() { void listKeepsLoadedFulfillmentAheadOfRawApprovedOrderStatus() {
UnifiedOrderQuery query = query(null); UnifiedOrderQuery query = query(null);
@@ -728,7 +792,7 @@ class UnifiedOrderQueryServiceTest {
@Test @Test
void fulfillmentTabsForwardStatusSearchAndPaginationToFirstleg() { void fulfillmentTabsForwardStatusSearchAndPaginationToFirstleg() {
List<String> statuses = List.of( List<String> statuses = List.of(
"WAIT_MEASURE", "INBOUNDED", "LOADING", "LOADED", "DELIVERED"); "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED");
when(firstleg.page(eq("internal-token"), any())) when(firstleg.page(eq("internal-token"), any()))
.thenReturn(ApiResponse.success(new PageResult<>(List.of(), 3, 20, 0, 0))); .thenReturn(ApiResponse.success(new PageResult<>(List.of(), 3, 20, 0, 0)));
@@ -840,6 +904,8 @@ class UnifiedOrderQueryServiceTest {
when(inbound.locationCodes()).thenReturn("A0-01, B2-03"); when(inbound.locationCodes()).thenReturn("A0-01, B2-03");
when(inbound.companyName()).thenReturn("测试客户有限公司"); when(inbound.companyName()).thenReturn("测试客户有限公司");
when(inbound.businessType()).thenReturn("SEA_LCL"); when(inbound.businessType()).thenReturn("SEA_LCL");
when(inbound.fulfillmentType()).thenReturn("ONE_CLICK_DROPSHIP");
when(inbound.lxCode()).thenReturn("LX-20260908-01");
when(inbound.palletCount()).thenReturn(2); when(inbound.palletCount()).thenReturn(2);
when(inbound.actualGrossWeightKg()).thenReturn(new BigDecimal("1787.500")); when(inbound.actualGrossWeightKg()).thenReturn(new BigDecimal("1787.500"));
when(inbound.actualNetWeightKg()).thenReturn(new BigDecimal("1747.500")); when(inbound.actualNetWeightKg()).thenReturn(new BigDecimal("1747.500"));
+1 -1
View File
@@ -21,7 +21,7 @@
| 订舱与拖车服务中心链路 | `admin-test-env-modules/service-center-flow.mjs` | 复用本轮 `RELEASED` 订舱,创建订舱级拖车并验证附件、完整状态链、历史和稳定关联;由 Admin 总入口按依赖运行,会写目标环境 | | 订舱与拖车服务中心链路 | `admin-test-env-modules/service-center-flow.mjs` | 复用本轮 `RELEASED` 订舱,创建订舱级拖车并验证附件、完整状态链、历史和稳定关联;由 Admin 总入口按依赖运行,会写目标环境 |
| 尾程单控制与目的港码头拖车 | `admin-test-env-modules/phase4-tail-order-controls.mjs`、`admin-test-env-modules/phase5-terminal-drayage.mjs`、`admin-test-env-modules/phase4-carrier-nodes.mjs` | PDA 前覆盖整柜接柜仓变更与恢复、单/批目的仓变更、单/批合托标记、CSV 导出、订单费用、单票暂扣/解除、八个人工节点和逐票人工计划;接柜仓仅复用公共主档且恢复原值。PDA 卸柜后经正式码头拖车接口完成选车队、预约、提柜、还柜,船节点模块只读核对最终回写,不直接伪造最后两个节点 | | 尾程单控制与目的港码头拖车 | `admin-test-env-modules/phase4-tail-order-controls.mjs`、`admin-test-env-modules/phase5-terminal-drayage.mjs`、`admin-test-env-modules/phase4-carrier-nodes.mjs` | PDA 前覆盖整柜接柜仓变更与恢复、单/批目的仓变更、单/批合托标记、CSV 导出、订单费用、单票暂扣/解除、八个人工节点和逐票人工计划;接柜仓仅复用公共主档且恢复原值。PDA 卸柜后经正式码头拖车接口完成选车队、预约、提柜、还柜,船节点模块只读核对最终回写,不直接伪造最后两个节点 |
| 定向 HTTP/诊断 | 以 `README.md` 的“仓库级入口清单”为准 | 必须明确是只读诊断还是会补证据、创建资源或推进状态,不能用诊断成功冒充全量通过 | | 定向 HTTP/诊断 | 以 `README.md` 的“仓库级入口清单”为准 | 必须明确是只读诊断还是会补证据、创建资源或推进状态,不能用诊断成功冒充全量通过 |
| 本地质量与静态合同 | `backend-full-regression.ps1`、`frontend-full-regression.ps1`、`mapper-scan-contract.mjs`、`script-documentation-contract.test.mjs` 等 | 默认不访问开发或测试环境;数据库非 dry-run、Nacos 发布等有写入能力的入口必须按 README 单独识别 | | 本地质量与静态合同 | `backend-full-regression.ps1`、`frontend-full-regression.ps1`(含国内 PDA Node 全量测试)、`mapper-scan-contract.mjs`、`script-documentation-contract.test.mjs` 等 | 默认不访问开发或测试环境;数据库非 dry-run、Nacos 发布等有写入能力的入口必须按 README 单独识别 |
| 部署与基础设施 | `../deploy/scripts/`、`../deploy/rocketmq/` | 靠近部署组件管理,不移动到根脚本目录;部署、初始化数据库或发布配置必须获得当前轮明确授权 | | 部署与基础设施 | `../deploy/scripts/`、`../deploy/rocketmq/` | 靠近部署组件管理,不移动到根脚本目录;部署、初始化数据库或发布配置必须获得当前轮明确授权 |
| 服务局部合同 | `../firstleg/scripts/`、`../lastleg-service/scripts/`、`../warehouse/scripts/` | 只服务所属模块,入口与命令登记在服务 `AGENTS.md` 或局部 README;不得散落到 `src/test/scripts/` | | 服务局部合同 | `../firstleg/scripts/`、`../lastleg-service/scripts/`、`../warehouse/scripts/` | 只服务所属模块,入口与命令登记在服务 `AGENTS.md` 或局部 README;不得散落到 `src/test/scripts/` |
+5 -4
View File
@@ -79,7 +79,7 @@ Client 全量、Client 入库测量与计费、待审核订单和批量待分柜
| 订单与客户本地验收 | `phase1-business-maturity-regression.ps1` | 订单与客户业务成熟度回归,调用本地服务并生成报告 | | 订单与客户本地验收 | `phase1-business-maturity-regression.ps1` | 订单与客户业务成熟度回归,调用本地服务并生成报告 |
| 订单与客户本地验收 | `phase1-onboarding-order-e2e.ps1` | 注册、资质、审核到下单的本地 E2E,会创建客户、仓库、往来单位和订单 | | 订单与客户本地验收 | `phase1-onboarding-order-e2e.ps1` | 注册、资质、审核到下单的本地 E2E,会创建客户、仓库、往来单位和订单 |
| 本地质量 | `backend-full-regression.ps1` | Mapper 门禁后执行受影响后端 Maven 测试;`-DryRun` 只打印与校验 | | 本地质量 | `backend-full-regression.ps1` | Mapper 门禁后执行受影响后端 Maven 测试;`-DryRun` 只打印与校验 |
| 本地质量 | `frontend-full-regression.ps1` | 编排 Admin/Client 本地测试与类型检查,不访问 Gateway | | 本地质量 | `frontend-full-regression.ps1` | 编排 Admin/Client 本地测试、类型检查及国内 PDA Node 全量测试,不访问 Gateway |
| 本地质量 | `database-comment-audit.ps1` | 离线扫描数据库中文注释,不连接数据库 | | 本地质量 | `database-comment-audit.ps1` | 离线扫描数据库中文注释,不连接数据库 |
| 本地质量 | `local-db-full-acceptance.ps1` | 只允许本机 MySQL 的受控 SQL 验收;非 dry-run 会写本地测试库 | | 本地质量 | `local-db-full-acceptance.ps1` | 只允许本机 MySQL 的受控 SQL 验收;非 dry-run 会写本地测试库 |
| 本地质量 | `mapper-scan-contract.mjs` | 离线检查 Mapper 注解与扫描入口,不启动 Spring、不连接数据库 | | 本地质量 | `mapper-scan-contract.mjs` | 离线检查 Mapper 注解与扫描入口,不启动 Spring、不连接数据库 |
@@ -153,8 +153,8 @@ node scripts/create-pending-review-orders.mjs
| 检查 | 入口 | | 检查 | 入口 |
|---|---| |---|---|
| 后端 Maven 契约/单元测试 | `scripts/backend-full-regression.ps1` | | 后端 Maven 契约/单元测试 | `scripts/backend-full-regression.ps1` |
| Admin/Client 本地测试与类型检查 | `scripts/frontend-full-regression.ps1` | | Admin/Client/国内 PDA 本地全量回归 | `scripts/frontend-full-regression.ps1` |
| PDA 本地页面和状态测试 | `../apps/pda-uniapp/tests/*.test.mjs` | | PDA 本地页面和状态定向测试 | `../apps/pda-uniapp/tests/*.test.mjs` |
| 本地数据库 SQL 静态/受控验收 | `scripts/local-db-full-acceptance.ps1` | | 本地数据库 SQL 静态/受控验收 | `scripts/local-db-full-acceptance.ps1` |
| AGENTS 快速索引一致性 | `scripts/agents-index-contract.ps1`;只读核对根快速表唯一性、部署单元、业务模块、Client active 路由、海外仓业务范围漂移、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、海外 Gateway 覆盖、PDA 页面/运行配置/独立更新/语言预留/能力开关、人工分段验收链、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口与排除目录 | | AGENTS 快速索引一致性 | `scripts/agents-index-contract.ps1`;只读核对根快速表唯一性、部署单元、业务模块、Client active 路由、海外仓业务范围漂移、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、海外 Gateway 覆盖、PDA 页面/运行配置/独立更新/语言预留/能力开关、人工分段验收链、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口与排除目录 |
| Mapper 注册静态合同 | `node scripts/mapper-scan-contract.mjs`、`node --test scripts/mapper-scan-contract.test.mjs`;自动核对全部持久化模块的 `@Mapper`、服务根包注解过滤扫描和唯一扫描入口,不启动 Spring、不连接数据库;`backend-full-regression.ps1` 在 Maven 前强制执行 | | Mapper 注册静态合同 | `node scripts/mapper-scan-contract.mjs`、`node --test scripts/mapper-scan-contract.test.mjs`;自动核对全部持久化模块的 `@Mapper`、服务根包注解过滤扫描和唯一扫描入口,不启动 Spring、不连接数据库;`backend-full-regression.ps1` 在 Maven 前强制执行 |
@@ -182,13 +182,14 @@ Admin/Client HTTP 脚本的离线合同只验证脚本结构、参数和模块
## 前端全量回归 ## 前端全量回归
`frontend-full-regression.ps1` 只编排本地 Admin/Client 的 `pnpm test` 与 `pnpm typecheck`,不调用 Gateway、不部署、不连接数据库。 `frontend-full-regression.ps1` 编排本地 Admin/Client 的 `pnpm test`、`pnpm typecheck`,并默认执行国内 PDA 的 `node --test tests/*.test.mjs`;不调用 Gateway、不部署、不连接数据库。
```powershell ```powershell
.\scripts\frontend-full-regression.ps1 -DryRun .\scripts\frontend-full-regression.ps1 -DryRun
.\scripts\frontend-full-regression.ps1 .\scripts\frontend-full-regression.ps1
.\scripts\frontend-full-regression.ps1 -SkipAdmin .\scripts\frontend-full-regression.ps1 -SkipAdmin
.\scripts\frontend-full-regression.ps1 -SkipClient .\scripts\frontend-full-regression.ps1 -SkipClient
.\scripts\frontend-full-regression.ps1 -SkipPda
``` ```
脚本会检查 `pnpm` 和目标应用的 `package.json`;任一命令失败时立即透传退出码,不能同时跳过 Admin 和 Client。 脚本会检查 `pnpm` 和目标应用的 `package.json`;任一命令失败时立即透传退出码,不能同时跳过 Admin 和 Client。
+1 -1
View File
@@ -887,7 +887,7 @@ $criticalPaths = @(
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/service/DropshipOrderService.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/service/DropshipOrderService.java',
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/service/InboundOrderDetainedOutboxService.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/service/InboundOrderDetainedOutboxService.java',
'baiyue-firstleg/common-event-contract/src/main/java/com/baiyue/common/event/OverseasInboundOrderDetainedChangedV1.java', 'baiyue-firstleg/common-event-contract/src/main/java/com/baiyue/common/event/OverseasInboundOrderDetainedChangedV1.java',
'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingWorkPalletService.java', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java',
'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumer.java', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumer.java',
'baiyue-firstleg/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java', 'baiyue-firstleg/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java',
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraftContainer.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraftContainer.java',
+27 -3
View File
@@ -2,7 +2,8 @@
param( param(
[switch]$DryRun, [switch]$DryRun,
[switch]$SkipAdmin, [switch]$SkipAdmin,
[switch]$SkipClient [switch]$SkipClient,
[switch]$SkipPda
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
@@ -17,8 +18,22 @@ function Invoke-FrontendStep {
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
} }
if ($SkipAdmin -and $SkipClient) { Write-Error 'At least one of Admin or Client must be selected.'; exit 2 } function Invoke-PdaTests {
if (-not (Get-Command pnpm -ErrorAction SilentlyContinue)) { Write-Error 'pnpm was not found on PATH.'; exit 2 } param([string]$Directory)
$pattern = Join-Path $Directory 'tests/*.test.mjs'
Write-Host " [test] node --test `"$pattern`""
if ($DryRun) { return }
& node --test $pattern
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
if ($SkipAdmin -and $SkipClient -and $SkipPda) { Write-Error 'At least one of Admin, Client or PDA must be selected.'; exit 2 }
if ((-not $SkipAdmin -or -not $SkipClient) -and -not (Get-Command pnpm -ErrorAction SilentlyContinue)) {
Write-Error 'pnpm was not found on PATH.'; exit 2
}
if (-not $SkipPda -and -not (Get-Command node -ErrorAction SilentlyContinue)) {
Write-Error 'node was not found on PATH.'; exit 2
}
$targets = @() $targets = @()
if (-not $SkipAdmin) { $targets += @{ Name = 'Admin'; Directory = Join-Path $repoRoot 'apps/admin-web' } } if (-not $SkipAdmin) { $targets += @{ Name = 'Admin'; Directory = Join-Path $repoRoot 'apps/admin-web' } }
@@ -37,4 +52,13 @@ foreach ($target in $targets) {
Invoke-FrontendStep 'typecheck' $target.Directory 'typecheck' Invoke-FrontendStep 'typecheck' $target.Directory 'typecheck'
} }
if (-not $SkipPda) {
$pdaDirectory = Join-Path $repoRoot 'apps/pda-uniapp'
if (-not (Test-Path -LiteralPath (Join-Path $pdaDirectory 'tests'))) {
Write-Error "PDA tests directory not found: $pdaDirectory"; exit 2
}
Write-Host '[PDA]'
Invoke-PdaTests $pdaDirectory
}
Write-Host 'Frontend regression completed.' Write-Host 'Frontend regression completed.'
@@ -7,8 +7,16 @@ import { normalizeModuleOrder } from './admin-test-env-acceptance.mjs'
const scriptsDir = dirname(fileURLToPath(import.meta.url)) const scriptsDir = dirname(fileURLToPath(import.meta.url))
const backendScript = readFileSync(resolve(scriptsDir, 'backend-full-regression.ps1'), 'utf8') const backendScript = readFileSync(resolve(scriptsDir, 'backend-full-regression.ps1'), 'utf8')
const frontendScript = readFileSync(resolve(scriptsDir, 'frontend-full-regression.ps1'), 'utf8')
const adminAcceptance = readFileSync(resolve(scriptsDir, 'admin-test-env-acceptance.mjs'), 'utf8') const adminAcceptance = readFileSync(resolve(scriptsDir, 'admin-test-env-acceptance.mjs'), 'utf8')
test('frontend full regression includes domestic PDA node tests by default', () => {
assert.match(frontendScript, /\[switch\]\$SkipPda/)
assert.match(frontendScript, /apps\/pda-uniapp/)
assert.match(frontendScript, /node --test/)
assert.match(frontendScript, /tests\/\*\.test\.mjs/)
})
test('backend full regression includes file reactor and critical file/container coverage', () => { test('backend full regression includes file reactor and critical file/container coverage', () => {
assert.match(backendScript, /-pl["',\s\S]*order,firstleg,iam,gateway,file/) assert.match(backendScript, /-pl["',\s\S]*order,firstleg,iam,gateway,file/)
for (const required of [ for (const required of [