diff --git a/config/nacos/local/auth-service.yml b/config/nacos/local/auth-service.yml index 8843875b3..64d7b2be0 100644 --- a/config/nacos/local/auth-service.yml +++ b/config/nacos/local/auth-service.yml @@ -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} diff --git a/config/nacos/local/iam-service.yml b/config/nacos/local/iam-service.yml index f821f2289..a1a4c5f90 100644 --- a/config/nacos/local/iam-service.yml +++ b/config/nacos/local/iam-service.yml @@ -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: diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 0cf63a895..3ddb7e31a 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -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;生产/存量库只执行增量脚本。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java index 59cc13d60..88a51cd6f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java @@ -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 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 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 + "')") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java index cba655675..d0d73eacc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java @@ -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, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java index f01660cc8..7bc4d4595 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java @@ -62,6 +62,33 @@ public class PdaRelabelController { identity.overseasWarehouseId(), relabelNo, request)); } + @PostMapping("/{relabelNo}/cartons/scan") + public ApiResponse 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> 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 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 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 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 complete(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Complete request) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java index 24e64d886..e402ee3fb 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java @@ -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 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 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, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java index f45764c81..92b660d53 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java @@ -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 completionPhotoFileIds, @@ -26,7 +26,7 @@ public final class PdaRelabelResponses { String cartonMark, String fbaCartonMark, String operationType, List 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, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelTaskMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelTaskMapper.java index ee6b56521..66f504d4b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelTaskMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelTaskMapper.java @@ -15,6 +15,9 @@ public interface PdaRelabelTaskMapper extends BaseMapper { @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}, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java index ce592573e..a4a5c9f73 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java @@ -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 candidates(String operatorKey, long overseasWarehouseId) { return applications.selectList(new LambdaQueryWrapper() .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 documents(long warehouseId, String relabelNo) { + RelabelApplication app = requireApplication(relabelNo, warehouseId, false); + List 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 detailItems = itemList(application); List 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 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() + .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() + .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 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() + .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 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 factList(PdaRelabelTask task) { return unitFacts.selectList(new LambdaQueryWrapper() + .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() + .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 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 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 scanned) { + if (!Set.of("SKU_LABEL", "CARTON_AND_SKU").contains(operationType(item))) return; + List expected = readSkuItems(item.getSkuRelabelItemsJson()); + if (expected.isEmpty()) throw conflict("该箱缺少 SKU 换标及数量明细,请补齐后作业"); + List 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 facts, String result) { return (int) facts.stream().filter(fact -> result.equals(fact.getResult())).count(); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java index afaf4ab38..6bf25b776 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java @@ -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; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java index 3f3edd580..cdb6223ac 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java @@ -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"; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java index b7a5908b9..3abdde3da 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java @@ -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 sourceOrders, List items, List cartons, List pallets, List relabelBindings, List allocations, LocalDateTime customerConfirmedAt, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferOrder.java index 025d170cc..6ce3b354f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferOrder.java @@ -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; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java index b7f826c1d..e34173b70 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java @@ -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") diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 450a185cc..51bd73123 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -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', diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 06e770e86..ef340b44f 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -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时不传 } diff --git a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml index 37c10f2ab..97fff745b 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml @@ -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: diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java index 08c501244..f6ab20500 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java @@ -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 diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java index 7e9c54b15..5a0718253 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java @@ -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(); + 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 carton = version -> service.scanCarton("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.CartonScan("BOX-1", version, "carton-" + version)); + java.util.function.IntFunction 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"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java index e872acb35..7e0db4689 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java @@ -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 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)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java index c09a5086c..a1dba7df4 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java @@ -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("; 地址不完整")); diff --git a/scripts/README.md b/scripts/README.md index d984c0701..11b2424ed 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -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 全量入口,本次合并仅做离线合同。 diff --git a/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs b/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs index a975613e9..7a7940830 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs @@ -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) } diff --git a/scripts/admin-test-env-modules/warehouse-transfer-review.mjs b/scripts/admin-test-env-modules/warehouse-transfer-review.mjs index 81f734281..9e3317b52 100644 --- a/scripts/admin-test-env-modules/warehouse-transfer-review.mjs +++ b/scripts/admin-test-env-modules/warehouse-transfer-review.mjs @@ -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 } } diff --git a/scripts/current-session-overseas-relabel-acceptance.mjs b/scripts/current-session-overseas-relabel-acceptance.mjs new file mode 100644 index 000000000..df617b1d5 --- /dev/null +++ b/scripts/current-session-overseas-relabel-acceptance.mjs @@ -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)) diff --git a/scripts/overseas-relabel-fulfillment-acceptance.mjs b/scripts/overseas-relabel-fulfillment-acceptance.mjs index 6e14525bd..390b7c6ca 100644 --- a/scripts/overseas-relabel-fulfillment-acceptance.mjs +++ b/scripts/overseas-relabel-fulfillment-acceptance.mjs @@ -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', diff --git a/scripts/overseas-relabel-real-acceptance.mjs b/scripts/overseas-relabel-real-acceptance.mjs index 096841182..f6b5f1879 100644 --- a/scripts/overseas-relabel-real-acceptance.mjs +++ b/scripts/overseas-relabel-real-acceptance.mjs @@ -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',