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:
yml2213
2026-10-24 11:31:29 +08:00
co-authored by Claude Opus 4.5
parent 43f402cf72
commit fb679d76f6
25 changed files with 953 additions and 2739 deletions
@@ -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;负数表示历史孤儿快照(已与新链路隔离)';
+16 -129
View File
@@ -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)
}
-2
View File
@@ -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 -1
View File
@@ -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)
}
-8
View File
@@ -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
}>
}