feat: complete overseas pda warehouse operations

This commit is contained in:
2026-08-25 11:20:36 +08:00
parent 45a3b9adfd
commit 042d0b738a
69 changed files with 4679 additions and 4 deletions
@@ -677,6 +677,125 @@ CREATE TABLE IF NOT EXISTS ll_pda_operation_history (
KEY idx_ll_pda_history_business(overseas_warehouse_id,operation_type,business_key)
) ENGINE=InnoDB COMMENT='海外PDA真实作业历史';
CREATE TABLE IF NOT EXISTS ll_pda_pallet_operation (
id BIGINT NOT NULL COMMENT 'PDA托盘作业ID', overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID',
operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', idempotency_key VARCHAR(128) NOT NULL COMMENT '设备幂等键',
operation_type VARCHAR(64) NOT NULL COMMENT '托盘作业类型', request_hash VARCHAR(128) NOT NULL COMMENT '请求内容摘要',
status VARCHAR(32) NOT NULL COMMENT '处理状态:PROCESSING、COMPLETED、FAILED_COMPENSATED、COMPENSATION_REQUIRED',
result_json LONGTEXT NULL COMMENT '成功响应快照', remote_transitions_json LONGTEXT NULL COMMENT '实物托盘状态变更回执',
last_error VARCHAR(2000) NULL 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_ll_pda_pallet_idempotency(operator_key,idempotency_key),
KEY idx_ll_pda_pallet_warehouse(overseas_warehouse_id,status,updated_at)
) ENGINE=InnoDB COMMENT='海外PDA托盘作业幂等、补偿与结果记录';
CREATE TABLE IF NOT EXISTS ll_pda_stocktake_task (
id BIGINT NOT NULL COMMENT '盘点任务ID', task_no VARCHAR(64) NOT NULL COMMENT '盘点任务号',
overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', scope_type VARCHAR(16) NOT NULL COMMENT '盘点范围类型LOCATION或PALLET',
scope_resource_id BIGINT NOT NULL COMMENT '盘点库位或托盘资源ID', scope_code VARCHAR(128) NOT NULL COMMENT '盘点库位码或托盘码快照',
status VARCHAR(32) NOT NULL COMMENT '盘点任务状态', position_count INT NOT NULL COMMENT '库存位置总数',
counted_count INT NOT NULL DEFAULT 0 COMMENT '已盘库存位置数', difference_count INT NOT NULL DEFAULT 0 COMMENT '存在差异的库存位置数',
created_by VARCHAR(128) NOT NULL COMMENT '创建操作账号标识', submitted_by VARCHAR(128) NULL COMMENT '提交操作账号标识',
submit_reason VARCHAR(500) NULL COMMENT '盘点差异提交原因', idempotency_key VARCHAR(128) NOT NULL COMMENT '创建幂等键',
request_hash VARCHAR(128) NOT NULL COMMENT '创建请求哈希', version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间',
submitted_at DATETIME(3) NULL COMMENT '提交完成时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_pda_stocktake_no(task_no),
UNIQUE KEY uk_ll_pda_stocktake_create(overseas_warehouse_id,created_by,idempotency_key),
KEY idx_ll_pda_stocktake_list(overseas_warehouse_id,status,created_at)
) ENGINE=InnoDB COMMENT='海外PDA库存盘点任务';
CREATE TABLE IF NOT EXISTS ll_pda_stocktake_item (
id BIGINT NOT NULL COMMENT '盘点明细ID', task_id BIGINT NOT NULL COMMENT '盘点任务ID', customer_id VARCHAR(128) NOT NULL COMMENT '库存所属客户标识',
balance_id BIGINT NOT NULL COMMENT '库存余额ID', position_id BIGINT NOT NULL COMMENT '库存位置ID', sku_code VARCHAR(128) NULL COMMENT 'SKU快照',
product_name VARCHAR(255) NULL COMMENT '品名快照', inbound_code VARCHAR(128) NULL COMMENT '入仓码快照', pallet_id BIGINT NOT NULL COMMENT '实物托盘ID',
pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照', location_id BIGINT NOT NULL COMMENT '库位ID快照', location_code VARCHAR(128) NOT NULL COMMENT '库位码快照',
book_packages BIGINT NOT NULL COMMENT '任务创建时账面总箱数', actual_packages BIGINT NULL COMMENT '现场实盘总箱数',
difference_packages BIGINT NULL COMMENT '实盘与账面箱数差异', status VARCHAR(32) NOT NULL COMMENT '盘点明细状态',
position_version BIGINT NOT NULL COMMENT '任务创建时库存位置版本', counted_by VARCHAR(128) NULL COMMENT '盘点操作账号标识',
version BIGINT NOT NULL DEFAULT 0 COMMENT '明细乐观锁版本', counted_at DATETIME(3) NULL COMMENT '盘点时间',
created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_pda_stocktake_item(task_id,position_id), KEY idx_ll_pda_stocktake_item_task(task_id,status,id)
) ENGINE=InnoDB COMMENT='海外PDA库存盘点位置明细';
CREATE TABLE IF NOT EXISTS ll_pda_stocktake_action (
id BIGINT NOT NULL COMMENT '盘点动作ID', overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID',
operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', idempotency_key VARCHAR(128) NOT NULL COMMENT '动作幂等键',
action_type VARCHAR(32) NOT NULL COMMENT '盘点动作类型', task_id BIGINT NULL COMMENT '盘点任务ID', request_hash VARCHAR(128) NOT NULL COMMENT '动作请求哈希',
status VARCHAR(32) NOT NULL COMMENT '动作处理状态', result_json LONGTEXT NULL COMMENT '成功响应JSON',
created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_pda_stocktake_action(overseas_warehouse_id,operator_key,idempotency_key), KEY idx_ll_pda_stocktake_action_task(task_id,created_at)
) ENGINE=InnoDB COMMENT='海外PDA盘点动作幂等记录';
CREATE TABLE IF NOT EXISTS ll_pda_inventory_move (
id BIGINT NOT NULL COMMENT '移库操作ID', move_no VARCHAR(64) NOT NULL COMMENT '移库操作单号',
overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', pallet_id BIGINT NOT NULL COMMENT '整托移库的实物托盘ID',
pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照', source_location_id BIGINT NOT NULL COMMENT '来源库位ID',
source_location_code VARCHAR(128) NOT NULL COMMENT '来源库位码快照', target_location_id BIGINT NOT NULL COMMENT '目标库位ID',
target_location_code VARCHAR(128) NOT NULL COMMENT '目标库位码快照', customer_count INT NOT NULL COMMENT '托盘涉及客户数',
position_count INT NOT NULL COMMENT '迁移库存位置数', package_count BIGINT NOT NULL COMMENT '迁移总箱数',
pallet_version_before INT NOT NULL COMMENT '迁移前托盘版本', pallet_version_after INT NULL COMMENT '迁移后托盘版本',
operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', reason VARCHAR(500) NOT NULL COMMENT '移库原因',
idempotency_key VARCHAR(128) NOT NULL COMMENT '移库幂等键', request_hash VARCHAR(128) NOT NULL COMMENT '移库请求哈希',
status VARCHAR(32) NOT NULL COMMENT '移库处理状态', result_json LONGTEXT NULL COMMENT '成功响应JSON',
created_at DATETIME(3) NOT NULL COMMENT '创建时间', completed_at DATETIME(3) NULL COMMENT '完成时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_pda_inventory_move_no(move_no),
UNIQUE KEY uk_ll_pda_inventory_move_key(overseas_warehouse_id,operator_key,idempotency_key),
KEY idx_ll_pda_inventory_move_pallet(overseas_warehouse_id,pallet_id,created_at)
) ENGINE=InnoDB COMMENT='海外PDA同仓整托移库记录';
CREATE TABLE IF NOT EXISTS ll_relabel_pda_task (
id BIGINT NOT NULL COMMENT '换标PDA任务ID', application_id BIGINT NOT NULL COMMENT '换标申请单ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', relabel_no VARCHAR(64) NOT NULL COMMENT '换标申请单号快照',
overseas_warehouse_id BIGINT NOT NULL COMMENT 'PDA令牌冻结的海外实际作业仓ID',
status VARCHAR(32) NOT NULL COMMENT '任务状态:PROCESSING、WAIT_REVIEW或COMPLETED',
operator_key VARCHAR(128) NOT NULL COMMENT '认领操作账号标识', operator_name VARCHAR(255) NOT NULL COMMENT '认领操作员名称快照',
reviewer_key VARCHAR(128) NULL COMMENT '复核账号标识', reviewer_name VARCHAR(255) NULL COMMENT '复核员名称快照',
expected_packages INT NOT NULL COMMENT '申请单通知的应换标箱数', relabelled_packages INT NOT NULL DEFAULT 0 COMMENT '已正常换标箱数',
skipped_packages INT NOT NULL DEFAULT 0 COMMENT '已说明原因跳过箱数', exception_packages INT NOT NULL DEFAULT 0 COMMENT '已登记异常箱数',
completion_photo_file_ids JSON NULL COMMENT '换标完成现场照片文件ID列表', completion_remark VARCHAR(1000) NULL COMMENT '完成提交备注',
review_decision VARCHAR(16) NULL COMMENT '最近复核结论:APPROVE或REWORK', review_remark VARCHAR(1000) NULL COMMENT '最近复核备注',
claimed_at DATETIME(3) NOT NULL COMMENT '任务认领时间', submitted_at DATETIME(3) NULL COMMENT '提交复核时间',
reviewed_at DATETIME(3) NULL COMMENT '最近复核时间', version INT 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_ll_relabel_pda_task_application (customer_id,application_id),
UNIQUE KEY uk_ll_relabel_pda_task_no (relabel_no), KEY idx_ll_relabel_pda_task_warehouse (overseas_warehouse_id,status,updated_at),
KEY idx_ll_relabel_pda_task_operator (operator_key,status,updated_at)
) ENGINE=InnoDB COMMENT='海外PDA换标现场作业任务';
CREATE TABLE IF NOT EXISTS ll_relabel_pda_unit_fact (
id BIGINT NOT NULL COMMENT '逐件换标事实ID', task_id BIGINT NOT NULL COMMENT '换标PDA任务ID',
application_id BIGINT NOT NULL COMMENT '换标申请单ID', item_id BIGINT NOT NULL COMMENT '换标申请明细ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', old_label VARCHAR(255) NULL COMMENT '扫描的实物旧标签或箱标',
new_label VARCHAR(255) NULL COMMENT '扫描的换标后新标签', result VARCHAR(32) NOT NULL COMMENT '逐件结果:RELABELLED、SKIPPED或EXCEPTION',
exception_code VARCHAR(32) NULL COMMENT '异常编码', reason VARCHAR(1000) NULL COMMENT '跳过或异常原因',
operator_key VARCHAR(128) NOT NULL COMMENT '操作账号标识', operator_name VARCHAR(255) NOT NULL COMMENT '操作员名称快照',
occurred_at DATETIME(3) NOT NULL COMMENT '逐件事实发生时间', PRIMARY KEY (id),
UNIQUE KEY uk_ll_relabel_pda_old_label (old_label), KEY idx_ll_relabel_pda_unit_task (customer_id,task_id,occurred_at,id),
KEY idx_ll_relabel_pda_unit_item (customer_id,application_id,item_id)
) ENGINE=InnoDB COMMENT='海外PDA逐件换标扫码与异常事实';
CREATE TABLE IF NOT EXISTS ll_relabel_pda_operation (
id BIGINT NOT NULL COMMENT '换标PDA幂等操作ID', task_id BIGINT NOT NULL COMMENT '换标PDA任务ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', operator_key VARCHAR(128) NOT NULL COMMENT '请求操作账号标识',
action VARCHAR(32) NOT NULL COMMENT '幂等操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '设备生成的幂等键',
request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', result_json LONGTEXT NOT NULL COMMENT '成功响应快照JSON',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id),
UNIQUE KEY uk_ll_relabel_pda_operation_key (operator_key,idempotency_key),
KEY idx_ll_relabel_pda_operation_task (customer_id,task_id,created_at)
) ENGINE=InnoDB COMMENT='海外PDA换标写操作幂等记录';
CREATE TABLE IF NOT EXISTS ll_relabel_pda_history (
id BIGINT NOT NULL COMMENT '换标PDA任务历史ID', task_id BIGINT NOT NULL COMMENT '换标PDA任务ID',
application_id BIGINT NOT NULL COMMENT '换标申请单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
action VARCHAR(32) NOT NULL COMMENT '现场动作类型', from_status VARCHAR(32) NULL COMMENT '动作前任务状态',
to_status VARCHAR(32) NOT NULL COMMENT '动作后任务状态', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号标识',
operator_name VARCHAR(255) NOT NULL COMMENT '操作员名称快照', detail_json LONGTEXT NULL COMMENT '动作详情JSON',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id),
KEY idx_ll_relabel_pda_history_task (customer_id,task_id,created_at,id),
KEY idx_ll_relabel_pda_history_application (customer_id,application_id,created_at)
) ENGINE=InnoDB COMMENT='海外PDA换标任务不可变操作历史';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='ll_cargo_discard_application' AND column_name='review_idempotency_key'),
'SELECT 1', 'ALTER TABLE ll_cargo_discard_application ADD COLUMN review_idempotency_key VARCHAR(255) NULL COMMENT ''审核幂等键'' AFTER request_hash'));
@@ -689,6 +808,20 @@ PREPARE phase4_discard_review_stmt FROM @ddl; EXECUTE phase4_discard_review_stmt
-- Warehouse Overseas 与 Lastleg 使用同一海外区域发布入口但保持独立数据库。
-- 公共托盘详情实体读取 fba_warehouse_code;旧库缺列时创建可成功但详情会 S0500。
USE baiyue_lastlag_warehouse;
CREATE TABLE IF NOT EXISTS wh_pallet_operation (
id BIGINT NOT NULL COMMENT '托盘状态操作ID', operation_key VARCHAR(128) NOT NULL COMMENT '跨服务幂等操作键',
request_hash VARCHAR(128) NOT NULL COMMENT '状态变更请求摘要', warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID',
pallet_id BIGINT NOT NULL COMMENT '实物托盘ID', pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照',
before_status VARCHAR(20) NOT NULL COMMENT '变更前托盘状态', after_status VARCHAR(20) NOT NULL COMMENT '变更后托盘状态',
before_location_id BIGINT NOT NULL COMMENT '变更前库位ID', after_location_id BIGINT NOT NULL COMMENT '变更后库位ID',
before_version INT NOT NULL COMMENT '变更前托盘版本', after_version INT NOT NULL COMMENT '变更后托盘版本',
operation_status VARCHAR(32) NOT NULL COMMENT '操作状态:APPLIED已应用、COMPENSATED已补偿',
reason VARCHAR(500) NULL 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_wh_pallet_operation_key (operation_key),
KEY idx_wh_pallet_operation_pallet (warehouse_id,pallet_id,created_at)
) ENGINE=InnoDB COMMENT='实物托盘状态幂等变更与补偿记录';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='fba_warehouse_code'),
'SELECT 1',
@@ -713,4 +846,23 @@ CALL assert_phase4_warehouse_pallet_ready();
DROP PROCEDURE assert_phase4_warehouse_pallet_ready;
USE baiyue_lastleg;
DROP PROCEDURE IF EXISTS assert_phase4_pda_operations_ready;
DELIMITER $$
CREATE PROCEDURE assert_phase4_pda_operations_ready()
BEGIN
IF (SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema='baiyue_lastleg' AND table_name IN (
'll_pda_pallet_operation','ll_pda_stocktake_task','ll_pda_stocktake_item',
'll_pda_stocktake_action','ll_pda_inventory_move','ll_relabel_pda_task',
'll_relabel_pda_unit_fact','ll_relabel_pda_operation','ll_relabel_pda_history')) <> 9 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA盘点、移库、托盘或换标表未完整部署';
END IF;
IF (SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema='baiyue_lastlag_warehouse' AND table_name='wh_pallet_operation') <> 1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA实物托盘操作表未部署';
END IF;
END$$
DELIMITER ;
CALL assert_phase4_pda_operations_ready();
DROP PROCEDURE assert_phase4_pda_operations_ready;
SELECT 'PASS' AS migration_status, '海外仓报价、月度汇率、车辆、海外业务、入仓库存与海外仓托盘结构已就绪' AS message;
@@ -23,7 +23,8 @@ public class InternalBusinessFileService {
"LOADING_CUSTOMS_DOCUMENT",
"CONTAINER_DELIVERY_ORDER",
"CONTAINER_PICKUP_ORDER",
"OVERSEAS_UNLOAD_PHOTO");
"OVERSEAS_UNLOAD_PHOTO",
"OVERSEAS_RELABEL_PHOTO");
@Resource private FileObjectMapper files;
@Resource private BusinessFileStorage storage;
@@ -80,6 +80,22 @@ class InternalBusinessFileServiceTest {
verifyNoInteractions(files, storage);
}
@Test void overseasRelabelPhotoPersistsExactTaskOwnership() {
MockMultipartFile file = new MockMultipartFile("file", "relabel.jpg", "image/jpeg", "photo".getBytes());
when(storage.storeLoading(100L, 81L, file)).thenReturn(new BusinessFileStorage.Stored(
"business-files/100/81/relabel.jpg", "image/jpeg", file.getSize(), "d".repeat(64), "relabel.jpg"));
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
((FileObjectEntity) invocation.getArgument(0)).setId(91L); return 1;
});
var result = service.upload(100L, "OVERSEAS_RELABEL_PHOTO", "81", 7L, file);
assertThat(result).extracting(InternalBusinessFileService.Metadata::tenantId,
InternalBusinessFileService.Metadata::businessType,
InternalBusinessFileService.Metadata::businessId)
.containsExactly(100L, "OVERSEAS_RELABEL_PHOTO", "81");
}
@Test void unsupportedBusinessTypeFailsClosed() {
assertThatThrownBy(() -> service.upload(10001L, "ARBITRARY", "41", 7L,
new MockMultipartFile("file", "a.txt", "text/plain", "x".getBytes())))
+1
View File
@@ -26,6 +26,7 @@
| PDA 卸柜照片文件上传与归属校验 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO` |
| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` |
| 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 |
| 海外PDA盘点、同仓整托移库、托盘组拆换绑与换标现场作业 | `pda/stocktake/`、`pda/move/`、`palletops/`、`relabel/pda/`;版本化契约 `src/main/resources/openapi/overseas-pda-operations-v1.yaml`;全部范围取自PDA Token,不含出库和派送 |
| 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`、`dispatch/mapper/DispatchCandidateMapper.java`、`dispatch/service/DispatchService.java`;草稿柜 `entity/DispatchDraftContainer.java`、`mapper/DispatchDraftContainerMapper.java`,托盘锁 `DispatchPalletLock.java`;入口 `/api/platform/v1/last-leg/dispatches`,范围开关与禁用写操作见“四期开发范围路由” |
| 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` |
| 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java`、`transfer/service/WarehouseTransferService.java` |
@@ -0,0 +1,58 @@
package com.baiyue.lastleg.palletops.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.palletops.dto.PalletOperationRequests;
import com.baiyue.lastleg.palletops.dto.PalletOperationResponses;
import com.baiyue.lastleg.palletops.service.PdaPalletOperationService;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/pda/v1/last-leg/pallet-operations")
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')")
public class PdaPalletOperationController {
@Resource private PdaPalletOperationService service;
@PostMapping("/pallets/resolve")
public ApiResponse<PalletOperationResponses.PalletContent> resolve(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PalletOperationRequests.Scan request) {
return ApiResponse.success(service.resolve(PdaWarehouseIdentity.require(user), request));
}
@PostMapping("/assemble")
public ApiResponse<PalletOperationResponses.Operation> assemble(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PalletOperationRequests.Assemble request) {
return ApiResponse.success(service.assemble(PdaWarehouseIdentity.require(user), request));
}
@PostMapping("/disassemble")
public ApiResponse<PalletOperationResponses.Operation> disassemble(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PalletOperationRequests.Disassemble request) {
return ApiResponse.success(service.disassemble(PdaWarehouseIdentity.require(user), request));
}
@PostMapping("/swap")
public ApiResponse<PalletOperationResponses.Operation> swap(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PalletOperationRequests.Swap request) {
return ApiResponse.success(service.swap(PdaWarehouseIdentity.require(user), request));
}
@PostMapping("/unbind")
public ApiResponse<PalletOperationResponses.Operation> unbind(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PalletOperationRequests.Unbind request) {
return ApiResponse.success(service.unbind(PdaWarehouseIdentity.require(user), request));
}
}
@@ -0,0 +1,42 @@
package com.baiyue.lastleg.palletops.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import java.util.List;
public final class PalletOperationRequests {
private PalletOperationRequests() { }
public record Scan(@NotBlank @Size(max = 128) String code) { }
public record PalletExpectation(@NotBlank @Size(max = 128) String palletCode,
@PositiveOrZero int expectedVersion) { }
public record MoveLine(@Positive long positionId, @Positive long packages,
@PositiveOrZero long expectedPositionVersion) { }
public record TargetMove(@Valid PalletExpectation target,
@NotEmpty @Size(max = 200) List<@Valid MoveLine> lines) { }
public record Assemble(@NotBlank @Size(max = 128) String idempotencyKey,
@Valid PalletExpectation target,
@NotEmpty @Size(max = 20) List<@Valid PalletExpectation> sources,
@NotEmpty @Size(max = 200) List<@Valid MoveLine> lines,
@Size(max = 500) String reason) { }
public record Disassemble(@NotBlank @Size(max = 128) String idempotencyKey,
@Valid PalletExpectation source,
@NotEmpty @Size(max = 20) List<@Valid TargetMove> targets,
@Size(max = 500) String reason) { }
public record Swap(@NotBlank @Size(max = 128) String idempotencyKey,
@Valid PalletExpectation source,
@Valid PalletExpectation target,
@Size(max = 500) String reason) { }
public record Unbind(@NotBlank @Size(max = 128) String idempotencyKey,
@Valid PalletExpectation source,
@Size(max = 500) String reason) { }
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.palletops.dto;
import java.time.LocalDateTime;
import java.util.List;
public final class PalletOperationResponses {
private PalletOperationResponses() { }
public record InventoryLine(String customerId, String customerName, long balanceId, long positionId,
String inboundCode, String skuCode, String productName,
long availablePackages, long holdPackages, long allocatedPackages,
long pickedPackages, long receivingPackages, long positionVersion) { }
public record PalletContent(long palletId, String palletCode, String status, int palletVersion,
long locationId, String locationCode, String destinationWarehouseCode,
long totalPackages, List<InventoryLine> inventory) { }
public record Operation(long operationId, String operationType, String idempotencyKey,
String status, boolean replayed, LocalDateTime completedAt,
List<PalletContent> pallets) {
public Operation asReplay() {
return new Operation(operationId, operationType, idempotencyKey, status, true, completedAt, pallets);
}
}
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.palletops.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_pda_pallet_operation")
public class PdaPalletOperation {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long overseasWarehouseId;
private String operatorKey;
private String idempotencyKey;
private String operationType;
private String requestHash;
private String status;
private String resultJson;
private String remoteTransitionsJson;
private String lastError;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,75 @@
package com.baiyue.lastleg.palletops.gateway;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatusCode;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import java.util.LinkedHashMap;
import java.util.Map;
@Component
public class HttpPalletPhysicalGateway implements PalletPhysicalGateway {
@Resource private RestClient.Builder restClientBuilder;
@Value("${baiyue.warehouse.base-url:http://127.0.0.1:9305}") private String baseUrl;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
private RestClient client;
@PostConstruct void initialize() { client = restClientBuilder.baseUrl(baseUrl).build(); }
@Override
public PalletReference resolve(String palletCode) {
ResolveEnvelope response = client.get().uri("/internal/v1/warehouse-pallets/resolve/{code}", palletCode)
.header("X-Internal-Service-Token", internalToken).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> {
throw new IllegalArgumentException("Warehouse rejected pallet scan: HTTP " + result.getStatusCode());
}).body(ResolveEnvelope.class);
if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned no pallet");
return response.data();
}
@Override
public LocationReference resolveLocation(long warehouseId, long locationId) {
LocationEnvelope response = client.get().uri(uri -> uri
.path("/internal/v1/warehouse-resource-locks/locations/{locationId}")
.queryParam("warehouseId", warehouseId).build(locationId))
.header("X-Internal-Service-Token", internalToken).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> {
throw new IllegalArgumentException("Warehouse rejected location lookup: HTTP " + result.getStatusCode());
}).body(LocationEnvelope.class);
if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned no location");
return response.data();
}
@Override
public Transition apply(long warehouseId, PalletReference pallet, String targetStatus, long targetLocationId,
String operationKey, String reason) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("warehouseId", warehouseId); body.put("expectedVersion", pallet.version());
body.put("targetStatus", targetStatus); body.put("targetLocationId", targetLocationId);
body.put("operationKey", operationKey); if (reason != null) body.put("reason", reason);
TransitionEnvelope response = client.post().uri("/internal/v1/pda-pallet-transitions/{id}", pallet.palletId())
.header("X-Internal-Service-Token", internalToken).body(body).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> {
throw new IllegalStateException("Warehouse rejected pallet transition: HTTP " + result.getStatusCode());
}).body(TransitionEnvelope.class);
if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned no transition");
return response.data();
}
@Override
public void compensate(long warehouseId, Transition transition) {
client.post().uri("/internal/v1/pda-pallet-transitions/{id}/{key}/compensate",
transition.palletId(), transition.operationKey())
.header("X-Internal-Service-Token", internalToken).body(Map.of("warehouseId", warehouseId))
.retrieve().onStatus(HttpStatusCode::isError, (request, result) -> {
throw new IllegalStateException("Warehouse rejected pallet compensation: HTTP " + result.getStatusCode());
}).toBodilessEntity();
}
private record ResolveEnvelope(PalletReference data) { }
private record LocationEnvelope(LocationReference data) { }
private record TransitionEnvelope(Transition data) { }
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.palletops.gateway;
public interface PalletPhysicalGateway {
PalletReference resolve(String palletCode);
LocationReference resolveLocation(long warehouseId, long locationId);
Transition apply(long warehouseId, PalletReference pallet, String targetStatus, long targetLocationId,
String operationKey, String reason);
void compensate(long warehouseId, Transition transition);
record PalletReference(long palletId, String palletCode, long warehouseId, long locationId,
String status, String fbaWarehouseCode, int version) { }
record LocationReference(long locationId, String locationCode, String locationType, String status) { }
record Transition(String operationKey, long palletId, String palletCode, long warehouseId,
String beforeStatus, String afterStatus, long beforeLocationId, long afterLocationId,
int beforeVersion, int afterVersion, String operationStatus, boolean replayed) { }
}
@@ -0,0 +1,45 @@
package com.baiyue.lastleg.palletops.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.palletops.entity.PdaPalletOperation;
import com.baiyue.lastleg.palletops.model.PalletInventoryRow;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface PdaPalletOperationMapper extends BaseMapper<PdaPalletOperation> {
String CONTENT_SELECT = "SELECT b.customer_id,b.customer_name,b.id balance_id,p.id position_id," +
"b.inbound_code,b.sku_code,b.product_name_zh,b.product_name_en,b.destination_warehouse_code," +
"p.pallet_id,p.pallet_no,p.batch_no,p.warehouse_location_id,p.warehouse_location_code," +
"p.available_packages,p.hold_packages,p.allocated_packages,p.picked_packages,p.receiving_packages,p.version " +
"FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.id=p.balance_id AND b.customer_id=p.customer_id ";
@Select("SELECT * FROM ll_pda_pallet_operation WHERE operator_key=#{operatorKey} " +
"AND idempotency_key=#{idempotencyKey} LIMIT 1")
PdaPalletOperation findOperation(@Param("operatorKey") String operatorKey,
@Param("idempotencyKey") String idempotencyKey);
@Select(CONTENT_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} AND p.pallet_id=#{palletId} " +
"AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)&gt;0 " +
"ORDER BY b.customer_id,b.inbound_code,p.id")
List<PalletInventoryRow> content(@Param("warehouseId") String warehouseId,
@Param("palletId") long palletId);
@Select(CONTENT_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} AND p.id=#{positionId} " +
"LIMIT 1 FOR UPDATE")
PalletInventoryRow lockPosition(@Param("warehouseId") String warehouseId,
@Param("positionId") long positionId);
@Select("SELECT * FROM ll_inventory_position WHERE customer_id=#{customerId} AND balance_id=#{balanceId} " +
"AND pallet_id=#{palletId} AND batch_no=#{batchNo} LIMIT 1 FOR UPDATE")
InventoryPosition lockTarget(@Param("customerId") String customerId, @Param("balanceId") long balanceId,
@Param("palletId") long palletId, @Param("batchNo") String batchNo);
@Select("SELECT COALESCE(SUM(available_packages+hold_packages+allocated_packages+picked_packages+receiving_packages),0) " +
"FROM ll_inventory_position WHERE pallet_id=#{palletId} AND overseas_warehouse_id=#{warehouseId}")
long palletPackages(@Param("warehouseId") String warehouseId, @Param("palletId") long palletId);
}
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.palletops.model;
import lombok.Data;
@Data
public class PalletInventoryRow {
private String customerId;
private String customerName;
private Long balanceId;
private Long positionId;
private String inboundCode;
private String skuCode;
private String productNameZh;
private String productNameEn;
private String destinationWarehouseCode;
private Long palletId;
private String palletNo;
private String batchNo;
private Long warehouseLocationId;
private String warehouseLocationCode;
private Long availablePackages;
private Long holdPackages;
private Long allocatedPackages;
private Long pickedPackages;
private Long receivingPackages;
private Long version;
}
@@ -0,0 +1,506 @@
package com.baiyue.lastleg.palletops.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryLedger;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.palletops.dto.PalletOperationRequests;
import com.baiyue.lastleg.palletops.dto.PalletOperationResponses;
import com.baiyue.lastleg.palletops.entity.PdaPalletOperation;
import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway;
import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper;
import com.baiyue.lastleg.palletops.model.PalletInventoryRow;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.DefaultTransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
@Service
public class PdaPalletOperationService {
@Resource private PdaPalletOperationMapper mapper;
@Resource private InventoryPositionMapper positions;
@Resource private InventoryBalanceMapper balances;
@Resource private InventoryLedgerMapper ledgers;
@Resource private PalletPhysicalGateway physical;
@Resource private PdaWarehouseService history;
@Resource private ObjectMapper objectMapper;
private TransactionTemplate transaction;
private TransactionTemplate newTransaction;
@Resource
void configureTransactions(PlatformTransactionManager manager) {
transaction = new TransactionTemplate(manager);
DefaultTransactionDefinition definition = new DefaultTransactionDefinition();
definition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
newTransaction = new TransactionTemplate(manager, definition);
}
public PalletOperationResponses.PalletContent resolve(PdaWarehouseIdentity identity,
PalletOperationRequests.Scan request) {
PalletPhysicalGateway.PalletReference pallet = requirePallet(identity.overseasWarehouseId(), request.code());
return content(identity.overseasWarehouseId(), pallet);
}
public PalletOperationResponses.Operation assemble(PdaWarehouseIdentity identity,
PalletOperationRequests.Assemble request) {
Map<String, PalletPhysicalGateway.PalletReference> pallets = resolvePallets(identity.overseasWarehouseId(),
request.target(), request.sources());
PalletPhysicalGateway.PalletReference target = pallets.get(code(request.target().palletCode()));
Map<String, PalletPhysicalGateway.PalletReference> sources = new LinkedHashMap<>();
request.sources().forEach(value -> sources.put(code(value.palletCode()), pallets.get(code(value.palletCode()))));
List<PlannedMove> moves = plannedLines(identity.overseasWarehouseId(), request.lines(), sources,
target, null);
requireCommonDestination(identity.overseasWarehouseId(), moves, List.of(target));
return execute(identity, "PALLET_ASSEMBLE", request.idempotencyKey(), request, request.reason(),
plan(identity.overseasWarehouseId(), pallets, moves, Set.of(target.palletId()), false));
}
public PalletOperationResponses.Operation disassemble(PdaWarehouseIdentity identity,
PalletOperationRequests.Disassemble request) {
LinkedHashMap<String, PalletOperationRequests.PalletExpectation> expectations = new LinkedHashMap<>();
expectations.put(code(request.source().palletCode()), request.source());
request.targets().forEach(value -> expectations.put(code(value.target().palletCode()), value.target()));
Map<String, PalletPhysicalGateway.PalletReference> pallets = resolvePallets(identity.overseasWarehouseId(),
expectations.values());
PalletPhysicalGateway.PalletReference source = pallets.get(code(request.source().palletCode()));
List<PlannedMove> moves = new ArrayList<>();
for (PalletOperationRequests.TargetMove targetMove : request.targets()) {
PalletPhysicalGateway.PalletReference target = pallets.get(code(targetMove.target().palletCode()));
moves.addAll(plannedLines(identity.overseasWarehouseId(), targetMove.lines(), Map.of(
code(request.source().palletCode()), source), target, source));
}
requireUniqueLines(moves);
List<PalletPhysicalGateway.PalletReference> targets = request.targets().stream()
.map(value -> pallets.get(code(value.target().palletCode()))).toList();
requireCommonDestination(identity.overseasWarehouseId(), moves, targets);
return execute(identity, "PALLET_DISASSEMBLE", request.idempotencyKey(), request, request.reason(),
plan(identity.overseasWarehouseId(), pallets, moves,
targets.stream().map(PalletPhysicalGateway.PalletReference::palletId).collect(java.util.stream.Collectors.toSet()),
false));
}
public PalletOperationResponses.Operation swap(PdaWarehouseIdentity identity,
PalletOperationRequests.Swap request) {
Map<String, PalletPhysicalGateway.PalletReference> pallets = resolvePallets(identity.overseasWarehouseId(),
request.source(), List.of(request.target()));
PalletPhysicalGateway.PalletReference source = pallets.get(code(request.source().palletCode()));
PalletPhysicalGateway.PalletReference target = pallets.get(code(request.target().palletCode()));
if (!mapper.content(String.valueOf(identity.overseasWarehouseId()), target.palletId()).isEmpty()) {
throw conflict("换托目标托盘必须为空");
}
List<PalletInventoryRow> rows = nonEmptyContent(identity.overseasWarehouseId(), source.palletId());
List<PlannedMove> moves = rows.stream().map(row -> {
requireMovable(row, value(row.getAvailablePackages()));
return new PlannedMove(row.getPositionId(), value(row.getAvailablePackages()), value(row.getVersion()),
source.palletId(), source.palletCode(), target.palletId(), target.palletCode(), target.locationId(),
locationCode(identity.overseasWarehouseId(), target.locationId()));
}).toList();
return execute(identity, "PALLET_SWAP", request.idempotencyKey(), request, request.reason(),
plan(identity.overseasWarehouseId(), pallets, moves, Set.of(target.palletId()), false));
}
public PalletOperationResponses.Operation unbind(PdaWarehouseIdentity identity,
PalletOperationRequests.Unbind request) {
Map<String, PalletPhysicalGateway.PalletReference> pallets = resolvePallets(identity.overseasWarehouseId(),
List.of(request.source()));
PalletPhysicalGateway.PalletReference source = pallets.get(code(request.source().palletCode()));
List<PalletInventoryRow> rows = nonEmptyContent(identity.overseasWarehouseId(), source.palletId());
List<PlannedMove> moves = rows.stream().map(row -> {
requireMovable(row, value(row.getAvailablePackages()));
return new PlannedMove(row.getPositionId(), value(row.getAvailablePackages()), value(row.getVersion()),
source.palletId(), source.palletCode(), 0L, "UNBOUND", source.locationId(),
locationCode(identity.overseasWarehouseId(), source.locationId()));
}).toList();
return execute(identity, "PALLET_UNBIND", request.idempotencyKey(), request, request.reason(),
plan(identity.overseasWarehouseId(), pallets, moves, Set.of(), true));
}
private PalletOperationResponses.Operation execute(PdaWarehouseIdentity identity, String type, String key,
Object request, String reason, Plan plan) {
String normalizedKey = required(key, "幂等键不能为空");
String requestHash = hash(request);
PdaPalletOperation operation = begin(identity, type, normalizedKey, requestHash);
if ("COMPLETED".equals(operation.getStatus())) return read(operation).asReplay();
List<PalletPhysicalGateway.Transition> transitions = new ArrayList<>();
try {
for (PalletChange change : plan.changes()) {
transitions.add(physical.apply(identity.overseasWarehouseId(), change.pallet(), change.targetStatus(),
change.targetLocationId(), transitionKey(identity.operatorKey(), normalizedKey,
change.pallet().palletId()), reason));
}
saveTransitions(operation.getId(), transitions);
PalletOperationResponses.Operation result = transaction.execute(status ->
mutate(identity, operation, plan, normalizedKey, requestHash, reason));
if (result == null) throw new IllegalStateException("托盘作业事务没有返回结果");
return result;
} catch (RuntimeException failure) {
boolean compensated = compensate(identity.overseasWarehouseId(), transitions);
fail(operation.getId(), failure, compensated);
throw failure;
}
}
private PalletOperationResponses.Operation mutate(PdaWarehouseIdentity identity, PdaPalletOperation operation,
Plan plan, String key, String requestHash, String reason) {
Set<Long> seen = new HashSet<>();
LinkedHashSet<String> customers = new LinkedHashSet<>();
for (PlannedMove move : plan.moves()) {
if (!seen.add(move.positionId())) throw conflict("同一库存行不能在一次托盘作业中重复移动");
PalletInventoryRow locked = mapper.lockPosition(String.valueOf(identity.overseasWarehouseId()),
move.positionId());
if (locked == null || !Objects.equals(locked.getPalletId(), move.sourcePalletId())) {
throw conflict("库存所在托盘已变化,请重新扫码");
}
if (value(locked.getVersion()) != move.expectedVersion()) throw conflict("库存版本已变化,请重新扫码");
requireMovable(locked, move.packages());
customers.add(locked.getCustomerId());
moveInventory(identity, operation, locked, move, key, reason);
}
for (PalletChange change : plan.changes()) {
long actual = mapper.palletPackages(String.valueOf(identity.overseasWarehouseId()), change.pallet().palletId());
boolean shouldBeEmpty = "IDLE".equals(change.targetStatus());
if (shouldBeEmpty != (actual == 0)) {
throw conflict("库存与实物托盘状态不一致,作业已回滚");
}
}
List<PalletOperationResponses.PalletContent> contents = plan.pallets().values().stream()
.sorted(Comparator.comparingLong(PalletPhysicalGateway.PalletReference::palletId))
.map(pallet -> content(identity.overseasWarehouseId(), physical.resolve(pallet.palletCode())))
.toList();
PalletOperationResponses.Operation result = new PalletOperationResponses.Operation(operation.getId(),
operation.getOperationType(), key, "COMPLETED", false, LocalDateTime.now(), contents);
operation.setStatus("COMPLETED"); operation.setResultJson(write(result)); operation.setLastError(null);
operation.setUpdatedAt(result.completedAt()); mapper.updateById(operation);
history.record(identity.overseasWarehouseId(), customers.size() == 1 ? customers.iterator().next() : null,
identity.operatorKey(), operation.getOperationType(), businessKey(plan), "COMPLETED", key, requestHash,
"托盘作业完成,移动库存行 " + plan.moves().size() + " 条");
return result;
}
private void moveInventory(PdaWarehouseIdentity identity, PdaPalletOperation operation,
PalletInventoryRow sourceRow, PlannedMove move, String key, String reason) {
InventoryPosition source = positions.selectById(sourceRow.getPositionId());
if (source == null) throw conflict("库存行不存在");
long sourceBefore = value(source.getAvailablePackages());
source.setAvailablePackages(sourceBefore - move.packages());
source.setStatus(positionPackages(source) == 0 ? "EMPTY" : "OCCUPIED");
source.setVersion(value(source.getVersion()) + 1); source.setUpdatedAt(LocalDateTime.now());
positions.updateById(source);
InventoryPosition target = mapper.lockTarget(source.getCustomerId(), source.getBalanceId(),
move.targetPalletId(), source.getBatchNo());
if (target == null) {
target = new InventoryPosition(); target.setId(IdWorker.getId());
target.setCustomerId(source.getCustomerId()); target.setBalanceId(source.getBalanceId());
target.setOverseasWarehouseId(source.getOverseasWarehouseId());
target.setDestinationWarehouseCode(source.getDestinationWarehouseCode());
target.setPalletId(move.targetPalletId()); target.setPalletNo(move.targetPalletCode());
target.setBatchNo(source.getBatchNo()); target.setWarehouseLocationId(move.targetLocationId());
target.setWarehouseLocationCode(move.targetLocationCode());
target.setAvailablePackages(0L); target.setHoldPackages(0L); target.setAllocatedPackages(0L);
target.setPickedPackages(0L); target.setReceivingPackages(0L); target.setStatus("OCCUPIED");
target.setVersion(0L); target.setCreatedAt(LocalDateTime.now()); target.setUpdatedAt(target.getCreatedAt());
try { positions.insert(target); }
catch (DuplicateKeyException duplicate) {
target = mapper.lockTarget(source.getCustomerId(), source.getBalanceId(), move.targetPalletId(), source.getBatchNo());
if (target == null) throw duplicate;
}
}
long targetBefore = value(target.getAvailablePackages());
target.setAvailablePackages(targetBefore + move.packages()); target.setStatus("OCCUPIED");
target.setWarehouseLocationId(move.targetLocationId());
target.setWarehouseLocationCode(move.targetLocationCode());
target.setVersion(value(target.getVersion()) + 1); target.setUpdatedAt(LocalDateTime.now());
positions.updateById(target);
InventoryBalance balance = balances.selectById(source.getBalanceId());
if (balance == null) throw new IllegalStateException("库存余额不存在");
ledger(identity, operation, balance, source, key + ":" + source.getId() + ":OUT",
sourceBefore, -move.packages(), sourceBefore - move.packages(), move.sourcePalletCode(), move.targetPalletCode(), reason);
ledger(identity, operation, balance, target, key + ":" + source.getId() + ":IN",
targetBefore, move.packages(), targetBefore + move.packages(), move.sourcePalletCode(), move.targetPalletCode(), reason);
}
private void ledger(PdaWarehouseIdentity identity, PdaPalletOperation operation, InventoryBalance balance,
InventoryPosition position, String idempotencyKey, long before, long delta, long after,
String sourcePallet, String targetPallet, String reason) {
InventoryLedger ledger = new InventoryLedger(); ledger.setId(IdWorker.getId());
ledger.setCustomerId(balance.getCustomerId()); ledger.setBalanceId(balance.getId());
ledger.setPositionId(position.getId()); ledger.setOverseasWarehouseId(balance.getOverseasWarehouseId());
ledger.setDestinationWarehouseCode(balance.getDestinationWarehouseCode());
ledger.setInboundOrderId(balance.getInboundOrderId()); ledger.setPalletId(position.getPalletId());
ledger.setPalletNo(position.getPalletNo()); ledger.setBatchNo(position.getBatchNo());
ledger.setSkuCode(balance.getSkuCode()); ledger.setWarehouseLocationCode(position.getWarehouseLocationCode());
ledger.setBusinessType(operation.getOperationType()); ledger.setBusinessOrderId(operation.getId());
ledger.setBusinessOrderNo(String.valueOf(operation.getId())); ledger.setInboundCode(balance.getInboundCode());
ledger.setInboundOrderNo(balance.getInboundOrderNo()); ledger.setIdempotencyKey(idempotencyKey);
ledger.setStatusFrom("PALLET:" + sourcePallet); ledger.setStatusTo("PALLET:" + targetPallet);
ledger.setBeforePackages(before); ledger.setDeltaPackages(delta); ledger.setAfterPackages(after);
ledger.setOperatorId(identity.operatorKey()); ledger.setRemark(trim(reason));
ledger.setOccurredAt(LocalDateTime.now()); ledger.setCreatedAt(ledger.getOccurredAt()); ledgers.insert(ledger);
}
private Plan plan(long warehouseId, Map<String, PalletPhysicalGateway.PalletReference> pallets,
List<PlannedMove> moves, Set<Long> targets, boolean unbind) {
Map<Long, Long> movedBySource = new HashMap<>();
moves.forEach(move -> movedBySource.merge(move.sourcePalletId(), move.packages(), Long::sum));
List<PalletChange> changes = new ArrayList<>();
for (PalletPhysicalGateway.PalletReference pallet : pallets.values()) {
long total = mapper.palletPackages(String.valueOf(warehouseId), pallet.palletId());
long moved = movedBySource.getOrDefault(pallet.palletId(), 0L);
String targetStatus = targets.contains(pallet.palletId()) ? "OCCUPIED"
: moved > 0 && moved == total ? "IDLE" : pallet.status();
if ("SCRAPPED".equals(targetStatus)) throw conflict("已报废托盘不能执行作业");
changes.add(new PalletChange(pallet, targetStatus, pallet.locationId()));
}
if (unbind && changes.stream().noneMatch(change -> "IDLE".equals(change.targetStatus()))) {
throw conflict("解绑必须清空来源托盘");
}
changes.sort(Comparator.comparingLong(change -> change.pallet().palletId()));
return new Plan(pallets, moves, changes);
}
private List<PlannedMove> plannedLines(long warehouseId, List<PalletOperationRequests.MoveLine> lines,
Map<String, PalletPhysicalGateway.PalletReference> sources,
PalletPhysicalGateway.PalletReference target,
PalletPhysicalGateway.PalletReference requiredSource) {
Map<Long, PalletInventoryRow> available = new HashMap<>();
for (PalletPhysicalGateway.PalletReference source : sources.values()) {
for (PalletInventoryRow row : mapper.content(String.valueOf(warehouseId), source.palletId())) {
available.put(row.getPositionId(), row);
}
}
List<PlannedMove> result = new ArrayList<>();
for (PalletOperationRequests.MoveLine line : lines) {
PalletInventoryRow row = available.get(line.positionId());
if (row == null || requiredSource != null && !Objects.equals(row.getPalletId(), requiredSource.palletId())) {
throw conflict("库存行不属于已扫描的来源托盘");
}
if (value(row.getVersion()) != line.expectedPositionVersion()) throw conflict("库存版本已变化,请重新扫码");
requireMovable(row, line.packages());
result.add(new PlannedMove(row.getPositionId(), line.packages(), line.expectedPositionVersion(),
row.getPalletId(), row.getPalletNo(), target.palletId(), target.palletCode(), target.locationId(),
locationCode(warehouseId, target.locationId())));
}
requireUniqueLines(result);
return result;
}
private void requireCommonDestination(long warehouseId, List<PlannedMove> moves,
List<PalletPhysicalGateway.PalletReference> targets) {
LinkedHashSet<String> destinations = new LinkedHashSet<>();
for (PlannedMove move : moves) {
PalletInventoryRow row = mapper.content(String.valueOf(warehouseId), move.sourcePalletId()).stream()
.filter(value -> Objects.equals(value.getPositionId(), move.positionId())).findFirst()
.orElseThrow(() -> conflict("库存行不存在"));
destinations.add(text(row.getDestinationWarehouseCode()));
}
for (PalletPhysicalGateway.PalletReference target : targets) {
mapper.content(String.valueOf(warehouseId), target.palletId()).stream()
.map(PalletInventoryRow::getDestinationWarehouseCode).map(PdaPalletOperationService::text)
.forEach(destinations::add);
}
if (destinations.size() > 1) throw conflict("不同目的仓货物不能组到同一托盘");
}
private Map<String, PalletPhysicalGateway.PalletReference> resolvePallets(long warehouseId,
PalletOperationRequests.PalletExpectation first, List<PalletOperationRequests.PalletExpectation> rest) {
List<PalletOperationRequests.PalletExpectation> values = new ArrayList<>(); values.add(first); values.addAll(rest);
return resolvePallets(warehouseId, values);
}
private Map<String, PalletPhysicalGateway.PalletReference> resolvePallets(long warehouseId,
java.util.Collection<PalletOperationRequests.PalletExpectation> values) {
LinkedHashMap<String, PalletPhysicalGateway.PalletReference> result = new LinkedHashMap<>();
for (PalletOperationRequests.PalletExpectation expected : values) {
String code = code(expected.palletCode());
if (result.containsKey(code)) throw badRequest("一次作业不能重复提交同一托盘");
PalletPhysicalGateway.PalletReference pallet = requirePallet(warehouseId, code);
if (pallet.version() != expected.expectedVersion()) throw conflict("托盘版本已变化,请重新扫码");
result.put(code, pallet);
}
return result;
}
private PalletPhysicalGateway.PalletReference requirePallet(long warehouseId, String code) {
PalletPhysicalGateway.PalletReference pallet = physical.resolve(code(code));
if (pallet.warehouseId() != warehouseId) throw conflict("托盘不属于 Token 冻结的海外仓");
if ("SCRAPPED".equals(pallet.status())) throw conflict("已报废托盘不能执行作业");
return pallet;
}
private List<PalletInventoryRow> nonEmptyContent(long warehouseId, long palletId) {
List<PalletInventoryRow> values = mapper.content(String.valueOf(warehouseId), palletId);
if (values.isEmpty()) throw conflict("来源托盘没有可操作库存");
return values;
}
private PalletOperationResponses.PalletContent content(long warehouseId,
PalletPhysicalGateway.PalletReference pallet) {
List<PalletInventoryRow> rows = mapper.content(String.valueOf(warehouseId), pallet.palletId());
LinkedHashSet<String> destinations = new LinkedHashSet<>();
List<PalletOperationResponses.InventoryLine> inventory = rows.stream().map(row -> {
destinations.add(text(row.getDestinationWarehouseCode()));
return new PalletOperationResponses.InventoryLine(row.getCustomerId(), row.getCustomerName(),
value(row.getBalanceId()), value(row.getPositionId()), row.getInboundCode(), row.getSkuCode(),
first(row.getProductNameZh(), row.getProductNameEn()), value(row.getAvailablePackages()),
value(row.getHoldPackages()), value(row.getAllocatedPackages()), value(row.getPickedPackages()),
value(row.getReceivingPackages()), value(row.getVersion()));
}).toList();
long total = rows.stream().mapToLong(PdaPalletOperationService::rowPackages).sum();
String locationCode = locationCode(warehouseId, pallet.locationId());
return new PalletOperationResponses.PalletContent(pallet.palletId(), pallet.palletCode(), pallet.status(),
pallet.version(), pallet.locationId(), locationCode, destinations.size() == 1 ? destinations.iterator().next() : null,
total, inventory);
}
private PdaPalletOperation begin(PdaWarehouseIdentity identity, String type, String key, String requestHash) {
PdaPalletOperation existing = mapper.findOperation(identity.operatorKey(), key);
if (existing != null) return validateExisting(existing, type, requestHash);
try {
return newTransaction.execute(status -> {
PdaPalletOperation operation = new PdaPalletOperation(); operation.setId(IdWorker.getId());
operation.setOverseasWarehouseId(identity.overseasWarehouseId());
operation.setOperatorKey(identity.operatorKey()); operation.setIdempotencyKey(key);
operation.setOperationType(type); operation.setRequestHash(requestHash); operation.setStatus("PROCESSING");
operation.setCreatedAt(LocalDateTime.now()); operation.setUpdatedAt(operation.getCreatedAt());
mapper.insert(operation); return operation;
});
} catch (DuplicateKeyException duplicate) {
PdaPalletOperation winner = mapper.findOperation(identity.operatorKey(), key);
if (winner != null) return validateExisting(winner, type, requestHash);
throw duplicate;
}
}
private PdaPalletOperation validateExisting(PdaPalletOperation value, String type, String requestHash) {
if (!type.equals(value.getOperationType()) || !requestHash.equals(value.getRequestHash())) {
throw conflict("幂等键已被不同托盘作业使用");
}
if (!"COMPLETED".equals(value.getStatus())) {
throw conflict("同一托盘作业正在处理或需要人工恢复");
}
return value;
}
private void saveTransitions(long operationId, List<PalletPhysicalGateway.Transition> transitions) {
newTransaction.executeWithoutResult(status -> {
PdaPalletOperation operation = mapper.selectById(operationId);
operation.setRemoteTransitionsJson(write(transitions)); operation.setUpdatedAt(LocalDateTime.now());
mapper.updateById(operation);
});
}
private void fail(long operationId, RuntimeException failure, boolean compensated) {
newTransaction.executeWithoutResult(status -> {
PdaPalletOperation operation = mapper.selectById(operationId);
if (operation == null || "COMPLETED".equals(operation.getStatus())) return;
operation.setStatus(compensated ? "FAILED_COMPENSATED" : "COMPENSATION_REQUIRED");
operation.setLastError(limit(failure.getMessage(), 2000)); operation.setUpdatedAt(LocalDateTime.now());
mapper.updateById(operation);
});
}
private boolean compensate(long warehouseId, List<PalletPhysicalGateway.Transition> transitions) {
boolean success = true;
for (int i = transitions.size() - 1; i >= 0; i--) {
try { physical.compensate(warehouseId, transitions.get(i)); }
catch (RuntimeException ignored) { success = false; }
}
return success;
}
private PalletOperationResponses.Operation read(PdaPalletOperation operation) {
try { return objectMapper.readValue(operation.getResultJson(), PalletOperationResponses.Operation.class); }
catch (JsonProcessingException exception) { throw new IllegalStateException("无法读取托盘作业幂等结果", exception); }
}
private String write(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException exception) { throw new IllegalStateException("无法序列化托盘作业", exception); }
}
private String hash(Object value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(write(value).getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); }
}
private static String transitionKey(String operator, String key, long palletId) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest((operator + "|" + key + "|" + palletId).getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); }
}
private String locationCode(long warehouseId, long locationId) {
PalletPhysicalGateway.LocationReference location = physical.resolveLocation(warehouseId, locationId);
if (location == null || location.locationId() != locationId || "INACTIVE".equals(location.status())) {
throw conflict("托盘库位不存在或已停用");
}
return location.locationCode();
}
private static void requireMovable(PalletInventoryRow row, long packages) {
if (packages <= 0 || value(row.getAvailablePackages()) < packages) throw conflict("可用库存箱数不足");
if (value(row.getHoldPackages()) > 0 || value(row.getAllocatedPackages()) > 0
|| value(row.getPickedPackages()) > 0 || value(row.getReceivingPackages()) > 0) {
throw conflict("冻结、已分配、已拣或收货中的库存不能执行托盘作业");
}
}
private static void requireUniqueLines(List<PlannedMove> moves) {
Set<Long> ids = new HashSet<>();
if (moves.stream().anyMatch(move -> !ids.add(move.positionId()))) throw badRequest("库存行不能重复提交");
}
private static long rowPackages(PalletInventoryRow row) {
return value(row.getAvailablePackages()) + value(row.getHoldPackages()) + value(row.getAllocatedPackages())
+ value(row.getPickedPackages()) + value(row.getReceivingPackages());
}
private static long positionPackages(InventoryPosition value) {
return value(value.getAvailablePackages()) + value(value.getHoldPackages()) + value(value.getAllocatedPackages())
+ value(value.getPickedPackages()) + value(value.getReceivingPackages());
}
private static String businessKey(Plan plan) {
return plan.pallets().values().stream().map(PalletPhysicalGateway.PalletReference::palletCode)
.sorted().collect(java.util.stream.Collectors.joining("->"));
}
private static String required(String value, String message) { String result = trim(value); if (result == null) throw badRequest(message); return result; }
private static String code(String value) { return required(value, "托盘码不能为空").toUpperCase(); }
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private static String text(String value) { return value == null ? "" : value.trim(); }
private static String first(String first, String second) { String value = trim(first); return value == null ? trim(second) : value; }
private static String limit(String value, int size) { if (value == null) return null; return value.length() <= size ? value : value.substring(0, size); }
private static long value(Long value) { return value == null ? 0L : value; }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private record PlannedMove(long positionId, long packages, long expectedVersion,
long sourcePalletId, String sourcePalletCode,
long targetPalletId, String targetPalletCode, long targetLocationId,
String targetLocationCode) { }
private record PalletChange(PalletPhysicalGateway.PalletReference pallet, String targetStatus,
long targetLocationId) { }
private record Plan(Map<String, PalletPhysicalGateway.PalletReference> pallets,
List<PlannedMove> moves, List<PalletChange> changes) { }
}
@@ -0,0 +1,30 @@
package com.baiyue.lastleg.pda.move.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveRequest;
import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveResponse;
import com.baiyue.lastleg.pda.move.service.PdaInventoryMoveService;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/pda/v1/last-leg/inventory-moves")
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')")
public class PdaInventoryMoveController {
@Resource private PdaInventoryMoveService service;
@PostMapping
public ApiResponse<PdaInventoryMoveResponse> move(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PdaInventoryMoveRequest request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.move(identity.overseasWarehouseId(), identity.operatorKey(), request));
}
}
@@ -0,0 +1,13 @@
package com.baiyue.lastleg.pda.move.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
public record PdaInventoryMoveRequest(
@NotBlank @Size(max = 128) String sourcePalletCode,
@NotBlank @Size(max = 128) String targetLocationCode,
@PositiveOrZero int expectedPalletVersion,
@NotBlank @Size(max = 128) String idempotencyKey,
@NotBlank @Size(max = 500) String reason
) { }
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.pda.move.dto;
import java.time.LocalDateTime;
public record PdaInventoryMoveResponse(
String moveNo,
long palletId,
String palletCode,
long sourceLocationId,
String sourceLocationCode,
long targetLocationId,
String targetLocationCode,
int customerCount,
int positionCount,
long packageCount,
int palletVersion,
String status,
LocalDateTime completedAt,
boolean replayed
) {
public PdaInventoryMoveResponse asReplay() {
return replayed ? this : new PdaInventoryMoveResponse(moveNo, palletId, palletCode, sourceLocationId,
sourceLocationCode, targetLocationId, targetLocationCode, customerCount, positionCount,
packageCount, palletVersion, status, completedAt, true);
}
}
@@ -0,0 +1,35 @@
package com.baiyue.lastleg.pda.move.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_pda_inventory_move")
public class PdaInventoryMove {
@TableId(type = IdType.INPUT) private Long id;
private String moveNo;
private Long overseasWarehouseId;
private Long palletId;
private String palletCode;
private Long sourceLocationId;
private String sourceLocationCode;
private Long targetLocationId;
private String targetLocationCode;
private Integer customerCount;
private Integer positionCount;
private Long packageCount;
private Integer palletVersionBefore;
private Integer palletVersionAfter;
private String operatorKey;
private String reason;
private String idempotencyKey;
private String requestHash;
private String status;
private String resultJson;
private LocalDateTime createdAt;
private LocalDateTime completedAt;
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.pda.move.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.pda.move.entity.PdaInventoryMove;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface PdaInventoryMoveMapper extends BaseMapper<PdaInventoryMove> {
@Select("SELECT * FROM ll_pda_inventory_move WHERE overseas_warehouse_id=#{warehouseId} " +
"AND operator_key=#{operatorKey} AND idempotency_key=#{key} LIMIT 1")
PdaInventoryMove find(@Param("warehouseId") long warehouseId,
@Param("operatorKey") String operatorKey, @Param("key") String key);
@Select("SELECT * FROM ll_inventory_position WHERE overseas_warehouse_id=#{warehouseId} " +
"AND pallet_id=#{palletId} AND " +
"(available_packages+hold_packages+allocated_packages+picked_packages+receiving_packages)>0 " +
"ORDER BY customer_id,id FOR UPDATE")
List<InventoryPosition> lockPositions(@Param("warehouseId") String warehouseId,
@Param("palletId") long palletId);
}
@@ -0,0 +1,192 @@
package com.baiyue.lastleg.pda.move.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryLedger;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway;
import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveRequest;
import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveResponse;
import com.baiyue.lastleg.pda.move.entity.PdaInventoryMove;
import com.baiyue.lastleg.pda.move.mapper.PdaInventoryMoveMapper;
import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.List;
import java.util.Set;
@Service
public class PdaInventoryMoveService {
@Resource private PdaInventoryMoveMapper moves;
@Resource private InventoryPositionMapper positions;
@Resource private InventoryBalanceMapper balances;
@Resource private InventoryLedgerMapper ledgers;
@Resource private WarehouseResourceGateway warehouse;
@Resource private PalletPhysicalGateway physical;
@Resource private PdaWarehouseService history;
@Resource private ObjectMapper objectMapper;
@Transactional
public PdaInventoryMoveResponse move(long warehouseId, String operatorKey, PdaInventoryMoveRequest request) {
String key = required(request.idempotencyKey(), "Idempotency key is required");
String requestHash = hash(request);
PdaInventoryMove existing = moves.find(warehouseId, operatorKey, key);
if (existing != null) return replay(existing, requestHash);
var pallet = warehouse.resolvePallet(required(request.sourcePalletCode(), "Source pallet code is required"));
if (pallet.warehouseId() != warehouseId) throw conflict("Source pallet belongs to another overseas warehouse");
var target = warehouse.resolveLocation(warehouseId,
required(request.targetLocationCode(), "Target location code is required"));
if (!Set.of("ACTIVE", "IDLE", "OCCUPIED").contains(target.status())) {
throw conflict("Target location is not available");
}
boolean recoveringRemoteSuccess = pallet.locationId() == target.locationId()
&& pallet.version() == request.expectedPalletVersion() + 1;
if (!recoveringRemoteSuccess && pallet.version() != request.expectedPalletVersion()) {
throw conflict("Source pallet version changed; rescan it");
}
List<InventoryPosition> inventory = moves.lockPositions(String.valueOf(warehouseId), pallet.palletId());
if (inventory.isEmpty()) throw conflict("Source pallet has no movable inventory");
Set<Long> inventoryLocations = new HashSet<>();
inventory.forEach(position -> inventoryLocations.add(position.getWarehouseLocationId()));
if (inventoryLocations.contains(null) || inventoryLocations.size() != 1) {
throw conflict("Inventory and warehouse pallet locations are inconsistent; reconcile before moving");
}
long sourceLocationId = inventoryLocations.iterator().next();
if (!recoveringRemoteSuccess && sourceLocationId != pallet.locationId()) {
throw conflict("Inventory and warehouse pallet locations are inconsistent; reconcile before moving");
}
if (sourceLocationId == target.locationId()) throw badRequest("Source and target locations must differ");
var source = warehouse.resolveLocation(warehouseId, sourceLocationId);
long packages = inventory.stream().mapToLong(PdaInventoryMoveService::physical).sum();
Set<String> customers = new HashSet<>();
inventory.forEach(position -> customers.add(position.getCustomerId()));
long moveId = IdWorker.getId();
PdaInventoryMove operation = new PdaInventoryMove();
operation.setId(moveId); operation.setMoveNo("MV-" + Long.toUnsignedString(moveId));
operation.setOverseasWarehouseId(warehouseId); operation.setPalletId(pallet.palletId());
operation.setPalletCode(pallet.palletNo()); operation.setSourceLocationId(source.locationId());
operation.setSourceLocationCode(source.locationCode()); operation.setTargetLocationId(target.locationId());
operation.setTargetLocationCode(target.locationCode()); operation.setCustomerCount(customers.size());
operation.setPositionCount(inventory.size()); operation.setPackageCount(packages);
operation.setPalletVersionBefore(pallet.version()); operation.setOperatorKey(operatorKey);
operation.setReason(request.reason().trim()); operation.setIdempotencyKey(key); operation.setRequestHash(requestHash);
operation.setStatus("PROCESSING"); operation.setCreatedAt(LocalDateTime.now());
try { moves.insert(operation); }
catch (DuplicateKeyException duplicate) {
PdaInventoryMove winner = moves.find(warehouseId, operatorKey, key);
if (winner == null) throw duplicate;
return replay(winner, requestHash);
}
String transitionKey = transitionKey(operatorKey, key, pallet.palletId());
var transitionSource = new PalletPhysicalGateway.PalletReference(pallet.palletId(), pallet.palletNo(),
warehouseId, source.locationId(), "OCCUPIED", pallet.fbaWarehouseCode(), request.expectedPalletVersion());
var relocated = physical.apply(warehouseId, transitionSource, "OCCUPIED", target.locationId(),
transitionKey, request.reason());
if (relocated.afterLocationId() != target.locationId()
|| !"APPLIED".equals(relocated.operationStatus())) {
throw new IllegalStateException("Warehouse relocation returned another target location");
}
LocalDateTime completedAt = LocalDateTime.now();
for (InventoryPosition position : inventory) {
InventoryBalance balance = balances.selectById(position.getBalanceId());
if (balance == null || !position.getCustomerId().equals(balance.getCustomerId())
|| !String.valueOf(warehouseId).equals(balance.getOverseasWarehouseId())) {
throw new IllegalStateException("Inventory balance scope is inconsistent during relocation");
}
position.setWarehouseLocationId(target.locationId()); position.setWarehouseLocationCode(target.locationCode());
position.setVersion(value(position.getVersion()) + 1); position.setUpdatedAt(completedAt);
positions.updateById(position);
ledger(operation, position, balance, operatorKey, key, request.reason(), source.locationCode(),
target.locationCode(), completedAt);
}
operation.setPalletVersionAfter(relocated.afterVersion()); operation.setStatus("COMPLETED");
operation.setCompletedAt(completedAt);
PdaInventoryMoveResponse response = response(operation, false);
operation.setResultJson(write(response)); moves.updateById(operation);
history.record(warehouseId, null, operatorKey, "INVENTORY_MOVE", operation.getMoveNo(), "COMPLETED",
"INVENTORY_MOVE:" + key, requestHash,
pallet.palletNo() + ":" + source.locationCode() + " → " + target.locationCode()
+ "," + packages + " 箱");
return response;
}
private PdaInventoryMoveResponse replay(PdaInventoryMove operation, String requestHash) {
if (!requestHash.equals(operation.getRequestHash())) {
throw conflict("Idempotency key was reused with a different inventory move");
}
if (!"COMPLETED".equals(operation.getStatus()) || operation.getResultJson() == null) {
throw conflict("Inventory move is still processing");
}
try { return objectMapper.readValue(operation.getResultJson(), PdaInventoryMoveResponse.class).asReplay(); }
catch (JsonProcessingException error) { throw new IllegalStateException("Cannot read inventory move replay", error); }
}
private PdaInventoryMoveResponse response(PdaInventoryMove value, boolean replayed) {
return new PdaInventoryMoveResponse(value.getMoveNo(), value.getPalletId(), value.getPalletCode(),
value.getSourceLocationId(), value.getSourceLocationCode(), value.getTargetLocationId(),
value.getTargetLocationCode(), value.getCustomerCount(), value.getPositionCount(),
value.getPackageCount(), value.getPalletVersionAfter(), value.getStatus(), value.getCompletedAt(), replayed);
}
private void ledger(PdaInventoryMove operation, InventoryPosition position, InventoryBalance balance,
String operator, String key, String reason, String source, String target,
LocalDateTime now) {
InventoryLedger row = new InventoryLedger(); row.setId(IdWorker.getId()); row.setCustomerId(position.getCustomerId());
row.setBalanceId(balance.getId()); row.setPositionId(position.getId());
row.setOverseasWarehouseId(String.valueOf(operation.getOverseasWarehouseId()));
row.setDestinationWarehouseCode(balance.getDestinationWarehouseCode()); row.setInboundOrderId(balance.getInboundOrderId());
row.setPalletId(position.getPalletId()); row.setPalletNo(position.getPalletNo()); row.setBatchNo(position.getBatchNo());
row.setSkuCode(balance.getSkuCode()); row.setWarehouseLocationCode(target); row.setBusinessType("INTERNAL_MOVE");
row.setBusinessOrderId(operation.getId()); row.setBusinessOrderNo(operation.getMoveNo());
row.setInboundCode(balance.getInboundCode()); row.setInboundOrderNo(balance.getInboundOrderNo());
row.setIdempotencyKey("MOVE:" + operation.getId() + ":" + position.getId());
row.setStatusFrom(source); row.setStatusTo(target); long packages = physical(position);
row.setBeforePackages(packages); row.setDeltaPackages(0L); row.setAfterPackages(packages);
row.setOperatorId(operator); row.setRemark(reason.trim()); row.setOccurredAt(now); row.setCreatedAt(now);
ledgers.insert(row);
}
private String hash(Object value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(write(value).getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException error) { throw new IllegalStateException(error); }
}
private static String transitionKey(String operator, String key, long palletId) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(("INVENTORY_MOVE|" + operator + "|" + key + "|" + palletId)
.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException error) { throw new IllegalStateException(error); }
}
private String write(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException error) { throw new IllegalStateException("Cannot serialize inventory move", error); }
}
private static long physical(InventoryPosition value) { return value(value.getAvailablePackages()) + value(value.getHoldPackages())
+ value(value.getAllocatedPackages()) + value(value.getPickedPackages()) + value(value.getReceivingPackages()); }
private static long value(Long value) { return value == null ? 0 : value; }
private static String required(String value, String message) { if (value == null || value.isBlank()) throw badRequest(message); return value.trim(); }
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}
@@ -0,0 +1,61 @@
package com.baiyue.lastleg.pda.stocktake.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeRequests;
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeResponses;
import com.baiyue.lastleg.pda.stocktake.service.PdaStocktakeService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/pda/v1/last-leg/stocktakes")
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')")
public class PdaStocktakeController {
@Resource private PdaStocktakeService service;
@GetMapping
public ApiResponse<PageResult<PdaStocktakeResponses.Summary>> page(
@AuthenticationPrincipal AuthenticatedUser user, @Valid PdaStocktakeRequests.TaskQuery query) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.page(identity.overseasWarehouseId(), query));
}
@PostMapping
public ApiResponse<PdaStocktakeResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PdaStocktakeRequests.Create request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.create(identity.overseasWarehouseId(), identity.operatorKey(), request));
}
@GetMapping("/{taskNo}")
public ApiResponse<PdaStocktakeResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String taskNo) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.detail(identity.overseasWarehouseId(), taskNo));
}
@PostMapping("/{taskNo}/counts")
public ApiResponse<PdaStocktakeResponses.Item> count(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String taskNo, @Valid @RequestBody PdaStocktakeRequests.Count request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.count(identity.overseasWarehouseId(), identity.operatorKey(), taskNo, request));
}
@PostMapping("/{taskNo}/submit")
public ApiResponse<PdaStocktakeResponses.Detail> submit(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String taskNo, @Valid @RequestBody PdaStocktakeRequests.Submit request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.submit(identity.overseasWarehouseId(), identity.operatorKey(), taskNo, request));
}
}
@@ -0,0 +1,28 @@
package com.baiyue.lastleg.pda.stocktake.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
public final class PdaStocktakeRequests {
private PdaStocktakeRequests() { }
public record TaskQuery(@Min(1) long page, @Min(1) @Max(100) long size, String status) {
public TaskQuery { if (page == 0) page = 1; if (size == 0) size = 20; }
}
public record Create(@NotBlank @Size(max = 16) String scopeType,
@NotBlank @Size(max = 128) String scopeCode,
@NotBlank @Size(max = 128) String idempotencyKey) { }
public record Count(@NotNull Long positionId, @PositiveOrZero long actualPackages,
@PositiveOrZero long expectedVersion,
@NotBlank @Size(max = 128) String idempotencyKey) { }
public record Submit(@PositiveOrZero long expectedVersion,
@NotBlank @Size(max = 500) String reason,
@NotBlank @Size(max = 128) String idempotencyKey) { }
}
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.pda.stocktake.dto;
import java.time.LocalDateTime;
import java.util.List;
public final class PdaStocktakeResponses {
private PdaStocktakeResponses() { }
public record Summary(String taskNo, String scopeType, String scopeCode, String status,
int positionCount, int countedCount, int differenceCount, long version,
LocalDateTime createdAt, LocalDateTime submittedAt) { }
public record Item(long itemId, String customerId, long balanceId, long positionId,
String skuCode, String productName, String inboundCode,
long palletId, String palletCode, long locationId, String locationCode,
long bookPackages, Long actualPackages, Long differencePackages,
String status, long positionVersion, long version) { }
public record Detail(Summary task, List<Item> items) { }
}
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.pda.stocktake.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_pda_stocktake_action")
public class PdaStocktakeAction {
@TableId(type = IdType.INPUT) private Long id;
private Long overseasWarehouseId;
private String operatorKey;
private String idempotencyKey;
private String actionType;
private Long taskId;
private String requestHash;
private String status;
private String resultJson;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,35 @@
package com.baiyue.lastleg.pda.stocktake.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_pda_stocktake_item")
public class PdaStocktakeItem {
@TableId(type = IdType.INPUT) private Long id;
private Long taskId;
private String customerId;
private Long balanceId;
private Long positionId;
private String skuCode;
private String productName;
private String inboundCode;
private Long palletId;
private String palletCode;
private Long locationId;
private String locationCode;
private Long bookPackages;
private Long actualPackages;
private Long differencePackages;
private String status;
private Long positionVersion;
private String countedBy;
private Long version;
private LocalDateTime countedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,32 @@
package com.baiyue.lastleg.pda.stocktake.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_pda_stocktake_task")
public class PdaStocktakeTask {
@TableId(type = IdType.INPUT) private Long id;
private String taskNo;
private Long overseasWarehouseId;
private String scopeType;
private Long scopeResourceId;
private String scopeCode;
private String status;
private Integer positionCount;
private Integer countedCount;
private Integer differenceCount;
private String createdBy;
private String submittedBy;
private String submitReason;
private String idempotencyKey;
private String requestHash;
private Long version;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private LocalDateTime submittedAt;
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.pda.stocktake.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeAction;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface PdaStocktakeActionMapper extends BaseMapper<PdaStocktakeAction> {
@Select("SELECT * FROM ll_pda_stocktake_action WHERE overseas_warehouse_id=#{warehouseId} " +
"AND operator_key=#{operatorKey} AND idempotency_key=#{key} LIMIT 1")
PdaStocktakeAction find(@Param("warehouseId") long warehouseId,
@Param("operatorKey") String operatorKey, @Param("key") String key);
}
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.pda.stocktake.mapper;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface PdaStocktakeInventoryMapper {
@Select("SELECT * FROM ll_inventory_position WHERE id=#{positionId} AND customer_id=#{customerId} " +
"AND overseas_warehouse_id=#{warehouseId} LIMIT 1 FOR UPDATE")
InventoryPosition lockPosition(@Param("positionId") long positionId, @Param("customerId") String customerId,
@Param("warehouseId") String warehouseId);
@Select("SELECT * FROM ll_inventory_balance WHERE id=#{balanceId} AND customer_id=#{customerId} " +
"AND overseas_warehouse_id=#{warehouseId} LIMIT 1 FOR UPDATE")
InventoryBalance lockBalance(@Param("balanceId") long balanceId, @Param("customerId") String customerId,
@Param("warehouseId") String warehouseId);
}
@@ -0,0 +1,66 @@
package com.baiyue.lastleg.pda.stocktake.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeResponses;
import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeItem;
import com.baiyue.lastleg.pda.stocktake.model.StocktakeCandidate;
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 PdaStocktakeItemMapper extends BaseMapper<PdaStocktakeItem> {
String CANDIDATE = "SELECT p.customer_id,b.id balance_id,p.id position_id,b.sku_code," +
"COALESCE(NULLIF(b.product_name_zh,''),b.product_name_en) product_name,b.inbound_code," +
"p.pallet_id,p.pallet_no pallet_code,p.warehouse_location_id location_id," +
"p.warehouse_location_code location_code," +
"(p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages) book_packages," +
"p.version position_version FROM ll_inventory_position p JOIN ll_inventory_balance b " +
"ON b.id=p.balance_id AND b.customer_id=p.customer_id WHERE p.overseas_warehouse_id=#{warehouseId} ";
@Select(CANDIDATE + "AND p.pallet_id=#{palletId} AND " +
"(p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " +
"ORDER BY p.customer_id,p.id")
List<StocktakeCandidate> candidatesByPallet(@Param("warehouseId") String warehouseId,
@Param("palletId") long palletId);
@Select(CANDIDATE + "AND (p.warehouse_location_id=#{locationId} OR p.warehouse_location_code=#{locationCode}) " +
"AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " +
"ORDER BY p.customer_id,p.pallet_no,p.id")
List<StocktakeCandidate> candidatesByLocation(@Param("warehouseId") String warehouseId,
@Param("locationId") long locationId,
@Param("locationCode") String locationCode);
@Select("SELECT * FROM ll_pda_stocktake_item WHERE task_id=#{taskId} ORDER BY location_code,pallet_code,position_id")
List<PdaStocktakeItem> list(@Param("taskId") long taskId);
@Select("SELECT * FROM ll_pda_stocktake_item WHERE task_id=#{taskId} ORDER BY id FOR UPDATE")
List<PdaStocktakeItem> lockAll(@Param("taskId") long taskId);
@Select("SELECT * FROM ll_pda_stocktake_item WHERE task_id=#{taskId} AND position_id=#{positionId} LIMIT 1 FOR UPDATE")
PdaStocktakeItem lockItem(@Param("taskId") long taskId, @Param("positionId") long positionId);
@Update("UPDATE ll_pda_stocktake_item SET actual_packages=#{actualPackages}," +
"difference_packages=#{differencePackages},status=#{status},counted_by=#{operatorKey}," +
"counted_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3),version=version+1 " +
"WHERE id=#{itemId} AND version=#{expectedVersion} AND status IN ('PENDING','COUNTED')")
int countItem(@Param("itemId") long itemId, @Param("expectedVersion") long expectedVersion,
@Param("actualPackages") long actualPackages, @Param("differencePackages") long differencePackages,
@Param("status") String status, @Param("operatorKey") String operatorKey);
@Select("SELECT COUNT(*) FROM ll_pda_stocktake_item WHERE task_id=#{taskId} AND actual_packages IS NOT NULL")
int counted(@Param("taskId") long taskId);
@Select("SELECT COUNT(*) FROM ll_pda_stocktake_item WHERE task_id=#{taskId} " +
"AND actual_packages IS NOT NULL AND difference_packages&lt;&gt;0")
int differences(@Param("taskId") long taskId);
@Select("SELECT id item_id,customer_id,balance_id,position_id,sku_code,product_name,inbound_code," +
"pallet_id,pallet_code,location_id,location_code,book_packages,actual_packages,difference_packages," +
"status,position_version,version FROM ll_pda_stocktake_item WHERE task_id=#{taskId} " +
"ORDER BY location_code,pallet_code,position_id")
List<PdaStocktakeResponses.Item> views(@Param("taskId") long taskId);
}
@@ -0,0 +1,51 @@
package com.baiyue.lastleg.pda.stocktake.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeResponses;
import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeTask;
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 PdaStocktakeTaskMapper extends BaseMapper<PdaStocktakeTask> {
@Select("SELECT * FROM ll_pda_stocktake_task WHERE overseas_warehouse_id=#{warehouseId} " +
"AND created_by=#{operatorKey} AND idempotency_key=#{key} LIMIT 1")
PdaStocktakeTask findCreate(@Param("warehouseId") long warehouseId,
@Param("operatorKey") String operatorKey, @Param("key") String key);
@Select("SELECT * FROM ll_pda_stocktake_task WHERE overseas_warehouse_id=#{warehouseId} " +
"AND task_no=#{taskNo} LIMIT 1")
PdaStocktakeTask find(@Param("warehouseId") long warehouseId, @Param("taskNo") String taskNo);
@Select("SELECT * FROM ll_pda_stocktake_task WHERE overseas_warehouse_id=#{warehouseId} " +
"AND task_no=#{taskNo} LIMIT 1 FOR UPDATE")
PdaStocktakeTask lock(@Param("warehouseId") long warehouseId, @Param("taskNo") String taskNo);
@Select("<script>SELECT task_no,scope_type,scope_code,status,position_count,counted_count,difference_count," +
"version,created_at,submitted_at FROM ll_pda_stocktake_task WHERE overseas_warehouse_id=#{warehouseId} " +
"<if test='status != null and status != \"\"'>AND status=#{status} </if>" +
"ORDER BY created_at DESC,id DESC LIMIT #{limit} OFFSET #{offset}</script>")
List<PdaStocktakeResponses.Summary> page(@Param("warehouseId") long warehouseId,
@Param("status") String status,
@Param("offset") long offset, @Param("limit") long limit);
@Select("<script>SELECT COUNT(*) FROM ll_pda_stocktake_task WHERE overseas_warehouse_id=#{warehouseId} " +
"<if test='status != null and status != \"\"'>AND status=#{status}</if></script>")
long count(@Param("warehouseId") long warehouseId, @Param("status") String status);
@Update("UPDATE ll_pda_stocktake_task SET counted_count=#{countedCount},difference_count=#{differenceCount}," +
"version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{taskId} AND version=#{expectedVersion} " +
"AND status='IN_PROGRESS'")
int refreshCounts(@Param("taskId") long taskId, @Param("expectedVersion") long expectedVersion,
@Param("countedCount") int countedCount, @Param("differenceCount") int differenceCount);
@Update("UPDATE ll_pda_stocktake_task SET status='SUBMITTED',submitted_by=#{operatorKey}," +
"submit_reason=#{reason},submitted_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3)," +
"version=version+1 WHERE id=#{taskId} AND version=#{expectedVersion} AND status='IN_PROGRESS'")
int submit(@Param("taskId") long taskId, @Param("expectedVersion") long expectedVersion,
@Param("operatorKey") String operatorKey, @Param("reason") String reason);
}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.pda.stocktake.model;
import lombok.Data;
@Data
public class StocktakeCandidate {
private String customerId;
private Long balanceId;
private Long positionId;
private String skuCode;
private String productName;
private String inboundCode;
private Long palletId;
private String palletCode;
private Long locationId;
private String locationCode;
private Long bookPackages;
private Long positionVersion;
}
@@ -0,0 +1,323 @@
package com.baiyue.lastleg.pda.stocktake.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryLedger;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeRequests;
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeResponses;
import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeAction;
import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeItem;
import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeTask;
import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeActionMapper;
import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeInventoryMapper;
import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeItemMapper;
import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeTaskMapper;
import com.baiyue.lastleg.pda.stocktake.model.StocktakeCandidate;
import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
@Service
public class PdaStocktakeService {
@Resource private PdaStocktakeTaskMapper tasks;
@Resource private PdaStocktakeItemMapper items;
@Resource private PdaStocktakeActionMapper actions;
@Resource private PdaStocktakeInventoryMapper inventory;
@Resource private InventoryPositionMapper positions;
@Resource private InventoryBalanceMapper balances;
@Resource private InventoryLedgerMapper ledgers;
@Resource private WarehouseResourceGateway warehouse;
@Resource private PdaWarehouseService history;
@Resource private ObjectMapper objectMapper;
public PageResult<PdaStocktakeResponses.Summary> page(long warehouseId, PdaStocktakeRequests.TaskQuery query) {
String status = status(query.status());
long offset = (query.page() - 1) * query.size();
List<PdaStocktakeResponses.Summary> rows = tasks.page(warehouseId, status, offset, query.size());
long total = tasks.count(warehouseId, status);
return new PageResult<>(rows, query.page(), query.size(), total,
total == 0 ? 0 : (total + query.size() - 1) / query.size());
}
public PdaStocktakeResponses.Detail detail(long warehouseId, String taskNo) {
PdaStocktakeTask task = requireTask(warehouseId, taskNo, false);
return detail(task);
}
@Transactional
public PdaStocktakeResponses.Detail create(long warehouseId, String operatorKey,
PdaStocktakeRequests.Create request) {
String key = required(request.idempotencyKey(), "Idempotency key is required");
String requestHash = hash(request);
PdaStocktakeTask replay = tasks.findCreate(warehouseId, operatorKey, key);
if (replay != null) {
if (!requestHash.equals(replay.getRequestHash())) throw conflict("Idempotency key was reused with another stocktake");
return detail(replay);
}
String scopeType = required(request.scopeType(), "Scope type is required").toUpperCase();
String scopeCode = required(request.scopeCode(), "Scope code is required");
long resourceId;
List<StocktakeCandidate> candidates;
if ("PALLET".equals(scopeType)) {
var pallet = warehouse.resolvePallet(scopeCode);
if (pallet.warehouseId() != warehouseId) throw conflict("Pallet belongs to another overseas warehouse");
resourceId = pallet.palletId();
scopeCode = pallet.palletNo();
candidates = items.candidatesByPallet(String.valueOf(warehouseId), resourceId);
} else if ("LOCATION".equals(scopeType)) {
var location = warehouse.resolveLocation(warehouseId, scopeCode);
if (!List.of("ACTIVE", "IDLE", "OCCUPIED").contains(location.status())) {
throw conflict("Stocktake location is not active");
}
resourceId = location.locationId();
scopeCode = location.locationCode();
candidates = items.candidatesByLocation(String.valueOf(warehouseId), resourceId, scopeCode);
} else {
throw badRequest("scopeType must be LOCATION or PALLET");
}
if (candidates.isEmpty()) throw conflict("No inventory is available in the scanned scope");
long taskId = IdWorker.getId();
LocalDateTime now = LocalDateTime.now();
PdaStocktakeTask task = new PdaStocktakeTask();
task.setId(taskId); task.setTaskNo("CC-" + Long.toUnsignedString(taskId));
task.setOverseasWarehouseId(warehouseId); task.setScopeType(scopeType); task.setScopeResourceId(resourceId);
task.setScopeCode(scopeCode); task.setStatus("IN_PROGRESS"); task.setPositionCount(candidates.size());
task.setCountedCount(0); task.setDifferenceCount(0); task.setCreatedBy(operatorKey);
task.setIdempotencyKey(key); task.setRequestHash(requestHash); task.setVersion(0L);
task.setCreatedAt(now); task.setUpdatedAt(now);
try {
tasks.insert(task);
} catch (DuplicateKeyException duplicate) {
PdaStocktakeTask winner = tasks.findCreate(warehouseId, operatorKey, key);
if (winner == null || !requestHash.equals(winner.getRequestHash())) throw duplicate;
return detail(winner);
}
for (StocktakeCandidate candidate : candidates) {
PdaStocktakeItem item = new PdaStocktakeItem();
item.setId(IdWorker.getId()); item.setTaskId(taskId); item.setCustomerId(candidate.getCustomerId());
item.setBalanceId(candidate.getBalanceId()); item.setPositionId(candidate.getPositionId());
item.setSkuCode(candidate.getSkuCode()); item.setProductName(candidate.getProductName());
item.setInboundCode(candidate.getInboundCode()); item.setPalletId(candidate.getPalletId());
item.setPalletCode(candidate.getPalletCode()); item.setLocationId(candidate.getLocationId());
item.setLocationCode(candidate.getLocationCode()); item.setBookPackages(value(candidate.getBookPackages()));
item.setStatus("PENDING"); item.setPositionVersion(value(candidate.getPositionVersion()));
item.setVersion(0L); item.setCreatedAt(now); item.setUpdatedAt(now); items.insert(item);
}
history.record(warehouseId, null, operatorKey, "STOCKTAKE_CREATE", task.getTaskNo(), "IN_PROGRESS",
"STOCKTAKE_CREATE:" + key, requestHash,
scopeType + " " + scopeCode + ",共 " + candidates.size() + " 个库存位置");
return detail(task);
}
@Transactional
public PdaStocktakeResponses.Item count(long warehouseId, String operatorKey, String taskNo,
PdaStocktakeRequests.Count request) {
String key = required(request.idempotencyKey(), "Idempotency key is required");
String requestHash = hash(request);
PdaStocktakeAction action = beginAction(warehouseId, operatorKey, key, "COUNT", requestHash, taskNo);
if ("COMPLETED".equals(action.getStatus())) return read(action, PdaStocktakeResponses.Item.class);
PdaStocktakeTask task = requireTask(warehouseId, taskNo, true);
if (!"IN_PROGRESS".equals(task.getStatus())) throw conflict("Submitted stocktake cannot be changed");
PdaStocktakeItem item = items.lockItem(task.getId(), request.positionId());
if (item == null) throw notFound("Stocktake position was not found in this task");
long difference = request.actualPackages() - value(item.getBookPackages());
if (items.countItem(item.getId(), request.expectedVersion(), request.actualPackages(), difference,
difference == 0 ? "MATCHED" : "DIFFERENCE", operatorKey) != 1) {
throw conflict("Stocktake item version changed; refresh and retry");
}
int counted = items.counted(task.getId());
int differences = items.differences(task.getId());
if (tasks.refreshCounts(task.getId(), value(task.getVersion()), counted, differences) != 1) {
throw conflict("Stocktake task version changed; refresh and retry");
}
PdaStocktakeResponses.Item response = items.views(task.getId()).stream()
.filter(row -> row.positionId() == request.positionId()).findFirst().orElseThrow();
complete(action, response);
return response;
}
@Transactional
public PdaStocktakeResponses.Detail submit(long warehouseId, String operatorKey, String taskNo,
PdaStocktakeRequests.Submit request) {
String key = required(request.idempotencyKey(), "Idempotency key is required");
String requestHash = hash(request);
PdaStocktakeAction action = beginAction(warehouseId, operatorKey, key, "SUBMIT", requestHash, taskNo);
if ("COMPLETED".equals(action.getStatus())) return read(action, PdaStocktakeResponses.Detail.class);
PdaStocktakeTask task = requireTask(warehouseId, taskNo, true);
if (!"IN_PROGRESS".equals(task.getStatus())) throw conflict("Stocktake was already submitted");
if (value(task.getVersion()) != request.expectedVersion()) throw conflict("Stocktake task version changed; refresh and retry");
List<PdaStocktakeItem> taskItems = items.lockAll(task.getId());
if (taskItems.isEmpty() || taskItems.stream().anyMatch(item -> item.getActualPackages() == null)) {
throw conflict("Every stocktake position must be counted before submission");
}
Map<Long, InventoryBalance> changedBalances = new HashMap<>();
LocalDateTime now = LocalDateTime.now();
for (PdaStocktakeItem item : taskItems) {
InventoryPosition position = inventory.lockPosition(item.getPositionId(), item.getCustomerId(),
String.valueOf(warehouseId));
if (position == null || value(position.getVersion()) != value(item.getPositionVersion())) {
throw conflict("Inventory changed after counting; recreate the stocktake task");
}
long before = physical(position);
if (before != value(item.getBookPackages())) {
throw conflict("Inventory package total changed after counting; recreate the stocktake task");
}
long fixed = value(position.getHoldPackages()) + value(position.getAllocatedPackages())
+ value(position.getPickedPackages()) + value(position.getReceivingPackages());
if (value(item.getActualPackages()) < fixed) {
throw conflict("Actual packages cannot be lower than reserved or processing packages");
}
long newAvailable = value(item.getActualPackages()) - fixed;
long delta = newAvailable - value(position.getAvailablePackages());
InventoryBalance balance = changedBalances.get(item.getBalanceId());
if (balance == null) {
balance = inventory.lockBalance(item.getBalanceId(), item.getCustomerId(), String.valueOf(warehouseId));
if (balance == null) throw new IllegalStateException("Stocktake inventory balance is missing");
changedBalances.put(item.getBalanceId(), balance);
}
long balanceAfter = value(balance.getAvailablePackages()) + delta;
if (balanceAfter < 0) throw conflict("Stocktake adjustment would make inventory negative");
position.setAvailablePackages(newAvailable); position.setStatus(physicalAfter(position, newAvailable) == 0 ? "EMPTY" : "OCCUPIED");
position.setVersion(value(position.getVersion()) + 1); position.setUpdatedAt(now); positions.updateById(position);
balance.setAvailablePackages(balanceAfter); balance.setVersion(value(balance.getVersion()) + 1); balance.setUpdatedAt(now);
ledger(task, item, position, balance, operatorKey, key, request.reason(), before, delta,
value(item.getActualPackages()), now);
item.setStatus(delta == 0 ? "MATCHED" : "ADJUSTED"); item.setUpdatedAt(now);
item.setVersion(value(item.getVersion()) + 1); items.updateById(item);
}
changedBalances.values().forEach(balances::updateById);
if (tasks.submit(task.getId(), request.expectedVersion(), operatorKey, request.reason()) != 1) {
throw conflict("Stocktake task version changed; refresh and retry");
}
history.record(warehouseId, null, operatorKey, "STOCKTAKE_SUBMIT", taskNo, "COMPLETED",
"STOCKTAKE_SUBMIT:" + key, requestHash,
"盘点提交,差异位置 " + task.getDifferenceCount() + " 个;原因:" + request.reason().trim());
PdaStocktakeResponses.Detail response = detail(requireTask(warehouseId, taskNo, false));
complete(action, response);
return response;
}
private PdaStocktakeResponses.Detail detail(PdaStocktakeTask task) {
return new PdaStocktakeResponses.Detail(summary(task), items.views(task.getId()));
}
private PdaStocktakeResponses.Summary summary(PdaStocktakeTask task) {
return new PdaStocktakeResponses.Summary(task.getTaskNo(), task.getScopeType(), task.getScopeCode(),
task.getStatus(), intValue(task.getPositionCount()), intValue(task.getCountedCount()),
intValue(task.getDifferenceCount()), value(task.getVersion()), task.getCreatedAt(), task.getSubmittedAt());
}
private PdaStocktakeTask requireTask(long warehouseId, String taskNo, boolean lock) {
String normalized = required(taskNo, "Stocktake task number is required");
PdaStocktakeTask task = lock ? tasks.lock(warehouseId, normalized) : tasks.find(warehouseId, normalized);
if (task == null) throw notFound("Stocktake task was not found in the bound warehouse");
return task;
}
private PdaStocktakeAction beginAction(long warehouseId, String operatorKey, String key, String type,
String requestHash, String taskNo) {
PdaStocktakeAction existing = actions.find(warehouseId, operatorKey, key);
if (existing != null) return replay(existing, type, requestHash);
PdaStocktakeAction action = new PdaStocktakeAction();
action.setId(IdWorker.getId()); action.setOverseasWarehouseId(warehouseId); action.setOperatorKey(operatorKey);
action.setIdempotencyKey(key); action.setActionType(type); action.setRequestHash(requestHash);
PdaStocktakeTask task = tasks.find(warehouseId, taskNo); action.setTaskId(task == null ? null : task.getId());
action.setStatus("PROCESSING"); action.setCreatedAt(LocalDateTime.now()); action.setUpdatedAt(action.getCreatedAt());
try { actions.insert(action); return action; }
catch (DuplicateKeyException duplicate) {
PdaStocktakeAction winner = actions.find(warehouseId, operatorKey, key);
if (winner == null) throw duplicate;
return replay(winner, type, requestHash);
}
}
private PdaStocktakeAction replay(PdaStocktakeAction action, String type, String requestHash) {
if (!type.equals(action.getActionType()) || !requestHash.equals(action.getRequestHash())) {
throw conflict("Idempotency key was reused with a different stocktake action");
}
if (!"COMPLETED".equals(action.getStatus())) throw conflict("Stocktake action is still processing");
return action;
}
private void complete(PdaStocktakeAction action, Object response) {
action.setStatus("COMPLETED"); action.setResultJson(write(response)); action.setUpdatedAt(LocalDateTime.now());
actions.updateById(action);
}
private <T> T read(PdaStocktakeAction action, Class<T> type) {
try { return objectMapper.readValue(action.getResultJson(), type); }
catch (JsonProcessingException error) { throw new IllegalStateException("Cannot read stocktake replay", error); }
}
private void ledger(PdaStocktakeTask task, PdaStocktakeItem item, InventoryPosition position,
InventoryBalance balance, String operator, String key, String reason,
long before, long delta, long after, LocalDateTime now) {
InventoryLedger row = new InventoryLedger();
row.setId(IdWorker.getId()); row.setCustomerId(item.getCustomerId()); row.setBalanceId(balance.getId());
row.setPositionId(position.getId()); row.setOverseasWarehouseId(String.valueOf(task.getOverseasWarehouseId()));
row.setDestinationWarehouseCode(balance.getDestinationWarehouseCode()); row.setInboundOrderId(balance.getInboundOrderId());
row.setPalletId(position.getPalletId()); row.setPalletNo(position.getPalletNo()); row.setBatchNo(position.getBatchNo());
row.setSkuCode(item.getSkuCode()); row.setWarehouseLocationCode(position.getWarehouseLocationCode());
row.setBusinessType("STOCKTAKE_ADJUST"); row.setBusinessOrderId(task.getId()); row.setBusinessOrderNo(task.getTaskNo());
row.setInboundCode(balance.getInboundCode()); row.setInboundOrderNo(balance.getInboundOrderNo());
row.setIdempotencyKey("STOCKTAKE:" + task.getId() + ":" + item.getId());
row.setStatusFrom("PHYSICAL"); row.setStatusTo("PHYSICAL"); row.setBeforePackages(before);
row.setDeltaPackages(delta); row.setAfterPackages(after); row.setOperatorId(operator); row.setRemark(reason.trim());
row.setOccurredAt(now); row.setCreatedAt(now); ledgers.insert(row);
}
private String hash(Object value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(write(value).getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException error) { throw new IllegalStateException(error); }
}
private String write(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException error) { throw new IllegalStateException("Cannot serialize stocktake operation", error); }
}
private static String status(String value) {
String normalized = trim(value); if (normalized == null) return null;
normalized = normalized.toUpperCase();
if (!List.of("IN_PROGRESS", "SUBMITTED").contains(normalized)) throw badRequest("Unsupported stocktake status");
return normalized;
}
private static long physical(InventoryPosition value) { return value(value.getAvailablePackages()) + value(value.getHoldPackages())
+ value(value.getAllocatedPackages()) + value(value.getPickedPackages()) + value(value.getReceivingPackages()); }
private static long physicalAfter(InventoryPosition value, long available) { return available + value(value.getHoldPackages())
+ value(value.getAllocatedPackages()) + value(value.getPickedPackages()) + value(value.getReceivingPackages()); }
private static long value(Long value) { return value == null ? 0 : value; }
private static int intValue(Integer value) { return value == null ? 0 : value; }
private static String required(String value, String message) { String normalized = trim(value); if (normalized == null) throw badRequest(message); return normalized; }
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.relabel.pda;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan("com.baiyue.lastleg.relabel.pda.mapper")
public class PdaRelabelMapperConfiguration {
}
@@ -0,0 +1,127 @@
package com.baiyue.lastleg.relabel.pda.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelRequests;
import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelResponses;
import com.baiyue.lastleg.relabel.pda.service.PdaRelabelService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@RestController
@RequestMapping("/api/pda/v1/last-leg/relabel-tasks")
@PreAuthorize("hasAuthority('lastleg:pda:operate')")
public class PdaRelabelController {
public static final String OPERATE_PERMISSION = "lastleg:pda:operate";
private static final String PICKER_ROLE = "ROLE_WAREHOUSE_PICKER";
@Resource private PdaRelabelService service;
@GetMapping("/candidates")
public ApiResponse<List<PdaRelabelResponses.Candidate>> candidates(
@AuthenticationPrincipal AuthenticatedUser user) {
Identity identity = identity(user);
return ApiResponse.success(service.candidates(identity.operatorKey(), identity.overseasWarehouseId()));
}
@GetMapping("/{relabelNo}")
public ApiResponse<PdaRelabelResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo) {
Identity identity = identity(user);
return ApiResponse.success(service.detail(identity.operatorKey(), identity.overseasWarehouseId(), relabelNo));
}
@PostMapping("/{relabelNo}/claim")
public ApiResponse<PdaRelabelResponses.Detail> claim(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Claim request) {
Identity identity = identity(user);
return ApiResponse.success(service.claim(identity.operatorKey(), identity.operatorName(),
identity.overseasWarehouseId(), relabelNo, request));
}
@PostMapping("/{relabelNo}/units")
public ApiResponse<PdaRelabelResponses.Detail> scan(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Scan request) {
Identity identity = identity(user);
return ApiResponse.success(service.scan(identity.operatorKey(), identity.operatorName(),
identity.overseasWarehouseId(), relabelNo, request));
}
@PostMapping("/{relabelNo}/units/skip")
public ApiResponse<PdaRelabelResponses.Detail> skip(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Skip request) {
Identity identity = identity(user);
return ApiResponse.success(service.skip(identity.operatorKey(), identity.operatorName(),
identity.overseasWarehouseId(), relabelNo, request));
}
@PostMapping("/{relabelNo}/exceptions")
public ApiResponse<PdaRelabelResponses.Detail> exception(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.ExceptionFact request) {
Identity identity = identity(user);
return ApiResponse.success(service.exception(identity.operatorKey(), identity.operatorName(),
identity.overseasWarehouseId(), relabelNo, request));
}
@PostMapping(value = "/{relabelNo}/photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<PdaRelabelResponses.Photo> uploadPhoto(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @RequestPart("file") MultipartFile file) {
Identity identity = identity(user);
return ApiResponse.success(service.uploadPhoto(identity.operatorKey(), identity.overseasWarehouseId(),
identity.userId(), relabelNo, file));
}
@PostMapping("/{relabelNo}/complete")
public ApiResponse<PdaRelabelResponses.Detail> complete(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Complete request) {
Identity identity = identity(user);
return ApiResponse.success(service.complete(identity.operatorKey(), identity.operatorName(),
identity.overseasWarehouseId(), relabelNo, request));
}
@PostMapping("/{relabelNo}/review")
public ApiResponse<PdaRelabelResponses.Detail> review(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Review request) {
Identity identity = identity(user);
return ApiResponse.success(service.review(identity.operatorKey(), identity.operatorName(),
identity.overseasWarehouseId(), relabelNo, request));
}
static Identity identity(AuthenticatedUser user) {
if (user == null || user.userId() == null || user.tenantId() == null || user.tenantId() != 0L
|| !"PLATFORM".equals(user.appCode())) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,
"Authenticated platform warehouse PDA identity is required");
}
if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION)
|| !user.permissions().contains(PICKER_ROLE)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
"WAREHOUSE_PICKER and " + OPERATE_PERMISSION + " are required");
}
if (user.overseasWarehouseId() == null || user.overseasWarehouseId() <= 0L) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
"PDA operator must be bound to one overseas warehouse");
}
String operator = String.valueOf(user.userId());
return new Identity(user.userId(), operator,
user.username() == null || user.username().isBlank() ? operator : user.username(),
user.overseasWarehouseId());
}
record Identity(long userId, String operatorKey, String operatorName, long overseasWarehouseId) { }
}
@@ -0,0 +1,45 @@
package com.baiyue.lastleg.relabel.pda.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.util.List;
public final class PdaRelabelRequests {
private PdaRelabelRequests() { }
public record Claim(@NotBlank @Size(max = 255) String idempotencyKey) { }
public record Scan(@NotNull @Positive Long itemId,
@NotBlank @Size(max = 255) String oldLabel,
@NotBlank @Size(max = 255) String newLabel,
@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 255) String idempotencyKey) { }
public record Skip(@NotNull @Positive Long itemId,
@NotBlank @Size(max = 255) String oldLabel,
@NotBlank @Size(max = 1000) String reason,
@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 255) String idempotencyKey) { }
public record ExceptionFact(@NotNull @Positive Long itemId,
@Size(max = 255) String oldLabel,
@NotBlank @Size(max = 32) String exceptionCode,
@NotBlank @Size(max = 1000) String reason,
@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 255) String idempotencyKey) { }
public record Complete(@NotEmpty @Size(max = 20) List<@Positive Long> photoFileIds,
@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) { }
public record Review(@NotBlank @Size(max = 16) String decision,
@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) { }
}
@@ -0,0 +1,37 @@
package com.baiyue.lastleg.relabel.pda.dto;
import java.time.LocalDateTime;
import java.util.List;
public final class PdaRelabelResponses {
private PdaRelabelResponses() { }
public record Candidate(String relabelNo, String customerOrderNo, LocalDateTime labelDeadline,
String applicationStatus, String taskStatus, int itemCount,
int expectedPackages, int relabelledPackages, int skippedPackages,
int exceptionPackages, int version, boolean claimedByCurrentOperator) { }
public record Detail(String relabelNo, String customerOrderNo, LocalDateTime labelDeadline,
String contactName, String contactPhone, String applicationStatus,
String taskStatus, int expectedPackages, int relabelledPackages,
int skippedPackages, int exceptionPackages, int version,
String operatorName, String reviewerName, List<Long> completionPhotoFileIds,
String completionRemark, String reviewDecision, String reviewRemark,
LocalDateTime claimedAt, LocalDateTime submittedAt, LocalDateTime reviewedAt,
List<Item> items, List<UnitFact> unitFacts, List<History> history) { }
public record Item(Long itemId, String skuCode, String productNameZh, String shipmentNo,
String trackingCode, String newSkuCode, String labelRequirement,
String relabelFileId, String relabelFileName, String relabelFileUrl,
int expectedPackages, int relabelledPackages, int skippedPackages,
int exceptionPackages) { }
public record UnitFact(Long factId, Long itemId, String oldLabel, String newLabel,
String result, String exceptionCode, String reason,
String operatorName, LocalDateTime occurredAt) { }
public record History(String action, String fromStatus, String toStatus,
String operatorName, String detail, LocalDateTime createdAt) { }
public record Photo(long fileId, String fileName, String contentType, long sizeBytes) { }
}
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.relabel.pda.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_relabel_pda_history")
public class PdaRelabelHistory {
@TableId(type = IdType.INPUT) private Long id;
private Long taskId;
private Long applicationId;
private String customerId;
private String action;
private String fromStatus;
private String toStatus;
private String operatorKey;
private String operatorName;
private String detailJson;
private LocalDateTime createdAt;
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.relabel.pda.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_relabel_pda_operation")
public class PdaRelabelOperation {
@TableId(type = IdType.INPUT) private Long id;
private Long taskId;
private String customerId;
private String operatorKey;
private String action;
private String idempotencyKey;
private String requestHash;
private String resultJson;
private LocalDateTime createdAt;
}
@@ -0,0 +1,37 @@
package com.baiyue.lastleg.relabel.pda.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_relabel_pda_task")
public class PdaRelabelTask {
@TableId(type = IdType.INPUT) private Long id;
private Long applicationId;
private String customerId;
private String relabelNo;
private Long overseasWarehouseId;
private String status;
private String operatorKey;
private String operatorName;
private String reviewerKey;
private String reviewerName;
private Integer expectedPackages;
private Integer relabelledPackages;
private Integer skippedPackages;
private Integer exceptionPackages;
private String completionPhotoFileIds;
private String completionRemark;
private String reviewDecision;
private String reviewRemark;
private LocalDateTime claimedAt;
private LocalDateTime submittedAt;
private LocalDateTime reviewedAt;
private Integer version;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.relabel.pda.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_relabel_pda_unit_fact")
public class PdaRelabelUnitFact {
@TableId(type = IdType.INPUT) private Long id;
private Long taskId;
private Long applicationId;
private Long itemId;
private String customerId;
private String oldLabel;
private String newLabel;
private String result;
private String exceptionCode;
private String reason;
private String operatorKey;
private String operatorName;
private LocalDateTime occurredAt;
}
@@ -0,0 +1,109 @@
package com.baiyue.lastleg.relabel.pda.gateway;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
@Component
public class HttpRelabelPhotoFileGateway implements RelabelPhotoFileGateway {
private static final String BUSINESS_TYPE = "OVERSEAS_RELABEL_PHOTO";
@Resource private RestClient.Builder restClientBuilder;
@Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
private RestClient client;
@PostConstruct
void initializeClient() { client = restClientBuilder.baseUrl(baseUrl).build(); }
@Override
public UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file) {
long tenantId = tenantId(customerId);
MultipartBodyBuilder body = new MultipartBodyBuilder();
try {
body.part("file", new ByteArrayResource(file.getBytes()) {
@Override public String getFilename() { return file.getOriginalFilename(); }
}).contentType(file.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM
: MediaType.parseMediaType(file.getContentType()));
} catch (IOException exception) {
throw new IllegalArgumentException("Cannot read relabel photo", exception);
}
MetadataEnvelope response = client.post()
.uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}",
tenantId, BUSINESS_TYPE, taskId)
.header("X-Internal-Service-Token", internalToken)
.header("X-Operator-Id", String.valueOf(operatorId))
.contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> {
throw new ResponseStatusException(result.getStatusCode().is4xxClientError()
? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE,
"Relabel photo upload failed");
}).body(MetadataEnvelope.class);
requireSuccess(response, "Relabel photo upload failed");
Metadata data = response == null ? null : response.data();
if (data == null || data.fileId() == null) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"File service returned no relabel photo metadata");
}
return new UploadedFile(data.fileId(), data.fileName(), data.contentType(),
data.sizeBytes() == null ? 0L : data.sizeBytes());
}
@Override
public void validate(String customerId, long taskId, long fileId) {
long tenantId = tenantId(customerId);
MetadataEnvelope response = client.get()
.uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}",
tenantId, BUSINESS_TYPE, taskId, fileId)
.header("X-Internal-Service-Token", internalToken).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> {
throw new ResponseStatusException(result.getStatusCode().is4xxClientError()
? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE,
"Relabel photo does not exist or its ownership is invalid");
}).body(MetadataEnvelope.class);
requireSuccess(response, "Relabel photo does not exist or its ownership is invalid");
Metadata data = response == null ? null : response.data();
if (data == null || data.fileId() == null || data.fileId() != fileId
|| data.tenantId() == null || data.tenantId() != tenantId
|| !BUSINESS_TYPE.equals(data.businessType())
|| !String.valueOf(taskId).equals(data.businessId())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Relabel photo metadata does not match the PDA task");
}
}
private static long tenantId(String customerId) {
try { return Long.parseLong(customerId); }
catch (NumberFormatException exception) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Customer ID must be numeric", exception);
}
}
private static void requireSuccess(MetadataEnvelope response, String fallback) {
if (response == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, fallback);
if ("200".equals(response.code())) return;
HttpStatus status = switch (String.valueOf(response.code())) {
case "A0400", "A0422" -> HttpStatus.BAD_REQUEST;
case "A0403" -> HttpStatus.FORBIDDEN;
case "A0404" -> HttpStatus.NOT_FOUND;
default -> HttpStatus.SERVICE_UNAVAILABLE;
};
throw new ResponseStatusException(status,
response.message() == null || response.message().isBlank() ? fallback : response.message());
}
private record MetadataEnvelope(String code, String message, Metadata data) { }
private record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
String fileName, String contentType, Long sizeBytes) { }
}
@@ -0,0 +1,10 @@
package com.baiyue.lastleg.relabel.pda.gateway;
import org.springframework.web.multipart.MultipartFile;
public interface RelabelPhotoFileGateway {
UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file);
void validate(String customerId, long taskId, long fileId);
record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) { }
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.relabel.pda.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface PdaRelabelHistoryMapper extends BaseMapper<PdaRelabelHistory> {
}
@@ -0,0 +1,13 @@
package com.baiyue.lastleg.relabel.pda.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelOperation;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface PdaRelabelOperationMapper extends BaseMapper<PdaRelabelOperation> {
@Select("SELECT * FROM ll_relabel_pda_operation WHERE operator_key=#{operatorKey} AND idempotency_key=#{key} LIMIT 1")
PdaRelabelOperation find(@Param("operatorKey") String operatorKey, @Param("key") String key);
}
@@ -0,0 +1,30 @@
package com.baiyue.lastleg.relabel.pda.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface PdaRelabelTaskMapper extends BaseMapper<PdaRelabelTask> {
@Select("SELECT * FROM ll_relabel_pda_task WHERE customer_id=#{customerId} AND application_id=#{applicationId} LIMIT 1")
PdaRelabelTask find(@Param("customerId") String customerId, @Param("applicationId") Long applicationId);
@Select("SELECT * FROM ll_relabel_pda_task WHERE customer_id=#{customerId} AND application_id=#{applicationId} LIMIT 1 FOR UPDATE")
PdaRelabelTask lock(@Param("customerId") String customerId, @Param("applicationId") Long applicationId);
@Update("""
UPDATE ll_relabel_pda_task
SET status=#{task.status}, operator_key=#{task.operatorKey}, operator_name=#{task.operatorName},
reviewer_key=#{task.reviewerKey}, reviewer_name=#{task.reviewerName},
relabelled_packages=#{task.relabelledPackages}, skipped_packages=#{task.skippedPackages},
exception_packages=#{task.exceptionPackages}, completion_photo_file_ids=#{task.completionPhotoFileIds},
completion_remark=#{task.completionRemark}, review_decision=#{task.reviewDecision},
review_remark=#{task.reviewRemark}, submitted_at=#{task.submittedAt}, reviewed_at=#{task.reviewedAt},
version=version+1, updated_at=#{task.updatedAt}
WHERE id=#{task.id} AND customer_id=#{task.customerId} AND version=#{expectedVersion}
""")
int updateWithVersion(@Param("task") PdaRelabelTask task, @Param("expectedVersion") Integer expectedVersion);
}
@@ -0,0 +1,13 @@
package com.baiyue.lastleg.relabel.pda.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface PdaRelabelUnitFactMapper extends BaseMapper<PdaRelabelUnitFact> {
@Select("SELECT * FROM ll_relabel_pda_unit_fact WHERE old_label=#{oldLabel} LIMIT 1")
PdaRelabelUnitFact findOldLabel(@Param("oldLabel") String oldLabel);
}
@@ -0,0 +1,480 @@
package com.baiyue.lastleg.relabel.pda.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.relabel.dto.RelabelRequests;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelRequests;
import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelResponses;
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory;
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelOperation;
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask;
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact;
import com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway;
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper;
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelOperationMapper;
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper;
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelUnitFactMapper;
import com.baiyue.lastleg.relabel.service.RelabelApplicationService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Consumer;
import java.util.stream.Collectors;
@Service
public class PdaRelabelService {
public static final String PROCESSING = "PROCESSING";
public static final String WAIT_REVIEW = "WAIT_REVIEW";
public static final String COMPLETED = "COMPLETED";
private static final long MAX_PHOTO_SIZE = 10L * 1024 * 1024;
private static final Set<String> EXCEPTION_CODES = Set.of(
"DAMAGED", "MISSING_LABEL", "LABEL_MISMATCH", "UNREADABLE", "OTHER");
@Resource private RelabelApplicationMapper applications;
@Resource private RelabelItemMapper items;
@Resource private PdaRelabelTaskMapper tasks;
@Resource private PdaRelabelUnitFactMapper unitFacts;
@Resource private PdaRelabelOperationMapper operations;
@Resource private PdaRelabelHistoryMapper histories;
@Resource private RelabelApplicationService applicationService;
@Resource private RelabelPhotoFileGateway photoFiles;
@Resource private ObjectMapper sourceObjectMapper;
private ObjectMapper objectMapper;
@PostConstruct
void initializeObjectMapper() {
objectMapper = sourceObjectMapper.copy().findAndRegisterModules()
.configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true)
.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true);
}
@Transactional(readOnly = true)
public List<PdaRelabelResponses.Candidate> candidates(String operatorKey, long overseasWarehouseId) {
return applications.selectList(new LambdaQueryWrapper<RelabelApplication>()
.eq(RelabelApplication::getOverseasWarehouseId, String.valueOf(overseasWarehouseId))
.in(RelabelApplication::getStatus, RelabelApplicationService.PENDING_PROCESSING, PROCESSING)
.orderByAsc(RelabelApplication::getLabelDeadline)
.orderByAsc(RelabelApplication::getCreatedAt))
.stream().map(application -> candidate(application,
tasks.find(application.getCustomerId(), application.getId()), operatorKey)).toList();
}
@Transactional(readOnly = true)
public PdaRelabelResponses.Detail detail(String operatorKey, long overseasWarehouseId, String relabelNo) {
RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, false);
return detail(application, tasks.find(application.getCustomerId(), application.getId()));
}
@Transactional
public PdaRelabelResponses.Detail claim(String operatorKey, String operatorName, long overseasWarehouseId,
String relabelNo, PdaRelabelRequests.Claim request) {
ClaimCommand command = new ClaimCommand(relabelNo, request.idempotencyKey());
PdaRelabelResponses.Detail replay = replay(operatorKey, overseasWarehouseId, relabelNo,
"CLAIM", request.idempotencyKey(), command);
if (replay != null) return replay;
RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, true);
if (!Set.of(RelabelApplicationService.PENDING_PROCESSING, PROCESSING).contains(application.getStatus())) {
throw conflict("Relabel application is not executable");
}
PdaRelabelTask task = tasks.lock(application.getCustomerId(), application.getId());
if (task != null) {
if (!operatorKey.equals(task.getOperatorKey()) || !PROCESSING.equals(task.getStatus())) {
throw conflict("Relabel task is already claimed or awaiting review");
}
} else {
LocalDateTime now = LocalDateTime.now();
task = new PdaRelabelTask();
task.setId(IdWorker.getId());
task.setApplicationId(application.getId()); task.setCustomerId(application.getCustomerId());
task.setRelabelNo(application.getRelabelNo()); task.setOverseasWarehouseId(overseasWarehouseId);
task.setStatus(PROCESSING); task.setOperatorKey(operatorKey); task.setOperatorName(operatorName);
task.setExpectedPackages(expectedPackages(application.getId()));
task.setRelabelledPackages(0); task.setSkippedPackages(0); task.setExceptionPackages(0);
task.setClaimedAt(now); task.setVersion(0); task.setCreatedAt(now); task.setUpdatedAt(now);
try { tasks.insert(task); }
catch (DuplicateKeyException duplicate) { throw conflict("Relabel task was claimed concurrently"); }
if (RelabelApplicationService.PENDING_PROCESSING.equals(application.getStatus())) {
applicationService.changeStatus(application.getCustomerId(), application.getRelabelNo(), operatorKey,
new RelabelRequests.ChangeStatus(PROCESSING, "PDA relabel task claimed"));
application.setStatus(PROCESSING);
}
insertHistory(task, "CLAIM", null, PROCESSING, operatorKey, operatorName,
Map.of("relabelNo", relabelNo));
}
PdaRelabelResponses.Detail result = detail(application, task);
saveOperation(task, operatorKey, "CLAIM", request.idempotencyKey(), command, result);
return result;
}
@Transactional
public PdaRelabelResponses.Detail scan(String operatorKey, String operatorName, long overseasWarehouseId,
String relabelNo, PdaRelabelRequests.Scan request) {
return unitAction(operatorKey, operatorName, overseasWarehouseId, relabelNo, "RELABEL", request.idempotencyKey(),
request, request.itemId(), request.oldLabel(), request.newLabel(), "RELABELLED", null, null,
request.expectedVersion(), item -> {
if (!item.getNewSkuCode().equalsIgnoreCase(request.newLabel().trim())) {
throw conflict("Scanned new label does not match the approved new SKU");
}
if (request.oldLabel().trim().equalsIgnoreCase(request.newLabel().trim())) {
throw conflict("Old and new labels must be different");
}
});
}
@Transactional
public PdaRelabelResponses.Detail skip(String operatorKey, String operatorName, long overseasWarehouseId,
String relabelNo, PdaRelabelRequests.Skip request) {
return unitAction(operatorKey, operatorName, overseasWarehouseId, relabelNo, "SKIP", request.idempotencyKey(),
request, request.itemId(), request.oldLabel(), null, "SKIPPED", null, request.reason(),
request.expectedVersion(), ignored -> { });
}
@Transactional
public PdaRelabelResponses.Detail exception(String operatorKey, String operatorName, long overseasWarehouseId,
String relabelNo, PdaRelabelRequests.ExceptionFact request) {
String code = request.exceptionCode().trim().toUpperCase(Locale.ROOT);
if (!EXCEPTION_CODES.contains(code)) throw badRequest("Unsupported relabel exception code");
return unitAction(operatorKey, operatorName, overseasWarehouseId, relabelNo, "EXCEPTION",
request.idempotencyKey(), request, request.itemId(), trim(request.oldLabel()), null,
"EXCEPTION", code, request.reason(), request.expectedVersion(), ignored -> { });
}
public PdaRelabelResponses.Photo uploadPhoto(String operatorKey, long overseasWarehouseId, long operatorId,
String relabelNo, MultipartFile file) {
RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, false);
PdaRelabelTask task = requireOwnedTask(application, operatorKey, false);
if (!PROCESSING.equals(task.getStatus())) throw conflict("Relabel photos can only be uploaded while processing");
if (file == null || file.isEmpty()) throw badRequest("Relabel photo is required");
if (file.getSize() > MAX_PHOTO_SIZE) throw badRequest("Relabel photo exceeds 10 MB");
if (file.getContentType() == null || !file.getContentType().toLowerCase(Locale.ROOT).startsWith("image/")) {
throw badRequest("Relabel photo must be an image");
}
RelabelPhotoFileGateway.UploadedFile uploaded = photoFiles.upload(application.getCustomerId(),
task.getId(), operatorId, file);
return new PdaRelabelResponses.Photo(uploaded.fileId(), uploaded.fileName(),
uploaded.contentType(), uploaded.sizeBytes());
}
@Transactional
public PdaRelabelResponses.Detail complete(String operatorKey, String operatorName, long overseasWarehouseId,
String relabelNo, PdaRelabelRequests.Complete request) {
CompleteCommand command = new CompleteCommand(relabelNo, request);
PdaRelabelResponses.Detail replay = replay(operatorKey, overseasWarehouseId, relabelNo,
"COMPLETE", request.idempotencyKey(), command);
if (replay != null) return replay;
RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, true);
PdaRelabelTask task = requireOwnedTask(application, operatorKey, true);
requireVersion(task, request.expectedVersion());
List<RelabelItem> detailItems = itemList(application);
List<PdaRelabelUnitFact> facts = factList(task);
for (RelabelItem item : detailItems) {
long actual = facts.stream().filter(fact -> item.getId().equals(fact.getItemId())).count();
if (actual != value(item.getNotifiedPackageCount())) {
throw conflict("Every relabel item must have exactly one fact per notified package");
}
}
List<Long> photoIds = request.photoFileIds().stream().distinct().toList();
if (photoIds.size() != request.photoFileIds().size()) throw conflict("Relabel photo IDs must be unique");
photoIds.forEach(fileId -> photoFiles.validate(application.getCustomerId(), task.getId(), fileId));
String before = task.getStatus();
task.setStatus(WAIT_REVIEW); task.setCompletionPhotoFileIds(write(photoIds));
task.setCompletionRemark(trim(request.remark())); task.setSubmittedAt(LocalDateTime.now());
updateTask(task, request.expectedVersion());
insertHistory(task, "COMPLETE", before, WAIT_REVIEW, operatorKey, operatorName,
Map.of("photoFileIds", photoIds, "remark", Objects.toString(trim(request.remark()), "")));
application.setStatus(PROCESSING);
PdaRelabelResponses.Detail result = detail(application, task);
saveOperation(task, operatorKey, "COMPLETE", request.idempotencyKey(), command, result);
return result;
}
@Transactional
public PdaRelabelResponses.Detail review(String operatorKey, String operatorName, long overseasWarehouseId,
String relabelNo, PdaRelabelRequests.Review request) {
ReviewCommand command = new ReviewCommand(relabelNo, request);
PdaRelabelResponses.Detail replay = replay(operatorKey, overseasWarehouseId, relabelNo,
"REVIEW", request.idempotencyKey(), command);
if (replay != null) return replay;
String decision = request.decision().trim().toUpperCase(Locale.ROOT);
if (!Set.of("APPROVE", "REWORK").contains(decision)) throw badRequest("Review decision must be APPROVE or REWORK");
RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, true);
if (!PROCESSING.equals(application.getStatus())) {
throw conflict("Relabel application is no longer executable");
}
PdaRelabelTask task = tasks.lock(application.getCustomerId(), application.getId());
if (task == null) throw notFound("PDA relabel task was not found");
if (!Objects.equals(task.getOverseasWarehouseId(), overseasWarehouseId)) {
throw notFound("PDA relabel task was not found in the bound overseas warehouse");
}
if (!WAIT_REVIEW.equals(task.getStatus())) throw conflict("Relabel task is not awaiting review");
requireVersion(task, request.expectedVersion());
String before = task.getStatus();
String after = "APPROVE".equals(decision) ? COMPLETED : PROCESSING;
task.setStatus(after); task.setReviewerKey(operatorKey); task.setReviewerName(operatorName);
task.setReviewDecision(decision); task.setReviewRemark(trim(request.remark()));
task.setReviewedAt(LocalDateTime.now());
updateTask(task, request.expectedVersion());
if ("APPROVE".equals(decision)) {
applicationService.changeStatus(application.getCustomerId(), relabelNo, operatorKey,
new RelabelRequests.ChangeStatus(COMPLETED, request.remark()));
application.setStatus(COMPLETED);
}
insertHistory(task, "REVIEW_" + decision, before, after, operatorKey, operatorName,
Map.of("decision", decision, "remark", Objects.toString(trim(request.remark()), "")));
PdaRelabelResponses.Detail result = detail(application, task);
saveOperation(task, operatorKey, "REVIEW", request.idempotencyKey(), command, result);
return result;
}
private PdaRelabelResponses.Detail unitAction(String operatorKey, String operatorName,
long overseasWarehouseId, String relabelNo, String action, String idempotencyKey, Object command,
Long itemId, String oldLabel, String newLabel, String resultCode, String exceptionCode, String reason,
Integer expectedVersion, Consumer<RelabelItem> validation) {
UnitCommand replayCommand = new UnitCommand(relabelNo, action, command);
PdaRelabelResponses.Detail replay = replay(operatorKey, overseasWarehouseId, relabelNo,
action, idempotencyKey, replayCommand);
if (replay != null) return replay;
RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, true);
PdaRelabelTask task = requireOwnedTask(application, operatorKey, true);
requireVersion(task, expectedVersion);
RelabelItem item = requireItem(application, itemId);
validation.accept(item);
List<PdaRelabelUnitFact> existingFacts = factList(task);
long itemFacts = existingFacts.stream().filter(fact -> itemId.equals(fact.getItemId())).count();
if (itemFacts >= value(item.getNotifiedPackageCount())) {
throw conflict("Relabel item already has all required package facts");
}
String normalizedOldLabel = trim(oldLabel);
if (normalizedOldLabel != null && unitFacts.findOldLabel(normalizedOldLabel) != null) {
throw conflict("Old label was already processed by another relabel unit");
}
PdaRelabelUnitFact fact = new PdaRelabelUnitFact();
fact.setId(IdWorker.getId()); fact.setTaskId(task.getId()); fact.setApplicationId(application.getId());
fact.setItemId(itemId); fact.setCustomerId(application.getCustomerId());
fact.setOldLabel(normalizedOldLabel); fact.setNewLabel(trim(newLabel)); fact.setResult(resultCode);
fact.setExceptionCode(exceptionCode); fact.setReason(trim(reason));
fact.setOperatorKey(operatorKey); fact.setOperatorName(operatorName); fact.setOccurredAt(LocalDateTime.now());
try { unitFacts.insert(fact); }
catch (DuplicateKeyException duplicate) { throw conflict("Old label was processed concurrently"); }
if ("RELABELLED".equals(resultCode)) task.setRelabelledPackages(value(task.getRelabelledPackages()) + 1);
if ("SKIPPED".equals(resultCode)) task.setSkippedPackages(value(task.getSkippedPackages()) + 1);
if ("EXCEPTION".equals(resultCode)) task.setExceptionPackages(value(task.getExceptionPackages()) + 1);
updateTask(task, expectedVersion);
insertHistory(task, action, PROCESSING, PROCESSING, operatorKey, operatorName,
Map.of("factId", fact.getId(), "itemId", itemId, "result", resultCode));
PdaRelabelResponses.Detail response = detail(application, task);
saveOperation(task, operatorKey, action, idempotencyKey, replayCommand, response);
return response;
}
private RelabelApplication requireApplication(String relabelNo, long overseasWarehouseId, boolean lock) {
RelabelApplication application = lock ? applications.lockAnyByRelabelNo(relabelNo)
: applications.findAnyByRelabelNo(relabelNo);
if (application == null) throw notFound("Relabel application was not found");
long applicationWarehouse;
try { applicationWarehouse = Long.parseLong(application.getOverseasWarehouseId()); }
catch (Exception invalid) { throw conflict("Relabel application has no valid overseas warehouse"); }
if (applicationWarehouse != overseasWarehouseId) {
throw notFound("Relabel application was not found in the bound overseas warehouse");
}
return application;
}
private PdaRelabelTask requireOwnedTask(RelabelApplication application, String operatorKey, boolean lock) {
if (!PROCESSING.equals(application.getStatus())) {
throw conflict("Relabel application is no longer executable");
}
PdaRelabelTask task = lock ? tasks.lock(application.getCustomerId(), application.getId())
: tasks.find(application.getCustomerId(), application.getId());
if (task == null) throw conflict("Claim the relabel task before operating");
if (!operatorKey.equals(task.getOperatorKey())) throw conflict("Relabel task is claimed by another operator");
if (!Objects.equals(task.getOverseasWarehouseId(), Long.parseLong(application.getOverseasWarehouseId()))) {
throw conflict("Relabel task warehouse does not match the approved application");
}
if (!PROCESSING.equals(task.getStatus())) throw conflict("Relabel task is not in processing status");
return task;
}
private RelabelItem requireItem(RelabelApplication application, Long itemId) {
RelabelItem item = items.selectOne(new LambdaQueryWrapper<RelabelItem>()
.eq(RelabelItem::getId, itemId).eq(RelabelItem::getApplicationId, application.getId())
.eq(RelabelItem::getCustomerId, application.getCustomerId()));
if (item == null) throw notFound("Relabel item does not belong to the current application");
return item;
}
private void requireVersion(PdaRelabelTask task, Integer expectedVersion) {
if (!Objects.equals(task.getVersion(), expectedVersion)) {
throw conflict("Relabel task version changed; reload before retrying");
}
}
private void updateTask(PdaRelabelTask task, Integer expectedVersion) {
task.setUpdatedAt(LocalDateTime.now());
if (tasks.updateWithVersion(task, expectedVersion) != 1) {
throw conflict("Relabel task version changed concurrently");
}
task.setVersion(expectedVersion + 1);
}
private int expectedPackages(Long applicationId) {
return itemList(applicationId).stream().mapToInt(item -> value(item.getNotifiedPackageCount())).sum();
}
private List<RelabelItem> itemList(RelabelApplication application) { return itemList(application.getId()); }
private List<RelabelItem> itemList(Long applicationId) {
return items.selectList(new LambdaQueryWrapper<RelabelItem>()
.eq(RelabelItem::getApplicationId, applicationId).orderByAsc(RelabelItem::getId));
}
private List<PdaRelabelUnitFact> factList(PdaRelabelTask task) {
return unitFacts.selectList(new LambdaQueryWrapper<PdaRelabelUnitFact>()
.eq(PdaRelabelUnitFact::getCustomerId, task.getCustomerId())
.eq(PdaRelabelUnitFact::getTaskId, task.getId())
.orderByAsc(PdaRelabelUnitFact::getOccurredAt).orderByAsc(PdaRelabelUnitFact::getId));
}
private PdaRelabelResponses.Candidate candidate(RelabelApplication application, PdaRelabelTask task,
String operatorKey) {
return new PdaRelabelResponses.Candidate(application.getRelabelNo(), application.getCustomerOrderNo(),
application.getLabelDeadline(), application.getStatus(), task == null ? "AVAILABLE" : task.getStatus(),
value(application.getItemCount()), task == null ? value(application.getNotifiedPackageCount()) : value(task.getExpectedPackages()),
task == null ? 0 : value(task.getRelabelledPackages()), task == null ? 0 : value(task.getSkippedPackages()),
task == null ? 0 : value(task.getExceptionPackages()), task == null ? 0 : value(task.getVersion()),
task != null && operatorKey.equals(task.getOperatorKey()));
}
private PdaRelabelResponses.Detail detail(RelabelApplication application, PdaRelabelTask task) {
List<RelabelItem> detailItems = itemList(application);
List<PdaRelabelUnitFact> facts = task == null ? List.of() : factList(task);
Map<Long, List<PdaRelabelUnitFact>> factsByItem = facts.stream()
.collect(Collectors.groupingBy(PdaRelabelUnitFact::getItemId, LinkedHashMap::new, Collectors.toList()));
List<PdaRelabelResponses.Item> itemResponses = detailItems.stream().map(item -> {
List<PdaRelabelUnitFact> itemFacts = factsByItem.getOrDefault(item.getId(), List.of());
return new PdaRelabelResponses.Item(item.getId(), item.getSkuCode(), item.getProductNameZh(),
item.getShipmentNo(), item.getTrackingCode(), item.getNewSkuCode(), item.getLabelRequirement(),
item.getRelabelFileId(), item.getRelabelFileName(), item.getRelabelFileUrl(),
value(item.getNotifiedPackageCount()), count(itemFacts, "RELABELLED"), count(itemFacts, "SKIPPED"),
count(itemFacts, "EXCEPTION"));
}).toList();
List<PdaRelabelResponses.UnitFact> factResponses = facts.stream().map(fact ->
new PdaRelabelResponses.UnitFact(fact.getId(), fact.getItemId(), fact.getOldLabel(), fact.getNewLabel(),
fact.getResult(), fact.getExceptionCode(), fact.getReason(), fact.getOperatorName(), fact.getOccurredAt())).toList();
List<PdaRelabelResponses.History> historyResponses = task == null ? List.of() : histories.selectList(
new LambdaQueryWrapper<PdaRelabelHistory>().eq(PdaRelabelHistory::getCustomerId, application.getCustomerId())
.eq(PdaRelabelHistory::getTaskId, task.getId())
.orderByAsc(PdaRelabelHistory::getCreatedAt).orderByAsc(PdaRelabelHistory::getId)).stream()
.map(history -> new PdaRelabelResponses.History(history.getAction(), history.getFromStatus(),
history.getToStatus(), history.getOperatorName(), history.getDetailJson(), history.getCreatedAt())).toList();
return new PdaRelabelResponses.Detail(application.getRelabelNo(), application.getCustomerOrderNo(),
application.getLabelDeadline(), application.getContactName(), application.getContactPhone(),
application.getStatus(), task == null ? "AVAILABLE" : task.getStatus(),
task == null ? value(application.getNotifiedPackageCount()) : value(task.getExpectedPackages()),
task == null ? 0 : value(task.getRelabelledPackages()), task == null ? 0 : value(task.getSkippedPackages()),
task == null ? 0 : value(task.getExceptionPackages()), task == null ? 0 : value(task.getVersion()),
task == null ? null : task.getOperatorName(), task == null ? null : task.getReviewerName(),
task == null ? List.of() : readIds(task.getCompletionPhotoFileIds()),
task == null ? null : task.getCompletionRemark(), task == null ? null : task.getReviewDecision(),
task == null ? null : task.getReviewRemark(), task == null ? null : task.getClaimedAt(),
task == null ? null : task.getSubmittedAt(), task == null ? null : task.getReviewedAt(),
itemResponses, factResponses, historyResponses);
}
private void insertHistory(PdaRelabelTask task, String action, String from, String to,
String operatorKey, String operatorName, Object detail) {
PdaRelabelHistory history = new PdaRelabelHistory();
history.setId(IdWorker.getId()); history.setTaskId(task.getId());
history.setApplicationId(task.getApplicationId()); history.setCustomerId(task.getCustomerId());
history.setAction(action); history.setFromStatus(from); history.setToStatus(to);
history.setOperatorKey(operatorKey); history.setOperatorName(operatorName);
history.setDetailJson(write(detail)); history.setCreatedAt(LocalDateTime.now());
histories.insert(history);
}
private PdaRelabelResponses.Detail replay(String operatorKey, long overseasWarehouseId, String relabelNo,
String action, String key, Object request) {
PdaRelabelOperation operation = operations.find(operatorKey, idempotencyKey(key));
if (operation == null) return null;
PdaRelabelTask task = tasks.selectById(operation.getTaskId());
if (task == null || !Objects.equals(task.getOverseasWarehouseId(), overseasWarehouseId)
|| !Objects.equals(task.getRelabelNo(), relabelNo)
|| !Objects.equals(task.getCustomerId(), operation.getCustomerId())) {
throw notFound("PDA relabel operation was not found in the bound overseas warehouse");
}
if (!action.equals(operation.getAction()) || !hash(request).equals(operation.getRequestHash())) {
throw conflict("Idempotency key was already used with a different relabel request");
}
try { return objectMapper.readValue(operation.getResultJson(), PdaRelabelResponses.Detail.class); }
catch (JsonProcessingException exception) { throw new IllegalStateException("Cannot read PDA relabel replay", exception); }
}
private void saveOperation(PdaRelabelTask task, String operatorKey, String action, String key,
Object request, PdaRelabelResponses.Detail result) {
PdaRelabelOperation operation = new PdaRelabelOperation();
operation.setId(IdWorker.getId()); operation.setTaskId(task.getId()); operation.setCustomerId(task.getCustomerId());
operation.setOperatorKey(operatorKey); operation.setAction(action); operation.setIdempotencyKey(idempotencyKey(key));
operation.setRequestHash(hash(request)); operation.setResultJson(write(result)); operation.setCreatedAt(LocalDateTime.now());
try { operations.insert(operation); }
catch (DuplicateKeyException duplicate) { throw conflict("Relabel operation was submitted concurrently"); }
}
private String hash(Object value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(write(value).getBytes(StandardCharsets.UTF_8)));
} catch (Exception exception) { throw new IllegalStateException(exception); }
}
private String write(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException exception) { throw new IllegalStateException(exception); }
}
private List<Long> readIds(String value) {
if (!StringUtils.hasText(value)) return List.of();
try {
return objectMapper.readValue(value, objectMapper.getTypeFactory().constructCollectionType(List.class, Long.class));
} catch (JsonProcessingException exception) { throw new IllegalStateException("Cannot read relabel photo IDs", exception); }
}
private static int count(List<PdaRelabelUnitFact> facts, String result) {
return (int) facts.stream().filter(fact -> result.equals(fact.getResult())).count();
}
private static int value(Integer value) { return value == null ? 0 : value; }
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static String idempotencyKey(String value) { return value == null ? null : value.trim(); }
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
private record ClaimCommand(String relabelNo, String idempotencyKey) { }
private record UnitCommand(String relabelNo, String action, Object request) { }
private record CompleteCommand(String relabelNo, PdaRelabelRequests.Complete request) { }
private record ReviewCommand(String relabelNo, PdaRelabelRequests.Review request) { }
}
@@ -744,6 +744,72 @@ CREATE TABLE IF NOT EXISTS ll_pda_operation_history (
KEY idx_ll_pda_history_business(overseas_warehouse_id,operation_type,business_key)
) ENGINE=InnoDB COMMENT='海外PDA真实作业历史';
CREATE TABLE IF NOT EXISTS ll_pda_pallet_operation (
id BIGINT NOT NULL COMMENT 'PDA托盘作业ID', overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID',
operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', idempotency_key VARCHAR(128) NOT NULL COMMENT '设备幂等键',
operation_type VARCHAR(64) NOT NULL COMMENT '托盘作业类型', request_hash VARCHAR(128) NOT NULL COMMENT '请求内容摘要',
status VARCHAR(32) NOT NULL COMMENT '处理状态:PROCESSING、COMPLETED、FAILED_COMPENSATED、COMPENSATION_REQUIRED',
result_json LONGTEXT NULL COMMENT '成功响应快照', remote_transitions_json LONGTEXT NULL COMMENT '实物托盘状态变更回执',
last_error VARCHAR(2000) NULL 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_ll_pda_pallet_idempotency(operator_key,idempotency_key),
KEY idx_ll_pda_pallet_warehouse(overseas_warehouse_id,status,updated_at)
) ENGINE=InnoDB COMMENT='海外PDA托盘作业幂等、补偿与结果记录';
CREATE TABLE IF NOT EXISTS ll_pda_stocktake_task (
id BIGINT NOT NULL COMMENT '盘点任务ID', task_no VARCHAR(64) NOT NULL COMMENT '盘点任务号',
overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', scope_type VARCHAR(16) NOT NULL COMMENT '盘点范围类型LOCATION或PALLET',
scope_resource_id BIGINT NOT NULL COMMENT '盘点库位或托盘资源ID', scope_code VARCHAR(128) NOT NULL COMMENT '盘点库位码或托盘码快照',
status VARCHAR(32) NOT NULL COMMENT '盘点任务状态', position_count INT NOT NULL COMMENT '库存位置总数',
counted_count INT NOT NULL DEFAULT 0 COMMENT '已盘库存位置数', difference_count INT NOT NULL DEFAULT 0 COMMENT '存在差异的库存位置数',
created_by VARCHAR(128) NOT NULL COMMENT '创建操作账号标识', submitted_by VARCHAR(128) NULL COMMENT '提交操作账号标识',
submit_reason VARCHAR(500) NULL COMMENT '盘点差异提交原因', idempotency_key VARCHAR(128) NOT NULL COMMENT '创建幂等键',
request_hash VARCHAR(128) NOT NULL COMMENT '创建请求哈希', version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间',
submitted_at DATETIME(3) NULL COMMENT '提交完成时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_pda_stocktake_no(task_no),
UNIQUE KEY uk_ll_pda_stocktake_create(overseas_warehouse_id,created_by,idempotency_key),
KEY idx_ll_pda_stocktake_list(overseas_warehouse_id,status,created_at)
) ENGINE=InnoDB COMMENT='海外PDA库存盘点任务';
CREATE TABLE IF NOT EXISTS ll_pda_stocktake_item (
id BIGINT NOT NULL COMMENT '盘点明细ID', task_id BIGINT NOT NULL COMMENT '盘点任务ID', customer_id VARCHAR(128) NOT NULL COMMENT '库存所属客户标识',
balance_id BIGINT NOT NULL COMMENT '库存余额ID', position_id BIGINT NOT NULL COMMENT '库存位置ID', sku_code VARCHAR(128) NULL COMMENT 'SKU快照',
product_name VARCHAR(255) NULL COMMENT '品名快照', inbound_code VARCHAR(128) NULL COMMENT '入仓码快照', pallet_id BIGINT NOT NULL COMMENT '实物托盘ID',
pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照', location_id BIGINT NOT NULL COMMENT '库位ID快照', location_code VARCHAR(128) NOT NULL COMMENT '库位码快照',
book_packages BIGINT NOT NULL COMMENT '任务创建时账面总箱数', actual_packages BIGINT NULL COMMENT '现场实盘总箱数',
difference_packages BIGINT NULL COMMENT '实盘与账面箱数差异', status VARCHAR(32) NOT NULL COMMENT '盘点明细状态',
position_version BIGINT NOT NULL COMMENT '任务创建时库存位置版本', counted_by VARCHAR(128) NULL COMMENT '盘点操作账号标识',
version BIGINT NOT NULL DEFAULT 0 COMMENT '明细乐观锁版本', counted_at DATETIME(3) NULL COMMENT '盘点时间',
created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_pda_stocktake_item(task_id,position_id), KEY idx_ll_pda_stocktake_item_task(task_id,status,id)
) ENGINE=InnoDB COMMENT='海外PDA库存盘点位置明细';
CREATE TABLE IF NOT EXISTS ll_pda_stocktake_action (
id BIGINT NOT NULL COMMENT '盘点动作ID', overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID',
operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', idempotency_key VARCHAR(128) NOT NULL COMMENT '动作幂等键',
action_type VARCHAR(32) NOT NULL COMMENT '盘点动作类型', task_id BIGINT NULL COMMENT '盘点任务ID', request_hash VARCHAR(128) NOT NULL COMMENT '动作请求哈希',
status VARCHAR(32) NOT NULL COMMENT '动作处理状态', result_json LONGTEXT NULL COMMENT '成功响应JSON',
created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_pda_stocktake_action(overseas_warehouse_id,operator_key,idempotency_key), KEY idx_ll_pda_stocktake_action_task(task_id,created_at)
) ENGINE=InnoDB COMMENT='海外PDA盘点动作幂等记录';
CREATE TABLE IF NOT EXISTS ll_pda_inventory_move (
id BIGINT NOT NULL COMMENT '移库操作ID', move_no VARCHAR(64) NOT NULL COMMENT '移库操作单号',
overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', pallet_id BIGINT NOT NULL COMMENT '整托移库的实物托盘ID',
pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照', source_location_id BIGINT NOT NULL COMMENT '来源库位ID',
source_location_code VARCHAR(128) NOT NULL COMMENT '来源库位码快照', target_location_id BIGINT NOT NULL COMMENT '目标库位ID',
target_location_code VARCHAR(128) NOT NULL COMMENT '目标库位码快照', customer_count INT NOT NULL COMMENT '托盘涉及客户数',
position_count INT NOT NULL COMMENT '迁移库存位置数', package_count BIGINT NOT NULL COMMENT '迁移总箱数',
pallet_version_before INT NOT NULL COMMENT '迁移前托盘版本', pallet_version_after INT NULL COMMENT '迁移后托盘版本',
operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', reason VARCHAR(500) NOT NULL COMMENT '移库原因',
idempotency_key VARCHAR(128) NOT NULL COMMENT '移库幂等键', request_hash VARCHAR(128) NOT NULL COMMENT '移库请求哈希',
status VARCHAR(32) NOT NULL COMMENT '移库处理状态', result_json LONGTEXT NULL COMMENT '成功响应JSON',
created_at DATETIME(3) NOT NULL COMMENT '创建时间', completed_at DATETIME(3) NULL COMMENT '完成时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_pda_inventory_move_no(move_no),
UNIQUE KEY uk_ll_pda_inventory_move_key(overseas_warehouse_id,operator_key,idempotency_key),
KEY idx_ll_pda_inventory_move_pallet(overseas_warehouse_id,pallet_id,created_at)
) ENGINE=InnoDB COMMENT='海外PDA同仓整托移库记录';
CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment (
id BIGINT NOT NULL COMMENT '主键ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
@@ -1236,6 +1302,90 @@ CREATE TABLE IF NOT EXISTS ll_relabel_status_history (
KEY idx_ll_relabel_status_history_application (customer_id,application_id,created_at)
) ENGINE=InnoDB COMMENT='换标状态变更历史';
CREATE TABLE IF NOT EXISTS ll_relabel_pda_task (
id BIGINT NOT NULL COMMENT '换标PDA任务ID',
application_id BIGINT NOT NULL COMMENT '换标申请单ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
relabel_no VARCHAR(64) NOT NULL COMMENT '换标申请单号快照',
overseas_warehouse_id BIGINT NOT NULL COMMENT 'PDA令牌冻结的海外实际作业仓ID',
status VARCHAR(32) NOT NULL COMMENT '任务状态:PROCESSING、WAIT_REVIEW或COMPLETED',
operator_key VARCHAR(128) NOT NULL COMMENT '认领操作账号标识',
operator_name VARCHAR(255) NOT NULL COMMENT '认领操作员名称快照',
reviewer_key VARCHAR(128) NULL COMMENT '复核账号标识',
reviewer_name VARCHAR(255) NULL COMMENT '复核员名称快照',
expected_packages INT NOT NULL COMMENT '申请单通知的应换标箱数',
relabelled_packages INT NOT NULL DEFAULT 0 COMMENT '已正常换标箱数',
skipped_packages INT NOT NULL DEFAULT 0 COMMENT '已说明原因跳过箱数',
exception_packages INT NOT NULL DEFAULT 0 COMMENT '已登记异常箱数',
completion_photo_file_ids JSON NULL COMMENT '换标完成现场照片文件ID列表',
completion_remark VARCHAR(1000) NULL COMMENT '完成提交备注',
review_decision VARCHAR(16) NULL COMMENT '最近复核结论:APPROVE或REWORK',
review_remark VARCHAR(1000) NULL COMMENT '最近复核备注',
claimed_at DATETIME(3) NOT NULL COMMENT '任务认领时间',
submitted_at DATETIME(3) NULL COMMENT '提交复核时间',
reviewed_at DATETIME(3) NULL COMMENT '最近复核时间',
version INT 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_ll_relabel_pda_task_application (customer_id,application_id),
UNIQUE KEY uk_ll_relabel_pda_task_no (relabel_no),
KEY idx_ll_relabel_pda_task_warehouse (overseas_warehouse_id,status,updated_at),
KEY idx_ll_relabel_pda_task_operator (operator_key,status,updated_at)
) ENGINE=InnoDB COMMENT='海外PDA换标现场作业任务';
CREATE TABLE IF NOT EXISTS ll_relabel_pda_unit_fact (
id BIGINT NOT NULL COMMENT '逐件换标事实ID',
task_id BIGINT NOT NULL COMMENT '换标PDA任务ID',
application_id BIGINT NOT NULL COMMENT '换标申请单ID',
item_id BIGINT NOT NULL COMMENT '换标申请明细ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
old_label VARCHAR(255) NULL COMMENT '扫描的实物旧标签或箱标',
new_label VARCHAR(255) NULL COMMENT '扫描的换标后新标签',
result VARCHAR(32) NOT NULL COMMENT '逐件结果:RELABELLED、SKIPPED或EXCEPTION',
exception_code VARCHAR(32) NULL COMMENT '异常编码',
reason VARCHAR(1000) NULL COMMENT '跳过或异常原因',
operator_key VARCHAR(128) NOT NULL COMMENT '操作账号标识',
operator_name VARCHAR(255) NOT NULL COMMENT '操作员名称快照',
occurred_at DATETIME(3) NOT NULL COMMENT '逐件事实发生时间',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_relabel_pda_old_label (old_label),
KEY idx_ll_relabel_pda_unit_task (customer_id,task_id,occurred_at,id),
KEY idx_ll_relabel_pda_unit_item (customer_id,application_id,item_id)
) ENGINE=InnoDB COMMENT='海外PDA逐件换标扫码与异常事实';
CREATE TABLE IF NOT EXISTS ll_relabel_pda_operation (
id BIGINT NOT NULL COMMENT '换标PDA幂等操作ID',
task_id BIGINT NOT NULL COMMENT '换标PDA任务ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
operator_key VARCHAR(128) NOT NULL COMMENT '请求操作账号标识',
action VARCHAR(32) NOT NULL COMMENT '幂等操作动作',
idempotency_key VARCHAR(255) NOT NULL COMMENT '设备生成的幂等键',
request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希',
result_json LONGTEXT NOT NULL COMMENT '成功响应快照JSON',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_relabel_pda_operation_key (operator_key,idempotency_key),
KEY idx_ll_relabel_pda_operation_task (customer_id,task_id,created_at)
) ENGINE=InnoDB COMMENT='海外PDA换标写操作幂等记录';
CREATE TABLE IF NOT EXISTS ll_relabel_pda_history (
id BIGINT NOT NULL COMMENT '换标PDA任务历史ID',
task_id BIGINT NOT NULL COMMENT '换标PDA任务ID',
application_id BIGINT NOT NULL COMMENT '换标申请单ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
action VARCHAR(32) NOT NULL COMMENT '现场动作类型',
from_status VARCHAR(32) NULL COMMENT '动作前任务状态',
to_status VARCHAR(32) NOT NULL COMMENT '动作后任务状态',
operator_key VARCHAR(128) NOT NULL COMMENT '操作账号标识',
operator_name VARCHAR(255) NOT NULL COMMENT '操作员名称快照',
detail_json LONGTEXT NULL COMMENT '动作详情JSON',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id),
KEY idx_ll_relabel_pda_history_task (customer_id,task_id,created_at,id),
KEY idx_ll_relabel_pda_history_application (customer_id,application_id,created_at)
) ENGINE=InnoDB COMMENT='海外PDA换标任务不可变操作历史';
CREATE TABLE IF NOT EXISTS ll_dropship_order_profile (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID',
warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号',
@@ -0,0 +1,256 @@
openapi: 3.0.3
info:
title: Lastleg Overseas PDA Warehouse Operations API
version: 1.0.0
description: 海外仓PDA盘点、同仓整托移库、托盘作业与换标现场作业;全部范围从PDA Token解析。
security:
- bearerAuth: []
paths:
/api/pda/v1/last-leg/stocktakes:
get:
summary: 查询Token绑定仓盘点任务
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } }
- { name: status, in: query, schema: { type: string, enum: [IN_PROGRESS, SUBMITTED] } }
responses: { '200': { description: 盘点任务分页 } }
post:
summary: 按库位或托盘创建盘点任务
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/StocktakeCreate' } } } }
responses: { '200': { description: 创建后的完整盘点任务 } }
/api/pda/v1/last-leg/stocktakes/{taskNo}:
get:
summary: 查询盘点任务详情
parameters: [{ name: taskNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 盘点任务及冻结库存位置 } }
/api/pda/v1/last-leg/stocktakes/{taskNo}/counts:
post:
summary: 提交一个库存位置的实盘箱数
parameters: [{ name: taskNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/StocktakeCount' } } } }
responses: { '200': { description: 已计数明细 }, '409': { description: 版本或幂等冲突 } }
/api/pda/v1/last-leg/stocktakes/{taskNo}/submit:
post:
summary: 提交盘点并按差异调整库存
parameters: [{ name: taskNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/StocktakeSubmit' } } } }
responses: { '200': { description: 已提交盘点任务 }, '409': { description: 未全盘、库存版本变化或负库存风险 } }
/api/pda/v1/last-leg/inventory-moves:
post:
summary: 同仓整托移库
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/InventoryMove' } } } }
responses: { '200': { description: 移库结果 }, '409': { description: 跨仓、资源或库存版本冲突 } }
/api/pda/v1/last-leg/pallet-operations/pallets/resolve:
post:
summary: 扫码查询托盘及库存内容
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ScanCode' } } } }
responses: { '200': { description: Token绑定仓托盘内容 } }
/api/pda/v1/last-leg/pallet-operations/assemble:
post:
summary: 将多个来源托盘的指定库存行组到目标托盘
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PalletAssemble' } } } }
responses: { '200': { description: 组托及实物托盘状态结果 }, '409': { description: 目的仓、库存或资源版本冲突 } }
/api/pda/v1/last-leg/pallet-operations/disassemble:
post:
summary: 将来源托盘库存拆到一个或多个目标托盘
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PalletDisassemble' } } } }
responses: { '200': { description: 拆托结果 }, '409': { description: 库存或资源版本冲突 } }
/api/pda/v1/last-leg/pallet-operations/swap:
post:
summary: 将整托库存更换到空目标托盘
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PalletSwap' } } } }
responses: { '200': { description: 换托结果 }, '409': { description: 目标托盘非空或版本冲突 } }
/api/pda/v1/last-leg/pallet-operations/unbind:
post:
summary: 解除整托库存与实物托盘绑定
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PalletUnbind' } } } }
responses: { '200': { description: 解绑结果 }, '409': { description: 库存或资源版本冲突 } }
/api/pda/v1/last-leg/relabel-tasks/candidates:
get:
summary: 查询Token绑定仓可执行换标任务
responses: { '200': { description: 待认领、处理中或待复核换标任务 } }
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}:
get:
summary: 查询换标现场任务详情
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 申请明细、逐件事实、照片与历史 } }
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/claim:
post:
summary: 认领换标任务并推进申请至处理中
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/IdempotentAction' } } } }
responses: { '200': { description: 已认领任务 }, '409': { description: 仓库、状态或幂等冲突 } }
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/units:
post:
summary: 登记一件正常换标事实
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelUnit' } } } }
responses: { '200': { description: 更新后的任务 }, '409': { description: 旧标重复、新标不符或版本冲突 } }
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/units/skip:
post:
summary: 登记一件有原因的跳过事实
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelSkip' } } } }
responses: { '200': { description: 更新后的任务 } }
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/exceptions:
post:
summary: 登记一件换标异常事实
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelException' } } } }
responses: { '200': { description: 更新后的任务 } }
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/photos/files:
post:
summary: 上传换标完成现场照片
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { multipart/form-data: { schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } } } } }
responses: { '200': { description: OVERSEAS_RELABEL_PHOTO文件元数据 } }
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/complete:
post:
summary: 提交现场任务等待复核,不提前完成申请
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelComplete' } } } }
responses: { '200': { description: WAIT_REVIEW任务 }, '409': { description: 箱数未核平、照片或版本冲突 } }
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/review:
post:
summary: 现场复核;APPROVE才完成既有换标申请,REWORK退回处理
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelReview' } } } }
responses: { '200': { description: COMPLETED或PROCESSING任务 }, '409': { description: 状态或版本冲突 } }
components:
securitySchemes:
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
schemas:
ScanCode:
type: object
required: [code]
properties: { code: { type: string, minLength: 1, maxLength: 128 } }
IdempotentAction:
type: object
required: [idempotencyKey]
properties: { idempotencyKey: { type: string, minLength: 1, maxLength: 255 } }
StocktakeCreate:
type: object
required: [scopeType, scopeCode, idempotencyKey]
properties:
scopeType: { type: string, enum: [LOCATION, PALLET] }
scopeCode: { type: string, maxLength: 128 }
idempotencyKey: { type: string, maxLength: 128 }
StocktakeCount:
type: object
required: [positionId, actualPackages, expectedVersion, idempotencyKey]
properties:
positionId: { type: string, pattern: '^[0-9]+$' }
actualPackages: { type: integer, format: int64, minimum: 0 }
expectedVersion: { type: integer, format: int64, minimum: 0 }
idempotencyKey: { type: string, maxLength: 128 }
StocktakeSubmit:
type: object
required: [expectedVersion, reason, idempotencyKey]
properties:
expectedVersion: { type: integer, format: int64, minimum: 0 }
reason: { type: string, minLength: 1, maxLength: 500 }
idempotencyKey: { type: string, maxLength: 128 }
InventoryMove:
type: object
required: [sourcePalletCode, targetLocationCode, expectedPalletVersion, idempotencyKey, reason]
properties:
sourcePalletCode: { type: string, maxLength: 128 }
targetLocationCode: { type: string, maxLength: 128 }
expectedPalletVersion: { type: integer, minimum: 0 }
idempotencyKey: { type: string, maxLength: 128 }
reason: { type: string, minLength: 1, maxLength: 500 }
PalletExpectation:
type: object
required: [palletCode, expectedVersion]
properties:
palletCode: { type: string, maxLength: 128 }
expectedVersion: { type: integer, minimum: 0 }
PalletMoveLine:
type: object
required: [positionId, packages, expectedPositionVersion]
properties:
positionId: { type: string, pattern: '^[0-9]+$' }
packages: { type: integer, format: int64, minimum: 1 }
expectedPositionVersion: { type: integer, format: int64, minimum: 0 }
PalletAssemble:
type: object
required: [idempotencyKey, target, sources, lines]
properties:
idempotencyKey: { type: string, maxLength: 128 }
target: { $ref: '#/components/schemas/PalletExpectation' }
sources: { type: array, minItems: 1, maxItems: 20, items: { $ref: '#/components/schemas/PalletExpectation' } }
lines: { type: array, minItems: 1, maxItems: 200, items: { $ref: '#/components/schemas/PalletMoveLine' } }
reason: { type: string, maxLength: 500 }
PalletTargetMove:
type: object
required: [target, lines]
properties:
target: { $ref: '#/components/schemas/PalletExpectation' }
lines: { type: array, minItems: 1, maxItems: 200, items: { $ref: '#/components/schemas/PalletMoveLine' } }
PalletDisassemble:
type: object
required: [idempotencyKey, source, targets]
properties:
idempotencyKey: { type: string, maxLength: 128 }
source: { $ref: '#/components/schemas/PalletExpectation' }
targets: { type: array, minItems: 1, maxItems: 20, items: { $ref: '#/components/schemas/PalletTargetMove' } }
reason: { type: string, maxLength: 500 }
PalletSwap:
type: object
required: [idempotencyKey, source, target]
properties:
idempotencyKey: { type: string, maxLength: 128 }
source: { $ref: '#/components/schemas/PalletExpectation' }
target: { $ref: '#/components/schemas/PalletExpectation' }
reason: { type: string, maxLength: 500 }
PalletUnbind:
type: object
required: [idempotencyKey, source]
properties:
idempotencyKey: { type: string, maxLength: 128 }
source: { $ref: '#/components/schemas/PalletExpectation' }
reason: { type: string, maxLength: 500 }
RelabelUnit:
type: object
required: [itemId, oldLabel, newLabel, expectedVersion, idempotencyKey]
properties:
itemId: { type: string, pattern: '^[0-9]+$' }
oldLabel: { type: string, maxLength: 255 }
newLabel: { type: string, maxLength: 255 }
expectedVersion: { type: integer, minimum: 0 }
idempotencyKey: { type: string, maxLength: 255 }
RelabelSkip:
type: object
required: [itemId, oldLabel, reason, expectedVersion, idempotencyKey]
properties:
itemId: { type: string, pattern: '^[0-9]+$' }
oldLabel: { type: string, maxLength: 255 }
reason: { type: string, maxLength: 1000 }
expectedVersion: { type: integer, minimum: 0 }
idempotencyKey: { type: string, maxLength: 255 }
RelabelException:
type: object
required: [itemId, exceptionCode, reason, expectedVersion, idempotencyKey]
properties:
itemId: { type: string, pattern: '^[0-9]+$' }
oldLabel: { type: string, maxLength: 255, nullable: true }
exceptionCode: { type: string, enum: [DAMAGED, MISSING_LABEL, LABEL_MISMATCH, UNREADABLE, OTHER] }
reason: { type: string, maxLength: 1000 }
expectedVersion: { type: integer, minimum: 0 }
idempotencyKey: { type: string, maxLength: 255 }
RelabelComplete:
type: object
required: [photoFileIds, expectedVersion, idempotencyKey]
properties:
photoFileIds: { type: array, minItems: 1, maxItems: 20, items: { type: string, pattern: '^[0-9]+$' } }
expectedVersion: { type: integer, minimum: 0 }
idempotencyKey: { type: string, maxLength: 255 }
remark: { type: string, maxLength: 1000, nullable: true }
RelabelReview:
type: object
required: [decision, expectedVersion, idempotencyKey]
properties:
decision: { type: string, enum: [APPROVE, REWORK] }
expectedVersion: { type: integer, minimum: 0 }
idempotencyKey: { type: string, maxLength: 255 }
remark: { type: string, maxLength: 1000, nullable: true }
@@ -58,4 +58,5 @@ class HttpWarehouseResourceGatewayTest {
assertThat(result.palletId()).isEqualTo(2092040891261427713L);
server.verify();
}
}
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.palletops.controller;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Set;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.*;
class PdaPalletOperationControllerContractTest {
@Test void exposesOnlyDedicatedPdaPalletEndpointsWithCanonicalPickerAuthority() {
RequestMapping mapping = PdaPalletOperationController.class.getAnnotation(RequestMapping.class);
assertArrayEquals(new String[]{"/api/pda/v1/last-leg/pallet-operations"}, mapping.value());
PreAuthorize authorize = PdaPalletOperationController.class.getAnnotation(PreAuthorize.class);
assertEquals("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')", authorize.value());
Set<String> paths = Arrays.stream(PdaPalletOperationController.class.getDeclaredMethods())
.map(Method::getDeclaredAnnotations).flatMap(Arrays::stream)
.filter(PostMapping.class::isInstance).map(PostMapping.class::cast)
.flatMap(annotation -> Arrays.stream(annotation.value())).collect(Collectors.toSet());
assertEquals(Set.of("/pallets/resolve", "/assemble", "/disassemble", "/swap", "/unbind"), paths);
}
}
@@ -0,0 +1,124 @@
package com.baiyue.lastleg.palletops.service;
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.palletops.dto.PalletOperationRequests;
import com.baiyue.lastleg.palletops.entity.PdaPalletOperation;
import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway;
import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper;
import com.baiyue.lastleg.palletops.model.PalletInventoryRow;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.AbstractPlatformTransactionManager;
import org.springframework.transaction.support.DefaultTransactionStatus;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class PdaPalletOperationServiceTest {
@Mock private PdaPalletOperationMapper mapper;
@Mock private InventoryPositionMapper positions;
@Mock private InventoryBalanceMapper balances;
@Mock private InventoryLedgerMapper ledgers;
@Mock private PalletPhysicalGateway physical;
@Mock private PdaWarehouseService history;
private PdaPalletOperationService service;
@BeforeEach void setUp() {
service = new PdaPalletOperationService();
ReflectionTestUtils.setField(service, "mapper", mapper);
ReflectionTestUtils.setField(service, "positions", positions);
ReflectionTestUtils.setField(service, "balances", balances);
ReflectionTestUtils.setField(service, "ledgers", ledgers);
ReflectionTestUtils.setField(service, "physical", physical);
ReflectionTestUtils.setField(service, "history", history);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
service.configureTransactions(new NoOpTransactionManager());
}
@Test void resolveRejectsPalletOutsideTokenFrozenWarehouse() {
when(physical.resolve("P-1")).thenReturn(pallet(10L, "P-1", 99L, 1));
assertThrows(ResponseStatusException.class, () -> service.resolve(identity(),
new PalletOperationRequests.Scan("P-1")));
verifyNoInteractions(mapper);
}
@Test void localConcurrencyFailureCompensatesEveryAppliedPhysicalTransitionInReverseSafeForm() {
var source = pallet(10L, "SOURCE", 88L, 1);
var target = pallet(20L, "TARGET", 88L, 2);
when(physical.resolve("SOURCE")).thenReturn(source);
when(physical.resolve("TARGET")).thenReturn(target);
when(physical.resolveLocation(88L, target.locationId())).thenReturn(
new PalletPhysicalGateway.LocationReference(target.locationId(), "LOC-" + target.locationId(),
"STORAGE", "ACTIVE"));
PalletInventoryRow planned = row(100L, 10L, 1L, 5L);
PalletInventoryRow stale = row(100L, 10L, 2L, 5L);
when(mapper.content("88", 10L)).thenReturn(List.of(planned));
when(mapper.content("88", 20L)).thenReturn(List.of());
when(mapper.palletPackages("88", 10L)).thenReturn(5L);
when(mapper.palletPackages("88", 20L)).thenReturn(0L);
when(mapper.lockPosition("88", 100L)).thenReturn(stale);
AtomicReference<PdaPalletOperation> operation = new AtomicReference<>();
doAnswer(invocation -> { operation.set(invocation.getArgument(0)); return 1; })
.when(mapper).insert(any(PdaPalletOperation.class));
when(mapper.selectById(anyLong())).thenAnswer(invocation -> operation.get());
when(physical.apply(eq(88L), any(), anyString(), anyLong(), anyString(), any()))
.thenAnswer(invocation -> transition(invocation.getArgument(1), invocation.getArgument(2)));
var request = new PalletOperationRequests.Assemble("idem-1",
new PalletOperationRequests.PalletExpectation("TARGET", 2),
List.of(new PalletOperationRequests.PalletExpectation("SOURCE", 1)),
List.of(new PalletOperationRequests.MoveLine(100L, 5L, 1L)), "assemble");
assertThrows(ResponseStatusException.class, () -> service.assemble(identity(), request));
verify(physical, times(2)).compensate(eq(88L), any(PalletPhysicalGateway.Transition.class));
assertEquals("FAILED_COMPENSATED", operation.get().getStatus());
verify(positions, never()).updateById(any(com.baiyue.lastleg.inventory.entity.InventoryPosition.class));
verifyNoInteractions(ledgers, history);
}
private static PdaWarehouseIdentity identity() { return new PdaWarehouseIdentity("7", "picker", 88L); }
private static PalletPhysicalGateway.PalletReference pallet(long id, String code, long warehouse, int version) {
return new PalletPhysicalGateway.PalletReference(id, code, warehouse, 1000L + id,
id == 20L ? "IDLE" : "OCCUPIED", null, version);
}
private static PalletPhysicalGateway.Transition transition(PalletPhysicalGateway.PalletReference pallet,
String targetStatus) {
return new PalletPhysicalGateway.Transition("op-" + pallet.palletId(), pallet.palletId(), pallet.palletCode(),
pallet.warehouseId(), pallet.status(), targetStatus, pallet.locationId(), pallet.locationId(),
pallet.version(), pallet.version() + 1, "APPLIED", false);
}
private static PalletInventoryRow row(long positionId, long palletId, long version, long packages) {
PalletInventoryRow value = new PalletInventoryRow(); value.setCustomerId("100"); value.setCustomerName("C");
value.setBalanceId(200L); value.setPositionId(positionId); value.setInboundCode("IN-1");
value.setSkuCode("SKU-1"); value.setDestinationWarehouseCode("ONT8"); value.setPalletId(palletId);
value.setPalletNo(palletId == 10L ? "SOURCE" : "TARGET"); value.setBatchNo("DEFAULT");
value.setWarehouseLocationId(1010L); value.setWarehouseLocationCode("L-1");
value.setAvailablePackages(packages); value.setHoldPackages(0L); value.setAllocatedPackages(0L);
value.setPickedPackages(0L); value.setReceivingPackages(0L); value.setVersion(version); return value;
}
private static final class NoOpTransactionManager extends AbstractPlatformTransactionManager {
@Override protected Object doGetTransaction() { return new Object(); }
@Override protected void doBegin(Object transaction, TransactionDefinition definition) { }
@Override protected void doCommit(DefaultTransactionStatus status) { }
@Override protected void doRollback(DefaultTransactionStatus status) { }
}
}
@@ -0,0 +1,41 @@
package com.baiyue.lastleg.pda;
import com.baiyue.lastleg.pda.move.controller.PdaInventoryMoveController;
import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveRequest;
import com.baiyue.lastleg.pda.stocktake.controller.PdaStocktakeController;
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeRequests;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.RequestMapping;
import java.util.Set;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
class PdaInventoryOperationContractTest {
@Test
void controllersUseOnlyCanonicalPdaIdentityAndRoutes() {
assertThat(PdaStocktakeController.class.getAnnotation(RequestMapping.class).value())
.containsExactly("/api/pda/v1/last-leg/stocktakes");
assertThat(PdaInventoryMoveController.class.getAnnotation(RequestMapping.class).value())
.containsExactly("/api/pda/v1/last-leg/inventory-moves");
assertThat(PdaStocktakeController.class.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')");
assertThat(PdaInventoryMoveController.class.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')");
}
@Test
void deviceRequestsCannotSupplyIdentityOrWarehouseScope() {
Set<String> forbidden = Set.of("customerId", "warehouseId", "overseasWarehouseId", "operator", "operatorId");
for (Class<?> type : List.of(PdaStocktakeRequests.Create.class, PdaStocktakeRequests.Count.class,
PdaStocktakeRequests.Submit.class, PdaInventoryMoveRequest.class)) {
Set<String> fields = Stream.of(type.getRecordComponents()).map(component -> component.getName())
.collect(Collectors.toSet());
assertThat(fields).doesNotContainAnyElementsOf(forbidden);
}
}
}
@@ -0,0 +1,83 @@
package com.baiyue.lastleg.pda;
import com.baiyue.lastleg.palletops.dto.PalletOperationRequests;
import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveRequest;
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeRequests;
import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelRequests;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.lang.reflect.RecordComponent;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PdaOperationsOpenApiContractTest {
private static final Set<String> SERVER_OWNED = Set.of(
"customerId", "warehouseId", "overseasWarehouseId", "operator", "operatorKey", "operatorName");
@Test
void pdaOperationRequestsNeverAcceptServerOwnedScope() {
List<Class<?>> containers = List.of(PdaStocktakeRequests.class, PalletOperationRequests.class,
PdaRelabelRequests.class);
for (Class<?> container : containers) {
for (Class<?> type : container.getDeclaredClasses()) {
if (type.isRecord()) assertNoServerOwnedFields(type);
}
}
assertNoServerOwnedFields(PdaInventoryMoveRequest.class);
}
@Test
void versionedOpenApiPublishesEveryPdaWarehouseOperation() throws IOException {
String yaml = resource("openapi/overseas-pda-operations-v1.yaml");
List<String> paths = List.of(
"/api/pda/v1/last-leg/stocktakes:",
"/api/pda/v1/last-leg/stocktakes/{taskNo}:",
"/api/pda/v1/last-leg/stocktakes/{taskNo}/counts:",
"/api/pda/v1/last-leg/stocktakes/{taskNo}/submit:",
"/api/pda/v1/last-leg/inventory-moves:",
"/api/pda/v1/last-leg/pallet-operations/pallets/resolve:",
"/api/pda/v1/last-leg/pallet-operations/assemble:",
"/api/pda/v1/last-leg/pallet-operations/disassemble:",
"/api/pda/v1/last-leg/pallet-operations/swap:",
"/api/pda/v1/last-leg/pallet-operations/unbind:",
"/api/pda/v1/last-leg/relabel-tasks/candidates:",
"/api/pda/v1/last-leg/relabel-tasks/{relabelNo}:",
"/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/claim:",
"/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/units:",
"/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/units/skip:",
"/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/exceptions:",
"/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/photos/files:",
"/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/complete:",
"/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/review:");
for (String path : paths) assertTrue(yaml.contains(path), path);
assertEquals(19, paths.size());
assertTrue(yaml.contains("enum: [LOCATION, PALLET]"));
assertTrue(yaml.contains("enum: [APPROVE, REWORK]"));
assertTrue(yaml.contains("enum: [DAMAGED, MISSING_LABEL, LABEL_MISMATCH, UNREADABLE, OTHER]"));
for (String field : SERVER_OWNED) assertFalse(yaml.contains(field + ":"), field);
for (String excluded : List.of("/outbound", "/dispatch", "/delivery")) {
assertFalse(yaml.contains(excluded), excluded);
}
}
private static void assertNoServerOwnedFields(Class<?> type) {
Set<String> fields = Arrays.stream(type.getRecordComponents())
.map(RecordComponent::getName).collect(Collectors.toSet());
assertTrue(java.util.Collections.disjoint(fields, SERVER_OWNED), type.getSimpleName());
}
private String resource(String name) throws IOException {
try (var input = getClass().getClassLoader().getResourceAsStream(name)) {
assertTrue(input != null, name);
return new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
}
}
@@ -0,0 +1,141 @@
package com.baiyue.lastleg.pda.move.service;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryLedger;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway;
import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveRequest;
import com.baiyue.lastleg.pda.move.entity.PdaInventoryMove;
import com.baiyue.lastleg.pda.move.mapper.PdaInventoryMoveMapper;
import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
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.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class PdaInventoryMoveServiceTest {
@Mock PdaInventoryMoveMapper moves;
@Mock InventoryPositionMapper positions;
@Mock InventoryBalanceMapper balances;
@Mock InventoryLedgerMapper ledgers;
@Mock WarehouseResourceGateway warehouse;
@Mock PalletPhysicalGateway physical;
@Mock PdaWarehouseService history;
private PdaInventoryMoveService service;
@BeforeEach
void setUp() {
service = new PdaInventoryMoveService(); ReflectionTestUtils.setField(service, "moves", moves);
ReflectionTestUtils.setField(service, "positions", positions); ReflectionTestUtils.setField(service, "balances", balances);
ReflectionTestUtils.setField(service, "ledgers", ledgers); ReflectionTestUtils.setField(service, "warehouse", warehouse);
ReflectionTestUtils.setField(service, "physical", physical);
ReflectionTestUtils.setField(service, "history", history);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
}
@Test
void atomicallyRelocatesPhysicalPalletAndEveryWarehouseScopedPosition() {
when(warehouse.resolvePallet("PAL-1")).thenReturn(pallet(900L, 20L, 2));
when(warehouse.resolveLocation(900L, 20L)).thenReturn(location(20L, "A-01"));
when(warehouse.resolveLocation(900L, "B-01")).thenReturn(location(21L, "B-01"));
InventoryPosition position = new InventoryPosition(); position.setId(11L); position.setCustomerId("100");
position.setBalanceId(10L); position.setOverseasWarehouseId("900"); position.setPalletId(30L);
position.setPalletNo("PAL-1"); position.setWarehouseLocationId(20L); position.setWarehouseLocationCode("A-01");
position.setAvailablePackages(5L); position.setHoldPackages(1L); position.setAllocatedPackages(0L);
position.setPickedPackages(0L); position.setReceivingPackages(0L); position.setVersion(4L);
when(moves.lockPositions("900", 30L)).thenReturn(List.of(position));
InventoryBalance balance = new InventoryBalance(); balance.setId(10L); balance.setCustomerId("100");
balance.setOverseasWarehouseId("900"); balance.setInboundOrderId(50L); balance.setInboundCode("IN-1");
when(balances.selectById(10L)).thenReturn(balance);
when(physical.apply(eq(900L), any(PalletPhysicalGateway.PalletReference.class), eq("OCCUPIED"),
eq(21L), anyString(), eq("库位优化"))).thenReturn(transition(false));
var response = service.move(900L, "operator-1",
new PdaInventoryMoveRequest("PAL-1", "B-01", 2, "move-1", "库位优化"));
verify(physical).apply(eq(900L), argThat(value -> value.palletId() == 30L
&& value.locationId() == 20L && value.version() == 2), eq("OCCUPIED"),
eq(21L), anyString(), eq("库位优化"));
ArgumentCaptor<InventoryPosition> updated = ArgumentCaptor.forClass(InventoryPosition.class);
verify(positions).updateById(updated.capture());
assertThat(updated.getValue().getWarehouseLocationId()).isEqualTo(21L);
assertThat(updated.getValue().getWarehouseLocationCode()).isEqualTo("B-01");
assertThat(response.packageCount()).isEqualTo(6L);
assertThat(response.palletVersion()).isEqualTo(3);
assertThat(response.status()).isEqualTo("COMPLETED");
verify(ledgers).insert(any(InventoryLedger.class)); verify(moves).updateById(any(PdaInventoryMove.class));
}
@Test
void rejectsCrossWarehousePalletWithoutMovingResources() {
when(warehouse.resolvePallet("PAL-1")).thenReturn(pallet(901L, 20L, 2));
assertThatThrownBy(() -> service.move(900L, "operator-1",
new PdaInventoryMoveRequest("PAL-1", "B-01", 2, "move-1", "库位优化")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verify(physical, never()).apply(anyLong(), any(), anyString(), anyLong(), anyString(), any());
}
@Test
void resumesWhenWarehouseCommittedButLastlegTransactionRolledBack() {
when(warehouse.resolvePallet("PAL-1")).thenReturn(pallet(900L, 21L, 3));
when(warehouse.resolveLocation(900L, "B-01")).thenReturn(location(21L, "B-01"));
when(warehouse.resolveLocation(900L, 20L)).thenReturn(location(20L, "A-01"));
InventoryPosition position = new InventoryPosition(); position.setId(11L); position.setCustomerId("100");
position.setBalanceId(10L); position.setOverseasWarehouseId("900"); position.setPalletId(30L);
position.setPalletNo("PAL-1"); position.setWarehouseLocationId(20L); position.setWarehouseLocationCode("A-01");
position.setAvailablePackages(5L); position.setHoldPackages(0L); position.setAllocatedPackages(0L);
position.setPickedPackages(0L); position.setReceivingPackages(0L); position.setVersion(4L);
when(moves.lockPositions("900", 30L)).thenReturn(List.of(position));
InventoryBalance balance = new InventoryBalance(); balance.setId(10L); balance.setCustomerId("100");
balance.setOverseasWarehouseId("900"); balance.setInboundOrderId(50L); balance.setInboundCode("IN-1");
when(balances.selectById(10L)).thenReturn(balance);
when(physical.apply(eq(900L), any(PalletPhysicalGateway.PalletReference.class), eq("OCCUPIED"),
eq(21L), anyString(), eq("恢复移库"))).thenReturn(transition(true));
var response = service.move(900L, "operator-1",
new PdaInventoryMoveRequest("PAL-1", "B-01", 2, "move-retry", "恢复移库"));
verify(physical).apply(eq(900L), argThat(value -> value.locationId() == 20L && value.version() == 2),
eq("OCCUPIED"), eq(21L), anyString(), eq("恢复移库"));
assertThat(response.sourceLocationCode()).isEqualTo("A-01");
assertThat(response.targetLocationCode()).isEqualTo("B-01");
verify(positions).updateById(any(InventoryPosition.class));
}
private static WarehouseResourceGateway.WarehousePalletReference pallet(long warehouseId, long locationId, int version) {
return new WarehouseResourceGateway.WarehousePalletReference(30L, "PAL-1", warehouseId, locationId,
"OCCUPIED", null, version);
}
private static WarehouseResourceGateway.WarehouseLocationReference location(long id, String code) {
return new WarehouseResourceGateway.WarehouseLocationReference(id, code, "STORAGE", BigDecimal.TEN, "OCCUPIED");
}
private static PalletPhysicalGateway.Transition transition(boolean replayed) {
return new PalletPhysicalGateway.Transition("move-transition", 30L, "PAL-1", 900L,
"OCCUPIED", "OCCUPIED", 20L, 21L, 2, 3, "APPLIED", replayed);
}
}
@@ -0,0 +1,146 @@
package com.baiyue.lastleg.pda.stocktake.service;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryLedger;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeRequests;
import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeItem;
import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeTask;
import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeActionMapper;
import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeInventoryMapper;
import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeItemMapper;
import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeTaskMapper;
import com.baiyue.lastleg.pda.stocktake.model.StocktakeCandidate;
import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
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.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class PdaStocktakeServiceTest {
@Mock PdaStocktakeTaskMapper tasks;
@Mock PdaStocktakeItemMapper items;
@Mock PdaStocktakeActionMapper actions;
@Mock PdaStocktakeInventoryMapper inventory;
@Mock InventoryPositionMapper positions;
@Mock InventoryBalanceMapper balances;
@Mock InventoryLedgerMapper ledgers;
@Mock WarehouseResourceGateway warehouse;
@Mock PdaWarehouseService history;
private PdaStocktakeService service;
@BeforeEach
void setUp() {
service = new PdaStocktakeService();
ReflectionTestUtils.setField(service, "tasks", tasks); ReflectionTestUtils.setField(service, "items", items);
ReflectionTestUtils.setField(service, "actions", actions); ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "positions", positions); ReflectionTestUtils.setField(service, "balances", balances);
ReflectionTestUtils.setField(service, "ledgers", ledgers); ReflectionTestUtils.setField(service, "warehouse", warehouse);
ReflectionTestUtils.setField(service, "history", history);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
}
@Test
void createsWarehouseBoundLocationTaskAndFreezesEveryPosition() {
when(warehouse.resolveLocation(900L, "A-01")).thenReturn(new WarehouseResourceGateway.WarehouseLocationReference(
20L, "A-01", "STORAGE", BigDecimal.TEN, "OCCUPIED"));
StocktakeCandidate candidate = new StocktakeCandidate(); candidate.setCustomerId("100");
candidate.setBalanceId(10L); candidate.setPositionId(11L); candidate.setSkuCode("SKU-1");
candidate.setPalletId(30L); candidate.setPalletCode("PAL-1"); candidate.setLocationId(20L);
candidate.setLocationCode("A-01"); candidate.setBookPackages(8L); candidate.setPositionVersion(3L);
when(items.candidatesByLocation("900", 20L, "A-01")).thenReturn(List.of(candidate));
when(items.views(anyLong())).thenReturn(List.of());
var response = service.create(900L, "operator-1", new PdaStocktakeRequests.Create(
"location", "A-01", "create-1"));
ArgumentCaptor<PdaStocktakeTask> task = ArgumentCaptor.forClass(PdaStocktakeTask.class);
ArgumentCaptor<PdaStocktakeItem> item = ArgumentCaptor.forClass(PdaStocktakeItem.class);
verify(tasks).insert(task.capture()); verify(items).insert(item.capture());
assertThat(task.getValue().getOverseasWarehouseId()).isEqualTo(900L);
assertThat(task.getValue().getScopeType()).isEqualTo("LOCATION");
assertThat(task.getValue().getCreatedBy()).isEqualTo("operator-1");
assertThat(item.getValue().getCustomerId()).isEqualTo("100");
assertThat(item.getValue().getBookPackages()).isEqualTo(8L);
assertThat(item.getValue().getPositionVersion()).isEqualTo(3L);
assertThat(response.task().status()).isEqualTo("IN_PROGRESS");
}
@Test
void rejectsPalletFromAnotherWarehouseBeforeReadingInventory() {
when(warehouse.resolvePallet("PAL-1")).thenReturn(new WarehouseResourceGateway.WarehousePalletReference(
30L, "PAL-1", 901L, 20L, "OCCUPIED", null, 2));
assertThatThrownBy(() -> service.create(900L, "operator-1",
new PdaStocktakeRequests.Create("PALLET", "PAL-1", "create-1")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verify(items, never()).candidatesByPallet(anyString(), anyLong());
}
@Test
void submitsCountedDifferenceAndWritesBalancedAuditLedger() {
PdaStocktakeTask task = new PdaStocktakeTask(); task.setId(40L); task.setTaskNo("CC-40");
task.setOverseasWarehouseId(900L); task.setScopeType("PALLET"); task.setScopeCode("PAL-1");
task.setStatus("IN_PROGRESS"); task.setPositionCount(1); task.setCountedCount(1); task.setDifferenceCount(1);
task.setVersion(2L);
PdaStocktakeTask submitted = new PdaStocktakeTask(); submitted.setId(40L); submitted.setTaskNo("CC-40");
submitted.setOverseasWarehouseId(900L); submitted.setScopeType("PALLET"); submitted.setScopeCode("PAL-1");
submitted.setStatus("SUBMITTED"); submitted.setPositionCount(1); submitted.setCountedCount(1);
submitted.setDifferenceCount(1); submitted.setVersion(3L);
when(tasks.find(900L, "CC-40")).thenReturn(task, submitted);
when(tasks.lock(900L, "CC-40")).thenReturn(task); when(tasks.submit(40L, 2L, "operator-1", "复盘确认")).thenReturn(1);
PdaStocktakeItem item = new PdaStocktakeItem(); item.setId(41L); item.setTaskId(40L); item.setCustomerId("100");
item.setBalanceId(10L); item.setPositionId(11L); item.setSkuCode("SKU-1"); item.setBookPackages(5L);
item.setActualPackages(7L); item.setDifferencePackages(2L); item.setPositionVersion(3L); item.setVersion(1L);
when(items.lockAll(40L)).thenReturn(List.of(item)); when(items.views(40L)).thenReturn(List.of());
InventoryPosition position = new InventoryPosition(); position.setId(11L); position.setCustomerId("100");
position.setBalanceId(10L); position.setOverseasWarehouseId("900"); position.setPalletId(30L);
position.setPalletNo("PAL-1"); position.setAvailablePackages(5L); position.setHoldPackages(0L);
position.setAllocatedPackages(0L); position.setPickedPackages(0L); position.setReceivingPackages(0L);
position.setVersion(3L);
InventoryBalance balance = new InventoryBalance(); balance.setId(10L); balance.setCustomerId("100");
balance.setOverseasWarehouseId("900"); balance.setAvailablePackages(5L); balance.setVersion(8L);
when(inventory.lockPosition(11L, "100", "900")).thenReturn(position);
when(inventory.lockBalance(10L, "100", "900")).thenReturn(balance);
var response = service.submit(900L, "operator-1", "CC-40",
new PdaStocktakeRequests.Submit(2L, "复盘确认", "submit-1"));
ArgumentCaptor<InventoryPosition> changedPosition = ArgumentCaptor.forClass(InventoryPosition.class);
ArgumentCaptor<InventoryBalance> changedBalance = ArgumentCaptor.forClass(InventoryBalance.class);
ArgumentCaptor<InventoryLedger> ledger = ArgumentCaptor.forClass(InventoryLedger.class);
verify(positions).updateById(changedPosition.capture()); verify(balances).updateById(changedBalance.capture());
verify(ledgers).insert(ledger.capture());
assertThat(changedPosition.getValue().getAvailablePackages()).isEqualTo(7L);
assertThat(changedBalance.getValue().getAvailablePackages()).isEqualTo(7L);
assertThat(ledger.getValue().getBeforePackages()).isEqualTo(5L);
assertThat(ledger.getValue().getDeltaPackages()).isEqualTo(2L);
assertThat(ledger.getValue().getAfterPackages()).isEqualTo(7L);
assertThat(ledger.getValue().getBusinessType()).isEqualTo("STOCKTAKE_ADJUST");
assertThat(response.task().status()).isEqualTo("SUBMITTED");
}
}
@@ -0,0 +1,86 @@
package com.baiyue.lastleg.relabel.pda.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelRequests;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.transaction.annotation.Transactional;
import java.lang.reflect.RecordComponent;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Set;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PdaRelabelContractTest {
@Test
void controllerUsesStablePdaPathAndExactPickerAuthority() {
assertEquals("/api/pda/v1/last-leg/relabel-tasks",
PdaRelabelController.class.getAnnotation(RequestMapping.class).value()[0]);
assertEquals("hasAuthority('lastleg:pda:operate')",
PdaRelabelController.class.getAnnotation(PreAuthorize.class).value());
}
@Test
void deviceRequestsNeverAcceptServerOwnedScopeOrOperatorFields() {
Set<String> forbidden = Set.of("customerId", "warehouseId", "overseasWarehouseId",
"operatorId", "operatorName", "tenantId");
for (Class<?> type : PdaRelabelRequests.class.getDeclaredClasses()) {
Set<String> fields = Arrays.stream(type.getRecordComponents()).map(RecordComponent::getName)
.collect(Collectors.toSet());
assertTrue(fields.stream().noneMatch(forbidden::contains), type.getSimpleName() + " exposes server scope");
}
}
@Test
void identityRequiresPlatformPickerPermissionAndFrozenWarehouse() {
AuthenticatedUser valid = user(Set.of("lastleg:pda:operate", "ROLE_WAREHOUSE_PICKER"), 900L);
assertEquals(900L, PdaRelabelController.identity(valid).overseasWarehouseId());
assertThrows(ResponseStatusException.class,
() -> PdaRelabelController.identity(user(Set.of("lastleg:pda:operate"), 900L)));
assertThrows(ResponseStatusException.class,
() -> PdaRelabelController.identity(user(Set.of("lastleg:pda:operate", "ROLE_WAREHOUSE_PICKER"), null)));
}
@Test
void baselineDefinesPdaRelabelFactsWithoutOutboundFields() throws Exception {
String sql = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8);
for (String table : Set.of("ll_relabel_pda_task", "ll_relabel_pda_unit_fact",
"ll_relabel_pda_operation", "ll_relabel_pda_history")) {
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS " + table));
}
assertTrue(sql.contains("uk_ll_relabel_pda_operation_key (operator_key,idempotency_key)"));
assertTrue(sql.contains("uk_ll_relabel_pda_old_label (old_label)"));
int start = sql.indexOf("CREATE TABLE IF NOT EXISTS ll_relabel_pda_task");
int end = sql.indexOf("CREATE TABLE IF NOT EXISTS ll_dropship", start);
String block = sql.substring(start, end);
assertFalse(block.contains("dispatch_no"));
assertFalse(block.contains("delivery_no"));
assertFalse(block.contains("outbound_order_no"));
}
@Test
void everyStateMutationIsTransactional() throws Exception {
Class<?> service = Class.forName("com.baiyue.lastleg.relabel.pda.service.PdaRelabelService");
for (String method : Set.of("claim", "scan", "skip", "exception", "complete", "review")) {
assertTrue(Arrays.stream(service.getDeclaredMethods())
.filter(candidate -> method.equals(candidate.getName()))
.anyMatch(candidate -> candidate.isAnnotationPresent(Transactional.class)),
method + " must be transactional");
}
}
private static AuthenticatedUser user(Set<String> permissions, Long warehouseId) {
return new AuthenticatedUser(7L, 0L, 0L, 70L, "picker", "PLATFORM", "PDA",
permissions, false, null, null, false, warehouseId);
}
}
@@ -0,0 +1,245 @@
package com.baiyue.lastleg.relabel.pda.service;
import com.baiyue.lastleg.relabel.dto.RelabelRequests;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelRequests;
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelOperation;
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask;
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact;
import com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway;
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper;
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelOperationMapper;
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper;
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelUnitFactMapper;
import com.baiyue.lastleg.relabel.service.RelabelApplicationService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class PdaRelabelServiceTest {
@Mock RelabelApplicationMapper applications;
@Mock RelabelItemMapper items;
@Mock PdaRelabelTaskMapper tasks;
@Mock PdaRelabelUnitFactMapper unitFacts;
@Mock PdaRelabelOperationMapper operations;
@Mock PdaRelabelHistoryMapper histories;
@Mock RelabelApplicationService applicationService;
@Mock RelabelPhotoFileGateway photoFiles;
PdaRelabelService service;
RelabelApplication application;
RelabelItem item;
List<PdaRelabelUnitFact> persistedFacts;
@BeforeEach
void setUp() {
service = new PdaRelabelService();
ReflectionTestUtils.setField(service, "applications", applications);
ReflectionTestUtils.setField(service, "items", items);
ReflectionTestUtils.setField(service, "tasks", tasks);
ReflectionTestUtils.setField(service, "unitFacts", unitFacts);
ReflectionTestUtils.setField(service, "operations", operations);
ReflectionTestUtils.setField(service, "histories", histories);
ReflectionTestUtils.setField(service, "applicationService", applicationService);
ReflectionTestUtils.setField(service, "photoFiles", photoFiles);
ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper());
service.initializeObjectMapper();
application = application("PENDING_PROCESSING");
item = item();
persistedFacts = new ArrayList<>();
lenient().when(operations.find(anyString(), anyString())).thenReturn(null);
lenient().when(items.selectList(any())).thenReturn(List.of(item));
lenient().when(histories.selectList(any())).thenReturn(List.of());
lenient().when(unitFacts.selectList(any())).thenAnswer(call -> List.copyOf(persistedFacts));
lenient().when(unitFacts.insert(any(PdaRelabelUnitFact.class))).thenAnswer(call -> { persistedFacts.add(call.getArgument(0)); return 1; });
lenient().when(tasks.updateWithVersion(any(), anyInt())).thenReturn(1);
}
@Test
void claimUsesFrozenWarehouseAndAdvancesExistingApplicationStateMachine() {
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
when(tasks.lock("100", 1L)).thenReturn(null);
ArgumentCaptor<PdaRelabelTask> task = ArgumentCaptor.forClass(PdaRelabelTask.class);
var result = service.claim("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.Claim("claim-1"));
verify(tasks).insert(task.capture());
assertEquals(900L, task.getValue().getOverseasWarehouseId());
assertEquals("100", task.getValue().getCustomerId());
assertEquals(PdaRelabelService.PROCESSING, result.taskStatus());
verify(applicationService).changeStatus(eq("100"), eq("FBR-1"), eq("7"), any(RelabelRequests.ChangeStatus.class));
}
@Test
void crossWarehouseClaimIsHiddenAndCannotCreateTask() {
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> service.claim("7", "picker", 901L, "FBR-1", new PdaRelabelRequests.Claim("claim-1")));
assertEquals(404, error.getStatusCode().value());
verify(tasks, never()).insert(any(PdaRelabelTask.class));
}
@Test
void unitScanLocksItemToApplicationValidatesNewSkuAndUpdatesVersion() {
application.setStatus("PROCESSING");
PdaRelabelTask task = task("PROCESSING", 0);
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
when(tasks.lock("100", 1L)).thenReturn(task);
when(items.selectOne(any())).thenReturn(item);
when(unitFacts.findOldLabel("BOX-1")).thenReturn(null);
ArgumentCaptor<PdaRelabelUnitFact> fact = ArgumentCaptor.forClass(PdaRelabelUnitFact.class);
var result = service.scan("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.Scan(10L, "BOX-1", "SKU-NEW", 0, "scan-1"));
verify(unitFacts).insert(fact.capture());
assertEquals("RELABELLED", fact.getValue().getResult());
assertEquals("BOX-1", fact.getValue().getOldLabel());
assertEquals(1, result.relabelledPackages());
assertEquals(1, result.version());
}
@Test
void duplicateOldLabelAndStaleVersionFailBeforeWritingAnotherFact() {
application.setStatus("PROCESSING");
PdaRelabelTask task = task("PROCESSING", 1);
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
when(tasks.lock("100", 1L)).thenReturn(task);
assertThrows(ResponseStatusException.class, () -> service.skip("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.Skip(10L, "BOX-2", "damaged", 0, "skip-stale")));
verify(unitFacts, never()).insert(any(PdaRelabelUnitFact.class));
}
@Test
void oldLabelAlreadyAttributedToAnotherTaskIsRejected() {
application.setStatus("PROCESSING");
PdaRelabelTask task = task("PROCESSING", 0);
PdaRelabelUnitFact otherTaskFact = fact("RELABELLED");
otherTaskFact.setTaskId(99L);
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
when(tasks.lock("100", 1L)).thenReturn(task);
when(items.selectOne(any())).thenReturn(item);
when(unitFacts.findOldLabel("BOX-1")).thenReturn(otherTaskFact);
assertThrows(ResponseStatusException.class, () -> service.scan("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.Scan(10L, "BOX-1", "SKU-NEW", 0, "scan-cross-task")));
verify(unitFacts, never()).insert(any(PdaRelabelUnitFact.class));
}
@Test
void completeRequiresEveryUnitAndMovesOnlyPdaTaskToWaitReview() {
application.setStatus("PROCESSING");
PdaRelabelTask task = task("PROCESSING", 1);
task.setRelabelledPackages(1);
persistedFacts.add(fact("RELABELLED"));
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
when(tasks.lock("100", 1L)).thenReturn(task);
var result = service.complete("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.Complete(List.of(501L), 1, "complete-1", "done"));
verify(photoFiles).validate("100", 20L, 501L);
assertEquals(PdaRelabelService.WAIT_REVIEW, result.taskStatus());
assertEquals("PROCESSING", result.applicationStatus());
verify(applicationService, never()).changeStatus(anyString(), anyString(), anyString(), any());
}
@Test
void approvalUsesExistingApplicationCompletionAndReworkDoesNot() {
application.setStatus("PROCESSING");
PdaRelabelTask approved = task("WAIT_REVIEW", 2);
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
when(tasks.lock("100", 1L)).thenReturn(approved);
var result = service.review("8", "reviewer", 900L, "FBR-1",
new PdaRelabelRequests.Review("APPROVE", 2, "review-1", "ok"));
assertEquals("COMPLETED", result.taskStatus());
assertEquals("COMPLETED", result.applicationStatus());
verify(applicationService).changeStatus(eq("100"), eq("FBR-1"), eq("8"), any(RelabelRequests.ChangeStatus.class));
}
@Test
void reviewCanReturnTaskForReworkWithoutCompletingApplication() {
application.setStatus("PROCESSING");
PdaRelabelTask task = task("WAIT_REVIEW", 2);
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
when(tasks.lock("100", 1L)).thenReturn(task);
var result = service.review("8", "reviewer", 900L, "FBR-1",
new PdaRelabelRequests.Review("REWORK", 2, "review-rework", "wrong label"));
assertEquals("PROCESSING", result.taskStatus());
assertEquals("PROCESSING", result.applicationStatus());
verify(applicationService, never()).changeStatus(anyString(), anyString(), anyString(), any());
}
@Test
void completionRejectsMissingPackageFactsBeforePhotoValidation() {
application.setStatus("PROCESSING");
PdaRelabelTask task = task("PROCESSING", 0);
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
when(tasks.lock("100", 1L)).thenReturn(task);
assertThrows(ResponseStatusException.class, () -> service.complete("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.Complete(List.of(501L), 0, "complete-1", null)));
verify(photoFiles, never()).validate(anyString(), anyLong(), anyLong());
}
private RelabelApplication application(String status) {
RelabelApplication value = new RelabelApplication();
value.setId(1L); value.setCustomerId("100"); value.setRelabelNo("FBR-1");
value.setCustomerOrderNo("ORD-1"); value.setOverseasWarehouseId("900");
value.setStatus(status); value.setItemCount(1); value.setNotifiedPackageCount(1);
value.setLabelDeadline(LocalDateTime.now().plusDays(1));
return value;
}
private RelabelItem item() {
RelabelItem value = new RelabelItem(); value.setId(10L); value.setApplicationId(1L); value.setCustomerId("100");
value.setSkuCode("SKU-OLD"); value.setNewSkuCode("SKU-NEW"); value.setProductNameZh("product");
value.setShipmentNo("SHIP-1"); value.setNotifiedPackageCount(1); return value;
}
private PdaRelabelTask task(String status, int version) {
PdaRelabelTask value = new PdaRelabelTask(); value.setId(20L); value.setApplicationId(1L); value.setCustomerId("100");
value.setRelabelNo("FBR-1"); value.setOverseasWarehouseId(900L); value.setStatus(status);
value.setOperatorKey("7"); value.setOperatorName("picker"); value.setExpectedPackages(1);
value.setRelabelledPackages(0); value.setSkippedPackages(0); value.setExceptionPackages(0);
value.setClaimedAt(LocalDateTime.now()); value.setVersion(version); return value;
}
private PdaRelabelUnitFact fact(String result) {
PdaRelabelUnitFact value = new PdaRelabelUnitFact(); value.setId(30L); value.setTaskId(20L);
value.setApplicationId(1L); value.setItemId(10L); value.setCustomerId("100");
value.setOldLabel("BOX-1"); value.setNewLabel("SKU-NEW"); value.setResult(result);
value.setOperatorName("picker"); value.setOccurredAt(LocalDateTime.now()); return value;
}
}
+35
View File
@@ -3614,6 +3614,41 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_business_binding' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE wh_pallet_business_binding MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1'));
PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND COALESCE(table_comment,'') <> '实物托盘状态幂等变更与补偿记录'), 'ALTER TABLE wh_pallet_operation COMMENT = ''实物托盘状态幂等变更与补偿记录''', 'SELECT 1'));
PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='id' AND COALESCE(column_comment,'') <> '托盘状态操作ID'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN id BIGINT NOT NULL COMMENT ''托盘状态操作ID''', 'SELECT 1'));
PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='operation_key' AND COALESCE(column_comment,'') <> '跨服务幂等操作键'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN operation_key VARCHAR(128) NOT NULL COMMENT ''跨服务幂等操作键''', 'SELECT 1'));
PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='request_hash' AND COALESCE(column_comment,'') <> '状态变更请求摘要'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN request_hash VARCHAR(128) NOT NULL COMMENT ''状态变更请求摘要''', 'SELECT 1'));
PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='warehouse_id' AND COALESCE(column_comment,'') <> '实际海外仓ID'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN warehouse_id BIGINT NOT NULL COMMENT ''实际海外仓ID''', 'SELECT 1'));
PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='pallet_id' AND COALESCE(column_comment,'') <> '实物托盘ID'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN pallet_id BIGINT NOT NULL COMMENT ''实物托盘ID''', 'SELECT 1'));
PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='pallet_code' AND COALESCE(column_comment,'') <> '实物托盘码快照'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN pallet_code VARCHAR(128) NOT NULL COMMENT ''实物托盘码快照''', 'SELECT 1'));
PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='before_status' AND COALESCE(column_comment,'') <> '变更前托盘状态'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN before_status VARCHAR(20) NOT NULL COMMENT ''变更前托盘状态''', 'SELECT 1'));
PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='after_status' AND COALESCE(column_comment,'') <> '变更后托盘状态'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN after_status VARCHAR(20) NOT NULL COMMENT ''变更后托盘状态''', 'SELECT 1'));
PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='before_location_id' AND COALESCE(column_comment,'') <> '变更前库位ID'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN before_location_id BIGINT NOT NULL COMMENT ''变更前库位ID''', 'SELECT 1'));
PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='after_location_id' AND COALESCE(column_comment,'') <> '变更后库位ID'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN after_location_id BIGINT NOT NULL COMMENT ''变更后库位ID''', 'SELECT 1'));
PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='before_version' AND COALESCE(column_comment,'') <> '变更前托盘版本'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN before_version INT NOT NULL COMMENT ''变更前托盘版本''', 'SELECT 1'));
PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='after_version' AND COALESCE(column_comment,'') <> '变更后托盘版本'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN after_version INT NOT NULL COMMENT ''变更后托盘版本''', 'SELECT 1'));
PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='operation_status' AND COALESCE(column_comment,'') <> '操作状态:APPLIED已应用、COMPENSATED已补偿'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN operation_status VARCHAR(32) NOT NULL COMMENT ''操作状态:APPLIED已应用、COMPENSATED已补偿''', 'SELECT 1'));
PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='reason' AND COALESCE(column_comment,'') <> '操作原因'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN reason VARCHAR(500) NULL COMMENT ''操作原因''', 'SELECT 1'));
PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1'));
PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1'));
PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025;
-- ============================================================================
-- 2026-08-19 / 026
-- Separate tail-leg main workflow status from child-document status
@@ -0,0 +1,39 @@
package com.baiyue.warehouse.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.warehouse.dto.PalletTransitionRequests;
import com.baiyue.warehouse.dto.PalletTransitionResponse;
import com.baiyue.warehouse.security.InternalServiceTokenGuard;
import com.baiyue.warehouse.service.WarehousePalletTransitionService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/internal/v1/pda-pallet-transitions")
public class InternalPdaPalletTransitionController {
@Resource private InternalServiceTokenGuard tokenGuard;
@Resource private WarehousePalletTransitionService service;
@PostMapping("/{palletId}")
public ApiResponse<PalletTransitionResponse> apply(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long palletId, @Valid @RequestBody PalletTransitionRequests.Apply request) {
tokenGuard.verify(token);
return ApiResponse.success(service.apply(palletId, request));
}
@PostMapping("/{palletId}/{operationKey}/compensate")
public ApiResponse<PalletTransitionResponse> compensate(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long palletId, @PathVariable String operationKey,
@Valid @RequestBody PalletTransitionRequests.Compensate request) {
tokenGuard.verify(token);
return ApiResponse.success(service.compensate(palletId, operationKey, request));
}
}
@@ -31,4 +31,5 @@ public class InternalWarehousePalletController {
tokenGuard.verify(token);
return ApiResponse.success(service.resolvePublicPallet(palletCode));
}
}
@@ -0,0 +1,20 @@
package com.baiyue.warehouse.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
public final class PalletTransitionRequests {
private PalletTransitionRequests() { }
public record Apply(@Positive long warehouseId,
@PositiveOrZero int expectedVersion,
@NotBlank @Size(max = 20) String targetStatus,
@NotNull @Positive Long targetLocationId,
@NotBlank @Size(max = 128) String operationKey,
@Size(max = 500) String reason) { }
public record Compensate(@Positive long warehouseId) { }
}
@@ -0,0 +1,16 @@
package com.baiyue.warehouse.dto;
public record PalletTransitionResponse(
String operationKey,
long palletId,
String palletCode,
long warehouseId,
String beforeStatus,
String afterStatus,
long beforeLocationId,
long afterLocationId,
int beforeVersion,
int afterVersion,
String operationStatus,
boolean replayed
) { }
@@ -0,0 +1,29 @@
package com.baiyue.warehouse.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("wh_pallet_operation")
public class WarehousePalletOperationEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String operationKey;
private String requestHash;
private Long warehouseId;
private Long palletId;
private String palletCode;
private String beforeStatus;
private String afterStatus;
private Long beforeLocationId;
private Long afterLocationId;
private Integer beforeVersion;
private Integer afterVersion;
private String operationStatus;
private String reason;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -3,5 +3,4 @@ package com.baiyue.warehouse.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.warehouse.entity.WarehousePalletEntity;
public interface WarehousePalletMapper extends BaseMapper<WarehousePalletEntity> {
}
public interface WarehousePalletMapper extends BaseMapper<WarehousePalletEntity> { }
@@ -0,0 +1,32 @@
package com.baiyue.warehouse.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.warehouse.entity.WarehousePalletEntity;
import com.baiyue.warehouse.entity.WarehousePalletOperationEntity;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
public interface WarehousePalletTransitionMapper extends BaseMapper<WarehousePalletOperationEntity> {
@Select("SELECT * FROM wh_pallet_operation WHERE operation_key=#{operationKey} LIMIT 1")
WarehousePalletOperationEntity findOperation(@Param("operationKey") String operationKey);
@Select("SELECT * FROM wh_pallet_operation WHERE operation_key=#{operationKey} LIMIT 1 FOR UPDATE")
WarehousePalletOperationEntity lockOperation(@Param("operationKey") String operationKey);
@Select("SELECT * FROM wh_pallet WHERE id=#{palletId} AND customer_id=0 " +
"AND warehouse_id=#{warehouseId} AND deleted=0 LIMIT 1 FOR UPDATE")
WarehousePalletEntity lockPublicPallet(@Param("warehouseId") long warehouseId,
@Param("palletId") long palletId);
@Select("SELECT COUNT(*) FROM wh_location WHERE id=#{locationId} AND customer_id=0 " +
"AND warehouse_id=#{warehouseId} AND status&lt;&gt;'INACTIVE' AND deleted=0")
long countActiveLocation(@Param("warehouseId") long warehouseId, @Param("locationId") long locationId);
@Update("UPDATE wh_pallet SET status=#{targetStatus},location_id=#{targetLocationId},version=version+1," +
"updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{palletId} AND customer_id=0 " +
"AND warehouse_id=#{warehouseId} AND version=#{expectedVersion} AND status&lt;&gt;'SCRAPPED' AND deleted=0")
int transition(@Param("warehouseId") long warehouseId, @Param("palletId") long palletId,
@Param("targetStatus") String targetStatus, @Param("targetLocationId") long targetLocationId,
@Param("expectedVersion") int expectedVersion);
}
@@ -0,0 +1,106 @@
package com.baiyue.warehouse.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.PalletTransitionRequests;
import com.baiyue.warehouse.dto.PalletTransitionResponse;
import com.baiyue.warehouse.entity.WarehousePalletEntity;
import com.baiyue.warehouse.entity.WarehousePalletOperationEntity;
import com.baiyue.warehouse.mapper.WarehousePalletTransitionMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.HexFormat;
import java.util.Set;
@Service
public class WarehousePalletTransitionService {
private static final Set<String> MUTABLE_STATUSES = Set.of("IDLE", "OCCUPIED");
@Resource private WarehousePalletTransitionMapper mapper;
@Transactional
public PalletTransitionResponse apply(long palletId, PalletTransitionRequests.Apply request) {
String key = request.operationKey().trim();
String status = request.targetStatus().trim().toUpperCase();
if (!MUTABLE_STATUSES.contains(status)) {
throw new BizException(ResultCode.BAD_REQUEST, "托盘作业状态只允许 IDLE 或 OCCUPIED");
}
String requestHash = hash(request.warehouseId() + "|" + palletId + "|" + request.expectedVersion()
+ "|" + status + "|" + request.targetLocationId());
WarehousePalletOperationEntity existing = mapper.findOperation(key);
if (existing != null) return replay(existing, requestHash);
WarehousePalletEntity pallet = mapper.lockPublicPallet(request.warehouseId(), palletId);
if (pallet == null) throw new BizException(ResultCode.NOT_FOUND, "当前冻结仓内托盘不存在");
int version = pallet.getVersion() == null ? 0 : pallet.getVersion();
if (version != request.expectedVersion()) {
throw new BizException(ResultCode.CONFLICT, "托盘版本已变化,请重新扫码");
}
if (mapper.countActiveLocation(request.warehouseId(), request.targetLocationId()) != 1) {
throw new BizException(ResultCode.CONFLICT, "目标库位无效或已停用");
}
if (pallet.getLocationId() == null) {
throw new BizException(ResultCode.CONFLICT, "托盘没有有效库位,不能执行库内作业");
}
if (mapper.transition(request.warehouseId(), palletId, status, request.targetLocationId(), version) != 1) {
throw new BizException(ResultCode.CONFLICT, "托盘版本已变化,请重新扫码");
}
WarehousePalletOperationEntity operation = new WarehousePalletOperationEntity();
operation.setId(IdWorker.getId()); operation.setOperationKey(key); operation.setRequestHash(requestHash);
operation.setWarehouseId(request.warehouseId()); operation.setPalletId(palletId);
operation.setPalletCode(pallet.getPalletCode()); operation.setBeforeStatus(pallet.getStatus());
operation.setAfterStatus(status); operation.setBeforeLocationId(pallet.getLocationId());
operation.setAfterLocationId(request.targetLocationId()); operation.setBeforeVersion(version);
operation.setAfterVersion(version + 1); operation.setOperationStatus("APPLIED");
operation.setReason(trim(request.reason())); operation.setCreatedAt(LocalDateTime.now());
operation.setUpdatedAt(operation.getCreatedAt()); mapper.insert(operation);
return response(operation, false);
}
@Transactional
public PalletTransitionResponse compensate(long palletId, String operationKey,
PalletTransitionRequests.Compensate request) {
WarehousePalletOperationEntity operation = mapper.lockOperation(operationKey);
if (operation == null || !Long.valueOf(palletId).equals(operation.getPalletId())
|| !Long.valueOf(request.warehouseId()).equals(operation.getWarehouseId())) {
throw new BizException(ResultCode.NOT_FOUND, "托盘状态变更记录不存在");
}
if ("COMPENSATED".equals(operation.getOperationStatus())) return response(operation, true);
if (!"APPLIED".equals(operation.getOperationStatus())) {
throw new BizException(ResultCode.CONFLICT, "托盘状态变更不可补偿");
}
if (mapper.transition(request.warehouseId(), palletId, operation.getBeforeStatus(),
operation.getBeforeLocationId(), operation.getAfterVersion()) != 1) {
throw new BizException(ResultCode.CONFLICT, "托盘已被后续作业修改,自动补偿失败");
}
operation.setOperationStatus("COMPENSATED"); operation.setUpdatedAt(LocalDateTime.now());
mapper.updateById(operation);
return response(operation, false);
}
private PalletTransitionResponse replay(WarehousePalletOperationEntity operation, String hash) {
if (!hash.equals(operation.getRequestHash())) {
throw new BizException(ResultCode.CONFLICT, "操作幂等键已被不同托盘请求使用");
}
return response(operation, true);
}
private static PalletTransitionResponse response(WarehousePalletOperationEntity value, boolean replayed) {
return new PalletTransitionResponse(value.getOperationKey(), value.getPalletId(), value.getPalletCode(),
value.getWarehouseId(), value.getBeforeStatus(), value.getAfterStatus(), value.getBeforeLocationId(),
value.getAfterLocationId(), value.getBeforeVersion(), value.getAfterVersion(),
value.getOperationStatus(), replayed);
}
private static String hash(String value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); }
}
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
}
@@ -178,6 +178,20 @@ CREATE TABLE IF NOT EXISTS wh_pallet_business_binding (
UNIQUE KEY uk_wh_pallet_binding_pallet (pallet_id)
) ENGINE=InnoDB COMMENT='托盘业务绑定关系';
CREATE TABLE IF NOT EXISTS wh_pallet_operation (
id BIGINT NOT NULL COMMENT '托盘状态操作ID', operation_key VARCHAR(128) NOT NULL COMMENT '跨服务幂等操作键',
request_hash VARCHAR(128) NOT NULL COMMENT '状态变更请求摘要', warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID',
pallet_id BIGINT NOT NULL COMMENT '实物托盘ID', pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照',
before_status VARCHAR(20) NOT NULL COMMENT '变更前托盘状态', after_status VARCHAR(20) NOT NULL COMMENT '变更后托盘状态',
before_location_id BIGINT NOT NULL COMMENT '变更前库位ID', after_location_id BIGINT NOT NULL COMMENT '变更后库位ID',
before_version INT NOT NULL COMMENT '变更前托盘版本', after_version INT NOT NULL COMMENT '变更后托盘版本',
operation_status VARCHAR(32) NOT NULL COMMENT '操作状态:APPLIED已应用、COMPENSATED已补偿',
reason VARCHAR(500) NULL 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_wh_pallet_operation_key (operation_key),
KEY idx_wh_pallet_operation_pallet (warehouse_id,pallet_id,created_at)
) ENGINE=InnoDB COMMENT='实物托盘状态幂等变更与补偿记录';
-- Every warehouse has one non-occupiable virtual location used for inventory history.
INSERT INTO wh_location (id, customer_id, warehouse_id, zone_code, location_code, location_type,
max_load_kg, status, remark)
@@ -29,7 +29,7 @@ class SchemaChineseCommentContractTest {
Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8);
List<TableDefinition> tables = parseAndAssert(baseline, "仓储基线 SQL");
assertEquals(7, tables.size(), "仓储表数量变化时必须同步表索引和备注迁移");
assertEquals(8, tables.size(), "仓储表数量变化时必须同步表索引和备注迁移");
parseAndAssert(incremental, "统一增量 SQL");
int marker = incremental.indexOf("2026-08-18 / 025 Chinese table and column comments");
@@ -0,0 +1,91 @@
package com.baiyue.warehouse.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.PalletTransitionRequests;
import com.baiyue.warehouse.entity.WarehousePalletEntity;
import com.baiyue.warehouse.entity.WarehousePalletOperationEntity;
import com.baiyue.warehouse.mapper.WarehousePalletTransitionMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class WarehousePalletTransitionServiceTest {
@Mock private WarehousePalletTransitionMapper mapper;
private WarehousePalletTransitionService service;
@BeforeEach void setUp() {
service = new WarehousePalletTransitionService();
ReflectionTestUtils.setField(service, "mapper", mapper);
}
@Test void appliesVersionedPublicPalletTransitionAndPersistsCompensationReceipt() {
WarehousePalletEntity pallet = pallet("P-1", "IDLE", 4);
when(mapper.lockPublicPallet(88L, 10L)).thenReturn(pallet);
when(mapper.countActiveLocation(88L, 20L)).thenReturn(1L);
when(mapper.transition(88L, 10L, "OCCUPIED", 20L, 4)).thenReturn(1);
var response = service.apply(10L, new PalletTransitionRequests.Apply(
88L, 4, "OCCUPIED", 20L, "operation-1", "assemble"));
assertEquals("APPLIED", response.operationStatus());
assertEquals(5, response.afterVersion());
ArgumentCaptor<WarehousePalletOperationEntity> captor = ArgumentCaptor.forClass(WarehousePalletOperationEntity.class);
verify(mapper).insert(captor.capture());
assertEquals("IDLE", captor.getValue().getBeforeStatus());
assertEquals("OCCUPIED", captor.getValue().getAfterStatus());
assertEquals(4, captor.getValue().getBeforeVersion());
}
@Test void exactReplayDoesNotMutatePhysicalPalletAgain() {
WarehousePalletOperationEntity existing = operation();
when(mapper.findOperation("operation-1")).thenReturn(existing);
var response = service.apply(10L, new PalletTransitionRequests.Apply(
88L, 4, "OCCUPIED", 20L, "operation-1", "any changed reason"));
assertTrue(response.replayed());
verify(mapper, never()).transition(anyLong(), anyLong(), anyString(), anyLong(), anyInt());
}
@Test void compensationRequiresExactAppliedVersionAndRestoresBeforeState() {
WarehousePalletOperationEntity existing = operation();
when(mapper.lockOperation("operation-1")).thenReturn(existing);
when(mapper.transition(88L, 10L, "IDLE", 11L, 5)).thenReturn(1);
var response = service.compensate(10L, "operation-1", new PalletTransitionRequests.Compensate(88L));
assertEquals("COMPENSATED", response.operationStatus());
verify(mapper).updateById(existing);
}
@Test void staleVersionIsRejectedWithoutPhysicalMutation() {
when(mapper.lockPublicPallet(88L, 10L)).thenReturn(pallet("P-1", "IDLE", 5));
assertThrows(BizException.class, () -> service.apply(10L, new PalletTransitionRequests.Apply(
88L, 4, "OCCUPIED", 20L, "operation-1", null)));
verify(mapper, never()).transition(anyLong(), anyLong(), anyString(), anyLong(), anyInt());
}
private static WarehousePalletEntity pallet(String code, String status, int version) {
WarehousePalletEntity value = new WarehousePalletEntity(); value.setId(10L); value.setCustomerId(0L);
value.setWarehouseId(88L); value.setLocationId(11L); value.setPalletCode(code);
value.setStatus(status); value.setVersion(version); return value;
}
private static WarehousePalletOperationEntity operation() {
WarehousePalletOperationEntity value = new WarehousePalletOperationEntity(); value.setId(1L);
value.setOperationKey("operation-1");
value.setRequestHash("f17b4386e6fdecbaab1d066ae4fefa47dba0687ff27361b8f92ef097d4e8072e");
value.setWarehouseId(88L); value.setPalletId(10L); value.setPalletCode("P-1");
value.setBeforeStatus("IDLE"); value.setAfterStatus("OCCUPIED"); value.setBeforeLocationId(11L);
value.setAfterLocationId(20L); value.setBeforeVersion(4); value.setAfterVersion(5);
value.setOperationStatus("APPLIED"); return value;
}
}