From 6737c4c3271aa74d5b23320cc6ddb9b9e3474092 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 20:53:24 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PlatformInboundMeasurementController.java | 4 +- .../service/CartonMeasurementService.java | 4 +- .../firstleg/service/FbaPricingService.java | 19 +- .../InboundMeasurementOpenApiService.java | 3 +- .../service/InboundMeasurementService.java | 8 +- .../service/InboundOrderCreationService.java | 20 +- .../service/MeasurementBatchService.java | 2 +- .../service/MeasurementReviewService.java | 3 + .../main/resources/openapi/firstleg-v1.yaml | 8 +- .../service/FbaPricingServiceTest.java | 55 ++++- .../InboundOrderCreationServiceTest.java | 20 ++ .../service/MeasurementReviewServiceTest.java | 16 ++ .../service/OrderApplicationService.java | 34 ++- .../service/OrderInboundOutboxPublisher.java | 9 +- .../service/UnifiedOrderQueryService.java | 29 ++- .../OrderApplicationServiceStateTest.java | 27 ++ .../OrderInboundOutboxPublisherTest.java | 16 ++ .../service/UnifiedOrderQueryServiceTest.java | 6 +- scripts/AGENTS.md | 2 +- scripts/README.md | 2 +- scripts/real-order-xlsx-import.md | 18 +- scripts/real-order-xlsx-test-env.mjs | 232 +++++++++++++----- scripts/real-order-xlsx-test-env.test.mjs | 108 +++++++- 23 files changed, 525 insertions(+), 120 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java index 569cfa5e5..a7196bb7b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java @@ -79,7 +79,7 @@ public class PlatformInboundMeasurementController { @BusinessAudit(event = "INBOUND_MEASUREMENT_DRAFT_SAVE", resourceType = "INBOUND", resourceId = "#inboundId", description = "保存入库测量草稿") - @Operation(summary = "保存测量草稿", description = "仅在待测量、测量中或审核驳回状态保存人工测量批次和服务端计算明细;" + @Operation(summary = "保存测量草稿", description = "订单待审核但货物已到仓时可先保存;也可在待测量、测量中或审核驳回状态保存人工测量批次和服务端计算明细;" + "进入待审核后批次冻结,确认箱数修正须使用 measurement/items/{itemId}/confirmed-package-count 专用接口。") @PutMapping("/measurement/draft") @PreAuthorize("hasAnyAuthority('platform:firstleg:inbound:warehouse-suggestion:edit'," @@ -96,7 +96,7 @@ public class PlatformInboundMeasurementController { @BusinessAudit(event = "INBOUND_MEASUREMENT_SUBMIT", resourceType = "INBOUND", resourceId = "#inboundId", description = "确认测量并提交审核") @Operation(summary = "确认测量并提交审核", - description = "校验测量图片和全部 OCR 重量后,最多推进到 MEASURE_PENDING。") + description = "校验测量图片和全部 OCR 重量后,最多推进到 MEASURE_PENDING;订单未审核时只保留待审核测量,不能审核通过或进入后续履约。") @PostMapping("/measurement/submit") @PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:submit')") public ApiResponse submit( diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java index 7ab22451a..9e8086042 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java @@ -34,9 +34,9 @@ import java.util.Set; public class CartonMeasurementService { private static final Logger log = LoggerFactory.getLogger(CartonMeasurementService.class); private static final Set DEVICE_WRITABLE_STATUSES = Set.of( - "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED"); + "PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED"); private static final Set MANUAL_COUNT_EDITABLE_STATUSES = Set.of( - "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED"); + "PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED"); private static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167"); private static final long MAX_IMAGE_BYTES = 5L * 1024 * 1024; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java index 633c88542..ace5297cd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java @@ -219,6 +219,7 @@ public class FbaPricingService { /** * 柜内明细变更后,按最新有效柜内成员重新命中区域重量阶梯。 * 仅使用当前费用快照冻结的选定重量、体积和测量来源,绝不回退到入库单实测值。 + * 已发送或客户已确认的费用继续参与柜级计费量聚合,但自身快照保持不变。 */ @Transactional public ContainerFeeRecalculationResult recalculateUnlockedContainerCustomerFees( @@ -257,19 +258,22 @@ public class FbaPricingService { notReady = true; continue; } - if ("SENT".equals(current.getFeeStatus()) || current.getCustomerConfirmedAt() != null) { - throw conflict("柜内存在已发送或客户已确认费用,不能重算阶梯费用"); - } - if (!List.of("CALCULATED", "CONFIRMED").contains(current.getFeeStatus())) { + boolean locked = List.of("CONFIRMED", "SENT").contains(current.getFeeStatus()) + || current.getCustomerConfirmedAt() != null; + if (!locked && !"CALCULATED".equals(current.getFeeStatus())) { throw conflict("柜内费用状态已变化,不能重算阶梯费用"); } BigDecimal weight = requiredFrozenQuantity(current.getActualWeightKg(), "重量"); BigDecimal volume = requiredFrozenQuantity(current.getActualVolumeCbm(), "体积"); members.add(new ContainerFeeMember(inbound, current, weight.setScale(3, RoundingMode.HALF_UP), volume.setScale(4, RoundingMode.HALF_UP), - chargeableWeight(weight, volume))); + chargeableWeight(weight, volume), locked)); } if (notReady) return new ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus.NOT_READY, 0); + List unlockedMembers = members.stream().filter(member -> !member.locked()).toList(); + if (unlockedMembers.isEmpty()) { + return new ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus.NOT_APPLICABLE, 0); + } InboundPricingIdempotencyEntity token = beginContainerRecalculation( tenantId, containerBookingId, key, normalizedTrigger); if ("COMPLETED".equals(token.getStatus())) { @@ -281,7 +285,7 @@ public class FbaPricingService { .map(member -> new ContainerTierAmountAllocator.Member(member.inbound().getId(), member.chargeableQuantity())) .toList(); List quotes = new ArrayList<>(); - for (ContainerFeeMember member : members) { + for (ContainerFeeMember member : unlockedMembers) { PricingPolicy policy = pricingPolicy(member.inbound(), now); ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(tenantId, new EstimateRequest( member.inbound().getWarehouseId(), member.inbound().getChannelCode(), member.inbound().getDestinationCode(), @@ -371,7 +375,8 @@ public class FbaPricingService { } private record ContainerFeeMember(InboundPricingEntity inbound, InboundFeeEntity current, - BigDecimal weight, BigDecimal volume, BigDecimal chargeableQuantity) { } + BigDecimal weight, BigDecimal volume, BigDecimal chargeableQuantity, + boolean locked) { } private record ContainerFeeQuote(ContainerFeeMember member, PricingPolicy policy, ActivePriceBookEstimator.Estimate estimate) { } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiService.java index 594ddf8a6..5e705112b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiService.java @@ -47,7 +47,8 @@ import java.util.Set; public class InboundMeasurementOpenApiService { private static final Logger log = LoggerFactory.getLogger(InboundMeasurementOpenApiService.class); static final String CALLBACK_PATH = "/openapi/v1/inbound-measurements"; - private static final Set ACCEPTABLE_INBOUND_STATUSES = Set.of("WAIT_MEASURE", "MEASURE_REJECTED"); + private static final Set ACCEPTABLE_INBOUND_STATUSES = Set.of( + "PREPARING", "WAIT_MEASURE", "MEASURE_REJECTED"); private static final BigDecimal CUBIC_CENTIMETERS_PER_CBM = new BigDecimal("1000000"); private static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167"); private static final String CALCULATION_RULE_VERSION = "V1_CBM_CEILING_4_GIRTH_L2WH"; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java index 66c4c636a..6e15a20d8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java @@ -49,9 +49,9 @@ public class InboundMeasurementService { /** OCR 结果无效或不完整,必须人工录入或修正。 */ private static final String WEIGHT_NEEDS_MANUAL = "NEEDS_MANUAL"; private static final Set MEASUREMENT_DRAFT_EDITABLE_STATUSES = Set.of( - "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED"); + "PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED"); private static final Set WEIGHT_EDITABLE_STATUSES = Set.of( - "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED"); + "PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED"); /** 测量完成后的默认普通托盘类型;实际拆托和库位占用由提交编排服务执行。 */ private static final String RECOMMENDED_PALLET_TYPE = "STANDARD"; private static final BigDecimal LB_TO_KG = new BigDecimal("0.45359237"); @@ -167,7 +167,7 @@ public class InboundMeasurementService { long tenantId, long inboundId, long operatorId, InboundMeasurementRequests.SubmitRequest request) { InboundOrderEntity inbound = requireInbound(tenantId, inboundId); - if (!Set.of("WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED") + if (!Set.of("PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED") .contains(inbound.getProcessStatus())) { throw new BizException(ResultCode.CONFLICT, "测量已提交或审核完成,不能重复提交或修改冻结结果"); } @@ -384,7 +384,7 @@ public class InboundMeasurementService { inbound.setActualGrossWeightKg(totals.grossWeightKg()); inbound.setActualNetWeightKg(totals.netWeightKg()); inbound.setPalletCount(totals.palletCount()); - if ("WAIT_MEASURE".equals(inbound.getProcessStatus())) { + if (Set.of("PREPARING", "WAIT_MEASURE").contains(inbound.getProcessStatus())) { inbound.setProcessStatus("MEASURING"); } inbound.setUpdatedBy(operatorId); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java index e0db31b88..350edfe28 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java @@ -40,7 +40,7 @@ public class InboundOrderCreationService { long inboundId = requireMatchingInboundCode(existing, command.inboundCode()); FulfillmentTaskEntity task = fulfillmentTasks.provision(command, LocalDateTime.now()); linkTask(existing, task.getId()); - upgradePreparing(existing, command); + approvePreparedInbound(existing, command); savePickupSnapshot(existing.getId(), command, LocalDateTime.now()); return inboundId; } @@ -132,7 +132,7 @@ public class InboundOrderCreationService { } LocalDateTime now = LocalDateTime.now(); - InboundOrderEntity inbound = newInbound(command, now, null, "PREPARING", "DRAFT"); + InboundOrderEntity inbound = newInbound(command, now, null, "PREPARING", "SUBMITTED"); try { orders.insert(inbound); savePickupSnapshot(inbound.getId(), command, now); @@ -153,6 +153,7 @@ public class InboundOrderCreationService { .eq(InboundOrderEntity::getTenantId, existing.getTenantId()) .eq(InboundOrderEntity::getId, existing.getId()) .eq(InboundOrderEntity::getProcessStatus, "PREPARING") + .set(InboundOrderEntity::getOrderStatusSnapshot, "SUBMITTED") .set(InboundOrderEntity::getOrderNo, command.orderNo()) .set(InboundOrderEntity::getCustomerId, command.customerId()) .set(InboundOrderEntity::getCustomerName, command.customerName()) @@ -175,13 +176,16 @@ public class InboundOrderCreationService { .setSql("version = version + 1")); } - private void upgradePreparing(InboundOrderEntity existing, OrderApprovedInboundCommand command) { - if (!"PREPARING".equals(existing.getProcessStatus())) return; + private void approvePreparedInbound(InboundOrderEntity existing, OrderApprovedInboundCommand command) { + String currentStatus = existing.getProcessStatus(); + if (currentStatus == null || !java.util.Set.of("PREPARING", "MEASURING", "MEASURE_PENDING") + .contains(currentStatus)) return; + String approvedStatus = "PREPARING".equals(currentStatus) ? "WAIT_MEASURE" : currentStatus; var update = new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper() .eq(InboundOrderEntity::getTenantId, existing.getTenantId()) .eq(InboundOrderEntity::getId, existing.getId()) - .eq(InboundOrderEntity::getProcessStatus, "PREPARING") - .set(InboundOrderEntity::getProcessStatus, "WAIT_MEASURE") + .eq(InboundOrderEntity::getProcessStatus, currentStatus) + .set(InboundOrderEntity::getProcessStatus, approvedStatus) .set(InboundOrderEntity::getOrderStatusSnapshot, "APPROVED") .set(InboundOrderEntity::getTotalPackageCount, command.totalPackageCount()) .set(InboundOrderEntity::getFulfillmentType, fulfillmentType(command.fulfillmentType())) @@ -189,12 +193,12 @@ public class InboundOrderCreationService { .setSql("version = version + 1"); if (orders.update(null, update) != 1) { InboundOrderEntity current = findByOrder(existing.getTenantId(), existing.getOrderId(), true); - if (current == null || !"WAIT_MEASURE".equals(current.getProcessStatus())) { + if (current == null || !"APPROVED".equals(current.getOrderStatusSnapshot())) { throw new BizException(ResultCode.CONFLICT, "预入库单审核升级发生并发冲突"); } return; } - addHistory(existing.getId(), existing.getTenantId(), "PREPARING", "WAIT_MEASURE", + addHistory(existing.getId(), existing.getTenantId(), currentStatus, approvedStatus, "ORDER_APPROVED", LocalDateTime.now()); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java index bad5a21ad..b2345e2b5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java @@ -43,7 +43,7 @@ public class MeasurementBatchService { /** 已接收第三方测量数据,待平台提交审核。 */ static final String STATUS_RECEIVED = "RECEIVED"; private static final Set EDITABLE_INBOUND_STATUSES = Set.of( - "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED"); + "PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED"); static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167"); private static final BigDecimal MAX_RAW_VOLUME_CBM = new BigDecimal("999999999999.999999"); private static final BigDecimal MAX_TOTAL_VOLUME_CBM = new BigDecimal("99999999999999.9999"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java index ecfe7c525..54175a32a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java @@ -64,6 +64,9 @@ public class MeasurementReviewService { InboundOrderEntity order = requireOrder(tenantId, inboundId); requirePendingReview(order); + if (!"APPROVED".equals(order.getOrderStatusSnapshot())) { + throw new BizException(ResultCode.CONFLICT, "订单尚未审核通过,已到仓测量数据可保留但暂不能审核"); + } InboundMeasurementEntity measurement = repository.findMeasurement( tenantId, inboundId, request.measurementId()); if (measurement == null) { diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 51f3c9c4e..fcf2441e8 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -950,8 +950,8 @@ paths: summary: 保存测量草稿 description: >- 原单据号码、申报毛重和申报体积由服务端从客户订单生成快照,客户端不得提交覆盖; - actualGoodsName 默认使用客户货物品名汇总,但允许测量人员修正。仅允许 WAIT_MEASURE、MEASURING - 或 MEASURE_REJECTED 状态保存;进入 MEASURE_PENDING 后整批测量冻结,确认箱数修正使用 + actualGoodsName 默认使用客户货物品名汇总,但允许测量人员修正。仅允许 PREPARING(订单待审核但已到仓)、 + WAIT_MEASURE、MEASURING 或 MEASURE_REJECTED 状态保存;进入 MEASURE_PENDING 后整批测量冻结,确认箱数修正使用 measurement/items/{itemId}/confirmed-package-count 明细专用接口。 parameters: - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} @@ -1154,7 +1154,7 @@ paths: /api/platform/v1/inbounds/{inboundId}/measurements/approve: post: summary: 审核通过测量 - description: 需要 firstleg:inbound:measurement-review 权限。审核通过后立即进入 WAIT_SPLIT,并按该次测量数据尽力生成 CALCULATED 初算费用;价格未覆盖或初算异常只返回非阻断提示,不影响审核结果和待分柜状态。 + description: 需要 firstleg:inbound:measurement-review 权限,且 Order 权威订单必须已审核通过。订单待审核期间可保留并提交到仓测量数据,但不能审核测量。审核通过后立即进入 WAIT_SPLIT,并按该次测量数据尽力生成 CALCULATED 初算费用;价格未覆盖或初算异常只返回非阻断提示,不影响审核结果和待分柜状态。 parameters: - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} @@ -1175,7 +1175,7 @@ paths: /api/platform/v1/inbounds/{inboundId}/measurements/submit: post: summary: 提交测量完成 - description: 提交只校验并复用称重图片确认阶段已经 OCCUPIED 且绑定完整的权威整票库位块,然后进入客服待审核;不会首次分配、重建或更换已显示槽位。 + description: 提交只校验并复用称重图片确认阶段已经 OCCUPIED 且绑定完整的权威整票库位块,然后进入客服待审核;不会首次分配、重建或更换已显示槽位。Order 仍待审核时允许提交并冻结到仓测量证据,但不得继续审核测量或进入计费、分柜和装柜。 parameters: - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java index 9bede13f7..407eded52 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java @@ -788,7 +788,7 @@ class FbaPricingServiceTest { InboundFeeEntity first = savedFee("CALCULATED"); first.setActualWeightKg(new BigDecimal("90.000")); first.setActualVolumeCbm(new BigDecimal("0.1000")); first.setMeasurementId(301L); first.setMeasurementSource("OUTER"); - InboundFeeEntity second = savedFee("CONFIRMED"); + InboundFeeEntity second = savedFee("CALCULATED"); second.setId(502L); second.setInboundId(30002L); second.setActualWeightKg(new BigDecimal("110.000")); second.setActualVolumeCbm(new BigDecimal("0.1000")); second.setMeasurementId(302L); second.setMeasurementSource("ACTUAL"); @@ -834,20 +834,61 @@ class FbaPricingServiceTest { } @Test - @DisplayName("柜内任一已发送或客户确认费用阻断整组重算") - void rejectsContainerRecalculationWhenAnyFeeIsImmutable() { + @DisplayName("柜内已发送费用保持锁定且不阻断其他未锁定费用重算") + void keepsImmutableContainerFeeAndRecalculatesOnlyUnlockedMembers() { readyRepository(); - InboundPricingEntity inbound = inbound("100", "0.1"); + InboundPricingEntity lockedInbound = inbound("100", "0.1"); + InboundPricingEntity unlockedInbound = inbound("80", "0.1"); + unlockedInbound.setId(30002L); unlockedInbound.setInboundCode("RK-002"); InboundFeeEntity sent = savedFee("SENT"); + sent.setActualWeightKg(new BigDecimal("100")); sent.setActualVolumeCbm(new BigDecimal("0.1")); + InboundFeeEntity calculated = savedFee("CALCULATED"); + calculated.setId(502L); calculated.setInboundId(30002L); + calculated.setActualWeightKg(new BigDecimal("80")); calculated.setActualVolumeCbm(new BigDecimal("0.1")); + when(repository.containerTierMembersForUpdate(901L, TENANT)).thenReturn(List.of( + memberRow(901L, INBOUND, "100", "0.1"), memberRow(901L, 30002L, "80", "0.1") + )); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(lockedInbound); + when(repository.findInbound(TENANT, 30002L)).thenReturn(unlockedInbound); + when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(sent); + when(repository.findLatestFeeForUpdate(TENANT, 30002L)).thenReturn(calculated); + when(repository.nextFeeVersion(TENANT, 30002L)).thenReturn(2); + when(repository.completeIdempotency(any(), eq(901L), eq(NOW))).thenReturn(1); + when(repository.supersedeCurrent(TENANT, 502L, "CALCULATED")).thenReturn(1); + when(repository.markInboundContainerFeeRecalculated(eq(TENANT), eq(30002L), anyString(), anyString(), any())) + .thenReturn(1); + when(priceBookEstimator.estimate(eq(TENANT), any())).thenReturn(regionEstimate("180", "8", "1440")); + when(containerTierAllocator.allocate(any(), eq(new BigDecimal("8")))).thenReturn( + new ContainerTierAmountAllocator.Allocation(new BigDecimal("180"), new BigDecimal("1440"), + Map.of(INBOUND, new BigDecimal("800.00"), 30002L, new BigDecimal("640.00")))); + + var result = service.recalculateUnlockedContainerCustomerFees(901L, TENANT, "loading-change-2"); + + assertThat(result.status()).isEqualTo(FbaPricingService.ContainerFeeRecalculationStatus.RECALCULATED); + assertThat(result.recalculatedCount()).isEqualTo(1); + ArgumentCaptor fee = ArgumentCaptor.forClass(InboundFeeEntity.class); + verify(repository).insertFee(fee.capture()); + assertThat(fee.getValue().getInboundId()).isEqualTo(30002L); + assertThat(fee.getValue().getSubtotalAmount()).isEqualByComparingTo("640.00"); + verify(repository, never()).supersedeCurrent(TENANT, 501L, "SENT"); + verify(priceBookEstimator).estimate(eq(TENANT), any()); + } + + @Test + @DisplayName("柜内费用全部锁定时关系变更无需重算且不报错") + void skipsContainerRecalculationWhenEveryFeeIsImmutable() { + InboundPricingEntity inbound = inbound("100", "0.1"); + InboundFeeEntity confirmed = savedFee("CONFIRMED"); when(repository.containerTierMembersForUpdate(901L, TENANT)).thenReturn(List.of( memberRow(901L, INBOUND, "100", "0.1") )); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); - when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(sent); + when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(confirmed); - assertThatThrownBy(() -> service.recalculateUnlockedContainerCustomerFees(901L, TENANT, "loading-change-2")) - .isInstanceOf(BizException.class).hasMessageContaining("已发送或客户已确认"); + var result = service.recalculateUnlockedContainerCustomerFees(901L, TENANT, "loading-change-locked"); + assertThat(result.status()).isEqualTo(FbaPricingService.ContainerFeeRecalculationStatus.NOT_APPLICABLE); + assertThat(result.recalculatedCount()).isZero(); verify(repository, never()).supersedeCurrent(anyLong(), anyLong(), anyString()); verify(repository, never()).insertFee(any()); verify(priceBookEstimator, never()).estimate(anyLong(), any()); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java index 93c19b4b3..98e4b2f4f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java @@ -157,6 +157,26 @@ class InboundOrderCreationServiceTest { verify(orders, times(2)).update(any(), any(Wrapper.class)); } + @Test + void approvalKeepsMeasurementAlreadyCapturedBeforeOrderReview() { + InboundOrderEntity prepared = inbound(900L, "PCHK2608140001", "UNPRICED", null, null); + prepared.setProcessStatus("MEASURING"); + prepared.setOrderStatusSnapshot("SUBMITTED"); + when(orders.selectOne(any(Wrapper.class))).thenReturn(prepared); + when(orders.update(any(), any(Wrapper.class))).thenReturn(1); + when(fulfillmentTasks.provision(any(), any())).thenReturn(task(700L)); + + assertThat(service.createFromApprovedOrder(approvedCommand("PCHK2608140001"))).isEqualTo(900L); + + verify(orders, times(2)).update(any(), any(Wrapper.class)); + ArgumentCaptor historyCaptor = + ArgumentCaptor.forClass(InboundStatusHistoryEntity.class); + verify(histories).insert(historyCaptor.capture()); + assertThat(historyCaptor.getValue().getFromStatus()).isEqualTo("MEASURING"); + assertThat(historyCaptor.getValue().getToStatus()).isEqualTo("MEASURING"); + assertThat(historyCaptor.getValue().getAction()).isEqualTo("ORDER_APPROVED"); + } + private static InboundOrderEntity inbound(long id, String code, String feeStatus, Long carrierId, String carrierName) { InboundOrderEntity inbound = new InboundOrderEntity(); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java index 1452af2df..47d037ebd 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java @@ -65,6 +65,21 @@ class MeasurementReviewServiceTest { verify(repository, never()).transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong()); } + @Test + @DisplayName("订单待审核时保留测量数据但不能审核测量") + void shouldBlockMeasurementReviewUntilOrderIsApproved() { + Fixture fixture = fixture(false); + fixture.order.setOrderStatusSnapshot("SUBMITTED"); + + assertThatThrownBy(() -> fixture.service.approve(10001L, 9L, "审核员", 10L, + "approve-before-order", new MeasurementReviewRequest(20L, 3, null))) + .isInstanceOf(BizException.class) + .hasMessageContaining("订单尚未审核通过"); + + verify(fixture.repository, never()).transitionReview( + anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong()); + } + @Test @DisplayName("审核通过是测量到计费的唯一正常推进关口") void shouldApproveOnlyPendingSubmittedMeasurement() { @@ -249,6 +264,7 @@ class MeasurementReviewServiceTest { when(properties.isAllowRejectedMeasurement()).thenReturn(allowRelease); InboundOrderEntity order = new InboundOrderEntity(); order.setProcessStatus("MEASURE_PENDING"); + order.setOrderStatusSnapshot("APPROVED"); order.setMeasureReviewStatus("PENDING"); order.setTotalPackageCount(5); order.setActualGrossWeightKg(new BigDecimal("1787.500")); diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index bbc2390d9..de11b6b0b 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -187,7 +187,10 @@ public class OrderApplicationService { if (target == OrderStatus.COMPLETED) order.setCompletedAt(LocalDateTime.now()); if (repository.saveOrder(tenantId, order) != 1) throw new BizException("Concurrent order update"); record(tenantId, id, from, target, target.name(), reason, userId, username); - if (target == OrderStatus.APPROVED || target == OrderStatus.CANCELLED) writeOutbox(order, target); + if (target == OrderStatus.SUBMITTED) prepareSubmittedInbound(order); + if (target == OrderStatus.SUBMITTED || target == OrderStatus.APPROVED || target == OrderStatus.CANCELLED) { + writeOutbox(order, target); + } } /** 提交订单的幂等入口。 */ @@ -668,9 +671,13 @@ public class OrderApplicationService { private void writeOutbox(OrderEntity order, OrderStatus target) { OutboxEventEntity event = new OutboxEventEntity(); event.setTenantId(order.getTenantId()); event.setAggregateType("FIRST_LEG_ORDER"); event.setAggregateId(order.getId()); - event.setEventType(target == OrderStatus.APPROVED ? "FIRST_LEG_ORDER_APPROVED" : "FIRST_LEG_ORDER_CANCELLED"); + event.setEventType(switch (target) { + case SUBMITTED -> "FIRST_LEG_ORDER_PREPARED"; + case APPROVED -> "FIRST_LEG_ORDER_APPROVED"; + default -> "FIRST_LEG_ORDER_CANCELLED"; + }); try { - event.setPayloadJson(target == OrderStatus.APPROVED + event.setPayloadJson(target == OrderStatus.SUBMITTED || target == OrderStatus.APPROVED ? json.writeValueAsString(inboundCommand(order, totalPackageCount(order))) : json.writeValueAsString(Map.of("tenantId", order.getTenantId(), "orderId", order.getId(), "orderNo", order.getOrderNo(), "status", target.name()))); @@ -699,10 +706,27 @@ public class OrderApplicationService { order.getFulfillmentType() == null ? "STANDARD" : order.getFulfillmentType(), order.getLxCode()); } private void prepareDraftInbound(OrderEntity order, OrderRequests.SaveRequest request) { - FirstlegInboundClient client = firstlegProvider == null ? null : firstlegProvider.getIfAvailable(); - if (client == null || !StringUtils.hasText(internalToken)) return; int packageCount = request.cargoes().stream().map(OrderRequests.Cargo::packageCount) .filter(java.util.Objects::nonNull).mapToInt(Integer::intValue).sum(); + prepareSubmittedInbound(order, packageCount); + } + + /** + * 提交即准备稳定的预入库单,供货物提前到仓时采集称重和箱规; + * 订单审核仍由 Order 独立控制,Firstleg 不得据此推进测量审核或后续履约。 + */ + private void prepareSubmittedInbound(OrderEntity order) { + long total = repository.findCargo(order.getTenantId(), order.getId()).stream() + .map(OrderCargoEntity::getPackageCount) + .filter(java.util.Objects::nonNull) + .mapToLong(Integer::longValue) + .sum(); + prepareSubmittedInbound(order, total > 0 && total <= 1_000_000 ? (int) total : null); + } + + private void prepareSubmittedInbound(OrderEntity order, Integer packageCount) { + FirstlegInboundClient client = firstlegProvider == null ? null : firstlegProvider.getIfAvailable(); + if (client == null || !StringUtils.hasText(internalToken)) return; FirstlegInboundClient.OrderApprovedInboundCommand command = inboundCommand(order, packageCount); Runnable prepare = () -> { try { diff --git a/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java b/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java index e933b22cc..67caa0a69 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java +++ b/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java @@ -17,7 +17,7 @@ import org.springframework.stereotype.Component; import java.time.LocalDateTime; -/** 可靠投递订单审核事件;接收端以订单唯一键保证重复投递安全。 */ +/** 可靠投递订单预入库与审核事件;接收端以订单唯一键保证重复投递安全。 */ @Component @Slf4j public class OrderInboundOutboxPublisher { @@ -38,7 +38,8 @@ public class OrderInboundOutboxPublisher { publishDeclarationUpdate(event); continue; } - if (!"FIRST_LEG_ORDER_APPROVED".equals(event.getEventType())) { + boolean preparing = "FIRST_LEG_ORDER_PREPARED".equals(event.getEventType()); + if (!preparing && !"FIRST_LEG_ORDER_APPROVED".equals(event.getEventType())) { failPermanently(event, "unsupported order event"); continue; } @@ -73,7 +74,9 @@ public class OrderInboundOutboxPublisher { } } try { - ApiResponse response = client.create(internalToken, command); + ApiResponse response = preparing + ? client.prepare(internalToken, command) + : client.create(internalToken, command); if (response == null || !response.successful()) { throw new IllegalStateException("头程入库服务返回失败"); } diff --git a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java index 39caa2e28..e9bbf2a52 100644 --- a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java +++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java @@ -64,9 +64,7 @@ public class UnifiedOrderQueryService { .collect(Collectors.toMap(OrderEntity::getOrderNo, Function.identity(), (left, right) -> left)); List selected = orderNos.stream().map(orderByNo::get).filter(Objects::nonNull).toList(); List orderIds = selected.stream().map(OrderEntity::getId).toList(); - Map inboundByOrder = summaries(null, - selected.stream().filter(order -> order.getStatus() != OrderStatus.SUBMITTED) - .map(OrderEntity::getId).toList()); + Map inboundByOrder = summaries(null, orderIds); Map latestNotes = latestNotes(null, orderIds); Map cargoSummaries = cargoSummaries(null, orderIds); Map childCounts = childCounts(selected); @@ -86,9 +84,10 @@ public class UnifiedOrderQueryService { List statuses = "PENDING_REVIEW".equals(query.mainStatus()) ? List.of(OrderStatus.SUBMITTED) : ACTIVE_ORDER_STATUSES; PageResult source = orders.unifiedPage(tenantId, query, statuses); + List sourceOrderIds = source.records().stream().map(OrderEntity::getId).toList(); Map inboundByOrder = "PENDING_REVIEW".equals(query.mainStatus()) - ? Map.of() - : summaries(tenantId, source.records().stream().map(OrderEntity::getId).toList()); + ? optionalSummaries(tenantId, sourceOrderIds) + : summaries(tenantId, sourceOrderIds); Map latestNotes = latestNotes(tenantId, source.records().stream().map(OrderEntity::getId).toList()); Map cargoSummaries = cargoSummaries(tenantId, source.records().stream().map(OrderEntity::getId).toList()); @@ -117,7 +116,7 @@ public class UnifiedOrderQueryService { orders.findParties(tenantId, orderId), includeInternalNotes ? orders.findNotes(tenantId, orderId) : List.of(), orders.findHistory(tenantId, orderId)); FirstlegUnifiedOrderClient.Detail inbound = order.getStatus() == OrderStatus.SUBMITTED - ? null : firstlegDetail(tenantId, orderId); + ? optionalFirstlegDetail(tenantId, orderId) : firstlegDetail(tenantId, orderId); String latestNote = orderDetail.internalNotes().isEmpty() ? null : orderDetail.internalNotes().get(0).getContent(); String creatorName = orderDetail.statusHistory().stream() .filter(item -> "CREATE".equals(item.getAction())) @@ -192,6 +191,15 @@ public class UnifiedOrderQueryService { Function.identity(), (left, right) -> left)); } + private Map optionalSummaries(Long tenantId, List orderIds) { + try { + return summaries(tenantId, orderIds); + } catch (BizException ignored) { + // Order 待审核列表仍可独立使用;Firstleg 预入库投影恢复后再开放提前测量入口。 + return Map.of(); + } + } + private FirstlegUnifiedOrderClient.Detail firstlegDetail(long tenantId, long orderId) { FirstlegUnifiedOrderClient.Detail detail = requireNullable(call(() -> firstleg.detail(internalToken, new FirstlegUnifiedOrderClient.DetailRequest(tenantId, orderId))), "统一订单履约详情响应无效"); @@ -199,6 +207,15 @@ public class UnifiedOrderQueryService { return detail; } + + private FirstlegUnifiedOrderClient.Detail optionalFirstlegDetail(long tenantId, long orderId) { + try { + return firstlegDetail(tenantId, orderId); + } catch (BizException ignored) { + return null; + } + } + private List validateSummaries( List summaries, String message) { return summaries.stream().map(summary -> validateSummary(summary, message)).toList(); diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java index c959de48a..98f67b1e7 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java @@ -1,7 +1,9 @@ package com.baiyue.order.service; import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.exception.BizException; +import com.baiyue.order.client.FirstlegInboundClient; import com.baiyue.order.client.WarehouseClient; import com.baiyue.order.config.InboundCodeModeProvider; import com.baiyue.order.dto.OrderRequests; @@ -19,6 +21,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.beans.factory.ObjectProvider; import java.math.BigDecimal; import java.time.LocalTime; @@ -272,6 +275,30 @@ class OrderApplicationServiceStateTest { verify(repository).saveOrder(20002L, order); } + @Test + void submittingDraftPreparesInboundForWarehouseMeasurementBeforeReview() { + OrderRepository repository = mock(OrderRepository.class); + OrderEntity order = persistedDraft("SELF_EXPRESS", "OVERSEAS"); + when(repository.findOwned(20002L, 1L)).thenReturn(order); + when(repository.findCargo(20002L, 1L)).thenReturn(List.of(completeCargo())); + when(repository.findParties(20002L, 1L)).thenReturn(List.of()); + when(repository.saveOrder(20002L, order)).thenReturn(1); + FirstlegInboundClient client = mock(FirstlegInboundClient.class); + @SuppressWarnings("unchecked") + ObjectProvider provider = mock(ObjectProvider.class); + when(provider.getIfAvailable()).thenReturn(client); + when(client.prepare(eq("internal-token"), any())).thenReturn(ApiResponse.success(99L)); + OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class), + mock(OrderMasterDataGuard.class)); + ReflectionTestUtils.setField(service, "firstlegProvider", provider); + ReflectionTestUtils.setField(service, "internalToken", "internal-token"); + + service.transit(20002L, 2L, "u", 1L, OrderStatus.SUBMITTED, null); + + verify(client).prepare(eq("internal-token"), argThat(command -> command.orderId() == 1L + && command.totalPackageCount() == 1)); + } + @Test void legacyManualModeStoresTheExternalInboundCodeOnTheDraft() { OrderRepository repository = mock(OrderRepository.class); diff --git a/order/src/test/java/com/baiyue/order/service/OrderInboundOutboxPublisherTest.java b/order/src/test/java/com/baiyue/order/service/OrderInboundOutboxPublisherTest.java index 33285db1b..a339603dd 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderInboundOutboxPublisherTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderInboundOutboxPublisherTest.java @@ -23,6 +23,22 @@ import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; class OrderInboundOutboxPublisherTest { + @Test + void matchingPreparedEventIsDeliveredAndPublished() { + Fixture fixture = fixture(event("FIRST_LEG_ORDER_PREPARED", 11L, 21L, + completePayload(11L, 21L, "BUY_DECLARATION"))); + when(fixture.client.prepare(eq("internal-token"), any())).thenReturn(ApiResponse.success(99L)); + + fixture.publisher.publishApprovedOrders(); + + verify(fixture.client).prepare(eq("internal-token"), + org.mockito.ArgumentMatchers.argThat(command -> command.orderId() == 21L + && command.totalPackageCount() == 4)); + verify(fixture.client, never()).create(any(), any()); + verify(fixture.repository).markOutboxPublished(eq(11L), eq(41L), any(LocalDateTime.class)); + verify(fixture.repository, never()).markOutboxRetry(anyLong(), anyLong(), any()); + } + @Test void matchingApprovedEventIsDeliveredAndPublished() { Fixture fixture = fixture(event("FIRST_LEG_ORDER_APPROVED", 11L, 21L, payload(11L, 21L))); diff --git a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java index a7b18a898..41a61356f 100644 --- a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java @@ -81,7 +81,7 @@ class UnifiedOrderQueryServiceTest { assertThat(row.latestInternalNote()).isEqualTo("客服已确认提货时间"); assertThat(row.relation()).isNull(); }); - verify(firstleg, never()).summaries(any(), any()); + verify(firstleg).summaries(eq("internal-token"), any()); } @Test @@ -439,7 +439,7 @@ class UnifiedOrderQueryServiceTest { } @Test - void pendingReviewDetailUsesOrderInboundCodeWithoutCallingFirstleg() { + void pendingReviewDetailUsesOrderInboundCodeWhenPreparingProjectionIsUnavailable() { OrderEntity order = order(OrderStatus.SUBMITTED); order.setInboundCode("RK-77"); when(orders.findOwned(10001L, 77L)).thenReturn(order); @@ -452,7 +452,7 @@ class UnifiedOrderQueryServiceTest { assertThat(result.summary().inboundCode()).isEqualTo("RK-77"); assertThat(result.order().order().getInboundCode()).isEqualTo("RK-77"); - verify(firstleg, never()).detail(any(), any()); + verify(firstleg).detail(eq("internal-token"), any()); } @Test diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 616b60f4f..e8ffd0bba 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -15,7 +15,7 @@ | Client 快速下单定向验证 | `client-quick-order-test-env-acceptance.mjs` | 使用既有测试客户通过批量草稿接口验证 FBA、海外仓、私人地址、逐行失败和幂等重放;只创建草稿,不提交或审核,不替代 Client 全量入口 | | 待审核订单造数 | `create-pending-review-orders.mjs` | 使用已配置客户创建并提交订单,停在待审核;不审核、不推进后续履约 | | 批量待分柜造数 | `it-test-wait-split-bulk.mjs` | 固定“IT测试”和“义乌仓”,随机客户可选真实线路并尽力推进到 `WAIT_SPLIT`;失败记录后继续,不自动删除成功数据;只复用 `TP-001`/`YW-TP-002`,不新增托盘或库位,普通位不足落 `A0` | -| 源 Excel 订单造数 | `real-order-xlsx-test-env.mjs`、`real-order-xlsx-import.md` | 按源入仓码归并及严格主数据匹配,分别支持预检、真实字段草稿和显式模拟测量到待分柜;仅单个已配置客户,私人地址及一件代发工作托盘未获确认前阻断;批次检查点防重复,清库后用新批次重跑 | +| 源 Excel 订单造数 | `real-order-xlsx-test-env.mjs`、`real-order-xlsx-import.md` | 按源入仓码归并及严格主数据匹配,支持预检、真实字段草稿、批量提交到待审核和少量完整测量到待分柜;待审核阶段不审核且不使用托盘/库位;仅单个已配置客户,私人地址及一件代发工作托盘未获确认前阻断;批次检查点防重复,清库后用新批次重跑 | | Admin HTTP 内部模块 | `admin-test-env-modules/*.mjs` | 由 Admin 总入口动态加载,除文档明确的独立编排器外不得当作独立全量入口;`*contract.test.mjs` 只做离线合同 | | 入仓测量与整票库位块专项 | `admin-test-env-modules/inbound-measurement.mjs`、`admin-test-env-modules/README.md` | 仍从 Admin 总入口按 `orders,inbound-measurement` 依赖链执行;开启 `--verify-pallet-location-guide true` 后逐张确认回查块扩容只能追加,既有槽身份不得漂移,并验证删除图片后空槽保留与替代图片复用;会写目标环境,必须有对应环境授权 | | Finance 扩展 HTTP 模块 | `admin-test-env-modules/firstleg-container-fee-allocation.mjs`、`finance-direct-paid-isolation.mjs`、`phase4-overseas-finance-cross-region.mjs` | 分别验证柜费跨客户分摊、海外仓服务直接已付隔离、五类海外费用跨区入池;必须由 Admin 总入口按已登记依赖运行,使用本轮资源且禁止借历史数据,真实 HTTP 仍须当前轮授权 | diff --git a/scripts/README.md b/scripts/README.md index 3e75b558d..69aba35e5 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -69,7 +69,7 @@ Client 全量、Client 入库测量与计费、待审核订单和批量待分柜 | 测试环境 HTTP | `client-phase2-test-env-acceptance.mjs` | Client 入库、测量、费用和权限专项;写测试数据 | | 测试环境 HTTP | `create-pending-review-orders.mjs` | 固定客户批量创建并提交待审核订单,不做平台审核和后续履约 | | 测试环境 HTTP | `it-test-wait-split-bulk.mjs` | 固定“IT测试”与“义乌仓”,渠道等随机,逐票推进到 `WAIT_SPLIT`;失败继续并落报告。只复用 `TP-001`/`YW-TP-002`,不新增托盘或库位,库位不足使用公共位 `A0` | -| 测试环境 Excel 造数 | `real-order-xlsx-test-env.mjs` | 从外部源 XLSX 按入仓码归并,严格匹配客户可用渠道、税制、目的仓;`preflight` 只核对,`create` 创建缺失字段留空的草稿,`wait-split` 显式允许测试占位后走正式审核测量。只复用两个原有托盘,不新增主档或库位。参数、限制及重跑说明见 `real-order-xlsx-import.md` | +| 测试环境 Excel 造数 | `real-order-xlsx-test-env.mjs` | 从外部源 XLSX 按入仓码归并,严格匹配客户可用渠道、税制、目的仓;`preflight` 只核对,`create` 创建草稿,`pending-review` 批量提交后停在待审核且不使用托盘/库位,`wait-split` 只供少量完整测量验收。参数、限制及重跑说明见 `real-order-xlsx-import.md` | | 测试环境 HTTP | `dimension-ocr-pricing-test-env.ps1` | 尺寸、OCR 和渠道计价专项,上传本地图片并写测试业务数据;包含历史默认参数,只能显式传入当前目标参数后使用 | | 测试环境 HTTP | `export-center-test-env-acceptance.ps1` | 导出中心测试环境验收,会创建导出任务和相关测试数据 | | 测试环境 HTTP | `phase2-test-env-acceptance.ps1` | 入库测量与计费测试环境 API 验收,会写业务数据 | diff --git a/scripts/real-order-xlsx-import.md b/scripts/real-order-xlsx-import.md index e0c256035..bf6a9d56b 100644 --- a/scripts/real-order-xlsx-import.md +++ b/scripts/real-order-xlsx-import.md @@ -17,13 +17,17 @@ node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" - `--expected-host` 必须与配置完全相同且以 `test-` 或 `test.` 开头,禁止生产环境。 - `--stage preflight` 登录并查询目录、托盘和编号策略,不创建业务数据。 - `--stage create --limit 1` 按源实际箱数、重量、体积尝试创建一票草稿,不伪造图片、尺寸或报关字段,不提交订单。按用户确认规则,源表未注明木制品时 `woodenProduct=false`;其他缺失必填项保留服务端错误,不保证所有源行能创建。 -- `--stage wait-split --allow-test-placeholders true --limit 1` 创建完整测试订单并提交、审核、测量,回查 `WAIT_SPLIT`、审核状态、箱数和货重。只有明确接受下述模拟规则时才能使用。 -- `--limit` 是本次新增尝试数,默认 1,串行执行。前三票连续失败且没有成功时停止扩大写入。 +- `--stage pending-review --allow-test-placeholders true --limit 1` 创建订单并由客户提交,回查客户状态 `SUBMITTED` 和统一状态 `PENDING_REVIEW` 后停止;不审核、不生成入库投影,不调用箱码、测量、称重、托盘或库位接口。这是源订单批量造数的默认推荐阶段。试价返回“无匹配报价”或“未达到最低重量阶梯”时只记录 `pricingWarning`,仍创建并提交到待审核,交由人工审核处理。 +- `--stage wait-split --allow-test-placeholders true --limit 1` 创建完整测试订单并提交、审核、测量,回查 `WAIT_SPLIT`、审核状态、箱数和货重。只用于少量完整链验收,不用于大批量造数。 +- `--limit` 是本次新增尝试数,默认 1;实际处理方式由 `--concurrency` 决定。前三票连续失败且没有成功时停止扩大写入。 +- `--concurrency` 是单进程并发票数,默认 1、最大 10。所有并发票共享一个带串行落盘队列的 `state.json` 和一个批次锁,不得再启动多个进程。测试环境实测 3 路可稳定运行;提高并发前必须重新小批压测,出现服务端 `S0500` 时降低并发,不能用失败率换速度。 ## 源数据与模拟规则 同入仓码多行按实际箱数/毛重/体积求和,分柜明细还须与唯一预报箱数相符;同码客户、渠道、目的仓或报关方式冲突则阻断。缺失或零实际值不以预报替换。无有效入仓码的行排除,源文件保持原样。 +订单表持久精度为毛重 3 位小数、体积 4 位小数。拆分行先按源实际值求和,再统一按该精度四舍五入后创建和回查;不逐行先取整,避免分摊尾差导致总量漂移。 + 报关方式“买单改退税”取最终退税,“退税改买单”取最终买单;空白或未识别文本阻断,不默认买单。 试价计费方式取“计费数据”后的第一个“单位”列:KGS/KG 映射 KG,CBM 保留。包税按服务端契约固定 KG;不包税单位缺失或分柜行冲突则阻断。 @@ -38,20 +42,24 @@ node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" 源体积可用三位小数表达时,优先以 0.001 m³ 分组,使各明细体积重和汇总体积重避免逐行向上取整差异;否则按四位精度分组。提交前同时检查明细体积重合计与批次一致,不一致记录服务端取整阻断,不提交审核或伪造总量。 -照片、缺失产品数量/单价/HS/材质/危险品属性等为明确标记的测试占位,不能用于真实报关、计费准确性或实物测量验收。木制品未注明时按用户确认默认非木制品。多行或超长货件标识不截取为另一个真实 ID,模拟模式用 TEST 标识、草稿模式留空,完整原值仍在源文件中。`create` 模式不添加这些占位值。 +产品照片、缺失产品数量/单价/HS/材质/危险品属性等为明确标记的测试占位,不能用于真实报关、计费准确性或实物测量验收。木制品未注明时按用户确认默认非木制品。同一批次只上传一次产品占位图并在订单中复用;箱级测量图和每托称重图仍逐条上传作为正式流程要求的证据。客户箱规每次进程启动只查询一次。多行或超长货件标识不截取为另一个真实 ID,模拟模式用 TEST 标识、草稿模式留空,完整原值仍在源文件中。`create` 模式不添加这些占位值。 -托盘只允许 `TP-001` 和 `YW-TP-002`,任一缺失则停止;不调用托盘/库位新增接口。普通位与公共位 A0 由正式测量链分配。一件代发后续需要新建工作托盘,与当前禁止新增约束有待确认,因此先记录阻断,不降为普通单或擅自创建工作托盘。 +测量草稿是带版本的幂等更新。仅当该接口返回服务端 `S0500` 时,脚本最多重读两次最新测量版本后重试,并把每次 requestId/traceId 记入订单检查点;业务校验错误、其他接口错误和第三次失败均原样终止该票,不无限重试。 + +只有 `wait-split` 阶段会查询并复用 `TP-001` 和 `YW-TP-002`,任一缺失则停止;不调用托盘/库位新增接口。普通位与公共位 A0 由正式测量链分配。`pending-review` 完全不查询或占用托盘、库位。一件代发后续需要新建工作托盘,与当前禁止新增约束有待确认,因此先记录阻断,不降为普通单或擅自创建工作托盘。 ## 报告与重跑 必须保留批次目录:`preflight.json` 为全部解析/主数据匹配结果;`state.json` 为源文件哈希、批次身份、源行号到新订单/入仓码的对应、每次写入前后的检查点、错误码和 requestId/traceId。该目录包含业务数据,仅在本机仓库外保存,不提交或公开分享。原始 Excel 自身是完整源数据依据。 -同目录重新运行会跳过所有已经尝试过的票(包括成功、失败和中断票),不会自动重新创建。`limit` 每次从尚未尝试的票继续;不是完整失败恢复器。特别注意:`create` 成功的草稿不会因切换 `wait-split` 自动补齐和推进;需单独选择尚未尝试票做完整链试跑。失败/中断保留服务端部分数据,先核对检查点,不删单或自动补偿。 +同目录重新运行会跳过所有已经尝试过的票(包括成功、失败和中断票),不会自动重新创建。`pending-review` 仅安全恢复停在试价阶段、尚未创建订单的 `A0404/A0422`,旧错误保留在 `priorAttempts`。载荷规则发生变化后必须使用新的 batch-id 和输出目录,不能用旧创建幂等键发送不同载荷。`limit` 每次从尚未尝试或上述可恢复票继续;不是通用失败恢复器。特别注意:不同阶段不能用同一批状态自动推进已有订单;需使用全新批次选择尚未尝试票。失败/中断保留服务端部分数据,先核对检查点,不删单或自动补偿。任一并发票返回 `A0401` 时,本批最多完成当前并发组便立即熔断,防止令牌失效污染后续订单。批次锁记录进程 PID;异常终止后,下次启动仅在该 PID 已不存在时清理精确的陈旧锁。 源文件、客户、网关或批次 ID 改变时拒绝复用目录。并发进程通过 `run.lock` 拒绝;异常终止残留锁,人工确认旧进程已结束后才可移除精确锁文件。 用户清库并重新初始化客户、报价、仓库和原有托盘后,指定**新的 batch-id 和全新输出目录**重新运行同一脚本。不能复用旧状态,否则已尝试票会跳过。清库不由本脚本执行。 +清库后的批量待审核运行固定使用 `--stage pending-review --allow-test-placeholders true --concurrency 10 --limit 10000`。先确认 IT测试客户、义乌仓、可选渠道与目的仓主档已经初始化;报价缺失不阻断待审核创建。运行完成以 `preflight.matched = state.orders`、全部检查点无 `STARTED`、成功项均为 `PENDING_REVIEW` 为完成口径。 + ## 本地检查 ```powershell diff --git a/scripts/real-order-xlsx-test-env.mjs b/scripts/real-order-xlsx-test-env.mjs index 9605eec49..7cf881ead 100644 --- a/scripts/real-order-xlsx-test-env.mjs +++ b/scripts/real-order-xlsx-test-env.mjs @@ -2,7 +2,7 @@ // Explicitly authorized non-production HTTP import. Never resets a database. import { createHash } from 'node:crypto' import { execFileSync } from 'node:child_process' -import { mkdir, readFile, writeFile, rename, open } from 'node:fs/promises' +import { mkdir, readFile, writeFile, rename, open, unlink } from 'node:fs/promises' import { resolve } from 'node:path' import { pathToFileURL } from 'node:url' import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs' @@ -45,7 +45,7 @@ export function normalizeRows(rows) { for (const name of ['实际件数', '实际毛重', '实际立方']) { if (entries.some(({ row }) => !positive(get(row, name)))) problems.push(`MISSING_${name}`) } - const sum = (name) => round(entries.reduce((total, { row }) => total + (Number(get(row, name)) || 0), 0)) + const sum = (name, digits = 6) => round(entries.reduce((total, { row }) => total + (Number(get(row, name)) || 0), 0), digits) const packageCount = sum('实际件数') if (!Number.isInteger(packageCount) || packageCount < 1) problems.push('INVALID_PACKAGE_COUNT') if (entries.length > 1) { @@ -68,7 +68,10 @@ export function normalizeRows(rows) { sourceCustomer: text(get(first, '客户')), sourceChannel, channelName: channelName(sourceChannel), destination, destinationType, pricingTaxMode, taxMode, chargeMode, fulfillmentType: destination.includes('一件代发') ? 'ONE_CLICK_DROPSHIP' : 'STANDARD', - packageCount, weightKg: sum('实际毛重'), volumeCbm: sum('实际立方'), + // Match the persisted order schema: kg has 3 decimals and CBM has 4. + // Split source rows can contain longer allocation decimals whose exact sum + // cannot be represented by the order table. + packageCount, weightKg: sum('实际毛重', 3), volumeCbm: sum('实际立方', 4), cargoName: distinct('品名').filter(Boolean).join(' / '), sku: distinct('SKU').filter(Boolean).join('\n'), shipmentId: distinct('FBA货件编号').filter(Boolean).join('\n'), referenceId: distinct('跟踪编号').filter(Boolean).join('\n'), problems } @@ -199,7 +202,7 @@ export function requireSuccess(response) { async function main() { const args = new Map() - const allowed = new Set(['input', 'sheet', 'python', 'output-dir', 'batch-id', 'stage', 'limit', 'customer-name', 'origin-warehouse-name', 'allow-test-placeholders', 'expected-host']) + const allowed = new Set(['input', 'sheet', 'python', 'output-dir', 'batch-id', 'stage', 'limit', 'concurrency', 'customer-name', 'origin-warehouse-name', 'allow-test-placeholders', 'expected-host']) for (let i = 2; i < process.argv.length; i += 2) { const name = process.argv[i].replace(/^--/, ''), value = process.argv[i + 1] if (!allowed.has(name) || !value || value.startsWith('--')) throw Error(`无效参数:${name}`) @@ -207,10 +210,14 @@ async function main() { } for (const key of ['input', 'python', 'output-dir', 'batch-id', 'expected-host']) if (!args.get(key)) throw Error(`缺少 --${key}`) const stage = args.get('stage') || 'preflight' - if (!['preflight', 'create', 'wait-split'].includes(stage)) throw Error('stage 必须为 preflight/create/wait-split') - if (stage === 'wait-split' && args.get('allow-test-placeholders') !== 'true') throw Error('此源表缺少必填字段,模拟测量须显式允许测试占位') + if (!['preflight', 'create', 'pending-review', 'wait-split'].includes(stage)) throw Error('stage 必须为 preflight/create/pending-review/wait-split') + if (['pending-review', 'wait-split'].includes(stage) && args.get('allow-test-placeholders') !== 'true') { + throw Error('此源表缺少提交必填字段,须显式允许测试占位') + } const limit = Number(args.get('limit') || 1) if (!Number.isInteger(limit) || limit < 1) throw Error('limit 必须为正整数') + const concurrency = Number(args.get('concurrency') || 1) + if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 10) throw Error('concurrency 必须是1到10的整数') const options = validateExecutionOptions(parseArguments(['--modules', 'orders,inbound-measurement', '--quiet', 'true'])) if (new URL(options.gateway).hostname !== args.get('expected-host') || !/^test[-.]/.test(args.get('expected-host'))) throw Error('只允许明确匹配的测试域名') const input = resolve(args.get('input')) @@ -229,16 +236,22 @@ async function main() { try { state = JSON.parse(await readFile(statePath, 'utf8')) } catch (error) { if (error.code !== 'ENOENT') throw error } if (state && JSON.stringify(state.identity) !== JSON.stringify(identity)) throw Error('批次/源文件/目标发生变化,请使用独立输出目录') state ||= { identity, orders: {}, startedAt: new Date().toISOString() } - const lock = await open(resolve(directory, 'run.lock'), 'wx') - const save = async () => { - await writeFile(`${statePath}.tmp`, JSON.stringify(state, null, 2), 'utf8') + const lockPath = resolve(directory, 'run.lock') + const lock = await acquireRunLock(lockPath) + let saveQueue = Promise.resolve() + const save = () => { + saveQueue = saveQueue.then(async () => { + const temporaryPath = `${statePath}.${process.pid}.tmp` + await writeFile(temporaryPath, JSON.stringify(state, null, 2), 'utf8') for (let attempt = 0; ; attempt++) { - try { await rename(`${statePath}.tmp`, statePath); break } + try { await rename(temporaryPath, statePath); break } catch (error) { if (!['EPERM', 'EBUSY', 'EACCES'].includes(error.code) || attempt >= 5) throw error await new Promise((resolve) => setTimeout(resolve, 100 * (attempt + 1))) } } + }) + return saveQueue } const ctx = new AcceptanceRun(options) const api = async (method, path, body, token = ctx.token, headers = {}) => requireSuccess(await ctx.request(method, path, body, token, headers)) @@ -264,14 +277,35 @@ async function main() { const channels = await api('GET', `/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${receiving.id}`, undefined, merchant) const catalog = { receiving, channels, destinations: [...await list('FBA'), ...await list('OVERSEAS')] } const pallets = [] - for (const code of PALLETS) { - const page = await api('GET', `/api/platform/v1/pallets?page=1&size=20&tenantId=0&warehouseId=${receiving.id}&palletCode=${encodeURIComponent(code)}`) - const found = page.records?.filter((item) => item.palletCode === code && String(item.warehouseId) === String(receiving.id)) - if (found?.length !== 1) throw Error(`REQUIRED_PALLET_MISSING_${code}`) - pallets.push(found[0]) + if (stage === 'wait-split') { + for (const code of PALLETS) { + const page = await api('GET', `/api/platform/v1/pallets?page=1&size=20&tenantId=0&warehouseId=${receiving.id}&palletCode=${encodeURIComponent(code)}`) + const found = page.records?.filter((item) => item.palletCode === code && String(item.warehouseId) === String(receiving.id)) + if (found?.length !== 1) throw Error(`REQUIRED_PALLET_MISSING_${code}`) + pallets.push(found[0]) + } } const policy = await api('GET', '/api/merchant/v1/orders/first-leg/inbound-code-policy', undefined, merchant) if (policy.mode !== 'AUTO') throw Error('INBOUND_CODE_POLICY_NOT_AUTO') + let sharedResources = null + if (['pending-review', 'wait-split'].includes(stage)) { + let dimensionRules = null + if (stage === 'wait-split') { + const dimensionConfig = await api('GET', `/api/platform/v1/first-leg/customer-dimension-config?tenantId=${profile.id}`) + totalPreservingShapes(normalized.orders.find((order) => order.problems.length === 0), dimensionConfig.rules) + dimensionRules = dimensionConfig.rules + } + let productImageFileId = state.sharedResources?.productImageFileId + if (!productImageFileId) { + const image = requireSuccess(await ctx.requestMultipart('POST', '/api/merchant/v1/files/product-images', + { content: PNG, type: 'image/png', name: `TEST-XLSX-${state.identity.batchId}.png` }, merchant)) + productImageFileId = image?.fileId + if (!productImageFileId) throw Error('SHARED_PRODUCT_IMAGE_ID_MISSING') + } + state.sharedResources = { productImageFileId, ...(dimensionRules ? { dimensionRules } : {}) } + sharedResources = state.sharedResources + await save() + } const candidates = [], rejected = [...normalized.excluded] for (const order of normalized.orders) { try { candidates.push({ order, route: matchRoute(order, catalog) }) } @@ -284,47 +318,88 @@ async function main() { console.log(JSON.stringify({ stage, sourceRows: preflight.sourceRows, orders: preflight.orders, matched: candidates.length, rejected: rejected.length })) await save() if (stage === 'preflight') return - await executeCandidates({ candidates, limit, state, save, api, ctx, merchant, profile, pallets, stage }) + await executeCandidates({ candidates, limit, concurrency, state, save, api, ctx, merchant, profile, pallets, + sharedResources, stage }) } finally { + await saveQueue.catch(() => {}) await lock.close() - const { unlink } = await import('node:fs/promises') - await unlink(resolve(directory, 'run.lock')) + await unlink(lockPath) } } -export async function executeCandidates({ candidates, limit, state, save, api, ctx, merchant, profile, pallets, stage }) { - let attempted = 0, succeeded = 0, failed = 0, skipped = 0 +async function acquireRunLock(lockPath) { + try { + const handle = await open(lockPath, 'wx') + await handle.writeFile(String(process.pid), 'utf8') + return handle + } catch (error) { + if (error.code !== 'EEXIST') throw error + const existingPid = Number(await readFile(lockPath, 'utf8').catch(() => '')) + let alive = false + if (Number.isInteger(existingPid) && existingPid > 0) { + try { process.kill(existingPid, 0); alive = true } catch { /* Stale PID. */ } + } + if (alive) throw Error(`批次正在运行,pid=${existingPid}`) + await unlink(lockPath) + const handle = await open(lockPath, 'wx') + await handle.writeFile(String(process.pid), 'utf8') + return handle + } +} + +export async function executeCandidates({ candidates, limit, concurrency = 1, state, save, api, ctx, merchant, profile, pallets, + sharedResources, stage }) { + let attempted = 0, completed = 0, succeeded = 0, failed = 0, skipped = 0, stopReason = null + const pending = [] for (const { order, route } of candidates) { // An interrupted write is not permission to create another order. Keep its last // checkpoint for inspection. A reset database is always a NEW batch/directory. - if (state.orders[order.key]) { skipped++; continue } - if (attempted >= limit) break + const previous = state.orders[order.key] + const retryablePricingWarning = stage === 'pending-review' && previous?.status === 'FAILED' + && previous.stage === 'estimate' && !previous.orderId && ['A0404', 'A0422'].includes(previous.evidence?.code) + if (previous && !retryablePricingWarning) { skipped++; continue } + if (pending.length >= limit) break + pending.push({ order, route, previous }) + } + for (let offset = 0; offset < pending.length; offset += concurrency) { + const batch = pending.slice(offset, offset + concurrency) + await Promise.all(batch.map(async ({ order, route, previous }) => { const marker = `XLSX-${hash(`${state.identity.batchId}:${order.key}`).slice(0, 24)}` - const entry = { rows: order.rows, marker, status: 'STARTED', stage: 'estimate', startedAt: new Date().toISOString() } + const entry = { rows: order.rows, marker, status: 'STARTED', stage: 'estimate', startedAt: new Date().toISOString(), + ...(previous ? { priorAttempts: [...(previous.priorAttempts || []), { status: previous.status, stage: previous.stage, + error: previous.error, evidence: previous.evidence, finishedAt: previous.finishedAt }] } : {}) } state.orders[order.key] = entry await save() attempted++ try { - const estimate = await api('POST', '/api/merchant/v1/first-leg/pricing/estimate', { - originWarehouseId: route.receiving.id, channelCode: route.channel.channelCode, - destinationCode: route.destination.warehouseCode, destinationType: order.destinationType, - destinationWarehouseId: route.destination.id, destinationName: route.destination.warehouseName, - pricingTaxMode: order.pricingTaxMode, chargeMode: order.chargeMode, - actualWeightKg: order.weightKg, actualVolumeCbm: order.volumeCbm, - }, merchant) - if (estimate?.channelCode !== route.channel.channelCode || estimate?.pricingTaxMode !== order.pricingTaxMode) throw Error('ESTIMATE_ROUTE_MISMATCH') + let estimate = null + try { + estimate = await api('POST', '/api/merchant/v1/first-leg/pricing/estimate', { + originWarehouseId: route.receiving.id, channelCode: route.channel.channelCode, + destinationCode: route.destination.warehouseCode, destinationType: order.destinationType, + destinationWarehouseId: route.destination.id, destinationName: route.destination.warehouseName, + pricingTaxMode: order.pricingTaxMode, chargeMode: order.chargeMode, + actualWeightKg: order.weightKg, actualVolumeCbm: order.volumeCbm, + }, merchant) + } catch (error) { + if (stage !== 'pending-review' || !['A0404', 'A0422'].includes(error.evidence?.code)) throw error + entry.pricingWarning = { code: error.evidence.code, message: error.evidence.message, + requestId: error.evidence.requestId, traceId: error.evidence.traceId } + } + if (estimate && (estimate.channelCode !== route.channel.channelCode || estimate.pricingTaxMode !== order.pricingTaxMode)) { + throw Error('ESTIMATE_ROUTE_MISMATCH') + } let payload if (stage === 'create') { payload = buildSourceDraft(order, route, profile.tenantName, marker) } else { - const config = await api('GET', `/api/platform/v1/first-leg/customer-dimension-config?tenantId=${profile.id}`) - entry.dimensionPlan = totalPreservingShapes(order, config.rules) - entry.stage = 'product-image'; await save() - const image = requireSuccess(await ctx.requestMultipart('POST', '/api/merchant/v1/files/product-images', - { content: PNG, type: 'image/png', name: `${marker}-TEST.png` }, merchant)) - if (!image?.fileId) throw Error('PRODUCT_IMAGE_ID_MISSING') - entry.productImageFileId = image.fileId - payload = buildPayload(order, route, profile.tenantName, image.fileId, marker) + if (!sharedResources?.productImageFileId) throw Error('SHARED_PRODUCT_IMAGE_MISSING') + if (stage === 'wait-split') { + if (!Array.isArray(sharedResources.dimensionRules)) throw Error('SHARED_DIMENSION_RULES_MISSING') + entry.dimensionPlan = totalPreservingShapes(order, sharedResources.dimensionRules) + } + entry.productImageFileId = sharedResources.productImageFileId + payload = buildPayload(order, route, profile.tenantName, sharedResources.productImageFileId, marker) } entry.stage = 'create'; entry.payloadHash = hash(JSON.stringify(payload)); await save() entry.orderId = String(await api('POST', '/api/merchant/v1/orders/first-leg', payload, merchant, @@ -340,9 +415,25 @@ export async function executeCandidates({ candidates, limit, state, save, api, c || Math.abs(Number(cargo.volumeCbm) - order.volumeCbm) > 0.00001) throw Error('DRAFT_SOURCE_FACTS_MISMATCH') entry.orderNo = draft.order.orderNo entry.declarationVerified = true - if (stage === 'wait-split') { + if (['pending-review', 'wait-split'].includes(stage)) { entry.stage = 'submit'; await save() await api('POST', `${clientPath}/submit`, undefined, merchant, { 'Idempotency-Key': `${marker}-submit` }) + } + if (stage === 'pending-review') { + const submitted = await api('GET', clientPath, undefined, merchant) + const unified = await api('GET', `/api/merchant/v1/orders/${entry.orderId}`, undefined, merchant) + if (submitted.order?.status !== 'SUBMITTED' || unified.summary?.mainStatus !== 'PENDING_REVIEW') { + const error = Error('PENDING_REVIEW_NOT_CONFIRMED') + error.evidence = { clientStatus: submitted.order?.status, mainStatus: unified.summary?.mainStatus } + throw error + } + entry.stage = 'PENDING_REVIEW' + entry.verification = { clientStatus: submitted.order.status, mainStatus: unified.summary.mainStatus, + expectedPackages: order.packageCount, actualPackages: Number(submitted.cargoes?.[0]?.packageCount), + expectedCargoWeightKg: order.weightKg, actualCargoWeightKg: Number(submitted.cargoes?.[0]?.grossWeightKg), + expectedVolumeCbm: order.volumeCbm, actualVolumeCbm: Number(submitted.cargoes?.[0]?.volumeCbm) } + } + if (stage === 'wait-split') { entry.stage = 'approve'; await save() const platformPath = `/api/platform/v1/orders/first-leg/${entry.orderId}` const q = `tenantId=${profile.id}` @@ -358,14 +449,22 @@ export async function executeCandidates({ candidates, limit, state, save, api, c entry.status = 'SUCCEEDED'; succeeded++ } catch (error) { entry.status = 'FAILED'; entry.error = error.message; entry.evidence = error.evidence; failed++ + if (error.evidence?.code === 'A0401') stopReason ||= 'AUTHENTICATION_EXPIRED' } + completed++ entry.finishedAt = new Date().toISOString(); await save() - console.log(JSON.stringify({ attempted, succeeded, failed, skipped, rows: entry.rows, - stage: entry.stage, status: entry.status, orderNo: entry.orderNo, inboundCode: entry.inboundCode, error: entry.error, evidence: entry.evidence })) - // A small pilot must expose a common deployment/data defect before multiplying it. + if (entry.status === 'FAILED' || completed % 100 === 0 || completed === pending.length) { + console.log(JSON.stringify({ attempted, completed, succeeded, failed, skipped, rows: entry.rows, + stage: entry.stage, status: entry.status, orderNo: entry.orderNo, inboundCode: entry.inboundCode, + error: entry.error, evidence: entry.evidence })) + } + })) + if (stopReason) break + // A small concurrent pilot must expose a common deployment/data defect before multiplying it. if (failed >= 3 && succeeded === 0) break } - state.lastRun = { attempted, succeeded, failed, skipped, stage, finishedAt: new Date().toISOString() } + state.lastRun = { attempted, completed, succeeded, failed, skipped, concurrency, stage, stopReason, + finishedAt: new Date().toISOString() } await save() if (failed) process.exitCode = 1 } @@ -383,6 +482,34 @@ async function poll(read, predicate) { throw error } +export async function saveMeasurementDraft({ api, measurementPath, base, q, order, entry, marker, + attempts = 3, wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), onRetry }) { + for (let attempt = 1; attempt <= attempts; attempt++) { + const measured = await api('GET', measurementPath) + if (measured.items?.length !== entry.dimensionPlan.length) throw Error('UNEXPECTED_MEASUREMENT_ITEMS') + const payload = { + measurementId: measured.measurementId, version: Number(measured.version), + actualGoodsName: oneValue(order.cargoName, '测试导入汇总货物', 128), + items: entry.dimensionPlan.map((group) => { + const item = measured.items.find((value) => String(value.id) === group.itemId) + if (!item) throw Error('MEASUREMENT_GROUP_MISSING') + return { inboundDetailId: item.inboundDetailId, itemNo: item.itemNo, boxSpecification: item.boxSpecification, + packageCount: group.count, lengthCm: group.length, widthCm: group.width, heightCm: group.height, + intelligentImageFileId: item.intelligentImageFileId, overLimitWarning: item.overLimitWarning ?? null, + outerLengthCm: group.length, outerWidthCm: group.width, outerHeightCm: group.height, + coefficientMode: 'ADD', remark: `${marker} TEST按总体积分组反算箱规,非实测` } + }), + } + try { return await api('PUT', `${base}/measurement/draft?${q}`, payload) } + catch (error) { + if (error.evidence?.code !== 'S0500' || attempt === attempts) throw error + await onRetry?.({ attempt, code: error.evidence.code, requestId: error.evidence.requestId, traceId: error.evidence.traceId }) + await wait(250 * attempt) + } + } + throw Error('MEASUREMENT_DRAFT_RETRY_EXHAUSTED') +} + async function measureOrder({ order, entry, ctx, api, pallets, tenantId, save }) { const inboundId = entry.inboundId, q = `tenantId=${tenantId}`, marker = entry.marker const inboundPath = `/api/platform/inbounds/${inboundId}?${q}` @@ -411,21 +538,8 @@ async function measureOrder({ order, entry, ctx, api, pallets, tenantId, save }) group.itemId = String(scan.itemId) cartonIndex += group.count } - const measured = await api('GET', measurementPath) - if (measured.items?.length !== entry.dimensionPlan.length) throw Error('UNEXPECTED_MEASUREMENT_ITEMS') - const draft = await api('PUT', `${base}/measurement/draft?${q}`, { - measurementId: measured.measurementId, version: Number(measured.version), - actualGoodsName: oneValue(order.cargoName, '测试导入汇总货物', 128), - items: entry.dimensionPlan.map((group) => { - const item = measured.items.find((value) => String(value.id) === group.itemId) - if (!item) throw Error('MEASUREMENT_GROUP_MISSING') - return { inboundDetailId: item.inboundDetailId, itemNo: item.itemNo, boxSpecification: item.boxSpecification, - packageCount: group.count, lengthCm: group.length, widthCm: group.width, heightCm: group.height, - intelligentImageFileId: item.intelligentImageFileId, overLimitWarning: item.overLimitWarning ?? null, - outerLengthCm: group.length, outerWidthCm: group.width, outerHeightCm: group.height, - coefficientMode: 'ADD', - remark: `${marker} TEST按总体积分组反算箱规,非实测` } }), - }) + const draft = await saveMeasurementDraft({ api, measurementPath, base, q, order, entry, marker, + onRetry: async (retry) => { entry.retries ||= []; entry.retries.push({ stage: 'measurement-draft', ...retry }); await save() } }) const draftCheck = await api('GET', measurementPath) if (Math.abs(Number(draftCheck.actualVolumeCbm) - order.volumeCbm) > 0.0000001 || Number(draftCheck.actualPackageCount) !== order.packageCount) throw Error('DRAFT_VOLUME_OR_COUNT_MISMATCH') diff --git a/scripts/real-order-xlsx-test-env.test.mjs b/scripts/real-order-xlsx-test-env.test.mjs index e38c0130c..1bf1fdfb5 100644 --- a/scripts/real-order-xlsx-test-env.test.mjs +++ b/scripts/real-order-xlsx-test-env.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict' import test from 'node:test' import { normalizeRows, channelName, matchRoute, measurementShape, buildSourceDraft, buildPayload, - requireSuccess, executeCandidates, totalPreservingShapes } from './real-order-xlsx-test-env.mjs' + requireSuccess, executeCandidates, totalPreservingShapes, saveMeasurementDraft } from './real-order-xlsx-test-env.mjs' const header = ['进仓编号', '客户', '渠道', '亚马逊仓', '实际件数', '实际毛重', '实际立方', '预报件数', '买单/退税', '品名', 'SKU', 'FBA货件编号', '跟踪编号'] const row = ['ABCD2606010001', 'Example', '合德海卡包税', 'ABC1', 31, 100, 2, 31, '买单', 'Goods', 'SKU1', 'SHIP1', 'REF1'] @@ -39,6 +39,15 @@ test('split rows aggregate actual counts once and reconcile forecast', () => { b[4] = 15 assert.ok(normalizeRows([header, a, b]).orders[0].problems.includes('SPLIT_COUNT_MISMATCH')) }) +test('split weight and volume totals use the persisted order precision', () => { + const a = [...row], b = [...row] + a[4] = 90; b[4] = 300; a[7] = 390; b[7] = 390 + a[5] = 1137.461538; b[5] = 3791.538 + a[6] = 7.256538462; b[6] = 24.188 + const result = normalizeRows([header, a, b]).orders[0] + assert.equal(result.weightKg, 4929) + assert.equal(result.volumeCbm, 31.4445) +}) test('conflicting channels on same inbound code block import', () => { const b = [...row]; b[2] = '其他包税' assert.ok(normalizeRows([header, row, b]).orders[0].problems.includes('CONFLICT_渠道')) @@ -105,9 +114,106 @@ test('HTTP business errors preserve diagnostic evidence', () => { assert.throws(() => requireSuccess({ http: 200, path: '/example', body: { code: 'A0409' }, requestId: 'req' }), (error) => error.evidence.code === 'A0409' && error.evidence.requestId === 'req') }) +test('measurement draft retries a transient server error after refreshing the version', async () => { + let reads = 0, writes = 0 + const retries = [] + const entry = { dimensionPlan: [{ itemId: 'item-1', count: 31, length: 40, width: 30, height: 30 }] } + const api = async (method) => { + if (method === 'GET') return { measurementId: 'measurement-1', version: ++reads, + items: [{ id: 'item-1', inboundDetailId: 'detail-1', itemNo: 1, boxSpecification: 'TEST' }] } + writes++ + if (writes === 1) { const error = Error('temporary'); error.evidence = { code: 'S0500', requestId: 'req-1' }; throw error } + return { measurementId: 'measurement-1', version: 3 } + } + const result = await saveMeasurementDraft({ api, measurementPath: '/measurement', base: '/inbound/1', q: 'tenantId=1', + order: order(), entry, marker: 'MARKER', wait: async () => {}, onRetry: async (retry) => retries.push(retry) }) + assert.equal(result.version, 3) + assert.equal(reads, 2) + assert.equal(writes, 2) + assert.deepEqual(retries.map((retry) => retry.code), ['S0500']) +}) test('same batch never recreates an attempted order, even when interrupted', async () => { const value = order(), state = { identity: { batchId: 'test' }, orders: { [value.key]: { status: 'STARTED' } } } await executeCandidates({ candidates: [{ order: value, route: catalog }], limit: 1, state, save: async () => {}, api: async () => { throw Error('must not call HTTP') } }) assert.equal(state.lastRun.attempted, 0); assert.equal(state.lastRun.skipped, 1) }) +test('pending-review stage submits but never calls approval or measurement APIs', async () => { + const value = order(), route = matchRoute(value, catalog) + const state = { identity: { batchId: 'pending-review' }, orders: {} } + const calls = [] + let detailReads = 0 + const api = async (method, path) => { + calls.push(`${method} ${path}`) + if (path.endsWith('/pricing/estimate')) return { channelCode: route.channel.channelCode, pricingTaxMode: value.pricingTaxMode } + if (method === 'POST' && path === '/api/merchant/v1/orders/first-leg') return 'order-1' + if (path === '/api/merchant/v1/orders/first-leg/order-1' && method === 'GET') { + detailReads++ + return { order: { id: 'order-1', orderNo: 'FL-1', customerRemark: Object.values(state.orders)[0].marker, + status: detailReads > 1 ? 'SUBMITTED' : 'DRAFT' }, + cargoes: [{ packageCount: value.packageCount, grossWeightKg: value.weightKg, volumeCbm: value.volumeCbm }] } + } + if (path === '/api/merchant/v1/orders/order-1') return { summary: { mainStatus: 'PENDING_REVIEW' } } + if (method === 'POST' && path.endsWith('/submit')) return true + throw Error(`unexpected call ${method} ${path}`) + } + await executeCandidates({ candidates: [{ order: value, route }], limit: 1, concurrency: 1, state, + save: async () => {}, api, merchant: 'merchant', profile: { tenantName: 'Example' }, + sharedResources: { productImageFileId: 'image-1' }, stage: 'pending-review' }) + assert.equal(Object.values(state.orders)[0].stage, 'PENDING_REVIEW') + assert.equal(Object.values(state.orders)[0].verification.mainStatus, 'PENDING_REVIEW') + assert.equal(calls.some((call) => /approve|measurement|inbounds/.test(call)), false) +}) +test('pending-review retries an estimate-only pricing failure and records it as a warning', async () => { + const value = order(), route = matchRoute(value, catalog) + const state = { identity: { batchId: 'pricing-warning' }, orders: { [value.key]: { status: 'FAILED', stage: 'estimate', + error: 'HTTP_200_CODE_A0404', evidence: { code: 'A0404', requestId: 'old-request' }, finishedAt: '2026-01-01T00:00:00Z' } } } + let detailReads = 0 + const api = async (method, path) => { + if (path.endsWith('/pricing/estimate')) { const error = Error('no price'); error.evidence = { code: 'A0404', message: 'no price' }; throw error } + if (method === 'POST' && path === '/api/merchant/v1/orders/first-leg') return 'order-2' + if (path === '/api/merchant/v1/orders/first-leg/order-2' && method === 'GET') { + detailReads++ + return { order: { id: 'order-2', orderNo: 'FL-2', customerRemark: Object.values(state.orders)[0].marker, + status: detailReads > 1 ? 'SUBMITTED' : 'DRAFT' }, + cargoes: [{ packageCount: value.packageCount, grossWeightKg: value.weightKg, volumeCbm: value.volumeCbm }] } + } + if (path === '/api/merchant/v1/orders/order-2') return { summary: { mainStatus: 'PENDING_REVIEW' } } + if (method === 'POST' && path.endsWith('/submit')) return true + throw Error(`unexpected call ${method} ${path}`) + } + await executeCandidates({ candidates: [{ order: value, route }], limit: 1, concurrency: 1, state, + save: async () => {}, api, merchant: 'merchant', profile: { tenantName: 'Example' }, + sharedResources: { productImageFileId: 'image-1' }, stage: 'pending-review' }) + const entry = state.orders[value.key] + assert.equal(entry.status, 'SUCCEEDED') + assert.equal(entry.pricingWarning.code, 'A0404') + assert.equal(entry.priorAttempts.length, 1) +}) +test('single process respects bounded concurrency while keeping per-order checkpoints', async () => { + const base = order() + const route = matchRoute(base, catalog) + const candidates = Array.from({ length: 7 }, (_, index) => ({ + order: { ...base, key: `key-${index}`, sourceCode: `SRC-${index}`, rows: [index + 2] }, route, + })) + const state = { identity: { batchId: 'concurrency' }, orders: {} } + let active = 0, maximum = 0, id = 0 + const api = async (method, path, body) => { + if (path.endsWith('/pricing/estimate')) { + active++; maximum = Math.max(maximum, active) + await new Promise((resolve) => setTimeout(resolve, 5)) + active-- + return { channelCode: route.channel.channelCode, pricingTaxMode: base.pricingTaxMode } + } + if (method === 'POST') return String(++id) + const marker = path.split('/').at(-1) + return { order: { id: marker, orderNo: `ORDER-${marker}`, customerRemark: Object.values(state.orders) + .find((entry) => entry.orderId === marker)?.marker }, cargoes: [{ packageCount: 31, grossWeightKg: 100, volumeCbm: 2 }] } + } + await executeCandidates({ candidates, limit: 7, concurrency: 3, state, save: async () => {}, api, + profile: { tenantName: 'Example' }, stage: 'create' }) + assert.equal(maximum, 3) + assert.equal(state.lastRun.attempted, 7) + assert.equal(state.lastRun.succeeded, 7) + assert.equal(Object.values(state.orders).filter((entry) => entry.status === 'SUCCEEDED').length, 7) +})