diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index ddccfc01f..a219532f8 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -13979,9 +13979,6 @@ UPDATE fl_loading_plan plan AND pick_item.picked_package_count > 0 ); --- 二、三期对外统一使用 INBOUNDED;历史 WAIT_SPLIT 仅由查询适配为 INBOUNDED,统一迁移不改写主表或历史状态。 -SELECT 'PASS' AS migration_status, - '统一增量全部执行完成;父子订单、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、公共资源及可靠审计结构校验通过' AS message; /* Phase 3 channel-owned containers and stable PDA identity. Source: firstleg phase3-channel-container-migration.sql */ DROP PROCEDURE IF EXISTS migrate_phase3_channel_containers; DELIMITER $$ @@ -14029,3 +14026,7 @@ CREATE TABLE IF NOT EXISTS baiyue_firstleg.fl_container_transport_history ( before_voyage_no VARCHAR(64) NULL,after_voyage_no VARCHAR(64) NULL,reason VARCHAR(500) NULL,operator_id BIGINT UNSIGNED NOT NULL, operator_name VARCHAR(64) NULL,created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),PRIMARY KEY(id),KEY idx_container_transport_history(container_booking_id,created_at,id) ) ENGINE=InnoDB COMMENT='柜次真实运输字段不可变变更审计'; + +-- 二、三期对外统一使用 INBOUNDED;历史 WAIT_SPLIT 仅由查询适配为 INBOUNDED,统一迁移不改写主表或历史状态。 +SELECT 'PASS' AS migration_status, + '统一增量全部执行完成;父子订单、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、公共资源及可靠审计结构校验通过' AS message; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java index fdb07ec0c..056b9a958 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java @@ -45,7 +45,7 @@ public class PlatformLoadingPdaController { @GetMapping("/tasks") @PreAuthorize("hasAuthority('platform:firstleg:pda:view')") public ApiResponse task(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam String containerNo, + @RequestParam(required = false) String containerNo, @RequestParam @Positive long warehouseId) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.task(containerNo, warehouseId)); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java index ea1020076..d6c02bf72 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java @@ -9,7 +9,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; imp public class PlatformSmartLoadingController { @Resource private SmartLoadingService service; @GetMapping("/candidates") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')") - public ApiResponse> candidates(@AuthenticationPrincipal AuthenticatedUser u,@RequestParam @Positive long warehouseId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.candidates(warehouseId));} + public ApiResponse> candidates(@AuthenticationPrincipal AuthenticatedUser u,@RequestParam @Positive long bookingId,@RequestParam @Positive long warehouseId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.candidates(bookingId,warehouseId));} @GetMapping("/plans/by-booking/{bookingId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')") public ApiResponse draftByBooking(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long bookingId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.draftByBooking(bookingId));} @GetMapping("/plans/{planId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java index 5cfc40fab..391715a89 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java @@ -49,7 +49,7 @@ public interface LoadingPickTaskMapper extends BaseMapper WHERE task.task_status = 'LOADING' AND (assignment.pick_task_id IS NULL OR assignment.assignee_id = #{operatorId}) - AND (task.container_no LIKE CONCAT('%', #{keyword}, '%') + AND (booking.container_no LIKE CONCAT('%', #{keyword}, '%') OR booking.warehouse_name LIKE CONCAT('%', #{keyword}, '%')) GROUP BY task.id, task.loading_plan_id, task.container_booking_id, task.cabinet_no, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java index b9606c4ed..926b32f25 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java @@ -88,6 +88,7 @@ public class OceanBookingService { if (replay != null) return replay; OceanBookingEntity before = requiredVersion(id, request.expectedVersion()); requireStatus(before, OceanBookingStatus.SUBMITTED); + validateConfirmation(before, repository.requirements(id)); if (repository.transition(id, OceanBookingStatus.SUBMITTED.name(), OceanBookingStatus.CONFIRMED.name(), null, null, request.expectedVersion(), operatorId, name) != 1) throw concurrent(); OceanBookingEntity after = required(id); @@ -331,6 +332,18 @@ public class OceanBookingService { } } + private static void validateConfirmation(OceanBookingEntity booking, + List requirements) { + if (!StringUtils.hasText(booking.getChannelCode()) || !StringUtils.hasText(booking.getChannelName()) + || booking.getWarehouseId() == null || !StringUtils.hasText(booking.getWarehouseName())) { + throw conflict("订舱缺少渠道或国内中转仓快照,不能确认"); + } + if (requirements.isEmpty() || requirements.stream().anyMatch(requirement -> + requirement.getCapacityCbm() == null || requirement.getMaxWeightKg() == null)) { + throw conflict("箱型需求缺少容量或最大承重快照,不能确认"); + } + } + private static void requireSameChannel(OceanBookingEntity booking, ContainerBookingEntity container) { if (!StringUtils.hasText(booking.getChannelCode()) || !StringUtils.hasText(container.getChannelCode())) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java index 1c737f49f..7542111c9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java @@ -18,6 +18,7 @@ import com.baiyue.firstleg.mapper.ContainerTransportHistoryMapper; import com.baiyue.firstleg.repository.ContainerRepository; import com.baiyue.firstleg.repository.SmartLoadingRepository; import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; @@ -31,7 +32,7 @@ public class ContainerService { @Resource private WarehouseMasterDataClient warehouses; @Resource private SmartLoadingRepository smartLoadingRepository; @Resource private LoadingPickService loadingPickService; - @Resource private ContainerTransportHistoryMapper transportHistories; + @Autowired(required = false) private ContainerTransportHistoryMapper transportHistories; @Value("${baiyue.security.internal-service-token:}") private String internalToken; @Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId; public PageResult page(Query query) { @@ -94,7 +95,8 @@ public class ContainerService { history.setContainerBookingId(id); history.setBeforeContainerNo(current.getContainerNo()); history.setAfterContainerNo(containerNo); history.setBeforeVoyageNo(current.getVoyageNo()); history.setAfterVoyageNo(trim(request.voyageNo())); history.setReason(request.reason().trim()); - history.setOperatorId(operatorId); history.setOperatorName(operatorName); transportHistories.insert(history); + history.setOperatorId(operatorId); history.setOperatorName(operatorName); + if (transportHistories != null) transportHistories.insert(history); ContainerBookingEntity updated = required(id); beginPdaTaskAfterContainerBound(updated, containerNo, operatorId, operatorName); return updated; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index 1b54def36..335660bea 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -43,6 +43,12 @@ public class SmartLoadingService { } return repository.candidates(warehouseId); } + public List candidates(long bookingId, long warehouseId) { + ContainerBookingEntity booking = requireBooking(bookingId); + if (!Objects.equals(booking.getWarehouseId(), warehouseId)) throw validation("柜次仓库与候选仓库不一致"); + if (!StringUtils.hasText(booking.getChannelCode())) throw validation("柜次缺少渠道快照,请先完成存量数据回填"); + return candidates(warehouseId).stream().filter(candidate -> sameChannel(booking, candidate)).toList(); + } public Plan draftByBooking(long bookingId) { ContainerBookingEntity booking = requireBooking(bookingId); diff --git a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml index 1e61e3f2c..bcfa0bccc 100644 --- a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml @@ -7,7 +7,9 @@ paths: get: summary: Find the active container picking task in one platform warehouse parameters: - - {name: containerNo, in: query, required: true, deprecated: true, description: Compatibility lookup only; taskId is the stable identity, schema: {type: string}} + - {name: containerNo, in: query, required: false, deprecated: true, description: Compatibility lookup only; taskId is the stable identity, schema: {type: string}} + - {name: cabinetNo, in: query, required: false, schema: {type: string}} + - {name: loadingPlanId, in: query, required: false, schema: {type: integer, format: int64}} - {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} responses: '200': diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java index f6ac4e369..d9cd3c9b0 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java @@ -80,6 +80,7 @@ class OceanBookingServiceTest { @Test void keepsCurrentVoyagePendingUntilChangeConfirmation() { OceanBookingEntity current = booking("CONFIRMED", 5); + current.setChannelCode("CHANNEL-A"); current.setVesselName("OLD"); current.setVoyageNo("V1"); OceanBookingEntity pending = booking("CHANGE_PENDING", 6); pending.setVesselName("OLD"); pending.setVoyageNo("V1"); pending.setPendingVesselName("NEW"); pending.setPendingVoyageNo("V2"); @@ -118,6 +119,7 @@ class OceanBookingServiceTest { requirement.setId(11L); requirement.setOceanBookingId(1L); requirement.setContainerType("40HQ"); requirement.setQuantity(1); ContainerBookingEntity container = new ContainerBookingEntity(); + container.setChannelCode("CHANNEL-A"); container.setId(12L); container.setContainerType("40HQ"); when(repository.requirement(1L, 11L)).thenReturn(requirement); when(repository.containerBooking(12L)).thenReturn(container); @@ -157,7 +159,8 @@ class OceanBookingServiceTest { private static OceanBookingEntity booking(String status, int version) { OceanBookingEntity value = new OceanBookingEntity(); value.setId(1L); value.setBookingNo("OB1"); - value.setStatus(status); value.setCustomerId(100L); value.setCustomerName("客户"); value.setVersion(version); + value.setStatus(status); value.setCustomerId(100L); value.setCustomerName("客户"); + value.setChannelCode("CHANNEL-A"); value.setVersion(version); return value; } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java index 1d17aeea9..d67a17969 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java @@ -194,9 +194,12 @@ class SmartLoadingSqlContractTest { assertThat(unified) .contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_task (") .contains("loading_plan_id BIGINT UNSIGNED NOT NULL") - .contains("container_no VARCHAR(64) NOT NULL") + .contains("container_booking_id BIGINT UNSIGNED NOT NULL") + .contains("cabinet_no VARCHAR(64) NOT NULL") + .contains("container_no VARCHAR(64) NULL") .contains("task_status VARCHAR(16) NOT NULL DEFAULT 'LOADING'") .contains("UNIQUE KEY uk_loading_pick_task_plan (loading_plan_id)") + .contains("UNIQUE KEY uk_loading_pick_task_booking (container_booking_id)") .contains("KEY idx_loading_pick_container (container_no, task_status, started_at)") .contains("CREATE TABLE IF NOT EXISTS fl_inbound_loading_pick_task (") .contains("tenant_id BIGINT UNSIGNED NOT NULL") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java index efb6bdceb..81edc5ffb 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java @@ -8,6 +8,7 @@ import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest; import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest; import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.entity.LoadingPlanEntity; +import com.baiyue.firstleg.mapper.ContainerTransportHistoryMapper; import com.baiyue.firstleg.repository.ContainerRepository; import com.baiyue.firstleg.repository.SmartLoadingRepository; import jakarta.annotation.Resource; @@ -37,6 +38,7 @@ class ContainerServiceTest { @MockBean private WarehouseMasterDataClient warehouses; @MockBean private SmartLoadingRepository smartLoadingRepository; @MockBean private LoadingPickService loadingPickService; + @MockBean private ContainerTransportHistoryMapper transportHistories; @Resource private ContainerService service; @BeforeEach 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 b32c0e1d4..be0e3520d 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 @@ -59,10 +59,11 @@ test('恢复模式只续补固定报告来源并校验 AR AP 金额状态', () = assert.match(source, /拒绝借历史数据/) assert.match(source, /const recoverySupplierId = role === 'apPartial'/) assert.match(source, /matrix\.payableSettlements\?\.apPaid\?\.supplierId/) - assert.match(source, /pageSize=200/) + assert.match(source, /process\.env\.FINANCE_DEMO_AP_UNPAID_ID/) + assert.match(source, /process\.env\.FINANCE_DEMO_AP_PARTIAL_ID/) assert.match(source, /const payableIds = \['307', '310'\]/) assert.match(source, /finance-container-partner-not-deployed/) - assert.match(source, /findPayableBySource/) + assert.match(source, /recoverPayable/) assert.match(source, /approved\?\.statementGenerated === true/) assert.match(source, /finance-settlement-payment-status-not-deployed/) assert.match(source, /\['arUnpaid', 'UNPAID'\]/) diff --git a/scripts/admin-test-env-modules/finance-demo-fixtures.mjs b/scripts/admin-test-env-modules/finance-demo-fixtures.mjs index 7db173aeb..dbffe5dc2 100644 --- a/scripts/admin-test-env-modules/finance-demo-fixtures.mjs +++ b/scripts/admin-test-env-modules/finance-demo-fixtures.mjs @@ -140,16 +140,12 @@ async function createAp(context, fixture, source, role) { return value } -async function findPayableBySource(context, feeSourceId) { - 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) - if (value?.direction === 'PAYABLE' && value?.lines?.some((line) => id(line.feeSourceId) === id(feeSourceId))) { - return { response: detailResponse, value } - } - } - return { response: listResponse, value: null } +async function recoverPayable(context, settlement, feeSourceId) { + if (!settlementId(settlement)) return null + const response = await context.request('GET', `${BASE}/settlements/${settlementId(settlement)}`) + const value = data(response) + return value?.direction === 'PAYABLE' && value?.lines?.some((line) => id(line.feeSourceId) === id(feeSourceId)) + ? { response, value } : null } function verifySettlementState(context, direction, role, response, value, expectedStatus) { @@ -183,6 +179,13 @@ async function recoverExistingDemo(context, reportPath) { const payableIds = ['307', '310'] const roles = ['apUnpaid', 'apPartial'] + matrix.payableSettlements ??= {} + if (!settlementId(matrix.payableSettlements.apUnpaid)) { + matrix.payableSettlements.apUnpaid = { settlementId: process.env.FINANCE_DEMO_AP_UNPAID_ID } + } + if (!settlementId(matrix.payableSettlements.apPartial)) { + matrix.payableSettlements.apPartial = { settlementId: process.env.FINANCE_DEMO_AP_PARTIAL_ID } + } for (let index = 0; index < roles.length; index += 1) { const role = roles[index] const detail = await sourceDetail(context, payableIds[index]) @@ -204,7 +207,8 @@ async function recoverExistingDemo(context, reportPath) { continue } const approved = await approveAndInclude(context, { feeSourceId: payableIds[index] }, role) - const existing = approved?.statementGenerated === true ? await findPayableBySource(context, payableIds[index]) : null + const existing = approved?.statementGenerated === true + ? await recoverPayable(context, matrix.payableSettlements[role], payableIds[index]) : null const recoverySupplierId = role === 'apPartial' ? id(matrix.payableSettlements?.apPaid?.supplierId) : id(fixture.supplier?.supplierId) @@ -215,10 +219,10 @@ async function recoverExistingDemo(context, reportPath) { `settlementId=${settlementId(existing.value)}, feeSourceId=${payableIds[index]}`) if (!settlementId(settlement)) continue if (role === 'apUnpaid') matrix.payableSettlements[role] = settlement - else { + else if (Number(settlement.paidAmount ?? 0) === 0) { const amount = Math.max(0.01, Math.floor(outstanding(settlement) * 50) / 100) matrix.payableSettlements[role] = (await pay(context, fixture, settlement, amount, role)).value - } + } else matrix.payableSettlements[role] = settlement verifySettlementState(context, 'PAYABLE', role, detail.response, matrix.payableSettlements[role], role === 'apUnpaid' ? 'UNPAID' : 'PARTIALLY_PAID') } diff --git a/scripts/admin-test-env-modules/finance-extended-settlement-contract.test.mjs b/scripts/admin-test-env-modules/finance-extended-settlement-contract.test.mjs new file mode 100644 index 000000000..2ae7cefe7 --- /dev/null +++ b/scripts/admin-test-env-modules/finance-extended-settlement-contract.test.mjs @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +const moduleUrl = new URL('./finance-extended-settlement.mjs', import.meta.url) +const source = await readFile(moduleUrl, 'utf8') + +test('扩展财务验收只接受指定独立 marker 且不借历史来源', () => { + assert.match(source, /FIN-EXT-SETTLE-20260812/) + assert.match(source, /context\.marker = `\$\{REQUIRED_MARKER\}-/) + assert.match(source, /runOwnedFixture/) + assert.match(source, /fixture\?\.marker === context\.marker/) + assert.match(source, /禁止借历史来源/) + assert.doesNotMatch(source, /INSERT\s+INTO|UPDATE\s+fin_|DELETE\s+FROM/i) +}) + +test('扩展财务验收覆盖真实来源、SELECTED 状态机和三 AR 三 AP', () => { + assert.match(source, /finance-full-fixtures\.mjs/) + assert.match(source, /finance-reconciliation-full\.mjs/) + assert.match(source, /generationMode: 'SELECTED'/) + assert.match(source, /WAYBILL-PAYABLE/) + assert.match(source, /receivables\.length !== 3/) + assert.match(source, /payables\.length !== 3/) + assert.match(source, /DUPLICATE-OCCUPANCY/) + assert.match(source, /三张 AR 分别保留未收、部分收、已收清/) + assert.match(source, /三张 AP 分别保留未付、部分付、已付/) +}) + +test('扩展财务验收覆盖凭证、多单分配、超额拒绝及幂等重放', () => { + assert.match(source, /voucherFileIds: \[fixture\.voucherFileId\]/) + assert.match(source, /allocations\.length === 2/) + assert.match(source, /收款幂等重放不重复入账/) + assert.match(source, /AP 全额付款且幂等重放/) + assert.match(source, /拒绝超额付款/) + assert.match(source, /requestId/) + assert.match(source, /traceId/) +}) + +test('扩展财务验收输出可回传资源与柜次部署阻断', () => { + assert.match(source, /financeExtendedSettlement/) + assert.match(source, /statementIds/) + assert.match(source, /receivableSettlementIds/) + assert.match(source, /payableSettlementIds/) + assert.match(source, /statusMatrix/) + assert.match(source, /containerPayableDeploymentBlocker/) +}) diff --git a/scripts/admin-test-env-modules/finance-extended-settlement.mjs b/scripts/admin-test-env-modules/finance-extended-settlement.mjs new file mode 100644 index 000000000..4137a7cc9 --- /dev/null +++ b/scripts/admin-test-env-modules/finance-extended-settlement.mjs @@ -0,0 +1,301 @@ +const MODULE = 'finance-extended-settlement' +const SCENARIO = 'finance-extended-settlement-real-api' +const REQUIRED_MARKER = 'FIN-EXT-SETTLE-20260812' +const BASE = '/api/platform/finance/v1' +const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } + +const id = (value) => value == null ? '' : String(value) +const data = (response) => response?.body?.data ?? response?.body +const rows = (value) => Array.isArray(value) ? value : Array.isArray(value?.records) ? value.records : [] +const ok = (response) => response?.http === 200 && String(response?.body?.code ?? '200') === '200' +const settlementId = (value) => id(value?.settlementId ?? value?.summary?.settlementId ?? value?.id) +const statementId = (value) => id(value?.statementId ?? value?.reconciliationStatementId ?? value?.id) +const outstanding = (value) => Number(value?.outstandingAmount ?? value?.summary?.outstandingAmount ?? 0) +const version = (value) => Number(value?.version ?? value?.summary?.version ?? 0) +const paymentStatus = (value) => id(value?.paymentStatus ?? value?.summary?.paymentStatus ?? value?.status) +const dateOffset = (days) => { const value = new Date(); value.setUTCDate(value.getUTCDate() + days); return value.toISOString().slice(0, 10) } + +function key(context, operation) { + return `${context.marker}-extended-${operation}`.replace(/[^A-Za-z0-9_.:-]/g, '-').slice(0, 128) +} + +function write(context, method, path, body, operation) { + return context.request(method, path, body, context.token, { 'Idempotency-Key': key(context, operation) }) +} + +function diagnostic(response) { + return { http: response?.http ?? null, code: response?.body?.code == null ? null : String(response.body.code), + requestId: response?.requestId ?? null, traceId: response?.traceId ?? null } +} + +function blocker(context, name, evidence, category, response = null) { + context.addStep(MODULE, name, response, false, evidence, OPTIONS) + context.addCoverageGap?.(MODULE, SCENARIO, name, evidence, category, + { marker: context.marker, ...diagnostic(response) }) +} + +function finish(context, start) { + const steps = context.steps.slice(start) + context.scenarios.push({ name: 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 runOwnedFixture(context) { + const fixtureModule = await import(new URL('./finance-full-fixtures.mjs', import.meta.url)) + await fixtureModule.run(context) + const fixture = context.createdResources.financeFullFixtures + return fixture?.marker === context.marker ? fixture : null +} + +async function loadPayableInputs(context) { + const [feeItemsResponse, partnersResponse] = await Promise.all([ + context.request('GET', '/api/platform/v1/fee-items?page=1&pageSize=200'), + context.request('GET', '/api/platform/v1/partners?page=1&size=200&status=ACTIVE'), + ]) + const feeItems = rows(data(feeItemsResponse)) + const partners = rows(data(partnersResponse)) + return { + feeItem: feeItems.find((item) => item?.status === 'ACTIVE' && item?.warehousePayable === true) || null, + supplier: partners.find((item) => item?.status === 'ACTIVE' && item?.id) || null, + response: !ok(feeItemsResponse) ? feeItemsResponse : partnersResponse, + } +} + +async function createWaybillPayable(context, fixture, order, input, index) { + const inboundId = id(order?.inboundId) + if (!inboundId) return null + const path = `/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fee-details?accountId=${encodeURIComponent(fixture.customerId)}` + const body = { feeItemId: Number(input.feeItem.id), direction: 'PAYABLE', businessDate: dateOffset(-3 - index), + unitPrice: 90 + index * 10, amount: 90 + index * 10, currency: 'CNY', settlementIncluded: true, + partnerId: id(input.supplier.id), partnerName: input.supplier.partnerName || null, + remark: `${context.marker}-WAYBILL-PAYABLE-${index + 1}` } + const response = await write(context, 'POST', path, body, `waybill-payable-${index + 1}`) + const detail = context.apiSuccess(MODULE, `创建第 ${index + 1} 条独占运单 PAYABLE 来源`, response, + `inboundId=${inboundId}`, OPTIONS) + const sourceFeeId = id(detail?.id) + if (!sourceFeeId) return null + const approvePath = `/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fee-details/${sourceFeeId}/approve?accountId=${encodeURIComponent(fixture.customerId)}` + const approveResponse = await write(context, 'POST', approvePath, {}, `waybill-payable-${index + 1}-approve`) + context.apiSuccess(MODULE, `审核第 ${index + 1} 条独占运单 PAYABLE 来源`, approveResponse, '', OPTIONS) + if (!ok(approveResponse)) return null + return { sourceFeeId, inboundId, orderId: id(order.orderId), + waybillId: id(order.waybillId ?? order.shipmentId ?? order.orderNo), + businessDate: body.businessDate, amount: body.amount, + supplierId: id(input.supplier.id), supplierName: input.supplier.partnerName || null } +} + +async function resolvePoolSources(context, fixture, created) { + let poolResponse = null + for (let attempt = 0; attempt < 20; attempt += 1) { + poolResponse = await context.request('GET', `${BASE}/fee-pool?customerId=${encodeURIComponent(fixture.customerId)}&direction=PAYABLE`) + const poolData = data(poolResponse) + const pool = rows(poolData).concat(rows(poolData?.items)) + const bySource = new Map(pool.map((item) => [id(item?.sourceId), item])) + const resolved = created.map((item) => { + const row = bySource.get(item.sourceFeeId) + const poolItemId = id(row?.poolItemId) + return row ? { ...item, feeSourceId: poolItemId.includes(':') ? poolItemId.slice(poolItemId.indexOf(':') + 1) + : id(row?.feeSourceId ?? row?.financeFeeSourceId ?? row?.id) } : null + }) + if (resolved.every((item) => item?.feeSourceId)) return { resolved, response: poolResponse } + await new Promise((resolve) => setTimeout(resolve, 500)) + } + return { resolved: [], response: poolResponse } +} + +async function runReconciliation(context, fixture, payableForLegacyModule) { + const original = fixture.feeSources + fixture.feeSources = [...original.filter((source) => source.direction !== 'PAYABLE'), payableForLegacyModule] + try { + const module = await import(new URL('./finance-reconciliation-full.mjs', import.meta.url)) + await module.run(context) + } finally { + fixture.feeSources = original + } +} + +async function createExtraPayableSettlement(context, fixture, source, index) { + const body = { customerId: Number(fixture.customerId), supplierId: Number(source.supplierId), + feeSourceIds: [Number(source.feeSourceId)], dueDate: source.businessDate, currency: 'CNY' } + const response = await write(context, 'POST', `${BASE}/payable-settlements`, body, `ap-${index}`) + const settlement = context.apiSuccess(MODULE, `创建第 ${index} 张独立 AP`, response, + `feeSourceId=${source.feeSourceId}`, OPTIONS) + if (!settlement) return null + const replayResponse = await write(context, 'POST', `${BASE}/payable-settlements`, body, `ap-${index}`) + const replay = context.apiSuccess(MODULE, `第 ${index} 张 AP 幂等重放`, replayResponse, '', OPTIONS) + context.assert(MODULE, `第 ${index} 张 AP 幂等保持同一资源`, replayResponse, + settlementId(replay) === settlementId(settlement), `settlementId=${settlementId(settlement)}`, OPTIONS) + return settlement +} + +async function detail(context, targetId) { + const response = await context.request('GET', `${BASE}/settlements/${encodeURIComponent(targetId)}`) + return { response, value: data(response) } +} + +function receiptBody(context, fixture, allocations) { + return { customerId: Number(fixture.customerId), receivedDate: new Date().toISOString().slice(0, 10), + amount: allocations.reduce((sum, item) => sum + item.amount, 0), currency: 'CNY', + paymentMethod: 'BANK_TRANSFER', voucherFileIds: [fixture.voucherFileId], + remark: `${context.marker}-MULTI-AR`, allocations } +} + +async function settleReceivables(context, fixture, receivables) { + const details = await Promise.all(receivables.map((item) => detail(context, settlementId(item)))) + const partialBefore = outstanding(details[1].value) + const paidBefore = outstanding(details[2].value) + const partialAmount = Math.max(0.01, Math.floor(partialBefore * 50) / 100) + const allocations = [ + { billId: Number(statementId(receivables[1])), amount: partialAmount }, + { billId: Number(statementId(receivables[2])), amount: paidBefore }, + ] + const response = await write(context, 'POST', `${BASE}/receipts`, receiptBody(context, fixture, allocations), 'multi-ar-receipt') + const replayResponse = await write(context, 'POST', `${BASE}/receipts`, receiptBody(context, fixture, allocations), 'multi-ar-receipt') + const receipt = data(response) + const replay = data(replayResponse) + context.assert(MODULE, '一笔真实收款分配两张 AR 且携带凭证', response, ok(response) + && id(receipt?.receiptId ?? receipt?.id) && allocations.length === 2, + `receiptId=${id(receipt?.receiptId ?? receipt?.id) || 'missing'}, voucherFileId=${fixture.voucherFileId}`, OPTIONS) + context.assert(MODULE, '收款幂等重放不重复入账', replayResponse, + ok(replayResponse) && id(replay?.receiptId ?? replay?.id) === id(receipt?.receiptId ?? receipt?.id), + `first=${id(receipt?.receiptId ?? receipt?.id)}, replay=${id(replay?.receiptId ?? replay?.id)}`, OPTIONS) + const after = await Promise.all(receivables.map((item) => detail(context, settlementId(item)))) + context.assert(MODULE, '三张 AR 分别保留未收、部分收、已收清', response, + outstanding(after[0].value) > 0 && outstanding(after[1].value) > 0 + && outstanding(after[1].value) < partialBefore && outstanding(after[2].value) === 0, + `outstanding=${after.map((item) => outstanding(item.value)).join(',')}`, OPTIONS) + return { receipt, states: after.map((item) => item.value) } +} + +function paymentBody(context, fixture, amount, expectedVersion, suffix) { + return { transactionDate: new Date().toISOString().slice(0, 10), amount, currency: 'CNY', + method: 'BANK_TRANSFER', referenceNo: `${context.marker}-${suffix}`, voucherFileIds: [fixture.voucherFileId], + expectedVersion, remark: context.marker } +} + +async function settlePayables(context, fixture, payables) { + const initial = await Promise.all(payables.map((item) => detail(context, settlementId(item)))) + const partialAmount = Math.max(0.01, Math.floor(outstanding(initial[1].value) * 50) / 100) + const partialBody = paymentBody(context, fixture, partialAmount, version(initial[1].value), 'AP-PARTIAL') + const partialResponse = await write(context, 'POST', `${BASE}/settlements/${settlementId(payables[1])}/transactions`, partialBody, 'ap-partial') + const partial = data(partialResponse) + context.assert(MODULE, '第二张 AP 部分付款', partialResponse, ok(partialResponse) + && outstanding(partial) > 0 && outstanding(partial) < outstanding(initial[1].value), + `outstanding=${outstanding(partial)}`, OPTIONS) + + const paidBody = paymentBody(context, fixture, outstanding(initial[2].value), version(initial[2].value), 'AP-PAID') + const paidResponse = await write(context, 'POST', `${BASE}/settlements/${settlementId(payables[2])}/transactions`, paidBody, 'ap-paid') + const paidReplay = await write(context, 'POST', `${BASE}/settlements/${settlementId(payables[2])}/transactions`, paidBody, 'ap-paid') + const paid = data(paidResponse) + const replay = data(paidReplay) + context.assert(MODULE, '第三张 AP 全额付款且幂等重放', paidResponse, ok(paidResponse) && ok(paidReplay) + && outstanding(paid) === 0 && settlementId(replay) === settlementId(paid) && version(replay) === version(paid), + `settlementId=${settlementId(paid)}, outstanding=${outstanding(paid)}`, OPTIONS) + const overBody = paymentBody(context, fixture, 0.01, version(paid), 'AP-OVER') + const overResponse = await write(context, 'POST', `${BASE}/settlements/${settlementId(payables[2])}/transactions`, overBody, 'ap-overpayment') + context.assert(MODULE, '已付清 AP 拒绝超额付款', overResponse, !ok(overResponse), + `HTTP=${overResponse.http}, code=${id(overResponse.body?.code)}, requestId=${overResponse.requestId ?? 'null'}, traceId=${overResponse.traceId ?? 'null'}`, OPTIONS) + const final = await Promise.all(payables.map((item) => detail(context, settlementId(item)))) + context.assert(MODULE, '三张 AP 分别保留未付、部分付、已付', paidResponse, + outstanding(final[0].value) > 0 && outstanding(final[1].value) > 0 && outstanding(final[2].value) === 0, + `outstanding=${final.map((item) => outstanding(item.value)).join(',')}`, OPTIONS) + return { states: final.map((item) => item.value), overpayment: diagnostic(overResponse) } +} + +async function assertDuplicateOccupancy(context, fixture) { + const first = fixture.statements?.find((item) => item?.feeSourceIds?.length) + if (!first) return + const body = { customerId: Number(fixture.customerId), currency: 'CNY', generationMode: 'SELECTED', + feeSourceIds: [Number(first.feeSourceIds[0])], periodStart: first.periodStart, + periodEnd: first.periodEnd, remark: `${context.marker}-DUPLICATE-OCCUPANCY` } + const response = await write(context, 'POST', `${BASE}/statements`, body, 'duplicate-source') + context.assert(MODULE, '已占用来源拒绝生成重复对账单', response, !ok(response), + `code=${id(response.body?.code)}, requestId=${response.requestId ?? 'null'}, traceId=${response.traceId ?? 'null'}`, OPTIONS) +} + +export async function run(context) { + const start = context.steps.length + const runnerMarker = context.marker + context.marker = `${REQUIRED_MARKER}-${runnerMarker.slice(-5)}` + context.assert(MODULE, '使用指定独立 marker 前缀和本轮幂等后缀', null, + context.marker.startsWith(`${REQUIRED_MARKER}-`), + `baseMarker=${REQUIRED_MARKER}, executionMarker=${context.marker}`, OPTIONS) + + const fixture = await runOwnedFixture(context) + if (!fixture?.customerId || !fixture?.voucherFileId || !Array.isArray(fixture.orders) || fixture.orders.length < 3) { + blocker(context, '建立真实独占订单、费用与凭证 Fixture', + `customerId=${fixture?.customerId || 'missing'}, orders=${fixture?.orders?.length || 0}, voucherFileId=${fixture?.voucherFileId || 'missing'};禁止借历史来源`, + 'marker-owned-fixture-unavailable') + finish(context, start) + return + } + + const input = await loadPayableInputs(context) + if (!input.feeItem?.id || !input.supplier?.id) { + blocker(context, '取得真实 PAYABLE 费用项目与供应商', + `feeItemId=${id(input.feeItem?.id) || 'missing'}, supplierId=${id(input.supplier?.id) || 'missing'}`, + 'payable-master-data-unavailable', input.response) + finish(context, start) + return + } + + const createdPayables = [] + for (let index = 0; index < 3; index += 1) { + const source = await createWaybillPayable(context, fixture, fixture.orders[index], input, index) + if (source) createdPayables.push(source) + } + const pool = await resolvePoolSources(context, fixture, createdPayables) + if (pool.resolved.length !== 3) { + blocker(context, '三条本轮 PAYABLE 来源进入 Finance 费用池', + `created=${createdPayables.length}, visible=${pool.resolved.length}`, 'payable-fee-pool-sync-failed', pool.response) + finish(context, start) + return + } + + const legacyPayable = { ...pool.resolved[0], feeSourceId: pool.resolved[0].sourceFeeId, + direction: 'PAYABLE', currency: 'CNY', sourceType: 'MANUAL', + marker: context.marker, partnerId: pool.resolved[0].supplierId } + await runReconciliation(context, fixture, legacyPayable) + const receivables = fixture.receivableSettlements || [] + const firstPayable = fixture.payableSettlements?.[0] + if (receivables.length !== 3 || !firstPayable) { + blocker(context, '生成三张 SELECTED 对账单、三张 AR 与首张 AP', + `statements=${fixture.statements?.length || 0}, AR=${receivables.length}, AP=${fixture.payableSettlements?.length || 0}`, + 'reconciliation-chain-incomplete') + finish(context, start) + return + } + + await assertDuplicateOccupancy(context, fixture) + const secondPayable = await createExtraPayableSettlement(context, fixture, pool.resolved[1], 2) + const thirdPayable = await createExtraPayableSettlement(context, fixture, pool.resolved[2], 3) + const payables = [firstPayable, secondPayable, thirdPayable].filter(Boolean) + if (payables.length !== 3) { + blocker(context, '生成三张独立 AP', `expected=3, actual=${payables.length}`, + 'three-payable-settlements-failed') + finish(context, start) + return + } + + const arResult = await settleReceivables(context, fixture, receivables) + const apResult = await settlePayables(context, fixture, payables) + const statusMatrix = { + AR: arResult.states.map((item) => ({ settlementId: settlementId(item), settlementNo: item?.settlementNo ?? item?.summary?.settlementNo ?? null, + paymentStatus: paymentStatus(item), outstandingAmount: outstanding(item) })), + AP: apResult.states.map((item) => ({ settlementId: settlementId(item), settlementNo: item?.settlementNo ?? item?.summary?.settlementNo ?? null, + paymentStatus: paymentStatus(item), outstandingAmount: outstanding(item) })), + } + context.createdResources.financeExtendedSettlement = { + marker: REQUIRED_MARKER, executionMarker: context.marker, + customerId: id(fixture.customerId), orderIds: fixture.orders.map((item) => id(item.orderId)), + sourceIds: { receivable: fixture.feeSources.filter((item) => item.direction === 'RECEIVABLE').map((item) => id(item.feeSourceId)), + payable: pool.resolved.map((item) => item.feeSourceId) }, + statementIds: fixture.statements.filter((item) => item.kind !== 'SELECTED_DATE').map((item) => id(item.statementId)), + receivableSettlementIds: receivables.map(settlementId), payableSettlementIds: payables.map(settlementId), + voucherFileId: id(fixture.voucherFileId), receiptId: id(arResult.receipt?.receiptId ?? arResult.receipt?.id), + statusMatrix, overpayment: apResult.overpayment, + containerPayableDeploymentBlocker: fixture.deploymentBlocker || [], + } + finish(context, start) +}