This commit is contained in:
2026-08-21 11:43:03 +08:00
parent dceec6cbed
commit 5fd00d3151
25 changed files with 507 additions and 44 deletions
@@ -4,7 +4,6 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses;
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
@@ -18,7 +17,6 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
@@ -28,8 +26,6 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@RestController
@ConditionalOnProperty(name = "lastleg.future-outbound-api.enabled", havingValue = "true")
@RequestMapping("/api/platform/v1/last-leg/dispatches")
@@ -38,28 +34,6 @@ public class DispatchController {
@Resource private DispatchService service;
@Resource private DispatchPdaWorkflowService pdaWorkflow;
@GetMapping("/candidates")
@Operation(summary = "List unlocked planned or received pallets for intelligent pre-dispatch")
public ApiResponse<List<DispatchPalletCandidate>> candidates(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@RequestParam(required = false) String orderNo,
@RequestParam(required = false) String fbaWarehouseCode,
@RequestParam(required = false) String sourceStage) {
return ApiResponse.success(service.candidates(scope(user, customerId), orderNo, fbaWarehouseCode, sourceStage));
}
@PostMapping("/pre-arrange")
@Operation(summary = "Create a pending-departure dispatch and lock all selected pallets")
public ApiResponse<DispatchResponse> preArrange(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid @RequestBody DispatchRequests.PreArrange request) {
String scopedTenant = scope(user, customerId);
String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId());
return ApiResponse.success(service.preArrange(scopedTenant, operatorId, request));
}
@PutMapping("/{dispatchNo}")
@Operation(summary = "Edit vehicle, driver, appointment, route and departure data")
public ApiResponse<DispatchResponse> update(
@@ -6,12 +6,14 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
import com.baiyue.lastleg.dispatch.service.DispatchService;
import com.baiyue.lastleg.security.OverseasPermissions;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -27,21 +29,23 @@ public class DispatchQueryController {
@Resource private DispatchService service;
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')")
@Operation(summary = "Page dispatch orders")
public ApiResponse<PageResult<DispatchResponse>> page(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid DispatchRequests.Query query) {
requirePlatform(user);
return ApiResponse.success(service.page(null, query));
return ApiResponse.success(service.page(service.resolveSingleCustomerScope(), query));
}
@GetMapping("/{dispatchNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')")
@Operation(summary = "Get dispatch details and all pallet destinations")
public ApiResponse<DispatchResponse> detail(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo) {
requirePlatform(user);
return ApiResponse.success(service.detail(null, dispatchNo));
return ApiResponse.success(service.detail(service.resolveSingleCustomerScope(), dispatchNo));
}
private void requirePlatform(AuthenticatedUser user) {
@@ -0,0 +1,70 @@
package com.baiyue.lastleg.dispatch.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import com.baiyue.lastleg.dispatch.service.DispatchService;
import com.baiyue.lastleg.security.OverseasPermissions;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@RestController
@ConditionalOnProperty(name = "lastleg.smart-dispatch-api.enabled", havingValue = "true")
@RequestMapping("/api/platform/v1/last-leg/dispatches")
@Tag(name = "Tail-leg smart dispatch")
public class SmartDispatchController {
@Resource private DispatchService service;
@GetMapping("/candidates")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')")
@Operation(summary = "List unlocked planned or received pallets for intelligent pre-dispatch",
description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST)
public ApiResponse<List<DispatchPalletCandidate>> candidates(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String orderNo,
@RequestParam(required = false) String fbaWarehouseCode,
@RequestParam(required = false) String sourceStage) {
requirePlatform(user);
return ApiResponse.success(service.candidates(service.resolveSingleCustomerScope(), orderNo,
fbaWarehouseCode, sourceStage));
}
@PostMapping("/pre-arrange")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')")
@Operation(summary = "Create a pending-departure dispatch and lock all selected pallets",
description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_EDIT)
public ApiResponse<DispatchResponse> preArrange(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody DispatchRequests.PreArrange request) {
requirePlatform(user);
String scopedCustomer = service.resolveSingleCustomerScope();
String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId());
return ApiResponse.success(service.preArrange(scopedCustomer, operatorId, request));
}
private void requirePlatform(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, "Platform application required");
}
}
}
@@ -9,6 +9,12 @@ 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 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," +
@@ -11,9 +11,6 @@ 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} LIMIT 1")
DispatchOrder findAnyByDispatchNo(@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);
}
@@ -41,16 +41,30 @@ public class DispatchService {
@Resource private DispatchPalletLockMapper locks;
@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 List<DispatchPalletCandidate> candidates(String customerId, String orderNo,
String fbaWarehouseCode, String sourceStage) {
if (StringUtils.hasText(sourceStage) && !Set.of("PLAN", "RECEIVED").contains(sourceStage)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "sourceStage must be PLAN or RECEIVED");
}
if (!StringUtils.hasText(customerId)) return List.of();
return candidates.available(customerId, trimToNull(orderNo), trimToNull(fbaWarehouseCode), trimToNull(sourceStage));
}
@Transactional
public DispatchResponse preArrange(String customerId, String operatorId, DispatchRequests.PreArrange request) {
if (!StringUtils.hasText(customerId)) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"No customer business data is available for dispatch");
}
List<Long> palletIds = request.planPalletIds().stream().filter(Objects::nonNull).distinct().sorted().toList();
if (palletIds.isEmpty() || palletIds.size() != request.planPalletIds().size()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Plan pallet IDs must be unique and non-empty");
@@ -149,8 +163,11 @@ public class DispatchService {
}
public PageResult<DispatchResponse> page(String customerId, DispatchRequests.Query query) {
if (!StringUtils.hasText(customerId)) {
return new PageResult<>(List.of(), query.page(), query.size(), 0, 0);
}
Page<DispatchOrder> page = orders.selectPage(new Page<>(query.page(), query.size()),
new LambdaQueryWrapper<DispatchOrder>().eq(StringUtils.hasText(customerId), DispatchOrder::getCustomerId, customerId)
new LambdaQueryWrapper<DispatchOrder>().eq(DispatchOrder::getCustomerId, customerId)
.like(StringUtils.hasText(query.dispatchNo()), DispatchOrder::getDispatchNo, query.dispatchNo())
.like(StringUtils.hasText(query.vehiclePlate()), DispatchOrder::getVehiclePlate, query.vehiclePlate())
.eq(StringUtils.hasText(query.status()), DispatchOrder::getStatus, query.status())
@@ -216,9 +233,10 @@ public class DispatchService {
}
private DispatchOrder find(String customerId, String dispatchNo) {
DispatchOrder dispatch = StringUtils.hasText(customerId)
? orders.findByDispatchNo(customerId, dispatchNo)
: orders.findAnyByDispatchNo(dispatchNo);
if (!StringUtils.hasText(customerId)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
}
DispatchOrder dispatch = orders.findByDispatchNo(customerId, dispatchNo);
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
return dispatch;
}
@@ -34,6 +34,12 @@ spring:
password: ${LASTLEG_DB_PASSWORD:123456}
management.endpoints.web.exposure.include: health,info,prometheus
lastleg:
dispatch-query-api:
enabled: ${LASTLEG_DISPATCH_QUERY_API_ENABLED:true}
smart-dispatch-api:
enabled: ${LASTLEG_SMART_DISPATCH_API_ENABLED:true}
baiyue:
clients:
domestic-warehouse:
@@ -3,6 +3,55 @@ info:
title: Lastleg Overseas Business API
version: 1.0.0
paths:
/api/platform/v1/last-leg/dispatches:
get:
summary: 分页查询排车单
description: 权限 platform:overseas-warehouse:inbound-order:list;服务端仅在当前部署恰好存在一个客户范围时查询,前端不传 customerId。
security: [{ bearerAuth: [] }]
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
- { name: dispatchNo, in: query, schema: { type: string } }
- { name: vehiclePlate, in: query, schema: { type: string } }
- { name: status, in: query, schema: { type: string } }
- { 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。
security: [{ bearerAuth: [] }]
parameters:
- { name: dispatchNo, in: path, required: true, schema: { type: string } }
responses:
'200': { description: 统一 ApiResponse 包装的 DispatchResponse }
'404': { description: 排车单不存在 }
'409': { description: 当前部署检测到多个客户范围 }
/api/platform/v1/last-leg/dispatches/candidates:
get:
summary: 查询智能排车候选托盘
description: 权限 platform:overseas-warehouse:inbound-order:list;服务端解析唯一客户范围,仅返回尚未被有效排车锁定的计划或已实收托盘,前端不传 customerId。
security: [{ bearerAuth: [] }]
parameters:
- { name: orderNo, in: query, schema: { type: string } }
- { name: fbaWarehouseCode, in: query, schema: { type: string } }
- { name: sourceStage, in: query, schema: { type: string, enum: [PLAN, RECEIVED] } }
responses:
'200':
description: 统一 ApiResponse 包装的候选托盘数组
content: { application/json: { schema: { type: array, items: { $ref: '#/components/schemas/SmartDispatchCandidate' } } } }
/api/platform/v1/last-leg/dispatches/pre-arrange:
post:
summary: 创建智能预排车单
description: 权限 platform:overseas-warehouse:inbound-order:edit;服务端解析唯一客户范围,锁定同一海外仓的所选托盘并创建 PENDING_DEPARTURE 排车单,前端不传 customerId;不开放编辑、取消、depart、PDA 出库或派送。
security: [{ bearerAuth: [] }]
requestBody:
required: true
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/overseas-procurements:
get:
summary: 分页查询海外采购订单
@@ -212,6 +261,81 @@ paths:
responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '409': { description: 非法状态迁移或关联尾程单缺失 } }
components:
schemas:
SmartDispatchCandidate:
type: object
properties:
planId: { type: string }
planPalletId: { type: string }
warehousePalletId: { type: string, nullable: true }
orderId: { type: string }
overseasWarehouseId: { type: string }
overseasWarehouseName: { type: string }
orderNo: { type: string }
orderRefs: { type: string }
inboundCode: { type: string }
palletNo: { type: string }
destinationWarehouseId: { type: string }
destinationWarehouseCode: { type: string }
destinationWarehouseName: { type: string }
fbaWarehouseCode: { type: string }
packageCount: { type: integer }
volumeCbm: { type: number }
sourceStage: { type: string, enum: [PLAN, RECEIVED] }
SmartDispatchPreArrange:
type: object
required: [planPalletIds]
properties:
planPalletIds: { type: array, minItems: 1, maxItems: 500, uniqueItems: true, items: { type: string } }
route: { type: string, nullable: true, maxLength: 255 }
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 }
DispatchResponse:
type: object
properties:
id: { type: string }
dispatchNo: { type: string }
vehiclePlate: { type: string, nullable: true }
driverName: { type: string, nullable: true }
driverPhone: { type: string, nullable: true }
carrierName: { type: string, nullable: true }
orderNos: { type: string }
route: { type: string, nullable: true }
overseasWarehouseId: { type: string }
overseasWarehouseName: { type: string }
departAt: { type: string, format: date-time, nullable: true }
fbaAppointmentNo: { type: string, nullable: true }
fbaAppointmentStatus: { type: string }
vehicleAppointmentStatus: { type: string }
estimatedArrivalAt: { type: string, format: date-time, nullable: true }
status: { type: string, description: 预排新建固定为 PENDING_DEPARTURE;查询可返回历史排车状态 }
itemCount: { type: integer }
palletCount: { type: integer }
packageCount: { type: integer }
pickedPackageCount: { type: integer }
shippedPackageCount: { type: integer }
remainingPackageCount: { type: integer }
partialCompletionReason: { type: string, nullable: true }
createdAt: { type: string, format: date-time }
items: { type: array, items: { $ref: '#/components/schemas/DispatchItem' } }
DispatchItem:
type: object
properties:
id: { type: string }
planPalletId: { type: string }
warehousePalletId: { type: string, nullable: true }
palletNo: { type: string }
overseasWarehouseId: { type: string }
overseasWarehouseName: { type: string }
orderNo: { type: string }
orderRefs: { type: string }
inboundCode: { type: string }
destinationWarehouseCode: { type: string }
destinationWarehouseName: { type: string }
fbaWarehouseCode: { type: string }
packageCount: { type: integer }
volumeCbm: { type: number }
sourceStage: { type: string, enum: [PLAN, RECEIVED] }
RelabelStatus: { type: string, enum: [PENDING_PROCESSING, PROCESSING, COMPLETED, REJECTED, CANCELLED] }
RelabelCreate:
type: object