贴标申请,仓储转运

This commit is contained in:
2026-09-24 09:01:14 +08:00
parent 10a2ab3e42
commit 186b6be7d8
27 changed files with 1203 additions and 118 deletions
+1 -1
View File
@@ -40,7 +40,7 @@
| 所属库 | 保留内容 | 不包含 | | 所属库 | 保留内容 | 不包含 |
|---|---|---| |---|---|---|
| Auth / IAM | 平台主体、唯一平台管理员、8 个系统角色及企业管理员模板、364 个权限、539 条角色权限、7 个数据范围、客户编号起点 | 测试客户、固定客户账号、额外扩权 | | Auth / IAM | 平台主体、唯一平台管理员、8 个系统角色及企业管理员模板、370 个权限、552 条角色权限、7 个数据范围、客户编号起点 | 测试客户、固定客户账号、额外扩权 |
| Warehouse | 既有公共仓目录 318 条及地址;义乌、东莞、洛杉矶、纽约 4 个作业仓收货能力 | 示例供应商、快递、往来单位、托盘、库位、预占、库存 | | Warehouse | 既有公共仓目录 318 条及地址;义乌、东莞、洛杉矶、纽约 4 个作业仓收货能力 | 示例供应商、快递、往来单位、托盘、库位、预占、库存 |
| Firstleg | 112 个费用项目、15 个渠道目录、7 个始发地别名、4 个公共仓映射 | 客户报价、订单、入库、实测或履约记录 | | Firstleg | 112 个费用项目、15 个渠道目录、7 个始发地别名、4 个公共仓映射 | 客户报价、订单、入库、实测或履约记录 |
| File / Finance | 导出互斥键 `GLOBAL_CREATE`、默认平台财务主体 | 文件任务、资金、账单或交易流水 | | File / Finance | 导出互斥键 `GLOBAL_CREATE`、默认平台财务主体 | 文件任务、资金、账单或交易流水 |
@@ -138,7 +138,8 @@ CREATE TABLE iam_membership (
UNIQUE KEY uk_membership_tenant_user (tenant_id,user_id), UNIQUE KEY uk_membership_tenant_user (tenant_id,user_id),
UNIQUE KEY uk_membership_tenant_no (tenant_id,member_no), UNIQUE KEY uk_membership_tenant_no (tenant_id,member_no),
KEY idx_membership_user (user_id,status), 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='客户成员关系表:表示用户在客户企业中的成员身份,是分配企业客户端角色的主体'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户成员关系表:表示用户在客户企业中的成员身份,是分配企业客户端角色的主体';
CREATE TABLE iam_membership_role ( 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) CONSTRAINT fk_fee_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库头程费用计算和确认快照'; ) 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 ( CREATE TABLE fl_inbound_fee_detail (
id bigint unsigned NOT NULL COMMENT '入库费用明细ID', id bigint unsigned NOT NULL COMMENT '入库费用明细ID',
tenant_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', length_cm decimal(12,3) NOT NULL COMMENT '设备实测长度cm',
width_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', 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 '设备编号', machine_no varchar(64) NOT NULL COMMENT '设备编号',
measured_at datetime(3) NOT NULL COMMENT '设备测量时间或服务接收时间', measured_at datetime(3) NOT NULL COMMENT '设备测量时间或服务接收时间',
image_file_id bigint unsigned NOT NULL COMMENT 'File服务中的墨家联调图片ID', image_file_id bigint unsigned NOT NULL COMMENT 'File服务中的墨家联调图片ID',
+2 -1
View File
@@ -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_user (tenant_id, user_id),
UNIQUE KEY uk_membership_tenant_no (tenant_id, member_no), UNIQUE KEY uk_membership_tenant_no (tenant_id, member_no),
KEY idx_membership_user (user_id, status), 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='客户成员关系表:表示用户在客户企业中的成员身份,是分配企业客户端角色的主体'; ) ENGINE=InnoDB COMMENT='客户成员关系表:表示用户在客户企业中的成员身份,是分配企业客户端角色的主体';
CREATE TABLE IF NOT EXISTS iam_role ( CREATE TABLE IF NOT EXISTS iam_role (
@@ -17,6 +17,9 @@ import com.baiyue.lastleg.security.OverseasPermissions;
import com.baiyue.lastleg.service.LastlegOrderService; import com.baiyue.lastleg.service.LastlegOrderService;
import com.baiyue.lastleg.service.LastlegOrderCommandService; import com.baiyue.lastleg.service.LastlegOrderCommandService;
import com.baiyue.lastleg.service.LoadingCompletedConsumer; 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.lastleg.shipping.service.ContainerShippingNodeConsumer;
import com.baiyue.common.event.EventTypes; import com.baiyue.common.event.EventTypes;
import com.baiyue.lastleg.wallet.WalletCommandCompletedConsumer; import com.baiyue.lastleg.wallet.WalletCommandCompletedConsumer;
@@ -52,6 +55,7 @@ public class LastlegOrderController {
@Resource private LastlegOrderCommandService commandService; @Resource private LastlegOrderCommandService commandService;
@Resource private InternalServiceTokenGuard tokenGuard; @Resource private InternalServiceTokenGuard tokenGuard;
@Resource private WalletCommandCompletedConsumer walletCommandCompletedConsumer; @Resource private WalletCommandCompletedConsumer walletCommandCompletedConsumer;
@Resource private RelabelApplicationService relabelApplicationService;
@PostMapping("/internal/lastleg/events") @PostMapping("/internal/lastleg/events")
@Operation(summary = "Receive a first-leg loading or actual shipping-node event", @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)); 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<RelabelResponses.Detail> 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") @PostMapping("/api/platform/v1/last-leg/orders/{id}/fees")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") @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) @Operation(summary = "Add a fee to one tail-leg order", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT)
@@ -430,33 +430,38 @@ public class DeliveryTransferService {
new LambdaQueryWrapper<WarehouseTransferOrder>() new LambdaQueryWrapper<WarehouseTransferOrder>()
.eq(StringUtils.hasText(customerId), WarehouseTransferOrder::getCustomerId, trim(customerId)) .eq(StringUtils.hasText(customerId), WarehouseTransferOrder::getCustomerId, trim(customerId))
.eq(StringUtils.hasText(warehouseId), WarehouseTransferOrder::getOperatingWarehouseId, trim(warehouseId)) .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)); .orderByDesc(WarehouseTransferOrder::getUpdatedAt));
List<DeliveryResponses.WarehouseTransferOutboundCandidate> result = new ArrayList<>(); List<DeliveryResponses.WarehouseTransferOutboundCandidate> result = new ArrayList<>();
for (WarehouseTransferOrder transfer : transfers) { 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()); LastlegOrder order = tailOrders.selectById(transfer.getLastlegOrderId());
List<WarehouseTransferPallet> completedPallets = warehouseTransferPallets.listOrder( List<WarehouseTransferPallet> completedPallets = warehouseTransferPallets.listOrder(
transfer.getCustomerId(), transfer.getId()).stream() transfer.getCustomerId(), transfer.getId()).stream()
.filter(pallet -> "COMPLETED".equals(pallet.getStatus()) && pallet.getWarehousePalletId() != null) .filter(pallet -> "COMPLETED".equals(pallet.getStatus()) && pallet.getWarehousePalletId() != null)
.toList(); .toList();
long available = 0;
for (WarehouseTransferPallet pallet : completedPallets) { for (WarehouseTransferPallet pallet : completedPallets) {
long palletAvailable = inventoryPositions.listPallet(transfer.getCustomerId(), pallet.getWarehousePalletId()) long palletAvailable = inventoryPositions.listPallet(transfer.getCustomerId(), pallet.getWarehousePalletId())
.stream().filter(position -> transfer.getOperatingWarehouseId().equals(position.getOverseasWarehouseId())) .stream().filter(position -> transfer.getOperatingWarehouseId().equals(position.getOverseasWarehouseId()))
.mapToLong(position -> value(position.getAvailablePackages())).sum(); .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; return result;
} }
@@ -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," + "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.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," +
"p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," + "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) " + "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) " + "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 &gt; 0 THEN a.picked_package_count " + "ELSE COALESCE(SUM(CASE WHEN a.picked_package_count &gt; 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 " + "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 ri.fba_carton_mark IS NOT NULL AND ri.fba_carton_mark != '' " +
"AND ra.status NOT IN ('REJECTED','CANCELLED')) AS relabelCartonMarks," + "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 " + "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 " + "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_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_inbound_plan_line l ON l.id=a.line_id " +
"LEFT JOIN ll_order o ON o.id=l.order_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 " + "(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)))) " +
"<if test='customerId != null and customerId != \"\"'> AND ip.customer_id=#{customerId}</if> " + "<if test='customerId != null and customerId != \"\"'> AND ip.customer_id=#{customerId}</if> " +
"<if test='planIds != null and planIds.size() > 0'> AND p.plan_id IN " + "<if test='planIds != null and planIds.size() > 0'> AND p.plan_id IN " +
"<foreach collection='planIds' item='id' open='(' separator=',' close=')'>#{id}</foreach></if> " + "<foreach collection='planIds' item='id' open='(' separator=',' close=')'>#{id}</foreach></if> " +
@@ -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('PLAN:',p.id) OR (p.warehouse_pallet_id IS NOT NULL AND " +
"d.source_pallet_key=CONCAT('RECEIVED:',p.warehouse_pallet_id))) " + "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 (#{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 " + "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) " + "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 " + // 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 " + "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," + "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 " + "p.destination_warehouse_code,p.destination_warehouse_name,p.allocated_volume_cbm " +
"ORDER BY p.plan_id,p.sequence_no" + "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," + "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.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," +
"p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," + "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) " + "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) " + "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 &gt; 0 THEN a.picked_package_count " + "ELSE COALESCE(SUM(CASE WHEN a.picked_package_count &gt; 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 " + "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 ri.fba_carton_mark IS NOT NULL AND ri.fba_carton_mark != '' " +
"AND ra.status NOT IN ('REJECTED','CANCELLED')) AS relabelCartonMarks," + "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 " + "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 " + "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_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_inbound_plan_line l ON l.id=a.line_id " +
"LEFT JOIN ll_order o ON o.id=l.order_id " + "LEFT JOIN ll_order o ON o.id=l.order_id " +
"WHERE ip.customer_id=#{customerId} AND p.id IN " + "WHERE ip.customer_id=#{customerId} AND p.id IN " +
"<foreach collection='planPalletIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " + "<foreach collection='planPalletIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"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 " + "(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 " + "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_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 " + "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) " + "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 " + "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 &gt; 0)) " + "WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 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 " + "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) " + "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 " + "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 " + "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 " + "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) " + "(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," + "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 " + "p.destination_warehouse_code,p.destination_warehouse_name,p.allocated_volume_cbm " +
"ORDER BY p.id FOR UPDATE" + "ORDER BY p.id FOR UPDATE" +
@@ -241,7 +241,12 @@ public class DispatchPdaWorkflowService {
} }
} }
for (DispatchItem item : dispatchItems) { 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) { if ("RELABEL_ORDER".equals(item.getSourceBusinessType()) && item.getSourceBusinessNo() != null) {
warehouseTransfers.markOutboundByRelabel(customerId, item.getSourceBusinessNo(), now); warehouseTransfers.markOutboundByRelabel(customerId, item.getSourceBusinessNo(), now);
} }
@@ -53,6 +53,12 @@ public class MerchantRelabelApplicationController {
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')")
public ApiResponse<RelabelResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<RelabelResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo) { return ApiResponse.success(service.detail(customer(user), relabelNo)); } @PathVariable String relabelNo) { return ApiResponse.success(service.detail(customer(user), relabelNo)); }
@PutMapping("/{relabelNo}/resubmit")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_CREATE + "')")
public ApiResponse<RelabelResponses.Detail> 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) @PostMapping(value = "/{relabelNo}/shipping-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_FILES + "')") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_FILES + "')")
public ApiResponse<RelabelResponses.Detail> uploadShippingLabel(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<RelabelResponses.Detail> uploadShippingLabel(@AuthenticationPrincipal AuthenticatedUser user,
@@ -129,6 +129,12 @@ public final class RelabelRequests {
public record ChangeStatus(@NotBlank @Size(max = 32) String status, public record ChangeStatus(@NotBlank @Size(max = 32) String status,
@Size(max = 1000) String remark) {} @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, public record CustomerConfirm(@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) {} @Size(max = 1000) String remark) {}
@@ -18,7 +18,7 @@ public final class RelabelResponses {
String walletErrorMessage, String deliveryMethod, boolean printShippingLabel, String walletErrorMessage, String deliveryMethod, boolean printShippingLabel,
String shippingLabelFileId, String shippingLabelFileName, String shippingLabelFileUrl, String shippingLabelFileId, String shippingLabelFileName, String shippingLabelFileUrl,
String fulfillmentStatus, String outboundOrderNo, LocalDateTime customerConfirmedAt, 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, public record Item(Long id, String skuCode, String productImageFileId, String productImageFileName,
String productImageUrl, String productNameZh, int totalPackageCount, String productImageUrl, String productNameZh, int totalPackageCount,
int notifiedPackageCount, String shipmentNo, String trackingCode, int notifiedPackageCount, String shipmentNo, String trackingCode,
@@ -9,7 +9,8 @@ public final class PdaRelabelResponses {
public record Candidate(String relabelNo, String customerOrderNo, LocalDateTime labelDeadline, public record Candidate(String relabelNo, String customerOrderNo, LocalDateTime labelDeadline,
String applicationStatus, String taskStatus, String operationType, int itemCount, String applicationStatus, String taskStatus, String operationType, int itemCount,
int expectedPackages, int relabelledPackages, int skippedPackages, 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, public record Detail(String relabelNo, String customerOrderNo, LocalDateTime labelDeadline,
String contactName, String contactPhone, String applicationStatus, String operationType, String contactName, String contactPhone, String applicationStatus, String operationType,
@@ -35,7 +35,8 @@ public interface PdaRelabelTaskMapper extends BaseMapper<PdaRelabelTask> {
relabelled_packages=#{task.relabelledPackages}, skipped_packages=#{task.skippedPackages}, relabelled_packages=#{task.relabelledPackages}, skipped_packages=#{task.skippedPackages},
exception_packages=#{task.exceptionPackages}, completion_photo_file_ids=#{task.completionPhotoFileIds}, exception_packages=#{task.exceptionPackages}, completion_photo_file_ids=#{task.completionPhotoFileIds},
completion_remark=#{task.completionRemark}, review_decision=#{task.reviewDecision}, 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} version=version+1, updated_at=#{task.updatedAt}
WHERE id=#{task.id} AND customer_id=#{task.customerId} AND version=#{expectedVersion} WHERE id=#{task.id} AND customer_id=#{task.customerId} AND version=#{expectedVersion}
""") """)
@@ -53,6 +53,7 @@ import java.util.stream.Collectors;
@Service @Service
public class PdaRelabelService { public class PdaRelabelService {
@Resource private com.baiyue.lastleg.relabel.service.RelabelBindingGuard bindingGuard; @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 PROCESSING = "PROCESSING";
public static final String WAIT_REVIEW = "WAIT_REVIEW"; public static final String WAIT_REVIEW = "WAIT_REVIEW";
public static final String COMPLETED = "COMPLETED"; public static final String COMPLETED = "COMPLETED";
@@ -116,7 +117,16 @@ public class PdaRelabelService {
throw conflict("当前账号正在执行换标单 " + activeTask.getRelabelNo() + ",请先完成后再操作其他换标单"); throw conflict("当前账号正在执行换标单 " + activeTask.getRelabelNo() + ",请先完成后再操作其他换标单");
} }
if (task != null) { 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"); throw conflict("Relabel task is already claimed or awaiting review");
} }
} else { } else {
@@ -488,7 +498,7 @@ public class PdaRelabelService {
requireVersion(task, request.expectedVersion()); requireVersion(task, request.expectedVersion());
// Preserve the previous round before freeing the active-label uniqueness constraint. // Preserve the previous round before freeing the active-label uniqueness constraint.
List<PdaRelabelUnitFact> previousFacts = factList(task); List<PdaRelabelUnitFact> 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(), "[]"), Map.of("facts", previousFacts, "photoFileIds", Objects.toString(task.getCompletionPhotoFileIds(), "[]"),
"completionRemark", Objects.toString(task.getCompletionRemark(), ""), "remark", request.remark())); "completionRemark", Objects.toString(task.getCompletionRemark(), ""), "remark", request.remark()));
for (PdaRelabelUnitFact fact : previousFacts) { for (PdaRelabelUnitFact fact : previousFacts) {
@@ -501,7 +511,7 @@ public class PdaRelabelService {
applicationService.resetForRework(application.getCustomerId(), relabelNo, operatorKey, request.remark()); applicationService.resetForRework(application.getCustomerId(), relabelNo, operatorKey, request.remark());
application.setStatus(PROCESSING); application.setStatus(PROCESSING);
String before = task.getStatus(); String before = task.getStatus();
String after = PROCESSING; String after = AVAILABLE;
task.setStatus(after); task.setReviewerKey(operatorKey); task.setReviewerName(operatorName); task.setStatus(after); task.setReviewerKey(operatorKey); task.setReviewerName(operatorName);
task.setReviewDecision(decision); task.setReviewRemark(trim(request.remark())); task.setReviewDecision(decision); task.setReviewRemark(trim(request.remark()));
task.setReviewedAt(LocalDateTime.now()); task.setReviewedAt(LocalDateTime.now());
@@ -694,7 +704,9 @@ public class PdaRelabelService {
task == null ? value(application.getNotifiedPackageCount()) : value(task.getExpectedPackages()), 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.getRelabelledPackages()), task == null ? 0 : value(task.getSkippedPackages()),
task == null ? 0 : value(task.getExceptionPackages()), task == null ? 0 : value(task.getVersion()), 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) { private PdaRelabelResponses.Detail detail(RelabelApplication application, PdaRelabelTask task) {
@@ -332,7 +332,11 @@ public class RelabelApplicationService implements WalletResultHandler {
private RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request, private RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request,
Long existingTailOrderId) { Long existingTailOrderId) {
if (!StringUtils.hasText(request.operationWarehouseId())) throw badRequest("Operation warehouse is required"); 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()); String deliveryMethod = trim(request.deliveryMethod());
if (requiresOutbound && (!StringUtils.hasText(deliveryMethod) || !DELIVERY_METHODS.contains(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"); 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.setPrintShippingLabel(Boolean.TRUE.equals(request.printShippingLabel()));
application.setFulfillmentStatus(requiresOutbound ? "WAIT_RELABEL" : "NOT_REQUIRED"); application.setFulfillmentStatus(requiresOutbound ? "WAIT_RELABEL" : "NOT_REQUIRED");
application.setRemark(trim(request.remark())); application.setRemark(trim(request.remark()));
application.setStatus(PENDING_PROCESSING); application.setStatus(PENDING_REVIEW);
application.setItemCount(request.items().size()); application.setItemCount(request.items().size());
application.setTotalPackageCount(total); application.setTotalPackageCount(total);
application.setNotifiedPackageCount(notified); application.setNotifiedPackageCount(notified);
@@ -476,7 +480,7 @@ public class RelabelApplicationService implements WalletResultHandler {
for (SourceInventory source : sources) { for (SourceInventory source : sources) {
insertItem(customerId, applicationId, source, now); 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) { if (existingTailOrderId == null) {
tailOrders.forEach(order -> insertOrderHistory(customerId, order.getId(), null, tailOrders.forEach(order -> insertOrderHistory(customerId, order.getId(), null,
order.getCurrentStatus(), "Relabel application submitted", now)); order.getCurrentStatus(), "Relabel application submitted", now));
@@ -541,6 +545,24 @@ public class RelabelApplicationService implements WalletResultHandler {
@Transactional @Transactional
public RelabelResponses.Detail changeStatus(String customerId, String relabelNo, String operatorId, public RelabelResponses.Detail changeStatus(String customerId, String relabelNo, String operatorId,
RelabelRequests.ChangeStatus request) { 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) RelabelApplication application = StringUtils.hasText(customerId)
? applications.lockNo(customerId, relabelNo) ? applications.lockNo(customerId, relabelNo)
: applications.lockAnyByRelabelNo(relabelNo); : applications.lockAnyByRelabelNo(relabelNo);
@@ -548,6 +570,16 @@ public class RelabelApplicationService implements WalletResultHandler {
customerId = application.getCustomerId(); customerId = application.getCustomerId();
String next = request.status().trim().toUpperCase(); String next = request.status().trim().toUpperCase();
if (!STATUSES.contains(next)) throw badRequest("Unsupported relabel status"); 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); if (next.equals(application.getStatus())) return detail(customerId, relabelNo);
validateTransition(application.getStatus(), next); validateTransition(application.getStatus(), next);
if (PENDING_REVIEW.equals(application.getStatus()) && PENDING_PROCESSING.equals(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 CUSTOMER_CONFIRM_PENDING -> mainStatus;
case "COMPLETED" -> "RETURN_RELABEL".equals(order.getOrderType()) case "COMPLETED" -> "RETURN_RELABEL".equals(order.getOrderType())
? "PENDING_ASSIGNMENT" : "COMPLETED"; ? "PENDING_ASSIGNMENT" : "COMPLETED";
case "REJECTED", "CANCELLED" -> "CANCELLED"; case "REJECTED" -> directApplication ? "REJECTED" : "CANCELLED";
case "CANCELLED" -> "CANCELLED";
default -> mainStatus; default -> mainStatus;
}; };
// Reviewing/processing a return relabel must not move the already // Reviewing/processing a return relabel must not move the already
@@ -700,6 +733,51 @@ public class RelabelApplicationService implements WalletResultHandler {
return detail(customerId, relabelNo); 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 @Transactional
public void resetForRework(String customerId, String relabelNo, String operatorId, String remark) { public void resetForRework(String customerId, String relabelNo, String operatorId, String remark) {
RelabelApplication application = applications.lockNo(customerId, relabelNo); RelabelApplication application = applications.lockNo(customerId, relabelNo);
@@ -801,19 +879,22 @@ public class RelabelApplicationService implements WalletResultHandler {
if (linkedTransfer != null) { if (linkedTransfer != null) {
WarehouseTransferOrder transfer = linkedTransfer; WarehouseTransferOrder transfer = linkedTransfer;
if (transfer != null && "WAIT_RELABEL".equals(transfer.getStatus())) { if (transfer != null && "WAIT_RELABEL".equals(transfer.getStatus())) {
Set<String> destinations = relabelItems.stream() // Item-level relabel destinations apply only to those cartons.
.map(RelabelItem::getDestinationFbaWarehouseCode) // They must never bind the whole transfer, because its remaining
.filter(StringUtils::hasText).map(String::trim).collect(Collectors.toSet()); // cartons may still require a separate destination selection.
if (!StringUtils.hasText(transfer.getFbaWarehouseCode()) && destinations.size() == 1) { boolean transferHasDestination = "FBA".equals(transfer.getDestinationType())
transfer.setFbaWarehouseCode(destinations.iterator().next()); ? StringUtils.hasText(transfer.getDestinationWarehouseCode())
} : "PRIVATE".equals(transfer.getDestinationType())
String transferStatus = StringUtils.hasText(transfer.getFbaWarehouseCode()) && StringUtils.hasText(transfer.getPrivateDestinationAddress());
String transferStatus = transferHasDestination
? "READY_FOR_DISPATCH" : "WAIT_FBA_DESTINATION"; ? "READY_FOR_DISPATCH" : "WAIT_FBA_DESTINATION";
transfer.setStatus(transferStatus); transfer.setStatus(transferStatus);
transfer.setRelabelCompletedAt(now); transfer.setRelabelCompletedAt(now);
transfer.setUpdatedAt(now); transfer.setUpdatedAt(now);
warehouseTransfers.updateById(transfer); 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); ? "WAIT_PRE_DISPATCH" : transferStatus);
applications.updateById(application); applications.updateById(application);
// 转运单自身的尾程订单与换标申请尾程订单是两条记录,需同步释放原转运单参与排车。 // 转运单自身的尾程订单与换标申请尾程订单是两条记录,需同步释放原转运单参与排车。
@@ -945,6 +1026,8 @@ public class RelabelApplicationService implements WalletResultHandler {
value.getDeliveryMethod(), Boolean.TRUE.equals(value.getPrintShippingLabel()), value.getDeliveryMethod(), Boolean.TRUE.equals(value.getPrintShippingLabel()),
value.getShippingLabelFileId(), value.getShippingLabelFileName(), value.getShippingLabelFileUrl(), value.getShippingLabelFileId(), value.getShippingLabelFileName(), value.getShippingLabelFileUrl(),
value.getFulfillmentStatus(), value.getOutboundOrderNo(), value.getCustomerConfirmedAt(), value.getFulfillmentStatus(), value.getOutboundOrderNo(), value.getCustomerConfirmedAt(),
order != null && "RELABEL_APPLICATION".equals(order.getOrderType()) ? "TAIL_ORDER" : "RELABEL_ORDER",
null,
value.getCreatedAt()); value.getCreatedAt());
} }
@@ -996,8 +1079,8 @@ public class RelabelApplicationService implements WalletResultHandler {
.and(w -> w.eq(LastlegOrder::getSourceOrderNo, customerOrderNo).or().eq(LastlegOrder::getOrderNo, customerOrderNo)) .and(w -> w.eq(LastlegOrder::getSourceOrderNo, customerOrderNo).or().eq(LastlegOrder::getOrderNo, customerOrderNo))
.last("LIMIT 1")); .last("LIMIT 1"));
if (existingReturn != null) { if (existingReturn != null) {
existingReturn.setRelabelOrderNo(relabelNo); existingReturn.setRelabelStatus(PENDING_PROCESSING); existingReturn.setRelabelOrderNo(relabelNo); existingReturn.setRelabelStatus(PENDING_REVIEW);
existingReturn.setSubOrderStatus("RETURN_RELABEL:" + PENDING_PROCESSING); existingReturn.setPackageCount(packageCount); existingReturn.setSubOrderStatus("RETURN_RELABEL:" + PENDING_REVIEW); existingReturn.setPackageCount(packageCount);
existingReturn.setUpdatedAt(now); orders.updateById(existingReturn); return existingReturn; existingReturn.setUpdatedAt(now); orders.updateById(existingReturn); return existingReturn;
} }
LastlegOrder order = new LastlegOrder(); LastlegOrder order = new LastlegOrder();
@@ -1008,9 +1091,9 @@ public class RelabelApplicationService implements WalletResultHandler {
order.setSourceInboundId(relabelNo + ":" + warehouseId); order.setSourceInboundId(relabelNo + ":" + warehouseId);
order.setSourceOrderNo(request.customerOrderNo().trim()); order.setOverseasWarehouseId(warehouseId); order.setSourceOrderNo(request.customerOrderNo().trim()); order.setOverseasWarehouseId(warehouseId);
order.setPalletCount(0); order.setPackageCount(packageCount); order.setPickedPackageCount(0); order.setPalletCount(0); order.setPackageCount(packageCount); order.setPickedPackageCount(0);
order.setShippedPackageCount(0); order.setCurrentStatus("WAIT_RELABEL"); order.setShippedPackageCount(0); order.setCurrentStatus(PENDING_REVIEW);
order.setSubOrderStatus("RELABEL:" + PENDING_PROCESSING); order.setRelabelOrderNo(relabelNo); order.setSubOrderStatus("RELABEL:" + PENDING_REVIEW); order.setRelabelOrderNo(relabelNo);
order.setRelabelStatus(PENDING_PROCESSING); order.setCustomerRemark(trim(request.remark())); order.setRelabelStatus(PENDING_REVIEW); order.setCustomerRemark(trim(request.remark()));
order.setSourceEventId("CUSTOMER_RELABEL:" + relabelNo + ":" + warehouseId); order.setSourceEventId("CUSTOMER_RELABEL:" + relabelNo + ":" + warehouseId);
order.setSourceLoadingPlanId("RELABEL_APPLICATION"); order.setSourcePayloadHash("sha256:" + requestHash); order.setSourceLoadingPlanId("RELABEL_APPLICATION"); order.setSourcePayloadHash("sha256:" + requestHash);
order.setCreatedAt(now); order.setUpdatedAt(now); orders.insert(order); 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())) { if (order == null || !customerId.equals(order.getCustomerId())) {
throw conflict("Existing tail order does not belong to this customer"); throw conflict("Existing tail order does not belong to this customer");
} }
order.setRelabelOrderNo(relabelNo); order.setRelabelStatus(PENDING_PROCESSING); order.setRelabelOrderNo(relabelNo); order.setRelabelStatus(PENDING_REVIEW);
order.setPackageCount(packageCount); order.setUpdatedAt(now); orders.updateById(order); // 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; return order;
} }
@@ -30,6 +30,7 @@ public final class WarehouseTransferResponses {
String lastlegOrderNo, String operatingWarehouseId, String fbaWarehouseCode, String lastlegOrderNo, String operatingWarehouseId, String fbaWarehouseCode,
String destinationType, String destinationWarehouseCode, String destinationWarehouseName, String destinationType, String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, String deliveryMethod, boolean needRelabel, String relabelNo, int packageCount, String deliveryMethod, boolean needRelabel, String relabelNo,
String relabelApplicationStatus, String relabelFulfillmentStatus,
String status, LocalDateTime estimatedArrivalAt, LocalDateTime createdAt) {} String status, LocalDateTime estimatedArrivalAt, LocalDateTime createdAt) {}
public record Detail(Long id, String transferNo, Long inboundOrderId, String inboundCode, public record Detail(Long id, String transferNo, Long inboundOrderId, String inboundCode,
Long lastlegOrderId, String lastlegOrderNo, String orderType, String orderStatus, Long lastlegOrderId, String lastlegOrderNo, String orderType, String orderStatus,
@@ -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") @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, int markOutboundByOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId,
@Param("outboundAt") java.time.LocalDateTime outboundAt); @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, int markOutboundByRelabel(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo,
@Param("outboundAt") java.time.LocalDateTime outboundAt); @Param("outboundAt") java.time.LocalDateTime outboundAt);
} }
@@ -147,8 +147,10 @@ public class WarehouseTransferService {
validateDestination(request.destinationType(), request.destinationWarehouseCode(), request.fbaWarehouseCode(), validateDestination(request.destinationType(), request.destinationWarehouseCode(), request.fbaWarehouseCode(),
request.privateDestinationAddress(), request.privateDestinationPostalCode(), request.privateDestinationAddress(), request.privateDestinationPostalCode(),
request.privateDestinationContactName(), request.privateDestinationContactPhone()); request.privateDestinationContactName(), request.privateDestinationContactPhone());
validateTransferDestinationRelabel(request.destinationType(), request.fbaWarehouseCode(), request.destinationWarehouseCode(), // A split relabel transfer may intentionally defer the whole-order
request.needRelabel()); // 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 key = request.idempotencyKey().trim();
String requestHash = hash(request); String requestHash = hash(request);
WarehouseTransferOrder replay = transfers.findKey(customerId, key); WarehouseTransferOrder replay = transfers.findKey(customerId, key);
@@ -279,8 +281,6 @@ public class WarehouseTransferService {
validateDestination(request.destinationType(), request.destinationWarehouseCode(), request.fbaWarehouseCode(), validateDestination(request.destinationType(), request.destinationWarehouseCode(), request.fbaWarehouseCode(),
request.privateDestinationAddress(), request.privateDestinationPostalCode(), request.privateDestinationAddress(), request.privateDestinationPostalCode(),
request.privateDestinationContactName(), request.privateDestinationContactPhone()); request.privateDestinationContactName(), request.privateDestinationContactPhone());
validateTransferDestinationRelabel(request.destinationType(), request.fbaWarehouseCode(), request.destinationWarehouseCode(),
request.needRelabel());
LastlegOrder order = requireTailOrder(customerId, transfer); LastlegOrder order = requireTailOrder(customerId, transfer);
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
@@ -422,12 +422,16 @@ public class WarehouseTransferService {
.le(query.createdTo() != null, WarehouseTransferOrder::getCreatedAt, query.createdTo()) .le(query.createdTo() != null, WarehouseTransferOrder::getCreatedAt, query.createdTo())
.orderByDesc(WarehouseTransferOrder::getCreatedAt).orderByDesc(WarehouseTransferOrder::getId); .orderByDesc(WarehouseTransferOrder::getCreatedAt).orderByDesc(WarehouseTransferOrder::getId);
Page<WarehouseTransferOrder> result = transfers.selectPage(Page.of(query.page(), query.size()), wrapper); Page<WarehouseTransferOrder> result = transfers.selectPage(Page.of(query.page(), query.size()), wrapper);
Map<String, RelabelApplication> relabelApplicationsByNo = relabelApplications(result.getRecords());
List<WarehouseTransferResponses.Summary> records = result.getRecords().stream().map(value -> List<WarehouseTransferResponses.Summary> records = result.getRecords().stream().map(value ->
new WarehouseTransferResponses.Summary(value.getId(), value.getTransferNo(), value.getInboundCode(), new WarehouseTransferResponses.Summary(value.getId(), value.getTransferNo(), value.getInboundCode(),
value.getLastlegOrderId(), value.getLastlegOrderNo(), value.getOperatingWarehouseId(), value.getLastlegOrderId(), value.getLastlegOrderNo(), value.getOperatingWarehouseId(),
value.getFbaWarehouseCode(), value.getDestinationType(), value.getDestinationWarehouseCode(), value.getFbaWarehouseCode(), value.getDestinationType(), value.getDestinationWarehouseCode(),
value.getDestinationWarehouseName(), value.getPackageCount(), value.getDeliveryMethod(), 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(); value.getEstimatedArrivalAt(), value.getCreatedAt())).toList();
return new PageResult<>(records, result.getCurrent(), result.getSize(), result.getTotal(), result.getPages()); return new PageResult<>(records, result.getCurrent(), result.getSize(), result.getTotal(), result.getPages());
} }
@@ -911,7 +915,7 @@ public class WarehouseTransferService {
transfers.updateById(transfer); transfers.updateById(transfer);
LastlegOrder order = requireTailOrder(customerId, transfer); String before = order.getCurrentStatus(); LastlegOrder order = requireTailOrder(customerId, transfer); String before = order.getCurrentStatus();
order.setCurrentStatus(INBOUND_COMPLETED); 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.setSubOrderStatus("WAREHOUSE_TRANSFER:" + transfer.getStatus() + ";RELABEL:" + order.getRelabelStatus());
order.setUpdatedAt(now); orders.updateById(order); order.setUpdatedAt(now); orders.updateById(order);
if (!java.util.Objects.equals(before, order.getCurrentStatus())) { if (!java.util.Objects.equals(before, order.getCurrentStatus())) {
@@ -1214,18 +1218,34 @@ public class WarehouseTransferService {
@Transactional(readOnly = true) @Transactional(readOnly = true)
public List<WarehouseTransferResponses.Summary> pdaCandidates(long overseasWarehouseId) { public List<WarehouseTransferResponses.Summary> pdaCandidates(long overseasWarehouseId) {
return transfers.selectList(new LambdaQueryWrapper<WarehouseTransferOrder>() List<WarehouseTransferOrder> candidates = transfers.selectList(new LambdaQueryWrapper<WarehouseTransferOrder>()
.eq(WarehouseTransferOrder::getOperatingWarehouseId, String.valueOf(overseasWarehouseId)) .eq(WarehouseTransferOrder::getOperatingWarehouseId, String.valueOf(overseasWarehouseId))
.in(WarehouseTransferOrder::getStatus, List.of(WAIT_PDA_RECEIVING, APPROVED, ALLOCATED, WAIT_RELABEL, READY_FOR_DISPATCH)) .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<String, RelabelApplication> relabelApplicationsByNo = relabelApplications(candidates);
return candidates.stream().map(value ->
new WarehouseTransferResponses.Summary(value.getId(), value.getTransferNo(), value.getInboundCode(), new WarehouseTransferResponses.Summary(value.getId(), value.getTransferNo(), value.getInboundCode(),
value.getLastlegOrderId(), value.getLastlegOrderNo(), value.getOperatingWarehouseId(), value.getLastlegOrderId(), value.getLastlegOrderNo(), value.getOperatingWarehouseId(),
value.getFbaWarehouseCode(), value.getDestinationType(), value.getDestinationWarehouseCode(), value.getFbaWarehouseCode(), value.getDestinationType(), value.getDestinationWarehouseCode(),
value.getDestinationWarehouseName(), value.getPackageCount(), value.getDeliveryMethod(), 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(); value.getEstimatedArrivalAt(), value.getCreatedAt())).toList();
} }
private Map<String, RelabelApplication> relabelApplications(List<WarehouseTransferOrder> transferOrders) {
Set<String> relabelNos = transferOrders.stream().map(WarehouseTransferOrder::getRelabelNo)
.filter(StringUtils::hasText).collect(Collectors.toSet());
if (relabelNos.isEmpty()) return Map.of();
List<RelabelApplication> applications = relabelApplications.selectList(
new LambdaQueryWrapper<RelabelApplication>().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) @Transactional(readOnly = true)
public WarehouseTransferResponses.Detail pdaDetail(long overseasWarehouseId, String transferNo) { public WarehouseTransferResponses.Detail pdaDetail(long overseasWarehouseId, String transferNo) {
WarehouseTransferOrder transfer = transfers.findNoByWarehouse(transferNo, String.valueOf(overseasWarehouseId)); WarehouseTransferOrder transfer = transfers.findNoByWarehouse(transferNo, String.valueOf(overseasWarehouseId));
@@ -1391,19 +1411,13 @@ public class WarehouseTransferService {
} }
} }
private static void validateTransferDestinationRelabel(String destinationType, String fbaWarehouseCode, private static void validateTransferDestinationRequired(String destinationType, String fbaWarehouseCode,
String destinationWarehouseCode, boolean needRelabel) { boolean allowDeferredForRelabel) {
String type = trim(destinationType); String type = trim(destinationType);
if (type == null && StringUtils.hasText(fbaWarehouseCode)) type = "FBA"; if (type == null && StringUtils.hasText(fbaWarehouseCode)) type = "FBA";
if (type == null || "NONE".equalsIgnoreCase(type)) { if ((type == null || "NONE".equalsIgnoreCase(type)) && !allowDeferredForRelabel) {
throw badRequest("仓储转运必须选择 FBA 仓或私人仓"); 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) { private static void applyDestination(WarehouseTransferOrder transfer, WarehouseTransferRequests.Create request) {
@@ -1598,7 +1612,7 @@ public class WarehouseTransferService {
transfer.getOperatingWarehouseId(), transfer.getLabelDeadline(), transfer.getRelabelContactName(), transfer.getOperatingWarehouseId(), transfer.getLabelDeadline(), transfer.getRelabelContactName(),
transfer.getRelabelContactPhone(), transfer.getRelabelChargeAmount(), "仓储转运确认后自动创建换标单", transfer.getRelabelContactPhone(), transfer.getRelabelChargeAmount(), "仓储转运确认后自动创建换标单",
"WAREHOUSE_TRANSFER:" + transfer.getTransferNo() + ":RELABEL", items, "WAREHOUSE_TRANSFER:" + transfer.getTransferNo() + ":RELABEL", items,
trim(transfer.getRelabelDeliveryMethod()), false); firstText(transfer.getRelabelDeliveryMethod(), "TRUCK"), false);
return relabelService.createForExistingTailOrder(customerId, operatorId, return relabelService.createForExistingTailOrder(customerId, operatorId,
transfer.getLastlegOrderId(), request); transfer.getLastlegOrderId(), request);
} }
@@ -240,6 +240,16 @@ paths:
responses: responses:
'200': { description: 统一 ApiResponse 包装的尾程订单详情;order.relabelApplications 返回所有换标子单的独立状态和本次数量 } '200': { description: 统一 ApiResponse 包装的尾程订单详情;order.relabelApplications 返回所有换标子单的独立状态和本次数量 }
'404': { description: 尾程订单不存在或不属于查询客户 } '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: /api/platform/v1/last-leg/orders/{id}/fees:
post: post:
summary: 新增尾程订单费用 summary: 新增尾程订单费用
@@ -1001,7 +1011,7 @@ paths:
/api/merchant/v1/last-leg/warehouse-transfers: /api/merchant/v1/last-leg/warehouse-transfers:
get: get:
summary: 客户分页查询自己的仓储转运单 summary: 客户分页查询自己的仓储转运单
description: 权限 merchant:overseas-warehouse:transfer:list;列表权限包含详情只读。 description: 权限 merchant:overseas-warehouse:transfer:list;列表权限包含详情只读;每行同时返回关联换标单号 relabelNo、换标申请流程状态 relabelStatus 与履约状态 relabelFulfillmentStatus。
security: [{ bearerAuth: [] }] security: [{ bearerAuth: [] }]
responses: { '200': { description: 当前客户仓储转运单分页 }, '403': { description: 缺少仓储转运查看权限 } } responses: { '200': { description: 当前客户仓储转运单分页 }, '403': { description: 缺少仓储转运查看权限 } }
post: post:
@@ -1355,7 +1365,7 @@ paths:
/api/platform/v1/last-leg/relabel-applications/{relabelNo}/status: /api/platform/v1/last-leg/relabel-applications/{relabelNo}/status:
put: put:
summary: 变更海外换标单状态 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: [] }] security: [{ bearerAuth: [] }]
parameters: parameters:
- { name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } } - { 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: /api/platform/v1/last-leg/relabel-applications/{relabelNo}/files:
post: post:
summary: 上传总换标PDF或明细换标PDF 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: [] }] security: [{ bearerAuth: [] }]
parameters: parameters:
- { name: relabelNo, in: path, required: true, schema: { type: string } } - { name: relabelNo, in: path, required: true, schema: { type: string } }
@@ -1458,10 +1468,18 @@ paths:
responses: responses:
'200': { description: 换标申请详情, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelMerchantDetailResponse' } } } } '200': { description: 换标申请详情, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelMerchantDetailResponse' } } } }
'404': { description: 申请不存在或不属于当前客户 } '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: /api/merchant/v1/last-leg/relabel-applications/{relabelNo}/shipping-label:
post: post:
summary: 上传换标快递面单 summary: 上传换标快递面单
description: 权限 merchant:overseas-warehouse:relabel:files;仅 EXPRESS 且申请尚未客户确认时允许上传,文件由服务端绑定到申请单。 description: 权限 merchant:overseas-warehouse:relabel:files;仅 EXPRESS 且申请尚未客户确认时允许上传;审核驳回后可替换文件再重新提交,文件由服务端绑定到申请单。
security: [{ bearerAuth: [] }] security: [{ bearerAuth: [] }]
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } }] 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 } } } } } } 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: 来源冻结货件编号;多值以逗号分隔 } fbaShipmentId: { type: string, nullable: true, description: 来源冻结货件编号;多值以逗号分隔 }
poReferenceNo: { type: string, nullable: true, description: 来源冻结FBA跟踪/参考号;多值以逗号分隔 } poReferenceNo: { type: string, nullable: true, description: 来源冻结FBA跟踪/参考号;多值以逗号分隔 }
packageCount: { type: integer, minimum: 0 } 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: RelabelMerchantPageResponse:
allOf: allOf:
- { $ref: '#/components/schemas/MerchantApiResponse' } - { $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] } fulfillmentStatus: { type: string, nullable: true, enum: [WAIT_RELABEL, WAIT_PRE_DISPATCH, READY_FOR_OUTBOUND, OUTBOUND_CREATED, COMPLETED] }
outboundOrderNo: { type: string, nullable: true } outboundOrderNo: { type: string, nullable: true }
customerConfirmedAt: { type: string, format: date-time, 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 } createdAt: { type: string, format: date-time }
RelabelDetail: RelabelDetail:
type: object type: object
@@ -2764,6 +2784,15 @@ components:
properties: properties:
status: { $ref: '#/components/schemas/RelabelStatus' } status: { $ref: '#/components/schemas/RelabelStatus' }
remark: { type: string, nullable: true, maxLength: 1000 } 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: WalletFreezeRetry:
type: object type: object
required: [idempotencyKey] required: [idempotencyKey]
@@ -113,6 +113,31 @@ class PdaRelabelServiceTest {
verify(tasks, never()).insert(any(PdaRelabelTask.class)); 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 @Test
void workPhotoDownloadValidatesTaskOwnershipAndFileScope() { void workPhotoDownloadValidatesTaskOwnershipAndFileScope() {
application.setStatus("PROCESSING"); application.setStatus("PROCESSING");
@@ -369,10 +394,12 @@ class PdaRelabelServiceTest {
var result = service.returnForRework("8", "reviewer", "FBR-1", var result = service.returnForRework("8", "reviewer", "FBR-1",
new PdaRelabelRequests.Review("REWORK", 2, "review-rework", "wrong label")); new PdaRelabelRequests.Review("REWORK", 2, "review-rework", "wrong label"));
assertEquals("PROCESSING", result.taskStatus()); assertEquals("AVAILABLE", result.taskStatus());
assertEquals("PROCESSING", result.applicationStatus()); assertEquals("PROCESSING", result.applicationStatus());
assertEquals(0, result.relabelledPackages()); assertEquals(0, result.relabelledPackages());
assertEquals("wrong label", result.reviewRemark()); assertEquals("wrong label", result.reviewRemark());
assertEquals("7", task.getOperatorKey());
assertEquals("picker", task.getOperatorName());
assertTrue(result.completionPhotoFileIds().isEmpty()); assertTrue(result.completionPhotoFileIds().isEmpty());
verify(applicationService).resetForRework("100", "FBR-1", "8", "wrong label"); verify(applicationService).resetForRework("100", "FBR-1", "8", "wrong label");
verify(applicationService, never()).changeStatus(anyString(), anyString(), anyString(), any()); verify(applicationService, never()).changeStatus(anyString(), anyString(), anyString(), any());
@@ -126,7 +126,7 @@ class RelabelApplicationServiceTest {
void createBuildsPendingRelabelTailOrderItemsAndBothHistories() { void createBuildsPendingRelabelTailOrderItemsAndBothHistories() {
RelabelRequests.Create request = request("key-1", 10, 6); RelabelRequests.Create request = request("key-1", 10, 6);
RelabelApplication persisted = application(11L, "FBR-1", "10001", "key-1", null, RelabelApplication persisted = application(11L, "FBR-1", "10001", "key-1", null,
RelabelApplicationService.PENDING_PROCESSING); RelabelApplicationService.PENDING_REVIEW);
persisted.setLastlegOrderId(21L); persisted.setLastlegOrderId(21L);
persisted.setItemCount(1); persisted.setTotalPackageCount(10); persisted.setNotifiedPackageCount(6); persisted.setItemCount(1); persisted.setTotalPackageCount(10); persisted.setNotifiedPackageCount(6);
when(applications.findNo(eq("10001"), any())).thenReturn(persisted); when(applications.findNo(eq("10001"), any())).thenReturn(persisted);
@@ -149,8 +149,8 @@ class RelabelApplicationServiceTest {
verify(orderHistories).insert(tailHistory.capture()); verify(orderHistories).insert(tailHistory.capture());
assertEquals("RELABEL_APPLICATION", tailOrder.getValue().getOrderType()); assertEquals("RELABEL_APPLICATION", tailOrder.getValue().getOrderType());
assertEquals("WAIT_RELABEL", tailOrder.getValue().getCurrentStatus()); assertEquals("PENDING_REVIEW", tailOrder.getValue().getCurrentStatus());
assertEquals("RELABEL:PENDING_PROCESSING", tailOrder.getValue().getSubOrderStatus()); assertEquals("RELABEL:PENDING_REVIEW", tailOrder.getValue().getSubOrderStatus());
assertEquals("CUSTOMER", tailOrder.getValue().getSourceRegion()); assertEquals("CUSTOMER", tailOrder.getValue().getSourceRegion());
assertTrue(tailOrder.getValue().getOrderNo().matches("RL-WH-\\d{8}-\\d{6}")); assertTrue(tailOrder.getValue().getOrderNo().matches("RL-WH-\\d{8}-\\d{6}"));
assertNull(tailOrder.getValue().getInboundCode()); assertNull(tailOrder.getValue().getInboundCode());
@@ -159,8 +159,8 @@ class RelabelApplicationServiceTest {
assertEquals(10, tailOrder.getValue().getPackageCount()); assertEquals(10, tailOrder.getValue().getPackageCount());
assertEquals(6, application.getValue().getNotifiedPackageCount()); assertEquals(6, application.getValue().getNotifiedPackageCount());
assertEquals("SKU-A", relabelItem.getValue().getSkuCode()); assertEquals("SKU-A", relabelItem.getValue().getSkuCode());
assertEquals("PENDING_PROCESSING", relabelHistory.getValue().getToStatus()); assertEquals("PENDING_REVIEW", relabelHistory.getValue().getToStatus());
assertEquals("WAIT_RELABEL", tailHistory.getValue().getToStatus()); assertEquals("PENDING_REVIEW", tailHistory.getValue().getToStatus());
assertEquals("FBR-1", result.application().relabelNo()); assertEquals("FBR-1", result.application().relabelNo());
} }
@@ -168,7 +168,7 @@ class RelabelApplicationServiceTest {
void warehouseTransferRelabelReusesExistingTailOrder() { void warehouseTransferRelabelReusesExistingTailOrder() {
RelabelRequests.Create request = request("key-transfer", 10, 6); RelabelRequests.Create request = request("key-transfer", 10, 6);
RelabelApplication persisted = application(11L, "FBR-TRANSFER", "10001", "key-transfer", null, RelabelApplication persisted = application(11L, "FBR-TRANSFER", "10001", "key-transfer", null,
RelabelApplicationService.PENDING_PROCESSING); RelabelApplicationService.PENDING_REVIEW);
persisted.setLastlegOrderId(21L); persisted.setLastlegOrderId(21L);
LastlegOrder transferOrder = order(21L, "10001", "WT-WH-20260916-560449", "INBOUND_COMPLETED"); LastlegOrder transferOrder = order(21L, "10001", "WT-WH-20260916-560449", "INBOUND_COMPLETED");
transferOrder.setOrderType("WAREHOUSE_TRANSFER"); transferOrder.setOrderType("WAREHOUSE_TRANSFER");
@@ -185,9 +185,38 @@ class RelabelApplicationServiceTest {
verify(orderHistories, never()).insert(any(LastlegOrderStatusHistory.class)); verify(orderHistories, never()).insert(any(LastlegOrderStatusHistory.class));
assertEquals(21L, application.getValue().getLastlegOrderId()); assertEquals(21L, application.getValue().getLastlegOrderId());
assertEquals(application.getValue().getRelabelNo(), transferOrder.getRelabelOrderNo()); assertEquals(application.getValue().getRelabelNo(), transferOrder.getRelabelOrderNo());
assertEquals(RelabelApplicationService.PENDING_PROCESSING, transferOrder.getRelabelStatus()); assertEquals(RelabelApplicationService.PENDING_REVIEW, transferOrder.getRelabelStatus());
assertEquals("WAREHOUSE_TRANSFER", transferOrder.getOrderType()); assertEquals("WAREHOUSE_TRANSFER", transferOrder.getOrderType());
assertEquals("INBOUND_COMPLETED", transferOrder.getCurrentStatus()); 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<RelabelApplication> application = ArgumentCaptor.forClass(RelabelApplication.class);
verify(applications).insert(application.capture());
assertEquals("TRUCK", application.getValue().getDeliveryMethod());
assertEquals("WAIT_RELABEL", application.getValue().getFulfillmentStatus());
} }
@Test @Test
@@ -465,6 +494,56 @@ class RelabelApplicationServiceTest {
verify(walletCommands, never()).publish(any(), any(Long.class), any(), any(), any(), any(), any(Long.class)); 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<RelabelStatusHistory> history = ArgumentCaptor.forClass(RelabelStatusHistory.class);
verify(histories).insert(history.capture());
assertEquals("请替换模糊的箱唛文件", history.getValue().getRemark());
}
@Test @Test
void completedReturnRelabelKeepsParentInboundCompleted() { void completedReturnRelabelKeepsParentInboundCompleted() {
RelabelApplication application = application(11L, "FBR-1", "10001", "key-1", "hash", RelabelApplicationService.READY_FOR_OUTBOUND); RelabelApplication application = application(11L, "FBR-1", "10001", "key-1", "hash", RelabelApplicationService.READY_FOR_OUTBOUND);
@@ -44,6 +44,7 @@ import com.baiyue.lastleg.transfer.gateway.TransferMeasurementPhotoFileGateway;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferReceiptMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferReceiptMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferSourceOrderMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferSourceOrderMapper;
import com.fasterxml.jackson.databind.ObjectMapper; 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.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
@@ -90,6 +91,28 @@ class WarehouseTransferServiceTest {
assertNull(WarehouseTransferService.customerMainStatuses(WarehouseTransferService.DRAFT)); assertNull(WarehouseTransferService.customerMainStatuses(WarehouseTransferService.DRAFT));
} }
@Test
void transferPageReturnsRelabelApplicationAndFulfillmentStatuses() {
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_RELABEL);
transfer.setNeedRelabel(true);
transfer.setRelabelNo("RL-20260923-000001");
Page<WarehouseTransferOrder> 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 WarehouseTransferOrderMapper transfers;
@Mock WarehouseTransferItemMapper transferItems; @Mock WarehouseTransferItemMapper transferItems;
@Mock WarehouseTransferCartonMapper transferCartons; @Mock WarehouseTransferCartonMapper transferCartons;
@@ -328,9 +351,9 @@ class WarehouseTransferServiceTest {
} }
@Test @Test
void transferCanEnterTheTransitWarehouseBeforeFbaDestinationIsKnown() { void transferCanEnterTheTransitWarehouseWithFbaDestinationAndNoRelabel() {
WarehouseTransferRequests.Create request = new WarehouseTransferRequests.Create( 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), 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", null, null, null, "FBA destination will be supplied later", false, "key-no-fba",
List.of(new WarehouseTransferRequests.Item("SKU-1", "Product", 5, "MARK-1"))); List.of(new WarehouseTransferRequests.Item("SKU-1", "Product", 5, "MARK-1")));
@@ -346,9 +369,9 @@ class WarehouseTransferServiceTest {
ArgumentCaptor<LastlegOrder> tailOrder = ArgumentCaptor.forClass(LastlegOrder.class); ArgumentCaptor<LastlegOrder> tailOrder = ArgumentCaptor.forClass(LastlegOrder.class);
verify(transfers).insert(transfer.capture()); verify(transfers).insert(transfer.capture());
verify(orders).insert(tailOrder.capture()); verify(orders).insert(tailOrder.capture());
assertNull(transfer.getValue().getFbaWarehouseCode()); assertEquals("ONT8", transfer.getValue().getFbaWarehouseCode());
assertNull(transfer.getValue().getFbaShipmentNo()); assertNull(transfer.getValue().getFbaShipmentNo());
assertNull(tailOrder.getValue().getFbaWarehouseCode()); assertEquals("ONT8", tailOrder.getValue().getFbaWarehouseCode());
assertEquals("WAREHOUSE_PICKUP", transfer.getValue().getDeliveryMethod()); assertEquals("WAREHOUSE_PICKUP", transfer.getValue().getDeliveryMethod());
} }
@@ -391,7 +414,7 @@ class WarehouseTransferServiceTest {
@Test @Test
void newTransitInboundCreatesSnapshotAndImmutableCartonMarksWithoutHistoricalInventory() { void newTransitInboundCreatesSnapshotAndImmutableCartonMarksWithoutHistoricalInventory() {
WarehouseTransferRequests.Create request = new WarehouseTransferRequests.Create( 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), 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", null, "TRACK-MUST-NOT-BE-SAVED", null, "Incoming transit cargo", true, "key-incoming",
List.of(new WarehouseTransferRequests.Item("SKU-1", "Product A", 2, null), List.of(new WarehouseTransferRequests.Item("SKU-1", "Product A", 2, null),
+2
View File
@@ -2,6 +2,8 @@
`db/local-return-relabel-multiple-applications.sql`:本地 `baiyue_lastleg.ll_relabel_application` 单次结构修复,将尾程订单关联的唯一索引替换为普通索引,允许同一退货单剩余货物分批换标;不修改业务行,单号与幂等键唯一索引保留。凭据及目标连接由操作者显式提供,只在已授权本地库执行;DDL失败停止,已执行后不要重复运行,回查SHOW INDEX。新库结构同步在Lastleg基线和海外区域SQL中。 `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-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-<marker>.json` 或 `--output`;创建临时PDA并finally停用,不改客户权限。平台库存核验不替代源报告的客户读权限失败。会推进库存与出库状态,保留单据及报告;失败后按已保存单号核查,不自动清理,不可对已出库库存盲目重跑。 `local-return-relabel-outbound.mjs`:仅本机网关,读取显式 `LOCAL_RETURN_SOURCE` 单票报告,为其中同客户、同仓、已完成换标的唯一一件可用库存执行平台创建出库、原键重放、PDA拣货及完成。沿用平台账号环境参数,报告默认 `reports/local-return-relabel-outbound-<marker>.json` 或 `--output`;创建临时PDA并finally停用,不改客户权限。平台库存核验不替代源报告的客户读权限失败。会推进库存与出库状态,保留单据及报告;失败后按已保存单号核查,不自动清理,不可对已出库库存盲目重跑。
@@ -47,7 +47,7 @@ test('平台管理员由外部bcrypt引导,强制首登改密且不创建客
}) })
test('必要系统种子完整保留,但不写入示例资源与业务事实', () => { 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, 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, 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) 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') const admin = roleAuthorities(cn, 'PLATFORM_ADMIN')
for (const code of ['platform:order:declaration:edit', 'platform:firstleg:inbound:hold', 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-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('海外固定身份投影完全匹配国内管理员的当前海外权限', () => { test('海外固定身份投影完全匹配国内管理员的当前海外权限', () => {
+261
View File
@@ -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;
@@ -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'
);
@@ -89,7 +89,9 @@ assert(transferDeliveries.length === 1,
`Expected one destination delivery, got ${transferDeliveries.length}`) `Expected one destination delivery, got ${transferDeliveries.length}`)
const deliveryNo = transferDeliveries[0].deliveryNo 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 (!alreadyDeparted) {
if (task.items.every(item => Number(item.pickedPackages) === 0)) { if (task.items.every(item => Number(item.pickedPackages) === 0)) {
await api(overseas, platformToken, 'PUT', `/api/platform/v1/last-leg/dispatches/${dispatchNo}`, { await api(overseas, platformToken, 'PUT', `/api/platform/v1/last-leg/dispatches/${dispatchNo}`, {
@@ -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 customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID || ''
let warehouseId = process.env.OVERSEAS_TEST_WAREHOUSE_ID || '' let warehouseId = process.env.OVERSEAS_TEST_WAREHOUSE_ID || ''
const destinationCode = process.env.OVERSEAS_TEST_DESTINATION_CODE || 'ONT8' 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 const output = resolve(process.env.WAREHOUSE_TRANSFER_REMAINING_OUTPUT
|| 'reports/warehouse-transfer-remaining-acceptance.json') || 'reports/warehouse-transfer-remaining-acceptance.json')
const marker = `WT-REMAIN-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}` 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 cartonMarks = [`CM-${marker}-1`, `CM-${marker}-2`]
const cartonRelabelBindings = partialRelabel ? cartonMarks.slice(0, 1) : cartonMarks
const transfer = await api(overseas, merchant.overseasAccessToken, 'POST', const transfer = await api(overseas, merchant.overseasAccessToken, 'POST',
'/api/merchant/v1/last-leg/warehouse-transfers', { '/api/merchant/v1/last-leg/warehouse-transfers', {
operatingWarehouseId: warehouseId, operatingWarehouseId: warehouseId,
fbaWarehouseCode: destinationCode, fbaWarehouseCode: partialRelabel ? null : destinationCode,
destinationType: 'FBA', destinationType: partialRelabel ? null : 'FBA',
destinationWarehouseCode: destinationCode, destinationWarehouseCode: partialRelabel ? null : destinationCode,
destinationWarehouseName: destinationCode, destinationWarehouseName: partialRelabel ? null : destinationCode,
deliveryAddress: `Acceptance inbound address ${marker}`, deliveryAddress: `Acceptance inbound address ${marker}`,
packageCount: 2, packageCount: 2,
volumeCbm: 0.12, volumeCbm: 0.12,
@@ -214,7 +216,7 @@ try {
idempotencyKey: key('CREATE'), idempotencyKey: key('CREATE'),
items: [{ skuCode: `SKU-${marker}`.slice(0, 128), productNameZh: 'Acceptance cargo', items: [{ skuCode: `SKU-${marker}`.slice(0, 128), productNameZh: 'Acceptance cargo',
packageCount: 2, shippingMark: cartonMarks.join(',') }], packageCount: 2, shippingMark: cartonMarks.join(',') }],
relabelItems: cartonMarks.map((cartonMark, index) => ({ relabelItems: cartonRelabelBindings.map((cartonMark, index) => ({
operationType: 'CARTON_AND_SKU', customerCartonMarks: [cartonMark], operationType: 'CARTON_AND_SKU', customerCartonMarks: [cartonMark],
originalSku: `SKU-${marker}`.slice(0, 128), originalSku: `SKU-${marker}`.slice(0, 128),
newSku: `NEW-${marker}-${index + 1}`.slice(0, 128), quantity: 1, newSku: `NEW-${marker}-${index + 1}`.slice(0, 128), quantity: 1,
@@ -278,9 +280,18 @@ try {
status: transferDetail.status, relabelNo, 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', let relabel = await api(overseas, pda.token, 'POST',
`/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/claim`, { idempotencyKey: key('RELABEL-CLAIM') }) `/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()) { for (const [index, item] of relabel.items.entries()) {
relabel = await api(overseas, pda.token, 'POST', relabel = await api(overseas, pda.token, 'POST',
`/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/cartons/scan`, { `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/cartons/scan`, {
@@ -330,7 +341,8 @@ try {
transferDetail = await api(overseas, merchant.overseasAccessToken, 'GET', transferDetail = await api(overseas, merchant.overseasAccessToken, 'GET',
`/api/merchant/v1/last-leg/warehouse-transfers/${transferNo}`) `/api/merchant/v1/last-leg/warehouse-transfers/${transferNo}`)
assert(confirmedRelabel.application.status === 'READY_FOR_OUTBOUND', 'Linked relabel did not reach outbound-ready') 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', { step('Completed linked relabel and released transfer for dispatch', {
relabelStatus: confirmedRelabel.application.status, transferStatus: transferDetail.status, relabelStatus: confirmedRelabel.application.status, transferStatus: transferDetail.status,
}) })
@@ -339,17 +351,30 @@ try {
`/api/platform/v1/last-leg/dispatches/candidates?orderNo=${encodeURIComponent(relabelNo)}&sourceStage=RECEIVED`) `/api/platform/v1/last-leg/dispatches/candidates?orderNo=${encodeURIComponent(relabelNo)}&sourceStage=RECEIVED`)
const targetIds = new Set(targetPallets.map((value) => id(value.palletId))) const targetIds = new Set(targetPallets.map((value) => id(value.palletId)))
const rows = candidates.filter((value) => value.sourceStage === 'RELABEL' && targetIds.has(id(value.warehousePalletId))) 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', const dispatch = await api(overseas, admin.overseasAccessToken, 'POST',
'/api/platform/v1/last-leg/dispatches/pre-arrange', { '/api/platform/v1/last-leg/dispatches/pre-arrange', {
planPalletIds: rows.map((value) => id(value.warehousePalletId)), route: marker, relabelNo, planPalletIds: rows.map((value) => id(value.warehousePalletId)), route: marker, relabelNo,
}) })
step('Created smart pre-dispatch', { dispatchNo: dispatch.dispatchNo, status: dispatch.status }) 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 sourceReport = resolve('reports', `${marker}-relabel-source.json`)
const fulfillmentReport = resolve('reports', `${marker}-fulfillment.json`) const fulfillmentReport = resolve('reports', `${marker}-fulfillment.json`)
await writeFile(sourceReport, JSON.stringify({ marker, passed: true, scenarios: [{ 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') }] }, null, 2), 'utf8')
const child = spawnSync(process.execPath, [resolve('scripts', 'overseas-relabel-fulfillment-acceptance.mjs')], { const child = spawnSync(process.execPath, [resolve('scripts', 'overseas-relabel-fulfillment-acceptance.mjs')], {
cwd: resolve('.'), stdio: 'inherit', env: { cwd: resolve('.'), stdio: 'inherit', env: {
@@ -366,11 +391,70 @@ try {
}) })
if (child.error) throw child.error if (child.error) throw child.error
if (child.status !== 0) throw new Error(`Fulfillment acceptance failed with exit code ${child.status}`) 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}`) `/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, { Object.assign(report, {
passed: true, transferNo, relabelNo, dispatchNo: dispatch.dispatchNo, 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) { } catch (error) {
report.error = error instanceof Error ? error.message : String(error) report.error = error instanceof Error ? error.message : String(error)