- P0/P1:影子总账追加调整、关账/退款/资金/积分幂等、成本版本追溯、对账订单项维度 - 迁移收敛:原 103–114 合并为 103_finance_foundation、104_finance_controls、105_work_order_consignment - 数据:L1 钱包 BIGINT/CHECK/奖励幂等;重复奖励审计口径修正与人工核对清单 - 报表:销售毛利缺失成本收入提示,旧收支页明确现金流口径 - 文档:同步迁移基线、合并评估、代码复核与上线说明
182 lines
7.0 KiB
TypeScript
182 lines
7.0 KiB
TypeScript
import assert from 'node:assert/strict'
|
||
import { readFile } from 'node:fs/promises'
|
||
import test from 'node:test'
|
||
|
||
import { resolveManualFulfillmentCost } from './manual-fulfillment-service.js'
|
||
|
||
test('人工成本:固定成本按配置积分 × 平台换算率', () => {
|
||
assert.deepEqual(
|
||
resolveManualFulfillmentCost({
|
||
creditQuantity: 4,
|
||
unitPriceFen: 100,
|
||
providedCostFen: undefined,
|
||
}),
|
||
{ costFen: 400, costMode: 'fixed' },
|
||
)
|
||
// 固定成本时手填金额不生效(客服零输入)。
|
||
assert.equal(
|
||
resolveManualFulfillmentCost({ creditQuantity: 2, unitPriceFen: 100, providedCostFen: 999 })
|
||
.costFen,
|
||
200,
|
||
)
|
||
})
|
||
|
||
test('人工成本:成本不固定必须手填正数金额', () => {
|
||
assert.deepEqual(
|
||
resolveManualFulfillmentCost({
|
||
creditQuantity: 0,
|
||
unitPriceFen: 1,
|
||
providedCostFen: 1234,
|
||
}),
|
||
{ costFen: 1234, costMode: 'variable' },
|
||
)
|
||
assert.throws(
|
||
() => resolveManualFulfillmentCost({ creditQuantity: 0, unitPriceFen: 1 }),
|
||
/成本不固定/,
|
||
)
|
||
assert.throws(
|
||
() => resolveManualFulfillmentCost({ creditQuantity: 0, unitPriceFen: 1, providedCostFen: 0 }),
|
||
/成本不固定/,
|
||
)
|
||
assert.throws(
|
||
() =>
|
||
resolveManualFulfillmentCost({
|
||
creditQuantity: 0,
|
||
unitPriceFen: 1,
|
||
providedCostFen: -1,
|
||
}),
|
||
/金额无效/,
|
||
)
|
||
})
|
||
|
||
test('人工确认必须单事务:扣款与快照同事务,且使用 with-client 版本', async () => {
|
||
const source = await readFile(new URL('./manual-fulfillment-service.ts', import.meta.url), 'utf8')
|
||
const start = source.indexOf('export async function confirmManualFulfillment')
|
||
const end = source.indexOf('async function resolveFundAccountId')
|
||
const body = source.slice(start, end)
|
||
|
||
assert.ok(body.includes('withTransaction('), '人工确认必须包在事务里')
|
||
assert.ok(
|
||
body.indexOf('withTransaction(') < body.indexOf('writeFundExpense(client'),
|
||
'备用金扣款必须在同一事务内执行',
|
||
)
|
||
assert.ok(
|
||
body.includes('consumePlatformCreditWithClient(client'),
|
||
'积分扣减必须复用调用方事务(不能再用自带事务的 consumePlatformCredit)',
|
||
)
|
||
assert.ok(
|
||
!body.includes('consumePlatformCredit('),
|
||
'confirm 内不得调用自带事务的 consumePlatformCredit',
|
||
)
|
||
assert.ok(
|
||
body.includes('FOR UPDATE OF oi') && body.includes("subject_type = 'manual_case'"),
|
||
'必须先锁订单项并复查快照,避免并发重复扣款',
|
||
)
|
||
assert.ok(
|
||
body.includes("'manual_case', $1") && body.includes('subjectSource'),
|
||
'manual_case 的 subject_id 必须是 order_item_id,资金来源只写 metadata',
|
||
)
|
||
})
|
||
|
||
test('人工确认前必须自动匹配履约配置,配置保存/定时任务也要回填', async () => {
|
||
const [manualSource, configSource, jobSource] = await Promise.all([
|
||
readFile(new URL('./manual-fulfillment-service.ts', import.meta.url), 'utf8'),
|
||
readFile(
|
||
new URL('../cost-center/manual-fulfillment-config-service.ts', import.meta.url),
|
||
'utf8',
|
||
),
|
||
readFile(new URL('../scheduler/cost-finance-sync-job.ts', import.meta.url), 'utf8'),
|
||
])
|
||
assert.ok(
|
||
manualSource.includes('matchManualFulfillmentConfigWithClient(client, orderItemId, now)'),
|
||
'确认时必须按 SKU 自动挂渠道',
|
||
)
|
||
assert.ok(
|
||
configSource.includes('rematchManualFulfillments({') &&
|
||
configSource.includes('skuKey: config.sku_key') &&
|
||
configSource.includes('dateFrom: shiftDate(today, -29)'),
|
||
'保存人工 SKU 配置后必须立即回填最近 30 天订单项',
|
||
)
|
||
assert.ok(
|
||
jobSource.includes('rematchManualFulfillments({') &&
|
||
jobSource.includes('onlyUnmatched: true') &&
|
||
jobSource.includes('dateFrom:'),
|
||
'定时同步必须自动回填未匹配的人工订单项,并限制在最近窗口',
|
||
)
|
||
assert.ok(
|
||
manualSource.includes('NULLIF(oi.sku_code') && configSource.includes('NULLIF(oi.sku_code'),
|
||
'SKU 匹配必须有 sku_code / sku_name 兜底,不能只认 spec_json',
|
||
)
|
||
})
|
||
|
||
test('人工成本缺失必须进入异常队列,且只监控首次配置之后的订单项', async () => {
|
||
const source = await readFile(
|
||
new URL('../cost-center/cost-snapshot-sync-service.ts', import.meta.url),
|
||
'utf8',
|
||
)
|
||
assert.ok(source.includes("'manual_cost_missing'"), '缺少人工成本缺失异常')
|
||
assert.ok(
|
||
source.includes('FROM manual_fulfillment_configs') &&
|
||
source.includes('s.basis_at >= c.enforce_from'),
|
||
'人工异常必须以首次配置时间为起点,避免历史人工单刷屏',
|
||
)
|
||
assert.ok(
|
||
source.includes("cs.subject_type = 'manual_case'") &&
|
||
source.includes('cs.order_item_id = s.order_item_id'),
|
||
'人工异常回填/收口必须按订单项关联人工快照',
|
||
)
|
||
})
|
||
|
||
test('迁移 104 提供订单项级人工成本唯一约束与 subject 语义约束', async () => {
|
||
const source = await readFile(
|
||
new URL('../../db/migrations/104_finance_controls.sql', import.meta.url),
|
||
'utf8',
|
||
)
|
||
assert.ok(source.includes('uq_cost_snapshots_manual_order_item'), '缺少订单项级人工快照唯一索引')
|
||
assert.ok(
|
||
source.includes("WHERE subject_type = 'manual_case' AND superseded_at IS NULL"),
|
||
'唯一索引必须只约束有效人工快照',
|
||
)
|
||
assert.ok(
|
||
source.includes('subject_id <> order_item_id') &&
|
||
source.includes('chk_cost_snapshots_manual_subject'),
|
||
'必须把历史 subject_id 归一并加语义约束',
|
||
)
|
||
})
|
||
|
||
test('总账/对账/关账/平台退款能力已接入', async () => {
|
||
const [migration, ledger, reconciliation, refund, manual] = await Promise.all([
|
||
readFile(new URL('../../db/migrations/104_finance_controls.sql', import.meta.url), 'utf8'),
|
||
readFile(new URL('./finance-ledger-service.ts', import.meta.url), 'utf8'),
|
||
readFile(new URL('./finance-reconciliation-service.ts', import.meta.url), 'utf8'),
|
||
readFile(new URL('./order-refund-service.ts', import.meta.url), 'utf8'),
|
||
readFile(new URL('./manual-fulfillment-service.ts', import.meta.url), 'utf8'),
|
||
])
|
||
assert.ok(
|
||
migration.includes('finance_journal_entries') && migration.includes('finance_journal_lines'),
|
||
)
|
||
assert.ok(migration.includes('finance_periods') && migration.includes('finance_reconciliations'))
|
||
assert.ok(
|
||
ledger.includes('finance_period_closed') && ledger.includes('syncFinanceLedgerFromFacts'),
|
||
)
|
||
assert.ok(reconciliation.includes('amount_mismatch') && reconciliation.includes('missing_order'))
|
||
assert.ok(
|
||
refund.includes('approveKuaishouIndustryRefund') &&
|
||
refund.includes("source: 'platform'") &&
|
||
refund.includes('platform_refund_not_supported'),
|
||
'平台退款必须走平台 API 并落 source=platform',
|
||
)
|
||
const kuaishouRoute = await readFile(
|
||
new URL('../../routes/admin/kuaishou-industry.ts', import.meta.url),
|
||
'utf8',
|
||
)
|
||
assert.ok(
|
||
kuaishouRoute.includes('/kuaishou-industry/orders/:orderId/refunds/approve-and-record'),
|
||
'平台退款路由必须明确收口在快手行业前缀下',
|
||
)
|
||
assert.ok(
|
||
manual.includes('fundAccountId') && manual.includes('creditOwnerAdminUserId'),
|
||
'管理员/财务必须可以指定客服账户代录',
|
||
)
|
||
})
|