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)
|
||||
}
|
||||
@@ -22,7 +22,6 @@ import WorkerLayout from '@/layouts/WorkerLayout'
|
||||
const AdminDashboardPage = lazy(() => import('@/pages/admin/AdminDashboardPage'))
|
||||
const AdminFinancePage = lazy(() => import('@/pages/admin/AdminFinancePage'))
|
||||
const AdminSalesProfitPage = lazy(() => import('@/pages/admin/AdminSalesProfitPage'))
|
||||
const AdminManualExpensePage = lazy(() => import('@/pages/admin/AdminManualExpensePage'))
|
||||
const AdminCostCenterPage = lazy(() => import('@/pages/admin/AdminCostCenterPage'))
|
||||
const AdminFundPage = lazy(() => import('@/pages/admin/AdminFundPage'))
|
||||
const AdminAuditLogsPage = lazy(() => import('@/pages/admin/AdminAuditLogsPage'))
|
||||
@@ -153,7 +152,6 @@ function AdminApplication() {
|
||||
</Route>
|
||||
<Route element={<RequireRole roles={['admin', 'operator', 'support', 'finance']} />}>
|
||||
<Route path="fund" element={<AdminFundPage />} />
|
||||
<Route path="manual-expenses" element={<AdminManualExpensePage />} />
|
||||
</Route>
|
||||
<Route path="worker-platform" element={<AdminWorkerPlatformPage />} />
|
||||
<Route element={<RequireRole roles={['admin', 'operator', 'support']} />}>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { App, Alert, Form, Input, InputNumber, Modal, Select, Switch } from 'antd'
|
||||
import { App, Alert, Form, Input, InputNumber, Modal, Select } from 'antd'
|
||||
import type { FormInstance } from 'antd'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { createAdminManualOrderCost, fetchAdminFundAccounts } from '@/services/admin'
|
||||
import { getAdminRole, hasAdminPermission } from '@/utils/admin-auth'
|
||||
import { getAdminRole } from '@/utils/admin-auth'
|
||||
|
||||
export type ManualOrderCostItem = {
|
||||
orderItemId: number
|
||||
@@ -31,7 +31,6 @@ type ManualOrderCostFormValues = {
|
||||
paymentMethod?: string
|
||||
evidence?: string
|
||||
note?: string
|
||||
effective?: boolean
|
||||
}
|
||||
|
||||
const CATEGORY_OPTIONS = [
|
||||
@@ -68,7 +67,6 @@ export default function ManualOrderCostModal({
|
||||
const { message } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const [form] = Form.useForm<ManualOrderCostFormValues>()
|
||||
const [canApprove, setCanApprove] = useState(false)
|
||||
const fundAccountsQuery = useQuery({
|
||||
queryKey: ['admin-fund-accounts', 'agent'],
|
||||
queryFn: () =>
|
||||
@@ -82,7 +80,6 @@ export default function ManualOrderCostModal({
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setCanApprove(hasAdminPermission('finance.review'))
|
||||
form.resetFields()
|
||||
form.setFieldsValue({
|
||||
orderItemId: items.length === 1 ? items[0].orderItemId : undefined,
|
||||
@@ -90,7 +87,6 @@ export default function ManualOrderCostModal({
|
||||
amountYuan: undefined,
|
||||
expenseCategory: 'service_fee',
|
||||
paymentMethod: 'other',
|
||||
effective: hasAdminPermission('finance.review'),
|
||||
})
|
||||
}, [form, items, open, soleFundAccountId])
|
||||
|
||||
@@ -108,19 +104,16 @@ export default function ManualOrderCostModal({
|
||||
evidenceJson: values.evidence
|
||||
? { reference: values.evidence }
|
||||
: { note: values.note || '订单页人工录单' },
|
||||
effective: canApprove ? values.effective !== false : false,
|
||||
}),
|
||||
onSuccess: (response) => {
|
||||
const result = response.data
|
||||
if (result.effective) {
|
||||
message.success(`已计入订单成本,支出单号 ${result.record.expenseNo}`)
|
||||
} else if (result.reason === 'approval_failed') {
|
||||
message.warning(result.message || '已提交,但自动生效失败,请到人工支出页审核')
|
||||
if (result.warning) {
|
||||
message.warning(result.warning)
|
||||
} else {
|
||||
message.success(`已提交人工支出 ${result.record.expenseNo},财务审核后计入毛利`)
|
||||
message.success(`已计入订单成本,支出单号 ${result.expenseNo}`)
|
||||
}
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-manual-expenses'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-fund-accounts'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-fund-expenses'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-sales-profit-overview'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-sales-facts'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-order-detail', String(orderId)] })
|
||||
@@ -140,7 +133,7 @@ export default function ManualOrderCostModal({
|
||||
<Modal
|
||||
open={open}
|
||||
title="人工录单 / 录入订单成本"
|
||||
okText={canApprove ? '确认录入并计入成本' : '提交审核'}
|
||||
okText="确认录入并计入成本"
|
||||
cancelText="取消"
|
||||
confirmLoading={mutation.isPending}
|
||||
onOk={() => void submit()}
|
||||
@@ -158,7 +151,7 @@ export default function ManualOrderCostModal({
|
||||
showIcon
|
||||
style={{ marginBottom: 14 }}
|
||||
message="每一单单独录实际金额,不按小时、不按单量套模板"
|
||||
description="成本会从所选客服备用金账户扣减,并关联到订单号;只有一笔订单项时自动带入,多笔时需要选择具体订单项。审核通过后写入成本快照,并计入销售毛利。"
|
||||
description="成本会从所选客服备用金账户扣减,并关联到订单号;只有一笔订单项时自动带入,多笔时需要选择具体订单项。录入即写入成本快照并计入销售毛利,没有审批与报销环节。"
|
||||
/>
|
||||
<Form.Item label="订单号">
|
||||
<Input value={orderNo} readOnly />
|
||||
@@ -272,23 +265,6 @@ export default function ManualOrderCostModal({
|
||||
placeholder="这一单实际做了什么、为什么是这个金额"
|
||||
/>
|
||||
</Form.Item>
|
||||
{canApprove ? (
|
||||
<Form.Item
|
||||
name="effective"
|
||||
label="立即计入成本"
|
||||
valuePropName="checked"
|
||||
extra="关闭后先提交,等财务在人工支出页审核通过再计入毛利。"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="提交后需要财务审核"
|
||||
description="当前账号没有 finance.review 权限,录入后状态为待审核,审核通过才会写入成本快照。"
|
||||
/>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
BugOutlined,
|
||||
DashboardOutlined,
|
||||
FileSearchOutlined,
|
||||
FileTextOutlined,
|
||||
LogoutOutlined,
|
||||
LineChartOutlined,
|
||||
MenuFoldOutlined,
|
||||
@@ -135,7 +134,6 @@ export default function AdminLayout() {
|
||||
? [
|
||||
{ key: '/admin/finance', icon: <AccountBookOutlined />, label: '财务' },
|
||||
{ key: '/admin/sales-profit', icon: <LineChartOutlined />, label: '销售毛利' },
|
||||
{ key: '/admin/manual-expenses', icon: <FileTextOutlined />, label: '人工支出' },
|
||||
{ key: '/admin/cost-center', icon: <ApartmentOutlined />, label: '商品与成本' },
|
||||
{ key: '/admin/fund', icon: <WalletOutlined />, label: '备用金' },
|
||||
{ key: '/admin/worker-platform', icon: <TrophyOutlined />, label: '资金审核' },
|
||||
@@ -148,11 +146,6 @@ export default function AdminLayout() {
|
||||
icon: <WalletOutlined />,
|
||||
label: role === 'support' ? '我的备用金' : '备用金',
|
||||
},
|
||||
{
|
||||
key: '/admin/manual-expenses',
|
||||
icon: <FileTextOutlined />,
|
||||
label: '人工支出',
|
||||
},
|
||||
...(role === 'support'
|
||||
? []
|
||||
: [
|
||||
|
||||
@@ -1,778 +0,0 @@
|
||||
import {
|
||||
CheckOutlined,
|
||||
CloseOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
WalletOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
DatePicker,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router'
|
||||
|
||||
import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
|
||||
import PageHeader from '@/components/admin/PageHeader'
|
||||
import dayjs from '@/lib/dayjs'
|
||||
import {
|
||||
approveAdminManualExpense,
|
||||
createAdminManualExpense,
|
||||
fetchAdminFundAccounts,
|
||||
fetchAdminManualExpenseCostItems,
|
||||
fetchAdminManualExpenseOverview,
|
||||
fetchAdminManualExpenses,
|
||||
rejectAdminManualExpense,
|
||||
reimburseAdminManualExpense,
|
||||
} from '@/services/admin'
|
||||
import type { AdminManualExpense, AdminManualExpenseStatus } from '@/types/admin'
|
||||
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
|
||||
import { hasAdminPermission, getAdminRole } from '@/utils/admin-auth'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
import { formatMoney } from './panels/shared'
|
||||
|
||||
type FilterForm = { dateRange?: unknown; status?: string; keyword?: string }
|
||||
|
||||
type ExpenseForm = {
|
||||
orderId?: number
|
||||
orderItemId?: number
|
||||
fundAccountId?: number
|
||||
costItemId?: number
|
||||
amountYuan?: number
|
||||
expenseCategory: string
|
||||
payeeType: string
|
||||
payeeName: string
|
||||
payeeAccount?: string
|
||||
paymentMethod: string
|
||||
paidAt?: unknown
|
||||
evidenceReference: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
const STATUS_META: Record<AdminManualExpenseStatus, { label: string; color: string }> = {
|
||||
draft: { label: '草稿', color: 'default' },
|
||||
submitted: { label: '待审核', color: 'orange' },
|
||||
approved: { label: '已通过', color: 'green' },
|
||||
rejected: { label: '已驳回', color: 'red' },
|
||||
reimbursed: { label: '已报销', color: 'blue' },
|
||||
reversed: { label: '已冲正', color: 'purple' },
|
||||
}
|
||||
|
||||
const CATEGORY_OPTIONS = [
|
||||
{ value: 'goods_purchase', label: '采购拿货' },
|
||||
{ value: 'compensation', label: '客户赔偿' },
|
||||
{ value: 'refund_advance', label: '退款垫付' },
|
||||
{ value: 'service_fee', label: '服务费' },
|
||||
{ value: 'freight', label: '运费' },
|
||||
{ value: 'recharge', label: '平台充值' },
|
||||
{ value: 'other', label: '其他' },
|
||||
]
|
||||
|
||||
const PAYEE_TYPE_OPTIONS = [
|
||||
{ value: 'worker', label: '打手' },
|
||||
{ value: 'upstream', label: '上游/供货' },
|
||||
{ value: 'customer', label: '客户' },
|
||||
{ value: 'platform', label: '平台' },
|
||||
{ value: 'other', label: '其他' },
|
||||
]
|
||||
|
||||
const PAYMENT_METHOD_OPTIONS = [
|
||||
{ value: 'wechat', label: '微信' },
|
||||
{ value: 'alipay', label: '支付宝' },
|
||||
{ value: 'bank', label: '银行卡' },
|
||||
{ value: 'cash', label: '现金' },
|
||||
{ value: 'platform_balance', label: '平台余额' },
|
||||
{ value: 'other', label: '其他' },
|
||||
]
|
||||
|
||||
export default function AdminManualExpensePage() {
|
||||
const { message, modal } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const [searchParams] = useSearchParams()
|
||||
const [filterForm] = Form.useForm<FilterForm>()
|
||||
const [expenseForm] = Form.useForm<ExpenseForm>()
|
||||
const canReview = hasAdminPermission('finance.review')
|
||||
const canCreate = hasAdminPermission('fund.expense') || hasAdminPermission('finance.config')
|
||||
|
||||
const [filters, setFilters] = useState({
|
||||
dateFrom: '',
|
||||
dateTo: '',
|
||||
status: '',
|
||||
keyword: '',
|
||||
})
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [actionId, setActionId] = useState<number | null>(null)
|
||||
const [rejectTarget, setRejectTarget] = useState<AdminManualExpense | null>(null)
|
||||
const [rejectReason, setRejectReason] = useState('')
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: ['admin-manual-expenses', filters, page, pageSize],
|
||||
queryFn: () => fetchAdminManualExpenses({ ...filters, page, pageSize }),
|
||||
})
|
||||
const overviewQuery = useQuery({
|
||||
queryKey: ['admin-manual-expense-overview', filters],
|
||||
queryFn: () => fetchAdminManualExpenseOverview(filters),
|
||||
})
|
||||
const costItemsQuery = useQuery({
|
||||
queryKey: ['admin-manual-expense-cost-items'],
|
||||
queryFn: fetchAdminManualExpenseCostItems,
|
||||
})
|
||||
const fundAccountsQuery = useQuery({
|
||||
queryKey: ['admin-fund-accounts', 'agent'],
|
||||
queryFn: () =>
|
||||
fetchAdminFundAccounts({ accountType: 'agent', status: 'active', pageSize: 200 }),
|
||||
enabled: createOpen,
|
||||
})
|
||||
|
||||
const list = listQuery.data?.data
|
||||
const overview = overviewQuery.data?.data?.overview
|
||||
const costItems = costItemsQuery.data?.data.items || []
|
||||
const fundAccounts = fundAccountsQuery.data?.data.items || []
|
||||
const isSupport = getAdminRole() === 'support'
|
||||
const ownFundAccount = isSupport ? fundAccounts[0] : undefined
|
||||
|
||||
useEffect(() => {
|
||||
const orderId = Number(searchParams.get('orderId') || 0)
|
||||
if (Number.isInteger(orderId) && orderId > 0) {
|
||||
setCreateOpen(true)
|
||||
expenseForm.setFieldsValue({ orderId })
|
||||
}
|
||||
}, [expenseForm, searchParams])
|
||||
|
||||
useEffect(() => {
|
||||
if (!createOpen) return
|
||||
const current = expenseForm.getFieldValue('costItemId')
|
||||
if (current) return
|
||||
const systemItem = costItems.find(
|
||||
(item: { code: string; id: number }) => item.code === 'sys_manual_service',
|
||||
)
|
||||
if (systemItem) expenseForm.setFieldValue('costItemId', systemItem.id)
|
||||
}, [costItems, createOpen, expenseForm])
|
||||
|
||||
useEffect(() => {
|
||||
if (!createOpen) return
|
||||
if (isSupport && !ownFundAccount) return
|
||||
if (fundAccounts.length !== 1 && !ownFundAccount) return
|
||||
if (!expenseForm.getFieldValue('fundAccountId')) {
|
||||
expenseForm.setFieldValue('fundAccountId', ownFundAccount?.id || fundAccounts[0]?.id)
|
||||
}
|
||||
}, [createOpen, expenseForm, fundAccounts, isSupport, ownFundAccount])
|
||||
|
||||
function refreshAll() {
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-manual-expenses'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-manual-expense-overview'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-fund-accounts'] })
|
||||
}
|
||||
|
||||
function applyFilters(values: FilterForm) {
|
||||
const range = Array.isArray(values.dateRange) ? values.dateRange : []
|
||||
setPage(1)
|
||||
setFilters({
|
||||
dateFrom: range[0] ? dayjs(range[0]).format('YYYY-MM-DD') : '',
|
||||
dateTo: range[1] ? dayjs(range[1]).format('YYYY-MM-DD') : '',
|
||||
status: values.status || '',
|
||||
keyword: String(values.keyword || '').trim(),
|
||||
})
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filterForm.resetFields()
|
||||
setPage(1)
|
||||
setFilters({ dateFrom: '', dateTo: '', status: '', keyword: '' })
|
||||
}
|
||||
|
||||
async function submitExpense() {
|
||||
const values = await expenseForm.validateFields()
|
||||
setCreating(true)
|
||||
try {
|
||||
const amountFen = Math.round(Number(values.amountYuan || 0) * 100)
|
||||
const paidAt = values.paidAt
|
||||
? dayjs(values.paidAt as Dayjs).format('YYYY-MM-DD HH:mm:ss')
|
||||
: dayjs().format('YYYY-MM-DD HH:mm:ss')
|
||||
const response = await createAdminManualExpense({
|
||||
orderId: values.orderId || undefined,
|
||||
orderItemId: values.orderItemId || undefined,
|
||||
fundAccountId: values.fundAccountId,
|
||||
costItemId: values.costItemId || undefined,
|
||||
amountFen,
|
||||
expenseCategory: values.expenseCategory,
|
||||
payeeType: values.payeeType,
|
||||
payeeName: values.payeeName,
|
||||
payeeAccount: values.payeeAccount || '',
|
||||
paymentMethod: values.paymentMethod,
|
||||
paidAt,
|
||||
evidenceJson: { reference: values.evidenceReference },
|
||||
note: values.note || '',
|
||||
})
|
||||
message.success(`已登记 ${response.data.expenseNo}`)
|
||||
setCreateOpen(false)
|
||||
expenseForm.resetFields()
|
||||
refreshAll()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '登记人工支出失败')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
function approve(row: AdminManualExpense) {
|
||||
modal.confirm({
|
||||
title: '通过人工支出',
|
||||
content: `确认通过 ${row.expenseNo},金额 ${formatMoney(row.amountFen)}?通过后会计入成本。`,
|
||||
okText: '通过',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
setActionId(row.id)
|
||||
try {
|
||||
await approveAdminManualExpense(row.id)
|
||||
message.success('人工支出已通过')
|
||||
refreshAll()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '审核失败')
|
||||
} finally {
|
||||
setActionId(null)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function openReject(row: AdminManualExpense) {
|
||||
setRejectTarget(row)
|
||||
setRejectReason('')
|
||||
}
|
||||
|
||||
async function submitReject() {
|
||||
if (!rejectTarget) return
|
||||
if (!rejectReason.trim()) {
|
||||
message.warning('请填写驳回原因')
|
||||
return
|
||||
}
|
||||
setActionId(rejectTarget.id)
|
||||
try {
|
||||
await rejectAdminManualExpense(rejectTarget.id, rejectReason.trim())
|
||||
message.success('人工支出已驳回')
|
||||
setRejectTarget(null)
|
||||
refreshAll()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '驳回失败')
|
||||
} finally {
|
||||
setActionId(null)
|
||||
}
|
||||
}
|
||||
|
||||
function reimburse(row: AdminManualExpense) {
|
||||
modal.confirm({
|
||||
title: '标记已报销',
|
||||
content: `确认 ${row.expenseNo} 已完成报销?`,
|
||||
okText: '标记已报销',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
setActionId(row.id)
|
||||
try {
|
||||
await reimburseAdminManualExpense(row.id)
|
||||
message.success('已标记报销')
|
||||
refreshAll()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '标记报销失败')
|
||||
} finally {
|
||||
setActionId(null)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const columns: TableColumnsType<AdminManualExpense> = [
|
||||
{
|
||||
title: '登记号 / 状态',
|
||||
dataIndex: 'expenseNo',
|
||||
width: 190,
|
||||
render: (value: string, row) => {
|
||||
const meta = STATUS_META[row.status]
|
||||
return (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Typography.Text copyable>{value}</Typography.Text>
|
||||
<Tag color={meta.color}>{meta.label}</Tag>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '付款时间',
|
||||
dataIndex: 'paidAt',
|
||||
width: 165,
|
||||
render: (value: string) => formatAdminDateTime(value),
|
||||
},
|
||||
{
|
||||
title: '订单 / 商品',
|
||||
key: 'order',
|
||||
width: 260,
|
||||
render: (_, row) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<span>{row.platformOrderId || (row.orderId ? `#${row.orderId}` : '无关联订单')}</span>
|
||||
<Typography.Text type="secondary" ellipsis>
|
||||
{row.skuName || row.workOrderNo || '-'}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '付款项目',
|
||||
key: 'costItem',
|
||||
width: 180,
|
||||
render: (_, row) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<span>{row.costItemName || '未归类'}</span>
|
||||
<Typography.Text type="secondary">
|
||||
{CATEGORY_OPTIONS.find((item) => item.value === row.expenseCategory)?.label ||
|
||||
row.expenseCategory}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'amountFen',
|
||||
width: 120,
|
||||
render: (value: number) => <Typography.Text strong>{formatMoney(value)}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '付款人 → 收款人',
|
||||
key: 'parties',
|
||||
width: 190,
|
||||
render: (_, row) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<span>{row.paidByName || '-'}</span>
|
||||
<Typography.Text type="secondary">→ {row.payeeName || '-'}</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '备用金账户',
|
||||
key: 'fundAccount',
|
||||
width: 180,
|
||||
render: (_, row) =>
|
||||
row.fundAccountId ? (
|
||||
<Space direction="vertical" size={2}>
|
||||
<span>{row.fundAccountOwnerName || '-'}</span>
|
||||
<Typography.Text type="secondary">
|
||||
{row.fundAccountNo || `#${row.fundAccountId}`} · {row.fundExpenseNo || '-'}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
) : (
|
||||
<Tag color="default">未走备用金</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '凭证',
|
||||
dataIndex: 'evidenceJson',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
render: (value: Record<string, unknown>) => {
|
||||
const reference = typeof value?.reference === 'string' ? value.reference : ''
|
||||
return (
|
||||
<Typography.Text ellipsis title={reference}>
|
||||
{reference || '-'}
|
||||
</Typography.Text>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '成本快照',
|
||||
dataIndex: 'costSnapshotId',
|
||||
width: 100,
|
||||
render: (value: number | null) =>
|
||||
value ? <Tag color="green">#{value}</Tag> : <Tag color="orange">未生成</Tag>,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 190,
|
||||
fixed: 'right',
|
||||
render: (_, row) => {
|
||||
if (!canReview) return <Typography.Text type="secondary">仅查看</Typography.Text>
|
||||
return (
|
||||
<Space size={4}>
|
||||
{row.status === 'submitted' ? (
|
||||
<>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<CheckOutlined />}
|
||||
loading={actionId === row.id}
|
||||
onClick={() => approve(row)}
|
||||
>
|
||||
通过
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<CloseOutlined />}
|
||||
disabled={actionId === row.id}
|
||||
onClick={() => openReject(row)}
|
||||
>
|
||||
驳回
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{row.status === 'approved' ? (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<WalletOutlined />}
|
||||
loading={actionId === row.id}
|
||||
onClick={() => reimburse(row)}
|
||||
>
|
||||
已报销
|
||||
</Button>
|
||||
) : null}
|
||||
{row.status === 'reimbursed' ? (
|
||||
<Typography.Text type="secondary">已完成</Typography.Text>
|
||||
) : null}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader
|
||||
title="人工支出"
|
||||
description="登记公司实际付出的人工/垫付支出:项目、金额、订单、付款人、收款人与凭证。"
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<Form<FilterForm>
|
||||
form={filterForm}
|
||||
layout="inline"
|
||||
className="filter-form"
|
||||
onFinish={applyFilters}
|
||||
initialValues={{
|
||||
dateRange: [dayjs().subtract(29, 'day'), dayjs()],
|
||||
status: '',
|
||||
keyword: '',
|
||||
}}
|
||||
>
|
||||
<Form.Item name="dateRange" label="付款日期">
|
||||
<AdminRangePicker format="YYYY-MM-DD" />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ value: '', label: '全部状态' },
|
||||
...Object.entries(STATUS_META).map(([value, meta]) => ({
|
||||
value,
|
||||
label: meta.label,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="keyword" label="关键词">
|
||||
<Input allowClear placeholder="登记号 / 订单号 / 收款人" style={{ width: 220 }} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button onClick={resetFilters}>重置</Button>
|
||||
{canCreate ? (
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
|
||||
登记人工支出
|
||||
</Button>
|
||||
) : null}
|
||||
<Button icon={<ReloadOutlined />} onClick={refreshAll}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
{overview && overview.unclassifiedCount > 0 ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={`有 ${overview.unclassifiedCount} 条支出未归类,金额 ${formatMoney(overview.unclassifiedAmountFen)}`}
|
||||
description="未归类的支出无法进入成本报表,请补齐付款项目。"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{overview && overview.noOrderCount > 0 ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={`有 ${overview.noOrderCount} 条支出没有关联订单,金额 ${formatMoney(overview.noOrderAmountFen)}`}
|
||||
description="平台充值、批量拿货、办公支出可以没有订单,但建议在备注里写清用途。"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={12} md={8} xl={4}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="待审核"
|
||||
value={overview?.submittedAmountFen || 0}
|
||||
formatter={() => formatMoney(overview?.submittedAmountFen || 0)}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={8} xl={5}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="已通过"
|
||||
value={overview?.approvedAmountFen || 0}
|
||||
formatter={() => formatMoney(overview?.approvedAmountFen || 0)}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={8} xl={5}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="已报销"
|
||||
value={overview?.reimbursedAmountFen || 0}
|
||||
formatter={() => formatMoney(overview?.reimbursedAmountFen || 0)}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={8} xl={5}>
|
||||
<Card>
|
||||
<Statistic title="待审核笔数" value={overview?.submittedCount || 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={8} xl={5}>
|
||||
<Card>
|
||||
<Statistic title="已通过笔数" value={overview?.approvedCount || 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card>
|
||||
<Table<AdminManualExpense>
|
||||
rowKey="id"
|
||||
size="small"
|
||||
columns={columns}
|
||||
dataSource={list?.items || []}
|
||||
loading={listQuery.isLoading}
|
||||
scroll={{ x: 1830 }}
|
||||
pagination={buildAdminTablePagination({
|
||||
current: page,
|
||||
pageSize,
|
||||
total: list?.pagination.total || 0,
|
||||
showTotal: (total) => `共 ${total} 条支出`,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setPage(nextPage)
|
||||
setPageSize(nextPageSize)
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="登记人工支出"
|
||||
open={createOpen}
|
||||
onCancel={() => {
|
||||
setCreateOpen(false)
|
||||
expenseForm.resetFields()
|
||||
}}
|
||||
onOk={submitExpense}
|
||||
confirmLoading={creating}
|
||||
okText="提交登记"
|
||||
width={720}
|
||||
>
|
||||
<Form<ExpenseForm>
|
||||
form={expenseForm}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
expenseCategory: 'service_fee',
|
||||
payeeType: 'upstream',
|
||||
paymentMethod: 'wechat',
|
||||
paidAt: dayjs(),
|
||||
}}
|
||||
>
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="orderId" label="订单 ID(可选)">
|
||||
<InputNumber min={1} style={{ width: '100%' }} placeholder="有订单时填写" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="orderItemId" label="订单项 ID(可选)">
|
||||
<InputNumber
|
||||
min={1}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="按订单项归集成本时填写"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
{isSupport ? (
|
||||
<>
|
||||
<Form.Item label="备用金账户" extra="只能使用自己的备用金账户">
|
||||
<Input
|
||||
readOnly
|
||||
value={
|
||||
ownFundAccount
|
||||
? `${ownFundAccount.ownerName} · ${ownFundAccount.accountNo} · 余额 ${formatMoney(
|
||||
ownFundAccount.balanceFen,
|
||||
)}`
|
||||
: '当前账号还没有备用金账户'
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="fundAccountId" hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</>
|
||||
) : (
|
||||
<Form.Item
|
||||
name="fundAccountId"
|
||||
label="备用金账户"
|
||||
rules={[{ required: true, message: '请选择备用金账户' }]}
|
||||
extra="人工支出会直接从该客服的备用金余额扣减,并同步出现在「备用金」支出明细里"
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
loading={fundAccountsQuery.isLoading}
|
||||
placeholder="请选择这笔支出从哪个客服备用金账户出"
|
||||
options={fundAccounts.map((account) => ({
|
||||
value: account.id,
|
||||
label: `${account.ownerName} · ${account.accountNo} · 余额 ${formatMoney(
|
||||
account.balanceFen,
|
||||
)}`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
{!fundAccountsQuery.isLoading && fundAccounts.length === 0 ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 14 }}
|
||||
message="没有可用的客服备用金账户"
|
||||
description="请先到「备用金」页为客服开通账户并拨付备用金,否则无法登记人工支出。"
|
||||
/>
|
||||
) : null}
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="costItemId"
|
||||
label="付款项目"
|
||||
rules={[{ required: true, message: '请选择付款项目' }]}
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
loading={costItemsQuery.isLoading}
|
||||
options={costItems.map((item: { id: number; code: string; name: string }) => ({
|
||||
value: item.id,
|
||||
label: `${item.name}(${item.code})`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item
|
||||
name="amountYuan"
|
||||
label="金额(元)"
|
||||
rules={[{ required: true, message: '请填写金额' }]}
|
||||
>
|
||||
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item name="expenseCategory" label="支出分类" rules={[{ required: true }]}>
|
||||
<Select options={CATEGORY_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={12}>
|
||||
<Col span={6}>
|
||||
<Form.Item name="payeeType" label="收款方类型" rules={[{ required: true }]}>
|
||||
<Select options={PAYEE_TYPE_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={9}>
|
||||
<Form.Item
|
||||
name="payeeName"
|
||||
label="收款方"
|
||||
rules={[{ required: true, message: '请填写收款方' }]}
|
||||
>
|
||||
<Input placeholder="微信号 / 支付宝 / 姓名" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={9}>
|
||||
<Form.Item name="payeeAccount" label="收款账号(可选)">
|
||||
<Input placeholder="账号、手机号或备注" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={12}>
|
||||
<Col span={6}>
|
||||
<Form.Item name="paymentMethod" label="付款方式" rules={[{ required: true }]}>
|
||||
<Select options={PAYMENT_METHOD_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={9}>
|
||||
<Form.Item name="paidAt" label="付款时间" rules={[{ required: true }]}>
|
||||
<DatePicker showTime style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={9}>
|
||||
<Form.Item
|
||||
name="evidenceReference"
|
||||
label="凭证链接 / 流水号"
|
||||
rules={[{ required: true, message: '至少留一项可追溯凭证' }]}
|
||||
>
|
||||
<Input placeholder="转账截图链接、微信流水号或聊天记录说明" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item name="note" label="备注 / 原因">
|
||||
<Input.TextArea rows={3} placeholder="赔偿、加急、补差价等必须写清原因" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`驳回 ${rejectTarget?.expenseNo || ''}`}
|
||||
open={Boolean(rejectTarget)}
|
||||
onCancel={() => setRejectTarget(null)}
|
||||
onOk={submitReject}
|
||||
confirmLoading={Boolean(rejectTarget && actionId === rejectTarget.id)}
|
||||
okText="确认驳回"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
value={rejectReason}
|
||||
onChange={(event) => setRejectReason(event.target.value)}
|
||||
placeholder="必须填写驳回原因"
|
||||
/>
|
||||
</Modal>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
export * from './auth'
|
||||
export * from './dashboard'
|
||||
export * from './finance'
|
||||
export * from './manual-expense'
|
||||
export * from './manual-order-cost'
|
||||
export * from './cost-center'
|
||||
export * from './fund'
|
||||
export * from './users'
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { apiGet, apiPost } from '@/lib/http'
|
||||
import type {
|
||||
AdminManualExpense,
|
||||
AdminManualExpenseListResult,
|
||||
AdminManualExpenseOverview,
|
||||
} from '@/types/admin/manual-expense'
|
||||
|
||||
export type AdminManualExpenseParams = {
|
||||
dateFrom?: string
|
||||
dateTo?: string
|
||||
status?: string
|
||||
keyword?: string
|
||||
costItemId?: number
|
||||
paidByAdminUserId?: number
|
||||
orderId?: number
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export function fetchAdminManualExpenses(params: AdminManualExpenseParams = {}) {
|
||||
return apiGet<AdminManualExpenseListResult>('/api/v1/admin/finance/manual-expenses', params)
|
||||
}
|
||||
|
||||
export function fetchAdminManualExpenseOverview(params: AdminManualExpenseParams = {}) {
|
||||
return apiGet<AdminManualExpenseOverview>(
|
||||
'/api/v1/admin/finance/manual-expenses/overview',
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchAdminManualExpenseCostItems() {
|
||||
return apiGet<{
|
||||
items: Array<{
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
costModel: string
|
||||
pricingMode: string
|
||||
}>
|
||||
}>('/api/v1/admin/finance/manual-expenses/cost-items')
|
||||
}
|
||||
|
||||
export function fetchAdminManualExpense(expenseId: number) {
|
||||
return apiGet<AdminManualExpense>(`/api/v1/admin/finance/manual-expenses/${expenseId}`)
|
||||
}
|
||||
|
||||
export function createAdminManualExpense(payload: Record<string, unknown>) {
|
||||
return apiPost<AdminManualExpense>('/api/v1/admin/finance/manual-expenses', payload)
|
||||
}
|
||||
|
||||
export function createAdminManualOrderCost(payload: Record<string, unknown>) {
|
||||
return apiPost<{
|
||||
record: AdminManualExpense
|
||||
effective: boolean
|
||||
reason: 'approved' | 'pending_review' | 'approval_failed'
|
||||
message?: string
|
||||
}>('/api/v1/admin/finance/manual-order-costs', payload)
|
||||
}
|
||||
|
||||
export function approveAdminManualExpense(
|
||||
expenseId: number,
|
||||
payload: Record<string, unknown> = {},
|
||||
) {
|
||||
return apiPost<AdminManualExpense>(
|
||||
`/api/v1/admin/finance/manual-expenses/${expenseId}/approve`,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function rejectAdminManualExpense(expenseId: number, rejectReason: string) {
|
||||
return apiPost<AdminManualExpense>(`/api/v1/admin/finance/manual-expenses/${expenseId}/reject`, {
|
||||
rejectReason,
|
||||
})
|
||||
}
|
||||
|
||||
export function reimburseAdminManualExpense(expenseId: number) {
|
||||
return apiPost<AdminManualExpense>(`/api/v1/admin/finance/manual-expenses/${expenseId}/reimburse`)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { apiGet, apiPost } from '@/lib/http'
|
||||
|
||||
/**
|
||||
* 人工录单:直接扣客服备用金 + 直接写成本快照。
|
||||
* 没有登记单、没有审批、没有报销环节。
|
||||
*/
|
||||
|
||||
export type AdminManualOrderCostResult = {
|
||||
expenseNo: string
|
||||
fundExpenseId: number
|
||||
fundAccountId: number
|
||||
fundAccountNo: string
|
||||
amountFen: number
|
||||
orderId: number
|
||||
orderItemId: number
|
||||
costSnapshotId: number | null
|
||||
fulfillmentMode: string
|
||||
warning: string
|
||||
}
|
||||
|
||||
export function fetchAdminManualOrderCostItems() {
|
||||
return apiGet<{
|
||||
items: Array<{
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
costModel: string
|
||||
pricingMode: string
|
||||
}>
|
||||
}>('/api/v1/admin/finance/manual-order-cost-items')
|
||||
}
|
||||
|
||||
export function createAdminManualOrderCost(payload: Record<string, unknown>) {
|
||||
return apiPost<AdminManualOrderCostResult>('/api/v1/admin/finance/manual-order-costs', payload)
|
||||
}
|
||||
@@ -32,14 +32,6 @@ export type {
|
||||
AdminSalesProfitRange,
|
||||
} from './finance'
|
||||
|
||||
// Manual expense types
|
||||
export type {
|
||||
AdminManualExpense,
|
||||
AdminManualExpenseListResult,
|
||||
AdminManualExpenseOverview,
|
||||
AdminManualExpenseStatus,
|
||||
} from './manual-expense'
|
||||
|
||||
// Fund / imprest account types
|
||||
export type {
|
||||
AdminFundAccount,
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
export type AdminManualExpenseStatus =
|
||||
| 'draft'
|
||||
| 'submitted'
|
||||
| 'approved'
|
||||
| 'rejected'
|
||||
| 'reimbursed'
|
||||
| 'reversed'
|
||||
|
||||
export type AdminManualExpense = {
|
||||
id: number
|
||||
expenseNo: string
|
||||
orderId: number | null
|
||||
orderItemId: number | null
|
||||
workOrderId: number | null
|
||||
fundExpenseId: number | null
|
||||
fundExpenseNo: string
|
||||
fundAccountId: number | null
|
||||
fundAccountNo: string
|
||||
fundAccountOwnerName: string
|
||||
costItemId: number | null
|
||||
costItemName: string
|
||||
costItemCode: string
|
||||
expenseCategory: string
|
||||
amountFen: number
|
||||
currency: string
|
||||
paidByAdminUserId: number | null
|
||||
paidByName: string
|
||||
payeeType: string
|
||||
payeeName: string
|
||||
payeeAccount: string
|
||||
paymentMethod: string
|
||||
paidAt: string
|
||||
status: AdminManualExpenseStatus
|
||||
approvedBy: string
|
||||
approvedAt: string | null
|
||||
rejectedBy: string
|
||||
rejectedAt: string | null
|
||||
rejectReason: string
|
||||
reimbursedBy: string
|
||||
reimbursedAt: string | null
|
||||
evidenceJson: Record<string, unknown>
|
||||
costSnapshotId: number | null
|
||||
reversalOfId: number | null
|
||||
expenseBatchNo: string
|
||||
note: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
platformOrderId: string
|
||||
provider: string
|
||||
shopId: string
|
||||
skuCode: string
|
||||
skuName: string
|
||||
workOrderNo: string
|
||||
}
|
||||
|
||||
export type AdminManualExpenseListResult = {
|
||||
range: { fromDate: string; toDate: string }
|
||||
items: AdminManualExpense[]
|
||||
pagination: { page: number; pageSize: number; total: number }
|
||||
}
|
||||
|
||||
export type AdminManualExpenseOverview = {
|
||||
range: { fromDate: string; toDate: string }
|
||||
overview: {
|
||||
submittedCount: number
|
||||
submittedAmountFen: number
|
||||
approvedCount: number
|
||||
approvedAmountFen: number
|
||||
reimbursedCount: number
|
||||
reimbursedAmountFen: number
|
||||
rejectedCount: number
|
||||
rejectedAmountFen: number
|
||||
noOrderCount: number
|
||||
noOrderAmountFen: number
|
||||
unclassifiedCount: number
|
||||
unclassifiedAmountFen: number
|
||||
}
|
||||
byPayer: Array<{ paidByName: string; recordCount: number; amountFen: number }>
|
||||
byCostItem: Array<{
|
||||
costItemId: number | null
|
||||
costItemName: string
|
||||
expenseCategory: string
|
||||
recordCount: number
|
||||
amountFen: number
|
||||
}>
|
||||
}
|
||||
+17
-12
@@ -1,6 +1,6 @@
|
||||
# order_site 文档索引
|
||||
|
||||
> 最后校准:2026-10-23,代码基线 `feat/finance-rewrite @ 7557de6d + 迁移 103–107`。
|
||||
> 最后校准:2026-10-23,代码基线 `dev @ 43f402cf + 迁移 103–109`(已合并到 `dev`,**未推送、未部署生产**)。
|
||||
> 本文是 `docs/` 的唯一入口。**财务与履约口径以 [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) 为准**;数据模型看 [成本中心与履约模型.md](成本中心与履约模型.md),接口看 [成本中心API契约.md](成本中心API契约.md)。
|
||||
> 新增文档必须在下表登记状态;过期文档不再直接改写结论,统一移入 `docs/archive/`。
|
||||
|
||||
@@ -43,20 +43,24 @@
|
||||
- 订单/工单/人工单只保存 `cost_snapshot_id`,历史成本永不回写
|
||||
- 自动发货、接单平台、人工客服共用同一个成本解析器;匹配不到成本进入 `cost_missing`,不默认 0
|
||||
|
||||
### 5. 两个“人工支出”不是一回事
|
||||
### 5. 人工成本只有一条链路
|
||||
|
||||
```text
|
||||
订单页「人工录单」→ 扣客服备用金(manual_expenses)→ 写 cost_snapshots(manual_case)
|
||||
```
|
||||
|
||||
| 名称 | 业务含义 | 当前状态 |
|
||||
| --- | --- | --- |
|
||||
| `manual_expenses` | **客服备用金支出明细**:客服从自己的备用金里充值/采购/赔偿,减少客服余额 | 已实施(迁移 100) |
|
||||
| `manual_expense_records` | **公司人工支出/垫付登记**:按订单/成本项记录公司实际付出去的钱、付款人、收款人、凭证、审批 | 已实施 P0(迁移 106) |
|
||||
| `manual_expenses` | **客服备用金支出明细**:客服从自己的备用金里充值/采购/赔偿,减少客服余额 | 已实施(迁移 100);人工录单也写这里 |
|
||||
| `cost_snapshots` | **人工成本的唯一落点**:`subject_type='manual_case'`,`subject_id=manual_expenses.id` | 已实施(迁移 104) |
|
||||
|
||||
后续做财务系统时,**不要把 `manual_expenses` 直接改名或复用为 `manual_expense_records`**。两者业务含义、审批链和报表口径不同,必须通过 `fund_expense_id` 关联;新人工支出登记会先扣备用金,再写人工支出记录。
|
||||
**没有独立的登记单,也没有审批与报销环节。** `manual_expense_records` / `manual_expense_events` 已随迁移 `108` 删除。后续做财务系统时,人工成本的钱与账都从这条链路走,不要再另造一张人工支出表。
|
||||
|
||||
### 6. 历史人工支出不补录,上线后必须全覆盖
|
||||
### 6. 历史人工直接放弃,上线后必须全覆盖
|
||||
|
||||
- 历史人工支出只取少量样本做校准,不补录、不阻塞上线;
|
||||
- 历史人工支出找不回来,**直接放弃**:不补录、不做样本校准、不阻塞上线;
|
||||
- 上线后每一笔人工单必须有处理人、实际金额、备用金支出与成本快照;
|
||||
- 人工单无成本记录、人工支出无 `fund_expense_id`、金额与备用金流水不一致,均不允许关账。
|
||||
- 人工单无成本记录、人工单未扣备用金、金额与备用金流水不一致,均不允许关账。
|
||||
|
||||
### 7. 文档状态
|
||||
|
||||
@@ -76,13 +80,13 @@
|
||||
| 商品主数据 + 渠道 SKU 映射 | **已实施 P0** | 745 个标准商品、4,426 条映射;只覆盖自动发货 38 个 + 接单平台 707 个。人工 11,349 项虽已归一但全部挂 `tpl:*` 模板商品,需在 P1 复核;人工专用商品尚未建立 |
|
||||
| 成本中心 | **已实施 P0** | 迁移 `104`;`cost_items.cost_model` 取代 `cost_type + carrier_type`;38 个商品级版本 + 662 个规则级版本;人工走 `sys_manual_service` 实际快照 |
|
||||
| 销售事实 / 订单毛利 | **已实施 P0** | 迁移 `105`;94 天事实收入 ¥2,650.87 万、成本 ¥950.27 万;`sales_daily_facts.fulfillment_mode` |
|
||||
| 公司人工支出登记 | **已实施 P0** | 迁移 `106`;登记时强制扣客服备用金并写 `fund_expense_id`;客服只能使用本人账户 |
|
||||
| 人工成本录入(人工录单) | **已实施 P0** | 迁移 `106/108`;录入即扣客服备用金并写 `actual` 快照;无登记单/审批/报销;客服只能使用本人账户 |
|
||||
| 履约方式统一 | **已实施 P0** | 迁移 `107`;316,585 项已回填,四项计数与总纲 §二 一致;订单页、成本快照、销售事实只读 `fulfillment_mode` |
|
||||
| 人工商品归一 | **未实施(P1)** | 人工单按 `spec.skuId` 建 `man:<skuId>`;历史 11,349 项 `tpl:*` 商品待复核 |
|
||||
| 影子总账 | 未实施 | 现有财务页仍是 `UNION ALL` 业务事件拼装,不是复式总账 |
|
||||
| 退款冲减 / 平台对账 / 期间关账 | 未实施 | 仅方案 |
|
||||
|
||||
> 分支状态(2026-10-23):重写分支 `feat/finance-rewrite` 把原 `103–114` 十二个迁移合并为 `103–107` 五个;本地库已按新 schema 原地改造并重跑回填,**尚未合并到 `dev`、未部署生产**;生产口径仍以 `main` 为准。
|
||||
> 分支状态(2026-10-23):原 `103–114` 十二个迁移已合并为 `103–107` 五个,另加 `108` 清理迁移(删人工支出登记两表);已快进合并到 `dev`(tip `43f402cf`),本地库已按新 schema 改造并重跑回填,**未推送远端、未部署生产**;生产口径仍以 `main` 为准。
|
||||
> 财务与履约口径以 [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) 为准。
|
||||
|
||||
## 三、统一实施顺序
|
||||
@@ -91,6 +95,7 @@
|
||||
0. 统一履约事实(fulfillment_mode + 成本模型正交) 已完成 P0(迁移 107)
|
||||
1. 商品主数据 + 渠道 SKU 映射 已完成 P0(仅自动发货/接单平台)
|
||||
2. 成本中心 + 销售事实 已完成 P0
|
||||
2.5 人工成本链路收敛(人工录单 → 扣备用金 → 写快照) 已完成 P0(迁移 106/108)
|
||||
3. 人工商品归一与上线后人工成本全覆盖 未实施(下一阶段)
|
||||
4. 退款冲减与异常队列 未实施
|
||||
5. 影子总账(订单、工单、押金、提现、寄卖、售后) 未实施
|
||||
@@ -121,7 +126,7 @@
|
||||
| [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) | **现行唯一口径** | 履约方式、成本模型、人工全覆盖、历史策略、实施顺序、运营日检与验收 SQL |
|
||||
| [成本中心与履约模型.md](成本中心与履约模型.md) | 现行 | 商品、成本版本、快照、异常、销售事实的数据模型;迁移 `103–107`;履约回填;本地库原地改造;索引与性能 |
|
||||
| [成本中心API契约.md](成本中心API契约.md) | 现行 | 成本中心接口字段替换总表、枚举、模块边界(后端 5 模块 + 前端 8 面板) |
|
||||
| [人工支出与备用金.md](人工支出与备用金.md) | 现行(P0 已实施) | 人工支出登记字段/状态机/权限、客服备用金台账、本人账户与编号规则 |
|
||||
| [人工支出与备用金.md](人工支出与备用金.md) | 现行(P0 已实施) | 人工录单字段/流程/权限、客服备用金台账、本人账户与编号规则 |
|
||||
| [财务系统集成方案.md](财务系统集成方案.md) | 现行方案(总账未实施) | 复式记账、总账表、对账、关账 |
|
||||
| [影子总账说明.md](影子总账说明.md) | 现行说明(未实施) | 影子总账是什么、和钱包流水的区别、对账与转正标准 |
|
||||
| [项目全量分析报告-2026-10-17.md](项目全量分析报告-2026-10-17.md) | 历史参考 | 2026-10-17 架构与风险;财务口径以总纲为准 |
|
||||
@@ -144,7 +149,7 @@
|
||||
| --- | --- | --- |
|
||||
| [drafts/2026-10-17-载体判定与人工块量化.sql](drafts/2026-10-17-载体判定与人工块量化.sql) | 载体分布/人工块量化查询、`carrier_type` 判定与回填 | **已过时**:`carrier_type` 已从商品/成本/快照/异常表删除;只读查询里的口径结论已吸收进总纲 §二 |
|
||||
| [drafts/2026-10-17-成本中心与销售事实-DDL草案.sql](drafts/2026-10-17-成本中心与销售事实-DDL草案.sql) | `product_catalog`、`channel_product_mappings`、`cost_items/versions/snapshots/exceptions`、`sales_daily_facts` | 已落地为 `103`–`105`;草案里的 `carrier_type` 已被 `cost_model` / `fulfillment_mode` 取代,仅作设计追溯 |
|
||||
| [drafts/2026-10-17-人工支出登记-DDL草案.sql](drafts/2026-10-17-人工支出登记-DDL草案.sql) | `manual_fulfillment_cases`、`manual_expense_records`、`manual_expense_events` | 人工支出登记已落地为 `106`;轻量人工 case 仍为草案 |
|
||||
| [archive/2026-10-23/2026-10-17-人工支出登记-DDL草案.sql](archive/2026-10-23/2026-10-17-人工支出登记-DDL草案.sql) | `manual_fulfillment_cases`、`manual_expense_records`、`manual_expense_events` | **已作废**:`manual_expense_records` / `manual_expense_events` 已随迁移 `108` 删除,人工成本改走「人工录单 → 扣备用金 → 写快照」;文件已移入 archive |
|
||||
|
||||
## 六、厂商协议与外部资料
|
||||
|
||||
|
||||
+16
@@ -1,3 +1,19 @@
|
||||
-- ============================================================================
|
||||
-- 【已作废】2026-10-23
|
||||
--
|
||||
-- 本草案设计的 `manual_expense_records`(公司人工支出登记单)与 `manual_expense_events`
|
||||
-- (审批报销状态机)已随迁移 108_drop_manual_expense_records.sql 删除。
|
||||
--
|
||||
-- 现行口径:人工成本只有一条链路 ——
|
||||
-- 订单页「人工录单」→ 扣客服备用金(manual_expenses)→ 写 cost_snapshots
|
||||
-- (subject_type='manual_case',subject_id=manual_expenses.id)
|
||||
-- 没有独立的登记单,也没有审批与报销环节。
|
||||
--
|
||||
-- 本文件仅作设计追溯,禁止按此实现。当前口径见:
|
||||
-- docs/人工支出与备用金.md
|
||||
-- docs/财务与履约口径总纲-2026-10-23.md
|
||||
-- ============================================================================
|
||||
|
||||
-- ============================================================================
|
||||
-- 人工支出登记 + 轻量人工处理记录(建表草案)
|
||||
-- ----------------------------------------------------------------------------
|
||||
+72
-80
@@ -2,26 +2,35 @@
|
||||
|
||||
> 状态:**现行(P0 已实施,未部署生产)**。
|
||||
> 最后校准:2026-10-23
|
||||
> 代码基线:`feat/finance-rewrite @ 7557de6d + 迁移 100/101/106`
|
||||
> **业务口径以 [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) 为准**;本文只负责人工支出的字段、流程、权限与备用金台账。
|
||||
> 代码基线:`dev @ 43f402cf + 迁移 100/101/106/108`(已合并到 `dev`,**未推送、未部署生产**)
|
||||
> **业务口径以 [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) 为准**;本文只负责人工录单的字段、流程、权限与备用金台账。
|
||||
> 本文合并自原《人工支出登记落地方案-2026-10-17.md》与《客服备用金台账实施方案-2026-10-18.md》。
|
||||
|
||||
---
|
||||
|
||||
## 〇、先分清两张表
|
||||
## 〇、只有一条链路:人工录单
|
||||
|
||||
2026-10-23 口径调整:**公司人工支出登记单与审批报销状态机整链下线**。人工成本现在只有一条路径。
|
||||
|
||||
```text
|
||||
订单页「人工录单」
|
||||
→ 扣客服备用金余额(写 fund_entries + manual_expenses)
|
||||
→ 写 cost_snapshots(subject_type='manual_case',subject_id=manual_expenses.id)
|
||||
```
|
||||
|
||||
| 表 | 业务含义 | 状态 | 关键字段 |
|
||||
| --- | --- | --- | --- |
|
||||
| `manual_expenses` | **客服备用金支出**:客服从自己的备用金账户里充值/采购/赔偿 | 已实施(迁移 `100`) | `account_id` / `fund_entry_id` / `category` / `payee_*` / `evidence_json` |
|
||||
| `manual_expense_records` | **公司人工支出登记**:按订单/成本项记录公司实际付出去的钱 | 已实施 P0(迁移 `106`) | `order_id` / `order_item_id` / `cost_item_id` / `paid_by_admin_user_id` / `status` / `cost_snapshot_id` |
|
||||
| `manual_expenses` | **客服备用金支出**:客服从自己的备用金账户里充值/采购/赔偿 | 已实施(迁移 `100`) | `account_id` / `fund_entry_id` / `category` / `payee_*` / `order_item_id` / `evidence_json` |
|
||||
| `cost_snapshots` | **人工成本的唯一落点** | 已实施(迁移 `104`) | `subject_type='manual_case'` / `subject_id=manual_expenses.id` / `total_amount_fen` / `snapshot_stage='actual'` |
|
||||
|
||||
结论:
|
||||
|
||||
1. 客服侧已收口的是"备用金余额与用途",解决"客服手里有多少钱、这笔钱花到哪"。
|
||||
2. **公司口径的实际支出已落地**:后台「人工支出」页(`/admin/manual-expenses`)支持登记、审批、驳回、报销;通过后按成本项写 `cost_snapshots`,没有有效成本版本时进入异常队列。
|
||||
3. 订单界面已接入「人工录单」:订单列表对人工单显示按钮、订单详情始终可录,弹窗锁定订单号、选择**备用金账户**并逐单填写**实际金额**,不存在小时单价或固定单量计价。
|
||||
4. 两张表通过 `fund_expense_id` 关联,且该字段由系统写入:人工支出先从客服备用金账户扣款并生成 `manual_expenses`,再写 `manual_expense_records`。
|
||||
5. **不要**把 `manual_expenses` 直接改名为 `manual_expense_records`,也不要让两张表共用索引/约束名。
|
||||
1. **没有单独的登记单表**。`manual_expense_records` 与 `manual_expense_events` 已随迁移 `108` 删除。
|
||||
2. **没有审批、没有报销**。订单页「人工录单」一次操作完成"扣钱 + 记成本",录入即计入毛利。
|
||||
3. 客服侧收口的是"备用金余额与用途":钱从谁的备用金出、付给谁、有没有凭证,全在 `manual_expenses` + `fund_entries`。
|
||||
4. 客服角色只能用本人备用金账户(后端越权直接 403);管理员/财务可代录到指定客服账户。
|
||||
5. 余额不足直接拒绝,**不允许负余额**。
|
||||
6. 历史人工支出找不回来,**直接放弃**:不补录、不做样本回填、不做均价估算。
|
||||
|
||||
---
|
||||
|
||||
@@ -30,10 +39,11 @@
|
||||
1. **人工成本逐单、按实际金额。** 不按小时、不按固定单量、不按固定单价。
|
||||
2. **人工支出从客服备用金账户扣款。** 余额不足直接拒绝,不允许负余额。
|
||||
3. **客服角色只能使用自己的备用金账户。** 后端越权直接 403;管理员/财务可代录到指定客服账户。
|
||||
4. **历史人工支出不补录。** 只取少量样本做校准;历史缺口标记 `historical_missing`,不阻塞上线。
|
||||
4. **历史人工支出找不回来,直接放弃。** 不补录、不做样本校准;历史缺口标记 `historical_missing`,不阻塞上线。
|
||||
5. **上线后每一笔人工单必须 100% 有成本记录**,否则不允许关账。
|
||||
6. **登记必须"发生在垫付当天"才有监管价值**;事后补记只能算账,不能算监管。
|
||||
6. **录入必须"发生在垫付当天"才有监管价值**;事后补记只能算账,不能算监管。
|
||||
7. **客服备用金是周转金台账,不是报销系统。** P0 只做"账户 + 流水 + 支出 + 对账",不做复杂审批和总账。
|
||||
8. **人工录单一录到底**:没有登记单、没有审批、没有报销。钱与成本在同一次操作里落地。
|
||||
|
||||
---
|
||||
|
||||
@@ -45,7 +55,7 @@
|
||||
| --- | --- |
|
||||
| 数据库 | 迁移 `100_fund_accounts.sql`:`fund_accounts` / `fund_entries` / `manual_expenses` / `fund_reconciliations` |
|
||||
| 显示名 | 迁移 `101_admin_display_name.sql`:`admin_users.display_name`,备用金 `owner_name` 由触发器同步 |
|
||||
| 账户编号 | 迁移 `106_manual_expense_records.sql`:`FA-<大写用户名>`(如 `FA-KF_1`、`FA-JIAJIALI`),重名追加 `-2`、`-3` |
|
||||
| 账户编号 | 迁移 `106_fund_account_no_readable.sql`:`FA-<大写用户名>`(如 `FA-KF_1`、`FA-JIAJIALI`),重名追加 `-2`、`-3` |
|
||||
| 后端 | `admin-fund-service.ts`、`routes/admin/fund.ts`;金额全部用分,写操作使用事务与行锁 |
|
||||
| 前端 | `AdminFundPage.tsx`、`FundManagementView.tsx`、`FundSupportView.tsx`;管理端与客服端分流 |
|
||||
| 权限 | `fund.view / fund.issue / fund.expense / fund.reconcile / fund.adjust / fund.export` |
|
||||
@@ -76,86 +86,64 @@
|
||||
|
||||
### 2.7 P0 未实现(仍在 P1)
|
||||
|
||||
异常队列、批量发放/对账、导出、低余额提醒、大额支出主管确认;支出凭证目前只校验数组非空,未校验对象存储 key 是否真实存在。
|
||||
批量发放/对账、导出、低余额提醒、大额支出主管确认;支出凭证目前只校验数组非空,未校验对象存储 key 是否真实存在。
|
||||
|
||||
> 原「异常队列」指人工支出登记单的审批异常队列,随登记链下线而取消。现在的人工异常只有一类:人工录单匹配不到有效成本版本时写 `manual_expense_cost_missing`,由成本中心的异常面板统一处理。
|
||||
|
||||
---
|
||||
|
||||
## 三、公司人工支出登记
|
||||
## 三、人工录单
|
||||
|
||||
### 3.1 落点
|
||||
|
||||
- 迁移:`apps/backend/src/db/migrations/106_manual_expense_records.sql`
|
||||
- 服务:`apps/backend/src/services/finance/manual-expense-service.ts`;备用金扣款唯一写入口 `admin-fund-service.ts` 的 `writeFundExpense`
|
||||
- 接口:`apps/backend/src/routes/admin/finance.ts` 的 `/finance/manual-expenses*`;订单页专用 `POST /finance/manual-order-costs`
|
||||
- 页面:`apps/frontend/src/pages/admin/AdminManualExpensePage.tsx`,入口 `/admin/manual-expenses`
|
||||
- 订单入口:订单列表/详情「人工录单」打开 `ManualOrderCostModal`,锁定订单号、选择订单项与备用金账户、填写本单实际金额;支持深链 `/admin/orders/:orderId?manualCost=1`
|
||||
- 权限:查看 `finance.view` / `fund.view`,登记 `fund.expense` / `finance.config`,审核 `finance.review`
|
||||
- 服务:`apps/backend/src/services/finance/manual-order-cost-service.ts`;备用金扣款唯一写入口 `admin-fund-service.ts` 的 `writeFundExpense`
|
||||
- 接口:`apps/backend/src/routes/admin/finance.ts` 的 `POST /finance/manual-order-costs`;成本项选项 `GET /finance/manual-order-cost-items`
|
||||
- 组件:`apps/frontend/src/components/admin/ManualOrderCostModal.tsx`
|
||||
- 订单入口:订单列表/详情「人工录单」打开弹窗,锁定订单号、选择订单项与备用金账户、填写本单实际金额;支持深链 `/admin/orders/:orderId?manualCost=1`
|
||||
- 权限:登记 `fund.expense` / `finance.config`;客服角色自动锁定本人账户
|
||||
- **已删除**:迁移 `106_manual_expense_records.sql` 建的登记单表(迁移 `108` 删表)、`manual-expense-service.ts`、`AdminManualExpensePage.tsx`、`/admin/manual-expenses` 页面与菜单
|
||||
|
||||
### 3.2 资金流(同一事务)
|
||||
### 3.2 资金流与成本流(同一事务)
|
||||
|
||||
```text
|
||||
锁定 fund_accounts 行
|
||||
→ 校验余额
|
||||
→ 扣减余额
|
||||
→ 写 fund_entries
|
||||
→ 写 manual_expenses
|
||||
→ 写 manual_expense_records.fund_expense_id
|
||||
→ 写 manual_expenses(order_id / order_item_id 已带上)
|
||||
→ 解析成本版本
|
||||
→ 写 cost_snapshots(actual)
|
||||
```
|
||||
|
||||
余额不足直接拒绝,**不允许负余额**。
|
||||
余额不足直接拒绝,**不允许负余额**。扣钱与记成本在同一个事务里,要么都成功要么都回滚。
|
||||
|
||||
### 3.3 成本快照
|
||||
|
||||
通过后以 `subject_type='manual_case'`、`subject_id=expense.id` 写 `actual` 快照,金额 = 登记的实际金额。同一订单项多笔支出可累加,不会互相覆盖。
|
||||
以 `subject_type='manual_case'`、`subject_id=manual_expenses.id` 写 `actual` 快照,金额 = 录入的实际金额。
|
||||
|
||||
没有匹配到有效成本版本时,写 `manual_expense_cost_missing` 异常(`status='open'`),不允许静默记 0。
|
||||
同一订单项多笔支出可累加,不会互相覆盖(唯一键是 `subject_type + subject_id + cost_item_id`,每笔支出单一个主体)。快照 `metadata_json` 里记录处理人、收款方、付款方式、费用类型、凭证、履约方式。
|
||||
|
||||
### 3.4 状态机
|
||||
没有匹配到有效成本版本时,写 `manual_expense_cost_missing` 异常(`status='open'`),**不允许静默记 0**;此时备用金已经扣了,界面会提示"已扣备用金,但未匹配到有效成本版本,已进入异常队列"。
|
||||
|
||||
### 3.4 录入字段
|
||||
|
||||
```text
|
||||
draft ──> submitted ──> approved ──> reimbursed
|
||||
│
|
||||
└──> rejected
|
||||
order_id, order_item_id -- 订单与订单项(锁定订单号,不允许改)
|
||||
fund_account_id -- 备用金账户;客服只能本人
|
||||
amount_fen -- 实际金额(分),> 0
|
||||
expense_category -- goods_purchase / compensation / refund_advance /
|
||||
-- service_fee / freight / recharge / other
|
||||
payee_type, payee_name, payee_account
|
||||
payment_method -- wechat / alipay / bank / cash / platform_balance / other
|
||||
note -- 备注兼凭证(无附件时)
|
||||
evidenceJson -- 凭证(对象存储 key / 流水号 / 聊天记录 key)
|
||||
```
|
||||
|
||||
| 状态 | 含义 | 可执行动作 |
|
||||
| --- | --- | --- |
|
||||
| `draft` | 草稿 | 编辑、提交 |
|
||||
| `submitted` | 待审批 | 审批通过 / 驳回 |
|
||||
| `approved` | 已通过 | 标记已报销;此时写 `actual` 成本快照 |
|
||||
| `rejected` | 已驳回 | 需填 `reject_reason` |
|
||||
| `reimbursed` | 已报销 | 终态 |
|
||||
| `reversed` | 已冲正 | 预留,关联 `reversal_of_id` |
|
||||
费用类型到备用金分类的映射:`goods_purchase→purchase`、`compensation→compensation`、`freight→freight`、`recharge→platform_recharge`,其余 → `other`。
|
||||
|
||||
状态变更只追加到 `manual_expense_events`,不更新、不删除历史事件。
|
||||
### 3.5 凭证
|
||||
|
||||
### 3.5 字段
|
||||
|
||||
```text
|
||||
manual_expense_records
|
||||
expense_no -- 唯一编号
|
||||
order_id, order_item_id, work_order_id
|
||||
fund_expense_id -- 关联 manual_expenses.id;系统写入
|
||||
cost_item_id -- 付款项目 -> cost_items.cost_model
|
||||
expense_category -- goods_purchase / compensation / refund_advance /
|
||||
-- service_fee / freight / recharge / other
|
||||
amount_fen -- 实际金额(分),> 0
|
||||
currency
|
||||
paid_by_admin_user_id, paid_by_name
|
||||
payee_type, payee_name, payee_account
|
||||
payment_method
|
||||
paid_at
|
||||
status, approved_by, approved_at, rejected_by, rejected_at, reject_reason
|
||||
reimbursed_by, reimbursed_at
|
||||
evidence_json -- 凭证:支付截图对象 key、流水号、聊天记录 key
|
||||
cost_snapshot_id
|
||||
reversal_of_id
|
||||
expense_batch_no, note
|
||||
```
|
||||
|
||||
### 3.6 凭证
|
||||
|
||||
`evidence_json` **至少留一项**可追溯凭证(对象存储 key、流水号或聊天记录 key),否则拒绝提交。
|
||||
凭证**至少留一项**可追溯信息(对象存储 key、流水号、聊天记录 key,或备注文字),否则后端拒绝。
|
||||
|
||||
---
|
||||
|
||||
@@ -168,7 +156,7 @@ manual_expense_records
|
||||
- 锁定订单号,不允许改;
|
||||
- 必须选择备用金账户(客服角色自动带入本人账户且不可改);
|
||||
- 金额按本单实际金额填写,**没有小时单价或固定单量**;
|
||||
- 付款时间不能晚于当前时间,也不能早于订单支付时间。
|
||||
- 录入即生效:扣备用金与写成本快照一次完成,不再有"提交审核"步骤。
|
||||
|
||||
---
|
||||
|
||||
@@ -176,16 +164,15 @@ manual_expense_records
|
||||
|
||||
| 指标 | 目标 |
|
||||
| --- | ---: |
|
||||
| 人工订单项无人工支出记录 | 0 |
|
||||
| 人工支出无 `fund_expense_id`(上线后) | 0 |
|
||||
| 人工支出无成本快照 | 0 |
|
||||
| 人工订单项无备用金支出单 | 0 |
|
||||
| 人工订单项无成本快照 | 0 |
|
||||
| 人工支出金额与备用金流水不一致 | 0 |
|
||||
| 成本异常 open 中人工项 | 0(历史标记除外) |
|
||||
|
||||
关账门槛:
|
||||
|
||||
- 存在人工单无成本记录:**不允许关账**;
|
||||
- 存在人工支出未关联备用金:**不允许关账**;
|
||||
- 存在人工单未扣备用金:**不允许关账**;
|
||||
- 存在人工支出与备用金流水金额不一致:**不允许关账**。
|
||||
|
||||
核对 SQL 见 [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) §八。
|
||||
@@ -194,19 +181,24 @@ manual_expense_records
|
||||
|
||||
## 六、异常处理
|
||||
|
||||
- 发现人工单无成本:当天补录;补录仍走备用金扣款,不允许直接改余额。
|
||||
- 备用金余额不足:拒绝登记,由财务充值后再录;不允许负余额。
|
||||
- 发现人工单无成本:当天补录;补录走人工录单,仍然从备用金扣款,不允许直接改余额。
|
||||
- 备用金余额不足:拒绝录入,由主管补拨后再录;不允许负余额。
|
||||
- 人工录单匹配不到成本版本:备用金已扣,成本异常进队列;补齐 `sys_manual_service` 的有效版本后重新解析。
|
||||
- 同一订单项多笔支出:允许累加(渠道费、加急费、补差价),但每笔都必须有独立备用金流水。
|
||||
- 历史人工单:标记 `historical_missing`,不补录、不计入上线后缺口。
|
||||
- 历史人工单:标记 `historical_missing`,找不回来、**直接放弃**,不补录、不计入上线后缺口。
|
||||
|
||||
---
|
||||
|
||||
## 七、历史样本校准(只做参考)
|
||||
## 七、历史人工:直接放弃
|
||||
|
||||
- 每个来源抽 20–30 单,覆盖低价/中价/高价;
|
||||
- 只用于估算历史人工成本区间与毛利率,**不写 `cost_snapshots`**;
|
||||
- 样本结论单独记录,禁止回填成逐单成本;
|
||||
- 历史平台 53,901 项同理,只有拿到旧平台结算数据才允许按历史成本入账。
|
||||
历史人工支出找不回来,口径明确为**放弃**:
|
||||
|
||||
- 不补录、不回填 `cost_snapshots`;
|
||||
- 不做样本抽取与均价估算;
|
||||
- 不阻塞上线,历史缺口在销售事实里标记 `historical_missing`;
|
||||
- 只保证**上线后**的新单不丢:人工单必须有备用金支出与成本快照。
|
||||
|
||||
历史平台 53,901 项同理,只有拿到旧平台结算数据才允许按历史成本入账。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
> 状态:**现行说明(未实施)**
|
||||
> 最后校准:2026-10-23
|
||||
> 代码基线:`feat/finance-rewrite @ 7557de6d + 迁移 103–107`
|
||||
> 代码基线:`dev @ 43f402cf + 迁移 103–109`(已合并到 `dev`,**未推送、未部署生产**)
|
||||
> 配套方案:[财务系统集成方案.md](财务系统集成方案.md)
|
||||
> **总口径以 [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) 为准。**
|
||||
> 前置:统一履约事实、商品主数据、渠道映射、成本中心与销售事实;实施顺序以总纲第六节为准。
|
||||
@@ -10,7 +10,7 @@
|
||||
>
|
||||
> 边界:影子总账解决“钱怎么记、怎么对”,不负责“商品是什么、成本是多少、人工谁处理”。商品与成本见 [成本中心与履约模型.md](成本中心与履约模型.md),人工支出见 [人工支出与备用金.md](人工支出与备用金.md)。
|
||||
|
||||
> 当前进度:客服备用金、商品与成本中心、成本快照、销售事实、公司人工支出登记已实施;`finance_*` 表、分录、对账、转正均未实施。人工支出现在强制走客服备用金;历史人工支出不补录,上线后必须全覆盖。
|
||||
> 当前进度:客服备用金、商品与成本中心、成本快照、销售事实、人工录单已实施;`finance_*` 表、分录、对账、转正均未实施。人工成本现在只有一条链路:人工录单 → 扣客服备用金 → 写成本快照,无登记单/审批/报销;历史人工支出找不回来、直接放弃,上线后必须全覆盖。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+2
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
> 状态:**现行**。后端服务与前端页面都必须严格按此实现,不得各自发挥。
|
||||
> 最后校准:2026-10-23
|
||||
> 代码基线:`feat/finance-rewrite @ 7557de6d + 迁移 103–107`
|
||||
> 代码基线:`dev @ 43f402cf + 迁移 103–109`(已合并到 `dev`,**未推送、未部署生产**)
|
||||
> 详细业务口径见 [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md);数据模型见 [成本中心与履约模型.md](成本中心与履约模型.md)。
|
||||
|
||||
## 一、字段替换总表
|
||||
@@ -16,6 +16,7 @@
|
||||
| `product_catalog.carrier_type` | (删除,改由 `canonical_sku` 前缀推导) | DB / 商品接口 |
|
||||
| `sales_daily_facts.carrier_type` | `sales_daily_facts.fulfillment_mode` | DB / 销售事实接口 |
|
||||
| `work_product_rules.legacy_cost_source` | (删除) | DB |
|
||||
| `manual_expense_records` / `manual_expense_events` | (删除,人工成本只走人工录单) | DB |
|
||||
|
||||
## 二、枚举映射
|
||||
|
||||
|
||||
+20
-11
@@ -2,7 +2,7 @@
|
||||
|
||||
> 状态:**现行**。商品主数据、成本中心、成本快照、销售事实、履约事实均已落地。
|
||||
> 最后校准:2026-10-23
|
||||
> 代码基线:`feat/finance-rewrite @ 7557de6d + 迁移 103–107`
|
||||
> 代码基线:`dev @ 43f402cf + 迁移 103–109`(已合并到 `dev`,**未推送、未部署生产**)
|
||||
> 数据基线:本地生产快照克隆(数据截至 2026-10-14),已按新 schema 原地改造
|
||||
> **业务口径以 [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) 为准**;本文只负责数据模型、迁移与运维。
|
||||
> 接口字段见 [成本中心API契约.md](成本中心API契约.md)。
|
||||
@@ -20,7 +20,7 @@ orders ──┬── order_items ──── fulfillment_mode (唯一履约
|
||||
├── cost_snapshots ──── cost_versions ──── cost_items (cost_model)
|
||||
├── cost_exceptions (order_item_id → order_items.fulfillment_mode)
|
||||
├── sales_daily_facts (fulfillment_mode)
|
||||
└── manual_expense_records ── fund_expense_id ── manual_expenses
|
||||
└── manual_expenses (客服备用金支出) ── subject_id ── cost_snapshots
|
||||
```
|
||||
|
||||
四条不变式:
|
||||
@@ -32,7 +32,7 @@ orders ──┬── order_items ──── fulfillment_mode (唯一履约
|
||||
|
||||
---
|
||||
|
||||
## 一、迁移基线:12 个合并为 5 个
|
||||
## 一、迁移基线:12 个合并为 5 个(+ 1 个清理迁移)
|
||||
|
||||
原 `dev` 分支上,商品与成本中心用 12 个迁移(`103`–`114`,1,087 行)分批落地,字段反复摇摆。2026-10-23 重写为 5 个:
|
||||
|
||||
@@ -41,8 +41,14 @@ orders ──┬── order_items ──── fulfillment_mode (唯一履约
|
||||
| `103_product_catalog.sql` | `product_catalog`(无载体列)+ `channel_product_mappings`(含匹配键)+ `order_items` 商品/归一字段 | 152 |
|
||||
| `104_cost_center.sql` | `cost_items`(`cost_model`)+ `cost_versions` + `cost_snapshots`(无载体列)+ `cost_exceptions`(无载体列)+ 订单项收入固化 + 系统成本项 | 476 |
|
||||
| `105_sales_daily_facts.sql` | `sales_daily_facts`(`fulfillment_mode`) | 104 |
|
||||
| `106_manual_expense_records.sql` | `manual_expense_records` + `manual_expense_events` + 备用金账户编号可读化 | 201 |
|
||||
| `106_fund_account_no_readable.sql` | 备用金账户编号可读化(`FA-<用户名>`) | 51 |
|
||||
| `107_order_item_fulfillment_mode.sql` | `order_items.fulfillment_mode` 三列 | 48 |
|
||||
| `108_drop_manual_expense_records.sql` | 下线人工支出登记单与审批报销状态机(删两张表) | 66 |
|
||||
| `109_isolate_legacy_manual_snapshots.sql` | 隔离历史人工快照的主体 id(改负数) | 44 |
|
||||
|
||||
> `106` 原名 `106_manual_expense_records.sql`,2026-10-23 因下线人工支出登记链而改为 `106_fund_account_no_readable.sql`;`108` 是配套的清理迁移。
|
||||
|
||||
**关于迁移 109(重要坑)**:人工成本快照的主体从「人工支出登记单 id」改成「客服备用金支出单 id」(`manual_expenses.id`),两套 id 空间各自从 1 开始会撞车。历史孤儿快照保留原 `subject_id` 会与新建的 `manual_expenses.id` 撞唯一索引 `uq_cost_snapshots_active_subject_item`,导致 `INSERT ... ON CONFLICT DO NOTHING` 静默跳过——**钱从备用金扣了、成本快照却没写成**。迁移 109 把来源为旧登记链的历史快照主体改成负数,与正数的 `manual_expenses.id` 彻底隔离。
|
||||
|
||||
### 1.1 字段替换对照
|
||||
|
||||
@@ -57,6 +63,7 @@ orders ──┬── order_items ──── fulfillment_mode (唯一履约
|
||||
| `work_product_rules.legacy_cost_source` | (删除) | DB |
|
||||
| `cost_versions.cost_item_carrier_type` | `cost_versions.cost_item_cost_model` | 成本版本接口 |
|
||||
| `pricing_mode: per_hour / fixed_daily` | (删除,只剩三种) | 成本项接口 |
|
||||
| `manual_expense_records` / `manual_expense_events` | (删除,人工成本只走人工录单) | DB |
|
||||
|
||||
### 1.2 成本模型映射
|
||||
|
||||
@@ -215,7 +222,7 @@ cost_versions
|
||||
| --- | --- | --- |
|
||||
| `sys_work_order_reward` | `work_order_reward` | 662 个接单模板各一条规则级版本;全局 0 元版本只作"按工单实际结算"的占位 |
|
||||
| `sys_affiliate_purchase` | `affiliate_purchase` | 上游 SKU 固定成本版本,38 个商品级 |
|
||||
| `sys_manual_service` | `manual_actual` | 人工支出登记写入 `actual` 快照;版本先为 `draft` 占位 |
|
||||
| `sys_manual_service` | `manual_actual` | 订单页人工录单写入 `actual` 快照;版本先为 `draft` 占位 |
|
||||
|
||||
工单报酬的三种快照:
|
||||
|
||||
@@ -244,7 +251,9 @@ resolved_at, superseded_at, created_at
|
||||
- 同一主体 + 同一成本项只允许一条"当前有效"快照(`uq_cost_snapshots_active_subject_item`);
|
||||
- 预估快照代表支付时点的成本,**不因后续改价重写**;只有实际成本允许替代当前有效行;
|
||||
- 匹配不到成本时写 `cost_missing` 异常,**不能默认 0**;
|
||||
- 履约方式不存快照里,需要时 join `order_items.fulfillment_mode`。
|
||||
- 履约方式不存快照里,需要时 join `order_items.fulfillment_mode`;
|
||||
- 人工单的快照主体是 `subject_type='manual_case'` + `subject_id=manual_expenses.id`(客服备用金支出单),**没有独立的登记单表**;
|
||||
- 负数 `subject_id` 表示历史孤儿快照(已由迁移 109 隔离),金额保留供毛利参考,不与新链路争主体 id。
|
||||
|
||||
### 3.5 `cost_exceptions`
|
||||
|
||||
@@ -302,7 +311,7 @@ computed_at
|
||||
- 未归一商品允许 `canonical_product_id` 为空,单独成"未归一"行,保证总收入对平;
|
||||
- `cost_stage`:`missing`(全部无快照)/ `partial`(部分无)/ `estimated`(全部预估)/ `actual`(全部实际);
|
||||
- `cost_missing_count > 0` 时该行毛利不直接用于决策;
|
||||
- 退款暂未接入,`refund_amount_fen` 先按 `pay_status='refunded'` 预留。
|
||||
- 退款暂未接入,`refund_amount_fen` 先按 `pay_status='refunded'` 预留(整单全额冲减,无部分退款)。
|
||||
|
||||
### 5.2 本地重算结果(2026-10-23)
|
||||
|
||||
@@ -404,7 +413,7 @@ WHERE oi.id = d.id
|
||||
|
||||
## 八、本地库原地改造(2026-10-23 已执行)
|
||||
|
||||
生产尚未执行过 `103–114`,本地库需要从旧 schema 改到新 schema。改造脚本:`scripts/rewrite-local-db-2026-10-23.sql`。
|
||||
生产尚未执行过 `103–114`,本地库需要从旧 schema 改到新 schema。改造脚本:`scripts/rewrite-local-db-2026-10-23.sql`(列级改造)+ 迁移 `108`(删人工支出登记两表)。
|
||||
|
||||
改造矩阵(列级 `ALTER`,不重建表、不动源表):
|
||||
|
||||
@@ -415,7 +424,7 @@ WHERE oi.id = d.id
|
||||
| `cost_snapshots` | 删 `carrier_type`、删未使用的 `manual_case_id` |
|
||||
| `cost_exceptions` | 删 `carrier_type` |
|
||||
| `sales_daily_facts` | `carrier_type` → `fulfillment_mode`(改名) |
|
||||
| `manual_expense_records` | `cost_item_id` 改 `NOT NULL` |
|
||||
| `manual_expense_records` / `manual_expense_events` | 整表删除(迁移 `108`);成本快照主体改挂 `manual_expenses.id` |
|
||||
| `work_product_rules` | 删 `legacy_cost_source` |
|
||||
| `order_items` | **不动**(履约事实、收入固化、商品指向全部保留) |
|
||||
|
||||
@@ -427,7 +436,7 @@ WHERE oi.id = d.id
|
||||
# 2. 删旧迁移记录 + 列级改造
|
||||
docker exec -i order_site-postgres-1 psql -U postgres -d order_site \
|
||||
-v ON_ERROR_STOP=1 < scripts/rewrite-local-db-2026-10-23.sql
|
||||
# 3. 应用新迁移 103–107
|
||||
# 3. 应用新迁移 103–109
|
||||
npm run db:migrate
|
||||
# 4. 重跑履约回填(幂等)
|
||||
npm run fulfillment:backfill
|
||||
@@ -436,7 +445,7 @@ npm run finance:sync -- --mode=facts --from=2026-07-10 --to=2026-08-08
|
||||
# ...按 30 天窗口依次执行
|
||||
```
|
||||
|
||||
改造后数据量核对:商品 745、映射 4,426、成本项 3、成本版本 702、快照 158,764、异常 248,878、销售事实 14,798、人工支出 1、订单项 316,585 —— 全部与改造前一致。
|
||||
改造后数据量核对(2026-10-23,迁移 `108` 执行后复验):商品 745、映射 4,426、成本项 3、成本版本 702、快照 158,764、异常 248,878、销售事实 14,798、备用金支出 2、订单项 316,585 —— 与改造前一致。`manual_expense_records`(1 条测试数据)与 `manual_expense_events`(2 条)随迁移 `108` 一并删除。
|
||||
|
||||
`check-sql-guard.ts` 对 `103`–`114` 段迁移放开字节校验(这一组在分支内整体重写);`001`–`102` 仍必须保持字节不变。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 接单模板匹配引擎重构设计
|
||||
|
||||
> 目标读者:后端 / 后台运营。状态:**P0 + P1 已实施;P2 / P3 待办**。最后校准:2026-10-23(代码基线 `feat/finance-rewrite @ 7557de6d + 迁移 103–107`;匹配业务逻辑未变)。
|
||||
> 目标读者:后端 / 后台运营。状态:**P0 + P1 已实施;P2 / P3 待办**。最后校准:2026-10-23(代码基线 `dev @ 43f402cf + 迁移 103–109`;匹配业务逻辑未变)。
|
||||
>
|
||||
> 一句话:把「订单信息 → 接单模板(价格/拼单/要求)」的匹配,从散落的多个消费点、平台判断和"店铺必填"的绑定表里,收敛为**一个平台无关的引擎**:匹配条件完全由**订单信息**表达,**店铺只是可选条件之一**,新增来源只加一个适配器 + 一组配置。
|
||||
>
|
||||
|
||||
@@ -2,21 +2,22 @@
|
||||
|
||||
> 状态:**现行唯一口径**。本文是财务、成本、履约分类的总入口;其他文档与本文件冲突时,以本文为准。
|
||||
> 最后校准:2026-10-23
|
||||
> 代码基线:`feat/finance-rewrite @ 7557de6d + 迁移 103–107`
|
||||
> 代码基线:`dev @ 43f402cf + 迁移 103–109`(已合并到 `dev`,**未推送、未部署生产**)
|
||||
> 数据基线:本地生产快照克隆(数据截至 2026-10-14),已按新 schema 原地改造
|
||||
> 适用范围:订单接入、履约判定、商品归一、成本快照、销售事实、人工支出、备用金、后续总账与对账。
|
||||
> 适用范围:订单接入、履约判定、商品归一、成本快照、销售事实、人工录单、备用金、后续总账与对账。
|
||||
|
||||
---
|
||||
|
||||
## 〇、七条硬口径
|
||||
## 〇、八条硬口径
|
||||
|
||||
1. **电子凭证不是履约方式,也不是成本类型。** 快手电子凭证只是核销码/兑换码,抖音 DYC 券(`redeem_certificates`)同理;一个有核销码的 91/快手或抖音订单,仍可能走自动发货、接单平台或人工。
|
||||
2. **旧平台通道已经下线。** `kuaishou_ct_assisted`、`kuaishou_feifei` 只保留历史数据,报表中统一标记为 `legacy_platform`,不得混入人工。
|
||||
3. **人工的定义只有一个:** 已支付订单项,没有工单、没有自动发货任务、没有历史平台任务,即为人工;有没有核销码不影响判定。
|
||||
4. **人工成本逐单、按实际金额。** 不按小时、不按固定单量、不按固定单价;必须从客服备用金账户扣款,客服只能使用本人账户。
|
||||
5. **历史人工支出不补录。** 只取少量样本做校准参考;历史缺口统一标记为"历史未采集",不阻塞上线。
|
||||
6. **上线后人工账单必须 100% 可监控。** 每一笔人工单必须有处理人、实际金额、备用金支出与成本快照;缺失即进入异常队列,不允许关账。
|
||||
7. **履约方式只有一个真源。** `order_items.fulfillment_mode` 是唯一事实来源,商品表、成本项表、快照表、异常表、销售事实表都不再各自存一份"载体"。
|
||||
5. **人工成本只走「人工录单 → 扣备用金 → 写成本快照」一条路。** 没有登记单、没有审批、没有报销环节,录入即计入毛利。
|
||||
6. **历史人工支出找不回来,直接放弃。** 历史缺口统一标记为"历史未采集",不补录、不做样本回填;只保证上线后的新单不丢。
|
||||
7. **上线后人工账单必须 100% 可监控。** 每一笔人工单必须有处理人、实际金额、备用金支出与成本快照;缺失即进入异常队列,不允许关账。
|
||||
8. **履约方式只有一个真源。** `order_items.fulfillment_mode` 是唯一事实来源,商品表、成本项表、快照表、异常表、销售事实表都不再各自存一份"载体"。
|
||||
|
||||
---
|
||||
|
||||
@@ -74,13 +75,13 @@
|
||||
| --- | --- | --- | --- |
|
||||
| `affiliate_purchase` | 自动发货 | 上游 SKU 固定成本版本 | 38 个商品,全部有 active 版本 |
|
||||
| `work_order_reward` | 接单平台 | 模板规则级版本 / 工单实际报酬 | 662 个规则版本,966 项异常 |
|
||||
| `manual_actual` | 人工 | 订单页逐单录入的实际支出 | 已实现,历史未采集 |
|
||||
| `manual_actual` | 人工 | 订单页人工录单的实际支出(直接扣备用金) | 已实现,历史未采集 |
|
||||
| `platform_fee` / `payment_fee` / `after_sales_loss` / `other` | 全部履约方式 | 通用成本项 | 未配置 |
|
||||
|
||||
硬规则:
|
||||
|
||||
- 未确认成本时保持 `cost_missing`,**绝不默认记 0**;
|
||||
- 人工成本只走 `sys_manual_service`,通过 `manual_expense_records` 写 `actual` 快照;
|
||||
- 人工成本只走 `sys_manual_service`,由订单页「人工录单」写 `actual` 快照(主体 = `manual_expenses.id`);
|
||||
- 自动发货、接单平台继续使用现有成本版本与规则版本;
|
||||
- 电子凭证不建成本项,也不建"凭证载体"成本;
|
||||
- 计价方式只保留三种:`per_item`(按件)、`per_order`(按单)、`percent_of_revenue`(按收入比例)。**已删除 `per_hour` / `fixed_daily`**——人工成本逐单按实际金额,不按工时;
|
||||
@@ -111,32 +112,33 @@
|
||||
- 实际付款金额(分);
|
||||
- 费用项目;
|
||||
- 收款方与付款方式;
|
||||
- 备用金账户与 `fund_expense_id`;
|
||||
- 备用金账户与备用金支出单(`manual_expenses.id`);
|
||||
- 凭证或备注;
|
||||
- 成本快照(`manual_case` / `actual`)。
|
||||
|
||||
录入路径只有一条:订单页「人工录单」→ 扣客服备用金 → 写 `actual` 快照。**没有单独的登记单,也没有审批与报销环节**;钱与成本在同一次操作里落地。
|
||||
|
||||
### 4.3 监控指标(上线后每日检查)
|
||||
|
||||
| 指标 | 目标 |
|
||||
| --- | ---: |
|
||||
| 已支付订单项无 `fulfillment_mode` | 0 |
|
||||
| 人工订单项无人工支出记录 | 0 |
|
||||
| 人工支出无 `fund_expense_id` | 0(历史样本除外) |
|
||||
| 人工支出无成本快照 | 0 |
|
||||
| 人工订单项无备用金支出单 | 0 |
|
||||
| 人工订单项无成本快照 | 0 |
|
||||
| 人工支出金额与备用金流水不一致 | 0 |
|
||||
| 成本异常 open 中人工项 | 0(历史标记除外) |
|
||||
|
||||
### 4.4 关账门槛
|
||||
|
||||
- 存在人工单无成本记录:**不允许关账**;
|
||||
- 存在人工支出未关联备用金:**不允许关账**;
|
||||
- 存在人工单未扣备用金:**不允许关账**;
|
||||
- 存在人工支出与备用金流水金额不一致:**不允许关账**。
|
||||
|
||||
---
|
||||
|
||||
## 五、历史数据处理
|
||||
|
||||
- 历史人工支出**不补录**,只允许抽取样本做成本校准;
|
||||
- 历史人工支出**找不回来,直接放弃**:不补录、不做样本回填、不做均价估算;
|
||||
- 历史人工单在销售事实中保留收入,成本标记为 `historical_missing`,不计入"上线后监控缺口";
|
||||
- 历史平台 `legacy_platform` 单独成行,成本未采集的标记 `legacy_missing`;
|
||||
- 自动发货、接单平台历史成本不回写、不重算版本,沿用现有快照。
|
||||
@@ -148,6 +150,7 @@
|
||||
| 阶段 | 内容 | 状态 |
|
||||
| --- | --- | --- |
|
||||
| P0 | 统一履约事实(`fulfillment_mode`)+ 成本模型正交 | **已完成(本地,未部署生产)** |
|
||||
| P0.5 | 人工成本链路收敛为「人工录单 → 扣备用金 → 写快照」(下线登记/审批/报销) | **已完成(本地,未部署生产)** |
|
||||
| P1 | 人工商品归一(`man:<skuId>`)+ 上线后人工成本全覆盖 | 未实施 |
|
||||
| P2 | 退款冲减与异常闭环 | 未实施 |
|
||||
| P3 | 影子总账与对账 | 未实施 |
|
||||
@@ -165,18 +168,19 @@ P0 的具体迁移、回填、代码清单与验收 SQL 见 [成本中心与履
|
||||
| 订单页人工录单,按单实际金额 | **已实施** | `74b62f34` |
|
||||
| 人工支出从客服备用金扣款 | **已实施** | `2aeb789e` |
|
||||
| 客服只能使用本人备用金账户 | **已实施** | `a7f60414` |
|
||||
| 备用金账户编号 `FA-<用户名>` | **已实施** | `fac34c84` + 迁移 `106` |
|
||||
| 备用金账户编号 `FA-<用户名>` | **已实施** | `fac34c84` + 迁移 `106_fund_account_no_readable` |
|
||||
| 订单页只展示平台判断 SKU | **已实施** | `c38cd8ca`、`cd9bb141` |
|
||||
| `order_items.fulfillment_mode` 持久化 | **已实施** | 迁移 `107` + 统一决策服务 |
|
||||
| 订单页 / 成本快照 / 销售事实统一读字段 | **已实施** | `admin-order-read-helpers`、`sales-facts-service`、`cost-snapshot-sync-service` |
|
||||
| 本地 316,585 项回填 | **已实施** | `fulfillment:backfill`,四项计数与 §二 一致 |
|
||||
| 成本模型与履约方式正交(去 `carrier_type`) | **已实施(本地库已改造)** | 迁移 `103–107` 重写;`cost_items.cost_model` |
|
||||
| 12 个迁移合并为 5 个 | **已实施** | `103_product_catalog` ~ `107_order_item_fulfillment_mode` |
|
||||
| 人工成本链路收敛为「人工录单 → 扣备用金 → 写快照」 | **已实施(本地库已改造)** | 迁移 `108_drop_manual_expense_records`;下线登记单与审批报销 |
|
||||
| 人工商品归一 `man:<skuId>` | **未实施** | P1 |
|
||||
| 退款冲减、异常闭环 | **未实施** | §六 P2 |
|
||||
| 影子总账、对账、关账 | **未实施** | §六 P3 |
|
||||
|
||||
> 分支事实:以上实现均在 `feat/finance-rewrite`;迁移 `103–107` 尚未合并到 `dev`、未部署生产。
|
||||
> 分支事实:以上实现已快进合并到 `dev`(tip `43f402cf`);迁移 `103–109` **未推送远端、未部署生产**。
|
||||
> 生产口径仍以 `main` 为准。
|
||||
|
||||
---
|
||||
@@ -211,18 +215,24 @@ WHERE oi.fulfillment_mode = 'manual'
|
||||
WHERE cs.order_item_id = oi.id AND cs.superseded_at IS NULL
|
||||
);
|
||||
|
||||
-- 4. 人工支出未关联备用金:上线后应为 0
|
||||
-- 4. 人工订单项没有备用金支出单:上线后应为 0
|
||||
SELECT COUNT(*)
|
||||
FROM manual_expense_records
|
||||
WHERE fund_expense_id IS NULL
|
||||
AND created_at >= DATE '2026-11-01';
|
||||
FROM order_items oi
|
||||
WHERE oi.fulfillment_mode = 'manual'
|
||||
AND oi.created_at >= DATE '2026-11-01'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM manual_expenses me
|
||||
WHERE me.order_item_id = oi.id
|
||||
);
|
||||
|
||||
-- 5. 人工支出与备用金流水金额不一致:应为 0
|
||||
SELECT r.id, r.expense_no, r.amount_fen, me.amount_fen
|
||||
FROM manual_expense_records r
|
||||
JOIN manual_expenses me ON me.id = r.fund_expense_id
|
||||
WHERE r.amount_fen <> me.amount_fen
|
||||
AND r.created_at >= DATE '2026-11-01';
|
||||
-- 5. 人工成本快照金额与备用金支出金额不一致:应为 0
|
||||
SELECT cs.id, me.expense_no, me.amount_fen, cs.total_amount_fen
|
||||
FROM cost_snapshots cs
|
||||
JOIN manual_expenses me ON me.id = cs.subject_id
|
||||
WHERE cs.subject_type = 'manual_case'
|
||||
AND cs.superseded_at IS NULL
|
||||
AND cs.total_amount_fen <> me.amount_fen
|
||||
AND me.created_at >= DATE '2026-11-01';
|
||||
|
||||
-- 6. 人工单未归一商品:P1 完成后应为 0
|
||||
SELECT COUNT(*)
|
||||
@@ -272,7 +282,7 @@ WHERE oi.fulfillment_mode = 'manual'
|
||||
| 4 | 在商品/成本项/快照/异常四张表各存一份载体 | 四处口径分叉 | 载体只存 `order_items.fulfillment_mode` |
|
||||
| 5 | 只回填已支付历史单,不做增量兜底 | 新单长期停在 `NULL` | 24 小时定时归集 + 异常队列 |
|
||||
| 6 | 把"人工 91,606"当成"人工全量" | 与 102,955 对不上,报表互相矛盾 | 写清"全量 102,955 / 未归一 91,606" |
|
||||
| 7 | 把 `manual_expenses` 当公司人工支出表 | 两套语义混用,无法对账 | 用 `manual_expense_records` + `fund_expense_id` |
|
||||
| 7 | 给人工成本再造一张「登记单 + 审批 + 报销」表 | 两套资金语义混用,客服和财务都要录两遍 | 人工录单直接扣备用金并写快照,`manual_expenses` 是唯一资金台账 |
|
||||
| 8 | 用均价、工时、固定单价补历史人工 | 违背逐单实际金额,毛利失真 | 历史不补录,只做样本区间 |
|
||||
| 9 | 只改订单页,不改成本/销售事实 | 三处口径继续分叉 | 同一字段、同一决策入口 |
|
||||
| 10 | 在旧文档里保留"三块/按小时/公司直付"旧结论 | 下次开发又按旧口径实现 | 旧文只留追溯标记,现行结论只写本文件 |
|
||||
@@ -286,7 +296,7 @@ WHERE oi.fulfillment_mode = 'manual'
|
||||
| **本文件** | 履约/成本/人工口径、实施顺序、验收 SQL | **现行唯一口径** |
|
||||
| [成本中心与履约模型.md](成本中心与履约模型.md) | 商品、成本版本、快照、销售事实的数据模型与迁移 | 现行 |
|
||||
| [成本中心API契约.md](成本中心API契约.md) | 成本中心后端/前端接口字段与模块边界 | 现行 |
|
||||
| [人工支出与备用金.md](人工支出与备用金.md) | 人工支出登记、客服备用金台账、审批与权限 | 现行(P0 已实施) |
|
||||
| [人工支出与备用金.md](人工支出与备用金.md) | 人工录单、客服备用金台账与权限 | 现行(P0 已实施) |
|
||||
| [财务系统集成方案.md](财务系统集成方案.md) | `finance_*` 复式总账层(未实施) | 方案 |
|
||||
| [README.md](README.md) | 文档索引、分支与进度 | 现行入口 |
|
||||
|
||||
|
||||
+17
-19
@@ -2,7 +2,7 @@
|
||||
|
||||
> 状态:**现行方案(总账层未实施)**。本文只描述 `finance_*` 总账层;当前总账表尚未建立,现有财务页仍是 `UNION ALL` 业务事件拼装。
|
||||
> 最后校准:2026-10-23
|
||||
> 代码基线:`feat/finance-rewrite @ 7557de6d + 迁移 103–107`
|
||||
> 代码基线:`dev @ 43f402cf + 迁移 103–109`(已合并到 `dev`,**未推送、未部署生产**)
|
||||
> **履约方式、成本模型、人工全覆盖口径以 [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) 为准**;本文不重复分类设计。
|
||||
> 目标:在**不破坏现有抢单、押金、结算、寄卖、提现链路**的前提下,增加可对账、可关账、可追溯的复式记账底座。
|
||||
> 结论先说:**不要直接在 `worker_wallets` / `worker_wallet_ledgers` 上继续加字段。** 现有表是业务钱包与流水,不是会计总账;正确做法是新增复式记账底座,让现有钱包成为它的“余额投影”。
|
||||
@@ -16,15 +16,15 @@
|
||||
| 能力 | 状态 |
|
||||
| --- | --- |
|
||||
| 客服备用金台账(`fund_*` / 备用金 `manual_expenses`) | **已实施 P0**;账户编号 `FA-<用户名>`;客服只能使用本人账户 |
|
||||
| 商品主数据 / 渠道 SKU 映射 / 成本中心 | **已实施 P0(`dev` 分支)**,迁移 `103/104/105/106/107/108/110/112`;标准商品 745 个,只覆盖自动发货 38 + 接单平台 707 |
|
||||
| `sales_daily_facts` / 订单级毛利 | **已实施 P0(`dev` 分支)**,迁移 `109`;94 天收入 ¥2,650.87 万、成本 ¥950.27 万 |
|
||||
| 公司人工支出登记 `manual_expense_records` | **已实施 P0(`dev` 分支)**,迁移 `111`;登记时强制扣客服备用金并写 `fund_expense_id` |
|
||||
| 履约方式统一 `fulfillment_mode` | **已实施 P0(`dev` 分支)**;迁移 `114`,316,585 项已回填;订单页、成本快照、销售事实统一读该字段 |
|
||||
| 商品主数据 / 渠道 SKU 映射 / 成本中心 | **已实施 P0(`dev` 分支)**,迁移 `103/104`;标准商品 745 个,只覆盖自动发货 38 + 接单平台 707 |
|
||||
| `sales_daily_facts` / 订单级毛利 | **已实施 P0(`dev` 分支)**,迁移 `105`;94 天收入 ¥2,650.87 万、成本 ¥950.27 万 |
|
||||
| 人工成本录入(人工录单 → 扣备用金 → 写快照) | **已实施 P0(`dev` 分支)**,迁移 `106/108`;**无登记单、无审批、无报销** |
|
||||
| 履约方式统一 `fulfillment_mode` | **已实施 P0(`dev` 分支)**;迁移 `107`,316,585 项已回填;订单页、成本快照、销售事实统一读该字段 |
|
||||
| `finance_accounts` / `finance_business_events` / `finance_journal_*` | 未实施 |
|
||||
| `finance_settlements` / `finance_reconciliations` | 未实施 |
|
||||
| 现有 `admin-finance-service.ts` | 仍是业务事件 `UNION ALL`,不是总账 |
|
||||
|
||||
**不要复用客服备用金的 `manual_expenses` 作为公司人工支出表。** 公司人工支出使用 `manual_expense_records`,并通过 `fund_expense_id` 关联备用金支出;新登记会先扣备用金。
|
||||
**人工成本只有一条链路**:订单页人工录单 → 扣客服备用金(`manual_expenses`)→ 写 `cost_snapshots`(`subject_type='manual_case'`,`subject_id=manual_expenses.id`)。没有独立的人工支出登记表,也没有审批与报销环节。
|
||||
|
||||
---
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
| 自动发货 | `affiliate_dash` | 上游固定成本 → `cost_versions` | 采购成本 |
|
||||
| 接单平台 | `work_orders` | 模板规则版本 / 工单实际报酬 | 履约成本 + 押金负债 |
|
||||
| 历史平台 | `ct_assisted` / `feifei`(已下线) | 历史成本未采集则单列 | 仅历史报表 |
|
||||
| 人工 | 无工单、无自动发货任务、无历史平台任务 | **逐单实际金额**,从客服备用金扣款 | 人工成本 + 备用金 |
|
||||
| 人工 | 无工单、无自动发货任务、无历史平台任务 | **逐单实际金额**,人工录单直接从客服备用金扣款 | 人工成本 + 备用金 |
|
||||
|
||||
电子凭证只是核销码,不进入上述分类。
|
||||
|
||||
@@ -313,8 +313,8 @@ CREATE TABLE finance_reconciliation_items (
|
||||
| 寄卖验收货款 | 5001 商品采购成本 | 2002 应付卖家货款 | 货款金额取 `cost_price_fen || asking_price_fen` |
|
||||
| 支付卖家货款 | 2002 应付卖家货款 | 1001 银行存款 | 需结算单 |
|
||||
| 平台服务费 | 5401 平台服务费 | 2101 应付平台服务费 | 从平台账单导入 |
|
||||
| 人工支出登记通过(垫付) | 5102 履约成本-人工客服(按 `cost_item_id` 映射科目) | 2005 其他应付-垫付人 | 付款项目/订单号/付款人必填;见 [人工支出登记草案](drafts/2026-10-17-人工支出登记-DDL草案.sql) |
|
||||
| 人工垫付报销 | 2005 其他应付-垫付人 | 1001 银行存款 | 需凭证与审批留痕 |
|
||||
| 人工录单(客服垫付) | 5102 履约成本-人工客服(按 `cost_item_id` 映射科目) | 2005 其他应付-垫付人 | 扣客服备用金时同步发生;不需要单独审批 |
|
||||
| 客服备用金发放 | 1001 银行存款 | 2005 其他应付-垫付人 | 主管发放时发生 |
|
||||
| 退款冲正(预留) | 反向凭证 `reversal_of_id` 指向原凭证 | — | 退款/仲裁暂未接入,先预留不实现 |
|
||||
|
||||
> **已确认(2026-10-17)**:收入金额口径 = 用户实付;打手报酬 = 支出(总额法);退款/仲裁先预留;人工支出以登记表为入口。
|
||||
@@ -359,7 +359,7 @@ postBusinessEvent({
|
||||
| `after-sales-repo.ts` | 售后扣款/欠款抵扣发事件 |
|
||||
| `worker-finance-request-service.ts` | 充值/提现审核发事件 |
|
||||
| `consignment-deposit-repo.ts` | 发布押金、货款结算发事件 |
|
||||
| 新增 `services/finance/manual-expense-service.ts`(未来) | 公司人工支出 `approved / reimbursed / reversed` 发事件;通过 `fund_expense_id` 关联备用金支出 |
|
||||
| `services/finance/manual-order-cost-service.ts`(已实施) | 人工录单成功时发事件;主体是 `manual_expenses.id` |
|
||||
| `admin-finance-service.ts` | 改为读总账/试算平衡,不再用 `UNION ALL` 拼报表 |
|
||||
|
||||
### 4.3 钱包投影一致性
|
||||
@@ -397,14 +397,12 @@ GET /api/v1/admin/finance/sales-profit/overview
|
||||
GET /api/v1/admin/finance/sales-facts
|
||||
POST /api/v1/admin/finance/sales-facts/rebuild
|
||||
POST /api/v1/admin/cost-center/snapshots/sync
|
||||
GET /api/v1/admin/finance/manual-expenses
|
||||
GET /api/v1/admin/finance/manual-expenses/overview
|
||||
POST /api/v1/admin/finance/manual-expenses
|
||||
POST /api/v1/admin/finance/manual-expenses/:id/approve
|
||||
POST /api/v1/admin/finance/manual-expenses/:id/reject
|
||||
POST /api/v1/admin/finance/manual-expenses/:id/reimburse
|
||||
GET /api/v1/admin/finance/manual-order-cost-items
|
||||
POST /api/v1/admin/finance/manual-order-costs
|
||||
```
|
||||
|
||||
> 2026-10-23 下线了 `/finance/manual-expenses*` 全套接口(登记/审批/报销),改为上表的人工录单接口。
|
||||
|
||||
总账层建议新增前缀 `/api/v1/admin/finance`:
|
||||
|
||||
```text
|
||||
@@ -438,14 +436,14 @@ POST /periods/:key/close
|
||||
## 六、总账层分期
|
||||
|
||||
> 项目级顺序以 [README.md](README.md) §三 为准;本节只描述“总账层”内部阶段。
|
||||
> 进入 P0 前应已完成:统一履约事实、商品主数据、渠道 SKU 映射、成本中心、销售事实、公司人工支出登记(`manual_expense_records`)。
|
||||
> 进入 P0 前应已完成:统一履约事实、商品主数据、渠道 SKU 映射、成本中心、销售事实、人工录单(`manual-order-cost-service`)。
|
||||
|
||||
### P0:影子总账(1–2 周)
|
||||
|
||||
> 前置:先完成统一履约事实、商品主数据、渠道 SKU 映射、成本中心与销售事实(见 [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md))。否则影子总账只能记“总额”,无法按店铺/皮肤/成本版本出毛利。
|
||||
|
||||
- 建 `finance_accounts / business_events / journal_entries / journal_lines / periods`。
|
||||
- 先接高频且核心的事件:订单支付、工单结算、押金冻结/释放/没收、提现,以及公司人工支出的 `approved / reimbursed`。
|
||||
- 先接高频且核心的事件:订单支付、工单结算、押金冻结/释放/没收、提现,以及人工录单。
|
||||
- 写总账但不改现有钱包逻辑。
|
||||
- 每日对账:钱包余额 vs 总账余额。
|
||||
- 补金额守恒与幂等测试。
|
||||
@@ -506,7 +504,7 @@ POST /periods/:key/close
|
||||
| 5 | 寄卖货款的采购成本确认时点 | ⏳ 待定 | 决定存货/成本 |
|
||||
| 6 | 提现通道费、平台服务费由谁承担 | ⏳ 待定 | 决定费用科目与结算 |
|
||||
| 7 | 退款、拒付、仲裁的冲减规则 | ✅ **已确认:暂未接入,先预留**(字段 + `reversal_of_id` + 事件类型,不实现逻辑) | 决定售后与收入冲回 |
|
||||
| 8 | 人工支出如何入账 | ✅ **已确认:逐单实际金额,从客服备用金扣款,客服只能用本人账户**;历史人工支出不补录 | 决定人工成本与备用金 |
|
||||
| 8 | 人工支出如何入账 | ✅ **已确认:逐单实际金额,人工录单直接从客服备用金扣款并写成本快照,客服只能用本人账户;无登记单/审批/报销;历史人工支出找不回来、直接放弃** | 决定人工成本与备用金 |
|
||||
| 9 | 电子凭证是否作为履约/成本分类 | ✅ **不参与分类**;电子凭证只是快手核销码 | 决定报表口径 |
|
||||
| 10 | 历史人工支出 | ✅ **不补录**,只取少量样本校准;上线后人工单必须 100% 有成本记录 | 决定上线范围 |
|
||||
| 11 | 是否需要发票/税务 | ⏳ 待定 | 决定发票表与税额 |
|
||||
|
||||
Reference in New Issue
Block a user