feat: complete overseas pda warehouse operations
This commit is contained in:
@@ -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())))
|
||||
|
||||
@@ -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` |
|
||||
|
||||
+58
@@ -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));
|
||||
}
|
||||
}
|
||||
+42
@@ -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) { }
|
||||
}
|
||||
+23
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -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;
|
||||
}
|
||||
+75
@@ -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) { }
|
||||
}
|
||||
+16
@@ -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) { }
|
||||
}
|
||||
+45
@@ -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)>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);
|
||||
}
|
||||
+27
@@ -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;
|
||||
}
|
||||
+506
@@ -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) { }
|
||||
}
|
||||
+30
@@ -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));
|
||||
}
|
||||
}
|
||||
+13
@@ -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
|
||||
) { }
|
||||
+26
@@ -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);
|
||||
}
|
||||
}
|
||||
+35
@@ -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;
|
||||
}
|
||||
+25
@@ -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);
|
||||
}
|
||||
+192
@@ -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); }
|
||||
}
|
||||
+61
@@ -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));
|
||||
}
|
||||
}
|
||||
+28
@@ -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) { }
|
||||
}
|
||||
+20
@@ -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) { }
|
||||
}
|
||||
+24
@@ -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;
|
||||
}
|
||||
+35
@@ -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;
|
||||
}
|
||||
+32
@@ -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;
|
||||
}
|
||||
+15
@@ -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);
|
||||
}
|
||||
+20
@@ -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);
|
||||
}
|
||||
+66
@@ -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<>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);
|
||||
}
|
||||
+51
@@ -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);
|
||||
}
|
||||
+19
@@ -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;
|
||||
}
|
||||
+323
@@ -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); }
|
||||
}
|
||||
+9
@@ -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 {
|
||||
}
|
||||
+127
@@ -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) { }
|
||||
}
|
||||
+45
@@ -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) { }
|
||||
}
|
||||
+37
@@ -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) { }
|
||||
}
|
||||
+24
@@ -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;
|
||||
}
|
||||
+22
@@ -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;
|
||||
}
|
||||
+37
@@ -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;
|
||||
}
|
||||
+26
@@ -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;
|
||||
}
|
||||
+109
@@ -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) { }
|
||||
}
|
||||
+10
@@ -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) { }
|
||||
}
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+13
@@ -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);
|
||||
}
|
||||
+30
@@ -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);
|
||||
}
|
||||
+13
@@ -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);
|
||||
}
|
||||
+480
@@ -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 }
|
||||
+1
@@ -58,4 +58,5 @@ class HttpWarehouseResourceGatewayTest {
|
||||
assertThat(result.palletId()).isEqualTo(2092040891261427713L);
|
||||
server.verify();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+27
@@ -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);
|
||||
}
|
||||
}
|
||||
+124
@@ -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) { }
|
||||
}
|
||||
}
|
||||
+41
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+83
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+141
@@ -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);
|
||||
}
|
||||
}
|
||||
+146
@@ -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");
|
||||
}
|
||||
}
|
||||
+86
@@ -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);
|
||||
}
|
||||
}
|
||||
+245
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+39
@@ -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));
|
||||
}
|
||||
}
|
||||
+1
@@ -31,4 +31,5 @@ public class InternalWarehousePalletController {
|
||||
tokenGuard.verify(token);
|
||||
return ApiResponse.success(service.resolvePublicPallet(palletCode));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+20
@@ -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) { }
|
||||
}
|
||||
+16
@@ -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
|
||||
) { }
|
||||
+29
@@ -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;
|
||||
}
|
||||
+1
-2
@@ -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> { }
|
||||
|
||||
+32
@@ -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<>'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<>'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);
|
||||
}
|
||||
+106
@@ -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)
|
||||
|
||||
+1
-1
@@ -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");
|
||||
|
||||
+91
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user