- P0/P1:影子总账追加调整、关账/退款/资金/积分幂等、成本版本追溯、对账订单项维度 - 迁移收敛:原 103–114 合并为 103_finance_foundation、104_finance_controls、105_work_order_consignment - 数据:L1 钱包 BIGINT/CHECK/奖励幂等;重复奖励审计口径修正与人工核对清单 - 报表:销售毛利缺失成本收入提示,旧收支页明确现金流口径 - 文档:同步迁移基线、合并评估、代码复核与上线说明
838 lines
28 KiB
TypeScript
838 lines
28 KiB
TypeScript
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'
|
||
|
||
/**
|
||
* 人工履约待办与「确认履约」。
|
||
*
|
||
* 口径(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
|
||
}
|
||
|
||
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
|
||
revenue_fen: string
|
||
basis_at: 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_order_no: string | null
|
||
work_product_rule_id: number | 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')
|
||
}
|
||
// 订单页按单打开确认框:只取该订单的待办项。
|
||
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
|
||
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,
|
||
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()
|
||
|
||
// 钱与成本必须同一事务:先锁订单项、复查未履约,再扣款、最后写快照。
|
||
// 任何一步失败(余额不足、成本版本缺失、重复快照)都整体回滚,不会留下「扣了钱没记成本」。
|
||
return withTransaction(async (client) => {
|
||
// 配置可能晚于订单创建:确认前按 SKU 再匹配一次(与定时 rematch 同口径),
|
||
// 保证「配置好之后的新单」不需要人工再点一次「重新匹配」。
|
||
await matchManualFulfillmentConfigWithClient(client, orderItemId, now)
|
||
|
||
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,
|
||
COALESCE(oi.revenue_amount_fen, 0)::bigint AS revenue_fen,
|
||
COALESCE(o.paid_at, o.created_at) AS basis_at,
|
||
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.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.fulfillment_mode !== '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 platformKey = row.platform_key as PlatformCreditKey | 'fund_cash'
|
||
if (platformKey !== 'fund_cash' && !PLATFORM_CREDIT_KEYS.includes(platformKey)) {
|
||
throw createHttpError('该商品的人工履约平台配置无效,请重新配置', {
|
||
statusCode: 409,
|
||
errorCode: 'manual_fulfillment_config_invalid',
|
||
})
|
||
}
|
||
|
||
// 锁内复查:并发/双击的第二个请求会等锁,醒来后看到快照直接拒绝,不会重复扣款。
|
||
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
|
||
LIMIT 1`,
|
||
[orderItemId],
|
||
)
|
||
if (existing.rows[0]) {
|
||
throw createHttpError('该订单项已确认过履约,不能重复操作', {
|
||
statusCode: 409,
|
||
errorCode: 'manual_fulfillment_already_done',
|
||
})
|
||
}
|
||
|
||
const creditQuantity = Number(row.credit_quantity || 0)
|
||
const unitPriceFen = Number(row.credit_unit_price_fen || 1)
|
||
if (platformKey !== 'fund_cash' && 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 costFen = costPlan.costFen
|
||
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,
|
||
})
|
||
|
||
// 先把成本项/版本解析完再扣钱:版本缺失不能发生在扣款之后。
|
||
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',
|
||
})
|
||
}
|
||
|
||
let fundExpenseId: number | null = null
|
||
let creditEntryId: number | null = null
|
||
let balanceAfter: number | null = null
|
||
let subjectSource: 'manual_expense' | 'platform_credit'
|
||
|
||
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'
|
||
}
|
||
|
||
// subject_id 固定为 order_item_id:不再与 manual_expenses.id /
|
||
// platform_credit_entries.id 混用,避免两张表序列撞车。
|
||
// subject_type 仍为 manual_case,资金流水只放在 metadata 里。
|
||
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,
|
||
],
|
||
)
|
||
const snapshotId = snapshot.rows[0]?.id ?? null
|
||
if (!snapshotId) {
|
||
// 订单项唯一索引兜底:任何竞态都会走到这里,抛错回滚本次扣款。
|
||
throw createHttpError('该订单项已有人工成本快照,不能重复录入', {
|
||
statusCode: 409,
|
||
errorCode: 'manual_fulfillment_snapshot_conflict',
|
||
})
|
||
}
|
||
|
||
// 人工缺成本异常立即收口,不等下一轮定时任务。
|
||
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],
|
||
)
|
||
|
||
return {
|
||
orderItemId,
|
||
orderId: Number(row.order_id_resolved),
|
||
costSnapshotId: snapshotId,
|
||
platformKey,
|
||
platformLabel: PLATFORM_LABELS[platformKey] || platformKey,
|
||
creditQuantity,
|
||
costFen,
|
||
fundExpenseId,
|
||
creditEntryId,
|
||
balanceAfter,
|
||
}
|
||
})
|
||
}
|
||
|
||
/** 客服自己的备用金账户(管理员代录时用指定账户)。 */
|
||
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` 使用同一套键:
|
||
* spec_json.skuNick -> spec_json.outSkuSn -> sku_code -> sku_name。
|
||
* 这样即使配置晚于订单创建,新单在确认时也能自动挂上渠道。
|
||
*/
|
||
async function matchManualFulfillmentConfigWithClient(
|
||
client: PoolClient,
|
||
orderItemId: number,
|
||
now: string,
|
||
) {
|
||
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 (
|
||
cfg.sku_key = COALESCE(
|
||
NULLIF(oi.spec_json->>'skuNick', ''),
|
||
NULLIF(oi.spec_json->>'outSkuSn', ''),
|
||
NULLIF(oi.sku_code, ''),
|
||
NULLIF(oi.sku_name, '')
|
||
)
|
||
OR (
|
||
cfg.canonical_product_id IS NOT NULL
|
||
AND cfg.canonical_product_id = oi.canonical_product_id
|
||
)
|
||
)
|
||
WHERE oi.id = $1
|
||
AND oi.fulfillment_mode = '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 client.query(
|
||
`
|
||
UPDATE order_items
|
||
SET fulfillment_config_id = $2,
|
||
canonical_product_id = COALESCE($3, canonical_product_id),
|
||
updated_at = $4
|
||
WHERE id = $1
|
||
AND fulfillment_config_id IS NULL
|
||
`,
|
||
[orderItemId, match.id, match.canonical_product_id, now],
|
||
)
|
||
return true
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
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 = Math.max(0, Math.trunc(Number(input.creditQuantity) || 0))
|
||
if (creditQuantity > 0) {
|
||
const unitPriceFen = Math.max(0, Math.trunc(Number(input.unitPriceFen) || 0))
|
||
return { costFen: 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) {
|
||
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 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
|
||
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_mode = '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),
|
||
},
|
||
]),
|
||
)
|
||
}
|