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 出库或派送 |
| 海外排车执行、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 自身客户范围和状态为准 |
| 一件代发 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/` |
| 海外仓 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 候选进入可收货 |
@@ -2860,6 +2860,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
cargo_item_no INT UNSIGNED NOT NULL COMMENT '订单货物明细序号快照',
cargo_name VARCHAR(255) NULL COMMENT '货物名称快照',
sku_code VARCHAR(128) NULL COMMENT '箱唛绑定的SKU快照;二维码内容不变',
units_per_carton INT UNSIGNED NULL COMMENT '每箱SKU数量快照;一件代发必须为正数',
detail_package_count INT UNSIGNED NOT NULL COMMENT '该订单货物明细箱数快照',
inbound_code VARCHAR(40) 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),
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),
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),
CONSTRAINT fk_carton_label_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='首次打印前冻结的实际箱号与订单货物明细映射';
@@ -5190,6 +5190,10 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item (
product_image_url VARCHAR(1000) NULL COMMENT '产品图片访问地址快照',
package_count INT UNSIGNED NOT 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 '每箱产品数',
pallet_count INT UNSIGNED NULL COMMENT '托盘数',
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_at DATETIME(3) NULL 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_name VARCHAR(64) NULL COMMENT '创建人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照',
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 '乐观锁版本号',
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_plan (loading_plan_id, deleted, load_sequence, 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_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),
CONSTRAINT fk_loading_item_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id),
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)
) 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;
DELIMITER $$
@@ -6276,12 +6221,13 @@ BEGIN
'destination_warehouse_name', 'product_name_zh', 'product_name_en',
'product_image_file_id', 'pallet_count', 'net_weight_kg',
'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 = '智能装柜迁移失败:明细归属、清单快照或冻结字段缺失';
END IF;
IF (SELECT COUNT(*) FROM information_schema.statistics
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 = '智能装柜迁移失败:计划内唯一活动明细锁缺失';
END IF;
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',
task_status VARCHAR(24) NOT NULL COMMENT '任务状态:PENDING待领取、CLAIMED已领取、COMPLETED已完成',
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 '已上托箱数',
discrepancy_cartons INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已确认差异箱数',
assignee_id BIGINT UNSIGNED NULL COMMENT '任务领取人ID',
assignee_name VARCHAR(64) 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),
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_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合托任务';
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 '客户隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '一件代发入仓单ID',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '作业仓库ID',
pallet_no VARCHAR(32) NULL COMMENT '封托时生成的DP托盘号',
pallet_status VARCHAR(16) NOT NULL COMMENT '托盘状态:OPEN合托中、COMPLETED已封托',
pallet_no VARCHAR(32) NOT NULL COMMENT '现场扫描的系统DP托盘码',
pallet_status VARCHAR(16) NOT NULL COMMENT '托盘状态:OPEN组托中、SEALED已封托',
open_operation_id VARCHAR(128) NOT NULL COMMENT '创建托盘幂等操作ID',
seal_operation_id VARCHAR(128) 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),
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 chk_dropship_pallet_status CHECK (pallet_status IN ('OPEN','COMPLETED'))
CONSTRAINT chk_dropship_pallet_status CHECK (pallet_status IN ('OPEN','SEALED'))
) ENGINE=InnoDB COMMENT='一件代发合托形成的可打印托盘';
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 (
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 '客户隔离键',
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',
sku_code VARCHAR(128) NOT NULL COMMENT '箱唛冻结SKU编码',
cargo_name VARCHAR(255) NULL COMMENT '货物名称快照',
box_code VARCHAR(64) NOT NULL COMMENT '稳定箱码',
operation_id VARCHAR(128) NOT NULL COMMENT '上托幂等操作ID',
bound_by BIGINT UNSIGNED NOT NULL COMMENT '上托操作人ID',
box_code VARCHAR(80) NOT NULL COMMENT '稳定箱码',
expected_sku_quantity INT UNSIGNED NOT NULL COMMENT '箱唛冻结的应收SKU数量',
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_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上托时间',
bound_at DATETIME(3) NULL COMMENT '上托时间',
PRIMARY KEY (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_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_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)
@@ -63,12 +63,13 @@ public interface OrderCargoClient {
}
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) {
public CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer packageCount, BigDecimal lengthCm, BigDecimal widthCm,
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()));
}
@PostMapping("/tasks/{taskId}/pallets/open")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<Pallet> open(@AuthenticationPrincipal AuthenticatedUser user,
@GetMapping("/tasks/{taskId}/cartons/resolve")
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
public ApiResponse<DropshipPalletizationDtos.Carton> resolveCarton(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@Valid @RequestBody DropshipPalletizationDtos.OpenPalletRequest request) {
@RequestParam @Positive long warehouseId,
@RequestParam @Size(max = 80) String boxCode) {
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')")
public ApiResponse<DropshipPalletizationDtos.ScanResult> scan(
public ApiResponse<DropshipPalletizationDtos.CountResult> countCarton(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId, @PathVariable @Positive long palletId,
@Valid @RequestBody DropshipPalletizationDtos.ScanRequest request) {
@PathVariable @Positive long taskId,
@Valid @RequestBody DropshipPalletizationDtos.CountCartonRequest request) {
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")
@@ -3,6 +3,7 @@ package com.baiyue.firstleg.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
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.SkipRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.ClaimRequest;
@@ -24,11 +25,6 @@ import org.springframework.http.ResponseEntity;
import org.springframework.http.MediaType;
import org.springframework.web.multipart.MultipartFile;
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.validation.Valid;
import jakarta.validation.constraints.NotBlank;
@@ -56,8 +52,6 @@ public class PlatformLoadingPdaController {
private LoadingPickService service;
@Resource
private CabinetLoadingEvidenceService evidenceService;
@Resource
private LoadingWorkPalletService palletService;
@GetMapping("/tasks")
@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()));
}
@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")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<Task> partial(@AuthenticationPrincipal AuthenticatedUser user,
@@ -226,44 +229,4 @@ public class PlatformLoadingPdaController {
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()));}
@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()));}
@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')")
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')")
@@ -1,7 +1,8 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
@@ -14,13 +15,23 @@ public final class DropshipPalletizationDtos {
public record ClaimRequest(@Positive long warehouseId) {}
public record OpenPalletRequest(@Positive long warehouseId,
@NotBlank @Size(max = 128) String operationId) {}
public enum DiscrepancyAction { MATCH, ACCEPT_VARIANCE }
public record ScanRequest(@Positive long warehouseId,
@NotBlank @Size(max = 64) String boxCode,
@Positive @Max(2000) int cartonCount,
@NotBlank @Size(max = 128) String operationId) {}
public record CountCartonRequest(@Positive long warehouseId,
@NotBlank @Size(max = 80) String boxCode,
@Positive int actualSkuQuantity,
@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,
@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,
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) {}
public record Task(Long taskId, Long inboundId, String inboundCode, Long tenantId,
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,
List<Pallet> pallets) {}
List<Pallet> pallets, List<Carton> unbound) {}
public record Pallet(Long palletId, String palletNo, String palletStatus,
Integer cartonCount, Integer skuCount, Integer version,
List<SkuItem> items, LocalDateTime completedAt) {}
public record Pallet(Long palletId, String palletNo, String status,
Integer cartonCount, Integer skuCount, Integer discrepancyCartons,
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,
String skuCode, String cargoName, Integer cartonCount,
String skuCode, String cargoName, Integer cartonCount, Integer skuQuantity,
List<String> cartonMarks) {}
public record ScanResult(String skuCode, String cargoName, String inboundCode,
int addedCartons, int allocatedCartons, int remainingCartons,
Pallet pallet) {}
public record Carton(Long cartonId, String boxCode, Long inboundId, String inboundCode,
Long inboundDetailId, String skuCode, String cargoName,
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 englishName,
String goodsImageUrl,
String unitType,
Long palletId,
String palletNo,
Boolean indivisible,
int packageCount,
int palletCount,
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
) {}
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 明细的最终实际拣出箱数,不是增量。 */
public record PartialPickRequest(
@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,
String inboundCode, String customerName, Integer availablePackageCount,
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,
Long warehouseId, String channelCode, String channelName, Long destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName, String customerName, String goodsSummary,
Integer availablePackageCount, BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg,
BigDecimal averageUnitVolumeCbm, BigDecimal availableVolumeCbm,
String unitType,Long palletId,String palletNo,Boolean indivisible,
Long coLoadGroupId, String groupNo, Integer memberCount, Integer groupTotalBoxes,
Boolean locked, Boolean canAssign, String blockReason,
String orderStatusSnapshot, LocalDateTime expectedArrivalAt,
@@ -66,6 +69,10 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
public record AssignGroupRequest(@NotNull @Positive Long inboundId,
@NotNull @Min(0) Integer expectedVersion,
@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,
@NotBlank @Size(max=128) String operationId) {}
public record RecommendRequest(@NotNull @Min(0) Integer expectedVersion,
@@ -23,6 +23,7 @@ public class InboundCartonLabelEntity {
private Integer cargoItemNo;
private String cargoName;
private String skuCode;
private Integer unitsPerCarton;
private Integer detailPackageCount;
private String inboundCode;
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 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 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 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;
@@ -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;
import com.baiyue.firstleg.dto.DropshipPalletizationDtos.PendingTask;
import com.baiyue.firstleg.dto.SmartLoadingDtos.PalletCandidate;
import lombok.Getter;
import lombok.Setter;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import org.apache.ibatis.annotations.*;
import java.time.LocalDateTime;
import java.math.BigDecimal;
import java.util.List;
/** 一件代发合托持久化;任务、托盘和逐箱绑定均独立于装柜拣货。 */
/** 一件代发逐箱清点、待绑托与封托事实。 */
@Mapper
public interface DropshipPalletizationMapper {
@Insert("""
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)
VALUES(#{id},#{tenantId},#{inboundId},#{warehouseId},'PENDING',#{totalCartons},0,1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))
""")
int insertTask(@Param("id") long id, @Param("tenantId") long tenantId,
@Param("inboundId") long inboundId, @Param("warehouseId") long warehouseId,
@Param("totalCartons") int totalCartons);
@Insert("""
INSERT IGNORE INTO fl_dropship_pallet_task
(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,0,0,1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))""")
int insertTask(@Param("id") long id,@Param("tenantId") long tenantId,@Param("inboundId") long inboundId,
@Param("warehouseId") long warehouseId,@Param("totalCartons") int totalCartons);
@Select("""
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
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.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},'%')
OR o.customer_name LIKE CONCAT('%',#{keyword},'%'))
ORDER BY t.created_at,t.id
""")
List<PendingTask> pending(@Param("operatorId") long operatorId, @Param("keyword") String keyword);
@Select("""
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.counted_cartons,t.allocated_cartons bound_cartons,
t.counted_cartons-t.allocated_cartons unbound_cartons,t.discrepancy_cartons,t.task_status,t.created_at
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.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},'%') OR o.customer_name LIKE CONCAT('%',#{keyword},'%'))
ORDER BY t.created_at,t.id""")
List<PendingTask> pending(@Param("operatorId") long operatorId,@Param("keyword") String keyword);
@Select("""
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
WHERE t.id=#{taskId} AND t.warehouse_id=#{warehouseId}
""")
TaskRow task(@Param("taskId") long taskId, @Param("warehouseId") long warehouseId);
@Select("""
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
WHERE t.id=#{taskId} AND t.warehouse_id=#{warehouseId}""")
TaskRow task(@Param("taskId") long taskId,@Param("warehouseId") long warehouseId);
@Update("""
UPDATE fl_dropship_pallet_task
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)
WHERE id=#{taskId} AND warehouse_id=#{warehouseId} AND task_status IN ('PENDING','CLAIMED')
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);
@Update("""
UPDATE fl_dropship_pallet_task 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)
WHERE id=#{taskId} AND warehouse_id=#{warehouseId} AND task_status IN ('PENDING','CLAIMED')
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);
@Insert("""
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)
VALUES(#{id},#{taskId},#{tenantId},#{inboundId},#{warehouseId},'OPEN',#{operationId},1,
#{operatorId},#{operatorName},CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))
""")
int insertPallet(@Param("id") long id, @Param("taskId") long taskId,
@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("warehouseId") long warehouseId, @Param("operationId") String operationId,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Insert("""
INSERT INTO fl_dropship_pallet
(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},#{palletNo},'OPEN',#{operationId},1,
#{operatorId},#{operatorName},CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))""")
int insertPallet(@Param("id") long id,@Param("taskId") long taskId,@Param("tenantId") long tenantId,
@Param("inboundId") long inboundId,@Param("warehouseId") long warehouseId,@Param("palletNo") String palletNo,
@Param("operationId") String operationId,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Select("SELECT * FROM fl_dropship_pallet WHERE open_operation_id=#{operationId}")
PalletRow palletByOpenOperation(@Param("operationId") String operationId);
@Select("SELECT * FROM fl_dropship_pallet WHERE pallet_no=#{palletNo}") PalletRow palletByNo(@Param("palletNo") String palletNo);
@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}")
PalletRow palletBySealOperation(@Param("operationId") String operationId);
@Select("""
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}")
PalletRow pallet(@Param("taskId") long taskId, @Param("palletId") long palletId,
@Param("warehouseId") long warehouseId);
@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,
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")
List<PalletRow> pallets(@Param("taskId") long taskId);
@Update("""
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("""
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);
@Update("""
UPDATE fl_dropship_pallet SET pallet_status='SEALED',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})
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")
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.pallet_id=#{palletId} ORDER BY c.sku_code,c.inbound_detail_id,c.id") List<CartonRow> cartons(@Param("palletId") long palletId);
@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 l.id carton_label_id,l.inbound_detail_id,l.sku_code,l.cargo_name,l.detail_package_count,
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 p.id pallet_id,p.pallet_no,p.pallet_status,p.tenant_id account_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 destination_warehouse_code,o.destination_name destination_warehouse_name,o.customer_name,
c.inbound_detail_id,c.sku_code,MAX(c.cargo_name) cargo_name,COUNT(c.id) carton_count,
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,
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("""
SELECT l.id carton_label_id,l.inbound_detail_id,l.sku_code,l.cargo_name,l.detail_package_count,
l.inbound_code,l.box_code
FROM fl_inbound_carton_label l
WHERE l.tenant_id=#{tenantId} AND l.inbound_id=#{inboundId}
AND l.inbound_detail_id=#{detailId} AND l.batch_status='ACTIVE'
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;
}
@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; }
@Getter @Setter class PalletRow { private Long id,taskId,tenantId,inboundId,warehouseId; private String palletNo,palletStatus,sealOperationId; private Integer version; private LocalDateTime completedAt; }
@Getter @Setter class LabelRow { private Long cartonLabelId,inboundDetailId; private String skuCode,cargoName,inboundCode,boxCode; private Integer unitsPerCarton; }
@Getter @Setter class CartonRow { private Long id,taskId,palletId,inboundId,inboundDetailId; private String palletNo,skuCode,cargoName,boxCode,discrepancyStatus,discrepancyRemark; private Integer expectedSkuQuantity,actualSkuQuantity; }
record PalletMetrics(BigDecimal weightKg,BigDecimal volumeCbm) {}
@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; }
}
@@ -91,7 +91,7 @@ public interface LoadingCompletedEventMapper {
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.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
""")
List<PalletRow> actualPalletRows(@Param("planId") long planId);
@@ -99,7 +99,7 @@ public interface LoadingCompletedEventMapper {
@Select("""
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
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);
@@ -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,
i.inbound_id, i.inbound_detail_id, i.inbound_code, b.container_no,
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),
CEIL(COALESCE(NULLIF(inbound.pallet_count,0),
(SELECT COUNT(*) FROM fl_inbound_weight_result weight
@@ -420,6 +421,10 @@ public interface LoadingListQueryMapper {
private String productNameZh;
private String productNameEn;
private String productImageUrl;
private String unitType;
private Long palletId;
private String palletNo;
private Boolean indivisible;
private Integer packageCount;
private Integer palletCount;
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(*),
COALESCE(batch.total_volume_cbm/NULLIF(source.total_package_count,0),0)
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
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
@@ -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(*),
COALESCE(batch.total_volume_cbm/NULLIF(source.total_package_count,0),0)
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
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
@@ -187,7 +187,8 @@ public class LoadingListRepository {
row.getOrderId(), row.getOrderNo(), row.getInboundId(), row.getInboundDetailId(),
row.getInboundCode(), row.getContainerNo(), row.getSkuCode(),
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.getDimensionalWeightKg()), value(row.getProductQuantity()),
integer(row.getUnitsPerCarton()), row.getFbaTrackingNo(), row.getShipmentNo(), row.getShippingMark(),
@@ -97,6 +97,11 @@ public class SmartLoadingRepository {
.eq(LoadingPlanItemEntity::getInboundId, inboundId)
.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 planByBooking(long bookingId) {
return plans.selectOne(new LambdaQueryWrapper<LoadingPlanEntity>()
@@ -121,6 +126,13 @@ public class SmartLoadingRepository {
.eq(LoadingPlanItemEntity::getInboundDetailId, detailId)
.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) {
return histories.selectOne(new LambdaQueryWrapper<LoadingPlanHistoryEntity>()
.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.exception.BizException;
import com.baiyue.firstleg.dto.DropshipPalletizationDtos;
import com.baiyue.firstleg.dto.DropshipPalletizationDtos.Pallet;
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.dto.DropshipPalletizationDtos.*;
import com.baiyue.firstleg.entity.InboundOrderEntity;
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 com.baiyue.firstleg.mapper.DropshipPalletizationMapper.*;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.math.BigDecimal;
import java.util.*;
/** 测量完成即下发的一件代发合托,不创建国内仓库托盘、库位或库存。 */
/** 一件代发测量后的逐箱清点、待绑托和封托,不创建第二套装柜工作托盘。 */
@Service
public class DropshipPalletizationService {
@Resource private DropshipPalletizationMapper mapper;
@Resource private FirstlegDocumentNumberGenerator numbers;
@Resource private DropshipPalletizationMapper mapper;
public void createTask(InboundOrderEntity inbound) {
if (!"ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())) return;
if (inbound.getTotalPackageCount() == null || inbound.getTotalPackageCount() < 1) {
throw invalid("一件代发测量完成箱数无效,不能下发合托任务");
}
mapper.insertTask(IdWorker.getId(), inbound.getTenantId(), inbound.getId(),
inbound.getWarehouseId(), inbound.getTotalPackageCount());
}
public void createTask(InboundOrderEntity inbound) {
if (!"ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())) return;
if (inbound.getTotalPackageCount()==null || inbound.getTotalPackageCount()<1) throw invalid("一件代发测量完成箱数无效,不能下发清点组托任务");
mapper.insertTask(IdWorker.getId(),inbound.getTenantId(),inbound.getId(),inbound.getWarehouseId(),inbound.getTotalPackageCount());
}
public List<DropshipPalletizationDtos.PendingTask> pending(long operatorId, String keyword) {
return mapper.pending(operatorId, normalize(keyword));
}
public List<PendingTask> pending(long operatorId,String keyword){ return mapper.pending(operatorId,normalize(keyword)); }
@Transactional
public Task claim(long taskId, long warehouseId, long operatorId, String operatorName) {
if (mapper.claim(taskId, warehouseId, operatorId, operatorName) != 1) {
throw conflict("任务已被其他人员领取或状态已变化");
}
return task(taskId, warehouseId, operatorId);
}
@Transactional
public Task claim(long taskId,long warehouseId,long operatorId,String operatorName){
if(mapper.claim(taskId,warehouseId,operatorId,operatorName)!=1) throw conflict("任务已被其他人员领取或状态已变化");
return task(taskId,warehouseId,operatorId);
}
public Task task(long taskId, long warehouseId, long operatorId) {
TaskRow row = requireTask(taskId, warehouseId);
if (row.getAssigneeId() != null && row.getAssigneeId() != operatorId) {
throw conflict("任务已由其他人员领取");
}
return view(row);
}
public Task task(long taskId,long warehouseId,long operatorId){
TaskRow row=requireTask(taskId,warehouseId);
if(row.getAssigneeId()!=null && row.getAssigneeId()!=operatorId) throw conflict("任务已由其他人员领取");
return view(row);
}
@Transactional
public Pallet open(long taskId, DropshipPalletizationDtos.OpenPalletRequest request,
long operatorId, String operatorName) {
TaskRow task = requireWorkingTask(taskId, request.warehouseId(), operatorId);
String operationId = request.operationId().trim();
PalletRow replay = mapper.palletByOpenOperation(operationId);
if (replay != null) {
if (replay.getTaskId() != taskId) throw conflict("开托幂等号已用于其他任务");
return palletView(replay, task.getInboundCode());
}
long id = IdWorker.getId();
mapper.insertPallet(id, taskId, task.getTenantId(), task.getInboundId(), task.getWarehouseId(),
operationId, operatorId, operatorName);
return palletView(mapper.pallet(taskId, id, request.warehouseId()), task.getInboundCode());
}
public Carton resolveCarton(long taskId,long warehouseId,String boxCode,long operatorId){
TaskRow task=requireTask(taskId,warehouseId);
if(task.getAssigneeId()!=null && task.getAssigneeId()!=operatorId) throw conflict("任务已由其他人员领取");
LabelRow label=requireLabel(taskId,boxCode);
CartonRow counted=mapper.countedByLabel(taskId,label.getCartonLabelId());
if(counted!=null) return cartonView(counted,task.getInboundCode(),false,"箱码已清点");
String block=null;
if(!"CLAIMED".equals(task.getTaskStatus())) block="请先领取任务";
else if(!Objects.equals(task.getAssigneeId(),operatorId)) block="任务不属于当前操作员";
else if(!"WAIT_PALLETIZE".equals(task.getProcessStatus())) block="入仓单已不在待清点组托状态";
return new Carton(null,label.getBoxCode(),task.getInboundId(),label.getInboundCode(),label.getInboundDetailId(),
label.getSkuCode(),label.getCargoName(),label.getUnitsPerCarton(),null,null,null,"UNCOUNTED",null,null,
block==null,block);
}
@Transactional
public ScanResult scan(long taskId, long palletId, DropshipPalletizationDtos.ScanRequest request,
long operatorId, String operatorName) {
TaskRow task = requireWorkingTask(taskId, request.warehouseId(), operatorId);
PalletRow pallet = requireOpenPallet(taskId, palletId, request.warehouseId());
String operationId = request.operationId().trim();
LabelRow scanned = mapper.label(taskId, request.boxCode().trim());
if (scanned == null) throw new BizException(ResultCode.NOT_FOUND, "箱唛不属于当前一件代发入仓单");
if (!StringUtils.hasText(scanned.getSkuCode())) throw conflict("箱唛缺少SKU,不能合托");
if (mapper.operationCount(operationId) > 0) {
return scanResult(scanned, request.cartonCount(), task, pallet);
}
List<LabelRow> labels = mapper.unboundLabels(taskId, task.getTenantId(), task.getInboundId(),
scanned.getInboundDetailId(), scanned.getBoxCode(), request.cartonCount());
if (labels.size() != request.cartonCount()) {
throw conflict("本SKU剩余未上托箱数不足,请刷新任务后重试");
}
for (LabelRow label : labels) {
mapper.insertCarton(IdWorker.getId(), taskId, palletId, task.getTenantId(), task.getInboundId(),
label.getInboundDetailId(), label.getCartonLabelId(), label.getSkuCode(), label.getCargoName(),
label.getBoxCode(), operationId, operatorId, operatorName);
}
if (mapper.addAllocated(taskId, operatorId, labels.size()) != 1) {
throw conflict("任务箱数已变化,请刷新后重试");
}
TaskRow refreshed = requireTask(taskId, request.warehouseId());
return scanResult(scanned, labels.size(), refreshed,
mapper.pallet(taskId, palletId, request.warehouseId()));
}
@Transactional
public CountResult countCarton(long taskId,CountCartonRequest request,long operatorId,String operatorName){
TaskRow task=requireWorkingTask(taskId,request.warehouseId(),operatorId);
String operation=operation(request.operationId());
CartonRow replay=mapper.cartonByCountOperation(operation);
if(replay!=null){
if(!Objects.equals(replay.getTaskId(),taskId) || !Objects.equals(replay.getBoxCode(),request.boxCode().trim())
|| !Objects.equals(replay.getActualSkuQuantity(),request.actualSkuQuantity())) throw conflict("清点幂等号已用于不同操作");
return countResult(task,replay);
}
LabelRow label=requireLabel(taskId,request.boxCode());
if(mapper.countedByLabel(taskId,label.getCartonLabelId())!=null) throw conflict("箱码已清点,不能重复清点");
int expected=requireExpectedQuantity(label);
boolean mismatch=expected!=request.actualSkuQuantity();
String remark=normalize(request.discrepancyRemark());
if(!mismatch && request.discrepancyAction()!=DiscrepancyAction.MATCH) throw invalid("实收数量一致时差异动作必须为 MATCH");
if(mismatch && request.discrepancyAction()!=DiscrepancyAction.ACCEPT_VARIANCE) throw conflict("实收数量存在差异,请确认差异处理结果");
if(mismatch && !StringUtils.hasText(remark)) throw invalid("接受数量差异时必须填写处理说明");
PalletRow pallet=StringUtils.hasText(request.palletNo())
? getOrCreatePallet(task,request.palletNo().trim(),operation,operatorId,operatorName):null;
long cartonId=IdWorker.getId();
mapper.insertCarton(cartonId,taskId,pallet==null?null:pallet.getId(),task.getTenantId(),task.getInboundId(),
label.getInboundDetailId(),label.getCartonLabelId(),label.getSkuCode(),label.getCargoName(),label.getBoxCode(),
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());
return countResult(refreshed,mapper.carton(taskId,cartonId));
}
@Transactional
public Task seal(long taskId, long palletId, DropshipPalletizationDtos.SealPalletRequest request,
long operatorId, String operatorName) {
TaskRow task = requireWorkingTask(taskId, request.warehouseId(), operatorId);
PalletRow replay = mapper.palletBySealOperation(request.operationId().trim());
if (replay != null) {
if (replay.getTaskId() != taskId || replay.getId() != palletId) {
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()));
}
@Transactional
public Task bindPending(long taskId,BindPendingRequest request,long operatorId,String operatorName){
TaskRow task=requireWorkingTask(taskId,request.warehouseId(),operatorId);
String operation=operation(request.operationId());
if(mapper.bindOperationCount(taskId,operation)>0) return view(requireTask(taskId,request.warehouseId()));
PalletRow pallet=getOrCreatePallet(task,request.palletNo().trim(),operation,operatorId,operatorName);
int expected=mapper.unboundCount(taskId);
if(expected==0) throw conflict("当前没有待绑托箱");
int bound=mapper.bindPending(taskId,pallet.getId(),operation,operatorId,operatorName);
if(bound!=expected || mapper.adjustBound(taskId,operatorId,bound)!=1) throw conflict("待绑托箱已变化,请刷新后重试");
return view(requireTask(taskId,request.warehouseId()));
}
public List<Pallet> inboundPallets(long tenantId, long inboundId) {
String inboundCode = mapper.inboundCode(tenantId, inboundId);
if (inboundCode == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
return mapper.palletsByInbound(tenantId, inboundId).stream()
.map(row -> palletView(row, inboundCode)).toList();
}
@Transactional
public Task moveCarton(long taskId,long cartonId,MoveCartonRequest request,long operatorId,String operatorName){
TaskRow task=requireWorkingTask(taskId,request.warehouseId(),operatorId);
CartonRow carton=mapper.carton(taskId,cartonId);
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) {
return new ScanResult(label.getSkuCode(), label.getCargoName(), label.getInboundCode(), added,
task.getAllocatedCartons(), task.getTotalCartons() - task.getAllocatedCartons(),
palletView(pallet, task.getInboundCode()));
}
@Transactional
public Task seal(long taskId,long palletId,SealPalletRequest request,long operatorId,String operatorName){
TaskRow task=requireWorkingTask(taskId,request.warehouseId(),operatorId);
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) {
return new Task(row.getId(), row.getInboundId(), row.getInboundCode(), row.getTenantId(),
row.getCustomerName(), row.getWarehouseId(), row.getWarehouseName(), row.getTotalCartons(),
row.getAllocatedCartons(), row.getTaskStatus(), row.getAssigneeId(), row.getAssigneeName(),
row.getVersion(), mapper.pallets(row.getId()).stream()
.map(pallet -> palletView(pallet, row.getInboundCode())).toList());
}
public List<Pallet> inboundPallets(long tenantId,long inboundId){
String code=mapper.inboundCode(tenantId,inboundId);
if(code==null) throw new BizException(ResultCode.NOT_FOUND,"入库单不存在");
return mapper.palletsByInbound(tenantId,inboundId).stream().map(p->palletView(p,code)).toList();
}
private Pallet palletView(PalletRow row, String inboundCode) {
List<CartonRow> cartons = mapper.cartons(row.getId());
Map<String, List<CartonRow>> groups = new LinkedHashMap<>();
for (CartonRow carton : cartons) {
groups.computeIfAbsent(carton.getInboundDetailId() + "|" + carton.getSkuCode(), ignored -> new ArrayList<>())
.add(carton);
}
List<SkuItem> items = groups.values().stream().map(values -> {
CartonRow first = values.get(0);
return new SkuItem(first.getInboundId(), inboundCode, first.getInboundDetailId(), first.getSkuCode(),
first.getCargoName(), values.size(), values.stream().map(CartonRow::getBoxCode).toList());
}).toList();
return new Pallet(row.getId(), row.getPalletNo(), row.getPalletStatus(), cartons.size(), items.size(),
row.getVersion(), items, row.getCompletedAt());
}
private PalletRow getOrCreatePallet(TaskRow task,String palletNo,String operation,long operatorId,String operatorName){
PalletRow existing=mapper.palletByNo(palletNo);
if(existing!=null){
if(!Objects.equals(existing.getTaskId(),task.getId()) || !Objects.equals(existing.getTenantId(),task.getTenantId())) throw conflict("托盘码已被其他客户或入仓任务占用");
if(!"OPEN".equals(existing.getPalletStatus())) throw conflict("托盘已封托,不能继续绑定箱");
return existing;
}
try{ mapper.insertPallet(IdWorker.getId(),task.getId(),task.getTenantId(),task.getInboundId(),task.getWarehouseId(),palletNo,operation,operatorId,operatorName); }
catch(DuplicateKeyException e){ existing=mapper.palletByNo(palletNo); if(existing==null) throw conflict("托盘码已被并发占用"); }
existing=mapper.palletByNo(palletNo);
if(existing==null || !Objects.equals(existing.getTaskId(),task.getId())) throw conflict("托盘码已被其他任务占用");
return existing;
}
private TaskRow requireWorkingTask(long taskId, long warehouseId, long operatorId) {
TaskRow task = requireTask(taskId, warehouseId);
if (!"CLAIMED".equals(task.getTaskStatus())) throw conflict("请先领取待合托任务");
if (task.getAssigneeId() == null || task.getAssigneeId() != operatorId) throw conflict("任务不属于当前操作员");
if (!"WAIT_PALLETIZE".equals(task.getProcessStatus())) throw conflict("入仓单已不在待合托状态");
return task;
}
private CountResult countResult(TaskRow task,CartonRow carton){
Pallet p=carton.getPalletId()==null?null:palletView(mapper.pallet(task.getId(),carton.getPalletId(),task.getWarehouseId()),task.getInboundCode());
return new CountResult(cartonView(carton,task.getInboundCode(),false,null),task.getCountedCartons(),task.getAllocatedCartons(),
task.getCountedCartons()-task.getAllocatedCartons(),task.getTotalCartons()-task.getCountedCartons(),p);
}
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 Task view(TaskRow row){
return new Task(row.getId(),row.getInboundId(),row.getInboundCode(),row.getTenantId(),row.getCustomerName(),row.getWarehouseId(),row.getWarehouseName(),
row.getTotalCartons(),row.getCountedCartons(),row.getAllocatedCartons(),row.getCountedCartons()-row.getAllocatedCartons(),row.getDiscrepancyCartons(),
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) {
PalletRow pallet = mapper.pallet(taskId, palletId, warehouseId);
if (pallet == null) throw new BizException(ResultCode.NOT_FOUND, "托盘不存在");
if (!"OPEN".equals(pallet.getPalletStatus())) throw conflict("托盘已完成,不能继续上托");
return pallet;
}
private Pallet palletView(PalletRow row,String inboundCode){
List<CartonRow> cartons=mapper.cartons(row.getId()); Map<String,List<CartonRow>> groups=new LinkedHashMap<>();
for(CartonRow c:cartons) groups.computeIfAbsent(c.getInboundDetailId()+"|"+c.getSkuCode(),ignored->new ArrayList<>()).add(c);
List<SkuItem> items=groups.values().stream().map(values->{CartonRow first=values.get(0);return new SkuItem(first.getInboundId(),inboundCode,
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 static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); }
private static BizException invalid(String message) { return new BizException(ResultCode.VALIDATION_FAILED, message); }
private Carton cartonView(CartonRow row,String inboundCode,boolean canCount,String blockReason){
return new Carton(row.getId(),row.getBoxCode(),row.getInboundId(),inboundCode,row.getInboundDetailId(),row.getSkuCode(),row.getCargoName(),
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())) {
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()
.filter(cargo -> java.util.Objects.equals(cargo.cargoId(), label.getInboundDetailId()))
.findFirst().ifPresent(cargo -> repository.initializeLegacySku(tenantId, inboundId,
@@ -211,6 +215,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
label.setCargoItemNo(cargo.itemNo());
label.setCargoName(cargo.cargoName());
label.setSkuCode(cargo.skuNo());
label.setUnitsPerCarton(cargo.unitsPerCarton());
label.setDetailPackageCount(cargo.packageCount());
label.setInboundCode(inbound.getInboundCode());
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()),
cargo.cargoName() == null ? "" : cargo.cargoName().trim(),
cargo.skuNo() == null ? "" : cargo.skuNo().trim(), String.valueOf(cargo.packageCount()),
String.valueOf(cargo.unitsPerCarton()),
decimalOrEmpty(cargo.lengthCm()), decimalOrEmpty(cargo.widthCm()), decimalOrEmpty(cargo.heightCm())))
.collect(java.util.stream.Collectors.joining("\n"));
try {
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.LoadingPickDtos.PickItem;
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.SkipRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.Task;
@@ -106,6 +107,8 @@ public class LoadingPickService {
private InboundPalletAllocationRepository palletAllocations;
@Resource
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. */
@Transactional
@@ -326,6 +329,7 @@ public class LoadingPickService {
if (item == null) {
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在");
}
requirePackageUnit(item);
if (repository.pick(itemId, request.pickedPackageCount(), request.version(), request.warehouseId()) != 1) {
throw conflict("拣货数量超过计划箱数或明细版本已变化");
}
@@ -344,6 +348,41 @@ public class LoadingPickService {
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
public Task partial(long taskId, long itemId, PartialPickRequest request,
long operatorId, String operatorName) {
@@ -364,7 +403,8 @@ public class LoadingPickService {
assertReplay(replay, taskId, itemId, requestHash);
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(),
request.warehouseId()) != 1) {
throw conflict("部分拣货箱数必须大于已拣箱数且小于计划箱数,或明细版本已变化");
@@ -397,6 +437,7 @@ public class LoadingPickService {
return view(task, request.warehouseId());
}
LoadingPickItemEntity item = requireWarehouseItem(taskId, itemId, request.warehouseId());
requirePackageUnit(item);
if (repository.hasPickedOrPartialItem(taskId, item.getInboundId())) {
throw conflict("同一入库单已存在已拣或部分拣货明细,不能跳拣并恢复智能装柜候选");
}
@@ -443,6 +484,7 @@ public class LoadingPickService {
List<BatchLine> lines = validateBatchLines(task, request);
validateInventorySnapshot(task, request, lines);
lines.forEach(line -> requirePackageUnit(line.item()));
for (BatchLine line : lines) {
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) {
if (repository.touchTask(task.getId(), task.getVersion()) != 1) {
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
public class MerchantInboundBillingService {
private static final Set<String> PUBLISHED_BILLING_PROCESS_STATUSES = Set.of(
InboundProcessStatus.WAIT_PALLETIZE,
InboundProcessStatus.WAIT_SPLIT,
InboundProcessStatus.LOADING,
InboundProcessStatus.LOADED,
@@ -54,6 +54,7 @@ public class SmartLoadingService {
"OA_NEW_YORK_TRUCK", Set.of("OA_NEW_YORK_TRUCK")
);
@Resource private SmartLoadingRepository repository;
@Resource private com.baiyue.firstleg.mapper.DropshipPalletizationMapper dropshipPallets;
@Resource private OrderCargoClient orderCargoClient;
@Resource private PendingReviewOrderClient pendingReviewOrders;
@Resource private InboundOrderCreationService inboundOrderCreationService;
@@ -111,7 +112,7 @@ public class SmartLoadingService {
Map<Long, List<Candidate>> grouped = candidates(bookingId, warehouseId).stream()
.collect(java.util.stream.Collectors.groupingBy(Candidate::inboundId, LinkedHashMap::new,
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) {
@@ -121,7 +122,7 @@ public class SmartLoadingService {
repository.planByBooking(booking.getId()), values).stream()
.collect(java.util.stream.Collectors.groupingBy(Candidate::inboundId, LinkedHashMap::new,
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. */
@@ -175,7 +176,7 @@ public class SmartLoadingService {
List<InboundCandidate> all = repository.globalCandidatesByUnits(unitPage.records()).stream()
.collect(java.util.stream.Collectors.groupingBy(Candidate::inboundId, LinkedHashMap::new,
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<Long, List<InboundCandidate>> grouped = all.stream()
.filter(candidate -> candidate.coLoadGroupId() != null)
@@ -216,6 +217,7 @@ public class SmartLoadingService {
new InboundCandidate(row.customerId(), null, row.orderId(), row.orderNo(), row.inboundCode(),
row.warehouseId(), row.channelCode(), row.channelName(), null, null, null,
row.customerName(), null, row.totalPackageCount(), null, null, null, null,
"PACKAGE", null, null, false,
null, null, null, null, false, true, null,
"PENDING_REVIEW", row.expectedArrivalAt(), List.of())).toList();
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()));
}
private InboundCandidate inboundCandidate(List<Candidate> details) {
private List<InboundCandidate> inboundCandidates(List<Candidate> details) {
Candidate first = details.get(0);
int packages = details.stream().map(Candidate::availablePackageCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
@@ -279,13 +281,23 @@ public class SmartLoadingService {
BigDecimal.valueOf(packages), 6, RoundingMode.HALF_UP);
String goods = details.stream().map(Candidate::goodsName).filter(StringUtils::hasText)
.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.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(),
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.expectedArrivalAt(), List.of());
first.expectedArrivalAt(), List.of()));
}
private InboundCandidate globalGroupCandidate(List<InboundCandidate> availableMembers) {
@@ -317,7 +329,7 @@ public class SmartLoadingService {
return new InboundCandidate(first.accountId(), first.inboundId(), first.orderId(), first.orderNo(),
first.inboundCode(), first.warehouseId(), first.channelCode(), first.channelName(),
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(),
occupied,
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",
resourceId = "#planId", description = "分配智能装柜明细")
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,
boolean wholeGroupAssignment) {
boolean wholeGroupAssignment, Long palletId, String palletNo,
Integer actualSkuQuantity, boolean primaryPalletLine, Candidate frozenCandidate) {
String operation = operation(request.operationId());
String requestHash = sha256("ASSIGN|" + planId + '|'
+ request.inboundId() + '|' + request.inboundDetailId() + '|' + request.packageCount()
@@ -431,8 +484,14 @@ public class SmartLoadingService {
if (replay != null) return replay;
LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName);
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("入库货物不存在或尚未完成入库");
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());
if (!wholeGroupAssignment) requireStandalone(candidate.accountId(), candidate.inboundId());
requireSameCustomerScope(booking, candidate);
@@ -445,7 +504,9 @@ public class SmartLoadingService {
operatorId, operatorName);
}
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 availableIncludingCurrent = candidate.availablePackageCount()
+ (existing == null ? 0 : existing.getPackageCount());
@@ -457,6 +518,14 @@ public class SmartLoadingService {
LoadingPlanItemEntity changed;
if (existing == null) {
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("货物明细已被并发锁定"); }
changed = item;
} else {
@@ -617,7 +686,7 @@ public class SmartLoadingService {
.substring(0, 48);
assign(planId, new AssignRequest(request.inboundId(), allocation.candidate().inboundDetailId(),
allocation.packageCount(), current.getVersion(), childOperation), operatorId, operatorName,
wholeGroupAssignment);
wholeGroupAssignment, null, null, null, false, null);
sequence++;
}
LoadingPlanEntity assigned = repository.plan(planId);
@@ -859,6 +928,9 @@ public class SmartLoadingService {
LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion(), operatorId, operatorName);
LoadingPlanItemEntity item = repository.item(planId, itemId);
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());
requireStandalone(item.getAccountId(), item.getInboundId());
if (!Objects.equals(item.getVersion(), request.expectedItemVersion())) throw conflict("装柜明细版本冲突");
@@ -971,6 +1043,9 @@ public class SmartLoadingService {
LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion(), operatorId, operatorName);
LoadingPlanItemEntity item = repository.item(planId, itemId);
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());
loadingPickService.remove(itemId);
touch(plan, operatorId, operatorName);
@@ -982,6 +1057,34 @@ public class SmartLoadingService {
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
@BusinessAudit(event = "SMART_LOADING_UNPICKED_REMOVE", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "批量移除智能装柜未拣明细")
@@ -1461,7 +1564,40 @@ public class SmartLoadingService {
: "COMPLETED".equals(task.taskStatus()) ? "COMPLETED" : "IN_PROGRESS";
return new Plan(p.getId(),p.getPlanNo(),p.getPlanStatus(),p.getContainerBookingId(),
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) {
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_name VARCHAR(255) NULL COMMENT '货物名称快照',
sku_code VARCHAR(128) NULL COMMENT '箱唛绑定的SKU快照;二维码内容不变',
units_per_carton INT UNSIGNED NULL COMMENT '每箱SKU数量快照;一件代发必须为正数',
detail_package_count INT UNSIGNED NOT NULL COMMENT '该订单货物明细箱数快照',
inbound_code VARCHAR(40) 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_name VARCHAR(255) NULL COMMENT '货物名称快照',
sku_code VARCHAR(128) NULL COMMENT '箱唛绑定的SKU快照;二维码内容不变',
units_per_carton INT UNSIGNED NULL COMMENT '每箱SKU数量快照;一件代发必须为正数',
detail_package_count INT UNSIGNED NOT NULL COMMENT '该订单货物明细箱数快照',
inbound_code VARCHAR(40) 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',
task_status VARCHAR(24) NOT NULL COMMENT '任务状态:PENDING待领取、CLAIMED已领取、COMPLETED已完成',
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 '已上托箱数',
discrepancy_cartons INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已确认差异箱数',
assignee_id BIGINT UNSIGNED NULL COMMENT '任务领取人ID',
assignee_name VARCHAR(64) 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),
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_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合托任务';
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 '客户隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '一件代发入仓单ID',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '作业仓库ID',
pallet_no VARCHAR(32) NULL COMMENT '封托时生成的DP托盘号',
pallet_status VARCHAR(16) NOT NULL COMMENT '托盘状态:OPEN合托中、COMPLETED已封托',
pallet_no VARCHAR(32) NOT NULL COMMENT '现场扫描的系统DP托盘码',
pallet_status VARCHAR(16) NOT NULL COMMENT '托盘状态:OPEN组托中、SEALED已封托',
open_operation_id VARCHAR(128) NOT NULL COMMENT '创建托盘幂等操作ID',
seal_operation_id VARCHAR(128) 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),
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 chk_dropship_pallet_status CHECK (pallet_status IN ('OPEN','COMPLETED'))
CONSTRAINT chk_dropship_pallet_status CHECK (pallet_status IN ('OPEN','SEALED'))
) ENGINE=InnoDB COMMENT='一件代发合托形成的可打印托盘';
CREATE TABLE IF NOT EXISTS fl_dropship_pallet_carton (
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 '客户隔离键',
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',
sku_code VARCHAR(128) NOT NULL COMMENT '箱唛冻结SKU编码',
cargo_name VARCHAR(255) NULL COMMENT '货物名称快照',
box_code VARCHAR(64) NOT NULL COMMENT '稳定箱码',
operation_id VARCHAR(128) NOT NULL COMMENT '上托幂等操作ID',
bound_by BIGINT UNSIGNED NOT NULL COMMENT '上托操作人ID',
box_code VARCHAR(80) NOT NULL COMMENT '稳定箱码',
expected_sku_quantity INT UNSIGNED NOT NULL COMMENT '箱唛冻结的应收SKU数量',
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_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上托时间',
bound_at DATETIME(3) NULL COMMENT '上托时间',
PRIMARY KEY (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_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_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)
@@ -267,6 +267,10 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item (
product_image_url VARCHAR(1000) NULL COMMENT '产品图片访问地址快照',
package_count INT UNSIGNED NOT 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 '每箱产品数',
pallet_count INT UNSIGNED NULL COMMENT '托盘数',
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_at DATETIME(3) NULL 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_name VARCHAR(64) NULL 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 '装柜明细最后更新时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
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_plan (loading_plan_id, deleted, load_sequence, 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_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),
CONSTRAINT fk_loading_item_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id),
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)
) 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;
DELIMITER $$
CREATE PROCEDURE repair_phase3_loading_pick_task_foreign_keys()
@@ -1478,12 +1424,13 @@ BEGIN
'destination_warehouse_name', 'product_name_zh', 'product_name_en',
'product_image_file_id', 'pallet_count', 'net_weight_kg',
'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 = '智能装柜迁移失败:明细归属、清单快照或冻结字段缺失';
END IF;
IF (SELECT COUNT(*) FROM information_schema.statistics
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 = '智能装柜迁移失败:计划内唯一活动明细锁缺失';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
@@ -1,54 +1,153 @@
openapi: 3.0.3
info:
title: 一件代发 PDA 合托 API
version: 1.0.0
description: 测量完成后独立下发;不创建国内库存。PDA 扫箱唛识别 SKU,并按箱数合托。
title: 一件代发国内清点组托 API
version: 2.0.0
description: 测量、计费后独立下发。箱码逐箱确认 SKU 实收数量;托盘码可先扫或后扫,封托后作为不可拆分柜单元。
paths:
/api/platform/v1/first-leg/pda-dropship/tasks/pending:
get:
summary: 查询待领取或本人已领取的一件代发合托任务
responses: { '200': { description: OK } }
summary: 查询待领取或本人已领取任务
x-required-authority: platform:firstleg:pda:view
responses: {'200': {description: OK}}
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}:
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:
- { name: taskId, in: path, required: true, schema: { type: integer, format: int64 } }
- { name: warehouseId, in: query, required: true, schema: { type: integer, format: int64 } }
responses: { '200': { description: OK } }
- {$ref: '#/components/parameters/TaskId'}
- {$ref: '#/components/parameters/WarehouseId'}
- {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:
post:
summary: 领取合托任务
parameters: [{ name: taskId, in: path, required: true, schema: { type: integer, format: int64 } }]
responses: { '200': { description: OK } }
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/open:
summary: 领取任务
x-required-authority: platform:firstleg:pda:work
parameters: [{$ref: '#/components/parameters/TaskId'}]
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:
summary: 开启一个现场托盘
parameters: [{ name: taskId, in: path, required: true, schema: { type: integer, format: int64 } }]
responses: { '200': { description: OK } }
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/{palletId}/scan:
summary: 逐箱确认 SKU 实收数量,可同时绑定当前托盘
description: palletNo 为空时进入 COUNTED_UNBOUND;不为空时幂等获取或创建 OPEN 托盘并绑定。数量不一致必须 ACCEPT_VARIANCE 且填写说明。
x-required-authority: platform:firstleg:pda:work
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:
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:
- { name: taskId, in: path, required: true, schema: { type: integer, format: int64 } }
- { name: palletId, in: path, required: true, schema: { type: integer, format: int64 } }
responses: { '200': { description: OK } }
- {$ref: '#/components/parameters/TaskId'}
- {name: cartonId, in: path, required: true, schema: {type: integer, format: int64}}
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:
post:
summary: 完成合托并生成 DP 托盘号
summary: 封托;禁止空托、待绑箱、版本冲突和重复归托
x-required-authority: platform:firstleg:pda:work
parameters:
- { name: taskId, in: path, required: true, schema: { type: integer, format: int64 } }
- { name: palletId, in: path, required: true, schema: { type: integer, format: int64 } }
responses: { '200': { description: OK } }
- {$ref: '#/components/parameters/TaskId'}
- {name: palletId, in: path, required: true, schema: {type: integer, format: int64}}
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:
get:
summary: 入仓单查看绑定的一个或多个托盘及各自箱数
parameters: [{ name: inboundId, in: path, required: true, schema: { type: integer, format: int64 } }]
responses: { '200': { description: OK } }
summary: Admin 查询入仓单权威托盘结果
x-required-authority: firstleg:inbound:list
responses: {'200': {description: OK}}
/api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets/{palletId}/print:
get:
summary: 打印托盘号、入仓码和 SKU 箱数托盘唛
parameters:
- { name: inboundId, in: path, required: true, schema: { type: integer, format: int64 } }
- { name: palletId, in: path, required: true, schema: { type: integer, format: int64 } }
responses:
'200': { description: PDF, content: { application/pdf: { schema: { type: string, format: binary } } } }
summary: 打印托盘码、入仓码和 SKU 实收汇总
x-required-authority: firstleg:inbound:list
responses: {'200': {description: PDF}}
components:
parameters:
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:
summary: 按统一订单条件分页查询履约摘要(Order 内部)
description: >-
mainStatus 接受六态产品状态;WAIT_SPLIT 为入库完成后的唯一待分柜状态。
mainStatus 接受七态产品状态;WAIT_PALLETIZE 为一件代发测量计费后的待清点组托状态,WAIT_SPLIT 为待分柜状态。
计费与入库处理中状态在完成前仍归并为 WAIT_MEASURE,不提前开放分柜。
security:
- internalServiceToken: []
@@ -1133,7 +1133,7 @@ paths:
/api/platform/v1/inbounds/{inboundId}/measurements/approve:
post:
summary: 审核通过测量
description: 需要 firstleg:inbound:measurement-review 权限。审核通过后立即进入 WAIT_SPLIT,并按该次测量数据尽力生成 CALCULATED 初算费用;价格未覆盖或初算异常只返回非阻断提示,不影响审核结果和待分柜状态。
description: 需要 firstleg:inbound:measurement-review 权限。标准头程审核通过后进入 WAIT_SPLIT;一件代发进入 WAIT_PALLETIZE,完成逐箱清点和封托后再进入 WAIT_SPLIT。审核时按该次测量数据尽力生成 CALCULATED 初算费用;价格未覆盖或初算异常只返回非阻断提示。
parameters:
- {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]*$'}}
@@ -1150,7 +1150,7 @@ paths:
expectedVersion: {type: integer, minimum: 0}
reason: {type: string, maxLength: 1000}
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:
post:
summary: 提交测量完成
@@ -1164,7 +1164,7 @@ paths:
/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing:
get:
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:
- {name: orderId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
responses:
@@ -1383,8 +1383,8 @@ components:
invalidatedFeeId: {type: integer, format: int64, nullable: true}
UnifiedMainStatus:
type: string
enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_SPLIT, LOADING, LOADED, DELIVERED]
description: 六态产品主状态;WAIT_SPLIT 为入库完成后的唯一待分柜状态
enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, WAIT_SPLIT, LOADING, LOADED, DELIVERED]
description: 七态产品主状态;WAIT_PALLETIZE 仅表示一件代发待清点组托
UnifiedInboundQuery:
type: object
properties:
@@ -1393,7 +1393,7 @@ components:
orderNo: {type: string, maxLength: 64, nullable: true}
mainStatus:
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
description: WAIT_SPLIT 为唯一待分柜筛选值
customerId: {type: integer, format: int64, nullable: true}
@@ -1511,6 +1511,7 @@ components:
deliveryWarehouseName: {type: string, nullable: true}
businessType: {type: string, nullable: true}
fulfillmentType: {type: string, enum: [STANDARD, ONE_CLICK_DROPSHIP], nullable: true}
lxCode: {type: string, nullable: true, description: 一件代发线路或履约产品编码快照}
channelCode: {type: string, nullable: true}
channelName: {type: string, nullable: true}
taxMode: {type: string, nullable: true}
@@ -170,104 +170,11 @@ paths:
expectedVersion: {type: integer, minimum: 0}
reason: {type: string, minLength: 1, maxLength: 500}
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:
parameters:
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}}
PalletId: {name: palletId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
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:
type: object
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("Idempotency-Key")
.contains("初算异常只返回非阻断提示")
.contains("data.processStatus 固定为 WAIT_SPLIT")
.contains("标准头程审核通过后进入 WAIT_SPLIT")
.contains("一件代发进入 WAIT_PALLETIZE")
.contains("feeStatus=UNPRICED 且主流程仍成功")
.contains("name: inboundId")
.contains("schema: {type: string, pattern:")
@@ -24,13 +24,8 @@ class PlatformLoadingPdaControllerContractTest {
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("operations", "platform:firstleg:pda:view", AuthenticatedUser.class, long.class);
authority("pallets", "platform:firstleg:pda:view", AuthenticatedUser.class, long.class, long.class);
authority("openPallet", "platform:firstleg:pda:work", AuthenticatedUser.class, long.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);
authority("pickPallet", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class,
PalletPickRequest.class);
}
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 {
@Test
void documentsInternalUnifiedOrderEndpointsAndSixStatuses() throws Exception {
void documentsInternalUnifiedOrderEndpointsAndSevenStatuses() throws Exception {
String yaml = new ClassPathResource("openapi/firstleg-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
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> responseStatus = stringMap(schemas.get("UnifiedMainStatus"));
assertThat(stringList(responseStatus.get("enum"))).containsExactly(
"PENDING_REVIEW", "WAIT_MEASURE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED");
assertThat(responseStatus.get("description").toString()).contains("WAIT_SPLIT", "唯一待分柜状态");
"PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED");
assertThat(responseStatus.get("description").toString()).contains("WAIT_PALLETIZE", "待清点组托");
Map<String, Object> query = stringMap(schemas.get("UnifiedInboundQuery"));
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"));
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_index (tenant_id, inbound_id, label_version, carton_index)")
.contains("inbound_detail_id BIGINT UNSIGNED NOT NULL");
assertThat(migration).contains("CREATE TABLE IF NOT EXISTS fl_inbound_carton_label")
.contains("units_per_carton INT UNSIGNED NULL")
.contains("固定二维码内容:入仓码#总箱数-箱下标")
.contains("idx_carton_label_detail (tenant_id, inbound_id, inbound_detail_id)");
assertThat(versioningMigration).contains("phase2_carton_label_add_column('label_version'")
.contains("phase2_carton_label_add_column('batch_status'")
.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")
.contains("units_per_carton INT UNSIGNED NULL")
.contains("unified_carton_label_add_column('label_version'")
.contains("unified_carton_label_add_column('batch_status'")
.contains("unified_carton_label_add_column('cargo_fingerprint'")
@@ -53,7 +53,7 @@ class SmartLoadingCandidateMapperContractTest {
.contains("SUM(package_count) package_count")
.contains("GROUP BY tenant_id,measurement_id,inbound_detail_id")
.contains("fl_dropship_pallet_carton carton")
.contains("pallet.pallet_status='COMPLETED'")
.contains("pallet.pallet_status='SEALED'")
.contains("source.fulfillment_type='ONE_CLICK_DROPSHIP'")
.contains("i.inbound_id=o.id")
.contains("o.warehouse_id=#{warehouseId}")
@@ -67,7 +67,8 @@ class SmartLoadingSqlContractTest {
assertThat(migration())
.contains("active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS")
.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("UNIQUE KEY uk_container_booking_no (booking_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.Member;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper;
import com.baiyue.firstleg.mapper.LoadingPickItemMapper;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.repository.InboundPalletAllocationRepository;
@@ -103,6 +104,8 @@ class LoadingPickServiceTest {
private InboundPalletAllocationRepository palletAllocations;
@MockBean
private InboundHoldGuard inboundHoldGuard;
@MockBean
private DropshipPalletizationMapper dropshipPallets;
@Resource
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.port.in.PriceBookQueryUseCase;
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.PlanColoadMemberRow;
import com.baiyue.firstleg.split.service.SplitPreloadService;
@@ -95,6 +96,7 @@ class SmartLoadingServiceTest {
@MockBean private ColoadGroupAssignmentPort coloadGroups;
@MockBean private PriceBookQueryUseCase priceBooks;
@MockBean private FirstlegDocumentNumberGenerator documentNumbers;
@MockBean private DropshipPalletizationMapper dropshipPallets;
@Resource private SmartLoadingService service;
@TestConfiguration
@@ -48,7 +48,8 @@ public interface FirstlegUnifiedOrderClient {
Long deliveryWarehouseId, String deliveryWarehouseName, String channelCode, String channelName,
Integer totalPackageCount, String locationCodes,
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 holdFlag, String currentHoldReason,
BigDecimal actualGrossWeightKg, BigDecimal actualNetWeightKg, BigDecimal actualVolumeCbm,
@@ -72,13 +72,27 @@ public class InternalOrderCargoController {
if (tenantId <= 0 || orderId <= 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()
.map(cargo -> new CargoLabelBinding(cargo.getId(), orderId, cargo.getItemNo(),
cargo.getCargoName(), cargo.getSkuNo(), cargo.getPackageCount(), cargo.getLengthCm(),
cargo.getWidthCm(), cargo.getHeightCm()))
cargo.getCargoName(), cargo.getSkuNo(), cargo.getPackageCount(),
unitsPerCarton(cargo, oneClickDropship), cargo.getLengthCm(), cargo.getWidthCm(),
cargo.getHeightCm()))
.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")
public ApiResponse<FirstlegInboundClient.OrderApprovedInboundCommand> inboundSnapshot(
@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,
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) {
public CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer packageCount, java.math.BigDecimal lengthCm,
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
@PreAuthorize("hasAuthority('platform:order:list')")
@Operation(summary = "统一订单列表", description = "返回七态产品主状态及测量、预装、装柜、派送摘要。")
@Operation(summary = "统一订单列表", description = "返回八态产品主状态及测量、组托、预装、装柜、派送摘要。")
public ApiResponse<PageResult<UnifiedOrderResponse.Row>> page(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) Long tenantId,
@@ -12,8 +12,8 @@ import java.time.LocalDateTime;
public record UnifiedOrderQuery(
@Size(max = 64) String keyword,
@Size(max = 64) String orderNo,
@Schema(allowableValues = {"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED",
"LOADING", "LOADED", "DELIVERED"})
@Schema(allowableValues = {"PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED",
"WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"})
@Size(max = 32) String mainStatus,
Long customerId,
Long receivingWarehouseId,
@@ -24,7 +24,7 @@ public final class UnifiedOrderResponse {
BigDecimal estimatedVolumeCbm, BigDecimal expectedVolumeCbm,
BigDecimal actualGrossWeightKg, BigDecimal actualVolumeCbm,
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 destinationType, String destinationCode,
Integer totalPalletCount, BigDecimal chargeableWeightKg, BigDecimal chargeableVolumeCbm,
@@ -38,7 +38,8 @@ import java.util.stream.Collectors;
@Service
public class UnifiedOrderQueryService {
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 List<OrderStatus> ACTIVE_ORDER_STATUSES = List.copyOf(EnumSet.of(
OrderStatus.SUBMITTED, OrderStatus.APPROVED, OrderStatus.ACCEPTED,
@@ -311,7 +312,8 @@ public class UnifiedOrderQueryService {
order.getInboundCode(),
order.getExpectedShipDate(), null, null, totalPackageCount(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(),
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),
@@ -334,7 +336,8 @@ public class UnifiedOrderQueryService {
order.getEstimatedVolumeCbm(), volumeCbm(cargo),
inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(),
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.chargeableWeightKg(), inbound.chargeableVolumeCbm(), inbound.volumeWeightKg(),
inbound.actualNetWeightKg(),
@@ -361,7 +364,8 @@ public class UnifiedOrderQueryService {
order == null ? null : order.getEstimatedVolumeCbm(), volumeCbm(cargo),
inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(),
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.chargeableWeightKg(), inbound.chargeableVolumeCbm(), inbound.volumeWeightKg(),
inbound.actualNetWeightKg(),
@@ -502,7 +506,7 @@ public class UnifiedOrderQueryService {
return switch (status) {
case "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
"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;
};
}
+12 -9
View File
@@ -173,7 +173,7 @@ paths:
get:
summary: 平台统一订单列表
description: >-
统一返回七态产品主状态、下一步动作及测量/预装/装柜/派送摘要,需要 platform:order:list。
统一返回八态产品主状态、下一步动作及测量/组托/预装/装柜/派送摘要,需要 platform:order:list。
入仓码来源由全系统统一模式控制:AUTO 在订单提交时按物流渠道生成,格式为物流前缀+YYMMDD+当天全平台票序;
LEGACY_MANUAL 在草稿中录入旧系统入仓码。两种模式下入仓码一经提交均固定;有入库单时同时返回
inboundId、inboundVersion、loadingVersion、pickingVersion;inboundVersion
@@ -617,7 +617,7 @@ components:
tenantId: {type: string, pattern: '^[1-9][0-9]*$'}
keyword: {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]*$'}
receivingWarehouseId: {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}
UnifiedMainStatus:
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: >-
统一订单七态产品主状态。WAIT_SPLIT 为正式待分柜状态,INBOUNDED 仅兼容存量已入库记录。
统一订单八态产品主状态。ONE_CLICK_DROPSHIP 测量计费完成后进入 WAIT_PALLETIZE,封托完成后进入 WAIT_SPLIT;
STANDARD 保持既有流程,INBOUNDED 仅兼容存量已入库记录。
UnifiedOrderPageEnvelope:
type: object
properties:
@@ -766,6 +767,8 @@ components:
feeReviewStatus: {type: string, nullable: true, description: Firstleg 入仓费用审核快照;财务状态不在本字段表达}
companyName: {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}
requestedPickupAt: {type: string, format: date-time, nullable: true, description: Customer-requested pickup appointment from the pickup snapshot}
taxMode: {type: string, nullable: true}
@@ -961,13 +964,13 @@ components:
fulfillmentType:
type: string
enum: [STANDARD, ONE_CLICK_DROPSHIP]
default: STANDARD
description: 独立履约类型;省略为普通头程,不复用业务/计费 businessType
lxCode:
type: string
nullable: true
maxLength: 128
description: 一件代发绑定的领星入库单 LX 代码
default: STANDARD
description: 独立履约类型;省略为普通头程,不复用业务/计费 businessType
inboundCode:
type: string
maxLength: 40
@@ -1194,17 +1197,17 @@ components:
name: mainStatus
in: query
required: false
description: 统一订单七态主状态筛选;WAIT_SPLIT 与存量兼容状态 INBOUNDED 独立查询
description: 统一订单八态主状态筛选;WAIT_PALLETIZE、WAIT_SPLIT 与存量兼容状态 INBOUNDED 独立查询
schema:
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:
{name: inboundCode, in: query, required: false, description: 订单创建时生成的入仓码,所有订单状态均可查询, schema: {type: string, maxLength: 40}}
UnifiedInternalOrderStatus:
name: internalOrderStatus
in: query
required: false
description: Order 内部生命周期状态;不是七态产品主状态
description: Order 内部生命周期状态;不是八态产品主状态
schema: {type: string, enum: [DRAFT, SUBMITTED, APPROVED, ACCEPTED, REJECTED, CANCELLED, PROCESSING, COMPLETED]}
UnifiedExpectedArrivalDate:
{name: expectedArrivalDate, in: query, required: false, schema: {type: string, format: date}}
@@ -88,7 +88,10 @@ class InternalOrderCargoControllerTest {
@Test
void listsCargoInRepositoryOrderForStableCartonAllocation() {
OrderCargoEntity first = cargo(31L, 1, 3, "60", "40", "30");
first.setUnitsPerPackage(24);
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));
var response = controller.listForCartonLabels("internal-token", 11L, 21L);
@@ -97,10 +100,68 @@ class InternalOrderCargoControllerTest {
assertThat(response.data()).extracting(InternalOrderCargoController.CargoLabelBinding::cargoId)
.containsExactly(31L, 32L);
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");
verify(orders).findOwned(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
void childOrderCanInheritRootCargoBindingWithoutCrossCustomerLeak() {
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.FirstlegColoadGroupClient;
import com.baiyue.order.dto.UnifiedOrderQuery;
import io.swagger.v3.oas.annotations.media.Schema;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.cloud.openfeign.FeignClient;
@@ -26,7 +28,7 @@ class UnifiedOrderApiContractTest {
}
@Test
void documentsSevenProductMainStatuses() throws Exception {
void documentsEightProductMainStatuses() throws Exception {
String yaml = new ClassPathResource("openapi/order-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
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> schema = stringMap(mainStatus.get("schema"));
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())
.contains("WAIT_SPLIT", "INBOUNDED");
.contains("WAIT_PALLETIZE", "WAIT_SPLIT", "INBOUNDED");
Map<String, Object> schemas = stringMap(components.get("schemas"));
Map<String, Object> responseStatus = stringMap(schemas.get("UnifiedMainStatus"));
assertThat(stringList(responseStatus.get("enum"))).containsExactly(
"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED");
assertThat(responseStatus.get("description").toString()).contains("WAIT_SPLIT", "INBOUNDED");
"PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED", "WAIT_SPLIT",
"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"));
assertThat(stringMap(row.get("properties"))).containsKeys(
"orderId", "mainStatus", "channelCode", "inboundId", "inboundVersion", "measurement", "preload",
"loading", "delivery", "transport", "customerRemark", "latestInternalNote",
"holdFlag", "currentHoldReason",
"expectedArrivalAt", "requestedPickupAt", "feeReviewStatus", "companyName", "businessType", "pickupMethod",
"expectedArrivalAt", "requestedPickupAt", "feeReviewStatus", "companyName", "businessType",
"fulfillmentType", "lxCode", "pickupMethod",
"taxMode", "containerMode", "customsInspection", "destinationType", "destinationCode",
"totalPalletCount", "chargeableWeightKg", "chargeableVolumeCbm", "volumetricWeightKg", "totalNetWeightKg",
"measurementCompletedAt", "inboundCompletedAt", "expressCompany", "expressTrackingNo",
@@ -86,7 +98,7 @@ class UnifiedOrderApiContractTest {
assertThat(page.getAnnotation(PostMapping.class).value()).containsExactly("/query");
assertThat(List.of(FirstlegUnifiedOrderClient.Inbound.class.getRecordComponents()).stream()
.map(component -> component.getName()))
.contains("channelCode", "channelName", "holdFlag", "currentHoldReason")
.contains("channelCode", "channelName", "holdFlag", "currentHoldReason", "fulfillmentType", "lxCode")
.doesNotContain("holdCargo", "holdReason");
}
@@ -549,6 +549,70 @@ class UnifiedOrderQueryServiceTest {
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
void listKeepsLoadedFulfillmentAheadOfRawApprovedOrderStatus() {
UnifiedOrderQuery query = query(null);
@@ -728,7 +792,7 @@ class UnifiedOrderQueryServiceTest {
@Test
void fulfillmentTabsForwardStatusSearchAndPaginationToFirstleg() {
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()))
.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.companyName()).thenReturn("测试客户有限公司");
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.actualGrossWeightKg()).thenReturn(new BigDecimal("1787.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/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` 的“仓库级入口清单”为准 | 必须明确是只读诊断还是会补证据、创建资源或推进状态,不能用诊断成功冒充全量通过 |
| 本地质量与静态合同 | `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/` | 靠近部署组件管理,不移动到根脚本目录;部署、初始化数据库或发布配置必须获得当前轮明确授权 |
| 服务局部合同 | `../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-onboarding-order-e2e.ps1` | 注册、资质、审核到下单的本地 E2E,会创建客户、仓库、往来单位和订单 |
| 本地质量 | `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` | 离线扫描数据库中文注释,不连接数据库 |
| 本地质量 | `local-db-full-acceptance.ps1` | 只允许本机 MySQL 的受控 SQL 验收;非 dry-run 会写本地测试库 |
| 本地质量 | `mapper-scan-contract.mjs` | 离线检查 Mapper 注解与扫描入口,不启动 Spring、不连接数据库 |
@@ -153,8 +153,8 @@ node scripts/create-pending-review-orders.mjs
| 检查 | 入口 |
|---|---|
| 后端 Maven 契约/单元测试 | `scripts/backend-full-regression.ps1` |
| Admin/Client 本地测试与类型检查 | `scripts/frontend-full-regression.ps1` |
| PDA 本地页面和状态测试 | `../apps/pda-uniapp/tests/*.test.mjs` |
| Admin/Client/国内 PDA 本地全量回归 | `scripts/frontend-full-regression.ps1` |
| PDA 本地页面和状态定向测试 | `../apps/pda-uniapp/tests/*.test.mjs` |
| 本地数据库 SQL 静态/受控验收 | `scripts/local-db-full-acceptance.ps1` |
| 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 前强制执行 |
@@ -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
.\scripts\frontend-full-regression.ps1 -DryRun
.\scripts\frontend-full-regression.ps1
.\scripts\frontend-full-regression.ps1 -SkipAdmin
.\scripts\frontend-full-regression.ps1 -SkipClient
.\scripts\frontend-full-regression.ps1 -SkipPda
```
脚本会检查 `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/service/InboundOrderDetainedOutboxService.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/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java',
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraftContainer.java',
+27 -3
View File
@@ -2,7 +2,8 @@
param(
[switch]$DryRun,
[switch]$SkipAdmin,
[switch]$SkipClient
[switch]$SkipClient,
[switch]$SkipPda
)
$ErrorActionPreference = 'Stop'
@@ -17,8 +18,22 @@ function Invoke-FrontendStep {
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
if ($SkipAdmin -and $SkipClient) { Write-Error 'At least one of Admin or Client must be selected.'; exit 2 }
if (-not (Get-Command pnpm -ErrorAction SilentlyContinue)) { Write-Error 'pnpm was not found on PATH.'; exit 2 }
function Invoke-PdaTests {
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 = @()
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'
}
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.'
@@ -7,8 +7,16 @@ import { normalizeModuleOrder } from './admin-test-env-acceptance.mjs'
const scriptsDir = dirname(fileURLToPath(import.meta.url))
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')
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', () => {
assert.match(backendScript, /-pl["',\s\S]*order,firstleg,iam,gateway,file/)
for (const required of [