diff --git a/apps/backend/src/db/migrations/100_fund_accounts.sql b/apps/backend/src/db/migrations/100_fund_accounts.sql new file mode 100644 index 00000000..19ad5a81 --- /dev/null +++ b/apps/backend/src/db/migrations/100_fund_accounts.sql @@ -0,0 +1,224 @@ +-- 100_fund_accounts.sql —— 客服备用金台账(主管池 / 客服余额 / 支出 / 对账)。 +-- +-- 业务口径(2026-10-18 确认): +-- 公司把钱交给主管,主管发给客服;客服用于充值/采购/赔偿;充值后立即消耗,直接记支出。 +-- 系统要回答:钱在谁手里、每笔花到哪、每个客服还剩多少、账实是否对平。 +-- +-- 设计约束: +-- 1. 余额只能由 fund_entries 产生,不允许手工改; +-- 2. 发放/缴回必须同时写主管与客服两条流水,金额相反; +-- 3. 支出必须同时写客服负数流水和 manual_expenses 明细; +-- 4. 已记账流水不修改、不删除,差异走 adjustment。 + +CREATE TABLE IF NOT EXISTS fund_accounts ( + id BIGSERIAL PRIMARY KEY, + account_no TEXT NOT NULL, + account_type TEXT NOT NULL, + owner_admin_user_id BIGINT NOT NULL REFERENCES admin_users(id) ON DELETE RESTRICT, + owner_name TEXT NOT NULL DEFAULT '', + responsible_supervisor_id BIGINT REFERENCES fund_accounts(id) ON DELETE SET NULL, + balance_fen BIGINT NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_fund_accounts_account_no + ON fund_accounts (account_no); +CREATE UNIQUE INDEX IF NOT EXISTS uq_fund_accounts_owner_type + ON fund_accounts (owner_admin_user_id, account_type); +CREATE INDEX IF NOT EXISTS idx_fund_accounts_type_status + ON fund_accounts (account_type, status, owner_name); +CREATE INDEX IF NOT EXISTS idx_fund_accounts_supervisor + ON fund_accounts (responsible_supervisor_id, status); + +CREATE TABLE IF NOT EXISTS fund_entries ( + id BIGSERIAL PRIMARY KEY, + entry_no TEXT NOT NULL, + account_id BIGINT NOT NULL REFERENCES fund_accounts(id) ON DELETE RESTRICT, + entry_type TEXT NOT NULL, + amount_fen BIGINT NOT NULL, + balance_after_fen BIGINT NOT NULL, + transfer_no TEXT NOT NULL DEFAULT '', + counterparty_account_id BIGINT REFERENCES fund_accounts(id) ON DELETE SET NULL, + reconciliation_id BIGINT, + occurred_at TIMESTAMPTZ NOT NULL, + created_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL, + evidence_json JSONB NOT NULL DEFAULT '[]'::jsonb, + note TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_fund_entries_entry_no + ON fund_entries (entry_no); +CREATE INDEX IF NOT EXISTS idx_fund_entries_account_time + ON fund_entries (account_id, occurred_at DESC, id DESC); +CREATE INDEX IF NOT EXISTS idx_fund_entries_type_time + ON fund_entries (entry_type, occurred_at DESC); +CREATE INDEX IF NOT EXISTS idx_fund_entries_transfer + ON fund_entries (transfer_no) WHERE transfer_no <> ''; +CREATE INDEX IF NOT EXISTS idx_fund_entries_counterparty + ON fund_entries (counterparty_account_id); + +CREATE TABLE IF NOT EXISTS manual_expenses ( + id BIGSERIAL PRIMARY KEY, + expense_no TEXT NOT NULL, + account_id BIGINT NOT NULL REFERENCES fund_accounts(id) ON DELETE RESTRICT, + amount_fen BIGINT NOT NULL, + category TEXT NOT NULL, + payee_type TEXT NOT NULL, + payee_name TEXT NOT NULL, + payee_account TEXT NOT NULL DEFAULT '', + platform_name TEXT NOT NULL DEFAULT '', + platform_account TEXT NOT NULL DEFAULT '', + order_id BIGINT REFERENCES orders(id) ON DELETE SET NULL, + order_item_id BIGINT REFERENCES order_items(id) ON DELETE SET NULL, + paid_at TIMESTAMPTZ NOT NULL, + evidence_json JSONB NOT NULL DEFAULT '[]'::jsonb, + note TEXT NOT NULL DEFAULT '', + fund_entry_id BIGINT NOT NULL REFERENCES fund_entries(id) ON DELETE RESTRICT, + created_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_manual_expenses_expense_no + ON manual_expenses (expense_no); +CREATE UNIQUE INDEX IF NOT EXISTS uq_manual_expenses_fund_entry + ON manual_expenses (fund_entry_id); +CREATE INDEX IF NOT EXISTS idx_manual_expenses_account_time + ON manual_expenses (account_id, paid_at DESC, id DESC); +CREATE INDEX IF NOT EXISTS idx_manual_expenses_category_time + ON manual_expenses (category, paid_at DESC); +CREATE INDEX IF NOT EXISTS idx_manual_expenses_order + ON manual_expenses (order_id) WHERE order_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_manual_expenses_payee + ON manual_expenses (payee_name, paid_at DESC); + +CREATE TABLE IF NOT EXISTS fund_reconciliations ( + id BIGSERIAL PRIMARY KEY, + reconciliation_no TEXT NOT NULL, + account_id BIGINT NOT NULL REFERENCES fund_accounts(id) ON DELETE RESTRICT, + period_start TIMESTAMPTZ NOT NULL, + period_end TIMESTAMPTZ NOT NULL, + system_balance_fen BIGINT NOT NULL, + actual_balance_fen BIGINT NOT NULL, + difference_fen BIGINT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + reason TEXT NOT NULL DEFAULT '', + evidence_json JSONB NOT NULL DEFAULT '[]'::jsonb, + confirmed_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL, + confirmed_at TIMESTAMPTZ, + created_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_fund_reconciliations_no + ON fund_reconciliations (reconciliation_no); +CREATE INDEX IF NOT EXISTS idx_fund_reconciliations_account_time + ON fund_reconciliations (account_id, period_end DESC, id DESC); +CREATE INDEX IF NOT EXISTS idx_fund_reconciliations_status_time + ON fund_reconciliations (status, period_end DESC); + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_fund_accounts_type') THEN + ALTER TABLE fund_accounts + ADD CONSTRAINT chk_fund_accounts_type + CHECK (account_type IN ('supervisor', 'agent')); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_fund_accounts_status') THEN + ALTER TABLE fund_accounts + ADD CONSTRAINT chk_fund_accounts_status + CHECK (status IN ('active', 'frozen', 'closed')); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_fund_accounts_balance') THEN + ALTER TABLE fund_accounts + ADD CONSTRAINT chk_fund_accounts_balance + CHECK (balance_fen >= 0); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_fund_entries_type') THEN + ALTER TABLE fund_entries + ADD CONSTRAINT chk_fund_entries_type + CHECK (entry_type IN ('opening', 'funding', 'issue', 'expense', 'return', 'adjustment')); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_fund_entries_amount') THEN + ALTER TABLE fund_entries + ADD CONSTRAINT chk_fund_entries_amount + CHECK (amount_fen <> 0); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_fund_entries_balance') THEN + ALTER TABLE fund_entries + ADD CONSTRAINT chk_fund_entries_balance + CHECK (balance_after_fen >= 0); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expenses_amount') THEN + ALTER TABLE manual_expenses + ADD CONSTRAINT chk_manual_expenses_amount + CHECK (amount_fen > 0); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expenses_category') THEN + ALTER TABLE manual_expenses + ADD CONSTRAINT chk_manual_expenses_category + CHECK (category IN ('platform_recharge', 'purchase', 'compensation', 'freight', 'other')); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expenses_payee_type') THEN + ALTER TABLE manual_expenses + ADD CONSTRAINT chk_manual_expenses_payee_type + CHECK (payee_type IN ('platform', 'supplier', 'customer', 'other')); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_fund_reconciliations_status') THEN + ALTER TABLE fund_reconciliations + ADD CONSTRAINT chk_fund_reconciliations_status + CHECK (status IN ('pending', 'confirmed', 'discrepancy')); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_fund_reconciliations_difference') THEN + ALTER TABLE fund_reconciliations + ADD CONSTRAINT chk_fund_reconciliations_difference + CHECK (difference_fen = actual_balance_fen - system_balance_fen); + END IF; +END $$; + +COMMENT ON TABLE fund_accounts IS '备用金资金账户:主管池与客服备用金账户'; +COMMENT ON COLUMN fund_accounts.responsible_supervisor_id IS '客服的责任主管账户;对账由该主管确认'; +COMMENT ON COLUMN fund_accounts.balance_fen IS '当前余额,只能由 fund_entries 变动'; +COMMENT ON TABLE fund_entries IS '备用金流水:期初/拨付/发放/支出/缴回/调整,只追加'; +COMMENT ON COLUMN fund_entries.amount_fen IS '有符号金额:正数流入,负数流出'; +COMMENT ON COLUMN fund_entries.balance_after_fen IS '记账后的账户余额快照'; +COMMENT ON COLUMN fund_entries.transfer_no IS '发放/缴回双边流水批次号,主管与客服两条共用'; +COMMENT ON TABLE manual_expenses IS '客服备用金支出明细;充值后立即消耗,直接记支出'; +COMMENT ON TABLE fund_reconciliations IS '备用金对账:系统余额与实际余额差异,差异通过 adjustment 流水修正'; + +INSERT INTO admin_permissions (permission_key, permission_name, permission_group, description) +VALUES + ('fund.view', '查看备用金', '备用金管理', '查看备用金账户、流水和支出明细'), + ('fund.issue', '发放/缴回备用金', '备用金管理', '主管向客服发放或收回备用金'), + ('fund.expense', '登记备用金支出', '备用金管理', '客服登记备用金支出'), + ('fund.reconcile', '备用金对账', '备用金管理', '发起和确认备用金对账'), + ('fund.adjust', '调整备用金余额', '备用金管理', '对账差异确认后调整账户余额'), + ('fund.export', '导出备用金数据', '备用金管理', '导出备用金账户、流水和支出') +ON CONFLICT (permission_key) DO UPDATE SET + permission_name = EXCLUDED.permission_name, + permission_group = EXCLUDED.permission_group, + description = EXCLUDED.description, + updated_at = NOW(); + +INSERT INTO admin_role_permissions (role_key, permission_key) +SELECT 'admin', permission_key +FROM admin_permissions +WHERE permission_key LIKE 'fund.%' +ON CONFLICT DO NOTHING; + +INSERT INTO admin_role_permissions (role_key, permission_key) +VALUES + ('finance', 'fund.view'), + ('finance', 'fund.reconcile'), + ('finance', 'fund.adjust'), + ('finance', 'fund.export'), + ('operator', 'fund.view'), + ('operator', 'fund.issue'), + ('operator', 'fund.reconcile'), + ('support', 'fund.view'), + ('support', 'fund.expense') +ON CONFLICT DO NOTHING; diff --git a/apps/backend/src/db/migrations/101_admin_display_name.sql b/apps/backend/src/db/migrations/101_admin_display_name.sql new file mode 100644 index 00000000..844c93c6 --- /dev/null +++ b/apps/backend/src/db/migrations/101_admin_display_name.sql @@ -0,0 +1,45 @@ +-- 101_admin_display_name.sql —— 后台用户增加中文姓名/显示名。 +-- +-- 账号 username 仍然是登录凭证,只能使用字母、数字和 ._-; +-- display_name 只用于展示,可输入中文姓名。为空时回退显示 username。 + +ALTER TABLE admin_users + ADD COLUMN IF NOT EXISTS display_name TEXT NOT NULL DEFAULT ''; + +UPDATE admin_users +SET display_name = username +WHERE display_name = ''; + +CREATE INDEX IF NOT EXISTS idx_admin_users_display_name + ON admin_users (display_name); + +COMMENT ON COLUMN admin_users.display_name IS '后台用户显示名(中文姓名等),为空时回退 username'; + +-- 备用金账户里的 owner_name 是姓名快照;改名后同步,避免还要逐表手工改。 +CREATE OR REPLACE FUNCTION sync_admin_display_name_to_fund_accounts() +RETURNS TRIGGER AS $$ +BEGIN + UPDATE fund_accounts + SET + owner_name = COALESCE(NULLIF(NEW.display_name, ''), NEW.username), + updated_at = NOW() + WHERE owner_admin_user_id = NEW.id + AND owner_name IS DISTINCT FROM COALESCE(NULLIF(NEW.display_name, ''), NEW.username); + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_admin_users_display_name_fund_accounts ON admin_users; +CREATE TRIGGER trg_admin_users_display_name_fund_accounts +AFTER UPDATE OF display_name, username ON admin_users +FOR EACH ROW +EXECUTE FUNCTION sync_admin_display_name_to_fund_accounts(); + +UPDATE fund_accounts fa +SET + owner_name = COALESCE(NULLIF(au.display_name, ''), au.username), + updated_at = NOW() +FROM admin_users au +WHERE fa.owner_admin_user_id = au.id + AND fa.owner_name IS DISTINCT FROM COALESCE(NULLIF(au.display_name, ''), au.username); diff --git a/apps/backend/src/repositories/admin-audit-log-repo.ts b/apps/backend/src/repositories/admin-audit-log-repo.ts index 1ce0a20f..88150091 100644 --- a/apps/backend/src/repositories/admin-audit-log-repo.ts +++ b/apps/backend/src/repositories/admin-audit-log-repo.ts @@ -5,6 +5,7 @@ type AdminAuditLogRow = { id: number actor_user_id: number | null actor_username: string + actor_display_name: string | null actor_role: string action: string target_type: string @@ -88,28 +89,30 @@ export async function listAdminAuditLogs( const params: unknown[] = [] if (queryInput.actorUsername) { - params.push(queryInput.actorUsername) - conditions.push(`actor_username = $${params.length}`) + params.push(`%${queryInput.actorUsername}%`) + conditions.push( + `(aal.actor_username ILIKE $${params.length} OR au.display_name ILIKE $${params.length})`, + ) } if (queryInput.action) { params.push(queryInput.action) - conditions.push(`action = $${params.length}`) + conditions.push(`aal.action = $${params.length}`) } if (queryInput.targetType) { params.push(queryInput.targetType) - conditions.push(`target_type = $${params.length}`) + conditions.push(`aal.target_type = $${params.length}`) } if (queryInput.dateFrom) { params.push(queryInput.dateFrom) - conditions.push(`created_at >= $${params.length}`) + conditions.push(`aal.created_at >= $${params.length}`) } if (queryInput.dateTo) { params.push(queryInput.dateTo) - conditions.push(`created_at <= $${params.length}`) + conditions.push(`aal.created_at <= $${params.length}`) } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' @@ -118,7 +121,12 @@ export async function listAdminAuditLogs( const offset = (page - 1) * pageSize const totalResult = await query<{ [column: string]: unknown; total: number }>( - `SELECT COUNT(*)::int AS total FROM admin_audit_logs ${whereClause}`, + ` + SELECT COUNT(*)::int AS total + FROM admin_audit_logs aal + LEFT JOIN admin_users au ON au.id = aal.actor_user_id + ${whereClause} + `, params, ) @@ -126,10 +134,13 @@ export async function listAdminAuditLogs( params.push(offset) const itemsResult = await query( ` - SELECT * - FROM admin_audit_logs + SELECT + aal.*, + COALESCE(NULLIF(au.display_name, ''), au.username) AS actor_display_name + FROM admin_audit_logs aal + LEFT JOIN admin_users au ON au.id = aal.actor_user_id ${whereClause} - ORDER BY id DESC + ORDER BY aal.id DESC LIMIT $${params.length - 1} OFFSET $${params.length} `, params, diff --git a/apps/backend/src/repositories/admin-login-log-repo.ts b/apps/backend/src/repositories/admin-login-log-repo.ts index 2d901a76..7c536c30 100644 --- a/apps/backend/src/repositories/admin-login-log-repo.ts +++ b/apps/backend/src/repositories/admin-login-log-repo.ts @@ -5,6 +5,7 @@ export type AdminLoginLogRow = { id: number user_id: number | null username: string + display_name: string | null role: string ip: string location: string @@ -75,18 +76,20 @@ export async function listAdminLoginLogs( const params: unknown[] = [] if (queryInput.username) { - params.push(String(queryInput.username).trim()) - conditions.push(`username = $${params.length}`) + params.push(`%${String(queryInput.username).trim()}%`) + conditions.push( + `(ll.username ILIKE $${params.length} OR au.display_name ILIKE $${params.length})`, + ) } if (queryInput.dateFrom) { params.push(queryInput.dateFrom) - conditions.push(`created_at >= $${params.length}`) + conditions.push(`ll.created_at >= $${params.length}`) } if (queryInput.dateTo) { params.push(queryInput.dateTo) - conditions.push(`created_at <= $${params.length}`) + conditions.push(`ll.created_at <= $${params.length}`) } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' @@ -95,7 +98,12 @@ export async function listAdminLoginLogs( const offset = (page - 1) * pageSize const totalResult = await query<{ total: number }>( - `SELECT COUNT(*)::int AS total FROM admin_login_logs ${whereClause}`, + ` + SELECT COUNT(*)::int AS total + FROM admin_login_logs ll + LEFT JOIN admin_users au ON au.id = ll.user_id + ${whereClause} + `, params, ) @@ -103,10 +111,13 @@ export async function listAdminLoginLogs( params.push(offset) const itemsResult = await query( ` - SELECT * - FROM admin_login_logs + SELECT + ll.*, + COALESCE(NULLIF(au.display_name, ''), au.username) AS display_name + FROM admin_login_logs ll + LEFT JOIN admin_users au ON au.id = ll.user_id ${whereClause} - ORDER BY id DESC + ORDER BY ll.id DESC LIMIT $${params.length - 1} OFFSET $${params.length} `, params, diff --git a/apps/backend/src/repositories/admin-user-repo.ts b/apps/backend/src/repositories/admin-user-repo.ts index e0910149..e274d9fe 100644 --- a/apps/backend/src/repositories/admin-user-repo.ts +++ b/apps/backend/src/repositories/admin-user-repo.ts @@ -3,6 +3,7 @@ import { query } from '../db/client.js' type AdminUserRow = { id: number username: string + display_name: string password_hash: string role: string status: string @@ -13,6 +14,7 @@ type AdminUserRow = { type AdminUserCreateInput = { username: string + displayName: string passwordHash: string role: string status: string @@ -23,14 +25,20 @@ type AdminUserCreateInput = { type AdminUserPatch = Partial< Pick< AdminUserRow, - 'username' | 'password_hash' | 'role' | 'status' | 'session_version' | 'updated_at' + | 'username' + | 'display_name' + | 'password_hash' + | 'role' + | 'status' + | 'session_version' + | 'updated_at' > > type AdminUserListInput = { page?: number pageSize?: number - username?: string + keyword?: string role?: string status?: string } @@ -66,17 +74,19 @@ export async function createAdminUser(input: AdminUserCreateInput): Promise { @@ -137,24 +149,24 @@ export async function listAdminUsers({ const filters: string[] = [] const params: unknown[] = [] - if (username) { - params.push(`%${username}%`) - filters.push(`username ILIKE $${params.length}`) + if (keyword) { + params.push(`%${keyword}%`) + filters.push(`(au.username ILIKE $${params.length} OR au.display_name ILIKE $${params.length})`) } if (role) { params.push(role) - filters.push(`role = $${params.length}`) + filters.push(`au.role = $${params.length}`) } if (status) { params.push(status) - filters.push(`status = $${params.length}`) + filters.push(`au.status = $${params.length}`) } const whereClause = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '' const totalResult = await query<{ [column: string]: unknown; total: number }>( - `SELECT COUNT(*)::int AS total FROM admin_users ${whereClause}`, + `SELECT COUNT(*)::int AS total FROM admin_users au ${whereClause}`, params, ) diff --git a/apps/backend/src/routes/admin.ts b/apps/backend/src/routes/admin.ts index 6114005f..68a3027d 100644 --- a/apps/backend/src/routes/admin.ts +++ b/apps/backend/src/routes/admin.ts @@ -6,6 +6,7 @@ import dashboardRouter from './admin/dashboard.js' import devMockRouter from './admin/dev-mock.js' import douyinMashidaRouter from './admin/douyin-mashida.js' import financeRouter from './admin/finance.js' +import fundRouter from './admin/fund.js' import filesRouter from './admin/files.js' import kuaishouIndustryRouter from './admin/kuaishou-industry.js' import ordersRouter from './admin/orders.js' @@ -26,6 +27,7 @@ router.use(requireAdminSession) router.use(filesRouter) router.use(dashboardRouter) router.use(financeRouter) +router.use(fundRouter) router.use(usersRouter) router.use(rbacRouter) router.use(auditLogsRouter) diff --git a/apps/backend/src/routes/admin/fund.ts b/apps/backend/src/routes/admin/fund.ts new file mode 100644 index 00000000..7720fcd0 --- /dev/null +++ b/apps/backend/src/routes/admin/fund.ts @@ -0,0 +1,328 @@ +import type { Request } from 'express' +import { Router } from 'express' + +import { + confirmAdminFundReconciliation, + createAdminFundAccount, + createAdminFundExpense, + createAdminFundFunding, + createAdminFundIssue, + createAdminFundReconciliation, + createAdminFundReturn, + ensureAdminFundAgentAccounts, + getAdminFundAccountDetail, + getAdminFundExpenseDetail, + getAdminFundMyAccount, + getAdminFundOverview, + getAdminFundSupervisorOptions, + listAdminFundAccounts, + listAdminFundExpenses, + listAdminFundReconciliations, + listAdminFundSetupOptions, + updateAdminFundAccount, +} from '../../services/admin/admin-fund-service.js' +import { + createJsonHandler, + requireAdminPermission, + requireAdminRoles, + requireAnyAdminPermission, +} from './session.js' +import type { AdminSession } from '../../services/admin/admin-auth-service.js' +import type { JsonObject } from '../../types/json.js' + +const router = Router() + +function sessionOf(req: Request): AdminSession { + return req.adminSession as AdminSession +} + +function accountIdOf(req: Request): number { + return Number(req.params.accountId) +} + +function expenseIdOf(req: Request): number { + return Number(req.params.expenseId) +} + +function reconciliationIdOf(req: Request): number { + return Number(req.params.reconciliationId) +} + +function bodyWithAccountId(req: Request, key: string): JsonObject { + const body = req.body && typeof req.body === 'object' ? (req.body as JsonObject) : {} + return { ...body, [key]: accountIdOf(req) } +} + +router.use('/fund', requireAdminRoles(['admin', 'operator', 'support', 'finance'])) + +router.get( + '/fund/overview', + requireAdminPermission('fund.view'), + createJsonHandler((req) => getAdminFundOverview(sessionOf(req)), { + successMessage: 'ok', + errorMessage: '读取备用金总览失败', + scope: '[admin/fund/overview]', + }), +) + +router.get( + '/fund/my', + requireAdminPermission('fund.view'), + createJsonHandler((req) => getAdminFundMyAccount(sessionOf(req)), { + successMessage: 'ok', + errorMessage: '读取我的备用金失败', + scope: '[admin/fund/my]', + }), +) + +router.get( + '/fund/supervisors', + requireAdminPermission('fund.view'), + createJsonHandler((req) => getAdminFundSupervisorOptions(sessionOf(req)), { + successMessage: 'ok', + errorMessage: '读取主管资金池失败', + scope: '[admin/fund/supervisors]', + }), +) + +router.get( + '/fund/options', + requireAdminRoles(['admin']), + createJsonHandler((req) => listAdminFundSetupOptions(sessionOf(req)), { + successMessage: 'ok', + errorMessage: '读取备用金初始化选项失败', + scope: '[admin/fund/options]', + }), +) + +router.get( + '/fund/accounts', + requireAdminPermission('fund.view'), + createJsonHandler((req) => listAdminFundAccounts(req.query as JsonObject, sessionOf(req)), { + successMessage: 'ok', + errorMessage: '读取备用金账户失败', + scope: '[admin/fund/accounts]', + }), +) + +router.get( + '/fund/accounts/:accountId', + requireAdminPermission('fund.view'), + createJsonHandler( + (req) => getAdminFundAccountDetail(accountIdOf(req), req.query as JsonObject, sessionOf(req)), + { + successMessage: 'ok', + errorMessage: '读取备用金账户详情失败', + scope: '[admin/fund/accounts/:accountId]', + }, + ), +) + +router.post( + '/fund/accounts', + requireAdminRoles(['admin']), + createJsonHandler((req) => createAdminFundAccount(req.body as JsonObject, sessionOf(req)), { + successMessage: '备用金账户已创建', + errorMessage: '创建备用金账户失败', + scope: '[admin/fund/accounts]', + audit: (req, data) => ({ + action: 'fund_account_created', + targetType: 'fund_account', + targetId: String((data as { account?: { id?: number } })?.account?.id || ''), + data: asRecord(data), + }), + }), +) + +router.post( + '/fund/accounts/ensure-agents', + requireAdminRoles(['admin']), + createJsonHandler((req) => ensureAdminFundAgentAccounts(req.body as JsonObject, sessionOf(req)), { + successMessage: '客服备用金账户已初始化', + errorMessage: '批量初始化客服备用金账户失败', + scope: '[admin/fund/accounts/ensure-agents]', + audit: (_req, data) => ({ + action: 'fund_agent_accounts_ensured', + targetType: 'fund_account', + targetId: 'batch', + data: asRecord(data), + }), + }), +) + +router.patch( + '/fund/accounts/:accountId', + requireAdminRoles(['admin']), + createJsonHandler( + (req) => updateAdminFundAccount(accountIdOf(req), req.body as JsonObject, sessionOf(req)), + { + successMessage: '备用金账户已更新', + errorMessage: '更新备用金账户失败', + scope: '[admin/fund/accounts/:accountId]', + audit: (req, data) => ({ + action: 'fund_account_updated', + targetType: 'fund_account', + targetId: String(req.params.accountId || ''), + data: asRecord(data), + }), + }, + ), +) + +router.post( + '/fund/accounts/:accountId/funding', + requireAdminRoles(['admin']), + createJsonHandler( + (req) => createAdminFundFunding(bodyWithAccountId(req, 'supervisorAccountId'), sessionOf(req)), + { + successMessage: '主管资金池已拨付', + errorMessage: '拨付主管资金池失败', + scope: '[admin/fund/accounts/:accountId/funding]', + audit: (req, data) => ({ + action: 'fund_funding', + targetType: 'fund_account', + targetId: String(req.params.accountId || ''), + data: asRecord(data), + }), + }, + ), +) + +router.post( + '/fund/accounts/:accountId/issue', + requireAdminPermission('fund.issue'), + createJsonHandler( + (req) => createAdminFundIssue(bodyWithAccountId(req, 'agentAccountId'), sessionOf(req)), + { + successMessage: '备用金已发放', + errorMessage: '发放备用金失败', + scope: '[admin/fund/accounts/:accountId/issue]', + audit: (req, data) => ({ + action: 'fund_issue', + targetType: 'fund_account', + targetId: String(req.params.accountId || ''), + data: asRecord(data), + }), + }, + ), +) + +router.post( + '/fund/accounts/:accountId/return', + requireAnyAdminPermission(['fund.issue', 'fund.expense']), + createJsonHandler( + (req) => createAdminFundReturn(bodyWithAccountId(req, 'agentAccountId'), sessionOf(req)), + { + successMessage: '备用金已缴回', + errorMessage: '缴回备用金失败', + scope: '[admin/fund/accounts/:accountId/return]', + audit: (req, data) => ({ + action: 'fund_return', + targetType: 'fund_account', + targetId: String(req.params.accountId || ''), + data: asRecord(data), + }), + }, + ), +) + +router.post( + '/fund/accounts/:accountId/expenses', + requireAdminPermission('fund.expense'), + createJsonHandler( + (req) => createAdminFundExpense(bodyWithAccountId(req, 'agentAccountId'), sessionOf(req)), + { + successMessage: '备用金支出已登记', + errorMessage: '登记备用金支出失败', + scope: '[admin/fund/accounts/:accountId/expenses]', + audit: (req, data) => ({ + action: 'fund_expense_created', + targetType: 'fund_expense', + targetId: String((data as { expense?: { id?: number } })?.expense?.id || ''), + data: asRecord(data), + }), + }, + ), +) + +router.get( + '/fund/expenses', + requireAdminPermission('fund.view'), + createJsonHandler((req) => listAdminFundExpenses(req.query as JsonObject, sessionOf(req)), { + successMessage: 'ok', + errorMessage: '读取备用金支出失败', + scope: '[admin/fund/expenses]', + }), +) + +router.get( + '/fund/expenses/:expenseId', + requireAdminPermission('fund.view'), + createJsonHandler((req) => getAdminFundExpenseDetail(expenseIdOf(req), sessionOf(req)), { + successMessage: 'ok', + errorMessage: '读取备用金支出详情失败', + scope: '[admin/fund/expenses/:expenseId]', + }), +) + +router.get( + '/fund/reconciliations', + requireAdminPermission('fund.view'), + createJsonHandler( + (req) => listAdminFundReconciliations(req.query as JsonObject, sessionOf(req)), + { + successMessage: 'ok', + errorMessage: '读取备用金对账失败', + scope: '[admin/fund/reconciliations]', + }, + ), +) + +router.post( + '/fund/reconciliations', + requireAdminPermission('fund.reconcile'), + createJsonHandler( + (req) => createAdminFundReconciliation(req.body as JsonObject, sessionOf(req)), + { + successMessage: '对账已发起', + errorMessage: '发起备用金对账失败', + scope: '[admin/fund/reconciliations]', + audit: (_req, data) => ({ + action: 'fund_reconciliation_created', + targetType: 'fund_reconciliation', + targetId: String((data as { reconciliation?: { id?: number } })?.reconciliation?.id || ''), + data: asRecord(data), + }), + }, + ), +) + +router.post( + '/fund/reconciliations/:reconciliationId/confirm', + requireAdminPermission('fund.reconcile'), + createJsonHandler( + (req) => + confirmAdminFundReconciliation( + reconciliationIdOf(req), + req.body as JsonObject, + sessionOf(req), + ), + { + successMessage: '对账已确认', + errorMessage: '确认备用金对账失败', + scope: '[admin/fund/reconciliations/:reconciliationId/confirm]', + audit: (req, data) => ({ + action: 'fund_reconciliation_confirmed', + targetType: 'fund_reconciliation', + targetId: String(req.params.reconciliationId || ''), + data: asRecord(data), + }), + }, + ), +) + +function asRecord(data: unknown): Record { + return data && typeof data === 'object' ? (data as Record) : { a: data } +} + +export default router diff --git a/apps/backend/src/routes/admin/users.ts b/apps/backend/src/routes/admin/users.ts index e0f5cee8..d5c1b7be 100644 --- a/apps/backend/src/routes/admin/users.ts +++ b/apps/backend/src/routes/admin/users.ts @@ -4,6 +4,7 @@ import { createManagedAdminUser, getAdminUserList, resetManagedAdminUserPassword, + updateManagedAdminUserDisplayName, updateManagedAdminUserRole, updateManagedAdminUserStatus, } from '../../services/admin/admin-auth-service.js' @@ -13,6 +14,7 @@ type AdminUserMutationResult = { user: { userId: number username: string + displayName: string role: string status?: string } @@ -82,6 +84,30 @@ router.post( ), ) +router.post( + '/users/:userId/display-name', + createJsonHandler( + (req) => updateManagedAdminUserDisplayName(String(req.params.userId || ''), req.body), + { + successMessage: '用户姓名已更新', + errorMessage: '更新用户姓名失败', + scope: '[admin/users/:userId/display-name]', + audit: (_req, data) => { + const result = data as AdminUserMutationResult + return { + action: 'admin_user_display_name_updated', + targetType: 'admin_user', + targetId: String(result.user.userId), + data: { + username: result.user.username, + displayName: result.user.displayName, + }, + } + }, + }, + ), +) + router.post( '/users/:userId/status', createJsonHandler( diff --git a/apps/backend/src/routes/admin/worker-platform/acceptance.ts b/apps/backend/src/routes/admin/worker-platform/acceptance.ts index d6c81caf..3a91e57a 100644 --- a/apps/backend/src/routes/admin/worker-platform/acceptance.ts +++ b/apps/backend/src/routes/admin/worker-platform/acceptance.ts @@ -21,7 +21,7 @@ router.post( markAdminWorkOrderProblem( String(req.params.workOrderId || ''), req.body || {}, - req.adminSession?.username || '', + req.adminSession?.displayName || req.adminSession?.username || '', ), { successMessage: '订单已标记为问题单', @@ -45,7 +45,7 @@ router.post( resolveAdminProblemWorkOrder( String(req.params.workOrderId || ''), req.body || {}, - req.adminSession?.username || '', + req.adminSession?.displayName || req.adminSession?.username || '', ), { successMessage: '问题单已处置', @@ -66,7 +66,10 @@ router.post( requireAdminPermission('worker_order.accept'), createJsonHandler( (req) => - acceptAdminWorkOrder(String(req.params.workOrderId || ''), req.adminSession?.username || ''), + acceptAdminWorkOrder( + String(req.params.workOrderId || ''), + req.adminSession?.displayName || req.adminSession?.username || '', + ), { successMessage: '订单已验收通过', errorMessage: '验收接单订单失败', @@ -89,7 +92,7 @@ router.post( acceptAdminWorkOrderShare( String(req.params.workOrderId || ''), String(req.params.shareId || ''), - req.adminSession?.username || '', + req.adminSession?.displayName || req.adminSession?.username || '', ), { successMessage: '拼单份额已验收通过', @@ -113,7 +116,7 @@ router.post( cancelAdminWorkOrderSharing( String(req.params.workOrderId || ''), req.body || {}, - req.adminSession?.username || '', + req.adminSession?.displayName || req.adminSession?.username || '', req.adminSession?.permissions || [], ), { @@ -142,7 +145,7 @@ router.post( String(req.params.workOrderId || ''), String(req.params.shareId || ''), req.body || {}, - req.adminSession?.username || '', + req.adminSession?.displayName || req.adminSession?.username || '', ), { successMessage: '拼单参与者已撤单,份数已退回大厅', @@ -166,7 +169,7 @@ router.post( deductAdminWorkOrderPendingDeposit( String(req.params.workOrderId || ''), req.body || {}, - req.adminSession?.username || '', + req.adminSession?.displayName || req.adminSession?.username || '', ), { successMessage: '待解冻押金已扣减', diff --git a/apps/backend/src/routes/admin/worker-platform/after-sales.ts b/apps/backend/src/routes/admin/worker-platform/after-sales.ts index 811bead8..272f496e 100644 --- a/apps/backend/src/routes/admin/worker-platform/after-sales.ts +++ b/apps/backend/src/routes/admin/worker-platform/after-sales.ts @@ -39,7 +39,7 @@ router.post( createAdminAfterSalesCase( String(req.params.workOrderId || ''), req.body || {}, - req.adminSession?.username || '', + req.adminSession?.displayName || req.adminSession?.username || '', ), { successMessage: '售后问题单已创建', @@ -63,7 +63,7 @@ router.post( resolveAdminAfterSalesCase( String(req.params.caseId || ''), req.body || {}, - req.adminSession?.username || '', + req.adminSession?.displayName || req.adminSession?.username || '', ), { successMessage: '售后问题单已处置', @@ -87,7 +87,7 @@ router.post( repayAdminAfterSalesCaseDebt( String(req.params.caseId || ''), req.body || {}, - req.adminSession?.username || '', + req.adminSession?.displayName || req.adminSession?.username || '', ), { successMessage: '售后欠款已偿还', diff --git a/apps/backend/src/routes/admin/worker-platform/config.ts b/apps/backend/src/routes/admin/worker-platform/config.ts index 3e9aed02..5b6fb9e5 100644 --- a/apps/backend/src/routes/admin/worker-platform/config.ts +++ b/apps/backend/src/routes/admin/worker-platform/config.ts @@ -167,7 +167,7 @@ router.post( (req) => syncAdminWorkProductRulePrice( String(req.params.ruleId || ''), - req.adminSession?.username || '', + req.adminSession?.displayName || req.adminSession?.username || '', ), { successMessage: '模板价格已同步', diff --git a/apps/backend/src/routes/admin/worker-platform/consignment.ts b/apps/backend/src/routes/admin/worker-platform/consignment.ts index 7d91bcd8..2b24b36c 100644 --- a/apps/backend/src/routes/admin/worker-platform/consignment.ts +++ b/apps/backend/src/routes/admin/worker-platform/consignment.ts @@ -68,7 +68,11 @@ router.post( '/worker-platform/consignment/inventories', requireAdminPermission('consignment.manage'), createJsonHandler( - (req) => createAdminConsignmentInventory(req.body, req.adminSession?.username || 'admin'), + (req) => + createAdminConsignmentInventory( + req.body, + req.adminSession?.displayName || req.adminSession?.username || 'admin', + ), { successMessage: '寄卖物品已入库', errorMessage: '创建寄卖物品失败', @@ -85,7 +89,7 @@ router.put( updateAdminConsignmentInventory( String(req.params.id || ''), req.body, - req.adminSession?.username || 'admin', + req.adminSession?.displayName || req.adminSession?.username || 'admin', ), { successMessage: '寄卖物品已更新', @@ -127,7 +131,7 @@ router.post( returnAdminConsignmentInventory( String(req.params.id || ''), req.body, - req.adminSession?.username || 'admin', + req.adminSession?.displayName || req.adminSession?.username || 'admin', ), { successMessage: '物品已退回复库', @@ -167,7 +171,7 @@ router.post( approveAdminConsignmentSubmission( String(req.params.id || ''), req.body, - req.adminSession?.username || 'admin', + req.adminSession?.displayName || req.adminSession?.username || 'admin', ), { successMessage: '审核通过,已正式入库', @@ -185,7 +189,7 @@ router.post( rejectAdminConsignmentSubmission( String(req.params.id || ''), req.body, - req.adminSession?.username || 'admin', + req.adminSession?.displayName || req.adminSession?.username || 'admin', ), { successMessage: '已驳回该提报', @@ -247,7 +251,7 @@ router.post( fulfillWorkOrderWithConsignment({ workOrderId: String(req.body?.workOrderId || ''), inventoryId: String(req.body?.inventoryId || ''), - actorName: req.adminSession?.username || 'admin', + actorName: req.adminSession?.displayName || req.adminSession?.username || 'admin', allowPartial: req.body?.allowPartial === true, allowSpecMismatch: req.body?.allowSpecMismatch === true, matchNote: String(req.body?.matchNote || ''), @@ -269,7 +273,7 @@ router.post( workOrderId: String(req.body?.workOrderId || ''), inventoryId: String(req.body?.inventoryId || ''), workerId: req.body?.workerId ? Number(req.body.workerId) : null, - actorName: req.adminSession?.username || 'admin', + actorName: req.adminSession?.displayName || req.adminSession?.username || 'admin', allowPartial: req.body?.allowPartial === true, allowSpecMismatch: req.body?.allowSpecMismatch === true, matchNote: String(req.body?.matchNote || ''), @@ -299,7 +303,7 @@ router.post( // 押金处置由运营显式选择,非法值一律归一成"按规则自动" workerDeposit: normalizeWorkerDepositHandling(req.body?.workerDeposit), consignmentDeposit: normalizeConsignmentDepositHandling(req.body?.consignmentDeposit), - actorName: req.adminSession?.username || 'admin', + actorName: req.adminSession?.displayName || req.adminSession?.username || 'admin', }), { successMessage: '寄售履约已撤销', diff --git a/apps/backend/src/routes/admin/worker-platform/finance.ts b/apps/backend/src/routes/admin/worker-platform/finance.ts index 4e61df37..c3784cb1 100644 --- a/apps/backend/src/routes/admin/worker-platform/finance.ts +++ b/apps/backend/src/routes/admin/worker-platform/finance.ts @@ -26,7 +26,7 @@ router.post( reviewAdminWorkerFinanceRequest( String(req.params.requestId || ''), req.body || {}, - req.adminSession?.username || '', + req.adminSession?.displayName || req.adminSession?.username || '', ), { successMessage: '资金申请已处理', diff --git a/apps/backend/src/routes/admin/worker-platform/orders.ts b/apps/backend/src/routes/admin/worker-platform/orders.ts index f11f2b9f..d8e99250 100644 --- a/apps/backend/src/routes/admin/worker-platform/orders.ts +++ b/apps/backend/src/routes/admin/worker-platform/orders.ts @@ -153,7 +153,7 @@ router.post( submitAdminWorkOrderMaterial( String(req.params.workOrderId || ''), req.body || {}, - req.adminSession?.username || '', + req.adminSession?.displayName || req.adminSession?.username || '', ), { successMessage: '订单资料已保存', @@ -176,7 +176,7 @@ router.put( (req) => updateAdminWorkOrder(String(req.params.workOrderId || ''), req.body || {}, { canEditRequirementFields: req.adminSession?.role !== 'support', - actorName: req.adminSession?.username || '', + actorName: req.adminSession?.displayName || req.adminSession?.username || '', }), { successMessage: '工单信息已保存', @@ -200,7 +200,7 @@ router.put( updateAdminWorkOrderNote( String(req.params.workOrderId || ''), req.body?.note, - req.adminSession?.username || '', + req.adminSession?.displayName || req.adminSession?.username || '', ), { successMessage: '工单备注已保存', @@ -224,7 +224,7 @@ router.post( assignAdminWorkOrderToWorker( String(req.params.workOrderId || ''), req.body || {}, - req.adminSession?.username || '', + req.adminSession?.displayName || req.adminSession?.username || '', ), { successMessage: '已指派打手', @@ -247,7 +247,7 @@ router.post( (req) => unassignAdminWorkOrder( String(req.params.workOrderId || ''), - req.adminSession?.username || '', + req.adminSession?.displayName || req.adminSession?.username || '', ), { successMessage: '已取消指派', @@ -271,7 +271,7 @@ router.post( cancelAdminWorkOrder( String(req.params.workOrderId || ''), req.body || {}, - req.adminSession?.username || '', + req.adminSession?.displayName || req.adminSession?.username || '', req.adminSession?.permissions || [], ), { @@ -314,7 +314,7 @@ router.post( (req) => reopenAdminCancelledWorkOrder( String(req.params.workOrderId || ''), - req.adminSession?.username || '', + req.adminSession?.displayName || req.adminSession?.username || '', ), { successMessage: '订单已恢复为未分配状态', @@ -335,7 +335,10 @@ router.post( requireAdminPermission('worker_order.publish'), createJsonHandler( (req) => - publishAdminWorkOrder(String(req.params.workOrderId || ''), req.adminSession?.username || ''), + publishAdminWorkOrder( + String(req.params.workOrderId || ''), + req.adminSession?.displayName || req.adminSession?.username || '', + ), { successMessage: '订单已进入大厅队列', errorMessage: '发布接单订单失败', @@ -357,7 +360,7 @@ router.post( (req) => unpublishAdminWorkOrder( String(req.params.workOrderId || ''), - req.adminSession?.username || '', + req.adminSession?.displayName || req.adminSession?.username || '', ), { successMessage: '订单已撤回到未分配', @@ -381,7 +384,7 @@ router.post( pinAdminWorkOrder( String(req.params.workOrderId || ''), req.body || {}, - req.adminSession?.username || '', + req.adminSession?.displayName || req.adminSession?.username || '', ), { successMessage: '置顶状态已更新', @@ -404,7 +407,7 @@ router.post( (req) => confirmAdminWorkOrderFriendAdded( String(req.params.workOrderId || ''), - req.adminSession?.username || '', + req.adminSession?.displayName || req.adminSession?.username || '', ), { successMessage: '已确认加好友,赠送冷却开始', @@ -428,7 +431,7 @@ router.post( resetAdminWorkOrderGiftMaterial( String(req.params.workOrderId || ''), req.body || {}, - req.adminSession?.username || '', + req.adminSession?.displayName || req.adminSession?.username || '', ), { successMessage: '已重置打手资料,等待打手重新提交', @@ -452,7 +455,7 @@ router.post( completeAdminWorkOrderGiftCooldown( String(req.params.workOrderId || ''), req.body || {}, - req.adminSession?.username || '', + req.adminSession?.displayName || req.adminSession?.username || '', ), { successMessage: '已手动结束赠送冷却,打手现在可以提交验收', @@ -496,7 +499,7 @@ router.post( updateAdminWorkOrderSharing( String(req.params.workOrderId || ''), req.body || {}, - req.adminSession?.username || '', + req.adminSession?.displayName || req.adminSession?.username || '', ), { successMessage: '拼单配置已保存', @@ -516,7 +519,11 @@ router.post( '/worker-platform/orders/accept-batch', requireAdminPermission('worker_order.accept'), createJsonHandler( - (req) => acceptAdminWorkOrders(req.body || {}, req.adminSession?.username || ''), + (req) => + acceptAdminWorkOrders( + req.body || {}, + req.adminSession?.displayName || req.adminSession?.username || '', + ), { successMessage: '选中订单已验收通过', errorMessage: '批量验收接单订单失败', diff --git a/apps/backend/src/routes/admin/worker-platform/review.ts b/apps/backend/src/routes/admin/worker-platform/review.ts index 052e6c6b..06b15ab9 100644 --- a/apps/backend/src/routes/admin/worker-platform/review.ts +++ b/apps/backend/src/routes/admin/worker-platform/review.ts @@ -38,7 +38,7 @@ router.post( resolveAdminWorkerOrderFeedback( String(req.params.feedbackId || ''), req.body || {}, - req.adminSession?.username || '', + req.adminSession?.displayName || req.adminSession?.username || '', ), { successMessage: '问题反馈已处理', @@ -56,7 +56,7 @@ router.post( reviewAdminWorkerCancelRequest( String(req.params.requestId || ''), req.body || {}, - req.adminSession?.username || '', + req.adminSession?.displayName || req.adminSession?.username || '', ), { successMessage: '打手退单申请已审核', diff --git a/apps/backend/src/routes/admin/worker-platform/risk.ts b/apps/backend/src/routes/admin/worker-platform/risk.ts index 13be6a21..6413c389 100644 --- a/apps/backend/src/routes/admin/worker-platform/risk.ts +++ b/apps/backend/src/routes/admin/worker-platform/risk.ts @@ -18,6 +18,7 @@ function actorOf(req: Request) { return { userId: req.adminSession?.userId ?? null, username: req.adminSession?.username || '', + displayName: req.adminSession?.displayName || req.adminSession?.username || '', } } diff --git a/apps/backend/src/routes/admin/worker-platform/workers.ts b/apps/backend/src/routes/admin/worker-platform/workers.ts index baa09992..46d789e1 100644 --- a/apps/backend/src/routes/admin/worker-platform/workers.ts +++ b/apps/backend/src/routes/admin/worker-platform/workers.ts @@ -73,7 +73,7 @@ router.post( reviewAdminWorkerUser( String(req.params.workerId || ''), req.body || {}, - req.adminSession?.username || '', + req.adminSession?.displayName || req.adminSession?.username || '', ), { successMessage: '打手账号状态已更新', @@ -97,7 +97,7 @@ router.post( updateAdminWorkerLevelLock( String(req.params.workerId || ''), req.body || {}, - req.adminSession?.username || '', + req.adminSession?.displayName || req.adminSession?.username || '', ), { successMessage: '打手等级锁定状态已更新', diff --git a/apps/backend/src/services/admin/admin-audit-service.ts b/apps/backend/src/services/admin/admin-audit-service.ts index 6c3ee1d0..530eefec 100644 --- a/apps/backend/src/services/admin/admin-audit-service.ts +++ b/apps/backend/src/services/admin/admin-audit-service.ts @@ -46,6 +46,7 @@ export async function getAdminAuditLogs(query: JsonObject = {}) { logId: Number(item.id), actorUserId: Number(item.actor_user_id || 0), actorUsername: item.actor_username, + actorDisplayName: String(item.actor_display_name || item.actor_username || ''), actorRole: item.actor_role, action: item.action, targetType: item.target_type, diff --git a/apps/backend/src/services/admin/admin-auth-service.ts b/apps/backend/src/services/admin/admin-auth-service.ts index ab8eaad9..b7001207 100644 --- a/apps/backend/src/services/admin/admin-auth-service.ts +++ b/apps/backend/src/services/admin/admin-auth-service.ts @@ -28,6 +28,7 @@ export type AdminSession = { sessionId: string userId: number username: string + displayName: string role: AdminRole expiresAt: string sessionVersion: number @@ -48,6 +49,7 @@ export async function ensureAdminUsersBootstrapped(): Promise { const username = String(configuredUser?.username || '') .trim() .toLowerCase() + const displayName = normalizeDisplayName(configuredUser?.displayName, username) || username const password = String(configuredUser?.password || '').trim() const role = normalizeAdminRole(configuredUser?.role) @@ -62,6 +64,7 @@ export async function ensureAdminUsersBootstrapped(): Promise { const now = nowIso() await createAdminUser({ username, + displayName, passwordHash: hashAdminPassword(password), role, status: 'active', @@ -229,6 +232,7 @@ export async function verifyAdminSessionToken(token: unknown): Promise { const username = normalizeUsername(payload.username) + const displayName = normalizeDisplayName(payload.displayName, username) || username const password = normalizePassword(payload.password) if (!username) { @@ -319,6 +325,7 @@ export async function createManagedAdminUser(payload: JsonObject = {}): Promise< const now = nowIso() const created = await createAdminUser({ username, + displayName, passwordHash: hashAdminPassword(password), role: normalizeAdminRole(payload.role), status: normalizeAdminUserStatus(payload.status || 'active'), @@ -401,6 +408,28 @@ export async function updateManagedAdminUserStatus( } } +export async function updateManagedAdminUserDisplayName( + userId: number | string, + payload: JsonObject = {}, +): Promise { + const user = await getRequiredAdminUser(userId) + const displayName = normalizeDisplayName(payload.displayName, user.username) || user.username + const updated = await updateAdminUser(user.id, { + display_name: displayName, + updated_at: nowIso(), + }) + if (!updated) { + throw createHttpError('后台用户更新失败', { + statusCode: 500, + errorCode: 'admin_user_update_failed', + }) + } + + return { + user: mapAdminUser(updated), + } +} + export async function resetManagedAdminUserPassword( userId: number | string, payload: JsonObject = {}, @@ -472,6 +501,7 @@ function createAdminSession(user: AdminUserRow): JsonObject { user: { userId: Number(user.id), username: String(user.username || ''), + displayName: String(user.display_name || user.username || ''), role: normalizeAdminRole(user.role), }, } @@ -559,6 +589,27 @@ function normalizeUsername(username: unknown): string { .toLowerCase() } +function normalizeDisplayName(displayName: unknown, fallback = ''): string { + const normalized = String(displayName ?? '') + .trim() + .replace(/\s+/g, ' ') + if (!normalized) { + return fallback + } + const characters = Array.from(normalized) + const hasControlCharacter = characters.some((character) => { + const codePoint = character.codePointAt(0) || 0 + return codePoint <= 0x1f || codePoint === 0x7f + }) + if (characters.length > 32 || hasControlCharacter) { + throw createHttpError('姓名格式无效,最多 32 个字符且不能包含控制字符', { + statusCode: 400, + errorCode: 'admin_user_display_name_invalid', + }) + } + return normalized +} + function normalizePassword(password: unknown): string { return String(password || '').trim() } @@ -634,6 +685,7 @@ function mapAdminUser(user: AdminUserRow): JsonObject { return { userId: Number(user.id), username: String(user.username || ''), + displayName: String(user.display_name || user.username || ''), role: normalizeAdminRole(user.role), status: normalizeAdminUserStatus(user.status), createdAt: user.created_at, diff --git a/apps/backend/src/services/admin/admin-fund-service.ts b/apps/backend/src/services/admin/admin-fund-service.ts new file mode 100644 index 00000000..5ca31b4b --- /dev/null +++ b/apps/backend/src/services/admin/admin-fund-service.ts @@ -0,0 +1,2088 @@ +import type { PoolClient } from 'pg' + +import { query, withTransaction } from '../../db/client.js' +import { createHttpError } from '../../utils/http.js' +import { nowIso } from '../../utils/time.js' +import { randomId } from '../../utils/random.js' +import type { JsonObject } from '../../types/json.js' +import type { AdminSession } from './admin-auth-service.js' +import { normalizeDateQuery, normalizePage, normalizePageSize } from './admin-query-utils.js' + +const MONTH_START_SQL = + "(date_trunc('month', now() AT TIME ZONE 'Asia/Shanghai') AT TIME ZONE 'Asia/Shanghai')" + +const ACCOUNT_TYPES = new Set(['supervisor', 'agent']) +const ACCOUNT_STATUSES = new Set(['active', 'frozen', 'closed']) +const EXPENSE_CATEGORIES = new Set([ + 'platform_recharge', + 'purchase', + 'compensation', + 'freight', + 'other', +]) +const PAYEE_TYPES = new Set(['platform', 'supplier', 'customer', 'other']) +const RECONCILIATION_STATUSES = new Set(['pending', 'confirmed', 'discrepancy']) + +type AccountType = 'supervisor' | 'agent' +type EntryType = 'opening' | 'funding' | 'issue' | 'expense' | 'return' | 'adjustment' +type Visibility = + | { kind: 'all' } + | { kind: 'none' } + | { kind: 'support'; userId: number } + | { kind: 'supervisor'; userId: number; supervisorAccountId: number } + +type FundAccountRow = { + id: string | number + account_no: string + account_type: AccountType + owner_admin_user_id: string | number + owner_name: string + responsible_supervisor_id: string | number | null + responsible_supervisor_name?: string | null + balance_fen: string | number + status: string + created_at: string + updated_at: string + month_in_fen?: string | number | null + month_out_fen?: string | number | null + last_entry_at?: string | null + last_reconciled_at?: string | null +} + +type FundEntryRow = { + id: string | number + entry_no: string + account_id: string | number + entry_type: EntryType + amount_fen: string | number + balance_after_fen: string | number + transfer_no: string + counterparty_account_id: string | number | null + counterparty_name?: string | null + reconciliation_id?: string | number | null + occurred_at: string + created_by_admin_user_id: string | number | null + created_by_username?: string | null + evidence_json?: unknown + note: string + created_at: string +} + +type ManualExpenseRow = { + id: string | number + expense_no: string + account_id: string | number + amount_fen: string | number + category: string + payee_type: string + payee_name: string + payee_account: string + platform_name: string + platform_account: string + order_id: string | number | null + order_item_id: string | number | null + paid_at: string + evidence_json?: unknown + note: string + fund_entry_id: string | number + created_by_admin_user_id: string | number | null + created_by_username?: string | null + created_at: string + updated_at: string + agent_name?: string | null + account_no?: string | null +} + +type ReconciliationRow = { + id: string | number + reconciliation_no: string + account_id: string | number + period_start: string + period_end: string + system_balance_fen: string | number + actual_balance_fen: string | number + difference_fen: string | number + status: string + reason: string + evidence_json?: unknown + confirmed_by_admin_user_id: string | number | null + confirmed_by_username?: string | null + confirmed_at: string | null + created_by_admin_user_id: string | number | null + created_by_username?: string | null + created_at: string + updated_at: string + agent_name?: string | null + account_no?: string | null +} + +function toNumber(value: unknown): number { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : 0 +} + +function toId(value: unknown): number { + const parsed = Number(value) + return Number.isInteger(parsed) && parsed > 0 ? parsed : 0 +} + +function text(value: unknown, fallback = ''): string { + return String(value ?? fallback).trim() +} + +function normalizeFen(value: unknown, minimum = 1): number { + const parsed = Number(value) + if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < minimum) { + throw createHttpError('金额必须是大于 0 的整数分', { + statusCode: 400, + errorCode: 'fund_amount_invalid', + }) + } + return parsed +} + +function normalizeNonNegativeFen(value: unknown): number { + const parsed = Number(value) + if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 0) { + throw createHttpError('金额必须是非负整数分', { + statusCode: 400, + errorCode: 'fund_amount_invalid', + }) + } + return parsed +} + +function normalizeOccurredAt(value: unknown): string { + const normalized = text(value) + if (!normalized) return nowIso() + const parsed = Date.parse(normalized) + if (!Number.isFinite(parsed)) { + throw createHttpError('时间格式无效', { + statusCode: 400, + errorCode: 'fund_time_invalid', + }) + } + if (parsed > Date.now() + 5 * 60 * 1000) { + throw createHttpError('时间不能晚于当前时间', { + statusCode: 400, + errorCode: 'fund_time_future', + }) + } + return new Date(parsed).toISOString() +} + +function normalizeEvidence(value: unknown): JsonObject[] { + if (!Array.isArray(value)) return [] + return value + .filter((item): item is Record => Boolean(item) && typeof item === 'object') + .map((item) => ({ + objectKey: text(item.objectKey), + url: text(item.url), + thumbnailUrl: text(item.thumbnailUrl), + mediumUrl: text(item.mediumUrl), + filename: text(item.filename), + contentType: text(item.contentType), + size: toNumber(item.size), + })) + .filter((item) => item.objectKey || item.url) + .slice(0, 6) +} + +function parseEvidence(value: unknown): JsonObject[] { + if (Array.isArray(value)) return value as JsonObject[] + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value) + return Array.isArray(parsed) ? (parsed as JsonObject[]) : [] + } catch { + return [] + } + } + return [] +} + +function assertEvidenceOrNote(evidence: JsonObject[], note: string) { + if (evidence.length === 0 && !note) { + throw createHttpError('请上传凭证或填写说明', { + statusCode: 400, + errorCode: 'fund_evidence_required', + }) + } +} + +function assertExpenseEvidence(evidence: JsonObject[]) { + if (evidence.length === 0) { + throw createHttpError('支出必须上传付款凭证', { + statusCode: 400, + errorCode: 'fund_expense_evidence_required', + }) + } +} + +function normalizeAccountType(value: unknown): AccountType { + const normalized = text(value) as AccountType + if (!ACCOUNT_TYPES.has(normalized)) { + throw createHttpError('账户类型无效', { + statusCode: 400, + errorCode: 'fund_account_type_invalid', + }) + } + return normalized +} + +function normalizeAccountStatus(value: unknown): string { + const normalized = text(value) + if (!ACCOUNT_STATUSES.has(normalized)) { + throw createHttpError('账户状态无效', { + statusCode: 400, + errorCode: 'fund_account_status_invalid', + }) + } + return normalized +} + +function normalizeExpenseCategory(value: unknown): string { + const normalized = text(value) + if (!EXPENSE_CATEGORIES.has(normalized)) { + throw createHttpError('支出分类无效', { + statusCode: 400, + errorCode: 'fund_expense_category_invalid', + }) + } + return normalized +} + +function normalizePayeeType(value: unknown): string { + const normalized = text(value) + if (!PAYEE_TYPES.has(normalized)) { + throw createHttpError('收款方类型无效', { + statusCode: 400, + errorCode: 'fund_payee_type_invalid', + }) + } + return normalized +} + +function normalizeReconciliationStatus(value: unknown): string { + const normalized = text(value) + if (!RECONCILIATION_STATUSES.has(normalized)) { + throw createHttpError('对账状态无效', { + statusCode: 400, + errorCode: 'fund_reconciliation_status_invalid', + }) + } + return normalized +} + +function generateNo(prefix: string) { + return randomId(prefix, 5).toUpperCase() +} + +function requiredRow(row: T | undefined, message: string): T { + if (!row) { + throw createHttpError(message, { + statusCode: 500, + errorCode: 'fund_row_missing', + }) + } + return row +} + +export function hasFundPermission(session: AdminSession, permission: string) { + return session.role === 'admin' || session.permissions.includes(permission) +} + +function assertAdmin(session: AdminSession) { + if (session.role !== 'admin') { + throw createHttpError('只有管理员可以执行此操作', { + statusCode: 403, + errorCode: 'fund_admin_required', + }) + } +} + +async function resolveVisibility(session: AdminSession): Promise { + if (session.role === 'admin' || session.role === 'finance') { + return { kind: 'all' } + } + + if (session.role === 'support') { + return { kind: 'support', userId: session.userId } + } + + const supervisor = await getSupervisorAccountForUser(session.userId) + if (!supervisor) { + return { kind: 'none' } + } + + return { + kind: 'supervisor', + userId: session.userId, + supervisorAccountId: Number(supervisor.id), + } +} + +function buildAccountScope(alias: string, visibility: Visibility, params: unknown[]): string { + if (visibility.kind === 'all') return 'TRUE' + if (visibility.kind === 'none') return 'FALSE' + if (visibility.kind === 'support') { + params.push(visibility.userId) + return `${alias}.account_type = 'agent' AND ${alias}.owner_admin_user_id = $${params.length}` + } + params.push(visibility.supervisorAccountId) + return `(${alias}.account_type = 'agent' OR ${alias}.id = $${params.length})` +} + +async function getSupervisorAccountForUser(userId: number, client?: PoolClient) { + const sql = ` + SELECT * + FROM fund_accounts + WHERE owner_admin_user_id = $1 + AND account_type = 'supervisor' + AND status <> 'closed' + ORDER BY id + LIMIT 1 + ` + const result = client + ? await client.query(sql, [userId]) + : await query(sql, [userId]) + return result.rows[0] || null +} + +async function selectAccountRow(id: number, client?: PoolClient) { + const sql = ` + SELECT + fa.*, + rs.owner_name AS responsible_supervisor_name, + COALESCE(month_stats.month_in_fen, 0)::bigint AS month_in_fen, + COALESCE(month_stats.month_out_fen, 0)::bigint AS month_out_fen, + last_entry.last_entry_at, + last_reconciliation.last_reconciled_at + FROM fund_accounts fa + LEFT JOIN fund_accounts rs ON rs.id = fa.responsible_supervisor_id + LEFT JOIN LATERAL ( + SELECT + COALESCE(SUM(fe.amount_fen) FILTER (WHERE fe.amount_fen > 0), 0)::bigint AS month_in_fen, + COALESCE(SUM(-fe.amount_fen) FILTER (WHERE fe.amount_fen < 0), 0)::bigint AS month_out_fen + FROM fund_entries fe + WHERE fe.account_id = fa.id + AND fe.entry_type <> 'opening' + AND fe.occurred_at >= ${MONTH_START_SQL} + ) month_stats ON TRUE + LEFT JOIN LATERAL ( + SELECT MAX(fe.occurred_at) AS last_entry_at + FROM fund_entries fe + WHERE fe.account_id = fa.id + ) last_entry ON TRUE + LEFT JOIN LATERAL ( + SELECT MAX(fr.confirmed_at) AS last_reconciled_at + FROM fund_reconciliations fr + WHERE fr.account_id = fa.id AND fr.status = 'confirmed' + ) last_reconciliation ON TRUE + WHERE fa.id = $1 + LIMIT 1 + ` + const result = client + ? await client.query(sql, [id]) + : await query(sql, [id]) + return result.rows[0] || null +} + +async function assertAccountVisible( + accountId: number, + visibility: Visibility, + client?: PoolClient, +): Promise { + const account = await selectAccountRow(accountId, client) + if (!account) { + throw createHttpError('备用金账户不存在', { + statusCode: 404, + errorCode: 'fund_account_not_found', + }) + } + + if (visibility.kind === 'all') return account + if (visibility.kind === 'none') { + throw createHttpError('当前账号没有备用金账户', { + statusCode: 403, + errorCode: 'fund_account_forbidden', + }) + } + if (visibility.kind === 'support') { + if ( + account.account_type !== 'agent' || + Number(account.owner_admin_user_id) !== visibility.userId + ) { + throw createHttpError('无权查看该备用金账户', { + statusCode: 403, + errorCode: 'fund_account_forbidden', + }) + } + return account + } + + if (account.account_type === 'agent' || Number(account.id) === visibility.supervisorAccountId) { + return account + } + + throw createHttpError('无权查看该备用金账户', { + statusCode: 403, + errorCode: 'fund_account_forbidden', + }) +} + +function mapFundAccount(row: FundAccountRow) { + return { + id: Number(row.id), + accountNo: row.account_no, + accountType: row.account_type, + ownerAdminUserId: Number(row.owner_admin_user_id), + ownerName: row.owner_name, + responsibleSupervisorId: row.responsible_supervisor_id + ? Number(row.responsible_supervisor_id) + : null, + responsibleSupervisorName: row.responsible_supervisor_name || '', + balanceFen: toNumber(row.balance_fen), + status: row.status, + monthInFen: toNumber(row.month_in_fen), + monthOutFen: toNumber(row.month_out_fen), + lastEntryAt: row.last_entry_at || null, + lastReconciledAt: row.last_reconciled_at || null, + createdAt: row.created_at, + updatedAt: row.updated_at, + } +} + +function mapFundEntry(row: FundEntryRow) { + return { + id: Number(row.id), + entryNo: row.entry_no, + accountId: Number(row.account_id), + entryType: row.entry_type, + amountFen: toNumber(row.amount_fen), + balanceAfterFen: toNumber(row.balance_after_fen), + transferNo: row.transfer_no || '', + counterpartyAccountId: row.counterparty_account_id ? Number(row.counterparty_account_id) : null, + counterpartyName: row.counterparty_name || '', + reconciliationId: row.reconciliation_id ? Number(row.reconciliation_id) : null, + occurredAt: row.occurred_at, + createdByAdminUserId: row.created_by_admin_user_id + ? Number(row.created_by_admin_user_id) + : null, + createdByUsername: row.created_by_username || '', + evidence: parseEvidence(row.evidence_json), + note: row.note || '', + createdAt: row.created_at, + } +} + +function mapManualExpense(row: ManualExpenseRow) { + return { + id: Number(row.id), + expenseNo: row.expense_no, + accountId: Number(row.account_id), + accountNo: row.account_no || '', + agentName: row.agent_name || '', + amountFen: toNumber(row.amount_fen), + category: row.category, + payeeType: row.payee_type, + payeeName: row.payee_name, + payeeAccount: row.payee_account || '', + platformName: row.platform_name || '', + platformAccount: row.platform_account || '', + orderId: row.order_id ? Number(row.order_id) : null, + orderItemId: row.order_item_id ? Number(row.order_item_id) : null, + paidAt: row.paid_at, + evidence: parseEvidence(row.evidence_json), + note: row.note || '', + fundEntryId: Number(row.fund_entry_id), + createdByAdminUserId: row.created_by_admin_user_id + ? Number(row.created_by_admin_user_id) + : null, + createdByUsername: row.created_by_username || '', + createdAt: row.created_at, + updatedAt: row.updated_at, + } +} + +function mapReconciliation(row: ReconciliationRow) { + return { + id: Number(row.id), + reconciliationNo: row.reconciliation_no, + accountId: Number(row.account_id), + accountNo: row.account_no || '', + agentName: row.agent_name || '', + periodStart: row.period_start, + periodEnd: row.period_end, + systemBalanceFen: toNumber(row.system_balance_fen), + actualBalanceFen: toNumber(row.actual_balance_fen), + differenceFen: toNumber(row.difference_fen), + status: row.status, + reason: row.reason || '', + evidence: parseEvidence(row.evidence_json), + confirmedByAdminUserId: row.confirmed_by_admin_user_id + ? Number(row.confirmed_by_admin_user_id) + : null, + confirmedByUsername: row.confirmed_by_username || '', + confirmedAt: row.confirmed_at, + createdByAdminUserId: row.created_by_admin_user_id + ? Number(row.created_by_admin_user_id) + : null, + createdByUsername: row.created_by_username || '', + createdAt: row.created_at, + updatedAt: row.updated_at, + } +} + +async function listEntries(accountId: number, page: number, pageSize: number) { + const offset = (page - 1) * pageSize + const [countResult, rowsResult] = await Promise.all([ + query<{ total: number }>( + 'SELECT COUNT(*)::int AS total FROM fund_entries WHERE account_id = $1', + [accountId], + ), + query( + ` + SELECT + fe.*, + COALESCE(NULLIF(cu.display_name, ''), cu.username) AS created_by_username, + ca.owner_name AS counterparty_name + FROM fund_entries fe + LEFT JOIN admin_users cu ON cu.id = fe.created_by_admin_user_id + LEFT JOIN fund_accounts ca ON ca.id = fe.counterparty_account_id + WHERE fe.account_id = $1 + ORDER BY fe.occurred_at DESC, fe.id DESC + LIMIT $2 OFFSET $3 + `, + [accountId, pageSize, offset], + ), + ]) + + return { + items: rowsResult.rows.map(mapFundEntry), + pagination: { page, pageSize, total: toNumber(countResult.rows[0]?.total) }, + } +} + +export async function getAdminFundOverview(session: AdminSession) { + const visibility = await resolveVisibility(session) + const params: unknown[] = [] + const scope = buildAccountScope('fa', visibility, params) + + const [accountResult, entryResult, reconciliationResult, unreconciledResult] = await Promise.all([ + query<{ + supervisor_balance_fen: string + agent_balance_fen: string + active_supervisor_count: number + active_agent_count: number + }>( + ` + SELECT + COALESCE(SUM(fa.balance_fen) FILTER (WHERE fa.account_type = 'supervisor'), 0)::bigint AS supervisor_balance_fen, + COALESCE(SUM(fa.balance_fen) FILTER (WHERE fa.account_type = 'agent'), 0)::bigint AS agent_balance_fen, + COUNT(*) FILTER (WHERE fa.account_type = 'supervisor' AND fa.status = 'active')::int AS active_supervisor_count, + COUNT(*) FILTER (WHERE fa.account_type = 'agent' AND fa.status = 'active')::int AS active_agent_count + FROM fund_accounts fa + WHERE ${scope} + `, + params, + ), + query<{ + month_issued_fen: string + month_expense_fen: string + month_returned_fen: string + }>( + ` + SELECT + COALESCE(SUM(fe.amount_fen) FILTER ( + WHERE fa.account_type = 'agent' AND fe.entry_type = 'issue' AND fe.amount_fen > 0 + ), 0)::bigint AS month_issued_fen, + COALESCE(-SUM(fe.amount_fen) FILTER ( + WHERE fa.account_type = 'agent' AND fe.entry_type = 'expense' + ), 0)::bigint AS month_expense_fen, + COALESCE(-SUM(fe.amount_fen) FILTER ( + WHERE fa.account_type = 'agent' AND fe.entry_type = 'return' + ), 0)::bigint AS month_returned_fen + FROM fund_entries fe + JOIN fund_accounts fa ON fa.id = fe.account_id + WHERE ${scope} + AND fe.occurred_at >= ${MONTH_START_SQL} + `, + params, + ), + query<{ pending_count: number; discrepancy_count: number }>( + ` + SELECT + COUNT(*) FILTER (WHERE fr.status IN ('pending', 'discrepancy'))::int AS pending_count, + COUNT(*) FILTER (WHERE fr.status = 'discrepancy')::int AS discrepancy_count + FROM fund_reconciliations fr + JOIN fund_accounts fa ON fa.id = fr.account_id + WHERE ${scope} + `, + params, + ), + query<{ unreconciled_count: number }>( + ` + SELECT COUNT(*)::int AS unreconciled_count + FROM fund_accounts fa + WHERE ${scope} + AND fa.account_type = 'agent' + AND fa.status = 'active' + AND NOT EXISTS ( + SELECT 1 + FROM fund_reconciliations fr + WHERE fr.account_id = fa.id + AND fr.status = 'confirmed' + AND fr.confirmed_at >= now() - interval '7 days' + ) + `, + params, + ), + ]) + + const accounts = accountResult.rows[0] + const entries = entryResult.rows[0] + const reconciliations = reconciliationResult.rows[0] + const unreconciled = unreconciledResult.rows[0] + + return { + supervisorBalanceFen: toNumber(accounts?.supervisor_balance_fen), + agentBalanceFen: toNumber(accounts?.agent_balance_fen), + totalBalanceFen: + toNumber(accounts?.supervisor_balance_fen) + toNumber(accounts?.agent_balance_fen), + activeSupervisorCount: toNumber(accounts?.active_supervisor_count), + activeAgentCount: toNumber(accounts?.active_agent_count), + monthIssuedFen: toNumber(entries?.month_issued_fen), + monthExpenseFen: toNumber(entries?.month_expense_fen), + monthReturnedFen: toNumber(entries?.month_returned_fen), + pendingReconciliationCount: toNumber(reconciliations?.pending_count), + discrepancyCount: toNumber(reconciliations?.discrepancy_count), + unreconciledAgentCount: toNumber(unreconciled?.unreconciled_count), + } +} + +export async function listAdminFundAccounts(payload: JsonObject, session: AdminSession) { + const visibility = await resolveVisibility(session) + const page = normalizePage(payload.page) + const pageSize = normalizePageSize(payload.pageSize) + const offset = (page - 1) * pageSize + const params: unknown[] = [] + const scope = buildAccountScope('fa', visibility, params) + const clauses = [scope] + + const accountType = text(payload.accountType) + if (accountType) { + if (!ACCOUNT_TYPES.has(accountType)) { + throw createHttpError('账户类型无效', { + statusCode: 400, + errorCode: 'fund_account_type_invalid', + }) + } + params.push(accountType) + clauses.push(`fa.account_type = $${params.length}`) + } + + const status = text(payload.status) + if (status) { + params.push(normalizeAccountStatus(status)) + clauses.push(`fa.status = $${params.length}`) + } + + const keyword = text(payload.keyword) + if (keyword) { + params.push(`%${keyword}%`) + clauses.push( + `(fa.owner_name ILIKE $${params.length} OR au.username ILIKE $${params.length} OR fa.account_no ILIKE $${params.length})`, + ) + } + + const responsibleSupervisorId = toId(payload.responsibleSupervisorId) + if (responsibleSupervisorId) { + params.push(responsibleSupervisorId) + clauses.push(`fa.responsible_supervisor_id = $${params.length}`) + } + + const where = clauses.join(' AND ') + const countResult = await query<{ total: number }>( + ` + SELECT COUNT(*)::int AS total + FROM fund_accounts fa + LEFT JOIN admin_users au ON au.id = fa.owner_admin_user_id + WHERE ${where} + `, + params, + ) + + const listParams = [...params, pageSize, offset] + const result = await query( + ` + SELECT + fa.*, + rs.owner_name AS responsible_supervisor_name, + COALESCE(month_stats.month_in_fen, 0)::bigint AS month_in_fen, + COALESCE(month_stats.month_out_fen, 0)::bigint AS month_out_fen, + last_entry.last_entry_at, + last_reconciliation.last_reconciled_at + FROM fund_accounts fa + LEFT JOIN admin_users au ON au.id = fa.owner_admin_user_id + LEFT JOIN fund_accounts rs ON rs.id = fa.responsible_supervisor_id + LEFT JOIN LATERAL ( + SELECT + COALESCE(SUM(fe.amount_fen) FILTER (WHERE fe.amount_fen > 0), 0)::bigint AS month_in_fen, + COALESCE(SUM(-fe.amount_fen) FILTER (WHERE fe.amount_fen < 0), 0)::bigint AS month_out_fen + FROM fund_entries fe + WHERE fe.account_id = fa.id + AND fe.entry_type <> 'opening' + AND fe.occurred_at >= ${MONTH_START_SQL} + ) month_stats ON TRUE + LEFT JOIN LATERAL ( + SELECT MAX(fe.occurred_at) AS last_entry_at + FROM fund_entries fe + WHERE fe.account_id = fa.id + ) last_entry ON TRUE + LEFT JOIN LATERAL ( + SELECT MAX(fr.confirmed_at) AS last_reconciled_at + FROM fund_reconciliations fr + WHERE fr.account_id = fa.id AND fr.status = 'confirmed' + ) last_reconciliation ON TRUE + WHERE ${where} + ORDER BY fa.account_type ASC, fa.owner_name ASC, fa.id ASC + LIMIT $${listParams.length - 1} OFFSET $${listParams.length} + `, + listParams, + ) + + return { + items: result.rows.map(mapFundAccount), + pagination: { page, pageSize, total: toNumber(countResult.rows[0]?.total) }, + } +} + +export async function getAdminFundAccountDetail( + accountId: number, + payload: JsonObject, + session: AdminSession, +) { + const visibility = await resolveVisibility(session) + const account = await assertAccountVisible(accountId, visibility) + const page = normalizePage(payload.page) + const pageSize = normalizePageSize(payload.pageSize) + const entries = await listEntries(accountId, page, pageSize) + return { account: mapFundAccount(account), ...entries } +} + +export async function getAdminFundMyAccount(session: AdminSession) { + const result = await query( + ` + SELECT + fa.*, + rs.owner_name AS responsible_supervisor_name, + COALESCE(month_stats.month_in_fen, 0)::bigint AS month_in_fen, + COALESCE(month_stats.month_out_fen, 0)::bigint AS month_out_fen, + last_entry.last_entry_at, + last_reconciliation.last_reconciled_at + FROM fund_accounts fa + LEFT JOIN fund_accounts rs ON rs.id = fa.responsible_supervisor_id + LEFT JOIN LATERAL ( + SELECT + COALESCE(SUM(fe.amount_fen) FILTER (WHERE fe.amount_fen > 0), 0)::bigint AS month_in_fen, + COALESCE(SUM(-fe.amount_fen) FILTER (WHERE fe.amount_fen < 0), 0)::bigint AS month_out_fen + FROM fund_entries fe + WHERE fe.account_id = fa.id + AND fe.entry_type <> 'opening' + AND fe.occurred_at >= ${MONTH_START_SQL} + ) month_stats ON TRUE + LEFT JOIN LATERAL ( + SELECT MAX(fe.occurred_at) AS last_entry_at + FROM fund_entries fe + WHERE fe.account_id = fa.id + ) last_entry ON TRUE + LEFT JOIN LATERAL ( + SELECT MAX(fr.confirmed_at) AS last_reconciled_at + FROM fund_reconciliations fr + WHERE fr.account_id = fa.id AND fr.status = 'confirmed' + ) last_reconciliation ON TRUE + WHERE fa.account_type = 'agent' + AND fa.owner_admin_user_id = $1 + LIMIT 1 + `, + [session.userId], + ) + const account = result.rows[0] || null + if (!account) { + return { + account: null, + entries: [], + expenses: [], + pagination: { page: 1, pageSize: 0, total: 0 }, + } + } + + const accountId = Number(account.id) + const [entries, expenses] = await Promise.all([ + listEntries(accountId, 1, 100), + listAdminFundExpenses({ accountId, page: 1, pageSize: 100 }, session), + ]) + + return { + account: mapFundAccount(account), + entries: entries.items, + expenses: expenses.items, + pagination: entries.pagination, + } +} + +export async function listAdminFundSetupOptions(session: AdminSession) { + assertAdmin(session) + const [usersResult, accountsResult] = await Promise.all([ + query<{ + id: string | number + username: string + display_name: string + role: string + status: string + }>( + ` + SELECT id, username, display_name, role, status + FROM admin_users + WHERE status = 'active' + ORDER BY role ASC, display_name ASC, username ASC + `, + ), + query( + ` + SELECT + fa.*, + rs.owner_name AS responsible_supervisor_name, + COALESCE(month_stats.month_in_fen, 0)::bigint AS month_in_fen, + COALESCE(month_stats.month_out_fen, 0)::bigint AS month_out_fen, + last_entry.last_entry_at, + last_reconciliation.last_reconciled_at + FROM fund_accounts fa + LEFT JOIN fund_accounts rs ON rs.id = fa.responsible_supervisor_id + LEFT JOIN LATERAL ( + SELECT + COALESCE(SUM(fe.amount_fen) FILTER (WHERE fe.amount_fen > 0), 0)::bigint AS month_in_fen, + COALESCE(SUM(-fe.amount_fen) FILTER (WHERE fe.amount_fen < 0), 0)::bigint AS month_out_fen + FROM fund_entries fe + WHERE fe.account_id = fa.id + AND fe.entry_type <> 'opening' + AND fe.occurred_at >= ${MONTH_START_SQL} + ) month_stats ON TRUE + LEFT JOIN LATERAL ( + SELECT MAX(fe.occurred_at) AS last_entry_at + FROM fund_entries fe + WHERE fe.account_id = fa.id + ) last_entry ON TRUE + LEFT JOIN LATERAL ( + SELECT MAX(fr.confirmed_at) AS last_reconciled_at + FROM fund_reconciliations fr + WHERE fr.account_id = fa.id AND fr.status = 'confirmed' + ) last_reconciliation ON TRUE + ORDER BY fa.account_type ASC, fa.owner_name ASC + `, + ), + ]) + + const accounts = accountsResult.rows.map(mapFundAccount) + return { + users: usersResult.rows.map((row) => ({ + id: Number(row.id), + username: row.username, + displayName: row.display_name || row.username, + role: row.role, + status: row.status, + })), + supervisorAccounts: accounts.filter((account) => account.accountType === 'supervisor'), + agentAccounts: accounts.filter((account) => account.accountType === 'agent'), + } +} + +export async function createAdminFundAccount(payload: JsonObject, session: AdminSession) { + assertAdmin(session) + const accountType = normalizeAccountType(payload.accountType) + const ownerAdminUserId = toId(payload.ownerAdminUserId) + if (!ownerAdminUserId) { + throw createHttpError('请选择账户所属人员', { + statusCode: 400, + errorCode: 'fund_owner_required', + }) + } + + const openingBalanceFen = normalizeNonNegativeFen(payload.openingBalanceFen ?? 0) + const responsibleSupervisorId = toId(payload.responsibleSupervisorAccountId) + const now = nowIso() + + return withTransaction(async (client) => { + const userResult = await client.query<{ + id: string | number + username: string + display_name: string + role: string + }>( + 'SELECT id, username, display_name, role FROM admin_users WHERE id = $1 AND status = $2 LIMIT 1', + [ownerAdminUserId, 'active'], + ) + const user = userResult.rows[0] + if (!user) { + throw createHttpError('账户所属人员不存在或已停用', { + statusCode: 404, + errorCode: 'fund_owner_not_found', + }) + } + + if (accountType === 'agent' && user.role !== 'support') { + throw createHttpError('客服备用金账户必须绑定 support 角色', { + statusCode: 400, + errorCode: 'fund_agent_role_invalid', + }) + } + if (accountType === 'supervisor' && user.role !== 'operator' && user.role !== 'admin') { + throw createHttpError('主管资金池必须绑定 operator 或 admin 角色', { + statusCode: 400, + errorCode: 'fund_supervisor_role_invalid', + }) + } + + if (accountType === 'agent') { + if (!responsibleSupervisorId) { + throw createHttpError('客服账户必须指定责任主管', { + statusCode: 400, + errorCode: 'fund_responsible_supervisor_required', + }) + } + const supervisorResult = await client.query( + "SELECT * FROM fund_accounts WHERE id = $1 AND account_type = 'supervisor' LIMIT 1", + [responsibleSupervisorId], + ) + if (!supervisorResult.rows[0]) { + throw createHttpError('责任主管账户不存在', { + statusCode: 404, + errorCode: 'fund_supervisor_account_not_found', + }) + } + } + + const existing = await client.query<{ id: string | number }>( + 'SELECT id FROM fund_accounts WHERE owner_admin_user_id = $1 AND account_type = $2 LIMIT 1', + [ownerAdminUserId, accountType], + ) + if (existing.rows[0]) { + throw createHttpError('该人员已有同类型备用金账户', { + statusCode: 409, + errorCode: 'fund_account_exists', + }) + } + + const accountNo = generateNo('FA') + const insertResult = await client.query( + ` + INSERT INTO fund_accounts ( + account_no, account_type, owner_admin_user_id, owner_name, + responsible_supervisor_id, balance_fen, status, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, 'active', $7, $7) + RETURNING * + `, + [ + accountNo, + accountType, + ownerAdminUserId, + user.display_name || user.username, + accountType === 'agent' ? responsibleSupervisorId : null, + openingBalanceFen, + now, + ], + ) + const account = insertResult.rows[0] + + if (openingBalanceFen > 0) { + await client.query( + ` + INSERT INTO fund_entries ( + entry_no, account_id, entry_type, amount_fen, balance_after_fen, + occurred_at, created_by_admin_user_id, evidence_json, note, created_at + ) VALUES ($1, $2, 'opening', $3, $3, $4, $5, '[]'::jsonb, $6, $4) + `, + [ + generateNo('FE'), + requiredRow(account, '创建备用金账户失败').id, + openingBalanceFen, + now, + session.userId, + '上线期初余额', + ], + ) + } + + return { account: mapFundAccount(requiredRow(account, '创建备用金账户失败')) } + }) +} + +export async function ensureAdminFundAgentAccounts(payload: JsonObject, session: AdminSession) { + assertAdmin(session) + const responsibleSupervisorId = toId(payload.responsibleSupervisorAccountId) + if (!responsibleSupervisorId) { + throw createHttpError('请选择责任主管', { + statusCode: 400, + errorCode: 'fund_responsible_supervisor_required', + }) + } + const openingBalanceFen = normalizeNonNegativeFen(payload.openingBalanceFen ?? 0) + const now = nowIso() + + return withTransaction(async (client) => { + const supervisorResult = await client.query( + "SELECT * FROM fund_accounts WHERE id = $1 AND account_type = 'supervisor' AND status <> 'closed' LIMIT 1", + [responsibleSupervisorId], + ) + if (!supervisorResult.rows[0]) { + throw createHttpError('责任主管账户不存在或已关闭', { + statusCode: 404, + errorCode: 'fund_supervisor_account_not_found', + }) + } + + const usersResult = await client.query<{ + id: string | number + username: string + display_name: string + }>( + ` + SELECT au.id, au.username, au.display_name + FROM admin_users au + WHERE au.status = 'active' + AND au.role = 'support' + AND NOT EXISTS ( + SELECT 1 FROM fund_accounts fa + WHERE fa.owner_admin_user_id = au.id AND fa.account_type = 'agent' + ) + ORDER BY au.id + `, + ) + + const created: Array<{ id: number; accountNo: string; ownerName: string }> = [] + for (const user of usersResult.rows) { + const accountNo = generateNo('FA') + const insertResult = await client.query( + ` + INSERT INTO fund_accounts ( + account_no, account_type, owner_admin_user_id, owner_name, + responsible_supervisor_id, balance_fen, status, created_at, updated_at + ) VALUES ($1, 'agent', $2, $3, $4, $5, 'active', $6, $6) + RETURNING * + `, + [ + accountNo, + user.id, + user.display_name || user.username, + responsibleSupervisorId, + openingBalanceFen, + now, + ], + ) + const account = insertResult.rows[0] + if (openingBalanceFen > 0) { + await client.query( + ` + INSERT INTO fund_entries ( + entry_no, account_id, entry_type, amount_fen, balance_after_fen, + occurred_at, created_by_admin_user_id, evidence_json, note, created_at + ) VALUES ($1, $2, 'opening', $3, $3, $4, $5, '[]'::jsonb, $6, $4) + `, + [ + generateNo('FE'), + requiredRow(account, '创建客服备用金账户失败').id, + openingBalanceFen, + now, + session.userId, + '上线期初余额', + ], + ) + } + created.push({ + id: Number(requiredRow(account, '创建客服备用金账户失败').id), + accountNo, + ownerName: user.display_name || user.username, + }) + } + + return { createdCount: created.length, items: created } + }) +} + +export async function updateAdminFundAccount( + accountId: number, + payload: JsonObject, + session: AdminSession, +) { + assertAdmin(session) + const account = await selectAccountRow(accountId) + if (!account) { + throw createHttpError('备用金账户不存在', { + statusCode: 404, + errorCode: 'fund_account_not_found', + }) + } + + const status = text(payload.status) + const responsibleSupervisorId = toId(payload.responsibleSupervisorAccountId) + if (status) normalizeAccountStatus(status) + if (status === 'closed' && toNumber(account.balance_fen) !== 0) { + throw createHttpError('余额不为 0 的账户不能关闭', { + statusCode: 409, + errorCode: 'fund_account_balance_not_zero', + }) + } + + if (responsibleSupervisorId) { + const supervisor = await selectAccountRow(responsibleSupervisorId) + if (!supervisor || supervisor.account_type !== 'supervisor') { + throw createHttpError('责任主管账户不存在', { + statusCode: 404, + errorCode: 'fund_supervisor_account_not_found', + }) + } + } + + const result = await query( + ` + UPDATE fund_accounts + SET + status = COALESCE(NULLIF($1, ''), status), + responsible_supervisor_id = COALESCE($2, responsible_supervisor_id), + updated_at = $3 + WHERE id = $4 + RETURNING * + `, + [status, responsibleSupervisorId || null, nowIso(), accountId], + ) + + return { account: mapFundAccount(requiredRow(result.rows[0], '更新备用金账户失败')) } +} + +async function lockAccounts(client: PoolClient, accountIds: number[]) { + const ids = Array.from(new Set(accountIds)).sort((a, b) => a - b) + const result = await client.query( + 'SELECT * FROM fund_accounts WHERE id = ANY($1::bigint[]) ORDER BY id FOR UPDATE', + [ids], + ) + return new Map(result.rows.map((row) => [Number(row.id), row])) +} + +async function insertEntry( + client: PoolClient, + input: { + accountId: number + entryType: EntryType + amountFen: number + balanceAfterFen: number + transferNo?: string + counterpartyAccountId?: number | null + reconciliationId?: number | null + occurredAt: string + actorUserId: number + evidence: JsonObject[] + note: string + createdAt: string + }, +) { + const result = await client.query( + ` + INSERT INTO fund_entries ( + entry_no, account_id, entry_type, amount_fen, balance_after_fen, + transfer_no, counterparty_account_id, reconciliation_id, occurred_at, + created_by_admin_user_id, evidence_json, note, created_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, $13) + RETURNING * + `, + [ + generateNo('FE'), + input.accountId, + input.entryType, + input.amountFen, + input.balanceAfterFen, + input.transferNo || '', + input.counterpartyAccountId || null, + input.reconciliationId || null, + input.occurredAt, + input.actorUserId, + JSON.stringify(input.evidence), + input.note, + input.createdAt, + ], + ) + return result.rows[0] +} + +export async function createAdminFundFunding(payload: JsonObject, session: AdminSession) { + assertAdmin(session) + const supervisorAccountId = toId(payload.supervisorAccountId) + if (!supervisorAccountId) { + throw createHttpError('请选择主管资金池', { + statusCode: 400, + errorCode: 'fund_supervisor_account_required', + }) + } + const amountFen = normalizeFen(payload.amountFen) + const occurredAt = normalizeOccurredAt(payload.occurredAt) + const evidence = normalizeEvidence(payload.evidence || payload.evidenceFiles) + const note = text(payload.note) + assertEvidenceOrNote(evidence, note) + const now = nowIso() + + return withTransaction(async (client) => { + const accounts = await lockAccounts(client, [supervisorAccountId]) + const supervisor = accounts.get(supervisorAccountId) + if (!supervisor || supervisor.account_type !== 'supervisor') { + throw createHttpError('主管资金池不存在', { + statusCode: 404, + errorCode: 'fund_supervisor_account_not_found', + }) + } + if (supervisor.status !== 'active') { + throw createHttpError('主管资金池已冻结或关闭', { + statusCode: 409, + errorCode: 'fund_account_inactive', + }) + } + + const nextBalance = toNumber(supervisor.balance_fen) + amountFen + await client.query('UPDATE fund_accounts SET balance_fen = $1, updated_at = $2 WHERE id = $3', [ + nextBalance, + now, + supervisorAccountId, + ]) + const entry = await insertEntry(client, { + accountId: supervisorAccountId, + entryType: 'funding', + amountFen, + balanceAfterFen: nextBalance, + occurredAt, + actorUserId: session.userId, + evidence, + note, + createdAt: now, + }) + + return { + entry: mapFundEntry(requiredRow(entry, '写入拨付流水失败')), + account: mapFundAccount({ ...supervisor, balance_fen: nextBalance }), + } + }) +} + +export async function createAdminFundIssue(payload: JsonObject, session: AdminSession) { + const agentAccountId = toId(payload.agentAccountId) + if (!agentAccountId) { + throw createHttpError('请选择客服备用金账户', { + statusCode: 400, + errorCode: 'fund_agent_account_required', + }) + } + const amountFen = normalizeFen(payload.amountFen) + const occurredAt = normalizeOccurredAt(payload.occurredAt) + const evidence = normalizeEvidence(payload.evidence || payload.evidenceFiles) + const note = text(payload.note) + assertEvidenceOrNote(evidence, note) + + const supervisorAccountId = await resolveSupervisorAccountId(payload, session) + const now = nowIso() + const transferNo = generateNo('FT') + + return withTransaction(async (client) => { + const accounts = await lockAccounts(client, [supervisorAccountId, agentAccountId]) + const supervisor = accounts.get(supervisorAccountId) + const agent = accounts.get(agentAccountId) + if (!supervisor || supervisor.account_type !== 'supervisor') { + throw createHttpError('主管资金池不存在', { + statusCode: 404, + errorCode: 'fund_supervisor_account_not_found', + }) + } + if (!agent || agent.account_type !== 'agent') { + throw createHttpError('客服备用金账户不存在', { + statusCode: 404, + errorCode: 'fund_agent_account_not_found', + }) + } + if (supervisor.status !== 'active' || agent.status !== 'active') { + throw createHttpError('账户已冻结或关闭,不能发放', { + statusCode: 409, + errorCode: 'fund_account_inactive', + }) + } + const supervisorBalance = toNumber(supervisor.balance_fen) + if (supervisorBalance < amountFen) { + throw createHttpError('主管资金池余额不足', { + statusCode: 409, + errorCode: 'fund_balance_insufficient', + }) + } + + const nextSupervisorBalance = supervisorBalance - amountFen + const nextAgentBalance = toNumber(agent.balance_fen) + amountFen + await client.query('UPDATE fund_accounts SET balance_fen = $1, updated_at = $2 WHERE id = $3', [ + nextSupervisorBalance, + now, + supervisorAccountId, + ]) + await client.query('UPDATE fund_accounts SET balance_fen = $1, updated_at = $2 WHERE id = $3', [ + nextAgentBalance, + now, + agentAccountId, + ]) + const supervisorEntry = await insertEntry(client, { + accountId: supervisorAccountId, + entryType: 'issue', + amountFen: -amountFen, + balanceAfterFen: nextSupervisorBalance, + transferNo, + counterpartyAccountId: agentAccountId, + occurredAt, + actorUserId: session.userId, + evidence, + note, + createdAt: now, + }) + const agentEntry = await insertEntry(client, { + accountId: agentAccountId, + entryType: 'issue', + amountFen, + balanceAfterFen: nextAgentBalance, + transferNo, + counterpartyAccountId: supervisorAccountId, + occurredAt, + actorUserId: session.userId, + evidence, + note, + createdAt: now, + }) + + return { + transferNo, + entries: [ + mapFundEntry(requiredRow(supervisorEntry, '写入主管发放流水失败')), + mapFundEntry(requiredRow(agentEntry, '写入客服发放流水失败')), + ], + } + }) +} + +async function resolveSupervisorAccountId( + payload: JsonObject, + session: AdminSession, +): Promise { + if (session.role === 'operator') { + const own = await getSupervisorAccountForUser(session.userId) + if (!own) { + throw createHttpError('当前账号没有主管资金池', { + statusCode: 403, + errorCode: 'fund_supervisor_account_required', + }) + } + return Number(own.id) + } + + const requested = toId(payload.supervisorAccountId) + if (requested) return requested + + if (session.role === 'admin') { + const own = await getSupervisorAccountForUser(session.userId) + if (own) return Number(own.id) + } + + throw createHttpError('请选择主管资金池', { + statusCode: 400, + errorCode: 'fund_supervisor_account_required', + }) +} + +export async function createAdminFundReturn(payload: JsonObject, session: AdminSession) { + const agentAccountId = await resolveAgentAccountId(payload, session) + const amountFen = normalizeFen(payload.amountFen) + const occurredAt = normalizeOccurredAt(payload.occurredAt) + const evidence = normalizeEvidence(payload.evidence || payload.evidenceFiles) + const note = text(payload.note) + assertEvidenceOrNote(evidence, note) + + const agent = await selectAccountRow(agentAccountId) + if (!agent || agent.account_type !== 'agent') { + throw createHttpError('客服备用金账户不存在', { + statusCode: 404, + errorCode: 'fund_agent_account_not_found', + }) + } + + const supervisorAccountId = + session.role === 'support' + ? toId(agent.responsible_supervisor_id) + : await resolveSupervisorAccountId(payload, session) + if (!supervisorAccountId) { + throw createHttpError('该客服未指定责任主管', { + statusCode: 409, + errorCode: 'fund_responsible_supervisor_required', + }) + } + + const now = nowIso() + const transferNo = generateNo('FT') + + return withTransaction(async (client) => { + const accounts = await lockAccounts(client, [agentAccountId, supervisorAccountId]) + const lockedAgent = accounts.get(agentAccountId) + const supervisor = accounts.get(supervisorAccountId) + if (!lockedAgent || lockedAgent.account_type !== 'agent') { + throw createHttpError('客服备用金账户不存在', { + statusCode: 404, + errorCode: 'fund_agent_account_not_found', + }) + } + if (!supervisor || supervisor.account_type !== 'supervisor') { + throw createHttpError('主管资金池不存在', { + statusCode: 404, + errorCode: 'fund_supervisor_account_not_found', + }) + } + if (lockedAgent.status !== 'active' || supervisor.status !== 'active') { + throw createHttpError('账户已冻结或关闭,不能缴回', { + statusCode: 409, + errorCode: 'fund_account_inactive', + }) + } + const agentBalance = toNumber(lockedAgent.balance_fen) + if (agentBalance < amountFen) { + throw createHttpError('客服备用金余额不足', { + statusCode: 409, + errorCode: 'fund_balance_insufficient', + }) + } + + const nextAgentBalance = agentBalance - amountFen + const nextSupervisorBalance = toNumber(supervisor.balance_fen) + amountFen + await client.query('UPDATE fund_accounts SET balance_fen = $1, updated_at = $2 WHERE id = $3', [ + nextAgentBalance, + now, + agentAccountId, + ]) + await client.query('UPDATE fund_accounts SET balance_fen = $1, updated_at = $2 WHERE id = $3', [ + nextSupervisorBalance, + now, + supervisorAccountId, + ]) + + const agentEntry = await insertEntry(client, { + accountId: agentAccountId, + entryType: 'return', + amountFen: -amountFen, + balanceAfterFen: nextAgentBalance, + transferNo, + counterpartyAccountId: supervisorAccountId, + occurredAt, + actorUserId: session.userId, + evidence, + note, + createdAt: now, + }) + const supervisorEntry = await insertEntry(client, { + accountId: supervisorAccountId, + entryType: 'return', + amountFen, + balanceAfterFen: nextSupervisorBalance, + transferNo, + counterpartyAccountId: agentAccountId, + occurredAt, + actorUserId: session.userId, + evidence, + note, + createdAt: now, + }) + + return { + transferNo, + entries: [ + mapFundEntry(requiredRow(agentEntry, '写入客服缴回流水失败')), + mapFundEntry(requiredRow(supervisorEntry, '写入主管收回流水失败')), + ], + } + }) +} + +async function resolveAgentAccountId(payload: JsonObject, session: AdminSession): Promise { + if (session.role === 'support') { + const own = await query( + "SELECT * FROM fund_accounts WHERE owner_admin_user_id = $1 AND account_type = 'agent' LIMIT 1", + [session.userId], + ) + if (!own.rows[0]) { + throw createHttpError('当前账号还没有备用金账户', { + statusCode: 404, + errorCode: 'fund_agent_account_not_found', + }) + } + return Number(own.rows[0].id) + } + + const accountId = toId(payload.agentAccountId) + if (!accountId) { + throw createHttpError('请选择客服备用金账户', { + statusCode: 400, + errorCode: 'fund_agent_account_required', + }) + } + return accountId +} + +export async function createAdminFundExpense(payload: JsonObject, session: AdminSession) { + const agentAccountId = await resolveAgentAccountId(payload, session) + const amountFen = normalizeFen(payload.amountFen) + const category = normalizeExpenseCategory(payload.category) + const payeeType = normalizePayeeType(payload.payeeType) + const payeeName = text(payload.payeeName) + const payeeAccount = text(payload.payeeAccount) + const platformName = text(payload.platformName) + const platformAccount = text(payload.platformAccount) + const note = text(payload.note) + const evidence = normalizeEvidence(payload.evidence || payload.evidenceFiles) + const paidAt = normalizeOccurredAt(payload.paidAt || payload.occurredAt) + assertExpenseEvidence(evidence) + if (!payeeName) { + throw createHttpError('请填写收款方', { + statusCode: 400, + errorCode: 'fund_payee_required', + }) + } + + const orderId = toId(payload.orderId) + const orderItemId = toId(payload.orderItemId) + let resolvedOrderId = orderId + if (orderItemId) { + const itemResult = await query<{ id: string | number; order_id: string | number }>( + 'SELECT id, order_id FROM order_items WHERE id = $1 LIMIT 1', + [orderItemId], + ) + const item = itemResult.rows[0] + if (!item) { + throw createHttpError('关联订单项不存在', { + statusCode: 404, + errorCode: 'fund_order_item_not_found', + }) + } + if (resolvedOrderId && Number(item.order_id) !== resolvedOrderId) { + throw createHttpError('订单项与订单不匹配', { + statusCode: 400, + errorCode: 'fund_order_item_mismatch', + }) + } + resolvedOrderId = Number(item.order_id) + } + if (resolvedOrderId) { + const orderResult = await query<{ id: string | number }>( + 'SELECT id FROM orders WHERE id = $1 LIMIT 1', + [resolvedOrderId], + ) + if (!orderResult.rows[0]) { + throw createHttpError('关联订单不存在', { + statusCode: 404, + errorCode: 'fund_order_not_found', + }) + } + } + + const now = nowIso() + const allowDuplicate = payload.allowDuplicate === true + + return withTransaction(async (client) => { + const accounts = await lockAccounts(client, [agentAccountId]) + const account = accounts.get(agentAccountId) + if (!account || account.account_type !== 'agent') { + throw createHttpError('客服备用金账户不存在', { + statusCode: 404, + errorCode: 'fund_agent_account_not_found', + }) + } + if (account.status !== 'active') { + throw createHttpError('客服备用金账户已冻结或关闭', { + statusCode: 409, + errorCode: 'fund_account_inactive', + }) + } + const balance = toNumber(account.balance_fen) + if (balance < amountFen) { + throw createHttpError('备用金余额不足,请先联系主管补拨', { + statusCode: 409, + errorCode: 'fund_balance_insufficient', + }) + } + + if (!allowDuplicate) { + const duplicateResult = await client.query<{ expense_no: string }>( + ` + SELECT expense_no + FROM manual_expenses + WHERE account_id = $1 + AND amount_fen = $2 + AND payee_name = $3 + AND created_at >= now() - interval '24 hours' + LIMIT 1 + `, + [agentAccountId, amountFen, payeeName], + ) + if (duplicateResult.rows[0]) { + throw createHttpError( + `疑似重复支出(${duplicateResult.rows[0].expense_no}),如确认无误请勾选“确认非重复”`, + { + statusCode: 409, + errorCode: 'fund_expense_duplicate', + }, + ) + } + } + + const nextBalance = balance - amountFen + await client.query('UPDATE fund_accounts SET balance_fen = $1, updated_at = $2 WHERE id = $3', [ + nextBalance, + now, + agentAccountId, + ]) + const entry = await insertEntry(client, { + accountId: agentAccountId, + entryType: 'expense', + amountFen: -amountFen, + balanceAfterFen: nextBalance, + occurredAt: paidAt, + actorUserId: session.userId, + evidence, + note, + createdAt: now, + }) + const entryRow = requiredRow(entry, '写入支出流水失败') + + const expenseNo = generateNo('FX') + const expenseResult = await client.query( + ` + INSERT INTO manual_expenses ( + expense_no, account_id, amount_fen, category, payee_type, payee_name, + payee_account, platform_name, platform_account, order_id, order_item_id, + paid_at, evidence_json, note, fund_entry_id, created_by_admin_user_id, + created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb, $14, $15, $16, $17, $17) + RETURNING * + `, + [ + expenseNo, + agentAccountId, + amountFen, + category, + payeeType, + payeeName, + payeeAccount, + platformName, + platformAccount, + resolvedOrderId || null, + orderItemId || null, + paidAt, + JSON.stringify(evidence), + note, + entryRow.id, + session.userId, + now, + ], + ) + + return { + expense: mapManualExpense(requiredRow(expenseResult.rows[0], '写入支出明细失败')), + } + }) +} + +export async function listAdminFundExpenses(payload: JsonObject, session: AdminSession) { + const visibility = await resolveVisibility(session) + const page = normalizePage(payload.page) + const pageSize = normalizePageSize(payload.pageSize) + const offset = (page - 1) * pageSize + const params: unknown[] = [] + const clauses = [buildAccountScope('fa', visibility, params)] + + const accountId = toId(payload.accountId) + if (accountId) { + params.push(accountId) + clauses.push(`me.account_id = $${params.length}`) + } + + const category = text(payload.category) + if (category) { + params.push(normalizeExpenseCategory(category)) + clauses.push(`me.category = $${params.length}`) + } + + const payeeType = text(payload.payeeType) + if (payeeType) { + params.push(normalizePayeeType(payeeType)) + clauses.push(`me.payee_type = $${params.length}`) + } + + const keyword = text(payload.keyword) + if (keyword) { + params.push(`%${keyword}%`) + clauses.push(`( + me.expense_no ILIKE $${params.length} + OR me.payee_name ILIKE $${params.length} + OR me.platform_name ILIKE $${params.length} + OR fa.owner_name ILIKE $${params.length} + OR ou.username ILIKE $${params.length} + OR me.order_id::text ILIKE $${params.length} + )`) + } + + const dateFrom = normalizeDateQuery(payload.dateFrom) + if (dateFrom) { + params.push(dateFrom) + clauses.push(`me.paid_at >= $${params.length}::timestamptz`) + } + const dateTo = normalizeDateQuery(payload.dateTo, true) + if (dateTo) { + params.push(dateTo) + clauses.push(`me.paid_at <= $${params.length}::timestamptz`) + } + + const where = clauses.join(' AND ') + const countResult = await query<{ total: number }>( + ` + SELECT COUNT(*)::int AS total + FROM manual_expenses me + JOIN fund_accounts fa ON fa.id = me.account_id + LEFT JOIN admin_users ou ON ou.id = fa.owner_admin_user_id + WHERE ${where} + `, + params, + ) + + const listParams = [...params, pageSize, offset] + const result = await query( + ` + SELECT + me.*, + fa.owner_name AS agent_name, + fa.account_no, + COALESCE(NULLIF(cu.display_name, ''), cu.username) AS created_by_username + FROM manual_expenses me + JOIN fund_accounts fa ON fa.id = me.account_id + LEFT JOIN admin_users ou ON ou.id = fa.owner_admin_user_id + LEFT JOIN admin_users cu ON cu.id = me.created_by_admin_user_id + WHERE ${where} + ORDER BY me.paid_at DESC, me.id DESC + LIMIT $${listParams.length - 1} OFFSET $${listParams.length} + `, + listParams, + ) + + return { + items: result.rows.map(mapManualExpense), + pagination: { page, pageSize, total: toNumber(countResult.rows[0]?.total) }, + } +} + +export async function getAdminFundExpenseDetail(expenseId: number, session: AdminSession) { + const visibility = await resolveVisibility(session) + const params: unknown[] = [expenseId] + const scope = buildAccountScope('fa', visibility, params) + const result = await query( + ` + SELECT + me.*, + fa.owner_name AS agent_name, + fa.account_no, + COALESCE(NULLIF(cu.display_name, ''), cu.username) AS created_by_username + FROM manual_expenses me + JOIN fund_accounts fa ON fa.id = me.account_id + LEFT JOIN admin_users cu ON cu.id = me.created_by_admin_user_id + WHERE me.id = $1 AND ${scope} + LIMIT 1 + `, + params, + ) + const row = result.rows[0] + if (!row) { + throw createHttpError('支出记录不存在', { + statusCode: 404, + errorCode: 'fund_expense_not_found', + }) + } + return { expense: mapManualExpense(row) } +} + +export async function listAdminFundReconciliations(payload: JsonObject, session: AdminSession) { + const visibility = await resolveVisibility(session) + const page = normalizePage(payload.page) + const pageSize = normalizePageSize(payload.pageSize) + const offset = (page - 1) * pageSize + const params: unknown[] = [] + const clauses = [buildAccountScope('fa', visibility, params)] + + const accountId = toId(payload.accountId) + if (accountId) { + params.push(accountId) + clauses.push(`fr.account_id = $${params.length}`) + } + const status = text(payload.status) + if (status) { + params.push(normalizeReconciliationStatus(status)) + clauses.push(`fr.status = $${params.length}`) + } + const dateFrom = normalizeDateQuery(payload.dateFrom) + if (dateFrom) { + params.push(dateFrom) + clauses.push(`fr.period_end >= $${params.length}::timestamptz`) + } + const dateTo = normalizeDateQuery(payload.dateTo, true) + if (dateTo) { + params.push(dateTo) + clauses.push(`fr.period_start <= $${params.length}::timestamptz`) + } + + const where = clauses.join(' AND ') + const countResult = await query<{ total: number }>( + ` + SELECT COUNT(*)::int AS total + FROM fund_reconciliations fr + JOIN fund_accounts fa ON fa.id = fr.account_id + WHERE ${where} + `, + params, + ) + + const listParams = [...params, pageSize, offset] + const result = await query( + ` + SELECT + fr.*, + fa.owner_name AS agent_name, + fa.account_no, + COALESCE(NULLIF(cu.display_name, ''), cu.username) AS confirmed_by_username, + COALESCE(NULLIF(cc.display_name, ''), cc.username) AS created_by_username + FROM fund_reconciliations fr + JOIN fund_accounts fa ON fa.id = fr.account_id + LEFT JOIN admin_users cu ON cu.id = fr.confirmed_by_admin_user_id + LEFT JOIN admin_users cc ON cc.id = fr.created_by_admin_user_id + WHERE ${where} + ORDER BY fr.period_end DESC, fr.id DESC + LIMIT $${listParams.length - 1} OFFSET $${listParams.length} + `, + listParams, + ) + + return { + items: result.rows.map(mapReconciliation), + pagination: { page, pageSize, total: toNumber(countResult.rows[0]?.total) }, + } +} + +export async function createAdminFundReconciliation(payload: JsonObject, session: AdminSession) { + const visibility = await resolveVisibility(session) + const accountId = toId(payload.accountId) + if (!accountId) { + throw createHttpError('请选择对账账户', { + statusCode: 400, + errorCode: 'fund_account_required', + }) + } + const account = await assertAccountVisible(accountId, visibility) + if (account.account_type !== 'agent') { + throw createHttpError('P0 只对客服备用金账户对账', { + statusCode: 400, + errorCode: 'fund_reconciliation_agent_only', + }) + } + + const periodStart = normalizeOccurredAt(payload.periodStart) + const periodEnd = normalizeOccurredAt(payload.periodEnd) + if (Date.parse(periodStart) > Date.parse(periodEnd)) { + throw createHttpError('对账开始时间不能晚于结束时间', { + statusCode: 400, + errorCode: 'fund_reconciliation_period_invalid', + }) + } + + const actualBalanceFen = normalizeNonNegativeFen(payload.actualBalanceFen ?? 0) + const systemBalanceFen = toNumber(account.balance_fen) + const differenceFen = actualBalanceFen - systemBalanceFen + const reason = text(payload.reason) + const evidence = normalizeEvidence(payload.evidence || payload.evidenceFiles) + if (differenceFen !== 0 && !reason) { + throw createHttpError('有差异时必须填写差异原因', { + statusCode: 400, + errorCode: 'fund_reconciliation_reason_required', + }) + } + + const now = nowIso() + const result = await query( + ` + INSERT INTO fund_reconciliations ( + reconciliation_no, account_id, period_start, period_end, + system_balance_fen, actual_balance_fen, difference_fen, status, + reason, evidence_json, created_by_admin_user_id, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11, $12, $12) + RETURNING * + `, + [ + generateNo('FR'), + accountId, + periodStart, + periodEnd, + systemBalanceFen, + actualBalanceFen, + differenceFen, + differenceFen === 0 ? 'pending' : 'discrepancy', + reason, + JSON.stringify(evidence), + session.userId, + now, + ], + ) + + return { + reconciliation: mapReconciliation({ + ...requiredRow(result.rows[0], '写入对账记录失败'), + agent_name: account.owner_name, + account_no: account.account_no, + }), + } +} + +export async function confirmAdminFundReconciliation( + reconciliationId: number, + payload: JsonObject, + session: AdminSession, +) { + if (session.role === 'support') { + throw createHttpError('客服不能确认对账', { + statusCode: 403, + errorCode: 'fund_reconciliation_forbidden', + }) + } + + const visibility = await resolveVisibility(session) + const params: unknown[] = [reconciliationId] + const scope = buildAccountScope('fa', visibility, params) + const result = await query( + ` + SELECT fr.*, fa.owner_name AS agent_name, fa.account_no + FROM fund_reconciliations fr + JOIN fund_accounts fa ON fa.id = fr.account_id + WHERE fr.id = $1 AND ${scope} + LIMIT 1 + `, + params, + ) + const reconciliation = result.rows[0] + if (!reconciliation) { + throw createHttpError('对账记录不存在', { + statusCode: 404, + errorCode: 'fund_reconciliation_not_found', + }) + } + if (reconciliation.status === 'confirmed') { + throw createHttpError('该对账已确认', { + statusCode: 409, + errorCode: 'fund_reconciliation_already_confirmed', + }) + } + + const differenceFen = toNumber(reconciliation.difference_fen) + const reason = text(payload.reason) || reconciliation.reason + if (differenceFen !== 0) { + if (!hasFundPermission(session, 'fund.adjust')) { + throw createHttpError('有差异的对账需要财务或管理员调整', { + statusCode: 403, + errorCode: 'fund_adjust_forbidden', + }) + } + if (!reason) { + throw createHttpError('有差异时必须填写差异原因', { + statusCode: 400, + errorCode: 'fund_reconciliation_reason_required', + }) + } + } + + const now = nowIso() + return withTransaction(async (client) => { + const locked = await lockAccounts(client, [Number(reconciliation.account_id)]) + const account = locked.get(Number(reconciliation.account_id)) + if (!account) { + throw createHttpError('对账账户不存在', { + statusCode: 404, + errorCode: 'fund_account_not_found', + }) + } + + if (differenceFen !== 0) { + const nextBalance = toNumber(account.balance_fen) + differenceFen + if (nextBalance < 0) { + throw createHttpError('调整后余额不能为负', { + statusCode: 409, + errorCode: 'fund_balance_insufficient', + }) + } + await client.query( + 'UPDATE fund_accounts SET balance_fen = $1, updated_at = $2 WHERE id = $3', + [nextBalance, now, account.id], + ) + await insertEntry(client, { + accountId: Number(account.id), + entryType: 'adjustment', + amountFen: differenceFen, + balanceAfterFen: nextBalance, + reconciliationId: reconciliationId, + occurredAt: now, + actorUserId: session.userId, + evidence: normalizeEvidence(payload.evidence || reconciliation.evidence_json), + note: reason || '对账差异调整', + createdAt: now, + }) + } + + await client.query( + ` + UPDATE fund_reconciliations + SET status = 'confirmed', reason = $1, confirmed_by_admin_user_id = $2, + confirmed_at = $3, updated_at = $3 + WHERE id = $4 + `, + [reason, session.userId, now, reconciliationId], + ) + + const updated = await client.query( + ` + SELECT fr.*, fa.owner_name AS agent_name, fa.account_no, COALESCE(NULLIF(cu.display_name, ''), cu.username) AS confirmed_by_username + FROM fund_reconciliations fr + JOIN fund_accounts fa ON fa.id = fr.account_id + LEFT JOIN admin_users cu ON cu.id = fr.confirmed_by_admin_user_id + WHERE fr.id = $1 + LIMIT 1 + `, + [reconciliationId], + ) + return { + reconciliation: mapReconciliation(requiredRow(updated.rows[0], '更新对账记录失败')), + } + }) +} + +export async function getAdminFundSupervisorOptions(session: AdminSession) { + const visibility = await resolveVisibility(session) + const params: unknown[] = [] + const scope = buildAccountScope('fa', visibility, params) + const result = await query( + ` + SELECT fa.*, rs.owner_name AS responsible_supervisor_name + FROM fund_accounts fa + LEFT JOIN fund_accounts rs ON rs.id = fa.responsible_supervisor_id + WHERE ${scope} AND fa.account_type = 'supervisor' + ORDER BY fa.owner_name + `, + params, + ) + return { items: result.rows.map(mapFundAccount) } +} diff --git a/apps/backend/src/services/admin/admin-login-log-service.ts b/apps/backend/src/services/admin/admin-login-log-service.ts index 9a7510fb..5f16c198 100644 --- a/apps/backend/src/services/admin/admin-login-log-service.ts +++ b/apps/backend/src/services/admin/admin-login-log-service.ts @@ -67,6 +67,7 @@ export async function getAdminLoginLogs(query: JsonObject = {}) { logId: Number(item.id), userId: Number(item.user_id || 0) || null, username: String(item.username || '').trim(), + displayName: String(item.display_name || item.username || '').trim(), role: String(item.role || '').trim(), ip: String(item.ip || '').trim(), location: String(item.location || '').trim(), diff --git a/apps/backend/src/services/admin/admin-rbac-service.ts b/apps/backend/src/services/admin/admin-rbac-service.ts index f6776b90..97f24d60 100644 --- a/apps/backend/src/services/admin/admin-rbac-service.ts +++ b/apps/backend/src/services/admin/admin-rbac-service.ts @@ -6,6 +6,12 @@ export const ADMIN_PERMISSION_KEYS = [ 'finance.review', 'finance.config', 'finance.export', + 'fund.view', + 'fund.issue', + 'fund.expense', + 'fund.reconcile', + 'fund.adjust', + 'fund.export', 'worker_order.view', 'worker_order.edit', 'worker_order.configure_share', diff --git a/apps/backend/src/services/admin/admin-read-shared-helpers.test.ts b/apps/backend/src/services/admin/admin-read-shared-helpers.test.ts index 65643c85..af67b8dd 100644 --- a/apps/backend/src/services/admin/admin-read-shared-helpers.test.ts +++ b/apps/backend/src/services/admin/admin-read-shared-helpers.test.ts @@ -5,6 +5,7 @@ import { canViewerCloseTask, createAdminViewerContext, mapKuaishouCloudFulfillmentContext, + mapManualDispatchContext, resolveAdminTaskScreenshotUrl, resolveOrderItemSkuNick, resolveOrderItemTitle, @@ -68,6 +69,23 @@ test('createAdminViewerContext lets support rebind-assisted actions without full assert.equal(operator.canManageTaskLifecycle, true) }) +test('mapManualDispatchContext prefers display name and falls back to username', () => { + const withDisplayName = mapManualDispatchContext({ + completedBy: { userId: 3, username: 'kf_1', displayName: '客服-1', role: 'support' }, + }) + assert.deepEqual(withDisplayName?.completedBy, { + userId: 3, + username: 'kf_1', + displayName: '客服-1', + role: 'support', + }) + + const legacy = mapManualDispatchContext({ + completedBy: { userId: 3, username: 'kf_1', role: 'support' }, + }) + assert.equal(legacy?.completedBy?.displayName, 'kf_1') +}) + test('mapKuaishouCloudFulfillmentContext exposes cloud account display labels', () => { const flow = mapKuaishouCloudFulfillmentContext( { diff --git a/apps/backend/src/services/admin/admin-read-shared-helpers.ts b/apps/backend/src/services/admin/admin-read-shared-helpers.ts index d1a8fd63..8b6b22f4 100644 --- a/apps/backend/src/services/admin/admin-read-shared-helpers.ts +++ b/apps/backend/src/services/admin/admin-read-shared-helpers.ts @@ -64,6 +64,9 @@ export function mapManualDispatchContext( ? { userId: Number(record.completedBy.userId || 0) || 0, username: String(record.completedBy.username || '').trim(), + displayName: String( + record.completedBy.displayName || record.completedBy.username || '', + ).trim(), role: String(record.completedBy.role || '').trim(), } : null, diff --git a/apps/backend/src/services/admin/write/kuaishou-industry-actions.ts b/apps/backend/src/services/admin/write/kuaishou-industry-actions.ts index f3421879..da766ddf 100644 --- a/apps/backend/src/services/admin/write/kuaishou-industry-actions.ts +++ b/apps/backend/src/services/admin/write/kuaishou-industry-actions.ts @@ -333,6 +333,7 @@ function buildAdminActionActor(session: AdminViewerSessionInput | null) { ? { userId: Number(session.userId || 0) || 0, username: String(session.username || '').trim(), + displayName: String(session.displayName || session.username || '').trim(), role: String(session.role || '').trim(), } : null diff --git a/apps/backend/src/services/admin/write/order-actions.ts b/apps/backend/src/services/admin/write/order-actions.ts index ffd9ade3..fb0a4883 100644 --- a/apps/backend/src/services/admin/write/order-actions.ts +++ b/apps/backend/src/services/admin/write/order-actions.ts @@ -59,6 +59,12 @@ export async function updateAdminOrderClaimStatus( claimStatus: updated.claim_status || payload.status, claimStatusReason: updated.claim_status_reason || reason, claimStatusUpdatedAt: updated.claim_status_updated_at || now, - operator: session ? { userId: session.userId, username: session.username } : null, + operator: session + ? { + userId: session.userId, + username: session.username, + displayName: session.displayName || session.username, + } + : null, } } diff --git a/apps/backend/src/services/admin/write/task-actions.ts b/apps/backend/src/services/admin/write/task-actions.ts index 341f12bf..8c5cd951 100644 --- a/apps/backend/src/services/admin/write/task-actions.ts +++ b/apps/backend/src/services/admin/write/task-actions.ts @@ -96,6 +96,7 @@ export async function closeAdminTask( ? { userId: session.userId, username: session.username, + displayName: session.displayName || session.username, role: session.role, } : null, @@ -140,6 +141,7 @@ export async function markAdminTaskManualReview( ? { userId: session.userId, username: session.username, + displayName: session.displayName || session.username, role: session.role, } : null, @@ -215,6 +217,7 @@ export async function retryAdminTask( ? { userId: session.userId, username: session.username, + displayName: session.displayName || session.username, role: session.role, } : null, @@ -335,6 +338,7 @@ export async function regenerateAdminTaskClaimLink( ? { userId: session.userId, username: session.username, + displayName: session.displayName || session.username, role: session.role, } : null, @@ -394,6 +398,7 @@ export async function completeAdminTaskManualDispatch( ? { userId: Number(session.userId || 0), username: String(session.username || ''), + displayName: String(session.displayName || session.username || ''), role: String(session.role || ''), } : null, diff --git a/apps/backend/src/services/worker-platform/admin-worker-risk-service.ts b/apps/backend/src/services/worker-platform/admin-worker-risk-service.ts index 25f36f67..a8eb42a6 100644 --- a/apps/backend/src/services/worker-platform/admin-worker-risk-service.ts +++ b/apps/backend/src/services/worker-platform/admin-worker-risk-service.ts @@ -29,7 +29,11 @@ import { getWorkerRiskConfig, saveWorkerRiskConfig } from './worker-risk-config- * - **留痕层**:worker_risk_events(只记显著事件)。持久、可追溯。 */ -export type WorkerRiskActor = { userId?: number | null; username?: string } +export type WorkerRiskActor = { + userId?: number | null + username?: string + displayName?: string +} const OVERVIEW_SUMMARY_WINDOW_MS = 24 * 60 * 60 * 1000 @@ -123,7 +127,7 @@ export async function banAdminWorkerRisk( reason, action: 'manual', actorUserId: actor.userId ?? null, - actorUsername: actor.username || '', + actorUsername: actor.displayName || actor.username || '', }) return { workerId: id, status: 'frozen', reason } @@ -162,7 +166,7 @@ export async function unbanAdminWorkerRisk( reason, action: 'manual', actorUserId: actor.userId ?? null, - actorUsername: actor.username || '', + actorUsername: actor.displayName || actor.username || '', }) return { workerId: id, status: 'active', reason } @@ -182,7 +186,7 @@ export async function releaseAdminWorkerRiskTracker( reason: '后台仅释放内存风控状态(账号状态未变更)', action: 'manual', actorUserId: actor.userId ?? null, - actorUsername: actor.username || '', + actorUsername: actor.displayName || actor.username || '', }) return { workerId: id } } diff --git a/apps/backend/src/types/admin/read-inputs.ts b/apps/backend/src/types/admin/read-inputs.ts index f2ee4b2d..aca413f0 100644 --- a/apps/backend/src/types/admin/read-inputs.ts +++ b/apps/backend/src/types/admin/read-inputs.ts @@ -1,6 +1,7 @@ export type AdminViewerSessionInput = { userId?: number | string username?: string + displayName?: string role?: string } diff --git a/apps/backend/src/types/runtime-config.ts b/apps/backend/src/types/runtime-config.ts index 4170d231..6eed50dc 100644 --- a/apps/backend/src/types/runtime-config.ts +++ b/apps/backend/src/types/runtime-config.ts @@ -1,5 +1,6 @@ export type AdminDefaultUser = { username: string + displayName?: string password: string role: string status?: string diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index bea398d1..a37d5d0d 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -21,6 +21,7 @@ import WorkerLayout from '@/layouts/WorkerLayout' const AdminDashboardPage = lazy(() => import('@/pages/admin/AdminDashboardPage')) const AdminFinancePage = lazy(() => import('@/pages/admin/AdminFinancePage')) +const AdminFundPage = lazy(() => import('@/pages/admin/AdminFundPage')) const AdminAuditLogsPage = lazy(() => import('@/pages/admin/AdminAuditLogsPage')) const AdminKuaishouIndustryPage = lazy(() => import('@/pages/admin/AdminKuaishouIndustryPage')) const AdminDouyinMashidaCertificatePage = lazy( @@ -145,6 +146,9 @@ function AdminApplication() { }> } /> + }> + } /> + } /> }> } /> diff --git a/apps/frontend/src/layouts/AdminLayout.tsx b/apps/frontend/src/layouts/AdminLayout.tsx index 04803b50..babc4e64 100644 --- a/apps/frontend/src/layouts/AdminLayout.tsx +++ b/apps/frontend/src/layouts/AdminLayout.tsx @@ -17,6 +17,7 @@ import { TrophyOutlined, UnorderedListOutlined, UserOutlined, + WalletOutlined, } from '@ant-design/icons' import { App, @@ -45,10 +46,12 @@ import { } from '@/services/admin' import { clearAdminSession, + getAdminDisplayName, getAdminRole, getAdminToken, getAdminTokenExpiresAt, getAdminUsername, + setAdminDisplayName, setAdminPermissions, } from '@/utils/admin-auth' import { formatAdminDateTime } from '@/utils/admin-time' @@ -89,9 +92,13 @@ export default function AdminLayout() { const session = adminSessionQuery.data?.data if (session?.authenticated && session.user) { setAdminPermissions(session.user.permissions || []) + setAdminDisplayName(session.user.displayName || session.user.username) } }, [adminSessionQuery.data]) + const displayName = + adminSessionQuery.data?.data.user?.displayName || getAdminDisplayName() || username + const devMockStatusQuery = useQuery({ queryKey: ['admin-dev-mock-status'], queryFn: () => fetchDevMockStatus(), @@ -124,11 +131,17 @@ export default function AdminLayout() { role === 'finance' ? [ { key: '/admin/finance', icon: , label: '财务' }, + { key: '/admin/fund', icon: , label: '备用金' }, { key: '/admin/worker-platform', icon: , label: '资金审核' }, ] : [ { key: '/admin/dashboard', icon: , label: '概览' }, { key: '/admin/orders', icon: , label: '订单' }, + { + key: '/admin/fund', + icon: , + label: role === 'support' ? '我的备用金' : '备用金', + }, ...(role === 'support' ? [] : [{ key: '/admin/finance', icon: , label: '财务' }]), @@ -291,7 +304,10 @@ export default function AdminLayout() { {!collapsed ? (
- {username} + {displayName} + {displayName !== username ? ( + @{username} + ) : null} {roleLabel(role)} {expiresAt ? ( diff --git a/apps/frontend/src/pages/admin/AdminAuditLogsPage.tsx b/apps/frontend/src/pages/admin/AdminAuditLogsPage.tsx index 805f8acc..574fe0d0 100644 --- a/apps/frontend/src/pages/admin/AdminAuditLogsPage.tsx +++ b/apps/frontend/src/pages/admin/AdminAuditLogsPage.tsx @@ -72,7 +72,12 @@ export default function AdminAuditLogsPage() { minWidth: 140, render: (_, row) => (
- {row.actorUsername || '-'} + + {row.actorDisplayName || row.actorUsername || '-'} + + {row.actorDisplayName && row.actorDisplayName !== row.actorUsername ? ( + @{row.actorUsername} + ) : null}
), @@ -155,7 +160,7 @@ export default function AdminAuditLogsPage() { className="filter-form" > - + + + diff --git a/apps/frontend/src/pages/admin/AdminFundPage.tsx b/apps/frontend/src/pages/admin/AdminFundPage.tsx new file mode 100644 index 00000000..9722bcb0 --- /dev/null +++ b/apps/frontend/src/pages/admin/AdminFundPage.tsx @@ -0,0 +1,13 @@ +import { getAdminRole } from '@/utils/admin-auth' +import FundManagementView from './fund/FundManagementView' +import FundSupportView from './fund/FundSupportView' + +export default function AdminFundPage() { + const role = getAdminRole() + + if (role === 'support') { + return + } + + return +} diff --git a/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx b/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx index 218964dc..9c7f03b6 100644 --- a/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx +++ b/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx @@ -1698,8 +1698,10 @@ function ManualDispatchPanel({ {formatAdminDateTime(detail.manualDispatch.completedAt)} - {detail.manualDispatch.completedBy?.username || '-'} /{' '} - {detail.manualDispatch.completedBy?.role || '-'} + {detail.manualDispatch.completedBy?.displayName || + detail.manualDispatch.completedBy?.username || + '-'}{' '} + / {detail.manualDispatch.completedBy?.role || '-'} {detail.manualDispatch.deliveryReference || '-'} diff --git a/apps/frontend/src/pages/admin/AdminUsersPage.tsx b/apps/frontend/src/pages/admin/AdminUsersPage.tsx index 240372e7..067e590b 100644 --- a/apps/frontend/src/pages/admin/AdminUsersPage.tsx +++ b/apps/frontend/src/pages/admin/AdminUsersPage.tsx @@ -27,12 +27,13 @@ import { fetchAdminRolePermissions, fetchAdminUsers, resetAdminUserPassword, + updateAdminUserDisplayName, updateAdminUserRole, updateAdminUserStatus, updateAdminRolePermissions, } from '@/services/admin' import type { AdminRole, AdminUserListItem } from '@/types/admin' -import { getAdminUserId, hasAdminRole } from '@/utils/admin-auth' +import { getAdminUserId, hasAdminRole, setAdminDisplayName } from '@/utils/admin-auth' import { adminUserRoleOptions, adminUserStatusOptions } from '@/utils/admin-options' import { ADMIN_DEFAULT_PAGE_SIZE, @@ -48,11 +49,16 @@ type UserFilterForm = { } type CreateUserForm = { + displayName?: string username: string password: string role: AdminRole } +type DisplayNameForm = { + displayName: string +} + const USER_ROLE_OPTIONS = [ { label: '普通运营', value: 'operator' }, { label: '客服', value: 'support' }, @@ -119,6 +125,7 @@ export default function AdminUsersPage() { try { await createAdminUser({ username: values.username.trim(), + displayName: values.displayName?.trim() || '', password: values.password.trim(), role: values.role, status: 'active', @@ -139,7 +146,7 @@ export default function AdminUsersPage() { modal.confirm({ title: '确认操作', - content: `确认将 ${item.username} 调整为${formatRoleLabel(nextRole)}吗?`, + content: `确认将 ${displayNameOf(item)} 调整为${formatRoleLabel(nextRole)}吗?`, okText: '继续执行', cancelText: '取消', centered: true, @@ -163,7 +170,7 @@ export default function AdminUsersPage() { modal.confirm({ title: '确认操作', - content: `确认将 ${item.username}${nextStatus === 'active' ? '启用' : '停用'}吗?`, + content: `确认将 ${displayNameOf(item)}${nextStatus === 'active' ? '启用' : '停用'}吗?`, okText: '继续执行', cancelText: '取消', centered: true, @@ -182,6 +189,66 @@ export default function AdminUsersPage() { }) } + function openEditDisplayNameModal(item: AdminUserListItem) { + const form = ModalFormStore.create() + + modal.confirm({ + title: '修改姓名', + content: ( +
+ + + +
+ ), + okText: '保存', + cancelText: '取消', + centered: true, + onOk: async () => { + const values = await form.validate() + setActionLoadingId(item.userId) + try { + const response = await updateAdminUserDisplayName(item.userId, { + displayName: values.displayName?.trim() || '', + }) + if (item.userId === currentUserId) { + setAdminDisplayName(response.data.user.displayName) + } + message.success('用户姓名已更新') + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['admin-session'] }), + queryClient.invalidateQueries({ queryKey: ['admin-users'] }), + queryClient.invalidateQueries({ queryKey: ['admin-audit-logs'] }), + queryClient.invalidateQueries({ queryKey: ['admin-login-logs'] }), + queryClient.invalidateQueries({ queryKey: ['admin-fund-overview'] }), + queryClient.invalidateQueries({ queryKey: ['admin-fund-accounts'] }), + queryClient.invalidateQueries({ queryKey: ['admin-fund-expenses'] }), + queryClient.invalidateQueries({ queryKey: ['admin-fund-reconciliations'] }), + queryClient.invalidateQueries({ queryKey: ['admin-fund-account-detail'] }), + queryClient.invalidateQueries({ queryKey: ['admin-fund-options'] }), + queryClient.invalidateQueries({ queryKey: ['admin-fund-my'] }), + ]) + await reloadUsers() + } catch (error) { + message.error(error instanceof Error ? error.message : '更新用户姓名失败') + } finally { + setActionLoadingId(null) + } + }, + }) + } + function openResetPasswordModal(item: AdminUserListItem) { const form = ModalFormStore.create<{ password: string }>() @@ -197,7 +264,7 @@ export default function AdminUsersPage() { > ( + title: '姓名', + dataIndex: 'displayName', + minWidth: 160, + render: (value: string, row) => ( - {value} + {value || row.username} {row.userId === currentUserId ? 当前账号 : null} ), }, + { + title: '账号', + dataIndex: 'username', + minWidth: 140, + render: (value: string) => {value}, + }, { title: '角色', dataIndex: 'role', @@ -287,7 +360,7 @@ export default function AdminUsersPage() { }, { title: '操作', - width: 310, + width: 390, render: (_, row) => { const isCurrent = row.userId === currentUserId const roleMenuItems: MenuProps['items'] = USER_ROLE_OPTIONS.map((item) => ({ @@ -298,6 +371,12 @@ export default function AdminUsersPage() { return ( + @@ -347,7 +426,7 @@ export default function AdminUsersPage() { className="filter-form users-action-form" > - + + - + - 共 {data?.pagination.total || 0} 个账号 + 共 {data?.pagination.total || 0} 个用户
} > @@ -434,7 +516,7 @@ export default function AdminUsersPage() { columns={columns} dataSource={data?.items || []} locale={{ emptyText: }} - scroll={{ x: 1060 }} + scroll={{ x: 1220 }} pagination={buildAdminTablePagination({ current: data?.pagination.page || page, pageSize: data?.pagination.pageSize || pageSize, @@ -487,6 +569,10 @@ function formatRoleLabel(role: AdminRole) { return '普通运营' } +function displayNameOf(user: AdminUserListItem) { + return user.displayName || user.username +} + function cleanParams(params: Record) { const next = new URLSearchParams() Object.entries(params).forEach(([key, value]) => { diff --git a/apps/frontend/src/pages/admin/fund/FundManagementView.tsx b/apps/frontend/src/pages/admin/fund/FundManagementView.tsx new file mode 100644 index 00000000..b47a2007 --- /dev/null +++ b/apps/frontend/src/pages/admin/fund/FundManagementView.tsx @@ -0,0 +1,1793 @@ +import { + CheckCircleOutlined, + PlusOutlined, + ReloadOutlined, + SwapOutlined, + WalletOutlined, +} from '@ant-design/icons' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { + Alert, + App, + Button, + Card, + Checkbox, + Col, + DatePicker, + Descriptions, + Drawer, + Form, + Input, + InputNumber, + Modal, + Row, + Select, + Space, + Statistic, + Table, + Tabs, + Tag, + Typography, +} from 'antd' +import type { TableColumnsType } from 'antd' +import dayjs from 'dayjs' +import type { Dayjs } from 'dayjs' +import { useState } from 'react' + +import ImagePreviewList from '@/components/files/ImagePreviewList' +import ImageUpload from '@/components/files/ImageUpload' +import PageHeader from '@/components/admin/PageHeader' +import { + confirmAdminFundReconciliation, + createAdminFundAccount, + createAdminFundExpense, + createAdminFundFunding, + createAdminFundIssue, + createAdminFundReconciliation, + createAdminFundReturn, + ensureAdminFundAgentAccounts, + fetchAdminFundAccountDetail, + fetchAdminFundAccounts, + fetchAdminFundExpenses, + fetchAdminFundOverview, + fetchAdminFundReconciliations, + fetchAdminFundSetupOptions, + updateAdminFundAccount, +} from '@/services/admin' +import type { + AdminFundAccount, + AdminFundEntry, + AdminFundExpense, + AdminFundExpenseCategory, + AdminFundPayeeType, + AdminFundReconciliation, + AdminFundSetupUser, +} from '@/types/admin' +import type { UploadedFile } from '@/types/worker-platform' +import { formatAdminDateTime } from '@/utils/admin-time' +import { buildAdminTablePagination } from '@/utils/admin-pagination' +import { + FUND_ACCOUNT_STATUS_LABELS, + FUND_CATEGORY_LABELS, + FUND_ENTRY_COLORS, + FUND_ENTRY_LABELS, + FUND_PAYEE_LABELS, + FUND_RECONCILIATION_STATUS_COLORS, + FUND_RECONCILIATION_STATUS_LABELS, + formatFundAmount, + fromFen, + toFen, +} from './fund-labels' + +type ManagementRole = 'admin' | 'operator' | 'finance' + +type IssueFormValues = { + agentAccountId: number + supervisorAccountId?: number + amountYuan: number + occurredAt: Dayjs + evidence: UploadedFile[] + note?: string +} + +type FundingFormValues = { + supervisorAccountId: number + amountYuan: number + occurredAt: Dayjs + evidence: UploadedFile[] + note?: string +} + +type ExpenseFormValues = { + agentAccountId: number + amountYuan: number + category: AdminFundExpenseCategory + payeeType: AdminFundPayeeType + payeeName: string + payeeAccount?: string + platformName?: string + platformAccount?: string + orderId?: number + paidAt: Dayjs + evidence: UploadedFile[] + note?: string + allowDuplicate?: boolean +} + +type ReturnFormValues = { + agentAccountId: number + supervisorAccountId?: number + amountYuan: number + occurredAt: Dayjs + evidence: UploadedFile[] + note?: string +} + +type ReconcileFormValues = { + accountId: number + period: [Dayjs, Dayjs] + actualBalanceYuan: number + reason?: string + evidence: UploadedFile[] +} + +type ConfirmReconcileFormValues = { + reason?: string + evidence: UploadedFile[] +} + +type CreateAccountFormValues = { + accountType: 'supervisor' | 'agent' + ownerAdminUserId: number + responsibleSupervisorAccountId?: number + openingBalanceYuan?: number +} + +type BulkAgentsFormValues = { + responsibleSupervisorAccountId: number + openingBalanceYuan?: number +} + +export default function FundManagementView({ role }: { role: ManagementRole }) { + const { message } = App.useApp() + const queryClient = useQueryClient() + const isAdmin = role === 'admin' + const canIssue = role === 'admin' || role === 'operator' + const canReconcile = role === 'admin' || role === 'operator' || role === 'finance' + const canExpense = role === 'admin' + + const [activeTab, setActiveTab] = useState('overview') + const [submitting, setSubmitting] = useState(false) + const [issueOpen, setIssueOpen] = useState(false) + const [fundingOpen, setFundingOpen] = useState(false) + const [expenseOpen, setExpenseOpen] = useState(false) + const [returnOpen, setReturnOpen] = useState(false) + const [reconcileOpen, setReconcileOpen] = useState(false) + const [confirmReconcileOpen, setConfirmReconcileOpen] = useState(false) + const [createAccountOpen, setCreateAccountOpen] = useState(false) + const [bulkAgentsOpen, setBulkAgentsOpen] = useState(false) + const [detailAccount, setDetailAccount] = useState(null) + const [confirmReconciliation, setConfirmReconciliation] = + useState(null) + + const [expensePage, setExpensePage] = useState(1) + const [expensePageSize, setExpensePageSize] = useState(20) + const [expenseKeyword, setExpenseKeyword] = useState('') + const [expenseCategory, setExpenseCategory] = useState('') + const [expenseRange, setExpenseRange] = useState<[Dayjs, Dayjs] | null>(null) + + const [reconciliationPage, setReconciliationPage] = useState(1) + const [reconciliationPageSize, setReconciliationPageSize] = useState(20) + const [reconciliationStatus, setReconciliationStatus] = useState('') + + const [issueForm] = Form.useForm() + const [fundingForm] = Form.useForm() + const [expenseForm] = Form.useForm() + const [returnForm] = Form.useForm() + const [reconcileForm] = Form.useForm() + const [confirmReconcileForm] = Form.useForm() + const [createAccountForm] = Form.useForm() + const [bulkAgentsForm] = Form.useForm() + + const createAccountType = Form.useWatch('accountType', createAccountForm) || 'agent' + + const overviewQuery = useQuery({ + queryKey: ['admin-fund-overview'], + queryFn: fetchAdminFundOverview, + }) + const agentsQuery = useQuery({ + queryKey: ['admin-fund-accounts', 'agent'], + queryFn: () => fetchAdminFundAccounts({ accountType: 'agent', pageSize: 100 }), + }) + const supervisorsQuery = useQuery({ + queryKey: ['admin-fund-accounts', 'supervisor'], + queryFn: () => fetchAdminFundAccounts({ accountType: 'supervisor', pageSize: 100 }), + }) + const expensesQuery = useQuery({ + queryKey: [ + 'admin-fund-expenses', + expensePage, + expensePageSize, + expenseKeyword, + expenseCategory, + expenseRange?.[0]?.format('YYYY-MM-DD HH:mm:ss') || '', + expenseRange?.[1]?.format('YYYY-MM-DD HH:mm:ss') || '', + ], + queryFn: () => + fetchAdminFundExpenses({ + page: expensePage, + pageSize: expensePageSize, + keyword: expenseKeyword || undefined, + category: expenseCategory || undefined, + dateFrom: expenseRange?.[0]?.format('YYYY-MM-DD HH:mm:ss'), + dateTo: expenseRange?.[1]?.format('YYYY-MM-DD HH:mm:ss'), + }), + }) + const reconciliationsQuery = useQuery({ + queryKey: [ + 'admin-fund-reconciliations', + reconciliationPage, + reconciliationPageSize, + reconciliationStatus, + ], + queryFn: () => + fetchAdminFundReconciliations({ + page: reconciliationPage, + pageSize: reconciliationPageSize, + status: reconciliationStatus || undefined, + }), + }) + const setupQuery = useQuery({ + queryKey: ['admin-fund-options'], + queryFn: fetchAdminFundSetupOptions, + enabled: isAdmin, + }) + const detailQuery = useQuery({ + queryKey: ['admin-fund-account-detail', detailAccount?.id], + queryFn: () => fetchAdminFundAccountDetail(detailAccount!.id, { page: 1, pageSize: 100 }), + enabled: Boolean(detailAccount?.id), + }) + + const overview = overviewQuery.data?.data + const agentAccounts = agentsQuery.data?.data.items || [] + const supervisorAccounts = supervisorsQuery.data?.data.items || [] + const expenses = expensesQuery.data?.data.items || [] + const expensePagination = expensesQuery.data?.data.pagination + const reconciliations = reconciliationsQuery.data?.data.items || [] + const reconciliationPagination = reconciliationsQuery.data?.data.pagination + const setup = setupQuery.data?.data + + const agentOptions = agentAccounts.map((account) => ({ + value: account.id, + label: `${account.ownerName}(余额 ${formatFundAmount(account.balanceFen)})`, + })) + const supervisorOptions = supervisorAccounts.map((account) => ({ + value: account.id, + label: `${account.ownerName}(余额 ${formatFundAmount(account.balanceFen)})`, + })) + + function refreshAll() { + void queryClient.invalidateQueries({ queryKey: ['admin-fund-overview'] }) + void queryClient.invalidateQueries({ queryKey: ['admin-fund-accounts'] }) + void queryClient.invalidateQueries({ queryKey: ['admin-fund-expenses'] }) + void queryClient.invalidateQueries({ queryKey: ['admin-fund-reconciliations'] }) + void queryClient.invalidateQueries({ queryKey: ['admin-fund-account-detail'] }) + void queryClient.invalidateQueries({ queryKey: ['admin-fund-options'] }) + void queryClient.invalidateQueries({ queryKey: ['admin-fund-my'] }) + } + + function openIssue(account?: AdminFundAccount) { + issueForm.resetFields() + issueForm.setFieldsValue({ + agentAccountId: + account?.accountType === 'agent' + ? account.id + : (agentAccounts[0]?.id as number | undefined), + supervisorAccountId: + account?.accountType === 'supervisor' + ? account.id + : (supervisorAccounts[0]?.id as number | undefined), + occurredAt: dayjs(), + evidence: [], + }) + setIssueOpen(true) + } + + function openFunding(account?: AdminFundAccount) { + fundingForm.resetFields() + fundingForm.setFieldsValue({ + supervisorAccountId: account?.id || (supervisorAccounts[0]?.id as number | undefined), + occurredAt: dayjs(), + evidence: [], + }) + setFundingOpen(true) + } + + function openExpense(account?: AdminFundAccount) { + expenseForm.resetFields() + expenseForm.setFieldsValue({ + agentAccountId: + account?.accountType === 'agent' + ? account.id + : (agentAccounts[0]?.id as number | undefined), + category: 'platform_recharge', + payeeType: 'platform', + paidAt: dayjs(), + evidence: [], + allowDuplicate: false, + }) + setExpenseOpen(true) + } + + function openReturn(account?: AdminFundAccount) { + returnForm.resetFields() + returnForm.setFieldsValue({ + agentAccountId: + account?.accountType === 'agent' + ? account.id + : (agentAccounts[0]?.id as number | undefined), + supervisorAccountId: + account?.accountType === 'supervisor' + ? account.id + : (supervisorAccounts[0]?.id as number | undefined), + occurredAt: dayjs(), + evidence: [], + }) + setReturnOpen(true) + } + + function openReconcile(account?: AdminFundAccount) { + reconcileForm.resetFields() + reconcileForm.setFieldsValue({ + accountId: + account?.accountType === 'agent' + ? account.id + : (agentAccounts[0]?.id as number | undefined), + period: [dayjs().subtract(7, 'day'), dayjs()], + actualBalanceYuan: account ? fromFen(account.balanceFen) : 0, + evidence: [], + }) + setReconcileOpen(true) + } + + function openConfirmReconcile(reconciliation: AdminFundReconciliation) { + setConfirmReconciliation(reconciliation) + confirmReconcileForm.resetFields() + confirmReconcileForm.setFieldsValue({ + reason: reconciliation.reason || '', + evidence: [], + }) + setConfirmReconcileOpen(true) + } + + async function submitIssue(values: IssueFormValues) { + setSubmitting(true) + try { + await createAdminFundIssue(values.agentAccountId, { + agentAccountId: values.agentAccountId, + supervisorAccountId: values.supervisorAccountId, + amountFen: toFen(values.amountYuan), + occurredAt: values.occurredAt.toISOString(), + evidence: values.evidence || [], + note: values.note || '', + }) + message.success('备用金已发放') + setIssueOpen(false) + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '发放备用金失败') + } finally { + setSubmitting(false) + } + } + + async function submitFunding(values: FundingFormValues) { + setSubmitting(true) + try { + await createAdminFundFunding(values.supervisorAccountId, { + supervisorAccountId: values.supervisorAccountId, + amountFen: toFen(values.amountYuan), + occurredAt: values.occurredAt.toISOString(), + evidence: values.evidence || [], + note: values.note || '', + }) + message.success('主管资金池已拨付') + setFundingOpen(false) + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '拨付失败') + } finally { + setSubmitting(false) + } + } + + async function submitExpense(values: ExpenseFormValues) { + setSubmitting(true) + try { + await createAdminFundExpense(values.agentAccountId, { + amountFen: toFen(values.amountYuan), + category: values.category, + payeeType: values.payeeType, + payeeName: values.payeeName, + payeeAccount: values.payeeAccount || '', + platformName: values.platformName || '', + platformAccount: values.platformAccount || '', + orderId: values.orderId || null, + paidAt: values.paidAt.toISOString(), + evidence: values.evidence || [], + note: values.note || '', + allowDuplicate: values.allowDuplicate === true, + }) + message.success('支出已登记') + setExpenseOpen(false) + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '登记支出失败') + } finally { + setSubmitting(false) + } + } + + async function submitReturn(values: ReturnFormValues) { + setSubmitting(true) + try { + await createAdminFundReturn(values.agentAccountId, { + agentAccountId: values.agentAccountId, + supervisorAccountId: values.supervisorAccountId, + amountFen: toFen(values.amountYuan), + occurredAt: values.occurredAt.toISOString(), + evidence: values.evidence || [], + note: values.note || '', + }) + message.success('备用金已缴回') + setReturnOpen(false) + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '缴回失败') + } finally { + setSubmitting(false) + } + } + + async function submitReconcile(values: ReconcileFormValues) { + setSubmitting(true) + try { + await createAdminFundReconciliation({ + accountId: values.accountId, + periodStart: values.period[0].toISOString(), + periodEnd: values.period[1].toISOString(), + actualBalanceFen: toFen(values.actualBalanceYuan), + reason: values.reason || '', + evidence: values.evidence || [], + }) + message.success('对账已发起') + setReconcileOpen(false) + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '发起对账失败') + } finally { + setSubmitting(false) + } + } + + async function submitConfirmReconcile(values: ConfirmReconcileFormValues) { + if (!confirmReconciliation) return + setSubmitting(true) + try { + await confirmAdminFundReconciliation(confirmReconciliation.id, { + reason: values.reason || '', + evidence: values.evidence || [], + }) + message.success('对账已确认') + setConfirmReconcileOpen(false) + setConfirmReconciliation(null) + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '确认对账失败') + } finally { + setSubmitting(false) + } + } + + async function submitCreateAccount(values: CreateAccountFormValues) { + setSubmitting(true) + try { + await createAdminFundAccount({ + accountType: values.accountType, + ownerAdminUserId: values.ownerAdminUserId, + responsibleSupervisorAccountId: values.responsibleSupervisorAccountId, + openingBalanceFen: toFen(values.openingBalanceYuan || 0), + }) + message.success('备用金账户已创建') + setCreateAccountOpen(false) + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '创建账户失败') + } finally { + setSubmitting(false) + } + } + + async function submitBulkAgents(values: BulkAgentsFormValues) { + setSubmitting(true) + try { + const result = await ensureAdminFundAgentAccounts({ + responsibleSupervisorAccountId: values.responsibleSupervisorAccountId, + openingBalanceFen: toFen(values.openingBalanceYuan || 0), + }) + message.success(`已创建 ${result.data.createdCount} 个客服账户`) + setBulkAgentsOpen(false) + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '批量初始化失败') + } finally { + setSubmitting(false) + } + } + + async function toggleAccountStatus(account: AdminFundAccount) { + setSubmitting(true) + try { + await updateAdminFundAccount(account.id, { + status: account.status === 'active' ? 'frozen' : 'active', + }) + message.success(account.status === 'active' ? '账户已冻结' : '账户已启用') + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '更新账户状态失败') + } finally { + setSubmitting(false) + } + } + + function buildAccountColumns(type: 'agent' | 'supervisor'): TableColumnsType { + return [ + { title: '账户编号', dataIndex: 'accountNo', width: 150 }, + { title: '姓名', dataIndex: 'ownerName', width: 120 }, + ...(type === 'agent' + ? [ + { + title: '责任主管', + dataIndex: 'responsibleSupervisorName', + width: 120, + render: (value: string) => value || '-', + }, + ] + : []), + { + title: '当前余额', + dataIndex: 'balanceFen', + width: 130, + render: (value: number) => ( + {formatFundAmount(value)} + ), + }, + { + title: '本月流入', + dataIndex: 'monthInFen', + width: 120, + render: (value: number) => formatFundAmount(value), + }, + { + title: '本月流出', + dataIndex: 'monthOutFen', + width: 120, + render: (value: number) => formatFundAmount(value), + }, + { + title: '最后流水', + dataIndex: 'lastEntryAt', + width: 170, + render: (value: string | null) => (value ? formatAdminDateTime(value) : '-'), + }, + { + title: '最后对账', + dataIndex: 'lastReconciledAt', + width: 170, + render: (value: string | null) => (value ? formatAdminDateTime(value) : '-'), + }, + { + title: '状态', + dataIndex: 'status', + width: 90, + render: (value: string) => ( + + {FUND_ACCOUNT_STATUS_LABELS[value] || value} + + ), + }, + { + title: '操作', + key: 'actions', + width: 260, + fixed: 'right', + render: (_value, record) => ( + + + {isAdmin && type === 'supervisor' ? ( + + ) : null} + {canIssue ? ( + + ) : null} + {type === 'agent' && canIssue ? ( + + ) : null} + {type === 'agent' && canReconcile ? ( + + ) : null} + {isAdmin ? ( + + ) : null} + + ), + }, + ] + } + + const expenseColumns: TableColumnsType = [ + { + title: '付款时间', + dataIndex: 'paidAt', + width: 170, + render: (value: string) => formatAdminDateTime(value), + }, + { title: '支出单号', dataIndex: 'expenseNo', width: 150 }, + { title: '客服', dataIndex: 'agentName', width: 110 }, + { + title: '分类', + dataIndex: 'category', + width: 110, + render: (value: AdminFundExpenseCategory) => FUND_CATEGORY_LABELS[value] || value, + }, + { + title: '收款方', + dataIndex: 'payeeName', + width: 180, + render: (_value: string, row) => ( + + {row.payeeName} + + {FUND_PAYEE_LABELS[row.payeeType]} + {row.payeeAccount ? ` · ${row.payeeAccount}` : ''} + + + ), + }, + { + title: '平台/账号', + dataIndex: 'platformName', + width: 180, + render: (_value: string, row) => + row.platformName || row.platformAccount + ? `${row.platformName || '-'}${row.platformAccount ? ` / ${row.platformAccount}` : ''}` + : '-', + }, + { + title: '关联订单', + dataIndex: 'orderId', + width: 100, + render: (value: number | null) => (value ? `#${value}` : '-'), + }, + { + title: '金额', + dataIndex: 'amountFen', + width: 120, + render: (value: number) => ( + {formatFundAmount(value)} + ), + }, + { + title: '凭证', + dataIndex: 'evidence', + width: 100, + render: (value: UploadedFile[]) => ( + + ), + }, + { title: '说明', dataIndex: 'note', render: (value: string) => value || '-' }, + ] + + const reconciliationColumns: TableColumnsType = [ + { + title: '对账区间', + dataIndex: 'periodStart', + width: 230, + render: (_value: string, row) => + `${formatAdminDateTime(row.periodStart)} ~ ${formatAdminDateTime(row.periodEnd)}`, + }, + { title: '对账单号', dataIndex: 'reconciliationNo', width: 150 }, + { title: '客服', dataIndex: 'agentName', width: 110 }, + { + title: '系统余额', + dataIndex: 'systemBalanceFen', + width: 120, + render: (value: number) => formatFundAmount(value), + }, + { + title: '实际余额', + dataIndex: 'actualBalanceFen', + width: 120, + render: (value: number) => formatFundAmount(value), + }, + { + title: '差异', + dataIndex: 'differenceFen', + width: 120, + render: (value: number) => ( + + {formatFundAmount(value, true)} + + ), + }, + { + title: '状态', + dataIndex: 'status', + width: 100, + render: (value: AdminFundReconciliation['status']) => ( + + {FUND_RECONCILIATION_STATUS_LABELS[value]} + + ), + }, + { + title: '原因', + dataIndex: 'reason', + render: (value: string) => value || '-', + }, + { + title: '确认人', + dataIndex: 'confirmedByUsername', + width: 110, + render: (value: string, row) => + value + ? `${value}${row.confirmedAt ? ` · ${formatAdminDateTime(row.confirmedAt)}` : ''}` + : '-', + }, + { + title: '操作', + key: 'actions', + width: 100, + fixed: 'right', + render: (_value, row) => + row.status === 'confirmed' ? ( + 已确认 + ) : ( + + ), + }, + ] + + const detailEntries = detailQuery.data?.data.items || [] + + const detailEntryColumns: TableColumnsType = [ + { + title: '时间', + dataIndex: 'occurredAt', + width: 170, + render: (value: string) => formatAdminDateTime(value), + }, + { + title: '类型', + dataIndex: 'entryType', + width: 110, + render: (value: AdminFundEntry['entryType']) => ( + {FUND_ENTRY_LABELS[value]} + ), + }, + { + title: '金额', + dataIndex: 'amountFen', + width: 120, + render: (value: number) => ( + = 0 ? 'success' : 'danger'}> + {formatFundAmount(value, true)} + + ), + }, + { + title: '余额', + dataIndex: 'balanceAfterFen', + width: 120, + render: (value: number) => formatFundAmount(value), + }, + { + title: '对方账户', + dataIndex: 'counterpartyName', + width: 130, + render: (value: string) => value || '-', + }, + { + title: '凭证', + dataIndex: 'evidence', + width: 100, + render: (value: UploadedFile[]) => ( + + ), + }, + { title: '说明', dataIndex: 'note', render: (value: string) => value || '-' }, + ] + + const setupSupportUsersWithoutAccount = + setup?.users.filter( + (user) => + user.role === 'support' && + !setup.agentAccounts.some((account) => account.ownerAdminUserId === user.id), + ) || [] + const setupSupervisorUsersWithoutAccount = + setup?.users.filter( + (user) => + (user.role === 'operator' || user.role === 'admin') && + !setup.supervisorAccounts.some((account) => account.ownerAdminUserId === user.id), + ) || [] + + function openCreateAccount(prefill?: Partial) { + createAccountForm.resetFields() + createAccountForm.setFieldsValue({ + accountType: 'agent', + openingBalanceYuan: 0, + ...prefill, + }) + setCreateAccountOpen(true) + } + + function openBulkAgents() { + bulkAgentsForm.resetFields() + bulkAgentsForm.setFieldsValue({ + responsibleSupervisorAccountId: supervisorAccounts[0]?.id, + openingBalanceYuan: 0, + }) + setBulkAgentsOpen(true) + } + + const createAccountUserOptions = + createAccountType === 'supervisor' + ? (setup?.users || []).filter( + (user) => + (user.role === 'operator' || user.role === 'admin') && + !setup?.supervisorAccounts.some((account) => account.ownerAdminUserId === user.id), + ) + : (setup?.users || []).filter( + (user) => + user.role === 'support' && + !setup?.agentAccounts.some((account) => account.ownerAdminUserId === user.id), + ) + + return ( +
+ } loading={overviewQuery.isFetching} onClick={refreshAll}> + 刷新 + + } + /> + + {overviewQuery.error ? ( + + ) : null} + + + + + + } + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 ? '#cf1322' : undefined, + }} + /> + + + + + + + {canIssue ? ( + + ) : null} + {canExpense ? ( + + ) : null} + {canReconcile ? ( + + ) : null} + {isAdmin ? : null} + + + + {(overview?.unreconciledAgentCount || 0) > 0 ? ( + + ) : null} + + ), + }, + { + key: 'agents', + label: '客服余额', + children: ( + openIssue()}> + 发放备用金 + + ) : null + } + > + + rowKey="id" + size="small" + columns={buildAccountColumns('agent')} + dataSource={agentAccounts} + loading={agentsQuery.isLoading} + pagination={false} + scroll={{ x: 1250 }} + /> + + ), + }, + { + key: 'supervisors', + label: '主管资金池', + children: ( + openFunding()}> + 公司拨付 + + ) : null + } + > + + rowKey="id" + size="small" + columns={buildAccountColumns('supervisor')} + dataSource={supervisorAccounts} + loading={supervisorsQuery.isLoading} + pagination={false} + scroll={{ x: 1150 }} + /> + + ), + }, + { + key: 'expenses', + label: '支出明细', + children: ( + openExpense()}> + 登记支出 + + ) : null + } + > + + { + setExpensePage(1) + setExpenseKeyword(event.target.value) + }} + style={{ width: 260 }} + /> + { + setReconciliationPage(1) + setReconciliationStatus(value || '') + }} + style={{ width: 140 }} + options={Object.entries(FUND_RECONCILIATION_STATUS_LABELS).map( + ([value, label]) => ({ value, label }), + )} + /> + + + rowKey="id" + size="small" + columns={reconciliationColumns} + dataSource={reconciliations} + loading={reconciliationsQuery.isLoading} + pagination={buildAdminTablePagination({ + current: reconciliationPage, + pageSize: reconciliationPageSize, + total: reconciliationPagination?.total || 0, + onChange: (page, pageSize) => { + setReconciliationPage(page || 1) + setReconciliationPageSize(pageSize || 20) + }, + })} + scroll={{ x: 1250 }} + /> + + ), + }, + ...(isAdmin + ? [ + { + key: 'setup', + label: '账户初始化', + children: ( + + + + + } + > + + rowKey="id" + size="small" + columns={buildAccountColumns('supervisor')} + dataSource={setup?.supervisorAccounts || []} + loading={setupQuery.isLoading} + pagination={false} + scroll={{ x: 1150 }} + /> + {setupSupervisorUsersWithoutAccount.length > 0 ? ( + user.displayName || user.username) + .join('、')} + /> + ) : null} + + + + + + + } + > + + rowKey="id" + size="small" + columns={buildAccountColumns('agent')} + dataSource={setup?.agentAccounts || []} + loading={setupQuery.isLoading} + pagination={false} + scroll={{ x: 1250 }} + /> + {setupSupportUsersWithoutAccount.length > 0 ? ( + user.displayName || user.username) + .join('、')} + /> + ) : null} + + + ), + }, + ] + : []), + ]} + /> + + setIssueOpen(false)} + onOk={() => issueForm.submit()} + confirmLoading={submitting} + destroyOnHidden + width={720} + > + form={issueForm} layout="vertical" onFinish={submitIssue}> + + + + + + + ) : null} + + + + + + + + + + + + + + + + + + + + + + + + + setFundingOpen(false)} + onOk={() => fundingForm.submit()} + confirmLoading={submitting} + destroyOnHidden + > + form={fundingForm} layout="vertical" onFinish={submitFunding}> + + + + + + + + + + + + ({ + value, + label, + }))} + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 我已确认这不是重复支出(同一客服、金额、收款方 24 小时内) + + + + + + + setReturnOpen(false)} + onOk={() => returnForm.submit()} + confirmLoading={submitting} + destroyOnHidden + width={680} + > + form={returnForm} layout="vertical" onFinish={submitReturn}> + + + + + + + ) : null} + + + + + + + + + + + + + + + + + + + + + + + + + setReconcileOpen(false)} + onOk={() => reconcileForm.submit()} + confirmLoading={submitting} + destroyOnHidden + width={680} + > + + form={reconcileForm} + layout="vertical" + onFinish={submitReconcile} + > + + createAccountForm.setFieldsValue({ ownerAdminUserId: undefined })} + /> + + + + + ) : null} + + + + + + + setBulkAgentsOpen(false)} + onOk={() => bulkAgentsForm.submit()} + confirmLoading={submitting} + destroyOnHidden + > + + + form={bulkAgentsForm} + layout="vertical" + onFinish={submitBulkAgents} + > + + ({ + value, + label, + }))} + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 我已确认这不是重复支出(同一客服、金额、收款方 24 小时内) + + + + + + + setReturnOpen(false)} + onOk={() => returnForm.submit()} + confirmLoading={submitting} + destroyOnHidden + > + form={returnForm} layout="vertical" onFinish={submitReturn}> + + + + + + + + + + + + + + +
+ ) +} diff --git a/apps/frontend/src/pages/admin/fund/fund-labels.ts b/apps/frontend/src/pages/admin/fund/fund-labels.ts new file mode 100644 index 00000000..2d1bdeeb --- /dev/null +++ b/apps/frontend/src/pages/admin/fund/fund-labels.ts @@ -0,0 +1,77 @@ +import type { + AdminFundEntryType, + AdminFundExpenseCategory, + AdminFundPayeeType, + AdminFundReconciliationStatus, +} from '@/types/admin' + +export const FUND_ENTRY_LABELS: Record = { + opening: '期初', + funding: '公司拨付', + issue: '发放', + expense: '支出', + return: '缴回', + adjustment: '调整', +} + +export const FUND_ENTRY_COLORS: Record = { + opening: 'default', + funding: 'blue', + issue: 'green', + expense: 'red', + return: 'orange', + adjustment: 'purple', +} + +export const FUND_CATEGORY_LABELS: Record = { + platform_recharge: '平台充值', + purchase: '采购/拿货', + compensation: '客户赔偿', + freight: '运费', + other: '其他', +} + +export const FUND_PAYEE_LABELS: Record = { + platform: '平台', + supplier: '供应商', + customer: '客户', + other: '其他', +} + +export const FUND_ACCOUNT_STATUS_LABELS: Record = { + active: '正常', + frozen: '冻结', + closed: '关闭', +} + +export const FUND_RECONCILIATION_STATUS_LABELS: Record = { + pending: '待确认', + confirmed: '已对平', + discrepancy: '有差异', +} + +export const FUND_RECONCILIATION_STATUS_COLORS: Record = { + pending: 'gold', + confirmed: 'green', + discrepancy: 'red', +} + +export function formatFundAmount(amountFen: number, withSign = false) { + const value = Number(amountFen || 0) / 100 + const absolute = Math.abs(value).toFixed(2) + if (withSign) { + if (value > 0) return `+¥${absolute}` + if (value < 0) return `-¥${absolute}` + } + return value < 0 ? `-¥${absolute}` : `¥${absolute}` +} + +export function toFen(value: unknown) { + const yuan = Number(value) + return Number.isFinite(yuan) ? Math.round(yuan * 100) : 0 +} + +export function fromFen(value: unknown) { + const fen = Number(value) + return Number.isFinite(fen) ? fen / 100 : 0 +} diff --git a/apps/frontend/src/services/admin/fund.ts b/apps/frontend/src/services/admin/fund.ts new file mode 100644 index 00000000..c6f15b60 --- /dev/null +++ b/apps/frontend/src/services/admin/fund.ts @@ -0,0 +1,105 @@ +import { apiGet, apiPatch, apiPost } from '@/lib/http' +import type { + AdminFundAccountDetailResult, + AdminFundAccountListResult, + AdminFundExpenseListResult, + AdminFundMyAccountResult, + AdminFundOverview, + AdminFundReconciliationListResult, + AdminFundSetupOptions, +} from '@/types/admin' + +export function fetchAdminFundOverview() { + return apiGet('/api/v1/admin/fund/overview') +} + +export function fetchAdminFundMyAccount() { + return apiGet('/api/v1/admin/fund/my') +} + +export function fetchAdminFundAccounts(params?: Record) { + return apiGet('/api/v1/admin/fund/accounts', params) +} + +export function fetchAdminFundAccountDetail(accountId: number, params?: Record) { + return apiGet(`/api/v1/admin/fund/accounts/${accountId}`, params) +} + +export function fetchAdminFundSupervisors() { + return apiGet<{ items: AdminFundAccountListResult['items'] }>('/api/v1/admin/fund/supervisors') +} + +export function fetchAdminFundSetupOptions() { + return apiGet('/api/v1/admin/fund/options') +} + +export function createAdminFundAccount(payload: Record) { + return apiPost<{ account: AdminFundAccountListResult['items'][number] }>( + '/api/v1/admin/fund/accounts', + payload, + ) +} + +export function ensureAdminFundAgentAccounts(payload: Record) { + return apiPost<{ + createdCount: number + items: Array<{ id: number; accountNo: string; ownerName: string }> + }>('/api/v1/admin/fund/accounts/ensure-agents', payload) +} + +export function updateAdminFundAccount(accountId: number, payload: Record) { + return apiPatch<{ account: AdminFundAccountListResult['items'][number] }>( + `/api/v1/admin/fund/accounts/${accountId}`, + payload, + ) +} + +export function createAdminFundFunding(accountId: number, payload: Record) { + return apiPost>( + `/api/v1/admin/fund/accounts/${accountId}/funding`, + payload, + ) +} + +export function createAdminFundIssue(accountId: number, payload: Record) { + return apiPost>(`/api/v1/admin/fund/accounts/${accountId}/issue`, payload) +} + +export function createAdminFundReturn(accountId: number, payload: Record) { + return apiPost>( + `/api/v1/admin/fund/accounts/${accountId}/return`, + payload, + ) +} + +export function createAdminFundExpense(accountId: number, payload: Record) { + return apiPost<{ expense: AdminFundExpenseListResult['items'][number] }>( + `/api/v1/admin/fund/accounts/${accountId}/expenses`, + payload, + ) +} + +export function fetchAdminFundExpenses(params?: Record) { + return apiGet('/api/v1/admin/fund/expenses', params) +} + +export function fetchAdminFundReconciliations(params?: Record) { + return apiGet('/api/v1/admin/fund/reconciliations', params) +} + +export function createAdminFundReconciliation(payload: Record) { + return apiPost<{ reconciliation: AdminFundReconciliationListResult['items'][number] }>( + '/api/v1/admin/fund/reconciliations', + payload, + ) +} + +export function confirmAdminFundReconciliation( + reconciliationId: number, + payload: Record, +) { + return apiPost<{ reconciliation: AdminFundReconciliationListResult['items'][number] }>( + `/api/v1/admin/fund/reconciliations/${reconciliationId}/confirm`, + payload, + ) +} diff --git a/apps/frontend/src/services/admin/index.ts b/apps/frontend/src/services/admin/index.ts index 8c130b0c..1c560172 100644 --- a/apps/frontend/src/services/admin/index.ts +++ b/apps/frontend/src/services/admin/index.ts @@ -1,6 +1,7 @@ export * from './auth' export * from './dashboard' export * from './finance' +export * from './fund' export * from './users' export * from './audit-logs' export * from './platform-config' diff --git a/apps/frontend/src/services/admin/users.ts b/apps/frontend/src/services/admin/users.ts index 4b3c73d4..9276de6e 100644 --- a/apps/frontend/src/services/admin/users.ts +++ b/apps/frontend/src/services/admin/users.ts @@ -35,6 +35,7 @@ export function fetchAdminUsers(params?: Record) { export function createAdminUser(payload: { username: string + displayName?: string password: string role: string status?: string @@ -42,6 +43,13 @@ export function createAdminUser(payload: { return apiPost<{ user: AdminUserListItem }>('/api/v1/admin/users', payload) } +export function updateAdminUserDisplayName( + userId: number | string, + payload: { displayName: string }, +) { + return apiPost<{ user: AdminUserListItem }>(`/api/v1/admin/users/${userId}/display-name`, payload) +} + export function updateAdminUserRole(userId: number | string, payload: { role: string }) { return apiPost<{ user: AdminUserListItem }>(`/api/v1/admin/users/${userId}/role`, payload) } diff --git a/apps/frontend/src/types/admin/audit-logs.ts b/apps/frontend/src/types/admin/audit-logs.ts index d54fc10b..3388e272 100644 --- a/apps/frontend/src/types/admin/audit-logs.ts +++ b/apps/frontend/src/types/admin/audit-logs.ts @@ -4,6 +4,7 @@ export interface AdminAuditLogItem { logId: number actorUserId: number actorUsername: string + actorDisplayName: string actorRole: AdminRole action: string targetType: string diff --git a/apps/frontend/src/types/admin/auth.ts b/apps/frontend/src/types/admin/auth.ts index e88dd9e2..20b8a3ef 100644 --- a/apps/frontend/src/types/admin/auth.ts +++ b/apps/frontend/src/types/admin/auth.ts @@ -7,6 +7,7 @@ export interface AdminLoginResponse { user: { userId: number username: string + displayName: string role: AdminRole permissions?: string[] } @@ -18,6 +19,7 @@ export interface AdminSessionSummary { user: { userId: number username: string + displayName: string role: AdminRole permissions?: string[] } diff --git a/apps/frontend/src/types/admin/fund.ts b/apps/frontend/src/types/admin/fund.ts new file mode 100644 index 00000000..1f312ff0 --- /dev/null +++ b/apps/frontend/src/types/admin/fund.ts @@ -0,0 +1,160 @@ +import type { UploadedFile } from '@/types/worker-platform' + +export type AdminFundAccountType = 'supervisor' | 'agent' +export type AdminFundAccountStatus = 'active' | 'frozen' | 'closed' +export type AdminFundEntryType = + | 'opening' + | 'funding' + | 'issue' + | 'expense' + | 'return' + | 'adjustment' +export type AdminFundExpenseCategory = + | 'platform_recharge' + | 'purchase' + | 'compensation' + | 'freight' + | 'other' +export type AdminFundPayeeType = 'platform' | 'supplier' | 'customer' | 'other' +export type AdminFundReconciliationStatus = 'pending' | 'confirmed' | 'discrepancy' + +export type AdminFundAccount = { + id: number + accountNo: string + accountType: AdminFundAccountType + ownerAdminUserId: number + ownerName: string + responsibleSupervisorId: number | null + responsibleSupervisorName: string + balanceFen: number + status: AdminFundAccountStatus + monthInFen: number + monthOutFen: number + lastEntryAt: string | null + lastReconciledAt: string | null + createdAt: string + updatedAt: string +} + +export type AdminFundEntry = { + id: number + entryNo: string + accountId: number + entryType: AdminFundEntryType + amountFen: number + balanceAfterFen: number + transferNo: string + counterpartyAccountId: number | null + counterpartyName: string + reconciliationId: number | null + occurredAt: string + createdByAdminUserId: number | null + createdByUsername: string + evidence: UploadedFile[] + note: string + createdAt: string +} + +export type AdminFundExpense = { + id: number + expenseNo: string + accountId: number + accountNo: string + agentName: string + amountFen: number + category: AdminFundExpenseCategory + payeeType: AdminFundPayeeType + payeeName: string + payeeAccount: string + platformName: string + platformAccount: string + orderId: number | null + orderItemId: number | null + paidAt: string + evidence: UploadedFile[] + note: string + fundEntryId: number + createdByAdminUserId: number | null + createdByUsername: string + createdAt: string + updatedAt: string +} + +export type AdminFundReconciliation = { + id: number + reconciliationNo: string + accountId: number + accountNo: string + agentName: string + periodStart: string + periodEnd: string + systemBalanceFen: number + actualBalanceFen: number + differenceFen: number + status: AdminFundReconciliationStatus + reason: string + evidence: UploadedFile[] + confirmedByAdminUserId: number | null + confirmedByUsername: string + confirmedAt: string | null + createdByAdminUserId: number | null + createdByUsername: string + createdAt: string + updatedAt: string +} + +export type AdminFundOverview = { + supervisorBalanceFen: number + agentBalanceFen: number + totalBalanceFen: number + activeSupervisorCount: number + activeAgentCount: number + monthIssuedFen: number + monthExpenseFen: number + monthReturnedFen: number + pendingReconciliationCount: number + discrepancyCount: number + unreconciledAgentCount: number +} + +export type AdminFundAccountListResult = { + items: AdminFundAccount[] + pagination: { page: number; pageSize: number; total: number } +} + +export type AdminFundAccountDetailResult = { + account: AdminFundAccount + items: AdminFundEntry[] + pagination: { page: number; pageSize: number; total: number } +} + +export type AdminFundMyAccountResult = { + account: AdminFundAccount | null + entries: AdminFundEntry[] + expenses: AdminFundExpense[] + pagination: { page: number; pageSize: number; total: number } +} + +export type AdminFundExpenseListResult = { + items: AdminFundExpense[] + pagination: { page: number; pageSize: number; total: number } +} + +export type AdminFundReconciliationListResult = { + items: AdminFundReconciliation[] + pagination: { page: number; pageSize: number; total: number } +} + +export type AdminFundSetupUser = { + id: number + username: string + displayName: string + role: 'admin' | 'operator' | 'support' | 'finance' + status: string +} + +export type AdminFundSetupOptions = { + users: AdminFundSetupUser[] + supervisorAccounts: AdminFundAccount[] + agentAccounts: AdminFundAccount[] +} diff --git a/apps/frontend/src/types/admin/index.ts b/apps/frontend/src/types/admin/index.ts index 91be2bab..ec6c944f 100644 --- a/apps/frontend/src/types/admin/index.ts +++ b/apps/frontend/src/types/admin/index.ts @@ -23,6 +23,28 @@ export type { AdminFinanceTransactionResult, } from './finance' +// Fund / imprest account types +export type { + AdminFundAccount, + AdminFundAccountDetailResult, + AdminFundAccountListResult, + AdminFundAccountStatus, + AdminFundAccountType, + AdminFundEntry, + AdminFundEntryType, + AdminFundExpense, + AdminFundExpenseCategory, + AdminFundExpenseListResult, + AdminFundMyAccountResult, + AdminFundOverview, + AdminFundPayeeType, + AdminFundReconciliation, + AdminFundReconciliationListResult, + AdminFundReconciliationStatus, + AdminFundSetupOptions, + AdminFundSetupUser, +} from './fund' + // Login logs export type { AdminLoginLogItem, AdminLoginLogListResult } from './login-logs' diff --git a/apps/frontend/src/types/admin/login-logs.ts b/apps/frontend/src/types/admin/login-logs.ts index 132e41cf..7d39b207 100644 --- a/apps/frontend/src/types/admin/login-logs.ts +++ b/apps/frontend/src/types/admin/login-logs.ts @@ -2,6 +2,7 @@ export interface AdminLoginLogItem { logId: number userId: number | null username: string + displayName: string role: string ip: string location: string diff --git a/apps/frontend/src/types/admin/tasks.ts b/apps/frontend/src/types/admin/tasks.ts index 4cff77eb..31f8dc88 100644 --- a/apps/frontend/src/types/admin/tasks.ts +++ b/apps/frontend/src/types/admin/tasks.ts @@ -250,6 +250,7 @@ export interface AdminTaskDetail { completedBy: null | { userId: number username: string + displayName: string role: string } } diff --git a/apps/frontend/src/types/admin/users.ts b/apps/frontend/src/types/admin/users.ts index 7c5438a2..8d0408e3 100644 --- a/apps/frontend/src/types/admin/users.ts +++ b/apps/frontend/src/types/admin/users.ts @@ -3,6 +3,7 @@ import type { AdminRole, AdminUserStatus } from './auth' export interface AdminUserListItem { userId: number username: string + displayName: string role: AdminRole status: AdminUserStatus createdAt: string diff --git a/apps/frontend/src/utils/admin-auth.ts b/apps/frontend/src/utils/admin-auth.ts index 29f4a338..1b14e44e 100644 --- a/apps/frontend/src/utils/admin-auth.ts +++ b/apps/frontend/src/utils/admin-auth.ts @@ -2,6 +2,7 @@ const ADMIN_TOKEN_KEY = 'order-site-admin-token' const ADMIN_EXPIRES_AT_KEY = 'order-site-admin-token-expires-at' const ADMIN_USER_ID_KEY = 'order-site-admin-user-id' const ADMIN_USERNAME_KEY = 'order-site-admin-username' +const ADMIN_DISPLAY_NAME_KEY = 'order-site-admin-display-name' const ADMIN_ROLE_KEY = 'order-site-admin-role' const ADMIN_PERMISSIONS_KEY = 'order-site-admin-permissions' @@ -29,6 +30,19 @@ export function getAdminUsername() { return localStorage.getItem(ADMIN_USERNAME_KEY) || '' } +export function getAdminDisplayName() { + return localStorage.getItem(ADMIN_DISPLAY_NAME_KEY) || '' +} + +export function setAdminDisplayName(displayName: string) { + const normalized = String(displayName || '').trim() + if (normalized) { + localStorage.setItem(ADMIN_DISPLAY_NAME_KEY, normalized) + } else { + localStorage.removeItem(ADMIN_DISPLAY_NAME_KEY) + } +} + export function getAdminRole() { const role = localStorage.getItem(ADMIN_ROLE_KEY) if (role === 'admin' || role === 'operator' || role === 'support' || role === 'finance') { @@ -94,6 +108,7 @@ export function setAdminSession( user?: { userId: number username: string + displayName?: string role: 'admin' | 'operator' | 'support' | 'finance' permissions?: string[] }, @@ -109,6 +124,8 @@ export function setAdminSession( localStorage.setItem(ADMIN_USERNAME_KEY, user.username) } + setAdminDisplayName(user?.displayName || user?.username || '') + if (user?.role) { localStorage.setItem(ADMIN_ROLE_KEY, user.role) } @@ -121,6 +138,7 @@ export function clearAdminSession() { localStorage.removeItem(ADMIN_EXPIRES_AT_KEY) localStorage.removeItem(ADMIN_USER_ID_KEY) localStorage.removeItem(ADMIN_USERNAME_KEY) + localStorage.removeItem(ADMIN_DISPLAY_NAME_KEY) localStorage.removeItem(ADMIN_ROLE_KEY) localStorage.removeItem(ADMIN_PERMISSIONS_KEY) } diff --git a/docs/README.md b/docs/README.md index 6f22adf7..2deba47a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -44,6 +44,7 @@ | --- | --- | --- | | [项目全量分析报告-2026-10-17.md](项目全量分析报告-2026-10-17.md) | 现行 | 当前架构、业务链路、风险与改进路线 | | [载体判定与成本中心落地-复核与草案-2026-10-17.md](载体判定与成本中心落地-复核与草案-2026-10-17.md) | 现行(复核+草案) | 只读克隆量化载体分布与人工块、对上一轮结论的复核与修正、两份 SQL 草案说明 | +| [人工支出登记落地方案-2026-10-17.md](人工支出登记落地方案-2026-10-17.md) | 现行(方案) | 以订单为入口的支出登记:字段级预填分工、交互入口、状态机与权限、校验与异常队列、分阶段落地 | | [成本中心与三块履约模型.md](成本中心与三块履约模型.md) | 现行(方案) | 自动发货/接单平台/人工客服三块、统一成本中心、销售事实与人工监管 | | [财务系统集成方案.md](财务系统集成方案.md) | 现行(方案) | 财务系统边界、复式记账模型、分期落地 | | [影子总账说明.md](影子总账说明.md) | 现行(方案) | 影子总账的定义、运行方式、对账与转正标准 | diff --git a/docs/drafts/2026-10-17-人工支出登记-DDL草案.sql b/docs/drafts/2026-10-17-人工支出登记-DDL草案.sql index 0a704058..decf59d2 100644 --- a/docs/drafts/2026-10-17-人工支出登记-DDL草案.sql +++ b/docs/drafts/2026-10-17-人工支出登记-DDL草案.sql @@ -117,6 +117,8 @@ CREATE TABLE IF NOT EXISTS manual_expense_records ( cost_snapshot_id BIGINT REFERENCES cost_snapshots(id) ON DELETE RESTRICT, -- 预留:退款/仲裁接入后用反向登记冲正,不修改已通过的原始记录 reversal_of_id BIGINT REFERENCES manual_expense_records(id) ON DELETE RESTRICT, + -- 可选:一笔转账覆盖多个订单时用于归组(拆成多条明细,同一 batch) + expense_batch_no TEXT NOT NULL DEFAULT '', note TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL @@ -131,6 +133,9 @@ CREATE INDEX IF NOT EXISTS idx_manual_expenses_status ON manual_expense_records CREATE INDEX IF NOT EXISTS idx_manual_expenses_cost_item ON manual_expense_records (cost_item_id); -- 付款人日结对账用 CREATE INDEX IF NOT EXISTS idx_manual_expenses_payer ON manual_expense_records (paid_by_admin_user_id, paid_at DESC); +-- 批量归组查询用 +CREATE INDEX IF NOT EXISTS idx_manual_expenses_batch + ON manual_expense_records (expense_batch_no) WHERE expense_batch_no <> ''; DO $$ BEGIN diff --git a/docs/人工支出登记落地方案-2026-10-17.md b/docs/人工支出登记落地方案-2026-10-17.md new file mode 100644 index 00000000..5a466ab6 --- /dev/null +++ b/docs/人工支出登记落地方案-2026-10-17.md @@ -0,0 +1,259 @@ +# 人工支出登记落地方案(2026-10-17) + +> 状态:现行(方案,未实施);基线:`main @ 7bacb7d5`;**本文只做分析与设计,不含代码改动**。 +> 依据:业务口径「人工块当前不以 SLA 监管为目标,先把流程搞清楚:哪个客服处理了哪些东西、付了多少钱、什么项目、关联哪个订单」。 +> 数据来源:本地只读克隆(生产快照,数据截至 2026-10-14)+ 现有代码盘点。 +> 相关文档:[成本中心与三块履约模型.md](成本中心与三块履约模型.md) §8 · [财务系统集成方案.md](财务系统集成方案.md) §八 · +> DDL 草案 [drafts/2026-10-17-人工支出登记-DDL草案.sql](drafts/2026-10-17-人工支出登记-DDL草案.sql) + +--- + +## 一、结论先行 + +1. **订单里的数据足够预填 90%**:店铺、商品、规格、数量、用户实付、支付时间、平台单号、履约标签、状态,后台订单列表/详情已经全部具备。客服实际只需要补 **4 件事**:渠道/项目、金额、收款方、凭证(外加默认带出的「谁、什么时候」)。 +2. **现有财务流水没有"支出"这一侧**。财务页现有 7 个账目类别(订单收款、订单退款、打手报酬、打手提现、打手充值、打手资金回收、售后追缴退还)**全部是收款或打手侧资金**;我方真实付出的钱(人工垫付、上游拿货、赔偿、运费)**一类都没有** —— 这就是"付了多少钱、什么项目"查不到的根本原因。 +3. **落地成本低、风险小**:不需要改订单主表,不需要新角色体系(现有 `admin/finance/operator/support` 四角色 + `finance.*` 权限即可覆盖),只需新增「支出登记」表 + 订单侧入口 + 财务侧一个新类别。 +4. **登记必须"发生在垫付当天"**才有监管价值;事后补记只能算账,不能算监管。 + +--- + +## 二、现状盘点(逐字段,来自代码而非猜测) + +### 2.1 订单已经带了什么(可直接预填) + +订单列表行数据由 `mapAdminOrderListItem` 组装(`apps/backend/src/services/admin/admin-order-read-helpers.ts:17`),字段与截图中看到的元素一一对应: + +| 界面元素(截图) | 数据字段 | 来源 | 支出登记用途 | +| --- | --- | --- | --- | +| 快手 / 91卡券 | `provider` / `platform` | 订单表 | 自动带出,决定默认渠道选项 | +| 2629001926827116 | `platformOrderId` | 订单表 | **对账主键**,必带 | +| 小鱼儿游戏服务店 | `shopName` / `shopId` | 订单表 | 统计维度(店铺) | +| 和平精英…雪山精灵兔 | `itemTitle` / `items[]` | 订单项 + 快照 | 商品/皮肤维度 | +| 指挥客密钥1个、(2进1)暗夜风云/蓝调溯影 | `skuNick` / `skuCode` | 订单项 | 规格维度(注意口径问题见 §7.4) | +| 数量 1 · 关联工单 1 | `totalQuantity` / `workOrderCount` | 订单项 / 工单 | 数量与载体提示 | +| ¥ 10.30 CNY | `totalAmount` / `totalAmountFen` | 订单表 | **用户实付(= 收入)**,用于算毛利 | +| 2026-10-17 22:24:21 | `paidAt` / `createdAt` | 订单表 | 支出时间不能早于支付时间 | +| 打手履约 / ⚡自动直发 / 🎫电子凭证 | `fulfillmentType` + `workOrderCount` | 派生 | 提示该单走哪条链路 | +| 已支付 | `payStatus` | 订单表 | 只有已支付才允许登记支出 | +| 待准备资源 / 未开始 | `resourceStatus` / `customerStatus` | 任务/工单派生 | 提示当前进度 | +| (未在列表显示)操作人 | 后台会话 `userId` / `username` / `role` | `admin_sessions` | 默认「登记人」 | + +补充事实:克隆库里后台账号 **29 个,其中 `support`(客服)20 个**,`finance`(财务)角色独立存在 —— 登记人群与审批人是现成的。 + +> 说明:截图上的标签文案在代码里是「待准备资源」(`resourceStatus = pending_prepare`)、「未开始」(`customerStatus = not_started`),见 `apps/frontend/src/components/admin/StatusTag.tsx:39,48`。 + +### 2.2 订单里**没有**的(必须人工补,这也是登记表的全部价值) + +| # | 缺失信息 | 为什么订单里不可能有 | +| --- | --- | --- | +| 1 | **实际走哪个渠道/找谁履约** | 订单只记录**平台来源**(快手/PDD/抖音),不记录我方线下找的上游、打手、代购 | +| 2 | **实际付了多少钱** | 订单只有**用户付给我们的钱**,没有**我们付出去的钱** | +| 3 | **收款方**(微信号/支付宝/银行卡/账号/昵称) | 完全在系统外 | +| 4 | **凭证**(转账截图、聊天记录、订单截图) | 完全在系统外 | +| 5 | **实际垫付人** | 可能与登录账号不同(主管代付、他人代垫) | +| 6 | **备注/原因**(为什么赔、加急、补差价) | 无结构可依 | + +### 2.3 现有财务流水的类别缺口(关键发现) + +`AdminFinancePage.tsx:39` 的账目类别: + +```text +订单收款(order_paid) · 订单退款(order_refund) · 打手报酬(worker_reward) · 打手提现(withdraw_paid) +打手充值(recharge) · 打手资金回收(worker_recovery) · 售后追缴退还(after_sales_refund) +``` + +**没有任何一类描述"我方支出"**。后端 `admin-finance-service.ts` 的 `allowedCategories` 同样只有这 7 类,且方向判定只把 `order_paid / recharge / worker_recovery` 视为流入。也就是说:即使今天记了人工支出,财务页也没有地方落。 + +**结论**:人工支出登记必须同时补「财务流水类别」,否则只是把数据存进了另一张看不见的表。 + +--- + +## 三、登记模型:以订单为入口,人工只做最小输入 + +### 3.1 字段级分工(自动带出 vs 人工填写) + +| 字段 | 谁提供 | 必填 | 说明 | +| --- | --- | --- | --- | +| 订单号 / 订单项 | 自动(订单) | 是(无订单支出除外) | 从订单详情/列表发起时锁定 | +| 平台 / 店铺 / 商品 / 规格 / 数量 | 自动(订单) | 是 | 只读展示,避免手抄错 | +| 用户实付 | 自动(订单) | 是 | 只读,用于毛利 | +| 支付时间 | 自动(订单) | 是 | 校验支出时间不早于它 | +| **付款项目(渠道/成本项)** | **人工选** | **是** | 对应 `cost_items`,例如「上游拿货-A 渠道」「代练打手」「平台充值」;没有可选项时走"新建项目"审批流 | +| **支出金额** | **人工填** | **是** | 单位分;> 阈值触发二级审批 | +| **收款方类型 + 名称** | **人工填** | **是** | 打手 / 上游 / 客户 / 平台 / 其他 | +| **收款账号** | 人工填 | 否 | 微信/支付宝/银行卡号,用于事后核对 | +| **付款方式** | 人工选 | 是 | 微信 / 支付宝 / 银行 / 现金 / 平台余额 | +| **付款时间** | 人工填 | 是 | 默认当前时间,可回填(当天) | +| **凭证** | **人工传** | **是** | 转账截图/聊天记录,支持多张 | +| 登记人 + 登记时间 | 自动(会话) | — | 默认登录账号,可改(代付场景) | +| 备注 / 原因 | 人工填 | 否 | 赔偿、差价、加急等必须写 | +| 报销状态 | 流程 | — | 待审 / 已通过 / 已驳回 / 已报销 | + +### 3.2 两种登记形态都要支持 + +| 形态 | 场景 | 订单号 | 占比预估 | +| --- | --- | --- | --- | +| **有订单支出** | 客服看到订单 → 去别的渠道拿货/找打手 → 登记 | 必填 | 绝大多数 | +| **无订单支出** | 平台充值、批量拿货、客户赔偿、运费、办公 | 允许为空 | 少数但必须支持,否则这部分钱永远查不到 | + +无订单支出**必须单独可见**(财务页筛选项 + 异常队列统计),不能混进订单支出里被平均掉。 + +### 3.3 一对多关系(必须允许) + +- **一个订单项可以有多笔支出**:渠道费 + 加急费 + 补差价 → 逐笔登记,汇总到订单损益。 +- **一笔转账覆盖多个订单**(例如一次拿货 5 单)→ 建议拆成 5 条明细,用 `expense_batch_no` 归组(可选字段),避免把成本摊错订单。 +- **实际支出 vs 标准成本**:`cost_snapshots` 记的是**标准成本**(成本中心算出来的),人工支出记的是**实际发生**。二者要能对比,差异就是"人工议价空间/异常"的信号 —— 这是后续做成本优化的关键数据,不要在实现时用其中一个覆盖另一个。 + +--- + +## 四、交互落地(三个入口,一套数据) + +### 4.1 订单列表(就是截图这一页) + +- **行操作**加一个「登记支出」按钮(放在「详情」旁边;现在该位置只在自动直发时显示 H5 链接,不冲突)。 +- **新增一列/一个标签**:本单已登记支出合计(无则留空)。让主管扫一眼就能看出哪些单垫了钱、垫了多少。 +- **筛选条件**增加:`有/无支出登记`、`支出待审批` —— 这是主管抽查与财务对账的入口。 + +### 4.2 订单详情页 + +`AdminOrderDetailPage.tsx` 现有四张卡片:基础信息 / 商品明细 / 任务 / 原始载荷。建议在**商品明细与任务之间**插入: + +```text +┌ 人工支出 ────────────────────────────────┐ +│ 合计 ¥XX.XX(已通过) / 待审 ¥XX.XX │ +│ ┌────────┬──────┬────────┬──────┬──────┐ │ +│ │项目 │金额 │收款方 │付款人 │状态 │ │ +│ ├────────┼──────┼────────┼──────┼──────┤ │ +│ │上游拿货 │¥8.50 │微信-xx │客服A │待审 │ │ +│ └────────┴──────┴────────┴──────┴──────┘ │ +│ [+ 登记支出] │ +│ 毛利预览:收入 ¥10.30 − 标准成本 ¥0 − 支出 ¥8.50 = ¥1.80 │ +└──────────────────────────────────────────┘ +``` + +「毛利预览」是这张卡片真正的管理价值:**一眼看到这单是不是亏的**。 + +### 4.3 财务页 + +- 账目类别新增:`人工支出(manual_expense)`、`人工垫付报销(manual_expense_reimburse)`。 +- 新增「支出登记」页签:按 状态 / 客服 / 店铺 / 项目 / 日期 筛选,支持批量审批。 +- 汇总口径:**谁付了多少钱**(按登记人)、**钱花在哪**(按项目/店铺)、**还有多少没报销**。 + +### 4.4 时间要求(监管的生命线) + +**垫付当天登记**。建议规则: + +- 超过 24 小时补登记 → 标记「迟登记」,进异常队列; +- 超过 7 天 → 必须主管审批才能登记; +- 未登记但有订单证据(如工单备注提到垫付)→ 系统提示补登。 + +理由:事后补记的金额与项目失真最快,而"当天登记"是可执行、可考核的硬约束。 + +--- + +## 五、状态机与权限(复用现有体系,不新增角色) + +```text +draft ──提交──> submitted ──审批──> approved ──报销──> reimbursed + │ + └──驳回──> rejected ──修改后重提──> submitted +(预留)approved/reimbursed ──退款或仲裁──> reversed(反向登记,不改原记录) +``` + +| 动作 | 谁可以 | 现有能力 | +| --- | --- | --- | +| 提交登记 | 客服 / 运营 / 管理员 | `requireAdminRoles(['admin','operator','support'])`(`routes/admin/orders.ts:14` 已是这个组合) | +| 审批 / 驳回 | 财务(或管理员) | 角色 `finance`;权限点 `finance.review` 已存在 | +| 维护付款项目 | 财务 | `finance.config` 已存在 | +| 查看支出 | 财务 / 管理员 | `finance.view` 已存在 | +| 导出 | 财务 | `finance.export` 已存在 | + +> 注意:**订单路由目前只做角色校验,没有权限点校验**(`routes/admin/orders.ts` 全篇没有 `requirePermission`)。新增支出接口时建议按财务口径加权限点,避免客服看到全公司支出。 + +**审计双留痕**(现有约定即可满足): + +1. 路由层 `createJsonHandler({ audit })` → 写 `admin_audit_logs`(`admin-audit-service.ts:11`); +2. 业务层写 `manual_expense_events`(只追加,记录状态流转与操作人)。 + +**不可变约束**:`approved` 之后禁止 UPDATE 金额/项目/收款方;要改只能反向登记(`reversal_of_id`),这是为退款/仲裁预留的,也是财务能信这张表的前提。 + +--- + +## 六、校验与异常队列(不默认 0、不静默通过) + +| # | 规则 | 不通过时 | +| --- | --- | --- | +| 1 | 金额 > 0 | 拒绝提交 | +| 2 | 单笔 ≥ ¥5,000(阈值待定) | 需管理员二级审批 | +| 3 | 凭证必传(至少 1 张) | 拒绝提交 | +| 4 | 订单必须存在且 `pay_status = paid` | 拒绝;无订单支出走独立形态 | +| 5 | 付款项目必选 | 允许暂存但**不得 approved**;进「未归类」队列 | +| 6 | 同订单 + 同项目 + 同金额 + 同收款方,24h 内重复 | 提示疑似重复,需填原因才可提交 | +| 7 | 付款时间不得晚于当前、不得早于订单支付时间 | 拒绝 | +| 8 | 迟登记(> 24h) | 允许但标记,进异常队列 | +| 9 | 收款方为空/无意义(如「微信」) | 提示补全 | + +**异常队列必须在财务页可见**:未归类数、迟登记数、无订单支出占比、待审超期数。否则又会变成"看不见的账"。 + +--- + +## 七、与已有系统的衔接 + +### 7.1 载体判定(人工块显性化) + +人工支出登记本身就是「这单是人工在做」的强证据。回填规则补充一条:**存在 approved 人工支出的订单项,其 `carrier_type` 判定为 `manual`**(与任务/工单存在性并列,冲突时以现有优先级为准并记异常)。 + +### 7.2 成本中心 + +- `cost_item_id` 即"付款项目",与 `cost_items`(`cost_type = 'manual_service'` 或 `'auto_purchase'`)打通; +- `approved` 时写 `cost_snapshots`(`source = 'manual_input'`),与自动发货/工单的标准成本并列进入订单损益; +- 匹配不到项目 → `cost_exceptions`,不默认 0。 + +### 7.3 影子总账(分录) + +```text +approved(垫付) 借:5101 履约成本-人工 贷:2005 其他应付-垫付人 +reimbursed(报销) 借:2005 其他应付-垫付人 贷:1001 银行存款 +reversed(预留) 反向登记 + 反向凭证,原凭证不动 +``` + +幂等事件键:`manual_expense:{expense_no}:approved|reimbursed|reversed`。 + +### 7.4 报表口径的两个坑(已在别处确认,实施时必须遵守) + +1. **数量**:`order_items.quantity` 不可信(316,585 项里 316,578 项 = 1,而名称带「N个」的有 104,710 项,按名称解析总量是它的 **7.3 倍**)→ 支出若要按件摊,必须用 `normalized_quantity`。 +2. **商品**:同一商品名对应多个 `sku_code`(「苏尔南钻石100颗」47 个码)→ 必须经 `channel_product_mappings` 归一到 `canonical_product_id`。 + +--- + +## 八、分阶段落地 + +| 阶段 | 内容 | 产出价值 | 量级 | +| --- | --- | --- | --- | +| **P0** | 支出登记表 + 订单详情「登记支出」卡片 + 订单列表入口与标签 | **能记录**:钱花在哪、谁付的、有凭证 | 1~2 天 | +| **P1** | 审批流 + 财务页新类别 + 「支出登记」页签 + 异常队列 | **能审、能统计**:谁付了多少、还有多少没报销 | 2~3 天 | +| **P2** | 成本项归类 + `cost_snapshots` + 订单损益毛利预览 | **能算清**:每单真实毛利、渠道对比 | 3~5 天 | +| **P3** | 影子总账入账 + 退款/仲裁冲正(字段已预留) | **能对账**:总账与业务账一致 | 后续 | + +先做 P0 的理由:业务当前最痛的是"查不到",而不是"算不准"。能记录就已经解决 80% 的问题,且 P0 不依赖成本中心与总账。 + +--- + +## 九、待业务确认(实施前需拍板) + +| # | 问题 | 建议默认 | +| --- | --- | --- | +| 1 | 审批人是谁? | 财务(`finance` 角色)审批,管理员兜底 | +| 2 | 单笔二级审批阈值? | ¥5,000 起需管理员 | +| 3 | 区分「垫付」与「公司直付」吗? | 需要:垫付要报销,公司直付只记账 | +| 4 | 必须支持无订单支出吗? | **必须**(平台充值/赔偿/运费无处可记) | +| 5 | 允许一单多笔支出吗? | **必须** | +| 6 | 凭证必须吗? | **必须**(且支持多张) | +| 7 | 迟登记如何处理? | 24h 内正常,超时标记,超 7 天需审批 | +| 8 | 是否与现有 7 类财务流水合并展示? | 合并展示、分类筛选 | + +--- + +## 十、一句话小结 + +**订单里已经有"卖了什么、卖了多少钱、什么时候卖"的全部事实;缺的只是"我们为了这单付出去多少钱、付给谁、谁垫的、有没有凭证"。把这一件事做成当天可填、必附凭证、不可篡改、能进财务页的登记表,人工块就从"系统盲区"变成"可查账的成本项"。** diff --git a/docs/客服备用金台账实施方案-2026-10-18.md b/docs/客服备用金台账实施方案-2026-10-18.md new file mode 100644 index 00000000..1f7a3e61 --- /dev/null +++ b/docs/客服备用金台账实施方案-2026-10-18.md @@ -0,0 +1,440 @@ +# 客服备用金台账实施方案(2026-10-18) + +> 状态:方案,未实施。 +> 背景:两位主管向客服发放备用金,客服用于充值、采购、赔偿等;目前全部走人工,无法监控每笔资金用途和每个客服余额。 +> 结论:这不是报销系统,而是**客服备用金(周转金)台账**。先做“账户 + 流水 + 对账”,不做复杂审批和总账。 + +--- + +## 一、目标与非目标 + +### 1.1 目标 + +系统必须能回答四个问题: + +1. 公司现在有多少钱在主管和客服手里? +2. 每个客服当前余额是多少? +3. 每一笔钱花到哪、为什么花、有没有凭证? +4. 系统余额和客服实际余额对不对得上? + +### 1.2 非目标(P0 不做) + +- 不做垫付/直付的复杂资金性质区分; +- 不做多级审批流; +- 不做成本中心、订单毛利、复式总账; +- 不做平台账户独立钱包(当前口径:充值后立即用于订单/消耗,直接记支出); +- 不做多币种、发票、税务。 + +--- + +## 二、业务模型 + +```mermaid +flowchart LR + C["公司资金"] -->|拨付| S["主管资金池
2 位主管"] + S -->|发放| A["客服备用金账户
每人一个余额"] + A -->|支出| P["平台 / 供应商 / 客户"] + A -->|缴回| S + P -->|付款截图| E["支出记录"] +``` + +### 2.1 五类流水,缺一不可 + +| 流水类型 | 钱的方向 | 对客服余额 | 是否算支出 | 关键字段 | +| --- | --- | --- | --- | --- | +| 期初 | 上线初始化 | 增加 | 否 | 金额、主管确认 | +| 发放 | 主管 → 客服 | 增加 | 否 | 金额、主管、客服、时间、凭证 | +| 支出 | 客服 → 平台/供应商/客户 | 减少 | 是 | 金额、用途、收款方、平台账号、订单号(可选)、付款截图 | +| 缴回 | 客服 → 主管 | 减少 | 否 | 金额、主管、客服、时间、凭证 | +| 调整 | 主管/财务 → 客服 | 增加或减少 | 视原因 | 差异金额、原因、审批人、凭证 | + +> 只有“支出”没有“发放/缴回/期初”,余额永远算不出来。 + +### 2.2 余额公式 + +```text +客服余额 = 期初 + 累计发放 + 累计调整 - 累计支出 - 累计缴回 +主管池余额 = 期初 + 公司拨付 + 客服缴回 - 发放给客服 - 调整 +``` + +余额只能由流水产生,不允许直接手工修改。 + +--- + +## 三、账户设计 + +### 3.1 主管资金池 + +- 两位主管各一个账户; +- 公司给主管拨付、主管发给客服、客服缴回,都记入主管池; +- 每个客服指定一个责任主管; +- 发放可以两位主管都操作,但每笔必须记录发放人; +- 对账由责任主管确认,避免“两个人都以为对方对过了”。 + +### 3.2 客服备用金账户 + +- 每个客服一个账户; +- 客服只能看到自己的余额和流水; +- 主管能看到自己名下客服的余额和流水; +- 财务/管理员能看到全部; +- 余额不足时不能继续支出,先由主管补拨。 + +### 3.3 平台账户 + +当前口径为 A:充值后立即用于订单/消耗,因此不单独建平台钱包。支出记录中保留“平台名称 + 平台账号”,用于追溯钱充到了哪里。 + +如果以后出现“充值后平台还有余额、以后继续用”的情况,再增加平台账户层,不混入客服余额。 + +--- + +## 四、数据模型 + +P0 四张表即可。 + +### 4.1 `fund_accounts` 资金账户 + +| 字段 | 说明 | +| --- | --- | +| id | 主键 | +| account_no | 账户编号,唯一 | +| account_type | `supervisor` / `agent` | +| owner_admin_user_id | 主管或客服的后台用户 ID | +| owner_name | 姓名快照 | +| responsible_supervisor_id | 客服的责任主管;主管账户为空 | +| balance_fen | 当前余额,分 | +| status | `active` / `frozen` / `closed` | +| created_at / updated_at | 时间 | + +约束: + +- 同一用户同一类型只能有一个账户; +- 余额不得为负; +- 冻结账户不能发放、不能支出,但允许缴回和调整。 + +### 4.2 `fund_entries` 资金流水 + +| 字段 | 说明 | +| --- | --- | +| id | 主键 | +| entry_no | 流水号,唯一 | +| account_id | 所属账户 | +| entry_type | `opening` / `funding` / `issue` / `expense` / `return` / `adjustment` | +| amount_fen | 有符号金额,正数流入、负数流出 | +| balance_after_fen | 记账后余额快照 | +| transfer_no | 转账批次号;发放/缴回的两条流水共用 | +| counterparty_account_id | 对方账户;支出时为空 | +| expense_id | 支出明细 ID;仅 `expense` 使用 | +| reconciliation_id | 对账调整关联 | +| occurred_at | 资金实际发生时间 | +| created_by_admin_user_id | 操作人 | +| evidence_json | 凭证文件 key、备注 | +| note | 说明 | +| created_at | 创建时间 | + +约束: + +- `amount_fen <> 0`; +- 余额只能通过流水变化; +- 发放、缴回必须同时写两条流水:主管负数、客服正数,或反过来; +- 支出必须同时写一条客服负数流水和一条 `manual_expenses`; +- 已记账流水不可修改、不可删除;更正走调整或反向流水。 + +### 4.3 `manual_expenses` 支出明细 + +| 字段 | 说明 | +| --- | --- | +| id | 主键 | +| expense_no | 支出编号,唯一 | +| account_id | 客服备用金账户 | +| amount_fen | 支出金额,分 | +| category | `platform_recharge` / `purchase` / `compensation` / `freight` / `other` | +| payee_type | `platform` / `supplier` / `customer` / `other` | +| payee_name | 收款方名称 | +| payee_account | 收款账号 | +| platform_name | 充值平台名称 | +| platform_account | 平台账号 | +| order_id | 关联订单,可空 | +| order_item_id | 关联订单项,可空 | +| paid_at | 付款时间 | +| evidence_json | 付款截图、凭证 | +| note | 用途/原因 | +| fund_entry_id | 对应资金流水,唯一 | +| created_by_admin_user_id | 登记人 | +| created_at / updated_at | 时间 | + +约束: + +- 金额 > 0; +- 用途、收款方、付款时间、凭证必填; +- 订单号可选;赔偿类建议关联订单; +- 同一客服 + 同金额 + 同收款方 24 小时内重复,提示疑似重复; +- 付款时间不能晚于当前时间;超过 24 小时补记标记“迟登记”。 + +### 4.4 `fund_reconciliations` 对账 + +| 字段 | 说明 | +| --- | --- | +| id | 主键 | +| account_id | 客服或主管账户 | +| period_start / period_end | 对账区间 | +| system_balance_fen | 系统余额 | +| actual_balance_fen | 实际余额 | +| difference_fen | 差异 | +| status | `pending` / `confirmed` / `discrepancy` | +| reason | 差异原因 | +| evidence_json | 盘点/截图 | +| confirmed_by_admin_user_id | 确认人 | +| confirmed_at | 确认时间 | +| created_at | 创建时间 | + +规则: + +- 差异为 0 才能直接确认; +- 差异不为 0 必须填写原因,并走调整流水; +- 调整必须由主管/财务/管理员操作,不能由客服自行改余额。 + +--- + +## 五、业务流程 + +### 5.1 上线初始化 + +1. 建立两位主管账户; +2. 建立每个客服账户,指定责任主管; +3. 主管确认每个客服的期初余额; +4. 期初余额写 `opening` 流水,备注“上线期初余额”; +5. 期初确认后,所有余额变化必须走流水。 + +### 5.2 公司 → 主管 + +管理员记录“拨付”,主管池增加。若公司层暂不管理,至少给主管池录期初余额。 + +### 5.3 主管 → 客服 + +主管选择客服、填写金额、付款方式、时间、凭证,提交后: + +- 主管池减少; +- 客服余额增加; +- 生成两条 `issue` 流水,共用一个 `transfer_no`。 + +### 5.4 客服支出 + +客服填写金额、用途、收款方/平台、平台账号、订单号(可选)、付款时间、截图、备注,提交后: + +- 客服余额减少; +- 生成一条 `expense` 流水; +- 生成一条 `manual_expenses` 明细。 + +### 5.5 客服缴回 + +客服或主管发起缴回,主管确认后: + +- 客服余额减少; +- 主管池增加; +- 生成两条 `return` 流水。 + +### 5.6 对账 + +1. 系统显示当前余额; +2. 主管/客服盘点实际现金、微信、支付宝等余额; +3. 输入实际余额,系统计算差异; +4. 差异为 0 直接确认;不为 0 填写原因并走调整; +5. 调整后重新对平。 + +建议频率:每笔流水当天记,每天生成余额快照,每周主管与客服对一次账。 + +--- + +## 六、权限设计 + +新增权限点: + +- `fund.view`:查看账户和流水; +- `fund.issue`:发放/缴回; +- `fund.expense`:登记支出; +- `fund.reconcile`:发起/确认对账; +- `fund.adjust`:调整余额; +- `fund.export`:导出。 + +角色建议: + +| 角色 | 权限 | +| --- | --- | +| 管理员 | 全部 | +| 财务 | 查看全部、对账、调整、导出 | +| 主管(operator 或新增 `fund_manager`) | 查看名下客服、发放、缴回、对账 | +| 客服(support) | 登记自己的支出、查看自己的余额和流水 | + +服务层还要做归属校验: + +- 客服只能操作自己的账户; +- 主管只能操作自己名下客服; +- 发放人不能给自己发放; +- 调整必须由主管/财务/管理员操作; +- 所有写操作写审计日志。 + +--- + +## 七、页面设计 + +### 7.1 管理端“备用金”页 + +建议五个页签: + +1. **总览**:公司总备用金、主管池合计、客服余额合计、本月支出、待对账人数、异常笔数。 +2. **客服余额**:客服、责任主管、当前余额、本月发放、本月支出、本月缴回、最后交易、最后对账、状态。 +3. **主管资金池**:主管、池余额、本月拨入、本月发放、本月收回、名下客服数、差异。 +4. **支出明细**:时间、客服、金额、用途、收款方/平台、订单号、凭证、状态;支持筛选。 +5. **对账与异常**:负余额、无凭证、长期未动、疑似重复、对账差异、超限额。 + +### 7.2 客服端“我的备用金” + +- 当前余额; +- 发放/支出/缴回流水; +- “登记支出”按钮; +- “申请补拨”按钮; +- “申请缴回”按钮; +- 对账状态。 + +### 7.3 订单详情(可选) + +订单详情增加“本单相关支出”小卡片,展示已关联的支出合计和明细。订单号不是必填,所以这个卡片只对已关联订单的支出生效。 + +--- + +## 八、接口设计 + +| 方法 | 路径 | 用途 | +| --- | --- | --- | +| GET | `/admin/fund/overview` | 总览 | +| GET | `/admin/fund/accounts` | 账户列表 | +| GET | `/admin/fund/accounts/:id` | 账户详情 + 流水 | +| POST | `/admin/fund/accounts/:id/funding` | 公司 → 主管拨付 | +| POST | `/admin/fund/accounts/:id/issue` | 主管 → 客服发放 | +| POST | `/admin/fund/accounts/:id/return` | 客服 → 主管缴回 | +| POST | `/admin/fund/accounts/:id/expenses` | 登记支出 | +| GET | `/admin/fund/expenses` | 支出明细 | +| GET | `/admin/fund/expenses/:id` | 支出详情 | +| POST | `/admin/fund/reconciliations` | 发起对账 | +| POST | `/admin/fund/reconciliations/:id/confirm` | 确认对账 | +| POST | `/admin/fund/reconciliations/:id/adjust` | 差异调整 | + +实现要求: + +- 所有金额用“分”存储,前端按元展示; +- 发放、缴回、支出必须在数据库事务中完成; +- 账户行使用 `SELECT ... FOR UPDATE`,避免并发导致余额错乱; +- 所有写接口使用现有 `createJsonHandler({ audit })` 写审计日志; +- 所有写接口做权限点和归属校验。 + +--- + +## 九、校验与异常 + +| 规则 | 处理 | +| --- | --- | +| 金额 <= 0 | 拒绝 | +| 余额不足 | 拒绝,提示先补拨 | +| 账户冻结 | 禁止发放/支出,允许缴回/调整 | +| 支出无凭证 | 拒绝 | +| 发放/缴回无凭证 | 允许备注+交接说明,但标记待补 | +| 收款方为空 | 拒绝 | +| 付款时间晚于当前 | 拒绝 | +| 付款时间早于当天 | 允许,标记迟登记 | +| 同客服+同金额+同收款方 24h 内重复 | 提示,需备注原因 | +| 对账差异未填原因 | 拒绝确认 | +| 调整无审批人 | 拒绝 | + +异常队列: + +- 负余额; +- 无凭证; +- 超过 7 天未对账; +- 余额长期不动; +- 单笔/单日支出异常; +- 同一凭证被多笔复用; +- 对账差异。 + +--- + +## 十、分期落地 + +### P0(先能记、能看、能对) + +- 四张表 + 索引; +- 主管池、客服账户; +- 期初、发放、支出、缴回、调整五类流水; +- 支出明细 + 凭证上传; +- 总览、客服余额、主管池、支出明细、对账页面; +- 权限点 + 审计; +- 上线期初余额; +- 每周对账。 + +### P1(更好用) + +- 异常队列和提醒; +- 余额上限、低余额提醒; +- 批量发放/批量对账; +- 导出; +- 大额支出主管确认; +- 移动端快速登记。 + +### P2(算得更清) + +- 订单关联支出汇总; +- 支出分类与成本中心打通; +- 单均人工成本、店铺/客服维度报表。 + +### P3(财务总账) + +- 复式记账; +- 与订单、钱包、提现、人工支出统一对账; +- 期间关账。 + +--- + +## 十一、上线步骤 + +1. 业务确认:两位主管、客服名单、责任主管、支出分类、期初余额截止时间。 +2. 开发:迁移、后端台账服务、接口、前端页面、权限。 +3. 初始化:建立账户,录入期初余额,主管逐人确认。 +4. 试点:选 2 位主管 + 2~3 位客服,跑 1 周。 +5. 校准:根据试点调整字段、分类、对账频率。 +6. 全量:全部客服上线,关闭线下 Excel/微信群记账。 +7. 运营:每周对账,每月复盘余额和支出异常。 + +--- + +## 十二、测试与验收 + +### 12.1 必测 + +- 发放:主管池减少、客服余额增加,两条流水金额相反; +- 支出:客服余额减少,支出明细与流水一一对应; +- 缴回:客服余额减少、主管池增加; +- 余额不足:拒绝支出; +- 并发发放/支出:余额不超扣、不出现负数; +- 权限:客服不能看别人,主管不能看非名下客服,客服不能调整; +- 对账差异:必须填原因并生成调整流水; +- 审计:所有写操作有日志。 + +### 12.2 验收标准 + +- 每个客服的当前余额可查; +- 每笔支出可追溯到人、金额、用途、收款方、时间、凭证; +- 主管池余额与发放/缴回流水对得上; +- 系统余额与客服实际余额的差异有记录、有原因、有审批; +- 不存在无流水导致的余额变化; +- 不存在负余额(除非明确开启允许负余额)。 + +--- + +## 十三、待确认 + +1. 两位主管的钱是公司资金,还是主管个人先垫?P0 先按公司资金池记,个人垫资在备注中说明。 +2. 客服账号是否都是现有 `admin_users` 的 `support` 角色?如果是,直接绑定 `owner_admin_user_id`。 +3. 客服余额是“一个总余额”,还是需要分微信/支付宝/现金多个钱包?P0 建议一个总余额 + 流水记付款方式。 +4. 期初余额的截止时间和确认人。 +5. 支出分类是否就用:平台充值、采购/拿货、客户赔偿、运费、其他。 +6. 对账频率:每周一次是否足够,是否需要每日快照。 +7. 大额支出是否需要主管事前确认;阈值多少。