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
@@ -0,0 +1,51 @@
|
||||
-- 106_manual_expense_records.sql —— 备用金账户编号可读化。
|
||||
--
|
||||
-- 2026-10-23 口径调整:公司人工支出登记(manual_expense_records)与审批报销
|
||||
-- 状态机(manual_expense_events)整链下线。人工成本改为「订单页人工录单 →
|
||||
-- 直接从客服备用金扣款 → 直接写成本快照」,不再有登记单、审批与报销。
|
||||
--
|
||||
-- 因此本迁移不再创建这两张表,只保留备用金账户编号可读化:
|
||||
-- manual_expenses(迁移 100)= 客服备用金支出明细,唯一的资金台账
|
||||
-- cost_snapshots(迁移 104)= 人工成本的唯一落点,subject_type='manual_case'
|
||||
--
|
||||
-- 口径(2026-10-23 确认):
|
||||
-- * 人工成本逐单、按实际金额录入,不按工时、不按固定单量/固定单价;
|
||||
-- * 人工支出从客服备用金账户扣款,客服角色只能用本人账户;
|
||||
-- * 历史人工支出找不回来、也不补录,只保证上线后的新单不丢;
|
||||
-- * 没有审批与报销环节,录入即计入成本快照。
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 备用金账户编号改为可读的 FA-<用户名>
|
||||
--
|
||||
-- 历史编号是 FA + 10 位随机十六进制(如 FA7683D6B58B),在「备用金」列表里
|
||||
-- 无法一眼对应到具体客服。新规则:FA-<大写用户名>,重名追加 -2、-3。
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
WITH base AS (
|
||||
SELECT
|
||||
fa.id,
|
||||
'FA-' || COALESCE(
|
||||
NULLIF(
|
||||
trim(
|
||||
both '_' FROM upper(
|
||||
regexp_replace(COALESCE(NULLIF(au.username, ''), 'account-' || fa.id), '[^A-Za-z0-9_]+', '_', 'g')
|
||||
)
|
||||
),
|
||||
''
|
||||
),
|
||||
'ACCOUNT-' || fa.id
|
||||
) AS base_no
|
||||
FROM fund_accounts fa
|
||||
LEFT JOIN admin_users au ON au.id = fa.owner_admin_user_id
|
||||
),
|
||||
numbered AS (
|
||||
SELECT
|
||||
id,
|
||||
base_no,
|
||||
row_number() OVER (PARTITION BY base_no ORDER BY id) AS rn
|
||||
FROM base
|
||||
)
|
||||
UPDATE fund_accounts fa
|
||||
SET account_no = CASE WHEN n.rn = 1 THEN n.base_no ELSE n.base_no || '-' || n.rn END
|
||||
FROM numbered n
|
||||
WHERE fa.id = n.id;
|
||||
@@ -1,201 +0,0 @@
|
||||
-- 106_manual_expense_records.sql —— 公司人工支出登记 + 备用金账户编号可读化。
|
||||
--
|
||||
-- 与迁移 100 的 manual_expenses 严格区分:
|
||||
-- manual_expenses = 客服备用金支出明细(减少客服余额)
|
||||
-- manual_expense_records = 公司口径人工支出登记(谁为哪个订单付了多少钱)
|
||||
-- 两者通过 fund_expense_id 关联,不合并、不改名、不共用索引名。
|
||||
--
|
||||
-- 口径(2026-10-23 确认):
|
||||
-- * 人工成本逐单、按实际金额录入,不按工时、不按固定单量/固定单价;
|
||||
-- * 人工支出从客服备用金账户扣款,客服角色只能用本人账户;
|
||||
-- * 历史人工支出不补录,只取少量样本做校准;
|
||||
-- * 客服必须在上线后 24 小时内为人工单录入成本。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS manual_expense_records (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
expense_no TEXT NOT NULL,
|
||||
order_id BIGINT REFERENCES orders(id) ON DELETE SET NULL,
|
||||
order_item_id BIGINT REFERENCES order_items(id) ON DELETE SET NULL,
|
||||
work_order_id BIGINT REFERENCES work_orders(id) ON DELETE SET NULL,
|
||||
fund_expense_id BIGINT REFERENCES manual_expenses(id) ON DELETE SET NULL,
|
||||
cost_item_id BIGINT NOT NULL REFERENCES cost_items(id) ON DELETE RESTRICT,
|
||||
expense_category TEXT NOT NULL DEFAULT 'other',
|
||||
amount_fen BIGINT NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'CNY',
|
||||
paid_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL,
|
||||
paid_by_name TEXT NOT NULL DEFAULT '',
|
||||
payee_type TEXT NOT NULL DEFAULT 'other',
|
||||
payee_name TEXT NOT NULL DEFAULT '',
|
||||
payee_account TEXT NOT NULL DEFAULT '',
|
||||
payment_method TEXT NOT NULL DEFAULT 'other',
|
||||
paid_at TIMESTAMPTZ NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'submitted',
|
||||
approved_by TEXT NOT NULL DEFAULT '',
|
||||
approved_at TIMESTAMPTZ,
|
||||
rejected_by TEXT NOT NULL DEFAULT '',
|
||||
rejected_at TIMESTAMPTZ,
|
||||
reject_reason TEXT NOT NULL DEFAULT '',
|
||||
reimbursed_by TEXT NOT NULL DEFAULT '',
|
||||
reimbursed_at TIMESTAMPTZ,
|
||||
evidence_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
cost_snapshot_id BIGINT REFERENCES cost_snapshots(id) ON DELETE RESTRICT,
|
||||
reversal_of_id BIGINT REFERENCES manual_expense_records(id) ON DELETE RESTRICT,
|
||||
expense_batch_no TEXT NOT NULL DEFAULT '',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_manual_expense_records_expense_no
|
||||
ON manual_expense_records (expense_no);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expense_records_order
|
||||
ON manual_expense_records (order_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expense_records_item
|
||||
ON manual_expense_records (order_item_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expense_records_work_order
|
||||
ON manual_expense_records (work_order_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expense_records_paid_at
|
||||
ON manual_expense_records (paid_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expense_records_status
|
||||
ON manual_expense_records (status, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expense_records_cost_item
|
||||
ON manual_expense_records (cost_item_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expense_records_payer
|
||||
ON manual_expense_records (paid_by_admin_user_id, paid_at DESC);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_manual_expense_records_fund_expense
|
||||
ON manual_expense_records (fund_expense_id)
|
||||
WHERE fund_expense_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expense_records_batch
|
||||
ON manual_expense_records (expense_batch_no)
|
||||
WHERE expense_batch_no <> '';
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expense_records_amount'
|
||||
) THEN
|
||||
ALTER TABLE manual_expense_records
|
||||
ADD CONSTRAINT chk_manual_expense_records_amount CHECK (amount_fen > 0);
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expense_records_status'
|
||||
) THEN
|
||||
ALTER TABLE manual_expense_records
|
||||
ADD CONSTRAINT chk_manual_expense_records_status
|
||||
CHECK (status IN ('draft', 'submitted', 'approved', 'rejected', 'reimbursed', 'reversed'));
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expense_records_payee_type'
|
||||
) THEN
|
||||
ALTER TABLE manual_expense_records
|
||||
ADD CONSTRAINT chk_manual_expense_records_payee_type
|
||||
CHECK (payee_type IN ('worker', 'upstream', 'customer', 'platform', 'other'));
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expense_records_method'
|
||||
) THEN
|
||||
ALTER TABLE manual_expense_records
|
||||
ADD CONSTRAINT chk_manual_expense_records_method
|
||||
CHECK (payment_method IN ('wechat', 'alipay', 'bank', 'cash', 'platform_balance', 'other'));
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expense_records_category'
|
||||
) THEN
|
||||
ALTER TABLE manual_expense_records
|
||||
ADD CONSTRAINT chk_manual_expense_records_category
|
||||
CHECK (
|
||||
expense_category IN (
|
||||
'goods_purchase', 'compensation', 'refund_advance', 'service_fee',
|
||||
'freight', 'recharge', 'other'
|
||||
)
|
||||
);
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expense_records_approved'
|
||||
) THEN
|
||||
ALTER TABLE manual_expense_records
|
||||
ADD CONSTRAINT chk_manual_expense_records_approved
|
||||
CHECK (
|
||||
status NOT IN ('approved', 'reimbursed')
|
||||
OR (approved_at IS NOT NULL AND approved_by <> '')
|
||||
);
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expense_records_rejected'
|
||||
) THEN
|
||||
ALTER TABLE manual_expense_records
|
||||
ADD CONSTRAINT chk_manual_expense_records_rejected
|
||||
CHECK (
|
||||
status <> 'rejected'
|
||||
OR (rejected_at IS NOT NULL AND rejected_by <> '' AND reject_reason <> '')
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMENT ON TABLE manual_expense_records IS
|
||||
'公司人工支出登记:逐单实际金额;付款项目/收款方/凭证/审批;与客服备用金 manual_expenses 通过 fund_expense_id 关联';
|
||||
COMMENT ON COLUMN manual_expense_records.fund_expense_id IS
|
||||
'来源客服备用金支出(manual_expenses.id);人工支出必须从备用金扣款,因此正常必有值';
|
||||
COMMENT ON COLUMN manual_expense_records.cost_item_id IS
|
||||
'付款项目 -> cost_items;为空表示未归类,报表必须暴露未归类占比';
|
||||
COMMENT ON COLUMN manual_expense_records.evidence_json IS
|
||||
'凭证:支付截图对象 key、流水号、聊天记录 key 等';
|
||||
COMMENT ON COLUMN manual_expense_records.reversal_of_id IS
|
||||
'预留:退款/仲裁接入后按反向登记冲正,禁止 UPDATE 已通过记录';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS manual_expense_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
expense_id BIGINT NOT NULL REFERENCES manual_expense_records(id) ON DELETE CASCADE,
|
||||
event_type TEXT NOT NULL,
|
||||
from_status TEXT NOT NULL DEFAULT '',
|
||||
to_status TEXT NOT NULL DEFAULT '',
|
||||
actor_type TEXT NOT NULL DEFAULT 'admin',
|
||||
actor_id TEXT NOT NULL DEFAULT '',
|
||||
actor_name TEXT NOT NULL DEFAULT '',
|
||||
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expense_events_expense
|
||||
ON manual_expense_events (expense_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expense_events_type
|
||||
ON manual_expense_events (event_type, created_at DESC);
|
||||
|
||||
COMMENT ON TABLE manual_expense_events IS
|
||||
'人工支出状态变更审计:只追加,不更新、不删除';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 备用金账户编号改为可读的 FA-<用户名>
|
||||
--
|
||||
-- 历史编号是 FA + 10 位随机十六进制(如 FA7683D6B58B),在「备用金」列表里
|
||||
-- 无法一眼对应到具体客服。新规则:FA-<大写用户名>,重名追加 -2、-3。
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
WITH base AS (
|
||||
SELECT
|
||||
fa.id,
|
||||
'FA-' || COALESCE(
|
||||
NULLIF(
|
||||
trim(
|
||||
both '_' FROM upper(
|
||||
regexp_replace(COALESCE(NULLIF(au.username, ''), 'account-' || fa.id), '[^A-Za-z0-9_]+', '_', 'g')
|
||||
)
|
||||
),
|
||||
''
|
||||
),
|
||||
'ACCOUNT-' || fa.id
|
||||
) AS base_no
|
||||
FROM fund_accounts fa
|
||||
LEFT JOIN admin_users au ON au.id = fa.owner_admin_user_id
|
||||
),
|
||||
numbered AS (
|
||||
SELECT
|
||||
id,
|
||||
base_no,
|
||||
row_number() OVER (PARTITION BY base_no ORDER BY id) AS rn
|
||||
FROM base
|
||||
)
|
||||
UPDATE fund_accounts fa
|
||||
SET account_no = CASE WHEN n.rn = 1 THEN n.base_no ELSE n.base_no || '-' || n.rn END
|
||||
FROM numbered n
|
||||
WHERE fa.id = n.id;
|
||||
@@ -0,0 +1,66 @@
|
||||
-- 108_drop_manual_expense_records.sql —— 下线公司人工支出登记与审批报销状态机。
|
||||
--
|
||||
-- 背景(2026-10-23 确认):
|
||||
-- 人工成本不再走「登记单 → 审批 → 报销」三段式。订单页人工录单直接扣客服备用金、
|
||||
-- 直接写成本快照,备用金支出明细(manual_expenses,迁移 100)是唯一资金台账。
|
||||
-- 因此 manual_expense_records(登记单)与 manual_expense_events(状态机审计)
|
||||
-- 整链下线。
|
||||
--
|
||||
-- 处理顺序:
|
||||
-- 1. 先把挂在人工登记单上的成本快照改挂到备用金支出单上(若存在);
|
||||
-- 2. 再删表与索引;
|
||||
-- 3. 成本项 sys_manual_service 的备注改为「人工录单直写」口径。
|
||||
--
|
||||
-- 成本快照主体约定(改后唯一):
|
||||
-- cost_snapshots.subject_type = 'manual_case'
|
||||
-- cost_snapshots.subject_id = manual_expenses.id(客服备用金支出单 id)
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 1. 修正历史成本快照的主体指向
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 旧登记单的 subject_id 指向 manual_expense_records.id。旧数据里人工登记单没有
|
||||
-- 关联备用金支出单(fund_expense_id 为空)的,保留原主体 id 不动,避免误删成本;
|
||||
-- 有 fund_expense_id 的改挂到备用金支出单。
|
||||
|
||||
UPDATE cost_snapshots cs
|
||||
SET subject_id = mer.fund_expense_id
|
||||
FROM manual_expense_records mer
|
||||
WHERE cs.subject_type = 'manual_case'
|
||||
AND mer.fund_expense_id IS NOT NULL
|
||||
AND cs.subject_id = mer.id
|
||||
AND cs.subject_id <> mer.fund_expense_id;
|
||||
|
||||
-- 若同一备用金支出单已存在有效快照,保留先写的,把重复的作废(superseded_at)。
|
||||
UPDATE cost_snapshots dup
|
||||
SET superseded_at = NOW()
|
||||
WHERE dup.subject_type = 'manual_case'
|
||||
AND dup.superseded_at IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM cost_snapshots keep
|
||||
WHERE keep.subject_type = 'manual_case'
|
||||
AND keep.subject_id = dup.subject_id
|
||||
AND keep.cost_item_id = dup.cost_item_id
|
||||
AND keep.superseded_at IS NULL
|
||||
AND keep.id < dup.id
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 2. 删除人工支出登记与状态机
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
DROP TABLE IF EXISTS manual_expense_events;
|
||||
|
||||
DROP TABLE IF EXISTS manual_expense_records;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 3. 人工成本项备注按新口径更新
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
UPDATE cost_items
|
||||
SET note = '系统内置:订单页人工录单直接从客服备用金扣款并写入 actual 快照;逐单实际金额,不按工时或固定单价',
|
||||
updated_at = NOW()
|
||||
WHERE code = 'sys_manual_service';
|
||||
|
||||
COMMENT ON TABLE cost_snapshots IS
|
||||
'成本快照:订单项/工单/人工单实际使用的成本版本与金额;履约方式看 order_items.fulfillment_mode;人工单 subject_id 指向 manual_expenses.id';
|
||||
@@ -0,0 +1,44 @@
|
||||
-- 109_isolate_legacy_manual_snapshots.sql —— 隔离历史人工成本快照的主体 id 空间。
|
||||
--
|
||||
-- 背景(2026-10-23):
|
||||
-- 人工成本的快照主体从「人工支出登记单 id」改成「客服备用金支出单 id」
|
||||
-- (manual_expenses.id),两套 id 空间各自从 1 开始,必然撞车。
|
||||
-- 迁移 108 把有 fund_expense_id 的旧快照改挂到备用金支出单,但**没有**关联
|
||||
-- 备用金支出单的孤儿快照(fund_expense_id 为空)保留了原 subject_id,
|
||||
-- 于是会与新建的 manual_expenses.id 撞唯一索引
|
||||
-- uq_cost_snapshots_active_subject_item。
|
||||
--
|
||||
-- 实测后果:人工录单第二笔时 INSERT ... ON CONFLICT DO NOTHING 静默跳过,
|
||||
-- 钱从备用金扣了、成本快照却没写成 —— 账实不符。
|
||||
--
|
||||
-- 处理:把「来源是旧人工支出登记单」的历史快照主体 id 改成负数,
|
||||
-- 与 manual_expenses.id(正数)彻底隔离。这些是历史人工成本,按口径不再补录,
|
||||
-- 保留金额供毛利参考,只是不再与新链路争主体 id。
|
||||
--
|
||||
-- 判定依据:metadata_json.source = 'manual_expense_record'(旧登记链写入时打的标),
|
||||
-- 且该主体 id 没有对应的有效 manual_expenses 关联标记。
|
||||
--
|
||||
-- 幂等:可重复执行。
|
||||
|
||||
UPDATE cost_snapshots cs
|
||||
SET
|
||||
subject_id = -cs.subject_id,
|
||||
metadata_json = cs.metadata_json
|
||||
|| jsonb_build_object(
|
||||
'source', 'legacy_manual_expense_record',
|
||||
'legacySubjectId', cs.subject_id,
|
||||
'isolatedAt', NOW()
|
||||
)
|
||||
WHERE cs.subject_type = 'manual_case'
|
||||
AND cs.subject_id > 0
|
||||
AND cs.metadata_json->>'source' = 'manual_expense_record'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM cost_snapshots other
|
||||
WHERE other.subject_type = 'manual_case'
|
||||
AND other.subject_id = -cs.subject_id
|
||||
AND other.cost_item_id = cs.cost_item_id
|
||||
AND other.superseded_at IS NULL
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN cost_snapshots.subject_id IS
|
||||
'主体 id:order_item→order_items.id;work_order→work_orders.id;manual_case→manual_expenses.id;负数表示历史孤儿快照(已与新链路隔离)';
|
||||
@@ -12,16 +12,9 @@ import {
|
||||
rebuildSalesDailyFacts,
|
||||
} from '../../services/finance/sales-facts-service.js'
|
||||
import {
|
||||
approveManualExpense,
|
||||
createManualExpense,
|
||||
createManualOrderCost,
|
||||
getManualExpense,
|
||||
getManualExpenseOverview,
|
||||
listManualExpenseCostItems,
|
||||
listManualExpenses,
|
||||
rejectManualExpense,
|
||||
reimburseManualExpense,
|
||||
} from '../../services/finance/manual-expense-service.js'
|
||||
listManualOrderCostItems,
|
||||
} from '../../services/finance/manual-order-cost-service.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import {
|
||||
createJsonHandler,
|
||||
@@ -122,66 +115,12 @@ router.post(
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/finance/manual-expenses/overview',
|
||||
'/finance/manual-order-cost-items',
|
||||
requireAnyAdminPermission(['finance.view', 'fund.view']),
|
||||
createJsonHandler((req) => getManualExpenseOverview(req.query as Record<string, unknown>), {
|
||||
createJsonHandler(() => listManualOrderCostItems(), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取人工支出汇总失败',
|
||||
scope: '[admin/finance/manual-expenses/overview]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/finance/manual-expenses',
|
||||
requireAnyAdminPermission(['finance.view', 'fund.view']),
|
||||
createJsonHandler((req) => listManualExpenses(req.query as Record<string, unknown>), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取人工支出失败',
|
||||
scope: '[admin/finance/manual-expenses]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/finance/manual-expenses/cost-items',
|
||||
requireAnyAdminPermission(['finance.view', 'fund.view']),
|
||||
createJsonHandler(() => listManualExpenseCostItems(), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取人工支出成本项失败',
|
||||
scope: '[admin/finance/manual-expenses/cost-items]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/finance/manual-expenses/:expenseId',
|
||||
requireAnyAdminPermission(['finance.view', 'fund.view']),
|
||||
createJsonHandler((req) => getManualExpense(idOf(req, 'expenseId')), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取人工支出详情失败',
|
||||
scope: '[admin/finance/manual-expenses/:expenseId]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/finance/manual-expenses',
|
||||
requireAnyAdminPermission(['fund.expense', 'finance.config']),
|
||||
createJsonHandler((req) => createManualExpense(bodyOf(req), sessionOf(req)), {
|
||||
successMessage: '人工支出已登记',
|
||||
errorMessage: '登记人工支出失败',
|
||||
scope: '[admin/finance/manual-expenses]',
|
||||
audit: (req, data) => {
|
||||
const record = data as { id?: number; expenseNo?: string; amountFen?: number }
|
||||
return {
|
||||
action: 'manual_expense_created',
|
||||
targetType: 'manual_expense',
|
||||
targetId: String(record.id || ''),
|
||||
data: {
|
||||
expenseNo: record.expenseNo || '',
|
||||
amountFen: record.amountFen || 0,
|
||||
orderId: req.body?.orderId || '',
|
||||
orderItemId: req.body?.orderItemId || '',
|
||||
},
|
||||
}
|
||||
},
|
||||
errorMessage: '读取人工录单成本项失败',
|
||||
scope: '[admin/finance/manual-order-cost-items]',
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -194,76 +133,24 @@ router.post(
|
||||
scope: '[admin/finance/manual-order-costs]',
|
||||
audit: (req, data) => {
|
||||
const result = data as {
|
||||
record?: { id?: number; expenseNo?: string; amountFen?: number; orderId?: number }
|
||||
effective?: boolean
|
||||
expenseNo?: string
|
||||
amountFen?: number
|
||||
orderId?: number
|
||||
costSnapshotId?: number | null
|
||||
}
|
||||
return {
|
||||
action: 'manual_order_cost_created',
|
||||
targetType: 'manual_expense',
|
||||
targetId: String(result.record?.id || ''),
|
||||
targetType: 'fund_expense',
|
||||
targetId: result.expenseNo || '',
|
||||
data: {
|
||||
expenseNo: result.record?.expenseNo || '',
|
||||
amountFen: result.record?.amountFen || 0,
|
||||
orderId: result.record?.orderId || req.body?.orderId || '',
|
||||
effective: result.effective === true,
|
||||
expenseNo: result.expenseNo || '',
|
||||
amountFen: result.amountFen || 0,
|
||||
orderId: result.orderId || req.body?.orderId || '',
|
||||
costSnapshotId: result.costSnapshotId || null,
|
||||
},
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/finance/manual-expenses/:expenseId/approve',
|
||||
requireAnyAdminPermission(['finance.review']),
|
||||
createJsonHandler(
|
||||
(req) => approveManualExpense(idOf(req, 'expenseId'), bodyOf(req), sessionOf(req)),
|
||||
{
|
||||
successMessage: '人工支出已通过',
|
||||
errorMessage: '审核人工支出失败',
|
||||
scope: '[admin/finance/manual-expenses/:expenseId/approve]',
|
||||
audit: (req, data) => ({
|
||||
action: 'manual_expense_approved',
|
||||
targetType: 'manual_expense',
|
||||
targetId: String((data as { id?: number })?.id || req.params.expenseId || ''),
|
||||
data: { note: req.body?.note || '' },
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/finance/manual-expenses/:expenseId/reject',
|
||||
requireAnyAdminPermission(['finance.review']),
|
||||
createJsonHandler(
|
||||
(req) => rejectManualExpense(idOf(req, 'expenseId'), bodyOf(req), sessionOf(req)),
|
||||
{
|
||||
successMessage: '人工支出已驳回',
|
||||
errorMessage: '驳回人工支出失败',
|
||||
scope: '[admin/finance/manual-expenses/:expenseId/reject]',
|
||||
audit: (req, data) => ({
|
||||
action: 'manual_expense_rejected',
|
||||
targetType: 'manual_expense',
|
||||
targetId: String((data as { id?: number })?.id || req.params.expenseId || ''),
|
||||
data: { reason: req.body?.rejectReason || req.body?.reject_reason || '' },
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/finance/manual-expenses/:expenseId/reimburse',
|
||||
requireAnyAdminPermission(['finance.review']),
|
||||
createJsonHandler((req) => reimburseManualExpense(idOf(req, 'expenseId'), sessionOf(req)), {
|
||||
successMessage: '人工支出已标记报销',
|
||||
errorMessage: '标记报销失败',
|
||||
scope: '[admin/finance/manual-expenses/:expenseId/reimburse]',
|
||||
audit: (req, data) => ({
|
||||
action: 'manual_expense_reimbursed',
|
||||
targetType: 'manual_expense',
|
||||
targetId: String((data as { id?: number })?.id || req.params.expenseId || ''),
|
||||
data: {},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
export default router
|
||||
|
||||
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