From a96229a106b22314f38b3d0051e9bbc8b71ff4f8 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 24 Oct 2026 14:34:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(finance):=20=E5=AE=8C=E5=96=84=E5=AE=A2?= =?UTF-8?q?=E6=9C=8D=E9=92=B1=E5=8C=85=E4=B8=8E=E4=BA=BA=E5=B7=A5=E5=B1=A5?= =?UTF-8?q?=E7=BA=A6=E6=88=90=E6=9C=AC=E6=B5=81=E6=B0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cost-center/platform-credit-service.ts | 4 +- .../finance/manual-fulfillment-note.test.ts | 63 ++++++ .../finance/manual-fulfillment-note.ts | 23 ++ .../finance/manual-fulfillment-service.ts | 40 ++-- apps/frontend/src/layouts/AdminLayout.tsx | 4 +- .../admin/AdminManualFulfillmentPage.tsx | 3 + .../pages/admin/fund/FundManagementView.tsx | 34 ++- .../src/pages/admin/fund/FundSupportView.tsx | 40 +++- .../pages/admin/fund/PlatformCreditPanel.tsx | 211 +++++++++++++++--- .../src/services/admin/cost-center.ts | 5 + apps/frontend/src/types/admin/cost-center.ts | 10 + 11 files changed, 359 insertions(+), 78 deletions(-) create mode 100644 apps/backend/src/services/finance/manual-fulfillment-note.test.ts create mode 100644 apps/backend/src/services/finance/manual-fulfillment-note.ts diff --git a/apps/backend/src/services/cost-center/platform-credit-service.ts b/apps/backend/src/services/cost-center/platform-credit-service.ts index 2278c679..c26e54ba 100644 --- a/apps/backend/src/services/cost-center/platform-credit-service.ts +++ b/apps/backend/src/services/cost-center/platform-credit-service.ts @@ -419,8 +419,8 @@ export async function listPlatformCreditOwners() { SELECT au.id, au.username, COALESCE(NULLIF(au.display_name, ''), au.username) AS display_name FROM admin_users au - WHERE au.status = 'active' - ORDER BY au.id + WHERE au.status = 'active' AND au.role = 'support' + ORDER BY display_name, au.id `, ) return { diff --git a/apps/backend/src/services/finance/manual-fulfillment-note.test.ts b/apps/backend/src/services/finance/manual-fulfillment-note.test.ts new file mode 100644 index 00000000..d6d90dbd --- /dev/null +++ b/apps/backend/src/services/finance/manual-fulfillment-note.test.ts @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + buildManualFulfillmentNote, + buildManualFulfillmentReference, +} from './manual-fulfillment-note.js' + +test('人工履约编号同时带工单号与平台单号', () => { + assert.equal( + buildManualFulfillmentReference({ + workOrderNo: 'WK26102414033739', + platformOrderId: '2628501763808879', + orderItemId: 10, + }), + '工单 WK26102414033739 · 平台单号 2628501763808879', + ) +}) + +test('人工履约编号在没有工单号时回退平台单号', () => { + assert.equal( + buildManualFulfillmentReference({ + workOrderNo: null, + platformOrderId: '2628501763808879', + orderItemId: 10, + }), + '平台单号 2628501763808879', + ) +}) + +test('人工履约编号完全没有单号时回退订单项', () => { + assert.equal( + buildManualFulfillmentReference({ + workOrderNo: null, + platformOrderId: '', + orderItemId: 10, + }), + '订单项 #10', + ) +}) + +test('人工履约备注包含商品、编号与客服备注并按上限截断', () => { + const note = buildManualFulfillmentNote({ + skuName: '幸运币2个', + reference: '工单 WK26102414033739 · 平台单号 2628501763808879', + note: '已发放', + maxLength: 300, + }) + assert.equal( + note, + '人工履约 幸运币2个 · 工单 WK26102414033739 · 平台单号 2628501763808879 · 已发放', + ) + + assert.equal( + buildManualFulfillmentNote({ + skuName: '幸运币2个', + reference: '工单 WK26102414033739', + note: '', + maxLength: 12, + }), + '人工履约 幸运币2个 ·', + ) +}) diff --git a/apps/backend/src/services/finance/manual-fulfillment-note.ts b/apps/backend/src/services/finance/manual-fulfillment-note.ts new file mode 100644 index 00000000..674f1c71 --- /dev/null +++ b/apps/backend/src/services/finance/manual-fulfillment-note.ts @@ -0,0 +1,23 @@ +/** 人工履约流水备注:商品、可追溯编号与客服备注。 */ +export function buildManualFulfillmentReference(input: { + workOrderNo: string | null + platformOrderId: string + orderItemId: number +}) { + const parts: string[] = [] + if (input.workOrderNo) parts.push(`工单 ${input.workOrderNo}`) + if (input.platformOrderId) parts.push(`平台单号 ${input.platformOrderId}`) + if (parts.length === 0) parts.push(`订单项 #${input.orderItemId}`) + return parts.join(' · ') +} + +export function buildManualFulfillmentNote(input: { + skuName: string + reference: string + note: string + maxLength: number +}) { + const parts = [`人工履约 ${input.skuName}`.trim(), input.reference] + if (input.note) parts.push(input.note) + return parts.filter(Boolean).join(' · ').slice(0, input.maxLength) +} diff --git a/apps/backend/src/services/finance/manual-fulfillment-service.ts b/apps/backend/src/services/finance/manual-fulfillment-service.ts index dc870484..4a7d5896 100644 --- a/apps/backend/src/services/finance/manual-fulfillment-service.ts +++ b/apps/backend/src/services/finance/manual-fulfillment-service.ts @@ -3,6 +3,10 @@ import type { JsonObject } from '../../types/json.js' import { createHttpError } from '../../utils/http.js' import { nowIso } from '../../utils/time.js' import type { AdminSession } from '../admin/admin-auth-service.js' +import { + buildManualFulfillmentNote, + buildManualFulfillmentReference, +} from './manual-fulfillment-note.js' import { consumePlatformCredit, type PlatformCreditKey, @@ -175,6 +179,7 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm cost_mode: string credit_unit_price_fen: string work_order_id: number | null + work_order_no: string | null work_product_rule_id: number | null fulfilled_at: string | null }>( @@ -203,6 +208,7 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm COALESCE(cfg.cost_mode, 'variable') AS cost_mode, COALESCE((fp.config_json->>'rechargeUnitPriceFen')::bigint, 1) AS credit_unit_price_fen, wo.id AS work_order_id, + wo.work_order_no, wo.product_rule_id AS work_product_rule_id, fulfilled.fulfilled_at FROM order_items oi @@ -210,7 +216,7 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm LEFT JOIN manual_fulfillment_configs cfg ON cfg.id = oi.fulfillment_config_id LEFT JOIN fulfillment_profiles fp ON fp.profile_key = 'manual_' || cfg.platform_key LEFT JOIN LATERAL ( - SELECT id, product_rule_id FROM work_orders + SELECT id, product_rule_id, work_order_no FROM work_orders WHERE order_item_id = oi.id ORDER BY id DESC LIMIT 1 ) wo ON TRUE LEFT JOIN fulfilled ON fulfilled.order_item_id = oi.id @@ -267,6 +273,11 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm const note = optionalText(payload.note, 300) const payeeName = optionalText(payload.payeeName ?? payload.payee_name, 120) + const fulfillmentReference = buildManualFulfillmentReference({ + workOrderNo: row.work_order_no, + platformOrderId: row.platform_order_id, + orderItemId, + }) // 1. 先扣额度(积分平台走积分钱包;备用金现金走现有 fund_* 链路)。 let creditEntryId: number | null = null @@ -288,7 +299,12 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm orderItemId, paidAt: now, evidence: [], - note: buildFundNote(row.sku_name, note), + note: buildManualFulfillmentNote({ + skuName: row.sku_name, + reference: fulfillmentReference, + note, + maxLength: 500, + }), actorUserId: session.userId, }) }) @@ -300,7 +316,13 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm platformKey: platformKey as PlatformCreditKey, quantity: creditQuantity, relatedTaskId: null, - note: buildCreditNote(row.sku_name, note), + platformRef: row.work_order_no || row.platform_order_id, + note: buildManualFulfillmentNote({ + skuName: row.sku_name, + reference: fulfillmentReference, + note, + maxLength: 300, + }), }) creditEntryId = consumed.entryId balanceAfter = consumed.balanceAfter @@ -500,18 +522,6 @@ function mapPending(row: PendingRow) { } } -function buildFundNote(skuName: string, note: string) { - const parts = [`人工履约 ${skuName}`.trim()] - if (note) parts.push(note) - return parts.join(' · ').slice(0, 500) -} - -function buildCreditNote(skuName: string, note: string) { - const parts = [`人工履约 ${skuName}`.trim()] - if (note) parts.push(note) - return parts.join(' · ').slice(0, 300) -} - function normalizeCostFen(value: unknown) { const parsed = Number(value) if (!Number.isFinite(parsed) || parsed < 0) { diff --git a/apps/frontend/src/layouts/AdminLayout.tsx b/apps/frontend/src/layouts/AdminLayout.tsx index a041b7c3..7accd7a8 100644 --- a/apps/frontend/src/layouts/AdminLayout.tsx +++ b/apps/frontend/src/layouts/AdminLayout.tsx @@ -136,7 +136,7 @@ export default function AdminLayout() { { key: '/admin/finance', icon: , label: '财务' }, { key: '/admin/sales-profit', icon: , label: '销售毛利' }, { key: '/admin/cost-center', icon: , label: '商品与成本' }, - { key: '/admin/fund', icon: , label: '备用金' }, + { key: '/admin/fund', icon: , label: '钱包管理' }, { key: '/admin/worker-platform', icon: , label: '资金审核' }, ] : [ @@ -145,7 +145,7 @@ export default function AdminLayout() { { key: '/admin/fund', icon: , - label: role === 'support' ? '我的钱包' : '备用金', + label: role === 'support' ? '我的钱包' : '钱包管理', }, ...(role === 'support' ? [] diff --git a/apps/frontend/src/pages/admin/AdminManualFulfillmentPage.tsx b/apps/frontend/src/pages/admin/AdminManualFulfillmentPage.tsx index 83143b40..14408991 100644 --- a/apps/frontend/src/pages/admin/AdminManualFulfillmentPage.tsx +++ b/apps/frontend/src/pages/admin/AdminManualFulfillmentPage.tsx @@ -87,7 +87,10 @@ export default function AdminManualFulfillmentPage() { void queryClient.invalidateQueries({ queryKey: ['admin-manual-fulfillment-pending'], }) + void queryClient.invalidateQueries({ queryKey: ['admin-fund-my'] }) + void queryClient.invalidateQueries({ queryKey: ['admin-fund-accounts'] }) void queryClient.invalidateQueries({ queryKey: ['admin-platform-credit-accounts'] }) + void queryClient.invalidateQueries({ queryKey: ['admin-platform-credit-entries'] }) }, onError: (error) => { message.error(error instanceof Error ? error.message : '确认履约失败') diff --git a/apps/frontend/src/pages/admin/fund/FundManagementView.tsx b/apps/frontend/src/pages/admin/fund/FundManagementView.tsx index 8f7a2454..5fa3a07a 100644 --- a/apps/frontend/src/pages/admin/fund/FundManagementView.tsx +++ b/apps/frontend/src/pages/admin/fund/FundManagementView.tsx @@ -156,6 +156,7 @@ export default function FundManagementView({ role }: { role: ManagementRole }) { const canIssue = role === 'admin' || role === 'operator' const canReconcile = role === 'admin' || role === 'operator' || role === 'finance' const canExpense = role === 'admin' + const canManageCredits = role === 'admin' || role === 'finance' const [activeTab, setActiveTab] = useState('overview') const [submitting, setSubmitting] = useState(false) @@ -875,8 +876,8 @@ export default function FundManagementView({ role }: { role: ManagementRole }) { return (
} loading={overviewQuery.isFetching} onClick={refreshAll}> 刷新 @@ -902,7 +903,7 @@ export default function FundManagementView({ role }: { role: ManagementRole }) { items={[ { key: 'overview', - label: '总览', + label: '钱包总览', children: ( @@ -997,14 +998,25 @@ export default function FundManagementView({ role }: { role: ManagementRole }) { 发放备用金 ) : null} + {canManageCredits ? ( + + ) : null} {canExpense ? ( ) : null} {canReconcile ? ( - ) : null} {isAdmin ? : null} @@ -1024,7 +1036,7 @@ export default function FundManagementView({ role }: { role: ManagementRole }) { }, { key: 'agents', - label: '客服余额', + label: '客服现金钱包', children: ( , }, { key: 'expenses', - label: '支出明细', + label: '现金支出', children: ( openReconcile()}> diff --git a/apps/frontend/src/pages/admin/fund/FundSupportView.tsx b/apps/frontend/src/pages/admin/fund/FundSupportView.tsx index 25576884..584ca31a 100644 --- a/apps/frontend/src/pages/admin/fund/FundSupportView.tsx +++ b/apps/frontend/src/pages/admin/fund/FundSupportView.tsx @@ -96,13 +96,18 @@ export default function FundSupportView() { const entries = data?.entries || [] const expenses = data?.expenses || [] + // 全局 staleTime=Infinity:钱包页每次进入都重新拉取,停留期间也轮询最新流水。 const creditAccountsQuery = useQuery({ queryKey: ['admin-platform-credit-accounts'], queryFn: fetchAdminPlatformCreditAccounts, + refetchOnMount: 'always', + refetchInterval: 15_000, }) const creditEntriesQuery = useQuery({ queryKey: ['admin-platform-credit-entries', 'mine'], queryFn: () => fetchAdminPlatformCreditEntries({ pageSize: 50 }), + refetchOnMount: 'always', + refetchInterval: 15_000, }) const creditAccounts = creditAccountsQuery.data?.data.items || [] const creditEntries = creditEntriesQuery.data?.data.items || [] @@ -247,7 +252,8 @@ export default function FundSupportView() { { title: '平台', dataIndex: 'platformKey', - width: 100, + width: 110, + align: 'center', render: (value: string, row) => ( {row.platformLabel} @@ -257,7 +263,8 @@ export default function FundSupportView() { { title: '类型', dataIndex: 'entryType', - width: 80, + width: 88, + align: 'center', render: (value: string) => ( {value === 'grant' @@ -273,8 +280,8 @@ export default function FundSupportView() { { title: '变动', dataIndex: 'quantityDelta', - width: 100, - align: 'right', + width: 110, + align: 'center', render: (value: number) => value > 0 ? ( +{value} @@ -285,26 +292,32 @@ export default function FundSupportView() { { title: '余额', dataIndex: 'balanceAfter', - width: 100, - align: 'right', + width: 120, + align: 'center', }, { title: '折合金额', dataIndex: 'amountFen', - width: 110, - align: 'right', + width: 120, + align: 'center', render: (value: number) => `¥${(Number(value || 0) / 100).toFixed(2)}`, }, { title: '备注', dataIndex: 'note', + width: 320, + align: 'center', + ellipsis: true, render: (value: string) => value || '-', }, { title: '时间', dataIndex: 'createdAt', - width: 150, - render: (value: string) => formatAdminDateTime(value), + width: 180, + align: 'center', + render: (value: string) => ( + {formatAdminDateTime(value)} + ), }, ] @@ -378,7 +391,9 @@ export default function FundSupportView() { extra={ + + + ) : ( + '-' + ), + }, + ] const entryColumns: TableColumnsType = [ { title: '客服', dataIndex: 'ownerName', width: 110, + align: 'center', render: (value: string) => value || '-', }, { title: '平台', dataIndex: 'platformKey', width: 100, + align: 'center', render: (value: string, row) => ( {row.platformLabel} ), @@ -167,6 +287,7 @@ export function PlatformCreditPanel() { title: '类型', dataIndex: 'entryType', width: 80, + align: 'center', render: (value: string) => ( {ENTRY_TYPE_LABELS[value] || value} ), @@ -175,7 +296,7 @@ export function PlatformCreditPanel() { title: '变动', dataIndex: 'quantityDelta', width: 100, - align: 'right', + align: 'center', render: (value: number) => value > 0 ? ( +{value} @@ -187,45 +308,49 @@ export function PlatformCreditPanel() { title: '余额', dataIndex: 'balanceAfter', width: 100, - align: 'right', + align: 'center', }, { title: '金额', dataIndex: 'amountFen', width: 110, - align: 'right', + align: 'center', render: (value: number) => `¥${(Number(value || 0) / 100).toFixed(2)}`, }, { title: '备注', dataIndex: 'note', + align: 'center', render: (value: string) => value || '-', }, { title: '时间', dataIndex: 'createdAt', - width: 150, - render: (value: string) => formatAdminDateTime(value), + width: 180, + align: 'center', + render: (value: string) => ( + {formatAdminDateTime(value)} + ), }, ] return ( - + - + {platforms.map((platform) => { const total = accounts .filter((item) => item.platformKey === platform.key) .reduce((sum, item) => sum + item.balanceQuantity, 0) return ( - + {canManage ? ( <> - - + ) : null} @@ -347,7 +480,7 @@ export function PlatformCreditPanel() { - +