优化提交

This commit is contained in:
2026-09-14 09:58:30 +08:00
parent 5ffd6c0118
commit 8cf7c4b9c9
93 changed files with 2945 additions and 188 deletions
@@ -77,7 +77,14 @@ public class LastlegOrderController {
public ApiResponse<PageResult<LastlegOrderSummary>> list(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@RequestParam(required = false) String relabelNo,
@Valid LastlegOrderRequests.Query query) {
return ApiResponse.success(orderService.page(platformScope(user, customerId), query, relabelNo));
}
/** Compatibility overload used by non-Spring callers and existing contract tests. */
public ApiResponse<PageResult<LastlegOrderSummary>> list(
AuthenticatedUser user, String customerId, LastlegOrderRequests.Query query) {
return ApiResponse.success(orderService.page(platformScope(user, customerId), query));
}
@@ -5,6 +5,7 @@ import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface OutboundOrderMapper extends BaseMapper<OutboundOrder> {
@@ -22,4 +23,11 @@ public interface OutboundOrderMapper extends BaseMapper<OutboundOrder> {
OutboundOrder lockByNo(@Param("customerId") String customerId, @Param("outboundNo") String outboundNo);
@Select("SELECT * FROM ll_outbound_order WHERE outbound_no=#{outboundNo} LIMIT 1 FOR UPDATE")
OutboundOrder lockGlobalByNo(@Param("outboundNo") String outboundNo);
@Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} " +
"AND overseas_warehouse_id=#{warehouseId} AND outbound_type='RELABEL_OUTBOUND' " +
"AND outbound_no LIKE CONCAT('%',#{keyword},'%') ORDER BY created_at DESC,id DESC LIMIT 100")
List<OutboundOrder> listRelabelOutbounds(@Param("customerId") String customerId,
@Param("warehouseId") String warehouseId,
@Param("keyword") String keyword);
}
@@ -57,6 +57,8 @@ import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.relabel.service.RelabelApplicationService;
import com.baiyue.lastleg.relabel.dto.RelabelRequests;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
@@ -86,6 +88,7 @@ public class DeliveryTransferService {
@Resource private InventoryPositionMapper inventoryPositions;
@Resource private InventoryBalanceMapper inventoryBalances;
@Resource private InventoryCommandService inventoryCommands;
@Resource private WarehouseTransferPalletMapper warehouseTransferPallets;
@Resource private DropshipOrderProfileMapper dropshipProfiles;
@Resource private DropshipCartonMapper dropshipCartons;
@Resource private DropshipSkuInventoryMapper dropshipSkuInventory;
@@ -175,10 +178,8 @@ public class DeliveryTransferService {
item.setDestinationWarehouseCode(source.getDestinationWarehouseCode());
item.setDestinationWarehouseName(source.getDestinationWarehouseName());
item.setPackageCount(Objects.requireNonNullElse(source.getPackageCount(), 0)); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK");
List<String> requestedMarks = request.palletCartonMarks() == null ? List.of() : request.palletCartonMarks().getOrDefault(source.getPalletNo(), List.of());
if (!requestedMarks.isEmpty()) {
if (requestedMarks.size() != Objects.requireNonNullElse(source.getPackageCount(), 0)) throw conflict("Selected carton mark count must equal pallet package count");
item.setCartonMarks(String.join(",", requestedMarks.stream().distinct().toList()));
if (StringUtils.hasText(source.getCartonMarks())) {
item.setCartonMarks(source.getCartonMarks());
} else if (source.getWarehousePalletId() != null) {
String marks = inboundCartons.listByPallet(customerId, source.getWarehousePalletId()).stream()
.map(c -> c.getCartonMark()).filter(StringUtils::hasText).distinct().collect(java.util.stream.Collectors.joining(","));
@@ -802,7 +803,27 @@ public class DeliveryTransferService {
.stream().map(LastlegOrder::getLxCode).filter(StringUtils::hasText)
.map(String::trim).distinct().collect(java.util.stream.Collectors.joining(","));
}
private BigDecimal totalWeight(String customerId, List<DispatchItem> items) { List<Long> ids = items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).distinct().toList(); if (ids.isEmpty()) return BigDecimal.ZERO; return tailOrderDetails.selectList(new LambdaQueryWrapper<LastlegOrderDetail>().eq(LastlegOrderDetail::getCustomerId, customerId).in(LastlegOrderDetail::getOrderId, ids)).stream().map(LastlegOrderDetail::getGrossWeightKg).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add); }
private BigDecimal totalWeight(String customerId, List<DispatchItem> items) {
List<Long> palletIds = items.stream().map(DispatchItem::getWarehousePalletId)
.filter(Objects::nonNull).distinct().toList();
List<WarehouseTransferPallet> transferPallets = palletIds.isEmpty() ? List.of()
: warehouseTransferPallets.selectList(new LambdaQueryWrapper<WarehouseTransferPallet>()
.eq(WarehouseTransferPallet::getCustomerId, customerId)
.in(WarehouseTransferPallet::getWarehousePalletId, palletIds));
Set<Long> measuredPalletIds = transferPallets.stream()
.map(WarehouseTransferPallet::getWarehousePalletId).collect(java.util.stream.Collectors.toSet());
BigDecimal measuredWeight = transferPallets.stream().map(WarehouseTransferPallet::getGrossWeightKg)
.filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add);
List<Long> orderIds = items.stream()
.filter(item -> item.getWarehousePalletId() == null || !measuredPalletIds.contains(item.getWarehousePalletId()))
.map(DispatchItem::getOrderId).filter(Objects::nonNull).distinct().toList();
if (orderIds.isEmpty()) return measuredWeight;
BigDecimal orderWeight = tailOrderDetails.selectList(new LambdaQueryWrapper<LastlegOrderDetail>()
.eq(LastlegOrderDetail::getCustomerId, customerId).in(LastlegOrderDetail::getOrderId, orderIds))
.stream().map(LastlegOrderDetail::getGrossWeightKg).filter(Objects::nonNull)
.reduce(BigDecimal.ZERO, BigDecimal::add);
return measuredWeight.add(orderWeight);
}
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static Long parseLong(String value) {
try { return StringUtils.hasText(value) ? Long.parseLong(value.trim()) : null; }
@@ -38,6 +38,17 @@ public class DispatchController {
return ApiResponse.success(service.update(dispatchNo, request));
}
@DeleteMapping("/{dispatchNo}/items/{itemId}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_EDIT + "')")
@Operation(summary = "Remove one unpicked pallet before an outbound order is generated")
public ApiResponse<DispatchResponse> removeItem(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo,
@PathVariable Long itemId) {
requirePlatform(user);
return ApiResponse.success(service.removeItem(dispatchNo, itemId));
}
@DeleteMapping("/{dispatchNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_CANCEL + "')")
@Operation(summary = "Cancel a pending dispatch and release its pallet locks")
@@ -74,7 +74,9 @@ public class SmartDispatchController {
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody DispatchRequests.PreArrange request) {
requirePlatform(user);
String scopedCustomer = service.resolveCustomerScopeForPallets(request.planPalletIds());
String scopedCustomer = request.relabelNo() == null || request.relabelNo().isBlank()
? service.resolveCustomerScopeForPallets(request.planPalletIds())
: service.resolveCustomerScopeForRelabel(request.relabelNo());
String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId());
return ApiResponse.success(service.preArrange(scopedCustomer, operatorId, request));
}
@@ -53,7 +53,18 @@ public interface DispatchCandidateMapper {
"ELSE a.planned_package_count END),0) END AS packageCount,p.allocated_volume_cbm AS volumeCbm," +
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," +
"(SELECT GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') " +
"FROM ll_inbound_plan_carton c WHERE c.plan_pallet_id=p.id) AS cartonMarks " +
"FROM ll_inbound_plan_carton c WHERE c.plan_pallet_id=p.id AND NOT EXISTS (" +
"SELECT 1 FROM ll_relabel_item ri JOIN ll_inventory_position rp ON rp.id=ri.source_inventory_position_id " +
"JOIN ll_relabel_application ra ON ra.id=ri.application_id AND ra.customer_id=ri.customer_id " +
"WHERE ri.customer_id=ip.customer_id AND rp.pallet_id=p.warehouse_pallet_id " +
"AND ri.carton_mark=c.carton_mark AND ri.fba_carton_mark IS NOT NULL AND ri.fba_carton_mark != '' " +
"AND ra.status NOT IN ('REJECTED','CANCELLED'))) AS cartonMarks," +
"(SELECT GROUP_CONCAT(DISTINCT ri.fba_carton_mark ORDER BY ri.fba_carton_mark SEPARATOR ',') " +
"FROM ll_relabel_item ri JOIN ll_inventory_position rp ON rp.id=ri.source_inventory_position_id " +
"JOIN ll_relabel_application ra ON ra.id=ri.application_id AND ra.customer_id=ri.customer_id " +
"WHERE ri.customer_id=ip.customer_id AND rp.pallet_id=p.warehouse_pallet_id " +
"AND ri.fba_carton_mark IS NOT NULL AND ri.fba_carton_mark != '' " +
"AND ra.status NOT IN ('REJECTED','CANCELLED')) AS relabelCartonMarks " +
"FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " +
"LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " +
"LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id " +
@@ -81,6 +92,9 @@ public interface DispatchCandidateMapper {
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
"JOIN ll_inventory_position tp ON tp.id=ta.inventory_position_id " +
"WHERE ta.customer_id=ip.customer_id AND ta.status='RESERVED' AND tp.pallet_id=p.warehouse_pallet_id) " +
"AND NOT EXISTS (SELECT 1 FROM ll_relabel_item rli JOIN ll_relabel_application rla ON rla.id=rli.application_id AND rla.customer_id=rli.customer_id " +
"WHERE rli.customer_id=ip.customer_id AND rli.source_inbound_order_id IS NOT NULL " +
"AND rli.source_inbound_order_id=o.id AND rla.status NOT IN ('REJECTED','CANCELLED','COMPLETED')) " +
"GROUP BY p.plan_id,p.id,p.warehouse_pallet_id,p.pallet_no,p.destination_warehouse_id," +
"p.destination_warehouse_code,p.destination_warehouse_name,p.allocated_volume_cbm " +
"ORDER BY p.plan_id,p.sequence_no" +
@@ -121,7 +135,18 @@ public interface DispatchCandidateMapper {
"ELSE a.planned_package_count END),0) END AS packageCount,p.allocated_volume_cbm AS volumeCbm," +
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," +
"(SELECT GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') " +
"FROM ll_inbound_plan_carton c WHERE c.plan_pallet_id=p.id) AS cartonMarks " +
"FROM ll_inbound_plan_carton c WHERE c.plan_pallet_id=p.id AND NOT EXISTS (" +
"SELECT 1 FROM ll_relabel_item ri JOIN ll_inventory_position rp ON rp.id=ri.source_inventory_position_id " +
"JOIN ll_relabel_application ra ON ra.id=ri.application_id AND ra.customer_id=ri.customer_id " +
"WHERE ri.customer_id=ip.customer_id AND rp.pallet_id=p.warehouse_pallet_id " +
"AND ri.carton_mark=c.carton_mark AND ri.fba_carton_mark IS NOT NULL AND ri.fba_carton_mark != '' " +
"AND ra.status NOT IN ('REJECTED','CANCELLED'))) AS cartonMarks," +
"(SELECT GROUP_CONCAT(DISTINCT ri.fba_carton_mark ORDER BY ri.fba_carton_mark SEPARATOR ',') " +
"FROM ll_relabel_item ri JOIN ll_inventory_position rp ON rp.id=ri.source_inventory_position_id " +
"JOIN ll_relabel_application ra ON ra.id=ri.application_id AND ra.customer_id=ri.customer_id " +
"WHERE ri.customer_id=ip.customer_id AND rp.pallet_id=p.warehouse_pallet_id " +
"AND ri.fba_carton_mark IS NOT NULL AND ri.fba_carton_mark != '' " +
"AND ra.status NOT IN ('REJECTED','CANCELLED')) AS relabelCartonMarks " +
"FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " +
"LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " +
"LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id " +
@@ -137,6 +162,11 @@ public interface DispatchCandidateMapper {
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
"JOIN ll_inventory_position tp ON tp.id=ta.inventory_position_id " +
"WHERE ta.customer_id=#{customerId} AND ta.status='RESERVED' AND tp.pallet_id=p.warehouse_pallet_id) " +
"AND NOT EXISTS (SELECT 1 FROM ll_relabel_item rli JOIN ll_relabel_application rla ON rla.id=rli.application_id AND rla.customer_id=rli.customer_id " +
"JOIN ll_inbound_plan_line rpl ON rpl.order_id=rli.source_inbound_order_id " +
"WHERE rli.customer_id=#{customerId} AND rli.source_inbound_order_id IS NOT NULL AND rpl.id IN " +
"(SELECT pa.line_id FROM ll_inbound_plan_pallet_allocation pa WHERE pa.pallet_id=p.id) " +
"AND rla.status NOT IN ('REJECTED','CANCELLED','COMPLETED')) " +
"GROUP BY p.plan_id,p.id,p.warehouse_pallet_id,p.pallet_no,p.destination_warehouse_id," +
"p.destination_warehouse_code,p.destination_warehouse_name,p.allocated_volume_cbm " +
"ORDER BY p.id FOR UPDATE" +
@@ -148,20 +178,27 @@ public interface DispatchCandidateMapper {
"SELECT NULL AS planId,NULL AS planPalletId,pos.pallet_id AS warehousePalletId,a.lastleg_order_id AS orderId," +
"a.customer_id AS customerId,NULL AS customerName,NULL AS planNo,'RELABEL_APPLICATION' AS orderType,NULL AS volumeWeightKg,NULL AS containerNo," +
"NULL AS appointmentAt,NULL AS expectedLoadingAt,a.overseas_warehouse_id AS overseasWarehouseId,MIN(ri.source_warehouse_name) AS overseasWarehouseName," +
"MIN(a.relabel_no) AS orderNo,a.relabel_no AS orderRefs,MIN(ri.source_inbound_code) AS inboundCode,pos.pallet_no AS palletNo,NULL AS destinationWarehouseId," +
"MIN(COALESCE(NULLIF(ri.source_inbound_order_no,''),NULLIF(a.customer_order_no,''),a.relabel_no)) AS orderNo,a.relabel_no AS orderRefs,MIN(ri.source_inbound_code) AS inboundCode,pos.pallet_no AS palletNo,NULL AS destinationWarehouseId," +
"GROUP_CONCAT(DISTINCT ri.destination_fba_warehouse_code ORDER BY ri.destination_fba_warehouse_code SEPARATOR ',') AS destinationWarehouseCode," +
"GROUP_CONCAT(DISTINCT ri.destination_fba_warehouse_code ORDER BY ri.destination_fba_warehouse_code SEPARATOR ',') AS destinationWarehouseName," +
"GROUP_CONCAT(DISTINCT ri.destination_fba_warehouse_code ORDER BY ri.destination_fba_warehouse_code SEPARATOR ',') AS fbaWarehouseCode," +
"SUM(ri.notified_package_count) AS packageCount,NULL AS volumeCbm,'RELABEL' AS sourceStage," +
"GROUP_CONCAT(DISTINCT CONCAT(ri.carton_mark,'->',ri.fba_carton_mark) ORDER BY ri.carton_mark SEPARATOR ',') AS cartonMarks " +
"SUM(ri.notified_package_count) AS packageCount,MAX(wtp.volume_cbm) AS volumeCbm,'RELABEL' AS sourceStage," +
"NULL AS cartonMarks,GROUP_CONCAT(DISTINCT COALESCE(NULLIF(ri.fba_carton_mark,''),ri.carton_mark) " +
"ORDER BY COALESCE(NULLIF(ri.fba_carton_mark,''),ri.carton_mark) SEPARATOR ',') AS relabelCartonMarks " +
"FROM ll_relabel_application a JOIN ll_relabel_item ri ON ri.application_id=a.id AND ri.customer_id=a.customer_id " +
"JOIN ll_inventory_position pos ON pos.id=ri.source_inventory_position_id AND pos.customer_id=a.customer_id " +
"LEFT JOIN ll_warehouse_transfer_order wto ON wto.relabel_no=a.relabel_no AND wto.customer_id=a.customer_id " +
"LEFT JOIN ll_warehouse_transfer_pallet wtp ON wtp.transfer_order_id=wto.id AND wtp.customer_id=a.customer_id " +
"AND wtp.warehouse_pallet_id=pos.pallet_id " +
"WHERE a.delivery_method='TRUCK' AND a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' " +
"<if test='customerId != null and customerId != \"\"'>AND a.customer_id=#{customerId} </if>" +
"<if test='relabelNo != null and relabelNo != \"\"'>AND a.relabel_no=#{relabelNo} </if>" +
"<if test='orderNo != null and orderNo != \"\"'>AND a.relabel_no LIKE CONCAT('%',#{orderNo},'%') </if>" +
"AND pos.available_packages &gt; 0 " +
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE di.customer_id=a.customer_id AND di.source_stage='RELABEL' AND di.order_refs=a.relabel_no) " +
"AND pos.hold_packages &gt; 0 " +
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_item di JOIN ll_dispatch_order existing_dispatch " +
"ON existing_dispatch.id=di.dispatch_order_id AND existing_dispatch.customer_id=di.customer_id " +
"WHERE di.customer_id=a.customer_id AND di.source_stage='RELABEL' AND di.order_refs=a.relabel_no " +
"AND existing_dispatch.status!='CANCELLED') " +
"GROUP BY a.id,a.lastleg_order_id,a.customer_id,a.overseas_warehouse_id,a.relabel_no,pos.pallet_id,pos.pallet_no" +
"</script>")
List<DispatchPalletCandidate> relabelAvailable(@Param("customerId") String customerId,
@@ -171,4 +208,8 @@ public interface DispatchCandidateMapper {
@org.apache.ibatis.annotations.Update("UPDATE ll_relabel_application SET fulfillment_status='PRE_DISPATCH_CREATED',updated_at=CURRENT_TIMESTAMP(3) " +
"WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} AND fulfillment_status='WAIT_PRE_DISPATCH'")
int markRelabelPreDispatched(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo);
@org.apache.ibatis.annotations.Update("UPDATE ll_relabel_application SET fulfillment_status='WAIT_PRE_DISPATCH',updated_at=CURRENT_TIMESTAMP(3) " +
"WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} AND fulfillment_status='PRE_DISPATCH_CREATED'")
int restoreRelabelPreDispatch(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo);
}
@@ -10,7 +10,13 @@ import java.util.List;
@Mapper
public interface DispatchDraftMapper extends BaseMapper<DispatchDraft> {
@Select("SELECT * FROM ll_dispatch_draft WHERE operator_id=#{operatorId} AND status='ACTIVE' AND (expires_at IS NULL OR expires_at>CURRENT_TIMESTAMP(3)) ORDER BY updated_at DESC")
@Select("SELECT d.* FROM ll_dispatch_draft d WHERE d.operator_id=#{operatorId} AND d.status='ACTIVE' " +
"AND (d.expires_at IS NULL OR d.expires_at>CURRENT_TIMESTAMP(3)) AND NOT EXISTS (" +
"SELECT 1 FROM ll_dispatch_pallet_lock l JOIN ll_outbound_order o ON o.customer_id=l.customer_id " +
"AND o.dispatch_order_id=l.dispatch_order_id WHERE l.draft_token=d.draft_token) AND NOT EXISTS (" +
"SELECT 1 FROM ll_dispatch_pallet_lock l JOIN ll_dispatch_order x ON x.id=l.dispatch_order_id " +
"AND x.customer_id=l.customer_id WHERE l.draft_token=d.draft_token AND x.status!='PENDING_DEPARTURE') " +
"ORDER BY d.updated_at DESC")
List<DispatchDraft> findActiveByOperator(@Param("operatorId") String operatorId);
@Select("SELECT * FROM ll_dispatch_draft WHERE draft_token=#{draftToken} AND operator_id=#{operatorId} AND status='ACTIVE' LIMIT 1 FOR UPDATE")
@@ -15,6 +15,18 @@ public interface DispatchItemMapper extends BaseMapper<DispatchItem> {
List<DispatchItem> findByDispatchOrder(@Param("customerId") String customerId,
@Param("dispatchOrderId") Long dispatchOrderId);
@Select("SELECT * FROM ll_dispatch_item WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} " +
"AND id=#{itemId} LIMIT 1 FOR UPDATE")
DispatchItem lockById(@Param("customerId") String customerId,
@Param("dispatchOrderId") Long dispatchOrderId,
@Param("itemId") Long itemId);
@Select("SELECT * FROM ll_dispatch_item WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} " +
"AND plan_pallet_id=#{planPalletId} LIMIT 1 FOR UPDATE")
DispatchItem lockByPlanPallet(@Param("customerId") String customerId,
@Param("dispatchOrderId") Long dispatchOrderId,
@Param("planPalletId") Long planPalletId);
@Update("UPDATE ll_dispatch_item i JOIN ll_inbound_plan_pallet p ON p.id=i.plan_pallet_id " +
"JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=i.customer_id " +
"SET i.warehouse_pallet_id=p.warehouse_pallet_id,i.source_stage='RECEIVED' " +
@@ -17,6 +17,11 @@ public interface DispatchPalletLockMapper extends BaseMapper<DispatchPalletLock>
List<DispatchPalletLock> findActiveByDraftContainer(@Param("draftToken") String draftToken,
@Param("draftContainerId") Long draftContainerId);
@Select("SELECT l.dispatch_order_id FROM ll_dispatch_pallet_lock l JOIN ll_dispatch_order d " +
"ON d.id=l.dispatch_order_id AND d.customer_id=l.customer_id AND d.status='PENDING_DEPARTURE' " +
"WHERE l.draft_token=#{draftToken} ORDER BY l.updated_at DESC,l.id DESC LIMIT 1")
Long findDispatchOrderIdByDraft(@Param("draftToken") String draftToken);
@Select("SELECT * FROM ll_dispatch_pallet_lock WHERE customer_id=#{customerId} AND source_pallet_key=#{sourcePalletKey} " +
"AND status='ACTIVE' LIMIT 1 FOR UPDATE")
DispatchPalletLock lockActive(@Param("customerId") String customerId,
@@ -29,7 +29,8 @@ public record DispatchPalletCandidate(
Integer packageCount,
BigDecimal volumeCbm,
String sourceStage,
String cartonMarks
String cartonMarks,
String relabelCartonMarks
) {
public DispatchPalletCandidate(Long planId, Long planPalletId, Long warehousePalletId, Long orderId,
String overseasWarehouseId, String overseasWarehouseName, String orderNo,
@@ -40,7 +41,7 @@ public record DispatchPalletCandidate(
this(planId, planPalletId, warehousePalletId, orderId, null, null, null, null, null, null, null, null, overseasWarehouseId,
overseasWarehouseName, orderNo, orderRefs, inboundCode, palletNo, destinationWarehouseId,
destinationWarehouseCode, destinationWarehouseName, fbaWarehouseCode, packageCount, volumeCbm,
sourceStage, null);
sourceStage, null, null);
}
public DispatchPalletCandidate(Long planId, Long planPalletId, Long warehousePalletId, Long orderId,
@@ -50,7 +51,7 @@ public record DispatchPalletCandidate(
BigDecimal volumeCbm, String sourceStage) {
this(planId, planPalletId, warehousePalletId, orderId, null, null, null, null, null, null, null, null, null, null, orderNo, orderRefs, inboundCode,
palletNo, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName,
fbaWarehouseCode, packageCount, volumeCbm, sourceStage, null);
fbaWarehouseCode, packageCount, volumeCbm, sourceStage, null, null);
}
public String sourcePalletKey() {
// A plan pallet keeps the same lock identity before and after the physical pallet is received.
@@ -11,8 +11,10 @@ import java.util.List;
@Mapper
public interface DispatchPdaPickOrderMapper extends BaseMapper<DispatchPdaPickOrder> {
@Select("SELECT r.* FROM ll_inbound_stock_receipt r " +
"WHERE r.customer_id=#{customerId} AND r.inventory_position_id=#{positionId} " +
@Select("SELECT DISTINCT r.* FROM ll_inventory_position current_position " +
"JOIN ll_inventory_position receipt_position ON receipt_position.balance_id=current_position.balance_id " +
"JOIN ll_inbound_stock_receipt r ON r.inventory_position_id=receipt_position.id " +
"WHERE current_position.id=#{positionId} AND r.customer_id=#{customerId} " +
"AND r.received_packages > (SELECT COALESCE(SUM(a.packages),0) FROM ll_dispatch_pda_pick_order a " +
"WHERE a.customer_id=r.customer_id AND a.receipt_id=r.id) ORDER BY r.received_at,r.id")
List<InboundStockReceipt> availableReceipts(@Param("customerId") String customerId,
@@ -15,8 +15,11 @@ import com.baiyue.lastleg.dispatch.pda.entity.*;
import com.baiyue.lastleg.dispatch.pda.mapper.*;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.relabel.dto.RelabelRequests;
import com.baiyue.lastleg.relabel.service.RelabelApplicationService;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
@@ -41,6 +44,8 @@ public class DispatchPdaWorkflowService {
@Resource private DispatchPalletLockMapper palletLocks;
@Resource private InventoryPositionMapper positions;
@Resource private InventoryCommandService inventory;
@Resource private RelabelApplicationService relabelApplications;
@Resource private WarehouseTransferOrderMapper warehouseTransfers;
@Resource private DispatchPickOrderAttributionService pickAttribution;
@Resource private ObjectMapper objectMapper;
@@ -111,6 +116,11 @@ public class DispatchPdaWorkflowService {
InventoryCommandRequests.Move move = new InventoryCommandRequests.Move(request.positionId(),
request.packages(), order.getDispatchNo(), order.getDispatchNo(), null,
keyBase + ":ALLOCATE", request.remark());
if ("RELABEL_ORDER".equals(item.getSourceBusinessType())) {
inventory.release(customerId, operatorId, new InventoryCommandRequests.Move(request.positionId(),
request.packages(), item.getSourceBusinessNo(), order.getDispatchNo(), null,
keyBase + ":RELABEL_RELEASE", "换标排车拣货前释放冻结库存"));
}
inventory.allocate(customerId, operatorId, move);
inventory.pick(customerId, operatorId, new InventoryCommandRequests.Move(request.positionId(),
request.packages(), order.getDispatchNo(), order.getDispatchNo(), null,
@@ -125,7 +135,8 @@ public class DispatchPdaWorkflowService {
pick.setDispatchItemId(itemId); pick.setInventoryPositionId(request.positionId()); pick.setPackages(request.packages());
pick.setProductQuantity(request.productQuantity()); pick.setIdempotencyKey(request.idempotencyKey());
pick.setPickedAt(LocalDateTime.now()); pick.setCreatedAt(pick.getPickedAt()); picks.insert(pick);
pickAttribution.attribute(customerId, pick);
pickAttribution.attribute(customerId, pick,
"RELABEL_ORDER".equals(item.getSourceBusinessType()) ? item.getOrderId() : null);
long total = picked + request.packages();
order.setPickedPackageCount((int) (n(order.getPickedPackageCount()) + request.packages()));
task.setStatus("PICKING"); task.setUpdatedAt(LocalDateTime.now());
@@ -216,11 +227,22 @@ public class DispatchPdaWorkflowService {
long shipped = dispatchPicks.stream().mapToLong(p -> n(p.getPackages())).sum();
LocalDateTime now = LocalDateTime.now();
pickAttribution.depart(customerId, outbound.getOutboundNo(), order.getDispatchNo(), dispatchPicks, now);
for (DispatchItem item : dispatchItems) {
if (item.getOrderId() != null) warehouseTransfers.markOutboundByOrder(customerId, item.getOrderId(), now);
if ("RELABEL_ORDER".equals(item.getSourceBusinessType()) && item.getSourceBusinessNo() != null) {
warehouseTransfers.markOutboundByRelabel(customerId, item.getSourceBusinessNo(), now);
}
}
dispatchItems.stream().filter(item -> "RELABEL_ORDER".equals(item.getSourceBusinessType()))
.map(DispatchItem::getSourceBusinessNo).filter(Objects::nonNull).distinct().forEach(relabelNo ->
relabelApplications.changeStatus(customerId, relabelNo, operatorId,
new RelabelRequests.ChangeStatus("COMPLETED", "换标排车PDA发车完成")));
outbound.setStatus("OUTBOUNDED");
outbound.setUpdatedAt(now);
outboundOrders.updateById(outbound);
order.setShippedPackageCount((int) shipped);
order.setStatus("DEPARTED");
order.setDepartAt(now);
order.setUpdatedAt(now);
orders.updateById(order);
releasePalletLocks(customerId, order.getId(), now);
@@ -235,11 +257,13 @@ public class DispatchPdaWorkflowService {
for (DispatchItem item : source) {
List<InventoryPosition> palletPositions = item.getWarehousePalletId() == null ? List.of()
: positions.listPallet(customerId, item.getWarehousePalletId());
boolean relabel = "RELABEL_ORDER".equals(item.getSourceBusinessType());
values.add(new DispatchPdaResponses.Item(item.getId(), item.getPalletNo(), item.getDestinationWarehouseCode(),
item.getDestinationWarehouseName(), item.getFbaWarehouseCode(),
warehouseRouteLabel(item.getFbaWarehouseCode(), item.getDestinationWarehouseCode()), item.getPackageCount(),
picks.totalItem(customerId, task.getId(), item.getId()), palletPositions.stream().map(p ->
new DispatchPdaResponses.Position(p.getId(), p.getBalanceId(), n(p.getAvailablePackages()),
new DispatchPdaResponses.Position(p.getId(), p.getBalanceId(), n(p.getAvailablePackages())
+ (relabel ? n(p.getHoldPackages()) : 0),
n(p.getAllocatedPackages()), n(p.getPickedPackages()))).toList()));
}
OutboundOrder outbound = outboundOrders.findByDispatch(customerId, order.getId());
@@ -27,6 +27,15 @@ public class DispatchPickOrderAttributionService {
/** Call after the inventory position has been locked/moved and the PDA pick row has been inserted. */
public List<OrderPackages> attribute(String customerId, DispatchPdaPick pick) {
return attribute(customerId, pick, null);
}
/**
* Relabel inventory created before receipt-detail backfill may have no
* ll_inbound_stock_receipt row. Its dispatch item still carries the
* authoritative tail-leg order, so use that order only for relabel picks.
*/
public List<OrderPackages> attribute(String customerId, DispatchPdaPick pick, Long fallbackOrderId) {
long remainingPackages = value(pick.getPackages());
long totalPackages = remainingPackages;
long totalQuantity = value(pick.getProductQuantity());
@@ -58,6 +67,10 @@ public class DispatchPickOrderAttributionService {
allocatedPackages = nextPackages;
allocatedQuantity = nextQuantity;
}
if (remainingPackages != 0 && fallbackOrderId != null && fallbackOrderId > 0) {
pickedByOrder.merge(fallbackOrderId, remainingPackages, Long::sum);
remainingPackages = 0;
}
if (remainingPackages != 0) throw conflict("PDA pick cannot be attributed to inbound receipt orders");
for (Map.Entry<Long, Long> entry : pickedByOrder.entrySet()) {
if (orders.increasePicked(customerId, entry.getKey(), entry.getValue()) != 1) {
@@ -24,6 +24,7 @@ import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask;
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
@@ -36,7 +37,9 @@ import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import java.util.Arrays;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
@@ -56,6 +59,7 @@ public class DispatchService {
@Resource private DispatchPdaTaskMapper pdaTasks;
@Resource private LastlegOrderNoGenerator orderNoGenerator;
@Resource private LastlegOrderMapper tailOrders;
@Resource private OutboundOrderMapper outboundOrders;
public String resolveCustomerScopeForPlans(List<Long> planIds) {
return requireSingleCustomerScope(candidates.findCustomerScopesByPlanIds(normalizeIds(planIds, "Plan IDs")),
@@ -68,6 +72,12 @@ public class DispatchService {
"Selected pallets");
}
public String resolveCustomerScopeForRelabel(String relabelNo) {
List<DispatchPalletCandidate> values = candidates.relabelAvailable(null, null, trimToNull(relabelNo));
return requireSingleCustomerScope(values.stream().map(DispatchPalletCandidate::customerId)
.filter(StringUtils::hasText).distinct().toList(), "Selected relabel order");
}
public List<DispatchPalletCandidate> candidates(String customerId, List<Long> planIds, String orderNo,
String fbaWarehouseCode, String sourceStage, String draftToken) {
validateSourceStage(sourceStage);
@@ -191,6 +201,15 @@ public class DispatchService {
DispatchPalletLock active = activeLock(customerId, candidate);
if (active == null) return;
requireDraftOwner(active, operatorId, request.draftToken());
if (active.getDispatchOrderId() != null) {
DispatchOrder dispatch = orders.lockById(customerId, active.getDispatchOrderId());
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
requirePalletEditingAllowed(dispatch);
DispatchItem item = items.lockByPlanPallet(customerId, dispatch.getId(), request.planPalletId());
if (item != null) removeLockedItem(dispatch, item, List.of(active));
else releaseLock(active, LocalDateTime.now());
return;
}
releaseLock(active, LocalDateTime.now());
}
@@ -199,7 +218,8 @@ public class DispatchService {
List<DispatchPalletLock> activeLocks = locks.selectList(new LambdaQueryWrapper<DispatchPalletLock>()
.eq(DispatchPalletLock::getDraftToken, draftToken)
.eq(DispatchPalletLock::getOperatorId, trimToNull(operatorId))
.eq(DispatchPalletLock::getStatus, "ACTIVE"));
.eq(DispatchPalletLock::getStatus, "ACTIVE")
.isNull(DispatchPalletLock::getDispatchOrderId));
LocalDateTime now = LocalDateTime.now();
activeLocks.forEach(lock -> releaseLock(lock, now));
draftContainers.delete(new LambdaQueryWrapper<DispatchDraftContainer>().eq(DispatchDraftContainer::getDraftToken, draftToken));
@@ -262,35 +282,60 @@ public class DispatchService {
}
LocalDateTime now = LocalDateTime.now();
DispatchOrder dispatch = new DispatchOrder();
dispatch.setId(IdWorker.getId());
dispatch.setCustomerId(customerId);
dispatch.setDispatchNo(orderNoGenerator.next("DISPATCH", selected.get(0).overseasWarehouseId(), dispatch.getId()));
dispatch.setOverseasWarehouseId(selected.get(0).overseasWarehouseId());
dispatch.setOverseasWarehouseName(selected.get(0).overseasWarehouseName());
Long existingDispatchId = draftToken == null ? null : locks.findDispatchOrderIdByDraft(draftToken);
DispatchOrder dispatch = existingDispatchId == null ? null : orders.lockById(customerId, existingDispatchId);
boolean refreshing = dispatch != null;
if (refreshing) {
requirePalletEditingAllowed(dispatch);
} else {
dispatch = new DispatchOrder();
dispatch.setId(IdWorker.getId());
dispatch.setCustomerId(customerId);
dispatch.setDispatchNo(orderNoGenerator.next("DISPATCH", selected.get(0).overseasWarehouseId(), dispatch.getId()));
dispatch.setOverseasWarehouseId(selected.get(0).overseasWarehouseId());
dispatch.setOverseasWarehouseName(selected.get(0).overseasWarehouseName());
dispatch.setStatus(PENDING_DEPARTURE);
dispatch.setPickedPackageCount(0);
dispatch.setShippedPackageCount(0);
dispatch.setCreatedBy(trimToNull(operatorId));
dispatch.setCreatedAt(now);
}
dispatch.setRoute(StringUtils.hasText(request.route()) ? request.route().trim() : route(selected));
dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo()));
dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus()));
dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus()));
dispatch.setStatus(PENDING_DEPARTURE);
dispatch.setItemCount(selected.size());
dispatch.setPalletCount(selected.size());
dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum());
dispatch.setPickedPackageCount(0);
dispatch.setShippedPackageCount(0);
dispatch.setCreatedBy(trimToNull(operatorId));
dispatch.setCreatedAt(now);
dispatch.setUpdatedAt(now);
orders.insert(dispatch);
if (refreshing) orders.updateById(dispatch); else orders.insert(dispatch);
List<DispatchItem> previousItems = items.findByDispatchOrder(customerId, dispatch.getId());
Set<Long> selectedPalletIds = selected.stream().map(DispatchPalletCandidate::planPalletId)
.collect(Collectors.toSet());
if (refreshing) {
List<DispatchPalletLock> previousLocks = locks.selectList(new LambdaQueryWrapper<DispatchPalletLock>()
.eq(DispatchPalletLock::getCustomerId, customerId)
.eq(DispatchPalletLock::getDispatchOrderId, dispatch.getId())
.eq(DispatchPalletLock::getStatus, "ACTIVE"));
previousItems.stream().filter(item -> item.getPlanPalletId() != null
&& !selectedPalletIds.contains(item.getPlanPalletId()))
.forEach(item -> {
previousLocks.stream().filter(lock -> Objects.equals(lock.getPlanPalletId(), item.getPlanPalletId()))
.forEach(lock -> releaseLock(lock, now));
items.deleteById(item.getId());
});
}
Set<Long> existingPalletIds = previousItems.stream()
.filter(item -> item.getPlanPalletId() == null || selectedPalletIds.contains(item.getPlanPalletId()))
.map(DispatchItem::getPlanPalletId).filter(Objects::nonNull).collect(Collectors.toSet());
for (DispatchPalletCandidate candidate : selected) {
DispatchPalletLock lock = activeLock(customerId, candidate);
if (draftToken != null) {
requireDraftOwner(lock, operatorId, draftToken);
lock.setDispatchOrderId(dispatch.getId());
lock.setDraftContainerId(null);
lock.setDraftToken(null);
lock.setExpiresAt(null);
lock.setUpdatedAt(now);
locks.updateById(lock);
@@ -316,6 +361,7 @@ public class DispatchService {
"Pallet is already locked by another active dispatch: " + candidate.palletNo(), exception);
}
}
if (existingPalletIds.contains(candidate.planPalletId())) continue;
DispatchItem item = new DispatchItem();
item.setId(IdWorker.getId());
item.setDispatchOrderId(dispatch.getId());
@@ -337,27 +383,27 @@ public class DispatchService {
item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0));
item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO));
item.setSourceStage(candidate.sourceStage());
item.setCartonMarks(mergeCartonMarks(candidate.cartonMarks(), candidate.relabelCartonMarks()));
item.setCreatedAt(now);
items.insert(item);
}
DispatchPdaTask pdaTask = new DispatchPdaTask();
pdaTask.setId(IdWorker.getId());
pdaTask.setCustomerId(customerId);
pdaTask.setDispatchOrderId(dispatch.getId());
pdaTask.setTaskNo("DPDA" + pdaTask.getId());
pdaTask.setStatus("PDA_PENDING");
pdaTask.setWarehouseId(dispatch.getOverseasWarehouseId());
pdaTask.setPushedAt(now);
pdaTask.setCreatedAt(now);
pdaTask.setUpdatedAt(now);
pdaTasks.insert(pdaTask);
if (!refreshing) {
DispatchPdaTask pdaTask = new DispatchPdaTask();
pdaTask.setId(IdWorker.getId());
pdaTask.setCustomerId(customerId);
pdaTask.setDispatchOrderId(dispatch.getId());
pdaTask.setTaskNo("DPDA" + pdaTask.getId());
pdaTask.setStatus("PDA_PENDING");
pdaTask.setWarehouseId(dispatch.getOverseasWarehouseId());
pdaTask.setPushedAt(now);
pdaTask.setCreatedAt(now);
pdaTask.setUpdatedAt(now);
pdaTasks.insert(pdaTask);
}
if (draftToken != null) {
draftContainers.delete(new LambdaQueryWrapper<DispatchDraftContainer>()
.eq(DispatchDraftContainer::getDraftToken, draftToken));
DispatchDraft draft = drafts.lockActive(draftToken, trimToNull(operatorId));
if (draft != null) {
draft.setStatus("RELEASED");
draft.setReleasedAt(now);
draft.setExpiresAt(null);
draft.setUpdatedAt(now);
drafts.updateById(draft);
}
@@ -377,7 +423,7 @@ public class DispatchService {
DispatchOrder dispatch = new DispatchOrder(); dispatch.setId(IdWorker.getId()); dispatch.setCustomerId(customerId);
dispatch.setDispatchNo(orderNoGenerator.next("DISPATCH", warehouse, dispatch.getId())); dispatch.setOverseasWarehouseId(warehouse); dispatch.setOverseasWarehouseName(selected.get(0).overseasWarehouseName());
dispatch.setRoute(StringUtils.hasText(request.route()) ? request.route().trim() : route(selected)); dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); dispatch.setStatus(PENDING_DEPARTURE); dispatch.setItemCount(selected.size()); dispatch.setPalletCount(selected.size()); dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); dispatch.setPickedPackageCount(0); dispatch.setShippedPackageCount(0); dispatch.setCreatedBy(trimToNull(operatorId)); dispatch.setCreatedAt(now); dispatch.setUpdatedAt(now); orders.insert(dispatch);
for (DispatchPalletCandidate candidate : selected) { DispatchItem item = new DispatchItem(); item.setId(IdWorker.getId()); item.setDispatchOrderId(dispatch.getId()); item.setCustomerId(customerId); item.setWarehousePalletId(candidate.warehousePalletId()); item.setOrderId(candidate.orderId()); item.setOverseasWarehouseId(candidate.overseasWarehouseId()); item.setOverseasWarehouseName(candidate.overseasWarehouseName()); item.setOrderNo(candidate.orderNo()); item.setOrderRefs(candidate.orderRefs()); item.setInboundCode(candidate.inboundCode()); item.setPalletNo(candidate.palletNo()); item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); item.setDestinationWarehouseName(candidate.destinationWarehouseName()); item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); item.setVolumeCbm(BigDecimal.ZERO); item.setSourceStage("RELABEL"); item.setCartonMarks(candidate.cartonMarks()); item.setSourceBusinessType("RELABEL_ORDER"); item.setSourceBusinessNo(request.relabelNo()); item.setCreatedAt(now); items.insert(item); }
for (DispatchPalletCandidate candidate : selected) { DispatchItem item = new DispatchItem(); item.setId(IdWorker.getId()); item.setDispatchOrderId(dispatch.getId()); item.setCustomerId(customerId); item.setWarehousePalletId(candidate.warehousePalletId()); item.setOrderId(candidate.orderId()); item.setOverseasWarehouseId(candidate.overseasWarehouseId()); item.setOverseasWarehouseName(candidate.overseasWarehouseName()); item.setOrderNo(candidate.orderNo()); item.setOrderRefs(candidate.orderRefs()); item.setInboundCode(candidate.inboundCode()); item.setPalletNo(candidate.palletNo()); item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); item.setDestinationWarehouseName(candidate.destinationWarehouseName()); item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); item.setSourceStage("RELABEL"); item.setCartonMarks(candidate.relabelCartonMarks()); item.setSourceBusinessType("RELABEL_ORDER"); item.setSourceBusinessNo(request.relabelNo()); item.setCreatedAt(now); items.insert(item); }
DispatchPdaTask task = new DispatchPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setDispatchOrderId(dispatch.getId()); task.setTaskNo("DPDA" + task.getId()); task.setStatus("PDA_PENDING"); task.setWarehouseId(warehouse); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task);
if (candidates.markRelabelPreDispatched(customerId, request.relabelNo()) != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单已被其他排车占用");
return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId()));
@@ -463,6 +509,50 @@ public class DispatchService {
return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId()));
}
@Transactional
public DispatchResponse removeItem(String dispatchNo, Long itemId) {
DispatchOrder dispatch = orders.lockGlobalByDispatchNo(trimToNull(dispatchNo));
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
requirePalletEditingAllowed(dispatch);
DispatchItem item = items.lockById(dispatch.getCustomerId(), dispatch.getId(), itemId);
if (item == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch pallet not found");
List<DispatchPalletLock> activeLocks = locks.selectList(new LambdaQueryWrapper<DispatchPalletLock>()
.eq(DispatchPalletLock::getCustomerId, dispatch.getCustomerId())
.eq(DispatchPalletLock::getDispatchOrderId, dispatch.getId())
.eq(DispatchPalletLock::getStatus, "ACTIVE"));
List<DispatchPalletLock> matchingLocks = activeLocks.stream().filter(lock -> Objects.equals(lock.getPlanPalletId(), item.getPlanPalletId())
|| (item.getWarehousePalletId() != null && Objects.equals(lock.getWarehousePalletId(), item.getWarehousePalletId())))
.toList();
return removeLockedItem(dispatch, item, matchingLocks);
}
private DispatchResponse removeLockedItem(DispatchOrder dispatch, DispatchItem item,
List<DispatchPalletLock> matchingLocks) {
LocalDateTime now = LocalDateTime.now();
matchingLocks.forEach(lock -> releaseLock(lock, now));
items.deleteById(item.getId());
List<DispatchItem> remaining = items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId());
dispatch.setItemCount(remaining.size());
dispatch.setPalletCount(remaining.size());
dispatch.setPackageCount(remaining.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull)
.mapToInt(Integer::intValue).sum());
dispatch.setUpdatedAt(now);
orders.updateById(dispatch);
return response(dispatch, remaining);
}
private void requirePalletEditingAllowed(DispatchOrder dispatch) {
if (!PENDING_DEPARTURE.equals(dispatch.getStatus())
|| Objects.requireNonNullElse(dispatch.getPickedPackageCount(), 0) > 0) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Only an unpicked pending dispatch can edit pallets");
}
if (outboundOrders.findByDispatch(dispatch.getCustomerId(), dispatch.getId()) != null) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "已生成出库单,不能再修改排车托盘");
}
}
@Transactional
public void cancel(String dispatchNo) {
DispatchOrder dispatch = orders.lockGlobalByDispatchNo(trimToNull(dispatchNo));
@@ -476,6 +566,10 @@ public class DispatchService {
dispatch.setStatus(CANCELLED);
dispatch.setUpdatedAt(now);
orders.updateById(dispatch);
items.findByDispatchOrder(customerId, dispatch.getId()).stream()
.filter(item -> "RELABEL_ORDER".equals(item.getSourceBusinessType()))
.map(DispatchItem::getSourceBusinessNo).filter(StringUtils::hasText).distinct()
.forEach(relabelNo -> candidates.restoreRelabelPreDispatch(customerId, relabelNo));
List<DispatchPalletLock> activeLocks = locks.selectList(new LambdaQueryWrapper<DispatchPalletLock>()
.eq(DispatchPalletLock::getCustomerId, customerId)
.eq(DispatchPalletLock::getDispatchOrderId, dispatch.getId())
@@ -567,6 +661,14 @@ public class DispatchService {
return StringUtils.hasText(value) ? value.trim() : null;
}
private static String mergeCartonMarks(String regularMarks, String relabelMarks) {
LinkedHashSet<String> values = new LinkedHashSet<>();
for (String source : List.of(Objects.toString(regularMarks, ""), Objects.toString(relabelMarks, ""))) {
Arrays.stream(source.split(",")).map(String::trim).filter(StringUtils::hasText).forEach(values::add);
}
return values.isEmpty() ? null : String.join(",", values);
}
private static void validateSourceStage(String sourceStage) {
if (StringUtils.hasText(sourceStage) && !Set.of("PLAN", "RECEIVED").contains(sourceStage)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "sourceStage must be PLAN or RECEIVED");
@@ -29,7 +29,6 @@ public record LastlegOrderSummary(
String transferWarehouseName,
String fbaWarehouseCode,
String containerNo,
String containerStatus,
Boolean abnormal,
Boolean detained,
Boolean heldChargeable,
@@ -46,6 +45,8 @@ public record LastlegOrderSummary(
LocalDateTime actualArrivalAt,
LocalDateTime estimatedWarehouseAt,
String inboundOrderNo,
@Schema(description = "排车状态", allowableValues = {"SCHEDULED", "UNSCHEDULED"})
String dispatchStatus,
String relabelOrderNo,
String relabelStatus,
String palletMergeFlag,
@@ -17,6 +17,15 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
String WAREHOUSE_FILTER_SQL = "(LOWER(TRIM(overseas_warehouse_code)) LIKE CONCAT('%',LOWER({0}),'%') " +
"OR LOWER(TRIM(overseas_warehouse_name)) LIKE CONCAT('%',LOWER({0}),'%'))";
@Select("<script>SELECT o.id AS orderId,CASE WHEN EXISTS (SELECT 1 FROM ll_dispatch_item di " +
"JOIN ll_dispatch_order d ON d.id=di.dispatch_order_id AND d.customer_id=di.customer_id " +
"WHERE di.customer_id=o.customer_id AND di.order_id=o.id AND d.status!='CANCELLED') " +
"THEN 'SCHEDULED' ELSE 'UNSCHEDULED' END AS dispatchStatus FROM ll_order o WHERE o.id IN " +
"<foreach collection='orderIds' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
List<DispatchStatusRow> findDispatchStatuses(@Param("orderIds") List<Long> orderIds);
record DispatchStatusRow(Long orderId, String dispatchStatus) { }
String CONTAINER_PROGRESS_FILTER = " FROM ll_order o LEFT JOIN ll_inbound_plan p " +
"ON p.customer_id=o.customer_id AND p.source_order_id=o.id " +
"WHERE o.source_container_booking_id IS NOT NULL AND o.source_container_booking_id&lt;&gt;'' " +
@@ -28,6 +28,13 @@ public class PdaPalletOperationController {
return ApiResponse.success(service.resolve(PdaWarehouseIdentity.require(user), request));
}
@PostMapping("/relabel-pallet-label")
public ApiResponse<PalletOperationResponses.PrintablePallet> createRelabelPallet(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PalletOperationRequests.NewRelabelPallet request) {
return ApiResponse.success(service.createRelabelPallet(PdaWarehouseIdentity.require(user), request));
}
@PostMapping("/assemble")
public ApiResponse<PalletOperationResponses.Operation> assemble(
@AuthenticationPrincipal AuthenticatedUser user,
@@ -13,6 +13,8 @@ public final class PalletOperationRequests {
private PalletOperationRequests() { }
public record Scan(@NotBlank @Size(max = 128) String code) { }
public record NewRelabelPallet(@NotBlank @Size(max = 64) String relabelNo,
@NotBlank @Size(max = 128) String idempotencyKey) { }
public record PalletExpectation(@NotBlank @Size(max = 128) String palletCode,
@PositiveOrZero int expectedVersion) { }
public record MoveLine(@Positive long positionId, @Positive long packages,
@@ -11,8 +11,10 @@ public final class PalletOperationResponses {
long availablePackages, long holdPackages, long allocatedPackages,
long pickedPackages, long receivingPackages, long positionVersion) { }
public record PalletContent(long palletId, String palletCode, String status, int palletVersion,
long locationId, String locationCode, String destinationWarehouseCode,
Long locationId, String locationCode, String destinationWarehouseCode,
long totalPackages, List<InventoryLine> inventory) { }
public record PrintablePallet(long palletId, String palletCode, int palletVersion,
String relabelNo, String printableHtml) { }
public record Operation(long operationId, String operationType, String idempotencyKey,
String status, boolean replayed, LocalDateTime completedAt,
List<PalletContent> pallets) {
@@ -28,6 +28,21 @@ public class HttpPalletPhysicalGateway implements PalletPhysicalGateway {
restClientProperties.getPalletPhysical());
}
@Override
public PalletReference ensureRelabelPallet(long warehouseId, String businessId) {
Map<String, Object> body = Map.of("warehouseId", warehouseId, "businessId", businessId);
ResolveEnvelope response = client.post().uri("/internal/v1/warehouse-pallets/ensure-relabel")
.header("X-Internal-Service-Token", internalToken).body(body).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> {
throw palletLookupFailure(result.getStatusCode());
}).body(ResolveEnvelope.class);
if (response == null || response.data() == null) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"Warehouse pallet service returned no usable response");
}
return response.data();
}
@Override
public PalletReference resolve(String palletCode) {
ResolveEnvelope response = client.get().uri("/internal/v1/warehouse-pallets/resolve/{code}", palletCode)
@@ -76,11 +91,12 @@ public class HttpPalletPhysicalGateway implements PalletPhysicalGateway {
}
@Override
public Transition apply(long warehouseId, PalletReference pallet, String targetStatus, long targetLocationId,
public Transition apply(long warehouseId, PalletReference pallet, String targetStatus, Long targetLocationId,
String operationKey, String reason) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("warehouseId", warehouseId); body.put("expectedVersion", pallet.version());
body.put("targetStatus", targetStatus); body.put("targetLocationId", targetLocationId);
body.put("targetStatus", targetStatus);
if (targetLocationId != null) body.put("targetLocationId", targetLocationId);
body.put("operationKey", operationKey); if (reason != null) body.put("reason", reason);
TransitionEnvelope response = client.post().uri("/internal/v1/pda-pallet-transitions/{id}", pallet.palletId())
.header("X-Internal-Service-Token", internalToken).body(body).retrieve()
@@ -1,16 +1,17 @@
package com.baiyue.lastleg.palletops.gateway;
public interface PalletPhysicalGateway {
PalletReference ensureRelabelPallet(long warehouseId, String businessId);
PalletReference resolve(String palletCode);
PalletReference resolve(long palletId);
LocationReference resolveLocation(long warehouseId, long locationId);
Transition apply(long warehouseId, PalletReference pallet, String targetStatus, long targetLocationId,
Transition apply(long warehouseId, PalletReference pallet, String targetStatus, Long targetLocationId,
String operationKey, String reason);
void compensate(long warehouseId, Transition transition);
record PalletReference(long palletId, String palletCode, long warehouseId, long locationId,
record PalletReference(long palletId, String palletCode, long warehouseId, Long locationId,
String status, String fbaWarehouseCode, java.math.BigDecimal unitPrice, int version) {
public PalletReference(long palletId, String palletCode, long warehouseId, long locationId,
public PalletReference(long palletId, String palletCode, long warehouseId, Long locationId,
String status, String fbaWarehouseCode, int version) {
this(palletId, palletCode, warehouseId, locationId, status, fbaWarehouseCode,
java.math.BigDecimal.ZERO, version);
@@ -18,6 +19,6 @@ public interface PalletPhysicalGateway {
}
record LocationReference(long locationId, String locationCode, String locationType, String status) { }
record Transition(String operationKey, long palletId, String palletCode, long warehouseId,
String beforeStatus, String afterStatus, long beforeLocationId, long afterLocationId,
String beforeStatus, String afterStatus, Long beforeLocationId, Long afterLocationId,
int beforeVersion, int afterVersion, String operationStatus, boolean replayed) { }
}
@@ -30,6 +30,7 @@ import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.DefaultTransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.web.util.HtmlUtils;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
@@ -77,6 +78,30 @@ public class PdaPalletOperationService {
return content(identity.overseasWarehouseId(), pallet);
}
public PalletOperationResponses.PrintablePallet createRelabelPallet(PdaWarehouseIdentity identity,
PalletOperationRequests.NewRelabelPallet request) {
String relabelNo = required(request.relabelNo(), "换标单号不能为空");
RelabelApplication application = relabelApplications.findAnyByRelabelNo(relabelNo);
if (application == null || !"READY_FOR_OUTBOUND".equals(application.getStatus())) {
throw conflict("换标单尚未完成客户确认,不能生成新托盘");
}
if (!Objects.equals(application.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) {
throw conflict("换标单不属于 Token 冻结的海外仓");
}
String key = required(request.idempotencyKey(), "幂等键不能为空");
PalletPhysicalGateway.PalletReference pallet = physical.ensureRelabelPallet(identity.overseasWarehouseId(),
relabelNo + ":" + key);
String code = HtmlUtils.htmlEscape(pallet.palletCode());
String order = HtmlUtils.htmlEscape(relabelNo);
String html = "<!doctype html><html><head><meta charset=\"UTF-8\"><title>" + code
+ "</title><style>@page{size:100mm 70mm;margin:4mm}body{font-family:Arial,sans-serif;text-align:center;margin:0}"
+ "h1{font-size:24px;margin:8mm 0 4mm;letter-spacing:0}.code{font-size:18px;font-weight:700;word-break:break-all}"
+ ".ref{font-size:12px;margin-top:6mm}</style></head><body><h1>托盘标签</h1><div class=\"code\">"
+ code + "</div><div class=\"ref\">换标单:" + order + "</div></body></html>";
return new PalletOperationResponses.PrintablePallet(pallet.palletId(), pallet.palletCode(), pallet.version(),
relabelNo, html);
}
public PalletOperationResponses.Operation assemble(PdaWarehouseIdentity identity,
PalletOperationRequests.Assemble request) {
PalletOperationResponses.Operation replay = replayIfPresent(identity, "PALLET_ASSEMBLE",
@@ -175,8 +200,10 @@ public class PdaPalletOperationService {
Plan base = plan(identity.overseasWarehouseId(), pallets, moves, Set.of(target.palletId()), false);
Plan relabelPlan = new Plan(base.pallets(), base.moves(), base.changes(),
new RelabelMutation(application, itemsByPosition));
String reason = "换标拆托" + (request.reason() == null || request.reason().isBlank()
? "" : ":" + request.reason().trim());
return execute(identity, "RELABEL_PALLET_DISASSEMBLE", request.idempotencyKey(), request,
request.reason(), relabelPlan);
reason, relabelPlan);
}
public PalletOperationResponses.Operation swap(PdaWarehouseIdentity identity,
@@ -563,9 +590,10 @@ public class PdaPalletOperationService {
.digest((operator + "|" + key + "|" + palletId).getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); }
}
private String locationCode(long warehouseId, long locationId) {
private String locationCode(long warehouseId, Long locationId) {
if (locationId == null) return null;
PalletPhysicalGateway.LocationReference location = physical.resolveLocation(warehouseId, locationId);
if (location == null || location.locationId() != locationId || "INACTIVE".equals(location.status())) {
if (location == null || location.locationId() != locationId.longValue() || "INACTIVE".equals(location.status())) {
throw conflict("托盘库位不存在或已停用");
}
return location.locationCode();
@@ -622,10 +650,10 @@ public class PdaPalletOperationService {
private record PlannedMove(long positionId, long packages, long expectedVersion,
long sourcePalletId, String sourcePalletCode,
long targetPalletId, String targetPalletCode, long targetLocationId,
long targetPalletId, String targetPalletCode, Long targetLocationId,
String targetLocationCode, InventoryBucket bucket) { }
private record PalletChange(PalletPhysicalGateway.PalletReference pallet, String targetStatus,
long targetLocationId) { }
Long targetLocationId) { }
private record Plan(Map<String, PalletPhysicalGateway.PalletReference> pallets,
List<PlannedMove> moves, List<PalletChange> changes,
RelabelMutation relabelMutation) { }
@@ -30,6 +30,13 @@ public class MerchantRelabelApplicationController {
@RequestParam(required = false) String inboundCode) {
return ApiResponse.success(service.candidates(customer(user), operationWarehouseId, inboundCode));
}
@GetMapping("/historical-orders")
public ApiResponse<List<RelabelResponses.HistoricalOrder>> historicalOrders(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String operationWarehouseId,
@RequestParam(required = false, defaultValue = "") String keyword) {
return ApiResponse.success(service.historicalOrders(customer(user), operationWarehouseId, keyword));
}
@PostMapping
public ApiResponse<RelabelResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody RelabelRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); }
@@ -61,6 +61,26 @@ public class RelabelApplicationController {
return ApiResponse.success(service.changeStatus(null, relabelNo, operator(user), request));
}
@PostMapping("/{relabelNo}/wallet-freeze/retry")
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_STATUS + "')")
@Operation(summary = "Retry a failed relabel wallet freeze", description = "Required authority: " + OverseasPermissions.RELABEL_ORDER_STATUS)
public ApiResponse<RelabelResponses.Detail> retryWalletFreeze(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo,
@Valid @RequestBody RelabelRequests.WalletFreezeRetry request) {
requirePlatform(user);
return ApiResponse.success(service.retryWalletFreeze(relabelNo, operator(user), request));
}
@PostMapping("/{relabelNo}/wallet-settlement/retry")
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_STATUS + "')")
@Operation(summary = "Retry a failed relabel wallet settlement", description = "Required authority: " + OverseasPermissions.RELABEL_ORDER_STATUS)
public ApiResponse<RelabelResponses.Detail> retryWalletSettlement(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo,
@Valid @RequestBody RelabelRequests.WalletFreezeRetry request) {
requirePlatform(user);
return ApiResponse.success(service.retryWalletSettlement(relabelNo, operator(user), request));
}
@PostMapping(value = "/{relabelNo}/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_CREATE + "')")
public ApiResponse<RelabelResponses.Detail> uploadFile(@AuthenticationPrincipal AuthenticatedUser user,
@@ -117,4 +117,7 @@ public final class RelabelRequests {
public record CustomerConfirm(@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) {}
public record WalletFreezeRetry(@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) {}
}
@@ -35,6 +35,9 @@ public final class RelabelResponses {
String overseasWarehouseName, String destinationFbaWarehouseCode,
String destinationFbaWarehouseName, String skuCode, String productNameZh,
long availablePackages, long version, List<InventoryPosition> positions) {}
public record HistoricalOrder(Long id, String orderNo, String sourceOrderNo, String outboundOrderNo,
String relabelOrderNo, String orderType, String status, Integer packageCount,
LocalDateTime createdAt) {}
public record TailOrder(Long id, String orderNo, String overseasWarehouseId, int packageCount,
String status) {}
public record History(String fromStatus, String toStatus, String operatorId,
@@ -43,4 +43,7 @@ public class RelabelItem {
private String sourceWarehouseCode;
private String sourceWarehouseName;
private LocalDateTime createdAt;
private Long returnCartonId;
private String sourceCartonMark;
private String newCartonMark;
}
@@ -5,6 +5,7 @@ import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface RelabelApplicationMapper extends BaseMapper<RelabelApplication> {
@@ -25,4 +26,11 @@ public interface RelabelApplicationMapper extends BaseMapper<RelabelApplication>
@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);
@Update("UPDATE ll_relabel_application SET wallet_status=#{status}, wallet_error_code=#{errorCode}, "
+ "wallet_error_message=#{errorMessage}, wallet_command_event_id=COALESCE(#{eventId},wallet_command_event_id), "
+ "updated_at=NOW(3) WHERE id=#{id}")
int updateWalletState(@Param("id") Long id, @Param("status") String status,
@Param("errorCode") String errorCode, @Param("errorMessage") String errorMessage,
@Param("eventId") String eventId);
}
@@ -18,6 +18,8 @@ import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper;
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelOperationMapper;
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper;
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelUnitFactMapper;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import com.baiyue.lastleg.relabel.service.RelabelApplicationService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.MapperFeature;
@@ -60,6 +62,7 @@ public class PdaRelabelService {
@Resource private RelabelItemMapper items;
@Resource private PdaRelabelTaskMapper tasks;
@Resource private PdaRelabelUnitFactMapper unitFacts;
@Resource private ReturnInventoryCartonMapper returnCartons;
@Resource private PdaRelabelOperationMapper operations;
@Resource private PdaRelabelHistoryMapper histories;
@Resource private RelabelApplicationService applicationService;
@@ -296,6 +299,21 @@ public class PdaRelabelService {
fact.setOperatorKey(operatorKey); fact.setOperatorName(operatorName); fact.setOccurredAt(LocalDateTime.now());
try { unitFacts.insert(fact); }
catch (DuplicateKeyException duplicate) { throw conflict("Old label was processed concurrently"); }
if ("RELABELLED".equals(resultCode)) {
item.setNewCartonMark(resolvedNewLabel);
item.setFbaCartonMark(resolvedNewLabel);
items.updateById(item);
if (item.getReturnCartonId() != null && returnCartons != null) {
ReturnInventoryCarton carton = returnCartons.selectById(item.getReturnCartonId());
if (carton != null && application.getCustomerId().equals(carton.getCustomerId())) {
carton.setNewCartonMark(resolvedNewLabel);
carton.setFbaCartonMark(resolvedNewLabel);
carton.setRelabelStatus(PROCESSING);
carton.setUpdatedAt(LocalDateTime.now());
returnCartons.updateById(carton);
}
}
}
if ("RELABELLED".equals(resultCode)) task.setRelabelledPackages(value(task.getRelabelledPackages()) + 1);
if ("SKIPPED".equals(resultCode)) task.setSkippedPackages(value(task.getSkippedPackages()) + 1);
if ("EXCEPTION".equals(resultCode)) task.setExceptionPackages(value(task.getExceptionPackages()) + 1);
@@ -38,6 +38,8 @@ 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;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
import com.baiyue.common.event.WalletCommandCompletedV1;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
@@ -89,6 +91,7 @@ public class RelabelApplicationService implements WalletResultHandler {
@Resource private OutboundPalletItemMapper outboundItems;
@Resource private OutboundPdaTaskMapper outboundTasks;
@Resource private OverseasFeeSourceService feeSources;
@Resource private WarehouseTransferOrderMapper warehouseTransfers;
@Transactional(readOnly = true)
public String customerId(String relabelNo) { return require(null, relabelNo).getCustomerId(); }
@@ -233,6 +236,32 @@ public class RelabelApplicationService implements WalletResultHandler {
balance.getProductNameZh(), available, value(balance.getVersion()), positions);
}).filter(candidate -> candidate.availablePackages() > 0).toList();
}
@Transactional(readOnly = true)
public List<RelabelResponses.HistoricalOrder> historicalOrders(String customerId, String operationWarehouseId,
String keyword) {
if (!StringUtils.hasText(operationWarehouseId)) throw badRequest("Operation warehouse is required");
String q = StringUtils.hasText(keyword) ? keyword.trim() : "";
List<LastlegOrder> orders = this.orders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getCustomerId, customerId)
.eq(LastlegOrder::getOverseasWarehouseId, operationWarehouseId.trim())
.and(w -> w.like(StringUtils.hasText(q), LastlegOrder::getOrderNo, q)
.or().like(StringUtils.hasText(q), LastlegOrder::getSourceOrderNo, q)
.or().like(StringUtils.hasText(q), LastlegOrder::getRelabelOrderNo, q)
.or().like(StringUtils.hasText(q), LastlegOrder::getOutboundOrderNo, q))
.orderByDesc(LastlegOrder::getCreatedAt).orderByDesc(LastlegOrder::getId)
.last("LIMIT 100"));
Map<String, RelabelResponses.HistoricalOrder> result = new java.util.LinkedHashMap<>();
orders.forEach(order -> result.put("ORDER:" + order.getId(), new RelabelResponses.HistoricalOrder(order.getId(),
order.getOrderNo(), order.getSourceOrderNo(), order.getOutboundOrderNo(), order.getRelabelOrderNo(),
order.getOrderType(), order.getCurrentStatus(), order.getPackageCount(), order.getCreatedAt())));
for (OutboundOrder outbound : outboundOrders.listRelabelOutbounds(customerId, operationWarehouseId.trim(), q)) {
result.putIfAbsent("OUTBOUND:" + outbound.getId(), new RelabelResponses.HistoricalOrder(null,
outbound.getOutboundNo(), null, outbound.getOutboundNo(), null, outbound.getOutboundType(),
outbound.getStatus(), outbound.getPackageCount(), outbound.getCreatedAt()));
}
return result.values().stream().limit(100).toList();
}
@Transactional
public RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request) {
if (!StringUtils.hasText(request.operationWarehouseId())) throw badRequest("Operation warehouse is required");
@@ -411,7 +440,12 @@ public class RelabelApplicationService implements WalletResultHandler {
application.setFulfillmentStatus("WAIT_CUSTOMER_CONFIRMATION");
applications.updateById(application);
} else if ("COMPLETED".equals(next)) {
if (!StringUtils.hasText(application.getOutboundOrderNo())) releaseInventory(customerId, operatorId, application);
boolean preDispatched = "PRE_DISPATCH_CREATED".equals(application.getFulfillmentStatus());
application.setFulfillmentStatus("OUTBOUNDED");
if (!StringUtils.hasText(application.getOutboundOrderNo())
&& !preDispatched) {
releaseInventory(customerId, operatorId, application);
}
BigDecimal quantity = BigDecimal.valueOf(Objects.requireNonNullElse(application.getTotalPackageCount(), 0));
feeSources.publishApproved(operatorId, "RELABEL:" + relabelNo,
new OverseasFeeSourceDtos.SaveRequest(customerId, linkedOrders.get(0).getCustomerName(),
@@ -457,6 +491,62 @@ public class RelabelApplicationService implements WalletResultHandler {
return detail(customerId, relabelNo);
}
@Transactional
public RelabelResponses.Detail retryWalletFreeze(String relabelNo, String operatorId,
RelabelRequests.WalletFreezeRetry request) {
RelabelApplication application = applications.lockAnyByRelabelNo(relabelNo);
if (application == null) throw notFound("Relabel application not found");
String customerId = application.getCustomerId();
if ("FROZEN".equals(application.getWalletStatus()) || "FREEZE_PENDING".equals(application.getWalletStatus())) {
return detail(customerId, relabelNo);
}
if (!PENDING_PROCESSING.equals(application.getStatus()) || !"FREEZE_FAILED".equals(application.getWalletStatus())) {
throw conflict("Only a pending relabel application with failed funds freeze can be retried");
}
String retryKey = request.idempotencyKey().trim();
long walletVersion = 100L + Long.parseLong(hash(retryKey).substring(0, 15), 16);
int holdIndex = 0;
for (RelabelItem item : itemList(application.getId())) {
if (item.getSourceInventoryPositionId() == null
|| Objects.requireNonNullElse(item.getNotifiedPackageCount(), 0) <= 0) continue;
inventoryCommands.hold(customerId, operatorId, new InventoryCommandRequests.Move(
item.getSourceInventoryPositionId(), item.getNotifiedPackageCount(), relabelNo,
null, "RELABEL:" + relabelNo, "RELABEL:" + application.getId() + ":RETRY_HOLD:"
+ walletVersion + ":" + holdIndex++, "补款后重试冻结换标库存"));
}
String walletEventId = walletCommands.publish("FREEZE", Long.parseLong(customerId), "RELABEL_ORDER",
relabelNo, application.getChargeAmount(), trim(request.remark()), walletVersion);
application.setWalletStatus("FREEZE_PENDING");
application.setWalletErrorCode(null); application.setWalletErrorMessage(null);
application.setWalletCommandEventId(walletEventId); application.setUpdatedAt(LocalDateTime.now());
applications.updateWalletState(application.getId(), application.getWalletStatus(), null, null, walletEventId);
insertHistory(customerId, application.getId(), application.getStatus(), application.getStatus(), operatorId,
"Wallet freeze retry requested", LocalDateTime.now());
return detail(customerId, relabelNo);
}
@Transactional
public RelabelResponses.Detail retryWalletSettlement(String relabelNo, String operatorId,
RelabelRequests.WalletFreezeRetry request) {
RelabelApplication application = applications.lockAnyByRelabelNo(relabelNo);
if (application == null) throw notFound("Relabel application not found");
String customerId = application.getCustomerId();
if ("SETTLED".equals(application.getWalletStatus()) || "SETTLE_PENDING".equals(application.getWalletStatus())) {
return detail(customerId, relabelNo);
}
if (!"COMPLETED".equals(application.getStatus()) || !"SETTLE_FAILED".equals(application.getWalletStatus())) {
throw conflict("Only a completed relabel application with failed funds settlement can be retried");
}
String retryKey = request.idempotencyKey().trim();
long walletVersion = 200L + Long.parseLong(hash(retryKey).substring(0, 15), 16);
String walletEventId = walletCommands.publish("SETTLE", Long.parseLong(customerId), "RELABEL_ORDER",
relabelNo, null, trim(request.remark()), walletVersion);
applications.updateWalletState(application.getId(), "SETTLE_PENDING", null, null, walletEventId);
insertHistory(customerId, application.getId(), application.getStatus(), application.getStatus(), operatorId,
"Wallet settlement retry requested", LocalDateTime.now());
return detail(customerId, relabelNo);
}
@Transactional
public RelabelResponses.Detail confirmCompletion(String customerId, String relabelNo, String operatorId,
RelabelRequests.CustomerConfirm request) {
@@ -492,12 +582,37 @@ public class RelabelApplicationService implements WalletResultHandler {
order.setUpdatedAt(now);
orders.updateById(order);
if (!Objects.equals(orderBefore, order.getCurrentStatus())) {
insertOrderHistory(customerId, order.getId(), 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);
// 仓储转运换标在客户确认后立即恢复为可排车,发车时再转为 OUTBOUNDED。
if (warehouseTransfers != null) {
WarehouseTransferOrder transfer = warehouseTransfers.lockByRelabelNo(customerId, relabelNo);
if (transfer != null && "WAIT_RELABEL".equals(transfer.getStatus())) {
transfer.setStatus("READY_FOR_DISPATCH");
transfer.setRelabelCompletedAt(now);
transfer.setUpdatedAt(now);
warehouseTransfers.updateById(transfer);
// 转运单自身的尾程订单与换标申请尾程订单是两条记录,需同步释放原转运单参与排车。
LastlegOrder transferOrder = orders.selectById(transfer.getLastlegOrderId());
if (transferOrder != null && customerId.equals(transferOrder.getCustomerId())) {
String transferOrderBefore = transferOrder.getCurrentStatus();
transferOrder.setCurrentStatus("READY_FOR_DISPATCH");
transferOrder.setRelabelStatus(READY_FOR_OUTBOUND);
transferOrder.setSubOrderStatus("WAREHOUSE_TRANSFER:READY_FOR_DISPATCH;RELABEL:"
+ READY_FOR_OUTBOUND);
transferOrder.setUpdatedAt(now);
orders.updateById(transferOrder);
if (!Objects.equals(transferOrderBefore, transferOrder.getCurrentStatus())) {
insertOrderHistory(customerId, transferOrder.getId(), transferOrderBefore,
transferOrder.getCurrentStatus(), "Warehouse transfer relabel completed", now);
}
}
}
}
return detail(customerId, relabelNo);
}
@@ -622,7 +737,8 @@ public class RelabelApplicationService implements WalletResultHandler {
releaseInventory(value.getCustomerId(), "WALLET_CALLBACK", value);
}
}
value.setUpdatedAt(LocalDateTime.now()); applications.updateById(value);
applications.updateWalletState(value.getId(), value.getWalletStatus(), value.getWalletErrorCode(),
value.getWalletErrorMessage(), null);
}
private RelabelResponses.Item item(RelabelItem value) {
@@ -23,6 +23,15 @@ public class MerchantReturnOrderController {
@Valid ReturnOrderRequests.Query query) { return ApiResponse.success(service.page(customer(user), query)); }
@GetMapping("/{returnNo}") public ApiResponse<ReturnOrderResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String returnNo) { return ApiResponse.success(service.detail(customer(user), returnNo)); }
@GetMapping("/{returnNo}/relabel-candidates") public ApiResponse<java.util.List<ReturnOrderResponses.RelabelCandidate>> relabelCandidates(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String returnNo) {
return ApiResponse.success(service.relabelCandidates(customer(user), returnNo));
}
@PostMapping("/{returnNo}/relabel") public ApiResponse<ReturnOrderResponses.RelabelCreated> createRelabel(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String returnNo,
@Valid @RequestBody ReturnOrderRequests.RelabelCreate request) {
return ApiResponse.success(service.createRelabel(customer(user), operator(user), returnNo, request));
}
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");
@@ -27,4 +27,8 @@ public final class ReturnOrderRequests {
String trackingNo, String skuCode, String status) {
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
}
public record RelabelCreate(@NotEmpty @Size(max = 500) List<@NotBlank @Size(max = 255) String> cartonMarks,
@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 128) String contactName, @Size(max = 64) String contactPhone,
@Size(max = 32) String deliveryMethod, @Size(max = 1000) String remark) {}
}
@@ -14,4 +14,8 @@ public final class ReturnOrderResponses {
public record Detail(Summary order, String remark, List<Item> items, List<Receipt> receipts) { }
public record Receipt(String receiptNo, String receiptBatchNo, String cartonMark, String trackingNo, String startedBy,
long receivedQuantity, String operatorName, LocalDateTime signedAt) { }
public record RelabelCandidate(Long cartonId, String cartonMark, String sourceReturnNo, String status,
long totalQuantity, List<Item> items, String relabelStatus) { }
public record RelabelCreated(String relabelNo, Long relabelApplicationId, String returnNo,
List<String> cartonMarks, String status) { }
}
@@ -36,4 +36,5 @@ public class ReturnInventoryCarton {
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private LocalDateTime mergedAt;
private String newCartonMark;
}
@@ -18,6 +18,10 @@ import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryQueryMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryMovementMapper;
import com.baiyue.lastleg.returnreceipt.model.ReturnInventoryRow;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
@@ -52,6 +56,8 @@ public class ReturnInventoryService {
@Resource private LastlegOrderNoGenerator numbers;
@Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper orders;
@Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptMapper receiptMapper;
@Resource private LastlegOrderMapper lastlegOrders;
@Resource private LastlegOrderDetailMapper lastlegOrderDetails;
@Transactional
public ReturnInventoryCarton createReceiptCarton(ReturnOrder order, ReturnReceipt receipt,
@@ -66,6 +72,7 @@ public class ReturnInventoryService {
ReturnInventoryCarton carton = new ReturnInventoryCarton();
carton.setId(cartonId); carton.setCustomerId(order.getCustomerId()); carton.setOverseasWarehouseId(order.getOverseasWarehouseId());
carton.setSourceReceiptId(receipt.getId()); carton.setSourceBatchNo(receipt.getReceiptBatchNo()); carton.setCartonMark(cartonMark);
carton.setSourceReturnNo(order.getReturnNo());
carton.setStatus(AVAILABLE); carton.setSkuTypes(lines.size()); carton.setTotalQuantity(total); carton.setCreatedAt(now); carton.setUpdatedAt(now);
cartons.insert(carton);
for (ReceiptLine line : lines) {
@@ -216,9 +223,13 @@ public class ReturnInventoryService {
if (selectedOrders.stream().anyMatch(order -> !"COMPLETED".equals(order.getStatus()))) throw conflict("只能合并已完成签收的退货单");
if (selectedOrders.stream().anyMatch(order -> !customerId.equals(order.getCustomerId()))) throw conflict("不同客户的退货单禁止合箱");
List<ReturnInventoryCarton> sources = new ArrayList<>();
Map<Long, ReturnOrder> sourceOwners = new LinkedHashMap<>();
for (ReturnOrder order : selectedOrders) for (ReturnReceipt receipt : receiptMapper.listOrder(order.getId())) {
ReturnInventoryCarton carton = cartons.findReceipt(receipt.getId());
if (carton != null && AVAILABLE.equals(carton.getStatus())) sources.add(cartons.lockMark(carton.getCartonMark()));
if (carton != null && AVAILABLE.equals(carton.getStatus())) {
ReturnInventoryCarton locked = cartons.lockMark(carton.getCartonMark());
sources.add(locked); sourceOwners.put(locked.getId(), order);
}
}
if (sources.isEmpty()) throw conflict("所选退货单没有可合箱的库存箱");
Map<String, Long> requested = new LinkedHashMap<>();
@@ -229,6 +240,7 @@ public class ReturnInventoryService {
}
Map<String, Long> remaining = new LinkedHashMap<>(requested);
Map<String, Integer> emptiedBySku = new LinkedHashMap<>();
Map<Long, Map<String, Long>> attachedByOrder = new LinkedHashMap<>();
long total = 0;
for (ReturnInventoryCarton source : sources) {
List<ReturnInventoryCartonItem> sourceItems = cartonItems.listCarton(source.getId());
@@ -238,6 +250,9 @@ public class ReturnInventoryService {
long take = Math.min(value(remaining.get(sku)), value(item.getQuantity()));
if (take > 0) {
remaining.put(sku, remaining.get(sku) - take); total = Math.addExact(total, take);
ReturnOrder owner = sourceOwners.get(source.getId());
if (owner != null) attachedByOrder.computeIfAbsent(owner.getId(), ignored -> new LinkedHashMap<>())
.merge(sku, take, Math::addExact);
productNames.putIfAbsent(sku, item.getProductNameZh());
item.setQuantity(item.getQuantity() - take);
if (item.getQuantity() == 0) cartonItems.deleteById(item.getId()); else cartonItems.updateById(item);
@@ -267,10 +282,36 @@ public class ReturnInventoryService {
balances.adjust(IdWorker.getId(), customerId, target.getOverseasWarehouseId(), entry.getKey(), productNames.get(entry.getKey()), 0,
-entry.getValue(), cartonDelta);
}
attachMergedCarton(target, attachedByOrder, selectedOrders, productNames, now);
operation.setResultCartonId(targetId); operation.setUpdatedAt(now); operations.updateById(operation);
return new ReturnInventoryResponses.MergeResult(target.getCartonMark(), requested.keySet().iterator().next(), total, label(target));
}
private void attachMergedCarton(ReturnInventoryCarton target, Map<Long, Map<String, Long>> attachedByOrder,
List<ReturnOrder> selectedOrders, Map<String, String> productNames, LocalDateTime now) {
Map<Long, ReturnOrder> byId = selectedOrders.stream().collect(java.util.stream.Collectors.toMap(ReturnOrder::getId, value -> value));
for (Map.Entry<Long, Map<String, Long>> orderEntry : attachedByOrder.entrySet()) {
ReturnOrder returnOrder = byId.get(orderEntry.getKey());
if (returnOrder == null || returnOrder.getLastlegOrderId() == null) continue;
LastlegOrder tail = lastlegOrders.selectById(returnOrder.getLastlegOrderId());
if (tail == null || !returnOrder.getCustomerId().equals(tail.getCustomerId())) continue;
long quantity = orderEntry.getValue().values().stream().mapToLong(Long::longValue).sum();
boolean firstSku = true;
for (Map.Entry<String, Long> skuEntry : orderEntry.getValue().entrySet()) {
LastlegOrderDetail detail = new LastlegOrderDetail();
detail.setId(IdWorker.getId()); detail.setCustomerId(returnOrder.getCustomerId()); detail.setOrderId(tail.getId());
detail.setSourceInboundDetailId("RETURN_CARTON:" + target.getId() + ":" + returnOrder.getId() + ":" + skuEntry.getKey());
detail.setSkuCode(skuEntry.getKey()); detail.setProductNameZh(productNames.get(skuEntry.getKey()));
detail.setPackageCount(firstSku ? 1 : 0); detail.setProductQuantity(Math.toIntExact(skuEntry.getValue())); detail.setShippingMark(target.getCartonMark());
detail.setDestinationWarehouseCode(target.getDestinationWarehouseCode()); detail.setDestinationWarehouseName(target.getDestinationWarehouseName());
lastlegOrderDetails.insert(detail); firstSku = false;
}
tail.setPackageCount(Math.addExact(tail.getPackageCount() == null ? 0 : tail.getPackageCount(), 1));
tail.setProductQuantity(Math.addExact(tail.getProductQuantity() == null ? 0 : tail.getProductQuantity(), Math.toIntExact(quantity)));
tail.setUpdatedAt(now); lastlegOrders.updateById(tail);
}
}
private ReturnInventoryResponses.MergeResult replayMerge(ReturnInventoryOperation operation, String requestHash) {
if (!requestHash.equals(operation.getRequestHash())) throw conflict("幂等键已用于不同的合箱请求");
if (operation.getResultCartonId() == null) throw conflict("合箱请求正在处理");
@@ -21,6 +21,14 @@ import com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptItemMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptMapper;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -59,6 +67,10 @@ public class ReturnReceiptService {
@Resource private LastlegOrderNoGenerator numbers;
@Resource private ObjectMapper objectMapper;
@Resource private LastlegOrderMapper lastlegOrders;
@Resource private ReturnInventoryCartonMapper cartons;
@Resource private ReturnInventoryCartonItemMapper cartonItems;
@Resource private RelabelApplicationMapper relabelApplications;
@Resource private RelabelItemMapper relabelItems;
@Transactional
public ReturnOrderResponses.Detail create(String customerId, String operatorId, ReturnOrderRequests.Create request) {
@@ -159,6 +171,61 @@ public class ReturnReceiptService {
return new ReturnOrderResponses.Detail(summary(order), order.getRemark(), items, receiptViews);
}
@Transactional(readOnly = true)
public List<ReturnOrderResponses.RelabelCandidate> relabelCandidates(String customerId, String returnNo) {
ReturnOrder order = orders.findNo(customerId, text(returnNo));
if (order == null) throw notFound("退货单不存在");
if (!COMPLETED.equals(order.getStatus())) throw conflict("退货单尚未完成签收");
List<ReturnOrderResponses.RelabelCandidate> result = new ArrayList<>();
for (ReturnReceipt receipt : receipts.listOrder(order.getId())) {
ReturnInventoryCarton carton = cartons.findReceipt(receipt.getId());
if (carton == null || !ReturnInventoryService.AVAILABLE.equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) continue;
List<ReturnOrderResponses.Item> items = cartonItems.listCarton(carton.getId()).stream()
.map(i -> new ReturnOrderResponses.Item(i.getSkuCode(), i.getProductNameZh(), value(i.getQuantity()), value(i.getQuantity()))).toList();
result.add(new ReturnOrderResponses.RelabelCandidate(carton.getId(), carton.getCartonMark(), carton.getSourceReturnNo(), carton.getStatus(), value(carton.getTotalQuantity()), items, carton.getRelabelStatus()));
}
return result;
}
@Transactional
public ReturnOrderResponses.RelabelCreated createRelabel(String customerId, String operatorId, String returnNo,
ReturnOrderRequests.RelabelCreate request) {
ReturnOrder order = orders.findNo(customerId, text(returnNo));
if (order == null) throw notFound("退货单不存在");
if (!COMPLETED.equals(order.getStatus())) throw conflict("退货单尚未完成签收");
String key = text(request.idempotencyKey());
RelabelApplication replay = relabelApplications.findKey(customerId, key);
if (replay != null) return new ReturnOrderResponses.RelabelCreated(replay.getRelabelNo(), replay.getId(), order.getReturnNo(), request.cartonMarks(), replay.getStatus());
List<ReturnInventoryCarton> selected = new ArrayList<>();
Set<String> seen = new LinkedHashSet<>();
for (String mark : request.cartonMarks()) {
String normalized = text(mark);
if (!seen.add(normalized.toUpperCase(java.util.Locale.ROOT))) throw badRequest("箱唛不能重复");
ReturnInventoryCarton carton = cartons.findCustomerMark(customerId, normalized);
if (carton == null || !order.getOverseasWarehouseId().equals(carton.getOverseasWarehouseId())) throw notFound("退货箱不存在: " + normalized);
if (!ReturnInventoryService.AVAILABLE.equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) throw conflict("退货箱不可申请换标: " + normalized);
if (carton.getSourceReturnNo() == null || !java.util.Arrays.asList(carton.getSourceReturnNo().split(",")).contains(order.getReturnNo())) throw conflict("退货箱不属于当前退货单: " + normalized);
selected.add(cartons.lockMark(normalized));
}
long appId = IdWorker.getId(); LocalDateTime now = LocalDateTime.now();
String relabelNo = numbers.next("RELABEL", order.getOverseasWarehouseId(), appId);
LastlegOrder tail = new LastlegOrder(); tail.setId(IdWorker.getId()); tail.setOrderNo(numbers.next("RELABEL", order.getOverseasWarehouseId(), tail.getId()));
tail.setCustomerId(customerId); tail.setOrderType("RELABEL_APPLICATION"); tail.setSourceRegion("CUSTOMER"); tail.setSourceOrderId(String.valueOf(appId)); tail.setSourceOrderNo(relabelNo);
tail.setOverseasWarehouseId(order.getOverseasWarehouseId()); tail.setCurrentStatus("PENDING_ASSIGNMENT"); tail.setSubOrderStatus("RELABEL:RETURN_CARTON"); tail.setRelabelStatus("RETURN_CARTON"); tail.setPackageCount(selected.size());
tail.setProductQuantity(Math.toIntExact(selected.stream().mapToLong(c -> value(c.getTotalQuantity())).sum())); tail.setCreatedAt(now); tail.setUpdatedAt(now); lastlegOrders.insert(tail);
RelabelApplication app = new RelabelApplication(); app.setId(appId); app.setCustomerId(customerId); app.setRelabelNo(relabelNo); app.setLastlegOrderId(tail.getId());
app.setCustomerOrderNo(order.getReturnNo()); app.setOverseasWarehouseId(order.getOverseasWarehouseId()); app.setContactName(text(request.contactName())); app.setContactPhone(text(request.contactPhone())); app.setDeliveryMethod(text(request.deliveryMethod()) == null ? "TRUCK" : text(request.deliveryMethod()));
app.setFulfillmentStatus("WAIT_RELABEL"); app.setRemark(text(request.remark())); app.setStatus("PENDING_PROCESSING"); app.setItemCount(selected.size()); app.setTotalPackageCount(selected.size()); app.setNotifiedPackageCount(selected.size()); app.setChargeAmount(java.math.BigDecimal.ZERO); app.setChargeCurrency("CNY"); app.setWalletStatus("NOT_REQUIRED"); app.setIdempotencyKey(key); app.setRequestHash(hash(request)); app.setCreatedBy(text(operatorId)); app.setCreatedAt(now); app.setUpdatedAt(now); relabelApplications.insert(app);
for (ReturnInventoryCarton carton : selected) {
for (ReturnInventoryCartonItem ci : cartonItems.listCarton(carton.getId())) {
RelabelItem item = new RelabelItem(); item.setId(IdWorker.getId()); item.setApplicationId(appId); item.setCustomerId(customerId); item.setSkuCode(ci.getSkuCode()); item.setProductNameZh(ci.getProductNameZh()); item.setTotalPackageCount(1); item.setNotifiedPackageCount(1); item.setShipmentNo(order.getReturnNo()); item.setCartonMark(carton.getCartonMark()); item.setFbaCartonMark(carton.getFbaCartonMark()); item.setOperationType("CARTON_LABEL"); item.setReturnCartonId(carton.getId()); item.setSourceCartonMark(carton.getCartonMark()); item.setCreatedAt(now); relabelItems.insert(item);
}
carton.setRelabelApplicationId(appId); carton.setRelabelNo(relabelNo); carton.setRelabelStatus("PENDING"); carton.setStatus("RELABEL_PENDING"); carton.setUpdatedAt(now); cartons.updateById(carton);
}
order.setRelabelNo(relabelNo); order.setRelabelStatus("PENDING_PROCESSING"); orders.updateById(order);
return new ReturnOrderResponses.RelabelCreated(relabelNo, appId, order.getReturnNo(), selected.stream().map(ReturnInventoryCarton::getCartonMark).toList(), app.getStatus());
}
@Transactional(readOnly = true)
public PdaReturnReceiptResponses.Start start(PdaWarehouseIdentity identity, PdaReturnReceiptRequests.Start request) {
ReturnOrder order = activeOrder(String.valueOf(identity.overseasWarehouseId()), request.returnNo());
@@ -5,6 +5,8 @@ public final class OverseasPermissions {
public static final String LAST_MILE_ORDER_LIST = "platform:overseas-warehouse:last-mile-order:list";
public static final String LAST_MILE_ORDER_EDIT = "platform:overseas-warehouse:last-mile-order:edit";
public static final String LAST_MILE_ORDER_EXPORT = "platform:overseas-warehouse:last-mile-order:export";
public static final String WAREHOUSE_TRANSFER_APPROVE =
"platform:overseas-warehouse:warehouse-transfer:approve";
public static final String INBOUND_ORDER_LIST = "platform:overseas-warehouse:inbound-order:list";
public static final String INBOUND_ORDER_CREATE = "platform:overseas-warehouse:inbound-order:create";
public static final String INBOUND_ORDER_EDIT = "platform:overseas-warehouse:inbound-order:edit";
@@ -50,6 +50,7 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
@Service
public class LastlegOrderService {
@@ -76,10 +77,16 @@ public class LastlegOrderService {
@Resource private WarehouseResourceGateway warehouseResourceGateway;
public PageResult<LastlegOrderSummary> page(String customerId, LastlegOrderRequests.Query query) {
return page(customerId, query, null);
}
public PageResult<LastlegOrderSummary> page(String customerId, LastlegOrderRequests.Query query, String relabelNo) {
Page<LastlegOrder> page = orderMapper.selectPage(
new Page<>(query.page(), query.size()), filter(customerId, query).orderByDesc(LastlegOrder::getCreatedAt));
new Page<>(query.page(), query.size()), filter(customerId, query, relabelNo).orderByDesc(LastlegOrder::getCreatedAt));
Map<Long, List<LastlegOrderMapper.PalletMergeRelationRow>> mergeRelations = palletMergeRelations(page.getRecords());
return new PageResult<>(page.getRecords().stream().map(order -> summary(order, mergeRelations.get(order.getId()))).toList(),
Map<Long, String> dispatchStatuses = dispatchStatuses(page.getRecords());
return new PageResult<>(page.getRecords().stream().map(order -> summary(order, mergeRelations.get(order.getId()),
dispatchStatuses.getOrDefault(order.getId(), "UNSCHEDULED"))).toList(),
page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
}
@@ -261,7 +268,8 @@ public class LastlegOrderService {
.map(value -> new LastlegOrderCommandDtos.ManualNodeView(
value.getNodeCode(), value.getNodeAt(), value.getSource()))
.sorted(java.util.Comparator.comparingInt(value -> MANUAL_NODE_CODES.indexOf(value.nodeCode()))).toList();
return new LastlegOrderDetailResponse(summary(order, palletMergeRelations(List.of(order)).get(order.getId())), manualNodes, cargo, history.stream()
return new LastlegOrderDetailResponse(summary(order, palletMergeRelations(List.of(order)).get(order.getId()),
dispatchStatuses(List.of(order)).getOrDefault(order.getId(), "UNSCHEDULED")), manualNodes, cargo, history.stream()
.map(value -> new LastlegOrderDetailResponse.History(value.getId(), value.getFromStatus(),
value.getToStatus(), value.getRemark(), value.getCreatedAt())).toList(), fees, operations.stream()
.map(value -> new LastlegOrderDetailResponse.Operation(value.getId(), value.getOperationNode(),
@@ -508,6 +516,10 @@ public class LastlegOrderService {
}
private LambdaQueryWrapper<LastlegOrder> filter(String customerId, LastlegOrderRequests.Query query) {
return filter(customerId, query, null);
}
private LambdaQueryWrapper<LastlegOrder> filter(String customerId, LastlegOrderRequests.Query query, String relabelNo) {
if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "createdFrom must not be after createdTo");
}
@@ -516,6 +528,7 @@ public class LastlegOrderService {
return new LambdaQueryWrapper<LastlegOrder>()
.eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId)
.like(StringUtils.hasText(query.orderNo()), LastlegOrder::getOrderNo, query.orderNo())
.like(StringUtils.hasText(relabelNo), LastlegOrder::getRelabelOrderNo, relabelNo)
.eq(StringUtils.hasText(query.orderType()), LastlegOrder::getOrderType, query.orderType())
.like(StringUtils.hasText(query.inboundCode()), LastlegOrder::getInboundCode, query.inboundCode())
.like(StringUtils.hasText(query.lxCode()), LastlegOrder::getLxCode, query.lxCode())
@@ -558,18 +571,28 @@ public class LastlegOrderService {
return result;
}
private LastlegOrderSummary summary(LastlegOrder o, List<LastlegOrderMapper.PalletMergeRelationRow> relations) {
private Map<Long, String> dispatchStatuses(List<LastlegOrder> orders) {
if (orders.isEmpty()) return Map.of();
List<LastlegOrderMapper.DispatchStatusRow> rows = orderMapper.findDispatchStatuses(
orders.stream().map(LastlegOrder::getId).toList());
if (rows == null) return Map.of();
return rows.stream().collect(Collectors.toMap(LastlegOrderMapper.DispatchStatusRow::orderId,
LastlegOrderMapper.DispatchStatusRow::dispatchStatus));
}
private LastlegOrderSummary summary(LastlegOrder o, List<LastlegOrderMapper.PalletMergeRelationRow> relations,
String dispatchStatus) {
List<LastlegOrderMapper.PalletMergeRelationRow> related = relations == null ? List.of() : relations;
return new LastlegOrderSummary(o.getId(), o.getOrderNo(), o.getOrderType(),
LastlegOrderType.labelOf(o.getOrderType()), o.getDestinationType(), o.getSourceInboundId(),
displayInboundCode(o), o.getLxCode(), o.getSourceOrderNo(), o.getCustomerId(), o.getCustomerName(), o.getOverseasWarehouseId(),
o.getOverseasWarehouseCode(), o.getOverseasWarehouseName(), o.getTransferWarehouseId(), o.getTransferWarehouseCode(),
o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), o.getContainerStatus(),
o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(),
o.getAbnormal(), o.getDetained(), o.getHoldChargeable(), o.getHoldReason(),
Boolean.TRUE.equals(o.getManualNodeOverride()), Boolean.TRUE.equals(o.getManualNodeOverride()) ? "MANUAL" : "CONTAINER",
o.getVersion() == null ? 0 : o.getVersion(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(),
o.getProductQuantity(), o.getVolumeWeightKg(), o.getEstimatedArrivalAt(), o.getActualArrivalAt(),
o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), o.getRelabelOrderNo(), o.getRelabelStatus(),
o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), dispatchStatus, o.getRelabelOrderNo(), o.getRelabelStatus(),
o.getPalletMergeFlag(), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::mergeGroupNo).distinct().findFirst().orElse(null),
related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::relatedInboundNo).filter(Objects::nonNull).distinct().toList(),
o.getCurrentStatus(), o.getSubOrderStatus(), o.getPrivateRemark(),
@@ -36,6 +36,13 @@ public class MerchantWarehouseTransferController {
return ApiResponse.success(service.detail(customer(user), transferNo));
}
@PostMapping("/{transferNo}/confirm-measurement")
public ApiResponse<WarehouseTransferResponses.Detail> confirmMeasurement(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo,
@Valid @RequestBody WarehouseTransferRequests.CustomerConfirm request) {
return ApiResponse.success(service.confirmMeasurement(customer(user), transferNo, operator(user), request));
}
private String customer(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) {
@@ -0,0 +1,43 @@
package com.baiyue.lastleg.transfer.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses;
import com.baiyue.lastleg.transfer.service.WarehouseTransferService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/pda/v1/last-leg/warehouse-transfers")
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
public class PdaWarehouseTransferController {
@Resource private WarehouseTransferService service;
@GetMapping("/candidates")
public ApiResponse<List<WarehouseTransferResponses.Summary>> candidates(
@AuthenticationPrincipal AuthenticatedUser user) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.pdaCandidates(identity.overseasWarehouseId()));
}
@PostMapping("/{transferNo}/palletize")
public ApiResponse<WarehouseTransferResponses.Detail> palletize(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo,
@Valid @RequestBody WarehouseTransferRequests.Palletize request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.palletize(identity.operatorKey(), identity.overseasWarehouseId(),
transferNo, request));
}
}
@@ -3,12 +3,14 @@ package com.baiyue.lastleg.transfer.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.security.OverseasPermissions;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses;
import com.baiyue.lastleg.transfer.service.WarehouseTransferService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@@ -26,22 +28,31 @@ public class WarehouseTransferController {
}
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')")
public ApiResponse<PageResult<WarehouseTransferResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @Valid WarehouseTransferRequests.Query query) {
return ApiResponse.success(service.page(scope(user, customerId), query));
}
@GetMapping("/{transferNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')")
public ApiResponse<WarehouseTransferResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @PathVariable String transferNo) {
return ApiResponse.success(service.detail(scope(user, customerId), transferNo));
}
@GetMapping("/{transferNo}/carton-label")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')")
public ApiResponse<WarehouseTransferResponses.Label> cartonLabel(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @PathVariable String transferNo) {
return ApiResponse.success(service.cartonLabel(scope(user, customerId), transferNo));
}
@PostMapping("/{transferNo}/review")
@PreAuthorize("hasAuthority('" + OverseasPermissions.WAREHOUSE_TRANSFER_APPROVE + "')")
public ApiResponse<WarehouseTransferResponses.Detail> review(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @PathVariable String transferNo,
@Valid @RequestBody WarehouseTransferRequests.Review request) {
requireApprovalPermission(user);
return ApiResponse.success(service.review(scope(user, customerId), transferNo, operator(user), request));
}
@@ -69,13 +80,6 @@ public class WarehouseTransferController {
return ApiResponse.success(service.receive(scope(user, customerId), transferNo, operator(user), request));
}
private void requireApprovalPermission(AuthenticatedUser user) {
if (user == null || user.permissions() == null
|| !user.permissions().contains("lastleg:warehouse-transfer:approve")) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Warehouse transfer approval permission is required");
}
}
private void requirePermission(AuthenticatedUser user, String permission) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if (user.permissions() == null || !user.permissions().contains(permission)) {
@@ -23,17 +23,34 @@ public final class WarehouseTransferRequests {
@Size(max = 128) String fbaWarehouseCode,
@NotBlank @Size(max = 1000) String deliveryAddress,
@Positive int packageCount,
@NotNull @DecimalMin(value = "0.0001") BigDecimal volumeCbm,
@NotNull @DecimalMin(value = "0.0") BigDecimal volumeCbm,
@NotBlank @Size(max = 64) String deliveryMethod,
@NotNull LocalDateTime estimatedArrivalAt,
LocalDateTime estimatedShipAt,
@Size(max = 128) String fbaShipmentNo,
@Size(max = 255) String trackingNo,
@Size(max = 2000) String fbaBoxNos,
@Size(max = 1000) String remark,
boolean needRelabel,
LocalDateTime labelDeadline,
@Size(max = 128) String relabelContactName,
@Size(max = 64) String relabelContactPhone,
@DecimalMin(value = "0.01") BigDecimal relabelChargeAmount,
@Size(max = 32) String relabelDeliveryMethod,
@NotBlank @Size(max = 255) String idempotencyKey,
@NotEmpty @Size(max = 500) List<@Valid Item> items
) {}
@NotEmpty @Size(max = 500) List<@Valid Item> items,
@Size(max = 1000) List<@Valid RelabelItem> relabelItems
) {
public Create(String inboundCode, List<Long> sourceLastlegOrderIds, String operatingWarehouseId,
String fbaWarehouseCode, String deliveryAddress, int packageCount, BigDecimal volumeCbm,
String deliveryMethod, LocalDateTime estimatedArrivalAt, String fbaShipmentNo,
String trackingNo, String fbaBoxNos, String remark, boolean needRelabel,
String idempotencyKey, List<Item> items) {
this(inboundCode, sourceLastlegOrderIds, operatingWarehouseId, fbaWarehouseCode, deliveryAddress,
packageCount, volumeCbm, deliveryMethod, estimatedArrivalAt, null, fbaShipmentNo, trackingNo,
fbaBoxNos, remark, needRelabel, null, null, null, null, null, idempotencyKey, items, null);
}
}
public record Review(@NotBlank @Size(max = 32) String decision,
@Size(max = 1000) String remark) {}
@@ -52,6 +69,24 @@ public final class WarehouseTransferRequests {
public record Outbound(@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) {}
public record Palletize(
@NotBlank @Size(max = 255) String idempotencyKey,
@NotEmpty @Size(max = 100) List<@Valid Pallet> pallets
) {}
public record Pallet(
@Size(max = 128) String palletCode,
@Positive int cartonCount,
@NotNull @DecimalMin("0.000001") BigDecimal volumeCbm,
@NotNull @DecimalMin("0.01") BigDecimal grossWeightKg,
@NotEmpty @Size(max = 500) List<@Valid PalletItem> items
) {}
public record PalletItem(@NotNull Long transferItemId, @Positive int packageCount) {}
public record CustomerConfirm(@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) {}
public record Receipt(
@NotNull Long sourceInventoryPositionId,
@NotBlank @Size(max = 128) String destinationOverseasWarehouseId,
@@ -76,6 +111,17 @@ public final class WarehouseTransferRequests {
@Size(max = 255) String shippingMark
) {}
public record RelabelItem(
@NotBlank @Size(max = 32) String operationType,
@NotEmpty @Size(max = 500) List<@NotBlank @Size(max = 255) String> customerCartonMarks,
@NotBlank @Size(max = 128) String originalSku,
@Size(max = 128) String newSku,
@Positive int quantity,
@Size(max = 255) String fbaCartonMark,
@Size(max = 128) String destinationWarehouseCode,
@Size(max = 1000) String remark
) {}
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,
@@ -1,12 +1,23 @@
package com.baiyue.lastleg.transfer.dto;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
public final class WarehouseTransferResponses {
private WarehouseTransferResponses() {}
public record Item(String skuCode, String productNameZh, int packageCount, String shippingMark) {}
public record Carton(String cartonMark, int cartonIndex, int totalCartons, String status) {}
public record Item(Long id, String skuCode, String productNameZh, int packageCount, String shippingMark) {}
public record Carton(String cartonMark, String customerCartonMark, int cartonIndex, int totalCartons, String status) {}
public record Label(String transferNo, List<Carton> cartons, String printableHtml) {}
public record PalletItem(Long transferItemId, String skuCode, int packageCount) {}
public record Pallet(Long id, int sequenceNo, Long warehousePalletId, String palletNo,
Long warehouseLocationId, String warehouseLocationCode, int cartonCount,
BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm,
BigDecimal grossWeightKg, BigDecimal volumeCbm, String status,
List<PalletItem> items, LocalDateTime completedAt) {}
public record RelabelBinding(String operationType, String customerCartonMark, String originalSku,
String newSku, int quantity, String fbaCartonMark,
String destinationWarehouseCode, String remark) {}
public record SourceOrder(Long lastlegOrderId, String lastlegOrderNo, Long inboundOrderId, String inboundCode) {}
public record Allocation(Long inventoryPositionId, Long inboundOrderId, String inboundCode,
Long palletId, String palletNo, int packageCount, String status,
@@ -19,9 +30,12 @@ public final class WarehouseTransferResponses {
Long lastlegOrderId, String lastlegOrderNo, String orderType, String orderStatus,
String status,
boolean needRelabel, Long relabelApplicationId, String relabelNo, String relabelStatus,
String operatingWarehouseId, String fbaWarehouseCode, String deliveryAddress,
String operatingWarehouseId, String fbaWarehouseCode, String deliveryAddress,
int packageCount, String deliveryMethod, LocalDateTime estimatedArrivalAt,
LocalDateTime estimatedShipAt,
String fbaShipmentNo, String trackingNo, String fbaBoxNos, String remark,
List<SourceOrder> sourceOrders, List<Item> items, List<Carton> cartons, List<Allocation> allocations,
LocalDateTime outboundAt, LocalDateTime createdAt) {}
List<SourceOrder> sourceOrders, List<Item> items, List<Carton> cartons,
List<Pallet> pallets, List<RelabelBinding> relabelBindings,
List<Allocation> allocations, LocalDateTime customerConfirmedAt,
LocalDateTime actualShipAt, LocalDateTime createdAt) {}
}
@@ -15,6 +15,7 @@ public class WarehouseTransferCarton {
private Long transferItemId;
private String customerId;
private String cartonMark;
private String customerCartonMark;
private Integer cartonIndex;
private Integer totalCartons;
private String status;
@@ -22,19 +22,31 @@ public class WarehouseTransferOrder {
private Integer packageCount;
private String deliveryMethod;
private LocalDateTime estimatedArrivalAt;
private LocalDateTime estimatedShipAt;
private String fbaShipmentNo;
private String trackingNo;
private String fbaBoxNos;
private String remark;
private Boolean needRelabel;
private LocalDateTime labelDeadline;
private String relabelContactName;
private String relabelContactPhone;
private java.math.BigDecimal relabelChargeAmount;
private String relabelDeliveryMethod;
private Long relabelApplicationId;
private String relabelNo;
private LocalDateTime relabelCompletedAt;
private String status;
private String allocationIdempotencyKey;
private String allocationRequestHash;
private String outboundIdempotencyKey;
private String outboundRequestHash;
private LocalDateTime outboundAt;
private String palletizeIdempotencyKey;
private String palletizeRequestHash;
private String confirmationIdempotencyKey;
private String confirmationRequestHash;
private LocalDateTime customerConfirmedAt;
private String idempotencyKey;
private String requestHash;
private String createdBy;
@@ -0,0 +1,33 @@
package com.baiyue.lastleg.transfer.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@TableName("ll_warehouse_transfer_pallet")
public class WarehouseTransferPallet {
@TableId(type = IdType.INPUT) private Long id;
private Long transferOrderId;
private String customerId;
private Integer sequenceNo;
private Long warehousePalletId;
private String palletNo;
private Long warehouseLocationId;
private String warehouseLocationCode;
private Long warehouseResourceLockId;
private Integer cartonCount;
private BigDecimal lengthCm;
private BigDecimal widthCm;
private BigDecimal heightCm;
private BigDecimal grossWeightKg;
private BigDecimal volumeCbm;
private String status;
private LocalDateTime completedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.transfer.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_warehouse_transfer_pallet_item")
public class WarehouseTransferPalletItem {
@TableId(type = IdType.INPUT) private Long id;
private Long transferOrderId;
private Long transferPalletId;
private Long transferItemId;
private String customerId;
private Integer packageCount;
private LocalDateTime createdAt;
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.transfer.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_warehouse_transfer_relabel_binding")
public class WarehouseTransferRelabelBinding {
@TableId(type = IdType.INPUT) private Long id;
private Long transferOrderId;
private String customerId;
private String operationType;
private String customerCartonMark;
private String originalSku;
private String newSku;
private Integer quantity;
private String fbaCartonMark;
private String destinationWarehouseCode;
private String remark;
private LocalDateTime createdAt;
}
@@ -4,6 +4,7 @@ import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@Mapper public interface WarehouseTransferOrderMapper extends BaseMapper<WarehouseTransferOrder> {
@Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1")
WarehouseTransferOrder findKey(@Param("customerId") String customerId, @Param("key") String key);
@@ -11,4 +12,16 @@ import org.apache.ibatis.annotations.Select;
WarehouseTransferOrder findNo(@Param("customerId") String customerId, @Param("transferNo") String transferNo);
@Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customerId} AND transfer_no=#{transferNo} LIMIT 1 FOR UPDATE")
WarehouseTransferOrder lockNo(@Param("customerId") String customerId, @Param("transferNo") String transferNo);
@Select("SELECT * FROM ll_warehouse_transfer_order WHERE transfer_no=#{transferNo} AND operating_warehouse_id=#{warehouseId} LIMIT 1 FOR UPDATE")
WarehouseTransferOrder lockForPda(@Param("transferNo") String transferNo, @Param("warehouseId") String warehouseId);
@Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} LIMIT 1 FOR 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")
int markOutboundByOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId,
@Param("outboundAt") java.time.LocalDateTime outboundAt);
@Update("UPDATE ll_warehouse_transfer_order SET status='OUTBOUNDED',outbound_at=#{outboundAt},updated_at=#{outboundAt} WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} AND outbound_at IS NULL")
int markOutboundByRelabel(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo,
@Param("outboundAt") java.time.LocalDateTime outboundAt);
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.transfer.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferPalletItem;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface WarehouseTransferPalletItemMapper extends BaseMapper<WarehouseTransferPalletItem> {
@Select("SELECT * FROM ll_warehouse_transfer_pallet_item WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} ORDER BY transfer_pallet_id,id")
List<WarehouseTransferPalletItem> listOrder(@Param("customerId") String customerId,
@Param("transferOrderId") Long transferOrderId);
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.transfer.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface WarehouseTransferPalletMapper extends BaseMapper<WarehouseTransferPallet> {
@Select("SELECT * FROM ll_warehouse_transfer_pallet WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} ORDER BY sequence_no")
List<WarehouseTransferPallet> listOrder(@Param("customerId") String customerId,
@Param("transferOrderId") Long transferOrderId);
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.transfer.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferRelabelBinding;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface WarehouseTransferRelabelBindingMapper extends BaseMapper<WarehouseTransferRelabelBinding> {
@Select("SELECT * FROM ll_warehouse_transfer_relabel_binding WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} ORDER BY id")
List<WarehouseTransferRelabelBinding> listOrder(@Param("customerId") String customerId,
@Param("transferOrderId") Long transferOrderId);
}
@@ -20,6 +20,20 @@ import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import com.baiyue.lastleg.relabel.dto.RelabelRequests;
import com.baiyue.lastleg.relabel.dto.RelabelResponses;
import com.baiyue.lastleg.relabel.service.RelabelApplicationService;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
import com.baiyue.lastleg.inboundplan.pda.mapper.InboundStockReceiptMapper;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses;
@@ -29,6 +43,9 @@ import com.baiyue.lastleg.transfer.entity.WarehouseTransferInventoryAllocation;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferReceipt;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferSourceOrder;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferPalletItem;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferRelabelBinding;
import com.baiyue.lastleg.transfer.mapper.InboundSnapshotMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferItemMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferCartonMapper;
@@ -36,6 +53,9 @@ import com.baiyue.lastleg.transfer.mapper.WarehouseTransferInventoryAllocationMa
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferReceiptMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferSourceOrderMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletItemMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferRelabelBindingMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
@@ -53,7 +73,12 @@ import java.util.HexFormat;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.ArrayList;
import java.util.Comparator;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -68,11 +93,18 @@ public class WarehouseTransferService {
public static final String OUTBOUNDED = "OUTBOUNDED";
public static final String PARTIALLY_RECEIVED = "PARTIALLY_RECEIVED";
public static final String RECEIVED = "RECEIVED";
public static final String WAIT_PDA_RECEIVING = "WAIT_PDA_RECEIVING";
public static final String WAIT_CUSTOMER_CONFIRMATION = "WAIT_CUSTOMER_CONFIRMATION";
public static final String READY_FOR_DISPATCH = "READY_FOR_DISPATCH";
public static final String WAIT_RELABEL = "WAIT_RELABEL";
@Resource private WarehouseTransferOrderMapper transfers;
@Resource private WarehouseTransferItemMapper transferItems;
@Resource private WarehouseTransferCartonMapper transferCartons;
@Resource private WarehouseTransferSourceOrderMapper sourceOrders;
@Resource private WarehouseTransferPalletMapper transferPallets;
@Resource private WarehouseTransferPalletItemMapper transferPalletItems;
@Resource private WarehouseTransferRelabelBindingMapper relabelBindings;
@Resource private WarehouseTransferInventoryAllocationMapper inventoryAllocations;
@Resource private WarehouseTransferReceiptMapper transferReceipts;
@Resource private InboundSnapshotMapper inboundSnapshots;
@@ -81,6 +113,13 @@ public class WarehouseTransferService {
@Resource private LastlegOrderDetailMapper orderDetails;
@Resource private LastlegOrderStatusHistoryMapper orderHistories;
@Resource private RelabelApplicationMapper relabelApplications;
@Resource private RelabelApplicationService relabelService;
@Resource private WarehouseResourceGateway warehouseResources;
@Resource private InboundPlanMapper inboundPlans;
@Resource private InboundPlanLineMapper inboundPlanLines;
@Resource private InboundPlanPalletMapper inboundPlanPallets;
@Resource private InboundPlanPalletAllocationMapper inboundPlanAllocations;
@Resource private InboundStockReceiptMapper stockReceipts;
@Resource private LastlegOrderNoGenerator orderNoGenerator;
@Resource private InventoryCommandService inventory;
@Resource private InventoryCommandMapper inventoryCommands;
@@ -93,6 +132,7 @@ public class WarehouseTransferService {
if (itemPackages != request.packageCount()) {
throw badRequest("Cargo item package total must equal transfer package count");
}
validateRelabelRequest(request);
String key = request.idempotencyKey().trim();
String requestHash = hash(request);
WarehouseTransferOrder replay = transfers.findKey(customerId, key);
@@ -142,10 +182,16 @@ public class WarehouseTransferService {
transfer.setFbaWarehouseCode(trim(request.fbaWarehouseCode()));
transfer.setDeliveryAddress(request.deliveryAddress().trim()); transfer.setPackageCount(request.packageCount());
transfer.setDeliveryMethod(request.deliveryMethod().trim()); transfer.setEstimatedArrivalAt(request.estimatedArrivalAt());
transfer.setEstimatedShipAt(request.estimatedShipAt());
transfer.setFbaShipmentNo(trim(request.fbaShipmentNo()));
transfer.setTrackingNo("CUSTOMER_DELIVERY".equals(request.deliveryMethod().trim()) ? trim(request.trackingNo()) : null);
transfer.setFbaBoxNos(trim(request.fbaBoxNos())); transfer.setRemark(trim(request.remark()));
transfer.setNeedRelabel(request.needRelabel()); transfer.setStatus(PENDING_REVIEW);
transfer.setLabelDeadline(request.labelDeadline());
transfer.setRelabelContactName(trim(request.relabelContactName()));
transfer.setRelabelContactPhone(trim(request.relabelContactPhone()));
transfer.setRelabelChargeAmount(request.relabelChargeAmount());
transfer.setRelabelDeliveryMethod(trim(request.relabelDeliveryMethod()));
transfer.setIdempotencyKey(key); transfer.setRequestHash(requestHash); transfer.setCreatedBy(trim(operatorId));
transfer.setCreatedAt(now); transfer.setUpdatedAt(now);
transfers.insert(transfer);
@@ -164,14 +210,17 @@ public class WarehouseTransferService {
sourceOrders.insert(link);
}
if (request.needRelabel() && request.relabelItems() != null) insertRelabelBindings(customerId, transferId, request.relabelItems(), now);
int cartonIndex = 0;
for (int index = 0; index < request.items().size(); index++) {
WarehouseTransferRequests.Item source = request.items().get(index);
long itemId = insertTransferItem(customerId, transferId, source, now);
List<String> customerMarks = splitMarks(source.shippingMark());
for (int itemCarton = 0; itemCarton < source.packageCount(); itemCarton++) {
cartonIndex++;
insertTransferCarton(customerId, transferId, itemId, transferNo,
cartonIndex, request.packageCount(), now);
cartonIndex, request.packageCount(), itemCarton < customerMarks.size() ? customerMarks.get(itemCarton) : null, now);
}
insertOrderDetail(customerId, order.getId(), transferNo, index, source, request, now);
}
@@ -213,16 +262,36 @@ public class WarehouseTransferService {
List<WarehouseTransferResponses.Item> items = transferItems.selectList(
new LambdaQueryWrapper<WarehouseTransferItem>().eq(WarehouseTransferItem::getCustomerId, customerId)
.eq(WarehouseTransferItem::getTransferOrderId, transfer.getId()).orderByAsc(WarehouseTransferItem::getId))
.stream().map(value -> new WarehouseTransferResponses.Item(value.getSkuCode(), value.getProductNameZh(),
.stream().map(value -> new WarehouseTransferResponses.Item(value.getId(), value.getSkuCode(), value.getProductNameZh(),
value.getPackageCount(), value.getShippingMark())).toList();
Map<Long, WarehouseTransferResponses.Item> itemById = items.stream()
.collect(Collectors.toMap(WarehouseTransferResponses.Item::id, Function.identity()));
List<WarehouseTransferResponses.SourceOrder> sources = sourceOrders.selectList(
new LambdaQueryWrapper<WarehouseTransferSourceOrder>().eq(WarehouseTransferSourceOrder::getCustomerId, customerId)
.eq(WarehouseTransferSourceOrder::getTransferOrderId, transfer.getId()).orderByAsc(WarehouseTransferSourceOrder::getId))
.stream().map(value -> new WarehouseTransferResponses.SourceOrder(value.getSourceLastlegOrderId(),
value.getSourceLastlegOrderNo(), value.getInboundOrderId(), value.getInboundCode())).toList();
List<WarehouseTransferResponses.Carton> cartons = transferCartons.listOrder(customerId, transfer.getId())
.stream().map(value -> new WarehouseTransferResponses.Carton(value.getCartonMark(),
.stream().map(value -> new WarehouseTransferResponses.Carton(value.getCartonMark(), value.getCustomerCartonMark(),
value.getCartonIndex(), value.getTotalCartons(), value.getStatus())).toList();
List<WarehouseTransferPalletItem> palletItemRows = transferPalletItems == null
? List.of() : transferPalletItems.listOrder(customerId, transfer.getId());
Map<Long, List<WarehouseTransferPalletItem>> palletItemsByPallet = palletItemRows.stream()
.collect(Collectors.groupingBy(WarehouseTransferPalletItem::getTransferPalletId));
List<WarehouseTransferResponses.Pallet> pallets = transferPallets == null ? List.of() : transferPallets.listOrder(customerId, transfer.getId()).stream()
.map(value -> new WarehouseTransferResponses.Pallet(value.getId(), value.getSequenceNo(),
value.getWarehousePalletId(), value.getPalletNo(), value.getWarehouseLocationId(),
value.getWarehouseLocationCode(), value.getCartonCount(), value.getLengthCm(), value.getWidthCm(),
value.getHeightCm(), value.getGrossWeightKg(), value.getVolumeCbm(), value.getStatus(),
palletItemsByPallet.getOrDefault(value.getId(), List.of()).stream().map(row -> {
WarehouseTransferResponses.Item item = itemById.get(row.getTransferItemId());
return new WarehouseTransferResponses.PalletItem(row.getTransferItemId(),
item == null ? null : item.skuCode(), row.getPackageCount());
}).toList(), value.getCompletedAt())).toList();
List<WarehouseTransferResponses.RelabelBinding> bindings = relabelBindings == null ? List.of() : relabelBindings.listOrder(customerId, transfer.getId())
.stream().map(value -> new WarehouseTransferResponses.RelabelBinding(value.getOperationType(),
value.getCustomerCartonMark(), value.getOriginalSku(), value.getNewSku(), value.getQuantity(),
value.getFbaCartonMark(), value.getDestinationWarehouseCode(), value.getRemark())).toList();
Map<Long, WarehouseTransferReceipt> receiptByAllocation = transferReceipts.selectList(
new LambdaQueryWrapper<WarehouseTransferReceipt>().eq(WarehouseTransferReceipt::getCustomerId, customerId)
.eq(WarehouseTransferReceipt::getTransferOrderId, transfer.getId()))
@@ -252,9 +321,32 @@ public class WarehouseTransferService {
Boolean.TRUE.equals(transfer.getNeedRelabel()), transfer.getRelabelApplicationId(), transfer.getRelabelNo(),
relabelStatus,
transfer.getOperatingWarehouseId(), transfer.getFbaWarehouseCode(), transfer.getDeliveryAddress(),
transfer.getPackageCount(), transfer.getDeliveryMethod(), transfer.getEstimatedArrivalAt(), transfer.getFbaShipmentNo(),
transfer.getTrackingNo(), transfer.getFbaBoxNos(), transfer.getRemark(), sources, items, cartons, allocations,
transfer.getOutboundAt(), transfer.getCreatedAt());
transfer.getPackageCount(), transfer.getDeliveryMethod(), transfer.getEstimatedArrivalAt(),
transfer.getEstimatedShipAt(), transfer.getFbaShipmentNo(), transfer.getTrackingNo(), transfer.getFbaBoxNos(),
transfer.getRemark(), sources, items, cartons, pallets, bindings, allocations,
transfer.getCustomerConfirmedAt(), transfer.getOutboundAt(), transfer.getCreatedAt());
}
@Transactional(readOnly = true)
public WarehouseTransferResponses.Label cartonLabel(String customerId, String transferNo) {
WarehouseTransferOrder transfer = transfers.findNo(customerId, transferNo);
if (transfer == null) throw notFound("Warehouse transfer order not found");
List<WarehouseTransferResponses.Carton> cartons = transferCartons.listOrder(customerId, transfer.getId()).stream()
.map(value -> new WarehouseTransferResponses.Carton(value.getCartonMark(), value.getCustomerCartonMark(), value.getCartonIndex(), value.getTotalCartons(), value.getStatus())).toList();
StringBuilder labels = new StringBuilder();
for (WarehouseTransferResponses.Carton carton : cartons) {
labels.append("<section class=label><div class=title>仓储转运箱唛</div><div class=mark>")
.append(escape(carton.cartonMark())).append("</div><div class=meta>")
.append(carton.cartonIndex()).append(" / ").append(carton.totalCartons()).append(" 转运单号:")
.append(escape(transferNo)).append("</div>");
if (text(carton.customerCartonMark()) != null) labels.append("<div class=customer>客户箱唛:")
.append(escape(carton.customerCartonMark())).append("</div>");
labels.append("</section>");
}
String html = "<!doctype html><html><head><meta charset=utf-8><title>" + escape(transferNo) + "-箱唛</title><style>"
+ "@page{size:100mm 100mm;margin:8mm}body{margin:0;font-family:Arial,'Microsoft YaHei',sans-serif;color:#111}.label{height:84mm;display:flex;flex-direction:column;align-items:center;justify-content:center;page-break-after:always;border:1px solid #222;box-sizing:border-box;padding:8mm}.title{font-size:18px;font-weight:700}.mark{margin:12mm 0 6mm;font-size:30px;font-weight:700;letter-spacing:1px;word-break:break-all;text-align:center}.meta,.customer{font-size:12px;margin-top:3mm}</style></head><body>"
+ labels + "</body></html>";
return new WarehouseTransferResponses.Label(transferNo, cartons, html);
}
@Transactional
@@ -269,15 +361,18 @@ 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())) {
if (next.equals(transfer.getStatus())) return detail(customerId, transferNo);
if (next.equals(transfer.getStatus()) || APPROVED.equals(next)
&& Set.of(WAIT_PDA_RECEIVING, WAIT_CUSTOMER_CONFIRMATION, READY_FOR_DISPATCH, WAIT_RELABEL)
.contains(transfer.getStatus())) return detail(customerId, transferNo);
throw conflict("Warehouse transfer order was already reviewed");
}
LastlegOrder order = orders.selectById(transfer.getLastlegOrderId());
if (order == null || !customerId.equals(order.getCustomerId())) throw conflict("Linked tail-leg order is missing");
LocalDateTime now = LocalDateTime.now();
String before = order.getCurrentStatus();
transfer.setStatus(next); transfer.setUpdatedAt(now); transfers.updateById(transfer);
boolean incomingTransit = transfer.getTransferNo().equals(transfer.getInboundCode());
String transferStatus = APPROVED.equals(next) && incomingTransit ? WAIT_PDA_RECEIVING : next;
transfer.setStatus(transferStatus); transfer.setUpdatedAt(now); transfers.updateById(transfer);
String orderStatus = APPROVED.equals(next)
? (incomingTransit
? ("WAREHOUSE_PICKUP".equals(transfer.getDeliveryMethod()) ? "WAIT_PICKUP" : "WAIT_DELIVERY")
@@ -294,6 +389,191 @@ public class WarehouseTransferService {
return detail(customerId, transferNo);
}
@Transactional
public WarehouseTransferResponses.Detail palletize(String operatorId, long overseasWarehouseId,
String transferNo,
WarehouseTransferRequests.Palletize request) {
WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(overseasWarehouseId));
if (transfer == null) throw notFound("Warehouse transfer order not found");
if (!String.valueOf(overseasWarehouseId).equals(transfer.getOperatingWarehouseId())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Warehouse transfer belongs to another warehouse");
}
String customerId = transfer.getCustomerId();
String requestHash = hash(request);
if (WAIT_CUSTOMER_CONFIRMATION.equals(transfer.getStatus())
&& request.idempotencyKey().equals(transfer.getPalletizeIdempotencyKey())) {
if (!requestHash.equals(transfer.getPalletizeRequestHash())) {
throw conflict("Palletization idempotency key was reused with different data");
}
return detail(customerId, transferNo);
}
if (!WAIT_PDA_RECEIVING.equals(transfer.getStatus())) {
throw conflict("Warehouse transfer is not awaiting PDA receiving");
}
List<WarehouseTransferItem> items = transferItems.selectList(new LambdaQueryWrapper<WarehouseTransferItem>()
.eq(WarehouseTransferItem::getCustomerId, customerId)
.eq(WarehouseTransferItem::getTransferOrderId, transfer.getId()).orderByAsc(WarehouseTransferItem::getId));
Map<Long, WarehouseTransferItem> itemById = items.stream()
.collect(Collectors.toMap(WarehouseTransferItem::getId, Function.identity()));
Map<Long, Integer> totals = new java.util.HashMap<>();
int palletCartons = 0;
for (WarehouseTransferRequests.Pallet pallet : request.pallets()) {
int itemTotal = pallet.items().stream().mapToInt(WarehouseTransferRequests.PalletItem::packageCount).sum();
if (itemTotal != pallet.cartonCount()) throw badRequest("Each pallet carton count must equal its SKU carton total");
palletCartons = Math.addExact(palletCartons, pallet.cartonCount());
Set<Long> uniqueItems = new HashSet<>();
for (WarehouseTransferRequests.PalletItem item : pallet.items()) {
if (!uniqueItems.add(item.transferItemId()) || !itemById.containsKey(item.transferItemId())) {
throw badRequest("Pallet SKU rows must be unique and belong to this transfer");
}
totals.merge(item.transferItemId(), item.packageCount(), Math::addExact);
}
}
if (palletCartons != transfer.getPackageCount()) throw badRequest("Pallet carton total must equal transfer package count");
for (WarehouseTransferItem item : items) {
if (totals.getOrDefault(item.getId(), 0) != item.getPackageCount()) {
throw badRequest("Palletized carton total does not match declared SKU " + item.getSkuCode());
}
}
String batchKey = "WAREHOUSE_TRANSFER:" + transferNo + ":PALLETIZE";
List<WarehouseResourceGateway.PalletBatchItemRequest> batchItems = new ArrayList<>();
for (int index = 0; index < request.pallets().size(); index++) {
WarehouseTransferRequests.Pallet pallet = request.pallets().get(index);
batchItems.add(new WarehouseResourceGateway.PalletBatchItemRequest(Long.parseLong(customerId),
transferNo + ":" + (index + 1), transferNo, index + 1, "STANDARD",
trim(transfer.getFbaWarehouseCode()), trim(pallet.palletCode())));
}
WarehouseResourceGateway.PalletBatchResponse ensured = warehouseResources.ensurePalletBatch(
new WarehouseResourceGateway.PalletBatchEnsure(overseasWarehouseId, batchKey, batchItems));
WarehouseResourceGateway.PalletBatchResponse confirmed = warehouseResources.confirmPalletBatch(
new WarehouseResourceGateway.PalletBatchCommand(overseasWarehouseId, batchKey,
request.idempotencyKey() + ":CONFIRM"));
if (confirmed == null || confirmed.items() == null || confirmed.items().size() != request.pallets().size()) {
throw conflict("Warehouse pallet batch did not return every pallet");
}
LocalDateTime now = LocalDateTime.now();
for (int index = 0; index < request.pallets().size(); index++) {
int sequenceNo = index + 1;
WarehouseTransferRequests.Pallet source = request.pallets().get(index);
WarehouseResourceGateway.PalletBatchItem resource = confirmed.items().stream()
.filter(value -> value.sequenceNo() == sequenceNo).findFirst()
.orElseThrow(() -> conflict("Warehouse pallet sequence is incomplete"));
WarehouseResourceGateway.WarehouseLocationReference location = warehouseResources.resolveLocation(
overseasWarehouseId, resource.locationId());
WarehouseTransferPallet pallet = new WarehouseTransferPallet();
pallet.setId(IdWorker.getId()); pallet.setTransferOrderId(transfer.getId()); pallet.setCustomerId(customerId);
pallet.setSequenceNo(sequenceNo); pallet.setWarehousePalletId(resource.palletId());
pallet.setPalletNo(resource.palletCode()); pallet.setWarehouseLocationId(resource.locationId());
pallet.setWarehouseLocationCode(location.locationCode()); pallet.setWarehouseResourceLockId(resource.resourceLockId());
// 转运测量以打托后的托盘为单位,体积由 PDA 直接录入托盘总体积,不再按单箱尺寸换算。
// 旧表字段仍为 NOT NULL;按托模式不采集长宽高,以 0 兼容存储且不得参与总体积计算。
pallet.setLengthCm(BigDecimal.ZERO); pallet.setWidthCm(BigDecimal.ZERO); pallet.setHeightCm(BigDecimal.ZERO);
pallet.setCartonCount(source.cartonCount()); pallet.setGrossWeightKg(source.grossWeightKg());
pallet.setVolumeCbm(source.volumeCbm().setScale(6, RoundingMode.HALF_UP));
pallet.setStatus("COMPLETED"); pallet.setCompletedAt(now); pallet.setCreatedAt(now); pallet.setUpdatedAt(now);
transferPallets.insert(pallet);
for (WarehouseTransferRequests.PalletItem sourceItem : source.items()) {
WarehouseTransferPalletItem row = new WarehouseTransferPalletItem();
row.setId(IdWorker.getId()); row.setTransferOrderId(transfer.getId()); row.setTransferPalletId(pallet.getId());
row.setTransferItemId(sourceItem.transferItemId()); row.setCustomerId(customerId);
row.setPackageCount(sourceItem.packageCount()); row.setCreatedAt(now); transferPalletItems.insert(row);
}
}
transfer.setStatus(WAIT_CUSTOMER_CONFIRMATION); 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(WAIT_CUSTOMER_CONFIRMATION); order.setPalletCount(request.pallets().size());
order.setVolumeCbm(transferPallets.listOrder(customerId, transfer.getId()).stream()
.map(WarehouseTransferPallet::getVolumeCbm).reduce(BigDecimal.ZERO, BigDecimal::add));
order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + WAIT_CUSTOMER_CONFIRMATION); order.setUpdatedAt(now); orders.updateById(order);
insertOrderHistory(customerId, order.getId(), before, order.getCurrentStatus(), transferNo,
"PDA receiving measurement and palletization completed", now);
return detail(customerId, transferNo);
}
@Transactional
public WarehouseTransferResponses.Detail confirmMeasurement(String customerId, String transferNo,
String operatorId,
WarehouseTransferRequests.CustomerConfirm request) {
WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo);
if (transfer == null) throw notFound("Warehouse transfer order not found");
String requestHash = hash(request);
if (Set.of(READY_FOR_DISPATCH, WAIT_RELABEL, OUTBOUNDED).contains(transfer.getStatus())
&& request.idempotencyKey().equals(transfer.getConfirmationIdempotencyKey())) {
if (!requestHash.equals(transfer.getConfirmationRequestHash())) {
throw conflict("Confirmation idempotency key was reused with different data");
}
return detail(customerId, transferNo);
}
if (!WAIT_CUSTOMER_CONFIRMATION.equals(transfer.getStatus())) {
throw conflict("Warehouse transfer measurement is not awaiting customer confirmation");
}
List<WarehouseTransferItem> items = transferItems.selectList(new LambdaQueryWrapper<WarehouseTransferItem>()
.eq(WarehouseTransferItem::getCustomerId, customerId)
.eq(WarehouseTransferItem::getTransferOrderId, transfer.getId()).orderByAsc(WarehouseTransferItem::getId));
Map<Long, WarehouseTransferItem> itemById = items.stream()
.collect(Collectors.toMap(WarehouseTransferItem::getId, Function.identity()));
List<WarehouseTransferPallet> pallets = transferPallets.listOrder(customerId, transfer.getId());
List<WarehouseTransferPalletItem> palletItems = transferPalletItems.listOrder(customerId, transfer.getId());
WarehouseResourceGateway.WarehouseReference warehouse = warehouseResources.requireActiveSharedWarehouse(
Long.parseLong(transfer.getOperatingWarehouseId()));
Map<String, List<ConfirmedStockSource>> stockSourcesBySku = new java.util.LinkedHashMap<>();
LocalDateTime now = LocalDateTime.now();
for (WarehouseTransferPallet pallet : pallets) {
for (WarehouseTransferPalletItem row : palletItems.stream()
.filter(value -> value.getTransferPalletId().equals(pallet.getId())).toList()) {
WarehouseTransferItem item = itemById.get(row.getTransferItemId());
String operationKey = "WAREHOUSE_TRANSFER_STOCK_IN:" + transferNo + ":" + pallet.getSequenceNo() + ":" + item.getId();
InventoryCommandResponse result = inventory.stockIn(customerId, operatorId,
new InventoryCommandRequests.StockIn(customerId, "客户-" + customerId,
transfer.getOperatingWarehouseId(), warehouse.warehouseCode(), warehouse.warehouseName(),
null, trim(transfer.getFbaWarehouseCode()), trim(transfer.getFbaWarehouseCode()),
transfer.getInboundOrderId(), transferNo, transferNo, item.getSkuCode(), item.getProductNameZh(), null,
pallet.getWarehousePalletId(), pallet.getPalletNo(), transferNo, pallet.getWarehouseLocationId(),
pallet.getWarehouseLocationCode(), row.getPackageCount(), transferNo,
"WAREHOUSE_TRANSFER_CONFIRMED:" + transferNo, operationKey, trim(request.remark())));
stockSourcesBySku.computeIfAbsent(item.getSkuCode(), ignored -> new ArrayList<>())
.add(new ConfirmedStockSource(result.balanceId(), result.positionId(), row.getPackageCount()));
InboundStockReceipt receipt = new InboundStockReceipt();
receipt.setId(IdWorker.getId()); receipt.setCustomerId(customerId);
// 转运收货没有独立 PDA 入仓任务,使用本业务链的稳定主键填充通用收货快照维度。
receipt.setPlanId(transfer.getInboundOrderId()); receipt.setTaskId(transfer.getId());
receipt.setAllocationId(row.getId()); receipt.setLineId(item.getId());
receipt.setOrderId(transfer.getLastlegOrderId());
receipt.setInboundCode(transferNo); receipt.setSkuCode(item.getSkuCode()); receipt.setProductNameZh(item.getProductNameZh());
receipt.setWarehousePalletId(pallet.getWarehousePalletId()); receipt.setPalletNo(pallet.getPalletNo());
receipt.setWarehouseLocationId(pallet.getWarehouseLocationId()); receipt.setWarehouseLocationCode(pallet.getWarehouseLocationCode());
receipt.setBatchNo(transferNo); receipt.setReceivedPackages(Long.valueOf(row.getPackageCount()));
receipt.setInventoryBalanceId(result.balanceId()); receipt.setInventoryPositionId(result.positionId());
receipt.setInventoryOperationKey(operationKey); receipt.setReceivedAt(now); receipt.setCreatedAt(now); stockReceipts.insert(receipt);
}
}
if (Boolean.TRUE.equals(transfer.getNeedRelabel())) {
RelabelResponses.Detail relabel = createLinkedRelabel(customerId, operatorId, transfer, itemById,
stockSourcesBySku);
transfer.setRelabelApplicationId(relabel.application().id());
transfer.setRelabelNo(relabel.application().relabelNo()); transfer.setStatus(WAIT_RELABEL);
} else {
if (!StringUtils.hasText(transfer.getFbaWarehouseCode())) {
throw conflict("A destination warehouse is required before dispatch");
}
createDispatchPlan(customerId, transfer, items, pallets, palletItems, now);
transfer.setStatus(READY_FOR_DISPATCH);
}
transfer.setConfirmationIdempotencyKey(request.idempotencyKey().trim());
transfer.setConfirmationRequestHash(requestHash); transfer.setCustomerConfirmedAt(now); transfer.setUpdatedAt(now);
transfers.updateById(transfer);
LastlegOrder order = requireTailOrder(customerId, transfer); String before = order.getCurrentStatus();
order.setCurrentStatus(Boolean.TRUE.equals(transfer.getNeedRelabel()) ? WAIT_RELABEL : READY_FOR_DISPATCH);
order.setRelabelStatus(Boolean.TRUE.equals(transfer.getNeedRelabel()) ? "PENDING_PROCESSING" : "NOT_REQUIRED");
order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + transfer.getStatus() + ";RELABEL:" + order.getRelabelStatus());
order.setUpdatedAt(now); orders.updateById(order);
insertOrderHistory(customerId, order.getId(), before, order.getCurrentStatus(), transferNo,
"Customer confirmed PDA measurements; actual inventory stocked in", now);
return detail(customerId, transferNo);
}
@Transactional
public WarehouseTransferResponses.Detail allocate(String customerId, String transferNo, String operatorId,
WarehouseTransferRequests.Allocate request) {
@@ -490,6 +770,182 @@ public class WarehouseTransferService {
return detail(customerId, transferNo);
}
@Transactional(readOnly = true)
public List<WarehouseTransferResponses.Summary> pdaCandidates(long overseasWarehouseId) {
return transfers.pdaCandidates(String.valueOf(overseasWarehouseId)).stream().map(value ->
new WarehouseTransferResponses.Summary(value.getId(), value.getTransferNo(), value.getInboundCode(),
value.getLastlegOrderId(), value.getLastlegOrderNo(), value.getOperatingWarehouseId(),
value.getFbaWarehouseCode(), value.getPackageCount(), value.getDeliveryMethod(),
Boolean.TRUE.equals(value.getNeedRelabel()), value.getRelabelNo(), value.getStatus(),
value.getEstimatedArrivalAt(), value.getCreatedAt())).toList();
}
private void validateRelabelRequest(WarehouseTransferRequests.Create request) {
if (!request.needRelabel()) {
if (request.relabelItems() != null && !request.relabelItems().isEmpty()) {
throw badRequest("Relabel items require needRelabel=true");
}
return;
}
if (request.relabelItems() == null || request.relabelItems().isEmpty()) return;
if (request.labelDeadline() == null || !StringUtils.hasText(request.relabelContactName())
|| !StringUtils.hasText(request.relabelContactPhone()) || request.relabelChargeAmount() == null) {
throw badRequest("Relabel deadline, contact and charge are required");
}
Set<String> declaredSkus = request.items().stream().map(item -> item.skuCode().trim()).collect(Collectors.toSet());
Set<String> marks = new HashSet<>();
for (WarehouseTransferRequests.RelabelItem row : request.relabelItems()) {
String type = row.operationType().trim().toUpperCase();
if (!Set.of("CARTON_LABEL", "SKU_LABEL", "CARTON_AND_SKU").contains(type)) {
throw badRequest("Unsupported relabel operation type");
}
if (!declaredSkus.contains(row.originalSku().trim())) {
throw badRequest("Relabel SKU must exist in transfer cargo: " + row.originalSku());
}
if (("CARTON_LABEL".equals(type) || "CARTON_AND_SKU".equals(type))
&& (!StringUtils.hasText(row.fbaCartonMark()) || !StringUtils.hasText(row.destinationWarehouseCode()))) {
throw badRequest("Carton relabel requires new FBA carton mark and destination warehouse");
}
if (("SKU_LABEL".equals(type) || "CARTON_AND_SKU".equals(type)) && !StringUtils.hasText(row.newSku())) {
throw badRequest("SKU relabel requires a new SKU");
}
for (String mark : row.customerCartonMarks()) {
if (!marks.add(mark.trim().toUpperCase())) throw badRequest("Customer carton marks must be unique");
}
}
if (marks.size() > request.packageCount()) throw badRequest("Relabel carton marks cannot exceed transfer cartons");
}
private void insertRelabelBindings(String customerId, long transferId,
List<WarehouseTransferRequests.RelabelItem> rows, LocalDateTime now) {
if (rows == null || rows.isEmpty()) return;
for (WarehouseTransferRequests.RelabelItem source : rows) {
for (String mark : source.customerCartonMarks()) {
WarehouseTransferRelabelBinding row = new WarehouseTransferRelabelBinding();
row.setId(IdWorker.getId()); row.setTransferOrderId(transferId); row.setCustomerId(customerId);
row.setOperationType(source.operationType().trim().toUpperCase());
row.setCustomerCartonMark(mark.trim()); row.setOriginalSku(source.originalSku().trim());
row.setNewSku(trim(source.newSku())); row.setQuantity(source.quantity());
row.setFbaCartonMark(trim(source.fbaCartonMark()));
row.setDestinationWarehouseCode(trim(source.destinationWarehouseCode()));
row.setRemark(trim(source.remark())); row.setCreatedAt(now); relabelBindings.insert(row);
}
}
}
private RelabelResponses.Detail createLinkedRelabel(String customerId, String operatorId,
WarehouseTransferOrder transfer,
Map<Long, WarehouseTransferItem> itemById,
Map<String, List<ConfirmedStockSource>> stockSourcesBySku) {
Map<String, WarehouseTransferItem> itemsBySku = itemById.values().stream()
.collect(Collectors.toMap(WarehouseTransferItem::getSkuCode, Function.identity(), (left, right) -> left));
List<RelabelRequests.Item> items = new ArrayList<>();
for (WarehouseTransferRelabelBinding binding : relabelBindings.listOrder(customerId, transfer.getId())) {
WarehouseTransferItem source = itemsBySku.get(binding.getOriginalSku());
ConfirmedStockSource stockSource = nextStockSource(stockSourcesBySku.get(binding.getOriginalSku()));
if (source == null || stockSource == null) {
throw conflict("Relabel binding cannot find confirmed inventory for SKU " + binding.getOriginalSku());
}
List<RelabelRequests.SkuItem> skuItems = Set.of("SKU_LABEL", "CARTON_AND_SKU").contains(binding.getOperationType())
? List.of(new RelabelRequests.SkuItem(binding.getOriginalSku(), binding.getNewSku(),
binding.getQuantity(), binding.getRemark())) : List.of();
items.add(new RelabelRequests.Item(stockSource.balanceId, stockSource.positionId, transfer.getInboundOrderId(),
binding.getOriginalSku(), null, null, null, source.getProductNameZh(), 1, 1,
StringUtils.hasText(transfer.getFbaShipmentNo()) ? transfer.getFbaShipmentNo() : transfer.getTransferNo(),
transfer.getTrackingNo(), binding.getNewSku(), "仓储转运自动换标", binding.getRemark(),
null, null, null, binding.getCustomerCartonMark(), binding.getFbaCartonMark(),
binding.getDestinationWarehouseCode(), binding.getOperationType(), skuItems));
}
RelabelRequests.Create request = new RelabelRequests.Create(transfer.getTransferNo(),
transfer.getOperatingWarehouseId(), transfer.getLabelDeadline(), transfer.getRelabelContactName(),
transfer.getRelabelContactPhone(), transfer.getRelabelChargeAmount(), "仓储转运确认后自动创建换标单",
"WAREHOUSE_TRANSFER:" + transfer.getTransferNo() + ":RELABEL", items,
trim(transfer.getRelabelDeliveryMethod()), false);
return relabelService.create(customerId, operatorId, request);
}
private static ConfirmedStockSource nextStockSource(List<ConfirmedStockSource> sources) {
if (sources == null) return null;
for (ConfirmedStockSource source : sources) {
if (source.remainingPackages > 0) {
source.remainingPackages--;
return source;
}
}
return null;
}
private static final class ConfirmedStockSource {
private final Long balanceId;
private final Long positionId;
private int remainingPackages;
private ConfirmedStockSource(Long balanceId, Long positionId, int remainingPackages) {
this.balanceId = balanceId;
this.positionId = positionId;
this.remainingPackages = remainingPackages;
}
}
private void createDispatchPlan(String customerId, WarehouseTransferOrder transfer,
List<WarehouseTransferItem> items, List<WarehouseTransferPallet> pallets,
List<WarehouseTransferPalletItem> palletItems, LocalDateTime now) {
long planId = IdWorker.getId();
InboundPlan plan = new InboundPlan(); plan.setId(planId); plan.setPlanNo("WT-" + transfer.getTransferNo());
plan.setCustomerId(customerId); plan.setSourceOrderId(transfer.getLastlegOrderId());
plan.setContainerNo(transfer.getTransferNo()); plan.setOverseasWarehouseId(Long.parseLong(transfer.getOperatingWarehouseId()));
plan.setPlannedArrivalDate(transfer.getEstimatedArrivalAt().toLocalDate()); plan.setPalletType("STANDARD");
plan.setAssignedFlag(true); plan.setMergeRequired(false); plan.setUrgentFlag(false);
plan.setCreationMode("WAREHOUSE_TRANSFER"); plan.setPlanRemark("Customer-confirmed transfer measurements");
plan.setStatus("COMPLETED"); plan.setLineCount(items.size()); plan.setPalletCount(pallets.size());
plan.setTotalVolumeCbm(pallets.stream().map(WarehouseTransferPallet::getVolumeCbm)
.reduce(BigDecimal.ZERO, BigDecimal::add)); plan.setCreatedAt(now); plan.setUpdatedAt(now); plan.setVersion(0);
inboundPlans.insert(plan);
Map<Long, Long> lineIds = new java.util.HashMap<>();
for (WarehouseTransferItem source : items) {
InboundPlanLine line = new InboundPlanLine(); line.setId(IdWorker.getId()); line.setCustomerId(customerId);
line.setPlanId(planId); line.setOrderId(transfer.getLastlegOrderId()); line.setInboundCode(transfer.getTransferNo());
line.setDestinationType("FBA"); line.setFbaWarehouseCode(transfer.getFbaWarehouseCode());
line.setDestinationWarehouseCode(transfer.getFbaWarehouseCode()); line.setDestinationWarehouseName(transfer.getFbaWarehouseCode());
line.setVolumeCbm(BigDecimal.ZERO); line.setRequiredPackageCount(source.getPackageCount());
line.setPlannedPalletCount((int) palletItems.stream().filter(row -> row.getTransferItemId().equals(source.getId())).count());
inboundPlanLines.insert(line); lineIds.put(source.getId(), line.getId());
}
for (WarehouseTransferPallet source : pallets) {
InboundPlanPallet pallet = new InboundPlanPallet(); pallet.setId(IdWorker.getId()); pallet.setCustomerId(customerId);
pallet.setPlanId(planId); pallet.setPalletNo(source.getPalletNo()); pallet.setPalletType("STANDARD");
pallet.setDestinationWarehouseCode(transfer.getFbaWarehouseCode()); pallet.setDestinationWarehouseName(transfer.getFbaWarehouseCode());
pallet.setAllocatedVolumeCbm(source.getVolumeCbm()); pallet.setSequenceNo(source.getSequenceNo());
pallet.setWarehousePalletId(source.getWarehousePalletId()); pallet.setWarehouseLocationId(source.getWarehouseLocationId());
pallet.setWarehouseResourceLockId(source.getWarehouseResourceLockId()); pallet.setAssignmentStatus("ASSIGNED");
pallet.setLockedAt(source.getCompletedAt()); pallet.setActualVolumeCbm(source.getVolumeCbm());
pallet.setActualPackageCount(source.getCartonCount()); inboundPlanPallets.insert(pallet);
for (WarehouseTransferPalletItem sourceItem : palletItems.stream()
.filter(row -> row.getTransferPalletId().equals(source.getId())).toList()) {
InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId());
allocation.setCustomerId(customerId); allocation.setPlanId(planId); allocation.setLineId(lineIds.get(sourceItem.getTransferItemId()));
allocation.setPalletId(pallet.getId()); allocation.setAllocatedVolumeCbm(BigDecimal.ZERO);
allocation.setPlannedPackageCount(sourceItem.getPackageCount()); allocation.setCountedPackageCount(sourceItem.getPackageCount());
allocation.setPickedPackageCount(0); allocation.setActualVolumeCbm(source.getVolumeCbm());
allocation.setStatus("COMPLETED"); allocation.setVersion(0); inboundPlanAllocations.insert(allocation);
}
}
}
private static List<String> splitMarks(String value) {
if (!StringUtils.hasText(value)) return List.of();
return java.util.Arrays.stream(value.split("[,\\n\\r]+"))
.map(String::trim).filter(StringUtils::hasText).distinct().toList();
}
private static String escape(String value) {
return org.springframework.web.util.HtmlUtils.htmlEscape(Objects.toString(value, ""));
}
private static String text(String value) {
return StringUtils.hasText(value) ? value.trim() : null;
}
private Long createInboundSnapshot(String customerId, long inboundOrderId, String transferNo,
WarehouseTransferRequests.Create request, String requestHash,
LocalDateTime now) {
@@ -544,10 +1000,11 @@ public class WarehouseTransferService {
private void insertTransferCarton(String customerId, long transferId, long transferItemId,
String transferNo, int cartonIndex, int totalCartons,
LocalDateTime now) {
String customerCartonMark, LocalDateTime now) {
WarehouseTransferCarton carton = new WarehouseTransferCarton();
carton.setId(IdWorker.getId()); carton.setTransferOrderId(transferId); carton.setTransferItemId(transferItemId);
carton.setCustomerId(customerId); carton.setCartonMark(transferNo + "#" + totalCartons + "-" + cartonIndex);
carton.setCustomerCartonMark(trim(customerCartonMark));
carton.setCartonIndex(cartonIndex); carton.setTotalCartons(totalCartons); carton.setStatus("EXPECTED");
carton.setCreatedAt(now); carton.setUpdatedAt(now); transferCartons.insert(carton);
}