提交
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;生产/存量库只执行增量脚本。
|
||||
|
||||
+19
@@ -25,6 +25,25 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
@RequestMapping("/api/platform/v1/last-leg/relabel-applications")
|
||||
public class RelabelApplicationController {
|
||||
@Resource private RelabelApplicationService service;
|
||||
@Resource private com.baiyue.lastleg.relabel.pda.service.PdaRelabelService pdaService;
|
||||
|
||||
@PostMapping("/{relabelNo}/rework")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_STATUS + "')")
|
||||
public ApiResponse<com.baiyue.lastleg.relabel.pda.dto.PdaRelabelResponses.Detail> rework(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String relabelNo,
|
||||
@Valid @RequestBody com.baiyue.lastleg.relabel.pda.dto.PdaRelabelRequests.Review request) {
|
||||
requirePlatform(user);
|
||||
return ApiResponse.success(pdaService.returnForRework(operator(user), user.username(), relabelNo, request));
|
||||
}
|
||||
|
||||
@GetMapping("/{relabelNo}/review-task")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')")
|
||||
public ApiResponse<com.baiyue.lastleg.relabel.pda.dto.PdaRelabelResponses.Detail> reviewTask(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String relabelNo) {
|
||||
requirePlatform(user);
|
||||
var detail = service.detail(null, relabelNo);
|
||||
return ApiResponse.success(pdaService.detail(operator(user), Long.parseLong(detail.application().overseasWarehouseId()), relabelNo));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_CREATE + "')")
|
||||
|
||||
@@ -23,7 +23,7 @@ public final class RelabelRequests {
|
||||
@NotNull LocalDateTime labelDeadline,
|
||||
@NotBlank @Size(max = 128) String contactName,
|
||||
@NotBlank @Size(max = 64) String contactPhone,
|
||||
@NotNull @DecimalMin(value = "0.01") BigDecimal chargeAmount,
|
||||
@DecimalMin(value = "0.01") BigDecimal chargeAmount,
|
||||
@Size(max = 1000) String remark,
|
||||
@NotBlank @Size(max = 255) String idempotencyKey,
|
||||
@NotEmpty @Size(max = 500) List<@Valid Item> items,
|
||||
|
||||
+36
@@ -62,6 +62,33 @@ public class PdaRelabelController {
|
||||
identity.overseasWarehouseId(), relabelNo, request));
|
||||
}
|
||||
|
||||
@PostMapping("/{relabelNo}/cartons/scan")
|
||||
public ApiResponse<PdaRelabelResponses.Detail> scanCarton(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.CartonScan request) {
|
||||
Identity identity = identity(user);
|
||||
return ApiResponse.success(service.scanCarton(identity.operatorKey(), identity.operatorName(),
|
||||
identity.overseasWarehouseId(), relabelNo, request));
|
||||
}
|
||||
|
||||
@GetMapping("/{relabelNo}/documents")
|
||||
public ApiResponse<List<PdaRelabelService.Document>> documents(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String relabelNo) {
|
||||
return ApiResponse.success(service.documents(identity(user).overseasWarehouseId(), relabelNo));
|
||||
}
|
||||
|
||||
@GetMapping("/{relabelNo}/documents/{fileId}/download")
|
||||
public org.springframework.http.ResponseEntity<byte[]> download(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String relabelNo, @PathVariable long fileId,
|
||||
@org.springframework.web.bind.annotation.RequestParam String scope,
|
||||
@org.springframework.web.bind.annotation.RequestParam(required = false) Long itemId) {
|
||||
var file = service.downloadDocument(identity(user).overseasWarehouseId(), relabelNo, scope, itemId, fileId);
|
||||
return org.springframework.http.ResponseEntity.ok()
|
||||
.contentType(MediaType.parseMediaType(file.contentType()))
|
||||
.header("Content-Disposition", org.springframework.http.ContentDisposition.inline()
|
||||
.filename(file.fileName(), java.nio.charset.StandardCharsets.UTF_8).build().toString())
|
||||
.body(file.content());
|
||||
}
|
||||
|
||||
@PostMapping("/{relabelNo}/units/skip")
|
||||
public ApiResponse<PdaRelabelResponses.Detail> skip(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Skip request) {
|
||||
@@ -86,6 +113,15 @@ public class PdaRelabelController {
|
||||
identity.userId(), relabelNo, file));
|
||||
}
|
||||
|
||||
@GetMapping("/{relabelNo}/photos/files/{fileId}/download")
|
||||
public org.springframework.http.ResponseEntity<byte[]> downloadWorkPhoto(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String relabelNo, @PathVariable long fileId) {
|
||||
Identity identity = identity(user);
|
||||
var file = service.downloadWorkPhoto(identity.operatorKey(), identity.overseasWarehouseId(), relabelNo, fileId);
|
||||
return org.springframework.http.ResponseEntity.ok().contentType(MediaType.parseMediaType(file.contentType()))
|
||||
.header("Cache-Control", "no-store").body(file.content());
|
||||
}
|
||||
|
||||
@PostMapping("/{relabelNo}/complete")
|
||||
public ApiResponse<PdaRelabelResponses.Detail> complete(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Complete request) {
|
||||
|
||||
+19
-2
@@ -1,6 +1,7 @@
|
||||
package com.baiyue.lastleg.relabel.pda.dto;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
@@ -14,11 +15,23 @@ public final class PdaRelabelRequests {
|
||||
|
||||
public record Claim(@NotBlank @Size(max = 255) String idempotencyKey) { }
|
||||
|
||||
public record CartonScan(@NotBlank @Size(max = 255) String cartonMark,
|
||||
@NotNull @Min(0) Integer expectedVersion,
|
||||
@NotBlank @Size(max = 255) String idempotencyKey) { }
|
||||
|
||||
public record Scan(@Positive Long itemId,
|
||||
@NotBlank @Size(max = 255) String oldLabel,
|
||||
@Size(max = 255) String newLabel,
|
||||
@NotNull @Min(0) Integer expectedVersion,
|
||||
@NotBlank @Size(max = 255) String idempotencyKey) { }
|
||||
@NotBlank @Size(max = 255) String idempotencyKey,
|
||||
@Valid List<SkuVerification> skuVerifications) {
|
||||
public Scan(Long itemId, String oldLabel, String newLabel, Integer expectedVersion, String idempotencyKey) {
|
||||
this(itemId, oldLabel, newLabel, expectedVersion, idempotencyKey, List.of());
|
||||
}
|
||||
}
|
||||
|
||||
public record SkuVerification(@NotBlank String skuCode, @NotBlank String newSkuCode,
|
||||
@Positive int quantity) { }
|
||||
|
||||
public record Skip(@NotNull @Positive Long itemId,
|
||||
@NotBlank @Size(max = 255) String oldLabel,
|
||||
@@ -36,7 +49,11 @@ public final class PdaRelabelRequests {
|
||||
public record Complete(@NotEmpty @Size(max = 20) List<@Positive Long> photoFileIds,
|
||||
@NotNull @Min(0) Integer expectedVersion,
|
||||
@NotBlank @Size(max = 255) String idempotencyKey,
|
||||
@Size(max = 1000) String remark) { }
|
||||
@Size(max = 1000) String remark, boolean manualCompletion) {
|
||||
public Complete(List<Long> photoFileIds, Integer expectedVersion, String idempotencyKey, String remark) {
|
||||
this(photoFileIds, expectedVersion, idempotencyKey, remark, false);
|
||||
}
|
||||
}
|
||||
|
||||
public record Review(@NotBlank @Size(max = 16) String decision,
|
||||
@NotNull @Min(0) Integer expectedVersion,
|
||||
|
||||
+3
-3
@@ -7,12 +7,12 @@ public final class PdaRelabelResponses {
|
||||
private PdaRelabelResponses() { }
|
||||
|
||||
public record Candidate(String relabelNo, String customerOrderNo, LocalDateTime labelDeadline,
|
||||
String applicationStatus, String taskStatus, int itemCount,
|
||||
String applicationStatus, String taskStatus, String operationType, int itemCount,
|
||||
int expectedPackages, int relabelledPackages, int skippedPackages,
|
||||
int exceptionPackages, int version, boolean claimedByCurrentOperator) { }
|
||||
|
||||
public record Detail(String relabelNo, String customerOrderNo, LocalDateTime labelDeadline,
|
||||
String contactName, String contactPhone, String applicationStatus,
|
||||
String contactName, String contactPhone, String applicationStatus, String operationType,
|
||||
String taskStatus, int expectedPackages, int relabelledPackages,
|
||||
int skippedPackages, int exceptionPackages, int version,
|
||||
String operatorName, String reviewerName, List<Long> completionPhotoFileIds,
|
||||
@@ -26,7 +26,7 @@ public final class PdaRelabelResponses {
|
||||
String cartonMark, String fbaCartonMark, String operationType,
|
||||
List<SkuItem> skuItems,
|
||||
int expectedPackages, int relabelledPackages, int skippedPackages,
|
||||
int exceptionPackages) { }
|
||||
int exceptionPackages, int cartonProcessedPackages, int skuProcessedPackages) { }
|
||||
public record SkuItem(String skuCode, String newSkuCode, int quantity, String remark) { }
|
||||
|
||||
public record UnitFact(Long factId, Long itemId, String oldLabel, String newLabel,
|
||||
|
||||
+3
@@ -15,6 +15,9 @@ public interface PdaRelabelTaskMapper extends BaseMapper<PdaRelabelTask> {
|
||||
@Select("SELECT * FROM ll_relabel_pda_task WHERE customer_id=#{customerId} AND application_id=#{applicationId} LIMIT 1 FOR UPDATE")
|
||||
PdaRelabelTask lock(@Param("customerId") String customerId, @Param("applicationId") Long applicationId);
|
||||
|
||||
@Select("SELECT * FROM ll_relabel_pda_task WHERE operator_key=#{operatorKey} AND status<>'COMPLETED' ORDER BY claimed_at ASC LIMIT 1 FOR UPDATE")
|
||||
PdaRelabelTask lockUnfinishedByOperator(@Param("operatorKey") String operatorKey);
|
||||
|
||||
@Update("""
|
||||
UPDATE ll_relabel_pda_task
|
||||
SET status=#{task.status}, operator_key=#{task.operatorKey}, operator_name=#{task.operatorName},
|
||||
|
||||
+193
-15
@@ -68,6 +68,8 @@ public class PdaRelabelService {
|
||||
@Resource private RelabelApplicationService applicationService;
|
||||
@Resource private RelabelPhotoFileGateway photoFiles;
|
||||
@Resource private ObjectMapper sourceObjectMapper;
|
||||
@Resource private com.baiyue.lastleg.mapper.LastlegOrderMapper tailOrders;
|
||||
@Resource private com.baiyue.lastleg.exceptionorder.service.ExceptionOrderService exceptionOrders;
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@PostConstruct
|
||||
@@ -81,7 +83,8 @@ public class PdaRelabelService {
|
||||
public List<PdaRelabelResponses.Candidate> candidates(String operatorKey, long overseasWarehouseId) {
|
||||
return applications.selectList(new LambdaQueryWrapper<RelabelApplication>()
|
||||
.eq(RelabelApplication::getOverseasWarehouseId, String.valueOf(overseasWarehouseId))
|
||||
.in(RelabelApplication::getStatus, RelabelApplicationService.PENDING_PROCESSING, PROCESSING)
|
||||
.in(RelabelApplication::getStatus, RelabelApplicationService.PENDING_PROCESSING, PROCESSING,
|
||||
RelabelApplicationService.CUSTOMER_CONFIRM_PENDING)
|
||||
.orderByAsc(RelabelApplication::getLabelDeadline)
|
||||
.orderByAsc(RelabelApplication::getCreatedAt))
|
||||
.stream().map(application -> candidate(application,
|
||||
@@ -106,6 +109,10 @@ public class PdaRelabelService {
|
||||
throw conflict("Relabel application is not executable");
|
||||
}
|
||||
PdaRelabelTask task = tasks.lock(application.getCustomerId(), application.getId());
|
||||
PdaRelabelTask activeTask = tasks.lockUnfinishedByOperator(operatorKey);
|
||||
if (activeTask != null && !Objects.equals(activeTask.getApplicationId(), application.getId())) {
|
||||
throw conflict("当前账号正在执行换标单 " + activeTask.getRelabelNo() + ",请先完成后再操作其他换标单");
|
||||
}
|
||||
if (task != null) {
|
||||
if (!operatorKey.equals(task.getOperatorKey()) || !PROCESSING.equals(task.getStatus())) {
|
||||
throw conflict("Relabel task is already claimed or awaiting review");
|
||||
@@ -146,10 +153,18 @@ public class PdaRelabelService {
|
||||
return unitAction(operatorKey, operatorName, overseasWarehouseId, relabelNo, "RELABEL", request.idempotencyKey(),
|
||||
request, itemId, request.oldLabel(), request.newLabel(), "RELABELLED", null, null,
|
||||
request.expectedVersion(), item -> {
|
||||
validateSkuVerification(item, request.skuVerifications());
|
||||
if (StringUtils.hasText(item.getCartonMark()) && !item.getCartonMark().trim().equalsIgnoreCase(request.oldLabel().trim())) {
|
||||
throw conflict("Scanned old carton mark does not match the relabel item");
|
||||
}
|
||||
String expectedFbaLabel = trim(item.getFbaCartonMark());
|
||||
if ("CARTON_LABEL".equals(operationType(item)) && StringUtils.hasText(expectedFbaLabel)
|
||||
&& !StringUtils.hasText(request.newLabel())) {
|
||||
throw conflict("请扫描新 FBA 箱唛确认箱唛换标");
|
||||
}
|
||||
if ("SKU_LABEL".equals(operationType(item)) && StringUtils.hasText(request.newLabel())) {
|
||||
throw conflict("SKU 换标不能修改 FBA 箱唛");
|
||||
}
|
||||
if (StringUtils.hasText(request.newLabel()) && StringUtils.hasText(expectedFbaLabel)
|
||||
&& !expectedFbaLabel.equalsIgnoreCase(request.newLabel().trim())) {
|
||||
throw conflict("Scanned new label does not match the approved FBA carton mark");
|
||||
@@ -160,6 +175,42 @@ public class PdaRelabelService {
|
||||
});
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PdaRelabelResponses.Detail scanCarton(String operatorKey, String operatorName, long warehouseId,
|
||||
String relabelNo, PdaRelabelRequests.CartonScan request) {
|
||||
RelabelApplication application = requireApplication(relabelNo, warehouseId, false);
|
||||
RelabelItem item = requireItemByCartonMark(application, request.cartonMark());
|
||||
if ("SKU_LABEL".equals(operationType(item))) throw conflict("该箱为 SKU 换标,请进入 SKU 作业");
|
||||
if (!StringUtils.hasText(item.getFbaCartonMark())) throw conflict("该箱未维护对应 FBA 箱唛");
|
||||
return unitAction(operatorKey, operatorName, warehouseId, relabelNo, "SCAN_CARTON", request.idempotencyKey(),
|
||||
request, item.getId(), item.getCartonMark(), item.getFbaCartonMark(), "RELABELLED", null, null,
|
||||
request.expectedVersion(), current -> {
|
||||
if (!Objects.equals(current.getCartonMark(), item.getCartonMark())
|
||||
|| !Objects.equals(current.getFbaCartonMark(), item.getFbaCartonMark())) {
|
||||
throw conflict("箱唛绑定已变化,请重新扫描");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public record Document(String scope, String fileId, String fileName, Long itemId) { }
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<Document> documents(long warehouseId, String relabelNo) {
|
||||
RelabelApplication app = requireApplication(relabelNo, warehouseId, false);
|
||||
List<Document> result = new ArrayList<>();
|
||||
if (StringUtils.hasText(app.getCartonTotalFileId())) result.add(new Document("CARTON_TOTAL", app.getCartonTotalFileId(), app.getCartonTotalFileName(), null));
|
||||
if (StringUtils.hasText(app.getSkuTotalFileId())) result.add(new Document("SKU_TOTAL", app.getSkuTotalFileId(), app.getSkuTotalFileName(), null));
|
||||
for (RelabelItem item : itemList(app)) if (StringUtils.hasText(item.getRelabelFileId()))
|
||||
result.add(new Document("ITEM", item.getRelabelFileId(), item.getRelabelFileName(), item.getId()));
|
||||
return result;
|
||||
}
|
||||
|
||||
public com.baiyue.lastleg.relabel.gateway.RelabelDocumentFileGateway.DownloadedFile downloadDocument(
|
||||
long warehouseId, String relabelNo, String scope, Long itemId, long fileId) {
|
||||
RelabelApplication app = requireApplication(relabelNo, warehouseId, false);
|
||||
return applicationService.downloadFile(app.getCustomerId(), relabelNo, scope, itemId, fileId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PdaRelabelResponses.Detail skip(String operatorKey, String operatorName, long overseasWarehouseId,
|
||||
String relabelNo, PdaRelabelRequests.Skip request) {
|
||||
@@ -194,6 +245,15 @@ public class PdaRelabelService {
|
||||
uploaded.contentType(), uploaded.sizeBytes());
|
||||
}
|
||||
|
||||
public RelabelPhotoFileGateway.DownloadedFile downloadWorkPhoto(String operatorKey, long warehouseId,
|
||||
String relabelNo, long fileId) {
|
||||
RelabelApplication application = requireApplication(relabelNo, warehouseId, false);
|
||||
PdaRelabelTask task = requireOwnedTask(application, operatorKey, false);
|
||||
if (fileId <= 0) throw notFound("作业照片不存在");
|
||||
photoFiles.validate(application.getCustomerId(), task.getId(), fileId);
|
||||
return photoFiles.download(application.getCustomerId(), task.getId(), fileId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PdaRelabelResponses.Detail complete(String operatorKey, String operatorName, long overseasWarehouseId,
|
||||
String relabelNo, PdaRelabelRequests.Complete request) {
|
||||
@@ -206,22 +266,59 @@ public class PdaRelabelService {
|
||||
requireVersion(task, request.expectedVersion());
|
||||
List<RelabelItem> detailItems = itemList(application);
|
||||
List<PdaRelabelUnitFact> facts = factList(task);
|
||||
boolean incomplete = detailItems.stream().anyMatch(item -> facts.stream()
|
||||
.filter(fact -> item.getId().equals(fact.getItemId())).count() < value(item.getNotifiedPackageCount()));
|
||||
if ((incomplete || request.manualCompletion()) && !StringUtils.hasText(request.remark()))
|
||||
throw conflict("手动完成必须填写仓库异常作业备注");
|
||||
if (incomplete && !request.manualCompletion()) throw conflict("仍有未处理箱,请选择手动完成并填写作业备注");
|
||||
if (request.photoFileIds() == null || request.photoFileIds().isEmpty() || request.photoFileIds().size() > 20) throw conflict("完成换标必须上传 1 至 20 张作业照片");
|
||||
if (request.manualCompletion() && request.remark().trim().length() > 900) throw conflict("手动作业备注不能超过 900 字");
|
||||
for (RelabelItem item : detailItems) {
|
||||
long actual = facts.stream().filter(fact -> item.getId().equals(fact.getItemId())).count();
|
||||
if (actual != value(item.getNotifiedPackageCount())) {
|
||||
if (actual > value(item.getNotifiedPackageCount())) {
|
||||
throw conflict("Every relabel item must have exactly one fact per notified package");
|
||||
}
|
||||
for (long i = actual; i < value(item.getNotifiedPackageCount()); i++) {
|
||||
PdaRelabelUnitFact fact = new PdaRelabelUnitFact();
|
||||
fact.setId(IdWorker.getId()); fact.setTaskId(task.getId()); fact.setApplicationId(application.getId());
|
||||
fact.setItemId(item.getId()); fact.setCustomerId(application.getCustomerId());
|
||||
fact.setResult("EXCEPTION"); fact.setExceptionCode("MANUAL_COMPLETE");
|
||||
fact.setReason(request.remark()); fact.setOperatorKey(operatorKey); fact.setOperatorName(operatorName);
|
||||
fact.setOccurredAt(LocalDateTime.now()); unitFacts.insert(fact);
|
||||
task.setExceptionPackages(value(task.getExceptionPackages()) + 1);
|
||||
}
|
||||
}
|
||||
List<Long> photoIds = request.photoFileIds().stream().distinct().toList();
|
||||
if (photoIds.size() != request.photoFileIds().size()) throw conflict("Relabel photo IDs must be unique");
|
||||
photoIds.forEach(fileId -> photoFiles.validate(application.getCustomerId(), task.getId(), fileId));
|
||||
if (request.manualCompletion()) {
|
||||
var relatedOrders = tailOrders.selectList(new LambdaQueryWrapper<com.baiyue.lastleg.entity.LastlegOrder>()
|
||||
.eq(com.baiyue.lastleg.entity.LastlegOrder::getCustomerId, application.getCustomerId())
|
||||
.eq(com.baiyue.lastleg.entity.LastlegOrder::getRelabelOrderNo, relabelNo));
|
||||
if (relatedOrders.isEmpty()) throw conflict("未找到关联尾程订单,无法登记异常完成");
|
||||
for (var order : relatedOrders) {
|
||||
String note = "换标单 " + relabelNo + " 手动完成:" + request.remark().trim();
|
||||
exceptionOrders.create(application.getCustomerId(), operatorKey, operatorName,
|
||||
new com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderRequests.Create(
|
||||
application.getCustomerId(), null, order.getOrderNo(), "OTHER", note, null, null));
|
||||
tailOrders.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<com.baiyue.lastleg.entity.LastlegOrder>()
|
||||
.eq(com.baiyue.lastleg.entity.LastlegOrder::getId, order.getId())
|
||||
.eq(com.baiyue.lastleg.entity.LastlegOrder::getCustomerId, application.getCustomerId())
|
||||
.set(com.baiyue.lastleg.entity.LastlegOrder::getAbnormal, true)
|
||||
.set(com.baiyue.lastleg.entity.LastlegOrder::getWarehouseRemark,
|
||||
StringUtils.hasText(order.getWarehouseRemark()) ? order.getWarehouseRemark() + "\n" + note : note));
|
||||
}
|
||||
}
|
||||
String before = task.getStatus();
|
||||
task.setStatus(WAIT_REVIEW); task.setCompletionPhotoFileIds(write(photoIds));
|
||||
task.setCompletionRemark(trim(request.remark())); task.setSubmittedAt(LocalDateTime.now());
|
||||
updateTask(task, request.expectedVersion());
|
||||
insertHistory(task, "COMPLETE", before, WAIT_REVIEW, operatorKey, operatorName,
|
||||
Map.of("photoFileIds", photoIds, "remark", Objects.toString(trim(request.remark()), "")));
|
||||
application.setStatus(PROCESSING);
|
||||
Map.of("photoFileIds", photoIds, "remark", Objects.toString(trim(request.remark()), ""),
|
||||
"manualCompletion", request.manualCompletion()));
|
||||
applicationService.changeStatus(application.getCustomerId(), relabelNo, operatorKey,
|
||||
new RelabelRequests.ChangeStatus(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, request.remark()));
|
||||
application.setStatus(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING);
|
||||
PdaRelabelResponses.Detail result = detail(application, task);
|
||||
saveOperation(task, operatorKey, "COMPLETE", request.idempotencyKey(), command, result);
|
||||
return result;
|
||||
@@ -230,6 +327,17 @@ public class PdaRelabelService {
|
||||
@Transactional
|
||||
public PdaRelabelResponses.Detail review(String operatorKey, String operatorName, long overseasWarehouseId,
|
||||
String relabelNo, PdaRelabelRequests.Review request) {
|
||||
throw conflict("复核由客户确认完成;需要重新贴标时请由平台填写备注退回");
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PdaRelabelResponses.Detail returnForRework(String operatorKey, String operatorName, String relabelNo,
|
||||
PdaRelabelRequests.Review request) {
|
||||
if (!"REWORK".equals(request.decision()) || !StringUtils.hasText(request.remark()))
|
||||
throw badRequest("退回重新贴标必须填写原因");
|
||||
RelabelApplication scoped = applications.findAnyByRelabelNo(relabelNo);
|
||||
if (scoped == null) throw notFound("换标单不存在");
|
||||
long overseasWarehouseId = Long.parseLong(scoped.getOverseasWarehouseId());
|
||||
ReviewCommand command = new ReviewCommand(relabelNo, request);
|
||||
PdaRelabelResponses.Detail replay = replay(operatorKey, overseasWarehouseId, relabelNo,
|
||||
"REVIEW", request.idempotencyKey(), command);
|
||||
@@ -237,7 +345,8 @@ public class PdaRelabelService {
|
||||
String decision = request.decision().trim().toUpperCase(Locale.ROOT);
|
||||
if (!Set.of("APPROVE", "REWORK").contains(decision)) throw badRequest("Review decision must be APPROVE or REWORK");
|
||||
RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, true);
|
||||
if (!PROCESSING.equals(application.getStatus())) {
|
||||
if (!RelabelApplicationService.CUSTOMER_CONFIRM_PENDING.equals(application.getStatus())
|
||||
&& !PROCESSING.equals(application.getStatus())) {
|
||||
throw conflict("Relabel application is no longer executable");
|
||||
}
|
||||
PdaRelabelTask task = tasks.lock(application.getCustomerId(), application.getId());
|
||||
@@ -247,17 +356,26 @@ public class PdaRelabelService {
|
||||
}
|
||||
if (!WAIT_REVIEW.equals(task.getStatus())) throw conflict("Relabel task is not awaiting review");
|
||||
requireVersion(task, request.expectedVersion());
|
||||
// Preserve the previous round before freeing the active-label uniqueness constraint.
|
||||
List<PdaRelabelUnitFact> previousFacts = factList(task);
|
||||
insertHistory(task, "REWORK_ARCHIVE", WAIT_REVIEW, PROCESSING, operatorKey, operatorName,
|
||||
Map.of("facts", previousFacts, "photoFileIds", Objects.toString(task.getCompletionPhotoFileIds(), "[]"),
|
||||
"completionRemark", Objects.toString(task.getCompletionRemark(), ""), "remark", request.remark()));
|
||||
for (PdaRelabelUnitFact fact : previousFacts) {
|
||||
unitFacts.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<PdaRelabelUnitFact>()
|
||||
.eq(PdaRelabelUnitFact::getId, fact.getId()).eq(PdaRelabelUnitFact::getTaskId, task.getId())
|
||||
.set(PdaRelabelUnitFact::getResult, "REWORK_ARCHIVED").set(PdaRelabelUnitFact::getOldLabel, null));
|
||||
}
|
||||
task.setRelabelledPackages(0); task.setSkippedPackages(0); task.setExceptionPackages(0);
|
||||
task.setCompletionPhotoFileIds("[]"); task.setCompletionRemark(null); task.setSubmittedAt(null);
|
||||
applicationService.resetForRework(application.getCustomerId(), relabelNo, operatorKey, request.remark());
|
||||
application.setStatus(PROCESSING);
|
||||
String before = task.getStatus();
|
||||
String after = "APPROVE".equals(decision) ? COMPLETED : PROCESSING;
|
||||
String after = PROCESSING;
|
||||
task.setStatus(after); task.setReviewerKey(operatorKey); task.setReviewerName(operatorName);
|
||||
task.setReviewDecision(decision); task.setReviewRemark(trim(request.remark()));
|
||||
task.setReviewedAt(LocalDateTime.now());
|
||||
updateTask(task, request.expectedVersion());
|
||||
if ("APPROVE".equals(decision)) {
|
||||
applicationService.changeStatus(application.getCustomerId(), relabelNo, operatorKey,
|
||||
new RelabelRequests.ChangeStatus(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, request.remark()));
|
||||
application.setStatus(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING);
|
||||
}
|
||||
insertHistory(task, "REVIEW_" + decision, before, after, operatorKey, operatorName,
|
||||
Map.of("decision", decision, "remark", Objects.toString(trim(request.remark()), "")));
|
||||
PdaRelabelResponses.Detail result = detail(application, task);
|
||||
@@ -278,6 +396,23 @@ public class PdaRelabelService {
|
||||
requireVersion(task, expectedVersion);
|
||||
RelabelItem item = requireItem(application, itemId);
|
||||
validation.accept(item);
|
||||
if ("CARTON_AND_SKU".equals(operationType(item)) && "RELABELLED".equals(resultCode)) {
|
||||
String stage = "SCAN_CARTON".equals(action) ? "CARTON" : "SKU";
|
||||
if (mixedStageDone(task, itemId, stage)) throw conflict("本箱该项作业已完成,请勿重复扫描");
|
||||
boolean otherDone = mixedStageDone(task, itemId, "CARTON".equals(stage) ? "SKU" : "CARTON");
|
||||
if (factList(task).stream().anyMatch(fact -> itemId.equals(fact.getItemId())))
|
||||
throw conflict("本箱已经处理完成");
|
||||
if (unitFacts.findOldLabel(trim(oldLabel)) != null) throw conflict("该仓库唛头已被其他作业处理");
|
||||
insertHistory(task, "MIXED_" + stage, PROCESSING, PROCESSING, operatorKey, operatorName,
|
||||
Map.of("itemId", itemId.toString(), "round", mixedRound(task), "oldLabel", oldLabel,
|
||||
"stage", stage, "request", command));
|
||||
if (!otherDone) {
|
||||
updateTask(task, expectedVersion);
|
||||
PdaRelabelResponses.Detail partial = detail(application, task);
|
||||
saveOperation(task, operatorKey, action, idempotencyKey, replayCommand, partial);
|
||||
return partial;
|
||||
}
|
||||
}
|
||||
List<PdaRelabelUnitFact> existingFacts = factList(task);
|
||||
long itemFacts = existingFacts.stream().filter(fact -> itemId.equals(fact.getItemId())).count();
|
||||
if (itemFacts >= value(item.getNotifiedPackageCount())) {
|
||||
@@ -299,7 +434,7 @@ public class PdaRelabelService {
|
||||
fact.setOperatorKey(operatorKey); fact.setOperatorName(operatorName); fact.setOccurredAt(LocalDateTime.now());
|
||||
try { unitFacts.insert(fact); }
|
||||
catch (DuplicateKeyException duplicate) { throw conflict("Old label was processed concurrently"); }
|
||||
if ("RELABELLED".equals(resultCode)) {
|
||||
if ("RELABELLED".equals(resultCode) && !"SKU_LABEL".equals(operationType(item))) {
|
||||
item.setNewCartonMark(resolvedNewLabel);
|
||||
item.setFbaCartonMark(resolvedNewLabel);
|
||||
items.updateById(item);
|
||||
@@ -395,16 +530,37 @@ public class PdaRelabelService {
|
||||
|
||||
private List<PdaRelabelUnitFact> factList(PdaRelabelTask task) {
|
||||
return unitFacts.selectList(new LambdaQueryWrapper<PdaRelabelUnitFact>()
|
||||
.ne(PdaRelabelUnitFact::getResult, "REWORK_ARCHIVED")
|
||||
.eq(PdaRelabelUnitFact::getCustomerId, task.getCustomerId())
|
||||
.eq(PdaRelabelUnitFact::getTaskId, task.getId())
|
||||
.orderByAsc(PdaRelabelUnitFact::getOccurredAt).orderByAsc(PdaRelabelUnitFact::getId));
|
||||
}
|
||||
|
||||
private String mixedRound(PdaRelabelTask task) {
|
||||
return Objects.toString(task.getReviewedAt(), "INITIAL");
|
||||
}
|
||||
|
||||
private boolean mixedStageDone(PdaRelabelTask task, Long itemId, String stage) {
|
||||
if (task == null) return false;
|
||||
return histories.selectList(new LambdaQueryWrapper<PdaRelabelHistory>()
|
||||
.eq(PdaRelabelHistory::getTaskId, task.getId())
|
||||
.eq(PdaRelabelHistory::getCustomerId, task.getCustomerId())
|
||||
.eq(PdaRelabelHistory::getAction, "MIXED_" + stage)).stream().anyMatch(history -> {
|
||||
if (!("MIXED_" + stage).equals(history.getAction())) return false;
|
||||
try {
|
||||
var data = objectMapper.readTree(history.getDetailJson());
|
||||
return itemId.toString().equals(data.path("itemId").asText())
|
||||
&& mixedRound(task).equals(data.path("round").asText());
|
||||
} catch (Exception invalid) { throw conflict("混合换标作业记录无法读取,请联系平台处理"); }
|
||||
});
|
||||
}
|
||||
|
||||
private PdaRelabelResponses.Candidate candidate(RelabelApplication application, PdaRelabelTask task,
|
||||
String operatorKey) {
|
||||
return new PdaRelabelResponses.Candidate(application.getRelabelNo(), application.getCustomerOrderNo(),
|
||||
application.getLabelDeadline(), application.getStatus(), task == null ? "AVAILABLE" : task.getStatus(),
|
||||
value(application.getItemCount()), task == null ? value(application.getNotifiedPackageCount()) : value(task.getExpectedPackages()),
|
||||
operationType(application), value(application.getItemCount()),
|
||||
task == null ? value(application.getNotifiedPackageCount()) : value(task.getExpectedPackages()),
|
||||
task == null ? 0 : value(task.getRelabelledPackages()), task == null ? 0 : value(task.getSkippedPackages()),
|
||||
task == null ? 0 : value(task.getExceptionPackages()), task == null ? 0 : value(task.getVersion()),
|
||||
task != null && operatorKey.equals(task.getOperatorKey()));
|
||||
@@ -422,7 +578,9 @@ public class PdaRelabelService {
|
||||
item.getRelabelFileId(), item.getRelabelFileName(), item.getRelabelFileUrl(),
|
||||
item.getCartonMark(), item.getFbaCartonMark(), operationType(item), readSkuItems(item.getSkuRelabelItemsJson()),
|
||||
value(item.getNotifiedPackageCount()), count(itemFacts, "RELABELLED"), count(itemFacts, "SKIPPED"),
|
||||
count(itemFacts, "EXCEPTION"));
|
||||
count(itemFacts, "EXCEPTION"),
|
||||
count(itemFacts, "RELABELLED") + (itemFacts.isEmpty() && mixedStageDone(task, item.getId(), "CARTON") ? 1 : 0),
|
||||
count(itemFacts, "RELABELLED") + (itemFacts.isEmpty() && mixedStageDone(task, item.getId(), "SKU") ? 1 : 0));
|
||||
}).toList();
|
||||
List<PdaRelabelResponses.UnitFact> factResponses = facts.stream().map(fact ->
|
||||
new PdaRelabelResponses.UnitFact(fact.getId(), fact.getItemId(), fact.getOldLabel(), fact.getNewLabel(),
|
||||
@@ -435,7 +593,7 @@ public class PdaRelabelService {
|
||||
history.getToStatus(), history.getOperatorName(), history.getDetailJson(), history.getCreatedAt())).toList();
|
||||
return new PdaRelabelResponses.Detail(application.getRelabelNo(), application.getCustomerOrderNo(),
|
||||
application.getLabelDeadline(), application.getContactName(), application.getContactPhone(),
|
||||
application.getStatus(), task == null ? "AVAILABLE" : task.getStatus(),
|
||||
application.getStatus(), operationType(application), task == null ? "AVAILABLE" : task.getStatus(),
|
||||
task == null ? value(application.getNotifiedPackageCount()) : value(task.getExpectedPackages()),
|
||||
task == null ? 0 : value(task.getRelabelledPackages()), task == null ? 0 : value(task.getSkippedPackages()),
|
||||
task == null ? 0 : value(task.getExceptionPackages()), task == null ? 0 : value(task.getVersion()),
|
||||
@@ -514,6 +672,26 @@ public class PdaRelabelService {
|
||||
return StringUtils.hasText(item.getOperationType()) ? item.getOperationType() : "CARTON_LABEL";
|
||||
}
|
||||
|
||||
private String operationType(RelabelApplication application) {
|
||||
List<RelabelItem> items = itemList(application);
|
||||
boolean sku = items.stream().anyMatch(item -> Set.of("SKU_LABEL", "CARTON_AND_SKU").contains(operationType(item)));
|
||||
boolean carton = items.stream().anyMatch(item -> !"SKU_LABEL".equals(operationType(item)));
|
||||
return sku && carton ? "CARTON_AND_SKU" : sku ? "SKU_LABEL" : "CARTON_LABEL";
|
||||
}
|
||||
|
||||
private void validateSkuVerification(RelabelItem item, List<PdaRelabelRequests.SkuVerification> scanned) {
|
||||
if (!Set.of("SKU_LABEL", "CARTON_AND_SKU").contains(operationType(item))) return;
|
||||
List<PdaRelabelResponses.SkuItem> expected = readSkuItems(item.getSkuRelabelItemsJson());
|
||||
if (expected.isEmpty()) throw conflict("该箱缺少 SKU 换标及数量明细,请补齐后作业");
|
||||
List<PdaRelabelRequests.SkuVerification> remaining = new java.util.ArrayList<>(scanned == null ? List.of() : scanned);
|
||||
if (remaining.size() != expected.size()) throw conflict("请完成该箱全部 SKU 换标核对");
|
||||
for (PdaRelabelResponses.SkuItem sku : expected) {
|
||||
boolean matched = remaining.remove(new PdaRelabelRequests.SkuVerification(
|
||||
sku.skuCode(), sku.newSkuCode(), sku.quantity()));
|
||||
if (!matched) throw conflict("扫描的旧 SKU、新 SKU 或数量与换标明细不一致");
|
||||
}
|
||||
}
|
||||
|
||||
private static int count(List<PdaRelabelUnitFact> facts, String result) {
|
||||
return (int) facts.stream().filter(fact -> result.equals(fact.getResult())).count();
|
||||
}
|
||||
|
||||
+62
-23
@@ -180,7 +180,15 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
if (!String.valueOf(fileId).equals(item.getRelabelFileId())) throw notFound("换标资料未绑定当前明细");
|
||||
return documentFiles.download(application.getCustomerId(), "OVERSEAS_RELABEL_ITEM_DOCUMENT", String.valueOf(item.getId()), fileId);
|
||||
}
|
||||
if (!"TOTAL".equalsIgnoreCase(scope) || itemId != null) throw badRequest("资料范围必须为 TOTAL 或 ITEM,总资料不指定明细");
|
||||
if (itemId == null && ("CARTON_TOTAL".equalsIgnoreCase(scope) || "TOTAL".equalsIgnoreCase(scope))
|
||||
&& String.valueOf(fileId).equals(application.getCartonTotalFileId())) {
|
||||
return documentFiles.download(application.getCustomerId(), "OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT", String.valueOf(application.getId()), fileId);
|
||||
}
|
||||
if (itemId == null && ("SKU_TOTAL".equalsIgnoreCase(scope) || "TOTAL".equalsIgnoreCase(scope))
|
||||
&& String.valueOf(fileId).equals(application.getSkuTotalFileId())) {
|
||||
return documentFiles.download(application.getCustomerId(), "OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT", String.valueOf(application.getId()), fileId);
|
||||
}
|
||||
if (!"TOTAL".equalsIgnoreCase(scope) || itemId != null) throw badRequest("资料未绑定当前申请单");
|
||||
if (!String.valueOf(fileId).equals(application.getTotalFileId())) throw notFound("换标资料未绑定当前申请单");
|
||||
return documentFiles.download(application.getCustomerId(), "OVERSEAS_RELABEL_TOTAL_DOCUMENT", String.valueOf(application.getId()), fileId);
|
||||
}
|
||||
@@ -323,6 +331,8 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
long applicationId = IdWorker.getId();
|
||||
String operationWarehouseId = request.operationWarehouseId().trim();
|
||||
String relabelNo = orderNoGenerator.next("RELABEL", operationWarehouseId, applicationId);
|
||||
BigDecimal chargeAmount = Objects.requireNonNullElse(request.chargeAmount(), BigDecimal.ZERO);
|
||||
boolean requiresWallet = chargeAmount.signum() > 0;
|
||||
int holdIndex = 0;
|
||||
for (SourceInventory source : sources) {
|
||||
inventoryCommands.hold(customerId, operatorId, new InventoryCommandRequests.Move(
|
||||
@@ -352,9 +362,9 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
application.setItemCount(request.items().size());
|
||||
application.setTotalPackageCount(total);
|
||||
application.setNotifiedPackageCount(notified);
|
||||
application.setChargeAmount(request.chargeAmount());
|
||||
application.setChargeAmount(chargeAmount);
|
||||
application.setChargeCurrency("CNY");
|
||||
application.setWalletStatus("FREEZE_PENDING");
|
||||
application.setWalletStatus(requiresWallet ? "FREEZE_PENDING" : "NOT_REQUIRED");
|
||||
application.setIdempotencyKey(request.idempotencyKey().trim());
|
||||
application.setRequestHash(requestHash);
|
||||
application.setCreatedBy(trim(operatorId));
|
||||
@@ -362,10 +372,12 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
application.setUpdatedAt(now);
|
||||
applications.insert(application);
|
||||
|
||||
String walletEventId = walletCommands.publish("FREEZE", Long.parseLong(customerId), "RELABEL_ORDER",
|
||||
relabelNo, request.chargeAmount(), "换标订单费用冻结", 1L);
|
||||
application.setWalletCommandEventId(walletEventId);
|
||||
applications.updateById(application);
|
||||
if (requiresWallet) {
|
||||
String walletEventId = walletCommands.publish("FREEZE", Long.parseLong(customerId), "RELABEL_ORDER",
|
||||
relabelNo, chargeAmount, "换标订单费用冻结", 1L);
|
||||
application.setWalletCommandEventId(walletEventId);
|
||||
applications.updateById(application);
|
||||
}
|
||||
|
||||
for (SourceInventory source : sources) {
|
||||
insertItem(customerId, applicationId, source, now);
|
||||
@@ -395,7 +407,7 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
query.skuCode())
|
||||
.orderByDesc(RelabelApplication::getCreatedAt);
|
||||
if (PENDING_PROCESSING.equals(requestedStatus)) {
|
||||
wrapper.in(RelabelApplication::getStatus, PENDING_PROCESSING, "PROCESSING", CUSTOMER_CONFIRM_PENDING,
|
||||
wrapper.in(RelabelApplication::getStatus, PENDING_PROCESSING, CUSTOMER_CONFIRM_PENDING,
|
||||
"REJECTED", "CANCELLED");
|
||||
} else if ("COMPLETED".equals(requestedStatus)) {
|
||||
wrapper.in(RelabelApplication::getStatus, READY_FOR_OUTBOUND, "COMPLETED");
|
||||
@@ -442,7 +454,8 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
if (!STATUSES.contains(next)) throw badRequest("Unsupported relabel status");
|
||||
if (next.equals(application.getStatus())) return detail(customerId, relabelNo);
|
||||
validateTransition(application.getStatus(), next);
|
||||
if ("PROCESSING".equals(next) && !"FROZEN".equals(application.getWalletStatus())) {
|
||||
if ("PROCESSING".equals(next)
|
||||
&& !Set.of("FROZEN", "NOT_REQUIRED").contains(application.getWalletStatus())) {
|
||||
throw conflict("Relabel funds must be frozen before processing");
|
||||
}
|
||||
String before = application.getStatus();
|
||||
@@ -462,20 +475,22 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
&& !preDispatched) {
|
||||
releaseInventory(customerId, operatorId, application);
|
||||
}
|
||||
BigDecimal quantity = BigDecimal.valueOf(Objects.requireNonNullElse(application.getTotalPackageCount(), 0));
|
||||
feeSources.publishApproved(operatorId, "RELABEL:" + relabelNo,
|
||||
new OverseasFeeSourceDtos.SaveRequest(customerId, linkedOrders.get(0).getCustomerName(),
|
||||
"OVERSEAS_RELABEL_FEE", "RELABEL_ORDER", application.getId(), relabelNo,
|
||||
application.getLastlegOrderId(), null, null, null, null, null,
|
||||
parseLong(application.getOverseasWarehouseId()), "RELABEL_SERVICE", "换标费", "RECEIVABLE",
|
||||
null, null, null, null, null, null, null, List.of(), LocalDate.now(),
|
||||
application.getChargeCurrency(), application.getChargeAmount(),
|
||||
quantity.signum() > 0 ? quantity : null, quantity.signum() > 0 ? "BOX" : null,
|
||||
quantity.signum() > 0 ? application.getChargeAmount().divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null,
|
||||
null, null));
|
||||
walletCommands.publish("SETTLE", Long.parseLong(customerId), "RELABEL_ORDER", relabelNo, null,
|
||||
"换标完成,结算费用", 2L);
|
||||
application.setWalletStatus("SETTLE_PENDING"); applications.updateById(application);
|
||||
if (!"NOT_REQUIRED".equals(application.getWalletStatus())) {
|
||||
BigDecimal quantity = BigDecimal.valueOf(Objects.requireNonNullElse(application.getTotalPackageCount(), 0));
|
||||
feeSources.publishApproved(operatorId, "RELABEL:" + relabelNo,
|
||||
new OverseasFeeSourceDtos.SaveRequest(customerId, linkedOrders.get(0).getCustomerName(),
|
||||
"OVERSEAS_RELABEL_FEE", "RELABEL_ORDER", application.getId(), relabelNo,
|
||||
application.getLastlegOrderId(), null, null, null, null, null,
|
||||
parseLong(application.getOverseasWarehouseId()), "RELABEL_SERVICE", "换标费", "RECEIVABLE",
|
||||
null, null, null, null, null, null, null, List.of(), LocalDate.now(),
|
||||
application.getChargeCurrency(), application.getChargeAmount(),
|
||||
quantity.signum() > 0 ? quantity : null, quantity.signum() > 0 ? "BOX" : null,
|
||||
quantity.signum() > 0 ? application.getChargeAmount().divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null,
|
||||
null, null));
|
||||
walletCommands.publish("SETTLE", Long.parseLong(customerId), "RELABEL_ORDER", relabelNo, null,
|
||||
"换标完成,结算费用", 2L);
|
||||
application.setWalletStatus("SETTLE_PENDING"); applications.updateById(application);
|
||||
}
|
||||
} else if (Set.of("REJECTED", "CANCELLED").contains(next)) {
|
||||
releaseInventory(customerId, operatorId, application);
|
||||
if ("FROZEN".equals(application.getWalletStatus())) {
|
||||
@@ -563,6 +578,21 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
return detail(customerId, relabelNo);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void resetForRework(String customerId, String relabelNo, String operatorId, String remark) {
|
||||
RelabelApplication application = applications.lockNo(customerId, relabelNo);
|
||||
if (application == null || !Set.of(CUSTOMER_CONFIRM_PENDING, "PROCESSING").contains(application.getStatus())
|
||||
|| application.getCustomerConfirmedAt() != null) throw conflict("客户已确认或当前状态不允许退回");
|
||||
String before = application.getStatus();
|
||||
application.setStatus("PROCESSING"); application.setFulfillmentStatus("WAIT_RELABEL");
|
||||
application.setUpdatedAt(LocalDateTime.now()); applications.updateById(application);
|
||||
for (LastlegOrder order : tailOrders(application)) {
|
||||
order.setRelabelStatus("PROCESSING"); order.setSubOrderStatus("RELABEL:PROCESSING");
|
||||
order.setUpdatedAt(LocalDateTime.now()); orders.updateById(order);
|
||||
}
|
||||
insertHistory(customerId, application.getId(), before, "PROCESSING", operatorId, remark, LocalDateTime.now());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public RelabelResponses.Detail confirmCompletion(String customerId, String relabelNo, String operatorId,
|
||||
RelabelRequests.CustomerConfirm request) {
|
||||
@@ -573,6 +603,14 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
if (!CUSTOMER_CONFIRM_PENDING.equals(application.getStatus())) {
|
||||
throw conflict("Relabel order is not awaiting customer confirmation");
|
||||
}
|
||||
PdaRelabelTask reviewTask = pdaTasks.lock(customerId, application.getId());
|
||||
if (reviewTask == null || !Set.of("WAIT_REVIEW", "COMPLETED").contains(reviewTask.getStatus()))
|
||||
throw conflict("仓库尚未提交换标完工复核");
|
||||
reviewTask.setStatus("COMPLETED"); reviewTask.setReviewDecision("CUSTOMER_CONFIRMED");
|
||||
reviewTask.setReviewerKey(operatorId); reviewTask.setReviewerName("客户确认");
|
||||
reviewTask.setReviewRemark(request.remark()); reviewTask.setReviewedAt(LocalDateTime.now());
|
||||
reviewTask.setUpdatedAt(LocalDateTime.now());
|
||||
if (pdaTasks.updateWithVersion(reviewTask, reviewTask.getVersion()) != 1) throw conflict("复核状态已变化,请刷新");
|
||||
if (Boolean.TRUE.equals(application.getPrintShippingLabel())
|
||||
&& !StringUtils.hasText(application.getShippingLabelFileId())) {
|
||||
throw conflict("Upload the shipping label before confirming relabel completion");
|
||||
@@ -899,6 +937,7 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
}
|
||||
|
||||
private static String businessRelabelStatus(String workflowStatus) {
|
||||
if ("PROCESSING".equals(workflowStatus)) return "PROCESSING";
|
||||
return Set.of(READY_FOR_OUTBOUND, "COMPLETED").contains(workflowStatus)
|
||||
? "COMPLETED" : PENDING_PROCESSING;
|
||||
}
|
||||
|
||||
@@ -641,6 +641,7 @@ public class LastlegOrderService {
|
||||
}
|
||||
|
||||
private static String relabelBusinessStatus(String workflowStatus) {
|
||||
if ("PROCESSING".equals(workflowStatus)) return "PROCESSING";
|
||||
if (!StringUtils.hasText(workflowStatus) || "NOT_REQUIRED".equals(workflowStatus)) return workflowStatus;
|
||||
return Set.of("READY_FOR_OUTBOUND", "COMPLETED").contains(workflowStatus)
|
||||
? "COMPLETED" : "PENDING_PROCESSING";
|
||||
|
||||
+1
@@ -34,6 +34,7 @@ public final class WarehouseTransferResponses {
|
||||
int packageCount, String deliveryMethod, LocalDateTime estimatedArrivalAt,
|
||||
LocalDateTime estimatedShipAt,
|
||||
String fbaShipmentNo, String trackingNo, String fbaBoxNos, String remark,
|
||||
String reviewDecision, String reviewRemark, String reviewedBy, LocalDateTime reviewedAt,
|
||||
List<SourceOrder> sourceOrders, List<Item> items, List<Carton> cartons,
|
||||
List<Pallet> pallets, List<RelabelBinding> relabelBindings,
|
||||
List<Allocation> allocations, LocalDateTime customerConfirmedAt,
|
||||
|
||||
+4
@@ -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;
|
||||
|
||||
+9
-2
@@ -323,7 +323,8 @@ public class WarehouseTransferService {
|
||||
transfer.getOperatingWarehouseId(), transfer.getFbaWarehouseCode(), transfer.getDeliveryAddress(),
|
||||
transfer.getPackageCount(), transfer.getDeliveryMethod(), transfer.getEstimatedArrivalAt(),
|
||||
transfer.getEstimatedShipAt(), transfer.getFbaShipmentNo(), transfer.getTrackingNo(), transfer.getFbaBoxNos(),
|
||||
transfer.getRemark(), sources, items, cartons, pallets, bindings, allocations,
|
||||
transfer.getRemark(), transfer.getReviewDecision(), transfer.getReviewRemark(), transfer.getReviewedBy(),
|
||||
transfer.getReviewedAt(), sources, items, cartons, pallets, bindings, allocations,
|
||||
transfer.getCustomerConfirmedAt(), transfer.getOutboundAt(), transfer.getCreatedAt());
|
||||
}
|
||||
|
||||
@@ -375,7 +376,13 @@ public class WarehouseTransferService {
|
||||
String before = order.getCurrentStatus();
|
||||
boolean incomingTransit = transfer.getTransferNo().equals(transfer.getInboundCode());
|
||||
String transferStatus = APPROVED.equals(next) && incomingTransit ? WAIT_PDA_RECEIVING : next;
|
||||
transfer.setStatus(transferStatus); transfer.setUpdatedAt(now); transfers.updateById(transfer);
|
||||
transfer.setStatus(transferStatus);
|
||||
transfer.setReviewDecision(next);
|
||||
transfer.setReviewRemark(trim(request.remark()));
|
||||
transfer.setReviewedBy(operatorId);
|
||||
transfer.setReviewedAt(now);
|
||||
transfer.setUpdatedAt(now);
|
||||
transfers.updateById(transfer);
|
||||
String orderStatus = APPROVED.equals(next)
|
||||
? (incomingTransit
|
||||
? ("WAREHOUSE_PICKUP".equals(transfer.getDeliveryMethod()) ? "WAIT_PICKUP" : "WAIT_DELIVERY")
|
||||
|
||||
@@ -1497,7 +1497,10 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_order (
|
||||
estimated_arrival_at DATETIME(3) NOT NULL COMMENT '预计入仓时间', fba_shipment_no VARCHAR(128) NULL COMMENT '可选FBA货件编号',
|
||||
tracking_no VARCHAR(255) NULL COMMENT '物流跟踪号', fba_box_nos VARCHAR(2000) NULL COMMENT 'FBA箱号集合', remark VARCHAR(1000) NULL COMMENT '备注',
|
||||
need_relabel TINYINT NOT NULL DEFAULT 0 COMMENT '是否需要换标', relabel_application_id BIGINT NULL COMMENT '换标申请单ID', relabel_no VARCHAR(64) NULL COMMENT '换标申请单号',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态',
|
||||
review_decision VARCHAR(32) NULL COMMENT '审核决定', review_remark VARCHAR(1000) NULL COMMENT '审核备注或驳回原因',
|
||||
reviewed_by VARCHAR(128) NULL COMMENT '审核人ID', reviewed_at DATETIME(3) NULL COMMENT '审核时间',
|
||||
idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键',
|
||||
allocation_idempotency_key VARCHAR(255) NULL COMMENT '库存分配幂等键',
|
||||
allocation_request_hash CHAR(64) NULL COMMENT '库存分配请求哈希',
|
||||
outbound_idempotency_key VARCHAR(255) NULL COMMENT '出库幂等键',
|
||||
@@ -1534,6 +1537,8 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
|
||||
PREPARE transfer_relabel_completed_stmt FROM @ddl; EXECUTE transfer_relabel_completed_stmt; DEALLOCATE PREPARE transfer_relabel_completed_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='palletize_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN palletize_idempotency_key VARCHAR(255) NULL COMMENT ''PDA打托幂等键'', ADD COLUMN palletize_request_hash CHAR(64) NULL COMMENT ''PDA打托请求哈希'', ADD COLUMN confirmation_idempotency_key VARCHAR(255) NULL COMMENT ''客户测量确认幂等键'', ADD COLUMN confirmation_request_hash CHAR(64) NULL COMMENT ''客户测量确认请求哈希'', ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认测量时间'''));
|
||||
PREPARE transfer_fulfillment_stmt FROM @ddl; EXECUTE transfer_fulfillment_stmt; DEALLOCATE PREPARE transfer_fulfillment_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='review_decision'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN review_decision VARCHAR(32) NULL COMMENT ''审核决定'' AFTER status, ADD COLUMN review_remark VARCHAR(1000) NULL COMMENT ''审核备注或驳回原因'' AFTER review_decision, ADD COLUMN reviewed_by VARCHAR(128) NULL COMMENT ''审核人ID'' AFTER review_remark, ADD COLUMN reviewed_at DATETIME(3) NULL COMMENT ''审核时间'' AFTER reviewed_by'));
|
||||
PREPARE transfer_review_fact_stmt FROM @ddl; EXECUTE transfer_review_fact_stmt; DEALLOCATE PREPARE transfer_review_fact_stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_source_order (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||
|
||||
@@ -2468,14 +2468,14 @@ components:
|
||||
remark: { type: string, nullable: true, maxLength: 1000 }
|
||||
RelabelCreate:
|
||||
type: object
|
||||
required: [customerOrderNo, labelDeadline, contactName, contactPhone, chargeAmount, idempotencyKey, items]
|
||||
required: [customerOrderNo, labelDeadline, contactName, contactPhone, idempotencyKey, items]
|
||||
properties:
|
||||
customerOrderNo: { type: string, maxLength: 128 }
|
||||
overseasWarehouseId: { type: string, nullable: true, maxLength: 128, description: 统一目的海外仓;明细未填写目的仓时使用 }
|
||||
labelDeadline: { type: string, format: date-time }
|
||||
contactName: { type: string, maxLength: 128 }
|
||||
contactPhone: { type: string, maxLength: 64 }
|
||||
chargeAmount: { type: number, minimum: 0.01, description: 本次贴标费用(CNY) }
|
||||
chargeAmount: { type: number, nullable: true, minimum: 0.01, description: 本次贴标费用(CNY);未传时不冻结或结算资金 }
|
||||
remark: { type: string, nullable: true, maxLength: 1000 }
|
||||
idempotencyKey: { type: string, maxLength: 255 }
|
||||
deliveryMethod: { type: string, nullable: true, enum: [TRUCK, EXPRESS, TRUCK_PICKUP, EXPRESS_PICKUP, PICKUP], description: 只换箱唛或SKU与箱唛同时换时必填;只换SKU时不传 }
|
||||
|
||||
@@ -227,15 +227,27 @@ paths:
|
||||
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
|
||||
requestBody: { required: true, content: { multipart/form-data: { schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } } } } }
|
||||
responses: { '200': { description: OVERSEAS_RELABEL_PHOTO文件元数据, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelPhotoResponse' } } } } }
|
||||
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/photos/files/{fileId}/download:
|
||||
get:
|
||||
summary: 读取当前操作人处理中任务的已上传照片,用于缩略图和大图预览
|
||||
description: 校验 Token 绑定仓、任务认领人和文件的客户及任务归属;移除和重拍只替换待提交文件引用,不删除历史文件。
|
||||
parameters:
|
||||
- { name: relabelNo, in: path, required: true, schema: { type: string } }
|
||||
- { name: fileId, in: path, required: true, schema: { type: integer, format: int64 } }
|
||||
responses:
|
||||
'200': { description: 作业照片内容, content: { 'image/*': { schema: { type: string, format: binary } } } }
|
||||
'404': { description: 照片不属于当前任务或仓库 }
|
||||
'409': { description: 当前操作人未认领任务或任务不在处理中 }
|
||||
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/complete:
|
||||
post:
|
||||
summary: 提交现场任务等待复核,不提前完成申请
|
||||
summary: 提交现场任务到复核池并直接等待客户确认
|
||||
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelComplete' } } } }
|
||||
responses: { '200': { description: WAIT_REVIEW任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } }, '409': { description: 箱数未核平、照片或版本冲突 } }
|
||||
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/review:
|
||||
post:
|
||||
summary: 现场复核;APPROVE才完成既有换标申请,REWORK退回处理
|
||||
summary: 已停用;客户确认完成复核,退回重贴由平台执行
|
||||
deprecated: true
|
||||
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelReview' } } } }
|
||||
responses: { '200': { description: COMPLETED或PROCESSING任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } }, '409': { description: 状态或版本冲突 } }
|
||||
@@ -450,13 +462,14 @@ components:
|
||||
occurredAt: { type: string, format: date-time }
|
||||
RelabelCandidate:
|
||||
type: object
|
||||
required: [relabelNo, applicationStatus, taskStatus, itemCount, expectedPackages, relabelledPackages, skippedPackages, exceptionPackages, version, claimedByCurrentOperator]
|
||||
required: [relabelNo, applicationStatus, taskStatus, operationType, itemCount, expectedPackages, relabelledPackages, skippedPackages, exceptionPackages, version, claimedByCurrentOperator]
|
||||
properties:
|
||||
relabelNo: { type: string }
|
||||
customerOrderNo: { type: string, nullable: true }
|
||||
labelDeadline: { type: string, format: date-time, nullable: true }
|
||||
applicationStatus: { type: string }
|
||||
taskStatus: { type: string }
|
||||
operationType: { type: string, enum: [SKU_LABEL, CARTON_LABEL, CARTON_AND_SKU] }
|
||||
itemCount: { type: integer }
|
||||
expectedPackages: { type: integer }
|
||||
relabelledPackages: { type: integer }
|
||||
@@ -466,7 +479,7 @@ components:
|
||||
claimedByCurrentOperator: { type: boolean }
|
||||
RelabelDetail:
|
||||
type: object
|
||||
required: [relabelNo, applicationStatus, taskStatus, expectedPackages, relabelledPackages, skippedPackages, exceptionPackages, version, completionPhotoFileIds, items, unitFacts, history]
|
||||
required: [relabelNo, applicationStatus, operationType, taskStatus, expectedPackages, relabelledPackages, skippedPackages, exceptionPackages, version, completionPhotoFileIds, items, unitFacts, history]
|
||||
properties:
|
||||
relabelNo: { type: string }
|
||||
customerOrderNo: { type: string, nullable: true }
|
||||
@@ -474,6 +487,7 @@ components:
|
||||
contactName: { type: string, nullable: true }
|
||||
contactPhone: { type: string, nullable: true }
|
||||
applicationStatus: { type: string }
|
||||
operationType: { type: string, enum: [SKU_LABEL, CARTON_LABEL, CARTON_AND_SKU] }
|
||||
taskStatus: { type: string }
|
||||
expectedPackages: { type: integer }
|
||||
relabelledPackages: { type: integer }
|
||||
@@ -513,6 +527,8 @@ components:
|
||||
relabelledPackages: { type: integer }
|
||||
skippedPackages: { type: integer }
|
||||
exceptionPackages: { type: integer }
|
||||
cartonProcessedPackages: { type: integer, description: 本轮箱唛作业已完成箱数,混合单独立计数 }
|
||||
skuProcessedPackages: { type: integer, description: 本轮SKU作业已完成箱数,两项均完成才增加整箱已换标数 }
|
||||
RelabelSkuItem:
|
||||
type: object
|
||||
required: [skuCode, newSkuCode, quantity]
|
||||
@@ -723,8 +739,18 @@ components:
|
||||
required: [itemId, oldLabel, newLabel, expectedVersion, idempotencyKey]
|
||||
properties:
|
||||
itemId: { type: string, pattern: '^[0-9]+$' }
|
||||
oldLabel: { type: string, maxLength: 255 }
|
||||
newLabel: { type: string, maxLength: 255 }
|
||||
oldLabel: { type: string, maxLength: 255, description: 当前箱的仓库唛头,用于逐箱防重复 }
|
||||
newLabel: { type: string, maxLength: 255, description: 新FBA箱唛,SKU-only不得提交SKU到此字段 }
|
||||
skuVerifications:
|
||||
type: array
|
||||
description: SKU及组合换标必须提交该箱全部旧新SKU和件数核对结果
|
||||
items:
|
||||
type: object
|
||||
required: [skuCode, newSkuCode, quantity]
|
||||
properties:
|
||||
skuCode: { type: string }
|
||||
newSkuCode: { type: string }
|
||||
quantity: { type: integer, minimum: 1 }
|
||||
expectedVersion: { type: integer, minimum: 0 }
|
||||
idempotencyKey: { type: string, maxLength: 255 }
|
||||
RelabelSkip:
|
||||
|
||||
@@ -9,6 +9,8 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
|
||||
import jakarta.validation.Validation;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
@@ -24,6 +26,18 @@ class RelabelContractTest {
|
||||
"newSkuCode", "labelRequirement", "relabelFileId")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createAllowsChargeAmountToBeOmitted() {
|
||||
RelabelRequests.Create request = new RelabelRequests.Create("CUSTOMER-1", "WH-1",
|
||||
java.time.LocalDateTime.of(2026, 8, 20, 10, 0), "Li", "13800000000", null, null, "key-free",
|
||||
java.util.List.of(new RelabelRequests.Item("SKU-A", null, null, null, "Product", 1, 1,
|
||||
"SHIP-1", null, "SKU-B", "Replace", null, null, null)));
|
||||
try (var validatorFactory = Validation.buildDefaultValidatorFactory()) {
|
||||
assertTrue(validatorFactory.getValidator().validate(request).stream()
|
||||
.noneMatch(violation -> "chargeAmount".equals(violation.getPropertyPath().toString())));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void schemasDefineRelabelApplicationItemsAndHistoryContracts() throws Exception {
|
||||
String baseline = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8).toLowerCase();
|
||||
@@ -89,6 +103,8 @@ class RelabelContractTest {
|
||||
String relabelCreate = openApi.substring(openApi.indexOf(" RelabelCreate:"),
|
||||
openApi.indexOf(" RelabelItem:"));
|
||||
assertFalse(relabelCreate.contains("customerId"));
|
||||
assertFalse(relabelCreate.substring(relabelCreate.indexOf("required:"), relabelCreate.indexOf("properties:"))
|
||||
.contains("chargeAmount"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+106
-15
@@ -96,6 +96,35 @@ class PdaRelabelServiceTest {
|
||||
verify(applicationService).changeStatus(eq("100"), eq("FBR-1"), eq("7"), any(RelabelRequests.ChangeStatus.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void claimRejectsAnotherProcessingTaskOwnedBySameOperator() {
|
||||
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
|
||||
when(tasks.lock("100", 1L)).thenReturn(null);
|
||||
PdaRelabelTask active = task("PROCESSING", 0);
|
||||
active.setApplicationId(99L);
|
||||
active.setRelabelNo("FBR-ACTIVE");
|
||||
when(tasks.lockUnfinishedByOperator("7")).thenReturn(active);
|
||||
|
||||
ResponseStatusException error = assertThrows(ResponseStatusException.class,
|
||||
() -> service.claim("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.Claim("claim-other")));
|
||||
|
||||
assertTrue(error.getReason().contains("FBR-ACTIVE"));
|
||||
verify(tasks, never()).insert(any(PdaRelabelTask.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void workPhotoDownloadValidatesTaskOwnershipAndFileScope() {
|
||||
application.setStatus("PROCESSING");
|
||||
when(applications.findAnyByRelabelNo("FBR-1")).thenReturn(application);
|
||||
when(tasks.find("100", 1L)).thenReturn(task("PROCESSING", 0));
|
||||
service.downloadWorkPhoto("7", 900L, "FBR-1", 99L);
|
||||
verify(photoFiles).validate(eq("100"), anyLong(), eq(99L));
|
||||
verify(photoFiles).download(eq("100"), anyLong(), eq(99L));
|
||||
assertThrows(ResponseStatusException.class, () -> service.downloadWorkPhoto("8", 900L, "FBR-1", 100L));
|
||||
assertThrows(ResponseStatusException.class, () -> service.downloadWorkPhoto("7", 901L, "FBR-1", 100L));
|
||||
verify(photoFiles, never()).download(anyString(), anyLong(), eq(100L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void crossWarehouseClaimIsHiddenAndCannotCreateTask() {
|
||||
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
|
||||
@@ -105,6 +134,57 @@ class PdaRelabelServiceTest {
|
||||
verify(tasks, never()).insert(any(PdaRelabelTask.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void skuScanRequiresEverySkuAndNeverWritesSkuIntoCartonLabel() {
|
||||
application.setStatus("PROCESSING");
|
||||
item.setOperationType("SKU_LABEL");
|
||||
item.setCartonMark("BOX-1");
|
||||
item.setSkuRelabelItemsJson("[{\"skuCode\":\"OLD-A\",\"newSkuCode\":\"NEW-A\",\"quantity\":3}]");
|
||||
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
|
||||
when(tasks.lock("100", 1L)).thenReturn(task("PROCESSING", 0));
|
||||
when(items.selectOne(any())).thenReturn(item);
|
||||
assertThrows(ResponseStatusException.class, () -> service.scan("7", "picker", 900L, "FBR-1",
|
||||
new PdaRelabelRequests.Scan(10L, "BOX-1", null, 0, "missing-sku")));
|
||||
assertThrows(ResponseStatusException.class, () -> service.scan("7", "picker", 900L, "FBR-1",
|
||||
new PdaRelabelRequests.Scan(10L, "BOX-1", null, 0, "wrong-quantity",
|
||||
List.of(new PdaRelabelRequests.SkuVerification("OLD-A", "NEW-A", 2)))));
|
||||
var result = service.scan("7", "picker", 900L, "FBR-1",
|
||||
new PdaRelabelRequests.Scan(10L, "BOX-1", null, 0, "valid-sku",
|
||||
List.of(new PdaRelabelRequests.SkuVerification("OLD-A", "NEW-A", 3))));
|
||||
assertEquals(1, result.relabelledPackages());
|
||||
verify(items, never()).updateById(any(RelabelItem.class));
|
||||
assertEquals("BOX-1", persistedFacts.get(0).getOldLabel());
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(booleans = {true, false})
|
||||
void mixedWorkRequiresBothStagesAndCountsBoxOnce(boolean cartonFirst) {
|
||||
application.setStatus("PROCESSING");
|
||||
item.setOperationType("CARTON_AND_SKU"); item.setCartonMark("BOX-1"); item.setFbaCartonMark("FBA-1");
|
||||
item.setSkuRelabelItemsJson("[{\"skuCode\":\"OLD-A\",\"newSkuCode\":\"NEW-A\",\"quantity\":3}]");
|
||||
var task = task("PROCESSING", 0);
|
||||
var savedHistory = new ArrayList<com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory>();
|
||||
when(histories.selectList(any())).thenAnswer(call -> List.copyOf(savedHistory));
|
||||
when(histories.insert(any(com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory.class))).thenAnswer(call -> { savedHistory.add(call.getArgument(0)); return 1; });
|
||||
when(applications.findAnyByRelabelNo("FBR-1")).thenReturn(application);
|
||||
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
|
||||
when(tasks.lock("100", 1L)).thenReturn(task);
|
||||
when(items.selectOne(any())).thenReturn(item);
|
||||
java.util.function.IntFunction<com.baiyue.lastleg.relabel.pda.dto.PdaRelabelResponses.Detail> carton = version -> service.scanCarton("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.CartonScan("BOX-1", version, "carton-" + version));
|
||||
java.util.function.IntFunction<com.baiyue.lastleg.relabel.pda.dto.PdaRelabelResponses.Detail> sku = version -> service.scan("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.Scan(10L, "BOX-1", null, version, "sku-" + version, List.of(new PdaRelabelRequests.SkuVerification("OLD-A", "NEW-A", 3))));
|
||||
var first = (cartonFirst ? carton : sku).apply(0);
|
||||
assertEquals(0, first.relabelledPackages());
|
||||
assertEquals(cartonFirst ? 1 : 0, first.items().get(0).cartonProcessedPackages());
|
||||
assertEquals(cartonFirst ? 0 : 1, first.items().get(0).skuProcessedPackages());
|
||||
assertThrows(ResponseStatusException.class, () -> (cartonFirst ? carton : sku).apply(1));
|
||||
assertThrows(ResponseStatusException.class, () -> service.complete("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.Complete(List.of(501L), 1, "too-early", null)));
|
||||
var second = (cartonFirst ? sku : carton).apply(1);
|
||||
assertEquals(1, second.relabelledPackages());
|
||||
assertEquals(1, persistedFacts.size());
|
||||
assertEquals(1, second.items().get(0).cartonProcessedPackages());
|
||||
assertEquals(1, second.items().get(0).skuProcessedPackages());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unitScanLocksItemToApplicationValidatesNewSkuAndUpdatesVersion() {
|
||||
application.setStatus("PROCESSING");
|
||||
@@ -167,40 +247,51 @@ class PdaRelabelServiceTest {
|
||||
|
||||
verify(photoFiles).validate("100", 20L, 501L);
|
||||
assertEquals(PdaRelabelService.WAIT_REVIEW, result.taskStatus());
|
||||
assertEquals("PROCESSING", result.applicationStatus());
|
||||
verify(applicationService, never()).changeStatus(anyString(), anyString(), anyString(), any());
|
||||
assertEquals("CUSTOMER_CONFIRM_PENDING", result.applicationStatus());
|
||||
verify(applicationService).changeStatus(eq("100"), eq("FBR-1"), eq("7"), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void approvalUsesExistingApplicationCompletionAndReworkDoesNot() {
|
||||
application.setStatus("PROCESSING");
|
||||
PdaRelabelTask approved = task("WAIT_REVIEW", 2);
|
||||
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
|
||||
when(tasks.lock("100", 1L)).thenReturn(approved);
|
||||
|
||||
var result = service.review("8", "reviewer", 900L, "FBR-1",
|
||||
new PdaRelabelRequests.Review("APPROVE", 2, "review-1", "ok"));
|
||||
|
||||
assertEquals("COMPLETED", result.taskStatus());
|
||||
assertEquals(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, result.applicationStatus());
|
||||
verify(applicationService).changeStatus(eq("100"), eq("FBR-1"), eq("8"), any(RelabelRequests.ChangeStatus.class));
|
||||
assertThrows(ResponseStatusException.class, () -> service.review("8", "reviewer", 900L, "FBR-1",
|
||||
new PdaRelabelRequests.Review("APPROVE", 2, "review-1", "ok")));
|
||||
verify(applicationService, never()).changeStatus(anyString(), anyString(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reviewCanReturnTaskForReworkWithoutCompletingApplication() {
|
||||
application.setStatus("PROCESSING");
|
||||
application.setStatus("CUSTOMER_CONFIRM_PENDING");
|
||||
PdaRelabelTask task = task("WAIT_REVIEW", 2);
|
||||
task.setRelabelledPackages(1);
|
||||
task.setCompletionPhotoFileIds("[501]");
|
||||
when(applications.findAnyByRelabelNo("FBR-1")).thenReturn(application);
|
||||
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
|
||||
when(tasks.lock("100", 1L)).thenReturn(task);
|
||||
|
||||
var result = service.review("8", "reviewer", 900L, "FBR-1",
|
||||
var result = service.returnForRework("8", "reviewer", "FBR-1",
|
||||
new PdaRelabelRequests.Review("REWORK", 2, "review-rework", "wrong label"));
|
||||
|
||||
assertEquals("PROCESSING", result.taskStatus());
|
||||
assertEquals("PROCESSING", result.applicationStatus());
|
||||
assertEquals(0, result.relabelledPackages());
|
||||
assertEquals("wrong label", result.reviewRemark());
|
||||
assertTrue(result.completionPhotoFileIds().isEmpty());
|
||||
verify(applicationService).resetForRework("100", "FBR-1", "8", "wrong label");
|
||||
verify(applicationService, never()).changeStatus(anyString(), anyString(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reworkRejectsMissingRemarkAndConfirmedApplication() {
|
||||
assertThrows(ResponseStatusException.class, () -> service.returnForRework("8", "reviewer", "FBR-1",
|
||||
new PdaRelabelRequests.Review("REWORK", 2, "empty-reason", " ")));
|
||||
application.setStatus("READY_FOR_OUTBOUND");
|
||||
when(applications.findAnyByRelabelNo("FBR-1")).thenReturn(application);
|
||||
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
|
||||
assertThrows(ResponseStatusException.class, () -> service.returnForRework("8", "reviewer", "FBR-1",
|
||||
new PdaRelabelRequests.Review("REWORK", 2, "confirmed", "wrong label")));
|
||||
verify(applicationService, never()).resetForRework(anyString(), anyString(), anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void completionRejectsMissingPackageFactsBeforePhotoValidation() {
|
||||
application.setStatus("PROCESSING");
|
||||
|
||||
+53
@@ -75,6 +75,7 @@ class RelabelApplicationServiceTest {
|
||||
@Mock OutboundPdaTaskMapper outboundTasks;
|
||||
@Mock WarehouseTransferOrderMapper warehouseTransfers;
|
||||
@Mock OverseasFeeSourceService feeSources;
|
||||
@Mock com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper pdaTasks;
|
||||
@Spy LastlegOrderNoGenerator orderNoGenerator = new LastlegOrderNoGenerator();
|
||||
|
||||
private RelabelApplicationService service;
|
||||
@@ -82,6 +83,12 @@ class RelabelApplicationServiceTest {
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new RelabelApplicationService();
|
||||
ReflectionTestUtils.setField(service, "pdaTasks", pdaTasks);
|
||||
lenient().when(pdaTasks.lock(any(), any())).thenAnswer(call -> {
|
||||
var task = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask();
|
||||
task.setStatus("WAIT_REVIEW"); task.setVersion(1); return task;
|
||||
});
|
||||
lenient().when(pdaTasks.updateWithVersion(any(), any())).thenReturn(1);
|
||||
ReflectionTestUtils.setField(service, "applications", applications);
|
||||
ReflectionTestUtils.setField(service, "items", items);
|
||||
ReflectionTestUtils.setField(service, "histories", histories);
|
||||
@@ -152,6 +159,31 @@ class RelabelApplicationServiceTest {
|
||||
assertEquals("FBR-1", result.application().relabelNo());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createWithoutChargeSkipsWalletFreeze() {
|
||||
RelabelRequests.Create charged = request("key-free", 10, 6);
|
||||
RelabelRequests.Create request = new RelabelRequests.Create(charged.customerOrderNo(),
|
||||
charged.operationWarehouseId(), charged.labelDeadline(), charged.contactName(), charged.contactPhone(),
|
||||
null, charged.remark(), charged.idempotencyKey(), charged.items(), charged.deliveryMethod(),
|
||||
charged.printShippingLabel());
|
||||
RelabelApplication persisted = application(11L, "FBR-FREE", "10001", "key-free", null,
|
||||
RelabelApplicationService.PENDING_PROCESSING);
|
||||
persisted.setLastlegOrderId(21L);
|
||||
when(applications.findNo(eq("10001"), any())).thenReturn(persisted);
|
||||
when(items.selectList(any())).thenReturn(List.of(item(31L, 11L, "SKU-A", 10, 6)));
|
||||
when(histories.selectList(any())).thenReturn(List.of());
|
||||
when(orders.selectById(21L)).thenReturn(order(21L, "10001", "LMO-RELABEL-FREE",
|
||||
RelabelApplicationService.PENDING_PROCESSING));
|
||||
|
||||
service.create("10001", "operator-1", request);
|
||||
|
||||
ArgumentCaptor<RelabelApplication> application = ArgumentCaptor.forClass(RelabelApplication.class);
|
||||
verify(applications).insert(application.capture());
|
||||
assertEquals(BigDecimal.ZERO, application.getValue().getChargeAmount());
|
||||
assertEquals("NOT_REQUIRED", application.getValue().getWalletStatus());
|
||||
verify(walletCommands, never()).publish(any(), any(Long.class), any(), any(), any(), any(), any(Long.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void partialRelabelFreezesOnlyNotifiedPackages() {
|
||||
RelabelRequests.Create request = request("key-partial", 10, 2);
|
||||
@@ -363,6 +395,23 @@ class RelabelApplicationServiceTest {
|
||||
verify(applications).findNo("10002", "FBR-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void applicationWithoutChargeCanEnterProcessing() {
|
||||
RelabelApplication application = application(11L, "FBR-FREE", "10001", "key-free", "hash",
|
||||
RelabelApplicationService.PENDING_PROCESSING);
|
||||
application.setLastlegOrderId(21L);
|
||||
application.setChargeAmount(BigDecimal.ZERO);
|
||||
application.setWalletStatus("NOT_REQUIRED");
|
||||
when(applications.lockNo("10001", "FBR-FREE")).thenReturn(application);
|
||||
stubDetail(application);
|
||||
|
||||
service.changeStatus("10001", "FBR-FREE", "operator-2",
|
||||
new RelabelRequests.ChangeStatus("PROCESSING", "started"));
|
||||
|
||||
assertEquals("PROCESSING", application.getStatus());
|
||||
verify(walletCommands, never()).publish(any(), any(Long.class), any(), any(), any(), any(), any(Long.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void completedReturnRelabelOrderEntersPendingAssignment() {
|
||||
RelabelApplication application = application(11L, "FBR-1", "10001", "key-1", "hash", RelabelApplicationService.READY_FOR_OUTBOUND);
|
||||
@@ -414,6 +463,10 @@ class RelabelApplicationServiceTest {
|
||||
|
||||
assertEquals(RelabelApplicationService.READY_FOR_OUTBOUND, application.getStatus());
|
||||
assertEquals("WAIT_PRE_DISPATCH", application.getFulfillmentStatus());
|
||||
var review = ArgumentCaptor.forClass(com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask.class);
|
||||
verify(pdaTasks).updateWithVersion(review.capture(), eq(1));
|
||||
assertEquals("COMPLETED", review.getValue().getStatus());
|
||||
assertEquals("CUSTOMER_CONFIRMED", review.getValue().getReviewDecision());
|
||||
assertNull(application.getOutboundOrderNo());
|
||||
verify(outboundOrders, never()).insert(any(OutboundOrder.class));
|
||||
|
||||
|
||||
+6
@@ -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("; 地址不完整"));
|
||||
|
||||
@@ -273,5 +273,6 @@ Dry-run 只校验两个统一 SQL 的入口标记,打印当前 SHA-256、行
|
||||
|
||||
## 海外换标定向验收补充
|
||||
|
||||
- `scripts/current-session-overseas-relabel-acceptance.mjs`:本地开发环境定向入口,从当前 Codex 内置浏览器本地存储读取最新平台/客户 refresh token,在内存中刷新双区域会话,自动选择当前客户具备足量库存的海外仓并临时创建海外 PDA 操作员;随后串行运行换标拆/合托、智能预排、PDA 出库与派送完成。报告写入 `reports/current-session-overseas-relabel/`,不记录 Token/密码;会消耗当前浏览器 refresh token 并保留业务验收单据,临时 PDA 在 `finally` 中停用。只允许本机开发环境且须有当前轮真实写入授权。
|
||||
- `scripts/overseas-relabel-real-acceptance.mjs`:定向真实 HTTP 换标作业,会创建换标、库存及排车记录;从显式环境文件读取平台/PDA账号、网关及客户参数,输出脚本配置的报告,不自动清理。失败保留证据与已创建资源,重跑前核对报告,必须另获指定环境授权。
|
||||
- `scripts/overseas-relabel-fulfillment-acceptance.mjs`:读取 `OVERSEAS_ACCEPTANCE_SOURCE` 或指定排车/换标编号,执行 PDA 拣货、出库、派送后续真实 HTTP,会推进业务状态;凭据来自显式环境文件,报告默认 `reports/overseas-relabel-fulfillment-acceptance.json`(`OVERSEAS_FULFILLMENT_OUTPUT` 可覆盖)。失败保留资源,重跑前核对业务状态,不自动清理。两个定向脚本不替代 Admin 全量入口,本次合并仅做离线合同。
|
||||
|
||||
@@ -327,6 +327,93 @@ export async function run(context) {
|
||||
{ ...mergeBody, weight: 3 }, {}, picker.token)
|
||||
add(context, '合箱同键异载荷返回冲突', mergeConflict, conflict(mergeConflict))
|
||||
}
|
||||
|
||||
const relabelCandidates = await overseas(context, merchantToken, 'GET', '客户查询本轮退货换标候选',
|
||||
`${RETURN_ORDERS}/${encodeURIComponent(returnNo)}/relabel-candidates`)
|
||||
const relabelCandidate = (Array.isArray(relabelCandidates.data) ? relabelCandidates.data : [])
|
||||
.find(row => nonBlank(row?.cartonMark) && row?.status === 'AVAILABLE')
|
||||
context.assert(MODULE, '退货换标候选来自本轮已签收可用箱', relabelCandidates.response,
|
||||
Boolean(relabelCandidate) && relabelCandidate.sourceReturnNo === returnNo,
|
||||
`cartonMark=${relabelCandidate?.cartonMark || 'missing'}`, OPTIONS)
|
||||
if (relabelCandidate) {
|
||||
const relabelBody = {
|
||||
cartonMarks: [relabelCandidate.cartonMark], idempotencyKey: key(context, 'RELABEL'),
|
||||
contactName: '退货换标验收', contactPhone: '13800000008', deliveryMethod: 'TRUCK', remark: context.marker,
|
||||
}
|
||||
const relabel = await overseas(context, merchantToken, 'POST', '客户从退货箱创建换标单',
|
||||
`${RETURN_ORDERS}/${encodeURIComponent(returnNo)}/relabel`, relabelBody)
|
||||
const relabelNo = id(relabel.data?.relabelNo)
|
||||
context.assert(MODULE, '退货换标单绑定原退货单与选中箱', relabel.response,
|
||||
nonBlank(relabelNo) && relabel.data?.returnNo === returnNo
|
||||
&& relabel.data?.status === 'PENDING_PROCESSING'
|
||||
&& relabel.data?.cartonMarks?.includes?.(relabelCandidate.cartonMark),
|
||||
`relabelNo=${relabelNo || 'missing'}`, OPTIONS)
|
||||
if (relabelNo) {
|
||||
const form = new FormData()
|
||||
form.append('file', new Blob([new TextEncoder().encode(`%PDF-1.4\n${context.marker}\n%%EOF`)],
|
||||
{ type: 'application/pdf' }), `${suffix}-return-relabel.pdf`)
|
||||
const uploaded = await context.requestOverseasMultipart('POST',
|
||||
`/api/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/files?scope=CARTON_TOTAL`,
|
||||
form, {}, merchantToken)
|
||||
add(context, '客户编辑退货换标总资料', uploaded, success(uploaded))
|
||||
const relabelDetail = await overseas(context, merchantToken, 'GET', '客户回查退货换标资料修改',
|
||||
`/api/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}`)
|
||||
context.assert(MODULE, '退货换标详情持久化总资料且业务状态未漂移', relabelDetail.response,
|
||||
relabelDetail.data?.application?.relabelNo === relabelNo
|
||||
&& relabelDetail.data?.application?.status === 'PENDING_PROCESSING'
|
||||
&& nonBlank(relabelDetail.data?.application?.totalFileId), trace(relabelDetail.response), OPTIONS)
|
||||
context.createdResources.phase4OverseasReturnReceipt.relabelNo = relabelNo
|
||||
}
|
||||
}
|
||||
|
||||
const outboundCandidates = await overseas(context, context.overseasToken, 'GET', '平台查询本轮退货出库候选',
|
||||
query('/api/platform/v1/last-leg/outbound-orders/return/candidates', {
|
||||
customerId, overseasWarehouseId: warehouse.id,
|
||||
}))
|
||||
const outboundCandidate = (Array.isArray(outboundCandidates.data) ? outboundCandidates.data : [])
|
||||
.find(row => row?.returnNo === returnNo && nonBlank(row?.cartonMark)
|
||||
&& row.cartonMark !== relabelCandidate?.cartonMark && row.status === 'AVAILABLE')
|
||||
context.assert(MODULE, '退货出库候选排除已冻结换标箱', outboundCandidates.response,
|
||||
Boolean(outboundCandidate), `cartonMark=${outboundCandidate?.cartonMark || 'missing'}`, OPTIONS)
|
||||
if (outboundCandidate) {
|
||||
const outboundBody = {
|
||||
recipient: `${context.marker} RETURN OUTBOUND`, overseasWarehouseId: warehouse.id,
|
||||
returnNo, cartonMarks: [outboundCandidate.cartonMark], remark: context.marker,
|
||||
idempotencyKey: key(context, 'OUTBOUND'),
|
||||
}
|
||||
const outbound = await overseas(context, context.overseasToken, 'POST', '平台创建独立退货出库单',
|
||||
query('/api/platform/v1/last-leg/outbound-orders/return', { customerId }), outboundBody)
|
||||
const taskNo = id(outbound.data?.taskNo)
|
||||
const outboundNo = id(outbound.data?.outboundNo)
|
||||
context.assert(MODULE, 'RETURN_OUTBOUND创建PDA待拣任务', outbound.response,
|
||||
nonBlank(taskNo) && nonBlank(outboundNo) && outbound.data?.status === 'PENDING',
|
||||
`outboundNo=${outboundNo || 'missing'}, taskNo=${taskNo || 'missing'}`, OPTIONS)
|
||||
if (taskNo) {
|
||||
const task = await overseas(context, picker.token, 'GET', 'PDA读取退货出库任务明细',
|
||||
`/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(taskNo)}`)
|
||||
const taskItem = task.data?.items?.find?.(row => String(row?.cartonMarks || '').includes(outboundCandidate.cartonMark))
|
||||
|| task.data?.items?.[0]
|
||||
const pickBody = { outboundPalletItemId: taskItem?.outboundPalletItemId,
|
||||
inventoryPositionId: null, packages: 1, cartonMarks: [outboundCandidate.cartonMark],
|
||||
idempotencyKey: key(context, 'OUTBOUND-PICK') }
|
||||
const picked = await overseas(context, picker.token, 'POST', 'PDA一次扫描退货箱完成拣货',
|
||||
`/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(taskNo)}/pick`, pickBody)
|
||||
context.assert(MODULE, '退货出库拣货推进为PICKED且任务进行中', picked.response,
|
||||
picked.data?.outboundNo === outboundNo && picked.data?.status === 'IN_PROGRESS', trace(picked.response), OPTIONS)
|
||||
const completed = await overseas(context, picker.token, 'POST', 'PDA完成退货出库',
|
||||
`/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(taskNo)}/complete`,
|
||||
{ idempotencyKey: key(context, 'OUTBOUND-COMPLETE') })
|
||||
const outboundDetail = await overseas(context, context.overseasToken, 'GET', '平台回查退货出库最终状态',
|
||||
query(`/api/platform/v1/last-leg/outbound-orders/${encodeURIComponent(outboundNo)}`, { customerId }))
|
||||
context.assert(MODULE, 'RETURN_OUTBOUND真实完成且箱唛只出库一次', outboundDetail.response,
|
||||
completed.data?.status === 'COMPLETED'
|
||||
&& outboundDetail.data?.outbound?.outboundType === 'RETURN_OUTBOUND'
|
||||
&& outboundDetail.data?.outbound?.status === 'OUTBOUNDED'
|
||||
&& outboundDetail.data?.pallets?.some?.(row => row.cartonMarks === outboundCandidate.cartonMark
|
||||
&& Number(row.pickedPackageCount) === 1), trace(outboundDetail.response), OPTIONS)
|
||||
context.createdResources.phase4OverseasReturnReceipt.returnOutboundNo = outboundNo
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await disablePicker(context, picker, 91)
|
||||
}
|
||||
|
||||
@@ -65,6 +65,9 @@ export async function verifyWarehouseTransferReview(context, token, customerId,
|
||||
ok(result) && ok(detail) && detail.body.data?.status === expected
|
||||
&& detail.body.data?.orderType === 'WAREHOUSE_TRANSFER'
|
||||
&& detail.body.data?.orderStatus === expectedOrder
|
||||
&& detail.body.data?.reviewDecision === `${decision}ED`.replace('APPROVEED', 'APPROVED')
|
||||
&& detail.body.data?.reviewRemark === `${context.marker}-审核原因`
|
||||
&& Boolean(detail.body.data?.reviewedBy) && Boolean(detail.body.data?.reviewedAt)
|
||||
&& (detail.body.data?.pallets || []).length === 0, evidence(detail), OPTIONS)
|
||||
context.createdResources.phase4OverseasClientServices[`transit${decision}`] = { transferNo: no, status: detail.body?.data?.status }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const domestic = process.env.ADMIN_TEST_GATEWAY || 'http://127.0.0.1:9000'
|
||||
const overseas = process.env.ADMIN_OVERSEAS_GATEWAY || 'http://127.0.0.1:9300'
|
||||
const store = process.env.CODEX_BROWSER_LOCAL_STORAGE || join(process.env.APPDATA || '', 'Codex', 'web', 'Codex', 'Default', 'Partitions', 'codex-browser-app', 'Local Storage', 'leveldb')
|
||||
const outputDir = resolve(process.env.OVERSEAS_CURRENT_SESSION_OUTPUT_DIR || 'reports/current-session-overseas-relabel')
|
||||
const adminLoginId = process.env.ADMIN_TEST_USERNAME || 'platform_admin'
|
||||
const adminPassword = process.env.ADMIN_TEST_PASSWORD || 'ChangeMe123!'
|
||||
const marker = `CURRENT-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}`
|
||||
const summary = { marker, startedAt: new Date().toISOString(), scope: 'CURRENT_CODEX_BROWSER_SESSION', passed: false }
|
||||
|
||||
function assert(value, message) { if (!value) throw new Error(message) }
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function jwtId(token, ...claimNames) {
|
||||
try {
|
||||
const payload = Buffer.from(token.split('.')[1], 'base64url').toString('utf8')
|
||||
for (const claimName of claimNames) {
|
||||
const match = payload.match(new RegExp(`"${claimName}"\\s*:\\s*(?:"(\\d+)"|(\\d+))`))
|
||||
if (match) return match[1] || match[2]
|
||||
}
|
||||
} catch {}
|
||||
return ''
|
||||
}
|
||||
async function api(base, token, method, path, body) {
|
||||
const response = await fetch(`${base}${path}`, { method,
|
||||
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
|
||||
body: body === undefined ? undefined : JSON.stringify(body) })
|
||||
const text = await response.text(); let value
|
||||
try { value = text ? JSON.parse(text) : null } catch { value = { raw: text } }
|
||||
if (!response.ok || String(value?.code) !== '200') throw new Error(`${method} ${path}: HTTP=${response.status}, code=${value?.code}, message=${value?.message}`)
|
||||
return value.data
|
||||
}
|
||||
async function rawApi(base, token, method, path, body) {
|
||||
const response = await fetch(`${base}${path}`, { method,
|
||||
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
|
||||
body: body === undefined ? undefined : JSON.stringify(body) })
|
||||
const text = await response.text(); let value
|
||||
try { value = text ? JSON.parse(text) : null } catch { value = null }
|
||||
return { http: response.status, code: String(value?.code || ''), data: value?.data }
|
||||
}
|
||||
function storedValues(key) {
|
||||
assert(existsSync(store), `Codex browser local storage not found: ${store}`)
|
||||
const values = []
|
||||
const files = readdirSync(store).filter((name) => /\.(?:log|ldb)$/.test(name))
|
||||
.map((name) => ({ name, path: join(store, name) }))
|
||||
.sort((left, right) => readFileSync(right.path).length - readFileSync(left.path).length)
|
||||
for (const file of files) {
|
||||
const source = readFileSync(file.path).toString('latin1')
|
||||
let offset = 0
|
||||
while ((offset = source.indexOf(key, offset)) >= 0) {
|
||||
const match = source.slice(offset + key.length, offset + key.length + 256).match(/[A-Za-z0-9_-]{48,160}/)
|
||||
if (match) values.push(match[0])
|
||||
offset += key.length
|
||||
}
|
||||
}
|
||||
return [...new Set(values)].reverse()
|
||||
}
|
||||
function storedJwt(key) {
|
||||
for (const file of readdirSync(store).filter((name) => /\.(?:log|ldb)$/.test(name)).reverse()) {
|
||||
const source = readFileSync(join(store, file)).toString('latin1')
|
||||
let offset = source.lastIndexOf(key)
|
||||
while (offset >= 0) {
|
||||
const match = source.slice(offset + key.length, offset + key.length + 2000)
|
||||
.match(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/)
|
||||
if (match) return match[0]
|
||||
offset = source.lastIndexOf(key, offset - 1)
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
async function refreshStoredSession(key, label) {
|
||||
for (const refreshToken of storedValues(key)) {
|
||||
try { return await api(domestic, '', 'POST', '/api/auth/refresh', { refreshToken }) } catch {}
|
||||
}
|
||||
throw new Error(`No valid ${label} refresh token was found in the current Codex browser session`)
|
||||
}
|
||||
async function disableStalePdas(adminToken) {
|
||||
const users = await api(domestic, adminToken, 'GET', '/api/platform/v1/iam/users?page=1&size=100&keyword=pda_current_')
|
||||
for (const user of users.records || []) {
|
||||
if (String(user.username || '').startsWith('pda_current_') && user.status === 'ACTIVE') {
|
||||
await api(domestic, adminToken, 'PUT', `/api/platform/v1/iam/users/${id(user.membershipId || user.id)}/status`, { status: 'DISABLED' })
|
||||
}
|
||||
}
|
||||
}
|
||||
async function createPda(adminToken, warehouseId, onCreated) {
|
||||
const roles = await api(domestic, adminToken, 'GET', '/api/platform/v1/iam/roles')
|
||||
const role = roles.find((item) => item.roleCode === 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' && item.status === 'ACTIVE')
|
||||
assert(role, 'Active OVERSEAS_WAREHOUSE_PDA_OPERATOR role not found')
|
||||
const suffix = `${Date.now()}${process.pid}`.slice(-12)
|
||||
const username = `pda_current_${suffix}`
|
||||
const password = `PdaCurrent${suffix}A9`
|
||||
const created = await api(domestic, adminToken, 'POST', '/api/platform/v1/iam/users', {
|
||||
username, displayName: `当前会话海外PDA验收-${suffix}`, phone: `19${suffix.slice(-9)}`,
|
||||
email: `${username}@example.com`, tenantId: '0', roleIds: [id(role.id)],
|
||||
overseasWarehouseId: warehouseId, password, status: 'ACTIVE',
|
||||
})
|
||||
onCreated({ membershipId: id(created.membershipId), token: null })
|
||||
const login = await api(domestic, '', 'POST', '/api/auth/login', { loginId: username, password })
|
||||
assert(login.overseasAccessToken, 'Temporary PDA login did not return an overseas token')
|
||||
onCreated({ membershipId: id(created.membershipId), token: login.overseasAccessToken })
|
||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||
const response = await rawApi(overseas, login.overseasAccessToken, 'GET', '/api/pda/v1/last-leg/receiving-containers/current')
|
||||
if (response.http === 200 && response.code !== '403' && response.code !== 'A0403') {
|
||||
return { membershipId: id(created.membershipId), token: login.overseasAccessToken }
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
}
|
||||
throw new Error('Temporary PDA security projection did not converge')
|
||||
}
|
||||
|
||||
await mkdir(outputDir, { recursive: true })
|
||||
let admin
|
||||
let pda
|
||||
try {
|
||||
try {
|
||||
admin = await refreshStoredSession('cc-admin-refresh-token', 'platform')
|
||||
} catch {
|
||||
admin = await api(domestic, '', 'POST', '/api/auth/login', { loginId: adminLoginId, password: adminPassword })
|
||||
}
|
||||
const storedMerchantToken = storedJwt('cc-client-overseas-access-token')
|
||||
const storedCustomerId = jwtId(storedMerchantToken, 'tenant_id', 'tenantId')
|
||||
let merchant
|
||||
try {
|
||||
merchant = await refreshStoredSession('cc-client-refresh-token', 'merchant')
|
||||
} catch {
|
||||
assert(storedCustomerId, 'Current Codex merchant session does not contain a customer id')
|
||||
const ticket = await api(domestic, admin.accessToken, 'POST',
|
||||
`/api/platform/v1/iam/tenants/${storedCustomerId}/impersonation`, { reason: `${marker} 当前客户海外API验收` })
|
||||
merchant = await api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code })
|
||||
}
|
||||
assert(admin.accessToken && admin.overseasAccessToken && merchant.accessToken && merchant.overseasAccessToken, 'Refresh response did not contain both regional tokens')
|
||||
const customerId = jwtId(merchant.overseasAccessToken, 'tenant_id', 'tenantId')
|
||||
assert(customerId, 'Current merchant customer id is missing from the overseas token')
|
||||
const warehouses = await api(domestic, merchant.accessToken, 'GET', '/api/merchant/v1/warehouses?warehouseType=OVERSEAS&page=1&size=200')
|
||||
let selected = null
|
||||
for (const warehouse of warehouses.records || []) {
|
||||
if (warehouse.status !== 'ACTIVE') continue
|
||||
const candidates = await api(overseas, merchant.overseasAccessToken, 'GET', `/api/merchant/v1/last-leg/relabel-applications/inventory-candidates?operationWarehouseId=${encodeURIComponent(id(warehouse.id))}`)
|
||||
if (candidates.some((item) => String(item.inboundOrderNo || '').startsWith('DS-') && Number(item.availablePackages) >= 5 && item.destinationFbaWarehouseCode && item.positions?.some((position) => Number(position.availablePackages) >= 5))) {
|
||||
selected = warehouse; break
|
||||
}
|
||||
}
|
||||
assert(selected, 'Current merchant has no active overseas warehouse with a five-carton DS inventory source')
|
||||
await disableStalePdas(admin.accessToken)
|
||||
pda = await createPda(admin.accessToken, id(selected.id), (created) => { pda = created })
|
||||
|
||||
const relabelReport = join(outputDir, 'relabel.json')
|
||||
const fulfillmentReport = join(outputDir, 'fulfillment.json')
|
||||
const childEnv = { ...process.env, ADMIN_TEST_GATEWAY: domestic, ADMIN_OVERSEAS_GATEWAY: overseas,
|
||||
ADMIN_OVERSEAS_ACCESS_TOKEN: admin.overseasAccessToken, MERCHANT_OVERSEAS_ACCESS_TOKEN: merchant.overseasAccessToken,
|
||||
PDA_OVERSEAS_ACCESS_TOKEN: pda.token, OVERSEAS_TEST_CUSTOMER_ID: customerId,
|
||||
OVERSEAS_TEST_WAREHOUSE_ID: id(selected.id), OVERSEAS_ACCEPTANCE_OUTPUT: relabelReport,
|
||||
OVERSEAS_ACCEPTANCE_SOURCE: relabelReport, OVERSEAS_FULFILLMENT_OUTPUT: fulfillmentReport }
|
||||
for (const script of ['overseas-relabel-real-acceptance.mjs', 'overseas-relabel-fulfillment-acceptance.mjs']) {
|
||||
const result = spawnSync(process.execPath, [resolve('scripts', script)], { cwd: resolve('.'), env: childEnv, stdio: 'inherit' })
|
||||
if (result.error) throw result.error
|
||||
if (result.status !== 0) throw new Error(`${script} failed with exit code ${result.status}`)
|
||||
}
|
||||
Object.assign(summary, { customerId, warehouseId: id(selected.id), warehouseCode: selected.warehouseCode,
|
||||
relabelReport, fulfillmentReport, passed: true, finishedAt: new Date().toISOString() })
|
||||
} finally {
|
||||
if (admin?.accessToken && pda?.membershipId) {
|
||||
try { await api(domestic, admin.accessToken, 'PUT', `/api/platform/v1/iam/users/${pda.membershipId}/status`, { status: 'DISABLED' }); summary.pdaCleanup = 'DISABLED' }
|
||||
catch (error) { summary.pdaCleanup = `FAILED: ${error.message}` }
|
||||
}
|
||||
await writeFile(join(outputDir, 'summary.json'), JSON.stringify(summary, null, 2), 'utf8')
|
||||
}
|
||||
console.log(JSON.stringify(summary, null, 2))
|
||||
@@ -7,9 +7,13 @@ const directDispatchNo = process.env.OVERSEAS_DISPATCH_NO
|
||||
const directRelabelNo = process.env.OVERSEAS_RELABEL_NO
|
||||
const output = process.env.OVERSEAS_FULFILLMENT_OUTPUT || 'reports/overseas-relabel-fulfillment-acceptance.json'
|
||||
const customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID
|
||||
const directPlatformToken = process.env.ADMIN_OVERSEAS_ACCESS_TOKEN
|
||||
const directMerchantToken = process.env.MERCHANT_OVERSEAS_ACCESS_TOKEN
|
||||
const directPdaToken = process.env.PDA_OVERSEAS_ACCESS_TOKEN
|
||||
const marker = `FUL-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}`
|
||||
const required = ['ADMIN_TEST_USERNAME', 'ADMIN_TEST_PASSWORD', 'PDA_TEST_USERNAME', 'PDA_TEST_PASSWORD']
|
||||
for (const name of required) if (!process.env[name]) throw new Error(`Missing ${name}`)
|
||||
if (!directPlatformToken && (!process.env.ADMIN_TEST_USERNAME || !process.env.ADMIN_TEST_PASSWORD)) throw new Error('Missing platform credentials or token')
|
||||
if (!directMerchantToken && (!process.env.ADMIN_TEST_USERNAME || !process.env.ADMIN_TEST_PASSWORD)) throw new Error('Missing merchant token or platform credentials')
|
||||
if (!directPdaToken && (!process.env.PDA_TEST_USERNAME || !process.env.PDA_TEST_PASSWORD)) throw new Error('Missing PDA credentials or token')
|
||||
if ((!sourceReport && !directDispatchNo) || !customerId) throw new Error('Missing acceptance source/dispatch or customer id')
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
@@ -33,16 +37,16 @@ const scenario = source?.scenarios.find((value) => value.name === 'SPLIT-TWO-TAR
|
||||
|| { dispatchNo: directDispatchNo, relabelNo: directRelabelNo || '' }
|
||||
assert(scenario?.dispatchNo, 'Dispatch not found in source report')
|
||||
const dispatchNo = scenario.dispatchNo
|
||||
const platformLogin = await api(domestic, '', 'POST', '/api/auth/login', {
|
||||
const platformLogin = directPlatformToken ? null : await api(domestic, '', 'POST', '/api/auth/login', {
|
||||
loginId: process.env.ADMIN_TEST_USERNAME, password: process.env.ADMIN_TEST_PASSWORD })
|
||||
const pdaLogin = await api(domestic, '', 'POST', '/api/auth/login', {
|
||||
const pdaLogin = directPdaToken ? null : await api(domestic, '', 'POST', '/api/auth/login', {
|
||||
loginId: process.env.PDA_TEST_USERNAME, password: process.env.PDA_TEST_PASSWORD })
|
||||
const ticket = await api(domestic, platformLogin.accessToken, 'POST',
|
||||
const ticket = directMerchantToken ? null : await api(domestic, platformLogin.accessToken, 'POST',
|
||||
`/api/platform/v1/iam/tenants/${customerId}/impersonation`, { reason: `${marker} 海外尾程履约验收` })
|
||||
const merchantLogin = await api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code })
|
||||
const platformToken = platformLogin.overseasAccessToken
|
||||
const merchantToken = merchantLogin.overseasAccessToken
|
||||
const pdaToken = pdaLogin.overseasAccessToken
|
||||
const merchantLogin = directMerchantToken ? null : await api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code })
|
||||
const platformToken = directPlatformToken || platformLogin.overseasAccessToken
|
||||
const merchantToken = directMerchantToken || merchantLogin.overseasAccessToken
|
||||
const pdaToken = directPdaToken || pdaLogin.overseasAccessToken
|
||||
|
||||
await api(overseas, platformToken, 'PUT', `/api/platform/v1/last-leg/dispatches/${dispatchNo}`, {
|
||||
vehiclePlate: `UAT-${marker.slice(-8)}`, driverName: '尾程验收司机', driverPhone: '19900000000',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user