From 042d0b738a91390f35a2619f9f445e81aad62ed8 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 11:20:36 +0800 Subject: [PATCH] feat: complete overseas pda warehouse operations --- deploy/sql/海外区域统一增量-MySQL8.sql | 152 ++++++ .../service/InternalBusinessFileService.java | 3 +- .../InternalBusinessFileServiceTest.java | 16 + lastleg-service/AGENTS.md | 1 + .../PdaPalletOperationController.java | 58 ++ .../dto/PalletOperationRequests.java | 42 ++ .../dto/PalletOperationResponses.java | 23 + .../palletops/entity/PdaPalletOperation.java | 25 + .../gateway/HttpPalletPhysicalGateway.java | 75 +++ .../gateway/PalletPhysicalGateway.java | 16 + .../mapper/PdaPalletOperationMapper.java | 45 ++ .../palletops/model/PalletInventoryRow.java | 27 + .../service/PdaPalletOperationService.java | 506 ++++++++++++++++++ .../PdaInventoryMoveController.java | 30 ++ .../pda/move/dto/PdaInventoryMoveRequest.java | 13 + .../move/dto/PdaInventoryMoveResponse.java | 26 + .../pda/move/entity/PdaInventoryMove.java | 35 ++ .../move/mapper/PdaInventoryMoveMapper.java | 25 + .../move/service/PdaInventoryMoveService.java | 192 +++++++ .../controller/PdaStocktakeController.java | 61 +++ .../stocktake/dto/PdaStocktakeRequests.java | 28 + .../stocktake/dto/PdaStocktakeResponses.java | 20 + .../stocktake/entity/PdaStocktakeAction.java | 24 + .../stocktake/entity/PdaStocktakeItem.java | 35 ++ .../stocktake/entity/PdaStocktakeTask.java | 32 ++ .../mapper/PdaStocktakeActionMapper.java | 15 + .../mapper/PdaStocktakeInventoryMapper.java | 20 + .../mapper/PdaStocktakeItemMapper.java | 66 +++ .../mapper/PdaStocktakeTaskMapper.java | 51 ++ .../stocktake/model/StocktakeCandidate.java | 19 + .../service/PdaStocktakeService.java | 323 +++++++++++ .../pda/PdaRelabelMapperConfiguration.java | 9 + .../pda/controller/PdaRelabelController.java | 127 +++++ .../relabel/pda/dto/PdaRelabelRequests.java | 45 ++ .../relabel/pda/dto/PdaRelabelResponses.java | 37 ++ .../relabel/pda/entity/PdaRelabelHistory.java | 24 + .../pda/entity/PdaRelabelOperation.java | 22 + .../relabel/pda/entity/PdaRelabelTask.java | 37 ++ .../pda/entity/PdaRelabelUnitFact.java | 26 + .../gateway/HttpRelabelPhotoFileGateway.java | 109 ++++ .../pda/gateway/RelabelPhotoFileGateway.java | 10 + .../pda/mapper/PdaRelabelHistoryMapper.java | 9 + .../pda/mapper/PdaRelabelOperationMapper.java | 13 + .../pda/mapper/PdaRelabelTaskMapper.java | 30 ++ .../pda/mapper/PdaRelabelUnitFactMapper.java | 13 + .../pda/service/PdaRelabelService.java | 480 +++++++++++++++++ .../src/main/resources/db/lastleg.sql | 150 ++++++ .../openapi/overseas-pda-operations-v1.yaml | 256 +++++++++ .../HttpWarehouseResourceGatewayTest.java | 1 + ...PalletOperationControllerContractTest.java | 27 + .../PdaPalletOperationServiceTest.java | 124 +++++ .../PdaInventoryOperationContractTest.java | 41 ++ .../pda/PdaOperationsOpenApiContractTest.java | 83 +++ .../service/PdaInventoryMoveServiceTest.java | 141 +++++ .../service/PdaStocktakeServiceTest.java | 146 +++++ .../controller/PdaRelabelContractTest.java | 86 +++ .../pda/service/PdaRelabelServiceTest.java | 245 +++++++++ scripts/db/incremental.sql | 35 ++ ...InternalPdaPalletTransitionController.java | 39 ++ .../InternalWarehousePalletController.java | 1 + .../dto/PalletTransitionRequests.java | 20 + .../dto/PalletTransitionResponse.java | 16 + .../WarehousePalletOperationEntity.java | 29 + .../mapper/WarehousePalletMapper.java | 3 +- .../WarehousePalletTransitionMapper.java | 32 ++ .../WarehousePalletTransitionService.java | 106 ++++ .../src/main/resources/db/warehouse.sql | 14 + .../SchemaChineseCommentContractTest.java | 2 +- .../WarehousePalletTransitionServiceTest.java | 91 ++++ 69 files changed, 4679 insertions(+), 4 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationResponses.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/palletops/entity/PdaPalletOperation.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/PalletPhysicalGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/palletops/model/PalletInventoryRow.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/controller/PdaInventoryMoveController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/dto/PdaInventoryMoveRequest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/dto/PdaInventoryMoveResponse.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/entity/PdaInventoryMove.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/mapper/PdaInventoryMoveMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/controller/PdaStocktakeController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/dto/PdaStocktakeRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/dto/PdaStocktakeResponses.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeAction.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeItem.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeTask.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeActionMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeInventoryMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeItemMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeTaskMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/model/StocktakeCandidate.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/service/PdaStocktakeService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/PdaRelabelMapperConfiguration.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelHistory.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelOperation.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelTask.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelUnitFact.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/RelabelPhotoFileGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelHistoryMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelOperationMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelTaskMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelUnitFactMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java create mode 100644 lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaInventoryOperationContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOperationsOpenApiContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/pda/stocktake/service/PdaStocktakeServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalPdaPalletTransitionController.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionRequests.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionResponse.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletOperationEntity.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapper.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletTransitionService.java create mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletTransitionServiceTest.java diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 4748e0dba..a64450395 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -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; diff --git a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java index d320f4aaa..86268a298 100644 --- a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java +++ b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java @@ -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; diff --git a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java index 7b8c7bcd6..f0cc94a06 100644 --- a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java @@ -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()))) diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index d2853f74d..7adcf9f70 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -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` | diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java new file mode 100644 index 000000000..def4fce41 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java @@ -0,0 +1,58 @@ +package com.baiyue.lastleg.palletops.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.palletops.dto.PalletOperationRequests; +import com.baiyue.lastleg.palletops.dto.PalletOperationResponses; +import com.baiyue.lastleg.palletops.service.PdaPalletOperationService; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/pda/v1/last-leg/pallet-operations") +@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')") +public class PdaPalletOperationController { + @Resource private PdaPalletOperationService service; + + @PostMapping("/pallets/resolve") + public ApiResponse resolve( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PalletOperationRequests.Scan request) { + return ApiResponse.success(service.resolve(PdaWarehouseIdentity.require(user), request)); + } + + @PostMapping("/assemble") + public ApiResponse assemble( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PalletOperationRequests.Assemble request) { + return ApiResponse.success(service.assemble(PdaWarehouseIdentity.require(user), request)); + } + + @PostMapping("/disassemble") + public ApiResponse disassemble( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PalletOperationRequests.Disassemble request) { + return ApiResponse.success(service.disassemble(PdaWarehouseIdentity.require(user), request)); + } + + @PostMapping("/swap") + public ApiResponse swap( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PalletOperationRequests.Swap request) { + return ApiResponse.success(service.swap(PdaWarehouseIdentity.require(user), request)); + } + + @PostMapping("/unbind") + public ApiResponse unbind( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PalletOperationRequests.Unbind request) { + return ApiResponse.success(service.unbind(PdaWarehouseIdentity.require(user), request)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationRequests.java new file mode 100644 index 000000000..1b804c6ca --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationRequests.java @@ -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) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationResponses.java new file mode 100644 index 000000000..c0cd61ce7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationResponses.java @@ -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 inventory) { } + public record Operation(long operationId, String operationType, String idempotencyKey, + String status, boolean replayed, LocalDateTime completedAt, + List pallets) { + public Operation asReplay() { + return new Operation(operationId, operationType, idempotencyKey, status, true, completedAt, pallets); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/entity/PdaPalletOperation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/entity/PdaPalletOperation.java new file mode 100644 index 000000000..7d4e1a9be --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/entity/PdaPalletOperation.java @@ -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; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java new file mode 100644 index 000000000..c93045fd4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java @@ -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 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) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/PalletPhysicalGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/PalletPhysicalGateway.java new file mode 100644 index 000000000..f00726b97 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/PalletPhysicalGateway.java @@ -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) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java new file mode 100644 index 000000000..91be448d6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java @@ -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 { + 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 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); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/model/PalletInventoryRow.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/model/PalletInventoryRow.java new file mode 100644 index 000000000..98aa0084c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/model/PalletInventoryRow.java @@ -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; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java new file mode 100644 index 000000000..3bc77d10c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java @@ -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 pallets = resolvePallets(identity.overseasWarehouseId(), + request.target(), request.sources()); + PalletPhysicalGateway.PalletReference target = pallets.get(code(request.target().palletCode())); + Map sources = new LinkedHashMap<>(); + request.sources().forEach(value -> sources.put(code(value.palletCode()), pallets.get(code(value.palletCode())))); + List 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 expectations = new LinkedHashMap<>(); + expectations.put(code(request.source().palletCode()), request.source()); + request.targets().forEach(value -> expectations.put(code(value.target().palletCode()), value.target())); + Map pallets = resolvePallets(identity.overseasWarehouseId(), + expectations.values()); + PalletPhysicalGateway.PalletReference source = pallets.get(code(request.source().palletCode())); + List 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 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 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 rows = nonEmptyContent(identity.overseasWarehouseId(), source.palletId()); + List 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 pallets = resolvePallets(identity.overseasWarehouseId(), + List.of(request.source())); + PalletPhysicalGateway.PalletReference source = pallets.get(code(request.source().palletCode())); + List rows = nonEmptyContent(identity.overseasWarehouseId(), source.palletId()); + List 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 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 seen = new HashSet<>(); + LinkedHashSet 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 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 pallets, + List moves, Set targets, boolean unbind) { + Map movedBySource = new HashMap<>(); + moves.forEach(move -> movedBySource.merge(move.sourcePalletId(), move.packages(), Long::sum)); + List 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 plannedLines(long warehouseId, List lines, + Map sources, + PalletPhysicalGateway.PalletReference target, + PalletPhysicalGateway.PalletReference requiredSource) { + Map 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 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 moves, + List targets) { + LinkedHashSet 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 resolvePallets(long warehouseId, + PalletOperationRequests.PalletExpectation first, List rest) { + List values = new ArrayList<>(); values.add(first); values.addAll(rest); + return resolvePallets(warehouseId, values); + } + private Map resolvePallets(long warehouseId, + java.util.Collection values) { + LinkedHashMap 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 nonEmptyContent(long warehouseId, long palletId) { + List values = mapper.content(String.valueOf(warehouseId), palletId); + if (values.isEmpty()) throw conflict("来源托盘没有可操作库存"); + return values; + } + + private PalletOperationResponses.PalletContent content(long warehouseId, + PalletPhysicalGateway.PalletReference pallet) { + List rows = mapper.content(String.valueOf(warehouseId), pallet.palletId()); + LinkedHashSet destinations = new LinkedHashSet<>(); + List 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 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 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 moves) { + Set 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 pallets, + List moves, List changes) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/controller/PdaInventoryMoveController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/controller/PdaInventoryMoveController.java new file mode 100644 index 000000000..6bfdf88b7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/controller/PdaInventoryMoveController.java @@ -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 move(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PdaInventoryMoveRequest request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.move(identity.overseasWarehouseId(), identity.operatorKey(), request)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/dto/PdaInventoryMoveRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/dto/PdaInventoryMoveRequest.java new file mode 100644 index 000000000..0e79b961d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/dto/PdaInventoryMoveRequest.java @@ -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 +) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/dto/PdaInventoryMoveResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/dto/PdaInventoryMoveResponse.java new file mode 100644 index 000000000..40f55affc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/dto/PdaInventoryMoveResponse.java @@ -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); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/entity/PdaInventoryMove.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/entity/PdaInventoryMove.java new file mode 100644 index 000000000..c57580eaa --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/entity/PdaInventoryMove.java @@ -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; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/mapper/PdaInventoryMoveMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/mapper/PdaInventoryMoveMapper.java new file mode 100644 index 000000000..4347aa101 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/mapper/PdaInventoryMoveMapper.java @@ -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 { + @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 lockPositions(@Param("warehouseId") String warehouseId, + @Param("palletId") long palletId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveService.java new file mode 100644 index 000000000..03be9225d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveService.java @@ -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 inventory = moves.lockPositions(String.valueOf(warehouseId), pallet.palletId()); + if (inventory.isEmpty()) throw conflict("Source pallet has no movable inventory"); + Set 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 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); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/controller/PdaStocktakeController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/controller/PdaStocktakeController.java new file mode 100644 index 000000000..fb103115a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/controller/PdaStocktakeController.java @@ -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> page( + @AuthenticationPrincipal AuthenticatedUser user, @Valid PdaStocktakeRequests.TaskQuery query) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.page(identity.overseasWarehouseId(), query)); + } + + @PostMapping + public ApiResponse 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 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 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 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)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/dto/PdaStocktakeRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/dto/PdaStocktakeRequests.java new file mode 100644 index 000000000..bfba023cd --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/dto/PdaStocktakeRequests.java @@ -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) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/dto/PdaStocktakeResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/dto/PdaStocktakeResponses.java new file mode 100644 index 000000000..ce72041aa --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/dto/PdaStocktakeResponses.java @@ -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 items) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeAction.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeAction.java new file mode 100644 index 000000000..a0246bf33 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeAction.java @@ -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; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeItem.java new file mode 100644 index 000000000..0473f5343 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeItem.java @@ -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; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeTask.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeTask.java new file mode 100644 index 000000000..733b8ac43 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeTask.java @@ -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; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeActionMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeActionMapper.java new file mode 100644 index 000000000..3e42d42d6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeActionMapper.java @@ -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 { + @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); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeInventoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeInventoryMapper.java new file mode 100644 index 000000000..e2ab71e35 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeInventoryMapper.java @@ -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); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeItemMapper.java new file mode 100644 index 000000000..e74e61729 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeItemMapper.java @@ -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 { + 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 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 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 list(@Param("taskId") long taskId); + + @Select("SELECT * FROM ll_pda_stocktake_item WHERE task_id=#{taskId} ORDER BY id FOR UPDATE") + List 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 views(@Param("taskId") long taskId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeTaskMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeTaskMapper.java new file mode 100644 index 000000000..77ffae857 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeTaskMapper.java @@ -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 { + @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("") + List page(@Param("warehouseId") long warehouseId, + @Param("status") String status, + @Param("offset") long offset, @Param("limit") long limit); + + @Select("") + 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); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/model/StocktakeCandidate.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/model/StocktakeCandidate.java new file mode 100644 index 000000000..41443f089 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/model/StocktakeCandidate.java @@ -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; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/service/PdaStocktakeService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/service/PdaStocktakeService.java new file mode 100644 index 000000000..101e03b74 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/service/PdaStocktakeService.java @@ -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 page(long warehouseId, PdaStocktakeRequests.TaskQuery query) { + String status = status(query.status()); + long offset = (query.page() - 1) * query.size(); + List 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 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 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 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 read(PdaStocktakeAction action, Class 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); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/PdaRelabelMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/PdaRelabelMapperConfiguration.java new file mode 100644 index 000000000..893536c8e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/PdaRelabelMapperConfiguration.java @@ -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 { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java new file mode 100644 index 000000000..6d89ac391 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java @@ -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> candidates( + @AuthenticationPrincipal AuthenticatedUser user) { + Identity identity = identity(user); + return ApiResponse.success(service.candidates(identity.operatorKey(), identity.overseasWarehouseId())); + } + + @GetMapping("/{relabelNo}") + public ApiResponse 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 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 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 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 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 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 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 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) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java new file mode 100644 index 000000000..c363ac059 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java @@ -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) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java new file mode 100644 index 000000000..72724a325 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java @@ -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 completionPhotoFileIds, + String completionRemark, String reviewDecision, String reviewRemark, + LocalDateTime claimedAt, LocalDateTime submittedAt, LocalDateTime reviewedAt, + List items, List unitFacts, List 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) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelHistory.java new file mode 100644 index 000000000..674a07be8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelHistory.java @@ -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; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelOperation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelOperation.java new file mode 100644 index 000000000..991bbea9d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelOperation.java @@ -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; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelTask.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelTask.java new file mode 100644 index 000000000..f2e07ea86 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelTask.java @@ -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; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelUnitFact.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelUnitFact.java new file mode 100644 index 000000000..a6ece68da --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelUnitFact.java @@ -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; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java new file mode 100644 index 000000000..12cf36e23 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java @@ -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) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/RelabelPhotoFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/RelabelPhotoFileGateway.java new file mode 100644 index 000000000..c309f5dd8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/RelabelPhotoFileGateway.java @@ -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) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelHistoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelHistoryMapper.java new file mode 100644 index 000000000..ccd126e40 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelHistoryMapper.java @@ -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 { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelOperationMapper.java new file mode 100644 index 000000000..8e96b2050 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelOperationMapper.java @@ -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 { + @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); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelTaskMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelTaskMapper.java new file mode 100644 index 000000000..ee6b56521 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelTaskMapper.java @@ -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 { + @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); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelUnitFactMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelUnitFactMapper.java new file mode 100644 index 000000000..af9100c89 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelUnitFactMapper.java @@ -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 { + @Select("SELECT * FROM ll_relabel_pda_unit_fact WHERE old_label=#{oldLabel} LIMIT 1") + PdaRelabelUnitFact findOldLabel(@Param("oldLabel") String oldLabel); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java new file mode 100644 index 000000000..a9fc30a0b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java @@ -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 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 candidates(String operatorKey, long overseasWarehouseId) { + return applications.selectList(new LambdaQueryWrapper() + .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 detailItems = itemList(application); + List 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 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 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 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() + .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 itemList(RelabelApplication application) { return itemList(application.getId()); } + private List itemList(Long applicationId) { + return items.selectList(new LambdaQueryWrapper() + .eq(RelabelItem::getApplicationId, applicationId).orderByAsc(RelabelItem::getId)); + } + + private List factList(PdaRelabelTask task) { + return unitFacts.selectList(new LambdaQueryWrapper() + .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 detailItems = itemList(application); + List facts = task == null ? List.of() : factList(task); + Map> factsByItem = facts.stream() + .collect(Collectors.groupingBy(PdaRelabelUnitFact::getItemId, LinkedHashMap::new, Collectors.toList())); + List itemResponses = detailItems.stream().map(item -> { + List 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 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 historyResponses = task == null ? List.of() : histories.selectList( + new LambdaQueryWrapper().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 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 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) { } +} diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 201ed9d60..893cea457 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -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 '托盘号', diff --git a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml new file mode 100644 index 000000000..e455f6f48 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml @@ -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 } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java index 188ff9c41..a4005d745 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java @@ -58,4 +58,5 @@ class HttpWarehouseResourceGatewayTest { assertThat(result.palletId()).isEqualTo(2092040891261427713L); server.verify(); } + } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java new file mode 100644 index 000000000..1f728cc1d --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java @@ -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 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); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java new file mode 100644 index 000000000..59301bc76 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java @@ -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 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) { } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaInventoryOperationContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaInventoryOperationContractTest.java new file mode 100644 index 000000000..eaef73a37 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaInventoryOperationContractTest.java @@ -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 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 fields = Stream.of(type.getRecordComponents()).map(component -> component.getName()) + .collect(Collectors.toSet()); + assertThat(fields).doesNotContainAnyElementsOf(forbidden); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOperationsOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOperationsOpenApiContractTest.java new file mode 100644 index 000000000..c1a5118a4 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOperationsOpenApiContractTest.java @@ -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 SERVER_OWNED = Set.of( + "customerId", "warehouseId", "overseasWarehouseId", "operator", "operatorKey", "operatorName"); + + @Test + void pdaOperationRequestsNeverAcceptServerOwnedScope() { + List> 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 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 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); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveServiceTest.java new file mode 100644 index 000000000..cdb3714ae --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveServiceTest.java @@ -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 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); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/stocktake/service/PdaStocktakeServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/stocktake/service/PdaStocktakeServiceTest.java new file mode 100644 index 000000000..255e8180c --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/stocktake/service/PdaStocktakeServiceTest.java @@ -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 task = ArgumentCaptor.forClass(PdaStocktakeTask.class); + ArgumentCaptor 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 changedPosition = ArgumentCaptor.forClass(InventoryPosition.class); + ArgumentCaptor changedBalance = ArgumentCaptor.forClass(InventoryBalance.class); + ArgumentCaptor 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"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelContractTest.java new file mode 100644 index 000000000..188b70adf --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelContractTest.java @@ -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 forbidden = Set.of("customerId", "warehouseId", "overseasWarehouseId", + "operatorId", "operatorName", "tenantId"); + for (Class type : PdaRelabelRequests.class.getDeclaredClasses()) { + Set 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 permissions, Long warehouseId) { + return new AuthenticatedUser(7L, 0L, 0L, 70L, "picker", "PLATFORM", "PDA", + permissions, false, null, null, false, warehouseId); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java new file mode 100644 index 000000000..8ee0d9732 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java @@ -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 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 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 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; + } +} diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index 24a9d2c14..81332bff9 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -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 diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalPdaPalletTransitionController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalPdaPalletTransitionController.java new file mode 100644 index 000000000..37ffad011 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalPdaPalletTransitionController.java @@ -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 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 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)); + } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java index a5e66727a..1d651fc2d 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java @@ -31,4 +31,5 @@ public class InternalWarehousePalletController { tokenGuard.verify(token); return ApiResponse.success(service.resolvePublicPallet(palletCode)); } + } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionRequests.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionRequests.java new file mode 100644 index 000000000..9d8bdc683 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionRequests.java @@ -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) { } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionResponse.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionResponse.java new file mode 100644 index 000000000..c3e159661 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionResponse.java @@ -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 +) { } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletOperationEntity.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletOperationEntity.java new file mode 100644 index 000000000..f9bfbc36e --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletOperationEntity.java @@ -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; +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletMapper.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletMapper.java index 5e2f5ca0d..fb225f1f0 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletMapper.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletMapper.java @@ -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 { -} +public interface WarehousePalletMapper extends BaseMapper { } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapper.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapper.java new file mode 100644 index 000000000..751a848c0 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapper.java @@ -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 { + @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); +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletTransitionService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletTransitionService.java new file mode 100644 index 000000000..bf2782ec6 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletTransitionService.java @@ -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 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(); } +} diff --git a/warehouse-overseas-service/src/main/resources/db/warehouse.sql b/warehouse-overseas-service/src/main/resources/db/warehouse.sql index 9eb4d8dcd..7665093d0 100644 --- a/warehouse-overseas-service/src/main/resources/db/warehouse.sql +++ b/warehouse-overseas-service/src/main/resources/db/warehouse.sql @@ -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) diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java index f59b0735b..678cdbb21 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java @@ -29,7 +29,7 @@ class SchemaChineseCommentContractTest { Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8); List 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"); diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletTransitionServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletTransitionServiceTest.java new file mode 100644 index 000000000..5f998e23c --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletTransitionServiceTest.java @@ -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 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; + } +}