优化
This commit is contained in:
+33
@@ -12,6 +12,11 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@@ -34,10 +39,38 @@ public class MerchantRelabelApplicationController {
|
||||
@GetMapping("/{relabelNo}")
|
||||
public ApiResponse<RelabelResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String relabelNo) { return ApiResponse.success(service.detail(customer(user), relabelNo)); }
|
||||
@PostMapping(value = "/{relabelNo}/shipping-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public ApiResponse<RelabelResponses.Detail> uploadShippingLabel(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String relabelNo, @RequestPart("file") MultipartFile file) {
|
||||
return ApiResponse.success(service.uploadShippingLabel(customer(user), relabelNo, operatorLong(user), file));
|
||||
}
|
||||
@GetMapping("/{relabelNo}/shipping-label/{fileId}/download")
|
||||
public ResponseEntity<byte[]> downloadShippingLabel(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String relabelNo, @PathVariable long fileId) {
|
||||
var file = service.downloadShippingLabel(customer(user), relabelNo, fileId);
|
||||
return download(file.content(), file.fileName(), file.contentType());
|
||||
}
|
||||
@GetMapping("/{relabelNo}/completion-photos/{fileId}/download")
|
||||
public ResponseEntity<byte[]> downloadCompletionPhoto(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String relabelNo, @PathVariable long fileId) {
|
||||
var file = service.downloadCompletionPhoto(customer(user), relabelNo, fileId);
|
||||
return download(file.content(), file.fileName(), file.contentType());
|
||||
}
|
||||
@PostMapping("/{relabelNo}/confirm-completion")
|
||||
public ApiResponse<RelabelResponses.Detail> confirmCompletion(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String relabelNo, @Valid @RequestBody RelabelRequests.CustomerConfirm request) {
|
||||
return ApiResponse.success(service.confirmCompletion(customer(user), relabelNo, operator(user), request));
|
||||
}
|
||||
private ResponseEntity<byte[]> download(byte[] content, String name, String type) {
|
||||
return ResponseEntity.ok().cacheControl(org.springframework.http.CacheControl.noStore())
|
||||
.header("Content-Disposition", ContentDisposition.inline().filename(name, StandardCharsets.UTF_8).build().toString())
|
||||
.contentType(MediaType.parseMediaType(type)).contentLength(content.length).body(content);
|
||||
}
|
||||
private String customer(AuthenticatedUser user) {
|
||||
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required");
|
||||
return String.valueOf(user.tenantId());
|
||||
}
|
||||
private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); }
|
||||
private long operatorLong(AuthenticatedUser user) { return user.userId() == null ? 0L : user.userId(); }
|
||||
}
|
||||
|
||||
+10
@@ -85,6 +85,16 @@ public class RelabelApplicationController {
|
||||
return ResponseEntity.ok().cacheControl(org.springframework.http.CacheControl.noStore()).header("Content-Disposition", ContentDisposition.inline().filename(file.fileName(), StandardCharsets.UTF_8).build().toString())
|
||||
.contentType(org.springframework.http.MediaType.parseMediaType(file.contentType())).contentLength(file.content().length).body(file.content());
|
||||
}
|
||||
@GetMapping("/{relabelNo}/completion-photos/{fileId}/download")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')")
|
||||
public ResponseEntity<byte[]> downloadCompletionPhoto(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String relabelNo, @PathVariable long fileId) {
|
||||
requirePlatform(user);
|
||||
var file = service.downloadCompletionPhoto(service.customerId(relabelNo), relabelNo, fileId);
|
||||
return ResponseEntity.ok().cacheControl(org.springframework.http.CacheControl.noStore())
|
||||
.header("Content-Disposition", ContentDisposition.inline().filename(file.fileName(), StandardCharsets.UTF_8).build().toString())
|
||||
.contentType(MediaType.parseMediaType(file.contentType())).contentLength(file.content().length).body(file.content());
|
||||
}
|
||||
private void requirePlatform(AuthenticatedUser user) {
|
||||
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) {
|
||||
|
||||
@@ -26,8 +26,17 @@ public final class RelabelRequests {
|
||||
@NotNull @DecimalMin(value = "0.01") BigDecimal chargeAmount,
|
||||
@Size(max = 1000) String remark,
|
||||
@NotBlank @Size(max = 255) String idempotencyKey,
|
||||
@NotEmpty @Size(max = 500) List<@Valid Item> items
|
||||
) {}
|
||||
@NotEmpty @Size(max = 500) List<@Valid Item> items,
|
||||
@Size(max = 32) String deliveryMethod,
|
||||
Boolean printShippingLabel
|
||||
) {
|
||||
public Create(String customerOrderNo, String operationWarehouseId, LocalDateTime labelDeadline,
|
||||
String contactName, String contactPhone, BigDecimal chargeAmount, String remark,
|
||||
String idempotencyKey, List<Item> items) {
|
||||
this(customerOrderNo, operationWarehouseId, labelDeadline, contactName, contactPhone,
|
||||
chargeAmount, remark, idempotencyKey, items, "TRUCK", false);
|
||||
}
|
||||
}
|
||||
|
||||
public record Item(
|
||||
@NotNull Long sourceInventoryBalanceId,
|
||||
@@ -105,4 +114,7 @@ public final class RelabelRequests {
|
||||
|
||||
public record ChangeStatus(@NotBlank @Size(max = 32) String status,
|
||||
@Size(max = 1000) String remark) {}
|
||||
|
||||
public record CustomerConfirm(@NotBlank @Size(max = 255) String idempotencyKey,
|
||||
@Size(max = 1000) String remark) {}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ public final class RelabelResponses {
|
||||
int totalPackageCount, int notifiedPackageCount, String skuCodes,
|
||||
String totalFileId, String totalFileName, String totalFileUrl,
|
||||
BigDecimal chargeAmount, String chargeCurrency, String walletStatus,
|
||||
String walletErrorMessage,
|
||||
String walletErrorMessage, String deliveryMethod, boolean printShippingLabel,
|
||||
String shippingLabelFileId, String shippingLabelFileName, String shippingLabelFileUrl,
|
||||
String fulfillmentStatus, String outboundOrderNo, LocalDateTime customerConfirmedAt,
|
||||
LocalDateTime createdAt) {}
|
||||
public record Item(Long id, String skuCode, String productImageFileId, String productImageFileName,
|
||||
String productImageUrl, String productNameZh, int totalPackageCount,
|
||||
@@ -37,6 +39,8 @@ public final class RelabelResponses {
|
||||
String status) {}
|
||||
public record History(String fromStatus, String toStatus, String operatorId,
|
||||
String remark, LocalDateTime createdAt) {}
|
||||
public record CompletionEvidence(Long taskId, List<Long> photoFileIds, String completionRemark,
|
||||
String reviewerName, String reviewRemark, LocalDateTime completedAt) {}
|
||||
public record Detail(Summary application, String remark, List<Item> items, List<History> history,
|
||||
List<TailOrder> tailOrders) {}
|
||||
List<TailOrder> tailOrders, CompletionEvidence completionEvidence) {}
|
||||
}
|
||||
|
||||
+9
@@ -20,6 +20,15 @@ public class RelabelApplication {
|
||||
private LocalDateTime labelDeadline;
|
||||
private String contactName;
|
||||
private String contactPhone;
|
||||
private String deliveryMethod;
|
||||
private Boolean printShippingLabel;
|
||||
private String shippingLabelFileId;
|
||||
private String shippingLabelFileName;
|
||||
private String shippingLabelFileUrl;
|
||||
private String fulfillmentStatus;
|
||||
private String outboundOrderNo;
|
||||
private String customerConfirmationKey;
|
||||
private LocalDateTime customerConfirmedAt;
|
||||
private String remark;
|
||||
private String totalFileId;
|
||||
private String totalFileName;
|
||||
|
||||
+3
@@ -22,4 +22,7 @@ public interface RelabelApplicationMapper extends BaseMapper<RelabelApplication>
|
||||
|
||||
@Select("SELECT * FROM ll_relabel_application WHERE relabel_no=#{relabelNo} LIMIT 1 FOR UPDATE")
|
||||
RelabelApplication lockAnyByRelabelNo(@Param("relabelNo") String relabelNo);
|
||||
|
||||
@Select("SELECT * FROM ll_relabel_application WHERE customer_id=#{customerId} AND outbound_order_no=#{outboundNo} LIMIT 1 FOR UPDATE")
|
||||
RelabelApplication lockByOutbound(@Param("customerId") String customerId, @Param("outboundNo") String outboundNo);
|
||||
}
|
||||
|
||||
+19
@@ -10,6 +10,7 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.client.MultipartBodyBuilder;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -89,6 +90,24 @@ public class HttpRelabelPhotoFileGateway implements RelabelPhotoFileGateway {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public DownloadedFile download(String customerId, long taskId, long fileId) {
|
||||
validate(customerId, taskId, fileId);
|
||||
ResponseEntity<byte[]> response = client.get()
|
||||
.uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}/content",
|
||||
tenantId(customerId), BUSINESS_TYPE, taskId, fileId)
|
||||
.header("X-Internal-Service-Token", internalToken).retrieve()
|
||||
.onStatus(HttpStatusCode::isError, (request, result) -> {
|
||||
throw new ResponseStatusException(result.getStatusCode().is4xxClientError()
|
||||
? HttpStatus.NOT_FOUND : HttpStatus.SERVICE_UNAVAILABLE, "Relabel photo download failed");
|
||||
}).toEntity(byte[].class);
|
||||
String fileName = response.getHeaders().getContentDisposition().getFilename();
|
||||
String contentType = response.getHeaders().getContentType() == null
|
||||
? MediaType.APPLICATION_OCTET_STREAM_VALUE : response.getHeaders().getContentType().toString();
|
||||
return new DownloadedFile(response.getBody() == null ? new byte[0] : response.getBody(),
|
||||
fileName == null ? "relabel-photo" : fileName, contentType);
|
||||
}
|
||||
|
||||
private static long tenantId(String customerId) {
|
||||
try { return Long.parseLong(customerId); }
|
||||
catch (NumberFormatException exception) {
|
||||
|
||||
+2
@@ -5,6 +5,8 @@ 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);
|
||||
DownloadedFile download(String customerId, long taskId, long fileId);
|
||||
|
||||
record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) { }
|
||||
record DownloadedFile(byte[] content, String fileName, String contentType) { }
|
||||
}
|
||||
|
||||
+2
-2
@@ -252,8 +252,8 @@ public class PdaRelabelService {
|
||||
updateTask(task, request.expectedVersion());
|
||||
if ("APPROVE".equals(decision)) {
|
||||
applicationService.changeStatus(application.getCustomerId(), relabelNo, operatorKey,
|
||||
new RelabelRequests.ChangeStatus(COMPLETED, request.remark()));
|
||||
application.setStatus(COMPLETED);
|
||||
new RelabelRequests.ChangeStatus(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, request.remark()));
|
||||
application.setStatus(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING);
|
||||
}
|
||||
insertHistory(task, "REVIEW_" + decision, before, after, operatorKey, operatorName,
|
||||
Map.of("decision", decision, "remark", Objects.toString(trim(request.remark()), "")));
|
||||
|
||||
+205
-8
@@ -24,6 +24,15 @@ import com.baiyue.lastleg.relabel.entity.RelabelStatusHistory;
|
||||
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
|
||||
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
|
||||
import com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper;
|
||||
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask;
|
||||
import com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway;
|
||||
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper;
|
||||
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
|
||||
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
|
||||
import com.baiyue.lastleg.delivery.entity.OutboundPdaTask;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundPdaTaskMapper;
|
||||
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
|
||||
import com.baiyue.lastleg.wallet.WalletCommandOutboxService;
|
||||
import com.baiyue.lastleg.wallet.WalletResultHandler;
|
||||
@@ -52,7 +61,11 @@ import java.util.stream.Collectors;
|
||||
@Service
|
||||
public class RelabelApplicationService implements WalletResultHandler {
|
||||
public static final String PENDING_PROCESSING = "PENDING_PROCESSING";
|
||||
private static final Set<String> STATUSES = Set.of(PENDING_PROCESSING, "PROCESSING", "COMPLETED", "REJECTED", "CANCELLED");
|
||||
public static final String CUSTOMER_CONFIRM_PENDING = "CUSTOMER_CONFIRM_PENDING";
|
||||
public static final String READY_FOR_OUTBOUND = "READY_FOR_OUTBOUND";
|
||||
private static final Set<String> STATUSES = Set.of(PENDING_PROCESSING, "PROCESSING", CUSTOMER_CONFIRM_PENDING,
|
||||
READY_FOR_OUTBOUND, "COMPLETED", "REJECTED", "CANCELLED");
|
||||
private static final Set<String> DELIVERY_METHODS = Set.of("TRUCK", "EXPRESS", "TRUCK_PICKUP", "EXPRESS_PICKUP", "PICKUP");
|
||||
|
||||
@Resource private RelabelApplicationMapper applications;
|
||||
@Resource private RelabelItemMapper items;
|
||||
@@ -66,6 +79,11 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
@Resource private InventoryBalanceMapper inventoryBalances;
|
||||
@Resource private InventoryPositionMapper inventoryPositions;
|
||||
@Resource private RelabelDocumentFileGateway documentFiles;
|
||||
@Resource private PdaRelabelTaskMapper pdaTasks;
|
||||
@Resource private RelabelPhotoFileGateway photoFiles;
|
||||
@Resource private OutboundOrderMapper outboundOrders;
|
||||
@Resource private OutboundPalletItemMapper outboundItems;
|
||||
@Resource private OutboundPdaTaskMapper outboundTasks;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public String customerId(String relabelNo) { return require(null, relabelNo).getCustomerId(); }
|
||||
@@ -94,6 +112,48 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
return detail(application.getCustomerId(), relabelNo);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public RelabelResponses.Detail uploadShippingLabel(String customerId, String relabelNo, long operatorId,
|
||||
MultipartFile file) {
|
||||
RelabelApplication application = lockForDocument(customerId, relabelNo);
|
||||
if (!Boolean.TRUE.equals(application.getPrintShippingLabel())) {
|
||||
throw conflict("This relabel order does not require a shipping label");
|
||||
}
|
||||
requireShippingLabel(file);
|
||||
var uploaded = documentFiles.upload(application.getCustomerId(), "OVERSEAS_RELABEL_SHIPPING_LABEL",
|
||||
String.valueOf(application.getId()), operatorId, file);
|
||||
application.setShippingLabelFileId(String.valueOf(uploaded.fileId()));
|
||||
application.setShippingLabelFileName(uploaded.fileName());
|
||||
application.setShippingLabelFileUrl("/api/merchant/v1/last-leg/relabel-applications/"
|
||||
+ java.net.URLEncoder.encode(relabelNo, StandardCharsets.UTF_8) + "/shipping-label/"
|
||||
+ uploaded.fileId() + "/download");
|
||||
application.setUpdatedAt(LocalDateTime.now());
|
||||
applications.updateById(application);
|
||||
return detail(application.getCustomerId(), relabelNo);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public RelabelDocumentFileGateway.DownloadedFile downloadShippingLabel(String customerId, String relabelNo,
|
||||
long fileId) {
|
||||
RelabelApplication application = require(customerId, relabelNo);
|
||||
if (!String.valueOf(fileId).equals(application.getShippingLabelFileId())) {
|
||||
throw notFound("Shipping label does not belong to this relabel order");
|
||||
}
|
||||
return documentFiles.download(application.getCustomerId(), "OVERSEAS_RELABEL_SHIPPING_LABEL",
|
||||
String.valueOf(application.getId()), fileId);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public RelabelPhotoFileGateway.DownloadedFile downloadCompletionPhoto(String customerId, String relabelNo,
|
||||
long fileId) {
|
||||
RelabelApplication application = require(customerId, relabelNo);
|
||||
PdaRelabelTask task = pdaTasks.find(application.getCustomerId(), application.getId());
|
||||
if (task == null || !readIds(task.getCompletionPhotoFileIds()).contains(fileId)) {
|
||||
throw notFound("Completion photo does not belong to this relabel order");
|
||||
}
|
||||
return photoFiles.download(application.getCustomerId(), task.getId(), fileId);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public RelabelDocumentFileGateway.DownloadedFile downloadFile(String customerId, String relabelNo, String scope, Long itemId, long fileId) {
|
||||
RelabelApplication application = require(customerId, relabelNo);
|
||||
@@ -131,6 +191,15 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
} catch (java.io.IOException exception) { throw badRequest("无法读取PDF文件"); }
|
||||
}
|
||||
|
||||
private static void requireShippingLabel(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) throw badRequest("Shipping label file is required");
|
||||
if (file.getSize() > 20L * 1024 * 1024) throw badRequest("Shipping label file exceeds 20 MB");
|
||||
String contentType = Objects.toString(file.getContentType(), "").toLowerCase();
|
||||
if (!(contentType.equals("application/pdf") || contentType.startsWith("image/"))) {
|
||||
throw badRequest("Shipping label must be a PDF or image");
|
||||
}
|
||||
}
|
||||
|
||||
private static String documentUrl(String relabelNo, long fileId, Long itemId) {
|
||||
return "/api/platform/v1/last-leg/relabel-applications/" + java.net.URLEncoder.encode(relabelNo, StandardCharsets.UTF_8) + "/files/" + fileId + "/download?scope=" + (itemId == null ? "TOTAL" : "ITEM&itemId=" + itemId);
|
||||
}
|
||||
@@ -163,6 +232,17 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
@Transactional
|
||||
public RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request) {
|
||||
if (!StringUtils.hasText(request.operationWarehouseId())) throw badRequest("Operation warehouse is required");
|
||||
boolean requiresOutbound = request.items().stream().anyMatch(item -> !"SKU_LABEL".equals(operationType(item)));
|
||||
String deliveryMethod = trim(request.deliveryMethod());
|
||||
if (requiresOutbound && !DELIVERY_METHODS.contains(deliveryMethod)) {
|
||||
throw badRequest("Delivery method must be TRUCK, PICKUP or EXPRESS when carton labels are changed");
|
||||
}
|
||||
if (!requiresOutbound && StringUtils.hasText(deliveryMethod)) {
|
||||
throw badRequest("SKU-only relabel does not create an outbound fulfillment");
|
||||
}
|
||||
if (Boolean.TRUE.equals(request.printShippingLabel()) && !Set.of("EXPRESS", "EXPRESS_PICKUP").contains(deliveryMethod)) {
|
||||
throw badRequest("Shipping-label printing is only available for express delivery");
|
||||
}
|
||||
String requestHash = hash(request);
|
||||
RelabelApplication replay = applications.findKey(customerId, request.idempotencyKey().trim());
|
||||
if (replay != null) {
|
||||
@@ -223,6 +303,9 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
application.setLabelDeadline(request.labelDeadline());
|
||||
application.setContactName(request.contactName().trim());
|
||||
application.setContactPhone(request.contactPhone().trim());
|
||||
application.setDeliveryMethod(deliveryMethod);
|
||||
application.setPrintShippingLabel(Boolean.TRUE.equals(request.printShippingLabel()));
|
||||
application.setFulfillmentStatus(requiresOutbound ? "WAIT_RELABEL" : "NOT_REQUIRED");
|
||||
application.setRemark(trim(request.remark()));
|
||||
application.setStatus(PENDING_PROCESSING);
|
||||
application.setItemCount(request.items().size());
|
||||
@@ -285,12 +368,17 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
.eq(RelabelStatusHistory::getApplicationId, application.getId())
|
||||
.orderByAsc(RelabelStatusHistory::getCreatedAt));
|
||||
List<LastlegOrder> tailOrders = tailOrders(application);
|
||||
PdaRelabelTask pdaTask = pdaTasks == null ? null : pdaTasks.find(recordCustomerId, application.getId());
|
||||
RelabelResponses.CompletionEvidence evidence = pdaTask == null ? null
|
||||
: new RelabelResponses.CompletionEvidence(pdaTask.getId(), readIds(pdaTask.getCompletionPhotoFileIds()),
|
||||
pdaTask.getCompletionRemark(), pdaTask.getReviewerName(), pdaTask.getReviewRemark(),
|
||||
pdaTask.getReviewedAt());
|
||||
return new RelabelResponses.Detail(summary(application, detailItems), application.getRemark(),
|
||||
detailItems.stream().map(this::item).toList(), statusHistory.stream().map(value ->
|
||||
new RelabelResponses.History(value.getFromStatus(), value.getToStatus(), value.getOperatorId(),
|
||||
value.getRemark(), value.getCreatedAt())).toList(), tailOrders.stream().map(value ->
|
||||
new RelabelResponses.TailOrder(value.getId(), value.getOrderNo(), value.getOverseasWarehouseId(),
|
||||
Objects.requireNonNullElse(value.getPackageCount(), 0), value.getCurrentStatus())).toList());
|
||||
Objects.requireNonNullElse(value.getPackageCount(), 0), value.getCurrentStatus())).toList(), evidence);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -313,8 +401,11 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
application.setStatus(next);
|
||||
application.setUpdatedAt(now);
|
||||
applications.updateById(application);
|
||||
if ("COMPLETED".equals(next)) {
|
||||
releaseInventory(customerId, operatorId, application);
|
||||
if (CUSTOMER_CONFIRM_PENDING.equals(next)) {
|
||||
application.setFulfillmentStatus("WAIT_CUSTOMER_CONFIRMATION");
|
||||
applications.updateById(application);
|
||||
} else if ("COMPLETED".equals(next)) {
|
||||
if (!StringUtils.hasText(application.getOutboundOrderNo())) releaseInventory(customerId, operatorId, application);
|
||||
walletCommands.publish("SETTLE", Long.parseLong(customerId), "RELABEL_ORDER", relabelNo, null,
|
||||
"换标完成,结算费用", 2L);
|
||||
application.setWalletStatus("SETTLE_PENDING"); applications.updateById(application);
|
||||
@@ -332,6 +423,7 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
String orderBefore = order.getCurrentStatus();
|
||||
String mainStatus = STATUSES.contains(orderBefore) ? "PENDING_ASSIGNMENT" : orderBefore;
|
||||
String orderStatus = switch (next) {
|
||||
case CUSTOMER_CONFIRM_PENDING -> mainStatus;
|
||||
case "COMPLETED" -> "RETURN_RELABEL".equals(order.getOrderType())
|
||||
? "PENDING_ASSIGNMENT" : "COMPLETED";
|
||||
case "REJECTED", "CANCELLED" -> "CANCELLED";
|
||||
@@ -350,6 +442,102 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
return detail(customerId, relabelNo);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public RelabelResponses.Detail confirmCompletion(String customerId, String relabelNo, String operatorId,
|
||||
RelabelRequests.CustomerConfirm request) {
|
||||
RelabelApplication application = applications.lockNo(customerId, relabelNo);
|
||||
if (application == null) throw notFound("Relabel application not found");
|
||||
String key = request.idempotencyKey().trim();
|
||||
if (key.equals(application.getCustomerConfirmationKey())) return detail(customerId, relabelNo);
|
||||
if (!CUSTOMER_CONFIRM_PENDING.equals(application.getStatus())) {
|
||||
throw conflict("Relabel order is not awaiting customer confirmation");
|
||||
}
|
||||
if (Boolean.TRUE.equals(application.getPrintShippingLabel())
|
||||
&& !StringUtils.hasText(application.getShippingLabelFileId())) {
|
||||
throw conflict("Upload the shipping label before confirming relabel completion");
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
String before = application.getStatus();
|
||||
application.setStatus(READY_FOR_OUTBOUND);
|
||||
application.setCustomerConfirmationKey(key);
|
||||
application.setCustomerConfirmedAt(now);
|
||||
application.setFulfillmentStatus("TRUCK".equals(application.getDeliveryMethod())
|
||||
? "WAIT_PRE_DISPATCH" : "WAIT_OUTBOUND_CREATION");
|
||||
if (!"TRUCK".equals(application.getDeliveryMethod())) {
|
||||
createDirectOutbound(application, operatorId, now);
|
||||
application.setFulfillmentStatus("OUTBOUND_CREATED");
|
||||
}
|
||||
application.setUpdatedAt(now);
|
||||
applications.updateById(application);
|
||||
for (LastlegOrder order : tailOrders(application)) {
|
||||
String orderBefore = order.getCurrentStatus();
|
||||
order.setCurrentStatus("PENDING_ASSIGNMENT");
|
||||
order.setSubOrderStatus("RELABEL:" + application.getFulfillmentStatus());
|
||||
order.setRelabelStatus(READY_FOR_OUTBOUND);
|
||||
order.setUpdatedAt(now);
|
||||
orders.updateById(order);
|
||||
if (!Objects.equals(orderBefore, order.getCurrentStatus())) {
|
||||
insertOrderHistory(customerId, order.getId(), orderBefore, order.getCurrentStatus(),
|
||||
request.remark(), now);
|
||||
}
|
||||
}
|
||||
insertHistory(customerId, application.getId(), before, READY_FOR_OUTBOUND, operatorId,
|
||||
request.remark(), now);
|
||||
return detail(customerId, relabelNo);
|
||||
}
|
||||
|
||||
private void createDirectOutbound(RelabelApplication application, String operatorId, LocalDateTime now) {
|
||||
String key = "RELABEL:" + application.getId() + ":OUTBOUND";
|
||||
OutboundOrder existing = outboundOrders.findByIdempotency(application.getCustomerId(), key);
|
||||
if (existing != null) {
|
||||
application.setOutboundOrderNo(existing.getOutboundNo());
|
||||
return;
|
||||
}
|
||||
List<RelabelItem> relabelItems = itemList(application.getId());
|
||||
OutboundOrder outbound = new OutboundOrder();
|
||||
outbound.setId(IdWorker.getId()); outbound.setCustomerId(application.getCustomerId());
|
||||
outbound.setOutboundNo(orderNoGenerator.next("RELABEL_OUTBOUND", application.getOverseasWarehouseId(), outbound.getId()));
|
||||
outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType("RELABEL_OUTBOUND");
|
||||
outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(relabelItems.stream().mapToInt(item -> intValue(item.getNotifiedPackageCount())).sum());
|
||||
outbound.setPalletCount((int) relabelItems.stream().map(RelabelItem::getSourceInventoryPositionId).filter(Objects::nonNull).distinct().count());
|
||||
outbound.setRecipientInfo(application.getDeliveryMethod()); outbound.setIdempotencyKey(key);
|
||||
outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); outboundOrders.insert(outbound);
|
||||
Map<Long, List<RelabelItem>> byPosition = relabelItems.stream().collect(Collectors.groupingBy(
|
||||
RelabelItem::getSourceInventoryPositionId, LinkedHashMap::new, Collectors.toList()));
|
||||
for (Map.Entry<Long, List<RelabelItem>> entry : byPosition.entrySet()) {
|
||||
InventoryPosition position = inventoryPositions.selectById(entry.getKey());
|
||||
if (position == null || !application.getCustomerId().equals(position.getCustomerId())) {
|
||||
throw conflict("Relabel source inventory position no longer exists");
|
||||
}
|
||||
List<RelabelItem> group = entry.getValue();
|
||||
OutboundPalletItem item = new OutboundPalletItem(); item.setId(IdWorker.getId());
|
||||
item.setCustomerId(application.getCustomerId()); item.setOutboundOrderId(outbound.getId());
|
||||
item.setWarehousePalletId(position.getPalletId()); item.setPalletNo(position.getPalletNo());
|
||||
item.setPackageCount(group.stream().mapToInt(value -> intValue(value.getNotifiedPackageCount())).sum());
|
||||
item.setPickedPackageCount(0); item.setStatus("WAIT_PICK");
|
||||
item.setCartonMarks(group.stream().map(RelabelItem::getCartonMark).filter(StringUtils::hasText).distinct().collect(Collectors.joining(",")));
|
||||
item.setDestinationWarehouseCode(group.stream().map(RelabelItem::getDestinationFbaWarehouseCode)
|
||||
.filter(StringUtils::hasText).distinct().collect(Collectors.joining(",")));
|
||||
item.setInboundCode(group.stream().map(RelabelItem::getSourceInboundCode).filter(StringUtils::hasText).distinct().collect(Collectors.joining(",")));
|
||||
item.setOrderRefs(writeOutboundRefs(application.getRelabelNo(), entry.getKey(), group));
|
||||
item.setCreatedAt(now); outboundItems.insert(item);
|
||||
}
|
||||
OutboundPdaTask task = new OutboundPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(application.getCustomerId());
|
||||
task.setOutboundOrderId(outbound.getId()); task.setTaskNo("PDA" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + task.getId());
|
||||
task.setStatus("PENDING"); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); outboundTasks.insert(task);
|
||||
application.setOutboundOrderNo(outbound.getOutboundNo());
|
||||
}
|
||||
|
||||
private String writeOutboundRefs(String relabelNo, Long positionId, List<RelabelItem> values) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(Map.of("sourceType", "RELABEL_ORDER", "relabelNo", relabelNo,
|
||||
"sourceInventoryPositionId", positionId, "items", values.stream().map(item -> Map.of(
|
||||
"itemId", item.getId(), "cartonMark", Objects.toString(item.getCartonMark(), ""),
|
||||
"destination", Objects.toString(item.getDestinationFbaWarehouseCode(), ""),
|
||||
"packages", intValue(item.getNotifiedPackageCount()))).toList()));
|
||||
} catch (Exception exception) { throw new IllegalStateException("Cannot save relabel outbound snapshot", exception); }
|
||||
}
|
||||
|
||||
private void insertItem(String customerId, long applicationId, SourceInventory sourceInventory,
|
||||
LocalDateTime now) {
|
||||
RelabelRequests.Item source = sourceInventory.request();
|
||||
@@ -391,6 +579,9 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
Objects.requireNonNullElse(value.getNotifiedPackageCount(), 0), skuCodes,
|
||||
value.getTotalFileId(), value.getTotalFileName(), value.getTotalFileUrl(),
|
||||
value.getChargeAmount(), value.getChargeCurrency(), value.getWalletStatus(), value.getWalletErrorMessage(),
|
||||
value.getDeliveryMethod(), Boolean.TRUE.equals(value.getPrintShippingLabel()),
|
||||
value.getShippingLabelFileId(), value.getShippingLabelFileName(), value.getShippingLabelFileUrl(),
|
||||
value.getFulfillmentStatus(), value.getOutboundOrderNo(), value.getCustomerConfirmedAt(),
|
||||
value.getCreatedAt());
|
||||
}
|
||||
|
||||
@@ -474,6 +665,7 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
|
||||
private record SourceInventory(RelabelRequests.Item request, InventoryBalance balance) {}
|
||||
private static long value(Long value) { return value == null ? 0L : value; }
|
||||
private static int intValue(Integer value) { return value == null ? 0 : value; }
|
||||
|
||||
private void releaseInventory(String customerId, String operatorId, RelabelApplication application) {
|
||||
for (RelabelItem item : itemList(application.getId())) {
|
||||
@@ -505,9 +697,6 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
&& (item.skuItems() == null || item.skuItems().isEmpty())) {
|
||||
throw badRequest("SKU label changes require at least one SKU detail");
|
||||
}
|
||||
if ("CARTON_LABEL".equals(type) && !StringUtils.hasText(item.newSkuCode())) {
|
||||
throw badRequest("Carton label changes require the new SKU");
|
||||
}
|
||||
}
|
||||
private static String operationType(RelabelRequests.Item item) {
|
||||
return StringUtils.hasText(item.operationType()) ? item.operationType().trim().toUpperCase() : "CARTON_LABEL";
|
||||
@@ -527,6 +716,12 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
List.class, RelabelResponses.SkuItem.class));
|
||||
} catch (Exception exception) { throw new IllegalStateException("Cannot read relabel SKU details", 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 (Exception exception) { throw conflict("Relabel completion photo snapshot is invalid"); }
|
||||
}
|
||||
private RelabelApplication require(String customerId, String relabelNo) {
|
||||
RelabelApplication value = StringUtils.hasText(customerId)
|
||||
? applications.findNo(customerId, relabelNo)
|
||||
@@ -548,7 +743,9 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
}
|
||||
private static void validateTransition(String from, String to) {
|
||||
boolean allowed = PENDING_PROCESSING.equals(from) && Set.of("PROCESSING", "REJECTED", "CANCELLED").contains(to)
|
||||
|| "PROCESSING".equals(from) && Set.of("COMPLETED", "REJECTED", "CANCELLED").contains(to);
|
||||
|| "PROCESSING".equals(from) && Set.of(CUSTOMER_CONFIRM_PENDING, "REJECTED", "CANCELLED").contains(to)
|
||||
|| CUSTOMER_CONFIRM_PENDING.equals(from) && Set.of(READY_FOR_OUTBOUND, "REJECTED", "CANCELLED").contains(to)
|
||||
|| READY_FOR_OUTBOUND.equals(from) && Set.of("COMPLETED", "CANCELLED").contains(to);
|
||||
if (!allowed) throw conflict("Relabel status cannot change from " + from + " to " + to);
|
||||
}
|
||||
private String hash(Object value) {
|
||||
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
package com.baiyue.lastleg.relabel.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
|
||||
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
|
||||
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
|
||||
import com.baiyue.lastleg.relabel.entity.RelabelItem;
|
||||
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Keeps relabel ownership and its direct-outbound snapshot aligned after a physical pallet split. */
|
||||
@Service
|
||||
public class RelabelPalletMutationService {
|
||||
@Resource private RelabelItemMapper relabelItems;
|
||||
@Resource private InventoryPositionMapper positions;
|
||||
@Resource private OutboundOrderMapper outboundOrders;
|
||||
@Resource private OutboundPalletItemMapper outboundItems;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
|
||||
public void moveSelectedItems(RelabelApplication application,
|
||||
Map<Long, List<RelabelItem>> itemsBySourcePosition,
|
||||
Map<Long, InventoryPosition> targetBySourcePosition) {
|
||||
for (Map.Entry<Long, List<RelabelItem>> entry : itemsBySourcePosition.entrySet()) {
|
||||
InventoryPosition target = targetBySourcePosition.get(entry.getKey());
|
||||
if (target == null) throw conflict("换标拆托目标库存位置不存在");
|
||||
for (RelabelItem item : entry.getValue()) {
|
||||
item.setSourceInventoryPositionId(target.getId());
|
||||
relabelItems.updateById(item);
|
||||
}
|
||||
}
|
||||
syncDirectOutbound(application, itemsBySourcePosition, targetBySourcePosition);
|
||||
}
|
||||
|
||||
private void syncDirectOutbound(RelabelApplication application,
|
||||
Map<Long, List<RelabelItem>> itemsBySourcePosition,
|
||||
Map<Long, InventoryPosition> targetBySourcePosition) {
|
||||
if (application.getOutboundOrderNo() == null || application.getOutboundOrderNo().isBlank()) return;
|
||||
OutboundOrder outbound = outboundOrders.lockByNo(application.getCustomerId(), application.getOutboundOrderNo());
|
||||
if (outbound == null || !"RELABEL_OUTBOUND".equals(outbound.getOutboundType())) {
|
||||
throw conflict("换标出库单不存在或类型不正确");
|
||||
}
|
||||
if (!"WAIT_PICK".equals(outbound.getStatus())) throw conflict("换标出库已开始拣货,不能再拆托");
|
||||
List<OutboundPalletItem> rows = new ArrayList<>(outboundItems.findByOutbound(
|
||||
application.getCustomerId(), outbound.getId()));
|
||||
for (Map.Entry<Long, List<RelabelItem>> entry : itemsBySourcePosition.entrySet()) {
|
||||
List<RelabelItem> selected = entry.getValue();
|
||||
LinkedHashSet<String> selectedMarks = selected.stream().map(RelabelItem::getCartonMark)
|
||||
.filter(Objects::nonNull).map(String::trim)
|
||||
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
|
||||
long selectedPackages = selected.stream().mapToLong(item -> value(item.getNotifiedPackageCount())).sum();
|
||||
OutboundPalletItem sourceRow = rows.stream().filter(row -> value(row.getPickedPackageCount()) == 0)
|
||||
.filter(row -> cartonMarks(row).containsAll(selectedMarks)).findFirst()
|
||||
.orElseThrow(() -> conflict("换标出库箱唛快照与拆托任务不一致"));
|
||||
LinkedHashSet<String> remaining = cartonMarks(sourceRow);
|
||||
remaining.removeAll(selectedMarks);
|
||||
InventoryPosition target = targetBySourcePosition.get(entry.getKey());
|
||||
if (remaining.isEmpty()) {
|
||||
sourceRow.setWarehousePalletId(target.getPalletId()); sourceRow.setPalletNo(target.getPalletNo());
|
||||
sourceRow.setOrderRefs(outboundRefs(application.getRelabelNo(), target.getId(), selected));
|
||||
outboundItems.updateById(sourceRow);
|
||||
continue;
|
||||
}
|
||||
int remainingPackages = value(sourceRow.getPackageCount()) - Math.toIntExact(selectedPackages);
|
||||
if (remainingPackages <= 0) throw conflict("换标出库剩余箱唛与箱数快照不一致");
|
||||
sourceRow.setPackageCount(remainingPackages); sourceRow.setCartonMarks(String.join(",", remaining));
|
||||
List<RelabelItem> remainingItems = relabelItems.selectList(new LambdaQueryWrapper<RelabelItem>()
|
||||
.eq(RelabelItem::getApplicationId, application.getId())
|
||||
.eq(RelabelItem::getCustomerId, application.getCustomerId())
|
||||
.eq(RelabelItem::getSourceInventoryPositionId, entry.getKey()));
|
||||
sourceRow.setOrderRefs(outboundRefs(application.getRelabelNo(), entry.getKey(), remainingItems));
|
||||
outboundItems.updateById(sourceRow);
|
||||
|
||||
OutboundPalletItem targetRow = new OutboundPalletItem();
|
||||
targetRow.setId(IdWorker.getId()); targetRow.setCustomerId(sourceRow.getCustomerId());
|
||||
targetRow.setOutboundOrderId(sourceRow.getOutboundOrderId()); targetRow.setWarehousePalletId(target.getPalletId());
|
||||
targetRow.setPalletNo(target.getPalletNo()); targetRow.setDestinationWarehouseId(sourceRow.getDestinationWarehouseId());
|
||||
targetRow.setDestinationWarehouseCode(sourceRow.getDestinationWarehouseCode());
|
||||
targetRow.setDestinationWarehouseName(sourceRow.getDestinationWarehouseName());
|
||||
targetRow.setPackageCount(Math.toIntExact(selectedPackages)); targetRow.setPickedPackageCount(0);
|
||||
targetRow.setStatus("WAIT_PICK"); targetRow.setSkuCode(sourceRow.getSkuCode());
|
||||
targetRow.setCartonMarks(String.join(",", selectedMarks)); targetRow.setInboundCode(sourceRow.getInboundCode());
|
||||
targetRow.setOrderRefs(outboundRefs(application.getRelabelNo(), target.getId(), selected));
|
||||
targetRow.setCreatedAt(LocalDateTime.now()); outboundItems.insert(targetRow); rows.add(targetRow);
|
||||
}
|
||||
outbound.setPalletCount((int) rows.stream().map(OutboundPalletItem::getWarehousePalletId)
|
||||
.filter(Objects::nonNull).distinct().count());
|
||||
outbound.setUpdatedAt(LocalDateTime.now()); outboundOrders.updateById(outbound);
|
||||
}
|
||||
|
||||
private static LinkedHashSet<String> cartonMarks(OutboundPalletItem item) {
|
||||
LinkedHashSet<String> result = new LinkedHashSet<>();
|
||||
if (item.getCartonMarks() == null) return result;
|
||||
for (String value : item.getCartonMarks().split(",")) if (!value.isBlank()) result.add(value.trim());
|
||||
return result;
|
||||
}
|
||||
|
||||
private String outboundRefs(String relabelNo, Long positionId, List<RelabelItem> items) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(Map.of("sourceType", "RELABEL_ORDER", "relabelNo", relabelNo,
|
||||
"sourceInventoryPositionId", positionId, "items", items.stream().map(item -> Map.of(
|
||||
"itemId", item.getId(), "cartonMark", Objects.toString(item.getCartonMark(), ""),
|
||||
"destination", Objects.toString(item.getDestinationFbaWarehouseCode(), ""),
|
||||
"packages", value(item.getNotifiedPackageCount()))).toList()));
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("无法保存换标出库托盘快照", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static int value(Integer value) { return value == null ? 0 : value; }
|
||||
private static ResponseStatusException conflict(String message) {
|
||||
return new ResponseStatusException(HttpStatus.CONFLICT, message);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user