diff --git a/apps/backend/src/db/migrations/106_fund_account_no_readable.sql b/apps/backend/src/db/migrations/106_fund_account_no_readable.sql new file mode 100644 index 00000000..e7b43c23 --- /dev/null +++ b/apps/backend/src/db/migrations/106_fund_account_no_readable.sql @@ -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; diff --git a/apps/backend/src/db/migrations/106_manual_expense_records.sql b/apps/backend/src/db/migrations/106_manual_expense_records.sql deleted file mode 100644 index 76b14845..00000000 --- a/apps/backend/src/db/migrations/106_manual_expense_records.sql +++ /dev/null @@ -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; diff --git a/apps/backend/src/db/migrations/108_drop_manual_expense_records.sql b/apps/backend/src/db/migrations/108_drop_manual_expense_records.sql new file mode 100644 index 00000000..94d04a82 --- /dev/null +++ b/apps/backend/src/db/migrations/108_drop_manual_expense_records.sql @@ -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'; diff --git a/apps/backend/src/db/migrations/109_isolate_legacy_manual_snapshots.sql b/apps/backend/src/db/migrations/109_isolate_legacy_manual_snapshots.sql new file mode 100644 index 00000000..6330026a --- /dev/null +++ b/apps/backend/src/db/migrations/109_isolate_legacy_manual_snapshots.sql @@ -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;负数表示历史孤儿快照(已与新链路隔离)'; diff --git a/apps/backend/src/routes/admin/finance.ts b/apps/backend/src/routes/admin/finance.ts index 959a9d3a..45c8abae 100644 --- a/apps/backend/src/routes/admin/finance.ts +++ b/apps/backend/src/routes/admin/finance.ts @@ -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), { + 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), { - 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 diff --git a/apps/backend/src/services/finance/manual-expense-service.ts b/apps/backend/src/services/finance/manual-expense-service.ts deleted file mode 100644 index 61b45155..00000000 --- a/apps/backend/src/services/finance/manual-expense-service.ts +++ /dev/null @@ -1,1263 +0,0 @@ -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 { resolveFinanceDateRange } from '../../utils/finance-date-range.js' -import { generateManualExpenseNo } from '../../utils/random.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' - -const EXPENSE_STATUSES = new Set([ - 'draft', - 'submitted', - 'approved', - 'rejected', - 'reimbursed', - 'reversed', -]) -const PAYEE_TYPES = new Set(['worker', 'upstream', 'customer', 'platform', 'other']) -const PAYMENT_METHODS = new Set(['wechat', 'alipay', 'bank', 'cash', 'platform_balance', 'other']) -const EXPENSE_CATEGORIES = new Set([ - 'goods_purchase', - 'compensation', - 'refund_advance', - 'service_fee', - 'freight', - 'recharge', - 'other', -]) - -type ManualExpenseRow = { - [column: string]: unknown - id: number - expense_no: string - order_id: number | null - order_item_id: number | null - work_order_id: number | null - fund_expense_id: number | null - cost_item_id: number | null - cost_item_name: string | null - cost_item_code: string | null - expense_category: string - amount_fen: number - currency: string - paid_by_admin_user_id: number | null - paid_by_name: string - payee_type: string - payee_name: string - payee_account: string - payment_method: string - paid_at: string - status: string - approved_by: string - approved_at: string | null - rejected_by: string - rejected_at: string | null - reject_reason: string - reimbursed_by: string - reimbursed_at: string | null - evidence_json: unknown - cost_snapshot_id: number | null - reversal_of_id: number | null - expense_batch_no: string - note: string - created_at: string - updated_at: string - platform_order_id: string | null - provider: string | null - shop_id: string | null - sku_code: string | null - sku_name: string | null - work_order_no: string | null - fund_expense_no?: string | null - fund_account_id?: number | null - fund_account_no?: string | null - fund_account_owner_name?: string | null -} - -export async function listManualExpenses(input: Record = {}) { - const range = resolveFinanceDateRange(input, { defaultDays: 30, maxDays: 400 }) - const page = normalizePositiveInteger(input.page, 1, '页码') - const pageSize = Math.min( - 200, - normalizePositiveInteger(input.pageSize ?? input.page_size, 20, '每页数量'), - ) - const status = String(input.status || '').trim() - const keyword = String(input.keyword || input.q || '').trim() - const costItemId = normalizeOptionalId(input.costItemId ?? input.cost_item_id) - const paidBy = normalizeOptionalId(input.paidByAdminUserId ?? input.paid_by_admin_user_id) - const orderId = normalizeOptionalId(input.orderId ?? input.order_id) - - const conditions = [ - `(r.paid_at AT TIME ZONE 'Asia/Shanghai')::date BETWEEN $1::date AND $2::date`, - ] - const params: unknown[] = [range.fromDate, range.toDate] - - if (status) { - assertOneOf(status, EXPENSE_STATUSES, '支出状态无效') - params.push(status) - conditions.push(`r.status = $${params.length}`) - } - if (costItemId) { - params.push(costItemId) - conditions.push(`r.cost_item_id = $${params.length}`) - } - if (paidBy) { - params.push(paidBy) - conditions.push(`r.paid_by_admin_user_id = $${params.length}`) - } - if (orderId) { - params.push(orderId) - conditions.push(`r.order_id = $${params.length}`) - } - if (keyword) { - params.push(`%${escapeLike(keyword)}%`) - const placeholder = `$${params.length}` - conditions.push(`( - r.expense_no ILIKE ${placeholder} - OR r.payee_name ILIKE ${placeholder} - OR r.paid_by_name ILIKE ${placeholder} - OR o.platform_order_id ILIKE ${placeholder} - OR oi.sku_name ILIKE ${placeholder} - OR wo.work_order_no ILIKE ${placeholder} - )`) - } - - const where = conditions.join(' AND ') - const countResult = await query<{ total: number }>( - ` - SELECT COUNT(*)::int AS total - FROM manual_expense_records r - LEFT JOIN orders o ON o.id = r.order_id - LEFT JOIN order_items oi ON oi.id = r.order_item_id - LEFT JOIN work_orders wo ON wo.id = r.work_order_id - WHERE ${where} - `, - params, - ) - const listParams = [...params, pageSize, (page - 1) * pageSize] - const result = await query( - ` - SELECT - r.*, - o.platform_order_id, - o.provider, - o.shop_id, - oi.sku_code, - oi.sku_name, - ci.name AS cost_item_name, - ci.code AS cost_item_code, - wo.work_order_no, - me.expense_no AS fund_expense_no, - me.account_id AS fund_account_id, - fa.account_no AS fund_account_no, - fa.owner_name AS fund_account_owner_name - FROM manual_expense_records r - LEFT JOIN orders o ON o.id = r.order_id - LEFT JOIN order_items oi ON oi.id = r.order_item_id - LEFT JOIN cost_items ci ON ci.id = r.cost_item_id - LEFT JOIN work_orders wo ON wo.id = r.work_order_id - LEFT JOIN manual_expenses me ON me.id = r.fund_expense_id - LEFT JOIN fund_accounts fa ON fa.id = me.account_id - WHERE ${where} - ORDER BY r.paid_at DESC, r.id DESC - LIMIT $${listParams.length - 1} OFFSET $${listParams.length} - `, - listParams, - ) - - return { - range, - items: result.rows.map(mapManualExpense), - pagination: { page, pageSize, total: Number(countResult.rows[0]?.total || 0) }, - } -} - -export async function listManualExpenseCostItems() { - 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 code <> 'sys_work_order_reward' - ORDER BY - CASE WHEN code = 'sys_manual_service' THEN 0 ELSE 1 END, - cost_model, - 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, - })), - } -} - -export async function getManualExpenseOverview(input: Record = {}) { - const range = resolveFinanceDateRange(input, { defaultDays: 30, maxDays: 400 }) - const [summaryResult, payerResult, itemResult] = await Promise.all([ - query<{ - submitted_count: number - submitted_amount_fen: number - approved_count: number - approved_amount_fen: number - reimbursed_count: number - reimbursed_amount_fen: number - rejected_count: number - rejected_amount_fen: number - no_order_count: number - no_order_amount_fen: number - unclassified_count: number - unclassified_amount_fen: number - }>( - ` - SELECT - COUNT(*) FILTER (WHERE status = 'submitted')::int AS submitted_count, - COALESCE(SUM(amount_fen) FILTER (WHERE status = 'submitted'), 0)::bigint - AS submitted_amount_fen, - COUNT(*) FILTER (WHERE status = 'approved')::int AS approved_count, - COALESCE(SUM(amount_fen) FILTER (WHERE status = 'approved'), 0)::bigint - AS approved_amount_fen, - COUNT(*) FILTER (WHERE status = 'reimbursed')::int AS reimbursed_count, - COALESCE(SUM(amount_fen) FILTER (WHERE status = 'reimbursed'), 0)::bigint - AS reimbursed_amount_fen, - COUNT(*) FILTER (WHERE status = 'rejected')::int AS rejected_count, - COALESCE(SUM(amount_fen) FILTER (WHERE status = 'rejected'), 0)::bigint - AS rejected_amount_fen, - COUNT(*) FILTER (WHERE order_id IS NULL AND order_item_id IS NULL)::int - AS no_order_count, - COALESCE(SUM(amount_fen) FILTER (WHERE order_id IS NULL AND order_item_id IS NULL), 0)::bigint - AS no_order_amount_fen, - COUNT(*) FILTER (WHERE cost_item_id IS NULL AND status <> 'draft')::int - AS unclassified_count, - COALESCE(SUM(amount_fen) FILTER (WHERE cost_item_id IS NULL AND status <> 'draft'), 0)::bigint - AS unclassified_amount_fen - FROM manual_expense_records - WHERE (paid_at AT TIME ZONE 'Asia/Shanghai')::date BETWEEN $1::date AND $2::date - `, - [range.fromDate, range.toDate], - ), - query<{ paid_by_name: string; record_count: number; amount_fen: number }>( - ` - SELECT paid_by_name, COUNT(*)::int AS record_count, SUM(amount_fen)::bigint AS amount_fen - FROM manual_expense_records - WHERE status IN ('approved', 'reimbursed') - AND (paid_at AT TIME ZONE 'Asia/Shanghai')::date BETWEEN $1::date AND $2::date - GROUP BY paid_by_name - ORDER BY amount_fen DESC - LIMIT 20 - `, - [range.fromDate, range.toDate], - ), - query<{ - cost_item_id: number | null - cost_item_name: string | null - expense_category: string - record_count: number - amount_fen: number - }>( - ` - SELECT - r.cost_item_id, - ci.name AS cost_item_name, - r.expense_category, - COUNT(*)::int AS record_count, - SUM(r.amount_fen)::bigint AS amount_fen - FROM manual_expense_records r - LEFT JOIN cost_items ci ON ci.id = r.cost_item_id - WHERE r.status IN ('approved', 'reimbursed') - AND (r.paid_at AT TIME ZONE 'Asia/Shanghai')::date BETWEEN $1::date AND $2::date - GROUP BY r.cost_item_id, ci.name, r.expense_category - ORDER BY amount_fen DESC - LIMIT 20 - `, - [range.fromDate, range.toDate], - ), - ]) - - const summary = summaryResult.rows[0] - return { - range, - overview: { - submittedCount: Number(summary?.submitted_count || 0), - submittedAmountFen: Number(summary?.submitted_amount_fen || 0), - approvedCount: Number(summary?.approved_count || 0), - approvedAmountFen: Number(summary?.approved_amount_fen || 0), - reimbursedCount: Number(summary?.reimbursed_count || 0), - reimbursedAmountFen: Number(summary?.reimbursed_amount_fen || 0), - rejectedCount: Number(summary?.rejected_count || 0), - rejectedAmountFen: Number(summary?.rejected_amount_fen || 0), - noOrderCount: Number(summary?.no_order_count || 0), - noOrderAmountFen: Number(summary?.no_order_amount_fen || 0), - unclassifiedCount: Number(summary?.unclassified_count || 0), - unclassifiedAmountFen: Number(summary?.unclassified_amount_fen || 0), - }, - byPayer: payerResult.rows.map((row) => ({ - paidByName: row.paid_by_name || '未填写', - recordCount: Number(row.record_count || 0), - amountFen: Number(row.amount_fen || 0), - })), - byCostItem: itemResult.rows.map((row) => ({ - costItemId: row.cost_item_id ? Number(row.cost_item_id) : null, - costItemName: row.cost_item_name || '未归类', - expenseCategory: row.expense_category, - recordCount: Number(row.record_count || 0), - amountFen: Number(row.amount_fen || 0), - })), - } -} - -export async function createManualExpense(payload: JsonObject, session: AdminSession) { - const amountFen = normalizeAmountFen(payload.amountFen ?? payload.amount_fen) - const payeeName = requiredText(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 status = payload.status === 'draft' ? 'draft' : 'submitted' - const evidenceJson = normalizeEvidence(payload.evidenceJson ?? payload.evidence_json) - if (!hasEvidence(evidenceJson)) { - throw createHttpError('请填写凭证链接/流水号,至少留一项可追溯凭证', { - statusCode: 400, - errorCode: 'manual_expense_evidence_required', - }) - } - - const orderItemId = normalizeOptionalId(payload.orderItemId ?? payload.order_item_id) - const orderIdInput = normalizeOptionalId(payload.orderId ?? payload.order_id) - const orderContext = await resolveOrderContext(orderItemId, orderIdInput) - const orderId = orderContext?.orderId || orderIdInput - const paidAt = normalizeIsoDate(payload.paidAt ?? payload.paid_at, '付款时间', nowIso()) - if (Date.parse(paidAt) > Date.now() + 5 * 60 * 1000) { - throw createHttpError('付款时间不能晚于当前时间', { - statusCode: 400, - errorCode: 'manual_expense_paid_at_future', - }) - } - if (orderContext?.paidAt && Date.parse(paidAt) < Date.parse(orderContext.paidAt)) { - throw createHttpError('人工支出时间不能早于订单支付时间', { - statusCode: 400, - errorCode: 'manual_expense_paid_at_before_order', - }) - } - - const costItem = await resolveCostItem( - normalizeOptionalId(payload.costItemId ?? payload.cost_item_id), - ) - const expenseCategory = normalizeStatus( - payload.expenseCategory ?? payload.expense_category, - EXPENSE_CATEGORIES, - inferExpenseCategory(costItem?.costModel), - '支出分类无效', - ) - const requestedFundAccountId = normalizeOptionalId( - payload.fundAccountId ?? payload.fund_account_id ?? payload.agentAccountId, - ) - let fundAccountId = requestedFundAccountId - 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_expense_fund_account_forbidden', - }) - } - fundAccountId = ownAccountId - } - if (!fundAccountId) { - throw createHttpError('请选择备用金账户,人工支出必须从备用金扣款', { - statusCode: 400, - errorCode: 'manual_expense_fund_account_required', - }) - } - const expenseNo = generateManualExpenseNo() - const now = 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( - optionalText(payload.note, 500), - optionalText(evidenceJson.reference, 200), - expenseNo, - ), - actorUserId: session.userId, - }) - const fundExpenseId = Number(fundWritten.expense.id) - - const result = await client.query( - ` - INSERT INTO manual_expense_records ( - expense_no, order_id, order_item_id, work_order_id, fund_expense_id, - cost_item_id, expense_category, amount_fen, currency, - paid_by_admin_user_id, paid_by_name, payee_type, payee_name, payee_account, - payment_method, paid_at, status, evidence_json, expense_batch_no, note, - created_at, updated_at - ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, 'CNY', - $9, $10, $11, $12, $13, - $14, $15, $16, $17::jsonb, $18, $19, - $20, $20 - ) - RETURNING * - `, - [ - expenseNo, - orderId, - orderItemId, - orderContext?.workOrderId || - normalizeOptionalId(payload.workOrderId ?? payload.work_order_id), - fundExpenseId, - costItem?.id || null, - expenseCategory, - amountFen, - fundWritten.ownerAdminUserId, - fundWritten.ownerName || session.displayName, - payeeType, - payeeName, - optionalText(payload.payeeAccount ?? payload.payee_account, 120), - paymentMethod, - paidAt, - status, - JSON.stringify(evidenceJson), - optionalText(payload.expenseBatchNo ?? payload.expense_batch_no, 64), - optionalText(payload.note, 500), - now, - ], - ) - const record = requiredRow(result.rows[0], '人工支出登记失败') - await insertManualExpenseEvent(client, { - expenseId: Number(record.id), - eventType: 'created', - fromStatus: '', - toStatus: status, - actor: session, - payload: { amountFen, costItemId: costItem?.id || null, fundExpenseId }, - now, - }) - return getManualExpenseByIdWithClient(client, Number(record.id)) - }) -} - -export async function createManualOrderCost(payload: JsonObject, session: AdminSession) { - const orderId = normalizeId(payload.orderId ?? payload.order_id) - const requestedItemId = normalizeOptionalId(payload.orderItemId ?? payload.order_item_id) - const requestedFundAccountId = normalizeOptionalId( - payload.fundAccountId ?? payload.fund_account_id ?? payload.agentAccountId, - ) - if (!requestedFundAccountId && session.role !== 'support') { - throw createHttpError('请选择备用金账户,人工录单必须从备用金扣款', { - statusCode: 400, - errorCode: 'manual_order_cost_fund_account_required', - }) - } - 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', - }) - } - let orderItemId = requestedItemId - if (orderItemId) { - if (!itemsResult.rows.some((row) => Number(row.id) === orderItemId)) { - throw createHttpError('订单项不属于该订单', { - statusCode: 400, - errorCode: 'manual_order_cost_item_mismatch', - }) - } - } else if (itemsResult.rows.length === 1) { - orderItemId = Number(itemsResult.rows[0]?.id || 0) - } else { - throw createHttpError('订单有多个订单项,请选择要计入的订单项', { - statusCode: 400, - errorCode: 'manual_order_cost_item_required', - }) - } - - const costItemResult = await query<{ id: number }>( - `SELECT id FROM cost_items - WHERE code = 'sys_manual_service' AND status = 'active' - LIMIT 1`, - ) - const costItemId = Number(costItemResult.rows[0]?.id || 0) - if (!costItemId) { - throw createHttpError('人工客服履约成本项未启用', { - statusCode: 409, - errorCode: 'manual_order_cost_item_unavailable', - }) - } - - 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 evidenceJson = normalizeEvidence(payload.evidenceJson ?? payload.evidence_json) - if (!hasEvidence(evidenceJson)) { - evidenceJson.note = note || '订单页人工录单' - } - const canApprove = - session.role === 'admin' || - session.role === 'finance' || - (session.permissions || []).includes('finance.review') - const effective = payload.effective !== false && canApprove - - const created = await createManualExpense( - { - orderId, - orderItemId, - costItemId, - fundAccountId: requestedFundAccountId, - amountFen, - expenseCategory, - payeeName, - payeeType: payload.payeeType ?? payload.payee_type ?? 'other', - paymentMethod: payload.paymentMethod ?? payload.payment_method ?? 'other', - note: note || '订单页人工录单', - evidenceJson, - status: 'submitted', - }, - session, - ) - - if (!effective) { - return { record: created, effective: false, reason: 'pending_review' as const } - } - - try { - const approved = await approveManualExpense( - created.id, - { note: '订单页人工录单直接生效' }, - session, - ) - return { record: approved, effective: true, reason: 'approved' as const } - } catch (error) { - return { - record: created, - effective: false, - reason: 'approval_failed' as const, - message: error instanceof Error ? error.message : '自动生效失败', - } - } -} - -export async function approveManualExpense( - expenseId: number | string, - payload: JsonObject, - session: AdminSession, -) { - const id = normalizeId(expenseId) - const now = nowIso() - return withTransaction(async (client) => { - const current = await lockManualExpense(client, id) - if (current.status === 'approved' || current.status === 'reimbursed') { - return getManualExpenseByIdWithClient(client, id) - } - if (current.status !== 'submitted') { - throw createHttpError('只有待审核的人工支出可以通过', { - statusCode: 409, - errorCode: 'manual_expense_approve_status_invalid', - }) - } - await client.query( - ` - UPDATE manual_expense_records - SET status = 'approved', approved_by = $1, approved_at = $2, updated_at = $2 - WHERE id = $3 - `, - [session.displayName, now, id], - ) - await attachCostSnapshotForExpense(client, current, session) - await insertManualExpenseEvent(client, { - expenseId: id, - eventType: 'approved', - fromStatus: current.status, - toStatus: 'approved', - actor: session, - payload: { note: optionalText(payload.note, 500) }, - now, - }) - return getManualExpenseByIdWithClient(client, id) - }) -} - -export async function rejectManualExpense( - expenseId: number | string, - payload: JsonObject, - session: AdminSession, -) { - const id = normalizeId(expenseId) - const reason = requiredText(payload.rejectReason ?? payload.reject_reason, '驳回原因', 300) - const now = nowIso() - return withTransaction(async (client) => { - const current = await lockManualExpense(client, id) - if (current.status !== 'submitted') { - throw createHttpError('只有待审核的人工支出可以驳回', { - statusCode: 409, - errorCode: 'manual_expense_reject_status_invalid', - }) - } - await client.query( - ` - UPDATE manual_expense_records - SET status = 'rejected', rejected_by = $1, rejected_at = $2, - reject_reason = $3, updated_at = $2 - WHERE id = $4 - `, - [session.displayName, now, reason, id], - ) - await insertManualExpenseEvent(client, { - expenseId: id, - eventType: 'rejected', - fromStatus: current.status, - toStatus: 'rejected', - actor: session, - payload: { reason }, - now, - }) - return getManualExpenseByIdWithClient(client, id) - }) -} - -export async function reimburseManualExpense(expenseId: number | string, session: AdminSession) { - const id = normalizeId(expenseId) - const now = nowIso() - return withTransaction(async (client) => { - const current = await lockManualExpense(client, id) - if (current.status === 'reimbursed') { - return getManualExpenseByIdWithClient(client, id) - } - if (current.status !== 'approved') { - throw createHttpError('只有已通过的人工支出可以标记已报销', { - statusCode: 409, - errorCode: 'manual_expense_reimburse_status_invalid', - }) - } - await client.query( - ` - UPDATE manual_expense_records - SET status = 'reimbursed', reimbursed_by = $1, reimbursed_at = $2, updated_at = $2 - WHERE id = $3 - `, - [session.displayName, now, id], - ) - await insertManualExpenseEvent(client, { - expenseId: id, - eventType: 'reimbursed', - fromStatus: current.status, - toStatus: 'reimbursed', - actor: session, - payload: {}, - now, - }) - return getManualExpenseByIdWithClient(client, id) - }) -} - -export async function getManualExpense(expenseId: number | string) { - return getManualExpenseByIdWithClient(null, normalizeId(expenseId)) -} - -async function getManualExpenseByIdWithClient(client: PoolClient | null, expenseId: number) { - const sql = ` - SELECT - r.*, - o.platform_order_id, - o.provider, - o.shop_id, - oi.sku_code, - oi.sku_name, - ci.name AS cost_item_name, - ci.code AS cost_item_code, - wo.work_order_no, - me.expense_no AS fund_expense_no, - me.account_id AS fund_account_id, - fa.account_no AS fund_account_no, - fa.owner_name AS fund_account_owner_name - FROM manual_expense_records r - LEFT JOIN orders o ON o.id = r.order_id - LEFT JOIN order_items oi ON oi.id = r.order_item_id - LEFT JOIN cost_items ci ON ci.id = r.cost_item_id - LEFT JOIN work_orders wo ON wo.id = r.work_order_id - LEFT JOIN manual_expenses me ON me.id = r.fund_expense_id - LEFT JOIN fund_accounts fa ON fa.id = me.account_id - WHERE r.id = $1 - LIMIT 1 - ` - const result = client - ? await client.query(sql, [expenseId]) - : await query(sql, [expenseId]) - return mapManualExpense(requiredRow(result.rows[0], '人工支出记录不存在')) -} - -async function lockManualExpense(client: PoolClient, expenseId: number) { - const result = await client.query( - `SELECT * FROM manual_expense_records WHERE id = $1 FOR UPDATE`, - [expenseId], - ) - return requiredRow(result.rows[0], '人工支出记录不存在') -} - -async function attachCostSnapshotForExpense( - client: PoolClient, - expense: ManualExpenseRow, - session: AdminSession, -) { - if (!expense.order_item_id || !expense.cost_item_id || Number(expense.amount_fen) <= 0) { - return null - } - const contextResult = await client.query<{ - order_id: number - canonical_product_id: number | null - provider: string - shop_id: string - work_order_id: number | null - work_product_rule_id: number | null - fulfillment_mode: string - }>( - ` - 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 - `, - [expense.order_item_id, expense.cost_item_id], - ) - const context = contextResult.rows[0] - if (!context) return null - - const versionResult = 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 - `, - [ - expense.cost_item_id, - expense.paid_at, - context.provider, - context.shop_id, - context.canonical_product_id, - context.work_product_rule_id, - ], - ) - const versionId = versionResult.rows[0]?.id - if (!versionId) { - 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 - `, - [ - expense.order_item_id, - context.work_order_id, - JSON.stringify({ - expenseId: expense.id, - expenseNo: expense.expense_no, - message: '人工支出已通过,但该成本项没有匹配到有效成本版本', - }), - ], - ) - return null - } - - 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 - `, - [ - expense.id, - context.order_id, - expense.order_item_id, - context.work_order_id, - expense.cost_item_id, - versionId, - context.canonical_product_id, - expense.amount_fen, - JSON.stringify({ - source: 'manual_expense_record', - expenseId: expense.id, - expenseNo: expense.expense_no, - paidByName: expense.paid_by_name, - payeeName: expense.payee_name, - approvedBy: session.displayName, - fulfillmentMode: context.fulfillment_mode, - }), - expense.paid_at, - nowIso(), - ], - ) - const snapshotId = snapshotResult.rows[0]?.id - if (snapshotId) { - await client.query( - ` - UPDATE manual_expense_records - SET cost_snapshot_id = $1, updated_at = $2 - WHERE id = $3 - `, - [snapshotId, nowIso(), expense.id], - ) - } - return snapshotId || null -} - -async function insertManualExpenseEvent( - client: PoolClient, - input: { - expenseId: number - eventType: string - fromStatus: string - toStatus: string - actor: AdminSession - payload: JsonObject - now: string - }, -) { - await client.query( - ` - INSERT INTO manual_expense_events ( - expense_id, event_type, from_status, to_status, - actor_type, actor_id, actor_name, payload_json, created_at - ) VALUES ($1, $2, $3, $4, 'admin', $5, $6, $7::jsonb, $8) - `, - [ - input.expenseId, - input.eventType, - input.fromStatus, - input.toStatus, - String(input.actor.userId), - input.actor.displayName, - JSON.stringify(input.payload), - input.now, - ], - ) -} - -async function resolveOrderContext(orderItemId: number | null, orderId: number | null) { - if (orderItemId) { - const result = await query<{ - order_id: number - work_order_id: number | null - paid_at: string | null - }>( - ` - SELECT - oi.order_id, - ( - SELECT wo.id FROM work_orders wo - WHERE wo.order_item_id = oi.id - ORDER BY wo.id DESC - LIMIT 1 - ) AS work_order_id, - COALESCE(o.paid_at, o.created_at) AS paid_at - FROM order_items oi - JOIN orders o ON o.id = oi.order_id - WHERE oi.id = $1 - LIMIT 1 - `, - [orderItemId], - ) - const row = result.rows[0] - if (!row) { - throw createHttpError('订单项不存在', { - statusCode: 404, - errorCode: 'manual_expense_order_item_not_found', - }) - } - if (orderId && Number(row.order_id) !== orderId) { - throw createHttpError('订单项不属于该订单', { - statusCode: 400, - errorCode: 'manual_expense_order_mismatch', - }) - } - return { - orderId: Number(row.order_id), - workOrderId: row.work_order_id ? Number(row.work_order_id) : null, - paidAt: row.paid_at, - } - } - if (orderId) { - const result = await query<{ paid_at: string | null }>( - `SELECT COALESCE(paid_at, created_at) AS paid_at FROM orders WHERE id = $1 LIMIT 1`, - [orderId], - ) - if (!result.rows[0]) { - throw createHttpError('订单不存在', { - statusCode: 404, - errorCode: 'manual_expense_order_not_found', - }) - } - return { orderId, workOrderId: null, paidAt: result.rows[0].paid_at } - } - return null -} - -async function resolveCostItem(costItemId: number | null) { - if (!costItemId) return null - const result = await query<{ - id: number - code: string - cost_model: string - status: string - }>(`SELECT id, code, cost_model, status FROM cost_items WHERE id = $1 LIMIT 1`, [costItemId]) - const row = result.rows[0] - if (!row) { - throw createHttpError('成本项目不存在', { - statusCode: 404, - errorCode: 'manual_expense_cost_item_not_found', - }) - } - if (row.code === 'sys_work_order_reward') { - throw createHttpError('接单平台实际报酬由工单验收自动入账,不能用于人工支出', { - statusCode: 409, - errorCode: 'manual_expense_cost_item_reserved', - }) - } - if (row.status !== 'active') { - throw createHttpError('成本项目已归档,不能用于人工支出登记', { - statusCode: 409, - errorCode: 'manual_expense_cost_item_archived', - }) - } - return { id: Number(row.id), costModel: row.cost_model } -} - -function mapManualExpense(row: ManualExpenseRow) { - return { - id: Number(row.id), - expenseNo: row.expense_no, - orderId: row.order_id ? Number(row.order_id) : null, - orderItemId: row.order_item_id ? Number(row.order_item_id) : null, - workOrderId: row.work_order_id ? Number(row.work_order_id) : null, - fundExpenseId: row.fund_expense_id ? Number(row.fund_expense_id) : null, - fundExpenseNo: row.fund_expense_no || '', - fundAccountId: row.fund_account_id ? Number(row.fund_account_id) : null, - fundAccountNo: row.fund_account_no || '', - fundAccountOwnerName: row.fund_account_owner_name || '', - costItemId: row.cost_item_id ? Number(row.cost_item_id) : null, - costItemName: row.cost_item_name || '', - costItemCode: row.cost_item_code || '', - expenseCategory: row.expense_category, - amountFen: Number(row.amount_fen || 0), - currency: row.currency, - paidByAdminUserId: row.paid_by_admin_user_id ? Number(row.paid_by_admin_user_id) : null, - paidByName: row.paid_by_name, - payeeType: row.payee_type, - payeeName: row.payee_name, - payeeAccount: row.payee_account, - paymentMethod: row.payment_method, - paidAt: row.paid_at, - status: row.status, - approvedBy: row.approved_by, - approvedAt: row.approved_at, - rejectedBy: row.rejected_by, - rejectedAt: row.rejected_at, - rejectReason: row.reject_reason, - reimbursedBy: row.reimbursed_by, - reimbursedAt: row.reimbursed_at, - evidenceJson: row.evidence_json || {}, - costSnapshotId: row.cost_snapshot_id ? Number(row.cost_snapshot_id) : null, - reversalOfId: row.reversal_of_id ? Number(row.reversal_of_id) : null, - expenseBatchNo: row.expense_batch_no, - note: row.note, - createdAt: row.created_at, - updatedAt: row.updated_at, - platformOrderId: row.platform_order_id || '', - provider: row.provider || '', - shopId: row.shop_id || '', - skuCode: row.sku_code || '', - skuName: row.sku_name || '', - workOrderNo: row.work_order_no || '', - } -} - -function inferExpenseCategory(costModel: string | undefined) { - if (costModel === 'affiliate_purchase') return 'goods_purchase' - if (costModel === 'manual_actual') return 'service_fee' - if (costModel === 'other') return 'freight' - if (costModel === 'payment_fee' || costModel === 'platform_fee') return 'service_fee' - return 'other' -} - -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, expenseNo: string): string { - const parts = [`人工支出 ${expenseNo}`] - 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_expense_amount_invalid', - }) - } - if (amount > 100_000_000_00) { - throw createHttpError('支出金额超出允许范围', { - statusCode: 400, - errorCode: 'manual_expense_amount_too_large', - }) - } - return amount -} - -function normalizeIsoDate(value: unknown, label: string, fallback: string) { - const raw = String(value || '').trim() - if (!raw) return fallback - let normalized = raw.includes('T') ? raw : raw.replace(' ', 'T') - if (!normalized.includes('T')) normalized = `${normalized}T00:00:00` - if (!/(?:Z|[+-]\d{2}:?\d{2})$/i.test(normalized)) normalized = `${normalized}+08:00` - const timestamp = Date.parse(normalized) - if (!Number.isFinite(timestamp)) { - throw createHttpError(`${label}格式无效`, { - statusCode: 400, - errorCode: 'manual_expense_date_invalid', - }) - } - return new Date(timestamp).toISOString() -} - -function normalizePositiveInteger(value: unknown, fallback: number, label: string) { - const parsed = Number(value) - if (!value || !Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1) { - if (!value) return fallback - throw createHttpError(`${label}必须是正整数`, { - statusCode: 400, - errorCode: 'manual_expense_pagination_invalid', - }) - } - return parsed -} - -function normalizeOptionalId(value: unknown) { - const parsed = Number(value) - return Number.isInteger(parsed) && parsed > 0 ? parsed : null -} - -function normalizeId(value: unknown) { - const parsed = Number(value) - if (!Number.isInteger(parsed) || parsed <= 0) { - throw createHttpError('记录 ID 无效', { - statusCode: 400, - errorCode: 'manual_expense_id_invalid', - }) - } - return parsed -} - -function normalizeStatus(value: unknown, allowed: Set, fallback: string, message: string) { - const text = String(value || '').trim() || fallback - assertOneOf(text, allowed, message) - return text -} - -function assertOneOf(value: string, allowed: Set, message: string) { - if (!allowed.has(value)) { - throw createHttpError(message, { - statusCode: 400, - errorCode: 'manual_expense_value_invalid', - }) - } -} - -function requiredText(value: unknown, label: string, maxLength: number) { - const text = optionalText(value, maxLength) - if (!text) { - throw createHttpError(`${label}不能为空`, { - statusCode: 400, - errorCode: 'manual_expense_field_required', - }) - } - return text -} - -function optionalText(value: unknown, maxLength: number) { - return String(value || '') - .trim() - .slice(0, maxLength) -} - -function escapeLike(value: string) { - return value.replace(/[\\%_]/g, (char) => `\\${char}`) -} - -function requiredRow(row: T | undefined, message: string): T { - if (!row) { - throw createHttpError(message, { - statusCode: 404, - errorCode: 'manual_expense_not_found', - }) - } - return row -} diff --git a/apps/backend/src/services/finance/manual-order-cost-service.ts b/apps/backend/src/services/finance/manual-order-cost-service.ts new file mode 100644 index 00000000..08fe9d49 --- /dev/null +++ b/apps/backend/src/services/finance/manual-order-cost-service.ts @@ -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 { + 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 { + const result = await client.query( + ` + 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, 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) +} diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 661026af..2596bd49 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -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() { }> } /> - } /> } /> }> diff --git a/apps/frontend/src/components/admin/ManualOrderCostModal.tsx b/apps/frontend/src/components/admin/ManualOrderCostModal.tsx index 296d1141..076e3557 100644 --- a/apps/frontend/src/components/admin/ManualOrderCostModal.tsx +++ b/apps/frontend/src/components/admin/ManualOrderCostModal.tsx @@ -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() - 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({ void submit()} @@ -158,7 +151,7 @@ export default function ManualOrderCostModal({ showIcon style={{ marginBottom: 14 }} message="每一单单独录实际金额,不按小时、不按单量套模板" - description="成本会从所选客服备用金账户扣减,并关联到订单号;只有一笔订单项时自动带入,多笔时需要选择具体订单项。审核通过后写入成本快照,并计入销售毛利。" + description="成本会从所选客服备用金账户扣减,并关联到订单号;只有一笔订单项时自动带入,多笔时需要选择具体订单项。录入即写入成本快照并计入销售毛利,没有审批与报销环节。" /> @@ -272,23 +265,6 @@ export default function ManualOrderCostModal({ placeholder="这一单实际做了什么、为什么是这个金额" /> - {canApprove ? ( - - - - ) : ( - - )} ) diff --git a/apps/frontend/src/layouts/AdminLayout.tsx b/apps/frontend/src/layouts/AdminLayout.tsx index bb69d0ae..2fee48de 100644 --- a/apps/frontend/src/layouts/AdminLayout.tsx +++ b/apps/frontend/src/layouts/AdminLayout.tsx @@ -6,7 +6,6 @@ import { BugOutlined, DashboardOutlined, FileSearchOutlined, - FileTextOutlined, LogoutOutlined, LineChartOutlined, MenuFoldOutlined, @@ -135,7 +134,6 @@ export default function AdminLayout() { ? [ { key: '/admin/finance', icon: , label: '财务' }, { key: '/admin/sales-profit', icon: , label: '销售毛利' }, - { key: '/admin/manual-expenses', icon: , label: '人工支出' }, { key: '/admin/cost-center', icon: , label: '商品与成本' }, { key: '/admin/fund', icon: , label: '备用金' }, { key: '/admin/worker-platform', icon: , label: '资金审核' }, @@ -148,11 +146,6 @@ export default function AdminLayout() { icon: , label: role === 'support' ? '我的备用金' : '备用金', }, - { - key: '/admin/manual-expenses', - icon: , - label: '人工支出', - }, ...(role === 'support' ? [] : [ diff --git a/apps/frontend/src/pages/admin/AdminManualExpensePage.tsx b/apps/frontend/src/pages/admin/AdminManualExpensePage.tsx deleted file mode 100644 index 1e3f8dc2..00000000 --- a/apps/frontend/src/pages/admin/AdminManualExpensePage.tsx +++ /dev/null @@ -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 = { - 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() - const [expenseForm] = Form.useForm() - 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(null) - const [rejectTarget, setRejectTarget] = useState(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 = [ - { - title: '登记号 / 状态', - dataIndex: 'expenseNo', - width: 190, - render: (value: string, row) => { - const meta = STATUS_META[row.status] - return ( - - {value} - {meta.label} - - ) - }, - }, - { - title: '付款时间', - dataIndex: 'paidAt', - width: 165, - render: (value: string) => formatAdminDateTime(value), - }, - { - title: '订单 / 商品', - key: 'order', - width: 260, - render: (_, row) => ( - - {row.platformOrderId || (row.orderId ? `#${row.orderId}` : '无关联订单')} - - {row.skuName || row.workOrderNo || '-'} - - - ), - }, - { - title: '付款项目', - key: 'costItem', - width: 180, - render: (_, row) => ( - - {row.costItemName || '未归类'} - - {CATEGORY_OPTIONS.find((item) => item.value === row.expenseCategory)?.label || - row.expenseCategory} - - - ), - }, - { - title: '金额', - dataIndex: 'amountFen', - width: 120, - render: (value: number) => {formatMoney(value)}, - }, - { - title: '付款人 → 收款人', - key: 'parties', - width: 190, - render: (_, row) => ( - - {row.paidByName || '-'} - → {row.payeeName || '-'} - - ), - }, - { - title: '备用金账户', - key: 'fundAccount', - width: 180, - render: (_, row) => - row.fundAccountId ? ( - - {row.fundAccountOwnerName || '-'} - - {row.fundAccountNo || `#${row.fundAccountId}`} · {row.fundExpenseNo || '-'} - - - ) : ( - 未走备用金 - ), - }, - { - title: '凭证', - dataIndex: 'evidenceJson', - width: 160, - ellipsis: true, - render: (value: Record) => { - const reference = typeof value?.reference === 'string' ? value.reference : '' - return ( - - {reference || '-'} - - ) - }, - }, - { - title: '成本快照', - dataIndex: 'costSnapshotId', - width: 100, - render: (value: number | null) => - value ? #{value} : 未生成, - }, - { - title: '操作', - key: 'actions', - width: 190, - fixed: 'right', - render: (_, row) => { - if (!canReview) return 仅查看 - return ( - - {row.status === 'submitted' ? ( - <> - - - - ) : null} - {row.status === 'approved' ? ( - - ) : null} - {row.status === 'reimbursed' ? ( - 已完成 - ) : null} - - ) - }, - }, - ] - - return ( -
- - - - - form={filterForm} - layout="inline" - className="filter-form" - onFinish={applyFilters} - initialValues={{ - dateRange: [dayjs().subtract(29, 'day'), dayjs()], - status: '', - keyword: '', - }} - > - - - - - - - - - - - {canCreate ? ( - - ) : null} - - - - - - - {overview && overview.unclassifiedCount > 0 ? ( - - ) : null} - - {overview && overview.noOrderCount > 0 ? ( - - ) : null} - - - - - formatMoney(overview?.submittedAmountFen || 0)} - /> - - - - - formatMoney(overview?.approvedAmountFen || 0)} - /> - - - - - formatMoney(overview?.reimbursedAmountFen || 0)} - /> - - - - - - - - - - - - - - - - - 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) - }, - })} - /> - - - { - setCreateOpen(false) - expenseForm.resetFields() - }} - onOk={submitExpense} - confirmLoading={creating} - okText="提交登记" - width={720} - > - - form={expenseForm} - layout="vertical" - initialValues={{ - expenseCategory: 'service_fee', - payeeType: 'upstream', - paymentMethod: 'wechat', - paidAt: dayjs(), - }} - > - - - - - - - - - - - - - {isSupport ? ( - <> - - - - - - ) : ( - - ({ - value: item.id, - label: `${item.name}(${item.code})`, - }))} - /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - setRejectTarget(null)} - onOk={submitReject} - confirmLoading={Boolean(rejectTarget && actionId === rejectTarget.id)} - okText="确认驳回" - okButtonProps={{ danger: true }} - > - setRejectReason(event.target.value)} - placeholder="必须填写驳回原因" - /> - -
- ) -} diff --git a/apps/frontend/src/services/admin/index.ts b/apps/frontend/src/services/admin/index.ts index 58458eba..69fb348c 100644 --- a/apps/frontend/src/services/admin/index.ts +++ b/apps/frontend/src/services/admin/index.ts @@ -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' diff --git a/apps/frontend/src/services/admin/manual-expense.ts b/apps/frontend/src/services/admin/manual-expense.ts deleted file mode 100644 index bc41d86e..00000000 --- a/apps/frontend/src/services/admin/manual-expense.ts +++ /dev/null @@ -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('/api/v1/admin/finance/manual-expenses', params) -} - -export function fetchAdminManualExpenseOverview(params: AdminManualExpenseParams = {}) { - return apiGet( - '/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(`/api/v1/admin/finance/manual-expenses/${expenseId}`) -} - -export function createAdminManualExpense(payload: Record) { - return apiPost('/api/v1/admin/finance/manual-expenses', payload) -} - -export function createAdminManualOrderCost(payload: Record) { - 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 = {}, -) { - return apiPost( - `/api/v1/admin/finance/manual-expenses/${expenseId}/approve`, - payload, - ) -} - -export function rejectAdminManualExpense(expenseId: number, rejectReason: string) { - return apiPost(`/api/v1/admin/finance/manual-expenses/${expenseId}/reject`, { - rejectReason, - }) -} - -export function reimburseAdminManualExpense(expenseId: number) { - return apiPost(`/api/v1/admin/finance/manual-expenses/${expenseId}/reimburse`) -} diff --git a/apps/frontend/src/services/admin/manual-order-cost.ts b/apps/frontend/src/services/admin/manual-order-cost.ts new file mode 100644 index 00000000..1b14adbc --- /dev/null +++ b/apps/frontend/src/services/admin/manual-order-cost.ts @@ -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) { + return apiPost('/api/v1/admin/finance/manual-order-costs', payload) +} diff --git a/apps/frontend/src/types/admin/index.ts b/apps/frontend/src/types/admin/index.ts index 6dd53987..926bcc79 100644 --- a/apps/frontend/src/types/admin/index.ts +++ b/apps/frontend/src/types/admin/index.ts @@ -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, diff --git a/apps/frontend/src/types/admin/manual-expense.ts b/apps/frontend/src/types/admin/manual-expense.ts deleted file mode 100644 index d0b6bfcf..00000000 --- a/apps/frontend/src/types/admin/manual-expense.ts +++ /dev/null @@ -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 - 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 - }> -} diff --git a/docs/README.md b/docs/README.md index ca1a9d21..047f33a7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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:`;历史 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 | ## 六、厂商协议与外部资料 diff --git a/docs/drafts/2026-10-17-人工支出登记-DDL草案.sql b/docs/archive/2026-10-23/2026-10-17-人工支出登记-DDL草案.sql similarity index 95% rename from docs/drafts/2026-10-17-人工支出登记-DDL草案.sql rename to docs/archive/2026-10-23/2026-10-17-人工支出登记-DDL草案.sql index 46de73d1..05d8fe1f 100644 --- a/docs/drafts/2026-10-17-人工支出登记-DDL草案.sql +++ b/docs/archive/2026-10-23/2026-10-17-人工支出登记-DDL草案.sql @@ -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 +-- ============================================================================ + -- ============================================================================ -- 人工支出登记 + 轻量人工处理记录(建表草案) -- ---------------------------------------------------------------------------- diff --git a/docs/人工支出与备用金.md b/docs/人工支出与备用金.md index e349ad5f..19f8d5f9 100644 --- a/docs/人工支出与备用金.md +++ b/docs/人工支出与备用金.md @@ -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 项同理,只有拿到旧平台结算数据才允许按历史成本入账。 --- diff --git a/docs/影子总账说明.md b/docs/影子总账说明.md index fd7a1918..90deaf4a 100644 --- a/docs/影子总账说明.md +++ b/docs/影子总账说明.md @@ -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_*` 表、分录、对账、转正均未实施。人工成本现在只有一条链路:人工录单 → 扣客服备用金 → 写成本快照,无登记单/审批/报销;历史人工支出找不回来、直接放弃,上线后必须全覆盖。 --- diff --git a/docs/成本中心API契约.md b/docs/成本中心API契约.md index 63fea92c..111dfd6f 100644 --- a/docs/成本中心API契约.md +++ b/docs/成本中心API契约.md @@ -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 | ## 二、枚举映射 diff --git a/docs/成本中心与履约模型.md b/docs/成本中心与履约模型.md index f777525a..bcde36ba 100644 --- a/docs/成本中心与履约模型.md +++ b/docs/成本中心与履约模型.md @@ -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` 仍必须保持字节不变。 diff --git a/docs/接单模板匹配引擎重构设计.md b/docs/接单模板匹配引擎重构设计.md index 70c8c197..aa61c064 100644 --- a/docs/接单模板匹配引擎重构设计.md +++ b/docs/接单模板匹配引擎重构设计.md @@ -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`;匹配业务逻辑未变)。 > > 一句话:把「订单信息 → 接单模板(价格/拼单/要求)」的匹配,从散落的多个消费点、平台判断和"店铺必填"的绑定表里,收敛为**一个平台无关的引擎**:匹配条件完全由**订单信息**表达,**店铺只是可选条件之一**,新增来源只加一个适配器 + 一组配置。 > diff --git a/docs/财务与履约口径总纲-2026-10-23.md b/docs/财务与履约口径总纲-2026-10-23.md index cb6525e7..fb166209 100644 --- a/docs/财务与履约口径总纲-2026-10-23.md +++ b/docs/财务与履约口径总纲-2026-10-23.md @@ -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:`)+ 上线后人工成本全覆盖 | 未实施 | | 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:` | **未实施** | 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) | 文档索引、分支与进度 | 现行入口 | diff --git a/docs/财务系统集成方案.md b/docs/财务系统集成方案.md index 39305cfd..2bc41fd2 100644 --- a/docs/财务系统集成方案.md +++ b/docs/财务系统集成方案.md @@ -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 | 是否需要发票/税务 | ⏳ 待定 | 决定发票表与税额 |