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
@@ -64,6 +64,7 @@ export type FulfillmentChannel =
| 'affiliate_dash'
| 'third_party_voucher'
| 'manual'
| 'on_account'
| 'pending'
| 'archived'
| 'unclassified'
@@ -73,6 +74,7 @@ export const FULFILLMENT_CHANNELS = new Set<string>([
'affiliate_dash',
'third_party_voucher',
'manual',
'on_account',
'pending',
'archived',
'unclassified',
@@ -70,7 +70,7 @@ export function costModelAppliesTo(costModel: string, channel: string) {
if (costModel === 'affiliate_purchase') return channel === 'affiliate_dash'
if (costModel === 'work_order_reward') return channel === 'work_order'
if (costModel === 'third_party_voucher_cost') return channel === 'third_party_voucher'
if (costModel === 'manual_actual') return channel === 'manual'
if (costModel === 'manual_actual') return channel === 'manual' || channel === 'on_account'
return false
}
@@ -88,7 +88,7 @@ export function costModelScopeSql(channelExpression: string): string {
OR (ci.cost_model = 'third_party_voucher_cost'
AND ${channelExpression} = 'third_party_voucher')
OR (ci.cost_model = 'manual_actual'
AND ${channelExpression} = 'manual')
AND ${channelExpression} IN ('manual', 'on_account'))
)`
}
@@ -37,6 +37,7 @@ import type {
MissingCostItem,
OrderItemCostContext,
} from './cost-center-shared.js'
import { calculateManualCostFen, parseManualChannelQuantity } from './manual-credit-quantity.js'
export async function previewAdminCost(payload: JsonObject = {}) {
const context = await resolveCostContext(payload)
@@ -138,14 +139,14 @@ async function resolveManualCostHint(orderItemId: number | null) {
)
const row = result.rows[0]
if (!row) return null
const creditQuantity = Number(row.credit_quantity || 0)
const creditQuantity = parseManualChannelQuantity(row.credit_quantity, row.platform_key) ?? 0
const unitPriceFen = Number(row.unit_price_fen || 1)
return {
platformKey: row.platform_key,
creditQuantity,
unitPriceFen,
costMode: row.cost_mode === 'fixed' ? ('fixed' as const) : ('variable' as const),
costFen: creditQuantity > 0 ? creditQuantity * unitPriceFen : null,
costFen: creditQuantity > 0 ? calculateManualCostFen(creditQuantity, unitPriceFen) : null,
}
}
@@ -323,7 +323,7 @@ const CANDIDATE_SQL = `
LEFT JOIN work_product_rules wpr ON wpr.id = wo.product_rule_id
WHERE oi.fulfillment_status = 'assigned'
AND oi.fulfillment_channel IN (
'affiliate_dash', 'third_party_voucher', 'work_order', 'manual'
'affiliate_dash', 'third_party_voucher', 'work_order', 'manual', 'on_account'
)
`
@@ -0,0 +1,33 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
calculateManualCostFen,
normalizeManualCreditQuantity,
parseManualChannelQuantity,
parseManualCreditQuantity,
} from './manual-credit-quantity.js'
test('人工履约现金金额支持两位小数', () => {
assert.equal(normalizeManualCreditQuantity('12.345'), 12.35)
assert.equal(parseManualCreditQuantity('12.34'), 12.34)
assert.equal(parseManualCreditQuantity(-1), null)
})
test('积分渠道拒绝小数,现金渠道允许小数', () => {
assert.equal(parseManualChannelQuantity('2.5', 'nanhang'), null)
assert.equal(parseManualChannelQuantity('2', 'nanhang'), 2)
assert.equal(parseManualChannelQuantity('2.5', 'fund_cash'), 2.5)
assert.equal(parseManualChannelQuantity('2.5', 'on_account'), 2.5)
})
test('人工履约现金成本按分四舍五入', () => {
assert.equal(calculateManualCostFen(12.34, 100), 1234)
assert.equal(calculateManualCostFen(0.015, 100), 2)
})
test('负数和超出数据库精度的数量返回非法', () => {
assert.equal(parseManualCreditQuantity('abc'), null)
assert.equal(parseManualCreditQuantity(-0.01), null)
assert.equal(parseManualCreditQuantity('1000000000000000'), null)
})
@@ -0,0 +1,41 @@
const QUANTITY_SCALE = 100
const MAX_QUANTITY = 99_999_999_999.99
const CASH_CHANNELS = new Set(['fund_cash', 'on_account'])
/**
* 人工履约的现金金额统一保留 2 位小数。
* 积分渠道仍只接受整数,由配置入口按平台单独校验。
*/
export function normalizeManualCreditQuantity(value: unknown): number {
const parsed = Number(value)
if (!Number.isFinite(parsed) || parsed <= 0) return 0
const normalized = Math.round((parsed + Number.EPSILON) * QUANTITY_SCALE) / QUANTITY_SCALE
return Math.min(MAX_QUANTITY, normalized)
}
/** 解析接口入参;非法值返回 null,由调用方给出明确的业务错误。 */
export function parseManualCreditQuantity(value: unknown): number | null {
const parsed = parseManualSignedCreditQuantity(value)
return parsed !== null && parsed >= 0 ? parsed : null
}
/** 积分渠道只接受整数;备用金现金和统一结算接受两位小数。 */
export function parseManualChannelQuantity(value: unknown, platformKey: string): number | null {
const parsed = parseManualCreditQuantity(value)
if (parsed === null) return null
if (!CASH_CHANNELS.has(platformKey) && !Number.isInteger(parsed)) return null
return parsed
}
function parseManualSignedCreditQuantity(value: unknown): number | null {
const parsed = Number(value)
if (!Number.isFinite(parsed) || Math.abs(parsed) > MAX_QUANTITY) return null
return Math.round((parsed + Number.EPSILON) * QUANTITY_SCALE) / QUANTITY_SCALE
}
/** 成本金额始终落库为整数分;不足 1 分时按四舍五入处理。 */
export function calculateManualCostFen(creditQuantity: unknown, unitPriceFen: unknown): number {
const quantity = normalizeManualCreditQuantity(creditQuantity)
const unitPrice = Math.max(0, Math.trunc(Number(unitPriceFen) || 0))
return Math.round(quantity * unitPrice)
}
@@ -8,6 +8,11 @@ import { upsertInternalSkuChannel } from '../fulfillment/internal-sku-channel-se
import { reconcilePendingCarriers } from '../fulfillment/carrier-reconciliation-service.js'
import { canonicalizeInternalSku, containsCjk } from '../fulfillment/internal-sku-resolver.js'
import { refreshFactsForDates } from '../finance/refresh-facts.js'
import {
calculateManualCostFen,
normalizeManualCreditQuantity,
parseManualChannelQuantity,
} from './manual-credit-quantity.js'
/**
* 人工履约 SKU 配置服务。
@@ -15,7 +20,7 @@ import { refreshFactsForDates } from '../finance/refresh-facts.js'
* 口径(2026-10-24 确认):
* * 配置主键优先取内部 SKU(快手 spec_json.skuNick / 拼多多 spec_json.outSkuSn),
* 缺失时按 sku_code → sku_name 兜底;同名/同码算同一个商品,跨渠道共用一份配置;
* * 配置项:履约渠道(南航/乐玩/ROCK/备用金现金)+ 消耗积分 + 成本模式;
* * 配置项:履约渠道(南航/乐玩/ROCK/备用金现金/统一结算)+ 消耗量 + 成本模式;
* * 换算率固定:1 积分 = 1 分钱;
* * 消耗积分 0 = 成本不固定,由客服在确认履约时手填实际成本;
* * 「重新匹配」= 把配置回填到历史人工订单项,幂等可反复执行。
@@ -23,7 +28,7 @@ import { refreshFactsForDates } from '../finance/refresh-facts.js'
* 没有匹配引擎、没有打分:这就是一张带统计的 SKU 配置清单。
*/
export const MANUAL_PLATFORM_KEYS = ['nanhang', 'lewan', 'rock', 'fund_cash'] as const
export const MANUAL_PLATFORM_KEYS = ['nanhang', 'lewan', 'rock', 'fund_cash', 'on_account'] as const
export type ManualPlatformKey = (typeof MANUAL_PLATFORM_KEYS)[number]
/**
@@ -42,6 +47,7 @@ const PLATFORM_UNIT_PRICE_FEN: Record<ManualPlatformKey, number> = {
lewan: 1,
rock: 100,
fund_cash: 100,
on_account: 100,
}
const PLATFORM_LABELS: Record<ManualPlatformKey, string> = {
@@ -49,6 +55,7 @@ const PLATFORM_LABELS: Record<ManualPlatformKey, string> = {
lewan: '乐玩后台',
rock: 'ROCK 后台',
fund_cash: '备用金现金',
on_account: '统一结算',
}
/** 读取平台档案里配置的换算率;档案不存在时用默认表。 */
@@ -86,10 +93,18 @@ type ManualSkuRow = {
scan_to_date: string | null
config_id: number | null
config_platform_key: string | null
config_credit_quantity: number | null
config_credit_quantity: number | string | null
config_cost_mode: string | null
config_canonical_product_id: number | null
config_enabled: boolean | null
config_channels_json: unknown
}
type ManualChannelConfig = {
platformKey: ManualPlatformKey
creditQuantity: number
costMode: 'fixed' | 'variable'
enabled: boolean
}
/**
@@ -238,7 +253,10 @@ export async function listManualSkus(input: Record<string, unknown> = {}) {
}
if (platformKey) {
params.push(platformKey)
conditions.push(`cfg.platform_key = $${params.length}`)
conditions.push(`(
cfg.platform_key = $${params.length}
OR cfg.channels_json @> jsonb_build_array(jsonb_build_object('platformKey', $${params.length}))
)`)
}
if (provider) {
params.push(provider)
@@ -275,7 +293,8 @@ export async function listManualSkus(input: Record<string, unknown> = {}) {
cfg.credit_quantity AS config_credit_quantity,
cfg.cost_mode AS config_cost_mode,
cfg.canonical_product_id AS config_canonical_product_id,
cfg.enabled AS config_enabled
cfg.enabled AS config_enabled,
cfg.channels_json AS config_channels_json
FROM manual_sku_registry r
LEFT JOIN manual_fulfillment_configs cfg
ON canonicalize_internal_sku(cfg.sku_key) = canonicalize_internal_sku(r.sku_key)
@@ -340,8 +359,10 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) {
errorCode: 'manual_config_sku_key_required',
})
}
const platformKey = normalizePlatformKey(payload.platformKey ?? payload.platform_key)
const creditQuantity = normalizeCreditQuantity(payload.creditQuantity ?? payload.credit_quantity)
const channels = normalizeManualChannels(payload)
const primaryChannel = channels[0]!
const platformKey = primaryChannel.platformKey
const creditQuantity = primaryChannel.creditQuantity
const canonicalProductId = normalizeOptionalId(
payload.canonicalProductId ?? payload.canonical_product_id,
)
@@ -349,7 +370,7 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) {
const note = optionalText(payload.note, 300)
// 消耗积分为 0 = 成本不固定,客服确认履约时手填。
const costMode = creditQuantity > 0 ? 'fixed' : 'variable'
const costMode = primaryChannel.costMode
const unitPrices = await loadPlatformUnitPriceFen()
const now = nowIso()
@@ -411,7 +432,7 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) {
`UPDATE manual_fulfillment_configs
SET sku_key = $2, canonical_product_id = $3, platform_key = $4,
credit_quantity = $5, cost_mode = $6, enabled = $7, note = $8,
updated_at = $9
channels_json = $9::jsonb, updated_at = $10
WHERE id = $1
RETURNING *`,
[
@@ -423,16 +444,27 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) {
costMode,
enabled,
note,
JSON.stringify(channels),
now,
],
)
: await client.query(
`INSERT INTO manual_fulfillment_configs (
sku_key, canonical_product_id, platform_key, credit_quantity,
cost_mode, enabled, note, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8)
cost_mode, enabled, note, channels_json, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $9)
RETURNING *`,
[skuKey, resolvedProductId, platformKey, creditQuantity, costMode, enabled, note, now],
[
skuKey,
resolvedProductId,
platformKey,
creditQuantity,
costMode,
enabled,
note,
JSON.stringify(channels),
now,
],
)
return requiredRow(result.rows[0], '保存人工履约配置失败')
})
@@ -776,7 +808,7 @@ async function attachCostVersionsForManualConfigs() {
)
SELECT
$1, 'product', '', '', cfg.canonical_product_id, NULL,
(cfg.credit_quantity * (fp.config_json->>'rechargeUnitPriceFen')::bigint), 0,
ROUND(cfg.credit_quantity * (fp.config_json->>'rechargeUnitPriceFen')::bigint, 0)::bigint,
'1970-01-01T00:00:00+08:00', 'manual', 'active',
'人工履约配置:积分按平台换算率折算',
NOW(), NOW()
@@ -817,10 +849,11 @@ export async function getManualFulfillmentConfig(skuKey: string) {
sku_key: string
canonical_product_id: number | null
platform_key: string
credit_quantity: number
credit_quantity: number | string
cost_mode: string
enabled: boolean
note: string
channels_json: unknown
created_at: string
updated_at: string
}>(
@@ -842,16 +875,86 @@ export async function listManualPlatformOptions() {
items: MANUAL_PLATFORM_KEYS.map((key) => ({
key,
label: PLATFORM_LABELS[key],
isCredit: key !== 'fund_cash',
isCredit: key !== 'fund_cash' && key !== 'on_account',
creditUnitPriceFen: unitPrices[key],
})),
}
}
function normalizeStoredChannels(input: {
channels_json: unknown
platform_key: string
credit_quantity: number | string
cost_mode: string
enabled: boolean
}): ManualChannelConfig[] {
const raw = Array.isArray(input.channels_json) ? input.channels_json : []
const parsed: ManualChannelConfig[] = []
const seen = new Set<ManualPlatformKey>()
for (const item of raw) {
if (!item || typeof item !== 'object' || Array.isArray(item)) continue
const row = item as Record<string, unknown>
const key = String(row.platformKey || row.platform_key || '').trim() as ManualPlatformKey
if (!MANUAL_PLATFORM_KEYS.includes(key) || seen.has(key)) continue
seen.add(key)
const creditQuantity =
parseManualChannelQuantity(row.creditQuantity ?? row.credit_quantity ?? 0, key) ?? 0
parsed.push({
platformKey: key,
creditQuantity,
costMode: creditQuantity > 0 ? 'fixed' : 'variable',
enabled: row.enabled !== false,
})
}
if (parsed.length > 0) return parsed
const key = String(input.platform_key || '').trim() as ManualPlatformKey
if (!MANUAL_PLATFORM_KEYS.includes(key)) return []
const creditQuantity = parseManualChannelQuantity(input.credit_quantity, key) ?? 0
return [
{
platformKey: key,
creditQuantity,
costMode: creditQuantity > 0 ? 'fixed' : 'variable',
enabled: input.enabled !== false,
},
]
}
function mapConfiguredChannels(
channels: ManualChannelConfig[],
unitPrices: Record<ManualPlatformKey, number>,
) {
return channels.map((channel) => {
const creditUnitPriceFen = unitPrices[channel.platformKey] ?? DEFAULT_CREDIT_UNIT_PRICE_FEN
return {
platformKey: channel.platformKey,
platformLabel: PLATFORM_LABELS[channel.platformKey] || channel.platformKey,
creditQuantity: channel.creditQuantity,
costMode: channel.costMode,
costFen: calculateManualCostFen(channel.creditQuantity, creditUnitPriceFen),
creditUnitPriceFen,
enabled: channel.enabled,
isFundCash: channel.platformKey === 'fund_cash',
isOnAccount: channel.platformKey === 'on_account',
}
})
}
function mapManualSku(row: ManualSkuRow, unitPrices: Record<ManualPlatformKey, number>) {
const creditQuantity = Number(row.config_credit_quantity || 0)
const creditQuantity = normalizeManualCreditQuantity(row.config_credit_quantity)
const platformKey = (row.config_platform_key || '') as ManualPlatformKey
const unitPriceFen = unitPrices[platformKey] ?? DEFAULT_CREDIT_UNIT_PRICE_FEN
const channels =
row.config_id === null
? []
: normalizeStoredChannels({
channels_json: row.config_channels_json,
platform_key: row.config_platform_key || '',
credit_quantity: row.config_credit_quantity || 0,
cost_mode: row.config_cost_mode || 'variable',
enabled: row.config_enabled !== false,
})
return {
id: Number(row.id),
skuKey: row.sku_key,
@@ -873,12 +976,13 @@ function mapManualSku(row: ManualSkuRow, unitPrices: Record<ManualPlatformKey, n
platformLabel: PLATFORM_LABELS[platformKey] || '',
creditQuantity,
costMode: row.config_cost_mode || 'variable',
costFen: creditQuantity * unitPriceFen,
costFen: calculateManualCostFen(creditQuantity, unitPriceFen),
creditUnitPriceFen: unitPriceFen,
canonicalProductId: row.config_canonical_product_id
? Number(row.config_canonical_product_id)
: null,
enabled: row.config_enabled !== false,
channels: mapConfiguredChannels(channels, unitPrices),
}
: null,
}
@@ -890,18 +994,20 @@ function mapManualConfig(
sku_key: string
canonical_product_id: number | null
platform_key: string
credit_quantity: number
credit_quantity: number | string
cost_mode: string
enabled: boolean
note: string
channels_json: unknown
created_at: string
updated_at: string
},
unitPrices: Record<ManualPlatformKey, number>,
) {
const creditQuantity = Number(row.credit_quantity || 0)
const creditQuantity = normalizeManualCreditQuantity(row.credit_quantity)
const platformKey = row.platform_key as ManualPlatformKey
const unitPriceFen = unitPrices[platformKey] ?? DEFAULT_CREDIT_UNIT_PRICE_FEN
const channels = normalizeStoredChannels(row)
return {
id: Number(row.id),
skuKey: row.sku_key,
@@ -910,9 +1016,10 @@ function mapManualConfig(
platformLabel: PLATFORM_LABELS[platformKey] || row.platform_key,
creditQuantity,
costMode: row.cost_mode,
costFen: creditQuantity * unitPriceFen,
costFen: calculateManualCostFen(creditQuantity, unitPriceFen),
creditUnitPriceFen: unitPriceFen,
enabled: row.enabled,
channels: mapConfiguredChannels(channels, unitPrices),
note: row.note || '',
createdAt: row.created_at,
updatedAt: row.updated_at,
@@ -930,14 +1037,67 @@ function normalizePlatformKey(value: unknown): ManualPlatformKey {
return text as ManualPlatformKey
}
function normalizeCreditQuantity(value: unknown) {
const parsed = Number(value)
if (!Number.isFinite(parsed) || parsed < 0 || !Number.isInteger(parsed)) {
throw createHttpError('消耗积分必须是非负整数', {
statusCode: 400,
errorCode: 'manual_config_credit_invalid',
function normalizeManualChannels(payload: JsonObject): ManualChannelConfig[] {
const rawChannels = Array.isArray(payload.channels) ? payload.channels : []
const source =
rawChannels.length > 0
? rawChannels
: [
{
platformKey: payload.platformKey ?? payload.platform_key,
creditQuantity: payload.creditQuantity ?? payload.credit_quantity,
enabled: payload.enabled,
},
]
const seen = new Set<ManualPlatformKey>()
const channels: ManualChannelConfig[] = []
for (const raw of source) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue
const item = raw as JsonObject
const platformKey = normalizePlatformKey(item.platformKey ?? item.platform_key)
if (seen.has(platformKey)) continue
seen.add(platformKey)
const creditQuantity = normalizeCreditQuantity(
item.creditQuantity ?? item.credit_quantity ?? 0,
platformKey,
)
channels.push({
platformKey,
creditQuantity,
costMode: creditQuantity > 0 ? 'fixed' : 'variable',
enabled: item.enabled === false ? false : true,
})
}
if (channels.length === 0) {
throw createHttpError('请至少配置一个履约渠道', {
statusCode: 400,
errorCode: 'manual_config_channels_required',
})
}
if (channels.length > 10) {
throw createHttpError('单个 SKU 最多配置 10 个履约渠道', {
statusCode: 400,
errorCode: 'manual_config_channels_too_many',
})
}
return channels
}
function normalizeCreditQuantity(value: unknown, platformKey: ManualPlatformKey) {
const parsed = parseManualChannelQuantity(value, platformKey)
if (parsed === null) {
throw createHttpError(
platformKey === 'fund_cash' || platformKey === 'on_account'
? '金额必须是大于等于 0 且最多 2 位小数的数字'
: '积分渠道的消耗积分必须是非负整数',
{
statusCode: 400,
errorCode: 'manual_config_credit_invalid',
},
)
}
return parsed
}
@@ -822,7 +822,7 @@ export async function getAdminChannelMappingDiagnostics(payload: JsonObject = {}
const modes = channel
? [channel]
: ['work_order', 'affiliate_dash', 'third_party_voucher', 'manual']
: ['work_order', 'affiliate_dash', 'third_party_voucher', 'manual', 'on_account']
// 顺序跑:并行会同时扫 cost_exceptions,实测 4 路并发比串行更慢(I/O 争抢)。
const aggregates: Array<{ groupTotal: number; itemTotal: number }> = []
for (const mode of modes) {
@@ -874,7 +874,7 @@ export async function listAdminUnmappedMappingGroups(payload: JsonObject = {}) {
// 按履约方式拆开统计:命中索引,单模式约 90ms;「全部」时顺序跑四种模式。
const modes = channel
? [channel]
: ['work_order', 'affiliate_dash', 'third_party_voucher', 'manual']
: ['work_order', 'affiliate_dash', 'third_party_voucher', 'manual', 'on_account']
const totals: Array<{ groupTotal: number; itemTotal: number }> = []
for (const mode of modes) {
const totalParams = [...params, mode]