From b3b8d949575e71ea2269b73584cd023a7bad09da Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 12 Aug 2026 17:34:03 +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 --- .../baiyue/firstleg/dto/ContainerDtos.java | 6 +- .../baiyue/firstleg/dto/LoadingPickDtos.java | 5 + .../baiyue/firstleg/dto/SmartLoadingDtos.java | 6 + .../Phase3FinalUnifiedSqlContractTest.java | 2 +- .../service/LoadingPickServiceTest.java | 9 +- .../service/SmartLoadingServiceTest.java | 3 +- .../finance-demo-fixtures-contract.test.mjs | 8 +- .../finance-demo-fixtures.mjs | 11 +- .../finance-extended-fees-contract.test.mjs | 52 ++++ .../finance-extended-fees.mjs | 244 ++++++++++++++++++ 10 files changed, 333 insertions(+), 13 deletions(-) create mode 100644 scripts/admin-test-env-modules/finance-extended-fees-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/finance-extended-fees.mjs diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java index 9504318ba..39b899631 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java @@ -86,5 +86,9 @@ public final class ContainerDtos { @Size(max = 64) String voyageNo, @NotNull @PositiveOrZero Integer version, @NotBlank @Size(max = 500) String reason - ) {} + ) { + public TransportUpdateRequest(String containerNo, String voyageNo, Integer version) { + this(containerNo, voyageNo, version, "运输信息更新"); + } + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java index b214310d5..f08139693 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java @@ -65,6 +65,11 @@ public final class LoadingPickDtos { @NotBlank @Size(max = 64) String inventorySnapshotToken, @NotEmpty @Size(max = 500) List<@Valid BatchItemRequest> items ) { + @Deprecated + public BatchRequest(Long warehouseId, String containerNo, Integer expectedVersion, + String inventorySnapshotToken, List items) { + this(warehouseId, null, null, containerNo, expectedVersion, inventorySnapshotToken, items); + } } public record BatchItemRequest( diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java index 3d99928c0..9b5474500 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java @@ -9,6 +9,12 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){} BigDecimal unitWeightKg, BigDecimal unitVolumeCbm) { this(accountId,inboundId,inboundDetailId,orderId,orderNo,inboundCode,warehouseId,null,null,null,destinationWarehouseCode,null,customerName,goodsName,availablePackageCount,unitWeightKg,unitVolumeCbm); } + public Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode, + Long warehouseId, Long destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, + String customerName, String goodsName, Integer availablePackageCount, BigDecimal unitWeightKg, BigDecimal unitVolumeCbm) { + this(accountId,inboundId,inboundDetailId,orderId,orderNo,inboundCode,warehouseId,null,null,destinationWarehouseId, + destinationWarehouseCode,destinationWarehouseName,customerName,goodsName,availablePackageCount,unitWeightKg,unitVolumeCbm); + } } public record CreateRequest(@NotNull @Positive Long bookingId, @Size(max=500) String remark) {} public record AssignRequest(@NotNull @Positive Long inboundId,@NotNull @Positive Long inboundDetailId, diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java index d1b2946e2..c9ad2d46e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java @@ -132,7 +132,7 @@ class Phase3FinalUnifiedSqlContractTest { .contains("存在缺失中文注释的业务字段") .doesNotContain("CREATE PROCEDURE upgrade_column_comment"); assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(150); - assertThat(count(sql, "CALL queue_column_comment(")).isEqualTo(2457); + assertThat(count(sql, "CALL queue_column_comment(")).isEqualTo(2459); assertThat(sql.indexOf("CALL apply_queued_comments();", sql.indexOf("-- END GENERATED NINE-SCHEMA COMMENT UPGRADE CALLS"))) .isGreaterThan(sql.indexOf("-- END GENERATED NINE-SCHEMA COMMENT UPGRADE CALLS")); 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 10c0aa093..700d349f6 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -226,7 +226,7 @@ class LoadingPickServiceTest { ApiResponse.success(List.of(new WarehousePalletLocationClient.OccupiedResource( 71L, 72L, 0, 101L, 73L, 74L, "A-01", false, 2)))); String snapshotToken = service.inventorySnapshot(20L, 101L).snapshotToken(); - BatchRequest request = new BatchRequest(101L, "CSLU1234567", 0, snapshotToken, List.of( + BatchRequest request = new BatchRequest(101L, 10L, 99L, "CSLU1234567", 0, snapshotToken, List.of( new BatchItemRequest(30L, "PICK", 2, null, "MANUAL", List.of(), 0, null), new BatchItemRequest(31L, "SKIP", null, null, null, null, 0, "damaged carton"))); @@ -256,7 +256,7 @@ class LoadingPickServiceTest { when(repository.assignment(20L)).thenReturn(assignment()); when(repository.items(20L)).thenReturn(items); when(repository.items(20L, 101L)).thenReturn(items); - BatchRequest request = new BatchRequest(101L, "CSLU1234567", 0, "stale-token", List.of( + BatchRequest request = new BatchRequest(101L, 10L, 99L, "CSLU1234567", 0, "stale-token", List.of( new BatchItemRequest(30L, "PICK", 2, null, "MANUAL", List.of(), 0, null), new BatchItemRequest(31L, "PARTIAL", null, 4, "AUTO", null, 0, null))); @@ -280,7 +280,7 @@ class LoadingPickServiceTest { when(warehouse.occupiedResources(any(), any(Long.class), any(), any(Long.class))).thenReturn( ApiResponse.success(List.of(new WarehousePalletLocationClient.OccupiedResource( 71L, 72L, 0, 101L, 73L, 74L, "A-01", false, 2)))); - BatchRequest request = new BatchRequest(101L, "CSLU1234567", 0, "0".repeat(64), List.of( + BatchRequest request = new BatchRequest(101L, 10L, 99L, "CSLU1234567", 0, "0".repeat(64), List.of( new BatchItemRequest(30L, "PICK", 2, null, "MANUAL", List.of(), 0, null))); assertThatThrownBy(() -> service.batch(20L, "batch-inventory-stale", request, 9L, "operator")) @@ -298,7 +298,7 @@ class LoadingPickServiceTest { LoadingPickTaskEntity task = task(); task.setVersion(3); when(repository.taskForUpdate(20L)).thenReturn(task); - BatchRequest request = new BatchRequest(101L, "CSLU1234567", 2, "stale-token", List.of( + BatchRequest request = new BatchRequest(101L, 10L, 99L, "CSLU1234567", 2, "stale-token", List.of( new BatchItemRequest(30L, "PICK", 2, null, "AUTO", null, 0, null))); assertThatThrownBy(() -> service.batch(20L, "batch-stale", request, 9L, "operator")) @@ -334,6 +334,7 @@ class LoadingPickServiceTest { LoadingPickTaskEntity task = new LoadingPickTaskEntity(); task.setId(20L); task.setLoadingPlanId(10L); + task.setContainerBookingId(99L); task.setContainerNo("CSLU1234567"); task.setTaskStatus("LOADING"); task.setVersion(0); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index 8b57c257d..ea790c269 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -455,13 +455,14 @@ class SmartLoadingServiceTest { } private static Candidate candidate(int boxes) { - return new Candidate(9L, 10L, 20L, 30L, "O", "I", 100L, 200L, + return new Candidate(9L, 10L, 20L, 30L, "O", "I", 100L, "FBA", "FBA渠道", 200L, "FBA1", "FBA仓", "客户", "货物", boxes, new BigDecimal("2"), new BigDecimal("0.5")); } private static ContainerBookingEntity booking() { ContainerBookingEntity value = new ContainerBookingEntity(); value.setId(99L); value.setWarehouseId(100L); value.setDestinationWarehouseCode("FBA1"); + value.setChannelCode("FBA"); value.setContainerNo("MSCU1234567"); value.setCapacityCbm(new BigDecimal("60")); value.setMaxWeightKg(new BigDecimal("20000")); return value; diff --git a/scripts/admin-test-env-modules/finance-demo-fixtures-contract.test.mjs b/scripts/admin-test-env-modules/finance-demo-fixtures-contract.test.mjs index a736d5247..b32c0e1d4 100644 --- a/scripts/admin-test-env-modules/finance-demo-fixtures-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-demo-fixtures-contract.test.mjs @@ -37,7 +37,7 @@ test('所有财务写操作使用 marker 幂等键并上传真实凭证', () => assert.match(source, /finance-full-fixtures\.mjs/) assert.match(source, /context\.createdResources\.financeDemo = matrix/) assert.match(source, /`source-\$\{role\}-\$\{source\.feeSourceId\}-include`/) - assert.match(source, /`ap-\$\{role\}-\$\{source\.feeSourceId\}-create`/) + assert.match(source, /`ap-\$\{role\}-\$\{source\.feeSourceId\}-\$\{source\.supplierId\}-create`/) }) test('额外应付审核路径先追加 feeId 再追加 accountId 查询参数', () => { @@ -49,13 +49,17 @@ test('费用来源使用 operationVersion,应付供应商长整型保持字符 assert.match(source, /expectedVersion: operationVersion\(current\.value\)/) assert.match(source, /supplierId: id\(source\.supplierId\)/) assert.doesNotMatch(source, /supplierId: Number\(source\.supplierId\)/) + assert.match(source, /partnerId, partnerName:/) + assert.doesNotMatch(source, /partnerId: Number\(partnerId\)/) }) test('恢复模式只续补固定报告来源并校验 AR AP 金额状态', () => { assert.match(source, /process\.env\.FINANCE_DEMO_RECOVERY_REPORT/) assert.match(source, /恢复报告不是 FIN-DEMO-20260812/) assert.match(source, /拒绝借历史数据/) - assert.match(source, /supplierId: id\(fixture\.supplier\?\.supplierId\)/) + assert.match(source, /const recoverySupplierId = role === 'apPartial'/) + assert.match(source, /matrix\.payableSettlements\?\.apPaid\?\.supplierId/) + assert.match(source, /pageSize=200/) assert.match(source, /const payableIds = \['307', '310'\]/) assert.match(source, /finance-container-partner-not-deployed/) assert.match(source, /findPayableBySource/) diff --git a/scripts/admin-test-env-modules/finance-demo-fixtures.mjs b/scripts/admin-test-env-modules/finance-demo-fixtures.mjs index c2fc5b5c0..7db173aeb 100644 --- a/scripts/admin-test-env-modules/finance-demo-fixtures.mjs +++ b/scripts/admin-test-env-modules/finance-demo-fixtures.mjs @@ -79,7 +79,7 @@ async function createExtraPayable(context, fixture, index) { if (!feeItem || !inboundId || !partnerId) return null const body = { feeItemId: Number(feeItem.id), direction: 'PAYABLE', businessDate: new Date().toISOString().slice(0, 10), unitPrice: 90 + index * 10, amount: 90 + index * 10, currency: 'CNY', settlementIncluded: true, - partnerId: Number(partnerId), partnerName: fixture.supplier?.supplierName || null, remark: `${MARKER}-AP-${index}` } + partnerId, partnerName: fixture.supplier?.supplierName || null, remark: `${MARKER}-AP-${index}` } const basePath = `/api/platform/v1/inbounds/${inboundId}/fee-details` const createdResponse = await write(context, 'POST', `${basePath}?accountId=10001`, body, `extra-payable-${index}`) const created = data(createdResponse) @@ -133,7 +133,7 @@ async function createAp(context, fixture, source, role) { const response = await write(context, 'POST', `${BASE}/payable-settlements`, { customerId: 10001, supplierId: id(source.supplierId), feeSourceIds: [Number(source.feeSourceId)], dueDate: new Date().toISOString().slice(0, 10), currency: 'CNY', - }, `ap-${role}-${source.feeSourceId}-create`) + }, `ap-${role}-${source.feeSourceId}-${source.supplierId}-create`) const value = data(response) record(context, `${role} 创建应付结算单`, response, ok(response) && settlementId(value), `settlementId=${settlementId(value)}, status=${value?.paymentStatus}`) @@ -141,7 +141,7 @@ async function createAp(context, fixture, source, role) { } async function findPayableBySource(context, feeSourceId) { - const listResponse = await context.request('GET', `${BASE}/payable-settlements?customerId=10001`) + const listResponse = await context.request('GET', `${BASE}/payable-settlements?customerId=10001&page=1&pageSize=200`) for (const item of rows(data(listResponse))) { const detailResponse = await context.request('GET', `${BASE}/settlements/${settlementId(item)}`) const value = data(detailResponse) @@ -205,8 +205,11 @@ async function recoverExistingDemo(context, reportPath) { } const approved = await approveAndInclude(context, { feeSourceId: payableIds[index] }, role) const existing = approved?.statementGenerated === true ? await findPayableBySource(context, payableIds[index]) : null + const recoverySupplierId = role === 'apPartial' + ? id(matrix.payableSettlements?.apPaid?.supplierId) + : id(fixture.supplier?.supplierId) const settlement = existing?.value ?? await createAp(context, fixture, { - feeSourceId: payableIds[index], supplierId: id(fixture.supplier?.supplierId), + feeSourceId: payableIds[index], supplierId: recoverySupplierId, }, role) if (existing?.value) record(context, `${role} 回读已有应付结算单`, existing.response, true, `settlementId=${settlementId(existing.value)}, feeSourceId=${payableIds[index]}`) diff --git a/scripts/admin-test-env-modules/finance-extended-fees-contract.test.mjs b/scripts/admin-test-env-modules/finance-extended-fees-contract.test.mjs new file mode 100644 index 000000000..1a18d9957 --- /dev/null +++ b/scripts/admin-test-env-modules/finance-extended-fees-contract.test.mjs @@ -0,0 +1,52 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const source = fs.readFileSync(path.join(here, 'finance-extended-fees.mjs'), 'utf8') + +test('extended fee module is fixed-marker isolated and exports the acceptance entry', () => { + assert.match(source, /FIXED_MARKER = 'FIN-EXT-FEE-20260812'/) + assert.match(source, /export async function run\(context\)/) + assert.match(source, /baseFixture\.marker !== FIXED_MARKER/) + assert.match(source, /createdResources\.financeExtendedFees/) +}) + +test('extended fee module covers six orders and both automatic billing bases', () => { + assert.match(source, /创建第六张独立订单与入库单/) + assert.match(source, /resources\.length >= 6/) + assert.match(source, /'WEIGHT_KG', 'VOLUME_CBM'/) + assert.match(source, /length >= 2/) +}) + +test('manual status matrix covers receivable, payable, rejection, approval and void', () => { + for (const name of ['AR-PENDING', 'AR-REJECTED', 'AR-APPROVED', 'AP-PENDING', 'AP-APPROVED', 'AR-VOID']) { + assert.match(source, new RegExp(name)) + } + assert.match(source, /expectedFinance: 'AVAILABLE'/) + assert.match(source, /expectedFinance: 'ABSENT'/) + assert.match(source, /作废来源不可选择出账/) +}) + +test('every source is traced through order, inbound, fee, and Finance pool reads', () => { + assert.match(source, /readOrderInboundAndFee/) + assert.match(source, /financeState/) + assert.match(source, /回读订单/) + assert.match(source, /回读入库单/) + assert.match(source, /回读费用/) + assert.match(source, /回读 Finance 池/) +}) + +test('writes carry marker idempotency and include replay and stale-version conflict', () => { + assert.match(source, /'Idempotency-Key': key\(action\)/) + assert.match(source, /同键同参人工费用创建幂等重放/) + assert.match(source, /expectedSourceVersion=999/) + assert.match(source, /错误 sourceVersion 调整产生并发版本冲突/) +}) + +test('uses existing ACTIVE partner and never creates one', () => { + assert.match(source, /\/api\/platform\/v1\/partners\?page=1&size=200&status=ACTIVE/) + assert.doesNotMatch(source, /request\(context, 'POST', '\/api\/platform\/v1\/partners'/) +}) diff --git a/scripts/admin-test-env-modules/finance-extended-fees.mjs b/scripts/admin-test-env-modules/finance-extended-fees.mjs new file mode 100644 index 000000000..43f586dbf --- /dev/null +++ b/scripts/admin-test-env-modules/finance-extended-fees.mjs @@ -0,0 +1,244 @@ +const MODULE = 'finance-extended-fees' +const OPTIONS = { scenario: 'finance-extended-real-fee-statuses', deploymentBlocker: true } +const FIXED_MARKER = 'FIN-EXT-FEE-20260812' +const BASE = '/api/platform/finance/v1' + +function id(value) { return value == null ? '' : String(value) } +function rows(value) { return Array.isArray(value) ? value : Array.isArray(value?.records) ? value.records : [] } +function dateOffset(days) { const value = new Date(); value.setUTCDate(value.getUTCDate() + days); return value.toISOString().slice(0, 10) } +function key(action) { return `${FIXED_MARKER}-${action}`.slice(0, 128) } +function request(context, method, path, body, action) { + return context.request(method, path, body, context.token, { 'Idempotency-Key': key(action) }) +} +function blocker(context, name, evidence, response = null) { + context.addStep(MODULE, name, response, false, evidence, OPTIONS) +} + +async function createSixthOrder(context) { + const originalMarker = context.marker + const original = context.createdResources.orders + context.marker = `${FIXED_MARKER}-STATUS6` + try { + const orders = await import(new URL('./orders.mjs', import.meta.url)) + await orders.run(context) + const resource = context.createdResources.orders + return resource?.marker === context.marker && resource?.orderId && resource?.inboundId ? { ...resource } : null + } finally { + context.marker = originalMarker + if (original) context.createdResources.orders = original + else delete context.createdResources.orders + } +} + +async function loadFeeItem(context, direction) { + const response = await context.request('GET', '/api/platform/v1/fee-items?page=1&pageSize=200') + const data = context.apiSuccess(MODULE, `读取 ${direction} ACTIVE 费用项目`, response, '', OPTIONS) + return rows(data).find((item) => item?.status === 'ACTIVE' && (direction === 'RECEIVABLE' + ? item.warehouseReceivable === true : item.warehousePayable === true)) || null +} + +async function loadActivePartner(context) { + const response = await context.request('GET', '/api/platform/v1/partners?page=1&size=200&status=ACTIVE') + const data = context.apiSuccess(MODULE, '读取现有 ACTIVE 往来单位', response, '', OPTIONS) + return rows(data).find((item) => item?.status === 'ACTIVE') || null +} + +async function createFee(context, resource, item, spec, partner) { + const base = `/api/platform/v1/inbounds/${encodeURIComponent(resource.inboundId)}/fee-details` + const path = `${base}?accountId=${encodeURIComponent(resource.tenantId)}` + const body = { + feeItemId: Number(item.id), direction: spec.direction, businessDate: spec.businessDate, + unitPrice: spec.amount, currency: 'CNY', amount: spec.amount, settlementIncluded: true, + partnerId: spec.direction === 'PAYABLE' ? String(partner.id) : null, + partnerName: spec.direction === 'PAYABLE' ? (partner.partnerName || partner.name || null) : null, + remark: `${FIXED_MARKER}-${spec.name}`, + } + const response = await request(context, 'POST', path, body, `create-${spec.name}`) + const detail = context.apiSuccess(MODULE, `创建${spec.label}`, response, + `orderId=${resource.orderId}, inboundId=${resource.inboundId}`, OPTIONS) + const feeId = id(detail?.id) + context.assert(MODULE, `${spec.label}初始状态和归属`, response, + Boolean(feeId) && id(detail?.inboundId) === id(resource.inboundId) + && detail?.reviewStatus === 'PENDING' && Number(detail?.amount) === spec.amount, + `feeId=${feeId || 'missing'}, review=${detail?.reviewStatus || 'missing'}, amount=${detail?.amount ?? 'missing'}`, OPTIONS) + return feeId ? { ...spec, feeId, base, resource, createResponse: response } : null +} + +async function readOrderInboundAndFee(context, fixture) { + const orderResponse = await context.request('GET', + `/api/platform/v1/orders/first-leg/${encodeURIComponent(fixture.resource.orderId)}?tenantId=${encodeURIComponent(fixture.resource.tenantId)}`) + const order = context.apiSuccess(MODULE, `${fixture.label}回读订单`, orderResponse, + `orderId=${fixture.resource.orderId}`, OPTIONS) + const inboundResponse = await context.request('GET', + `/api/platform/inbounds/${encodeURIComponent(fixture.resource.inboundId)}?tenantId=${encodeURIComponent(fixture.resource.tenantId)}`) + const inbound = context.apiSuccess(MODULE, `${fixture.label}回读入库单`, inboundResponse, + `inboundId=${fixture.resource.inboundId}`, OPTIONS) + const feeResponse = await context.request('GET', + `${fixture.base}?accountId=${encodeURIComponent(fixture.resource.tenantId)}`) + const feeRows = context.apiSuccess(MODULE, `${fixture.label}回读费用`, feeResponse, '', OPTIONS) + const fee = rows(feeRows).find((item) => id(item.id) === fixture.feeId) + context.assert(MODULE, `${fixture.label}订单、入库和费用关联一致`, feeResponse, + Boolean(order) && Boolean(inbound) && id(fee?.inboundId) === id(fixture.resource.inboundId), + `order=${Boolean(order)}, inbound=${Boolean(inbound)}, fee=${Boolean(fee)}`, OPTIONS) + return { order, inbound, fee, responses: { orderResponse, inboundResponse, feeResponse } } +} + +async function financeState(context, customerId, fixture) { + const response = await context.request('GET', + `${BASE}/fee-pool?customerId=${encodeURIComponent(customerId)}&direction=${fixture.direction}`) + const data = context.apiSuccess(MODULE, `${fixture.label}回读 Finance 池`, response, '', OPTIONS) + const source = rows(data).find((item) => id(item.feeSourceId || item.id) === fixture.feeId) + const shouldExist = fixture.expectedFinance === 'AVAILABLE' + context.assert(MODULE, `${fixture.label} Finance 池状态`, response, + shouldExist ? source?.sourceStatus === 'AVAILABLE' : !source, + `expected=${fixture.expectedFinance}, actual=${source?.sourceStatus || 'ABSENT'}, feeSourceId=${fixture.feeId}`, OPTIONS) + return { response, source: source || null } +} + +export async function run(context) { + const start = context.steps.length + const originalMarker = context.marker + context.marker = FIXED_MARKER + try { + const full = await import(new URL('./finance-full-fixtures.mjs', import.meta.url)) + await full.run(context) + const baseFixture = context.createdResources.financeFullFixtures + if (!baseFixture || baseFixture.marker !== FIXED_MARKER) { + blocker(context, '生成基础重量/体积/人工费用 fixture', + `financeFullFixtures.marker=${baseFixture?.marker || 'missing'};禁止消费其他 marker`) + return + } + const automatic = baseFixture.feeSources.filter((item) => item.sourceType === 'FBA_PRICING') + for (const basis of ['WEIGHT_KG', 'VOLUME_CBM']) { + context.assert(MODULE, `${basis} 至少两条真实自动费用`, null, + automatic.filter((item) => item.billingBasis === basis).length >= 2, + `count=${automatic.filter((item) => item.billingBasis === basis).length}`, OPTIONS) + } + + const sixth = await createSixthOrder(context) + context.assert(MODULE, '创建第六张独立订单与入库单', null, Boolean(sixth), + sixth ? `orderId=${sixth.orderId}, inboundId=${sixth.inboundId}` : 'missing', OPTIONS) + const resources = [...baseFixture.orders.map((item) => ({ ...item, tenantId: baseFixture.customerId })), sixth].filter(Boolean) + context.assert(MODULE, '本轮至少六张订单/入库单且无重复', null, + resources.length >= 6 && new Set(resources.map((item) => id(item.orderId))).size >= 6 + && new Set(resources.map((item) => id(item.inboundId))).size >= 6, + `orders=${resources.length}, uniqueOrders=${new Set(resources.map((item) => id(item.orderId))).size}, uniqueInbounds=${new Set(resources.map((item) => id(item.inboundId))).size}`, OPTIONS) + + const receivableItem = await loadFeeItem(context, 'RECEIVABLE') + const payableItem = await loadFeeItem(context, 'PAYABLE') + const partner = await loadActivePartner(context) + if (!receivableItem || !payableItem || !partner?.id) { + blocker(context, '费用状态矩阵前置主数据完整', + `receivableFeeItem=${id(receivableItem?.id) || 'missing'}, payableFeeItem=${id(payableItem?.id) || 'missing'}, activePartner=${id(partner?.id) || 'missing'}`) + return + } + + const specs = [ + { name: 'AR-PENDING', label: '手工应收待审核', direction: 'RECEIVABLE', amount: 111, businessDate: dateOffset(-5), action: null, expectedReview: 'PENDING', expectedFinance: 'ABSENT' }, + { name: 'AR-REJECTED', label: '手工应收驳回', direction: 'RECEIVABLE', amount: 112, businessDate: dateOffset(-4), action: 'reject', expectedReview: 'REJECTED', expectedFinance: 'ABSENT' }, + { name: 'AR-APPROVED', label: '手工应收已审核待出账', direction: 'RECEIVABLE', amount: 113, businessDate: dateOffset(-3), action: 'approve', expectedReview: 'APPROVED', expectedFinance: 'AVAILABLE' }, + { name: 'AP-PENDING', label: '手工应付待审核', direction: 'PAYABLE', amount: 81, businessDate: dateOffset(-2), action: null, expectedReview: 'PENDING', expectedFinance: 'ABSENT' }, + { name: 'AP-APPROVED', label: '手工应付已审核', direction: 'PAYABLE', amount: 82, businessDate: dateOffset(-1), action: 'approve', expectedReview: 'APPROVED', expectedFinance: 'AVAILABLE' }, + { name: 'AR-VOID', label: '手工应收作废', direction: 'RECEIVABLE', amount: 114, businessDate: dateOffset(-6), action: 'approve-void', expectedReview: 'APPROVED', expectedFinance: 'ABSENT' }, + ] + const fixtures = [] + for (const [index, spec] of specs.entries()) { + const resource = resources[index % resources.length] + const fixture = await createFee(context, resource, + spec.direction === 'RECEIVABLE' ? receivableItem : payableItem, spec, partner) + if (!fixture) continue + if (spec.action === 'reject') { + const response = await request(context, 'POST', `${fixture.base}/${fixture.feeId}/reject?accountId=${encodeURIComponent(resource.tenantId)}`, + { reason: FIXED_MARKER }, `reject-${spec.name}`) + context.apiSuccess(MODULE, `${spec.label}执行驳回`, response, '', OPTIONS) + } else if (spec.action?.startsWith('approve')) { + const response = await request(context, 'POST', `${fixture.base}/${fixture.feeId}/approve?accountId=${encodeURIComponent(resource.tenantId)}`, + undefined, `approve-${spec.name}`) + context.apiSuccess(MODULE, `${spec.label}执行审核`, response, '', OPTIONS) + if (spec.action === 'approve-void') { + const voidResponse = await request(context, 'POST', `${fixture.base}/${fixture.feeId}/void?accountId=${encodeURIComponent(resource.tenantId)}`, + { expectedSourceVersion: 1 }, `void-${spec.name}`) + context.apiSuccess(MODULE, `${spec.label}执行作废`, voidResponse, '', OPTIONS) + } + } + fixtures.push(fixture) + } + + const conflictTarget = fixtures.find((item) => item.name === 'AR-APPROVED') + if (conflictTarget) { + const adjustBody = { feeItemId: Number(receivableItem.id), direction: 'RECEIVABLE', businessDate: conflictTarget.businessDate, + unitPrice: 115, currency: 'CNY', amount: 115, settlementIncluded: true, + partnerId: null, partnerName: null, remark: `${FIXED_MARKER}-CONFLICT` } + const conflict = await request(context, 'POST', + `${conflictTarget.base}/${conflictTarget.feeId}/adjust?accountId=${encodeURIComponent(conflictTarget.resource.tenantId)}&expectedSourceVersion=999`, + adjustBody, 'stale-adjust') + context.assert(MODULE, '错误 sourceVersion 调整产生并发版本冲突', conflict, + conflict?.body?.code !== '200', + `code=${conflict?.body?.code || 'missing'}, requestId=${conflict?.requestId || 'missing'}, traceId=${conflict?.traceId || 'missing'}`, OPTIONS) + } + + const replayTarget = fixtures.find((item) => item.name === 'AR-PENDING') + if (replayTarget) { + const replayPath = `${replayTarget.base}?accountId=${encodeURIComponent(replayTarget.resource.tenantId)}` + const replayBody = { feeItemId: Number(receivableItem.id), direction: 'RECEIVABLE', businessDate: replayTarget.businessDate, + unitPrice: 116, currency: 'CNY', amount: 116, settlementIncluded: true, + partnerId: null, partnerName: null, remark: `${FIXED_MARKER}-IDEMPOTENT` } + const first = await request(context, 'POST', replayPath, replayBody, 'idempotent-create') + const second = await request(context, 'POST', replayPath, replayBody, 'idempotent-create') + const firstData = first?.body?.data; const secondData = second?.body?.data + context.assert(MODULE, '同键同参人工费用创建幂等重放', second, + first?.body?.code === '200' && second?.body?.code === '200' && id(firstData?.id) === id(secondData?.id), + `first=${id(firstData?.id) || 'missing'}, replay=${id(secondData?.id) || 'missing'}, requestId=${second?.requestId || 'missing'}`, OPTIONS) + } + + const statusMatrix = [] + for (const fixture of fixtures) { + const read = await readOrderInboundAndFee(context, fixture) + context.assert(MODULE, `${fixture.label}最终审核状态`, read.responses.feeResponse, + read.fee?.reviewStatus === fixture.expectedReview, + `expected=${fixture.expectedReview}, actual=${read.fee?.reviewStatus || 'missing'}, publication=${read.fee?.publicationStatus || 'missing'}`, OPTIONS) + const finance = await financeState(context, baseFixture.customerId, fixture) + statusMatrix.push({ name: fixture.name, label: fixture.label, orderId: id(fixture.resource.orderId), + orderNo: fixture.resource.orderNo || null, inboundId: id(fixture.resource.inboundId), feeId: fixture.feeId, + direction: fixture.direction, businessDate: fixture.businessDate, amount: fixture.amount, + reviewStatus: read.fee?.reviewStatus || null, publicationStatus: read.fee?.publicationStatus || null, + financeState: finance.source?.sourceStatus || 'ABSENT', requestId: finance.response?.requestId || null, + traceId: finance.response?.traceId || null }) + } + const voided = fixtures.find((item) => item.name === 'AR-VOID') + if (voided) { + const create = await request(context, 'POST', `${BASE}/statements`, { + customerId: String(baseFixture.customerId), currency: 'CNY', generationMode: 'SELECTED', + feeSourceIds: [voided.feeId], periodStart: voided.businessDate, periodEnd: voided.businessDate, + remark: `${FIXED_MARKER}-VOID-STATEMENT`, + }, 'voided-statement') + context.assert(MODULE, '作废来源不可选择出账', create, create?.body?.code !== '200', + `code=${create?.body?.code || 'missing'}, requestId=${create?.requestId || 'missing'}, traceId=${create?.traceId || 'missing'}`, OPTIONS) + } + + const orderFeeCounts = new Map() + for (const item of [...baseFixture.feeSources, ...fixtures]) { + if (!item.orderId) continue + orderFeeCounts.set(id(item.orderId), (orderFeeCounts.get(id(item.orderId)) || 0) + 1) + } + context.assert(MODULE, '至少一张订单包含多个费用项目', null, + [...orderFeeCounts.values()].some((count) => count >= 2), + `maxFeeCount=${Math.max(0, ...orderFeeCounts.values())}`, OPTIONS) + context.assert(MODULE, '状态矩阵覆盖多个业务日期', null, + new Set(statusMatrix.map((item) => item.businessDate)).size >= 4, + `dates=${[...new Set(statusMatrix.map((item) => item.businessDate))].join(',')}`, OPTIONS) + + context.createdResources.financeExtendedFees = { + marker: FIXED_MARKER, customerId: id(baseFixture.customerId), + orders: resources.map((item) => ({ orderId: id(item.orderId), orderNo: item.orderNo || null, inboundId: id(item.inboundId) })), + automaticFees: automatic, statusMatrix, + baseDeploymentBlockers: baseFixture.deploymentBlocker || [], + } + } finally { + context.marker = originalMarker + 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 }) + } +}