Test #1

Merged
admin merged 1726 commits from test into main 2026-09-17 11:52:45 +08:00
36 changed files with 1045 additions and 100 deletions
Showing only changes of commit bbd303cd65 - Show all commits
@@ -16194,6 +16194,41 @@ WHERE role.tenant_id=0 AND role.app_code='PLATFORM'
AND UPPER(role.role_code) IN ('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN')
AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0;
-- 海外智能排车草稿锁:拖入即锁定,移除或关闭草稿即释放。
CREATE DATABASE IF NOT EXISTS baiyue_lastleg
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE baiyue_lastleg;
CREATE TABLE IF NOT EXISTS ll_dispatch_pallet_lock (
id BIGINT NOT NULL COMMENT '主键ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
source_pallet_key VARCHAR(160) NOT NULL COMMENT '来源托盘业务键',
active_key VARCHAR(160) NULL COMMENT '生效中的资源排他键',
plan_id BIGINT NULL COMMENT '入仓计划ID',
plan_pallet_id BIGINT NULL COMMENT '计划托盘ID',
warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID',
dispatch_order_id BIGINT NULL COMMENT '正式派车单ID,草稿锁阶段为空',
draft_token VARCHAR(64) NULL COMMENT '智能排车草稿随机标识',
operator_id VARCHAR(128) NULL COMMENT '草稿锁操作人ID',
status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '业务状态',
locked_at DATETIME(3) NOT NULL COMMENT '锁定时间',
expires_at DATETIME(3) NULL COMMENT '草稿锁失效时间,正式派车锁为空',
released_at DATETIME(3) NULL COMMENT '释放时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_dispatch_active_pallet (customer_id,active_key),
KEY idx_ll_dispatch_pallet_lock_order (customer_id,dispatch_order_id,status),
KEY idx_ll_dispatch_pallet_lock_source (customer_id,source_pallet_key,status)
) ENGINE=InnoDB COMMENT='派车托盘排他锁';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='draft_token'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN draft_token VARCHAR(64) NULL COMMENT ''智能排车草稿随机标识'' AFTER dispatch_order_id'));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='operator_id'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN operator_id VARCHAR(128) NULL COMMENT ''草稿锁操作人ID'' AFTER draft_token'));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='expires_at'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN expires_at DATETIME(3) NULL COMMENT ''草稿锁失效时间,正式派车锁为空'' AFTER locked_at'));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='dispatch_order_id' AND is_nullable='YES'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN dispatch_order_id BIGINT NULL COMMENT ''正式派车单ID,草稿锁阶段为空'''));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
CALL baiyue_firstleg.assert_unified_final_schema();
DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema;
SELECT 'PASS' AS migration_status,
+1 -1
View File
@@ -41,7 +41,7 @@
- 四期在本服务内只进入尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、海外采购和本土卡车;本土散板新增实现也归本服务的独立稳定子域。
- `dispatch/`、`delivery/`、出库单、PDA 出库拣货、派送和其他库存离仓能力虽然已存在于服务代码或表结构中,但不属于当前四期开发与验收范围,不得因代码已存在而自动纳入。
- 历史排车编辑/取消/出库、PDA 出库和 `delivery/` Controller 默认不注册;只有后续阶段明确启用 `lastleg.future-outbound-api.enabled=true` 才可恢复。平台排车列表与详情由 `lastleg.dispatch-query-api.enabled=true` 独立启用;入库单智能排车候选和预排创建由 `lastleg.smart-dispatch-api.enabled=true` 独立启用。四个排车平台入口均不接收 `customerId`:排车列表/详情从既有排车数据解析范围,智能排车候选根据已选入库计划 ID、预排创建根据所选托盘 ID 在服务端解析并校验唯一客户范围;预排只能创建待出库排车并锁定托盘,不得借此开放编辑、取消、depart、PDA 出库或派送。四期平台库存 Controller 不映射 allocate、unallocate、pick、ship。
- 历史排车编辑/取消/出库、PDA 出库和 `delivery/` Controller 默认不注册;只有后续阶段明确启用 `lastleg.future-outbound-api.enabled=true` 才可恢复。平台排车列表与详情由 `lastleg.dispatch-query-api.enabled=true` 独立启用;入库单智能排车候选、草稿锁定/解锁/释放和预排创建由 `lastleg.smart-dispatch-api.enabled=true` 独立启用。这些排车平台入口均不接收 `customerId`:排车列表跨客户分页,详情按全局排车单号定位后使用记录自身客户范围加载明细;智能排车候选未传计划 ID 时跨客户查询,传入时根据计划 ID 解析范围;托盘移入草稿时立即以操作人和随机草稿标识锁定,移除或关闭时释放,草稿锁超时失效;预排创建根据所选托盘 ID 在服务端解析并校验唯一客户范围,并将当前操作人的草稿锁原子转换为正式排车锁。预排只能创建待出库排车并锁定托盘,不得借此开放编辑、取消、depart、PDA 出库或派送。四期平台库存 Controller 不映射 allocate、unallocate、pick、ship。
- 海外仓库、库位、实物托盘和资源锁归 `warehouse-overseas-service/`;浏览器 API 统一经 `gateway-overseas/`,本服务不得复制仓储资源主数据或另建公网认证入口。
## 数据库表索引与注释约束
@@ -0,0 +1,39 @@
package com.baiyue.lastleg.discard.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.discard.dto.CargoDiscardRequests;
import com.baiyue.lastleg.discard.dto.CargoDiscardResponses;
import com.baiyue.lastleg.discard.service.CargoDiscardService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@RestController
@RequestMapping("/api/merchant/v1/last-leg/cargo-discards")
public class MerchantCargoDiscardController {
@Resource private CargoDiscardService service;
@GetMapping("/inventory-candidates")
public ApiResponse<List<CargoDiscardResponses.Candidate>> candidates(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String inboundCode) { return ApiResponse.success(service.candidates(customer(user), inboundCode)); }
@PostMapping
public ApiResponse<CargoDiscardResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody CargoDiscardRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); }
@GetMapping
public ApiResponse<PageResult<CargoDiscardResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid CargoDiscardRequests.Query query) { return ApiResponse.success(service.page(customer(user), query)); }
@GetMapping("/{discardNo}")
public ApiResponse<CargoDiscardResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String discardNo) { return ApiResponse.success(service.detail(customer(user), discardNo)); }
private String customer(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required");
return String.valueOf(user.tenantId());
}
private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); }
}
@@ -35,7 +35,7 @@ public class DispatchQueryController {
@AuthenticationPrincipal AuthenticatedUser user,
@Valid DispatchRequests.Query query) {
requirePlatform(user);
return ApiResponse.success(service.page(service.resolveSingleCustomerScope(), query));
return ApiResponse.success(service.platformPage(query));
}
@GetMapping("/{dispatchNo}")
@@ -45,7 +45,7 @@ public class DispatchQueryController {
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo) {
requirePlatform(user);
return ApiResponse.success(service.detail(service.resolveSingleCustomerScope(), dispatchNo));
return ApiResponse.success(service.platformDetail(dispatchNo));
}
private void requirePlatform(AuthenticatedUser user) {
@@ -20,6 +20,7 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@@ -38,13 +39,30 @@ public class SmartDispatchController {
description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST)
public ApiResponse<List<DispatchPalletCandidate>> candidates(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam List<Long> planIds,
@RequestParam(required = false) List<Long> planIds,
@RequestParam(required = false) String orderNo,
@RequestParam(required = false) String fbaWarehouseCode,
@RequestParam(required = false) String sourceStage) {
@RequestParam(required = false) String sourceStage,
@RequestParam(required = false) String draftToken) {
requirePlatform(user);
if (planIds == null || planIds.isEmpty()) {
return ApiResponse.success(service.platformCandidates(orderNo, fbaWarehouseCode, sourceStage, draftToken));
}
return ApiResponse.success(service.candidates(service.resolveCustomerScopeForPlans(planIds), planIds, orderNo,
fbaWarehouseCode, sourceStage));
fbaWarehouseCode, sourceStage, draftToken));
}
@GetMapping("/drafts")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')")
public ApiResponse<List<com.baiyue.lastleg.dispatch.dto.DispatchDraftResponse>> drafts(@AuthenticationPrincipal AuthenticatedUser user) {
requirePlatform(user); return ApiResponse.success(service.activeDrafts(operatorId(user)));
}
@PostMapping("/drafts/snapshot")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')")
public ApiResponse<Void> saveDraft(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody DispatchRequests.DraftSnapshot request) {
requirePlatform(user); service.saveDraftSnapshot(operatorId(user), request); return ApiResponse.success(null);
}
@PostMapping("/pre-arrange")
@@ -60,6 +78,45 @@ public class SmartDispatchController {
return ApiResponse.success(service.preArrange(scopedCustomer, operatorId, request));
}
@PostMapping("/draft-locks/lock")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')")
@Operation(summary = "Immediately lock one pallet in the current intelligent dispatch draft")
public ApiResponse<Void> lockDraftPallet(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody DispatchRequests.DraftPallet request) {
requirePlatform(user);
String customerId = service.resolveCustomerScopeForPallets(List.of(request.planPalletId()));
service.lockDraftPallet(customerId, operatorId(user), request);
return ApiResponse.success(null);
}
@PostMapping("/draft-locks/unlock")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')")
@Operation(summary = "Unlock one pallet removed from the current intelligent dispatch draft")
public ApiResponse<Void> unlockDraftPallet(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody DispatchRequests.DraftPallet request) {
requirePlatform(user);
String customerId = service.resolveCustomerScopeForPallets(List.of(request.planPalletId()));
service.unlockDraftPallet(customerId, operatorId(user), request);
return ApiResponse.success(null);
}
@PostMapping("/draft-locks/release")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')")
@Operation(summary = "Release every pallet lock held by the current intelligent dispatch draft")
public ApiResponse<Void> releaseDraft(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody DispatchRequests.DraftRelease request) {
requirePlatform(user);
service.releaseDraft(operatorId(user), request.draftToken());
return ApiResponse.success(null);
}
private String operatorId(AuthenticatedUser user) {
return user.userId() == null ? user.username() : String.valueOf(user.userId());
}
private void requirePlatform(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
@@ -0,0 +1,4 @@
package com.baiyue.lastleg.dispatch.dto;
public record DispatchDraftResponse(String draftToken, String planIdsJson, String containersJson) {
}
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.dispatch.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
@@ -21,7 +22,23 @@ public final class DispatchRequests {
public record PreArrange(@NotEmpty @Size(max = 500) List<@NotNull Long> planPalletIds,
@Size(max = 255) String route, @Size(max = 64) String fbaAppointmentNo,
@Size(max = 32) String fbaAppointmentStatus,
@Size(max = 32) String vehicleAppointmentStatus) {
@Size(max = 32) String vehicleAppointmentStatus,
@Size(max = 64) String draftToken) {
public PreArrange(List<Long> planPalletIds, String route, String fbaAppointmentNo,
String fbaAppointmentStatus, String vehicleAppointmentStatus) {
this(planPalletIds, route, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, null);
}
}
public record DraftPallet(@NotNull Long planPalletId, @NotBlank @Size(max = 64) String draftToken) {
}
public record DraftRelease(@NotBlank @Size(max = 64) String draftToken) {
}
public record DraftSnapshot(@NotBlank @Size(max = 64) String draftToken,
@NotEmpty @Size(max = 500) List<@NotNull Long> planIds,
@NotBlank @Size(max = 20000) String containersJson) {
}
public record Update(@Size(max = 64) String vehiclePlate, @Size(max = 128) String driverName,
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.dispatch.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class DispatchDraft {
@TableId(type = IdType.INPUT)
private Long id;
private String draftToken;
private String customerId;
private String operatorId;
private String planIdsJson;
private String containersJson;
private String status;
private LocalDateTime expiresAt;
private LocalDateTime releasedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -1,7 +1,9 @@
package com.baiyue.lastleg.dispatch.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@@ -16,13 +18,19 @@ public class DispatchPalletLock {
private String customerId;
private String sourcePalletKey;
/** Equals sourcePalletKey while ACTIVE and is null after release; unique index enforces exclusivity. */
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String activeKey;
private Long planId;
private Long planPalletId;
private Long warehousePalletId;
private Long dispatchOrderId;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String draftToken;
private String operatorId;
private String status;
private LocalDateTime lockedAt;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private LocalDateTime expiresAt;
private LocalDateTime releasedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@@ -9,12 +9,6 @@ import java.util.List;
@Mapper
public interface DispatchCandidateMapper {
@Select("SELECT customer_id FROM (" +
"SELECT customer_id FROM ll_inbound_plan UNION SELECT customer_id FROM ll_dispatch_order" +
") dispatch_customers WHERE customer_id IS NOT NULL AND customer_id != '' " +
"ORDER BY customer_id LIMIT 2")
List<String> findCustomerScopes();
@Select("<script>" +
"SELECT DISTINCT customer_id FROM ll_inbound_plan WHERE id IN " +
"<foreach collection='planIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
@@ -32,35 +26,51 @@ public interface DispatchCandidateMapper {
@Select("<script>" +
"SELECT p.plan_id AS planId,p.id AS planPalletId,p.warehouse_pallet_id AS warehousePalletId," +
"MIN(l.order_id) AS orderId,MIN(o.overseas_warehouse_id) AS overseasWarehouseId,MIN(o.overseas_warehouse_name) AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
"MIN(l.order_id) AS orderId," +
"MIN(ip.customer_id) AS customerId,MIN(o.customer_name) AS customerName," +
"MIN(ip.plan_no) AS planNo,MIN(o.order_type) AS orderType,MIN(o.volume_weight_kg) AS volumeWeightKg," +
"MIN(ip.container_no) AS containerNo," +
"(SELECT MAX(fa.appointment_at) FROM ll_inbound_fba_appointment_item fi " +
"JOIN ll_inbound_fba_appointment fa ON fa.id=fi.appointment_id WHERE fi.line_id IN " +
"(SELECT pa.line_id FROM ll_inbound_plan_pallet_allocation pa WHERE pa.pallet_id=p.id)) AS appointmentAt," +
"(SELECT MAX(fa.expected_loading_at) FROM ll_inbound_fba_appointment_item fi " +
"JOIN ll_inbound_fba_appointment fa ON fa.id=fi.appointment_id WHERE fi.line_id IN " +
"(SELECT pa.line_id FROM ll_inbound_plan_pallet_allocation pa WHERE pa.pallet_id=p.id)) AS expectedLoadingAt," +
"MIN(o.overseas_warehouse_id) AS overseasWarehouseId,MIN(o.overseas_warehouse_name) AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
"GROUP_CONCAT(DISTINCT o.order_no ORDER BY o.order_no SEPARATOR ',') AS orderRefs,MIN(l.inbound_code) AS inboundCode," +
"p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," +
"p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," +
"MIN(l.fba_warehouse_code) AS fbaWarehouseCode," +
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN COALESCE((SELECT SUM(pos.available_packages) " +
"FROM ll_inventory_position pos WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id),0) " +
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id 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 " +
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," +
"(SELECT GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') " +
"FROM ll_inbound_plan_carton c WHERE c.plan_pallet_id=p.id) AS cartonMarks " +
"FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " +
"LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " +
"LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id " +
"LEFT JOIN ll_order o ON o.id=l.order_id " +
"WHERE ip.customer_id=#{customerId} AND p.plan_id IN " +
"<foreach collection='planIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"WHERE 1=1 " +
"<if test='customerId != null and customerId != \"\"'> AND ip.customer_id=#{customerId}</if> " +
"<if test='planIds != null and planIds.size() > 0'> AND p.plan_id IN " +
"<foreach collection='planIds' item='id' open='(' separator=',' close=')'>#{id}</foreach></if> " +
"<if test='orderNo != null and orderNo != \"\"'> AND o.order_no LIKE CONCAT('%',#{orderNo},'%')</if> " +
"<if test='fbaWarehouseCode != null and fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code=#{fbaWarehouseCode}</if> " +
"<if test='sourceStage != null and sourceStage != \"\"'> AND " +
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END=#{sourceStage}</if> " +
"AND (p.warehouse_pallet_id IS NULL OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
"WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0)) " +
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0)) " +
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " +
"WHERE d.customer_id=ip.customer_id AND d.status='ACTIVE' AND (" +
"WHERE d.customer_id=ip.customer_id AND d.status='ACTIVE' " +
"AND (d.expires_at IS NULL OR d.expires_at &gt; CURRENT_TIMESTAMP(3)) 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)))) " +
"d.source_pallet_key=CONCAT('RECEIVED:',p.warehouse_pallet_id))) " +
"AND (#{draftToken} IS NULL OR d.draft_token IS NULL OR d.draft_token != #{draftToken})) " +
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
"JOIN ll_inventory_position tp ON tp.id=ta.inventory_position_id " +
"WHERE ta.customer_id=#{customerId} AND ta.status='RESERVED' AND tp.pallet_id=p.warehouse_pallet_id) " +
"WHERE ta.customer_id=ip.customer_id AND ta.status='RESERVED' AND tp.pallet_id=p.warehouse_pallet_id) " +
"GROUP BY p.plan_id,p.id,p.warehouse_pallet_id,p.pallet_no,p.destination_warehouse_id," +
"p.destination_warehouse_code,p.destination_warehouse_name,p.allocated_volume_cbm " +
"ORDER BY p.plan_id,p.sequence_no" +
@@ -69,11 +79,22 @@ public interface DispatchCandidateMapper {
@Param("planIds") List<Long> planIds,
@Param("orderNo") String orderNo,
@Param("fbaWarehouseCode") String fbaWarehouseCode,
@Param("sourceStage") String sourceStage);
@Param("sourceStage") String sourceStage,
@Param("draftToken") String draftToken);
@Select("<script>" +
"SELECT p.plan_id AS planId,p.id AS planPalletId,p.warehouse_pallet_id AS warehousePalletId," +
"MIN(l.order_id) AS orderId,MIN(o.overseas_warehouse_id) AS overseasWarehouseId,MIN(o.overseas_warehouse_name) AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
"MIN(l.order_id) AS orderId," +
"MIN(ip.customer_id) AS customerId,MIN(o.customer_name) AS customerName," +
"MIN(ip.plan_no) AS planNo,MIN(o.order_type) AS orderType,MIN(o.volume_weight_kg) AS volumeWeightKg," +
"MIN(ip.container_no) AS containerNo," +
"(SELECT MAX(fa.appointment_at) FROM ll_inbound_fba_appointment_item fi " +
"JOIN ll_inbound_fba_appointment fa ON fa.id=fi.appointment_id WHERE fi.line_id IN " +
"(SELECT pa.line_id FROM ll_inbound_plan_pallet_allocation pa WHERE pa.pallet_id=p.id)) AS appointmentAt," +
"(SELECT MAX(fa.expected_loading_at) FROM ll_inbound_fba_appointment_item fi " +
"JOIN ll_inbound_fba_appointment fa ON fa.id=fi.appointment_id WHERE fi.line_id IN " +
"(SELECT pa.line_id FROM ll_inbound_plan_pallet_allocation pa WHERE pa.pallet_id=p.id)) AS expectedLoadingAt," +
"MIN(o.overseas_warehouse_id) AS overseasWarehouseId,MIN(o.overseas_warehouse_name) AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
"GROUP_CONCAT(DISTINCT o.order_no ORDER BY o.order_no SEPARATOR ',') AS orderRefs,MIN(l.inbound_code) AS inboundCode," +
"p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," +
"p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," +
@@ -82,13 +103,17 @@ public interface DispatchCandidateMapper {
"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 " +
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," +
"(SELECT GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') " +
"FROM ll_inbound_plan_carton c WHERE c.plan_pallet_id=p.id) AS cartonMarks " +
"FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " +
"LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " +
"LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id " +
"LEFT JOIN ll_order o ON o.id=l.order_id " +
"WHERE ip.customer_id=#{customerId} AND p.id IN " +
"<foreach collection='planPalletIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"AND (p.warehouse_pallet_id IS NULL OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
"WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0)) " +
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
"JOIN ll_inventory_position tp ON tp.id=ta.inventory_position_id " +
"WHERE ta.customer_id=#{customerId} AND ta.status='RESERVED' AND tp.pallet_id=p.warehouse_pallet_id) " +
@@ -0,0 +1,18 @@
package com.baiyue.lastleg.dispatch.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.dispatch.entity.DispatchDraft;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface DispatchDraftMapper extends BaseMapper<DispatchDraft> {
@Select("SELECT * FROM ll_dispatch_draft WHERE operator_id=#{operatorId} AND status='ACTIVE' AND (expires_at IS NULL OR expires_at>CURRENT_TIMESTAMP(3)) ORDER BY updated_at DESC")
List<DispatchDraft> findActiveByOperator(@Param("operatorId") String operatorId);
@Select("SELECT * FROM ll_dispatch_draft WHERE draft_token=#{draftToken} AND operator_id=#{operatorId} AND status='ACTIVE' LIMIT 1 FOR UPDATE")
DispatchDraft lockActive(@Param("draftToken") String draftToken, @Param("operatorId") String operatorId);
}
@@ -6,11 +6,16 @@ import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface DispatchOrderMapper extends BaseMapper<DispatchOrder> {
@Select("SELECT * FROM ll_dispatch_order WHERE customer_id=#{customerId} AND dispatch_no=#{dispatchNo} LIMIT 1")
DispatchOrder findByDispatchNo(@Param("customerId") String customerId, @Param("dispatchNo") String dispatchNo);
@Select("SELECT * FROM ll_dispatch_order WHERE dispatch_no=#{dispatchNo} ORDER BY customer_id LIMIT 2")
List<DispatchOrder> findAcrossCustomersByDispatchNo(@Param("dispatchNo") String dispatchNo);
@Select("SELECT * FROM ll_dispatch_order WHERE customer_id=#{customerId} AND id=#{id} LIMIT 1 FOR UPDATE")
DispatchOrder lockById(@Param("customerId") String customerId, @Param("id") Long id);
}
@@ -5,6 +5,7 @@ import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface DispatchPalletLockMapper extends BaseMapper<DispatchPalletLock> {
@@ -12,4 +13,9 @@ public interface DispatchPalletLockMapper extends BaseMapper<DispatchPalletLock>
"AND status='ACTIVE' LIMIT 1 FOR UPDATE")
DispatchPalletLock lockActive(@Param("customerId") String customerId,
@Param("sourcePalletKey") String sourcePalletKey);
@Update("UPDATE ll_dispatch_pallet_lock SET active_key=NULL,updated_at=CURRENT_TIMESTAMP(3) " +
"WHERE customer_id=#{customerId} AND active_key=#{activeKey} AND status!='ACTIVE'")
int clearInactiveActiveKey(@Param("customerId") String customerId,
@Param("activeKey") String activeKey);
}
@@ -1,12 +1,21 @@
package com.baiyue.lastleg.dispatch.model;
import java.math.BigDecimal;
import java.time.LocalDateTime;
public record DispatchPalletCandidate(
Long planId,
Long planPalletId,
Long warehousePalletId,
Long orderId,
String customerId,
String customerName,
String planNo,
String orderType,
BigDecimal volumeWeightKg,
String containerNo,
LocalDateTime appointmentAt,
LocalDateTime expectedLoadingAt,
String overseasWarehouseId,
String overseasWarehouseName,
String orderNo,
@@ -19,16 +28,29 @@ public record DispatchPalletCandidate(
String fbaWarehouseCode,
Integer packageCount,
BigDecimal volumeCbm,
String sourceStage
String sourceStage,
String cartonMarks
) {
public DispatchPalletCandidate(Long planId, Long planPalletId, Long warehousePalletId, Long orderId,
String overseasWarehouseId, String overseasWarehouseName, String orderNo,
String orderRefs, String inboundCode, String palletNo, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
String fbaWarehouseCode, Integer packageCount, BigDecimal volumeCbm,
String sourceStage) {
this(planId, planPalletId, warehousePalletId, orderId, null, null, null, null, null, null, null, null, overseasWarehouseId,
overseasWarehouseName, orderNo, orderRefs, inboundCode, palletNo, destinationWarehouseId,
destinationWarehouseCode, destinationWarehouseName, fbaWarehouseCode, packageCount, volumeCbm,
sourceStage, null);
}
public DispatchPalletCandidate(Long planId, Long planPalletId, Long warehousePalletId, Long orderId,
String orderNo, String orderRefs, String inboundCode, String palletNo,
String destinationWarehouseId, String destinationWarehouseCode,
String destinationWarehouseName, String fbaWarehouseCode, Integer packageCount,
BigDecimal volumeCbm, String sourceStage) {
this(planId, planPalletId, warehousePalletId, orderId, null, null, orderNo, orderRefs, inboundCode,
this(planId, planPalletId, warehousePalletId, orderId, null, null, null, null, null, null, null, null, null, null, orderNo, orderRefs, inboundCode,
palletNo, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName,
fbaWarehouseCode, packageCount, volumeCbm, sourceStage);
fbaWarehouseCode, packageCount, volumeCbm, sourceStage, null);
}
public String sourcePalletKey() {
// A plan pallet keeps the same lock identity before and after the physical pallet is received.
@@ -9,10 +9,13 @@ import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock;
import com.baiyue.lastleg.dispatch.entity.DispatchDraft;
import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchDraftMapper;
import com.baiyue.lastleg.dispatch.dto.DispatchDraftResponse;
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
@@ -39,17 +42,9 @@ public class DispatchService {
@Resource private DispatchOrderMapper orders;
@Resource private DispatchItemMapper items;
@Resource private DispatchPalletLockMapper locks;
@Resource private DispatchDraftMapper drafts;
@Resource private DispatchCandidateMapper candidates;
public String resolveSingleCustomerScope() {
List<String> customerIds = candidates.findCustomerScopes();
if (customerIds.size() > 1) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Dispatch APIs require exactly one customer in the current deployment");
}
return customerIds.isEmpty() ? null : customerIds.get(0);
}
public String resolveCustomerScopeForPlans(List<Long> planIds) {
return requireSingleCustomerScope(candidates.findCustomerScopesByPlanIds(normalizeIds(planIds, "Plan IDs")),
"Selected inbound plans");
@@ -62,14 +57,100 @@ public class DispatchService {
}
public List<DispatchPalletCandidate> candidates(String customerId, List<Long> planIds, 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");
}
String fbaWarehouseCode, String sourceStage, String draftToken) {
validateSourceStage(sourceStage);
if (!StringUtils.hasText(customerId)) return List.of();
List<Long> scopedPlanIds = normalizeIds(planIds, "Plan IDs");
return candidates.available(customerId, scopedPlanIds, trimToNull(orderNo), trimToNull(fbaWarehouseCode),
trimToNull(sourceStage));
trimToNull(sourceStage), trimToNull(draftToken));
}
public List<DispatchPalletCandidate> platformCandidates(String orderNo, String fbaWarehouseCode,
String sourceStage, String draftToken) {
validateSourceStage(sourceStage);
return candidates.available(null, null, trimToNull(orderNo), trimToNull(fbaWarehouseCode),
trimToNull(sourceStage), trimToNull(draftToken));
}
public List<DispatchDraftResponse> activeDrafts(String operatorId) {
return drafts.findActiveByOperator(trimToNull(operatorId)).stream()
.map(item -> new DispatchDraftResponse(item.getDraftToken(), item.getPlanIdsJson(), item.getContainersJson())).toList();
}
@Transactional
public void saveDraftSnapshot(String operatorId, DispatchRequests.DraftSnapshot request) {
String customerId = resolveCustomerScopeForPlans(request.planIds());
LocalDateTime now = LocalDateTime.now();
DispatchDraft draft = drafts.lockActive(request.draftToken(), trimToNull(operatorId));
if (draft == null) {
draft = new DispatchDraft(); draft.setId(IdWorker.getId()); draft.setDraftToken(request.draftToken());
draft.setOperatorId(trimToNull(operatorId)); draft.setCustomerId(customerId); draft.setStatus("ACTIVE");
draft.setCreatedAt(now); draft.setExpiresAt(now.plusMinutes(30));
}
draft.setPlanIdsJson(request.planIds().stream().map(String::valueOf).collect(Collectors.joining(",")));
draft.setContainersJson(request.containersJson()); draft.setUpdatedAt(now); draft.setExpiresAt(now.plusMinutes(30));
drafts.insertOrUpdate(draft);
}
@Transactional
public void lockDraftPallet(String customerId, String operatorId, DispatchRequests.DraftPallet request) {
DispatchPalletCandidate candidate = requireDraftCandidate(customerId, request.planPalletId());
LocalDateTime now = LocalDateTime.now();
DispatchPalletLock active = activeLock(customerId, candidate);
if (active != null && active.getExpiresAt() != null && !active.getExpiresAt().isAfter(now)) {
releaseLock(active, now);
active = null;
}
if (active != null) {
if (request.draftToken().equals(active.getDraftToken())
&& Objects.equals(trimToNull(operatorId), active.getOperatorId())) return;
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Pallet is already locked by another dispatch draft: " + candidate.palletNo());
}
// Older releases did not persist active_key=NULL; clear that inactive unique-key residue before relocking.
locks.clearInactiveActiveKey(customerId, candidate.sourcePalletKey());
DispatchPalletLock lock = new DispatchPalletLock();
lock.setId(IdWorker.getId());
lock.setCustomerId(customerId);
lock.setSourcePalletKey(candidate.sourcePalletKey());
lock.setActiveKey(candidate.sourcePalletKey());
lock.setPlanId(candidate.planId());
lock.setPlanPalletId(candidate.planPalletId());
lock.setWarehousePalletId(candidate.warehousePalletId());
lock.setDraftToken(request.draftToken());
lock.setOperatorId(trimToNull(operatorId));
lock.setStatus("ACTIVE");
lock.setLockedAt(now);
lock.setExpiresAt(now.plusMinutes(30));
lock.setCreatedAt(now);
lock.setUpdatedAt(now);
try {
locks.insert(lock);
} catch (DuplicateKeyException exception) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Pallet is already locked by another dispatch draft: " + candidate.palletNo(), exception);
}
}
@Transactional
public void unlockDraftPallet(String customerId, String operatorId, DispatchRequests.DraftPallet request) {
DispatchPalletCandidate candidate = requireDraftCandidate(customerId, request.planPalletId());
DispatchPalletLock active = activeLock(customerId, candidate);
if (active == null) return;
requireDraftOwner(active, operatorId, request.draftToken());
releaseLock(active, LocalDateTime.now());
}
@Transactional
public void releaseDraft(String operatorId, String draftToken) {
List<DispatchPalletLock> activeLocks = locks.selectList(new LambdaQueryWrapper<DispatchPalletLock>()
.eq(DispatchPalletLock::getDraftToken, draftToken)
.eq(DispatchPalletLock::getOperatorId, trimToNull(operatorId))
.eq(DispatchPalletLock::getStatus, "ACTIVE"));
LocalDateTime now = LocalDateTime.now();
activeLocks.forEach(lock -> releaseLock(lock, now));
DispatchDraft draft = drafts.lockActive(draftToken, trimToNull(operatorId));
if (draft != null) { draft.setStatus("RELEASED"); draft.setReleasedAt(now); draft.setUpdatedAt(now); drafts.updateById(draft); }
}
@Transactional
@@ -86,20 +167,21 @@ public class DispatchService {
if (selected.size() != palletIds.size()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more selected pallets are not in this tenant");
}
if (selected.stream().anyMatch(candidate -> candidate.packageCount() == null || candidate.packageCount() <= 0)) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Selected pallet has no remaining packages to dispatch");
}
Set<String> warehouseIds = selected.stream().map(DispatchPalletCandidate::overseasWarehouseId)
.filter(Objects::nonNull).map(String::trim).filter(value -> !value.isEmpty()).collect(Collectors.toSet());
if (warehouseIds.size() != 1 || selected.stream().anyMatch(candidate -> candidate.overseasWarehouseId() == null)) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "A dispatch must belong to exactly one overseas warehouse");
}
String draftToken = trimToNull(request.draftToken());
for (DispatchPalletCandidate candidate : selected) {
boolean planLocked = candidate.planPalletId() != null
&& locks.lockActive(customerId, "PLAN:" + candidate.planPalletId()) != null;
boolean receivedLocked = candidate.warehousePalletId() != null
&& locks.lockActive(customerId, "RECEIVED:" + candidate.warehousePalletId()) != null;
if (planLocked || receivedLocked) {
DispatchPalletLock active = activeLock(customerId, candidate);
if (draftToken != null) {
if (active == null) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Pallet draft lock has expired: " + candidate.palletNo());
}
requireDraftOwner(active, operatorId, draftToken);
} else if (active != null) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Pallet is already locked by another active dispatch: " + candidate.palletNo());
}
@@ -129,24 +211,35 @@ public class DispatchService {
orders.insert(dispatch);
for (DispatchPalletCandidate candidate : selected) {
DispatchPalletLock lock = new DispatchPalletLock();
lock.setId(IdWorker.getId());
lock.setCustomerId(customerId);
lock.setSourcePalletKey(candidate.sourcePalletKey());
lock.setActiveKey(candidate.sourcePalletKey());
lock.setPlanId(candidate.planId());
lock.setPlanPalletId(candidate.planPalletId());
lock.setWarehousePalletId(candidate.warehousePalletId());
lock.setDispatchOrderId(dispatch.getId());
lock.setStatus("ACTIVE");
lock.setLockedAt(now);
lock.setCreatedAt(now);
lock.setUpdatedAt(now);
try {
locks.insert(lock);
} catch (DuplicateKeyException exception) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Pallet is already locked by another active dispatch: " + candidate.palletNo(), exception);
DispatchPalletLock lock = activeLock(customerId, candidate);
if (draftToken != null) {
requireDraftOwner(lock, operatorId, draftToken);
lock.setDispatchOrderId(dispatch.getId());
lock.setDraftToken(null);
lock.setExpiresAt(null);
lock.setUpdatedAt(now);
locks.updateById(lock);
} else {
lock = new DispatchPalletLock();
lock.setId(IdWorker.getId());
lock.setCustomerId(customerId);
lock.setSourcePalletKey(candidate.sourcePalletKey());
lock.setActiveKey(candidate.sourcePalletKey());
lock.setPlanId(candidate.planId());
lock.setPlanPalletId(candidate.planPalletId());
lock.setWarehousePalletId(candidate.warehousePalletId());
lock.setDispatchOrderId(dispatch.getId());
lock.setOperatorId(trimToNull(operatorId));
lock.setStatus("ACTIVE");
lock.setLockedAt(now);
lock.setCreatedAt(now);
lock.setUpdatedAt(now);
try {
locks.insert(lock);
} catch (DuplicateKeyException exception) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Pallet is already locked by another active dispatch: " + candidate.palletNo(), exception);
}
}
DispatchItem item = new DispatchItem();
item.setId(IdWorker.getId());
@@ -179,8 +272,18 @@ public class DispatchService {
if (!StringUtils.hasText(customerId)) {
return new PageResult<>(List.of(), query.page(), query.size(), 0, 0);
}
return page(customerId, query, false);
}
public PageResult<DispatchResponse> platformPage(DispatchRequests.Query query) {
return page(null, query, true);
}
private PageResult<DispatchResponse> page(String customerId, DispatchRequests.Query query,
boolean acrossCustomers) {
Page<DispatchOrder> page = orders.selectPage(new Page<>(query.page(), query.size()),
new LambdaQueryWrapper<DispatchOrder>().eq(DispatchOrder::getCustomerId, customerId)
new LambdaQueryWrapper<DispatchOrder>()
.eq(!acrossCustomers, 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())
@@ -197,6 +300,19 @@ public class DispatchService {
return response(dispatch, items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()));
}
public DispatchResponse platformDetail(String dispatchNo) {
List<DispatchOrder> matches = orders.findAcrossCustomersByDispatchNo(dispatchNo);
if (matches == null || matches.isEmpty()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
}
if (matches.size() > 1) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Dispatch number is not unique across customers");
}
DispatchOrder dispatch = matches.get(0);
return response(dispatch, items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()));
}
@Transactional
public DispatchResponse update(String customerId, String dispatchNo, DispatchRequests.Update request) {
DispatchOrder dispatch = orders.lockById(customerId, find(customerId, dispatchNo).getId());
@@ -279,6 +395,37 @@ public class DispatchService {
.distinct().collect(Collectors.joining(" -> "));
}
private DispatchPalletCandidate requireDraftCandidate(String customerId, Long planPalletId) {
List<DispatchPalletCandidate> found = candidates.lockSelected(customerId, List.of(planPalletId));
if (found.size() != 1) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Pallet is not available for dispatch");
}
return found.get(0);
}
private DispatchPalletLock activeLock(String customerId, DispatchPalletCandidate candidate) {
DispatchPalletLock active = locks.lockActive(customerId, candidate.sourcePalletKey());
if (active == null && candidate.warehousePalletId() != null) {
active = locks.lockActive(customerId, "RECEIVED:" + candidate.warehousePalletId());
}
return active;
}
private void requireDraftOwner(DispatchPalletLock lock, String operatorId, String draftToken) {
if (lock == null || !draftToken.equals(lock.getDraftToken())
|| !Objects.equals(trimToNull(operatorId), lock.getOperatorId())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Pallet is locked by another dispatch draft");
}
}
private void releaseLock(DispatchPalletLock lock, LocalDateTime now) {
lock.setStatus("RELEASED");
lock.setActiveKey(null);
lock.setReleasedAt(now);
lock.setUpdatedAt(now);
locks.updateById(lock);
}
private static List<String> splitOrderRefs(String refs, String fallback) {
String value = StringUtils.hasText(refs) ? refs : fallback;
return StringUtils.hasText(value) ? List.of(value.split(",")) : List.of();
@@ -292,6 +439,12 @@ public class DispatchService {
return StringUtils.hasText(value) ? value.trim() : null;
}
private static void validateSourceStage(String sourceStage) {
if (StringUtils.hasText(sourceStage) && !Set.of("PLAN", "RECEIVED").contains(sourceStage)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "sourceStage must be PLAN or RECEIVED");
}
}
private static List<Long> normalizeIds(List<Long> ids, String label) {
if (ids == null || ids.isEmpty() || ids.stream().anyMatch(Objects::isNull)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, label + " must be non-empty");
@@ -0,0 +1,35 @@
package com.baiyue.lastleg.relabel.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.relabel.dto.RelabelRequests;
import com.baiyue.lastleg.relabel.dto.RelabelResponses;
import com.baiyue.lastleg.relabel.service.RelabelApplicationService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/merchant/v1/last-leg/relabel-applications")
public class MerchantRelabelApplicationController {
@Resource private RelabelApplicationService service;
@PostMapping
public ApiResponse<RelabelResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody RelabelRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); }
@GetMapping
public ApiResponse<PageResult<RelabelResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid RelabelRequests.Query query) { return ApiResponse.success(service.page(customer(user), query)); }
@GetMapping("/{relabelNo}")
public ApiResponse<RelabelResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo) { return ApiResponse.success(service.detail(customer(user), relabelNo)); }
private String customer(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required");
return String.valueOf(user.tenantId());
}
private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); }
}
@@ -0,0 +1,47 @@
package com.baiyue.lastleg.transfer.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses;
import com.baiyue.lastleg.transfer.service.WarehouseTransferService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/merchant/v1/last-leg/warehouse-transfers")
public class MerchantWarehouseTransferController {
@Resource private WarehouseTransferService service;
@PostMapping
public ApiResponse<WarehouseTransferResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody WarehouseTransferRequests.Create request) {
return ApiResponse.success(service.create(customer(user), operator(user), request));
}
@GetMapping
public ApiResponse<PageResult<WarehouseTransferResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid WarehouseTransferRequests.Query query) {
return ApiResponse.success(service.page(customer(user), query));
}
@GetMapping("/{transferNo}")
public ApiResponse<WarehouseTransferResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String transferNo) {
return ApiResponse.success(service.detail(customer(user), transferNo));
}
private String customer(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required");
}
return String.valueOf(user.tenantId());
}
private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); }
}
@@ -1,6 +1,7 @@
package com.baiyue.lastleg.transfer.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses;
@@ -24,6 +25,12 @@ public class WarehouseTransferController {
return ApiResponse.success(service.create(scope(user, customerId), operator(user), request));
}
@GetMapping
public ApiResponse<PageResult<WarehouseTransferResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @Valid WarehouseTransferRequests.Query query) {
return ApiResponse.success(service.page(scope(user, customerId), query));
}
@GetMapping("/{transferNo}")
public ApiResponse<WarehouseTransferResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @PathVariable String transferNo) {
@@ -1,6 +1,8 @@
package com.baiyue.lastleg.transfer.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
@@ -73,4 +75,11 @@ public final class WarehouseTransferRequests {
@Positive int packageCount,
@Size(max = 255) String shippingMark
) {}
public record Query(@Min(1) long page, @Min(1) @Max(200) long size,
String transferNo, String inboundCode, String operatingWarehouseId,
String fbaWarehouseCode, String status, Boolean needRelabel,
LocalDateTime createdFrom, LocalDateTime createdTo) {
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
}
}
@@ -10,6 +10,10 @@ public final class WarehouseTransferResponses {
public record Allocation(Long inventoryPositionId, Long inboundOrderId, String inboundCode,
Long palletId, String palletNo, int packageCount, String status,
Long destinationInventoryPositionId) {}
public record Summary(Long id, String transferNo, String inboundCode, Long lastlegOrderId,
String lastlegOrderNo, String operatingWarehouseId, String fbaWarehouseCode,
int packageCount, String deliveryMethod, boolean needRelabel, String relabelNo,
String status, LocalDateTime estimatedArrivalAt, LocalDateTime createdAt) {}
public record Detail(Long id, String transferNo, Long inboundOrderId, String inboundCode,
Long lastlegOrderId, String lastlegOrderNo, String orderType, String orderStatus,
boolean needRelabel, Long relabelApplicationId, String relabelNo, String relabelStatus,
@@ -1,6 +1,8 @@
package com.baiyue.lastleg.transfer.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
@@ -158,6 +160,32 @@ public class WarehouseTransferService {
return detail(customerId, transferNo);
}
@Transactional(readOnly = true)
public PageResult<WarehouseTransferResponses.Summary> page(String customerId, WarehouseTransferRequests.Query query) {
if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) {
throw badRequest("createdFrom must not be after createdTo");
}
LambdaQueryWrapper<WarehouseTransferOrder> wrapper = new LambdaQueryWrapper<WarehouseTransferOrder>()
.eq(StringUtils.hasText(customerId), WarehouseTransferOrder::getCustomerId, customerId)
.like(StringUtils.hasText(query.transferNo()), WarehouseTransferOrder::getTransferNo, query.transferNo())
.like(StringUtils.hasText(query.inboundCode()), WarehouseTransferOrder::getInboundCode, query.inboundCode())
.eq(StringUtils.hasText(query.operatingWarehouseId()), WarehouseTransferOrder::getOperatingWarehouseId, query.operatingWarehouseId())
.like(StringUtils.hasText(query.fbaWarehouseCode()), WarehouseTransferOrder::getFbaWarehouseCode, query.fbaWarehouseCode())
.eq(StringUtils.hasText(query.status()), WarehouseTransferOrder::getStatus, query.status())
.eq(query.needRelabel() != null, WarehouseTransferOrder::getNeedRelabel, query.needRelabel())
.ge(query.createdFrom() != null, WarehouseTransferOrder::getCreatedAt, query.createdFrom())
.le(query.createdTo() != null, WarehouseTransferOrder::getCreatedAt, query.createdTo())
.orderByDesc(WarehouseTransferOrder::getCreatedAt).orderByDesc(WarehouseTransferOrder::getId);
Page<WarehouseTransferOrder> result = transfers.selectPage(Page.of(query.page(), query.size()), wrapper);
List<WarehouseTransferResponses.Summary> records = result.getRecords().stream().map(value ->
new WarehouseTransferResponses.Summary(value.getId(), value.getTransferNo(), value.getInboundCode(),
value.getLastlegOrderId(), value.getLastlegOrderNo(), value.getOperatingWarehouseId(),
value.getFbaWarehouseCode(), value.getPackageCount(), value.getDeliveryMethod(),
Boolean.TRUE.equals(value.getNeedRelabel()), value.getRelabelNo(), value.getStatus(),
value.getEstimatedArrivalAt(), value.getCreatedAt())).toList();
return new PageResult<>(records, result.getCurrent(), result.getSize(), result.getTotal(), result.getPages());
}
@Transactional(readOnly = true)
public WarehouseTransferResponses.Detail detail(String customerId, String transferNo) {
WarehouseTransferOrder transfer = transfers.findNo(customerId, transferNo);
@@ -828,9 +828,12 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_pallet_lock (
plan_id BIGINT NULL COMMENT '入仓计划ID',
plan_pallet_id BIGINT NULL COMMENT '计划托盘ID',
warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID',
dispatch_order_id BIGINT NOT NULL COMMENT '派车单ID',
dispatch_order_id BIGINT NULL COMMENT '正式派车单ID,草稿锁阶段为空',
draft_token VARCHAR(64) NULL COMMENT '智能排车草稿随机标识',
operator_id VARCHAR(128) NULL COMMENT '草稿锁操作人ID',
status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '业务状态',
locked_at DATETIME(3) NOT NULL COMMENT '锁定时间',
expires_at DATETIME(3) NULL COMMENT '草稿锁失效时间,正式派车锁为空',
released_at DATETIME(3) NULL COMMENT '释放时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
@@ -840,6 +843,15 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_pallet_lock (
KEY idx_ll_dispatch_pallet_lock_source (customer_id,source_pallet_key,status)
) ENGINE=InnoDB COMMENT='派车托盘排他锁';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='draft_token'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN draft_token VARCHAR(64) NULL COMMENT ''智能排车草稿随机标识'' AFTER dispatch_order_id'));
PREPARE dispatch_schema_stmt FROM @ddl; EXECUTE dispatch_schema_stmt; DEALLOCATE PREPARE dispatch_schema_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='operator_id'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN operator_id VARCHAR(128) NULL COMMENT ''草稿锁操作人ID'' AFTER draft_token'));
PREPARE dispatch_schema_stmt FROM @ddl; EXECUTE dispatch_schema_stmt; DEALLOCATE PREPARE dispatch_schema_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='expires_at'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN expires_at DATETIME(3) NULL COMMENT ''草稿锁失效时间,正式派车锁为空'' AFTER locked_at'));
PREPARE dispatch_schema_stmt FROM @ddl; EXECUTE dispatch_schema_stmt; DEALLOCATE PREPARE dispatch_schema_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='dispatch_order_id' AND is_nullable='YES'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN dispatch_order_id BIGINT NULL COMMENT ''正式派车单ID,草稿锁阶段为空'''));
PREPARE dispatch_schema_stmt FROM @ddl; EXECUTE dispatch_schema_stmt; DEALLOCATE PREPARE dispatch_schema_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='order_refs'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN order_refs VARCHAR(2000) NULL AFTER order_no'));
PREPARE dispatch_schema_stmt FROM @ddl; EXECUTE dispatch_schema_stmt; DEALLOCATE PREPARE dispatch_schema_stmt;
@@ -27,7 +27,7 @@ paths:
/api/platform/v1/last-leg/dispatches:
get:
summary: 分页查询排车单
description: 权限 platform:overseas-warehouse:inbound-order:list;服务端仅在当前部署恰好存在一个客户范围时查询,前端不传 customerId。
description: 权限 platform:overseas-warehouse:inbound-order:list;平台跨客户分页查询,前端不传 customerId。
security: [{ bearerAuth: [] }]
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
@@ -38,28 +38,27 @@ paths:
- { name: fbaAppointmentNo, in: query, schema: { type: string } }
responses:
'200': { description: 统一 ApiResponse 包装的排车单分页,records 元素为 DispatchResponse }
'409': { description: 当前部署检测到多个客户范围,拒绝跨客户查询 }
/api/platform/v1/last-leg/dispatches/{dispatchNo}:
get:
summary: 查询排车单详情
description: 权限 platform:overseas-warehouse:inbound-order:list;服务端解析唯一客户范围,前端不传 customerId。
description: 权限 platform:overseas-warehouse:inbound-order:list;平台按全局排车单号定位记录,并使用记录自身客户范围查询明细,前端不传 customerId。
security: [{ bearerAuth: [] }]
parameters:
- { name: dispatchNo, in: path, required: true, schema: { type: string } }
responses:
'200': { description: 统一 ApiResponse 包装的 DispatchResponse }
'404': { description: 排车单不存在 }
'409': { description: 当前部署检测到多个客户范围 }
'409': { description: 排车单号在不同客户下重复,无法唯一定位 }
/api/platform/v1/last-leg/dispatches/candidates:
get:
summary: 查询智能排车候选托盘
description: 权限 platform:overseas-warehouse:inbound-order:list;服务端解析唯一客户范围,仅返回尚未被有效排车锁定的计划或已实收托盘,前端不传 customerId。
description: 权限 platform:overseas-warehouse:inbound-order:list;planIds 为空时平台跨客户查询全部可排托盘,传入时按所选入库计划解析客户范围;仅返回尚未被有效排车锁定的计划或已实收托盘,前端不传 customerId。
security: [{ bearerAuth: [] }]
parameters:
- name: planIds
in: query
required: true
description: 已选入库计划 ID,逗号分隔;服务端据此解析并校验唯一客户范围。
required: false
description: 可选的入库计划 ID,逗号分隔;省略时平台查询全部客户候选。
schema: { type: array, minItems: 1, maxItems: 500, items: { type: integer, format: int64 } }
style: form
explode: false
@@ -80,6 +79,25 @@ paths:
content: { application/json: { schema: { $ref: '#/components/schemas/SmartDispatchPreArrange' } } }
responses:
'200': { description: 统一 ApiResponse 包装的预排车详情, content: { application/json: { schema: { $ref: '#/components/schemas/DispatchResponse' } } } }
/api/platform/v1/last-leg/dispatches/draft-locks/lock:
post:
summary: 将托盘移入智能排车草稿并立即锁定
description: 权限 platform:overseas-warehouse:inbound-order:smart-dispatch;草稿锁 30 分钟失效,与正式排车锁共享托盘排他约束。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/SmartDispatchDraftPallet' } } } }
responses: { '200': { description: 锁定成功 }, '409': { description: 托盘已被其他草稿或正式排车锁定 } }
/api/platform/v1/last-leg/dispatches/draft-locks/unlock:
post:
summary: 从智能排车草稿移除托盘并立即解锁
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/SmartDispatchDraftPallet' } } } }
responses: { '200': { description: 解锁成功 }, '409': { description: 草稿锁不属于当前操作人 } }
/api/platform/v1/last-leg/dispatches/draft-locks/release:
post:
summary: 关闭智能排车草稿并释放草稿内全部托盘
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/SmartDispatchDraftRelease' } } } }
responses: { '200': { description: 草稿锁已全部释放 } }
/api/platform/v1/last-leg/overseas-procurements:
get:
summary: 分页查询海外采购订单
@@ -296,6 +314,14 @@ components:
planPalletId: { type: string }
warehousePalletId: { type: string, nullable: true }
orderId: { type: string }
customerId: { type: string }
customerName: { type: string, nullable: true }
planNo: { type: string, nullable: true }
orderType: { type: string, nullable: true }
volumeWeightKg: { type: number, nullable: true }
containerNo: { type: string, nullable: true }
appointmentAt: { type: string, format: date-time, nullable: true }
expectedLoadingAt: { type: string, format: date-time, nullable: true }
overseasWarehouseId: { type: string }
overseasWarehouseName: { type: string }
orderNo: { type: string }
@@ -309,6 +335,7 @@ components:
packageCount: { type: integer }
volumeCbm: { type: number }
sourceStage: { type: string, enum: [PLAN, RECEIVED] }
cartonMarks: { type: string, nullable: true, description: 该托盘箱号,逗号分隔 }
SmartDispatchPreArrange:
type: object
required: [planPalletIds]
@@ -318,6 +345,18 @@ components:
fbaAppointmentNo: { type: string, nullable: true, maxLength: 64 }
fbaAppointmentStatus: { type: string, nullable: true, maxLength: 32, default: PENDING }
vehicleAppointmentStatus: { type: string, nullable: true, maxLength: 32, default: PENDING }
draftToken: { type: string, nullable: true, maxLength: 64, description: 拖入托盘时使用的智能排车草稿标识 }
SmartDispatchDraftPallet:
type: object
required: [planPalletId, draftToken]
properties:
planPalletId: { type: string }
draftToken: { type: string, minLength: 1, maxLength: 64 }
SmartDispatchDraftRelease:
type: object
required: [draftToken]
properties:
draftToken: { type: string, minLength: 1, maxLength: 64 }
DispatchResponse:
type: object
properties:
@@ -0,0 +1,48 @@
package com.baiyue.lastleg.discard.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.discard.dto.CargoDiscardRequests;
import com.baiyue.lastleg.discard.service.CargoDiscardService;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class MerchantCargoDiscardControllerTest {
private final CargoDiscardService service = mock(CargoDiscardService.class);
private final MerchantCargoDiscardController controller = controller();
private final CargoDiscardRequests.Query query = new CargoDiscardRequests.Query(
1, 20, null, null, null, null, null, null);
@Test
void merchantQueriesUseOnlyAuthenticatedTenant() {
AuthenticatedUser user = user(100L, "MERCHANT");
controller.page(user, query);
controller.candidates(user, "IN001");
controller.detail(user, "DS001");
verify(service).page("100", query);
verify(service).candidates("100", "IN001");
verify(service).detail("100", "DS001");
}
@Test
void merchantEndpointRejectsPlatformIdentity() {
assertThrows(ResponseStatusException.class,
() -> controller.page(user(0L, "PLATFORM"), query));
}
private static AuthenticatedUser user(long tenantId, String appCode) {
return new AuthenticatedUser(1L, tenantId, tenantId, 9L, "merchant", appCode, "WEB", Set.of());
}
private MerchantCargoDiscardController controller() {
MerchantCargoDiscardController controller = new MerchantCargoDiscardController();
ReflectionTestUtils.setField(controller, "service", service);
return controller;
}
}
@@ -0,0 +1,48 @@
package com.baiyue.lastleg.dispatch;
import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertTrue;
class DispatchCandidateMapperContractTest {
private static final List<String> CONSTRUCTOR_ALIASES = List.of(
"planId", "planPalletId", "warehousePalletId", "orderId", "customerId", "customerName",
"planNo", "orderType", "volumeWeightKg", "containerNo", "appointmentAt", "expectedLoadingAt",
"overseasWarehouseId", "overseasWarehouseName", "orderNo", "orderRefs", "inboundCode", "palletNo",
"destinationWarehouseId", "destinationWarehouseCode", "destinationWarehouseName", "fbaWarehouseCode",
"packageCount", "volumeCbm", "sourceStage", "cartonMarks"
);
@Test
void candidateQueriesKeepRecordConstructorColumnOrder() throws Exception {
assertConstructorColumnOrder(DispatchCandidateMapper.class.getMethod("available",
String.class, List.class, String.class, String.class, String.class));
assertConstructorColumnOrder(DispatchCandidateMapper.class.getMethod("lockSelected",
String.class, List.class));
}
@Test
void selectedReceivedPalletMustStillHaveAvailableInventory() throws Exception {
Method method = DispatchCandidateMapper.class.getMethod("lockSelected", String.class, List.class);
String sql = String.join(" ", method.getAnnotation(Select.class).value());
assertTrue(sql.contains("p.warehouse_pallet_id IS NULL OR EXISTS"));
assertTrue(sql.contains("pos.available_packages &gt; 0"));
}
private void assertConstructorColumnOrder(Method method) {
String sql = String.join(" ", method.getAnnotation(Select.class).value());
int previous = -1;
for (String alias : CONSTRUCTOR_ALIASES) {
int current = sql.indexOf(" AS " + alias, previous + 1);
assertTrue(current > previous,
() -> method.getName() + " must select alias " + alias + " in record constructor order");
previous = current;
}
}
}
@@ -0,0 +1,38 @@
package com.baiyue.lastleg.dispatch;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock;
import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper;
import org.apache.ibatis.annotations.Update;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class DispatchPalletLockPersistenceContractTest {
@Test
void nullableLockFieldsAreWrittenAsNullOnReleaseAndFinalization() throws Exception {
assertAlwaysUpdated("activeKey");
assertAlwaysUpdated("draftToken");
assertAlwaysUpdated("expiresAt");
}
@Test
void relockClearsInactiveUniqueKeyResidue() throws Exception {
Update update = DispatchPalletLockMapper.class
.getMethod("clearInactiveActiveKey", String.class, String.class)
.getAnnotation(Update.class);
String sql = String.join(" ", Arrays.asList(update.value()));
assertTrue(sql.contains("active_key=NULL"));
assertTrue(sql.contains("status!='ACTIVE'"));
}
private void assertAlwaysUpdated(String fieldName) throws Exception {
TableField field = DispatchPalletLock.class.getDeclaredField(fieldName).getAnnotation(TableField.class);
assertEquals(FieldStrategy.ALWAYS, field.updateStrategy());
}
}
@@ -19,6 +19,9 @@ class DispatchSchemaContractTest {
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_pda_operation"));
assertTrue(sql.contains("active_key"));
assertTrue(sql.contains("draft_token"));
assertTrue(sql.contains("expires_at"));
assertTrue(sql.contains("dispatch_order_id BIGINT NULL"));
assertTrue(sql.contains("order_refs"));
}
@@ -16,12 +16,17 @@ class SmartDispatchOpenApiContractTest {
}
assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/candidates:"));
assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/pre-arrange:"));
assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/draft-locks/lock:"));
assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/draft-locks/unlock:"));
assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/draft-locks/release:"));
assertTrue(yaml.contains("platform:overseas-warehouse:inbound-order:list"));
assertTrue(yaml.contains("platform:overseas-warehouse:inbound-order:smart-dispatch"));
assertTrue(yaml.contains("SmartDispatchPreArrange"));
assertTrue(yaml.contains("SmartDispatchCandidate"));
assertTrue(yaml.contains("name: planIds"));
assertTrue(yaml.contains("required: true"));
String planIdsParameter = yaml.substring(yaml.indexOf("name: planIds"),
yaml.indexOf("name: orderNo", yaml.indexOf("name: planIds")));
assertTrue(planIdsParameter.contains("required: false"));
String dispatchContract = yaml.substring(0,
yaml.indexOf("/api/platform/v1/last-leg/overseas-procurements:"));
assertFalse(dispatchContract.contains("name: customerId"));
@@ -34,14 +34,13 @@ class DispatchQueryControllerTest {
}
@Test
void platformPageUsesServerResolvedSingleCustomerWithoutCustomerId() {
void platformPageQueriesAcrossCustomersWithoutCustomerId() {
DispatchRequests.Query query = new DispatchRequests.Query(1, 20, null, null, null, null);
when(service.resolveSingleCustomerScope()).thenReturn("100");
when(service.page("100", query)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
when(service.platformPage(query)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
var response = controller.page(platform(), query);
verify(service).page("100", query);
verify(service).platformPage(query);
assertThat(response.code()).isEqualTo("200");
assertThat(response.data().records()).isEmpty();
assertThatThrownBy(() -> controller.page(null, query))
@@ -40,6 +40,15 @@ class SmartDispatchControllerTest {
verify(service).candidates("100", List.of(1L, 2L), "LMO-1", "ONT8", "PLAN");
}
@Test
void candidatesWithoutSelectedPlansUsePlatformScope() {
when(service.platformCandidates(null, null, "RECEIVED")).thenReturn(List.of());
controller.candidates(platform(), null, null, null, "RECEIVED");
verify(service).platformCandidates(null, null, "RECEIVED");
}
@Test
void preArrangeResolvesSingleCustomerAndAuthenticatedOperator() {
DispatchRequests.PreArrange request = new DispatchRequests.PreArrange(
@@ -1,5 +1,7 @@
package com.baiyue.lastleg.dispatch.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
@@ -69,6 +71,74 @@ class DispatchServiceTest {
verify(items).insert(any(DispatchItem.class));
}
@Test
void movingPalletIntoDraftImmediatelyCreatesExpiringExclusiveLock() {
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8")));
service.lockDraftPallet("100", "7", new DispatchRequests.DraftPallet(10L, "draft-1"));
ArgumentCaptor<DispatchPalletLock> lock = ArgumentCaptor.forClass(DispatchPalletLock.class);
verify(locks).insert(lock.capture());
assertEquals("draft-1", lock.getValue().getDraftToken());
assertEquals("7", lock.getValue().getOperatorId());
assertNotNull(lock.getValue().getExpiresAt());
assertEquals(null, lock.getValue().getDispatchOrderId());
verify(locks).clearInactiveActiveKey("100", "PLAN:10");
}
@Test
void movingVolumeOnlyPlanPalletIntoDraftCreatesLock() {
when(candidates.lockSelected("100", List.of(10L)))
.thenReturn(List.of(candidate(10L, null, "ONT8", 0)));
service.lockDraftPallet("100", "7", new DispatchRequests.DraftPallet(10L, "draft-1"));
verify(locks).insert(any(DispatchPalletLock.class));
}
@Test
void preArrangeAcceptsVolumeOnlyPlanPallet() {
when(candidates.lockSelected("100", List.of(10L)))
.thenReturn(List.of(candidate(10L, null, "ONT8", 0)));
service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null,
null, null, null));
ArgumentCaptor<DispatchOrder> dispatch = ArgumentCaptor.forClass(DispatchOrder.class);
verify(orders).insert(dispatch.capture());
assertEquals(0, dispatch.getValue().getPackageCount());
verify(items).insert(any(DispatchItem.class));
}
@Test
void removingPalletFromOwnedDraftImmediatelyReleasesLock() {
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8")));
DispatchPalletLock lock = new DispatchPalletLock();
lock.setDraftToken("draft-1"); lock.setOperatorId("7"); lock.setStatus("ACTIVE"); lock.setActiveKey("PLAN:10");
when(locks.lockActive("100", "PLAN:10")).thenReturn(lock);
service.unlockDraftPallet("100", "7", new DispatchRequests.DraftPallet(10L, "draft-1"));
assertEquals("RELEASED", lock.getStatus());
assertEquals(null, lock.getActiveKey());
verify(locks).updateById(lock);
}
@Test
void preArrangeConvertsOwnedDraftLockInsteadOfInsertingAnotherLock() {
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8")));
DispatchPalletLock lock = new DispatchPalletLock();
lock.setDraftToken("draft-1"); lock.setOperatorId("7"); lock.setStatus("ACTIVE"); lock.setActiveKey("PLAN:10");
when(locks.lockActive("100", "PLAN:10")).thenReturn(lock);
service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null, null, null, null, "draft-1"));
assertNotNull(lock.getDispatchOrderId());
assertEquals(null, lock.getDraftToken());
verify(locks).updateById(lock);
verify(locks, never()).insert(any(DispatchPalletLock.class));
}
@Test
void rejectsPalletAlreadyLockedByAnotherDispatch() {
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8")));
@@ -87,6 +157,15 @@ class DispatchServiceTest {
verify(candidates, never()).available(any(), any(), any(), any(), any());
}
@Test
void platformCandidatesQueryAcrossCustomersWithoutPlanScope() {
when(candidates.available(null, null, null, null, "RECEIVED")).thenReturn(List.of());
service.platformCandidates(null, null, "RECEIVED");
verify(candidates).available(null, null, null, null, "RECEIVED");
}
@Test
void resolvesCustomerFromSelectedPlansAndPalletsInMultiCustomerDeployment() {
when(candidates.findCustomerScopesByPlanIds(List.of(1L, 2L))).thenReturn(List.of("100"));
@@ -106,17 +185,6 @@ class DispatchServiceTest {
assertEquals(409, exception.getStatusCode().value());
}
@Test
void resolvesOnlyCustomerScopeAndRejectsMultipleCustomers() {
when(candidates.findCustomerScopes()).thenReturn(List.of("100"));
assertEquals("100", service.resolveSingleCustomerScope());
when(candidates.findCustomerScopes()).thenReturn(List.of("100", "200"));
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.resolveSingleCustomerScope());
assertEquals(409, exception.getStatusCode().value());
}
@Test
void emptyCustomerScopeProducesEmptyCandidatesAndRejectsPreArrange() {
assertEquals(List.of(), service.candidates(null, null, null, null, null));
@@ -159,9 +227,42 @@ class DispatchServiceTest {
verify(items).findByDispatchOrder("200", 1L);
}
@Test
void platformPageQueriesAllCustomersAndLoadsItemsInEachCustomerScope() {
DispatchOrder first = new DispatchOrder();
first.setId(1L); first.setCustomerId("100"); first.setDispatchNo("PC1");
DispatchOrder second = new DispatchOrder();
second.setId(2L); second.setCustomerId("200"); second.setDispatchNo("PC2");
Page<DispatchOrder> resultPage = new Page<>(1, 20, 2);
resultPage.setRecords(List.of(first, second));
when(orders.selectPage(any(Page.class), any(LambdaQueryWrapper.class))).thenReturn(resultPage);
var result = service.platformPage(new DispatchRequests.Query(1, 20, null, null, null, null));
assertEquals(2, result.total());
verify(items).findByDispatchOrder("100", 1L);
verify(items).findByDispatchOrder("200", 2L);
}
@Test
void platformDetailUsesCustomerFromMatchedDispatch() {
DispatchOrder dispatch = new DispatchOrder();
dispatch.setId(1L); dispatch.setCustomerId("200"); dispatch.setDispatchNo("PC1");
when(orders.findAcrossCustomersByDispatchNo("PC1")).thenReturn(List.of(dispatch));
service.platformDetail("PC1");
verify(items).findByDispatchOrder("200", 1L);
}
private DispatchPalletCandidate candidate(long planPalletId, Long warehousePalletId, String destination) {
return candidate(planPalletId, warehousePalletId, destination, 12);
}
private DispatchPalletCandidate candidate(long planPalletId, Long warehousePalletId, String destination,
int packageCount) {
return new DispatchPalletCandidate(1L, planPalletId, warehousePalletId, 2L, "WH-1", "Overseas warehouse", "LMO1", "LMO1", "RK1",
"LIP1-P001", destination, destination, destination, destination, 12,
"LIP1-P001", destination, destination, destination, destination, packageCount,
new BigDecimal("1.2000"), warehousePalletId == null ? "PLAN" : "RECEIVED");
}
}
@@ -0,0 +1,46 @@
package com.baiyue.lastleg.relabel.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.relabel.dto.RelabelRequests;
import com.baiyue.lastleg.relabel.service.RelabelApplicationService;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class MerchantRelabelApplicationControllerTest {
private final RelabelApplicationService service = mock(RelabelApplicationService.class);
private final MerchantRelabelApplicationController controller = controller();
private final RelabelRequests.Query query = new RelabelRequests.Query(
1, 20, null, null, null, null, null, null, null);
@Test
void merchantPageAndDetailUseOnlyAuthenticatedTenant() {
AuthenticatedUser user = user(100L, "MERCHANT");
controller.page(user, query);
controller.detail(user, "RL001");
verify(service).page("100", query);
verify(service).detail("100", "RL001");
}
@Test
void merchantEndpointRejectsPlatformIdentity() {
assertThrows(ResponseStatusException.class,
() -> controller.page(user(0L, "PLATFORM"), query));
}
private static AuthenticatedUser user(long tenantId, String appCode) {
return new AuthenticatedUser(1L, tenantId, tenantId, 9L, "merchant", appCode, "WEB", Set.of());
}
private MerchantRelabelApplicationController controller() {
MerchantRelabelApplicationController controller = new MerchantRelabelApplicationController();
ReflectionTestUtils.setField(controller, "service", service);
return controller;
}
}
@@ -0,0 +1,49 @@
package com.baiyue.lastleg.transfer.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
import com.baiyue.lastleg.transfer.service.WarehouseTransferService;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class MerchantWarehouseTransferControllerTest {
private final WarehouseTransferService service = mock(WarehouseTransferService.class);
private final MerchantWarehouseTransferController controller = controller();
private final WarehouseTransferRequests.Query query = new WarehouseTransferRequests.Query(
1, 20, null, null, null, null, null, null, null, null);
@Test
void merchantPageUsesOnlyAuthenticatedTenant() {
controller.page(user(100L, "MERCHANT"), query);
verify(service).page("100", query);
}
@Test
void merchantDetailUsesOnlyAuthenticatedTenant() {
controller.detail(user(100L, "MERCHANT"), "TR001");
verify(service).detail("100", "TR001");
}
@Test
void merchantEndpointRejectsPlatformIdentity() {
assertThrows(ResponseStatusException.class,
() -> controller.page(user(0L, "PLATFORM"), query));
}
private static AuthenticatedUser user(long tenantId, String appCode) {
return new AuthenticatedUser(1L, tenantId, tenantId, 9L, "merchant", appCode, "WEB", Set.of());
}
private MerchantWarehouseTransferController controller() {
MerchantWarehouseTransferController controller = new MerchantWarehouseTransferController();
ReflectionTestUtils.setField(controller, "service", service);
return controller;
}
}
@@ -87,8 +87,7 @@ public class PlatformWarehouseResourceController {
}
@PostMapping("/pallets")
@PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.PALLET_CREATE + "')")
@Operation(summary = "Create a warehouse pallet", description = "Required authority: " + OverseasWarehousePermissions.PALLET_CREATE)
@Operation(summary = "Create a warehouse pallet", description = "Platform access required")
public ApiResponse<Long> createPallet(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long warehouseId,
@Valid @RequestBody PalletRequests.SaveRequest request) {
@@ -18,17 +18,17 @@ spring:
- optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true
cloud:
nacos:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858}
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
username: ${NACOS_USERNAME:nacos}
password: ${NACOS_PASSWORD:nacos}
discovery:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858}
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
enabled: ${NACOS_DISCOVERY_ENABLED:true}
register-enabled: ${NACOS_REGISTER_ENABLED:true}
group: ${NACOS_GROUP:BAIYUE}
namespace: ${NACOS_NAMESPACE:public}
config:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858}
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
namespace: ${NACOS_NAMESPACE:public}
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
@@ -10,6 +10,7 @@ import java.util.Map;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
class WarehouseResourcePermissionContractTest {
private static final String PREFIX = "platform:overseas-warehouse:";
@@ -27,9 +28,15 @@ class WarehouseResourcePermissionContractTest {
assertPermission(methods.get("deleteLocation"), PREFIX + "location:delete");
assertPermission(methods.get("pallets"), PREFIX + "pallet:list");
assertPermission(methods.get("pallet"), PREFIX + "pallet:list");
assertPermission(methods.get("createPallet"), PREFIX + "pallet:create");
assertPermission(methods.get("updatePallet"), PREFIX + "pallet:edit");
assertPermission(methods.get("deletePallet"), PREFIX + "pallet:delete");
Method createPallet = Arrays.stream(PlatformWarehouseResourceController.class.getDeclaredMethods())
.filter(method -> "createPallet".equals(method.getName()))
.findFirst()
.orElseThrow();
assertNull(createPallet.getAnnotation(PreAuthorize.class));
assertEquals("Platform access required", createPallet.getAnnotation(Operation.class).description());
}
private static void assertPermission(Method method, String authority) {