This commit is contained in:
2026-08-17 19:04:58 +08:00
parent 3082167411
commit ac6c0d0395
76 changed files with 2770 additions and 433 deletions
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.delivery;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan("com.baiyue.lastleg.delivery.mapper")
public class DeliveryMapperConfiguration {
}
@@ -0,0 +1,74 @@
package com.baiyue.lastleg.delivery.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
import com.baiyue.lastleg.delivery.dto.DeliveryResponses;
import com.baiyue.lastleg.delivery.service.DeliveryTransferService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/platform/v1/last-leg")
@Tag(name = "Outbound and delivery")
public class DeliveryTransferController {
@Resource private DeliveryTransferService service;
@PostMapping("/dispatches/{dispatchNo}/transfer-delivery")
@Operation(summary = "Create one pending outbound order and one pending delivery order")
public ApiResponse<DeliveryResponses.TransferResult> transfer(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo,
@RequestParam(required = false) String tenantId,
@Valid @RequestBody DeliveryRequests.Transfer request) {
return ApiResponse.success(service.transfer(scope(user, tenantId), dispatchNo, operator(user), request));
}
@GetMapping("/outbound-orders")
public ApiResponse<PageResult<DeliveryResponses.OutboundRow>> outboundPage(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String tenantId,
@Valid DeliveryRequests.Query query) {
return ApiResponse.success(service.outboundPage(scope(user, tenantId), query));
}
@GetMapping("/outbound-orders/{outboundNo}")
public ApiResponse<DeliveryResponses.OutboundDetail> outboundDetail(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String tenantId,
@PathVariable String outboundNo, @Valid DeliveryRequests.PalletQuery query) {
return ApiResponse.success(service.outboundDetail(scope(user, tenantId), outboundNo, query));
}
@GetMapping("/delivery-orders")
public ApiResponse<PageResult<DeliveryResponses.DeliveryRow>> deliveryPage(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String tenantId,
@Valid DeliveryRequests.Query query) {
return ApiResponse.success(service.deliveryPage(scope(user, tenantId), query));
}
@GetMapping("/delivery-orders/{deliveryNo}")
public ApiResponse<DeliveryResponses.OutboundDetail> deliveryDetail(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String tenantId,
@PathVariable String deliveryNo, @Valid DeliveryRequests.PalletQuery query) {
return ApiResponse.success(service.deliveryDetail(scope(user, tenantId), deliveryNo, query));
}
private String scope(AuthenticatedUser user, String requestedTenant) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
if (requestedTenant == null || requestedTenant.isBlank() || "0".equals(requestedTenant)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant");
}
return requestedTenant.trim();
}
return String.valueOf(user.tenantId());
}
private String operator(AuthenticatedUser user) {
return user.userId() == null ? user.username() : String.valueOf(user.userId());
}
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.delivery.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public final class DeliveryRequests {
private DeliveryRequests() {}
public record Transfer(@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) {}
public record Query(@Min(1) long page, @Min(1) @Max(200) long size,
String documentNo, String warehouseId, String status) {
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
}
public record PalletQuery(@Min(1) long page, @Min(1) @Max(200) long size,
String destinationWarehouseCode) {
public PalletQuery { if (page == 0) page = 1; if (size == 0) size = 20; }
}
}
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.delivery.dto;
import com.baiyue.common.core.api.PageResult;
import java.time.LocalDateTime;
public final class DeliveryResponses {
private DeliveryResponses() {}
public record TransferResult(String dispatchNo, String outboundNo, String outboundStatus,
String deliveryNo, String deliveryStatus, int palletCount,
int packageCount, boolean replayed) {}
public record OutboundRow(Long id, String outboundNo, String dispatchNo, String overseasWarehouseId,
String status, int palletCount, int packageCount, LocalDateTime createdAt) {}
public record DeliveryRow(Long id, String deliveryNo, String outboundNo, String dispatchNo,
String overseasWarehouseId, String status, int palletCount,
int packageCount, LocalDateTime createdAt) {}
public record PalletRow(Long id, Long warehousePalletId, String palletNo, String inboundCode,
String orderRefs, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
int packageCount) {}
public record OutboundDetail(OutboundRow outbound, DeliveryRow delivery,
PageResult<PalletRow> pallets) {}
}
@@ -0,0 +1,30 @@
package com.baiyue.lastleg.delivery.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;
/** Delivery document linked one-to-one to its outbound document and dispatch. */
@Data
@TableName("ll_delivery_order")
public class DeliveryOrder {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String tenantId;
private String deliveryNo;
private Long outboundOrderId;
private String outboundNo;
private Long dispatchOrderId;
private String dispatchNo;
private String overseasWarehouseId;
private String status;
private Integer packageCount;
private Integer palletCount;
private String idempotencyKey;
private String createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,28 @@
package com.baiyue.lastleg.delivery.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;
/** Outbound document created when a dispatch is transferred to delivery. */
@Data
@TableName("ll_outbound_order")
public class OutboundOrder {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String tenantId;
private String outboundNo;
private Long dispatchOrderId;
private String dispatchNo;
private String overseasWarehouseId;
private String status;
private Integer packageCount;
private Integer palletCount;
private String idempotencyKey;
private String createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.delivery.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;
/** Pallet-level immutable snapshot belonging to one outbound document. */
@Data
@TableName("ll_outbound_pallet_item")
public class OutboundPalletItem {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String tenantId;
private Long outboundOrderId;
private Long warehousePalletId;
private String palletNo;
private String destinationWarehouseId;
private String destinationWarehouseCode;
private String destinationWarehouseName;
private Integer packageCount;
private String orderRefs;
private String inboundCode;
private LocalDateTime createdAt;
}
@@ -0,0 +1,17 @@
package com.baiyue.lastleg.delivery.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface DeliveryOrderMapper extends BaseMapper<DeliveryOrder> {
@Select("SELECT * FROM ll_delivery_order WHERE tenant_id=#{tenantId} AND delivery_no=#{deliveryNo} LIMIT 1")
DeliveryOrder findByNo(@Param("tenantId") String tenantId, @Param("deliveryNo") String deliveryNo);
@Select("SELECT * FROM ll_delivery_order WHERE tenant_id=#{tenantId} AND idempotency_key=#{key} LIMIT 1")
DeliveryOrder findByIdempotency(@Param("tenantId") String tenantId, @Param("key") String key);
@Select("SELECT * FROM ll_delivery_order WHERE tenant_id=#{tenantId} AND outbound_order_id=#{outboundOrderId} LIMIT 1 FOR UPDATE")
DeliveryOrder lockByOutbound(@Param("tenantId") String tenantId, @Param("outboundOrderId") Long outboundOrderId);
}
@@ -0,0 +1,17 @@
package com.baiyue.lastleg.delivery.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface OutboundOrderMapper extends BaseMapper<OutboundOrder> {
@Select("SELECT * FROM ll_outbound_order WHERE tenant_id=#{tenantId} AND outbound_no=#{outboundNo} LIMIT 1")
OutboundOrder findByNo(@Param("tenantId") String tenantId, @Param("outboundNo") String outboundNo);
@Select("SELECT * FROM ll_outbound_order WHERE tenant_id=#{tenantId} AND idempotency_key=#{key} LIMIT 1")
OutboundOrder findByIdempotency(@Param("tenantId") String tenantId, @Param("key") String key);
@Select("SELECT * FROM ll_outbound_order WHERE tenant_id=#{tenantId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1 FOR UPDATE")
OutboundOrder lockByDispatch(@Param("tenantId") String tenantId, @Param("dispatchOrderId") Long dispatchOrderId);
}
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.delivery.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
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 OutboundPalletItemMapper extends BaseMapper<OutboundPalletItem> {
@Select("SELECT * FROM ll_outbound_pallet_item WHERE tenant_id=#{tenantId} AND outbound_order_id=#{outboundOrderId} ORDER BY id")
List<OutboundPalletItem> findByOutbound(@Param("tenantId") String tenantId, @Param("outboundOrderId") Long outboundOrderId);
}
@@ -0,0 +1,212 @@
package com.baiyue.lastleg.delivery.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
import com.baiyue.lastleg.delivery.dto.DeliveryResponses;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
@Service
public class DeliveryTransferService {
@Resource private DispatchOrderMapper dispatchOrders;
@Resource private DispatchItemMapper dispatchItems;
@Resource private OutboundOrderMapper outboundOrders;
@Resource private OutboundPalletItemMapper outboundPallets;
@Resource private DeliveryOrderMapper deliveryOrders;
@Resource private InboundPlanPalletAllocationMapper planAllocations;
@Resource private InboundPlanLineMapper planLines;
@Resource private LastlegOrderMapper tailOrders;
@Resource private LastlegOrderStatusHistoryMapper histories;
@Transactional
public DeliveryResponses.TransferResult transfer(String tenantId, String dispatchNo, String operatorId,
DeliveryRequests.Transfer request) {
DispatchOrder found = dispatchOrders.findByDispatchNo(tenantId, dispatchNo);
if (found == null) throw notFound("Dispatch order not found");
DispatchOrder dispatch = dispatchOrders.lockById(tenantId, found.getId());
OutboundOrder replay = outboundOrders.findByIdempotency(tenantId, request.idempotencyKey().trim());
if (replay != null) {
if (!dispatch.getId().equals(replay.getDispatchOrderId())) throw conflict("Idempotency key belongs to another dispatch");
return result(replay, requireDelivery(tenantId, replay.getId()), true);
}
OutboundOrder existing = outboundOrders.lockByDispatch(tenantId, dispatch.getId());
if (existing != null) return result(existing, requireDelivery(tenantId, existing.getId()), true);
if (Set.of("CANCELLED", "DEPARTED", "PARTIAL_DEPARTED", "COMPLETED").contains(dispatch.getStatus())) {
throw conflict("Dispatch cannot be transferred to delivery in its current status");
}
List<DispatchItem> items = dispatchItems.findByDispatchOrder(tenantId, dispatch.getId());
if (items.isEmpty()) throw conflict("Dispatch has no pallets");
if (items.stream().anyMatch(item -> item.getWarehousePalletId() == null)) {
throw conflict("All dispatch pallets must be received before transfer to delivery");
}
if (!StringUtils.hasText(dispatch.getOverseasWarehouseId())) throw conflict("Dispatch warehouse is required");
LocalDateTime now = LocalDateTime.now();
OutboundOrder outbound = new OutboundOrder();
outbound.setId(IdWorker.getId()); outbound.setTenantId(tenantId);
outbound.setOutboundNo("OUT" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + outbound.getId());
outbound.setDispatchOrderId(dispatch.getId()); outbound.setDispatchNo(dispatch.getDispatchNo());
outbound.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); outbound.setStatus("WAIT_OUTBOUND");
outbound.setPackageCount(items.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum());
outbound.setPalletCount(items.size()); outbound.setIdempotencyKey(request.idempotencyKey().trim());
outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now);
try {
outboundOrders.insert(outbound);
} catch (DuplicateKeyException e) {
// The unique keys are the final concurrent guard. A client retry must observe
// the document created by the winning transaction rather than receive a false failure.
OutboundOrder concurrent = outboundOrders.findByIdempotency(tenantId, request.idempotencyKey().trim());
if (concurrent != null && dispatch.getId().equals(concurrent.getDispatchOrderId())) {
return result(concurrent, requireDelivery(tenantId, concurrent.getId()), true);
}
concurrent = outboundOrders.lockByDispatch(tenantId, dispatch.getId());
if (concurrent != null) {
return result(concurrent, requireDelivery(tenantId, concurrent.getId()), true);
}
throw conflict("Dispatch was concurrently transferred to delivery");
}
for (DispatchItem source : items) {
OutboundPalletItem item = new OutboundPalletItem();
item.setId(IdWorker.getId()); item.setTenantId(tenantId); item.setOutboundOrderId(outbound.getId());
item.setWarehousePalletId(source.getWarehousePalletId()); item.setPalletNo(source.getPalletNo());
item.setDestinationWarehouseId(source.getDestinationWarehouseId());
item.setDestinationWarehouseCode(source.getDestinationWarehouseCode());
item.setDestinationWarehouseName(source.getDestinationWarehouseName());
item.setPackageCount(Objects.requireNonNullElse(source.getPackageCount(), 0));
item.setOrderRefs(source.getOrderRefs()); item.setInboundCode(source.getInboundCode()); item.setCreatedAt(now);
outboundPallets.insert(item);
}
DeliveryOrder delivery = new DeliveryOrder();
delivery.setId(IdWorker.getId()); delivery.setTenantId(tenantId);
delivery.setDeliveryNo("DEL" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + delivery.getId());
delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo());
delivery.setDispatchOrderId(dispatch.getId()); delivery.setDispatchNo(dispatch.getDispatchNo());
delivery.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); delivery.setStatus("WAIT_DELIVERY");
delivery.setPackageCount(outbound.getPackageCount()); delivery.setPalletCount(outbound.getPalletCount());
delivery.setIdempotencyKey(request.idempotencyKey().trim()); delivery.setCreatedBy(operatorId);
delivery.setCreatedAt(now); delivery.setUpdatedAt(now); deliveryOrders.insert(delivery);
bindTailOrders(tenantId, items, outbound.getOutboundNo(), delivery.getDeliveryNo(), request.remark(), now);
return result(outbound, delivery, false);
}
public PageResult<DeliveryResponses.OutboundRow> outboundPage(String tenantId, DeliveryRequests.Query query) {
Page<OutboundOrder> page = outboundOrders.selectPage(new Page<>(query.page(), query.size()),
new LambdaQueryWrapper<OutboundOrder>().eq(OutboundOrder::getTenantId, tenantId)
.like(StringUtils.hasText(query.documentNo()), OutboundOrder::getOutboundNo, query.documentNo())
.eq(StringUtils.hasText(query.warehouseId()), OutboundOrder::getOverseasWarehouseId, query.warehouseId())
.eq(StringUtils.hasText(query.status()), OutboundOrder::getStatus, query.status())
.orderByDesc(OutboundOrder::getCreatedAt));
return page(page, page.getRecords().stream().map(this::outboundRow).toList());
}
public DeliveryResponses.OutboundDetail outboundDetail(String tenantId, String outboundNo,
DeliveryRequests.PalletQuery query) {
OutboundOrder outbound = outboundOrders.findByNo(tenantId, outboundNo);
if (outbound == null) throw notFound("Outbound order not found");
DeliveryOrder delivery = requireDelivery(tenantId, outbound.getId());
Page<OutboundPalletItem> page = outboundPallets.selectPage(new Page<>(query.page(), query.size()),
new LambdaQueryWrapper<OutboundPalletItem>().eq(OutboundPalletItem::getTenantId, tenantId)
.eq(OutboundPalletItem::getOutboundOrderId, outbound.getId())
.eq(StringUtils.hasText(query.destinationWarehouseCode()),
OutboundPalletItem::getDestinationWarehouseCode, query.destinationWarehouseCode())
.orderByAsc(OutboundPalletItem::getId));
return new DeliveryResponses.OutboundDetail(outboundRow(outbound), deliveryRow(delivery),
page(page, page.getRecords().stream().map(this::palletRow).toList()));
}
public PageResult<DeliveryResponses.DeliveryRow> deliveryPage(String tenantId, DeliveryRequests.Query query) {
Page<DeliveryOrder> page = deliveryOrders.selectPage(new Page<>(query.page(), query.size()),
new LambdaQueryWrapper<DeliveryOrder>().eq(DeliveryOrder::getTenantId, tenantId)
.like(StringUtils.hasText(query.documentNo()), DeliveryOrder::getDeliveryNo, query.documentNo())
.eq(StringUtils.hasText(query.warehouseId()), DeliveryOrder::getOverseasWarehouseId, query.warehouseId())
.eq(StringUtils.hasText(query.status()), DeliveryOrder::getStatus, query.status())
.orderByDesc(DeliveryOrder::getCreatedAt));
return page(page, page.getRecords().stream().map(this::deliveryRow).toList());
}
public DeliveryResponses.OutboundDetail deliveryDetail(String tenantId, String deliveryNo,
DeliveryRequests.PalletQuery query) {
DeliveryOrder delivery = deliveryOrders.findByNo(tenantId, deliveryNo);
if (delivery == null) throw notFound("Delivery order not found");
OutboundOrder outbound = outboundOrders.selectById(delivery.getOutboundOrderId());
if (outbound == null || !tenantId.equals(outbound.getTenantId())) throw notFound("Outbound order not found");
return outboundDetail(tenantId, outbound.getOutboundNo(), query);
}
private void bindTailOrders(String tenantId, List<DispatchItem> items, String outboundNo, String deliveryNo,
String remark, LocalDateTime now) {
Set<Long> orderIds = new LinkedHashSet<>();
items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).forEach(orderIds::add);
List<Long> palletIds = items.stream().map(DispatchItem::getPlanPalletId).filter(Objects::nonNull).distinct().toList();
if (!palletIds.isEmpty()) {
List<InboundPlanPalletAllocation> allocations = planAllocations.selectList(
new LambdaQueryWrapper<InboundPlanPalletAllocation>().in(InboundPlanPalletAllocation::getPalletId, palletIds));
List<Long> lineIds = allocations.stream().map(InboundPlanPalletAllocation::getLineId)
.filter(Objects::nonNull).distinct().toList();
if (!lineIds.isEmpty()) {
planLines.selectBatchIds(lineIds).stream().map(InboundPlanLine::getOrderId)
.filter(Objects::nonNull).forEach(orderIds::add);
}
}
if (orderIds.isEmpty()) return;
for (LastlegOrder order : tailOrders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getTenantId, tenantId).in(LastlegOrder::getId, orderIds))) {
String from = order.getCurrentStatus(); order.setOutboundOrderNo(outboundNo);
order.setDeliveryOrderNo(deliveryNo); order.setCurrentStatus("WAIT_OUTBOUND"); order.setUpdatedAt(now);
tailOrders.updateById(order);
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setOrderId(order.getId());
history.setFromStatus(from); history.setToStatus("WAIT_OUTBOUND");
history.setEventId("TRANSFER_DELIVERY:" + deliveryNo);
history.setRemark(StringUtils.hasText(remark) ? remark.trim() : "Transferred to outbound and delivery documents");
history.setCreatedAt(now); histories.insert(history);
}
}
private DeliveryOrder requireDelivery(String tenantId, Long outboundId) {
DeliveryOrder value = deliveryOrders.lockByOutbound(tenantId, outboundId);
if (value == null) throw new IllegalStateException("Delivery document is missing for outbound order");
return value;
}
private DeliveryResponses.TransferResult result(OutboundOrder o, DeliveryOrder d, boolean replayed) {
return new DeliveryResponses.TransferResult(o.getDispatchNo(), o.getOutboundNo(), o.getStatus(),
d.getDeliveryNo(), d.getStatus(), o.getPalletCount(), o.getPackageCount(), replayed);
}
private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getCreatedAt()); }
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getStatus(), d.getPalletCount(), d.getPackageCount(), d.getCreatedAt()); }
private DeliveryResponses.PalletRow palletRow(OutboundPalletItem p) { return new DeliveryResponses.PalletRow(p.getId(), p.getWarehousePalletId(), p.getPalletNo(), p.getInboundCode(), p.getOrderRefs(), p.getDestinationWarehouseId(), p.getDestinationWarehouseCode(), p.getDestinationWarehouseName(), p.getPackageCount()); }
private static <T> PageResult<T> page(Page<?> page, List<T> records) { return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
}
@@ -6,11 +6,15 @@ import java.util.List;
public record DispatchResponse(Long id, String dispatchNo, String vehiclePlate, String driverName,
String driverPhone, String carrierName, String orderNos, String route,
LocalDateTime departAt, String fbaAppointmentNo, String fbaAppointmentStatus,
String overseasWarehouseId, String overseasWarehouseName, LocalDateTime departAt,
String fbaAppointmentNo, String fbaAppointmentStatus,
String vehicleAppointmentStatus, LocalDateTime estimatedArrivalAt, String status,
int itemCount, int palletCount, int packageCount, LocalDateTime createdAt,
int itemCount, int palletCount, int packageCount, int pickedPackageCount,
int shippedPackageCount, int remainingPackageCount, String partialCompletionReason,
LocalDateTime createdAt,
List<Item> items) {
public record Item(Long id, Long planPalletId, Long warehousePalletId, String palletNo,
String overseasWarehouseId, String overseasWarehouseName,
String orderNo, String orderRefs, String inboundCode, String destinationWarehouseCode,
String destinationWarehouseName, String fbaWarehouseCode, int packageCount,
BigDecimal volumeCbm, String sourceStage) {
@@ -20,6 +20,8 @@ public class DispatchItem {
private Long planPalletId;
private Long warehousePalletId;
private Long orderId;
private String overseasWarehouseId;
private String overseasWarehouseName;
private String orderNo;
private String orderRefs;
private String inboundCode;
@@ -15,6 +15,8 @@ public class DispatchOrder {
private Long id;
private String tenantId;
private String dispatchNo;
private String overseasWarehouseId;
private String overseasWarehouseName;
private String vehiclePlate;
private String driverName;
private String driverPhone;
@@ -29,6 +31,9 @@ public class DispatchOrder {
private Integer itemCount;
private Integer palletCount;
private Integer packageCount;
private Integer pickedPackageCount;
private Integer shippedPackageCount;
private String partialCompletionReason;
private String createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@@ -11,13 +11,15 @@ import java.util.List;
public interface DispatchCandidateMapper {
@Select("<script>" +
"SELECT p.plan_id AS planId,p.id AS planPalletId,p.warehouse_pallet_id AS warehousePalletId," +
"MIN(l.order_id) AS orderId,MIN(o.order_no) AS orderNo," +
"MIN(l.order_id) AS orderId,MIN(o.overseas_warehouse_id) AS overseasWarehouseId,MIN(o.overseas_warehouse_name) AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
"GROUP_CONCAT(DISTINCT o.order_no ORDER BY o.order_no SEPARATOR ',') AS orderRefs,MIN(l.inbound_code) AS inboundCode," +
"p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," +
"p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," +
"MIN(l.fba_warehouse_code) AS fbaWarehouseCode," +
"COALESCE(SUM(CASE WHEN a.picked_package_count &gt; 0 THEN a.picked_package_count " +
"ELSE a.planned_package_count END),0) AS packageCount,p.allocated_volume_cbm AS volumeCbm," +
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN COALESCE((SELECT SUM(pos.available_packages) " +
"FROM ll_inventory_position pos WHERE pos.tenant_id=#{tenantId} AND pos.pallet_id=p.warehouse_pallet_id),0) " +
"ELSE COALESCE(SUM(CASE WHEN a.picked_package_count &gt; 0 THEN a.picked_package_count " +
"ELSE a.planned_package_count END),0) END AS packageCount,p.allocated_volume_cbm AS volumeCbm," +
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage " +
"FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " +
"LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " +
@@ -28,6 +30,8 @@ public interface DispatchCandidateMapper {
"<if test='fbaWarehouseCode != null and fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code=#{fbaWarehouseCode}</if> " +
"<if test='sourceStage != null and sourceStage != \"\"'> AND " +
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END=#{sourceStage}</if> " +
"AND (p.warehouse_pallet_id IS NULL OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
"WHERE pos.tenant_id=#{tenantId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0)) " +
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " +
"WHERE d.tenant_id=ip.tenant_id AND d.status='ACTIVE' AND (" +
"d.source_pallet_key=CONCAT('PLAN:',p.id) OR (p.warehouse_pallet_id IS NOT NULL AND " +
@@ -43,13 +47,15 @@ public interface DispatchCandidateMapper {
@Select("<script>" +
"SELECT p.plan_id AS planId,p.id AS planPalletId,p.warehouse_pallet_id AS warehousePalletId," +
"MIN(l.order_id) AS orderId,MIN(o.order_no) AS orderNo," +
"MIN(l.order_id) AS orderId,MIN(o.overseas_warehouse_id) AS overseasWarehouseId,MIN(o.overseas_warehouse_name) AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
"GROUP_CONCAT(DISTINCT o.order_no ORDER BY o.order_no SEPARATOR ',') AS orderRefs,MIN(l.inbound_code) AS inboundCode," +
"p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," +
"p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," +
"MIN(l.fba_warehouse_code) AS fbaWarehouseCode," +
"COALESCE(SUM(CASE WHEN a.picked_package_count &gt; 0 THEN a.picked_package_count " +
"ELSE a.planned_package_count END),0) AS packageCount,p.allocated_volume_cbm AS volumeCbm," +
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN COALESCE((SELECT SUM(pos.available_packages) " +
"FROM ll_inventory_position pos WHERE pos.tenant_id=#{tenantId} AND pos.pallet_id=p.warehouse_pallet_id),0) " +
"ELSE COALESCE(SUM(CASE WHEN a.picked_package_count &gt; 0 THEN a.picked_package_count " +
"ELSE a.planned_package_count END),0) END AS packageCount,p.allocated_volume_cbm AS volumeCbm," +
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage " +
"FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " +
"LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " +
@@ -7,6 +7,8 @@ public record DispatchPalletCandidate(
Long planPalletId,
Long warehousePalletId,
Long orderId,
String overseasWarehouseId,
String overseasWarehouseName,
String orderNo,
String orderRefs,
String inboundCode,
@@ -19,6 +21,15 @@ public record DispatchPalletCandidate(
BigDecimal volumeCbm,
String sourceStage
) {
public DispatchPalletCandidate(Long planId, Long planPalletId, Long warehousePalletId, Long orderId,
String orderNo, String orderRefs, String inboundCode, String palletNo,
String destinationWarehouseId, String destinationWarehouseCode,
String destinationWarehouseName, String fbaWarehouseCode, Integer packageCount,
BigDecimal volumeCbm, String sourceStage) {
this(planId, planPalletId, warehousePalletId, orderId, null, null, orderNo, orderRefs, inboundCode,
palletNo, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName,
fbaWarehouseCode, packageCount, volumeCbm, sourceStage);
}
public String sourcePalletKey() {
// A plan pallet keeps the same lock identity before and after the physical pallet is received.
return planPalletId != null ? "PLAN:" + planPalletId : "RECEIVED:" + warehousePalletId;
@@ -37,6 +37,14 @@ public class DispatchPdaController {
@Valid @RequestBody DispatchPdaRequests.Pick request) {
return ApiResponse.success(service.pick(scope(user, tenantId), dispatchNo, itemId, operator(user), request));
}
@PostMapping("/complete")
public ApiResponse<DispatchPdaResponses.PickingCompleted> completePicking(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo,
@RequestParam(required = false) String tenantId,
@Valid @RequestBody DispatchPdaRequests.CompletePicking request) {
return ApiResponse.success(service.completePicking(scope(user, tenantId), dispatchNo, request));
}
private String scope(AuthenticatedUser user, String requestedTenant) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
@@ -4,11 +4,23 @@ import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
public final class DispatchPdaRequests {
private DispatchPdaRequests() {}
public record Push(@NotBlank String idempotencyKey, String operatorId, String operatorName) {}
public record Push(@NotBlank String idempotencyKey, String operatorId, String operatorName,
String warehouseId) {
public Push(String idempotencyKey, String operatorId, String operatorName) {
this(idempotencyKey, operatorId, operatorName, null);
}
}
public record Pick(@NotNull Long positionId, @Positive long packages, @PositiveOrZero long productQuantity,
@NotBlank String idempotencyKey, String remark) {}
@NotBlank String idempotencyKey, String remark, String warehouseId) {
public Pick(Long positionId, long packages, long productQuantity, String idempotencyKey, String remark) {
this(positionId, packages, productQuantity, idempotencyKey, remark, null);
}
}
public record Depart(@NotBlank String idempotencyKey, String remark) {}
public record CompletePicking(@NotBlank String idempotencyKey,
@NotBlank @Size(max = 1000) String reason) {}
}
@@ -4,16 +4,24 @@ import java.util.List;
public final class DispatchPdaResponses {
private DispatchPdaResponses() {}
public record Task(Long id, String taskNo, String dispatchNo, String vehiclePlate, String status,
public record Task(Long id, String taskNo, String dispatchNo, String vehiclePlate, String warehouseId, String status,
List<Item> items) {}
public record Item(Long dispatchItemId, String palletNo, String destinationWarehouseCode,
String destinationWarehouseName, String fbaWarehouseCode, int scheduledPackages,
long pickedPackages, List<Position> positions) {}
public record Position(Long id, String skuCode, long availablePackages, long allocatedPackages,
long pickedPackages, long productQuantity) {}
public record Position(Long id, Long balanceId, long availablePackages, long allocatedPackages,
long pickedPackages) {}
public record Picked(Long dispatchItemId, Long positionId, long packages, long pickedPackages,
boolean palletScrapped, boolean replayed) {}
public record Departed(String dispatchNo, String status, long shippedPackages, boolean replayed) {
public Departed withReplay() { return new Departed(dispatchNo, status, shippedPackages, true); }
}
public record PickingCompleted(String dispatchNo, String status, long plannedPackages,
long pickedPackages, long remainingPackages, boolean partial,
boolean replayed) {
public PickingCompleted withReplay() {
return new PickingCompleted(dispatchNo, status, plannedPackages, pickedPackages,
remainingPackages, partial, true);
}
}
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.dispatch.pda.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;
/** Immutable FIFO attribution of a PDA pick to its inbound receipt and tail-leg order. */
@Data
@TableName("ll_dispatch_pda_pick_order")
public class DispatchPdaPickOrder {
@TableId(type = IdType.INPUT) private Long id;
private String tenantId;
private Long pickId;
private Long receiptId;
private Long orderId;
private Long packages;
private Long productQuantity;
private LocalDateTime createdAt;
}
@@ -17,6 +17,7 @@ public class DispatchPdaTask {
private String status;
private String operatorId;
private String operatorName;
private String warehouseId;
private LocalDateTime pushedAt;
private LocalDateTime completedAt;
private LocalDateTime createdAt;
@@ -0,0 +1,31 @@
package com.baiyue.lastleg.dispatch.pda.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPickOrder;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
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 DispatchPdaPickOrderMapper extends BaseMapper<DispatchPdaPickOrder> {
@Select("SELECT r.* FROM ll_inbound_stock_receipt r " +
"WHERE r.tenant_id=#{tenantId} AND r.inventory_position_id=#{positionId} " +
"AND r.received_packages > (SELECT COALESCE(SUM(a.packages),0) FROM ll_dispatch_pda_pick_order a " +
"WHERE a.tenant_id=r.tenant_id AND a.receipt_id=r.id) ORDER BY r.received_at,r.id")
List<InboundStockReceipt> availableReceipts(@Param("tenantId") String tenantId,
@Param("positionId") Long positionId);
@Select("SELECT COALESCE(SUM(packages),0) FROM ll_dispatch_pda_pick_order " +
"WHERE tenant_id=#{tenantId} AND receipt_id=#{receiptId}")
long attributedPackages(@Param("tenantId") String tenantId, @Param("receiptId") Long receiptId);
@Select("SELECT COALESCE(SUM(product_quantity),0) FROM ll_dispatch_pda_pick_order " +
"WHERE tenant_id=#{tenantId} AND receipt_id=#{receiptId}")
long attributedQuantity(@Param("tenantId") String tenantId, @Param("receiptId") Long receiptId);
@Select("SELECT * FROM ll_dispatch_pda_pick_order WHERE tenant_id=#{tenantId} AND pick_id=#{pickId} ORDER BY id")
List<DispatchPdaPickOrder> listPick(@Param("tenantId") String tenantId, @Param("pickId") Long pickId);
}
@@ -1,6 +1,8 @@
package com.baiyue.lastleg.dispatch.pda.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock;
@@ -31,6 +33,7 @@ import java.util.stream.Collectors;
@Service
public class DispatchPdaWorkflowService {
@Resource private DispatchOrderMapper orders;
@Resource private OutboundOrderMapper outboundOrders;
@Resource private DispatchItemMapper items;
@Resource private DispatchPdaTaskMapper tasks;
@Resource private DispatchPdaPickMapper picks;
@@ -38,6 +41,7 @@ public class DispatchPdaWorkflowService {
@Resource private DispatchPalletLockMapper palletLocks;
@Resource private InventoryPositionMapper positions;
@Resource private InventoryCommandService inventory;
@Resource private DispatchPickOrderAttributionService pickAttribution;
@Resource private ObjectMapper objectMapper;
@Transactional
@@ -45,6 +49,8 @@ public class DispatchPdaWorkflowService {
DispatchOrder order = requireOrder(tenantId, dispatchNo);
requirePdaStatus(order);
requireVehicle(order);
String warehouseId = warehouse(request.warehouseId());
requireDispatchWarehouse(order, warehouseId);
DispatchPdaOperation replay = replay(tenantId, "PUSH", request.idempotencyKey(), request);
if (replay != null) return read(replay, DispatchPdaResponses.Task.class);
items.bindReceivedPallets(tenantId, order.getId());
@@ -59,10 +65,12 @@ public class DispatchPdaWorkflowService {
task = new DispatchPdaTask();
task.setId(IdWorker.getId()); task.setTenantId(tenantId); task.setDispatchOrderId(order.getId());
task.setTaskNo("DPDA" + task.getId()); task.setStatus("PDA_PENDING");
task.setWarehouseId(warehouseId);
task.setOperatorId(trim(request.operatorId())); task.setOperatorName(trim(request.operatorName()));
task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); tasks.insert(task);
}
if ("PENDING_DEPARTURE".equals(order.getStatus())) { order.setStatus("PICKING"); order.setUpdatedAt(now); orders.updateById(order); }
if (!warehouseId.equals(task.getWarehouseId())) throw conflict("PDA task warehouse does not match selected warehouse");
DispatchPdaResponses.Task result = task(tenantId, order, task, dispatchItems);
save(tenantId, task.getId(), "PUSH", request.idempotencyKey(), request, result);
return result;
@@ -82,14 +90,18 @@ public class DispatchPdaWorkflowService {
DispatchOrder order = requireOrder(tenantId, dispatchNo);
requirePdaStatus(order);
requireVehicle(order);
String warehouseId = warehouse(request.warehouseId());
requireDispatchWarehouse(order, warehouseId);
DispatchPdaTask task = tasks.lockByDispatch(tenantId, order.getId());
if (task == null || !("PDA_PENDING".equals(task.getStatus()) || "PICKING".equals(task.getStatus()))) {
throw conflict("Dispatch PDA task is not available for picking");
}
if (!warehouseId.equals(task.getWarehouseId())) throw conflict("PDA pick warehouse does not match task warehouse");
DispatchItem item = items.selectById(itemId);
if (item == null || !tenantId.equals(item.getTenantId()) || !order.getId().equals(item.getDispatchOrderId())) {
throw notFound("Dispatch pallet item not found");
}
if (!warehouseId.equals(item.getOverseasWarehouseId())) throw conflict("Pallet does not belong to selected warehouse");
DispatchPdaOperation replay = replay(tenantId, "PICK", request.idempotencyKey(), request);
if (replay != null) return read(replay, DispatchPdaResponses.Picked.class);
InventoryPosition position = positions.selectById(request.positionId());
@@ -101,29 +113,63 @@ public class DispatchPdaWorkflowService {
if (picked + request.packages() > item.getPackageCount()) throw conflict("Picked packages exceed dispatch pallet packages");
String keyBase = "DISPATCH:" + order.getDispatchNo() + ":" + itemId + ":" + request.idempotencyKey();
InventoryCommandRequests.Move move = new InventoryCommandRequests.Move(tenantId, request.positionId(),
request.packages(), request.productQuantity(), order.getDispatchNo(), order.getDispatchNo(), null,
request.packages(), order.getDispatchNo(), order.getDispatchNo(), null,
keyBase + ":ALLOCATE", request.remark());
inventory.allocate(tenantId, operatorId, move);
inventory.pick(tenantId, operatorId, new InventoryCommandRequests.Move(tenantId, request.positionId(),
request.packages(), request.productQuantity(), order.getDispatchNo(), order.getDispatchNo(), null,
request.packages(), order.getDispatchNo(), order.getDispatchNo(), null,
keyBase + ":PICK", request.remark()));
DispatchPdaPick pick = new DispatchPdaPick();
pick.setId(IdWorker.getId()); pick.setTenantId(tenantId); pick.setTaskId(task.getId());
pick.setDispatchItemId(itemId); pick.setInventoryPositionId(request.positionId()); pick.setPackages(request.packages());
pick.setProductQuantity(request.productQuantity()); pick.setIdempotencyKey(request.idempotencyKey());
pick.setPickedAt(LocalDateTime.now()); pick.setCreatedAt(pick.getPickedAt()); picks.insert(pick);
pickAttribution.attribute(tenantId, pick);
long total = picked + request.packages();
order.setPickedPackageCount((int) (n(order.getPickedPackageCount()) + request.packages()));
task.setStatus("PICKING"); task.setUpdatedAt(LocalDateTime.now());
if (allPicked(tenantId, task.getId(), items.findByDispatchOrder(tenantId, order.getId()))) {
task.setStatus("COMPLETED"); task.setCompletedAt(LocalDateTime.now()); order.setStatus("READY_TO_DEPART");
order.setUpdatedAt(LocalDateTime.now()); orders.updateById(order);
}
order.setUpdatedAt(LocalDateTime.now());
orders.updateById(order);
tasks.updateById(task);
DispatchPdaResponses.Picked result = new DispatchPdaResponses.Picked(itemId, request.positionId(), request.packages(), total, false, false);
save(tenantId, task.getId(), "PICK", request.idempotencyKey(), request, result);
return result;
}
@Transactional
public DispatchPdaResponses.PickingCompleted completePicking(String tenantId, String dispatchNo,
DispatchPdaRequests.CompletePicking request) {
DispatchOrder found = requireOrder(tenantId, dispatchNo);
DispatchOrder order = orders.lockById(tenantId, found.getId());
if (order == null) throw notFound("Dispatch order not found");
DispatchPdaOperation replay = replay(tenantId, "COMPLETE_PICKING", request.idempotencyKey(), request);
if (replay != null) return read(replay, DispatchPdaResponses.PickingCompleted.class).withReplay();
requireVehicle(order);
DispatchPdaTask task = tasks.lockByDispatch(tenantId, order.getId());
if (task == null || !("PDA_PENDING".equals(task.getStatus()) || "PICKING".equals(task.getStatus()))) {
throw conflict("Dispatch PDA task is not available for completion");
}
List<DispatchItem> source = items.findByDispatchOrder(tenantId, order.getId());
long planned = source.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull)
.mapToLong(Integer::longValue).sum();
long picked = picks.listTask(tenantId, task.getId()).stream().mapToLong(p -> n(p.getPackages())).sum();
if (picked <= 0 || picked > planned) throw conflict("PDA completion requires a valid picked package count");
boolean partial = picked < planned;
LocalDateTime now = LocalDateTime.now();
task.setStatus("COMPLETED"); task.setCompletedAt(now); task.setUpdatedAt(now); tasks.updateById(task);
order.setStatus(partial ? "PARTIAL_READY_TO_DEPART" : "READY_TO_DEPART");
order.setPartialCompletionReason(partial ? request.reason().trim() : null);
order.setPickedPackageCount((int) picked);
order.setUpdatedAt(now); orders.updateById(order);
DispatchPdaResponses.PickingCompleted result = new DispatchPdaResponses.PickingCompleted(order.getDispatchNo(),
order.getStatus(), planned, picked, planned - picked, partial, false);
save(tenantId, task.getId(), "COMPLETE_PICKING", request.idempotencyKey(), request, result);
return result;
}
/** Final warehouse exit: only fully PDA-picked dispatches can consume picked inventory. */
@Transactional
public DispatchPdaResponses.Departed depart(String tenantId, String dispatchNo, String operatorId,
@@ -134,13 +180,19 @@ public class DispatchPdaWorkflowService {
DispatchPdaOperation replay = replay(tenantId, "DEPART", request.idempotencyKey(), request);
if (replay != null) return read(replay, DispatchPdaResponses.Departed.class).withReplay();
requireVehicle(order);
OutboundOrder outbound = outboundOrders.lockByDispatch(tenantId, order.getId());
if (outbound == null || !"WAIT_OUTBOUND".equals(outbound.getStatus())) {
throw conflict("Dispatch must be transferred to a pending outbound order before departure");
}
DispatchPdaTask task = tasks.lockByDispatch(tenantId, order.getId());
if (task == null || !"COMPLETED".equals(task.getStatus()) || !"READY_TO_DEPART".equals(order.getStatus())) {
if (task == null || !"COMPLETED".equals(task.getStatus())
|| !("READY_TO_DEPART".equals(order.getStatus()) || "PARTIAL_READY_TO_DEPART".equals(order.getStatus()))) {
throw conflict("Dispatch must complete PDA picking before departure");
}
List<DispatchItem> dispatchItems = items.findByDispatchOrder(tenantId, order.getId());
if (!allPicked(tenantId, task.getId(), dispatchItems)) {
boolean partial = "PARTIAL_READY_TO_DEPART".equals(order.getStatus());
if (!partial && !allPicked(tenantId, task.getId(), dispatchItems)) {
throw conflict("Every dispatch pallet must be fully picked before departure");
}
List<DispatchPdaPick> dispatchPicks = picks.listTask(tenantId, task.getId());
@@ -149,12 +201,17 @@ public class DispatchPdaWorkflowService {
for (DispatchPdaPick pick : dispatchPicks) {
String key = "DISPATCH:" + order.getDispatchNo() + ":PICK:" + pick.getId() + ":SHIP";
inventory.ship(tenantId, operatorId, new InventoryCommandRequests.Move(tenantId,
pick.getInventoryPositionId(), n(pick.getPackages()), n(pick.getProductQuantity()),
pick.getInventoryPositionId(), n(pick.getPackages()),
order.getDispatchNo(), order.getDispatchNo(), null, key, request.remark()));
shipped += n(pick.getPackages());
}
LocalDateTime now = LocalDateTime.now();
order.setStatus("DEPARTED");
pickAttribution.depart(tenantId, outbound.getOutboundNo(), order.getDispatchNo(), dispatchPicks, now);
outbound.setStatus("OUTBOUNDED");
outbound.setUpdatedAt(now);
outboundOrders.updateById(outbound);
order.setShippedPackageCount((int) shipped);
order.setStatus(partial ? "PARTIAL_DEPARTED" : "DEPARTED");
order.setUpdatedAt(now);
orders.updateById(order);
releasePalletLocks(tenantId, order.getId(), now);
@@ -172,10 +229,10 @@ public class DispatchPdaWorkflowService {
values.add(new DispatchPdaResponses.Item(item.getId(), item.getPalletNo(), item.getDestinationWarehouseCode(),
item.getDestinationWarehouseName(), item.getFbaWarehouseCode(), item.getPackageCount(),
picks.totalItem(tenantId, task.getId(), item.getId()), palletPositions.stream().map(p ->
new DispatchPdaResponses.Position(p.getId(), p.getSkuCode(), n(p.getAvailablePackages()), n(p.getAllocatedPackages()),
n(p.getPickedPackages()), n(p.getProductQuantity()))).toList()));
new DispatchPdaResponses.Position(p.getId(), p.getBalanceId(), n(p.getAvailablePackages()),
n(p.getAllocatedPackages()), n(p.getPickedPackages()))).toList()));
}
return new DispatchPdaResponses.Task(task.getId(), task.getTaskNo(), order.getDispatchNo(), order.getVehiclePlate(), task.getStatus(), values);
return new DispatchPdaResponses.Task(task.getId(), task.getTaskNo(), order.getDispatchNo(), order.getVehiclePlate(), task.getWarehouseId(), task.getStatus(), values);
}
private boolean allPicked(String tenantId, Long taskId, List<DispatchItem> source) {
@@ -200,12 +257,19 @@ public class DispatchPdaWorkflowService {
|| "READY_TO_DEPART".equals(order.getStatus()))) throw conflict("Dispatch has already departed and cannot be changed by PDA");
}
private void requireVehicle(DispatchOrder order) { if (trim(order.getVehiclePlate()) == null) throw conflict("Vehicle plate is required before PDA push or picking"); }
private static String warehouse(String value) { return required(value, "Warehouse selection is required for PDA work"); }
private static void requireDispatchWarehouse(DispatchOrder order, String warehouseId) {
if (trim(order.getOverseasWarehouseId()) == null || !warehouseId.equals(order.getOverseasWarehouseId().trim())) {
throw conflict("Selected warehouse does not match dispatch warehouse");
}
}
private DispatchPdaOperation replay(String tenantId, String type, String key, Object request) { DispatchPdaOperation op = operations.findKey(tenantId, required(key, "Idempotency key is required")); if (op == null) return null; if (!type.equals(op.getOperationType()) || !hash(request).equals(op.getRequestHash())) throw conflict("Idempotency key was reused with a different request"); if (!"COMPLETED".equals(op.getStatus())) throw conflict("PDA operation is still processing"); return op; }
private void save(String tenantId, Long taskId, String type, String key, Object request, Object result) { DispatchPdaOperation op = new DispatchPdaOperation(); op.setId(IdWorker.getId()); op.setTenantId(tenantId); op.setTaskId(taskId); op.setOperationType(type); op.setIdempotencyKey(required(key,"Idempotency key is required")); op.setRequestHash(hash(request)); op.setResultJson(json(result)); op.setStatus("COMPLETED"); op.setCreatedAt(LocalDateTime.now()); op.setUpdatedAt(op.getCreatedAt()); operations.insert(op); }
private <T> T read(DispatchPdaOperation op, Class<T> type) { try { return objectMapper.readValue(op.getResultJson(), type); } catch (Exception e) { throw new IllegalStateException("Cannot read PDA replay", e); } }
private String hash(Object value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); } catch (Exception e) { throw new IllegalStateException(e); } }
private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { throw new IllegalStateException(e); } }
private static long n(Long value) { return value == null ? 0 : value; }
private static long n(Integer value) { return value == null ? 0 : value; }
private static String trim(String value) { return value == null || value.trim().isEmpty() ? null : value.trim(); }
private static String required(String value, String message) { String result = trim(value); if (result == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message); return result; }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
@@ -0,0 +1,105 @@
package com.baiyue.lastleg.dispatch.pda.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPick;
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPickOrder;
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
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.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Service
public class DispatchPickOrderAttributionService {
@Resource private DispatchPdaPickOrderMapper attributions;
@Resource private LastlegOrderMapper orders;
@Resource private LastlegOrderStatusHistoryMapper history;
/** Call after the inventory position has been locked/moved and the PDA pick row has been inserted. */
public List<OrderPackages> attribute(String tenantId, DispatchPdaPick pick) {
long remainingPackages = value(pick.getPackages());
long totalPackages = remainingPackages;
long totalQuantity = value(pick.getProductQuantity());
long allocatedPackages = 0;
long allocatedQuantity = 0;
Map<Long, Long> pickedByOrder = new LinkedHashMap<>();
for (InboundStockReceipt receipt : attributions.availableReceipts(tenantId,
pick.getInventoryPositionId())) {
if (remainingPackages == 0) break;
long receiptPackages = value(receipt.getReceivedPackages())
- attributions.attributedPackages(tenantId, receipt.getId());
if (receiptPackages <= 0) continue;
long packages = Math.min(remainingPackages, receiptPackages);
long nextPackages = allocatedPackages + packages;
long nextQuantity = totalPackages == 0 ? 0 : Math.floorDiv(totalQuantity * nextPackages, totalPackages);
long quantity = nextQuantity - allocatedQuantity;
DispatchPdaPickOrder row = new DispatchPdaPickOrder();
row.setId(IdWorker.getId());
row.setTenantId(tenantId);
row.setPickId(pick.getId());
row.setReceiptId(receipt.getId());
row.setOrderId(receipt.getOrderId());
row.setPackages(packages);
row.setProductQuantity(quantity);
row.setCreatedAt(pick.getPickedAt() == null ? LocalDateTime.now() : pick.getPickedAt());
attributions.insert(row);
pickedByOrder.merge(receipt.getOrderId(), packages, Long::sum);
remainingPackages -= packages;
allocatedPackages = nextPackages;
allocatedQuantity = nextQuantity;
}
if (remainingPackages != 0) throw conflict("PDA pick cannot be attributed to inbound receipt orders");
for (Map.Entry<Long, Long> entry : pickedByOrder.entrySet()) {
if (orders.increasePicked(tenantId, entry.getKey(), entry.getValue()) != 1) {
throw conflict("Picked packages exceed tail-leg order remaining packages");
}
}
return pickedByOrder.entrySet().stream().map(e -> new OrderPackages(e.getKey(), e.getValue())).toList();
}
/** Call once for each departing PDA pick; repeated dispatch departure is protected by its outer idempotency key. */
public List<OrderPackages> depart(String tenantId, String outboundNo, String dispatchNo, List<DispatchPdaPick> picks,
LocalDateTime departedAt) {
Map<Long, Long> shippedByOrder = new LinkedHashMap<>();
for (DispatchPdaPick pick : picks) {
for (DispatchPdaPickOrder row : attributions.listPick(tenantId, pick.getId())) {
shippedByOrder.merge(row.getOrderId(), value(row.getPackages()), Long::sum);
}
}
for (Map.Entry<Long, Long> entry : shippedByOrder.entrySet()) {
LastlegOrder before = orders.lockById(tenantId, entry.getKey());
if (before == null || orders.transferPickedToShipped(tenantId, entry.getKey(), entry.getValue(), outboundNo) != 1) {
throw conflict("Tail-leg order picked packages are inconsistent");
}
long shipped = value(before.getShippedPackageCount()) + entry.getValue();
String to = shipped == value(before.getPackageCount()) ? "COMPLETED" : "PARTIALLY_SHIPPED";
LastlegOrderStatusHistory row = new LastlegOrderStatusHistory();
row.setOrderId(entry.getKey());
row.setFromStatus(before.getCurrentStatus());
row.setToStatus(to);
row.setEventId("DISPATCH_DEPART:" + dispatchNo);
row.setRemark("Dispatch departed " + entry.getValue() + " packages; cumulative shipped "
+ shipped + "/" + value(before.getPackageCount()));
row.setCreatedAt(departedAt);
history.insert(row);
}
return shippedByOrder.entrySet().stream().map(e -> new OrderPackages(e.getKey(), e.getValue())).toList();
}
public record OrderPackages(Long orderId, long packages) {}
private static long value(Long value) { return value == null ? 0 : value; }
private static long value(Integer value) { return value == null ? 0 : value; }
private static ResponseStatusException conflict(String message) {
return new ResponseStatusException(HttpStatus.CONFLICT, message);
}
}
@@ -36,7 +36,7 @@ public class DispatchService {
public static final String PENDING_DEPARTURE = "PENDING_DEPARTURE";
public static final String CANCELLED = "CANCELLED";
private static final Set<String> STATUSES = Set.of(PENDING_DEPARTURE, "PICKING", "READY_TO_DEPART",
"DEPARTED", "COMPLETED", CANCELLED);
"PARTIAL_READY_TO_DEPART", "DEPARTED", "PARTIAL_DEPARTED", "COMPLETED", CANCELLED);
@Resource private DispatchOrderMapper orders;
@Resource private DispatchItemMapper items;
@@ -61,6 +61,14 @@ public class DispatchService {
if (selected.size() != palletIds.size()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more selected pallets are not in this tenant");
}
if (selected.stream().anyMatch(candidate -> candidate.packageCount() == null || candidate.packageCount() <= 0)) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Selected pallet has no remaining packages to dispatch");
}
Set<String> warehouseIds = selected.stream().map(DispatchPalletCandidate::overseasWarehouseId)
.filter(Objects::nonNull).map(String::trim).filter(value -> !value.isEmpty()).collect(Collectors.toSet());
if (warehouseIds.size() != 1 || selected.stream().anyMatch(candidate -> candidate.overseasWarehouseId() == null)) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "A dispatch must belong to exactly one overseas warehouse");
}
for (DispatchPalletCandidate candidate : selected) {
boolean planLocked = candidate.planPalletId() != null
&& locks.lockActive(tenantId, "PLAN:" + candidate.planPalletId()) != null;
@@ -77,6 +85,8 @@ public class DispatchService {
dispatch.setId(IdWorker.getId());
dispatch.setTenantId(tenantId);
dispatch.setDispatchNo("PC" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + dispatch.getId());
dispatch.setOverseasWarehouseId(selected.get(0).overseasWarehouseId());
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()));
@@ -86,6 +96,8 @@ public class DispatchService {
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);
@@ -119,6 +131,8 @@ public class DispatchService {
item.setPlanPalletId(candidate.planPalletId());
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());
@@ -216,12 +230,18 @@ public class DispatchService {
String orderNos = itemList.stream().flatMap(item -> splitOrderRefs(item.getOrderRefs(), item.getOrderNo()).stream())
.distinct().collect(Collectors.joining(","));
return new DispatchResponse(order.getId(), order.getDispatchNo(), order.getVehiclePlate(), order.getDriverName(),
order.getDriverPhone(), order.getCarrierName(), orderNos, order.getRoute(), order.getDepartAt(),
order.getDriverPhone(), order.getCarrierName(), orderNos, order.getRoute(),
order.getOverseasWarehouseId(), order.getOverseasWarehouseName(), order.getDepartAt(),
order.getFbaAppointmentNo(), order.getFbaAppointmentStatus(), order.getVehicleAppointmentStatus(),
order.getEstimatedArrivalAt(), order.getStatus(), Objects.requireNonNullElse(order.getItemCount(), 0),
Objects.requireNonNullElse(order.getPalletCount(), 0), Objects.requireNonNullElse(order.getPackageCount(), 0),
Objects.requireNonNullElse(order.getPickedPackageCount(), 0),
Objects.requireNonNullElse(order.getShippedPackageCount(), 0),
Math.max(0, Objects.requireNonNullElse(order.getPackageCount(), 0)
- Objects.requireNonNullElse(order.getShippedPackageCount(), 0)), order.getPartialCompletionReason(),
order.getCreatedAt(), itemList.stream().map(item -> new DispatchResponse.Item(item.getId(), item.getPlanPalletId(),
item.getWarehousePalletId(), item.getPalletNo(), item.getOrderNo(), item.getOrderRefs(), item.getInboundCode(),
item.getWarehousePalletId(), item.getPalletNo(), item.getOverseasWarehouseId(), item.getOverseasWarehouseName(),
item.getOrderNo(), item.getOrderRefs(), item.getInboundCode(),
item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), item.getFbaWarehouseCode(),
Objects.requireNonNullElse(item.getPackageCount(), 0), item.getVolumeCbm(), item.getSourceStage())).toList());
}
@@ -26,6 +26,9 @@ public record LastlegOrderSummary(
Boolean detained,
Integer palletCount,
Integer packageCount,
Integer pickedPackageCount,
Integer shippedPackageCount,
Integer remainingPackageCount,
BigDecimal volumeCbm,
Integer productQuantity,
BigDecimal volumeWeightKg,
@@ -35,6 +35,8 @@ public class LastlegOrder {
private Boolean detained;
private Integer palletCount;
private Integer packageCount;
private Integer pickedPackageCount;
private Integer shippedPackageCount;
private BigDecimal volumeCbm;
private Integer productQuantity;
private BigDecimal volumeWeightKg;
@@ -7,7 +7,7 @@ import lombok.Data;
import java.time.LocalDateTime;
/** Immutable SKU/pallet receipt created when a PDA inbound task completes. */
/** Immutable inbound-order/pallet package receipt created when a PDA task completes. */
@Data
@TableName("ll_inbound_stock_receipt")
public class InboundStockReceipt {
@@ -20,16 +20,12 @@ public class InboundStockReceipt {
private Long lineId;
private Long orderId;
private String inboundCode;
private String skuCode;
private String skuNameZh;
private String sourceDetailIds;
private Long warehousePalletId;
private String palletNo;
private Long warehouseLocationId;
private String warehouseLocationCode;
private String batchNo;
private Long receivedPackages;
private Long receivedQuantity;
private Long inventoryBalanceId;
private Long inventoryPositionId;
private String inventoryOperationKey;
@@ -9,6 +9,6 @@ import java.util.List;
public interface InboundStockReceiptMapper extends BaseMapper<InboundStockReceipt> {
@Select("SELECT * FROM ll_inbound_stock_receipt WHERE tenant_id=#{tenantId} AND task_id=#{taskId} " +
"ORDER BY allocation_id,sku_code")
"ORDER BY allocation_id")
List<InboundStockReceipt> listTask(@Param("tenantId") String tenantId, @Param("taskId") Long taskId);
}
@@ -1,9 +1,7 @@
package com.baiyue.lastleg.inboundplan.pda.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
@@ -14,138 +12,78 @@ import com.baiyue.lastleg.inboundplan.pda.mapper.InboundStockReceiptMapper;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
/** Converts final PDA package counts into inbound-order/pallet inventory. */
@Service
public class InboundStockReceiptService {
@Resource private LastlegOrderMapper orders;
@Resource private LastlegOrderDetailMapper details;
@Resource private InboundStockReceiptMapper receipts;
@Resource private InventoryCommandService inventory;
@Transactional
public void stockIn(String tenantId, InboundPlan plan, InboundPdaTask task,
List<InboundPlanLine> lines, List<InboundPlanPalletAllocation> allocations,
List<InboundPlanPallet> pallets, LocalDateTime receivedAt) {
if (!receipts.listTask(tenantId, task.getId()).isEmpty()) return;
var requestedOrderIds = lines.stream().map(InboundPlanLine::getOrderId).collect(Collectors.toSet());
Map<Long, LastlegOrder> orderById = orders.selectBatchIds(requestedOrderIds).stream()
Map<Long, LastlegOrder> orderById = orders.selectBatchIds(lines.stream()
.map(InboundPlanLine::getOrderId).collect(Collectors.toSet())).stream()
.filter(order -> tenantId.equals(order.getTenantId()))
.collect(Collectors.toMap(LastlegOrder::getId, order -> order));
if (orderById.size() != requestedOrderIds.size()) {
throw conflict("Inbound line tail order is missing or outside the tenant");
}
Map<Long, List<LastlegOrderDetail>> detailsByOrder = details.selectList(
new LambdaQueryWrapper<LastlegOrderDetail>().in(LastlegOrderDetail::getOrderId, orderById.keySet()))
.stream().collect(Collectors.groupingBy(LastlegOrderDetail::getOrderId));
Map<Long, InboundPlanPallet> palletById = pallets.stream()
.collect(Collectors.toMap(InboundPlanPallet::getId, pallet -> pallet));
Map<Long, List<InboundPlanPalletAllocation>> allocationsByLine = allocations.stream()
.filter(allocation -> value(allocation.getPickedPackageCount()) > 0)
.sorted(Comparator.comparing(InboundPlanPalletAllocation::getId))
.collect(Collectors.groupingBy(InboundPlanPalletAllocation::getLineId,
LinkedHashMap::new, Collectors.toList()));
.collect(Collectors.groupingBy(InboundPlanPalletAllocation::getLineId));
for (InboundPlanLine line : lines) {
LastlegOrder order = orderById.get(line.getOrderId());
if (order == null) throw conflict("Inbound line tail order is missing or outside the tenant");
List<InboundPlanPalletAllocation> lineAllocations = allocationsByLine
.getOrDefault(line.getId(), List.of());
List<SkuReceipt> skuReceipts = skuReceipts(line, detailsByOrder.getOrDefault(order.getId(), List.of()));
if (lineAllocations.stream().map(InboundPlanPalletAllocation::getPalletId).distinct().count() > 1
&& skuReceipts.size() > 1) {
throw conflict("Multi-SKU inbound lines split across pallets require SKU-level PDA receipt counts");
long pickedPackages = lineAllocations.stream()
.mapToLong(allocation -> value(allocation.getPickedPackageCount())).sum();
if (pickedPackages != value(line.getRequiredPackageCount())) {
throw conflict("PDA picked packages must equal the inbound order package total");
}
if (skuReceipts.size() > 1) {
if (lineAllocations.size() != 1) {
throw conflict("Multi-SKU inbound lines require one unambiguous pallet allocation");
}
InboundPlanPalletAllocation allocation = lineAllocations.get(0);
InboundPlanPallet pallet = requirePallet(palletById, allocation);
for (SkuReceipt sku : skuReceipts) {
persist(tenantId, plan, task, line, allocation, pallet, order, sku,
sku.packages(), sku.quantity(), receivedAt);
}
continue;
}
SkuReceipt sku = skuReceipts.get(0);
long cumulativePackages = 0;
for (InboundPlanPalletAllocation allocation : lineAllocations) {
long packages = value(allocation.getPickedPackageCount());
long nextPackages = cumulativePackages + packages;
long quantity = proportionalCumulative(sku.quantity(), nextPackages, sku.packages())
- proportionalCumulative(sku.quantity(), cumulativePackages, sku.packages());
persist(tenantId, plan, task, line, allocation, requirePallet(palletById, allocation),
order, sku, packages, quantity, receivedAt);
cumulativePackages = nextPackages;
}
if (cumulativePackages != sku.packages()) {
throw conflict("PDA picked packages do not conserve the frozen SKU package total");
persist(tenantId, plan, task, line, allocation,
requirePallet(palletById, allocation), order, receivedAt);
}
}
}
private List<SkuReceipt> skuReceipts(InboundPlanLine line, List<LastlegOrderDetail> source) {
if (source.isEmpty()) throw conflict("Inbound line has no frozen SKU details");
Map<String, MutableSku> grouped = new LinkedHashMap<>();
long frozenPackages = 0;
for (LastlegOrderDetail detail : source) {
String skuCode = required(detail.getSkuCode(), "Frozen SKU code is required");
long packages = value(detail.getPackageCount());
if (packages < 0) throw conflict("Frozen SKU package count cannot be negative");
frozenPackages += packages;
if (packages == 0) continue;
MutableSku sku = grouped.computeIfAbsent(skuCode, ignored -> new MutableSku(skuCode));
sku.packages += packages;
sku.quantity += value(detail.getProductQuantity());
sku.nameZh = first(sku.nameZh, detail.getProductNameZh());
sku.detailIds.add(detail.getId());
}
if (frozenPackages != value(line.getRequiredPackageCount())) {
throw conflict("Frozen SKU package totals must equal the inbound line required packages");
}
if (grouped.isEmpty()) throw conflict("Inbound line has no positive frozen SKU packages");
return grouped.values().stream().map(MutableSku::snapshot).toList();
}
private void persist(String tenantId, InboundPlan plan, InboundPdaTask task, InboundPlanLine line,
InboundPlanPalletAllocation allocation, InboundPlanPallet pallet,
LastlegOrder order, SkuReceipt sku, long packages, long quantity,
LocalDateTime receivedAt) {
LastlegOrder order, LocalDateTime receivedAt) {
long packages = value(allocation.getPickedPackageCount());
if (packages <= 0 || pallet.getWarehousePalletId() == null) {
throw conflict("Stock-in requires a positive package count and an assigned warehouse pallet");
throw conflict("Stock-in requires picked packages and an assigned warehouse pallet");
}
String destinationCode = required(first(line.getDestinationWarehouseCode(), order.getFbaWarehouseCode()),
"Destination warehouse code is required for stock-in");
String operationKey = "PDA_STOCK_IN:" + task.getId() + ":" + allocation.getId();
String overseasWarehouseId = required(order.getOverseasWarehouseId(),
"Actual overseas warehouse ID is required for stock-in");
String palletNo = required(pallet.getPalletNo(), "Pallet number is required for stock-in");
String locationCode = "LOC-" + overseasWarehouseId + "-DEFAULT";
String operationKey = "PDA_STOCK_IN:" + task.getId() + ":" + allocation.getId() + ":" + sku.skuCode();
"Actual overseas warehouse is required for stock-in");
InventoryCommandResponse result = inventory.stockIn(tenantId, task.getOperatorId(),
new InventoryCommandRequests.StockIn(null, order.getCustomerName(), overseasWarehouseId,
null, order.getOverseasWarehouseName(), line.getDestinationWarehouseId(), destinationCode,
line.getDestinationWarehouseName(), sku.skuCode(), sku.nameZh(), pallet.getWarehousePalletId(),
palletNo, plan.getPlanNo(), pallet.getWarehouseLocationId(), locationCode,
packages, quantity, order.getOrderNo(), plan.getPlanNo(), order.getSourceEventId(), operationKey,
line.getDestinationWarehouseName(), order.getId(), plan.getPlanNo(), line.getInboundCode(),
pallet.getWarehousePalletId(), pallet.getPalletNo(), plan.getPlanNo(),
pallet.getWarehouseLocationId(), null, packages, order.getOrderNo(),
order.getSourceEventId(), operationKey,
"PDA inbound completion for " + line.getInboundCode()));
InboundStockReceipt receipt = new InboundStockReceipt();
@@ -157,16 +95,11 @@ public class InboundStockReceiptService {
receipt.setLineId(line.getId());
receipt.setOrderId(order.getId());
receipt.setInboundCode(line.getInboundCode());
receipt.setSkuCode(sku.skuCode());
receipt.setSkuNameZh(sku.nameZh());
receipt.setSourceDetailIds(sku.detailIds());
receipt.setWarehousePalletId(pallet.getWarehousePalletId());
receipt.setPalletNo(palletNo);
receipt.setPalletNo(pallet.getPalletNo());
receipt.setWarehouseLocationId(pallet.getWarehouseLocationId());
receipt.setWarehouseLocationCode(locationCode);
receipt.setBatchNo(plan.getPlanNo());
receipt.setReceivedPackages(packages);
receipt.setReceivedQuantity(quantity);
receipt.setInventoryBalanceId(result.balanceId());
receipt.setInventoryPositionId(result.positionId());
receipt.setInventoryOperationKey(operationKey);
@@ -182,10 +115,6 @@ public class InboundStockReceiptService {
return pallet;
}
private static long proportionalCumulative(long quantity, long packages, long totalPackages) {
return Math.floorDiv(Math.multiplyExact(quantity, packages), totalPackages);
}
private static String required(String value, String message) {
String normalized = trim(value);
if (normalized == null) throw conflict(message);
@@ -202,20 +131,4 @@ public class InboundStockReceiptService {
private static ResponseStatusException conflict(String message) {
return new ResponseStatusException(HttpStatus.CONFLICT, message);
}
private static final class MutableSku {
private final String skuCode;
private String nameZh;
private long packages;
private long quantity;
private final List<Long> detailIds = new ArrayList<>();
private MutableSku(String skuCode) { this.skuCode = skuCode; }
private SkuReceipt snapshot() {
return new SkuReceipt(skuCode, nameZh, packages, quantity,
detailIds.stream().filter(Objects::nonNull).map(String::valueOf).collect(Collectors.joining(",")));
}
}
private record SkuReceipt(String skuCode, String nameZh, long packages, long quantity, String detailIds) {}
}
@@ -19,33 +19,33 @@ import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/platform/v1/last-leg/inventory")
@Tag(name = "Tail-leg SKU package inventory commands")
@Tag(name = "Tail-leg inbound-order package inventory commands")
public class InventoryCommandController {
@Resource private InventoryCommandService service;
@PostMapping("/stock-in")
@Operation(summary = "Put SKU packages into an overseas warehouse pallet")
@Operation(summary = "Put inbound-order packages into an overseas warehouse pallet")
public ApiResponse<InventoryCommandResponse> stockIn(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.StockIn request) {
return ApiResponse.success(service.stockIn(scope(user, request.tenantId()), operator(user), request));
}
@PostMapping("/hold")
@Operation(summary = "Move available SKU packages to hold")
@Operation(summary = "Move available inbound-order packages to hold")
public ApiResponse<InventoryCommandResponse> hold(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) {
return ApiResponse.success(service.hold(scope(user, request.tenantId()), operator(user), request));
}
@PostMapping("/release")
@Operation(summary = "Release held SKU packages")
@Operation(summary = "Release held inbound-order packages")
public ApiResponse<InventoryCommandResponse> release(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) {
return ApiResponse.success(service.release(scope(user, request.tenantId()), operator(user), request));
}
@PostMapping("/allocate")
@Operation(summary = "Allocate available SKU packages to an outbound order")
@Operation(summary = "Allocate available inbound-order packages to an outbound order")
public ApiResponse<InventoryCommandResponse> allocate(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) {
return ApiResponse.success(service.allocate(scope(user, request.tenantId()), operator(user), request));
@@ -59,21 +59,21 @@ public class InventoryCommandController {
}
@PostMapping("/pick")
@Operation(summary = "Confirm picked SKU packages")
@Operation(summary = "Confirm picked inbound-order packages")
public ApiResponse<InventoryCommandResponse> pick(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) {
return ApiResponse.success(service.pick(scope(user, request.tenantId()), operator(user), request));
}
@PostMapping("/ship")
@Operation(summary = "Ship picked SKU packages and report whether the pallet is empty")
@Operation(summary = "Ship picked inbound-order packages and report whether the pallet is empty")
public ApiResponse<InventoryCommandResponse> ship(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) {
return ApiResponse.success(service.ship(scope(user, request.tenantId()), operator(user), request));
}
@PostMapping("/adjust")
@Operation(summary = "Adjust SKU package inventory after a physical count")
@Operation(summary = "Adjust inbound-order package inventory after a physical count")
public ApiResponse<InventoryCommandResponse> adjust(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Adjustment request) {
return ApiResponse.success(service.adjust(scope(user, request.tenantId()), operator(user), request));
@@ -3,7 +3,6 @@ package com.baiyue.lastleg.inventory.command.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
public final class InventoryCommandRequests {
@@ -19,17 +18,16 @@ public final class InventoryCommandRequests {
String destinationWarehouseId,
@NotBlank String destinationWarehouseCode,
String destinationWarehouseName,
@NotBlank String skuCode,
String skuNameZh,
@NotNull Long inboundOrderId,
@NotBlank String inboundOrderNo,
@NotBlank String inboundCode,
@NotNull Long palletId,
@NotBlank String palletNo,
String batchNo,
Long warehouseLocationId,
String warehouseLocationCode,
@Positive long packages,
@PositiveOrZero long productQuantity,
String businessOrderNo,
String inboundOrderNo,
String sourceEventId,
@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark
@@ -40,7 +38,6 @@ public final class InventoryCommandRequests {
String tenantId,
@NotNull Long positionId,
@Positive long packages,
@PositiveOrZero long productQuantity,
String businessOrderNo,
String outboundOrderNo,
String sourceEventId,
@@ -53,7 +50,6 @@ public final class InventoryCommandRequests {
String tenantId,
@NotNull Long positionId,
long packageDelta,
long quantityDelta,
@NotBlank String reason,
String businessOrderNo,
@NotBlank @Size(max = 255) String idempotencyKey,
@@ -46,21 +46,18 @@ public class InventoryCommandService {
InventoryPosition position = position(tenantId, balance, request);
long before = value(position.getAvailablePackages());
position.setAvailablePackages(before + request.packages());
position.setProductQuantity(value(position.getProductQuantity()) + request.productQuantity());
position.setStatus("OCCUPIED");
position.setVersion(value(position.getVersion()) + 1);
position.setUpdatedAt(LocalDateTime.now());
positions.updateById(position);
balance.setAvailablePackages(value(balance.getAvailablePackages()) + request.packages());
balance.setAvailableQuantity(value(balance.getAvailableQuantity()) + request.productQuantity());
touch(balance);
balances.updateById(balance);
ledger(tenantId, operatorId, operation.getId(), balance, position, "STOCK_IN",
"OUTSIDE", "AVAILABLE", before, request.packages(), before + request.packages(),
request.businessOrderNo(), request.inboundOrderNo(), null, request.sourceEventId(),
request.idempotencyKey(), value(balance.getAvailableQuantity()) - request.productQuantity(),
request.productQuantity(), value(balance.getAvailableQuantity()), request.remark());
request.idempotencyKey(), request.remark());
return response(operation, balance, position, false);
});
}
@@ -97,20 +94,15 @@ public class InventoryCommandService {
InventoryPosition position = locked.position();
InventoryBalance balance = locked.balance();
requireAtLeast(value(position.getPickedPackages()), request.packages(), "picked");
requireAtLeast(value(position.getProductQuantity()), request.productQuantity(), "product quantity");
requireAtLeast(value(balance.getPickedPackages()), request.packages(), "balance picked");
requireAtLeast(value(balance.getPickedQuantity()), request.productQuantity(), "balance picked quantity");
long before = value(position.getPickedPackages());
long beforeQuantity = value(balance.getPickedQuantity());
position.setPickedPackages(before - request.packages());
position.setProductQuantity(value(position.getProductQuantity()) - request.productQuantity());
position.setStatus(positionPackages(position) == 0 ? "EMPTY" : "OCCUPIED");
position.setVersion(value(position.getVersion()) + 1);
position.setUpdatedAt(LocalDateTime.now());
positions.updateById(position);
balance.setPickedPackages(value(balance.getPickedPackages()) - request.packages());
balance.setPickedQuantity(value(balance.getPickedQuantity()) - request.productQuantity());
touch(balance);
balances.updateById(balance);
boolean palletEmpty = commands.palletPackages(tenantId, position.getPalletId()) == 0;
@@ -118,8 +110,7 @@ public class InventoryCommandService {
ledger(tenantId, operatorId, operation.getId(), balance, position, "SHIP",
"PICKED", "SHIPPED", before, -request.packages(), before - request.packages(),
request.businessOrderNo(), null, request.outboundOrderNo(), request.sourceEventId(),
request.idempotencyKey(), beforeQuantity, -request.productQuantity(),
value(balance.getPickedQuantity()), request.remark());
request.idempotencyKey(), request.remark());
return response(operation, balance, position, palletEmpty);
});
}
@@ -127,8 +118,8 @@ public class InventoryCommandService {
@Transactional
public InventoryCommandResponse adjust(String tenantId, String operatorId,
InventoryCommandRequests.Adjustment request) {
if (request.packageDelta() == 0 && request.quantityDelta() == 0) {
throw badRequest("Adjustment package and quantity deltas cannot both be zero");
if (request.packageDelta() == 0) {
throw badRequest("Adjustment package delta cannot be zero");
}
return execute(tenantId, operatorId, "ADJUST", request.idempotencyKey(), request, operation -> {
Locked locked = locked(tenantId, request.positionId());
@@ -136,27 +127,21 @@ public class InventoryCommandService {
InventoryBalance balance = locked.balance();
long before = value(position.getAvailablePackages());
long after = before + request.packageDelta();
long quantityAfter = value(position.getProductQuantity()) + request.quantityDelta();
long balanceAfter = value(balance.getAvailablePackages()) + request.packageDelta();
long balanceQuantityBefore = value(balance.getAvailableQuantity());
long balanceQuantityAfter = balanceQuantityBefore + request.quantityDelta();
if (after < 0 || quantityAfter < 0 || balanceAfter < 0 || balanceQuantityAfter < 0) {
if (after < 0 || balanceAfter < 0) {
throw conflict("Adjustment would make inventory negative");
}
position.setAvailablePackages(after);
position.setProductQuantity(quantityAfter);
position.setStatus(positionPackages(position) == 0 ? "EMPTY" : "OCCUPIED");
position.setVersion(value(position.getVersion()) + 1);
position.setUpdatedAt(LocalDateTime.now());
positions.updateById(position);
balance.setAvailablePackages(balanceAfter);
balance.setAvailableQuantity(balanceQuantityAfter);
touch(balance);
balances.updateById(balance);
ledger(tenantId, operatorId, operation.getId(), balance, position, "ADJUST",
"AVAILABLE", "AVAILABLE", before, request.packageDelta(), after,
request.businessOrderNo(), null, null, null, request.idempotencyKey(),
balanceQuantityBefore, request.quantityDelta(), balanceQuantityAfter,
request.reason() + remarkSuffix(request.remark()));
return response(operation, balance, position, false);
});
@@ -170,10 +155,7 @@ public class InventoryCommandService {
InventoryBalance balance = locked.balance();
requireAtLeast(packages(position, from), request.packages(), from.name().toLowerCase());
requireAtLeast(packages(balance, from), request.packages(), "balance " + from.name().toLowerCase());
requireAtLeast(quantity(balance, from), request.productQuantity(),
"balance " + from.name().toLowerCase() + " quantity");
long beforeTarget = packages(position, to);
long beforeTargetQuantity = quantity(balance, to);
packages(position, from, packages(position, from) - request.packages());
packages(position, to, beforeTarget + request.packages());
position.setVersion(value(position.getVersion()) + 1);
@@ -182,22 +164,19 @@ public class InventoryCommandService {
packages(balance, from, packages(balance, from) - request.packages());
packages(balance, to, packages(balance, to) + request.packages());
quantity(balance, from, quantity(balance, from) - request.productQuantity());
quantity(balance, to, quantity(balance, to) + request.productQuantity());
touch(balance);
balances.updateById(balance);
ledger(tenantId, operatorId, operation.getId(), balance, position, operationType,
from.name(), to.name(), beforeTarget, request.packages(), beforeTarget + request.packages(),
request.businessOrderNo(), null, request.outboundOrderNo(), request.sourceEventId(),
request.idempotencyKey(), beforeTargetQuantity, request.productQuantity(),
quantity(balance, to), request.remark());
request.idempotencyKey(), request.remark());
return response(operation, balance, position, false);
});
}
private InventoryBalance balance(String tenantId, InventoryCommandRequests.StockIn request) {
InventoryBalance balance = balances.lockDimension(tenantId, request.overseasWarehouseId(),
request.destinationWarehouseCode(), request.skuCode());
request.destinationWarehouseCode(), request.inboundOrderId(), request.inboundCode());
if (balance != null) return balance;
balance = new InventoryBalance();
balance.setId(IdWorker.getId());
@@ -209,8 +188,9 @@ public class InventoryCommandService {
balance.setDestinationWarehouseId(trim(request.destinationWarehouseId()));
balance.setDestinationWarehouseCode(request.destinationWarehouseCode().trim());
balance.setDestinationWarehouseName(trim(request.destinationWarehouseName()));
balance.setSkuCode(request.skuCode().trim());
balance.setSkuNameZh(trim(request.skuNameZh()));
balance.setInboundOrderId(request.inboundOrderId());
balance.setInboundOrderNo(request.inboundOrderNo().trim());
balance.setInboundCode(request.inboundCode().trim());
zero(balance);
balance.setVersion(0L);
balance.setCreatedAt(LocalDateTime.now());
@@ -220,7 +200,7 @@ public class InventoryCommandService {
return balance;
} catch (DuplicateKeyException exception) {
InventoryBalance concurrent = balances.lockDimension(tenantId, request.overseasWarehouseId(),
request.destinationWarehouseCode(), request.skuCode());
request.destinationWarehouseCode(), request.inboundOrderId(), request.inboundCode());
if (concurrent != null) return concurrent;
throw exception;
}
@@ -237,7 +217,6 @@ public class InventoryCommandService {
position.setBalanceId(balance.getId());
position.setOverseasWarehouseId(balance.getOverseasWarehouseId());
position.setDestinationWarehouseCode(balance.getDestinationWarehouseCode());
position.setSkuCode(balance.getSkuCode());
position.setPalletId(request.palletId());
position.setPalletNo(request.palletNo().trim());
position.setBatchNo(batchNo);
@@ -250,7 +229,6 @@ public class InventoryCommandService {
position.setAllocatedPackages(0L);
position.setPickedPackages(0L);
position.setReceivingPackages(0L);
position.setProductQuantity(0L);
position.setStatus("OCCUPIED");
position.setVersion(0L);
position.setCreatedAt(LocalDateTime.now());
@@ -323,8 +301,7 @@ public class InventoryCommandService {
InventoryPosition position, String businessType, String from, String to,
long beforePackages, long deltaPackages, long afterPackages,
String businessOrderNo, String inboundOrderNo, String outboundOrderNo,
String sourceEventId, String idempotencyKey, long beforeQuantity,
long deltaQuantity, long afterQuantity, String remark) {
String sourceEventId, String idempotencyKey, String remark) {
InventoryLedger ledger = new InventoryLedger();
ledger.setId(IdWorker.getId());
ledger.setTenantId(tenantId);
@@ -332,14 +309,17 @@ public class InventoryCommandService {
ledger.setPositionId(position.getId());
ledger.setOverseasWarehouseId(balance.getOverseasWarehouseId());
ledger.setDestinationWarehouseCode(balance.getDestinationWarehouseCode());
ledger.setSkuCode(balance.getSkuCode());
ledger.setInboundOrderId(balance.getInboundOrderId());
ledger.setPalletId(position.getPalletId());
ledger.setPalletNo(position.getPalletNo());
ledger.setBatchNo(position.getBatchNo());
ledger.setWarehouseLocationCode(position.getWarehouseLocationCode());
ledger.setBusinessType(businessType);
ledger.setBusinessOrderId(balance.getInboundOrderId());
ledger.setBusinessOrderNo(trim(businessOrderNo));
ledger.setInboundOrderNo(trim(inboundOrderNo));
ledger.setInboundCode(balance.getInboundCode());
ledger.setInboundOrderNo(trim(inboundOrderNo) == null
? balance.getInboundOrderNo() : trim(inboundOrderNo));
ledger.setOutboundOrderNo(trim(outboundOrderNo));
ledger.setSourceEventId(trim(sourceEventId));
ledger.setIdempotencyKey(idempotencyKey + ":" + operationId);
@@ -348,9 +328,6 @@ public class InventoryCommandService {
ledger.setBeforePackages(beforePackages);
ledger.setDeltaPackages(deltaPackages);
ledger.setAfterPackages(afterPackages);
ledger.setBeforeQuantity(beforeQuantity);
ledger.setDeltaQuantity(deltaQuantity);
ledger.setAfterQuantity(afterQuantity);
ledger.setOperatorId(trim(operatorId));
ledger.setRemark(trim(remark));
ledger.setOccurredAt(LocalDateTime.now());
@@ -373,9 +350,7 @@ public class InventoryCommandService {
private static void zero(InventoryBalance balance) {
balance.setAvailablePackages(0L); balance.setHoldPackages(0L); balance.setAllocatedPackages(0L);
balance.setPickedPackages(0L); balance.setInTransitPackages(0L); balance.setReceivingPackages(0L);
balance.setProcurementPackages(0L); balance.setAvailableQuantity(0L); balance.setHoldQuantity(0L);
balance.setAllocatedQuantity(0L); balance.setPickedQuantity(0L); balance.setInTransitQuantity(0L);
balance.setReceivingQuantity(0L); balance.setProcurementQuantity(0L);
balance.setProcurementPackages(0L);
}
private static long positionPackages(InventoryPosition position) {
@@ -420,24 +395,6 @@ public class InventoryCommandService {
}
}
private static long quantity(InventoryBalance balance, Bucket bucket) {
return switch (bucket) {
case AVAILABLE -> value(balance.getAvailableQuantity());
case HOLD -> value(balance.getHoldQuantity());
case ALLOCATED -> value(balance.getAllocatedQuantity());
case PICKED -> value(balance.getPickedQuantity());
};
}
private static void quantity(InventoryBalance balance, Bucket bucket, long value) {
switch (bucket) {
case AVAILABLE -> balance.setAvailableQuantity(value);
case HOLD -> balance.setHoldQuantity(value);
case ALLOCATED -> balance.setAllocatedQuantity(value);
case PICKED -> balance.setPickedQuantity(value);
}
}
private String hash(Object value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
@@ -461,7 +418,7 @@ public class InventoryCommandService {
}
private static void requireAtLeast(long actual, long requested, String field) {
if (actual < requested) throw conflict("Insufficient " + field + " packages or quantity");
if (actual < requested) throw conflict("Insufficient " + field + " packages");
}
private static String required(String value, String message) {
@@ -7,7 +7,7 @@ import lombok.Data;
import java.time.LocalDateTime;
/** Real-time SKU/package balance for one tenant, warehouse, destination and SKU. */
/** Real-time package balance for one inbound order at one overseas warehouse. */
@Data
@TableName("ll_inventory_balance")
public class InventoryBalance {
@@ -21,8 +21,9 @@ public class InventoryBalance {
private String destinationWarehouseId;
private String destinationWarehouseCode;
private String destinationWarehouseName;
private String skuCode;
private String skuNameZh;
private Long inboundOrderId;
private String inboundOrderNo;
private String inboundCode;
private Long availablePackages;
private Long holdPackages;
private Long allocatedPackages;
@@ -30,13 +31,6 @@ public class InventoryBalance {
private Long inTransitPackages;
private Long receivingPackages;
private Long procurementPackages;
private Long availableQuantity;
private Long holdQuantity;
private Long allocatedQuantity;
private Long pickedQuantity;
private Long inTransitQuantity;
private Long receivingQuantity;
private Long procurementQuantity;
private Long version;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@@ -18,7 +18,7 @@ public class InventoryLedger {
private Long positionId;
private String overseasWarehouseId;
private String destinationWarehouseCode;
private String skuCode;
private Long inboundOrderId;
private Long palletId;
private String palletNo;
private String batchNo;
@@ -36,9 +36,6 @@ public class InventoryLedger {
private Long beforePackages;
private Long deltaPackages;
private Long afterPackages;
private Long beforeQuantity;
private Long deltaQuantity;
private Long afterQuantity;
private String operatorId;
private String remark;
private LocalDateTime occurredAt;
@@ -17,7 +17,6 @@ public class InventoryPosition {
private Long balanceId;
private String overseasWarehouseId;
private String destinationWarehouseCode;
private String skuCode;
private Long palletId;
private String palletNo;
private String batchNo;
@@ -28,7 +27,6 @@ public class InventoryPosition {
private Long allocatedPackages;
private Long pickedPackages;
private Long receivingPackages;
private Long productQuantity;
private String status;
private Long version;
private LocalDateTime createdAt;
@@ -11,29 +11,33 @@ import java.util.List;
public interface InventoryBalanceMapper extends BaseMapper<InventoryBalance> {
@Select("SELECT * FROM ll_inventory_balance WHERE tenant_id=#{tenantId} " +
"AND overseas_warehouse_id=#{overseasWarehouseId} " +
"AND destination_warehouse_code=#{destinationWarehouseCode} AND sku_code=#{skuCode} LIMIT 1")
"AND destination_warehouse_code=#{destinationWarehouseCode} " +
"AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode} LIMIT 1")
InventoryBalance findDimension(@Param("tenantId") String tenantId,
@Param("overseasWarehouseId") String overseasWarehouseId,
@Param("destinationWarehouseCode") String destinationWarehouseCode,
@Param("skuCode") String skuCode);
@Param("inboundOrderId") Long inboundOrderId,
@Param("inboundCode") String inboundCode);
@Select("SELECT * FROM ll_inventory_balance WHERE tenant_id=#{tenantId} " +
"AND overseas_warehouse_id=#{overseasWarehouseId} " +
"AND destination_warehouse_code=#{destinationWarehouseCode} AND sku_code=#{skuCode} LIMIT 1 FOR UPDATE")
"AND destination_warehouse_code=#{destinationWarehouseCode} " +
"AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode} LIMIT 1 FOR UPDATE")
InventoryBalance lockDimension(@Param("tenantId") String tenantId,
@Param("overseasWarehouseId") String overseasWarehouseId,
@Param("destinationWarehouseCode") String destinationWarehouseCode,
@Param("skuCode") String skuCode);
@Param("inboundOrderId") Long inboundOrderId,
@Param("inboundCode") String inboundCode);
@Select("<script>SELECT * FROM ll_inventory_balance WHERE tenant_id=#{tenantId}" +
"<if test='overseasWarehouseId != null and overseasWarehouseId != \"\"'> AND overseas_warehouse_id=#{overseasWarehouseId}</if>" +
"<if test='destinationWarehouseCode != null and destinationWarehouseCode != \"\"'> AND destination_warehouse_code=#{destinationWarehouseCode}</if>" +
"<if test='skuCode != null and skuCode != \"\"'> AND sku_code=#{skuCode}</if>" +
"<if test='inboundCode != null and inboundCode != \"\"'> AND inbound_code=#{inboundCode}</if>" +
" ORDER BY updated_at DESC,id DESC LIMIT #{limit} OFFSET #{offset}</script>")
List<InventoryBalance> listTenant(@Param("tenantId") String tenantId,
@Param("overseasWarehouseId") String overseasWarehouseId,
@Param("destinationWarehouseCode") String destinationWarehouseCode,
@Param("skuCode") String skuCode,
@Param("inboundCode") String inboundCode,
@Param("offset") long offset,
@Param("limit") int limit);
@@ -41,11 +45,7 @@ public interface InventoryBalanceMapper extends BaseMapper<InventoryBalance> {
"COALESCE(SUM(available_packages),0) available_packages,COALESCE(SUM(hold_packages),0) hold_packages," +
"COALESCE(SUM(allocated_packages),0) allocated_packages,COALESCE(SUM(picked_packages),0) picked_packages," +
"COALESCE(SUM(in_transit_packages),0) in_transit_packages,COALESCE(SUM(receiving_packages),0) receiving_packages," +
"COALESCE(SUM(procurement_packages),0) procurement_packages," +
"COALESCE(SUM(available_quantity),0) available_quantity,COALESCE(SUM(hold_quantity),0) hold_quantity," +
"COALESCE(SUM(allocated_quantity),0) allocated_quantity,COALESCE(SUM(picked_quantity),0) picked_quantity," +
"COALESCE(SUM(in_transit_quantity),0) in_transit_quantity,COALESCE(SUM(receiving_quantity),0) receiving_quantity," +
"COALESCE(SUM(procurement_quantity),0) procurement_quantity FROM ll_inventory_balance WHERE tenant_id=#{tenantId}" +
"COALESCE(SUM(procurement_packages),0) procurement_packages FROM ll_inventory_balance WHERE tenant_id=#{tenantId}" +
"<if test='overseasWarehouseId != null and overseasWarehouseId != \"\"'> AND overseas_warehouse_id=#{overseasWarehouseId}</if></script>")
InventorySummary summarize(@Param("tenantId") String tenantId,
@Param("overseasWarehouseId") String overseasWarehouseId);
@@ -15,7 +15,7 @@ public interface InventoryLedgerMapper extends BaseMapper<InventoryLedger> {
@Select("<script>SELECT * FROM ll_inventory_ledger WHERE tenant_id=#{tenantId}" +
"<if test='overseasWarehouseId != null and overseasWarehouseId != \"\"'> AND overseas_warehouse_id=#{overseasWarehouseId}</if>" +
"<if test='skuCode != null and skuCode != \"\"'> AND sku_code=#{skuCode}</if>" +
"<if test='inboundCode != null and inboundCode != \"\"'> AND inbound_code=#{inboundCode}</if>" +
"<if test='businessType != null and businessType != \"\"'> AND business_type=#{businessType}</if>" +
"<if test='businessOrderNo != null and businessOrderNo != \"\"'> AND business_order_no=#{businessOrderNo}</if>" +
"<if test='startedAt != null'> AND occurred_at &gt;=#{startedAt}</if>" +
@@ -23,7 +23,7 @@ public interface InventoryLedgerMapper extends BaseMapper<InventoryLedger> {
" ORDER BY occurred_at DESC,id DESC LIMIT #{limit} OFFSET #{offset}</script>")
List<InventoryLedger> listTenant(@Param("tenantId") String tenantId,
@Param("overseasWarehouseId") String overseasWarehouseId,
@Param("skuCode") String skuCode,
@Param("inboundCode") String inboundCode,
@Param("businessType") String businessType,
@Param("businessOrderNo") String businessOrderNo,
@Param("startedAt") LocalDateTime startedAt,
@@ -14,7 +14,7 @@ public interface InventoryPositionMapper extends BaseMapper<InventoryPosition> {
@Param("balanceId") Long balanceId);
@Select("SELECT * FROM ll_inventory_position WHERE tenant_id=#{tenantId} AND pallet_id=#{palletId} " +
"ORDER BY sku_code,batch_no")
"ORDER BY balance_id,batch_no")
List<InventoryPosition> listPallet(@Param("tenantId") String tenantId,
@Param("palletId") Long palletId);
@@ -12,11 +12,4 @@ public class InventorySummary {
private Long inTransitPackages;
private Long receivingPackages;
private Long procurementPackages;
private Long availableQuantity;
private Long holdQuantity;
private Long allocatedQuantity;
private Long pickedQuantity;
private Long inTransitQuantity;
private Long receivingQuantity;
private Long procurementQuantity;
}
@@ -35,7 +35,7 @@ public class InventoryQueryController {
@Resource private InventoryQueryService service;
@GetMapping(BASE_PATH + "/summary")
@Operation(summary = "Get SKU/package inventory totals")
@Operation(summary = "Get inbound-order package inventory totals")
public ApiResponse<InventoryDashboardResponse> summary(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String tenantId,
@@ -44,7 +44,7 @@ public class InventoryQueryController {
}
@GetMapping(BASE_PATH + "/balances")
@Operation(summary = "Page inventory by customer, destination warehouse and SKU")
@Operation(summary = "Page inventory by customer, warehouse and inbound order")
public ApiResponse<PageResult<InventoryBalanceView>> balances(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String tenantId,
@@ -53,7 +53,7 @@ public class InventoryQueryController {
}
@GetMapping(BASE_PATH + "/balances/{balanceId}/positions")
@Operation(summary = "List pallet and batch positions for one SKU balance")
@Operation(summary = "List pallet and batch positions for one inbound-order balance")
public ApiResponse<List<InventoryPositionView>> positions(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String tenantId,
@@ -72,7 +72,7 @@ public class InventoryQueryController {
}
@GetMapping(value = BASE_PATH + "/balances/export", produces = "text/csv")
@Operation(summary = "Export filtered SKU inventory as UTF-8 CSV")
@Operation(summary = "Export filtered inbound-order inventory as UTF-8 CSV")
public ResponseEntity<byte[]> exportBalances(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String tenantId,
@@ -12,8 +12,9 @@ public record InventoryBalanceView(
String destinationWarehouseId,
String destinationWarehouseCode,
String destinationWarehouseName,
String skuCode,
String skuName,
Long inboundOrderId,
String inboundOrderNo,
String inboundCode,
long totalPackages,
long availablePackages,
long holdPackages,
@@ -22,14 +23,6 @@ public record InventoryBalanceView(
long inTransitPackages,
long receivingPackages,
long procurementPackages,
long totalQuantity,
long availableQuantity,
long holdQuantity,
long allocatedQuantity,
long pickedQuantity,
long inTransitQuantity,
long receivingQuantity,
long procurementQuantity,
LocalDateTime updatedAt
) {
}
@@ -1,7 +1,7 @@
package com.baiyue.lastleg.inventory.query.dto;
public record InventoryDashboardResponse(
long skuDimensionCount,
long inboundOrderCount,
long palletCount,
long totalPackages,
long availablePackages,
@@ -10,14 +10,6 @@ public record InventoryDashboardResponse(
long pickedPackages,
long inTransitPackages,
long receivingPackages,
long procurementPackages,
long totalQuantity,
long availableQuantity,
long holdQuantity,
long allocatedQuantity,
long pickedQuantity,
long inTransitQuantity,
long receivingQuantity,
long procurementQuantity
long procurementPackages
) {
}
@@ -7,22 +7,20 @@ public record InventoryLedgerView(
String customerId,
String overseasWarehouseId,
String destinationWarehouseCode,
String skuCode,
Long inboundOrderId,
String inboundOrderNo,
String inboundCode,
String palletNo,
String batchNo,
String warehouseLocationCode,
String businessType,
String businessOrderNo,
String inboundOrderNo,
String outboundOrderNo,
String statusFrom,
String statusTo,
long beforePackages,
long deltaPackages,
long afterPackages,
long beforeQuantity,
long deltaQuantity,
long afterQuantity,
String operatorId,
String remark,
LocalDateTime occurredAt
@@ -8,7 +8,9 @@ public record InventoryPositionView(
String customerId,
String overseasWarehouseId,
String destinationWarehouseCode,
String skuCode,
Long inboundOrderId,
String inboundOrderNo,
String inboundCode,
Long palletId,
String palletNo,
String batchNo,
@@ -20,7 +22,6 @@ public record InventoryPositionView(
long allocatedPackages,
long pickedPackages,
long receivingPackages,
long productQuantity,
String status,
LocalDateTime updatedAt
) {
@@ -16,8 +16,9 @@ public final class InventoryQueryRequests {
String overseasWarehouseId,
String overseasWarehouseCode,
String destinationWarehouseCode,
String skuCode,
String skuName,
String inboundOrderNo,
String inboundCode,
String businessOrderNo,
String inventoryStatus
) {
public BalanceQuery {
@@ -34,10 +35,10 @@ public final class InventoryQueryRequests {
@Min(1) @Max(200) long size,
String overseasWarehouseId,
String destinationWarehouseCode,
String skuCode,
String businessType,
String businessOrderNo,
String inboundOrderNo,
String inboundCode,
String outboundOrderNo,
String palletNo,
String batchNo,
@@ -16,8 +16,9 @@ public interface InventoryQueryMapper {
"<if test='q.overseasWarehouseId != null and q.overseasWarehouseId != \"\"'> AND overseas_warehouse_id=#{q.overseasWarehouseId}</if>" +
"<if test='q.overseasWarehouseCode != null and q.overseasWarehouseCode != \"\"'> AND overseas_warehouse_code=#{q.overseasWarehouseCode}</if>" +
"<if test='q.destinationWarehouseCode != null and q.destinationWarehouseCode != \"\"'> AND destination_warehouse_code=#{q.destinationWarehouseCode}</if>" +
"<if test='q.skuCode != null and q.skuCode != \"\"'> AND sku_code LIKE CONCAT('%',#{q.skuCode},'%')</if>" +
"<if test='q.skuName != null and q.skuName != \"\"'> AND sku_name_zh LIKE CONCAT('%',#{q.skuName},'%')</if>" +
"<if test='q.inboundOrderNo != null and q.inboundOrderNo != \"\"'> AND inbound_order_no LIKE CONCAT('%',#{q.inboundOrderNo},'%')</if>" +
"<if test='q.inboundCode != null and q.inboundCode != \"\"'> AND inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')</if>" +
"<if test='q.businessOrderNo != null and q.businessOrderNo != \"\"'> AND EXISTS (SELECT 1 FROM ll_inventory_ledger l WHERE l.balance_id=ll_inventory_balance.id AND l.tenant_id=#{tenantId} AND l.business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%'))</if>" +
"<choose>" +
"<when test='q.inventoryStatus == \"AVAILABLE\"'> AND available_packages &gt; 0</when>" +
"<when test='q.inventoryStatus == \"HOLD\"'> AND hold_packages &gt; 0</when>" +
@@ -33,8 +34,9 @@ public interface InventoryQueryMapper {
"<if test='q.overseasWarehouseId != null and q.overseasWarehouseId != \"\"'> AND b.overseas_warehouse_id=#{q.overseasWarehouseId}</if>" +
"<if test='q.overseasWarehouseCode != null and q.overseasWarehouseCode != \"\"'> AND b.overseas_warehouse_code=#{q.overseasWarehouseCode}</if>" +
"<if test='q.destinationWarehouseCode != null and q.destinationWarehouseCode != \"\"'> AND b.destination_warehouse_code=#{q.destinationWarehouseCode}</if>" +
"<if test='q.skuCode != null and q.skuCode != \"\"'> AND b.sku_code LIKE CONCAT('%',#{q.skuCode},'%')</if>" +
"<if test='q.skuName != null and q.skuName != \"\"'> AND b.sku_name_zh LIKE CONCAT('%',#{q.skuName},'%')</if>" +
"<if test='q.inboundOrderNo != null and q.inboundOrderNo != \"\"'> AND b.inbound_order_no LIKE CONCAT('%',#{q.inboundOrderNo},'%')</if>" +
"<if test='q.inboundCode != null and q.inboundCode != \"\"'> AND b.inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')</if>" +
"<if test='q.businessOrderNo != null and q.businessOrderNo != \"\"'> AND EXISTS (SELECT 1 FROM ll_inventory_ledger l WHERE l.balance_id=b.id AND l.tenant_id=#{tenantId} AND l.business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%'))</if>" +
"<choose>" +
"<when test='q.inventoryStatus == \"AVAILABLE\"'> AND b.available_packages &gt; 0</when>" +
"<when test='q.inventoryStatus == \"HOLD\"'> AND b.hold_packages &gt; 0</when>" +
@@ -63,14 +65,7 @@ public interface InventoryQueryMapper {
"COALESCE(SUM(picked_packages),0) picked_packages," +
"COALESCE(SUM(in_transit_packages),0) in_transit_packages," +
"COALESCE(SUM(receiving_packages),0) receiving_packages," +
"COALESCE(SUM(procurement_packages),0) procurement_packages," +
"COALESCE(SUM(available_quantity),0) available_quantity," +
"COALESCE(SUM(hold_quantity),0) hold_quantity," +
"COALESCE(SUM(allocated_quantity),0) allocated_quantity," +
"COALESCE(SUM(picked_quantity),0) picked_quantity," +
"COALESCE(SUM(in_transit_quantity),0) in_transit_quantity," +
"COALESCE(SUM(receiving_quantity),0) receiving_quantity," +
"COALESCE(SUM(procurement_quantity),0) procurement_quantity " +
"COALESCE(SUM(procurement_packages),0) procurement_packages " +
"FROM ll_inventory_balance" + BALANCE_FILTER + "</script>")
InventoryAggregateProjection aggregate(@Param("tenantId") String tenantId,
@Param("q") InventoryQueryRequests.BalanceQuery query);
@@ -95,16 +90,16 @@ public interface InventoryQueryMapper {
@Param("balanceId") long balanceId,
@Param("q") InventoryQueryRequests.PositionQuery query);
@Select("SELECT COUNT(*) FROM ll_inventory_balance WHERE tenant_id=#{tenantId} AND id=#{balanceId}")
long countOwnedBalance(@Param("tenantId") String tenantId, @Param("balanceId") long balanceId);
@Select("SELECT * FROM ll_inventory_balance WHERE tenant_id=#{tenantId} AND id=#{balanceId} LIMIT 1")
InventoryBalance ownedBalance(@Param("tenantId") String tenantId, @Param("balanceId") long balanceId);
String LEDGER_FILTER = " WHERE tenant_id=#{tenantId}" +
"<if test='q.overseasWarehouseId != null and q.overseasWarehouseId != \"\"'> AND overseas_warehouse_id=#{q.overseasWarehouseId}</if>" +
"<if test='q.destinationWarehouseCode != null and q.destinationWarehouseCode != \"\"'> AND destination_warehouse_code=#{q.destinationWarehouseCode}</if>" +
"<if test='q.skuCode != null and q.skuCode != \"\"'> AND sku_code LIKE CONCAT('%',#{q.skuCode},'%')</if>" +
"<if test='q.businessType != null and q.businessType != \"\"'> AND business_type=#{q.businessType}</if>" +
"<if test='q.businessOrderNo != null and q.businessOrderNo != \"\"'> AND business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%')</if>" +
"<if test='q.inboundOrderNo != null and q.inboundOrderNo != \"\"'> AND inbound_order_no LIKE CONCAT('%',#{q.inboundOrderNo},'%')</if>" +
"<if test='q.inboundCode != null and q.inboundCode != \"\"'> AND inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')</if>" +
"<if test='q.outboundOrderNo != null and q.outboundOrderNo != \"\"'> AND outbound_order_no LIKE CONCAT('%',#{q.outboundOrderNo},'%')</if>" +
"<if test='q.palletNo != null and q.palletNo != \"\"'> AND pallet_no LIKE CONCAT('%',#{q.palletNo},'%')</if>" +
"<if test='q.batchNo != null and q.batchNo != \"\"'> AND batch_no LIKE CONCAT('%',#{q.batchNo},'%')</if>" +
@@ -12,11 +12,4 @@ public class InventoryAggregateProjection {
private Long inTransitPackages;
private Long receivingPackages;
private Long procurementPackages;
private Long availableQuantity;
private Long holdQuantity;
private Long allocatedQuantity;
private Long pickedQuantity;
private Long inTransitQuantity;
private Long receivingQuantity;
private Long procurementQuantity;
}
@@ -41,22 +41,11 @@ public class InventoryQueryService {
long inTransitPackages = number(value.getInTransitPackages());
long receivingPackages = number(value.getReceivingPackages());
long procurementPackages = number(value.getProcurementPackages());
long availableQuantity = number(value.getAvailableQuantity());
long holdQuantity = number(value.getHoldQuantity());
long allocatedQuantity = number(value.getAllocatedQuantity());
long pickedQuantity = number(value.getPickedQuantity());
long inTransitQuantity = number(value.getInTransitQuantity());
long receivingQuantity = number(value.getReceivingQuantity());
long procurementQuantity = number(value.getProcurementQuantity());
return new InventoryDashboardResponse(number(value.getDimensionCount()), mapper.countPallets(tenantId, query),
availablePackages + holdPackages + allocatedPackages + pickedPackages + inTransitPackages
+ receivingPackages + procurementPackages,
availablePackages, holdPackages, allocatedPackages, pickedPackages, inTransitPackages,
receivingPackages, procurementPackages,
availableQuantity + holdQuantity + allocatedQuantity + pickedQuantity + inTransitQuantity
+ receivingQuantity + procurementQuantity,
availableQuantity, holdQuantity, allocatedQuantity, pickedQuantity, inTransitQuantity,
receivingQuantity, procurementQuantity);
receivingPackages, procurementPackages);
}
public PageResult<InventoryBalanceView> balances(String tenantId, InventoryQueryRequests.BalanceQuery query) {
@@ -70,10 +59,11 @@ public class InventoryQueryService {
public List<InventoryPositionView> positions(String tenantId, long balanceId,
InventoryQueryRequests.PositionQuery query) {
validateStatus(query.inventoryStatus(), POSITION_STATUSES);
if (mapper.countOwnedBalance(tenantId, balanceId) == 0) {
InventoryBalance balance = mapper.ownedBalance(tenantId, balanceId);
if (balance == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inventory balance not found");
}
return mapper.positions(tenantId, balanceId, query).stream().map(this::positionView).toList();
return mapper.positions(tenantId, balanceId, query).stream().map(value -> positionView(value, balance)).toList();
}
public PageResult<InventoryLedgerView> ledgers(String tenantId, InventoryQueryRequests.LedgerQuery query) {
@@ -88,30 +78,30 @@ public class InventoryQueryService {
validateStatus(query.inventoryStatus(), BALANCE_STATUSES);
List<String> rows = new ArrayList<>();
rows.add("customerId,customerName,overseasWarehouseCode,overseasWarehouseName,destinationWarehouseCode," +
"destinationWarehouseName,skuCode,skuName,totalPackages,availablePackages,holdPackages," +
"allocatedPackages,pickedPackages,inTransitPackages,receivingPackages,procurementPackages,totalQuantity");
"destinationWarehouseName,inboundOrderId,inboundOrderNo,inboundCode,totalPackages,availablePackages," +
"holdPackages,allocatedPackages,pickedPackages,inTransitPackages,receivingPackages,procurementPackages");
mapper.balances(tenantId, query, 0, EXPORT_LIMIT).stream().map(this::balanceView).forEach(v -> rows.add(
String.join(",", csv(v.customerId()), csv(v.customerName()), csv(v.overseasWarehouseCode()), csv(v.overseasWarehouseName()),
csv(v.destinationWarehouseCode()), csv(v.destinationWarehouseName()), csv(v.skuCode()), csv(v.skuName()),
csv(v.destinationWarehouseCode()), csv(v.destinationWarehouseName()), csv(v.inboundOrderId()),
csv(v.inboundOrderNo()), csv(v.inboundCode()),
csv(v.totalPackages()), csv(v.availablePackages()), csv(v.holdPackages()), csv(v.allocatedPackages()),
csv(v.pickedPackages()), csv(v.inTransitPackages()), csv(v.receivingPackages()),
csv(v.procurementPackages()), csv(v.totalQuantity()))));
csv(v.procurementPackages()))));
return ("\uFEFF" + String.join("\r\n", rows)).getBytes(StandardCharsets.UTF_8);
}
public byte[] exportLedgers(String tenantId, InventoryQueryRequests.LedgerQuery query) {
validateDateRange(query);
List<String> rows = new ArrayList<>();
rows.add("occurredAt,customerId,overseasWarehouseId,destinationWarehouseCode,skuCode,businessType," +
"businessOrderNo,inboundOrderNo,outboundOrderNo,palletNo,batchNo,statusFrom,statusTo," +
"beforePackages,deltaPackages,afterPackages,beforeQuantity,deltaQuantity,afterQuantity,operatorId,remark");
rows.add("occurredAt,customerId,overseasWarehouseId,destinationWarehouseCode,inboundOrderId,inboundOrderNo," +
"inboundCode,businessType,businessOrderNo,outboundOrderNo,palletNo,batchNo,statusFrom,statusTo," +
"beforePackages,deltaPackages,afterPackages,operatorId,remark");
mapper.ledgers(tenantId, query, 0, EXPORT_LIMIT).stream().map(this::ledgerView).forEach(v -> rows.add(
String.join(",", csv(v.occurredAt()), csv(v.customerId()), csv(v.overseasWarehouseId()),
csv(v.destinationWarehouseCode()), csv(v.skuCode()), csv(v.businessType()), csv(v.businessOrderNo()),
csv(v.inboundOrderNo()), csv(v.outboundOrderNo()), csv(v.palletNo()), csv(v.batchNo()),
csv(v.destinationWarehouseCode()), csv(v.inboundOrderId()), csv(v.inboundOrderNo()), csv(v.inboundCode()),
csv(v.businessType()), csv(v.businessOrderNo()), csv(v.outboundOrderNo()), csv(v.palletNo()), csv(v.batchNo()),
csv(v.statusFrom()), csv(v.statusTo()), csv(v.beforePackages()), csv(v.deltaPackages()),
csv(v.afterPackages()), csv(v.beforeQuantity()), csv(v.deltaQuantity()), csv(v.afterQuantity()),
csv(v.operatorId()), csv(v.remark()))));
csv(v.afterPackages()), csv(v.operatorId()), csv(v.remark()))));
return ("\uFEFF" + String.join("\r\n", rows)).getBytes(StandardCharsets.UTF_8);
}
@@ -123,47 +113,37 @@ public class InventoryQueryService {
long inTransitPackages = number(value.getInTransitPackages());
long receivingPackages = number(value.getReceivingPackages());
long procurementPackages = number(value.getProcurementPackages());
long availableQuantity = number(value.getAvailableQuantity());
long holdQuantity = number(value.getHoldQuantity());
long allocatedQuantity = number(value.getAllocatedQuantity());
long pickedQuantity = number(value.getPickedQuantity());
long inTransitQuantity = number(value.getInTransitQuantity());
long receivingQuantity = number(value.getReceivingQuantity());
long procurementQuantity = number(value.getProcurementQuantity());
return new InventoryBalanceView(value.getId(), value.getTenantId(), value.getCustomerName(), value.getOverseasWarehouseId(),
value.getOverseasWarehouseCode(), value.getOverseasWarehouseName(), value.getDestinationWarehouseId(),
value.getDestinationWarehouseCode(), value.getDestinationWarehouseName(), value.getSkuCode(),
value.getSkuNameZh(), availablePackages + holdPackages + allocatedPackages + pickedPackages
value.getDestinationWarehouseCode(), value.getDestinationWarehouseName(), value.getInboundOrderId(),
value.getInboundOrderNo(), value.getInboundCode(), availablePackages + holdPackages + allocatedPackages + pickedPackages
+ inTransitPackages + receivingPackages + procurementPackages,
availablePackages, holdPackages, allocatedPackages, pickedPackages, inTransitPackages,
receivingPackages, procurementPackages,
availableQuantity + holdQuantity + allocatedQuantity + pickedQuantity + inTransitQuantity
+ receivingQuantity + procurementQuantity,
availableQuantity, holdQuantity, allocatedQuantity, pickedQuantity, inTransitQuantity,
receivingQuantity, procurementQuantity, value.getUpdatedAt());
receivingPackages, procurementPackages, value.getUpdatedAt());
}
private InventoryPositionView positionView(InventoryPosition value) {
private InventoryPositionView positionView(InventoryPosition value, InventoryBalance balance) {
long available = number(value.getAvailablePackages());
long hold = number(value.getHoldPackages());
long allocated = number(value.getAllocatedPackages());
long picked = number(value.getPickedPackages());
long receiving = number(value.getReceivingPackages());
return new InventoryPositionView(value.getId(), value.getBalanceId(), value.getTenantId(),
value.getOverseasWarehouseId(), value.getDestinationWarehouseCode(), value.getSkuCode(),
value.getOverseasWarehouseId(), value.getDestinationWarehouseCode(), balance.getInboundOrderId(),
balance.getInboundOrderNo(), balance.getInboundCode(),
value.getPalletId(), value.getPalletNo(), value.getBatchNo(), value.getWarehouseLocationId(),
value.getWarehouseLocationCode(), available + hold + allocated + picked + receiving,
available, hold, allocated, picked, receiving, number(value.getProductQuantity()), value.getStatus(),
available, hold, allocated, picked, receiving, value.getStatus(),
value.getUpdatedAt());
}
private InventoryLedgerView ledgerView(InventoryLedger value) {
return new InventoryLedgerView(value.getId(), value.getTenantId(), value.getOverseasWarehouseId(),
value.getDestinationWarehouseCode(), value.getSkuCode(), value.getPalletNo(), value.getBatchNo(),
value.getDestinationWarehouseCode(), value.getInboundOrderId(), value.getInboundOrderNo(), value.getInboundCode(),
value.getPalletNo(), value.getBatchNo(),
value.getWarehouseLocationCode(), value.getBusinessType(), value.getBusinessOrderNo(),
value.getInboundOrderNo(), value.getOutboundOrderNo(), value.getStatusFrom(), value.getStatusTo(),
value.getOutboundOrderNo(), value.getStatusFrom(), value.getStatusTo(),
number(value.getBeforePackages()), number(value.getDeltaPackages()), number(value.getAfterPackages()),
number(value.getBeforeQuantity()), number(value.getDeltaQuantity()), number(value.getAfterQuantity()),
value.getOperatorId(), value.getRemark(), value.getOccurredAt());
}
@@ -5,6 +5,7 @@ import com.baiyue.lastleg.entity.LastlegOrder;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
@@ -16,4 +17,22 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
@Select("SELECT COUNT(*) FROM ll_order WHERE tenant_id=#{tenantId} AND container_no=#{containerNo}")
long countContainer(@Param("tenantId") String tenantId, @Param("containerNo") String containerNo);
@Select("SELECT * FROM ll_order WHERE tenant_id=#{tenantId} AND id=#{id} FOR UPDATE")
LastlegOrder lockById(@Param("tenantId") String tenantId, @Param("id") Long id);
@Update("UPDATE ll_order SET picked_package_count=picked_package_count+#{packages},updated_at=NOW() " +
"WHERE tenant_id=#{tenantId} AND id=#{id} " +
"AND package_count-shipped_package_count-picked_package_count >= #{packages}")
int increasePicked(@Param("tenantId") String tenantId, @Param("id") Long id,
@Param("packages") long packages);
@Update("UPDATE ll_order SET picked_package_count=picked_package_count-#{packages}," +
"shipped_package_count=shipped_package_count+#{packages},outbound_order_no=#{outboundNo}," +
"current_status=CASE WHEN shipped_package_count+#{packages}=package_count " +
"THEN 'COMPLETED' ELSE 'PARTIALLY_SHIPPED' END,updated_at=NOW() " +
"WHERE tenant_id=#{tenantId} AND id=#{id} AND picked_package_count >= #{packages} " +
"AND shipped_package_count+#{packages} <= package_count")
int transferPickedToShipped(@Param("tenantId") String tenantId, @Param("id") Long id,
@Param("packages") long packages, @Param("outboundNo") String outboundNo);
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.relabel;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan("com.baiyue.lastleg.relabel.mapper")
public class RelabelMapperConfiguration {
}
@@ -0,0 +1,53 @@
package com.baiyue.lastleg.relabel.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.relabel.dto.RelabelRequests;
import com.baiyue.lastleg.relabel.dto.RelabelResponses;
import com.baiyue.lastleg.relabel.service.RelabelApplicationService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/platform/v1/last-leg/relabel-applications")
public class RelabelApplicationController {
@Resource private RelabelApplicationService service;
@PostMapping
public ApiResponse<RelabelResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String tenantId, @Valid @RequestBody RelabelRequests.Create request) {
return ApiResponse.success(service.create(scope(user, tenantId), operator(user), request));
}
@GetMapping
public ApiResponse<PageResult<RelabelResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String tenantId, @Valid RelabelRequests.Query query) {
return ApiResponse.success(service.page(scope(user, tenantId), query));
}
@GetMapping("/{relabelNo}")
public ApiResponse<RelabelResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String tenantId, @PathVariable String relabelNo) {
return ApiResponse.success(service.detail(scope(user, tenantId), relabelNo));
}
@PutMapping("/{relabelNo}/status")
public ApiResponse<RelabelResponses.Detail> status(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String tenantId, @PathVariable String relabelNo,
@Valid @RequestBody RelabelRequests.ChangeStatus request) {
return ApiResponse.success(service.changeStatus(scope(user, tenantId), relabelNo, operator(user), request));
}
private String scope(AuthenticatedUser user, String requested) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
if (requested == null || requested.isBlank() || "0".equals(requested)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant");
}
return requested.trim();
}
return String.valueOf(user.tenantId());
}
private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); }
}
@@ -0,0 +1,54 @@
package com.baiyue.lastleg.relabel.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.List;
public final class RelabelRequests {
private RelabelRequests() {}
public record Create(
@NotBlank @Size(max = 128) String customerOrderNo,
@NotBlank @Size(max = 128) String overseasWarehouseId,
@NotNull LocalDateTime labelDeadline,
@NotBlank @Size(max = 128) String contactName,
@NotBlank @Size(max = 64) String contactPhone,
@Size(max = 1000) String remark,
@NotBlank @Size(max = 255) String idempotencyKey,
@NotEmpty @Size(max = 500) List<@Valid Item> items
) {}
public record Item(
@NotBlank @Size(max = 128) String skuCode,
@Size(max = 128) String productImageFileId,
@Size(max = 255) String productImageFileName,
@Size(max = 1000) String productImageUrl,
@NotBlank @Size(max = 255) String productNameZh,
@Positive int totalPackageCount,
@Positive int notifiedPackageCount,
@NotBlank @Size(max = 128) String shipmentNo,
@Size(max = 255) String trackingCode,
@NotBlank @Size(max = 128) String newSkuCode,
@Size(max = 1000) String labelRequirement,
@Size(max = 128) String relabelFileId,
@Size(max = 255) String relabelFileName,
@Size(max = 1000) String relabelFileUrl
) {}
public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String relabelNo,
String customerOrderNo, String skuCode, String status,
String overseasWarehouseId, LocalDateTime createdFrom, LocalDateTime createdTo) {
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
}
public record ChangeStatus(@NotBlank @Size(max = 32) String status,
@Size(max = 1000) String remark) {}
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.relabel.dto;
import java.time.LocalDateTime;
import java.util.List;
public final class RelabelResponses {
private RelabelResponses() {}
public record Summary(Long id, String relabelNo, Long lastlegOrderId, String lastlegOrderNo,
String customerOrderNo, String overseasWarehouseId, LocalDateTime labelDeadline,
String contactName, String contactPhone, String status, int itemCount,
int totalPackageCount, int notifiedPackageCount, String skuCodes,
LocalDateTime createdAt) {}
public record Item(Long id, String skuCode, String productImageFileId, String productImageFileName,
String productImageUrl, String productNameZh, int totalPackageCount,
int notifiedPackageCount, String shipmentNo, String trackingCode,
String newSkuCode, String labelRequirement, String relabelFileId,
String relabelFileName, String relabelFileUrl) {}
public record History(String fromStatus, String toStatus, String operatorId,
String remark, LocalDateTime createdAt) {}
public record Detail(Summary application, String remark, List<Item> items, List<History> history) {}
}
@@ -0,0 +1,32 @@
package com.baiyue.lastleg.relabel.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_relabel_application")
public class RelabelApplication {
@TableId(type = IdType.INPUT) private Long id;
private String tenantId;
private String relabelNo;
private Long lastlegOrderId;
private String customerOrderNo;
private String overseasWarehouseId;
private LocalDateTime labelDeadline;
private String contactName;
private String contactPhone;
private String remark;
private String status;
private Integer itemCount;
private Integer totalPackageCount;
private Integer notifiedPackageCount;
private String idempotencyKey;
private String requestHash;
private String createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,31 @@
package com.baiyue.lastleg.relabel.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_relabel_item")
public class RelabelItem {
@TableId(type = IdType.INPUT) private Long id;
private Long applicationId;
private String tenantId;
private String skuCode;
private String productImageFileId;
private String productImageFileName;
private String productImageUrl;
private String productNameZh;
private Integer totalPackageCount;
private Integer notifiedPackageCount;
private String shipmentNo;
private String trackingCode;
private String newSkuCode;
private String labelRequirement;
private String relabelFileId;
private String relabelFileName;
private String relabelFileUrl;
private LocalDateTime createdAt;
}
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.relabel.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_relabel_status_history")
public class RelabelStatusHistory {
@TableId(type = IdType.INPUT) private Long id;
private String tenantId;
private Long applicationId;
private String fromStatus;
private String toStatus;
private String operatorId;
private String remark;
private LocalDateTime createdAt;
}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.relabel.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface RelabelApplicationMapper extends BaseMapper<RelabelApplication> {
@Select("SELECT * FROM ll_relabel_application WHERE tenant_id=#{tenantId} AND relabel_no=#{relabelNo} LIMIT 1")
RelabelApplication findNo(@Param("tenantId") String tenantId, @Param("relabelNo") String relabelNo);
@Select("SELECT * FROM ll_relabel_application WHERE tenant_id=#{tenantId} AND idempotency_key=#{key} LIMIT 1")
RelabelApplication findKey(@Param("tenantId") String tenantId, @Param("key") String key);
@Select("SELECT * FROM ll_relabel_application WHERE tenant_id=#{tenantId} AND relabel_no=#{relabelNo} LIMIT 1 FOR UPDATE")
RelabelApplication lockNo(@Param("tenantId") String tenantId, @Param("relabelNo") String relabelNo);
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.relabel.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface RelabelItemMapper extends BaseMapper<RelabelItem> {
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.relabel.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.relabel.entity.RelabelStatusHistory;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface RelabelStatusHistoryMapper extends BaseMapper<RelabelStatusHistory> {
}
@@ -0,0 +1,252 @@
package com.baiyue.lastleg.relabel.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.relabel.dto.RelabelRequests;
import com.baiyue.lastleg.relabel.dto.RelabelResponses;
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.service.LastlegOrderNoGenerator;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@Service
public class RelabelApplicationService {
public static final String PENDING_PROCESSING = "PENDING_PROCESSING";
private static final Set<String> STATUSES = Set.of(PENDING_PROCESSING, "PROCESSING", "COMPLETED", "REJECTED", "CANCELLED");
@Resource private RelabelApplicationMapper applications;
@Resource private RelabelItemMapper items;
@Resource private RelabelStatusHistoryMapper histories;
@Resource private LastlegOrderMapper orders;
@Resource private LastlegOrderStatusHistoryMapper orderHistories;
@Resource private LastlegOrderNoGenerator orderNoGenerator;
@Resource private ObjectMapper objectMapper;
@Transactional
public RelabelResponses.Detail create(String tenantId, String operatorId, RelabelRequests.Create request) {
int total = 0;
int notified = 0;
for (RelabelRequests.Item item : request.items()) {
if (item.notifiedPackageCount() > item.totalPackageCount()) {
throw badRequest("Notified relocation packages cannot exceed total packages for SKU " + item.skuCode());
}
total = Math.addExact(total, item.totalPackageCount());
notified = Math.addExact(notified, item.notifiedPackageCount());
}
String requestHash = hash(request);
RelabelApplication replay = applications.findKey(tenantId, request.idempotencyKey().trim());
if (replay != null) {
if (!requestHash.equals(replay.getRequestHash())) throw conflict("Idempotency key was reused with different relabel data");
return detail(tenantId, replay.getRelabelNo());
}
LocalDateTime now = LocalDateTime.now();
long applicationId = IdWorker.getId();
String relabelNo = "FBR" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + applicationId;
LastlegOrder order = new LastlegOrder();
order.setId(IdWorker.getId());
order.setOrderNo(orderNoGenerator.next());
order.setTenantId(tenantId);
order.setOrderType("RELABEL_APPLICATION");
order.setInboundCode(relabelNo);
order.setSourceRegion("CUSTOMER");
order.setSourceInboundId(relabelNo);
order.setSourceOrderNo(request.customerOrderNo().trim());
order.setOverseasWarehouseId(request.overseasWarehouseId().trim());
order.setPalletCount(0);
order.setPackageCount(total);
order.setPickedPackageCount(0);
order.setShippedPackageCount(0);
order.setCurrentStatus(PENDING_PROCESSING);
order.setRelabelOrderNo(relabelNo);
order.setRelabelStatus(PENDING_PROCESSING);
order.setCustomerRemark(trim(request.remark()));
order.setSourceEventId("CUSTOMER_RELABEL:" + relabelNo);
order.setSourceLoadingPlanId("RELABEL_APPLICATION");
order.setSourcePayloadHash("sha256:" + requestHash);
order.setCreatedAt(now);
order.setUpdatedAt(now);
orders.insert(order);
RelabelApplication application = new RelabelApplication();
application.setId(applicationId);
application.setTenantId(tenantId);
application.setRelabelNo(relabelNo);
application.setLastlegOrderId(order.getId());
application.setCustomerOrderNo(request.customerOrderNo().trim());
application.setOverseasWarehouseId(request.overseasWarehouseId().trim());
application.setLabelDeadline(request.labelDeadline());
application.setContactName(request.contactName().trim());
application.setContactPhone(request.contactPhone().trim());
application.setRemark(trim(request.remark()));
application.setStatus(PENDING_PROCESSING);
application.setItemCount(request.items().size());
application.setTotalPackageCount(total);
application.setNotifiedPackageCount(notified);
application.setIdempotencyKey(request.idempotencyKey().trim());
application.setRequestHash(requestHash);
application.setCreatedBy(trim(operatorId));
application.setCreatedAt(now);
application.setUpdatedAt(now);
applications.insert(application);
for (RelabelRequests.Item source : request.items()) insertItem(tenantId, applicationId, source, now);
insertHistory(tenantId, applicationId, null, PENDING_PROCESSING, operatorId, "Relabel application submitted", now);
insertOrderHistory(order.getId(), null, PENDING_PROCESSING, "Relabel application submitted", now);
return detail(tenantId, relabelNo);
}
@Transactional(readOnly = true)
public PageResult<RelabelResponses.Summary> page(String tenantId, RelabelRequests.Query query) {
if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) {
throw badRequest("createdFrom must not be after createdTo");
}
LambdaQueryWrapper<RelabelApplication> wrapper = new LambdaQueryWrapper<RelabelApplication>()
.eq(RelabelApplication::getTenantId, tenantId)
.like(StringUtils.hasText(query.relabelNo()), RelabelApplication::getRelabelNo, query.relabelNo())
.like(StringUtils.hasText(query.customerOrderNo()), RelabelApplication::getCustomerOrderNo, query.customerOrderNo())
.eq(StringUtils.hasText(query.status()), RelabelApplication::getStatus, query.status())
.eq(StringUtils.hasText(query.overseasWarehouseId()), RelabelApplication::getOverseasWarehouseId, query.overseasWarehouseId())
.ge(query.createdFrom() != null, RelabelApplication::getCreatedAt, query.createdFrom())
.le(query.createdTo() != null, RelabelApplication::getCreatedAt, query.createdTo())
.apply(StringUtils.hasText(query.skuCode()),
"EXISTS (SELECT 1 FROM ll_relabel_item ri WHERE ri.application_id=ll_relabel_application.id AND ri.tenant_id=ll_relabel_application.tenant_id AND ri.sku_code LIKE CONCAT('%',{0},'%'))",
query.skuCode())
.orderByDesc(RelabelApplication::getCreatedAt);
Page<RelabelApplication> page = applications.selectPage(new Page<>(query.page(), query.size()), wrapper);
List<RelabelResponses.Summary> records = page.getRecords().stream().map(value -> summary(value, itemList(value.getId()))).toList();
return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
}
@Transactional(readOnly = true)
public RelabelResponses.Detail detail(String tenantId, String relabelNo) {
RelabelApplication application = require(tenantId, relabelNo);
List<RelabelItem> detailItems = itemList(application.getId());
List<RelabelStatusHistory> statusHistory = histories.selectList(new LambdaQueryWrapper<RelabelStatusHistory>()
.eq(RelabelStatusHistory::getTenantId, tenantId)
.eq(RelabelStatusHistory::getApplicationId, application.getId())
.orderByAsc(RelabelStatusHistory::getCreatedAt));
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());
}
@Transactional
public RelabelResponses.Detail changeStatus(String tenantId, String relabelNo, String operatorId,
RelabelRequests.ChangeStatus request) {
RelabelApplication application = applications.lockNo(tenantId, relabelNo);
if (application == null) throw notFound("Relabel application not found");
String next = request.status().trim().toUpperCase();
if (!STATUSES.contains(next)) throw badRequest("Unsupported relabel status");
if (next.equals(application.getStatus())) return detail(tenantId, relabelNo);
validateTransition(application.getStatus(), next);
String before = application.getStatus();
LocalDateTime now = LocalDateTime.now();
application.setStatus(next);
application.setUpdatedAt(now);
applications.updateById(application);
LastlegOrder order = orders.selectById(application.getLastlegOrderId());
if (order == null || !tenantId.equals(order.getTenantId())) throw conflict("Linked tail-leg order is missing");
String orderBefore = order.getCurrentStatus();
order.setCurrentStatus(next);
order.setRelabelStatus(next);
order.setUpdatedAt(now);
orders.updateById(order);
insertHistory(tenantId, application.getId(), before, next, operatorId, request.remark(), now);
insertOrderHistory(order.getId(), orderBefore, next, request.remark(), now);
return detail(tenantId, relabelNo);
}
private void insertItem(String tenantId, long applicationId, RelabelRequests.Item source, LocalDateTime now) {
RelabelItem item = new RelabelItem();
item.setId(IdWorker.getId()); item.setApplicationId(applicationId); item.setTenantId(tenantId);
item.setSkuCode(source.skuCode().trim()); item.setProductImageFileId(trim(source.productImageFileId()));
item.setProductImageFileName(trim(source.productImageFileName())); item.setProductImageUrl(trim(source.productImageUrl()));
item.setProductNameZh(source.productNameZh().trim()); item.setTotalPackageCount(source.totalPackageCount());
item.setNotifiedPackageCount(source.notifiedPackageCount()); item.setShipmentNo(source.shipmentNo().trim());
item.setTrackingCode(trim(source.trackingCode())); item.setNewSkuCode(source.newSkuCode().trim());
item.setLabelRequirement(trim(source.labelRequirement())); item.setRelabelFileId(trim(source.relabelFileId()));
item.setRelabelFileName(trim(source.relabelFileName())); item.setRelabelFileUrl(trim(source.relabelFileUrl()));
item.setCreatedAt(now); items.insert(item);
}
private RelabelResponses.Summary summary(RelabelApplication value, List<RelabelItem> detailItems) {
LastlegOrder order = orders.selectById(value.getLastlegOrderId());
String skuCodes = detailItems.stream().map(RelabelItem::getSkuCode).filter(Objects::nonNull).distinct().collect(Collectors.joining(","));
return new RelabelResponses.Summary(value.getId(), value.getRelabelNo(), value.getLastlegOrderId(),
order == null ? null : order.getOrderNo(), value.getCustomerOrderNo(), value.getOverseasWarehouseId(),
value.getLabelDeadline(), value.getContactName(), value.getContactPhone(), value.getStatus(),
Objects.requireNonNullElse(value.getItemCount(), detailItems.size()),
Objects.requireNonNullElse(value.getTotalPackageCount(), 0),
Objects.requireNonNullElse(value.getNotifiedPackageCount(), 0), skuCodes, value.getCreatedAt());
}
private RelabelResponses.Item item(RelabelItem value) {
return new RelabelResponses.Item(value.getId(), value.getSkuCode(), value.getProductImageFileId(),
value.getProductImageFileName(), value.getProductImageUrl(), value.getProductNameZh(),
value.getTotalPackageCount(), value.getNotifiedPackageCount(), value.getShipmentNo(),
value.getTrackingCode(), value.getNewSkuCode(), value.getLabelRequirement(), value.getRelabelFileId(),
value.getRelabelFileName(), value.getRelabelFileUrl());
}
private List<RelabelItem> itemList(Long applicationId) {
return items.selectList(new LambdaQueryWrapper<RelabelItem>().eq(RelabelItem::getApplicationId, applicationId)
.orderByAsc(RelabelItem::getId));
}
private RelabelApplication require(String tenantId, String relabelNo) {
RelabelApplication value = applications.findNo(tenantId, relabelNo);
if (value == null) throw notFound("Relabel application not found");
return value;
}
private void insertHistory(String tenantId, Long id, String from, String to, String operator,
String remark, LocalDateTime now) {
RelabelStatusHistory value = new RelabelStatusHistory(); value.setId(IdWorker.getId()); value.setTenantId(tenantId);
value.setApplicationId(id); value.setFromStatus(from); value.setToStatus(to); value.setOperatorId(trim(operator));
value.setRemark(trim(remark)); value.setCreatedAt(now); histories.insert(value);
}
private void insertOrderHistory(Long id, String from, String to, String remark, LocalDateTime now) {
LastlegOrderStatusHistory value = new LastlegOrderStatusHistory(); value.setOrderId(id); value.setFromStatus(from);
value.setToStatus(to); value.setEventId("RELABEL_APPLICATION"); value.setRemark(trim(remark)); value.setCreatedAt(now);
orderHistories.insert(value);
}
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);
if (!allowed) throw conflict("Relabel status cannot change from " + from + " to " + to);
}
private String hash(Object value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(objectMapper.copy().findAndRegisterModules().writeValueAsString(value).getBytes(StandardCharsets.UTF_8))); }
catch (Exception exception) { throw new IllegalStateException(exception); }
}
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
}
@@ -100,14 +100,17 @@ public class LastlegOrderService {
List<LastlegOrder> orders = orderMapper.selectList(filter(tenantId, query)
.orderByDesc(LastlegOrder::getCreatedAt).last("LIMIT 10000"));
List<String> lines = new ArrayList<>();
lines.add("ID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜状态,是否异常,是否暂扣,托数,箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,出库单号,派送单号,换标单号,换标状态,合托标记,当前状态,备注(仅自己),备注(客户),备注(仓库)");
lines.add("ID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜状态,是否异常,是否暂扣,托数,箱数,已拣箱数,已出库箱数,剩余箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,出库单号,派送单号,换标单号,换标状态,合托标记,当前状态,备注(仅自己),备注(客户),备注(仓库)");
for (LastlegOrder order : orders) {
lines.add(String.join(",", List.of(
csv(order.getId()), csv(order.getOrderNo()), csv(order.getOrderType()), csv(order.getInboundCode()),
csv(order.getSourceOrderNo()), csv(order.getCustomerName()), csv(order.getOverseasWarehouseName()),
csv(order.getTransferWarehouseName()), csv(order.getTransferWarehouseCode()), csv(order.getFbaWarehouseCode()),
csv(order.getContainerNo()), csv(order.getContainerStatus()), csv(order.getAbnormal()), csv(order.getDetained()),
csv(order.getPalletCount()), csv(order.getPackageCount()), csv(order.getVolumeCbm()), csv(order.getProductQuantity()),
csv(order.getPalletCount()), csv(order.getPackageCount()), csv(order.getPickedPackageCount()),
csv(order.getShippedPackageCount()), csv(Math.max(0, Objects.requireNonNullElse(order.getPackageCount(), 0)
- Objects.requireNonNullElse(order.getShippedPackageCount(), 0))),
csv(order.getVolumeCbm()), csv(order.getProductQuantity()),
csv(order.getVolumeWeightKg()), csv(order.getEstimatedArrivalAt()), csv(order.getActualArrivalAt()),
csv(order.getEstimatedWarehouseAt()), csv(order.getInboundOrderNo()), csv(order.getOutboundOrderNo()),
csv(order.getDeliveryOrderNo()), csv(order.getRelabelOrderNo()), csv(order.getRelabelStatus()),
@@ -155,7 +158,9 @@ public class LastlegOrderService {
o.getInboundCode(), o.getSourceOrderNo(), o.getCustomerName(), o.getOverseasWarehouseId(),
o.getOverseasWarehouseName(), o.getTransferWarehouseId(), o.getTransferWarehouseCode(),
o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), o.getContainerStatus(),
o.getAbnormal(), o.getDetained(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(),
o.getAbnormal(), o.getDetained(), o.getPalletCount(), o.getPackageCount(), o.getPickedPackageCount(),
o.getShippedPackageCount(), Math.max(0, Objects.requireNonNullElse(o.getPackageCount(), 0)
- Objects.requireNonNullElse(o.getShippedPackageCount(), 0)), o.getVolumeCbm(),
o.getProductQuantity(), o.getVolumeWeightKg(), o.getEstimatedArrivalAt(), o.getActualArrivalAt(),
o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), o.getOutboundOrderNo(), o.getDeliveryOrderNo(),
o.getRelabelOrderNo(), o.getRelabelStatus(), o.getPalletMergeFlag(), o.getCurrentStatus(),
+135 -19
View File
@@ -41,6 +41,8 @@ CREATE TABLE IF NOT EXISTS ll_order (
detained TINYINT NOT NULL DEFAULT 0 COMMENT '是否暂扣:0否1是',
pallet_count INT NOT NULL DEFAULT 0 COMMENT '托数',
package_count INT NOT NULL DEFAULT 0 COMMENT '箱数',
picked_package_count INT NOT NULL DEFAULT 0 COMMENT '累计已拣箱数',
shipped_package_count INT NOT NULL DEFAULT 0 COMMENT '累计已出库箱数',
volume_cbm DECIMAL(18,4) COMMENT '体积(立方米)',
product_quantity INT COMMENT '货物数量',
volume_weight_kg DECIMAL(18,4) COMMENT '体积重量(千克)',
@@ -551,45 +553,43 @@ CREATE TABLE IF NOT EXISTS ll_inventory_balance (
destination_warehouse_id VARCHAR(128) NULL COMMENT 'Customer destination warehouse ID snapshot',
destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'Customer destination warehouse code snapshot',
destination_warehouse_name VARCHAR(255) NULL COMMENT 'Customer destination warehouse name snapshot',
sku_code VARCHAR(128) NOT NULL COMMENT 'SKU code', sku_name_zh VARCHAR(255) NULL COMMENT 'Chinese product name',
inbound_order_id BIGINT NOT NULL COMMENT 'Inbound order ID',
inbound_order_no VARCHAR(128) NULL COMMENT 'Inbound order number snapshot',
inbound_code VARCHAR(128) NOT NULL COMMENT 'Inbound code snapshot',
available_packages BIGINT NOT NULL DEFAULT 0, hold_packages BIGINT NOT NULL DEFAULT 0,
allocated_packages BIGINT NOT NULL DEFAULT 0, picked_packages BIGINT NOT NULL DEFAULT 0,
in_transit_packages BIGINT NOT NULL DEFAULT 0, receiving_packages BIGINT NOT NULL DEFAULT 0,
procurement_packages BIGINT NOT NULL DEFAULT 0,
available_quantity BIGINT NOT NULL DEFAULT 0, hold_quantity BIGINT NOT NULL DEFAULT 0,
allocated_quantity BIGINT NOT NULL DEFAULT 0, picked_quantity BIGINT NOT NULL DEFAULT 0,
in_transit_quantity BIGINT NOT NULL DEFAULT 0, receiving_quantity BIGINT NOT NULL DEFAULT 0,
procurement_quantity BIGINT NOT NULL DEFAULT 0,
version BIGINT NOT NULL DEFAULT 0 COMMENT 'Optimistic lock version',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_ll_inventory_dimension (tenant_id,overseas_warehouse_id,destination_warehouse_code,sku_code),
UNIQUE KEY uk_ll_inventory_dimension (tenant_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code),
KEY idx_ll_inventory_list (tenant_id,overseas_warehouse_id,updated_at),
KEY idx_ll_inventory_sku (tenant_id,sku_code)
) ENGINE=InnoDB COMMENT='Real-time SKU package inventory balance';
KEY idx_ll_inventory_inbound (tenant_id,inbound_code)
) ENGINE=InnoDB COMMENT='Real-time inbound-order package inventory balance';
CREATE TABLE IF NOT EXISTS ll_inventory_position (
id BIGINT NOT NULL COMMENT 'Snowflake ID', tenant_id VARCHAR(128) NOT NULL, balance_id BIGINT NOT NULL,
overseas_warehouse_id VARCHAR(128) NOT NULL, destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '',
sku_code VARCHAR(128) NOT NULL, pallet_id BIGINT NOT NULL, pallet_no VARCHAR(64) NOT NULL,
pallet_id BIGINT NOT NULL, pallet_no VARCHAR(64) NOT NULL,
batch_no VARCHAR(128) NOT NULL DEFAULT '', warehouse_location_id BIGINT NULL,
warehouse_location_code VARCHAR(128) NOT NULL COMMENT 'Informational default location code',
available_packages BIGINT NOT NULL DEFAULT 0, hold_packages BIGINT NOT NULL DEFAULT 0,
allocated_packages BIGINT NOT NULL DEFAULT 0, picked_packages BIGINT NOT NULL DEFAULT 0,
receiving_packages BIGINT NOT NULL DEFAULT 0, product_quantity BIGINT NOT NULL DEFAULT 0,
receiving_packages BIGINT NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', version BIGINT NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id), UNIQUE KEY uk_ll_inventory_position (balance_id,pallet_id,batch_no),
KEY idx_ll_inventory_position_pallet (tenant_id,pallet_id),
KEY idx_ll_inventory_position_location (tenant_id,overseas_warehouse_id,warehouse_location_code)
) ENGINE=InnoDB COMMENT='SKU package stock by pallet, default location and batch';
) ENGINE=InnoDB COMMENT='Inbound-order package stock by pallet, default location and batch';
CREATE TABLE IF NOT EXISTS ll_inventory_ledger (
id BIGINT NOT NULL COMMENT 'Snowflake ID', tenant_id VARCHAR(128) NOT NULL,
balance_id BIGINT NOT NULL, position_id BIGINT NULL, overseas_warehouse_id VARCHAR(128) NOT NULL,
destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '', sku_code VARCHAR(128) NOT NULL,
destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '', inbound_order_id BIGINT NOT NULL,
pallet_id BIGINT NULL, pallet_no VARCHAR(64) NULL, batch_no VARCHAR(128) NULL,
warehouse_location_code VARCHAR(128) NULL, business_type VARCHAR(32) NOT NULL,
business_order_id BIGINT NULL, business_order_no VARCHAR(128) NULL, inbound_code VARCHAR(128) NULL,
@@ -597,14 +597,13 @@ CREATE TABLE IF NOT EXISTS ll_inventory_ledger (
source_event_id VARCHAR(128) NULL, idempotency_key VARCHAR(255) NOT NULL,
status_from VARCHAR(32) NULL, status_to VARCHAR(32) NULL,
before_packages BIGINT NOT NULL, delta_packages BIGINT NOT NULL, after_packages BIGINT NOT NULL,
before_quantity BIGINT NOT NULL, delta_quantity BIGINT NOT NULL, after_quantity BIGINT NOT NULL,
operator_id VARCHAR(128) NULL, remark VARCHAR(1000) NULL,
occurred_at DATETIME(3) NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id), UNIQUE KEY uk_ll_inventory_ledger_idempotency (tenant_id,idempotency_key),
KEY idx_ll_inventory_ledger_query (tenant_id,overseas_warehouse_id,occurred_at),
KEY idx_ll_inventory_ledger_sku (tenant_id,sku_code,occurred_at),
KEY idx_ll_inventory_ledger_inbound (tenant_id,inbound_code,occurred_at),
KEY idx_ll_inventory_ledger_business (tenant_id,business_type,business_order_no)
) ENGINE=InnoDB COMMENT='Immutable inventory package and quantity ledger';
) ENGINE=InnoDB COMMENT='Immutable inbound-order package inventory ledger';
CREATE TABLE IF NOT EXISTS ll_inventory_operation (
id BIGINT NOT NULL COMMENT 'Snowflake ID', tenant_id VARCHAR(128) NOT NULL,
@@ -621,21 +620,22 @@ CREATE TABLE IF NOT EXISTS ll_inbound_stock_receipt (
id BIGINT NOT NULL COMMENT 'Snowflake ID', tenant_id VARCHAR(128) NOT NULL,
plan_id BIGINT NOT NULL, task_id BIGINT NOT NULL, allocation_id BIGINT NOT NULL,
line_id BIGINT NOT NULL, order_id BIGINT NOT NULL, inbound_code VARCHAR(128) NOT NULL,
sku_code VARCHAR(128) NOT NULL, sku_name_zh VARCHAR(255) NULL, source_detail_ids VARCHAR(2000) NULL,
warehouse_pallet_id BIGINT NOT NULL, pallet_no VARCHAR(64) NOT NULL,
warehouse_location_id BIGINT NULL, warehouse_location_code VARCHAR(128) NULL,
batch_no VARCHAR(128) NOT NULL, received_packages BIGINT NOT NULL, received_quantity BIGINT NOT NULL,
batch_no VARCHAR(128) NOT NULL, received_packages BIGINT NOT NULL,
inventory_balance_id BIGINT NOT NULL, inventory_position_id BIGINT NOT NULL,
inventory_operation_key VARCHAR(255) NOT NULL, received_at DATETIME(3) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id), UNIQUE KEY uk_ll_stock_receipt_task_allocation_sku (tenant_id,task_id,allocation_id,sku_code),
PRIMARY KEY (id), UNIQUE KEY uk_ll_stock_receipt_task_allocation (tenant_id,task_id,allocation_id),
KEY idx_ll_stock_receipt_plan (tenant_id,plan_id,line_id), KEY idx_ll_stock_receipt_inventory (inventory_balance_id,inventory_position_id)
) ENGINE=InnoDB COMMENT='Immutable SKU-pallet PDA stock-in receipt snapshot';
) ENGINE=InnoDB COMMENT='Immutable inbound-order pallet package receipt snapshot';
CREATE TABLE IF NOT EXISTS ll_dispatch_order (
id BIGINT NOT NULL COMMENT 'Snowflake ID',
tenant_id VARCHAR(128) NOT NULL,
dispatch_no VARCHAR(64) NOT NULL COMMENT 'Dispatch order number',
overseas_warehouse_id VARCHAR(128) NULL COMMENT 'Overseas warehouse scope for PDA operations',
overseas_warehouse_name VARCHAR(255) NULL,
vehicle_plate VARCHAR(64) NULL COMMENT 'Editable vehicle plate; required before PDA picking',
driver_name VARCHAR(128) NULL,
driver_phone VARCHAR(64) NULL,
@@ -650,6 +650,9 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_order (
item_count INT NOT NULL DEFAULT 0,
pallet_count INT NOT NULL DEFAULT 0,
package_count INT NOT NULL DEFAULT 0,
picked_package_count INT NOT NULL DEFAULT 0 COMMENT 'PDA累计已拣箱数',
shipped_package_count INT NOT NULL DEFAULT 0 COMMENT '累计已出库箱数',
partial_completion_reason VARCHAR(1000) NULL COMMENT '部分完成原因',
created_by VARCHAR(128) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
@@ -671,6 +674,8 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_item (
order_refs VARCHAR(2000) NULL,
inbound_code VARCHAR(128) NULL,
pallet_no VARCHAR(128) NOT NULL,
overseas_warehouse_id VARCHAR(128) NULL,
overseas_warehouse_name VARCHAR(255) NULL,
destination_warehouse_id VARCHAR(128) NULL,
destination_warehouse_code VARCHAR(128) NULL,
destination_warehouse_name VARCHAR(255) NULL,
@@ -712,6 +717,7 @@ PREPARE dispatch_schema_stmt FROM @ddl; EXECUTE dispatch_schema_stmt; DEALLOCATE
CREATE TABLE IF NOT EXISTS ll_dispatch_pda_task (
id BIGINT NOT NULL, tenant_id VARCHAR(128) NOT NULL, dispatch_order_id BIGINT NOT NULL,
task_no VARCHAR(64) NOT NULL, status VARCHAR(32) NOT NULL, operator_id VARCHAR(128) NULL,
warehouse_id VARCHAR(128) NULL COMMENT 'Selected overseas warehouse for PDA operation',
operator_name VARCHAR(128) NULL, pushed_at DATETIME(3) NOT NULL, completed_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
@@ -736,3 +742,113 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_pda_operation (
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_pda_operation_key (tenant_id,idempotency_key)
) ENGINE=InnoDB COMMENT='Idempotent dispatch PDA operations';
CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick_order (
id BIGINT NOT NULL, tenant_id VARCHAR(128) NOT NULL, pick_id BIGINT NOT NULL,
receipt_id BIGINT NOT NULL, order_id BIGINT NOT NULL, packages BIGINT NOT NULL,
product_quantity BIGINT NOT NULL DEFAULT 0, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id), KEY idx_ll_dispatch_pick_order_pick (tenant_id,pick_id),
KEY idx_ll_dispatch_pick_order_receipt (tenant_id,receipt_id)
) ENGINE=InnoDB COMMENT='PDA pick attribution to frozen inbound receipt and tail order';
-- Transfer a dispatch to delivery: one outbound header, many pallet rows, one delivery header.
CREATE TABLE IF NOT EXISTS ll_outbound_order (
id BIGINT NOT NULL COMMENT 'Snowflake ID', tenant_id VARCHAR(128) NOT NULL,
outbound_no VARCHAR(64) NOT NULL, dispatch_order_id BIGINT NOT NULL, dispatch_no VARCHAR(64) NOT NULL,
overseas_warehouse_id VARCHAR(128) NULL, status VARCHAR(32) NOT NULL DEFAULT 'WAIT_OUTBOUND',
package_count INT NOT NULL DEFAULT 0, pallet_count INT NOT NULL DEFAULT 0,
idempotency_key VARCHAR(255) NOT NULL, created_by VARCHAR(128) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id), UNIQUE KEY uk_ll_outbound_no (tenant_id,outbound_no),
UNIQUE KEY uk_ll_outbound_idempotency (tenant_id,idempotency_key), UNIQUE KEY uk_ll_outbound_dispatch (tenant_id,dispatch_order_id),
KEY idx_ll_outbound_list (tenant_id,status,created_at)
) ENGINE=InnoDB COMMENT='Outbound document generated from a dispatch';
CREATE TABLE IF NOT EXISTS ll_outbound_pallet_item (
id BIGINT NOT NULL COMMENT 'Snowflake ID', tenant_id VARCHAR(128) NOT NULL, outbound_order_id BIGINT NOT NULL,
warehouse_pallet_id BIGINT NOT NULL, pallet_no VARCHAR(128) NOT NULL,
destination_warehouse_id VARCHAR(128) NULL, destination_warehouse_code VARCHAR(128) NULL, destination_warehouse_name VARCHAR(255) NULL,
package_count INT NOT NULL DEFAULT 0, order_refs VARCHAR(2000) NULL, inbound_code VARCHAR(128) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY (id),
UNIQUE KEY uk_ll_outbound_pallet (tenant_id,outbound_order_id,warehouse_pallet_id),
KEY idx_ll_outbound_pallet_list (tenant_id,outbound_order_id), KEY idx_ll_outbound_pallet_destination (tenant_id,destination_warehouse_code)
) ENGINE=InnoDB COMMENT='Pallet detail snapshot for an outbound document';
CREATE TABLE IF NOT EXISTS ll_delivery_order (
id BIGINT NOT NULL COMMENT 'Snowflake ID', tenant_id VARCHAR(128) NOT NULL,
delivery_no VARCHAR(64) NOT NULL, outbound_order_id BIGINT NOT NULL, outbound_no VARCHAR(64) NOT NULL,
dispatch_order_id BIGINT NOT NULL, dispatch_no VARCHAR(64) NOT NULL, overseas_warehouse_id VARCHAR(128) NULL,
status VARCHAR(32) NOT NULL DEFAULT 'WAIT_DELIVERY', package_count INT NOT NULL DEFAULT 0, pallet_count INT NOT NULL DEFAULT 0,
idempotency_key VARCHAR(255) NOT NULL, created_by VARCHAR(128) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id), UNIQUE KEY uk_ll_delivery_no (tenant_id,delivery_no),
UNIQUE KEY uk_ll_delivery_idempotency (tenant_id,idempotency_key), UNIQUE KEY uk_ll_delivery_outbound (tenant_id,outbound_order_id),
KEY idx_ll_delivery_list (tenant_id,status,created_at)
) ENGINE=InnoDB COMMENT='Delivery document linked to an outbound document';
-- Customer relabel application: header, item snapshots, and immutable process history.
CREATE TABLE IF NOT EXISTS ll_relabel_application (
id BIGINT NOT NULL COMMENT 'Snowflake ID',
tenant_id VARCHAR(128) NOT NULL,
relabel_no VARCHAR(64) NOT NULL,
lastleg_order_id BIGINT NOT NULL,
customer_order_no VARCHAR(128) NOT NULL,
overseas_warehouse_id VARCHAR(128) NOT NULL,
label_deadline DATETIME(3) NOT NULL,
contact_name VARCHAR(128) NOT NULL,
contact_phone VARCHAR(64) NOT NULL,
remark VARCHAR(1000) NULL,
status VARCHAR(32) NOT NULL DEFAULT 'PENDING_PROCESSING',
item_count INT NOT NULL DEFAULT 0,
total_package_count INT NOT NULL DEFAULT 0,
notified_package_count INT NOT NULL DEFAULT 0,
idempotency_key VARCHAR(255) NOT NULL,
request_hash CHAR(64) NOT NULL,
created_by VARCHAR(128) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_ll_relabel_application_no (tenant_id,relabel_no),
UNIQUE KEY uk_ll_relabel_application_idempotency (tenant_id,idempotency_key),
UNIQUE KEY uk_ll_relabel_application_order (lastleg_order_id),
KEY idx_ll_relabel_application_list (tenant_id,status,created_at),
KEY idx_ll_relabel_application_customer (tenant_id,customer_order_no),
KEY idx_ll_relabel_application_warehouse (tenant_id,overseas_warehouse_id)
) ENGINE=InnoDB COMMENT='Customer relabel application header';
CREATE TABLE IF NOT EXISTS ll_relabel_item (
id BIGINT NOT NULL COMMENT 'Snowflake ID',
application_id BIGINT NOT NULL,
tenant_id VARCHAR(128) NOT NULL,
sku_code VARCHAR(128) NOT NULL,
product_image_file_id VARCHAR(128) NULL,
product_image_file_name VARCHAR(255) NULL,
product_image_url VARCHAR(1000) NULL,
product_name_zh VARCHAR(255) NOT NULL,
total_package_count INT NOT NULL,
notified_package_count INT NOT NULL,
shipment_no VARCHAR(128) NOT NULL,
tracking_code VARCHAR(255) NULL,
new_sku_code VARCHAR(128) NOT NULL,
label_requirement VARCHAR(1000) NULL,
relabel_file_id VARCHAR(128) NULL,
relabel_file_name VARCHAR(255) NULL,
relabel_file_url VARCHAR(1000) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY idx_ll_relabel_item_application (tenant_id,application_id),
KEY idx_ll_relabel_item_sku (tenant_id,sku_code)
) ENGINE=InnoDB COMMENT='Frozen customer relabel cargo items';
CREATE TABLE IF NOT EXISTS ll_relabel_status_history (
id BIGINT NOT NULL COMMENT 'Snowflake ID',
tenant_id VARCHAR(128) NOT NULL,
application_id BIGINT NOT NULL,
from_status VARCHAR(32) NULL,
to_status VARCHAR(32) NOT NULL,
operator_id VARCHAR(128) NULL,
remark VARCHAR(1000) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY idx_ll_relabel_status_history_application (tenant_id,application_id,created_at)
) ENGINE=InnoDB COMMENT='Immutable customer relabel process history';
@@ -0,0 +1,58 @@
package com.baiyue.lastleg.delivery;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class DeliverySchemaContractTest {
@Test
void baselineDefinesOutboundPalletsAndDeliveryContracts() throws Exception {
String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8)
.toLowerCase();
assertTrue(schema.contains("create table if not exists ll_outbound_order"));
assertTrue(schema.contains("status varchar(32) not null default 'wait_outbound'"));
assertTrue(schema.contains("unique key uk_ll_outbound_idempotency (tenant_id,idempotency_key)"));
assertTrue(schema.contains("unique key uk_ll_outbound_dispatch (tenant_id,dispatch_order_id)"));
assertTrue(schema.contains("create table if not exists ll_outbound_pallet_item"));
assertTrue(schema.contains("unique key uk_ll_outbound_pallet (tenant_id,outbound_order_id,warehouse_pallet_id)"));
assertTrue(schema.contains("destination_warehouse_code varchar(128) null"));
assertTrue(schema.contains("order_refs varchar(2000) null"));
assertTrue(schema.contains("inbound_code varchar(128) null"));
assertTrue(schema.contains("create table if not exists ll_delivery_order"));
assertTrue(schema.contains("status varchar(32) not null default 'wait_delivery'"));
assertTrue(schema.contains("unique key uk_ll_delivery_idempotency (tenant_id,idempotency_key)"));
assertTrue(schema.contains("unique key uk_ll_delivery_outbound (tenant_id,outbound_order_id)"));
}
@Test
void deliveryPersistenceEntitiesUseSnowflakeIds() throws Exception {
for (Class<?> type : new Class<?>[] { OutboundOrder.class, OutboundPalletItem.class, DeliveryOrder.class }) {
TableId id = type.getDeclaredField("id").getAnnotation(TableId.class);
assertNotNull(id, type.getSimpleName());
assertEquals(IdType.ASSIGN_ID, id.type(), type.getSimpleName());
}
}
@Test
void incrementalMigrationAddsTransferTablesAndIsRerunnable() throws Exception {
String migration = Files.readString(Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8)
.toLowerCase();
assertTrue(migration.contains("2026-08-17 / 014"));
assertTrue(migration.contains("create table if not exists ll_outbound_order"));
assertTrue(migration.contains("create table if not exists ll_outbound_pallet_item"));
assertTrue(migration.contains("create table if not exists ll_delivery_order"));
}
}
@@ -0,0 +1,297 @@
package com.baiyue.lastleg.delivery.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
import com.baiyue.lastleg.delivery.dto.DeliveryResponses;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
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 org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class DeliveryTransferServiceTest {
@Mock DispatchOrderMapper dispatchOrders;
@Mock DispatchItemMapper dispatchItems;
@Mock OutboundOrderMapper outboundOrders;
@Mock OutboundPalletItemMapper outboundPallets;
@Mock DeliveryOrderMapper deliveryOrders;
@Mock InboundPlanPalletAllocationMapper planAllocations;
@Mock InboundPlanLineMapper planLines;
@Mock LastlegOrderMapper tailOrders;
@Mock LastlegOrderStatusHistoryMapper histories;
private DeliveryTransferService service;
@BeforeEach
void setUp() {
service = new DeliveryTransferService();
ReflectionTestUtils.setField(service, "dispatchOrders", dispatchOrders);
ReflectionTestUtils.setField(service, "dispatchItems", dispatchItems);
ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders);
ReflectionTestUtils.setField(service, "outboundPallets", outboundPallets);
ReflectionTestUtils.setField(service, "deliveryOrders", deliveryOrders);
ReflectionTestUtils.setField(service, "planAllocations", planAllocations);
ReflectionTestUtils.setField(service, "planLines", planLines);
ReflectionTestUtils.setField(service, "tailOrders", tailOrders);
ReflectionTestUtils.setField(service, "histories", histories);
}
@Test
void firstTransferCreatesOneOutboundOneDeliveryAndAllPalletSnapshots() {
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch);
when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch);
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(
item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12),
item(202L, 502L, "PLT-502", "LAX9", "Los Angeles", "IN-B", 8)
));
DeliveryResponses.TransferResult result = service.transfer("TENANT-A", "DSP-101", "U-1",
new DeliveryRequests.Transfer("transfer-101", "ready"));
ArgumentCaptor<OutboundOrder> outbound = ArgumentCaptor.forClass(OutboundOrder.class);
ArgumentCaptor<DeliveryOrder> delivery = ArgumentCaptor.forClass(DeliveryOrder.class);
ArgumentCaptor<OutboundPalletItem> pallets = ArgumentCaptor.forClass(OutboundPalletItem.class);
verify(outboundOrders).insert(outbound.capture());
verify(deliveryOrders).insert(delivery.capture());
verify(outboundPallets, org.mockito.Mockito.times(2)).insert(pallets.capture());
assertEquals("WAIT_OUTBOUND", outbound.getValue().getStatus());
assertEquals(2, outbound.getValue().getPalletCount());
assertEquals(20, outbound.getValue().getPackageCount());
assertEquals("transfer-101", outbound.getValue().getIdempotencyKey());
assertTrue(outbound.getValue().getOutboundNo().startsWith("OUT"));
assertEquals(outbound.getValue().getId(), delivery.getValue().getOutboundOrderId());
assertEquals("WAIT_DELIVERY", delivery.getValue().getStatus());
assertTrue(delivery.getValue().getDeliveryNo().startsWith("DEL"));
assertEquals(List.of("ONT8", "LAX9"), pallets.getAllValues().stream()
.map(OutboundPalletItem::getDestinationWarehouseCode).toList());
assertEquals(List.of("IN-A", "IN-B"), pallets.getAllValues().stream()
.map(OutboundPalletItem::getInboundCode).toList());
assertEquals(20, result.packageCount());
assertEquals(2, result.palletCount());
assertTrue(!result.replayed());
}
@Test
void sameIdempotencyKeyReturnsExistingDocumentsWithoutWrites() {
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101");
DeliveryOrder delivery = delivery(401L, outbound, "DEL-401");
when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch);
when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch);
when(outboundOrders.findByIdempotency("TENANT-A", "transfer-101")).thenReturn(outbound);
when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery);
DeliveryResponses.TransferResult result = service.transfer("TENANT-A", "DSP-101", "U-1",
new DeliveryRequests.Transfer(" transfer-101 ", null));
assertTrue(result.replayed());
assertEquals("OUT-301", result.outboundNo());
assertEquals("DEL-401", result.deliveryNo());
verify(outboundOrders, never()).insert((OutboundOrder) any());
verify(outboundPallets, never()).insert((OutboundPalletItem) any());
verify(deliveryOrders, never()).insert((DeliveryOrder) any());
}
@Test
void idempotencyKeyCannotBeReusedForAnotherDispatch() {
DispatchOrder dispatch = dispatch(102L, "DSP-102", "TENANT-A");
OutboundOrder other = outbound(301L, 101L, "DSP-101", "OUT-301", "shared-key");
when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-102")).thenReturn(dispatch);
when(dispatchOrders.lockById("TENANT-A", 102L)).thenReturn(dispatch);
when(outboundOrders.findByIdempotency("TENANT-A", "shared-key")).thenReturn(other);
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () ->
service.transfer("TENANT-A", "DSP-102", "U-1", new DeliveryRequests.Transfer("shared-key", null)));
assertEquals(409, exception.getStatusCode().value());
verify(outboundOrders, never()).insert((OutboundOrder) any());
}
@Test
void concurrentDuplicateInsertReturnsWinningDocuments() {
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
OutboundOrder winner = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101");
DeliveryOrder delivery = delivery(401L, winner, "DEL-401");
when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch);
when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch);
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(
item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12)));
when(outboundOrders.insert(any(OutboundOrder.class))).thenThrow(new org.springframework.dao.DuplicateKeyException("duplicate"));
when(outboundOrders.findByIdempotency("TENANT-A", "transfer-101")).thenReturn(null, winner);
when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery);
DeliveryResponses.TransferResult result = service.transfer("TENANT-A", "DSP-101", "U-1",
new DeliveryRequests.Transfer("transfer-101", null));
assertTrue(result.replayed());
assertEquals("OUT-301", result.outboundNo());
assertEquals("DEL-401", result.deliveryNo());
verify(outboundPallets, never()).insert((OutboundPalletItem) any());
verify(deliveryOrders, never()).insert((DeliveryOrder) any());
}
@Test
void directDispatchOrderReferenceIsBoundToCreatedDocuments() {
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
DispatchItem source = item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12);
source.setOrderId(901L);
LastlegOrder tailOrder = new LastlegOrder();
tailOrder.setId(901L); tailOrder.setTenantId("TENANT-A"); tailOrder.setCurrentStatus("PENDING_ASSIGNMENT");
when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch);
when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch);
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(source));
when(tailOrders.selectList(any())).thenReturn(List.of(tailOrder));
service.transfer("TENANT-A", "DSP-101", "U-1", new DeliveryRequests.Transfer("direct-order", null));
assertEquals("WAIT_OUTBOUND", tailOrder.getCurrentStatus());
assertNotNull(tailOrder.getOutboundOrderNo());
assertNotNull(tailOrder.getDeliveryOrderNo());
verify(tailOrders).updateById(tailOrder);
verify(histories).insert(any(LastlegOrderStatusHistory.class));
}
@Test
void transferRejectsAPlannedPalletThatHasNotBeenReceived() {
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
DispatchItem planned = item(201L, null, "PLAN-1", "ONT8", "Ontario", "IN-A", 12);
planned.setSourceStage("PLAN");
when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch);
when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch);
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(planned));
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () ->
service.transfer("TENANT-A", "DSP-101", "U-1", new DeliveryRequests.Transfer("transfer-101", null)));
assertEquals(409, exception.getStatusCode().value());
assertTrue(exception.getReason().contains("received"));
verify(outboundOrders, never()).insert((OutboundOrder) any());
}
@Test
void outboundDetailReturnsPagedPalletDestinationPackagesAndInboundCode() {
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101");
DeliveryOrder delivery = delivery(401L, outbound, "DEL-401");
OutboundPalletItem first = pallet(501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12);
OutboundPalletItem second = pallet(502L, "PLT-502", "LAX9", "Los Angeles", "IN-B", 8);
when(outboundOrders.findByNo("TENANT-A", "OUT-301")).thenReturn(outbound);
when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery);
when(outboundPallets.selectPage(any(Page.class), any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked") Page<OutboundPalletItem> page = invocation.getArgument(0);
page.setRecords(List.of(first, second));
page.setTotal(2);
return page;
});
DeliveryResponses.OutboundDetail detail = service.outboundDetail("TENANT-A", "OUT-301",
new DeliveryRequests.PalletQuery(1, 20, null));
PageResult<DeliveryResponses.PalletRow> pallets = detail.pallets();
assertEquals(2, pallets.total());
assertEquals(2, pallets.records().size());
assertEquals("ONT8", pallets.records().get(0).destinationWarehouseCode());
assertEquals("Ontario", pallets.records().get(0).destinationWarehouseName());
assertEquals(12, pallets.records().get(0).packageCount());
assertEquals("IN-A", pallets.records().get(0).inboundCode());
assertEquals("LAX9", pallets.records().get(1).destinationWarehouseCode());
assertEquals(8, pallets.records().get(1).packageCount());
}
@Test
void detailLookupCannotCrossTenantBoundary() {
when(outboundOrders.findByNo("TENANT-B", "OUT-301")).thenReturn(null);
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () ->
service.outboundDetail("TENANT-B", "OUT-301", new DeliveryRequests.PalletQuery(1, 20, null)));
assertEquals(404, exception.getStatusCode().value());
verify(outboundOrders).findByNo("TENANT-B", "OUT-301");
verify(deliveryOrders, never()).lockByOutbound(any(), any());
verify(outboundPallets, never()).selectPage(any(), any());
}
private static DispatchOrder dispatch(Long id, String dispatchNo, String tenantId) {
DispatchOrder value = new DispatchOrder();
value.setId(id); value.setTenantId(tenantId); value.setDispatchNo(dispatchNo);
value.setOverseasWarehouseId("WH-US-1"); value.setStatus("READY_TO_DEPART");
return value;
}
private static DispatchItem item(Long id, Long warehousePalletId, String palletNo, String destinationCode,
String destinationName, String inboundCode, int packages) {
DispatchItem value = new DispatchItem();
value.setId(id); value.setWarehousePalletId(warehousePalletId); value.setPalletNo(palletNo);
value.setDestinationWarehouseId("DEST-" + destinationCode);
value.setDestinationWarehouseCode(destinationCode); value.setDestinationWarehouseName(destinationName);
value.setInboundCode(inboundCode); value.setOrderRefs("LMO-1"); value.setPackageCount(packages);
value.setSourceStage("RECEIVED");
return value;
}
private static OutboundOrder outbound(Long id, Long dispatchId, String dispatchNo, String outboundNo,
String idempotencyKey) {
OutboundOrder value = new OutboundOrder();
value.setId(id); value.setTenantId("TENANT-A"); value.setDispatchOrderId(dispatchId);
value.setDispatchNo(dispatchNo); value.setOutboundNo(outboundNo); value.setStatus("WAIT_OUTBOUND");
value.setOverseasWarehouseId("WH-US-1"); value.setPalletCount(2); value.setPackageCount(20);
value.setIdempotencyKey(idempotencyKey); value.setCreatedAt(LocalDateTime.now());
return value;
}
private static DeliveryOrder delivery(Long id, OutboundOrder outbound, String deliveryNo) {
DeliveryOrder value = new DeliveryOrder();
value.setId(id); value.setTenantId(outbound.getTenantId()); value.setDeliveryNo(deliveryNo);
value.setOutboundOrderId(outbound.getId()); value.setOutboundNo(outbound.getOutboundNo());
value.setDispatchOrderId(outbound.getDispatchOrderId()); value.setDispatchNo(outbound.getDispatchNo());
value.setOverseasWarehouseId(outbound.getOverseasWarehouseId()); value.setStatus("WAIT_DELIVERY");
value.setPalletCount(outbound.getPalletCount()); value.setPackageCount(outbound.getPackageCount());
value.setCreatedAt(LocalDateTime.now());
return value;
}
private static OutboundPalletItem pallet(Long warehousePalletId, String palletNo, String destinationCode,
String destinationName, String inboundCode, int packages) {
OutboundPalletItem value = new OutboundPalletItem();
value.setId(warehousePalletId + 1000); value.setTenantId("TENANT-A"); value.setOutboundOrderId(301L);
value.setWarehousePalletId(warehousePalletId); value.setPalletNo(palletNo);
value.setDestinationWarehouseId("DEST-" + destinationCode);
value.setDestinationWarehouseCode(destinationCode); value.setDestinationWarehouseName(destinationName);
value.setInboundCode(inboundCode); value.setOrderRefs("LMO-1"); value.setPackageCount(packages);
return value;
}
}
@@ -1,5 +1,6 @@
package com.baiyue.lastleg.dispatch.pda.service;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests;
@@ -22,6 +23,7 @@ import static org.mockito.Mockito.when;
class DispatchPdaWorkflowServiceTest {
@Mock private DispatchOrderMapper orders;
@Mock private DispatchPdaOperationMapper operations;
@Mock private OutboundOrderMapper outboundOrders;
@Test
void pushRequiresVehiclePlateBeforeCreatingPdaTask() {
@@ -37,6 +39,22 @@ class DispatchPdaWorkflowServiceTest {
assertEquals(409, exception.getStatusCode().value());
}
@Test
void pushRequiresWarehouseSelection() {
DispatchPdaWorkflowService service = new DispatchPdaWorkflowService();
ReflectionTestUtils.setField(service, "orders", orders);
DispatchOrder order = new DispatchOrder();
order.setId(1L); order.setTenantId("100"); order.setDispatchNo("PC1");
order.setStatus("PENDING_DEPARTURE"); order.setVehiclePlate("CA-100");
order.setOverseasWarehouseId("WH-1");
when(orders.findByDispatchNo("100", "PC1")).thenReturn(order);
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.push("100", "PC1", new DispatchPdaRequests.Push("push-1", null, null)));
assertEquals(400, exception.getStatusCode().value());
}
@Test
void departRequiresVehiclePlate() {
DispatchPdaWorkflowService service = new DispatchPdaWorkflowService();
@@ -55,6 +73,26 @@ class DispatchPdaWorkflowServiceTest {
assertEquals(409, exception.getStatusCode().value());
}
@Test
void departRequiresPendingOutboundOrder() {
DispatchPdaWorkflowService service = new DispatchPdaWorkflowService();
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "operations", operations);
ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders);
DispatchOrder order = new DispatchOrder();
order.setId(1L); order.setTenantId("100"); order.setDispatchNo("PC1");
order.setStatus("READY_TO_DEPART"); order.setVehiclePlate("CA-100");
when(orders.findByDispatchNo("100", "PC1")).thenReturn(order);
when(orders.lockById("100", 1L)).thenReturn(order);
when(operations.findKey("100", "depart-1")).thenReturn(null);
when(outboundOrders.lockByDispatch("100", 1L)).thenReturn(null);
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.depart("100", "PC1", "operator", new DispatchPdaRequests.Depart("depart-1", null)));
assertEquals(409, exception.getStatusCode().value());
}
@Test
void departedRequestReplaysAfterStatusChanged() throws Exception {
DispatchPdaWorkflowService service = new DispatchPdaWorkflowService();
@@ -0,0 +1,89 @@
package com.baiyue.lastleg.dispatch.pda.service;
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPick;
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPickOrder;
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
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.time.LocalDateTime;
import java.util.List;
import java.util.ArrayList;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class DispatchPickOrderAttributionServiceTest {
@Mock private DispatchPdaPickOrderMapper attributions;
@Mock private LastlegOrderMapper orders;
@Mock private LastlegOrderStatusHistoryMapper history;
@Test
void attributesPackagesAcrossInboundOrdersInReceiptFifoOrder() {
DispatchPickOrderAttributionService service = service();
InboundStockReceipt first = receipt(11L, 101L, 3L, 30L);
InboundStockReceipt second = receipt(12L, 102L, 4L, 40L);
when(attributions.availableReceipts("t1", 91L)).thenReturn(List.of(first, second));
when(attributions.attributedPackages(eq("t1"), anyLong())).thenReturn(0L);
when(orders.increasePicked(eq("t1"), anyLong(), anyLong())).thenReturn(1);
DispatchPdaPick pick = pick(81L, 91L, 5L, 50L);
List<DispatchPickOrderAttributionService.OrderPackages> result = service.attribute("t1", pick);
assertEquals(List.of(new DispatchPickOrderAttributionService.OrderPackages(101L, 3L),
new DispatchPickOrderAttributionService.OrderPackages(102L, 2L)), result);
}
@Test
void departureMovesOnlyAttributedPackagesAndWritesOrderHistory() {
DispatchPickOrderAttributionService service = service();
DispatchPdaPick pick = pick(81L, 91L, 4L, 40L);
DispatchPdaPickOrder row = new DispatchPdaPickOrder();
row.setOrderId(101L); row.setPackages(4L);
when(attributions.listPick("t1", 81L)).thenReturn(List.of(row));
LastlegOrder order = new LastlegOrder();
order.setId(101L); order.setPackageCount(10); order.setPickedPackageCount(4);
order.setShippedPackageCount(2); order.setCurrentStatus("INBOUND_COMPLETED");
when(orders.lockById("t1", 101L)).thenReturn(order);
when(orders.transferPickedToShipped("t1", 101L, 4L, "OUT1")).thenReturn(1);
List<DispatchPickOrderAttributionService.OrderPackages> result = service.depart(
"t1", "OUT1", "D1", List.of(pick), LocalDateTime.of(2026, 8, 17, 15, 0));
assertEquals(List.of(new DispatchPickOrderAttributionService.OrderPackages(101L, 4L)), result);
verify(history).insert(argThat((LastlegOrderStatusHistory value) -> "PARTIALLY_SHIPPED".equals(value.getToStatus())
&& value.getRemark().contains("6/10")));
}
private DispatchPickOrderAttributionService service() {
DispatchPickOrderAttributionService service = new DispatchPickOrderAttributionService();
ReflectionTestUtils.setField(service, "attributions", attributions);
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "history", history);
return service;
}
private static InboundStockReceipt receipt(long id, long orderId, long packages, long quantity) {
InboundStockReceipt receipt = new InboundStockReceipt();
receipt.setId(id); receipt.setOrderId(orderId); receipt.setReceivedPackages(packages);
return receipt;
}
private static DispatchPdaPick pick(long id, long positionId, long packages, long quantity) {
DispatchPdaPick pick = new DispatchPdaPick();
pick.setId(id); pick.setInventoryPositionId(positionId); pick.setPackages(packages);
pick.setProductQuantity(quantity); pick.setPickedAt(LocalDateTime.of(2026, 8, 17, 14, 0));
return pick;
}
}
@@ -86,7 +86,7 @@ class DispatchServiceTest {
}
private DispatchPalletCandidate candidate(long planPalletId, Long warehousePalletId, String destination) {
return new DispatchPalletCandidate(1L, planPalletId, warehousePalletId, 2L, "LMO1", "LMO1", "RK1",
return new DispatchPalletCandidate(1L, planPalletId, warehousePalletId, 2L, "WH-1", "Overseas warehouse", "LMO1", "LMO1", "RK1",
"LIP1-P001", destination, destination, destination, destination, 12,
new BigDecimal("1.2000"), warehousePalletId == null ? "PLAN" : "RECEIVED");
}
@@ -12,7 +12,6 @@ import com.baiyue.lastleg.inboundplan.pda.mapper.InboundStockReceiptMapper;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -40,7 +39,6 @@ import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class InboundStockReceiptServiceTest {
@Mock LastlegOrderMapper orders;
@Mock LastlegOrderDetailMapper details;
@Mock InboundStockReceiptMapper receipts;
@Mock InventoryCommandService inventory;
private InboundStockReceiptService service;
@@ -49,7 +47,6 @@ class InboundStockReceiptServiceTest {
void setUp() {
service = new InboundStockReceiptService();
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "details", details);
ReflectionTestUtils.setField(service, "receipts", receipts);
ReflectionTestUtils.setField(service, "inventory", inventory);
when(receipts.listTask("100", 2L)).thenReturn(List.of());
@@ -59,7 +56,6 @@ class InboundStockReceiptServiceTest {
void writesOneReceiptPerSkuAndPalletAndUsesPdaPackageCount() {
Fixture fixture = fixture(5, 5, 5, 10);
when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order));
when(details.selectList(any())).thenReturn(List.of(fixture.detail));
when(inventory.stockIn(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class)))
.thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 5, 0, 0, 0, false, false));
@@ -69,30 +65,30 @@ class InboundStockReceiptServiceTest {
ArgumentCaptor<InventoryCommandRequests.StockIn> request = ArgumentCaptor.forClass(InventoryCommandRequests.StockIn.class);
verify(inventory).stockIn(eq("100"), eq("operator"), request.capture());
assertEquals(5L, request.getValue().packages());
assertEquals(10L, request.getValue().productQuantity());
assertEquals("SKU-1", request.getValue().skuCode());
assertEquals(101L, request.getValue().inboundOrderId());
assertEquals("IN-1", request.getValue().inboundCode());
ArgumentCaptor<InboundStockReceipt> receipt = ArgumentCaptor.forClass(InboundStockReceipt.class);
verify(receipts).insert(receipt.capture());
assertEquals(5L, receipt.getValue().getReceivedPackages());
assertEquals(10L, receipt.getValue().getReceivedQuantity());
assertEquals(10L, receipt.getValue().getInventoryPositionId());
}
@Test
void rejectsMultiSkuLineSplitAcrossPalletsInsteadOfProportionalMisallocation() {
void storesOneInboundOrderAcrossMultiplePalletsWithoutSkuSplitting() {
Fixture fixture = fixture(10, 5, 5, 10);
fixture.detail.setPackageCount(5);
LastlegOrderDetail second = detail(202L, "SKU-2", 5, 7);
InboundPlanPallet secondPallet = pallet(22L, 2002L);
InboundPlanPalletAllocation secondAllocation = allocation(32L, 12L, 22L, 5);
when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order));
when(details.selectList(any())).thenReturn(List.of(fixture.detail, second));
when(inventory.stockIn(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class)))
.thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 5, 0, 0, 0, false, false));
assertThrows(ResponseStatusException.class, () -> service.stockIn("100", fixture.plan, fixture.task,
service.stockIn("100", fixture.plan, fixture.task,
List.of(fixture.line), List.of(fixture.allocation, secondAllocation),
List.of(fixture.pallet, secondPallet), LocalDateTime.now()));
verify(inventory, never()).stockIn(any(), any(), any());
verify(receipts, never()).insert(any(InboundStockReceipt.class));
List.of(fixture.pallet, secondPallet), LocalDateTime.now());
verify(inventory, times(2)).stockIn(eq("100"), eq("operator"), any());
verify(receipts, times(2)).insert(any(InboundStockReceipt.class));
}
@Test
@@ -101,7 +97,6 @@ class InboundStockReceiptServiceTest {
InboundPlanPallet secondPallet = pallet(22L, 2002L);
InboundPlanPalletAllocation secondAllocation = allocation(32L, 12L, 22L, 3);
when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order));
when(details.selectList(any())).thenReturn(List.of(fixture.detail));
when(inventory.stockIn(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class)))
.thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 5, 0, 0, 0, false, false));
@@ -111,7 +106,6 @@ class InboundStockReceiptServiceTest {
ArgumentCaptor<InventoryCommandRequests.StockIn> requests = ArgumentCaptor.forClass(InventoryCommandRequests.StockIn.class);
verify(inventory, times(2)).stockIn(eq("100"), eq("operator"), requests.capture());
assertEquals(5L, requests.getAllValues().stream().mapToLong(InventoryCommandRequests.StockIn::packages).sum());
assertEquals(11L, requests.getAllValues().stream().mapToLong(InventoryCommandRequests.StockIn::productQuantity).sum());
}
@Test
@@ -28,23 +28,30 @@ class InventorySchemaContractTest {
}
assertTrue(schema.contains("create table if not exists ll_inventory_balance"));
assertTrue(schema.contains("unique key uk_ll_inventory_dimension (tenant_id,overseas_warehouse_id,destination_warehouse_code,sku_code)"));
assertTrue(schema.contains("unique key uk_ll_inventory_dimension (tenant_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code)"));
assertTrue(schema.contains("inbound_order_id bigint not null"));
assertTrue(schema.contains("inbound_code varchar(128) not null"));
assertTrue(schema.contains("available_packages bigint not null default 0"));
assertTrue(schema.contains("in_transit_packages bigint not null default 0"));
assertTrue(schema.contains("procurement_quantity bigint not null default 0"));
assertFalse(tableDefinition(schema, "ll_inventory_balance").contains("sku_code"));
assertFalse(tableDefinition(schema, "ll_inventory_balance").contains("_quantity"));
assertTrue(schema.contains("create table if not exists ll_inventory_position"));
assertTrue(schema.contains("unique key uk_ll_inventory_position (balance_id,pallet_id,batch_no)"));
assertTrue(schema.contains("warehouse_location_code varchar(128) not null"));
assertFalse(tableDefinition(schema, "ll_inventory_position").contains("sku_code"));
assertFalse(tableDefinition(schema, "ll_inventory_position").contains("product_quantity"));
assertTrue(schema.contains("create table if not exists ll_inventory_ledger"));
assertTrue(schema.contains("business_order_id bigint null"));
assertTrue(schema.contains("inbound_code varchar(128) null"));
assertTrue(schema.contains("before_packages bigint not null"));
assertTrue(schema.contains("delta_packages bigint not null"));
assertTrue(schema.contains("after_packages bigint not null"));
assertFalse(tableDefinition(schema, "ll_inventory_ledger").contains("sku_code"));
assertFalse(tableDefinition(schema, "ll_inventory_ledger").contains("before_quantity"));
assertTrue(schema.contains("unique key uk_ll_inventory_ledger_idempotency (tenant_id,idempotency_key)"));
assertTrue(schema.contains("create table if not exists ll_inventory_operation"));
assertTrue(schema.contains("create table if not exists ll_inbound_stock_receipt"));
assertTrue(schema.contains("unique key uk_ll_stock_receipt_task_allocation_sku (tenant_id,task_id,allocation_id,sku_code)"));
assertTrue(schema.contains("unique key uk_ll_stock_receipt_task_allocation (tenant_id,task_id,allocation_id)"));
assertFalse(tableDefinition(schema, "ll_inventory_ledger").contains("updated_at"));
}
@@ -68,6 +75,38 @@ class InventorySchemaContractTest {
assertTrue(migration.contains("create table if not exists ll_inventory_position"));
assertTrue(migration.contains("create table if not exists ll_inventory_ledger"));
assertTrue(migration.contains("create table if not exists ll_inventory_operation"));
assertTrue(migration.contains("2026-08-17 / 015"));
assertTrue(migration.contains("add column inbound_order_id bigint null"));
assertTrue(migration.contains("quantity columns remain untouched"));
assertTrue(migration.contains("2026-08-17 / 018"));
assertTrue(migration.contains("alter table ll_order add column picked_package_count"));
assertTrue(migration.contains("alter table ll_order add column shipped_package_count"));
assertTrue(migration.contains("alter table ll_inventory_balance add column inbound_order_no"));
assertTrue(migration.contains("update ll_inventory_balance"));
assertTrue(migration.contains("where inbound_order_id is null or inbound_code is null or inbound_code = ''"));
assertTrue(migration.contains("alter table ll_inventory_ledger add column inbound_order_id"));
assertTrue(migration.contains("update ll_inventory_ledger"));
assertTrue(migration.contains("alter table ll_dispatch_order add column partial_completion_reason"));
assertFalse(migration.contains("drop column sku_code"));
assertFalse(migration.contains("drop column product_quantity"));
}
@Test
void entitiesExposeInboundOrderPackageDimensionOnly() throws NoSuchFieldException {
assertNotNull(InventoryBalance.class.getDeclaredField("inboundOrderId"));
assertNotNull(InventoryBalance.class.getDeclaredField("inboundCode"));
assertNotNull(InventoryLedger.class.getDeclaredField("inboundOrderId"));
assertThrowsNoField(InventoryBalance.class, "skuCode");
assertThrowsNoField(InventoryBalance.class, "availableQuantity");
assertThrowsNoField(InventoryPosition.class, "skuCode");
assertThrowsNoField(InventoryPosition.class, "productQuantity");
assertThrowsNoField(InventoryLedger.class, "skuCode");
assertThrowsNoField(InventoryLedger.class, "deltaQuantity");
}
private static void assertThrowsNoField(Class<?> type, String fieldName) {
assertTrue(java.util.Arrays.stream(type.getDeclaredFields()).noneMatch(field -> field.getName().equals(fieldName)),
() -> type.getSimpleName() + " must not expose " + fieldName);
}
private static String tableDefinition(String schema, String tableName) {
@@ -63,7 +63,7 @@ class InventoryCommandServiceTest {
void stockInUpdatesBalancePositionAndLedgerInOneTransaction() throws Exception {
assertTrue(InventoryCommandService.class.getMethod("stockIn", String.class, String.class,
InventoryCommandRequests.StockIn.class).getAnnotation(Transactional.class) != null);
when(balances.lockDimension("100", "WH-1", "ONT8", "SKU-A")).thenReturn(null);
when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1")).thenReturn(null);
var result = service.stockIn("100", "7", stockIn("IN-1", "stock-in-1", 12, 24));
assertEquals(12, result.availablePackages());
@@ -85,7 +85,7 @@ class InventoryCommandServiceTest {
assertThrows(ResponseStatusException.class,
() -> service.hold("100", "7", new InventoryCommandRequests.Move(
"100", 201L, 6, 0, "ORD-1", null, null, "hold-1", null)));
"100", 201L, 6, "ORD-1", null, null, "hold-1", null)));
verify(positions, never()).updateById(any(InventoryPosition.class));
verify(balances, never()).updateById(any(InventoryBalance.class));
}
@@ -93,7 +93,7 @@ class InventoryCommandServiceTest {
@Test
void replayUsesPersistedResultWithoutMutatingInventoryAgain() throws Exception {
var request = stockIn("IN-1", "stock-in-1", 12, 24);
when(balances.lockDimension("100", "WH-1", "ONT8", "SKU-A")).thenReturn(null);
when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1")).thenReturn(null);
service.stockIn("100", "7", request);
ArgumentCaptor<InventoryOperation> operation = ArgumentCaptor.forClass(InventoryOperation.class);
verify(operations).insert(operation.capture());
@@ -110,16 +110,14 @@ class InventoryCommandServiceTest {
void shippingLastPackagesScrapsPalletWithoutReleasingItsVirtualLocation() {
InventoryBalance balance = balance(200L, 0);
balance.setPickedPackages(5L);
balance.setPickedQuantity(5L);
InventoryPosition position = position(201L, 200L, 0);
position.setPickedPackages(5L);
position.setProductQuantity(5L);
when(commands.lockPosition("100", 201L)).thenReturn(position);
when(commands.lockBalance("100", 200L)).thenReturn(balance);
when(commands.palletPackages("100", 300L)).thenReturn(0L);
var result = service.ship("100", "7", new InventoryCommandRequests.Move(
"100", 201L, 5, 5, "OUT-1", "OUT-1", null, "ship-1", null));
"100", 201L, 5, "OUT-1", "OUT-1", null, "ship-1", null));
assertTrue(result.palletEmpty());
assertEquals("EMPTY", position.getStatus());
@@ -129,17 +127,17 @@ class InventoryCommandServiceTest {
private InventoryCommandRequests.StockIn stockIn(String inbound, String key, long packages, long quantity) {
return new InventoryCommandRequests.StockIn("100", "Customer 100", "WH-1", "WH1", "Warehouse 1",
"DEST-1", "ONT8", "ONT8", "SKU-A", "Widget", 300L, "PLT-1", null, null, null,
packages, quantity, "ORD-1", inbound, "EVT-1", key, null);
"DEST-1", "ONT8", "ONT8", 101L, inbound, "IN-CODE-1", 300L, "PLT-1", null, null, null,
packages, "ORD-1", "EVT-1", key, null);
}
private InventoryBalance balance(Long id, long available) {
InventoryBalance value = new InventoryBalance();
value.setId(id); value.setTenantId("100"); value.setOverseasWarehouseId("WH-1");
value.setDestinationWarehouseCode("ONT8"); value.setSkuCode("SKU-A");
value.setAvailablePackages(available); value.setAvailableQuantity(available);
value.setDestinationWarehouseCode("ONT8"); value.setInboundOrderId(101L);
value.setInboundOrderNo("IN-1"); value.setInboundCode("IN-CODE-1");
value.setAvailablePackages(available);
value.setHoldPackages(0L); value.setAllocatedPackages(0L); value.setPickedPackages(0L);
value.setHoldQuantity(0L); value.setAllocatedQuantity(0L); value.setPickedQuantity(0L);
value.setVersion(0L);
return value;
}
@@ -150,7 +148,7 @@ class InventoryCommandServiceTest {
value.setPalletId(300L); value.setPalletNo("PLT-1"); value.setBatchNo("DEFAULT");
value.setAvailablePackages(available); value.setHoldPackages(0L);
value.setAllocatedPackages(0L); value.setPickedPackages(0L); value.setReceivingPackages(0L);
value.setProductQuantity(available); value.setVersion(0L);
value.setVersion(0L);
return value;
}
}
@@ -18,7 +18,7 @@ class InventoryQueryControllerTest {
private final InventoryQueryService service = mock(InventoryQueryService.class);
private final InventoryQueryController controller = controller();
private final InventoryQueryRequests.BalanceQuery query = new InventoryQueryRequests.BalanceQuery(
1, 20, null, null, null, null, null, null, null);
1, 20, null, null, null, null, null, null, null, null);
@Test
void merchantCannotOverrideCustomerScope() {
@@ -1,6 +1,7 @@
package com.baiyue.lastleg.inventory.query.service;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.query.dto.InventoryDashboardResponse;
import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests;
import com.baiyue.lastleg.inventory.query.mapper.InventoryQueryMapper;
@@ -10,10 +11,13 @@ import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
@@ -36,16 +40,14 @@ class InventoryQueryServiceTest {
InventoryAggregateProjection aggregate = new InventoryAggregateProjection();
aggregate.setDimensionCount(2L);
aggregate.setAvailablePackages(12L);
aggregate.setAvailableQuantity(120L);
when(mapper.aggregate("100", query)).thenReturn(aggregate);
when(mapper.countPallets("100", query)).thenReturn(3L);
InventoryDashboardResponse result = service.dashboard("100", query);
assertEquals(2, result.skuDimensionCount());
assertEquals(2, result.inboundOrderCount());
assertEquals(3, result.palletCount());
assertEquals(12, result.totalPackages());
assertEquals(120, result.totalQuantity());
}
@Test
@@ -74,12 +76,32 @@ class InventoryQueryServiceTest {
@Test
void positionDetailDoesNotLeakAnotherTenantBalance() {
InventoryQueryRequests.PositionQuery query = new InventoryQueryRequests.PositionQuery(null, null, null);
when(mapper.countOwnedBalance("100", 99L)).thenReturn(0L);
assertThrows(ResponseStatusException.class, () -> service.positions("100", 99L, query));
verify(mapper, never()).positions(eq("100"), eq(99L), any());
}
@Test
void positionDetailCarriesInboundOrderAndInboundCodeFromOwnedBalance() {
InventoryQueryRequests.PositionQuery query = new InventoryQueryRequests.PositionQuery(null, null, null);
InventoryBalance balance = new InventoryBalance();
balance.setId(99L);
balance.setInboundOrderId(88L);
balance.setInboundOrderNo("IBP-20260817-001");
balance.setInboundCode("RK-001");
InventoryPosition position = new InventoryPosition();
position.setBalanceId(99L);
position.setAvailablePackages(7L);
when(mapper.ownedBalance("100", 99L)).thenReturn(balance);
when(mapper.positions("100", 99L, query)).thenReturn(List.of(position));
var result = service.positions("100", 99L, query).get(0);
assertEquals(88L, result.inboundOrderId());
assertEquals("IBP-20260817-001", result.inboundOrderNo());
assertEquals("RK-001", result.inboundCode());
assertEquals(7L, result.totalPackages());
}
@Test
void reversedLedgerDateRangeIsRejected() {
InventoryQueryRequests.LedgerQuery query = new InventoryQueryRequests.LedgerQuery(
@@ -91,7 +113,19 @@ class InventoryQueryServiceTest {
verify(mapper, never()).countLedgers(any(), any());
}
@Test
void balanceExportUsesInboundOrderPackageColumnsOnly() {
InventoryQueryRequests.BalanceQuery query = balanceQuery(null);
when(mapper.balances("100", query, 0, 10_000)).thenReturn(List.of());
String csv = new String(service.exportBalances("100", query), StandardCharsets.UTF_8);
assertTrue(csv.contains("inboundOrderNo,inboundCode,totalPackages"));
assertFalse(csv.contains("skuCode"));
assertFalse(csv.contains("Quantity"));
}
private static InventoryQueryRequests.BalanceQuery balanceQuery(String status) {
return new InventoryQueryRequests.BalanceQuery(1, 20, null, null, null, null, null, null, status);
return new InventoryQueryRequests.BalanceQuery(1, 20, null, null, null, null, null, null, null, status);
}
}
@@ -0,0 +1,40 @@
package com.baiyue.lastleg.relabel;
import com.baiyue.lastleg.relabel.dto.RelabelRequests;
import org.junit.jupiter.api.Test;
import java.lang.reflect.RecordComponent;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class RelabelContractTest {
@Test
void createHeaderDoesNotExposeWaybillLabelFileAndItemCarriesRequestedFileFields() {
assertFalse(Arrays.stream(RelabelRequests.Create.class.getRecordComponents())
.map(RecordComponent::getName)
.anyMatch(name -> name.toLowerCase().contains("waybill") || name.toLowerCase().contains("labelFile")));
assertTrue(Arrays.stream(RelabelRequests.Item.class.getRecordComponents())
.map(RecordComponent::getName).toList().containsAll(Arrays.asList("skuCode", "productImageFileId",
"productNameZh", "totalPackageCount", "notifiedPackageCount", "shipmentNo", "trackingCode",
"newSkuCode", "labelRequirement", "relabelFileId")));
}
@Test
void schemasDefineRelabelApplicationItemsAndHistoryContracts() throws Exception {
String baseline = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8).toLowerCase();
String migration = Files.readString(Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8).toLowerCase();
for (String schema : new String[] { baseline, migration }) {
assertTrue(schema.contains("ll_relabel_application"));
assertTrue(schema.contains("ll_relabel_item"));
assertTrue(schema.contains("ll_relabel_status_history"));
assertTrue(schema.contains("tenant_id"));
assertTrue(schema.contains("idempotency_key"));
assertTrue(schema.contains("request_hash"));
}
}
}
@@ -0,0 +1,239 @@
package com.baiyue.lastleg.relabel.service;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.relabel.dto.RelabelRequests;
import com.baiyue.lastleg.relabel.dto.RelabelResponses;
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.service.LastlegOrderNoGenerator;
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 org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class RelabelApplicationServiceTest {
@Mock RelabelApplicationMapper applications;
@Mock RelabelItemMapper items;
@Mock RelabelStatusHistoryMapper histories;
@Mock LastlegOrderMapper orders;
@Mock LastlegOrderStatusHistoryMapper orderHistories;
@Mock LastlegOrderNoGenerator orderNoGenerator;
private RelabelApplicationService service;
@BeforeEach
void setUp() {
service = new RelabelApplicationService();
ReflectionTestUtils.setField(service, "applications", applications);
ReflectionTestUtils.setField(service, "items", items);
ReflectionTestUtils.setField(service, "histories", histories);
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "orderHistories", orderHistories);
ReflectionTestUtils.setField(service, "orderNoGenerator", orderNoGenerator);
ReflectionTestUtils.setField(service, "objectMapper", mapper());
lenient().when(orderNoGenerator.next()).thenReturn("LMO-RELABEL-1");
}
@Test
void createBuildsPendingRelabelTailOrderItemsAndBothHistories() {
RelabelRequests.Create request = request("key-1", 10, 6);
RelabelApplication persisted = application(11L, "FBR-1", "TENANT-A", "key-1", null,
RelabelApplicationService.PENDING_PROCESSING);
persisted.setLastlegOrderId(21L);
persisted.setItemCount(1); persisted.setTotalPackageCount(10); persisted.setNotifiedPackageCount(6);
when(applications.findNo(eq("TENANT-A"), any())).thenReturn(persisted);
when(items.selectList(any())).thenReturn(List.of(item(31L, 11L, "SKU-A", 10, 6)));
when(histories.selectList(any())).thenReturn(List.of());
when(orders.selectById(21L)).thenReturn(order(21L, "TENANT-A", "LMO-RELABEL-1",
RelabelApplicationService.PENDING_PROCESSING));
RelabelResponses.Detail result = service.create("TENANT-A", "operator-1", request);
ArgumentCaptor<LastlegOrder> tailOrder = ArgumentCaptor.forClass(LastlegOrder.class);
ArgumentCaptor<RelabelApplication> application = ArgumentCaptor.forClass(RelabelApplication.class);
ArgumentCaptor<RelabelItem> relabelItem = ArgumentCaptor.forClass(RelabelItem.class);
ArgumentCaptor<RelabelStatusHistory> relabelHistory = ArgumentCaptor.forClass(RelabelStatusHistory.class);
ArgumentCaptor<LastlegOrderStatusHistory> tailHistory = ArgumentCaptor.forClass(LastlegOrderStatusHistory.class);
verify(orders).insert(tailOrder.capture());
verify(applications).insert(application.capture());
verify(items).insert(relabelItem.capture());
verify(histories).insert(relabelHistory.capture());
verify(orderHistories).insert(tailHistory.capture());
assertEquals("RELABEL_APPLICATION", tailOrder.getValue().getOrderType());
assertEquals(RelabelApplicationService.PENDING_PROCESSING, tailOrder.getValue().getCurrentStatus());
assertEquals("CUSTOMER", tailOrder.getValue().getSourceRegion());
assertEquals("LMO-RELABEL-1", tailOrder.getValue().getOrderNo());
assertEquals(tailOrder.getValue().getInboundCode(), tailOrder.getValue().getSourceInboundId());
assertEquals(tailOrder.getValue().getInboundCode(), application.getValue().getRelabelNo());
assertTrue(tailOrder.getValue().getInboundCode().startsWith("FBR"));
assertEquals(10, tailOrder.getValue().getPackageCount());
assertEquals(6, application.getValue().getNotifiedPackageCount());
assertEquals("SKU-A", relabelItem.getValue().getSkuCode());
assertEquals("PENDING_PROCESSING", relabelHistory.getValue().getToStatus());
assertEquals("PENDING_PROCESSING", tailHistory.getValue().getToStatus());
assertEquals("FBR-1", result.application().relabelNo());
}
@Test
void createRejectsNotifiedBoxesAboveTotalBeforeAnyWrite() {
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () ->
service.create("TENANT-A", "operator-1", request("key-1", 5, 6)));
assertEquals(400, exception.getStatusCode().value());
verify(orders, never()).insert(any(LastlegOrder.class));
verify(applications, never()).insert(any(RelabelApplication.class));
verify(items, never()).insert(any(RelabelItem.class));
}
@Test
void sameIdempotencyKeyAndPayloadReturnsExistingApplicationWithoutWrites() {
RelabelRequests.Create request = request("key-1", 10, 6);
RelabelApplication existing = application(11L, "FBR-1", "TENANT-A", "key-1", hash(request),
RelabelApplicationService.PENDING_PROCESSING);
existing.setLastlegOrderId(21L); existing.setItemCount(1); existing.setTotalPackageCount(10);
existing.setNotifiedPackageCount(6);
when(applications.findKey("TENANT-A", "key-1")).thenReturn(existing);
stubDetail(existing);
RelabelResponses.Detail result = service.create("TENANT-A", "operator-1", request);
assertEquals("FBR-1", result.application().relabelNo());
verify(orders, never()).insert(any(LastlegOrder.class));
verify(applications, never()).insert(any(RelabelApplication.class));
verify(items, never()).insert(any(RelabelItem.class));
}
@Test
void idempotencyKeyCannotBeReusedWithDifferentRelabelPayload() {
RelabelRequests.Create request = request("key-1", 10, 6);
RelabelApplication existing = application(11L, "FBR-1", "TENANT-A", "key-1", "different-hash",
RelabelApplicationService.PENDING_PROCESSING);
when(applications.findKey("TENANT-A", "key-1")).thenReturn(existing);
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () ->
service.create("TENANT-A", "operator-1", request));
assertEquals(409, exception.getStatusCode().value());
verify(orders, never()).insert(any(LastlegOrder.class));
}
@Test
void statusChangeIsTenantScopedAndWritesApplicationAndTailOrderHistory() {
RelabelApplication application = application(11L, "FBR-1", "TENANT-A", "key-1", "hash",
RelabelApplicationService.PENDING_PROCESSING);
application.setLastlegOrderId(21L);
when(applications.lockNo("TENANT-A", "FBR-1")).thenReturn(application);
stubDetail(application);
RelabelResponses.Detail result = service.changeStatus("TENANT-A", "FBR-1", "operator-2",
new RelabelRequests.ChangeStatus("processing", "started"));
assertEquals("PROCESSING", application.getStatus());
assertEquals("PROCESSING", result.application().status());
verify(applications).updateById(application);
ArgumentCaptor<LastlegOrder> updatedOrder = ArgumentCaptor.forClass(LastlegOrder.class);
verify(orders).updateById(updatedOrder.capture());
assertEquals("PROCESSING", updatedOrder.getValue().getCurrentStatus());
verify(histories).insert(any(RelabelStatusHistory.class));
verify(orderHistories).insert(any(LastlegOrderStatusHistory.class));
}
@Test
void completedApplicationCannotReturnToProcessingAndOtherTenantCannotReadIt() {
RelabelApplication completed = application(11L, "FBR-1", "TENANT-A", "key-1", "hash", "COMPLETED");
completed.setLastlegOrderId(21L);
when(applications.lockNo("TENANT-A", "FBR-1")).thenReturn(completed);
ResponseStatusException transition = assertThrows(ResponseStatusException.class, () ->
service.changeStatus("TENANT-A", "FBR-1", "operator-2", new RelabelRequests.ChangeStatus("PROCESSING", null)));
assertEquals(409, transition.getStatusCode().value());
when(applications.findNo("TENANT-B", "FBR-1")).thenReturn(null);
ResponseStatusException boundary = assertThrows(ResponseStatusException.class, () ->
service.detail("TENANT-B", "FBR-1"));
assertEquals(404, boundary.getStatusCode().value());
verify(applications).findNo("TENANT-B", "FBR-1");
}
private void stubDetail(RelabelApplication application) {
when(applications.findNo(application.getTenantId(), application.getRelabelNo())).thenReturn(application);
when(items.selectList(any())).thenReturn(List.of(item(31L, application.getId(), "SKU-A", 10, 6)));
when(histories.selectList(any())).thenReturn(List.of());
when(orders.selectById(application.getLastlegOrderId())).thenReturn(order(application.getLastlegOrderId(),
application.getTenantId(), "LMO-RELABEL-1", application.getStatus()));
}
private static RelabelRequests.Create request(String key, int total, int notified) {
return new RelabelRequests.Create("CUSTOMER-1", "WH-1", LocalDateTime.of(2026, 8, 20, 10, 0),
"Li", "13800000000", "remark", key, List.of(new RelabelRequests.Item("SKU-A", "img-1",
"image.png", "https://files/image.png", "Chinese name", total, notified, "SHP-1", "TRACK-1",
"SKU-NEW", "Label on carton", "file-1", "relabel.pdf", "https://files/relabel.pdf")));
}
private static RelabelApplication application(long id, String no, String tenantId, String key, String requestHash,
String status) {
RelabelApplication value = new RelabelApplication();
value.setId(id); value.setTenantId(tenantId); value.setRelabelNo(no); value.setIdempotencyKey(key);
value.setRequestHash(requestHash); value.setStatus(status); value.setCreatedAt(LocalDateTime.now());
value.setContactName("Li"); value.setContactPhone("13800000000"); value.setCustomerOrderNo("CUSTOMER-1");
value.setOverseasWarehouseId("WH-1"); value.setLabelDeadline(LocalDateTime.now().plusDays(1));
value.setItemCount(1); value.setTotalPackageCount(10); value.setNotifiedPackageCount(6);
return value;
}
private static RelabelItem item(long id, long applicationId, String sku, int total, int notified) {
RelabelItem value = new RelabelItem();
value.setId(id); value.setApplicationId(applicationId); value.setTenantId("TENANT-A"); value.setSkuCode(sku);
value.setProductNameZh("Chinese name"); value.setTotalPackageCount(total); value.setNotifiedPackageCount(notified);
value.setShipmentNo("SHP-1"); value.setNewSkuCode("SKU-NEW");
return value;
}
private static LastlegOrder order(long id, String tenantId, String orderNo, String status) {
LastlegOrder value = new LastlegOrder();
value.setId(id); value.setTenantId(tenantId); value.setOrderNo(orderNo); value.setCurrentStatus(status);
return value;
}
private static String hash(RelabelRequests.Create request) {
try {
byte[] digest = java.security.MessageDigest.getInstance("SHA-256")
.digest(mapper().writeValueAsString(request).getBytes(java.nio.charset.StandardCharsets.UTF_8));
return java.util.HexFormat.of().formatHex(digest);
} catch (Exception exception) {
throw new AssertionError(exception);
}
}
private static ObjectMapper mapper() {
return new ObjectMapper().findAndRegisterModules();
}
}