feat: complete overseas pda warehouse operations

This commit is contained in:
2026-08-25 11:20:36 +08:00
parent 45a3b9adfd
commit 042d0b738a
69 changed files with 4679 additions and 4 deletions
@@ -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 {
}
@@ -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) { }
}
@@ -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) { }
}
@@ -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) { }
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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) { }
}
@@ -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) { }
}
@@ -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> {
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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) { }
}