This commit is contained in:
2026-09-10 17:11:33 +08:00
parent 1ee32b7a6f
commit 2e6a18c58e
54 changed files with 2094 additions and 167 deletions
@@ -37,7 +37,7 @@ public class DeliveryTransferController {
@PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CREATE + "')")
@Operation(summary = "Create one-click dropship outbound and push a PDA picking task")
public ApiResponse<DeliveryResponses.PdaTask> createOneClick(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
@Valid @RequestBody DeliveryRequests.OneClickOutbound request) {
return ApiResponse.success(service.createOneClick(requiredCustomer(user, customerId), operator(user), request));
}
@@ -80,7 +80,12 @@ public final class DeliveryRequests {
}
}
public record PdaPick(@NotNull Long outboundPalletItemId, Long inventoryPositionId,
@NotNull @Min(1) Long packages, @NotBlank @Size(max=255) String idempotencyKey) {}
@NotNull @Min(1) Long packages, @NotBlank @Size(max=255) String idempotencyKey,
@Size(max=500) List<@NotBlank @Size(max=255) String> cartonMarks) {
public PdaPick(Long outboundPalletItemId, Long inventoryPositionId, Long packages, String idempotencyKey) {
this(outboundPalletItemId, inventoryPositionId, packages, idempotencyKey, List.of());
}
}
public record PdaComplete(@NotBlank @Size(max=255) String idempotencyKey) {}
public record Cancel(@NotBlank @Size(max=1000) String reason) {}
}
@@ -50,6 +50,12 @@ import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.relabel.service.RelabelApplicationService;
import com.baiyue.lastleg.relabel.dto.RelabelRequests;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
@@ -88,6 +94,9 @@ public class DeliveryTransferService {
@Resource private LastlegOrderMapper tailOrders;
@Resource private LastlegOrderStatusHistoryMapper histories;
@Resource private LastlegOrderDetailMapper tailOrderDetails;
@Resource private RelabelApplicationMapper relabelApplications;
@Resource private RelabelItemMapper relabelItems;
@Resource private RelabelApplicationService relabelService;
@Resource private LastlegOrderNoGenerator orderNoGenerator;
@Transactional
@@ -358,7 +367,7 @@ public class DeliveryTransferService {
if (replay != null) {
if (!task.getId().equals(replay.getTaskId())
|| !request.outboundPalletItemId().equals(replay.getOutboundPalletItemId())
|| !request.inventoryPositionId().equals(replay.getInventoryPositionId())
|| !Objects.equals(request.inventoryPositionId(), replay.getInventoryPositionId())
|| !request.packages().equals(replay.getPackages())) {
throw conflict("Idempotency key was reused with a different outbound pick request");
}
@@ -393,6 +402,62 @@ public class DeliveryTransferService {
task.setStatus("IN_PROGRESS"); task.setUpdatedAt(LocalDateTime.now()); pdaTasks.updateById(task);
return taskResult(outbound, task);
}
if ("RELABEL_OUTBOUND".equals(outbound.getOutboundType())) {
if (request.inventoryPositionId() == null) throw conflict("换标出库必须确认实际库存位置");
InventoryPosition selectedPosition = inventoryPositions.selectById(request.inventoryPositionId());
if (selectedPosition == null || !customerId.equals(selectedPosition.getCustomerId())
|| !warehouseId.equals(selectedPosition.getOverseasWarehouseId())
|| !Objects.equals(selectedPosition.getPalletId(), item.getWarehousePalletId())) {
throw conflict("确认的库存位置不属于该换标出库托盘");
}
List<String> expectedMarks = Arrays.stream(Optional.ofNullable(item.getCartonMarks()).orElse("").split(","))
.map(String::trim).filter(StringUtils::hasText).distinct().sorted().toList();
List<String> submittedMarks = request.cartonMarks() == null ? List.of() : request.cartonMarks();
List<String> scannedMarks = submittedMarks.stream().map(String::trim).filter(StringUtils::hasText)
.distinct().sorted().toList();
if (scannedMarks.size() != submittedMarks.size() || !expectedMarks.equals(scannedMarks)) {
throw conflict("必须扫描并提交该换标出库任务的全部指定箱唛");
}
if (request.packages().intValue() != value(item.getPackageCount()) || value(item.getPickedPackageCount()) > 0) {
throw conflict("换标出库托盘必须按指定箱唛一次完成拆托确认");
}
RelabelApplication application = relabelApplications.lockByOutbound(customerId, outbound.getOutboundNo());
if (application == null || !RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) {
throw conflict("换标单尚未完成客户确认或已不可出库");
}
List<RelabelItem> sourceItems = relabelItems.selectList(new LambdaQueryWrapper<RelabelItem>()
.eq(RelabelItem::getApplicationId, application.getId())
.eq(RelabelItem::getCustomerId, customerId)
.eq(RelabelItem::getSourceInventoryPositionId, request.inventoryPositionId()));
long sourcePackages = sourceItems.stream().mapToLong(source -> value(source.getNotifiedPackageCount())).sum();
if (sourcePackages != request.packages()) throw conflict("换标出库来源库存快照与任务箱数不一致");
int releaseIndex = 0;
for (RelabelItem source : sourceItems) {
inventoryCommands.release(customerId, operatorId, new InventoryCommandRequests.Move(
request.inventoryPositionId(), value(source.getNotifiedPackageCount()), application.getRelabelNo(), null,
"RELABEL:" + application.getRelabelNo(), request.idempotencyKey() + ":RELEASE:" + releaseIndex++,
"换标出库拆托前释放原冻结库存"));
}
inventoryCommands.allocate(customerId, operatorId, new InventoryCommandRequests.Move(request.inventoryPositionId(),
request.packages(), application.getRelabelNo(), outbound.getOutboundNo(), null,
request.idempotencyKey() + ":ALLOCATE", "换标出库指定箱唛拆托"));
inventoryCommands.pick(customerId, operatorId, new InventoryCommandRequests.Move(request.inventoryPositionId(),
request.packages(), application.getRelabelNo(), outbound.getOutboundNo(), null,
request.idempotencyKey() + ":PICK", "换标出库指定箱唛拣货"));
inventoryCommands.ship(customerId, operatorId, new InventoryCommandRequests.Move(request.inventoryPositionId(),
request.packages(), application.getRelabelNo(), outbound.getOutboundNo(), null,
request.idempotencyKey() + ":SHIP", "换标出库指定箱唛出库"));
OutboundPdaPick pick = new OutboundPdaPick(); pick.setId(IdWorker.getId()); pick.setCustomerId(customerId);
pick.setTaskId(task.getId()); pick.setOutboundPalletItemId(item.getId()); pick.setInventoryPositionId(request.inventoryPositionId());
pick.setPackages(request.packages()); pick.setIdempotencyKey(request.idempotencyKey().trim()); pick.setPickedAt(LocalDateTime.now()); pdaPicks.insert(pick);
item.setPickedPackageCount(request.packages().intValue()); item.setStatus("PICKED"); outboundPallets.updateById(item);
List<OutboundPalletItem> pickedItems = outboundPallets.findByOutbound(customerId, outbound.getId());
if (!pickedItems.isEmpty() && pickedItems.stream().allMatch(value -> "PICKED".equals(value.getStatus()))) {
outbound.setStatus("PICKED"); outbound.setUpdatedAt(LocalDateTime.now()); outboundOrders.updateById(outbound);
}
task.setStatus("IN_PROGRESS"); task.setUpdatedAt(LocalDateTime.now()); pdaTasks.updateById(task);
return taskResult(outbound, task);
}
InventoryPosition selectedPosition = inventoryPositions.selectById(request.inventoryPositionId());
if (selectedPosition == null || !customerId.equals(selectedPosition.getCustomerId()) || !Objects.equals(selectedPosition.getPalletId(), item.getWarehousePalletId())) throw conflict("Scanned inventory position does not belong to the outbound pallet");
if (!warehouseId.equals(selectedPosition.getOverseasWarehouseId())) {
@@ -452,6 +517,13 @@ public class DeliveryTransferService {
task.setStatus("COMPLETED"); task.setCompletionIdempotencyKey(completionKey);
task.setCompletedAt(now); task.setUpdatedAt(now); pdaTasks.updateById(task);
outbound.setStatus("OUTBOUNDED"); outbound.setUpdatedAt(now); outboundOrders.updateById(outbound);
if ("RELABEL_OUTBOUND".equals(outbound.getOutboundType())) {
RelabelApplication application = relabelApplications.lockByOutbound(customerId, outbound.getOutboundNo());
if (application != null && RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) {
relabelService.changeStatus(customerId, application.getRelabelNo(), "PDA_OUTBOUND",
new RelabelRequests.ChangeStatus("COMPLETED", "换标出库PDA作业完成"));
}
}
return taskResult(outbound, task);
}
@@ -23,10 +23,15 @@ public final class DispatchRequests {
@Size(max = 255) String route, @Size(max = 64) String fbaAppointmentNo,
@Size(max = 32) String fbaAppointmentStatus,
@Size(max = 32) String vehicleAppointmentStatus,
@Size(max = 64) String draftToken) {
@Size(max = 64) String draftToken,
@Size(max = 64) String relabelNo) {
public PreArrange(List<Long> planPalletIds, String route, String fbaAppointmentNo,
String fbaAppointmentStatus, String vehicleAppointmentStatus) {
this(planPalletIds, route, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, null);
this(planPalletIds, route, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, null, null);
}
public PreArrange(List<Long> planPalletIds, String route, String fbaAppointmentNo,
String fbaAppointmentStatus, String vehicleAppointmentStatus, String draftToken) {
this(planPalletIds, route, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, draftToken, null);
}
}
@@ -34,5 +34,8 @@ public class DispatchItem {
private BigDecimal volumeCbm;
/** PLAN for planned pallets; RECEIVED for warehouse/entity pallets. */
private String sourceStage;
private String cartonMarks;
private String sourceBusinessType;
private String sourceBusinessNo;
private LocalDateTime createdAt;
}
@@ -143,4 +143,32 @@ public interface DispatchCandidateMapper {
"</script>")
List<DispatchPalletCandidate> lockSelected(@Param("customerId") String customerId,
@Param("planPalletIds") List<Long> planPalletIds);
@Select("<script>" +
"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," +
"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 " +
"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 " +
"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) " +
"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,
@Param("orderNo") String orderNo,
@Param("relabelNo") String relabelNo);
@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);
}
@@ -84,8 +84,15 @@ public class DispatchService {
public List<DispatchPalletCandidate> platformCandidates(String orderNo, String fbaWarehouseCode,
String sourceStage, String draftToken) {
validateSourceStage(sourceStage);
return candidates.available(null, null, trimToNull(orderNo), trimToNull(fbaWarehouseCode),
List<DispatchPalletCandidate> result = candidates.available(null, null, trimToNull(orderNo), trimToNull(fbaWarehouseCode),
trimToNull(sourceStage), trimToNull(draftToken));
if (!StringUtils.hasText(sourceStage) || "RECEIVED".equals(sourceStage)) {
List<DispatchPalletCandidate> relabel = candidates.relabelAvailable(null, trimToNull(orderNo), null);
if (relabel != null && !relabel.isEmpty()) {
result = java.util.stream.Stream.concat(result.stream(), relabel.stream()).toList();
}
}
return result;
}
public List<DispatchPalletCandidate> platformCandidates(String orderNo, String fbaWarehouseCode, String sourceStage) {
return platformCandidates(orderNo, fbaWarehouseCode, sourceStage, null);
@@ -212,6 +219,9 @@ public class DispatchService {
@Transactional
public DispatchResponse preArrange(String customerId, String operatorId, DispatchRequests.PreArrange request) {
if (StringUtils.hasText(request.relabelNo())) {
return preArrangeRelabel(customerId, operatorId, request);
}
if (!StringUtils.hasText(customerId)) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"No customer business data is available for dispatch");
@@ -355,6 +365,24 @@ public class DispatchService {
return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId()));
}
@Transactional
private DispatchResponse preArrangeRelabel(String customerId, String operatorId, DispatchRequests.PreArrange request) {
List<DispatchPalletCandidate> selected = candidates.relabelAvailable(customerId, null, request.relabelNo());
Set<Long> requested = request.planPalletIds().stream().map(Long::valueOf).collect(Collectors.toSet());
selected = selected.stream().filter(c -> requested.contains(c.warehousePalletId())).toList();
if (selected.isEmpty() || selected.size() != requested.size()) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单托盘候选已变化,请刷新后重试");
String warehouse = selected.get(0).overseasWarehouseId();
if (selected.stream().anyMatch(c -> !Objects.equals(warehouse, c.overseasWarehouseId()))) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单只能选择同一海外仓");
LocalDateTime now = LocalDateTime.now();
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); }
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()));
}
private void assertNoDetainedOrders(String customerId, List<DispatchPalletCandidate> selected) {
List<Long> orderIds = selected.stream().map(DispatchPalletCandidate::orderId)
.filter(Objects::nonNull).distinct().sorted().toList();
@@ -42,6 +42,13 @@ public class PdaPalletOperationController {
return ApiResponse.success(service.disassemble(PdaWarehouseIdentity.require(user), request));
}
@PostMapping("/relabel-disassemble")
public ApiResponse<PalletOperationResponses.Operation> relabelDisassemble(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PalletOperationRequests.RelabelDisassemble request) {
return ApiResponse.success(service.relabelDisassemble(PdaWarehouseIdentity.require(user), request));
}
@PostMapping("/swap")
public ApiResponse<PalletOperationResponses.Operation> swap(
@AuthenticationPrincipal AuthenticatedUser user,
@@ -31,6 +31,14 @@ public final class PalletOperationRequests {
@NotEmpty @Size(max = 20) List<@Valid TargetMove> targets,
@Size(max = 500) String reason) { }
/** 换标场景按箱唛拆托并绑定到一个目标托盘。箱唛位置由服务端从换标单快照解析。 */
public record RelabelDisassemble(@NotBlank @Size(max = 128) String idempotencyKey,
@NotBlank @Size(max = 64) String relabelNo,
@Valid PalletExpectation source,
@Valid PalletExpectation target,
@NotEmpty @Size(max = 500) List<@NotBlank @Size(max = 255) String> cartonMarks,
@Size(max = 500) String reason) { }
public record Swap(@NotBlank @Size(max = 128) String idempotencyKey,
@Valid PalletExpectation source,
@Valid PalletExpectation target,
@@ -13,6 +13,11 @@ import com.baiyue.lastleg.palletops.entity.PdaPalletOperation;
import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway;
import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper;
import com.baiyue.lastleg.palletops.model.PalletInventoryRow;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.relabel.service.RelabelPalletMutationService;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService;
import com.fasterxml.jackson.core.JsonProcessingException;
@@ -52,6 +57,9 @@ public class PdaPalletOperationService {
@Resource private PalletPhysicalGateway physical;
@Resource private PdaWarehouseService history;
@Resource private ObjectMapper objectMapper;
@Resource private RelabelApplicationMapper relabelApplications;
@Resource private RelabelItemMapper relabelItems;
@Resource private RelabelPalletMutationService relabelPalletMutations;
private TransactionTemplate transaction;
private TransactionTemplate newTransaction;
@@ -113,6 +121,64 @@ public class PdaPalletOperationService {
false));
}
public PalletOperationResponses.Operation relabelDisassemble(PdaWarehouseIdentity identity,
PalletOperationRequests.RelabelDisassemble request) {
PalletOperationResponses.Operation replay = replayIfPresent(identity, "RELABEL_PALLET_DISASSEMBLE",
request.idempotencyKey(), request);
if (replay != null) return replay;
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 冻结的海外仓");
}
Map<String, PalletPhysicalGateway.PalletReference> pallets = resolvePallets(identity.overseasWarehouseId(),
request.source(), List.of(request.target()));
PalletPhysicalGateway.PalletReference source = pallets.get(code(request.source().palletCode()));
PalletPhysicalGateway.PalletReference target = pallets.get(code(request.target().palletCode()));
if (source.palletId() == target.palletId()) throw conflict("原托盘和目标托盘不能相同");
List<String> marks = request.cartonMarks().stream().map(String::trim).filter(value -> !value.isEmpty()).toList();
if (marks.size() != request.cartonMarks().size() || new HashSet<>(marks).size() != marks.size()) {
throw badRequest("箱唛不能为空且不能重复");
}
List<RelabelItem> selected = relabelItems.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<RelabelItem>()
.eq("application_id", application.getId()).eq("customer_id", application.getCustomerId())
.in("carton_mark", marks));
if (selected.size() != marks.size()
|| selected.stream().map(RelabelItem::getCartonMark).distinct().count() != marks.size()) {
throw conflict("提交的箱唛不属于当前换标单");
}
Map<Long, Long> packagesByPosition = new LinkedHashMap<>();
Map<Long, List<RelabelItem>> itemsByPosition = new LinkedHashMap<>();
for (RelabelItem item : selected) {
if (item.getSourceInventoryPositionId() == null || item.getNotifiedPackageCount() == null
|| item.getNotifiedPackageCount() <= 0) throw conflict("换标箱唛缺少库存位置或箱数快照");
InventoryPosition position = positions.selectById(item.getSourceInventoryPositionId());
if (position == null || !Objects.equals(position.getCustomerId(), application.getCustomerId())
|| !Objects.equals(position.getPalletId(), source.palletId())
|| !Objects.equals(position.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) {
throw conflict("扫描的原托盘与换标箱唛实际库存位置不一致");
}
packagesByPosition.merge(position.getId(), item.getNotifiedPackageCount().longValue(), Long::sum);
itemsByPosition.computeIfAbsent(position.getId(), ignored -> new ArrayList<>()).add(item);
}
List<PlannedMove> moves = packagesByPosition.entrySet().stream().map(entry -> {
InventoryPosition position = positions.selectById(entry.getKey());
if (value(position.getHoldPackages()) < entry.getValue()) throw conflict("换标冻结库存箱数不足");
return new PlannedMove(entry.getKey(), entry.getValue(), value(position.getVersion()), source.palletId(),
source.palletCode(), target.palletId(), target.palletCode(), target.locationId(),
locationCode(identity.overseasWarehouseId(), target.locationId()), InventoryBucket.HOLD);
}).toList();
requireCommonDestination(identity.overseasWarehouseId(), moves, List.of(target));
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));
return execute(identity, "RELABEL_PALLET_DISASSEMBLE", request.idempotencyKey(), request,
request.reason(), relabelPlan);
}
public PalletOperationResponses.Operation swap(PdaWarehouseIdentity identity,
PalletOperationRequests.Swap request) {
PalletOperationResponses.Operation replay = replayIfPresent(identity, "PALLET_SWAP",
@@ -130,7 +196,7 @@ public class PdaPalletOperationService {
requireMovable(row, value(row.getAvailablePackages()));
return new PlannedMove(row.getPositionId(), value(row.getAvailablePackages()), value(row.getVersion()),
source.palletId(), source.palletCode(), target.palletId(), target.palletCode(), target.locationId(),
locationCode(identity.overseasWarehouseId(), target.locationId()));
locationCode(identity.overseasWarehouseId(), target.locationId()), InventoryBucket.AVAILABLE);
}).toList();
return execute(identity, "PALLET_SWAP", request.idempotencyKey(), request, request.reason(),
plan(identity.overseasWarehouseId(), pallets, moves, Set.of(target.palletId()), false));
@@ -149,7 +215,7 @@ public class PdaPalletOperationService {
requireMovable(row, value(row.getAvailablePackages()));
return new PlannedMove(row.getPositionId(), value(row.getAvailablePackages()), value(row.getVersion()),
source.palletId(), source.palletCode(), 0L, "UNBOUND", source.locationId(),
locationCode(identity.overseasWarehouseId(), source.locationId()));
locationCode(identity.overseasWarehouseId(), source.locationId()), InventoryBucket.AVAILABLE);
}).toList();
return execute(identity, "PALLET_UNBIND", request.idempotencyKey(), request, request.reason(),
plan(identity.overseasWarehouseId(), pallets, moves, Set.of(), true));
@@ -183,8 +249,10 @@ public class PdaPalletOperationService {
private PalletOperationResponses.Operation mutate(PdaWarehouseIdentity identity, PdaPalletOperation operation,
Plan plan, String key, String requestHash, String reason) {
if (plan.relabelMutation() != null) lockRelabelMutation(plan.relabelMutation(), identity.overseasWarehouseId());
Set<Long> seen = new HashSet<>();
LinkedHashSet<String> customers = new LinkedHashSet<>();
Map<Long, InventoryPosition> relabelTargets = new LinkedHashMap<>();
for (PlannedMove move : plan.moves()) {
if (!seen.add(move.positionId())) throw conflict("同一库存行不能在一次托盘作业中重复移动");
PalletInventoryRow locked = mapper.lockPosition(String.valueOf(identity.overseasWarehouseId()),
@@ -193,10 +261,14 @@ public class PdaPalletOperationService {
throw conflict("库存所在托盘已变化,请重新扫码");
}
if (value(locked.getVersion()) != move.expectedVersion()) throw conflict("库存版本已变化,请重新扫码");
requireMovable(locked, move.packages());
if (move.bucket() == InventoryBucket.HOLD) requireHeld(locked, move.packages());
else requireMovable(locked, move.packages());
customers.add(locked.getCustomerId());
moveInventory(identity, operation, locked, move, key, reason);
InventoryPosition target = moveInventory(identity, operation, locked, move, key, reason);
if (plan.relabelMutation() != null) relabelTargets.put(move.positionId(), target);
}
if (plan.relabelMutation() != null) relabelPalletMutations.moveSelectedItems(
plan.relabelMutation().application(), plan.relabelMutation().itemsByPosition(), relabelTargets);
for (PalletChange change : plan.changes()) {
long actual = mapper.palletPackages(String.valueOf(identity.overseasWarehouseId()), change.pallet().palletId());
boolean shouldBeEmpty = "IDLE".equals(change.targetStatus());
@@ -219,12 +291,22 @@ public class PdaPalletOperationService {
return result;
}
private void moveInventory(PdaWarehouseIdentity identity, PdaPalletOperation operation,
PalletInventoryRow sourceRow, PlannedMove move, String key, String reason) {
private void lockRelabelMutation(RelabelMutation mutation, long warehouseId) {
RelabelApplication locked = relabelApplications.lockAnyByRelabelNo(mutation.application().getRelabelNo());
if (locked == null || !"READY_FOR_OUTBOUND".equals(locked.getStatus())
|| !Objects.equals(locked.getCustomerId(), mutation.application().getCustomerId())
|| !Objects.equals(locked.getOverseasWarehouseId(), String.valueOf(warehouseId))
|| !Objects.equals(locked.getOutboundOrderNo(), mutation.application().getOutboundOrderNo())) {
throw conflict("换标单状态或履约单据已变化,请重新扫码");
}
}
private InventoryPosition moveInventory(PdaWarehouseIdentity identity, PdaPalletOperation operation,
PalletInventoryRow sourceRow, PlannedMove move, String key, String reason) {
InventoryPosition source = positions.selectById(sourceRow.getPositionId());
if (source == null) throw conflict("库存行不存在");
long sourceBefore = value(source.getAvailablePackages());
source.setAvailablePackages(sourceBefore - move.packages());
long sourceBefore = bucketValue(source, move.bucket());
setBucketValue(source, move.bucket(), sourceBefore - move.packages());
source.setStatus(positionPackages(source) == 0 ? "EMPTY" : "OCCUPIED");
source.setVersion(value(source.getVersion()) + 1); source.setUpdatedAt(LocalDateTime.now());
positions.updateById(source);
@@ -248,8 +330,8 @@ public class PdaPalletOperationService {
if (target == null) throw duplicate;
}
}
long targetBefore = value(target.getAvailablePackages());
target.setAvailablePackages(targetBefore + move.packages()); target.setStatus("OCCUPIED");
long targetBefore = bucketValue(target, move.bucket());
setBucketValue(target, move.bucket(), targetBefore + move.packages()); target.setStatus("OCCUPIED");
target.setWarehouseLocationId(move.targetLocationId());
target.setWarehouseLocationCode(move.targetLocationCode());
target.setVersion(value(target.getVersion()) + 1); target.setUpdatedAt(LocalDateTime.now());
@@ -261,6 +343,7 @@ public class PdaPalletOperationService {
sourceBefore, -move.packages(), sourceBefore - move.packages(), move.sourcePalletCode(), move.targetPalletCode(), reason);
ledger(identity, operation, balance, target, key + ":" + source.getId() + ":IN",
targetBefore, move.packages(), targetBefore + move.packages(), move.sourcePalletCode(), move.targetPalletCode(), reason);
return target;
}
private void ledger(PdaWarehouseIdentity identity, PdaPalletOperation operation, InventoryBalance balance,
@@ -302,7 +385,7 @@ public class PdaPalletOperationService {
}
changes.sort(Comparator.comparingInt((PalletChange change) -> "IDLE".equals(change.targetStatus()) ? 0 : 1)
.thenComparingLong(change -> change.pallet().palletId()));
return new Plan(pallets, moves, changes);
return new Plan(pallets, moves, changes, null);
}
private List<PlannedMove> plannedLines(long warehouseId, List<PalletOperationRequests.MoveLine> lines,
@@ -325,7 +408,7 @@ public class PdaPalletOperationService {
requireMovable(row, line.packages());
result.add(new PlannedMove(row.getPositionId(), line.packages(), line.expectedPositionVersion(),
row.getPalletId(), row.getPalletNo(), target.palletId(), target.palletCode(), target.locationId(),
locationCode(warehouseId, target.locationId())));
locationCode(warehouseId, target.locationId()), InventoryBucket.AVAILABLE));
}
requireUniqueLines(result);
return result;
@@ -494,6 +577,21 @@ public class PdaPalletOperationService {
throw conflict("冻结、已分配、已拣或收货中的库存不能执行托盘作业");
}
}
private static void requireHeld(PalletInventoryRow row, long packages) {
if (packages <= 0 || value(row.getHoldPackages()) < packages) throw conflict("换标冻结库存箱数不足");
if (value(row.getAllocatedPackages()) > 0 || value(row.getPickedPackages()) > 0
|| value(row.getReceivingPackages()) > 0) {
throw conflict("已分配、已拣或收货中的库存不能执行换标拆托");
}
}
private static long bucketValue(InventoryPosition position, InventoryBucket bucket) {
return bucket == InventoryBucket.HOLD ? value(position.getHoldPackages())
: value(position.getAvailablePackages());
}
private static void setBucketValue(InventoryPosition position, InventoryBucket bucket, long packages) {
if (bucket == InventoryBucket.HOLD) position.setHoldPackages(packages);
else position.setAvailablePackages(packages);
}
private static void requireUniqueLines(List<PlannedMove> moves) {
Set<Long> ids = new HashSet<>();
if (moves.stream().anyMatch(move -> !ids.add(move.positionId()))) throw badRequest("库存行不能重复提交");
@@ -517,6 +615,7 @@ public class PdaPalletOperationService {
private static String first(String first, String second) { String value = trim(first); return value == null ? trim(second) : value; }
private static String limit(String value, int size) { if (value == null) return null; return value.length() <= size ? value : value.substring(0, size); }
private static long value(Long value) { return value == null ? 0L : value; }
private static int value(Integer value) { return value == null ? 0 : value; }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
@@ -524,9 +623,13 @@ public class PdaPalletOperationService {
private record PlannedMove(long positionId, long packages, long expectedVersion,
long sourcePalletId, String sourcePalletCode,
long targetPalletId, String targetPalletCode, long targetLocationId,
String targetLocationCode) { }
String targetLocationCode, InventoryBucket bucket) { }
private record PalletChange(PalletPhysicalGateway.PalletReference pallet, String targetStatus,
long targetLocationId) { }
private record Plan(Map<String, PalletPhysicalGateway.PalletReference> pallets,
List<PlannedMove> moves, List<PalletChange> changes) { }
List<PlannedMove> moves, List<PalletChange> changes,
RelabelMutation relabelMutation) { }
private record RelabelMutation(RelabelApplication application,
Map<Long, List<RelabelItem>> itemsByPosition) { }
private enum InventoryBucket { AVAILABLE, HOLD }
}
@@ -12,6 +12,11 @@ import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.ContentDisposition;
import java.nio.charset.StandardCharsets;
import java.util.List;
@RestController
@@ -34,10 +39,38 @@ public class MerchantRelabelApplicationController {
@GetMapping("/{relabelNo}")
public ApiResponse<RelabelResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo) { return ApiResponse.success(service.detail(customer(user), relabelNo)); }
@PostMapping(value = "/{relabelNo}/shipping-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<RelabelResponses.Detail> uploadShippingLabel(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @RequestPart("file") MultipartFile file) {
return ApiResponse.success(service.uploadShippingLabel(customer(user), relabelNo, operatorLong(user), file));
}
@GetMapping("/{relabelNo}/shipping-label/{fileId}/download")
public ResponseEntity<byte[]> downloadShippingLabel(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @PathVariable long fileId) {
var file = service.downloadShippingLabel(customer(user), relabelNo, fileId);
return download(file.content(), file.fileName(), file.contentType());
}
@GetMapping("/{relabelNo}/completion-photos/{fileId}/download")
public ResponseEntity<byte[]> downloadCompletionPhoto(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @PathVariable long fileId) {
var file = service.downloadCompletionPhoto(customer(user), relabelNo, fileId);
return download(file.content(), file.fileName(), file.contentType());
}
@PostMapping("/{relabelNo}/confirm-completion")
public ApiResponse<RelabelResponses.Detail> confirmCompletion(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @Valid @RequestBody RelabelRequests.CustomerConfirm request) {
return ApiResponse.success(service.confirmCompletion(customer(user), relabelNo, operator(user), request));
}
private ResponseEntity<byte[]> download(byte[] content, String name, String type) {
return ResponseEntity.ok().cacheControl(org.springframework.http.CacheControl.noStore())
.header("Content-Disposition", ContentDisposition.inline().filename(name, StandardCharsets.UTF_8).build().toString())
.contentType(MediaType.parseMediaType(type)).contentLength(content.length).body(content);
}
private String customer(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required");
return String.valueOf(user.tenantId());
}
private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); }
private long operatorLong(AuthenticatedUser user) { return user.userId() == null ? 0L : user.userId(); }
}
@@ -85,6 +85,16 @@ public class RelabelApplicationController {
return ResponseEntity.ok().cacheControl(org.springframework.http.CacheControl.noStore()).header("Content-Disposition", ContentDisposition.inline().filename(file.fileName(), StandardCharsets.UTF_8).build().toString())
.contentType(org.springframework.http.MediaType.parseMediaType(file.contentType())).contentLength(file.content().length).body(file.content());
}
@GetMapping("/{relabelNo}/completion-photos/{fileId}/download")
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')")
public ResponseEntity<byte[]> downloadCompletionPhoto(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @PathVariable long fileId) {
requirePlatform(user);
var file = service.downloadCompletionPhoto(service.customerId(relabelNo), relabelNo, fileId);
return ResponseEntity.ok().cacheControl(org.springframework.http.CacheControl.noStore())
.header("Content-Disposition", ContentDisposition.inline().filename(file.fileName(), StandardCharsets.UTF_8).build().toString())
.contentType(MediaType.parseMediaType(file.contentType())).contentLength(file.content().length).body(file.content());
}
private void requirePlatform(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) {
@@ -26,8 +26,17 @@ public final class RelabelRequests {
@NotNull @DecimalMin(value = "0.01") BigDecimal chargeAmount,
@Size(max = 1000) String remark,
@NotBlank @Size(max = 255) String idempotencyKey,
@NotEmpty @Size(max = 500) List<@Valid Item> items
) {}
@NotEmpty @Size(max = 500) List<@Valid Item> items,
@Size(max = 32) String deliveryMethod,
Boolean printShippingLabel
) {
public Create(String customerOrderNo, String operationWarehouseId, LocalDateTime labelDeadline,
String contactName, String contactPhone, BigDecimal chargeAmount, String remark,
String idempotencyKey, List<Item> items) {
this(customerOrderNo, operationWarehouseId, labelDeadline, contactName, contactPhone,
chargeAmount, remark, idempotencyKey, items, "TRUCK", false);
}
}
public record Item(
@NotNull Long sourceInventoryBalanceId,
@@ -105,4 +114,7 @@ public final class RelabelRequests {
public record ChangeStatus(@NotBlank @Size(max = 32) String status,
@Size(max = 1000) String remark) {}
public record CustomerConfirm(@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) {}
}
@@ -13,7 +13,9 @@ public final class RelabelResponses {
int totalPackageCount, int notifiedPackageCount, String skuCodes,
String totalFileId, String totalFileName, String totalFileUrl,
BigDecimal chargeAmount, String chargeCurrency, String walletStatus,
String walletErrorMessage,
String walletErrorMessage, String deliveryMethod, boolean printShippingLabel,
String shippingLabelFileId, String shippingLabelFileName, String shippingLabelFileUrl,
String fulfillmentStatus, String outboundOrderNo, LocalDateTime customerConfirmedAt,
LocalDateTime createdAt) {}
public record Item(Long id, String skuCode, String productImageFileId, String productImageFileName,
String productImageUrl, String productNameZh, int totalPackageCount,
@@ -37,6 +39,8 @@ public final class RelabelResponses {
String status) {}
public record History(String fromStatus, String toStatus, String operatorId,
String remark, LocalDateTime createdAt) {}
public record CompletionEvidence(Long taskId, List<Long> photoFileIds, String completionRemark,
String reviewerName, String reviewRemark, LocalDateTime completedAt) {}
public record Detail(Summary application, String remark, List<Item> items, List<History> history,
List<TailOrder> tailOrders) {}
List<TailOrder> tailOrders, CompletionEvidence completionEvidence) {}
}
@@ -20,6 +20,15 @@ public class RelabelApplication {
private LocalDateTime labelDeadline;
private String contactName;
private String contactPhone;
private String deliveryMethod;
private Boolean printShippingLabel;
private String shippingLabelFileId;
private String shippingLabelFileName;
private String shippingLabelFileUrl;
private String fulfillmentStatus;
private String outboundOrderNo;
private String customerConfirmationKey;
private LocalDateTime customerConfirmedAt;
private String remark;
private String totalFileId;
private String totalFileName;
@@ -22,4 +22,7 @@ public interface RelabelApplicationMapper extends BaseMapper<RelabelApplication>
@Select("SELECT * FROM ll_relabel_application WHERE relabel_no=#{relabelNo} LIMIT 1 FOR UPDATE")
RelabelApplication lockAnyByRelabelNo(@Param("relabelNo") String relabelNo);
@Select("SELECT * FROM ll_relabel_application WHERE customer_id=#{customerId} AND outbound_order_no=#{outboundNo} LIMIT 1 FOR UPDATE")
RelabelApplication lockByOutbound(@Param("customerId") String customerId, @Param("outboundNo") String outboundNo);
}
@@ -10,6 +10,7 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.multipart.MultipartFile;
@@ -89,6 +90,24 @@ public class HttpRelabelPhotoFileGateway implements RelabelPhotoFileGateway {
}
}
@Override
public DownloadedFile download(String customerId, long taskId, long fileId) {
validate(customerId, taskId, fileId);
ResponseEntity<byte[]> response = client.get()
.uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}/content",
tenantId(customerId), BUSINESS_TYPE, taskId, fileId)
.header("X-Internal-Service-Token", internalToken).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> {
throw new ResponseStatusException(result.getStatusCode().is4xxClientError()
? HttpStatus.NOT_FOUND : HttpStatus.SERVICE_UNAVAILABLE, "Relabel photo download failed");
}).toEntity(byte[].class);
String fileName = response.getHeaders().getContentDisposition().getFilename();
String contentType = response.getHeaders().getContentType() == null
? MediaType.APPLICATION_OCTET_STREAM_VALUE : response.getHeaders().getContentType().toString();
return new DownloadedFile(response.getBody() == null ? new byte[0] : response.getBody(),
fileName == null ? "relabel-photo" : fileName, contentType);
}
private static long tenantId(String customerId) {
try { return Long.parseLong(customerId); }
catch (NumberFormatException exception) {
@@ -5,6 +5,8 @@ import org.springframework.web.multipart.MultipartFile;
public interface RelabelPhotoFileGateway {
UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file);
void validate(String customerId, long taskId, long fileId);
DownloadedFile download(String customerId, long taskId, long fileId);
record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) { }
record DownloadedFile(byte[] content, String fileName, String contentType) { }
}
@@ -252,8 +252,8 @@ public class PdaRelabelService {
updateTask(task, request.expectedVersion());
if ("APPROVE".equals(decision)) {
applicationService.changeStatus(application.getCustomerId(), relabelNo, operatorKey,
new RelabelRequests.ChangeStatus(COMPLETED, request.remark()));
application.setStatus(COMPLETED);
new RelabelRequests.ChangeStatus(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, request.remark()));
application.setStatus(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING);
}
insertHistory(task, "REVIEW_" + decision, before, after, operatorKey, operatorName,
Map.of("decision", decision, "remark", Objects.toString(trim(request.remark()), "")));
@@ -24,6 +24,15 @@ import com.baiyue.lastleg.relabel.entity.RelabelStatusHistory;
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper;
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask;
import com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway;
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
import com.baiyue.lastleg.delivery.entity.OutboundPdaTask;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundPdaTaskMapper;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.baiyue.lastleg.wallet.WalletCommandOutboxService;
import com.baiyue.lastleg.wallet.WalletResultHandler;
@@ -52,7 +61,11 @@ import java.util.stream.Collectors;
@Service
public class RelabelApplicationService implements WalletResultHandler {
public static final String PENDING_PROCESSING = "PENDING_PROCESSING";
private static final Set<String> STATUSES = Set.of(PENDING_PROCESSING, "PROCESSING", "COMPLETED", "REJECTED", "CANCELLED");
public static final String CUSTOMER_CONFIRM_PENDING = "CUSTOMER_CONFIRM_PENDING";
public static final String READY_FOR_OUTBOUND = "READY_FOR_OUTBOUND";
private static final Set<String> STATUSES = Set.of(PENDING_PROCESSING, "PROCESSING", CUSTOMER_CONFIRM_PENDING,
READY_FOR_OUTBOUND, "COMPLETED", "REJECTED", "CANCELLED");
private static final Set<String> DELIVERY_METHODS = Set.of("TRUCK", "EXPRESS", "TRUCK_PICKUP", "EXPRESS_PICKUP", "PICKUP");
@Resource private RelabelApplicationMapper applications;
@Resource private RelabelItemMapper items;
@@ -66,6 +79,11 @@ public class RelabelApplicationService implements WalletResultHandler {
@Resource private InventoryBalanceMapper inventoryBalances;
@Resource private InventoryPositionMapper inventoryPositions;
@Resource private RelabelDocumentFileGateway documentFiles;
@Resource private PdaRelabelTaskMapper pdaTasks;
@Resource private RelabelPhotoFileGateway photoFiles;
@Resource private OutboundOrderMapper outboundOrders;
@Resource private OutboundPalletItemMapper outboundItems;
@Resource private OutboundPdaTaskMapper outboundTasks;
@Transactional(readOnly = true)
public String customerId(String relabelNo) { return require(null, relabelNo).getCustomerId(); }
@@ -94,6 +112,48 @@ public class RelabelApplicationService implements WalletResultHandler {
return detail(application.getCustomerId(), relabelNo);
}
@Transactional
public RelabelResponses.Detail uploadShippingLabel(String customerId, String relabelNo, long operatorId,
MultipartFile file) {
RelabelApplication application = lockForDocument(customerId, relabelNo);
if (!Boolean.TRUE.equals(application.getPrintShippingLabel())) {
throw conflict("This relabel order does not require a shipping label");
}
requireShippingLabel(file);
var uploaded = documentFiles.upload(application.getCustomerId(), "OVERSEAS_RELABEL_SHIPPING_LABEL",
String.valueOf(application.getId()), operatorId, file);
application.setShippingLabelFileId(String.valueOf(uploaded.fileId()));
application.setShippingLabelFileName(uploaded.fileName());
application.setShippingLabelFileUrl("/api/merchant/v1/last-leg/relabel-applications/"
+ java.net.URLEncoder.encode(relabelNo, StandardCharsets.UTF_8) + "/shipping-label/"
+ uploaded.fileId() + "/download");
application.setUpdatedAt(LocalDateTime.now());
applications.updateById(application);
return detail(application.getCustomerId(), relabelNo);
}
@Transactional(readOnly = true)
public RelabelDocumentFileGateway.DownloadedFile downloadShippingLabel(String customerId, String relabelNo,
long fileId) {
RelabelApplication application = require(customerId, relabelNo);
if (!String.valueOf(fileId).equals(application.getShippingLabelFileId())) {
throw notFound("Shipping label does not belong to this relabel order");
}
return documentFiles.download(application.getCustomerId(), "OVERSEAS_RELABEL_SHIPPING_LABEL",
String.valueOf(application.getId()), fileId);
}
@Transactional(readOnly = true)
public RelabelPhotoFileGateway.DownloadedFile downloadCompletionPhoto(String customerId, String relabelNo,
long fileId) {
RelabelApplication application = require(customerId, relabelNo);
PdaRelabelTask task = pdaTasks.find(application.getCustomerId(), application.getId());
if (task == null || !readIds(task.getCompletionPhotoFileIds()).contains(fileId)) {
throw notFound("Completion photo does not belong to this relabel order");
}
return photoFiles.download(application.getCustomerId(), task.getId(), fileId);
}
@Transactional(readOnly = true)
public RelabelDocumentFileGateway.DownloadedFile downloadFile(String customerId, String relabelNo, String scope, Long itemId, long fileId) {
RelabelApplication application = require(customerId, relabelNo);
@@ -131,6 +191,15 @@ public class RelabelApplicationService implements WalletResultHandler {
} catch (java.io.IOException exception) { throw badRequest("无法读取PDF文件"); }
}
private static void requireShippingLabel(MultipartFile file) {
if (file == null || file.isEmpty()) throw badRequest("Shipping label file is required");
if (file.getSize() > 20L * 1024 * 1024) throw badRequest("Shipping label file exceeds 20 MB");
String contentType = Objects.toString(file.getContentType(), "").toLowerCase();
if (!(contentType.equals("application/pdf") || contentType.startsWith("image/"))) {
throw badRequest("Shipping label must be a PDF or image");
}
}
private static String documentUrl(String relabelNo, long fileId, Long itemId) {
return "/api/platform/v1/last-leg/relabel-applications/" + java.net.URLEncoder.encode(relabelNo, StandardCharsets.UTF_8) + "/files/" + fileId + "/download?scope=" + (itemId == null ? "TOTAL" : "ITEM&itemId=" + itemId);
}
@@ -163,6 +232,17 @@ public class RelabelApplicationService implements WalletResultHandler {
@Transactional
public RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request) {
if (!StringUtils.hasText(request.operationWarehouseId())) throw badRequest("Operation warehouse is required");
boolean requiresOutbound = request.items().stream().anyMatch(item -> !"SKU_LABEL".equals(operationType(item)));
String deliveryMethod = trim(request.deliveryMethod());
if (requiresOutbound && !DELIVERY_METHODS.contains(deliveryMethod)) {
throw badRequest("Delivery method must be TRUCK, PICKUP or EXPRESS when carton labels are changed");
}
if (!requiresOutbound && StringUtils.hasText(deliveryMethod)) {
throw badRequest("SKU-only relabel does not create an outbound fulfillment");
}
if (Boolean.TRUE.equals(request.printShippingLabel()) && !Set.of("EXPRESS", "EXPRESS_PICKUP").contains(deliveryMethod)) {
throw badRequest("Shipping-label printing is only available for express delivery");
}
String requestHash = hash(request);
RelabelApplication replay = applications.findKey(customerId, request.idempotencyKey().trim());
if (replay != null) {
@@ -223,6 +303,9 @@ public class RelabelApplicationService implements WalletResultHandler {
application.setLabelDeadline(request.labelDeadline());
application.setContactName(request.contactName().trim());
application.setContactPhone(request.contactPhone().trim());
application.setDeliveryMethod(deliveryMethod);
application.setPrintShippingLabel(Boolean.TRUE.equals(request.printShippingLabel()));
application.setFulfillmentStatus(requiresOutbound ? "WAIT_RELABEL" : "NOT_REQUIRED");
application.setRemark(trim(request.remark()));
application.setStatus(PENDING_PROCESSING);
application.setItemCount(request.items().size());
@@ -285,12 +368,17 @@ public class RelabelApplicationService implements WalletResultHandler {
.eq(RelabelStatusHistory::getApplicationId, application.getId())
.orderByAsc(RelabelStatusHistory::getCreatedAt));
List<LastlegOrder> tailOrders = tailOrders(application);
PdaRelabelTask pdaTask = pdaTasks == null ? null : pdaTasks.find(recordCustomerId, application.getId());
RelabelResponses.CompletionEvidence evidence = pdaTask == null ? null
: new RelabelResponses.CompletionEvidence(pdaTask.getId(), readIds(pdaTask.getCompletionPhotoFileIds()),
pdaTask.getCompletionRemark(), pdaTask.getReviewerName(), pdaTask.getReviewRemark(),
pdaTask.getReviewedAt());
return new RelabelResponses.Detail(summary(application, detailItems), application.getRemark(),
detailItems.stream().map(this::item).toList(), statusHistory.stream().map(value ->
new RelabelResponses.History(value.getFromStatus(), value.getToStatus(), value.getOperatorId(),
value.getRemark(), value.getCreatedAt())).toList(), tailOrders.stream().map(value ->
new RelabelResponses.TailOrder(value.getId(), value.getOrderNo(), value.getOverseasWarehouseId(),
Objects.requireNonNullElse(value.getPackageCount(), 0), value.getCurrentStatus())).toList());
Objects.requireNonNullElse(value.getPackageCount(), 0), value.getCurrentStatus())).toList(), evidence);
}
@Transactional
@@ -313,8 +401,11 @@ public class RelabelApplicationService implements WalletResultHandler {
application.setStatus(next);
application.setUpdatedAt(now);
applications.updateById(application);
if ("COMPLETED".equals(next)) {
releaseInventory(customerId, operatorId, application);
if (CUSTOMER_CONFIRM_PENDING.equals(next)) {
application.setFulfillmentStatus("WAIT_CUSTOMER_CONFIRMATION");
applications.updateById(application);
} else if ("COMPLETED".equals(next)) {
if (!StringUtils.hasText(application.getOutboundOrderNo())) releaseInventory(customerId, operatorId, application);
walletCommands.publish("SETTLE", Long.parseLong(customerId), "RELABEL_ORDER", relabelNo, null,
"换标完成,结算费用", 2L);
application.setWalletStatus("SETTLE_PENDING"); applications.updateById(application);
@@ -332,6 +423,7 @@ public class RelabelApplicationService implements WalletResultHandler {
String orderBefore = order.getCurrentStatus();
String mainStatus = STATUSES.contains(orderBefore) ? "PENDING_ASSIGNMENT" : orderBefore;
String orderStatus = switch (next) {
case CUSTOMER_CONFIRM_PENDING -> mainStatus;
case "COMPLETED" -> "RETURN_RELABEL".equals(order.getOrderType())
? "PENDING_ASSIGNMENT" : "COMPLETED";
case "REJECTED", "CANCELLED" -> "CANCELLED";
@@ -350,6 +442,102 @@ public class RelabelApplicationService implements WalletResultHandler {
return detail(customerId, relabelNo);
}
@Transactional
public RelabelResponses.Detail confirmCompletion(String customerId, String relabelNo, String operatorId,
RelabelRequests.CustomerConfirm request) {
RelabelApplication application = applications.lockNo(customerId, relabelNo);
if (application == null) throw notFound("Relabel application not found");
String key = request.idempotencyKey().trim();
if (key.equals(application.getCustomerConfirmationKey())) return detail(customerId, relabelNo);
if (!CUSTOMER_CONFIRM_PENDING.equals(application.getStatus())) {
throw conflict("Relabel order is not awaiting customer confirmation");
}
if (Boolean.TRUE.equals(application.getPrintShippingLabel())
&& !StringUtils.hasText(application.getShippingLabelFileId())) {
throw conflict("Upload the shipping label before confirming relabel completion");
}
LocalDateTime now = LocalDateTime.now();
String before = application.getStatus();
application.setStatus(READY_FOR_OUTBOUND);
application.setCustomerConfirmationKey(key);
application.setCustomerConfirmedAt(now);
application.setFulfillmentStatus("TRUCK".equals(application.getDeliveryMethod())
? "WAIT_PRE_DISPATCH" : "WAIT_OUTBOUND_CREATION");
if (!"TRUCK".equals(application.getDeliveryMethod())) {
createDirectOutbound(application, operatorId, now);
application.setFulfillmentStatus("OUTBOUND_CREATED");
}
application.setUpdatedAt(now);
applications.updateById(application);
for (LastlegOrder order : tailOrders(application)) {
String orderBefore = order.getCurrentStatus();
order.setCurrentStatus("PENDING_ASSIGNMENT");
order.setSubOrderStatus("RELABEL:" + application.getFulfillmentStatus());
order.setRelabelStatus(READY_FOR_OUTBOUND);
order.setUpdatedAt(now);
orders.updateById(order);
if (!Objects.equals(orderBefore, order.getCurrentStatus())) {
insertOrderHistory(customerId, order.getId(), orderBefore, order.getCurrentStatus(),
request.remark(), now);
}
}
insertHistory(customerId, application.getId(), before, READY_FOR_OUTBOUND, operatorId,
request.remark(), now);
return detail(customerId, relabelNo);
}
private void createDirectOutbound(RelabelApplication application, String operatorId, LocalDateTime now) {
String key = "RELABEL:" + application.getId() + ":OUTBOUND";
OutboundOrder existing = outboundOrders.findByIdempotency(application.getCustomerId(), key);
if (existing != null) {
application.setOutboundOrderNo(existing.getOutboundNo());
return;
}
List<RelabelItem> relabelItems = itemList(application.getId());
OutboundOrder outbound = new OutboundOrder();
outbound.setId(IdWorker.getId()); outbound.setCustomerId(application.getCustomerId());
outbound.setOutboundNo(orderNoGenerator.next("RELABEL_OUTBOUND", application.getOverseasWarehouseId(), outbound.getId()));
outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType("RELABEL_OUTBOUND");
outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(relabelItems.stream().mapToInt(item -> intValue(item.getNotifiedPackageCount())).sum());
outbound.setPalletCount((int) relabelItems.stream().map(RelabelItem::getSourceInventoryPositionId).filter(Objects::nonNull).distinct().count());
outbound.setRecipientInfo(application.getDeliveryMethod()); outbound.setIdempotencyKey(key);
outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); outboundOrders.insert(outbound);
Map<Long, List<RelabelItem>> byPosition = relabelItems.stream().collect(Collectors.groupingBy(
RelabelItem::getSourceInventoryPositionId, LinkedHashMap::new, Collectors.toList()));
for (Map.Entry<Long, List<RelabelItem>> entry : byPosition.entrySet()) {
InventoryPosition position = inventoryPositions.selectById(entry.getKey());
if (position == null || !application.getCustomerId().equals(position.getCustomerId())) {
throw conflict("Relabel source inventory position no longer exists");
}
List<RelabelItem> group = entry.getValue();
OutboundPalletItem item = new OutboundPalletItem(); item.setId(IdWorker.getId());
item.setCustomerId(application.getCustomerId()); item.setOutboundOrderId(outbound.getId());
item.setWarehousePalletId(position.getPalletId()); item.setPalletNo(position.getPalletNo());
item.setPackageCount(group.stream().mapToInt(value -> intValue(value.getNotifiedPackageCount())).sum());
item.setPickedPackageCount(0); item.setStatus("WAIT_PICK");
item.setCartonMarks(group.stream().map(RelabelItem::getCartonMark).filter(StringUtils::hasText).distinct().collect(Collectors.joining(",")));
item.setDestinationWarehouseCode(group.stream().map(RelabelItem::getDestinationFbaWarehouseCode)
.filter(StringUtils::hasText).distinct().collect(Collectors.joining(",")));
item.setInboundCode(group.stream().map(RelabelItem::getSourceInboundCode).filter(StringUtils::hasText).distinct().collect(Collectors.joining(",")));
item.setOrderRefs(writeOutboundRefs(application.getRelabelNo(), entry.getKey(), group));
item.setCreatedAt(now); outboundItems.insert(item);
}
OutboundPdaTask task = new OutboundPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(application.getCustomerId());
task.setOutboundOrderId(outbound.getId()); task.setTaskNo("PDA" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + task.getId());
task.setStatus("PENDING"); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); outboundTasks.insert(task);
application.setOutboundOrderNo(outbound.getOutboundNo());
}
private String writeOutboundRefs(String relabelNo, Long positionId, List<RelabelItem> values) {
try {
return objectMapper.writeValueAsString(Map.of("sourceType", "RELABEL_ORDER", "relabelNo", relabelNo,
"sourceInventoryPositionId", positionId, "items", values.stream().map(item -> Map.of(
"itemId", item.getId(), "cartonMark", Objects.toString(item.getCartonMark(), ""),
"destination", Objects.toString(item.getDestinationFbaWarehouseCode(), ""),
"packages", intValue(item.getNotifiedPackageCount()))).toList()));
} catch (Exception exception) { throw new IllegalStateException("Cannot save relabel outbound snapshot", exception); }
}
private void insertItem(String customerId, long applicationId, SourceInventory sourceInventory,
LocalDateTime now) {
RelabelRequests.Item source = sourceInventory.request();
@@ -391,6 +579,9 @@ public class RelabelApplicationService implements WalletResultHandler {
Objects.requireNonNullElse(value.getNotifiedPackageCount(), 0), skuCodes,
value.getTotalFileId(), value.getTotalFileName(), value.getTotalFileUrl(),
value.getChargeAmount(), value.getChargeCurrency(), value.getWalletStatus(), value.getWalletErrorMessage(),
value.getDeliveryMethod(), Boolean.TRUE.equals(value.getPrintShippingLabel()),
value.getShippingLabelFileId(), value.getShippingLabelFileName(), value.getShippingLabelFileUrl(),
value.getFulfillmentStatus(), value.getOutboundOrderNo(), value.getCustomerConfirmedAt(),
value.getCreatedAt());
}
@@ -474,6 +665,7 @@ public class RelabelApplicationService implements WalletResultHandler {
private record SourceInventory(RelabelRequests.Item request, InventoryBalance balance) {}
private static long value(Long value) { return value == null ? 0L : value; }
private static int intValue(Integer value) { return value == null ? 0 : value; }
private void releaseInventory(String customerId, String operatorId, RelabelApplication application) {
for (RelabelItem item : itemList(application.getId())) {
@@ -505,9 +697,6 @@ public class RelabelApplicationService implements WalletResultHandler {
&& (item.skuItems() == null || item.skuItems().isEmpty())) {
throw badRequest("SKU label changes require at least one SKU detail");
}
if ("CARTON_LABEL".equals(type) && !StringUtils.hasText(item.newSkuCode())) {
throw badRequest("Carton label changes require the new SKU");
}
}
private static String operationType(RelabelRequests.Item item) {
return StringUtils.hasText(item.operationType()) ? item.operationType().trim().toUpperCase() : "CARTON_LABEL";
@@ -527,6 +716,12 @@ public class RelabelApplicationService implements WalletResultHandler {
List.class, RelabelResponses.SkuItem.class));
} catch (Exception exception) { throw new IllegalStateException("Cannot read relabel SKU details", exception); }
}
private List<Long> readIds(String value) {
if (!StringUtils.hasText(value)) return List.of();
try {
return objectMapper.readValue(value, objectMapper.getTypeFactory().constructCollectionType(List.class, Long.class));
} catch (Exception exception) { throw conflict("Relabel completion photo snapshot is invalid"); }
}
private RelabelApplication require(String customerId, String relabelNo) {
RelabelApplication value = StringUtils.hasText(customerId)
? applications.findNo(customerId, relabelNo)
@@ -548,7 +743,9 @@ public class RelabelApplicationService implements WalletResultHandler {
}
private static void validateTransition(String from, String to) {
boolean allowed = PENDING_PROCESSING.equals(from) && Set.of("PROCESSING", "REJECTED", "CANCELLED").contains(to)
|| "PROCESSING".equals(from) && Set.of("COMPLETED", "REJECTED", "CANCELLED").contains(to);
|| "PROCESSING".equals(from) && Set.of(CUSTOMER_CONFIRM_PENDING, "REJECTED", "CANCELLED").contains(to)
|| CUSTOMER_CONFIRM_PENDING.equals(from) && Set.of(READY_FOR_OUTBOUND, "REJECTED", "CANCELLED").contains(to)
|| READY_FOR_OUTBOUND.equals(from) && Set.of("COMPLETED", "CANCELLED").contains(to);
if (!allowed) throw conflict("Relabel status cannot change from " + from + " to " + to);
}
private String hash(Object value) {
@@ -0,0 +1,129 @@
package com.baiyue.lastleg.relabel.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/** Keeps relabel ownership and its direct-outbound snapshot aligned after a physical pallet split. */
@Service
public class RelabelPalletMutationService {
@Resource private RelabelItemMapper relabelItems;
@Resource private InventoryPositionMapper positions;
@Resource private OutboundOrderMapper outboundOrders;
@Resource private OutboundPalletItemMapper outboundItems;
@Resource private ObjectMapper objectMapper;
public void moveSelectedItems(RelabelApplication application,
Map<Long, List<RelabelItem>> itemsBySourcePosition,
Map<Long, InventoryPosition> targetBySourcePosition) {
for (Map.Entry<Long, List<RelabelItem>> entry : itemsBySourcePosition.entrySet()) {
InventoryPosition target = targetBySourcePosition.get(entry.getKey());
if (target == null) throw conflict("换标拆托目标库存位置不存在");
for (RelabelItem item : entry.getValue()) {
item.setSourceInventoryPositionId(target.getId());
relabelItems.updateById(item);
}
}
syncDirectOutbound(application, itemsBySourcePosition, targetBySourcePosition);
}
private void syncDirectOutbound(RelabelApplication application,
Map<Long, List<RelabelItem>> itemsBySourcePosition,
Map<Long, InventoryPosition> targetBySourcePosition) {
if (application.getOutboundOrderNo() == null || application.getOutboundOrderNo().isBlank()) return;
OutboundOrder outbound = outboundOrders.lockByNo(application.getCustomerId(), application.getOutboundOrderNo());
if (outbound == null || !"RELABEL_OUTBOUND".equals(outbound.getOutboundType())) {
throw conflict("换标出库单不存在或类型不正确");
}
if (!"WAIT_PICK".equals(outbound.getStatus())) throw conflict("换标出库已开始拣货,不能再拆托");
List<OutboundPalletItem> rows = new ArrayList<>(outboundItems.findByOutbound(
application.getCustomerId(), outbound.getId()));
for (Map.Entry<Long, List<RelabelItem>> entry : itemsBySourcePosition.entrySet()) {
List<RelabelItem> selected = entry.getValue();
LinkedHashSet<String> selectedMarks = selected.stream().map(RelabelItem::getCartonMark)
.filter(Objects::nonNull).map(String::trim)
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
long selectedPackages = selected.stream().mapToLong(item -> value(item.getNotifiedPackageCount())).sum();
OutboundPalletItem sourceRow = rows.stream().filter(row -> value(row.getPickedPackageCount()) == 0)
.filter(row -> cartonMarks(row).containsAll(selectedMarks)).findFirst()
.orElseThrow(() -> conflict("换标出库箱唛快照与拆托任务不一致"));
LinkedHashSet<String> remaining = cartonMarks(sourceRow);
remaining.removeAll(selectedMarks);
InventoryPosition target = targetBySourcePosition.get(entry.getKey());
if (remaining.isEmpty()) {
sourceRow.setWarehousePalletId(target.getPalletId()); sourceRow.setPalletNo(target.getPalletNo());
sourceRow.setOrderRefs(outboundRefs(application.getRelabelNo(), target.getId(), selected));
outboundItems.updateById(sourceRow);
continue;
}
int remainingPackages = value(sourceRow.getPackageCount()) - Math.toIntExact(selectedPackages);
if (remainingPackages <= 0) throw conflict("换标出库剩余箱唛与箱数快照不一致");
sourceRow.setPackageCount(remainingPackages); sourceRow.setCartonMarks(String.join(",", remaining));
List<RelabelItem> remainingItems = relabelItems.selectList(new LambdaQueryWrapper<RelabelItem>()
.eq(RelabelItem::getApplicationId, application.getId())
.eq(RelabelItem::getCustomerId, application.getCustomerId())
.eq(RelabelItem::getSourceInventoryPositionId, entry.getKey()));
sourceRow.setOrderRefs(outboundRefs(application.getRelabelNo(), entry.getKey(), remainingItems));
outboundItems.updateById(sourceRow);
OutboundPalletItem targetRow = new OutboundPalletItem();
targetRow.setId(IdWorker.getId()); targetRow.setCustomerId(sourceRow.getCustomerId());
targetRow.setOutboundOrderId(sourceRow.getOutboundOrderId()); targetRow.setWarehousePalletId(target.getPalletId());
targetRow.setPalletNo(target.getPalletNo()); targetRow.setDestinationWarehouseId(sourceRow.getDestinationWarehouseId());
targetRow.setDestinationWarehouseCode(sourceRow.getDestinationWarehouseCode());
targetRow.setDestinationWarehouseName(sourceRow.getDestinationWarehouseName());
targetRow.setPackageCount(Math.toIntExact(selectedPackages)); targetRow.setPickedPackageCount(0);
targetRow.setStatus("WAIT_PICK"); targetRow.setSkuCode(sourceRow.getSkuCode());
targetRow.setCartonMarks(String.join(",", selectedMarks)); targetRow.setInboundCode(sourceRow.getInboundCode());
targetRow.setOrderRefs(outboundRefs(application.getRelabelNo(), target.getId(), selected));
targetRow.setCreatedAt(LocalDateTime.now()); outboundItems.insert(targetRow); rows.add(targetRow);
}
outbound.setPalletCount((int) rows.stream().map(OutboundPalletItem::getWarehousePalletId)
.filter(Objects::nonNull).distinct().count());
outbound.setUpdatedAt(LocalDateTime.now()); outboundOrders.updateById(outbound);
}
private static LinkedHashSet<String> cartonMarks(OutboundPalletItem item) {
LinkedHashSet<String> result = new LinkedHashSet<>();
if (item.getCartonMarks() == null) return result;
for (String value : item.getCartonMarks().split(",")) if (!value.isBlank()) result.add(value.trim());
return result;
}
private String outboundRefs(String relabelNo, Long positionId, List<RelabelItem> items) {
try {
return objectMapper.writeValueAsString(Map.of("sourceType", "RELABEL_ORDER", "relabelNo", relabelNo,
"sourceInventoryPositionId", positionId, "items", items.stream().map(item -> Map.of(
"itemId", item.getId(), "cartonMark", Objects.toString(item.getCartonMark(), ""),
"destination", Objects.toString(item.getDestinationFbaWarehouseCode(), ""),
"packages", value(item.getNotifiedPackageCount()))).toList()));
} catch (Exception exception) {
throw new IllegalStateException("无法保存换标出库托盘快照", exception);
}
}
private static int value(Integer value) { return value == null ? 0 : value; }
private static ResponseStatusException conflict(String message) {
return new ResponseStatusException(HttpStatus.CONFLICT, message);
}
}
@@ -13,6 +13,8 @@ import java.util.List;
public final class ReturnOrderRequests {
private ReturnOrderRequests() { }
public record Create(@NotBlank @Size(max = 128) String overseasWarehouseId,
@Size(max = 128) String warehouseContactName,
@Size(max = 64) String warehouseContactPhone,
@Size(max = 128) String customerReferenceNo,
boolean needRelabel,
@Size(max = 1000) String remark,
@@ -5,7 +5,8 @@ import java.util.List;
public final class ReturnOrderResponses {
private ReturnOrderResponses() { }
public record Summary(String returnNo, String lastlegOrderNo, String overseasWarehouseId, String customerReferenceNo, String status,
public record Summary(String returnNo, String lastlegOrderNo, String overseasWarehouseId,
String warehouseContactName, String warehouseContactPhone, String customerReferenceNo, String status,
int skuTypes, long expectedQuantity, long receivedQuantity, boolean needRelabel,
String relabelNo, String relabelStatus, LocalDateTime createdAt) { }
public record Item(String skuCode, String productNameZh, long expectedQuantity,
@@ -14,6 +14,8 @@ public class ReturnOrder {
private String customerId;
private String returnNo;
private String overseasWarehouseId;
private String warehouseContactName;
private String warehouseContactPhone;
private String customerReferenceNo;
private Boolean needRelabel;
private Long lastlegOrderId;
@@ -77,6 +77,8 @@ public class ReturnReceiptService {
ReturnOrder order = new ReturnOrder();
order.setId(IdWorker.getId()); order.setCustomerId(customerId);
order.setOverseasWarehouseId(text(request.overseasWarehouseId()));
order.setWarehouseContactName(text(request.warehouseContactName()));
order.setWarehouseContactPhone(text(request.warehouseContactPhone()));
order.setReturnNo(numbers.next("RETURN", order.getOverseasWarehouseId(), order.getId()));
order.setCustomerReferenceNo(text(request.customerReferenceNo())); order.setNeedRelabel(request.needRelabel()); order.setStatus(PENDING_RECEIPT);
order.setExpectedSkuTypes(skuCodes.size()); order.setExpectedQuantity(expected); order.setReceivedQuantity(0L);
@@ -293,7 +295,7 @@ public class ReturnReceiptService {
order.getStatus(), completed, value(order.getExpectedQuantity()), value(order.getReceivedQuantity()), receipt.getSignedAt());
}
private ReturnOrderResponses.Summary summary(ReturnOrder order) { return new ReturnOrderResponses.Summary(order.getReturnNo(), order.getLastlegOrderNo(),
order.getOverseasWarehouseId(), order.getCustomerReferenceNo(), order.getStatus(),
order.getOverseasWarehouseId(), order.getWarehouseContactName(), order.getWarehouseContactPhone(), order.getCustomerReferenceNo(), order.getStatus(),
order.getExpectedSkuTypes() == null ? 0 : order.getExpectedSkuTypes(), value(order.getExpectedQuantity()),
value(order.getReceivedQuantity()), Boolean.TRUE.equals(order.getNeedRelabel()), order.getRelabelNo(), order.getRelabelStatus(), order.getCreatedAt()); }
private ReturnOrderResponses.Item item(ReturnOrderItem item) { return new ReturnOrderResponses.Item(item.getSkuCode(),
@@ -17,16 +17,16 @@ public final class WarehouseTransferRequests {
private WarehouseTransferRequests() {}
public record Create(
@NotBlank @Size(max = 128) String inboundCode,
@NotEmpty @Size(max = 200) List<@NotNull Long> sourceLastlegOrderIds,
@Size(max = 128) String inboundCode,
@Size(max = 200) List<@NotNull Long> sourceLastlegOrderIds,
@NotBlank @Size(max = 128) String operatingWarehouseId,
@NotBlank @Size(max = 128) String fbaWarehouseCode,
@Size(max = 128) String fbaWarehouseCode,
@NotBlank @Size(max = 1000) String deliveryAddress,
@Positive int packageCount,
@NotNull @DecimalMin(value = "0.0001") BigDecimal volumeCbm,
@NotBlank @Size(max = 64) String deliveryMethod,
@NotNull LocalDateTime estimatedArrivalAt,
@NotBlank @Size(max = 128) String fbaShipmentNo,
@Size(max = 128) String fbaShipmentNo,
@Size(max = 255) String trackingNo,
@Size(max = 2000) String fbaBoxNos,
@Size(max = 1000) String remark,
@@ -6,6 +6,7 @@ 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 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,
@@ -21,6 +22,6 @@ public final class WarehouseTransferResponses {
String operatingWarehouseId, String fbaWarehouseCode, String deliveryAddress,
int packageCount, String deliveryMethod, LocalDateTime estimatedArrivalAt,
String fbaShipmentNo, String trackingNo, String fbaBoxNos, String remark,
List<SourceOrder> sourceOrders, List<Item> items, List<Allocation> allocations,
List<SourceOrder> sourceOrders, List<Item> items, List<Carton> cartons, List<Allocation> allocations,
LocalDateTime outboundAt, LocalDateTime createdAt) {}
}
@@ -0,0 +1,23 @@
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_carton")
public class WarehouseTransferCarton {
@TableId(type = IdType.INPUT) private Long id;
private Long transferOrderId;
private Long transferItemId;
private String customerId;
private String cartonMark;
private Integer cartonIndex;
private Integer totalCartons;
private String status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.transfer.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferCarton;
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 WarehouseTransferCartonMapper extends BaseMapper<WarehouseTransferCarton> {
@Select("SELECT * FROM ll_warehouse_transfer_carton WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} ORDER BY carton_index")
List<WarehouseTransferCarton> listOrder(@Param("customerId") String customerId,
@Param("transferOrderId") Long transferOrderId);
}
@@ -5,11 +5,13 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegInboundOrder;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.mapper.LastlegInboundOrderMapper;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper;
@@ -17,22 +19,19 @@ import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
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.entity.RelabelItem;
import com.baiyue.lastleg.relabel.entity.RelabelStatusHistory;
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper;
import com.baiyue.lastleg.relabel.service.RelabelApplicationService;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferItem;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferCarton;
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.mapper.InboundSnapshotMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferItemMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferCartonMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferInventoryAllocationMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferReceiptMapper;
@@ -61,7 +60,6 @@ import java.util.stream.Collectors;
@Service
public class WarehouseTransferService {
public static final String ORDER_TYPE = "WAREHOUSE_TRANSFER";
public static final String RETURN_RELABEL_ORDER_TYPE = "RETURN_RELABEL";
public static final String PENDING_REVIEW = "PENDING_REVIEW";
public static final String APPROVED = "APPROVED";
public static final String REJECTED = "REJECTED";
@@ -73,16 +71,16 @@ public class WarehouseTransferService {
@Resource private WarehouseTransferOrderMapper transfers;
@Resource private WarehouseTransferItemMapper transferItems;
@Resource private WarehouseTransferCartonMapper transferCartons;
@Resource private WarehouseTransferSourceOrderMapper sourceOrders;
@Resource private WarehouseTransferInventoryAllocationMapper inventoryAllocations;
@Resource private WarehouseTransferReceiptMapper transferReceipts;
@Resource private InboundSnapshotMapper inboundSnapshots;
@Resource private LastlegInboundOrderMapper inboundOrders;
@Resource private LastlegOrderMapper orders;
@Resource private LastlegOrderDetailMapper orderDetails;
@Resource private LastlegOrderStatusHistoryMapper orderHistories;
@Resource private RelabelApplicationMapper relabelApplications;
@Resource private RelabelItemMapper relabelItems;
@Resource private RelabelStatusHistoryMapper relabelHistories;
@Resource private LastlegOrderNoGenerator orderNoGenerator;
@Resource private InventoryCommandService inventory;
@Resource private InventoryCommandMapper inventoryCommands;
@@ -105,30 +103,47 @@ public class WarehouseTransferService {
return detail(customerId, replay.getTransferNo());
}
String inboundCode = request.inboundCode().trim();
Long inboundOrderId = inboundSnapshots.findId(customerId, inboundCode);
if (inboundOrderId == null) throw notFound("Inbound order was not found for inboundCode " + inboundCode);
List<Long> selectedIds = request.sourceLastlegOrderIds().stream().distinct().toList();
List<LastlegOrder> selectedOrders = orders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getCustomerId, customerId).in(LastlegOrder::getId, selectedIds));
if (selectedOrders.size() != selectedIds.size()) {
throw notFound("One or more selected customer orders do not exist in the current tenant");
}
LocalDateTime now = LocalDateTime.now();
long transferId = IdWorker.getId();
String transferNo = orderNoGenerator.next("TRANSFER", request.operatingWarehouseId(), transferId);
LastlegOrder order = createTailOrder(customerId, inboundOrderId, transferNo, request, requestHash, now);
String requestedInboundCode = trim(request.inboundCode());
List<Long> selectedIds = request.sourceLastlegOrderIds() == null
? List.of() : request.sourceLastlegOrderIds().stream().distinct().toList();
boolean existingInventoryTransfer = StringUtils.hasText(requestedInboundCode) || !selectedIds.isEmpty();
if (existingInventoryTransfer && (!StringUtils.hasText(requestedInboundCode) || selectedIds.isEmpty())) {
throw badRequest("Existing inventory transfer requires both inboundCode and sourceLastlegOrderIds");
}
Long inboundOrderId;
String inboundCode;
List<LastlegOrder> selectedOrders;
if (existingInventoryTransfer) {
inboundCode = requestedInboundCode;
inboundOrderId = inboundSnapshots.findId(customerId, inboundCode);
if (inboundOrderId == null) throw notFound("Inbound order was not found for inboundCode " + inboundCode);
selectedOrders = orders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getCustomerId, customerId).in(LastlegOrder::getId, selectedIds));
if (selectedOrders.size() != selectedIds.size()) {
throw notFound("One or more selected customer orders do not exist in the current tenant");
}
} else {
inboundCode = transferNo;
inboundOrderId = createInboundSnapshot(customerId, transferId, transferNo, request, requestHash, now);
selectedOrders = List.of();
}
LastlegOrder order = createTailOrder(customerId, inboundOrderId, inboundCode, transferNo,
request, requestHash, now);
WarehouseTransferOrder transfer = new WarehouseTransferOrder();
transfer.setId(transferId); transfer.setCustomerId(customerId); transfer.setTransferNo(transferNo);
transfer.setInboundOrderId(inboundOrderId); transfer.setInboundCode(inboundCode);
transfer.setLastlegOrderId(order.getId()); transfer.setLastlegOrderNo(order.getOrderNo());
transfer.setOperatingWarehouseId(request.operatingWarehouseId().trim());
transfer.setFbaWarehouseCode(request.fbaWarehouseCode().trim());
transfer.setFbaWarehouseCode(trim(request.fbaWarehouseCode()));
transfer.setDeliveryAddress(request.deliveryAddress().trim()); transfer.setPackageCount(request.packageCount());
transfer.setDeliveryMethod(request.deliveryMethod().trim()); transfer.setEstimatedArrivalAt(request.estimatedArrivalAt());
transfer.setFbaShipmentNo(request.fbaShipmentNo().trim()); transfer.setTrackingNo(trim(request.trackingNo()));
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.setIdempotencyKey(key); transfer.setRequestHash(requestHash); transfer.setCreatedBy(trim(operatorId));
@@ -149,14 +164,19 @@ public class WarehouseTransferService {
sourceOrders.insert(link);
}
int cartonIndex = 0;
for (int index = 0; index < request.items().size(); index++) {
WarehouseTransferRequests.Item source = request.items().get(index);
insertTransferItem(customerId, transferId, source, now);
long itemId = insertTransferItem(customerId, transferId, source, now);
for (int itemCarton = 0; itemCarton < source.packageCount(); itemCarton++) {
cartonIndex++;
insertTransferCarton(customerId, transferId, itemId, transferNo,
cartonIndex, request.packageCount(), now);
}
insertOrderDetail(customerId, order.getId(), transferNo, index, source, request, now);
}
insertOrderHistory(customerId, order.getId(), null, PENDING_REVIEW, transferNo,
"Warehouse transfer submitted for review", now);
if (request.needRelabel()) createRelabel(customerId, operatorId, transfer, order, request, now);
return detail(customerId, transferNo);
}
@@ -200,6 +220,9 @@ public class WarehouseTransferService {
.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(),
value.getCartonIndex(), value.getTotalCartons(), value.getStatus())).toList();
Map<Long, WarehouseTransferReceipt> receiptByAllocation = transferReceipts.selectList(
new LambdaQueryWrapper<WarehouseTransferReceipt>().eq(WarehouseTransferReceipt::getCustomerId, customerId)
.eq(WarehouseTransferReceipt::getTransferOrderId, transfer.getId()))
@@ -230,7 +253,7 @@ public class WarehouseTransferService {
relabelStatus,
transfer.getOperatingWarehouseId(), transfer.getFbaWarehouseCode(), transfer.getDeliveryAddress(),
transfer.getPackageCount(), transfer.getDeliveryMethod(), transfer.getEstimatedArrivalAt(), transfer.getFbaShipmentNo(),
transfer.getTrackingNo(), transfer.getFbaBoxNos(), transfer.getRemark(), sources, items, allocations,
transfer.getTrackingNo(), transfer.getFbaBoxNos(), transfer.getRemark(), sources, items, cartons, allocations,
transfer.getOutboundAt(), transfer.getCreatedAt());
}
@@ -254,9 +277,15 @@ public class WarehouseTransferService {
LocalDateTime now = LocalDateTime.now();
String before = order.getCurrentStatus();
transfer.setStatus(next); transfer.setUpdatedAt(now); transfers.updateById(transfer);
String orderStatus = APPROVED.equals(next) ? PENDING_ASSIGNMENT : "CANCELLED";
boolean incomingTransit = transfer.getTransferNo().equals(transfer.getInboundCode());
String orderStatus = APPROVED.equals(next)
? (incomingTransit
? ("WAREHOUSE_PICKUP".equals(transfer.getDeliveryMethod()) ? "WAIT_PICKUP" : "WAIT_DELIVERY")
: PENDING_ASSIGNMENT)
: "CANCELLED";
order.setCurrentStatus(orderStatus);
order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + next);
order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + next + ";RELABEL:"
+ (StringUtils.hasText(order.getRelabelStatus()) ? order.getRelabelStatus() : "NOT_REQUIRED"));
order.setUpdatedAt(now); orders.updateById(order);
if (!java.util.Objects.equals(before, orderStatus)) {
insertOrderHistory(customerId, order.getId(), before, orderStatus, transferNo,
@@ -461,20 +490,41 @@ public class WarehouseTransferService {
return detail(customerId, transferNo);
}
private LastlegOrder createTailOrder(String customerId, Long inboundOrderId, String transferNo,
private Long createInboundSnapshot(String customerId, long inboundOrderId, String transferNo,
WarehouseTransferRequests.Create request, String requestHash,
LocalDateTime now) {
LastlegInboundOrder inbound = new LastlegInboundOrder();
String sourceId = "WAREHOUSE_TRANSFER:" + transferNo;
inbound.setId(inboundOrderId); inbound.setCustomerId(customerId); inbound.setSourceRegion("CUSTOMER");
inbound.setSourceInboundId(sourceId); inbound.setRootInboundId(sourceId);
inbound.setSourceOrderId(transferNo); inbound.setSourceOrderNo(transferNo); inbound.setRootOrderId(transferNo);
inbound.setInboundCode(transferNo); inbound.setDestinationWarehouseId(request.operatingWarehouseId().trim());
inbound.setDestinationType("OVERSEAS"); inbound.setPackageCount(request.packageCount());
inbound.setPalletCount(0); inbound.setVolumeCbm(request.volumeCbm());
inbound.setSourceEventId(sourceId); inbound.setSourceLoadingPlanId(ORDER_TYPE);
inbound.setSourceLoadingPlanVersion(1L); inbound.setHandoverVersion(1L);
inbound.setPayloadHash("sha256:" + requestHash); inbound.setStatus(PENDING_REVIEW);
inbound.setExceptionFlag("NONE"); inbound.setCreatedAt(now); inbound.setUpdatedAt(now);
inboundOrders.insert(inbound);
return inbound.getId();
}
private LastlegOrder createTailOrder(String customerId, Long inboundOrderId, String inboundCode, String transferNo,
WarehouseTransferRequests.Create request, String requestHash, LocalDateTime now) {
LastlegOrder order = new LastlegOrder();
order.setId(IdWorker.getId()); order.setOrderNo(orderNoGenerator.next("TRANSFER", request.operatingWarehouseId(), order.getId())); order.setCustomerId(customerId);
order.setOrderType(request.needRelabel() ? RETURN_RELABEL_ORDER_TYPE : ORDER_TYPE);
order.setInboundCode(request.inboundCode().trim()); order.setSourceRegion("CUSTOMER");
order.setOrderType(ORDER_TYPE);
order.setInboundCode(inboundCode); order.setSourceRegion("CUSTOMER");
order.setSourceInboundId("WAREHOUSE_TRANSFER:" + transferNo); order.setRootInboundId(String.valueOf(inboundOrderId));
order.setSourceOrderNo(transferNo); order.setOverseasWarehouseId(request.operatingWarehouseId().trim());
order.setFbaWarehouseCode(request.fbaWarehouseCode().trim()); order.setPackageCount(request.packageCount());
order.setFbaWarehouseCode(trim(request.fbaWarehouseCode())); order.setPackageCount(request.packageCount());
order.setVolumeCbm(request.volumeCbm());
order.setContainerNo(transferNo);
order.setPalletCount(0); order.setPickedPackageCount(0); order.setShippedPackageCount(0);
order.setEstimatedWarehouseAt(request.estimatedArrivalAt());
order.setCurrentStatus("PENDING_ASSIGNMENT"); order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + PENDING_REVIEW);
order.setCurrentStatus(PENDING_REVIEW);
order.setRelabelStatus(request.needRelabel() ? "PENDING_BINDING" : "NOT_REQUIRED");
order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + PENDING_REVIEW + ";RELABEL:" + order.getRelabelStatus());
order.setCustomerRemark(trim(request.remark()));
order.setSourceEventId("WAREHOUSE_TRANSFER:" + transferNo); order.setSourceLoadingPlanId(ORDER_TYPE);
order.setSourcePayloadHash("sha256:" + requestHash); order.setCreatedAt(now); order.setUpdatedAt(now);
@@ -482,13 +532,24 @@ public class WarehouseTransferService {
return order;
}
private void insertTransferItem(String customerId, long transferId, WarehouseTransferRequests.Item source,
private long insertTransferItem(String customerId, long transferId, WarehouseTransferRequests.Item source,
LocalDateTime now) {
WarehouseTransferItem item = new WarehouseTransferItem();
item.setId(IdWorker.getId()); item.setTransferOrderId(transferId); item.setCustomerId(customerId);
item.setSkuCode(source.skuCode().trim()); item.setProductNameZh(source.productNameZh().trim());
item.setPackageCount(source.packageCount()); item.setShippingMark(trim(source.shippingMark())); item.setCreatedAt(now);
transferItems.insert(item);
return item.getId();
}
private void insertTransferCarton(String customerId, long transferId, long transferItemId,
String transferNo, int cartonIndex, int totalCartons,
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.setCartonIndex(cartonIndex); carton.setTotalCartons(totalCartons); carton.setStatus("EXPECTED");
carton.setCreatedAt(now); carton.setUpdatedAt(now); transferCartons.insert(carton);
}
private void insertOrderDetail(String customerId, long orderId, String transferNo, int index, WarehouseTransferRequests.Item source,
@@ -497,48 +558,13 @@ public class WarehouseTransferService {
detail.setCustomerId(customerId);
detail.setOrderId(orderId); detail.setSourceInboundDetailId(transferNo + ":" + index);
detail.setSkuCode(source.skuCode().trim()); detail.setProductNameZh(source.productNameZh().trim());
detail.setPackageCount(source.packageCount()); detail.setDestinationWarehouseCode(request.fbaWarehouseCode().trim());
detail.setFbaTrackingNo(trim(request.trackingNo())); detail.setShipmentNo(request.fbaShipmentNo().trim());
detail.setPackageCount(source.packageCount()); detail.setDestinationWarehouseCode(trim(request.fbaWarehouseCode()));
detail.setFbaTrackingNo("CUSTOMER_DELIVERY".equals(request.deliveryMethod().trim())
? trim(request.trackingNo()) : null);
detail.setShipmentNo(trim(request.fbaShipmentNo()));
detail.setShippingMark(trim(source.shippingMark())); orderDetails.insert(detail);
}
private void createRelabel(String customerId, String operatorId, WarehouseTransferOrder transfer, LastlegOrder order,
WarehouseTransferRequests.Create request, LocalDateTime now) {
long applicationId = IdWorker.getId();
String relabelNo = orderNoGenerator.next("RELABEL", request.operatingWarehouseId(), applicationId);
RelabelApplication application = new RelabelApplication();
application.setId(applicationId); application.setCustomerId(customerId); application.setRelabelNo(relabelNo);
application.setLastlegOrderId(order.getId()); application.setCustomerOrderNo(transfer.getTransferNo());
application.setOverseasWarehouseId(request.operatingWarehouseId().trim()); application.setLabelDeadline(request.estimatedArrivalAt());
application.setContactName(StringUtils.hasText(operatorId) ? operatorId.trim() : "SYSTEM");
application.setContactPhone("N/A"); application.setRemark(trim(request.remark()));
application.setStatus(RelabelApplicationService.PENDING_PROCESSING); application.setItemCount(request.items().size());
application.setTotalPackageCount(request.packageCount()); application.setNotifiedPackageCount(request.packageCount());
application.setIdempotencyKey("WAREHOUSE_TRANSFER:" + request.idempotencyKey().trim());
application.setRequestHash(hash(request)); application.setCreatedBy(trim(operatorId));
application.setCreatedAt(now); application.setUpdatedAt(now); relabelApplications.insert(application);
for (WarehouseTransferRequests.Item source : request.items()) {
RelabelItem item = new RelabelItem();
item.setId(IdWorker.getId()); item.setApplicationId(applicationId); item.setCustomerId(customerId);
item.setSkuCode(source.skuCode().trim()); item.setProductNameZh(source.productNameZh().trim());
item.setTotalPackageCount(source.packageCount()); item.setNotifiedPackageCount(source.packageCount());
item.setShipmentNo(request.fbaShipmentNo().trim()); item.setTrackingCode(trim(request.trackingNo()));
item.setNewSkuCode(source.skuCode().trim()); item.setLabelRequirement("WAREHOUSE_TRANSFER_RELABEL");
item.setCreatedAt(now); relabelItems.insert(item);
}
RelabelStatusHistory history = new RelabelStatusHistory();
history.setId(IdWorker.getId()); history.setCustomerId(customerId); history.setApplicationId(applicationId);
history.setToStatus(RelabelApplicationService.PENDING_PROCESSING); history.setOperatorId(trim(operatorId));
history.setRemark("Created from warehouse transfer " + transfer.getTransferNo()); history.setCreatedAt(now);
relabelHistories.insert(history);
transfer.setRelabelApplicationId(applicationId); transfer.setRelabelNo(relabelNo); transfer.setUpdatedAt(now);
transfers.updateById(transfer);
order.setRelabelOrderNo(relabelNo); order.setRelabelStatus(RelabelApplicationService.PENDING_PROCESSING);
order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + PENDING_REVIEW + ";RELABEL:" + RelabelApplicationService.PENDING_PROCESSING);
order.setUpdatedAt(now); orders.updateById(order);
}
private void insertOrderHistory(String customerId, long orderId, String fromStatus, String toStatus,
String transferNo, String remark, LocalDateTime now) {
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory();
@@ -81,7 +81,8 @@ baiyue:
lease-seconds: ${FIRSTLEG_OUTBOX_RELAY_LEASE_SECONDS:60}
retry-seconds: ${FIRSTLEG_OUTBOX_RELAY_RETRY_SECONDS:30}
security:
internal-service-token: ${INTERNAL_SERVICE_TOKEN:}
gateway-internal-service-token: ${GATEWAY_INTERNAL_SERVICE_TOKEN:${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes}}
internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes}
jwt-mode: jwks
session-mode: gateway
issuer: ${JWT_ISSUER:auth-service}
@@ -1293,6 +1293,9 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_item (
package_count INT NOT NULL DEFAULT 0 COMMENT '箱数',
volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '体积(立方米)',
source_stage VARCHAR(32) NOT NULL COMMENT '数据来源阶段',
carton_marks VARCHAR(4000) NULL COMMENT '换标箱唛映射快照',
source_business_type VARCHAR(32) NULL COMMENT '来源业务类型',
source_business_no VARCHAR(128) NULL COMMENT '来源业务单号',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_dispatch_item_source (dispatch_order_id,plan_pallet_id,warehouse_pallet_id),
@@ -1301,6 +1304,10 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_item (
KEY idx_ll_dispatch_item_pallet (customer_id,plan_pallet_id,warehouse_pallet_id)
) ENGINE=InnoDB COMMENT='派车货物冻结明细';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='carton_marks'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN carton_marks VARCHAR(4000) NULL COMMENT ''换标箱唛映射快照'' AFTER source_stage')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='source_business_type'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN source_business_type VARCHAR(32) NULL COMMENT ''来源业务类型'' AFTER carton_marks')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='source_business_no'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN source_business_no VARCHAR(128) NULL COMMENT ''来源业务单号'' AFTER source_business_type')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS ll_dispatch_pallet_lock (
id BIGINT NOT NULL COMMENT '主键ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
@@ -1429,6 +1436,23 @@ CREATE TABLE IF NOT EXISTS ll_outbound_pda_task (
PRIMARY KEY(id), UNIQUE KEY uk_ll_outbound_pda_task_order(customer_id,outbound_order_id), UNIQUE KEY uk_ll_outbound_pda_task_no(task_no),
UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key)
) ENGINE=InnoDB COMMENT='一件代发出库PDA拣货任务';
SET @ddl = (SELECT IF(EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='ll_outbound_pda_task' AND column_name='completion_idempotency_key'
), 'SELECT 1',
'ALTER TABLE ll_outbound_pda_task ADD COLUMN completion_idempotency_key VARCHAR(255) NULL COMMENT ''完成命令幂等键'' AFTER status'));
PREPARE outbound_pda_completion_column_stmt FROM @ddl; EXECUTE outbound_pda_completion_column_stmt; DEALLOCATE PREPARE outbound_pda_completion_column_stmt;
SET @index_ready = (SELECT COUNT(*)=2 AND MIN(non_unique)=0
AND SUM(seq_in_index=1 AND column_name='customer_id')=1
AND SUM(seq_in_index=2 AND column_name='completion_idempotency_key')=1
FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='ll_outbound_pda_task' AND index_name='uk_ll_outbound_pda_complete_key');
SET @index_exists = (SELECT COUNT(*)>0 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='ll_outbound_pda_task' AND index_name='uk_ll_outbound_pda_complete_key');
SET @ddl = IF(@index_ready, 'SELECT 1', IF(@index_exists,
'ALTER TABLE ll_outbound_pda_task DROP INDEX uk_ll_outbound_pda_complete_key, ADD UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key)',
'ALTER TABLE ll_outbound_pda_task ADD UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key)'));
PREPARE outbound_pda_completion_index_stmt FROM @ddl; EXECUTE outbound_pda_completion_index_stmt; DEALLOCATE PREPARE outbound_pda_completion_index_stmt;
CREATE TABLE IF NOT EXISTS ll_outbound_pda_pick (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', task_id BIGINT NOT NULL COMMENT 'PDA任务ID', outbound_pallet_item_id BIGINT NOT NULL COMMENT '出库托盘明细ID',
@@ -1458,9 +1482,9 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_order (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
transfer_no VARCHAR(64) NOT NULL COMMENT '转仓单号', inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照',
lastleg_order_id BIGINT NOT NULL COMMENT '尾程订单ID', lastleg_order_no VARCHAR(32) NOT NULL COMMENT '尾程订单号',
operating_warehouse_id VARCHAR(128) NOT NULL COMMENT '作业仓库ID', fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA仓库编码',
delivery_address VARCHAR(1000) NOT NULL COMMENT '派送地址', package_count INT NOT NULL COMMENT '箱数', delivery_method VARCHAR(64) NOT NULL COMMENT '派送方式',
estimated_arrival_at DATETIME(3) NOT NULL COMMENT '预计到达时间', fba_shipment_no VARCHAR(128) NOT NULL COMMENT 'FBA货件编号',
operating_warehouse_id VARCHAR(128) NOT NULL COMMENT '入库中转仓ID', fba_warehouse_code VARCHAR(128) NULL COMMENT '可选FBA目的仓编码',
delivery_address VARCHAR(1000) NOT NULL COMMENT '提货地址或中转仓收货地址', package_count INT NOT NULL COMMENT '箱数', delivery_method VARCHAR(64) NOT NULL COMMENT '入仓方式',
estimated_arrival_at DATETIME(3) NOT NULL COMMENT '预计入仓时间', fba_shipment_no VARCHAR(128) NULL COMMENT '可选FBA货件编号',
tracking_no VARCHAR(255) NULL COMMENT '物流跟踪号', fba_box_nos VARCHAR(2000) NULL COMMENT 'FBA箱号集合', remark VARCHAR(1000) NULL COMMENT '备注',
need_relabel TINYINT NOT NULL DEFAULT 0 COMMENT '是否需要换标', relabel_application_id BIGINT NULL COMMENT '换标申请单ID', relabel_no VARCHAR(64) NULL COMMENT '换标申请单号',
status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键',
@@ -1478,6 +1502,9 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_order (
KEY idx_ll_wh_transfer_list (customer_id,status,created_at), KEY idx_ll_wh_transfer_inbound (customer_id,inbound_code)
) ENGINE=InnoDB COMMENT='客户转仓单';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='fba_warehouse_code' AND is_nullable='NO'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''可选FBA目的仓编码'', MODIFY COLUMN fba_shipment_no VARCHAR(128) NULL COMMENT ''可选FBA货件编号'', MODIFY COLUMN delivery_address VARCHAR(1000) NOT NULL COMMENT ''提货地址或中转仓收货地址'', MODIFY COLUMN delivery_method VARCHAR(64) NOT NULL COMMENT ''入仓方式'', MODIFY COLUMN estimated_arrival_at DATETIME(3) NOT NULL COMMENT ''预计入仓时间''', 'SELECT 1'));
PREPARE transfer_optional_fba_stmt FROM @ddl; EXECUTE transfer_optional_fba_stmt; DEALLOCATE PREPARE transfer_optional_fba_stmt;
-- 兼容已存在的旧版转仓单表:CREATE TABLE IF NOT EXISTS 不会补充新增字段。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='allocation_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN allocation_idempotency_key VARCHAR(255) NULL COMMENT ''库存分配幂等键'' AFTER status'));
PREPARE transfer_allocation_key_stmt FROM @ddl; EXECUTE transfer_allocation_key_stmt; DEALLOCATE PREPARE transfer_allocation_key_stmt;
@@ -1507,6 +1534,18 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_item (
KEY idx_ll_wh_transfer_item_list (customer_id,transfer_order_id), KEY idx_ll_wh_transfer_item_sku (customer_id,sku_code)
) ENGINE=InnoDB COMMENT='转仓货物冻结明细';
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_carton (
id BIGINT NOT NULL COMMENT '预报箱主键', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID',
transfer_item_id BIGINT NOT NULL COMMENT '转运申报明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
carton_mark VARCHAR(255) NOT NULL COMMENT '系统生成的一箱一码箱唛', carton_index INT NOT NULL COMMENT '全单箱序号',
total_cartons INT NOT NULL COMMENT '首次生成时全单总箱数', status VARCHAR(32) NOT NULL DEFAULT 'EXPECTED' COMMENT '箱状态:EXPECTED待实收',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id), UNIQUE KEY uk_ll_wh_transfer_carton_mark (customer_id,carton_mark),
UNIQUE KEY uk_ll_wh_transfer_carton_index (transfer_order_id,carton_index),
KEY idx_ll_wh_transfer_carton_list (customer_id,transfer_order_id,status)
) ENGINE=InnoDB COMMENT='仓储转运系统箱唛预报';
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_inventory_allocation (
id BIGINT NOT NULL COMMENT '雪花主键',
transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID',
@@ -1564,6 +1603,15 @@ CREATE TABLE IF NOT EXISTS ll_relabel_application (
label_deadline DATETIME(3) NOT NULL COMMENT '换标截止时间',
contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名',
contact_phone VARCHAR(64) NOT NULL COMMENT '联系人电话',
delivery_method VARCHAR(32) NULL COMMENT '派送方式:TRUCK卡车、PICKUP自提、EXPRESS快递;仅涉及箱唛换标时必填',
print_shipping_label TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否需要打印面单',
shipping_label_file_id VARCHAR(128) NULL COMMENT '客户上传面单文件ID',
shipping_label_file_name VARCHAR(255) NULL COMMENT '客户上传面单文件名',
shipping_label_file_url VARCHAR(1000) NULL COMMENT '客户上传面单下载地址',
fulfillment_status VARCHAR(32) NOT NULL DEFAULT 'WAIT_RELABEL' COMMENT '换标后履约状态',
outbound_order_no VARCHAR(64) NULL COMMENT '自提或快递生成的换标出库单号',
customer_confirmation_key VARCHAR(255) NULL COMMENT '客户确认完成幂等键',
customer_confirmed_at DATETIME(3) NULL COMMENT '客户确认换标完成时间',
remark VARCHAR(1000) NULL COMMENT '备注',
total_file_id VARCHAR(128) NULL COMMENT '总换标PDF文件ID',
total_file_name VARCHAR(255) NULL COMMENT '总换标PDF文件名',
@@ -1595,6 +1643,15 @@ CREATE TABLE IF NOT EXISTS ll_relabel_application (
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='total_file_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN total_file_id VARCHAR(128) NULL COMMENT ''总换标PDF文件ID'' AFTER remark')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='total_file_name'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN total_file_name VARCHAR(255) NULL COMMENT ''总换标PDF文件名'' AFTER total_file_id')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='total_file_url'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN total_file_url VARCHAR(1000) NULL COMMENT ''总换标PDF预览地址'' AFTER total_file_name')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='delivery_method'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN delivery_method VARCHAR(32) NULL COMMENT ''派送方式:TRUCK卡车、PICKUP自提、EXPRESS快递;仅涉及箱唛换标时必填'' AFTER contact_phone')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='print_shipping_label'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN print_shipping_label TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否需要打印面单'' AFTER delivery_method')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_id VARCHAR(128) NULL COMMENT ''客户上传面单文件ID'' AFTER print_shipping_label')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_name'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_name VARCHAR(255) NULL COMMENT ''客户上传面单文件名'' AFTER shipping_label_file_id')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_url'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_url VARCHAR(1000) NULL COMMENT ''客户上传面单下载地址'' AFTER shipping_label_file_name')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='fulfillment_status'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN fulfillment_status VARCHAR(32) NOT NULL DEFAULT ''WAIT_RELABEL'' COMMENT ''换标后履约状态'' AFTER shipping_label_file_url')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='outbound_order_no'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN outbound_order_no VARCHAR(64) NULL COMMENT ''自提或快递生成的换标出库单号'' AFTER fulfillment_status')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='customer_confirmation_key'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN customer_confirmation_key VARCHAR(255) NULL COMMENT ''客户确认完成幂等键'' AFTER fulfillment_status')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='customer_confirmed_at'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认换标完成时间'' AFTER customer_confirmation_key')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS ll_relabel_item (
id BIGINT NOT NULL COMMENT '主键ID',
@@ -1607,7 +1664,7 @@ CREATE TABLE IF NOT EXISTS ll_relabel_item (
product_name_zh VARCHAR(255) NOT NULL COMMENT '产品中文名称',
total_package_count INT NOT NULL COMMENT '总箱数',
notified_package_count INT NOT NULL COMMENT '已通知箱数',
shipment_no VARCHAR(128) NOT NULL COMMENT '货件编号',
shipment_no VARCHAR(128) NULL COMMENT '货件编号(无FBA去向时为空)',
tracking_code VARCHAR(255) NULL COMMENT '物流跟踪编码',
new_sku_code VARCHAR(128) NOT NULL COMMENT '换标后SKU编码',
label_requirement VARCHAR(1000) NULL COMMENT '换标要求',
@@ -1618,12 +1675,12 @@ CREATE TABLE IF NOT EXISTS ll_relabel_item (
carton_mark VARCHAR(255) NULL COMMENT '客户自有箱唛',
fba_carton_mark VARCHAR(255) NULL COMMENT '关联FBA箱唛',
destination_warehouse_id VARCHAR(128) NOT NULL COMMENT '该箱目的海外仓ID',
destination_fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA目的仓编码(与海外操作仓分离)',
destination_fba_warehouse_code VARCHAR(128) NULL COMMENT '可选FBA目的仓编码(与海外操作仓分离)',
operation_type VARCHAR(32) NOT NULL DEFAULT 'CARTON_LABEL' COMMENT '换标模式:箱唛、SKU或组合换标',
sku_relabel_items_json JSON NULL COMMENT '同箱SKU换标子明细快照(数量按件)',
source_inventory_balance_id BIGINT NOT NULL COMMENT '原库存余额ID',
source_inventory_position_id BIGINT NOT NULL COMMENT '原库存库位ID',
source_inbound_order_id BIGINT NOT NULL COMMENT '原入仓单ID',
source_inventory_balance_id BIGINT NULL COMMENT '原库存余额ID(预入仓换标任务可为空)',
source_inventory_position_id BIGINT NULL COMMENT '原库存库位ID(预入仓换标任务可为空)',
source_inbound_order_id BIGINT NULL COMMENT '原入仓单ID(预入仓换标任务可为空)',
source_inbound_order_no VARCHAR(128) NULL COMMENT '原入仓单号快照',
source_inbound_code VARCHAR(128) NULL COMMENT '原进仓编码快照',
source_warehouse_id VARCHAR(128) NOT NULL COMMENT '原海外中转仓ID',
@@ -1636,6 +1693,9 @@ CREATE TABLE IF NOT EXISTS ll_relabel_item (
KEY idx_ll_relabel_item_source_position (customer_id,source_inventory_position_id)
) ENGINE=InnoDB COMMENT='换标货物冻结明细';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name IN ('shipment_no','destination_fba_warehouse_code','source_inventory_balance_id','source_inventory_position_id','source_inbound_order_id') AND is_nullable='NO'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN shipment_no VARCHAR(128) NULL COMMENT ''货件编号(无FBA去向时为空)'', MODIFY COLUMN destination_fba_warehouse_code VARCHAR(128) NULL COMMENT ''可选FBA目的仓编码(与海外操作仓分离)'', MODIFY COLUMN source_inventory_balance_id BIGINT NULL COMMENT ''原库存余额ID(预入仓换标任务可为空)'', MODIFY COLUMN source_inventory_position_id BIGINT NULL COMMENT ''原库存库位ID(预入仓换标任务可为空)'', MODIFY COLUMN source_inbound_order_id BIGINT NULL COMMENT ''原入仓单ID(预入仓换标任务可为空)''', 'SELECT 1'));
PREPARE relabel_pre_inbound_stmt FROM @ddl; EXECUTE relabel_pre_inbound_stmt; DEALLOCATE PREPARE relabel_pre_inbound_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='remark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN remark VARCHAR(1000) NULL COMMENT ''货物明细备注'' AFTER label_requirement')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='carton_mark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN carton_mark VARCHAR(255) NULL COMMENT ''客户自有箱唛'' AFTER relabel_file_url')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='fba_carton_mark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN fba_carton_mark VARCHAR(255) NULL COMMENT ''关联FBA箱唛'' AFTER carton_mark')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -2178,10 +2238,12 @@ CREATE TABLE IF NOT EXISTS ll_destuff_pallet_cost_allocation (
-- 客户退货预报、PDA 签收与仓储费起算批次
CREATE TABLE IF NOT EXISTS ll_return_order (
-- need_relabel is added below for existing deployments
id BIGINT NOT NULL COMMENT '退货订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', return_no VARCHAR(128) NOT NULL COMMENT '退货订单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '操作海外仓ID', customer_reference_no VARCHAR(128) NULL COMMENT '客户参考号', status VARCHAR(32) NOT NULL COMMENT 'PENDING_RECEIPT、PARTIALLY_RECEIVED或COMPLETED', expected_sku_types INT NOT NULL COMMENT '预报SKU种类数', expected_quantity BIGINT NOT NULL COMMENT '预报总数量', received_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '累计实收总数量', remark VARCHAR(1000) NULL COMMENT '客户备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键', request_hash CHAR(64) NOT NULL COMMENT '创建请求SHA-256', created_by VARCHAR(128) NULL COMMENT '创建操作人ID', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间',
id BIGINT NOT NULL COMMENT '退货订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', return_no VARCHAR(128) NOT NULL COMMENT '退货订单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '操作海外仓ID', warehouse_contact_name VARCHAR(128) NULL COMMENT '中转仓联系人快照', warehouse_contact_phone VARCHAR(64) NULL COMMENT '中转仓联系电话快照', customer_reference_no VARCHAR(128) NULL COMMENT '客户参考号', status VARCHAR(32) NOT NULL COMMENT 'PENDING_RECEIPT、PARTIALLY_RECEIVED或COMPLETED', expected_sku_types INT NOT NULL COMMENT '预报SKU种类数', expected_quantity BIGINT NOT NULL COMMENT '预报总数量', received_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '累计实收总数量', remark VARCHAR(1000) NULL COMMENT '客户备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键', request_hash CHAR(64) NOT NULL COMMENT '创建请求SHA-256', created_by VARCHAR(128) NULL COMMENT '创建操作人ID', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_order_customer_no(customer_id,return_no), UNIQUE KEY uk_ll_return_order_customer_key(customer_id,idempotency_key), KEY idx_ll_return_order_warehouse_status(overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_order_quantity CHECK(expected_sku_types>0 AND expected_quantity>0 AND received_quantity>=0 AND received_quantity<=expected_quantity)
) ENGINE=InnoDB COMMENT='客户退货订单及SKU实收汇总';
SET @ll_return_order_need_relabel = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='need_relabel'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN need_relabel TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否需要换标'' AFTER customer_reference_no')); PREPARE stmt FROM @ll_return_order_need_relabel; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='warehouse_contact_name'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN warehouse_contact_name VARCHAR(128) NULL COMMENT ''中转仓联系人快照'' AFTER overseas_warehouse_id')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='warehouse_contact_phone'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN warehouse_contact_phone VARCHAR(64) NULL COMMENT ''中转仓联系电话快照'' AFTER warehouse_contact_name')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='relabel_application_id'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN relabel_application_id BIGINT NULL COMMENT ''绑定换标申请单ID'' AFTER need_relabel')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='relabel_no'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN relabel_no VARCHAR(64) NULL COMMENT ''绑定换标单号'' AFTER relabel_application_id')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='relabel_status'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN relabel_status VARCHAR(32) NULL COMMENT ''换标状态'' AFTER relabel_no')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -1166,7 +1166,7 @@ paths:
/api/platform/v1/last-leg/relabel-applications/{relabelNo}/status:
put:
summary: 变更海外换标单状态
description: 权限 platform:overseas-warehouse:relabel-order:status;状态按 PENDING_PROCESSING→PROCESSING→COMPLETED/REJECTED/CANCELLED 流转。
description: 权限 platform:overseas-warehouse:relabel-order:status;PDA复核通过后进入CUSTOMER_CONFIRM_PENDING,客户确认后进入READY_FOR_OUTBOUND,再由排车或直接出库履约。
security: [{ bearerAuth: [] }]
parameters:
- { name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } }
@@ -1204,6 +1204,94 @@ paths:
responses:
'200': { description: PDF字节, content: { application/pdf: { schema: { type: string, format: binary } } } }
'404': { description: 资料未绑定或归属不匹配 }
/api/merchant/v1/last-leg/relabel-applications/inventory-candidates:
get:
summary: 查询客户可换标库存候选
description: 权限 merchant:warehouse:relabel;仅返回当前客户在指定海外中转仓仍有可用箱数的历史入仓单和库位摘要。候选按客户 Token 隔离,不接收 customerId;inboundCode 可选精确筛选。
security: [{ bearerAuth: [] }]
parameters:
- { name: operationWarehouseId, in: query, required: true, schema: { type: string, maxLength: 128 } }
- { name: inboundCode, in: query, required: false, schema: { type: string, maxLength: 128 } }
responses:
'200': { description: 可换标库存候选, content: { application/json: { schema: { type: object, properties: { code: { type: string, example: '200' }, data: { type: array, items: { $ref: '#/components/schemas/RelabelInventoryCandidate' } } } } } } }
'403': { description: 缺少客户换标权限 }
/api/merchant/v1/last-leg/relabel-applications:
get:
summary: 分页查询客户换标申请
description: 权限 merchant:warehouse:relabel;客户范围只取认证 Token,不接收 customerId。
security: [{ bearerAuth: [] }]
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
- { name: relabelNo, in: query, schema: { type: string, maxLength: 64 } }
- { name: customerOrderNo, in: query, schema: { type: string, maxLength: 128 } }
- { name: skuCode, in: query, schema: { type: string, maxLength: 128 } }
- { name: status, in: query, schema: { $ref: '#/components/schemas/RelabelStatus' } }
- { name: overseasWarehouseId, in: query, schema: { type: string, maxLength: 128 } }
- { name: createdFrom, in: query, schema: { type: string, format: date-time } }
- { name: createdTo, in: query, schema: { type: string, format: date-time } }
responses:
'200': { description: 客户换标申请分页, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelMerchantPageResponse' } } } }
post:
summary: 创建客户换标申请
description: 权限 merchant:warehouse:relabel;申请创建即冻结对应库存。SKU_LABEL 不要求派送方式;CARTON_LABEL/CARTON_AND_SKU 必须指定 TRUCK、PICKUP 或 EXPRESS。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelCreate' } } } }
responses:
'200': { description: 换标申请详情, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelMerchantDetailResponse' } } } }
'409': { description: 幂等键、库存或状态冲突 }
/api/merchant/v1/last-leg/relabel-applications/{relabelNo}:
get:
summary: 查询客户换标申请详情
description: 权限 merchant:warehouse:relabel;客户范围只取认证 Token。
security: [{ bearerAuth: [] }]
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } }]
responses:
'200': { description: 换标申请详情, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelMerchantDetailResponse' } } } }
'404': { description: 申请不存在或不属于当前客户 }
/api/merchant/v1/last-leg/relabel-applications/{relabelNo}/shipping-label:
post:
summary: 上传换标快递面单
description: 权限 merchant:warehouse:relabel;仅 EXPRESS 且申请尚未客户确认时允许上传,文件由服务端绑定到申请单。
security: [{ bearerAuth: [] }]
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } }]
requestBody: { required: true, content: { multipart/form-data: { schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } } } } }
responses:
'200': { description: 更新后的换标详情, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelMerchantDetailResponse' } } } }
'400': { description: 文件类型或大小不合法 }
'409': { description: 当前状态不允许上传 }
/api/merchant/v1/last-leg/relabel-applications/{relabelNo}/shipping-label/{fileId}/download:
get:
summary: 下载换标快递面单
description: 权限 merchant:warehouse:relabel;仅允许下载当前申请已绑定的面单文件。
security: [{ bearerAuth: [] }]
parameters:
- { name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } }
- { name: fileId, in: path, required: true, schema: { type: integer, format: int64, minimum: 1 } }
responses:
'200': { description: 面单文件, content: { application/pdf: { schema: { type: string, format: binary } }, image/jpeg: { schema: { type: string, format: binary } }, image/png: { schema: { type: string, format: binary } } } }
'404': { description: 文件未绑定或不属于当前客户 }
/api/merchant/v1/last-leg/relabel-applications/{relabelNo}/completion-photos/{fileId}/download:
get:
summary: 下载换标完成照片
description: 权限 merchant:warehouse:relabel;仅允许下载该申请 PDA 完工证据中的照片。
security: [{ bearerAuth: [] }]
parameters:
- { name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } }
- { name: fileId, in: path, required: true, schema: { type: integer, format: int64, minimum: 1 } }
responses:
'200': { description: 完工照片, content: { image/jpeg: { schema: { type: string, format: binary } }, image/png: { schema: { type: string, format: binary } } } }
'404': { description: 照片不存在或不属于当前客户 }
/api/merchant/v1/last-leg/relabel-applications/{relabelNo}/confirm-completion:
post:
summary: 客户确认换标完成
description: 权限 merchant:warehouse:relabel;仅 CUSTOMER_CONFIRM_PENDING 可确认。TRUCK 进入 WAIT_PRE_DISPATCH,PICKUP/EXPRESS 幂等生成 RELABEL_OUTBOUND 出库单;EXPRESS 且要求打印面单时必须先上传面单。
security: [{ bearerAuth: [] }]
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelCustomerConfirm' } } } }
responses:
'200': { description: 更新后的换标详情, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelMerchantDetailResponse' } } } }
'409': { description: 状态、面单、库存或幂等冲突 }
/api/merchant/v1/last-leg/return-orders:
get:
summary: 客户分页查询退货签收订单
@@ -1315,6 +1403,8 @@ components:
required: [overseasWarehouseId, needRelabel, idempotencyKey, items]
properties:
overseasWarehouseId: { type: string, maxLength: 128 }
warehouseContactName: { type: string, maxLength: 128, description: 中转仓联系人快照;仓库未维护时可由客户填写 }
warehouseContactPhone: { type: string, maxLength: 64, description: 中转仓联系电话快照;仓库未维护时可由客户填写 }
customerReferenceNo: { type: string, maxLength: 128 }
needRelabel: { type: boolean, default: false, description: 是否需要换标 }
remark: { type: string, maxLength: 1000 }
@@ -2127,7 +2217,101 @@ components:
packageCount: { type: integer }
volumeCbm: { type: number }
sourceStage: { type: string, enum: [PLAN, RECEIVED] }
RelabelStatus: { type: string, enum: [PENDING_PROCESSING, PROCESSING, COMPLETED, REJECTED, CANCELLED] }
RelabelStatus: { type: string, enum: [PENDING_PROCESSING, PROCESSING, CUSTOMER_CONFIRM_PENDING, READY_FOR_OUTBOUND, COMPLETED, REJECTED, CANCELLED] }
RelabelMerchantPageResponse:
allOf:
- { $ref: '#/components/schemas/MerchantApiResponse' }
- type: object
properties: { data: { $ref: '#/components/schemas/RelabelMerchantPage' } }
RelabelMerchantPage:
type: object
properties:
page: { type: integer }
size: { type: integer }
total: { type: integer, format: int64 }
records: { type: array, items: { $ref: '#/components/schemas/RelabelSummary' } }
RelabelMerchantDetailResponse:
allOf:
- { $ref: '#/components/schemas/MerchantApiResponse' }
- type: object
properties: { data: { $ref: '#/components/schemas/RelabelDetail' } }
RelabelSummary:
type: object
properties:
id: { type: string }
relabelNo: { type: string }
lastlegOrderNo: { type: string, nullable: true }
customerOrderNo: { type: string }
overseasWarehouseId: { type: string }
labelDeadline: { type: string, format: date-time }
status: { $ref: '#/components/schemas/RelabelStatus' }
itemCount: { type: integer }
totalPackageCount: { type: integer }
notifiedPackageCount: { type: integer }
deliveryMethod: { type: string, nullable: true, enum: [TRUCK, PICKUP, EXPRESS] }
printShippingLabel: { type: boolean }
shippingLabelFileId: { type: string, nullable: true }
fulfillmentStatus: { type: string, nullable: true, enum: [WAIT_RELABEL, WAIT_PRE_DISPATCH, READY_FOR_OUTBOUND, OUTBOUND_CREATED, COMPLETED] }
outboundOrderNo: { type: string, nullable: true }
customerConfirmedAt: { type: string, format: date-time, nullable: true }
createdAt: { type: string, format: date-time }
RelabelDetail:
type: object
properties:
application: { $ref: '#/components/schemas/RelabelSummary' }
remark: { type: string, nullable: true }
items: { type: array, items: { $ref: '#/components/schemas/RelabelItem' } }
history: { type: array, items: { $ref: '#/components/schemas/RelabelHistory' } }
completionEvidence: { $ref: '#/components/schemas/RelabelCompletionEvidence' }
RelabelHistory:
type: object
properties:
fromStatus: { type: string, nullable: true }
toStatus: { type: string }
operatorId: { type: string }
remark: { type: string, nullable: true }
createdAt: { type: string, format: date-time }
RelabelCompletionEvidence:
type: object
nullable: true
properties:
taskId: { type: string, nullable: true }
photoFileIds: { type: array, items: { type: string } }
completionRemark: { type: string, nullable: true }
reviewerName: { type: string, nullable: true }
reviewRemark: { type: string, nullable: true }
completedAt: { type: string, format: date-time, nullable: true }
RelabelInventoryCandidate:
type: object
properties:
inventoryBalanceId: { type: string }
inboundOrderId: { type: string }
inboundOrderNo: { type: string }
inboundCode: { type: string }
overseasWarehouseId: { type: string }
overseasWarehouseCode: { type: string }
overseasWarehouseName: { type: string }
destinationFbaWarehouseCode: { type: string, nullable: true }
destinationFbaWarehouseName: { type: string, nullable: true }
skuCode: { type: string, nullable: true }
productNameZh: { type: string }
availablePackages: { type: integer, format: int64 }
version: { type: integer, format: int64 }
positions: { type: array, items: { $ref: '#/components/schemas/RelabelInventoryPosition' } }
RelabelInventoryPosition:
type: object
properties:
positionId: { type: string }
palletNo: { type: string, nullable: true }
batchNo: { type: string, nullable: true }
warehouseLocationCode: { type: string, nullable: true }
availablePackages: { type: integer, format: int64 }
RelabelCustomerConfirm:
type: object
required: [idempotencyKey]
properties:
idempotencyKey: { type: string, maxLength: 255 }
remark: { type: string, nullable: true, maxLength: 1000 }
RelabelCreate:
type: object
required: [customerOrderNo, labelDeadline, contactName, contactPhone, chargeAmount, idempotencyKey, items]
@@ -2140,6 +2324,8 @@ components:
chargeAmount: { type: number, minimum: 0.01, description: 本次贴标费用(CNY) }
remark: { type: string, nullable: true, maxLength: 1000 }
idempotencyKey: { type: string, maxLength: 255 }
deliveryMethod: { type: string, nullable: true, enum: [TRUCK, EXPRESS, TRUCK_PICKUP, EXPRESS_PICKUP, PICKUP], description: 只换箱唛或SKU与箱唛同时换时必填;只换SKU时不传 }
printShippingLabel: { type: boolean, default: false, description: 仅快递派送可选,客户确认前必须已上传面单 }
items: { type: array, minItems: 1, maxItems: 500, items: { $ref: '#/components/schemas/RelabelItem' } }
RelabelItem:
type: object
@@ -164,6 +164,12 @@ paths:
summary: 将来源托盘库存拆到一个或多个目标托盘
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PalletDisassemble' } } } }
responses: { '200': { description: 拆托结果 }, '409': { description: 库存或资源版本冲突 } }
/api/pda/v1/last-leg/pallet-operations/relabel-disassemble:
post:
summary: 按换标箱唛拆托并绑定目标托盘
description: PDA先扫描原托盘,再扫描换标单指定箱唛;服务端按换标单库存快照解析仓位,在同一事务中扣减原托盘并绑定目标托盘。目标托盘须先由仓储服务创建后扫码确认。
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelPalletDisassemble' } } } }
responses: { '200': { description: 箱唛级拆托绑定结果 }, '409': { description: 箱唛、库存或托盘版本冲突 } }
/api/pda/v1/last-leg/pallet-operations/swap:
post:
summary: 将整托库存更换到空目标托盘
@@ -675,6 +681,16 @@ components:
source: { $ref: '#/components/schemas/PalletExpectation' }
targets: { type: array, minItems: 1, maxItems: 20, items: { $ref: '#/components/schemas/PalletTargetMove' } }
reason: { type: string, maxLength: 500 }
RelabelPalletDisassemble:
type: object
required: [idempotencyKey, relabelNo, source, target, cartonMarks]
properties:
idempotencyKey: { type: string, maxLength: 128 }
relabelNo: { type: string, maxLength: 64 }
source: { $ref: '#/components/schemas/PalletExpectation' }
target: { $ref: '#/components/schemas/PalletExpectation' }
cartonMarks: { type: array, minItems: 1, maxItems: 500, uniqueItems: true, items: { type: string, maxLength: 255 } }
reason: { type: string, maxLength: 500 }
PalletSwap:
type: object
required: [idempotencyKey, source, target]
@@ -24,7 +24,7 @@ class PdaPalletOperationControllerContractTest {
.map(Method::getDeclaredAnnotations).flatMap(Arrays::stream)
.filter(PostMapping.class::isInstance).map(PostMapping.class::cast)
.flatMap(annotation -> Arrays.stream(annotation.value())).collect(Collectors.toSet());
assertEquals(Set.of("/pallets/resolve", "/assemble", "/disassemble", "/swap", "/unbind"), paths);
assertEquals(Set.of("/pallets/resolve", "/assemble", "/disassemble", "/relabel-disassemble", "/swap", "/unbind"), paths);
}
@Test void palletContentAnnotationUsesNativeGreaterThanOperator() throws Exception {
@@ -13,6 +13,11 @@ import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper;
import com.baiyue.lastleg.palletops.model.PalletInventoryRow;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.relabel.service.RelabelPalletMutationService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -43,6 +48,9 @@ class PdaPalletOperationServiceTest {
@Mock private InventoryLedgerMapper ledgers;
@Mock private PalletPhysicalGateway physical;
@Mock private PdaWarehouseService history;
@Mock private RelabelApplicationMapper relabelApplications;
@Mock private RelabelItemMapper relabelItems;
@Mock private RelabelPalletMutationService relabelPalletMutations;
private PdaPalletOperationService service;
@BeforeEach void setUp() {
@@ -53,6 +61,9 @@ class PdaPalletOperationServiceTest {
ReflectionTestUtils.setField(service, "ledgers", ledgers);
ReflectionTestUtils.setField(service, "physical", physical);
ReflectionTestUtils.setField(service, "history", history);
ReflectionTestUtils.setField(service, "relabelApplications", relabelApplications);
ReflectionTestUtils.setField(service, "relabelItems", relabelItems);
ReflectionTestUtils.setField(service, "relabelPalletMutations", relabelPalletMutations);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
service.configureTransactions(new NoOpTransactionManager());
}
@@ -157,6 +168,83 @@ class PdaPalletOperationServiceTest {
verifyNoInteractions(physical);
}
@Test void relabelDisassembleMovesOnlySelectedHeldCarton() {
var sourcePallet = new PalletPhysicalGateway.PalletReference(20L, "SOURCE", 88L, 1020L,
"OCCUPIED", null, 1);
var targetPallet = new PalletPhysicalGateway.PalletReference(10L, "TARGET", 88L, 1010L,
"IDLE", null, 2);
when(physical.resolve("SOURCE")).thenReturn(sourcePallet);
when(physical.resolve("TARGET")).thenReturn(targetPallet);
when(physical.resolveLocation(eq(88L), anyLong())).thenAnswer(invocation ->
new PalletPhysicalGateway.LocationReference(invocation.getArgument(1), "LOC-" + invocation.getArgument(1),
"STORAGE", "ACTIVE"));
when(physical.apply(eq(88L), any(), anyString(), anyLong(), anyString(), any()))
.thenAnswer(invocation -> transition(invocation.getArgument(1), invocation.getArgument(2)));
RelabelApplication application = new RelabelApplication(); application.setId(600L);
application.setCustomerId("100"); application.setRelabelNo("RL-1"); application.setStatus("READY_FOR_OUTBOUND");
application.setOverseasWarehouseId("88"); application.setOutboundOrderNo("OUT-1");
when(relabelApplications.findAnyByRelabelNo("RL-1")).thenReturn(application);
when(relabelApplications.lockAnyByRelabelNo("RL-1")).thenReturn(application);
RelabelItem selected = relabelItem(701L, "CARTON-A", 100L);
when(relabelItems.selectList(any())).thenReturn(List.of(selected));
InventoryPosition source = position(100L, 20L, "SOURCE", 2L, 3L);
AtomicReference<InventoryPosition> target = new AtomicReference<>();
when(positions.selectById(anyLong())).thenAnswer(invocation -> {
long id = invocation.getArgument(0);
return id == 100L ? source : target.get();
});
doAnswer(invocation -> { target.set(invocation.getArgument(0)); return 1; }).when(positions)
.insert(any(InventoryPosition.class));
PalletInventoryRow held = row(100L, 20L, 3L, 0L); held.setPalletNo("SOURCE"); held.setHoldPackages(2L);
when(mapper.content("88", 20L)).thenReturn(List.of(held));
when(mapper.content("88", 10L)).thenReturn(List.of());
when(mapper.lockPosition("88", 100L)).thenReturn(held);
when(mapper.palletPackages("88", 20L)).thenReturn(2L, 1L);
when(mapper.palletPackages("88", 10L)).thenReturn(0L, 1L);
InventoryBalance balance = new InventoryBalance(); balance.setId(200L); balance.setCustomerId("100");
balance.setOverseasWarehouseId("88"); when(balances.selectById(200L)).thenReturn(balance);
AtomicReference<PdaPalletOperation> operation = new AtomicReference<>();
doAnswer(invocation -> { operation.set(invocation.getArgument(0)); return 1; }).when(mapper)
.insert(any(PdaPalletOperation.class));
when(mapper.selectById(anyLong())).thenAnswer(invocation -> operation.get());
var result = service.relabelDisassemble(identity(), new PalletOperationRequests.RelabelDisassemble(
"relabel-move-1", "RL-1", new PalletOperationRequests.PalletExpectation("SOURCE", 1),
new PalletOperationRequests.PalletExpectation("TARGET", 2), List.of("CARTON-A"), "split"));
assertEquals("COMPLETED", result.status());
assertEquals(1L, source.getHoldPackages());
assertEquals(0L, source.getAvailablePackages());
assertNotNull(target.get()); assertEquals(1L, target.get().getHoldPackages());
verify(relabelPalletMutations).moveSelectedItems(eq(application), anyMap(), argThat(targets ->
targets.size() == 1 && targets.get(100L) == target.get()));
verify(balances, never()).updateById(any(InventoryBalance.class));
assertEquals("RELABEL_PALLET_DISASSEMBLE", operation.get().getOperationType());
}
@Test void relabelDisassembleReplayDoesNotRevalidateChangedInventoryOrPallets() throws Exception {
var request = new PalletOperationRequests.RelabelDisassemble("relabel-replay", "RL-1",
new PalletOperationRequests.PalletExpectation("SOURCE", 1),
new PalletOperationRequests.PalletExpectation("TARGET", 2), List.of("CARTON-A"), "split");
PdaPalletOperation existing = new PdaPalletOperation(); existing.setId(900L);
existing.setOperationType("RELABEL_PALLET_DISASSEMBLE"); existing.setStatus("COMPLETED");
existing.setRequestHash(ReflectionTestUtils.invokeMethod(service, "hash", request));
var stored = new com.baiyue.lastleg.palletops.dto.PalletOperationResponses.Operation(
900L, "RELABEL_PALLET_DISASSEMBLE", "relabel-replay", "COMPLETED", false,
LocalDateTime.now(), List.of());
existing.setResultJson(new ObjectMapper().findAndRegisterModules().writeValueAsString(stored));
when(mapper.findOperation("7", "relabel-replay")).thenReturn(existing);
var replay = service.relabelDisassemble(identity(), request);
assertTrue(replay.replayed());
verifyNoInteractions(physical, relabelApplications, relabelItems, positions, balances, ledgers,
relabelPalletMutations);
}
private static PdaWarehouseIdentity identity() { return new PdaWarehouseIdentity("7", "picker", 88L); }
private static PalletPhysicalGateway.PalletReference pallet(long id, String code, long warehouse, int version) {
return new PalletPhysicalGateway.PalletReference(id, code, warehouse, 1000L + id,
@@ -178,6 +266,21 @@ class PdaPalletOperationServiceTest {
value.setPickedPackages(0L); value.setReceivingPackages(0L); value.setVersion(version); return value;
}
private static InventoryPosition position(long id, long palletId, String palletNo, long hold, long version) {
InventoryPosition value = new InventoryPosition(); value.setId(id); value.setCustomerId("100");
value.setBalanceId(200L); value.setOverseasWarehouseId("88"); value.setPalletId(palletId);
value.setPalletNo(palletNo); value.setBatchNo("DEFAULT"); value.setWarehouseLocationId(1020L);
value.setWarehouseLocationCode("LOC-1020"); value.setAvailablePackages(0L); value.setHoldPackages(hold);
value.setAllocatedPackages(0L); value.setPickedPackages(0L); value.setReceivingPackages(0L);
value.setStatus("OCCUPIED"); value.setVersion(version); return value;
}
private static RelabelItem relabelItem(long id, String cartonMark, long positionId) {
RelabelItem value = new RelabelItem(); value.setId(id); value.setApplicationId(600L); value.setCustomerId("100");
value.setCartonMark(cartonMark); value.setNotifiedPackageCount(1); value.setSourceInventoryPositionId(positionId);
value.setDestinationFbaWarehouseCode("ONT8"); return value;
}
private static final class NoOpTransactionManager extends AbstractPlatformTransactionManager {
@Override protected Object doGetTransaction() { return new Object(); }
@Override protected void doBegin(Object transaction, TransactionDefinition definition) { }
@@ -50,6 +50,13 @@ class RelabelContractTest {
"source_warehouse_code", "source_warehouse_name", "idx_ll_relabel_item_source_position" }) {
assertTrue(deployment.contains(field), () -> "deployment SQL must include " + field);
}
for (String field : new String[] { "delivery_method", "print_shipping_label", "shipping_label_file_id",
"shipping_label_file_name", "shipping_label_file_url", "fulfillment_status", "outbound_order_no",
"customer_confirmation_key", "customer_confirmed_at" }) {
assertTrue(baseline.contains(field), () -> "baseline SQL must include " + field);
assertTrue(migration.contains(field), () -> "migration SQL must include " + field);
assertTrue(deployment.contains(field), () -> "deployment SQL must include " + field);
}
}
@Test
@@ -63,6 +70,19 @@ class RelabelContractTest {
assertTrue(openApi.contains("platform:overseas-warehouse:relabel-order:list"));
assertTrue(openApi.contains("platform:overseas-warehouse:relabel-order:create"));
assertTrue(openApi.contains("platform:overseas-warehouse:relabel-order:status"));
for (String path : new String[] {
"/api/merchant/v1/last-leg/relabel-applications/inventory-candidates:",
"/api/merchant/v1/last-leg/relabel-applications:",
"/api/merchant/v1/last-leg/relabel-applications/{relabelNo}:",
"/api/merchant/v1/last-leg/relabel-applications/{relabelNo}/shipping-label:",
"/api/merchant/v1/last-leg/relabel-applications/{relabelNo}/shipping-label/{fileId}/download:",
"/api/merchant/v1/last-leg/relabel-applications/{relabelNo}/completion-photos/{fileId}/download:",
"/api/merchant/v1/last-leg/relabel-applications/{relabelNo}/confirm-completion:" }) {
assertTrue(openApi.contains(path), () -> "merchant relabel path missing: " + path);
}
assertTrue(openApi.contains("RelabelMerchantPageResponse"));
assertTrue(openApi.contains("RelabelCustomerConfirm"));
assertTrue(openApi.contains("merchant:warehouse:relabel"));
String relabelPaths = openApi.substring(openApi.indexOf("/api/platform/v1/last-leg/relabel-applications:"),
openApi.indexOf("components:"));
assertTrue(relabelPaths.contains("name: customerId, in: query, required: true"));
@@ -182,7 +182,7 @@ class PdaRelabelServiceTest {
new PdaRelabelRequests.Review("APPROVE", 2, "review-1", "ok"));
assertEquals("COMPLETED", result.taskStatus());
assertEquals("COMPLETED", result.applicationStatus());
assertEquals(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, result.applicationStatus());
verify(applicationService).changeStatus(eq("100"), eq("FBR-1"), eq("8"), any(RelabelRequests.ChangeStatus.class));
}
@@ -22,6 +22,12 @@ import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
import com.baiyue.lastleg.delivery.entity.OutboundPdaTask;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundPdaTaskMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -60,6 +66,9 @@ class RelabelApplicationServiceTest {
@Mock InventoryCommandService inventoryCommands;
@Mock InventoryBalanceMapper inventoryBalances;
@Mock InventoryPositionMapper inventoryPositions;
@Mock OutboundOrderMapper outboundOrders;
@Mock OutboundPalletItemMapper outboundItems;
@Mock OutboundPdaTaskMapper outboundTasks;
@Spy LastlegOrderNoGenerator orderNoGenerator = new LastlegOrderNoGenerator();
private RelabelApplicationService service;
@@ -77,6 +86,9 @@ class RelabelApplicationServiceTest {
ReflectionTestUtils.setField(service, "inventoryCommands", inventoryCommands);
ReflectionTestUtils.setField(service, "inventoryBalances", inventoryBalances);
ReflectionTestUtils.setField(service, "inventoryPositions", inventoryPositions);
ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders);
ReflectionTestUtils.setField(service, "outboundItems", outboundItems);
ReflectionTestUtils.setField(service, "outboundTasks", outboundTasks);
ReflectionTestUtils.setField(service, "objectMapper", mapper());
lenient().when(walletCommands.publish(any(), any(Long.class), any(), any(), any(), any(), any(Long.class))).thenReturn("wallet-event-1");
lenient().when(inventoryPositions.findTenantPosition(any(), any())).thenAnswer(invocation -> {
@@ -344,7 +356,7 @@ class RelabelApplicationServiceTest {
@Test
void completedReturnRelabelOrderEntersPendingAssignment() {
RelabelApplication application = application(11L, "FBR-1", "10001", "key-1", "hash", "PROCESSING");
RelabelApplication application = application(11L, "FBR-1", "10001", "key-1", "hash", RelabelApplicationService.READY_FOR_OUTBOUND);
application.setLastlegOrderId(21L);
LastlegOrder order = order(21L, "10001", "LMO-RELABEL-1", "PROCESSING");
order.setOrderType("RETURN_RELABEL");
@@ -362,7 +374,7 @@ class RelabelApplicationServiceTest {
@Test
void completingPartialRelabelReleasesOnlyNotifiedPackages() {
RelabelApplication application = application(11L, "FBR-PARTIAL", "10001", "key-partial", "hash", "PROCESSING");
RelabelApplication application = application(11L, "FBR-PARTIAL", "10001", "key-partial", "hash", RelabelApplicationService.READY_FOR_OUTBOUND);
application.setLastlegOrderId(21L);
LastlegOrder order = order(21L, "10001", "LMO-RELABEL-PARTIAL", "PROCESSING");
when(applications.lockNo("10001", "FBR-PARTIAL")).thenReturn(application);
@@ -380,6 +392,80 @@ class RelabelApplicationServiceTest {
assertEquals(6L, move.getValue().packages());
}
@Test
void truckCustomerConfirmationIsIdempotentAndWaitsForPreDispatch() {
RelabelApplication application = confirmationApplication("TRUCK", false);
when(applications.lockNo("10001", "FBR-1")).thenReturn(application);
stubDetail(application);
service.confirmCompletion("10001", "FBR-1", "customer-1",
new RelabelRequests.CustomerConfirm("confirm-1", "photos accepted"));
assertEquals(RelabelApplicationService.READY_FOR_OUTBOUND, application.getStatus());
assertEquals("WAIT_PRE_DISPATCH", application.getFulfillmentStatus());
assertNull(application.getOutboundOrderNo());
verify(outboundOrders, never()).insert(any(OutboundOrder.class));
service.confirmCompletion("10001", "FBR-1", "customer-1",
new RelabelRequests.CustomerConfirm("confirm-1", "photos accepted"));
verify(applications, times(1)).updateById(application);
}
@Test
void expressCustomerConfirmationRequiresShippingLabelWhenWarehousePrintingIsSelected() {
RelabelApplication application = confirmationApplication("EXPRESS", true);
when(applications.lockNo("10001", "FBR-1")).thenReturn(application);
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () ->
service.confirmCompletion("10001", "FBR-1", "customer-1",
new RelabelRequests.CustomerConfirm("confirm-express", null)));
assertEquals(409, exception.getStatusCode().value());
verify(outboundOrders, never()).insert(any(OutboundOrder.class));
verify(applications, never()).updateById(application);
}
@Test
void pickupCustomerConfirmationCreatesOneExactCartonOutbound() {
RelabelApplication application = confirmationApplication("PICKUP", false);
RelabelItem source = item(31L, application.getId(), "SKU-A", 1, 1);
source.setSourceInventoryPositionId(201L);
source.setSourceInboundCode("CODE-1");
source.setCartonMark("BOX-1");
source.setDestinationFbaWarehouseCode("ONT8");
InventoryPosition position = new InventoryPosition();
position.setId(201L); position.setCustomerId("10001"); position.setPalletId(501L); position.setPalletNo("PALLET-1");
when(applications.lockNo("10001", "FBR-1")).thenReturn(application);
when(applications.findNo("10001", "FBR-1")).thenReturn(application);
when(items.selectList(any())).thenReturn(List.of(source));
when(histories.selectList(any())).thenReturn(List.of());
when(orders.selectById(21L)).thenReturn(order(21L, "10001", "LMO-RELABEL-1", "PENDING_ASSIGNMENT"));
when(inventoryPositions.selectById(201L)).thenReturn(position);
service.confirmCompletion("10001", "FBR-1", "customer-1",
new RelabelRequests.CustomerConfirm("confirm-pickup", null));
assertEquals("OUTBOUND_CREATED", application.getFulfillmentStatus());
assertNotNull(application.getOutboundOrderNo());
ArgumentCaptor<OutboundOrder> outbound = ArgumentCaptor.forClass(OutboundOrder.class);
verify(outboundOrders, times(1)).insert(outbound.capture());
assertEquals("RELABEL_OUTBOUND", outbound.getValue().getOutboundType());
ArgumentCaptor<OutboundPalletItem> outboundItem = ArgumentCaptor.forClass(OutboundPalletItem.class);
verify(outboundItems, times(1)).insert(outboundItem.capture());
assertEquals("BOX-1", outboundItem.getValue().getCartonMarks());
assertEquals("PALLET-1", outboundItem.getValue().getPalletNo());
verify(outboundTasks, times(1)).insert(any(OutboundPdaTask.class));
}
private RelabelApplication confirmationApplication(String deliveryMethod, boolean printShippingLabel) {
RelabelApplication application = application(11L, "FBR-1", "10001", "key-1", "hash",
RelabelApplicationService.CUSTOMER_CONFIRM_PENDING);
application.setLastlegOrderId(21L);
application.setDeliveryMethod(deliveryMethod);
application.setPrintShippingLabel(printShippingLabel);
return application;
}
private void stubDetail(RelabelApplication application) {
when(applications.findNo(application.getCustomerId(), application.getRelabelNo())).thenReturn(application);
when(items.selectList(any())).thenReturn(List.of(item(31L, application.getId(), "SKU-A", 10, 6)));
@@ -0,0 +1,80 @@
package com.baiyue.lastleg.relabel.service;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class RelabelPalletMutationServiceTest {
@Mock private RelabelItemMapper relabelItems;
@Mock private InventoryPositionMapper positions;
@Mock private OutboundOrderMapper outboundOrders;
@Mock private OutboundPalletItemMapper outboundItems;
private RelabelPalletMutationService service;
@BeforeEach void setUp() {
service = new RelabelPalletMutationService();
ReflectionTestUtils.setField(service, "relabelItems", relabelItems);
ReflectionTestUtils.setField(service, "positions", positions);
ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders);
ReflectionTestUtils.setField(service, "outboundItems", outboundItems);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
}
@Test void partialCartonMoveSplitsOutboundSnapshotAndKeepsUnselectedCartonOnSource() {
RelabelApplication application = new RelabelApplication(); application.setId(10L);
application.setCustomerId("100"); application.setRelabelNo("RL-1"); application.setOutboundOrderNo("OUT-1");
RelabelItem selected = item(20L, "CARTON-A", 101L);
RelabelItem remaining = item(21L, "CARTON-B", 100L);
InventoryPosition target = new InventoryPosition(); target.setId(101L); target.setPalletId(11L); target.setPalletNo("TARGET");
OutboundOrder outbound = new OutboundOrder(); outbound.setId(30L); outbound.setCustomerId("100");
outbound.setOutboundNo("OUT-1"); outbound.setOutboundType("RELABEL_OUTBOUND"); outbound.setStatus("WAIT_PICK");
OutboundPalletItem source = new OutboundPalletItem(); source.setId(31L); source.setCustomerId("100");
source.setOutboundOrderId(30L); source.setWarehousePalletId(10L); source.setPalletNo("SOURCE");
source.setPackageCount(2); source.setPickedPackageCount(0); source.setStatus("WAIT_PICK");
source.setCartonMarks("CARTON-A,CARTON-B");
when(outboundOrders.lockByNo("100", "OUT-1")).thenReturn(outbound);
when(outboundItems.findByOutbound("100", 30L)).thenReturn(List.of(source));
when(relabelItems.selectList(any())).thenReturn(List.of(remaining));
service.moveSelectedItems(application, Map.of(100L, List.of(selected)), Map.of(100L, target));
assertEquals(101L, selected.getSourceInventoryPositionId());
verify(relabelItems).updateById(selected);
assertEquals("CARTON-B", source.getCartonMarks()); assertEquals(1, source.getPackageCount());
ArgumentCaptor<OutboundPalletItem> split = ArgumentCaptor.forClass(OutboundPalletItem.class);
verify(outboundItems).insert(split.capture());
assertEquals("CARTON-A", split.getValue().getCartonMarks());
assertEquals(11L, split.getValue().getWarehousePalletId());
assertEquals(1, split.getValue().getPackageCount());
assertEquals(2, outbound.getPalletCount());
}
private static RelabelItem item(long id, String mark, long positionId) {
RelabelItem value = new RelabelItem(); value.setId(id); value.setApplicationId(10L); value.setCustomerId("100");
value.setCartonMark(mark); value.setNotifiedPackageCount(1); value.setSourceInventoryPositionId(positionId);
value.setDestinationFbaWarehouseCode("ONT8"); return value;
}
}
@@ -1,9 +1,8 @@
package com.baiyue.lastleg.transfer.service;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegInboundOrder;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.relabel.entity.RelabelStatusHistory;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
@@ -12,16 +11,17 @@ import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.mapper.LastlegInboundOrderMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferCarton;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferInventoryAllocation;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferSourceOrder;
import com.baiyue.lastleg.transfer.mapper.InboundSnapshotMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferItemMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferCartonMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferInventoryAllocationMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferReceiptMapper;
@@ -58,21 +58,22 @@ import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.times;
@ExtendWith(MockitoExtension.class)
class WarehouseTransferServiceTest {
@Mock WarehouseTransferOrderMapper transfers;
@Mock WarehouseTransferItemMapper transferItems;
@Mock WarehouseTransferCartonMapper transferCartons;
@Mock WarehouseTransferSourceOrderMapper sourceOrders;
@Mock WarehouseTransferInventoryAllocationMapper inventoryAllocations;
@Mock WarehouseTransferReceiptMapper transferReceipts;
@Mock InboundSnapshotMapper inboundSnapshots;
@Mock LastlegInboundOrderMapper inboundOrders;
@Mock LastlegOrderMapper orders;
@Mock LastlegOrderDetailMapper orderDetails;
@Mock LastlegOrderStatusHistoryMapper orderHistories;
@Mock RelabelApplicationMapper relabelApplications;
@Mock RelabelItemMapper relabelItems;
@Mock RelabelStatusHistoryMapper relabelHistories;
@Spy LastlegOrderNoGenerator orderNoGenerator = new LastlegOrderNoGenerator();
@Mock InventoryCommandService inventory;
@Mock InventoryCommandMapper inventoryCommands;
@@ -83,16 +84,16 @@ class WarehouseTransferServiceTest {
service = new WarehouseTransferService();
ReflectionTestUtils.setField(service, "transfers", transfers);
ReflectionTestUtils.setField(service, "transferItems", transferItems);
ReflectionTestUtils.setField(service, "transferCartons", transferCartons);
ReflectionTestUtils.setField(service, "sourceOrders", sourceOrders);
ReflectionTestUtils.setField(service, "inventoryAllocations", inventoryAllocations);
ReflectionTestUtils.setField(service, "transferReceipts", transferReceipts);
ReflectionTestUtils.setField(service, "inboundSnapshots", inboundSnapshots);
ReflectionTestUtils.setField(service, "inboundOrders", inboundOrders);
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "orderDetails", orderDetails);
ReflectionTestUtils.setField(service, "orderHistories", orderHistories);
ReflectionTestUtils.setField(service, "relabelApplications", relabelApplications);
ReflectionTestUtils.setField(service, "relabelItems", relabelItems);
ReflectionTestUtils.setField(service, "relabelHistories", relabelHistories);
ReflectionTestUtils.setField(service, "orderNoGenerator", orderNoGenerator);
ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "inventoryCommands", inventoryCommands);
@@ -101,10 +102,11 @@ class WarehouseTransferServiceTest {
.thenReturn("WT-WH-20260901-000001");
lenient().when(inventoryAllocations.selectList(any())).thenReturn(List.of());
lenient().when(transferReceipts.selectList(any())).thenReturn(List.of());
lenient().when(transferCartons.listOrder(anyString(), anyLong())).thenReturn(List.of());
}
@Test
void relabelTransferLinksHistoricalOrderAndInboundAndCreatesOnePendingReviewTailOrder() {
void relabelTransferOnlyMarksPendingBindingUntilInboundIsComplete() {
WarehouseTransferRequests.Create request = request(true);
LastlegOrder historical = historicalOrder();
when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L);
@@ -117,18 +119,15 @@ class WarehouseTransferServiceTest {
ArgumentCaptor<LastlegOrder> tailOrder = ArgumentCaptor.forClass(LastlegOrder.class);
verify(orders).insert(tailOrder.capture());
assertEquals(WarehouseTransferService.RETURN_RELABEL_ORDER_TYPE, tailOrder.getValue().getOrderType());
assertEquals("PENDING_ASSIGNMENT", tailOrder.getValue().getCurrentStatus());
assertEquals("WAREHOUSE_TRANSFER:PENDING_REVIEW;RELABEL:PENDING_PROCESSING", tailOrder.getValue().getSubOrderStatus());
assertEquals(WarehouseTransferService.ORDER_TYPE, tailOrder.getValue().getOrderType());
assertEquals(WarehouseTransferService.PENDING_REVIEW, tailOrder.getValue().getCurrentStatus());
assertEquals("WAREHOUSE_TRANSFER:PENDING_REVIEW;RELABEL:PENDING_BINDING", tailOrder.getValue().getSubOrderStatus());
assertTrue(tailOrder.getValue().getOrderNo().matches("WT-WH-\\d{8}-\\d{6}"));
assertNull(tailOrder.getValue().getTransferWarehouseId());
verify(sourceOrders).insert(any(WarehouseTransferSourceOrder.class));
verify(relabelApplications).insert(any(RelabelApplication.class));
verify(relabelItems).insert(any(RelabelItem.class));
verify(relabelHistories).insert(any(RelabelStatusHistory.class));
verify(orders).updateById(tailOrder.getValue());
assertNotNull(tailOrder.getValue().getRelabelOrderNo());
assertEquals("PENDING_PROCESSING", tailOrder.getValue().getRelabelStatus());
verify(relabelApplications, never()).insert(any(RelabelApplication.class));
assertNull(tailOrder.getValue().getRelabelOrderNo());
assertEquals("PENDING_BINDING", tailOrder.getValue().getRelabelStatus());
assertEquals(WarehouseTransferService.PENDING_REVIEW, result.status());
assertEquals(WarehouseTransferService.PENDING_REVIEW, result.orderStatus());
}
@@ -144,11 +143,71 @@ class WarehouseTransferServiceTest {
var result = service.create("TENANT-A", "USER-1", request(false));
verify(relabelApplications, never()).insert(any(RelabelApplication.class));
verify(relabelItems, never()).insert(any(RelabelItem.class));
verify(relabelHistories, never()).insert(any(RelabelStatusHistory.class));
assertNull(result.relabelNo());
}
@Test
void transferCanEnterTheTransitWarehouseBeforeFbaDestinationIsKnown() {
WarehouseTransferRequests.Create request = new WarehouseTransferRequests.Create(
"IN-1", List.of(11L), "WH-1", null, "123 Pickup Road", 5,
new BigDecimal("0.2500"), "WAREHOUSE_PICKUP", LocalDateTime.of(2026, 9, 12, 10, 0),
null, null, null, "FBA destination will be supplied later", false, "key-no-fba",
List.of(new WarehouseTransferRequests.Item("SKU-1", "Product", 5, "MARK-1")));
when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L);
when(orders.selectList(any())).thenReturn(List.of(historicalOrder()));
when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> persistedTransfer());
when(transferItems.selectList(any())).thenReturn(List.of());
when(sourceOrders.selectList(any())).thenReturn(List.of());
service.create("TENANT-A", "USER-1", request);
ArgumentCaptor<WarehouseTransferOrder> transfer = ArgumentCaptor.forClass(WarehouseTransferOrder.class);
ArgumentCaptor<LastlegOrder> tailOrder = ArgumentCaptor.forClass(LastlegOrder.class);
verify(transfers).insert(transfer.capture());
verify(orders).insert(tailOrder.capture());
assertNull(transfer.getValue().getFbaWarehouseCode());
assertNull(transfer.getValue().getFbaShipmentNo());
assertNull(tailOrder.getValue().getFbaWarehouseCode());
assertEquals("WAREHOUSE_PICKUP", transfer.getValue().getDeliveryMethod());
}
@Test
void newTransitInboundCreatesSnapshotAndImmutableCartonMarksWithoutHistoricalInventory() {
WarehouseTransferRequests.Create request = new WarehouseTransferRequests.Create(
null, null, "WH-1", null, "123 Pickup Road", 5,
new BigDecimal("0.2500"), "WAREHOUSE_PICKUP", LocalDateTime.of(2026, 9, 12, 10, 0),
null, "TRACK-MUST-NOT-BE-SAVED", null, "Incoming transit cargo", true, "key-incoming",
List.of(new WarehouseTransferRequests.Item("SKU-1", "Product A", 2, null),
new WarehouseTransferRequests.Item("SKU-2", "Product B", 3, null)));
when(transfers.findNo(eq("TENANT-A"), anyString())).thenAnswer(invocation -> persistedTransfer());
when(transferItems.selectList(any())).thenReturn(List.of());
when(sourceOrders.selectList(any())).thenReturn(List.of());
service.create("TENANT-A", "USER-1", request);
ArgumentCaptor<LastlegInboundOrder> inbound = ArgumentCaptor.forClass(LastlegInboundOrder.class);
ArgumentCaptor<WarehouseTransferOrder> transfer = ArgumentCaptor.forClass(WarehouseTransferOrder.class);
ArgumentCaptor<WarehouseTransferCarton> cartons = ArgumentCaptor.forClass(WarehouseTransferCarton.class);
ArgumentCaptor<LastlegOrder> tailOrder = ArgumentCaptor.forClass(LastlegOrder.class);
verify(inboundOrders).insert(inbound.capture());
verify(transfers).insert(transfer.capture());
verify(transferCartons, times(5)).insert(cartons.capture());
verify(orders).insert(tailOrder.capture());
verify(sourceOrders, never()).insert(any(WarehouseTransferSourceOrder.class));
verify(relabelApplications, never()).insert(any(RelabelApplication.class));
assertEquals(transfer.getValue().getTransferNo(), transfer.getValue().getInboundCode());
assertEquals(transfer.getValue().getTransferNo(), inbound.getValue().getInboundCode());
assertEquals("OVERSEAS", inbound.getValue().getDestinationType());
assertEquals(WarehouseTransferService.PENDING_REVIEW, inbound.getValue().getStatus());
assertNull(transfer.getValue().getTrackingNo());
assertEquals("PENDING_BINDING", tailOrder.getValue().getRelabelStatus());
assertEquals(List.of(1, 2, 3, 4, 5), cartons.getAllValues().stream()
.map(WarehouseTransferCarton::getCartonIndex).toList());
assertTrue(cartons.getAllValues().stream().allMatch(value -> value.getCartonMark()
.matches("WT-WH-\\d{8}-\\d{6}#5-[1-5]")));
}
@Test
void approvedTransferWithoutRelabelEntersPendingAssignment() {
WarehouseTransferOrder transfer = persistedTransfer();
@@ -5,6 +5,7 @@
"dispatch-delivery->dropship",
"dispatch-delivery->inbound-pda",
"dispatch-delivery->inventory",
"dispatch-delivery->relabel-transfer-discard",
"dispatch-delivery->shared-technical",
"dispatch-delivery->tailleg-handoff",
"dropship->inbound-pda",
@@ -12,6 +13,7 @@
"dropship->tailleg-handoff",
"inbound-pda->dropship",
"inbound-pda->inventory",
"inbound-pda->relabel-transfer-discard",
"inbound-pda->shared-technical",
"inbound-pda->tailleg-handoff",
"inventory->inbound-pda",
@@ -20,7 +22,9 @@
"procurement-trucking-looseboard->overseas-master-data",
"procurement-trucking-looseboard->shared-technical",
"procurement-trucking-looseboard->tailleg-handoff",
"relabel-transfer-discard->dispatch-delivery",
"relabel-transfer-discard->dropship",
"shared-technical->tailleg-handoff",
"relabel-transfer-discard->inventory",
"relabel-transfer-discard->shared-technical",
"relabel-transfer-discard->tailleg-handoff",