暂存提交

This commit is contained in:
2026-08-19 16:24:02 +08:00
parent f5c4bf130a
commit 6acb6178da
354 changed files with 25000 additions and 198 deletions
@@ -0,0 +1,14 @@
package com.baiyue.lastleg;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 尾程微服务预留模块启动入口,初始化 Spring Boot 应用并扫描尾程 Mapper。
*/
@MapperScan("com.baiyue.lastleg.mapper")
@SpringBootApplication(scanBasePackages = "com.baiyue")
public class BaiyueLastlegApplication {
public static void main(String[] args) { SpringApplication.run(BaiyueLastlegApplication.class, args); }
}
@@ -0,0 +1,34 @@
package com.baiyue.lastleg.controller;
import com.baiyue.common.core.api.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* 尾程预留服务状态接口,用于验证尾程服务边界及基础可用性。
*/
@Tag(
name = "Last-leg service status / 尾程服务状态",
description = "Last-leg reserved service availability API. / 尾程预留服务的可用性接口。"
)
@RestController
@RequestMapping("/api/platform/v1/last-leg")
public class LastlegHealthController {
@Operation(
summary = "Get service status / 查询服务状态",
description = "Returns the logical status of the reserved lastleg-service boundary. / 返回预留 lastleg-service 边界的逻辑运行状态。"
)
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "Service is available / 服务可用"
)
@GetMapping("/status")
public ApiResponse<Map<String, String>> status() {
return ApiResponse.success(Map.of("service", "lastleg-service", "status", "UP"));
}
}
@@ -0,0 +1,113 @@
package com.baiyue.lastleg.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.dto.LastlegOrderDetailResponse;
import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.dto.LastlegOrderSummary;
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
import com.baiyue.lastleg.service.LastlegOrderService;
import com.baiyue.lastleg.service.LoadingCompletedConsumer;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.annotation.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
@RestController
@Tag(name = "Tail-leg orders")
public class LastlegOrderController {
@Resource private LoadingCompletedConsumer consumer;
@Resource private LastlegOrderService orderService;
@Resource private InternalServiceTokenGuard tokenGuard;
@PostMapping("/internal/lastleg/events")
@Operation(summary = "Receive a first-leg loading-completed event")
public ApiResponse<Integer> receive(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestBody EventEnvelope event) {
tokenGuard.verify(token);
return ApiResponse.success(consumer.consume(event));
}
@GetMapping("/api/platform/v1/last-leg/orders")
@Operation(summary = "Page tail-leg order rows")
public ApiResponse<PageResult<LastlegOrderSummary>> list(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid LastlegOrderRequests.Query query) {
return ApiResponse.success(orderService.page(scope(user, customerId), query));
}
@GetMapping("/api/platform/v1/last-leg/orders/{id}")
@Operation(summary = "Get one inbound-code-level tail-leg order")
public ApiResponse<LastlegOrderDetailResponse> detail(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@PathVariable long id) {
return ApiResponse.success(orderService.detail(scope(user, customerId), id));
}
@GetMapping(value = "/api/platform/v1/last-leg/orders/export", produces = "text/csv")
@Operation(summary = "Export filtered tail-leg orders as UTF-8 CSV")
public ResponseEntity<byte[]> export(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid LastlegOrderRequests.Query query) {
byte[] body = orderService.exportCsv(scope(user, customerId), query);
return ResponseEntity.ok()
.contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=lastleg-orders.csv")
.body(body);
}
@PutMapping("/api/platform/v1/last-leg/orders/{id}/destination")
@Operation(summary = "Transfer one order to a destination warehouse")
public ApiResponse<Integer> transferOne(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@PathVariable long id,
@Valid @RequestBody LastlegOrderRequests.SingleTransferDestination request) {
return ApiResponse.success(orderService.transferDestination(scope(user, customerId), request.withOrderId(id)));
}
@PutMapping("/api/platform/v1/last-leg/orders/destination")
@Operation(summary = "Transfer multiple orders to a destination warehouse")
public ApiResponse<Integer> transferBatch(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid @RequestBody LastlegOrderRequests.TransferDestination request) {
return ApiResponse.success(orderService.transferDestination(scope(user, customerId), request));
}
private String scope(AuthenticatedUser user, String requestedCustomer) {
if (user == null || user.tenantId() == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
}
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Platform queries must specify a customer");
}
return requestedCustomer;
}
return String.valueOf(user.tenantId());
}
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.delivery;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan("com.baiyue.lastleg.delivery.mapper")
public class DeliveryMapperConfiguration {
}
@@ -0,0 +1,74 @@
package com.baiyue.lastleg.delivery.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
import com.baiyue.lastleg.delivery.dto.DeliveryResponses;
import com.baiyue.lastleg.delivery.service.DeliveryTransferService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/platform/v1/last-leg")
@Tag(name = "Outbound and delivery")
public class DeliveryTransferController {
@Resource private DeliveryTransferService service;
@PostMapping("/dispatches/{dispatchNo}/transfer-delivery")
@Operation(summary = "Create one pending outbound order and one pending delivery order")
public ApiResponse<DeliveryResponses.TransferResult> transfer(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo,
@RequestParam(required = false) String customerId,
@Valid @RequestBody DeliveryRequests.Transfer request) {
return ApiResponse.success(service.transfer(scope(user, customerId), dispatchNo, operator(user), request));
}
@GetMapping("/outbound-orders")
public ApiResponse<PageResult<DeliveryResponses.OutboundRow>> outboundPage(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@Valid DeliveryRequests.Query query) {
return ApiResponse.success(service.outboundPage(scope(user, customerId), query));
}
@GetMapping("/outbound-orders/{outboundNo}")
public ApiResponse<DeliveryResponses.OutboundDetail> outboundDetail(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@PathVariable String outboundNo, @Valid DeliveryRequests.PalletQuery query) {
return ApiResponse.success(service.outboundDetail(scope(user, customerId), outboundNo, query));
}
@GetMapping("/delivery-orders")
public ApiResponse<PageResult<DeliveryResponses.DeliveryRow>> deliveryPage(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@Valid DeliveryRequests.Query query) {
return ApiResponse.success(service.deliveryPage(scope(user, customerId), query));
}
@GetMapping("/delivery-orders/{deliveryNo}")
public ApiResponse<DeliveryResponses.OutboundDetail> deliveryDetail(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@PathVariable String deliveryNo, @Valid DeliveryRequests.PalletQuery query) {
return ApiResponse.success(service.deliveryDetail(scope(user, customerId), deliveryNo, query));
}
private String scope(AuthenticatedUser user, String requestedCustomer) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant");
}
return requestedCustomer.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 customerId;
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 customerId;
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 customerId;
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 customer_id=#{customerId} AND delivery_no=#{deliveryNo} LIMIT 1")
DeliveryOrder findByNo(@Param("customerId") String customerId, @Param("deliveryNo") String deliveryNo);
@Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1")
DeliveryOrder findByIdempotency(@Param("customerId") String customerId, @Param("key") String key);
@Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND outbound_order_id=#{outboundOrderId} LIMIT 1 FOR UPDATE")
DeliveryOrder lockByOutbound(@Param("customerId") String customerId, @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 customer_id=#{customerId} AND outbound_no=#{outboundNo} LIMIT 1")
OutboundOrder findByNo(@Param("customerId") String customerId, @Param("outboundNo") String outboundNo);
@Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1")
OutboundOrder findByIdempotency(@Param("customerId") String customerId, @Param("key") String key);
@Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1 FOR UPDATE")
OutboundOrder lockByDispatch(@Param("customerId") String customerId, @Param("dispatchOrderId") Long dispatchOrderId);
}
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.delivery.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface OutboundPalletItemMapper extends BaseMapper<OutboundPalletItem> {
@Select("SELECT * FROM ll_outbound_pallet_item WHERE customer_id=#{customerId} AND outbound_order_id=#{outboundOrderId} ORDER BY id")
List<OutboundPalletItem> findByOutbound(@Param("customerId") String customerId, @Param("outboundOrderId") Long outboundOrderId);
}
@@ -0,0 +1,213 @@
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 customerId, String dispatchNo, String operatorId,
DeliveryRequests.Transfer request) {
DispatchOrder found = dispatchOrders.findByDispatchNo(customerId, dispatchNo);
if (found == null) throw notFound("Dispatch order not found");
DispatchOrder dispatch = dispatchOrders.lockById(customerId, found.getId());
OutboundOrder replay = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim());
if (replay != null) {
if (!dispatch.getId().equals(replay.getDispatchOrderId())) throw conflict("Idempotency key belongs to another dispatch");
return result(replay, requireDelivery(customerId, replay.getId()), true);
}
OutboundOrder existing = outboundOrders.lockByDispatch(customerId, dispatch.getId());
if (existing != null) return result(existing, requireDelivery(customerId, 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(customerId, 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.setCustomerId(customerId);
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(customerId, request.idempotencyKey().trim());
if (concurrent != null && dispatch.getId().equals(concurrent.getDispatchOrderId())) {
return result(concurrent, requireDelivery(customerId, concurrent.getId()), true);
}
concurrent = outboundOrders.lockByDispatch(customerId, dispatch.getId());
if (concurrent != null) {
return result(concurrent, requireDelivery(customerId, concurrent.getId()), true);
}
throw conflict("Dispatch was concurrently transferred to delivery");
}
for (DispatchItem source : items) {
OutboundPalletItem item = new OutboundPalletItem();
item.setId(IdWorker.getId()); item.setCustomerId(customerId); 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.setCustomerId(customerId);
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(customerId, items, outbound.getOutboundNo(), delivery.getDeliveryNo(), request.remark(), now);
return result(outbound, delivery, false);
}
public PageResult<DeliveryResponses.OutboundRow> outboundPage(String customerId, DeliveryRequests.Query query) {
Page<OutboundOrder> page = outboundOrders.selectPage(new Page<>(query.page(), query.size()),
new LambdaQueryWrapper<OutboundOrder>().eq(OutboundOrder::getCustomerId, customerId)
.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 customerId, String outboundNo,
DeliveryRequests.PalletQuery query) {
OutboundOrder outbound = outboundOrders.findByNo(customerId, outboundNo);
if (outbound == null) throw notFound("Outbound order not found");
DeliveryOrder delivery = requireDelivery(customerId, outbound.getId());
Page<OutboundPalletItem> page = outboundPallets.selectPage(new Page<>(query.page(), query.size()),
new LambdaQueryWrapper<OutboundPalletItem>().eq(OutboundPalletItem::getCustomerId, customerId)
.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 customerId, DeliveryRequests.Query query) {
Page<DeliveryOrder> page = deliveryOrders.selectPage(new Page<>(query.page(), query.size()),
new LambdaQueryWrapper<DeliveryOrder>().eq(DeliveryOrder::getCustomerId, customerId)
.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 customerId, String deliveryNo,
DeliveryRequests.PalletQuery query) {
DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo);
if (delivery == null) throw notFound("Delivery order not found");
OutboundOrder outbound = outboundOrders.selectById(delivery.getOutboundOrderId());
if (outbound == null || !customerId.equals(outbound.getCustomerId())) throw notFound("Outbound order not found");
return outboundDetail(customerId, outbound.getOutboundNo(), query);
}
private void bindTailOrders(String customerId, 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::getCustomerId, customerId).in(LastlegOrder::getId, orderIds))) {
String from = order.getCurrentStatus(); order.setOutboundOrderNo(outboundNo);
order.setDeliveryOrderNo(deliveryNo); order.setCurrentStatus("WAIT_OUTBOUND"); order.setUpdatedAt(now);
order.setSubOrderStatus("OUTBOUND:WAIT_OUTBOUND;DELIVERY:WAIT_DELIVERY");
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 customerId, Long outboundId) {
DeliveryOrder value = deliveryOrders.lockByOutbound(customerId, 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); }
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.discard;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan("com.baiyue.lastleg.discard.mapper")
public class DiscardMapperConfiguration {
}
@@ -0,0 +1,84 @@
package com.baiyue.lastleg.discard.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.discard.dto.CargoDiscardRequests;
import com.baiyue.lastleg.discard.dto.CargoDiscardResponses;
import com.baiyue.lastleg.discard.service.CargoDiscardService;
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.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@RestController
@RequestMapping("/api/platform/v1/last-leg/cargo-discards")
public class CargoDiscardController {
@Resource private CargoDiscardService service;
@GetMapping("/inventory-candidates")
public ApiResponse<List<CargoDiscardResponses.Candidate>> candidates(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@RequestParam(required = false) String inboundCode) {
return ApiResponse.success(service.candidates(scope(user, customerId), inboundCode));
}
@PostMapping
public ApiResponse<CargoDiscardResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @Valid @RequestBody CargoDiscardRequests.Create request) {
return ApiResponse.success(service.create(scope(user, customerId), operator(user), request));
}
@GetMapping
public ApiResponse<PageResult<CargoDiscardResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @Valid CargoDiscardRequests.Query query) {
return ApiResponse.success(service.page(scope(user, customerId), query));
}
@GetMapping("/{discardNo}")
public ApiResponse<CargoDiscardResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @PathVariable String discardNo) {
return ApiResponse.success(service.detail(scope(user, customerId), discardNo));
}
@PostMapping("/{discardNo}/review")
public ApiResponse<CargoDiscardResponses.Detail> review(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @PathVariable String discardNo,
@Valid @RequestBody CargoDiscardRequests.Review request) {
requireApprovalPermission(user);
return ApiResponse.success(service.review(scope(user, customerId), discardNo, 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 void requireApprovalPermission(AuthenticatedUser user) {
if (user == null || user.permissions() == null
|| !user.permissions().contains(CargoDiscardService.APPROVAL_PERMISSION)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Cargo discard approval permission is required");
}
}
private String operator(AuthenticatedUser user) {
return user.userId() == null ? user.username() : String.valueOf(user.userId());
}
}
@@ -0,0 +1,30 @@
package com.baiyue.lastleg.discard.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 CargoDiscardRequests {
private CargoDiscardRequests() {}
public record Create(@NotEmpty @Size(max = 200) List<@Valid Item> items,
@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) {}
public record Item(@NotNull Long orderId, @NotBlank @Size(max = 128) String skuCode,
@Positive int discardPackages) {}
public record Review(@NotBlank String decision, @Size(max = 1000) String remark) {}
public record Query(@Min(1) long page, @Min(1) @Max(200) long size,
String discardNo, String inboundCode, String overseasWarehouseId,
String status, LocalDateTime createdFrom, LocalDateTime createdTo) {}
}
@@ -0,0 +1,30 @@
package com.baiyue.lastleg.discard.dto;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
public final class CargoDiscardResponses {
private CargoDiscardResponses() {}
public record Candidate(Long orderId, String inboundCode, String inboundOrderNo,
String overseasWarehouseId, String overseasWarehouseName,
LocalDateTime inboundCreatedAt, long availablePackages,
List<CandidateSku> skus) {}
public record CandidateSku(String skuCode, String productName, long availablePackages) {}
public record Summary(Long id, String discardNo, String inboundCodes,
String overseasWarehouseId, String overseasWarehouseName,
int totalPackages, BigDecimal totalFee, String currency, String status,
String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt) {}
public record Item(Long id, Long orderId, String inboundCode, String skuCode,
String productName, int availablePackagesSnapshot, int discardPackages) {}
public record History(String fromStatus, String toStatus, String operatorId,
String remark, LocalDateTime createdAt) {}
public record Detail(Summary summary, BigDecimal feePerPackage, String remark,
String reviewedBy, String reviewRemark, List<Item> items,
List<History> histories) {}
}
@@ -0,0 +1,34 @@
package com.baiyue.lastleg.discard.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@TableName("ll_cargo_discard_application")
public class CargoDiscardApplication {
@TableId(type = IdType.INPUT) private Long id;
private String customerId;
private String discardNo;
private String overseasWarehouseId;
private String overseasWarehouseName;
private String inboundCodes;
private Integer totalPackages;
private BigDecimal feePerPackage;
private BigDecimal totalFee;
private String currency;
private String status;
private String remark;
private String idempotencyKey;
private String requestHash;
private String createdBy;
private String reviewedBy;
private String reviewRemark;
private LocalDateTime reviewedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.discard.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_cargo_discard_carton")
public class CargoDiscardCarton {
@TableId(type = IdType.INPUT) private Long id;
private Long applicationId;
private Long itemId;
private String customerId;
private Long cartonId;
private String cartonMark;
private LocalDateTime createdAt;
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.discard.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_cargo_discard_item")
public class CargoDiscardItem {
@TableId(type = IdType.INPUT) private Long id;
private Long applicationId;
private String customerId;
private Long orderId;
private String inboundCode;
private Long profileId;
private Long skuInventoryId;
private String skuCode;
private String productName;
private Integer availablePackagesSnapshot;
private Integer discardPackages;
private LocalDateTime createdAt;
}
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.discard.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_cargo_discard_status_history")
public class CargoDiscardStatusHistory {
@TableId(type = IdType.INPUT) private Long id;
private Long applicationId;
private String customerId;
private String fromStatus;
private String toStatus;
private String operatorId;
private String remark;
private LocalDateTime createdAt;
}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.discard.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.discard.entity.CargoDiscardApplication;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface CargoDiscardApplicationMapper extends BaseMapper<CargoDiscardApplication> {
@Select("SELECT * FROM ll_cargo_discard_application WHERE customer_id=#{customerId} AND discard_no=#{discardNo} LIMIT 1")
CargoDiscardApplication findNo(@Param("customerId") String customerId, @Param("discardNo") String discardNo);
@Select("SELECT * FROM ll_cargo_discard_application WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1")
CargoDiscardApplication findKey(@Param("customerId") String customerId, @Param("key") String key);
@Select("SELECT * FROM ll_cargo_discard_application WHERE customer_id=#{customerId} AND discard_no=#{discardNo} LIMIT 1 FOR UPDATE")
CargoDiscardApplication lockNo(@Param("customerId") String customerId, @Param("discardNo") String discardNo);
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.discard.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.discard.entity.CargoDiscardCarton;
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 CargoDiscardCartonMapper extends BaseMapper<CargoDiscardCarton> {
@Select("SELECT * FROM ll_cargo_discard_carton WHERE customer_id=#{customerId} AND application_id=#{applicationId} ORDER BY id")
List<CargoDiscardCarton> listApplication(@Param("customerId") String customerId,
@Param("applicationId") Long applicationId);
}
@@ -0,0 +1,69 @@
package com.baiyue.lastleg.discard.mapper;
import com.baiyue.lastleg.discard.model.CargoDiscardCandidateRow;
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@Mapper
public interface CargoDiscardInventoryMapper {
@Select("<script>SELECT o.id order_id,o.inbound_code,o.inbound_order_no,o.overseas_warehouse_id," +
"o.overseas_warehouse_name,o.created_at,p.id profile_id,s.id sku_inventory_id,s.sku_code,s.product_name," +
"COUNT(c.id) available_packages FROM ll_order o " +
"JOIN ll_dropship_order_profile p ON p.customer_id=o.customer_id AND p.order_id=o.id " +
"JOIN ll_dropship_sku_inventory s ON s.customer_id=o.customer_id AND s.order_id=o.id AND s.profile_id=p.id " +
"LEFT JOIN ll_dropship_carton c ON c.customer_id=o.customer_id AND c.order_id=o.id " +
"AND c.profile_id=p.id AND c.sku_code=s.sku_code AND c.status='AVAILABLE' " +
"WHERE o.customer_id=#{customerId} AND o.order_type='ONE_CLICK_DROPSHIP' " +
"<if test='inboundCode != null and inboundCode != \"\"'>AND o.inbound_code LIKE CONCAT('%',#{inboundCode},'%') </if>" +
"GROUP BY o.id,o.inbound_code,o.inbound_order_no,o.overseas_warehouse_id,o.overseas_warehouse_name,o.created_at," +
"p.id,s.id,s.sku_code,s.product_name HAVING COUNT(c.id)&gt;0 ORDER BY o.created_at DESC,o.id DESC,p.id,s.sku_code LIMIT 500</script>")
List<CargoDiscardCandidateRow> candidates(@Param("customerId") String customerId,
@Param("inboundCode") String inboundCode);
@Select("SELECT COUNT(*) FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} " +
"AND sku_code=#{skuCode} AND status='AVAILABLE'")
long countAvailable(@Param("customerId") String customerId, @Param("orderId") Long orderId,
@Param("skuCode") String skuCode);
@Select("SELECT COUNT(*) FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} " +
"AND profile_id=#{profileId} AND sku_code=#{skuCode} AND status='AVAILABLE'")
long countAvailableProfile(@Param("customerId") String customerId, @Param("orderId") Long orderId,
@Param("profileId") Long profileId, @Param("skuCode") String skuCode);
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} " +
"AND sku_code=#{skuCode} AND status='AVAILABLE' ORDER BY profile_id,carton_sequence,id LIMIT #{limit} FOR UPDATE")
List<DropshipCarton> lockAvailable(@Param("customerId") String customerId, @Param("orderId") Long orderId,
@Param("skuCode") String skuCode, @Param("limit") int limit);
@Update("UPDATE ll_dropship_carton SET status=#{toStatus},version=version+1,updated_at=NOW(3) " +
"WHERE customer_id=#{customerId} AND id=#{cartonId} AND status=#{fromStatus}")
int changeCartonStatus(@Param("customerId") String customerId, @Param("cartonId") Long cartonId,
@Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus);
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND id=#{cartonId} LIMIT 1 FOR UPDATE")
DropshipCarton lockCarton(@Param("customerId") String customerId, @Param("cartonId") Long cartonId);
@Select("SELECT * FROM ll_dropship_sku_inventory WHERE customer_id=#{customerId} AND id=#{id} LIMIT 1 FOR UPDATE")
DropshipSkuInventory lockSkuInventory(@Param("customerId") String customerId, @Param("id") Long id);
@Select("SELECT * FROM ll_dropship_sku_inventory WHERE customer_id=#{customerId} AND order_id=#{orderId} " +
"AND profile_id=#{profileId} AND sku_code=#{skuCode} LIMIT 1 FOR UPDATE")
DropshipSkuInventory lockSkuInventoryDimension(@Param("customerId") String customerId,
@Param("orderId") Long orderId,
@Param("profileId") Long profileId,
@Param("skuCode") String skuCode);
@Update("UPDATE ll_dropship_sku_inventory SET package_count=package_count-#{packages},updated_at=NOW(3) " +
"WHERE customer_id=#{customerId} AND id=#{id} AND package_count>=#{packages}")
int deductSkuInventory(@Param("customerId") String customerId, @Param("id") Long id,
@Param("packages") int packages);
@Select("SELECT package_count FROM ll_dropship_sku_inventory WHERE customer_id=#{customerId} AND id=#{id}")
Long skuPackages(@Param("customerId") String customerId, @Param("id") Long id);
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.discard.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.discard.entity.CargoDiscardItem;
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 CargoDiscardItemMapper extends BaseMapper<CargoDiscardItem> {
@Select("SELECT * FROM ll_cargo_discard_item WHERE customer_id=#{customerId} AND application_id=#{applicationId} ORDER BY inbound_code,sku_code,profile_id")
List<CargoDiscardItem> listApplication(@Param("customerId") String customerId,
@Param("applicationId") Long applicationId);
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.discard.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.discard.entity.CargoDiscardStatusHistory;
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 CargoDiscardStatusHistoryMapper extends BaseMapper<CargoDiscardStatusHistory> {
@Select("SELECT * FROM ll_cargo_discard_status_history WHERE customer_id=#{customerId} AND application_id=#{applicationId} ORDER BY created_at,id")
List<CargoDiscardStatusHistory> listApplication(@Param("customerId") String customerId,
@Param("applicationId") Long applicationId);
}
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.discard.model;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class CargoDiscardCandidateRow {
private Long orderId;
private String inboundCode;
private String inboundOrderNo;
private String overseasWarehouseId;
private String overseasWarehouseName;
private LocalDateTime createdAt;
private Long profileId;
private Long skuInventoryId;
private String skuCode;
private String productName;
private Long availablePackages;
}
@@ -0,0 +1,98 @@
package com.baiyue.lastleg.discard.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.discard.entity.CargoDiscardApplication;
import com.baiyue.lastleg.discard.entity.CargoDiscardCarton;
import com.baiyue.lastleg.discard.entity.CargoDiscardItem;
import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper;
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
import com.baiyue.lastleg.dropship.entity.DropshipCartonLedger;
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
import com.baiyue.lastleg.dropship.mapper.DropshipCartonLedgerMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/** Final inventory mutation for a warehouse-approved cargo discard. */
@Service
public class CargoDiscardInventoryAdjustmentService {
@Resource private CargoDiscardInventoryMapper inventory;
@Resource private DropshipCartonLedgerMapper cartonLedgers;
@Transactional(propagation = Propagation.MANDATORY)
public AdjustmentResult discard(String customerId, CargoDiscardApplication application,
List<CargoDiscardItem> items,
Map<Long, List<CargoDiscardCarton>> linksByItem,
String operatorId, LocalDateTime occurredAt) {
long beforePackages = 0;
long discardedPackages = 0;
long afterPackages = 0;
for (CargoDiscardItem item : items) {
DropshipSkuInventory sku = inventory.lockSkuInventory(customerId, item.getSkuInventoryId());
if (sku == null) throw conflict("SKU inventory is missing during discard approval: " + item.getSkuCode());
long before = value(sku.getPackageCount());
int discarded = item.getDiscardPackages();
if (before < discarded || inventory.deductSkuInventory(customerId, sku.getId(), discarded) != 1) {
throw conflict("SKU inventory is insufficient during discard approval: " + item.getSkuCode());
}
List<CargoDiscardCarton> links = linksByItem.getOrDefault(item.getId(), List.of());
if (links.size() != discarded) throw conflict("Reserved discard cartons are incomplete");
for (CargoDiscardCarton link : links) {
DropshipCarton carton = inventory.lockCarton(customerId, link.getCartonId());
if (carton == null || inventory.changeCartonStatus(customerId, carton.getId(),
"DISCARD_PENDING", "DISCARDED") != 1) throw conflict("Reserved cargo status changed before approval");
cartonLedgers.insert(discardFact(customerId, application, item, carton, operatorId, occurredAt));
}
Long persistedAfter = inventory.skuPackages(customerId, sku.getId());
long expectedAfter = before - discarded;
if (persistedAfter == null || persistedAfter != expectedAfter) {
throw conflict("Cargo discard package conservation check failed for SKU " + item.getSkuCode());
}
beforePackages = Math.addExact(beforePackages, before);
discardedPackages = Math.addExact(discardedPackages, discarded);
afterPackages = Math.addExact(afterPackages, persistedAfter);
}
if (beforePackages - discardedPackages != afterPackages
|| discardedPackages != application.getTotalPackages()) {
throw conflict("Cargo discard application package conservation check failed");
}
return new AdjustmentResult(beforePackages, discardedPackages, afterPackages);
}
private DropshipCartonLedger discardFact(String customerId, CargoDiscardApplication application,
CargoDiscardItem item, DropshipCarton carton,
String operatorId, LocalDateTime occurredAt) {
DropshipCartonLedger fact = new DropshipCartonLedger();
fact.setId(IdWorker.getId()); fact.setCustomerId(customerId); fact.setOrderId(item.getOrderId());
fact.setCartonId(carton.getId()); fact.setCartonMark(carton.getCartonMark()); fact.setSkuCode(item.getSkuCode());
fact.setOperationType("DISCARD"); fact.setBeforePackages(1L); fact.setDeltaPackages(-1L); fact.setAfterPackages(0L);
fact.setBusinessOrderNo(application.getDiscardNo());
fact.setIdempotencyKey("CARGO_DISCARD:" + application.getId() + ":" + carton.getId());
fact.setRemark("Warehouse discard approved by " + operatorId + remarkSuffix(application.getRemark()));
fact.setOccurredAt(occurredAt); fact.setCreatedAt(occurredAt);
return fact;
}
private static long value(Long value) { return value == null ? 0L : value; }
private static String remarkSuffix(String value) {
return value == null || value.isBlank() ? "" : "; " + value.trim();
}
private static ResponseStatusException conflict(String message) {
return new ResponseStatusException(HttpStatus.CONFLICT, message);
}
public record AdjustmentResult(long beforePackages, long discardedPackages, long afterPackages) {
public String auditRemark(String reviewRemark) {
String prefix = reviewRemark == null || reviewRemark.isBlank() ? "" : reviewRemark.trim() + "; ";
return prefix + "inventoryBefore=" + beforePackages + ", discarded=" + discardedPackages
+ ", inventoryAfter=" + afterPackages;
}
}
}
@@ -0,0 +1,303 @@
package com.baiyue.lastleg.discard.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.discard.dto.CargoDiscardRequests;
import com.baiyue.lastleg.discard.dto.CargoDiscardResponses;
import com.baiyue.lastleg.discard.entity.CargoDiscardApplication;
import com.baiyue.lastleg.discard.entity.CargoDiscardCarton;
import com.baiyue.lastleg.discard.entity.CargoDiscardItem;
import com.baiyue.lastleg.discard.entity.CargoDiscardStatusHistory;
import com.baiyue.lastleg.discard.mapper.CargoDiscardApplicationMapper;
import com.baiyue.lastleg.discard.mapper.CargoDiscardCartonMapper;
import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper;
import com.baiyue.lastleg.discard.mapper.CargoDiscardItemMapper;
import com.baiyue.lastleg.discard.mapper.CargoDiscardStatusHistoryMapper;
import com.baiyue.lastleg.discard.model.CargoDiscardCandidateRow;
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
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.math.BigDecimal;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Service
public class CargoDiscardService {
public static final String APPROVAL_PERMISSION = "lastleg:cargo-discard:approve";
public static final String PENDING_REVIEW = "PENDING_REVIEW";
public static final String APPROVED = "APPROVED";
public static final String REJECTED = "REJECTED";
private static final Set<String> STATUSES = Set.of(PENDING_REVIEW, APPROVED, REJECTED);
@Resource private CargoDiscardApplicationMapper applications;
@Resource private CargoDiscardItemMapper items;
@Resource private CargoDiscardCartonMapper discardCartons;
@Resource private CargoDiscardStatusHistoryMapper histories;
@Resource private CargoDiscardInventoryMapper inventory;
@Resource private CargoDiscardInventoryAdjustmentService adjustments;
@Resource private LastlegOrderMapper orders;
@Resource private ObjectMapper objectMapper;
@Value("${baiyue.lastleg.cargo-discard.fee-per-package-usd:5.00}")
private BigDecimal feePerPackage = new BigDecimal("5.00");
public List<CargoDiscardResponses.Candidate> candidates(String customerId, String inboundCode) {
List<CargoDiscardCandidateRow> rows = inventory.candidates(customerId, trim(inboundCode));
Map<Long, CandidateAccumulator> grouped = new LinkedHashMap<>();
for (CargoDiscardCandidateRow row : rows) {
CandidateAccumulator value = grouped.computeIfAbsent(row.getOrderId(), ignored -> new CandidateAccumulator(row));
value.add(row);
}
return grouped.values().stream().map(CandidateAccumulator::response).toList();
}
@Transactional
public CargoDiscardResponses.Detail create(String customerId, String operatorId,
CargoDiscardRequests.Create request) {
String key = request.idempotencyKey().trim();
String requestHash = hash(request);
CargoDiscardApplication replay = applications.findKey(customerId, key);
if (replay != null) {
if (!requestHash.equals(replay.getRequestHash())) {
throw conflict("Idempotency key was reused with different cargo discard data");
}
return detail(customerId, replay.getDiscardNo());
}
Set<String> requestedDimensions = new LinkedHashSet<>();
Map<Long, LastlegOrder> orderCache = new HashMap<>();
String warehouseId = null;
String warehouseName = null;
int total = 0;
for (CargoDiscardRequests.Item input : request.items()) {
String dimension = input.orderId() + ":" + input.skuCode().trim();
if (!requestedDimensions.add(dimension)) throw badRequest("Duplicate order/SKU discard item: " + dimension);
LastlegOrder order = requireDropshipOrder(customerId, input.orderId());
orderCache.put(order.getId(), order);
if (warehouseId == null) {
warehouseId = required(order.getOverseasWarehouseId(), "Order has no operating overseas warehouse");
warehouseName = trim(order.getOverseasWarehouseName());
} else if (!warehouseId.equals(order.getOverseasWarehouseId())) {
throw conflict("One cargo discard application cannot span operating warehouses");
}
total = Math.addExact(total, input.discardPackages());
}
LocalDateTime now = LocalDateTime.now();
CargoDiscardApplication application = new CargoDiscardApplication();
application.setId(IdWorker.getId()); application.setCustomerId(customerId);
application.setDiscardNo("FH" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + application.getId());
application.setOverseasWarehouseId(warehouseId); application.setOverseasWarehouseName(warehouseName);
application.setInboundCodes(orderCache.values().stream().map(LastlegOrder::getInboundCode)
.distinct().sorted().collect(java.util.stream.Collectors.joining(",")));
application.setTotalPackages(total); application.setFeePerPackage(feePerPackage);
application.setTotalFee(feePerPackage.multiply(BigDecimal.valueOf(total)));
application.setCurrency("USD"); application.setStatus(PENDING_REVIEW);
application.setRemark(trim(request.remark())); application.setIdempotencyKey(key);
application.setRequestHash(requestHash); application.setCreatedBy(operatorId);
application.setCreatedAt(now); application.setUpdatedAt(now);
applications.insert(application);
for (CargoDiscardRequests.Item input : request.items()) {
reserve(customerId, application, orderCache.get(input.orderId()), input, now);
}
history(customerId, application.getId(), null, PENDING_REVIEW, operatorId, request.remark(), now);
return detail(customerId, application.getDiscardNo());
}
public PageResult<CargoDiscardResponses.Summary> page(String customerId, CargoDiscardRequests.Query query) {
validateQuery(query);
LambdaQueryWrapper<CargoDiscardApplication> wrapper = new LambdaQueryWrapper<CargoDiscardApplication>()
.eq(CargoDiscardApplication::getCustomerId, customerId)
.like(StringUtils.hasText(query.discardNo()), CargoDiscardApplication::getDiscardNo, query.discardNo())
.like(StringUtils.hasText(query.inboundCode()), CargoDiscardApplication::getInboundCodes, query.inboundCode())
.eq(StringUtils.hasText(query.overseasWarehouseId()), CargoDiscardApplication::getOverseasWarehouseId,
query.overseasWarehouseId())
.eq(StringUtils.hasText(query.status()), CargoDiscardApplication::getStatus, query.status())
.ge(query.createdFrom() != null, CargoDiscardApplication::getCreatedAt, query.createdFrom())
.le(query.createdTo() != null, CargoDiscardApplication::getCreatedAt, query.createdTo())
.orderByDesc(CargoDiscardApplication::getCreatedAt).orderByDesc(CargoDiscardApplication::getId);
Page<CargoDiscardApplication> result = applications.selectPage(Page.of(query.page(), query.size()), wrapper);
List<CargoDiscardResponses.Summary> records = result.getRecords().stream().map(this::summary).toList();
return new PageResult<>(records, query.page(), query.size(), result.getTotal(), result.getPages());
}
public CargoDiscardResponses.Detail detail(String customerId, String discardNo) {
CargoDiscardApplication application = applications.findNo(customerId, discardNo);
if (application == null) throw notFound("Cargo discard application not found");
List<CargoDiscardResponses.Item> itemViews = items.listApplication(customerId, application.getId()).stream()
.map(value -> new CargoDiscardResponses.Item(value.getId(), value.getOrderId(), value.getInboundCode(),
value.getSkuCode(), value.getProductName(), value.getAvailablePackagesSnapshot(), value.getDiscardPackages()))
.toList();
List<CargoDiscardResponses.History> historyViews = histories.listApplication(customerId, application.getId()).stream()
.map(value -> new CargoDiscardResponses.History(value.getFromStatus(), value.getToStatus(),
value.getOperatorId(), value.getRemark(), value.getCreatedAt())).toList();
return new CargoDiscardResponses.Detail(summary(application), application.getFeePerPackage(),
application.getRemark(), application.getReviewedBy(), application.getReviewRemark(), itemViews, historyViews);
}
@Transactional
public CargoDiscardResponses.Detail review(String customerId, String discardNo, String operatorId,
CargoDiscardRequests.Review request) {
String decision = request.decision().trim().toUpperCase();
String next = switch (decision) {
case "APPROVE", APPROVED -> APPROVED;
case "REJECT", REJECTED -> REJECTED;
default -> throw badRequest("Review decision must be APPROVE or REJECT");
};
CargoDiscardApplication application = applications.lockNo(customerId, discardNo);
if (application == null) throw notFound("Cargo discard application not found");
if (next.equals(application.getStatus())) return detail(customerId, discardNo);
if (!PENDING_REVIEW.equals(application.getStatus())) throw conflict("Cargo discard application was already reviewed");
List<CargoDiscardItem> applicationItems = items.listApplication(customerId, application.getId());
List<CargoDiscardCarton> links = discardCartons.listApplication(customerId, application.getId());
Map<Long, List<CargoDiscardCarton>> linksByItem = links.stream()
.collect(java.util.stream.Collectors.groupingBy(CargoDiscardCarton::getItemId));
String historyRemark = request.remark();
if (APPROVED.equals(next)) {
CargoDiscardInventoryAdjustmentService.AdjustmentResult result = adjustments.discard(
customerId, application, applicationItems, linksByItem, operatorId, now());
historyRemark = result.auditRemark(request.remark());
} else {
release(customerId, links);
}
LocalDateTime reviewedAt = now();
application.setStatus(next); application.setReviewedBy(operatorId);
application.setReviewRemark(trim(request.remark())); application.setReviewedAt(reviewedAt);
application.setUpdatedAt(reviewedAt); applications.updateById(application);
history(customerId, application.getId(), PENDING_REVIEW, next, operatorId, historyRemark, reviewedAt);
return detail(customerId, discardNo);
}
private void reserve(String customerId, CargoDiscardApplication application, LastlegOrder order,
CargoDiscardRequests.Item input, LocalDateTime now) {
String skuCode = input.skuCode().trim();
if (inventory.countAvailable(customerId, order.getId(), skuCode) < input.discardPackages()) {
throw conflict("Insufficient available packages for SKU " + skuCode + " on inbound " + order.getInboundCode());
}
List<DropshipCarton> selected = inventory.lockAvailable(customerId, order.getId(), skuCode, input.discardPackages());
if (selected.size() != input.discardPackages()) throw conflict("Available cargo changed; retry the discard request");
Map<Long, List<DropshipCarton>> byProfile = selected.stream()
.collect(java.util.stream.Collectors.groupingBy(DropshipCarton::getProfileId, LinkedHashMap::new,
java.util.stream.Collectors.toList()));
for (Map.Entry<Long, List<DropshipCarton>> entry : byProfile.entrySet()) {
DropshipSkuInventory sku = inventory.lockSkuInventoryDimension(customerId, order.getId(), entry.getKey(), skuCode);
if (sku == null) throw conflict("SKU inventory disappeared while reserving cargo");
CargoDiscardItem item = new CargoDiscardItem();
item.setId(IdWorker.getId()); item.setApplicationId(application.getId()); item.setCustomerId(customerId);
item.setOrderId(order.getId()); item.setInboundCode(order.getInboundCode()); item.setProfileId(entry.getKey());
item.setSkuInventoryId(sku.getId()); item.setSkuCode(skuCode); item.setProductName(sku.getProductName());
item.setAvailablePackagesSnapshot(Math.toIntExact(inventory.countAvailableProfile(
customerId, order.getId(), entry.getKey(), skuCode)));
item.setDiscardPackages(entry.getValue().size()); item.setCreatedAt(now); items.insert(item);
for (DropshipCarton carton : entry.getValue()) {
if (inventory.changeCartonStatus(customerId, carton.getId(), "AVAILABLE", "DISCARD_PENDING") != 1) {
throw conflict("Cargo was reserved concurrently; retry the discard request");
}
CargoDiscardCarton link = new CargoDiscardCarton();
link.setId(IdWorker.getId()); link.setApplicationId(application.getId()); link.setItemId(item.getId());
link.setCustomerId(customerId); link.setCartonId(carton.getId()); link.setCartonMark(carton.getCartonMark());
link.setCreatedAt(now); discardCartons.insert(link);
}
}
}
private void release(String customerId, List<CargoDiscardCarton> links) {
for (CargoDiscardCarton link : links) {
if (inventory.changeCartonStatus(customerId, link.getCartonId(), "DISCARD_PENDING", "AVAILABLE") != 1) {
throw conflict("Reserved cargo status changed before rejection");
}
}
}
private LastlegOrder requireDropshipOrder(String customerId, Long orderId) {
LastlegOrder order = orders.selectById(orderId);
if (order == null || !customerId.equals(order.getCustomerId())) throw notFound("Inbound order not found");
if (!"ONE_CLICK_DROPSHIP".equals(order.getOrderType())) {
throw conflict("Cargo discard currently requires SKU/carton inventory from a one-click dropship order");
}
return order;
}
private CargoDiscardResponses.Summary summary(CargoDiscardApplication value) {
return new CargoDiscardResponses.Summary(value.getId(), value.getDiscardNo(), value.getInboundCodes(),
value.getOverseasWarehouseId(), value.getOverseasWarehouseName(), value.getTotalPackages(),
value.getTotalFee(), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(),
value.getReviewedAt());
}
private void history(String customerId, Long applicationId, String from, String to, String operatorId,
String remark, LocalDateTime createdAt) {
CargoDiscardStatusHistory value = new CargoDiscardStatusHistory();
value.setId(IdWorker.getId()); value.setApplicationId(applicationId); value.setCustomerId(customerId);
value.setFromStatus(from); value.setToStatus(to); value.setOperatorId(operatorId);
value.setRemark(trim(remark)); value.setCreatedAt(createdAt); histories.insert(value);
}
private void validateQuery(CargoDiscardRequests.Query query) {
if (StringUtils.hasText(query.status()) && !STATUSES.contains(query.status())) throw badRequest("Unsupported discard status");
if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) {
throw badRequest("createdFrom must not be after createdTo");
}
}
private String hash(Object value) {
try {
byte[] json = objectMapper.writeValueAsBytes(value);
return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json));
} catch (JsonProcessingException | NoSuchAlgorithmException exception) {
throw new IllegalStateException("Cannot hash cargo discard request", exception);
}
}
private static LocalDateTime now() { return LocalDateTime.now(); }
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private static String required(String value, String message) { String result = trim(value); if (result == null) throw conflict(message); return result; }
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); }
private static final class CandidateAccumulator {
private final CargoDiscardCandidateRow first;
private final Map<String, CandidateSkuAccumulator> skus = new LinkedHashMap<>();
private CandidateAccumulator(CargoDiscardCandidateRow first) { this.first = first; }
private void add(CargoDiscardCandidateRow row) {
skus.computeIfAbsent(row.getSkuCode(), ignored -> new CandidateSkuAccumulator(row.getSkuCode(), row.getProductName()))
.packages += row.getAvailablePackages() == null ? 0 : row.getAvailablePackages();
}
private CargoDiscardResponses.Candidate response() {
List<CargoDiscardResponses.CandidateSku> skuViews = skus.values().stream()
.map(value -> new CargoDiscardResponses.CandidateSku(value.skuCode, value.productName, value.packages)).toList();
long packages = skuViews.stream().mapToLong(CargoDiscardResponses.CandidateSku::availablePackages).sum();
return new CargoDiscardResponses.Candidate(first.getOrderId(), first.getInboundCode(), first.getInboundOrderNo(),
first.getOverseasWarehouseId(), first.getOverseasWarehouseName(), first.getCreatedAt(), packages, skuViews);
}
}
private static final class CandidateSkuAccumulator {
private final String skuCode;
private final String productName;
private long packages;
private CandidateSkuAccumulator(String skuCode, String productName) { this.skuCode = skuCode; this.productName = productName; }
}
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.dispatch;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan("com.baiyue.lastleg.dispatch.mapper")
public class DispatchMapperConfiguration {
}
@@ -0,0 +1,134 @@
package com.baiyue.lastleg.dispatch.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.dispatch.dto.DispatchRequests;
import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses;
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
import com.baiyue.lastleg.dispatch.pda.controller.DispatchPdaController;
import com.baiyue.lastleg.dispatch.service.DispatchService;
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.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@RestController
@RequestMapping("/api/platform/v1/last-leg/dispatches")
@Tag(name = "Tail-leg vehicle dispatch")
public class DispatchController {
@Resource private DispatchService service;
@Resource private DispatchPdaWorkflowService pdaWorkflow;
@GetMapping("/candidates")
@Operation(summary = "List unlocked planned or received pallets for intelligent pre-dispatch")
public ApiResponse<List<DispatchPalletCandidate>> candidates(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@RequestParam(required = false) String orderNo,
@RequestParam(required = false) String fbaWarehouseCode,
@RequestParam(required = false) String sourceStage) {
return ApiResponse.success(service.candidates(scope(user, customerId), orderNo, fbaWarehouseCode, sourceStage));
}
@PostMapping("/pre-arrange")
@Operation(summary = "Create a pending-departure dispatch and lock all selected pallets")
public ApiResponse<DispatchResponse> preArrange(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid @RequestBody DispatchRequests.PreArrange request) {
String scopedTenant = scope(user, customerId);
String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId());
return ApiResponse.success(service.preArrange(scopedTenant, operatorId, request));
}
@GetMapping
@Operation(summary = "Page dispatch orders")
public ApiResponse<PageResult<DispatchResponse>> page(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid DispatchRequests.Query query) {
return ApiResponse.success(service.page(scope(user, customerId), query));
}
@GetMapping("/{dispatchNo}")
@Operation(summary = "Get dispatch details and all pallet destinations")
public ApiResponse<DispatchResponse> detail(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@PathVariable String dispatchNo) {
return ApiResponse.success(service.detail(scope(user, customerId), dispatchNo));
}
@PutMapping("/{dispatchNo}")
@Operation(summary = "Edit vehicle, driver, appointment, route and departure data")
public ApiResponse<DispatchResponse> update(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@PathVariable String dispatchNo,
@Valid @RequestBody DispatchRequests.Update request) {
return ApiResponse.success(service.update(scope(user, customerId), dispatchNo, request));
}
@DeleteMapping("/{dispatchNo}")
@Operation(summary = "Cancel a pending dispatch and release its pallet locks")
public ApiResponse<Void> cancel(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@PathVariable String dispatchNo) {
service.cancel(scope(user, customerId), dispatchNo);
return ApiResponse.success(null);
}
@PostMapping("/{dispatchNo}/depart")
@Operation(summary = "Ship all PDA-picked pallets and mark the dispatch departed")
public ApiResponse<DispatchPdaResponses.Departed> depart(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@PathVariable String dispatchNo,
@Valid @RequestBody DispatchPdaRequests.Depart request) {
requirePdaOperate(user);
String scopedTenant = scope(user, customerId);
String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId());
return ApiResponse.success(pdaWorkflow.depart(scopedTenant, dispatchNo, operatorId, request));
}
private String scope(AuthenticatedUser user, String requestedCustomer) {
if (user == null || user.tenantId() == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
}
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Platform request must specify target tenant");
}
return requestedCustomer;
}
return String.valueOf(user.tenantId());
}
private void requirePdaOperate(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if (user.permissions() == null || !user.permissions().contains(DispatchPdaController.OPERATE_PERMISSION)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
DispatchPdaController.OPERATE_PERMISSION + " permission is required");
}
}
}
@@ -0,0 +1,35 @@
package com.baiyue.lastleg.dispatch.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.List;
public final class DispatchRequests {
private DispatchRequests() {
}
public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String dispatchNo,
String vehiclePlate, String status, String fbaAppointmentNo) {
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
}
public record PreArrange(@NotEmpty @Size(max = 500) List<@NotNull Long> planPalletIds,
@Size(max = 255) String route, @Size(max = 64) String fbaAppointmentNo,
@Size(max = 32) String fbaAppointmentStatus,
@Size(max = 32) String vehicleAppointmentStatus) {
}
public record Update(@Size(max = 64) String vehiclePlate, @Size(max = 128) String driverName,
@Size(max = 64) String driverPhone, @Size(max = 255) String carrierName,
@Size(max = 255) String route, LocalDateTime departAt,
@Size(max = 64) String fbaAppointmentNo,
@Size(max = 32) String fbaAppointmentStatus,
@Size(max = 32) String vehicleAppointmentStatus,
LocalDateTime estimatedArrivalAt, @Size(max = 32) String status) {
}
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.dispatch.dto;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
public record DispatchResponse(Long id, String dispatchNo, String vehiclePlate, String driverName,
String driverPhone, String carrierName, String orderNos, String route,
String overseasWarehouseId, String overseasWarehouseName, LocalDateTime departAt,
String fbaAppointmentNo, String fbaAppointmentStatus,
String vehicleAppointmentStatus, LocalDateTime estimatedArrivalAt, String status,
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) {
}
}
@@ -0,0 +1,38 @@
package com.baiyue.lastleg.dispatch.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** Immutable pallet/cargo snapshot selected into a dispatch order. */
@Data
@TableName("ll_dispatch_item")
public class DispatchItem {
@TableId(type = IdType.INPUT)
private Long id;
private Long dispatchOrderId;
private String customerId;
private Long planId;
private Long planPalletId;
private Long warehousePalletId;
private Long orderId;
private String overseasWarehouseId;
private String overseasWarehouseName;
private String orderNo;
private String orderRefs;
private String inboundCode;
private String palletNo;
private String destinationWarehouseId;
private String destinationWarehouseCode;
private String destinationWarehouseName;
private String fbaWarehouseCode;
private Integer packageCount;
private BigDecimal volumeCbm;
/** PLAN for planned pallets; RECEIVED for warehouse/entity pallets. */
private String sourceStage;
private LocalDateTime createdAt;
}
@@ -0,0 +1,40 @@
package com.baiyue.lastleg.dispatch.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;
/** Header for a pre-dispatched vehicle load. Vehicle information remains editable before departure. */
@Data
@TableName("ll_dispatch_order")
public class DispatchOrder {
@TableId(type = IdType.INPUT)
private Long id;
private String customerId;
private String dispatchNo;
private String overseasWarehouseId;
private String overseasWarehouseName;
private String vehiclePlate;
private String driverName;
private String driverPhone;
private String carrierName;
private String route;
private LocalDateTime departAt;
private String fbaAppointmentNo;
private String fbaAppointmentStatus;
private String vehicleAppointmentStatus;
private LocalDateTime estimatedArrivalAt;
private String status;
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;
}
@@ -0,0 +1,29 @@
package com.baiyue.lastleg.dispatch.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;
/** Prevents a source pallet from being added to more than one active dispatch order. */
@Data
@TableName("ll_dispatch_pallet_lock")
public class DispatchPalletLock {
@TableId(type = IdType.INPUT)
private Long id;
private String customerId;
private String sourcePalletKey;
/** Equals sourcePalletKey while ACTIVE and is null after release; unique index enforces exclusivity. */
private String activeKey;
private Long planId;
private Long planPalletId;
private Long warehousePalletId;
private Long dispatchOrderId;
private String status;
private LocalDateTime lockedAt;
private LocalDateTime releasedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,78 @@
package com.baiyue.lastleg.dispatch.mapper;
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
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 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.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," +
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN COALESCE((SELECT SUM(pos.available_packages) " +
"FROM ll_inventory_position pos WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id),0) " +
"ELSE COALESCE(SUM(CASE WHEN a.picked_package_count &gt; 0 THEN a.picked_package_count " +
"ELSE a.planned_package_count END),0) END AS packageCount,p.allocated_volume_cbm AS volumeCbm," +
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage " +
"FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " +
"LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " +
"LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id " +
"LEFT JOIN ll_order o ON o.id=l.order_id " +
"WHERE ip.customer_id=#{customerId} " +
"<if test='orderNo != null and orderNo != \"\"'> AND o.order_no LIKE CONCAT('%',#{orderNo},'%')</if> " +
"<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.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0)) " +
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " +
"WHERE d.customer_id=ip.customer_id AND d.status='ACTIVE' AND (" +
"d.source_pallet_key=CONCAT('PLAN:',p.id) OR (p.warehouse_pallet_id IS NOT NULL AND " +
"d.source_pallet_key=CONCAT('RECEIVED:',p.warehouse_pallet_id)))) " +
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
"JOIN ll_inventory_position tp ON tp.id=ta.inventory_position_id " +
"WHERE ta.customer_id=#{customerId} AND ta.status='RESERVED' AND tp.pallet_id=p.warehouse_pallet_id) " +
"GROUP BY p.plan_id,p.id,p.warehouse_pallet_id,p.pallet_no,p.destination_warehouse_id," +
"p.destination_warehouse_code,p.destination_warehouse_name,p.allocated_volume_cbm " +
"ORDER BY p.plan_id,p.sequence_no" +
"</script>")
List<DispatchPalletCandidate> available(@Param("customerId") String customerId,
@Param("orderNo") String orderNo,
@Param("fbaWarehouseCode") String fbaWarehouseCode,
@Param("sourceStage") String sourceStage);
@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.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," +
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN COALESCE((SELECT SUM(pos.available_packages) " +
"FROM ll_inventory_position pos WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id),0) " +
"ELSE COALESCE(SUM(CASE WHEN a.picked_package_count &gt; 0 THEN a.picked_package_count " +
"ELSE a.planned_package_count END),0) END AS packageCount,p.allocated_volume_cbm AS volumeCbm," +
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage " +
"FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " +
"LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " +
"LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id " +
"LEFT JOIN ll_order o ON o.id=l.order_id " +
"WHERE ip.customer_id=#{customerId} AND p.id IN " +
"<foreach collection='planPalletIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
"JOIN ll_inventory_position tp ON tp.id=ta.inventory_position_id " +
"WHERE ta.customer_id=#{customerId} AND ta.status='RESERVED' AND tp.pallet_id=p.warehouse_pallet_id) " +
"GROUP BY p.plan_id,p.id,p.warehouse_pallet_id,p.pallet_no,p.destination_warehouse_id," +
"p.destination_warehouse_code,p.destination_warehouse_name,p.allocated_volume_cbm " +
"ORDER BY p.id FOR UPDATE" +
"</script>")
List<DispatchPalletCandidate> lockSelected(@Param("customerId") String customerId,
@Param("planPalletIds") List<Long> planPalletIds);
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.dispatch.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@Mapper
public interface DispatchItemMapper extends BaseMapper<DispatchItem> {
@Select("SELECT * FROM ll_dispatch_item WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} ORDER BY id")
List<DispatchItem> findByDispatchOrder(@Param("customerId") String customerId,
@Param("dispatchOrderId") Long dispatchOrderId);
@Update("UPDATE ll_dispatch_item i JOIN ll_inbound_plan_pallet p ON p.id=i.plan_pallet_id " +
"JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=i.customer_id " +
"SET i.warehouse_pallet_id=p.warehouse_pallet_id,i.source_stage='RECEIVED' " +
"WHERE i.customer_id=#{customerId} AND i.dispatch_order_id=#{dispatchOrderId} " +
"AND i.warehouse_pallet_id IS NULL AND p.warehouse_pallet_id IS NOT NULL")
int bindReceivedPallets(@Param("customerId") String customerId,
@Param("dispatchOrderId") Long dispatchOrderId);
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.dispatch.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface DispatchOrderMapper extends BaseMapper<DispatchOrder> {
@Select("SELECT * FROM ll_dispatch_order WHERE customer_id=#{customerId} AND dispatch_no=#{dispatchNo} LIMIT 1")
DispatchOrder findByDispatchNo(@Param("customerId") String customerId, @Param("dispatchNo") String dispatchNo);
@Select("SELECT * FROM ll_dispatch_order WHERE customer_id=#{customerId} AND id=#{id} LIMIT 1 FOR UPDATE")
DispatchOrder lockById(@Param("customerId") String customerId, @Param("id") Long id);
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.dispatch.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface DispatchPalletLockMapper extends BaseMapper<DispatchPalletLock> {
@Select("SELECT * FROM ll_dispatch_pallet_lock WHERE customer_id=#{customerId} AND source_pallet_key=#{sourcePalletKey} " +
"AND status='ACTIVE' LIMIT 1 FOR UPDATE")
DispatchPalletLock lockActive(@Param("customerId") String customerId,
@Param("sourcePalletKey") String sourcePalletKey);
}
@@ -0,0 +1,37 @@
package com.baiyue.lastleg.dispatch.model;
import java.math.BigDecimal;
public record DispatchPalletCandidate(
Long planId,
Long planPalletId,
Long warehousePalletId,
Long orderId,
String overseasWarehouseId,
String overseasWarehouseName,
String orderNo,
String orderRefs,
String inboundCode,
String palletNo,
String destinationWarehouseId,
String destinationWarehouseCode,
String destinationWarehouseName,
String fbaWarehouseCode,
Integer packageCount,
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;
}
}
@@ -0,0 +1,8 @@
package com.baiyue.lastleg.dispatch.pda;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan("com.baiyue.lastleg.dispatch.pda.mapper")
public class DispatchPdaMapperConfiguration {}
@@ -0,0 +1,69 @@
package com.baiyue.lastleg.dispatch.pda.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses;
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
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/dispatches/{dispatchNo}/pda")
public class DispatchPdaController {
public static final String OPERATE_PERMISSION = "lastleg:pda:operate";
@Resource private DispatchPdaWorkflowService service;
@PostMapping("/push")
public ApiResponse<DispatchPdaResponses.Task> push(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo,
@RequestParam(required = false) String customerId,
@Valid @RequestBody DispatchPdaRequests.Push request) {
requireOperate(user);
return ApiResponse.success(service.push(scope(user, customerId), dispatchNo, request));
}
@GetMapping
public ApiResponse<DispatchPdaResponses.Task> task(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo,
@RequestParam(required = false) String customerId) {
return ApiResponse.success(service.task(scope(user, customerId), dispatchNo));
}
@PostMapping("/items/{itemId}/pick")
public ApiResponse<DispatchPdaResponses.Picked> pick(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo, @PathVariable long itemId,
@RequestParam(required = false) String customerId,
@Valid @RequestBody DispatchPdaRequests.Pick request) {
requireOperate(user);
return ApiResponse.success(service.pick(scope(user, customerId), dispatchNo, itemId, operator(user), request));
}
@PostMapping("/complete")
public ApiResponse<DispatchPdaResponses.PickingCompleted> completePicking(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo,
@RequestParam(required = false) String customerId,
@Valid @RequestBody DispatchPdaRequests.CompletePicking request) {
requireOperate(user);
return ApiResponse.success(service.completePicking(scope(user, customerId), dispatchNo, request));
}
private String scope(AuthenticatedUser user, String requestedCustomer) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant");
}
return requestedCustomer.trim();
}
return String.valueOf(user.tenantId());
}
private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); }
private void requireOperate(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required");
}
}
}
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.dispatch.pda.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 DispatchPdaRequests {
private DispatchPdaRequests() {}
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, 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) {}
}
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.dispatch.pda.dto;
import java.util.List;
public final class DispatchPdaResponses {
private DispatchPdaResponses() {}
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, Long balanceId, long availablePackages, long allocatedPackages,
long pickedPackages) {}
public record Picked(Long dispatchItemId, Long positionId, long packages, long pickedPackages,
boolean palletScrapped, boolean replayed) {}
public record Departed(String dispatchNo, String status, long shippedPackages, boolean replayed) {
public Departed withReplay() { return new Departed(dispatchNo, status, shippedPackages, true); }
}
public record PickingCompleted(String dispatchNo, String status, long plannedPackages,
long pickedPackages, long remainingPackages, boolean partial,
boolean replayed) {
public PickingCompleted withReplay() {
return new PickingCompleted(dispatchNo, status, plannedPackages, pickedPackages,
remainingPackages, partial, true);
}
}
}
@@ -0,0 +1,23 @@
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;
@Data
@TableName("ll_dispatch_pda_operation")
public class DispatchPdaOperation {
@TableId(type = IdType.INPUT) private Long id;
private String customerId;
private Long taskId;
private String operationType;
private String idempotencyKey;
private String requestHash;
private String resultJson;
private String status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,23 @@
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;
@Data
@TableName("ll_dispatch_pda_pick")
public class DispatchPdaPick {
@TableId(type = IdType.INPUT) private Long id;
private String customerId;
private Long taskId;
private Long dispatchItemId;
private Long inventoryPositionId;
private Long packages;
private Long productQuantity;
private String idempotencyKey;
private LocalDateTime pickedAt;
private LocalDateTime createdAt;
}
@@ -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 customerId;
private Long pickId;
private Long receiptId;
private Long orderId;
private Long packages;
private Long productQuantity;
private LocalDateTime createdAt;
}
@@ -0,0 +1,25 @@
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;
@Data
@TableName("ll_dispatch_pda_task")
public class DispatchPdaTask {
@TableId(type = IdType.INPUT) private Long id;
private String customerId;
private Long dispatchOrderId;
private String taskNo;
private String status;
private String operatorId;
private String operatorName;
private String warehouseId;
private LocalDateTime pushedAt;
private LocalDateTime completedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,11 @@
package com.baiyue.lastleg.dispatch.pda.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaOperation;
import org.apache.ibatis.annotations.*;
@Mapper
public interface DispatchPdaOperationMapper extends BaseMapper<DispatchPdaOperation> {
@Select("SELECT * FROM ll_dispatch_pda_operation WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1")
DispatchPdaOperation findKey(@Param("customerId") String customerId, @Param("key") String key);
}
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.dispatch.pda.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPick;
import org.apache.ibatis.annotations.*;
import java.util.List;
@Mapper
public interface DispatchPdaPickMapper extends BaseMapper<DispatchPdaPick> {
@Select("SELECT * FROM ll_dispatch_pda_pick WHERE customer_id=#{customerId} AND task_id=#{taskId} ORDER BY id")
List<DispatchPdaPick> listTask(@Param("customerId") String customerId, @Param("taskId") Long taskId);
@Select("SELECT COALESCE(SUM(packages),0) FROM ll_dispatch_pda_pick WHERE customer_id=#{customerId} AND task_id=#{taskId} AND dispatch_item_id=#{itemId}")
long totalItem(@Param("customerId") String customerId, @Param("taskId") Long taskId, @Param("itemId") Long itemId);
}
@@ -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.customer_id=#{customerId} 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.customer_id=r.customer_id AND a.receipt_id=r.id) ORDER BY r.received_at,r.id")
List<InboundStockReceipt> availableReceipts(@Param("customerId") String customerId,
@Param("positionId") Long positionId);
@Select("SELECT COALESCE(SUM(packages),0) FROM ll_dispatch_pda_pick_order " +
"WHERE customer_id=#{customerId} AND receipt_id=#{receiptId}")
long attributedPackages(@Param("customerId") String customerId, @Param("receiptId") Long receiptId);
@Select("SELECT COALESCE(SUM(product_quantity),0) FROM ll_dispatch_pda_pick_order " +
"WHERE customer_id=#{customerId} AND receipt_id=#{receiptId}")
long attributedQuantity(@Param("customerId") String customerId, @Param("receiptId") Long receiptId);
@Select("SELECT * FROM ll_dispatch_pda_pick_order WHERE customer_id=#{customerId} AND pick_id=#{pickId} ORDER BY id")
List<DispatchPdaPickOrder> listPick(@Param("customerId") String customerId, @Param("pickId") Long pickId);
}
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.dispatch.pda.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask;
import org.apache.ibatis.annotations.*;
@Mapper
public interface DispatchPdaTaskMapper extends BaseMapper<DispatchPdaTask> {
@Select("SELECT * FROM ll_dispatch_pda_task WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1 FOR UPDATE")
DispatchPdaTask lockByDispatch(@Param("customerId") String customerId, @Param("dispatchOrderId") Long dispatchOrderId);
@Select("SELECT * FROM ll_dispatch_pda_task WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1")
DispatchPdaTask findByDispatch(@Param("customerId") String customerId, @Param("dispatchOrderId") Long dispatchOrderId);
}
@@ -0,0 +1,277 @@
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;
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses;
import com.baiyue.lastleg.dispatch.pda.entity.*;
import com.baiyue.lastleg.dispatch.pda.mapper.*;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
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.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.*;
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;
@Resource private DispatchPdaOperationMapper operations;
@Resource private DispatchPalletLockMapper palletLocks;
@Resource private InventoryPositionMapper positions;
@Resource private InventoryCommandService inventory;
@Resource private DispatchPickOrderAttributionService pickAttribution;
@Resource private ObjectMapper objectMapper;
@Transactional
public DispatchPdaResponses.Task push(String customerId, String dispatchNo, DispatchPdaRequests.Push request) {
DispatchOrder order = requireOrder(customerId, dispatchNo);
requirePdaStatus(order);
requireVehicle(order);
String warehouseId = warehouse(request.warehouseId());
requireDispatchWarehouse(order, warehouseId);
DispatchPdaOperation replay = replay(customerId, "PUSH", request.idempotencyKey(), request);
if (replay != null) return read(replay, DispatchPdaResponses.Task.class);
items.bindReceivedPallets(customerId, order.getId());
List<DispatchItem> dispatchItems = items.findByDispatchOrder(customerId, order.getId());
if (dispatchItems.isEmpty()) throw conflict("Dispatch has no pallets to push");
if (dispatchItems.stream().anyMatch(item -> item.getWarehousePalletId() == null)) {
throw conflict("All pre-arranged pallets must be received before PDA push");
}
DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId());
LocalDateTime now = LocalDateTime.now();
if (task == null) {
task = new DispatchPdaTask();
task.setId(IdWorker.getId()); task.setCustomerId(customerId); 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(customerId, order, task, dispatchItems);
save(customerId, task.getId(), "PUSH", request.idempotencyKey(), request, result);
return result;
}
@Transactional(readOnly = true)
public DispatchPdaResponses.Task task(String customerId, String dispatchNo) {
DispatchOrder order = requireOrder(customerId, dispatchNo);
DispatchPdaTask task = tasks.findByDispatch(customerId, order.getId());
if (task == null) throw notFound("Dispatch PDA task not found");
return task(customerId, order, task, items.findByDispatchOrder(customerId, order.getId()));
}
@Transactional
public DispatchPdaResponses.Picked pick(String customerId, String dispatchNo, long itemId,
String operatorId, DispatchPdaRequests.Pick request) {
DispatchOrder order = requireOrder(customerId, dispatchNo);
requirePdaStatus(order);
requireVehicle(order);
String warehouseId = warehouse(request.warehouseId());
requireDispatchWarehouse(order, warehouseId);
DispatchPdaTask task = tasks.lockByDispatch(customerId, 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 || !customerId.equals(item.getCustomerId()) || !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(customerId, "PICK", request.idempotencyKey(), request);
if (replay != null) return read(replay, DispatchPdaResponses.Picked.class);
InventoryPosition position = positions.selectById(request.positionId());
if (position == null || !customerId.equals(position.getCustomerId())
|| item.getWarehousePalletId() == null || !item.getWarehousePalletId().equals(position.getPalletId())) {
throw conflict("Inventory position does not belong to this dispatch pallet");
}
long picked = picks.totalItem(customerId, task.getId(), itemId);
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(customerId, request.positionId(),
request.packages(), order.getDispatchNo(), order.getDispatchNo(), null,
keyBase + ":ALLOCATE", request.remark());
inventory.allocate(customerId, operatorId, move);
inventory.pick(customerId, operatorId, new InventoryCommandRequests.Move(customerId, request.positionId(),
request.packages(), order.getDispatchNo(), order.getDispatchNo(), null,
keyBase + ":PICK", request.remark()));
DispatchPdaPick pick = new DispatchPdaPick();
pick.setId(IdWorker.getId()); pick.setCustomerId(customerId); 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(customerId, pick);
long total = picked + request.packages();
order.setPickedPackageCount((int) (n(order.getPickedPackageCount()) + request.packages()));
task.setStatus("PICKING"); task.setUpdatedAt(LocalDateTime.now());
if (allPicked(customerId, task.getId(), items.findByDispatchOrder(customerId, order.getId()))) {
task.setStatus("COMPLETED"); task.setCompletedAt(LocalDateTime.now()); order.setStatus("READY_TO_DEPART");
}
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(customerId, task.getId(), "PICK", request.idempotencyKey(), request, result);
return result;
}
@Transactional
public DispatchPdaResponses.PickingCompleted completePicking(String customerId, String dispatchNo,
DispatchPdaRequests.CompletePicking request) {
DispatchOrder found = requireOrder(customerId, dispatchNo);
DispatchOrder order = orders.lockById(customerId, found.getId());
if (order == null) throw notFound("Dispatch order not found");
DispatchPdaOperation replay = replay(customerId, "COMPLETE_PICKING", request.idempotencyKey(), request);
if (replay != null) return read(replay, DispatchPdaResponses.PickingCompleted.class).withReplay();
requireVehicle(order);
DispatchPdaTask task = tasks.lockByDispatch(customerId, 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(customerId, order.getId());
long planned = source.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull)
.mapToLong(Integer::longValue).sum();
long picked = picks.listTask(customerId, 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(customerId, 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 customerId, String dispatchNo, String operatorId,
DispatchPdaRequests.Depart request) {
DispatchOrder found = requireOrder(customerId, dispatchNo);
DispatchOrder order = orders.lockById(customerId, found.getId());
if (order == null) throw notFound("Dispatch order not found");
DispatchPdaOperation replay = replay(customerId, "DEPART", request.idempotencyKey(), request);
if (replay != null) return read(replay, DispatchPdaResponses.Departed.class).withReplay();
requireVehicle(order);
OutboundOrder outbound = outboundOrders.lockByDispatch(customerId, 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(customerId, order.getId());
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(customerId, order.getId());
boolean partial = "PARTIAL_READY_TO_DEPART".equals(order.getStatus());
if (!partial && !allPicked(customerId, task.getId(), dispatchItems)) {
throw conflict("Every dispatch pallet must be fully picked before departure");
}
List<DispatchPdaPick> dispatchPicks = picks.listTask(customerId, task.getId());
if (dispatchPicks.isEmpty()) throw conflict("Dispatch has no PDA picks to ship");
long shipped = 0;
for (DispatchPdaPick pick : dispatchPicks) {
String key = "DISPATCH:" + order.getDispatchNo() + ":PICK:" + pick.getId() + ":SHIP";
inventory.ship(customerId, operatorId, new InventoryCommandRequests.Move(customerId,
pick.getInventoryPositionId(), n(pick.getPackages()),
outbound.getOutboundNo(), outbound.getOutboundNo(), null, key, request.remark()));
shipped += n(pick.getPackages());
}
LocalDateTime now = LocalDateTime.now();
pickAttribution.depart(customerId, 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(customerId, order.getId(), now);
DispatchPdaResponses.Departed result = new DispatchPdaResponses.Departed(order.getDispatchNo(),
order.getStatus(), shipped, false);
save(customerId, task.getId(), "DEPART", request.idempotencyKey(), request, result);
return result;
}
private DispatchPdaResponses.Task task(String customerId, DispatchOrder order, DispatchPdaTask task, List<DispatchItem> source) {
List<DispatchPdaResponses.Item> values = new ArrayList<>();
for (DispatchItem item : source) {
List<InventoryPosition> palletPositions = item.getWarehousePalletId() == null ? List.of()
: positions.listPallet(customerId, item.getWarehousePalletId());
values.add(new DispatchPdaResponses.Item(item.getId(), item.getPalletNo(), item.getDestinationWarehouseCode(),
item.getDestinationWarehouseName(), item.getFbaWarehouseCode(), item.getPackageCount(),
picks.totalItem(customerId, task.getId(), item.getId()), palletPositions.stream().map(p ->
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.getWarehouseId(), task.getStatus(), values);
}
private boolean allPicked(String customerId, Long taskId, List<DispatchItem> source) {
return source.stream().allMatch(item -> picks.totalItem(customerId, taskId, item.getId()) == item.getPackageCount());
}
private void releasePalletLocks(String customerId, Long dispatchOrderId, LocalDateTime now) {
List<DispatchPalletLock> activeLocks = palletLocks.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<DispatchPalletLock>()
.eq(DispatchPalletLock::getCustomerId, customerId)
.eq(DispatchPalletLock::getDispatchOrderId, dispatchOrderId)
.eq(DispatchPalletLock::getStatus, "ACTIVE"));
for (DispatchPalletLock lock : activeLocks) {
lock.setStatus("RELEASED");
lock.setActiveKey(null);
lock.setReleasedAt(now);
lock.setUpdatedAt(now);
palletLocks.updateById(lock);
}
}
private DispatchOrder requireOrder(String customerId, String dispatchNo) { DispatchOrder order = orders.findByDispatchNo(customerId, dispatchNo); if (order == null) throw notFound("Dispatch order not found"); return order; }
private void requirePdaStatus(DispatchOrder order) {
if (!("PENDING_DEPARTURE".equals(order.getStatus()) || "PICKING".equals(order.getStatus())
|| "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 customerId, String type, String key, Object request) { DispatchPdaOperation op = operations.findKey(customerId, 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 customerId, Long taskId, String type, String key, Object request, Object result) { DispatchPdaOperation op = new DispatchPdaOperation(); op.setId(IdWorker.getId()); op.setCustomerId(customerId); 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); }
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
}
@@ -0,0 +1,105 @@
package com.baiyue.lastleg.dispatch.pda.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPick;
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPickOrder;
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Service
public class DispatchPickOrderAttributionService {
@Resource private DispatchPdaPickOrderMapper attributions;
@Resource private LastlegOrderMapper orders;
@Resource private LastlegOrderStatusHistoryMapper history;
/** Call after the inventory position has been locked/moved and the PDA pick row has been inserted. */
public List<OrderPackages> attribute(String customerId, 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(customerId,
pick.getInventoryPositionId())) {
if (remainingPackages == 0) break;
long receiptPackages = value(receipt.getReceivedPackages())
- attributions.attributedPackages(customerId, 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.setCustomerId(customerId);
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(customerId, 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 customerId, String outboundNo, String dispatchNo, List<DispatchPdaPick> picks,
LocalDateTime departedAt) {
Map<Long, Long> shippedByOrder = new LinkedHashMap<>();
for (DispatchPdaPick pick : picks) {
for (DispatchPdaPickOrder row : attributions.listPick(customerId, pick.getId())) {
shippedByOrder.merge(row.getOrderId(), value(row.getPackages()), Long::sum);
}
}
for (Map.Entry<Long, Long> entry : shippedByOrder.entrySet()) {
LastlegOrder before = orders.lockById(customerId, entry.getKey());
if (before == null || orders.transferPickedToShipped(customerId, 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);
}
}
@@ -0,0 +1,261 @@
package com.baiyue.lastleg.dispatch.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.dispatch.dto.DispatchRequests;
import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock;
import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper;
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
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.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@Service
public class DispatchService {
public static final String PENDING_DEPARTURE = "PENDING_DEPARTURE";
public static final String CANCELLED = "CANCELLED";
@Resource private DispatchOrderMapper orders;
@Resource private DispatchItemMapper items;
@Resource private DispatchPalletLockMapper locks;
@Resource private DispatchCandidateMapper candidates;
public List<DispatchPalletCandidate> candidates(String customerId, String orderNo,
String fbaWarehouseCode, String sourceStage) {
if (StringUtils.hasText(sourceStage) && !Set.of("PLAN", "RECEIVED").contains(sourceStage)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "sourceStage must be PLAN or RECEIVED");
}
return candidates.available(customerId, trimToNull(orderNo), trimToNull(fbaWarehouseCode), trimToNull(sourceStage));
}
@Transactional
public DispatchResponse preArrange(String customerId, String operatorId, DispatchRequests.PreArrange request) {
List<Long> palletIds = request.planPalletIds().stream().filter(Objects::nonNull).distinct().sorted().toList();
if (palletIds.isEmpty() || palletIds.size() != request.planPalletIds().size()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Plan pallet IDs must be unique and non-empty");
}
List<DispatchPalletCandidate> selected = candidates.lockSelected(customerId, palletIds);
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(customerId, "PLAN:" + candidate.planPalletId()) != null;
boolean receivedLocked = candidate.warehousePalletId() != null
&& locks.lockActive(customerId, "RECEIVED:" + candidate.warehousePalletId()) != null;
if (planLocked || receivedLocked) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Pallet is already locked by another active dispatch: " + candidate.palletNo());
}
}
LocalDateTime now = LocalDateTime.now();
DispatchOrder dispatch = new DispatchOrder();
dispatch.setId(IdWorker.getId());
dispatch.setCustomerId(customerId);
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()));
dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus()));
dispatch.setStatus(PENDING_DEPARTURE);
dispatch.setItemCount(selected.size());
dispatch.setPalletCount(selected.size());
dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum());
dispatch.setPickedPackageCount(0);
dispatch.setShippedPackageCount(0);
dispatch.setCreatedBy(trimToNull(operatorId));
dispatch.setCreatedAt(now);
dispatch.setUpdatedAt(now);
orders.insert(dispatch);
for (DispatchPalletCandidate candidate : selected) {
DispatchPalletLock lock = new DispatchPalletLock();
lock.setId(IdWorker.getId());
lock.setCustomerId(customerId);
lock.setSourcePalletKey(candidate.sourcePalletKey());
lock.setActiveKey(candidate.sourcePalletKey());
lock.setPlanId(candidate.planId());
lock.setPlanPalletId(candidate.planPalletId());
lock.setWarehousePalletId(candidate.warehousePalletId());
lock.setDispatchOrderId(dispatch.getId());
lock.setStatus("ACTIVE");
lock.setLockedAt(now);
lock.setCreatedAt(now);
lock.setUpdatedAt(now);
try {
locks.insert(lock);
} catch (DuplicateKeyException exception) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Pallet is already locked by another active dispatch: " + candidate.palletNo(), exception);
}
DispatchItem item = new DispatchItem();
item.setId(IdWorker.getId());
item.setDispatchOrderId(dispatch.getId());
item.setCustomerId(customerId);
item.setPlanId(candidate.planId());
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());
item.setPalletNo(candidate.palletNo());
item.setDestinationWarehouseId(candidate.destinationWarehouseId());
item.setDestinationWarehouseCode(candidate.destinationWarehouseCode());
item.setDestinationWarehouseName(candidate.destinationWarehouseName());
item.setFbaWarehouseCode(candidate.fbaWarehouseCode());
item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0));
item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO));
item.setSourceStage(candidate.sourceStage());
item.setCreatedAt(now);
items.insert(item);
}
return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId()));
}
public PageResult<DispatchResponse> page(String customerId, DispatchRequests.Query query) {
Page<DispatchOrder> page = orders.selectPage(new Page<>(query.page(), query.size()),
new LambdaQueryWrapper<DispatchOrder>().eq(DispatchOrder::getCustomerId, customerId)
.like(StringUtils.hasText(query.dispatchNo()), DispatchOrder::getDispatchNo, query.dispatchNo())
.like(StringUtils.hasText(query.vehiclePlate()), DispatchOrder::getVehiclePlate, query.vehiclePlate())
.eq(StringUtils.hasText(query.status()), DispatchOrder::getStatus, query.status())
.like(StringUtils.hasText(query.fbaAppointmentNo()), DispatchOrder::getFbaAppointmentNo,
query.fbaAppointmentNo())
.orderByDesc(DispatchOrder::getCreatedAt));
List<DispatchResponse> records = page.getRecords().stream()
.map(order -> response(order, items.findByDispatchOrder(customerId, order.getId()))).toList();
return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
}
public DispatchResponse detail(String customerId, String dispatchNo) {
DispatchOrder dispatch = find(customerId, dispatchNo);
return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId()));
}
@Transactional
public DispatchResponse update(String customerId, String dispatchNo, DispatchRequests.Update request) {
DispatchOrder dispatch = orders.lockById(customerId, find(customerId, dispatchNo).getId());
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
// Vehicle plate is intentionally editable through all dispatch states.
dispatch.setVehiclePlate(trimToNull(request.vehiclePlate()));
dispatch.setDriverName(trimToNull(request.driverName()));
dispatch.setDriverPhone(trimToNull(request.driverPhone()));
dispatch.setCarrierName(trimToNull(request.carrierName()));
dispatch.setRoute(trimToNull(request.route()));
dispatch.setDepartAt(request.departAt());
dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo()));
dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus()));
dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus()));
dispatch.setEstimatedArrivalAt(request.estimatedArrivalAt());
if (StringUtils.hasText(request.status())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Dispatch status is PDA-managed and cannot be changed by the edit endpoint");
}
dispatch.setUpdatedAt(LocalDateTime.now());
orders.updateById(dispatch);
return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId()));
}
@Transactional
public void cancel(String customerId, String dispatchNo) {
DispatchOrder dispatch = orders.lockById(customerId, find(customerId, dispatchNo).getId());
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
if (!(PENDING_DEPARTURE.equals(dispatch.getStatus()))) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Dispatched order cannot be cancelled");
}
LocalDateTime now = LocalDateTime.now();
dispatch.setStatus(CANCELLED);
dispatch.setUpdatedAt(now);
orders.updateById(dispatch);
List<DispatchPalletLock> activeLocks = locks.selectList(new LambdaQueryWrapper<DispatchPalletLock>()
.eq(DispatchPalletLock::getCustomerId, customerId)
.eq(DispatchPalletLock::getDispatchOrderId, dispatch.getId())
.eq(DispatchPalletLock::getStatus, "ACTIVE"));
for (DispatchPalletLock lock : activeLocks) {
lock.setStatus("RELEASED");
lock.setActiveKey(null);
lock.setReleasedAt(now);
lock.setUpdatedAt(now);
locks.updateById(lock);
}
}
private DispatchOrder find(String customerId, String dispatchNo) {
DispatchOrder dispatch = orders.findByDispatchNo(customerId, dispatchNo);
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
return dispatch;
}
private DispatchResponse response(DispatchOrder order, List<DispatchItem> itemList) {
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.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.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());
}
private static String route(List<DispatchPalletCandidate> selected) {
return selected.stream().map(DispatchPalletCandidate::destinationWarehouseCode).filter(StringUtils::hasText)
.distinct().collect(Collectors.joining(" -> "));
}
private static List<String> splitOrderRefs(String refs, String fallback) {
String value = StringUtils.hasText(refs) ? refs : fallback;
return StringUtils.hasText(value) ? List.of(value.split(",")) : List.of();
}
private static String defaultStatus(String status) {
return StringUtils.hasText(status) ? status.trim().toUpperCase() : "PENDING";
}
private static String trimToNull(String value) {
return StringUtils.hasText(value) ? value.trim() : null;
}
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.dropship;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan("com.baiyue.lastleg.dropship.mapper")
public class DropshipMapperConfiguration {
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.dropship.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.lastleg.dropship.dto.DropshipRequests;
import com.baiyue.lastleg.dropship.service.DropshipOrderService;
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/internal/v1/lastleg/dropship")
public class DropshipOrderController {
@Resource private DropshipOrderService service;
@Resource private InternalServiceTokenGuard tokenGuard;
@PostMapping("/receive")
public ApiResponse<Integer> receive(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody DropshipRequests.Receive request) {
tokenGuard.verify(token);
return ApiResponse.success(service.receive(request));
}
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.dropship.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.util.List;
public final class DropshipRequests {
private DropshipRequests() {}
public record Receive(@NotBlank String customerId, @NotNull Long orderId, @NotNull Long warehouseId,
@NotBlank String idempotencyKey, @NotEmpty List<@Valid Pallet> pallets) {}
public record Pallet(@NotBlank String palletNo, @NotEmpty List<@Valid SkuPackages> skuPackages) {}
public record SkuPackages(@NotBlank String skuCode, String productName, @Positive long packageCount,
@Positive long productQuantity,
@NotEmpty List<@NotBlank String> cartonMarks) {}
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.dropship.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data @TableName("ll_dropship_carton")
public class DropshipCarton {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String customerId;
private Long orderId;
private Long profileId;
private Long warehousePalletId;
private String palletNo;
private String skuCode;
private String cartonMark;
private Integer cartonSequence;
private String status;
private String outboundOrderNo;
private Long version;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.dropship.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data @TableName("ll_dropship_carton_ledger")
public class DropshipCartonLedger {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String customerId;
private Long orderId;
private Long cartonId;
private String cartonMark;
private String skuCode;
private String operationType;
private Long beforePackages;
private Long deltaPackages;
private Long afterPackages;
private String businessOrderNo;
private String idempotencyKey;
private String remark;
private LocalDateTime occurredAt;
private LocalDateTime createdAt;
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.dropship.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data @TableName("ll_dropship_order_profile")
public class DropshipOrderProfile {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String customerId;
private Long orderId;
private Long warehousePalletId;
private String palletNo;
/** Immutable pallet number snapshot supplied by the first-leg handover event. */
private String sourcePalletNo;
private Long inventoryPositionId;
private String status;
private String idempotencyKey;
private Long version;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.dropship.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data @TableName("ll_dropship_sku_inventory")
public class DropshipSkuInventory {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String customerId;
private Long orderId;
private Long profileId;
private String skuCode;
private String productName;
private Long packageCount;
private Long productQuantity;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,8 @@
package com.baiyue.lastleg.dropship.mapper;
import com.baiyue.lastleg.dropship.entity.DropshipCartonLedger;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.*;
public interface DropshipCartonLedgerMapper extends BaseMapper<DropshipCartonLedger> {
@Select("SELECT * FROM ll_dropship_carton_ledger WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1")
DropshipCartonLedger findIdempotency(@Param("customerId") String customerId, @Param("key") String key);
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.dropship.mapper;
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.*;
import java.util.List;
public interface DropshipCartonMapper extends BaseMapper<DropshipCarton> {
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} AND carton_mark=#{cartonMark} LIMIT 1")
DropshipCarton find(@Param("customerId") String customerId, @Param("orderId") Long orderId, @Param("cartonMark") String cartonMark);
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND profile_id=#{profileId} ORDER BY carton_sequence")
List<DropshipCarton> listProfile(@Param("customerId") String customerId, @Param("profileId") Long profileId);
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY profile_id,carton_sequence")
List<DropshipCarton> listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId);
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.dropship.mapper;
import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.*;
import java.util.List;
public interface DropshipOrderProfileMapper extends BaseMapper<DropshipOrderProfile> {
@Select("SELECT * FROM ll_dropship_order_profile WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY source_pallet_no")
List<DropshipOrderProfile> listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId);
}
@@ -0,0 +1,13 @@
package com.baiyue.lastleg.dropship.mapper;
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
public interface DropshipSkuInventoryMapper extends BaseMapper<DropshipSkuInventory> {
@Select("SELECT * FROM ll_dropship_sku_inventory WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY profile_id,sku_code")
List<DropshipSkuInventory> listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId);
}
@@ -0,0 +1,184 @@
package com.baiyue.lastleg.dropship.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.dropship.dto.DropshipRequests;
import com.baiyue.lastleg.dropship.entity.*;
import com.baiyue.lastleg.dropship.mapper.*;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
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.HashSet;
import java.util.List;
import java.util.Set;
import java.util.Map;
import java.util.stream.Collectors;
@Service
public class DropshipOrderService {
@Resource private LastlegOrderMapper orders;
@Resource private DropshipOrderProfileMapper profiles;
@Resource private DropshipSkuInventoryMapper skuInventory;
@Resource private DropshipCartonMapper cartons;
@Resource private DropshipCartonLedgerMapper ledger;
@Transactional
public int receive(DropshipRequests.Receive request) {
LastlegOrder order = orders.selectById(request.orderId());
if (order == null || !request.customerId().equals(order.getCustomerId())) throw conflict("Tail order not found");
if (!"ONE_CLICK_DROPSHIP".equals(order.getOrderType())) throw conflict("Order is not a one-click dropship order");
long totalPackages = request.pallets().stream().flatMap(value -> value.skuPackages().stream())
.mapToLong(DropshipRequests.SkuPackages::packageCount).sum();
long totalQuantity = request.pallets().stream().flatMap(value -> value.skuPackages().stream())
.mapToLong(DropshipRequests.SkuPackages::productQuantity).sum();
if (order.getPackageCount() != null && order.getPackageCount().longValue() != totalPackages) {
throw conflict("SKU package total does not equal order package count");
}
if (order.getProductQuantity() != null && order.getProductQuantity().longValue() != totalQuantity) {
throw conflict("SKU product quantity total does not equal order product quantity");
}
List<DropshipOrderProfile> existing = profiles.listOrder(request.customerId(), request.orderId());
if (!existing.isEmpty()) {
Set<String> existingPallets = existing.stream().map(DropshipOrderProfile::getSourcePalletNo)
.collect(java.util.stream.Collectors.toSet());
Set<String> requestedPallets = request.pallets().stream().map(value -> value.palletNo().trim())
.collect(java.util.stream.Collectors.toSet());
if (!existingPallets.equals(requestedPallets)) throw conflict("Dropship order pallet snapshot conflicts with existing data");
return Math.toIntExact(totalPackages);
}
validateMarks(request);
LocalDateTime now = LocalDateTime.now();
for (DropshipRequests.Pallet inputPallet : request.pallets()) {
DropshipOrderProfile profile = profile(request, order, inputPallet, now);
profiles.insert(profile);
int sequence = 0;
for (DropshipRequests.SkuPackages input : inputPallet.skuPackages()) {
DropshipSkuInventory inventory = new DropshipSkuInventory();
inventory.setId(IdWorker.getId()); inventory.setCustomerId(request.customerId()); inventory.setOrderId(order.getId());
inventory.setProfileId(profile.getId()); inventory.setSkuCode(input.skuCode()); inventory.setProductName(input.productName());
inventory.setPackageCount(input.packageCount()); inventory.setProductQuantity(input.productQuantity());
inventory.setCreatedAt(now); inventory.setUpdatedAt(now);
skuInventory.insert(inventory);
for (String mark : input.cartonMarks()) {
DropshipCarton carton = new DropshipCarton();
carton.setId(IdWorker.getId()); carton.setCustomerId(request.customerId()); carton.setOrderId(order.getId());
carton.setProfileId(profile.getId()); carton.setWarehousePalletId(null); carton.setPalletNo(null);
carton.setSkuCode(input.skuCode()); carton.setCartonMark(mark.trim()); carton.setCartonSequence(++sequence);
carton.setStatus("PENDING_INBOUND"); carton.setVersion(0L); carton.setCreatedAt(now); carton.setUpdatedAt(now);
cartons.insert(carton);
ledger.insert(stockInFact(request, order, carton, now));
}
}
}
return Math.toIntExact(totalPackages);
}
private void validateMarks(DropshipRequests.Receive request) {
Set<String> marks = new HashSet<>();
Set<String> palletNos = new HashSet<>();
for (DropshipRequests.Pallet pallet : request.pallets()) {
if (!palletNos.add(pallet.palletNo().trim())) throw conflict("Duplicate pallet number: " + pallet.palletNo());
Set<String> skus = new HashSet<>();
for (DropshipRequests.SkuPackages item : pallet.skuPackages()) {
if (item.packageCount() != item.cartonMarks().size()) {
throw conflict("SKU packageCount must equal cartonMarks count: " + item.skuCode());
}
if (!skus.add(item.skuCode().trim())) throw conflict("Duplicate SKU on pallet: " + item.skuCode());
for (String mark : item.cartonMarks()) {
if (!marks.add(mark.trim())) throw conflict("Duplicate carton mark: " + mark);
}
}
}
}
private DropshipOrderProfile profile(DropshipRequests.Receive request, LastlegOrder order,
DropshipRequests.Pallet input, LocalDateTime now) {
DropshipOrderProfile profile = new DropshipOrderProfile();
profile.setId(IdWorker.getId()); profile.setCustomerId(request.customerId()); profile.setOrderId(order.getId());
profile.setSourcePalletNo(input.palletNo().trim());
profile.setStatus("PENDING_INBOUND"); profile.setVersion(0L);
profile.setIdempotencyKey(request.idempotencyKey() + ":" + input.palletNo().trim());
profile.setCreatedAt(now); profile.setUpdatedAt(now);
return profile;
}
@Transactional
public void activateAfterStockIn(String customerId, Long orderId, Long taskId,
List<InboundStockReceipt> receipts) {
List<DropshipOrderProfile> profilesForOrder = profiles.listOrder(customerId, orderId);
if (profilesForOrder.isEmpty()) return;
if (profilesForOrder.stream().allMatch(profile -> "ACTIVE".equals(profile.getStatus()))) return;
if (profilesForOrder.stream().anyMatch(profile -> "ACTIVE".equals(profile.getStatus()))) {
throw conflict("Dropship pallet activation is incomplete; repair profiles before retrying");
}
Map<Long, List<InboundStockReceipt>> receiptsByPallet = receipts.stream()
.filter(receipt -> receipt.getWarehousePalletId() != null)
.collect(Collectors.groupingBy(InboundStockReceipt::getWarehousePalletId));
if (receiptsByPallet.size() != profilesForOrder.size()) {
throw conflict("PDA pallet count does not match dropship source pallet count");
}
List<DropshipOrderProfile> orderedProfiles = profilesForOrder.stream()
.sorted(java.util.Comparator.comparing(DropshipOrderProfile::getSourcePalletNo)).toList();
List<List<InboundStockReceipt>> orderedReceipts = receiptsByPallet.values().stream()
.sorted(java.util.Comparator.comparing(value -> value.get(0).getWarehousePalletId())).toList();
LocalDateTime now = LocalDateTime.now();
for (int index = 0; index < orderedProfiles.size(); index++) {
DropshipOrderProfile profile = orderedProfiles.get(index);
List<InboundStockReceipt> actual = orderedReceipts.get(index);
List<DropshipCarton> profileCartons = cartons.listProfile(customerId, profile.getId());
long expected = profileCartons.size();
long received = actual.stream().mapToLong(value -> value.getReceivedPackages() == null ? 0 : value.getReceivedPackages()).sum();
if (expected != received) throw conflict("PDA package count does not match dropship carton snapshot");
Long positionId = actual.get(0).getInventoryPositionId();
if (actual.stream().anyMatch(value -> !java.util.Objects.equals(positionId, value.getInventoryPositionId()))) {
throw conflict("Dropship source pallet maps to multiple inventory positions");
}
InboundStockReceipt first = actual.get(0);
profile.setWarehousePalletId(first.getWarehousePalletId()); profile.setPalletNo(first.getPalletNo());
profile.setInventoryPositionId(positionId); profile.setStatus("ACTIVE"); profile.setVersion(value(profile.getVersion()) + 1);
profile.setUpdatedAt(now); profiles.updateById(profile);
for (DropshipCarton carton : profileCartons) {
carton.setWarehousePalletId(first.getWarehousePalletId()); carton.setPalletNo(first.getPalletNo());
carton.setStatus("AVAILABLE"); carton.setVersion(value(carton.getVersion()) + 1); carton.setUpdatedAt(now);
cartons.updateById(carton);
DropshipCartonLedger activation = stockInActivation(customerId, orderId, taskId, carton, now);
ledger.insert(activation);
}
}
}
private DropshipCartonLedger stockInActivation(String customerId, Long orderId, Long taskId,
DropshipCarton carton, LocalDateTime now) {
DropshipCartonLedger fact = new DropshipCartonLedger();
fact.setId(IdWorker.getId()); fact.setCustomerId(customerId); fact.setOrderId(orderId);
fact.setCartonId(carton.getId()); fact.setCartonMark(carton.getCartonMark()); fact.setSkuCode(carton.getSkuCode());
fact.setOperationType("STOCK_IN"); fact.setBeforePackages(0L); fact.setDeltaPackages(1L); fact.setAfterPackages(1L);
fact.setIdempotencyKey("PDA_STOCK_IN:" + taskId + ":" + carton.getId()); fact.setOccurredAt(now); fact.setCreatedAt(now);
return fact;
}
private DropshipCartonLedger stockInFact(DropshipRequests.Receive request, LastlegOrder order,
DropshipCarton carton, LocalDateTime now) {
DropshipCartonLedger fact = new DropshipCartonLedger();
fact.setId(IdWorker.getId()); fact.setCustomerId(request.customerId()); fact.setOrderId(order.getId());
fact.setCartonId(carton.getId()); fact.setCartonMark(carton.getCartonMark()); fact.setSkuCode(carton.getSkuCode());
fact.setOperationType("SNAPSHOT_CREATED"); fact.setBeforePackages(0L); fact.setDeltaPackages(0L); fact.setAfterPackages(0L);
fact.setBusinessOrderNo(order.getOrderNo());
fact.setIdempotencyKey(request.idempotencyKey() + ":" + carton.getCartonMark());
fact.setOccurredAt(now); fact.setCreatedAt(now);
return fact;
}
private static long value(Long value) { return value == null ? 0L : value; }
private static ResponseStatusException conflict(String message) {
return new ResponseStatusException(HttpStatus.CONFLICT, message);
}
}
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.dto;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
import java.util.List;
public record LastlegOrderDetailResponse(
LastlegOrder order,
List<LastlegOrderDetail> cargo,
List<LastlegOrderStatusHistory> statusHistory,
List<DropshipPallet> dropshipPallets
) {
public record DropshipPallet(
Long profileId,
String sourcePalletNo,
Long warehousePalletId,
String warehousePalletNo,
Long inventoryPositionId,
String status,
List<DropshipSkuInventory> skuInventory,
List<DropshipCarton> cartons
) {}
}
@@ -0,0 +1,55 @@
package com.baiyue.lastleg.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.List;
public final class LastlegOrderRequests {
private LastlegOrderRequests() {
}
public record Query(
@Min(1) long page,
@Min(1) @Max(200) long size,
String orderNo,
String inboundCode,
String sourceOrderNo,
String containerNo,
String status,
String transferWarehouseCode,
Boolean abnormal,
Boolean detained,
LocalDateTime createdFrom,
LocalDateTime createdTo
) {
public Query {
if (page == 0) page = 1;
if (size == 0) size = 20;
}
}
public record TransferDestination(
@NotEmpty @Size(max = 200) List<Long> orderIds,
@NotBlank @Size(max = 128) String warehouseId,
@NotBlank @Size(max = 128) String warehouseCode,
@NotBlank @Size(max = 255) String warehouseName,
@Size(max = 500) String remark
) {
}
public record SingleTransferDestination(
@NotBlank @Size(max = 128) String warehouseId,
@NotBlank @Size(max = 128) String warehouseCode,
@NotBlank @Size(max = 255) String warehouseName,
@Size(max = 500) String remark
) {
public TransferDestination withOrderId(long orderId) {
return new TransferDestination(List.of(orderId), warehouseId, warehouseCode, warehouseName, remark);
}
}
}
@@ -0,0 +1,51 @@
package com.baiyue.lastleg.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Schema(description = "Tail-leg order row at inbound-code granularity")
public record LastlegOrderSummary(
Long id,
String orderNo,
String orderType,
String sourceInboundId,
String inboundCode,
String sourceOrderNo,
String customerName,
String overseasWarehouseId,
String overseasWarehouseName,
String transferWarehouseId,
String transferWarehouseCode,
String transferWarehouseName,
String fbaWarehouseCode,
String containerNo,
String containerStatus,
Boolean abnormal,
Boolean detained,
Integer palletCount,
Integer packageCount,
Integer pickedPackageCount,
Integer shippedPackageCount,
Integer remainingPackageCount,
BigDecimal volumeCbm,
Integer productQuantity,
BigDecimal volumeWeightKg,
LocalDateTime estimatedArrivalAt,
LocalDateTime actualArrivalAt,
LocalDateTime estimatedWarehouseAt,
String inboundOrderNo,
String outboundOrderNo,
String deliveryOrderNo,
String relabelOrderNo,
String relabelStatus,
String palletMergeFlag,
String currentStatus,
String subOrderStatus,
String privateRemark,
String customerRemark,
String warehouseRemark,
LocalDateTime createdAt
) {
}
@@ -0,0 +1,5 @@
/**
* API request and response models for last-leg delivery.
* / 尾程配送 API 请求与响应模型。
*/
package com.baiyue.lastleg.dto;
@@ -0,0 +1,3 @@
package com.baiyue.lastleg.entity;
import com.baomidou.mybatisplus.annotation.*; import lombok.Data; import java.time.LocalDateTime;
@Data @TableName("ll_loading_event_inbox") public class LastlegEventInbox { @TableId private String eventId; private String customerId; private String eventType; private Integer eventVersion; private String payloadHash; private String payloadJson; private String status; private LocalDateTime receivedAt; private LocalDateTime processedAt; private String lastError; }
@@ -0,0 +1,64 @@
package com.baiyue.lastleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@TableName("ll_order")
public class LastlegOrder {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String orderNo;
private String customerId;
private String orderType;
private String inboundCode;
private String sourceRegion;
private String sourceInboundId;
private String rootInboundId;
private String sourceOrderId;
private String sourceOrderNo;
private String rootOrderId;
private String customerName;
private String overseasWarehouseId;
private String overseasWarehouseName;
private String fbaWarehouseCode;
private String containerNo;
private String transferWarehouseId;
private String transferWarehouseCode;
private String transferWarehouseName;
private String containerStatus;
private Boolean abnormal;
private Boolean detained;
private Integer palletCount;
private Integer packageCount;
private Integer pickedPackageCount;
private Integer shippedPackageCount;
private BigDecimal volumeCbm;
private Integer productQuantity;
private BigDecimal volumeWeightKg;
private LocalDateTime estimatedArrivalAt;
private LocalDateTime actualArrivalAt;
private LocalDateTime estimatedWarehouseAt;
private String inboundOrderNo;
private String outboundOrderNo;
private String deliveryOrderNo;
private String relabelOrderNo;
private String relabelStatus;
private String palletMergeFlag;
private String currentStatus;
private String subOrderStatus;
private String privateRemark;
private String customerRemark;
private String warehouseRemark;
private String sourceEventId;
private String sourceLoadingPlanId;
private Long sourceLoadingPlanVersion;
private Long sourceHandoverVersion;
private String sourcePayloadHash;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,3 @@
package com.baiyue.lastleg.entity;
import com.baomidou.mybatisplus.annotation.*; import lombok.Data; import java.math.BigDecimal;
@Data @TableName("ll_order_detail") public class LastlegOrderDetail { @TableId(type=IdType.AUTO) private Long id; private Long orderId; private String sourceInboundDetailId; private String skuCode; private String productNameZh; private String productNameEn; private Integer packageCount; private Integer productQuantity; private Integer palletCount; private BigDecimal grossWeightKg; private BigDecimal netWeightKg; private BigDecimal dimensionalWeightKg; private BigDecimal volumeCbm; private String destinationWarehouseId; private String destinationWarehouseCode; private String destinationWarehouseName; private String fbaTrackingNo; private String shipmentNo; private String shippingMark; }
@@ -0,0 +1,3 @@
package com.baiyue.lastleg.entity;
import com.baomidou.mybatisplus.annotation.*; import lombok.Data; import java.time.LocalDateTime;
@Data @TableName("ll_loading_order_status_history") public class LastlegOrderStatusHistory { @TableId(type=IdType.AUTO) private Long id; private Long orderId; private String fromStatus; private String toStatus; private String eventId; private String remark; private LocalDateTime createdAt; }
@@ -0,0 +1,5 @@
/**
* Persistence entities mapped to last-leg MySQL tables.
* / 映射尾程 MySQL 数据表的持久化实体。
*/
package com.baiyue.lastleg.entity;
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.inboundplan;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan({"com.baiyue.lastleg.inboundplan.mapper", "com.baiyue.lastleg.inboundplan.pda.mapper"})
public class InboundPlanMapperConfiguration {
}
@@ -0,0 +1,74 @@
package com.baiyue.lastleg.inboundplan.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.inboundplan.dto.ConvertInboundPlanRequest;
import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest;
import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse;
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
import com.baiyue.lastleg.inboundplan.service.InboundPlanService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.annotation.Resource;
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/inbound-plans")
@Tag(name = "Tail-leg inbound plans")
public class InboundPlanController {
@Resource private InboundPlanService service;
@Resource private InboundFbaAppointmentService appointmentService;
@PostMapping("/convert")
@Operation(summary = "Convert the entire container to one inbound plan")
public ApiResponse<InboundPlanResponse> convert(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ConvertInboundPlanRequest request) {
return ApiResponse.success(service.convert(scope(user, request.customerId()), request.orderId()));
}
@GetMapping("/{planNo}")
@Operation(summary = "Get an inbound plan with lines, pallets and allocations")
public ApiResponse<InboundPlanResponse> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@RequestParam(required = false) String customerId) {
return ApiResponse.success(service.detailByPlanNo(scope(user, customerId), planNo));
}
@PostMapping("/fba-appointments/batch")
@Operation(summary = "Create one FBA appointment for selected inbound lines")
public ApiResponse<FbaAppointmentResponse> batchFbaAppointment(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody BatchFbaAppointmentRequest request) {
String customerId = scope(user, request.customerId());
String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId());
return ApiResponse.success(appointmentService.batchCreate(customerId, operatorId, request));
}
@GetMapping("/fba-appointments/{appointmentNo}")
@Operation(summary = "Get an FBA appointment and its selected inbound lines")
public ApiResponse<FbaAppointmentResponse> fbaAppointmentDetail(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String appointmentNo,
@RequestParam(required = false) String customerId) {
return ApiResponse.success(appointmentService.detail(scope(user, customerId), appointmentNo));
}
private String scope(AuthenticatedUser user, String requestedCustomer) {
if (user == null || user.tenantId() == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
}
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Platform request must specify target tenant");
}
return requestedCustomer;
}
return String.valueOf(user.tenantId());
}
}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.inboundplan.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.List;
public record BatchFbaAppointmentRequest(
String customerId,
@NotEmpty @Size(max = 500) List<@NotNull Long> lineIds,
@NotBlank @Size(max = 64) String appointmentNo,
@NotNull LocalDateTime appointmentAt,
@NotBlank @Size(max = 32) String appointmentStatus,
@Size(max = 1000) String appointmentRemark
) {
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.inboundplan.dto;
import jakarta.validation.constraints.NotNull;
public record ConvertInboundPlanRequest(
@NotNull Long orderId,
String customerId
) {
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.inboundplan.dto;
import java.time.LocalDateTime;
import java.util.List;
public record FbaAppointmentResponse(
Long id,
String customerId,
String appointmentNo,
LocalDateTime appointmentAt,
String appointmentStatus,
String appointmentRemark,
String fbaWarehouseCode,
int selectedCount,
String createdBy,
LocalDateTime createdAt,
LocalDateTime updatedAt,
List<Item> items
) {
public record Item(Long id, Long planId, Long lineId, Long orderId, String inboundCode,
String fbaWarehouseCode) {
}
}
@@ -0,0 +1,37 @@
package com.baiyue.lastleg.inboundplan.dto;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
public record InboundPlanResponse(
Long id,
String planNo,
String customerId,
String containerNo,
String status,
int lineCount,
int palletCount,
BigDecimal totalVolumeCbm,
List<Line> lines,
List<Pallet> pallets,
List<Allocation> allocations
) {
public record Line(Long id, Long orderId, String inboundCode, String fbaWarehouseCode, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
BigDecimal volumeCbm, int requiredPackageCount, int plannedPalletCount) {
}
public record Pallet(Long id, String palletNo, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
BigDecimal capacityCbm, BigDecimal allocatedVolumeCbm, int sequenceNo,
Long warehousePalletId, Long warehouseLocationId, Long warehouseResourceLockId,
String assignmentStatus, LocalDateTime lockedAt, BigDecimal actualVolumeCbm,
int actualPackageCount) {
}
public record Allocation(Long lineId, Long palletId, BigDecimal allocatedVolumeCbm,
int plannedPackageCount, int countedPackageCount, int pickedPackageCount,
BigDecimal actualVolumeCbm, String status, int version) {
}
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.inboundplan.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_inbound_fba_appointment")
public class InboundFbaAppointment {
@TableId(type = IdType.INPUT)
private Long id;
private String customerId;
private String appointmentNo;
private LocalDateTime appointmentAt;
private String appointmentStatus;
private String appointmentRemark;
private String fbaWarehouseCode;
private Integer selectedCount;
private String createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.inboundplan.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_inbound_fba_appointment_item")
public class InboundFbaAppointmentItem {
@TableId(type = IdType.INPUT)
private Long id;
private Long appointmentId;
private Long planId;
private Long lineId;
private Long orderId;
private String inboundCode;
private String fbaWarehouseCode;
private LocalDateTime createdAt;
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.inboundplan.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@TableName("ll_inbound_plan")
public class InboundPlan {
@TableId(type = IdType.INPUT)
private Long id;
private String planNo;
private String customerId;
private String containerNo;
private String status;
private Integer lineCount;
private Integer palletCount;
private BigDecimal totalVolumeCbm;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.inboundplan.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
@Data
@TableName("ll_inbound_plan_line")
public class InboundPlanLine {
@TableId(type = IdType.INPUT)
private Long id;
private Long planId;
private Long orderId;
private String inboundCode;
private String fbaWarehouseCode;
private String destinationWarehouseId;
private String destinationWarehouseCode;
private String destinationWarehouseName;
private BigDecimal volumeCbm;
private Integer requiredPackageCount;
private Integer plannedPalletCount;
}
@@ -0,0 +1,31 @@
package com.baiyue.lastleg.inboundplan.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@TableName("ll_inbound_plan_pallet")
public class InboundPlanPallet {
@TableId(type = IdType.INPUT)
private Long id;
private Long planId;
private String palletNo;
private String destinationWarehouseId;
private String destinationWarehouseCode;
private String destinationWarehouseName;
private BigDecimal capacityCbm;
private BigDecimal allocatedVolumeCbm;
private Integer sequenceNo;
private Long warehousePalletId;
private Long warehouseLocationId;
private Long warehouseResourceLockId;
private String assignmentStatus;
private LocalDateTime lockedAt;
private BigDecimal actualVolumeCbm;
private Integer actualPackageCount;
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.inboundplan.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
@Data
@TableName("ll_inbound_plan_pallet_allocation")
public class InboundPlanPalletAllocation {
@TableId(type = IdType.INPUT)
private Long id;
private Long planId;
private Long lineId;
private Long palletId;
private BigDecimal allocatedVolumeCbm;
private Integer plannedPackageCount;
private Integer countedPackageCount;
private Integer pickedPackageCount;
private BigDecimal actualVolumeCbm;
private String status;
private Integer version;
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.inboundplan.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InboundFbaAppointmentItemMapper extends BaseMapper<InboundFbaAppointmentItem> {
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.inboundplan.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointment;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InboundFbaAppointmentMapper extends BaseMapper<InboundFbaAppointment> {
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.inboundplan.mapper;
import com.baiyue.lastleg.inboundplan.model.FbaAppointmentLineSnapshot;
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 InboundFbaAppointmentQueryMapper {
@Select("<script>" +
"SELECT l.id AS lineId,l.plan_id AS planId,l.order_id AS orderId,l.inbound_code AS inboundCode," +
"l.fba_warehouse_code AS fbaWarehouseCode " +
"FROM ll_inbound_plan_line l JOIN ll_inbound_plan p ON p.id=l.plan_id " +
"WHERE p.customer_id=#{customerId} AND l.id IN " +
"<foreach collection='lineIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"ORDER BY l.id FOR UPDATE" +
"</script>")
List<FbaAppointmentLineSnapshot> lockLines(@Param("customerId") String customerId,
@Param("lineIds") List<Long> lineIds);
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.inboundplan.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InboundPlanLineMapper extends BaseMapper<InboundPlanLine> {
}
@@ -0,0 +1,13 @@
package com.baiyue.lastleg.inboundplan.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface InboundPlanMapper extends BaseMapper<InboundPlan> {
@Select("SELECT * FROM ll_inbound_plan WHERE customer_id=#{customerId} AND container_no=#{containerNo} LIMIT 1")
InboundPlan findByContainer(@Param("customerId") String customerId, @Param("containerNo") String containerNo);
}
@@ -0,0 +1,50 @@
package com.baiyue.lastleg.inboundplan.mapper;
import com.baiyue.lastleg.inboundplan.model.SourceOrderSnapshot;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface InboundPlanOrderMapper {
@Select("SELECT container_no FROM ll_order WHERE customer_id=#{customerId} AND id=#{orderId} LIMIT 1")
String findContainerNo(@Param("customerId") String customerId, @Param("orderId") Long orderId);
@Select("SELECT o.id AS orderId,o.customer_id AS customerId,o.container_no AS containerNo," +
"o.inbound_code AS inboundCode,o.current_status AS currentStatus,o.inbound_order_no AS inboundOrderNo," +
"COALESCE(o.volume_cbm,0) AS volumeCbm,COALESCE(o.package_count,0) AS packageCount," +
"NULLIF(o.fba_warehouse_code,'') AS fbaWarehouseCode," +
"COALESCE(NULLIF(o.transfer_warehouse_id,''),MIN(NULLIF(d.destination_warehouse_id,''))) AS destinationWarehouseId," +
"COALESCE(NULLIF(o.transfer_warehouse_code,''),MIN(NULLIF(d.destination_warehouse_code,''))) AS destinationWarehouseCode," +
"COALESCE(NULLIF(o.transfer_warehouse_name,''),MIN(NULLIF(d.destination_warehouse_name,''))) AS destinationWarehouseName," +
"COUNT(DISTINCT COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#')) AS destinationCount," +
"SUM(CASE WHEN d.id IS NOT NULL " +
"AND COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) IS NOT NULL " +
"AND COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#') " +
"<> COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) THEN 1 ELSE 0 END) " +
"AS destinationMismatchCount " +
"FROM ll_order o LEFT JOIN ll_order_detail d ON d.order_id=o.id " +
"WHERE o.customer_id=#{customerId} AND o.container_no=#{containerNo} " +
"GROUP BY o.id,o.customer_id,o.container_no,o.inbound_code,o.current_status,o.inbound_order_no,o.volume_cbm,o.package_count,o.fba_warehouse_code," +
"o.transfer_warehouse_id,o.transfer_warehouse_code,o.transfer_warehouse_name " +
"ORDER BY o.id FOR UPDATE")
List<SourceOrderSnapshot> lockContainerOrders(@Param("customerId") String customerId,
@Param("containerNo") String containerNo);
@Update("UPDATE ll_order SET inbound_order_no=#{planNo},current_status='INBOUND_PLANNED'," +
"pallet_merge_flag='MERGED',pallet_count=#{palletCount},updated_at=#{updatedAt} " +
"WHERE customer_id=#{customerId} AND id=#{orderId}")
int markPlanned(@Param("customerId") String customerId, @Param("orderId") Long orderId,
@Param("planNo") String planNo, @Param("palletCount") int palletCount,
@Param("updatedAt") LocalDateTime updatedAt);
@Insert("INSERT INTO ll_loading_order_status_history(order_id,from_status,to_status,remark,created_at) " +
"VALUES(#{orderId},#{fromStatus},'INBOUND_PLANNED',#{remark},#{createdAt})")
int insertHistory(@Param("orderId") Long orderId, @Param("fromStatus") String fromStatus,
@Param("remark") String remark, @Param("createdAt") LocalDateTime createdAt);
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.inboundplan.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InboundPlanPalletAllocationMapper extends BaseMapper<InboundPlanPalletAllocation> {
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.inboundplan.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InboundPlanPalletMapper extends BaseMapper<InboundPlanPallet> {
}
@@ -0,0 +1,10 @@
package com.baiyue.lastleg.inboundplan.model;
public record FbaAppointmentLineSnapshot(
Long lineId,
Long planId,
Long orderId,
String inboundCode,
String fbaWarehouseCode
) {
}
@@ -0,0 +1,8 @@
package com.baiyue.lastleg.inboundplan.model;
public enum FbaAppointmentStatus {
PENDING,
SUCCESS,
FAILED,
CANCELLED
}

Some files were not shown because too many files have changed in this diff Show More