diff --git a/AGENTS.md b/AGENTS.md index 3d5428221..4b7eadd36 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,8 +60,8 @@ | 海外排车执行、PDA 出库、派送与一件代发 | `lastleg-service/` | `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`OutboundPdaController.java`、`MerchantDeliveryController.java`,一件代发库存归 `dropship/`;统一经 `gateway-overseas/`,设备端正式入口继续查 `../apps/pda-overseas-uniapp/AGENTS.md` | | 海外异常退回、库存恢复与客户回访 | `lastleg-service/`、`file/` | `exceptionorder/`、`inventory/command/`、`customervisit/`;异常按版本和幂等键执行 `PENDING → PROCESSING → COMPLETED`,完成证据固定归 `OVERSEAS_EXCEPTION_EVIDENCE`。派送退回明细只能来自原出库拣货事实,退回海外仓时校验真实目标托盘/库位并以 `RETURN_STOCK_IN` 恢复库存后,原子完成异常单与派送 `RETURNED`;回访仍以 Lastleg 客户范围和派送状态为准 | | 一件代发 SKU/箱唛与暂扣跨区协同 | `order/`、`firstleg/`、`common-event-contract/`、`lastleg-service/` | Order `InternalOrderCargoController` 提供强制 SKU 与每箱数量;Firstleg `InboundCartonLabelService` 冻结箱唛 SKU,`DropshipPalletizationService` 完成清点、组托和封托,智能排柜只按 `SEALED` 整托分配,并消费 `OverseasInboundOrderDetainedChangedV1`;Lastleg `dropship/` 建立库存并发布暂扣变更 | -| 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`relabel/pda/`、`transfer/`、`discard/` 的 Merchant/PDA Controller 和 Service;换标下单按“托盘中的入仓单库存”选择,合托后的同一托盘允许包含多张入仓单但冻结明细必须保持原入仓单归属。每个我方箱唛只形成一条箱任务,支持箱唛、SKU 和组合换标,SKU 子项不重复冻结;PDA 扫箱唛、照片复核完成后释放原库存,再沿既有排车出库。Client 消费端 `../apps/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` | -| 海外仓 PDA 盘点、移库、托盘、换标与查询 | `lastleg-service/`、`warehouse-overseas-service/` | Lastleg `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`palletops/`、`relabel/pda/`;托盘作业保留托盘-入仓单组成明细,合托通过入仓计划托盘分配和合托记录留痕,出库可扫描托盘但后台按托盘下各入仓单箱数核平;契约 `lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml`;物理托盘流转与补偿进入 Warehouse `InternalPdaPalletTransitionController`,设备端入口是否开放以 `../apps/pda-overseas-uniapp/AGENTS.md` 为准 | +| 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`relabel/pda/`、`transfer/`、`discard/` 的 Merchant/PDA Controller 和 Service;换标下单按历史入仓库存选择,每个我方箱唛只形成一条箱任务,支持箱唛、SKU 和组合换标,SKU 子项不重复冻结。涉及箱唛换标时冻结派送方式,PDA 逐箱作业并上传完工照片,仓库复核后必须由客户查看证据确认,之后卡车进入预排车,自提/快递进入换标出库待创建;不伪造已入库单。Client 消费端 `../html/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` | +| 海外仓 PDA 盘点、移库、托盘、换标与查询 | `lastleg-service/`、`warehouse-overseas-service/` | Lastleg `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`palletops/`、`relabel/pda/`;托盘作业保留托盘-入仓单组成明细,合托通过入仓计划托盘分配和合托记录留痕;`pallet-operations/relabel-disassemble` 按换标单箱唛快照解析原库存仓位,在一个幂等事务内移动对应冻结箱数并绑定已扫码目标托盘,同时更新换标明细和未开始拣货的出库托盘/箱唛快照;出库可扫描托盘但后台按托盘下各入仓单箱数核平;契约 `lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml`;物理托盘流转与补偿进入 Warehouse `InternalPdaPalletTransitionController`,设备端入口是否开放以 `../apps/pda-overseas-uniapp/AGENTS.md` 为准 | | 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` | Firstleg `portschedule/service/CarrierScheduleNodeOutboxService.java` 发布 `FirstLegContainerShippingNodeRecordedV1`;Lastleg `shipping/service/ContainerShippingNodeConsumer.java` 单调消费,只有实际 `PICKUP_READY` 及以后节点允许海外 PDA 候选进入可收货 | | 海外仓库目录、海外库位、海外托盘、资源锁 | 国内仓库目录归 `warehouse/`,海外资源归 `warehouse-overseas-service/` | `warehouse/AGENTS.md`、`warehouse-overseas-service/AGENTS.md`;海外库只保存国内 warehouseId 引用 | | Lastleg 内部模块化与依赖基线 | `lastleg-service/` | `lastleg-service/docs/lastleg-internal-modularization-baseline.md`、`lastleg-service/scripts/analyze-lastleg-dependencies.mjs`;只用于约束服务内部依赖,不改变服务和数据库边界 | diff --git a/auth/src/main/java/com/baiyue/auth/service/AuthService.java b/auth/src/main/java/com/baiyue/auth/service/AuthService.java index aed118905..6ff1a3fa6 100644 --- a/auth/src/main/java/com/baiyue/auth/service/AuthService.java +++ b/auth/src/main/java/com/baiyue/auth/service/AuthService.java @@ -205,10 +205,24 @@ public class AuthService { String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(signingHeader(), claims)).getTokenValue(); tokenSessionStore.storeAccessOnly(jwtId, account.getId(), authorization.getRoles(), authorization.getPermissions(), Duration.ofMinutes(15)); - return new ImpersonationTokenResponse("Bearer", accessToken, 900); + OverseasTokenService service = overseasTokens.getIfAvailable(); + OverseasTokenService.OverseasTokenResponse overseas = service == null + ? null : service.issue(account, authorization); + return new ImpersonationTokenResponse("Bearer", accessToken, 900, + overseas == null ? null : overseas.accessToken(), + overseas == null ? 0L : overseas.expiresIn(), + authorization.getRoles(), authorization.getPermissions()); } - public record ImpersonationTokenResponse(String tokenType, String accessToken, long expiresIn) { } + public record ImpersonationTokenResponse(String tokenType, String accessToken, long expiresIn, + String overseasAccessToken, long overseasExpiresIn, + java.util.Set roles, + java.util.Set authorities) { + public ImpersonationTokenResponse { + roles = roles == null ? java.util.Set.of() : java.util.Set.copyOf(roles); + authorities = authorities == null ? java.util.Set.of() : java.util.Set.copyOf(authorities); + } + } private JwsHeader signingHeader() { return JwsHeader.with(MacAlgorithm.HS256).build(); diff --git a/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java b/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java index 4b99948cc..1ea5b0459 100644 --- a/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java @@ -225,8 +225,16 @@ class AuthServiceSmallJwtTest { when(authorizationLoader.load(account)).thenReturn(snapshot); when(jwtEncoder.encode(any())).thenReturn(encoded); when(encoded.getTokenValue()).thenReturn("small-impersonation-token"); + when(overseasTokenService.issue(account, snapshot)).thenReturn( + new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-impersonation-token", 900, + Set.of("MERCHANT_OWNER"), Set.of("merchant:order:list"))); - service.exchangeImpersonationTicket("one-time-code"); + AuthService.ImpersonationTokenResponse response = service.exchangeImpersonationTicket("one-time-code"); + + assertThat(response.overseasAccessToken()).isEqualTo("overseas-impersonation-token"); + assertThat(response.overseasExpiresIn()).isEqualTo(900); + assertThat(response.roles()).containsExactly("MERCHANT_OWNER"); + assertThat(response.authorities()).containsExactly("merchant:order:list"); ArgumentCaptor jwt = ArgumentCaptor.forClass(JwtEncoderParameters.class); verify(jwtEncoder).encode(jwt.capture()); @@ -239,5 +247,6 @@ class AuthServiceSmallJwtTest { org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_OWNER")), org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")), org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(15))); + verify(overseasTokenService).issue(account, snapshot); } } diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 85e86aa10..58625ca82 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -1849,6 +1849,15 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_warehouse_code VARCHAR(128) NULL COMMENT ''原海外中转仓编码快照''')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_warehouse_name'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_warehouse_name VARCHAR(255) NULL COMMENT ''原海外中转仓名称快照''')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND index_name='idx_ll_relabel_item_source_position'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD KEY idx_ll_relabel_item_source_position (customer_id,source_inventory_position_id)')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='delivery_method'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN delivery_method VARCHAR(32) NULL COMMENT ''派送方式:TRUCK卡车、PICKUP自提、EXPRESS快递;仅涉及箱唛换标时必填''')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='print_shipping_label'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN print_shipping_label TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否需要打印面单''')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_id VARCHAR(128) NULL COMMENT ''客户上传面单文件ID''')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_name'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_name VARCHAR(255) NULL COMMENT ''客户上传面单文件名''')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_url'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_url VARCHAR(1000) NULL COMMENT ''客户上传面单下载地址''')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='fulfillment_status'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN fulfillment_status VARCHAR(32) NOT NULL DEFAULT ''WAIT_RELABEL'' COMMENT ''换标后履约状态''')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='outbound_order_no'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN outbound_order_no VARCHAR(64) NULL COMMENT ''自提或快递生成的换标出库单号''')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='customer_confirmation_key'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN customer_confirmation_key VARCHAR(255) NULL COMMENT ''客户确认完成幂等键''')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='customer_confirmed_at'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认换标完成时间''')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; -- 海外接柜仓由平台在尾程建单时预分配;历史测试数据不回填,缺失事实由业务接口失败关闭。 @@ -2251,6 +2260,15 @@ ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN pallet_no VARCHAR(128) NOT NULL SELECT 'PASS' AS migration_status, '海外尾程人工计划、暂扣与人工节点结构已就绪' AS message; +-- 2026-09-09 / 035 为历史退货订单补充尾程主订单关联,并回填已生成的 RETURN_RELABEL 尾程订单。 +USE baiyue_lastleg; +-- 2026-09-09 / 036 为派送单补充 BOL 号字段(不改原始 CREATE TABLE)。 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='bol_no'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN bol_no VARCHAR(128) NULL COMMENT ''派送资料BOL号,不作为文件归属标识''')); +PREPARE ll_delivery_order_bol_no_stmt FROM @ddl; EXECUTE ll_delivery_order_bol_no_stmt; DEALLOCATE PREPARE ll_delivery_order_bol_no_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='lastleg_order_id'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN lastleg_order_id BIGINT NULL COMMENT ''关联尾程主订单ID'' AFTER customer_reference_no')); +PREPARE ll_return_order_lastleg_id_stmt FROM @ddl; EXECUTE ll_return_order_lastleg_id_stmt; DEALLOCATE PREPARE ll_return_order_lastleg_id_stmt; +UPDATE ll_return_order r JOIN ll_order o ON o.source_order_id = CAST(r.id AS CHAR) AND o.source_order_no = r.return_no AND o.order_type = 'RETURN_RELABEL' SET r.lastleg_order_id = o.id WHERE r.lastleg_order_id IS NULL; + -- 柜级入库计划跨客户批量托盘预留、确认与失败补偿。 USE baiyue_lastlag_warehouse; CREATE TABLE IF NOT EXISTS wh_pallet_batch ( @@ -2278,3 +2296,21 @@ CREATE TABLE IF NOT EXISTS wh_pallet_batch_item ( UNIQUE KEY uk_wh_pallet_batch_item_sequence (batch_id,sequence_no), UNIQUE KEY uk_wh_pallet_batch_item_pallet (pallet_id), KEY idx_wh_pallet_batch_item_batch (batch_id,status) ) ENGINE=InnoDB COMMENT='柜级入库计划批量托盘明细与资源锁关联'; + +-- 2026-09-10 / 037 仓储转运以入中转仓为主流程,FBA 去向可后补;预入仓换标无需先绑定库存仓位。 +USE baiyue_lastleg; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order') AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='fba_warehouse_code' AND is_nullable='NO'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''可选FBA目的仓编码'', MODIFY COLUMN fba_shipment_no VARCHAR(128) NULL COMMENT ''可选FBA货件编号'', MODIFY COLUMN delivery_address VARCHAR(1000) NOT NULL COMMENT ''提货地址或中转仓收货地址'', MODIFY COLUMN delivery_method VARCHAR(64) NOT NULL COMMENT ''入仓方式'', MODIFY COLUMN estimated_arrival_at DATETIME(3) NOT NULL COMMENT ''预计入仓时间''', 'SELECT 1')); +PREPARE ll_transfer_optional_fba_stmt FROM @ddl; EXECUTE ll_transfer_optional_fba_stmt; DEALLOCATE PREPARE ll_transfer_optional_fba_stmt; +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_carton ( + id BIGINT NOT NULL COMMENT '预报箱主键', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', + transfer_item_id BIGINT NOT NULL COMMENT '转运申报明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + carton_mark VARCHAR(255) NOT NULL COMMENT '系统生成的一箱一码箱唛', carton_index INT NOT NULL COMMENT '全单箱序号', + total_cartons INT NOT NULL COMMENT '首次生成时全单总箱数', status VARCHAR(32) NOT NULL DEFAULT 'EXPECTED' COMMENT '箱状态:EXPECTED待实收', + 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_wh_transfer_carton_mark (customer_id,carton_mark), + UNIQUE KEY uk_ll_wh_transfer_carton_index (transfer_order_id,carton_index), + KEY idx_ll_wh_transfer_carton_list (customer_id,transfer_order_id,status) +) ENGINE=InnoDB COMMENT='仓储转运系统箱唛预报'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_relabel_item') AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name IN ('shipment_no','destination_fba_warehouse_code','source_inventory_balance_id','source_inventory_position_id','source_inbound_order_id') AND is_nullable='NO'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN shipment_no VARCHAR(128) NULL COMMENT ''货件编号(无FBA去向时为空)'', MODIFY COLUMN destination_fba_warehouse_code VARCHAR(128) NULL COMMENT ''可选FBA目的仓编码(与海外操作仓分离)'', MODIFY COLUMN source_inventory_balance_id BIGINT NULL COMMENT ''原库存余额ID(预入仓换标任务可为空)'', MODIFY COLUMN source_inventory_position_id BIGINT NULL COMMENT ''原库存库位ID(预入仓换标任务可为空)'', MODIFY COLUMN source_inbound_order_id BIGINT NULL COMMENT ''原入仓单ID(预入仓换标任务可为空)''', 'SELECT 1')); +PREPARE ll_relabel_pre_inbound_stmt FROM @ddl; EXECUTE ll_relabel_pre_inbound_stmt; DEALLOCATE PREPARE ll_relabel_pre_inbound_stmt; diff --git a/gateway-overseas/src/main/resources/application.yml b/gateway-overseas/src/main/resources/application.yml index 2d28efe8d..fca38239b 100644 --- a/gateway-overseas/src/main/resources/application.yml +++ b/gateway-overseas/src/main/resources/application.yml @@ -44,11 +44,11 @@ baiyue: jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json} pda-session-validation: auth-url: ${AUTH_SESSION_VALIDATION_URL} - internal-service-token: ${INTERNAL_SERVICE_TOKEN} + internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} timeout: ${PDA_SESSION_VALIDATION_TIMEOUT:2s} overseas-projection: service-url: ${LASTLEG_SECURITY_SERVICE_URL:http://lastleg-service:9303} - internal-service-token: ${INTERNAL_SERVICE_TOKEN} + internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} timeout: ${LASTLEG_SECURITY_VALIDATION_TIMEOUT:2s} permit-paths: - /actuator/health diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index f3d9c14d0..7073fb1b6 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -33,7 +33,7 @@ | 尾程暂扣状态回传 | `service/InboundOrderDetainedOutboxService.java`;暂扣与解除暂扣在订单更新事务内按 `sourceInboundId` 写入海外到国内 Outbox,国内按来源入仓单绑定更新 | | 入仓后 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,排车和出库使用独立 PDA Controller | -| 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/`、`relabel/pda/`;一条 `ll_relabel_item` 对应一个客户填写的我方箱唛任务,`CARTON_LABEL`、`SKU_LABEL`、`CARTON_AND_SKU` 分别保存箱唛、SKU 或组合需求,SKU 子项 JSON 数量按件;普通尾程入库没有箱唛到实物托盘的精确快照,历史入仓库存仓位只能作为冻结记账锚点和现场大概位置,禁止把该仓位解释为箱唛已绑定具体托盘。库存只按箱任务冻结一次,PDA 扫我方箱唛定位任务,现场找到实物后返回 FBA 箱唛/SKU 子项,照片完成复核通过才释放到原入仓库存,后续排车出库链路不变 | +| 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/`、`relabel/pda/`;一条 `ll_relabel_item` 对应一个客户填写的我方箱唛任务,`CARTON_LABEL`、`SKU_LABEL`、`CARTON_AND_SKU` 分别保存箱唛、SKU 或组合需求,SKU 子项 JSON 数量按件;普通尾程入库没有箱唛到实物托盘的精确快照,历史入仓库存仓位只能作为冻结记账锚点和现场大概位置,禁止把该仓位解释为箱唛已绑定具体托盘。库存只按箱任务冻结一次。涉及箱唛换标时申请必须选择 `TRUCK/EXPRESS/TRUCK_PICKUP/EXPRESS_PICKUP`,两种快递方式可要求打印并上传面单;PDA 扫我方箱唛完成逐箱事实和照片,仓库复核通过进入 `CUSTOMER_CONFIRM_PENDING`,客户查看照片确认后进入 `READY_FOR_OUTBOUND`:仅卡车派送为 `WAIT_PRE_DISPATCH`,快递派送和两种自提直接创建换标出库单。客户确认前不得进入出库 | | PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java`、`pda/warehouse/service/PdaWarehouseService.java`;解析库存、库位和托盘码并查询操作历史,固定要求 `lastleg:pda:operate` 与 `ROLE_WAREHOUSE_PICKER`,仓库范围只能取自 PDA Token | | 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java`、`shipping/entity/ContainerShippingGate.java`、`shipping/mapper/ContainerShippingGateMapper.java`;只单调接受实际标准节点,`PICKUP_READY` 及以后才允许对应柜进入 PDA 收货候选 | | 平台排车列表/详情与智能预排 | `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`,查询、预排与执行边界见“海外仓全流程开发路由” | @@ -60,6 +60,7 @@ - 海外仓全流程连续覆盖尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、转运、废弃、采购、本土卡车、本土散板、排车查询/智能预排、排车编辑/取消/发车、PDA 拣货与出库、一件代发、转派送、客户派送单、异常退回和客户回访。 - 服务端正式入口包括 `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`delivery/controller/OutboundPdaController.java`、`delivery/controller/MerchantDeliveryController.java`、`exceptionorder/` 与 `customervisit/`;设备页面是否可用以海外 PDA 正式路由和能力开关为准,未由 PDA 承载的操作由 Admin/Client 完成。 +- 平台一件代发出库候选由 `/outbound-orders/one-click-dropship/candidates` 统一返回已完成入库且仍有可用箱唛、SKU 汇总和库存仓位绑定的托盘明细;查询不强制先传 `customerId`,未传时跨客户展示供页面分页筛选,创建时必须从所选候选解析为唯一客户和唯一海外仓,禁止一张出库单跨客户或跨仓。 - 排车转派送允许在 `PENDING_DEPARTURE`、`PICKING`、`READY_TO_DEPART` 阶段幂等生成 `WAIT_OUTBOUND` 出库单与派送单,但必须早于发车;PDA 排车详情由 `DispatchPdaWorkflowService` 返回 `canDepart`、`departBlockReason`,只有车辆信息完整、拣货任务完成且箱数核平、排车为 `READY_TO_DEPART`、关联出库单仍为 `WAIT_OUTBOUND` 时才允许发车,PDA 不得自行推导该门禁。 - 排车执行与 `delivery/` Controller 和其他尾程业务接口一同注册,不受功能开关控制。平台排车列表与详情仍由 `lastleg.dispatch-query-api.enabled=true` 独立启用;入库单智能排车候选、草稿锁定/解锁/释放和预排创建仍由 `lastleg.smart-dispatch-api.enabled=true` 独立启用。这些排车平台入口均不接收 `customerId`:排车列表跨客户分页,详情按全局排车单号定位后使用记录自身客户范围加载明细;智能排车候选未传计划 ID 时跨客户查询,传入时根据计划 ID 解析范围;托盘移入草稿时立即以操作人和随机草稿标识锁定,移除或关闭时释放,草稿锁超时失效;预排创建根据所选托盘 ID 在服务端解析并校验唯一客户范围,并将当前操作人的草稿锁原子转换为正式排车锁。 - 仓库 ID、类型、状态、地址和能力唯一权威归国内 `warehouse-service`;海外库位、实物托盘和资源锁归 `warehouse-overseas-service/` 且只引用同一国内 `warehouseId`。Lastleg PDA 通过 Warehouse Overseas 内部资源入口校验该国内共享收货仓,不得要求海外数据库重复建仓;浏览器 API 统一经 `gateway-overseas/`。 @@ -104,6 +105,7 @@ - 尾程平台只读查询接口的 `customerId` 为可选筛选条件:平台身份省略时查询全部客户数据,传入时按指定客户过滤;商户身份忽略客户端传入值并始终按 Token 中的自身客户隔离。创建、编辑、审核等需要明确目标客户的写接口仍必须传入 `customerId`。 - 常规入仓库存余额以客户、海外仓、目的仓和入仓单为唯一业务维度并汇总箱数;SKU 继续保存在箱唛、实收和流水快照中,余额 SKU 仅作单 SKU 展示或以 `MIXED` 标识多 SKU,不参与当前唯一维度。 - 托盘是 PDA 出库的作业单位,但不是库存归属单位:`ll_inventory_position` 通过余额和托盘保存“托盘包含哪些入仓单、各多少箱”的组成明细。合托后的同一实物托盘允许对应多个入仓单,换标冻结和后续出库核算必须按这些明细分别记账;整托出库可扫描托盘一次完成,部分出库先拆托或按明细核平,不得用 FIFO 猜测来源入仓单。普通尾程入库只确认某入仓单在托盘上的箱数,没有逐箱唛到托盘的快照;贴标下单由客户填写我方箱唛,库存仓位只作为冻结记账锚点和现场大概位置,不能据此声称箱唛位于某个具体托盘。贴标任务以我方箱唛为主键,同箱多 SKU 只存为子明细且不重复冻结。 +- 换标箱唛经现场确认后执行 `pallet-operations/relabel-disassemble`:允许仅移动该换标单对应的 `hold_packages`,不得解冻为普通可用库存;事务内同步更新 `ll_relabel_item.source_inventory_position_id`。若客户确认后已生成换标出库单,还必须在拣货开始前同步拆分或改写 `ll_outbound_pallet_item` 的托盘、箱唛、箱数与来源仓位快照,使 PDA 后续按新旧托盘分别扫描且每个指定箱唛只出库一次。 - `FIRST_LEG` 在途、计划转换与 PDA 入账统一使用尾程订单冻结的实际接柜海外仓 ID/编码/名称,禁止使用国内来源仓或 FBA 目的仓冒充;接柜仓变更必须在同一事务内修订尚未形成实物仓位的在途余额并写零差额审计流水。余额分页、汇总和 CSV 默认排除所有数量桶均为零的维度,跨仓入账清空的无仓位来源余额必须原子删除。 - 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。 - 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java index 5568f4b1c..3934c7380 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java @@ -37,7 +37,7 @@ public class DeliveryTransferController { @PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CREATE + "')") @Operation(summary = "Create one-click dropship outbound and push a PDA picking task") public ApiResponse createOneClick( - @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, @Valid @RequestBody DeliveryRequests.OneClickOutbound request) { return ApiResponse.success(service.createOneClick(requiredCustomer(user, customerId), operator(user), request)); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java index d71ee02a0..428aa2f11 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java @@ -80,7 +80,12 @@ public final class DeliveryRequests { } } public record PdaPick(@NotNull Long outboundPalletItemId, Long inventoryPositionId, - @NotNull @Min(1) Long packages, @NotBlank @Size(max=255) String idempotencyKey) {} + @NotNull @Min(1) Long packages, @NotBlank @Size(max=255) String idempotencyKey, + @Size(max=500) List<@NotBlank @Size(max=255) String> cartonMarks) { + public PdaPick(Long outboundPalletItemId, Long inventoryPositionId, Long packages, String idempotencyKey) { + this(outboundPalletItemId, inventoryPositionId, packages, idempotencyKey, List.of()); + } + } public record PdaComplete(@NotBlank @Size(max=255) String idempotencyKey) {} public record Cancel(@NotBlank @Size(max=1000) String reason) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index b824679a4..953ab3caa 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -50,6 +50,12 @@ import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; +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.service.RelabelApplicationService; +import com.baiyue.lastleg.relabel.dto.RelabelRequests; import jakarta.annotation.Resource; import org.springframework.dao.DuplicateKeyException; import org.springframework.http.HttpStatus; @@ -88,6 +94,9 @@ public class DeliveryTransferService { @Resource private LastlegOrderMapper tailOrders; @Resource private LastlegOrderStatusHistoryMapper histories; @Resource private LastlegOrderDetailMapper tailOrderDetails; + @Resource private RelabelApplicationMapper relabelApplications; + @Resource private RelabelItemMapper relabelItems; + @Resource private RelabelApplicationService relabelService; @Resource private LastlegOrderNoGenerator orderNoGenerator; @Transactional @@ -358,7 +367,7 @@ public class DeliveryTransferService { if (replay != null) { if (!task.getId().equals(replay.getTaskId()) || !request.outboundPalletItemId().equals(replay.getOutboundPalletItemId()) - || !request.inventoryPositionId().equals(replay.getInventoryPositionId()) + || !Objects.equals(request.inventoryPositionId(), replay.getInventoryPositionId()) || !request.packages().equals(replay.getPackages())) { throw conflict("Idempotency key was reused with a different outbound pick request"); } @@ -393,6 +402,62 @@ public class DeliveryTransferService { task.setStatus("IN_PROGRESS"); task.setUpdatedAt(LocalDateTime.now()); pdaTasks.updateById(task); return taskResult(outbound, task); } + if ("RELABEL_OUTBOUND".equals(outbound.getOutboundType())) { + if (request.inventoryPositionId() == null) throw conflict("换标出库必须确认实际库存位置"); + InventoryPosition selectedPosition = inventoryPositions.selectById(request.inventoryPositionId()); + if (selectedPosition == null || !customerId.equals(selectedPosition.getCustomerId()) + || !warehouseId.equals(selectedPosition.getOverseasWarehouseId()) + || !Objects.equals(selectedPosition.getPalletId(), item.getWarehousePalletId())) { + throw conflict("确认的库存位置不属于该换标出库托盘"); + } + List expectedMarks = Arrays.stream(Optional.ofNullable(item.getCartonMarks()).orElse("").split(",")) + .map(String::trim).filter(StringUtils::hasText).distinct().sorted().toList(); + List submittedMarks = request.cartonMarks() == null ? List.of() : request.cartonMarks(); + List scannedMarks = submittedMarks.stream().map(String::trim).filter(StringUtils::hasText) + .distinct().sorted().toList(); + if (scannedMarks.size() != submittedMarks.size() || !expectedMarks.equals(scannedMarks)) { + throw conflict("必须扫描并提交该换标出库任务的全部指定箱唛"); + } + if (request.packages().intValue() != value(item.getPackageCount()) || value(item.getPickedPackageCount()) > 0) { + throw conflict("换标出库托盘必须按指定箱唛一次完成拆托确认"); + } + RelabelApplication application = relabelApplications.lockByOutbound(customerId, outbound.getOutboundNo()); + if (application == null || !RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) { + throw conflict("换标单尚未完成客户确认或已不可出库"); + } + List sourceItems = relabelItems.selectList(new LambdaQueryWrapper() + .eq(RelabelItem::getApplicationId, application.getId()) + .eq(RelabelItem::getCustomerId, customerId) + .eq(RelabelItem::getSourceInventoryPositionId, request.inventoryPositionId())); + long sourcePackages = sourceItems.stream().mapToLong(source -> value(source.getNotifiedPackageCount())).sum(); + if (sourcePackages != request.packages()) throw conflict("换标出库来源库存快照与任务箱数不一致"); + int releaseIndex = 0; + for (RelabelItem source : sourceItems) { + inventoryCommands.release(customerId, operatorId, new InventoryCommandRequests.Move( + request.inventoryPositionId(), value(source.getNotifiedPackageCount()), application.getRelabelNo(), null, + "RELABEL:" + application.getRelabelNo(), request.idempotencyKey() + ":RELEASE:" + releaseIndex++, + "换标出库拆托前释放原冻结库存")); + } + inventoryCommands.allocate(customerId, operatorId, new InventoryCommandRequests.Move(request.inventoryPositionId(), + request.packages(), application.getRelabelNo(), outbound.getOutboundNo(), null, + request.idempotencyKey() + ":ALLOCATE", "换标出库指定箱唛拆托")); + inventoryCommands.pick(customerId, operatorId, new InventoryCommandRequests.Move(request.inventoryPositionId(), + request.packages(), application.getRelabelNo(), outbound.getOutboundNo(), null, + request.idempotencyKey() + ":PICK", "换标出库指定箱唛拣货")); + inventoryCommands.ship(customerId, operatorId, new InventoryCommandRequests.Move(request.inventoryPositionId(), + request.packages(), application.getRelabelNo(), outbound.getOutboundNo(), null, + request.idempotencyKey() + ":SHIP", "换标出库指定箱唛出库")); + OutboundPdaPick pick = new OutboundPdaPick(); pick.setId(IdWorker.getId()); pick.setCustomerId(customerId); + pick.setTaskId(task.getId()); pick.setOutboundPalletItemId(item.getId()); pick.setInventoryPositionId(request.inventoryPositionId()); + pick.setPackages(request.packages()); pick.setIdempotencyKey(request.idempotencyKey().trim()); pick.setPickedAt(LocalDateTime.now()); pdaPicks.insert(pick); + item.setPickedPackageCount(request.packages().intValue()); item.setStatus("PICKED"); outboundPallets.updateById(item); + List pickedItems = outboundPallets.findByOutbound(customerId, outbound.getId()); + if (!pickedItems.isEmpty() && pickedItems.stream().allMatch(value -> "PICKED".equals(value.getStatus()))) { + outbound.setStatus("PICKED"); outbound.setUpdatedAt(LocalDateTime.now()); outboundOrders.updateById(outbound); + } + task.setStatus("IN_PROGRESS"); task.setUpdatedAt(LocalDateTime.now()); pdaTasks.updateById(task); + return taskResult(outbound, task); + } InventoryPosition selectedPosition = inventoryPositions.selectById(request.inventoryPositionId()); if (selectedPosition == null || !customerId.equals(selectedPosition.getCustomerId()) || !Objects.equals(selectedPosition.getPalletId(), item.getWarehousePalletId())) throw conflict("Scanned inventory position does not belong to the outbound pallet"); if (!warehouseId.equals(selectedPosition.getOverseasWarehouseId())) { @@ -452,6 +517,13 @@ public class DeliveryTransferService { task.setStatus("COMPLETED"); task.setCompletionIdempotencyKey(completionKey); task.setCompletedAt(now); task.setUpdatedAt(now); pdaTasks.updateById(task); outbound.setStatus("OUTBOUNDED"); outbound.setUpdatedAt(now); outboundOrders.updateById(outbound); + if ("RELABEL_OUTBOUND".equals(outbound.getOutboundType())) { + RelabelApplication application = relabelApplications.lockByOutbound(customerId, outbound.getOutboundNo()); + if (application != null && RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) { + relabelService.changeStatus(customerId, application.getRelabelNo(), "PDA_OUTBOUND", + new RelabelRequests.ChangeStatus("COMPLETED", "换标出库PDA作业完成")); + } + } return taskResult(outbound, task); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java index 10e58f95a..3167ab72e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java @@ -23,10 +23,15 @@ public final class DispatchRequests { @Size(max = 255) String route, @Size(max = 64) String fbaAppointmentNo, @Size(max = 32) String fbaAppointmentStatus, @Size(max = 32) String vehicleAppointmentStatus, - @Size(max = 64) String draftToken) { + @Size(max = 64) String draftToken, + @Size(max = 64) String relabelNo) { public PreArrange(List planPalletIds, String route, String fbaAppointmentNo, String fbaAppointmentStatus, String vehicleAppointmentStatus) { - this(planPalletIds, route, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, null); + this(planPalletIds, route, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, null, null); + } + public PreArrange(List planPalletIds, String route, String fbaAppointmentNo, + String fbaAppointmentStatus, String vehicleAppointmentStatus, String draftToken) { + this(planPalletIds, route, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, draftToken, null); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java index 2fa8e73d0..974f39ea4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java @@ -34,5 +34,8 @@ public class DispatchItem { private BigDecimal volumeCbm; /** PLAN for planned pallets; RECEIVED for warehouse/entity pallets. */ private String sourceStage; + private String cartonMarks; + private String sourceBusinessType; + private String sourceBusinessNo; private LocalDateTime createdAt; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java index 227e8ad57..8786bcbef 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java @@ -143,4 +143,32 @@ public interface DispatchCandidateMapper { "") List lockSelected(@Param("customerId") String customerId, @Param("planPalletIds") List planPalletIds); + + @Select("") + List relabelAvailable(@Param("customerId") String customerId, + @Param("orderNo") String orderNo, + @Param("relabelNo") String relabelNo); + + @org.apache.ibatis.annotations.Update("UPDATE ll_relabel_application SET fulfillment_status='PRE_DISPATCH_CREATED',updated_at=CURRENT_TIMESTAMP(3) " + + "WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} AND fulfillment_status='WAIT_PRE_DISPATCH'") + int markRelabelPreDispatched(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java index 05a5e000a..9a3e3eca2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -84,8 +84,15 @@ public class DispatchService { public List platformCandidates(String orderNo, String fbaWarehouseCode, String sourceStage, String draftToken) { validateSourceStage(sourceStage); - return candidates.available(null, null, trimToNull(orderNo), trimToNull(fbaWarehouseCode), + List result = candidates.available(null, null, trimToNull(orderNo), trimToNull(fbaWarehouseCode), trimToNull(sourceStage), trimToNull(draftToken)); + if (!StringUtils.hasText(sourceStage) || "RECEIVED".equals(sourceStage)) { + List relabel = candidates.relabelAvailable(null, trimToNull(orderNo), null); + if (relabel != null && !relabel.isEmpty()) { + result = java.util.stream.Stream.concat(result.stream(), relabel.stream()).toList(); + } + } + return result; } public List platformCandidates(String orderNo, String fbaWarehouseCode, String sourceStage) { return platformCandidates(orderNo, fbaWarehouseCode, sourceStage, null); @@ -212,6 +219,9 @@ public class DispatchService { @Transactional public DispatchResponse preArrange(String customerId, String operatorId, DispatchRequests.PreArrange request) { + if (StringUtils.hasText(request.relabelNo())) { + return preArrangeRelabel(customerId, operatorId, request); + } if (!StringUtils.hasText(customerId)) { throw new ResponseStatusException(HttpStatus.CONFLICT, "No customer business data is available for dispatch"); @@ -355,6 +365,24 @@ public class DispatchService { return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId())); } + @Transactional + private DispatchResponse preArrangeRelabel(String customerId, String operatorId, DispatchRequests.PreArrange request) { + List selected = candidates.relabelAvailable(customerId, null, request.relabelNo()); + Set requested = request.planPalletIds().stream().map(Long::valueOf).collect(Collectors.toSet()); + selected = selected.stream().filter(c -> requested.contains(c.warehousePalletId())).toList(); + if (selected.isEmpty() || selected.size() != requested.size()) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单托盘候选已变化,请刷新后重试"); + String warehouse = selected.get(0).overseasWarehouseId(); + if (selected.stream().anyMatch(c -> !Objects.equals(warehouse, c.overseasWarehouseId()))) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单只能选择同一海外仓"); + LocalDateTime now = LocalDateTime.now(); + DispatchOrder dispatch = new DispatchOrder(); dispatch.setId(IdWorker.getId()); dispatch.setCustomerId(customerId); + dispatch.setDispatchNo(orderNoGenerator.next("DISPATCH", warehouse, dispatch.getId())); dispatch.setOverseasWarehouseId(warehouse); dispatch.setOverseasWarehouseName(selected.get(0).overseasWarehouseName()); + dispatch.setRoute(StringUtils.hasText(request.route()) ? request.route().trim() : route(selected)); dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); dispatch.setStatus(PENDING_DEPARTURE); dispatch.setItemCount(selected.size()); dispatch.setPalletCount(selected.size()); dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); dispatch.setPickedPackageCount(0); dispatch.setShippedPackageCount(0); dispatch.setCreatedBy(trimToNull(operatorId)); dispatch.setCreatedAt(now); dispatch.setUpdatedAt(now); orders.insert(dispatch); + for (DispatchPalletCandidate candidate : selected) { DispatchItem item = new DispatchItem(); item.setId(IdWorker.getId()); item.setDispatchOrderId(dispatch.getId()); item.setCustomerId(customerId); item.setWarehousePalletId(candidate.warehousePalletId()); item.setOrderId(candidate.orderId()); item.setOverseasWarehouseId(candidate.overseasWarehouseId()); item.setOverseasWarehouseName(candidate.overseasWarehouseName()); item.setOrderNo(candidate.orderNo()); item.setOrderRefs(candidate.orderRefs()); item.setInboundCode(candidate.inboundCode()); item.setPalletNo(candidate.palletNo()); item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); item.setDestinationWarehouseName(candidate.destinationWarehouseName()); item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); item.setVolumeCbm(BigDecimal.ZERO); item.setSourceStage("RELABEL"); item.setCartonMarks(candidate.cartonMarks()); item.setSourceBusinessType("RELABEL_ORDER"); item.setSourceBusinessNo(request.relabelNo()); item.setCreatedAt(now); items.insert(item); } + DispatchPdaTask task = new DispatchPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setDispatchOrderId(dispatch.getId()); task.setTaskNo("DPDA" + task.getId()); task.setStatus("PDA_PENDING"); task.setWarehouseId(warehouse); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task); + if (candidates.markRelabelPreDispatched(customerId, request.relabelNo()) != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单已被其他排车占用"); + return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId())); + } + private void assertNoDetainedOrders(String customerId, List selected) { List orderIds = selected.stream().map(DispatchPalletCandidate::orderId) .filter(Objects::nonNull).distinct().sorted().toList(); 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 index 22d51660f..d62e93153 100644 --- 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 @@ -42,6 +42,13 @@ public class PdaPalletOperationController { return ApiResponse.success(service.disassemble(PdaWarehouseIdentity.require(user), request)); } + @PostMapping("/relabel-disassemble") + public ApiResponse relabelDisassemble( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PalletOperationRequests.RelabelDisassemble request) { + return ApiResponse.success(service.relabelDisassemble(PdaWarehouseIdentity.require(user), request)); + } + @PostMapping("/swap") public ApiResponse swap( @AuthenticationPrincipal AuthenticatedUser user, 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 index 1b804c6ca..783e3d9b3 100644 --- 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 @@ -31,6 +31,14 @@ public final class PalletOperationRequests { @NotEmpty @Size(max = 20) List<@Valid TargetMove> targets, @Size(max = 500) String reason) { } + /** 换标场景按箱唛拆托并绑定到一个目标托盘。箱唛位置由服务端从换标单快照解析。 */ + public record RelabelDisassemble(@NotBlank @Size(max = 128) String idempotencyKey, + @NotBlank @Size(max = 64) String relabelNo, + @Valid PalletExpectation source, + @Valid PalletExpectation target, + @NotEmpty @Size(max = 500) List<@NotBlank @Size(max = 255) String> cartonMarks, + @Size(max = 500) String reason) { } + public record Swap(@NotBlank @Size(max = 128) String idempotencyKey, @Valid PalletExpectation source, @Valid PalletExpectation target, 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 index 74315f3b3..b55f004cb 100644 --- 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 @@ -13,6 +13,11 @@ 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.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.service.RelabelPalletMutationService; import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService; import com.fasterxml.jackson.core.JsonProcessingException; @@ -52,6 +57,9 @@ public class PdaPalletOperationService { @Resource private PalletPhysicalGateway physical; @Resource private PdaWarehouseService history; @Resource private ObjectMapper objectMapper; + @Resource private RelabelApplicationMapper relabelApplications; + @Resource private RelabelItemMapper relabelItems; + @Resource private RelabelPalletMutationService relabelPalletMutations; private TransactionTemplate transaction; private TransactionTemplate newTransaction; @@ -113,6 +121,64 @@ public class PdaPalletOperationService { false)); } + public PalletOperationResponses.Operation relabelDisassemble(PdaWarehouseIdentity identity, + PalletOperationRequests.RelabelDisassemble request) { + PalletOperationResponses.Operation replay = replayIfPresent(identity, "RELABEL_PALLET_DISASSEMBLE", + request.idempotencyKey(), request); + if (replay != null) return replay; + String relabelNo = required(request.relabelNo(), "换标单号不能为空"); + RelabelApplication application = relabelApplications.findAnyByRelabelNo(relabelNo); + if (application == null || !"READY_FOR_OUTBOUND".equals(application.getStatus())) { + throw conflict("换标单尚未完成客户确认,不能执行拆托"); + } + if (!Objects.equals(application.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) { + throw conflict("换标单不属于 Token 冻结的海外仓"); + } + 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 (source.palletId() == target.palletId()) throw conflict("原托盘和目标托盘不能相同"); + List marks = request.cartonMarks().stream().map(String::trim).filter(value -> !value.isEmpty()).toList(); + if (marks.size() != request.cartonMarks().size() || new HashSet<>(marks).size() != marks.size()) { + throw badRequest("箱唛不能为空且不能重复"); + } + List selected = relabelItems.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper() + .eq("application_id", application.getId()).eq("customer_id", application.getCustomerId()) + .in("carton_mark", marks)); + if (selected.size() != marks.size() + || selected.stream().map(RelabelItem::getCartonMark).distinct().count() != marks.size()) { + throw conflict("提交的箱唛不属于当前换标单"); + } + Map packagesByPosition = new LinkedHashMap<>(); + Map> itemsByPosition = new LinkedHashMap<>(); + for (RelabelItem item : selected) { + if (item.getSourceInventoryPositionId() == null || item.getNotifiedPackageCount() == null + || item.getNotifiedPackageCount() <= 0) throw conflict("换标箱唛缺少库存位置或箱数快照"); + InventoryPosition position = positions.selectById(item.getSourceInventoryPositionId()); + if (position == null || !Objects.equals(position.getCustomerId(), application.getCustomerId()) + || !Objects.equals(position.getPalletId(), source.palletId()) + || !Objects.equals(position.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) { + throw conflict("扫描的原托盘与换标箱唛实际库存位置不一致"); + } + packagesByPosition.merge(position.getId(), item.getNotifiedPackageCount().longValue(), Long::sum); + itemsByPosition.computeIfAbsent(position.getId(), ignored -> new ArrayList<>()).add(item); + } + List moves = packagesByPosition.entrySet().stream().map(entry -> { + InventoryPosition position = positions.selectById(entry.getKey()); + if (value(position.getHoldPackages()) < entry.getValue()) throw conflict("换标冻结库存箱数不足"); + return new PlannedMove(entry.getKey(), entry.getValue(), value(position.getVersion()), source.palletId(), + source.palletCode(), target.palletId(), target.palletCode(), target.locationId(), + locationCode(identity.overseasWarehouseId(), target.locationId()), InventoryBucket.HOLD); + }).toList(); + requireCommonDestination(identity.overseasWarehouseId(), moves, List.of(target)); + Plan base = plan(identity.overseasWarehouseId(), pallets, moves, Set.of(target.palletId()), false); + Plan relabelPlan = new Plan(base.pallets(), base.moves(), base.changes(), + new RelabelMutation(application, itemsByPosition)); + return execute(identity, "RELABEL_PALLET_DISASSEMBLE", request.idempotencyKey(), request, + request.reason(), relabelPlan); + } + public PalletOperationResponses.Operation swap(PdaWarehouseIdentity identity, PalletOperationRequests.Swap request) { PalletOperationResponses.Operation replay = replayIfPresent(identity, "PALLET_SWAP", @@ -130,7 +196,7 @@ public class PdaPalletOperationService { 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())); + locationCode(identity.overseasWarehouseId(), target.locationId()), InventoryBucket.AVAILABLE); }).toList(); return execute(identity, "PALLET_SWAP", request.idempotencyKey(), request, request.reason(), plan(identity.overseasWarehouseId(), pallets, moves, Set.of(target.palletId()), false)); @@ -149,7 +215,7 @@ public class PdaPalletOperationService { 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())); + locationCode(identity.overseasWarehouseId(), source.locationId()), InventoryBucket.AVAILABLE); }).toList(); return execute(identity, "PALLET_UNBIND", request.idempotencyKey(), request, request.reason(), plan(identity.overseasWarehouseId(), pallets, moves, Set.of(), true)); @@ -183,8 +249,10 @@ public class PdaPalletOperationService { private PalletOperationResponses.Operation mutate(PdaWarehouseIdentity identity, PdaPalletOperation operation, Plan plan, String key, String requestHash, String reason) { + if (plan.relabelMutation() != null) lockRelabelMutation(plan.relabelMutation(), identity.overseasWarehouseId()); Set seen = new HashSet<>(); LinkedHashSet customers = new LinkedHashSet<>(); + Map relabelTargets = new LinkedHashMap<>(); for (PlannedMove move : plan.moves()) { if (!seen.add(move.positionId())) throw conflict("同一库存行不能在一次托盘作业中重复移动"); PalletInventoryRow locked = mapper.lockPosition(String.valueOf(identity.overseasWarehouseId()), @@ -193,10 +261,14 @@ public class PdaPalletOperationService { throw conflict("库存所在托盘已变化,请重新扫码"); } if (value(locked.getVersion()) != move.expectedVersion()) throw conflict("库存版本已变化,请重新扫码"); - requireMovable(locked, move.packages()); + if (move.bucket() == InventoryBucket.HOLD) requireHeld(locked, move.packages()); + else requireMovable(locked, move.packages()); customers.add(locked.getCustomerId()); - moveInventory(identity, operation, locked, move, key, reason); + InventoryPosition target = moveInventory(identity, operation, locked, move, key, reason); + if (plan.relabelMutation() != null) relabelTargets.put(move.positionId(), target); } + if (plan.relabelMutation() != null) relabelPalletMutations.moveSelectedItems( + plan.relabelMutation().application(), plan.relabelMutation().itemsByPosition(), relabelTargets); for (PalletChange change : plan.changes()) { long actual = mapper.palletPackages(String.valueOf(identity.overseasWarehouseId()), change.pallet().palletId()); boolean shouldBeEmpty = "IDLE".equals(change.targetStatus()); @@ -219,12 +291,22 @@ public class PdaPalletOperationService { return result; } - private void moveInventory(PdaWarehouseIdentity identity, PdaPalletOperation operation, - PalletInventoryRow sourceRow, PlannedMove move, String key, String reason) { + private void lockRelabelMutation(RelabelMutation mutation, long warehouseId) { + RelabelApplication locked = relabelApplications.lockAnyByRelabelNo(mutation.application().getRelabelNo()); + if (locked == null || !"READY_FOR_OUTBOUND".equals(locked.getStatus()) + || !Objects.equals(locked.getCustomerId(), mutation.application().getCustomerId()) + || !Objects.equals(locked.getOverseasWarehouseId(), String.valueOf(warehouseId)) + || !Objects.equals(locked.getOutboundOrderNo(), mutation.application().getOutboundOrderNo())) { + throw conflict("换标单状态或履约单据已变化,请重新扫码"); + } + } + + private InventoryPosition 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()); + long sourceBefore = bucketValue(source, move.bucket()); + setBucketValue(source, move.bucket(), sourceBefore - move.packages()); source.setStatus(positionPackages(source) == 0 ? "EMPTY" : "OCCUPIED"); source.setVersion(value(source.getVersion()) + 1); source.setUpdatedAt(LocalDateTime.now()); positions.updateById(source); @@ -248,8 +330,8 @@ public class PdaPalletOperationService { if (target == null) throw duplicate; } } - long targetBefore = value(target.getAvailablePackages()); - target.setAvailablePackages(targetBefore + move.packages()); target.setStatus("OCCUPIED"); + long targetBefore = bucketValue(target, move.bucket()); + setBucketValue(target, move.bucket(), targetBefore + move.packages()); target.setStatus("OCCUPIED"); target.setWarehouseLocationId(move.targetLocationId()); target.setWarehouseLocationCode(move.targetLocationCode()); target.setVersion(value(target.getVersion()) + 1); target.setUpdatedAt(LocalDateTime.now()); @@ -261,6 +343,7 @@ public class PdaPalletOperationService { 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); + return target; } private void ledger(PdaWarehouseIdentity identity, PdaPalletOperation operation, InventoryBalance balance, @@ -302,7 +385,7 @@ public class PdaPalletOperationService { } changes.sort(Comparator.comparingInt((PalletChange change) -> "IDLE".equals(change.targetStatus()) ? 0 : 1) .thenComparingLong(change -> change.pallet().palletId())); - return new Plan(pallets, moves, changes); + return new Plan(pallets, moves, changes, null); } private List plannedLines(long warehouseId, List lines, @@ -325,7 +408,7 @@ public class PdaPalletOperationService { 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()))); + locationCode(warehouseId, target.locationId()), InventoryBucket.AVAILABLE)); } requireUniqueLines(result); return result; @@ -494,6 +577,21 @@ public class PdaPalletOperationService { throw conflict("冻结、已分配、已拣或收货中的库存不能执行托盘作业"); } } + private static void requireHeld(PalletInventoryRow row, long packages) { + if (packages <= 0 || value(row.getHoldPackages()) < packages) throw conflict("换标冻结库存箱数不足"); + if (value(row.getAllocatedPackages()) > 0 || value(row.getPickedPackages()) > 0 + || value(row.getReceivingPackages()) > 0) { + throw conflict("已分配、已拣或收货中的库存不能执行换标拆托"); + } + } + private static long bucketValue(InventoryPosition position, InventoryBucket bucket) { + return bucket == InventoryBucket.HOLD ? value(position.getHoldPackages()) + : value(position.getAvailablePackages()); + } + private static void setBucketValue(InventoryPosition position, InventoryBucket bucket, long packages) { + if (bucket == InventoryBucket.HOLD) position.setHoldPackages(packages); + else position.setAvailablePackages(packages); + } private static void requireUniqueLines(List moves) { Set ids = new HashSet<>(); if (moves.stream().anyMatch(move -> !ids.add(move.positionId()))) throw badRequest("库存行不能重复提交"); @@ -517,6 +615,7 @@ public class PdaPalletOperationService { 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 int value(Integer value) { return value == null ? 0 : 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 static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } @@ -524,9 +623,13 @@ public class PdaPalletOperationService { private record PlannedMove(long positionId, long packages, long expectedVersion, long sourcePalletId, String sourcePalletCode, long targetPalletId, String targetPalletCode, long targetLocationId, - String targetLocationCode) { } + String targetLocationCode, InventoryBucket bucket) { } private record PalletChange(PalletPhysicalGateway.PalletReference pallet, String targetStatus, long targetLocationId) { } private record Plan(Map pallets, - List moves, List changes) { } + List moves, List changes, + RelabelMutation relabelMutation) { } + private record RelabelMutation(RelabelApplication application, + Map> itemsByPosition) { } + private enum InventoryBucket { AVAILABLE, HOLD } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java index 67a2f73b8..9f37c9b45 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java @@ -12,6 +12,11 @@ import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.ContentDisposition; +import java.nio.charset.StandardCharsets; import java.util.List; @RestController @@ -34,10 +39,38 @@ public class MerchantRelabelApplicationController { @GetMapping("/{relabelNo}") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String relabelNo) { return ApiResponse.success(service.detail(customer(user), relabelNo)); } + @PostMapping(value = "/{relabelNo}/shipping-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse uploadShippingLabel(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @RequestPart("file") MultipartFile file) { + return ApiResponse.success(service.uploadShippingLabel(customer(user), relabelNo, operatorLong(user), file)); + } + @GetMapping("/{relabelNo}/shipping-label/{fileId}/download") + public ResponseEntity downloadShippingLabel(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @PathVariable long fileId) { + var file = service.downloadShippingLabel(customer(user), relabelNo, fileId); + return download(file.content(), file.fileName(), file.contentType()); + } + @GetMapping("/{relabelNo}/completion-photos/{fileId}/download") + public ResponseEntity downloadCompletionPhoto(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @PathVariable long fileId) { + var file = service.downloadCompletionPhoto(customer(user), relabelNo, fileId); + return download(file.content(), file.fileName(), file.contentType()); + } + @PostMapping("/{relabelNo}/confirm-completion") + public ApiResponse confirmCompletion(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @Valid @RequestBody RelabelRequests.CustomerConfirm request) { + return ApiResponse.success(service.confirmCompletion(customer(user), relabelNo, operator(user), request)); + } + private ResponseEntity download(byte[] content, String name, String type) { + return ResponseEntity.ok().cacheControl(org.springframework.http.CacheControl.noStore()) + .header("Content-Disposition", ContentDisposition.inline().filename(name, StandardCharsets.UTF_8).build().toString()) + .contentType(MediaType.parseMediaType(type)).contentLength(content.length).body(content); + } private String customer(AuthenticatedUser user) { if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required"); return String.valueOf(user.tenantId()); } private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); } + private long operatorLong(AuthenticatedUser user) { return user.userId() == null ? 0L : user.userId(); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java index a5cd6f181..41fab1ed3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java @@ -85,6 +85,16 @@ public class RelabelApplicationController { return ResponseEntity.ok().cacheControl(org.springframework.http.CacheControl.noStore()).header("Content-Disposition", ContentDisposition.inline().filename(file.fileName(), StandardCharsets.UTF_8).build().toString()) .contentType(org.springframework.http.MediaType.parseMediaType(file.contentType())).contentLength(file.content().length).body(file.content()); } + @GetMapping("/{relabelNo}/completion-photos/{fileId}/download") + @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')") + public ResponseEntity downloadCompletionPhoto(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @PathVariable long fileId) { + requirePlatform(user); + var file = service.downloadCompletionPhoto(service.customerId(relabelNo), relabelNo, fileId); + return ResponseEntity.ok().cacheControl(org.springframework.http.CacheControl.noStore()) + .header("Content-Disposition", ContentDisposition.inline().filename(file.fileName(), StandardCharsets.UTF_8).build().toString()) + .contentType(MediaType.parseMediaType(file.contentType())).contentLength(file.content().length).body(file.content()); + } private void requirePlatform(AuthenticatedUser user) { if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java index a9078ef34..370d6bc36 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java @@ -26,8 +26,17 @@ public final class RelabelRequests { @NotNull @DecimalMin(value = "0.01") BigDecimal chargeAmount, @Size(max = 1000) String remark, @NotBlank @Size(max = 255) String idempotencyKey, - @NotEmpty @Size(max = 500) List<@Valid Item> items - ) {} + @NotEmpty @Size(max = 500) List<@Valid Item> items, + @Size(max = 32) String deliveryMethod, + Boolean printShippingLabel + ) { + public Create(String customerOrderNo, String operationWarehouseId, LocalDateTime labelDeadline, + String contactName, String contactPhone, BigDecimal chargeAmount, String remark, + String idempotencyKey, List items) { + this(customerOrderNo, operationWarehouseId, labelDeadline, contactName, contactPhone, + chargeAmount, remark, idempotencyKey, items, "TRUCK", false); + } + } public record Item( @NotNull Long sourceInventoryBalanceId, @@ -105,4 +114,7 @@ public final class RelabelRequests { public record ChangeStatus(@NotBlank @Size(max = 32) String status, @Size(max = 1000) String remark) {} + + public record CustomerConfirm(@NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java index 1fa1b52e1..28b7f4b2d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java @@ -13,7 +13,9 @@ public final class RelabelResponses { int totalPackageCount, int notifiedPackageCount, String skuCodes, String totalFileId, String totalFileName, String totalFileUrl, BigDecimal chargeAmount, String chargeCurrency, String walletStatus, - String walletErrorMessage, + String walletErrorMessage, String deliveryMethod, boolean printShippingLabel, + String shippingLabelFileId, String shippingLabelFileName, String shippingLabelFileUrl, + String fulfillmentStatus, String outboundOrderNo, LocalDateTime customerConfirmedAt, LocalDateTime createdAt) {} public record Item(Long id, String skuCode, String productImageFileId, String productImageFileName, String productImageUrl, String productNameZh, int totalPackageCount, @@ -37,6 +39,8 @@ public final class RelabelResponses { String status) {} public record History(String fromStatus, String toStatus, String operatorId, String remark, LocalDateTime createdAt) {} + public record CompletionEvidence(Long taskId, List photoFileIds, String completionRemark, + String reviewerName, String reviewRemark, LocalDateTime completedAt) {} public record Detail(Summary application, String remark, List items, List history, - List tailOrders) {} + List tailOrders, CompletionEvidence completionEvidence) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelApplication.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelApplication.java index 1feb6ace8..0c06c2b90 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelApplication.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelApplication.java @@ -20,6 +20,15 @@ public class RelabelApplication { private LocalDateTime labelDeadline; private String contactName; private String contactPhone; + private String deliveryMethod; + private Boolean printShippingLabel; + private String shippingLabelFileId; + private String shippingLabelFileName; + private String shippingLabelFileUrl; + private String fulfillmentStatus; + private String outboundOrderNo; + private String customerConfirmationKey; + private LocalDateTime customerConfirmedAt; private String remark; private String totalFileId; private String totalFileName; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelApplicationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelApplicationMapper.java index d082ee801..8fb2b3a2b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelApplicationMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelApplicationMapper.java @@ -22,4 +22,7 @@ public interface RelabelApplicationMapper extends BaseMapper @Select("SELECT * FROM ll_relabel_application WHERE relabel_no=#{relabelNo} LIMIT 1 FOR UPDATE") RelabelApplication lockAnyByRelabelNo(@Param("relabelNo") String relabelNo); + + @Select("SELECT * FROM ll_relabel_application WHERE customer_id=#{customerId} AND outbound_order_no=#{outboundNo} LIMIT 1 FOR UPDATE") + RelabelApplication lockByOutbound(@Param("customerId") String customerId, @Param("outboundNo") String outboundNo); } 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 index d693674a3..1e53232f7 100644 --- 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 @@ -10,6 +10,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; import org.springframework.http.client.MultipartBodyBuilder; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; import org.springframework.web.multipart.MultipartFile; @@ -89,6 +90,24 @@ public class HttpRelabelPhotoFileGateway implements RelabelPhotoFileGateway { } } + @Override + public DownloadedFile download(String customerId, long taskId, long fileId) { + validate(customerId, taskId, fileId); + ResponseEntity response = client.get() + .uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}/content", + tenantId(customerId), BUSINESS_TYPE, taskId, fileId) + .header("X-Internal-Service-Token", internalToken).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { + throw new ResponseStatusException(result.getStatusCode().is4xxClientError() + ? HttpStatus.NOT_FOUND : HttpStatus.SERVICE_UNAVAILABLE, "Relabel photo download failed"); + }).toEntity(byte[].class); + String fileName = response.getHeaders().getContentDisposition().getFilename(); + String contentType = response.getHeaders().getContentType() == null + ? MediaType.APPLICATION_OCTET_STREAM_VALUE : response.getHeaders().getContentType().toString(); + return new DownloadedFile(response.getBody() == null ? new byte[0] : response.getBody(), + fileName == null ? "relabel-photo" : fileName, contentType); + } + private static long tenantId(String customerId) { try { return Long.parseLong(customerId); } catch (NumberFormatException exception) { 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 index c309f5dd8..1cccc2344 100644 --- 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 @@ -5,6 +5,8 @@ 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); + DownloadedFile download(String customerId, long taskId, long fileId); record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) { } + record DownloadedFile(byte[] content, String fileName, String contentType) { } } 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 index eb3559fc2..d11e16125 100644 --- 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 @@ -252,8 +252,8 @@ public class PdaRelabelService { updateTask(task, request.expectedVersion()); if ("APPROVE".equals(decision)) { applicationService.changeStatus(application.getCustomerId(), relabelNo, operatorKey, - new RelabelRequests.ChangeStatus(COMPLETED, request.remark())); - application.setStatus(COMPLETED); + new RelabelRequests.ChangeStatus(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, request.remark())); + application.setStatus(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING); } insertHistory(task, "REVIEW_" + decision, before, after, operatorKey, operatorName, Map.of("decision", decision, "remark", Objects.toString(trim(request.remark()), ""))); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java index 9e21cd6ef..854e32fb1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java @@ -24,6 +24,15 @@ import com.baiyue.lastleg.relabel.entity.RelabelStatusHistory; import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; import com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask; +import com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway; +import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper; +import com.baiyue.lastleg.delivery.entity.OutboundOrder; +import com.baiyue.lastleg.delivery.entity.OutboundPalletItem; +import com.baiyue.lastleg.delivery.entity.OutboundPdaTask; +import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundPdaTaskMapper; import com.baiyue.lastleg.service.LastlegOrderNoGenerator; import com.baiyue.lastleg.wallet.WalletCommandOutboxService; import com.baiyue.lastleg.wallet.WalletResultHandler; @@ -52,7 +61,11 @@ import java.util.stream.Collectors; @Service public class RelabelApplicationService implements WalletResultHandler { public static final String PENDING_PROCESSING = "PENDING_PROCESSING"; - private static final Set STATUSES = Set.of(PENDING_PROCESSING, "PROCESSING", "COMPLETED", "REJECTED", "CANCELLED"); + public static final String CUSTOMER_CONFIRM_PENDING = "CUSTOMER_CONFIRM_PENDING"; + public static final String READY_FOR_OUTBOUND = "READY_FOR_OUTBOUND"; + private static final Set STATUSES = Set.of(PENDING_PROCESSING, "PROCESSING", CUSTOMER_CONFIRM_PENDING, + READY_FOR_OUTBOUND, "COMPLETED", "REJECTED", "CANCELLED"); + private static final Set DELIVERY_METHODS = Set.of("TRUCK", "EXPRESS", "TRUCK_PICKUP", "EXPRESS_PICKUP", "PICKUP"); @Resource private RelabelApplicationMapper applications; @Resource private RelabelItemMapper items; @@ -66,6 +79,11 @@ public class RelabelApplicationService implements WalletResultHandler { @Resource private InventoryBalanceMapper inventoryBalances; @Resource private InventoryPositionMapper inventoryPositions; @Resource private RelabelDocumentFileGateway documentFiles; + @Resource private PdaRelabelTaskMapper pdaTasks; + @Resource private RelabelPhotoFileGateway photoFiles; + @Resource private OutboundOrderMapper outboundOrders; + @Resource private OutboundPalletItemMapper outboundItems; + @Resource private OutboundPdaTaskMapper outboundTasks; @Transactional(readOnly = true) public String customerId(String relabelNo) { return require(null, relabelNo).getCustomerId(); } @@ -94,6 +112,48 @@ public class RelabelApplicationService implements WalletResultHandler { return detail(application.getCustomerId(), relabelNo); } + @Transactional + public RelabelResponses.Detail uploadShippingLabel(String customerId, String relabelNo, long operatorId, + MultipartFile file) { + RelabelApplication application = lockForDocument(customerId, relabelNo); + if (!Boolean.TRUE.equals(application.getPrintShippingLabel())) { + throw conflict("This relabel order does not require a shipping label"); + } + requireShippingLabel(file); + var uploaded = documentFiles.upload(application.getCustomerId(), "OVERSEAS_RELABEL_SHIPPING_LABEL", + String.valueOf(application.getId()), operatorId, file); + application.setShippingLabelFileId(String.valueOf(uploaded.fileId())); + application.setShippingLabelFileName(uploaded.fileName()); + application.setShippingLabelFileUrl("/api/merchant/v1/last-leg/relabel-applications/" + + java.net.URLEncoder.encode(relabelNo, StandardCharsets.UTF_8) + "/shipping-label/" + + uploaded.fileId() + "/download"); + application.setUpdatedAt(LocalDateTime.now()); + applications.updateById(application); + return detail(application.getCustomerId(), relabelNo); + } + + @Transactional(readOnly = true) + public RelabelDocumentFileGateway.DownloadedFile downloadShippingLabel(String customerId, String relabelNo, + long fileId) { + RelabelApplication application = require(customerId, relabelNo); + if (!String.valueOf(fileId).equals(application.getShippingLabelFileId())) { + throw notFound("Shipping label does not belong to this relabel order"); + } + return documentFiles.download(application.getCustomerId(), "OVERSEAS_RELABEL_SHIPPING_LABEL", + String.valueOf(application.getId()), fileId); + } + + @Transactional(readOnly = true) + public RelabelPhotoFileGateway.DownloadedFile downloadCompletionPhoto(String customerId, String relabelNo, + long fileId) { + RelabelApplication application = require(customerId, relabelNo); + PdaRelabelTask task = pdaTasks.find(application.getCustomerId(), application.getId()); + if (task == null || !readIds(task.getCompletionPhotoFileIds()).contains(fileId)) { + throw notFound("Completion photo does not belong to this relabel order"); + } + return photoFiles.download(application.getCustomerId(), task.getId(), fileId); + } + @Transactional(readOnly = true) public RelabelDocumentFileGateway.DownloadedFile downloadFile(String customerId, String relabelNo, String scope, Long itemId, long fileId) { RelabelApplication application = require(customerId, relabelNo); @@ -131,6 +191,15 @@ public class RelabelApplicationService implements WalletResultHandler { } catch (java.io.IOException exception) { throw badRequest("无法读取PDF文件"); } } + private static void requireShippingLabel(MultipartFile file) { + if (file == null || file.isEmpty()) throw badRequest("Shipping label file is required"); + if (file.getSize() > 20L * 1024 * 1024) throw badRequest("Shipping label file exceeds 20 MB"); + String contentType = Objects.toString(file.getContentType(), "").toLowerCase(); + if (!(contentType.equals("application/pdf") || contentType.startsWith("image/"))) { + throw badRequest("Shipping label must be a PDF or image"); + } + } + private static String documentUrl(String relabelNo, long fileId, Long itemId) { return "/api/platform/v1/last-leg/relabel-applications/" + java.net.URLEncoder.encode(relabelNo, StandardCharsets.UTF_8) + "/files/" + fileId + "/download?scope=" + (itemId == null ? "TOTAL" : "ITEM&itemId=" + itemId); } @@ -163,6 +232,17 @@ public class RelabelApplicationService implements WalletResultHandler { @Transactional public RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request) { if (!StringUtils.hasText(request.operationWarehouseId())) throw badRequest("Operation warehouse is required"); + boolean requiresOutbound = request.items().stream().anyMatch(item -> !"SKU_LABEL".equals(operationType(item))); + String deliveryMethod = trim(request.deliveryMethod()); + if (requiresOutbound && !DELIVERY_METHODS.contains(deliveryMethod)) { + throw badRequest("Delivery method must be TRUCK, PICKUP or EXPRESS when carton labels are changed"); + } + if (!requiresOutbound && StringUtils.hasText(deliveryMethod)) { + throw badRequest("SKU-only relabel does not create an outbound fulfillment"); + } + if (Boolean.TRUE.equals(request.printShippingLabel()) && !Set.of("EXPRESS", "EXPRESS_PICKUP").contains(deliveryMethod)) { + throw badRequest("Shipping-label printing is only available for express delivery"); + } String requestHash = hash(request); RelabelApplication replay = applications.findKey(customerId, request.idempotencyKey().trim()); if (replay != null) { @@ -223,6 +303,9 @@ public class RelabelApplicationService implements WalletResultHandler { application.setLabelDeadline(request.labelDeadline()); application.setContactName(request.contactName().trim()); application.setContactPhone(request.contactPhone().trim()); + application.setDeliveryMethod(deliveryMethod); + application.setPrintShippingLabel(Boolean.TRUE.equals(request.printShippingLabel())); + application.setFulfillmentStatus(requiresOutbound ? "WAIT_RELABEL" : "NOT_REQUIRED"); application.setRemark(trim(request.remark())); application.setStatus(PENDING_PROCESSING); application.setItemCount(request.items().size()); @@ -285,12 +368,17 @@ public class RelabelApplicationService implements WalletResultHandler { .eq(RelabelStatusHistory::getApplicationId, application.getId()) .orderByAsc(RelabelStatusHistory::getCreatedAt)); List tailOrders = tailOrders(application); + PdaRelabelTask pdaTask = pdaTasks == null ? null : pdaTasks.find(recordCustomerId, application.getId()); + RelabelResponses.CompletionEvidence evidence = pdaTask == null ? null + : new RelabelResponses.CompletionEvidence(pdaTask.getId(), readIds(pdaTask.getCompletionPhotoFileIds()), + pdaTask.getCompletionRemark(), pdaTask.getReviewerName(), pdaTask.getReviewRemark(), + pdaTask.getReviewedAt()); return new RelabelResponses.Detail(summary(application, detailItems), application.getRemark(), detailItems.stream().map(this::item).toList(), statusHistory.stream().map(value -> new RelabelResponses.History(value.getFromStatus(), value.getToStatus(), value.getOperatorId(), value.getRemark(), value.getCreatedAt())).toList(), tailOrders.stream().map(value -> new RelabelResponses.TailOrder(value.getId(), value.getOrderNo(), value.getOverseasWarehouseId(), - Objects.requireNonNullElse(value.getPackageCount(), 0), value.getCurrentStatus())).toList()); + Objects.requireNonNullElse(value.getPackageCount(), 0), value.getCurrentStatus())).toList(), evidence); } @Transactional @@ -313,8 +401,11 @@ public class RelabelApplicationService implements WalletResultHandler { application.setStatus(next); application.setUpdatedAt(now); applications.updateById(application); - if ("COMPLETED".equals(next)) { - releaseInventory(customerId, operatorId, application); + if (CUSTOMER_CONFIRM_PENDING.equals(next)) { + application.setFulfillmentStatus("WAIT_CUSTOMER_CONFIRMATION"); + applications.updateById(application); + } else if ("COMPLETED".equals(next)) { + if (!StringUtils.hasText(application.getOutboundOrderNo())) releaseInventory(customerId, operatorId, application); walletCommands.publish("SETTLE", Long.parseLong(customerId), "RELABEL_ORDER", relabelNo, null, "换标完成,结算费用", 2L); application.setWalletStatus("SETTLE_PENDING"); applications.updateById(application); @@ -332,6 +423,7 @@ public class RelabelApplicationService implements WalletResultHandler { String orderBefore = order.getCurrentStatus(); String mainStatus = STATUSES.contains(orderBefore) ? "PENDING_ASSIGNMENT" : orderBefore; String orderStatus = switch (next) { + case CUSTOMER_CONFIRM_PENDING -> mainStatus; case "COMPLETED" -> "RETURN_RELABEL".equals(order.getOrderType()) ? "PENDING_ASSIGNMENT" : "COMPLETED"; case "REJECTED", "CANCELLED" -> "CANCELLED"; @@ -350,6 +442,102 @@ public class RelabelApplicationService implements WalletResultHandler { return detail(customerId, relabelNo); } + @Transactional + public RelabelResponses.Detail confirmCompletion(String customerId, String relabelNo, String operatorId, + RelabelRequests.CustomerConfirm request) { + RelabelApplication application = applications.lockNo(customerId, relabelNo); + if (application == null) throw notFound("Relabel application not found"); + String key = request.idempotencyKey().trim(); + if (key.equals(application.getCustomerConfirmationKey())) return detail(customerId, relabelNo); + if (!CUSTOMER_CONFIRM_PENDING.equals(application.getStatus())) { + throw conflict("Relabel order is not awaiting customer confirmation"); + } + if (Boolean.TRUE.equals(application.getPrintShippingLabel()) + && !StringUtils.hasText(application.getShippingLabelFileId())) { + throw conflict("Upload the shipping label before confirming relabel completion"); + } + LocalDateTime now = LocalDateTime.now(); + String before = application.getStatus(); + application.setStatus(READY_FOR_OUTBOUND); + application.setCustomerConfirmationKey(key); + application.setCustomerConfirmedAt(now); + application.setFulfillmentStatus("TRUCK".equals(application.getDeliveryMethod()) + ? "WAIT_PRE_DISPATCH" : "WAIT_OUTBOUND_CREATION"); + if (!"TRUCK".equals(application.getDeliveryMethod())) { + createDirectOutbound(application, operatorId, now); + application.setFulfillmentStatus("OUTBOUND_CREATED"); + } + application.setUpdatedAt(now); + applications.updateById(application); + for (LastlegOrder order : tailOrders(application)) { + String orderBefore = order.getCurrentStatus(); + order.setCurrentStatus("PENDING_ASSIGNMENT"); + order.setSubOrderStatus("RELABEL:" + application.getFulfillmentStatus()); + order.setRelabelStatus(READY_FOR_OUTBOUND); + order.setUpdatedAt(now); + orders.updateById(order); + if (!Objects.equals(orderBefore, order.getCurrentStatus())) { + insertOrderHistory(customerId, order.getId(), orderBefore, order.getCurrentStatus(), + request.remark(), now); + } + } + insertHistory(customerId, application.getId(), before, READY_FOR_OUTBOUND, operatorId, + request.remark(), now); + return detail(customerId, relabelNo); + } + + private void createDirectOutbound(RelabelApplication application, String operatorId, LocalDateTime now) { + String key = "RELABEL:" + application.getId() + ":OUTBOUND"; + OutboundOrder existing = outboundOrders.findByIdempotency(application.getCustomerId(), key); + if (existing != null) { + application.setOutboundOrderNo(existing.getOutboundNo()); + return; + } + List relabelItems = itemList(application.getId()); + OutboundOrder outbound = new OutboundOrder(); + outbound.setId(IdWorker.getId()); outbound.setCustomerId(application.getCustomerId()); + outbound.setOutboundNo(orderNoGenerator.next("RELABEL_OUTBOUND", application.getOverseasWarehouseId(), outbound.getId())); + outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType("RELABEL_OUTBOUND"); + outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(relabelItems.stream().mapToInt(item -> intValue(item.getNotifiedPackageCount())).sum()); + outbound.setPalletCount((int) relabelItems.stream().map(RelabelItem::getSourceInventoryPositionId).filter(Objects::nonNull).distinct().count()); + outbound.setRecipientInfo(application.getDeliveryMethod()); outbound.setIdempotencyKey(key); + outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); outboundOrders.insert(outbound); + Map> byPosition = relabelItems.stream().collect(Collectors.groupingBy( + RelabelItem::getSourceInventoryPositionId, LinkedHashMap::new, Collectors.toList())); + for (Map.Entry> entry : byPosition.entrySet()) { + InventoryPosition position = inventoryPositions.selectById(entry.getKey()); + if (position == null || !application.getCustomerId().equals(position.getCustomerId())) { + throw conflict("Relabel source inventory position no longer exists"); + } + List group = entry.getValue(); + OutboundPalletItem item = new OutboundPalletItem(); item.setId(IdWorker.getId()); + item.setCustomerId(application.getCustomerId()); item.setOutboundOrderId(outbound.getId()); + item.setWarehousePalletId(position.getPalletId()); item.setPalletNo(position.getPalletNo()); + item.setPackageCount(group.stream().mapToInt(value -> intValue(value.getNotifiedPackageCount())).sum()); + item.setPickedPackageCount(0); item.setStatus("WAIT_PICK"); + item.setCartonMarks(group.stream().map(RelabelItem::getCartonMark).filter(StringUtils::hasText).distinct().collect(Collectors.joining(","))); + item.setDestinationWarehouseCode(group.stream().map(RelabelItem::getDestinationFbaWarehouseCode) + .filter(StringUtils::hasText).distinct().collect(Collectors.joining(","))); + item.setInboundCode(group.stream().map(RelabelItem::getSourceInboundCode).filter(StringUtils::hasText).distinct().collect(Collectors.joining(","))); + item.setOrderRefs(writeOutboundRefs(application.getRelabelNo(), entry.getKey(), group)); + item.setCreatedAt(now); outboundItems.insert(item); + } + OutboundPdaTask task = new OutboundPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(application.getCustomerId()); + task.setOutboundOrderId(outbound.getId()); task.setTaskNo("PDA" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + task.getId()); + task.setStatus("PENDING"); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); outboundTasks.insert(task); + application.setOutboundOrderNo(outbound.getOutboundNo()); + } + + private String writeOutboundRefs(String relabelNo, Long positionId, List values) { + try { + return objectMapper.writeValueAsString(Map.of("sourceType", "RELABEL_ORDER", "relabelNo", relabelNo, + "sourceInventoryPositionId", positionId, "items", values.stream().map(item -> Map.of( + "itemId", item.getId(), "cartonMark", Objects.toString(item.getCartonMark(), ""), + "destination", Objects.toString(item.getDestinationFbaWarehouseCode(), ""), + "packages", intValue(item.getNotifiedPackageCount()))).toList())); + } catch (Exception exception) { throw new IllegalStateException("Cannot save relabel outbound snapshot", exception); } + } + private void insertItem(String customerId, long applicationId, SourceInventory sourceInventory, LocalDateTime now) { RelabelRequests.Item source = sourceInventory.request(); @@ -391,6 +579,9 @@ public class RelabelApplicationService implements WalletResultHandler { Objects.requireNonNullElse(value.getNotifiedPackageCount(), 0), skuCodes, value.getTotalFileId(), value.getTotalFileName(), value.getTotalFileUrl(), value.getChargeAmount(), value.getChargeCurrency(), value.getWalletStatus(), value.getWalletErrorMessage(), + value.getDeliveryMethod(), Boolean.TRUE.equals(value.getPrintShippingLabel()), + value.getShippingLabelFileId(), value.getShippingLabelFileName(), value.getShippingLabelFileUrl(), + value.getFulfillmentStatus(), value.getOutboundOrderNo(), value.getCustomerConfirmedAt(), value.getCreatedAt()); } @@ -474,6 +665,7 @@ public class RelabelApplicationService implements WalletResultHandler { private record SourceInventory(RelabelRequests.Item request, InventoryBalance balance) {} private static long value(Long value) { return value == null ? 0L : value; } + private static int intValue(Integer value) { return value == null ? 0 : value; } private void releaseInventory(String customerId, String operatorId, RelabelApplication application) { for (RelabelItem item : itemList(application.getId())) { @@ -505,9 +697,6 @@ public class RelabelApplicationService implements WalletResultHandler { && (item.skuItems() == null || item.skuItems().isEmpty())) { throw badRequest("SKU label changes require at least one SKU detail"); } - if ("CARTON_LABEL".equals(type) && !StringUtils.hasText(item.newSkuCode())) { - throw badRequest("Carton label changes require the new SKU"); - } } private static String operationType(RelabelRequests.Item item) { return StringUtils.hasText(item.operationType()) ? item.operationType().trim().toUpperCase() : "CARTON_LABEL"; @@ -527,6 +716,12 @@ public class RelabelApplicationService implements WalletResultHandler { List.class, RelabelResponses.SkuItem.class)); } catch (Exception exception) { throw new IllegalStateException("Cannot read relabel SKU details", 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 (Exception exception) { throw conflict("Relabel completion photo snapshot is invalid"); } + } private RelabelApplication require(String customerId, String relabelNo) { RelabelApplication value = StringUtils.hasText(customerId) ? applications.findNo(customerId, relabelNo) @@ -548,7 +743,9 @@ public class RelabelApplicationService implements WalletResultHandler { } private static void validateTransition(String from, String to) { boolean allowed = PENDING_PROCESSING.equals(from) && Set.of("PROCESSING", "REJECTED", "CANCELLED").contains(to) - || "PROCESSING".equals(from) && Set.of("COMPLETED", "REJECTED", "CANCELLED").contains(to); + || "PROCESSING".equals(from) && Set.of(CUSTOMER_CONFIRM_PENDING, "REJECTED", "CANCELLED").contains(to) + || CUSTOMER_CONFIRM_PENDING.equals(from) && Set.of(READY_FOR_OUTBOUND, "REJECTED", "CANCELLED").contains(to) + || READY_FOR_OUTBOUND.equals(from) && Set.of("COMPLETED", "CANCELLED").contains(to); if (!allowed) throw conflict("Relabel status cannot change from " + from + " to " + to); } private String hash(Object value) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelPalletMutationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelPalletMutationService.java new file mode 100644 index 000000000..cff188fa9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelPalletMutationService.java @@ -0,0 +1,129 @@ +package com.baiyue.lastleg.relabel.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.delivery.entity.OutboundOrder; +import com.baiyue.lastleg.delivery.entity.OutboundPalletItem; +import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** Keeps relabel ownership and its direct-outbound snapshot aligned after a physical pallet split. */ +@Service +public class RelabelPalletMutationService { + @Resource private RelabelItemMapper relabelItems; + @Resource private InventoryPositionMapper positions; + @Resource private OutboundOrderMapper outboundOrders; + @Resource private OutboundPalletItemMapper outboundItems; + @Resource private ObjectMapper objectMapper; + + public void moveSelectedItems(RelabelApplication application, + Map> itemsBySourcePosition, + Map targetBySourcePosition) { + for (Map.Entry> entry : itemsBySourcePosition.entrySet()) { + InventoryPosition target = targetBySourcePosition.get(entry.getKey()); + if (target == null) throw conflict("换标拆托目标库存位置不存在"); + for (RelabelItem item : entry.getValue()) { + item.setSourceInventoryPositionId(target.getId()); + relabelItems.updateById(item); + } + } + syncDirectOutbound(application, itemsBySourcePosition, targetBySourcePosition); + } + + private void syncDirectOutbound(RelabelApplication application, + Map> itemsBySourcePosition, + Map targetBySourcePosition) { + if (application.getOutboundOrderNo() == null || application.getOutboundOrderNo().isBlank()) return; + OutboundOrder outbound = outboundOrders.lockByNo(application.getCustomerId(), application.getOutboundOrderNo()); + if (outbound == null || !"RELABEL_OUTBOUND".equals(outbound.getOutboundType())) { + throw conflict("换标出库单不存在或类型不正确"); + } + if (!"WAIT_PICK".equals(outbound.getStatus())) throw conflict("换标出库已开始拣货,不能再拆托"); + List rows = new ArrayList<>(outboundItems.findByOutbound( + application.getCustomerId(), outbound.getId())); + for (Map.Entry> entry : itemsBySourcePosition.entrySet()) { + List selected = entry.getValue(); + LinkedHashSet selectedMarks = selected.stream().map(RelabelItem::getCartonMark) + .filter(Objects::nonNull).map(String::trim) + .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); + long selectedPackages = selected.stream().mapToLong(item -> value(item.getNotifiedPackageCount())).sum(); + OutboundPalletItem sourceRow = rows.stream().filter(row -> value(row.getPickedPackageCount()) == 0) + .filter(row -> cartonMarks(row).containsAll(selectedMarks)).findFirst() + .orElseThrow(() -> conflict("换标出库箱唛快照与拆托任务不一致")); + LinkedHashSet remaining = cartonMarks(sourceRow); + remaining.removeAll(selectedMarks); + InventoryPosition target = targetBySourcePosition.get(entry.getKey()); + if (remaining.isEmpty()) { + sourceRow.setWarehousePalletId(target.getPalletId()); sourceRow.setPalletNo(target.getPalletNo()); + sourceRow.setOrderRefs(outboundRefs(application.getRelabelNo(), target.getId(), selected)); + outboundItems.updateById(sourceRow); + continue; + } + int remainingPackages = value(sourceRow.getPackageCount()) - Math.toIntExact(selectedPackages); + if (remainingPackages <= 0) throw conflict("换标出库剩余箱唛与箱数快照不一致"); + sourceRow.setPackageCount(remainingPackages); sourceRow.setCartonMarks(String.join(",", remaining)); + List remainingItems = relabelItems.selectList(new LambdaQueryWrapper() + .eq(RelabelItem::getApplicationId, application.getId()) + .eq(RelabelItem::getCustomerId, application.getCustomerId()) + .eq(RelabelItem::getSourceInventoryPositionId, entry.getKey())); + sourceRow.setOrderRefs(outboundRefs(application.getRelabelNo(), entry.getKey(), remainingItems)); + outboundItems.updateById(sourceRow); + + OutboundPalletItem targetRow = new OutboundPalletItem(); + targetRow.setId(IdWorker.getId()); targetRow.setCustomerId(sourceRow.getCustomerId()); + targetRow.setOutboundOrderId(sourceRow.getOutboundOrderId()); targetRow.setWarehousePalletId(target.getPalletId()); + targetRow.setPalletNo(target.getPalletNo()); targetRow.setDestinationWarehouseId(sourceRow.getDestinationWarehouseId()); + targetRow.setDestinationWarehouseCode(sourceRow.getDestinationWarehouseCode()); + targetRow.setDestinationWarehouseName(sourceRow.getDestinationWarehouseName()); + targetRow.setPackageCount(Math.toIntExact(selectedPackages)); targetRow.setPickedPackageCount(0); + targetRow.setStatus("WAIT_PICK"); targetRow.setSkuCode(sourceRow.getSkuCode()); + targetRow.setCartonMarks(String.join(",", selectedMarks)); targetRow.setInboundCode(sourceRow.getInboundCode()); + targetRow.setOrderRefs(outboundRefs(application.getRelabelNo(), target.getId(), selected)); + targetRow.setCreatedAt(LocalDateTime.now()); outboundItems.insert(targetRow); rows.add(targetRow); + } + outbound.setPalletCount((int) rows.stream().map(OutboundPalletItem::getWarehousePalletId) + .filter(Objects::nonNull).distinct().count()); + outbound.setUpdatedAt(LocalDateTime.now()); outboundOrders.updateById(outbound); + } + + private static LinkedHashSet cartonMarks(OutboundPalletItem item) { + LinkedHashSet result = new LinkedHashSet<>(); + if (item.getCartonMarks() == null) return result; + for (String value : item.getCartonMarks().split(",")) if (!value.isBlank()) result.add(value.trim()); + return result; + } + + private String outboundRefs(String relabelNo, Long positionId, List items) { + try { + return objectMapper.writeValueAsString(Map.of("sourceType", "RELABEL_ORDER", "relabelNo", relabelNo, + "sourceInventoryPositionId", positionId, "items", items.stream().map(item -> Map.of( + "itemId", item.getId(), "cartonMark", Objects.toString(item.getCartonMark(), ""), + "destination", Objects.toString(item.getDestinationFbaWarehouseCode(), ""), + "packages", value(item.getNotifiedPackageCount()))).toList())); + } catch (Exception exception) { + throw new IllegalStateException("无法保存换标出库托盘快照", exception); + } + } + + private static int value(Integer value) { return value == null ? 0 : value; } + private static ResponseStatusException conflict(String message) { + return new ResponseStatusException(HttpStatus.CONFLICT, message); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderRequests.java index 0cddef794..e763bec5a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderRequests.java @@ -13,6 +13,8 @@ import java.util.List; public final class ReturnOrderRequests { private ReturnOrderRequests() { } public record Create(@NotBlank @Size(max = 128) String overseasWarehouseId, + @Size(max = 128) String warehouseContactName, + @Size(max = 64) String warehouseContactPhone, @Size(max = 128) String customerReferenceNo, boolean needRelabel, @Size(max = 1000) String remark, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderResponses.java index 520fb3356..4870407fd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderResponses.java @@ -5,7 +5,8 @@ import java.util.List; public final class ReturnOrderResponses { private ReturnOrderResponses() { } - public record Summary(String returnNo, String lastlegOrderNo, String overseasWarehouseId, String customerReferenceNo, String status, + public record Summary(String returnNo, String lastlegOrderNo, String overseasWarehouseId, + String warehouseContactName, String warehouseContactPhone, String customerReferenceNo, String status, int skuTypes, long expectedQuantity, long receivedQuantity, boolean needRelabel, String relabelNo, String relabelStatus, LocalDateTime createdAt) { } public record Item(String skuCode, String productNameZh, long expectedQuantity, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnOrder.java index d47cd9334..8b9175362 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnOrder.java @@ -14,6 +14,8 @@ public class ReturnOrder { private String customerId; private String returnNo; private String overseasWarehouseId; + private String warehouseContactName; + private String warehouseContactPhone; private String customerReferenceNo; private Boolean needRelabel; private Long lastlegOrderId; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java index 3d3ffce2e..a44bf3f5f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java @@ -77,6 +77,8 @@ public class ReturnReceiptService { ReturnOrder order = new ReturnOrder(); order.setId(IdWorker.getId()); order.setCustomerId(customerId); order.setOverseasWarehouseId(text(request.overseasWarehouseId())); + order.setWarehouseContactName(text(request.warehouseContactName())); + order.setWarehouseContactPhone(text(request.warehouseContactPhone())); order.setReturnNo(numbers.next("RETURN", order.getOverseasWarehouseId(), order.getId())); order.setCustomerReferenceNo(text(request.customerReferenceNo())); order.setNeedRelabel(request.needRelabel()); order.setStatus(PENDING_RECEIPT); order.setExpectedSkuTypes(skuCodes.size()); order.setExpectedQuantity(expected); order.setReceivedQuantity(0L); @@ -293,7 +295,7 @@ public class ReturnReceiptService { order.getStatus(), completed, value(order.getExpectedQuantity()), value(order.getReceivedQuantity()), receipt.getSignedAt()); } private ReturnOrderResponses.Summary summary(ReturnOrder order) { return new ReturnOrderResponses.Summary(order.getReturnNo(), order.getLastlegOrderNo(), - order.getOverseasWarehouseId(), order.getCustomerReferenceNo(), order.getStatus(), + order.getOverseasWarehouseId(), order.getWarehouseContactName(), order.getWarehouseContactPhone(), order.getCustomerReferenceNo(), order.getStatus(), order.getExpectedSkuTypes() == null ? 0 : order.getExpectedSkuTypes(), value(order.getExpectedQuantity()), value(order.getReceivedQuantity()), Boolean.TRUE.equals(order.getNeedRelabel()), order.getRelabelNo(), order.getRelabelStatus(), order.getCreatedAt()); } private ReturnOrderResponses.Item item(ReturnOrderItem item) { return new ReturnOrderResponses.Item(item.getSkuCode(), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java index b05a53d11..24c090272 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java @@ -17,16 +17,16 @@ public final class WarehouseTransferRequests { private WarehouseTransferRequests() {} public record Create( - @NotBlank @Size(max = 128) String inboundCode, - @NotEmpty @Size(max = 200) List<@NotNull Long> sourceLastlegOrderIds, + @Size(max = 128) String inboundCode, + @Size(max = 200) List<@NotNull Long> sourceLastlegOrderIds, @NotBlank @Size(max = 128) String operatingWarehouseId, - @NotBlank @Size(max = 128) String fbaWarehouseCode, + @Size(max = 128) String fbaWarehouseCode, @NotBlank @Size(max = 1000) String deliveryAddress, @Positive int packageCount, @NotNull @DecimalMin(value = "0.0001") BigDecimal volumeCbm, @NotBlank @Size(max = 64) String deliveryMethod, @NotNull LocalDateTime estimatedArrivalAt, - @NotBlank @Size(max = 128) String fbaShipmentNo, + @Size(max = 128) String fbaShipmentNo, @Size(max = 255) String trackingNo, @Size(max = 2000) String fbaBoxNos, @Size(max = 1000) String remark, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java index 09cd8e495..5dacfd2d7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java @@ -6,6 +6,7 @@ import java.util.List; public final class WarehouseTransferResponses { private WarehouseTransferResponses() {} public record Item(String skuCode, String productNameZh, int packageCount, String shippingMark) {} + public record Carton(String cartonMark, int cartonIndex, int totalCartons, String status) {} public record SourceOrder(Long lastlegOrderId, String lastlegOrderNo, Long inboundOrderId, String inboundCode) {} public record Allocation(Long inventoryPositionId, Long inboundOrderId, String inboundCode, Long palletId, String palletNo, int packageCount, String status, @@ -21,6 +22,6 @@ public final class WarehouseTransferResponses { String operatingWarehouseId, String fbaWarehouseCode, String deliveryAddress, int packageCount, String deliveryMethod, LocalDateTime estimatedArrivalAt, String fbaShipmentNo, String trackingNo, String fbaBoxNos, String remark, - List sourceOrders, List items, List allocations, + List sourceOrders, List items, List cartons, List allocations, LocalDateTime outboundAt, LocalDateTime createdAt) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferCarton.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferCarton.java new file mode 100644 index 000000000..5bd2a1024 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferCarton.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.transfer.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_warehouse_transfer_carton") +public class WarehouseTransferCarton { + @TableId(type = IdType.INPUT) private Long id; + private Long transferOrderId; + private Long transferItemId; + private String customerId; + private String cartonMark; + private Integer cartonIndex; + private Integer totalCartons; + private String status; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferCartonMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferCartonMapper.java new file mode 100644 index 000000000..ae584e342 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferCartonMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.transfer.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferCarton; +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 WarehouseTransferCartonMapper extends BaseMapper { + @Select("SELECT * FROM ll_warehouse_transfer_carton WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} ORDER BY carton_index") + List listOrder(@Param("customerId") String customerId, + @Param("transferOrderId") Long transferOrderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java index 3abe2459e..a9697b181 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java @@ -5,11 +5,13 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.common.core.api.PageResult; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegInboundOrder; import com.baiyue.lastleg.entity.LastlegOrderDetail; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import com.baiyue.lastleg.mapper.LastlegInboundOrderMapper; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper; @@ -17,22 +19,19 @@ import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.baiyue.lastleg.inventory.entity.InventoryBalance; import com.baiyue.lastleg.inventory.entity.InventoryPosition; import com.baiyue.lastleg.relabel.entity.RelabelApplication; -import com.baiyue.lastleg.relabel.entity.RelabelItem; -import com.baiyue.lastleg.relabel.entity.RelabelStatusHistory; import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; -import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; -import com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper; -import com.baiyue.lastleg.relabel.service.RelabelApplicationService; import com.baiyue.lastleg.service.LastlegOrderNoGenerator; import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests; import com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses; import com.baiyue.lastleg.transfer.entity.WarehouseTransferItem; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferCarton; import com.baiyue.lastleg.transfer.entity.WarehouseTransferInventoryAllocation; import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder; import com.baiyue.lastleg.transfer.entity.WarehouseTransferReceipt; import com.baiyue.lastleg.transfer.entity.WarehouseTransferSourceOrder; import com.baiyue.lastleg.transfer.mapper.InboundSnapshotMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferItemMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferCartonMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferInventoryAllocationMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferReceiptMapper; @@ -61,7 +60,6 @@ import java.util.stream.Collectors; @Service public class WarehouseTransferService { public static final String ORDER_TYPE = "WAREHOUSE_TRANSFER"; - public static final String RETURN_RELABEL_ORDER_TYPE = "RETURN_RELABEL"; public static final String PENDING_REVIEW = "PENDING_REVIEW"; public static final String APPROVED = "APPROVED"; public static final String REJECTED = "REJECTED"; @@ -73,16 +71,16 @@ public class WarehouseTransferService { @Resource private WarehouseTransferOrderMapper transfers; @Resource private WarehouseTransferItemMapper transferItems; + @Resource private WarehouseTransferCartonMapper transferCartons; @Resource private WarehouseTransferSourceOrderMapper sourceOrders; @Resource private WarehouseTransferInventoryAllocationMapper inventoryAllocations; @Resource private WarehouseTransferReceiptMapper transferReceipts; @Resource private InboundSnapshotMapper inboundSnapshots; + @Resource private LastlegInboundOrderMapper inboundOrders; @Resource private LastlegOrderMapper orders; @Resource private LastlegOrderDetailMapper orderDetails; @Resource private LastlegOrderStatusHistoryMapper orderHistories; @Resource private RelabelApplicationMapper relabelApplications; - @Resource private RelabelItemMapper relabelItems; - @Resource private RelabelStatusHistoryMapper relabelHistories; @Resource private LastlegOrderNoGenerator orderNoGenerator; @Resource private InventoryCommandService inventory; @Resource private InventoryCommandMapper inventoryCommands; @@ -105,30 +103,47 @@ public class WarehouseTransferService { return detail(customerId, replay.getTransferNo()); } - String inboundCode = request.inboundCode().trim(); - Long inboundOrderId = inboundSnapshots.findId(customerId, inboundCode); - if (inboundOrderId == null) throw notFound("Inbound order was not found for inboundCode " + inboundCode); - List selectedIds = request.sourceLastlegOrderIds().stream().distinct().toList(); - List selectedOrders = orders.selectList(new LambdaQueryWrapper() - .eq(LastlegOrder::getCustomerId, customerId).in(LastlegOrder::getId, selectedIds)); - if (selectedOrders.size() != selectedIds.size()) { - throw notFound("One or more selected customer orders do not exist in the current tenant"); - } - LocalDateTime now = LocalDateTime.now(); long transferId = IdWorker.getId(); String transferNo = orderNoGenerator.next("TRANSFER", request.operatingWarehouseId(), transferId); - LastlegOrder order = createTailOrder(customerId, inboundOrderId, transferNo, request, requestHash, now); + String requestedInboundCode = trim(request.inboundCode()); + List selectedIds = request.sourceLastlegOrderIds() == null + ? List.of() : request.sourceLastlegOrderIds().stream().distinct().toList(); + boolean existingInventoryTransfer = StringUtils.hasText(requestedInboundCode) || !selectedIds.isEmpty(); + if (existingInventoryTransfer && (!StringUtils.hasText(requestedInboundCode) || selectedIds.isEmpty())) { + throw badRequest("Existing inventory transfer requires both inboundCode and sourceLastlegOrderIds"); + } + + Long inboundOrderId; + String inboundCode; + List selectedOrders; + if (existingInventoryTransfer) { + inboundCode = requestedInboundCode; + inboundOrderId = inboundSnapshots.findId(customerId, inboundCode); + if (inboundOrderId == null) throw notFound("Inbound order was not found for inboundCode " + inboundCode); + selectedOrders = orders.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, customerId).in(LastlegOrder::getId, selectedIds)); + if (selectedOrders.size() != selectedIds.size()) { + throw notFound("One or more selected customer orders do not exist in the current tenant"); + } + } else { + inboundCode = transferNo; + inboundOrderId = createInboundSnapshot(customerId, transferId, transferNo, request, requestHash, now); + selectedOrders = List.of(); + } + LastlegOrder order = createTailOrder(customerId, inboundOrderId, inboundCode, transferNo, + request, requestHash, now); WarehouseTransferOrder transfer = new WarehouseTransferOrder(); transfer.setId(transferId); transfer.setCustomerId(customerId); transfer.setTransferNo(transferNo); transfer.setInboundOrderId(inboundOrderId); transfer.setInboundCode(inboundCode); transfer.setLastlegOrderId(order.getId()); transfer.setLastlegOrderNo(order.getOrderNo()); transfer.setOperatingWarehouseId(request.operatingWarehouseId().trim()); - transfer.setFbaWarehouseCode(request.fbaWarehouseCode().trim()); + transfer.setFbaWarehouseCode(trim(request.fbaWarehouseCode())); transfer.setDeliveryAddress(request.deliveryAddress().trim()); transfer.setPackageCount(request.packageCount()); transfer.setDeliveryMethod(request.deliveryMethod().trim()); transfer.setEstimatedArrivalAt(request.estimatedArrivalAt()); - transfer.setFbaShipmentNo(request.fbaShipmentNo().trim()); transfer.setTrackingNo(trim(request.trackingNo())); + transfer.setFbaShipmentNo(trim(request.fbaShipmentNo())); + transfer.setTrackingNo("CUSTOMER_DELIVERY".equals(request.deliveryMethod().trim()) ? trim(request.trackingNo()) : null); transfer.setFbaBoxNos(trim(request.fbaBoxNos())); transfer.setRemark(trim(request.remark())); transfer.setNeedRelabel(request.needRelabel()); transfer.setStatus(PENDING_REVIEW); transfer.setIdempotencyKey(key); transfer.setRequestHash(requestHash); transfer.setCreatedBy(trim(operatorId)); @@ -149,14 +164,19 @@ public class WarehouseTransferService { sourceOrders.insert(link); } + int cartonIndex = 0; for (int index = 0; index < request.items().size(); index++) { WarehouseTransferRequests.Item source = request.items().get(index); - insertTransferItem(customerId, transferId, source, now); + long itemId = insertTransferItem(customerId, transferId, source, now); + for (int itemCarton = 0; itemCarton < source.packageCount(); itemCarton++) { + cartonIndex++; + insertTransferCarton(customerId, transferId, itemId, transferNo, + cartonIndex, request.packageCount(), now); + } insertOrderDetail(customerId, order.getId(), transferNo, index, source, request, now); } insertOrderHistory(customerId, order.getId(), null, PENDING_REVIEW, transferNo, "Warehouse transfer submitted for review", now); - if (request.needRelabel()) createRelabel(customerId, operatorId, transfer, order, request, now); return detail(customerId, transferNo); } @@ -200,6 +220,9 @@ public class WarehouseTransferService { .eq(WarehouseTransferSourceOrder::getTransferOrderId, transfer.getId()).orderByAsc(WarehouseTransferSourceOrder::getId)) .stream().map(value -> new WarehouseTransferResponses.SourceOrder(value.getSourceLastlegOrderId(), value.getSourceLastlegOrderNo(), value.getInboundOrderId(), value.getInboundCode())).toList(); + List cartons = transferCartons.listOrder(customerId, transfer.getId()) + .stream().map(value -> new WarehouseTransferResponses.Carton(value.getCartonMark(), + value.getCartonIndex(), value.getTotalCartons(), value.getStatus())).toList(); Map receiptByAllocation = transferReceipts.selectList( new LambdaQueryWrapper().eq(WarehouseTransferReceipt::getCustomerId, customerId) .eq(WarehouseTransferReceipt::getTransferOrderId, transfer.getId())) @@ -230,7 +253,7 @@ public class WarehouseTransferService { relabelStatus, transfer.getOperatingWarehouseId(), transfer.getFbaWarehouseCode(), transfer.getDeliveryAddress(), transfer.getPackageCount(), transfer.getDeliveryMethod(), transfer.getEstimatedArrivalAt(), transfer.getFbaShipmentNo(), - transfer.getTrackingNo(), transfer.getFbaBoxNos(), transfer.getRemark(), sources, items, allocations, + transfer.getTrackingNo(), transfer.getFbaBoxNos(), transfer.getRemark(), sources, items, cartons, allocations, transfer.getOutboundAt(), transfer.getCreatedAt()); } @@ -254,9 +277,15 @@ public class WarehouseTransferService { LocalDateTime now = LocalDateTime.now(); String before = order.getCurrentStatus(); transfer.setStatus(next); transfer.setUpdatedAt(now); transfers.updateById(transfer); - String orderStatus = APPROVED.equals(next) ? PENDING_ASSIGNMENT : "CANCELLED"; + boolean incomingTransit = transfer.getTransferNo().equals(transfer.getInboundCode()); + String orderStatus = APPROVED.equals(next) + ? (incomingTransit + ? ("WAREHOUSE_PICKUP".equals(transfer.getDeliveryMethod()) ? "WAIT_PICKUP" : "WAIT_DELIVERY") + : PENDING_ASSIGNMENT) + : "CANCELLED"; order.setCurrentStatus(orderStatus); - order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + next); + order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + next + ";RELABEL:" + + (StringUtils.hasText(order.getRelabelStatus()) ? order.getRelabelStatus() : "NOT_REQUIRED")); order.setUpdatedAt(now); orders.updateById(order); if (!java.util.Objects.equals(before, orderStatus)) { insertOrderHistory(customerId, order.getId(), before, orderStatus, transferNo, @@ -461,20 +490,41 @@ public class WarehouseTransferService { return detail(customerId, transferNo); } - private LastlegOrder createTailOrder(String customerId, Long inboundOrderId, String transferNo, + private Long createInboundSnapshot(String customerId, long inboundOrderId, String transferNo, + WarehouseTransferRequests.Create request, String requestHash, + LocalDateTime now) { + LastlegInboundOrder inbound = new LastlegInboundOrder(); + String sourceId = "WAREHOUSE_TRANSFER:" + transferNo; + inbound.setId(inboundOrderId); inbound.setCustomerId(customerId); inbound.setSourceRegion("CUSTOMER"); + inbound.setSourceInboundId(sourceId); inbound.setRootInboundId(sourceId); + inbound.setSourceOrderId(transferNo); inbound.setSourceOrderNo(transferNo); inbound.setRootOrderId(transferNo); + inbound.setInboundCode(transferNo); inbound.setDestinationWarehouseId(request.operatingWarehouseId().trim()); + inbound.setDestinationType("OVERSEAS"); inbound.setPackageCount(request.packageCount()); + inbound.setPalletCount(0); inbound.setVolumeCbm(request.volumeCbm()); + inbound.setSourceEventId(sourceId); inbound.setSourceLoadingPlanId(ORDER_TYPE); + inbound.setSourceLoadingPlanVersion(1L); inbound.setHandoverVersion(1L); + inbound.setPayloadHash("sha256:" + requestHash); inbound.setStatus(PENDING_REVIEW); + inbound.setExceptionFlag("NONE"); inbound.setCreatedAt(now); inbound.setUpdatedAt(now); + inboundOrders.insert(inbound); + return inbound.getId(); + } + + private LastlegOrder createTailOrder(String customerId, Long inboundOrderId, String inboundCode, String transferNo, WarehouseTransferRequests.Create request, String requestHash, LocalDateTime now) { LastlegOrder order = new LastlegOrder(); order.setId(IdWorker.getId()); order.setOrderNo(orderNoGenerator.next("TRANSFER", request.operatingWarehouseId(), order.getId())); order.setCustomerId(customerId); - order.setOrderType(request.needRelabel() ? RETURN_RELABEL_ORDER_TYPE : ORDER_TYPE); - order.setInboundCode(request.inboundCode().trim()); order.setSourceRegion("CUSTOMER"); + order.setOrderType(ORDER_TYPE); + order.setInboundCode(inboundCode); order.setSourceRegion("CUSTOMER"); order.setSourceInboundId("WAREHOUSE_TRANSFER:" + transferNo); order.setRootInboundId(String.valueOf(inboundOrderId)); order.setSourceOrderNo(transferNo); order.setOverseasWarehouseId(request.operatingWarehouseId().trim()); - order.setFbaWarehouseCode(request.fbaWarehouseCode().trim()); order.setPackageCount(request.packageCount()); + order.setFbaWarehouseCode(trim(request.fbaWarehouseCode())); order.setPackageCount(request.packageCount()); order.setVolumeCbm(request.volumeCbm()); order.setContainerNo(transferNo); order.setPalletCount(0); order.setPickedPackageCount(0); order.setShippedPackageCount(0); order.setEstimatedWarehouseAt(request.estimatedArrivalAt()); - order.setCurrentStatus("PENDING_ASSIGNMENT"); order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + PENDING_REVIEW); + order.setCurrentStatus(PENDING_REVIEW); + order.setRelabelStatus(request.needRelabel() ? "PENDING_BINDING" : "NOT_REQUIRED"); + order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + PENDING_REVIEW + ";RELABEL:" + order.getRelabelStatus()); order.setCustomerRemark(trim(request.remark())); order.setSourceEventId("WAREHOUSE_TRANSFER:" + transferNo); order.setSourceLoadingPlanId(ORDER_TYPE); order.setSourcePayloadHash("sha256:" + requestHash); order.setCreatedAt(now); order.setUpdatedAt(now); @@ -482,13 +532,24 @@ public class WarehouseTransferService { return order; } - private void insertTransferItem(String customerId, long transferId, WarehouseTransferRequests.Item source, + private long insertTransferItem(String customerId, long transferId, WarehouseTransferRequests.Item source, LocalDateTime now) { WarehouseTransferItem item = new WarehouseTransferItem(); item.setId(IdWorker.getId()); item.setTransferOrderId(transferId); item.setCustomerId(customerId); item.setSkuCode(source.skuCode().trim()); item.setProductNameZh(source.productNameZh().trim()); item.setPackageCount(source.packageCount()); item.setShippingMark(trim(source.shippingMark())); item.setCreatedAt(now); transferItems.insert(item); + return item.getId(); + } + + private void insertTransferCarton(String customerId, long transferId, long transferItemId, + String transferNo, int cartonIndex, int totalCartons, + LocalDateTime now) { + WarehouseTransferCarton carton = new WarehouseTransferCarton(); + carton.setId(IdWorker.getId()); carton.setTransferOrderId(transferId); carton.setTransferItemId(transferItemId); + carton.setCustomerId(customerId); carton.setCartonMark(transferNo + "#" + totalCartons + "-" + cartonIndex); + carton.setCartonIndex(cartonIndex); carton.setTotalCartons(totalCartons); carton.setStatus("EXPECTED"); + carton.setCreatedAt(now); carton.setUpdatedAt(now); transferCartons.insert(carton); } private void insertOrderDetail(String customerId, long orderId, String transferNo, int index, WarehouseTransferRequests.Item source, @@ -497,48 +558,13 @@ public class WarehouseTransferService { detail.setCustomerId(customerId); detail.setOrderId(orderId); detail.setSourceInboundDetailId(transferNo + ":" + index); detail.setSkuCode(source.skuCode().trim()); detail.setProductNameZh(source.productNameZh().trim()); - detail.setPackageCount(source.packageCount()); detail.setDestinationWarehouseCode(request.fbaWarehouseCode().trim()); - detail.setFbaTrackingNo(trim(request.trackingNo())); detail.setShipmentNo(request.fbaShipmentNo().trim()); + detail.setPackageCount(source.packageCount()); detail.setDestinationWarehouseCode(trim(request.fbaWarehouseCode())); + detail.setFbaTrackingNo("CUSTOMER_DELIVERY".equals(request.deliveryMethod().trim()) + ? trim(request.trackingNo()) : null); + detail.setShipmentNo(trim(request.fbaShipmentNo())); detail.setShippingMark(trim(source.shippingMark())); orderDetails.insert(detail); } - private void createRelabel(String customerId, String operatorId, WarehouseTransferOrder transfer, LastlegOrder order, - WarehouseTransferRequests.Create request, LocalDateTime now) { - long applicationId = IdWorker.getId(); - String relabelNo = orderNoGenerator.next("RELABEL", request.operatingWarehouseId(), applicationId); - RelabelApplication application = new RelabelApplication(); - application.setId(applicationId); application.setCustomerId(customerId); application.setRelabelNo(relabelNo); - application.setLastlegOrderId(order.getId()); application.setCustomerOrderNo(transfer.getTransferNo()); - application.setOverseasWarehouseId(request.operatingWarehouseId().trim()); application.setLabelDeadline(request.estimatedArrivalAt()); - application.setContactName(StringUtils.hasText(operatorId) ? operatorId.trim() : "SYSTEM"); - application.setContactPhone("N/A"); application.setRemark(trim(request.remark())); - application.setStatus(RelabelApplicationService.PENDING_PROCESSING); application.setItemCount(request.items().size()); - application.setTotalPackageCount(request.packageCount()); application.setNotifiedPackageCount(request.packageCount()); - application.setIdempotencyKey("WAREHOUSE_TRANSFER:" + request.idempotencyKey().trim()); - application.setRequestHash(hash(request)); application.setCreatedBy(trim(operatorId)); - application.setCreatedAt(now); application.setUpdatedAt(now); relabelApplications.insert(application); - - for (WarehouseTransferRequests.Item source : request.items()) { - RelabelItem item = new RelabelItem(); - item.setId(IdWorker.getId()); item.setApplicationId(applicationId); item.setCustomerId(customerId); - item.setSkuCode(source.skuCode().trim()); item.setProductNameZh(source.productNameZh().trim()); - item.setTotalPackageCount(source.packageCount()); item.setNotifiedPackageCount(source.packageCount()); - item.setShipmentNo(request.fbaShipmentNo().trim()); item.setTrackingCode(trim(request.trackingNo())); - item.setNewSkuCode(source.skuCode().trim()); item.setLabelRequirement("WAREHOUSE_TRANSFER_RELABEL"); - item.setCreatedAt(now); relabelItems.insert(item); - } - RelabelStatusHistory history = new RelabelStatusHistory(); - history.setId(IdWorker.getId()); history.setCustomerId(customerId); history.setApplicationId(applicationId); - history.setToStatus(RelabelApplicationService.PENDING_PROCESSING); history.setOperatorId(trim(operatorId)); - history.setRemark("Created from warehouse transfer " + transfer.getTransferNo()); history.setCreatedAt(now); - relabelHistories.insert(history); - transfer.setRelabelApplicationId(applicationId); transfer.setRelabelNo(relabelNo); transfer.setUpdatedAt(now); - transfers.updateById(transfer); - order.setRelabelOrderNo(relabelNo); order.setRelabelStatus(RelabelApplicationService.PENDING_PROCESSING); - order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + PENDING_REVIEW + ";RELABEL:" + RelabelApplicationService.PENDING_PROCESSING); - order.setUpdatedAt(now); orders.updateById(order); - } - private void insertOrderHistory(String customerId, long orderId, String fromStatus, String toStatus, String transferNo, String remark, LocalDateTime now) { LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); diff --git a/lastleg-service/src/main/resources/application.yml b/lastleg-service/src/main/resources/application.yml index bfa497633..8cd5e9d16 100644 --- a/lastleg-service/src/main/resources/application.yml +++ b/lastleg-service/src/main/resources/application.yml @@ -81,7 +81,8 @@ baiyue: lease-seconds: ${FIRSTLEG_OUTBOX_RELAY_LEASE_SECONDS:60} retry-seconds: ${FIRSTLEG_OUTBOX_RELAY_RETRY_SECONDS:30} security: - internal-service-token: ${INTERNAL_SERVICE_TOKEN:} + gateway-internal-service-token: ${GATEWAY_INTERNAL_SERVICE_TOKEN:${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes}} + internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} jwt-mode: jwks session-mode: gateway issuer: ${JWT_ISSUER:auth-service} diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index f11be6cd6..89fd98f65 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -1293,6 +1293,9 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_item ( package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '体积(立方米)', source_stage VARCHAR(32) NOT NULL COMMENT '数据来源阶段', + carton_marks VARCHAR(4000) NULL COMMENT '换标箱唛映射快照', + source_business_type VARCHAR(32) NULL COMMENT '来源业务类型', + source_business_no VARCHAR(128) NULL COMMENT '来源业务单号', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_item_source (dispatch_order_id,plan_pallet_id,warehouse_pallet_id), @@ -1301,6 +1304,10 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_item ( KEY idx_ll_dispatch_item_pallet (customer_id,plan_pallet_id,warehouse_pallet_id) ) ENGINE=InnoDB COMMENT='派车货物冻结明细'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='carton_marks'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN carton_marks VARCHAR(4000) NULL COMMENT ''换标箱唛映射快照'' AFTER source_stage')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='source_business_type'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN source_business_type VARCHAR(32) NULL COMMENT ''来源业务类型'' AFTER carton_marks')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='source_business_no'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN source_business_no VARCHAR(128) NULL COMMENT ''来源业务单号'' AFTER source_business_type')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + CREATE TABLE IF NOT EXISTS ll_dispatch_pallet_lock ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', @@ -1429,6 +1436,23 @@ CREATE TABLE IF NOT EXISTS ll_outbound_pda_task ( PRIMARY KEY(id), UNIQUE KEY uk_ll_outbound_pda_task_order(customer_id,outbound_order_id), UNIQUE KEY uk_ll_outbound_pda_task_no(task_no), UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key) ) ENGINE=InnoDB COMMENT='一件代发出库PDA拣货任务'; +SET @ddl = (SELECT IF(EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='ll_outbound_pda_task' AND column_name='completion_idempotency_key' +), 'SELECT 1', + 'ALTER TABLE ll_outbound_pda_task ADD COLUMN completion_idempotency_key VARCHAR(255) NULL COMMENT ''完成命令幂等键'' AFTER status')); +PREPARE outbound_pda_completion_column_stmt FROM @ddl; EXECUTE outbound_pda_completion_column_stmt; DEALLOCATE PREPARE outbound_pda_completion_column_stmt; +SET @index_ready = (SELECT COUNT(*)=2 AND MIN(non_unique)=0 + AND SUM(seq_in_index=1 AND column_name='customer_id')=1 + AND SUM(seq_in_index=2 AND column_name='completion_idempotency_key')=1 + FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name='ll_outbound_pda_task' AND index_name='uk_ll_outbound_pda_complete_key'); +SET @index_exists = (SELECT COUNT(*)>0 FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name='ll_outbound_pda_task' AND index_name='uk_ll_outbound_pda_complete_key'); +SET @ddl = IF(@index_ready, 'SELECT 1', IF(@index_exists, + 'ALTER TABLE ll_outbound_pda_task DROP INDEX uk_ll_outbound_pda_complete_key, ADD UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key)', + 'ALTER TABLE ll_outbound_pda_task ADD UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key)')); +PREPARE outbound_pda_completion_index_stmt FROM @ddl; EXECUTE outbound_pda_completion_index_stmt; DEALLOCATE PREPARE outbound_pda_completion_index_stmt; CREATE TABLE IF NOT EXISTS ll_outbound_pda_pick ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', task_id BIGINT NOT NULL COMMENT 'PDA任务ID', outbound_pallet_item_id BIGINT NOT NULL COMMENT '出库托盘明细ID', @@ -1458,9 +1482,9 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_order ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', transfer_no VARCHAR(64) NOT NULL COMMENT '转仓单号', inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', lastleg_order_id BIGINT NOT NULL COMMENT '尾程订单ID', lastleg_order_no VARCHAR(32) NOT NULL COMMENT '尾程订单号', - operating_warehouse_id VARCHAR(128) NOT NULL COMMENT '作业仓库ID', fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA仓库编码', - delivery_address VARCHAR(1000) NOT NULL COMMENT '派送地址', package_count INT NOT NULL COMMENT '箱数', delivery_method VARCHAR(64) NOT NULL COMMENT '派送方式', - estimated_arrival_at DATETIME(3) NOT NULL COMMENT '预计到达时间', fba_shipment_no VARCHAR(128) NOT NULL COMMENT 'FBA货件编号', + operating_warehouse_id VARCHAR(128) NOT NULL COMMENT '入库中转仓ID', fba_warehouse_code VARCHAR(128) NULL COMMENT '可选FBA目的仓编码', + delivery_address VARCHAR(1000) NOT NULL COMMENT '提货地址或中转仓收货地址', package_count INT NOT NULL COMMENT '箱数', delivery_method VARCHAR(64) NOT NULL COMMENT '入仓方式', + estimated_arrival_at DATETIME(3) NOT NULL COMMENT '预计入仓时间', fba_shipment_no VARCHAR(128) NULL COMMENT '可选FBA货件编号', tracking_no VARCHAR(255) NULL COMMENT '物流跟踪号', fba_box_nos VARCHAR(2000) NULL COMMENT 'FBA箱号集合', remark VARCHAR(1000) NULL COMMENT '备注', need_relabel TINYINT NOT NULL DEFAULT 0 COMMENT '是否需要换标', relabel_application_id BIGINT NULL COMMENT '换标申请单ID', relabel_no VARCHAR(64) NULL COMMENT '换标申请单号', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', @@ -1478,6 +1502,9 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_order ( KEY idx_ll_wh_transfer_list (customer_id,status,created_at), KEY idx_ll_wh_transfer_inbound (customer_id,inbound_code) ) ENGINE=InnoDB COMMENT='客户转仓单'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='fba_warehouse_code' AND is_nullable='NO'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''可选FBA目的仓编码'', MODIFY COLUMN fba_shipment_no VARCHAR(128) NULL COMMENT ''可选FBA货件编号'', MODIFY COLUMN delivery_address VARCHAR(1000) NOT NULL COMMENT ''提货地址或中转仓收货地址'', MODIFY COLUMN delivery_method VARCHAR(64) NOT NULL COMMENT ''入仓方式'', MODIFY COLUMN estimated_arrival_at DATETIME(3) NOT NULL COMMENT ''预计入仓时间''', 'SELECT 1')); +PREPARE transfer_optional_fba_stmt FROM @ddl; EXECUTE transfer_optional_fba_stmt; DEALLOCATE PREPARE transfer_optional_fba_stmt; + -- 兼容已存在的旧版转仓单表:CREATE TABLE IF NOT EXISTS 不会补充新增字段。 SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='allocation_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN allocation_idempotency_key VARCHAR(255) NULL COMMENT ''库存分配幂等键'' AFTER status')); PREPARE transfer_allocation_key_stmt FROM @ddl; EXECUTE transfer_allocation_key_stmt; DEALLOCATE PREPARE transfer_allocation_key_stmt; @@ -1507,6 +1534,18 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_item ( KEY idx_ll_wh_transfer_item_list (customer_id,transfer_order_id), KEY idx_ll_wh_transfer_item_sku (customer_id,sku_code) ) ENGINE=InnoDB COMMENT='转仓货物冻结明细'; +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_carton ( + id BIGINT NOT NULL COMMENT '预报箱主键', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', + transfer_item_id BIGINT NOT NULL COMMENT '转运申报明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + carton_mark VARCHAR(255) NOT NULL COMMENT '系统生成的一箱一码箱唛', carton_index INT NOT NULL COMMENT '全单箱序号', + total_cartons INT NOT NULL COMMENT '首次生成时全单总箱数', status VARCHAR(32) NOT NULL DEFAULT 'EXPECTED' COMMENT '箱状态:EXPECTED待实收', + 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_wh_transfer_carton_mark (customer_id,carton_mark), + UNIQUE KEY uk_ll_wh_transfer_carton_index (transfer_order_id,carton_index), + KEY idx_ll_wh_transfer_carton_list (customer_id,transfer_order_id,status) +) ENGINE=InnoDB COMMENT='仓储转运系统箱唛预报'; + CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_inventory_allocation ( id BIGINT NOT NULL COMMENT '雪花主键', transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', @@ -1564,6 +1603,15 @@ CREATE TABLE IF NOT EXISTS ll_relabel_application ( label_deadline DATETIME(3) NOT NULL COMMENT '换标截止时间', contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名', contact_phone VARCHAR(64) NOT NULL COMMENT '联系人电话', + delivery_method VARCHAR(32) NULL COMMENT '派送方式:TRUCK卡车、PICKUP自提、EXPRESS快递;仅涉及箱唛换标时必填', + print_shipping_label TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否需要打印面单', + shipping_label_file_id VARCHAR(128) NULL COMMENT '客户上传面单文件ID', + shipping_label_file_name VARCHAR(255) NULL COMMENT '客户上传面单文件名', + shipping_label_file_url VARCHAR(1000) NULL COMMENT '客户上传面单下载地址', + fulfillment_status VARCHAR(32) NOT NULL DEFAULT 'WAIT_RELABEL' COMMENT '换标后履约状态', + outbound_order_no VARCHAR(64) NULL COMMENT '自提或快递生成的换标出库单号', + customer_confirmation_key VARCHAR(255) NULL COMMENT '客户确认完成幂等键', + customer_confirmed_at DATETIME(3) NULL COMMENT '客户确认换标完成时间', remark VARCHAR(1000) NULL COMMENT '备注', total_file_id VARCHAR(128) NULL COMMENT '总换标PDF文件ID', total_file_name VARCHAR(255) NULL COMMENT '总换标PDF文件名', @@ -1595,6 +1643,15 @@ CREATE TABLE IF NOT EXISTS ll_relabel_application ( SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='total_file_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN total_file_id VARCHAR(128) NULL COMMENT ''总换标PDF文件ID'' AFTER remark')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='total_file_name'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN total_file_name VARCHAR(255) NULL COMMENT ''总换标PDF文件名'' AFTER total_file_id')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='total_file_url'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN total_file_url VARCHAR(1000) NULL COMMENT ''总换标PDF预览地址'' AFTER total_file_name')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='delivery_method'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN delivery_method VARCHAR(32) NULL COMMENT ''派送方式:TRUCK卡车、PICKUP自提、EXPRESS快递;仅涉及箱唛换标时必填'' AFTER contact_phone')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='print_shipping_label'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN print_shipping_label TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否需要打印面单'' AFTER delivery_method')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_id VARCHAR(128) NULL COMMENT ''客户上传面单文件ID'' AFTER print_shipping_label')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_name'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_name VARCHAR(255) NULL COMMENT ''客户上传面单文件名'' AFTER shipping_label_file_id')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_url'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_url VARCHAR(1000) NULL COMMENT ''客户上传面单下载地址'' AFTER shipping_label_file_name')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='fulfillment_status'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN fulfillment_status VARCHAR(32) NOT NULL DEFAULT ''WAIT_RELABEL'' COMMENT ''换标后履约状态'' AFTER shipping_label_file_url')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='outbound_order_no'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN outbound_order_no VARCHAR(64) NULL COMMENT ''自提或快递生成的换标出库单号'' AFTER fulfillment_status')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='customer_confirmation_key'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN customer_confirmation_key VARCHAR(255) NULL COMMENT ''客户确认完成幂等键'' AFTER fulfillment_status')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='customer_confirmed_at'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认换标完成时间'' AFTER customer_confirmation_key')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; CREATE TABLE IF NOT EXISTS ll_relabel_item ( id BIGINT NOT NULL COMMENT '主键ID', @@ -1607,7 +1664,7 @@ CREATE TABLE IF NOT EXISTS ll_relabel_item ( product_name_zh VARCHAR(255) NOT NULL COMMENT '产品中文名称', total_package_count INT NOT NULL COMMENT '总箱数', notified_package_count INT NOT NULL COMMENT '已通知箱数', - shipment_no VARCHAR(128) NOT NULL COMMENT '货件编号', + shipment_no VARCHAR(128) NULL COMMENT '货件编号(无FBA去向时为空)', tracking_code VARCHAR(255) NULL COMMENT '物流跟踪编码', new_sku_code VARCHAR(128) NOT NULL COMMENT '换标后SKU编码', label_requirement VARCHAR(1000) NULL COMMENT '换标要求', @@ -1618,12 +1675,12 @@ CREATE TABLE IF NOT EXISTS ll_relabel_item ( carton_mark VARCHAR(255) NULL COMMENT '客户自有箱唛', fba_carton_mark VARCHAR(255) NULL COMMENT '关联FBA箱唛', destination_warehouse_id VARCHAR(128) NOT NULL COMMENT '该箱目的海外仓ID', - destination_fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA目的仓编码(与海外操作仓分离)', + destination_fba_warehouse_code VARCHAR(128) NULL COMMENT '可选FBA目的仓编码(与海外操作仓分离)', operation_type VARCHAR(32) NOT NULL DEFAULT 'CARTON_LABEL' COMMENT '换标模式:箱唛、SKU或组合换标', sku_relabel_items_json JSON NULL COMMENT '同箱SKU换标子明细快照(数量按件)', - source_inventory_balance_id BIGINT NOT NULL COMMENT '原库存余额ID', - source_inventory_position_id BIGINT NOT NULL COMMENT '原库存库位ID', - source_inbound_order_id BIGINT NOT NULL COMMENT '原入仓单ID', + source_inventory_balance_id BIGINT NULL COMMENT '原库存余额ID(预入仓换标任务可为空)', + source_inventory_position_id BIGINT NULL COMMENT '原库存库位ID(预入仓换标任务可为空)', + source_inbound_order_id BIGINT NULL COMMENT '原入仓单ID(预入仓换标任务可为空)', source_inbound_order_no VARCHAR(128) NULL COMMENT '原入仓单号快照', source_inbound_code VARCHAR(128) NULL COMMENT '原进仓编码快照', source_warehouse_id VARCHAR(128) NOT NULL COMMENT '原海外中转仓ID', @@ -1636,6 +1693,9 @@ CREATE TABLE IF NOT EXISTS ll_relabel_item ( KEY idx_ll_relabel_item_source_position (customer_id,source_inventory_position_id) ) ENGINE=InnoDB COMMENT='换标货物冻结明细'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name IN ('shipment_no','destination_fba_warehouse_code','source_inventory_balance_id','source_inventory_position_id','source_inbound_order_id') AND is_nullable='NO'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN shipment_no VARCHAR(128) NULL COMMENT ''货件编号(无FBA去向时为空)'', MODIFY COLUMN destination_fba_warehouse_code VARCHAR(128) NULL COMMENT ''可选FBA目的仓编码(与海外操作仓分离)'', MODIFY COLUMN source_inventory_balance_id BIGINT NULL COMMENT ''原库存余额ID(预入仓换标任务可为空)'', MODIFY COLUMN source_inventory_position_id BIGINT NULL COMMENT ''原库存库位ID(预入仓换标任务可为空)'', MODIFY COLUMN source_inbound_order_id BIGINT NULL COMMENT ''原入仓单ID(预入仓换标任务可为空)''', 'SELECT 1')); +PREPARE relabel_pre_inbound_stmt FROM @ddl; EXECUTE relabel_pre_inbound_stmt; DEALLOCATE PREPARE relabel_pre_inbound_stmt; + SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='remark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN remark VARCHAR(1000) NULL COMMENT ''货物明细备注'' AFTER label_requirement')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='carton_mark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN carton_mark VARCHAR(255) NULL COMMENT ''客户自有箱唛'' AFTER relabel_file_url')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='fba_carton_mark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN fba_carton_mark VARCHAR(255) NULL COMMENT ''关联FBA箱唛'' AFTER carton_mark')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; @@ -2178,10 +2238,12 @@ CREATE TABLE IF NOT EXISTS ll_destuff_pallet_cost_allocation ( -- 客户退货预报、PDA 签收与仓储费起算批次 CREATE TABLE IF NOT EXISTS ll_return_order ( -- need_relabel is added below for existing deployments - id BIGINT NOT NULL COMMENT '退货订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', return_no VARCHAR(128) NOT NULL COMMENT '退货订单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '操作海外仓ID', customer_reference_no VARCHAR(128) NULL COMMENT '客户参考号', status VARCHAR(32) NOT NULL COMMENT 'PENDING_RECEIPT、PARTIALLY_RECEIVED或COMPLETED', expected_sku_types INT NOT NULL COMMENT '预报SKU种类数', expected_quantity BIGINT NOT NULL COMMENT '预报总数量', received_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '累计实收总数量', remark VARCHAR(1000) NULL COMMENT '客户备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键', request_hash CHAR(64) NOT NULL COMMENT '创建请求SHA-256', created_by VARCHAR(128) NULL COMMENT '创建操作人ID', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', + id BIGINT NOT NULL COMMENT '退货订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', return_no VARCHAR(128) NOT NULL COMMENT '退货订单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '操作海外仓ID', warehouse_contact_name VARCHAR(128) NULL COMMENT '中转仓联系人快照', warehouse_contact_phone VARCHAR(64) NULL COMMENT '中转仓联系电话快照', customer_reference_no VARCHAR(128) NULL COMMENT '客户参考号', status VARCHAR(32) NOT NULL COMMENT 'PENDING_RECEIPT、PARTIALLY_RECEIVED或COMPLETED', expected_sku_types INT NOT NULL COMMENT '预报SKU种类数', expected_quantity BIGINT NOT NULL COMMENT '预报总数量', received_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '累计实收总数量', remark VARCHAR(1000) NULL COMMENT '客户备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键', request_hash CHAR(64) NOT NULL COMMENT '创建请求SHA-256', created_by VARCHAR(128) NULL COMMENT '创建操作人ID', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_return_order_customer_no(customer_id,return_no), UNIQUE KEY uk_ll_return_order_customer_key(customer_id,idempotency_key), KEY idx_ll_return_order_warehouse_status(overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_order_quantity CHECK(expected_sku_types>0 AND expected_quantity>0 AND received_quantity>=0 AND received_quantity<=expected_quantity) ) ENGINE=InnoDB COMMENT='客户退货订单及SKU实收汇总'; SET @ll_return_order_need_relabel = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='need_relabel'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN need_relabel TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否需要换标'' AFTER customer_reference_no')); PREPARE stmt FROM @ll_return_order_need_relabel; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='warehouse_contact_name'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN warehouse_contact_name VARCHAR(128) NULL COMMENT ''中转仓联系人快照'' AFTER overseas_warehouse_id')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='warehouse_contact_phone'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN warehouse_contact_phone VARCHAR(64) NULL COMMENT ''中转仓联系电话快照'' AFTER warehouse_contact_name')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='relabel_application_id'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN relabel_application_id BIGINT NULL COMMENT ''绑定换标申请单ID'' AFTER need_relabel')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='relabel_no'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN relabel_no VARCHAR(64) NULL COMMENT ''绑定换标单号'' AFTER relabel_application_id')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='relabel_status'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN relabel_status VARCHAR(32) NULL COMMENT ''换标状态'' AFTER relabel_no')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index bf6ffb88a..3ff605a3f 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -1166,7 +1166,7 @@ paths: /api/platform/v1/last-leg/relabel-applications/{relabelNo}/status: put: summary: 变更海外换标单状态 - description: 权限 platform:overseas-warehouse:relabel-order:status;状态按 PENDING_PROCESSING→PROCESSING→COMPLETED/REJECTED/CANCELLED 流转。 + description: 权限 platform:overseas-warehouse:relabel-order:status;PDA复核通过后进入CUSTOMER_CONFIRM_PENDING,客户确认后进入READY_FOR_OUTBOUND,再由排车或直接出库履约。 security: [{ bearerAuth: [] }] parameters: - { name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } } @@ -1204,6 +1204,94 @@ paths: responses: '200': { description: PDF字节, content: { application/pdf: { schema: { type: string, format: binary } } } } '404': { description: 资料未绑定或归属不匹配 } + /api/merchant/v1/last-leg/relabel-applications/inventory-candidates: + get: + summary: 查询客户可换标库存候选 + description: 权限 merchant:warehouse:relabel;仅返回当前客户在指定海外中转仓仍有可用箱数的历史入仓单和库位摘要。候选按客户 Token 隔离,不接收 customerId;inboundCode 可选精确筛选。 + security: [{ bearerAuth: [] }] + parameters: + - { name: operationWarehouseId, in: query, required: true, schema: { type: string, maxLength: 128 } } + - { name: inboundCode, in: query, required: false, schema: { type: string, maxLength: 128 } } + responses: + '200': { description: 可换标库存候选, content: { application/json: { schema: { type: object, properties: { code: { type: string, example: '200' }, data: { type: array, items: { $ref: '#/components/schemas/RelabelInventoryCandidate' } } } } } } } + '403': { description: 缺少客户换标权限 } + /api/merchant/v1/last-leg/relabel-applications: + get: + summary: 分页查询客户换标申请 + description: 权限 merchant:warehouse:relabel;客户范围只取认证 Token,不接收 customerId。 + security: [{ bearerAuth: [] }] + parameters: + - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } + - { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } } + - { name: relabelNo, in: query, schema: { type: string, maxLength: 64 } } + - { name: customerOrderNo, in: query, schema: { type: string, maxLength: 128 } } + - { name: skuCode, in: query, schema: { type: string, maxLength: 128 } } + - { name: status, in: query, schema: { $ref: '#/components/schemas/RelabelStatus' } } + - { name: overseasWarehouseId, in: query, schema: { type: string, maxLength: 128 } } + - { name: createdFrom, in: query, schema: { type: string, format: date-time } } + - { name: createdTo, in: query, schema: { type: string, format: date-time } } + responses: + '200': { description: 客户换标申请分页, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelMerchantPageResponse' } } } } + post: + summary: 创建客户换标申请 + description: 权限 merchant:warehouse:relabel;申请创建即冻结对应库存。SKU_LABEL 不要求派送方式;CARTON_LABEL/CARTON_AND_SKU 必须指定 TRUCK、PICKUP 或 EXPRESS。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelCreate' } } } } + responses: + '200': { description: 换标申请详情, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelMerchantDetailResponse' } } } } + '409': { description: 幂等键、库存或状态冲突 } + /api/merchant/v1/last-leg/relabel-applications/{relabelNo}: + get: + summary: 查询客户换标申请详情 + description: 权限 merchant:warehouse:relabel;客户范围只取认证 Token。 + security: [{ bearerAuth: [] }] + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } }] + responses: + '200': { description: 换标申请详情, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelMerchantDetailResponse' } } } } + '404': { description: 申请不存在或不属于当前客户 } + /api/merchant/v1/last-leg/relabel-applications/{relabelNo}/shipping-label: + post: + summary: 上传换标快递面单 + description: 权限 merchant:warehouse:relabel;仅 EXPRESS 且申请尚未客户确认时允许上传,文件由服务端绑定到申请单。 + security: [{ bearerAuth: [] }] + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } }] + requestBody: { required: true, content: { multipart/form-data: { schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } } } } } + responses: + '200': { description: 更新后的换标详情, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelMerchantDetailResponse' } } } } + '400': { description: 文件类型或大小不合法 } + '409': { description: 当前状态不允许上传 } + /api/merchant/v1/last-leg/relabel-applications/{relabelNo}/shipping-label/{fileId}/download: + get: + summary: 下载换标快递面单 + description: 权限 merchant:warehouse:relabel;仅允许下载当前申请已绑定的面单文件。 + security: [{ bearerAuth: [] }] + parameters: + - { name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } } + - { name: fileId, in: path, required: true, schema: { type: integer, format: int64, minimum: 1 } } + responses: + '200': { description: 面单文件, content: { application/pdf: { schema: { type: string, format: binary } }, image/jpeg: { schema: { type: string, format: binary } }, image/png: { schema: { type: string, format: binary } } } } + '404': { description: 文件未绑定或不属于当前客户 } + /api/merchant/v1/last-leg/relabel-applications/{relabelNo}/completion-photos/{fileId}/download: + get: + summary: 下载换标完成照片 + description: 权限 merchant:warehouse:relabel;仅允许下载该申请 PDA 完工证据中的照片。 + security: [{ bearerAuth: [] }] + parameters: + - { name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } } + - { name: fileId, in: path, required: true, schema: { type: integer, format: int64, minimum: 1 } } + responses: + '200': { description: 完工照片, content: { image/jpeg: { schema: { type: string, format: binary } }, image/png: { schema: { type: string, format: binary } } } } + '404': { description: 照片不存在或不属于当前客户 } + /api/merchant/v1/last-leg/relabel-applications/{relabelNo}/confirm-completion: + post: + summary: 客户确认换标完成 + description: 权限 merchant:warehouse:relabel;仅 CUSTOMER_CONFIRM_PENDING 可确认。TRUCK 进入 WAIT_PRE_DISPATCH,PICKUP/EXPRESS 幂等生成 RELABEL_OUTBOUND 出库单;EXPRESS 且要求打印面单时必须先上传面单。 + security: [{ bearerAuth: [] }] + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelCustomerConfirm' } } } } + responses: + '200': { description: 更新后的换标详情, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelMerchantDetailResponse' } } } } + '409': { description: 状态、面单、库存或幂等冲突 } /api/merchant/v1/last-leg/return-orders: get: summary: 客户分页查询退货签收订单 @@ -1315,6 +1403,8 @@ components: required: [overseasWarehouseId, needRelabel, idempotencyKey, items] properties: overseasWarehouseId: { type: string, maxLength: 128 } + warehouseContactName: { type: string, maxLength: 128, description: 中转仓联系人快照;仓库未维护时可由客户填写 } + warehouseContactPhone: { type: string, maxLength: 64, description: 中转仓联系电话快照;仓库未维护时可由客户填写 } customerReferenceNo: { type: string, maxLength: 128 } needRelabel: { type: boolean, default: false, description: 是否需要换标 } remark: { type: string, maxLength: 1000 } @@ -2127,7 +2217,101 @@ components: packageCount: { type: integer } volumeCbm: { type: number } sourceStage: { type: string, enum: [PLAN, RECEIVED] } - RelabelStatus: { type: string, enum: [PENDING_PROCESSING, PROCESSING, COMPLETED, REJECTED, CANCELLED] } + RelabelStatus: { type: string, enum: [PENDING_PROCESSING, PROCESSING, CUSTOMER_CONFIRM_PENDING, READY_FOR_OUTBOUND, COMPLETED, REJECTED, CANCELLED] } + RelabelMerchantPageResponse: + allOf: + - { $ref: '#/components/schemas/MerchantApiResponse' } + - type: object + properties: { data: { $ref: '#/components/schemas/RelabelMerchantPage' } } + RelabelMerchantPage: + type: object + properties: + page: { type: integer } + size: { type: integer } + total: { type: integer, format: int64 } + records: { type: array, items: { $ref: '#/components/schemas/RelabelSummary' } } + RelabelMerchantDetailResponse: + allOf: + - { $ref: '#/components/schemas/MerchantApiResponse' } + - type: object + properties: { data: { $ref: '#/components/schemas/RelabelDetail' } } + RelabelSummary: + type: object + properties: + id: { type: string } + relabelNo: { type: string } + lastlegOrderNo: { type: string, nullable: true } + customerOrderNo: { type: string } + overseasWarehouseId: { type: string } + labelDeadline: { type: string, format: date-time } + status: { $ref: '#/components/schemas/RelabelStatus' } + itemCount: { type: integer } + totalPackageCount: { type: integer } + notifiedPackageCount: { type: integer } + deliveryMethod: { type: string, nullable: true, enum: [TRUCK, PICKUP, EXPRESS] } + printShippingLabel: { type: boolean } + shippingLabelFileId: { type: string, nullable: true } + fulfillmentStatus: { type: string, nullable: true, enum: [WAIT_RELABEL, WAIT_PRE_DISPATCH, READY_FOR_OUTBOUND, OUTBOUND_CREATED, COMPLETED] } + outboundOrderNo: { type: string, nullable: true } + customerConfirmedAt: { type: string, format: date-time, nullable: true } + createdAt: { type: string, format: date-time } + RelabelDetail: + type: object + properties: + application: { $ref: '#/components/schemas/RelabelSummary' } + remark: { type: string, nullable: true } + items: { type: array, items: { $ref: '#/components/schemas/RelabelItem' } } + history: { type: array, items: { $ref: '#/components/schemas/RelabelHistory' } } + completionEvidence: { $ref: '#/components/schemas/RelabelCompletionEvidence' } + RelabelHistory: + type: object + properties: + fromStatus: { type: string, nullable: true } + toStatus: { type: string } + operatorId: { type: string } + remark: { type: string, nullable: true } + createdAt: { type: string, format: date-time } + RelabelCompletionEvidence: + type: object + nullable: true + properties: + taskId: { type: string, nullable: true } + photoFileIds: { type: array, items: { type: string } } + completionRemark: { type: string, nullable: true } + reviewerName: { type: string, nullable: true } + reviewRemark: { type: string, nullable: true } + completedAt: { type: string, format: date-time, nullable: true } + RelabelInventoryCandidate: + type: object + properties: + inventoryBalanceId: { type: string } + inboundOrderId: { type: string } + inboundOrderNo: { type: string } + inboundCode: { type: string } + overseasWarehouseId: { type: string } + overseasWarehouseCode: { type: string } + overseasWarehouseName: { type: string } + destinationFbaWarehouseCode: { type: string, nullable: true } + destinationFbaWarehouseName: { type: string, nullable: true } + skuCode: { type: string, nullable: true } + productNameZh: { type: string } + availablePackages: { type: integer, format: int64 } + version: { type: integer, format: int64 } + positions: { type: array, items: { $ref: '#/components/schemas/RelabelInventoryPosition' } } + RelabelInventoryPosition: + type: object + properties: + positionId: { type: string } + palletNo: { type: string, nullable: true } + batchNo: { type: string, nullable: true } + warehouseLocationCode: { type: string, nullable: true } + availablePackages: { type: integer, format: int64 } + RelabelCustomerConfirm: + type: object + required: [idempotencyKey] + properties: + idempotencyKey: { type: string, maxLength: 255 } + remark: { type: string, nullable: true, maxLength: 1000 } RelabelCreate: type: object required: [customerOrderNo, labelDeadline, contactName, contactPhone, chargeAmount, idempotencyKey, items] @@ -2140,6 +2324,8 @@ components: chargeAmount: { type: number, minimum: 0.01, description: 本次贴标费用(CNY) } remark: { type: string, nullable: true, maxLength: 1000 } idempotencyKey: { type: string, maxLength: 255 } + deliveryMethod: { type: string, nullable: true, enum: [TRUCK, EXPRESS, TRUCK_PICKUP, EXPRESS_PICKUP, PICKUP], description: 只换箱唛或SKU与箱唛同时换时必填;只换SKU时不传 } + printShippingLabel: { type: boolean, default: false, description: 仅快递派送可选,客户确认前必须已上传面单 } items: { type: array, minItems: 1, maxItems: 500, items: { $ref: '#/components/schemas/RelabelItem' } } RelabelItem: type: object 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 index 95626c79a..65f37d0c5 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml @@ -164,6 +164,12 @@ paths: summary: 将来源托盘库存拆到一个或多个目标托盘 requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PalletDisassemble' } } } } responses: { '200': { description: 拆托结果 }, '409': { description: 库存或资源版本冲突 } } + /api/pda/v1/last-leg/pallet-operations/relabel-disassemble: + post: + summary: 按换标箱唛拆托并绑定目标托盘 + description: PDA先扫描原托盘,再扫描换标单指定箱唛;服务端按换标单库存快照解析仓位,在同一事务中扣减原托盘并绑定目标托盘。目标托盘须先由仓储服务创建后扫码确认。 + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelPalletDisassemble' } } } } + responses: { '200': { description: 箱唛级拆托绑定结果 }, '409': { description: 箱唛、库存或托盘版本冲突 } } /api/pda/v1/last-leg/pallet-operations/swap: post: summary: 将整托库存更换到空目标托盘 @@ -675,6 +681,16 @@ components: source: { $ref: '#/components/schemas/PalletExpectation' } targets: { type: array, minItems: 1, maxItems: 20, items: { $ref: '#/components/schemas/PalletTargetMove' } } reason: { type: string, maxLength: 500 } + RelabelPalletDisassemble: + type: object + required: [idempotencyKey, relabelNo, source, target, cartonMarks] + properties: + idempotencyKey: { type: string, maxLength: 128 } + relabelNo: { type: string, maxLength: 64 } + source: { $ref: '#/components/schemas/PalletExpectation' } + target: { $ref: '#/components/schemas/PalletExpectation' } + cartonMarks: { type: array, minItems: 1, maxItems: 500, uniqueItems: true, items: { type: string, maxLength: 255 } } + reason: { type: string, maxLength: 500 } PalletSwap: type: object required: [idempotencyKey, source, target] 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 index 0e4efce25..133b83069 100644 --- 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 @@ -24,7 +24,7 @@ class PdaPalletOperationControllerContractTest { .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); + assertEquals(Set.of("/pallets/resolve", "/assemble", "/disassemble", "/relabel-disassemble", "/swap", "/unbind"), paths); } @Test void palletContentAnnotationUsesNativeGreaterThanOperator() throws Exception { 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 index 3c0a6ac9e..94dcdad23 100644 --- 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 @@ -13,6 +13,11 @@ 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.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.service.RelabelPalletMutationService; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -43,6 +48,9 @@ class PdaPalletOperationServiceTest { @Mock private InventoryLedgerMapper ledgers; @Mock private PalletPhysicalGateway physical; @Mock private PdaWarehouseService history; + @Mock private RelabelApplicationMapper relabelApplications; + @Mock private RelabelItemMapper relabelItems; + @Mock private RelabelPalletMutationService relabelPalletMutations; private PdaPalletOperationService service; @BeforeEach void setUp() { @@ -53,6 +61,9 @@ class PdaPalletOperationServiceTest { ReflectionTestUtils.setField(service, "ledgers", ledgers); ReflectionTestUtils.setField(service, "physical", physical); ReflectionTestUtils.setField(service, "history", history); + ReflectionTestUtils.setField(service, "relabelApplications", relabelApplications); + ReflectionTestUtils.setField(service, "relabelItems", relabelItems); + ReflectionTestUtils.setField(service, "relabelPalletMutations", relabelPalletMutations); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); service.configureTransactions(new NoOpTransactionManager()); } @@ -157,6 +168,83 @@ class PdaPalletOperationServiceTest { verifyNoInteractions(physical); } + @Test void relabelDisassembleMovesOnlySelectedHeldCarton() { + var sourcePallet = new PalletPhysicalGateway.PalletReference(20L, "SOURCE", 88L, 1020L, + "OCCUPIED", null, 1); + var targetPallet = new PalletPhysicalGateway.PalletReference(10L, "TARGET", 88L, 1010L, + "IDLE", null, 2); + when(physical.resolve("SOURCE")).thenReturn(sourcePallet); + when(physical.resolve("TARGET")).thenReturn(targetPallet); + when(physical.resolveLocation(eq(88L), anyLong())).thenAnswer(invocation -> + new PalletPhysicalGateway.LocationReference(invocation.getArgument(1), "LOC-" + invocation.getArgument(1), + "STORAGE", "ACTIVE")); + when(physical.apply(eq(88L), any(), anyString(), anyLong(), anyString(), any())) + .thenAnswer(invocation -> transition(invocation.getArgument(1), invocation.getArgument(2))); + + RelabelApplication application = new RelabelApplication(); application.setId(600L); + application.setCustomerId("100"); application.setRelabelNo("RL-1"); application.setStatus("READY_FOR_OUTBOUND"); + application.setOverseasWarehouseId("88"); application.setOutboundOrderNo("OUT-1"); + when(relabelApplications.findAnyByRelabelNo("RL-1")).thenReturn(application); + when(relabelApplications.lockAnyByRelabelNo("RL-1")).thenReturn(application); + RelabelItem selected = relabelItem(701L, "CARTON-A", 100L); + when(relabelItems.selectList(any())).thenReturn(List.of(selected)); + + InventoryPosition source = position(100L, 20L, "SOURCE", 2L, 3L); + AtomicReference target = new AtomicReference<>(); + when(positions.selectById(anyLong())).thenAnswer(invocation -> { + long id = invocation.getArgument(0); + return id == 100L ? source : target.get(); + }); + doAnswer(invocation -> { target.set(invocation.getArgument(0)); return 1; }).when(positions) + .insert(any(InventoryPosition.class)); + PalletInventoryRow held = row(100L, 20L, 3L, 0L); held.setPalletNo("SOURCE"); held.setHoldPackages(2L); + when(mapper.content("88", 20L)).thenReturn(List.of(held)); + when(mapper.content("88", 10L)).thenReturn(List.of()); + when(mapper.lockPosition("88", 100L)).thenReturn(held); + when(mapper.palletPackages("88", 20L)).thenReturn(2L, 1L); + when(mapper.palletPackages("88", 10L)).thenReturn(0L, 1L); + InventoryBalance balance = new InventoryBalance(); balance.setId(200L); balance.setCustomerId("100"); + balance.setOverseasWarehouseId("88"); when(balances.selectById(200L)).thenReturn(balance); + + 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()); + + var result = service.relabelDisassemble(identity(), new PalletOperationRequests.RelabelDisassemble( + "relabel-move-1", "RL-1", new PalletOperationRequests.PalletExpectation("SOURCE", 1), + new PalletOperationRequests.PalletExpectation("TARGET", 2), List.of("CARTON-A"), "split")); + + assertEquals("COMPLETED", result.status()); + assertEquals(1L, source.getHoldPackages()); + assertEquals(0L, source.getAvailablePackages()); + assertNotNull(target.get()); assertEquals(1L, target.get().getHoldPackages()); + verify(relabelPalletMutations).moveSelectedItems(eq(application), anyMap(), argThat(targets -> + targets.size() == 1 && targets.get(100L) == target.get())); + verify(balances, never()).updateById(any(InventoryBalance.class)); + assertEquals("RELABEL_PALLET_DISASSEMBLE", operation.get().getOperationType()); + } + + @Test void relabelDisassembleReplayDoesNotRevalidateChangedInventoryOrPallets() throws Exception { + var request = new PalletOperationRequests.RelabelDisassemble("relabel-replay", "RL-1", + new PalletOperationRequests.PalletExpectation("SOURCE", 1), + new PalletOperationRequests.PalletExpectation("TARGET", 2), List.of("CARTON-A"), "split"); + PdaPalletOperation existing = new PdaPalletOperation(); existing.setId(900L); + existing.setOperationType("RELABEL_PALLET_DISASSEMBLE"); existing.setStatus("COMPLETED"); + existing.setRequestHash(ReflectionTestUtils.invokeMethod(service, "hash", request)); + var stored = new com.baiyue.lastleg.palletops.dto.PalletOperationResponses.Operation( + 900L, "RELABEL_PALLET_DISASSEMBLE", "relabel-replay", "COMPLETED", false, + LocalDateTime.now(), List.of()); + existing.setResultJson(new ObjectMapper().findAndRegisterModules().writeValueAsString(stored)); + when(mapper.findOperation("7", "relabel-replay")).thenReturn(existing); + + var replay = service.relabelDisassemble(identity(), request); + + assertTrue(replay.replayed()); + verifyNoInteractions(physical, relabelApplications, relabelItems, positions, balances, ledgers, + relabelPalletMutations); + } + 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, @@ -178,6 +266,21 @@ class PdaPalletOperationServiceTest { value.setPickedPackages(0L); value.setReceivingPackages(0L); value.setVersion(version); return value; } + private static InventoryPosition position(long id, long palletId, String palletNo, long hold, long version) { + InventoryPosition value = new InventoryPosition(); value.setId(id); value.setCustomerId("100"); + value.setBalanceId(200L); value.setOverseasWarehouseId("88"); value.setPalletId(palletId); + value.setPalletNo(palletNo); value.setBatchNo("DEFAULT"); value.setWarehouseLocationId(1020L); + value.setWarehouseLocationCode("LOC-1020"); value.setAvailablePackages(0L); value.setHoldPackages(hold); + value.setAllocatedPackages(0L); value.setPickedPackages(0L); value.setReceivingPackages(0L); + value.setStatus("OCCUPIED"); value.setVersion(version); return value; + } + + private static RelabelItem relabelItem(long id, String cartonMark, long positionId) { + RelabelItem value = new RelabelItem(); value.setId(id); value.setApplicationId(600L); value.setCustomerId("100"); + value.setCartonMark(cartonMark); value.setNotifiedPackageCount(1); value.setSourceInventoryPositionId(positionId); + value.setDestinationFbaWarehouseCode("ONT8"); return value; + } + private static final class NoOpTransactionManager extends AbstractPlatformTransactionManager { @Override protected Object doGetTransaction() { return new Object(); } @Override protected void doBegin(Object transaction, TransactionDefinition definition) { } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java index 4a97949a5..c1611680a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java @@ -50,6 +50,13 @@ class RelabelContractTest { "source_warehouse_code", "source_warehouse_name", "idx_ll_relabel_item_source_position" }) { assertTrue(deployment.contains(field), () -> "deployment SQL must include " + field); } + for (String field : new String[] { "delivery_method", "print_shipping_label", "shipping_label_file_id", + "shipping_label_file_name", "shipping_label_file_url", "fulfillment_status", "outbound_order_no", + "customer_confirmation_key", "customer_confirmed_at" }) { + assertTrue(baseline.contains(field), () -> "baseline SQL must include " + field); + assertTrue(migration.contains(field), () -> "migration SQL must include " + field); + assertTrue(deployment.contains(field), () -> "deployment SQL must include " + field); + } } @Test @@ -63,6 +70,19 @@ class RelabelContractTest { assertTrue(openApi.contains("platform:overseas-warehouse:relabel-order:list")); assertTrue(openApi.contains("platform:overseas-warehouse:relabel-order:create")); assertTrue(openApi.contains("platform:overseas-warehouse:relabel-order:status")); + for (String path : new String[] { + "/api/merchant/v1/last-leg/relabel-applications/inventory-candidates:", + "/api/merchant/v1/last-leg/relabel-applications:", + "/api/merchant/v1/last-leg/relabel-applications/{relabelNo}:", + "/api/merchant/v1/last-leg/relabel-applications/{relabelNo}/shipping-label:", + "/api/merchant/v1/last-leg/relabel-applications/{relabelNo}/shipping-label/{fileId}/download:", + "/api/merchant/v1/last-leg/relabel-applications/{relabelNo}/completion-photos/{fileId}/download:", + "/api/merchant/v1/last-leg/relabel-applications/{relabelNo}/confirm-completion:" }) { + assertTrue(openApi.contains(path), () -> "merchant relabel path missing: " + path); + } + assertTrue(openApi.contains("RelabelMerchantPageResponse")); + assertTrue(openApi.contains("RelabelCustomerConfirm")); + assertTrue(openApi.contains("merchant:warehouse:relabel")); String relabelPaths = openApi.substring(openApi.indexOf("/api/platform/v1/last-leg/relabel-applications:"), openApi.indexOf("components:")); assertTrue(relabelPaths.contains("name: customerId, in: query, required: true")); 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 index 8ee0d9732..7e9c54b15 100644 --- 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 @@ -182,7 +182,7 @@ class PdaRelabelServiceTest { new PdaRelabelRequests.Review("APPROVE", 2, "review-1", "ok")); assertEquals("COMPLETED", result.taskStatus()); - assertEquals("COMPLETED", result.applicationStatus()); + assertEquals(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, result.applicationStatus()); verify(applicationService).changeStatus(eq("100"), eq("FBR-1"), eq("8"), any(RelabelRequests.ChangeStatus.class)); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java index 69ea0dc47..1e733b97a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java @@ -22,6 +22,12 @@ import com.baiyue.lastleg.inventory.entity.InventoryBalance; import com.baiyue.lastleg.inventory.entity.InventoryPosition; import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.delivery.entity.OutboundOrder; +import com.baiyue.lastleg.delivery.entity.OutboundPalletItem; +import com.baiyue.lastleg.delivery.entity.OutboundPdaTask; +import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundPdaTaskMapper; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -60,6 +66,9 @@ class RelabelApplicationServiceTest { @Mock InventoryCommandService inventoryCommands; @Mock InventoryBalanceMapper inventoryBalances; @Mock InventoryPositionMapper inventoryPositions; + @Mock OutboundOrderMapper outboundOrders; + @Mock OutboundPalletItemMapper outboundItems; + @Mock OutboundPdaTaskMapper outboundTasks; @Spy LastlegOrderNoGenerator orderNoGenerator = new LastlegOrderNoGenerator(); private RelabelApplicationService service; @@ -77,6 +86,9 @@ class RelabelApplicationServiceTest { ReflectionTestUtils.setField(service, "inventoryCommands", inventoryCommands); ReflectionTestUtils.setField(service, "inventoryBalances", inventoryBalances); ReflectionTestUtils.setField(service, "inventoryPositions", inventoryPositions); + ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders); + ReflectionTestUtils.setField(service, "outboundItems", outboundItems); + ReflectionTestUtils.setField(service, "outboundTasks", outboundTasks); ReflectionTestUtils.setField(service, "objectMapper", mapper()); lenient().when(walletCommands.publish(any(), any(Long.class), any(), any(), any(), any(), any(Long.class))).thenReturn("wallet-event-1"); lenient().when(inventoryPositions.findTenantPosition(any(), any())).thenAnswer(invocation -> { @@ -344,7 +356,7 @@ class RelabelApplicationServiceTest { @Test void completedReturnRelabelOrderEntersPendingAssignment() { - RelabelApplication application = application(11L, "FBR-1", "10001", "key-1", "hash", "PROCESSING"); + RelabelApplication application = application(11L, "FBR-1", "10001", "key-1", "hash", RelabelApplicationService.READY_FOR_OUTBOUND); application.setLastlegOrderId(21L); LastlegOrder order = order(21L, "10001", "LMO-RELABEL-1", "PROCESSING"); order.setOrderType("RETURN_RELABEL"); @@ -362,7 +374,7 @@ class RelabelApplicationServiceTest { @Test void completingPartialRelabelReleasesOnlyNotifiedPackages() { - RelabelApplication application = application(11L, "FBR-PARTIAL", "10001", "key-partial", "hash", "PROCESSING"); + RelabelApplication application = application(11L, "FBR-PARTIAL", "10001", "key-partial", "hash", RelabelApplicationService.READY_FOR_OUTBOUND); application.setLastlegOrderId(21L); LastlegOrder order = order(21L, "10001", "LMO-RELABEL-PARTIAL", "PROCESSING"); when(applications.lockNo("10001", "FBR-PARTIAL")).thenReturn(application); @@ -380,6 +392,80 @@ class RelabelApplicationServiceTest { assertEquals(6L, move.getValue().packages()); } + @Test + void truckCustomerConfirmationIsIdempotentAndWaitsForPreDispatch() { + RelabelApplication application = confirmationApplication("TRUCK", false); + when(applications.lockNo("10001", "FBR-1")).thenReturn(application); + stubDetail(application); + + service.confirmCompletion("10001", "FBR-1", "customer-1", + new RelabelRequests.CustomerConfirm("confirm-1", "photos accepted")); + + assertEquals(RelabelApplicationService.READY_FOR_OUTBOUND, application.getStatus()); + assertEquals("WAIT_PRE_DISPATCH", application.getFulfillmentStatus()); + assertNull(application.getOutboundOrderNo()); + verify(outboundOrders, never()).insert(any(OutboundOrder.class)); + + service.confirmCompletion("10001", "FBR-1", "customer-1", + new RelabelRequests.CustomerConfirm("confirm-1", "photos accepted")); + verify(applications, times(1)).updateById(application); + } + + @Test + void expressCustomerConfirmationRequiresShippingLabelWhenWarehousePrintingIsSelected() { + RelabelApplication application = confirmationApplication("EXPRESS", true); + when(applications.lockNo("10001", "FBR-1")).thenReturn(application); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> + service.confirmCompletion("10001", "FBR-1", "customer-1", + new RelabelRequests.CustomerConfirm("confirm-express", null))); + + assertEquals(409, exception.getStatusCode().value()); + verify(outboundOrders, never()).insert(any(OutboundOrder.class)); + verify(applications, never()).updateById(application); + } + + @Test + void pickupCustomerConfirmationCreatesOneExactCartonOutbound() { + RelabelApplication application = confirmationApplication("PICKUP", false); + RelabelItem source = item(31L, application.getId(), "SKU-A", 1, 1); + source.setSourceInventoryPositionId(201L); + source.setSourceInboundCode("CODE-1"); + source.setCartonMark("BOX-1"); + source.setDestinationFbaWarehouseCode("ONT8"); + InventoryPosition position = new InventoryPosition(); + position.setId(201L); position.setCustomerId("10001"); position.setPalletId(501L); position.setPalletNo("PALLET-1"); + when(applications.lockNo("10001", "FBR-1")).thenReturn(application); + when(applications.findNo("10001", "FBR-1")).thenReturn(application); + when(items.selectList(any())).thenReturn(List.of(source)); + when(histories.selectList(any())).thenReturn(List.of()); + when(orders.selectById(21L)).thenReturn(order(21L, "10001", "LMO-RELABEL-1", "PENDING_ASSIGNMENT")); + when(inventoryPositions.selectById(201L)).thenReturn(position); + + service.confirmCompletion("10001", "FBR-1", "customer-1", + new RelabelRequests.CustomerConfirm("confirm-pickup", null)); + + assertEquals("OUTBOUND_CREATED", application.getFulfillmentStatus()); + assertNotNull(application.getOutboundOrderNo()); + ArgumentCaptor outbound = ArgumentCaptor.forClass(OutboundOrder.class); + verify(outboundOrders, times(1)).insert(outbound.capture()); + assertEquals("RELABEL_OUTBOUND", outbound.getValue().getOutboundType()); + ArgumentCaptor outboundItem = ArgumentCaptor.forClass(OutboundPalletItem.class); + verify(outboundItems, times(1)).insert(outboundItem.capture()); + assertEquals("BOX-1", outboundItem.getValue().getCartonMarks()); + assertEquals("PALLET-1", outboundItem.getValue().getPalletNo()); + verify(outboundTasks, times(1)).insert(any(OutboundPdaTask.class)); + } + + private RelabelApplication confirmationApplication(String deliveryMethod, boolean printShippingLabel) { + RelabelApplication application = application(11L, "FBR-1", "10001", "key-1", "hash", + RelabelApplicationService.CUSTOMER_CONFIRM_PENDING); + application.setLastlegOrderId(21L); + application.setDeliveryMethod(deliveryMethod); + application.setPrintShippingLabel(printShippingLabel); + return application; + } + private void stubDetail(RelabelApplication application) { when(applications.findNo(application.getCustomerId(), application.getRelabelNo())).thenReturn(application); when(items.selectList(any())).thenReturn(List.of(item(31L, application.getId(), "SKU-A", 10, 6))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelPalletMutationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelPalletMutationServiceTest.java new file mode 100644 index 000000000..a62325be6 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelPalletMutationServiceTest.java @@ -0,0 +1,80 @@ +package com.baiyue.lastleg.relabel.service; + +import com.baiyue.lastleg.delivery.entity.OutboundOrder; +import com.baiyue.lastleg.delivery.entity.OutboundPalletItem; +import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; +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 java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class RelabelPalletMutationServiceTest { + @Mock private RelabelItemMapper relabelItems; + @Mock private InventoryPositionMapper positions; + @Mock private OutboundOrderMapper outboundOrders; + @Mock private OutboundPalletItemMapper outboundItems; + private RelabelPalletMutationService service; + + @BeforeEach void setUp() { + service = new RelabelPalletMutationService(); + ReflectionTestUtils.setField(service, "relabelItems", relabelItems); + ReflectionTestUtils.setField(service, "positions", positions); + ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders); + ReflectionTestUtils.setField(service, "outboundItems", outboundItems); + ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); + } + + @Test void partialCartonMoveSplitsOutboundSnapshotAndKeepsUnselectedCartonOnSource() { + RelabelApplication application = new RelabelApplication(); application.setId(10L); + application.setCustomerId("100"); application.setRelabelNo("RL-1"); application.setOutboundOrderNo("OUT-1"); + RelabelItem selected = item(20L, "CARTON-A", 101L); + RelabelItem remaining = item(21L, "CARTON-B", 100L); + InventoryPosition target = new InventoryPosition(); target.setId(101L); target.setPalletId(11L); target.setPalletNo("TARGET"); + OutboundOrder outbound = new OutboundOrder(); outbound.setId(30L); outbound.setCustomerId("100"); + outbound.setOutboundNo("OUT-1"); outbound.setOutboundType("RELABEL_OUTBOUND"); outbound.setStatus("WAIT_PICK"); + OutboundPalletItem source = new OutboundPalletItem(); source.setId(31L); source.setCustomerId("100"); + source.setOutboundOrderId(30L); source.setWarehousePalletId(10L); source.setPalletNo("SOURCE"); + source.setPackageCount(2); source.setPickedPackageCount(0); source.setStatus("WAIT_PICK"); + source.setCartonMarks("CARTON-A,CARTON-B"); + when(outboundOrders.lockByNo("100", "OUT-1")).thenReturn(outbound); + when(outboundItems.findByOutbound("100", 30L)).thenReturn(List.of(source)); + when(relabelItems.selectList(any())).thenReturn(List.of(remaining)); + + service.moveSelectedItems(application, Map.of(100L, List.of(selected)), Map.of(100L, target)); + + assertEquals(101L, selected.getSourceInventoryPositionId()); + verify(relabelItems).updateById(selected); + assertEquals("CARTON-B", source.getCartonMarks()); assertEquals(1, source.getPackageCount()); + ArgumentCaptor split = ArgumentCaptor.forClass(OutboundPalletItem.class); + verify(outboundItems).insert(split.capture()); + assertEquals("CARTON-A", split.getValue().getCartonMarks()); + assertEquals(11L, split.getValue().getWarehousePalletId()); + assertEquals(1, split.getValue().getPackageCount()); + assertEquals(2, outbound.getPalletCount()); + } + + private static RelabelItem item(long id, String mark, long positionId) { + RelabelItem value = new RelabelItem(); value.setId(id); value.setApplicationId(10L); value.setCustomerId("100"); + value.setCartonMark(mark); value.setNotifiedPackageCount(1); value.setSourceInventoryPositionId(positionId); + value.setDestinationFbaWarehouseCode("ONT8"); return value; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java index b9135807a..6f3fbaea6 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java @@ -1,9 +1,8 @@ package com.baiyue.lastleg.transfer.service; import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegInboundOrder; import com.baiyue.lastleg.relabel.entity.RelabelApplication; -import com.baiyue.lastleg.relabel.entity.RelabelItem; -import com.baiyue.lastleg.relabel.entity.RelabelStatusHistory; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; @@ -12,16 +11,17 @@ import com.baiyue.lastleg.inventory.entity.InventoryPosition; import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import com.baiyue.lastleg.mapper.LastlegInboundOrderMapper; import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; -import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; -import com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper; import com.baiyue.lastleg.service.LastlegOrderNoGenerator; import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests; import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferCarton; import com.baiyue.lastleg.transfer.entity.WarehouseTransferInventoryAllocation; import com.baiyue.lastleg.transfer.entity.WarehouseTransferSourceOrder; import com.baiyue.lastleg.transfer.mapper.InboundSnapshotMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferItemMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferCartonMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferInventoryAllocationMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferReceiptMapper; @@ -58,21 +58,22 @@ import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.times; @ExtendWith(MockitoExtension.class) class WarehouseTransferServiceTest { @Mock WarehouseTransferOrderMapper transfers; @Mock WarehouseTransferItemMapper transferItems; + @Mock WarehouseTransferCartonMapper transferCartons; @Mock WarehouseTransferSourceOrderMapper sourceOrders; @Mock WarehouseTransferInventoryAllocationMapper inventoryAllocations; @Mock WarehouseTransferReceiptMapper transferReceipts; @Mock InboundSnapshotMapper inboundSnapshots; + @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderMapper orders; @Mock LastlegOrderDetailMapper orderDetails; @Mock LastlegOrderStatusHistoryMapper orderHistories; @Mock RelabelApplicationMapper relabelApplications; - @Mock RelabelItemMapper relabelItems; - @Mock RelabelStatusHistoryMapper relabelHistories; @Spy LastlegOrderNoGenerator orderNoGenerator = new LastlegOrderNoGenerator(); @Mock InventoryCommandService inventory; @Mock InventoryCommandMapper inventoryCommands; @@ -83,16 +84,16 @@ class WarehouseTransferServiceTest { service = new WarehouseTransferService(); ReflectionTestUtils.setField(service, "transfers", transfers); ReflectionTestUtils.setField(service, "transferItems", transferItems); + ReflectionTestUtils.setField(service, "transferCartons", transferCartons); ReflectionTestUtils.setField(service, "sourceOrders", sourceOrders); ReflectionTestUtils.setField(service, "inventoryAllocations", inventoryAllocations); ReflectionTestUtils.setField(service, "transferReceipts", transferReceipts); ReflectionTestUtils.setField(service, "inboundSnapshots", inboundSnapshots); + ReflectionTestUtils.setField(service, "inboundOrders", inboundOrders); ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "orderDetails", orderDetails); ReflectionTestUtils.setField(service, "orderHistories", orderHistories); ReflectionTestUtils.setField(service, "relabelApplications", relabelApplications); - ReflectionTestUtils.setField(service, "relabelItems", relabelItems); - ReflectionTestUtils.setField(service, "relabelHistories", relabelHistories); ReflectionTestUtils.setField(service, "orderNoGenerator", orderNoGenerator); ReflectionTestUtils.setField(service, "inventory", inventory); ReflectionTestUtils.setField(service, "inventoryCommands", inventoryCommands); @@ -101,10 +102,11 @@ class WarehouseTransferServiceTest { .thenReturn("WT-WH-20260901-000001"); lenient().when(inventoryAllocations.selectList(any())).thenReturn(List.of()); lenient().when(transferReceipts.selectList(any())).thenReturn(List.of()); + lenient().when(transferCartons.listOrder(anyString(), anyLong())).thenReturn(List.of()); } @Test - void relabelTransferLinksHistoricalOrderAndInboundAndCreatesOnePendingReviewTailOrder() { + void relabelTransferOnlyMarksPendingBindingUntilInboundIsComplete() { WarehouseTransferRequests.Create request = request(true); LastlegOrder historical = historicalOrder(); when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L); @@ -117,18 +119,15 @@ class WarehouseTransferServiceTest { ArgumentCaptor tailOrder = ArgumentCaptor.forClass(LastlegOrder.class); verify(orders).insert(tailOrder.capture()); - assertEquals(WarehouseTransferService.RETURN_RELABEL_ORDER_TYPE, tailOrder.getValue().getOrderType()); - assertEquals("PENDING_ASSIGNMENT", tailOrder.getValue().getCurrentStatus()); - assertEquals("WAREHOUSE_TRANSFER:PENDING_REVIEW;RELABEL:PENDING_PROCESSING", tailOrder.getValue().getSubOrderStatus()); + assertEquals(WarehouseTransferService.ORDER_TYPE, tailOrder.getValue().getOrderType()); + assertEquals(WarehouseTransferService.PENDING_REVIEW, tailOrder.getValue().getCurrentStatus()); + assertEquals("WAREHOUSE_TRANSFER:PENDING_REVIEW;RELABEL:PENDING_BINDING", tailOrder.getValue().getSubOrderStatus()); assertTrue(tailOrder.getValue().getOrderNo().matches("WT-WH-\\d{8}-\\d{6}")); assertNull(tailOrder.getValue().getTransferWarehouseId()); verify(sourceOrders).insert(any(WarehouseTransferSourceOrder.class)); - verify(relabelApplications).insert(any(RelabelApplication.class)); - verify(relabelItems).insert(any(RelabelItem.class)); - verify(relabelHistories).insert(any(RelabelStatusHistory.class)); - verify(orders).updateById(tailOrder.getValue()); - assertNotNull(tailOrder.getValue().getRelabelOrderNo()); - assertEquals("PENDING_PROCESSING", tailOrder.getValue().getRelabelStatus()); + verify(relabelApplications, never()).insert(any(RelabelApplication.class)); + assertNull(tailOrder.getValue().getRelabelOrderNo()); + assertEquals("PENDING_BINDING", tailOrder.getValue().getRelabelStatus()); assertEquals(WarehouseTransferService.PENDING_REVIEW, result.status()); assertEquals(WarehouseTransferService.PENDING_REVIEW, result.orderStatus()); } @@ -144,11 +143,71 @@ class WarehouseTransferServiceTest { var result = service.create("TENANT-A", "USER-1", request(false)); verify(relabelApplications, never()).insert(any(RelabelApplication.class)); - verify(relabelItems, never()).insert(any(RelabelItem.class)); - verify(relabelHistories, never()).insert(any(RelabelStatusHistory.class)); assertNull(result.relabelNo()); } + @Test + void transferCanEnterTheTransitWarehouseBeforeFbaDestinationIsKnown() { + WarehouseTransferRequests.Create request = new WarehouseTransferRequests.Create( + "IN-1", List.of(11L), "WH-1", null, "123 Pickup Road", 5, + new BigDecimal("0.2500"), "WAREHOUSE_PICKUP", LocalDateTime.of(2026, 9, 12, 10, 0), + null, null, null, "FBA destination will be supplied later", false, "key-no-fba", + List.of(new WarehouseTransferRequests.Item("SKU-1", "Product", 5, "MARK-1"))); + when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L); + when(orders.selectList(any())).thenReturn(List.of(historicalOrder())); + when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> persistedTransfer()); + when(transferItems.selectList(any())).thenReturn(List.of()); + when(sourceOrders.selectList(any())).thenReturn(List.of()); + + service.create("TENANT-A", "USER-1", request); + + ArgumentCaptor transfer = ArgumentCaptor.forClass(WarehouseTransferOrder.class); + ArgumentCaptor tailOrder = ArgumentCaptor.forClass(LastlegOrder.class); + verify(transfers).insert(transfer.capture()); + verify(orders).insert(tailOrder.capture()); + assertNull(transfer.getValue().getFbaWarehouseCode()); + assertNull(transfer.getValue().getFbaShipmentNo()); + assertNull(tailOrder.getValue().getFbaWarehouseCode()); + assertEquals("WAREHOUSE_PICKUP", transfer.getValue().getDeliveryMethod()); + } + + @Test + void newTransitInboundCreatesSnapshotAndImmutableCartonMarksWithoutHistoricalInventory() { + WarehouseTransferRequests.Create request = new WarehouseTransferRequests.Create( + null, null, "WH-1", null, "123 Pickup Road", 5, + new BigDecimal("0.2500"), "WAREHOUSE_PICKUP", LocalDateTime.of(2026, 9, 12, 10, 0), + null, "TRACK-MUST-NOT-BE-SAVED", null, "Incoming transit cargo", true, "key-incoming", + List.of(new WarehouseTransferRequests.Item("SKU-1", "Product A", 2, null), + new WarehouseTransferRequests.Item("SKU-2", "Product B", 3, null))); + when(transfers.findNo(eq("TENANT-A"), anyString())).thenAnswer(invocation -> persistedTransfer()); + when(transferItems.selectList(any())).thenReturn(List.of()); + when(sourceOrders.selectList(any())).thenReturn(List.of()); + + service.create("TENANT-A", "USER-1", request); + + ArgumentCaptor inbound = ArgumentCaptor.forClass(LastlegInboundOrder.class); + ArgumentCaptor transfer = ArgumentCaptor.forClass(WarehouseTransferOrder.class); + ArgumentCaptor cartons = ArgumentCaptor.forClass(WarehouseTransferCarton.class); + ArgumentCaptor tailOrder = ArgumentCaptor.forClass(LastlegOrder.class); + verify(inboundOrders).insert(inbound.capture()); + verify(transfers).insert(transfer.capture()); + verify(transferCartons, times(5)).insert(cartons.capture()); + verify(orders).insert(tailOrder.capture()); + verify(sourceOrders, never()).insert(any(WarehouseTransferSourceOrder.class)); + verify(relabelApplications, never()).insert(any(RelabelApplication.class)); + + assertEquals(transfer.getValue().getTransferNo(), transfer.getValue().getInboundCode()); + assertEquals(transfer.getValue().getTransferNo(), inbound.getValue().getInboundCode()); + assertEquals("OVERSEAS", inbound.getValue().getDestinationType()); + assertEquals(WarehouseTransferService.PENDING_REVIEW, inbound.getValue().getStatus()); + assertNull(transfer.getValue().getTrackingNo()); + assertEquals("PENDING_BINDING", tailOrder.getValue().getRelabelStatus()); + assertEquals(List.of(1, 2, 3, 4, 5), cartons.getAllValues().stream() + .map(WarehouseTransferCarton::getCartonIndex).toList()); + assertTrue(cartons.getAllValues().stream().allMatch(value -> value.getCartonMark() + .matches("WT-WH-\\d{8}-\\d{6}#5-[1-5]"))); + } + @Test void approvedTransferWithoutRelabelEntersPendingAssignment() { WarehouseTransferOrder transfer = persistedTransfer(); diff --git a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json index 9fce02c05..b37fa3273 100644 --- a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json +++ b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json @@ -5,6 +5,7 @@ "dispatch-delivery->dropship", "dispatch-delivery->inbound-pda", "dispatch-delivery->inventory", + "dispatch-delivery->relabel-transfer-discard", "dispatch-delivery->shared-technical", "dispatch-delivery->tailleg-handoff", "dropship->inbound-pda", @@ -12,6 +13,7 @@ "dropship->tailleg-handoff", "inbound-pda->dropship", "inbound-pda->inventory", + "inbound-pda->relabel-transfer-discard", "inbound-pda->shared-technical", "inbound-pda->tailleg-handoff", "inventory->inbound-pda", @@ -20,7 +22,9 @@ "procurement-trucking-looseboard->overseas-master-data", "procurement-trucking-looseboard->shared-technical", "procurement-trucking-looseboard->tailleg-handoff", + "relabel-transfer-discard->dispatch-delivery", "relabel-transfer-discard->dropship", + "shared-technical->tailleg-handoff", "relabel-transfer-discard->inventory", "relabel-transfer-discard->shared-technical", "relabel-transfer-discard->tailleg-handoff", diff --git a/scripts/README.md b/scripts/README.md index 36fa19d82..89c9100e9 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -89,6 +89,7 @@ Client 全量、Client 入库测量与计费、待审核订单和批量待分柜 | 契约数据 | `synchronous-dependency-baseline.mjs` | 同步服务依赖合同的数据源,只由相邻合同测试导入,不是 CLI | | OpenAPI | `openapi-validation/validate-openapi.mjs` | 发现并解析各服务 OpenAPI,检查跨文件引用;只读源码 | | 本地环境 | `local-env.ps1` | 本地开发环境公共参数/辅助函数,由其他 PowerShell 脚本加载 | +| 本地环境 | `start-gateway-overseas-local.ps1` | 仅启动本地 gateway-overseas,并将 Lastleg IAM 地址固定为 `127.0.0.1:9303`;会启动进程但不修改数据库 | | 本地环境 | `publish-local-nacos-config.ps1` | 将 `config/nacos/local/*.yml` 发布到指定本地 Nacos,会修改 Nacos 配置 | | Nacos 环境选择 | `use-nacos-environment.ps1` | 按显式环境与区域设置当前 PowerShell 进程的 Nacos、Spring Profile 和运行区域变量;开发/测试密码必须由参数或环境变量提供,不发布配置 | | Nacos 配置发布 | `publish-nacos-config.ps1` | 将 `config/nacos/{local|cn|overseas}/*.yml` 发布到显式指定的 Nacos;非本地环境强制要求非 public namespace,会修改目标 Nacos 配置,执行前须取得对应环境授权 | diff --git a/scripts/db/dev-lastleg-relabel-return-incremental.sql b/scripts/db/dev-lastleg-relabel-return-incremental.sql new file mode 100644 index 000000000..df72b23b4 --- /dev/null +++ b/scripts/db/dev-lastleg-relabel-return-incremental.sql @@ -0,0 +1,96 @@ +-- 尾程贴标申请 / 退货换标 dev 增量(MySQL 8.0) +-- 目标数据库:baiyue_lastleg +-- 依据:2026-09-09 本地 127.0.0.1 与 dev 8.163.49.32 的 information_schema 只读对比。 +-- 说明:脚本可重复执行,不删除或修改业务行;DDL 会隐式提交,请先备份并完整执行。 + +CREATE DATABASE IF NOT EXISTS baiyue_lastleg + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE baiyue_lastleg; + +DROP PROCEDURE IF EXISTS _dev_lastleg_relabel_return_patch; +DELIMITER $$ +CREATE PROCEDURE _dev_lastleg_relabel_return_patch() +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables + WHERE table_schema=DATABASE() AND table_name='ll_relabel_item') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少基础表 ll_relabel_item,请先执行海外尾程基线'; + END IF; + + -- 贴标申请:FBA目的仓、换标模式、SKU组合明细及原库存快照。 + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='destination_fba_warehouse_code') THEN + ALTER TABLE ll_relabel_item ADD COLUMN destination_fba_warehouse_code VARCHAR(128) NULL COMMENT 'FBA目的仓编码(与海外操作仓分离)'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='operation_type') THEN + ALTER TABLE ll_relabel_item ADD COLUMN operation_type VARCHAR(32) NOT NULL DEFAULT 'CARTON_LABEL' COMMENT '换标模式:箱唛、SKU或组合换标'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='sku_relabel_items_json') THEN + ALTER TABLE ll_relabel_item ADD COLUMN sku_relabel_items_json JSON NULL COMMENT '同箱SKU换标子明细快照(数量按件)'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_inventory_balance_id') THEN + ALTER TABLE ll_relabel_item ADD COLUMN source_inventory_balance_id BIGINT NULL COMMENT '原库存余额ID'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_inventory_position_id') THEN + ALTER TABLE ll_relabel_item ADD COLUMN source_inventory_position_id BIGINT NULL COMMENT '原库存库位ID'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_inbound_order_id') THEN + ALTER TABLE ll_relabel_item ADD COLUMN source_inbound_order_id BIGINT NULL COMMENT '原入仓单ID'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_inbound_order_no') THEN + ALTER TABLE ll_relabel_item ADD COLUMN source_inbound_order_no VARCHAR(128) NULL COMMENT '原入仓单号快照'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_inbound_code') THEN + ALTER TABLE ll_relabel_item ADD COLUMN source_inbound_code VARCHAR(128) NULL COMMENT '原进仓编码快照'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_warehouse_id') THEN + ALTER TABLE ll_relabel_item ADD COLUMN source_warehouse_id VARCHAR(128) NULL COMMENT '原海外中转仓ID'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_warehouse_code') THEN + ALTER TABLE ll_relabel_item ADD COLUMN source_warehouse_code VARCHAR(128) NULL COMMENT '原海外中转仓编码快照'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_warehouse_name') THEN + ALTER TABLE ll_relabel_item ADD COLUMN source_warehouse_name VARCHAR(255) NULL COMMENT '原海外中转仓名称快照'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND index_name='idx_ll_relabel_item_source_position') THEN + ALTER TABLE ll_relabel_item ADD KEY idx_ll_relabel_item_source_position (customer_id,source_inventory_position_id); + END IF; + + -- 退货换标:签收箱出库时不要求库存仓位,并允许箱状态 OUTBOUNDED。 + IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pda_pick' AND column_name='inventory_position_id' AND is_nullable='NO') THEN + ALTER TABLE ll_outbound_pda_pick MODIFY COLUMN inventory_position_id BIGINT NULL COMMENT '库存仓位ID,退货箱唛出库为空'; + END IF; + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity') THEN + ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity') THEN + ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK ((status IN ('AVAILABLE','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)); + END IF; + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status') THEN + ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_status; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status') THEN + ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK (status IN ('AVAILABLE','MERGED','OUTBOUNDED')); + END IF; + + -- 出库箱唛快照:用于退货箱唛出库指定箱号。 + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='carton_marks') THEN + ALTER TABLE ll_outbound_pallet_item ADD COLUMN carton_marks VARCHAR(4000) NULL COMMENT '本次出库指定箱唛,逗号分隔'; + END IF; + + -- 本地已有、dev 缺失的海外采购出库记录表。 + CREATE TABLE IF NOT EXISTS ll_overseas_procurement_outbound ( + id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', quantity BIGINT NOT NULL COMMENT '数量', + outbound_no VARCHAR(128) NOT NULL COMMENT '出库单号', carrier VARCHAR(128) NULL COMMENT '承运商', tracking_no VARCHAR(128) NULL COMMENT '物流跟踪号', outbound_at DATETIME(3) NOT NULL COMMENT '出库时间', + remark VARCHAR(1000) NULL COMMENT '备注', created_by VARCHAR(128) NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_overseas_procurement_outbound_no(customer_id,order_id,outbound_no), KEY idx_ll_overseas_procurement_outbound_order(customer_id,order_id,outbound_at) + ) ENGINE=InnoDB COMMENT='海外采购出库记录'; +END$$ +DELIMITER ; +CALL _dev_lastleg_relabel_return_patch(); +DROP PROCEDURE IF EXISTS _dev_lastleg_relabel_return_patch; + +-- 执行后只读核验:应分别返回 1、1、1、1。 +SELECT + (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_relabel_item' AND column_name IN ('destination_fba_warehouse_code','operation_type','sku_relabel_items_json','source_inventory_balance_id','source_inventory_position_id','source_inbound_order_id','source_inbound_order_no','source_inbound_code','source_warehouse_id','source_warehouse_code','source_warehouse_name')) = 11 AS relabel_fields_ready, + (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_outbound_pallet_item' AND column_name='carton_marks') = 1 AS outbound_carton_marks_ready, + (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_lastleg' AND table_name='ll_overseas_procurement_outbound') = 1 AS procurement_outbound_ready, + (SELECT COUNT(*) FROM information_schema.table_constraints WHERE constraint_schema='baiyue_lastleg' AND table_name='ll_return_inventory_carton' AND constraint_name IN ('chk_ll_return_inventory_carton_quantity','chk_ll_return_inventory_carton_status')) = 2 AS return_outbounded_checks_ready; diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index 5bfb6d56b..6b11bb977 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -1053,7 +1053,7 @@ CREATE TABLE IF NOT EXISTS ll_relabel_item ( sku_code VARCHAR(128) NULL COMMENT 'SKU编码(选填)', product_image_file_id VARCHAR(128) NULL COMMENT '产品图片文件ID', product_image_file_name VARCHAR(255) NULL COMMENT '产品图片文件名', product_image_url VARCHAR(1000) NULL COMMENT '产品图片地址', product_name_zh VARCHAR(255) NOT NULL COMMENT '产品中文名称', total_package_count INT NOT NULL COMMENT '总箱数', notified_package_count INT NOT NULL COMMENT '已通知箱数', - shipment_no VARCHAR(128) NOT NULL COMMENT '货件编号', tracking_code VARCHAR(255) NULL COMMENT '物流跟踪编码', new_sku_code VARCHAR(128) NOT NULL COMMENT '换标后SKU编码', + shipment_no VARCHAR(128) NULL COMMENT '货件编号(无FBA去向时为空)', tracking_code VARCHAR(255) NULL COMMENT '物流跟踪编码', new_sku_code VARCHAR(128) NOT NULL COMMENT '换标后SKU编码', label_requirement VARCHAR(1000) NULL COMMENT '换标要求', remark VARCHAR(1000) NULL COMMENT '货物明细备注', relabel_file_id VARCHAR(128) NULL COMMENT '换标文件ID', relabel_file_name VARCHAR(255) NULL COMMENT '换标文件名', relabel_file_url VARCHAR(1000) NULL COMMENT '换标文件地址', carton_mark VARCHAR(255) NULL COMMENT '客户自有箱唛', fba_carton_mark VARCHAR(255) NULL COMMENT '关联FBA箱唛', destination_warehouse_id VARCHAR(128) NULL COMMENT '该箱目的海外仓ID', @@ -1172,9 +1172,9 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_order ( id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', transfer_no VARCHAR(64) NOT NULL COMMENT '转仓单号', inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', lastleg_order_id BIGINT NOT NULL COMMENT '尾程订单ID', lastleg_order_no VARCHAR(32) NOT NULL COMMENT '尾程订单号', - operating_warehouse_id VARCHAR(128) NOT NULL COMMENT '作业仓库ID', fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA仓库编码', - delivery_address VARCHAR(1000) NOT NULL COMMENT '派送地址', package_count INT NOT NULL COMMENT '箱数', delivery_method VARCHAR(64) NOT NULL COMMENT '派送方式', - estimated_arrival_at DATETIME(3) NOT NULL COMMENT '预计到达时间', fba_shipment_no VARCHAR(128) NOT NULL COMMENT 'FBA货件编号', + operating_warehouse_id VARCHAR(128) NOT NULL COMMENT '入库中转仓ID', fba_warehouse_code VARCHAR(128) NULL COMMENT '可选FBA目的仓编码', + delivery_address VARCHAR(1000) NOT NULL COMMENT '提货地址或中转仓收货地址', package_count INT NOT NULL COMMENT '箱数', delivery_method VARCHAR(64) NOT NULL COMMENT '入仓方式', + estimated_arrival_at DATETIME(3) NOT NULL COMMENT '预计入仓时间', fba_shipment_no VARCHAR(128) NULL COMMENT '可选FBA货件编号', tracking_no VARCHAR(255) NULL COMMENT '物流跟踪号', fba_box_nos VARCHAR(2000) NULL COMMENT 'FBA箱号集合', remark VARCHAR(1000) NULL COMMENT '备注', need_relabel TINYINT NOT NULL DEFAULT 0 COMMENT '是否需要换标', relabel_application_id BIGINT NULL COMMENT '换标申请单ID', relabel_no VARCHAR(64) NULL COMMENT '换标申请单号', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', @@ -1197,6 +1197,18 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_item ( created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id) ) ENGINE=InnoDB COMMENT='转仓货物冻结明细'; +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_carton ( + id BIGINT NOT NULL COMMENT '预报箱主键', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', + transfer_item_id BIGINT NOT NULL COMMENT '转运申报明细ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + carton_mark VARCHAR(255) NOT NULL COMMENT '系统生成的一箱一码箱唛', carton_index INT NOT NULL COMMENT '全单箱序号', + total_cartons INT NOT NULL COMMENT '首次生成时全单总箱数', status VARCHAR(32) NOT NULL DEFAULT 'EXPECTED' COMMENT '箱状态:EXPECTED待实收', + 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_wh_transfer_carton_mark (tenant_id,carton_mark), + UNIQUE KEY uk_ll_wh_transfer_carton_index (transfer_order_id,carton_index), + KEY idx_ll_wh_transfer_carton_list (tenant_id,transfer_order_id,status) +) ENGINE=InnoDB COMMENT='仓储转运系统箱唛预报'; + SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND index_name='uk_ll_wh_transfer_no'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD UNIQUE KEY uk_ll_wh_transfer_no (tenant_id,transfer_no)')); PREPARE transfer_019 FROM @ddl; EXECUTE transfer_019; DEALLOCATE PREPARE transfer_019; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND index_name='uk_ll_wh_transfer_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD UNIQUE KEY uk_ll_wh_transfer_key (tenant_id,idempotency_key)')); @@ -2693,19 +2705,19 @@ 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='ll_warehouse_transfer_order' AND column_name='lastleg_order_no' AND COALESCE(column_comment,'') <> '尾程订单号'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN lastleg_order_no VARCHAR(32) 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='ll_warehouse_transfer_order' AND column_name='operating_warehouse_id' AND COALESCE(column_comment,'') <> '作业仓库ID'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN operating_warehouse_id VARCHAR(128) NOT NULL COMMENT ''作业仓库ID''', 'SELECT 1')); +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='operating_warehouse_id' AND COALESCE(column_comment,'') <> '入库中转仓ID'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN operating_warehouse_id VARCHAR(128) 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='ll_warehouse_transfer_order' AND column_name='fba_warehouse_code' AND COALESCE(column_comment,'') <> 'FBA仓库编码'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN fba_warehouse_code VARCHAR(128) NOT NULL COMMENT ''FBA仓库编码''', 'SELECT 1')); +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='fba_warehouse_code' AND (is_nullable='NO' OR COALESCE(column_comment,'') <> '可选FBA目的仓编码')), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''可选FBA目的仓编码''', '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='ll_warehouse_transfer_order' AND column_name='delivery_address' AND COALESCE(column_comment,'') <> '派送地址'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN delivery_address VARCHAR(1000) NOT NULL COMMENT ''派送地址''', 'SELECT 1')); +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='delivery_address' AND COALESCE(column_comment,'') <> '提货地址或中转仓收货地址'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN delivery_address VARCHAR(1000) 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='ll_warehouse_transfer_order' AND column_name='package_count' AND COALESCE(column_comment,'') <> '箱数'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN package_count 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='ll_warehouse_transfer_order' AND column_name='delivery_method' AND COALESCE(column_comment,'') <> '派送方式'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN delivery_method VARCHAR(64) NOT NULL COMMENT ''派送方式''', 'SELECT 1')); +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='delivery_method' AND COALESCE(column_comment,'') <> '入仓方式'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN delivery_method VARCHAR(64) 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='ll_warehouse_transfer_order' AND column_name='estimated_arrival_at' AND COALESCE(column_comment,'') <> '预计到达时间'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN estimated_arrival_at DATETIME(3) NOT NULL COMMENT ''预计到达时间''', 'SELECT 1')); +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='estimated_arrival_at' AND COALESCE(column_comment,'') <> '预计入仓时间'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN estimated_arrival_at DATETIME(3) 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='ll_warehouse_transfer_order' AND column_name='fba_shipment_no' AND COALESCE(column_comment,'') <> 'FBA货件编号'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN fba_shipment_no VARCHAR(128) NOT NULL COMMENT ''FBA货件编号''', 'SELECT 1')); +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='fba_shipment_no' AND (is_nullable='NO' OR COALESCE(column_comment,'') <> '可选FBA货件编号')), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN fba_shipment_no VARCHAR(128) NULL COMMENT ''可选FBA货件编号''', '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='ll_warehouse_transfer_order' AND column_name='tracking_no' AND COALESCE(column_comment,'') <> '物流跟踪号'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN tracking_no VARCHAR(255) NULL COMMENT ''物流跟踪号''', 'SELECT 1')); PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; @@ -3847,6 +3859,8 @@ PREPARE phase4_discard_review_stmt FROM @ddl; EXECUTE phase4_discard_review_stmt -- ============================================================================ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='destination_fba_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN destination_fba_warehouse_code VARCHAR(128) NULL COMMENT ''FBA目的仓编码(与海外操作仓分离)''')); PREPARE relabel_inventory_stmt FROM @ddl; EXECUTE relabel_inventory_stmt; DEALLOCATE PREPARE relabel_inventory_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='shipment_no' AND is_nullable='NO'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN shipment_no VARCHAR(128) NULL COMMENT ''货件编号(无FBA去向时为空)''', 'SELECT 1')); +PREPARE relabel_inventory_stmt FROM @ddl; EXECUTE relabel_inventory_stmt; DEALLOCATE PREPARE relabel_inventory_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='operation_type'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN operation_type VARCHAR(32) NOT NULL DEFAULT ''CARTON_LABEL'' COMMENT ''换标模式:箱唛、SKU或组合换标''')); PREPARE relabel_inventory_stmt FROM @ddl; EXECUTE relabel_inventory_stmt; DEALLOCATE PREPARE relabel_inventory_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='sku_relabel_items_json'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN sku_relabel_items_json JSON NULL COMMENT ''同箱SKU换标子明细快照(数量按件)''')); @@ -3870,6 +3884,26 @@ PREPARE relabel_inventory_stmt FROM @ddl; EXECUTE relabel_inventory_stmt; DEALLO SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND index_name='idx_ll_relabel_item_source_position'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD KEY idx_ll_relabel_item_source_position (customer_id,source_inventory_position_id)')); PREPARE relabel_inventory_stmt FROM @ddl; EXECUTE relabel_inventory_stmt; DEALLOCATE PREPARE relabel_inventory_stmt; +-- 2026-09-09 / 换标PDA完工、客户确认及出库分流 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='delivery_method'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN delivery_method VARCHAR(32) NULL COMMENT ''派送方式:TRUCK卡车、PICKUP自提、EXPRESS快递;仅涉及箱唛换标时必填'' AFTER contact_phone')); +PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='print_shipping_label'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN print_shipping_label TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否需要打印面单'' AFTER delivery_method')); +PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_id VARCHAR(128) NULL COMMENT ''客户上传面单文件ID'' AFTER print_shipping_label')); +PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_name'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_name VARCHAR(255) NULL COMMENT ''客户上传面单文件名'' AFTER shipping_label_file_id')); +PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_url'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_url VARCHAR(1000) NULL COMMENT ''客户上传面单下载地址'' AFTER shipping_label_file_name')); +PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='fulfillment_status'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN fulfillment_status VARCHAR(32) NOT NULL DEFAULT ''WAIT_RELABEL'' COMMENT ''换标后履约状态'' AFTER shipping_label_file_url')); +PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='outbound_order_no'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN outbound_order_no VARCHAR(64) NULL COMMENT ''自提或快递生成的换标出库单号'' AFTER fulfillment_status')); +PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='customer_confirmation_key'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN customer_confirmation_key VARCHAR(255) NULL COMMENT ''客户确认完成幂等键'' AFTER fulfillment_status')); +PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='customer_confirmed_at'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认换标完成时间'' AFTER customer_confirmation_key')); +PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt; + -- 柜级入库计划跨客户批量托盘预留、确认与失败补偿。 USE baiyue_lastlag_warehouse; CREATE TABLE IF NOT EXISTS wh_pallet_batch ( @@ -3974,8 +4008,12 @@ PREPARE outbound_carton_marks_stmt FROM @ddl; EXECUTE outbound_carton_marks_stmt -- 2026-09-07 / 032 客户退货预报、PDA 签收及仓储费起算批次。 USE baiyue_lastleg; CREATE TABLE IF NOT EXISTS ll_return_order ( - id BIGINT NOT NULL COMMENT '退货订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', return_no VARCHAR(128) NOT NULL COMMENT '退货订单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '操作海外仓ID', customer_reference_no VARCHAR(128) NULL COMMENT '客户参考号', status VARCHAR(32) NOT NULL COMMENT 'PENDING_RECEIPT、PARTIALLY_RECEIVED或COMPLETED', expected_sku_types INT NOT NULL COMMENT '预报SKU种类数', expected_quantity BIGINT NOT NULL COMMENT '预报总数量', received_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '累计实收总数量', remark VARCHAR(1000) NULL COMMENT '客户备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键', request_hash CHAR(64) NOT NULL COMMENT '创建请求SHA-256', created_by VARCHAR(128) NULL COMMENT '创建操作人ID', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_return_order_customer_no(customer_id,return_no), UNIQUE KEY uk_ll_return_order_customer_key(customer_id,idempotency_key), KEY idx_ll_return_order_warehouse_status(overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_order_quantity CHECK(expected_sku_types>0 AND expected_quantity>0 AND received_quantity>=0 AND received_quantity<=expected_quantity) + id BIGINT NOT NULL COMMENT '退货订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', return_no VARCHAR(128) NOT NULL COMMENT '退货订单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '操作海外仓ID', warehouse_contact_name VARCHAR(128) NULL COMMENT '中转仓联系人快照', warehouse_contact_phone VARCHAR(64) NULL COMMENT '中转仓联系电话快照', customer_reference_no VARCHAR(128) NULL COMMENT '客户参考号', status VARCHAR(32) NOT NULL COMMENT 'PENDING_RECEIPT、PARTIALLY_RECEIVED或COMPLETED', expected_sku_types INT NOT NULL COMMENT '预报SKU种类数', expected_quantity BIGINT NOT NULL COMMENT '预报总数量', received_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '累计实收总数量', remark VARCHAR(1000) NULL COMMENT '客户备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键', request_hash CHAR(64) NOT NULL COMMENT '创建请求SHA-256', created_by VARCHAR(128) NULL COMMENT '创建操作人ID', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_return_order_customer_no(customer_id,return_no), UNIQUE KEY uk_ll_return_order_customer_key(customer_id,idempotency_key), KEY idx_ll_return_order_warehouse_status(overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_order_quantity CHECK(expected_sku_types>0 AND expected_quantity>0 AND received_quantity>=0 AND received_quantity<=expected_quantity) ) ENGINE=InnoDB COMMENT='客户退货订单及SKU实收汇总'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='warehouse_contact_name'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN warehouse_contact_name VARCHAR(128) NULL COMMENT ''中转仓联系人快照'' AFTER overseas_warehouse_id')); +PREPARE ll_return_order_contact_name_stmt FROM @ddl; EXECUTE ll_return_order_contact_name_stmt; DEALLOCATE PREPARE ll_return_order_contact_name_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='warehouse_contact_phone'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN warehouse_contact_phone VARCHAR(64) NULL COMMENT ''中转仓联系电话快照'' AFTER warehouse_contact_name')); +PREPARE ll_return_order_contact_phone_stmt FROM @ddl; EXECUTE ll_return_order_contact_phone_stmt; DEALLOCATE PREPARE ll_return_order_contact_phone_stmt; CREATE TABLE IF NOT EXISTS ll_return_order_item ( id BIGINT NOT NULL COMMENT '退货预报明细ID', return_order_id BIGINT NOT NULL COMMENT '退货订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', expected_quantity BIGINT NOT NULL COMMENT 'SKU预报数量', received_quantity BIGINT NOT NULL DEFAULT 0 COMMENT 'SKU累计实收数量', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_return_item_order_sku(return_order_id,sku_code), KEY idx_ll_return_item_sku(sku_code,received_quantity), CONSTRAINT chk_ll_return_item_quantity CHECK(expected_quantity>0 AND received_quantity>=0 AND received_quantity<=expected_quantity) ) ENGINE=InnoDB COMMENT='客户退货订单的SKU预报与实收明细'; @@ -3985,6 +4023,24 @@ CREATE TABLE IF NOT EXISTS ll_return_receipt ( -- 已创建开发库升级为“客户只预报 SKU,快递单号仅属于签收批次”的最终模型。 DROP PROCEDURE IF EXISTS migrate_return_forecast_sku_only; + +-- 2026-09-09 / 035 为历史退货订单补充尾程主订单关联,并回填已生成的 RETURN_RELABEL 尾程订单。 +USE baiyue_lastleg; +-- 2026-09-09 / 036 为派送单补充 BOL 号字段(不改原始 CREATE TABLE)。 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='bol_no'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN bol_no VARCHAR(128) NULL COMMENT ''派送资料BOL号,不作为文件归属标识''')); +PREPARE ll_delivery_order_bol_no_stmt FROM @ddl; EXECUTE ll_delivery_order_bol_no_stmt; DEALLOCATE PREPARE ll_delivery_order_bol_no_stmt; +SET @ddl = (SELECT IF( + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='lastleg_order_id'), + 'SELECT 1', + 'ALTER TABLE ll_return_order ADD COLUMN lastleg_order_id BIGINT NULL COMMENT ''关联尾程主订单ID'' AFTER customer_reference_no' +)); +PREPARE ll_return_order_lastleg_id_stmt FROM @ddl; EXECUTE ll_return_order_lastleg_id_stmt; DEALLOCATE PREPARE ll_return_order_lastleg_id_stmt; +UPDATE ll_return_order r +JOIN ll_order o ON o.source_order_id = CAST(r.id AS CHAR) + AND o.source_order_no = r.return_no + AND o.order_type = 'RETURN_RELABEL' +SET r.lastleg_order_id = o.id +WHERE r.lastleg_order_id IS NULL; DELIMITER $$ CREATE PROCEDURE migrate_return_forecast_sku_only() BEGIN @@ -4071,3 +4127,26 @@ SET @ddl = (SELECT IF( 'ALTER TABLE ll_order ADD COLUMN receiving_warehouse_version INT NOT NULL DEFAULT 0 COMMENT ''柜级接柜仓分配乐观锁版本'' AFTER overseas_warehouse_name' )); PREPARE ll_order_receiving_warehouse_version_stmt FROM @ddl; EXECUTE ll_order_receiving_warehouse_version_stmt; DEALLOCATE PREPARE ll_order_receiving_warehouse_version_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='carton_marks'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN carton_marks VARCHAR(4000) NULL COMMENT ''换标箱唛映射快照'' AFTER source_stage')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='source_business_type'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN source_business_type VARCHAR(32) NULL COMMENT ''来源业务类型'' AFTER carton_marks')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='source_business_no'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN source_business_no VARCHAR(128) NULL COMMENT ''来源业务单号'' AFTER source_business_type')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 2026-09-10 / 035 出库PDA完成命令幂等字段与客户范围唯一索引。 +USE baiyue_lastleg; +SET @ddl = (SELECT IF(EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='ll_outbound_pda_task' AND column_name='completion_idempotency_key' +), 'SELECT 1', + 'ALTER TABLE ll_outbound_pda_task ADD COLUMN completion_idempotency_key VARCHAR(255) NULL COMMENT ''完成命令幂等键'' AFTER status')); +PREPARE outbound_pda_completion_column_stmt FROM @ddl; EXECUTE outbound_pda_completion_column_stmt; DEALLOCATE PREPARE outbound_pda_completion_column_stmt; +SET @index_ready = (SELECT COUNT(*)=2 AND MIN(non_unique)=0 + AND SUM(seq_in_index=1 AND column_name='customer_id')=1 + AND SUM(seq_in_index=2 AND column_name='completion_idempotency_key')=1 + FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name='ll_outbound_pda_task' AND index_name='uk_ll_outbound_pda_complete_key'); +SET @index_exists = (SELECT COUNT(*)>0 FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name='ll_outbound_pda_task' AND index_name='uk_ll_outbound_pda_complete_key'); +SET @ddl = IF(@index_ready, 'SELECT 1', IF(@index_exists, + 'ALTER TABLE ll_outbound_pda_task DROP INDEX uk_ll_outbound_pda_complete_key, ADD UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key)', + 'ALTER TABLE ll_outbound_pda_task ADD UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key)')); +PREPARE outbound_pda_completion_index_stmt FROM @ddl; EXECUTE outbound_pda_completion_index_stmt; DEALLOCATE PREPARE outbound_pda_completion_index_stmt; diff --git a/scripts/db/relabel-fulfillment-incremental.sql b/scripts/db/relabel-fulfillment-incremental.sql new file mode 100644 index 000000000..a298d32ac --- /dev/null +++ b/scripts/db/relabel-fulfillment-incremental.sql @@ -0,0 +1,30 @@ +-- 换标PDA完工、客户确认和出库分流字段(MySQL 8,可重复执行) +USE baiyue_lastleg; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='delivery_method'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN delivery_method VARCHAR(32) NULL COMMENT ''派送方式:TRUCK卡车、PICKUP自提、EXPRESS快递;仅涉及箱唛换标时必填'' AFTER contact_phone')); +PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='print_shipping_label'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN print_shipping_label TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否需要打印面单'' AFTER delivery_method')); +PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_id VARCHAR(128) NULL COMMENT ''客户上传面单文件ID'' AFTER print_shipping_label')); +PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_name'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_name VARCHAR(255) NULL COMMENT ''客户上传面单文件名'' AFTER shipping_label_file_id')); +PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_url'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_url VARCHAR(1000) NULL COMMENT ''客户上传面单下载地址'' AFTER shipping_label_file_name')); +PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='fulfillment_status'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN fulfillment_status VARCHAR(32) NOT NULL DEFAULT ''WAIT_RELABEL'' COMMENT ''换标后履约状态'' AFTER shipping_label_file_url')); +PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='outbound_order_no'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN outbound_order_no VARCHAR(64) NULL COMMENT ''自提或快递生成的换标出库单号'' AFTER fulfillment_status')); +PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='customer_confirmation_key'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN customer_confirmation_key VARCHAR(255) NULL COMMENT ''客户确认完成幂等键'' AFTER fulfillment_status')); +PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='customer_confirmed_at'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认换标完成时间'' AFTER customer_confirmation_key')); +PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt; + +UPDATE ll_relabel_application +SET fulfillment_status = CASE + WHEN status = 'CUSTOMER_CONFIRM_PENDING' THEN 'WAIT_CUSTOMER_CONFIRMATION' + WHEN status = 'READY_FOR_OUTBOUND' AND delivery_method = 'TRUCK' THEN 'WAIT_PRE_DISPATCH' + WHEN status = 'READY_FOR_OUTBOUND' THEN 'WAIT_OUTBOUND_CREATION' + WHEN status = 'COMPLETED' THEN 'COMPLETED' + ELSE COALESCE(NULLIF(fulfillment_status, ''), 'WAIT_RELABEL') +END; diff --git a/scripts/db/seed-one-click-dropship-outbound-candidates.sql b/scripts/db/seed-one-click-dropship-outbound-candidates.sql new file mode 100644 index 000000000..3680c4fe7 --- /dev/null +++ b/scripts/db/seed-one-click-dropship-outbound-candidates.sql @@ -0,0 +1,156 @@ +-- 本地验收数据:为 17909216591 对应客户建立 3 张已完成入库的一件代发订单。 +-- 可重复执行;候选数据覆盖订单、入库计划、托盘、库存余额/仓位、入库实收、一件代发档案、SKU 与箱唛。 +USE baiyue_lastleg; + +DROP TEMPORARY TABLE IF EXISTS _tmp_dropship_fixture; +CREATE TEMPORARY TABLE _tmp_dropship_fixture ( + seq_no INT NOT NULL PRIMARY KEY, + order_id BIGINT NOT NULL, + plan_id BIGINT NOT NULL, + plan_pallet_id BIGINT NOT NULL, + balance_id BIGINT NOT NULL, + position_id BIGINT NOT NULL, + profile_id BIGINT NOT NULL, + sku_inventory_id BIGINT NOT NULL, + receipt_id BIGINT NOT NULL, + warehouse_pallet_id BIGINT NOT NULL, + package_count INT NOT NULL, + order_no VARCHAR(32) NOT NULL, + inbound_code VARCHAR(128) NOT NULL, + plan_no VARCHAR(32) NOT NULL, + pallet_no VARCHAR(64) NOT NULL, + sku_code VARCHAR(128) NOT NULL, + product_name VARCHAR(255) NOT NULL +); + +INSERT INTO _tmp_dropship_fixture VALUES + (1,2191000000000909101,2191000000000909201,2191000000000909301,2191000000000909401,2191000000000909501,2191000000000909601,2191000000000909701,2191000000000909801,2191000000000909901, 6,'DS-IN-20260909-01','DS-RK-20260909-01','DS-PLAN-20260909-01','DS-LAX-PALLET-01','DS-SKU-STORAGE-01','折叠收纳箱'), + (2,2191000000000909102,2191000000000909202,2191000000000909302,2191000000000909402,2191000000000909502,2191000000000909602,2191000000000909702,2191000000000909802,2191000000000909902, 8,'DS-IN-20260909-02','DS-RK-20260909-02','DS-PLAN-20260909-02','DS-LAX-PALLET-02','DS-SKU-LAMP-02','桌面灯具'), + (3,2191000000000909103,2191000000000909203,2191000000000909303,2191000000000909403,2191000000000909503,2191000000000909603,2191000000000909703,2191000000000909803,2191000000000909903,10,'DS-IN-20260909-03','DS-RK-20260909-03','DS-PLAN-20260909-03','DS-LAX-PALLET-03','DS-SKU-RACK-03','厨房置物架'); + +INSERT INTO ll_order ( + id,tenant_id,order_no,customer_id,order_type,lx_code,destination_type,inbound_code, + source_region,source_inbound_id,source_order_id,source_order_no,customer_name, + overseas_warehouse_id,overseas_warehouse_code,overseas_warehouse_name, + fba_warehouse_code,container_no,container_status,pallet_count,package_count,product_quantity, + inbound_order_no,current_status,source_event_id,source_loading_plan_id,source_payload_hash,created_at,updated_at +) +SELECT order_id,'BAIYUE',order_no,'2093269089353961474','ONE_CLICK_DROPSHIP',CONCAT('LX-',order_no), + 'ONE_CLICK_DROPSHIP',inbound_code,'FIRST_LEG',CONCAT('SEED-DROPSHIP-',seq_no), + CAST(order_id AS CHAR),order_no,'17909216591测试客户','193000000000200003','US-LAX-01','洛杉矶仓', + CASE seq_no WHEN 1 THEN 'ONT8' WHEN 2 THEN 'ABE2' ELSE 'ABE3' END, + CONCAT('DS-CONT-20260909-',LPAD(seq_no,2,'0')),'已拆柜',1,package_count,package_count * 10, + plan_no,'INBOUND_COMPLETED',CONCAT('SEED-DROPSHIP-EVENT-',seq_no), + CONCAT('SEED-DROPSHIP-LOAD-',seq_no),CONCAT('sha256:seed-dropship-',seq_no),NOW(3),NOW(3) +FROM _tmp_dropship_fixture +ON DUPLICATE KEY UPDATE current_status='INBOUND_COMPLETED',package_count=VALUES(package_count), + product_quantity=VALUES(product_quantity),updated_at=NOW(3); + +INSERT INTO ll_inbound_plan ( + id,tenant_id,plan_no,customer_id,source_order_id,container_no,overseas_warehouse_id, + planned_arrival_date,pallet_type,assigned_flag,merge_required,urgent_flag,creation_mode, + plan_remark,status,line_count,pallet_count,total_volume_cbm,created_at,updated_at,version +) +SELECT plan_id,'BAIYUE',plan_no,'2093269089353961474',order_id, + CONCAT('DS-CONT-20260909-',LPAD(seq_no,2,'0')),193000000000200003, + CURRENT_DATE,'STANDARD',1,0,0,'AUTO','一件代发出库分页验收数据','COMPLETED',1,1,1.0000,NOW(3),NOW(3),0 +FROM _tmp_dropship_fixture +ON DUPLICATE KEY UPDATE status='COMPLETED',assigned_flag=1,updated_at=NOW(3); + +INSERT INTO ll_inbound_plan_pallet ( + id,tenant_id,customer_id,plan_id,pallet_no,source_pallet_no,pallet_type, + destination_warehouse_code,destination_warehouse_name,capacity_cbm,allocated_volume_cbm, + sequence_no,warehouse_pallet_id,assignment_status,locked_at,actual_volume_cbm,actual_package_count +) +SELECT plan_pallet_id,'BAIYUE','2093269089353961474',plan_id,pallet_no, + CONCAT('DS-SOURCE-PALLET-',LPAD(seq_no,2,'0')),'STANDARD', + CASE seq_no WHEN 1 THEN 'ONT8' WHEN 2 THEN 'ABE2' ELSE 'ABE3' END, + 'Amazon FBA',2.0000,1.0000,1,warehouse_pallet_id,'ASSIGNED',NOW(3),1.0000,package_count +FROM _tmp_dropship_fixture +ON DUPLICATE KEY UPDATE warehouse_pallet_id=VALUES(warehouse_pallet_id),assignment_status='ASSIGNED', + actual_package_count=VALUES(actual_package_count); + +INSERT INTO ll_inventory_balance ( + id,tenant_id,customer_id,customer_name,overseas_warehouse_id,overseas_warehouse_code, + overseas_warehouse_name,destination_warehouse_code,destination_warehouse_name, + inbound_order_id,inbound_order_no,inbound_code,sku_code,sku_name_zh,product_name_zh, + available_packages,available_quantity,version,created_at,updated_at +) +SELECT balance_id,'BAIYUE','2093269089353961474','17909216591测试客户','193000000000200003', + 'US-LAX-01','洛杉矶仓',CASE seq_no WHEN 1 THEN 'ONT8' WHEN 2 THEN 'ABE2' ELSE 'ABE3' END, + 'Amazon FBA',order_id,plan_no,inbound_code,sku_code,product_name,product_name, + package_count,package_count * 10,0,NOW(3),NOW(3) +FROM _tmp_dropship_fixture +ON DUPLICATE KEY UPDATE customer_name=VALUES(customer_name),sku_code=VALUES(sku_code), + available_packages=VALUES(available_packages),available_quantity=VALUES(available_quantity),updated_at=NOW(3); + +INSERT INTO ll_inventory_position ( + id,tenant_id,customer_id,balance_id,overseas_warehouse_id,destination_warehouse_code, + sku_code,pallet_id,pallet_no,batch_no,warehouse_location_code,available_packages, + product_quantity,status,version,created_at,updated_at +) +SELECT position_id,'BAIYUE','2093269089353961474',balance_id,'193000000000200003', + CASE seq_no WHEN 1 THEN 'ONT8' WHEN 2 THEN 'ABE2' ELSE 'ABE3' END, + sku_code,warehouse_pallet_id,pallet_no,CONCAT('DS-BATCH-20260909-',seq_no), + CONCAT('LAX-A-',LPAD(seq_no,2,'0')),package_count,package_count * 10,'OCCUPIED',0,NOW(3),NOW(3) +FROM _tmp_dropship_fixture +ON DUPLICATE KEY UPDATE available_packages=VALUES(available_packages),product_quantity=VALUES(product_quantity), + status='OCCUPIED',updated_at=NOW(3); + +INSERT INTO ll_dropship_order_profile ( + id,customer_id,order_id,warehouse_pallet_id,pallet_no,source_pallet_no, + inventory_position_id,status,idempotency_key,version,created_at,updated_at +) +SELECT profile_id,'2093269089353961474',order_id,warehouse_pallet_id,pallet_no, + CONCAT('DS-SOURCE-PALLET-',LPAD(seq_no,2,'0')),position_id,'ACTIVE', + CONCAT('SEED-DROPSHIP-PROFILE-',seq_no),0,NOW(3),NOW(3) +FROM _tmp_dropship_fixture +ON DUPLICATE KEY UPDATE warehouse_pallet_id=VALUES(warehouse_pallet_id),pallet_no=VALUES(pallet_no), + inventory_position_id=VALUES(inventory_position_id),status='ACTIVE',updated_at=NOW(3); + +INSERT INTO ll_dropship_sku_inventory ( + id,customer_id,order_id,profile_id,sku_code,product_name,package_count,product_quantity,created_at,updated_at +) +SELECT sku_inventory_id,'2093269089353961474',order_id,profile_id,sku_code,product_name, + package_count,package_count * 10,NOW(3),NOW(3) +FROM _tmp_dropship_fixture +ON DUPLICATE KEY UPDATE product_name=VALUES(product_name),package_count=VALUES(package_count), + product_quantity=VALUES(product_quantity),updated_at=NOW(3); + +INSERT INTO ll_dropship_carton ( + id,customer_id,order_id,profile_id,warehouse_pallet_id,pallet_no,sku_code, + carton_mark,carton_sequence,status,version,created_at,updated_at +) +SELECT 2191000000001000000 + fixture.seq_no * 100 + numbers.n, + '2093269089353961474',fixture.order_id,fixture.profile_id,fixture.warehouse_pallet_id, + fixture.pallet_no,fixture.sku_code, + CONCAT('DS-CARTON-20260909-',LPAD(fixture.seq_no,2,'0'),'-',LPAD(numbers.n,3,'0')), + numbers.n,'AVAILABLE',0,NOW(3),NOW(3) +FROM _tmp_dropship_fixture fixture +JOIN ( + SELECT 1 n UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 + UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9 UNION ALL SELECT 10 +) numbers ON numbers.n <= fixture.package_count +ON DUPLICATE KEY UPDATE warehouse_pallet_id=VALUES(warehouse_pallet_id),pallet_no=VALUES(pallet_no), + sku_code=VALUES(sku_code),status='AVAILABLE',outbound_order_no=NULL,updated_at=NOW(3); + +INSERT INTO ll_inbound_stock_receipt ( + id,tenant_id,customer_id,plan_id,task_id,allocation_id,line_id,order_id,inbound_code, + sku_code,product_name_zh,sku_name_zh,warehouse_pallet_id,pallet_no,source_pallet_no, + warehouse_location_code,batch_no,received_packages,received_quantity,inventory_balance_id, + inventory_position_id,inventory_operation_key,received_at,created_at +) +SELECT receipt_id,'BAIYUE','2093269089353961474',plan_id,plan_id + 10000,plan_id + 20000, + plan_id + 30000,order_id,inbound_code,sku_code,product_name,product_name,warehouse_pallet_id, + pallet_no,CONCAT('DS-SOURCE-PALLET-',LPAD(seq_no,2,'0')),CONCAT('LAX-A-',LPAD(seq_no,2,'0')), + CONCAT('DS-BATCH-20260909-',seq_no),package_count,package_count * 10,balance_id,position_id, + CONCAT('SEED_PDA_STOCK_IN:',plan_id),NOW(3),NOW(3) +FROM _tmp_dropship_fixture +ON DUPLICATE KEY UPDATE received_packages=VALUES(received_packages),received_quantity=VALUES(received_quantity); + +SELECT fixture.order_no,fixture.inbound_code,fixture.pallet_no,fixture.sku_code, + fixture.package_count AS available_packages +FROM _tmp_dropship_fixture fixture +ORDER BY fixture.seq_no; + +DROP TEMPORARY TABLE IF EXISTS _tmp_dropship_fixture; diff --git a/scripts/db/seed-pda-single-sku-return-inventory.sql b/scripts/db/seed-pda-single-sku-return-inventory.sql new file mode 100644 index 000000000..543375a30 --- /dev/null +++ b/scripts/db/seed-pda-single-sku-return-inventory.sql @@ -0,0 +1,137 @@ +-- 本地验收数据:客户 17909216591 / 洛杉矶仓 / 单 SKU 退货单 + PDA 已签收库存。 +-- 可重复执行;已存在的业务单据不会被覆盖或重复累计库存。 +USE baiyue_lastleg; + +START TRANSACTION; + +DROP TEMPORARY TABLE IF EXISTS _seed_pda_single_sku_returns; +CREATE TEMPORARY TABLE _seed_pda_single_sku_returns ( + seq INT NOT NULL PRIMARY KEY, + sku_code VARCHAR(128) NOT NULL, + product_name_zh VARCHAR(255) NOT NULL, + quantity BIGINT NOT NULL +); + +INSERT INTO _seed_pda_single_sku_returns (seq, sku_code, product_name_zh, quantity) VALUES + (1, 'RET-LAX-PHONE-0909', '退货验收手机支架', 6), + (2, 'RET-LAX-LAMP-0909', '退货验收桌面灯具', 8), + (3, 'RET-LAX-RACK-0909', '退货验收厨房置物架', 10), + (4, 'RET-LAX-BAG-0909', '退货验收旅行收纳袋', 12), + (5, 'RET-LAX-FAN-0909', '退货验收便携风扇', 15); + +INSERT IGNORE INTO ll_return_order + (id, customer_id, return_no, overseas_warehouse_id, customer_reference_no, need_relabel, + status, expected_sku_types, expected_quantity, received_quantity, remark, + idempotency_key, request_hash, created_by, created_at, updated_at) +SELECT + 2097400000000000000 + seq, + '2093269089353961474', + CONCAT('RT-LAX-PDA-20260909-0', seq), + '193000000000200003', + CONCAT('17909216591-PDA-RETURN-0', seq), + 0, 'COMPLETED', 1, quantity, quantity, + '单 SKU 退货单,已完成 PDA 签收验收', + CONCAT('seed-pda-single-sku-return-order-20260909-', seq), + SHA2(CONCAT('seed-pda-single-sku-return-order-20260909-', seq), 256), + '209608311713000001', DATE_SUB(NOW(3), INTERVAL (60 - seq) MINUTE), DATE_SUB(NOW(3), INTERVAL (45 - seq) MINUTE) +FROM _seed_pda_single_sku_returns; + +INSERT IGNORE INTO ll_return_order_item + (id, return_order_id, customer_id, sku_code, product_name_zh, + expected_quantity, received_quantity, created_at, updated_at) +SELECT + 2097400000000000100 + seq, + 2097400000000000000 + seq, + '2093269089353961474', sku_code, product_name_zh, + quantity, quantity, DATE_SUB(NOW(3), INTERVAL (60 - seq) MINUTE), DATE_SUB(NOW(3), INTERVAL (45 - seq) MINUTE) +FROM _seed_pda_single_sku_returns; + +INSERT IGNORE INTO ll_return_receipt + (id, return_order_id, customer_id, receipt_no, receipt_batch_no, carton_mark, tracking_no, + bound_by, status, received_quantity, operator_id, operator_name, + idempotency_key, request_hash, signed_at, created_at) +SELECT + 2097400000000000200 + seq, + 2097400000000000000 + seq, + '2093269089353961474', + CONCAT('RR-LAX-PDA-20260909-0', seq), + CONCAT('RB-LAX-PDA-20260909-0', seq), + CONCAT('RC-LAX-PDA-20260909-0', seq), + CONCAT('TRACK-17909216591-0909-0', seq), + 'RETURN_NO', 'SIGNED', quantity, + '209608311713000001', '本地PDA验收员', + CONCAT('seed-pda-single-sku-return-receipt-20260909-', seq), + SHA2(CONCAT('seed-pda-single-sku-return-receipt-20260909-', seq), 256), + DATE_SUB(NOW(3), INTERVAL (45 - seq) MINUTE), DATE_SUB(NOW(3), INTERVAL (45 - seq) MINUTE) +FROM _seed_pda_single_sku_returns; + +INSERT IGNORE INTO ll_return_receipt_item + (id, receipt_id, return_order_item_id, customer_id, sku_code, received_quantity) +SELECT + 2097400000000000300 + seq, + 2097400000000000200 + seq, + 2097400000000000100 + seq, + '2093269089353961474', sku_code, quantity +FROM _seed_pda_single_sku_returns; + +INSERT IGNORE INTO ll_return_receipt_batch + (id, return_order_id, receipt_id, customer_id, batch_no, overseas_warehouse_id, + storage_billing_status, storage_started_at, created_at) +SELECT + 2097400000000000400 + seq, + 2097400000000000000 + seq, + 2097400000000000200 + seq, + '2093269089353961474', + CONCAT('RB-LAX-PDA-20260909-0', seq), + '193000000000200003', 'PENDING_PRICING', + DATE_SUB(NOW(3), INTERVAL (45 - seq) MINUTE), DATE_SUB(NOW(3), INTERVAL (45 - seq) MINUTE) +FROM _seed_pda_single_sku_returns; + +INSERT IGNORE INTO ll_return_inventory_carton + (id, customer_id, overseas_warehouse_id, source_receipt_id, source_batch_no, source_return_no, + carton_mark, status, sku_types, total_quantity, created_at, updated_at) +SELECT + 2097400000000000500 + seq, + '2093269089353961474', '193000000000200003', + 2097400000000000200 + seq, + CONCAT('RB-LAX-PDA-20260909-0', seq), + CONCAT('RT-LAX-PDA-20260909-0', seq), + CONCAT('RC-LAX-PDA-20260909-0', seq), + 'AVAILABLE', 1, quantity, + DATE_SUB(NOW(3), INTERVAL (45 - seq) MINUTE), DATE_SUB(NOW(3), INTERVAL (45 - seq) MINUTE) +FROM _seed_pda_single_sku_returns; + +INSERT IGNORE INTO ll_return_inventory_carton_item + (id, carton_id, customer_id, sku_code, product_name_zh, quantity) +SELECT + 2097400000000000600 + seq, + 2097400000000000500 + seq, + '2093269089353961474', sku_code, product_name_zh, quantity +FROM _seed_pda_single_sku_returns; + +INSERT IGNORE INTO ll_return_inventory_balance + (id, customer_id, overseas_warehouse_id, sku_code, product_name_zh, + total_quantity, available_quantity, carton_quantity, created_at, updated_at) +SELECT + 2097400000000000700 + seq, + '2093269089353961474', '193000000000200003', sku_code, product_name_zh, + quantity, quantity, 1, + DATE_SUB(NOW(3), INTERVAL (45 - seq) MINUTE), DATE_SUB(NOW(3), INTERVAL (45 - seq) MINUTE) +FROM _seed_pda_single_sku_returns; + +DROP TEMPORARY TABLE _seed_pda_single_sku_returns; +COMMIT; + +SELECT o.return_no, o.status, o.expected_sku_types, o.expected_quantity, o.received_quantity, + r.receipt_no, r.status AS receipt_status, r.carton_mark, + b.sku_code, b.product_name_zh, b.total_quantity, b.available_quantity, b.carton_quantity +FROM ll_return_order o +JOIN ll_return_receipt r ON r.return_order_id = o.id +JOIN ll_return_inventory_balance b + ON b.customer_id = o.customer_id + AND b.overseas_warehouse_id = o.overseas_warehouse_id +JOIN ll_return_order_item oi + ON oi.return_order_id = o.id + AND oi.sku_code = b.sku_code +WHERE o.id BETWEEN 2097400000000000001 AND 2097400000000000005 +ORDER BY o.return_no;