feat: complete overseas pda warehouse operations
This commit is contained in:
+9
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.lastleg.relabel.pda;
|
||||
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
@MapperScan("com.baiyue.lastleg.relabel.pda.mapper")
|
||||
public class PdaRelabelMapperConfiguration {
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package com.baiyue.lastleg.relabel.pda.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelRequests;
|
||||
import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelResponses;
|
||||
import com.baiyue.lastleg.relabel.pda.service.PdaRelabelService;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/pda/v1/last-leg/relabel-tasks")
|
||||
@PreAuthorize("hasAuthority('lastleg:pda:operate')")
|
||||
public class PdaRelabelController {
|
||||
public static final String OPERATE_PERMISSION = "lastleg:pda:operate";
|
||||
private static final String PICKER_ROLE = "ROLE_WAREHOUSE_PICKER";
|
||||
|
||||
@Resource private PdaRelabelService service;
|
||||
|
||||
@GetMapping("/candidates")
|
||||
public ApiResponse<List<PdaRelabelResponses.Candidate>> candidates(
|
||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
Identity identity = identity(user);
|
||||
return ApiResponse.success(service.candidates(identity.operatorKey(), identity.overseasWarehouseId()));
|
||||
}
|
||||
|
||||
@GetMapping("/{relabelNo}")
|
||||
public ApiResponse<PdaRelabelResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String relabelNo) {
|
||||
Identity identity = identity(user);
|
||||
return ApiResponse.success(service.detail(identity.operatorKey(), identity.overseasWarehouseId(), relabelNo));
|
||||
}
|
||||
|
||||
@PostMapping("/{relabelNo}/claim")
|
||||
public ApiResponse<PdaRelabelResponses.Detail> claim(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Claim request) {
|
||||
Identity identity = identity(user);
|
||||
return ApiResponse.success(service.claim(identity.operatorKey(), identity.operatorName(),
|
||||
identity.overseasWarehouseId(), relabelNo, request));
|
||||
}
|
||||
|
||||
@PostMapping("/{relabelNo}/units")
|
||||
public ApiResponse<PdaRelabelResponses.Detail> scan(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Scan request) {
|
||||
Identity identity = identity(user);
|
||||
return ApiResponse.success(service.scan(identity.operatorKey(), identity.operatorName(),
|
||||
identity.overseasWarehouseId(), relabelNo, request));
|
||||
}
|
||||
|
||||
@PostMapping("/{relabelNo}/units/skip")
|
||||
public ApiResponse<PdaRelabelResponses.Detail> skip(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Skip request) {
|
||||
Identity identity = identity(user);
|
||||
return ApiResponse.success(service.skip(identity.operatorKey(), identity.operatorName(),
|
||||
identity.overseasWarehouseId(), relabelNo, request));
|
||||
}
|
||||
|
||||
@PostMapping("/{relabelNo}/exceptions")
|
||||
public ApiResponse<PdaRelabelResponses.Detail> exception(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.ExceptionFact request) {
|
||||
Identity identity = identity(user);
|
||||
return ApiResponse.success(service.exception(identity.operatorKey(), identity.operatorName(),
|
||||
identity.overseasWarehouseId(), relabelNo, request));
|
||||
}
|
||||
|
||||
@PostMapping(value = "/{relabelNo}/photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public ApiResponse<PdaRelabelResponses.Photo> uploadPhoto(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String relabelNo, @RequestPart("file") MultipartFile file) {
|
||||
Identity identity = identity(user);
|
||||
return ApiResponse.success(service.uploadPhoto(identity.operatorKey(), identity.overseasWarehouseId(),
|
||||
identity.userId(), relabelNo, file));
|
||||
}
|
||||
|
||||
@PostMapping("/{relabelNo}/complete")
|
||||
public ApiResponse<PdaRelabelResponses.Detail> complete(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Complete request) {
|
||||
Identity identity = identity(user);
|
||||
return ApiResponse.success(service.complete(identity.operatorKey(), identity.operatorName(),
|
||||
identity.overseasWarehouseId(), relabelNo, request));
|
||||
}
|
||||
|
||||
@PostMapping("/{relabelNo}/review")
|
||||
public ApiResponse<PdaRelabelResponses.Detail> review(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Review request) {
|
||||
Identity identity = identity(user);
|
||||
return ApiResponse.success(service.review(identity.operatorKey(), identity.operatorName(),
|
||||
identity.overseasWarehouseId(), relabelNo, request));
|
||||
}
|
||||
|
||||
static Identity identity(AuthenticatedUser user) {
|
||||
if (user == null || user.userId() == null || user.tenantId() == null || user.tenantId() != 0L
|
||||
|| !"PLATFORM".equals(user.appCode())) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,
|
||||
"Authenticated platform warehouse PDA identity is required");
|
||||
}
|
||||
if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION)
|
||||
|| !user.permissions().contains(PICKER_ROLE)) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
|
||||
"WAREHOUSE_PICKER and " + OPERATE_PERMISSION + " are required");
|
||||
}
|
||||
if (user.overseasWarehouseId() == null || user.overseasWarehouseId() <= 0L) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
|
||||
"PDA operator must be bound to one overseas warehouse");
|
||||
}
|
||||
String operator = String.valueOf(user.userId());
|
||||
return new Identity(user.userId(), operator,
|
||||
user.username() == null || user.username().isBlank() ? operator : user.username(),
|
||||
user.overseasWarehouseId());
|
||||
}
|
||||
|
||||
record Identity(long userId, String operatorKey, String operatorName, long overseasWarehouseId) { }
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package com.baiyue.lastleg.relabel.pda.dto;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public final class PdaRelabelRequests {
|
||||
private PdaRelabelRequests() { }
|
||||
|
||||
public record Claim(@NotBlank @Size(max = 255) String idempotencyKey) { }
|
||||
|
||||
public record Scan(@NotNull @Positive Long itemId,
|
||||
@NotBlank @Size(max = 255) String oldLabel,
|
||||
@NotBlank @Size(max = 255) String newLabel,
|
||||
@NotNull @Min(0) Integer expectedVersion,
|
||||
@NotBlank @Size(max = 255) String idempotencyKey) { }
|
||||
|
||||
public record Skip(@NotNull @Positive Long itemId,
|
||||
@NotBlank @Size(max = 255) String oldLabel,
|
||||
@NotBlank @Size(max = 1000) String reason,
|
||||
@NotNull @Min(0) Integer expectedVersion,
|
||||
@NotBlank @Size(max = 255) String idempotencyKey) { }
|
||||
|
||||
public record ExceptionFact(@NotNull @Positive Long itemId,
|
||||
@Size(max = 255) String oldLabel,
|
||||
@NotBlank @Size(max = 32) String exceptionCode,
|
||||
@NotBlank @Size(max = 1000) String reason,
|
||||
@NotNull @Min(0) Integer expectedVersion,
|
||||
@NotBlank @Size(max = 255) String idempotencyKey) { }
|
||||
|
||||
public record Complete(@NotEmpty @Size(max = 20) List<@Positive Long> photoFileIds,
|
||||
@NotNull @Min(0) Integer expectedVersion,
|
||||
@NotBlank @Size(max = 255) String idempotencyKey,
|
||||
@Size(max = 1000) String remark) { }
|
||||
|
||||
public record Review(@NotBlank @Size(max = 16) String decision,
|
||||
@NotNull @Min(0) Integer expectedVersion,
|
||||
@NotBlank @Size(max = 255) String idempotencyKey,
|
||||
@Size(max = 1000) String remark) { }
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.baiyue.lastleg.relabel.pda.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public final class PdaRelabelResponses {
|
||||
private PdaRelabelResponses() { }
|
||||
|
||||
public record Candidate(String relabelNo, String customerOrderNo, LocalDateTime labelDeadline,
|
||||
String applicationStatus, String taskStatus, int itemCount,
|
||||
int expectedPackages, int relabelledPackages, int skippedPackages,
|
||||
int exceptionPackages, int version, boolean claimedByCurrentOperator) { }
|
||||
|
||||
public record Detail(String relabelNo, String customerOrderNo, LocalDateTime labelDeadline,
|
||||
String contactName, String contactPhone, String applicationStatus,
|
||||
String taskStatus, int expectedPackages, int relabelledPackages,
|
||||
int skippedPackages, int exceptionPackages, int version,
|
||||
String operatorName, String reviewerName, List<Long> completionPhotoFileIds,
|
||||
String completionRemark, String reviewDecision, String reviewRemark,
|
||||
LocalDateTime claimedAt, LocalDateTime submittedAt, LocalDateTime reviewedAt,
|
||||
List<Item> items, List<UnitFact> unitFacts, List<History> history) { }
|
||||
|
||||
public record Item(Long itemId, String skuCode, String productNameZh, String shipmentNo,
|
||||
String trackingCode, String newSkuCode, String labelRequirement,
|
||||
String relabelFileId, String relabelFileName, String relabelFileUrl,
|
||||
int expectedPackages, int relabelledPackages, int skippedPackages,
|
||||
int exceptionPackages) { }
|
||||
|
||||
public record UnitFact(Long factId, Long itemId, String oldLabel, String newLabel,
|
||||
String result, String exceptionCode, String reason,
|
||||
String operatorName, LocalDateTime occurredAt) { }
|
||||
|
||||
public record History(String action, String fromStatus, String toStatus,
|
||||
String operatorName, String detail, LocalDateTime createdAt) { }
|
||||
|
||||
public record Photo(long fileId, String fileName, String contentType, long sizeBytes) { }
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.baiyue.lastleg.relabel.pda.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_relabel_pda_history")
|
||||
public class PdaRelabelHistory {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private Long taskId;
|
||||
private Long applicationId;
|
||||
private String customerId;
|
||||
private String action;
|
||||
private String fromStatus;
|
||||
private String toStatus;
|
||||
private String operatorKey;
|
||||
private String operatorName;
|
||||
private String detailJson;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.lastleg.relabel.pda.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_relabel_pda_operation")
|
||||
public class PdaRelabelOperation {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private Long taskId;
|
||||
private String customerId;
|
||||
private String operatorKey;
|
||||
private String action;
|
||||
private String idempotencyKey;
|
||||
private String requestHash;
|
||||
private String resultJson;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.baiyue.lastleg.relabel.pda.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_relabel_pda_task")
|
||||
public class PdaRelabelTask {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private Long applicationId;
|
||||
private String customerId;
|
||||
private String relabelNo;
|
||||
private Long overseasWarehouseId;
|
||||
private String status;
|
||||
private String operatorKey;
|
||||
private String operatorName;
|
||||
private String reviewerKey;
|
||||
private String reviewerName;
|
||||
private Integer expectedPackages;
|
||||
private Integer relabelledPackages;
|
||||
private Integer skippedPackages;
|
||||
private Integer exceptionPackages;
|
||||
private String completionPhotoFileIds;
|
||||
private String completionRemark;
|
||||
private String reviewDecision;
|
||||
private String reviewRemark;
|
||||
private LocalDateTime claimedAt;
|
||||
private LocalDateTime submittedAt;
|
||||
private LocalDateTime reviewedAt;
|
||||
private Integer version;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.baiyue.lastleg.relabel.pda.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_relabel_pda_unit_fact")
|
||||
public class PdaRelabelUnitFact {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private Long taskId;
|
||||
private Long applicationId;
|
||||
private Long itemId;
|
||||
private String customerId;
|
||||
private String oldLabel;
|
||||
private String newLabel;
|
||||
private String result;
|
||||
private String exceptionCode;
|
||||
private String reason;
|
||||
private String operatorKey;
|
||||
private String operatorName;
|
||||
private LocalDateTime occurredAt;
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package com.baiyue.lastleg.relabel.pda.gateway;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.client.MultipartBodyBuilder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@Component
|
||||
public class HttpRelabelPhotoFileGateway implements RelabelPhotoFileGateway {
|
||||
private static final String BUSINESS_TYPE = "OVERSEAS_RELABEL_PHOTO";
|
||||
@Resource private RestClient.Builder restClientBuilder;
|
||||
@Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl;
|
||||
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
||||
private RestClient client;
|
||||
|
||||
@PostConstruct
|
||||
void initializeClient() { client = restClientBuilder.baseUrl(baseUrl).build(); }
|
||||
|
||||
@Override
|
||||
public UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file) {
|
||||
long tenantId = tenantId(customerId);
|
||||
MultipartBodyBuilder body = new MultipartBodyBuilder();
|
||||
try {
|
||||
body.part("file", new ByteArrayResource(file.getBytes()) {
|
||||
@Override public String getFilename() { return file.getOriginalFilename(); }
|
||||
}).contentType(file.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM
|
||||
: MediaType.parseMediaType(file.getContentType()));
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalArgumentException("Cannot read relabel photo", exception);
|
||||
}
|
||||
MetadataEnvelope response = client.post()
|
||||
.uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}",
|
||||
tenantId, BUSINESS_TYPE, taskId)
|
||||
.header("X-Internal-Service-Token", internalToken)
|
||||
.header("X-Operator-Id", String.valueOf(operatorId))
|
||||
.contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve()
|
||||
.onStatus(HttpStatusCode::isError, (request, result) -> {
|
||||
throw new ResponseStatusException(result.getStatusCode().is4xxClientError()
|
||||
? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE,
|
||||
"Relabel photo upload failed");
|
||||
}).body(MetadataEnvelope.class);
|
||||
requireSuccess(response, "Relabel photo upload failed");
|
||||
Metadata data = response == null ? null : response.data();
|
||||
if (data == null || data.fileId() == null) {
|
||||
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
|
||||
"File service returned no relabel photo metadata");
|
||||
}
|
||||
return new UploadedFile(data.fileId(), data.fileName(), data.contentType(),
|
||||
data.sizeBytes() == null ? 0L : data.sizeBytes());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validate(String customerId, long taskId, long fileId) {
|
||||
long tenantId = tenantId(customerId);
|
||||
MetadataEnvelope response = client.get()
|
||||
.uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}",
|
||||
tenantId, BUSINESS_TYPE, taskId, fileId)
|
||||
.header("X-Internal-Service-Token", internalToken).retrieve()
|
||||
.onStatus(HttpStatusCode::isError, (request, result) -> {
|
||||
throw new ResponseStatusException(result.getStatusCode().is4xxClientError()
|
||||
? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE,
|
||||
"Relabel photo does not exist or its ownership is invalid");
|
||||
}).body(MetadataEnvelope.class);
|
||||
requireSuccess(response, "Relabel photo does not exist or its ownership is invalid");
|
||||
Metadata data = response == null ? null : response.data();
|
||||
if (data == null || data.fileId() == null || data.fileId() != fileId
|
||||
|| data.tenantId() == null || data.tenantId() != tenantId
|
||||
|| !BUSINESS_TYPE.equals(data.businessType())
|
||||
|| !String.valueOf(taskId).equals(data.businessId())) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||
"Relabel photo metadata does not match the PDA task");
|
||||
}
|
||||
}
|
||||
|
||||
private static long tenantId(String customerId) {
|
||||
try { return Long.parseLong(customerId); }
|
||||
catch (NumberFormatException exception) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||
"Customer ID must be numeric", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireSuccess(MetadataEnvelope response, String fallback) {
|
||||
if (response == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, fallback);
|
||||
if ("200".equals(response.code())) return;
|
||||
HttpStatus status = switch (String.valueOf(response.code())) {
|
||||
case "A0400", "A0422" -> HttpStatus.BAD_REQUEST;
|
||||
case "A0403" -> HttpStatus.FORBIDDEN;
|
||||
case "A0404" -> HttpStatus.NOT_FOUND;
|
||||
default -> HttpStatus.SERVICE_UNAVAILABLE;
|
||||
};
|
||||
throw new ResponseStatusException(status,
|
||||
response.message() == null || response.message().isBlank() ? fallback : response.message());
|
||||
}
|
||||
|
||||
private record MetadataEnvelope(String code, String message, Metadata data) { }
|
||||
private record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
|
||||
String fileName, String contentType, Long sizeBytes) { }
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.baiyue.lastleg.relabel.pda.gateway;
|
||||
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
public interface RelabelPhotoFileGateway {
|
||||
UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file);
|
||||
void validate(String customerId, long taskId, long fileId);
|
||||
|
||||
record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) { }
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.lastleg.relabel.pda.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface PdaRelabelHistoryMapper extends BaseMapper<PdaRelabelHistory> {
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.baiyue.lastleg.relabel.pda.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelOperation;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface PdaRelabelOperationMapper extends BaseMapper<PdaRelabelOperation> {
|
||||
@Select("SELECT * FROM ll_relabel_pda_operation WHERE operator_key=#{operatorKey} AND idempotency_key=#{key} LIMIT 1")
|
||||
PdaRelabelOperation find(@Param("operatorKey") String operatorKey, @Param("key") String key);
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.baiyue.lastleg.relabel.pda.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
@Mapper
|
||||
public interface PdaRelabelTaskMapper extends BaseMapper<PdaRelabelTask> {
|
||||
@Select("SELECT * FROM ll_relabel_pda_task WHERE customer_id=#{customerId} AND application_id=#{applicationId} LIMIT 1")
|
||||
PdaRelabelTask find(@Param("customerId") String customerId, @Param("applicationId") Long applicationId);
|
||||
|
||||
@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);
|
||||
|
||||
@Update("""
|
||||
UPDATE ll_relabel_pda_task
|
||||
SET status=#{task.status}, operator_key=#{task.operatorKey}, operator_name=#{task.operatorName},
|
||||
reviewer_key=#{task.reviewerKey}, reviewer_name=#{task.reviewerName},
|
||||
relabelled_packages=#{task.relabelledPackages}, skipped_packages=#{task.skippedPackages},
|
||||
exception_packages=#{task.exceptionPackages}, completion_photo_file_ids=#{task.completionPhotoFileIds},
|
||||
completion_remark=#{task.completionRemark}, review_decision=#{task.reviewDecision},
|
||||
review_remark=#{task.reviewRemark}, submitted_at=#{task.submittedAt}, reviewed_at=#{task.reviewedAt},
|
||||
version=version+1, updated_at=#{task.updatedAt}
|
||||
WHERE id=#{task.id} AND customer_id=#{task.customerId} AND version=#{expectedVersion}
|
||||
""")
|
||||
int updateWithVersion(@Param("task") PdaRelabelTask task, @Param("expectedVersion") Integer expectedVersion);
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.baiyue.lastleg.relabel.pda.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface PdaRelabelUnitFactMapper extends BaseMapper<PdaRelabelUnitFact> {
|
||||
@Select("SELECT * FROM ll_relabel_pda_unit_fact WHERE old_label=#{oldLabel} LIMIT 1")
|
||||
PdaRelabelUnitFact findOldLabel(@Param("oldLabel") String oldLabel);
|
||||
}
|
||||
+480
@@ -0,0 +1,480 @@
|
||||
package com.baiyue.lastleg.relabel.pda.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.lastleg.relabel.dto.RelabelRequests;
|
||||
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
|
||||
import com.baiyue.lastleg.relabel.entity.RelabelItem;
|
||||
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
|
||||
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
|
||||
import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelRequests;
|
||||
import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelResponses;
|
||||
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory;
|
||||
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelOperation;
|
||||
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask;
|
||||
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact;
|
||||
import com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway;
|
||||
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper;
|
||||
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelOperationMapper;
|
||||
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper;
|
||||
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelUnitFactMapper;
|
||||
import com.baiyue.lastleg.relabel.service.RelabelApplicationService;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.MapperFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class PdaRelabelService {
|
||||
public static final String PROCESSING = "PROCESSING";
|
||||
public static final String WAIT_REVIEW = "WAIT_REVIEW";
|
||||
public static final String COMPLETED = "COMPLETED";
|
||||
private static final long MAX_PHOTO_SIZE = 10L * 1024 * 1024;
|
||||
private static final Set<String> EXCEPTION_CODES = Set.of(
|
||||
"DAMAGED", "MISSING_LABEL", "LABEL_MISMATCH", "UNREADABLE", "OTHER");
|
||||
|
||||
@Resource private RelabelApplicationMapper applications;
|
||||
@Resource private RelabelItemMapper items;
|
||||
@Resource private PdaRelabelTaskMapper tasks;
|
||||
@Resource private PdaRelabelUnitFactMapper unitFacts;
|
||||
@Resource private PdaRelabelOperationMapper operations;
|
||||
@Resource private PdaRelabelHistoryMapper histories;
|
||||
@Resource private RelabelApplicationService applicationService;
|
||||
@Resource private RelabelPhotoFileGateway photoFiles;
|
||||
@Resource private ObjectMapper sourceObjectMapper;
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@PostConstruct
|
||||
void initializeObjectMapper() {
|
||||
objectMapper = sourceObjectMapper.copy().findAndRegisterModules()
|
||||
.configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true)
|
||||
.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<PdaRelabelResponses.Candidate> candidates(String operatorKey, long overseasWarehouseId) {
|
||||
return applications.selectList(new LambdaQueryWrapper<RelabelApplication>()
|
||||
.eq(RelabelApplication::getOverseasWarehouseId, String.valueOf(overseasWarehouseId))
|
||||
.in(RelabelApplication::getStatus, RelabelApplicationService.PENDING_PROCESSING, PROCESSING)
|
||||
.orderByAsc(RelabelApplication::getLabelDeadline)
|
||||
.orderByAsc(RelabelApplication::getCreatedAt))
|
||||
.stream().map(application -> candidate(application,
|
||||
tasks.find(application.getCustomerId(), application.getId()), operatorKey)).toList();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PdaRelabelResponses.Detail detail(String operatorKey, long overseasWarehouseId, String relabelNo) {
|
||||
RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, false);
|
||||
return detail(application, tasks.find(application.getCustomerId(), application.getId()));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PdaRelabelResponses.Detail claim(String operatorKey, String operatorName, long overseasWarehouseId,
|
||||
String relabelNo, PdaRelabelRequests.Claim request) {
|
||||
ClaimCommand command = new ClaimCommand(relabelNo, request.idempotencyKey());
|
||||
PdaRelabelResponses.Detail replay = replay(operatorKey, overseasWarehouseId, relabelNo,
|
||||
"CLAIM", request.idempotencyKey(), command);
|
||||
if (replay != null) return replay;
|
||||
RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, true);
|
||||
if (!Set.of(RelabelApplicationService.PENDING_PROCESSING, PROCESSING).contains(application.getStatus())) {
|
||||
throw conflict("Relabel application is not executable");
|
||||
}
|
||||
PdaRelabelTask task = tasks.lock(application.getCustomerId(), application.getId());
|
||||
if (task != null) {
|
||||
if (!operatorKey.equals(task.getOperatorKey()) || !PROCESSING.equals(task.getStatus())) {
|
||||
throw conflict("Relabel task is already claimed or awaiting review");
|
||||
}
|
||||
} else {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
task = new PdaRelabelTask();
|
||||
task.setId(IdWorker.getId());
|
||||
task.setApplicationId(application.getId()); task.setCustomerId(application.getCustomerId());
|
||||
task.setRelabelNo(application.getRelabelNo()); task.setOverseasWarehouseId(overseasWarehouseId);
|
||||
task.setStatus(PROCESSING); task.setOperatorKey(operatorKey); task.setOperatorName(operatorName);
|
||||
task.setExpectedPackages(expectedPackages(application.getId()));
|
||||
task.setRelabelledPackages(0); task.setSkippedPackages(0); task.setExceptionPackages(0);
|
||||
task.setClaimedAt(now); task.setVersion(0); task.setCreatedAt(now); task.setUpdatedAt(now);
|
||||
try { tasks.insert(task); }
|
||||
catch (DuplicateKeyException duplicate) { throw conflict("Relabel task was claimed concurrently"); }
|
||||
if (RelabelApplicationService.PENDING_PROCESSING.equals(application.getStatus())) {
|
||||
applicationService.changeStatus(application.getCustomerId(), application.getRelabelNo(), operatorKey,
|
||||
new RelabelRequests.ChangeStatus(PROCESSING, "PDA relabel task claimed"));
|
||||
application.setStatus(PROCESSING);
|
||||
}
|
||||
insertHistory(task, "CLAIM", null, PROCESSING, operatorKey, operatorName,
|
||||
Map.of("relabelNo", relabelNo));
|
||||
}
|
||||
PdaRelabelResponses.Detail result = detail(application, task);
|
||||
saveOperation(task, operatorKey, "CLAIM", request.idempotencyKey(), command, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PdaRelabelResponses.Detail scan(String operatorKey, String operatorName, long overseasWarehouseId,
|
||||
String relabelNo, PdaRelabelRequests.Scan request) {
|
||||
return unitAction(operatorKey, operatorName, overseasWarehouseId, relabelNo, "RELABEL", request.idempotencyKey(),
|
||||
request, request.itemId(), request.oldLabel(), request.newLabel(), "RELABELLED", null, null,
|
||||
request.expectedVersion(), item -> {
|
||||
if (!item.getNewSkuCode().equalsIgnoreCase(request.newLabel().trim())) {
|
||||
throw conflict("Scanned new label does not match the approved new SKU");
|
||||
}
|
||||
if (request.oldLabel().trim().equalsIgnoreCase(request.newLabel().trim())) {
|
||||
throw conflict("Old and new labels must be different");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PdaRelabelResponses.Detail skip(String operatorKey, String operatorName, long overseasWarehouseId,
|
||||
String relabelNo, PdaRelabelRequests.Skip request) {
|
||||
return unitAction(operatorKey, operatorName, overseasWarehouseId, relabelNo, "SKIP", request.idempotencyKey(),
|
||||
request, request.itemId(), request.oldLabel(), null, "SKIPPED", null, request.reason(),
|
||||
request.expectedVersion(), ignored -> { });
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PdaRelabelResponses.Detail exception(String operatorKey, String operatorName, long overseasWarehouseId,
|
||||
String relabelNo, PdaRelabelRequests.ExceptionFact request) {
|
||||
String code = request.exceptionCode().trim().toUpperCase(Locale.ROOT);
|
||||
if (!EXCEPTION_CODES.contains(code)) throw badRequest("Unsupported relabel exception code");
|
||||
return unitAction(operatorKey, operatorName, overseasWarehouseId, relabelNo, "EXCEPTION",
|
||||
request.idempotencyKey(), request, request.itemId(), trim(request.oldLabel()), null,
|
||||
"EXCEPTION", code, request.reason(), request.expectedVersion(), ignored -> { });
|
||||
}
|
||||
|
||||
public PdaRelabelResponses.Photo uploadPhoto(String operatorKey, long overseasWarehouseId, long operatorId,
|
||||
String relabelNo, MultipartFile file) {
|
||||
RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, false);
|
||||
PdaRelabelTask task = requireOwnedTask(application, operatorKey, false);
|
||||
if (!PROCESSING.equals(task.getStatus())) throw conflict("Relabel photos can only be uploaded while processing");
|
||||
if (file == null || file.isEmpty()) throw badRequest("Relabel photo is required");
|
||||
if (file.getSize() > MAX_PHOTO_SIZE) throw badRequest("Relabel photo exceeds 10 MB");
|
||||
if (file.getContentType() == null || !file.getContentType().toLowerCase(Locale.ROOT).startsWith("image/")) {
|
||||
throw badRequest("Relabel photo must be an image");
|
||||
}
|
||||
RelabelPhotoFileGateway.UploadedFile uploaded = photoFiles.upload(application.getCustomerId(),
|
||||
task.getId(), operatorId, file);
|
||||
return new PdaRelabelResponses.Photo(uploaded.fileId(), uploaded.fileName(),
|
||||
uploaded.contentType(), uploaded.sizeBytes());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PdaRelabelResponses.Detail complete(String operatorKey, String operatorName, long overseasWarehouseId,
|
||||
String relabelNo, PdaRelabelRequests.Complete request) {
|
||||
CompleteCommand command = new CompleteCommand(relabelNo, request);
|
||||
PdaRelabelResponses.Detail replay = replay(operatorKey, overseasWarehouseId, relabelNo,
|
||||
"COMPLETE", request.idempotencyKey(), command);
|
||||
if (replay != null) return replay;
|
||||
RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, true);
|
||||
PdaRelabelTask task = requireOwnedTask(application, operatorKey, true);
|
||||
requireVersion(task, request.expectedVersion());
|
||||
List<RelabelItem> detailItems = itemList(application);
|
||||
List<PdaRelabelUnitFact> facts = factList(task);
|
||||
for (RelabelItem item : detailItems) {
|
||||
long actual = facts.stream().filter(fact -> item.getId().equals(fact.getItemId())).count();
|
||||
if (actual != value(item.getNotifiedPackageCount())) {
|
||||
throw conflict("Every relabel item must have exactly one fact per notified package");
|
||||
}
|
||||
}
|
||||
List<Long> photoIds = request.photoFileIds().stream().distinct().toList();
|
||||
if (photoIds.size() != request.photoFileIds().size()) throw conflict("Relabel photo IDs must be unique");
|
||||
photoIds.forEach(fileId -> photoFiles.validate(application.getCustomerId(), task.getId(), fileId));
|
||||
String before = task.getStatus();
|
||||
task.setStatus(WAIT_REVIEW); task.setCompletionPhotoFileIds(write(photoIds));
|
||||
task.setCompletionRemark(trim(request.remark())); task.setSubmittedAt(LocalDateTime.now());
|
||||
updateTask(task, request.expectedVersion());
|
||||
insertHistory(task, "COMPLETE", before, WAIT_REVIEW, operatorKey, operatorName,
|
||||
Map.of("photoFileIds", photoIds, "remark", Objects.toString(trim(request.remark()), "")));
|
||||
application.setStatus(PROCESSING);
|
||||
PdaRelabelResponses.Detail result = detail(application, task);
|
||||
saveOperation(task, operatorKey, "COMPLETE", request.idempotencyKey(), command, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PdaRelabelResponses.Detail review(String operatorKey, String operatorName, long overseasWarehouseId,
|
||||
String relabelNo, PdaRelabelRequests.Review request) {
|
||||
ReviewCommand command = new ReviewCommand(relabelNo, request);
|
||||
PdaRelabelResponses.Detail replay = replay(operatorKey, overseasWarehouseId, relabelNo,
|
||||
"REVIEW", request.idempotencyKey(), command);
|
||||
if (replay != null) return replay;
|
||||
String decision = request.decision().trim().toUpperCase(Locale.ROOT);
|
||||
if (!Set.of("APPROVE", "REWORK").contains(decision)) throw badRequest("Review decision must be APPROVE or REWORK");
|
||||
RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, true);
|
||||
if (!PROCESSING.equals(application.getStatus())) {
|
||||
throw conflict("Relabel application is no longer executable");
|
||||
}
|
||||
PdaRelabelTask task = tasks.lock(application.getCustomerId(), application.getId());
|
||||
if (task == null) throw notFound("PDA relabel task was not found");
|
||||
if (!Objects.equals(task.getOverseasWarehouseId(), overseasWarehouseId)) {
|
||||
throw notFound("PDA relabel task was not found in the bound overseas warehouse");
|
||||
}
|
||||
if (!WAIT_REVIEW.equals(task.getStatus())) throw conflict("Relabel task is not awaiting review");
|
||||
requireVersion(task, request.expectedVersion());
|
||||
String before = task.getStatus();
|
||||
String after = "APPROVE".equals(decision) ? COMPLETED : PROCESSING;
|
||||
task.setStatus(after); task.setReviewerKey(operatorKey); task.setReviewerName(operatorName);
|
||||
task.setReviewDecision(decision); task.setReviewRemark(trim(request.remark()));
|
||||
task.setReviewedAt(LocalDateTime.now());
|
||||
updateTask(task, request.expectedVersion());
|
||||
if ("APPROVE".equals(decision)) {
|
||||
applicationService.changeStatus(application.getCustomerId(), relabelNo, operatorKey,
|
||||
new RelabelRequests.ChangeStatus(COMPLETED, request.remark()));
|
||||
application.setStatus(COMPLETED);
|
||||
}
|
||||
insertHistory(task, "REVIEW_" + decision, before, after, operatorKey, operatorName,
|
||||
Map.of("decision", decision, "remark", Objects.toString(trim(request.remark()), "")));
|
||||
PdaRelabelResponses.Detail result = detail(application, task);
|
||||
saveOperation(task, operatorKey, "REVIEW", request.idempotencyKey(), command, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private PdaRelabelResponses.Detail unitAction(String operatorKey, String operatorName,
|
||||
long overseasWarehouseId, String relabelNo, String action, String idempotencyKey, Object command,
|
||||
Long itemId, String oldLabel, String newLabel, String resultCode, String exceptionCode, String reason,
|
||||
Integer expectedVersion, Consumer<RelabelItem> validation) {
|
||||
UnitCommand replayCommand = new UnitCommand(relabelNo, action, command);
|
||||
PdaRelabelResponses.Detail replay = replay(operatorKey, overseasWarehouseId, relabelNo,
|
||||
action, idempotencyKey, replayCommand);
|
||||
if (replay != null) return replay;
|
||||
RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, true);
|
||||
PdaRelabelTask task = requireOwnedTask(application, operatorKey, true);
|
||||
requireVersion(task, expectedVersion);
|
||||
RelabelItem item = requireItem(application, itemId);
|
||||
validation.accept(item);
|
||||
List<PdaRelabelUnitFact> existingFacts = factList(task);
|
||||
long itemFacts = existingFacts.stream().filter(fact -> itemId.equals(fact.getItemId())).count();
|
||||
if (itemFacts >= value(item.getNotifiedPackageCount())) {
|
||||
throw conflict("Relabel item already has all required package facts");
|
||||
}
|
||||
String normalizedOldLabel = trim(oldLabel);
|
||||
if (normalizedOldLabel != null && unitFacts.findOldLabel(normalizedOldLabel) != null) {
|
||||
throw conflict("Old label was already processed by another relabel unit");
|
||||
}
|
||||
PdaRelabelUnitFact fact = new PdaRelabelUnitFact();
|
||||
fact.setId(IdWorker.getId()); fact.setTaskId(task.getId()); fact.setApplicationId(application.getId());
|
||||
fact.setItemId(itemId); fact.setCustomerId(application.getCustomerId());
|
||||
fact.setOldLabel(normalizedOldLabel); fact.setNewLabel(trim(newLabel)); fact.setResult(resultCode);
|
||||
fact.setExceptionCode(exceptionCode); fact.setReason(trim(reason));
|
||||
fact.setOperatorKey(operatorKey); fact.setOperatorName(operatorName); fact.setOccurredAt(LocalDateTime.now());
|
||||
try { unitFacts.insert(fact); }
|
||||
catch (DuplicateKeyException duplicate) { throw conflict("Old label was processed concurrently"); }
|
||||
if ("RELABELLED".equals(resultCode)) task.setRelabelledPackages(value(task.getRelabelledPackages()) + 1);
|
||||
if ("SKIPPED".equals(resultCode)) task.setSkippedPackages(value(task.getSkippedPackages()) + 1);
|
||||
if ("EXCEPTION".equals(resultCode)) task.setExceptionPackages(value(task.getExceptionPackages()) + 1);
|
||||
updateTask(task, expectedVersion);
|
||||
insertHistory(task, action, PROCESSING, PROCESSING, operatorKey, operatorName,
|
||||
Map.of("factId", fact.getId(), "itemId", itemId, "result", resultCode));
|
||||
PdaRelabelResponses.Detail response = detail(application, task);
|
||||
saveOperation(task, operatorKey, action, idempotencyKey, replayCommand, response);
|
||||
return response;
|
||||
}
|
||||
|
||||
private RelabelApplication requireApplication(String relabelNo, long overseasWarehouseId, boolean lock) {
|
||||
RelabelApplication application = lock ? applications.lockAnyByRelabelNo(relabelNo)
|
||||
: applications.findAnyByRelabelNo(relabelNo);
|
||||
if (application == null) throw notFound("Relabel application was not found");
|
||||
long applicationWarehouse;
|
||||
try { applicationWarehouse = Long.parseLong(application.getOverseasWarehouseId()); }
|
||||
catch (Exception invalid) { throw conflict("Relabel application has no valid overseas warehouse"); }
|
||||
if (applicationWarehouse != overseasWarehouseId) {
|
||||
throw notFound("Relabel application was not found in the bound overseas warehouse");
|
||||
}
|
||||
return application;
|
||||
}
|
||||
|
||||
private PdaRelabelTask requireOwnedTask(RelabelApplication application, String operatorKey, boolean lock) {
|
||||
if (!PROCESSING.equals(application.getStatus())) {
|
||||
throw conflict("Relabel application is no longer executable");
|
||||
}
|
||||
PdaRelabelTask task = lock ? tasks.lock(application.getCustomerId(), application.getId())
|
||||
: tasks.find(application.getCustomerId(), application.getId());
|
||||
if (task == null) throw conflict("Claim the relabel task before operating");
|
||||
if (!operatorKey.equals(task.getOperatorKey())) throw conflict("Relabel task is claimed by another operator");
|
||||
if (!Objects.equals(task.getOverseasWarehouseId(), Long.parseLong(application.getOverseasWarehouseId()))) {
|
||||
throw conflict("Relabel task warehouse does not match the approved application");
|
||||
}
|
||||
if (!PROCESSING.equals(task.getStatus())) throw conflict("Relabel task is not in processing status");
|
||||
return task;
|
||||
}
|
||||
|
||||
private RelabelItem requireItem(RelabelApplication application, Long itemId) {
|
||||
RelabelItem item = items.selectOne(new LambdaQueryWrapper<RelabelItem>()
|
||||
.eq(RelabelItem::getId, itemId).eq(RelabelItem::getApplicationId, application.getId())
|
||||
.eq(RelabelItem::getCustomerId, application.getCustomerId()));
|
||||
if (item == null) throw notFound("Relabel item does not belong to the current application");
|
||||
return item;
|
||||
}
|
||||
|
||||
private void requireVersion(PdaRelabelTask task, Integer expectedVersion) {
|
||||
if (!Objects.equals(task.getVersion(), expectedVersion)) {
|
||||
throw conflict("Relabel task version changed; reload before retrying");
|
||||
}
|
||||
}
|
||||
|
||||
private void updateTask(PdaRelabelTask task, Integer expectedVersion) {
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
if (tasks.updateWithVersion(task, expectedVersion) != 1) {
|
||||
throw conflict("Relabel task version changed concurrently");
|
||||
}
|
||||
task.setVersion(expectedVersion + 1);
|
||||
}
|
||||
|
||||
private int expectedPackages(Long applicationId) {
|
||||
return itemList(applicationId).stream().mapToInt(item -> value(item.getNotifiedPackageCount())).sum();
|
||||
}
|
||||
|
||||
private List<RelabelItem> itemList(RelabelApplication application) { return itemList(application.getId()); }
|
||||
private List<RelabelItem> itemList(Long applicationId) {
|
||||
return items.selectList(new LambdaQueryWrapper<RelabelItem>()
|
||||
.eq(RelabelItem::getApplicationId, applicationId).orderByAsc(RelabelItem::getId));
|
||||
}
|
||||
|
||||
private List<PdaRelabelUnitFact> factList(PdaRelabelTask task) {
|
||||
return unitFacts.selectList(new LambdaQueryWrapper<PdaRelabelUnitFact>()
|
||||
.eq(PdaRelabelUnitFact::getCustomerId, task.getCustomerId())
|
||||
.eq(PdaRelabelUnitFact::getTaskId, task.getId())
|
||||
.orderByAsc(PdaRelabelUnitFact::getOccurredAt).orderByAsc(PdaRelabelUnitFact::getId));
|
||||
}
|
||||
|
||||
private PdaRelabelResponses.Candidate candidate(RelabelApplication application, PdaRelabelTask task,
|
||||
String operatorKey) {
|
||||
return new PdaRelabelResponses.Candidate(application.getRelabelNo(), application.getCustomerOrderNo(),
|
||||
application.getLabelDeadline(), application.getStatus(), task == null ? "AVAILABLE" : task.getStatus(),
|
||||
value(application.getItemCount()), task == null ? value(application.getNotifiedPackageCount()) : value(task.getExpectedPackages()),
|
||||
task == null ? 0 : value(task.getRelabelledPackages()), task == null ? 0 : value(task.getSkippedPackages()),
|
||||
task == null ? 0 : value(task.getExceptionPackages()), task == null ? 0 : value(task.getVersion()),
|
||||
task != null && operatorKey.equals(task.getOperatorKey()));
|
||||
}
|
||||
|
||||
private PdaRelabelResponses.Detail detail(RelabelApplication application, PdaRelabelTask task) {
|
||||
List<RelabelItem> detailItems = itemList(application);
|
||||
List<PdaRelabelUnitFact> facts = task == null ? List.of() : factList(task);
|
||||
Map<Long, List<PdaRelabelUnitFact>> factsByItem = facts.stream()
|
||||
.collect(Collectors.groupingBy(PdaRelabelUnitFact::getItemId, LinkedHashMap::new, Collectors.toList()));
|
||||
List<PdaRelabelResponses.Item> itemResponses = detailItems.stream().map(item -> {
|
||||
List<PdaRelabelUnitFact> itemFacts = factsByItem.getOrDefault(item.getId(), List.of());
|
||||
return new PdaRelabelResponses.Item(item.getId(), item.getSkuCode(), item.getProductNameZh(),
|
||||
item.getShipmentNo(), item.getTrackingCode(), item.getNewSkuCode(), item.getLabelRequirement(),
|
||||
item.getRelabelFileId(), item.getRelabelFileName(), item.getRelabelFileUrl(),
|
||||
value(item.getNotifiedPackageCount()), count(itemFacts, "RELABELLED"), count(itemFacts, "SKIPPED"),
|
||||
count(itemFacts, "EXCEPTION"));
|
||||
}).toList();
|
||||
List<PdaRelabelResponses.UnitFact> factResponses = facts.stream().map(fact ->
|
||||
new PdaRelabelResponses.UnitFact(fact.getId(), fact.getItemId(), fact.getOldLabel(), fact.getNewLabel(),
|
||||
fact.getResult(), fact.getExceptionCode(), fact.getReason(), fact.getOperatorName(), fact.getOccurredAt())).toList();
|
||||
List<PdaRelabelResponses.History> historyResponses = task == null ? List.of() : histories.selectList(
|
||||
new LambdaQueryWrapper<PdaRelabelHistory>().eq(PdaRelabelHistory::getCustomerId, application.getCustomerId())
|
||||
.eq(PdaRelabelHistory::getTaskId, task.getId())
|
||||
.orderByAsc(PdaRelabelHistory::getCreatedAt).orderByAsc(PdaRelabelHistory::getId)).stream()
|
||||
.map(history -> new PdaRelabelResponses.History(history.getAction(), history.getFromStatus(),
|
||||
history.getToStatus(), history.getOperatorName(), history.getDetailJson(), history.getCreatedAt())).toList();
|
||||
return new PdaRelabelResponses.Detail(application.getRelabelNo(), application.getCustomerOrderNo(),
|
||||
application.getLabelDeadline(), application.getContactName(), application.getContactPhone(),
|
||||
application.getStatus(), task == null ? "AVAILABLE" : task.getStatus(),
|
||||
task == null ? value(application.getNotifiedPackageCount()) : value(task.getExpectedPackages()),
|
||||
task == null ? 0 : value(task.getRelabelledPackages()), task == null ? 0 : value(task.getSkippedPackages()),
|
||||
task == null ? 0 : value(task.getExceptionPackages()), task == null ? 0 : value(task.getVersion()),
|
||||
task == null ? null : task.getOperatorName(), task == null ? null : task.getReviewerName(),
|
||||
task == null ? List.of() : readIds(task.getCompletionPhotoFileIds()),
|
||||
task == null ? null : task.getCompletionRemark(), task == null ? null : task.getReviewDecision(),
|
||||
task == null ? null : task.getReviewRemark(), task == null ? null : task.getClaimedAt(),
|
||||
task == null ? null : task.getSubmittedAt(), task == null ? null : task.getReviewedAt(),
|
||||
itemResponses, factResponses, historyResponses);
|
||||
}
|
||||
|
||||
private void insertHistory(PdaRelabelTask task, String action, String from, String to,
|
||||
String operatorKey, String operatorName, Object detail) {
|
||||
PdaRelabelHistory history = new PdaRelabelHistory();
|
||||
history.setId(IdWorker.getId()); history.setTaskId(task.getId());
|
||||
history.setApplicationId(task.getApplicationId()); history.setCustomerId(task.getCustomerId());
|
||||
history.setAction(action); history.setFromStatus(from); history.setToStatus(to);
|
||||
history.setOperatorKey(operatorKey); history.setOperatorName(operatorName);
|
||||
history.setDetailJson(write(detail)); history.setCreatedAt(LocalDateTime.now());
|
||||
histories.insert(history);
|
||||
}
|
||||
|
||||
private PdaRelabelResponses.Detail replay(String operatorKey, long overseasWarehouseId, String relabelNo,
|
||||
String action, String key, Object request) {
|
||||
PdaRelabelOperation operation = operations.find(operatorKey, idempotencyKey(key));
|
||||
if (operation == null) return null;
|
||||
PdaRelabelTask task = tasks.selectById(operation.getTaskId());
|
||||
if (task == null || !Objects.equals(task.getOverseasWarehouseId(), overseasWarehouseId)
|
||||
|| !Objects.equals(task.getRelabelNo(), relabelNo)
|
||||
|| !Objects.equals(task.getCustomerId(), operation.getCustomerId())) {
|
||||
throw notFound("PDA relabel operation was not found in the bound overseas warehouse");
|
||||
}
|
||||
if (!action.equals(operation.getAction()) || !hash(request).equals(operation.getRequestHash())) {
|
||||
throw conflict("Idempotency key was already used with a different relabel request");
|
||||
}
|
||||
try { return objectMapper.readValue(operation.getResultJson(), PdaRelabelResponses.Detail.class); }
|
||||
catch (JsonProcessingException exception) { throw new IllegalStateException("Cannot read PDA relabel replay", exception); }
|
||||
}
|
||||
|
||||
private void saveOperation(PdaRelabelTask task, String operatorKey, String action, String key,
|
||||
Object request, PdaRelabelResponses.Detail result) {
|
||||
PdaRelabelOperation operation = new PdaRelabelOperation();
|
||||
operation.setId(IdWorker.getId()); operation.setTaskId(task.getId()); operation.setCustomerId(task.getCustomerId());
|
||||
operation.setOperatorKey(operatorKey); operation.setAction(action); operation.setIdempotencyKey(idempotencyKey(key));
|
||||
operation.setRequestHash(hash(request)); operation.setResultJson(write(result)); operation.setCreatedAt(LocalDateTime.now());
|
||||
try { operations.insert(operation); }
|
||||
catch (DuplicateKeyException duplicate) { throw conflict("Relabel operation was submitted concurrently"); }
|
||||
}
|
||||
|
||||
private String hash(Object value) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(write(value).getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (Exception exception) { throw new IllegalStateException(exception); }
|
||||
}
|
||||
|
||||
private String write(Object value) {
|
||||
try { return objectMapper.writeValueAsString(value); }
|
||||
catch (JsonProcessingException exception) { throw new IllegalStateException(exception); }
|
||||
}
|
||||
|
||||
private List<Long> readIds(String value) {
|
||||
if (!StringUtils.hasText(value)) return List.of();
|
||||
try {
|
||||
return objectMapper.readValue(value, objectMapper.getTypeFactory().constructCollectionType(List.class, Long.class));
|
||||
} catch (JsonProcessingException exception) { throw new IllegalStateException("Cannot read relabel photo IDs", exception); }
|
||||
}
|
||||
|
||||
private static int count(List<PdaRelabelUnitFact> facts, String result) {
|
||||
return (int) facts.stream().filter(fact -> result.equals(fact.getResult())).count();
|
||||
}
|
||||
private static int value(Integer value) { return value == null ? 0 : value; }
|
||||
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
|
||||
private static String idempotencyKey(String value) { return value == null ? null : value.trim(); }
|
||||
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
|
||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
|
||||
|
||||
private record ClaimCommand(String relabelNo, String idempotencyKey) { }
|
||||
private record UnitCommand(String relabelNo, String action, Object request) { }
|
||||
private record CompleteCommand(String relabelNo, PdaRelabelRequests.Complete request) { }
|
||||
private record ReviewCommand(String relabelNo, PdaRelabelRequests.Review request) { }
|
||||
}
|
||||
Reference in New Issue
Block a user