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
+11 -1
View File
@@ -2,4 +2,14 @@ spring.datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${AUTH_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${AUTH_DB_USERNAME:root}
password: ${AUTH_DB_PASSWORD:123456}
password: ${AUTH_DB_PASSWORD:123456}
baiyue:
security:
overseas-jwt:
enabled: ${OVERSEAS_JWT_ENABLED:true}
issuer: ${OVERSEAS_JWT_ISSUER:auth-service}
audience: ${OVERSEAS_JWT_AUDIENCE:lastleg-api}
rsa-jwk-set: ${OVERSEAS_JWT_RSA_JWK_SET:}
current-kid: ${OVERSEAS_JWT_CURRENT_KID:}
access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m}
+1 -1
View File
@@ -4,7 +4,7 @@ spring.datasource:
username: ${IAM_DB_USERNAME:root}
password: ${IAM_DB_PASSWORD:123456}
baiyue:
security:
security-event:
# 本机只运行一个 Event Bridge;部署环境通过 SECURITY_EVENT_BRIDGE_URL 指向海外实例。
event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://127.0.0.1:9312}
customer-dimension-initialization:
+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("; 地址不完整"));
+1
View File
@@ -273,5 +273,6 @@ Dry-run 只校验两个统一 SQL 的入口标记,打印当前 SHA-256、行
## 海外换标定向验收补充
- `scripts/current-session-overseas-relabel-acceptance.mjs`:本地开发环境定向入口,从当前 Codex 内置浏览器本地存储读取最新平台/客户 refresh token,在内存中刷新双区域会话,自动选择当前客户具备足量库存的海外仓并临时创建海外 PDA 操作员;随后串行运行换标拆/合托、智能预排、PDA 出库与派送完成。报告写入 `reports/current-session-overseas-relabel/`,不记录 Token/密码;会消耗当前浏览器 refresh token 并保留业务验收单据,临时 PDA 在 `finally` 中停用。只允许本机开发环境且须有当前轮真实写入授权。
- `scripts/overseas-relabel-real-acceptance.mjs`:定向真实 HTTP 换标作业,会创建换标、库存及排车记录;从显式环境文件读取平台/PDA账号、网关及客户参数,输出脚本配置的报告,不自动清理。失败保留证据与已创建资源,重跑前核对报告,必须另获指定环境授权。
- `scripts/overseas-relabel-fulfillment-acceptance.mjs`:读取 `OVERSEAS_ACCEPTANCE_SOURCE` 或指定排车/换标编号,执行 PDA 拣货、出库、派送后续真实 HTTP,会推进业务状态;凭据来自显式环境文件,报告默认 `reports/overseas-relabel-fulfillment-acceptance.json`(`OVERSEAS_FULFILLMENT_OUTPUT` 可覆盖)。失败保留资源,重跑前核对业务状态,不自动清理。两个定向脚本不替代 Admin 全量入口,本次合并仅做离线合同。
@@ -327,6 +327,93 @@ export async function run(context) {
{ ...mergeBody, weight: 3 }, {}, picker.token)
add(context, '合箱同键异载荷返回冲突', mergeConflict, conflict(mergeConflict))
}
const relabelCandidates = await overseas(context, merchantToken, 'GET', '客户查询本轮退货换标候选',
`${RETURN_ORDERS}/${encodeURIComponent(returnNo)}/relabel-candidates`)
const relabelCandidate = (Array.isArray(relabelCandidates.data) ? relabelCandidates.data : [])
.find(row => nonBlank(row?.cartonMark) && row?.status === 'AVAILABLE')
context.assert(MODULE, '退货换标候选来自本轮已签收可用箱', relabelCandidates.response,
Boolean(relabelCandidate) && relabelCandidate.sourceReturnNo === returnNo,
`cartonMark=${relabelCandidate?.cartonMark || 'missing'}`, OPTIONS)
if (relabelCandidate) {
const relabelBody = {
cartonMarks: [relabelCandidate.cartonMark], idempotencyKey: key(context, 'RELABEL'),
contactName: '退货换标验收', contactPhone: '13800000008', deliveryMethod: 'TRUCK', remark: context.marker,
}
const relabel = await overseas(context, merchantToken, 'POST', '客户从退货箱创建换标单',
`${RETURN_ORDERS}/${encodeURIComponent(returnNo)}/relabel`, relabelBody)
const relabelNo = id(relabel.data?.relabelNo)
context.assert(MODULE, '退货换标单绑定原退货单与选中箱', relabel.response,
nonBlank(relabelNo) && relabel.data?.returnNo === returnNo
&& relabel.data?.status === 'PENDING_PROCESSING'
&& relabel.data?.cartonMarks?.includes?.(relabelCandidate.cartonMark),
`relabelNo=${relabelNo || 'missing'}`, OPTIONS)
if (relabelNo) {
const form = new FormData()
form.append('file', new Blob([new TextEncoder().encode(`%PDF-1.4\n${context.marker}\n%%EOF`)],
{ type: 'application/pdf' }), `${suffix}-return-relabel.pdf`)
const uploaded = await context.requestOverseasMultipart('POST',
`/api/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/files?scope=CARTON_TOTAL`,
form, {}, merchantToken)
add(context, '客户编辑退货换标总资料', uploaded, success(uploaded))
const relabelDetail = await overseas(context, merchantToken, 'GET', '客户回查退货换标资料修改',
`/api/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}`)
context.assert(MODULE, '退货换标详情持久化总资料且业务状态未漂移', relabelDetail.response,
relabelDetail.data?.application?.relabelNo === relabelNo
&& relabelDetail.data?.application?.status === 'PENDING_PROCESSING'
&& nonBlank(relabelDetail.data?.application?.totalFileId), trace(relabelDetail.response), OPTIONS)
context.createdResources.phase4OverseasReturnReceipt.relabelNo = relabelNo
}
}
const outboundCandidates = await overseas(context, context.overseasToken, 'GET', '平台查询本轮退货出库候选',
query('/api/platform/v1/last-leg/outbound-orders/return/candidates', {
customerId, overseasWarehouseId: warehouse.id,
}))
const outboundCandidate = (Array.isArray(outboundCandidates.data) ? outboundCandidates.data : [])
.find(row => row?.returnNo === returnNo && nonBlank(row?.cartonMark)
&& row.cartonMark !== relabelCandidate?.cartonMark && row.status === 'AVAILABLE')
context.assert(MODULE, '退货出库候选排除已冻结换标箱', outboundCandidates.response,
Boolean(outboundCandidate), `cartonMark=${outboundCandidate?.cartonMark || 'missing'}`, OPTIONS)
if (outboundCandidate) {
const outboundBody = {
recipient: `${context.marker} RETURN OUTBOUND`, overseasWarehouseId: warehouse.id,
returnNo, cartonMarks: [outboundCandidate.cartonMark], remark: context.marker,
idempotencyKey: key(context, 'OUTBOUND'),
}
const outbound = await overseas(context, context.overseasToken, 'POST', '平台创建独立退货出库单',
query('/api/platform/v1/last-leg/outbound-orders/return', { customerId }), outboundBody)
const taskNo = id(outbound.data?.taskNo)
const outboundNo = id(outbound.data?.outboundNo)
context.assert(MODULE, 'RETURN_OUTBOUND创建PDA待拣任务', outbound.response,
nonBlank(taskNo) && nonBlank(outboundNo) && outbound.data?.status === 'PENDING',
`outboundNo=${outboundNo || 'missing'}, taskNo=${taskNo || 'missing'}`, OPTIONS)
if (taskNo) {
const task = await overseas(context, picker.token, 'GET', 'PDA读取退货出库任务明细',
`/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(taskNo)}`)
const taskItem = task.data?.items?.find?.(row => String(row?.cartonMarks || '').includes(outboundCandidate.cartonMark))
|| task.data?.items?.[0]
const pickBody = { outboundPalletItemId: taskItem?.outboundPalletItemId,
inventoryPositionId: null, packages: 1, cartonMarks: [outboundCandidate.cartonMark],
idempotencyKey: key(context, 'OUTBOUND-PICK') }
const picked = await overseas(context, picker.token, 'POST', 'PDA一次扫描退货箱完成拣货',
`/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(taskNo)}/pick`, pickBody)
context.assert(MODULE, '退货出库拣货推进为PICKED且任务进行中', picked.response,
picked.data?.outboundNo === outboundNo && picked.data?.status === 'IN_PROGRESS', trace(picked.response), OPTIONS)
const completed = await overseas(context, picker.token, 'POST', 'PDA完成退货出库',
`/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(taskNo)}/complete`,
{ idempotencyKey: key(context, 'OUTBOUND-COMPLETE') })
const outboundDetail = await overseas(context, context.overseasToken, 'GET', '平台回查退货出库最终状态',
query(`/api/platform/v1/last-leg/outbound-orders/${encodeURIComponent(outboundNo)}`, { customerId }))
context.assert(MODULE, 'RETURN_OUTBOUND真实完成且箱唛只出库一次', outboundDetail.response,
completed.data?.status === 'COMPLETED'
&& outboundDetail.data?.outbound?.outboundType === 'RETURN_OUTBOUND'
&& outboundDetail.data?.outbound?.status === 'OUTBOUNDED'
&& outboundDetail.data?.pallets?.some?.(row => row.cartonMarks === outboundCandidate.cartonMark
&& Number(row.pickedPackageCount) === 1), trace(outboundDetail.response), OPTIONS)
context.createdResources.phase4OverseasReturnReceipt.returnOutboundNo = outboundNo
}
}
} finally {
await disablePicker(context, picker, 91)
}
@@ -65,6 +65,9 @@ export async function verifyWarehouseTransferReview(context, token, customerId,
ok(result) && ok(detail) && detail.body.data?.status === expected
&& detail.body.data?.orderType === 'WAREHOUSE_TRANSFER'
&& detail.body.data?.orderStatus === expectedOrder
&& detail.body.data?.reviewDecision === `${decision}ED`.replace('APPROVEED', 'APPROVED')
&& detail.body.data?.reviewRemark === `${context.marker}-审核原因`
&& Boolean(detail.body.data?.reviewedBy) && Boolean(detail.body.data?.reviewedAt)
&& (detail.body.data?.pallets || []).length === 0, evidence(detail), OPTIONS)
context.createdResources.phase4OverseasClientServices[`transit${decision}`] = { transferNo: no, status: detail.body?.data?.status }
}
@@ -0,0 +1,173 @@
#!/usr/bin/env node
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { spawnSync } from 'node:child_process'
const domestic = process.env.ADMIN_TEST_GATEWAY || 'http://127.0.0.1:9000'
const overseas = process.env.ADMIN_OVERSEAS_GATEWAY || 'http://127.0.0.1:9300'
const store = process.env.CODEX_BROWSER_LOCAL_STORAGE || join(process.env.APPDATA || '', 'Codex', 'web', 'Codex', 'Default', 'Partitions', 'codex-browser-app', 'Local Storage', 'leveldb')
const outputDir = resolve(process.env.OVERSEAS_CURRENT_SESSION_OUTPUT_DIR || 'reports/current-session-overseas-relabel')
const adminLoginId = process.env.ADMIN_TEST_USERNAME || 'platform_admin'
const adminPassword = process.env.ADMIN_TEST_PASSWORD || 'ChangeMe123!'
const marker = `CURRENT-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}`
const summary = { marker, startedAt: new Date().toISOString(), scope: 'CURRENT_CODEX_BROWSER_SESSION', passed: false }
function assert(value, message) { if (!value) throw new Error(message) }
function id(value) { return value == null ? '' : String(value) }
function jwtId(token, ...claimNames) {
try {
const payload = Buffer.from(token.split('.')[1], 'base64url').toString('utf8')
for (const claimName of claimNames) {
const match = payload.match(new RegExp(`"${claimName}"\\s*:\\s*(?:"(\\d+)"|(\\d+))`))
if (match) return match[1] || match[2]
}
} catch {}
return ''
}
async function api(base, token, method, path, body) {
const response = await fetch(`${base}${path}`, { method,
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
body: body === undefined ? undefined : JSON.stringify(body) })
const text = await response.text(); let value
try { value = text ? JSON.parse(text) : null } catch { value = { raw: text } }
if (!response.ok || String(value?.code) !== '200') throw new Error(`${method} ${path}: HTTP=${response.status}, code=${value?.code}, message=${value?.message}`)
return value.data
}
async function rawApi(base, token, method, path, body) {
const response = await fetch(`${base}${path}`, { method,
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
body: body === undefined ? undefined : JSON.stringify(body) })
const text = await response.text(); let value
try { value = text ? JSON.parse(text) : null } catch { value = null }
return { http: response.status, code: String(value?.code || ''), data: value?.data }
}
function storedValues(key) {
assert(existsSync(store), `Codex browser local storage not found: ${store}`)
const values = []
const files = readdirSync(store).filter((name) => /\.(?:log|ldb)$/.test(name))
.map((name) => ({ name, path: join(store, name) }))
.sort((left, right) => readFileSync(right.path).length - readFileSync(left.path).length)
for (const file of files) {
const source = readFileSync(file.path).toString('latin1')
let offset = 0
while ((offset = source.indexOf(key, offset)) >= 0) {
const match = source.slice(offset + key.length, offset + key.length + 256).match(/[A-Za-z0-9_-]{48,160}/)
if (match) values.push(match[0])
offset += key.length
}
}
return [...new Set(values)].reverse()
}
function storedJwt(key) {
for (const file of readdirSync(store).filter((name) => /\.(?:log|ldb)$/.test(name)).reverse()) {
const source = readFileSync(join(store, file)).toString('latin1')
let offset = source.lastIndexOf(key)
while (offset >= 0) {
const match = source.slice(offset + key.length, offset + key.length + 2000)
.match(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/)
if (match) return match[0]
offset = source.lastIndexOf(key, offset - 1)
}
}
return ''
}
async function refreshStoredSession(key, label) {
for (const refreshToken of storedValues(key)) {
try { return await api(domestic, '', 'POST', '/api/auth/refresh', { refreshToken }) } catch {}
}
throw new Error(`No valid ${label} refresh token was found in the current Codex browser session`)
}
async function disableStalePdas(adminToken) {
const users = await api(domestic, adminToken, 'GET', '/api/platform/v1/iam/users?page=1&size=100&keyword=pda_current_')
for (const user of users.records || []) {
if (String(user.username || '').startsWith('pda_current_') && user.status === 'ACTIVE') {
await api(domestic, adminToken, 'PUT', `/api/platform/v1/iam/users/${id(user.membershipId || user.id)}/status`, { status: 'DISABLED' })
}
}
}
async function createPda(adminToken, warehouseId, onCreated) {
const roles = await api(domestic, adminToken, 'GET', '/api/platform/v1/iam/roles')
const role = roles.find((item) => item.roleCode === 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' && item.status === 'ACTIVE')
assert(role, 'Active OVERSEAS_WAREHOUSE_PDA_OPERATOR role not found')
const suffix = `${Date.now()}${process.pid}`.slice(-12)
const username = `pda_current_${suffix}`
const password = `PdaCurrent${suffix}A9`
const created = await api(domestic, adminToken, 'POST', '/api/platform/v1/iam/users', {
username, displayName: `当前会话海外PDA验收-${suffix}`, phone: `19${suffix.slice(-9)}`,
email: `${username}@example.com`, tenantId: '0', roleIds: [id(role.id)],
overseasWarehouseId: warehouseId, password, status: 'ACTIVE',
})
onCreated({ membershipId: id(created.membershipId), token: null })
const login = await api(domestic, '', 'POST', '/api/auth/login', { loginId: username, password })
assert(login.overseasAccessToken, 'Temporary PDA login did not return an overseas token')
onCreated({ membershipId: id(created.membershipId), token: login.overseasAccessToken })
for (let attempt = 0; attempt < 60; attempt += 1) {
const response = await rawApi(overseas, login.overseasAccessToken, 'GET', '/api/pda/v1/last-leg/receiving-containers/current')
if (response.http === 200 && response.code !== '403' && response.code !== 'A0403') {
return { membershipId: id(created.membershipId), token: login.overseasAccessToken }
}
await new Promise((resolve) => setTimeout(resolve, 500))
}
throw new Error('Temporary PDA security projection did not converge')
}
await mkdir(outputDir, { recursive: true })
let admin
let pda
try {
try {
admin = await refreshStoredSession('cc-admin-refresh-token', 'platform')
} catch {
admin = await api(domestic, '', 'POST', '/api/auth/login', { loginId: adminLoginId, password: adminPassword })
}
const storedMerchantToken = storedJwt('cc-client-overseas-access-token')
const storedCustomerId = jwtId(storedMerchantToken, 'tenant_id', 'tenantId')
let merchant
try {
merchant = await refreshStoredSession('cc-client-refresh-token', 'merchant')
} catch {
assert(storedCustomerId, 'Current Codex merchant session does not contain a customer id')
const ticket = await api(domestic, admin.accessToken, 'POST',
`/api/platform/v1/iam/tenants/${storedCustomerId}/impersonation`, { reason: `${marker} 当前客户海外API验收` })
merchant = await api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code })
}
assert(admin.accessToken && admin.overseasAccessToken && merchant.accessToken && merchant.overseasAccessToken, 'Refresh response did not contain both regional tokens')
const customerId = jwtId(merchant.overseasAccessToken, 'tenant_id', 'tenantId')
assert(customerId, 'Current merchant customer id is missing from the overseas token')
const warehouses = await api(domestic, merchant.accessToken, 'GET', '/api/merchant/v1/warehouses?warehouseType=OVERSEAS&page=1&size=200')
let selected = null
for (const warehouse of warehouses.records || []) {
if (warehouse.status !== 'ACTIVE') continue
const candidates = await api(overseas, merchant.overseasAccessToken, 'GET', `/api/merchant/v1/last-leg/relabel-applications/inventory-candidates?operationWarehouseId=${encodeURIComponent(id(warehouse.id))}`)
if (candidates.some((item) => String(item.inboundOrderNo || '').startsWith('DS-') && Number(item.availablePackages) >= 5 && item.destinationFbaWarehouseCode && item.positions?.some((position) => Number(position.availablePackages) >= 5))) {
selected = warehouse; break
}
}
assert(selected, 'Current merchant has no active overseas warehouse with a five-carton DS inventory source')
await disableStalePdas(admin.accessToken)
pda = await createPda(admin.accessToken, id(selected.id), (created) => { pda = created })
const relabelReport = join(outputDir, 'relabel.json')
const fulfillmentReport = join(outputDir, 'fulfillment.json')
const childEnv = { ...process.env, ADMIN_TEST_GATEWAY: domestic, ADMIN_OVERSEAS_GATEWAY: overseas,
ADMIN_OVERSEAS_ACCESS_TOKEN: admin.overseasAccessToken, MERCHANT_OVERSEAS_ACCESS_TOKEN: merchant.overseasAccessToken,
PDA_OVERSEAS_ACCESS_TOKEN: pda.token, OVERSEAS_TEST_CUSTOMER_ID: customerId,
OVERSEAS_TEST_WAREHOUSE_ID: id(selected.id), OVERSEAS_ACCEPTANCE_OUTPUT: relabelReport,
OVERSEAS_ACCEPTANCE_SOURCE: relabelReport, OVERSEAS_FULFILLMENT_OUTPUT: fulfillmentReport }
for (const script of ['overseas-relabel-real-acceptance.mjs', 'overseas-relabel-fulfillment-acceptance.mjs']) {
const result = spawnSync(process.execPath, [resolve('scripts', script)], { cwd: resolve('.'), env: childEnv, stdio: 'inherit' })
if (result.error) throw result.error
if (result.status !== 0) throw new Error(`${script} failed with exit code ${result.status}`)
}
Object.assign(summary, { customerId, warehouseId: id(selected.id), warehouseCode: selected.warehouseCode,
relabelReport, fulfillmentReport, passed: true, finishedAt: new Date().toISOString() })
} finally {
if (admin?.accessToken && pda?.membershipId) {
try { await api(domestic, admin.accessToken, 'PUT', `/api/platform/v1/iam/users/${pda.membershipId}/status`, { status: 'DISABLED' }); summary.pdaCleanup = 'DISABLED' }
catch (error) { summary.pdaCleanup = `FAILED: ${error.message}` }
}
await writeFile(join(outputDir, 'summary.json'), JSON.stringify(summary, null, 2), 'utf8')
}
console.log(JSON.stringify(summary, null, 2))
@@ -7,9 +7,13 @@ const directDispatchNo = process.env.OVERSEAS_DISPATCH_NO
const directRelabelNo = process.env.OVERSEAS_RELABEL_NO
const output = process.env.OVERSEAS_FULFILLMENT_OUTPUT || 'reports/overseas-relabel-fulfillment-acceptance.json'
const customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID
const directPlatformToken = process.env.ADMIN_OVERSEAS_ACCESS_TOKEN
const directMerchantToken = process.env.MERCHANT_OVERSEAS_ACCESS_TOKEN
const directPdaToken = process.env.PDA_OVERSEAS_ACCESS_TOKEN
const marker = `FUL-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}`
const required = ['ADMIN_TEST_USERNAME', 'ADMIN_TEST_PASSWORD', 'PDA_TEST_USERNAME', 'PDA_TEST_PASSWORD']
for (const name of required) if (!process.env[name]) throw new Error(`Missing ${name}`)
if (!directPlatformToken && (!process.env.ADMIN_TEST_USERNAME || !process.env.ADMIN_TEST_PASSWORD)) throw new Error('Missing platform credentials or token')
if (!directMerchantToken && (!process.env.ADMIN_TEST_USERNAME || !process.env.ADMIN_TEST_PASSWORD)) throw new Error('Missing merchant token or platform credentials')
if (!directPdaToken && (!process.env.PDA_TEST_USERNAME || !process.env.PDA_TEST_PASSWORD)) throw new Error('Missing PDA credentials or token')
if ((!sourceReport && !directDispatchNo) || !customerId) throw new Error('Missing acceptance source/dispatch or customer id')
function id(value) { return value == null ? '' : String(value) }
@@ -33,16 +37,16 @@ const scenario = source?.scenarios.find((value) => value.name === 'SPLIT-TWO-TAR
|| { dispatchNo: directDispatchNo, relabelNo: directRelabelNo || '' }
assert(scenario?.dispatchNo, 'Dispatch not found in source report')
const dispatchNo = scenario.dispatchNo
const platformLogin = await api(domestic, '', 'POST', '/api/auth/login', {
const platformLogin = directPlatformToken ? null : await api(domestic, '', 'POST', '/api/auth/login', {
loginId: process.env.ADMIN_TEST_USERNAME, password: process.env.ADMIN_TEST_PASSWORD })
const pdaLogin = await api(domestic, '', 'POST', '/api/auth/login', {
const pdaLogin = directPdaToken ? null : await api(domestic, '', 'POST', '/api/auth/login', {
loginId: process.env.PDA_TEST_USERNAME, password: process.env.PDA_TEST_PASSWORD })
const ticket = await api(domestic, platformLogin.accessToken, 'POST',
const ticket = directMerchantToken ? null : await api(domestic, platformLogin.accessToken, 'POST',
`/api/platform/v1/iam/tenants/${customerId}/impersonation`, { reason: `${marker} 海外尾程履约验收` })
const merchantLogin = await api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code })
const platformToken = platformLogin.overseasAccessToken
const merchantToken = merchantLogin.overseasAccessToken
const pdaToken = pdaLogin.overseasAccessToken
const merchantLogin = directMerchantToken ? null : await api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code })
const platformToken = directPlatformToken || platformLogin.overseasAccessToken
const merchantToken = directMerchantToken || merchantLogin.overseasAccessToken
const pdaToken = directPdaToken || pdaLogin.overseasAccessToken
await api(overseas, platformToken, 'PUT', `/api/platform/v1/last-leg/dispatches/${dispatchNo}`, {
vehiclePlate: `UAT-${marker.slice(-8)}`, driverName: '尾程验收司机', driverPhone: '19900000000',
+14 -8
View File
@@ -8,12 +8,18 @@ const pdaUser = process.env.PDA_TEST_USERNAME
const pdaPassword = process.env.PDA_TEST_PASSWORD
const customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID
const warehouseId = process.env.OVERSEAS_TEST_WAREHOUSE_ID
const directPlatformToken = process.env.ADMIN_OVERSEAS_ACCESS_TOKEN
const directMerchantToken = process.env.MERCHANT_OVERSEAS_ACCESS_TOKEN
const directPdaToken = process.env.PDA_OVERSEAS_ACCESS_TOKEN
const output = process.env.OVERSEAS_ACCEPTANCE_OUTPUT || 'reports/overseas-relabel-real-acceptance.json'
const marker = `TAIL-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}`
for (const [name, value] of Object.entries({ platformUser, platformPassword, pdaUser, pdaPassword, customerId, warehouseId })) {
for (const [name, value] of Object.entries({ customerId, warehouseId })) {
if (!value) throw new Error(`Missing environment variable: ${name}`)
}
if (!directPlatformToken && (!platformUser || !platformPassword)) throw new Error('Missing platform credentials or token')
if (!directMerchantToken && (!platformUser || !platformPassword)) throw new Error('Missing merchant token or platform credentials')
if (!directPdaToken && (!pdaUser || !pdaPassword)) throw new Error('Missing PDA credentials or token')
const report = { marker, startedAt: new Date().toISOString(), scope: 'OVERSEAS_LASTLEG_ONLY', scenarios: [], passed: false }
function key(value) { return `${marker}-${value}`.slice(0, 200) }
@@ -42,14 +48,14 @@ async function poll(action, predicate, attempts = 40) {
}
function assert(condition, message) { if (!condition) throw new Error(message) }
const platformLogin = await api(domestic, '', 'POST', '/api/auth/login', { loginId: platformUser, password: platformPassword })
const pdaLogin = await api(domestic, '', 'POST', '/api/auth/login', { loginId: pdaUser, password: pdaPassword })
const ticket = await api(domestic, platformLogin.accessToken, 'POST',
const platformLogin = directPlatformToken ? null : await api(domestic, '', 'POST', '/api/auth/login', { loginId: platformUser, password: platformPassword })
const pdaLogin = directPdaToken ? null : await api(domestic, '', 'POST', '/api/auth/login', { loginId: pdaUser, password: pdaPassword })
const ticket = directMerchantToken ? null : await api(domestic, platformLogin.accessToken, 'POST',
`/api/platform/v1/iam/tenants/${customerId}/impersonation`, { reason: `${marker} 海外尾程真实验收` })
const merchantLogin = await api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code })
const platformToken = platformLogin.overseasAccessToken
const merchantToken = merchantLogin.overseasAccessToken
const pdaToken = pdaLogin.overseasAccessToken
const merchantLogin = directMerchantToken ? null : await api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code })
const platformToken = directPlatformToken || platformLogin.overseasAccessToken
const merchantToken = directMerchantToken || merchantLogin.overseasAccessToken
const pdaToken = directPdaToken || pdaLogin.overseasAccessToken
assert(platformToken && merchantToken && pdaToken, 'Missing overseas token')
const candidates = await api(overseas, merchantToken, 'GET',