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
@@ -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")