修改提交
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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/`,本服务不得复制仓储资源主数据或另建公网认证入口。
|
||||
|
||||
## 数据库表索引与注释约束
|
||||
|
||||
+39
@@ -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()); }
|
||||
}
|
||||
+2
-2
@@ -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) {
|
||||
|
||||
+60
-3
@@ -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);
|
||||
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
package com.baiyue.lastleg.dispatch.dto;
|
||||
|
||||
public record DispatchDraftResponse(String draftToken, String planIdsJson, String containersJson) {
|
||||
}
|
||||
+18
-1
@@ -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;
|
||||
}
|
||||
+8
@@ -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;
|
||||
|
||||
+43
-18
@@ -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 > 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 > 0)) " +
|
||||
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " +
|
||||
"WHERE d.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 > 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 > 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 > 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) " +
|
||||
|
||||
+18
@@ -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);
|
||||
}
|
||||
+5
@@ -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);
|
||||
}
|
||||
|
||||
+6
@@ -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);
|
||||
}
|
||||
|
||||
+25
-3
@@ -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.
|
||||
|
||||
+194
-41
@@ -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");
|
||||
|
||||
+35
@@ -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()); }
|
||||
}
|
||||
+47
@@ -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()); }
|
||||
}
|
||||
+7
@@ -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) {
|
||||
|
||||
+9
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -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,
|
||||
|
||||
+28
@@ -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:
|
||||
|
||||
+48
@@ -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;
|
||||
}
|
||||
}
|
||||
+48
@@ -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 > 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
@@ -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());
|
||||
}
|
||||
}
|
||||
+3
@@ -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"));
|
||||
}
|
||||
|
||||
|
||||
+6
-1
@@ -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"));
|
||||
|
||||
+3
-4
@@ -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))
|
||||
|
||||
+9
@@ -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(
|
||||
|
||||
+113
-12
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+46
@@ -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;
|
||||
}
|
||||
}
|
||||
+49
@@ -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;
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -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
|
||||
|
||||
+8
-1
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user