This commit is contained in:
2026-09-10 17:11:33 +08:00
parent 1ee32b7a6f
commit 2e6a18c58e
54 changed files with 2094 additions and 167 deletions
@@ -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(); }
}
@@ -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) {}
}
@@ -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;
@@ -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);
}
@@ -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) {
@@ -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) { }
}
@@ -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()), "")));
@@ -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) {
@@ -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);
}
}