提交
This commit is contained in:
@@ -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 {
|
||||
}
|
||||
+74
@@ -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);
|
||||
}
|
||||
+14
@@ -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);
|
||||
}
|
||||
+212
@@ -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;
|
||||
|
||||
+12
-6
@@ -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 > 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 > 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 > 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 > 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 > 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;
|
||||
|
||||
+8
@@ -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) {}
|
||||
}
|
||||
|
||||
+11
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
@@ -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;
|
||||
|
||||
+31
@@ -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);
|
||||
}
|
||||
+74
-10
@@ -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); }
|
||||
|
||||
+105
@@ -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;
|
||||
|
||||
+1
-5
@@ -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;
|
||||
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
|
||||
+20
-107
@@ -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) {}
|
||||
}
|
||||
|
||||
+8
-8
@@ -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,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,
|
||||
|
||||
+19
-62
@@ -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
-11
@@ -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);
|
||||
|
||||
+2
-2
@@ -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 >=#{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,
|
||||
|
||||
+1
-1
@@ -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;
|
||||
}
|
||||
|
||||
+4
-4
@@ -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,
|
||||
|
||||
+3
-10
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
+2
-10
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
+3
-5
@@ -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
|
||||
|
||||
+3
-2
@@ -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
|
||||
) {
|
||||
|
||||
+4
-3
@@ -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,
|
||||
|
||||
+10
-15
@@ -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 > 0</when>" +
|
||||
"<when test='q.inventoryStatus == \"HOLD\"'> AND hold_packages > 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 > 0</when>" +
|
||||
"<when test='q.inventoryStatus == \"HOLD\"'> AND b.hold_packages > 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>" +
|
||||
|
||||
-7
@@ -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;
|
||||
}
|
||||
|
||||
+25
-45
@@ -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 {
|
||||
}
|
||||
+53
@@ -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> {
|
||||
}
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+252
@@ -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(),
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
+297
@@ -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;
|
||||
}
|
||||
}
|
||||
+38
@@ -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();
|
||||
|
||||
+89
@@ -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");
|
||||
}
|
||||
|
||||
+9
-15
@@ -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
|
||||
|
||||
+42
-3
@@ -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) {
|
||||
|
||||
+10
-12
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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() {
|
||||
|
||||
+40
-6
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+239
@@ -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();
|
||||
}
|
||||
}
|
||||
+300
-19
@@ -659,7 +659,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment_item (
|
||||
|
||||
-- ============================================================================
|
||||
-- 2026-08-17 / 008
|
||||
-- Last-leg SKU/package inventory balances, positions and immutable ledger
|
||||
-- Last-leg inbound-order/package inventory balances, positions and immutable ledger
|
||||
-- ============================================================================
|
||||
|
||||
USE baiyue_lastleg;
|
||||
@@ -669,44 +669,40 @@ CREATE TABLE IF NOT EXISTS ll_inventory_balance (
|
||||
overseas_warehouse_id VARCHAR(128) NOT NULL, overseas_warehouse_code VARCHAR(128) NULL,
|
||||
overseas_warehouse_name VARCHAR(255) NULL, destination_warehouse_id VARCHAR(128) NULL,
|
||||
destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '', destination_warehouse_name VARCHAR(255) NULL,
|
||||
sku_code VARCHAR(128) NOT NULL, sku_name_zh VARCHAR(255) NULL,
|
||||
inbound_order_id BIGINT NOT NULL, inbound_order_no VARCHAR(128) NULL, inbound_code VARCHAR(128) NOT NULL,
|
||||
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,
|
||||
procurement_packages BIGINT NOT NULL DEFAULT 0, 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_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,
|
||||
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,
|
||||
@@ -714,14 +710,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,
|
||||
@@ -784,7 +779,8 @@ USE baiyue_lastleg;
|
||||
|
||||
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, vehicle_plate VARCHAR(64) NULL, driver_name VARCHAR(128) NULL,
|
||||
dispatch_no VARCHAR(64) NOT NULL, overseas_warehouse_id VARCHAR(128) NULL, overseas_warehouse_name VARCHAR(255) NULL,
|
||||
vehicle_plate VARCHAR(64) NULL, driver_name VARCHAR(128) NULL,
|
||||
driver_phone VARCHAR(64) NULL, carrier_name VARCHAR(255) NULL, route VARCHAR(255) NULL,
|
||||
depart_at DATETIME(3) NULL, fba_appointment_no VARCHAR(64) NULL,
|
||||
fba_appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
|
||||
@@ -801,6 +797,7 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_item (
|
||||
id BIGINT NOT NULL COMMENT 'Snowflake ID', dispatch_order_id BIGINT NOT NULL, tenant_id VARCHAR(128) NOT NULL,
|
||||
plan_id BIGINT NULL, plan_pallet_id BIGINT NULL, warehouse_pallet_id BIGINT NULL,
|
||||
order_id BIGINT NULL, order_no VARCHAR(64) NULL, 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, fba_warehouse_code VARCHAR(128) NULL,
|
||||
package_count INT NOT NULL DEFAULT 0, volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0,
|
||||
@@ -833,7 +830,8 @@ USE baiyue_lastleg;
|
||||
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,
|
||||
operator_name VARCHAR(128) NULL, pushed_at DATETIME(3) NOT NULL, completed_at DATETIME(3) NULL,
|
||||
operator_name VARCHAR(128) NULL, warehouse_id 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),
|
||||
PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_pda_task_dispatch (tenant_id,dispatch_order_id),
|
||||
@@ -867,3 +865,286 @@ USE baiyue_lastleg;
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='order_refs'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN order_refs VARCHAR(2000) NULL AFTER order_no'));
|
||||
PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt;
|
||||
|
||||
-- ============================================================================
|
||||
-- 2026-08-17 / 013
|
||||
-- Partial dispatch completion and cumulative package counters
|
||||
-- ============================================================================
|
||||
USE baiyue_lastleg;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='overseas_warehouse_id'), 'SELECT 1', 'ALTER TABLE ll_dispatch_order ADD COLUMN overseas_warehouse_id VARCHAR(128) NULL AFTER dispatch_no'));
|
||||
PREPARE warehouse_scope_stmt FROM @ddl; EXECUTE warehouse_scope_stmt; DEALLOCATE PREPARE warehouse_scope_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='overseas_warehouse_name'), 'SELECT 1', 'ALTER TABLE ll_dispatch_order ADD COLUMN overseas_warehouse_name VARCHAR(255) NULL AFTER overseas_warehouse_id'));
|
||||
PREPARE warehouse_scope_stmt FROM @ddl; EXECUTE warehouse_scope_stmt; DEALLOCATE PREPARE warehouse_scope_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='overseas_warehouse_id'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN overseas_warehouse_id VARCHAR(128) NULL AFTER pallet_no'));
|
||||
PREPARE warehouse_scope_stmt FROM @ddl; EXECUTE warehouse_scope_stmt; DEALLOCATE PREPARE warehouse_scope_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='overseas_warehouse_name'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN overseas_warehouse_name VARCHAR(255) NULL AFTER overseas_warehouse_id'));
|
||||
PREPARE warehouse_scope_stmt FROM @ddl; EXECUTE warehouse_scope_stmt; DEALLOCATE PREPARE warehouse_scope_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_task' AND column_name='warehouse_id'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pda_task ADD COLUMN warehouse_id VARCHAR(128) NULL AFTER operator_name'));
|
||||
PREPARE warehouse_scope_stmt FROM @ddl; EXECUTE warehouse_scope_stmt; DEALLOCATE PREPARE warehouse_scope_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='picked_package_count'), 'SELECT 1', 'ALTER TABLE ll_dispatch_order ADD COLUMN picked_package_count INT NOT NULL DEFAULT 0 AFTER package_count'));
|
||||
PREPARE dispatch_partial_stmt FROM @ddl; EXECUTE dispatch_partial_stmt; DEALLOCATE PREPARE dispatch_partial_stmt;
|
||||
|
||||
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';
|
||||
|
||||
-- Tail order cumulative picking/shipping counters. Original package_count is immutable.
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='picked_package_count'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN picked_package_count INT NOT NULL DEFAULT 0 AFTER package_count'));
|
||||
PREPARE tail_order_partial_stmt FROM @ddl; EXECUTE tail_order_partial_stmt; DEALLOCATE PREPARE tail_order_partial_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='shipped_package_count'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN shipped_package_count INT NOT NULL DEFAULT 0 AFTER picked_package_count'));
|
||||
PREPARE tail_order_partial_stmt FROM @ddl; EXECUTE tail_order_partial_stmt; DEALLOCATE PREPARE tail_order_partial_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='shipped_package_count'), 'SELECT 1', 'ALTER TABLE ll_dispatch_order ADD COLUMN shipped_package_count INT NOT NULL DEFAULT 0 AFTER picked_package_count'));
|
||||
PREPARE dispatch_partial_stmt FROM @ddl; EXECUTE dispatch_partial_stmt; DEALLOCATE PREPARE dispatch_partial_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='partial_completion_reason'), 'SELECT 1', 'ALTER TABLE ll_dispatch_order ADD COLUMN partial_completion_reason VARCHAR(1000) NULL AFTER shipped_package_count'));
|
||||
PREPARE dispatch_partial_stmt FROM @ddl; EXECUTE dispatch_partial_stmt; DEALLOCATE PREPARE dispatch_partial_stmt;
|
||||
|
||||
-- ============================================================================
|
||||
-- 2026-08-17 / 014
|
||||
-- Dispatch transfer to outbound and delivery documents
|
||||
-- ============================================================================
|
||||
USE baiyue_lastleg;
|
||||
|
||||
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';
|
||||
|
||||
-- ============================================================================
|
||||
-- 2026-08-17 / 015
|
||||
-- Change inventory ownership from SKU/quantity to inbound-order/package count.
|
||||
-- Legacy SKU columns become nullable and quantity columns remain untouched so original data is preserved.
|
||||
-- ============================================================================
|
||||
USE baiyue_lastleg;
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_order_id'), 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN inbound_order_id BIGINT NULL AFTER destination_warehouse_name'));
|
||||
PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt;
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_order_no'), 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN inbound_order_no VARCHAR(128) NULL AFTER inbound_order_id'));
|
||||
PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_code'), 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN inbound_code VARCHAR(128) NULL AFTER inbound_order_no'));
|
||||
PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt;
|
||||
|
||||
-- One legacy balance row represented one SKU. Keep each row intact and give it a
|
||||
-- collision-free migration identity while retaining the best known inbound snapshots.
|
||||
UPDATE ll_inventory_balance b
|
||||
LEFT JOIN (
|
||||
SELECT balance_id, MAX(inbound_order_no) AS inbound_order_no, MAX(inbound_code) AS inbound_code
|
||||
FROM ll_inventory_ledger GROUP BY balance_id
|
||||
) l ON l.balance_id=b.id
|
||||
SET b.inbound_order_id=COALESCE(b.inbound_order_id,b.id),
|
||||
b.inbound_order_no=COALESCE(b.inbound_order_no,l.inbound_order_no),
|
||||
b.inbound_code=COALESCE(NULLIF(b.inbound_code,''),NULLIF(l.inbound_code,''),CONCAT('LEGACY-',b.id))
|
||||
WHERE b.inbound_order_id IS NULL OR b.inbound_code IS NULL OR b.inbound_code='';
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='sku_code'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN sku_code VARCHAR(128) NULL', 'SELECT 1'));
|
||||
PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_order_id'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN inbound_order_id BIGINT NOT NULL', 'SELECT 1'));
|
||||
PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_code'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL', 'SELECT 1'));
|
||||
PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt;
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='uk_ll_inventory_dimension'), 'ALTER TABLE ll_inventory_balance DROP INDEX uk_ll_inventory_dimension', 'SELECT 1'));
|
||||
PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='uk_ll_inventory_dimension'), 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD UNIQUE KEY uk_ll_inventory_dimension (tenant_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code)'));
|
||||
PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='idx_ll_inventory_inbound'), 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD KEY idx_ll_inventory_inbound (tenant_id,inbound_code)'));
|
||||
PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='idx_ll_inventory_sku'), 'ALTER TABLE ll_inventory_balance DROP INDEX idx_ll_inventory_sku', 'SELECT 1'));
|
||||
PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt;
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='sku_code'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN sku_code VARCHAR(128) NULL', 'SELECT 1'));
|
||||
PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt;
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='inbound_order_id'), 'SELECT 1', 'ALTER TABLE ll_inventory_ledger ADD COLUMN inbound_order_id BIGINT NULL AFTER destination_warehouse_code'));
|
||||
PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt;
|
||||
UPDATE ll_inventory_ledger SET inbound_order_id=COALESCE(inbound_order_id,business_order_id,balance_id) WHERE inbound_order_id IS NULL;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='sku_code'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN sku_code VARCHAR(128) NULL', 'SELECT 1'));
|
||||
PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='inbound_order_id'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN inbound_order_id BIGINT NOT NULL', 'SELECT 1'));
|
||||
PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND index_name='idx_ll_inventory_ledger_inbound'), 'SELECT 1', 'ALTER TABLE ll_inventory_ledger ADD KEY idx_ll_inventory_ledger_inbound (tenant_id,inbound_code,occurred_at)'));
|
||||
PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND index_name='idx_ll_inventory_ledger_sku'), 'ALTER TABLE ll_inventory_ledger DROP INDEX idx_ll_inventory_ledger_sku', 'SELECT 1'));
|
||||
PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt;
|
||||
|
||||
-- ============================================================================
|
||||
-- 2026-08-17 / 016
|
||||
-- PDA stock receipts follow the same inbound-order/package-only inventory model.
|
||||
-- Legacy SKU and quantity columns remain in place but become nullable/defaulted.
|
||||
-- ============================================================================
|
||||
USE baiyue_lastleg;
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='sku_code'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN sku_code VARCHAR(128) NULL', 'SELECT 1'));
|
||||
PREPARE stock_receipt_v2_stmt FROM @ddl; EXECUTE stock_receipt_v2_stmt; DEALLOCATE PREPARE stock_receipt_v2_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='received_quantity'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN received_quantity BIGINT NULL DEFAULT 0', 'SELECT 1'));
|
||||
PREPARE stock_receipt_v2_stmt FROM @ddl; EXECUTE stock_receipt_v2_stmt; DEALLOCATE PREPARE stock_receipt_v2_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND index_name='uk_ll_stock_receipt_task_allocation_sku'), 'ALTER TABLE ll_inbound_stock_receipt DROP INDEX uk_ll_stock_receipt_task_allocation_sku', 'SELECT 1'));
|
||||
PREPARE stock_receipt_v2_stmt FROM @ddl; EXECUTE stock_receipt_v2_stmt; DEALLOCATE PREPARE stock_receipt_v2_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND index_name='uk_ll_stock_receipt_task_allocation'), 'SELECT 1', 'ALTER TABLE ll_inbound_stock_receipt ADD UNIQUE KEY uk_ll_stock_receipt_task_allocation (tenant_id,task_id,allocation_id)'));
|
||||
PREPARE stock_receipt_v2_stmt FROM @ddl; EXECUTE stock_receipt_v2_stmt; DEALLOCATE PREPARE stock_receipt_v2_stmt;
|
||||
|
||||
-- ============================================================================
|
||||
-- 2026-08-17 / 017
|
||||
-- Customer relabel applications, cargo snapshots, and process status history
|
||||
-- ============================================================================
|
||||
USE baiyue_lastleg;
|
||||
|
||||
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)
|
||||
) 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)
|
||||
) 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)
|
||||
) ENGINE=InnoDB COMMENT='Immutable customer relabel process history';
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND index_name='uk_ll_relabel_application_no'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD UNIQUE KEY uk_ll_relabel_application_no (tenant_id,relabel_no)'));
|
||||
PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND index_name='uk_ll_relabel_application_idempotency'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD UNIQUE KEY uk_ll_relabel_application_idempotency (tenant_id,idempotency_key)'));
|
||||
PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND index_name='uk_ll_relabel_application_order'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD UNIQUE KEY uk_ll_relabel_application_order (lastleg_order_id)'));
|
||||
PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND index_name='idx_ll_relabel_application_list'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD KEY idx_ll_relabel_application_list (tenant_id,status,created_at)'));
|
||||
PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND index_name='idx_ll_relabel_application_customer'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD KEY idx_ll_relabel_application_customer (tenant_id,customer_order_no)'));
|
||||
PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND index_name='idx_ll_relabel_application_warehouse'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD KEY idx_ll_relabel_application_warehouse (tenant_id,overseas_warehouse_id)'));
|
||||
PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND index_name='idx_ll_relabel_item_application'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD KEY idx_ll_relabel_item_application (tenant_id,application_id)'));
|
||||
PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND index_name='idx_ll_relabel_item_sku'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD KEY idx_ll_relabel_item_sku (tenant_id,sku_code)'));
|
||||
PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_status_history' AND index_name='idx_ll_relabel_status_history_application'), 'SELECT 1', 'ALTER TABLE ll_relabel_status_history ADD KEY idx_ll_relabel_status_history_application (tenant_id,application_id,created_at)'));
|
||||
PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt;
|
||||
|
||||
-- ============================================================================
|
||||
-- 2026-08-17 / 018
|
||||
-- Existing-table correction: apply schema changes with ADD/UPDATE/ALTER guards
|
||||
-- ============================================================================
|
||||
-- Sections 008-017 keep CREATE TABLE definitions for fresh databases. This
|
||||
-- section is the authoritative path for databases that already contain those
|
||||
-- tables. Legacy SKU/quantity columns are retained and made nullable; no
|
||||
-- business rows or Outbox rows are deleted.
|
||||
USE baiyue_lastleg;
|
||||
|
||||
-- Tail orders: cumulative PDA picking/shipping counters.
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='picked_package_count'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN picked_package_count INT NOT NULL DEFAULT 0 COMMENT ''累计已拣箱数'' AFTER package_count'));
|
||||
PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='shipped_package_count'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN shipped_package_count INT NOT NULL DEFAULT 0 COMMENT ''累计已出库箱数'' AFTER picked_package_count'));
|
||||
PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018;
|
||||
|
||||
-- Inventory balance: add inbound-order identity without dropping legacy SKU data.
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_order_id'), 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN inbound_order_id BIGINT NULL COMMENT ''入仓单ID'' AFTER destination_warehouse_name'));
|
||||
PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_order_no'), 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN inbound_order_no VARCHAR(128) NULL COMMENT ''入库单号快照'' AFTER inbound_order_id'));
|
||||
PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_code'), 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN inbound_code VARCHAR(128) NULL COMMENT ''入仓码快照'' AFTER inbound_order_no'));
|
||||
PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018;
|
||||
UPDATE ll_inventory_balance
|
||||
SET inbound_order_id = COALESCE(inbound_order_id, id),
|
||||
inbound_code = COALESCE(NULLIF(inbound_code, ''), CONCAT('LEGACY-', id))
|
||||
WHERE inbound_order_id IS NULL OR inbound_code IS NULL OR inbound_code = '';
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_order_id'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN inbound_order_id BIGINT NOT NULL', 'SELECT 1'));
|
||||
PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_code'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL', 'SELECT 1'));
|
||||
PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='uk_ll_inventory_dimension'), 'ALTER TABLE ll_inventory_balance DROP INDEX uk_ll_inventory_dimension', 'SELECT 1'));
|
||||
PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='uk_ll_inventory_dimension'), 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD UNIQUE KEY uk_ll_inventory_dimension (tenant_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code)'));
|
||||
PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='idx_ll_inventory_sku'), 'ALTER TABLE ll_inventory_balance DROP INDEX idx_ll_inventory_sku', 'SELECT 1'));
|
||||
PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='idx_ll_inventory_inbound'), 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD KEY idx_ll_inventory_inbound (tenant_id,inbound_code)'));
|
||||
PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018;
|
||||
|
||||
-- Inventory positions and ledger: preserve SKU columns, add inbound identity.
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='sku_code'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN sku_code VARCHAR(128) NULL', 'SELECT 1'));
|
||||
PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='inbound_order_id'), 'SELECT 1', 'ALTER TABLE ll_inventory_ledger ADD COLUMN inbound_order_id BIGINT NULL COMMENT ''入仓单ID'' AFTER destination_warehouse_code'));
|
||||
PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018;
|
||||
UPDATE ll_inventory_ledger
|
||||
SET inbound_order_id = COALESCE(inbound_order_id, business_order_id, balance_id)
|
||||
WHERE inbound_order_id IS NULL;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='inbound_order_id'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN inbound_order_id BIGINT NOT NULL', 'SELECT 1'));
|
||||
PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='sku_code'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN sku_code VARCHAR(128) NULL', 'SELECT 1'));
|
||||
PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND index_name='idx_ll_inventory_ledger_sku'), 'ALTER TABLE ll_inventory_ledger DROP INDEX idx_ll_inventory_ledger_sku', 'SELECT 1'));
|
||||
PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND index_name='idx_ll_inventory_ledger_inbound'), 'SELECT 1', 'ALTER TABLE ll_inventory_ledger ADD KEY idx_ll_inventory_ledger_inbound (tenant_id,inbound_code,occurred_at)'));
|
||||
PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018;
|
||||
|
||||
-- Stock receipts: keep legacy SKU/quantity snapshots but allow inbound-only rows.
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='sku_code'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN sku_code VARCHAR(128) NULL', 'SELECT 1'));
|
||||
PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='received_quantity'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN received_quantity BIGINT NULL DEFAULT 0', 'SELECT 1'));
|
||||
PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND index_name='uk_ll_stock_receipt_task_allocation_sku'), 'ALTER TABLE ll_inbound_stock_receipt DROP INDEX uk_ll_stock_receipt_task_allocation_sku', 'SELECT 1'));
|
||||
PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND index_name='uk_ll_stock_receipt_task_allocation'), 'SELECT 1', 'ALTER TABLE ll_inbound_stock_receipt ADD UNIQUE KEY uk_ll_stock_receipt_task_allocation (tenant_id,task_id,allocation_id)'));
|
||||
PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018;
|
||||
|
||||
-- Dispatch/PDA fields are also ADD operations for existing installations.
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='picked_package_count'), 'SELECT 1', 'ALTER TABLE ll_dispatch_order ADD COLUMN picked_package_count INT NOT NULL DEFAULT 0 COMMENT ''累计已拣箱数'' AFTER package_count'));
|
||||
PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='shipped_package_count'), 'SELECT 1', 'ALTER TABLE ll_dispatch_order ADD COLUMN shipped_package_count INT NOT NULL DEFAULT 0 COMMENT ''累计已出库箱数'' AFTER picked_package_count'));
|
||||
PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='partial_completion_reason'), 'SELECT 1', 'ALTER TABLE ll_dispatch_order ADD COLUMN partial_completion_reason VARCHAR(1000) NULL COMMENT ''部分完成原因'' AFTER shipped_package_count'));
|
||||
PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018;
|
||||
|
||||
-- No DROP COLUMN is included. Existing columns and business rows remain recoverable.
|
||||
|
||||
Reference in New Issue
Block a user