feat(finance): 积分钱包操作 + 客服「确认履约」闭环

把「配置 → 待办 → 确认履约 → 扣额度 → 写成本快照」整条链路打通,
客服点一下就完成履约,不需要填复杂的东西。

积分钱包(P1.3):
- platform-credit-service.ts:充值 / 调整 / 消耗 / 流水查询。
- 三平台积分互不通用,每客服每平台一个账户;余额只能由流水产生,
  消耗不足直接拒绝(409),不允许负余额,与备用金口径一致。
- 换算率按平台:南航、乐玩 1 积分 = 1 分,ROCK 1 积分 = 1 元。
- 充值 / 调整只对 admin/operator/finance 开放;客服只能看自己的钱包。
- 备用金现金不在这里,走现有 fund_* 链路。

确认履约(P1.4):
- manual-fulfillment-service.ts:待办列表 + 确认履约。
- 待办口径:order_items.fulfillment_mode='manual' 且已配 fulfillment_config_id。
  人工单**不建 fulfillment_tasks**——那是自动发货通道的体系,人工单历来不走它
  (本地 102,955 个人工单的任务行数为 0)。
- 「已履约」判定:该订单项存在有效 manual_case 成本快照。
- 固定成本(消耗积分 > 0)→ 零输入;成本不固定(= 0)→ 只填实际成本金额。
- 确认时:积分平台扣积分钱包、备用金现金走 writeFundExpense,
  随后写 actual 成本快照(主体 = 积分流水 id 或备用金支出单 id)。
- 渠道与消耗积分永远提前配好,客服不选;重复履约直接拒绝。

前端:
- 新增「人工履约」页(客服菜单):待确认 / 已履约 / 全部,确认履约弹窗。
- 备用金页新增「平台积分」Tab(客服端与管理端各一份):
  余额卡片、积分流水、管理员充值与调整。

E2E 实测:配置 ROCK 100 积分 → 成本 ¥100.00(1 积分 = 1 元);充值 5000 积分
= ¥5000;确认履约后积分 5000 → 4900,成本快照 actual ¥100.00 写入;
重复履约被拒绝。测试数据已清理,登记表保留 357 条扫描结果供运营使用。

校验:后端 765 项 / 0 失败,前端 143 项全通过(含 tsc -b 与 build)。

Co-Authored-By: Claude Opus 4.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
yml2213
2026-10-24 13:21:34 +08:00
co-authored by Claude Opus 4.5
parent 4bcb4e6b57
commit d07d88e25c
12 changed files with 2221 additions and 0 deletions
@@ -0,0 +1,557 @@
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 {
consumePlatformCredit,
type PlatformCreditKey,
} from '../cost-center/platform-credit-service.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: '备用金现金',
}
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
fulfilled_at: string | null
}
/**
* 列出客服的人工履约待办。
*
* 只看「已配好渠道」的人工订单项;未配置的 SKU 不出现在待办里
* (那是配置缺口,不是客服能处理的)。
*/
export async function listManualFulfillmentPending(
input: Record<string, unknown> = {},
session: AdminSession,
) {
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_mode = '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')
}
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
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','')) 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,
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(mapPending),
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 context = await query<{
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
revenue_fen: string
canonical_product_id: number | null
fulfillment_mode: string
config_id: number | null
platform_key: string
credit_quantity: string
cost_mode: string
credit_unit_price_fen: string
work_order_id: number | null
work_product_rule_id: number | null
fulfilled_at: string | null
}>(
`
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
GROUP BY cs.order_item_id
)
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,
COALESCE(oi.revenue_amount_fen, 0)::bigint AS revenue_fen,
oi.canonical_product_id,
oi.fulfillment_mode,
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.cost_mode, 'variable') AS cost_mode,
COALESCE((fp.config_json->>'rechargeUnitPriceFen')::bigint, 1) AS credit_unit_price_fen,
wo.id AS work_order_id,
wo.product_rule_id AS work_product_rule_id,
fulfilled.fulfilled_at
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 FROM work_orders
WHERE order_item_id = oi.id ORDER BY id DESC LIMIT 1
) wo ON TRUE
LEFT JOIN fulfilled ON fulfilled.order_item_id = oi.id
WHERE oi.id = $1
LIMIT 1
`,
[orderItemId],
)
const row = context.rows[0]
if (!row) {
throw createHttpError('订单项不存在', {
statusCode: 404,
errorCode: 'manual_fulfillment_order_item_not_found',
})
}
if (row.fulfillment_mode !== 'manual') {
throw createHttpError('该订单项不是人工履约', {
statusCode: 409,
errorCode: 'manual_fulfillment_not_manual',
})
}
if (row.fulfilled_at) {
throw createHttpError('该订单项已确认过履约,不能重复操作', {
statusCode: 409,
errorCode: 'manual_fulfillment_already_done',
})
}
if (!row.config_id) {
throw createHttpError('该商品还没有配置履约渠道,请先在成本中心配置', {
statusCode: 409,
errorCode: 'manual_fulfillment_config_missing',
})
}
const platformKey = row.platform_key as PlatformCreditKey | 'fund_cash'
const creditQuantity = Number(row.credit_quantity || 0)
const unitPriceFen = Number(row.credit_unit_price_fen || 1)
// 成本:固定成本用配置值;成本不固定必须客服手填。
let costFen: number
if (creditQuantity > 0) {
costFen = creditQuantity * unitPriceFen
} else {
const provided = payload.costFen ?? payload.cost_fen ?? payload.amountFen
costFen = normalizeCostFen(provided)
if (costFen === 0) {
throw createHttpError('该商品成本不固定,请填写本次实际成本', {
statusCode: 400,
errorCode: 'manual_fulfillment_cost_required',
})
}
}
const note = optionalText(payload.note, 300)
const payeeName = optionalText(payload.payeeName ?? payload.payee_name, 120)
// 1. 先扣额度(积分平台走积分钱包;备用金现金走现有 fund_* 链路)。
let creditEntryId: number | null = null
let fundExpenseId: number | null = null
let balanceAfter: number | null = null
if (platformKey === 'fund_cash') {
// 备用金现金:复用现有客服备用金支出链路(扣余额 + 写 fund_entries + manual_expenses)。
const { writeFundExpense } = await import('../admin/admin-fund-service.js')
const fundAccountId = await resolveFundAccountId(session)
const written = await withTransaction(async (client) => {
return writeFundExpense(client, {
agentAccountId: fundAccountId,
amountFen: costFen,
category: 'other',
payeeType: 'other',
payeeName: payeeName || '人工履约',
orderId: Number(row.order_id_resolved),
orderItemId,
paidAt: now,
evidence: [],
note: buildFundNote(row.sku_name, note),
actorUserId: session.userId,
})
})
fundExpenseId = Number(written.expense.id)
balanceAfter = written.balanceAfterFen
} else if (creditQuantity > 0) {
const consumed = await consumePlatformCredit({
ownerAdminUserId: session.userId,
platformKey: platformKey as PlatformCreditKey,
quantity: creditQuantity,
relatedTaskId: null,
note: buildCreditNote(row.sku_name, note),
})
creditEntryId = consumed.entryId
balanceAfter = consumed.balanceAfter
}
// 2. 写 actual 成本快照(主体 = 备用金支出单 / 积分流水)。
const subjectId = fundExpenseId ?? creditEntryId ?? orderItemId
const costItemId = await resolveManualCostItemId()
const versionId = await resolveCostVersionId(costItemId, row, now)
if (!versionId) {
throw createHttpError('该成本项没有可用的成本版本,请先在成本中心配置后重试', {
statusCode: 409,
errorCode: 'manual_fulfillment_cost_version_missing',
})
}
const snapshot = await 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, $3, $4,
$5, $6, $7,
1, $8, $8, 'actual', 'manual_input',
$9::jsonb, $10, $11, $11
)
ON CONFLICT (subject_type, subject_id, cost_item_id)
WHERE superseded_at IS NULL
DO NOTHING
RETURNING id
`,
[
subjectId,
row.order_id_resolved,
orderItemId,
row.work_order_id,
costItemId,
versionId,
row.canonical_product_id,
costFen,
JSON.stringify({
source: 'manual_fulfillment_confirm',
platformKey,
platformLabel: PLATFORM_LABELS[platformKey] || platformKey,
creditQuantity,
creditUnitPriceFen: unitPriceFen,
costMode: creditQuantity > 0 ? 'fixed' : 'variable',
costFen,
fundExpenseId,
creditEntryId,
payeeName,
note,
fulfilledBy: {
userId: Number(session.userId || 0),
displayName: String(session.displayName || ''),
role: String(session.role || ''),
},
}),
now,
nowIso(),
],
)
const snapshotId = snapshot.rows[0]?.id ?? null
if (!snapshotId) {
throw createHttpError('该订单项已有人工成本快照,不能重复录入', {
statusCode: 409,
errorCode: 'manual_fulfillment_snapshot_conflict',
})
}
return {
orderItemId,
orderId: Number(row.order_id_resolved),
costSnapshotId: snapshotId,
platformKey,
platformLabel: PLATFORM_LABELS[platformKey] || platformKey,
creditQuantity,
costFen,
fundExpenseId,
creditEntryId,
balanceAfter,
}
}
/** 客服自己的备用金账户(管理员代录时用指定账户)。 */
async function resolveFundAccountId(session: AdminSession) {
const { getOwnAgentAccountId } = await import('../admin/admin-fund-service.js')
const own = await getOwnAgentAccountId(session.userId)
if (own) return own
throw createHttpError('当前账号还没有备用金账户', {
statusCode: 404,
errorCode: 'fund_agent_account_not_found',
})
}
async function resolveManualCostItemId() {
const result = await 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
}
async function resolveCostVersionId(
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 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
}
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'
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: row.platform_key,
platformLabel: PLATFORM_LABELS[row.platform_key] || row.platform_key,
isFundCash,
creditQuantity,
creditUnitPriceFen: unitPriceFen,
costMode: row.cost_mode || 'variable',
/** 固定成本的金额;成本不固定时为 null,客服要手填。 */
costFen: creditQuantity > 0 ? creditQuantity * unitPriceFen : null,
fulfilled: Boolean(row.fulfilled_at),
fulfilledAt: row.fulfilled_at || null,
}
}
function buildFundNote(skuName: string, note: string) {
const parts = [`人工履约 ${skuName}`.trim()]
if (note) parts.push(note)
return parts.join(' · ').slice(0, 500)
}
function buildCreditNote(skuName: string, note: string) {
const parts = [`人工履约 ${skuName}`.trim()]
if (note) parts.push(note)
return parts.join(' · ').slice(0, 300)
}
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}`)
}