feat(fulfillment): 增加统一结算与多渠道人工履约

This commit is contained in:
yml2213
2026-11-05 16:14:56 +08:00
parent 548070c2ad
commit 4e83dbb84c
43 changed files with 1582 additions and 334 deletions
@@ -21,6 +21,17 @@ test('人工成本:固定成本按配置积分 × 平台换算率', () => {
)
})
test('人工成本:备用金和统一结算金额支持两位小数', () => {
assert.deepEqual(
resolveManualFulfillmentCost({
creditQuantity: 12.34,
unitPriceFen: 100,
providedCostFen: undefined,
}),
{ costFen: 1234, costMode: 'fixed' },
)
})
test('人工成本:成本不固定必须手填正数金额', () => {
assert.deepEqual(
resolveManualFulfillmentCost({
@@ -17,6 +17,11 @@ import {
import { rebuildChannelSalesDailyFacts } from './sales-facts-channel-service.js'
import { syncFinanceLedgerFromFacts } from './finance-ledger-service.js'
import { scheduleVoucherConsumeForOrderItems } from '../fulfillment/voucher-consume-queue-service.js'
import {
calculateManualCostFen,
normalizeManualCreditQuantity,
parseManualChannelQuantity,
} from '../cost-center/manual-credit-quantity.js'
/**
* 人工履约待办与「确认履约」。
@@ -37,6 +42,92 @@ const PLATFORM_LABELS: Record<string, string> = {
lewan: '乐玩后台',
rock: 'ROCK 后台',
fund_cash: '备用金现金',
on_account: '统一结算',
}
const MANUAL_PLATFORM_KEYS = ['nanhang', 'lewan', 'rock', 'fund_cash', 'on_account'] as const
type ManualExecutionPlatformKey = (typeof MANUAL_PLATFORM_KEYS)[number]
type ManualConfiguredChannel = {
platformKey: ManualExecutionPlatformKey
platformLabel: string
creditQuantity: number
creditUnitPriceFen: number
costFen: number | null
costMode: 'fixed' | 'variable'
isFundCash: boolean
isOnAccount: boolean
enabled: boolean
}
async function loadManualPlatformUnitPrices(): Promise<Record<ManualExecutionPlatformKey, number>> {
const result = await query<{ profile_key: string; config_json: Record<string, unknown> }>(
`SELECT profile_key, config_json FROM fulfillment_profiles WHERE profile_key LIKE 'manual_%'`,
)
const prices: Record<ManualExecutionPlatformKey, number> = {
nanhang: 1,
lewan: 1,
rock: 100,
fund_cash: 100,
on_account: 100,
}
for (const row of result.rows) {
const key = row.profile_key.replace(/^manual_/, '') as ManualExecutionPlatformKey
const price = Number(row.config_json?.rechargeUnitPriceFen)
if (key in prices && Number.isInteger(price) && price > 0) {
prices[key] = price
}
}
return prices
}
function resolveManualConfiguredChannels(input: {
channelsJson: unknown
legacyPlatformKey: unknown
legacyCreditQuantity: unknown
legacyCostMode: unknown
legacyEnabled: unknown
unitPrices: Record<ManualExecutionPlatformKey, number>
}): ManualConfiguredChannel[] {
const raw = Array.isArray(input.channelsJson) ? input.channelsJson : []
const source =
raw.length > 0
? raw
: [
{
platformKey: input.legacyPlatformKey,
creditQuantity: input.legacyCreditQuantity,
costMode: input.legacyCostMode,
enabled: input.legacyEnabled,
},
]
const seen = new Set<ManualExecutionPlatformKey>()
const channels: ManualConfiguredChannel[] = []
for (const item of source) {
if (!item || typeof item !== 'object' || Array.isArray(item)) continue
const row = item as Record<string, unknown>
const platformKey = String(
row.platformKey ?? row.platform_key ?? '',
).trim() as ManualExecutionPlatformKey
if (!MANUAL_PLATFORM_KEYS.includes(platformKey) || seen.has(platformKey)) continue
if (row.enabled === false) continue
seen.add(platformKey)
const creditQuantity = parseManualChannelQuantity(row.creditQuantity, platformKey) ?? 0
const unitPriceFen = input.unitPrices[platformKey]
channels.push({
platformKey,
platformLabel: PLATFORM_LABELS[platformKey] || platformKey,
creditQuantity,
creditUnitPriceFen: unitPriceFen,
costFen: creditQuantity > 0 ? calculateManualCostFen(creditQuantity, unitPriceFen) : null,
costMode: creditQuantity > 0 ? 'fixed' : 'variable',
isFundCash: platformKey === 'fund_cash',
isOnAccount: platformKey === 'on_account',
enabled: true,
})
}
return channels
}
type PendingRow = {
@@ -55,6 +146,7 @@ type PendingRow = {
credit_quantity: string
cost_mode: string
credit_unit_price_fen: string
config_channels_json: unknown
fulfilled_at: string | null
}
@@ -67,6 +159,7 @@ type ConfirmContextRow = {
shop_id: string
sku_code: string
sku_name: string
internal_sku: string
revenue_fen: string
basis_at: string
canonical_product_id: number | null
@@ -76,6 +169,7 @@ type ConfirmContextRow = {
credit_quantity: string
cost_mode: string
credit_unit_price_fen: string
config_channels_json: unknown
work_order_id: number | null
work_order_no: string | null
work_product_rule_id: number | null
@@ -91,6 +185,7 @@ export async function listManualFulfillmentPending(
input: Record<string, unknown> = {},
session: AdminSession,
) {
const unitPrices = await loadManualPlatformUnitPrices()
const page = normalizePositiveInteger(input.page, 1, '页码')
const pageSize = Math.min(
100,
@@ -173,6 +268,7 @@ export async function listManualFulfillmentPending(
cfg.platform_key,
cfg.credit_quantity,
cfg.cost_mode,
cfg.channels_json AS config_channels_json,
COALESCE((fp.config_json->>'rechargeUnitPriceFen')::bigint, 1) AS credit_unit_price_fen,
fulfilled.fulfilled_at
FROM order_items oi
@@ -187,7 +283,7 @@ export async function listManualFulfillmentPending(
)
return {
items: result.rows.map(mapPending),
items: result.rows.map((row) => mapPending(row, unitPrices)),
pagination: { page, pageSize, total: Number(countResult.rows[0]?.total || 0) },
}
}
@@ -204,6 +300,7 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
'请选择订单项',
)
const now = nowIso()
const platformUnitPrices = await loadManualPlatformUnitPrices()
// 钱与成本必须同一事务:先锁订单项、复查未履约,再扣款、最后写快照。
// 任何一步失败(余额不足、成本版本缺失、重复快照)都整体回滚,不会留下「扣了钱没记成本」。
@@ -223,14 +320,16 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
o.shop_id,
oi.sku_code,
oi.sku_name,
resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku,
COALESCE(oi.revenue_amount_fen, 0)::bigint AS revenue_fen,
COALESCE(o.paid_at, o.created_at) AS basis_at,
oi.canonical_product_id,
COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) AS channel,
oi.fulfillment_config_id AS config_id,
COALESCE(cfg.platform_key, '') AS platform_key,
COALESCE(cfg.credit_quantity, 0)::bigint AS credit_quantity,
COALESCE(cfg.credit_quantity, 0) AS credit_quantity,
COALESCE(cfg.cost_mode, 'variable') AS cost_mode,
cfg.channels_json AS config_channels_json,
COALESCE((fp.config_json->>'rechargeUnitPriceFen')::bigint, 1) AS credit_unit_price_fen,
wo.id AS work_order_id,
wo.work_order_no,
@@ -284,13 +383,30 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
errorCode: 'manual_fulfillment_config_missing',
})
}
const platformKey = row.platform_key as PlatformCreditKey | 'fund_cash'
if (platformKey !== 'fund_cash' && !PLATFORM_CREDIT_KEYS.includes(platformKey)) {
throw createHttpError('该商品的人工履约平台配置无效,请重新配置', {
const configuredChannels = resolveManualConfiguredChannels({
channelsJson: row.config_channels_json,
legacyPlatformKey: row.platform_key,
legacyCreditQuantity: row.credit_quantity,
legacyCostMode: row.cost_mode,
legacyEnabled: true,
unitPrices: platformUnitPrices,
})
if (configuredChannels.length === 0) {
throw createHttpError('该商品没有可用的人工履约渠道,请重新配置', {
statusCode: 409,
errorCode: 'manual_fulfillment_config_invalid',
})
}
const requestedPlatformKey = String(payload.platformKey ?? payload.platform_key ?? '').trim()
const selectedChannel =
configuredChannels.find((channel) => channel.platformKey === requestedPlatformKey) ||
(configuredChannels.length === 1 ? configuredChannels[0] : undefined)
if (!selectedChannel) {
throw createHttpError('请选择本次实际执行的履约渠道', {
statusCode: 400,
errorCode: 'manual_fulfillment_platform_required',
})
}
// 锁内复查:并发/双击的第二个请求会等锁,醒来后看到快照直接拒绝,不会重复扣款。
const existing = await client.query<{ id: number }>(
@@ -306,20 +422,26 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
})
}
const creditQuantity = Number(row.credit_quantity || 0)
const unitPriceFen = Number(row.credit_unit_price_fen || 1)
if (platformKey !== 'fund_cash' && creditQuantity <= 0) {
const platformKey = selectedChannel.platformKey
const creditQuantity = selectedChannel.creditQuantity
const unitPriceFen = selectedChannel.creditUnitPriceFen
if (platformKey !== 'fund_cash' && platformKey !== 'on_account' && creditQuantity <= 0) {
throw createHttpError('该人工 SKU 的积分配置无效,请先在成本中心重新配置', {
statusCode: 409,
errorCode: 'manual_fulfillment_config_invalid',
})
}
const costPlan = resolveManualFulfillmentCost({
creditQuantity,
unitPriceFen,
providedCostFen: payload.costFen ?? payload.cost_fen ?? payload.amountFen,
})
const providedCostFen = payload.costFen ?? payload.cost_fen ?? payload.amountFen
const costPlan =
platformKey === 'on_account' &&
(providedCostFen === undefined || providedCostFen === null || providedCostFen === '')
? ({ costFen: 0, costMode: 'variable' } as const)
: resolveManualFulfillmentCost({
creditQuantity,
unitPriceFen,
providedCostFen,
})
const costFen = costPlan.costFen
const payeeName = optionalText(payload.payeeName ?? payload.payee_name, 120)
const note = optionalText(payload.note, 300)
@@ -342,9 +464,12 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
let fundExpenseId: number | null = null
let creditEntryId: number | null = null
let balanceAfter: number | null = null
let subjectSource: 'manual_expense' | 'platform_credit'
let onAccountBillId: number | null = null
let subjectSource: 'manual_expense' | 'platform_credit' | 'on_account'
if (platformKey === 'fund_cash') {
if (platformKey === 'on_account') {
subjectSource = 'on_account'
} else if (platformKey === 'fund_cash') {
// 备用金现金:复用唯一写入口,但把 client 传进去,和快照共用同一个事务。
const fundAccountId = await resolveFundAccountId(payload, session, client)
const { writeFundExpense } = await import('../admin/admin-fund-service.js')
@@ -457,11 +582,49 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
})
}
if (platformKey === 'on_account') {
const billNo = `OAB${Date.now()}${orderItemId}`
const bill = await client.query<{ id: number }>(
`
INSERT INTO on_account_bills (
bill_no, order_id, order_item_id, sku_key, canonical_product_id,
platform_order_id, amount_fen, status, payee_name, note, metadata_json,
created_by_admin_user_id, created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7, 'pending', $8, $9, $10::jsonb, $11, $12, $12
)
RETURNING id
`,
[
billNo,
row.order_id_resolved,
orderItemId,
String(row.internal_sku || row.sku_name || '').trim(),
row.canonical_product_id,
row.platform_order_id,
costFen,
payeeName,
note,
JSON.stringify({
platformKey,
configId: Number(row.config_id),
creditQuantity,
costMode: costPlan.costMode,
orderItemId,
}),
session.userId,
now,
],
)
onAccountBillId = Number(bill.rows[0]?.id || 0) || null
}
// 人工确认即渠道成立:写新三列,避免订单一直停在 pending。
const finalChannel = platformKey === 'on_account' ? 'on_account' : 'manual'
await client.query(
`
UPDATE order_items
SET fulfillment_channel = 'manual',
SET fulfillment_channel = $3,
fulfillment_status = 'assigned',
fulfillment_source = 'manual_confirm',
fulfillment_decided_at = $2,
@@ -469,7 +632,7 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
WHERE id = $1
AND fulfillment_status <> 'archived'
`,
[orderItemId, now],
[orderItemId, now, finalChannel],
)
// 人工缺成本异常立即收口,不等下一轮定时任务。
@@ -508,6 +671,7 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
fundExpenseId,
creditEntryId,
balanceAfter,
onAccountBillId,
}
})
@@ -754,10 +918,13 @@ export function resolveManualFulfillmentCost(input: {
unitPriceFen: number
providedCostFen?: unknown
}): { costFen: number; costMode: 'fixed' | 'variable' } {
const creditQuantity = Math.max(0, Math.trunc(Number(input.creditQuantity) || 0))
const creditQuantity = normalizeManualCreditQuantity(input.creditQuantity)
if (creditQuantity > 0) {
const unitPriceFen = Math.max(0, Math.trunc(Number(input.unitPriceFen) || 0))
return { costFen: creditQuantity * unitPriceFen, costMode: 'fixed' }
return {
costFen: calculateManualCostFen(creditQuantity, unitPriceFen),
costMode: 'fixed',
}
}
if (
input.providedCostFen === undefined ||
@@ -779,10 +946,16 @@ export function resolveManualFulfillmentCost(input: {
return { costFen, costMode: 'variable' }
}
function mapPending(row: PendingRow) {
const creditQuantity = Number(row.credit_quantity || 0)
const unitPriceFen = Number(row.credit_unit_price_fen || 1)
const isFundCash = row.platform_key === 'fund_cash'
function mapPending(row: PendingRow, unitPrices: Record<ManualExecutionPlatformKey, number>) {
const channels = resolveManualConfiguredChannels({
channelsJson: row.config_channels_json,
legacyPlatformKey: row.platform_key,
legacyCreditQuantity: row.credit_quantity,
legacyCostMode: row.cost_mode,
legacyEnabled: true,
unitPrices,
})
const primary = channels[0]
return {
orderItemId: Number(row.order_item_id),
orderId: Number(row.order_id),
@@ -795,14 +968,16 @@ function mapPending(row: PendingRow) {
revenueFen: Number(row.revenue_fen || 0),
paidAt: row.paid_at,
configId: Number(row.config_id),
platformKey: row.platform_key,
platformLabel: PLATFORM_LABELS[row.platform_key] || row.platform_key,
isFundCash,
creditQuantity,
creditUnitPriceFen: unitPriceFen,
costMode: row.cost_mode || 'variable',
platformKey: primary?.platformKey || '',
platformLabel: primary?.platformLabel || '',
isFundCash: primary?.isFundCash === true,
isOnAccount: primary?.isOnAccount === true,
creditQuantity: primary?.creditQuantity || 0,
creditUnitPriceFen: primary?.creditUnitPriceFen || 1,
costMode: primary?.costMode || 'variable',
/** 固定成本的金额;成本不固定时为 null,客服要手填。 */
costFen: creditQuantity > 0 ? creditQuantity * unitPriceFen : null,
costFen: primary?.costFen ?? null,
channels,
fulfilled: Boolean(row.fulfilled_at),
fulfilledAt: row.fulfilled_at || null,
}