This commit is contained in:
2026-08-18 11:39:40 +08:00
parent ac6c0d0395
commit d2c532f807
165 changed files with 2547 additions and 1060 deletions
+11 -2
View File
@@ -88,7 +88,6 @@ PDA 部分拣货会生成本柜实装 `LOADED` 分片以及可继续分柜的 `R
"loadingPlanNo": "LP202608130001",
"containerBookingId": "208500001",
"containerNo": "MSCU1234567",
"cabinetNo": "CAB20260813001",
"containerType": "40HQ",
"sealNo": "SEAL001",
"voyageNo": "V001",
@@ -142,12 +141,20 @@ PDA 部分拣货会生成本柜实装 `LOADED` 分片以及可继续分柜的 `R
- 文件只传不可变文件引用、区域和摘要,不传二进制内容。
- V1 消费者忽略未知字段;已有字段不改变含义或类型。
### 5.1 订单类型分支
`orderType`、`pallets`、`palletNo`、`skuPackages` 和 `cartonMarks` 必须正式加入头程公共事件契约及发送端,不能长期依赖尾程读取原始 JSON 扩展字段。三个服务升级到同一 `common-event-contract` 版本后再启用生产流量。
`ONE_CLICK_DROPSHIP` 的一笔最终实装入仓单可包含一个或多个来源托盘快照。消费时必须满足:所有托盘的 SKU 箱数之和等于订单箱数、SKU 数量之和等于订单数量、每个 SKU 的箱唛数等于其箱数、同一托盘内 SKU 不重复、同一订单内来源托盘号和箱唛不重复。通过后按来源托盘创建一件代发档案及托盘级 SKU 库存,并创建箱唛记录和不可变箱唛流水。`source_pallet_no` 是头程冻结快照;`warehouse_pallet_id/pallet_no` 是 Warehouse API 分配的海外仓实物托盘,两者不得混用。
非 `ONE_CLICK_DROPSHIP` 类型只创建 `ll_order`、冻结明细和初始状态历史,来源 `palletCount` 按 0 入库,不创建一件代发 SKU/箱唛库存。后续入仓 PDA 分配的托盘属于海外仓作业资源。
## 6. 尾程建单幂等
尾程订单表必须有以下唯一约束:
```text
UNIQUE (tenant_id, source_region, source_inbound_id)
UNIQUE (customer_id, source_region, source_inbound_id)
```
建议同时保存:
@@ -170,6 +177,8 @@ UNIQUE (tenant_id, source_region, source_inbound_id)
事件内任意一笔订单建单失败时,整个事件消费事务回滚,不允许出现同一客户同一柜次只生成部分尾程订单。
同一实际集装箱的订单共享 `order_no`、`container_no` 和 `source_loading_plan_id`;`ll_order.id` 仍是每条订单独立的雪花 ID。整柜查询、整柜操作和转入库计划统一以 `containerNo` 聚合,不使用虚拟柜号 `cabinetNo`。数据库必须保留 `KEY idx_ll_order_container (customer_id, container_no)`。
## 7. 尾程订单初始状态
事件自动生成的订单建议使用:
@@ -20,7 +20,7 @@ public class BridgeInbox {
private Integer eventVersion;
private String sourceRegion;
private String targetRegion;
private String tenantId;
private String customerId;
private String aggregateType;
private String aggregateId;
private Long aggregateVersion;
@@ -69,7 +69,7 @@ public class EventInboxService {
inbox.setEventVersion(envelope.eventVersion());
inbox.setSourceRegion(envelope.sourceRegion().name());
inbox.setTargetRegion(envelope.targetRegion().name());
inbox.setTenantId(envelope.tenantId());
inbox.setCustomerId(envelope.tenantId());
inbox.setAggregateType(envelope.aggregateType());
inbox.setAggregateId(envelope.aggregateId());
inbox.setAggregateVersion(envelope.aggregateVersion());
@@ -34,7 +34,7 @@ public class HttpLastlegEventClient implements LastlegEventClient {
public String deliver(BridgeInbox row) throws Exception {
EventEnvelope envelope = new EventEnvelope(row.getEventId(), row.getEventType(), row.getEventVersion(),
row.getOccurredAt(), RegionCode.valueOf(row.getSourceRegion()), RegionCode.valueOf(row.getTargetRegion()),
row.getTenantId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(),
row.getCustomerId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(),
row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(),
objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash());
String body = objectMapper.writeValueAsString(envelope);
@@ -8,7 +8,7 @@ CREATE TABLE IF NOT EXISTS bridge_outbox (
event_version INT NOT NULL COMMENT '事件契约版本',
source_region VARCHAR(32) NOT NULL COMMENT '来源区域',
target_region VARCHAR(32) NOT NULL COMMENT '目标区域',
tenant_id VARCHAR(128) NOT NULL COMMENT '租户标识',
customer_id VARCHAR(128) NOT NULL COMMENT '租户标识',
aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型',
aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识',
aggregate_version BIGINT NOT NULL COMMENT '聚合版本',
@@ -39,7 +39,7 @@ CREATE TABLE IF NOT EXISTS bridge_inbox (
event_version INT NOT NULL COMMENT '事件契约版本',
source_region VARCHAR(32) NOT NULL COMMENT '来源区域',
target_region VARCHAR(32) NOT NULL COMMENT '目标区域',
tenant_id VARCHAR(128) NOT NULL COMMENT '租户标识',
customer_id VARCHAR(128) NOT NULL COMMENT '租户标识',
aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型',
aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识',
aggregate_version BIGINT NOT NULL COMMENT '聚合版本',
+5 -2
View File
@@ -21,13 +21,17 @@
| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
| PDA 入仓、实收、上架、合托、完成 | `inboundplan/pda/controller/`、`inboundplan/pda/service/InboundPdaWorkflowService.java` |
| 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` |
| 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java`、`transfer/service/WarehouseTransferService.java` |
| 一件代发接收、SKU/箱唛库存 | `dropship/service/DropshipOrderService.java`、`dropship/entity/`、`dropship/mapper/` |
| 数据库 | `src/main/resources/db/lastleg.sql` |
| 测试 | `src/test/java/com/baiyue/lastleg/` |
## 事件建单规则
- 只接受冻结契约支持的事件类型和版本;校验事件 ID、租户、区域、Payload 哈希及必要字段。
- 同一事件/loading plan 生成一个共享尾程单号;`payload.orders` 每项按 `sourceInboundId` 独立落一条 `ll_order`,并保存对应明细和初始状态历史。
- 同一实际集装箱 `containerNo` 生成一个共享业务尾程单号;`payload.orders` 每项按 `sourceInboundId` 独立落一条雪花 ID `ll_order`,并保存对应明细和初始状态历史。整柜操作不使用 `cabinetNo`。
- `ONE_CLICK_DROPSHIP` 每个来源入仓单必须携带一个或多个不可变来源托盘快照;每托按 SKU 携带箱数、数量和箱唛。校验订单汇总一致、托盘号/SKU/箱唛唯一后,按托建立档案及 SKU 库存,并建立箱唛和流水。仓库实物托盘由 Warehouse API 分配,不得覆盖来源托盘快照。
- 其他订单类型只建立入仓单维度的尾程订单与冻结明细,来源 `palletCount` 强制为 0;后续 PDA 托盘属于海外仓作业资源。
- 整个事件消费在一个事务内完成;任意订单失败时不得留下部分订单。
- 相同事件重复投递返回既有结果,不重新生成单号或插入明细。来源入仓单唯一约束是最终防线。
- 内部接口先执行常量时间令牌校验;缺失或错误令牌不得进入消费事务。
@@ -44,4 +48,3 @@
- 事件契约变化同时检查 `common-event-contract` 版本、Event Bridge 消费方和旧 Payload 哈希兼容。
- 定向验证:`mvn -pl lastleg -am test`。
- 事件链路变更至少覆盖重复事件、相同来源入仓单、事务回滚、并发单号、内部令牌和租户越权测试。
@@ -51,27 +51,27 @@ public class LastlegOrderController {
@Operation(summary = "Page tail-leg order rows")
public ApiResponse<PageResult<LastlegOrderSummary>> list(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String tenantId,
@RequestParam(required = false) String customerId,
@Valid LastlegOrderRequests.Query query) {
return ApiResponse.success(orderService.page(scope(user, tenantId), 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 tenantId,
@RequestParam(required = false) String customerId,
@PathVariable long id) {
return ApiResponse.success(orderService.detail(scope(user, tenantId), 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 tenantId,
@RequestParam(required = false) String customerId,
@Valid LastlegOrderRequests.Query query) {
byte[] body = orderService.exportCsv(scope(user, tenantId), 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")
@@ -82,31 +82,31 @@ public class LastlegOrderController {
@Operation(summary = "Transfer one order to a destination warehouse")
public ApiResponse<Integer> transferOne(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String tenantId,
@RequestParam(required = false) String customerId,
@PathVariable long id,
@Valid @RequestBody LastlegOrderRequests.SingleTransferDestination request) {
return ApiResponse.success(orderService.transferDestination(scope(user, tenantId), request.withOrderId(id)));
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 tenantId,
@RequestParam(required = false) String customerId,
@Valid @RequestBody LastlegOrderRequests.TransferDestination request) {
return ApiResponse.success(orderService.transferDestination(scope(user, tenantId), request));
return ApiResponse.success(orderService.transferDestination(scope(user, customerId), request));
}
private String scope(AuthenticatedUser user, String requestedTenant) {
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 (requestedTenant == null || requestedTenant.isBlank() || "0".equals(requestedTenant)) {
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Platform queries must specify a customer tenant");
"Platform queries must specify a customer");
}
return requestedTenant;
return requestedCustomer;
}
return String.valueOf(user.tenantId());
}
@@ -25,46 +25,46 @@ public class DeliveryTransferController {
@Operation(summary = "Create one pending outbound order and one pending delivery order")
public ApiResponse<DeliveryResponses.TransferResult> transfer(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo,
@RequestParam(required = false) String tenantId,
@RequestParam(required = false) String customerId,
@Valid @RequestBody DeliveryRequests.Transfer request) {
return ApiResponse.success(service.transfer(scope(user, tenantId), dispatchNo, operator(user), 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 tenantId,
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@Valid DeliveryRequests.Query query) {
return ApiResponse.success(service.outboundPage(scope(user, tenantId), 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 tenantId,
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@PathVariable String outboundNo, @Valid DeliveryRequests.PalletQuery query) {
return ApiResponse.success(service.outboundDetail(scope(user, tenantId), outboundNo, 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 tenantId,
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@Valid DeliveryRequests.Query query) {
return ApiResponse.success(service.deliveryPage(scope(user, tenantId), 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 tenantId,
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@PathVariable String deliveryNo, @Valid DeliveryRequests.PalletQuery query) {
return ApiResponse.success(service.deliveryDetail(scope(user, tenantId), deliveryNo, query));
return ApiResponse.success(service.deliveryDetail(scope(user, customerId), deliveryNo, query));
}
private String scope(AuthenticatedUser user, String requestedTenant) {
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 (requestedTenant == null || requestedTenant.isBlank() || "0".equals(requestedTenant)) {
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant");
}
return requestedTenant.trim();
return requestedCustomer.trim();
}
return String.valueOf(user.tenantId());
}
@@ -13,7 +13,7 @@ import java.time.LocalDateTime;
public class DeliveryOrder {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String tenantId;
private String customerId;
private String deliveryNo;
private Long outboundOrderId;
private String outboundNo;
@@ -13,7 +13,7 @@ import java.time.LocalDateTime;
public class OutboundOrder {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String tenantId;
private String customerId;
private String outboundNo;
private Long dispatchOrderId;
private String dispatchNo;
@@ -13,7 +13,7 @@ import java.time.LocalDateTime;
public class OutboundPalletItem {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String tenantId;
private String customerId;
private Long outboundOrderId;
private Long warehousePalletId;
private String palletNo;
@@ -8,10 +8,10 @@ import org.apache.ibatis.annotations.Select;
@Mapper
public interface DeliveryOrderMapper extends BaseMapper<DeliveryOrder> {
@Select("SELECT * FROM ll_delivery_order WHERE tenant_id=#{tenantId} AND delivery_no=#{deliveryNo} LIMIT 1")
DeliveryOrder findByNo(@Param("tenantId") String tenantId, @Param("deliveryNo") String deliveryNo);
@Select("SELECT * FROM ll_delivery_order WHERE tenant_id=#{tenantId} AND idempotency_key=#{key} LIMIT 1")
DeliveryOrder findByIdempotency(@Param("tenantId") String tenantId, @Param("key") String key);
@Select("SELECT * FROM ll_delivery_order WHERE tenant_id=#{tenantId} AND outbound_order_id=#{outboundOrderId} LIMIT 1 FOR UPDATE")
DeliveryOrder lockByOutbound(@Param("tenantId") String tenantId, @Param("outboundOrderId") Long outboundOrderId);
@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);
}
@@ -8,10 +8,10 @@ import org.apache.ibatis.annotations.Select;
@Mapper
public interface OutboundOrderMapper extends BaseMapper<OutboundOrder> {
@Select("SELECT * FROM ll_outbound_order WHERE tenant_id=#{tenantId} AND outbound_no=#{outboundNo} LIMIT 1")
OutboundOrder findByNo(@Param("tenantId") String tenantId, @Param("outboundNo") String outboundNo);
@Select("SELECT * FROM ll_outbound_order WHERE tenant_id=#{tenantId} AND idempotency_key=#{key} LIMIT 1")
OutboundOrder findByIdempotency(@Param("tenantId") String tenantId, @Param("key") String key);
@Select("SELECT * FROM ll_outbound_order WHERE tenant_id=#{tenantId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1 FOR UPDATE")
OutboundOrder lockByDispatch(@Param("tenantId") String tenantId, @Param("dispatchOrderId") Long dispatchOrderId);
@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);
}
@@ -9,6 +9,6 @@ import java.util.List;
@Mapper
public interface OutboundPalletItemMapper extends BaseMapper<OutboundPalletItem> {
@Select("SELECT * FROM ll_outbound_pallet_item WHERE tenant_id=#{tenantId} AND outbound_order_id=#{outboundOrderId} ORDER BY id")
List<OutboundPalletItem> findByOutbound(@Param("tenantId") String tenantId, @Param("outboundOrderId") Long outboundOrderId);
@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);
}
@@ -49,22 +49,22 @@ public class DeliveryTransferService {
@Resource private LastlegOrderStatusHistoryMapper histories;
@Transactional
public DeliveryResponses.TransferResult transfer(String tenantId, String dispatchNo, String operatorId,
public DeliveryResponses.TransferResult transfer(String customerId, String dispatchNo, String operatorId,
DeliveryRequests.Transfer request) {
DispatchOrder found = dispatchOrders.findByDispatchNo(tenantId, dispatchNo);
DispatchOrder found = dispatchOrders.findByDispatchNo(customerId, dispatchNo);
if (found == null) throw notFound("Dispatch order not found");
DispatchOrder dispatch = dispatchOrders.lockById(tenantId, found.getId());
OutboundOrder replay = outboundOrders.findByIdempotency(tenantId, request.idempotencyKey().trim());
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(tenantId, replay.getId()), true);
return result(replay, requireDelivery(customerId, replay.getId()), true);
}
OutboundOrder existing = outboundOrders.lockByDispatch(tenantId, dispatch.getId());
if (existing != null) return result(existing, requireDelivery(tenantId, existing.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(tenantId, dispatch.getId());
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");
@@ -73,7 +73,7 @@ public class DeliveryTransferService {
LocalDateTime now = LocalDateTime.now();
OutboundOrder outbound = new OutboundOrder();
outbound.setId(IdWorker.getId()); outbound.setTenantId(tenantId);
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");
@@ -85,20 +85,20 @@ public class DeliveryTransferService {
} catch (DuplicateKeyException e) {
// The unique keys are the final concurrent guard. A client retry must observe
// the document created by the winning transaction rather than receive a false failure.
OutboundOrder concurrent = outboundOrders.findByIdempotency(tenantId, request.idempotencyKey().trim());
OutboundOrder concurrent = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim());
if (concurrent != null && dispatch.getId().equals(concurrent.getDispatchOrderId())) {
return result(concurrent, requireDelivery(tenantId, concurrent.getId()), true);
return result(concurrent, requireDelivery(customerId, concurrent.getId()), true);
}
concurrent = outboundOrders.lockByDispatch(tenantId, dispatch.getId());
concurrent = outboundOrders.lockByDispatch(customerId, dispatch.getId());
if (concurrent != null) {
return result(concurrent, requireDelivery(tenantId, concurrent.getId()), true);
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.setTenantId(tenantId); item.setOutboundOrderId(outbound.getId());
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());
@@ -109,7 +109,7 @@ public class DeliveryTransferService {
}
DeliveryOrder delivery = new DeliveryOrder();
delivery.setId(IdWorker.getId()); delivery.setTenantId(tenantId);
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());
@@ -117,13 +117,13 @@ public class DeliveryTransferService {
delivery.setPackageCount(outbound.getPackageCount()); delivery.setPalletCount(outbound.getPalletCount());
delivery.setIdempotencyKey(request.idempotencyKey().trim()); delivery.setCreatedBy(operatorId);
delivery.setCreatedAt(now); delivery.setUpdatedAt(now); deliveryOrders.insert(delivery);
bindTailOrders(tenantId, items, outbound.getOutboundNo(), delivery.getDeliveryNo(), request.remark(), now);
bindTailOrders(customerId, items, outbound.getOutboundNo(), delivery.getDeliveryNo(), request.remark(), now);
return result(outbound, delivery, false);
}
public PageResult<DeliveryResponses.OutboundRow> outboundPage(String tenantId, DeliveryRequests.Query query) {
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::getTenantId, tenantId)
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())
@@ -131,13 +131,13 @@ public class DeliveryTransferService {
return page(page, page.getRecords().stream().map(this::outboundRow).toList());
}
public DeliveryResponses.OutboundDetail outboundDetail(String tenantId, String outboundNo,
public DeliveryResponses.OutboundDetail outboundDetail(String customerId, String outboundNo,
DeliveryRequests.PalletQuery query) {
OutboundOrder outbound = outboundOrders.findByNo(tenantId, outboundNo);
OutboundOrder outbound = outboundOrders.findByNo(customerId, outboundNo);
if (outbound == null) throw notFound("Outbound order not found");
DeliveryOrder delivery = requireDelivery(tenantId, outbound.getId());
DeliveryOrder delivery = requireDelivery(customerId, outbound.getId());
Page<OutboundPalletItem> page = outboundPallets.selectPage(new Page<>(query.page(), query.size()),
new LambdaQueryWrapper<OutboundPalletItem>().eq(OutboundPalletItem::getTenantId, tenantId)
new LambdaQueryWrapper<OutboundPalletItem>().eq(OutboundPalletItem::getCustomerId, customerId)
.eq(OutboundPalletItem::getOutboundOrderId, outbound.getId())
.eq(StringUtils.hasText(query.destinationWarehouseCode()),
OutboundPalletItem::getDestinationWarehouseCode, query.destinationWarehouseCode())
@@ -146,9 +146,9 @@ public class DeliveryTransferService {
page(page, page.getRecords().stream().map(this::palletRow).toList()));
}
public PageResult<DeliveryResponses.DeliveryRow> deliveryPage(String tenantId, DeliveryRequests.Query query) {
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::getTenantId, tenantId)
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())
@@ -156,16 +156,16 @@ public class DeliveryTransferService {
return page(page, page.getRecords().stream().map(this::deliveryRow).toList());
}
public DeliveryResponses.OutboundDetail deliveryDetail(String tenantId, String deliveryNo,
public DeliveryResponses.OutboundDetail deliveryDetail(String customerId, String deliveryNo,
DeliveryRequests.PalletQuery query) {
DeliveryOrder delivery = deliveryOrders.findByNo(tenantId, deliveryNo);
DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo);
if (delivery == null) throw notFound("Delivery order not found");
OutboundOrder outbound = outboundOrders.selectById(delivery.getOutboundOrderId());
if (outbound == null || !tenantId.equals(outbound.getTenantId())) throw notFound("Outbound order not found");
return outboundDetail(tenantId, outbound.getOutboundNo(), query);
if (outbound == null || !customerId.equals(outbound.getCustomerId())) throw notFound("Outbound order not found");
return outboundDetail(customerId, outbound.getOutboundNo(), query);
}
private void bindTailOrders(String tenantId, List<DispatchItem> items, String outboundNo, String deliveryNo,
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);
@@ -182,7 +182,7 @@ public class DeliveryTransferService {
}
if (orderIds.isEmpty()) return;
for (LastlegOrder order : tailOrders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getTenantId, tenantId).in(LastlegOrder::getId, orderIds))) {
.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);
tailOrders.updateById(order);
@@ -194,8 +194,8 @@ public class DeliveryTransferService {
}
}
private DeliveryOrder requireDelivery(String tenantId, Long outboundId) {
DeliveryOrder value = deliveryOrders.lockByOutbound(tenantId, outboundId);
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;
}
@@ -40,20 +40,20 @@ public class DispatchController {
@Operation(summary = "List unlocked planned or received pallets for intelligent pre-dispatch")
public ApiResponse<List<DispatchPalletCandidate>> candidates(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String tenantId,
@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, tenantId), orderNo, fbaWarehouseCode, 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 tenantId,
@RequestParam(required = false) String customerId,
@Valid @RequestBody DispatchRequests.PreArrange request) {
String scopedTenant = scope(user, tenantId);
String scopedTenant = scope(user, customerId);
String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId());
return ApiResponse.success(service.preArrange(scopedTenant, operatorId, request));
}
@@ -62,37 +62,37 @@ public class DispatchController {
@Operation(summary = "Page dispatch orders")
public ApiResponse<PageResult<DispatchResponse>> page(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String tenantId,
@RequestParam(required = false) String customerId,
@Valid DispatchRequests.Query query) {
return ApiResponse.success(service.page(scope(user, tenantId), 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 tenantId,
@RequestParam(required = false) String customerId,
@PathVariable String dispatchNo) {
return ApiResponse.success(service.detail(scope(user, tenantId), 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 tenantId,
@RequestParam(required = false) String customerId,
@PathVariable String dispatchNo,
@Valid @RequestBody DispatchRequests.Update request) {
return ApiResponse.success(service.update(scope(user, tenantId), dispatchNo, 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 tenantId,
@RequestParam(required = false) String customerId,
@PathVariable String dispatchNo) {
service.cancel(scope(user, tenantId), dispatchNo);
service.cancel(scope(user, customerId), dispatchNo);
return ApiResponse.success(null);
}
@@ -100,24 +100,24 @@ public class DispatchController {
@Operation(summary = "Ship all PDA-picked pallets and mark the dispatch departed")
public ApiResponse<DispatchPdaResponses.Departed> depart(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String tenantId,
@RequestParam(required = false) String customerId,
@PathVariable String dispatchNo,
@Valid @RequestBody DispatchPdaRequests.Depart request) {
String scopedTenant = scope(user, tenantId);
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 requestedTenant) {
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 (requestedTenant == null || requestedTenant.isBlank() || "0".equals(requestedTenant)) {
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Platform request must specify target tenant");
}
return requestedTenant;
return requestedCustomer;
}
return String.valueOf(user.tenantId());
}
@@ -15,7 +15,7 @@ public class DispatchItem {
@TableId(type = IdType.INPUT)
private Long id;
private Long dispatchOrderId;
private String tenantId;
private String customerId;
private Long planId;
private Long planPalletId;
private Long warehousePalletId;
@@ -13,7 +13,7 @@ import java.time.LocalDateTime;
public class DispatchOrder {
@TableId(type = IdType.INPUT)
private Long id;
private String tenantId;
private String customerId;
private String dispatchNo;
private String overseasWarehouseId;
private String overseasWarehouseName;
@@ -13,7 +13,7 @@ import java.time.LocalDateTime;
public class DispatchPalletLock {
@TableId(type = IdType.INPUT)
private Long id;
private String tenantId;
private String customerId;
private String sourcePalletKey;
/** Equals sourcePalletKey while ACTIVE and is null after release; unique index enforces exclusivity. */
private String activeKey;
@@ -17,7 +17,7 @@ public interface DispatchCandidateMapper {
"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.tenant_id=#{tenantId} AND pos.pallet_id=p.warehouse_pallet_id),0) " +
"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 " +
@@ -25,22 +25,22 @@ public interface DispatchCandidateMapper {
"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.tenant_id=#{tenantId} " +
"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.tenant_id=#{tenantId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0)) " +
"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.tenant_id=ip.tenant_id AND d.status='ACTIVE' AND (" +
"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)))) " +
"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("tenantId") String tenantId,
List<DispatchPalletCandidate> available(@Param("customerId") String customerId,
@Param("orderNo") String orderNo,
@Param("fbaWarehouseCode") String fbaWarehouseCode,
@Param("sourceStage") String sourceStage);
@@ -53,7 +53,7 @@ public interface DispatchCandidateMapper {
"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.tenant_id=#{tenantId} AND pos.pallet_id=p.warehouse_pallet_id),0) " +
"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 " +
@@ -61,12 +61,12 @@ public interface DispatchCandidateMapper {
"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.tenant_id=#{tenantId} AND p.id IN " +
"WHERE ip.customer_id=#{customerId} AND p.id IN " +
"<foreach collection='planPalletIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"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("tenantId") String tenantId,
List<DispatchPalletCandidate> lockSelected(@Param("customerId") String customerId,
@Param("planPalletIds") List<Long> planPalletIds);
}
@@ -11,15 +11,15 @@ import java.util.List;
@Mapper
public interface DispatchItemMapper extends BaseMapper<DispatchItem> {
@Select("SELECT * FROM ll_dispatch_item WHERE tenant_id=#{tenantId} AND dispatch_order_id=#{dispatchOrderId} ORDER BY id")
List<DispatchItem> findByDispatchOrder(@Param("tenantId") String tenantId,
@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.tenant_id=i.tenant_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.tenant_id=#{tenantId} AND i.dispatch_order_id=#{dispatchOrderId} " +
"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("tenantId") String tenantId,
int bindReceivedPallets(@Param("customerId") String customerId,
@Param("dispatchOrderId") Long dispatchOrderId);
}
@@ -8,9 +8,9 @@ import org.apache.ibatis.annotations.Select;
@Mapper
public interface DispatchOrderMapper extends BaseMapper<DispatchOrder> {
@Select("SELECT * FROM ll_dispatch_order WHERE tenant_id=#{tenantId} AND dispatch_no=#{dispatchNo} LIMIT 1")
DispatchOrder findByDispatchNo(@Param("tenantId") String tenantId, @Param("dispatchNo") String dispatchNo);
@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 tenant_id=#{tenantId} AND id=#{id} LIMIT 1 FOR UPDATE")
DispatchOrder lockById(@Param("tenantId") String tenantId, @Param("id") Long id);
@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);
}
@@ -8,8 +8,8 @@ import org.apache.ibatis.annotations.Select;
@Mapper
public interface DispatchPalletLockMapper extends BaseMapper<DispatchPalletLock> {
@Select("SELECT * FROM ll_dispatch_pallet_lock WHERE tenant_id=#{tenantId} AND source_pallet_key=#{sourcePalletKey} " +
@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("tenantId") String tenantId,
DispatchPalletLock lockActive(@Param("customerId") String customerId,
@Param("sourcePalletKey") String sourcePalletKey);
}
@@ -20,38 +20,38 @@ public class DispatchPdaController {
@PostMapping("/push")
public ApiResponse<DispatchPdaResponses.Task> push(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo,
@RequestParam(required = false) String tenantId,
@RequestParam(required = false) String customerId,
@Valid @RequestBody DispatchPdaRequests.Push request) {
return ApiResponse.success(service.push(scope(user, tenantId), dispatchNo, request));
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 tenantId) {
return ApiResponse.success(service.task(scope(user, tenantId), 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 tenantId,
@RequestParam(required = false) String customerId,
@Valid @RequestBody DispatchPdaRequests.Pick request) {
return ApiResponse.success(service.pick(scope(user, tenantId), dispatchNo, itemId, operator(user), request));
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 tenantId,
@RequestParam(required = false) String customerId,
@Valid @RequestBody DispatchPdaRequests.CompletePicking request) {
return ApiResponse.success(service.completePicking(scope(user, tenantId), dispatchNo, request));
return ApiResponse.success(service.completePicking(scope(user, customerId), dispatchNo, request));
}
private String scope(AuthenticatedUser user, String requestedTenant) {
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 (requestedTenant == null || requestedTenant.isBlank() || "0".equals(requestedTenant)) {
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant");
}
return requestedTenant.trim();
return requestedCustomer.trim();
}
return String.valueOf(user.tenantId());
}
@@ -11,7 +11,7 @@ import java.time.LocalDateTime;
@TableName("ll_dispatch_pda_operation")
public class DispatchPdaOperation {
@TableId(type = IdType.INPUT) private Long id;
private String tenantId;
private String customerId;
private Long taskId;
private String operationType;
private String idempotencyKey;
@@ -11,7 +11,7 @@ import java.time.LocalDateTime;
@TableName("ll_dispatch_pda_pick")
public class DispatchPdaPick {
@TableId(type = IdType.INPUT) private Long id;
private String tenantId;
private String customerId;
private Long taskId;
private Long dispatchItemId;
private Long inventoryPositionId;
@@ -12,7 +12,7 @@ import java.time.LocalDateTime;
@TableName("ll_dispatch_pda_pick_order")
public class DispatchPdaPickOrder {
@TableId(type = IdType.INPUT) private Long id;
private String tenantId;
private String customerId;
private Long pickId;
private Long receiptId;
private Long orderId;
@@ -11,7 +11,7 @@ import java.time.LocalDateTime;
@TableName("ll_dispatch_pda_task")
public class DispatchPdaTask {
@TableId(type = IdType.INPUT) private Long id;
private String tenantId;
private String customerId;
private Long dispatchOrderId;
private String taskNo;
private String status;
@@ -6,6 +6,6 @@ import org.apache.ibatis.annotations.*;
@Mapper
public interface DispatchPdaOperationMapper extends BaseMapper<DispatchPdaOperation> {
@Select("SELECT * FROM ll_dispatch_pda_operation WHERE tenant_id=#{tenantId} AND idempotency_key=#{key} LIMIT 1")
DispatchPdaOperation findKey(@Param("tenantId") String tenantId, @Param("key") String key);
@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);
}
@@ -7,8 +7,8 @@ import java.util.List;
@Mapper
public interface DispatchPdaPickMapper extends BaseMapper<DispatchPdaPick> {
@Select("SELECT * FROM ll_dispatch_pda_pick WHERE tenant_id=#{tenantId} AND task_id=#{taskId} ORDER BY id")
List<DispatchPdaPick> listTask(@Param("tenantId") String tenantId, @Param("taskId") Long taskId);
@Select("SELECT COALESCE(SUM(packages),0) FROM ll_dispatch_pda_pick WHERE tenant_id=#{tenantId} AND task_id=#{taskId} AND dispatch_item_id=#{itemId}")
long totalItem(@Param("tenantId") String tenantId, @Param("taskId") Long taskId, @Param("itemId") Long itemId);
@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);
}
@@ -12,20 +12,20 @@ import java.util.List;
@Mapper
public interface DispatchPdaPickOrderMapper extends BaseMapper<DispatchPdaPickOrder> {
@Select("SELECT r.* FROM ll_inbound_stock_receipt r " +
"WHERE r.tenant_id=#{tenantId} AND r.inventory_position_id=#{positionId} " +
"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.tenant_id=r.tenant_id AND a.receipt_id=r.id) ORDER BY r.received_at,r.id")
List<InboundStockReceipt> availableReceipts(@Param("tenantId") String tenantId,
"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 tenant_id=#{tenantId} AND receipt_id=#{receiptId}")
long attributedPackages(@Param("tenantId") String tenantId, @Param("receiptId") Long receiptId);
"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 tenant_id=#{tenantId} AND receipt_id=#{receiptId}")
long attributedQuantity(@Param("tenantId") String tenantId, @Param("receiptId") Long receiptId);
"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 tenant_id=#{tenantId} AND pick_id=#{pickId} ORDER BY id")
List<DispatchPdaPickOrder> listPick(@Param("tenantId") String tenantId, @Param("pickId") Long pickId);
@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);
}
@@ -6,9 +6,9 @@ import org.apache.ibatis.annotations.*;
@Mapper
public interface DispatchPdaTaskMapper extends BaseMapper<DispatchPdaTask> {
@Select("SELECT * FROM ll_dispatch_pda_task WHERE tenant_id=#{tenantId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1 FOR UPDATE")
DispatchPdaTask lockByDispatch(@Param("tenantId") String tenantId, @Param("dispatchOrderId") Long dispatchOrderId);
@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 tenant_id=#{tenantId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1")
DispatchPdaTask findByDispatch(@Param("tenantId") String tenantId, @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);
}
@@ -45,25 +45,25 @@ public class DispatchPdaWorkflowService {
@Resource private ObjectMapper objectMapper;
@Transactional
public DispatchPdaResponses.Task push(String tenantId, String dispatchNo, DispatchPdaRequests.Push request) {
DispatchOrder order = requireOrder(tenantId, dispatchNo);
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(tenantId, "PUSH", request.idempotencyKey(), request);
DispatchPdaOperation replay = replay(customerId, "PUSH", request.idempotencyKey(), request);
if (replay != null) return read(replay, DispatchPdaResponses.Task.class);
items.bindReceivedPallets(tenantId, order.getId());
List<DispatchItem> dispatchItems = items.findByDispatchOrder(tenantId, order.getId());
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(tenantId, order.getId());
DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId());
LocalDateTime now = LocalDateTime.now();
if (task == null) {
task = new DispatchPdaTask();
task.setId(IdWorker.getId()); task.setTenantId(tenantId); task.setDispatchOrderId(order.getId());
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()));
@@ -71,91 +71,91 @@ public class DispatchPdaWorkflowService {
}
if ("PENDING_DEPARTURE".equals(order.getStatus())) { order.setStatus("PICKING"); order.setUpdatedAt(now); orders.updateById(order); }
if (!warehouseId.equals(task.getWarehouseId())) throw conflict("PDA task warehouse does not match selected warehouse");
DispatchPdaResponses.Task result = task(tenantId, order, task, dispatchItems);
save(tenantId, task.getId(), "PUSH", request.idempotencyKey(), request, result);
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 tenantId, String dispatchNo) {
DispatchOrder order = requireOrder(tenantId, dispatchNo);
DispatchPdaTask task = tasks.findByDispatch(tenantId, order.getId());
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(tenantId, order, task, items.findByDispatchOrder(tenantId, order.getId()));
return task(customerId, order, task, items.findByDispatchOrder(customerId, order.getId()));
}
@Transactional
public DispatchPdaResponses.Picked pick(String tenantId, String dispatchNo, long itemId,
public DispatchPdaResponses.Picked pick(String customerId, String dispatchNo, long itemId,
String operatorId, DispatchPdaRequests.Pick request) {
DispatchOrder order = requireOrder(tenantId, dispatchNo);
DispatchOrder order = requireOrder(customerId, dispatchNo);
requirePdaStatus(order);
requireVehicle(order);
String warehouseId = warehouse(request.warehouseId());
requireDispatchWarehouse(order, warehouseId);
DispatchPdaTask task = tasks.lockByDispatch(tenantId, order.getId());
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 || !tenantId.equals(item.getTenantId()) || !order.getId().equals(item.getDispatchOrderId())) {
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(tenantId, "PICK", request.idempotencyKey(), request);
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 || !tenantId.equals(position.getTenantId())
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(tenantId, task.getId(), itemId);
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(tenantId, request.positionId(),
InventoryCommandRequests.Move move = new InventoryCommandRequests.Move(customerId, request.positionId(),
request.packages(), order.getDispatchNo(), order.getDispatchNo(), null,
keyBase + ":ALLOCATE", request.remark());
inventory.allocate(tenantId, operatorId, move);
inventory.pick(tenantId, operatorId, new InventoryCommandRequests.Move(tenantId, request.positionId(),
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.setTenantId(tenantId); pick.setTaskId(task.getId());
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(tenantId, 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(tenantId, task.getId(), items.findByDispatchOrder(tenantId, order.getId()))) {
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(tenantId, task.getId(), "PICK", request.idempotencyKey(), request, result);
save(customerId, task.getId(), "PICK", request.idempotencyKey(), request, result);
return result;
}
@Transactional
public DispatchPdaResponses.PickingCompleted completePicking(String tenantId, String dispatchNo,
public DispatchPdaResponses.PickingCompleted completePicking(String customerId, String dispatchNo,
DispatchPdaRequests.CompletePicking request) {
DispatchOrder found = requireOrder(tenantId, dispatchNo);
DispatchOrder order = orders.lockById(tenantId, found.getId());
DispatchOrder found = requireOrder(customerId, dispatchNo);
DispatchOrder order = orders.lockById(customerId, found.getId());
if (order == null) throw notFound("Dispatch order not found");
DispatchPdaOperation replay = replay(tenantId, "COMPLETE_PICKING", request.idempotencyKey(), request);
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(tenantId, order.getId());
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(tenantId, order.getId());
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(tenantId, task.getId()).stream().mapToLong(p -> n(p.getPackages())).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();
@@ -166,47 +166,47 @@ public class DispatchPdaWorkflowService {
order.setUpdatedAt(now); orders.updateById(order);
DispatchPdaResponses.PickingCompleted result = new DispatchPdaResponses.PickingCompleted(order.getDispatchNo(),
order.getStatus(), planned, picked, planned - picked, partial, false);
save(tenantId, task.getId(), "COMPLETE_PICKING", request.idempotencyKey(), request, result);
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 tenantId, String dispatchNo, String operatorId,
public DispatchPdaResponses.Departed depart(String customerId, String dispatchNo, String operatorId,
DispatchPdaRequests.Depart request) {
DispatchOrder found = requireOrder(tenantId, dispatchNo);
DispatchOrder order = orders.lockById(tenantId, found.getId());
DispatchOrder found = requireOrder(customerId, dispatchNo);
DispatchOrder order = orders.lockById(customerId, found.getId());
if (order == null) throw notFound("Dispatch order not found");
DispatchPdaOperation replay = replay(tenantId, "DEPART", request.idempotencyKey(), request);
DispatchPdaOperation replay = replay(customerId, "DEPART", request.idempotencyKey(), request);
if (replay != null) return read(replay, DispatchPdaResponses.Departed.class).withReplay();
requireVehicle(order);
OutboundOrder outbound = outboundOrders.lockByDispatch(tenantId, order.getId());
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(tenantId, order.getId());
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(tenantId, order.getId());
List<DispatchItem> dispatchItems = items.findByDispatchOrder(customerId, order.getId());
boolean partial = "PARTIAL_READY_TO_DEPART".equals(order.getStatus());
if (!partial && !allPicked(tenantId, task.getId(), dispatchItems)) {
if (!partial && !allPicked(customerId, task.getId(), dispatchItems)) {
throw conflict("Every dispatch pallet must be fully picked before departure");
}
List<DispatchPdaPick> dispatchPicks = picks.listTask(tenantId, task.getId());
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(tenantId, operatorId, new InventoryCommandRequests.Move(tenantId,
inventory.ship(customerId, operatorId, new InventoryCommandRequests.Move(customerId,
pick.getInventoryPositionId(), n(pick.getPackages()),
order.getDispatchNo(), order.getDispatchNo(), null, key, request.remark()));
shipped += n(pick.getPackages());
}
LocalDateTime now = LocalDateTime.now();
pickAttribution.depart(tenantId, outbound.getOutboundNo(), order.getDispatchNo(), dispatchPicks, now);
pickAttribution.depart(customerId, outbound.getOutboundNo(), order.getDispatchNo(), dispatchPicks, now);
outbound.setStatus("OUTBOUNDED");
outbound.setUpdatedAt(now);
outboundOrders.updateById(outbound);
@@ -214,33 +214,33 @@ public class DispatchPdaWorkflowService {
order.setStatus(partial ? "PARTIAL_DEPARTED" : "DEPARTED");
order.setUpdatedAt(now);
orders.updateById(order);
releasePalletLocks(tenantId, order.getId(), now);
releasePalletLocks(customerId, order.getId(), now);
DispatchPdaResponses.Departed result = new DispatchPdaResponses.Departed(order.getDispatchNo(),
order.getStatus(), shipped, false);
save(tenantId, task.getId(), "DEPART", request.idempotencyKey(), request, result);
save(customerId, task.getId(), "DEPART", request.idempotencyKey(), request, result);
return result;
}
private DispatchPdaResponses.Task task(String tenantId, DispatchOrder order, DispatchPdaTask task, List<DispatchItem> source) {
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(tenantId, item.getWarehousePalletId());
: positions.listPallet(customerId, item.getWarehousePalletId());
values.add(new DispatchPdaResponses.Item(item.getId(), item.getPalletNo(), item.getDestinationWarehouseCode(),
item.getDestinationWarehouseName(), item.getFbaWarehouseCode(), item.getPackageCount(),
picks.totalItem(tenantId, task.getId(), item.getId()), palletPositions.stream().map(p ->
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 tenantId, Long taskId, List<DispatchItem> source) {
return source.stream().allMatch(item -> picks.totalItem(tenantId, taskId, item.getId()) == item.getPackageCount());
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 tenantId, Long dispatchOrderId, LocalDateTime now) {
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::getTenantId, tenantId)
.eq(DispatchPalletLock::getCustomerId, customerId)
.eq(DispatchPalletLock::getDispatchOrderId, dispatchOrderId)
.eq(DispatchPalletLock::getStatus, "ACTIVE"));
for (DispatchPalletLock lock : activeLocks) {
@@ -251,7 +251,7 @@ public class DispatchPdaWorkflowService {
palletLocks.updateById(lock);
}
}
private DispatchOrder requireOrder(String tenantId, String dispatchNo) { DispatchOrder order = orders.findByDispatchNo(tenantId, dispatchNo); if (order == null) throw notFound("Dispatch order not found"); return order; }
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");
@@ -263,8 +263,8 @@ public class DispatchPdaWorkflowService {
throw conflict("Selected warehouse does not match dispatch warehouse");
}
}
private DispatchPdaOperation replay(String tenantId, String type, String key, Object request) { DispatchPdaOperation op = operations.findKey(tenantId, required(key, "Idempotency key is required")); if (op == null) return null; if (!type.equals(op.getOperationType()) || !hash(request).equals(op.getRequestHash())) throw conflict("Idempotency key was reused with a different request"); if (!"COMPLETED".equals(op.getStatus())) throw conflict("PDA operation is still processing"); return op; }
private void save(String tenantId, Long taskId, String type, String key, Object request, Object result) { DispatchPdaOperation op = new DispatchPdaOperation(); op.setId(IdWorker.getId()); op.setTenantId(tenantId); op.setTaskId(taskId); op.setOperationType(type); op.setIdempotencyKey(required(key,"Idempotency key is required")); op.setRequestHash(hash(request)); op.setResultJson(json(result)); op.setStatus("COMPLETED"); op.setCreatedAt(LocalDateTime.now()); op.setUpdatedAt(op.getCreatedAt()); operations.insert(op); }
private 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); } }
@@ -26,18 +26,18 @@ public class DispatchPickOrderAttributionService {
@Resource private LastlegOrderStatusHistoryMapper history;
/** Call after the inventory position has been locked/moved and the PDA pick row has been inserted. */
public List<OrderPackages> attribute(String tenantId, DispatchPdaPick pick) {
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(tenantId,
for (InboundStockReceipt receipt : attributions.availableReceipts(customerId,
pick.getInventoryPositionId())) {
if (remainingPackages == 0) break;
long receiptPackages = value(receipt.getReceivedPackages())
- attributions.attributedPackages(tenantId, receipt.getId());
- attributions.attributedPackages(customerId, receipt.getId());
if (receiptPackages <= 0) continue;
long packages = Math.min(remainingPackages, receiptPackages);
long nextPackages = allocatedPackages + packages;
@@ -45,7 +45,7 @@ public class DispatchPickOrderAttributionService {
long quantity = nextQuantity - allocatedQuantity;
DispatchPdaPickOrder row = new DispatchPdaPickOrder();
row.setId(IdWorker.getId());
row.setTenantId(tenantId);
row.setCustomerId(customerId);
row.setPickId(pick.getId());
row.setReceiptId(receipt.getId());
row.setOrderId(receipt.getOrderId());
@@ -60,7 +60,7 @@ public class DispatchPickOrderAttributionService {
}
if (remainingPackages != 0) throw conflict("PDA pick cannot be attributed to inbound receipt orders");
for (Map.Entry<Long, Long> entry : pickedByOrder.entrySet()) {
if (orders.increasePicked(tenantId, entry.getKey(), entry.getValue()) != 1) {
if (orders.increasePicked(customerId, entry.getKey(), entry.getValue()) != 1) {
throw conflict("Picked packages exceed tail-leg order remaining packages");
}
}
@@ -68,17 +68,17 @@ public class DispatchPickOrderAttributionService {
}
/** Call once for each departing PDA pick; repeated dispatch departure is protected by its outer idempotency key. */
public List<OrderPackages> depart(String tenantId, String outboundNo, String dispatchNo, List<DispatchPdaPick> picks,
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(tenantId, pick.getId())) {
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(tenantId, entry.getKey());
if (before == null || orders.transferPickedToShipped(tenantId, entry.getKey(), entry.getValue(), outboundNo) != 1) {
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();
@@ -43,21 +43,21 @@ public class DispatchService {
@Resource private DispatchPalletLockMapper locks;
@Resource private DispatchCandidateMapper candidates;
public List<DispatchPalletCandidate> candidates(String tenantId, String orderNo,
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(tenantId, trimToNull(orderNo), trimToNull(fbaWarehouseCode), trimToNull(sourceStage));
return candidates.available(customerId, trimToNull(orderNo), trimToNull(fbaWarehouseCode), trimToNull(sourceStage));
}
@Transactional
public DispatchResponse preArrange(String tenantId, String operatorId, DispatchRequests.PreArrange request) {
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(tenantId, palletIds);
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");
}
@@ -71,9 +71,9 @@ public class DispatchService {
}
for (DispatchPalletCandidate candidate : selected) {
boolean planLocked = candidate.planPalletId() != null
&& locks.lockActive(tenantId, "PLAN:" + candidate.planPalletId()) != null;
&& locks.lockActive(customerId, "PLAN:" + candidate.planPalletId()) != null;
boolean receivedLocked = candidate.warehousePalletId() != null
&& locks.lockActive(tenantId, "RECEIVED:" + 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());
@@ -83,7 +83,7 @@ public class DispatchService {
LocalDateTime now = LocalDateTime.now();
DispatchOrder dispatch = new DispatchOrder();
dispatch.setId(IdWorker.getId());
dispatch.setTenantId(tenantId);
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());
@@ -106,7 +106,7 @@ public class DispatchService {
for (DispatchPalletCandidate candidate : selected) {
DispatchPalletLock lock = new DispatchPalletLock();
lock.setId(IdWorker.getId());
lock.setTenantId(tenantId);
lock.setCustomerId(customerId);
lock.setSourcePalletKey(candidate.sourcePalletKey());
lock.setActiveKey(candidate.sourcePalletKey());
lock.setPlanId(candidate.planId());
@@ -126,7 +126,7 @@ public class DispatchService {
DispatchItem item = new DispatchItem();
item.setId(IdWorker.getId());
item.setDispatchOrderId(dispatch.getId());
item.setTenantId(tenantId);
item.setCustomerId(customerId);
item.setPlanId(candidate.planId());
item.setPlanPalletId(candidate.planPalletId());
item.setWarehousePalletId(candidate.warehousePalletId());
@@ -147,12 +147,12 @@ public class DispatchService {
item.setCreatedAt(now);
items.insert(item);
}
return response(dispatch, items.findByDispatchOrder(tenantId, dispatch.getId()));
return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId()));
}
public PageResult<DispatchResponse> page(String tenantId, DispatchRequests.Query query) {
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::getTenantId, tenantId)
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())
@@ -160,18 +160,18 @@ public class DispatchService {
query.fbaAppointmentNo())
.orderByDesc(DispatchOrder::getCreatedAt));
List<DispatchResponse> records = page.getRecords().stream()
.map(order -> response(order, items.findByDispatchOrder(tenantId, order.getId()))).toList();
.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 tenantId, String dispatchNo) {
DispatchOrder dispatch = find(tenantId, dispatchNo);
return response(dispatch, items.findByDispatchOrder(tenantId, dispatch.getId()));
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 tenantId, String dispatchNo, DispatchRequests.Update request) {
DispatchOrder dispatch = orders.lockById(tenantId, find(tenantId, dispatchNo).getId());
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()));
@@ -193,12 +193,12 @@ public class DispatchService {
}
dispatch.setUpdatedAt(LocalDateTime.now());
orders.updateById(dispatch);
return response(dispatch, items.findByDispatchOrder(tenantId, dispatch.getId()));
return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId()));
}
@Transactional
public void cancel(String tenantId, String dispatchNo) {
DispatchOrder dispatch = orders.lockById(tenantId, find(tenantId, dispatchNo).getId());
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");
@@ -208,7 +208,7 @@ public class DispatchService {
dispatch.setUpdatedAt(now);
orders.updateById(dispatch);
List<DispatchPalletLock> activeLocks = locks.selectList(new LambdaQueryWrapper<DispatchPalletLock>()
.eq(DispatchPalletLock::getTenantId, tenantId)
.eq(DispatchPalletLock::getCustomerId, customerId)
.eq(DispatchPalletLock::getDispatchOrderId, dispatch.getId())
.eq(DispatchPalletLock::getStatus, "ACTIVE"));
for (DispatchPalletLock lock : activeLocks) {
@@ -220,8 +220,8 @@ public class DispatchService {
}
}
private DispatchOrder find(String tenantId, String dispatchNo) {
DispatchOrder dispatch = orders.findByDispatchNo(tenantId, dispatchNo);
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;
}
@@ -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,8 @@
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.*;
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);
}
@@ -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,6 @@
package com.baiyue.lastleg.dropship.mapper;
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
public interface DropshipSkuInventoryMapper extends BaseMapper<DropshipSkuInventory> {}
@@ -0,0 +1,130 @@
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.gateway.WarehouseResourceGateway;
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;
@Service
public class DropshipOrderService {
@Resource private LastlegOrderMapper orders;
@Resource private DropshipOrderProfileMapper profiles;
@Resource private DropshipSkuInventoryMapper skuInventory;
@Resource private DropshipCartonMapper cartons;
@Resource private DropshipCartonLedgerMapper ledger;
@Resource private WarehouseResourceGateway warehouse;
@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()) {
WarehouseResourceGateway.WarehousePalletReference pallet = warehouse.ensureDropshipPallet(
request.customerId(), request.warehouseId(), request.orderId() + ":" + inputPallet.palletNo().trim());
DropshipOrderProfile profile = profile(request, order, inputPallet, pallet, 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(pallet.palletId()); carton.setPalletNo(pallet.palletNo());
carton.setSkuCode(input.skuCode()); carton.setCartonMark(mark.trim()); carton.setCartonSequence(++sequence);
carton.setStatus("AVAILABLE"); 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,
WarehouseResourceGateway.WarehousePalletReference pallet, LocalDateTime now) {
DropshipOrderProfile profile = new DropshipOrderProfile();
profile.setId(IdWorker.getId()); profile.setCustomerId(request.customerId()); profile.setOrderId(order.getId());
profile.setWarehousePalletId(pallet.palletId()); profile.setPalletNo(pallet.palletNo());
profile.setSourcePalletNo(input.palletNo().trim());
profile.setStatus("RECEIVED"); profile.setVersion(0L);
profile.setIdempotencyKey(request.idempotencyKey() + ":" + input.palletNo().trim());
profile.setCreatedAt(now); profile.setUpdatedAt(now);
return profile;
}
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("STOCK_IN"); fact.setBeforePackages(0L); fact.setDeltaPackages(1L); fact.setAfterPackages(1L);
fact.setBusinessOrderNo(order.getOrderNo());
fact.setIdempotencyKey(request.idempotencyKey() + ":" + carton.getCartonMark());
fact.setOccurredAt(now); fact.setCreatedAt(now);
return fact;
}
private static ResponseStatusException conflict(String message) {
return new ResponseStatusException(HttpStatus.CONFLICT, message);
}
}
@@ -1,3 +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 tenantId; private String eventType; private Integer eventVersion; private String payloadHash; private String payloadJson; private String status; private LocalDateTime receivedAt; private LocalDateTime processedAt; private String lastError; }
@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; }
@@ -13,7 +13,7 @@ public class LastlegOrder {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String orderNo;
private String tenantId;
private String customerId;
private String orderType;
private String inboundCode;
private String sourceRegion;
@@ -28,15 +28,15 @@ public class InboundPlanController {
@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.tenantId()), request.orderId()));
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 tenantId) {
return ApiResponse.success(service.detailByPlanNo(scope(user, tenantId), planNo));
@RequestParam(required = false) String customerId) {
return ApiResponse.success(service.detailByPlanNo(scope(user, customerId), planNo));
}
@PostMapping("/fba-appointments/batch")
@@ -44,9 +44,9 @@ public class InboundPlanController {
public ApiResponse<FbaAppointmentResponse> batchFbaAppointment(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody BatchFbaAppointmentRequest request) {
String tenantId = scope(user, request.tenantId());
String customerId = scope(user, request.customerId());
String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId());
return ApiResponse.success(appointmentService.batchCreate(tenantId, operatorId, request));
return ApiResponse.success(appointmentService.batchCreate(customerId, operatorId, request));
}
@GetMapping("/fba-appointments/{appointmentNo}")
@@ -54,20 +54,20 @@ public class InboundPlanController {
public ApiResponse<FbaAppointmentResponse> fbaAppointmentDetail(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String appointmentNo,
@RequestParam(required = false) String tenantId) {
return ApiResponse.success(appointmentService.detail(scope(user, tenantId), appointmentNo));
@RequestParam(required = false) String customerId) {
return ApiResponse.success(appointmentService.detail(scope(user, customerId), appointmentNo));
}
private String scope(AuthenticatedUser user, String requestedTenant) {
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 (requestedTenant == null || requestedTenant.isBlank() || "0".equals(requestedTenant)) {
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Platform request must specify target tenant");
}
return requestedTenant;
return requestedCustomer;
}
return String.valueOf(user.tenantId());
}
@@ -9,7 +9,7 @@ import java.time.LocalDateTime;
import java.util.List;
public record BatchFbaAppointmentRequest(
String tenantId,
String customerId,
@NotEmpty @Size(max = 500) List<@NotNull Long> lineIds,
@NotBlank @Size(max = 64) String appointmentNo,
@NotNull LocalDateTime appointmentAt,
@@ -4,6 +4,6 @@ import jakarta.validation.constraints.NotNull;
public record ConvertInboundPlanRequest(
@NotNull Long orderId,
String tenantId
String customerId
) {
}
@@ -5,7 +5,7 @@ import java.util.List;
public record FbaAppointmentResponse(
Long id,
String tenantId,
String customerId,
String appointmentNo,
LocalDateTime appointmentAt,
String appointmentStatus,
@@ -7,7 +7,7 @@ import java.util.List;
public record InboundPlanResponse(
Long id,
String planNo,
String tenantId,
String customerId,
String containerNo,
String status,
int lineCount,
@@ -12,7 +12,7 @@ import java.time.LocalDateTime;
public class InboundFbaAppointment {
@TableId(type = IdType.INPUT)
private Long id;
private String tenantId;
private String customerId;
private String appointmentNo;
private LocalDateTime appointmentAt;
private String appointmentStatus;
@@ -14,7 +14,7 @@ public class InboundPlan {
@TableId(type = IdType.INPUT)
private Long id;
private String planNo;
private String tenantId;
private String customerId;
private String containerNo;
private String status;
private Integer lineCount;
@@ -13,10 +13,10 @@ public interface InboundFbaAppointmentQueryMapper {
"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.tenant_id=#{tenantId} AND l.id IN " +
"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("tenantId") String tenantId,
List<FbaAppointmentLineSnapshot> lockLines(@Param("customerId") String customerId,
@Param("lineIds") List<Long> lineIds);
}
@@ -8,6 +8,6 @@ import org.apache.ibatis.annotations.Select;
@Mapper
public interface InboundPlanMapper extends BaseMapper<InboundPlan> {
@Select("SELECT * FROM ll_inbound_plan WHERE tenant_id=#{tenantId} AND container_no=#{containerNo} LIMIT 1")
InboundPlan findByContainer(@Param("tenantId") String tenantId, @Param("containerNo") String containerNo);
@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);
}
@@ -12,10 +12,10 @@ import java.util.List;
@Mapper
public interface InboundPlanOrderMapper {
@Select("SELECT container_no FROM ll_order WHERE tenant_id=#{tenantId} AND id=#{orderId} LIMIT 1")
String findContainerNo(@Param("tenantId") String tenantId, @Param("orderId") Long orderId);
@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.tenant_id AS tenantId,o.container_no AS containerNo," +
@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," +
@@ -29,17 +29,17 @@ public interface InboundPlanOrderMapper {
"<> 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.tenant_id=#{tenantId} AND o.container_no=#{containerNo} " +
"GROUP BY o.id,o.tenant_id,o.container_no,o.inbound_code,o.current_status,o.inbound_order_no,o.volume_cbm,o.package_count,o.fba_warehouse_code," +
"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("tenantId") String tenantId,
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 tenant_id=#{tenantId} AND id=#{orderId}")
int markPlanned(@Param("tenantId") String tenantId, @Param("orderId") Long orderId,
"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);
@@ -4,7 +4,7 @@ import java.math.BigDecimal;
public record SourceOrderSnapshot(
Long orderId,
String tenantId,
String customerId,
String containerNo,
String inboundCode,
String currentStatus,
@@ -18,12 +18,12 @@ public record SourceOrderSnapshot(
int destinationCount,
int destinationMismatchCount
) {
public SourceOrderSnapshot(Long orderId, String tenantId, String containerNo, String inboundCode,
public SourceOrderSnapshot(Long orderId, String customerId, String containerNo, String inboundCode,
String currentStatus, String inboundOrderNo, BigDecimal volumeCbm,
int packageCount, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
int destinationCount, int destinationMismatchCount) {
this(orderId, tenantId, containerNo, inboundCode, currentStatus, inboundOrderNo, volumeCbm,
this(orderId, customerId, containerNo, inboundCode, currentStatus, inboundOrderNo, volumeCbm,
packageCount, null, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, destinationCount, destinationMismatchCount);
}
@@ -24,19 +24,19 @@ public class InboundPalletAssignmentController {
@PostMapping("/{planPalletId}/assign")
public ApiResponse<Void> assign(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo, @PathVariable long planPalletId,
@RequestParam(required = false) String tenantId,
@RequestParam(required = false) String customerId,
@Valid @RequestBody PdaRequests.Assign request) {
service.assign(scope(user, tenantId), planNo, planPalletId, request);
service.assign(scope(user, customerId), planNo, planPalletId, request);
return ApiResponse.success(null);
}
private String scope(AuthenticatedUser user, String requestedTenant) {
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 (requestedTenant == null || requestedTenant.isBlank() || "0".equals(requestedTenant)) {
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant");
}
return requestedTenant;
return requestedCustomer;
}
return String.valueOf(user.tenantId());
}
@@ -28,66 +28,66 @@ public class InboundPdaController {
@PostMapping("/push")
public ApiResponse<PdaResponses.Task> push(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@RequestParam(required = false) String tenantId,
@RequestParam(required = false) String customerId,
@Valid @RequestBody PdaRequests.Push request) {
return ApiResponse.success(service.push(scope(user, tenantId), planNo, request));
return ApiResponse.success(service.push(scope(user, customerId), planNo, request));
}
@GetMapping
public ApiResponse<PdaResponses.Task> task(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@RequestParam(required = false) String tenantId) {
return ApiResponse.success(service.task(scope(user, tenantId), planNo));
@RequestParam(required = false) String customerId) {
return ApiResponse.success(service.task(scope(user, customerId), planNo));
}
@PostMapping("/allocations/{allocationId}/count")
public ApiResponse<PdaResponses.Operation> count(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@PathVariable long allocationId,
@RequestParam(required = false) String tenantId,
@RequestParam(required = false) String customerId,
@Valid @RequestBody PdaRequests.PackageOperation request) {
return ApiResponse.success(service.count(scope(user, tenantId), planNo, allocationId, request));
return ApiResponse.success(service.count(scope(user, customerId), planNo, allocationId, request));
}
@PostMapping("/allocations/{allocationId}/pick")
public ApiResponse<PdaResponses.Operation> pick(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@PathVariable long allocationId,
@RequestParam(required = false) String tenantId,
@RequestParam(required = false) String customerId,
@Valid @RequestBody PdaRequests.PackageOperation request) {
return ApiResponse.success(service.pick(scope(user, tenantId), planNo, allocationId, request));
return ApiResponse.success(service.pick(scope(user, customerId), planNo, allocationId, request));
}
@GetMapping("/allocations/{allocationId}/merge-candidates")
public ApiResponse<List<PdaResponses.MergeCandidate>> candidates(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo,
@PathVariable long allocationId, @RequestParam(required = false) String tenantId) {
return ApiResponse.success(service.mergeCandidates(scope(user, tenantId), planNo, allocationId));
@PathVariable long allocationId, @RequestParam(required = false) String customerId) {
return ApiResponse.success(service.mergeCandidates(scope(user, customerId), planNo, allocationId));
}
@PostMapping("/allocations/{allocationId}/merge")
public ApiResponse<PdaResponses.Merge> merge(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@PathVariable long allocationId,
@RequestParam(required = false) String tenantId,
@RequestParam(required = false) String customerId,
@Valid @RequestBody PdaRequests.ConfirmMerge request) {
return ApiResponse.success(service.merge(scope(user, tenantId), planNo, allocationId, request));
return ApiResponse.success(service.merge(scope(user, customerId), planNo, allocationId, request));
}
@PostMapping("/complete")
public ApiResponse<PdaResponses.Task> complete(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@RequestParam(required = false) String tenantId) {
return ApiResponse.success(service.complete(scope(user, tenantId), planNo));
@RequestParam(required = false) String customerId) {
return ApiResponse.success(service.complete(scope(user, customerId), planNo));
}
private String scope(AuthenticatedUser user, String requestedTenant) {
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 (requestedTenant == null || requestedTenant.isBlank() || "0".equals(requestedTenant)) {
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant");
}
return requestedTenant;
return requestedCustomer;
}
return String.valueOf(user.tenantId());
}
@@ -13,7 +13,7 @@ import java.time.LocalDateTime;
public class InboundPalletMerge {
@TableId(type = IdType.INPUT)
private Long id;
private String tenantId;
private String customerId;
private Long taskId;
private String mergeGroupNo;
private Long sourcePalletId;
@@ -12,7 +12,7 @@ import java.time.LocalDateTime;
public class InboundPdaOperation {
@TableId(type = IdType.INPUT)
private Long id;
private String tenantId;
private String customerId;
private Long taskId;
private Long allocationId;
private String action;
@@ -13,7 +13,7 @@ public class InboundPdaTask {
@TableId(type = IdType.INPUT)
private Long id;
private Long planId;
private String tenantId;
private String customerId;
private String taskNo;
private String status;
private String operatorId;
@@ -13,7 +13,7 @@ import java.time.LocalDateTime;
public class InboundStockReceipt {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String tenantId;
private String customerId;
private Long planId;
private Long taskId;
private Long allocationId;
@@ -27,12 +27,24 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
}
@Override
public long reserve(String tenantId, long warehouseId, long palletId, long locationId,
public WarehousePalletReference ensureDropshipPallet(String customerId, long warehouseId, String businessId) {
WarehousePalletResponse response = client.post().uri("/internal/v1/warehouse-pallets/ensure-dropship")
.header("X-Internal-Service-Token", internalToken)
.body(Map.of("customerId", Long.parseLong(customerId), "warehouseId", warehouseId, "businessId", businessId))
.retrieve().onStatus(HttpStatusCode::isError, (request, result) -> {
throw new IllegalStateException("Warehouse rejected dropship pallet: HTTP " + result.getStatusCode());
}).body(WarehousePalletResponse.class);
if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned an empty pallet response");
return new WarehousePalletReference(response.data().id(), response.data().palletCode(), response.data().locationId());
}
@Override
public long reserve(String customerId, long warehouseId, long palletId, long locationId,
String businessType, String businessId, String reservationKey) {
ReserveEnvelope response = client.post()
.uri("/internal/v1/warehouse-resource-locks")
.header("X-Internal-Service-Token", internalToken)
.body(Map.of("tenantId", Long.parseLong(tenantId), "warehouseId", warehouseId,
.body(Map.of("customerId", Long.parseLong(customerId), "warehouseId", warehouseId,
"palletId", palletId, "locationId", locationId, "businessType", businessType,
"businessId", businessId, "reservationKey", reservationKey))
.retrieve()
@@ -45,11 +57,11 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
}
@Override
public void scrap(String tenantId, long resourceLockId) {
public void scrap(String customerId, long resourceLockId) {
client.post()
.uri("/internal/v1/warehouse-resource-locks/{lockId}/scrap", resourceLockId)
.header("X-Internal-Service-Token", internalToken)
.body(Map.of("tenantId", Long.parseLong(tenantId)))
.body(Map.of("customerId", Long.parseLong(customerId)))
.retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> {
throw new IllegalStateException("Warehouse rejected pallet scrap: HTTP " + response.getStatusCode());
@@ -58,11 +70,11 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
}
@Override
public void scrapPallet(String tenantId, long palletId) {
public void scrapPallet(String customerId, long palletId) {
client.post()
.uri("/internal/v1/warehouse-resource-locks/pallets/{palletId}/scrap", palletId)
.header("X-Internal-Service-Token", internalToken)
.body(Map.of("tenantId", Long.parseLong(tenantId)))
.body(Map.of("customerId", Long.parseLong(customerId)))
.retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> {
throw new IllegalStateException("Warehouse rejected pallet scrap: HTTP " + response.getStatusCode());
@@ -70,6 +82,8 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
.toBodilessEntity();
}
private record WarehousePalletResponse(WarehousePalletData data) {}
private record WarehousePalletData(long id, String palletCode, long locationId) {}
private record ReserveEnvelope(ReserveData data) {}
private record ReserveData(long lockId) {}
}
@@ -1,8 +1,11 @@
package com.baiyue.lastleg.inboundplan.pda.gateway;
public interface WarehouseResourceGateway {
long reserve(String tenantId, long warehouseId, long palletId, long locationId,
WarehousePalletReference ensureDropshipPallet(String customerId, long warehouseId, String businessId);
long reserve(String customerId, long warehouseId, long palletId, long locationId,
String businessType, String businessId, String reservationKey);
void scrap(String tenantId, long resourceLockId);
void scrapPallet(String tenantId, long palletId);
void scrap(String customerId, long resourceLockId);
void scrapPallet(String customerId, long palletId);
record WarehousePalletReference(long palletId, String palletNo, long locationId) {}
}
@@ -8,6 +8,6 @@ import org.apache.ibatis.annotations.Select;
@Mapper
public interface InboundPdaOperationMapper extends BaseMapper<InboundPdaOperation> {
@Select("SELECT * FROM ll_inbound_pda_operation WHERE tenant_id=#{tenantId} AND idempotency_key=#{key} LIMIT 1")
InboundPdaOperation findByKey(@Param("tenantId") String tenantId, @Param("key") String key);
@Select("SELECT * FROM ll_inbound_pda_operation WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1")
InboundPdaOperation findByKey(@Param("customerId") String customerId, @Param("key") String key);
}
@@ -8,9 +8,9 @@ import org.apache.ibatis.annotations.Select;
@Mapper
public interface InboundPdaTaskMapper extends BaseMapper<InboundPdaTask> {
@Select("SELECT * FROM ll_inbound_pda_task WHERE tenant_id=#{tenantId} AND plan_id=#{planId} LIMIT 1 FOR UPDATE")
InboundPdaTask lockByPlan(@Param("tenantId") String tenantId, @Param("planId") Long planId);
@Select("SELECT * FROM ll_inbound_pda_task WHERE customer_id=#{customerId} AND plan_id=#{planId} LIMIT 1 FOR UPDATE")
InboundPdaTask lockByPlan(@Param("customerId") String customerId, @Param("planId") Long planId);
@Select("SELECT * FROM ll_inbound_pda_task WHERE tenant_id=#{tenantId} AND plan_id=#{planId} LIMIT 1")
InboundPdaTask findByPlan(@Param("tenantId") String tenantId, @Param("planId") Long planId);
@Select("SELECT * FROM ll_inbound_pda_task WHERE customer_id=#{customerId} AND plan_id=#{planId} LIMIT 1")
InboundPdaTask findByPlan(@Param("customerId") String customerId, @Param("planId") Long planId);
}
@@ -8,7 +8,7 @@ import org.apache.ibatis.annotations.Select;
import java.util.List;
public interface InboundStockReceiptMapper extends BaseMapper<InboundStockReceipt> {
@Select("SELECT * FROM ll_inbound_stock_receipt WHERE tenant_id=#{tenantId} AND task_id=#{taskId} " +
@Select("SELECT * FROM ll_inbound_stock_receipt WHERE customer_id=#{customerId} AND task_id=#{taskId} " +
"ORDER BY allocation_id")
List<InboundStockReceipt> listTask(@Param("tenantId") String tenantId, @Param("taskId") Long taskId);
List<InboundStockReceipt> listTask(@Param("customerId") String customerId, @Param("taskId") Long taskId);
}
@@ -30,14 +30,14 @@ public class PdaWorkflowRepository {
@Resource private InboundPdaOperationMapper operations;
@Resource private InboundPalletMergeMapper merges;
public InboundPlan lockPlan(String tenantId, String planNo) {
public InboundPlan lockPlan(String customerId, String planNo) {
return plans.selectOne(new LambdaQueryWrapper<InboundPlan>()
.eq(InboundPlan::getTenantId, tenantId).eq(InboundPlan::getPlanNo, planNo).last("FOR UPDATE"));
.eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getPlanNo, planNo).last("FOR UPDATE"));
}
public InboundPdaTask lockTask(String tenantId, Long planId) { return tasks.lockByPlan(tenantId, planId); }
public InboundPdaTask task(String tenantId, Long planId) { return tasks.findByPlan(tenantId, planId); }
public InboundPdaOperation operation(String tenantId, String key) { return operations.findByKey(tenantId, key); }
public InboundPdaTask lockTask(String customerId, Long planId) { return tasks.lockByPlan(customerId, planId); }
public InboundPdaTask task(String customerId, Long planId) { return tasks.findByPlan(customerId, planId); }
public InboundPdaOperation operation(String customerId, String key) { return operations.findByKey(customerId, key); }
public InboundPlanPalletAllocation lockAllocation(Long planId, Long allocationId) {
return allocations.selectOne(new LambdaQueryWrapper<InboundPlanPalletAllocation>()
@@ -55,22 +55,22 @@ public class InboundPdaWorkflowService {
}
@Transactional
public PdaResponses.Task push(String tenantId, String planNo, PdaRequests.Push request) {
InboundPlan plan = requirePlan(tenantId, planNo);
InboundPdaOperation replay = replay(tenantId, "PUSH", request.idempotencyKey(), request);
public PdaResponses.Task push(String customerId, String planNo, PdaRequests.Push request) {
InboundPlan plan = requirePlan(customerId, planNo);
InboundPdaOperation replay = replay(customerId, "PUSH", request.idempotencyKey(), request);
if (replay != null) return read(replay, PdaResponses.Task.class);
List<InboundPlanPallet> pallets = repository.pallets(plan.getId());
if (pallets.isEmpty() || pallets.stream().anyMatch(this::isNotAssigned)) {
throw conflict("Every planned pallet must have an assigned and locked warehouse pallet/location");
}
InboundPdaTask task = repository.lockTask(tenantId, plan.getId());
InboundPdaTask task = repository.lockTask(customerId, plan.getId());
LocalDateTime now = LocalDateTime.now();
if (task == null) {
task = new InboundPdaTask();
task.setId(IdWorker.getId());
task.setPlanId(plan.getId());
task.setTenantId(tenantId);
task.setCustomerId(customerId);
task.setTaskNo("PDA" + task.getId());
task.setStatus("PDA_PENDING");
task.setOperatorId(request.operatorId());
@@ -85,13 +85,13 @@ public class InboundPdaWorkflowService {
plan.setUpdatedAt(now);
repository.update(plan);
PdaResponses.Task result = task(task, planNo);
saveOperation(tenantId, task.getId(), null, "PUSH", request.idempotencyKey(), request, result);
saveOperation(customerId, task.getId(), null, "PUSH", request.idempotencyKey(), request, result);
return result;
}
@Transactional
public void assign(String tenantId, String planNo, long planPalletId, PdaRequests.Assign request) {
InboundPlan plan = requirePlan(tenantId, planNo);
public void assign(String customerId, String planNo, long planPalletId, PdaRequests.Assign request) {
InboundPlan plan = requirePlan(customerId, planNo);
InboundPlanPallet pallet = requirePallet(plan.getId(), planPalletId);
validateDestinationWarehouse(pallet, request.warehouseId());
if (pallet.getWarehouseResourceLockId() != null) {
@@ -100,7 +100,7 @@ public class InboundPdaWorkflowService {
throw conflict("Planned pallet is already assigned to another warehouse resource");
}
String businessId = planNo + ":" + planPalletId;
long lockId = warehouse.reserve(tenantId, request.warehouseId(), request.palletId(), request.locationId(),
long lockId = warehouse.reserve(customerId, request.warehouseId(), request.palletId(), request.locationId(),
"INBOUND_PLAN_PALLET", businessId, request.idempotencyKey());
pallet.setWarehousePalletId(request.palletId());
pallet.setWarehouseLocationId(request.locationId());
@@ -117,20 +117,20 @@ public class InboundPdaWorkflowService {
}
@Transactional
public PdaResponses.Task task(String tenantId, String planNo) {
InboundPlan plan = requirePlan(tenantId, planNo);
InboundPdaTask task = repository.task(tenantId, plan.getId());
public PdaResponses.Task task(String customerId, String planNo) {
InboundPlan plan = requirePlan(customerId, planNo);
InboundPdaTask task = repository.task(customerId, plan.getId());
if (task == null) throw notFound("PDA task not found");
return task(task, planNo);
}
@Transactional
public PdaResponses.Operation count(String tenantId, String planNo, long allocationId,
public PdaResponses.Operation count(String customerId, String planNo, long allocationId,
PdaRequests.PackageOperation request) {
InboundPlan plan = requirePlan(tenantId, planNo);
InboundPdaOperation replay = replay(tenantId, "COUNT", request.idempotencyKey(), request);
InboundPlan plan = requirePlan(customerId, planNo);
InboundPdaOperation replay = replay(customerId, "COUNT", request.idempotencyKey(), request);
if (replay != null) return read(replay, PdaResponses.Operation.class);
InboundPdaTask task = requireActiveTask(tenantId, plan);
InboundPdaTask task = requireActiveTask(customerId, plan);
InboundPlanPalletAllocation allocation = requireAllocation(plan.getId(), allocationId);
int picked = value(allocation.getPickedPackageCount());
if (request.packageCount() < picked) {
@@ -147,17 +147,17 @@ public class InboundPdaWorkflowService {
startPicking(task, plan, request.operatorId(), request.operatorName());
InboundPlanLine line = requireLine(plan.getId(), allocation.getLineId());
PdaResponses.Operation result = operation("COUNTED", allocation, line, List.of(), null, null, null);
saveOperation(tenantId, task.getId(), allocationId, "COUNT", request.idempotencyKey(), request, result);
saveOperation(customerId, task.getId(), allocationId, "COUNT", request.idempotencyKey(), request, result);
return result;
}
@Transactional
public PdaResponses.Operation pick(String tenantId, String planNo, long allocationId,
public PdaResponses.Operation pick(String customerId, String planNo, long allocationId,
PdaRequests.PackageOperation request) {
InboundPlan plan = requirePlan(tenantId, planNo);
InboundPdaOperation replay = replay(tenantId, "PICK", request.idempotencyKey(), request);
InboundPlan plan = requirePlan(customerId, planNo);
InboundPdaOperation replay = replay(customerId, "PICK", request.idempotencyKey(), request);
if (replay != null) return read(replay, PdaResponses.Operation.class);
InboundPdaTask task = requireActiveTask(tenantId, plan);
InboundPdaTask task = requireActiveTask(customerId, plan);
InboundPlanPalletAllocation allocation = requireAllocation(plan.getId(), allocationId);
InboundPlanLine line = requireLine(plan.getId(), allocation.getLineId());
int picked = value(allocation.getPickedPackageCount());
@@ -169,7 +169,7 @@ public class InboundPdaWorkflowService {
List<PdaResponses.MergeCandidate> candidates = candidates(plan, allocation, mergeCount);
PdaResponses.Operation result = operation("MERGE_CONFIRMATION_REQUIRED", allocation, line,
candidates, plannedRemaining, request.packageCount(), mergeCount);
saveOperation(tenantId, task.getId(), allocationId, "PICK", request.idempotencyKey(), request, result);
saveOperation(customerId, task.getId(), allocationId, "PICK", request.idempotencyKey(), request, result);
return result;
}
if (request.packageCount() > countedRemaining) {
@@ -189,25 +189,25 @@ public class InboundPdaWorkflowService {
repository.update(pallet);
startPicking(task, plan, request.operatorId(), request.operatorName());
PdaResponses.Operation result = operation("PICKED", allocation, line, List.of(), null, null, null);
saveOperation(tenantId, task.getId(), allocationId, "PICK", request.idempotencyKey(), request, result);
saveOperation(customerId, task.getId(), allocationId, "PICK", request.idempotencyKey(), request, result);
return result;
}
@Transactional
public List<PdaResponses.MergeCandidate> mergeCandidates(String tenantId, String planNo,
public List<PdaResponses.MergeCandidate> mergeCandidates(String customerId, String planNo,
long allocationId) {
InboundPlan plan = requirePlan(tenantId, planNo);
InboundPlan plan = requirePlan(customerId, planNo);
InboundPlanPalletAllocation allocation = requireAllocation(plan.getId(), allocationId);
return candidates(plan, allocation, 1);
}
@Transactional
public PdaResponses.Merge merge(String tenantId, String planNo, long targetAllocationId,
public PdaResponses.Merge merge(String customerId, String planNo, long targetAllocationId,
PdaRequests.ConfirmMerge request) {
InboundPlan plan = requirePlan(tenantId, planNo);
InboundPdaOperation replay = replay(tenantId, "MERGE", request.idempotencyKey(), request);
InboundPlan plan = requirePlan(customerId, planNo);
InboundPdaOperation replay = replay(customerId, "MERGE", request.idempotencyKey(), request);
if (replay != null) return read(replay, PdaResponses.Merge.class);
InboundPdaTask task = requireActiveTask(tenantId, plan);
InboundPdaTask task = requireActiveTask(customerId, plan);
if (request.sourceAllocationId() == targetAllocationId) throw conflict("Source and target allocations differ");
InboundPlanPalletAllocation target = requireAllocation(plan.getId(), targetAllocationId);
InboundPlanPalletAllocation source = requireAllocation(plan.getId(), request.sourceAllocationId());
@@ -240,7 +240,7 @@ public class InboundPdaWorkflowService {
if (sourcePallet.getWarehouseResourceLockId() == null) {
throw conflict("Source pallet has no warehouse resource lock");
}
warehouse.scrap(tenantId, sourcePallet.getWarehouseResourceLockId());
warehouse.scrap(customerId, sourcePallet.getWarehouseResourceLockId());
sourcePallet.setAssignmentStatus("SCRAPPED");
sourcePallet.setWarehouseLocationId(null);
sourcePallet.setWarehouseResourceLockId(null);
@@ -251,7 +251,7 @@ public class InboundPdaWorkflowService {
String groupNo = "PMG" + IdWorker.getId();
InboundPalletMerge merge = new InboundPalletMerge();
merge.setId(IdWorker.getId());
merge.setTenantId(tenantId);
merge.setCustomerId(customerId);
merge.setTaskId(task.getId());
merge.setMergeGroupNo(groupNo);
merge.setSourcePalletId(sourcePallet.getId());
@@ -270,14 +270,14 @@ public class InboundPdaWorkflowService {
repository.insert(merge);
PdaResponses.Merge result = new PdaResponses.Merge("MERGED", groupNo, sourceLine.getOrderId(),
targetLine.getOrderId(), request.packageCount(), movedVolume, scrapped);
saveOperation(tenantId, task.getId(), targetAllocationId, "MERGE", request.idempotencyKey(), request, result);
saveOperation(customerId, task.getId(), targetAllocationId, "MERGE", request.idempotencyKey(), request, result);
return result;
}
@Transactional
public PdaResponses.Task complete(String tenantId, String planNo) {
InboundPlan plan = requirePlan(tenantId, planNo);
InboundPdaTask task = repository.lockTask(tenantId, plan.getId());
public PdaResponses.Task complete(String customerId, String planNo) {
InboundPlan plan = requirePlan(customerId, planNo);
InboundPdaTask task = repository.lockTask(customerId, plan.getId());
if (task == null) throw conflict("Push the inbound plan to PDA before operating");
if ("COMPLETED".equals(task.getStatus())) return task(task, planNo);
Map<Long, Integer> pickedByLine = new HashMap<>();
@@ -293,7 +293,7 @@ public class InboundPdaWorkflowService {
}
}
LocalDateTime now = LocalDateTime.now();
stockReceipts.stockIn(tenantId, plan, task, lines, allocations, repository.pallets(plan.getId()), now);
stockReceipts.stockIn(customerId, plan, task, lines, allocations, repository.pallets(plan.getId()), now);
task.setStatus("COMPLETED");
task.setCompletedAt(now);
task.setUpdatedAt(now);
@@ -419,14 +419,14 @@ public class InboundPdaWorkflowService {
|| !("ASSIGNED".equals(pallet.getAssignmentStatus()) || "LOCKED".equals(pallet.getAssignmentStatus()));
}
private InboundPlan requirePlan(String tenantId, String planNo) {
InboundPlan plan = repository.lockPlan(tenantId, planNo);
private InboundPlan requirePlan(String customerId, String planNo) {
InboundPlan plan = repository.lockPlan(customerId, planNo);
if (plan == null) throw notFound("Inbound plan not found");
return plan;
}
private InboundPdaTask requireActiveTask(String tenantId, InboundPlan plan) {
InboundPdaTask task = repository.lockTask(tenantId, plan.getId());
private InboundPdaTask requireActiveTask(String customerId, InboundPlan plan) {
InboundPdaTask task = repository.lockTask(customerId, plan.getId());
if (task == null) throw conflict("Push the inbound plan to PDA before operating");
if ("COMPLETED".equals(task.getStatus())) throw conflict("PDA task is already completed");
return task;
@@ -470,8 +470,8 @@ public class InboundPdaWorkflowService {
return volume(line.getVolumeCbm()).divide(BigDecimal.valueOf(required), 8, RoundingMode.HALF_UP);
}
private InboundPdaOperation replay(String tenantId, String action, String key, Object request) {
InboundPdaOperation operation = repository.operation(tenantId, key);
private InboundPdaOperation replay(String customerId, String action, String key, Object request) {
InboundPdaOperation operation = repository.operation(customerId, key);
if (operation == null) return null;
if (!action.equals(operation.getAction()) || !hash(request).equals(operation.getRequestHash())) {
throw conflict("Idempotency key was already used with a different request");
@@ -479,11 +479,11 @@ public class InboundPdaWorkflowService {
return operation;
}
private void saveOperation(String tenantId, Long taskId, Long allocationId, String action,
private void saveOperation(String customerId, Long taskId, Long allocationId, String action,
String key, Object request, Object result) {
InboundPdaOperation operation = new InboundPdaOperation();
operation.setId(IdWorker.getId());
operation.setTenantId(tenantId);
operation.setCustomerId(customerId);
operation.setTaskId(taskId);
operation.setAllocationId(allocationId);
operation.setAction(action);
@@ -32,14 +32,14 @@ public class InboundStockReceiptService {
@Resource private InboundStockReceiptMapper receipts;
@Resource private InventoryCommandService inventory;
public void stockIn(String tenantId, InboundPlan plan, InboundPdaTask task,
public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task,
List<InboundPlanLine> lines, List<InboundPlanPalletAllocation> allocations,
List<InboundPlanPallet> pallets, LocalDateTime receivedAt) {
if (!receipts.listTask(tenantId, task.getId()).isEmpty()) return;
if (!receipts.listTask(customerId, task.getId()).isEmpty()) return;
Map<Long, LastlegOrder> orderById = orders.selectBatchIds(lines.stream()
.map(InboundPlanLine::getOrderId).collect(Collectors.toSet())).stream()
.filter(order -> tenantId.equals(order.getTenantId()))
.filter(order -> customerId.equals(order.getCustomerId()))
.collect(Collectors.toMap(LastlegOrder::getId, order -> order));
Map<Long, InboundPlanPallet> palletById = pallets.stream()
.collect(Collectors.toMap(InboundPlanPallet::getId, pallet -> pallet));
@@ -59,13 +59,13 @@ public class InboundStockReceiptService {
throw conflict("PDA picked packages must equal the inbound order package total");
}
for (InboundPlanPalletAllocation allocation : lineAllocations) {
persist(tenantId, plan, task, line, allocation,
persist(customerId, plan, task, line, allocation,
requirePallet(palletById, allocation), order, receivedAt);
}
}
}
private void persist(String tenantId, InboundPlan plan, InboundPdaTask task, InboundPlanLine line,
private void persist(String customerId, InboundPlan plan, InboundPdaTask task, InboundPlanLine line,
InboundPlanPalletAllocation allocation, InboundPlanPallet pallet,
LastlegOrder order, LocalDateTime receivedAt) {
long packages = value(allocation.getPickedPackageCount());
@@ -77,7 +77,7 @@ public class InboundStockReceiptService {
String operationKey = "PDA_STOCK_IN:" + task.getId() + ":" + allocation.getId();
String overseasWarehouseId = required(order.getOverseasWarehouseId(),
"Actual overseas warehouse is required for stock-in");
InventoryCommandResponse result = inventory.stockIn(tenantId, task.getOperatorId(),
InventoryCommandResponse result = inventory.stockIn(customerId, task.getOperatorId(),
new InventoryCommandRequests.StockIn(null, order.getCustomerName(), overseasWarehouseId,
null, order.getOverseasWarehouseName(), line.getDestinationWarehouseId(), destinationCode,
line.getDestinationWarehouseName(), order.getId(), plan.getPlanNo(), line.getInboundCode(),
@@ -88,7 +88,7 @@ public class InboundStockReceiptService {
InboundStockReceipt receipt = new InboundStockReceipt();
receipt.setId(IdWorker.getId());
receipt.setTenantId(tenantId);
receipt.setCustomerId(customerId);
receipt.setPlanId(plan.getId());
receipt.setTaskId(task.getId());
receipt.setAllocationId(allocation.getId());
@@ -32,7 +32,7 @@ public class InboundFbaAppointmentService {
@Resource private InboundFbaAppointmentQueryMapper queryMapper;
@Transactional
public FbaAppointmentResponse batchCreate(String tenantId, String operatorId,
public FbaAppointmentResponse batchCreate(String customerId, String operatorId,
BatchFbaAppointmentRequest request) {
List<Long> lineIds = List.copyOf(request.lineIds());
if (new HashSet<>(lineIds).size() != lineIds.size()) {
@@ -42,12 +42,12 @@ public class InboundFbaAppointmentService {
String status = normalizeStatus(request.appointmentStatus());
String remark = trimToNull(request.appointmentRemark());
InboundFbaAppointment existing = find(tenantId, appointmentNo);
InboundFbaAppointment existing = find(customerId, appointmentNo);
if (existing != null) {
return verifyReplay(existing, lineIds, request.appointmentAt(), status, remark);
}
List<FbaAppointmentLineSnapshot> lines = queryMapper.lockLines(tenantId, lineIds);
List<FbaAppointmentLineSnapshot> lines = queryMapper.lockLines(customerId, lineIds);
if (lines.size() != lineIds.size()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
"One or more selected inbound lines do not exist in this tenant");
@@ -55,7 +55,7 @@ public class InboundFbaAppointmentService {
String fbaWarehouseCode = commonFbaWarehouse(lines);
// The row locks serialize concurrent submissions selecting the same inbound lines.
existing = find(tenantId, appointmentNo);
existing = find(customerId, appointmentNo);
if (existing != null) {
return verifyReplay(existing, lineIds, request.appointmentAt(), status, remark);
}
@@ -63,7 +63,7 @@ public class InboundFbaAppointmentService {
LocalDateTime now = LocalDateTime.now();
InboundFbaAppointment appointment = new InboundFbaAppointment();
appointment.setId(IdWorker.getId());
appointment.setTenantId(tenantId);
appointment.setCustomerId(customerId);
appointment.setAppointmentNo(appointmentNo);
appointment.setAppointmentAt(request.appointmentAt());
appointment.setAppointmentStatus(status);
@@ -76,7 +76,7 @@ public class InboundFbaAppointmentService {
try {
appointmentMapper.insert(appointment);
} catch (DuplicateKeyException exception) {
InboundFbaAppointment concurrent = find(tenantId, appointmentNo);
InboundFbaAppointment concurrent = find(customerId, appointmentNo);
if (concurrent != null) {
return verifyReplay(concurrent, lineIds, request.appointmentAt(), status, remark);
}
@@ -99,8 +99,8 @@ public class InboundFbaAppointmentService {
}
@Transactional(readOnly = true)
public FbaAppointmentResponse detail(String tenantId, String appointmentNo) {
InboundFbaAppointment appointment = find(tenantId,
public FbaAppointmentResponse detail(String customerId, String appointmentNo) {
InboundFbaAppointment appointment = find(customerId,
requiredTrimmed(appointmentNo, "Appointment number is required"));
if (appointment == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "FBA appointment not found");
@@ -134,9 +134,9 @@ public class InboundFbaAppointmentService {
return warehouses.get(0);
}
private InboundFbaAppointment find(String tenantId, String appointmentNo) {
private InboundFbaAppointment find(String customerId, String appointmentNo) {
return appointmentMapper.selectOne(new LambdaQueryWrapper<InboundFbaAppointment>()
.eq(InboundFbaAppointment::getTenantId, tenantId)
.eq(InboundFbaAppointment::getCustomerId, customerId)
.eq(InboundFbaAppointment::getAppointmentNo, appointmentNo));
}
@@ -148,7 +148,7 @@ public class InboundFbaAppointmentService {
private FbaAppointmentResponse response(InboundFbaAppointment appointment,
List<InboundFbaAppointmentItem> items) {
return new FbaAppointmentResponse(appointment.getId(), appointment.getTenantId(),
return new FbaAppointmentResponse(appointment.getId(), appointment.getCustomerId(),
appointment.getAppointmentNo(), appointment.getAppointmentAt(), appointment.getAppointmentStatus(),
appointment.getAppointmentRemark(), appointment.getFbaWarehouseCode(), appointment.getSelectedCount(),
appointment.getCreatedBy(), appointment.getCreatedAt(), appointment.getUpdatedAt(),
@@ -36,20 +36,20 @@ public class InboundPlanService {
@Resource private InboundPlanOrderMapper orderMapper;
@Transactional
public InboundPlanResponse convert(String tenantId, Long selectedOrderId) {
String containerNo = trimToNull(orderMapper.findContainerNo(tenantId, selectedOrderId));
public InboundPlanResponse convert(String customerId, Long selectedOrderId) {
String containerNo = trimToNull(orderMapper.findContainerNo(customerId, selectedOrderId));
if (containerNo == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Order does not exist in this tenant or container number is empty");
}
List<SourceOrderSnapshot> orders = orderMapper.lockContainerOrders(tenantId, containerNo);
List<SourceOrderSnapshot> orders = orderMapper.lockContainerOrders(customerId, containerNo);
if (orders.isEmpty()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Container orders not found");
}
InboundPlan existing = planMapper.findByContainer(tenantId, containerNo);
InboundPlan existing = planMapper.findByContainer(customerId, containerNo);
if (existing != null) {
return detail(tenantId, existing);
return detail(customerId, existing);
}
validateOrders(orders);
@@ -57,7 +57,7 @@ public class InboundPlanService {
InboundPlan plan = new InboundPlan();
plan.setId(IdWorker.getId());
plan.setPlanNo("LIP" + plan.getId());
plan.setTenantId(tenantId);
plan.setCustomerId(customerId);
plan.setContainerNo(containerNo);
plan.setStatus("PENDING_ASSIGNMENT");
plan.setLineCount(orders.size());
@@ -88,23 +88,23 @@ public class InboundPlanService {
InboundPlanLine line = lineByOrder.get(order.orderId());
line.setPlannedPalletCount(count);
lineMapper.updateById(line);
if (orderMapper.markPlanned(tenantId, order.orderId(), plan.getPlanNo(), count, now) != 1) {
if (orderMapper.markPlanned(customerId, order.orderId(), plan.getPlanNo(), count, now) != 1) {
throw new IllegalStateException("Failed to update order " + order.orderId());
}
orderMapper.insertHistory(order.orderId(), order.currentStatus(),
"Container converted to inbound plan " + plan.getPlanNo(), now);
}
return detail(tenantId, plan);
return detail(customerId, plan);
}
@Transactional(readOnly = true)
public InboundPlanResponse detailByPlanNo(String tenantId, String planNo) {
public InboundPlanResponse detailByPlanNo(String customerId, String planNo) {
InboundPlan plan = planMapper.selectOne(new LambdaQueryWrapper<InboundPlan>()
.eq(InboundPlan::getTenantId, tenantId).eq(InboundPlan::getPlanNo, planNo));
.eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getPlanNo, planNo));
if (plan == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found");
}
return detail(tenantId, plan);
return detail(customerId, plan);
}
private Map<Long, InboundPlanLine> createLines(InboundPlan plan, List<SourceOrderSnapshot> orders) {
@@ -195,8 +195,8 @@ public class InboundPlanService {
}
}
private InboundPlanResponse detail(String tenantId, InboundPlan plan) {
if (!tenantId.equals(plan.getTenantId())) {
private InboundPlanResponse detail(String customerId, InboundPlan plan) {
if (!customerId.equals(plan.getCustomerId())) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found");
}
List<InboundPlanLine> lines = lineMapper.selectList(new LambdaQueryWrapper<InboundPlanLine>()
@@ -207,7 +207,7 @@ public class InboundPlanService {
new LambdaQueryWrapper<InboundPlanPalletAllocation>()
.eq(InboundPlanPalletAllocation::getPlanId, plan.getId())
.orderByAsc(InboundPlanPalletAllocation::getId));
return new InboundPlanResponse(plan.getId(), plan.getPlanNo(), plan.getTenantId(), plan.getContainerNo(),
return new InboundPlanResponse(plan.getId(), plan.getPlanNo(), plan.getCustomerId(), plan.getContainerNo(),
plan.getStatus(), plan.getLineCount(), plan.getPalletCount(), plan.getTotalVolumeCbm(),
lines.stream().map(line -> new InboundPlanResponse.Line(line.getId(), line.getOrderId(), line.getInboundCode(), line.getFbaWarehouseCode(),
line.getDestinationWarehouseId(), line.getDestinationWarehouseCode(), line.getDestinationWarehouseName(),
@@ -27,66 +27,66 @@ public class InventoryCommandController {
@Operation(summary = "Put inbound-order packages into an overseas warehouse pallet")
public ApiResponse<InventoryCommandResponse> stockIn(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.StockIn request) {
return ApiResponse.success(service.stockIn(scope(user, request.tenantId()), operator(user), request));
return ApiResponse.success(service.stockIn(scope(user, request.customerId()), operator(user), request));
}
@PostMapping("/hold")
@Operation(summary = "Move available inbound-order packages to hold")
public ApiResponse<InventoryCommandResponse> hold(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) {
return ApiResponse.success(service.hold(scope(user, request.tenantId()), operator(user), request));
return ApiResponse.success(service.hold(scope(user, request.customerId()), operator(user), request));
}
@PostMapping("/release")
@Operation(summary = "Release held inbound-order packages")
public ApiResponse<InventoryCommandResponse> release(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) {
return ApiResponse.success(service.release(scope(user, request.tenantId()), operator(user), request));
return ApiResponse.success(service.release(scope(user, request.customerId()), operator(user), request));
}
@PostMapping("/allocate")
@Operation(summary = "Allocate available inbound-order packages to an outbound order")
public ApiResponse<InventoryCommandResponse> allocate(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) {
return ApiResponse.success(service.allocate(scope(user, request.tenantId()), operator(user), request));
return ApiResponse.success(service.allocate(scope(user, request.customerId()), operator(user), request));
}
@PostMapping("/unallocate")
@Operation(summary = "Cancel an outbound allocation")
public ApiResponse<InventoryCommandResponse> unallocate(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) {
return ApiResponse.success(service.unallocate(scope(user, request.tenantId()), operator(user), request));
return ApiResponse.success(service.unallocate(scope(user, request.customerId()), operator(user), request));
}
@PostMapping("/pick")
@Operation(summary = "Confirm picked inbound-order packages")
public ApiResponse<InventoryCommandResponse> pick(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) {
return ApiResponse.success(service.pick(scope(user, request.tenantId()), operator(user), request));
return ApiResponse.success(service.pick(scope(user, request.customerId()), operator(user), request));
}
@PostMapping("/ship")
@Operation(summary = "Ship picked inbound-order packages and report whether the pallet is empty")
public ApiResponse<InventoryCommandResponse> ship(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) {
return ApiResponse.success(service.ship(scope(user, request.tenantId()), operator(user), request));
return ApiResponse.success(service.ship(scope(user, request.customerId()), operator(user), request));
}
@PostMapping("/adjust")
@Operation(summary = "Adjust inbound-order package inventory after a physical count")
public ApiResponse<InventoryCommandResponse> adjust(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Adjustment request) {
return ApiResponse.success(service.adjust(scope(user, request.tenantId()), operator(user), request));
return ApiResponse.success(service.adjust(scope(user, request.customerId()), operator(user), request));
}
private String scope(AuthenticatedUser user, String requestedTenant) {
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 (requestedTenant == null || requestedTenant.isBlank() || "0".equals(requestedTenant)) {
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Platform request must specify target tenant");
}
return requestedTenant.trim();
return requestedCustomer.trim();
}
return String.valueOf(user.tenantId());
}
@@ -10,7 +10,7 @@ public final class InventoryCommandRequests {
}
public record StockIn(
String tenantId,
String customerId,
String customerName,
@NotBlank String overseasWarehouseId,
String overseasWarehouseCode,
@@ -35,7 +35,7 @@ public final class InventoryCommandRequests {
}
public record Move(
String tenantId,
String customerId,
@NotNull Long positionId,
@Positive long packages,
String businessOrderNo,
@@ -47,7 +47,7 @@ public final class InventoryCommandRequests {
}
public record Adjustment(
String tenantId,
String customerId,
@NotNull Long positionId,
long packageDelta,
@NotBlank String reason,
@@ -8,15 +8,15 @@ import org.apache.ibatis.annotations.Select;
@Mapper
public interface InventoryCommandMapper {
@Select("SELECT * FROM ll_inventory_position WHERE tenant_id=#{tenantId} AND id=#{positionId} LIMIT 1 FOR UPDATE")
InventoryPosition lockPosition(@Param("tenantId") String tenantId,
@Select("SELECT * FROM ll_inventory_position WHERE customer_id=#{customerId} AND id=#{positionId} LIMIT 1 FOR UPDATE")
InventoryPosition lockPosition(@Param("customerId") String customerId,
@Param("positionId") Long positionId);
@Select("SELECT * FROM ll_inventory_balance WHERE tenant_id=#{tenantId} AND id=#{balanceId} LIMIT 1 FOR UPDATE")
InventoryBalance lockBalance(@Param("tenantId") String tenantId,
@Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} AND id=#{balanceId} LIMIT 1 FOR UPDATE")
InventoryBalance lockBalance(@Param("customerId") String customerId,
@Param("balanceId") Long balanceId);
@Select("SELECT COALESCE(SUM(available_packages+hold_packages+allocated_packages+picked_packages+receiving_packages),0) " +
"FROM ll_inventory_position WHERE tenant_id=#{tenantId} AND pallet_id=#{palletId}")
long palletPackages(@Param("tenantId") String tenantId, @Param("palletId") Long palletId);
"FROM ll_inventory_position WHERE customer_id=#{customerId} AND pallet_id=#{palletId}")
long palletPackages(@Param("customerId") String customerId, @Param("palletId") Long palletId);
}
@@ -39,11 +39,11 @@ public class InventoryCommandService {
@Resource private ObjectMapper objectMapper;
@Transactional
public InventoryCommandResponse stockIn(String tenantId, String operatorId,
public InventoryCommandResponse stockIn(String customerId, String operatorId,
InventoryCommandRequests.StockIn request) {
return execute(tenantId, operatorId, "STOCK_IN", request.idempotencyKey(), request, operation -> {
InventoryBalance balance = balance(tenantId, request);
InventoryPosition position = position(tenantId, balance, request);
return execute(customerId, operatorId, "STOCK_IN", request.idempotencyKey(), request, operation -> {
InventoryBalance balance = balance(customerId, request);
InventoryPosition position = position(customerId, balance, request);
long before = value(position.getAvailablePackages());
position.setAvailablePackages(before + request.packages());
position.setStatus("OCCUPIED");
@@ -54,7 +54,7 @@ public class InventoryCommandService {
balance.setAvailablePackages(value(balance.getAvailablePackages()) + request.packages());
touch(balance);
balances.updateById(balance);
ledger(tenantId, operatorId, operation.getId(), balance, position, "STOCK_IN",
ledger(customerId, operatorId, operation.getId(), balance, position, "STOCK_IN",
"OUTSIDE", "AVAILABLE", before, request.packages(), before + request.packages(),
request.businessOrderNo(), request.inboundOrderNo(), null, request.sourceEventId(),
request.idempotencyKey(), request.remark());
@@ -63,34 +63,34 @@ public class InventoryCommandService {
}
@Transactional
public InventoryCommandResponse hold(String tenantId, String operatorId, InventoryCommandRequests.Move request) {
return move(tenantId, operatorId, "HOLD", Bucket.AVAILABLE, Bucket.HOLD, request);
public InventoryCommandResponse hold(String customerId, String operatorId, InventoryCommandRequests.Move request) {
return move(customerId, operatorId, "HOLD", Bucket.AVAILABLE, Bucket.HOLD, request);
}
@Transactional
public InventoryCommandResponse release(String tenantId, String operatorId, InventoryCommandRequests.Move request) {
return move(tenantId, operatorId, "RELEASE", Bucket.HOLD, Bucket.AVAILABLE, request);
public InventoryCommandResponse release(String customerId, String operatorId, InventoryCommandRequests.Move request) {
return move(customerId, operatorId, "RELEASE", Bucket.HOLD, Bucket.AVAILABLE, request);
}
@Transactional
public InventoryCommandResponse allocate(String tenantId, String operatorId, InventoryCommandRequests.Move request) {
return move(tenantId, operatorId, "ALLOCATE", Bucket.AVAILABLE, Bucket.ALLOCATED, request);
public InventoryCommandResponse allocate(String customerId, String operatorId, InventoryCommandRequests.Move request) {
return move(customerId, operatorId, "ALLOCATE", Bucket.AVAILABLE, Bucket.ALLOCATED, request);
}
@Transactional
public InventoryCommandResponse unallocate(String tenantId, String operatorId, InventoryCommandRequests.Move request) {
return move(tenantId, operatorId, "UNALLOCATE", Bucket.ALLOCATED, Bucket.AVAILABLE, request);
public InventoryCommandResponse unallocate(String customerId, String operatorId, InventoryCommandRequests.Move request) {
return move(customerId, operatorId, "UNALLOCATE", Bucket.ALLOCATED, Bucket.AVAILABLE, request);
}
@Transactional
public InventoryCommandResponse pick(String tenantId, String operatorId, InventoryCommandRequests.Move request) {
return move(tenantId, operatorId, "PICK", Bucket.ALLOCATED, Bucket.PICKED, request);
public InventoryCommandResponse pick(String customerId, String operatorId, InventoryCommandRequests.Move request) {
return move(customerId, operatorId, "PICK", Bucket.ALLOCATED, Bucket.PICKED, request);
}
@Transactional
public InventoryCommandResponse ship(String tenantId, String operatorId, InventoryCommandRequests.Move request) {
return execute(tenantId, operatorId, "SHIP", request.idempotencyKey(), request, operation -> {
Locked locked = locked(tenantId, request.positionId());
public InventoryCommandResponse ship(String customerId, String operatorId, InventoryCommandRequests.Move request) {
return execute(customerId, operatorId, "SHIP", request.idempotencyKey(), request, operation -> {
Locked locked = locked(customerId, request.positionId());
InventoryPosition position = locked.position();
InventoryBalance balance = locked.balance();
requireAtLeast(value(position.getPickedPackages()), request.packages(), "picked");
@@ -105,9 +105,9 @@ public class InventoryCommandService {
balance.setPickedPackages(value(balance.getPickedPackages()) - request.packages());
touch(balance);
balances.updateById(balance);
boolean palletEmpty = commands.palletPackages(tenantId, position.getPalletId()) == 0;
if (palletEmpty) warehouseGateway.scrapPallet(tenantId, position.getPalletId());
ledger(tenantId, operatorId, operation.getId(), balance, position, "SHIP",
boolean palletEmpty = commands.palletPackages(customerId, position.getPalletId()) == 0;
if (palletEmpty) warehouseGateway.scrapPallet(customerId, position.getPalletId());
ledger(customerId, operatorId, operation.getId(), balance, position, "SHIP",
"PICKED", "SHIPPED", before, -request.packages(), before - request.packages(),
request.businessOrderNo(), null, request.outboundOrderNo(), request.sourceEventId(),
request.idempotencyKey(), request.remark());
@@ -116,13 +116,13 @@ public class InventoryCommandService {
}
@Transactional
public InventoryCommandResponse adjust(String tenantId, String operatorId,
public InventoryCommandResponse adjust(String customerId, String operatorId,
InventoryCommandRequests.Adjustment request) {
if (request.packageDelta() == 0) {
throw badRequest("Adjustment package delta cannot be zero");
}
return execute(tenantId, operatorId, "ADJUST", request.idempotencyKey(), request, operation -> {
Locked locked = locked(tenantId, request.positionId());
return execute(customerId, operatorId, "ADJUST", request.idempotencyKey(), request, operation -> {
Locked locked = locked(customerId, request.positionId());
InventoryPosition position = locked.position();
InventoryBalance balance = locked.balance();
long before = value(position.getAvailablePackages());
@@ -139,7 +139,7 @@ public class InventoryCommandService {
balance.setAvailablePackages(balanceAfter);
touch(balance);
balances.updateById(balance);
ledger(tenantId, operatorId, operation.getId(), balance, position, "ADJUST",
ledger(customerId, operatorId, operation.getId(), balance, position, "ADJUST",
"AVAILABLE", "AVAILABLE", before, request.packageDelta(), after,
request.businessOrderNo(), null, null, null, request.idempotencyKey(),
request.reason() + remarkSuffix(request.remark()));
@@ -147,10 +147,10 @@ public class InventoryCommandService {
});
}
private InventoryCommandResponse move(String tenantId, String operatorId, String operationType,
private InventoryCommandResponse move(String customerId, String operatorId, String operationType,
Bucket from, Bucket to, InventoryCommandRequests.Move request) {
return execute(tenantId, operatorId, operationType, request.idempotencyKey(), request, operation -> {
Locked locked = locked(tenantId, request.positionId());
return execute(customerId, operatorId, operationType, request.idempotencyKey(), request, operation -> {
Locked locked = locked(customerId, request.positionId());
InventoryPosition position = locked.position();
InventoryBalance balance = locked.balance();
requireAtLeast(packages(position, from), request.packages(), from.name().toLowerCase());
@@ -166,7 +166,7 @@ public class InventoryCommandService {
packages(balance, to, packages(balance, to) + request.packages());
touch(balance);
balances.updateById(balance);
ledger(tenantId, operatorId, operation.getId(), balance, position, operationType,
ledger(customerId, operatorId, operation.getId(), balance, position, operationType,
from.name(), to.name(), beforeTarget, request.packages(), beforeTarget + request.packages(),
request.businessOrderNo(), null, request.outboundOrderNo(), request.sourceEventId(),
request.idempotencyKey(), request.remark());
@@ -174,13 +174,13 @@ public class InventoryCommandService {
});
}
private InventoryBalance balance(String tenantId, InventoryCommandRequests.StockIn request) {
InventoryBalance balance = balances.lockDimension(tenantId, request.overseasWarehouseId(),
private InventoryBalance balance(String customerId, InventoryCommandRequests.StockIn request) {
InventoryBalance balance = balances.lockDimension(customerId, request.overseasWarehouseId(),
request.destinationWarehouseCode(), request.inboundOrderId(), request.inboundCode());
if (balance != null) return balance;
balance = new InventoryBalance();
balance.setId(IdWorker.getId());
balance.setTenantId(tenantId);
balance.setCustomerId(customerId);
balance.setCustomerName(trim(request.customerName()));
balance.setOverseasWarehouseId(request.overseasWarehouseId().trim());
balance.setOverseasWarehouseCode(trim(request.overseasWarehouseCode()));
@@ -199,21 +199,21 @@ public class InventoryCommandService {
balances.insert(balance);
return balance;
} catch (DuplicateKeyException exception) {
InventoryBalance concurrent = balances.lockDimension(tenantId, request.overseasWarehouseId(),
InventoryBalance concurrent = balances.lockDimension(customerId, request.overseasWarehouseId(),
request.destinationWarehouseCode(), request.inboundOrderId(), request.inboundCode());
if (concurrent != null) return concurrent;
throw exception;
}
}
private InventoryPosition position(String tenantId, InventoryBalance balance,
private InventoryPosition position(String customerId, InventoryBalance balance,
InventoryCommandRequests.StockIn request) {
String batchNo = trim(request.batchNo()) == null ? "DEFAULT" : request.batchNo().trim();
InventoryPosition position = positions.lockPosition(tenantId, balance.getId(), request.palletId(), batchNo);
InventoryPosition position = positions.lockPosition(customerId, balance.getId(), request.palletId(), batchNo);
if (position != null) return position;
position = new InventoryPosition();
position.setId(IdWorker.getId());
position.setTenantId(tenantId);
position.setCustomerId(customerId);
position.setBalanceId(balance.getId());
position.setOverseasWarehouseId(balance.getOverseasWarehouseId());
position.setDestinationWarehouseCode(balance.getDestinationWarehouseCode());
@@ -237,30 +237,30 @@ public class InventoryCommandService {
positions.insert(position);
return position;
} catch (DuplicateKeyException exception) {
InventoryPosition concurrent = positions.lockPosition(tenantId, balance.getId(), request.palletId(), batchNo);
InventoryPosition concurrent = positions.lockPosition(customerId, balance.getId(), request.palletId(), batchNo);
if (concurrent != null) return concurrent;
throw exception;
}
}
private Locked locked(String tenantId, Long positionId) {
InventoryPosition position = commands.lockPosition(tenantId, positionId);
private Locked locked(String customerId, Long positionId) {
InventoryPosition position = commands.lockPosition(customerId, positionId);
if (position == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inventory position not found");
InventoryBalance balance = commands.lockBalance(tenantId, position.getBalanceId());
InventoryBalance balance = commands.lockBalance(customerId, position.getBalanceId());
if (balance == null) throw new IllegalStateException("Inventory position balance is missing");
return new Locked(balance, position);
}
private InventoryCommandResponse execute(String tenantId, String operatorId, String operationType,
private InventoryCommandResponse execute(String customerId, String operatorId, String operationType,
String key, Object request, Work work) {
String normalizedKey = required(key, "Idempotency key is required");
String requestHash = hash(request);
InventoryOperation existing = operations.findIdempotency(tenantId, normalizedKey);
InventoryOperation existing = operations.findIdempotency(customerId, normalizedKey);
if (existing != null) return replay(existing, operationType, requestHash);
InventoryOperation operation = new InventoryOperation();
operation.setId(IdWorker.getId());
operation.setTenantId(tenantId);
operation.setCustomerId(customerId);
operation.setIdempotencyKey(normalizedKey);
operation.setOperationType(operationType);
operation.setRequestHash(requestHash);
@@ -270,7 +270,7 @@ public class InventoryCommandService {
try {
operations.insert(operation);
} catch (DuplicateKeyException exception) {
InventoryOperation concurrent = operations.findIdempotency(tenantId, normalizedKey);
InventoryOperation concurrent = operations.findIdempotency(customerId, normalizedKey);
if (concurrent != null) return replay(concurrent, operationType, requestHash);
throw exception;
}
@@ -297,14 +297,14 @@ public class InventoryCommandService {
}
}
private void ledger(String tenantId, String operatorId, Long operationId, InventoryBalance balance,
private void ledger(String customerId, String operatorId, Long operationId, InventoryBalance balance,
InventoryPosition position, String businessType, String from, String to,
long beforePackages, long deltaPackages, long afterPackages,
String businessOrderNo, String inboundOrderNo, String outboundOrderNo,
String sourceEventId, String idempotencyKey, String remark) {
InventoryLedger ledger = new InventoryLedger();
ledger.setId(IdWorker.getId());
ledger.setTenantId(tenantId);
ledger.setCustomerId(customerId);
ledger.setBalanceId(balance.getId());
ledger.setPositionId(position.getId());
ledger.setOverseasWarehouseId(balance.getOverseasWarehouseId());
@@ -13,7 +13,7 @@ import java.time.LocalDateTime;
public class InventoryBalance {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String tenantId;
private String customerId;
private String customerName;
private String overseasWarehouseId;
private String overseasWarehouseCode;
@@ -13,7 +13,7 @@ import java.time.LocalDateTime;
public class InventoryLedger {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String tenantId;
private String customerId;
private Long balanceId;
private Long positionId;
private String overseasWarehouseId;
@@ -22,6 +22,9 @@ public class InventoryLedger {
private Long palletId;
private String palletNo;
private String batchNo;
private String skuCode;
private String cartonMark;
private Long cartonPackages;
private String warehouseLocationCode;
private String businessType;
private Long businessOrderId;
@@ -13,7 +13,7 @@ import java.time.LocalDateTime;
public class InventoryOperation {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String tenantId;
private String customerId;
private String idempotencyKey;
private String operationType;
private String requestHash;
@@ -13,7 +13,7 @@ import java.time.LocalDateTime;
public class InventoryPosition {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String tenantId;
private String customerId;
private Long balanceId;
private String overseasWarehouseId;
private String destinationWarehouseCode;
@@ -9,32 +9,32 @@ import org.apache.ibatis.annotations.Select;
import java.util.List;
public interface InventoryBalanceMapper extends BaseMapper<InventoryBalance> {
@Select("SELECT * FROM ll_inventory_balance WHERE tenant_id=#{tenantId} " +
@Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} " +
"AND overseas_warehouse_id=#{overseasWarehouseId} " +
"AND destination_warehouse_code=#{destinationWarehouseCode} " +
"AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode} LIMIT 1")
InventoryBalance findDimension(@Param("tenantId") String tenantId,
InventoryBalance findDimension(@Param("customerId") String customerId,
@Param("overseasWarehouseId") String overseasWarehouseId,
@Param("destinationWarehouseCode") String destinationWarehouseCode,
@Param("inboundOrderId") Long inboundOrderId,
@Param("inboundCode") String inboundCode);
@Select("SELECT * FROM ll_inventory_balance WHERE tenant_id=#{tenantId} " +
@Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} " +
"AND overseas_warehouse_id=#{overseasWarehouseId} " +
"AND destination_warehouse_code=#{destinationWarehouseCode} " +
"AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode} LIMIT 1 FOR UPDATE")
InventoryBalance lockDimension(@Param("tenantId") String tenantId,
InventoryBalance lockDimension(@Param("customerId") String customerId,
@Param("overseasWarehouseId") String overseasWarehouseId,
@Param("destinationWarehouseCode") String destinationWarehouseCode,
@Param("inboundOrderId") Long inboundOrderId,
@Param("inboundCode") String inboundCode);
@Select("<script>SELECT * FROM ll_inventory_balance WHERE tenant_id=#{tenantId}" +
@Select("<script>SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId}" +
"<if test='overseasWarehouseId != null and overseasWarehouseId != \"\"'> AND overseas_warehouse_id=#{overseasWarehouseId}</if>" +
"<if test='destinationWarehouseCode != null and destinationWarehouseCode != \"\"'> AND destination_warehouse_code=#{destinationWarehouseCode}</if>" +
"<if test='inboundCode != null and inboundCode != \"\"'> AND inbound_code=#{inboundCode}</if>" +
" ORDER BY updated_at DESC,id DESC LIMIT #{limit} OFFSET #{offset}</script>")
List<InventoryBalance> listTenant(@Param("tenantId") String tenantId,
List<InventoryBalance> listTenant(@Param("customerId") String customerId,
@Param("overseasWarehouseId") String overseasWarehouseId,
@Param("destinationWarehouseCode") String destinationWarehouseCode,
@Param("inboundCode") String inboundCode,
@@ -45,8 +45,8 @@ public interface InventoryBalanceMapper extends BaseMapper<InventoryBalance> {
"COALESCE(SUM(available_packages),0) available_packages,COALESCE(SUM(hold_packages),0) hold_packages," +
"COALESCE(SUM(allocated_packages),0) allocated_packages,COALESCE(SUM(picked_packages),0) picked_packages," +
"COALESCE(SUM(in_transit_packages),0) in_transit_packages,COALESCE(SUM(receiving_packages),0) receiving_packages," +
"COALESCE(SUM(procurement_packages),0) procurement_packages FROM ll_inventory_balance WHERE tenant_id=#{tenantId}" +
"COALESCE(SUM(procurement_packages),0) procurement_packages FROM ll_inventory_balance WHERE customer_id=#{customerId}" +
"<if test='overseasWarehouseId != null and overseasWarehouseId != \"\"'> AND overseas_warehouse_id=#{overseasWarehouseId}</if></script>")
InventorySummary summarize(@Param("tenantId") String tenantId,
InventorySummary summarize(@Param("customerId") String customerId,
@Param("overseasWarehouseId") String overseasWarehouseId);
}
@@ -9,11 +9,11 @@ import java.time.LocalDateTime;
import java.util.List;
public interface InventoryLedgerMapper extends BaseMapper<InventoryLedger> {
@Select("SELECT * FROM ll_inventory_ledger WHERE tenant_id=#{tenantId} AND idempotency_key=#{idempotencyKey} LIMIT 1")
InventoryLedger findIdempotency(@Param("tenantId") String tenantId,
@Select("SELECT * FROM ll_inventory_ledger WHERE customer_id=#{customerId} AND idempotency_key=#{idempotencyKey} LIMIT 1")
InventoryLedger findIdempotency(@Param("customerId") String customerId,
@Param("idempotencyKey") String idempotencyKey);
@Select("<script>SELECT * FROM ll_inventory_ledger WHERE tenant_id=#{tenantId}" +
@Select("<script>SELECT * FROM ll_inventory_ledger WHERE customer_id=#{customerId}" +
"<if test='overseasWarehouseId != null and overseasWarehouseId != \"\"'> AND overseas_warehouse_id=#{overseasWarehouseId}</if>" +
"<if test='inboundCode != null and inboundCode != \"\"'> AND inbound_code=#{inboundCode}</if>" +
"<if test='businessType != null and businessType != \"\"'> AND business_type=#{businessType}</if>" +
@@ -21,7 +21,7 @@ public interface InventoryLedgerMapper extends BaseMapper<InventoryLedger> {
"<if test='startedAt != null'> AND occurred_at &gt;=#{startedAt}</if>" +
"<if test='endedAt != null'> AND occurred_at &lt;#{endedAt}</if>" +
" ORDER BY occurred_at DESC,id DESC LIMIT #{limit} OFFSET #{offset}</script>")
List<InventoryLedger> listTenant(@Param("tenantId") String tenantId,
List<InventoryLedger> listTenant(@Param("customerId") String customerId,
@Param("overseasWarehouseId") String overseasWarehouseId,
@Param("inboundCode") String inboundCode,
@Param("businessType") String businessType,
@@ -6,11 +6,11 @@ import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
public interface InventoryOperationMapper extends BaseMapper<InventoryOperation> {
@Select("SELECT * FROM ll_inventory_operation WHERE tenant_id=#{tenantId} AND idempotency_key=#{idempotencyKey} LIMIT 1")
InventoryOperation findIdempotency(@Param("tenantId") String tenantId,
@Select("SELECT * FROM ll_inventory_operation WHERE customer_id=#{customerId} AND idempotency_key=#{idempotencyKey} LIMIT 1")
InventoryOperation findIdempotency(@Param("customerId") String customerId,
@Param("idempotencyKey") String idempotencyKey);
@Select("SELECT * FROM ll_inventory_operation WHERE tenant_id=#{tenantId} AND idempotency_key=#{idempotencyKey} LIMIT 1 FOR UPDATE")
InventoryOperation lockIdempotency(@Param("tenantId") String tenantId,
@Select("SELECT * FROM ll_inventory_operation WHERE customer_id=#{customerId} AND idempotency_key=#{idempotencyKey} LIMIT 1 FOR UPDATE")
InventoryOperation lockIdempotency(@Param("customerId") String customerId,
@Param("idempotencyKey") String idempotencyKey);
}
@@ -9,18 +9,18 @@ import java.util.List;
public interface InventoryPositionMapper extends BaseMapper<InventoryPosition> {
@Select("SELECT p.* FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.id=p.balance_id " +
"WHERE b.tenant_id=#{tenantId} AND p.balance_id=#{balanceId} ORDER BY p.pallet_no,p.batch_no")
List<InventoryPosition> listBalance(@Param("tenantId") String tenantId,
"WHERE b.customer_id=#{customerId} AND p.balance_id=#{balanceId} ORDER BY p.pallet_no,p.batch_no")
List<InventoryPosition> listBalance(@Param("customerId") String customerId,
@Param("balanceId") Long balanceId);
@Select("SELECT * FROM ll_inventory_position WHERE tenant_id=#{tenantId} AND pallet_id=#{palletId} " +
@Select("SELECT * FROM ll_inventory_position WHERE customer_id=#{customerId} AND pallet_id=#{palletId} " +
"ORDER BY balance_id,batch_no")
List<InventoryPosition> listPallet(@Param("tenantId") String tenantId,
List<InventoryPosition> listPallet(@Param("customerId") String customerId,
@Param("palletId") Long palletId);
@Select("SELECT * FROM ll_inventory_position WHERE tenant_id=#{tenantId} AND balance_id=#{balanceId} " +
@Select("SELECT * FROM ll_inventory_position WHERE customer_id=#{customerId} AND balance_id=#{balanceId} " +
"AND pallet_id=#{palletId} AND batch_no=#{batchNo} LIMIT 1 FOR UPDATE")
InventoryPosition lockPosition(@Param("tenantId") String tenantId,
InventoryPosition lockPosition(@Param("customerId") String customerId,
@Param("balanceId") Long balanceId,
@Param("palletId") Long palletId,
@Param("batchNo") String batchNo);
@@ -38,55 +38,55 @@ public class InventoryQueryController {
@Operation(summary = "Get inbound-order package inventory totals")
public ApiResponse<InventoryDashboardResponse> summary(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String tenantId,
@RequestParam(required = false) String customerId,
@Valid InventoryQueryRequests.BalanceQuery query) {
return ApiResponse.success(service.dashboard(scope(user, tenantId), query));
return ApiResponse.success(service.dashboard(scope(user, customerId), query));
}
@GetMapping(BASE_PATH + "/balances")
@Operation(summary = "Page inventory by customer, warehouse and inbound order")
public ApiResponse<PageResult<InventoryBalanceView>> balances(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String tenantId,
@RequestParam(required = false) String customerId,
@Valid InventoryQueryRequests.BalanceQuery query) {
return ApiResponse.success(service.balances(scope(user, tenantId), query));
return ApiResponse.success(service.balances(scope(user, customerId), query));
}
@GetMapping(BASE_PATH + "/balances/{balanceId}/positions")
@Operation(summary = "List pallet and batch positions for one inbound-order balance")
public ApiResponse<List<InventoryPositionView>> positions(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String tenantId,
@RequestParam(required = false) String customerId,
@PathVariable long balanceId,
@Valid InventoryQueryRequests.PositionQuery query) {
return ApiResponse.success(service.positions(scope(user, tenantId), balanceId, query));
return ApiResponse.success(service.positions(scope(user, customerId), balanceId, query));
}
@GetMapping(BASE_PATH + "/ledger")
@Operation(summary = "Page append-only inventory movements")
public ApiResponse<PageResult<InventoryLedgerView>> ledgers(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String tenantId,
@RequestParam(required = false) String customerId,
@Valid InventoryQueryRequests.LedgerQuery query) {
return ApiResponse.success(service.ledgers(scope(user, tenantId), query));
return ApiResponse.success(service.ledgers(scope(user, customerId), query));
}
@GetMapping(value = BASE_PATH + "/balances/export", produces = "text/csv")
@Operation(summary = "Export filtered inbound-order inventory as UTF-8 CSV")
public ResponseEntity<byte[]> exportBalances(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String tenantId,
@RequestParam(required = false) String customerId,
@Valid InventoryQueryRequests.BalanceQuery query) {
return csv(service.exportBalances(scope(user, tenantId), query), "inventory-balances.csv");
return csv(service.exportBalances(scope(user, customerId), query), "inventory-balances.csv");
}
@GetMapping(value = BASE_PATH + "/ledger/export", produces = "text/csv")
@Operation(summary = "Export filtered inventory movements as UTF-8 CSV")
public ResponseEntity<byte[]> exportLedgers(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String tenantId,
@RequestParam(required = false) String customerId,
@Valid InventoryQueryRequests.LedgerQuery query) {
return csv(service.exportLedgers(scope(user, tenantId), query), "inventory-ledger.csv");
return csv(service.exportLedgers(scope(user, customerId), query), "inventory-ledger.csv");
}
private static ResponseEntity<byte[]> csv(byte[] body, String filename) {
@@ -96,16 +96,16 @@ public class InventoryQueryController {
.body(body);
}
private String scope(AuthenticatedUser user, String requestedTenant) {
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 (requestedTenant == null || requestedTenant.isBlank() || "0".equals(requestedTenant)) {
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Platform queries must specify a customer tenant");
"Platform queries must specify a customer");
}
return requestedTenant;
return requestedCustomer;
}
return String.valueOf(user.tenantId());
}
@@ -11,14 +11,14 @@ import org.apache.ibatis.annotations.Select;
import java.util.List;
public interface InventoryQueryMapper {
String BALANCE_FILTER = " WHERE tenant_id=#{tenantId}" +
String BALANCE_FILTER = " WHERE customer_id=#{customerId}" +
"<if test='q.customerName != null and q.customerName != \"\"'> AND customer_name LIKE CONCAT('%',#{q.customerName},'%')</if>" +
"<if test='q.overseasWarehouseId != null and q.overseasWarehouseId != \"\"'> AND overseas_warehouse_id=#{q.overseasWarehouseId}</if>" +
"<if test='q.overseasWarehouseCode != null and q.overseasWarehouseCode != \"\"'> AND overseas_warehouse_code=#{q.overseasWarehouseCode}</if>" +
"<if test='q.destinationWarehouseCode != null and q.destinationWarehouseCode != \"\"'> AND destination_warehouse_code=#{q.destinationWarehouseCode}</if>" +
"<if test='q.inboundOrderNo != null and q.inboundOrderNo != \"\"'> AND inbound_order_no LIKE CONCAT('%',#{q.inboundOrderNo},'%')</if>" +
"<if test='q.inboundCode != null and q.inboundCode != \"\"'> AND inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')</if>" +
"<if test='q.businessOrderNo != null and q.businessOrderNo != \"\"'> AND EXISTS (SELECT 1 FROM ll_inventory_ledger l WHERE l.balance_id=ll_inventory_balance.id AND l.tenant_id=#{tenantId} AND l.business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%'))</if>" +
"<if test='q.businessOrderNo != null and q.businessOrderNo != \"\"'> AND EXISTS (SELECT 1 FROM ll_inventory_ledger l WHERE l.balance_id=ll_inventory_balance.id AND l.customer_id=#{customerId} AND l.business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%'))</if>" +
"<choose>" +
"<when test='q.inventoryStatus == \"AVAILABLE\"'> AND available_packages &gt; 0</when>" +
"<when test='q.inventoryStatus == \"HOLD\"'> AND hold_packages &gt; 0</when>" +
@@ -29,14 +29,14 @@ public interface InventoryQueryMapper {
"<when test='q.inventoryStatus == \"PROCUREMENT\"'> AND procurement_packages &gt; 0</when>" +
"</choose>";
String JOINED_BALANCE_FILTER = " WHERE b.tenant_id=#{tenantId}" +
String JOINED_BALANCE_FILTER = " WHERE b.customer_id=#{customerId}" +
"<if test='q.customerName != null and q.customerName != \"\"'> AND b.customer_name LIKE CONCAT('%',#{q.customerName},'%')</if>" +
"<if test='q.overseasWarehouseId != null and q.overseasWarehouseId != \"\"'> AND b.overseas_warehouse_id=#{q.overseasWarehouseId}</if>" +
"<if test='q.overseasWarehouseCode != null and q.overseasWarehouseCode != \"\"'> AND b.overseas_warehouse_code=#{q.overseasWarehouseCode}</if>" +
"<if test='q.destinationWarehouseCode != null and q.destinationWarehouseCode != \"\"'> AND b.destination_warehouse_code=#{q.destinationWarehouseCode}</if>" +
"<if test='q.inboundOrderNo != null and q.inboundOrderNo != \"\"'> AND b.inbound_order_no LIKE CONCAT('%',#{q.inboundOrderNo},'%')</if>" +
"<if test='q.inboundCode != null and q.inboundCode != \"\"'> AND b.inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')</if>" +
"<if test='q.businessOrderNo != null and q.businessOrderNo != \"\"'> AND EXISTS (SELECT 1 FROM ll_inventory_ledger l WHERE l.balance_id=b.id AND l.tenant_id=#{tenantId} AND l.business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%'))</if>" +
"<if test='q.businessOrderNo != null and q.businessOrderNo != \"\"'> AND EXISTS (SELECT 1 FROM ll_inventory_ledger l WHERE l.balance_id=b.id AND l.customer_id=#{customerId} AND l.business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%'))</if>" +
"<choose>" +
"<when test='q.inventoryStatus == \"AVAILABLE\"'> AND b.available_packages &gt; 0</when>" +
"<when test='q.inventoryStatus == \"HOLD\"'> AND b.hold_packages &gt; 0</when>" +
@@ -49,13 +49,13 @@ public interface InventoryQueryMapper {
@Select("<script>SELECT * FROM ll_inventory_balance" + BALANCE_FILTER +
" ORDER BY updated_at DESC,id DESC LIMIT #{limit} OFFSET #{offset}</script>")
List<InventoryBalance> balances(@Param("tenantId") String tenantId,
List<InventoryBalance> balances(@Param("customerId") String customerId,
@Param("q") InventoryQueryRequests.BalanceQuery query,
@Param("offset") long offset,
@Param("limit") long limit);
@Select("<script>SELECT COUNT(*) FROM ll_inventory_balance" + BALANCE_FILTER + "</script>")
long countBalances(@Param("tenantId") String tenantId,
long countBalances(@Param("customerId") String customerId,
@Param("q") InventoryQueryRequests.BalanceQuery query);
@Select("<script>SELECT COUNT(*) dimension_count," +
@@ -67,16 +67,16 @@ public interface InventoryQueryMapper {
"COALESCE(SUM(receiving_packages),0) receiving_packages," +
"COALESCE(SUM(procurement_packages),0) procurement_packages " +
"FROM ll_inventory_balance" + BALANCE_FILTER + "</script>")
InventoryAggregateProjection aggregate(@Param("tenantId") String tenantId,
InventoryAggregateProjection aggregate(@Param("customerId") String customerId,
@Param("q") InventoryQueryRequests.BalanceQuery query);
@Select("<script>SELECT COUNT(DISTINCT p.pallet_id) FROM ll_inventory_position p " +
"JOIN ll_inventory_balance b ON b.id=p.balance_id" +
JOINED_BALANCE_FILTER + "</script>")
long countPallets(@Param("tenantId") String tenantId,
long countPallets(@Param("customerId") String customerId,
@Param("q") InventoryQueryRequests.BalanceQuery query);
@Select("<script>SELECT * FROM ll_inventory_position WHERE tenant_id=#{tenantId} AND balance_id=#{balanceId}" +
@Select("<script>SELECT * FROM ll_inventory_position WHERE customer_id=#{customerId} AND balance_id=#{balanceId}" +
"<if test='q.palletNo != null and q.palletNo != \"\"'> AND pallet_no LIKE CONCAT('%',#{q.palletNo},'%')</if>" +
"<if test='q.batchNo != null and q.batchNo != \"\"'> AND batch_no LIKE CONCAT('%',#{q.batchNo},'%')</if>" +
"<choose>" +
@@ -86,14 +86,14 @@ public interface InventoryQueryMapper {
"<when test='q.inventoryStatus == \"PICKED\"'> AND picked_packages &gt; 0</when>" +
"<when test='q.inventoryStatus == \"RECEIVING\"'> AND receiving_packages &gt; 0</when>" +
"</choose> ORDER BY pallet_no,batch_no,id</script>")
List<InventoryPosition> positions(@Param("tenantId") String tenantId,
List<InventoryPosition> positions(@Param("customerId") String customerId,
@Param("balanceId") long balanceId,
@Param("q") InventoryQueryRequests.PositionQuery query);
@Select("SELECT * FROM ll_inventory_balance WHERE tenant_id=#{tenantId} AND id=#{balanceId} LIMIT 1")
InventoryBalance ownedBalance(@Param("tenantId") String tenantId, @Param("balanceId") long balanceId);
@Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} AND id=#{balanceId} LIMIT 1")
InventoryBalance ownedBalance(@Param("customerId") String customerId, @Param("balanceId") long balanceId);
String LEDGER_FILTER = " WHERE tenant_id=#{tenantId}" +
String LEDGER_FILTER = " WHERE customer_id=#{customerId}" +
"<if test='q.overseasWarehouseId != null and q.overseasWarehouseId != \"\"'> AND overseas_warehouse_id=#{q.overseasWarehouseId}</if>" +
"<if test='q.destinationWarehouseCode != null and q.destinationWarehouseCode != \"\"'> AND destination_warehouse_code=#{q.destinationWarehouseCode}</if>" +
"<if test='q.businessType != null and q.businessType != \"\"'> AND business_type=#{q.businessType}</if>" +
@@ -108,12 +108,12 @@ public interface InventoryQueryMapper {
@Select("<script>SELECT * FROM ll_inventory_ledger" + LEDGER_FILTER +
" ORDER BY occurred_at DESC,id DESC LIMIT #{limit} OFFSET #{offset}</script>")
List<InventoryLedger> ledgers(@Param("tenantId") String tenantId,
List<InventoryLedger> ledgers(@Param("customerId") String customerId,
@Param("q") InventoryQueryRequests.LedgerQuery query,
@Param("offset") long offset,
@Param("limit") long limit);
@Select("<script>SELECT COUNT(*) FROM ll_inventory_ledger" + LEDGER_FILTER + "</script>")
long countLedgers(@Param("tenantId") String tenantId,
long countLedgers(@Param("customerId") String customerId,
@Param("q") InventoryQueryRequests.LedgerQuery query);
}
@@ -31,9 +31,9 @@ public class InventoryQueryService {
@Resource private InventoryQueryMapper mapper;
public InventoryDashboardResponse dashboard(String tenantId, InventoryQueryRequests.BalanceQuery query) {
public InventoryDashboardResponse dashboard(String customerId, InventoryQueryRequests.BalanceQuery query) {
validateStatus(query.inventoryStatus(), BALANCE_STATUSES);
InventoryAggregateProjection value = mapper.aggregate(tenantId, query);
InventoryAggregateProjection value = mapper.aggregate(customerId, query);
long availablePackages = number(value.getAvailablePackages());
long holdPackages = number(value.getHoldPackages());
long allocatedPackages = number(value.getAllocatedPackages());
@@ -41,46 +41,46 @@ public class InventoryQueryService {
long inTransitPackages = number(value.getInTransitPackages());
long receivingPackages = number(value.getReceivingPackages());
long procurementPackages = number(value.getProcurementPackages());
return new InventoryDashboardResponse(number(value.getDimensionCount()), mapper.countPallets(tenantId, query),
return new InventoryDashboardResponse(number(value.getDimensionCount()), mapper.countPallets(customerId, query),
availablePackages + holdPackages + allocatedPackages + pickedPackages + inTransitPackages
+ receivingPackages + procurementPackages,
availablePackages, holdPackages, allocatedPackages, pickedPackages, inTransitPackages,
receivingPackages, procurementPackages);
}
public PageResult<InventoryBalanceView> balances(String tenantId, InventoryQueryRequests.BalanceQuery query) {
public PageResult<InventoryBalanceView> balances(String customerId, InventoryQueryRequests.BalanceQuery query) {
validateStatus(query.inventoryStatus(), BALANCE_STATUSES);
long total = mapper.countBalances(tenantId, query);
long total = mapper.countBalances(customerId, query);
List<InventoryBalanceView> records = total == 0 ? List.of() : mapper.balances(
tenantId, query, offset(query.page(), query.size()), query.size()).stream().map(this::balanceView).toList();
customerId, query, offset(query.page(), query.size()), query.size()).stream().map(this::balanceView).toList();
return page(records, query.page(), query.size(), total);
}
public List<InventoryPositionView> positions(String tenantId, long balanceId,
public List<InventoryPositionView> positions(String customerId, long balanceId,
InventoryQueryRequests.PositionQuery query) {
validateStatus(query.inventoryStatus(), POSITION_STATUSES);
InventoryBalance balance = mapper.ownedBalance(tenantId, balanceId);
InventoryBalance balance = mapper.ownedBalance(customerId, balanceId);
if (balance == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inventory balance not found");
}
return mapper.positions(tenantId, balanceId, query).stream().map(value -> positionView(value, balance)).toList();
return mapper.positions(customerId, balanceId, query).stream().map(value -> positionView(value, balance)).toList();
}
public PageResult<InventoryLedgerView> ledgers(String tenantId, InventoryQueryRequests.LedgerQuery query) {
public PageResult<InventoryLedgerView> ledgers(String customerId, InventoryQueryRequests.LedgerQuery query) {
validateDateRange(query);
long total = mapper.countLedgers(tenantId, query);
long total = mapper.countLedgers(customerId, query);
List<InventoryLedgerView> records = total == 0 ? List.of() : mapper.ledgers(
tenantId, query, offset(query.page(), query.size()), query.size()).stream().map(this::ledgerView).toList();
customerId, query, offset(query.page(), query.size()), query.size()).stream().map(this::ledgerView).toList();
return page(records, query.page(), query.size(), total);
}
public byte[] exportBalances(String tenantId, InventoryQueryRequests.BalanceQuery query) {
public byte[] exportBalances(String customerId, InventoryQueryRequests.BalanceQuery query) {
validateStatus(query.inventoryStatus(), BALANCE_STATUSES);
List<String> rows = new ArrayList<>();
rows.add("customerId,customerName,overseasWarehouseCode,overseasWarehouseName,destinationWarehouseCode," +
"destinationWarehouseName,inboundOrderId,inboundOrderNo,inboundCode,totalPackages,availablePackages," +
"holdPackages,allocatedPackages,pickedPackages,inTransitPackages,receivingPackages,procurementPackages");
mapper.balances(tenantId, query, 0, EXPORT_LIMIT).stream().map(this::balanceView).forEach(v -> rows.add(
mapper.balances(customerId, query, 0, EXPORT_LIMIT).stream().map(this::balanceView).forEach(v -> rows.add(
String.join(",", csv(v.customerId()), csv(v.customerName()), csv(v.overseasWarehouseCode()), csv(v.overseasWarehouseName()),
csv(v.destinationWarehouseCode()), csv(v.destinationWarehouseName()), csv(v.inboundOrderId()),
csv(v.inboundOrderNo()), csv(v.inboundCode()),
@@ -90,13 +90,13 @@ public class InventoryQueryService {
return ("\uFEFF" + String.join("\r\n", rows)).getBytes(StandardCharsets.UTF_8);
}
public byte[] exportLedgers(String tenantId, InventoryQueryRequests.LedgerQuery query) {
public byte[] exportLedgers(String customerId, InventoryQueryRequests.LedgerQuery query) {
validateDateRange(query);
List<String> rows = new ArrayList<>();
rows.add("occurredAt,customerId,overseasWarehouseId,destinationWarehouseCode,inboundOrderId,inboundOrderNo," +
"inboundCode,businessType,businessOrderNo,outboundOrderNo,palletNo,batchNo,statusFrom,statusTo," +
"beforePackages,deltaPackages,afterPackages,operatorId,remark");
mapper.ledgers(tenantId, query, 0, EXPORT_LIMIT).stream().map(this::ledgerView).forEach(v -> rows.add(
mapper.ledgers(customerId, query, 0, EXPORT_LIMIT).stream().map(this::ledgerView).forEach(v -> rows.add(
String.join(",", csv(v.occurredAt()), csv(v.customerId()), csv(v.overseasWarehouseId()),
csv(v.destinationWarehouseCode()), csv(v.inboundOrderId()), csv(v.inboundOrderNo()), csv(v.inboundCode()),
csv(v.businessType()), csv(v.businessOrderNo()), csv(v.outboundOrderNo()), csv(v.palletNo()), csv(v.batchNo()),
@@ -113,7 +113,7 @@ public class InventoryQueryService {
long inTransitPackages = number(value.getInTransitPackages());
long receivingPackages = number(value.getReceivingPackages());
long procurementPackages = number(value.getProcurementPackages());
return new InventoryBalanceView(value.getId(), value.getTenantId(), value.getCustomerName(), value.getOverseasWarehouseId(),
return new InventoryBalanceView(value.getId(), value.getCustomerId(), value.getCustomerName(), value.getOverseasWarehouseId(),
value.getOverseasWarehouseCode(), value.getOverseasWarehouseName(), value.getDestinationWarehouseId(),
value.getDestinationWarehouseCode(), value.getDestinationWarehouseName(), value.getInboundOrderId(),
value.getInboundOrderNo(), value.getInboundCode(), availablePackages + holdPackages + allocatedPackages + pickedPackages
@@ -128,7 +128,7 @@ public class InventoryQueryService {
long allocated = number(value.getAllocatedPackages());
long picked = number(value.getPickedPackages());
long receiving = number(value.getReceivingPackages());
return new InventoryPositionView(value.getId(), value.getBalanceId(), value.getTenantId(),
return new InventoryPositionView(value.getId(), value.getBalanceId(), value.getCustomerId(),
value.getOverseasWarehouseId(), value.getDestinationWarehouseCode(), balance.getInboundOrderId(),
balance.getInboundOrderNo(), balance.getInboundCode(),
value.getPalletId(), value.getPalletNo(), value.getBatchNo(), value.getWarehouseLocationId(),
@@ -138,7 +138,7 @@ public class InventoryQueryService {
}
private InventoryLedgerView ledgerView(InventoryLedger value) {
return new InventoryLedgerView(value.getId(), value.getTenantId(), value.getOverseasWarehouseId(),
return new InventoryLedgerView(value.getId(), value.getCustomerId(), value.getOverseasWarehouseId(),
value.getDestinationWarehouseCode(), value.getInboundOrderId(), value.getInboundOrderNo(), value.getInboundCode(),
value.getPalletNo(), value.getBatchNo(),
value.getWarehouseLocationCode(), value.getBusinessType(), value.getBusinessOrderNo(),
@@ -9,30 +9,30 @@ import org.apache.ibatis.annotations.Update;
@Mapper
public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
@Select("SELECT * FROM ll_order WHERE tenant_id=#{tenantId} AND source_region=#{sourceRegion} " +
@Select("SELECT * FROM ll_order WHERE customer_id=#{customerId} AND source_region=#{sourceRegion} " +
"AND source_inbound_id=#{sourceInboundId} LIMIT 1")
LastlegOrder findSource(@Param("tenantId") String tenantId,
LastlegOrder findSource(@Param("customerId") String customerId,
@Param("sourceRegion") String sourceRegion,
@Param("sourceInboundId") String sourceInboundId);
@Select("SELECT COUNT(*) FROM ll_order WHERE tenant_id=#{tenantId} AND container_no=#{containerNo}")
long countContainer(@Param("tenantId") String tenantId, @Param("containerNo") String containerNo);
@Select("SELECT COUNT(*) FROM ll_order WHERE customer_id=#{customerId} AND container_no=#{containerNo}")
long countContainer(@Param("customerId") String customerId, @Param("containerNo") String containerNo);
@Select("SELECT * FROM ll_order WHERE tenant_id=#{tenantId} AND id=#{id} FOR UPDATE")
LastlegOrder lockById(@Param("tenantId") String tenantId, @Param("id") Long id);
@Select("SELECT * FROM ll_order WHERE customer_id=#{customerId} AND id=#{id} FOR UPDATE")
LastlegOrder lockById(@Param("customerId") String customerId, @Param("id") Long id);
@Update("UPDATE ll_order SET picked_package_count=picked_package_count+#{packages},updated_at=NOW() " +
"WHERE tenant_id=#{tenantId} AND id=#{id} " +
"WHERE customer_id=#{customerId} AND id=#{id} " +
"AND package_count-shipped_package_count-picked_package_count >= #{packages}")
int increasePicked(@Param("tenantId") String tenantId, @Param("id") Long id,
int increasePicked(@Param("customerId") String customerId, @Param("id") Long id,
@Param("packages") long packages);
@Update("UPDATE ll_order SET picked_package_count=picked_package_count-#{packages}," +
"shipped_package_count=shipped_package_count+#{packages},outbound_order_no=#{outboundNo}," +
"current_status=CASE WHEN shipped_package_count+#{packages}=package_count " +
"THEN 'COMPLETED' ELSE 'PARTIALLY_SHIPPED' END,updated_at=NOW() " +
"WHERE tenant_id=#{tenantId} AND id=#{id} AND picked_package_count >= #{packages} " +
"WHERE customer_id=#{customerId} AND id=#{id} AND picked_package_count >= #{packages} " +
"AND shipped_package_count+#{packages} <= package_count")
int transferPickedToShipped(@Param("tenantId") String tenantId, @Param("id") Long id,
int transferPickedToShipped(@Param("customerId") String customerId, @Param("id") Long id,
@Param("packages") long packages, @Param("outboundNo") String outboundNo);
}
@@ -20,24 +20,24 @@ public class RelabelApplicationController {
@PostMapping
public ApiResponse<RelabelResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String tenantId, @Valid @RequestBody RelabelRequests.Create request) {
return ApiResponse.success(service.create(scope(user, tenantId), operator(user), request));
@RequestParam(required = false) String customerId, @Valid @RequestBody RelabelRequests.Create request) {
return ApiResponse.success(service.create(scope(user, customerId), operator(user), request));
}
@GetMapping
public ApiResponse<PageResult<RelabelResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String tenantId, @Valid RelabelRequests.Query query) {
return ApiResponse.success(service.page(scope(user, tenantId), query));
@RequestParam(required = false) String customerId, @Valid RelabelRequests.Query query) {
return ApiResponse.success(service.page(scope(user, customerId), query));
}
@GetMapping("/{relabelNo}")
public ApiResponse<RelabelResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String tenantId, @PathVariable String relabelNo) {
return ApiResponse.success(service.detail(scope(user, tenantId), relabelNo));
@RequestParam(required = false) String customerId, @PathVariable String relabelNo) {
return ApiResponse.success(service.detail(scope(user, customerId), relabelNo));
}
@PutMapping("/{relabelNo}/status")
public ApiResponse<RelabelResponses.Detail> status(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String tenantId, @PathVariable String relabelNo,
@RequestParam(required = false) String customerId, @PathVariable String relabelNo,
@Valid @RequestBody RelabelRequests.ChangeStatus request) {
return ApiResponse.success(service.changeStatus(scope(user, tenantId), relabelNo, operator(user), request));
return ApiResponse.success(service.changeStatus(scope(user, customerId), relabelNo, operator(user), request));
}
private String scope(AuthenticatedUser user, String requested) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
@@ -11,7 +11,7 @@ import java.time.LocalDateTime;
@TableName("ll_relabel_application")
public class RelabelApplication {
@TableId(type = IdType.INPUT) private Long id;
private String tenantId;
private String customerId;
private String relabelNo;
private Long lastlegOrderId;
private String customerOrderNo;
@@ -12,7 +12,7 @@ import java.time.LocalDateTime;
public class RelabelItem {
@TableId(type = IdType.INPUT) private Long id;
private Long applicationId;
private String tenantId;
private String customerId;
private String skuCode;
private String productImageFileId;
private String productImageFileName;
@@ -11,7 +11,7 @@ import java.time.LocalDateTime;
@TableName("ll_relabel_status_history")
public class RelabelStatusHistory {
@TableId(type = IdType.INPUT) private Long id;
private String tenantId;
private String customerId;
private Long applicationId;
private String fromStatus;
private String toStatus;
@@ -8,12 +8,12 @@ import org.apache.ibatis.annotations.Select;
@Mapper
public interface RelabelApplicationMapper extends BaseMapper<RelabelApplication> {
@Select("SELECT * FROM ll_relabel_application WHERE tenant_id=#{tenantId} AND relabel_no=#{relabelNo} LIMIT 1")
RelabelApplication findNo(@Param("tenantId") String tenantId, @Param("relabelNo") String relabelNo);
@Select("SELECT * FROM ll_relabel_application WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} LIMIT 1")
RelabelApplication findNo(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo);
@Select("SELECT * FROM ll_relabel_application WHERE tenant_id=#{tenantId} AND idempotency_key=#{key} LIMIT 1")
RelabelApplication findKey(@Param("tenantId") String tenantId, @Param("key") String key);
@Select("SELECT * FROM ll_relabel_application WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1")
RelabelApplication findKey(@Param("customerId") String customerId, @Param("key") String key);
@Select("SELECT * FROM ll_relabel_application WHERE tenant_id=#{tenantId} AND relabel_no=#{relabelNo} LIMIT 1 FOR UPDATE")
RelabelApplication lockNo(@Param("tenantId") String tenantId, @Param("relabelNo") String relabelNo);
@Select("SELECT * FROM ll_relabel_application WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} LIMIT 1 FOR UPDATE")
RelabelApplication lockNo(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo);
}
@@ -49,7 +49,7 @@ public class RelabelApplicationService {
@Resource private ObjectMapper objectMapper;
@Transactional
public RelabelResponses.Detail create(String tenantId, String operatorId, RelabelRequests.Create request) {
public RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request) {
int total = 0;
int notified = 0;
for (RelabelRequests.Item item : request.items()) {
@@ -60,10 +60,10 @@ public class RelabelApplicationService {
notified = Math.addExact(notified, item.notifiedPackageCount());
}
String requestHash = hash(request);
RelabelApplication replay = applications.findKey(tenantId, request.idempotencyKey().trim());
RelabelApplication replay = applications.findKey(customerId, request.idempotencyKey().trim());
if (replay != null) {
if (!requestHash.equals(replay.getRequestHash())) throw conflict("Idempotency key was reused with different relabel data");
return detail(tenantId, replay.getRelabelNo());
return detail(customerId, replay.getRelabelNo());
}
LocalDateTime now = LocalDateTime.now();
long applicationId = IdWorker.getId();
@@ -71,7 +71,7 @@ public class RelabelApplicationService {
LastlegOrder order = new LastlegOrder();
order.setId(IdWorker.getId());
order.setOrderNo(orderNoGenerator.next());
order.setTenantId(tenantId);
order.setCustomerId(customerId);
order.setOrderType("RELABEL_APPLICATION");
order.setInboundCode(relabelNo);
order.setSourceRegion("CUSTOMER");
@@ -95,7 +95,7 @@ public class RelabelApplicationService {
RelabelApplication application = new RelabelApplication();
application.setId(applicationId);
application.setTenantId(tenantId);
application.setCustomerId(customerId);
application.setRelabelNo(relabelNo);
application.setLastlegOrderId(order.getId());
application.setCustomerOrderNo(request.customerOrderNo().trim());
@@ -115,19 +115,19 @@ public class RelabelApplicationService {
application.setUpdatedAt(now);
applications.insert(application);
for (RelabelRequests.Item source : request.items()) insertItem(tenantId, applicationId, source, now);
insertHistory(tenantId, applicationId, null, PENDING_PROCESSING, operatorId, "Relabel application submitted", now);
for (RelabelRequests.Item source : request.items()) insertItem(customerId, applicationId, source, now);
insertHistory(customerId, applicationId, null, PENDING_PROCESSING, operatorId, "Relabel application submitted", now);
insertOrderHistory(order.getId(), null, PENDING_PROCESSING, "Relabel application submitted", now);
return detail(tenantId, relabelNo);
return detail(customerId, relabelNo);
}
@Transactional(readOnly = true)
public PageResult<RelabelResponses.Summary> page(String tenantId, RelabelRequests.Query query) {
public PageResult<RelabelResponses.Summary> page(String customerId, RelabelRequests.Query query) {
if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) {
throw badRequest("createdFrom must not be after createdTo");
}
LambdaQueryWrapper<RelabelApplication> wrapper = new LambdaQueryWrapper<RelabelApplication>()
.eq(RelabelApplication::getTenantId, tenantId)
.eq(RelabelApplication::getCustomerId, customerId)
.like(StringUtils.hasText(query.relabelNo()), RelabelApplication::getRelabelNo, query.relabelNo())
.like(StringUtils.hasText(query.customerOrderNo()), RelabelApplication::getCustomerOrderNo, query.customerOrderNo())
.eq(StringUtils.hasText(query.status()), RelabelApplication::getStatus, query.status())
@@ -135,7 +135,7 @@ public class RelabelApplicationService {
.ge(query.createdFrom() != null, RelabelApplication::getCreatedAt, query.createdFrom())
.le(query.createdTo() != null, RelabelApplication::getCreatedAt, query.createdTo())
.apply(StringUtils.hasText(query.skuCode()),
"EXISTS (SELECT 1 FROM ll_relabel_item ri WHERE ri.application_id=ll_relabel_application.id AND ri.tenant_id=ll_relabel_application.tenant_id AND ri.sku_code LIKE CONCAT('%',{0},'%'))",
"EXISTS (SELECT 1 FROM ll_relabel_item ri WHERE ri.application_id=ll_relabel_application.id AND ri.customer_id=ll_relabel_application.customer_id AND ri.sku_code LIKE CONCAT('%',{0},'%'))",
query.skuCode())
.orderByDesc(RelabelApplication::getCreatedAt);
Page<RelabelApplication> page = applications.selectPage(new Page<>(query.page(), query.size()), wrapper);
@@ -144,11 +144,11 @@ public class RelabelApplicationService {
}
@Transactional(readOnly = true)
public RelabelResponses.Detail detail(String tenantId, String relabelNo) {
RelabelApplication application = require(tenantId, relabelNo);
public RelabelResponses.Detail detail(String customerId, String relabelNo) {
RelabelApplication application = require(customerId, relabelNo);
List<RelabelItem> detailItems = itemList(application.getId());
List<RelabelStatusHistory> statusHistory = histories.selectList(new LambdaQueryWrapper<RelabelStatusHistory>()
.eq(RelabelStatusHistory::getTenantId, tenantId)
.eq(RelabelStatusHistory::getCustomerId, customerId)
.eq(RelabelStatusHistory::getApplicationId, application.getId())
.orderByAsc(RelabelStatusHistory::getCreatedAt));
return new RelabelResponses.Detail(summary(application, detailItems), application.getRemark(),
@@ -158,13 +158,13 @@ public class RelabelApplicationService {
}
@Transactional
public RelabelResponses.Detail changeStatus(String tenantId, String relabelNo, String operatorId,
public RelabelResponses.Detail changeStatus(String customerId, String relabelNo, String operatorId,
RelabelRequests.ChangeStatus request) {
RelabelApplication application = applications.lockNo(tenantId, relabelNo);
RelabelApplication application = applications.lockNo(customerId, relabelNo);
if (application == null) throw notFound("Relabel application not found");
String next = request.status().trim().toUpperCase();
if (!STATUSES.contains(next)) throw badRequest("Unsupported relabel status");
if (next.equals(application.getStatus())) return detail(tenantId, relabelNo);
if (next.equals(application.getStatus())) return detail(customerId, relabelNo);
validateTransition(application.getStatus(), next);
String before = application.getStatus();
LocalDateTime now = LocalDateTime.now();
@@ -172,20 +172,20 @@ public class RelabelApplicationService {
application.setUpdatedAt(now);
applications.updateById(application);
LastlegOrder order = orders.selectById(application.getLastlegOrderId());
if (order == null || !tenantId.equals(order.getTenantId())) throw conflict("Linked tail-leg order is missing");
if (order == null || !customerId.equals(order.getCustomerId())) throw conflict("Linked tail-leg order is missing");
String orderBefore = order.getCurrentStatus();
order.setCurrentStatus(next);
order.setRelabelStatus(next);
order.setUpdatedAt(now);
orders.updateById(order);
insertHistory(tenantId, application.getId(), before, next, operatorId, request.remark(), now);
insertHistory(customerId, application.getId(), before, next, operatorId, request.remark(), now);
insertOrderHistory(order.getId(), orderBefore, next, request.remark(), now);
return detail(tenantId, relabelNo);
return detail(customerId, relabelNo);
}
private void insertItem(String tenantId, long applicationId, RelabelRequests.Item source, LocalDateTime now) {
private void insertItem(String customerId, long applicationId, RelabelRequests.Item source, LocalDateTime now) {
RelabelItem item = new RelabelItem();
item.setId(IdWorker.getId()); item.setApplicationId(applicationId); item.setTenantId(tenantId);
item.setId(IdWorker.getId()); item.setApplicationId(applicationId); item.setCustomerId(customerId);
item.setSkuCode(source.skuCode().trim()); item.setProductImageFileId(trim(source.productImageFileId()));
item.setProductImageFileName(trim(source.productImageFileName())); item.setProductImageUrl(trim(source.productImageUrl()));
item.setProductNameZh(source.productNameZh().trim()); item.setTotalPackageCount(source.totalPackageCount());
@@ -219,14 +219,14 @@ public class RelabelApplicationService {
return items.selectList(new LambdaQueryWrapper<RelabelItem>().eq(RelabelItem::getApplicationId, applicationId)
.orderByAsc(RelabelItem::getId));
}
private RelabelApplication require(String tenantId, String relabelNo) {
RelabelApplication value = applications.findNo(tenantId, relabelNo);
private RelabelApplication require(String customerId, String relabelNo) {
RelabelApplication value = applications.findNo(customerId, relabelNo);
if (value == null) throw notFound("Relabel application not found");
return value;
}
private void insertHistory(String tenantId, Long id, String from, String to, String operator,
private void insertHistory(String customerId, Long id, String from, String to, String operator,
String remark, LocalDateTime now) {
RelabelStatusHistory value = new RelabelStatusHistory(); value.setId(IdWorker.getId()); value.setTenantId(tenantId);
RelabelStatusHistory value = new RelabelStatusHistory(); value.setId(IdWorker.getId()); value.setCustomerId(customerId);
value.setApplicationId(id); value.setFromStatus(from); value.setToStatus(to); value.setOperatorId(trim(operator));
value.setRemark(trim(remark)); value.setCreatedAt(now); histories.insert(value);
}
@@ -35,15 +35,15 @@ public class LastlegOrderService {
@Resource private LastlegOrderDetailMapper detailMapper;
@Resource private LastlegOrderStatusHistoryMapper historyMapper;
public PageResult<LastlegOrderSummary> page(String tenantId, LastlegOrderRequests.Query query) {
public PageResult<LastlegOrderSummary> page(String customerId, LastlegOrderRequests.Query query) {
Page<LastlegOrder> page = orderMapper.selectPage(
new Page<>(query.page(), query.size()), filter(tenantId, query).orderByDesc(LastlegOrder::getCreatedAt));
new Page<>(query.page(), query.size()), filter(customerId, query).orderByDesc(LastlegOrder::getCreatedAt));
return new PageResult<>(page.getRecords().stream().map(this::summary).toList(),
page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
}
public LastlegOrderDetailResponse detail(String tenantId, long orderId) {
LastlegOrder order = findOwned(tenantId, orderId);
public LastlegOrderDetailResponse detail(String customerId, long orderId) {
LastlegOrder order = findOwned(customerId, orderId);
List<LastlegOrderDetail> cargo = detailMapper.selectList(new LambdaQueryWrapper<LastlegOrderDetail>()
.eq(LastlegOrderDetail::getOrderId, orderId).orderByAsc(LastlegOrderDetail::getId));
List<LastlegOrderStatusHistory> history = historyMapper.selectList(
@@ -54,13 +54,13 @@ public class LastlegOrderService {
}
@Transactional
public int transferDestination(String tenantId, LastlegOrderRequests.TransferDestination request) {
public int transferDestination(String customerId, LastlegOrderRequests.TransferDestination request) {
List<Long> ids = request.orderIds().stream().filter(Objects::nonNull).distinct().toList();
if (ids.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "At least one order is required");
}
List<LastlegOrder> orders = orderMapper.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getTenantId, tenantId).in(LastlegOrder::getId, ids).last("FOR UPDATE"));
.eq(LastlegOrder::getCustomerId, customerId).in(LastlegOrder::getId, ids).last("FOR UPDATE"));
if (orders.size() != ids.size()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more tail-leg orders were not found");
}
@@ -96,8 +96,8 @@ public class LastlegOrderService {
return orders.size();
}
public byte[] exportCsv(String tenantId, LastlegOrderRequests.Query query) {
List<LastlegOrder> orders = orderMapper.selectList(filter(tenantId, query)
public byte[] exportCsv(String customerId, LastlegOrderRequests.Query query) {
List<LastlegOrder> orders = orderMapper.selectList(filter(customerId, query)
.orderByDesc(LastlegOrder::getCreatedAt).last("LIMIT 10000"));
List<String> lines = new ArrayList<>();
lines.add("ID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜状态,是否异常,是否暂扣,托数,箱数,已拣箱数,已出库箱数,剩余箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,出库单号,派送单号,换标单号,换标状态,合托标记,当前状态,备注(仅自己),备注(客户),备注(仓库)");
@@ -120,12 +120,12 @@ public class LastlegOrderService {
return ("\uFEFF" + String.join("\r\n", lines)).getBytes(StandardCharsets.UTF_8);
}
private LambdaQueryWrapper<LastlegOrder> filter(String tenantId, LastlegOrderRequests.Query query) {
private LambdaQueryWrapper<LastlegOrder> filter(String customerId, LastlegOrderRequests.Query query) {
if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "createdFrom must not be after createdTo");
}
return new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getTenantId, tenantId)
.eq(LastlegOrder::getCustomerId, customerId)
.like(StringUtils.hasText(query.orderNo()), LastlegOrder::getOrderNo, query.orderNo())
.like(StringUtils.hasText(query.inboundCode()), LastlegOrder::getInboundCode, query.inboundCode())
.like(StringUtils.hasText(query.sourceOrderNo()), LastlegOrder::getSourceOrderNo, query.sourceOrderNo())
@@ -139,9 +139,9 @@ public class LastlegOrderService {
.le(query.createdTo() != null, LastlegOrder::getCreatedAt, query.createdTo());
}
private LastlegOrder findOwned(String tenantId, long orderId) {
private LastlegOrder findOwned(String customerId, long orderId) {
LastlegOrder order = orderMapper.selectOne(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getTenantId, tenantId).eq(LastlegOrder::getId, orderId));
.eq(LastlegOrder::getCustomerId, customerId).eq(LastlegOrder::getId, orderId));
if (order == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Tail-leg order not found");
return order;
}
@@ -3,7 +3,10 @@ package com.baiyue.lastleg.service;
import com.baiyue.common.event.*;
import com.baiyue.lastleg.entity.*;
import com.baiyue.lastleg.mapper.*;
import com.baiyue.lastleg.dropship.dto.DropshipRequests;
import com.baiyue.lastleg.dropship.service.DropshipOrderService;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.DeserializationFeature;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
@@ -12,6 +15,9 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Objects;
import java.util.ArrayList;
import java.util.List;
import com.fasterxml.jackson.databind.JsonNode;
@Service
public class LoadingCompletedConsumer {
@@ -21,6 +27,7 @@ public class LoadingCompletedConsumer {
@Resource private LastlegOrderStatusHistoryMapper historyMapper;
@Resource private ObjectMapper objectMapper;
@Resource private LastlegOrderNoGenerator orderNoGenerator;
@Resource private DropshipOrderService dropshipOrders;
@Transactional
public int consume(EventEnvelope envelope) {
@@ -42,7 +49,7 @@ public class LoadingCompletedConsumer {
}
LastlegEventInbox inbox=new LastlegEventInbox();
inbox.setEventId(envelope.eventId()); inbox.setTenantId(envelope.tenantId()); inbox.setEventType(envelope.eventType());
inbox.setEventId(envelope.eventId()); inbox.setCustomerId(envelope.tenantId()); inbox.setEventType(envelope.eventType());
inbox.setEventVersion(envelope.eventVersion()); inbox.setPayloadHash(envelope.payloadHash());
inbox.setPayloadJson(envelope.payload().toString()); inbox.setStatus("RECEIVED"); inbox.setReceivedAt(LocalDateTime.now());
try { inboxMapper.insert(inbox); } catch(DuplicateKeyException exception) {
@@ -50,13 +57,22 @@ public class LoadingCompletedConsumer {
}
String orderNo = orderNoGenerator.forContainerNo(payload.containerNo());
for(var source:payload.orders()) createOrder(envelope,payload,source,orderNo);
JsonNode rawOrders = envelope.payload().path("orders");
for (int index = 0; index < payload.orders().size(); index++) {
var source = payload.orders().get(index);
JsonNode rawSource = rawOrders.isArray() && index < rawOrders.size() ? rawOrders.get(index) : objectMapper.createObjectNode();
createOrder(envelope, payload, source, orderNo, rawSource);
}
inbox.setStatus("PROCESSED"); inbox.setProcessedAt(LocalDateTime.now()); inboxMapper.updateById(inbox);
return payload.orders().size();
}
private FirstLegLoadingPlanCompletedV1 parsePayload(EventEnvelope envelope) {
try { return objectMapper.treeToValue(envelope.payload(),FirstLegLoadingPlanCompletedV1.class); }
try {
ObjectMapper compatibleMapper = objectMapper.copy()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
return compatibleMapper.treeToValue(envelope.payload(), FirstLegLoadingPlanCompletedV1.class);
}
catch(Exception exception) { throw new IllegalArgumentException("尾程事件负载无效",exception); }
}
@@ -65,22 +81,33 @@ public class LoadingCompletedConsumer {
}
private void createOrder(EventEnvelope event,FirstLegLoadingPlanCompletedV1 payload,
FirstLegLoadingPlanCompletedV1.Order source,String orderNo) {
String orderHash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(objectMapper.valueToTree(source)));
FirstLegLoadingPlanCompletedV1.Order source,String orderNo, JsonNode rawSource) {
String orderHash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(rawSource));
LastlegOrder existing=orderMapper.findSource(event.tenantId(),event.sourceRegion().name(),source.sourceInboundId());
if(existing!=null) {
if(!Objects.equals(existing.getSourcePayloadHash(),orderHash)) throw new IllegalStateException("来源入仓单快照冲突: "+source.sourceInboundId());
return;
}
LastlegOrder order=new LastlegOrder();
order.setId(IdWorker.getId()); order.setOrderNo(orderNo); order.setTenantId(event.tenantId()); order.setOrderType("FIRST_LEG");
String orderType = rawSource.path("orderType").asText(event.payload().path("orderType").asText("FIRST_LEG"));
String customerId = rawSource.path("customerId")
.asText(event.payload().path("customerId").asText(event.tenantId()));
if (!event.tenantId().equals(customerId)) {
throw new IllegalArgumentException("Order customerId does not match the trusted event customer");
}
List<DropshipRequests.Pallet> dropshipPallets = "ONE_CLICK_DROPSHIP".equals(orderType)
? pallets(rawSource) : List.of();
if ("ONE_CLICK_DROPSHIP".equals(orderType) && dropshipPallets.isEmpty()) {
throw new IllegalArgumentException("One-click dropship event has no pallet snapshot");
}
order.setId(IdWorker.getId()); order.setOrderNo(orderNo); order.setCustomerId(customerId); order.setOrderType(orderType);
order.setInboundCode(source.inboundCode()); order.setSourceRegion(event.sourceRegion().name());
order.setSourceInboundId(source.sourceInboundId()); order.setRootInboundId(source.rootInboundId());
order.setSourceOrderId(source.sourceOrderId()); order.setSourceOrderNo(source.sourceOrderNo()); order.setRootOrderId(source.rootOrderId());
order.setOverseasWarehouseId(payload.warehouseId()); order.setOverseasWarehouseName(payload.warehouseName());
order.setFbaWarehouseCode(source.destinationWarehouseCode()); order.setContainerNo(payload.containerNo());
order.setContainerStatus("待转柜"); order.setAbnormal(false); order.setDetained(false);
order.setPackageCount(source.packageCount()); order.setPalletCount(Objects.requireNonNullElse(source.palletCount(),0));
order.setPackageCount(source.packageCount()); order.setPalletCount(dropshipPallets.size());
order.setProductQuantity(source.productQuantity()); order.setVolumeCbm(source.volumeCbm());
order.setVolumeWeightKg(source.items().stream().map(FirstLegLoadingPlanCompletedV1.Item::dimensionalWeightKg)
.filter(Objects::nonNull).reduce(BigDecimal.ZERO,BigDecimal::add));
@@ -110,5 +137,57 @@ public class LoadingCompletedConsumer {
LastlegOrderStatusHistory history=new LastlegOrderStatusHistory();
history.setOrderId(order.getId()); history.setToStatus("PENDING_ASSIGNMENT"); history.setEventId(event.eventId());
history.setRemark("头程装柜完成自动生成"); history.setCreatedAt(LocalDateTime.now()); historyMapper.insert(history);
if ("ONE_CLICK_DROPSHIP".equals(orderType)) {
long warehouseId;
try { warehouseId = Long.parseLong(payload.warehouseId()); }
catch (Exception exception) { throw new IllegalArgumentException("One-click dropship warehouseId is invalid", exception); }
dropshipOrders.receive(new DropshipRequests.Receive(customerId, order.getId(), warehouseId,
event.eventId() + ":DROPSHIP", dropshipPallets));
}
}
private List<DropshipRequests.Pallet> pallets(JsonNode source) {
List<DropshipRequests.Pallet> result = new ArrayList<>();
JsonNode nodes = source.path("pallets");
if (nodes.isArray()) {
for (JsonNode pallet : nodes) {
String palletNo = pallet.path("palletNo").asText(pallet.path("sourcePalletNo").asText(null));
List<DropshipRequests.SkuPackages> skuRows = skuPackages(pallet);
if (palletNo != null && !palletNo.isBlank() && !skuRows.isEmpty()) {
result.add(new DropshipRequests.Pallet(palletNo, skuRows));
}
}
return result;
}
String palletNo = source.path("palletNo").asText(source.path("sourcePalletNo").asText(null));
List<DropshipRequests.SkuPackages> skuRows = skuPackages(source);
if (palletNo != null && !palletNo.isBlank() && !skuRows.isEmpty()) {
result.add(new DropshipRequests.Pallet(palletNo, skuRows));
}
return result;
}
private List<DropshipRequests.SkuPackages> skuPackages(JsonNode source) {
List<DropshipRequests.SkuPackages> result = new ArrayList<>();
JsonNode nodes = source.path("skuPackages");
if (!nodes.isArray()) nodes = source.path("items");
if (!nodes.isArray()) return result;
for (JsonNode item : nodes) {
String sku = item.path("skuCode").asText(null);
long packageCount = item.path("packageCount").asLong(0);
long productQuantity = item.path("productQuantity").asLong(0);
JsonNode markNodes = item.path("cartonMarks");
if (!markNodes.isArray()) markNodes = item.path("boxMarks");
if (!markNodes.isArray()) markNodes = item.path("cartonNos");
List<String> marks = new ArrayList<>();
if (markNodes.isArray()) {
for (JsonNode mark : markNodes) if (!mark.asText().isBlank()) marks.add(mark.asText());
}
if (sku != null && !sku.isBlank() && packageCount > 0 && productQuantity > 0 && !marks.isEmpty()) {
String productName = item.path("productName").asText(item.path("productNameZh").asText(null));
result.add(new DropshipRequests.SkuPackages(sku, productName, packageCount, productQuantity, marks));
}
}
return result;
}
}
@@ -0,0 +1,4 @@
package com.baiyue.lastleg.transfer;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
@Configuration @MapperScan("com.baiyue.lastleg.transfer.mapper") public class TransferMapperConfiguration {}

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