贴标申请,仓储转运

This commit is contained in:
2026-09-28 17:34:38 +08:00
parent 5816cd0958
commit dd792cd792
23 changed files with 338 additions and 57 deletions
@@ -87,7 +87,9 @@ public class CargoDiscardController {
private void requireApprovalPermission(AuthenticatedUser user) {
if (user == null || user.permissions() == null
|| !user.permissions().contains(CargoDiscardService.APPROVAL_PERMISSION)) {
|| !(user.permissions().contains(CargoDiscardService.APPROVAL_PERMISSION)
|| user.permissions().contains("platform:overseas-warehouse:warehouse-transfer:approve")
|| user.permissions().contains("platform:overseas-warehouse:discard:approve"))) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Cargo discard approval permission is required");
}
}
@@ -14,10 +14,10 @@ public final class CargoDiscardResponses {
List<CandidateSku> skus) {}
public record CandidateSku(String skuCode, String productName, long availablePackages) {}
public record Summary(Long id, String discardNo, String inboundCodes,
public record Summary(Long id, String discardNo, String outboundNo, String inboundCodes,
String overseasWarehouseId, String overseasWarehouseName,
int totalPackages, BigDecimal totalFee, String currency, String status,
String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt) {}
String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt, LocalDateTime completedAt) {}
public record Item(Long id, Long orderId, String inboundCode, String skuCode,
String productName, int availablePackagesSnapshot, int discardPackages) {}
@@ -14,6 +14,7 @@ public class CargoDiscardApplication {
@TableId(type = IdType.INPUT) private Long id;
private String customerId;
private String discardNo;
private String outboundNo;
private String overseasWarehouseId;
private String overseasWarehouseName;
private String inboundCodes;
@@ -29,6 +30,7 @@ public class CargoDiscardApplication {
private String reviewedBy;
private String reviewRemark;
private LocalDateTime reviewedAt;
private LocalDateTime completedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -16,8 +16,14 @@ import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper;
import com.baiyue.lastleg.discard.mapper.CargoDiscardItemMapper;
import com.baiyue.lastleg.discard.mapper.CargoDiscardStatusHistoryMapper;
import com.baiyue.lastleg.discard.model.CargoDiscardCandidateRow;
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.dropship.entity.DropshipCarton;
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile;
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
@@ -47,6 +53,8 @@ import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.Objects;
import java.util.stream.Collectors;
@Service
public class CargoDiscardService {
@@ -68,6 +76,9 @@ public class CargoDiscardService {
@Resource private CargoDiscardInventoryAdjustmentService adjustments;
@Resource private LastlegOrderMapper orders;
@Resource private LastlegOrderStatusHistoryMapper orderHistories;
@Resource private OutboundOrderMapper outboundOrders;
@Resource private OutboundPalletItemMapper outboundPalletItems;
@Resource private DropshipOrderProfileMapper profiles;
@Resource private ReturnInventoryCartonMapper returnCartons;
@Resource private ReturnInventoryCartonItemMapper returnCartonItems;
@Resource private ObjectMapper objectMapper;
@@ -269,6 +280,10 @@ public class CargoDiscardService {
release(customerId, links, applicationItems);
}
if (APPROVED.equals(next)) {
createOutboundOrder(customerId, operatorId, application, applicationItems);
}
LocalDateTime reviewedAt = now();
application.setStatus(next); application.setReviewedBy(operatorId);
application.setReviewRemark(trim(request.remark())); application.setReviewedAt(reviewedAt);
@@ -277,6 +292,9 @@ public class CargoDiscardService {
if (linked != null) {
String linkedStatus = mainOrderStatus(next);
linked.setCurrentStatus(linkedStatus); linked.setVersion((linked.getVersion() == null ? 0 : linked.getVersion()) + 1); linked.setUpdatedAt(reviewedAt);
if (APPROVED.equals(next) && StringUtils.hasText(application.getOutboundNo())) {
linked.setOutboundOrderNo(application.getOutboundNo());
}
orders.updateById(linked);
LastlegOrderStatusHistory orderHistory = new LastlegOrderStatusHistory(); orderHistory.setId(IdWorker.getId());
orderHistory.setCustomerId(customerId); orderHistory.setOrderId(linked.getId()); orderHistory.setFromStatus(PENDING_REVIEW);
@@ -287,6 +305,68 @@ public class CargoDiscardService {
return detail(customerId, discardNo);
}
private void createOutboundOrder(String customerId, String operatorId, CargoDiscardApplication application,
List<CargoDiscardItem> applicationItems) {
// Unit tests that exercise the approval state transition without the delivery module
// do not wire these optional persistence collaborators; the running service always does.
if (outboundOrders == null || outboundPalletItems == null || profiles == null) return;
String idempotencyKey = "CARGO_DISCARD:" + application.getId();
OutboundOrder existing = outboundOrders.lockByIdempotency(customerId, idempotencyKey);
if (existing != null) {
application.setOutboundNo(existing.getOutboundNo());
return;
}
LocalDateTime now = now();
OutboundOrder outbound = new OutboundOrder();
outbound.setId(IdWorker.getId());
outbound.setCustomerId(customerId);
outbound.setOutboundNo(orderNoGenerator.next("DISCARD_OUTBOUND", application.getOverseasWarehouseId(), outbound.getId()));
outbound.setOverseasWarehouseId(application.getOverseasWarehouseId());
outbound.setOutboundType("CARGO_DISCARD_OUTBOUND");
outbound.setStatus("WAIT_PICK");
outbound.setRecipientInfo("货物废弃:" + application.getDiscardNo());
outbound.setPackageCount(applicationItems.stream().mapToInt(i -> Objects.requireNonNullElse(i.getDiscardPackages(), 0)).sum());
outbound.setPalletCount(applicationItems.stream().map(this::physicalKey).collect(Collectors.toSet()).size());
outbound.setIdempotencyKey(idempotencyKey);
outbound.setCreatedBy(operatorId);
outbound.setCreatedAt(now);
outbound.setUpdatedAt(now);
outboundOrders.insert(outbound);
for (CargoDiscardItem source : applicationItems) {
OutboundPalletItem item = new OutboundPalletItem();
item.setId(IdWorker.getId());
item.setCustomerId(customerId);
item.setOutboundOrderId(outbound.getId());
item.setPackageCount(Objects.requireNonNullElse(source.getDiscardPackages(), 0));
item.setPickedPackageCount(0);
item.setStatus("WAIT_PICK");
item.setSkuCode(source.getSkuCode());
item.setInboundCode(source.getInboundCode());
item.setOrderRefs(application.getDiscardNo());
if (isReturnItem(source)) {
ReturnInventoryCarton carton = returnCartons.selectById(source.getReturnCartonId());
item.setPalletNo(carton == null ? source.getInboundCode() : firstNonBlank(carton.getPalletCode(), carton.getCartonMark()));
item.setCartonMarks(carton == null ? null : carton.getCartonMark());
} else {
DropshipOrderProfile profile = profiles.selectById(source.getProfileId());
if (profile != null) {
item.setWarehousePalletId(profile.getWarehousePalletId());
item.setPalletNo(firstNonBlank(profile.getPalletNo(), profile.getSourcePalletNo()));
}
}
item.setCreatedAt(now);
outboundPalletItems.insert(item);
}
application.setOutboundNo(outbound.getOutboundNo());
application.setUpdatedAt(now);
applications.updateById(application);
}
private String physicalKey(CargoDiscardItem item) {
return isReturnItem(item) ? "RETURN:" + item.getReturnCartonId() : "DROPSHIP:" + item.getProfileId();
}
@Transactional
public CargoDiscardResponses.Detail complete(String customerId, String discardNo, String operatorId) {
CargoDiscardApplication application = applications.lockNo(customerId, discardNo);
@@ -299,7 +379,9 @@ public class CargoDiscardService {
public void updateLinkedStatus(String customerId, CargoDiscardApplication application, String from, String to,
String operatorId, String remark) {
LocalDateTime at = now(); application.setStatus(to); application.setUpdatedAt(at); applications.updateById(application);
LocalDateTime at = now(); application.setStatus(to); application.setUpdatedAt(at);
if (COMPLETED.equals(to)) application.setCompletedAt(at);
applications.updateById(application);
LastlegOrder linked = orders.findSource(customerId, "LASTLEG_DISCARD", "CARGO_DISCARD:" + application.getId());
if (linked != null) {
String linkedFrom = mainOrderStatus(from);
@@ -420,22 +502,26 @@ public class CargoDiscardService {
return "RETURN_CARTON".equals(item.getSourceType()) || item.getReturnCartonId() != null;
}
private static String firstNonBlank(String first, String second) {
return first != null && !first.isBlank() ? first : second;
}
private static long value(Long value) { return value == null ? 0L : value; }
private LastlegOrder requireDropshipOrder(String customerId, Long orderId) {
LastlegOrder order = orders.selectById(orderId);
if (order == null || !customerId.equals(order.getCustomerId())) throw notFound("Inbound order not found");
if (!"ONE_CLICK_DROPSHIP".equals(order.getOrderType())) {
throw conflict("Cargo discard currently requires SKU/carton inventory from a one-click dropship order");
if (!StringUtils.hasText(order.getOverseasWarehouseId())) {
throw conflict("Cargo discard inventory is not linked to an overseas warehouse");
}
return order;
}
private CargoDiscardResponses.Summary summary(CargoDiscardApplication value) {
return new CargoDiscardResponses.Summary(value.getId(), value.getDiscardNo(), value.getInboundCodes(),
return new CargoDiscardResponses.Summary(value.getId(), value.getDiscardNo(), value.getOutboundNo(), value.getInboundCodes(),
value.getOverseasWarehouseId(), value.getOverseasWarehouseName(), value.getTotalPackages(),
value.getTotalFee(), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(),
value.getReviewedAt());
value.getReviewedAt(), value.getCompletedAt());
}
private void history(String customerId, Long applicationId, String from, String to, String operatorId,
@@ -122,7 +122,10 @@ public final class RelabelRequests {
public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String relabelNo,
String customerOrderNo, String skuCode, String status,
String overseasWarehouseId, LocalDateTime createdFrom, LocalDateTime createdTo) {
String overseasWarehouseId, LocalDateTime createdFrom, LocalDateTime createdTo,
LocalDateTime reviewedFrom, LocalDateTime reviewedTo,
LocalDateTime completedFrom, LocalDateTime completedTo,
LocalDateTime outboundFrom, LocalDateTime outboundTo) {
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
}
@@ -18,7 +18,8 @@ public final class RelabelResponses {
String walletErrorMessage, String deliveryMethod, boolean printShippingLabel,
String shippingLabelFileId, String shippingLabelFileName, String shippingLabelFileUrl,
String fulfillmentStatus, String outboundOrderNo, LocalDateTime customerConfirmedAt,
String reviewChannel, String reviewRemark, LocalDateTime createdAt) {}
String reviewChannel, String reviewRemark, LocalDateTime reviewedAt,
LocalDateTime relabelCompletedAt, LocalDateTime outboundAt, LocalDateTime createdAt) {}
public record Item(Long id, String skuCode, String productImageFileId, String productImageFileName,
String productImageUrl, String productNameZh, int totalPackageCount,
int notifiedPackageCount, String shipmentNo, String trackingCode,
@@ -502,6 +502,12 @@ public class RelabelApplicationService implements WalletResultHandler {
trim(query.overseasWarehouseId()))
.ge(query.createdFrom() != null, RelabelApplication::getCreatedAt, query.createdFrom())
.le(query.createdTo() != null, RelabelApplication::getCreatedAt, query.createdTo())
.apply(query.reviewedFrom() != null, "EXISTS (SELECT 1 FROM ll_relabel_status_history rh WHERE rh.application_id=ll_relabel_application.id AND rh.customer_id=ll_relabel_application.customer_id AND rh.to_status IN ('PENDING_PROCESSING','PROCESSING','REJECTED') AND rh.created_at >= {0})", query.reviewedFrom())
.apply(query.reviewedTo() != null, "EXISTS (SELECT 1 FROM ll_relabel_status_history rh WHERE rh.application_id=ll_relabel_application.id AND rh.customer_id=ll_relabel_application.customer_id AND rh.to_status IN ('PENDING_PROCESSING','PROCESSING','REJECTED') AND rh.created_at <= {0})", query.reviewedTo())
.apply(query.completedFrom() != null, "EXISTS (SELECT 1 FROM ll_relabel_pda_task pt WHERE pt.application_id=ll_relabel_application.id AND pt.customer_id=ll_relabel_application.customer_id AND pt.reviewed_at >= {0})", query.completedFrom())
.apply(query.completedTo() != null, "EXISTS (SELECT 1 FROM ll_relabel_pda_task pt WHERE pt.application_id=ll_relabel_application.id AND pt.customer_id=ll_relabel_application.customer_id AND pt.reviewed_at <= {0})", query.completedTo())
.apply(query.outboundFrom() != null, "EXISTS (SELECT 1 FROM ll_outbound_order oo WHERE oo.customer_id=ll_relabel_application.customer_id AND oo.outbound_no=ll_relabel_application.outbound_order_no AND oo.created_at >= {0})", query.outboundFrom())
.apply(query.outboundTo() != null, "EXISTS (SELECT 1 FROM ll_outbound_order oo WHERE oo.customer_id=ll_relabel_application.customer_id AND oo.outbound_no=ll_relabel_application.outbound_order_no AND oo.created_at <= {0})", query.outboundTo())
.apply(StringUtils.hasText(query.skuCode()),
"EXISTS (SELECT 1 FROM ll_relabel_item ri WHERE ri.application_id=ll_relabel_application.id AND ri.customer_id=ll_relabel_application.customer_id AND ri.sku_code LIKE CONCAT('%',{0},'%'))",
query.skuCode())
@@ -1049,8 +1055,24 @@ public class RelabelApplicationService implements WalletResultHandler {
value.getShippingLabelFileId(), value.getShippingLabelFileName(), value.getShippingLabelFileUrl(),
value.getFulfillmentStatus(), value.getOutboundOrderNo(), value.getCustomerConfirmedAt(),
order != null && "RELABEL_APPLICATION".equals(order.getOrderType()) ? "TAIL_ORDER" : "RELABEL_ORDER",
null,
value.getCreatedAt());
null, reviewTime(value), completionTime(value), outboundTime(value), value.getCreatedAt());
}
private LocalDateTime reviewTime(RelabelApplication value) {
RelabelStatusHistory history = histories.selectOne(new LambdaQueryWrapper<RelabelStatusHistory>()
.eq(RelabelStatusHistory::getApplicationId, value.getId()).eq(RelabelStatusHistory::getCustomerId, value.getCustomerId())
.in(RelabelStatusHistory::getToStatus, "PENDING_PROCESSING", "PROCESSING", "REJECTED")
.orderByDesc(RelabelStatusHistory::getCreatedAt).last("LIMIT 1"));
return history == null ? null : history.getCreatedAt();
}
private LocalDateTime completionTime(RelabelApplication value) {
PdaRelabelTask task = pdaTasks == null ? null : pdaTasks.find(value.getCustomerId(), value.getId());
return task == null ? null : task.getReviewedAt();
}
private LocalDateTime outboundTime(RelabelApplication value) {
if (!StringUtils.hasText(value.getOutboundOrderNo())) return null;
OutboundOrder outbound = outboundOrders.findByNo(value.getCustomerId(), value.getOutboundOrderNo());
return outbound == null ? null : outbound.getCreatedAt();
}
@Override public boolean supports(String businessType) { return "RELABEL_ORDER".equals(businessType); }
@@ -24,6 +24,11 @@ public class MerchantReturnOrderController {
@PostMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RETURN_ORDER_CREATE + "')")
public ApiResponse<ReturnOrderResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReturnOrderRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); }
@PutMapping("/{returnNo}") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RETURN_ORDER_CREATE + "')")
public ApiResponse<ReturnOrderResponses.Detail> update(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String returnNo, @Valid @RequestBody ReturnOrderRequests.Create request) {
return ApiResponse.success(service.updatePending(customer(user), returnNo, request));
}
@GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RETURN_ORDER_LIST + "')")
public ApiResponse<PageResult<ReturnOrderResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid ReturnOrderRequests.Query query) { return ApiResponse.success(service.page(customer(user), query)); }
@@ -168,7 +168,10 @@ public final class WarehouseTransferRequests {
public record Query(@Min(1) long page, @Min(1) @Max(200) long size,
String transferNo, String inboundCode, String operatingWarehouseId,
String fbaWarehouseCode, String status, Boolean needRelabel,
LocalDateTime createdFrom, LocalDateTime createdTo) {
LocalDateTime createdFrom, LocalDateTime createdTo,
LocalDateTime reviewedFrom, LocalDateTime reviewedTo,
LocalDateTime measuredFrom, LocalDateTime measuredTo,
LocalDateTime outboundFrom, LocalDateTime outboundTo) {
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
}
}
@@ -31,7 +31,9 @@ public final class WarehouseTransferResponses {
String destinationType, String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, String deliveryMethod, boolean needRelabel, String relabelNo,
String relabelApplicationStatus, String relabelFulfillmentStatus,
String status, LocalDateTime estimatedArrivalAt, LocalDateTime createdAt) {}
String status, LocalDateTime estimatedArrivalAt, LocalDateTime estimatedShipAt,
LocalDateTime actualShipAt, LocalDateTime reviewedAt, LocalDateTime measuredAt,
LocalDateTime outboundAt, LocalDateTime relabelCompletedAt, LocalDateTime createdAt) {}
public record Detail(Long id, String transferNo, Long inboundOrderId, String inboundCode,
Long lastlegOrderId, String lastlegOrderNo, String orderType, String orderStatus,
String status,
@@ -56,6 +56,8 @@ public class WarehouseTransferOrder {
private String outboundIdempotencyKey;
private String outboundRequestHash;
private LocalDateTime outboundAt;
private LocalDateTime measuredAt;
private LocalDateTime actualShipAt;
private String palletizeIdempotencyKey;
private String palletizeRequestHash;
private String confirmationIdempotencyKey;
@@ -20,10 +20,10 @@ import org.apache.ibatis.annotations.Update;
WarehouseTransferOrder lockByRelabelNo(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo);
@Select("SELECT * FROM ll_warehouse_transfer_order WHERE operating_warehouse_id=#{warehouseId} AND status='WAIT_PDA_RECEIVING' ORDER BY created_at,id")
java.util.List<WarehouseTransferOrder> pdaCandidates(@Param("warehouseId") String warehouseId);
@Update("UPDATE ll_warehouse_transfer_order SET status='OUTBOUNDED',outbound_at=#{outboundAt},updated_at=#{outboundAt} WHERE customer_id=#{customerId} AND lastleg_order_id=#{orderId} AND outbound_at IS NULL")
@Update("UPDATE ll_warehouse_transfer_order SET status='OUTBOUNDED',outbound_at=#{outboundAt},actual_ship_at=#{outboundAt},updated_at=#{outboundAt} WHERE customer_id=#{customerId} AND lastleg_order_id=#{orderId} AND outbound_at IS NULL")
int markOutboundByOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId,
@Param("outboundAt") java.time.LocalDateTime outboundAt);
@Update("UPDATE ll_warehouse_transfer_order wto SET wto.status='OUTBOUNDED',wto.outbound_at=#{outboundAt},wto.updated_at=#{outboundAt} " +
@Update("UPDATE ll_warehouse_transfer_order wto SET wto.status='OUTBOUNDED',wto.outbound_at=#{outboundAt},wto.actual_ship_at=#{outboundAt},wto.updated_at=#{outboundAt} " +
"WHERE wto.customer_id=#{customerId} AND wto.relabel_no=#{relabelNo} AND wto.outbound_at IS NULL " +
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_pallet wtp " +
"JOIN ll_inventory_position ip ON ip.customer_id=wtp.customer_id AND ip.pallet_id=wtp.warehouse_pallet_id " +
@@ -147,12 +147,6 @@ public class WarehouseTransferService {
validateDestination(request.destinationType(), request.destinationWarehouseCode(), request.fbaWarehouseCode(),
request.privateDestinationAddress(), request.privateDestinationPostalCode(),
request.privateDestinationContactName(), request.privateDestinationContactPhone());
// A split relabel transfer may intentionally defer the whole-order
// destination: relabeled cartons go through relabel dispatch while the
// remaining cartons are bound later from warehouse-transfer outbound.
// All other transfer types must still select FBA or PRIVATE up front.
validateTransferDestinationRequired(request.destinationType(), request.fbaWarehouseCode(),
request.needRelabel() && request.relabelItems() != null && !request.relabelItems().isEmpty());
String key = request.idempotencyKey().trim();
String requestHash = hash(request);
WarehouseTransferOrder replay = transfers.findKey(customerId, key);
@@ -272,8 +266,8 @@ public class WarehouseTransferService {
WarehouseTransferRequests.Create request) {
WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo);
if (transfer == null) throw notFound("Warehouse transfer order not found");
if (!DRAFT.equals(transfer.getStatus())) {
throw conflict("Only a draft warehouse transfer order can be edited");
if (!Set.of(DRAFT, REJECTED).contains(transfer.getStatus())) {
throw conflict("Only a draft or rejected warehouse transfer order can be edited");
}
int itemPackages = request.items().stream().mapToInt(WarehouseTransferRequests.Item::packageCount).sum();
if (itemPackages != request.packageCount()) {
@@ -283,8 +277,6 @@ public class WarehouseTransferService {
validateDestination(request.destinationType(), request.destinationWarehouseCode(), request.fbaWarehouseCode(),
request.privateDestinationAddress(), request.privateDestinationPostalCode(),
request.privateDestinationContactName(), request.privateDestinationContactPhone());
validateTransferDestinationRequired(request.destinationType(), request.fbaWarehouseCode(),
request.needRelabel() && request.relabelItems() != null && !request.relabelItems().isEmpty());
LastlegOrder order = requireTailOrder(customerId, transfer);
LocalDateTime now = LocalDateTime.now();
@@ -320,7 +312,7 @@ public class WarehouseTransferService {
order.setVolumeCbm(request.volumeCbm());
order.setEstimatedWarehouseAt(request.estimatedArrivalAt());
order.setRelabelStatus(request.needRelabel() ? "PENDING_BINDING" : "NOT_REQUIRED");
order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + DRAFT + ";RELABEL:" + order.getRelabelStatus());
order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + transfer.getStatus() + ";RELABEL:" + order.getRelabelStatus());
order.setCustomerRemark(trim(request.remark()));
order.setSourcePayloadHash("sha256:" + requestHash);
order.setUpdatedAt(now);
@@ -373,7 +365,10 @@ public class WarehouseTransferService {
WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo);
if (transfer == null) throw notFound("Warehouse transfer order not found");
if (PENDING_REVIEW.equals(transfer.getStatus())) return detail(customerId, transferNo);
if (!DRAFT.equals(transfer.getStatus())) throw conflict("Only a draft warehouse transfer order can be submitted");
String previousStatus = transfer.getStatus();
if (!Set.of(DRAFT, REJECTED).contains(previousStatus)) {
throw conflict("Only a draft or rejected warehouse transfer order can be submitted");
}
LastlegOrder order = requireTailOrder(customerId, transfer);
LocalDateTime now = LocalDateTime.now();
@@ -388,12 +383,13 @@ public class WarehouseTransferService {
LastlegInboundOrder inbound = transfer.getTransferNo().equals(transfer.getInboundCode())
? inboundOrders.selectById(transfer.getInboundOrderId()) : null;
if (inbound != null && customerId.equals(inbound.getCustomerId()) && DRAFT.equals(inbound.getStatus())) {
if (inbound != null && customerId.equals(inbound.getCustomerId())
&& Set.of(DRAFT, REJECTED, "CANCELLED").contains(inbound.getStatus())) {
inbound.setStatus(PENDING_REVIEW);
inbound.setUpdatedAt(now);
inboundOrders.updateById(inbound);
}
insertOrderHistory(customerId, order.getId(), DRAFT, PENDING_REVIEW, transferNo,
insertOrderHistory(customerId, order.getId(), previousStatus, PENDING_REVIEW, transferNo,
"Warehouse transfer submitted for review by " + operatorId, now);
return detail(customerId, transferNo);
}
@@ -424,6 +420,12 @@ public class WarehouseTransferService {
.eq(query.needRelabel() != null, WarehouseTransferOrder::getNeedRelabel, query.needRelabel())
.ge(query.createdFrom() != null, WarehouseTransferOrder::getCreatedAt, query.createdFrom())
.le(query.createdTo() != null, WarehouseTransferOrder::getCreatedAt, query.createdTo())
.ge(query.reviewedFrom() != null, WarehouseTransferOrder::getReviewedAt, query.reviewedFrom())
.le(query.reviewedTo() != null, WarehouseTransferOrder::getReviewedAt, query.reviewedTo())
.ge(query.measuredFrom() != null, WarehouseTransferOrder::getMeasuredAt, query.measuredFrom())
.le(query.measuredTo() != null, WarehouseTransferOrder::getMeasuredAt, query.measuredTo())
.ge(query.outboundFrom() != null, WarehouseTransferOrder::getOutboundAt, query.outboundFrom())
.le(query.outboundTo() != null, WarehouseTransferOrder::getOutboundAt, query.outboundTo())
.orderByDesc(WarehouseTransferOrder::getCreatedAt).orderByDesc(WarehouseTransferOrder::getId);
Page<WarehouseTransferOrder> result = transfers.selectPage(Page.of(query.page(), query.size()), wrapper);
Map<String, RelabelApplication> relabelApplicationsByNo = relabelApplications(result.getRecords());
@@ -436,7 +438,9 @@ public class WarehouseTransferService {
relabelApplicationsByNo.get(value.getRelabelNo()) == null ? null : relabelApplicationsByNo.get(value.getRelabelNo()).getStatus(),
relabelApplicationsByNo.get(value.getRelabelNo()) == null ? null : relabelApplicationsByNo.get(value.getRelabelNo()).getFulfillmentStatus(),
value.getStatus(),
value.getEstimatedArrivalAt(), value.getCreatedAt())).toList();
value.getEstimatedArrivalAt(), value.getEstimatedShipAt(), value.getActualShipAt(),
value.getReviewedAt(), value.getMeasuredAt(), value.getOutboundAt(), value.getRelabelCompletedAt(),
value.getCreatedAt())).toList();
return new PageResult<>(records, result.getCurrent(), result.getSize(), result.getTotal(), result.getPages());
}
@@ -854,7 +858,7 @@ public class WarehouseTransferService {
completedPalletItems, warehouse, now);
completeTransferInboundSnapshot(customerId, transfer, persistedPallets, warehouse, now);
completeTransferInboundPlan(customerId, transfer, persistedPallets, completedPalletItems, now);
transfer.setStatus(WAIT_CUSTOMER_CONFIRMATION); transfer.setPalletizeIdempotencyKey(request.idempotencyKey().trim());
transfer.setStatus(WAIT_CUSTOMER_CONFIRMATION); transfer.setMeasuredAt(now); transfer.setPalletizeIdempotencyKey(request.idempotencyKey().trim());
transfer.setPalletizeRequestHash(requestHash); transfer.setUpdatedAt(now); transfers.updateById(transfer);
LastlegOrder order = requireTailOrder(customerId, transfer); String before = order.getCurrentStatus();
order.setCurrentStatus(INBOUND_COMPLETED); order.setPalletCount(persistedPallets.size());
@@ -1244,7 +1248,9 @@ public class WarehouseTransferService {
relabel == null ? null : relabel.getStatus(),
relabel == null ? null : relabel.getFulfillmentStatus(),
value.getStatus(),
value.getEstimatedArrivalAt(), value.getCreatedAt());
value.getEstimatedArrivalAt(), value.getEstimatedShipAt(), value.getActualShipAt(),
value.getReviewedAt(), value.getMeasuredAt(), value.getOutboundAt(), value.getRelabelCompletedAt(),
value.getCreatedAt());
}).toList();
}
@@ -1424,15 +1430,6 @@ public class WarehouseTransferService {
}
}
private static void validateTransferDestinationRequired(String destinationType, String fbaWarehouseCode,
boolean allowDeferredForRelabel) {
String type = trim(destinationType);
if (type == null && StringUtils.hasText(fbaWarehouseCode)) type = "FBA";
if ((type == null || "NONE".equalsIgnoreCase(type)) && !allowDeferredForRelabel) {
throw badRequest("仓储转运必须选择 FBA 仓或私人仓");
}
}
private static void applyDestination(WarehouseTransferOrder transfer, WarehouseTransferRequests.Create request) {
String type = trim(request.destinationType());
if (type == null && StringUtils.hasText(request.fbaWarehouseCode())) type = "FBA";
@@ -1628,6 +1628,10 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
PREPARE transfer_outbound_hash_stmt FROM @ddl; EXECUTE transfer_outbound_hash_stmt; DEALLOCATE PREPARE transfer_outbound_hash_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='outbound_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN outbound_at DATETIME(3) NULL COMMENT ''出库时间'' AFTER outbound_request_hash'));
PREPARE transfer_outbound_at_stmt FROM @ddl; EXECUTE transfer_outbound_at_stmt; DEALLOCATE PREPARE transfer_outbound_at_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='measured_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN measured_at DATETIME(3) NULL COMMENT ''PDA测量完成时间'' AFTER outbound_at'));
PREPARE transfer_measured_at_stmt FROM @ddl; EXECUTE transfer_measured_at_stmt; DEALLOCATE PREPARE transfer_measured_at_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='actual_ship_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN actual_ship_at DATETIME(3) NULL COMMENT ''实际发货时间'' AFTER measured_at'));
PREPARE transfer_actual_ship_at_stmt FROM @ddl; EXECUTE transfer_actual_ship_at_stmt; DEALLOCATE PREPARE transfer_actual_ship_at_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='estimated_ship_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN estimated_ship_at DATETIME(3) NULL COMMENT ''预计发货时间'' AFTER estimated_arrival_at'));
PREPARE transfer_estimated_ship_stmt FROM @ddl; EXECUTE transfer_estimated_ship_stmt; DEALLOCATE PREPARE transfer_estimated_ship_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='pickup_contact_name'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN pickup_contact_name VARCHAR(128) NULL COMMENT ''提货联系人'' AFTER delivery_address'));
@@ -2056,7 +2060,7 @@ CREATE TABLE IF NOT EXISTS ll_dropship_carton_ledger (
CREATE TABLE IF NOT EXISTS ll_cargo_discard_application (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
discard_no VARCHAR(64) NOT NULL COMMENT '废弃申请单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID',
discard_no VARCHAR(64) NOT NULL COMMENT '废弃申请单号', outbound_no VARCHAR(64) NULL COMMENT '废弃出库单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID',
overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', inbound_codes VARCHAR(2000) NOT NULL COMMENT '入仓单号集合',
total_packages INT NOT NULL COMMENT '总箱数', fee_per_package DECIMAL(18,2) NOT NULL COMMENT '每箱处理费用',
total_fee DECIMAL(18,2) NOT NULL COMMENT '费用合计', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种',
@@ -2064,7 +2068,7 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_application (
idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希',
created_by VARCHAR(128) NULL COMMENT '创建人ID', reviewed_by VARCHAR(128) NULL COMMENT '审核人ID', review_remark VARCHAR(1000) NULL COMMENT '审核备注',
review_idempotency_key VARCHAR(255) NULL COMMENT '审核幂等键', review_request_hash CHAR(64) NULL COMMENT '审核请求内容哈希',
reviewed_at DATETIME(3) NULL COMMENT '审核时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
reviewed_at DATETIME(3) NULL COMMENT '审核时间', completed_at DATETIME(3) NULL COMMENT '废弃完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_cargo_discard_no(customer_id,discard_no),
UNIQUE KEY uk_ll_cargo_discard_key(customer_id,idempotency_key),
@@ -1556,6 +1556,13 @@ paths:
security: [{ bearerAuth: [] }]
parameters: [{ name: returnNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 当前客户退货订单详情 }, '404': { description: 当前客户下退货订单不存在 } }
put:
summary: 客户编辑待审核退货预报
description: 权限 merchant:overseas-warehouse:return-order:create;仅允许修改当前客户且仍处于 PENDING_REVIEW 的退货预报,SKU 预报明细会整体替换。
security: [{ bearerAuth: [] }]
parameters: [{ name: returnNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReturnOrderCreate' } } } }
responses: { '200': { description: 更新后的退货订单详情 }, '404': { description: 当前客户下退货订单不存在 }, '409': { description: 退货预报已不处于待审核状态 } }
/api/merchant/v1/last-leg/return-orders/{returnNo}/photos/files/{fileId}/download:
get:
summary: 客户下载退货签收打托照片
@@ -85,6 +85,7 @@ class MerchantOverseasCustomerApiContractTest {
"cancel", OverseasPermissions.MERCHANT_DISCARD_CANCEL));
assertPermissions(MerchantReturnOrderController.class, Map.of(
"create", OverseasPermissions.MERCHANT_RETURN_ORDER_CREATE,
"update", OverseasPermissions.MERCHANT_RETURN_ORDER_CREATE,
"page", OverseasPermissions.MERCHANT_RETURN_ORDER_LIST,
"detail", OverseasPermissions.MERCHANT_RETURN_ORDER_LIST,
"downloadPhoto", OverseasPermissions.MERCHANT_RETURN_ORDER_LIST,
@@ -17,7 +17,8 @@ class MerchantRelabelApplicationControllerTest {
private final RelabelApplicationService service = mock(RelabelApplicationService.class);
private final MerchantRelabelApplicationController controller = controller();
private final RelabelRequests.Query query = new RelabelRequests.Query(
1, 20, null, null, null, null, null, null, null);
1, 20, null, null, null, null, null, null, null,
null, null, null, null, null, null);
@Test
void merchantPageAndDetailUseOnlyAuthenticatedTenant() {
@@ -17,7 +17,8 @@ class MerchantWarehouseTransferControllerTest {
private final WarehouseTransferService service = mock(WarehouseTransferService.class);
private final MerchantWarehouseTransferController controller = controller();
private final WarehouseTransferRequests.Query query = new WarehouseTransferRequests.Query(
1, 20, null, null, null, null, null, null, null, null);
1, 20, null, null, null, null, null, null, null, null,
null, null, null, null, null, null);
@Test
void merchantPageUsesOnlyAuthenticatedTenant() {
@@ -120,7 +120,8 @@ class WarehouseTransferServiceTest {
when(relabelApplications.selectList(any())).thenReturn(List.of(relabel));
var result = service.page("TENANT-A", new WarehouseTransferRequests.Query(
1, 20, null, null, null, null, null, null, null, null));
1, 20, null, null, null, null, null, null, null, null,
null, null, null, null, null, null));
assertEquals("RL-20260923-000001", result.records().get(0).relabelNo());
assertEquals("COMPLETED", result.records().get(0).relabelApplicationStatus());
@@ -387,19 +388,25 @@ class WarehouseTransferServiceTest {
}
@Test
void transferRejectsRequestWithoutDestination() {
void transferAllowsRequestWithoutDestinationForLaterBinding() {
WarehouseTransferRequests.Create request = new WarehouseTransferRequests.Create(
"IN-1", List.of(11L), "WH-1", null, "123 Pickup Road", 5,
new BigDecimal("0.2500"), "WAREHOUSE_PICKUP", LocalDateTime.of(2026, 9, 12, 10, 0),
null, null, null, "FBA destination will be supplied later", false, "key-no-fba",
List.of(new WarehouseTransferRequests.Item("SKU-1", "Product", 5, "MARK-1")));
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.create("TENANT-A", "USER-1", request));
when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L);
when(orders.selectList(any())).thenReturn(List.of(historicalOrder()));
when(transfers.findNo(eq("TENANT-A"), anyString())).thenAnswer(invocation -> transfer(WarehouseTransferService.DRAFT));
when(transferItems.selectList(any())).thenReturn(List.of());
when(sourceOrders.selectList(any())).thenReturn(List.of());
assertEquals(400, exception.getStatusCode().value());
assertEquals("仓储转运必须选择 FBA 仓或私人仓", exception.getReason());
verifyNoInteractions(transfers);
var result = service.create("TENANT-A", "USER-1", request);
assertEquals(WarehouseTransferService.DRAFT, result.status());
ArgumentCaptor<WarehouseTransferOrder> transfer = ArgumentCaptor.forClass(WarehouseTransferOrder.class);
verify(transfers).insert(transfer.capture());
assertNull(transfer.getValue().getDestinationType());
}
@Test
@@ -611,6 +618,53 @@ class WarehouseTransferServiceTest {
verify(orderDetails).insert(any(LastlegOrderDetail.class));
}
@Test
void merchantUpdatesRejectedTransferBeforeResubmission() {
WarehouseTransferOrder transfer = persistedTransfer();
transfer.setStatus(WarehouseTransferService.REJECTED);
LastlegOrder tailOrder = historicalOrder();
tailOrder.setId(31L); tailOrder.setCustomerId("TENANT-A"); tailOrder.setCurrentStatus("CANCELLED");
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(orders.selectById(31L)).thenReturn(tailOrder);
when(transferItems.selectList(any())).thenReturn(List.of());
when(sourceOrders.selectList(any())).thenReturn(List.of());
var result = service.updateDraft("TENANT-A", "WTR-1", "USER-1", request(false));
assertEquals(WarehouseTransferService.REJECTED, result.status());
assertTrue(tailOrder.getSubOrderStatus().startsWith("WAREHOUSE_TRANSFER:REJECTED"));
verify(transfers).updateById(transfer);
verify(orders).updateById(tailOrder);
}
@Test
void merchantResubmitsRejectedTransferForReview() {
WarehouseTransferOrder transfer = persistedTransfer();
transfer.setStatus(WarehouseTransferService.REJECTED);
transfer.setInboundCode("WTR-1");
LastlegOrder tailOrder = historicalOrder();
tailOrder.setId(31L); tailOrder.setCustomerId("TENANT-A"); tailOrder.setCurrentStatus("CANCELLED");
tailOrder.setRelabelStatus("NOT_REQUIRED");
LastlegInboundOrder inbound = new LastlegInboundOrder();
inbound.setId(101L); inbound.setCustomerId("TENANT-A"); inbound.setStatus(WarehouseTransferService.PENDING_REVIEW);
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(orders.selectById(31L)).thenReturn(tailOrder);
when(inboundOrders.selectById(101L)).thenReturn(inbound);
when(transferItems.selectList(any())).thenReturn(List.of());
when(sourceOrders.selectList(any())).thenReturn(List.of());
var result = service.submit("TENANT-A", "WTR-1", "USER-1");
assertEquals(WarehouseTransferService.PENDING_REVIEW, result.status());
assertEquals(WarehouseTransferService.PENDING_REVIEW, tailOrder.getCurrentStatus());
var history = ArgumentCaptor.forClass(com.baiyue.lastleg.entity.LastlegOrderStatusHistory.class);
verify(orderHistories).insert(history.capture());
assertEquals(WarehouseTransferService.REJECTED, history.getValue().getFromStatus());
assertEquals(WarehouseTransferService.PENDING_REVIEW, history.getValue().getToStatus());
}
@Test
void merchantCannotUpdateSubmittedTransfer() {
WarehouseTransferOrder transfer = persistedTransfer();