This commit is contained in:
2026-08-13 20:00:30 +08:00
parent 50251240f4
commit 5bc62a109e
28 changed files with 602 additions and 176 deletions
+3 -2
View File
@@ -13,6 +13,7 @@
| 履约任务/分配 | `controller/PlatformFulfillmentAssignmentController.java`、`service/FulfillmentTaskService.java` |
| 入库单查询/聚合 | `controller/PlatformInboundOrderController.java`、`service/InboundOrderQueryService.java` |
| 平台测量/OCR | `controller/PlatformInboundMeasurementController.java`、`service/InboundMeasurementService.java`、`service/CartonMeasurementService.java` |
| 箱唛箱码生成与版本化 | `repository/InboundCartonLabelRepository.java`、`service/InboundCartonLabelService.java`;基线/升级 SQL 为 `src/main/resources/db/phase2-inbound-carton-label.sql`、`phase2-carton-label-versioning-migration.sql` |
| 墨家设备测量上传 | `controller/OpenApiInboundMeasurementController.java`、`dto/MojiaMeasurementDtos.java`、`service/MojiaMeasurementOpenApiService.java`、`security/MeasurementRequestAuthenticator.java`;接口契约见 `src/main/resources/openapi/firstleg-v1.yaml` 与 `../../docs/墨家设备测量上传接口说明-V3.md` |
| 审核/自动完成 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`InboundCompletionOrchestrator.java` |
| FBA 主数据/计费 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java` |
@@ -31,7 +32,7 @@
| 报关协同 | `customs/controller/PlatformCustomsController.java`、`MerchantCustomsController.java` |
| 跨模型兼容桥 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` |
| 跨服务客户端 | `client/OrderCargoClient.java`、`WarehousePalletLocationClient.java`、`MeasurementImageClient.java` |
| SQL/OpenAPI | `src/main/resources/db/phase2-*.sql`、`src/main/resources/openapi/`;部署统一 `../deploy/sql/头程数据库统一增量-MySQL8.sql` |
| SQL/OpenAPI | `src/main/resources/db/phase2-*.sql`、`src/main/resources/db/phase3-*.sql`、`src/main/resources/openapi/`;部署统一 `../deploy/sql/头程数据库统一增量-MySQL8.sql` |
| 测试/验证 | `src/test/java/com/baiyue/firstleg/`;`mvn -pl firstleg -am test` |
## 修改约束
@@ -44,7 +45,7 @@
## 三期装柜与 PDA 资源结算
- 智能装柜候选只能按同一国内中转仓合柜;推荐顺序按入库时间先进先出。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。
- PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
- PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;首次打印冻结箱码映射,重复打印和墨家测量复用同一箱码,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
- PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。
- `releaseMode=AUTO` 时,释放资源数为 `ceil(实际拣货箱数 × 原资源数 / 原可用箱数)`;`MANUAL` 按 `locationIds` 选择,空数组表示明确释放 0 个。未传模式时,`locationIds=null` 兼容为 AUTO。
- 头程快照必须保存仓库 `reservation_id`。A0 是共享临时库位,多个托盘可具有相同 `location_id`,跨服务结算、转移、释放必须使用 `reservationId`,不得把 `locationId` 当作唯一资源标识。
@@ -30,6 +30,9 @@ public final class InboundMeasurementResponses {
Long measurementId,
String sourceType,
String measurementStatus,
@Schema(description = "入库主单乐观锁版本,用于测量审核、驳回和异常放行", example = "3")
Integer inboundVersion,
@Schema(description = "测量批次乐观锁版本,用于测量草稿和明细维护", example = "4")
Integer version,
String originalDocumentNo,
BigDecimal originalGrossWeightKg,
@@ -17,13 +17,13 @@ public class InternalFinanceCostSnapshotController {
@Value("${baiyue.security.internal-service-token}") private String expectedToken;
public InternalFinanceCostSnapshotController(FinanceCostSnapshotService service) { this.service = service; }
@GetMapping("/customers/{customerId}/containers/{containerId}")
public ApiResponse<FinanceCostSnapshotService.ContainerSnapshot> container(
@GetMapping("/customers/{customerId}/loading-plans/{loadingPlanId}")
public ApiResponse<FinanceCostSnapshotService.ContainerSnapshot> loadingPlan(
@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@PathVariable long customerId, @PathVariable long containerId) {
@PathVariable long customerId, @PathVariable long loadingPlanId) {
if (token == null || !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8), expectedToken.getBytes(StandardCharsets.UTF_8))) {
throw new BizException(ResultCode.FORBIDDEN, "内部服务令牌无效");
}
return ApiResponse.success(service.container(customerId, containerId));
return ApiResponse.success(service.loadingPlan(customerId, loadingPlanId));
}
}
@@ -13,27 +13,26 @@ public class FinanceCostSnapshotService {
private final SmartLoadingRepository repository;
public FinanceCostSnapshotService(SmartLoadingRepository repository) { this.repository = repository; }
public ContainerSnapshot container(long customerId, long loadingPlanId) {
public ContainerSnapshot loadingPlan(long customerId, long loadingPlanId) {
if (customerId <= 0 || loadingPlanId <= 0) {
throw new BizException(ResultCode.NOT_FOUND, "柜次不存在");
}
// Older Finance deployments called this endpoint with the physical
// container booking ID. Resolve both identities during rollout while
// preserving the caller's ID in the snapshot contract.
var plan = repository.plan(loadingPlanId);
if (plan == null) plan = repository.planByBooking(loadingPlanId);
if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次不存在");
long effectivePlanId = plan.getId() == null || plan.getId() <= 0 ? loadingPlanId : plan.getId();
List<OrderVolume> orders = repository.items(effectivePlanId).stream()
if (plan.getContainerBookingId() == null || plan.getContainerBookingId() <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "装柜计划未关联柜次");
}
List<OrderVolume> orders = repository.items(loadingPlanId).stream()
.filter(item -> item.getAccountId() != null && item.getAccountId() == customerId)
.filter(item -> item.getOrderId() != null && item.getOrderId() > 0)
.filter(item -> item.getVolumeCbm() != null && item.getVolumeCbm().signum() > 0)
.map(item -> new OrderVolume(item.getOrderId(), item.getInboundId(), item.getVolumeCbm()))
.toList();
if (orders.isEmpty()) throw new BizException(ResultCode.BAD_REQUEST, "柜次没有该客户的有效订单体积");
return new ContainerSnapshot(loadingPlanId, customerId, orders);
return new ContainerSnapshot(loadingPlanId, plan.getContainerBookingId(), customerId, orders);
}
public record ContainerSnapshot(long containerId, long customerId, List<OrderVolume> orders) {}
public record ContainerSnapshot(long loadingPlanId, long containerBookingId, long customerId,
List<OrderVolume> orders) {}
public record OrderVolume(long orderId, Long inboundId, BigDecimal volumeCbm) {}
}
@@ -347,6 +347,7 @@ public class InboundMeasurementService {
review == null ? null : review.getReviewerName(), measurement == null ? null : measurement.getId(),
measurement == null ? null : measurement.getSourceType(),
measurement == null ? null : measurement.getStatus(),
inbound.getVersion(),
measurement == null ? null : measurement.getVersion(),
measurement == null ? null : measurement.getOriginalDocumentNo(),
measurement == null ? null : measurement.getOriginalGrossWeightKg(),
@@ -0,0 +1,44 @@
package com.baiyue.firstleg.loading.controller;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.loading.service.FinanceCostSnapshotService;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.bind.annotation.GetMapping;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class InternalFinanceCostSnapshotControllerTest {
private final FinanceCostSnapshotService service = mock(FinanceCostSnapshotService.class);
private final InternalFinanceCostSnapshotController controller = controller();
@Test
void requiresInternalTokenAndUsesStableLoadingPlanIdentity() {
assertThatThrownBy(() -> controller.loadingPlan("wrong", 10L, 88L))
.isInstanceOf(BizException.class).hasMessageContaining("令牌无效");
controller.loadingPlan("finance-token", 10L, 88L);
verify(service).loadingPlan(10L, 88L);
}
@Test
void exposesLoadingPlanSnapshotPath() throws Exception {
var mapping = InternalFinanceCostSnapshotController.class
.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class);
assertThat(mapping.value()).containsExactly("/internal/firstleg/v1/finance-cost-snapshots");
var method = InternalFinanceCostSnapshotController.class.getDeclaredMethod(
"loadingPlan", String.class, long.class, long.class);
assertThat(method.getAnnotation(GetMapping.class).value())
.containsExactly("/customers/{customerId}/loading-plans/{loadingPlanId}");
}
private InternalFinanceCostSnapshotController controller() {
var value = new InternalFinanceCostSnapshotController(service);
ReflectionTestUtils.setField(value, "expectedToken", "finance-token");
return value;
}
}
@@ -24,28 +24,34 @@ class FinanceCostSnapshotServiceTest {
@Test
void returnsOnlyValidVolumesOwnedByCustomer() {
when(repository.plan(88L)).thenReturn(mock(LoadingPlanEntity.class));
var plan = new LoadingPlanEntity();
plan.setId(88L);
plan.setContainerBookingId(99L);
when(repository.plan(88L)).thenReturn(plan);
when(repository.items(88L)).thenReturn(List.of(
item(10L, 101L, 1001L, "1.25"),
item(11L, 102L, 1002L, "2.00"),
item(10L, 103L, 1003L, "0")));
var snapshot = service.container(10L, 88L);
var snapshot = service.loadingPlan(10L, 88L);
assertThat(snapshot.customerId()).isEqualTo(10L);
assertThat(snapshot.containerId()).isEqualTo(88L);
assertThat(snapshot.loadingPlanId()).isEqualTo(88L);
assertThat(snapshot.containerBookingId()).isEqualTo(99L);
assertThat(snapshot.orders()).containsExactly(
new FinanceCostSnapshotService.OrderVolume(101L, 1001L, new BigDecimal("1.25")));
}
@Test
void rejectsContainerWithoutValidCustomerVolume() {
when(repository.plan(88L)).thenReturn(mock(LoadingPlanEntity.class));
var plan = new LoadingPlanEntity();
plan.setContainerBookingId(99L);
when(repository.plan(88L)).thenReturn(plan);
when(repository.items(88L)).thenReturn(List.of(
item(11L, 102L, 1002L, "2.00"),
item(10L, 103L, 1003L, "0")));
assertThatThrownBy(() -> service.container(10L, 88L))
assertThatThrownBy(() -> service.loadingPlan(10L, 88L))
.hasMessageContaining("没有该客户的有效订单体积");
}
@@ -53,23 +59,18 @@ class FinanceCostSnapshotServiceTest {
void rejectsUnknownContainer() {
when(repository.plan(88L)).thenReturn(null);
assertThatThrownBy(() -> service.container(10L, 88L))
assertThatThrownBy(() -> service.loadingPlan(10L, 88L))
.hasMessageContaining("柜次不存在");
}
@Test
void resolvesLegacyContainerBookingIdToLoadingPlan() {
void rejectsPlanWithoutContainerBooking() {
var plan = new LoadingPlanEntity();
plan.setId(88L);
plan.setContainerBookingId(99L);
when(repository.plan(99L)).thenReturn(null);
when(repository.planByBooking(99L)).thenReturn(plan);
when(repository.items(88L)).thenReturn(List.of(item(10L, 101L, 1001L, "1.25")));
when(repository.plan(88L)).thenReturn(plan);
var snapshot = service.container(10L, 99L);
assertThat(snapshot.containerId()).isEqualTo(99L);
assertThat(snapshot.orders()).hasSize(1);
assertThatThrownBy(() -> service.loadingPlan(10L, 88L))
.hasMessageContaining("未关联柜次");
}
private LoadingPlanItemEntity item(long customerId, long orderId, long inboundId, String volume) {
@@ -87,12 +87,20 @@ class InboundMeasurementServiceTest {
@Test
void detailReturnsStandardPalletRecommendationWithoutAllocatingPallet() {
when(repository.findInbound(1L, 10L)).thenReturn(inbound("MEASURE_PENDING"));
InboundOrderEntity inbound = inbound("MEASURE_PENDING");
inbound.setVersion(7);
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(30L);
measurement.setVersion(4);
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findLatestMeasurement(1L, 10L)).thenReturn(measurement);
when(repository.listWeights(1L, 10L)).thenReturn(List.of());
InboundMeasurementResponses.Detail result = service.detail(1L, 10L);
assertThat(result.recommendedPalletType()).isEqualTo("STANDARD");
assertThat(result.inboundVersion()).isEqualTo(7);
assertThat(result.version()).isEqualTo(4);
verify(repository, never()).updateInbound(any());
}