Files
order_site/apps/backend/src/services/finance/manual-fulfillment-service.ts
T

1119 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { PoolClient } from 'pg'
import { query, withTransaction } from '../../db/client.js'
import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
import { nowIso } from '../../utils/time.js'
import type { AdminSession } from '../admin/admin-auth-service.js'
import {
buildManualFulfillmentNote,
buildManualFulfillmentReference,
} from './manual-fulfillment-note.js'
import {
consumePlatformCreditWithClient,
PLATFORM_CREDIT_KEYS,
type PlatformCreditKey,
} from '../cost-center/platform-credit-service.js'
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 {
attachFulfillmentConfigurationsWithExecutor,
transitionFulfillmentStateWithExecutor,
} from '../fulfillment/fulfillment-state-service.js'
import { upsertAggregatedException } from '../cost-center/cost-exception-service.js'
import {
calculateManualCostFen,
normalizeManualCreditQuantity,
parseManualChannelQuantity,
} from '../cost-center/manual-credit-quantity.js'
/**
* 人工履约待办与「确认履约」。
*
* 口径(2026-10-24 确认):
* * 客服人工履约有四个钱包:南航积分 / 乐玩积分 / ROCK 积分 / 备用金现金;
* * 每个内部 SKU 的「渠道 + 消耗积分 + 成本」在成本中心提前配好,客服不选;
* * 客服点「确认履约」:固定成本 → 零输入;成本不固定 → 只填金额;
* * 消耗积分从对应平台的积分钱包扣,备用金现金走现有 fund_* 链路;
* * 同时写 cost_snapshots(actual),成本 = 消耗积分 × 平台换算率。
*
* 「已履约」的判定:order_items 上存在该订单项的有效成本快照。
* 人工单不建 fulfillment_tasks —— 那是自动发货通道的体系,人工单历来不走它。
*/
const PLATFORM_LABELS: Record<string, string> = {
nanhang: '南航平台',
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 = {
order_item_id: number
order_id: number
platform_order_id: string
provider: string
shop_id: string
sku_code: string
sku_name: string
internal_sku: string
revenue_fen: string
paid_at: string
config_id: number
platform_key: string
credit_quantity: string
cost_mode: string
credit_unit_price_fen: string
config_channels_json: unknown
fulfilled_at: string | null
}
type ConfirmContextRow = {
order_item_id: number
order_id: number
order_id_resolved: number
platform_order_id: string
provider: string
shop_id: string
sku_code: string
sku_name: string
internal_sku: string
revenue_fen: string
basis_at: string
canonical_product_id: number | null
channel: string
config_id: number | null
platform_key: string
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
}
/**
* 列出客服的人工履约待办。
*
* 只看「已配好渠道」的人工订单项;未配置的 SKU 不出现在待办里
* (那是配置缺口,不是客服能处理的)。
*/
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,
normalizePositiveInteger(input.pageSize ?? input.page_size, 20, '每页数量'),
)
const keyword = String(input.keyword || '').trim()
const status = String(input.status || '').trim()
const conditions = [
"oi.fulfillment_status = 'assigned'",
"oi.fulfillment_channel = 'manual'",
'cfg.id IS NOT NULL',
]
const params: unknown[] = []
if (status === 'pending') {
conditions.push('fulfilled.fulfilled_at IS NULL')
} else if (status === 'fulfilled') {
conditions.push('fulfilled.fulfilled_at IS NOT NULL')
}
// 订单页按单打开确认框:只取该订单的待办项。
const orderId = Number(input.orderId ?? input.order_id ?? 0)
if (Number.isFinite(orderId) && orderId > 0) {
params.push(Math.trunc(orderId))
conditions.push(`oi.order_id = $${params.length}`)
}
if (keyword) {
params.push(`%${escapeLike(keyword)}%`)
const placeholder = `$${params.length}`
conditions.push(
`(oi.sku_name ILIKE ${placeholder}
OR oi.sku_code ILIKE ${placeholder}
OR o.platform_order_id ILIKE ${placeholder}
OR COALESCE(NULLIF(oi.spec_json->>'skuNick',''), NULLIF(oi.spec_json->>'outSkuSn','')) ILIKE ${placeholder})`,
)
}
const where = conditions.join(' AND ')
const baseCte = `
WITH fulfilled AS (
SELECT
cs.order_item_id,
MAX(cs.created_at) AS fulfilled_at
FROM cost_snapshots cs
WHERE cs.subject_type = 'manual_case' AND cs.superseded_at IS NULL AND reversed_at IS NULL
GROUP BY cs.order_item_id
)
`
const countResult = await query<{ total: number }>(
`${baseCte}
SELECT COUNT(*)::int AS total
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN manual_fulfillment_configs cfg ON cfg.id = oi.fulfillment_config_id
LEFT JOIN fulfilled ON fulfilled.order_item_id = oi.id
WHERE ${where}`,
params,
)
const listParams = [...params, pageSize, (page - 1) * pageSize]
const result = await query<PendingRow>(
`${baseCte}
SELECT
oi.id AS order_item_id,
oi.order_id,
o.platform_order_id,
o.provider,
o.shop_id,
oi.sku_code,
oi.sku_name,
COALESCE(
NULLIF(oi.spec_json->>'skuNick',''),
NULLIF(oi.spec_json->>'outSkuSn',''),
NULLIF(oi.sku_code,''),
NULLIF(oi.sku_name,'')
) AS internal_sku,
COALESCE(oi.revenue_amount_fen, 0)::bigint AS revenue_fen,
COALESCE(o.paid_at, o.created_at) AS paid_at,
cfg.id AS config_id,
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
JOIN orders o ON o.id = oi.order_id
JOIN manual_fulfillment_configs cfg ON cfg.id = oi.fulfillment_config_id
LEFT JOIN fulfillment_profiles fp ON fp.profile_key = 'manual_' || cfg.platform_key
LEFT JOIN fulfilled ON fulfilled.order_item_id = oi.id
WHERE ${where}
ORDER BY fulfilled.fulfilled_at NULLS FIRST, oi.id DESC
LIMIT $${listParams.length - 1} OFFSET $${listParams.length}`,
listParams,
)
return {
items: result.rows.map((row) => mapPending(row, unitPrices)),
pagination: { page, pageSize, total: Number(countResult.rows[0]?.total || 0) },
}
}
/**
* 确认履约:扣额度 + 写成本快照。
*
* 固定成本(消耗积分 > 0):直接用配置值,零输入。
* 成本不固定(消耗积分 = 0):客服必须填实际成本金额。
*/
export async function confirmManualFulfillment(payload: JsonObject, session: AdminSession) {
const orderItemId = normalizeRequiredId(
payload.orderItemId ?? payload.order_item_id,
'请选择订单项',
)
const now = nowIso()
const platformUnitPrices = await loadManualPlatformUnitPrices()
// 钱与成本必须同一事务:先锁订单项、复查未履约,再扣款、最后写快照。
// 任何一步失败(余额不足、成本版本缺失、重复快照)都整体回滚,不会留下「扣了钱没记成本」。
const result = await withTransaction(async (client) => {
// 配置可能晚于订单创建:确认前按 SKU 再匹配一次(与定时 rematch 同口径),
// 保证「配置好之后的新单」不需要人工再点一次「重新匹配」。
await matchManualFulfillmentConfigWithClient(client, orderItemId)
const context = await client.query<ConfirmContextRow>(
`
SELECT
oi.id AS order_item_id,
oi.order_id,
o.id AS order_id_resolved,
o.platform_order_id,
o.provider,
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) 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,
wo.product_rule_id AS work_product_rule_id
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
LEFT JOIN manual_fulfillment_configs cfg ON cfg.id = oi.fulfillment_config_id
LEFT JOIN fulfillment_profiles fp ON fp.profile_key = 'manual_' || cfg.platform_key
LEFT JOIN LATERAL (
SELECT id, product_rule_id, work_order_no FROM work_orders
WHERE order_item_id = oi.id ORDER BY id DESC LIMIT 1
) wo ON TRUE
WHERE oi.id = $1
LIMIT 1
FOR UPDATE OF oi
`,
[orderItemId],
)
const row = context.rows[0]
if (!row) {
throw createHttpError('订单项不存在', {
statusCode: 404,
errorCode: 'manual_fulfillment_order_item_not_found',
})
}
if (row.channel !== 'manual') {
throw createHttpError('该订单项不是人工履约', {
statusCode: 409,
errorCode: 'manual_fulfillment_not_manual',
})
}
// 已关账期间不能再改成本事实;需要先反关账,避免“扣了钱但销售事实/总账没记”。
const period = await client.query<{ status: string }>(
`SELECT p.status
FROM finance_periods p
WHERE p.period_key = to_char(($1::timestamptz AT TIME ZONE 'Asia/Shanghai'), 'YYYY-MM')
LIMIT 1`,
[row.basis_at],
)
if (period.rows[0]?.status === 'closed') {
throw createHttpError('该订单所属期间已关账,请先反关账后再确认履约', {
statusCode: 409,
errorCode: 'manual_fulfillment_period_closed',
})
}
if (!row.config_id) {
throw createHttpError('该商品还没有配置履约渠道,请先在成本中心配置', {
statusCode: 409,
errorCode: 'manual_fulfillment_config_missing',
})
}
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',
})
}
if (selectedChannel.platformKey === 'on_account') {
const existingBill = await client.query<{ id: number }>(
`SELECT id FROM on_account_bills
WHERE order_item_id = $1 AND status IN ('pending', 'reconciled')
LIMIT 1`,
[orderItemId],
)
if (existingBill.rows[0]) {
throw createHttpError('该订单项已生成统一结算账单,不能重复操作', {
statusCode: 409,
errorCode: 'manual_fulfillment_on_account_already_exists',
})
}
}
// 锁内复查:并发/双击的第二个请求会等锁,醒来后看到快照直接拒绝,不会重复扣款。
const existing = await client.query<{ id: number }>(
`SELECT id FROM cost_snapshots
WHERE subject_type = 'manual_case' AND order_item_id = $1 AND superseded_at IS NULL AND reversed_at IS NULL
LIMIT 1`,
[orderItemId],
)
if (existing.rows[0]) {
throw createHttpError('该订单项已确认过履约,不能重复操作', {
statusCode: 409,
errorCode: 'manual_fulfillment_already_done',
})
}
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 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 onAccountCostPending = platformKey === 'on_account' && costFen <= 0
const payeeName = optionalText(payload.payeeName ?? payload.payee_name, 120)
const note = optionalText(payload.note, 300)
const fulfillmentReference = buildManualFulfillmentReference({
workOrderNo: row.work_order_no,
platformOrderId: row.platform_order_id,
orderItemId,
})
// 扣款前先确定最终成本模式。统一结算未填金额时只生成待对账账单,
// 不写 0 元 actual 成本快照,避免利润被虚增。
let fundExpenseId: number | null = null
let creditEntryId: number | null = null
let balanceAfter: number | null = null
let onAccountBillId: number | null = null
let subjectSource: 'manual_expense' | 'platform_credit' | 'on_account'
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')
const written = await writeFundExpense(client, {
agentAccountId: fundAccountId,
amountFen: costFen,
category: 'other',
payeeType: 'other',
payeeName: payeeName || '人工履约',
orderId: Number(row.order_id_resolved),
orderItemId,
paidAt: now,
evidence: [],
note: buildManualFulfillmentNote({
skuName: row.sku_name,
reference: fulfillmentReference,
note,
maxLength: 500,
}),
actorUserId: session.userId,
// 同一订单可能有多个同价人工项:幂等由订单项锁 + 快照唯一索引保证,
// 这里放开 24 小时同金额同收款方的通用防重,避免误拦第二个订单项。
allowDuplicate: true,
})
fundExpenseId = Number(written.expense.id)
balanceAfter = written.balanceAfterFen
subjectSource = 'manual_expense'
} else {
// 管理员/财务可以代录到指定客服的积分账户;客服只能用自己的。
const creditOwnerAdminUserId = await resolveCreditOwnerAdminUserId(payload, session, client)
const consumed = await consumePlatformCreditWithClient(client, {
ownerAdminUserId: creditOwnerAdminUserId,
platformKey,
quantity: creditQuantity,
relatedTaskId: null,
platformRef: row.work_order_no || row.platform_order_id,
note: buildManualFulfillmentNote({
skuName: row.sku_name,
reference: fulfillmentReference,
note,
maxLength: 300,
}),
})
creditEntryId = consumed.entryId
balanceAfter = consumed.balanceAfter
subjectSource = 'platform_credit'
}
let snapshotId: number | null = null
if (!onAccountCostPending) {
const costItemId = await resolveManualCostItemIdWithClient(client)
const versionId = await resolveCostVersionIdWithClient(client, costItemId, row, now)
if (!versionId) {
throw createHttpError('该成本项没有可用的成本版本,请先在成本中心配置后重试', {
statusCode: 409,
errorCode: 'manual_fulfillment_cost_version_missing',
})
}
// subject_id 固定为 order_item_id:不再与 manual_expenses.id /
// platform_credit_entries.id 混用,资金流水只放在 metadata 里。
// subject_type 仍为 manual_case。
const snapshot = await client.query<{ id: number }>(
`
INSERT INTO cost_snapshots (
subject_type, subject_id, order_id, order_item_id, work_order_id,
cost_item_id, cost_version_id, canonical_product_id,
quantity, unit_amount_fen, total_amount_fen, snapshot_stage, source,
metadata_json, cost_basis_at, resolved_at, created_at
) VALUES (
'manual_case', $1, $2, $1, $3,
$4, $5, $6,
1, $7, $7, 'actual', 'manual_input',
$8::jsonb, $9, $9, $9
)
ON CONFLICT DO NOTHING
RETURNING id
`,
[
orderItemId,
row.order_id_resolved,
row.work_order_id,
costItemId,
versionId,
row.canonical_product_id,
costFen,
JSON.stringify({
source: 'manual_fulfillment_confirm',
subjectSource,
configId: Number(row.config_id),
platformKey,
platformLabel: PLATFORM_LABELS[platformKey] || platformKey,
creditOwnerAdminUserId:
platformKey === 'fund_cash'
? null
: Number(payload.creditOwnerAdminUserId || 0) || null,
creditQuantity,
creditUnitPriceFen: unitPriceFen,
costMode: costPlan.costMode,
costFen,
fundExpenseId,
creditEntryId,
balanceAfter,
payeeName,
note,
fulfilledBy: {
userId: Number(session.userId || 0),
displayName: String(session.displayName || ''),
role: String(session.role || ''),
},
}),
now,
],
)
snapshotId = snapshot.rows[0]?.id ?? null
if (!snapshotId) {
// 订单项唯一索引兜底:任何竞态都会走到这里,抛错回滚本次扣款。
throw createHttpError('该订单项已有人工成本快照,不能重复录入', {
statusCode: 409,
errorCode: 'manual_fulfillment_snapshot_conflict',
})
}
}
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 transitionFulfillmentStateWithExecutor(client, {
orderItemId,
patch: {
channel: finalChannel,
status: 'assigned',
source: 'manual_confirm',
decidedAt: now,
decidedBy: Number(session.userId || 0) || null,
pendingReason: null,
pendingDetail: null,
},
eventType: 'manual_fulfillment_confirmed',
actor: { type: 'finance', id: session.userId },
reason: platformKey,
})
if (onAccountCostPending) {
await upsertAggregatedException({
reason: 'on_account_cost_pending',
scopeKey: `on_account_cost_pending|-|-|${row.internal_sku}|on_account`,
orderItemId,
detail: {
billId: onAccountBillId,
internalSku: row.internal_sku,
platformOrderId: row.platform_order_id,
},
client,
})
} else {
// 人工缺成本异常立即收口,不等下一轮定时任务。
await client.query(
`UPDATE cost_exceptions
SET status = 'resolved', resolved_at = $2
WHERE subject_type = 'order_item'
AND subject_id = $1
AND reason = 'manual_cost_missing'
AND status = 'open'`,
[orderItemId, now],
)
}
if (!onAccountCostPending) {
// v2 聚合异常:成本已经落账后,删除本条明细并自动关闭空异常头。
await client.query(`DELETE FROM cost_exception_items WHERE order_item_id = $1`, [orderItemId])
await client.query(
`
UPDATE cost_exceptions ce
SET status = 'resolved', resolved_at = $1, item_count = 0, last_seen_at = $1
WHERE ce.status <> 'resolved'
AND NOT EXISTS (
SELECT 1 FROM cost_exception_items i WHERE i.exception_id = ce.id
)
`,
[now],
)
}
return {
orderItemId,
orderId: Number(row.order_id_resolved),
costSnapshotId: snapshotId,
platformKey,
platformLabel: PLATFORM_LABELS[platformKey] || platformKey,
creditQuantity,
costFen,
fundExpenseId,
creditEntryId,
balanceAfter,
onAccountBillId,
costPending: onAccountCostPending,
}
})
await scheduleVoucherConsumeForOrderItems([orderItemId], 'order_received').catch(() => undefined)
// 成本已落快照,立刻重算该业务日的事实与总账;失败不阻断确认(定时任务会兜底)。
await refreshFactsForOrderItem(orderItemId)
return result
}
/**
* 人工确认后按「订单业务日期」重算销售事实与总账。
*
* 事实任务默认只回看最近几天,确认一笔较早的订单时不会自动覆盖,
* 所以这里按该订单自己的业务日期精确重算。
*/
export async function refreshFactsForOrderItem(orderItemId: number): Promise<void> {
try {
const basis = await query<{ stat_date: string | null }>(
`
SELECT (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date::text
AS stat_date
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE oi.id = $1
`,
[orderItemId],
)
const statDate = basis.rows[0]?.stat_date
if (!statDate) return
await rebuildChannelSalesDailyFacts({ dateFrom: statDate, dateTo: statDate })
await syncFinanceLedgerFromFacts({ dateFrom: statDate, dateTo: statDate })
} catch (error) {
// 成本已入账,事实重算失败只影响报表时效,交给定时任务兜底。
void error
}
}
/** 客服自己的备用金账户(管理员代录时用指定账户)。 */
const PRIVILEGED_ROLES = new Set(['admin', 'finance', 'operator'])
function canActOnBehalf(session: AdminSession) {
return PRIVILEGED_ROLES.has(String(session.role || ''))
}
/**
* 备用金账户:
* - 客服只能用本人账户;
* - 管理员/财务/运营可显式指定客服账户(fundAccountId),未指定时回退本人账户。
*/
async function resolveFundAccountId(
payload: JsonObject,
session: AdminSession,
client?: Pick<PoolClient, 'query'>,
) {
const { getOwnAgentAccountId } = await import('../admin/admin-fund-service.js')
const selectedId = Number(payload.fundAccountId ?? payload.fund_account_id ?? 0)
if (canActOnBehalf(session) && Number.isInteger(selectedId) && selectedId > 0) {
const sql = `SELECT fa.id
FROM fund_accounts fa
JOIN admin_users au ON au.id = fa.owner_admin_user_id
WHERE fa.id = $1
AND fa.account_type = 'agent'
AND fa.status = 'active'
AND au.status = 'active'
LIMIT 1`
const selected = client
? await client.query<{ id: number }>(sql, [selectedId])
: await query<{ id: number }>(sql, [selectedId])
const found = selected.rows[0]?.id
if (found) return Number(found)
throw createHttpError('指定的客服备用金账户不存在或已停用', {
statusCode: 404,
errorCode: 'fund_agent_account_not_found',
})
}
const own = await getOwnAgentAccountId(session.userId, client)
if (own) return own
throw createHttpError('当前账号还没有备用金账户', {
statusCode: 404,
errorCode: 'fund_agent_account_not_found',
})
}
/**
* 积分账户归属人:
* - 客服只能扣本人;
* - 管理员/财务/运营可指定客服(creditOwnerAdminUserId),未指定时回退本人。
*/
async function resolveCreditOwnerAdminUserId(
payload: JsonObject,
session: AdminSession,
client?: Pick<PoolClient, 'query'>,
) {
const selectedId = Number(
payload.creditOwnerAdminUserId ?? payload.credit_owner_admin_user_id ?? 0,
)
if (canActOnBehalf(session) && Number.isInteger(selectedId) && selectedId > 0) {
const sql = `SELECT id FROM admin_users WHERE id = $1 AND status = 'active' AND role = 'support' LIMIT 1`
const selected = client
? await client.query<{ id: number }>(sql, [selectedId])
: await query<{ id: number }>(sql, [selectedId])
if (selected.rows[0]?.id) return Number(selected.rows[0].id)
throw createHttpError('指定的客服账号不存在或已停用', {
statusCode: 404,
errorCode: 'platform_credit_owner_not_found',
})
}
return Number(session.userId)
}
/**
* 确认前按 SKU 匹配人工履约配置。
*
* 与 `rematchManualFulfillments` 使用同一套键:规范中文内部 SKU。
* 这样即使配置晚于订单创建,新单在确认时也能自动挂上渠道。
*/
async function matchManualFulfillmentConfigWithClient(client: PoolClient, orderItemId: number) {
const matched = await client.query<{
id: number
canonical_product_id: number | null
}>(
`
SELECT cfg.id, cfg.canonical_product_id
FROM order_items oi
JOIN manual_fulfillment_configs cfg ON cfg.enabled = TRUE
AND (
canonicalize_internal_sku(cfg.sku_key) = resolve_internal_sku(
oi.manual_sku_key, oi.sku_name, oi.sku_code
)
OR (
cfg.canonical_product_id IS NOT NULL
AND cfg.canonical_product_id = oi.canonical_product_id
)
)
WHERE oi.id = $1
AND oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual'
AND oi.fulfillment_config_id IS NULL
ORDER BY
(cfg.canonical_product_id IS NOT NULL
AND cfg.canonical_product_id = oi.canonical_product_id) DESC,
cfg.id ASC
LIMIT 1
`,
[orderItemId],
)
const match = matched.rows[0]
if (!match) return false
await attachFulfillmentConfigurationsWithExecutor(client, {
mappings: [
{
orderItemId,
configId: Number(match.id),
...(match.canonical_product_id
? { canonicalProductId: Number(match.canonical_product_id) }
: {}),
},
],
onlyWhenConfigNull: true,
onlyWhenCanonicalProductNull: true,
})
return true
}
export async function resolveManualCostItemIdWithClient(client: PoolClient) {
const result = await client.query<{ id: number }>(
`SELECT id FROM cost_items WHERE code = 'sys_manual_service' AND status = 'active' LIMIT 1`,
)
const id = Number(result.rows[0]?.id || 0)
if (!id) {
throw createHttpError('人工客服履约成本项未启用', {
statusCode: 409,
errorCode: 'manual_fulfillment_cost_item_unavailable',
})
}
return id
}
export async function resolveCostVersionIdWithClient(
client: PoolClient,
costItemId: number,
row: {
order_id_resolved: number
provider: string
shop_id: string
canonical_product_id: number | null
work_product_rule_id: number | null
credit_unit_price_fen: string
},
basisAt: string,
) {
const result = await client.query<{ id: number }>(
`
SELECT v.id
FROM cost_versions v
WHERE v.cost_item_id = $1
AND (
v.status = 'active'
OR (v.status = 'draft' AND EXISTS (
SELECT 1 FROM cost_items ci
WHERE ci.id = v.cost_item_id AND ci.code = 'sys_manual_service'
))
)
AND v.effective_from <= $2
AND (v.effective_to IS NULL OR v.effective_to > $2)
AND (
(v.scope_type = 'rule' AND $6::bigint IS NOT NULL AND v.work_product_rule_id = $6)
OR (v.scope_type = 'product' AND $5::bigint IS NOT NULL AND v.canonical_product_id = $5)
OR (v.scope_type = 'shop' AND v.provider = $3 AND v.shop_id = $4)
OR (v.scope_type = 'provider' AND v.provider = $3)
OR (v.scope_type = 'global')
)
ORDER BY
CASE v.scope_type
WHEN 'rule' THEN 1
WHEN 'product' THEN 2
WHEN 'shop' THEN 3
WHEN 'provider' THEN 4
ELSE 5
END,
v.effective_from DESC,
v.id DESC
LIMIT 1
`,
[
costItemId,
basisAt,
row.provider,
row.shop_id,
row.canonical_product_id,
row.work_product_rule_id,
],
)
return result.rows[0]?.id ?? null
}
/**
* 人工履约的本次成本:固定成本按「配置积分 × 平台换算率」,成本不固定必须客服手填。
* 抽成纯函数,便于单测覆盖固定/手填/非法金额三种分支。
*/
export function resolveManualFulfillmentCost(input: {
creditQuantity: number
unitPriceFen: number
providedCostFen?: unknown
}): { costFen: number; costMode: 'fixed' | 'variable' } {
const creditQuantity = normalizeManualCreditQuantity(input.creditQuantity)
if (creditQuantity > 0) {
const unitPriceFen = Math.max(0, Math.trunc(Number(input.unitPriceFen) || 0))
return {
costFen: calculateManualCostFen(creditQuantity, unitPriceFen),
costMode: 'fixed',
}
}
if (
input.providedCostFen === undefined ||
input.providedCostFen === null ||
input.providedCostFen === ''
) {
throw createHttpError('该商品成本不固定,请填写本次实际成本', {
statusCode: 400,
errorCode: 'manual_fulfillment_cost_required',
})
}
const costFen = normalizeCostFen(input.providedCostFen)
if (costFen <= 0) {
throw createHttpError('该商品成本不固定,请填写本次实际成本', {
statusCode: 400,
errorCode: 'manual_fulfillment_cost_required',
})
}
return { costFen, costMode: 'variable' }
}
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),
platformOrderId: row.platform_order_id || '',
provider: row.provider || '',
shopId: row.shop_id || '',
skuCode: row.sku_code || '',
skuName: row.sku_name || '',
internalSku: row.internal_sku || '',
revenueFen: Number(row.revenue_fen || 0),
paidAt: row.paid_at,
configId: Number(row.config_id),
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: primary?.costFen ?? null,
channels,
fulfilled: Boolean(row.fulfilled_at),
fulfilledAt: row.fulfilled_at || null,
}
}
function normalizeCostFen(value: unknown) {
const parsed = Number(value)
if (!Number.isFinite(parsed) || parsed < 0) {
throw createHttpError('实际成本金额无效', {
statusCode: 400,
errorCode: 'manual_fulfillment_cost_invalid',
})
}
return Math.trunc(parsed)
}
function normalizeRequiredId(value: unknown, message: string) {
const parsed = Number(value)
if (!Number.isInteger(parsed) || parsed <= 0) {
throw createHttpError(message, {
statusCode: 400,
errorCode: 'manual_fulfillment_id_invalid',
})
}
return parsed
}
function normalizePositiveInteger(value: unknown, fallback: number, label: string) {
const parsed = Number(value)
if (!value || !Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1) {
if (!value) return fallback
throw createHttpError(`${label}必须是正整数`, {
statusCode: 400,
errorCode: 'manual_fulfillment_pagination_invalid',
})
}
return parsed
}
function optionalText(value: unknown, maxLength: number) {
return String(value || '')
.trim()
.slice(0, maxLength)
}
function escapeLike(value: string) {
return value.replace(/[\\%_]/g, (char) => `\\${char}`)
}
/**
* 订单列表/详情用:按订单统计「可确认履约」与「未配置履约渠道」的数量。
*
* - pending:有人工履约配置且还没有 manual_case 快照;
* - unconfigured:人工单但没有绑定履约配置(需要先到成本中心配 SKU)。
*/
export async function getManualFulfillmentCountsByOrderIds(
orderIds: Array<number | string>,
): Promise<Map<number, { pending: number; unconfigured: number }>> {
const ids = orderIds.map(Number).filter((id) => Number.isFinite(id) && id > 0)
if (ids.length === 0) return new Map()
const result = await query<{
order_id: number
pending_count: number
unconfigured_count: number
}>(
`
WITH fulfilled AS (
SELECT order_item_id, MAX(created_at) AS fulfilled_at
FROM cost_snapshots
WHERE subject_type = 'manual_case' AND superseded_at IS NULL AND reversed_at IS NULL
GROUP BY order_item_id
)
SELECT
oi.order_id,
COUNT(*) FILTER (
WHERE cfg.id IS NOT NULL AND fulfilled.fulfilled_at IS NULL
)::int AS pending_count,
COUNT(*) FILTER (WHERE cfg.id IS NULL)::int AS unconfigured_count
FROM order_items oi
LEFT JOIN manual_fulfillment_configs cfg ON cfg.id = oi.fulfillment_config_id
LEFT JOIN fulfilled ON fulfilled.order_item_id = oi.id
WHERE oi.order_id = ANY($1::bigint[])
AND oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual'
GROUP BY oi.order_id
`,
[ids],
)
return new Map(
result.rows.map((row) => [
Number(row.order_id),
{
pending: Number(row.pending_count || 0),
unconfigured: Number(row.unconfigured_count || 0),
},
]),
)
}