feat: support overseas warehouse locations and A0 overflow

This commit is contained in:
2026-09-22 09:38:49 +08:00
parent d3ce268ca3
commit 1ebc128c59
15 changed files with 203 additions and 15 deletions
@@ -14,6 +14,7 @@ public record InboundPlanResponse(
String customerName,
String orderType,
String containerNo,
Long overseasWarehouseId,
String overseasWarehouseCode,
String overseasWarehouseName,
String status,
@@ -1124,7 +1124,7 @@ public class InboundPlanService {
return new InboundPlanResponse(plan.getId(), plan.getPlanNo(), plan.getCustomerId(), plan.getSourceOrderId(),
sourceOrder == null ? null : sourceOrder.orderNo(),
sourceOrder == null ? null : sourceOrder.customerName(),
sourceOrder == null ? null : sourceOrder.orderType(), plan.getContainerNo(),
sourceOrder == null ? null : sourceOrder.orderType(), plan.getContainerNo(), authoritativeWarehouse.warehouseId(),
authoritativeWarehouse.warehouseCode(), authoritativeWarehouse.warehouseName(),
plan.getStatus(), plan.getLineCount(), plan.getPalletCount(), totalGrossWeightKg,
totalNetWeightKg, plan.getTotalVolumeCbm(),
@@ -0,0 +1,42 @@
package com.baiyue.lastleg.pda.move.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.pda.move.dto.PlatformInventoryMoveRequest;
import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveResponse;
import com.baiyue.lastleg.pda.move.service.PdaInventoryMoveService;
import com.baiyue.lastleg.security.OverseasPermissions;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
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.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
/** Browser/platform entry point; the PDA entry point remains restricted to warehouse operators. */
@RestController
@RequestMapping("/api/platform/v1/last-leg/inventory-moves")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_EDIT + "')")
public class PlatformInventoryMoveController {
@Resource private PdaInventoryMoveService service;
@PostMapping
public ApiResponse<PdaInventoryMoveResponse> move(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PlatformInventoryMoveRequest request) {
requirePlatform(user);
String operator = user.userId() == null ? user.username() : String.valueOf(user.userId());
return ApiResponse.success(service.movePlatform(request.overseasWarehouseId(), operator,
request.asPdaRequest()));
}
private static 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 identity is required");
}
}
}
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.pda.move.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
/** Platform/admin variant of the pallet put-away command. */
public record PlatformInventoryMoveRequest(
@Positive long overseasWarehouseId,
@NotBlank @Size(max = 128) String sourcePalletCode,
@NotBlank @Size(max = 128) String targetLocationCode,
@PositiveOrZero int expectedPalletVersion,
@NotBlank @Size(max = 128) String idempotencyKey,
@NotBlank @Size(max = 500) String reason
) {
public PdaInventoryMoveRequest asPdaRequest() {
return new PdaInventoryMoveRequest(sourcePalletCode, targetLocationCode,
expectedPalletVersion, idempotencyKey, reason);
}
}
@@ -45,6 +45,22 @@ public class PdaInventoryMoveService {
@Transactional
public PdaInventoryMoveResponse move(long warehouseId, String operatorKey, PdaInventoryMoveRequest request) {
return moveInternal(warehouseId, operatorKey, request);
}
/**
* Platform/admin put-away uses the same command, idempotency record and physical
* transition as the PDA flow. Keeping one implementation prevents the two entry
* points from drifting on A0, virtual-location and inventory-position semantics.
*/
@Transactional
public PdaInventoryMoveResponse movePlatform(long warehouseId, String operatorKey,
PdaInventoryMoveRequest request) {
return moveInternal(warehouseId, "PLATFORM:" + required(operatorKey, "Operator is required"), request);
}
private PdaInventoryMoveResponse moveInternal(long warehouseId, String operatorKey,
PdaInventoryMoveRequest request) {
String key = required(request.idempotencyKey(), "Idempotency key is required");
String requestHash = hash(request);
PdaInventoryMove existing = moves.find(warehouseId, operatorKey, key);
@@ -58,6 +74,11 @@ public class PdaInventoryMoveService {
if (!Set.of("ACTIVE", "IDLE", "OCCUPIED").contains(target.status())) {
throw conflict("Target location is not available");
}
// A virtual receiving location is an intake staging position, not a put-away
// destination. A0 is deliberately allowed as a shared overflow location.
if ("VIRTUAL".equalsIgnoreCase(target.locationType())) {
throw conflict("Virtual receiving location cannot be used as a put-away target");
}
boolean recoveringRemoteSuccess = pallet.locationId() == target.locationId()
&& pallet.version() == request.expectedPalletVersion() + 1;
if (!recoveringRemoteSuccess && pallet.version() != request.expectedPalletVersion()) {
@@ -129,6 +129,14 @@ paths:
'200': { description: 移库结果, content: { application/json: { schema: { $ref: '#/components/schemas/InventoryMoveResponse' } } } }
'404': { description: 来源托盘不存在(统一响应实际 HTTP 200,body.code=A0404), content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } } }
'409': { description: 跨仓、资源或库存版本冲突 }
/api/platform/v1/last-leg/inventory-moves:
post:
summary: 平台发起海外仓整托移库
description: 平台按海外仓业务标识执行普通库位或 A0 共享溢出位移库;目标虚拟收货位、跨仓和版本冲突均拒绝。
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PlatformInventoryMove' } } } }
responses:
'200': { description: 移库结果, content: { application/json: { schema: { $ref: '#/components/schemas/InventoryMoveResponse' } } } }
'409': { description: 物理资源或本地版本冲突 }
/api/pda/v1/last-leg/inventory/resolve:
post:
summary: 扫码查询Token绑定仓库存
@@ -930,6 +938,16 @@ components:
expectedPalletVersion: { type: integer, minimum: 0 }
idempotencyKey: { type: string, maxLength: 128 }
reason: { type: string, minLength: 1, maxLength: 500 }
PlatformInventoryMove:
type: object
required: [overseasWarehouseId, sourcePalletCode, targetLocationCode, expectedPalletVersion, idempotencyKey, reason]
properties:
overseasWarehouseId: { type: string, pattern: '^[0-9]+$' }
sourcePalletCode: { type: string, maxLength: 128 }
targetLocationCode: { type: string, maxLength: 128 }
expectedPalletVersion: { type: integer, format: int64, minimum: 0 }
idempotencyKey: { type: string, maxLength: 128 }
reason: { type: string, minLength: 1, maxLength: 500 }
PalletExpectation:
type: object
required: [palletCode, expectedVersion]
@@ -433,7 +433,7 @@ class ContainerInboundPlanConversionCoordinatorTest {
private InboundPlanResponse plan(String planNo, List<InboundPlanResponse.Pallet> pallets) {
return new InboundPlanResponse(21L, planNo, "100", 11L, "FL-WH-1", "验收客户", "ONE_CLICK_DROPSHIP",
"MSCU6639870", "US-LAX-01",
"MSCU6639870", 7L, "US-LAX-01",
"Los Angeles", "PENDING_ASSIGNMENT", 1, 1, BigDecimal.TEN, new BigDecimal("8"), BigDecimal.ONE, 10, 0,
List.of(), pallets, List.of(), 1, LocalDate.of(2026, 9, 10), "WOOD", false,
false, false, "MANUAL", "自动生成", null, null);