贴标申请,仓储转运
This commit is contained in:
@@ -1,4 +1,8 @@
|
||||
spring:
|
||||
cloud:
|
||||
nacos:
|
||||
discovery:
|
||||
ip: ${NACOS_DISCOVERY_IP:127.0.0.1}
|
||||
datasource:
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
url: ${AUTH_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
|
||||
|
||||
@@ -2314,6 +2314,7 @@ CREATE TABLE ll_relabel_pda_unit_fact (
|
||||
result varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '逐件结果:RELABELLED、SKIPPED或EXCEPTION',
|
||||
exception_code varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '异常编码',
|
||||
reason varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '跳过或异常原因',
|
||||
quantity int NOT NULL DEFAULT '1' COMMENT '本次事实处理件数,箱级作业固定为1',
|
||||
operator_key varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作账号标识',
|
||||
operator_name varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作员名称快照',
|
||||
occurred_at datetime(3) NOT NULL COMMENT '逐件事实发生时间',
|
||||
@@ -2360,6 +2361,7 @@ CREATE TABLE ll_return_inventory_carton (
|
||||
source_receipt_id bigint DEFAULT NULL COMMENT '来源PDA签收单ID',
|
||||
source_batch_no text COLLATE utf8mb4_unicode_ci COMMENT '实际消耗来源签收批次号,多个批次逗号分隔',
|
||||
source_return_no text COLLATE utf8mb4_unicode_ci COMMENT '实际消耗来源退货单号,多个单号逗号分隔',
|
||||
pallet_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '签收时绑定的托盘码',
|
||||
carton_mark varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '退货箱号和箱唛编码',
|
||||
fba_carton_mark varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'FBA箱唛',
|
||||
destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码',
|
||||
@@ -2382,8 +2384,8 @@ CREATE TABLE ll_return_inventory_carton (
|
||||
UNIQUE KEY uk_ll_return_inventory_carton_mark (carton_mark),
|
||||
UNIQUE KEY uk_ll_return_inventory_carton_receipt (source_receipt_id),
|
||||
KEY idx_ll_return_inventory_carton_customer (customer_id,overseas_warehouse_id,status,created_at),
|
||||
CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK ((((status in (_utf8mb4'AVAILABLE',_utf8mb4'OUTBOUNDED')) and (sku_types > 0) and (total_quantity > 0)) or ((status = _utf8mb4'MERGED') and (sku_types = 0) and (total_quantity = 0)))),
|
||||
CONSTRAINT chk_ll_return_inventory_carton_status CHECK ((status in (_utf8mb4'AVAILABLE',_utf8mb4'MERGED',_utf8mb4'OUTBOUNDED')))
|
||||
CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK ((((status in (_utf8mb4'AVAILABLE',_utf8mb4'RELABEL_PENDING',_utf8mb4'OUTBOUNDED')) and (sku_types > 0) and (total_quantity > 0)) or ((status = _utf8mb4'MERGED') and (sku_types = 0) and (total_quantity = 0)))),
|
||||
CONSTRAINT chk_ll_return_inventory_carton_status CHECK ((status in (_utf8mb4'AVAILABLE',_utf8mb4'RELABEL_PENDING',_utf8mb4'MERGED',_utf8mb4'OUTBOUNDED')))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货实物库存箱及箱唛';
|
||||
|
||||
CREATE TABLE ll_return_inventory_carton_item (
|
||||
@@ -2432,6 +2434,8 @@ CREATE TABLE ll_return_order (
|
||||
expected_quantity bigint NOT NULL COMMENT '预报总数量',
|
||||
received_quantity bigint NOT NULL DEFAULT '0' COMMENT '累计实收总数量',
|
||||
remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户备注',
|
||||
discrepancy_reason varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '手动完结差异原因',
|
||||
manually_completed_at datetime(3) DEFAULT NULL COMMENT '手动差异完结时间',
|
||||
idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建幂等键',
|
||||
request_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建请求SHA-256',
|
||||
created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建操作人ID',
|
||||
@@ -2466,6 +2470,7 @@ CREATE TABLE ll_return_receipt (
|
||||
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键',
|
||||
receipt_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '签收单号',
|
||||
receipt_batch_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '签收批次号',
|
||||
pallet_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本次签收绑定的托盘码',
|
||||
carton_mark varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '签收生成或绑定的退货箱号',
|
||||
tracking_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本次签收包裹的可选快递单号',
|
||||
bound_by varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '开始签收方式,RETURN_NO退货单号或SKU候选',
|
||||
|
||||
@@ -44,6 +44,15 @@ public class InternalBusinessFileController {
|
||||
return ApiResponse.success(service.upload(tenantId, businessType, businessId, operatorId, file));
|
||||
}
|
||||
|
||||
@GetMapping("/{tenantId}/{businessType}/{businessId}")
|
||||
public ApiResponse<java.util.List<InternalBusinessFileService.Metadata>> list(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@PathVariable long tenantId, @PathVariable @NotBlank String businessType,
|
||||
@PathVariable @NotBlank String businessId) {
|
||||
check(token); checkTenantId(tenantId);
|
||||
return ApiResponse.success(service.list(tenantId, businessType, businessId));
|
||||
}
|
||||
|
||||
@GetMapping("/{tenantId}/{businessType}/{businessId}/{fileId}")
|
||||
public ApiResponse<InternalBusinessFileService.Metadata> metadata(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
|
||||
@@ -10,6 +10,7 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
@Service
|
||||
public class InternalBusinessFileService {
|
||||
@Resource private FileObjectMapper files;
|
||||
@@ -52,6 +53,16 @@ public class InternalBusinessFileService {
|
||||
return metadata(required(tenantId, businessType, businessId, fileId));
|
||||
}
|
||||
|
||||
public List<Metadata> list(long tenantId, String businessType, String businessId) {
|
||||
BusinessFileCategory category = BusinessFileCategory.require(businessType);
|
||||
category.validateOwner(tenantId); category.storageBusinessId(businessId);
|
||||
return files.selectList(new LambdaQueryWrapper<FileObjectEntity>()
|
||||
.eq(FileObjectEntity::getTenantId, tenantId).eq(FileObjectEntity::getBusinessType, businessType)
|
||||
.eq(FileObjectEntity::getBusinessId, businessId).eq(FileObjectEntity::getStatus, "ACTIVE")
|
||||
.eq(FileObjectEntity::getDeleted, false).orderByAsc(FileObjectEntity::getCreatedAt, FileObjectEntity::getId))
|
||||
.stream().map(InternalBusinessFileService::metadata).toList();
|
||||
}
|
||||
|
||||
public Download download(long tenantId, String businessType, String businessId, long fileId) {
|
||||
FileObjectEntity value = required(tenantId, businessType, businessId, fileId);
|
||||
return new Download(value.getOriginalName(), value.getContentType(), storage.read(value.getObjectKey()));
|
||||
|
||||
+11
-1
@@ -39,7 +39,17 @@ public class DeliveryCompletedOutboxService {
|
||||
Objects.requireNonNull(completedAt, "completedAt");
|
||||
Map<String, LastlegOrder> bySourceInbound = new LinkedHashMap<>();
|
||||
for (LastlegOrder order : orders == null ? List.<LastlegOrder>of() : orders) {
|
||||
if ("RELABEL_APPLICATION".equals(order.getOrderType())) continue;
|
||||
// Only orders projected from the domestic first-leg domain need an
|
||||
// acknowledgement event. Customer/local overseas orders can still carry
|
||||
// a local sourceInboundId, but deliberately have no domestic sourceOrderId.
|
||||
if (StringUtils.hasText(order.getSourceRegion())
|
||||
&& !RegionCode.CN.name().equalsIgnoreCase(order.getSourceRegion())) continue;
|
||||
// Relabel and warehouse-transfer deliveries are created and completed wholly
|
||||
// in the overseas last-leg domain. They do not have a domestic source order
|
||||
// to acknowledge, so publishing the cross-region event would make receipt
|
||||
// completion fail on the missing source-order fields.
|
||||
if ("RELABEL_APPLICATION".equals(order.getOrderType())
|
||||
|| "WAREHOUSE_TRANSFER".equals(order.getOrderType())) continue;
|
||||
if (!StringUtils.hasText(order.getSourceInboundId())) continue;
|
||||
requireText(order.getSourceOrderId(), "sourceOrderId");
|
||||
requireText(order.getInboundCode(), "inboundCode");
|
||||
|
||||
+18
-6
@@ -1,24 +1,36 @@
|
||||
package com.baiyue.lastleg.palletops.dto;
|
||||
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public final class PalletOperationResponses {
|
||||
private PalletOperationResponses() { }
|
||||
public record RelabelSource(long positionId, long positionVersion, String palletCode,
|
||||
public record RelabelSource(
|
||||
@JsonSerialize(using = ToStringSerializer.class) long positionId,
|
||||
long positionVersion,
|
||||
@JsonSerialize(using = ToStringSerializer.class) long palletId, String palletCode,
|
||||
String locationCode, String inboundCode, long stockPackages,
|
||||
long movablePackages, List<String> cartonMarks) { }
|
||||
|
||||
public record InventoryLine(String customerId, String customerName, long balanceId, long positionId,
|
||||
public record InventoryLine(String customerId, String customerName,
|
||||
@JsonSerialize(using = ToStringSerializer.class) long balanceId,
|
||||
@JsonSerialize(using = ToStringSerializer.class) long positionId,
|
||||
String inboundCode, String skuCode, String productName,
|
||||
long availablePackages, long holdPackages, long allocatedPackages,
|
||||
long pickedPackages, long receivingPackages, long positionVersion) { }
|
||||
public record PalletContent(long palletId, String palletCode, String status, int palletVersion,
|
||||
Long locationId, String locationCode, String destinationWarehouseCode,
|
||||
public record PalletContent(@JsonSerialize(using = ToStringSerializer.class) long palletId,
|
||||
String palletCode, String status, int palletVersion,
|
||||
@JsonSerialize(using = ToStringSerializer.class) Long locationId,
|
||||
String locationCode, String destinationWarehouseCode,
|
||||
long totalPackages, List<InventoryLine> inventory) { }
|
||||
public record PrintablePallet(long palletId, String palletCode, int palletVersion,
|
||||
public record PrintablePallet(@JsonSerialize(using = ToStringSerializer.class) long palletId,
|
||||
String palletCode, int palletVersion,
|
||||
String relabelNo, String printableHtml) { }
|
||||
public record Operation(long operationId, String operationType, String idempotencyKey,
|
||||
public record Operation(@JsonSerialize(using = ToStringSerializer.class) long operationId,
|
||||
String operationType, String idempotencyKey,
|
||||
String status, boolean replayed, LocalDateTime completedAt,
|
||||
List<PalletContent> pallets) {
|
||||
public Operation asReplay() {
|
||||
|
||||
+12
-1
@@ -2,6 +2,7 @@ package com.baiyue.lastleg.palletops.gateway;
|
||||
|
||||
import com.baiyue.lastleg.config.LastlegRestClientFactory;
|
||||
import com.baiyue.lastleg.config.LastlegRestClientProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
@@ -30,7 +31,14 @@ public class HttpPalletPhysicalGateway implements PalletPhysicalGateway {
|
||||
|
||||
@Override
|
||||
public PalletReference ensureRelabelPallet(long warehouseId, String businessId) {
|
||||
Map<String, Object> body = Map.of("warehouseId", warehouseId, "businessId", businessId);
|
||||
return ensureRelabelPallet(warehouseId, businessId, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PalletReference ensureRelabelPallet(long warehouseId, String businessId, String fbaWarehouseCode) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("warehouseId", warehouseId); body.put("businessId", businessId);
|
||||
if (fbaWarehouseCode != null && !fbaWarehouseCode.isBlank()) body.put("fbaWarehouseCode", fbaWarehouseCode.trim());
|
||||
ResolveEnvelope response = client.post().uri("/internal/v1/warehouse-pallets/ensure-relabel")
|
||||
.header("X-Internal-Service-Token", internalToken).body(body).retrieve()
|
||||
.onStatus(HttpStatusCode::isError, (request, result) -> {
|
||||
@@ -117,7 +125,10 @@ public class HttpPalletPhysicalGateway implements PalletPhysicalGateway {
|
||||
}).toBodilessEntity();
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
private record ResolveEnvelope(String code, String message, PalletReference data) { }
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
private record LocationEnvelope(LocationReference data) { }
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
private record TransitionEnvelope(Transition data) { }
|
||||
}
|
||||
|
||||
+6
@@ -1,7 +1,12 @@
|
||||
package com.baiyue.lastleg.palletops.gateway;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
public interface PalletPhysicalGateway {
|
||||
PalletReference ensureRelabelPallet(long warehouseId, String businessId);
|
||||
default PalletReference ensureRelabelPallet(long warehouseId, String businessId, String fbaWarehouseCode) {
|
||||
return ensureRelabelPallet(warehouseId, businessId);
|
||||
}
|
||||
PalletReference resolve(String palletCode);
|
||||
PalletReference resolve(long palletId);
|
||||
LocationReference resolveLocation(long warehouseId, long locationId);
|
||||
@@ -9,6 +14,7 @@ public interface PalletPhysicalGateway {
|
||||
String operationKey, String reason);
|
||||
void compensate(long warehouseId, Transition transition);
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
record PalletReference(long palletId, String palletCode, long warehouseId, Long locationId,
|
||||
String status, String fbaWarehouseCode, java.math.BigDecimal unitPrice, int version) {
|
||||
public PalletReference(long palletId, String palletCode, long warehouseId, Long locationId,
|
||||
|
||||
+168
-9
@@ -18,6 +18,11 @@ import com.baiyue.lastleg.relabel.entity.RelabelItem;
|
||||
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
|
||||
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
|
||||
import com.baiyue.lastleg.relabel.service.RelabelPalletMutationService;
|
||||
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
|
||||
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem;
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryBalanceMapper;
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper;
|
||||
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
|
||||
import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
@@ -48,6 +53,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class PdaPalletOperationService {
|
||||
@@ -61,6 +67,9 @@ public class PdaPalletOperationService {
|
||||
@Resource private RelabelApplicationMapper relabelApplications;
|
||||
@Resource private RelabelItemMapper relabelItems;
|
||||
@Resource private RelabelPalletMutationService relabelPalletMutations;
|
||||
@Resource private ReturnInventoryCartonMapper returnCartons;
|
||||
@Resource private ReturnInventoryCartonItemMapper returnCartonItems;
|
||||
@Resource private ReturnInventoryBalanceMapper returnBalances;
|
||||
private TransactionTemplate transaction;
|
||||
private TransactionTemplate newTransaction;
|
||||
|
||||
@@ -85,6 +94,26 @@ public class PdaPalletOperationService {
|
||||
Set<String> boundMarks = boundRelabelMarks(app);
|
||||
List<RelabelItem> items = relabelItems.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<RelabelItem>()
|
||||
.eq("application_id", app.getId()).eq("customer_id", app.getCustomerId()));
|
||||
if (!items.isEmpty() && items.stream().allMatch(item -> item.getReturnCartonId() != null)) {
|
||||
List<PalletOperationResponses.RelabelSource> result = new ArrayList<>();
|
||||
Map<Long, List<RelabelItem>> byCarton = items.stream().filter(item -> !boundMarks.contains(item.getCartonMark())).collect(Collectors.groupingBy(
|
||||
RelabelItem::getReturnCartonId, LinkedHashMap::new, Collectors.toList()));
|
||||
for (var entry : byCarton.entrySet()) {
|
||||
ReturnInventoryCarton carton = returnCartons.selectById(entry.getKey());
|
||||
if (carton == null || !Objects.equals(carton.getCustomerId(), app.getCustomerId())
|
||||
|| !Objects.equals(carton.getOverseasWarehouseId(), app.getOverseasWarehouseId())
|
||||
|| !Objects.equals(carton.getStatus(), "RELABEL_PENDING"))
|
||||
throw conflict("退货换标来源托盘不存在或已被占用");
|
||||
PalletPhysicalGateway.PalletReference pallet = physical.resolve(carton.getPalletCode());
|
||||
if (pallet == null || pallet.warehouseId() != identity.overseasWarehouseId())
|
||||
throw conflict("退货换标来源托盘不属于当前仓库");
|
||||
long quantity = value(carton.getTotalQuantity());
|
||||
result.add(new PalletOperationResponses.RelabelSource(carton.getId(), 0L,
|
||||
pallet.palletId(), pallet.palletCode(), null, carton.getSourceBatchNo(), quantity, quantity,
|
||||
entry.getValue().stream().map(RelabelItem::getCartonMark).filter(Objects::nonNull).toList()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
Map<Long, List<RelabelItem>> byBalance = new LinkedHashMap<>();
|
||||
for (RelabelItem item : items) {
|
||||
InventoryPosition anchor = positions.selectById(item.getSourceInventoryPositionId());
|
||||
@@ -99,7 +128,7 @@ public class PdaPalletOperationService {
|
||||
long ownHold = group.stream().filter(item -> Objects.equals(item.getSourceInventoryPositionId(), row.getPositionId()))
|
||||
.mapToLong(item -> value(item.getNotifiedPackageCount())).sum();
|
||||
result.add(new PalletOperationResponses.RelabelSource(row.getPositionId(), value(row.getVersion()),
|
||||
row.getPalletNo(), row.getWarehouseLocationCode(), row.getInboundCode(),
|
||||
row.getPalletId(), row.getPalletNo(), row.getWarehouseLocationCode(), row.getInboundCode(),
|
||||
value(row.getAvailablePackages()) + value(row.getHoldPackages()) + value(row.getAllocatedPackages())
|
||||
+ value(row.getPickedPackages()),
|
||||
value(row.getAvailablePackages()) + Math.min(ownHold, value(row.getHoldPackages())),
|
||||
@@ -118,15 +147,22 @@ public class PdaPalletOperationService {
|
||||
throw conflict("换标单不属于 Token 冻结的海外仓");
|
||||
}
|
||||
String key = required(request.idempotencyKey(), "幂等键不能为空");
|
||||
String destination = relabelItems.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<RelabelItem>()
|
||||
.eq("application_id", application.getId()).eq("customer_id", application.getCustomerId()))
|
||||
.stream().map(RelabelItem::getDestinationFbaWarehouseCode).filter(value -> value != null && !value.isBlank())
|
||||
.distinct().reduce((left, right) -> { throw conflict("同一换标单只能绑定一个 FBA 目的仓"); }).orElse(null);
|
||||
PalletPhysicalGateway.PalletReference pallet = physical.ensureRelabelPallet(identity.overseasWarehouseId(),
|
||||
relabelNo + ":" + key);
|
||||
relabelNo + ":" + key, destination);
|
||||
String code = HtmlUtils.htmlEscape(pallet.palletCode());
|
||||
String order = HtmlUtils.htmlEscape(relabelNo);
|
||||
String fbaValue = destination == null ? pallet.fbaWarehouseCode() : destination;
|
||||
String fba = HtmlUtils.htmlEscape(fbaValue == null ? "" : fbaValue);
|
||||
if (fba == null || fba.isBlank()) fba = "待绑定";
|
||||
String html = "<!doctype html><html><head><meta charset=\"UTF-8\"><title>" + code
|
||||
+ "</title><style>@page{size:100mm 70mm;margin:4mm}body{font-family:Arial,sans-serif;text-align:center;margin:0}"
|
||||
+ "h1{font-size:24px;margin:8mm 0 4mm;letter-spacing:0}.code{font-size:18px;font-weight:700;word-break:break-all}"
|
||||
+ ".ref{font-size:12px;margin-top:6mm}</style></head><body><h1>托盘标签</h1><div class=\"code\">"
|
||||
+ code + "</div><div class=\"ref\">换标单:" + order + "</div></body></html>";
|
||||
+ code + "</div><div class=\"ref\">换标单:" + order + "<br>FBA目的仓:" + fba + "</div></body></html>";
|
||||
return new PalletOperationResponses.PrintablePallet(pallet.palletId(), pallet.palletCode(), pallet.version(),
|
||||
relabelNo, html);
|
||||
}
|
||||
@@ -205,8 +241,10 @@ public class PdaPalletOperationService {
|
||||
Map<Long, Long> packagesByPosition = new LinkedHashMap<>();
|
||||
Map<Long, List<RelabelItem>> itemsByPosition = new LinkedHashMap<>();
|
||||
Map<Long, InventoryPosition> anchors = new LinkedHashMap<>();
|
||||
InventoryPosition actual = request.actualSource() == null ? null : positions.selectById(request.actualSource().positionId());
|
||||
if (request.actualSource() != null && (actual == null
|
||||
boolean returnRelabel = selected.stream().allMatch(item -> item.getReturnCartonId() != null);
|
||||
InventoryPosition actual = returnRelabel || request.actualSource() == null
|
||||
? null : positions.selectById(request.actualSource().positionId());
|
||||
if (!returnRelabel && request.actualSource() != null && (actual == null
|
||||
|| !Objects.equals(actual.getCustomerId(), application.getCustomerId())
|
||||
|| !Objects.equals(actual.getPalletId(), source.palletId())
|
||||
|| !Objects.equals(actual.getOverseasWarehouseId(), application.getOverseasWarehouseId())
|
||||
@@ -219,13 +257,17 @@ public class PdaPalletOperationService {
|
||||
if ("PROCESSING".equals(application.getStatus())) {
|
||||
var workTask = relabelWorkTasks.find(application.getCustomerId(), application.getId());
|
||||
for (RelabelItem item : selected) {
|
||||
Long completed = relabelWorkFacts.selectCount(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact>()
|
||||
.eq("task_id", workTask.getId()).eq("customer_id", application.getCustomerId())
|
||||
.eq("item_id", item.getId()).eq("result", "RELABELLED"));
|
||||
if (completed == null || completed < value(item.getNotifiedPackageCount()))
|
||||
long completed = relabelWorkFacts.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact>()
|
||||
.eq("task_id", workTask.getId()).eq("customer_id", application.getCustomerId())
|
||||
.eq("item_id", item.getId()).eq("result", "RELABELLED"))
|
||||
.stream().mapToLong(fact -> fact.getQuantity() == null ? 1L : fact.getQuantity()).sum();
|
||||
if (completed < value(item.getNotifiedPackageCount()))
|
||||
throw conflict("请先完成本箱全部换标作业,再进行组托");
|
||||
}
|
||||
}
|
||||
if (returnRelabel) {
|
||||
return bindReturnRelabelPallet(identity, application, selected, source, target, request);
|
||||
}
|
||||
for (RelabelItem item : selected) {
|
||||
if (item.getSourceInventoryPositionId() == null || item.getNotifiedPackageCount() == null
|
||||
|| item.getNotifiedPackageCount() <= 0) throw conflict("换标箱唛缺少库存位置或箱数快照");
|
||||
@@ -265,6 +307,107 @@ public class PdaPalletOperationService {
|
||||
reason, relabelPlan);
|
||||
}
|
||||
|
||||
private PalletOperationResponses.Operation bindReturnRelabelPallet(PdaWarehouseIdentity identity,
|
||||
RelabelApplication application, List<RelabelItem> selected,
|
||||
PalletPhysicalGateway.PalletReference source, PalletPhysicalGateway.PalletReference target,
|
||||
PalletOperationRequests.RelabelDisassemble request) {
|
||||
requireRelabelDestination(identity.overseasWarehouseId(), selected, target);
|
||||
Set<String> alreadyBound = boundRelabelMarks(application);
|
||||
if (selected.stream().map(RelabelItem::getCartonMark).anyMatch(alreadyBound::contains))
|
||||
throw conflict("退货换标箱已在本轮完成组托,请刷新后重试");
|
||||
long quantity = 0;
|
||||
List<ReturnInventoryCarton> cartons = new ArrayList<>();
|
||||
for (RelabelItem item : selected) {
|
||||
ReturnInventoryCarton carton = returnCartons.lockId(item.getReturnCartonId());
|
||||
if (carton == null || !Objects.equals(carton.getCustomerId(), application.getCustomerId())
|
||||
|| !Objects.equals(carton.getPalletCode(), source.palletCode())
|
||||
|| !Objects.equals(carton.getRelabelApplicationId(), application.getId())
|
||||
|| !Objects.equals(carton.getStatus(), "RELABEL_PENDING"))
|
||||
throw conflict("退货换标来源托盘或库存状态已变化,请刷新后重试");
|
||||
quantity += value(item.getNotifiedPackageCount());
|
||||
cartons.add(carton);
|
||||
}
|
||||
if (request.actualSource() == null || request.actualSource().positionId() != cartons.get(0).getId()
|
||||
|| request.actualSource().packages() != quantity)
|
||||
throw conflict("退货换标拆出数量必须等于本批已完成贴标数量");
|
||||
String key = required(request.idempotencyKey(), "幂等键不能为空");
|
||||
String requestHash = hash(request);
|
||||
PdaPalletOperation operation = begin(identity, "RELABEL_PALLET_DISASSEMBLE", key, requestHash);
|
||||
if ("COMPLETED".equals(operation.getStatus())) return read(operation).asReplay();
|
||||
physical.apply(identity.overseasWarehouseId(), target, "OCCUPIED", target.locationId(), key, "退货换标组托");
|
||||
return transaction.execute(status -> {
|
||||
for (int index = 0; index < cartons.size(); index++) {
|
||||
RelabelItem item = selected.get(index);
|
||||
ReturnInventoryCarton carton = returnCartons.lockId(item.getReturnCartonId());
|
||||
if (carton == null || !Objects.equals(carton.getCustomerId(), application.getCustomerId())
|
||||
|| !Objects.equals(carton.getPalletCode(), source.palletCode())
|
||||
|| !Objects.equals(carton.getRelabelApplicationId(), application.getId())
|
||||
|| !"RELABEL_PENDING".equals(carton.getStatus()))
|
||||
throw conflict("退货换标来源库存已变化,请刷新后重试");
|
||||
long move = value(item.getNotifiedPackageCount());
|
||||
if (move <= 0 || move > value(carton.getTotalQuantity())) throw conflict("来源托盘可拆数量不足");
|
||||
if (move < value(carton.getTotalQuantity())) {
|
||||
splitReturnRelabelCarton(application, item, carton, target, move);
|
||||
} else {
|
||||
carton.setPalletCode(target.palletCode());
|
||||
carton.setDestinationWarehouseCode(item.getDestinationFbaWarehouseCode());
|
||||
carton.setRelabelStatus("PROCESSING");
|
||||
carton.setUpdatedAt(LocalDateTime.now());
|
||||
returnCartons.updateById(carton);
|
||||
}
|
||||
}
|
||||
recordReturnRelabelBinding(identity, application, selected, target.palletCode());
|
||||
List<PalletOperationResponses.PalletContent> contents = List.of(
|
||||
content(identity.overseasWarehouseId(), physical.resolve(source.palletCode())),
|
||||
content(identity.overseasWarehouseId(), physical.resolve(target.palletCode())));
|
||||
PalletOperationResponses.Operation result = new PalletOperationResponses.Operation(operation.getId(),
|
||||
operation.getOperationType(), key, "COMPLETED", false, LocalDateTime.now(), contents);
|
||||
operation.setStatus("COMPLETED"); operation.setResultJson(write(result));
|
||||
operation.setUpdatedAt(result.completedAt()); mapper.updateById(operation);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
/** Keep the unlabelled remainder on its original pallet; move only the confirmed SKU pieces. */
|
||||
private void splitReturnRelabelCarton(RelabelApplication application, RelabelItem item,
|
||||
ReturnInventoryCarton source, PalletPhysicalGateway.PalletReference target, long quantity) {
|
||||
if (relabelItems.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<RelabelItem>()
|
||||
.eq("application_id", application.getId()).eq("return_carton_id", source.getId())).size() != 1)
|
||||
throw conflict("来源托盘包含多项换标任务,不能按单 SKU 拆分,请核对库存");
|
||||
List<ReturnInventoryCartonItem> lines = returnCartonItems.lockCarton(source.getId());
|
||||
if (lines.size() != 1 || !item.getSkuCode().equalsIgnoreCase(lines.get(0).getSkuCode())
|
||||
|| value(lines.get(0).getQuantity()) != value(source.getTotalQuantity()))
|
||||
throw conflict("来源托盘包含其他 SKU,不能按单 SKU 拆分,请核对库存");
|
||||
long remaining = value(source.getTotalQuantity()) - quantity;
|
||||
ReturnInventoryCartonItem sourceLine = lines.get(0);
|
||||
sourceLine.setQuantity(remaining);
|
||||
if (returnCartonItems.updateById(sourceLine) != 1
|
||||
|| returnCartons.releasePartial(source.getId(), application.getCustomerId(), application.getId(), remaining) != 1)
|
||||
throw conflict("来源托盘数量已变化,请刷新后重试");
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
long targetId = IdWorker.getId();
|
||||
ReturnInventoryCarton moved = new ReturnInventoryCarton();
|
||||
moved.setId(targetId); moved.setCustomerId(source.getCustomerId());
|
||||
moved.setOverseasWarehouseId(source.getOverseasWarehouseId());
|
||||
moved.setSourceBatchNo(source.getSourceBatchNo()); moved.setSourceReturnNo(source.getSourceReturnNo());
|
||||
moved.setPalletCode(target.palletCode()); moved.setCartonMark("RL-CARTON-" + targetId);
|
||||
moved.setDestinationWarehouseCode(item.getDestinationFbaWarehouseCode());
|
||||
moved.setRelabelApplicationId(application.getId()); moved.setRelabelNo(application.getRelabelNo());
|
||||
moved.setRelabelStatus("PROCESSING"); moved.setStatus("RELABEL_PENDING");
|
||||
moved.setSkuTypes(1); moved.setTotalQuantity(quantity);
|
||||
moved.setCreatedAt(now); moved.setUpdatedAt(now);
|
||||
if (returnCartons.insert(moved) != 1) throw conflict("新托盘库存创建失败");
|
||||
ReturnInventoryCartonItem targetLine = new ReturnInventoryCartonItem();
|
||||
targetLine.setId(IdWorker.getId()); targetLine.setCartonId(targetId);
|
||||
targetLine.setCustomerId(source.getCustomerId()); targetLine.setSkuCode(sourceLine.getSkuCode());
|
||||
targetLine.setProductNameZh(sourceLine.getProductNameZh()); targetLine.setQuantity(quantity);
|
||||
if (returnCartonItems.insert(targetLine) != 1
|
||||
|| returnBalances.adjustCartonQuantity(source.getCustomerId(), source.getOverseasWarehouseId(),
|
||||
sourceLine.getSkuCode(), 1) != 1) throw conflict("退货库存拆分记账失败");
|
||||
item.setReturnCartonId(targetId);
|
||||
if (relabelItems.updateById(item) != 1) throw conflict("换标单来源托盘更新失败");
|
||||
}
|
||||
|
||||
public PalletOperationResponses.Operation swap(PdaWarehouseIdentity identity,
|
||||
PalletOperationRequests.Swap request) {
|
||||
PalletOperationResponses.Operation replay = replayIfPresent(identity, "PALLET_SWAP",
|
||||
@@ -610,6 +753,22 @@ public class PdaPalletOperationService {
|
||||
relabelWorkHistory.insert(record);
|
||||
}
|
||||
|
||||
private void recordReturnRelabelBinding(PdaWarehouseIdentity identity, RelabelApplication app,
|
||||
List<RelabelItem> items, String targetPallet) {
|
||||
var task = relabelWorkTasks.find(app.getCustomerId(), app.getId());
|
||||
if (task == null) throw conflict("退货换标作业任务不存在,不能记录组托");
|
||||
var record = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory();
|
||||
record.setId(IdWorker.getId()); record.setTaskId(task.getId()); record.setApplicationId(app.getId());
|
||||
record.setCustomerId(app.getCustomerId()); record.setAction("PALLET_BIND");
|
||||
record.setFromStatus(task.getStatus()); record.setToStatus(task.getStatus());
|
||||
record.setOperatorKey(identity.operatorKey()); record.setOperatorName(identity.operatorName());
|
||||
record.setCreatedAt(LocalDateTime.now());
|
||||
record.setDetailJson(write(Map.of("round", Objects.toString(task.getReviewedAt(), "INITIAL"),
|
||||
"cartonMarks", items.stream().map(RelabelItem::getCartonMark).toList(),
|
||||
"targetPallets", List.of(targetPallet))));
|
||||
relabelWorkHistory.insert(record);
|
||||
}
|
||||
|
||||
private void requireRelabelWork(PdaWarehouseIdentity identity, RelabelApplication application) {
|
||||
if (application == null || !Set.of("PROCESSING", "READY_FOR_OUTBOUND").contains(application.getStatus()))
|
||||
throw conflict("当前换标状态不能组托");
|
||||
|
||||
+16
@@ -78,6 +78,22 @@ public class PdaRelabelController {
|
||||
identity.overseasWarehouseId(), relabelNo, request));
|
||||
}
|
||||
|
||||
@PostMapping("/{relabelNo}/return-skus/complete")
|
||||
public ApiResponse<PdaRelabelResponses.Detail> completeReturnSku(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.BulkSku request) {
|
||||
Identity identity = identity(user);
|
||||
return ApiResponse.success(service.completeReturnSku(identity.operatorKey(), identity.operatorName(),
|
||||
identity.overseasWarehouseId(), relabelNo, request));
|
||||
}
|
||||
|
||||
@PostMapping("/{relabelNo}/return-skus/complete-total")
|
||||
public ApiResponse<PdaRelabelResponses.Detail> completeReturnSkuTotal(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.BulkSkuTotal request) {
|
||||
Identity identity = identity(user);
|
||||
return ApiResponse.success(service.completeReturnSkuTotal(identity.operatorKey(), identity.operatorName(),
|
||||
identity.overseasWarehouseId(), relabelNo, request));
|
||||
}
|
||||
|
||||
@GetMapping("/{relabelNo}/documents")
|
||||
public ApiResponse<List<PdaRelabelService.Document>> documents(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String relabelNo) {
|
||||
|
||||
+10
@@ -37,6 +37,16 @@ public final class PdaRelabelRequests {
|
||||
public record SkuVerification(@NotBlank String skuCode, @NotBlank String newSkuCode,
|
||||
@Positive int quantity) { }
|
||||
|
||||
public record BulkSku(@NotNull @Positive Long itemId,
|
||||
@Positive int quantity,
|
||||
@NotNull @Min(0) Integer expectedVersion,
|
||||
@NotBlank @Size(max = 255) String idempotencyKey) { }
|
||||
|
||||
public record BulkSkuTotal(@NotBlank @Size(max = 255) String skuCode,
|
||||
@Positive int quantity,
|
||||
@NotNull @Min(0) Integer expectedVersion,
|
||||
@NotBlank @Size(max = 255) String idempotencyKey) { }
|
||||
|
||||
public record Skip(@NotNull @Positive Long itemId,
|
||||
@NotBlank @Size(max = 255) String oldLabel,
|
||||
@NotBlank @Size(max = 1000) String reason,
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ public final class PdaRelabelResponses {
|
||||
|
||||
public record UnitFact(Long factId, Long itemId, String oldLabel, String newLabel,
|
||||
String result, String exceptionCode, String reason,
|
||||
String operatorName, LocalDateTime occurredAt) { }
|
||||
int quantity, String operatorName, LocalDateTime occurredAt) { }
|
||||
|
||||
public record History(String action, String fromStatus, String toStatus,
|
||||
String operatorName, String detail, LocalDateTime createdAt) { }
|
||||
|
||||
+1
@@ -20,6 +20,7 @@ public class PdaRelabelUnitFact {
|
||||
private String result;
|
||||
private String exceptionCode;
|
||||
private String reason;
|
||||
private Integer quantity;
|
||||
private String operatorKey;
|
||||
private String operatorName;
|
||||
private LocalDateTime occurredAt;
|
||||
|
||||
+12
@@ -90,6 +90,17 @@ public class HttpRelabelPhotoFileGateway implements RelabelPhotoFileGateway {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.List<UploadedFile> list(String customerId, long taskId) {
|
||||
MetadataListEnvelope response = client.get()
|
||||
.uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}", tenantId(customerId), BUSINESS_TYPE, taskId)
|
||||
.header("X-Internal-Service-Token", internalToken).retrieve()
|
||||
.onStatus(HttpStatusCode::isError, (request, result) -> { throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Relabel photo list failed"); })
|
||||
.body(MetadataListEnvelope.class);
|
||||
if (response == null || !"200".equals(response.code()) || response.data() == null) return java.util.List.of();
|
||||
return response.data().stream().map(value -> new UploadedFile(value.fileId(), value.fileName(), value.contentType(), value.sizeBytes() == null ? 0L : value.sizeBytes())).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public DownloadedFile download(String customerId, long taskId, long fileId) {
|
||||
validate(customerId, taskId, fileId);
|
||||
@@ -130,6 +141,7 @@ public class HttpRelabelPhotoFileGateway implements RelabelPhotoFileGateway {
|
||||
}
|
||||
|
||||
private record MetadataEnvelope(String code, String message, Metadata data) { }
|
||||
private record MetadataListEnvelope(String code, String message, java.util.List<Metadata> data) { }
|
||||
private record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
|
||||
String fileName, String contentType, Long sizeBytes) { }
|
||||
}
|
||||
|
||||
+1
@@ -6,6 +6,7 @@ public interface RelabelPhotoFileGateway {
|
||||
UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file);
|
||||
void validate(String customerId, long taskId, long fileId);
|
||||
DownloadedFile download(String customerId, long taskId, long fileId);
|
||||
java.util.List<UploadedFile> list(String customerId, long taskId);
|
||||
|
||||
record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) { }
|
||||
record DownloadedFile(byte[] content, String fileName, String contentType) { }
|
||||
|
||||
+104
-8
@@ -39,6 +39,7 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
@@ -238,6 +239,88 @@ public class PdaRelabelService {
|
||||
return result;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PdaRelabelResponses.Detail completeReturnSku(String operatorKey, String operatorName, long warehouseId,
|
||||
String relabelNo, PdaRelabelRequests.BulkSku request) {
|
||||
UnitCommand command = new UnitCommand(relabelNo, "RETURN_BULK_SKU", request);
|
||||
PdaRelabelResponses.Detail replay = replay(operatorKey, warehouseId, relabelNo, "RETURN_BULK_SKU",
|
||||
request.idempotencyKey(), command);
|
||||
if (replay != null) return replay;
|
||||
RelabelApplication application = requireApplication(relabelNo, warehouseId, true);
|
||||
PdaRelabelTask task = requireOwnedTask(application, operatorKey, true);
|
||||
requireVersion(task, request.expectedVersion());
|
||||
RelabelItem item = requireItem(application, request.itemId());
|
||||
if (!"RETURN_BULK_SKU_LABEL".equals(operationType(item))) throw conflict("当前明细不是退货按件贴标任务");
|
||||
int completed = quantity(factList(task).stream().filter(fact -> item.getId().equals(fact.getItemId())).toList(), "RELABELLED");
|
||||
int remaining = value(item.getNotifiedPackageCount()) - completed;
|
||||
if (request.quantity() > remaining) throw conflict("本次完成数量超过该 SKU 剩余贴标数量");
|
||||
PdaRelabelUnitFact fact = new PdaRelabelUnitFact();
|
||||
fact.setId(IdWorker.getId()); fact.setTaskId(task.getId()); fact.setApplicationId(application.getId());
|
||||
// A bulk SKU is shared by many units/pallets; old_label is uniquely indexed for physical carton scans.
|
||||
fact.setItemId(item.getId()); fact.setCustomerId(application.getCustomerId());
|
||||
fact.setResult("RELABELLED"); fact.setQuantity(request.quantity()); fact.setOperatorKey(operatorKey);
|
||||
fact.setOperatorName(operatorName); fact.setOccurredAt(LocalDateTime.now()); unitFacts.insert(fact);
|
||||
task.setRelabelledPackages(value(task.getRelabelledPackages()) + request.quantity());
|
||||
updateTask(task, request.expectedVersion());
|
||||
insertHistory(task, "RETURN_BULK_SKU", PROCESSING, PROCESSING, operatorKey, operatorName,
|
||||
Map.of("factId", fact.getId(), "itemId", item.getId(), "skuCode", item.getSkuCode(), "quantity", request.quantity()));
|
||||
PdaRelabelResponses.Detail result = detail(application, task);
|
||||
saveOperation(task, operatorKey, "RETURN_BULK_SKU", request.idempotencyKey(), command, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PdaRelabelResponses.Detail completeReturnSkuTotal(String operatorKey, String operatorName, long warehouseId,
|
||||
String relabelNo, PdaRelabelRequests.BulkSkuTotal request) {
|
||||
UnitCommand command = new UnitCommand(relabelNo, "RETURN_BULK_SKU_TOTAL", request);
|
||||
PdaRelabelResponses.Detail replay = replay(operatorKey, warehouseId, relabelNo, "RETURN_BULK_SKU_TOTAL",
|
||||
request.idempotencyKey(), command);
|
||||
if (replay != null) return replay;
|
||||
RelabelApplication application = requireApplication(relabelNo, warehouseId, true);
|
||||
PdaRelabelTask task = requireOwnedTask(application, operatorKey, true);
|
||||
requireVersion(task, request.expectedVersion());
|
||||
String skuCode = request.skuCode().trim();
|
||||
List<RelabelItem> skuItems = itemList(application).stream()
|
||||
.filter(item -> "RETURN_BULK_SKU_LABEL".equals(operationType(item)))
|
||||
.filter(item -> skuCode.equalsIgnoreCase(Objects.toString(item.getSkuCode(), "").trim()))
|
||||
.toList();
|
||||
if (skuItems.isEmpty()) throw notFound("当前换标单没有该退货 SKU");
|
||||
List<PdaRelabelUnitFact> existingFacts = factList(task);
|
||||
Map<Long, Integer> completedByItem = new HashMap<>();
|
||||
for (RelabelItem item : skuItems) {
|
||||
completedByItem.put(item.getId(), quantity(existingFacts.stream()
|
||||
.filter(fact -> item.getId().equals(fact.getItemId())).toList(), "RELABELLED"));
|
||||
}
|
||||
int totalRemaining = skuItems.stream().mapToInt(item -> value(item.getNotifiedPackageCount())
|
||||
- completedByItem.getOrDefault(item.getId(), 0)).sum();
|
||||
if (request.quantity() > totalRemaining) throw conflict("本次完成数量超过该 SKU 剩余贴标数量");
|
||||
int remainingToAllocate = request.quantity();
|
||||
List<Map<String, Object>> allocations = new ArrayList<>();
|
||||
LocalDateTime occurredAt = LocalDateTime.now();
|
||||
for (RelabelItem item : skuItems) {
|
||||
if (remainingToAllocate == 0) break;
|
||||
int itemRemaining = value(item.getNotifiedPackageCount()) - completedByItem.getOrDefault(item.getId(), 0);
|
||||
int allocated = Math.min(remainingToAllocate, itemRemaining);
|
||||
if (allocated <= 0) continue;
|
||||
PdaRelabelUnitFact fact = new PdaRelabelUnitFact();
|
||||
fact.setId(IdWorker.getId()); fact.setTaskId(task.getId()); fact.setApplicationId(application.getId());
|
||||
// Do not write a shared SKU into the unique physical old-label column.
|
||||
fact.setItemId(item.getId()); fact.setCustomerId(application.getCustomerId());
|
||||
fact.setResult("RELABELLED"); fact.setQuantity(allocated); fact.setOperatorKey(operatorKey);
|
||||
fact.setOperatorName(operatorName); fact.setOccurredAt(occurredAt); unitFacts.insert(fact);
|
||||
allocations.add(Map.of("itemId", item.getId(), "cartonMark", Objects.toString(item.getCartonMark(), ""),
|
||||
"quantity", allocated, "factId", fact.getId()));
|
||||
remainingToAllocate -= allocated;
|
||||
}
|
||||
task.setRelabelledPackages(value(task.getRelabelledPackages()) + request.quantity());
|
||||
updateTask(task, request.expectedVersion());
|
||||
insertHistory(task, "RETURN_BULK_SKU_TOTAL", PROCESSING, PROCESSING, operatorKey, operatorName,
|
||||
Map.of("skuCode", skuCode, "quantity", request.quantity(), "allocations", allocations));
|
||||
PdaRelabelResponses.Detail result = detail(application, task);
|
||||
saveOperation(task, operatorKey, "RETURN_BULK_SKU_TOTAL", request.idempotencyKey(), command, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<Document> documents(long warehouseId, String relabelNo) {
|
||||
RelabelApplication app = requireApplication(relabelNo, warehouseId, false);
|
||||
@@ -311,26 +394,28 @@ public class PdaRelabelService {
|
||||
if (!request.manualCompletion()) bindingGuard.requireBound(application);
|
||||
List<RelabelItem> detailItems = itemList(application);
|
||||
List<PdaRelabelUnitFact> facts = factList(task);
|
||||
boolean incomplete = detailItems.stream().anyMatch(item -> facts.stream()
|
||||
.filter(fact -> item.getId().equals(fact.getItemId())).count() < value(item.getNotifiedPackageCount()));
|
||||
boolean incomplete = detailItems.stream().anyMatch(item -> processedQuantity(facts, item.getId()) < value(item.getNotifiedPackageCount()));
|
||||
if ((incomplete || request.manualCompletion()) && !StringUtils.hasText(request.remark()))
|
||||
throw conflict("手动完成必须填写仓库异常作业备注");
|
||||
if (incomplete && !request.manualCompletion()) throw conflict("仍有未处理箱,请选择手动完成并填写作业备注");
|
||||
if (request.photoFileIds() == null || request.photoFileIds().isEmpty() || request.photoFileIds().size() > 20) throw conflict("完成换标必须上传 1 至 20 张作业照片");
|
||||
if (request.manualCompletion() && request.remark().trim().length() > 900) throw conflict("手动作业备注不能超过 900 字");
|
||||
for (RelabelItem item : detailItems) {
|
||||
long actual = facts.stream().filter(fact -> item.getId().equals(fact.getItemId())).count();
|
||||
long actual = processedQuantity(facts, item.getId());
|
||||
if (actual > value(item.getNotifiedPackageCount())) {
|
||||
throw conflict("Every relabel item must have exactly one fact per notified package");
|
||||
}
|
||||
for (long i = actual; i < value(item.getNotifiedPackageCount()); i++) {
|
||||
long missing = value(item.getNotifiedPackageCount()) - actual;
|
||||
int factCount = "RETURN_BULK_SKU_LABEL".equals(operationType(item)) && missing > 0 ? 1 : Math.toIntExact(missing);
|
||||
for (int i = 0; i < factCount; i++) {
|
||||
PdaRelabelUnitFact fact = new PdaRelabelUnitFact();
|
||||
fact.setId(IdWorker.getId()); fact.setTaskId(task.getId()); fact.setApplicationId(application.getId());
|
||||
fact.setItemId(item.getId()); fact.setCustomerId(application.getCustomerId());
|
||||
fact.setResult("EXCEPTION"); fact.setExceptionCode("MANUAL_COMPLETE");
|
||||
fact.setQuantity("RETURN_BULK_SKU_LABEL".equals(operationType(item)) ? Math.toIntExact(missing) : 1);
|
||||
fact.setReason(request.remark()); fact.setOperatorKey(operatorKey); fact.setOperatorName(operatorName);
|
||||
fact.setOccurredAt(LocalDateTime.now()); unitFacts.insert(fact);
|
||||
task.setExceptionPackages(value(task.getExceptionPackages()) + 1);
|
||||
task.setExceptionPackages(value(task.getExceptionPackages()) + fact.getQuantity());
|
||||
}
|
||||
}
|
||||
List<Long> photoIds = request.photoFileIds().stream().distinct().toList();
|
||||
@@ -475,6 +560,7 @@ public class PdaRelabelService {
|
||||
resolvedNewLabel = trim(item.getFbaCartonMark());
|
||||
}
|
||||
fact.setOldLabel(normalizedOldLabel); fact.setNewLabel(resolvedNewLabel); fact.setResult(resultCode);
|
||||
fact.setQuantity(1);
|
||||
fact.setExceptionCode(exceptionCode); fact.setReason(trim(reason));
|
||||
fact.setOperatorKey(operatorKey); fact.setOperatorName(operatorName); fact.setOccurredAt(LocalDateTime.now());
|
||||
try { unitFacts.insert(fact); }
|
||||
@@ -622,15 +708,15 @@ public class PdaRelabelService {
|
||||
item.getShipmentNo(), item.getTrackingCode(), item.getNewSkuCode(), item.getLabelRequirement(),
|
||||
item.getRelabelFileId(), item.getRelabelFileName(), item.getRelabelFileUrl(),
|
||||
item.getCartonMark(), item.getFbaCartonMark(), operationType(item), readSkuItems(item.getSkuRelabelItemsJson()),
|
||||
value(item.getNotifiedPackageCount()), count(itemFacts, "RELABELLED"), count(itemFacts, "SKIPPED"),
|
||||
count(itemFacts, "EXCEPTION"),
|
||||
value(item.getNotifiedPackageCount()), quantity(itemFacts, "RELABELLED"), quantity(itemFacts, "SKIPPED"),
|
||||
quantity(itemFacts, "EXCEPTION"),
|
||||
count(itemFacts, "RELABELLED") + (itemFacts.isEmpty() && mixedStageDone(task, item.getId(), "CARTON") ? 1 : 0),
|
||||
count(itemFacts, "RELABELLED") + (itemFacts.isEmpty() && mixedStageDone(task, item.getId(), "SKU") ? 1 : 0),
|
||||
item.getDestinationFbaWarehouseCode());
|
||||
}).toList();
|
||||
List<PdaRelabelResponses.UnitFact> factResponses = facts.stream().map(fact ->
|
||||
new PdaRelabelResponses.UnitFact(fact.getId(), fact.getItemId(), fact.getOldLabel(), fact.getNewLabel(),
|
||||
fact.getResult(), fact.getExceptionCode(), fact.getReason(), fact.getOperatorName(), fact.getOccurredAt())).toList();
|
||||
fact.getResult(), fact.getExceptionCode(), fact.getReason(), factQuantity(fact), fact.getOperatorName(), fact.getOccurredAt())).toList();
|
||||
List<PdaRelabelResponses.History> historyResponses = task == null ? List.of() : histories.selectList(
|
||||
new LambdaQueryWrapper<PdaRelabelHistory>().eq(PdaRelabelHistory::getCustomerId, application.getCustomerId())
|
||||
.eq(PdaRelabelHistory::getTaskId, task.getId())
|
||||
@@ -727,6 +813,9 @@ public class PdaRelabelService {
|
||||
|
||||
private String operationType(RelabelApplication application) {
|
||||
List<RelabelItem> items = itemList(application);
|
||||
if (!items.isEmpty() && items.stream().allMatch(item -> "RETURN_BULK_SKU_LABEL".equals(operationType(item)))) {
|
||||
return "RETURN_BULK_SKU_LABEL";
|
||||
}
|
||||
boolean sku = items.stream().anyMatch(item -> Set.of("SKU_LABEL", "CARTON_AND_SKU").contains(operationType(item)));
|
||||
boolean carton = items.stream().anyMatch(item -> !"SKU_LABEL".equals(operationType(item)));
|
||||
return sku && carton ? "CARTON_AND_SKU" : sku ? "SKU_LABEL" : "CARTON_LABEL";
|
||||
@@ -748,6 +837,13 @@ public class PdaRelabelService {
|
||||
private static int count(List<PdaRelabelUnitFact> facts, String result) {
|
||||
return (int) facts.stream().filter(fact -> result.equals(fact.getResult())).count();
|
||||
}
|
||||
private static int quantity(List<PdaRelabelUnitFact> facts, String result) {
|
||||
return facts.stream().filter(fact -> result.equals(fact.getResult())).mapToInt(PdaRelabelService::factQuantity).sum();
|
||||
}
|
||||
private static int processedQuantity(List<PdaRelabelUnitFact> facts, Long itemId) {
|
||||
return facts.stream().filter(fact -> itemId.equals(fact.getItemId())).mapToInt(PdaRelabelService::factQuantity).sum();
|
||||
}
|
||||
private static int factQuantity(PdaRelabelUnitFact fact) { return fact.getQuantity() == null ? 1 : fact.getQuantity(); }
|
||||
private static int value(Integer value) { return value == null ? 0 : value; }
|
||||
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
|
||||
private static String idempotencyKey(String value) { return value == null ? null : value.trim(); }
|
||||
|
||||
+72
-13
@@ -31,8 +31,10 @@ import com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway;
|
||||
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper;
|
||||
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
|
||||
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem;
|
||||
import com.baiyue.lastleg.returnreceipt.entity.ReturnOrder;
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper;
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper;
|
||||
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
|
||||
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
|
||||
import com.baiyue.lastleg.delivery.entity.OutboundPdaTask;
|
||||
@@ -70,10 +72,11 @@ import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class RelabelApplicationService implements WalletResultHandler {
|
||||
public static final String PENDING_REVIEW = "PENDING_REVIEW";
|
||||
public static final String PENDING_PROCESSING = "PENDING_PROCESSING";
|
||||
public static final String CUSTOMER_CONFIRM_PENDING = "CUSTOMER_CONFIRM_PENDING";
|
||||
public static final String READY_FOR_OUTBOUND = "READY_FOR_OUTBOUND";
|
||||
private static final Set<String> STATUSES = Set.of(PENDING_PROCESSING, "PROCESSING", CUSTOMER_CONFIRM_PENDING,
|
||||
private static final Set<String> STATUSES = Set.of(PENDING_REVIEW, PENDING_PROCESSING, "PROCESSING", CUSTOMER_CONFIRM_PENDING,
|
||||
READY_FOR_OUTBOUND, "COMPLETED", "REJECTED", "CANCELLED");
|
||||
private static final Set<String> DELIVERY_METHODS = Set.of("TRUCK", "EXPRESS", "TRUCK_PICKUP", "EXPRESS_PICKUP", "PICKUP");
|
||||
|
||||
@@ -90,6 +93,7 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
@Resource private InventoryPositionMapper inventoryPositions;
|
||||
@Resource private ReturnInventoryCartonMapper returnCartons;
|
||||
@Resource private ReturnInventoryCartonItemMapper returnCartonItems;
|
||||
@Resource private ReturnOrderMapper returnOrders;
|
||||
@Resource private RelabelDocumentFileGateway documentFiles;
|
||||
@Resource private PdaRelabelTaskMapper pdaTasks;
|
||||
@Resource private RelabelPhotoFileGateway photoFiles;
|
||||
@@ -203,7 +207,7 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
if (!StringUtils.hasText(customerId)) throw badRequest("客户不能为空");
|
||||
RelabelApplication application = applications.lockNo(customerId, relabelNo);
|
||||
if (application == null) throw notFound("Relabel application not found");
|
||||
if (!Set.of(PENDING_PROCESSING, "PROCESSING").contains(application.getStatus())) throw conflict("当前换标状态不允许修改资料");
|
||||
if (!Set.of(PENDING_REVIEW, PENDING_PROCESSING, "PROCESSING").contains(application.getStatus())) throw conflict("当前换标状态不允许修改资料");
|
||||
return application;
|
||||
}
|
||||
|
||||
@@ -270,9 +274,13 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
if (cartonSkus.isEmpty()) continue;
|
||||
String skuCodes = cartonSkus.stream().map(RelabelResponses.SkuItem::skuCode)
|
||||
.collect(Collectors.joining(","));
|
||||
long availablePackages = value(carton.getTotalQuantity());
|
||||
if (availablePackages <= 0) {
|
||||
availablePackages = cartonSkus.stream().mapToLong(RelabelResponses.SkuItem::quantity).sum();
|
||||
}
|
||||
result.add(new RelabelResponses.InventoryCandidate(null, null, carton.getSourceReturnNo(),
|
||||
null, carton.getOverseasWarehouseId(), null, null, carton.getDestinationWarehouseCode(),
|
||||
carton.getDestinationWarehouseName(), skuCodes, "退货合箱库存", 1, 0, List.of(),
|
||||
carton.getDestinationWarehouseName(), skuCodes, "退货入库库存", availablePackages, 0, List.of(),
|
||||
"RETURN_CARTON", carton.getId(), carton.getCartonMark(), cartonSkus));
|
||||
}
|
||||
return result;
|
||||
@@ -347,6 +355,8 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
int notified = 0;
|
||||
List<SourceInventory> sources = new java.util.ArrayList<>();
|
||||
Set<String> selectedCartonMarks = new HashSet<>();
|
||||
Map<Long, ReturnInventoryCarton> selectedReturnCartons = new LinkedHashMap<>();
|
||||
Map<Long, Long> selectedReturnPackages = new LinkedHashMap<>();
|
||||
for (RelabelRequests.Item item : request.items()) {
|
||||
validateOperation(item);
|
||||
if (item.notifiedPackageCount() > item.totalPackageCount()) {
|
||||
@@ -358,7 +368,11 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
throw badRequest("The same carton mark cannot create more than one relabel task");
|
||||
}
|
||||
if (item.returnCartonId() != null) {
|
||||
ReturnInventoryCarton carton = returnCartons.lockId(item.returnCartonId());
|
||||
ReturnInventoryCarton carton = selectedReturnCartons.get(item.returnCartonId());
|
||||
if (carton == null) {
|
||||
carton = returnCartons.lockId(item.returnCartonId());
|
||||
if (carton != null) selectedReturnCartons.put(item.returnCartonId(), carton);
|
||||
}
|
||||
if (carton == null || !customerId.equals(carton.getCustomerId())
|
||||
|| !request.operationWarehouseId().trim().equals(carton.getOverseasWarehouseId())) {
|
||||
throw conflict("所选退货箱不属于当前客户和操作仓");
|
||||
@@ -366,8 +380,10 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
if (!"AVAILABLE".equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) {
|
||||
throw conflict("仓库唛头 " + carton.getCartonMark() + " 已不可用于换标");
|
||||
}
|
||||
if (!carton.getCartonMark().equalsIgnoreCase(item.cartonMark().trim())) {
|
||||
throw conflict("退货箱仓库唛头已变化,请重新查询");
|
||||
long requestedPackages = selectedReturnPackages.merge(carton.getId(),
|
||||
(long) item.notifiedPackageCount(), Math::addExact);
|
||||
if (requestedPackages > value(carton.getTotalQuantity())) {
|
||||
throw conflict("退货托盘 " + carton.getCartonMark() + " 可用箱数不足");
|
||||
}
|
||||
sources.add(new SourceInventory(item, null, carton));
|
||||
continue;
|
||||
@@ -438,9 +454,7 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
application.setUpdatedAt(now);
|
||||
applications.insert(application);
|
||||
|
||||
for (SourceInventory source : sources) {
|
||||
if (source.returnCarton() == null) continue;
|
||||
ReturnInventoryCarton carton = source.returnCarton();
|
||||
for (ReturnInventoryCarton carton : selectedReturnCartons.values()) {
|
||||
carton.setRelabelApplicationId(applicationId); carton.setRelabelNo(relabelNo);
|
||||
carton.setRelabelStatus("PENDING"); carton.setStatus("RELABEL_PENDING"); carton.setUpdatedAt(now);
|
||||
returnCartons.updateById(carton);
|
||||
@@ -530,6 +544,12 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
if (!STATUSES.contains(next)) throw badRequest("Unsupported relabel status");
|
||||
if (next.equals(application.getStatus())) return detail(customerId, relabelNo);
|
||||
validateTransition(application.getStatus(), next);
|
||||
if (PENDING_REVIEW.equals(application.getStatus()) && PENDING_PROCESSING.equals(next)
|
||||
&& itemList(application.getId()).stream().anyMatch(item -> "RETURN_BULK_SKU_LABEL".equals(operationType(item)))) {
|
||||
boolean hasCartonFile = StringUtils.hasText(application.getCartonTotalFileId());
|
||||
boolean hasSkuFile = StringUtils.hasText(application.getSkuTotalFileId());
|
||||
if (hasCartonFile == hasSkuFile) throw conflict("退货贴标审核前必须且只能上传一个箱唛总文件或 SKU 总文件");
|
||||
}
|
||||
if ("PROCESSING".equals(next)
|
||||
&& !Set.of("FROZEN", "NOT_REQUIRED").contains(application.getWalletStatus())) {
|
||||
throw conflict("Relabel funds must be frozen before processing");
|
||||
@@ -541,6 +561,10 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
applications.updateById(application);
|
||||
List<LastlegOrder> linkedOrders = tailOrders(application);
|
||||
if (linkedOrders.isEmpty()) throw conflict("Linked tail-leg order is missing");
|
||||
if (PENDING_PROCESSING.equals(next)) {
|
||||
application.setFulfillmentStatus("WAIT_RELABEL");
|
||||
applications.updateById(application);
|
||||
}
|
||||
if (CUSTOMER_CONFIRM_PENDING.equals(next)) {
|
||||
application.setFulfillmentStatus("WAIT_CUSTOMER_CONFIRMATION");
|
||||
applications.updateById(application);
|
||||
@@ -586,6 +610,12 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
case "REJECTED", "CANCELLED" -> "CANCELLED";
|
||||
default -> mainStatus;
|
||||
};
|
||||
// Reviewing/processing a return relabel must not move the already
|
||||
// completed inbound order back to assignment or relabel waiting.
|
||||
if ("RETURN_RELABEL".equals(order.getOrderType())
|
||||
&& !Set.of("COMPLETED", "REJECTED", "CANCELLED").contains(next)) {
|
||||
orderStatus = "INBOUND_COMPLETED";
|
||||
}
|
||||
order.setCurrentStatus(orderStatus);
|
||||
order.setSubOrderStatus("RELABEL:" + next);
|
||||
order.setRelabelStatus(businessRelabelStatus(next));
|
||||
@@ -595,6 +625,15 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
insertOrderHistory(customerId, order.getId(), orderBefore, orderStatus, request.remark(), now);
|
||||
}
|
||||
}
|
||||
ReturnOrder returnOrder = returnOrders.selectOne(new LambdaQueryWrapper<ReturnOrder>()
|
||||
.eq(ReturnOrder::getRelabelApplicationId, application.getId())
|
||||
.eq(ReturnOrder::getCustomerId, customerId)
|
||||
.last("LIMIT 1"));
|
||||
if (returnOrder != null) {
|
||||
returnOrder.setRelabelStatus(next);
|
||||
returnOrder.setUpdatedAt(now);
|
||||
returnOrders.updateById(returnOrder);
|
||||
}
|
||||
insertHistory(customerId, application.getId(), before, next, operatorId, request.remark(), now);
|
||||
return detail(customerId, relabelNo);
|
||||
}
|
||||
@@ -689,7 +728,7 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
}
|
||||
List<RelabelItem> relabelItems = itemList(application.getId());
|
||||
boolean requiresOutbound = relabelItems.stream()
|
||||
.anyMatch(item -> !"SKU_LABEL".equals(operationType(item)));
|
||||
.anyMatch(item -> Set.of("CARTON_LABEL", "CARTON_AND_SKU").contains(operationType(item)));
|
||||
reviewTask.setStatus("COMPLETED"); reviewTask.setReviewDecision("CONFIRMED");
|
||||
reviewTask.setReviewerKey(operatorId); reviewTask.setReviewerName("客户确认");
|
||||
reviewTask.setReviewRemark(request.remark()); reviewTask.setReviewedAt(LocalDateTime.now());
|
||||
@@ -721,6 +760,22 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
request.remark(), now);
|
||||
}
|
||||
}
|
||||
ReturnOrder returnOrder = returnOrders.selectOne(new LambdaQueryWrapper<ReturnOrder>()
|
||||
.eq(ReturnOrder::getRelabelApplicationId, application.getId())
|
||||
.eq(ReturnOrder::getCustomerId, customerId)
|
||||
.last("LIMIT 1"));
|
||||
if (returnOrder != null) {
|
||||
returnOrder.setRelabelStatus("COMPLETED");
|
||||
returnOrder.setUpdatedAt(now);
|
||||
returnOrders.updateById(returnOrder);
|
||||
}
|
||||
for (RelabelItem item : relabelItems) {
|
||||
if (item.getReturnCartonId() == null) continue;
|
||||
ReturnInventoryCarton carton = returnCartons.lockId(item.getReturnCartonId());
|
||||
if (carton == null || !Objects.equals(carton.getRelabelApplicationId(), application.getId())) continue;
|
||||
carton.setRelabelStatus("COMPLETED"); carton.setRelabelCompletedAt(now);
|
||||
carton.setStatus("AVAILABLE"); carton.setUpdatedAt(now); returnCartons.updateById(carton);
|
||||
}
|
||||
insertHistory(customerId, application.getId(), before, application.getStatus(), operatorId,
|
||||
request.remark(), now);
|
||||
// 仓储转运换标在客户确认后立即恢复为可排车,发车时再转为 OUTBOUNDED。
|
||||
@@ -739,7 +794,8 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
transfer.setRelabelCompletedAt(now);
|
||||
transfer.setUpdatedAt(now);
|
||||
warehouseTransfers.updateById(transfer);
|
||||
application.setFulfillmentStatus(transferStatus);
|
||||
application.setFulfillmentStatus("READY_FOR_DISPATCH".equals(transferStatus)
|
||||
? "WAIT_PRE_DISPATCH" : transferStatus);
|
||||
applications.updateById(application);
|
||||
// 转运单自身的尾程订单与换标申请尾程订单是两条记录,需同步释放原转运单参与排车。
|
||||
LastlegOrder transferOrder = orders.selectById(transfer.getLastlegOrderId());
|
||||
@@ -1057,7 +1113,8 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
orderHistories.insert(value);
|
||||
}
|
||||
private static void validateTransition(String from, String to) {
|
||||
boolean allowed = PENDING_PROCESSING.equals(from) && Set.of("PROCESSING", "REJECTED", "CANCELLED").contains(to)
|
||||
boolean allowed = PENDING_REVIEW.equals(from) && Set.of(PENDING_PROCESSING, "REJECTED", "CANCELLED").contains(to)
|
||||
|| PENDING_PROCESSING.equals(from) && Set.of("PROCESSING", "REJECTED", "CANCELLED").contains(to)
|
||||
|| "PROCESSING".equals(from) && Set.of(CUSTOMER_CONFIRM_PENDING, "REJECTED", "CANCELLED").contains(to)
|
||||
|| CUSTOMER_CONFIRM_PENDING.equals(from) && Set.of(READY_FOR_OUTBOUND, "REJECTED", "CANCELLED").contains(to)
|
||||
|| READY_FOR_OUTBOUND.equals(from) && Set.of("COMPLETED", "CANCELLED").contains(to);
|
||||
@@ -1065,7 +1122,9 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
}
|
||||
|
||||
private static String businessRelabelStatus(String workflowStatus) {
|
||||
if ("PROCESSING".equals(workflowStatus)) return "PROCESSING";
|
||||
if (Set.of(PENDING_REVIEW, PENDING_PROCESSING, "PROCESSING", "REJECTED", "CANCELLED").contains(workflowStatus)) {
|
||||
return workflowStatus;
|
||||
}
|
||||
return Set.of(CUSTOMER_CONFIRM_PENDING, READY_FOR_OUTBOUND, "COMPLETED").contains(workflowStatus)
|
||||
? "COMPLETED" : PENDING_PROCESSING;
|
||||
}
|
||||
|
||||
+11
-1
@@ -7,6 +7,7 @@ import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
|
||||
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory;
|
||||
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper;
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -20,12 +21,14 @@ public class RelabelBindingGuard {
|
||||
@Resource private RelabelItemMapper items;
|
||||
@Resource private PdaRelabelHistoryMapper histories;
|
||||
@Resource private InventoryPositionMapper positions;
|
||||
@Resource private ReturnInventoryCartonMapper returnCartons;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
|
||||
public void requireBound(RelabelApplication app) {
|
||||
List<RelabelItem> cartons = items.selectList(new QueryWrapper<RelabelItem>()
|
||||
.eq("application_id", app.getId()).eq("customer_id", app.getCustomerId()))
|
||||
.stream().filter(item -> !"SKU_LABEL".equals(item.getOperationType())).toList();
|
||||
.stream().filter(item -> Set.of("CARTON_LABEL", "CARTON_AND_SKU", "RETURN_BULK_SKU_LABEL")
|
||||
.contains(item.getOperationType())).toList();
|
||||
if (cartons.isEmpty()) return;
|
||||
Map<String, Set<String>> bindings = new HashMap<>();
|
||||
for (PdaRelabelHistory history : histories.selectList(new QueryWrapper<PdaRelabelHistory>()
|
||||
@@ -41,6 +44,13 @@ public class RelabelBindingGuard {
|
||||
} catch (Exception e) { throw conflict("组托记录无法读取,请核对后重试"); }
|
||||
}
|
||||
for (RelabelItem item : cartons) {
|
||||
if ("RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) {
|
||||
var carton = item.getReturnCartonId() == null ? null : returnCartons.selectById(item.getReturnCartonId());
|
||||
if (carton == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(carton.getPalletCode())
|
||||
|| !"PROCESSING".equals(carton.getRelabelStatus()))
|
||||
throw conflict("退货箱 " + item.getCartonMark() + " 尚未绑定带目的仓的新托盘,不能完成换标");
|
||||
continue;
|
||||
}
|
||||
var position = item.getSourceInventoryPositionId() == null ? null
|
||||
: positions.findTenantPosition(app.getCustomerId(), item.getSourceInventoryPositionId());
|
||||
if (position == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(position.getPalletNo())
|
||||
|
||||
+68
@@ -6,17 +6,28 @@ import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
|
||||
import com.baiyue.lastleg.returnreceipt.dto.PdaReturnReceiptRequests;
|
||||
import com.baiyue.lastleg.returnreceipt.dto.PdaReturnReceiptResponses;
|
||||
import com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService;
|
||||
import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway;
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/pda/v1/last-leg/return-receipts")
|
||||
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
|
||||
public class PdaReturnReceiptController {
|
||||
@Resource private ReturnReceiptService service;
|
||||
@Resource private PalletPhysicalGateway physicalPallets;
|
||||
@Resource private ReturnInventoryCartonMapper inventoryCartons;
|
||||
@Resource private ReturnInventoryCartonItemMapper inventoryCartonItems;
|
||||
@Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper returnOrders;
|
||||
@Resource private RelabelPhotoFileGateway photoFiles;
|
||||
@PostMapping("/start") public ApiResponse<PdaReturnReceiptResponses.Start> start(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody PdaReturnReceiptRequests.Start request) {
|
||||
return ApiResponse.success(service.start(PdaWarehouseIdentity.require(user), request));
|
||||
@@ -30,4 +41,61 @@ public class PdaReturnReceiptController {
|
||||
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
|
||||
return ApiResponse.success(service.confirm(identity, request));
|
||||
}
|
||||
@PostMapping("/{returnNo}/manual-complete") public ApiResponse<com.baiyue.lastleg.returnreceipt.dto.ReturnOrderResponses.Detail> manualComplete(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String returnNo, @Valid @RequestBody PdaReturnReceiptRequests.ManualComplete request) {
|
||||
return ApiResponse.success(service.manualComplete(PdaWarehouseIdentity.require(user), returnNo, request));
|
||||
}
|
||||
public record Pallet(String palletCode, long palletId, String status, String printableHtml) { }
|
||||
public record Photo(long fileId, String fileName, String contentType, long sizeBytes) { }
|
||||
@PostMapping(value = "/{returnNo}/photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public ApiResponse<Photo> uploadPhoto(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String returnNo,
|
||||
@RequestPart("file") MultipartFile file) {
|
||||
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
|
||||
var order = returnOrders.findActiveNo(String.valueOf(identity.overseasWarehouseId()), returnNo);
|
||||
if (order == null) throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND, "当前仓没有该待签收退货单");
|
||||
if (file == null || file.isEmpty()) throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST, "请选择退货打托照片");
|
||||
if (file.getSize() > 10L * 1024 * 1024) throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST, "照片不能超过 10MB");
|
||||
if (file.getContentType() == null || !file.getContentType().toLowerCase(java.util.Locale.ROOT).startsWith("image/")) throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST, "只能上传图片");
|
||||
var uploaded = photoFiles.upload(order.getCustomerId(), order.getId(), user.userId(), file);
|
||||
return ApiResponse.success(new Photo(uploaded.fileId(), uploaded.fileName(), uploaded.contentType(), uploaded.sizeBytes()));
|
||||
}
|
||||
@GetMapping("/{returnNo}/photos/files/{fileId}/download")
|
||||
public org.springframework.http.ResponseEntity<byte[]> downloadPhoto(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String returnNo, @PathVariable long fileId) {
|
||||
var file = service.downloadPhoto(PdaWarehouseIdentity.require(user), returnNo, fileId);
|
||||
return org.springframework.http.ResponseEntity.ok().contentType(org.springframework.http.MediaType.parseMediaType(file.contentType()))
|
||||
.header("Cache-Control", "no-store").body(file.content());
|
||||
}
|
||||
@PostMapping("/pallets") public ApiResponse<Pallet> createPallet(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody PdaReturnReceiptRequests.Pallet request) {
|
||||
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
|
||||
if (returnOrders.findActiveNo(String.valueOf(identity.overseasWarehouseId()), request.returnNo()) == null) {
|
||||
throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND, "当前仓没有该待签收退货单");
|
||||
}
|
||||
var pallet = physicalPallets.ensureRelabelPallet(identity.overseasWarehouseId(),
|
||||
"RETURN:" + request.returnNo() + ":" + request.skuCode() + ":" + System.nanoTime());
|
||||
String code = org.springframework.web.util.HtmlUtils.htmlEscape(pallet.palletCode());
|
||||
String returnNo = org.springframework.web.util.HtmlUtils.htmlEscape(request.returnNo());
|
||||
String sku = org.springframework.web.util.HtmlUtils.htmlEscape(request.skuCode());
|
||||
String html = "<!doctype html><html><head><meta charset=\"UTF-8\"><title>" + code + "</title><style>@page{size:100mm 70mm;margin:4mm}body{font-family:Arial,sans-serif;text-align:center;margin:0}h1{font-size:24px;margin:7mm 0 3mm}.code{font-size:17px;font-weight:700;word-break:break-all}.ref{font-size:11px;margin-top:4mm}</style></head><body><h1>退货托盘标签</h1><div class=\"code\">" + code + "</div><div class=\"ref\">退货单:" + returnNo + "<br>SKU:" + sku + "</div></body></html>";
|
||||
return ApiResponse.success(new Pallet(pallet.palletCode(), pallet.palletId(), pallet.status(), html));
|
||||
}
|
||||
@PostMapping("/pallets/check") public ApiResponse<PdaReturnReceiptResponses.PalletCheck> checkPallet(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody PdaReturnReceiptRequests.CheckPallet request) {
|
||||
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
|
||||
var order = returnOrders.findActiveNo(String.valueOf(identity.overseasWarehouseId()), request.returnNo().trim());
|
||||
if (order == null) throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND, "当前仓没有该待签收退货单");
|
||||
var physical = physicalPallets.resolve(request.palletCode().trim());
|
||||
if (physical == null || physical.warehouseId() != identity.overseasWarehouseId()) {
|
||||
throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND, "当前仓没有该托盘");
|
||||
}
|
||||
var inventory = inventoryCartons.listPalletInventory(String.valueOf(identity.overseasWarehouseId()), physical.palletCode());
|
||||
if (inventory.isEmpty()) return ApiResponse.success(new PdaReturnReceiptResponses.PalletCheck(physical.palletCode(), true, java.util.List.of(), 0));
|
||||
if (inventory.stream().anyMatch(carton -> !order.getReturnNo().equals(carton.getSourceReturnNo()))) {
|
||||
throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "该托盘已绑定其他退货单,不能用于当前退货单");
|
||||
}
|
||||
java.util.LinkedHashSet<String> skuCodes = new java.util.LinkedHashSet<>(); long quantity = 0;
|
||||
for (var carton : inventory) for (var item : inventoryCartonItems.listCarton(carton.getId())) { skuCodes.add(item.getSkuCode()); quantity += item.getQuantity() == null ? 0 : item.getQuantity(); }
|
||||
return ApiResponse.success(new PdaReturnReceiptResponses.PalletCheck(physical.palletCode(), false, java.util.List.copyOf(skuCodes), quantity));
|
||||
}
|
||||
}
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.lastleg.returnreceipt.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.lastleg.returnreceipt.dto.ReturnOrderRequests;
|
||||
import com.baiyue.lastleg.returnreceipt.dto.ReturnOrderResponses;
|
||||
import com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/last-leg/return-orders")
|
||||
@PreAuthorize("hasAuthority('platform:overseas-warehouse:return-inventory:list')")
|
||||
public class PlatformReturnOrderController {
|
||||
@Resource private ReturnReceiptService service;
|
||||
@PostMapping("/{returnNo}/review")
|
||||
public ApiResponse<ReturnOrderResponses.Detail> review(@RequestHeader("X-Customer-Id") String customerId,
|
||||
@PathVariable String returnNo, @Valid @RequestBody ReturnOrderRequests.Review request) {
|
||||
return ApiResponse.success(service.review(customerId, returnNo, request));
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -12,10 +12,13 @@ public final class PdaReturnReceiptRequests {
|
||||
private PdaReturnReceiptRequests() { }
|
||||
public record Start(@NotBlank @Size(max = 128) String returnNo) { }
|
||||
public record SkuLookup(@NotBlank @Size(max = 128) String skuCode) { }
|
||||
public record Pallet(@NotBlank @Size(max = 128) String returnNo, @NotBlank @Size(max = 128) String skuCode) { }
|
||||
public record CheckPallet(@NotBlank @Size(max = 128) String returnNo, @NotBlank @Size(max = 128) String palletCode) { }
|
||||
public record ManualComplete(@NotBlank @Size(max = 500) String discrepancyReason) { }
|
||||
public record Confirm(@NotBlank @Size(max = 128) String returnNo,
|
||||
@Size(max = 128) String trackingNo,
|
||||
@NotBlank @Size(max = 16) String startedBy,
|
||||
@Size(max = 128) String cartonMark,
|
||||
@NotBlank @Size(max = 128) String palletCode,
|
||||
@NotEmpty @Size(max = 500) List<@Valid Item> items,
|
||||
@NotBlank @Size(max = 255) String idempotencyKey) { }
|
||||
public record Item(@NotBlank @Size(max = 128) String skuCode,
|
||||
|
||||
+5
-2
@@ -5,13 +5,16 @@ import java.util.List;
|
||||
|
||||
public final class PdaReturnReceiptResponses {
|
||||
private PdaReturnReceiptResponses() { }
|
||||
public record Start(String returnNo, String status, List<Item> expectedItems) { }
|
||||
public record Start(String returnNo, String status, List<Item> expectedItems, List<CompletedPallet> completedPallets, List<Photo> photos) { }
|
||||
public record Photo(long fileId, String fileName, String contentType, long sizeBytes) { }
|
||||
public record CompletedPallet(String palletCode, String skuCode, long quantity, String receiptBatchNo) { }
|
||||
public record PalletCheck(String palletCode, boolean empty, List<String> skuCodes, long quantity) { }
|
||||
public record SkuCandidates(String skuCode, List<Candidate> candidates) { }
|
||||
public record Candidate(String returnNo, String customerId, String customerReferenceNo, String status,
|
||||
long skuExpectedQuantity, long skuReceivedQuantity, long orderExpectedQuantity,
|
||||
long orderReceivedQuantity) { }
|
||||
public record Item(String skuCode, String productNameZh, long expectedQuantity, long receivedQuantity) { }
|
||||
public record Confirmed(String returnNo, String receiptNo, String receiptBatchNo, String cartonMark, String orderStatus,
|
||||
public record Confirmed(String returnNo, String receiptNo, String receiptBatchNo, String palletCode, String orderStatus,
|
||||
boolean fullyMatched, long expectedQuantity, long receivedQuantity,
|
||||
LocalDateTime signedAt) { }
|
||||
}
|
||||
|
||||
+8
-3
@@ -28,8 +28,13 @@ public final class ReturnOrderRequests {
|
||||
String trackingNo, String skuCode, String status) {
|
||||
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
|
||||
}
|
||||
public record RelabelCreate(@NotEmpty @Size(max = 500) List<@NotBlank @Size(max = 255) String> cartonMarks,
|
||||
public record RelabelCreate(@NotEmpty @Size(max = 500) List<@Valid RelabelItem> items,
|
||||
@NotBlank @Size(max = 16) String fileScope,
|
||||
@NotBlank @Size(max = 128) String destinationFbaWarehouseId,
|
||||
@NotBlank @Size(max = 128) String destinationFbaWarehouseCode,
|
||||
@NotBlank @Size(max = 255) String idempotencyKey,
|
||||
@Size(max = 128) String contactName, @Size(max = 64) String contactPhone,
|
||||
@Size(max = 32) String deliveryMethod, @Size(max = 1000) String remark) {}
|
||||
@Size(max = 1000) String cartonRemark) {}
|
||||
public record RelabelItem(@NotBlank @Size(max = 128) String skuCode,
|
||||
@Positive long quantity) {}
|
||||
public record Review(@NotBlank String decision, @Size(max = 500) String remark) { }
|
||||
}
|
||||
|
||||
+7
-4
@@ -8,14 +8,17 @@ public final class ReturnOrderResponses {
|
||||
public record Summary(String returnNo, String lastlegOrderNo, String overseasWarehouseId,
|
||||
String warehouseContactName, String warehouseContactPhone, String customerReferenceNo, String status,
|
||||
int skuTypes, long expectedQuantity, long receivedQuantity, boolean needRelabel,
|
||||
String relabelNo, String relabelStatus, LocalDateTime createdAt) { }
|
||||
String relabelNo, String relabelStatus, String discrepancyReason, LocalDateTime createdAt) { }
|
||||
public record Item(String skuCode, String productNameZh, long expectedQuantity,
|
||||
long receivedQuantity) { }
|
||||
public record Detail(Summary order, String remark, List<Item> items, List<Receipt> receipts) { }
|
||||
public record Receipt(String receiptNo, String receiptBatchNo, String cartonMark, String trackingNo, String startedBy,
|
||||
public record Detail(Summary order, String remark, List<Item> items, List<Receipt> receipts, List<PalletDetail> pallets, List<Photo> photos) { }
|
||||
public record Photo(long fileId, String fileName, String contentType, long sizeBytes) { }
|
||||
public record PalletDetail(String palletCode, int skuTypes, long totalQuantity, List<PalletItem> items) { }
|
||||
public record PalletItem(String skuCode, String productNameZh, long quantity) { }
|
||||
public record Receipt(String receiptNo, String receiptBatchNo, String palletCode, String trackingNo, String startedBy,
|
||||
long receivedQuantity, String operatorName, LocalDateTime signedAt) { }
|
||||
public record RelabelCandidate(Long cartonId, String cartonMark, String sourceReturnNo, String status,
|
||||
long totalQuantity, List<Item> items, String relabelStatus) { }
|
||||
public record RelabelCreated(String relabelNo, Long relabelApplicationId, String returnNo,
|
||||
List<String> cartonMarks, String status) { }
|
||||
List<String> skuCodes, String status) { }
|
||||
}
|
||||
|
||||
+1
@@ -18,6 +18,7 @@ public class ReturnInventoryCarton {
|
||||
private Long sourceReceiptId;
|
||||
private String sourceBatchNo;
|
||||
private String sourceReturnNo;
|
||||
private String palletCode;
|
||||
private String cartonMark;
|
||||
private String fbaCartonMark;
|
||||
private String destinationWarehouseCode;
|
||||
|
||||
@@ -28,6 +28,8 @@ public class ReturnOrder {
|
||||
private Long expectedQuantity;
|
||||
private Long receivedQuantity;
|
||||
private String remark;
|
||||
private String discrepancyReason;
|
||||
private LocalDateTime manuallyCompletedAt;
|
||||
private String idempotencyKey;
|
||||
private String requestHash;
|
||||
private String createdBy;
|
||||
|
||||
+1
@@ -15,6 +15,7 @@ public class ReturnReceipt {
|
||||
private String customerId;
|
||||
private String receiptNo;
|
||||
private String receiptBatchNo;
|
||||
private String palletCode;
|
||||
private String cartonMark;
|
||||
private String trackingNo;
|
||||
private String boundBy;
|
||||
|
||||
+17
@@ -5,6 +5,7 @@ import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
@@ -35,9 +36,25 @@ public interface ReturnInventoryCartonMapper extends BaseMapper<ReturnInventoryC
|
||||
@Select("SELECT * FROM ll_return_inventory_carton WHERE id=#{id} FOR UPDATE")
|
||||
ReturnInventoryCarton lockId(@Param("id") long id);
|
||||
|
||||
@Update("UPDATE ll_return_inventory_carton SET total_quantity=#{remaining},sku_types=1,status='AVAILABLE'," +
|
||||
"relabel_application_id=NULL,relabel_no=NULL,relabel_status=NULL,updated_at=NOW(3) " +
|
||||
"WHERE id=#{id} AND customer_id=#{customerId} AND relabel_application_id=#{applicationId} " +
|
||||
"AND status='RELABEL_PENDING' AND total_quantity>#{remaining}")
|
||||
int releasePartial(@Param("id") long id, @Param("customerId") String customerId,
|
||||
@Param("applicationId") long applicationId, @Param("remaining") long remaining);
|
||||
|
||||
@Select("SELECT * FROM ll_return_inventory_carton WHERE source_receipt_id=#{receiptId} LIMIT 1")
|
||||
ReturnInventoryCarton findReceipt(@Param("receiptId") long receiptId);
|
||||
|
||||
@Select("SELECT c.* FROM ll_return_inventory_carton c JOIN ll_return_inventory_carton_item i ON i.carton_id=c.id WHERE c.customer_id=#{customerId} AND c.overseas_warehouse_id=#{warehouseId} AND c.pallet_code=#{palletCode} AND c.status='AVAILABLE' ORDER BY c.id LIMIT 1 FOR UPDATE")
|
||||
ReturnInventoryCarton findPalletSku(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId, @Param("palletCode") String palletCode);
|
||||
|
||||
@Select("SELECT * FROM ll_return_inventory_carton WHERE overseas_warehouse_id=#{warehouseId} AND pallet_code=#{palletCode} AND status='AVAILABLE' ORDER BY id LIMIT 1")
|
||||
ReturnInventoryCarton findPalletSkuForCheck(@Param("customerId") String ignoredCustomerId, @Param("warehouseId") String warehouseId, @Param("palletCode") String palletCode);
|
||||
|
||||
@Select("SELECT * FROM ll_return_inventory_carton WHERE overseas_warehouse_id=#{warehouseId} AND pallet_code=#{palletCode} AND status='AVAILABLE' ORDER BY id")
|
||||
List<ReturnInventoryCarton> listPalletInventory(@Param("warehouseId") String warehouseId, @Param("palletCode") String palletCode);
|
||||
|
||||
@Select("SELECT * FROM ll_return_inventory_carton WHERE customer_id=#{customerId} AND carton_mark=#{cartonMark} LIMIT 1")
|
||||
ReturnInventoryCarton findCustomerMark(@Param("customerId") String customerId, @Param("cartonMark") String cartonMark);
|
||||
|
||||
|
||||
+7
-1
@@ -3,6 +3,12 @@ package com.baiyue.lastleg.returnreceipt.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.returnreceipt.entity.ReturnReceiptItem;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface ReturnReceiptItemMapper extends BaseMapper<ReturnReceiptItem> { }
|
||||
public interface ReturnReceiptItemMapper extends BaseMapper<ReturnReceiptItem> {
|
||||
@Select("SELECT * FROM ll_return_receipt_item WHERE receipt_id=#{receiptId} ORDER BY id")
|
||||
List<ReturnReceiptItem> listReceipt(@Param("receiptId") long receiptId);
|
||||
}
|
||||
|
||||
+4
@@ -63,6 +63,9 @@ public class ReturnInventoryService {
|
||||
public ReturnInventoryCarton createReceiptCarton(ReturnOrder order, ReturnReceipt receipt,
|
||||
String requestedMark, List<ReceiptLine> lines) {
|
||||
if (lines.isEmpty()) throw new IllegalArgumentException("退货签收库存明细不能为空");
|
||||
if (lines.size() != 1) throw conflict("一个签收批次只能签收一个 SKU");
|
||||
String normalizedPalletCode = required(receipt.getPalletCode(), "托盘码不能为空");
|
||||
required(lines.get(0).skuCode(), "SKU 不能为空");
|
||||
String cartonMark = text(requestedMark);
|
||||
long cartonId = IdWorker.getId();
|
||||
if (cartonMark == null) cartonMark = numbers.next("RETURN_CARTON", order.getOverseasWarehouseId(), cartonId);
|
||||
@@ -73,6 +76,7 @@ public class ReturnInventoryService {
|
||||
carton.setId(cartonId); carton.setCustomerId(order.getCustomerId()); carton.setOverseasWarehouseId(order.getOverseasWarehouseId());
|
||||
carton.setSourceReceiptId(receipt.getId()); carton.setSourceBatchNo(receipt.getReceiptBatchNo());
|
||||
carton.setSourceReturnNo(order.getReturnNo()); carton.setCartonMark(cartonMark);
|
||||
carton.setPalletCode(normalizedPalletCode);
|
||||
carton.setStatus(AVAILABLE); carton.setSkuTypes(lines.size()); carton.setTotalQuantity(total); carton.setCreatedAt(now); carton.setUpdatedAt(now);
|
||||
cartons.insert(carton);
|
||||
for (ReceiptLine line : lines) {
|
||||
|
||||
+216
-35
@@ -25,6 +25,7 @@ import com.baiyue.lastleg.relabel.entity.RelabelApplication;
|
||||
import com.baiyue.lastleg.relabel.entity.RelabelItem;
|
||||
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
|
||||
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
|
||||
import com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway;
|
||||
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
|
||||
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem;
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
|
||||
@@ -55,6 +56,7 @@ import java.util.Set;
|
||||
@Service
|
||||
public class ReturnReceiptService {
|
||||
public static final String PENDING_RECEIPT = "PENDING_RECEIPT";
|
||||
public static final String PENDING_REVIEW = "PENDING_REVIEW";
|
||||
public static final String PARTIALLY_RECEIVED = "PARTIALLY_RECEIVED";
|
||||
public static final String COMPLETED = "COMPLETED";
|
||||
|
||||
@@ -71,6 +73,8 @@ public class ReturnReceiptService {
|
||||
@Resource private ReturnInventoryCartonItemMapper cartonItems;
|
||||
@Resource private RelabelApplicationMapper relabelApplications;
|
||||
@Resource private RelabelItemMapper relabelItems;
|
||||
@Resource private com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway photoFiles;
|
||||
@Resource private com.baiyue.lastleg.service.FbaWarehouseValidator fbaWarehouseValidator;
|
||||
|
||||
@Transactional
|
||||
public ReturnOrderResponses.Detail create(String customerId, String operatorId, ReturnOrderRequests.Create request) {
|
||||
@@ -92,7 +96,7 @@ public class ReturnReceiptService {
|
||||
order.setWarehouseContactName(text(request.warehouseContactName()));
|
||||
order.setWarehouseContactPhone(text(request.warehouseContactPhone()));
|
||||
order.setReturnNo(numbers.next("RETURN", order.getOverseasWarehouseId(), order.getId()));
|
||||
order.setCustomerReferenceNo(text(request.customerReferenceNo())); order.setNeedRelabel(request.needRelabel()); order.setStatus(PENDING_RECEIPT);
|
||||
order.setCustomerReferenceNo(text(request.customerReferenceNo())); order.setNeedRelabel(request.needRelabel()); order.setStatus(PENDING_REVIEW);
|
||||
order.setExpectedSkuTypes(skuCodes.size()); order.setExpectedQuantity(expected); order.setReceivedQuantity(0L);
|
||||
order.setRemark(text(request.remark())); order.setIdempotencyKey(key); order.setRequestHash(requestHash);
|
||||
order.setCreatedBy(text(operatorId)); order.setCreatedAt(now); order.setUpdatedAt(now);
|
||||
@@ -121,8 +125,8 @@ public class ReturnReceiptService {
|
||||
tail.setSourceRegion("CUSTOMER"); tail.setSourceInboundId(returnOrder.getReturnNo());
|
||||
tail.setSourceOrderId(String.valueOf(returnOrder.getId()));
|
||||
tail.setSourceOrderNo(returnOrder.getReturnNo()); tail.setOverseasWarehouseId(returnOrder.getOverseasWarehouseId());
|
||||
tail.setCurrentStatus("PENDING_ASSIGNMENT"); tail.setSubOrderStatus(Boolean.TRUE.equals(returnOrder.getNeedRelabel()) ? "RETURN_RELABEL:PENDING_BINDING" : "RETURN_RELABEL:NOT_REQUIRED");
|
||||
tail.setRelabelStatus(Boolean.TRUE.equals(returnOrder.getNeedRelabel()) ? "PENDING_BINDING" : "NOT_REQUIRED");
|
||||
tail.setCurrentStatus(PENDING_REVIEW); tail.setSubOrderStatus(Boolean.TRUE.equals(returnOrder.getNeedRelabel()) ? "RETURN_RELABEL:PENDING_REVIEW" : "RETURN_RELABEL:NOT_REQUIRED");
|
||||
tail.setRelabelStatus(Boolean.TRUE.equals(returnOrder.getNeedRelabel()) ? PENDING_REVIEW : "NOT_REQUIRED");
|
||||
tail.setCustomerRemark(returnOrder.getRemark()); tail.setSourceEventId("RETURN_ORDER:" + returnOrder.getReturnNo());
|
||||
tail.setSourceLoadingPlanId("RETURN_RELABEL"); tail.setCreatedAt(now); tail.setUpdatedAt(now);
|
||||
tail.setSourcePayloadHash(returnOrder.getRequestHash());
|
||||
@@ -132,6 +136,30 @@ public class ReturnReceiptService {
|
||||
orders.updateById(returnOrder);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ReturnOrderResponses.Detail updatePending(String customerId, String returnNo, ReturnOrderRequests.Create request) {
|
||||
ReturnOrder order = orders.findNo(customerId, text(returnNo));
|
||||
if (order == null) throw notFound("退货预报不存在");
|
||||
if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("只有待审核退货预报可以修改");
|
||||
Set<String> skus = new LinkedHashSet<>(); long expected = 0;
|
||||
for (ReturnOrderRequests.Item input : request.items()) { if (!skus.add(norm(input.skuCode()))) throw badRequest("同一退货单不能重复预报相同 SKU"); expected = Math.addExact(expected, input.expectedQuantity()); }
|
||||
order.setOverseasWarehouseId(text(request.overseasWarehouseId())); order.setWarehouseContactName(text(request.warehouseContactName()));
|
||||
order.setWarehouseContactPhone(text(request.warehouseContactPhone())); order.setCustomerReferenceNo(text(request.customerReferenceNo()));
|
||||
order.setNeedRelabel(request.needRelabel()); order.setRemark(text(request.remark())); order.setExpectedSkuTypes(skus.size()); order.setExpectedQuantity(expected); order.setUpdatedAt(LocalDateTime.now()); orders.updateById(order);
|
||||
orderItems.delete(new LambdaQueryWrapper<ReturnOrderItem>().eq(ReturnOrderItem::getReturnOrderId, order.getId()));
|
||||
for (ReturnOrderRequests.Item input : request.items()) { ReturnOrderItem item = new ReturnOrderItem(); item.setId(IdWorker.getId()); item.setReturnOrderId(order.getId()); item.setCustomerId(customerId); item.setSkuCode(text(input.skuCode())); item.setProductNameZh(text(input.productNameZh())); item.setExpectedQuantity(input.expectedQuantity()); item.setReceivedQuantity(0L); item.setCreatedAt(LocalDateTime.now()); item.setUpdatedAt(LocalDateTime.now()); orderItems.insert(item); }
|
||||
return detail(customerId, returnNo);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ReturnOrderResponses.Detail cancelPending(String customerId, String returnNo) {
|
||||
ReturnOrder order = orders.findNo(customerId, text(returnNo));
|
||||
if (order == null) throw notFound("退货预报不存在");
|
||||
if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("只有待审核退货预报可以取消");
|
||||
order.setStatus("CANCELLED"); order.setUpdatedAt(LocalDateTime.now()); orders.updateById(order);
|
||||
return detail(customerId, returnNo);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResult<ReturnOrderResponses.Summary> page(String customerId, ReturnOrderRequests.Query query) {
|
||||
LambdaQueryWrapper<ReturnOrder> w = new LambdaQueryWrapper<ReturnOrder>()
|
||||
@@ -166,11 +194,56 @@ public class ReturnReceiptService {
|
||||
public ReturnOrderResponses.Detail detail(String customerId, String returnNo) {
|
||||
ReturnOrder order = orders.findNo(customerId, text(returnNo));
|
||||
if (order == null) throw notFound("退货单不存在");
|
||||
List<ReturnOrderResponses.Item> items = orderItems.listOrder(order.getId()).stream().map(this::item).toList();
|
||||
List<ReturnOrderResponses.Receipt> receiptViews = receipts.listOrder(order.getId()).stream().map(receipt ->
|
||||
new ReturnOrderResponses.Receipt(receipt.getReceiptNo(), receipt.getReceiptBatchNo(), receipt.getCartonMark(), receipt.getTrackingNo(),
|
||||
List<ReturnOrderItem> orderLines = orderItems.listOrder(order.getId());
|
||||
List<ReturnOrderResponses.Item> items = orderLines.stream().map(this::item).toList();
|
||||
List<ReturnReceipt> orderReceipts = receipts.listOrder(order.getId());
|
||||
List<ReturnOrderResponses.Receipt> receiptViews = orderReceipts.stream().map(receipt ->
|
||||
new ReturnOrderResponses.Receipt(receipt.getReceiptNo(), receipt.getReceiptBatchNo(), receipt.getPalletCode(), receipt.getTrackingNo(),
|
||||
receipt.getBoundBy(), value(receipt.getReceivedQuantity()), receipt.getOperatorName(), receipt.getSignedAt())).toList();
|
||||
return new ReturnOrderResponses.Detail(summary(order), order.getRemark(), items, receiptViews);
|
||||
Map<String, Map<String, ReturnOrderResponses.PalletItem>> palletItems = new LinkedHashMap<>();
|
||||
Map<String, String> productNames = new LinkedHashMap<>();
|
||||
for (ReturnOrderItem line : orderLines) productNames.put(line.getSkuCode(), line.getProductNameZh());
|
||||
for (ReturnReceipt receipt : orderReceipts) {
|
||||
if (text(receipt.getPalletCode()) == null) continue;
|
||||
Map<String, ReturnOrderResponses.PalletItem> bySku = palletItems.computeIfAbsent(receipt.getPalletCode(), ignored -> new LinkedHashMap<>());
|
||||
for (ReturnReceiptItem line : receiptItems.listReceipt(receipt.getId())) bySku.merge(line.getSkuCode(),
|
||||
new ReturnOrderResponses.PalletItem(line.getSkuCode(), productNames.get(line.getSkuCode()), value(line.getReceivedQuantity())),
|
||||
(left, right) -> new ReturnOrderResponses.PalletItem(left.skuCode(), text(left.productNameZh()) == null ? right.productNameZh() : left.productNameZh(), left.quantity() + right.quantity()));
|
||||
}
|
||||
List<ReturnOrderResponses.PalletDetail> palletViews = palletItems.entrySet().stream().map(entry -> {
|
||||
List<ReturnOrderResponses.PalletItem> palletLines = List.copyOf(entry.getValue().values());
|
||||
return new ReturnOrderResponses.PalletDetail(entry.getKey(), palletLines.size(), palletLines.stream().mapToLong(ReturnOrderResponses.PalletItem::quantity).sum(), palletLines);
|
||||
}).toList();
|
||||
List<ReturnOrderResponses.Photo> photos = photoList(order).stream()
|
||||
.map(file -> new ReturnOrderResponses.Photo(file.fileId(), file.fileName(), file.contentType(), file.sizeBytes())).toList();
|
||||
return new ReturnOrderResponses.Detail(summary(order), order.getRemark(), items, receiptViews, palletViews, photos);
|
||||
}
|
||||
|
||||
public com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway.DownloadedFile downloadPhoto(String customerId, String returnNo, long fileId) {
|
||||
ReturnOrder order = orders.findNo(customerId, text(returnNo));
|
||||
if (order == null) throw notFound("退货单不存在");
|
||||
return photoFiles.download(order.getCustomerId(), order.getId(), fileId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ReturnOrderResponses.Detail review(String customerId, String returnNo, ReturnOrderRequests.Review request) {
|
||||
ReturnOrder order = orders.findNo(customerId, text(returnNo));
|
||||
if (order == null) throw notFound("退货预报不存在");
|
||||
if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("只有待审核退货预报可以审核");
|
||||
String decision = norm(request.decision());
|
||||
if (!Set.of("APPROVE", "REJECT").contains(decision)) throw badRequest("decision 必须为 APPROVE 或 REJECT");
|
||||
if ("REJECT".equals(decision) && text(request.remark()) == null) throw badRequest("审核驳回必须填写原因");
|
||||
order.setStatus("APPROVE".equals(decision) ? PENDING_RECEIPT : "REJECTED");
|
||||
if ("REJECT".equals(decision)) order.setRemark(text(request.remark()));
|
||||
order.setUpdatedAt(LocalDateTime.now()); orders.updateById(order);
|
||||
LastlegOrder tail = order.getLastlegOrderId() == null ? null : lastlegOrders.selectById(order.getLastlegOrderId());
|
||||
if (tail != null) {
|
||||
tail.setCurrentStatus("APPROVE".equals(decision) ? PENDING_RECEIPT : "REJECTED");
|
||||
tail.setSubOrderStatus("RETURN_RELABEL:" + ("APPROVE".equals(decision) ? PENDING_RECEIPT : "REJECTED"));
|
||||
tail.setRelabelStatus("APPROVE".equals(decision) && Boolean.TRUE.equals(order.getNeedRelabel()) ? "WAIT_INBOUND" : ("APPROVE".equals(decision) ? "NOT_REQUIRED" : "REJECTED"));
|
||||
tail.setUpdatedAt(LocalDateTime.now()); lastlegOrders.updateById(tail);
|
||||
}
|
||||
return detail(customerId, returnNo);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@@ -195,44 +268,119 @@ public class ReturnReceiptService {
|
||||
ReturnOrder order = orders.findNo(customerId, text(returnNo));
|
||||
if (order == null) throw notFound("退货单不存在");
|
||||
if (!COMPLETED.equals(order.getStatus())) throw conflict("退货单尚未完成签收");
|
||||
String fileScope = norm(request.fileScope());
|
||||
if (!Set.of("CARTON_TOTAL", "SKU_TOTAL").contains(fileScope)) throw badRequest("贴标总文件类型必须为 CARTON_TOTAL 或 SKU_TOTAL");
|
||||
String key = text(request.idempotencyKey());
|
||||
String requestHash = hash(request);
|
||||
RelabelApplication replay = relabelApplications.findKey(customerId, key);
|
||||
if (replay != null) return new ReturnOrderResponses.RelabelCreated(replay.getRelabelNo(), replay.getId(), order.getReturnNo(), request.cartonMarks(), replay.getStatus());
|
||||
List<ReturnInventoryCarton> selected = new ArrayList<>();
|
||||
if (replay != null) return new ReturnOrderResponses.RelabelCreated(replay.getRelabelNo(), replay.getId(),
|
||||
order.getReturnNo(), request.items().stream().map(ReturnOrderRequests.RelabelItem::skuCode).toList(), replay.getStatus());
|
||||
if (text(order.getRelabelNo()) != null) throw conflict("当前退货单已经生成贴标申请");
|
||||
var destination = fbaWarehouseValidator.required(request.destinationFbaWarehouseId(), request.destinationFbaWarehouseCode(), null);
|
||||
Map<String, ReturnOrderItem> receivedBySku = orderItems.lockOrder(order.getId()).stream()
|
||||
.collect(java.util.stream.Collectors.toMap(item -> norm(item.getSkuCode()), item -> item));
|
||||
Set<String> seen = new LinkedHashSet<>();
|
||||
for (String mark : request.cartonMarks()) {
|
||||
String normalized = text(mark);
|
||||
if (!seen.add(normalized.toUpperCase(java.util.Locale.ROOT))) throw badRequest("箱唛不能重复");
|
||||
ReturnInventoryCarton carton = cartons.findCustomerMark(customerId, normalized);
|
||||
if (carton == null || !order.getOverseasWarehouseId().equals(carton.getOverseasWarehouseId())) throw notFound("退货箱不存在: " + normalized);
|
||||
if (!ReturnInventoryService.AVAILABLE.equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) throw conflict("退货箱不可申请换标: " + normalized);
|
||||
if (carton.getSourceReturnNo() == null || !java.util.Arrays.asList(carton.getSourceReturnNo().split(",")).contains(order.getReturnNo())) throw conflict("退货箱不属于当前退货单: " + normalized);
|
||||
selected.add(cartons.lockMark(normalized));
|
||||
long totalQuantity = 0;
|
||||
for (ReturnOrderRequests.RelabelItem requested : request.items()) {
|
||||
String sku = norm(requested.skuCode());
|
||||
if (!seen.add(sku)) throw badRequest("同一贴标申请不能重复选择 SKU: " + requested.skuCode());
|
||||
ReturnOrderItem received = receivedBySku.get(sku);
|
||||
if (received == null || requested.quantity() > value(received.getReceivedQuantity())) {
|
||||
throw conflict("SKU " + requested.skuCode() + " 的贴标数量超过当前退货单实收数量");
|
||||
}
|
||||
totalQuantity = Math.addExact(totalQuantity, requested.quantity());
|
||||
}
|
||||
long appId = IdWorker.getId(); LocalDateTime now = LocalDateTime.now();
|
||||
String relabelNo = numbers.next("RELABEL", order.getOverseasWarehouseId(), appId);
|
||||
LastlegOrder tail = new LastlegOrder(); tail.setId(IdWorker.getId()); tail.setOrderNo(numbers.next("RELABEL", order.getOverseasWarehouseId(), tail.getId()));
|
||||
tail.setCustomerId(customerId); tail.setOrderType("RELABEL_APPLICATION"); tail.setSourceRegion("CUSTOMER"); tail.setSourceOrderId(String.valueOf(appId)); tail.setSourceOrderNo(relabelNo);
|
||||
tail.setOverseasWarehouseId(order.getOverseasWarehouseId()); tail.setCurrentStatus("PENDING_ASSIGNMENT"); tail.setSubOrderStatus("RELABEL:RETURN_CARTON"); tail.setRelabelStatus("RETURN_CARTON"); tail.setPackageCount(selected.size());
|
||||
tail.setProductQuantity(Math.toIntExact(selected.stream().mapToLong(c -> value(c.getTotalQuantity())).sum())); tail.setCreatedAt(now); tail.setUpdatedAt(now); lastlegOrders.insert(tail);
|
||||
RelabelApplication app = new RelabelApplication(); app.setId(appId); app.setCustomerId(customerId); app.setRelabelNo(relabelNo); app.setLastlegOrderId(tail.getId());
|
||||
app.setCustomerOrderNo(order.getReturnNo()); app.setOverseasWarehouseId(order.getOverseasWarehouseId()); app.setContactName(text(request.contactName())); app.setContactPhone(text(request.contactPhone())); app.setDeliveryMethod(text(request.deliveryMethod()) == null ? "TRUCK" : text(request.deliveryMethod()));
|
||||
app.setFulfillmentStatus("WAIT_RELABEL"); app.setRemark(text(request.remark())); app.setStatus("PENDING_PROCESSING"); app.setItemCount(selected.size()); app.setTotalPackageCount(selected.size()); app.setNotifiedPackageCount(selected.size()); app.setChargeAmount(java.math.BigDecimal.ZERO); app.setChargeCurrency("CNY"); app.setWalletStatus("NOT_REQUIRED"); app.setIdempotencyKey(key); app.setRequestHash(hash(request)); app.setCreatedBy(text(operatorId)); app.setCreatedAt(now); app.setUpdatedAt(now); relabelApplications.insert(app);
|
||||
for (ReturnInventoryCarton carton : selected) {
|
||||
for (ReturnInventoryCartonItem ci : cartonItems.listCarton(carton.getId())) {
|
||||
RelabelItem item = new RelabelItem(); item.setId(IdWorker.getId()); item.setApplicationId(appId); item.setCustomerId(customerId); item.setSkuCode(ci.getSkuCode()); item.setProductNameZh(ci.getProductNameZh()); item.setTotalPackageCount(1); item.setNotifiedPackageCount(1); item.setShipmentNo(order.getReturnNo()); item.setCartonMark(carton.getCartonMark()); item.setFbaCartonMark(carton.getFbaCartonMark()); item.setOperationType("CARTON_LABEL"); item.setReturnCartonId(carton.getId()); item.setSourceCartonMark(carton.getCartonMark()); item.setCreatedAt(now); relabelItems.insert(item);
|
||||
}
|
||||
carton.setRelabelApplicationId(appId); carton.setRelabelNo(relabelNo); carton.setRelabelStatus("PENDING"); carton.setStatus("RELABEL_PENDING"); carton.setUpdatedAt(now); cartons.updateById(carton);
|
||||
LastlegOrder tail = order.getLastlegOrderId() == null ? null : lastlegOrders.selectById(order.getLastlegOrderId());
|
||||
if (tail == null) {
|
||||
tail = new LastlegOrder(); tail.setId(IdWorker.getId()); tail.setOrderNo(numbers.next("RELABEL", order.getOverseasWarehouseId(), tail.getId()));
|
||||
tail.setCustomerId(customerId); tail.setOrderType("RELABEL_APPLICATION"); tail.setSourceRegion("CUSTOMER");
|
||||
tail.setSourceInboundId("RETURN_RELABEL:" + relabelNo);
|
||||
tail.setSourceEventId("RETURN_RELABEL:" + relabelNo);
|
||||
tail.setSourceLoadingPlanId("RETURN_RELABEL");
|
||||
tail.setSourcePayloadHash("sha256:" + requestHash);
|
||||
tail.setSourceOrderId(String.valueOf(appId)); tail.setSourceOrderNo(relabelNo);
|
||||
tail.setOverseasWarehouseId(order.getOverseasWarehouseId()); tail.setCurrentStatus("WAIT_RELABEL");
|
||||
tail.setCreatedAt(now); tail.setPackageCount(Math.toIntExact(totalQuantity));
|
||||
tail.setProductQuantity(Math.toIntExact(totalQuantity)); tail.setUpdatedAt(now); lastlegOrders.insert(tail);
|
||||
}
|
||||
order.setRelabelNo(relabelNo); order.setRelabelStatus("PENDING_PROCESSING"); orders.updateById(order);
|
||||
return new ReturnOrderResponses.RelabelCreated(relabelNo, appId, order.getReturnNo(), selected.stream().map(ReturnInventoryCarton::getCartonMark).toList(), app.getStatus());
|
||||
// A return relabel application belongs to the already received return tail-leg order.
|
||||
// Do not create a second independent tail order or overwrite the inbound status.
|
||||
tail.setRelabelOrderNo(relabelNo);
|
||||
tail.setRelabelStatus("PENDING_REVIEW");
|
||||
tail.setSubOrderStatus("RETURN_RELABEL:PENDING_REVIEW");
|
||||
tail.setCurrentStatus("RETURN_RELABEL".equals(tail.getOrderType()) ? "INBOUND_COMPLETED" : tail.getCurrentStatus());
|
||||
tail.setPackageCount(Math.toIntExact(totalQuantity));
|
||||
tail.setProductQuantity(Math.toIntExact(totalQuantity));
|
||||
tail.setUpdatedAt(now);
|
||||
lastlegOrders.updateById(tail);
|
||||
RelabelApplication app = new RelabelApplication(); app.setId(appId); app.setCustomerId(customerId); app.setRelabelNo(relabelNo); app.setLastlegOrderId(tail.getId());
|
||||
app.setCustomerOrderNo(order.getReturnNo()); app.setOverseasWarehouseId(order.getOverseasWarehouseId());
|
||||
app.setLabelDeadline(now.plusDays(7));
|
||||
app.setContactName(text(order.getWarehouseContactName())); app.setContactPhone(text(order.getWarehouseContactPhone()));
|
||||
app.setFulfillmentStatus("WAIT_APPROVAL"); app.setRemark(text(request.cartonRemark())); app.setStatus("PENDING_REVIEW"); app.setItemCount(request.items().size()); app.setTotalPackageCount(Math.toIntExact(totalQuantity)); app.setNotifiedPackageCount(Math.toIntExact(totalQuantity)); app.setChargeAmount(java.math.BigDecimal.ZERO); app.setChargeCurrency("CNY"); app.setWalletStatus("NOT_REQUIRED"); app.setIdempotencyKey(key); app.setRequestHash(requestHash); app.setCreatedBy(text(operatorId)); app.setCreatedAt(now); app.setUpdatedAt(now); relabelApplications.insert(app);
|
||||
List<ReturnInventoryCarton> availableCartons = cartons.findAvailable(customerId, order.getOverseasWarehouseId());
|
||||
Map<Long, ReturnInventoryCarton> selectedCartons = new LinkedHashMap<>();
|
||||
for (ReturnOrderRequests.RelabelItem requested : request.items()) {
|
||||
ReturnOrderItem source = receivedBySku.get(norm(requested.skuCode()));
|
||||
long remaining = requested.quantity();
|
||||
for (ReturnInventoryCarton candidate : availableCartons) {
|
||||
if (remaining == 0) break;
|
||||
if (!order.getReturnNo().equals(candidate.getSourceReturnNo())) continue;
|
||||
long skuQuantity = cartonItems.listCarton(candidate.getId()).stream()
|
||||
.filter(line -> norm(requested.skuCode()).equals(norm(line.getSkuCode())))
|
||||
.mapToLong(line -> value(line.getQuantity())).sum();
|
||||
if (skuQuantity <= 0) continue;
|
||||
ReturnInventoryCarton carton = selectedCartons.computeIfAbsent(candidate.getId(), ignored -> cartons.lockId(candidate.getId()));
|
||||
if (carton == null || !"AVAILABLE".equals(carton.getStatus()) || carton.getRelabelApplicationId() != null)
|
||||
throw conflict("退货箱已被其他换标申请占用,请刷新后重试");
|
||||
long allocated = Math.min(remaining, skuQuantity);
|
||||
RelabelItem item = new RelabelItem(); item.setId(IdWorker.getId()); item.setApplicationId(appId); item.setCustomerId(customerId);
|
||||
item.setSkuCode(source.getSkuCode());
|
||||
item.setProductNameZh(text(source.getProductNameZh()) == null ? source.getSkuCode() : text(source.getProductNameZh()));
|
||||
item.setTotalPackageCount(Math.toIntExact(allocated)); item.setNotifiedPackageCount(Math.toIntExact(allocated));
|
||||
item.setShipmentNo(order.getReturnNo()); item.setLabelRequirement(text(request.cartonRemark()));
|
||||
item.setDestinationWarehouseId(String.valueOf(destination.id())); item.setDestinationFbaWarehouseCode(destination.code());
|
||||
item.setOperationType("RETURN_BULK_SKU_LABEL"); item.setReturnCartonId(carton.getId());
|
||||
item.setCartonMark(carton.getCartonMark()); item.setSourceCartonMark(carton.getCartonMark());
|
||||
item.setCreatedAt(now); relabelItems.insert(item);
|
||||
remaining -= allocated;
|
||||
}
|
||||
if (remaining > 0) throw conflict("退货 SKU 可用实收数量不足: " + requested.skuCode());
|
||||
}
|
||||
for (ReturnInventoryCarton carton : selectedCartons.values()) {
|
||||
carton.setRelabelApplicationId(appId); carton.setRelabelNo(relabelNo); carton.setRelabelStatus("PENDING");
|
||||
carton.setStatus("RELABEL_PENDING"); carton.setUpdatedAt(now); cartons.updateById(carton);
|
||||
}
|
||||
order.setRelabelApplicationId(appId); order.setRelabelNo(relabelNo); order.setRelabelStatus("PENDING_REVIEW"); orders.updateById(order);
|
||||
return new ReturnOrderResponses.RelabelCreated(relabelNo, appId, order.getReturnNo(),
|
||||
request.items().stream().map(ReturnOrderRequests.RelabelItem::skuCode).toList(), app.getStatus());
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PdaReturnReceiptResponses.Start start(PdaWarehouseIdentity identity, PdaReturnReceiptRequests.Start request) {
|
||||
ReturnOrder order = activeOrder(String.valueOf(identity.overseasWarehouseId()), request.returnNo());
|
||||
List<PdaReturnReceiptResponses.CompletedPallet> completedPallets = receipts.listOrder(order.getId()).stream()
|
||||
.filter(receipt -> text(receipt.getPalletCode()) != null)
|
||||
.collect(java.util.stream.Collectors.toMap(ReturnReceipt::getPalletCode, receipt -> {
|
||||
ReturnInventoryCarton carton = cartons.findReceipt(receipt.getId());
|
||||
ReturnInventoryCartonItem item = carton == null ? null : cartonItems.listCarton(carton.getId()).stream().findFirst().orElse(null);
|
||||
return new PdaReturnReceiptResponses.CompletedPallet(receipt.getPalletCode(), item == null ? "" : item.getSkuCode(),
|
||||
item == null ? 0 : value(item.getQuantity()), receipt.getReceiptBatchNo());
|
||||
}, (left, right) -> new PdaReturnReceiptResponses.CompletedPallet(left.palletCode(), left.skuCode(),
|
||||
left.quantity() + right.quantity(), right.receiptBatchNo()), java.util.LinkedHashMap::new))
|
||||
.values().stream().toList();
|
||||
List<PdaReturnReceiptResponses.Photo> photos = photoList(order).stream()
|
||||
.map(file -> new PdaReturnReceiptResponses.Photo(file.fileId(), file.fileName(), file.contentType(), file.sizeBytes())).toList();
|
||||
return new PdaReturnReceiptResponses.Start(order.getReturnNo(), order.getStatus(), orderItems.listOrder(order.getId()).stream()
|
||||
.filter(item -> value(item.getReceivedQuantity()) < value(item.getExpectedQuantity())).map(this::pdaItem).toList());
|
||||
.filter(item -> value(item.getReceivedQuantity()) < value(item.getExpectedQuantity())).map(this::pdaItem).toList(), completedPallets, photos);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway.DownloadedFile downloadPhoto(PdaWarehouseIdentity identity, String returnNo, long fileId) {
|
||||
ReturnOrder order = activeOrder(String.valueOf(identity.overseasWarehouseId()), returnNo);
|
||||
return photoFiles.download(order.getCustomerId(), order.getId(), fileId);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@@ -255,6 +403,16 @@ public class ReturnReceiptService {
|
||||
return new PdaReturnReceiptResponses.SkuCandidates(skuCode, candidates);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ReturnOrderResponses.Detail manualComplete(PdaWarehouseIdentity identity, String returnNo, PdaReturnReceiptRequests.ManualComplete request) {
|
||||
ReturnOrder order = activeOrder(String.valueOf(identity.overseasWarehouseId()), returnNo);
|
||||
if (value(order.getReceivedQuantity()) >= value(order.getExpectedQuantity())) throw conflict("退货单已按预期数量签收完成,不需要手动完结");
|
||||
order.setStatus("COMPLETED_WITH_DISCREPANCY"); order.setDiscrepancyReason(text(request.discrepancyReason()));
|
||||
order.setManuallyCompletedAt(LocalDateTime.now()); order.setUpdatedAt(LocalDateTime.now()); orders.updateById(order);
|
||||
syncReturnTail(order, "INBOUND_COMPLETED", true);
|
||||
return detail(order.getCustomerId(), order.getReturnNo());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PdaReturnReceiptResponses.Confirmed confirm(PdaWarehouseIdentity identity, PdaReturnReceiptRequests.Confirm request) {
|
||||
String key = text(request.idempotencyKey());
|
||||
@@ -289,6 +447,7 @@ public class ReturnReceiptService {
|
||||
receipt.setReceiptNo(numbers.next("RETURN_RECEIPT", order.getOverseasWarehouseId(), receiptId));
|
||||
receipt.setReceiptBatchNo(numbers.next("RETURN_BATCH", order.getOverseasWarehouseId(), receiptId));
|
||||
receipt.setTrackingNo(trackingNo); receipt.setBoundBy(startedBy); receipt.setStatus("SIGNED");
|
||||
receipt.setPalletCode(text(request.palletCode()));
|
||||
receipt.setReceivedQuantity(receiptQuantity); receipt.setOperatorId(identity.operatorKey());
|
||||
receipt.setOperatorName(identity.operatorName()); receipt.setIdempotencyKey(key); receipt.setRequestHash(requestHash);
|
||||
receipt.setSignedAt(now); receipt.setCreatedAt(now);
|
||||
@@ -315,13 +474,20 @@ public class ReturnReceiptService {
|
||||
left.skuCode(), left.productNameZh() == null ? right.productNameZh() : left.productNameZh(),
|
||||
Math.addExact(left.quantity(), right.quantity())));
|
||||
}
|
||||
receipt.setCartonMark(inventory.createReceiptCarton(order, receipt, request.cartonMark(),
|
||||
if (allocations.stream().map(allocation -> allocation.item().getSkuCode()).distinct().count() != 1) {
|
||||
throw conflict("一个签收批次只能签收一个 SKU");
|
||||
}
|
||||
receipt.setCartonMark(inventory.createReceiptCarton(order, receipt, text(request.palletCode()),
|
||||
new ArrayList<>(inventoryLines.values())).getCartonMark());
|
||||
receipts.updateById(receipt);
|
||||
List<ReturnOrderItem> finalItems = orderItems.listOrder(order.getId());
|
||||
long received = finalItems.stream().mapToLong(item -> value(item.getReceivedQuantity())).sum();
|
||||
boolean completed = finalItems.stream().allMatch(item -> value(item.getReceivedQuantity()) == value(item.getExpectedQuantity()));
|
||||
if (completed && photoFiles != null && photoList(order).isEmpty()) {
|
||||
throw conflict("本次将完成签收,请先拍摄并上传退货打托照片");
|
||||
}
|
||||
order.setReceivedQuantity(received); order.setStatus(completed ? COMPLETED : PARTIALLY_RECEIVED); order.setUpdatedAt(now); orders.updateById(order);
|
||||
syncReturnTail(order, completed ? "INBOUND_COMPLETED" : "PARTIALLY_RECEIVED", completed || received > 0);
|
||||
ReturnReceiptBatch batch = new ReturnReceiptBatch();
|
||||
batch.setId(IdWorker.getId()); batch.setReturnOrderId(order.getId()); batch.setReceiptId(receiptId);
|
||||
batch.setCustomerId(order.getCustomerId()); batch.setBatchNo(receipt.getReceiptBatchNo());
|
||||
@@ -330,6 +496,21 @@ public class ReturnReceiptService {
|
||||
return confirmed(order, receipt, completed);
|
||||
}
|
||||
|
||||
private void syncReturnTail(ReturnOrder order, String currentStatus, boolean inboundStarted) {
|
||||
if (order.getLastlegOrderId() == null) return;
|
||||
LastlegOrder tail = lastlegOrders.selectById(order.getLastlegOrderId());
|
||||
if (tail == null) return;
|
||||
tail.setCurrentStatus(currentStatus);
|
||||
tail.setSubOrderStatus("RETURN_RELABEL:" + currentStatus);
|
||||
tail.setRelabelStatus(Boolean.TRUE.equals(order.getNeedRelabel()) && inboundStarted ? "PENDING_PROCESSING" : "NOT_REQUIRED");
|
||||
tail.setUpdatedAt(LocalDateTime.now());
|
||||
lastlegOrders.updateById(tail);
|
||||
}
|
||||
|
||||
private List<RelabelPhotoFileGateway.UploadedFile> photoList(ReturnOrder order) {
|
||||
return photoFiles == null ? List.of() : photoFiles.list(order.getCustomerId(), order.getId());
|
||||
}
|
||||
|
||||
private ReturnOrder activeOrder(String warehouseId, String returnNo) {
|
||||
ReturnOrder order = orders.findActiveNo(warehouseId, text(returnNo));
|
||||
if (order == null) throw notFound("当前仓没有待签收或签收中的退货单");
|
||||
@@ -367,13 +548,13 @@ public class ReturnReceiptService {
|
||||
return confirmed(order, receipt, COMPLETED.equals(order.getStatus()));
|
||||
}
|
||||
private PdaReturnReceiptResponses.Confirmed confirmed(ReturnOrder order, ReturnReceipt receipt, boolean completed) {
|
||||
return new PdaReturnReceiptResponses.Confirmed(order.getReturnNo(), receipt.getReceiptNo(), receipt.getReceiptBatchNo(), receipt.getCartonMark(),
|
||||
return new PdaReturnReceiptResponses.Confirmed(order.getReturnNo(), receipt.getReceiptNo(), receipt.getReceiptBatchNo(), receipt.getPalletCode(),
|
||||
order.getStatus(), completed, value(order.getExpectedQuantity()), value(order.getReceivedQuantity()), receipt.getSignedAt());
|
||||
}
|
||||
private ReturnOrderResponses.Summary summary(ReturnOrder order) { return new ReturnOrderResponses.Summary(order.getReturnNo(), order.getLastlegOrderNo(),
|
||||
order.getOverseasWarehouseId(), order.getWarehouseContactName(), order.getWarehouseContactPhone(), order.getCustomerReferenceNo(), order.getStatus(),
|
||||
order.getExpectedSkuTypes() == null ? 0 : order.getExpectedSkuTypes(), value(order.getExpectedQuantity()),
|
||||
value(order.getReceivedQuantity()), Boolean.TRUE.equals(order.getNeedRelabel()), order.getRelabelNo(), order.getRelabelStatus(), order.getCreatedAt()); }
|
||||
value(order.getReceivedQuantity()), Boolean.TRUE.equals(order.getNeedRelabel()), order.getRelabelNo(), order.getRelabelStatus(), order.getDiscrepancyReason(), order.getCreatedAt()); }
|
||||
private ReturnOrderResponses.Item item(ReturnOrderItem item) { return new ReturnOrderResponses.Item(item.getSkuCode(),
|
||||
item.getProductNameZh(), value(item.getExpectedQuantity()), value(item.getReceivedQuantity())); }
|
||||
private PdaReturnReceiptResponses.Item pdaItem(ReturnOrderItem item) { return new PdaReturnReceiptResponses.Item(item.getSkuCode(),
|
||||
|
||||
@@ -33,7 +33,7 @@ public class FbaWarehouseValidator {
|
||||
|| !"FBA".equals(warehouse.warehouseType())
|
||||
|| !"ACTIVE".equals(warehouse.status())
|
||||
|| !same(requestedCode, warehouse.warehouseCode())
|
||||
|| !same(requestedName, warehouse.warehouseName())) {
|
||||
|| !sameIfProvided(requestedName, warehouse.warehouseName())) {
|
||||
throw invalid();
|
||||
}
|
||||
String region = firstRegion(response.data().addresses());
|
||||
@@ -69,6 +69,10 @@ public class FbaWarehouseValidator {
|
||||
&& requested.trim().equals(actual.trim());
|
||||
}
|
||||
|
||||
private boolean sameIfProvided(String requested, String actual) {
|
||||
return !StringUtils.hasText(requested) || same(requested, actual);
|
||||
}
|
||||
|
||||
private String firstRegion(List<DomesticWarehouseDirectoryClient.Address> addresses) {
|
||||
if (addresses == null) return null;
|
||||
return addresses.stream().filter(Objects::nonNull)
|
||||
|
||||
+9
-1
@@ -478,6 +478,14 @@ public class WarehouseTransferService {
|
||||
orderDetails.updateById(detail);
|
||||
}
|
||||
syncInboundPlanDestination(customerId, transfer);
|
||||
if (StringUtils.hasText(transfer.getRelabelNo())) {
|
||||
RelabelApplication relabel = relabelApplications.lockNo(customerId, transfer.getRelabelNo());
|
||||
if (relabel != null && "WAIT_FBA_DESTINATION".equals(relabel.getFulfillmentStatus())) {
|
||||
relabel.setFulfillmentStatus("WAIT_PRE_DISPATCH");
|
||||
relabel.setUpdatedAt(now);
|
||||
relabelApplications.updateById(relabel);
|
||||
}
|
||||
}
|
||||
insertOrderHistory(customerId, order.getId(), order.getCurrentStatus(), order.getCurrentStatus(), transferNo,
|
||||
"Warehouse transfer destination bound by " + trim(operatorId), now);
|
||||
results.add(new WarehouseTransferResponses.DestinationResult(customerId, transferNo));
|
||||
@@ -1160,7 +1168,7 @@ public class WarehouseTransferService {
|
||||
throw conflict("Source inventory balance is missing for warehouse transfer receipt");
|
||||
}
|
||||
InventoryCommandResponse result = inventory.stockIn(customerId, operatorId,
|
||||
new InventoryCommandRequests.StockIn(customerId, null,
|
||||
new InventoryCommandRequests.StockIn(customerId, sourceBalance.getCustomerName(),
|
||||
request.destinationOverseasWarehouseId().trim(), trim(request.destinationOverseasWarehouseCode()),
|
||||
trim(request.destinationOverseasWarehouseName()), trim(request.destinationWarehouseId()),
|
||||
request.destinationWarehouseCode().trim(), trim(request.destinationWarehouseName()),
|
||||
|
||||
@@ -1957,6 +1957,7 @@ CREATE TABLE IF NOT EXISTS ll_relabel_pda_unit_fact (
|
||||
result VARCHAR(32) NOT NULL COMMENT '逐件结果:RELABELLED、SKIPPED或EXCEPTION',
|
||||
exception_code VARCHAR(32) NULL COMMENT '异常编码',
|
||||
reason VARCHAR(1000) NULL COMMENT '跳过或异常原因',
|
||||
quantity INT NOT NULL DEFAULT 1 COMMENT '本次事实处理件数,箱级作业固定为1',
|
||||
operator_key VARCHAR(128) NOT NULL COMMENT '操作账号标识',
|
||||
operator_name VARCHAR(255) NOT NULL COMMENT '操作员名称快照',
|
||||
occurred_at DATETIME(3) NOT NULL COMMENT '逐件事实发生时间',
|
||||
@@ -1965,6 +1966,8 @@ CREATE TABLE IF NOT EXISTS ll_relabel_pda_unit_fact (
|
||||
KEY idx_ll_relabel_pda_unit_task (customer_id,task_id,occurred_at,id),
|
||||
KEY idx_ll_relabel_pda_unit_item (customer_id,application_id,item_id)
|
||||
) ENGINE=InnoDB COMMENT='海外PDA逐件换标扫码与异常事实';
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_pda_unit_fact' AND column_name='quantity'), 'SELECT 1', 'ALTER TABLE ll_relabel_pda_unit_fact ADD COLUMN quantity INT NOT NULL DEFAULT 1 COMMENT ''本次事实处理件数,箱级作业固定为1'' AFTER reason'));
|
||||
PREPARE ll_relabel_fact_quantity_stmt FROM @ddl; EXECUTE ll_relabel_fact_quantity_stmt; DEALLOCATE PREPARE ll_relabel_fact_quantity_stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_relabel_pda_operation (
|
||||
id BIGINT NOT NULL COMMENT '换标PDA幂等操作ID',
|
||||
@@ -2616,6 +2619,8 @@ SET @ll_return_receipt_alter = 'ALTER TABLE ll_return_receipt MODIFY COLUMN trac
|
||||
PREPARE ll_return_receipt_alter_stmt FROM @ll_return_receipt_alter;
|
||||
EXECUTE ll_return_receipt_alter_stmt;
|
||||
DEALLOCATE PREPARE ll_return_receipt_alter_stmt;
|
||||
SET @ll_return_receipt_add_pallet = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='pallet_code'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN pallet_code VARCHAR(128) NULL COMMENT ''本次签收绑定的托盘码'' AFTER receipt_batch_no'));
|
||||
PREPARE ll_return_receipt_add_pallet_stmt FROM @ll_return_receipt_add_pallet; EXECUTE ll_return_receipt_add_pallet_stmt; DEALLOCATE PREPARE ll_return_receipt_add_pallet_stmt;
|
||||
CREATE TABLE IF NOT EXISTS ll_return_receipt_item (
|
||||
id BIGINT NOT NULL COMMENT '签收SKU明细ID', receipt_id BIGINT NOT NULL COMMENT 'PDA退货签收单ID', return_order_item_id BIGINT NOT NULL COMMENT '退货预报明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', received_quantity BIGINT NOT NULL COMMENT '本次SKU实收数量',
|
||||
PRIMARY KEY(id), KEY idx_ll_return_receipt_item_receipt(receipt_id), KEY idx_ll_return_receipt_item_order_item(return_order_item_id), CONSTRAINT chk_ll_return_receipt_item_quantity CHECK(received_quantity>0)
|
||||
@@ -2631,15 +2636,17 @@ PREPARE return_receipt_carton_mark_stmt FROM @ddl; EXECUTE return_receipt_carton
|
||||
CREATE TABLE IF NOT EXISTS ll_return_inventory_carton (
|
||||
new_carton_mark VARCHAR(255) NULL COMMENT '换标后新箱唛',
|
||||
id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', source_receipt_id BIGINT NULL COMMENT '来源PDA签收单ID', source_batch_no VARCHAR(128) NULL COMMENT '来源签收批次号', carton_mark VARCHAR(128) NOT NULL COMMENT '退货箱号和箱唛编码', status VARCHAR(32) NOT NULL COMMENT 'AVAILABLE可用或MERGED已合箱', sku_types INT NOT NULL COMMENT '箱内SKU种类数', total_quantity BIGINT NOT NULL COMMENT '箱内总件数', length DECIMAL(12,3) NULL COMMENT '箱长CM', width DECIMAL(12,3) NULL COMMENT '箱宽CM', height DECIMAL(12,3) NULL COMMENT '箱高CM', weight DECIMAL(12,3) NULL COMMENT '箱重KG', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', merged_at DATETIME(3) NULL COMMENT '合箱完成时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_mark(carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt(source_receipt_id), KEY idx_ll_return_inventory_carton_customer(customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED','OUTBOUNDED'))
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_mark(carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt(source_receipt_id), KEY idx_ll_return_inventory_carton_customer(customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED'))
|
||||
) ENGINE=InnoDB COMMENT='退货实物库存箱及箱唛';
|
||||
SET @ll_return_carton_add_pallet = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='pallet_code'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN pallet_code VARCHAR(128) NULL COMMENT ''签收时绑定的托盘码'' AFTER source_return_no'));
|
||||
PREPARE ll_return_carton_add_pallet_stmt FROM @ll_return_carton_add_pallet; EXECUTE ll_return_carton_add_pallet_stmt; DEALLOCATE PREPARE ll_return_carton_add_pallet_stmt;
|
||||
-- 已合箱的来源箱保留审计记录,但其箱内库存必须归零;动态 SQL 兼容 Spring 的数据库初始化器。
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity', 'SELECT 1'));
|
||||
PREPARE return_inventory_carton_check_drop_stmt FROM @ddl; EXECUTE return_inventory_carton_check_drop_stmt; DEALLOCATE PREPARE return_inventory_carton_check_drop_stmt;
|
||||
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0));
|
||||
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0));
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_status', 'SELECT 1'));
|
||||
PREPARE return_inventory_carton_status_drop_stmt FROM @ddl; EXECUTE return_inventory_carton_status_drop_stmt; DEALLOCATE PREPARE return_inventory_carton_status_drop_stmt;
|
||||
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED','OUTBOUNDED'));
|
||||
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED'));
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='source_return_no'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN source_return_no VARCHAR(2000) NULL COMMENT ''来源退货单号'' AFTER source_batch_no'));
|
||||
PREPARE return_inventory_carton_return_no_stmt FROM @ddl; EXECUTE return_inventory_carton_return_no_stmt; DEALLOCATE PREPARE return_inventory_carton_return_no_stmt;
|
||||
ALTER TABLE ll_return_inventory_carton MODIFY COLUMN source_batch_no TEXT NULL COMMENT '实际消耗来源签收批次号,多个批次逗号分隔', MODIFY COLUMN source_return_no TEXT NULL COMMENT '实际消耗来源退货单号,多个单号逗号分隔';
|
||||
@@ -2679,3 +2686,8 @@ CREATE TABLE IF NOT EXISTS ll_container_loading_evidence (
|
||||
UNIQUE KEY uk_ll_container_loading_evidence_type(source_container_booking_id,evidence_type),
|
||||
UNIQUE KEY uk_ll_container_loading_evidence_file(source_container_booking_id,source_file_id)
|
||||
) ENGINE=InnoDB COMMENT='海外拆柜只读国内装柜照片引用';
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='discrepancy_reason'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN discrepancy_reason VARCHAR(500) NULL COMMENT ''手动完结差异原因'' AFTER remark'));
|
||||
PREPARE ll_return_order_discrepancy_stmt FROM @ddl; EXECUTE ll_return_order_discrepancy_stmt; DEALLOCATE PREPARE ll_return_order_discrepancy_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='manually_completed_at'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN manually_completed_at DATETIME(3) NULL COMMENT ''手动差异完结时间'' AFTER discrepancy_reason'));
|
||||
PREPARE ll_return_order_manual_completed_stmt FROM @ddl; EXECUTE ll_return_order_manual_completed_stmt; DEALLOCATE PREPARE ll_return_order_manual_completed_stmt;
|
||||
|
||||
+34
@@ -66,6 +66,40 @@ class DeliveryCompletedOutboxServiceTest {
|
||||
verifyNoInteractions(mapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsWarehouseTransferWithoutDomesticSourceOrder() {
|
||||
LastlegEventOutboxMapper mapper = mock(LastlegEventOutboxMapper.class);
|
||||
DeliveryCompletedOutboxService service = new DeliveryCompletedOutboxService();
|
||||
ReflectionTestUtils.setField(service, "outbox", mapper);
|
||||
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().registerModule(new JavaTimeModule()));
|
||||
DeliveryOrder delivery = new DeliveryOrder();
|
||||
delivery.setId(2L); delivery.setDeliveryNo("DL-WT"); delivery.setVersion(1L);
|
||||
LastlegOrder transfer = new LastlegOrder();
|
||||
transfer.setId(22L); transfer.setOrderType("WAREHOUSE_TRANSFER");
|
||||
transfer.setCustomerId("88"); transfer.setSourceInboundId("WAREHOUSE_TRANSFER:WT-1");
|
||||
|
||||
service.publish(delivery, List.of(transfer), Instant.parse("2026-09-15T08:00:00Z"));
|
||||
|
||||
verifyNoInteractions(mapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsOverseasLocalOrderWithoutDomesticSourceOrder() {
|
||||
LastlegEventOutboxMapper mapper = mock(LastlegEventOutboxMapper.class);
|
||||
DeliveryCompletedOutboxService service = new DeliveryCompletedOutboxService();
|
||||
ReflectionTestUtils.setField(service, "outbox", mapper);
|
||||
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().registerModule(new JavaTimeModule()));
|
||||
DeliveryOrder delivery = new DeliveryOrder();
|
||||
delivery.setId(3L); delivery.setDeliveryNo("DL-LOCAL"); delivery.setVersion(1L);
|
||||
LastlegOrder local = new LastlegOrder();
|
||||
local.setId(23L); local.setOrderType("RETURN_RELABEL"); local.setSourceRegion("CUSTOMER");
|
||||
local.setCustomerId("88"); local.setSourceInboundId("RETURN-1");
|
||||
|
||||
service.publish(delivery, List.of(local), Instant.parse("2026-09-15T08:00:00Z"));
|
||||
|
||||
verifyNoInteractions(mapper);
|
||||
}
|
||||
|
||||
private LastlegOrder order(long id, String inboundId, String orderId, String inboundCode, String fba) {
|
||||
LastlegOrder value = new LastlegOrder();
|
||||
value.setId(id); value.setOrderNo("LM-" + id); value.setCustomerId("88");
|
||||
|
||||
+20
@@ -1,6 +1,8 @@
|
||||
package com.baiyue.lastleg.palletops.controller;
|
||||
|
||||
import com.baiyue.lastleg.palletops.dto.PalletOperationResponses;
|
||||
import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
@@ -8,6 +10,7 @@ import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.List;
|
||||
import java.util.Arrays;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -35,4 +38,21 @@ class PdaPalletOperationControllerContractTest {
|
||||
assertFalse(sql.contains("<"));
|
||||
assertFalse(sql.contains(">"));
|
||||
}
|
||||
|
||||
@Test void relabelSourceAndResolvedPalletSerializeSnowflakeIdsAsStrings() throws Exception {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
var source = new PalletOperationResponses.RelabelSource(2100204001227157507L, 2L,
|
||||
2100203890682015746L, "PLT-1", "LOC-1", "IN-1", 1L, 1L, List.of("C-1"));
|
||||
var content = new PalletOperationResponses.PalletContent(2100203890682015746L, "PLT-1",
|
||||
"OCCUPIED", 1, 2092783373163335682L, "LOC-1", null, 1L, List.of());
|
||||
|
||||
assertEquals("2100204001227157507", mapper.readTree(mapper.writeValueAsString(source))
|
||||
.path("positionId").textValue());
|
||||
assertEquals("2100203890682015746", mapper.readTree(mapper.writeValueAsString(source))
|
||||
.path("palletId").textValue());
|
||||
assertEquals("2100203890682015746", mapper.readTree(mapper.writeValueAsString(content))
|
||||
.path("palletId").textValue());
|
||||
assertEquals("2092783373163335682", mapper.readTree(mapper.writeValueAsString(content))
|
||||
.path("locationId").textValue());
|
||||
}
|
||||
}
|
||||
|
||||
+20
@@ -9,6 +9,7 @@ import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
|
||||
|
||||
@@ -37,4 +38,23 @@ class HttpPalletPhysicalGatewayTest {
|
||||
.hasMessageContaining("404");
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveIgnoresNewWarehousePalletMetadata() {
|
||||
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-1"))
|
||||
.andRespond(withSuccess("""
|
||||
{"code":"200","message":"success","data":{"palletId":"30","palletCode":"PAL-1",
|
||||
"warehouseId":"88","locationId":"40","status":"IDLE","fbaWarehouseCode":"XLX1",
|
||||
"unitPrice":1.00,"version":2,"locationType":"VIRTUAL","locationStatus":"ACTIVE",
|
||||
"reservable":true,"reservationBlockReason":null},
|
||||
"timestamp":"2026-09-19T03:18:35.283336500Z"}
|
||||
""", MediaType.APPLICATION_JSON));
|
||||
|
||||
var result = gateway.resolve("PAL-1");
|
||||
|
||||
assertThat(result.palletId()).isEqualTo(30L);
|
||||
assertThat(result.palletCode()).isEqualTo("PAL-1");
|
||||
assertThat(result.fbaWarehouseCode()).isEqualTo("XLX1");
|
||||
server.verify();
|
||||
}
|
||||
}
|
||||
|
||||
+65
-2
@@ -18,6 +18,11 @@ import com.baiyue.lastleg.relabel.entity.RelabelItem;
|
||||
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
|
||||
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
|
||||
import com.baiyue.lastleg.relabel.service.RelabelPalletMutationService;
|
||||
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
|
||||
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem;
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryBalanceMapper;
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -62,6 +67,9 @@ class PdaPalletOperationServiceTest {
|
||||
@Mock private RelabelApplicationMapper relabelApplications;
|
||||
@Mock private RelabelItemMapper relabelItems;
|
||||
@Mock private RelabelPalletMutationService relabelPalletMutations;
|
||||
@Mock private ReturnInventoryCartonMapper returnCartons;
|
||||
@Mock private ReturnInventoryCartonItemMapper returnCartonItems;
|
||||
@Mock private ReturnInventoryBalanceMapper returnBalances;
|
||||
private PdaPalletOperationService service;
|
||||
|
||||
@BeforeEach void setUp() {
|
||||
@@ -75,10 +83,44 @@ class PdaPalletOperationServiceTest {
|
||||
ReflectionTestUtils.setField(service, "relabelApplications", relabelApplications);
|
||||
ReflectionTestUtils.setField(service, "relabelItems", relabelItems);
|
||||
ReflectionTestUtils.setField(service, "relabelPalletMutations", relabelPalletMutations);
|
||||
ReflectionTestUtils.setField(service, "returnCartons", returnCartons);
|
||||
ReflectionTestUtils.setField(service, "returnCartonItems", returnCartonItems);
|
||||
ReflectionTestUtils.setField(service, "returnBalances", returnBalances);
|
||||
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
|
||||
service.configureTransactions(new NoOpTransactionManager());
|
||||
}
|
||||
|
||||
@Test void partialReturnRelabelKeepsUnlabelledRemainderOnOriginalPallet() {
|
||||
RelabelApplication app = new RelabelApplication(); app.setId(600L); app.setCustomerId("100"); app.setRelabelNo("RL-1");
|
||||
RelabelItem item = new RelabelItem(); item.setId(701L); item.setSkuCode("SKU-1");
|
||||
item.setReturnCartonId(801L); item.setDestinationFbaWarehouseCode("XLX6");
|
||||
ReturnInventoryCarton original = new ReturnInventoryCarton(); original.setId(801L);
|
||||
original.setCustomerId("100"); original.setOverseasWarehouseId("88");
|
||||
original.setTotalQuantity(100L); original.setPalletCode("OLD");
|
||||
ReturnInventoryCartonItem originalLine = new ReturnInventoryCartonItem();
|
||||
originalLine.setId(901L); originalLine.setSkuCode("SKU-1"); originalLine.setQuantity(100L);
|
||||
when(relabelItems.selectList(any())).thenReturn(List.of(item));
|
||||
when(returnCartonItems.lockCarton(801L)).thenReturn(List.of(originalLine));
|
||||
when(returnCartonItems.updateById(any(ReturnInventoryCartonItem.class))).thenReturn(1);
|
||||
when(returnCartons.releasePartial(801L, "100", 600L, 70L)).thenReturn(1);
|
||||
when(returnCartons.insert(any(ReturnInventoryCarton.class))).thenReturn(1);
|
||||
when(returnCartonItems.insert(any(ReturnInventoryCartonItem.class))).thenReturn(1);
|
||||
when(returnBalances.adjustCartonQuantity("100", "88", "SKU-1", 1)).thenReturn(1);
|
||||
when(relabelItems.updateById(any(RelabelItem.class))).thenReturn(1);
|
||||
|
||||
ReflectionTestUtils.invokeMethod(service, "splitReturnRelabelCarton", app, item, original,
|
||||
pallet(30L, "NEW-XLX6", 88L, 0), 30L);
|
||||
|
||||
assertEquals(70L, originalLine.getQuantity());
|
||||
var moved = ArgumentCaptor.forClass(ReturnInventoryCarton.class);
|
||||
verify(returnCartons).insert(moved.capture());
|
||||
assertEquals(30L, moved.getValue().getTotalQuantity());
|
||||
assertEquals("NEW-XLX6", moved.getValue().getPalletCode());
|
||||
assertEquals("XLX6", moved.getValue().getDestinationWarehouseCode());
|
||||
assertEquals(moved.getValue().getId(), item.getReturnCartonId());
|
||||
verify(returnBalances).adjustCartonQuantity("100", "88", "SKU-1", 1);
|
||||
}
|
||||
|
||||
@Test void resolveRejectsPalletOutsideTokenFrozenWarehouse() {
|
||||
when(physical.resolve("P-1")).thenReturn(pallet(10L, "P-1", 99L, 1));
|
||||
assertThrows(ResponseStatusException.class, () -> service.resolve(identity(),
|
||||
@@ -100,7 +142,7 @@ class PdaPalletOperationServiceTest {
|
||||
RelabelApplication application = new RelabelApplication(); application.setRelabelNo("RL-1");
|
||||
application.setStatus("READY_FOR_OUTBOUND"); application.setOverseasWarehouseId("88");
|
||||
when(relabelApplications.findAnyByRelabelNo("RL-1")).thenReturn(application);
|
||||
when(physical.ensureRelabelPallet(88L, "RL-1:new-pallet-1")).thenReturn(
|
||||
when(physical.ensureRelabelPallet(88L, "RL-1:new-pallet-1", null)).thenReturn(
|
||||
new PalletPhysicalGateway.PalletReference(30L, "PAL-NEW-1", 88L, 1030L, "IDLE", null, 0));
|
||||
|
||||
var result = service.createRelabelPallet(identity(),
|
||||
@@ -358,6 +400,27 @@ class PdaPalletOperationServiceTest {
|
||||
assertEquals(java.util.Set.of(), ReflectionTestUtils.invokeMethod(service, "boundRelabelMarks", app));
|
||||
}
|
||||
|
||||
@Test void relabelSourcesExposeAuthoritativePalletId() {
|
||||
var tasks = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class);
|
||||
ReflectionTestUtils.setField(service, "relabelWorkTasks", tasks);
|
||||
RelabelApplication app = new RelabelApplication(); app.setId(600L); app.setCustomerId("100");
|
||||
app.setRelabelNo("RL-1"); app.setOverseasWarehouseId("88"); app.setStatus("READY_FOR_OUTBOUND");
|
||||
app.setFulfillmentStatus("WAIT_PRE_DISPATCH");
|
||||
when(relabelApplications.findAnyByRelabelNo("RL-1")).thenReturn(app);
|
||||
RelabelItem item = relabelItem(701L, "CARTON-A", 100L);
|
||||
when(relabelItems.selectList(any())).thenReturn(List.of(item));
|
||||
when(positions.selectById(100L)).thenReturn(position(100L, 20L, "DISPLAY-CODE", 1L, 3L));
|
||||
PalletInventoryRow source = row(100L, 20L, 3L, 0L); source.setPalletNo("CANONICAL-CODE");
|
||||
source.setHoldPackages(1L);
|
||||
when(mapper.relabelSources("100", "88", 200L)).thenReturn(List.of(source));
|
||||
|
||||
var result = service.relabelSources(identity(), new PalletOperationRequests.RelabelSources("RL-1"));
|
||||
|
||||
assertEquals(1, result.size());
|
||||
assertEquals(20L, result.get(0).palletId());
|
||||
assertEquals("CANONICAL-CODE", result.get(0).palletCode());
|
||||
}
|
||||
|
||||
@Test void createsPalletBeforeCustomerConfirmationOnlyForClaimingOperator() {
|
||||
var tasks = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class);
|
||||
ReflectionTestUtils.setField(service, "relabelWorkTasks", tasks);
|
||||
@@ -366,7 +429,7 @@ class PdaPalletOperationServiceTest {
|
||||
when(relabelApplications.findAnyByRelabelNo("RL-1")).thenReturn(app);
|
||||
var task = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask(); task.setStatus("PROCESSING"); task.setOperatorKey("7");
|
||||
when(tasks.find("100", 600L)).thenReturn(task);
|
||||
when(physical.ensureRelabelPallet(88L, "RL-1:before-confirm")).thenReturn(pallet(30L, "NEW", 88L, 0));
|
||||
when(physical.ensureRelabelPallet(88L, "RL-1:before-confirm", null)).thenReturn(pallet(30L, "NEW", 88L, 0));
|
||||
assertEquals("NEW", service.createRelabelPallet(identity(), new PalletOperationRequests.NewRelabelPallet("RL-1", "before-confirm")).palletCode());
|
||||
task.setOperatorKey("other");
|
||||
assertThrows(ResponseStatusException.class, () -> service.createRelabelPallet(identity(), new PalletOperationRequests.NewRelabelPallet("RL-1", "different")));
|
||||
|
||||
@@ -117,4 +117,20 @@ class RelabelContractTest {
|
||||
assertTrue(service.contains("requiresOutbound && (!StringUtils.hasText(deliveryMethod)"
|
||||
+ " || !DELIVERY_METHODS.contains(deliveryMethod))"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnRelabelReviewStatusIsProjectedWithoutLosingPendingOrRejectedState() throws Exception {
|
||||
String service = Files.readString(Path.of("src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java"),
|
||||
StandardCharsets.UTF_8);
|
||||
assertTrue(service.contains("Set.of(PENDING_REVIEW, PENDING_PROCESSING, \"PROCESSING\", \"REJECTED\", \"CANCELLED\")"));
|
||||
assertTrue(service.contains("return workflowStatus;"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnCartonConstraintsAllowRelabelReservationWithPositiveInventory() throws Exception {
|
||||
String baseline = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8)
|
||||
.replaceAll("\\s+", "").toUpperCase();
|
||||
assertTrue(baseline.contains("STATUSIN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED')ANDSKU_TYPES>0ANDTOTAL_QUANTITY>0"));
|
||||
assertTrue(baseline.contains("STATUSIN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED')"));
|
||||
}
|
||||
}
|
||||
|
||||
+67
@@ -264,6 +264,73 @@ class PdaRelabelServiceTest {
|
||||
verify(unitFacts, never()).insert(any(PdaRelabelUnitFact.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnSkuTotalCompletesOneSkuOnceAndAllocatesAcrossSourcePalletItems() {
|
||||
application.setStatus("PROCESSING"); application.setNotifiedPackageCount(100);
|
||||
item.setOperationType("RETURN_BULK_SKU_LABEL"); item.setSkuCode("sku-fba100");
|
||||
item.setNotifiedPackageCount(50); item.setCartonMark("PALLET-A");
|
||||
RelabelItem second = item(); second.setId(11L); second.setOperationType("RETURN_BULK_SKU_LABEL");
|
||||
second.setSkuCode("sku-fba100"); second.setNotifiedPackageCount(50); second.setCartonMark("PALLET-B");
|
||||
PdaRelabelTask task = task("PROCESSING", 0); task.setExpectedPackages(100);
|
||||
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
|
||||
when(tasks.lock("100", 1L)).thenReturn(task);
|
||||
when(items.selectList(any())).thenReturn(List.of(item, second));
|
||||
|
||||
var result = service.completeReturnSkuTotal("7", "picker", 900L, "FBR-1",
|
||||
new PdaRelabelRequests.BulkSkuTotal("sku-fba100", 100, 0, "sku-total-1"));
|
||||
|
||||
assertEquals(100, result.relabelledPackages());
|
||||
assertEquals(2, persistedFacts.size());
|
||||
assertEquals(List.of(50, 50), persistedFacts.stream().map(PdaRelabelUnitFact::getQuantity).toList());
|
||||
assertEquals(List.of(10L, 11L), persistedFacts.stream().map(PdaRelabelUnitFact::getItemId).toList());
|
||||
assertTrue(persistedFacts.stream().allMatch(fact -> fact.getOldLabel() == null));
|
||||
assertEquals(1, result.version());
|
||||
}
|
||||
|
||||
@Test
|
||||
void multiSkuPartialReturnRelabelRequiresBothSkuTotalsBeforeCompletion() {
|
||||
verifyMultiSkuReturnCompletion(30, 15);
|
||||
}
|
||||
|
||||
@Test
|
||||
void multiSkuFullReturnRelabelRequiresBothSkuTotalsBeforeCompletion() {
|
||||
verifyMultiSkuReturnCompletion(50, 40);
|
||||
}
|
||||
|
||||
private void verifyMultiSkuReturnCompletion(int firstQuantity, int secondQuantity) {
|
||||
application.setStatus("PROCESSING");
|
||||
application.setNotifiedPackageCount(firstQuantity + secondQuantity);
|
||||
item.setOperationType("RETURN_BULK_SKU_LABEL"); item.setSkuCode("SKU-A");
|
||||
item.setNotifiedPackageCount(firstQuantity); item.setCartonMark("RC-A");
|
||||
RelabelItem second = item(); second.setId(11L);
|
||||
second.setOperationType("RETURN_BULK_SKU_LABEL"); second.setSkuCode("SKU-B");
|
||||
second.setNotifiedPackageCount(secondQuantity); second.setCartonMark("RC-B");
|
||||
PdaRelabelTask task = task("PROCESSING", 0);
|
||||
task.setExpectedPackages(firstQuantity + secondQuantity);
|
||||
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
|
||||
when(tasks.lock("100", 1L)).thenReturn(task);
|
||||
when(items.selectList(any())).thenReturn(List.of(item, second));
|
||||
|
||||
var first = service.completeReturnSkuTotal("7", "picker", 900L, "FBR-1",
|
||||
new PdaRelabelRequests.BulkSkuTotal("SKU-A", firstQuantity, 0, "multi-a"));
|
||||
assertEquals(firstQuantity, first.relabelledPackages());
|
||||
assertThrows(ResponseStatusException.class, () -> service.complete("7", "picker", 900L, "FBR-1",
|
||||
new PdaRelabelRequests.Complete(List.of(501L), 1, "multi-early", null)));
|
||||
assertThrows(ResponseStatusException.class, () -> service.completeReturnSkuTotal("7", "picker", 900L, "FBR-1",
|
||||
new PdaRelabelRequests.BulkSkuTotal("SKU-A", 1, 1, "multi-over")));
|
||||
|
||||
var both = service.completeReturnSkuTotal("7", "picker", 900L, "FBR-1",
|
||||
new PdaRelabelRequests.BulkSkuTotal("SKU-B", secondQuantity, 1, "multi-b"));
|
||||
assertEquals(firstQuantity + secondQuantity, both.relabelledPackages());
|
||||
assertEquals(List.of("SKU-A", "SKU-B"), persistedFacts.stream()
|
||||
.map(fact -> fact.getItemId().equals(item.getId()) ? "SKU-A" : "SKU-B").toList());
|
||||
assertEquals(List.of(firstQuantity, secondQuantity), persistedFacts.stream()
|
||||
.map(PdaRelabelUnitFact::getQuantity).toList());
|
||||
var completed = service.complete("7", "picker", 900L, "FBR-1",
|
||||
new PdaRelabelRequests.Complete(List.of(501L), 2, "multi-complete", "multi SKU complete"));
|
||||
assertEquals(PdaRelabelService.WAIT_REVIEW, completed.taskStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void completeRequiresEveryUnitAndMovesOnlyPdaTaskToWaitReview() {
|
||||
application.setStatus("PROCESSING");
|
||||
|
||||
+12
@@ -30,6 +30,8 @@ import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundPdaTaskMapper;
|
||||
import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
|
||||
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper;
|
||||
import com.baiyue.lastleg.returnreceipt.entity.ReturnOrder;
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper;
|
||||
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
|
||||
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -75,6 +77,7 @@ class RelabelApplicationServiceTest {
|
||||
@Mock OutboundPdaTaskMapper outboundTasks;
|
||||
@Mock WarehouseTransferOrderMapper warehouseTransfers;
|
||||
@Mock OverseasFeeSourceService feeSources;
|
||||
@Mock ReturnOrderMapper returnOrders;
|
||||
@Mock com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper pdaTasks;
|
||||
@Spy LastlegOrderNoGenerator orderNoGenerator = new LastlegOrderNoGenerator();
|
||||
|
||||
@@ -103,6 +106,7 @@ class RelabelApplicationServiceTest {
|
||||
ReflectionTestUtils.setField(service, "outboundItems", outboundItems);
|
||||
ReflectionTestUtils.setField(service, "outboundTasks", outboundTasks);
|
||||
ReflectionTestUtils.setField(service, "warehouseTransfers", warehouseTransfers);
|
||||
ReflectionTestUtils.setField(service, "returnOrders", returnOrders);
|
||||
ReflectionTestUtils.setField(service, "objectMapper", mapper());
|
||||
lenient().when(walletCommands.publish(any(), any(Long.class), any(), any(), any(), any(), any(Long.class))).thenReturn("wallet-event-1");
|
||||
lenient().when(inventoryPositions.findTenantPosition(any(), any())).thenAnswer(invocation -> {
|
||||
@@ -411,6 +415,10 @@ class RelabelApplicationServiceTest {
|
||||
when(items.selectList(any())).thenReturn(List.of(item(31L, application.getId(), "SKU-A", 1, 1)));
|
||||
when(histories.selectList(any())).thenReturn(List.of());
|
||||
when(orders.selectById(21L)).thenReturn(order);
|
||||
ReturnOrder returnOrder = new ReturnOrder();
|
||||
returnOrder.setId(41L); returnOrder.setCustomerId("10001");
|
||||
returnOrder.setRelabelApplicationId(application.getId()); returnOrder.setRelabelStatus("PROCESSING");
|
||||
when(returnOrders.selectOne(any())).thenReturn(returnOrder);
|
||||
|
||||
service.changeStatus("10001", "FBR-REVIEW", "operator-2",
|
||||
new RelabelRequests.ChangeStatus(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, "submitted"));
|
||||
@@ -419,6 +427,8 @@ class RelabelApplicationServiceTest {
|
||||
assertEquals("WAIT_CUSTOMER_CONFIRMATION", application.getFulfillmentStatus());
|
||||
assertEquals("COMPLETED", order.getRelabelStatus());
|
||||
assertEquals("RELABEL:CUSTOMER_CONFIRM_PENDING", order.getSubOrderStatus());
|
||||
assertEquals(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, returnOrder.getRelabelStatus());
|
||||
verify(returnOrders).updateById(returnOrder);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -558,6 +568,7 @@ class RelabelApplicationServiceTest {
|
||||
new RelabelRequests.CustomerConfirm("confirm-transfer", "accepted"));
|
||||
|
||||
assertEquals("READY_FOR_DISPATCH", transfer.getStatus());
|
||||
assertEquals("WAIT_PRE_DISPATCH", application.getFulfillmentStatus());
|
||||
assertNotNull(transfer.getRelabelCompletedAt());
|
||||
assertEquals("READY_FOR_DISPATCH", transferOrder.getCurrentStatus());
|
||||
assertEquals("COMPLETED", transferOrder.getRelabelStatus());
|
||||
@@ -610,6 +621,7 @@ class RelabelApplicationServiceTest {
|
||||
|
||||
assertEquals("ONT8", transfer.getFbaWarehouseCode());
|
||||
assertEquals("READY_FOR_DISPATCH", transfer.getStatus());
|
||||
assertEquals("WAIT_PRE_DISPATCH", application.getFulfillmentStatus());
|
||||
assertEquals("ONT8", transferOrder.getFbaWarehouseCode());
|
||||
assertEquals("READY_FOR_DISPATCH", transferOrder.getCurrentStatus());
|
||||
}
|
||||
|
||||
+1
@@ -45,6 +45,7 @@ class RelabelReturnCartonCandidateTest {
|
||||
assertEquals("RETURN_CARTON", result.get(0).sourceType());
|
||||
assertEquals(9001L, result.get(0).returnCartonId());
|
||||
assertEquals("RC-WH-0001", result.get(0).cartonMark());
|
||||
assertEquals(12, result.get(0).availablePackages());
|
||||
assertEquals("SKU-RETURN", result.get(0).skuItems().get(0).skuCode());
|
||||
assertEquals(12, result.get(0).skuItems().get(0).quantity());
|
||||
}
|
||||
|
||||
+106
-5
@@ -4,6 +4,7 @@ import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
|
||||
import com.baiyue.lastleg.returnreceipt.dto.PdaReturnReceiptRequests;
|
||||
import com.baiyue.lastleg.returnreceipt.dto.ReturnOrderRequests;
|
||||
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
|
||||
import com.baiyue.lastleg.returnreceipt.entity.ReturnOrder;
|
||||
import com.baiyue.lastleg.returnreceipt.entity.ReturnOrderItem;
|
||||
@@ -14,6 +15,14 @@ import com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper;
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptBatchMapper;
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptItemMapper;
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptMapper;
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper;
|
||||
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem;
|
||||
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
|
||||
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
|
||||
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
|
||||
import com.baiyue.lastleg.relabel.entity.RelabelItem;
|
||||
import com.baiyue.lastleg.service.FbaWarehouseValidator;
|
||||
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -53,6 +62,11 @@ class ReturnReceiptServiceTest {
|
||||
@Mock ReturnInventoryService inventory;
|
||||
@Mock LastlegOrderNoGenerator numbers;
|
||||
@Mock LastlegOrderMapper lastlegOrders;
|
||||
@Mock ReturnInventoryCartonMapper cartons;
|
||||
@Mock ReturnInventoryCartonItemMapper cartonItems;
|
||||
@Mock RelabelApplicationMapper relabelApplications;
|
||||
@Mock RelabelItemMapper relabelItems;
|
||||
@Mock FbaWarehouseValidator fbaWarehouseValidator;
|
||||
|
||||
ReturnReceiptService service;
|
||||
PdaWarehouseIdentity identity;
|
||||
@@ -69,9 +83,76 @@ class ReturnReceiptServiceTest {
|
||||
ReflectionTestUtils.setField(service, "numbers", numbers);
|
||||
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
|
||||
ReflectionTestUtils.setField(service, "lastlegOrders", lastlegOrders);
|
||||
ReflectionTestUtils.setField(service, "cartons", cartons);
|
||||
ReflectionTestUtils.setField(service, "cartonItems", cartonItems);
|
||||
ReflectionTestUtils.setField(service, "relabelApplications", relabelApplications);
|
||||
ReflectionTestUtils.setField(service, "relabelItems", relabelItems);
|
||||
ReflectionTestUtils.setField(service, "fbaWarehouseValidator", fbaWarehouseValidator);
|
||||
identity = new PdaWarehouseIdentity("operator-1", "仓管员", 10L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void multiSkuPartialRelabelLocksOnlyRequestedQuantities() {
|
||||
verifyMultiSkuRelabel(30, 15);
|
||||
}
|
||||
|
||||
@Test
|
||||
void multiSkuFullRelabelLocksAllReceivedQuantities() {
|
||||
verifyMultiSkuRelabel(50, 40);
|
||||
}
|
||||
|
||||
private void verifyMultiSkuRelabel(long firstQuantity, long secondQuantity) {
|
||||
ReturnOrder order = order("RT-MULTI", "10", "COMPLETED", 90, 90);
|
||||
order.setLastlegOrderId(99L);
|
||||
LastlegOrder tail = new LastlegOrder(); tail.setId(99L);
|
||||
ReturnInventoryCarton first = returnCarton(101L, "RC-A", "P-1", 50);
|
||||
ReturnInventoryCarton second = returnCarton(102L, "RC-B", "P-2", 40);
|
||||
when(orders.findNo("customer-1", "RT-MULTI")).thenReturn(order);
|
||||
when(orderItems.lockOrder(1L)).thenReturn(List.of(item(11L, "SKU-A", 50, 50), item(12L, "SKU-B", 40, 40)));
|
||||
when(fbaWarehouseValidator.required("200", "XLX6", null)).thenReturn(
|
||||
new FbaWarehouseValidator.VerifiedFbaWarehouse(200L, "XLX6", "FBA XLX6", "US", "CA"));
|
||||
when(numbers.next(eq("RELABEL"), eq("10"), anyLong())).thenReturn("RL-MULTI");
|
||||
when(lastlegOrders.selectById(99L)).thenReturn(tail);
|
||||
when(cartons.findAvailable("customer-1", "10")).thenReturn(List.of(first, second));
|
||||
when(cartons.lockId(101L)).thenReturn(first);
|
||||
when(cartons.lockId(102L)).thenReturn(second);
|
||||
when(cartonItems.listCarton(101L)).thenReturn(List.of(returnCartonItem("SKU-A", 50)));
|
||||
when(cartonItems.listCarton(102L)).thenReturn(List.of(returnCartonItem("SKU-B", 40)));
|
||||
|
||||
var result = service.createRelabel("customer-1", "merchant-1", "RT-MULTI",
|
||||
new ReturnOrderRequests.RelabelCreate(List.of(
|
||||
new ReturnOrderRequests.RelabelItem("SKU-A", firstQuantity),
|
||||
new ReturnOrderRequests.RelabelItem("SKU-B", secondQuantity)),
|
||||
"SKU_TOTAL", "200", "XLX6", "multi-key", "multi SKU acceptance"));
|
||||
|
||||
assertEquals("RL-MULTI", result.relabelNo());
|
||||
var application = ArgumentCaptor.forClass(RelabelApplication.class);
|
||||
verify(relabelApplications).insert(application.capture());
|
||||
assertEquals(firstQuantity + secondQuantity, application.getValue().getNotifiedPackageCount().longValue());
|
||||
assertEquals(2, application.getValue().getItemCount());
|
||||
var lines = ArgumentCaptor.forClass(RelabelItem.class);
|
||||
verify(relabelItems, times(2)).insert(lines.capture());
|
||||
assertEquals(List.of("SKU-A", "SKU-B"), lines.getAllValues().stream().map(RelabelItem::getSkuCode).toList());
|
||||
assertEquals(List.of((int) firstQuantity, (int) secondQuantity),
|
||||
lines.getAllValues().stream().map(RelabelItem::getNotifiedPackageCount).toList());
|
||||
assertTrue(lines.getAllValues().stream().allMatch(line -> "RETURN_BULK_SKU_LABEL".equals(line.getOperationType())));
|
||||
assertEquals("RELABEL_PENDING", first.getStatus());
|
||||
assertEquals("RELABEL_PENDING", second.getStatus());
|
||||
}
|
||||
|
||||
private static ReturnInventoryCarton returnCarton(long id, String mark, String pallet, long quantity) {
|
||||
ReturnInventoryCarton carton = new ReturnInventoryCarton();
|
||||
carton.setId(id); carton.setCustomerId("customer-1"); carton.setOverseasWarehouseId("10");
|
||||
carton.setSourceReturnNo("RT-MULTI"); carton.setCartonMark(mark); carton.setPalletCode(pallet);
|
||||
carton.setTotalQuantity(quantity); carton.setStatus("AVAILABLE");
|
||||
return carton;
|
||||
}
|
||||
|
||||
private static ReturnInventoryCartonItem returnCartonItem(String sku, long quantity) {
|
||||
ReturnInventoryCartonItem line = new ReturnInventoryCartonItem();
|
||||
line.setSkuCode(sku); line.setQuantity(quantity); return line;
|
||||
}
|
||||
|
||||
@Test
|
||||
void startReturnsOnlyRemainingSkuLinesForTheBoundWarehouse() {
|
||||
ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PARTIALLY_RECEIVED, 8, 3);
|
||||
@@ -110,6 +191,26 @@ class ReturnReceiptServiceTest {
|
||||
assertEquals("RETU-1", result.order().lastlegOrderNo());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectingForecastMarksReturnAndLinkedTailAsRejected() {
|
||||
ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PENDING_REVIEW, 2, 0);
|
||||
order.setLastlegOrderId(99L);
|
||||
LastlegOrder tail = new LastlegOrder();
|
||||
tail.setId(99L);
|
||||
when(orders.findNo("customer-1", "RT-1")).thenReturn(order);
|
||||
when(lastlegOrders.selectById(99L)).thenReturn(tail);
|
||||
|
||||
service.review("customer-1", "RT-1", new ReturnOrderRequests.Review("REJECT", "资料不齐"));
|
||||
|
||||
assertEquals("REJECTED", order.getStatus());
|
||||
assertEquals("资料不齐", order.getRemark());
|
||||
assertEquals("REJECTED", tail.getCurrentStatus());
|
||||
assertEquals("RETURN_RELABEL:REJECTED", tail.getSubOrderStatus());
|
||||
assertEquals("REJECTED", tail.getRelabelStatus());
|
||||
verify(orders).updateById(order);
|
||||
verify(lastlegOrders).updateById(tail);
|
||||
}
|
||||
|
||||
@Test
|
||||
void skuLookupReturnsOnlyActiveCandidatesFromTheBoundWarehouse() {
|
||||
ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PENDING_RECEIPT, 5, 1);
|
||||
@@ -162,7 +263,7 @@ class ReturnReceiptServiceTest {
|
||||
ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PARTIALLY_RECEIVED, 2, 1);
|
||||
ReturnOrderItem line = item(11L, "SKU-A", 2, 1);
|
||||
stubNewReceipt(order, List.of(line), "RC-AUTO");
|
||||
var request = confirm("SKU", null, null, "key-final",
|
||||
var request = confirm("SKU", null, "RC-AUTO", "key-final",
|
||||
List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1)));
|
||||
|
||||
var first = service.confirm(identity, request);
|
||||
@@ -175,7 +276,7 @@ class ReturnReceiptServiceTest {
|
||||
assertTrue(first.fullyMatched());
|
||||
assertEquals(ReturnReceiptService.COMPLETED, first.orderStatus());
|
||||
assertEquals(first.receiptNo(), replay.receiptNo());
|
||||
assertEquals("RC-AUTO", replay.cartonMark());
|
||||
assertEquals("RC-AUTO", replay.palletCode());
|
||||
verify(inventory, times(1)).createReceiptCarton(any(), any(), any(), any());
|
||||
verify(batches, times(1)).insert(any(ReturnReceiptBatch.class));
|
||||
verify(receipts, times(1)).insert(any(ReturnReceipt.class));
|
||||
@@ -249,7 +350,7 @@ class ReturnReceiptServiceTest {
|
||||
var request = confirm("RETURN_NO", null, null, "concurrent-key", List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1)));
|
||||
ReturnOrder completed = order("RT-1", "10", ReturnReceiptService.COMPLETED, 1, 1);
|
||||
ReturnReceipt committed = new ReturnReceipt();
|
||||
committed.setReceiptNo("RR-COMMITTED"); committed.setReceiptBatchNo("RB-COMMITTED"); committed.setCartonMark("RC-COMMITTED");
|
||||
committed.setReceiptNo("RR-COMMITTED"); committed.setReceiptBatchNo("RB-COMMITTED"); committed.setPalletCode("RC-COMMITTED");
|
||||
committed.setRequestHash(java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256")
|
||||
.digest(new ObjectMapper().writeValueAsBytes(request))));
|
||||
when(orders.lockNo("RT-1")).thenReturn(completed);
|
||||
@@ -301,8 +402,8 @@ class ReturnReceiptServiceTest {
|
||||
return item;
|
||||
}
|
||||
|
||||
private static PdaReturnReceiptRequests.Confirm confirm(String startedBy, String trackingNo, String cartonMark,
|
||||
private static PdaReturnReceiptRequests.Confirm confirm(String startedBy, String trackingNo, String palletCode,
|
||||
String key, List<PdaReturnReceiptRequests.Item> items) {
|
||||
return new PdaReturnReceiptRequests.Confirm("RT-1", trackingNo, startedBy, cartonMark, items, key);
|
||||
return new PdaReturnReceiptRequests.Confirm("RT-1", trackingNo, startedBy, palletCode, items, key);
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -46,6 +46,18 @@ class FbaWarehouseValidatorTest {
|
||||
assertThat(result.region()).isEqualTo("CA");
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsMatchingIdAndCodeWhenCallerDoesNotCarryWarehouseName() {
|
||||
when(client.activeWarehouse(193000000000000001L, "internal-token"))
|
||||
.thenReturn(ApiResponse.success(detail("FBA", "ACTIVE", "XLX1", "XLX1")));
|
||||
|
||||
var result = validator.required("193000000000000001", "XLX1", null);
|
||||
|
||||
assertThat(result.id()).isEqualTo(193000000000000001L);
|
||||
assertThat(result.code()).isEqualTo("XLX1");
|
||||
assertThat(result.name()).isEqualTo("XLX1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAnActualOverseasReceivingWarehouseAsFbaDestination() {
|
||||
when(client.activeWarehouse(193000000000000001L, "internal-token"))
|
||||
|
||||
+38
@@ -352,6 +352,42 @@ class WarehouseTransferServiceTest {
|
||||
assertEquals("WAREHOUSE_PICKUP", transfer.getValue().getDeliveryMethod());
|
||||
}
|
||||
|
||||
@Test
|
||||
void bindingDestinationReleasesLinkedRelabelForPreDispatch() {
|
||||
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_FBA_DESTINATION);
|
||||
transfer.setNeedRelabel(true);
|
||||
transfer.setRelabelNo("FBR-1");
|
||||
transfer.setFbaWarehouseCode(null);
|
||||
LastlegOrder order = tailOrder();
|
||||
RelabelApplication relabel = new RelabelApplication();
|
||||
relabel.setId(71L);
|
||||
relabel.setCustomerId("TENANT-A");
|
||||
relabel.setRelabelNo("FBR-1");
|
||||
relabel.setFulfillmentStatus("WAIT_FBA_DESTINATION");
|
||||
InboundPlan plan = new InboundPlan();
|
||||
plan.setId(81L);
|
||||
|
||||
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
|
||||
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
|
||||
when(orders.selectById(transfer.getLastlegOrderId())).thenReturn(order);
|
||||
when(orderDetails.selectList(any())).thenReturn(List.of());
|
||||
when(inboundPlans.findBySourceOrder("TENANT-A", transfer.getLastlegOrderId())).thenReturn(plan);
|
||||
when(inboundPlanLines.selectList(any())).thenReturn(List.of());
|
||||
when(inboundPlanPallets.selectList(any())).thenReturn(List.of());
|
||||
when(relabelApplications.lockNo("TENANT-A", "FBR-1")).thenReturn(relabel);
|
||||
when(transferItems.selectList(any())).thenReturn(List.of());
|
||||
when(sourceOrders.selectList(any())).thenReturn(List.of());
|
||||
when(transferPallets.listOrder("TENANT-A", transfer.getId())).thenReturn(List.of());
|
||||
|
||||
service.bindDestination("OP-1", new WarehouseTransferRequests.BindDestination(
|
||||
List.of(new WarehouseTransferRequests.DestinationSelection("TENANT-A", "WTR-1")),
|
||||
"FBA", null, "ONT8", "ONT8", null, null, null, null, "BIND-1"));
|
||||
|
||||
assertEquals(WarehouseTransferService.READY_FOR_DISPATCH, transfer.getStatus());
|
||||
assertEquals("WAIT_PRE_DISPATCH", relabel.getFulfillmentStatus());
|
||||
verify(relabelApplications).updateById(relabel);
|
||||
}
|
||||
|
||||
@Test
|
||||
void newTransitInboundCreatesSnapshotAndImmutableCartonMarksWithoutHistoricalInventory() {
|
||||
WarehouseTransferRequests.Create request = new WarehouseTransferRequests.Create(
|
||||
@@ -645,6 +681,7 @@ class WarehouseTransferServiceTest {
|
||||
ArgumentCaptor.forClass(com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.StockIn.class);
|
||||
verify(inventory).stockIn(eq("TENANT-A"), eq("USER-1"), stockIn.capture());
|
||||
assertEquals(5, stockIn.getValue().packages());
|
||||
assertEquals("Customer A", stockIn.getValue().customerName());
|
||||
assertEquals("SKU-TRANSFER", stockIn.getValue().skuCode());
|
||||
assertEquals("WTR-1", stockIn.getValue().businessOrderNo());
|
||||
assertEquals("WAREHOUSE_TRANSFER_RECEIPT:WTR-1:501", stockIn.getValue().idempotencyKey());
|
||||
@@ -840,6 +877,7 @@ class WarehouseTransferServiceTest {
|
||||
private static InventoryBalance balance(long id, long inboundOrderId, String inboundCode) {
|
||||
InventoryBalance balance = new InventoryBalance();
|
||||
balance.setId(id); balance.setCustomerId("TENANT-A"); balance.setInboundOrderId(inboundOrderId);
|
||||
balance.setCustomerName("Customer A");
|
||||
balance.setInboundCode(inboundCode); balance.setSkuCode("SKU-TRANSFER");
|
||||
return balance;
|
||||
}
|
||||
|
||||
@@ -149,6 +149,8 @@ Client 全量、Client 入库测量与计费、待审核订单和批量待分柜
|
||||
|
||||
服务局部分析入口为 `firstleg/scripts/analyze-firstleg-dependencies.mjs` 与 `lastleg-service/scripts/analyze-lastleg-dependencies.mjs`,相邻的 `*.test.mjs` 负责对基线和违规依赖做离线回归;`warehouse/scripts/warehouse-public-master-data-contract.test.mjs` 只读校验 Warehouse 公共主数据 CSV 与 SQL。SQL 辅助文件 `scripts/db/incremental.sql` 不是当前统一部署入口,正式数据库变更以 `deploy/sql/README.md` 指向的统一增量为准。
|
||||
|
||||
`scripts/db/test-lastleg-relabel-return-transfer-columns.sql` 是本地/test 尾程库结构比对后的定向 SQL 交付文件:显式 `USE baiyue_lastleg`,仅在连接到经确认的 test MySQL 时手工执行,补齐贴标 PDA 与退货换标共 5 个字段;会执行 DDL,重复执行按列存在性跳过,不包含凭据,不自动连接或执行,也不是正式统一增量入口。执行失败须停止并核对目标实例和现有结构。
|
||||
|
||||
新增可直接运行的脚本时,必须同时在本节登记用途、目标环境、是否写数据和密钥边界,并运行 `node --test scripts/script-documentation-contract.test.mjs`。仅新增 `*.test.mjs` 或 `admin-test-env-modules/` 内部模块时,分别更新对应合同范围或模块 README,不把内部文件误写成独立入口。
|
||||
|
||||
| 验收目标 | 唯一入口 | 数据副作用 | 覆盖范围 |
|
||||
@@ -328,3 +330,6 @@ Dry-run 只校验两个统一 SQL 的入口标记,打印当前 SHA-256、行
|
||||
- 该脚本现按“扫码→组托→完工→客户确认”执行,`OVERSEAS_RELABEL_CHARGE_AMOUNT=0` 表示省略费用字段、只验仓库链路(不代表财务通过);默认 0.01 验钱包冻结。照片为有效的合成测试 PNG,不是真实现场照片。
|
||||
- `scripts/relabel-pdf-local-acceptance.mjs`:仅本机 Print→File 真实 HTTP 验收,显式提供 `INTERNAL_SERVICE_TOKEN`、`OVERSEAS_TEST_CUSTOMER_ID`,可配置 `PRINT_SERVICE_URL`、`FILE_SERVICE_URL`。创建三类测试 PDF 文件元数据,验证下载哈希及跨客户拒绝,报告 `reports/relabel-closure-20260916/pdf.json`;保留文件,不清理,失败非零退出,可重跑生成新 businessId。不代表换标下单/PDA全流程通过。
|
||||
- `scripts/overseas-relabel-fulfillment-acceptance.mjs`:读取 `OVERSEAS_ACCEPTANCE_SOURCE` 或指定排车/换标编号,执行 PDA 拣货、出库、派送后续真实 HTTP,会推进业务状态;凭据来自显式环境文件,报告默认 `reports/overseas-relabel-fulfillment-acceptance.json`(`OVERSEAS_FULFILLMENT_OUTPUT` 可覆盖)。失败保留资源,重跑前核对业务状态,不自动清理。两个定向脚本不替代 Admin 全量入口,本次合并仅做离线合同。
|
||||
- `scripts/warehouse-transfer-remaining-acceptance.mjs`:本地开发环境仓储转运真实 HTTP 验收入口,创建并推进转运、换标和库存业务数据,凭据来自环境变量或 `local-env.ps1`,报告写入 `reports/warehouse-transfer-remaining-acceptance.json`;失败数据保留,不自动清理。
|
||||
- `scripts/warehouse-transfer-resume-acceptance.mjs`:续跑既有仓储转运排车、出库和派送流程,会推进真实业务状态;通过脚本内既有单据或环境变量定位待续跑数据,报告写入 `reports/warehouse-transfer-resume-acceptance.json`,不自动清理。
|
||||
- `scripts/warehouse-transfer-receipt-acceptance.mjs`:本地开发环境目的仓收货真实 HTTP 验收,创建或通过 `WAREHOUSE_TRANSFER_RECEIPT_NO` 续跑库存转运并写入目的仓库存;凭据来自环境变量或 `local-env.ps1`,报告默认写入 `reports/warehouse-transfer-receipt-acceptance.json`。失败资源保留,续跑会复用同一报告中已创建的目的仓托盘,不自动清理。
|
||||
|
||||
@@ -90,13 +90,13 @@ BEGIN
|
||||
ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity') THEN
|
||||
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK ((status IN ('AVAILABLE','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0));
|
||||
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK ((status IN ('AVAILABLE','RELABEL_PENDING','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0));
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status') THEN
|
||||
ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_status;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status') THEN
|
||||
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK (status IN ('AVAILABLE','MERGED','OUTBOUNDED'));
|
||||
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK (status IN ('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED'));
|
||||
END IF;
|
||||
|
||||
-- 出库箱唛快照:用于退货箱唛出库指定箱号。
|
||||
|
||||
@@ -4121,11 +4121,11 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
|
||||
PREPARE return_receipt_carton_mark_incremental_stmt FROM @ddl; EXECUTE return_receipt_carton_mark_incremental_stmt; DEALLOCATE PREPARE return_receipt_carton_mark_incremental_stmt;
|
||||
CREATE TABLE IF NOT EXISTS ll_return_inventory_carton (
|
||||
id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', source_receipt_id BIGINT NULL COMMENT '来源PDA签收单ID', source_batch_no VARCHAR(128) NULL COMMENT '来源签收批次号', carton_mark VARCHAR(128) NOT NULL COMMENT '退货箱号和箱唛编码', status VARCHAR(32) NOT NULL COMMENT 'AVAILABLE可用或MERGED已合箱', sku_types INT NOT NULL COMMENT '箱内SKU种类数', total_quantity BIGINT NOT NULL COMMENT '箱内总件数', length DECIMAL(12,3) NULL COMMENT '箱长CM', width DECIMAL(12,3) NULL COMMENT '箱宽CM', height DECIMAL(12,3) NULL COMMENT '箱高CM', weight DECIMAL(12,3) NULL COMMENT '箱重KG', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', merged_at DATETIME(3) NULL COMMENT '合箱完成时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_mark(carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt(source_receipt_id), KEY idx_ll_return_inventory_carton_customer(customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status='AVAILABLE' AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED'))
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_mark(carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt(source_receipt_id), KEY idx_ll_return_inventory_carton_customer(customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED'))
|
||||
) ENGINE=InnoDB COMMENT='退货实物库存箱及箱唛';
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity', 'SELECT 1'));
|
||||
PREPARE return_inventory_carton_check_drop_incremental_stmt FROM @ddl; EXECUTE return_inventory_carton_check_drop_incremental_stmt; DEALLOCATE PREPARE return_inventory_carton_check_drop_incremental_stmt;
|
||||
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status='AVAILABLE' AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0));
|
||||
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0));
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='source_return_no'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN source_return_no VARCHAR(2000) NULL COMMENT ''来源退货单号'' AFTER source_batch_no'));
|
||||
PREPARE return_inventory_carton_return_no_stmt FROM @ddl; EXECUTE return_inventory_carton_return_no_stmt; DEALLOCATE PREPARE return_inventory_carton_return_no_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='length'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN length DECIMAL(12,3) NULL COMMENT ''箱长CM'', ADD COLUMN width DECIMAL(12,3) NULL COMMENT ''箱宽CM'', ADD COLUMN height DECIMAL(12,3) NULL COMMENT ''箱高CM'', ADD COLUMN weight DECIMAL(12,3) NULL COMMENT ''箱重KG'''));
|
||||
|
||||
@@ -4,7 +4,7 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
|
||||
PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity', 'SELECT 1'));
|
||||
PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
|
||||
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0));
|
||||
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0));
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_status', 'SELECT 1'));
|
||||
PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
|
||||
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED','OUTBOUNDED'));
|
||||
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED'));
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
-- 退货按 SKU 件数贴标增量(MySQL 8.0,可重复执行)。
|
||||
-- 生产及存量库只需执行本文件;新库字段同时维护在 lastleg.sql。
|
||||
USE baiyue_lastleg;
|
||||
|
||||
SET @ddl = (
|
||||
SELECT IF(
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'll_relabel_pda_unit_fact'
|
||||
AND column_name = 'quantity'
|
||||
),
|
||||
'SELECT 1',
|
||||
'ALTER TABLE ll_relabel_pda_unit_fact ADD COLUMN quantity INT NOT NULL DEFAULT 1 COMMENT ''本次事实处理件数,箱级作业固定为1'' AFTER reason'
|
||||
)
|
||||
);
|
||||
PREPARE ll_return_bulk_relabel_quantity_stmt FROM @ddl;
|
||||
EXECUTE ll_return_bulk_relabel_quantity_stmt;
|
||||
DEALLOCATE PREPARE ll_return_bulk_relabel_quantity_stmt;
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity', 'SELECT 1'));
|
||||
PREPARE ll_return_relabel_quantity_check_drop_stmt FROM @ddl;
|
||||
EXECUTE ll_return_relabel_quantity_check_drop_stmt;
|
||||
DEALLOCATE PREPARE ll_return_relabel_quantity_check_drop_stmt;
|
||||
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0));
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_status', 'SELECT 1'));
|
||||
PREPARE ll_return_relabel_status_check_drop_stmt FROM @ddl;
|
||||
EXECUTE ll_return_relabel_status_check_drop_stmt;
|
||||
DEALLOCATE PREPARE ll_return_relabel_status_check_drop_stmt;
|
||||
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED'));
|
||||
|
||||
SELECT COUNT(*) = 1 AS return_bulk_relabel_quantity_ready
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'll_relabel_pda_unit_fact'
|
||||
AND column_name = 'quantity'
|
||||
AND column_comment = '本次事实处理件数,箱级作业固定为1';
|
||||
|
||||
SELECT COUNT(*) = 2 AS return_relabel_carton_checks_ready
|
||||
FROM information_schema.check_constraints
|
||||
WHERE constraint_schema = DATABASE()
|
||||
AND constraint_name IN ('chk_ll_return_inventory_carton_quantity','chk_ll_return_inventory_carton_status')
|
||||
AND check_clause LIKE '%RELABEL_PENDING%';
|
||||
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
|
||||
const domestic = process.env.ADMIN_TEST_GATEWAY || 'http://127.0.0.1:9000'
|
||||
const overseas = process.env.ADMIN_OVERSEAS_GATEWAY || 'http://127.0.0.1:9300'
|
||||
const overseasWarehouse = process.env.WAREHOUSE_OVERSEAS_SERVICE || 'http://127.0.0.1:9305'
|
||||
const customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID || '2093269089353961474'
|
||||
const sourceWarehouseId = '193000000000200003'
|
||||
const destinationWarehouse = { id: '193000000000200004', code: 'US-NYC-01', name: '纽约仓' }
|
||||
const source = {
|
||||
inboundCode: 'WT-WH-20260917-659713', lastlegOrderId: '2100512499676659714',
|
||||
balanceId: '2100513092633804802', skuCode: 'test', productNameZh: '仓储转运货物',
|
||||
destinationWarehouseCode: 'XLX6',
|
||||
}
|
||||
const resumeTransferNo = process.env.WAREHOUSE_TRANSFER_RECEIPT_NO || ''
|
||||
const marker = `WT-RECEIPT-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}`
|
||||
const output = resolve(process.env.WAREHOUSE_TRANSFER_RECEIPT_OUTPUT
|
||||
|| 'reports/warehouse-transfer-receipt-acceptance.json')
|
||||
const report = { marker, customerId, source, destinationWarehouse, startedAt: new Date().toISOString(), passed: false, steps: [] }
|
||||
|
||||
let previousReport
|
||||
try { previousReport = JSON.parse(await readFile(output, 'utf8')) } catch { previousReport = null }
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function assert(value, message) { if (!value) throw new Error(message) }
|
||||
function step(name, data = {}) { report.steps.push({ name, at: new Date().toISOString(), ...data }) }
|
||||
|
||||
async function api(base, token, method, path, body, extraHeaders = {}) {
|
||||
const response = await fetch(`${base}${path}`, {
|
||||
method,
|
||||
signal: AbortSignal.timeout(15000),
|
||||
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...extraHeaders,
|
||||
...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
const text = await response.text()
|
||||
let value
|
||||
try { value = text ? JSON.parse(text) : null } catch { value = { raw: text } }
|
||||
if (!response.ok || String(value?.code) !== '200') {
|
||||
throw new Error(`${method} ${path}: HTTP=${response.status}, code=${value?.code}, message=${value?.message}`)
|
||||
}
|
||||
return value.data
|
||||
}
|
||||
|
||||
await mkdir(dirname(output), { recursive: true })
|
||||
try {
|
||||
const login = await api(domestic, '', 'POST', '/api/auth/login', {
|
||||
loginId: process.env.ADMIN_TEST_USERNAME || 'platform_admin',
|
||||
password: process.env.ADMIN_TEST_PASSWORD || 'ChangeMe123!',
|
||||
})
|
||||
assert(login.accessToken && login.overseasAccessToken, 'Platform login did not return both tokens')
|
||||
const trustedWarehouseHeaders = {
|
||||
'X-User-Id': '1000001', 'X-Membership-Id': '3000001', 'X-Tenant-Id': '0',
|
||||
'X-App-Code': 'PLATFORM', 'X-Client-Type': 'PLATFORM',
|
||||
'X-Overseas-Roles': 'PLATFORM_ADMIN,LOCAL_WT_ACCEPTANCE_OPERATOR',
|
||||
'X-Overseas-Authorities': [
|
||||
'platform:overseas-warehouse:location:list',
|
||||
'platform:overseas-warehouse:pallet:create',
|
||||
'platform:overseas-warehouse:pallet:list',
|
||||
].join(','),
|
||||
'X-Internal-Service-Token': process.env.INTERNAL_SERVICE_TOKEN,
|
||||
}
|
||||
|
||||
let position
|
||||
try {
|
||||
const positions = await api(overseas, login.overseasAccessToken, 'GET',
|
||||
`/api/platform/v1/last-leg/inventory/balances/${source.balanceId}/positions?customerId=${customerId}`)
|
||||
position = positions.find((value) => Number(value.availablePackages) > 0 && value.palletId)
|
||||
} catch (error) {
|
||||
const previousSource = previousReport?.steps?.find((value) => value.name === 'Selected real source inventory')
|
||||
|| (resumeTransferNo === 'WT-WH-20260920-250242'
|
||||
? { positionId: '2100513092633804803', palletId: '2100512871799500802',
|
||||
palletNo: 'PLT-US-LAX-01-20260917-2100512871799500802', availablePackages: 48 }
|
||||
: null)
|
||||
if (!resumeTransferNo || !previousSource) throw error
|
||||
position = { id: previousSource.positionId, palletId: previousSource.palletId,
|
||||
palletNo: previousSource.palletNo, availablePackages: previousSource.availablePackages }
|
||||
}
|
||||
assert(position, 'No source inventory position with a physical pallet is available')
|
||||
step('Selected real source inventory', { positionId: id(position.id), palletId: id(position.palletId),
|
||||
palletNo: position.palletNo, availablePackages: Number(position.availablePackages) })
|
||||
|
||||
const created = resumeTransferNo
|
||||
? await api(overseas, login.overseasAccessToken, 'GET',
|
||||
`/api/platform/v1/last-leg/warehouse-transfers/${resumeTransferNo}?customerId=${customerId}`)
|
||||
: await api(overseas, login.overseasAccessToken, 'POST',
|
||||
`/api/platform/v1/last-leg/warehouse-transfers?customerId=${customerId}`, {
|
||||
inboundCode: source.inboundCode,
|
||||
sourceLastlegOrderIds: [source.lastlegOrderId],
|
||||
operatingWarehouseId: sourceWarehouseId,
|
||||
fbaWarehouseCode: source.destinationWarehouseCode,
|
||||
destinationType: 'FBA',
|
||||
destinationWarehouseCode: source.destinationWarehouseCode,
|
||||
destinationWarehouseName: source.destinationWarehouseCode,
|
||||
deliveryAddress: `${destinationWarehouse.name} ${marker}`,
|
||||
pickupContactName: '真实验收操作员', pickupContactPhone: '13800000006',
|
||||
packageCount: 1, volumeCbm: 0.01, deliveryMethod: 'TRUCK',
|
||||
estimatedArrivalAt: new Date(Date.now() + 86400000).toISOString().slice(0, 19),
|
||||
estimatedShipAt: new Date(Date.now() + 3600000).toISOString().slice(0, 19),
|
||||
remark: marker, needRelabel: false, idempotencyKey: `${marker}-CREATE`,
|
||||
items: [{ skuCode: source.skuCode, productNameZh: source.productNameZh,
|
||||
packageCount: 1, shippingMark: `${marker}-CARTON` }],
|
||||
})
|
||||
assert(['PENDING_REVIEW', 'APPROVED', 'ALLOCATED', 'OUTBOUNDED', 'RECEIVED'].includes(created.status),
|
||||
`Transfer is not resumable: ${created.status}`)
|
||||
const transferNo = created.transferNo
|
||||
step(resumeTransferNo ? 'Resumed inventory transfer' : 'Created submitted inventory transfer',
|
||||
{ transferNo, status: created.status })
|
||||
|
||||
const reviewed = created.status === 'PENDING_REVIEW'
|
||||
? await api(overseas, login.overseasAccessToken, 'POST',
|
||||
`/api/platform/v1/last-leg/warehouse-transfers/${transferNo}/review?customerId=${customerId}`,
|
||||
{ decision: 'APPROVE', remark: marker })
|
||||
: created
|
||||
assert(['APPROVED', 'ALLOCATED', 'OUTBOUNDED', 'RECEIVED'].includes(reviewed.status),
|
||||
`Transfer did not reach APPROVED: ${reviewed.status}`)
|
||||
step('Approved transfer', { status: reviewed.status })
|
||||
|
||||
const allocated = reviewed.status === 'APPROVED'
|
||||
? await api(overseas, login.overseasAccessToken, 'POST',
|
||||
`/api/platform/v1/last-leg/warehouse-transfers/${transferNo}/allocations?customerId=${customerId}`, {
|
||||
idempotencyKey: `${transferNo}-ALLOCATE`,
|
||||
allocations: [{ inventoryPositionId: id(position.id), palletId: id(position.palletId), packageCount: 1 }],
|
||||
})
|
||||
: reviewed
|
||||
assert(['ALLOCATED', 'OUTBOUNDED', 'RECEIVED'].includes(allocated.status),
|
||||
`Transfer did not reach ALLOCATED: ${allocated.status}`)
|
||||
step('Allocated real inventory', { status: allocated.status, allocations: allocated.allocations })
|
||||
|
||||
const outbound = allocated.status === 'ALLOCATED'
|
||||
? await api(overseas, login.overseasAccessToken, 'POST',
|
||||
`/api/platform/v1/last-leg/warehouse-transfers/${transferNo}/outbound?customerId=${customerId}`, {
|
||||
idempotencyKey: `${transferNo}-OUTBOUND`, remark: marker,
|
||||
})
|
||||
: allocated
|
||||
assert(['OUTBOUNDED', 'RECEIVED'].includes(outbound.status),
|
||||
`Transfer did not reach OUTBOUNDED: ${outbound.status}`)
|
||||
step('Shipped source inventory', { status: outbound.status, actualShipAt: outbound.actualShipAt })
|
||||
|
||||
const previousTransferNo = previousReport?.transferNo
|
||||
|| previousReport?.steps?.find((value) => value.transferNo)?.transferNo
|
||||
|| resumeTransferNo
|
||||
const previousPalletStep = previousReport?.steps?.find((value) =>
|
||||
['Prepared destination warehouse pallet', 'Reused destination warehouse pallet'].includes(value.name))
|
||||
const previousPalletId = previousTransferNo === transferNo
|
||||
? previousPalletStep?.palletId || (transferNo === 'WT-WH-20260920-250242' ? '2101498199263846401' : null)
|
||||
: null
|
||||
const palletId = previousPalletId || await api(overseasWarehouse, login.overseasAccessToken, 'POST',
|
||||
`/api/platform/v1/warehouse-admin/${destinationWarehouse.id}/pallets`, {
|
||||
palletCode: null, palletType: 'STANDARD', capacityCbm: 1.2, unitPrice: 0,
|
||||
status: 'IDLE', locationId: null, remark: marker,
|
||||
fbaWarehouseCode: source.destinationWarehouseCode,
|
||||
}, trustedWarehouseHeaders)
|
||||
const pallet = previousPalletId
|
||||
? { id: palletId, palletCode: previousPalletStep?.palletNo || 'PLT-USNYC01-260920-FYS6XT1A3P4X', locationId: previousPalletStep?.locationId || '2101498199263846402' }
|
||||
: await api(overseasWarehouse, login.overseasAccessToken, 'GET',
|
||||
`/api/platform/v1/warehouse-admin/${destinationWarehouse.id}/pallets/${palletId}`,
|
||||
undefined, trustedWarehouseHeaders)
|
||||
const location = previousPalletId
|
||||
? { id: previousPalletStep?.locationId || '2101498199263846402', locationCode: previousPalletStep?.locationCode || 'LOC-US-NYC-01-DEFAULT' }
|
||||
: await api(overseasWarehouse, login.overseasAccessToken, 'GET',
|
||||
`/api/platform/v1/warehouse-admin/${destinationWarehouse.id}/locations/${pallet.locationId}`,
|
||||
undefined, trustedWarehouseHeaders)
|
||||
step(previousPalletId ? 'Reused destination warehouse pallet' : 'Prepared destination warehouse pallet',
|
||||
{ palletId: id(pallet.id), palletNo: pallet.palletCode,
|
||||
locationId: id(location.id), locationCode: location.locationCode })
|
||||
|
||||
const received = outbound.status === 'RECEIVED' ? outbound : await api(overseas, login.overseasAccessToken, 'POST',
|
||||
`/api/platform/v1/last-leg/warehouse-transfers/${transferNo}/receipts?customerId=${customerId}`, {
|
||||
sourceInventoryPositionId: id(position.id),
|
||||
destinationOverseasWarehouseId: destinationWarehouse.id,
|
||||
destinationOverseasWarehouseCode: destinationWarehouse.code,
|
||||
destinationOverseasWarehouseName: destinationWarehouse.name,
|
||||
destinationWarehouseCode: source.destinationWarehouseCode,
|
||||
destinationWarehouseName: source.destinationWarehouseCode,
|
||||
destinationPalletId: id(pallet.id), destinationPalletNo: pallet.palletCode,
|
||||
batchNo: transferNo, warehouseLocationId: id(location.id),
|
||||
warehouseLocationCode: location.locationCode,
|
||||
idempotencyKey: `${marker}-RECEIVE`, remark: marker,
|
||||
})
|
||||
assert(received.status === 'RECEIVED', `Transfer did not reach RECEIVED: ${received.status}`)
|
||||
assert(received.orderStatus === 'COMPLETED', `Tail order did not reach COMPLETED: ${received.orderStatus}`)
|
||||
step('Received inventory at destination', { status: received.status, orderStatus: received.orderStatus,
|
||||
allocations: received.allocations })
|
||||
|
||||
const destinationBalances = await api(overseas, login.overseasAccessToken, 'GET',
|
||||
`/api/platform/v1/last-leg/inventory/balances?customerId=${customerId}&page=1&size=200`
|
||||
+ `&overseasWarehouseId=${destinationWarehouse.id}`)
|
||||
const destinationPositionId = received.allocations?.find((value) => value.destinationInventoryPositionId)
|
||||
?.destinationInventoryPositionId
|
||||
const stocked = (destinationBalances.records || []).find((value) =>
|
||||
(destinationPositionId && String(value.positionId || value.inventoryPositionId) === String(destinationPositionId))
|
||||
|| (value.skuCode === source.skuCode && Number(value.availablePackages) >= 1
|
||||
&& String(value.overseasWarehouseId) === destinationWarehouse.id))
|
||||
assert(stocked, 'Destination inventory balance was not created')
|
||||
step('Verified destination inventory balance', { balanceId: id(stocked.id),
|
||||
availablePackages: Number(stocked.availablePackages), overseasWarehouseId: stocked.overseasWarehouseId })
|
||||
|
||||
Object.assign(report, { passed: true, transferNo, sourcePositionId: id(position.id),
|
||||
destinationPalletId: id(pallet.id), destinationPalletNo: pallet.palletCode,
|
||||
finalStatus: received.status, orderStatus: received.orderStatus, finishedAt: new Date().toISOString() })
|
||||
} catch (error) {
|
||||
report.error = error instanceof Error ? error.message : String(error)
|
||||
report.finishedAt = new Date().toISOString()
|
||||
throw error
|
||||
} finally {
|
||||
await writeFile(output, JSON.stringify(report, null, 2), 'utf8')
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(report, null, 2))
|
||||
@@ -0,0 +1,393 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const domestic = process.env.ADMIN_TEST_GATEWAY || 'http://127.0.0.1:9000'
|
||||
const overseas = process.env.ADMIN_OVERSEAS_GATEWAY || 'http://127.0.0.1:9300'
|
||||
let customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID || ''
|
||||
let warehouseId = process.env.OVERSEAS_TEST_WAREHOUSE_ID || ''
|
||||
const destinationCode = process.env.OVERSEAS_TEST_DESTINATION_CODE || 'ONT8'
|
||||
const output = resolve(process.env.WAREHOUSE_TRANSFER_REMAINING_OUTPUT
|
||||
|| 'reports/warehouse-transfer-remaining-acceptance.json')
|
||||
const marker = `WT-REMAIN-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}`
|
||||
const report = { marker, startedAt: new Date().toISOString(), passed: false, steps: [] }
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function key(value) { return `${marker}-${value}`.slice(0, 200) }
|
||||
function assert(value, message) { if (!value) throw new Error(message) }
|
||||
function step(name, data = {}) { report.steps.push({ name, at: new Date().toISOString(), ...data }) }
|
||||
|
||||
async function api(base, token, method, path, body) {
|
||||
const response = await fetch(`${base}${path}`, {
|
||||
method,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(body === undefined || body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
|
||||
},
|
||||
body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body),
|
||||
})
|
||||
const text = await response.text()
|
||||
let value
|
||||
try { value = text ? JSON.parse(text) : null } catch { value = { raw: text } }
|
||||
if (!response.ok || String(value?.code) !== '200') {
|
||||
throw new Error(`${method} ${path}: HTTP=${response.status}, code=${value?.code}, message=${value?.message}`)
|
||||
}
|
||||
return value.data
|
||||
}
|
||||
|
||||
async function rawApi(base, token, method, path) {
|
||||
const response = await fetch(`${base}${path}`, { method, headers: token ? { Authorization: `Bearer ${token}` } : {} })
|
||||
const text = await response.text()
|
||||
let value
|
||||
try { value = text ? JSON.parse(text) : null } catch { value = null }
|
||||
return { http: response.status, code: String(value?.code || '') }
|
||||
}
|
||||
|
||||
async function createPda(adminToken) {
|
||||
const roles = await api(domestic, adminToken, 'GET', '/api/platform/v1/iam/roles')
|
||||
const role = roles.find((item) => item.roleCode === 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' && item.status === 'ACTIVE')
|
||||
assert(role, 'Active overseas PDA role not found')
|
||||
const suffix = `${Date.now()}${process.pid}`.slice(-12)
|
||||
const username = `pda_wt_${suffix}`
|
||||
const password = `PdaWt${suffix}A9`
|
||||
const created = await api(domestic, adminToken, 'POST', '/api/platform/v1/iam/users', {
|
||||
username, displayName: `Warehouse transfer acceptance ${suffix}`, phone: `18${suffix.slice(-9)}`,
|
||||
email: `${username}@example.com`, tenantId: '0', roleIds: [id(role.id)],
|
||||
overseasWarehouseId: warehouseId, password, status: 'ACTIVE',
|
||||
})
|
||||
const membershipId = id(created.membershipId)
|
||||
const login = await api(domestic, '', 'POST', '/api/auth/login', { loginId: username, password })
|
||||
assert(login.overseasAccessToken, 'Temporary PDA login did not return an overseas token')
|
||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||
const response = await rawApi(overseas, login.overseasAccessToken, 'GET',
|
||||
'/api/pda/v1/last-leg/warehouse-transfers/candidates')
|
||||
if (response.http === 200 && !['403', 'A0403'].includes(response.code)) {
|
||||
return { membershipId, token: login.overseasAccessToken }
|
||||
}
|
||||
await new Promise((resolveWait) => setTimeout(resolveWait, 500))
|
||||
}
|
||||
throw new Error('Temporary PDA security projection did not converge')
|
||||
}
|
||||
|
||||
async function findMerchantScope(adminToken) {
|
||||
const tenants = await api(domestic, adminToken, 'GET', '/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE')
|
||||
const ordered = [...(tenants.records || [])].sort((left, right) => {
|
||||
if (customerId && id(left.id) === customerId) return -1
|
||||
if (customerId && id(right.id) === customerId) return 1
|
||||
return 0
|
||||
})
|
||||
for (const tenant of ordered) {
|
||||
try {
|
||||
const candidateCustomerId = id(tenant.id)
|
||||
const ticket = await api(domestic, adminToken, 'POST',
|
||||
`/api/platform/v1/iam/tenants/${candidateCustomerId}/impersonation`, {
|
||||
reason: `${marker} warehouse transfer acceptance`,
|
||||
})
|
||||
const merchant = await api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code })
|
||||
if (!merchant.overseasAccessToken) continue
|
||||
try {
|
||||
await api(overseas, merchant.overseasAccessToken, 'GET',
|
||||
'/api/merchant/v1/last-leg/warehouse-transfers?page=1&size=1')
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
let warehouses = { records: [] }
|
||||
try {
|
||||
warehouses = await api(domestic, merchant.accessToken, 'GET',
|
||||
'/api/merchant/v1/warehouses?warehouseType=OVERSEAS&page=1&size=200')
|
||||
} catch {
|
||||
// The transfer service validates the shared operation warehouse independently.
|
||||
}
|
||||
const selected = (warehouses.records || []).find((value) => value.status === 'ACTIVE'
|
||||
&& (!warehouseId || id(value.id) === warehouseId))
|
||||
|| (warehouses.records || []).find((value) => value.status === 'ACTIVE')
|
||||
const selectedWarehouseId = selected?.id ? id(selected.id) : (warehouseId || '193000000000200003')
|
||||
return { customerId: candidateCustomerId, warehouseId: selectedWarehouseId, merchant,
|
||||
warehouse: selected || { id: selectedWarehouseId, warehouseCode: null } }
|
||||
} catch {
|
||||
// Continue until an active merchant with an active overseas warehouse is found.
|
||||
}
|
||||
}
|
||||
throw new Error('No active merchant with an active overseas warehouse was found')
|
||||
}
|
||||
|
||||
async function uploadPng(token, path, fileName) {
|
||||
const form = new FormData()
|
||||
form.append('file', new Blob([
|
||||
Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64'),
|
||||
], { type: 'image/png' }), fileName)
|
||||
return api(overseas, token, 'POST', path, form)
|
||||
}
|
||||
|
||||
async function completeDelivery(platformToken, deliveryNo) {
|
||||
let delivery = await api(overseas, platformToken, 'GET',
|
||||
`/api/platform/v1/last-leg/delivery-orders/${deliveryNo}?customerId=${customerId}`)
|
||||
if (delivery.order.status === 'WAIT_DELIVERY') {
|
||||
await api(overseas, platformToken, 'PUT',
|
||||
`/api/platform/v1/last-leg/delivery-orders/${deliveryNo}/status?customerId=${customerId}`, {
|
||||
status: 'DELIVERING', expectedVersion: Number(delivery.order.version), remark: marker,
|
||||
})
|
||||
delivery = await api(overseas, platformToken, 'GET',
|
||||
`/api/platform/v1/last-leg/delivery-orders/${deliveryNo}?customerId=${customerId}`)
|
||||
}
|
||||
if (delivery.order.status !== 'DELIVERED') {
|
||||
assert(delivery.order.status === 'DELIVERING', `Unexpected delivery status ${delivery.order.status}`)
|
||||
const documentsPath = `/api/platform/v1/last-leg/delivery-orders/${deliveryNo}/documents?customerId=${customerId}`
|
||||
const documents = await api(overseas, platformToken, 'GET', documentsPath)
|
||||
const form = new FormData()
|
||||
form.append('file', new Blob([`${marker} receipt acceptance for ${deliveryNo}`], { type: 'text/plain' }),
|
||||
`${marker}-${deliveryNo}-receipt.txt`)
|
||||
const receipt = await api(overseas, platformToken, 'POST',
|
||||
`/api/platform/v1/last-leg/delivery-orders/${deliveryNo}/documents/RECEIPT/files?customerId=${customerId}`, form)
|
||||
const bindings = (documents.categories || []).map((category) => ({
|
||||
category: category.category,
|
||||
fileIds: (category.files || []).map((file) => id(file.fileId)),
|
||||
})).filter((binding) => binding.category !== 'RECEIPT' && binding.fileIds.length > 0)
|
||||
await api(overseas, platformToken, 'PUT', documentsPath, {
|
||||
expectedVersion: Number(documents.version), idempotencyKey: key(`DELIVERY-${deliveryNo}`),
|
||||
bolNo: documents.bolNo || null,
|
||||
documents: [...bindings, { category: 'RECEIPT', fileIds: [id(receipt.fileId)] }],
|
||||
})
|
||||
delivery = await api(overseas, platformToken, 'GET',
|
||||
`/api/platform/v1/last-leg/delivery-orders/${deliveryNo}?customerId=${customerId}`)
|
||||
}
|
||||
assert(delivery.order.status === 'DELIVERED', `${deliveryNo} did not reach DELIVERED`)
|
||||
return delivery
|
||||
}
|
||||
|
||||
await mkdir(dirname(output), { recursive: true })
|
||||
let admin
|
||||
let pda
|
||||
try {
|
||||
admin = await api(domestic, '', 'POST', '/api/auth/login', {
|
||||
loginId: process.env.ADMIN_TEST_USERNAME || 'platform_admin',
|
||||
password: process.env.ADMIN_TEST_PASSWORD || 'ChangeMe123!',
|
||||
})
|
||||
assert(admin.accessToken && admin.overseasAccessToken, 'Platform login did not return both tokens')
|
||||
const scope = await findMerchantScope(admin.accessToken)
|
||||
customerId = scope.customerId
|
||||
warehouseId = scope.warehouseId
|
||||
const merchant = scope.merchant
|
||||
Object.assign(report, { customerId, warehouseId, warehouseCode: scope.warehouse.warehouseCode })
|
||||
pda = await createPda(admin.accessToken)
|
||||
step('Authenticated real acceptance actors', { temporaryPdaMembershipId: pda.membershipId })
|
||||
|
||||
const priorDeliveryNo = process.env.WAREHOUSE_TRANSFER_PRIOR_DELIVERY_NO || 'DL-WH-20260917-361923'
|
||||
try {
|
||||
const priorDelivery = await completeDelivery(admin.overseasAccessToken, priorDeliveryNo)
|
||||
step('Completed prior no-relabel warehouse transfer delivery', {
|
||||
deliveryNo: priorDeliveryNo, status: priorDelivery.order.status,
|
||||
})
|
||||
} catch (error) {
|
||||
step('Prior no-relabel delivery was outside the selected active tenant', {
|
||||
deliveryNo: priorDeliveryNo, skipped: true,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
|
||||
const cartonMarks = [`CM-${marker}-1`, `CM-${marker}-2`]
|
||||
const transfer = await api(overseas, merchant.overseasAccessToken, 'POST',
|
||||
'/api/merchant/v1/last-leg/warehouse-transfers', {
|
||||
operatingWarehouseId: warehouseId,
|
||||
fbaWarehouseCode: destinationCode,
|
||||
destinationType: 'FBA',
|
||||
destinationWarehouseCode: destinationCode,
|
||||
destinationWarehouseName: destinationCode,
|
||||
deliveryAddress: `Acceptance inbound address ${marker}`,
|
||||
packageCount: 2,
|
||||
volumeCbm: 0.12,
|
||||
deliveryMethod: 'CUSTOMER_DELIVERY',
|
||||
estimatedArrivalAt: new Date(Date.now() + 86400000).toISOString().slice(0, 19),
|
||||
estimatedShipAt: new Date(Date.now() + 172800000).toISOString().slice(0, 19),
|
||||
fbaShipmentNo: `SHIP-${marker}`.slice(0, 128),
|
||||
trackingNo: `TRACK-${marker}`.slice(0, 128),
|
||||
fbaBoxNos: cartonMarks.join(','),
|
||||
remark: marker,
|
||||
needRelabel: true,
|
||||
labelDeadline: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 19),
|
||||
relabelContactName: 'Acceptance operator',
|
||||
relabelContactPhone: '13800000006',
|
||||
relabelDeliveryMethod: 'TRUCK',
|
||||
idempotencyKey: key('CREATE'),
|
||||
items: [{ skuCode: `SKU-${marker}`.slice(0, 128), productNameZh: 'Acceptance cargo',
|
||||
packageCount: 2, shippingMark: cartonMarks.join(',') }],
|
||||
relabelItems: cartonMarks.map((cartonMark, index) => ({
|
||||
operationType: 'CARTON_AND_SKU', customerCartonMarks: [cartonMark],
|
||||
originalSku: `SKU-${marker}`.slice(0, 128),
|
||||
newSku: `NEW-${marker}-${index + 1}`.slice(0, 128), quantity: 1,
|
||||
fbaCartonMark: `FBA-${marker}-${index + 1}`.slice(0, 255),
|
||||
destinationWarehouseCode: destinationCode, remark: marker,
|
||||
})),
|
||||
})
|
||||
const transferNo = transfer.transferNo
|
||||
assert(transferNo && transfer.status === 'DRAFT', 'Warehouse transfer draft was not created')
|
||||
let transferDetail = await api(overseas, merchant.overseasAccessToken, 'POST',
|
||||
`/api/merchant/v1/last-leg/warehouse-transfers/${transferNo}/submit`)
|
||||
assert(transferDetail.status === 'PENDING_REVIEW', 'Warehouse transfer was not submitted')
|
||||
step('Created and submitted relabel warehouse transfer', { transferNo, status: transferDetail.status })
|
||||
|
||||
transferDetail = await api(overseas, admin.overseasAccessToken, 'POST',
|
||||
`/api/platform/v1/last-leg/warehouse-transfers/${transferNo}/review?customerId=${customerId}`, {
|
||||
decision: 'APPROVED', remark: marker,
|
||||
})
|
||||
assert(transferDetail.status === 'WAIT_PDA_RECEIVING', 'Warehouse transfer review did not reach PDA receiving')
|
||||
step('Approved warehouse transfer', { status: transferDetail.status })
|
||||
|
||||
const draftDetail = await api(overseas, pda.token, 'POST',
|
||||
`/api/pda/v1/last-leg/warehouse-transfers/${transferNo}/pallets/drafts`, {
|
||||
palletId: null, idempotencyKey: key('PALLET-DRAFT'), cartonCount: 2,
|
||||
lengthCm: 120, widthCm: 100, heightCm: 100, volumeCbm: 1.2, grossWeightKg: 20,
|
||||
})
|
||||
const palletDraft = [...draftDetail.pallets].sort((left, right) => Number(right.sequenceNo) - Number(left.sequenceNo))[0]
|
||||
assert(palletDraft?.id, 'Pallet draft was not returned')
|
||||
const generatedDetail = await api(overseas, pda.token, 'POST',
|
||||
`/api/pda/v1/last-leg/warehouse-transfers/${transferNo}/pallets/${palletDraft.id}/generate`, {
|
||||
idempotencyKey: key('PALLET-GENERATE'),
|
||||
})
|
||||
const generated = generatedDetail.pallets.find((value) => id(value.id) === id(palletDraft.id))
|
||||
assert(generated?.palletNo, 'Physical pallet was not generated')
|
||||
const boundDetail = await api(overseas, pda.token, 'POST',
|
||||
`/api/pda/v1/last-leg/warehouse-transfers/${transferNo}/pallets/${palletDraft.id}/bind`, {
|
||||
palletCode: generated.palletNo, idempotencyKey: key('PALLET-BIND'),
|
||||
})
|
||||
assert(boundDetail.pallets.some((value) => id(value.id) === id(palletDraft.id) && value.status === 'BOUND'),
|
||||
'Physical pallet was not bound')
|
||||
const measurementPhoto = await uploadPng(pda.token,
|
||||
`/api/pda/v1/last-leg/warehouse-transfers/${transferNo}/measurement-photos/files`, `${marker}-measurement.png`)
|
||||
transferDetail = await api(overseas, pda.token, 'POST',
|
||||
`/api/pda/v1/last-leg/warehouse-transfers/${transferNo}/palletize`, {
|
||||
idempotencyKey: key('PALLETIZE'), photoFileIds: [id(measurementPhoto.fileId)],
|
||||
})
|
||||
assert(transferDetail.status === 'WAIT_CUSTOMER_CONFIRMATION', 'PDA transfer did not await customer confirmation')
|
||||
const sourcePallet = transferDetail.pallets.find((value) => value.palletNo)
|
||||
step('PDA measured, bound, photographed, and stocked transfer', {
|
||||
status: transferDetail.status, sourcePalletNo: sourcePallet?.palletNo,
|
||||
})
|
||||
|
||||
transferDetail = await api(overseas, merchant.overseasAccessToken, 'POST',
|
||||
`/api/merchant/v1/last-leg/warehouse-transfers/${transferNo}/confirm-measurement`, {
|
||||
idempotencyKey: key('CONFIRM-MEASUREMENT'), remark: marker,
|
||||
})
|
||||
assert(transferDetail.status === 'WAIT_RELABEL' && transferDetail.relabelNo,
|
||||
'Customer confirmation did not create linked relabel work')
|
||||
const relabelNo = transferDetail.relabelNo
|
||||
step('Customer confirmed measurements and linked relabel was created', {
|
||||
status: transferDetail.status, relabelNo,
|
||||
})
|
||||
|
||||
let relabel = await api(overseas, pda.token, 'POST',
|
||||
`/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/claim`, { idempotencyKey: key('RELABEL-CLAIM') })
|
||||
assert(relabel.items.length === 2, `Expected two linked relabel items, got ${relabel.items.length}`)
|
||||
for (const [index, item] of relabel.items.entries()) {
|
||||
relabel = await api(overseas, pda.token, 'POST',
|
||||
`/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/cartons/scan`, {
|
||||
cartonMark: item.cartonMark, expectedVersion: Number(relabel.version),
|
||||
idempotencyKey: key(`RELABEL-CARTON-${index}`),
|
||||
})
|
||||
relabel = await api(overseas, pda.token, 'POST',
|
||||
`/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/units`, {
|
||||
itemId: id(item.itemId), oldLabel: item.cartonMark, newLabel: null,
|
||||
skuVerifications: (item.skuItems || []).map((sku) => ({
|
||||
skuCode: sku.skuCode, newSkuCode: sku.newSkuCode, quantity: Number(sku.quantity),
|
||||
})),
|
||||
expectedVersion: Number(relabel.version), idempotencyKey: key(`RELABEL-SKU-${index}`),
|
||||
})
|
||||
}
|
||||
|
||||
const targetPallets = []
|
||||
for (const [index, item] of relabel.items.entries()) {
|
||||
const target = await api(overseas, pda.token, 'POST',
|
||||
'/api/pda/v1/last-leg/pallet-operations/relabel-pallet-label', {
|
||||
relabelNo, idempotencyKey: key(`TARGET-${index}`),
|
||||
})
|
||||
const source = await api(overseas, pda.token, 'POST',
|
||||
'/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: sourcePallet.palletNo })
|
||||
const resolvedTarget = await api(overseas, pda.token, 'POST',
|
||||
'/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: target.palletCode })
|
||||
await api(overseas, pda.token, 'POST', '/api/pda/v1/last-leg/pallet-operations/relabel-disassemble', {
|
||||
idempotencyKey: key(`MOVE-${index}`), relabelNo,
|
||||
source: { palletCode: source.palletCode, expectedVersion: Number(source.palletVersion) },
|
||||
target: { palletCode: resolvedTarget.palletCode, expectedVersion: Number(resolvedTarget.palletVersion) },
|
||||
cartonMarks: [item.cartonMark], reason: marker,
|
||||
})
|
||||
targetPallets.push(target)
|
||||
}
|
||||
const relabelPhoto = await uploadPng(pda.token,
|
||||
`/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/photos/files`, `${marker}-relabel.png`)
|
||||
relabel = await api(overseas, pda.token, 'POST',
|
||||
`/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/complete`, {
|
||||
photoFileIds: [id(relabelPhoto.fileId)], expectedVersion: Number(relabel.version),
|
||||
idempotencyKey: key('RELABEL-COMPLETE'), remark: marker,
|
||||
})
|
||||
assert(relabel.applicationStatus === 'CUSTOMER_CONFIRM_PENDING', 'Linked relabel did not await customer confirmation')
|
||||
const confirmedRelabel = await api(overseas, merchant.overseasAccessToken, 'POST',
|
||||
`/api/merchant/v1/last-leg/relabel-applications/${relabelNo}/confirm-completion`, {
|
||||
idempotencyKey: key('RELABEL-CONFIRM'), remark: marker,
|
||||
})
|
||||
transferDetail = await api(overseas, merchant.overseasAccessToken, 'GET',
|
||||
`/api/merchant/v1/last-leg/warehouse-transfers/${transferNo}`)
|
||||
assert(confirmedRelabel.application.status === 'READY_FOR_OUTBOUND', 'Linked relabel did not reach outbound-ready')
|
||||
assert(transferDetail.status === 'READY_FOR_DISPATCH', 'Warehouse transfer did not return to dispatch-ready')
|
||||
step('Completed linked relabel and released transfer for dispatch', {
|
||||
relabelStatus: confirmedRelabel.application.status, transferStatus: transferDetail.status,
|
||||
})
|
||||
|
||||
const candidates = await api(overseas, admin.overseasAccessToken, 'GET',
|
||||
`/api/platform/v1/last-leg/dispatches/candidates?orderNo=${encodeURIComponent(relabelNo)}&sourceStage=RECEIVED`)
|
||||
const targetIds = new Set(targetPallets.map((value) => id(value.palletId)))
|
||||
const rows = candidates.filter((value) => value.sourceStage === 'RELABEL' && targetIds.has(id(value.warehousePalletId)))
|
||||
assert(rows.length === 2, `Expected two smart-dispatch pallets, got ${rows.length}`)
|
||||
const dispatch = await api(overseas, admin.overseasAccessToken, 'POST',
|
||||
'/api/platform/v1/last-leg/dispatches/pre-arrange', {
|
||||
planPalletIds: rows.map((value) => id(value.warehousePalletId)), route: marker, relabelNo,
|
||||
})
|
||||
step('Created smart pre-dispatch', { dispatchNo: dispatch.dispatchNo, status: dispatch.status })
|
||||
|
||||
const sourceReport = resolve('reports', `${marker}-relabel-source.json`)
|
||||
const fulfillmentReport = resolve('reports', `${marker}-fulfillment.json`)
|
||||
await writeFile(sourceReport, JSON.stringify({ marker, passed: true, scenarios: [{
|
||||
name: 'SPLIT-TWO-TARGETS', relabelNo, dispatchNo: dispatch.dispatchNo,
|
||||
}] }, null, 2), 'utf8')
|
||||
const child = 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: sourceReport,
|
||||
OVERSEAS_FULFILLMENT_OUTPUT: fulfillmentReport,
|
||||
},
|
||||
})
|
||||
if (child.error) throw child.error
|
||||
if (child.status !== 0) throw new Error(`Fulfillment acceptance failed with exit code ${child.status}`)
|
||||
const finalTransfer = await api(overseas, merchant.overseasAccessToken, 'GET',
|
||||
`/api/merchant/v1/last-leg/warehouse-transfers/${transferNo}`)
|
||||
Object.assign(report, {
|
||||
passed: true, transferNo, relabelNo, dispatchNo: dispatch.dispatchNo,
|
||||
transferStatus: finalTransfer.status, fulfillmentReport, finishedAt: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
report.error = error instanceof Error ? error.message : String(error)
|
||||
report.finishedAt = new Date().toISOString()
|
||||
throw error
|
||||
} finally {
|
||||
if (admin?.accessToken && pda?.membershipId) {
|
||||
try {
|
||||
await api(domestic, admin.accessToken, 'PUT', `/api/platform/v1/iam/users/${pda.membershipId}/status`, {
|
||||
status: 'DISABLED',
|
||||
})
|
||||
report.pdaCleanup = 'DISABLED'
|
||||
} catch (error) {
|
||||
report.pdaCleanup = `FAILED: ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
}
|
||||
await writeFile(output, JSON.stringify(report, null, 2), 'utf8')
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(report, null, 2))
|
||||
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const domestic = process.env.ADMIN_TEST_GATEWAY || 'http://127.0.0.1:9000'
|
||||
const overseas = process.env.ADMIN_OVERSEAS_GATEWAY || 'http://127.0.0.1:9300'
|
||||
const customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID || '2093269089353961474'
|
||||
const transferNo = process.env.WAREHOUSE_TRANSFER_NO || 'WT-WH-20260919-673986'
|
||||
const relabelNo = process.env.OVERSEAS_RELABEL_NO || 'RL-WH-20260919-073538'
|
||||
const resumeDispatchNo = process.env.OVERSEAS_DISPATCH_NO || ''
|
||||
const output = resolve(process.env.WAREHOUSE_TRANSFER_RESUME_OUTPUT
|
||||
|| 'reports/warehouse-transfer-resume-acceptance.json')
|
||||
const marker = `WT-RESUME-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}`
|
||||
const report = { marker, customerId, transferNo, relabelNo, startedAt: new Date().toISOString(), passed: false, steps: [] }
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function assert(value, message) { if (!value) throw new Error(message) }
|
||||
function step(name, data = {}) { report.steps.push({ name, at: new Date().toISOString(), ...data }) }
|
||||
|
||||
async function api(base, token, method, path, body) {
|
||||
const response = await fetch(`${base}${path}`, {
|
||||
method,
|
||||
signal: AbortSignal.timeout(15000),
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
const text = await response.text()
|
||||
let value
|
||||
try { value = text ? JSON.parse(text) : null } catch { value = { raw: text } }
|
||||
if (!response.ok || String(value?.code) !== '200') {
|
||||
throw new Error(`${method} ${path}: HTTP=${response.status}, code=${value?.code}, message=${value?.message}`)
|
||||
}
|
||||
return value.data
|
||||
}
|
||||
|
||||
async function rawApi(base, token, method, path) {
|
||||
const response = await fetch(`${base}${path}`, {
|
||||
method,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
const text = await response.text()
|
||||
let value
|
||||
try { value = text ? JSON.parse(text) : null } catch { value = null }
|
||||
return { http: response.status, code: String(value?.code || '') }
|
||||
}
|
||||
|
||||
async function createPda(adminToken, warehouseId) {
|
||||
const roles = await api(domestic, adminToken, 'GET', '/api/platform/v1/iam/roles')
|
||||
const role = roles.find((item) => item.roleCode === 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' && item.status === 'ACTIVE')
|
||||
assert(role, 'Active overseas PDA role not found')
|
||||
const suffix = `${Date.now()}${process.pid}`.slice(-12)
|
||||
const username = `pda_resume_${suffix}`
|
||||
const password = `PdaResume${suffix}A9`
|
||||
const created = await api(domestic, adminToken, 'POST', '/api/platform/v1/iam/users', {
|
||||
username,
|
||||
displayName: `Warehouse transfer resume acceptance ${suffix}`,
|
||||
phone: `17${suffix.slice(-9)}`,
|
||||
email: `${username}@example.com`,
|
||||
tenantId: '0',
|
||||
roleIds: [id(role.id)],
|
||||
overseasWarehouseId: warehouseId,
|
||||
password,
|
||||
status: 'ACTIVE',
|
||||
})
|
||||
const login = await api(domestic, '', 'POST', '/api/auth/login', { loginId: username, password })
|
||||
assert(login.overseasAccessToken, 'Temporary PDA login did not return an overseas token')
|
||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||
const response = await rawApi(overseas, login.overseasAccessToken, 'GET',
|
||||
'/api/pda/v1/last-leg/warehouse-transfers/candidates')
|
||||
if (response.http === 200 && !['403', 'A0403'].includes(response.code)) {
|
||||
return { membershipId: id(created.membershipId), token: login.overseasAccessToken }
|
||||
}
|
||||
await new Promise((resolveWait) => setTimeout(resolveWait, 500))
|
||||
}
|
||||
throw new Error('Temporary PDA security projection did not converge')
|
||||
}
|
||||
|
||||
await mkdir(dirname(output), { recursive: true })
|
||||
let admin
|
||||
let pda
|
||||
try {
|
||||
admin = await api(domestic, '', 'POST', '/api/auth/login', {
|
||||
loginId: process.env.ADMIN_TEST_USERNAME || 'platform_admin',
|
||||
password: process.env.ADMIN_TEST_PASSWORD || 'ChangeMe123!',
|
||||
})
|
||||
assert(admin.accessToken && admin.overseasAccessToken, 'Platform login did not return both tokens')
|
||||
const ticket = await api(domestic, admin.accessToken, 'POST',
|
||||
`/api/platform/v1/iam/tenants/${customerId}/impersonation`, { reason: `${marker} resume acceptance` })
|
||||
const merchant = await api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code })
|
||||
assert(merchant.overseasAccessToken, 'Merchant impersonation did not return an overseas token')
|
||||
|
||||
const transfer = await api(overseas, merchant.overseasAccessToken, 'GET',
|
||||
`/api/merchant/v1/last-leg/warehouse-transfers/${transferNo}`)
|
||||
assert(transfer.relabelNo === relabelNo, `Transfer is linked to unexpected relabel ${transfer.relabelNo}`)
|
||||
assert(['READY_FOR_DISPATCH', 'OUTBOUNDED', 'COMPLETED'].includes(transfer.status),
|
||||
`Unexpected transfer status ${transfer.status}`)
|
||||
const warehouseId = id(transfer.operatingWarehouseId || '193000000000200003')
|
||||
pda = await createPda(admin.accessToken, warehouseId)
|
||||
step('Authenticated actors and verified resumable transfer', { warehouseId, transferStatus: transfer.status })
|
||||
|
||||
let dispatch
|
||||
if (resumeDispatchNo) {
|
||||
dispatch = { dispatchNo: resumeDispatchNo, status: 'RESUMED' }
|
||||
step('Resumed existing smart pre-dispatch', dispatch)
|
||||
} else {
|
||||
const candidates = await api(overseas, admin.overseasAccessToken, 'GET',
|
||||
`/api/platform/v1/last-leg/dispatches/candidates?orderNo=${encodeURIComponent(relabelNo)}&sourceStage=RECEIVED`)
|
||||
const rows = candidates.filter((value) => value.sourceStage === 'RELABEL'
|
||||
&& String(value.orderRefs || '').split(',').includes(relabelNo))
|
||||
assert(rows.length === 2, `Expected two relabel dispatch candidates, got ${rows.length}`)
|
||||
assert(rows.every((value) => Number(value.packageCount) > 0), 'A dispatch candidate has no packages')
|
||||
step('Verified smart-dispatch candidates', {
|
||||
pallets: rows.map((value) => ({ warehousePalletId: id(value.warehousePalletId), palletNo: value.palletNo,
|
||||
packageCount: Number(value.packageCount) })),
|
||||
})
|
||||
|
||||
dispatch = await api(overseas, admin.overseasAccessToken, 'POST',
|
||||
'/api/platform/v1/last-leg/dispatches/pre-arrange', {
|
||||
planPalletIds: rows.map((value) => id(value.warehousePalletId)),
|
||||
route: marker,
|
||||
relabelNo,
|
||||
})
|
||||
assert(dispatch.dispatchNo, 'Pre-dispatch did not return a dispatch number')
|
||||
step('Created smart pre-dispatch', { dispatchNo: dispatch.dispatchNo, status: dispatch.status })
|
||||
}
|
||||
|
||||
const sourceReport = resolve('reports', `${marker}-source.json`)
|
||||
const fulfillmentReport = resolve('reports', `${marker}-fulfillment.json`)
|
||||
await writeFile(sourceReport, JSON.stringify({ marker, passed: true, scenarios: [{
|
||||
name: 'SPLIT-TWO-TARGETS', relabelNo, dispatchNo: dispatch.dispatchNo,
|
||||
}] }, null, 2), 'utf8')
|
||||
const child = 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: sourceReport,
|
||||
OVERSEAS_FULFILLMENT_OUTPUT: fulfillmentReport,
|
||||
},
|
||||
})
|
||||
if (child.error) throw child.error
|
||||
if (child.status !== 0) throw new Error(`Fulfillment acceptance failed with exit code ${child.status}`)
|
||||
|
||||
const finalTransfer = await api(overseas, merchant.overseasAccessToken, 'GET',
|
||||
`/api/merchant/v1/last-leg/warehouse-transfers/${transferNo}`)
|
||||
Object.assign(report, {
|
||||
passed: true,
|
||||
dispatchNo: dispatch.dispatchNo,
|
||||
transferStatus: finalTransfer.status,
|
||||
fulfillmentReport,
|
||||
finishedAt: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
report.error = error instanceof Error ? error.message : String(error)
|
||||
report.finishedAt = new Date().toISOString()
|
||||
throw error
|
||||
} finally {
|
||||
if (admin?.accessToken && pda?.membershipId) {
|
||||
try {
|
||||
await api(domestic, admin.accessToken, 'PUT', `/api/platform/v1/iam/users/${pda.membershipId}/status`, {
|
||||
status: 'DISABLED',
|
||||
})
|
||||
report.pdaCleanup = 'DISABLED'
|
||||
} catch (error) {
|
||||
report.pdaCleanup = `FAILED: ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
}
|
||||
await writeFile(output, JSON.stringify(report, null, 2), 'utf8')
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(report, null, 2))
|
||||
+1
-1
@@ -33,7 +33,7 @@ public class InternalWarehousePalletController {
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@Valid @RequestBody RelabelPalletRequests.Ensure request) {
|
||||
tokenGuard.verify(token);
|
||||
return ApiResponse.success(service.ensureRelabelPallet(request.warehouseId(), request.businessId()));
|
||||
return ApiResponse.success(service.ensureRelabelPallet(request.warehouseId(), request.businessId(), request.fbaWarehouseCode()));
|
||||
}
|
||||
|
||||
@GetMapping("/resolve/{palletCode}")
|
||||
|
||||
+2
-1
@@ -8,5 +8,6 @@ public final class RelabelPalletRequests {
|
||||
private RelabelPalletRequests() { }
|
||||
|
||||
public record Ensure(@Positive long warehouseId,
|
||||
@NotBlank @Size(max = 128) String businessId) { }
|
||||
@NotBlank @Size(max = 128) String businessId,
|
||||
@Size(max = 128) String fbaWarehouseCode) { }
|
||||
}
|
||||
|
||||
+19
-3
@@ -118,13 +118,20 @@ public class WarehouseResourceService {
|
||||
|
||||
@Transactional
|
||||
public WarehousePalletResolveResponse ensureRelabelPallet(long warehouseId, String businessId) {
|
||||
return ensureRelabelPallet(warehouseId, businessId, null);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public WarehousePalletResolveResponse ensureRelabelPallet(long warehouseId, String businessId,
|
||||
String fbaWarehouseCode) {
|
||||
String normalizedBusinessId = businessId == null ? null : businessId.trim();
|
||||
String normalizedFbaWarehouseCode = StringUtils.hasText(fbaWarehouseCode) ? fbaWarehouseCode.trim() : null;
|
||||
WarehousePalletBindingEntity existing = repository.findPalletBinding(PUBLIC_RESOURCE_SCOPE, warehouseId,
|
||||
"RELABEL_PDA_NEW", normalizedBusinessId);
|
||||
if (existing != null) return resolvePublicPallet(existing.getPalletId());
|
||||
if (existing != null) return requireRelabelPalletDestination(existing.getPalletId(), normalizedFbaWarehouseCode);
|
||||
Long palletId = createPallet(PUBLIC_RESOURCE_SCOPE, warehouseId,
|
||||
new PalletRequests.SaveRequest(null, "STANDARD", null, java.math.BigDecimal.ZERO, "IDLE", null,
|
||||
"PDA换标拆托新托盘"));
|
||||
"PDA换标拆托新托盘", normalizedFbaWarehouseCode));
|
||||
WarehousePalletBindingEntity binding = new WarehousePalletBindingEntity();
|
||||
binding.setId(IdWorker.getId()); binding.setCustomerId(PUBLIC_RESOURCE_SCOPE); binding.setWarehouseId(warehouseId);
|
||||
binding.setBusinessType("RELABEL_PDA_NEW"); binding.setBusinessId(normalizedBusinessId); binding.setPalletId(palletId);
|
||||
@@ -136,10 +143,19 @@ public class WarehouseResourceService {
|
||||
WarehousePalletBindingEntity winner = repository.findPalletBinding(PUBLIC_RESOURCE_SCOPE, warehouseId,
|
||||
"RELABEL_PDA_NEW", normalizedBusinessId);
|
||||
if (winner == null) throw duplicate;
|
||||
return resolvePublicPallet(winner.getPalletId());
|
||||
return requireRelabelPalletDestination(winner.getPalletId(), normalizedFbaWarehouseCode);
|
||||
}
|
||||
}
|
||||
|
||||
private WarehousePalletResolveResponse requireRelabelPalletDestination(long palletId, String fbaWarehouseCode) {
|
||||
WarehousePalletResolveResponse pallet = resolvePublicPallet(palletId);
|
||||
if (fbaWarehouseCode != null && !fbaWarehouseCode.equalsIgnoreCase(
|
||||
java.util.Objects.toString(pallet.fbaWarehouseCode(), ""))) {
|
||||
throw new BizException(ResultCode.CONFLICT, "换标新托盘的 FBA 目的仓与当前申请不一致");
|
||||
}
|
||||
return pallet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the warehouse-scoped virtual location used only as a pallet/inventory reference.
|
||||
* It is deliberately never made IDLE/OCCUPIED by resource locking.
|
||||
|
||||
Reference in New Issue
Block a user