This commit is contained in:
2026-08-20 16:13:51 +08:00
parent 164d862588
commit 09f6e28c1f
10 changed files with 164 additions and 15 deletions
@@ -366,7 +366,20 @@ public class FinancePhase1OperationService {
private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { throw error("FIN-DATA-500", "财务数据序列化失败"); } }
private String hash(Object value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); } catch (Exception e) { throw error("FIN-DATA-500", "幂等请求哈希失败"); } }
private static String text(JsonNode node, String field) { JsonNode value = node.path(field); return value.isMissingNode() || value.isNull() ? null : value.asText(); }
private static Long jsonLong(JsonNode node, String field) { JsonNode value = node.path(field); return value.isIntegralNumber() && value.asLong() > 0 ? value.asLong() : null; }
private static Long jsonLong(JsonNode node, String field) {
JsonNode value = node.path(field);
if (value.isIntegralNumber() && value.canConvertToLong()) {
long parsed = value.longValue();
return parsed > 0 ? parsed : null;
}
if (!value.isTextual()) return null;
try {
long parsed = Long.parseLong(value.textValue());
return parsed > 0 ? parsed : null;
} catch (NumberFormatException ignored) {
return null;
}
}
private static Long firstLong(Long first, Long second) { return first != null ? first : second; }
private static String firstText(String first, String second) { return StringUtils.hasText(first) ? first : second; }
private static String defaultText(JsonNode node, String field, String fallback) { String value = text(node, field); return value == null || value.isBlank() ? fallback : value; }
@@ -462,7 +462,9 @@ public class FinanceSettlementService {
BigDecimal unitPrice = quantity == null || quantity.signum() == 0 ? null
: amount.divide(quantity, 6, java.math.RoundingMode.HALF_UP);
Long supplierId = jsonLong(snapshot, "supplierId");
if (supplierId == null) throw error("FIN_409", "应付结算缺少供应商快照");
if ("PAYABLE".equals(text(row, "direction")) && supplierId == null) {
throw error("FIN_409", "应付结算缺少供应商快照");
}
return new SettlementLine(number(row, "fee_source_id").longValue(), text(row, "source_type"),
number(row, "source_fee_id").longValue(), number(row, "source_fee_version").intValue(),
text(row, "direction"), number(row, "customer_id").longValue(), jsonText(snapshot, "customerName"),
@@ -594,7 +596,17 @@ public class FinanceSettlementService {
}
private static Long jsonLong(JsonNode node, String key) {
JsonNode value = node.path(key);
return value.isIntegralNumber() ? value.longValue() : null;
if (value.isIntegralNumber() && value.canConvertToLong()) {
long parsed = value.longValue();
return parsed > 0 ? parsed : null;
}
if (!value.isTextual()) return null;
try {
long parsed = Long.parseLong(value.textValue());
return parsed > 0 ? parsed : null;
} catch (NumberFormatException ignored) {
return null;
}
}
private static LocalDate jsonDate(JsonNode node, String key) {
String value = jsonText(node, key);
@@ -144,7 +144,7 @@ class FinancePhase1OperationBehaviorTest {
when(rs.getString("currency_code")).thenReturn("CNY");
when(rs.getString("review_status")).thenReturn("APPROVED");
when(rs.getString("effective_status")).thenReturn("AVAILABLE");
when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"supplierId\":79,\"supplierName\":\"供应商A\",\"inboundCode\":\"IN-9\",\"containerNo\":\"CONT-9\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\"}");
when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"supplierId\":\"79\",\"supplierName\":\"供应商A\",\"inboundCode\":\"IN-9\",\"containerNo\":\"CONT-9\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\"}");
when(rs.getBigDecimal("amount")).thenReturn(new BigDecimal("120.00"));
when(rs.getBoolean("statement_generated")).thenReturn(false);
return List.of(mapper.mapRow(rs, 0));
@@ -164,6 +164,7 @@ class FinancePhase1OperationBehaviorTest {
assertThat(source.sourceBusinessId()).isEqualTo("IN-9");
assertThat(source.inboundCode()).isEqualTo("IN-9");
assertThat(source.containerNo()).isEqualTo("CONT-9");
assertThat(source.supplierId()).isEqualTo(79L);
assertThat(source.supplierName()).isEqualTo("供应商A");
assertThat(source.reviewStatus()).isEqualTo("APPROVED");
assertThat(source.businessDate()).isEqualTo(LocalDate.of(2026, 8, 11));
@@ -10,6 +10,7 @@ import com.baiyue.finance.dto.FinanceSettlementDtos.CreatePayableSettlementReque
import com.baiyue.finance.dto.FinanceSettlementDtos.CreateReceivableSettlementRequest;
import com.baiyue.finance.dto.FinanceSettlementDtos.CreateSettlementPaymentRequest;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementLine;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementActionRequest;
import com.baiyue.finance.service.FinanceEventException;
@@ -286,6 +287,66 @@ class FinanceSettlementBehaviorTest {
verify(jdbc).update(startsWith("INSERT INTO fin_settlement_line"), eq(88L), eq(10L), eq(501L));
}
@Test
void receivableSettlementLineDoesNotRequireSupplierSnapshot() {
Map<String, Object> row = new java.util.HashMap<>(Map.ofEntries(
Map.entry("fee_source_id", 31L), Map.entry("source_type", "FBA_PRICING"),
Map.entry("source_fee_id", 41L), Map.entry("source_fee_version", 1),
Map.entry("direction", "RECEIVABLE"), Map.entry("customer_id", 10L),
Map.entry("source_snapshot", "{\"customerName\":\"客户10\",\"businessDate\":\"2026-08-11\"}"),
Map.entry("fee_item_name", "头程运费"), Map.entry("amount", new BigDecimal("100.00")),
Map.entry("currency_code", "CNY"), Map.entry("review_status", "APPROVED"),
Map.entry("status", "GENERATED")));
row.put("order_id", null);
row.put("inbound_id", null);
row.put("container_id", null);
SettlementLine line = ReflectionTestUtils.invokeMethod(
service(org.mockito.Mockito.mock(JdbcTemplate.class), org.mockito.Mockito.mock(FinanceFileClient.class)),
"line", row, detail("RECEIVABLE", "ISSUED", BigDecimal.ZERO,
new BigDecimal("100.00"), 0).summary());
assertThat(line.direction()).isEqualTo("RECEIVABLE");
assertThat(line.supplierId()).isNull();
assertThat(line.customerName()).isEqualTo("客户10");
}
@Test
void payableSettlementLineStillRequiresSupplierSnapshot() {
Map<String, Object> row = new java.util.HashMap<>(Map.ofEntries(
Map.entry("direction", "PAYABLE"),
Map.entry("source_snapshot", "{\"customerName\":\"客户10\",\"businessDate\":\"2026-08-11\"}"),
Map.entry("amount", new BigDecimal("100.00"))));
assertCode(() -> ReflectionTestUtils.invokeMethod(
service(org.mockito.Mockito.mock(JdbcTemplate.class), org.mockito.Mockito.mock(FinanceFileClient.class)),
"line", row, detail("PAYABLE", "ISSUED", BigDecimal.ZERO,
new BigDecimal("100.00"), 0).summary()), "FIN_409");
}
@Test
void payableSettlementLineAcceptsStringEncodedSupplierIdSnapshot() {
Map<String, Object> row = new java.util.HashMap<>(Map.ofEntries(
Map.entry("fee_source_id", 31L), Map.entry("source_type", "INBOUND_FEE"),
Map.entry("source_fee_id", 41L), Map.entry("source_fee_version", 1),
Map.entry("direction", "PAYABLE"), Map.entry("customer_id", 10L),
Map.entry("source_snapshot", "{\"customerName\":\"客户10\",\"supplierId\":\"2090349552523644929\",\"supplierName\":\"供应商A\",\"businessDate\":\"2026-08-11\"}"),
Map.entry("fee_item_name", "装车费"), Map.entry("amount", new BigDecimal("80.00")),
Map.entry("currency_code", "CNY"), Map.entry("review_status", "APPROVED"),
Map.entry("status", "AVAILABLE")));
row.put("order_id", null);
row.put("inbound_id", null);
row.put("container_id", null);
SettlementLine line = ReflectionTestUtils.invokeMethod(
service(org.mockito.Mockito.mock(JdbcTemplate.class), org.mockito.Mockito.mock(FinanceFileClient.class)),
"line", row, detail("PAYABLE", "ISSUED", BigDecimal.ZERO,
new BigDecimal("80.00"), 0).summary());
assertThat(line.supplierId()).isEqualTo(2090349552523644929L);
assertThat(line.supplierName()).isEqualTo("供应商A");
}
@Test
void receivableCreationRejectsAlreadySettledStatement() {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
@@ -15,7 +15,8 @@ test('Finance AP consumes only published fin_fee_source rows and never substitut
assert.match(source, /!isUnboundContainerCost\(source\.raw\) \|\| hasContainerAllocations/)
})
const financePermissions = [
'platform:finance:customer-account:list', 'platform:finance:customer-statement:list',
'platform:finance:customer-account:list', 'platform:finance:customer-arap:list',
'platform:finance:fee-source:review', 'platform:finance:customer-statement:list',
'platform:finance:reconciliation:create', 'platform:finance:reconciliation:review',
'platform:finance:reconciliation:issue', 'platform:finance:reconciliation:adjust',
'platform:finance:reconciliation:void', 'platform:finance:settlement-ar:create',
@@ -58,12 +59,13 @@ function fakeContext(withFixture = true, permissions = financePermissions, denyF
const calls = []
const steps = []
const coverageGaps = []
const reviewedSourceIds = new Set()
function responseForStatement(statement) { return structuredClone(statement) }
function applyAction(statement, body) {
const status = { SUBMIT: 'PENDING_REVIEW', APPROVE: 'APPROVED', REJECT: 'REJECTED',
ISSUE: 'ISSUED', ADJUST: 'ISSUED', VOID: 'VOIDED', REOPEN: 'DRAFT' }[body.action]
statement.revisions.push({ action: `RECONCILIATION_${body.action}` })
statement.revisions.push({ action: body.action })
statement.status = status
statement.version += 1
if (body.action === 'ADJUST') {
@@ -112,6 +114,20 @@ function fakeContext(withFixture = true, permissions = financePermissions, denyF
reviewStatus: 'APPROVED', sourceStatus: 'AVAILABLE' }
}))
}
const feeSourceDetailMatch = requestPath.match(/\/fee-sources\/(\d+)$/)
if (method === 'GET' && feeSourceDetailMatch) {
const localId = feeSourceDetailMatch[1]
return ok({ feeSourceId: localId, reviewStatus: reviewedSourceIds.has(localId) ? 'APPROVED' : 'PENDING',
sourceStatus: 'AVAILABLE', statementGenerated: false, operationVersion: reviewedSourceIds.has(localId) ? 1 : 0 })
}
const feeSourceReviewMatch = requestPath.match(/\/fee-sources\/(\d+)\/review$/)
if (method === 'POST' && feeSourceReviewMatch) {
reviewedSourceIds.add(feeSourceReviewMatch[1])
const reviewed = { feeSourceId: feeSourceReviewMatch[1], reviewStatus: 'APPROVED',
sourceStatus: 'AVAILABLE', statementGenerated: false, operationVersion: 1 }
if (idem) idempotency.set(idem, reviewed)
return ok(reviewed)
}
if (method === 'POST' && requestPath.endsWith('/statements')) {
if (body.currency !== 'CNY') return { http: 200, body: { code: 'FIN_400' } }
if (String(body.customerId) !== '11') return { http: 200, body: { code: 'FIN_404' } }
@@ -6,6 +6,8 @@ const STATEMENT_LIST_PERMISSION = 'platform:finance:customer-statement:list'
const LEGACY_STATEMENT_VIEW_PERMISSION = 'platform:finance:bill:view'
const REQUIRED_PERMISSIONS = [
'platform:finance:customer-account:list',
'platform:finance:customer-arap:list',
'platform:finance:fee-source:review',
STATEMENT_LIST_PERMISSION,
'platform:finance:reconciliation:create',
'platform:finance:reconciliation:review',
@@ -116,6 +118,31 @@ function finish(context, start) {
failed: steps.filter((step) => !step.passed).length,
})
}
async function approveFixtureSources(context, fixture) {
const approved = []
for (const source of fixture.sources) {
const detailResponse = await context.request('GET', `${BASE}/fee-sources/${encodeURIComponent(source.feeSourceId)}`)
let detail = context.apiSuccess(MODULE, '回读本轮 Finance 费用来源审核状态', detailResponse,
`feeSourceId=${source.feeSourceId}`, OPTIONS)
if (!detail) return false
if (detail.reviewStatus === 'PENDING') {
const reviewResponse = await write(context, 'POST', `${BASE}/fee-sources/${encodeURIComponent(source.feeSourceId)}/review`, {
action: 'APPROVE', reason: context.marker, expectedVersion: Number(detail.operationVersion),
}, `fee-source-review-${source.feeSourceId}`)
detail = context.apiSuccess(MODULE, '审核本轮 Finance 费用来源', reviewResponse,
`feeSourceId=${source.feeSourceId}`, OPTIONS)
context.assert(MODULE, '费用来源审核后成为结算候选', reviewResponse,
detail?.reviewStatus === 'APPROVED' && detail?.sourceStatus === 'AVAILABLE'
&& detail?.statementGenerated === false,
`feeSourceId=${source.feeSourceId}, reviewStatus=${detail?.reviewStatus ?? 'missing'}, sourceStatus=${detail?.sourceStatus ?? 'missing'}`, OPTIONS)
}
if (detail?.reviewStatus !== 'APPROVED') return false
approved.push(source.feeSourceId)
}
fixture.root.approvedFinanceFeeSourceIds = approved
return approved.length === fixture.sources.length
}
function diagnostic(response) {
return { http: response?.http ?? null,
code: response?.body?.code == null ? null : String(response.body.code),
@@ -306,8 +333,7 @@ async function issueCompleteChain(context, statement) {
statement = await act(context, statement, 'ISSUE', 'chain-issue-final')
const actions = new Set(list(statement?.revisions).map((revision) => revision.action))
context.assert(MODULE, '完整链保留驳回、重开、调整、作废和再次出账 revision', null,
['RECONCILIATION_REJECT', 'RECONCILIATION_REOPEN', 'RECONCILIATION_ADJUST',
'RECONCILIATION_VOID', 'RECONCILIATION_ISSUE'].every((action) => actions.has(action)),
['REJECT', 'REOPEN', 'ADJUST', 'VOID', 'ISSUE'].every((action) => actions.has(action)),
`actions=${[...actions].join(',')}`, OPTIONS)
return statement
}
@@ -445,6 +471,13 @@ export async function run(context) {
const groups = [cutoffSources, ...(() => {
return [remainingSources.slice(0, 1), remainingSources.slice(1)]
})()]
if (!await approveFixtureSources(context, fixture)) {
blocker(context, '本轮 Finance 费用来源全部审核通过',
'费用来源未完成 PENDING -> APPROVED,禁止跳过审核直接生成对账单',
'finance-fee-source-review-failed')
finish(context, start)
return
}
const issuedStatements = []
for (let index = 0; index < groups.length; index += 1) {
const created = await createStatement(context, groups[index], `selected-${index + 1}`,
@@ -23,6 +23,12 @@ test('measurable inbound without fulfillment task remains pending', () => {
}, { http: 200, body: { code: 'A0404' } }, tenantId, orderId), false)
})
test('approval polling remains pending before the first fulfillment task response', () => {
assert.equal(approvedInboundReady({
summary: { inboundId: null, mainStatus: 'PENDING_REVIEW' },
}, null, tenantId, orderId), false)
})
test('approval convergence requires matching inbound state, order and customer task identity', () => {
const detail = { summary: { inboundId: '40001', mainStatus: 'WAIT_MEASURE' } }
assert.equal(approvedInboundReady(detail, taskResponse, tenantId, orderId), true)
+1 -1
View File
@@ -17,7 +17,7 @@ const ORDER_ESTIMATE_VOLUME_CBM = BOXES.reduce((total, box) =>
function id(value) { return value == null ? '' : String(value) }
function records(value) { return Array.isArray(value?.records) ? value.records : [] }
function pageOk(value) { return Array.isArray(value?.records) && Number.isFinite(Number(value?.total)) }
function success(response) { return response.http === 200 && String(response.body?.code) === '200' }
function success(response) { return response?.http === 200 && String(response.body?.code) === '200' }
function sleep(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)) }
function key(context, operation) { return `${context.marker}-${operation}`.slice(0, 128) }
export function approvedInboundReady(orderDetail, taskResponse, tenantId, orderId) {
@@ -147,12 +147,20 @@ export async function run(context) {
const primaryRequirement = { containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000 }
const realContainerNo = `TST-${suffix}-01`.slice(0, 64)
const wrongContainerNo = `TST-${suffix}-02`.slice(0, 64)
const agentResponse = await context.request('GET',
'/api/platform/v1/suppliers?tenantId=0&category=BOOKING&status=ACTIVE&page=1&size=20')
const agentPage = context.apiSuccess(MODULE, '查询放舱所需 ACTIVE 订舱代理', agentResponse, '', OPTIONS)
const bookingAgent = records(agentPage).find((item) => item?.category === 'BOOKING' && item?.status === 'ACTIVE')
context.assert(MODULE, '放舱订舱使用真实启用供应商', agentResponse,
Boolean(bookingAgent?.id && bookingAgent?.name),
`supplierId=${id(bookingAgent?.id) || 'missing'}`, OPTIONS)
if (!bookingAgent?.id || !bookingAgent?.name) return
const bookingDraft = {
customerId: tenantId, customerName: inbound?.summary?.customerName || `验收客户-${tenantId}`,
channelCode, warehouseId,
billOfLadingNo: `BL-${suffix}`.slice(0, 128),
shippingCompanyId: null, shippingCompanyName: null,
bookingAgentId: null, bookingAgentName: null,
shippingCompanyId: id(bookingAgent.id), shippingCompanyName: bookingAgent.name,
bookingAgentId: id(bookingAgent.id), bookingAgentName: bookingAgent.name,
vesselName: `验收船-${suffix}`.slice(0, 128), voyageNo: `V-${suffix}`.slice(0, 64),
departurePortCode: 'CNSZX', departurePortName: '深圳',
destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', placeOfDelivery: 'LAX',
@@ -372,10 +372,9 @@ test('container loading enforces channel isolation and stable PDA identity acros
assert.match(value, /id\(replacedTask\?\.cabinetNo\) === stableIdentity\.cabinetNo/)
assert.match(value, /afterReplacementSnapshot\?\.snapshotToken === beforeReplacementSnapshot\?\.snapshotToken/)
assert.match(value, /pdaIdentityStable: true/)
assert.match(value, /shippingCompanyId: null, shippingCompanyName: null/)
assert.match(value, /bookingAgentId: null, bookingAgentName: null/)
assert.doesNotMatch(value, /shippingCompanyName: `验收船公司-/)
assert.doesNotMatch(value, /bookingAgentName: `验收代理-/)
assert.match(value, /category=BOOKING&status=ACTIVE/)
assert.match(value, /shippingCompanyId: id\(bookingAgent\.id\), shippingCompanyName: bookingAgent\.name/)
assert.match(value, /bookingAgentId: id\(bookingAgent\.id\), bookingAgentName: bookingAgent\.name/)
assert.match(value, /const frozenTransport = Object\.fromEntries\(transportFields\.map/)
assert.match(value, /if \(!frozenTransportMatches\) return/)
assert.match(value, /expectedVersion: Number\(bookingCreatedDraft\?\.oceanBooking\?\.version\), reason: context\.marker/)