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:
co-authored by
Claude Opus 4.5
parent
4bcb4e6b57
commit
d07d88e25c
@@ -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}`)
|
||||
}
|
||||
Reference in New Issue
Block a user