diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 8afc2bb8f..a8dd32542 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -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, diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index e16e1f071..57ea56cf3 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -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/`,本服务不得复制仓储资源主数据或另建公网认证入口。 ## 数据库表索引与注释约束 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardController.java new file mode 100644 index 000000000..fc5577461 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardController.java @@ -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> candidates(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String inboundCode) { return ApiResponse.success(service.candidates(customer(user), inboundCode)); } + @PostMapping + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody CargoDiscardRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); } + @GetMapping + public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, + @Valid CargoDiscardRequests.Query query) { return ApiResponse.success(service.page(customer(user), query)); } + @GetMapping("/{discardNo}") + public ApiResponse 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()); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java index cc8677d2b..c31d84a80 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java @@ -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) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java index fe30d351a..7e82f21b1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java @@ -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> candidates( @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam List planIds, + @RequestParam(required = false) List 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> 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 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 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 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 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); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchDraftResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchDraftResponse.java new file mode 100644 index 000000000..75949095b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchDraftResponse.java @@ -0,0 +1,4 @@ +package com.baiyue.lastleg.dispatch.dto; + +public record DispatchDraftResponse(String draftToken, String planIdsJson, String containersJson) { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java index 370d81171..d0176a129 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java @@ -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 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, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraft.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraft.java new file mode 100644 index 000000000..7e569b07f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraft.java @@ -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; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java index 386d77e17..4869e059b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java @@ -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; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java index e25b11ff0..e5e4f5875 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java @@ -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 findCustomerScopes(); - @Select("