This commit is contained in:
2026-09-15 15:15:46 +08:00
parent 419950482f
commit cadc5a175e
28 changed files with 879 additions and 89 deletions
+4
View File
@@ -130,6 +130,10 @@
## 修改与验证
- 混合换标 `CARTON_AND_SKU` 使用现有 PDA 历史保存 `MIXED_CARTON/MIXED_SKU` 分项事实(itemId + 退回轮次);同一项防重复,两项全部完成才写一条箱事实并增加整箱计数。详情返回 `cartonProcessedPackages/skuProcessedPackages`;不得将单项完成当作整箱完成,返工后旧轮次不计入当前分项进度。
- 换标复核闭环:`PdaRelabelService.complete` 直接提交待客户确认,`RelabelApplicationService.confirmCompletion` 原子完成 PDA 复核;平台 `RelabelApplicationController` 的 `/review-task`、`/rework` 按状态权限提供带版本的退回。退回备注必填,上一轮事实及照片引用写入 `REWORK_ARCHIVE` 历史,原事实标记 `REWORK_ARCHIVED` 并释放扫描唯一键,当前计数归零;客户已确认不可退回。PDA 不再自行批准复核。
- 移动包、拆分端口/适配器或引入新的跨子域依赖时,先核对内部模块化基线并运行 `node --test scripts/analyze-lastleg-dependencies.test.mjs`;确需更新依赖基线时必须说明稳定边界变化,不能把临时循环依赖登记为长期豁免。
- 安全投影边界变更同时运行 `SecurityProjectionArchitectureTest` 与 `LastlegSecurityContractTest`;`security/controller|dto|service` 只允许依赖本子域和根 `InternalServiceTokenGuard`,其他 Lastleg 业务包不得直接依赖这些投影内部包。
- 表字段、注释和索引同步维护基线 SQL 与统一增量 SQL;生产/存量库只执行增量脚本。
@@ -25,6 +25,25 @@ import org.springframework.web.server.ResponseStatusException;
@RequestMapping("/api/platform/v1/last-leg/relabel-applications")
public class RelabelApplicationController {
@Resource private RelabelApplicationService service;
@Resource private com.baiyue.lastleg.relabel.pda.service.PdaRelabelService pdaService;
@PostMapping("/{relabelNo}/rework")
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_STATUS + "')")
public ApiResponse<com.baiyue.lastleg.relabel.pda.dto.PdaRelabelResponses.Detail> rework(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String relabelNo,
@Valid @RequestBody com.baiyue.lastleg.relabel.pda.dto.PdaRelabelRequests.Review request) {
requirePlatform(user);
return ApiResponse.success(pdaService.returnForRework(operator(user), user.username(), relabelNo, request));
}
@GetMapping("/{relabelNo}/review-task")
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')")
public ApiResponse<com.baiyue.lastleg.relabel.pda.dto.PdaRelabelResponses.Detail> reviewTask(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String relabelNo) {
requirePlatform(user);
var detail = service.detail(null, relabelNo);
return ApiResponse.success(pdaService.detail(operator(user), Long.parseLong(detail.application().overseasWarehouseId()), relabelNo));
}
@PostMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_CREATE + "')")
@@ -23,7 +23,7 @@ public final class RelabelRequests {
@NotNull LocalDateTime labelDeadline,
@NotBlank @Size(max = 128) String contactName,
@NotBlank @Size(max = 64) String contactPhone,
@NotNull @DecimalMin(value = "0.01") BigDecimal chargeAmount,
@DecimalMin(value = "0.01") BigDecimal chargeAmount,
@Size(max = 1000) String remark,
@NotBlank @Size(max = 255) String idempotencyKey,
@NotEmpty @Size(max = 500) List<@Valid Item> items,
@@ -62,6 +62,33 @@ public class PdaRelabelController {
identity.overseasWarehouseId(), relabelNo, request));
}
@PostMapping("/{relabelNo}/cartons/scan")
public ApiResponse<PdaRelabelResponses.Detail> scanCarton(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.CartonScan request) {
Identity identity = identity(user);
return ApiResponse.success(service.scanCarton(identity.operatorKey(), identity.operatorName(),
identity.overseasWarehouseId(), relabelNo, request));
}
@GetMapping("/{relabelNo}/documents")
public ApiResponse<List<PdaRelabelService.Document>> documents(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo) {
return ApiResponse.success(service.documents(identity(user).overseasWarehouseId(), relabelNo));
}
@GetMapping("/{relabelNo}/documents/{fileId}/download")
public org.springframework.http.ResponseEntity<byte[]> download(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @PathVariable long fileId,
@org.springframework.web.bind.annotation.RequestParam String scope,
@org.springframework.web.bind.annotation.RequestParam(required = false) Long itemId) {
var file = service.downloadDocument(identity(user).overseasWarehouseId(), relabelNo, scope, itemId, fileId);
return org.springframework.http.ResponseEntity.ok()
.contentType(MediaType.parseMediaType(file.contentType()))
.header("Content-Disposition", org.springframework.http.ContentDisposition.inline()
.filename(file.fileName(), java.nio.charset.StandardCharsets.UTF_8).build().toString())
.body(file.content());
}
@PostMapping("/{relabelNo}/units/skip")
public ApiResponse<PdaRelabelResponses.Detail> skip(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Skip request) {
@@ -86,6 +113,15 @@ public class PdaRelabelController {
identity.userId(), relabelNo, file));
}
@GetMapping("/{relabelNo}/photos/files/{fileId}/download")
public org.springframework.http.ResponseEntity<byte[]> downloadWorkPhoto(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String relabelNo, @PathVariable long fileId) {
Identity identity = identity(user);
var file = service.downloadWorkPhoto(identity.operatorKey(), identity.overseasWarehouseId(), relabelNo, fileId);
return org.springframework.http.ResponseEntity.ok().contentType(MediaType.parseMediaType(file.contentType()))
.header("Cache-Control", "no-store").body(file.content());
}
@PostMapping("/{relabelNo}/complete")
public ApiResponse<PdaRelabelResponses.Detail> complete(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Complete request) {
@@ -1,6 +1,7 @@
package com.baiyue.lastleg.relabel.pda.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
@@ -14,11 +15,23 @@ public final class PdaRelabelRequests {
public record Claim(@NotBlank @Size(max = 255) String idempotencyKey) { }
public record CartonScan(@NotBlank @Size(max = 255) String cartonMark,
@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 255) String idempotencyKey) { }
public record Scan(@Positive Long itemId,
@NotBlank @Size(max = 255) String oldLabel,
@Size(max = 255) String newLabel,
@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 255) String idempotencyKey) { }
@NotBlank @Size(max = 255) String idempotencyKey,
@Valid List<SkuVerification> skuVerifications) {
public Scan(Long itemId, String oldLabel, String newLabel, Integer expectedVersion, String idempotencyKey) {
this(itemId, oldLabel, newLabel, expectedVersion, idempotencyKey, List.of());
}
}
public record SkuVerification(@NotBlank String skuCode, @NotBlank String newSkuCode,
@Positive int quantity) { }
public record Skip(@NotNull @Positive Long itemId,
@NotBlank @Size(max = 255) String oldLabel,
@@ -36,7 +49,11 @@ public final class PdaRelabelRequests {
public record Complete(@NotEmpty @Size(max = 20) List<@Positive Long> photoFileIds,
@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) { }
@Size(max = 1000) String remark, boolean manualCompletion) {
public Complete(List<Long> photoFileIds, Integer expectedVersion, String idempotencyKey, String remark) {
this(photoFileIds, expectedVersion, idempotencyKey, remark, false);
}
}
public record Review(@NotBlank @Size(max = 16) String decision,
@NotNull @Min(0) Integer expectedVersion,
@@ -7,12 +7,12 @@ public final class PdaRelabelResponses {
private PdaRelabelResponses() { }
public record Candidate(String relabelNo, String customerOrderNo, LocalDateTime labelDeadline,
String applicationStatus, String taskStatus, int itemCount,
String applicationStatus, String taskStatus, String operationType, int itemCount,
int expectedPackages, int relabelledPackages, int skippedPackages,
int exceptionPackages, int version, boolean claimedByCurrentOperator) { }
public record Detail(String relabelNo, String customerOrderNo, LocalDateTime labelDeadline,
String contactName, String contactPhone, String applicationStatus,
String contactName, String contactPhone, String applicationStatus, String operationType,
String taskStatus, int expectedPackages, int relabelledPackages,
int skippedPackages, int exceptionPackages, int version,
String operatorName, String reviewerName, List<Long> completionPhotoFileIds,
@@ -26,7 +26,7 @@ public final class PdaRelabelResponses {
String cartonMark, String fbaCartonMark, String operationType,
List<SkuItem> skuItems,
int expectedPackages, int relabelledPackages, int skippedPackages,
int exceptionPackages) { }
int exceptionPackages, int cartonProcessedPackages, int skuProcessedPackages) { }
public record SkuItem(String skuCode, String newSkuCode, int quantity, String remark) { }
public record UnitFact(Long factId, Long itemId, String oldLabel, String newLabel,
@@ -15,6 +15,9 @@ public interface PdaRelabelTaskMapper extends BaseMapper<PdaRelabelTask> {
@Select("SELECT * FROM ll_relabel_pda_task WHERE customer_id=#{customerId} AND application_id=#{applicationId} LIMIT 1 FOR UPDATE")
PdaRelabelTask lock(@Param("customerId") String customerId, @Param("applicationId") Long applicationId);
@Select("SELECT * FROM ll_relabel_pda_task WHERE operator_key=#{operatorKey} AND status<>'COMPLETED' ORDER BY claimed_at ASC LIMIT 1 FOR UPDATE")
PdaRelabelTask lockUnfinishedByOperator(@Param("operatorKey") String operatorKey);
@Update("""
UPDATE ll_relabel_pda_task
SET status=#{task.status}, operator_key=#{task.operatorKey}, operator_name=#{task.operatorName},
@@ -68,6 +68,8 @@ public class PdaRelabelService {
@Resource private RelabelApplicationService applicationService;
@Resource private RelabelPhotoFileGateway photoFiles;
@Resource private ObjectMapper sourceObjectMapper;
@Resource private com.baiyue.lastleg.mapper.LastlegOrderMapper tailOrders;
@Resource private com.baiyue.lastleg.exceptionorder.service.ExceptionOrderService exceptionOrders;
private ObjectMapper objectMapper;
@PostConstruct
@@ -81,7 +83,8 @@ public class PdaRelabelService {
public List<PdaRelabelResponses.Candidate> candidates(String operatorKey, long overseasWarehouseId) {
return applications.selectList(new LambdaQueryWrapper<RelabelApplication>()
.eq(RelabelApplication::getOverseasWarehouseId, String.valueOf(overseasWarehouseId))
.in(RelabelApplication::getStatus, RelabelApplicationService.PENDING_PROCESSING, PROCESSING)
.in(RelabelApplication::getStatus, RelabelApplicationService.PENDING_PROCESSING, PROCESSING,
RelabelApplicationService.CUSTOMER_CONFIRM_PENDING)
.orderByAsc(RelabelApplication::getLabelDeadline)
.orderByAsc(RelabelApplication::getCreatedAt))
.stream().map(application -> candidate(application,
@@ -106,6 +109,10 @@ public class PdaRelabelService {
throw conflict("Relabel application is not executable");
}
PdaRelabelTask task = tasks.lock(application.getCustomerId(), application.getId());
PdaRelabelTask activeTask = tasks.lockUnfinishedByOperator(operatorKey);
if (activeTask != null && !Objects.equals(activeTask.getApplicationId(), application.getId())) {
throw conflict("当前账号正在执行换标单 " + activeTask.getRelabelNo() + ",请先完成后再操作其他换标单");
}
if (task != null) {
if (!operatorKey.equals(task.getOperatorKey()) || !PROCESSING.equals(task.getStatus())) {
throw conflict("Relabel task is already claimed or awaiting review");
@@ -146,10 +153,18 @@ public class PdaRelabelService {
return unitAction(operatorKey, operatorName, overseasWarehouseId, relabelNo, "RELABEL", request.idempotencyKey(),
request, itemId, request.oldLabel(), request.newLabel(), "RELABELLED", null, null,
request.expectedVersion(), item -> {
validateSkuVerification(item, request.skuVerifications());
if (StringUtils.hasText(item.getCartonMark()) && !item.getCartonMark().trim().equalsIgnoreCase(request.oldLabel().trim())) {
throw conflict("Scanned old carton mark does not match the relabel item");
}
String expectedFbaLabel = trim(item.getFbaCartonMark());
if ("CARTON_LABEL".equals(operationType(item)) && StringUtils.hasText(expectedFbaLabel)
&& !StringUtils.hasText(request.newLabel())) {
throw conflict("请扫描新 FBA 箱唛确认箱唛换标");
}
if ("SKU_LABEL".equals(operationType(item)) && StringUtils.hasText(request.newLabel())) {
throw conflict("SKU 换标不能修改 FBA 箱唛");
}
if (StringUtils.hasText(request.newLabel()) && StringUtils.hasText(expectedFbaLabel)
&& !expectedFbaLabel.equalsIgnoreCase(request.newLabel().trim())) {
throw conflict("Scanned new label does not match the approved FBA carton mark");
@@ -160,6 +175,42 @@ public class PdaRelabelService {
});
}
@Transactional
public PdaRelabelResponses.Detail scanCarton(String operatorKey, String operatorName, long warehouseId,
String relabelNo, PdaRelabelRequests.CartonScan request) {
RelabelApplication application = requireApplication(relabelNo, warehouseId, false);
RelabelItem item = requireItemByCartonMark(application, request.cartonMark());
if ("SKU_LABEL".equals(operationType(item))) throw conflict("该箱为 SKU 换标,请进入 SKU 作业");
if (!StringUtils.hasText(item.getFbaCartonMark())) throw conflict("该箱未维护对应 FBA 箱唛");
return unitAction(operatorKey, operatorName, warehouseId, relabelNo, "SCAN_CARTON", request.idempotencyKey(),
request, item.getId(), item.getCartonMark(), item.getFbaCartonMark(), "RELABELLED", null, null,
request.expectedVersion(), current -> {
if (!Objects.equals(current.getCartonMark(), item.getCartonMark())
|| !Objects.equals(current.getFbaCartonMark(), item.getFbaCartonMark())) {
throw conflict("箱唛绑定已变化,请重新扫描");
}
});
}
public record Document(String scope, String fileId, String fileName, Long itemId) { }
@Transactional(readOnly = true)
public List<Document> documents(long warehouseId, String relabelNo) {
RelabelApplication app = requireApplication(relabelNo, warehouseId, false);
List<Document> result = new ArrayList<>();
if (StringUtils.hasText(app.getCartonTotalFileId())) result.add(new Document("CARTON_TOTAL", app.getCartonTotalFileId(), app.getCartonTotalFileName(), null));
if (StringUtils.hasText(app.getSkuTotalFileId())) result.add(new Document("SKU_TOTAL", app.getSkuTotalFileId(), app.getSkuTotalFileName(), null));
for (RelabelItem item : itemList(app)) if (StringUtils.hasText(item.getRelabelFileId()))
result.add(new Document("ITEM", item.getRelabelFileId(), item.getRelabelFileName(), item.getId()));
return result;
}
public com.baiyue.lastleg.relabel.gateway.RelabelDocumentFileGateway.DownloadedFile downloadDocument(
long warehouseId, String relabelNo, String scope, Long itemId, long fileId) {
RelabelApplication app = requireApplication(relabelNo, warehouseId, false);
return applicationService.downloadFile(app.getCustomerId(), relabelNo, scope, itemId, fileId);
}
@Transactional
public PdaRelabelResponses.Detail skip(String operatorKey, String operatorName, long overseasWarehouseId,
String relabelNo, PdaRelabelRequests.Skip request) {
@@ -194,6 +245,15 @@ public class PdaRelabelService {
uploaded.contentType(), uploaded.sizeBytes());
}
public RelabelPhotoFileGateway.DownloadedFile downloadWorkPhoto(String operatorKey, long warehouseId,
String relabelNo, long fileId) {
RelabelApplication application = requireApplication(relabelNo, warehouseId, false);
PdaRelabelTask task = requireOwnedTask(application, operatorKey, false);
if (fileId <= 0) throw notFound("作业照片不存在");
photoFiles.validate(application.getCustomerId(), task.getId(), fileId);
return photoFiles.download(application.getCustomerId(), task.getId(), fileId);
}
@Transactional
public PdaRelabelResponses.Detail complete(String operatorKey, String operatorName, long overseasWarehouseId,
String relabelNo, PdaRelabelRequests.Complete request) {
@@ -206,22 +266,59 @@ public class PdaRelabelService {
requireVersion(task, request.expectedVersion());
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()));
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();
if (actual != value(item.getNotifiedPackageCount())) {
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++) {
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.setReason(request.remark()); fact.setOperatorKey(operatorKey); fact.setOperatorName(operatorName);
fact.setOccurredAt(LocalDateTime.now()); unitFacts.insert(fact);
task.setExceptionPackages(value(task.getExceptionPackages()) + 1);
}
}
List<Long> photoIds = request.photoFileIds().stream().distinct().toList();
if (photoIds.size() != request.photoFileIds().size()) throw conflict("Relabel photo IDs must be unique");
photoIds.forEach(fileId -> photoFiles.validate(application.getCustomerId(), task.getId(), fileId));
if (request.manualCompletion()) {
var relatedOrders = tailOrders.selectList(new LambdaQueryWrapper<com.baiyue.lastleg.entity.LastlegOrder>()
.eq(com.baiyue.lastleg.entity.LastlegOrder::getCustomerId, application.getCustomerId())
.eq(com.baiyue.lastleg.entity.LastlegOrder::getRelabelOrderNo, relabelNo));
if (relatedOrders.isEmpty()) throw conflict("未找到关联尾程订单,无法登记异常完成");
for (var order : relatedOrders) {
String note = "换标单 " + relabelNo + " 手动完成:" + request.remark().trim();
exceptionOrders.create(application.getCustomerId(), operatorKey, operatorName,
new com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderRequests.Create(
application.getCustomerId(), null, order.getOrderNo(), "OTHER", note, null, null));
tailOrders.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<com.baiyue.lastleg.entity.LastlegOrder>()
.eq(com.baiyue.lastleg.entity.LastlegOrder::getId, order.getId())
.eq(com.baiyue.lastleg.entity.LastlegOrder::getCustomerId, application.getCustomerId())
.set(com.baiyue.lastleg.entity.LastlegOrder::getAbnormal, true)
.set(com.baiyue.lastleg.entity.LastlegOrder::getWarehouseRemark,
StringUtils.hasText(order.getWarehouseRemark()) ? order.getWarehouseRemark() + "\n" + note : note));
}
}
String before = task.getStatus();
task.setStatus(WAIT_REVIEW); task.setCompletionPhotoFileIds(write(photoIds));
task.setCompletionRemark(trim(request.remark())); task.setSubmittedAt(LocalDateTime.now());
updateTask(task, request.expectedVersion());
insertHistory(task, "COMPLETE", before, WAIT_REVIEW, operatorKey, operatorName,
Map.of("photoFileIds", photoIds, "remark", Objects.toString(trim(request.remark()), "")));
application.setStatus(PROCESSING);
Map.of("photoFileIds", photoIds, "remark", Objects.toString(trim(request.remark()), ""),
"manualCompletion", request.manualCompletion()));
applicationService.changeStatus(application.getCustomerId(), relabelNo, operatorKey,
new RelabelRequests.ChangeStatus(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, request.remark()));
application.setStatus(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING);
PdaRelabelResponses.Detail result = detail(application, task);
saveOperation(task, operatorKey, "COMPLETE", request.idempotencyKey(), command, result);
return result;
@@ -230,6 +327,17 @@ public class PdaRelabelService {
@Transactional
public PdaRelabelResponses.Detail review(String operatorKey, String operatorName, long overseasWarehouseId,
String relabelNo, PdaRelabelRequests.Review request) {
throw conflict("复核由客户确认完成;需要重新贴标时请由平台填写备注退回");
}
@Transactional
public PdaRelabelResponses.Detail returnForRework(String operatorKey, String operatorName, String relabelNo,
PdaRelabelRequests.Review request) {
if (!"REWORK".equals(request.decision()) || !StringUtils.hasText(request.remark()))
throw badRequest("退回重新贴标必须填写原因");
RelabelApplication scoped = applications.findAnyByRelabelNo(relabelNo);
if (scoped == null) throw notFound("换标单不存在");
long overseasWarehouseId = Long.parseLong(scoped.getOverseasWarehouseId());
ReviewCommand command = new ReviewCommand(relabelNo, request);
PdaRelabelResponses.Detail replay = replay(operatorKey, overseasWarehouseId, relabelNo,
"REVIEW", request.idempotencyKey(), command);
@@ -237,7 +345,8 @@ public class PdaRelabelService {
String decision = request.decision().trim().toUpperCase(Locale.ROOT);
if (!Set.of("APPROVE", "REWORK").contains(decision)) throw badRequest("Review decision must be APPROVE or REWORK");
RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, true);
if (!PROCESSING.equals(application.getStatus())) {
if (!RelabelApplicationService.CUSTOMER_CONFIRM_PENDING.equals(application.getStatus())
&& !PROCESSING.equals(application.getStatus())) {
throw conflict("Relabel application is no longer executable");
}
PdaRelabelTask task = tasks.lock(application.getCustomerId(), application.getId());
@@ -247,17 +356,26 @@ public class PdaRelabelService {
}
if (!WAIT_REVIEW.equals(task.getStatus())) throw conflict("Relabel task is not awaiting review");
requireVersion(task, request.expectedVersion());
// Preserve the previous round before freeing the active-label uniqueness constraint.
List<PdaRelabelUnitFact> previousFacts = factList(task);
insertHistory(task, "REWORK_ARCHIVE", WAIT_REVIEW, PROCESSING, operatorKey, operatorName,
Map.of("facts", previousFacts, "photoFileIds", Objects.toString(task.getCompletionPhotoFileIds(), "[]"),
"completionRemark", Objects.toString(task.getCompletionRemark(), ""), "remark", request.remark()));
for (PdaRelabelUnitFact fact : previousFacts) {
unitFacts.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<PdaRelabelUnitFact>()
.eq(PdaRelabelUnitFact::getId, fact.getId()).eq(PdaRelabelUnitFact::getTaskId, task.getId())
.set(PdaRelabelUnitFact::getResult, "REWORK_ARCHIVED").set(PdaRelabelUnitFact::getOldLabel, null));
}
task.setRelabelledPackages(0); task.setSkippedPackages(0); task.setExceptionPackages(0);
task.setCompletionPhotoFileIds("[]"); task.setCompletionRemark(null); task.setSubmittedAt(null);
applicationService.resetForRework(application.getCustomerId(), relabelNo, operatorKey, request.remark());
application.setStatus(PROCESSING);
String before = task.getStatus();
String after = "APPROVE".equals(decision) ? COMPLETED : PROCESSING;
String after = PROCESSING;
task.setStatus(after); task.setReviewerKey(operatorKey); task.setReviewerName(operatorName);
task.setReviewDecision(decision); task.setReviewRemark(trim(request.remark()));
task.setReviewedAt(LocalDateTime.now());
updateTask(task, request.expectedVersion());
if ("APPROVE".equals(decision)) {
applicationService.changeStatus(application.getCustomerId(), relabelNo, operatorKey,
new RelabelRequests.ChangeStatus(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, request.remark()));
application.setStatus(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING);
}
insertHistory(task, "REVIEW_" + decision, before, after, operatorKey, operatorName,
Map.of("decision", decision, "remark", Objects.toString(trim(request.remark()), "")));
PdaRelabelResponses.Detail result = detail(application, task);
@@ -278,6 +396,23 @@ public class PdaRelabelService {
requireVersion(task, expectedVersion);
RelabelItem item = requireItem(application, itemId);
validation.accept(item);
if ("CARTON_AND_SKU".equals(operationType(item)) && "RELABELLED".equals(resultCode)) {
String stage = "SCAN_CARTON".equals(action) ? "CARTON" : "SKU";
if (mixedStageDone(task, itemId, stage)) throw conflict("本箱该项作业已完成,请勿重复扫描");
boolean otherDone = mixedStageDone(task, itemId, "CARTON".equals(stage) ? "SKU" : "CARTON");
if (factList(task).stream().anyMatch(fact -> itemId.equals(fact.getItemId())))
throw conflict("本箱已经处理完成");
if (unitFacts.findOldLabel(trim(oldLabel)) != null) throw conflict("该仓库唛头已被其他作业处理");
insertHistory(task, "MIXED_" + stage, PROCESSING, PROCESSING, operatorKey, operatorName,
Map.of("itemId", itemId.toString(), "round", mixedRound(task), "oldLabel", oldLabel,
"stage", stage, "request", command));
if (!otherDone) {
updateTask(task, expectedVersion);
PdaRelabelResponses.Detail partial = detail(application, task);
saveOperation(task, operatorKey, action, idempotencyKey, replayCommand, partial);
return partial;
}
}
List<PdaRelabelUnitFact> existingFacts = factList(task);
long itemFacts = existingFacts.stream().filter(fact -> itemId.equals(fact.getItemId())).count();
if (itemFacts >= value(item.getNotifiedPackageCount())) {
@@ -299,7 +434,7 @@ public class PdaRelabelService {
fact.setOperatorKey(operatorKey); fact.setOperatorName(operatorName); fact.setOccurredAt(LocalDateTime.now());
try { unitFacts.insert(fact); }
catch (DuplicateKeyException duplicate) { throw conflict("Old label was processed concurrently"); }
if ("RELABELLED".equals(resultCode)) {
if ("RELABELLED".equals(resultCode) && !"SKU_LABEL".equals(operationType(item))) {
item.setNewCartonMark(resolvedNewLabel);
item.setFbaCartonMark(resolvedNewLabel);
items.updateById(item);
@@ -395,16 +530,37 @@ public class PdaRelabelService {
private List<PdaRelabelUnitFact> factList(PdaRelabelTask task) {
return unitFacts.selectList(new LambdaQueryWrapper<PdaRelabelUnitFact>()
.ne(PdaRelabelUnitFact::getResult, "REWORK_ARCHIVED")
.eq(PdaRelabelUnitFact::getCustomerId, task.getCustomerId())
.eq(PdaRelabelUnitFact::getTaskId, task.getId())
.orderByAsc(PdaRelabelUnitFact::getOccurredAt).orderByAsc(PdaRelabelUnitFact::getId));
}
private String mixedRound(PdaRelabelTask task) {
return Objects.toString(task.getReviewedAt(), "INITIAL");
}
private boolean mixedStageDone(PdaRelabelTask task, Long itemId, String stage) {
if (task == null) return false;
return histories.selectList(new LambdaQueryWrapper<PdaRelabelHistory>()
.eq(PdaRelabelHistory::getTaskId, task.getId())
.eq(PdaRelabelHistory::getCustomerId, task.getCustomerId())
.eq(PdaRelabelHistory::getAction, "MIXED_" + stage)).stream().anyMatch(history -> {
if (!("MIXED_" + stage).equals(history.getAction())) return false;
try {
var data = objectMapper.readTree(history.getDetailJson());
return itemId.toString().equals(data.path("itemId").asText())
&& mixedRound(task).equals(data.path("round").asText());
} catch (Exception invalid) { throw conflict("混合换标作业记录无法读取,请联系平台处理"); }
});
}
private PdaRelabelResponses.Candidate candidate(RelabelApplication application, PdaRelabelTask task,
String operatorKey) {
return new PdaRelabelResponses.Candidate(application.getRelabelNo(), application.getCustomerOrderNo(),
application.getLabelDeadline(), application.getStatus(), task == null ? "AVAILABLE" : task.getStatus(),
value(application.getItemCount()), task == null ? value(application.getNotifiedPackageCount()) : value(task.getExpectedPackages()),
operationType(application), value(application.getItemCount()),
task == null ? value(application.getNotifiedPackageCount()) : value(task.getExpectedPackages()),
task == null ? 0 : value(task.getRelabelledPackages()), task == null ? 0 : value(task.getSkippedPackages()),
task == null ? 0 : value(task.getExceptionPackages()), task == null ? 0 : value(task.getVersion()),
task != null && operatorKey.equals(task.getOperatorKey()));
@@ -422,7 +578,9 @@ public class PdaRelabelService {
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"));
count(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));
}).toList();
List<PdaRelabelResponses.UnitFact> factResponses = facts.stream().map(fact ->
new PdaRelabelResponses.UnitFact(fact.getId(), fact.getItemId(), fact.getOldLabel(), fact.getNewLabel(),
@@ -435,7 +593,7 @@ public class PdaRelabelService {
history.getToStatus(), history.getOperatorName(), history.getDetailJson(), history.getCreatedAt())).toList();
return new PdaRelabelResponses.Detail(application.getRelabelNo(), application.getCustomerOrderNo(),
application.getLabelDeadline(), application.getContactName(), application.getContactPhone(),
application.getStatus(), task == null ? "AVAILABLE" : task.getStatus(),
application.getStatus(), operationType(application), task == null ? "AVAILABLE" : task.getStatus(),
task == null ? value(application.getNotifiedPackageCount()) : value(task.getExpectedPackages()),
task == null ? 0 : value(task.getRelabelledPackages()), task == null ? 0 : value(task.getSkippedPackages()),
task == null ? 0 : value(task.getExceptionPackages()), task == null ? 0 : value(task.getVersion()),
@@ -514,6 +672,26 @@ public class PdaRelabelService {
return StringUtils.hasText(item.getOperationType()) ? item.getOperationType() : "CARTON_LABEL";
}
private String operationType(RelabelApplication application) {
List<RelabelItem> items = itemList(application);
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";
}
private void validateSkuVerification(RelabelItem item, List<PdaRelabelRequests.SkuVerification> scanned) {
if (!Set.of("SKU_LABEL", "CARTON_AND_SKU").contains(operationType(item))) return;
List<PdaRelabelResponses.SkuItem> expected = readSkuItems(item.getSkuRelabelItemsJson());
if (expected.isEmpty()) throw conflict("该箱缺少 SKU 换标及数量明细,请补齐后作业");
List<PdaRelabelRequests.SkuVerification> remaining = new java.util.ArrayList<>(scanned == null ? List.of() : scanned);
if (remaining.size() != expected.size()) throw conflict("请完成该箱全部 SKU 换标核对");
for (PdaRelabelResponses.SkuItem sku : expected) {
boolean matched = remaining.remove(new PdaRelabelRequests.SkuVerification(
sku.skuCode(), sku.newSkuCode(), sku.quantity()));
if (!matched) throw conflict("扫描的旧 SKU、新 SKU 或数量与换标明细不一致");
}
}
private static int count(List<PdaRelabelUnitFact> facts, String result) {
return (int) facts.stream().filter(fact -> result.equals(fact.getResult())).count();
}
@@ -180,7 +180,15 @@ public class RelabelApplicationService implements WalletResultHandler {
if (!String.valueOf(fileId).equals(item.getRelabelFileId())) throw notFound("换标资料未绑定当前明细");
return documentFiles.download(application.getCustomerId(), "OVERSEAS_RELABEL_ITEM_DOCUMENT", String.valueOf(item.getId()), fileId);
}
if (!"TOTAL".equalsIgnoreCase(scope) || itemId != null) throw badRequest("资料范围必须为 TOTAL 或 ITEM,总资料不指定明细");
if (itemId == null && ("CARTON_TOTAL".equalsIgnoreCase(scope) || "TOTAL".equalsIgnoreCase(scope))
&& String.valueOf(fileId).equals(application.getCartonTotalFileId())) {
return documentFiles.download(application.getCustomerId(), "OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT", String.valueOf(application.getId()), fileId);
}
if (itemId == null && ("SKU_TOTAL".equalsIgnoreCase(scope) || "TOTAL".equalsIgnoreCase(scope))
&& String.valueOf(fileId).equals(application.getSkuTotalFileId())) {
return documentFiles.download(application.getCustomerId(), "OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT", String.valueOf(application.getId()), fileId);
}
if (!"TOTAL".equalsIgnoreCase(scope) || itemId != null) throw badRequest("资料未绑定当前申请单");
if (!String.valueOf(fileId).equals(application.getTotalFileId())) throw notFound("换标资料未绑定当前申请单");
return documentFiles.download(application.getCustomerId(), "OVERSEAS_RELABEL_TOTAL_DOCUMENT", String.valueOf(application.getId()), fileId);
}
@@ -323,6 +331,8 @@ public class RelabelApplicationService implements WalletResultHandler {
long applicationId = IdWorker.getId();
String operationWarehouseId = request.operationWarehouseId().trim();
String relabelNo = orderNoGenerator.next("RELABEL", operationWarehouseId, applicationId);
BigDecimal chargeAmount = Objects.requireNonNullElse(request.chargeAmount(), BigDecimal.ZERO);
boolean requiresWallet = chargeAmount.signum() > 0;
int holdIndex = 0;
for (SourceInventory source : sources) {
inventoryCommands.hold(customerId, operatorId, new InventoryCommandRequests.Move(
@@ -352,9 +362,9 @@ public class RelabelApplicationService implements WalletResultHandler {
application.setItemCount(request.items().size());
application.setTotalPackageCount(total);
application.setNotifiedPackageCount(notified);
application.setChargeAmount(request.chargeAmount());
application.setChargeAmount(chargeAmount);
application.setChargeCurrency("CNY");
application.setWalletStatus("FREEZE_PENDING");
application.setWalletStatus(requiresWallet ? "FREEZE_PENDING" : "NOT_REQUIRED");
application.setIdempotencyKey(request.idempotencyKey().trim());
application.setRequestHash(requestHash);
application.setCreatedBy(trim(operatorId));
@@ -362,10 +372,12 @@ public class RelabelApplicationService implements WalletResultHandler {
application.setUpdatedAt(now);
applications.insert(application);
String walletEventId = walletCommands.publish("FREEZE", Long.parseLong(customerId), "RELABEL_ORDER",
relabelNo, request.chargeAmount(), "换标订单费用冻结", 1L);
application.setWalletCommandEventId(walletEventId);
applications.updateById(application);
if (requiresWallet) {
String walletEventId = walletCommands.publish("FREEZE", Long.parseLong(customerId), "RELABEL_ORDER",
relabelNo, chargeAmount, "换标订单费用冻结", 1L);
application.setWalletCommandEventId(walletEventId);
applications.updateById(application);
}
for (SourceInventory source : sources) {
insertItem(customerId, applicationId, source, now);
@@ -395,7 +407,7 @@ public class RelabelApplicationService implements WalletResultHandler {
query.skuCode())
.orderByDesc(RelabelApplication::getCreatedAt);
if (PENDING_PROCESSING.equals(requestedStatus)) {
wrapper.in(RelabelApplication::getStatus, PENDING_PROCESSING, "PROCESSING", CUSTOMER_CONFIRM_PENDING,
wrapper.in(RelabelApplication::getStatus, PENDING_PROCESSING, CUSTOMER_CONFIRM_PENDING,
"REJECTED", "CANCELLED");
} else if ("COMPLETED".equals(requestedStatus)) {
wrapper.in(RelabelApplication::getStatus, READY_FOR_OUTBOUND, "COMPLETED");
@@ -442,7 +454,8 @@ 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 ("PROCESSING".equals(next) && !"FROZEN".equals(application.getWalletStatus())) {
if ("PROCESSING".equals(next)
&& !Set.of("FROZEN", "NOT_REQUIRED").contains(application.getWalletStatus())) {
throw conflict("Relabel funds must be frozen before processing");
}
String before = application.getStatus();
@@ -462,20 +475,22 @@ public class RelabelApplicationService implements WalletResultHandler {
&& !preDispatched) {
releaseInventory(customerId, operatorId, application);
}
BigDecimal quantity = BigDecimal.valueOf(Objects.requireNonNullElse(application.getTotalPackageCount(), 0));
feeSources.publishApproved(operatorId, "RELABEL:" + relabelNo,
new OverseasFeeSourceDtos.SaveRequest(customerId, linkedOrders.get(0).getCustomerName(),
"OVERSEAS_RELABEL_FEE", "RELABEL_ORDER", application.getId(), relabelNo,
application.getLastlegOrderId(), null, null, null, null, null,
parseLong(application.getOverseasWarehouseId()), "RELABEL_SERVICE", "换标费", "RECEIVABLE",
null, null, null, null, null, null, null, List.of(), LocalDate.now(),
application.getChargeCurrency(), application.getChargeAmount(),
quantity.signum() > 0 ? quantity : null, quantity.signum() > 0 ? "BOX" : null,
quantity.signum() > 0 ? application.getChargeAmount().divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null,
null, null));
walletCommands.publish("SETTLE", Long.parseLong(customerId), "RELABEL_ORDER", relabelNo, null,
"换标完成,结算费用", 2L);
application.setWalletStatus("SETTLE_PENDING"); applications.updateById(application);
if (!"NOT_REQUIRED".equals(application.getWalletStatus())) {
BigDecimal quantity = BigDecimal.valueOf(Objects.requireNonNullElse(application.getTotalPackageCount(), 0));
feeSources.publishApproved(operatorId, "RELABEL:" + relabelNo,
new OverseasFeeSourceDtos.SaveRequest(customerId, linkedOrders.get(0).getCustomerName(),
"OVERSEAS_RELABEL_FEE", "RELABEL_ORDER", application.getId(), relabelNo,
application.getLastlegOrderId(), null, null, null, null, null,
parseLong(application.getOverseasWarehouseId()), "RELABEL_SERVICE", "换标费", "RECEIVABLE",
null, null, null, null, null, null, null, List.of(), LocalDate.now(),
application.getChargeCurrency(), application.getChargeAmount(),
quantity.signum() > 0 ? quantity : null, quantity.signum() > 0 ? "BOX" : null,
quantity.signum() > 0 ? application.getChargeAmount().divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null,
null, null));
walletCommands.publish("SETTLE", Long.parseLong(customerId), "RELABEL_ORDER", relabelNo, null,
"换标完成,结算费用", 2L);
application.setWalletStatus("SETTLE_PENDING"); applications.updateById(application);
}
} else if (Set.of("REJECTED", "CANCELLED").contains(next)) {
releaseInventory(customerId, operatorId, application);
if ("FROZEN".equals(application.getWalletStatus())) {
@@ -563,6 +578,21 @@ public class RelabelApplicationService implements WalletResultHandler {
return detail(customerId, relabelNo);
}
@Transactional
public void resetForRework(String customerId, String relabelNo, String operatorId, String remark) {
RelabelApplication application = applications.lockNo(customerId, relabelNo);
if (application == null || !Set.of(CUSTOMER_CONFIRM_PENDING, "PROCESSING").contains(application.getStatus())
|| application.getCustomerConfirmedAt() != null) throw conflict("客户已确认或当前状态不允许退回");
String before = application.getStatus();
application.setStatus("PROCESSING"); application.setFulfillmentStatus("WAIT_RELABEL");
application.setUpdatedAt(LocalDateTime.now()); applications.updateById(application);
for (LastlegOrder order : tailOrders(application)) {
order.setRelabelStatus("PROCESSING"); order.setSubOrderStatus("RELABEL:PROCESSING");
order.setUpdatedAt(LocalDateTime.now()); orders.updateById(order);
}
insertHistory(customerId, application.getId(), before, "PROCESSING", operatorId, remark, LocalDateTime.now());
}
@Transactional
public RelabelResponses.Detail confirmCompletion(String customerId, String relabelNo, String operatorId,
RelabelRequests.CustomerConfirm request) {
@@ -573,6 +603,14 @@ public class RelabelApplicationService implements WalletResultHandler {
if (!CUSTOMER_CONFIRM_PENDING.equals(application.getStatus())) {
throw conflict("Relabel order is not awaiting customer confirmation");
}
PdaRelabelTask reviewTask = pdaTasks.lock(customerId, application.getId());
if (reviewTask == null || !Set.of("WAIT_REVIEW", "COMPLETED").contains(reviewTask.getStatus()))
throw conflict("仓库尚未提交换标完工复核");
reviewTask.setStatus("COMPLETED"); reviewTask.setReviewDecision("CUSTOMER_CONFIRMED");
reviewTask.setReviewerKey(operatorId); reviewTask.setReviewerName("客户确认");
reviewTask.setReviewRemark(request.remark()); reviewTask.setReviewedAt(LocalDateTime.now());
reviewTask.setUpdatedAt(LocalDateTime.now());
if (pdaTasks.updateWithVersion(reviewTask, reviewTask.getVersion()) != 1) throw conflict("复核状态已变化,请刷新");
if (Boolean.TRUE.equals(application.getPrintShippingLabel())
&& !StringUtils.hasText(application.getShippingLabelFileId())) {
throw conflict("Upload the shipping label before confirming relabel completion");
@@ -899,6 +937,7 @@ public class RelabelApplicationService implements WalletResultHandler {
}
private static String businessRelabelStatus(String workflowStatus) {
if ("PROCESSING".equals(workflowStatus)) return "PROCESSING";
return Set.of(READY_FOR_OUTBOUND, "COMPLETED").contains(workflowStatus)
? "COMPLETED" : PENDING_PROCESSING;
}
@@ -641,6 +641,7 @@ public class LastlegOrderService {
}
private static String relabelBusinessStatus(String workflowStatus) {
if ("PROCESSING".equals(workflowStatus)) return "PROCESSING";
if (!StringUtils.hasText(workflowStatus) || "NOT_REQUIRED".equals(workflowStatus)) return workflowStatus;
return Set.of("READY_FOR_OUTBOUND", "COMPLETED").contains(workflowStatus)
? "COMPLETED" : "PENDING_PROCESSING";
@@ -34,6 +34,7 @@ public final class WarehouseTransferResponses {
int packageCount, String deliveryMethod, LocalDateTime estimatedArrivalAt,
LocalDateTime estimatedShipAt,
String fbaShipmentNo, String trackingNo, String fbaBoxNos, String remark,
String reviewDecision, String reviewRemark, String reviewedBy, LocalDateTime reviewedAt,
List<SourceOrder> sourceOrders, List<Item> items, List<Carton> cartons,
List<Pallet> pallets, List<RelabelBinding> relabelBindings,
List<Allocation> allocations, LocalDateTime customerConfirmedAt,
@@ -37,6 +37,10 @@ public class WarehouseTransferOrder {
private String relabelNo;
private LocalDateTime relabelCompletedAt;
private String status;
private String reviewDecision;
private String reviewRemark;
private String reviewedBy;
private LocalDateTime reviewedAt;
private String allocationIdempotencyKey;
private String allocationRequestHash;
private String outboundIdempotencyKey;
@@ -323,7 +323,8 @@ public class WarehouseTransferService {
transfer.getOperatingWarehouseId(), transfer.getFbaWarehouseCode(), transfer.getDeliveryAddress(),
transfer.getPackageCount(), transfer.getDeliveryMethod(), transfer.getEstimatedArrivalAt(),
transfer.getEstimatedShipAt(), transfer.getFbaShipmentNo(), transfer.getTrackingNo(), transfer.getFbaBoxNos(),
transfer.getRemark(), sources, items, cartons, pallets, bindings, allocations,
transfer.getRemark(), transfer.getReviewDecision(), transfer.getReviewRemark(), transfer.getReviewedBy(),
transfer.getReviewedAt(), sources, items, cartons, pallets, bindings, allocations,
transfer.getCustomerConfirmedAt(), transfer.getOutboundAt(), transfer.getCreatedAt());
}
@@ -375,7 +376,13 @@ public class WarehouseTransferService {
String before = order.getCurrentStatus();
boolean incomingTransit = transfer.getTransferNo().equals(transfer.getInboundCode());
String transferStatus = APPROVED.equals(next) && incomingTransit ? WAIT_PDA_RECEIVING : next;
transfer.setStatus(transferStatus); transfer.setUpdatedAt(now); transfers.updateById(transfer);
transfer.setStatus(transferStatus);
transfer.setReviewDecision(next);
transfer.setReviewRemark(trim(request.remark()));
transfer.setReviewedBy(operatorId);
transfer.setReviewedAt(now);
transfer.setUpdatedAt(now);
transfers.updateById(transfer);
String orderStatus = APPROVED.equals(next)
? (incomingTransit
? ("WAREHOUSE_PICKUP".equals(transfer.getDeliveryMethod()) ? "WAIT_PICKUP" : "WAIT_DELIVERY")
@@ -1497,7 +1497,10 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_order (
estimated_arrival_at DATETIME(3) NOT NULL COMMENT '预计入仓时间', fba_shipment_no VARCHAR(128) NULL COMMENT '可选FBA货件编号',
tracking_no VARCHAR(255) NULL COMMENT '物流跟踪号', fba_box_nos VARCHAR(2000) NULL COMMENT 'FBA箱号集合', remark VARCHAR(1000) NULL COMMENT '备注',
need_relabel TINYINT NOT NULL DEFAULT 0 COMMENT '是否需要换标', relabel_application_id BIGINT NULL COMMENT '换标申请单ID', relabel_no VARCHAR(64) NULL COMMENT '换标申请单号',
status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键',
status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态',
review_decision VARCHAR(32) NULL COMMENT '审核决定', review_remark VARCHAR(1000) NULL COMMENT '审核备注或驳回原因',
reviewed_by VARCHAR(128) NULL COMMENT '审核人ID', reviewed_at DATETIME(3) NULL COMMENT '审核时间',
idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键',
allocation_idempotency_key VARCHAR(255) NULL COMMENT '库存分配幂等键',
allocation_request_hash CHAR(64) NULL COMMENT '库存分配请求哈希',
outbound_idempotency_key VARCHAR(255) NULL COMMENT '出库幂等键',
@@ -1534,6 +1537,8 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
PREPARE transfer_relabel_completed_stmt FROM @ddl; EXECUTE transfer_relabel_completed_stmt; DEALLOCATE PREPARE transfer_relabel_completed_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='palletize_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN palletize_idempotency_key VARCHAR(255) NULL COMMENT ''PDA打托幂等键'', ADD COLUMN palletize_request_hash CHAR(64) NULL COMMENT ''PDA打托请求哈希'', ADD COLUMN confirmation_idempotency_key VARCHAR(255) NULL COMMENT ''客户测量确认幂等键'', ADD COLUMN confirmation_request_hash CHAR(64) NULL COMMENT ''客户测量确认请求哈希'', ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认测量时间'''));
PREPARE transfer_fulfillment_stmt FROM @ddl; EXECUTE transfer_fulfillment_stmt; DEALLOCATE PREPARE transfer_fulfillment_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='review_decision'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN review_decision VARCHAR(32) NULL COMMENT ''审核决定'' AFTER status, ADD COLUMN review_remark VARCHAR(1000) NULL COMMENT ''审核备注或驳回原因'' AFTER review_decision, ADD COLUMN reviewed_by VARCHAR(128) NULL COMMENT ''审核人ID'' AFTER review_remark, ADD COLUMN reviewed_at DATETIME(3) NULL COMMENT ''审核时间'' AFTER reviewed_by'));
PREPARE transfer_review_fact_stmt FROM @ddl; EXECUTE transfer_review_fact_stmt; DEALLOCATE PREPARE transfer_review_fact_stmt;
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_source_order (
id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
@@ -2468,14 +2468,14 @@ components:
remark: { type: string, nullable: true, maxLength: 1000 }
RelabelCreate:
type: object
required: [customerOrderNo, labelDeadline, contactName, contactPhone, chargeAmount, idempotencyKey, items]
required: [customerOrderNo, labelDeadline, contactName, contactPhone, idempotencyKey, items]
properties:
customerOrderNo: { 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, nullable: true, minimum: 0.01, description: 本次贴标费用(CNY);未传时不冻结或结算资金 }
remark: { type: string, nullable: true, maxLength: 1000 }
idempotencyKey: { type: string, maxLength: 255 }
deliveryMethod: { type: string, nullable: true, enum: [TRUCK, EXPRESS, TRUCK_PICKUP, EXPRESS_PICKUP, PICKUP], description: 只换箱唛或SKU与箱唛同时换时必填;只换SKU时不传 }
@@ -227,15 +227,27 @@ paths:
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { multipart/form-data: { schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } } } } }
responses: { '200': { description: OVERSEAS_RELABEL_PHOTO文件元数据, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelPhotoResponse' } } } } }
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/photos/files/{fileId}/download:
get:
summary: 读取当前操作人处理中任务的已上传照片,用于缩略图和大图预览
description: 校验 Token 绑定仓、任务认领人和文件的客户及任务归属;移除和重拍只替换待提交文件引用,不删除历史文件。
parameters:
- { name: relabelNo, in: path, required: true, schema: { type: string } }
- { name: fileId, in: path, required: true, schema: { type: integer, format: int64 } }
responses:
'200': { description: 作业照片内容, content: { 'image/*': { schema: { type: string, format: binary } } } }
'404': { description: 照片不属于当前任务或仓库 }
'409': { description: 当前操作人未认领任务或任务不在处理中 }
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/complete:
post:
summary: 提交现场任务等待复核,不提前完成申请
summary: 提交现场任务到复核池并直接等待客户确认
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelComplete' } } } }
responses: { '200': { description: WAIT_REVIEW任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } }, '409': { description: 箱数未核平、照片或版本冲突 } }
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/review:
post:
summary: 现场复核;APPROVE才完成既有换标申请,REWORK退回处理
summary: 已停用;客户确认完成复核,退回重贴由平台执行
deprecated: true
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelReview' } } } }
responses: { '200': { description: COMPLETED或PROCESSING任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } }, '409': { description: 状态或版本冲突 } }
@@ -450,13 +462,14 @@ components:
occurredAt: { type: string, format: date-time }
RelabelCandidate:
type: object
required: [relabelNo, applicationStatus, taskStatus, itemCount, expectedPackages, relabelledPackages, skippedPackages, exceptionPackages, version, claimedByCurrentOperator]
required: [relabelNo, applicationStatus, taskStatus, operationType, itemCount, expectedPackages, relabelledPackages, skippedPackages, exceptionPackages, version, claimedByCurrentOperator]
properties:
relabelNo: { type: string }
customerOrderNo: { type: string, nullable: true }
labelDeadline: { type: string, format: date-time, nullable: true }
applicationStatus: { type: string }
taskStatus: { type: string }
operationType: { type: string, enum: [SKU_LABEL, CARTON_LABEL, CARTON_AND_SKU] }
itemCount: { type: integer }
expectedPackages: { type: integer }
relabelledPackages: { type: integer }
@@ -466,7 +479,7 @@ components:
claimedByCurrentOperator: { type: boolean }
RelabelDetail:
type: object
required: [relabelNo, applicationStatus, taskStatus, expectedPackages, relabelledPackages, skippedPackages, exceptionPackages, version, completionPhotoFileIds, items, unitFacts, history]
required: [relabelNo, applicationStatus, operationType, taskStatus, expectedPackages, relabelledPackages, skippedPackages, exceptionPackages, version, completionPhotoFileIds, items, unitFacts, history]
properties:
relabelNo: { type: string }
customerOrderNo: { type: string, nullable: true }
@@ -474,6 +487,7 @@ components:
contactName: { type: string, nullable: true }
contactPhone: { type: string, nullable: true }
applicationStatus: { type: string }
operationType: { type: string, enum: [SKU_LABEL, CARTON_LABEL, CARTON_AND_SKU] }
taskStatus: { type: string }
expectedPackages: { type: integer }
relabelledPackages: { type: integer }
@@ -513,6 +527,8 @@ components:
relabelledPackages: { type: integer }
skippedPackages: { type: integer }
exceptionPackages: { type: integer }
cartonProcessedPackages: { type: integer, description: 本轮箱唛作业已完成箱数,混合单独立计数 }
skuProcessedPackages: { type: integer, description: 本轮SKU作业已完成箱数,两项均完成才增加整箱已换标数 }
RelabelSkuItem:
type: object
required: [skuCode, newSkuCode, quantity]
@@ -723,8 +739,18 @@ components:
required: [itemId, oldLabel, newLabel, expectedVersion, idempotencyKey]
properties:
itemId: { type: string, pattern: '^[0-9]+$' }
oldLabel: { type: string, maxLength: 255 }
newLabel: { type: string, maxLength: 255 }
oldLabel: { type: string, maxLength: 255, description: 当前箱的仓库唛头,用于逐箱防重复 }
newLabel: { type: string, maxLength: 255, description: 新FBA箱唛,SKU-only不得提交SKU到此字段 }
skuVerifications:
type: array
description: SKU及组合换标必须提交该箱全部旧新SKU和件数核对结果
items:
type: object
required: [skuCode, newSkuCode, quantity]
properties:
skuCode: { type: string }
newSkuCode: { type: string }
quantity: { type: integer, minimum: 1 }
expectedVersion: { type: integer, minimum: 0 }
idempotencyKey: { type: string, maxLength: 255 }
RelabelSkip:
@@ -9,6 +9,8 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import jakarta.validation.Validation;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -24,6 +26,18 @@ class RelabelContractTest {
"newSkuCode", "labelRequirement", "relabelFileId")));
}
@Test
void createAllowsChargeAmountToBeOmitted() {
RelabelRequests.Create request = new RelabelRequests.Create("CUSTOMER-1", "WH-1",
java.time.LocalDateTime.of(2026, 8, 20, 10, 0), "Li", "13800000000", null, null, "key-free",
java.util.List.of(new RelabelRequests.Item("SKU-A", null, null, null, "Product", 1, 1,
"SHIP-1", null, "SKU-B", "Replace", null, null, null)));
try (var validatorFactory = Validation.buildDefaultValidatorFactory()) {
assertTrue(validatorFactory.getValidator().validate(request).stream()
.noneMatch(violation -> "chargeAmount".equals(violation.getPropertyPath().toString())));
}
}
@Test
void schemasDefineRelabelApplicationItemsAndHistoryContracts() throws Exception {
String baseline = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8).toLowerCase();
@@ -89,6 +103,8 @@ class RelabelContractTest {
String relabelCreate = openApi.substring(openApi.indexOf(" RelabelCreate:"),
openApi.indexOf(" RelabelItem:"));
assertFalse(relabelCreate.contains("customerId"));
assertFalse(relabelCreate.substring(relabelCreate.indexOf("required:"), relabelCreate.indexOf("properties:"))
.contains("chargeAmount"));
}
@Test
@@ -96,6 +96,35 @@ class PdaRelabelServiceTest {
verify(applicationService).changeStatus(eq("100"), eq("FBR-1"), eq("7"), any(RelabelRequests.ChangeStatus.class));
}
@Test
void claimRejectsAnotherProcessingTaskOwnedBySameOperator() {
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
when(tasks.lock("100", 1L)).thenReturn(null);
PdaRelabelTask active = task("PROCESSING", 0);
active.setApplicationId(99L);
active.setRelabelNo("FBR-ACTIVE");
when(tasks.lockUnfinishedByOperator("7")).thenReturn(active);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> service.claim("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.Claim("claim-other")));
assertTrue(error.getReason().contains("FBR-ACTIVE"));
verify(tasks, never()).insert(any(PdaRelabelTask.class));
}
@Test
void workPhotoDownloadValidatesTaskOwnershipAndFileScope() {
application.setStatus("PROCESSING");
when(applications.findAnyByRelabelNo("FBR-1")).thenReturn(application);
when(tasks.find("100", 1L)).thenReturn(task("PROCESSING", 0));
service.downloadWorkPhoto("7", 900L, "FBR-1", 99L);
verify(photoFiles).validate(eq("100"), anyLong(), eq(99L));
verify(photoFiles).download(eq("100"), anyLong(), eq(99L));
assertThrows(ResponseStatusException.class, () -> service.downloadWorkPhoto("8", 900L, "FBR-1", 100L));
assertThrows(ResponseStatusException.class, () -> service.downloadWorkPhoto("7", 901L, "FBR-1", 100L));
verify(photoFiles, never()).download(anyString(), anyLong(), eq(100L));
}
@Test
void crossWarehouseClaimIsHiddenAndCannotCreateTask() {
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
@@ -105,6 +134,57 @@ class PdaRelabelServiceTest {
verify(tasks, never()).insert(any(PdaRelabelTask.class));
}
@Test
void skuScanRequiresEverySkuAndNeverWritesSkuIntoCartonLabel() {
application.setStatus("PROCESSING");
item.setOperationType("SKU_LABEL");
item.setCartonMark("BOX-1");
item.setSkuRelabelItemsJson("[{\"skuCode\":\"OLD-A\",\"newSkuCode\":\"NEW-A\",\"quantity\":3}]");
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
when(tasks.lock("100", 1L)).thenReturn(task("PROCESSING", 0));
when(items.selectOne(any())).thenReturn(item);
assertThrows(ResponseStatusException.class, () -> service.scan("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.Scan(10L, "BOX-1", null, 0, "missing-sku")));
assertThrows(ResponseStatusException.class, () -> service.scan("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.Scan(10L, "BOX-1", null, 0, "wrong-quantity",
List.of(new PdaRelabelRequests.SkuVerification("OLD-A", "NEW-A", 2)))));
var result = service.scan("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.Scan(10L, "BOX-1", null, 0, "valid-sku",
List.of(new PdaRelabelRequests.SkuVerification("OLD-A", "NEW-A", 3))));
assertEquals(1, result.relabelledPackages());
verify(items, never()).updateById(any(RelabelItem.class));
assertEquals("BOX-1", persistedFacts.get(0).getOldLabel());
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(booleans = {true, false})
void mixedWorkRequiresBothStagesAndCountsBoxOnce(boolean cartonFirst) {
application.setStatus("PROCESSING");
item.setOperationType("CARTON_AND_SKU"); item.setCartonMark("BOX-1"); item.setFbaCartonMark("FBA-1");
item.setSkuRelabelItemsJson("[{\"skuCode\":\"OLD-A\",\"newSkuCode\":\"NEW-A\",\"quantity\":3}]");
var task = task("PROCESSING", 0);
var savedHistory = new ArrayList<com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory>();
when(histories.selectList(any())).thenAnswer(call -> List.copyOf(savedHistory));
when(histories.insert(any(com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory.class))).thenAnswer(call -> { savedHistory.add(call.getArgument(0)); return 1; });
when(applications.findAnyByRelabelNo("FBR-1")).thenReturn(application);
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
when(tasks.lock("100", 1L)).thenReturn(task);
when(items.selectOne(any())).thenReturn(item);
java.util.function.IntFunction<com.baiyue.lastleg.relabel.pda.dto.PdaRelabelResponses.Detail> carton = version -> service.scanCarton("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.CartonScan("BOX-1", version, "carton-" + version));
java.util.function.IntFunction<com.baiyue.lastleg.relabel.pda.dto.PdaRelabelResponses.Detail> sku = version -> service.scan("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.Scan(10L, "BOX-1", null, version, "sku-" + version, List.of(new PdaRelabelRequests.SkuVerification("OLD-A", "NEW-A", 3))));
var first = (cartonFirst ? carton : sku).apply(0);
assertEquals(0, first.relabelledPackages());
assertEquals(cartonFirst ? 1 : 0, first.items().get(0).cartonProcessedPackages());
assertEquals(cartonFirst ? 0 : 1, first.items().get(0).skuProcessedPackages());
assertThrows(ResponseStatusException.class, () -> (cartonFirst ? carton : sku).apply(1));
assertThrows(ResponseStatusException.class, () -> service.complete("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.Complete(List.of(501L), 1, "too-early", null)));
var second = (cartonFirst ? sku : carton).apply(1);
assertEquals(1, second.relabelledPackages());
assertEquals(1, persistedFacts.size());
assertEquals(1, second.items().get(0).cartonProcessedPackages());
assertEquals(1, second.items().get(0).skuProcessedPackages());
}
@Test
void unitScanLocksItemToApplicationValidatesNewSkuAndUpdatesVersion() {
application.setStatus("PROCESSING");
@@ -167,40 +247,51 @@ class PdaRelabelServiceTest {
verify(photoFiles).validate("100", 20L, 501L);
assertEquals(PdaRelabelService.WAIT_REVIEW, result.taskStatus());
assertEquals("PROCESSING", result.applicationStatus());
verify(applicationService, never()).changeStatus(anyString(), anyString(), anyString(), any());
assertEquals("CUSTOMER_CONFIRM_PENDING", result.applicationStatus());
verify(applicationService).changeStatus(eq("100"), eq("FBR-1"), eq("7"), any());
}
@Test
void approvalUsesExistingApplicationCompletionAndReworkDoesNot() {
application.setStatus("PROCESSING");
PdaRelabelTask approved = task("WAIT_REVIEW", 2);
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
when(tasks.lock("100", 1L)).thenReturn(approved);
var result = service.review("8", "reviewer", 900L, "FBR-1",
new PdaRelabelRequests.Review("APPROVE", 2, "review-1", "ok"));
assertEquals("COMPLETED", result.taskStatus());
assertEquals(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, result.applicationStatus());
verify(applicationService).changeStatus(eq("100"), eq("FBR-1"), eq("8"), any(RelabelRequests.ChangeStatus.class));
assertThrows(ResponseStatusException.class, () -> service.review("8", "reviewer", 900L, "FBR-1",
new PdaRelabelRequests.Review("APPROVE", 2, "review-1", "ok")));
verify(applicationService, never()).changeStatus(anyString(), anyString(), anyString(), any());
}
@Test
void reviewCanReturnTaskForReworkWithoutCompletingApplication() {
application.setStatus("PROCESSING");
application.setStatus("CUSTOMER_CONFIRM_PENDING");
PdaRelabelTask task = task("WAIT_REVIEW", 2);
task.setRelabelledPackages(1);
task.setCompletionPhotoFileIds("[501]");
when(applications.findAnyByRelabelNo("FBR-1")).thenReturn(application);
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
when(tasks.lock("100", 1L)).thenReturn(task);
var result = service.review("8", "reviewer", 900L, "FBR-1",
var result = service.returnForRework("8", "reviewer", "FBR-1",
new PdaRelabelRequests.Review("REWORK", 2, "review-rework", "wrong label"));
assertEquals("PROCESSING", result.taskStatus());
assertEquals("PROCESSING", result.applicationStatus());
assertEquals(0, result.relabelledPackages());
assertEquals("wrong label", result.reviewRemark());
assertTrue(result.completionPhotoFileIds().isEmpty());
verify(applicationService).resetForRework("100", "FBR-1", "8", "wrong label");
verify(applicationService, never()).changeStatus(anyString(), anyString(), anyString(), any());
}
@Test
void reworkRejectsMissingRemarkAndConfirmedApplication() {
assertThrows(ResponseStatusException.class, () -> service.returnForRework("8", "reviewer", "FBR-1",
new PdaRelabelRequests.Review("REWORK", 2, "empty-reason", " ")));
application.setStatus("READY_FOR_OUTBOUND");
when(applications.findAnyByRelabelNo("FBR-1")).thenReturn(application);
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
assertThrows(ResponseStatusException.class, () -> service.returnForRework("8", "reviewer", "FBR-1",
new PdaRelabelRequests.Review("REWORK", 2, "confirmed", "wrong label")));
verify(applicationService, never()).resetForRework(anyString(), anyString(), anyString(), anyString());
}
@Test
void completionRejectsMissingPackageFactsBeforePhotoValidation() {
application.setStatus("PROCESSING");
@@ -75,6 +75,7 @@ class RelabelApplicationServiceTest {
@Mock OutboundPdaTaskMapper outboundTasks;
@Mock WarehouseTransferOrderMapper warehouseTransfers;
@Mock OverseasFeeSourceService feeSources;
@Mock com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper pdaTasks;
@Spy LastlegOrderNoGenerator orderNoGenerator = new LastlegOrderNoGenerator();
private RelabelApplicationService service;
@@ -82,6 +83,12 @@ class RelabelApplicationServiceTest {
@BeforeEach
void setUp() {
service = new RelabelApplicationService();
ReflectionTestUtils.setField(service, "pdaTasks", pdaTasks);
lenient().when(pdaTasks.lock(any(), any())).thenAnswer(call -> {
var task = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask();
task.setStatus("WAIT_REVIEW"); task.setVersion(1); return task;
});
lenient().when(pdaTasks.updateWithVersion(any(), any())).thenReturn(1);
ReflectionTestUtils.setField(service, "applications", applications);
ReflectionTestUtils.setField(service, "items", items);
ReflectionTestUtils.setField(service, "histories", histories);
@@ -152,6 +159,31 @@ class RelabelApplicationServiceTest {
assertEquals("FBR-1", result.application().relabelNo());
}
@Test
void createWithoutChargeSkipsWalletFreeze() {
RelabelRequests.Create charged = request("key-free", 10, 6);
RelabelRequests.Create request = new RelabelRequests.Create(charged.customerOrderNo(),
charged.operationWarehouseId(), charged.labelDeadline(), charged.contactName(), charged.contactPhone(),
null, charged.remark(), charged.idempotencyKey(), charged.items(), charged.deliveryMethod(),
charged.printShippingLabel());
RelabelApplication persisted = application(11L, "FBR-FREE", "10001", "key-free", 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", 10, 6)));
when(histories.selectList(any())).thenReturn(List.of());
when(orders.selectById(21L)).thenReturn(order(21L, "10001", "LMO-RELABEL-FREE",
RelabelApplicationService.PENDING_PROCESSING));
service.create("10001", "operator-1", request);
ArgumentCaptor<RelabelApplication> application = ArgumentCaptor.forClass(RelabelApplication.class);
verify(applications).insert(application.capture());
assertEquals(BigDecimal.ZERO, application.getValue().getChargeAmount());
assertEquals("NOT_REQUIRED", application.getValue().getWalletStatus());
verify(walletCommands, never()).publish(any(), any(Long.class), any(), any(), any(), any(), any(Long.class));
}
@Test
void partialRelabelFreezesOnlyNotifiedPackages() {
RelabelRequests.Create request = request("key-partial", 10, 2);
@@ -363,6 +395,23 @@ class RelabelApplicationServiceTest {
verify(applications).findNo("10002", "FBR-1");
}
@Test
void applicationWithoutChargeCanEnterProcessing() {
RelabelApplication application = application(11L, "FBR-FREE", "10001", "key-free", "hash",
RelabelApplicationService.PENDING_PROCESSING);
application.setLastlegOrderId(21L);
application.setChargeAmount(BigDecimal.ZERO);
application.setWalletStatus("NOT_REQUIRED");
when(applications.lockNo("10001", "FBR-FREE")).thenReturn(application);
stubDetail(application);
service.changeStatus("10001", "FBR-FREE", "operator-2",
new RelabelRequests.ChangeStatus("PROCESSING", "started"));
assertEquals("PROCESSING", application.getStatus());
verify(walletCommands, never()).publish(any(), any(Long.class), any(), any(), any(), any(), any(Long.class));
}
@Test
void completedReturnRelabelOrderEntersPendingAssignment() {
RelabelApplication application = application(11L, "FBR-1", "10001", "key-1", "hash", RelabelApplicationService.READY_FOR_OUTBOUND);
@@ -414,6 +463,10 @@ class RelabelApplicationServiceTest {
assertEquals(RelabelApplicationService.READY_FOR_OUTBOUND, application.getStatus());
assertEquals("WAIT_PRE_DISPATCH", application.getFulfillmentStatus());
var review = ArgumentCaptor.forClass(com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask.class);
verify(pdaTasks).updateWithVersion(review.capture(), eq(1));
assertEquals("COMPLETED", review.getValue().getStatus());
assertEquals("CUSTOMER_CONFIRMED", review.getValue().getReviewDecision());
assertNull(application.getOutboundOrderNo());
verify(outboundOrders, never()).insert(any(OutboundOrder.class));
@@ -262,6 +262,12 @@ class WarehouseTransferServiceTest {
new WarehouseTransferRequests.Review("REJECT", " 地址不完整 "));
assertEquals("REJECTED", result.status());
assertEquals("CANCELLED", tailOrder.getCurrentStatus());
assertEquals("REJECTED", transfer.getReviewDecision());
assertEquals("地址不完整", transfer.getReviewRemark());
assertEquals("USER-1", transfer.getReviewedBy());
assertNotNull(transfer.getReviewedAt());
assertEquals("REJECTED", result.reviewDecision());
assertEquals("地址不完整", result.reviewRemark());
var history = ArgumentCaptor.forClass(com.baiyue.lastleg.entity.LastlegOrderStatusHistory.class);
verify(orderHistories).insert(history.capture());
assertTrue(history.getValue().getRemark().endsWith("; 地址不完整"));