diff --git a/apps/backend/src/db/migrations/111_manual_service_zero_placeholder.sql b/apps/backend/src/db/migrations/111_manual_service_zero_placeholder.sql new file mode 100644 index 00000000..b8720ab8 --- /dev/null +++ b/apps/backend/src/db/migrations/111_manual_service_zero_placeholder.sql @@ -0,0 +1,31 @@ +-- 人工履约成本金额的唯一真源是 manual_fulfillment_configs.credit_quantity × 平台换算率。 +-- cost_versions 里的 sys_manual_service 行只是 cost_snapshots.cost_version_id 的挂载点, +-- 金额不参与计算;历史上手工填的 ¥88 全局版本会让「成本预览」显示错误金额,这里清零。 + +UPDATE cost_versions v +SET amount_fen = 0, + rate_bps = 0, + change_reason = '系统占位:人工成本按人工 SKU 配置的消耗积分结算,版本金额不参与计算', + updated_at = NOW() +FROM cost_items ci +WHERE ci.id = v.cost_item_id + AND ci.code = 'sys_manual_service' + AND v.scope_type = 'global' + AND v.status = 'active'; + +-- 若人工成本项还没有生效版本,补一条 0 元全局占位,保证快照能挂到版本上。 +INSERT INTO cost_versions ( + cost_item_id, scope_type, provider, shop_id, canonical_product_id, + work_product_rule_id, amount_fen, rate_bps, effective_from, source, + status, change_reason, created_at, updated_at +) +SELECT + ci.id, 'global', '', '', NULL, NULL, 0, 0, + '1970-01-01T00:00:00+08:00', 'manual', 'active', + '系统占位:人工成本按人工 SKU 配置的消耗积分结算', NOW(), NOW() +FROM cost_items ci +WHERE ci.code = 'sys_manual_service' + AND NOT EXISTS ( + SELECT 1 FROM cost_versions v + WHERE v.cost_item_id = ci.id AND v.status = 'active' + ); diff --git a/apps/backend/src/routes/admin/cost-center.ts b/apps/backend/src/routes/admin/cost-center.ts index 88bce1da..aec509b2 100644 --- a/apps/backend/src/routes/admin/cost-center.ts +++ b/apps/backend/src/routes/admin/cost-center.ts @@ -17,6 +17,7 @@ import { previewAdminCost, resolveAdminCostException, retireAdminCostVersion, + summarizeAdminCostExceptions, updateAdminCostItem, } from '../../services/cost-center/cost-definition-service.js' import { @@ -515,6 +516,17 @@ router.get( }), ) +// 必须注册在 /cost-center/exceptions/:exceptionId 之前,避免 summary 被当成 ID。 +router.get( + '/cost-center/exceptions/summary', + requireAdminPermission('finance.view'), + createJsonHandler((req) => summarizeAdminCostExceptions(req.query as JsonObject), { + successMessage: 'ok', + errorMessage: '读取成本异常汇总失败', + scope: '[admin/cost-center/exceptions/summary]', + }), +) + router.patch( '/cost-center/exceptions/:exceptionId', requireAdminPermission('finance.config'), diff --git a/apps/backend/src/services/cost-center/cost-definition-service.ts b/apps/backend/src/services/cost-center/cost-definition-service.ts index 7bc7482f..18166eff 100644 --- a/apps/backend/src/services/cost-center/cost-definition-service.ts +++ b/apps/backend/src/services/cost-center/cost-definition-service.ts @@ -570,6 +570,17 @@ export async function listAdminCostExceptions(payload: JsonObject = {}) { params.push(subjectType) clauses.push(`ce.subject_type = $${params.length}`) } + const reason = text(payload.reason) + if (reason) { + params.push(reason) + clauses.push(`ce.reason = $${params.length}`) + } + // 时间范围:默认由前端传「近 N 天」,历史批量扫描产生的异常需要显式放开才能看到。 + const since = normalizeIsoDate(payload.since, '异常起始时间', true) + if (since) { + params.push(since) + clauses.push(`ce.created_at >= $${params.length}::timestamptz`) + } // 履约方式筛选:异常表不再存载体,改为 join order_items 读 fulfillment_mode。 // 用 IN 半连接而不是 EXISTS:248k 异常 × 316k 订单项下,EXISTS 会逐行回表, // 半连接能一次物化候选集合(实测 2.7s -> 0.4s)。 @@ -610,6 +621,90 @@ export async function listAdminCostExceptions(payload: JsonObject = {}) { } } +export type CostExceptionSummary = { + recentDays: number + openTotal: number + recentOpenTotal: number + historicalOpenTotal: number + reasons: Array<{ reason: string; openCount: number; recentOpenCount: number }> +} + +/** + * 成本异常汇总:按原因聚合,并区分「近 N 天新增」与「历史批量」。 + * + * 历史扫描会一次性写入十几万条开放异常,直接列全部没有可操作性; + * 页面先看近 N 天的量,再决定是否展开历史。 + */ +export async function summarizeAdminCostExceptions( + payload: JsonObject = {}, +): Promise { + const rawDays = toNumber(payload.recentDays ?? payload.recent_days ?? 7) + const recentDays = Math.min(Math.max(Number.isFinite(rawDays) ? Math.round(rawDays) : 7, 1), 90) + const params: unknown[] = [] + const clauses: string[] = [] + + const fulfillmentMode = text(payload.fulfillmentMode ?? payload.fulfillment_mode) + if (fulfillmentMode) { + assertOneOf(fulfillmentMode, FULFILLMENT_MODES, '履约方式无效') + params.push(fulfillmentMode) + clauses.push( + `ce.order_item_id IN ( + SELECT oi.id FROM order_items oi WHERE oi.fulfillment_mode = $${params.length} + )`, + ) + } + + const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '' + params.push(recentDays) + const recentInterval = `NOW() - make_interval(days => $${params.length}::int)` + + const totals = await query<{ open_total: number; recent_open_total: number }>( + ` + SELECT + COUNT(*) FILTER (WHERE ce.status = 'open')::int AS open_total, + COUNT(*) FILTER ( + WHERE ce.status = 'open' AND ce.created_at >= ${recentInterval} + )::int AS recent_open_total + FROM cost_exceptions ce + ${where} + `, + params, + ) + const reasons = await query<{ + reason: string + open_count: number + recent_open_count: number + }>( + ` + SELECT + ce.reason, + COUNT(*) FILTER (WHERE ce.status = 'open')::int AS open_count, + COUNT(*) FILTER ( + WHERE ce.status = 'open' AND ce.created_at >= ${recentInterval} + )::int AS recent_open_count + FROM cost_exceptions ce + ${where} + GROUP BY ce.reason + ORDER BY open_count DESC, ce.reason ASC + `, + params, + ) + + const openTotal = toNumber(totals.rows[0]?.open_total) + const recentOpenTotal = toNumber(totals.rows[0]?.recent_open_total) + return { + recentDays, + openTotal, + recentOpenTotal, + historicalOpenTotal: Math.max(0, openTotal - recentOpenTotal), + reasons: reasons.rows.map((row) => ({ + reason: row.reason, + openCount: toNumber(row.open_count), + recentOpenCount: toNumber(row.recent_open_count), + })), + } +} + export async function resolveAdminCostException( exceptionId: number | string, payload: JsonObject, @@ -691,6 +786,14 @@ export async function previewAdminCost(payload: JsonObject = {}) { }) } + // 人工履约的成本来自「人工 SKU 配置的消耗积分 × 平台换算率」,版本金额只是占位; + // 这里单独给出提示,并从成本行里剔掉 0 元占位,避免预览与实际扣款对不上。 + const manualCost = + context.fulfillmentMode === 'manual' ? await resolveManualCostHint(context.orderItemId) : null + const lines = manualCost + ? resolution.lines.filter((line) => line.costItemCode !== 'sys_manual_service') + : resolution.lines + return { context: { ...context, @@ -698,13 +801,52 @@ export async function previewAdminCost(payload: JsonObject = {}) { }, mapping: mapping ? mapMapping(mapping) : null, product, - lines: resolution.lines, - missingCostItems: resolution.missingCostItems, - totalCostFen: resolution.lines.reduce((sum, line) => sum + line.totalAmountFen, 0), + lines, + missingCostItems: resolution.missingCostItems.filter( + (item) => !(manualCost && item.costItemCode === 'sys_manual_service'), + ), + manualCost, + totalCostFen: lines.reduce((sum, line) => sum + line.totalAmountFen, 0), exceptions, } } +/** 人工履约成本提示:直接读订单项绑定的履约配置。 */ +async function resolveManualCostHint(orderItemId: number | null) { + if (!orderItemId) return null + const result = await query<{ + platform_key: string + credit_quantity: string + cost_mode: string + unit_price_fen: string + }>( + ` + SELECT + cfg.platform_key, + cfg.credit_quantity, + cfg.cost_mode, + COALESCE((fp.config_json->>'rechargeUnitPriceFen')::bigint, 1) AS unit_price_fen + FROM order_items oi + 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 + WHERE oi.id = $1 + LIMIT 1 + `, + [orderItemId], + ) + const row = result.rows[0] + if (!row) return null + const creditQuantity = Number(row.credit_quantity || 0) + const unitPriceFen = Number(row.unit_price_fen || 1) + return { + platformKey: row.platform_key, + creditQuantity, + unitPriceFen, + costMode: row.cost_mode === 'fixed' ? ('fixed' as const) : ('variable' as const), + costFen: creditQuantity > 0 ? creditQuantity * unitPriceFen : null, + } +} + export async function createAdminCostSnapshot(payload: JsonObject = {}, _session: AdminSession) { const preview = await previewAdminCost(payload) const orderItemId = preview.context.orderItemId diff --git a/apps/backend/src/services/cost-center/cost-snapshot-sync-service.ts b/apps/backend/src/services/cost-center/cost-snapshot-sync-service.ts index 3bc935e4..6fc3452e 100644 --- a/apps/backend/src/services/cost-center/cost-snapshot-sync-service.ts +++ b/apps/backend/src/services/cost-center/cost-snapshot-sync-service.ts @@ -81,6 +81,9 @@ export async function syncEstimatedOrderItemCostSnapshots( AND ${costModelScopeSql('s.fulfillment_mode')} AND ci.pricing_mode IN ('per_item', 'per_order', 'percent_of_revenue') AND ci.code <> 'sys_work_order_reward' + -- 人工成本只能在「确认履约」时按实际支出写 actual 快照, + -- 这里的版本金额是 0 元占位,不能拿来生成预估快照。 + AND ci.cost_model <> 'manual_actual' JOIN LATERAL ( SELECT v.id, v.amount_fen, v.rate_bps FROM cost_versions v @@ -697,6 +700,9 @@ async function insertAndResolveCostMissingExceptions( NOW() FROM item_scope s WHERE s.canonical_product_id IS NOT NULL + -- 人工履约不靠成本版本结算:金额由「人工 SKU 配置 × 换算率」决定, + -- 并在客服确认履约时写 manual_case 实际快照,因此不参与成本缺失判定。 + AND s.fulfillment_mode <> 'manual' AND NOT EXISTS ( SELECT 1 FROM cost_snapshots cs WHERE cs.subject_type = 'order_item' diff --git a/apps/frontend/src/pages/admin/AdminCostCenterPage.tsx b/apps/frontend/src/pages/admin/AdminCostCenterPage.tsx index c4e5b576..873fc257 100644 --- a/apps/frontend/src/pages/admin/AdminCostCenterPage.tsx +++ b/apps/frontend/src/pages/admin/AdminCostCenterPage.tsx @@ -6,10 +6,10 @@ * - ProductCatalogPanel.tsx 标准商品 * - ChannelMappingPanel.tsx 渠道映射 * - AffiliateSkuPanel.tsx 自动发货 SKU - * - CostItemPanel.tsx 成本项 - * - CostVersionPanel.tsx 成本版本 - * - CostExceptionPanel.tsx 成本异常 - * - CostPreviewPanel.tsx 成本预览 + * - ManualSkuConfigPanel.tsx 人工 SKU 配置(消耗积分即人工成本) + * - WorkOrderPricePanel.tsx 发单价格(只读核对接单模板发单金额) + * - CostExceptionPanel.tsx 成本异常(按原因聚合 + 近 24 小时默认) + * - CostPreviewModal.tsx 成本解析弹窗(异常行 / 订单详情进入) */ import CostCenterShell from './cost-center/CostCenterShell' diff --git a/apps/frontend/src/pages/admin/AdminOrderDetailPage.tsx b/apps/frontend/src/pages/admin/AdminOrderDetailPage.tsx index dacddf6f..65aae79a 100644 --- a/apps/frontend/src/pages/admin/AdminOrderDetailPage.tsx +++ b/apps/frontend/src/pages/admin/AdminOrderDetailPage.tsx @@ -34,6 +34,8 @@ import type { AdminOrderDetail, AdminTaskListItem } from '@/types/admin' import { formatAdminDateTime } from '@/utils/admin-time' import { resolveOrderFulfillmentMode, resolveVoucherStateLabel } from '@/utils/admin-display' +import { CostPreviewModal } from './cost-center/CostPreviewModal' + export default function AdminOrderDetailPage() { const navigate = useNavigate() const queryClient = useQueryClient() @@ -42,6 +44,7 @@ export default function AdminOrderDetailPage() { const [searchParams, setSearchParams] = useSearchParams() const [statusModalVisible, setStatusModalVisible] = useState(false) const [manualCostOpen, setManualCostOpen] = useState(false) + const [costPreviewItemId, setCostPreviewItemId] = useState(null) const [targetStatus, setTargetStatus] = useState<'active' | 'paused' | 'closed'>('active') const [statusReason, setStatusReason] = useState('') @@ -349,6 +352,15 @@ export default function AdminOrderDetailPage() { }, { title: '数量', dataIndex: 'quantity', width: 90 }, { title: '履约模式', dataIndex: 'deliveryMode', minWidth: 140 }, + { + title: '成本', + width: 110, + render: (_, item) => ( + + ), + }, ]} /> @@ -408,6 +420,12 @@ export default function AdminOrderDetailPage() { }))} onClose={closeManualCost} /> + + setCostPreviewItemId(null)} + /> ) } diff --git a/apps/frontend/src/pages/admin/cost-center/CostCenterShell.tsx b/apps/frontend/src/pages/admin/cost-center/CostCenterShell.tsx index 1d6331f4..43b3360a 100644 --- a/apps/frontend/src/pages/admin/cost-center/CostCenterShell.tsx +++ b/apps/frontend/src/pages/admin/cost-center/CostCenterShell.tsx @@ -17,11 +17,9 @@ import { hasAdminPermission } from '@/utils/admin-auth' import { AffiliateSkuPanel } from './AffiliateSkuPanel' import { ChannelMappingPanel } from './ChannelMappingPanel' import { CostExceptionPanel } from './CostExceptionPanel' -import { CostItemPanel } from './CostItemPanel' -import { CostPreviewPanel } from './CostPreviewPanel' -import { CostVersionPanel } from './CostVersionPanel' import { ManualSkuConfigPanel } from './ManualSkuConfigPanel' import { ProductCatalogPanel } from './ProductCatalogPanel' +import { WorkOrderPricePanel } from './WorkOrderPricePanel' import { CostCenterContext } from './CostCenterContext' import type { CostScope } from './shared' @@ -58,12 +56,10 @@ const SCOPE_TAG_COLORS: Record = { function tabKeysForScope(scope: CostScope) { return [ ...(scope === 'all' || scope === 'work_order' ? ['products', 'mappings'] : []), - ...(scope === 'affiliate_dash' ? ['affiliate-skus'] : []), + ...(scope === 'all' || scope === 'affiliate_dash' ? ['affiliate-skus'] : []), ...(scope === 'all' || scope === 'manual' ? ['manual-skus'] : []), - 'cost-items', - 'cost-versions', + ...(scope === 'all' || scope === 'work_order' ? ['prices'] : []), 'exceptions', - 'preview', ] } @@ -93,7 +89,7 @@ export default function CostCenterShell() { useEffect(() => { if (!visibleTabKeys.includes(activeTab)) { - setActiveTab(visibleTabKeys[0] ?? 'cost-items') + setActiveTab(visibleTabKeys[0] ?? 'exceptions') } }, [activeTab, setActiveTab, visibleTabKeys]) @@ -102,9 +98,9 @@ export default function CostCenterShell() { void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-options'] }) void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-products'] }) void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-mappings'] }) - void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-cost-items'] }) void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-cost-versions'] }) void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-exceptions'] }) + void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-exceptions-summary'] }) } const scopeLabel = @@ -139,7 +135,7 @@ export default function CostCenterShell() {
} @@ -155,8 +151,8 @@ export default function CostCenterShell() { {[ ['标准商品', overview?.productCount || 0], ['渠道映射', overview?.mappingCount || 0], - ['成本项目', overview?.costItemCount || 0], - ['生效版本', overview?.activeVersionCount || 0], + ['系统成本项', overview?.costItemCount || 0], + ['生效发单价格', overview?.activeVersionCount || 0], ['待处理异常', overview?.openExceptionCount || 0], ['未映射订单项', overview?.unmappedOrderItemCount || 0], ].map(([label, value]) => ( @@ -179,7 +175,7 @@ export default function CostCenterShell() { ? 'products' : next === 'affiliate_dash' ? 'affiliate-skus' - : 'cost-items', + : 'manual-skus', ) }} items={SCOPES.map((item) => { @@ -187,16 +183,12 @@ export default function CostCenterShell() { item.key === 'all' ? overview?.productCount : overview?.productSourceCounts?.[item.key] - const costItemCount = item.key === 'all' ? overview?.costItemCount : undefined return { key: item.key, label: ( {item.label} - - {Number(productCount || 0)} 商品 - {costItemCount === undefined ? '' : ` / ${Number(costItemCount)} 成本项`} - + {Number(productCount || 0)} 商品 ), children: {item.hint}, @@ -210,7 +202,7 @@ export default function CostCenterShell() { type="info" showIcon message="当前账号为只读权限" - description="可以查看商品、映射、成本版本和异常;新增/修改需要 finance.config 权限。" + description="可以查看商品、映射、发单价格和成本异常;新增/修改需要 finance.config 权限。" /> ) : null} @@ -239,25 +231,24 @@ export default function CostCenterShell() { children: , }, { - key: 'cost-items', - label: '成本项目', - children: , - }, - { - key: 'cost-versions', - label: '成本版本', - children: , + key: 'prices', + label: '发单价格', + children: , }, { key: 'exceptions', - label: '成本异常', + label: ( + + 成本异常 + {Number(overview?.openExceptionCount || 0) > 0 ? ( + + {Number(overview?.openExceptionCount || 0)} + + ) : null} + + ), children: , }, - { - key: 'preview', - label: '成本预览', - children: , - }, ].filter((item) => visibleTabKeys.includes(item.key))} />
diff --git a/apps/frontend/src/pages/admin/cost-center/CostExceptionPanel.tsx b/apps/frontend/src/pages/admin/cost-center/CostExceptionPanel.tsx index 8087bf13..310486b9 100644 --- a/apps/frontend/src/pages/admin/cost-center/CostExceptionPanel.tsx +++ b/apps/frontend/src/pages/admin/cost-center/CostExceptionPanel.tsx @@ -1,21 +1,27 @@ /** * 成本异常 Tab:成本缺失 / 冲突队列。 * - * 异常表不再存履约载体,履约方式从 order_items.fulfillment_mode join 得到; - * 前端按履约方式筛选时传 fulfillmentMode 参数。 + * 历史批量扫描会一次性写入十几万条开放异常,直接全量列表没有可操作性: + * 页面先按原因聚合,默认只看近 24 小时,历史存量需要显式切换时间范围。 + * 每行可直接打开「成本解析」定位是哪个 SKU 没归一、哪条成本没命中。 */ -import { App, Button, Card, Select, Space, Tag, Typography } from 'antd' +import { App, Button, Card, Col, Row, Select, Space, Statistic, Tag, Typography } from 'antd' import PercentTable from '@/components/common/PercentTable' import type { TableColumnsType } from 'antd' import { useQuery } from '@tanstack/react-query' import { useState } from 'react' -import { fetchAdminCostExceptions, resolveAdminCostException } from '@/services/admin' +import { + fetchAdminCostExceptionSummary, + fetchAdminCostExceptions, + resolveAdminCostException, +} from '@/services/admin' import type { CostException, FulfillmentMode } from '@/types/admin' import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination' import { formatAdminDateTime } from '@/utils/admin-time' import { useCostCenter } from './CostCenterContext' +import { CostPreviewModal } from './CostPreviewModal' const FULFILLMENT_MODE_TAG_COLORS: Record = { work_order: 'blue', @@ -31,20 +37,63 @@ const FULFILLMENT_MODE_LABELS: Record = { manual: '人工客服', } +const REASON_LABELS: Record = { + product_mapping_missing: '商品未归一', + cost_missing: '成本缺失', +} + +const RANGE_OPTIONS = [ + { value: 1, label: '近 24 小时' }, + { value: 7, label: '近 7 天' }, + { value: 30, label: '近 30 天' }, + { value: 0, label: '全部历史' }, +] + export function CostExceptionPanel() { const { message, modal } = App.useApp() const { canConfig, fulfillmentModeFilter, refreshAll } = useCostCenter() const [status, setStatus] = useState('open') + const [rangeDays, setRangeDays] = useState(1) + const [reason, setReason] = useState(undefined) const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) + const [previewItemId, setPreviewItemId] = useState(null) + + // 时间下界在取数时计算,避免渲染期调用 Date.now(React 纯度规则)。 + const summaryQuery = useQuery({ + queryKey: [ + 'admin-cost-center-exceptions-summary', + fulfillmentModeFilter, + rangeDays <= 0 ? 1 : rangeDays, + ], + queryFn: () => + fetchAdminCostExceptionSummary({ + fulfillmentMode: fulfillmentModeFilter, + recentDays: rangeDays <= 0 ? 1 : rangeDays, + }), + }) + const summary = summaryQuery.data?.data const exceptionsQuery = useQuery({ - queryKey: ['admin-cost-center-exceptions', fulfillmentModeFilter, status, page, pageSize], + queryKey: [ + 'admin-cost-center-exceptions', + fulfillmentModeFilter, + status, + rangeDays, + reason, + page, + pageSize, + ], queryFn: () => fetchAdminCostExceptions({ status: status || undefined, fulfillmentMode: fulfillmentModeFilter, + reason, + since: + rangeDays > 0 + ? new Date(Date.now() - rangeDays * 24 * 60 * 60 * 1000).toISOString() + : undefined, page, pageSize, }), @@ -56,7 +105,7 @@ export function CostExceptionPanel() { function resolve(item: CostException, nextStatus: 'resolved' | 'ignored') { modal.confirm({ title: nextStatus === 'resolved' ? '标记异常已处理' : '忽略异常', - content: `确认处理「${item.reason}」?`, + content: `确认处理「${REASON_LABELS[item.reason] || item.reason}」?`, okText: '确认', cancelText: '取消', onOk: async () => { @@ -72,17 +121,29 @@ export function CostExceptionPanel() { } const columns: TableColumnsType = [ - { title: '主体', dataIndex: 'subjectType', width: 110 }, + { + title: '原因', + dataIndex: 'reason', + width: 300, + render: (value: string, row) => ( + + {REASON_LABELS[value] || value} + + {String(row.detail?.message || '')} + + + ), + }, { title: '订单项', dataIndex: 'orderItemId', - width: 100, - render: (value) => (value ? `#${value}` : '-'), + width: 110, + render: (value: number | null) => (value ? `#${value}` : '-'), }, { title: '履约方式', dataIndex: 'fulfillmentMode', - width: 110, + width: 120, render: (value: FulfillmentMode | '') => value ? ( @@ -92,21 +153,10 @@ export function CostExceptionPanel() { 未知 ), }, - { - title: '原因', - dataIndex: 'reason', - width: 360, - render: (value: string, row) => ( - - {value} - {String(row.detail?.message || '')} - - ), - }, { title: '状态', dataIndex: 'status', - width: 90, + width: 100, render: (value: string) => ( {value === 'open' ? '待处理' : value === 'resolved' ? '已处理' : '已忽略'} @@ -121,18 +171,28 @@ export function CostExceptionPanel() { }, { title: '操作', - width: 170, - render: (_, row) => - canConfig && row.status === 'open' ? ( - - - - - ) : null, + width: 230, + render: (_, row) => ( + + + {canConfig && row.status === 'open' ? ( + <> + + + + ) : null} + + ), }, ] @@ -140,27 +200,94 @@ export function CostExceptionPanel() { - 成本缺失 / 冲突 - {total} 条 + 成本异常 + 待处理 {Number(summary?.openTotal ?? 0)} + + 近 {rangeDays <= 0 ? 1 : rangeDays} 天新增 {Number(summary?.recentOpenTotal ?? 0)} + + {Number(summary?.historicalOpenTotal ?? 0) > 0 ? ( + 历史存量 {Number(summary?.historicalOpenTotal ?? 0)} + ) : null} } extra={ - { + setRangeDays(value) + setPage(1) + }} + options={RANGE_OPTIONS} + style={{ width: 130 }} + /> + } - placeholder="编码 / 名称" - value={keyword} - onChange={(event) => { - setKeyword(event.target.value) - setPage(1) - }} - style={{ width: 260 }} - /> - - `共 ${count} 个成本项`, - onChange: (nextPage, nextPageSize) => { - setPage(nextPage) - setPageSize(nextPageSize) - }, - })} - /> - - - setModalOpen(false)} - onOk={() => costItemForm.submit()} - okText="保存" - cancelText="取消" - destroyOnClose - > -
- - - - - - - - - - - - } - placeholder="成本项 / 商品" - value={keyword} - onChange={(event) => { - setKeyword(event.target.value) - setPage(1) - }} - style={{ width: 260 }} - /> - - - - ({ - value: provider, - label: providerLabel(provider), - }))} - /> - - ) : null} - {versionScopeType === 'shop' ? ( - - - - ) : null} - {versionScopeType === 'product' ? ( - - - - ) : null} - {versionPricingMode === 'percent_of_revenue' ? ( - - - - ) : ( - - - - )} - - - - - - - - - - -
- - ) -} - -/** 只有接单平台(或全部)才需要同步模板发单金额。 */ -function fulfillmentFilterAllowsTemplateSync( - fulfillmentMode: 'affiliate_dash' | 'work_order' | 'manual' | undefined, -) { - return fulfillmentMode === undefined || fulfillmentMode === 'work_order' -} diff --git a/apps/frontend/src/pages/admin/cost-center/ManualSkuConfigPanel.tsx b/apps/frontend/src/pages/admin/cost-center/ManualSkuConfigPanel.tsx index c8ec3461..2737722d 100644 --- a/apps/frontend/src/pages/admin/cost-center/ManualSkuConfigPanel.tsx +++ b/apps/frontend/src/pages/admin/cost-center/ManualSkuConfigPanel.tsx @@ -291,7 +291,7 @@ export function ManualSkuConfigPanel() { showIcon style={{ marginBottom: 14 }} message="配置主键是内部 SKU,不是商品名称" - description="扫描后列出所有走到人工履约的内部 SKU。运营在这里配置「履约渠道 + 消耗积分」,保存后点「重新匹配」回填到历史订单项。客服确认履约时直接读配置,固定成本零输入。消耗积分填 0 表示成本不固定,由客服在确认履约时手填。" + description="扫描后列出所有走到人工履约的内部 SKU。运营在这里配置「履约渠道 + 消耗积分」,保存后点「重新匹配」回填到历史订单项。消耗积分 × 平台换算率就是这单的人工成本,确认履约时直接扣减,不需要再去成本版本里配一次;消耗积分填 0 表示成本不固定,由客服在确认履约时手填。" /> diff --git a/apps/frontend/src/pages/admin/cost-center/WorkOrderPricePanel.tsx b/apps/frontend/src/pages/admin/cost-center/WorkOrderPricePanel.tsx new file mode 100644 index 00000000..52a958f1 --- /dev/null +++ b/apps/frontend/src/pages/admin/cost-center/WorkOrderPricePanel.tsx @@ -0,0 +1,246 @@ +/** + * 发单价格 Tab:只读展示「接单模板 → 发单金额」的当前生效版本。 + * + * 接单平台的成本以打手报酬为主,金额由接单模板(work_product_rules)决定, + * 这里只做核对视图;改价请改模板后点「同步模板发单金额」。 + * 人工与自动发货的成本分别在各自 SKU 配置里维护,不在这里重复配置。 + */ +import { SearchOutlined, SyncOutlined } from '@ant-design/icons' +import { Alert, App, Button, Card, Input, Select, Space, Tag, Typography } from 'antd' +import PercentTable from '@/components/common/PercentTable' +import type { TableColumnsType } from 'antd' +import { useQuery } from '@tanstack/react-query' +import { useState } from 'react' + +import { fetchAdminCostVersions, syncAdminWorkOrderTemplateCostVersions } from '@/services/admin' +import type { CostVersion } from '@/types/admin' +import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination' +import { formatAdminDateTime } from '@/utils/admin-time' +import { formatMoney } from '../panels/shared' + +import { useCostCenter } from './CostCenterContext' +import { formatRate } from './shared' + +export function WorkOrderPricePanel() { + const { message } = App.useApp() + const { canConfig, refreshAll } = useCostCenter() + + const [keyword, setKeyword] = useState('') + const [status, setStatus] = useState('active') + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) + const [syncing, setSyncing] = useState(false) + + const versionsQuery = useQuery({ + queryKey: ['admin-cost-center-cost-versions', 'work_order', keyword, status, page, pageSize], + queryFn: () => + fetchAdminCostVersions({ + keyword: keyword || undefined, + status: status || undefined, + fulfillmentMode: 'work_order', + page, + pageSize, + }), + }) + + const versions = versionsQuery.data?.data.items || [] + const total = versionsQuery.data?.data.pagination.total || 0 + + async function syncTemplateCosts() { + setSyncing(true) + try { + const response = await syncAdminWorkOrderTemplateCostVersions() + const result = response.data + message.success( + `已同步 ${result.activeVersionCount} 条模板发单金额(新建 ${result.createdCount},停用 ${result.retiredCount}),关联 ${result.linkedSnapshotCount} 条成本快照`, + ) + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '同步接单模板发单金额失败') + } finally { + setSyncing(false) + } + } + + const columns: TableColumnsType = [ + { + title: '接单规则 / 模板', + dataIndex: 'workProductRuleName', + width: 300, + render: (_, row) => { + if (row.scopeType === 'global') { + return ( + + 全局占位 + + 按工单实际结算,金额取工单 reward_amount + + + ) + } + const label = + row.workProductRuleName || + (row.workProductRuleId ? `规则 #${row.workProductRuleId}` : '历史模板版本') + const sub = + row.workProductRuleKey || + (row.workProductRuleId ? '' : row.changeReason || '模板已删除,历史版本保留') + return ( + + {label} + + {sub} + + + ) + }, + }, + { + title: '发单金额 / 比例', + width: 220, + render: (_, row) => { + if (row.isSystemPlaceholder) return 按工单实际结算 + if (row.pricingMode === 'percent_of_revenue') return formatRate(row.rateBps) + if (row.amountKind === 'unit_price') { + return ( + + {formatMoney(row.amountFen)} / 件 + + {row.revenueItemCount > 0 + ? `平均实发 ${formatMoney(row.rewardAvgFen)} / 项` + : '暂无成交'} + + + ) + } + return ( + + {formatMoney(row.amountFen)} + {row.revenueItemCount > 0 ? ( + + 平均实发 {formatMoney(row.rewardAvgFen)} / 项 + + ) : null} + + ) + }, + }, + { + title: '用户实付均价', + width: 190, + render: (_, row) => + row.revenueItemCount > 0 ? ( + + {formatMoney(row.revenueAvgFen)} + + {row.revenueItemCount} 项 / 合计 {formatMoney(row.revenueFen)} + + + ) : ( + '-' + ), + }, + { + title: '生效时间', + dataIndex: 'effectiveFrom', + width: 200, + render: (value: string, row) => ( + + {row.isSystemPlaceholder ? '系统占位' : formatAdminDateTime(value)} + + {row.isSystemPlaceholder + ? '按工单实际结算' + : row.effectiveTo + ? `至 ${formatAdminDateTime(row.effectiveTo)}` + : '长期'} + + + ), + }, + { + title: '状态', + dataIndex: 'status', + width: 100, + render: (value: string) => ( + + {value === 'active' ? '生效' : value === 'draft' ? '草稿' : '停用'} + + ), + }, + ] + + return ( + + 发单价格(只读) + {total} 条 + + } + extra={ + canConfig ? ( + + ) : null + } + > + + + } + placeholder="模板 / 商品 / 规则" + value={keyword} + onChange={(event) => { + setKeyword(event.target.value) + setPage(1) + }} + style={{ width: 260 }} + /> +