refactor(finance): 人工成本收敛为「人工录单 → 扣备用金 → 写快照」
公司人工支出登记单与审批报销状态机整链下线,人工成本只留一条链路。
口径调整(2026-10-23 确认):
- 历史人工支出找不回来,直接放弃:不补录、不做样本校准、不做均价估算,
只保证上线后的新单不丢。
- 人工成本逐单按实际金额,从客服备用金扣款,客服只能用本人账户;
没有登记单、没有审批、没有报销,录入即计入毛利。
后端:
- 新增 services/finance/manual-order-cost-service.ts:扣备用金 + 写 actual
成本快照,同一事务,余额不足直接拒绝不允许负余额。
- 成本快照主体从「人工支出登记单 id」改为「客服备用金支出单 id」
(subject_type='manual_case',subject_id=manual_expenses.id)。
- 删除 services/finance/manual-expense-service.ts(1263 行)与
routes/admin/finance.ts 下 /finance/manual-expenses* 全套接口。
前端:
- 删除 AdminManualExpensePage.tsx(778 行)与 /admin/manual-expenses 菜单页。
- ManualOrderCostModal 去掉「提交审核 / 立即计入成本」开关,录入即生效。
- 新增 services/admin/manual-order-cost.ts。
迁移:
- 106_manual_expense_records.sql → 106_fund_account_no_readable.sql,
只保留备用金账户编号可读化。
- 108_drop_manual_expense_records.sql:删除 manual_expense_records 与
manual_expense_events 两张表。
- 109_isolate_legacy_manual_snapshots.sql:把来源为旧登记链的历史孤儿快照
subject_id 改为负数,与 manual_expenses.id 彻底隔离。
踩坑修复(E2E 实测发现):主体 id 换空间后与历史孤儿快照撞唯一索引
uq_cost_snapshots_active_subject_item,INSERT ... ON CONFLICT DO NOTHING
静默跳过,导致「钱从备用金扣了、成本快照却没写成」。修法是迁移 109 隔离
legacy 主体 + 后端快照写失败必须抛错回滚(409),杜绝账实不符。
文档:现行 5 篇 + README 基线校准为 dev @ 43f402cf + 迁移 103–109,
修正已删除的旧迁移号,删除「人工支出登记 + 审批 + 报销」口径,
已作废 DDL 草案移入 docs/archive/2026-10-23/。
校验:后端 765 项 / 0 失败(含 check:sql 迁移护栏),前端 143 项全通过;
E2E 实测两笔同订单项各自独立主体、余额扣减与快照金额一一对应;
本地库迁移 108/109 执行后数据量与改造前完全一致。
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
43f402cf72
commit
fb679d76f6
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,547 @@
|
||||
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 { getOwnAgentAccountId, writeFundExpense } from '../admin/admin-fund-service.js'
|
||||
|
||||
/**
|
||||
* 订单页「人工录单」服务。
|
||||
*
|
||||
* 口径(2026-10-23 确认):
|
||||
* * 人工成本逐单、按实际金额,不按工时、不按固定单量/固定单价;
|
||||
* * 成本从客服备用金账户直接扣款,客服角色只能用本人账户,管理员/财务可代录;
|
||||
* * **没有登记单、没有审批、没有报销**:录入即写 actual 成本快照;
|
||||
* * 资金台账唯一真源是 manual_expenses(客服备用金支出明细),本服务不另存支出表。
|
||||
*
|
||||
* 成本快照主体:subject_type='manual_case',subject_id=manual_expenses.id。
|
||||
*/
|
||||
|
||||
const PAYEE_TYPES = new Set(['worker', 'upstream', 'customer', 'platform', 'other'])
|
||||
const PAYMENT_METHODS = new Set(['wechat', 'alipay', 'bank', 'cash', 'platform_balance', 'other'])
|
||||
/** 人工录单的费用类型;与备用金分类的映射见 mapFundExpenseCategory()。 */
|
||||
const EXPENSE_CATEGORIES = new Set([
|
||||
'goods_purchase',
|
||||
'compensation',
|
||||
'refund_advance',
|
||||
'service_fee',
|
||||
'freight',
|
||||
'recharge',
|
||||
'other',
|
||||
])
|
||||
|
||||
type ManualOrderCostResult = {
|
||||
expenseNo: string
|
||||
fundExpenseId: number
|
||||
fundAccountId: number
|
||||
fundAccountNo: string
|
||||
amountFen: number
|
||||
orderId: number
|
||||
orderItemId: number
|
||||
costSnapshotId: number | null
|
||||
fulfillmentMode: string
|
||||
warning: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 录入一笔人工成本:扣客服备用金 + 写 actual 成本快照。
|
||||
*
|
||||
* 全流程在同一事务内:余额不足直接拒绝,不允许负余额。
|
||||
*/
|
||||
export async function createManualOrderCost(
|
||||
payload: JsonObject,
|
||||
session: AdminSession,
|
||||
): Promise<ManualOrderCostResult> {
|
||||
const orderId = normalizeId(payload.orderId ?? payload.order_id, '订单号无效')
|
||||
const amountFen = normalizeAmountFen(payload.amountFen ?? payload.amount_fen)
|
||||
const note = optionalText(payload.note, 500) || '订单页人工录单'
|
||||
const expenseCategory = normalizeStatus(
|
||||
payload.expenseCategory ?? payload.expense_category,
|
||||
EXPENSE_CATEGORIES,
|
||||
'service_fee',
|
||||
'费用类型无效',
|
||||
)
|
||||
const payeeName = optionalText(payload.payeeName ?? payload.payee_name, 120) || '内部人工'
|
||||
const payeeType = normalizeStatus(
|
||||
payload.payeeType ?? payload.payee_type,
|
||||
PAYEE_TYPES,
|
||||
'other',
|
||||
'收款方类型无效',
|
||||
)
|
||||
const paymentMethod = normalizeStatus(
|
||||
payload.paymentMethod ?? payload.payment_method,
|
||||
PAYMENT_METHODS,
|
||||
'other',
|
||||
'付款方式无效',
|
||||
)
|
||||
const evidenceJson = normalizeEvidence(payload.evidenceJson ?? payload.evidence_json)
|
||||
if (!hasEvidence(evidenceJson)) {
|
||||
evidenceJson.note = note
|
||||
}
|
||||
|
||||
const orderItemId = await resolveOrderItemId(
|
||||
orderId,
|
||||
normalizeOptionalId(payload.orderItemId ?? payload.order_item_id),
|
||||
)
|
||||
const fundAccountId = await resolveFundAccountId(
|
||||
normalizeOptionalId(payload.fundAccountId ?? payload.fund_account_id),
|
||||
session,
|
||||
)
|
||||
const costItemId = await resolveManualCostItemId()
|
||||
const paidAt = nowIso()
|
||||
|
||||
return withTransaction(async (client) => {
|
||||
const fundWritten = await writeFundExpense(client, {
|
||||
agentAccountId: fundAccountId,
|
||||
amountFen,
|
||||
category: mapFundExpenseCategory(expenseCategory),
|
||||
payeeType: mapFundPayeeType(payeeType),
|
||||
payeeName,
|
||||
payeeAccount: optionalText(payload.payeeAccount ?? payload.payee_account, 120),
|
||||
orderId,
|
||||
orderItemId,
|
||||
paidAt,
|
||||
evidence: toFundEvidence(evidenceJson),
|
||||
note: buildFundExpenseNote(note, optionalText(evidenceJson.reference, 200)),
|
||||
actorUserId: session.userId,
|
||||
})
|
||||
const fundExpenseId = Number(fundWritten.expense.id)
|
||||
const fundAccountNo = await loadFundAccountNo(client, fundAccountId)
|
||||
|
||||
const context = await loadOrderItemContext(client, orderItemId)
|
||||
const versionId = await resolveCostVersionId(client, costItemId, context, paidAt)
|
||||
let costSnapshotId: number | null = null
|
||||
let warning = ''
|
||||
|
||||
if (!versionId) {
|
||||
// 匹配不到有效成本版本不允许静默记 0,必须进异常队列。
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO cost_exceptions (
|
||||
subject_type, subject_id, order_item_id, work_order_id,
|
||||
reason, status, detail_json, created_at
|
||||
) VALUES (
|
||||
'order_item', $1, $1, $2, 'manual_expense_cost_missing', 'open', $3::jsonb, NOW()
|
||||
)
|
||||
ON CONFLICT (subject_type, subject_id, reason) DO NOTHING
|
||||
`,
|
||||
[
|
||||
orderItemId,
|
||||
context.workOrderId,
|
||||
JSON.stringify({
|
||||
fundExpenseId,
|
||||
fundExpenseNo: fundWritten.expense.expense_no,
|
||||
amountFen,
|
||||
message: '人工录单已扣备用金,但该成本项没有匹配到有效成本版本',
|
||||
}),
|
||||
],
|
||||
)
|
||||
warning = '人工录单已扣备用金,但未匹配到有效成本版本,已进入异常队列'
|
||||
} else {
|
||||
const snapshotResult = 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, $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
|
||||
`,
|
||||
[
|
||||
fundExpenseId,
|
||||
orderId,
|
||||
orderItemId,
|
||||
context.workOrderId,
|
||||
costItemId,
|
||||
versionId,
|
||||
context.canonicalProductId,
|
||||
amountFen,
|
||||
JSON.stringify({
|
||||
source: 'manual_order_cost',
|
||||
fundExpenseId,
|
||||
fundExpenseNo: fundWritten.expense.expense_no,
|
||||
fundAccountNo,
|
||||
paidByName: fundWritten.ownerName,
|
||||
payeeName,
|
||||
payeeType,
|
||||
paymentMethod,
|
||||
expenseCategory,
|
||||
evidence: evidenceJson,
|
||||
fulfillmentMode: context.fulfillmentMode,
|
||||
}),
|
||||
paidAt,
|
||||
nowIso(),
|
||||
],
|
||||
)
|
||||
costSnapshotId = snapshotResult.rows[0]?.id ?? null
|
||||
if (!costSnapshotId) {
|
||||
// 唯一索引冲突意味着同一主体+成本项已有有效快照。钱已经扣了,不能静默吞掉,
|
||||
// 否则会出现「钱扣了、成本没记」的账实不符。
|
||||
throw createHttpError(
|
||||
'该订单项已有同成本项的人工成本快照,本次未写入成本;备用金扣款已回滚,请改用新的支出单或先处理已有快照',
|
||||
{ statusCode: 409, errorCode: 'manual_order_cost_snapshot_conflict' },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
expenseNo: fundWritten.expense.expense_no,
|
||||
fundExpenseId,
|
||||
fundAccountId,
|
||||
fundAccountNo,
|
||||
amountFen,
|
||||
orderId,
|
||||
orderItemId,
|
||||
costSnapshotId,
|
||||
fulfillmentMode: context.fulfillmentMode,
|
||||
warning,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 人工录单可用的成本项:人工客服履约成本。 */
|
||||
export async function listManualOrderCostItems() {
|
||||
const result = await query<{
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
cost_model: string
|
||||
pricing_mode: string
|
||||
}>(
|
||||
`
|
||||
SELECT id, code, name, cost_model, pricing_mode
|
||||
FROM cost_items
|
||||
WHERE status = 'active'
|
||||
AND cost_model = 'manual_actual'
|
||||
ORDER BY id
|
||||
`,
|
||||
)
|
||||
return {
|
||||
items: result.rows.map((row) => ({
|
||||
id: Number(row.id),
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
costModel: row.cost_model,
|
||||
pricingMode: row.pricing_mode,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveOrderItemId(orderId: number, requestedItemId: number | null) {
|
||||
const itemsResult = await query<{ id: number }>(
|
||||
'SELECT id FROM order_items WHERE order_id = $1 ORDER BY id ASC',
|
||||
[orderId],
|
||||
)
|
||||
if (itemsResult.rows.length === 0) {
|
||||
throw createHttpError('订单没有订单项,无法录入人工成本', {
|
||||
statusCode: 400,
|
||||
errorCode: 'manual_order_cost_items_missing',
|
||||
})
|
||||
}
|
||||
if (requestedItemId) {
|
||||
if (!itemsResult.rows.some((row) => Number(row.id) === requestedItemId)) {
|
||||
throw createHttpError('订单项不属于该订单', {
|
||||
statusCode: 400,
|
||||
errorCode: 'manual_order_cost_item_mismatch',
|
||||
})
|
||||
}
|
||||
return requestedItemId
|
||||
}
|
||||
if (itemsResult.rows.length === 1) {
|
||||
return Number(itemsResult.rows[0]?.id || 0)
|
||||
}
|
||||
throw createHttpError('订单有多个订单项,请选择要计入的订单项', {
|
||||
statusCode: 400,
|
||||
errorCode: 'manual_order_cost_item_required',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 备用金账户归属校验:客服只能用本人账户;管理员/财务可代录到指定客服账户。
|
||||
*/
|
||||
async function resolveFundAccountId(requestedFundAccountId: number | null, session: AdminSession) {
|
||||
if (session.role === 'support') {
|
||||
const ownAccountId = await getOwnAgentAccountId(session.userId)
|
||||
if (!ownAccountId) {
|
||||
throw createHttpError('当前账号还没有备用金账户', {
|
||||
statusCode: 404,
|
||||
errorCode: 'fund_agent_account_not_found',
|
||||
})
|
||||
}
|
||||
if (requestedFundAccountId && requestedFundAccountId !== ownAccountId) {
|
||||
throw createHttpError('只能使用自己的备用金账户', {
|
||||
statusCode: 403,
|
||||
errorCode: 'manual_order_cost_fund_account_forbidden',
|
||||
})
|
||||
}
|
||||
return ownAccountId
|
||||
}
|
||||
if (!requestedFundAccountId) {
|
||||
throw createHttpError('请选择备用金账户,人工录单必须从备用金扣款', {
|
||||
statusCode: 400,
|
||||
errorCode: 'manual_order_cost_fund_account_required',
|
||||
})
|
||||
}
|
||||
return requestedFundAccountId
|
||||
}
|
||||
|
||||
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 costItemId = Number(result.rows[0]?.id || 0)
|
||||
if (!costItemId) {
|
||||
throw createHttpError('人工客服履约成本项未启用', {
|
||||
statusCode: 409,
|
||||
errorCode: 'manual_order_cost_item_unavailable',
|
||||
})
|
||||
}
|
||||
return costItemId
|
||||
}
|
||||
|
||||
type OrderItemContext = {
|
||||
orderId: number
|
||||
canonicalProductId: number | null
|
||||
provider: string
|
||||
shopId: string
|
||||
workOrderId: number | null
|
||||
workProductRuleId: number | null
|
||||
fulfillmentMode: string
|
||||
}
|
||||
|
||||
async function loadFundAccountNo(client: PoolClient, fundAccountId: number) {
|
||||
const result = await client.query<{ account_no: string }>(
|
||||
'SELECT account_no FROM fund_accounts WHERE id = $1 LIMIT 1',
|
||||
[fundAccountId],
|
||||
)
|
||||
return result.rows[0]?.account_no || ''
|
||||
}
|
||||
|
||||
async function loadOrderItemContext(
|
||||
client: PoolClient,
|
||||
orderItemId: number,
|
||||
): Promise<OrderItemContext> {
|
||||
const result = await client.query<OrderItemContext>(
|
||||
`
|
||||
SELECT
|
||||
oi.order_id,
|
||||
oi.canonical_product_id,
|
||||
o.provider,
|
||||
o.shop_id,
|
||||
wo.id AS work_order_id,
|
||||
wo.product_rule_id AS work_product_rule_id,
|
||||
COALESCE(oi.fulfillment_mode, 'manual') AS fulfillment_mode
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
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
|
||||
WHERE oi.id = $1
|
||||
LIMIT 1
|
||||
`,
|
||||
[orderItemId],
|
||||
)
|
||||
const row = result.rows[0]
|
||||
if (!row) {
|
||||
throw createHttpError('订单项不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'manual_order_cost_order_item_not_found',
|
||||
})
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析成本版本:优先级 rule > product > shop > provider > global,按业务发生时间匹配。
|
||||
* 人工成本项的 draft 占位版本也接受,避免未启用正式版本时人工单无法入账。
|
||||
*/
|
||||
async function resolveCostVersionId(
|
||||
client: PoolClient,
|
||||
costItemId: number,
|
||||
context: OrderItemContext,
|
||||
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,
|
||||
context.provider,
|
||||
context.shopId,
|
||||
context.canonicalProductId,
|
||||
context.workProductRuleId,
|
||||
],
|
||||
)
|
||||
return result.rows[0]?.id ?? null
|
||||
}
|
||||
|
||||
/** 人工录单的费用类型映射到备用金支出分类。 */
|
||||
function mapFundExpenseCategory(category: string): string {
|
||||
switch (category) {
|
||||
case 'goods_purchase':
|
||||
return 'purchase'
|
||||
case 'compensation':
|
||||
return 'compensation'
|
||||
case 'freight':
|
||||
return 'freight'
|
||||
case 'recharge':
|
||||
return 'platform_recharge'
|
||||
default:
|
||||
return 'other'
|
||||
}
|
||||
}
|
||||
|
||||
function mapFundPayeeType(payeeType: string): string {
|
||||
switch (payeeType) {
|
||||
case 'worker':
|
||||
case 'upstream':
|
||||
return 'supplier'
|
||||
case 'customer':
|
||||
return 'customer'
|
||||
case 'platform':
|
||||
return 'platform'
|
||||
default:
|
||||
return 'other'
|
||||
}
|
||||
}
|
||||
|
||||
function toFundEvidence(evidence: JsonObject): JsonObject[] {
|
||||
const items: JsonObject[] = []
|
||||
const urls = Array.isArray(evidence.urls) ? evidence.urls : []
|
||||
for (const value of urls) {
|
||||
const url = String(value || '').trim()
|
||||
if (url) {
|
||||
items.push({ url, filename: '人工录单凭证', contentType: 'image/*' })
|
||||
}
|
||||
}
|
||||
const reference = String(evidence.reference || '').trim()
|
||||
if (/^https?:\/\//i.test(reference) && !items.some((item) => item.url === reference)) {
|
||||
items.push({ url: reference, filename: '人工录单凭证', contentType: 'image/*' })
|
||||
}
|
||||
return items.slice(0, 6)
|
||||
}
|
||||
|
||||
function buildFundExpenseNote(note: string, reference: string): string {
|
||||
const parts = ['人工录单']
|
||||
if (reference) parts.push(`凭证:${reference}`)
|
||||
if (note) parts.push(note)
|
||||
return parts.join(' · ').slice(0, 500)
|
||||
}
|
||||
|
||||
function normalizeEvidence(value: unknown): JsonObject {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as JsonObject
|
||||
}
|
||||
const text = String(value || '').trim()
|
||||
return text ? { reference: text } : {}
|
||||
}
|
||||
|
||||
function hasEvidence(evidence: JsonObject) {
|
||||
const urls = Array.isArray(evidence.urls) ? evidence.urls : []
|
||||
return (
|
||||
urls.length > 0 ||
|
||||
Boolean(String(evidence.reference || '').trim()) ||
|
||||
Boolean(String(evidence.note || '').trim())
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeAmountFen(value: unknown) {
|
||||
const amount = Math.trunc(Number(value) || 0)
|
||||
if (amount <= 0) {
|
||||
throw createHttpError('支出金额必须大于 0', {
|
||||
statusCode: 400,
|
||||
errorCode: 'manual_order_cost_amount_invalid',
|
||||
})
|
||||
}
|
||||
if (amount > 100_000_000_00) {
|
||||
throw createHttpError('支出金额超出允许范围', {
|
||||
statusCode: 400,
|
||||
errorCode: 'manual_order_cost_amount_too_large',
|
||||
})
|
||||
}
|
||||
return amount
|
||||
}
|
||||
|
||||
function normalizeOptionalId(value: unknown) {
|
||||
const parsed = Number(value)
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : null
|
||||
}
|
||||
|
||||
function normalizeId(value: unknown, message: string) {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
throw createHttpError(message, {
|
||||
statusCode: 400,
|
||||
errorCode: 'manual_order_cost_id_invalid',
|
||||
})
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function normalizeStatus(value: unknown, allowed: Set<string>, fallback: string, message: string) {
|
||||
const text = String(value || '').trim() || fallback
|
||||
if (!allowed.has(text)) {
|
||||
throw createHttpError(message, {
|
||||
statusCode: 400,
|
||||
errorCode: 'manual_order_cost_value_invalid',
|
||||
})
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
function optionalText(value: unknown, maxLength: number) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.slice(0, maxLength)
|
||||
}
|
||||
Reference in New Issue
Block a user