This commit is contained in:
2026-08-12 17:45:45 +08:00
parent b3b8d94957
commit 8e8e123d23
15 changed files with 410 additions and 26 deletions
@@ -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;
@@ -45,7 +45,7 @@ public class PlatformLoadingPdaController {
@GetMapping("/tasks")
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
public ApiResponse<Task> 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));
@@ -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<List<Candidate>> candidates(@AuthenticationPrincipal AuthenticatedUser u,@RequestParam @Positive long warehouseId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.candidates(warehouseId));}
public ApiResponse<List<Candidate>> 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<Plan> 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')")
@@ -49,7 +49,7 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
WHERE task.task_status = 'LOADING'
AND (assignment.pick_task_id IS NULL OR assignment.assignee_id = #{operatorId})
<if test="keyword != null and keyword != ''">
AND (task.container_no LIKE CONCAT('%', #{keyword}, '%')
AND (booking.container_no LIKE CONCAT('%', #{keyword}, '%')
OR booking.warehouse_name LIKE CONCAT('%', #{keyword}, '%'))
</if>
GROUP BY task.id, task.loading_plan_id, task.container_booking_id, task.cabinet_no,
@@ -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<OceanBookingContainerRequirementEntity> 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())) {
@@ -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<ContainerBookingEntity> 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;
@@ -43,6 +43,12 @@ public class SmartLoadingService {
}
return repository.candidates(warehouseId);
}
public List<Candidate> 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);
@@ -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':
@@ -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;
}
}
@@ -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")
@@ -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
@@ -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'\]/)
@@ -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')
}
@@ -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/)
})
@@ -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)
}