提交
This commit is contained in:
@@ -2,4 +2,14 @@ spring.datasource:
|
|||||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
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}
|
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}
|
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}
|
username: ${IAM_DB_USERNAME:root}
|
||||||
password: ${IAM_DB_PASSWORD:123456}
|
password: ${IAM_DB_PASSWORD:123456}
|
||||||
baiyue:
|
baiyue:
|
||||||
security:
|
security-event:
|
||||||
# 本机只运行一个 Event Bridge;部署环境通过 SECURITY_EVENT_BRIDGE_URL 指向海外实例。
|
# 本机只运行一个 Event Bridge;部署环境通过 SECURITY_EVENT_BRIDGE_URL 指向海外实例。
|
||||||
event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://127.0.0.1:9312}
|
event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://127.0.0.1:9312}
|
||||||
customer-dimension-initialization:
|
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`;确需更新依赖基线时必须说明稳定边界变化,不能把临时循环依赖登记为长期豁免。
|
- 移动包、拆分端口/适配器或引入新的跨子域依赖时,先核对内部模块化基线并运行 `node --test scripts/analyze-lastleg-dependencies.test.mjs`;确需更新依赖基线时必须说明稳定边界变化,不能把临时循环依赖登记为长期豁免。
|
||||||
- 安全投影边界变更同时运行 `SecurityProjectionArchitectureTest` 与 `LastlegSecurityContractTest`;`security/controller|dto|service` 只允许依赖本子域和根 `InternalServiceTokenGuard`,其他 Lastleg 业务包不得直接依赖这些投影内部包。
|
- 安全投影边界变更同时运行 `SecurityProjectionArchitectureTest` 与 `LastlegSecurityContractTest`;`security/controller|dto|service` 只允许依赖本子域和根 `InternalServiceTokenGuard`,其他 Lastleg 业务包不得直接依赖这些投影内部包。
|
||||||
- 表字段、注释和索引同步维护基线 SQL 与统一增量 SQL;生产/存量库只执行增量脚本。
|
- 表字段、注释和索引同步维护基线 SQL 与统一增量 SQL;生产/存量库只执行增量脚本。
|
||||||
|
|||||||
+19
@@ -25,6 +25,25 @@ import org.springframework.web.server.ResponseStatusException;
|
|||||||
@RequestMapping("/api/platform/v1/last-leg/relabel-applications")
|
@RequestMapping("/api/platform/v1/last-leg/relabel-applications")
|
||||||
public class RelabelApplicationController {
|
public class RelabelApplicationController {
|
||||||
@Resource private RelabelApplicationService service;
|
@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
|
@PostMapping
|
||||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_CREATE + "')")
|
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_CREATE + "')")
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ public final class RelabelRequests {
|
|||||||
@NotNull LocalDateTime labelDeadline,
|
@NotNull LocalDateTime labelDeadline,
|
||||||
@NotBlank @Size(max = 128) String contactName,
|
@NotBlank @Size(max = 128) String contactName,
|
||||||
@NotBlank @Size(max = 64) String contactPhone,
|
@NotBlank @Size(max = 64) String contactPhone,
|
||||||
@NotNull @DecimalMin(value = "0.01") BigDecimal chargeAmount,
|
@DecimalMin(value = "0.01") BigDecimal chargeAmount,
|
||||||
@Size(max = 1000) String remark,
|
@Size(max = 1000) String remark,
|
||||||
@NotBlank @Size(max = 255) String idempotencyKey,
|
@NotBlank @Size(max = 255) String idempotencyKey,
|
||||||
@NotEmpty @Size(max = 500) List<@Valid Item> items,
|
@NotEmpty @Size(max = 500) List<@Valid Item> items,
|
||||||
|
|||||||
+36
@@ -62,6 +62,33 @@ public class PdaRelabelController {
|
|||||||
identity.overseasWarehouseId(), relabelNo, request));
|
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")
|
@PostMapping("/{relabelNo}/units/skip")
|
||||||
public ApiResponse<PdaRelabelResponses.Detail> skip(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<PdaRelabelResponses.Detail> skip(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Skip request) {
|
@PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Skip request) {
|
||||||
@@ -86,6 +113,15 @@ public class PdaRelabelController {
|
|||||||
identity.userId(), relabelNo, file));
|
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")
|
@PostMapping("/{relabelNo}/complete")
|
||||||
public ApiResponse<PdaRelabelResponses.Detail> complete(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<PdaRelabelResponses.Detail> complete(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Complete request) {
|
@PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Complete request) {
|
||||||
|
|||||||
+19
-2
@@ -1,6 +1,7 @@
|
|||||||
package com.baiyue.lastleg.relabel.pda.dto;
|
package com.baiyue.lastleg.relabel.pda.dto;
|
||||||
|
|
||||||
import jakarta.validation.constraints.Min;
|
import jakarta.validation.constraints.Min;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import jakarta.validation.constraints.NotEmpty;
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
@@ -14,11 +15,23 @@ public final class PdaRelabelRequests {
|
|||||||
|
|
||||||
public record Claim(@NotBlank @Size(max = 255) String idempotencyKey) { }
|
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,
|
public record Scan(@Positive Long itemId,
|
||||||
@NotBlank @Size(max = 255) String oldLabel,
|
@NotBlank @Size(max = 255) String oldLabel,
|
||||||
@Size(max = 255) String newLabel,
|
@Size(max = 255) String newLabel,
|
||||||
@NotNull @Min(0) Integer expectedVersion,
|
@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,
|
public record Skip(@NotNull @Positive Long itemId,
|
||||||
@NotBlank @Size(max = 255) String oldLabel,
|
@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,
|
public record Complete(@NotEmpty @Size(max = 20) List<@Positive Long> photoFileIds,
|
||||||
@NotNull @Min(0) Integer expectedVersion,
|
@NotNull @Min(0) Integer expectedVersion,
|
||||||
@NotBlank @Size(max = 255) String idempotencyKey,
|
@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,
|
public record Review(@NotBlank @Size(max = 16) String decision,
|
||||||
@NotNull @Min(0) Integer expectedVersion,
|
@NotNull @Min(0) Integer expectedVersion,
|
||||||
|
|||||||
+3
-3
@@ -7,12 +7,12 @@ public final class PdaRelabelResponses {
|
|||||||
private PdaRelabelResponses() { }
|
private PdaRelabelResponses() { }
|
||||||
|
|
||||||
public record Candidate(String relabelNo, String customerOrderNo, LocalDateTime labelDeadline,
|
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 expectedPackages, int relabelledPackages, int skippedPackages,
|
||||||
int exceptionPackages, int version, boolean claimedByCurrentOperator) { }
|
int exceptionPackages, int version, boolean claimedByCurrentOperator) { }
|
||||||
|
|
||||||
public record Detail(String relabelNo, String customerOrderNo, LocalDateTime labelDeadline,
|
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,
|
String taskStatus, int expectedPackages, int relabelledPackages,
|
||||||
int skippedPackages, int exceptionPackages, int version,
|
int skippedPackages, int exceptionPackages, int version,
|
||||||
String operatorName, String reviewerName, List<Long> completionPhotoFileIds,
|
String operatorName, String reviewerName, List<Long> completionPhotoFileIds,
|
||||||
@@ -26,7 +26,7 @@ public final class PdaRelabelResponses {
|
|||||||
String cartonMark, String fbaCartonMark, String operationType,
|
String cartonMark, String fbaCartonMark, String operationType,
|
||||||
List<SkuItem> skuItems,
|
List<SkuItem> skuItems,
|
||||||
int expectedPackages, int relabelledPackages, int skippedPackages,
|
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 SkuItem(String skuCode, String newSkuCode, int quantity, String remark) { }
|
||||||
|
|
||||||
public record UnitFact(Long factId, Long itemId, String oldLabel, String newLabel,
|
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")
|
@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);
|
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("""
|
||||||
UPDATE ll_relabel_pda_task
|
UPDATE ll_relabel_pda_task
|
||||||
SET status=#{task.status}, operator_key=#{task.operatorKey}, operator_name=#{task.operatorName},
|
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 RelabelApplicationService applicationService;
|
||||||
@Resource private RelabelPhotoFileGateway photoFiles;
|
@Resource private RelabelPhotoFileGateway photoFiles;
|
||||||
@Resource private ObjectMapper sourceObjectMapper;
|
@Resource private ObjectMapper sourceObjectMapper;
|
||||||
|
@Resource private com.baiyue.lastleg.mapper.LastlegOrderMapper tailOrders;
|
||||||
|
@Resource private com.baiyue.lastleg.exceptionorder.service.ExceptionOrderService exceptionOrders;
|
||||||
private ObjectMapper objectMapper;
|
private ObjectMapper objectMapper;
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
@@ -81,7 +83,8 @@ public class PdaRelabelService {
|
|||||||
public List<PdaRelabelResponses.Candidate> candidates(String operatorKey, long overseasWarehouseId) {
|
public List<PdaRelabelResponses.Candidate> candidates(String operatorKey, long overseasWarehouseId) {
|
||||||
return applications.selectList(new LambdaQueryWrapper<RelabelApplication>()
|
return applications.selectList(new LambdaQueryWrapper<RelabelApplication>()
|
||||||
.eq(RelabelApplication::getOverseasWarehouseId, String.valueOf(overseasWarehouseId))
|
.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::getLabelDeadline)
|
||||||
.orderByAsc(RelabelApplication::getCreatedAt))
|
.orderByAsc(RelabelApplication::getCreatedAt))
|
||||||
.stream().map(application -> candidate(application,
|
.stream().map(application -> candidate(application,
|
||||||
@@ -106,6 +109,10 @@ public class PdaRelabelService {
|
|||||||
throw conflict("Relabel application is not executable");
|
throw conflict("Relabel application is not executable");
|
||||||
}
|
}
|
||||||
PdaRelabelTask task = tasks.lock(application.getCustomerId(), application.getId());
|
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 (task != null) {
|
||||||
if (!operatorKey.equals(task.getOperatorKey()) || !PROCESSING.equals(task.getStatus())) {
|
if (!operatorKey.equals(task.getOperatorKey()) || !PROCESSING.equals(task.getStatus())) {
|
||||||
throw conflict("Relabel task is already claimed or awaiting review");
|
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(),
|
return unitAction(operatorKey, operatorName, overseasWarehouseId, relabelNo, "RELABEL", request.idempotencyKey(),
|
||||||
request, itemId, request.oldLabel(), request.newLabel(), "RELABELLED", null, null,
|
request, itemId, request.oldLabel(), request.newLabel(), "RELABELLED", null, null,
|
||||||
request.expectedVersion(), item -> {
|
request.expectedVersion(), item -> {
|
||||||
|
validateSkuVerification(item, request.skuVerifications());
|
||||||
if (StringUtils.hasText(item.getCartonMark()) && !item.getCartonMark().trim().equalsIgnoreCase(request.oldLabel().trim())) {
|
if (StringUtils.hasText(item.getCartonMark()) && !item.getCartonMark().trim().equalsIgnoreCase(request.oldLabel().trim())) {
|
||||||
throw conflict("Scanned old carton mark does not match the relabel item");
|
throw conflict("Scanned old carton mark does not match the relabel item");
|
||||||
}
|
}
|
||||||
String expectedFbaLabel = trim(item.getFbaCartonMark());
|
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)
|
if (StringUtils.hasText(request.newLabel()) && StringUtils.hasText(expectedFbaLabel)
|
||||||
&& !expectedFbaLabel.equalsIgnoreCase(request.newLabel().trim())) {
|
&& !expectedFbaLabel.equalsIgnoreCase(request.newLabel().trim())) {
|
||||||
throw conflict("Scanned new label does not match the approved FBA carton mark");
|
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
|
@Transactional
|
||||||
public PdaRelabelResponses.Detail skip(String operatorKey, String operatorName, long overseasWarehouseId,
|
public PdaRelabelResponses.Detail skip(String operatorKey, String operatorName, long overseasWarehouseId,
|
||||||
String relabelNo, PdaRelabelRequests.Skip request) {
|
String relabelNo, PdaRelabelRequests.Skip request) {
|
||||||
@@ -194,6 +245,15 @@ public class PdaRelabelService {
|
|||||||
uploaded.contentType(), uploaded.sizeBytes());
|
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
|
@Transactional
|
||||||
public PdaRelabelResponses.Detail complete(String operatorKey, String operatorName, long overseasWarehouseId,
|
public PdaRelabelResponses.Detail complete(String operatorKey, String operatorName, long overseasWarehouseId,
|
||||||
String relabelNo, PdaRelabelRequests.Complete request) {
|
String relabelNo, PdaRelabelRequests.Complete request) {
|
||||||
@@ -206,22 +266,59 @@ public class PdaRelabelService {
|
|||||||
requireVersion(task, request.expectedVersion());
|
requireVersion(task, request.expectedVersion());
|
||||||
List<RelabelItem> detailItems = itemList(application);
|
List<RelabelItem> detailItems = itemList(application);
|
||||||
List<PdaRelabelUnitFact> facts = factList(task);
|
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) {
|
for (RelabelItem item : detailItems) {
|
||||||
long actual = facts.stream().filter(fact -> item.getId().equals(fact.getItemId())).count();
|
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");
|
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();
|
List<Long> photoIds = request.photoFileIds().stream().distinct().toList();
|
||||||
if (photoIds.size() != request.photoFileIds().size()) throw conflict("Relabel photo IDs must be unique");
|
if (photoIds.size() != request.photoFileIds().size()) throw conflict("Relabel photo IDs must be unique");
|
||||||
photoIds.forEach(fileId -> photoFiles.validate(application.getCustomerId(), task.getId(), fileId));
|
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();
|
String before = task.getStatus();
|
||||||
task.setStatus(WAIT_REVIEW); task.setCompletionPhotoFileIds(write(photoIds));
|
task.setStatus(WAIT_REVIEW); task.setCompletionPhotoFileIds(write(photoIds));
|
||||||
task.setCompletionRemark(trim(request.remark())); task.setSubmittedAt(LocalDateTime.now());
|
task.setCompletionRemark(trim(request.remark())); task.setSubmittedAt(LocalDateTime.now());
|
||||||
updateTask(task, request.expectedVersion());
|
updateTask(task, request.expectedVersion());
|
||||||
insertHistory(task, "COMPLETE", before, WAIT_REVIEW, operatorKey, operatorName,
|
insertHistory(task, "COMPLETE", before, WAIT_REVIEW, operatorKey, operatorName,
|
||||||
Map.of("photoFileIds", photoIds, "remark", Objects.toString(trim(request.remark()), "")));
|
Map.of("photoFileIds", photoIds, "remark", Objects.toString(trim(request.remark()), ""),
|
||||||
application.setStatus(PROCESSING);
|
"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);
|
PdaRelabelResponses.Detail result = detail(application, task);
|
||||||
saveOperation(task, operatorKey, "COMPLETE", request.idempotencyKey(), command, result);
|
saveOperation(task, operatorKey, "COMPLETE", request.idempotencyKey(), command, result);
|
||||||
return result;
|
return result;
|
||||||
@@ -230,6 +327,17 @@ public class PdaRelabelService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public PdaRelabelResponses.Detail review(String operatorKey, String operatorName, long overseasWarehouseId,
|
public PdaRelabelResponses.Detail review(String operatorKey, String operatorName, long overseasWarehouseId,
|
||||||
String relabelNo, PdaRelabelRequests.Review request) {
|
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);
|
ReviewCommand command = new ReviewCommand(relabelNo, request);
|
||||||
PdaRelabelResponses.Detail replay = replay(operatorKey, overseasWarehouseId, relabelNo,
|
PdaRelabelResponses.Detail replay = replay(operatorKey, overseasWarehouseId, relabelNo,
|
||||||
"REVIEW", request.idempotencyKey(), command);
|
"REVIEW", request.idempotencyKey(), command);
|
||||||
@@ -237,7 +345,8 @@ public class PdaRelabelService {
|
|||||||
String decision = request.decision().trim().toUpperCase(Locale.ROOT);
|
String decision = request.decision().trim().toUpperCase(Locale.ROOT);
|
||||||
if (!Set.of("APPROVE", "REWORK").contains(decision)) throw badRequest("Review decision must be APPROVE or REWORK");
|
if (!Set.of("APPROVE", "REWORK").contains(decision)) throw badRequest("Review decision must be APPROVE or REWORK");
|
||||||
RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, true);
|
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");
|
throw conflict("Relabel application is no longer executable");
|
||||||
}
|
}
|
||||||
PdaRelabelTask task = tasks.lock(application.getCustomerId(), application.getId());
|
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");
|
if (!WAIT_REVIEW.equals(task.getStatus())) throw conflict("Relabel task is not awaiting review");
|
||||||
requireVersion(task, request.expectedVersion());
|
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 before = task.getStatus();
|
||||||
String after = "APPROVE".equals(decision) ? COMPLETED : PROCESSING;
|
String after = PROCESSING;
|
||||||
task.setStatus(after); task.setReviewerKey(operatorKey); task.setReviewerName(operatorName);
|
task.setStatus(after); task.setReviewerKey(operatorKey); task.setReviewerName(operatorName);
|
||||||
task.setReviewDecision(decision); task.setReviewRemark(trim(request.remark()));
|
task.setReviewDecision(decision); task.setReviewRemark(trim(request.remark()));
|
||||||
task.setReviewedAt(LocalDateTime.now());
|
task.setReviewedAt(LocalDateTime.now());
|
||||||
updateTask(task, request.expectedVersion());
|
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,
|
insertHistory(task, "REVIEW_" + decision, before, after, operatorKey, operatorName,
|
||||||
Map.of("decision", decision, "remark", Objects.toString(trim(request.remark()), "")));
|
Map.of("decision", decision, "remark", Objects.toString(trim(request.remark()), "")));
|
||||||
PdaRelabelResponses.Detail result = detail(application, task);
|
PdaRelabelResponses.Detail result = detail(application, task);
|
||||||
@@ -278,6 +396,23 @@ public class PdaRelabelService {
|
|||||||
requireVersion(task, expectedVersion);
|
requireVersion(task, expectedVersion);
|
||||||
RelabelItem item = requireItem(application, itemId);
|
RelabelItem item = requireItem(application, itemId);
|
||||||
validation.accept(item);
|
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);
|
List<PdaRelabelUnitFact> existingFacts = factList(task);
|
||||||
long itemFacts = existingFacts.stream().filter(fact -> itemId.equals(fact.getItemId())).count();
|
long itemFacts = existingFacts.stream().filter(fact -> itemId.equals(fact.getItemId())).count();
|
||||||
if (itemFacts >= value(item.getNotifiedPackageCount())) {
|
if (itemFacts >= value(item.getNotifiedPackageCount())) {
|
||||||
@@ -299,7 +434,7 @@ public class PdaRelabelService {
|
|||||||
fact.setOperatorKey(operatorKey); fact.setOperatorName(operatorName); fact.setOccurredAt(LocalDateTime.now());
|
fact.setOperatorKey(operatorKey); fact.setOperatorName(operatorName); fact.setOccurredAt(LocalDateTime.now());
|
||||||
try { unitFacts.insert(fact); }
|
try { unitFacts.insert(fact); }
|
||||||
catch (DuplicateKeyException duplicate) { throw conflict("Old label was processed concurrently"); }
|
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.setNewCartonMark(resolvedNewLabel);
|
||||||
item.setFbaCartonMark(resolvedNewLabel);
|
item.setFbaCartonMark(resolvedNewLabel);
|
||||||
items.updateById(item);
|
items.updateById(item);
|
||||||
@@ -395,16 +530,37 @@ public class PdaRelabelService {
|
|||||||
|
|
||||||
private List<PdaRelabelUnitFact> factList(PdaRelabelTask task) {
|
private List<PdaRelabelUnitFact> factList(PdaRelabelTask task) {
|
||||||
return unitFacts.selectList(new LambdaQueryWrapper<PdaRelabelUnitFact>()
|
return unitFacts.selectList(new LambdaQueryWrapper<PdaRelabelUnitFact>()
|
||||||
|
.ne(PdaRelabelUnitFact::getResult, "REWORK_ARCHIVED")
|
||||||
.eq(PdaRelabelUnitFact::getCustomerId, task.getCustomerId())
|
.eq(PdaRelabelUnitFact::getCustomerId, task.getCustomerId())
|
||||||
.eq(PdaRelabelUnitFact::getTaskId, task.getId())
|
.eq(PdaRelabelUnitFact::getTaskId, task.getId())
|
||||||
.orderByAsc(PdaRelabelUnitFact::getOccurredAt).orderByAsc(PdaRelabelUnitFact::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,
|
private PdaRelabelResponses.Candidate candidate(RelabelApplication application, PdaRelabelTask task,
|
||||||
String operatorKey) {
|
String operatorKey) {
|
||||||
return new PdaRelabelResponses.Candidate(application.getRelabelNo(), application.getCustomerOrderNo(),
|
return new PdaRelabelResponses.Candidate(application.getRelabelNo(), application.getCustomerOrderNo(),
|
||||||
application.getLabelDeadline(), application.getStatus(), task == null ? "AVAILABLE" : task.getStatus(),
|
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.getRelabelledPackages()), task == null ? 0 : value(task.getSkippedPackages()),
|
||||||
task == null ? 0 : value(task.getExceptionPackages()), task == null ? 0 : value(task.getVersion()),
|
task == null ? 0 : value(task.getExceptionPackages()), task == null ? 0 : value(task.getVersion()),
|
||||||
task != null && operatorKey.equals(task.getOperatorKey()));
|
task != null && operatorKey.equals(task.getOperatorKey()));
|
||||||
@@ -422,7 +578,9 @@ public class PdaRelabelService {
|
|||||||
item.getRelabelFileId(), item.getRelabelFileName(), item.getRelabelFileUrl(),
|
item.getRelabelFileId(), item.getRelabelFileName(), item.getRelabelFileUrl(),
|
||||||
item.getCartonMark(), item.getFbaCartonMark(), operationType(item), readSkuItems(item.getSkuRelabelItemsJson()),
|
item.getCartonMark(), item.getFbaCartonMark(), operationType(item), readSkuItems(item.getSkuRelabelItemsJson()),
|
||||||
value(item.getNotifiedPackageCount()), count(itemFacts, "RELABELLED"), count(itemFacts, "SKIPPED"),
|
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();
|
}).toList();
|
||||||
List<PdaRelabelResponses.UnitFact> factResponses = facts.stream().map(fact ->
|
List<PdaRelabelResponses.UnitFact> factResponses = facts.stream().map(fact ->
|
||||||
new PdaRelabelResponses.UnitFact(fact.getId(), fact.getItemId(), fact.getOldLabel(), fact.getNewLabel(),
|
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();
|
history.getToStatus(), history.getOperatorName(), history.getDetailJson(), history.getCreatedAt())).toList();
|
||||||
return new PdaRelabelResponses.Detail(application.getRelabelNo(), application.getCustomerOrderNo(),
|
return new PdaRelabelResponses.Detail(application.getRelabelNo(), application.getCustomerOrderNo(),
|
||||||
application.getLabelDeadline(), application.getContactName(), application.getContactPhone(),
|
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 ? value(application.getNotifiedPackageCount()) : value(task.getExpectedPackages()),
|
||||||
task == null ? 0 : value(task.getRelabelledPackages()), task == null ? 0 : value(task.getSkippedPackages()),
|
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 ? 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";
|
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) {
|
private static int count(List<PdaRelabelUnitFact> facts, String result) {
|
||||||
return (int) facts.stream().filter(fact -> result.equals(fact.getResult())).count();
|
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("换标资料未绑定当前明细");
|
if (!String.valueOf(fileId).equals(item.getRelabelFileId())) throw notFound("换标资料未绑定当前明细");
|
||||||
return documentFiles.download(application.getCustomerId(), "OVERSEAS_RELABEL_ITEM_DOCUMENT", String.valueOf(item.getId()), fileId);
|
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("换标资料未绑定当前申请单");
|
if (!String.valueOf(fileId).equals(application.getTotalFileId())) throw notFound("换标资料未绑定当前申请单");
|
||||||
return documentFiles.download(application.getCustomerId(), "OVERSEAS_RELABEL_TOTAL_DOCUMENT", String.valueOf(application.getId()), fileId);
|
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();
|
long applicationId = IdWorker.getId();
|
||||||
String operationWarehouseId = request.operationWarehouseId().trim();
|
String operationWarehouseId = request.operationWarehouseId().trim();
|
||||||
String relabelNo = orderNoGenerator.next("RELABEL", operationWarehouseId, applicationId);
|
String relabelNo = orderNoGenerator.next("RELABEL", operationWarehouseId, applicationId);
|
||||||
|
BigDecimal chargeAmount = Objects.requireNonNullElse(request.chargeAmount(), BigDecimal.ZERO);
|
||||||
|
boolean requiresWallet = chargeAmount.signum() > 0;
|
||||||
int holdIndex = 0;
|
int holdIndex = 0;
|
||||||
for (SourceInventory source : sources) {
|
for (SourceInventory source : sources) {
|
||||||
inventoryCommands.hold(customerId, operatorId, new InventoryCommandRequests.Move(
|
inventoryCommands.hold(customerId, operatorId, new InventoryCommandRequests.Move(
|
||||||
@@ -352,9 +362,9 @@ public class RelabelApplicationService implements WalletResultHandler {
|
|||||||
application.setItemCount(request.items().size());
|
application.setItemCount(request.items().size());
|
||||||
application.setTotalPackageCount(total);
|
application.setTotalPackageCount(total);
|
||||||
application.setNotifiedPackageCount(notified);
|
application.setNotifiedPackageCount(notified);
|
||||||
application.setChargeAmount(request.chargeAmount());
|
application.setChargeAmount(chargeAmount);
|
||||||
application.setChargeCurrency("CNY");
|
application.setChargeCurrency("CNY");
|
||||||
application.setWalletStatus("FREEZE_PENDING");
|
application.setWalletStatus(requiresWallet ? "FREEZE_PENDING" : "NOT_REQUIRED");
|
||||||
application.setIdempotencyKey(request.idempotencyKey().trim());
|
application.setIdempotencyKey(request.idempotencyKey().trim());
|
||||||
application.setRequestHash(requestHash);
|
application.setRequestHash(requestHash);
|
||||||
application.setCreatedBy(trim(operatorId));
|
application.setCreatedBy(trim(operatorId));
|
||||||
@@ -362,10 +372,12 @@ public class RelabelApplicationService implements WalletResultHandler {
|
|||||||
application.setUpdatedAt(now);
|
application.setUpdatedAt(now);
|
||||||
applications.insert(application);
|
applications.insert(application);
|
||||||
|
|
||||||
String walletEventId = walletCommands.publish("FREEZE", Long.parseLong(customerId), "RELABEL_ORDER",
|
if (requiresWallet) {
|
||||||
relabelNo, request.chargeAmount(), "换标订单费用冻结", 1L);
|
String walletEventId = walletCommands.publish("FREEZE", Long.parseLong(customerId), "RELABEL_ORDER",
|
||||||
application.setWalletCommandEventId(walletEventId);
|
relabelNo, chargeAmount, "换标订单费用冻结", 1L);
|
||||||
applications.updateById(application);
|
application.setWalletCommandEventId(walletEventId);
|
||||||
|
applications.updateById(application);
|
||||||
|
}
|
||||||
|
|
||||||
for (SourceInventory source : sources) {
|
for (SourceInventory source : sources) {
|
||||||
insertItem(customerId, applicationId, source, now);
|
insertItem(customerId, applicationId, source, now);
|
||||||
@@ -395,7 +407,7 @@ public class RelabelApplicationService implements WalletResultHandler {
|
|||||||
query.skuCode())
|
query.skuCode())
|
||||||
.orderByDesc(RelabelApplication::getCreatedAt);
|
.orderByDesc(RelabelApplication::getCreatedAt);
|
||||||
if (PENDING_PROCESSING.equals(requestedStatus)) {
|
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");
|
"REJECTED", "CANCELLED");
|
||||||
} else if ("COMPLETED".equals(requestedStatus)) {
|
} else if ("COMPLETED".equals(requestedStatus)) {
|
||||||
wrapper.in(RelabelApplication::getStatus, READY_FOR_OUTBOUND, "COMPLETED");
|
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 (!STATUSES.contains(next)) throw badRequest("Unsupported relabel status");
|
||||||
if (next.equals(application.getStatus())) return detail(customerId, relabelNo);
|
if (next.equals(application.getStatus())) return detail(customerId, relabelNo);
|
||||||
validateTransition(application.getStatus(), next);
|
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");
|
throw conflict("Relabel funds must be frozen before processing");
|
||||||
}
|
}
|
||||||
String before = application.getStatus();
|
String before = application.getStatus();
|
||||||
@@ -462,20 +475,22 @@ public class RelabelApplicationService implements WalletResultHandler {
|
|||||||
&& !preDispatched) {
|
&& !preDispatched) {
|
||||||
releaseInventory(customerId, operatorId, application);
|
releaseInventory(customerId, operatorId, application);
|
||||||
}
|
}
|
||||||
BigDecimal quantity = BigDecimal.valueOf(Objects.requireNonNullElse(application.getTotalPackageCount(), 0));
|
if (!"NOT_REQUIRED".equals(application.getWalletStatus())) {
|
||||||
feeSources.publishApproved(operatorId, "RELABEL:" + relabelNo,
|
BigDecimal quantity = BigDecimal.valueOf(Objects.requireNonNullElse(application.getTotalPackageCount(), 0));
|
||||||
new OverseasFeeSourceDtos.SaveRequest(customerId, linkedOrders.get(0).getCustomerName(),
|
feeSources.publishApproved(operatorId, "RELABEL:" + relabelNo,
|
||||||
"OVERSEAS_RELABEL_FEE", "RELABEL_ORDER", application.getId(), relabelNo,
|
new OverseasFeeSourceDtos.SaveRequest(customerId, linkedOrders.get(0).getCustomerName(),
|
||||||
application.getLastlegOrderId(), null, null, null, null, null,
|
"OVERSEAS_RELABEL_FEE", "RELABEL_ORDER", application.getId(), relabelNo,
|
||||||
parseLong(application.getOverseasWarehouseId()), "RELABEL_SERVICE", "换标费", "RECEIVABLE",
|
application.getLastlegOrderId(), null, null, null, null, null,
|
||||||
null, null, null, null, null, null, null, List.of(), LocalDate.now(),
|
parseLong(application.getOverseasWarehouseId()), "RELABEL_SERVICE", "换标费", "RECEIVABLE",
|
||||||
application.getChargeCurrency(), application.getChargeAmount(),
|
null, null, null, null, null, null, null, List.of(), LocalDate.now(),
|
||||||
quantity.signum() > 0 ? quantity : null, quantity.signum() > 0 ? "BOX" : null,
|
application.getChargeCurrency(), application.getChargeAmount(),
|
||||||
quantity.signum() > 0 ? application.getChargeAmount().divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null,
|
quantity.signum() > 0 ? quantity : null, quantity.signum() > 0 ? "BOX" : null,
|
||||||
null, null));
|
quantity.signum() > 0 ? application.getChargeAmount().divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null,
|
||||||
walletCommands.publish("SETTLE", Long.parseLong(customerId), "RELABEL_ORDER", relabelNo, null,
|
null, null));
|
||||||
"换标完成,结算费用", 2L);
|
walletCommands.publish("SETTLE", Long.parseLong(customerId), "RELABEL_ORDER", relabelNo, null,
|
||||||
application.setWalletStatus("SETTLE_PENDING"); applications.updateById(application);
|
"换标完成,结算费用", 2L);
|
||||||
|
application.setWalletStatus("SETTLE_PENDING"); applications.updateById(application);
|
||||||
|
}
|
||||||
} else if (Set.of("REJECTED", "CANCELLED").contains(next)) {
|
} else if (Set.of("REJECTED", "CANCELLED").contains(next)) {
|
||||||
releaseInventory(customerId, operatorId, application);
|
releaseInventory(customerId, operatorId, application);
|
||||||
if ("FROZEN".equals(application.getWalletStatus())) {
|
if ("FROZEN".equals(application.getWalletStatus())) {
|
||||||
@@ -563,6 +578,21 @@ public class RelabelApplicationService implements WalletResultHandler {
|
|||||||
return detail(customerId, relabelNo);
|
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
|
@Transactional
|
||||||
public RelabelResponses.Detail confirmCompletion(String customerId, String relabelNo, String operatorId,
|
public RelabelResponses.Detail confirmCompletion(String customerId, String relabelNo, String operatorId,
|
||||||
RelabelRequests.CustomerConfirm request) {
|
RelabelRequests.CustomerConfirm request) {
|
||||||
@@ -573,6 +603,14 @@ public class RelabelApplicationService implements WalletResultHandler {
|
|||||||
if (!CUSTOMER_CONFIRM_PENDING.equals(application.getStatus())) {
|
if (!CUSTOMER_CONFIRM_PENDING.equals(application.getStatus())) {
|
||||||
throw conflict("Relabel order is not awaiting customer confirmation");
|
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())
|
if (Boolean.TRUE.equals(application.getPrintShippingLabel())
|
||||||
&& !StringUtils.hasText(application.getShippingLabelFileId())) {
|
&& !StringUtils.hasText(application.getShippingLabelFileId())) {
|
||||||
throw conflict("Upload the shipping label before confirming relabel completion");
|
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) {
|
private static String businessRelabelStatus(String workflowStatus) {
|
||||||
|
if ("PROCESSING".equals(workflowStatus)) return "PROCESSING";
|
||||||
return Set.of(READY_FOR_OUTBOUND, "COMPLETED").contains(workflowStatus)
|
return Set.of(READY_FOR_OUTBOUND, "COMPLETED").contains(workflowStatus)
|
||||||
? "COMPLETED" : PENDING_PROCESSING;
|
? "COMPLETED" : PENDING_PROCESSING;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -641,6 +641,7 @@ public class LastlegOrderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static String relabelBusinessStatus(String workflowStatus) {
|
private static String relabelBusinessStatus(String workflowStatus) {
|
||||||
|
if ("PROCESSING".equals(workflowStatus)) return "PROCESSING";
|
||||||
if (!StringUtils.hasText(workflowStatus) || "NOT_REQUIRED".equals(workflowStatus)) return workflowStatus;
|
if (!StringUtils.hasText(workflowStatus) || "NOT_REQUIRED".equals(workflowStatus)) return workflowStatus;
|
||||||
return Set.of("READY_FOR_OUTBOUND", "COMPLETED").contains(workflowStatus)
|
return Set.of("READY_FOR_OUTBOUND", "COMPLETED").contains(workflowStatus)
|
||||||
? "COMPLETED" : "PENDING_PROCESSING";
|
? "COMPLETED" : "PENDING_PROCESSING";
|
||||||
|
|||||||
+1
@@ -34,6 +34,7 @@ public final class WarehouseTransferResponses {
|
|||||||
int packageCount, String deliveryMethod, LocalDateTime estimatedArrivalAt,
|
int packageCount, String deliveryMethod, LocalDateTime estimatedArrivalAt,
|
||||||
LocalDateTime estimatedShipAt,
|
LocalDateTime estimatedShipAt,
|
||||||
String fbaShipmentNo, String trackingNo, String fbaBoxNos, String remark,
|
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<SourceOrder> sourceOrders, List<Item> items, List<Carton> cartons,
|
||||||
List<Pallet> pallets, List<RelabelBinding> relabelBindings,
|
List<Pallet> pallets, List<RelabelBinding> relabelBindings,
|
||||||
List<Allocation> allocations, LocalDateTime customerConfirmedAt,
|
List<Allocation> allocations, LocalDateTime customerConfirmedAt,
|
||||||
|
|||||||
+4
@@ -37,6 +37,10 @@ public class WarehouseTransferOrder {
|
|||||||
private String relabelNo;
|
private String relabelNo;
|
||||||
private LocalDateTime relabelCompletedAt;
|
private LocalDateTime relabelCompletedAt;
|
||||||
private String status;
|
private String status;
|
||||||
|
private String reviewDecision;
|
||||||
|
private String reviewRemark;
|
||||||
|
private String reviewedBy;
|
||||||
|
private LocalDateTime reviewedAt;
|
||||||
private String allocationIdempotencyKey;
|
private String allocationIdempotencyKey;
|
||||||
private String allocationRequestHash;
|
private String allocationRequestHash;
|
||||||
private String outboundIdempotencyKey;
|
private String outboundIdempotencyKey;
|
||||||
|
|||||||
+9
-2
@@ -323,7 +323,8 @@ public class WarehouseTransferService {
|
|||||||
transfer.getOperatingWarehouseId(), transfer.getFbaWarehouseCode(), transfer.getDeliveryAddress(),
|
transfer.getOperatingWarehouseId(), transfer.getFbaWarehouseCode(), transfer.getDeliveryAddress(),
|
||||||
transfer.getPackageCount(), transfer.getDeliveryMethod(), transfer.getEstimatedArrivalAt(),
|
transfer.getPackageCount(), transfer.getDeliveryMethod(), transfer.getEstimatedArrivalAt(),
|
||||||
transfer.getEstimatedShipAt(), transfer.getFbaShipmentNo(), transfer.getTrackingNo(), transfer.getFbaBoxNos(),
|
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());
|
transfer.getCustomerConfirmedAt(), transfer.getOutboundAt(), transfer.getCreatedAt());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,7 +376,13 @@ public class WarehouseTransferService {
|
|||||||
String before = order.getCurrentStatus();
|
String before = order.getCurrentStatus();
|
||||||
boolean incomingTransit = transfer.getTransferNo().equals(transfer.getInboundCode());
|
boolean incomingTransit = transfer.getTransferNo().equals(transfer.getInboundCode());
|
||||||
String transferStatus = APPROVED.equals(next) && incomingTransit ? WAIT_PDA_RECEIVING : next;
|
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)
|
String orderStatus = APPROVED.equals(next)
|
||||||
? (incomingTransit
|
? (incomingTransit
|
||||||
? ("WAREHOUSE_PICKUP".equals(transfer.getDeliveryMethod()) ? "WAIT_PICKUP" : "WAIT_DELIVERY")
|
? ("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货件编号',
|
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 '备注',
|
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 '换标申请单号',
|
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_idempotency_key VARCHAR(255) NULL COMMENT '库存分配幂等键',
|
||||||
allocation_request_hash CHAR(64) NULL COMMENT '库存分配请求哈希',
|
allocation_request_hash CHAR(64) NULL COMMENT '库存分配请求哈希',
|
||||||
outbound_idempotency_key VARCHAR(255) 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;
|
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 ''客户确认测量时间'''));
|
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;
|
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 (
|
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',
|
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 }
|
remark: { type: string, nullable: true, maxLength: 1000 }
|
||||||
RelabelCreate:
|
RelabelCreate:
|
||||||
type: object
|
type: object
|
||||||
required: [customerOrderNo, labelDeadline, contactName, contactPhone, chargeAmount, idempotencyKey, items]
|
required: [customerOrderNo, labelDeadline, contactName, contactPhone, idempotencyKey, items]
|
||||||
properties:
|
properties:
|
||||||
customerOrderNo: { type: string, maxLength: 128 }
|
customerOrderNo: { type: string, maxLength: 128 }
|
||||||
overseasWarehouseId: { type: string, nullable: true, maxLength: 128, description: 统一目的海外仓;明细未填写目的仓时使用 }
|
overseasWarehouseId: { type: string, nullable: true, maxLength: 128, description: 统一目的海外仓;明细未填写目的仓时使用 }
|
||||||
labelDeadline: { type: string, format: date-time }
|
labelDeadline: { type: string, format: date-time }
|
||||||
contactName: { type: string, maxLength: 128 }
|
contactName: { type: string, maxLength: 128 }
|
||||||
contactPhone: { type: string, maxLength: 64 }
|
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 }
|
remark: { type: string, nullable: true, maxLength: 1000 }
|
||||||
idempotencyKey: { type: string, maxLength: 255 }
|
idempotencyKey: { type: string, maxLength: 255 }
|
||||||
deliveryMethod: { type: string, nullable: true, enum: [TRUCK, EXPRESS, TRUCK_PICKUP, EXPRESS_PICKUP, PICKUP], description: 只换箱唛或SKU与箱唛同时换时必填;只换SKU时不传 }
|
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 } }]
|
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 } } } } } }
|
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' } } } } }
|
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:
|
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/complete:
|
||||||
post:
|
post:
|
||||||
summary: 提交现场任务等待复核,不提前完成申请
|
summary: 提交现场任务到复核池并直接等待客户确认
|
||||||
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
|
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
|
||||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelComplete' } } } }
|
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: 箱数未核平、照片或版本冲突 } }
|
responses: { '200': { description: WAIT_REVIEW任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } }, '409': { description: 箱数未核平、照片或版本冲突 } }
|
||||||
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/review:
|
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/review:
|
||||||
post:
|
post:
|
||||||
summary: 现场复核;APPROVE才完成既有换标申请,REWORK退回处理
|
summary: 已停用;客户确认完成复核,退回重贴由平台执行
|
||||||
|
deprecated: true
|
||||||
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
|
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
|
||||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelReview' } } } }
|
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: 状态或版本冲突 } }
|
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 }
|
occurredAt: { type: string, format: date-time }
|
||||||
RelabelCandidate:
|
RelabelCandidate:
|
||||||
type: object
|
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:
|
properties:
|
||||||
relabelNo: { type: string }
|
relabelNo: { type: string }
|
||||||
customerOrderNo: { type: string, nullable: true }
|
customerOrderNo: { type: string, nullable: true }
|
||||||
labelDeadline: { type: string, format: date-time, nullable: true }
|
labelDeadline: { type: string, format: date-time, nullable: true }
|
||||||
applicationStatus: { type: string }
|
applicationStatus: { type: string }
|
||||||
taskStatus: { type: string }
|
taskStatus: { type: string }
|
||||||
|
operationType: { type: string, enum: [SKU_LABEL, CARTON_LABEL, CARTON_AND_SKU] }
|
||||||
itemCount: { type: integer }
|
itemCount: { type: integer }
|
||||||
expectedPackages: { type: integer }
|
expectedPackages: { type: integer }
|
||||||
relabelledPackages: { type: integer }
|
relabelledPackages: { type: integer }
|
||||||
@@ -466,7 +479,7 @@ components:
|
|||||||
claimedByCurrentOperator: { type: boolean }
|
claimedByCurrentOperator: { type: boolean }
|
||||||
RelabelDetail:
|
RelabelDetail:
|
||||||
type: object
|
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:
|
properties:
|
||||||
relabelNo: { type: string }
|
relabelNo: { type: string }
|
||||||
customerOrderNo: { type: string, nullable: true }
|
customerOrderNo: { type: string, nullable: true }
|
||||||
@@ -474,6 +487,7 @@ components:
|
|||||||
contactName: { type: string, nullable: true }
|
contactName: { type: string, nullable: true }
|
||||||
contactPhone: { type: string, nullable: true }
|
contactPhone: { type: string, nullable: true }
|
||||||
applicationStatus: { type: string }
|
applicationStatus: { type: string }
|
||||||
|
operationType: { type: string, enum: [SKU_LABEL, CARTON_LABEL, CARTON_AND_SKU] }
|
||||||
taskStatus: { type: string }
|
taskStatus: { type: string }
|
||||||
expectedPackages: { type: integer }
|
expectedPackages: { type: integer }
|
||||||
relabelledPackages: { type: integer }
|
relabelledPackages: { type: integer }
|
||||||
@@ -513,6 +527,8 @@ components:
|
|||||||
relabelledPackages: { type: integer }
|
relabelledPackages: { type: integer }
|
||||||
skippedPackages: { type: integer }
|
skippedPackages: { type: integer }
|
||||||
exceptionPackages: { type: integer }
|
exceptionPackages: { type: integer }
|
||||||
|
cartonProcessedPackages: { type: integer, description: 本轮箱唛作业已完成箱数,混合单独立计数 }
|
||||||
|
skuProcessedPackages: { type: integer, description: 本轮SKU作业已完成箱数,两项均完成才增加整箱已换标数 }
|
||||||
RelabelSkuItem:
|
RelabelSkuItem:
|
||||||
type: object
|
type: object
|
||||||
required: [skuCode, newSkuCode, quantity]
|
required: [skuCode, newSkuCode, quantity]
|
||||||
@@ -723,8 +739,18 @@ components:
|
|||||||
required: [itemId, oldLabel, newLabel, expectedVersion, idempotencyKey]
|
required: [itemId, oldLabel, newLabel, expectedVersion, idempotencyKey]
|
||||||
properties:
|
properties:
|
||||||
itemId: { type: string, pattern: '^[0-9]+$' }
|
itemId: { type: string, pattern: '^[0-9]+$' }
|
||||||
oldLabel: { type: string, maxLength: 255 }
|
oldLabel: { type: string, maxLength: 255, description: 当前箱的仓库唛头,用于逐箱防重复 }
|
||||||
newLabel: { type: string, maxLength: 255 }
|
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 }
|
expectedVersion: { type: integer, minimum: 0 }
|
||||||
idempotencyKey: { type: string, maxLength: 255 }
|
idempotencyKey: { type: string, maxLength: 255 }
|
||||||
RelabelSkip:
|
RelabelSkip:
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import java.nio.file.Files;
|
|||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
import jakarta.validation.Validation;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
@@ -24,6 +26,18 @@ class RelabelContractTest {
|
|||||||
"newSkuCode", "labelRequirement", "relabelFileId")));
|
"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
|
@Test
|
||||||
void schemasDefineRelabelApplicationItemsAndHistoryContracts() throws Exception {
|
void schemasDefineRelabelApplicationItemsAndHistoryContracts() throws Exception {
|
||||||
String baseline = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8).toLowerCase();
|
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:"),
|
String relabelCreate = openApi.substring(openApi.indexOf(" RelabelCreate:"),
|
||||||
openApi.indexOf(" RelabelItem:"));
|
openApi.indexOf(" RelabelItem:"));
|
||||||
assertFalse(relabelCreate.contains("customerId"));
|
assertFalse(relabelCreate.contains("customerId"));
|
||||||
|
assertFalse(relabelCreate.substring(relabelCreate.indexOf("required:"), relabelCreate.indexOf("properties:"))
|
||||||
|
.contains("chargeAmount"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+106
-15
@@ -96,6 +96,35 @@ class PdaRelabelServiceTest {
|
|||||||
verify(applicationService).changeStatus(eq("100"), eq("FBR-1"), eq("7"), any(RelabelRequests.ChangeStatus.class));
|
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
|
@Test
|
||||||
void crossWarehouseClaimIsHiddenAndCannotCreateTask() {
|
void crossWarehouseClaimIsHiddenAndCannotCreateTask() {
|
||||||
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
|
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
|
||||||
@@ -105,6 +134,57 @@ class PdaRelabelServiceTest {
|
|||||||
verify(tasks, never()).insert(any(PdaRelabelTask.class));
|
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
|
@Test
|
||||||
void unitScanLocksItemToApplicationValidatesNewSkuAndUpdatesVersion() {
|
void unitScanLocksItemToApplicationValidatesNewSkuAndUpdatesVersion() {
|
||||||
application.setStatus("PROCESSING");
|
application.setStatus("PROCESSING");
|
||||||
@@ -167,40 +247,51 @@ class PdaRelabelServiceTest {
|
|||||||
|
|
||||||
verify(photoFiles).validate("100", 20L, 501L);
|
verify(photoFiles).validate("100", 20L, 501L);
|
||||||
assertEquals(PdaRelabelService.WAIT_REVIEW, result.taskStatus());
|
assertEquals(PdaRelabelService.WAIT_REVIEW, result.taskStatus());
|
||||||
assertEquals("PROCESSING", result.applicationStatus());
|
assertEquals("CUSTOMER_CONFIRM_PENDING", result.applicationStatus());
|
||||||
verify(applicationService, never()).changeStatus(anyString(), anyString(), anyString(), any());
|
verify(applicationService).changeStatus(eq("100"), eq("FBR-1"), eq("7"), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void approvalUsesExistingApplicationCompletionAndReworkDoesNot() {
|
void approvalUsesExistingApplicationCompletionAndReworkDoesNot() {
|
||||||
application.setStatus("PROCESSING");
|
assertThrows(ResponseStatusException.class, () -> service.review("8", "reviewer", 900L, "FBR-1",
|
||||||
PdaRelabelTask approved = task("WAIT_REVIEW", 2);
|
new PdaRelabelRequests.Review("APPROVE", 2, "review-1", "ok")));
|
||||||
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
|
verify(applicationService, never()).changeStatus(anyString(), anyString(), anyString(), any());
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void reviewCanReturnTaskForReworkWithoutCompletingApplication() {
|
void reviewCanReturnTaskForReworkWithoutCompletingApplication() {
|
||||||
application.setStatus("PROCESSING");
|
application.setStatus("CUSTOMER_CONFIRM_PENDING");
|
||||||
PdaRelabelTask task = task("WAIT_REVIEW", 2);
|
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(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
|
||||||
when(tasks.lock("100", 1L)).thenReturn(task);
|
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"));
|
new PdaRelabelRequests.Review("REWORK", 2, "review-rework", "wrong label"));
|
||||||
|
|
||||||
assertEquals("PROCESSING", result.taskStatus());
|
assertEquals("PROCESSING", result.taskStatus());
|
||||||
assertEquals("PROCESSING", result.applicationStatus());
|
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());
|
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
|
@Test
|
||||||
void completionRejectsMissingPackageFactsBeforePhotoValidation() {
|
void completionRejectsMissingPackageFactsBeforePhotoValidation() {
|
||||||
application.setStatus("PROCESSING");
|
application.setStatus("PROCESSING");
|
||||||
|
|||||||
+53
@@ -75,6 +75,7 @@ class RelabelApplicationServiceTest {
|
|||||||
@Mock OutboundPdaTaskMapper outboundTasks;
|
@Mock OutboundPdaTaskMapper outboundTasks;
|
||||||
@Mock WarehouseTransferOrderMapper warehouseTransfers;
|
@Mock WarehouseTransferOrderMapper warehouseTransfers;
|
||||||
@Mock OverseasFeeSourceService feeSources;
|
@Mock OverseasFeeSourceService feeSources;
|
||||||
|
@Mock com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper pdaTasks;
|
||||||
@Spy LastlegOrderNoGenerator orderNoGenerator = new LastlegOrderNoGenerator();
|
@Spy LastlegOrderNoGenerator orderNoGenerator = new LastlegOrderNoGenerator();
|
||||||
|
|
||||||
private RelabelApplicationService service;
|
private RelabelApplicationService service;
|
||||||
@@ -82,6 +83,12 @@ class RelabelApplicationServiceTest {
|
|||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
service = new RelabelApplicationService();
|
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, "applications", applications);
|
||||||
ReflectionTestUtils.setField(service, "items", items);
|
ReflectionTestUtils.setField(service, "items", items);
|
||||||
ReflectionTestUtils.setField(service, "histories", histories);
|
ReflectionTestUtils.setField(service, "histories", histories);
|
||||||
@@ -152,6 +159,31 @@ class RelabelApplicationServiceTest {
|
|||||||
assertEquals("FBR-1", result.application().relabelNo());
|
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
|
@Test
|
||||||
void partialRelabelFreezesOnlyNotifiedPackages() {
|
void partialRelabelFreezesOnlyNotifiedPackages() {
|
||||||
RelabelRequests.Create request = request("key-partial", 10, 2);
|
RelabelRequests.Create request = request("key-partial", 10, 2);
|
||||||
@@ -363,6 +395,23 @@ class RelabelApplicationServiceTest {
|
|||||||
verify(applications).findNo("10002", "FBR-1");
|
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
|
@Test
|
||||||
void completedReturnRelabelOrderEntersPendingAssignment() {
|
void completedReturnRelabelOrderEntersPendingAssignment() {
|
||||||
RelabelApplication application = application(11L, "FBR-1", "10001", "key-1", "hash", RelabelApplicationService.READY_FOR_OUTBOUND);
|
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(RelabelApplicationService.READY_FOR_OUTBOUND, application.getStatus());
|
||||||
assertEquals("WAIT_PRE_DISPATCH", application.getFulfillmentStatus());
|
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());
|
assertNull(application.getOutboundOrderNo());
|
||||||
verify(outboundOrders, never()).insert(any(OutboundOrder.class));
|
verify(outboundOrders, never()).insert(any(OutboundOrder.class));
|
||||||
|
|
||||||
|
|||||||
+6
@@ -262,6 +262,12 @@ class WarehouseTransferServiceTest {
|
|||||||
new WarehouseTransferRequests.Review("REJECT", " 地址不完整 "));
|
new WarehouseTransferRequests.Review("REJECT", " 地址不完整 "));
|
||||||
assertEquals("REJECTED", result.status());
|
assertEquals("REJECTED", result.status());
|
||||||
assertEquals("CANCELLED", tailOrder.getCurrentStatus());
|
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);
|
var history = ArgumentCaptor.forClass(com.baiyue.lastleg.entity.LastlegOrderStatusHistory.class);
|
||||||
verify(orderHistories).insert(history.capture());
|
verify(orderHistories).insert(history.capture());
|
||||||
assertTrue(history.getValue().getRemark().endsWith("; 地址不完整"));
|
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-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 全量入口,本次合并仅做离线合同。
|
- `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)
|
{ ...mergeBody, weight: 3 }, {}, picker.token)
|
||||||
add(context, '合箱同键异载荷返回冲突', mergeConflict, conflict(mergeConflict))
|
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 {
|
} finally {
|
||||||
await disablePicker(context, picker, 91)
|
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
|
ok(result) && ok(detail) && detail.body.data?.status === expected
|
||||||
&& detail.body.data?.orderType === 'WAREHOUSE_TRANSFER'
|
&& detail.body.data?.orderType === 'WAREHOUSE_TRANSFER'
|
||||||
&& detail.body.data?.orderStatus === expectedOrder
|
&& 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)
|
&& (detail.body.data?.pallets || []).length === 0, evidence(detail), OPTIONS)
|
||||||
context.createdResources.phase4OverseasClientServices[`transit${decision}`] = { transferNo: no, status: detail.body?.data?.status }
|
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 directRelabelNo = process.env.OVERSEAS_RELABEL_NO
|
||||||
const output = process.env.OVERSEAS_FULFILLMENT_OUTPUT || 'reports/overseas-relabel-fulfillment-acceptance.json'
|
const output = process.env.OVERSEAS_FULFILLMENT_OUTPUT || 'reports/overseas-relabel-fulfillment-acceptance.json'
|
||||||
const customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID
|
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 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']
|
if (!directPlatformToken && (!process.env.ADMIN_TEST_USERNAME || !process.env.ADMIN_TEST_PASSWORD)) throw new Error('Missing platform credentials or token')
|
||||||
for (const name of required) if (!process.env[name]) throw new Error(`Missing ${name}`)
|
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')
|
if ((!sourceReport && !directDispatchNo) || !customerId) throw new Error('Missing acceptance source/dispatch or customer id')
|
||||||
|
|
||||||
function id(value) { return value == null ? '' : String(value) }
|
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 || '' }
|
|| { dispatchNo: directDispatchNo, relabelNo: directRelabelNo || '' }
|
||||||
assert(scenario?.dispatchNo, 'Dispatch not found in source report')
|
assert(scenario?.dispatchNo, 'Dispatch not found in source report')
|
||||||
const dispatchNo = scenario.dispatchNo
|
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 })
|
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 })
|
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} 海外尾程履约验收` })
|
`/api/platform/v1/iam/tenants/${customerId}/impersonation`, { reason: `${marker} 海外尾程履约验收` })
|
||||||
const merchantLogin = await api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code })
|
const merchantLogin = directMerchantToken ? null : await api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code })
|
||||||
const platformToken = platformLogin.overseasAccessToken
|
const platformToken = directPlatformToken || platformLogin.overseasAccessToken
|
||||||
const merchantToken = merchantLogin.overseasAccessToken
|
const merchantToken = directMerchantToken || merchantLogin.overseasAccessToken
|
||||||
const pdaToken = pdaLogin.overseasAccessToken
|
const pdaToken = directPdaToken || pdaLogin.overseasAccessToken
|
||||||
|
|
||||||
await api(overseas, platformToken, 'PUT', `/api/platform/v1/last-leg/dispatches/${dispatchNo}`, {
|
await api(overseas, platformToken, 'PUT', `/api/platform/v1/last-leg/dispatches/${dispatchNo}`, {
|
||||||
vehiclePlate: `UAT-${marker.slice(-8)}`, driverName: '尾程验收司机', driverPhone: '19900000000',
|
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 pdaPassword = process.env.PDA_TEST_PASSWORD
|
||||||
const customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID
|
const customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID
|
||||||
const warehouseId = process.env.OVERSEAS_TEST_WAREHOUSE_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 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}`
|
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 (!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 }
|
const report = { marker, startedAt: new Date().toISOString(), scope: 'OVERSEAS_LASTLEG_ONLY', scenarios: [], passed: false }
|
||||||
function key(value) { return `${marker}-${value}`.slice(0, 200) }
|
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) }
|
function assert(condition, message) { if (!condition) throw new Error(message) }
|
||||||
|
|
||||||
const platformLogin = await api(domestic, '', 'POST', '/api/auth/login', { loginId: platformUser, password: platformPassword })
|
const platformLogin = directPlatformToken ? null : 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 pdaLogin = directPdaToken ? null : await api(domestic, '', 'POST', '/api/auth/login', { loginId: pdaUser, password: pdaPassword })
|
||||||
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} 海外尾程真实验收` })
|
`/api/platform/v1/iam/tenants/${customerId}/impersonation`, { reason: `${marker} 海外尾程真实验收` })
|
||||||
const merchantLogin = await api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code })
|
const merchantLogin = directMerchantToken ? null : await api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code })
|
||||||
const platformToken = platformLogin.overseasAccessToken
|
const platformToken = directPlatformToken || platformLogin.overseasAccessToken
|
||||||
const merchantToken = merchantLogin.overseasAccessToken
|
const merchantToken = directMerchantToken || merchantLogin.overseasAccessToken
|
||||||
const pdaToken = pdaLogin.overseasAccessToken
|
const pdaToken = directPdaToken || pdaLogin.overseasAccessToken
|
||||||
assert(platformToken && merchantToken && pdaToken, 'Missing overseas token')
|
assert(platformToken && merchantToken && pdaToken, 'Missing overseas token')
|
||||||
|
|
||||||
const candidates = await api(overseas, merchantToken, 'GET',
|
const candidates = await api(overseas, merchantToken, 'GET',
|
||||||
|
|||||||
Reference in New Issue
Block a user