From cddd23e4c781aa991949af96780553833243a614 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 17 Aug 2026 19:13:08 +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 --- deploy/sql/头程数据库统一增量-MySQL8.sql | 2 +- .../PlatformInboundFeeDetailController.java | 4 +- .../firstleg/mapper/InboundOrderMapper.java | 16 +- .../mapper/LoadingCompletedEventMapper.java | 1 + .../repository/FeeItemRepository.java | 23 +- .../service/InboundFeeDetailService.java | 78 ++++-- .../firstleg/service/LoadingListService.java | 8 + .../firstleg/service/LoadingPickService.java | 1 + ...se2-published-freight-detail-migration.sql | 4 +- .../resources/openapi/fba-pricing-v1.yaml | 243 +++++++++++++++++- .../main/resources/openapi/firstleg-v1.yaml | 32 +++ .../controller/FbaPricingContractTest.java | 5 +- .../InboundFeeOpenApiContractTest.java | 80 ++++++ .../InboundOrderStatusSqlContractTest.java | 10 + .../service/FeeManagementServiceTest.java | 78 ++++++ .../LoadingCompletedOutboxServiceTest.java | 9 + .../service/LoadingListServiceTest.java | 4 + .../service/LoadingPickServiceTest.java | 31 +++ .../controller/PlatformTenantController.java | 4 +- .../java/com/baiyue/iam/dto/TenantDtos.java | 15 ++ .../iam/repository/TenantRepository.java | 7 + .../com/baiyue/iam/service/TenantService.java | 28 +- iam/src/main/resources/openapi/iam-v1.yaml | 29 ++- .../controller/IamOpenApiContractTest.java | 4 + .../PlatformTenantControllerContractTest.java | 5 + .../baiyue/iam/service/TenantServiceTest.java | 32 +++ .../client/FirstlegUnifiedOrderClient.java | 7 +- .../order/dto/UnifiedOrderResponse.java | 9 + .../service/UnifiedOrderQueryService.java | 74 +++++- .../src/main/resources/openapi/order-v1.yaml | 25 +- .../UnifiedOrderApiContractTest.java | 7 +- .../service/UnifiedOrderQueryServiceTest.java | 54 +++- scripts/admin-test-env-acceptance.mjs | 8 +- scripts/admin-test-env-modules/README.md | 1 + .../booking-supplier-scope-contract.test.mjs | 8 + .../inbound-fees-contract.test.mjs | 24 ++ .../admin-test-env-modules/inbound-fees.mjs | 190 ++++++++++++++ .../admin-test-env-modules/ocean-bookings.mjs | 25 +- .../phase3-container-loading.mjs | 77 ++++++ .../phase3-producer-chain-contract.test.mjs | 12 + 40 files changed, 1203 insertions(+), 71 deletions(-) create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/controller/InboundFeeOpenApiContractTest.java create mode 100644 scripts/admin-test-env-modules/inbound-fees-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/inbound-fees.mjs diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index b8211eddc..1eac3300d 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -7316,7 +7316,7 @@ DELIMITER ; CALL unified_publish_fee_add_index('fl_fee_item', 'uk_fee_item_code', 'UNIQUE KEY `uk_fee_item_code` (`fee_code`)'); CALL unified_publish_fee_add_index('fl_inbound_fee_detail', 'uk_inbound_fee_source', - 'UNIQUE KEY `uk_inbound_fee_source` (`tenant_id`, `source_type`, `source_fee_id`)'); + 'UNIQUE KEY `uk_inbound_fee_source` (`tenant_id`, `source_type`, `source_fee_id`, `source_fee_version`)'); CALL unified_publish_fee_add_index('fl_inbound_fee_detail', 'idx_inbound_fee_published', 'KEY `idx_inbound_fee_published` (`tenant_id`, `inbound_id`, `publication_status`, `source_fee_version`)'); DROP PROCEDURE IF EXISTS unified_publish_fee_add_index; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundFeeDetailController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundFeeDetailController.java index f4ff30e34..d9feb89f7 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundFeeDetailController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundFeeDetailController.java @@ -41,8 +41,10 @@ public class PlatformInboundFeeDetailController { public ApiResponse> list( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long accountId, + @RequestParam(defaultValue = "false") boolean includeHistory, @PathVariable long inboundId) { - return ApiResponse.success(service.list(FirstlegScopeGuard.platform(user, accountId), inboundId)); + return ApiResponse.success(service.list( + FirstlegScopeGuard.platform(user, accountId), inboundId, includeHistory)); } @PostMapping diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderMapper.java index 922ab2fb5..7765e6957 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderMapper.java @@ -12,9 +12,19 @@ public interface InboundOrderMapper extends BaseMapper { @Update(""" UPDATE fl_inbound_order SET process_status = 'WAIT_SPLIT', measure_review_status = 'APPROVED', - actual_gross_weight_kg = (SELECT COALESCE(SUM(item.total_weight_kg), 0) - FROM fl_inbound_measurement_item item - WHERE item.tenant_id = #{tenantId} AND item.measurement_id = #{measurementId}), + actual_gross_weight_kg = CASE + WHEN EXISTS (SELECT 1 + FROM fl_inbound_weight_result weight + WHERE weight.tenant_id = #{tenantId} AND weight.inbound_id = #{inboundId} + AND weight.deleted = 0 AND weight.recognition_status = 'CONFIRMED') + THEN (SELECT COALESCE(SUM(weight.net_weight_kg), 0) + FROM fl_inbound_weight_result weight + WHERE weight.tenant_id = #{tenantId} AND weight.inbound_id = #{inboundId} + AND weight.deleted = 0 AND weight.recognition_status = 'CONFIRMED') + ELSE (SELECT COALESCE(SUM(item.total_weight_kg), 0) + FROM fl_inbound_measurement_item item + WHERE item.tenant_id = #{tenantId} AND item.measurement_id = #{measurementId}) + END, actual_volume_cbm = (SELECT COALESCE(SUM(item.total_volume_cbm), 0) FROM fl_inbound_measurement_item item WHERE item.tenant_id = #{tenantId} AND item.measurement_id = #{measurementId}), diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java index 86735b3a3..d464a8d59 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java @@ -31,6 +31,7 @@ public interface LoadingCompletedEventMapper { JOIN fl_inbound_order o ON o.id = i.inbound_id AND o.tenant_id = i.account_id AND o.deleted = 0 LEFT JOIN fl_inbound_order root_order ON root_order.id = COALESCE(o.root_inbound_id, o.id) WHERE p.id = #{planId} AND p.plan_status = 'COMPLETED' + AND o.process_status = 'LOADED' AND i.package_count > 0 ORDER BY i.account_id, i.inbound_id, i.inbound_detail_id, i.id """) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/FeeItemRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/FeeItemRepository.java index 7d197ce2f..00c7ac710 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/FeeItemRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/FeeItemRepository.java @@ -98,11 +98,13 @@ public class FeeItemRepository { .eq(InboundOrderEntity::getId, inboundId)); } - public List listDetails(long tenantId, long inboundId) { + public List listDetails(long tenantId, long inboundId, boolean includeHistory) { return feeDetails.selectList(new LambdaQueryWrapper() .eq(InboundFeeDetailEntity::getTenantId, tenantId) .eq(InboundFeeDetailEntity::getInboundId, inboundId) + .ne(!includeHistory, InboundFeeDetailEntity::getPublicationStatus, "SUPERSEDED") .orderByDesc(InboundFeeDetailEntity::getBusinessDate) + .orderByDesc(InboundFeeDetailEntity::getSourceFeeVersion) .orderByDesc(InboundFeeDetailEntity::getId)); } @@ -223,13 +225,20 @@ public class FeeItemRepository { .set(InboundFeeDetailEntity::getUpdatedBy, reviewerId)); } - public int adjustApproved(InboundFeeDetailEntity detail, int expectedSourceVersion) { - return feeDetails.update(detail, new LambdaUpdateWrapper() - .eq(InboundFeeDetailEntity::getTenantId, detail.getTenantId()) - .eq(InboundFeeDetailEntity::getInboundId, detail.getInboundId()) - .eq(InboundFeeDetailEntity::getId, detail.getId()) + public int supersedeApprovedManual(long tenantId, long inboundId, long detailId, + int expectedSourceVersion, long operatorId, + LocalDateTime now) { + return feeDetails.update(null, new LambdaUpdateWrapper() + .eq(InboundFeeDetailEntity::getTenantId, tenantId) + .eq(InboundFeeDetailEntity::getInboundId, inboundId) + .eq(InboundFeeDetailEntity::getId, detailId) + .eq(InboundFeeDetailEntity::getSourceType, "MANUAL") .eq(InboundFeeDetailEntity::getReviewStatus, "APPROVED") - .eq(InboundFeeDetailEntity::getSourceFeeVersion, expectedSourceVersion)); + .eq(InboundFeeDetailEntity::getSourceFeeVersion, expectedSourceVersion) + .ne(InboundFeeDetailEntity::getPublicationStatus, "SUPERSEDED") + .set(InboundFeeDetailEntity::getPublicationStatus, "SUPERSEDED") + .set(InboundFeeDetailEntity::getUpdatedBy, operatorId) + .set(InboundFeeDetailEntity::getUpdatedAt, now)); } public int voidApproved(long tenantId, long inboundId, long detailId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java index 3123b3ae6..84cf4c698 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java @@ -30,9 +30,24 @@ public class InboundFeeDetailService { @Resource private FinanceFeeSourceOutboxService financeOutbox; - public List list(long tenantId, long inboundId) { + @Transactional + public List list(long tenantId, long inboundId, boolean includeHistory) { requiredInbound(tenantId, inboundId); - return repository.listDetails(tenantId, inboundId); + materializeLatestFreightIfMissing(tenantId, inboundId); + return repository.listDetails(tenantId, inboundId, includeHistory); + } + + private void materializeLatestFreightIfMissing(long tenantId, long inboundId) { + InboundFeeEntity latest = pricingRepository.findLatestFee(tenantId, inboundId); + if (latest == null || repository.findBySourceFee( + tenantId, "FBA_PRICING", latest.getId()) != null) return; + LocalDateTime occurredAt = latest.getSentAt() != null ? latest.getSentAt() + : latest.getCalculatedAt(); + if ("SENT".equals(latest.getFeeStatus())) { + publishFreight(tenantId, inboundId, latest.getId(), 0L, "SYSTEM", occurredAt); + } else if ("CALCULATED".equals(latest.getFeeStatus())) { + materializeCalculatedFreight(tenantId, inboundId, latest.getId(), 0L, occurredAt); + } } public InboundFeeDetailEntity publishedFreight(long tenantId, long inboundId) { @@ -147,8 +162,15 @@ public class InboundFeeDetailService { repository.supersedeFreight(tenantId, inboundId, feeId, operatorId, now); InboundFeeDetailEntity detail = freightDetail( tenantId, inboundId, operatorId, null, now, inbound, fee, requiredFreightItem(), false); - repository.insertDetail(detail); - return detail; + try { + repository.insertDetail(detail); + return detail; + } catch (DuplicateKeyException exception) { + InboundFeeDetailEntity concurrent = repository.findBySourceFee( + tenantId, "FBA_PRICING", feeId); + if (concurrent != null) return concurrent; + throw exception; + } } private InboundFeeDetailEntity freightDetail(long tenantId, long inboundId, long operatorId, @@ -230,7 +252,7 @@ public class InboundFeeDetailService { @Transactional public void approve(long tenantId, long inboundId, long detailId, long reviewerId, String reviewerName) { - required(tenantId, inboundId, detailId); + requiredManual(tenantId, inboundId, detailId); if (repository.review(tenantId, inboundId, detailId, "APPROVED", reviewerId, reviewerName, null) != 1) { throw conflict("只有待审核费用明细可以审核通过"); @@ -243,7 +265,7 @@ public class InboundFeeDetailService { public InboundFeeDetailEntity adjust(long tenantId, long inboundId, long detailId, int expectedSourceVersion, long operatorId, SaveRequest request) { - InboundFeeDetailEntity value = required(tenantId, inboundId, detailId); + InboundFeeDetailEntity value = requiredManual(tenantId, inboundId, detailId); if (!"APPROVED".equals(value.getReviewStatus()) || "VOIDED".equals(value.getPublicationStatus())) { throw conflict("只有审核通过且未作废的费用明细可以调整"); } @@ -252,25 +274,30 @@ public class InboundFeeDetailService { } InboundOrderEntity inbound = requiredInbound(tenantId, inboundId); FeeItemEntity item = requiredActiveItem(request.feeItemId()); - apply(value, inbound, item, request, operatorId); - value.setSourceType("MANUAL"); - value.setSourceFeeVersion(expectedSourceVersion + 1); - value.setReviewStatus("PENDING"); - value.setReviewerId(null); - value.setReviewerName(null); - value.setReviewedAt(null); - value.setReviewReason(null); - value.setPublicationStatus("DRAFT"); - if (repository.adjustApproved(value, expectedSourceVersion) != 1) { + LocalDateTime now = LocalDateTime.now(); + if (repository.supersedeApprovedManual(tenantId, inboundId, detailId, + expectedSourceVersion, operatorId, now) != 1) { throw conflict("费用明细已被其他人调整,请刷新后重试"); } - return required(tenantId, inboundId, detailId); + InboundFeeDetailEntity next = new InboundFeeDetailEntity(); + next.setTenantId(tenantId); + next.setInboundId(inboundId); + next.setSourceType("MANUAL"); + next.setSourceFeeId(value.getSourceFeeId() == null ? value.getId() : value.getSourceFeeId()); + next.setSourceFeeVersion(expectedSourceVersion + 1); + next.setSourceFeeNo(value.getSourceFeeNo()); + next.setCreatedBy(operatorId); + apply(next, inbound, item, request, operatorId); + next.setReviewStatus("PENDING"); + next.setPublicationStatus("DRAFT"); + repository.insertDetail(next); + return next; } @Transactional public void voidApproved(long tenantId, long inboundId, long detailId, int expectedSourceVersion, long operatorId) { - InboundFeeDetailEntity current = required(tenantId, inboundId, detailId); + InboundFeeDetailEntity current = requiredManual(tenantId, inboundId, detailId); if (repository.voidApproved(tenantId, inboundId, detailId, expectedSourceVersion, operatorId) != 1) { throw conflict("只有当前版本的已审核费用明细可以作废"); } @@ -283,7 +310,7 @@ public class InboundFeeDetailService { @Transactional public void reject(long tenantId, long inboundId, long detailId, long reviewerId, String reviewerName, String reason) { - required(tenantId, inboundId, detailId); + requiredManual(tenantId, inboundId, detailId); if (repository.review(tenantId, inboundId, detailId, "REJECTED", reviewerId, reviewerName, reason.trim()) != 1) { throw conflict("只有待审核费用明细可以驳回"); @@ -333,11 +360,19 @@ public class InboundFeeDetailService { } private InboundFeeDetailEntity requiredEditable(long tenantId, long inboundId, long detailId) { - InboundFeeDetailEntity value = required(tenantId, inboundId, detailId); + InboundFeeDetailEntity value = requiredManual(tenantId, inboundId, detailId); if ("APPROVED".equals(value.getReviewStatus())) throw conflict("审核通过的费用明细不能修改或删除"); return value; } + private InboundFeeDetailEntity requiredManual(long tenantId, long inboundId, long detailId) { + InboundFeeDetailEntity value = required(tenantId, inboundId, detailId); + if ("FBA_PRICING".equals(value.getSourceType())) { + throw conflict("自动头程运费明细为只读派生,请在头程费用中调整或发布"); + } + return value; + } + private static void apply(InboundFeeDetailEntity value, InboundOrderEntity inbound, FeeItemEntity item, SaveRequest request, long operatorId) { ensureWarehouseApplicable(item, request.direction()); @@ -380,7 +415,8 @@ public class InboundFeeDetailService { String eventType = "VOIDED".equals(status) ? FinanceFeeSourceOutboxService.VOIDED : version > 1 ? FinanceFeeSourceOutboxService.ADJUSTED : FinanceFeeSourceOutboxService.PUBLISHED; + long sourceId = detail.getSourceFeeId() == null ? detail.getId() : detail.getSourceFeeId(); financeOutbox.publishInbound(eventType, detail.getTenantId(), detail.getInboundId(), - detail.getId(), version, occurredAt); + sourceId, version, occurredAt); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java index 626caa1b4..2b287f49d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java @@ -508,6 +508,14 @@ public class LoadingListService { json(values("source", "PDA", "pickTaskId", pickTaskId, "status", "COMPLETED", "version", before.getVersion() + 1)), operatorId, trim(operatorName), "PDA已人工确认装柜完成"); + } + + /** + * Publishes the cross-region handover only after every inbound projection has reached LOADED. + * The PDA completion orchestrator invokes this after finalizing all fulfillment slices. + */ + @Transactional + public void publishCompletedEvent(long planId) { loadingCompletedOutboxService.publish(planId); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index 61fed730e..6922b9c2e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -472,6 +472,7 @@ public class LoadingPickService { loadingDeliveryService.completeAfterPdaSettlement(owner.tenantId(), owner.inboundId(), operatorId, operatorName, "PDA-" + operationId + '-' + owner.inboundId(), remark); } + loadingListService.publishCompletedEvent(task.getLoadingPlanId()); return view(requiredTask(taskId), null); } diff --git a/firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql b/firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql index d5176a8dc..08bf9d82b 100644 --- a/firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql +++ b/firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql @@ -406,7 +406,7 @@ DEALLOCATE PREPARE statement; CALL phase2_publish_add_column('fl_inbound_fee_detail', 'source_type', "VARCHAR(32) NOT NULL DEFAULT 'MANUAL' COMMENT 'MANUAL或FBA_PRICING' AFTER fee_item_id"); CALL phase2_publish_add_column('fl_inbound_fee_detail', 'source_fee_id', - "BIGINT UNSIGNED NULL COMMENT '来源计费快照ID' AFTER source_type"); + "BIGINT UNSIGNED NULL COMMENT '来源计费快照ID或人工费用版本链根ID' AFTER source_type"); CALL phase2_publish_add_column('fl_inbound_fee_detail', 'source_fee_version', "INT UNSIGNED NULL COMMENT '来源计费快照版本' AFTER source_fee_id"); CALL phase2_publish_add_column('fl_inbound_fee_detail', 'source_fee_no', @@ -457,7 +457,7 @@ DELIMITER ; CALL phase2_publish_add_index('fl_fee_item', 'uk_fee_item_code', 'UNIQUE KEY `uk_fee_item_code` (`fee_code`)'); CALL phase2_publish_add_index('fl_inbound_fee_detail', 'uk_inbound_fee_source', - 'UNIQUE KEY `uk_inbound_fee_source` (`tenant_id`, `source_type`, `source_fee_id`)'); + 'UNIQUE KEY `uk_inbound_fee_source` (`tenant_id`, `source_type`, `source_fee_id`, `source_fee_version`)'); CALL phase2_publish_add_index('fl_inbound_fee_detail', 'idx_inbound_fee_published', 'KEY `idx_inbound_fee_published` (`tenant_id`, `inbound_id`, `publication_status`, `source_fee_version`)'); diff --git a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml index ff1510eb8..90b3ded29 100644 --- a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml +++ b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml @@ -133,24 +133,25 @@ paths: description: 仅匹配当前唯一 ACTIVE 船期价格表中的 ACTIVE 价格行,按起运仓、渠道、目的仓、pricingTaxMode 和计费单位估价;不再以 carrierId、业务 customerId 或客户计费规则作为前置。区域阶梯渠道可按平台 FBA 仓或海外中转仓的邮编区域和重量阶梯匹配;测量审核后即使主流程已到 WAIT_SPLIT,也可独立试算且不回退主流程状态;需要 firstleg:fba-pricing:calculate 权限。 parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: {content: {application/json: {schema: {$ref: '#/components/schemas/CalculateRequest'}}}} - responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} + responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}} /api/platform/v1/inbounds/{inboundId}/fees/confirm: post: summary: 确认最新费用快照 description: 确认费用只更新费用状态;主流程已到 WAIT_SPLIT 时保持不变。 parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}] - responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} + responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}} /api/platform/v1/inbounds/{inboundId}/fees/send: post: summary: 发送已确认费用快照 - description: 该动作涉及后续仓储流程,本轮二期验收不调用。 + description: 人工发布当前已确认版本;仅此动作将费用置为 SENT、生成 PUBLISHED 的 FBA_PRICING 只读派生明细并写入 Finance Outbox。入库单已到 WAIT_SPLIT 时不得回退或阻塞主流程。 parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}] - responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} + responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}} /api/platform/v1/inbounds/{inboundId}/fees/latest: get: - summary: 查询最新费用快照 + summary: 查询 fl_inbound_fee 最新费用快照 + description: fl_inbound_fee 是头程运费唯一事实源;fee-details 中 sourceType=FBA_PRICING 的记录仅为最新版本只读派生。 parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}] - responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} + responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}} /api/platform/v1/inbounds/{inboundId}/fees/published: put: summary: 调整初算或已发布费用 @@ -161,7 +162,62 @@ paths: content: application/json: schema: {$ref: '#/components/schemas/AdjustFeeRequest'} - responses: {'200': {description: 成功或业务失败}, '403': {description: 无调整权限}} + responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无调整权限}} + /api/platform/v1/inbounds/{inboundId}/fee-details: + get: + summary: 查询入仓费用明细 + description: 默认隐藏 publicationStatus=SUPERSEDED 的历史版本。FBA_PRICING 是 fl_inbound_fee 最新版本的只读派生;MANUAL 是独立新增、审核和版本调整的其他应收/应付。 + parameters: + - {$ref: '#/components/parameters/AccountId'} + - {$ref: '#/components/parameters/InboundId'} + - {name: includeHistory, in: query, required: false, schema: {type: boolean, default: false}, description: true 时包含 SUPERSEDED 历史版本} + responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/InboundFeeDetailListApiResponse'}}}}, '403': {description: 无权限}} + post: + summary: 新增 MANUAL 其他费用 + description: 只允许创建 MANUAL 来源;FBA_PRICING 由系统从 fl_inbound_fee 派生。 + parameters: [{$ref: '#/components/parameters/AccountId'}, {$ref: '#/components/parameters/InboundId'}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/InboundFeeDetailSaveRequest'}}}} + responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/InboundFeeDetailApiResponse'}}}}, '403': {description: 无权限}} + /api/platform/v1/inbounds/{inboundId}/fee-details/{detailId}: + put: + summary: 编辑待审核 MANUAL 费用 + description: FBA_PRICING 只读派生禁止通过通用费用接口修改。 + parameters: [{$ref: '#/components/parameters/AccountId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/DetailId'}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/InboundFeeDetailSaveRequest'}}}} + responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/InboundFeeDetailApiResponse'}}}}, '403': {description: 无权限}} + delete: + summary: 删除待审核 MANUAL 费用 + description: FBA_PRICING 只读派生禁止删除。 + parameters: [{$ref: '#/components/parameters/AccountId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/DetailId'}] + responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/VoidApiResponse'}}}}, '403': {description: 无权限}} + /api/platform/v1/inbounds/{inboundId}/fee-details/{detailId}/approve: + post: + summary: 审核通过 MANUAL 费用当前版本 + parameters: [{$ref: '#/components/parameters/AccountId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/DetailId'}] + responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/VoidApiResponse'}}}}, '403': {description: 无权限}} + /api/platform/v1/inbounds/{inboundId}/fee-details/{detailId}/reject: + post: + summary: 驳回 MANUAL 费用当前版本 + parameters: [{$ref: '#/components/parameters/AccountId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/DetailId'}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/InboundFeeDetailRejectRequest'}}}} + responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/VoidApiResponse'}}}}, '403': {description: 无权限}} + /api/platform/v1/inbounds/{inboundId}/fee-details/{detailId}/adjust: + post: + summary: 调整已审核 MANUAL 费用并创建新版本 + description: 原版本置为 SUPERSEDED,新记录使用相同 sourceFeeId、sourceFeeVersion+1 并重新进入 PENDING/DRAFT;FBA_PRICING 必须通过头程费用调整接口处理。 + parameters: + - {$ref: '#/components/parameters/AccountId'} + - {$ref: '#/components/parameters/InboundId'} + - {$ref: '#/components/parameters/DetailId'} + - {name: expectedSourceVersion, in: query, required: true, schema: {type: integer, minimum: 1}} + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/InboundFeeDetailSaveRequest'}}}} + responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/InboundFeeDetailApiResponse'}}}}, '403': {description: 无权限}} + /api/platform/v1/inbounds/{inboundId}/fee-details/{detailId}/void: + post: + summary: 作废已审核 MANUAL 费用当前版本 + parameters: [{$ref: '#/components/parameters/AccountId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/DetailId'}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/InboundFeeDetailVersionRequest'}}}} + responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/VoidApiResponse'}}}}, '403': {description: 无权限}} components: securitySchemes: bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} @@ -170,9 +226,182 @@ components: TenantId: {name: tenantId, in: query, required: true, description: 目标客户 ID;参数名为历史兼容, schema: {type: string, pattern: '^[1-9][0-9]*$'}} Id: {name: id, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} InboundId: {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + DetailId: {name: detailId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + AccountId: {name: accountId, in: query, required: true, description: 目标客户 ID, schema: {type: string, pattern: '^[1-9][0-9]*$'}} VersionId: {name: versionId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}} schemas: + FeeSnapshotApiResponse: + type: object + required: [code, message, data] + properties: + code: {type: string} + message: {type: string} + data: {$ref: '#/components/schemas/FeeSnapshotResponse'} + requestId: {type: string, nullable: true} + traceId: {type: string, nullable: true} + timestamp: {type: string, format: date-time, nullable: true} + FeeSnapshotResponse: + type: object + description: fl_inbound_fee 当前版本快照;自动初算为 CALCULATED,人工确认后为 CONFIRMED,只有人工发布后为 SENT。 + required: [feeId, feeNo, feeVersion, feeStatus, inboundId, totalAmount, currency] + properties: + feeId: {type: string, pattern: '^[1-9][0-9]*$'} + feeNo: {type: string} + feeVersion: {type: integer, minimum: 1} + feeStatus: {type: string, enum: [CALCULATED, CONFIRMED, SENT, SUPERSEDED]} + inboundId: {type: string, pattern: '^[1-9][0-9]*$'} + inboundCode: {type: string, nullable: true} + chargeRuleId: {type: string, nullable: true} + ruleCode: {type: string, nullable: true} + chargeRuleVersion: {type: integer, nullable: true} + carrierRateId: {type: string, nullable: true} + rateCode: {type: string, nullable: true} + carrierRateVersion: {type: integer, nullable: true} + priceBookVersionId: {type: string, nullable: true} + customerId: {type: string, nullable: true} + customerName: {type: string, nullable: true} + originWarehouseId: {type: string, nullable: true} + originWarehouseName: {type: string, nullable: true} + businessType: {type: string, nullable: true} + channelCode: {type: string, nullable: true} + carrierId: {type: string, nullable: true} + carrierName: {type: string, nullable: true} + destinationType: {type: string, nullable: true} + destinationWarehouseId: {type: string, nullable: true} + destinationName: {type: string, nullable: true} + destinationCode: {type: string, nullable: true} + taxMode: {type: string, nullable: true} + pricingTaxMode: {type: string, nullable: true} + chargeMode: {type: string, nullable: true} + conversionFactor: {type: number, nullable: true} + roundingScale: {type: integer, nullable: true} + roundingMode: {type: string, nullable: true} + actualWeightKg: {type: number, nullable: true} + actualVolumeCbm: {type: number, nullable: true} + convertedQuantity: {type: number, nullable: true} + chargeableQuantity: {type: number, nullable: true} + chargeUnit: {type: string, nullable: true} + unitPrice: {type: number, nullable: true} + subtotalAmount: {type: number, nullable: true} + additionalAmount: {type: number, nullable: true} + totalAmount: {type: number} + currency: {type: string, pattern: '^[A-Z]{3}$'} + rateEffectiveFrom: {type: string, format: date-time, nullable: true} + rateEffectiveTo: {type: string, format: date-time, nullable: true} + calculatedAt: {type: string, format: date-time, nullable: true} + confirmedAt: {type: string, format: date-time, nullable: true} + sentAt: {type: string, format: date-time, nullable: true} + nextProcessStatus: {type: string, nullable: true, description: 费用动作后的入库主流程状态;审核完成后保持 WAIT_SPLIT} + InboundFeeDetailSaveRequest: + type: object + required: [feeItemId, direction, businessDate, unitPrice, currency, amount, settlementIncluded] + properties: + feeItemId: {type: string, pattern: '^[1-9][0-9]*$'} + direction: {type: string, enum: [RECEIVABLE, PAYABLE]} + businessDate: {type: string, format: date} + unitPrice: {type: number, minimum: 0} + currency: {type: string, pattern: '^[A-Z]{3}$'} + amount: {type: number, minimum: 0} + settlementIncluded: {type: boolean} + partnerId: {type: string, nullable: true} + partnerName: {type: string, maxLength: 200, nullable: true} + remark: {type: string, maxLength: 500, nullable: true} + InboundFeeDetailVersionRequest: + type: object + required: [expectedSourceVersion] + properties: {expectedSourceVersion: {type: integer, minimum: 1}} + InboundFeeDetailRejectRequest: + type: object + required: [reason] + properties: {reason: {type: string, minLength: 1, maxLength: 500}} + InboundFeeDetailResource: + type: object + description: 入仓费用展示记录。sourceType=FBA_PRICING 时是只读派生;sourceType=MANUAL 时按 sourceFeeId/sourceFeeVersion 独立版本化。 + required: [id, tenantId, inboundId, feeItemId, sourceType, sourceFeeVersion, direction, businessDate, unitPrice, currency, amount, reviewStatus, publicationStatus, settlementIncluded] + properties: + id: {type: string, pattern: '^[1-9][0-9]*$'} + tenantId: {type: string, pattern: '^[1-9][0-9]*$'} + inboundId: {type: string, pattern: '^[1-9][0-9]*$'} + feeItemId: {type: string, pattern: '^[1-9][0-9]*$'} + sourceType: {type: string, enum: [MANUAL, FBA_PRICING]} + sourceFeeId: {type: string, nullable: true, description: FBA_PRICING 对应 fl_inbound_fee.id;MANUAL V2+ 指向稳定的首版本来源 ID} + sourceFeeVersion: {type: integer, minimum: 1} + sourceFeeNo: {type: string, nullable: true} + inboundCode: {type: string, nullable: true} + direction: {type: string, enum: [RECEIVABLE, PAYABLE]} + partnerId: {type: string, nullable: true} + partnerName: {type: string, nullable: true} + businessDate: {type: string, format: date} + customerId: {type: string, nullable: true} + customerName: {type: string, nullable: true} + invoiceTitle: {type: string, nullable: true} + feeCategorySnapshot: {type: string, nullable: true} + tradeScopeSnapshot: {type: string, nullable: true} + feeTypeNameSnapshot: {type: string, nullable: true} + channelCode: {type: string, nullable: true} + channelName: {type: string, nullable: true} + destinationCode: {type: string, nullable: true} + destinationName: {type: string, nullable: true} + packageCount: {type: integer, nullable: true} + actualGrossWeightKg: {type: number, nullable: true} + actualVolumeCbm: {type: number, nullable: true} + volumeWeightKg: {type: number, nullable: true} + chargeableWeightKg: {type: number, nullable: true} + chargeableVolumeCbm: {type: number, nullable: true} + chargeableQuantity: {type: number, nullable: true} + chargeUnit: {type: string, nullable: true} + unitPrice: {type: number} + currency: {type: string, pattern: '^[A-Z]{3}$'} + amount: {type: number} + remark: {type: string, nullable: true} + reviewStatus: {type: string, enum: [PENDING, APPROVED, REJECTED]} + reviewerId: {type: string, nullable: true} + reviewerName: {type: string, nullable: true} + reviewedAt: {type: string, format: date-time, nullable: true} + reviewReason: {type: string, nullable: true} + publicationStatus: {type: string, enum: [DRAFT, PUBLISHED, SUPERSEDED, VOIDED]} + publishedBy: {type: string, nullable: true} + publishedByName: {type: string, nullable: true} + publishedAt: {type: string, format: date-time, nullable: true} + inboundedAt: {type: string, format: date-time, nullable: true} + settlementIncluded: {type: boolean} + createdBy: {type: string, nullable: true} + createdAt: {type: string, format: date-time, nullable: true} + updatedBy: {type: string, nullable: true} + updatedAt: {type: string, format: date-time, nullable: true} + deleted: {type: boolean, nullable: true} + version: {type: integer, nullable: true} + InboundFeeDetailApiResponse: + type: object + required: [code, message, data] + properties: + code: {type: string} + message: {type: string} + data: {$ref: '#/components/schemas/InboundFeeDetailResource'} + requestId: {type: string, nullable: true} + traceId: {type: string, nullable: true} + timestamp: {type: string, format: date-time, nullable: true} + InboundFeeDetailListApiResponse: + type: object + required: [code, message, data] + properties: + code: {type: string} + message: {type: string} + data: {type: array, items: {$ref: '#/components/schemas/InboundFeeDetailResource'}} + requestId: {type: string, nullable: true} + traceId: {type: string, nullable: true} + timestamp: {type: string, format: date-time, nullable: true} + VoidApiResponse: + type: object + required: [code, message] + properties: + code: {type: string} + message: {type: string} + data: {nullable: true} + requestId: {type: string, nullable: true} + traceId: {type: string, nullable: true} + timestamp: {type: string, format: date-time, nullable: true} AdjustFeeRequest: type: object required: [expectedFeeVersion, chargeableQuantity, unitPrice, additionalAmount, reason] diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 8a73bb6cf..6b4be33be 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -1053,6 +1053,7 @@ components: sailingAt: {type: string, format: date-time, nullable: true} portArrivalAt: {type: string, format: date-time, nullable: true} expressTrackingNo: {type: string, nullable: true} + createdByName: {type: string, nullable: true} createdAt: {type: string, format: date-time, nullable: true} updatedAt: {type: string, format: date-time, nullable: true} UnifiedPreloadSummary: @@ -1629,6 +1630,37 @@ components: companyId: {type: string, nullable: true} taxMode: {type: string, nullable: true, description: 订单报关方式快照} pricingTaxMode: {type: string, enum: [TAX_INCLUDED, TAX_EXCLUDED], nullable: true, description: 下单时选择的报价包税维度快照} + expectedArrivalAt: {type: string, format: date-time, nullable: true} + customerName: {type: string, nullable: true} + companyName: {type: string, nullable: true} + warehouseId: {type: string, nullable: true} + warehouseName: {type: string, nullable: true} + deliveryWarehouseId: {type: string, nullable: true} + deliveryWarehouseName: {type: string, nullable: true} + businessType: {type: string, nullable: true} + channelCode: {type: string, nullable: true} + channelName: {type: string, nullable: true} + containerMode: {type: string, nullable: true} + customsInspection: {type: boolean, nullable: true} + destinationType: {type: string, nullable: true} + destinationWarehouseId: {type: string, nullable: true} + destinationCode: {type: string, nullable: true} + destinationName: {type: string, nullable: true} + totalPackageCount: {type: integer, nullable: true} + actualGrossWeightKg: {type: number, nullable: true} + actualNetWeightKg: {type: number, nullable: true} + actualVolumeCbm: {type: number, nullable: true} + volumeWeightKg: {type: number, nullable: true} + chargeableWeightKg: {type: number, nullable: true} + chargeableVolumeCbm: {type: number, nullable: true} + palletCount: {type: integer, nullable: true} + locationCodes: {type: string, nullable: true} + measureCompletedAt: {type: string, format: date-time, nullable: true} + inboundedAt: {type: string, format: date-time, nullable: true} + expressCompanyName: {type: string, nullable: true} + expressTrackingNo: {type: string, nullable: true} + createdAt: {type: string, format: date-time, nullable: true} + updatedAt: {type: string, format: date-time, nullable: true} InboundMeasurementCallbackRequest: type: object required: [eventNo, externalDetailNo, warehouseCode, inboundCode, inboundDetailId, deviceCode, measuredAt, boxSpecification, packageCount, lengthCm, widthCm, heightCm] diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java index 385785b92..956e14f4a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java @@ -51,14 +51,13 @@ class FbaPricingContractTest { .contains("主流程已到 WAIT_SPLIT,也可独立试算且不回退主流程状态") .contains("区域阶梯渠道可按平台 FBA 仓或海外中转仓的邮编区域") .contains("不再以 carrierId、业务 customerId 或客户计费规则作为前置") - .contains("该动作涉及后续仓储流程,本轮二期验收不调用") + .contains("仅此动作将费用置为 SENT") + .contains("写入 Finance Outbox") .contains("不回退 WAIT_SPLIT 状态") .contains("expectedFeeVersion") .contains("chargeableQuantity") .contains("unitPrice") .doesNotContain("required: [subtotalAmount, additionalAmount, reason]") - .doesNotContain("应收") - .doesNotContain("应付") .doesNotContain("待客户确认"); String customerRules = openapi.substring( openapi.indexOf("/api/platform/v1/fba-pricing/customer-rules:"), diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundFeeOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundFeeOpenApiContractTest.java new file mode 100644 index 000000000..a6e9f0fe6 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundFeeOpenApiContractTest.java @@ -0,0 +1,80 @@ +package com.baiyue.firstleg.controller; + +import com.baiyue.firstleg.dto.FbaPricingDtos; +import com.baiyue.firstleg.dto.InboundFeeDetailDtos; +import com.baiyue.firstleg.entity.InboundFeeDetailEntity; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import org.yaml.snakeyaml.Yaml; + +import java.lang.reflect.Field; +import java.lang.reflect.RecordComponent; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class InboundFeeOpenApiContractTest { + @Test + void documentsEveryInboundFeeControllerPathAndReadOnlySourceContract() throws Exception { + String source = new ClassPathResource("openapi/fba-pricing-v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + Map document = map(new Yaml().load(source)); + Map paths = map(document.get("paths")); + assertThat(paths).containsKeys( + "/api/platform/v1/inbounds/{inboundId}/fees/calculate", + "/api/platform/v1/inbounds/{inboundId}/fees/confirm", + "/api/platform/v1/inbounds/{inboundId}/fees/send", + "/api/platform/v1/inbounds/{inboundId}/fees/latest", + "/api/platform/v1/inbounds/{inboundId}/fees/published", + "/api/platform/v1/inbounds/{inboundId}/fee-details", + "/api/platform/v1/inbounds/{inboundId}/fee-details/{detailId}", + "/api/platform/v1/inbounds/{inboundId}/fee-details/{detailId}/approve", + "/api/platform/v1/inbounds/{inboundId}/fee-details/{detailId}/reject", + "/api/platform/v1/inbounds/{inboundId}/fee-details/{detailId}/adjust", + "/api/platform/v1/inbounds/{inboundId}/fee-details/{detailId}/void"); + assertThat(source).contains("includeHistory", "FBA_PRICING", "MANUAL", "只读派生", + "Finance Outbox", "CALCULATED", "CONFIRMED", "SENT", "SUPERSEDED"); + } + + @Test + void responseAndRequestSchemasTrackActualDtoAndEntityFields() throws Exception { + Map document = map(new Yaml().load(new ClassPathResource( + "openapi/fba-pricing-v1.yaml").getContentAsString(StandardCharsets.UTF_8))); + Map schemas = map(map(document.get("components")).get("schemas")); + + assertThat(properties(schemas, "FeeSnapshotResponse").keySet()) + .containsExactlyInAnyOrder(recordFields(FbaPricingDtos.FeeSnapshotResponse.class).toArray(String[]::new)); + assertThat(properties(schemas, "InboundFeeDetailSaveRequest").keySet()) + .containsExactlyInAnyOrder(recordFields(InboundFeeDetailDtos.SaveRequest.class).toArray(String[]::new)); + assertThat(properties(schemas, "InboundFeeDetailVersionRequest").keySet()) + .containsExactlyInAnyOrder(recordFields(InboundFeeDetailDtos.VersionRequest.class).toArray(String[]::new)); + assertThat(properties(schemas, "InboundFeeDetailRejectRequest").keySet()) + .containsExactlyInAnyOrder(recordFields(InboundFeeDetailDtos.RejectRequest.class).toArray(String[]::new)); + + Set entityFields = new LinkedHashSet<>(); + for (Class type = InboundFeeDetailEntity.class; type != Object.class; type = type.getSuperclass()) { + Arrays.stream(type.getDeclaredFields()).map(Field::getName).forEach(entityFields::add); + } + assertThat(properties(schemas, "InboundFeeDetailResource").keySet()) + .containsExactlyInAnyOrder(entityFields.toArray(String[]::new)); + } + + private static Set recordFields(Class type) { + Set fields = new LinkedHashSet<>(); + Arrays.stream(type.getRecordComponents()).map(RecordComponent::getName).forEach(fields::add); + return fields; + } + + private static Map properties(Map schemas, String schema) { + return map(map(schemas.get(schema)).get("properties")); + } + + @SuppressWarnings("unchecked") + private static Map map(Object value) { + return (Map) value; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderStatusSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderStatusSqlContractTest.java index 80a0b1e77..80f0cc74e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderStatusSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderStatusSqlContractTest.java @@ -22,6 +22,16 @@ class InboundOrderStatusSqlContractTest { .contains("process_status = 'INBOUNDING'"); } + @Test + void keepsConfirmedWeightNetSumAuthoritativeWhenMeasurementIsApproved() { + assertThat(sql("approveMeasurement")) + .contains("FROM fl_inbound_weight_result weight") + .contains("weight.recognition_status = 'CONFIRMED'") + .contains("weight.deleted = 0") + .contains("SUM(weight.net_weight_kg)") + .contains("ELSE (SELECT COALESCE(SUM(item.total_weight_kg), 0)"); + } + private static String sql(String methodName) { Method method = Arrays.stream(InboundOrderMapper.class.getDeclaredMethods()) .filter(candidate -> candidate.getName().equals(methodName)) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java index 4c6d3a06a..1375bfee8 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java @@ -223,6 +223,37 @@ class FeeManagementServiceTest { assertThat(saved.getSettlementIncluded()).isTrue(); } + @Test + @DisplayName("查询费用明细会幂等补齐历史初算缺失的派生行") + void materializesMissingLatestCalculatedFreightOnList() { + InboundOrderEntity inbound = inbound(); + inbound.setCustomerId(101L); + inbound.setCustomerName("客户A"); + when(repository.findInbound(10001L, 21L)).thenReturn(inbound); + when(repository.findBySourceFee(10001L, "FBA_PRICING", 501L)).thenReturn(null); + when(repository.listDetails(10001L, 21L, false)).thenReturn(List.of()); + FeeItemEntity freightItem = item(true, false); + freightItem.setFeeCode("FBA_HEAD_FREIGHT"); + when(repository.findItemByCode("FBA_HEAD_FREIGHT")).thenReturn(freightItem); + + InboundFeeEntity fee = new InboundFeeEntity(); + fee.setId(501L); fee.setInboundId(21L); fee.setFeeNo("FBAF-21-2"); + fee.setFeeVersion(2); fee.setFeeStatus("CALCULATED"); + fee.setActualWeightKg(new BigDecimal("100.0000")); + fee.setActualVolumeCbm(new BigDecimal("1.000000")); + fee.setChargeableQuantity(new BigDecimal("167.0000")); + fee.setChargeUnit("KG"); fee.setCurrency("USD"); + fee.setTotalAmount(new BigDecimal("354.00")); + fee.setCalculatedAt(LocalDateTime.of(2026, 8, 17, 12, 0)); + when(pricingRepository.findLatestFee(10001L, 21L)).thenReturn(fee); + when(pricingRepository.findFee(10001L, 501L)).thenReturn(fee); + + details.list(10001L, 21L, false); + + verify(repository).insertDetail(any(InboundFeeDetailEntity.class)); + verify(repository).listDetails(10001L, 21L, false); + } + @Test @DisplayName("系统头程运费项目缺失时拒绝运行期补建") void rejectsMissingSystemFreightItem() { @@ -321,6 +352,53 @@ class FeeManagementServiceTest { eq(10001L), eq(21L), eq(31L), eq(1), any(LocalDateTime.class)); } + @Test + @DisplayName("自动头程运费派生明细不能在费用明细中二次审核") + void rejectsReviewingDerivedFreight() { + InboundFeeDetailEntity derived = new InboundFeeDetailEntity(); + derived.setSourceType("FBA_PRICING"); + derived.setReviewStatus("PENDING"); + when(repository.findDetail(10001L, 21L, 31L)).thenReturn(derived); + + assertThatThrownBy(() -> details.approve(10001L, 21L, 31L, 9L, "admin")) + .isInstanceOf(BizException.class) + .hasMessageContaining("只读派生"); + + verify(repository, never()).review(10001L, 21L, 31L, "APPROVED", 9L, "admin", null); + } + + @Test + @DisplayName("已审核人工费用调整保留旧版本并创建待审核新版本") + void versionsApprovedManualAdjustment() { + InboundFeeDetailEntity approved = new InboundFeeDetailEntity(); + approved.setId(31L); + approved.setTenantId(10001L); + approved.setInboundId(21L); + approved.setSourceType("MANUAL"); + approved.setSourceFeeVersion(1); + approved.setReviewStatus("APPROVED"); + approved.setPublicationStatus("PUBLISHED"); + when(repository.findDetail(10001L, 21L, 31L)).thenReturn(approved); + when(repository.findInbound(10001L, 21L)).thenReturn(inbound()); + when(repository.findItem(11L)).thenReturn(item(true, true)); + when(repository.supersedeApprovedManual(eq(10001L), eq(21L), eq(31L), + eq(1), eq(9L), any(LocalDateTime.class))).thenReturn(1); + doAnswer(invocation -> { + ((InboundFeeDetailEntity) invocation.getArgument(0)).setId(32L); + return null; + }).when(repository).insertDetail(any()); + + InboundFeeDetailEntity next = details.adjust( + 10001L, 21L, 31L, 1, 9L, detailRequest("RECEIVABLE")); + + assertThat(next.getId()).isEqualTo(32L); + assertThat(next.getSourceFeeId()).isEqualTo(31L); + assertThat(next.getSourceFeeVersion()).isEqualTo(2); + assertThat(next.getReviewStatus()).isEqualTo("PENDING"); + assertThat(next.getPublicationStatus()).isEqualTo("DRAFT"); + verify(repository).insertDetail(next); + } + @Test @DisplayName("作废已入池人工费用引用当前版本并发布VOIDED") void voidsPublishedSettlementFee() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java index a513d6b4c..9a0e44baa 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -10,6 +10,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.apache.ibatis.annotations.Select; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; @@ -48,6 +49,14 @@ class LoadingCompletedOutboxServiceTest { assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:1"); } + @Test void snapshotOnlyPublishesFinalizedLoadedInbounds() throws Exception { + String sql = String.join(" ", LoadingCompletedEventMapper.class.getMethod("rows", long.class) + .getAnnotation(Select.class).value()).replaceAll("\\s+", " "); + + assertThat(sql).contains("p.plan_status = 'COMPLETED'") + .contains("o.process_status = 'LOADED'"); + } + private Row row(long account,long inbound,long detail) { Row r=new Row(); r.setLoadingPlanId(90L); r.setPlanNo("P90"); r.setContainerBookingId(91L); r.setAggregateVersion(8); r.setContainerNo("CONT"); r.setCabinetNo("CAB"); r.setWarehouseId(92L); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java index 8aaf35b37..aac76e2e6 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -176,6 +176,10 @@ class LoadingListServiceTest { verify(repository).appendHistory(eq(10L), eq("PDA-COMPLETE-10-21"), eq("PDA_COMPLETE_LOADING"), eq("LOADING"), eq("COMPLETED"), anyString(), anyString(), eq(9L), eq("operator"), eq("PDA已人工确认装柜完成")); + verify(loadingCompletedOutboxService, never()).publish(10L); + + service.publishCompletedEvent(10L); + verify(loadingCompletedOutboxService).publish(10L); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index 1069116a4..433ab5d6b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -523,6 +523,12 @@ class LoadingPickServiceTest { verify(truckAppointmentService).completeLoadingFromPda(99L, 20L, 9L, "operator"); verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator", "PDA-complete-1-50", "checked"); + verify(loadingListService).publishCompletedEvent(10L); + InOrder finalization = inOrder(loadingListService, loadingDeliveryService); + finalization.verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator"); + finalization.verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator", + "PDA-complete-1-50", "checked"); + finalization.verify(loadingListService).publishCompletedEvent(10L); } @Test @@ -548,6 +554,31 @@ class LoadingPickServiceTest { verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString()); verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(), anyString(), anyString(), any()); + verify(loadingListService, never()).publishCompletedEvent(anyLong()); + } + + @Test + void doesNotPublishCompletedEventWhenInboundFinalizationFails() { + LoadingPickTaskEntity task = task(); + when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.allTaskItemsResolved(20L)).thenReturn(true); + when(repository.allPlanItemsPicked(10L)).thenReturn(true); + when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1); + var owner = new LoadingPickItemMapper.InboundOwner(101L, 50L); + when(repository.inboundOwners(20L)).thenReturn(List.of(owner)); + when(repository.inboundForUpdate(101L, 50L)).thenReturn( + new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7)); + doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, + "入库单并发修改")) + .when(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator", + "PDA-complete-finalize-fails-50", "checked"); + + assertThatThrownBy(() -> service.complete(20L, + new CompleteLoadingRequest(0, "complete-finalize-fails", "checked"), 9L, "operator")) + .isInstanceOf(BizException.class).hasMessageContaining("并发修改"); + + verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator"); + verify(loadingListService, never()).publishCompletedEvent(anyLong()); } @Test diff --git a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java index 0cfcfd30b..d51fe0878 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java @@ -13,7 +13,7 @@ import com.baiyue.iam.dto.TenantDtos.ApprovalResponse; import com.baiyue.iam.dto.TenantDtos.CreationResponse; import com.baiyue.iam.dto.TenantDtos.CustomerImpersonationRequest; import com.baiyue.iam.dto.TenantDtos.CustomerImpersonationResponse; -import com.baiyue.iam.entity.IamTenantReview; +import com.baiyue.iam.dto.TenantDtos.PendingReviewResponse; import com.baiyue.iam.service.TenantGuard; import com.baiyue.iam.service.TenantService; import com.baiyue.iam.service.MemberService; @@ -179,7 +179,7 @@ public class PlatformTenantController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Review list returned / 查询成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform scope required / 需要平台端权限域") }) - public ApiResponse> reviews(@AuthenticationPrincipal AuthenticatedUser user) { + public ApiResponse> reviews(@AuthenticationPrincipal AuthenticatedUser user) { TenantGuard.tenant(user, "PLATFORM"); return ApiResponse.success(service.pendingReviews()); } diff --git a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java index bc74467f3..40e3422e9 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java @@ -101,6 +101,21 @@ public final class TenantDtos { @Schema(description = "Approval or rejection comment / 审核意见", example = "资料完整,审核通过") String comment ) { } + @Schema(name = "PendingEnterpriseReviewResponse", description = "Pending enterprise review with the customer summary required by platform reviewers / 待审核企业及客户摘要") + public record PendingReviewResponse( + @Schema(description = "Review ID / 审核单 ID", example = "190000000000000001") Long id, + @Schema(description = "Customer ID / 客户 ID", example = "190000000000000002") Long tenantId, + @Schema(description = "Review type / 审核类型", example = "REGISTER") String reviewType, + @Schema(description = "Review status / 审核状态", example = "PENDING") String status, + @Schema(description = "Customer name / 客户名称", example = "深圳虫虫出海科技有限公司") String tenantName, + @Schema(description = "Customer code / 客户编号", example = "TEN190000000000000002") String tenantCode, + @Schema(description = "Primary contact / 联系人", example = "张三") String contactName, + @Schema(description = "Contact phone / 联系电话", example = "13800138000") String contactPhone, + @Schema(description = "Contact email / 联系邮箱", example = "ops@example.com") String contactEmail, + @Schema(description = "Review submission time / 提交审核时间") LocalDateTime submittedAt, + @Schema(description = "Review comment / 审核意见", nullable = true) String reviewComment + ) { } + @Schema(name = "TenantApprovalResponse", description = "Enterprise approval result and one-time administrator credentials / 企业审核结果及一次性管理员凭据") public record ApprovalResponse( @Schema(description = "Tenant ID / 企业租户 ID", example = "190000000000000001") Long tenantId, diff --git a/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java b/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java index a5257de94..04e432e32 100644 --- a/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java +++ b/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java @@ -12,6 +12,7 @@ import org.springframework.stereotype.Repository; import org.springframework.util.StringUtils; import java.util.List; +import java.util.Collection; /** * 租户仓储,封装租户及审核单的唯一性校验、查询、创建、更新和待审列表操作。 @@ -48,6 +49,12 @@ public class TenantRepository { } public IamTenant findTenant(Long tenantId) { return tenantMapper.selectById(tenantId); } + public List findMerchantsByIds(Collection tenantIds) { + if (tenantIds == null || tenantIds.isEmpty()) return List.of(); + return tenantMapper.selectList(new LambdaQueryWrapper() + .in(IamTenant::getId, tenantIds) + .eq(IamTenant::getTenantType, "MERCHANT")); + } /** * Serializes the draft qualification workflow for one enterprise. The file service and the * merchant submit action run in separate requests, so qualification-row locks alone cannot diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantService.java b/iam/src/main/java/com/baiyue/iam/service/TenantService.java index 0a10d16e3..00291bbd9 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java @@ -22,6 +22,7 @@ import com.baiyue.iam.dto.OnboardingDtos.OrderEligibilityResponse; import com.baiyue.iam.dto.TenantDtos.RegisterRequest; import com.baiyue.iam.dto.TenantDtos.TenantResponse; import com.baiyue.iam.dto.TenantDtos.FinanceCustomerSummary; +import com.baiyue.iam.dto.TenantDtos.PendingReviewResponse; import com.baiyue.iam.dto.TenantDtos.UpdateProfileRequest; import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest; import com.baiyue.iam.dto.TenantDtos.ChangeTenantStatusRequest; @@ -40,7 +41,10 @@ import feign.FeignException; import java.time.LocalDateTime; import java.util.List; +import java.util.Map; import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; /** * 租户生命周期应用服务,编排商户入驻、资料维护、审核流转和租户状态变更事务。 @@ -189,8 +193,17 @@ public class TenantService { return new CreationResponse(response(tenant), administrator.username(), administrator.temporaryPassword()); } - public List pendingReviews() { - return repository.findPendingReviews(); + public List pendingReviews() { + List reviews = repository.findPendingReviews(); + if (reviews.isEmpty()) return List.of(); + Map tenants = repository.findMerchantsByIds(reviews.stream() + .map(IamTenantReview::getTenantId) + .collect(Collectors.toSet())) + .stream() + .collect(Collectors.toMap(IamTenant::getId, Function.identity())); + return reviews.stream() + .map(review -> pendingReviewResponse(review, tenants.get(review.getTenantId()))) + .toList(); } public PageResult pageMerchants(long page, long size, String keyword, String status) { @@ -497,6 +510,17 @@ public class TenantService { ); } + private PendingReviewResponse pendingReviewResponse(IamTenantReview review, IamTenant tenant) { + return new PendingReviewResponse( + review.getId(), review.getTenantId(), review.getReviewType(), review.getStatus(), + tenant == null ? null : tenant.getTenantName(), + tenant == null ? null : tenant.getTenantCode(), + tenant == null ? null : tenant.getContactName(), + tenant == null ? null : tenant.getContactPhone(), + tenant == null ? null : tenant.getContactEmail(), + review.getCreatedAt(), review.getReviewComment()); + } + private TenantResponse summaryResponse(IamTenant tenant) { TenantResponse response = response(tenant); return new TenantResponse(response.id(), response.tenantCode(), response.tenantName(), response.status(), diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index 0ae6774de..f0fb4c4ea 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -259,7 +259,11 @@ paths: summary: List pending tenant reviews / 查询待审核企业 description: Return enterprise registrations waiting for a platform decision. / 返回等待平台处理的企业注册审核记录。 responses: - '200': {$ref: '#/components/responses/Success'} + '200': + description: 待审核企业及客户摘要查询成功 + content: + application/json: + schema: {$ref: '#/components/schemas/PendingEnterpriseReviewListApiResponse'} '403': {$ref: '#/components/responses/Forbidden'} /api/platform/v1/iam/reviews/{id}/approve: post: @@ -903,6 +907,29 @@ components: - type: object properties: data: {$ref: '#/components/schemas/TenantApprovalResponse'} + PendingEnterpriseReviewListApiResponse: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: + type: array + items: {$ref: '#/components/schemas/PendingEnterpriseReviewResponse'} + PendingEnterpriseReviewResponse: + type: object + required: [id, tenantId, reviewType, status, tenantName, tenantCode, contactName, submittedAt] + properties: + id: {type: string, pattern: '^\d+$', description: 审核单 ID} + tenantId: {type: string, pattern: '^\d+$', description: 客户 ID} + reviewType: {type: string, example: REGISTER} + status: {type: string, enum: [PENDING]} + tenantName: {type: string, description: 客户名称} + tenantCode: {type: string, description: 客户编号} + contactName: {type: string, description: 联系人} + contactPhone: {type: string, nullable: true, description: 联系电话} + contactEmail: {type: string, format: email, nullable: true, description: 联系邮箱} + submittedAt: {type: string, format: date-time, description: 提交审核时间} + reviewComment: {type: string, nullable: true, description: 审核意见} TenantCreationApiResponse: allOf: - {$ref: '#/components/schemas/ApiResponse'} diff --git a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java index 921252509..1240e07d5 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java @@ -23,6 +23,10 @@ class IamOpenApiContractTest { .contains("PermissionCodesApiResponse:") .contains("TenantPageApiResponse:") .contains("TenantApiResponse:") + .contains("PendingEnterpriseReviewListApiResponse:") + .contains("PendingEnterpriseReviewResponse:") + .contains("required: [id, tenantId, reviewType, status, tenantName, tenantCode, contactName, submittedAt]") + .contains("submittedAt: {type: string, format: date-time, description: 提交审核时间}") .contains("TenantSuccess:") .contains("salesOwnerName:") .contains("paymentTermDays:") diff --git a/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java index 9066fd423..a6fc8ce28 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java @@ -46,6 +46,11 @@ class PlatformTenantControllerContractTest { assertEquals("hasAuthority('platform:customer:review')", method.getAnnotation(PreAuthorize.class).value()); + ParameterizedType apiResponse = (ParameterizedType) method.getGenericReturnType(); + ParameterizedType list = (ParameterizedType) apiResponse.getActualTypeArguments()[0]; + assertEquals(java.util.List.class, list.getRawType()); + assertEquals(com.baiyue.iam.dto.TenantDtos.PendingReviewResponse.class, + list.getActualTypeArguments()[0]); } @Test diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java index 18d4a36c0..2d99dd94a 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -30,6 +30,7 @@ import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; +import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.verify; @@ -93,6 +94,37 @@ class TenantServiceTest { verify(repository).pageMerchants(1, 10, "Example", "ACTIVE"); } + @Test + void pendingReviewsIncludeCustomerSummaryWithoutPerReviewQueries() { + IamTenantReview review = review(71L, 10001L); + review.setReviewType("REGISTER"); + review.setCreatedAt(LocalDateTime.of(2026, 8, 17, 10, 27)); + IamTenant tenant = merchant(10001L, "PENDING"); + tenant.setTenantCode("TEN10001"); + tenant.setTenantName("Example Merchant"); + tenant.setContactName("Alice"); + tenant.setContactPhone("13800138000"); + tenant.setContactEmail("alice@example.com"); + when(repository.findPendingReviews()).thenReturn(List.of(review)); + when(repository.findMerchantsByIds(Set.of(10001L))).thenReturn(List.of(tenant)); + + var result = service.pendingReviews(); + + assertEquals(1, result.size()); + assertEquals(71L, result.get(0).id()); + assertEquals(10001L, result.get(0).tenantId()); + assertEquals("REGISTER", result.get(0).reviewType()); + assertEquals("PENDING", result.get(0).status()); + assertEquals("Example Merchant", result.get(0).tenantName()); + assertEquals("TEN10001", result.get(0).tenantCode()); + assertEquals("Alice", result.get(0).contactName()); + assertEquals("13800138000", result.get(0).contactPhone()); + assertEquals("alice@example.com", result.get(0).contactEmail()); + assertEquals(LocalDateTime.of(2026, 8, 17, 10, 27), result.get(0).submittedAt()); + verify(repository).findMerchantsByIds(Set.of(10001L)); + verify(repository, never()).findTenant(10001L); + } + @Test void pageFinanceCustomersKeepsFullContactSummaryForInternalFinance() { IamTenant tenant = merchant(10001L, "ACTIVE"); diff --git a/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java b/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java index cabdcfbab..ca9fd3b24 100644 --- a/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java +++ b/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java @@ -47,8 +47,13 @@ public interface FirstlegUnifiedOrderClient { Long customerId, String customerName, Long warehouseId, String warehouseName, Long deliveryWarehouseId, String deliveryWarehouseName, String channelName, Integer totalPackageCount, String locationCodes, - BigDecimal actualGrossWeightKg, BigDecimal actualVolumeCbm, + LocalDateTime expectedArrivalAt, String feeReviewStatus, + String companyName, String businessType, String taxMode, String containerMode, + Boolean customsInspection, String destinationType, String destinationCode, + BigDecimal actualGrossWeightKg, BigDecimal actualNetWeightKg, BigDecimal actualVolumeCbm, + BigDecimal volumeWeightKg, BigDecimal chargeableVolumeCbm, Integer palletCount, BigDecimal chargeableWeightKg, LocalDateTime measureCompletedAt, + LocalDateTime inboundedAt, String expressCompanyName, String createdByName, String loadingStatus, String containerNo, String cabinetNo, String vesselName, String voyageNo, LocalDateTime sailingAt, LocalDateTime portArrivalAt, String expressTrackingNo, LocalDateTime createdAt, LocalDateTime updatedAt) {} diff --git a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java index 4ea88fa45..b56e5c27f 100644 --- a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java +++ b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java @@ -21,6 +21,15 @@ public final class UnifiedOrderResponse { Long inboundId, Integer inboundVersion, String inboundCode, LocalDate expectedShipDate, Integer totalPackageCount, String recommendedPalletType, String locationCodes, BigDecimal actualGrossWeightKg, BigDecimal actualVolumeCbm, + LocalDateTime expectedArrivalAt, String feeReviewStatus, + String companyName, String businessType, String pickupMethod, + String taxMode, String containerMode, Boolean customsInspection, + String destinationType, String destinationCode, + Integer totalPalletCount, BigDecimal chargeableVolumeCbm, + BigDecimal volumetricWeightKg, BigDecimal totalNetWeightKg, + LocalDateTime measurementCompletedAt, LocalDateTime inboundCompletedAt, + String expressCompany, String expressTrackingNo, String createdBy, + List skuNos, Integer totalQuantity, String shipmentId, String referenceId, MeasurementSummary measurement, PreloadSummary preload, LoadingSummary loading, DeliverySummary delivery, TransportSummary transport, String customerRemark, String latestInternalNote, 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 8d98938c1..159dc2180 100644 --- a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java +++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java @@ -9,6 +9,7 @@ import com.baiyue.order.dto.OrderDetailResponse; import com.baiyue.order.dto.UnifiedOrderQuery; import com.baiyue.order.dto.UnifiedOrderResponse; import com.baiyue.order.entity.OrderEntity; +import com.baiyue.order.entity.OrderCargoEntity; import com.baiyue.order.entity.OrderInternalNoteEntity; import com.baiyue.order.entity.OrderStatus; import com.baiyue.order.repository.OrderRepository; @@ -23,6 +24,7 @@ import java.util.Comparator; import java.util.EnumSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; @@ -56,9 +58,12 @@ public class UnifiedOrderQueryService { ? Map.of() : summaries(tenantId, source.records().stream().map(OrderEntity::getId).toList()); Map latestNotes = latestNotes(tenantId, source.records().stream().map(OrderEntity::getId).toList()); + Map cargoSummaries = cargoSummaries(tenantId, + source.records().stream().map(OrderEntity::getId).toList()); Map childCounts = childCounts(source.records()); List rows = source.records().stream() - .map(order -> row(order, inboundByOrder.get(order.getId()), latestNotes.get(order.getId()), childCounts)).toList(); + .map(order -> row(order, inboundByOrder.get(order.getId()), latestNotes.get(order.getId()), + childCounts, cargoSummaries.get(order.getId()))).toList(); return new PageResult<>(rows, source.page(), source.size(), source.total(), source.pages()); } @@ -72,7 +77,7 @@ public class UnifiedOrderQueryService { ? null : firstlegDetail(tenantId, orderId); String latestNote = orderDetail.internalNotes().isEmpty() ? null : orderDetail.internalNotes().get(0).getContent(); UnifiedOrderResponse.Row summary = row(order, inbound == null ? null : inbound.summary(), latestNote, - childCounts(List.of(order))); + childCounts(List.of(order)), CargoSummary.from(orderDetail.cargoes())); List timeline = new ArrayList<>(); orderDetail.statusHistory().forEach(item -> timeline.add(new UnifiedOrderResponse.TimelineEvent( "ORDER", "STATUS", orderMainStatus(item.getToStatus()), item.getAction(), item.getReason(), @@ -106,10 +111,12 @@ public class UnifiedOrderQueryService { Map orderById = orders.findByIds(tenantId, orderIds).stream() .collect(Collectors.toMap(OrderEntity::getId, Function.identity(), (left, right) -> left)); Map latestNotes = latestNotes(tenantId, orderIds); + Map cargoSummaries = cargoSummaries(tenantId, orderIds); Map childCounts = childCounts(List.copyOf(orderById.values())); List records = source.records().stream() .map(summary -> row(summary, orderById.get(summary.inbound().orderId()), - latestNotes.get(summary.inbound().orderId()), childCounts)) + latestNotes.get(summary.inbound().orderId()), childCounts, + cargoSummaries.get(summary.inbound().orderId()))) .toList(); return new PageResult<>(records, source.page(), source.size(), source.total(), source.pages()); } @@ -134,6 +141,13 @@ public class UnifiedOrderQueryService { OrderInternalNoteEntity::getOrderId, OrderInternalNoteEntity::getContent, (latest, ignored) -> latest)); } + private Map cargoSummaries(Long tenantId, List orderIds) { + if (orderIds.isEmpty()) return Map.of(); + return orders.findCargoByOrderIds(tenantId, orderIds).stream() + .collect(Collectors.groupingBy(OrderCargoEntity::getOrderId, + Collectors.collectingAndThen(Collectors.toList(), CargoSummary::from))); + } + private Map childCounts(List pageOrders) { if (pageOrders.isEmpty()) return Map.of(); return orders.findChildrenByParentOrders(pageOrders).stream() @@ -155,7 +169,8 @@ public class UnifiedOrderQueryService { } private UnifiedOrderResponse.Row row(OrderEntity order, FirstlegUnifiedOrderClient.Summary summary, - String latestInternalNote, Map childCounts) { + String latestInternalNote, Map childCounts, + CargoSummary cargo) { if (summary == null) { String mainStatus = orderMainStatus(order.getStatus()); return new UnifiedOrderResponse.Row(order.getId(), order.getTenantId(), order.getOrderNo(), mainStatus, @@ -165,7 +180,12 @@ public class UnifiedOrderQueryService { order.getChannelName(), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(), order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), null, null, order.getInboundCode(), - order.getExpectedShipDate(), null, null, null, null, null, null, null, null, null, null, + order.getExpectedShipDate(), null, null, null, null, + null, null, null, null, null, order.getPickupMethod(), order.getTaxMode(), order.getContainerMode(), + order.getCustomsInspection(), order.getDestinationType(), order.getAmazonWarehouseCode(), + null, null, null, null, null, null, order.getExpressCompany(), order.getExpressTrackingNo(), + string(order.getCreatedBy()), skuNos(cargo), totalQuantity(cargo), order.getShipmentId(), order.getReferenceId(), + null, null, null, null, null, order.getCustomerRemark(), latestInternalNote, order.getCreatedAt(), order.getUpdatedAt()); } @@ -175,15 +195,21 @@ public class UnifiedOrderQueryService { order.getCustomerId(), order.getCustomerName(), order.getChannelName(), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(), order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), inbound.id(), inbound.version(), inbound.inboundCode(), - order.getExpectedShipDate(), inbound.totalPackageCount(), "STANDARD", inbound.locationCodes(), - inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(), measurement(inbound), + order.getExpectedShipDate(), inbound.totalPackageCount(), null, inbound.locationCodes(), + inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(), inbound.expectedArrivalAt(), inbound.feeReviewStatus(), + inbound.companyName(), inbound.businessType(), order.getPickupMethod(), inbound.taxMode(), inbound.containerMode(), + inbound.customsInspection(), inbound.destinationType(), inbound.destinationCode(), inbound.palletCount(), + inbound.chargeableVolumeCbm(), inbound.volumeWeightKg(), inbound.actualNetWeightKg(), + inbound.measureCompletedAt(), inbound.inboundedAt(), inbound.expressCompanyName(), inbound.expressTrackingNo(), + inbound.createdByName(), skuNos(cargo), totalQuantity(cargo), order.getShipmentId(), order.getReferenceId(), measurement(inbound), preload(summary.preload()), loading(summary.loading()), delivery(summary.delivery()), transport(inbound), order.getCustomerRemark(), latestInternalNote, order.getCreatedAt(), order.getUpdatedAt()); } private UnifiedOrderResponse.Row row(FirstlegUnifiedOrderClient.Summary summary, OrderEntity order, - String latestInternalNote, Map childCounts) { + String latestInternalNote, Map childCounts, + CargoSummary cargo) { var inbound = summary.inbound(); return new UnifiedOrderResponse.Row(inbound.orderId(), inbound.tenantId(), inbound.orderNo(), summary.mainStatus(), summary.nextAction(), relation(order, childCounts), @@ -191,8 +217,14 @@ public class UnifiedOrderQueryService { inbound.customerId(), inbound.customerName(), inbound.channelName(), inbound.warehouseId(), inbound.warehouseName(), inbound.deliveryWarehouseId(), inbound.deliveryWarehouseName(), inbound.id(), inbound.version(), inbound.inboundCode(), null, inbound.totalPackageCount(), - "STANDARD", inbound.locationCodes(), inbound.actualGrossWeightKg(), - inbound.actualVolumeCbm(), measurement(inbound), preload(summary.preload()), loading(summary.loading()), + null, inbound.locationCodes(), inbound.actualGrossWeightKg(), + inbound.actualVolumeCbm(), inbound.expectedArrivalAt(), inbound.feeReviewStatus(), inbound.companyName(), + inbound.businessType(), order == null ? null : order.getPickupMethod(), inbound.taxMode(), inbound.containerMode(), + inbound.customsInspection(), inbound.destinationType(), inbound.destinationCode(), inbound.palletCount(), + inbound.chargeableVolumeCbm(), inbound.volumeWeightKg(), inbound.actualNetWeightKg(), + inbound.measureCompletedAt(), inbound.inboundedAt(), inbound.expressCompanyName(), inbound.expressTrackingNo(), + inbound.createdByName(), skuNos(cargo), totalQuantity(cargo), order == null ? null : order.getShipmentId(), + order == null ? null : order.getReferenceId(), measurement(inbound), preload(summary.preload()), loading(summary.loading()), delivery(summary.delivery()), transport(inbound), order == null ? null : order.getCustomerRemark(), latestInternalNote, inbound.createdAt(), inbound.updatedAt()); } @@ -225,6 +257,28 @@ public class UnifiedOrderQueryService { return status == OrderStatus.SUBMITTED ? "PENDING_REVIEW" : ACTIVE_ORDER_STATUSES.contains(status) ? "WAIT_MEASURE" : null; } + + private String string(Long value) { + return value == null ? null : value.toString(); + } + + private List skuNos(CargoSummary cargo) { + return cargo == null || cargo.skuNos().isEmpty() ? null : cargo.skuNos(); + } + + private Integer totalQuantity(CargoSummary cargo) { + return cargo == null ? null : cargo.totalQuantity(); + } + + private record CargoSummary(List skuNos, Integer totalQuantity) { + private static CargoSummary from(List cargoes) { + List skuNos = cargoes.stream().map(OrderCargoEntity::getSkuNo) + .filter(Objects::nonNull).map(String::trim).filter(value -> !value.isEmpty()).distinct().toList(); + int totalQuantity = cargoes.stream().map(OrderCargoEntity::getTotalQuantity) + .filter(Objects::nonNull).mapToInt(Integer::intValue).sum(); + return new CargoSummary(skuNos, totalQuantity); + } + } private String inboundMainStatus(String status) { if (status == null) return null; return switch (status) { diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index 08b282327..1d3728227 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -424,10 +424,33 @@ components: inboundCode: {type: string, nullable: true} expectedShipDate: {type: string, format: date, nullable: true} totalPackageCount: {type: integer, nullable: true} - recommendedPalletType: {type: string, nullable: true} + recommendedPalletType: {type: string, nullable: true, description: 当前无列表级权威事实时返回 null,不得返回固定托盘类型} locationCodes: {type: string, nullable: true} actualGrossWeightKg: {type: number, nullable: true} actualVolumeCbm: {type: number, nullable: true} + expectedArrivalAt: {type: string, format: date-time, nullable: true} + feeReviewStatus: {type: string, nullable: true, description: Firstleg 入仓费用审核快照;财务状态不在本字段表达} + companyName: {type: string, nullable: true} + businessType: {type: string, nullable: true} + pickupMethod: {type: string, nullable: true} + taxMode: {type: string, nullable: true} + containerMode: {type: string, nullable: true} + customsInspection: {type: boolean, nullable: true} + destinationType: {type: string, nullable: true} + destinationCode: {type: string, nullable: true} + totalPalletCount: {type: integer, nullable: true} + chargeableVolumeCbm: {type: number, nullable: true} + volumetricWeightKg: {type: number, nullable: true} + totalNetWeightKg: {type: number, nullable: true, description: Firstleg 确认后的实际净重;未确认时返回 null} + measurementCompletedAt: {type: string, format: date-time, nullable: true} + inboundCompletedAt: {type: string, format: date-time, nullable: true} + expressCompany: {type: string, nullable: true} + expressTrackingNo: {type: string, nullable: true} + createdBy: {type: string, nullable: true} + skuNos: {type: array, nullable: true, items: {type: string}} + totalQuantity: {type: integer, nullable: true} + shipmentId: {type: string, nullable: true} + referenceId: {type: string, nullable: true} measurement: {$ref: '#/components/schemas/UnifiedMeasurementSummary'} preload: {$ref: '#/components/schemas/UnifiedPreloadSummary'} loading: {$ref: '#/components/schemas/UnifiedLoadingSummary'} diff --git a/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java b/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java index 24b688cbe..b9b55dc6f 100644 --- a/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java +++ b/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java @@ -52,7 +52,12 @@ class UnifiedOrderApiContractTest { Map row = stringMap(schemas.get("UnifiedOrderRow")); assertThat(stringMap(row.get("properties"))).containsKeys( "orderId", "mainStatus", "inboundId", "inboundVersion", "measurement", "preload", - "loading", "delivery", "transport", "customerRemark", "latestInternalNote"); + "loading", "delivery", "transport", "customerRemark", "latestInternalNote", + "expectedArrivalAt", "feeReviewStatus", "companyName", "businessType", "pickupMethod", + "taxMode", "containerMode", "customsInspection", "destinationType", "destinationCode", + "totalPalletCount", "chargeableVolumeCbm", "volumetricWeightKg", "totalNetWeightKg", + "measurementCompletedAt", "inboundCompletedAt", "expressCompany", "expressTrackingNo", + "createdBy", "skuNos", "totalQuantity", "shipmentId", "referenceId"); assertThat(responseSchema(paths, "/api/platform/v1/orders", "get")) .containsEntry("$ref", "#/components/schemas/UnifiedOrderPageEnvelope"); assertThat(responseSchema(paths, "/api/merchant/v1/orders/{orderId}", "get")) 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 3f7077e08..9bbf59feb 100644 --- a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java @@ -77,8 +77,8 @@ class UnifiedOrderQueryServiceTest { void pendingReviewInboundCodeFilterStaysInOrderQuery() { UnifiedOrderQuery query = new UnifiedOrderQuery( null, null, "PENDING_REVIEW", null, null, null, "RK-77", null, - null, null, null, null, null, null, null, null, null, null, - null, null, 1, 20); + null, null, null, null, null, null, null, null, null, + null, null, null, 1, 20); when(orders.unifiedPage(eq(10001L), eq(query), eq(List.of(OrderStatus.SUBMITTED)))) .thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); @@ -167,7 +167,7 @@ class UnifiedOrderQueryServiceTest { assertThat(row.loading().loadingVersion()).isEqualTo(3); assertThat(row.loading().pickingVersion()).isEqualTo(3); assertThat(row.inboundCode()).isEqualTo("RK-77"); - assertThat(row.recommendedPalletType()).isEqualTo("STANDARD"); + assertThat(row.recommendedPalletType()).isNull(); assertThat(row.locationCodes()).isEqualTo("A0-01, B2-03"); }); verify(orders, never()).unifiedPage(any(), any(), any()); @@ -192,6 +192,53 @@ class UnifiedOrderQueryServiceTest { assertThat(captor.getValue().mainStatus()).isEqualTo("WAIT_SPLIT"); } + @Test + void listKeepsLoadedFulfillmentAheadOfRawApprovedOrderStatus() { + UnifiedOrderQuery query = query(null); + OrderEntity order = order(OrderStatus.APPROVED); + var summary = summary("LOADED", null); + when(orders.unifiedPage(eq(10001L), eq(query), any())) + .thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1)); + when(firstleg.summaries(eq("internal-token"), any())) + .thenReturn(ApiResponse.success(List.of(summary))); + + var result = service.page(10001L, query); + + assertThat(result.records()).singleElement().satisfies(row -> { + assertThat(row.mainStatus()).isEqualTo("LOADED"); + assertThat(row.internalOrderStatus()).isEqualTo(OrderStatus.APPROVED); + assertThat(row.inboundId()).isEqualTo(88L); + }); + ArgumentCaptor captor = + ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.BatchRequest.class); + verify(firstleg).summaries(eq("internal-token"), captor.capture()); + assertThat(captor.getValue().tenantId()).isEqualTo(10001L); + assertThat(captor.getValue().orderIds()).containsExactly(77L); + } + + @Test + void detailKeepsLoadedFulfillmentAheadOfRawApprovedOrderStatus() { + OrderEntity order = order(OrderStatus.APPROVED); + when(orders.findOwned(10001L, 77L)).thenReturn(order); + when(orders.findCargo(10001L, 77L)).thenReturn(List.of()); + when(orders.findParties(10001L, 77L)).thenReturn(List.of()); + when(orders.findNotes(10001L, 77L)).thenReturn(List.of()); + when(orders.findHistory(10001L, 77L)).thenReturn(List.of()); + when(firstleg.detail(eq("internal-token"), any())).thenReturn(ApiResponse.success( + new FirstlegUnifiedOrderClient.Detail(summary("LOADED", null), List.of()))); + + var result = service.detail(10001L, 77L, false); + + assertThat(result.summary().mainStatus()).isEqualTo("LOADED"); + assertThat(result.summary().internalOrderStatus()).isEqualTo(OrderStatus.APPROVED); + assertThat(result.summary().inboundId()).isEqualTo(88L); + ArgumentCaptor captor = + ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.DetailRequest.class); + verify(firstleg).detail(eq("internal-token"), captor.capture()); + assertThat(captor.getValue().tenantId()).isEqualTo(10001L); + assertThat(captor.getValue().orderId()).isEqualTo(77L); + } + @Test void fulfillmentTabsForwardStatusSearchAndPaginationToFirstleg() { List statuses = List.of( @@ -278,6 +325,7 @@ class UnifiedOrderQueryServiceTest { var inbound = new FirstlegUnifiedOrderClient.Inbound( 88L, 10001L, 77L, "FL-77", "RK-77", "INBOUNDED", "APPROVED", "APPROVED", 7, 30001L, "测试客户", 11L, "深圳仓", 22L, "ONT8", "美森", 10, "A0-01, B2-03", null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, LocalDateTime.of(2026, 8, 6, 10, 0), LocalDateTime.of(2026, 8, 6, 11, 0)); return new FirstlegUnifiedOrderClient.Summary(inbound, status, action, diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index fd91d03ce..84b3f5819 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -12,7 +12,7 @@ import process from 'node:process' const DEFAULT_GATEWAY = 'http://8.163.30.128:9000' const DEFAULT_USERNAME = 'platform_admin' const DEFAULT_PASSWORD = 'ChangeMe123!' -const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-loading-finalize', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics'] +const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-loading-finalize', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics'] export function normalizeModuleOrder(modules) { const normalized = [...modules] @@ -91,6 +91,12 @@ export function normalizeModuleOrder(modules) { normalized.splice(0, normalized.length, ...requested) } } + if (normalized.includes('inbound-fees')) { + const prerequisites = ['merchant-qualification-fixture', 'orders', 'inbound-measurement'] + const targetIndex = normalized.indexOf('inbound-fees') + const missing = prerequisites.filter((name) => !normalized.includes(name)) + if (missing.length > 0) normalized.splice(targetIndex, 0, ...missing) + } const loadingIndex = normalized.indexOf('phase3-loading') const overviewIndex = normalized.indexOf('phase3-container-overview') if (loadingIndex < 0 || overviewIndex < 0 || overviewIndex > loadingIndex) return normalized diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index e5f66fc45..09d92413c 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -46,6 +46,7 @@ export async function run(context) { | 公共主数据与往来单位 | `master-data.mjs` | `master-data-partner-contract.test.mjs` | | 订单创建、可计价主数据与审核异步收敛 | `orders.mjs` | `orders-pricing-fixture-contract.test.mjs`、`orders-approval-convergence-contract.test.mjs` | | 入库测量、首次打印冻结箱码与墨家扫码 | `inbound-measurement.mjs` | `inbound-measurement-box-code-contract.test.mjs` | +| 入仓自动头程费用、派生详情、人工发布与 MANUAL 其他费用版本 | `inbound-fees.mjs`(前置:`merchant-qualification-fixture,orders,inbound-measurement`) | `inbound-fees-contract.test.mjs` | | 三期模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、动态增量、报关与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-customs.mjs`、`phase3-loading-finalize.mjs`、`phase3-outbound-dispatch.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-loading-dynamic-final-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` | | 分柜与父子入库单数量守恒 | `phase3-order-splitting.mjs` | `phase3-order-splitting-contract.test.mjs` | | 订舱共享主数据前置与订舱验收 | `ocean-booking-fixture.mjs`、`ocean-bookings.mjs`、`phase3-port-schedules.mjs`、`phase3-carrier-schedule-queries.mjs` | `ocean-booking-prototype-contract.test.mjs`、`ocean-booking-fixture-contract.test.mjs`、`phase3-carrier-schedule-queries.test.mjs` | diff --git a/scripts/admin-test-env-modules/booking-supplier-scope-contract.test.mjs b/scripts/admin-test-env-modules/booking-supplier-scope-contract.test.mjs index 4327c7d18..e7266b5c6 100644 --- a/scripts/admin-test-env-modules/booking-supplier-scope-contract.test.mjs +++ b/scripts/admin-test-env-modules/booking-supplier-scope-contract.test.mjs @@ -4,6 +4,7 @@ import test from 'node:test' const supplierFixture = readFileSync(new URL('./suppliers.mjs', import.meta.url), 'utf8') const financeFixture = readFileSync(new URL('./finance-full-fixtures.mjs', import.meta.url), 'utf8') +const oceanBookingFixture = readFileSync(new URL('./ocean-bookings.mjs', import.meta.url), 'utf8') const supplierOpenapi = readFileSync(new URL('../../warehouse/src/main/resources/openapi/supplier-v1.yaml', import.meta.url), 'utf8') const firstlegSnapshot = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/service/WarehouseSupplierSnapshotService.java', import.meta.url), 'utf8') const firstlegClient = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseSupplierClient.java', import.meta.url), 'utf8') @@ -34,3 +35,10 @@ test('Firstleg booking supplier snapshot requires ACTIVE BOOKING and has no dome assert.doesNotMatch(firstlegSnapshot, /"INTERNATIONAL"|"DOMESTIC"|String scope/) assert.doesNotMatch(firstlegClient, /String scope|String category,\s*String scope/s) }) + +test('HTTP ocean booking acceptance selects an ACTIVE BOOKING supplier without a geographic scope', () => { + assert.match(oceanBookingFixture, /category=BOOKING&status=ACTIVE/) + assert.match(oceanBookingFixture, /bookingAgentId: bookingAgent\.supplierId/) + assert.match(oceanBookingFixture, /订舱持久化启用订舱供应商快照/) + assert.doesNotMatch(oceanBookingFixture, /category=BOOKING[^'\n]*scope=/) +}) diff --git a/scripts/admin-test-env-modules/inbound-fees-contract.test.mjs b/scripts/admin-test-env-modules/inbound-fees-contract.test.mjs new file mode 100644 index 000000000..21ef4f5a1 --- /dev/null +++ b/scripts/admin-test-env-modules/inbound-fees-contract.test.mjs @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +const entry = new URL('../admin-test-env-acceptance.mjs', import.meta.url) +const moduleFile = new URL('./inbound-fees.mjs', import.meta.url) + +test('admin full acceptance registers inbound fee lifecycle after measurement', async () => { + const source = await readFile(entry, 'utf8') + assert.match(source, /'inbound-measurement', 'inbound-fees', 'phase3-container-loading'/) + assert.match(source, /normalized\.includes\('inbound-fees'\)/) + assert.match(source, /\['merchant-qualification-fixture', 'orders', 'inbound-measurement'\]/) +}) + +test('inbound fee lifecycle covers frozen source, version, publication and WAIT_SPLIT contracts', async () => { + const source = await readFile(moduleFile, 'utf8') + for (const token of [ + '/fees/latest', '/fees/published', '/fees/confirm', '/fees/send', '/fee-details', + 'FBA_PRICING', 'MANUAL', 'CALCULATED', 'CONFIRMED', 'SENT', 'PUBLISHED', + 'sourceFeeVersion', 'reviewStatus', 'publicationStatus', 'settlementIncluded', 'WAIT_SPLIT', + ]) assert.match(source, new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))) + assert.match(source, /人工发布头程费用并触发 Finance Outbox/) + assert.match(source, /对客合计仅含已发布头程与已审核其他应收且其他应付单列/) +}) diff --git a/scripts/admin-test-env-modules/inbound-fees.mjs b/scripts/admin-test-env-modules/inbound-fees.mjs new file mode 100644 index 000000000..c3da0df54 --- /dev/null +++ b/scripts/admin-test-env-modules/inbound-fees.mjs @@ -0,0 +1,190 @@ +const MODULE = 'inbound-fees' +const OPTIONS = { scenario: 'inbound-fee-lifecycle', deploymentBlocker: true } + +function id(value) { return value === null || value === undefined ? '' : String(value) } +function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' } +function money(value) { return Number(value) } +function requestHeaders(key) { return { 'Idempotency-Key': key.slice(0, 128) } } + +function finish(context, start) { + const steps = context.steps.slice(start) + context.scenarios.push({ + name: OPTIONS.scenario, + module: MODULE, + success: steps.every((step) => step.passed), + deploymentBlocked: steps.some((step) => step.deploymentBlocker), + passed: steps.filter((step) => step.passed).length, + failed: steps.filter((step) => !step.passed).length, + }) +} + +async function listDetails(context, tenantId, inboundId, name) { + const response = await context.request('GET', + `/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fee-details?accountId=${encodeURIComponent(tenantId)}`) + const rows = context.apiSuccess(MODULE, name, response, '', OPTIONS) + return { response, rows: Array.isArray(rows) ? rows : [] } +} + +export async function run(context) { + const start = context.steps.length + const fixture = context.createdResources.inboundMeasurement + const tenantId = id(fixture?.tenantId) + const inboundId = id(fixture?.inboundId) + context.assert(MODULE, '复用本轮审核通过且进入 WAIT_SPLIT 的入库单', null, + Boolean(tenantId && inboundId && fixture?.finalStatus === 'APPROVED' && fixture?.processStatus === 'WAIT_SPLIT'), + `tenantId=${tenantId || 'missing'}, inboundId=${inboundId || 'missing'}, measurement=${fixture?.finalStatus || 'missing'}, process=${fixture?.processStatus || 'missing'}`, OPTIONS) + if (!tenantId || !inboundId) { finish(context, start); return } + + const latestPath = `/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fees/latest?tenantId=${encodeURIComponent(tenantId)}` + const latestResponse = await context.request('GET', latestPath) + const initial = context.apiSuccess(MODULE, '审核成功后读取自动初算费用', latestResponse, '', OPTIONS) + context.assert(MODULE, '自动初算保持 WAIT_SPLIT 且尚未发布', latestResponse, + initial?.feeStatus === 'CALCULATED' && initial?.nextProcessStatus === 'WAIT_SPLIT' + && Number(initial?.feeVersion) >= 1 && money(initial?.totalAmount) >= 0, + `feeId=${id(initial?.feeId) || 'missing'}, version=${initial?.feeVersion ?? 'missing'}, status=${initial?.feeStatus || 'missing'}, process=${initial?.nextProcessStatus || 'missing'}`, OPTIONS) + if (!initial?.feeId || initial?.feeStatus !== 'CALCULATED') { finish(context, start); return } + + const initialDetails = await listDetails(context, tenantId, inboundId, '查询自动初算派生费用明细') + const initialDerived = initialDetails.rows.find((row) => row.sourceType === 'FBA_PRICING' + && id(row.sourceFeeId) === id(initial.feeId)) + context.assert(MODULE, 'FBA_PRICING 明细是初算版本的只读草稿派生', initialDetails.response, + initialDerived?.sourceFeeVersion === initial.feeVersion && initialDerived?.reviewStatus === 'PENDING' + && initialDerived?.publicationStatus === 'DRAFT' && initialDerived?.direction === 'RECEIVABLE' + && money(initialDerived?.amount) === money(initial?.totalAmount), + `sourceType=${initialDerived?.sourceType || 'missing'}, sourceVersion=${initialDerived?.sourceFeeVersion ?? 'missing'}, review=${initialDerived?.reviewStatus || 'missing'}, publication=${initialDerived?.publicationStatus || 'missing'}`, OPTIONS) + if (initialDerived?.id) { + const derivedUpdateResponse = await context.request('PUT', + `/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fee-details/${encodeURIComponent(initialDerived.id)}?accountId=${encodeURIComponent(tenantId)}`, + { feeItemId: initialDerived.feeItemId, direction: 'RECEIVABLE', + businessDate: initialDerived.businessDate, unitPrice: initialDerived.unitPrice, + currency: initialDerived.currency, amount: initialDerived.amount, + settlementIncluded: initialDerived.settlementIncluded, remark: `${context.marker}-禁止修改派生` }) + context.assert(MODULE, 'FBA_PRICING 派生详情拒绝通用编辑', derivedUpdateResponse, + derivedUpdateResponse.http === 200 && !success(derivedUpdateResponse) + && String(derivedUpdateResponse.body?.message || '').includes('只读派生'), + `code=${derivedUpdateResponse.body?.code ?? 'missing'}, message=${derivedUpdateResponse.body?.message || 'missing'}`, OPTIONS) + } + + const adjustedQuantity = Math.max(0.0001, money(initial.chargeableQuantity) + 0.01) + const adjustedUnitPrice = Math.max(0, money(initial.unitPrice)) + const adjustedAdditional = money(initial.additionalAmount) + 0.01 + const adjustResponse = await context.request('PUT', + `/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fees/published?tenantId=${encodeURIComponent(tenantId)}`, + { expectedFeeVersion: Number(initial.feeVersion), chargeableQuantity: adjustedQuantity, + unitPrice: adjustedUnitPrice, additionalAmount: adjustedAdditional, + reason: `${context.marker}-自动费用版本调整` }, undefined, + requestHeaders(`${context.marker}-inbound-fee-adjust`)) + const adjusted = context.apiSuccess(MODULE, '调整初算并生成新版本', adjustResponse, '', OPTIONS) + context.assert(MODULE, '调整版本仍为 CALCULATED 且不阻塞 WAIT_SPLIT', adjustResponse, + Number(adjusted?.feeVersion) > Number(initial.feeVersion) && adjusted?.feeStatus === 'CALCULATED' + && adjusted?.nextProcessStatus === 'WAIT_SPLIT' && money(adjusted?.totalAmount) >= adjustedAdditional, + `oldVersion=${initial.feeVersion}, newVersion=${adjusted?.feeVersion ?? 'missing'}, status=${adjusted?.feeStatus || 'missing'}, process=${adjusted?.nextProcessStatus || 'missing'}`, OPTIONS) + if (!adjusted?.feeId) { finish(context, start); return } + + const adjustedDetails = await listDetails(context, tenantId, inboundId, '回查调整后的派生详情版本') + const currentDerived = adjustedDetails.rows.find((row) => row.sourceType === 'FBA_PRICING' + && id(row.sourceFeeId) === id(adjusted.feeId)) + context.assert(MODULE, '派生详情切换到最新费用版本且旧版本不再当前发布', adjustedDetails.response, + currentDerived?.sourceFeeVersion === adjusted.feeVersion && currentDerived?.publicationStatus === 'DRAFT' + && !adjustedDetails.rows.some((row) => row.sourceType === 'FBA_PRICING' + && id(row.sourceFeeId) !== id(adjusted.feeId) && row.publicationStatus === 'PUBLISHED'), + `derivedFeeId=${id(currentDerived?.sourceFeeId) || 'missing'}, derivedVersion=${currentDerived?.sourceFeeVersion ?? 'missing'}`, OPTIONS) + + const confirmResponse = await context.request('POST', + `/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fees/confirm?tenantId=${encodeURIComponent(tenantId)}`, + undefined, undefined, requestHeaders(`${context.marker}-inbound-fee-confirm`)) + const confirmed = context.apiSuccess(MODULE, '人工确认最新头程费用', confirmResponse, '', OPTIONS) + context.assert(MODULE, '人工确认只改变费用状态且保持 WAIT_SPLIT', confirmResponse, + confirmed?.feeStatus === 'CONFIRMED' && confirmed?.feeVersion === adjusted.feeVersion + && confirmed?.nextProcessStatus === 'WAIT_SPLIT', + `version=${confirmed?.feeVersion ?? 'missing'}, status=${confirmed?.feeStatus || 'missing'}, process=${confirmed?.nextProcessStatus || 'missing'}`, OPTIONS) + + const sendResponse = await context.request('POST', + `/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fees/send?tenantId=${encodeURIComponent(tenantId)}`, + undefined, undefined, requestHeaders(`${context.marker}-inbound-fee-send`)) + const sent = context.apiSuccess(MODULE, '人工发布头程费用并触发 Finance Outbox', sendResponse, '', OPTIONS) + const publishedDetails = await listDetails(context, tenantId, inboundId, '发布后回查对客费用派生') + const published = publishedDetails.rows.find((row) => row.sourceType === 'FBA_PRICING' + && id(row.sourceFeeId) === id(sent?.feeId)) + context.assert(MODULE, '仅人工发布后费用为 SENT 且派生明细为 PUBLISHED', sendResponse, + sent?.feeStatus === 'SENT' && sent?.nextProcessStatus === 'WAIT_SPLIT' + && published?.publicationStatus === 'PUBLISHED' && published?.reviewStatus === 'APPROVED' + && published?.settlementIncluded === true, + `status=${sent?.feeStatus || 'missing'}, process=${sent?.nextProcessStatus || 'missing'}, publication=${published?.publicationStatus || 'missing'}`, OPTIONS) + + const feeItemsResponse = await context.request('GET', '/api/platform/v1/fee-items?page=1&size=500&status=ACTIVE') + const feeItemsPage = context.apiSuccess(MODULE, '查询可用于其他应收的费用项目', feeItemsResponse, '', OPTIONS) + const feeItems = Array.isArray(feeItemsPage?.records) ? feeItemsPage.records + : Array.isArray(feeItemsPage) ? feeItemsPage : [] + const manualItem = feeItems.find((item) => item.status === 'ACTIVE' && item.warehouseReceivable === true + && item.feeCode !== 'FBA_HEAD_FREIGHT') + context.assert(MODULE, '存在启用的非头程运费仓储应收项目', feeItemsResponse, + Boolean(manualItem?.id), `feeItemId=${id(manualItem?.id) || 'missing'}, feeCode=${manualItem?.feeCode || 'missing'}`, OPTIONS) + if (!manualItem?.id) { finish(context, start); return } + + const detailsBase = `/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fee-details` + const manualPayload = { feeItemId: manualItem.id, direction: 'RECEIVABLE', + businessDate: new Date().toISOString().slice(0, 10), unitPrice: 12.34, currency: 'CNY', amount: 12.34, + settlementIncluded: true, remark: `${context.marker}-其他应收` } + const manualCreateResponse = await context.request('POST', `${detailsBase}?accountId=${encodeURIComponent(tenantId)}`, manualPayload) + const manual = context.apiSuccess(MODULE, '新增独立 MANUAL 其他应收', manualCreateResponse, '', OPTIONS) + context.assert(MODULE, 'MANUAL 初始版本待审核且不冒充 FBA_PRICING', manualCreateResponse, + manual?.sourceType === 'MANUAL' && manual?.sourceFeeVersion === 1 + && manual?.reviewStatus === 'PENDING' && manual?.publicationStatus === 'DRAFT', + `detailId=${id(manual?.id) || 'missing'}, sourceType=${manual?.sourceType || 'missing'}, sourceVersion=${manual?.sourceFeeVersion ?? 'missing'}`, OPTIONS) + if (!manual?.id) { finish(context, start); return } + + const approveResponse = await context.request('POST', + `${detailsBase}/${encodeURIComponent(manual.id)}/approve?accountId=${encodeURIComponent(tenantId)}`) + context.apiSuccess(MODULE, '审核通过 MANUAL 其他应收', approveResponse, '', OPTIONS) + const afterApproval = await listDetails(context, tenantId, inboundId, '回查已审核 MANUAL 其他应收') + const approved = afterApproval.rows.find((row) => id(row.id) === id(manual.id)) + context.assert(MODULE, '已审核 MANUAL 纳入对客应收但与头程费用独立', afterApproval.response, + approved?.sourceType === 'MANUAL' && approved?.reviewStatus === 'APPROVED' + && approved?.settlementIncluded === true, + `review=${approved?.reviewStatus || 'missing'}, settlementIncluded=${approved?.settlementIncluded ?? 'missing'}`, OPTIONS) + + const adjustedManualPayload = { ...manualPayload, unitPrice: 15.67, amount: 15.67, + remark: `${context.marker}-其他应收版本调整` } + const manualAdjustResponse = await context.request('POST', + `${detailsBase}/${encodeURIComponent(manual.id)}/adjust?accountId=${encodeURIComponent(tenantId)}&expectedSourceVersion=1`, + adjustedManualPayload) + const manualV2 = context.apiSuccess(MODULE, '调整已审核 MANUAL 并生成新版本', manualAdjustResponse, '', OPTIONS) + context.assert(MODULE, 'MANUAL V2 重新进入待审核且不影响头程 SENT', manualAdjustResponse, + id(manualV2?.id) !== id(manual.id) && id(manualV2?.sourceFeeId) === id(manual.id) + && manualV2?.sourceType === 'MANUAL' && manualV2?.sourceFeeVersion === 2 + && manualV2?.reviewStatus === 'PENDING' && manualV2?.publicationStatus === 'DRAFT' + && sent?.feeStatus === 'SENT', + `sourceVersion=${manualV2?.sourceFeeVersion ?? 'missing'}, review=${manualV2?.reviewStatus || 'missing'}, freight=${sent?.feeStatus || 'missing'}`, OPTIONS) + + const currentOnly = await listDetails(context, tenantId, inboundId, '默认列表隐藏 MANUAL 已替换版本') + const historyResponse = await context.request('GET', + `${detailsBase}?accountId=${encodeURIComponent(tenantId)}&includeHistory=true`) + const historyRows = context.apiSuccess(MODULE, '显式查询费用历史版本', historyResponse, '', OPTIONS) + context.assert(MODULE, 'includeHistory 仅扩展 SUPERSEDED 历史且当前版本不重复', historyResponse, + !currentOnly.rows.some((row) => id(row.id) === id(manual.id)) + && Array.isArray(historyRows) && historyRows.some((row) => id(row.id) === id(manual.id) + && row.publicationStatus === 'SUPERSEDED') + && historyRows.some((row) => id(row.id) === id(manualV2.id) && row.sourceFeeVersion === 2), + `currentHasV1=${currentOnly.rows.some((row) => id(row.id) === id(manual.id))}, historyCount=${Array.isArray(historyRows) ? historyRows.length : 'n/a'}`, OPTIONS) + + const approveV2Response = await context.request('POST', + `${detailsBase}/${encodeURIComponent(manualV2.id)}/approve?accountId=${encodeURIComponent(tenantId)}`) + context.apiSuccess(MODULE, '审核通过 MANUAL 新版本', approveV2Response, '', OPTIONS) + const finalDetails = await listDetails(context, tenantId, inboundId, '汇总已发布头程与已审核其他应收') + const receivableTotal = finalDetails.rows.filter((row) => row.direction === 'RECEIVABLE' + && ((row.sourceType === 'FBA_PRICING' && row.publicationStatus === 'PUBLISHED') + || (row.sourceType === 'MANUAL' && row.reviewStatus === 'APPROVED'))) + .reduce((sum, row) => sum + money(row.amount), 0) + const payableTotal = finalDetails.rows.filter((row) => row.direction === 'PAYABLE' + && row.sourceType === 'MANUAL' && row.reviewStatus === 'APPROVED') + .reduce((sum, row) => sum + money(row.amount), 0) + context.assert(MODULE, '对客合计仅含已发布头程与已审核其他应收且其他应付单列', finalDetails.response, + receivableTotal >= money(published?.amount) + money(manualV2?.amount) && payableTotal >= 0, + `receivableTotal=${receivableTotal.toFixed(2)}, freight=${money(published?.amount).toFixed(2)}, manual=${money(manualV2?.amount).toFixed(2)}, payableTotal=${payableTotal.toFixed(2)}`, OPTIONS) + + context.createdResources.inboundFees = { marker: context.marker, tenantId, inboundId, + freightFeeId: id(sent.feeId), freightVersion: sent.feeVersion, manualDetailId: id(manualV2.id), + manualVersion: manualV2.sourceFeeVersion } + finish(context, start) +} diff --git a/scripts/admin-test-env-modules/ocean-bookings.mjs b/scripts/admin-test-env-modules/ocean-bookings.mjs index b39ddbe06..26ff17b26 100644 --- a/scripts/admin-test-env-modules/ocean-bookings.mjs +++ b/scripts/admin-test-env-modules/ocean-bookings.mjs @@ -16,13 +16,17 @@ async function request(context, method, path, body, headers = {}) { } catch (error) { return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } } } function data(r) { return String(r?.body?.code) === '200' ? r.body.data : null } -async function createBooking(context, suffix, key, master) { +async function createBooking(context, suffix, key, master, bookingAgent) { const containers = [ { containerNo: `TST-${suffix}-01`.slice(0, 64), containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000 }, { containerNo: `TST-${suffix}-02`.slice(0, 64), containerType: '20GP', containerKind: 'FCL', capacityCbm: 33, maxWeightKg: 28000 }, ] return request(context, 'POST', BASE, - { expectedVersion: 0, data: oceanBookingDraft(context, suffix, master, { containers }) }, + { expectedVersion: 0, data: oceanBookingDraft(context, suffix, master, { + containers, + bookingAgentId: bookingAgent.supplierId, + bookingAgentName: bookingAgent.name, + }) }, { 'Idempotency-Key': key }) } function step(context, name, r, pass, evidence, options = OPTIONS) { context.addStep(MODULE, name, r, pass, evidence, { ...options, deploymentBlocker: options.deploymentBlocker && !pass }) } @@ -116,6 +120,15 @@ export async function run(context) { } const master = await resolveOceanBookingMaster(context, MODULE) if (!master) return + const bookingAgentResponse = await request(context, 'GET', + '/api/platform/v1/suppliers?tenantId=0&category=BOOKING&status=ACTIVE&page=1&size=20') + const bookingAgentPage = context.apiSuccess(MODULE, '查询启用的订舱供应商', bookingAgentResponse, '', OPTIONS) + const bookingAgent = bookingAgentPage?.records?.find?.((item) => item?.category === 'BOOKING' && item?.status === 'ACTIVE') + context.assert(MODULE, '订舱供应商不区分国内或国外', bookingAgentResponse, + Boolean(bookingAgent?.id && bookingAgent?.name) && !Object.hasOwn(bookingAgent || {}, 'scope'), + `supplierId=${id(bookingAgent?.id) || 'missing'}, scopeExposed=${Object.hasOwn(bookingAgent || {}, 'scope')}`, OPTIONS) + if (!bookingAgent?.id || !bookingAgent?.name || Object.hasOwn(bookingAgent, 'scope')) return + const bookingAgentSnapshot = { supplierId: id(bookingAgent.id), name: bookingAgent.name } const list = await request(context, 'GET', `${BASE}?page=1&size=5`) if (missing(list) || list.http === 0) { step(context, '海运订舱服务契约已部署', list, false, `入口 ${BASE} 不可用:HTTP=${list.http}, code=${codeOf(list)}`) @@ -148,7 +161,7 @@ export async function run(context) { await createConfirmedUnassignedBooking(context, suffix, master) const key = `${context.marker}-ocean-create` - const createResponse = await createBooking(context, suffix, key, master) + const createResponse = await createBooking(context, suffix, key, master, bookingAgentSnapshot) const detail = data(createResponse); const bookingId = id(detail?.oceanBooking?.id) context.apiSuccess(MODULE, '创建隔离海运订舱', createResponse, `key=${key}`, OPTIONS) context.assert(MODULE, '线下已确认订舱 DTO、运输快照和关联字段完整', createResponse, @@ -160,6 +173,10 @@ export async function run(context) { context.assert(MODULE, '指定客户订舱持久化本轮客户范围快照', createResponse, id(detail?.oceanBooking?.customerId) === id(master.customerId) && detail?.oceanBooking?.customerName === master.customerName, `expectedCustomerId=${id(master.customerId)}, actualCustomerId=${id(detail?.oceanBooking?.customerId) || 'missing'}`, OPTIONS) + context.assert(MODULE, '订舱持久化启用订舱供应商快照', createResponse, + id(detail?.oceanBooking?.bookingAgentId) === bookingAgentSnapshot.supplierId + && detail?.oceanBooking?.bookingAgentName === bookingAgentSnapshot.name, + `expected=${bookingAgentSnapshot.supplierId}/${bookingAgentSnapshot.name}, actual=${id(detail?.oceanBooking?.bookingAgentId) || 'missing'}/${detail?.oceanBooking?.bookingAgentName || 'missing'}`, OPTIONS) const expectedContainerNos = [`TST-${suffix}-01`.slice(0, 64), `TST-${suffix}-02`.slice(0, 64)] context.assert(MODULE, '一单两柜逐柜原子生成独立需求分配和 ACTIVE 操作身份', createResponse, detail?.containers?.length === 2 && expectedContainerNos.every((containerNo) => { @@ -172,7 +189,7 @@ export async function run(context) { if (!bookingId) return context.createdResources.oceanBooking = { id: bookingId, customerId: master.customerId, marker: context.marker, containerBookingId: null, loadingPlanId: null, finalStatus: 'CONFIRMED' } - const replay = await createBooking(context, suffix, key, master) + const replay = await createBooking(context, suffix, key, master, bookingAgentSnapshot) context.assert(MODULE, '创建 Idempotency-Key 重放返回同一订舱', replay, String(data(replay)?.oceanBooking?.id) === bookingId, `first=${bookingId}, replay=${id(data(replay)?.oceanBooking?.id)}`, OPTIONS) const existingContainerNo = detail?.containers?.[0]?.containerNo const globalDuplicateDraft = oceanBookingDraft(context, `${suffix}G`, master, { containers: [{ diff --git a/scripts/admin-test-env-modules/phase3-container-loading.mjs b/scripts/admin-test-env-modules/phase3-container-loading.mjs index 5c1d7320a..52e13c308 100644 --- a/scripts/admin-test-env-modules/phase3-container-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-container-loading.mjs @@ -40,6 +40,37 @@ async function request(context, method, path, body, headers = {}) { } } +async function uploadLoadingEvidence(context, tenantId, inboundId, evidenceType) { + const path = `/api/platform/v1/files/loading-evidence/${tenantId}/${inboundId}?evidenceType=${encodeURIComponent(evidenceType)}` + const form = new FormData() + const png = Uint8Array.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,13,73,68,65,84,8,215,99,248,207,192,240,31,0,5,0,1,255,137,153,61,29,0,0,0,0,73,69,78,68,174,66,96,130]) + form.append('file', new Blob([png], { type: 'image/png' }), `${context.marker}-${evidenceType.toLowerCase()}.png`) + try { + const response = await fetch(`${context.options.gateway}${path}`, { + method: 'POST', + headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` }, + body: form, + signal: AbortSignal.timeout(60_000), + }) + const raw = await response.text(); let payload = null + try { payload = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ } + return { method: 'POST', path, http: response.status, body: payload, + raw: payload === null ? raw.slice(0, 500) : null, + requestId: payload?.requestId || response.headers.get('x-request-id'), + traceId: payload?.traceId || response.headers.get('x-trace-id') } + } catch (error) { + return { method: 'POST', path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } + } +} + +async function bindLoadingEvidence(context, inboundId, tenantId, evidenceType, fileId, sealNo) { + return request(context, 'POST', + `/api/platform/v1/inbounds/${inboundId}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`, + { expectedVersion: 0, evidenceType, fileId, fileName: 'client-must-not-override.png', + sealNo: evidenceType === 'SEAL' ? sealNo : null, remark: context.marker }, + { 'Idempotency-Key': `${context.marker}-cab-evidence-${evidenceType}`.slice(0, 128) }) +} + export function selectContainerLoadingFixture(context) { const candidates = [ context.createdResources?.orchestrationFixtures?.phase3Container, @@ -662,6 +693,52 @@ export async function run(context) { && pickReplay?.items?.every?.((item) => Number(item.pickedPackageCount) === Number(item.plannedPackageCount)), `taskStatus=${pickReplay?.taskStatus || 'missing'}`, OPTIONS) + const evidenceTypes = ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL'] + const sealNo = `SEAL-${suffix}`.slice(0, 64) + for (const evidenceType of evidenceTypes) { + const uploadResponse = await uploadLoadingEvidence(context, tenantId, inboundId, evidenceType) + const uploaded = context.apiSuccess(MODULE, `上传柜次 ${evidenceType} 真实证据文件`, uploadResponse, + responseEvidence(uploadResponse), OPTIONS) + const fileId = id(uploaded?.fileId) + context.assert(MODULE, `${evidenceType} 文件类型、归属和二进制元数据完整`, uploadResponse, + Boolean(fileId) && uploaded?.businessType === `LOADING_${evidenceType}` + && uploaded?.contentType === 'image/png' && Number(uploaded?.sizeBytes) > 0, + `fileId=${fileId || 'missing'}, businessType=${uploaded?.businessType || 'missing'}, size=${uploaded?.sizeBytes ?? 'missing'}`, OPTIONS) + if (!fileId) return + + const bindResponse = await bindLoadingEvidence(context, inboundId, tenantId, evidenceType, fileId, sealNo) + const bound = context.apiSuccess(MODULE, `绑定柜次 ${evidenceType} 装柜证据`, bindResponse, + responseEvidence(bindResponse), OPTIONS) + const boundItem = bound?.evidences?.evidences?.find?.((item) => + item.evidenceType === evidenceType && id(item.fileId) === fileId && item.status !== 'VOID') + context.assert(MODULE, `${evidenceType} 有效证据归属稳定且锁码门禁完整`, bindResponse, + bound?.mainStatus === 'LOADING' && Boolean(boundItem?.id) + && boundItem?.remark === context.marker + && (evidenceType !== 'SEAL' || boundItem?.sealNo === sealNo), + `mainStatus=${bound?.mainStatus || 'missing'}, evidenceId=${id(boundItem?.id) || 'missing'}, sealNo=${boundItem?.sealNo || 'n/a'}`, OPTIONS) + if (!boundItem?.id) return + + const downloadResponse = await context.requestBinary('GET', + `/api/platform/v1/files/loading-evidence/${tenantId}/${inboundId}/${fileId}`) + context.assert(MODULE, `下载柜次 ${evidenceType} 证据为非空受控二进制`, downloadResponse, + downloadResponse.http === 200 && downloadResponse.bytes.length > 0 + && downloadResponse.contentType.toLowerCase().includes('image/png'), + `bytes=${downloadResponse.bytes.length}, contentType=${downloadResponse.contentType || 'missing'}`, OPTIONS) + } + + const evidenceListResponse = await context.request('GET', + `/api/platform/v1/inbounds/${inboundId}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`) + const evidenceList = context.apiSuccess(MODULE, 'PDA 完成前回查五类有效装柜证据与锁码', evidenceListResponse, + '', OPTIONS) + const activeEvidence = Array.isArray(evidenceList?.evidences) + ? evidenceList.evidences.filter((item) => item.status !== 'VOID') : [] + context.assert(MODULE, '五类有效照片和 SEAL 锁码齐全后才调用 PDA complete', evidenceListResponse, + evidenceTypes.every((type) => activeEvidence.some((item) => item.evidenceType === type)) + && activeEvidence.some((item) => item.evidenceType === 'SEAL' && item.sealNo === sealNo), + `types=${[...new Set(activeEvidence.map((item) => item.evidenceType))].join(',') || 'missing'}, sealNo=${sealNo}`, OPTIONS) + if (!evidenceTypes.every((type) => activeEvidence.some((item) => item.evidenceType === type)) + || !activeEvidence.some((item) => item.evidenceType === 'SEAL' && item.sealNo === sealNo)) return + const completePath = `${PDA}/tasks/${taskId}/complete` const completePayload = { expectedTaskVersion: Number(picked?.version), diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index ef14abbff..7b4d543e7 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -405,6 +405,18 @@ test('preload and loading versions use immediate operation baselines and exact p test('PDA completion acceptance verifies immutable BATCH and COMPLETE_LOADING audit with operator metadata', () => { const container = source('./phase3-container-loading.mjs') const convergence = source('./phase3-pda-convergence.mjs') + assertOrder(container, [ + '最后一箱拣货后 PDA 任务等待人工确认装柜完成', + '上传柜次 ${evidenceType} 真实证据文件', + '绑定柜次 ${evidenceType} 装柜证据', + 'PDA 完成前回查五类有效装柜证据与锁码', + 'PDA 人工确认装柜完成', + ]) + assert.match(container, /const evidenceTypes = \['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL'\]/) + assert.match(container, /businessType === `LOADING_\$\{evidenceType\}`/) + assert.match(container, /item\.evidenceType === 'SEAL' && item\.sealNo === sealNo/) + assert.match(container, /if \(!evidenceTypes\.every[\s\S]*\) return/) + assert.match(container, /requestBinary\('GET',[\s\S]*files\/loading-evidence/) assert.match(container, /回查完成后的 PDA 操作审计/) assert.match(container, /item\.actionType === 'BATCH'/) assert.match(container, /item\.actionType === 'COMPLETE_LOADING'/)