Files
order_site/apps/backend/src/services/finance/manual-fulfillment-service.ts
T
yml2213 9d5af359fa feat(finance): 完成财务控制、L1 钱包硬化与迁移收敛
- P0/P1:影子总账追加调整、关账/退款/资金/积分幂等、成本版本追溯、对账订单项维度
- 迁移收敛:原 103–114 合并为 103_finance_foundation、104_finance_controls、105_work_order_consignment
- 数据:L1 钱包 BIGINT/CHECK/奖励幂等;重复奖励审计口径修正与人工核对清单
- 报表:销售毛利缺失成本收入提示,旧收支页明确现金流口径
- 文档:同步迁移基线、合并评估、代码复核与上线说明
2026-10-27 16:08:16 +08:00

838 lines
28 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'
/**
* 人工履约待办与「确认履约」。
*
* 口径(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),
},
]),
)
}