修改提交

This commit is contained in:
2026-09-06 15:02:33 +08:00
parent 42ddc19826
commit 3ae8964fe5
15 changed files with 242 additions and 137 deletions
@@ -19,7 +19,7 @@ public final class RelabelRequests {
public record Create(
@NotBlank @Size(max = 128) String customerOrderNo,
@NotBlank @Size(max = 128) String overseasWarehouseId,
@Size(max = 128) String overseasWarehouseId,
@NotNull LocalDateTime labelDeadline,
@NotBlank @Size(max = 128) String contactName,
@NotBlank @Size(max = 64) String contactPhone,
@@ -41,10 +41,23 @@ public final class RelabelRequests {
@Size(max = 255) String trackingCode,
@NotBlank @Size(max = 128) String newSkuCode,
@Size(max = 1000) String labelRequirement,
@Size(max = 1000) String remark,
@Size(max = 128) String relabelFileId,
@Size(max = 255) String relabelFileName,
@Size(max = 1000) String relabelFileUrl
) {}
@Size(max = 1000) String relabelFileUrl,
@NotBlank @Size(max = 255) String cartonMark,
@NotBlank @Size(max = 255) String fbaCartonMark,
@Size(max = 128) String destinationWarehouseId
) {
public Item(String skuCode, String productImageFileId, String productImageFileName,
String productImageUrl, String productNameZh, int totalPackageCount,
int notifiedPackageCount, String shipmentNo, String trackingCode, String newSkuCode,
String labelRequirement, String relabelFileId, String relabelFileName, String relabelFileUrl) {
this(skuCode, productImageFileId, productImageFileName, productImageUrl, productNameZh,
totalPackageCount, notifiedPackageCount, shipmentNo, trackingCode, newSkuCode,
labelRequirement, null, relabelFileId, relabelFileName, relabelFileUrl, skuCode, shipmentNo, null);
}
}
public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String relabelNo,
String customerOrderNo, String skuCode, String status,
@@ -17,9 +17,13 @@ public final class RelabelResponses {
public record Item(Long id, String skuCode, String productImageFileId, String productImageFileName,
String productImageUrl, String productNameZh, int totalPackageCount,
int notifiedPackageCount, String shipmentNo, String trackingCode,
String newSkuCode, String labelRequirement, String relabelFileId,
String relabelFileName, String relabelFileUrl) {}
String newSkuCode, String labelRequirement, String remark, String relabelFileId,
String relabelFileName, String relabelFileUrl,
String cartonMark, String fbaCartonMark, String destinationWarehouseId) {}
public record TailOrder(Long id, String orderNo, String overseasWarehouseId, int packageCount,
String status) {}
public record History(String fromStatus, String toStatus, String operatorId,
String remark, LocalDateTime createdAt) {}
public record Detail(Summary application, String remark, List<Item> items, List<History> history) {}
public record Detail(Summary application, String remark, List<Item> items, List<History> history,
List<TailOrder> tailOrders) {}
}
@@ -24,8 +24,12 @@ public class RelabelItem {
private String trackingCode;
private String newSkuCode;
private String labelRequirement;
private String remark;
private String relabelFileId;
private String relabelFileName;
private String relabelFileUrl;
private String cartonMark;
private String fbaCartonMark;
private String destinationWarehouseId;
private LocalDateTime createdAt;
}
@@ -33,7 +33,9 @@ import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@@ -56,12 +58,15 @@ public class RelabelApplicationService implements WalletResultHandler {
public RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request) {
int total = 0;
int notified = 0;
Map<String, Integer> packagesByWarehouse = new LinkedHashMap<>();
for (RelabelRequests.Item item : request.items()) {
if (item.notifiedPackageCount() > item.totalPackageCount()) {
throw badRequest("Notified relocation packages cannot exceed total packages for SKU " + item.skuCode());
}
total = Math.addExact(total, item.totalPackageCount());
notified = Math.addExact(notified, item.notifiedPackageCount());
String warehouseId = destination(item, request.overseasWarehouseId());
packagesByWarehouse.merge(warehouseId, item.totalPackageCount(), Math::addExact);
}
String requestHash = hash(request);
RelabelApplication replay = applications.findKey(customerId, request.idempotencyKey().trim());
@@ -71,40 +76,20 @@ public class RelabelApplicationService implements WalletResultHandler {
}
LocalDateTime now = LocalDateTime.now();
long applicationId = IdWorker.getId();
String relabelNo = orderNoGenerator.next("RELABEL", request.overseasWarehouseId(), applicationId);
LastlegOrder order = new LastlegOrder();
order.setId(IdWorker.getId());
order.setOrderNo(orderNoGenerator.next("RELABEL", request.overseasWarehouseId(), order.getId()));
order.setCustomerId(customerId);
order.setOrderType("RELABEL_APPLICATION");
order.setInboundCode(relabelNo);
order.setSourceRegion("CUSTOMER");
order.setSourceInboundId(relabelNo);
order.setSourceOrderNo(request.customerOrderNo().trim());
order.setOverseasWarehouseId(request.overseasWarehouseId().trim());
order.setPalletCount(0);
order.setPackageCount(total);
order.setPickedPackageCount(0);
order.setShippedPackageCount(0);
order.setCurrentStatus("PENDING_ASSIGNMENT");
order.setSubOrderStatus("RELABEL:" + PENDING_PROCESSING);
order.setRelabelOrderNo(relabelNo);
order.setRelabelStatus(PENDING_PROCESSING);
order.setCustomerRemark(trim(request.remark()));
order.setSourceEventId("CUSTOMER_RELABEL:" + relabelNo);
order.setSourceLoadingPlanId("RELABEL_APPLICATION");
order.setSourcePayloadHash("sha256:" + requestHash);
order.setCreatedAt(now);
order.setUpdatedAt(now);
orders.insert(order);
String primaryWarehouseId = packagesByWarehouse.keySet().iterator().next();
String relabelNo = orderNoGenerator.next("RELABEL", primaryWarehouseId, applicationId);
List<LastlegOrder> tailOrders = packagesByWarehouse.entrySet().stream()
.map(entry -> insertTailOrder(customerId, request, requestHash, relabelNo,
entry.getKey(), entry.getValue(), now))
.toList();
RelabelApplication application = new RelabelApplication();
application.setId(applicationId);
application.setCustomerId(customerId);
application.setRelabelNo(relabelNo);
application.setLastlegOrderId(order.getId());
application.setLastlegOrderId(tailOrders.get(0).getId());
application.setCustomerOrderNo(request.customerOrderNo().trim());
application.setOverseasWarehouseId(request.overseasWarehouseId().trim());
application.setOverseasWarehouseId(primaryWarehouseId);
application.setLabelDeadline(request.labelDeadline());
application.setContactName(request.contactName().trim());
application.setContactPhone(request.contactPhone().trim());
@@ -128,10 +113,12 @@ public class RelabelApplicationService implements WalletResultHandler {
application.setWalletCommandEventId(walletEventId);
applications.updateById(application);
for (RelabelRequests.Item source : request.items()) insertItem(customerId, applicationId, source, now);
for (RelabelRequests.Item source : request.items()) {
insertItem(customerId, applicationId, source, request.overseasWarehouseId(), now);
}
insertHistory(customerId, applicationId, null, PENDING_PROCESSING, operatorId, "Relabel application submitted", now);
insertOrderHistory(customerId, order.getId(), null, order.getCurrentStatus(),
"Relabel application submitted", now);
tailOrders.forEach(order -> insertOrderHistory(customerId, order.getId(), null,
order.getCurrentStatus(), "Relabel application submitted", now));
return detail(customerId, relabelNo);
}
@@ -145,7 +132,9 @@ public class RelabelApplicationService implements WalletResultHandler {
.like(StringUtils.hasText(query.relabelNo()), RelabelApplication::getRelabelNo, query.relabelNo())
.like(StringUtils.hasText(query.customerOrderNo()), RelabelApplication::getCustomerOrderNo, query.customerOrderNo())
.eq(StringUtils.hasText(query.status()), RelabelApplication::getStatus, query.status())
.eq(StringUtils.hasText(query.overseasWarehouseId()), RelabelApplication::getOverseasWarehouseId, query.overseasWarehouseId())
.apply(StringUtils.hasText(query.overseasWarehouseId()),
"EXISTS (SELECT 1 FROM ll_relabel_item rw WHERE rw.application_id=ll_relabel_application.id AND rw.customer_id=ll_relabel_application.customer_id AND rw.destination_warehouse_id={0})",
query.overseasWarehouseId())
.ge(query.createdFrom() != null, RelabelApplication::getCreatedAt, query.createdFrom())
.le(query.createdTo() != null, RelabelApplication::getCreatedAt, query.createdTo())
.apply(StringUtils.hasText(query.skuCode()),
@@ -166,10 +155,13 @@ public class RelabelApplicationService implements WalletResultHandler {
.eq(RelabelStatusHistory::getCustomerId, recordCustomerId)
.eq(RelabelStatusHistory::getApplicationId, application.getId())
.orderByAsc(RelabelStatusHistory::getCreatedAt));
List<LastlegOrder> tailOrders = tailOrders(application);
return new RelabelResponses.Detail(summary(application, detailItems), application.getRemark(),
detailItems.stream().map(this::item).toList(), statusHistory.stream().map(value ->
new RelabelResponses.History(value.getFromStatus(), value.getToStatus(), value.getOperatorId(),
value.getRemark(), value.getCreatedAt())).toList());
value.getRemark(), value.getCreatedAt())).toList(), tailOrders.stream().map(value ->
new RelabelResponses.TailOrder(value.getId(), value.getOrderNo(), value.getOverseasWarehouseId(),
Objects.requireNonNullElse(value.getPackageCount(), 0), value.getCurrentStatus())).toList());
}
@Transactional
@@ -201,30 +193,32 @@ public class RelabelApplicationService implements WalletResultHandler {
"换标取消,释放冻结", 2L);
application.setWalletStatus("RELEASE_PENDING"); applications.updateById(application);
}
LastlegOrder order = orders.selectById(application.getLastlegOrderId());
if (order == null || !customerId.equals(order.getCustomerId())) throw conflict("Linked tail-leg order is missing");
String orderBefore = order.getCurrentStatus();
String mainStatus = STATUSES.contains(orderBefore) ? "PENDING_ASSIGNMENT" : orderBefore;
// A return-relabel tail order joins the normal inbound queue after labels are finished.
String orderStatus = switch (next) {
case "COMPLETED" -> "RETURN_RELABEL".equals(order.getOrderType())
? "PENDING_ASSIGNMENT" : "COMPLETED";
case "REJECTED", "CANCELLED" -> "CANCELLED";
default -> mainStatus;
};
order.setCurrentStatus(orderStatus);
order.setSubOrderStatus("RELABEL:" + next);
order.setRelabelStatus(next);
order.setUpdatedAt(now);
orders.updateById(order);
insertHistory(customerId, application.getId(), before, next, operatorId, request.remark(), now);
if (!Objects.equals(orderBefore, orderStatus)) {
insertOrderHistory(customerId, order.getId(), orderBefore, orderStatus, request.remark(), now);
List<LastlegOrder> linkedOrders = tailOrders(application);
if (linkedOrders.isEmpty()) throw conflict("Linked tail-leg order is missing");
for (LastlegOrder order : linkedOrders) {
String orderBefore = order.getCurrentStatus();
String mainStatus = STATUSES.contains(orderBefore) ? "PENDING_ASSIGNMENT" : orderBefore;
String orderStatus = switch (next) {
case "COMPLETED" -> "RETURN_RELABEL".equals(order.getOrderType())
? "PENDING_ASSIGNMENT" : "COMPLETED";
case "REJECTED", "CANCELLED" -> "CANCELLED";
default -> mainStatus;
};
order.setCurrentStatus(orderStatus);
order.setSubOrderStatus("RELABEL:" + next);
order.setRelabelStatus(next);
order.setUpdatedAt(now);
orders.updateById(order);
if (!Objects.equals(orderBefore, orderStatus)) {
insertOrderHistory(customerId, order.getId(), orderBefore, orderStatus, request.remark(), now);
}
}
insertHistory(customerId, application.getId(), before, next, operatorId, request.remark(), now);
return detail(customerId, relabelNo);
}
private void insertItem(String customerId, long applicationId, RelabelRequests.Item source, LocalDateTime now) {
private void insertItem(String customerId, long applicationId, RelabelRequests.Item source,
String defaultWarehouseId, LocalDateTime now) {
RelabelItem item = new RelabelItem();
item.setId(IdWorker.getId()); item.setApplicationId(applicationId); item.setCustomerId(customerId);
item.setSkuCode(source.skuCode().trim()); item.setProductImageFileId(trim(source.productImageFileId()));
@@ -232,16 +226,21 @@ public class RelabelApplicationService implements WalletResultHandler {
item.setProductNameZh(source.productNameZh().trim()); item.setTotalPackageCount(source.totalPackageCount());
item.setNotifiedPackageCount(source.notifiedPackageCount()); item.setShipmentNo(source.shipmentNo().trim());
item.setTrackingCode(trim(source.trackingCode())); item.setNewSkuCode(source.newSkuCode().trim());
item.setLabelRequirement(trim(source.labelRequirement())); item.setRelabelFileId(trim(source.relabelFileId()));
item.setLabelRequirement(trim(source.labelRequirement())); item.setRemark(trim(source.remark())); item.setRelabelFileId(trim(source.relabelFileId()));
item.setRelabelFileName(trim(source.relabelFileName())); item.setRelabelFileUrl(trim(source.relabelFileUrl()));
item.setCartonMark(source.cartonMark().trim()); item.setFbaCartonMark(source.fbaCartonMark().trim());
item.setDestinationWarehouseId(destination(source, defaultWarehouseId));
item.setCreatedAt(now); items.insert(item);
}
private RelabelResponses.Summary summary(RelabelApplication value, List<RelabelItem> detailItems) {
LastlegOrder order = orders.selectById(value.getLastlegOrderId());
String skuCodes = detailItems.stream().map(RelabelItem::getSkuCode).filter(Objects::nonNull).distinct().collect(Collectors.joining(","));
String warehouses = detailItems.stream().map(RelabelItem::getDestinationWarehouseId)
.filter(StringUtils::hasText).distinct().collect(Collectors.joining(","));
return new RelabelResponses.Summary(value.getId(), value.getRelabelNo(), value.getLastlegOrderId(),
order == null ? null : order.getOrderNo(), value.getCustomerOrderNo(), value.getOverseasWarehouseId(),
order == null ? null : order.getOrderNo(), value.getCustomerOrderNo(),
StringUtils.hasText(warehouses) ? warehouses : value.getOverseasWarehouseId(),
value.getLabelDeadline(), value.getContactName(), value.getContactPhone(), value.getStatus(),
Objects.requireNonNullElse(value.getItemCount(), detailItems.size()),
Objects.requireNonNullElse(value.getTotalPackageCount(), 0),
@@ -276,8 +275,47 @@ public class RelabelApplicationService implements WalletResultHandler {
return new RelabelResponses.Item(value.getId(), value.getSkuCode(), value.getProductImageFileId(),
value.getProductImageFileName(), value.getProductImageUrl(), value.getProductNameZh(),
value.getTotalPackageCount(), value.getNotifiedPackageCount(), value.getShipmentNo(),
value.getTrackingCode(), value.getNewSkuCode(), value.getLabelRequirement(), value.getRelabelFileId(),
value.getRelabelFileName(), value.getRelabelFileUrl());
value.getTrackingCode(), value.getNewSkuCode(), value.getLabelRequirement(), value.getRemark(), value.getRelabelFileId(),
value.getRelabelFileName(), value.getRelabelFileUrl(), value.getCartonMark(), value.getFbaCartonMark(),
value.getDestinationWarehouseId());
}
private LastlegOrder insertTailOrder(String customerId, RelabelRequests.Create request, String requestHash,
String relabelNo, String warehouseId, int packageCount,
LocalDateTime now) {
LastlegOrder order = new LastlegOrder();
order.setId(IdWorker.getId());
order.setOrderNo(orderNoGenerator.next("RELABEL", warehouseId, order.getId()));
order.setCustomerId(customerId); order.setOrderType("RELABEL_APPLICATION");
order.setInboundCode(relabelNo); order.setSourceRegion("CUSTOMER");
order.setSourceInboundId(relabelNo + ":" + warehouseId);
order.setSourceOrderNo(request.customerOrderNo().trim()); order.setOverseasWarehouseId(warehouseId);
order.setPalletCount(0); order.setPackageCount(packageCount); order.setPickedPackageCount(0);
order.setShippedPackageCount(0); order.setCurrentStatus("PENDING_ASSIGNMENT");
order.setSubOrderStatus("RELABEL:" + PENDING_PROCESSING); order.setRelabelOrderNo(relabelNo);
order.setRelabelStatus(PENDING_PROCESSING); order.setCustomerRemark(trim(request.remark()));
order.setSourceEventId("CUSTOMER_RELABEL:" + relabelNo + ":" + warehouseId);
order.setSourceLoadingPlanId("RELABEL_APPLICATION"); order.setSourcePayloadHash("sha256:" + requestHash);
order.setCreatedAt(now); order.setUpdatedAt(now); orders.insert(order);
return order;
}
private List<LastlegOrder> tailOrders(RelabelApplication application) {
List<LastlegOrder> linked = orders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getCustomerId, application.getCustomerId())
.eq(LastlegOrder::getRelabelOrderNo, application.getRelabelNo())
.orderByAsc(LastlegOrder::getId));
if (!linked.isEmpty()) return linked;
LastlegOrder legacy = orders.selectById(application.getLastlegOrderId());
return legacy == null ? List.of() : List.of(legacy);
}
private static String destination(RelabelRequests.Item item, String fallback) {
String value = StringUtils.hasText(item.destinationWarehouseId())
? item.destinationWarehouseId().trim()
: trim(fallback);
if (!StringUtils.hasText(value)) throw badRequest("Destination warehouse is required for every carton");
return value;
}
private List<RelabelItem> itemList(Long applicationId) {
@@ -1456,9 +1456,13 @@ CREATE TABLE IF NOT EXISTS ll_relabel_item (
tracking_code VARCHAR(255) NULL COMMENT '物流跟踪编码',
new_sku_code VARCHAR(128) NOT NULL COMMENT '换标后SKU编码',
label_requirement VARCHAR(1000) NULL COMMENT '换标要求',
remark VARCHAR(1000) NULL COMMENT '货物明细备注',
relabel_file_id VARCHAR(128) NULL COMMENT '换标文件ID',
relabel_file_name VARCHAR(255) NULL COMMENT '换标文件名',
relabel_file_url VARCHAR(1000) NULL COMMENT '换标文件地址',
carton_mark VARCHAR(255) NULL COMMENT '客户自有箱唛',
fba_carton_mark VARCHAR(255) NULL COMMENT '关联FBA箱唛',
destination_warehouse_id VARCHAR(128) NOT NULL COMMENT '该箱目的海外仓ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id),
KEY idx_ll_relabel_item_application (customer_id,application_id),
@@ -678,27 +678,6 @@ paths:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementReview' } } }
responses: { '200': { description: 统一 ApiResponse 包装的采购订单详情 } }
/api/platform/v1/last-leg/overseas-procurements/{orderNo}/files/{category}:
post:
summary: 上传采购签收文件或费用凭证
description: 权限 platform:overseas-business:purchase:edit;category 仅允许 RECEIPT 或 VOUCHER。
security: [{ bearerAuth: [] }]
parameters:
- { name: orderNo, in: path, required: true, schema: { type: string } }
- { name: category, in: path, required: true, schema: { type: string, enum: [RECEIPT, VOUCHER] } }
- { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
requestBody: { required: true, content: { multipart/form-data: { schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } } } } }
responses: { '200': { description: 已保存文件元数据 } }
/api/platform/v1/last-leg/overseas-procurements/{orderNo}/fees:
post:
summary: 登记采购费用并发起资金冻结
description: 权限 platform:overseas-business:purchase:edit;订单必须已审核通过,费用凭证必须属于本订单。
security: [{ bearerAuth: [] }]
parameters:
- { name: orderNo, in: path, required: true, schema: { type: string } }
- { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementFeeCreate' } } } }
responses: { '200': { description: 已创建费用并进入冻结流程 }, '409': { description: 订单状态或凭证不允许 } }
/api/merchant/v1/last-leg/delivery-orders:
get:
summary: 客户分页查询自己的派送单
@@ -1662,20 +1641,20 @@ components:
RelabelStatus: { type: string, enum: [PENDING_PROCESSING, PROCESSING, COMPLETED, REJECTED, CANCELLED] }
RelabelCreate:
type: object
required: [customerOrderNo, overseasWarehouseId, labelDeadline, contactName, contactPhone, chargeAmount, idempotencyKey, items]
required: [customerOrderNo, labelDeadline, contactName, contactPhone, chargeAmount, idempotencyKey, items]
properties:
customerOrderNo: { type: string, maxLength: 128 }
overseasWarehouseId: { type: string, maxLength: 128 }
overseasWarehouseId: { type: string, nullable: true, maxLength: 128, description: 统一目的海外仓;明细未填写目的仓时使用 }
labelDeadline: { type: string, format: date-time }
contactName: { type: string, maxLength: 128 }
contactPhone: { type: string, maxLength: 64 }
chargeAmount: { type: number, minimum: 0.01, description: 换标费用,币种固定 CNY }
chargeAmount: { type: number, minimum: 0.01, description: 本次贴标费用(CNY) }
remark: { type: string, nullable: true, maxLength: 1000 }
idempotencyKey: { type: string, maxLength: 255 }
items: { type: array, minItems: 1, maxItems: 500, items: { $ref: '#/components/schemas/RelabelItem' } }
RelabelItem:
type: object
required: [skuCode, productNameZh, totalPackageCount, notifiedPackageCount, shipmentNo, newSkuCode]
required: [skuCode, productNameZh, totalPackageCount, notifiedPackageCount, shipmentNo, newSkuCode, cartonMark, fbaCartonMark]
properties:
skuCode: { type: string, maxLength: 128 }
productImageFileId: { type: string, nullable: true, maxLength: 128 }
@@ -1691,6 +1670,9 @@ components:
relabelFileId: { type: string, nullable: true, maxLength: 128 }
relabelFileName: { type: string, nullable: true, maxLength: 255 }
relabelFileUrl: { type: string, nullable: true, maxLength: 1000 }
cartonMark: { type: string, maxLength: 255, description: 客户自有箱唛(不在编辑列表展示) }
fbaCartonMark: { type: string, maxLength: 255, description: 关联 FBA 箱唛 }
destinationWarehouseId: { type: string, nullable: true, maxLength: 128, description: 该箱目的海外仓;为空时使用申请统一目的仓 }
RelabelStatusChange:
type: object
required: [status]
@@ -1786,7 +1768,7 @@ components:
updatedAt: { type: string, format: date-time }
ProcurementDetail:
type: object
required: [summary, histories, outboundDetails, files, fees]
required: [summary, histories, outboundDetails]
properties:
summary: { $ref: '#/components/schemas/ProcurementSummary' }
productUrl: { type: string, nullable: true }
@@ -1805,42 +1787,6 @@ components:
reviewedAt: { type: string, format: date-time, nullable: true }
histories: { type: array, items: { $ref: '#/components/schemas/BusinessHistory' } }
outboundDetails: { type: array, items: { $ref: '#/components/schemas/ProcurementOutboundDetail' } }
files: { type: array, items: { $ref: '#/components/schemas/ProcurementFile' } }
fees: { type: array, items: { $ref: '#/components/schemas/ProcurementFee' } }
ProcurementFeeCreate:
type: object
required: [feeItemCode, feeItemName, amount, voucherFileId]
properties:
feeItemCode: { type: string, maxLength: 64 }
feeItemName: { type: string, maxLength: 128 }
amount: { type: number, minimum: 0.01 }
voucherFileId: { type: string, pattern: '^[0-9]+$' }
remark: { type: string, nullable: true, maxLength: 500 }
ProcurementFile:
type: object
properties:
id: { type: string }
category: { type: string, enum: [RECEIPT, VOUCHER] }
fileId: { type: string }
fileName: { type: string }
contentType: { type: string, nullable: true }
sizeBytes: { type: integer, format: int64 }
createdAt: { type: string, format: date-time }
ProcurementFee:
type: object
properties:
id: { type: string }
feeNo: { type: string }
feeItemCode: { type: string }
feeItemName: { type: string }
amount: { type: number }
currency: { type: string, enum: [CNY] }
voucherFileId: { type: string }
voucherFileName: { type: string }
remark: { type: string, nullable: true }
walletStatus: { type: string }
walletErrorMessage: { type: string, nullable: true }
createdAt: { type: string, format: date-time }
ProcurementOutboundDetail:
type: object
properties:
@@ -40,6 +40,7 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.times;
@ExtendWith(MockitoExtension.class)
class RelabelApplicationServiceTest {
@@ -98,7 +99,7 @@ class RelabelApplicationServiceTest {
assertEquals("RELABEL:PENDING_PROCESSING", tailOrder.getValue().getSubOrderStatus());
assertEquals("CUSTOMER", tailOrder.getValue().getSourceRegion());
assertTrue(tailOrder.getValue().getOrderNo().matches("RL-WH-\\d{8}-\\d{6}"));
assertEquals(tailOrder.getValue().getInboundCode(), tailOrder.getValue().getSourceInboundId());
assertEquals(tailOrder.getValue().getInboundCode() + ":WH-1", tailOrder.getValue().getSourceInboundId());
assertEquals(tailOrder.getValue().getInboundCode(), application.getValue().getRelabelNo());
assertTrue(tailOrder.getValue().getInboundCode().startsWith("RL-"));
assertEquals(10, tailOrder.getValue().getPackageCount());
@@ -120,6 +121,71 @@ class RelabelApplicationServiceTest {
verify(items, never()).insert(any(RelabelItem.class));
}
@Test
void createPersistsCartonMarkBinding() {
RelabelRequests.Item attachedItem = new RelabelRequests.Item("SKU-A", null, null, null,
"Chinese name", 2, 2, "SHP-1", null, "SKU-NEW", null, null, null, null, null,
"BOX-1", "FBA-BOX-1", "WH-1");
RelabelRequests.Create request = new RelabelRequests.Create("CUSTOMER-1", "WH-1",
LocalDateTime.of(2026, 8, 20, 10, 0), "Li", "13800000000", BigDecimal.ONE, null, "key-marks",
List.of(attachedItem));
RelabelApplication persisted = application(11L, "FBR-1", "100", "key-files", null,
RelabelApplicationService.PENDING_PROCESSING);
persisted.setLastlegOrderId(21L); persisted.setItemCount(1); persisted.setTotalPackageCount(2);
persisted.setNotifiedPackageCount(2);
when(applications.findNo(eq("100"), any())).thenReturn(persisted);
when(items.selectList(any())).thenReturn(List.of(item(31L, 11L, "SKU-A", 2, 2)));
when(histories.selectList(any())).thenReturn(List.of());
when(orders.selectById(21L)).thenReturn(order(21L, "100", "LMO-RELABEL-1",
RelabelApplicationService.PENDING_PROCESSING));
service.create("100", "operator-1", request);
ArgumentCaptor<RelabelItem> saved = ArgumentCaptor.forClass(RelabelItem.class);
verify(items).insert(saved.capture());
assertEquals("BOX-1", saved.getValue().getCartonMark());
assertEquals("FBA-BOX-1", saved.getValue().getFbaCartonMark());
assertEquals("WH-1", saved.getValue().getDestinationWarehouseId());
}
@Test
void createGroupsCartonsIntoOneTailOrderPerDestinationWarehouse() {
RelabelRequests.Item west = carton("SKU-A", "BOX-1", "FBA-1", "ONT8");
RelabelRequests.Item east = carton("SKU-B", "BOX-2", "FBA-2", "ABE8");
RelabelRequests.Create request = new RelabelRequests.Create("CUSTOMER-1", null,
LocalDateTime.of(2026, 8, 20, 10, 0), "Li", "13800000000", BigDecimal.ONE, null, "key-multi",
List.of(west, east));
RelabelApplication persisted = application(11L, "FBR-1", "10001", "key-multi", null,
RelabelApplicationService.PENDING_PROCESSING);
persisted.setLastlegOrderId(21L);
when(applications.findNo(eq("10001"), any())).thenReturn(persisted);
when(items.selectList(any())).thenReturn(List.of(item(31L, 11L, "SKU-A", 1, 1)));
when(histories.selectList(any())).thenReturn(List.of());
when(orders.selectById(21L)).thenReturn(order(21L, "10001", "LMO-RELABEL-1",
RelabelApplicationService.PENDING_PROCESSING));
service.create("10001", "operator-1", request);
ArgumentCaptor<LastlegOrder> created = ArgumentCaptor.forClass(LastlegOrder.class);
verify(orders, times(2)).insert(created.capture());
assertEquals(List.of("ONT8", "ABE8"), created.getAllValues().stream()
.map(LastlegOrder::getOverseasWarehouseId).toList());
assertTrue(created.getAllValues().stream().allMatch(value -> value.getPackageCount() == 1));
}
@Test
void createRejectsCartonWithoutDetailOrDefaultDestinationWarehouse() {
RelabelRequests.Create request = new RelabelRequests.Create("CUSTOMER-1", null,
LocalDateTime.of(2026, 8, 20, 10, 0), "Li", "13800000000", BigDecimal.ONE, null, "key-no-destination",
List.of(carton("SKU-A", "BOX-1", "FBA-1", null)));
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.create("10001", "operator-1", request));
assertEquals(400, exception.getStatusCode().value());
verify(orders, never()).insert(any(LastlegOrder.class));
}
@Test
void sameIdempotencyKeyAndPayloadReturnsExistingApplicationWithoutWrites() {
RelabelRequests.Create request = request("key-1", 10, 6);
@@ -224,6 +290,13 @@ class RelabelApplicationServiceTest {
"SKU-NEW", "Label on carton", "file-1", "relabel.pdf", "https://files/relabel.pdf")));
}
private static RelabelRequests.Item carton(String sku, String cartonMark, String fbaCartonMark,
String destinationWarehouseId) {
return new RelabelRequests.Item(sku, null, null, null, "Chinese name", 1, 1,
"SHP-1", null, sku + "-NEW", null, null, null, null, null,
cartonMark, fbaCartonMark, destinationWarehouseId);
}
private static RelabelApplication application(long id, String no, String customerId, String key, String requestHash,
String status) {
RelabelApplication value = new RelabelApplication();