From 186b6be7d8f50543748b7ffab81eb39ab9ea314d Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Thu, 24 Sep 2026 09:01:14 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B4=B4=E6=A0=87=E7=94=B3=E8=AF=B7=EF=BC=8C?= =?UTF-8?q?=E4=BB=93=E5=82=A8=E8=BD=AC=E8=BF=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/README.md | 2 +- deploy/sql/头程数据库统一增量-MySQL8.sql | 10 +- iam/src/main/resources/db/iam.sql | 3 +- .../controller/LastlegOrderController.java | 16 + .../service/DeliveryTransferService.java | 33 +- .../mapper/DispatchCandidateMapper.java | 48 ++- .../service/DispatchPdaWorkflowService.java | 7 +- .../MerchantRelabelApplicationController.java | 6 + .../lastleg/relabel/dto/RelabelRequests.java | 6 + .../lastleg/relabel/dto/RelabelResponses.java | 2 +- .../relabel/pda/dto/PdaRelabelResponses.java | 3 +- .../pda/mapper/PdaRelabelTaskMapper.java | 3 +- .../pda/service/PdaRelabelService.java | 20 +- .../service/RelabelApplicationService.java | 125 +++++- .../dto/WarehouseTransferResponses.java | 1 + .../mapper/WarehouseTransferOrderMapper.java | 6 +- .../service/WarehouseTransferService.java | 52 ++- .../openapi/overseas-business-v1.yaml | 39 +- .../pda/service/PdaRelabelServiceTest.java | 29 +- .../RelabelApplicationServiceTest.java | 93 ++++- .../service/WarehouseTransferServiceTest.java | 33 +- scripts/README.md | 2 + .../initialization-baseline-contract.test.mjs | 26 +- scripts/db/init-domestic-pda-operators.sql | 261 ++++++++++++ .../db/recent-schema-incremental-20260923.sql | 385 ++++++++++++++++++ ...verseas-relabel-fulfillment-acceptance.mjs | 4 +- ...arehouse-transfer-remaining-acceptance.mjs | 106 ++++- 27 files changed, 1203 insertions(+), 118 deletions(-) create mode 100644 scripts/db/init-domestic-pda-operators.sql create mode 100644 scripts/db/recent-schema-incremental-20260923.sql diff --git a/deploy/sql/README.md b/deploy/sql/README.md index aadb21f39..8b83be046 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -40,7 +40,7 @@ | 所属库 | 保留内容 | 不包含 | |---|---|---| -| Auth / IAM | 平台主体、唯一平台管理员、8 个系统角色及企业管理员模板、364 个权限、539 条角色权限、7 个数据范围、客户编号起点 | 测试客户、固定客户账号、额外扩权 | +| Auth / IAM | 平台主体、唯一平台管理员、8 个系统角色及企业管理员模板、370 个权限、552 条角色权限、7 个数据范围、客户编号起点 | 测试客户、固定客户账号、额外扩权 | | Warehouse | 既有公共仓目录 318 条及地址;义乌、东莞、洛杉矶、纽约 4 个作业仓收货能力 | 示例供应商、快递、往来单位、托盘、库位、预占、库存 | | Firstleg | 112 个费用项目、15 个渠道目录、7 个始发地别名、4 个公共仓映射 | 客户报价、订单、入库、实测或履约记录 | | File / Finance | 导出互斥键 `GLOBAL_CREATE`、默认平台财务主体 | 文件任务、资金、账单或交易流水 | diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 8bd2d0573..a0a4f7989 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -138,7 +138,8 @@ CREATE TABLE iam_membership ( UNIQUE KEY uk_membership_tenant_user (tenant_id,user_id), UNIQUE KEY uk_membership_tenant_no (tenant_id,member_no), KEY idx_membership_user (user_id,status), - KEY idx_membership_tenant (tenant_id,status) + KEY idx_membership_tenant (tenant_id,status), + KEY idx_membership_domestic_warehouse (tenant_id,domestic_warehouse_id,status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户成员关系表:表示用户在客户企业中的成员身份,是分配企业客户端角色的主体'; CREATE TABLE iam_membership_role ( @@ -2351,11 +2352,6 @@ CREATE TABLE fl_inbound_fee ( CONSTRAINT fk_fee_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库头程费用计算和确认快照'; -ALTER TABLE fl_inbound_fee - MODIFY COLUMN carrier_rate_id bigint unsigned DEFAULT NULL COMMENT '船司FBA价格ID;人工报价为空', - MODIFY COLUMN carrier_rate_version int unsigned DEFAULT NULL COMMENT '船司价格版本快照;人工报价为空', - MODIFY COLUMN rate_effective_from datetime(3) DEFAULT NULL COMMENT '价格生效开始快照;人工报价为空'; - CREATE TABLE fl_inbound_fee_detail ( id bigint unsigned NOT NULL COMMENT '入库费用明细ID', tenant_id bigint unsigned NOT NULL COMMENT '客户ID(客户隔离键);费用明细引用平台公共费用项目', @@ -3325,7 +3321,7 @@ CREATE TABLE fl_mojia_measurement_receipt ( length_cm decimal(12,3) NOT NULL COMMENT '设备实测长度cm', width_cm decimal(12,3) NOT NULL COMMENT '设备实测宽度cm', height_cm decimal(12,3) NOT NULL COMMENT '设备实测高度cm', - weight_kg decimal(18,3) DEFAULT NULL COMMENT '设备实测重量kg', + weight_kg decimal(18,3) NOT NULL COMMENT '设备实测重量kg', machine_no varchar(64) NOT NULL COMMENT '设备编号', measured_at datetime(3) NOT NULL COMMENT '设备测量时间或服务接收时间', image_file_id bigint unsigned NOT NULL COMMENT 'File服务中的墨家联调图片ID', diff --git a/iam/src/main/resources/db/iam.sql b/iam/src/main/resources/db/iam.sql index e15c0eb20..901bdd60d 100644 --- a/iam/src/main/resources/db/iam.sql +++ b/iam/src/main/resources/db/iam.sql @@ -161,7 +161,8 @@ CREATE TABLE IF NOT EXISTS iam_membership ( UNIQUE KEY uk_membership_tenant_user (tenant_id, user_id), UNIQUE KEY uk_membership_tenant_no (tenant_id, member_no), KEY idx_membership_user (user_id, status), - KEY idx_membership_tenant (tenant_id, status) + KEY idx_membership_tenant (tenant_id, status), + KEY idx_membership_domestic_warehouse (tenant_id, domestic_warehouse_id, status) ) ENGINE=InnoDB COMMENT='客户成员关系表:表示用户在客户企业中的成员身份,是分配企业客户端角色的主体'; CREATE TABLE IF NOT EXISTS iam_role ( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java index b73a5ffe4..1b283ce63 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java @@ -17,6 +17,9 @@ import com.baiyue.lastleg.security.OverseasPermissions; import com.baiyue.lastleg.service.LastlegOrderService; import com.baiyue.lastleg.service.LastlegOrderCommandService; import com.baiyue.lastleg.service.LoadingCompletedConsumer; +import com.baiyue.lastleg.relabel.dto.RelabelRequests; +import com.baiyue.lastleg.relabel.dto.RelabelResponses; +import com.baiyue.lastleg.relabel.service.RelabelApplicationService; import com.baiyue.lastleg.shipping.service.ContainerShippingNodeConsumer; import com.baiyue.common.event.EventTypes; import com.baiyue.lastleg.wallet.WalletCommandCompletedConsumer; @@ -52,6 +55,7 @@ public class LastlegOrderController { @Resource private LastlegOrderCommandService commandService; @Resource private InternalServiceTokenGuard tokenGuard; @Resource private WalletCommandCompletedConsumer walletCommandCompletedConsumer; + @Resource private RelabelApplicationService relabelApplicationService; @PostMapping("/internal/lastleg/events") @Operation(summary = "Receive a first-leg loading or actual shipping-node event", @@ -163,6 +167,18 @@ public class LastlegOrderController { return ApiResponse.success(orderService.detail(platformScope(user, customerId), id)); } + @PutMapping("/api/platform/v1/last-leg/orders/{id}/relabel-review") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") + @Operation(summary = "Review a directly-created labeling order in the tail-leg order workbench") + public ApiResponse reviewDirectRelabel( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable long id, + @Valid @RequestBody RelabelRequests.ChangeStatus request) { + return ApiResponse.success(relabelApplicationService.reviewDirectTailOrder( + platformScope(user, customerId), id, operatorId(user), request)); + } + @PostMapping("/api/platform/v1/last-leg/orders/{id}/fees") @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") @Operation(summary = "Add a fee to one tail-leg order", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT) 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 59aabd63e..4b5349e75 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 @@ -430,33 +430,38 @@ public class DeliveryTransferService { new LambdaQueryWrapper() .eq(StringUtils.hasText(customerId), WarehouseTransferOrder::getCustomerId, trim(customerId)) .eq(StringUtils.hasText(warehouseId), WarehouseTransferOrder::getOperatingWarehouseId, trim(warehouseId)) - .eq(WarehouseTransferOrder::getStatus, "WAIT_FBA_DESTINATION") + // 已确定目的仓的转运单在完成入库/换标后进入 READY_FOR_DISPATCH; + // 未确定目的仓的历史单据仍保留 WAIT_FBA_DESTINATION,均应进入出库候选。 + .in(WarehouseTransferOrder::getStatus, "WAIT_FBA_DESTINATION", "READY_FOR_DISPATCH") .orderByDesc(WarehouseTransferOrder::getUpdatedAt)); List result = new ArrayList<>(); for (WarehouseTransferOrder transfer : transfers) { + boolean destinationBound = "FBA".equals(transfer.getDestinationType()) + ? StringUtils.hasText(transfer.getDestinationWarehouseCode()) + : "PRIVATE".equals(transfer.getDestinationType()) + && StringUtils.hasText(transfer.getPrivateDestinationAddress()); + // This screen is the destination-completion queue. Once a real + // destination is bound, the remaining inventory belongs in smart + // dispatch and must disappear from this list. + if (destinationBound) continue; LastlegOrder order = tailOrders.selectById(transfer.getLastlegOrderId()); List completedPallets = warehouseTransferPallets.listOrder( transfer.getCustomerId(), transfer.getId()).stream() .filter(pallet -> "COMPLETED".equals(pallet.getStatus()) && pallet.getWarehousePalletId() != null) .toList(); - long available = 0; for (WarehouseTransferPallet pallet : completedPallets) { long palletAvailable = inventoryPositions.listPallet(transfer.getCustomerId(), pallet.getWarehousePalletId()) .stream().filter(position -> transfer.getOperatingWarehouseId().equals(position.getOverseasWarehouseId())) .mapToLong(position -> value(position.getAvailablePackages())).sum(); - available += palletAvailable; + if (palletAvailable <= 0) continue; + result.add(new DeliveryResponses.WarehouseTransferOutboundCandidate( + transfer.getCustomerId(), order == null ? null : order.getCustomerName(), transfer.getTransferNo(), + transfer.getOperatingWarehouseId(), transfer.getStatus(), Boolean.TRUE.equals(transfer.getNeedRelabel()), + transfer.getRelabelNo(), transfer.getDestinationType(), transfer.getDestinationWarehouseCode(), + transfer.getPrivateDestinationAddress(), transfer.getPrivateDestinationPostalCode(), + pallet.getId(), pallet.getWarehousePalletId(), pallet.getPalletNo(), Math.toIntExact(palletAvailable), + pallet.getGrossWeightKg(), pallet.getVolumeCbm())); } - if (available <= 0 || completedPallets.isEmpty()) continue; - result.add(new DeliveryResponses.WarehouseTransferOutboundCandidate( - transfer.getCustomerId(), order == null ? null : order.getCustomerName(), transfer.getTransferNo(), - transfer.getOperatingWarehouseId(), transfer.getStatus(), Boolean.TRUE.equals(transfer.getNeedRelabel()), - transfer.getRelabelNo(), transfer.getDestinationType(), transfer.getDestinationWarehouseCode(), - transfer.getPrivateDestinationAddress(), transfer.getPrivateDestinationPostalCode(), - null, null, null, Math.toIntExact(available), - completedPallets.stream().map(WarehouseTransferPallet::getGrossWeightKg).filter(Objects::nonNull) - .reduce(BigDecimal.ZERO, BigDecimal::add), - completedPallets.stream().map(WarehouseTransferPallet::getVolumeCbm).filter(Objects::nonNull) - .reduce(BigDecimal.ZERO, BigDecimal::add))); } return result; } 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 f504f93d4..3495e7769 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 @@ -60,7 +60,7 @@ public interface DispatchCandidateMapper { "GROUP_CONCAT(DISTINCT o.order_no ORDER BY o.order_no SEPARATOR ',') AS orderRefs,MIN(l.inbound_code) AS inboundCode," + "p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," + "p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," + - "MIN(l.fba_warehouse_code) AS fbaWarehouseCode," + + "COALESCE(NULLIF(MIN(l.fba_warehouse_code),''),NULLIF(p.destination_warehouse_code,'')) AS fbaWarehouseCode," + "CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN COALESCE((SELECT SUM(pos.available_packages) " + "FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id),0) " + "ELSE COALESCE(SUM(CASE WHEN a.picked_package_count > 0 THEN a.picked_package_count " + @@ -79,15 +79,20 @@ public interface DispatchCandidateMapper { "WHERE ri.customer_id=ip.customer_id AND rp.pallet_id=p.warehouse_pallet_id " + "AND ri.fba_carton_mark IS NOT NULL AND ri.fba_carton_mark != '' " + "AND ra.status NOT IN ('REJECTED','CANCELLED')) AS relabelCartonMarks," + - "MIN(l.destination_type) AS destinationType,MIN(l.delivery_address) AS deliveryAddress," + + "COALESCE(NULLIF(MIN(l.destination_type),''),NULLIF(p.destination_type,'')) AS destinationType,COALESCE(MIN(l.delivery_address),p.delivery_address) AS deliveryAddress," + "MIN(l.postal_code) AS postalCode,MIN(l.contact_name) AS contactName,MIN(l.contact_phone) AS contactPhone " + "FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " + + "LEFT JOIN ll_warehouse_transfer_order wt ON wt.customer_id=ip.customer_id AND wt.lastleg_order_id=ip.source_order_id " + "LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " + "LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id " + "LEFT JOIN ll_order o ON o.id=l.order_id " + - "WHERE ((l.destination_type='FBA' AND NULLIF(TRIM(l.fba_warehouse_code),'') IS NOT NULL) OR " + + "WHERE ((ip.creation_mode='WAREHOUSE_TRANSFER' AND ((wt.destination_type='FBA' AND NULLIF(TRIM(wt.destination_warehouse_code),'') IS NOT NULL) OR " + + "(wt.destination_type='PRIVATE' AND NULLIF(TRIM(wt.private_destination_address),'') IS NOT NULL) OR " + + "(p.destination_type='FBA' AND NULLIF(TRIM(p.destination_warehouse_code),'') IS NOT NULL) OR " + + "(p.destination_type='PRIVATE' AND NULLIF(TRIM(p.delivery_address),'') IS NOT NULL))) OR " + + "(ip.creation_mode!='WAREHOUSE_TRANSFER' AND ((l.destination_type='FBA' AND NULLIF(TRIM(l.fba_warehouse_code),'') IS NOT NULL) OR " + "(l.destination_type='PRIVATE' AND NULLIF(TRIM(l.delivery_address),'') IS NOT NULL AND NULLIF(TRIM(l.postal_code),'') IS NOT NULL " + - "AND NULLIF(TRIM(l.contact_name),'') IS NOT NULL AND NULLIF(TRIM(l.contact_phone),'') IS NOT NULL)) " + + "AND NULLIF(TRIM(l.contact_name),'') IS NOT NULL AND NULLIF(TRIM(l.contact_phone),'') IS NOT NULL)))) " + " AND ip.customer_id=#{customerId} " + " AND p.plan_id IN " + "#{id} " + @@ -107,12 +112,18 @@ public interface DispatchCandidateMapper { "d.source_pallet_key=CONCAT('PLAN:',p.id) OR (p.warehouse_pallet_id IS NOT NULL AND " + "d.source_pallet_key=CONCAT('RECEIVED:',p.warehouse_pallet_id))) " + "AND (#{draftToken} IS NULL OR d.draft_token IS NULL OR d.draft_token != #{draftToken})) " + - "AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " + + // Warehouse-transfer inventory is intentionally reserved before dispatch. It must still be + // eligible for the normal smart-dispatch flow once its destination is bound; other order + // types keep the reservation guard to prevent duplicate dispatching. + "AND (ip.creation_mode='WAREHOUSE_TRANSFER' OR NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " + "JOIN ll_inventory_position tp ON tp.id=ta.inventory_position_id " + - "WHERE ta.customer_id=ip.customer_id AND ta.status='RESERVED' AND tp.pallet_id=p.warehouse_pallet_id) " + - "AND NOT EXISTS (SELECT 1 FROM ll_relabel_item rli JOIN ll_relabel_application rla ON rla.id=rli.application_id AND rla.customer_id=rli.customer_id " + + "WHERE ta.customer_id=ip.customer_id AND ta.status='RESERVED' AND tp.pallet_id=p.warehouse_pallet_id)) " + + // A warehouse-transfer pallet may contain both relabel and non-relabel inventory. + // Do not hide the whole pallet because one carton/SKU has an active relabel item; + // the remaining available inventory must still be dispatchable. + "AND (ip.creation_mode!='WAREHOUSE_TRANSFER' OR NOT EXISTS (SELECT 1 FROM ll_relabel_item rli JOIN ll_relabel_application rla ON rla.id=rli.application_id AND rla.customer_id=rli.customer_id " + "WHERE rli.customer_id=ip.customer_id AND rli.source_inbound_order_id IS NOT NULL " + - "AND rli.source_inbound_order_id=o.id AND rla.status NOT IN ('REJECTED','CANCELLED','COMPLETED')) " + + "AND rli.source_inbound_order_id=o.id AND rla.status NOT IN ('REJECTED','CANCELLED','COMPLETED'))) " + "GROUP BY p.plan_id,p.id,p.warehouse_pallet_id,p.pallet_no,p.destination_warehouse_id," + "p.destination_warehouse_code,p.destination_warehouse_name,p.allocated_volume_cbm " + "ORDER BY p.plan_id,p.sequence_no" + @@ -160,7 +171,7 @@ public interface DispatchCandidateMapper { "GROUP_CONCAT(DISTINCT o.order_no ORDER BY o.order_no SEPARATOR ',') AS orderRefs,MIN(l.inbound_code) AS inboundCode," + "p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," + "p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," + - "MIN(l.fba_warehouse_code) AS fbaWarehouseCode," + + "COALESCE(NULLIF(MIN(l.fba_warehouse_code),''),NULLIF(p.destination_warehouse_code,'')) AS fbaWarehouseCode," + "CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN COALESCE((SELECT SUM(pos.available_packages) " + "FROM ll_inventory_position pos WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id),0) " + "ELSE COALESCE(SUM(CASE WHEN a.picked_package_count > 0 THEN a.picked_package_count " + @@ -179,31 +190,36 @@ public interface DispatchCandidateMapper { "WHERE ri.customer_id=ip.customer_id AND rp.pallet_id=p.warehouse_pallet_id " + "AND ri.fba_carton_mark IS NOT NULL AND ri.fba_carton_mark != '' " + "AND ra.status NOT IN ('REJECTED','CANCELLED')) AS relabelCartonMarks," + - "MIN(l.destination_type) AS destinationType,MIN(l.delivery_address) AS deliveryAddress," + + "COALESCE(NULLIF(MIN(l.destination_type),''),NULLIF(p.destination_type,'')) AS destinationType,COALESCE(MIN(l.delivery_address),p.delivery_address) AS deliveryAddress," + "MIN(l.postal_code) AS postalCode,MIN(l.contact_name) AS contactName,MIN(l.contact_phone) AS contactPhone " + "FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " + + "LEFT JOIN ll_warehouse_transfer_order wt ON wt.customer_id=ip.customer_id AND wt.lastleg_order_id=ip.source_order_id " + "LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " + "LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id " + "LEFT JOIN ll_order o ON o.id=l.order_id " + "WHERE ip.customer_id=#{customerId} AND p.id IN " + "#{id} " + - "AND ((l.destination_type='FBA' AND NULLIF(TRIM(l.fba_warehouse_code),'') IS NOT NULL) OR " + + "AND ((ip.creation_mode='WAREHOUSE_TRANSFER' AND ((wt.destination_type='FBA' AND NULLIF(TRIM(wt.destination_warehouse_code),'') IS NOT NULL) OR " + + "(wt.destination_type='PRIVATE' AND NULLIF(TRIM(wt.private_destination_address),'') IS NOT NULL) OR " + + "(p.destination_type='FBA' AND NULLIF(TRIM(p.destination_warehouse_code),'') IS NOT NULL) OR " + + "(p.destination_type='PRIVATE' AND NULLIF(TRIM(p.delivery_address),'') IS NOT NULL))) OR " + + "(ip.creation_mode!='WAREHOUSE_TRANSFER' AND ((l.destination_type='FBA' AND NULLIF(TRIM(l.fba_warehouse_code),'') IS NOT NULL) OR " + "(l.destination_type='PRIVATE' AND NULLIF(TRIM(l.delivery_address),'') IS NOT NULL AND NULLIF(TRIM(l.postal_code),'') IS NOT NULL " + - "AND NULLIF(TRIM(l.contact_name),'') IS NOT NULL AND NULLIF(TRIM(l.contact_phone),'') IS NOT NULL)) " + + "AND NULLIF(TRIM(l.contact_name),'') IS NOT NULL AND NULLIF(TRIM(l.contact_phone),'') IS NOT NULL)))) " + "AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation ha " + "JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " + "JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " + "WHERE ha.pallet_id=p.id AND ho.detained=1) " + "AND (p.warehouse_pallet_id IS NULL OR EXISTS (SELECT 1 FROM ll_inventory_position pos " + "WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " + - "AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " + + "AND (ip.creation_mode='WAREHOUSE_TRANSFER' OR NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " + "JOIN ll_inventory_position tp ON tp.id=ta.inventory_position_id " + - "WHERE ta.customer_id=#{customerId} AND ta.status='RESERVED' AND tp.pallet_id=p.warehouse_pallet_id) " + - "AND NOT EXISTS (SELECT 1 FROM ll_relabel_item rli JOIN ll_relabel_application rla ON rla.id=rli.application_id AND rla.customer_id=rli.customer_id " + + "WHERE ta.customer_id=#{customerId} AND ta.status='RESERVED' AND tp.pallet_id=p.warehouse_pallet_id)) " + + "AND (ip.creation_mode='WAREHOUSE_TRANSFER' OR NOT EXISTS (SELECT 1 FROM ll_relabel_item rli JOIN ll_relabel_application rla ON rla.id=rli.application_id AND rla.customer_id=rli.customer_id " + "JOIN ll_inbound_plan_line rpl ON rpl.order_id=rli.source_inbound_order_id " + "WHERE rli.customer_id=#{customerId} AND rli.source_inbound_order_id IS NOT NULL AND rpl.id IN " + "(SELECT pa.line_id FROM ll_inbound_plan_pallet_allocation pa WHERE pa.pallet_id=p.id) " + - "AND rla.status NOT IN ('REJECTED','CANCELLED','COMPLETED')) " + + "AND rla.status NOT IN ('REJECTED','CANCELLED','COMPLETED'))) " + "GROUP BY p.plan_id,p.id,p.warehouse_pallet_id,p.pallet_no,p.destination_warehouse_id," + "p.destination_warehouse_code,p.destination_warehouse_name,p.allocated_volume_cbm " + "ORDER BY p.id FOR UPDATE" + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java index 2500ce175..9d9f22994 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java @@ -241,7 +241,12 @@ public class DispatchPdaWorkflowService { } } for (DispatchItem item : dispatchItems) { - if (item.getOrderId() != null) warehouseTransfers.markOutboundByOrder(customerId, item.getOrderId(), now); + // A relabel dispatch may consume only part of a warehouse-transfer + // pallet. Do not close the whole transfer by its parent order; the + // remaining cartons still need destination binding/outbound. + if (item.getOrderId() != null && !"RELABEL_ORDER".equals(item.getSourceBusinessType())) { + warehouseTransfers.markOutboundByOrder(customerId, item.getOrderId(), now); + } if ("RELABEL_ORDER".equals(item.getSourceBusinessType()) && item.getSourceBusinessNo() != null) { warehouseTransfers.markOutboundByRelabel(customerId, item.getSourceBusinessNo(), now); } 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 56263ab90..419d2e3f4 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 @@ -53,6 +53,12 @@ public class MerchantRelabelApplicationController { @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String relabelNo) { return ApiResponse.success(service.detail(customer(user), relabelNo)); } + @PutMapping("/{relabelNo}/resubmit") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_CREATE + "')") + public ApiResponse resubmit(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @Valid @RequestBody RelabelRequests.Resubmit request) { + return ApiResponse.success(service.resubmit(customer(user), relabelNo, operator(user), request)); + } @PostMapping(value = "/{relabelNo}/shipping-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_FILES + "')") public ApiResponse uploadShippingLabel(@AuthenticationPrincipal AuthenticatedUser user, 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 f43b27f0e..4595244e2 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 @@ -129,6 +129,12 @@ public final class RelabelRequests { public record ChangeStatus(@NotBlank @Size(max = 32) String status, @Size(max = 1000) String remark) {} + public record Resubmit(@NotNull LocalDateTime labelDeadline, + @NotBlank @Size(max = 128) String contactName, + @NotBlank @Size(max = 64) String contactPhone, + @Size(max = 1000) String remark, + @NotBlank @Size(max = 255) String idempotencyKey) {} + 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 051a9659f..9e8d44564 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 @@ -18,7 +18,7 @@ public final class RelabelResponses { String walletErrorMessage, String deliveryMethod, boolean printShippingLabel, String shippingLabelFileId, String shippingLabelFileName, String shippingLabelFileUrl, String fulfillmentStatus, String outboundOrderNo, LocalDateTime customerConfirmedAt, - LocalDateTime createdAt) {} + String reviewChannel, String reviewRemark, LocalDateTime createdAt) {} public record Item(Long id, String skuCode, String productImageFileId, String productImageFileName, String productImageUrl, String productNameZh, int totalPackageCount, int notifiedPackageCount, String shipmentNo, String trackingCode, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java index dc74a0191..fc19f9bce 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java @@ -9,7 +9,8 @@ public final class PdaRelabelResponses { public record Candidate(String relabelNo, String customerOrderNo, LocalDateTime labelDeadline, String applicationStatus, String taskStatus, String operationType, int itemCount, int expectedPackages, int relabelledPackages, int skippedPackages, - int exceptionPackages, int version, boolean claimedByCurrentOperator) { } + int exceptionPackages, int version, boolean claimedByCurrentOperator, + String reviewDecision, String reviewRemark, LocalDateTime reviewedAt) { } public record Detail(String relabelNo, String customerOrderNo, LocalDateTime labelDeadline, String contactName, String contactPhone, String applicationStatus, String operationType, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelTaskMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelTaskMapper.java index 93c912595..1668781b3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelTaskMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelTaskMapper.java @@ -35,7 +35,8 @@ public interface PdaRelabelTaskMapper extends BaseMapper { relabelled_packages=#{task.relabelledPackages}, skipped_packages=#{task.skippedPackages}, exception_packages=#{task.exceptionPackages}, completion_photo_file_ids=#{task.completionPhotoFileIds}, completion_remark=#{task.completionRemark}, review_decision=#{task.reviewDecision}, - review_remark=#{task.reviewRemark}, submitted_at=#{task.submittedAt}, reviewed_at=#{task.reviewedAt}, + review_remark=#{task.reviewRemark}, claimed_at=#{task.claimedAt}, + submitted_at=#{task.submittedAt}, reviewed_at=#{task.reviewedAt}, version=version+1, updated_at=#{task.updatedAt} WHERE id=#{task.id} AND customer_id=#{task.customerId} AND version=#{expectedVersion} """) 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 2f783ae44..ec1a14951 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 @@ -53,6 +53,7 @@ import java.util.stream.Collectors; @Service public class PdaRelabelService { @Resource private com.baiyue.lastleg.relabel.service.RelabelBindingGuard bindingGuard; + public static final String AVAILABLE = "AVAILABLE"; public static final String PROCESSING = "PROCESSING"; public static final String WAIT_REVIEW = "WAIT_REVIEW"; public static final String COMPLETED = "COMPLETED"; @@ -116,7 +117,16 @@ public class PdaRelabelService { throw conflict("当前账号正在执行换标单 " + activeTask.getRelabelNo() + ",请先完成后再操作其他换标单"); } if (task != null) { - if (!operatorKey.equals(task.getOperatorKey()) || !PROCESSING.equals(task.getStatus())) { + if (AVAILABLE.equals(task.getStatus()) && "REWORK".equals(task.getReviewDecision())) { + int expectedVersion = task.getVersion(); + task.setStatus(PROCESSING); + task.setOperatorKey(operatorKey); + task.setOperatorName(operatorName); + task.setClaimedAt(LocalDateTime.now()); + updateTask(task, expectedVersion); + insertHistory(task, "REWORK_CLAIM", AVAILABLE, PROCESSING, operatorKey, operatorName, + Map.of("relabelNo", relabelNo)); + } else if (!operatorKey.equals(task.getOperatorKey()) || !PROCESSING.equals(task.getStatus())) { throw conflict("Relabel task is already claimed or awaiting review"); } } else { @@ -488,7 +498,7 @@ public class PdaRelabelService { requireVersion(task, request.expectedVersion()); // Preserve the previous round before freeing the active-label uniqueness constraint. List previousFacts = factList(task); - insertHistory(task, "REWORK_ARCHIVE", WAIT_REVIEW, PROCESSING, operatorKey, operatorName, + insertHistory(task, "REWORK_ARCHIVE", WAIT_REVIEW, AVAILABLE, operatorKey, operatorName, Map.of("facts", previousFacts, "photoFileIds", Objects.toString(task.getCompletionPhotoFileIds(), "[]"), "completionRemark", Objects.toString(task.getCompletionRemark(), ""), "remark", request.remark())); for (PdaRelabelUnitFact fact : previousFacts) { @@ -501,7 +511,7 @@ public class PdaRelabelService { applicationService.resetForRework(application.getCustomerId(), relabelNo, operatorKey, request.remark()); application.setStatus(PROCESSING); String before = task.getStatus(); - String after = PROCESSING; + String after = AVAILABLE; task.setStatus(after); task.setReviewerKey(operatorKey); task.setReviewerName(operatorName); task.setReviewDecision(decision); task.setReviewRemark(trim(request.remark())); task.setReviewedAt(LocalDateTime.now()); @@ -694,7 +704,9 @@ public class PdaRelabelService { task == null ? value(application.getNotifiedPackageCount()) : value(task.getExpectedPackages()), task == null ? 0 : value(task.getRelabelledPackages()), task == null ? 0 : value(task.getSkippedPackages()), task == null ? 0 : value(task.getExceptionPackages()), task == null ? 0 : value(task.getVersion()), - task != null && operatorKey.equals(task.getOperatorKey())); + task != null && PROCESSING.equals(task.getStatus()) && operatorKey.equals(task.getOperatorKey()), + task == null ? null : task.getReviewDecision(), task == null ? null : task.getReviewRemark(), + task == null ? null : task.getReviewedAt()); } private PdaRelabelResponses.Detail detail(RelabelApplication application, PdaRelabelTask task) { 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 bcba7a6c9..ca1600b69 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 @@ -332,7 +332,11 @@ public class RelabelApplicationService implements WalletResultHandler { private RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request, Long existingTailOrderId) { if (!StringUtils.hasText(request.operationWarehouseId())) throw badRequest("Operation warehouse is required"); - boolean requiresOutbound = request.items().stream().anyMatch(item -> !"SKU_LABEL".equals(operationType(item))); + LastlegOrder linkedSourceOrder = existingTailOrderId == null ? null : orders.selectById(existingTailOrderId); + boolean warehouseTransferRelabel = linkedSourceOrder != null + && "WAREHOUSE_TRANSFER".equals(linkedSourceOrder.getOrderType()); + boolean requiresOutbound = warehouseTransferRelabel + || request.items().stream().anyMatch(item -> !"SKU_LABEL".equals(operationType(item))); String deliveryMethod = trim(request.deliveryMethod()); if (requiresOutbound && (!StringUtils.hasText(deliveryMethod) || !DELIVERY_METHODS.contains(deliveryMethod))) { throw badRequest("Delivery method must be TRUCK, PICKUP or EXPRESS when carton labels are changed"); @@ -446,7 +450,7 @@ public class RelabelApplicationService implements WalletResultHandler { application.setPrintShippingLabel(Boolean.TRUE.equals(request.printShippingLabel())); application.setFulfillmentStatus(requiresOutbound ? "WAIT_RELABEL" : "NOT_REQUIRED"); application.setRemark(trim(request.remark())); - application.setStatus(PENDING_PROCESSING); + application.setStatus(PENDING_REVIEW); application.setItemCount(request.items().size()); application.setTotalPackageCount(total); application.setNotifiedPackageCount(notified); @@ -476,7 +480,7 @@ public class RelabelApplicationService implements WalletResultHandler { for (SourceInventory source : sources) { insertItem(customerId, applicationId, source, now); } - insertHistory(customerId, applicationId, null, PENDING_PROCESSING, operatorId, "Relabel application submitted", now); + insertHistory(customerId, applicationId, null, PENDING_REVIEW, operatorId, "Relabel application submitted for review", now); if (existingTailOrderId == null) { tailOrders.forEach(order -> insertOrderHistory(customerId, order.getId(), null, order.getCurrentStatus(), "Relabel application submitted", now)); @@ -541,6 +545,24 @@ public class RelabelApplicationService implements WalletResultHandler { @Transactional public RelabelResponses.Detail changeStatus(String customerId, String relabelNo, String operatorId, RelabelRequests.ChangeStatus request) { + return changeStatus(customerId, relabelNo, operatorId, request, false); + } + + @Transactional + public RelabelResponses.Detail reviewDirectTailOrder(String customerId, long orderId, String operatorId, + RelabelRequests.ChangeStatus request) { + LastlegOrder order = orders.selectById(orderId); + if (order == null || !customerId.equals(order.getCustomerId()) + || !"RELABEL_APPLICATION".equals(order.getOrderType()) + || !StringUtils.hasText(order.getRelabelOrderNo())) { + throw notFound("Direct relabel tail order not found"); + } + return changeStatus(customerId, order.getRelabelOrderNo(), operatorId, request, true); + } + + private RelabelResponses.Detail changeStatus(String customerId, String relabelNo, String operatorId, + RelabelRequests.ChangeStatus request, + boolean directTailOrderReview) { RelabelApplication application = StringUtils.hasText(customerId) ? applications.lockNo(customerId, relabelNo) : applications.lockAnyByRelabelNo(relabelNo); @@ -548,6 +570,16 @@ public class RelabelApplicationService implements WalletResultHandler { customerId = application.getCustomerId(); String next = request.status().trim().toUpperCase(); if (!STATUSES.contains(next)) throw badRequest("Unsupported relabel status"); + if ("REJECTED".equals(next) && !StringUtils.hasText(request.remark())) { + throw badRequest("审核驳回必须填写原因"); + } + LastlegOrder primaryOrder = orders.selectById(application.getLastlegOrderId()); + boolean directApplication = primaryOrder != null && "RELABEL_APPLICATION".equals(primaryOrder.getOrderType()); + if (PENDING_REVIEW.equals(application.getStatus()) && directApplication != directTailOrderReview) { + throw conflict(directApplication + ? "直接创建的贴标单请在尾程订单审核" + : "关联业务产生的换标单请在换标单审核"); + } if (next.equals(application.getStatus())) return detail(customerId, relabelNo); validateTransition(application.getStatus(), next); if (PENDING_REVIEW.equals(application.getStatus()) && PENDING_PROCESSING.equals(next) @@ -614,7 +646,8 @@ public class RelabelApplicationService implements WalletResultHandler { case CUSTOMER_CONFIRM_PENDING -> mainStatus; case "COMPLETED" -> "RETURN_RELABEL".equals(order.getOrderType()) ? "PENDING_ASSIGNMENT" : "COMPLETED"; - case "REJECTED", "CANCELLED" -> "CANCELLED"; + case "REJECTED" -> directApplication ? "REJECTED" : "CANCELLED"; + case "CANCELLED" -> "CANCELLED"; default -> mainStatus; }; // Reviewing/processing a return relabel must not move the already @@ -700,6 +733,51 @@ public class RelabelApplicationService implements WalletResultHandler { return detail(customerId, relabelNo); } + @Transactional + public RelabelResponses.Detail resubmit(String customerId, String relabelNo, String operatorId, + RelabelRequests.Resubmit request) { + RelabelApplication application = applications.lockNo(customerId, relabelNo); + if (application == null) throw notFound("Relabel application not found"); + if (!"REJECTED".equals(application.getStatus())) { + throw conflict("Only a rejected relabel application can be resubmitted"); + } + LocalDateTime now = LocalDateTime.now(); + int holdIndex = 0; + for (RelabelItem item : itemList(application.getId())) { + if (item.getReturnCartonId() != null) { + ReturnInventoryCarton carton = returnCartons.lockId(item.getReturnCartonId()); + if (carton == null || !customerId.equals(carton.getCustomerId()) + || !"AVAILABLE".equals(carton.getStatus()) + || returnCartons.markRelabelPending(carton.getId(), application.getId(), relabelNo, now) != 1) { + throw conflict("退货箱已被其他作业占用,无法重新提交"); + } + } else if (item.getSourceInventoryPositionId() != null + && Objects.requireNonNullElse(item.getNotifiedPackageCount(), 0) > 0) { + inventoryCommands.hold(customerId, operatorId, new InventoryCommandRequests.Move( + item.getSourceInventoryPositionId(), item.getNotifiedPackageCount(), relabelNo, null, + "RELABEL:" + relabelNo, request.idempotencyKey().trim() + ":RESUBMIT-HOLD:" + holdIndex++, + "换标驳回修改后重新冻结库存")); + } + } + application.setLabelDeadline(request.labelDeadline()); + application.setContactName(request.contactName().trim()); + application.setContactPhone(request.contactPhone().trim()); + application.setRemark(trim(request.remark())); + application.setStatus(PENDING_REVIEW); + application.setFulfillmentStatus("WAIT_REVIEW"); + application.setUpdatedAt(now); + applications.updateById(application); + for (LastlegOrder order : tailOrders(application)) { + order.setRelabelStatus(PENDING_REVIEW); + order.setSubOrderStatus("RELABEL:" + PENDING_REVIEW); + order.setUpdatedAt(now); + orders.updateById(order); + } + insertHistory(customerId, application.getId(), "REJECTED", PENDING_REVIEW, operatorId, + request.remark(), now); + return detail(customerId, relabelNo); + } + @Transactional public void resetForRework(String customerId, String relabelNo, String operatorId, String remark) { RelabelApplication application = applications.lockNo(customerId, relabelNo); @@ -801,19 +879,22 @@ public class RelabelApplicationService implements WalletResultHandler { if (linkedTransfer != null) { WarehouseTransferOrder transfer = linkedTransfer; if (transfer != null && "WAIT_RELABEL".equals(transfer.getStatus())) { - Set destinations = relabelItems.stream() - .map(RelabelItem::getDestinationFbaWarehouseCode) - .filter(StringUtils::hasText).map(String::trim).collect(Collectors.toSet()); - if (!StringUtils.hasText(transfer.getFbaWarehouseCode()) && destinations.size() == 1) { - transfer.setFbaWarehouseCode(destinations.iterator().next()); - } - String transferStatus = StringUtils.hasText(transfer.getFbaWarehouseCode()) + // Item-level relabel destinations apply only to those cartons. + // They must never bind the whole transfer, because its remaining + // cartons may still require a separate destination selection. + boolean transferHasDestination = "FBA".equals(transfer.getDestinationType()) + ? StringUtils.hasText(transfer.getDestinationWarehouseCode()) + : "PRIVATE".equals(transfer.getDestinationType()) + && StringUtils.hasText(transfer.getPrivateDestinationAddress()); + String transferStatus = transferHasDestination ? "READY_FOR_DISPATCH" : "WAIT_FBA_DESTINATION"; transfer.setStatus(transferStatus); transfer.setRelabelCompletedAt(now); transfer.setUpdatedAt(now); warehouseTransfers.updateById(transfer); - application.setFulfillmentStatus("READY_FOR_DISPATCH".equals(transferStatus) + boolean relabelHasDestination = !relabelItems.isEmpty() && relabelItems.stream() + .allMatch(item -> StringUtils.hasText(item.getDestinationFbaWarehouseCode())); + application.setFulfillmentStatus("READY_FOR_DISPATCH".equals(transferStatus) || relabelHasDestination ? "WAIT_PRE_DISPATCH" : transferStatus); applications.updateById(application); // 转运单自身的尾程订单与换标申请尾程订单是两条记录,需同步释放原转运单参与排车。 @@ -945,6 +1026,8 @@ public class RelabelApplicationService implements WalletResultHandler { value.getDeliveryMethod(), Boolean.TRUE.equals(value.getPrintShippingLabel()), value.getShippingLabelFileId(), value.getShippingLabelFileName(), value.getShippingLabelFileUrl(), value.getFulfillmentStatus(), value.getOutboundOrderNo(), value.getCustomerConfirmedAt(), + order != null && "RELABEL_APPLICATION".equals(order.getOrderType()) ? "TAIL_ORDER" : "RELABEL_ORDER", + null, value.getCreatedAt()); } @@ -996,8 +1079,8 @@ public class RelabelApplicationService implements WalletResultHandler { .and(w -> w.eq(LastlegOrder::getSourceOrderNo, customerOrderNo).or().eq(LastlegOrder::getOrderNo, customerOrderNo)) .last("LIMIT 1")); if (existingReturn != null) { - existingReturn.setRelabelOrderNo(relabelNo); existingReturn.setRelabelStatus(PENDING_PROCESSING); - existingReturn.setSubOrderStatus("RETURN_RELABEL:" + PENDING_PROCESSING); existingReturn.setPackageCount(packageCount); + existingReturn.setRelabelOrderNo(relabelNo); existingReturn.setRelabelStatus(PENDING_REVIEW); + existingReturn.setSubOrderStatus("RETURN_RELABEL:" + PENDING_REVIEW); existingReturn.setPackageCount(packageCount); existingReturn.setUpdatedAt(now); orders.updateById(existingReturn); return existingReturn; } LastlegOrder order = new LastlegOrder(); @@ -1008,9 +1091,9 @@ public class RelabelApplicationService implements WalletResultHandler { order.setSourceInboundId(relabelNo + ":" + warehouseId); order.setSourceOrderNo(request.customerOrderNo().trim()); order.setOverseasWarehouseId(warehouseId); order.setPalletCount(0); order.setPackageCount(packageCount); order.setPickedPackageCount(0); - order.setShippedPackageCount(0); order.setCurrentStatus("WAIT_RELABEL"); - order.setSubOrderStatus("RELABEL:" + PENDING_PROCESSING); order.setRelabelOrderNo(relabelNo); - order.setRelabelStatus(PENDING_PROCESSING); order.setCustomerRemark(trim(request.remark())); + order.setShippedPackageCount(0); order.setCurrentStatus(PENDING_REVIEW); + order.setSubOrderStatus("RELABEL:" + PENDING_REVIEW); order.setRelabelOrderNo(relabelNo); + order.setRelabelStatus(PENDING_REVIEW); order.setCustomerRemark(trim(request.remark())); order.setSourceEventId("CUSTOMER_RELABEL:" + relabelNo + ":" + warehouseId); order.setSourceLoadingPlanId("RELABEL_APPLICATION"); order.setSourcePayloadHash("sha256:" + requestHash); order.setCreatedAt(now); order.setUpdatedAt(now); orders.insert(order); @@ -1023,8 +1106,12 @@ public class RelabelApplicationService implements WalletResultHandler { if (order == null || !customerId.equals(order.getCustomerId())) { throw conflict("Existing tail order does not belong to this customer"); } - order.setRelabelOrderNo(relabelNo); order.setRelabelStatus(PENDING_PROCESSING); - order.setPackageCount(packageCount); order.setUpdatedAt(now); orders.updateById(order); + order.setRelabelOrderNo(relabelNo); order.setRelabelStatus(PENDING_REVIEW); + // A linked warehouse-transfer relabel can cover only part of the + // transfer. Keep the authoritative whole-order package count so later + // dispatches can attribute the remaining cartons correctly. + if (!"WAREHOUSE_TRANSFER".equals(order.getOrderType())) order.setPackageCount(packageCount); + order.setUpdatedAt(now); orders.updateById(order); return order; } 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 140c50ef6..882719a4c 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 @@ -30,6 +30,7 @@ public final class WarehouseTransferResponses { String lastlegOrderNo, String operatingWarehouseId, String fbaWarehouseCode, String destinationType, String destinationWarehouseCode, String destinationWarehouseName, int packageCount, String deliveryMethod, boolean needRelabel, String relabelNo, + String relabelApplicationStatus, String relabelFulfillmentStatus, String status, LocalDateTime estimatedArrivalAt, LocalDateTime createdAt) {} public record Detail(Long id, String transferNo, Long inboundOrderId, String inboundCode, Long lastlegOrderId, String lastlegOrderNo, String orderType, String orderStatus, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java index ceb53edcc..bf0be19c6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java @@ -23,7 +23,11 @@ import org.apache.ibatis.annotations.Update; @Update("UPDATE ll_warehouse_transfer_order SET status='OUTBOUNDED',outbound_at=#{outboundAt},updated_at=#{outboundAt} WHERE customer_id=#{customerId} AND lastleg_order_id=#{orderId} AND outbound_at IS NULL") int markOutboundByOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId, @Param("outboundAt") java.time.LocalDateTime outboundAt); - @Update("UPDATE ll_warehouse_transfer_order SET status='OUTBOUNDED',outbound_at=#{outboundAt},updated_at=#{outboundAt} WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} AND outbound_at IS NULL") + @Update("UPDATE ll_warehouse_transfer_order wto SET wto.status='OUTBOUNDED',wto.outbound_at=#{outboundAt},wto.updated_at=#{outboundAt} " + + "WHERE wto.customer_id=#{customerId} AND wto.relabel_no=#{relabelNo} AND wto.outbound_at IS NULL " + + "AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_pallet wtp " + + "JOIN ll_inventory_position ip ON ip.customer_id=wtp.customer_id AND ip.pallet_id=wtp.warehouse_pallet_id " + + "WHERE wtp.customer_id=wto.customer_id AND wtp.transfer_order_id=wto.id AND ip.available_packages>0)") int markOutboundByRelabel(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo, @Param("outboundAt") java.time.LocalDateTime outboundAt); } 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 825deef0d..876c3508c 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 @@ -147,8 +147,10 @@ public class WarehouseTransferService { validateDestination(request.destinationType(), request.destinationWarehouseCode(), request.fbaWarehouseCode(), request.privateDestinationAddress(), request.privateDestinationPostalCode(), request.privateDestinationContactName(), request.privateDestinationContactPhone()); - validateTransferDestinationRelabel(request.destinationType(), request.fbaWarehouseCode(), request.destinationWarehouseCode(), - request.needRelabel()); + // A split relabel transfer may intentionally defer the whole-order + // destination: relabeled cartons go through relabel dispatch while the + // remaining cartons are bound later from warehouse-transfer outbound. + // All other transfer types must still select FBA or PRIVATE up front. String key = request.idempotencyKey().trim(); String requestHash = hash(request); WarehouseTransferOrder replay = transfers.findKey(customerId, key); @@ -279,8 +281,6 @@ public class WarehouseTransferService { validateDestination(request.destinationType(), request.destinationWarehouseCode(), request.fbaWarehouseCode(), request.privateDestinationAddress(), request.privateDestinationPostalCode(), request.privateDestinationContactName(), request.privateDestinationContactPhone()); - validateTransferDestinationRelabel(request.destinationType(), request.fbaWarehouseCode(), request.destinationWarehouseCode(), - request.needRelabel()); LastlegOrder order = requireTailOrder(customerId, transfer); LocalDateTime now = LocalDateTime.now(); @@ -422,12 +422,16 @@ public class WarehouseTransferService { .le(query.createdTo() != null, WarehouseTransferOrder::getCreatedAt, query.createdTo()) .orderByDesc(WarehouseTransferOrder::getCreatedAt).orderByDesc(WarehouseTransferOrder::getId); Page result = transfers.selectPage(Page.of(query.page(), query.size()), wrapper); + Map relabelApplicationsByNo = relabelApplications(result.getRecords()); List records = result.getRecords().stream().map(value -> new WarehouseTransferResponses.Summary(value.getId(), value.getTransferNo(), value.getInboundCode(), value.getLastlegOrderId(), value.getLastlegOrderNo(), value.getOperatingWarehouseId(), value.getFbaWarehouseCode(), value.getDestinationType(), value.getDestinationWarehouseCode(), value.getDestinationWarehouseName(), value.getPackageCount(), value.getDeliveryMethod(), - Boolean.TRUE.equals(value.getNeedRelabel()), value.getRelabelNo(), value.getStatus(), + Boolean.TRUE.equals(value.getNeedRelabel()), value.getRelabelNo(), + relabelApplicationsByNo.get(value.getRelabelNo()) == null ? null : relabelApplicationsByNo.get(value.getRelabelNo()).getStatus(), + relabelApplicationsByNo.get(value.getRelabelNo()) == null ? null : relabelApplicationsByNo.get(value.getRelabelNo()).getFulfillmentStatus(), + value.getStatus(), value.getEstimatedArrivalAt(), value.getCreatedAt())).toList(); return new PageResult<>(records, result.getCurrent(), result.getSize(), result.getTotal(), result.getPages()); } @@ -911,7 +915,7 @@ public class WarehouseTransferService { transfers.updateById(transfer); LastlegOrder order = requireTailOrder(customerId, transfer); String before = order.getCurrentStatus(); order.setCurrentStatus(INBOUND_COMPLETED); - order.setRelabelStatus(Boolean.TRUE.equals(transfer.getNeedRelabel()) ? "PENDING_PROCESSING" : "NOT_REQUIRED"); + order.setRelabelStatus(Boolean.TRUE.equals(transfer.getNeedRelabel()) ? "PENDING_REVIEW" : "NOT_REQUIRED"); order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + transfer.getStatus() + ";RELABEL:" + order.getRelabelStatus()); order.setUpdatedAt(now); orders.updateById(order); if (!java.util.Objects.equals(before, order.getCurrentStatus())) { @@ -1214,18 +1218,34 @@ public class WarehouseTransferService { @Transactional(readOnly = true) public List pdaCandidates(long overseasWarehouseId) { - return transfers.selectList(new LambdaQueryWrapper() + List candidates = transfers.selectList(new LambdaQueryWrapper() .eq(WarehouseTransferOrder::getOperatingWarehouseId, String.valueOf(overseasWarehouseId)) .in(WarehouseTransferOrder::getStatus, List.of(WAIT_PDA_RECEIVING, APPROVED, ALLOCATED, WAIT_RELABEL, READY_FOR_DISPATCH)) - .orderByDesc(WarehouseTransferOrder::getUpdatedAt)).stream().map(value -> + .orderByDesc(WarehouseTransferOrder::getUpdatedAt)); + Map relabelApplicationsByNo = relabelApplications(candidates); + return candidates.stream().map(value -> new WarehouseTransferResponses.Summary(value.getId(), value.getTransferNo(), value.getInboundCode(), value.getLastlegOrderId(), value.getLastlegOrderNo(), value.getOperatingWarehouseId(), value.getFbaWarehouseCode(), value.getDestinationType(), value.getDestinationWarehouseCode(), value.getDestinationWarehouseName(), value.getPackageCount(), value.getDeliveryMethod(), - Boolean.TRUE.equals(value.getNeedRelabel()), value.getRelabelNo(), value.getStatus(), + Boolean.TRUE.equals(value.getNeedRelabel()), value.getRelabelNo(), + relabelApplicationsByNo.get(value.getRelabelNo()) == null ? null : relabelApplicationsByNo.get(value.getRelabelNo()).getStatus(), + relabelApplicationsByNo.get(value.getRelabelNo()) == null ? null : relabelApplicationsByNo.get(value.getRelabelNo()).getFulfillmentStatus(), + value.getStatus(), value.getEstimatedArrivalAt(), value.getCreatedAt())).toList(); } + private Map relabelApplications(List transferOrders) { + Set relabelNos = transferOrders.stream().map(WarehouseTransferOrder::getRelabelNo) + .filter(StringUtils::hasText).collect(Collectors.toSet()); + if (relabelNos.isEmpty()) return Map.of(); + List applications = relabelApplications.selectList( + new LambdaQueryWrapper().in(RelabelApplication::getRelabelNo, relabelNos)); + if (applications == null || applications.isEmpty()) return Map.of(); + return applications.stream().collect(Collectors.toMap(RelabelApplication::getRelabelNo, + Function.identity(), (left, right) -> right)); + } + @Transactional(readOnly = true) public WarehouseTransferResponses.Detail pdaDetail(long overseasWarehouseId, String transferNo) { WarehouseTransferOrder transfer = transfers.findNoByWarehouse(transferNo, String.valueOf(overseasWarehouseId)); @@ -1391,19 +1411,13 @@ public class WarehouseTransferService { } } - private static void validateTransferDestinationRelabel(String destinationType, String fbaWarehouseCode, - String destinationWarehouseCode, boolean needRelabel) { + private static void validateTransferDestinationRequired(String destinationType, String fbaWarehouseCode, + boolean allowDeferredForRelabel) { String type = trim(destinationType); if (type == null && StringUtils.hasText(fbaWarehouseCode)) type = "FBA"; - if (type == null || "NONE".equalsIgnoreCase(type)) { + if ((type == null || "NONE".equalsIgnoreCase(type)) && !allowDeferredForRelabel) { throw badRequest("仓储转运必须选择 FBA 仓或私人仓"); } - if ("FBA".equalsIgnoreCase(type) && !needRelabel) { - throw badRequest("转运到 FBA 仓必须同时创建贴标单"); - } - if ("PRIVATE".equalsIgnoreCase(type) && needRelabel) { - throw badRequest("不需要贴标时请选择私人仓作为目的地"); - } } private static void applyDestination(WarehouseTransferOrder transfer, WarehouseTransferRequests.Create request) { @@ -1598,7 +1612,7 @@ public class WarehouseTransferService { transfer.getOperatingWarehouseId(), transfer.getLabelDeadline(), transfer.getRelabelContactName(), transfer.getRelabelContactPhone(), transfer.getRelabelChargeAmount(), "仓储转运确认后自动创建换标单", "WAREHOUSE_TRANSFER:" + transfer.getTransferNo() + ":RELABEL", items, - trim(transfer.getRelabelDeliveryMethod()), false); + firstText(transfer.getRelabelDeliveryMethod(), "TRUCK"), false); return relabelService.createForExistingTailOrder(customerId, operatorId, transfer.getLastlegOrderId(), request); } 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 ee70f6513..9f7836de1 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -240,6 +240,16 @@ paths: responses: '200': { description: 统一 ApiResponse 包装的尾程订单详情;order.relabelApplications 返回所有换标子单的独立状态和本次数量 } '404': { description: 尾程订单不存在或不属于查询客户 } + /api/platform/v1/last-leg/orders/{id}/relabel-review: + put: + summary: 在尾程订单工作台审核客户直接创建的贴标单 + description: 权限 platform:overseas-warehouse:last-mile-order:edit;仅 RELABEL_APPLICATION + PENDING_REVIEW 可审核。通过进入 WAIT_RELABEL,驳回必须填写原因并保持 REJECTED 供客户修改后重提。 + security: [{ bearerAuth: [] }] + parameters: + - { name: id, in: path, required: true, schema: { type: integer, format: int64 } } + - { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelStatusChange' } } } } + responses: { '200': { description: 审核后的贴标单详情 }, '400': { description: 驳回原因缺失或决定非法 }, '409': { description: 非直接贴标单、审核入口错误或状态冲突 } } /api/platform/v1/last-leg/orders/{id}/fees: post: summary: 新增尾程订单费用 @@ -1001,7 +1011,7 @@ paths: /api/merchant/v1/last-leg/warehouse-transfers: get: summary: 客户分页查询自己的仓储转运单 - description: 权限 merchant:overseas-warehouse:transfer:list;列表权限包含详情只读。 + description: 权限 merchant:overseas-warehouse:transfer:list;列表权限包含详情只读;每行同时返回关联换标单号 relabelNo、换标申请流程状态 relabelStatus 与履约状态 relabelFulfillmentStatus。 security: [{ bearerAuth: [] }] responses: { '200': { description: 当前客户仓储转运单分页 }, '403': { description: 缺少仓储转运查看权限 } } post: @@ -1355,7 +1365,7 @@ paths: /api/platform/v1/last-leg/relabel-applications/{relabelNo}/status: put: summary: 变更海外换标单状态 - description: 权限 platform:overseas-warehouse:relabel-order:status;PDA复核通过后进入CUSTOMER_CONFIRM_PENDING,客户确认后进入READY_FOR_OUTBOUND,再由排车或直接出库履约。 + 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 } } @@ -1373,7 +1383,7 @@ paths: /api/platform/v1/last-leg/relabel-applications/{relabelNo}/files: post: summary: 上传总换标PDF或明细换标PDF - description: 权限 platform:overseas-warehouse:relabel-order:create。仅PENDING_PROCESSING/PROCESSING可替换,PDF内容且不超过10MiB。客户从申请单解析,ITEM必须属于该申请单同客户。File类别分别为OVERSEAS_RELABEL_TOTAL_DOCUMENT(application.id)及OVERSEAS_RELABEL_ITEM_DOCUMENT(item.id)。成功保存File返回的资料元数据,旧资料不再允许通过当前绑定下载。 + description: 权限 platform:overseas-warehouse:relabel-order:create。PENDING_REVIEW/PENDING_PROCESSING/PROCESSING/REJECTED 可替换,PDF内容且不超过10MiB。客户从申请单解析,ITEM必须属于该申请单同客户。成功保存File返回的资料元数据,旧资料不再允许通过当前绑定下载。 security: [{ bearerAuth: [] }] parameters: - { name: relabelNo, in: path, required: true, schema: { type: string } } @@ -1458,10 +1468,18 @@ paths: responses: '200': { description: 换标申请详情, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelMerchantDetailResponse' } } } } '404': { description: 申请不存在或不属于当前客户 } + /api/merchant/v1/last-leg/relabel-applications/{relabelNo}/resubmit: + put: + summary: 修改并重新提交已驳回贴标单 + description: 权限 merchant:overseas-warehouse:relabel:create;客户可先替换贴标资料,再修改截止时间、联系人、电话和备注并重新提交审核。重新冻结原库存和费用,直接贴标回到尾程订单审核,关联换标回到换标单审核。 + security: [{ bearerAuth: [] }] + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelResubmit' } } } } + responses: { '200': { description: 重新提交后的贴标单详情 }, '409': { description: 非驳回状态、库存已变化或费用尚未释放 } } /api/merchant/v1/last-leg/relabel-applications/{relabelNo}/shipping-label: post: summary: 上传换标快递面单 - description: 权限 merchant:overseas-warehouse:relabel:files;仅 EXPRESS 且申请尚未客户确认时允许上传,文件由服务端绑定到申请单。 + description: 权限 merchant:overseas-warehouse:relabel:files;仅 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 } } } } } } @@ -2589,7 +2607,7 @@ components: fbaShipmentId: { type: string, nullable: true, description: 来源冻结货件编号;多值以逗号分隔 } poReferenceNo: { type: string, nullable: true, description: 来源冻结FBA跟踪/参考号;多值以逗号分隔 } packageCount: { type: integer, minimum: 0 } - RelabelStatus: { type: string, enum: [PENDING_PROCESSING, PROCESSING, CUSTOMER_CONFIRM_PENDING, READY_FOR_OUTBOUND, COMPLETED, REJECTED, CANCELLED] } + RelabelStatus: { type: string, enum: [PENDING_REVIEW, PENDING_PROCESSING, PROCESSING, CUSTOMER_CONFIRM_PENDING, READY_FOR_OUTBOUND, COMPLETED, REJECTED, CANCELLED] } RelabelMerchantPageResponse: allOf: - { $ref: '#/components/schemas/MerchantApiResponse' } @@ -2626,6 +2644,8 @@ components: 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 } + reviewChannel: { type: string, enum: [TAIL_ORDER, RELABEL_ORDER], description: TAIL_ORDER 表示客户直接贴标,RELABEL_ORDER 表示其他业务关联换标 } + reviewRemark: { type: string, nullable: true, description: 当前驳回状态最近一次审核驳回原因 } createdAt: { type: string, format: date-time } RelabelDetail: type: object @@ -2764,6 +2784,15 @@ components: properties: status: { $ref: '#/components/schemas/RelabelStatus' } remark: { type: string, nullable: true, maxLength: 1000 } + RelabelResubmit: + type: object + required: [labelDeadline, contactName, contactPhone, idempotencyKey] + properties: + labelDeadline: { type: string, format: date-time } + contactName: { type: string, minLength: 1, maxLength: 128 } + contactPhone: { type: string, minLength: 1, maxLength: 64 } + remark: { type: string, nullable: true, maxLength: 1000 } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } WalletFreezeRetry: type: object required: [idempotencyKey] 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 73a870fb3..d6a50dfbb 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 @@ -113,6 +113,31 @@ class PdaRelabelServiceTest { verify(tasks, never()).insert(any(PdaRelabelTask.class)); } + @Test + void claimReworkTaskOneAtATimeAndAssignsItToCurrentOperator() { + application.setStatus("PROCESSING"); + PdaRelabelTask rework = task("AVAILABLE", 3); + rework.setOperatorKey(null); + rework.setOperatorName(null); + rework.setReviewDecision("REWORK"); + rework.setReviewRemark("label is crooked"); + when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application); + when(tasks.lock("100", 1L)).thenReturn(rework); + + var result = service.claim("7", "picker", 900L, "FBR-1", + new PdaRelabelRequests.Claim("claim-rework")); + + assertEquals("PROCESSING", result.taskStatus()); + assertEquals("REWORK", result.reviewDecision()); + assertEquals("label is crooked", result.reviewRemark()); + assertEquals("7", rework.getOperatorKey()); + assertEquals("picker", rework.getOperatorName()); + assertEquals(4, rework.getVersion()); + verify(tasks).lockProcessingByOperator("7"); + verify(tasks).updateWithVersion(rework, 3); + verify(tasks, never()).insert(any(PdaRelabelTask.class)); + } + @Test void workPhotoDownloadValidatesTaskOwnershipAndFileScope() { application.setStatus("PROCESSING"); @@ -369,10 +394,12 @@ class PdaRelabelServiceTest { var result = service.returnForRework("8", "reviewer", "FBR-1", new PdaRelabelRequests.Review("REWORK", 2, "review-rework", "wrong label")); - assertEquals("PROCESSING", result.taskStatus()); + assertEquals("AVAILABLE", result.taskStatus()); assertEquals("PROCESSING", result.applicationStatus()); assertEquals(0, result.relabelledPackages()); assertEquals("wrong label", result.reviewRemark()); + assertEquals("7", task.getOperatorKey()); + assertEquals("picker", task.getOperatorName()); assertTrue(result.completionPhotoFileIds().isEmpty()); verify(applicationService).resetForRework("100", "FBR-1", "8", "wrong label"); verify(applicationService, never()).changeStatus(anyString(), anyString(), anyString(), any()); 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 6e5db795d..9beb01f8b 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 @@ -126,7 +126,7 @@ class RelabelApplicationServiceTest { void createBuildsPendingRelabelTailOrderItemsAndBothHistories() { RelabelRequests.Create request = request("key-1", 10, 6); RelabelApplication persisted = application(11L, "FBR-1", "10001", "key-1", null, - RelabelApplicationService.PENDING_PROCESSING); + RelabelApplicationService.PENDING_REVIEW); persisted.setLastlegOrderId(21L); persisted.setItemCount(1); persisted.setTotalPackageCount(10); persisted.setNotifiedPackageCount(6); when(applications.findNo(eq("10001"), any())).thenReturn(persisted); @@ -149,8 +149,8 @@ class RelabelApplicationServiceTest { verify(orderHistories).insert(tailHistory.capture()); assertEquals("RELABEL_APPLICATION", tailOrder.getValue().getOrderType()); - assertEquals("WAIT_RELABEL", tailOrder.getValue().getCurrentStatus()); - assertEquals("RELABEL:PENDING_PROCESSING", tailOrder.getValue().getSubOrderStatus()); + assertEquals("PENDING_REVIEW", tailOrder.getValue().getCurrentStatus()); + assertEquals("RELABEL:PENDING_REVIEW", tailOrder.getValue().getSubOrderStatus()); assertEquals("CUSTOMER", tailOrder.getValue().getSourceRegion()); assertTrue(tailOrder.getValue().getOrderNo().matches("RL-WH-\\d{8}-\\d{6}")); assertNull(tailOrder.getValue().getInboundCode()); @@ -159,8 +159,8 @@ class RelabelApplicationServiceTest { assertEquals(10, tailOrder.getValue().getPackageCount()); assertEquals(6, application.getValue().getNotifiedPackageCount()); assertEquals("SKU-A", relabelItem.getValue().getSkuCode()); - assertEquals("PENDING_PROCESSING", relabelHistory.getValue().getToStatus()); - assertEquals("WAIT_RELABEL", tailHistory.getValue().getToStatus()); + assertEquals("PENDING_REVIEW", relabelHistory.getValue().getToStatus()); + assertEquals("PENDING_REVIEW", tailHistory.getValue().getToStatus()); assertEquals("FBR-1", result.application().relabelNo()); } @@ -168,7 +168,7 @@ class RelabelApplicationServiceTest { void warehouseTransferRelabelReusesExistingTailOrder() { RelabelRequests.Create request = request("key-transfer", 10, 6); RelabelApplication persisted = application(11L, "FBR-TRANSFER", "10001", "key-transfer", null, - RelabelApplicationService.PENDING_PROCESSING); + RelabelApplicationService.PENDING_REVIEW); persisted.setLastlegOrderId(21L); LastlegOrder transferOrder = order(21L, "10001", "WT-WH-20260916-560449", "INBOUND_COMPLETED"); transferOrder.setOrderType("WAREHOUSE_TRANSFER"); @@ -185,9 +185,38 @@ class RelabelApplicationServiceTest { verify(orderHistories, never()).insert(any(LastlegOrderStatusHistory.class)); assertEquals(21L, application.getValue().getLastlegOrderId()); assertEquals(application.getValue().getRelabelNo(), transferOrder.getRelabelOrderNo()); - assertEquals(RelabelApplicationService.PENDING_PROCESSING, transferOrder.getRelabelStatus()); + assertEquals(RelabelApplicationService.PENDING_REVIEW, transferOrder.getRelabelStatus()); assertEquals("WAREHOUSE_TRANSFER", transferOrder.getOrderType()); assertEquals("INBOUND_COMPLETED", transferOrder.getCurrentStatus()); + assertEquals("WAIT_RELABEL", application.getValue().getFulfillmentStatus()); + } + + @Test + void warehouseTransferSkuOnlyRelabelStillRequiresOutboundFulfillment() { + RelabelRequests.Item skuOnly = new RelabelRequests.Item(101L, 201L, 301L, + "SKU-A", null, null, null, "Chinese name", 1, 1, + "WT-WH-1", null, "SKU-NEW", "SKU relabel", null, + null, null, null, "CARTON-1", null, "XLX1", "SKU_LABEL", + List.of(new RelabelRequests.SkuItem("SKU-A", "SKU-NEW", 1, null))); + RelabelRequests.Create request = new RelabelRequests.Create("WT-WH-1", "WH-1", + LocalDateTime.of(2026, 9, 25, 18, 0), "Li", "13800000000", null, + "warehouse transfer", "key-transfer-sku", List.of(skuOnly), "TRUCK", false); + RelabelApplication persisted = application(11L, "FBR-TRANSFER-SKU", "10001", + "key-transfer-sku", null, RelabelApplicationService.PENDING_REVIEW); + persisted.setLastlegOrderId(21L); + LastlegOrder transferOrder = order(21L, "10001", "WT-WH-1", "INBOUND_COMPLETED"); + transferOrder.setOrderType("WAREHOUSE_TRANSFER"); + when(applications.findNo(eq("10001"), any())).thenReturn(persisted); + when(items.selectList(any())).thenReturn(List.of(item(31L, 11L, "SKU-A", 1, 1))); + when(histories.selectList(any())).thenReturn(List.of()); + when(orders.selectById(21L)).thenReturn(transferOrder); + + service.createForExistingTailOrder("10001", "operator-1", 21L, request); + + ArgumentCaptor application = ArgumentCaptor.forClass(RelabelApplication.class); + verify(applications).insert(application.capture()); + assertEquals("TRUCK", application.getValue().getDeliveryMethod()); + assertEquals("WAIT_RELABEL", application.getValue().getFulfillmentStatus()); } @Test @@ -465,6 +494,56 @@ class RelabelApplicationServiceTest { verify(walletCommands, never()).publish(any(), any(Long.class), any(), any(), any(), any(), any(Long.class)); } + @Test + void directLabelingOrderCanOnlyBeApprovedFromTailOrderReview() { + RelabelApplication application = application(11L, "FBR-DIRECT", "10001", "key-direct", "hash", + RelabelApplicationService.PENDING_REVIEW); + application.setLastlegOrderId(21L); + LastlegOrder order = order(21L, "10001", "LMO-DIRECT", RelabelApplicationService.PENDING_REVIEW); + order.setOrderType("RELABEL_APPLICATION"); order.setRelabelOrderNo("FBR-DIRECT"); + stubDetail(application); + when(applications.lockNo("10001", "FBR-DIRECT")).thenReturn(application); + when(orders.selectById(21L)).thenReturn(order); + + ResponseStatusException wrongEntry = assertThrows(ResponseStatusException.class, () -> + service.changeStatus("10001", "FBR-DIRECT", "operator-2", + new RelabelRequests.ChangeStatus("PENDING_PROCESSING", null))); + assertEquals(409, wrongEntry.getStatusCode().value()); + + service.reviewDirectTailOrder("10001", 21L, "operator-2", + new RelabelRequests.ChangeStatus("PENDING_PROCESSING", null)); + + assertEquals(RelabelApplicationService.PENDING_PROCESSING, application.getStatus()); + assertEquals("WAIT_RELABEL", order.getCurrentStatus()); + assertEquals("RELABEL:PENDING_PROCESSING", order.getSubOrderStatus()); + } + + @Test + void directLabelingRejectionRequiresAndKeepsReviewRemark() { + RelabelApplication application = application(11L, "FBR-DIRECT", "10001", "key-direct", "hash", + RelabelApplicationService.PENDING_REVIEW); + application.setLastlegOrderId(21L); + LastlegOrder order = order(21L, "10001", "LMO-DIRECT", RelabelApplicationService.PENDING_REVIEW); + order.setOrderType("RELABEL_APPLICATION"); order.setRelabelOrderNo("FBR-DIRECT"); + stubDetail(application); + when(applications.lockNo("10001", "FBR-DIRECT")).thenReturn(application); + when(orders.selectById(21L)).thenReturn(order); + + ResponseStatusException missingReason = assertThrows(ResponseStatusException.class, () -> + service.reviewDirectTailOrder("10001", 21L, "operator-2", + new RelabelRequests.ChangeStatus("REJECTED", " "))); + assertEquals(400, missingReason.getStatusCode().value()); + + service.reviewDirectTailOrder("10001", 21L, "operator-2", + new RelabelRequests.ChangeStatus("REJECTED", "请替换模糊的箱唛文件")); + + assertEquals("REJECTED", application.getStatus()); + assertEquals("REJECTED", order.getCurrentStatus()); + ArgumentCaptor history = ArgumentCaptor.forClass(RelabelStatusHistory.class); + verify(histories).insert(history.capture()); + assertEquals("请替换模糊的箱唛文件", history.getValue().getRemark()); + } + @Test void completedReturnRelabelKeepsParentInboundCompleted() { RelabelApplication application = application(11L, "FBR-1", "10001", "key-1", "hash", RelabelApplicationService.READY_FOR_OUTBOUND); 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 7ecafb8ee..927352348 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 @@ -44,6 +44,7 @@ import com.baiyue.lastleg.transfer.gateway.TransferMeasurementPhotoFileGateway; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferReceiptMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferSourceOrderMapper; import com.fasterxml.jackson.databind.ObjectMapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -90,6 +91,28 @@ class WarehouseTransferServiceTest { assertNull(WarehouseTransferService.customerMainStatuses(WarehouseTransferService.DRAFT)); } + @Test + void transferPageReturnsRelabelApplicationAndFulfillmentStatuses() { + WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_RELABEL); + transfer.setNeedRelabel(true); + transfer.setRelabelNo("RL-20260923-000001"); + Page resultPage = new Page<>(1, 20, 1); + resultPage.setRecords(List.of(transfer)); + RelabelApplication relabel = new RelabelApplication(); + relabel.setRelabelNo("RL-20260923-000001"); + relabel.setStatus("COMPLETED"); + relabel.setFulfillmentStatus("READY_FOR_DISPATCH"); + when(transfers.selectPage(any(), any())).thenReturn(resultPage); + when(relabelApplications.selectList(any())).thenReturn(List.of(relabel)); + + var result = service.page("TENANT-A", new WarehouseTransferRequests.Query( + 1, 20, null, null, null, null, null, null, null, null)); + + assertEquals("RL-20260923-000001", result.records().get(0).relabelNo()); + assertEquals("COMPLETED", result.records().get(0).relabelStatus()); + assertEquals("READY_FOR_DISPATCH", result.records().get(0).relabelFulfillmentStatus()); + } + @Mock WarehouseTransferOrderMapper transfers; @Mock WarehouseTransferItemMapper transferItems; @Mock WarehouseTransferCartonMapper transferCartons; @@ -328,9 +351,9 @@ class WarehouseTransferServiceTest { } @Test - void transferCanEnterTheTransitWarehouseBeforeFbaDestinationIsKnown() { + void transferCanEnterTheTransitWarehouseWithFbaDestinationAndNoRelabel() { WarehouseTransferRequests.Create request = new WarehouseTransferRequests.Create( - "IN-1", List.of(11L), "WH-1", null, "123 Pickup Road", 5, + "IN-1", List.of(11L), "WH-1", "ONT8", "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"))); @@ -346,9 +369,9 @@ class WarehouseTransferServiceTest { ArgumentCaptor tailOrder = ArgumentCaptor.forClass(LastlegOrder.class); verify(transfers).insert(transfer.capture()); verify(orders).insert(tailOrder.capture()); - assertNull(transfer.getValue().getFbaWarehouseCode()); + assertEquals("ONT8", transfer.getValue().getFbaWarehouseCode()); assertNull(transfer.getValue().getFbaShipmentNo()); - assertNull(tailOrder.getValue().getFbaWarehouseCode()); + assertEquals("ONT8", tailOrder.getValue().getFbaWarehouseCode()); assertEquals("WAREHOUSE_PICKUP", transfer.getValue().getDeliveryMethod()); } @@ -391,7 +414,7 @@ class WarehouseTransferServiceTest { @Test void newTransitInboundCreatesSnapshotAndImmutableCartonMarksWithoutHistoricalInventory() { WarehouseTransferRequests.Create request = new WarehouseTransferRequests.Create( - null, null, "WH-1", null, "123 Pickup Road", 5, + null, null, "WH-1", "ONT8", "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), diff --git a/scripts/README.md b/scripts/README.md index ad44819c0..828acb0c7 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -2,6 +2,8 @@ `db/local-return-relabel-multiple-applications.sql`:本地 `baiyue_lastleg.ll_relabel_application` 单次结构修复,将尾程订单关联的唯一索引替换为普通索引,允许同一退货单剩余货物分批换标;不修改业务行,单号与幂等键唯一索引保留。凭据及目标连接由操作者显式提供,只在已授权本地库执行;DDL失败停止,已执行后不要重复运行,回查SHOW INDEX。新库结构同步在Lastleg基线和海外区域SQL中。 +`db/recent-schema-incremental-20260923.sql`:汇总 2026-09-21 至 2026-09-22 的近期结构变更,依次显式指向 `baiyue_iam`、`baiyue_firstleg`、`baiyue_lastleg`、`baiyue_lastlag_warehouse`;补齐国内 PDA 作业仓字段及索引、人工 FBA 报价可空字段、墨家 SKU 测量字段、换标申请普通索引和 A0 共享溢出位字段。所有字段和索引变更均经 `information_schema` 判断,可重复执行;不创建数据库、不含凭据、权限种子或业务状态修正,末尾输出逐库 `READY/CHECK_REQUIRED`。由操作者选择并确认目标连接后执行,任一 DDL 报错立即停止。 + `local-return-relabel-acceptance.mjs` 的 `LOCAL_RETURN_VERIFY_REMAINING=true` 专项新建3件退货,先换1件并客户确认,再对同一退货单剩余2件中的1件新建第二申请,验证同键重放、第二轮完工后剩余1件;不执行出库,报告 scope 为 `REMAINING_RETURN_RELABEL`,不代表出库验收通过。仍保留单据、使用正式API和合成照片,默认单票模式不变。 `local-return-relabel-outbound.mjs`:仅本机网关,读取显式 `LOCAL_RETURN_SOURCE` 单票报告,为其中同客户、同仓、已完成换标的唯一一件可用库存执行平台创建出库、原键重放、PDA拣货及完成。沿用平台账号环境参数,报告默认 `reports/local-return-relabel-outbound-.json` 或 `--output`;创建临时PDA并finally停用,不改客户权限。平台库存核验不替代源报告的客户读权限失败。会推进库存与出库状态,保留单据及报告;失败后按已保存单号核查,不自动清理,不可对已出库库存盲目重跑。 diff --git a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs index c7672bae7..8dc2e527f 100644 --- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs +++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs @@ -47,7 +47,7 @@ test('平台管理员由外部bcrypt引导,强制首登改密且不创建客 }) test('必要系统种子完整保留,但不写入示例资源与业务事实', () => { - for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 367, iam_role_permission: 543, + for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 370, iam_role_permission: 552, iam_data_scope: 7, wh_warehouse: 318, wh_warehouse_address: 318, wh_service_capability: 4, fl_fee_item: 112, fl_price_channel_catalog: 15, fl_price_origin_alias: 7, fl_price_warehouse_mapping: 4, file_export_task_guard: 1, fin_subject: 1 })) assert.equal(seedRows(cn, name).length, count, name) @@ -73,7 +73,29 @@ test('国内IAM权限与角色授权没有丢失或悬挂,客户模板仍完 const admin = roleAuthorities(cn, 'PLATFORM_ADMIN') for (const code of ['platform:order:declaration:edit', 'platform:firstleg:inbound:hold', 'platform:overseas-warehouse:last-mile-order:list', 'platform:overseas-business:purchase:list', - 'platform:overseas-master-data:quote:list', 'platform:finance:fee-source:review']) assert.ok(admin.has(code), code) + 'platform:overseas-master-data:quote:list', 'platform:finance:fee-source:review', + 'platform:mojia-sku-sync:list', 'platform:mojia-sku-sync:edit', + 'platform:mojia-sku-sync:retry']) assert.ok(admin.has(code), code) + const operator = roleAuthorities(cn, 'ORDER_OPERATOR') + for (const code of ['platform:mojia-sku-sync:list', 'platform:mojia-sku-sync:edit', + 'platform:mojia-sku-sync:retry']) assert.ok(operator.has(code), code) +}) + +test('近期头程与海外结构完整进入各自统一初始化入口', () => { + const membership = table(cn, 'baiyue_iam', 'iam_membership') + assert.match(membership, /domestic_warehouse_id bigint DEFAULT NULL/i) + assert.match(membership, /idx_membership_domestic_warehouse \(tenant_id,domestic_warehouse_id,status\)/i) + + const receipt = table(cn, 'baiyue_firstleg', 'fl_mojia_measurement_receipt') + for (const field of ['measurement_kind', 'weight_kg', 'sync_status', 'sync_failure_code', + 'sync_failure_reason', 'lingxing_sku_id', 'revision']) assert.match(receipt, new RegExp(`\\b${field}\\b`, 'i')) + assert.match(receipt, /weight_kg decimal\(18,3\) NOT NULL/i) + + const relabel = table(overseas, 'baiyue_lastleg', 'll_relabel_application') + assert.match(relabel, /KEY idx_ll_relabel_application_order \(lastleg_order_id\)/i) + assert.doesNotMatch(relabel, /UNIQUE KEY uk_ll_relabel_application_order/i) + assert.match(table(overseas, 'baiyue_lastlag_warehouse', 'wh_location'), + /shared_overflow tinyint\(1\) NOT NULL DEFAULT '?0'?/i) }) test('海外固定身份投影完全匹配国内管理员的当前海外权限', () => { diff --git a/scripts/db/init-domestic-pda-operators.sql b/scripts/db/init-domestic-pda-operators.sql new file mode 100644 index 000000000..5911232b2 --- /dev/null +++ b/scripts/db/init-domestic-pda-operators.sql @@ -0,0 +1,261 @@ +-- 国内头程 PDA 操作员初始化(MySQL 8.0+,可重复执行) +-- +-- 初始化内容: +-- 1. 平台角色 CN_FIRSTLEG_PDA_OPERATOR(国内头程 PDA 作业员) +-- 2. 张三 / pda_zhangsan,绑定 CN-YIWU-01(义乌仓) +-- 3. 李四 / pda_lisi,绑定 CN-YIWU-01(义乌仓) +-- +-- 目标库:同一 MySQL 实例中的 baiyue_auth、baiyue_iam、baiyue_warehouse。 +-- 前置条件:IAM 已包含 iam_membership.domestic_warehouse_id,且义乌仓主数据已初始化。 +-- 密码列只写 BCrypt 哈希;首次插入时要求首次登录修改密码。重复执行不会覆盖用户后来修改的密码。 + +SET NAMES utf8mb4; + +USE baiyue_iam; + +DROP PROCEDURE IF EXISTS assert_domestic_pda_operator_seed_ready; +DELIMITER $$ +CREATE PROCEDURE assert_domestic_pda_operator_seed_ready() +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'baiyue_auth' AND table_name = 'auth_account' + ) OR NOT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'baiyue_iam' AND table_name = 'iam_membership' + ) OR NOT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'baiyue_warehouse' AND table_name = 'wh_warehouse' + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '国内 PDA 初始化失败:baiyue_auth、baiyue_iam 或 baiyue_warehouse 基础表未部署'; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'baiyue_iam' AND table_name = 'iam_membership' + AND column_name = 'domestic_warehouse_id' + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '国内 PDA 初始化失败:请先部署 iam_membership.domestic_warehouse_id'; + END IF; + + IF (SELECT COUNT(*) + FROM baiyue_warehouse.wh_warehouse + WHERE tenant_id = 0 AND warehouse_code = 'CN-YIWU-01' + AND warehouse_type = 'DOMESTIC' AND status = 'ACTIVE' AND deleted = 0) <> 1 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '国内 PDA 初始化失败:未找到唯一启用的国内仓 CN-YIWU-01'; + END IF; + + IF EXISTS ( + SELECT 1 FROM iam_role + WHERE (id = 4000007 AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR role_code <> 'CN_FIRSTLEG_PDA_OPERATOR')) + OR (tenant_id = 0 AND app_code = 'PLATFORM' AND role_code = 'CN_FIRSTLEG_PDA_OPERATOR' AND id <> 4000007) + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '国内 PDA 初始化失败:角色ID 4000007 或角色编码已被其他数据占用'; + END IF; + + IF EXISTS ( + SELECT 1 FROM iam_data_scope + WHERE (id = 6000007 AND role_id <> 4000007) + OR (role_id = 4000007 AND id <> 6000007) + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '国内 PDA 初始化失败:数据范围ID 6000007 已被占用'; + END IF; + + IF (SELECT COUNT(*) FROM iam_permission + WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND status = 'ACTIVE' AND deleted = 0 + AND permission_code IN ('platform:firstleg:pda:view', 'platform:firstleg:pda:work')) <> 2 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '国内 PDA 初始化失败:PDA view/work 权限尚未完整部署'; + END IF; +END$$ +DELIMITER ; + +CALL assert_domestic_pda_operator_seed_ready(); +DROP PROCEDURE assert_domestic_pda_operator_seed_ready; + +SET @domestic_warehouse_id = ( + SELECT id FROM baiyue_warehouse.wh_warehouse + WHERE tenant_id = 0 AND warehouse_code = 'CN-YIWU-01' + AND warehouse_type = 'DOMESTIC' AND status = 'ACTIVE' AND deleted = 0 + LIMIT 1 +); + +-- 优先复用同名账号已有ID;首次执行使用下面的稳定ID。 +SET @zhangsan_auth_id = COALESCE( + (SELECT id FROM baiyue_auth.auth_account WHERE username = 'pda_zhangsan' AND deleted = 0 LIMIT 1), + 194000000000000101 +); +SET @lisi_auth_id = COALESCE( + (SELECT id FROM baiyue_auth.auth_account WHERE username = 'pda_lisi' AND deleted = 0 LIMIT 1), + 194000000000000102 +); +SET @zhangsan_user_id = COALESCE( + (SELECT id FROM iam_user WHERE auth_user_id = @zhangsan_auth_id AND deleted = 0 LIMIT 1), + 194000000000000201 +); +SET @lisi_user_id = COALESCE( + (SELECT id FROM iam_user WHERE auth_user_id = @lisi_auth_id AND deleted = 0 LIMIT 1), + 194000000000000202 +); +SET @zhangsan_membership_id = COALESCE( + (SELECT id FROM iam_membership WHERE tenant_id = 0 AND user_id = @zhangsan_user_id AND deleted = 0 LIMIT 1), + 194000000000000301 +); +SET @lisi_membership_id = COALESCE( + (SELECT id FROM iam_membership WHERE tenant_id = 0 AND user_id = @lisi_user_id AND deleted = 0 LIMIT 1), + 194000000000000302 +); + +DROP PROCEDURE IF EXISTS assert_domestic_pda_operator_ids_available; +DELIMITER $$ +CREATE PROCEDURE assert_domestic_pda_operator_ids_available() +BEGIN + IF EXISTS ( + SELECT 1 FROM baiyue_auth.auth_account + WHERE (id = @zhangsan_auth_id AND username <> 'pda_zhangsan') + OR (id = @lisi_auth_id AND username <> 'pda_lisi') + OR (username = 'pda_zhangsan' AND id <> @zhangsan_auth_id) + OR (username = 'pda_lisi' AND id <> @lisi_auth_id) + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '国内 PDA 初始化失败:张三或李四的认证账号ID/用户名冲突'; + END IF; + + IF EXISTS ( + SELECT 1 FROM iam_user + WHERE (id = @zhangsan_user_id AND auth_user_id <> @zhangsan_auth_id) + OR (id = @lisi_user_id AND auth_user_id <> @lisi_auth_id) + OR (auth_user_id = @zhangsan_auth_id AND id <> @zhangsan_user_id) + OR (auth_user_id = @lisi_auth_id AND id <> @lisi_user_id) + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '国内 PDA 初始化失败:张三或李四的 IAM 用户ID冲突'; + END IF; + + IF EXISTS ( + SELECT 1 FROM iam_membership + WHERE (id = @zhangsan_membership_id AND (tenant_id <> 0 OR user_id <> @zhangsan_user_id)) + OR (id = @lisi_membership_id AND (tenant_id <> 0 OR user_id <> @lisi_user_id)) + OR (tenant_id = 0 AND member_no = 'CN-PDA-YIWU-ZS' AND user_id <> @zhangsan_user_id) + OR (tenant_id = 0 AND member_no = 'CN-PDA-YIWU-LS' AND user_id <> @lisi_user_id) + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '国内 PDA 初始化失败:张三或李四的成员ID/工号冲突'; + END IF; +END$$ +DELIMITER ; + +CALL assert_domestic_pda_operator_ids_available(); +DROP PROCEDURE assert_domestic_pda_operator_ids_available; + +START TRANSACTION; + +INSERT INTO iam_role ( + id, tenant_id, app_code, role_code, role_name, role_type, status, + description, deleted, version +) VALUES ( + 4000007, 0, 'PLATFORM', 'CN_FIRSTLEG_PDA_OPERATOR', '国内头程 PDA 作业员', + 'SYSTEM', 'ACTIVE', '负责使用国内 PDA 执行柜次拣货、现场取证和装柜完成', 0, 0 +) +ON DUPLICATE KEY UPDATE + role_name = VALUES(role_name), role_type = 'SYSTEM', status = 'ACTIVE', + description = VALUES(description), deleted = 0; + +-- 该角色只保留国内 PDA 查看、作业两项最小权限。 +DELETE role_permission + FROM iam_role_permission role_permission + JOIN iam_permission permission ON permission.id = role_permission.permission_id + WHERE role_permission.role_id = 4000007 + AND permission.permission_code NOT IN ('platform:firstleg:pda:view', 'platform:firstleg:pda:work'); + +INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000007, id, 0 + FROM iam_permission + WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND status = 'ACTIVE' AND deleted = 0 + AND permission_code IN ('platform:firstleg:pda:view', 'platform:firstleg:pda:work'); + +INSERT INTO iam_data_scope (id, role_id, tenant_id, scope_type, scope_expression) +VALUES (6000007, 4000007, 0, 'TENANT', NULL) +ON DUPLICATE KEY UPDATE tenant_id = 0, scope_type = 'TENANT', scope_expression = NULL; + +-- 首次插入使用既定临时密码的 BCrypt(12) 哈希;重复执行保留账号当前密码。 +INSERT INTO baiyue_auth.auth_account ( + id, username, phone, password_hash, display_name, account_status, + failed_login_count, locked_until, token_version, password_change_required, + default_tenant_id, operator_tenant_id, default_membership_id, default_app_code, deleted +) VALUES + (@zhangsan_auth_id, 'pda_zhangsan', NULL, + '$2a$12$/BdqRpdgVPAu6aoizfuTJ.UhtgpZYEAkwdvworS5GVirUBSwCcuKG', + '张三', 'ACTIVE', 0, NULL, 1, 1, 0, 0, @zhangsan_membership_id, 'PLATFORM', 0), + (@lisi_auth_id, 'pda_lisi', NULL, + '$2a$12$Uv.KUNtVSAwT1R0Sk3Gb6uaytWitd7Hrda5xrZl0zKxLY1/SCUyXe', + '李四', 'ACTIVE', 0, NULL, 1, 1, 0, 0, @lisi_membership_id, 'PLATFORM', 0) +ON DUPLICATE KEY UPDATE + display_name = VALUES(display_name), account_status = 'ACTIVE', + failed_login_count = 0, locked_until = NULL, + default_tenant_id = 0, operator_tenant_id = 0, + default_membership_id = VALUES(default_membership_id), default_app_code = 'PLATFORM', deleted = 0; + +INSERT INTO iam_user ( + id, auth_user_id, user_type, display_name, phone, email, status, deleted, version +) VALUES + (@zhangsan_user_id, @zhangsan_auth_id, 'INTERNAL', '张三', NULL, NULL, 'ACTIVE', 0, 0), + (@lisi_user_id, @lisi_auth_id, 'INTERNAL', '李四', NULL, NULL, 'ACTIVE', 0, 0) +ON DUPLICATE KEY UPDATE + user_type = 'INTERNAL', display_name = VALUES(display_name), status = 'ACTIVE', deleted = 0; + +INSERT INTO iam_membership ( + id, tenant_id, user_id, member_type, member_no, + overseas_warehouse_id, domestic_warehouse_id, + status, joined_at, left_at, deleted, version +) VALUES + (@zhangsan_membership_id, 0, @zhangsan_user_id, 'EMPLOYEE', 'CN-PDA-YIWU-ZS', + NULL, @domestic_warehouse_id, 'ACTIVE', CURRENT_TIMESTAMP(3), NULL, 0, 0), + (@lisi_membership_id, 0, @lisi_user_id, 'EMPLOYEE', 'CN-PDA-YIWU-LS', + NULL, @domestic_warehouse_id, 'ACTIVE', CURRENT_TIMESTAMP(3), NULL, 0, 0) +ON DUPLICATE KEY UPDATE + member_type = 'EMPLOYEE', member_no = VALUES(member_no), + overseas_warehouse_id = NULL, domestic_warehouse_id = VALUES(domestic_warehouse_id), + status = 'ACTIVE', joined_at = COALESCE(joined_at, CURRENT_TIMESTAMP(3)), + left_at = NULL, deleted = 0; + +-- 国内、海外 PDA 角色互斥;保留人员可能已有的其他非 PDA 角色。 +DELETE membership_role + FROM iam_membership_role membership_role + JOIN iam_role role ON role.id = membership_role.role_id + WHERE membership_role.membership_id IN (@zhangsan_membership_id, @lisi_membership_id) + AND role.role_code = 'OVERSEAS_WAREHOUSE_PDA_OPERATOR'; + +INSERT INTO iam_membership_role (membership_id, role_id, tenant_id) VALUES + (@zhangsan_membership_id, 4000007, 0), + (@lisi_membership_id, 4000007, 0) +ON DUPLICATE KEY UPDATE tenant_id = 0; + +INSERT IGNORE INTO iam_security_subject_version (auth_user_id, permission_version) +VALUES (@zhangsan_auth_id, 1), (@lisi_auth_id, 1); + +COMMIT; + +-- 验证结果:应返回两行,且 role_code、warehouse_code、account_status、member_status 均为有效值。 +SELECT account.username, + user_profile.display_name, + role.role_code, + warehouse.warehouse_code, + warehouse.warehouse_name, + account.account_status, + membership.status AS member_status + FROM baiyue_auth.auth_account account + JOIN iam_user user_profile ON user_profile.auth_user_id = account.id AND user_profile.deleted = 0 + JOIN iam_membership membership ON membership.user_id = user_profile.id + AND membership.tenant_id = 0 AND membership.deleted = 0 + JOIN iam_membership_role membership_role ON membership_role.membership_id = membership.id + JOIN iam_role role ON role.id = membership_role.role_id + AND role.role_code = 'CN_FIRSTLEG_PDA_OPERATOR' + JOIN baiyue_warehouse.wh_warehouse warehouse ON warehouse.id = membership.domestic_warehouse_id + WHERE account.username IN ('pda_zhangsan', 'pda_lisi') + ORDER BY account.username; diff --git a/scripts/db/recent-schema-incremental-20260923.sql b/scripts/db/recent-schema-incremental-20260923.sql new file mode 100644 index 000000000..95fb242b8 --- /dev/null +++ b/scripts/db/recent-schema-incremental-20260923.sql @@ -0,0 +1,385 @@ +-- 2026-09-23 近期字段与表结构统一增量(MySQL 8.0+) +-- +-- 范围:2026-09-21 至 2026-09-22 合入的结构变化。 +-- 特性:显式切换到所属 schema;字段和索引均通过 information_schema 判断;可重复执行。 +-- 边界:不创建数据库、不删除业务字段/表、不包含权限种子或业务数据修正。 +-- 注意:DDL 在 MySQL 中会隐式提交;任一步失败后应停止执行并核对目标实例。 + +SET NAMES utf8mb4; + +-- ============================================================================ +-- IAM:国内头程 PDA 操作员绑定作业仓 +-- ============================================================================ +USE baiyue_iam; + +SET @ddl = IF( + EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = 'iam_membership' + ) + AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'iam_membership' + AND column_name = 'domestic_warehouse_id' + ), + 'ALTER TABLE iam_membership ADD COLUMN domestic_warehouse_id BIGINT NULL COMMENT ''国内头程PDA操作员绑定的唯一国内作业仓ID'' AFTER overseas_warehouse_id', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @domestic_warehouse_index_columns = ( + SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ',') + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'iam_membership' + AND index_name = 'idx_membership_domestic_warehouse' +); +SET @domestic_warehouse_index_non_unique = ( + SELECT MIN(non_unique) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'iam_membership' + AND index_name = 'idx_membership_domestic_warehouse' +); +SET @ddl = CASE + WHEN NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'iam_membership' + AND column_name = 'domestic_warehouse_id' + ) THEN 'SELECT 1' + WHEN @domestic_warehouse_index_columns IS NULL THEN + 'ALTER TABLE iam_membership ADD KEY idx_membership_domestic_warehouse (tenant_id, domestic_warehouse_id, status)' + WHEN @domestic_warehouse_index_columns <> 'tenant_id,domestic_warehouse_id,status' + OR @domestic_warehouse_index_non_unique <> 1 THEN + 'ALTER TABLE iam_membership DROP INDEX idx_membership_domestic_warehouse, ADD KEY idx_membership_domestic_warehouse (tenant_id, domestic_warehouse_id, status)' + ELSE 'SELECT 1' +END; +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +-- ============================================================================ +-- Firstleg:人工 FBA 报价允许缺少船司价格快照 +-- ============================================================================ +USE baiyue_firstleg; + +SET @ddl = IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_fee' + AND column_name = 'carrier_rate_id' AND is_nullable = 'NO' + ), + 'ALTER TABLE fl_inbound_fee MODIFY COLUMN carrier_rate_id BIGINT UNSIGNED NULL COMMENT ''船司FBA价格ID;人工报价为空''', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_fee' + AND column_name = 'carrier_rate_version' AND is_nullable = 'NO' + ), + 'ALTER TABLE fl_inbound_fee MODIFY COLUMN carrier_rate_version INT UNSIGNED NULL COMMENT ''船司价格版本快照;人工报价为空''', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_fee' + AND column_name = 'rate_effective_from' AND is_nullable = 'NO' + ), + 'ALTER TABLE fl_inbound_fee MODIFY COLUMN rate_effective_from DATETIME(3) NULL COMMENT ''价格生效开始快照;人工报价为空''', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +-- ============================================================================ +-- Firstleg:墨家测量收件箱扩展为箱唛/SKU 双分流 +-- ============================================================================ + +SET @ddl = IF( + NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' + AND column_name = 'measurement_kind' + ), + 'ALTER TABLE fl_mojia_measurement_receipt ADD COLUMN measurement_kind VARCHAR(16) NOT NULL DEFAULT ''BOX_LABEL'' COMMENT ''测量分流:BOX_LABEL箱唛;SKU一件代发SKU'' AFTER box_code', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' + AND column_name = 'weight_kg' + ), + 'ALTER TABLE fl_mojia_measurement_receipt ADD COLUMN weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT ''设备实测重量kg'' AFTER height_cm', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' + AND column_name = 'weight_kg' + AND (column_type <> 'decimal(18,3)' OR is_nullable <> 'NO' OR column_default IS NOT NULL + OR column_comment <> '设备实测重量kg') + ), + 'ALTER TABLE fl_mojia_measurement_receipt MODIFY COLUMN weight_kg DECIMAL(18,3) NOT NULL COMMENT ''设备实测重量kg''', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' + AND column_name = 'sync_status' + ), + 'ALTER TABLE fl_mojia_measurement_receipt ADD COLUMN sync_status VARCHAR(24) NULL COMMENT ''SKU同步状态:PENDING/SYNCING/SUCCESS/FAILED'' AFTER status', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' + AND column_name = 'sync_failure_code' + ), + 'ALTER TABLE fl_mojia_measurement_receipt ADD COLUMN sync_failure_code VARCHAR(64) NULL COMMENT ''SKU同步失败业务码'' AFTER sync_status', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' + AND column_name = 'sync_failure_reason' + ), + 'ALTER TABLE fl_mojia_measurement_receipt ADD COLUMN sync_failure_reason VARCHAR(500) NULL COMMENT ''脱敏后的SKU同步失败原因'' AFTER sync_failure_code', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' + AND column_name = 'lingxing_sku_id' + ), + 'ALTER TABLE fl_mojia_measurement_receipt ADD COLUMN lingxing_sku_id VARCHAR(128) NULL COMMENT ''领星SKU内部标识'' AFTER sync_failure_reason', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' + AND column_name = 'revision' + ), + 'ALTER TABLE fl_mojia_measurement_receipt ADD COLUMN revision INT NOT NULL DEFAULT 1 COMMENT ''当前测量版本号'' AFTER lingxing_sku_id', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' + AND column_name = 'match_failure_code' AND is_nullable = 'NO' + ), + 'ALTER TABLE fl_mojia_measurement_receipt MODIFY COLUMN match_failure_code VARCHAR(16) NULL COMMENT ''正式箱唛业务匹配失败码''', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' + AND column_name = 'match_failure_reason' AND is_nullable = 'NO' + ), + 'ALTER TABLE fl_mojia_measurement_receipt MODIFY COLUMN match_failure_reason VARCHAR(500) NULL COMMENT ''脱敏后的箱唛匹配失败原因''', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' + AND column_name = 'box_code' + AND column_comment <> '设备扫描的原始箱码或不带#的SKU' + ), + 'ALTER TABLE fl_mojia_measurement_receipt MODIFY COLUMN box_code VARCHAR(128) NOT NULL COMMENT ''设备扫描的原始箱码或不带#的SKU''', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' + AND column_name = 'status' + AND column_comment <> '原始收件状态;SKU记录使用SKU_PENDING_SYNC' + ), + 'ALTER TABLE fl_mojia_measurement_receipt MODIFY COLUMN status VARCHAR(24) NOT NULL DEFAULT ''UNMATCHED'' COMMENT ''原始收件状态;SKU记录使用SKU_PENDING_SYNC''', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +-- ============================================================================ +-- Lastleg:同一尾程单允许多次换标申请,订单关联由唯一索引改为普通索引 +-- ============================================================================ +USE baiyue_lastleg; + +SET @ddl = IF( + EXISTS ( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'll_relabel_application' + AND index_name = 'uk_ll_relabel_application_order' + ), + 'ALTER TABLE ll_relabel_application DROP INDEX uk_ll_relabel_application_order', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @relabel_order_index_columns = ( + SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ',') + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'll_relabel_application' + AND index_name = 'idx_ll_relabel_application_order' +); +SET @relabel_order_index_non_unique = ( + SELECT MIN(non_unique) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'll_relabel_application' + AND index_name = 'idx_ll_relabel_application_order' +); +SET @ddl = CASE + WHEN @relabel_order_index_columns IS NULL THEN + 'ALTER TABLE ll_relabel_application ADD KEY idx_ll_relabel_application_order (lastleg_order_id)' + WHEN @relabel_order_index_columns <> 'lastleg_order_id' + OR @relabel_order_index_non_unique <> 1 THEN + 'ALTER TABLE ll_relabel_application DROP INDEX idx_ll_relabel_application_order, ADD KEY idx_ll_relabel_application_order (lastleg_order_id)' + ELSE 'SELECT 1' +END; +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +-- ============================================================================ +-- Overseas Warehouse:A0 系统共享溢出库位标记 +-- 注意:正式 schema 名沿用项目现有拼写 baiyue_lastlag_warehouse。 +-- ============================================================================ +USE baiyue_lastlag_warehouse; + +SET @ddl = IF( + NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'wh_location' + AND column_name = 'shared_overflow' + ), + 'ALTER TABLE wh_location ADD COLUMN shared_overflow TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否系统共享溢出库位(A0)'' AFTER location_type', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'wh_location' + AND column_name = 'shared_overflow' + AND (column_type <> 'tinyint(1)' OR is_nullable <> 'NO' + OR COALESCE(column_default, '') <> '0' + OR column_comment <> '是否系统共享溢出库位(A0)') + ), + 'ALTER TABLE wh_location MODIFY COLUMN shared_overflow TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否系统共享溢出库位(A0)''', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +-- 最终只读核对;四行均应返回 READY。 +SELECT 'baiyue_iam' AS target_schema, + IF( + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'baiyue_iam' AND table_name = 'iam_membership' AND column_name = 'domestic_warehouse_id') + AND (SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ',') FROM information_schema.statistics WHERE table_schema = 'baiyue_iam' AND table_name = 'iam_membership' AND index_name = 'idx_membership_domestic_warehouse') = 'tenant_id,domestic_warehouse_id,status', + 'READY', 'CHECK_REQUIRED' + ) AS schema_status +UNION ALL +SELECT 'baiyue_firstleg', + IF( + (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_mojia_measurement_receipt' AND column_name IN ('measurement_kind','weight_kg','sync_status','sync_failure_code','sync_failure_reason','lingxing_sku_id','revision')) = 7 + AND (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_fee' AND column_name IN ('carrier_rate_id','carrier_rate_version','rate_effective_from') AND is_nullable = 'YES') = 3, + 'READY', 'CHECK_REQUIRED' + ) +UNION ALL +SELECT 'baiyue_lastleg', + IF( + NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = 'baiyue_lastleg' AND table_name = 'll_relabel_application' AND index_name = 'uk_ll_relabel_application_order') + AND (SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ',') FROM information_schema.statistics WHERE table_schema = 'baiyue_lastleg' AND table_name = 'll_relabel_application' AND index_name = 'idx_ll_relabel_application_order') = 'lastleg_order_id' + AND (SELECT MIN(non_unique) FROM information_schema.statistics WHERE table_schema = 'baiyue_lastleg' AND table_name = 'll_relabel_application' AND index_name = 'idx_ll_relabel_application_order') = 1, + 'READY', 'CHECK_REQUIRED' + ) +UNION ALL +SELECT 'baiyue_lastlag_warehouse', + IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'baiyue_lastlag_warehouse' + AND table_name = 'wh_location' + AND column_name = 'shared_overflow' + AND column_type = 'tinyint(1)' + AND is_nullable = 'NO' + AND COALESCE(column_default, '') = '0' + ), + 'READY', 'CHECK_REQUIRED' + ); diff --git a/scripts/overseas-relabel-fulfillment-acceptance.mjs b/scripts/overseas-relabel-fulfillment-acceptance.mjs index aa0912c22..fa7bd677b 100644 --- a/scripts/overseas-relabel-fulfillment-acceptance.mjs +++ b/scripts/overseas-relabel-fulfillment-acceptance.mjs @@ -89,7 +89,9 @@ assert(transferDeliveries.length === 1, `Expected one destination delivery, got ${transferDeliveries.length}`) const deliveryNo = transferDeliveries[0].deliveryNo -assert(Array.isArray(task.items) && task.items.length === 2, 'Expected two PDA pallet items') +const expectedPdaItems = scenario.partialRelabel ? 1 : 2 +assert(Array.isArray(task.items) && task.items.length === expectedPdaItems, + `Expected ${expectedPdaItems} PDA pallet items`) if (!alreadyDeparted) { if (task.items.every(item => Number(item.pickedPackages) === 0)) { await api(overseas, platformToken, 'PUT', `/api/platform/v1/last-leg/dispatches/${dispatchNo}`, { diff --git a/scripts/warehouse-transfer-remaining-acceptance.mjs b/scripts/warehouse-transfer-remaining-acceptance.mjs index f3e902efa..bca0f4599 100644 --- a/scripts/warehouse-transfer-remaining-acceptance.mjs +++ b/scripts/warehouse-transfer-remaining-acceptance.mjs @@ -9,6 +9,7 @@ const overseas = process.env.ADMIN_OVERSEAS_GATEWAY || 'http://127.0.0.1:9300' let customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID || '' let warehouseId = process.env.OVERSEAS_TEST_WAREHOUSE_ID || '' const destinationCode = process.env.OVERSEAS_TEST_DESTINATION_CODE || 'ONT8' +const partialRelabel = process.env.WAREHOUSE_TRANSFER_PARTIAL_RELABEL === 'true' const output = resolve(process.env.WAREHOUSE_TRANSFER_REMAINING_OUTPUT || 'reports/warehouse-transfer-remaining-acceptance.json') const marker = `WT-REMAIN-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}` @@ -189,13 +190,14 @@ try { } const cartonMarks = [`CM-${marker}-1`, `CM-${marker}-2`] + const cartonRelabelBindings = partialRelabel ? cartonMarks.slice(0, 1) : cartonMarks const transfer = await api(overseas, merchant.overseasAccessToken, 'POST', '/api/merchant/v1/last-leg/warehouse-transfers', { operatingWarehouseId: warehouseId, - fbaWarehouseCode: destinationCode, - destinationType: 'FBA', - destinationWarehouseCode: destinationCode, - destinationWarehouseName: destinationCode, + fbaWarehouseCode: partialRelabel ? null : destinationCode, + destinationType: partialRelabel ? null : 'FBA', + destinationWarehouseCode: partialRelabel ? null : destinationCode, + destinationWarehouseName: partialRelabel ? null : destinationCode, deliveryAddress: `Acceptance inbound address ${marker}`, packageCount: 2, volumeCbm: 0.12, @@ -214,7 +216,7 @@ try { idempotencyKey: key('CREATE'), items: [{ skuCode: `SKU-${marker}`.slice(0, 128), productNameZh: 'Acceptance cargo', packageCount: 2, shippingMark: cartonMarks.join(',') }], - relabelItems: cartonMarks.map((cartonMark, index) => ({ + relabelItems: cartonRelabelBindings.map((cartonMark, index) => ({ operationType: 'CARTON_AND_SKU', customerCartonMarks: [cartonMark], originalSku: `SKU-${marker}`.slice(0, 128), newSku: `NEW-${marker}-${index + 1}`.slice(0, 128), quantity: 1, @@ -278,9 +280,18 @@ try { status: transferDetail.status, relabelNo, }) + const reviewedRelabel = await api(overseas, admin.overseasAccessToken, 'PUT', + `/api/platform/v1/last-leg/relabel-applications/${relabelNo}/status`, { + status: 'PENDING_PROCESSING', remark: marker, + }) + assert(reviewedRelabel.application.status === 'PENDING_PROCESSING', + `Linked relabel review did not approve execution: ${reviewedRelabel.application.status}`) + step('Platform approved linked relabel for PDA execution', { status: reviewedRelabel.application.status }) + let relabel = await api(overseas, pda.token, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/claim`, { idempotencyKey: key('RELABEL-CLAIM') }) - assert(relabel.items.length === 2, `Expected two linked relabel items, got ${relabel.items.length}`) + assert(relabel.items.length === (partialRelabel ? 1 : 2), + `Expected ${partialRelabel ? 1 : 2} linked relabel items, got ${relabel.items.length}`) for (const [index, item] of relabel.items.entries()) { relabel = await api(overseas, pda.token, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/cartons/scan`, { @@ -330,7 +341,8 @@ try { transferDetail = await api(overseas, merchant.overseasAccessToken, 'GET', `/api/merchant/v1/last-leg/warehouse-transfers/${transferNo}`) assert(confirmedRelabel.application.status === 'READY_FOR_OUTBOUND', 'Linked relabel did not reach outbound-ready') - assert(transferDetail.status === 'READY_FOR_DISPATCH', 'Warehouse transfer did not return to dispatch-ready') + assert(transferDetail.status === (partialRelabel ? 'WAIT_FBA_DESTINATION' : 'READY_FOR_DISPATCH'), + `Warehouse transfer reached unexpected status ${transferDetail.status}`) step('Completed linked relabel and released transfer for dispatch', { relabelStatus: confirmedRelabel.application.status, transferStatus: transferDetail.status, }) @@ -339,17 +351,30 @@ try { `/api/platform/v1/last-leg/dispatches/candidates?orderNo=${encodeURIComponent(relabelNo)}&sourceStage=RECEIVED`) const targetIds = new Set(targetPallets.map((value) => id(value.palletId))) const rows = candidates.filter((value) => value.sourceStage === 'RELABEL' && targetIds.has(id(value.warehousePalletId))) - assert(rows.length === 2, `Expected two smart-dispatch pallets, got ${rows.length}`) + assert(rows.length === (partialRelabel ? 1 : 2), + `Expected ${partialRelabel ? 1 : 2} smart-dispatch pallets, got ${rows.length}`) const dispatch = await api(overseas, admin.overseasAccessToken, 'POST', '/api/platform/v1/last-leg/dispatches/pre-arrange', { planPalletIds: rows.map((value) => id(value.warehousePalletId)), route: marker, relabelNo, }) step('Created smart pre-dispatch', { dispatchNo: dispatch.dispatchNo, status: dispatch.status }) + if (partialRelabel) { + const outboundCandidates = await api(overseas, admin.overseasAccessToken, 'GET', + `/api/platform/v1/last-leg/outbound-orders/warehouse-transfer/candidates?customerId=${encodeURIComponent(customerId)}&warehouseId=${encodeURIComponent(warehouseId)}`) + const remaining = outboundCandidates.filter((value) => value.transferNo === transferNo) + assert(remaining.length === 1 && Number(remaining[0].packageCount) === 1, + `Expected one unlabelled carton in warehouse-transfer outbound candidates, got ${JSON.stringify(remaining)}`) + step('Verified split outbound routing', { + relabelDispatchPallets: rows.length, remainingOutboundCandidates: remaining.length, + remainingPackageCount: remaining[0].packageCount, + }) + } + const sourceReport = resolve('reports', `${marker}-relabel-source.json`) const fulfillmentReport = resolve('reports', `${marker}-fulfillment.json`) await writeFile(sourceReport, JSON.stringify({ marker, passed: true, scenarios: [{ - name: 'SPLIT-TWO-TARGETS', relabelNo, dispatchNo: dispatch.dispatchNo, + name: 'SPLIT-TWO-TARGETS', relabelNo, dispatchNo: dispatch.dispatchNo, partialRelabel, }] }, null, 2), 'utf8') const child = spawnSync(process.execPath, [resolve('scripts', 'overseas-relabel-fulfillment-acceptance.mjs')], { cwd: resolve('.'), stdio: 'inherit', env: { @@ -366,11 +391,70 @@ try { }) if (child.error) throw child.error if (child.status !== 0) throw new Error(`Fulfillment acceptance failed with exit code ${child.status}`) - const finalTransfer = await api(overseas, merchant.overseasAccessToken, 'GET', + let finalTransfer = await api(overseas, merchant.overseasAccessToken, 'GET', `/api/merchant/v1/last-leg/warehouse-transfers/${transferNo}`) + let remainingDispatchNo = null + let remainingFulfillmentReport = null + if (partialRelabel) { + assert(finalTransfer.status === 'WAIT_FBA_DESTINATION', + `Partial relabel dispatch incorrectly closed the transfer: ${finalTransfer.status}`) + const beforeBind = await api(overseas, admin.overseasAccessToken, 'GET', + `/api/platform/v1/last-leg/outbound-orders/warehouse-transfer/candidates?customerId=${encodeURIComponent(customerId)}&warehouseId=${encodeURIComponent(warehouseId)}`) + const remainingBeforeBind = beforeBind.filter((value) => value.transferNo === transferNo) + assert(remainingBeforeBind.length === 1 && Number(remainingBeforeBind[0].packageCount) === 1 + && remainingBeforeBind[0].palletId && remainingBeforeBind[0].warehousePalletId, + `Expected one selectable remaining pallet before destination binding, got ${JSON.stringify(remainingBeforeBind)}`) + await api(overseas, admin.overseasAccessToken, 'POST', + '/api/platform/v1/last-leg/warehouse-transfers/bind-destination', { + transfers: [{ customerId, transferNo }], destinationType: 'FBA', + destinationWarehouseCode: destinationCode, destinationWarehouseName: destinationCode, + idempotencyKey: key('BIND-REMAINING-DESTINATION'), + }) + const afterBind = await api(overseas, admin.overseasAccessToken, 'GET', + `/api/platform/v1/last-leg/outbound-orders/warehouse-transfer/candidates?customerId=${encodeURIComponent(customerId)}&warehouseId=${encodeURIComponent(warehouseId)}`) + assert(!afterBind.some((value) => value.transferNo === transferNo), + 'Destination-bound transfer remained in the destination-completion outbound list') + const remainingCandidates = await api(overseas, admin.overseasAccessToken, 'GET', + `/api/platform/v1/last-leg/dispatches/candidates?orderNo=${encodeURIComponent(transferNo)}&sourceStage=RECEIVED`) + const remainingRows = remainingCandidates.filter((value) => value.sourceStage === 'RECEIVED' + && Number(value.packageCount) === 1 && value.destinationWarehouseCode === destinationCode) + assert(remainingRows.length === 1, + `Expected one destination-bound smart-dispatch pallet, got ${JSON.stringify(remainingRows)}`) + const remainingDispatch = await api(overseas, admin.overseasAccessToken, 'POST', + '/api/platform/v1/last-leg/dispatches/pre-arrange', { + planPalletIds: remainingRows.map((value) => id(value.planPalletId)), route: `${marker}-REMAINING`, + }) + remainingDispatchNo = remainingDispatch.dispatchNo + const remainingSourceReport = resolve('reports', `${marker}-remaining-source.json`) + remainingFulfillmentReport = resolve('reports', `${marker}-remaining-fulfillment.json`) + await writeFile(remainingSourceReport, JSON.stringify({ marker, passed: true, scenarios: [{ + name: 'SPLIT-TWO-TARGETS', relabelNo: '', dispatchNo: remainingDispatchNo, partialRelabel: true, + }] }, null, 2), 'utf8') + const remainingChild = spawnSync(process.execPath, + [resolve('scripts', 'overseas-relabel-fulfillment-acceptance.mjs')], { + cwd: resolve('.'), stdio: 'inherit', env: { + ...process.env, ADMIN_TEST_GATEWAY: domestic, ADMIN_OVERSEAS_GATEWAY: overseas, + ADMIN_OVERSEAS_ACCESS_TOKEN: admin.overseasAccessToken, + MERCHANT_OVERSEAS_ACCESS_TOKEN: merchant.overseasAccessToken, + PDA_OVERSEAS_ACCESS_TOKEN: pda.token, OVERSEAS_TEST_CUSTOMER_ID: customerId, + OVERSEAS_ACCEPTANCE_SOURCE: remainingSourceReport, + OVERSEAS_FULFILLMENT_OUTPUT: remainingFulfillmentReport, + }, + }) + if (remainingChild.error) throw remainingChild.error + if (remainingChild.status !== 0) throw new Error(`Remaining fulfillment failed with exit code ${remainingChild.status}`) + finalTransfer = await api(overseas, merchant.overseasAccessToken, 'GET', + `/api/merchant/v1/last-leg/warehouse-transfers/${transferNo}`) + assert(finalTransfer.status === 'OUTBOUNDED', + `Remaining transfer did not complete outbound: ${finalTransfer.status}`) + step('Bound remaining carton destination and completed its smart dispatch', { + remainingDispatchNo, remainingPackageCount: 1, transferStatus: finalTransfer.status, + }) + } Object.assign(report, { passed: true, transferNo, relabelNo, dispatchNo: dispatch.dispatchNo, - transferStatus: finalTransfer.status, fulfillmentReport, finishedAt: new Date().toISOString(), + remainingDispatchNo, transferStatus: finalTransfer.status, fulfillmentReport, + remainingFulfillmentReport, finishedAt: new Date().toISOString(), }) } catch (error) { report.error = error instanceof Error ? error.message : String(error)