feat(fulfillment): 增加统一结算与多渠道人工履约
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user