feat(cost-center): 总览统计支持按来源收敛

页面顶部的 6 张统计卡原本是全局口径:选了「自动发货」,商品数还是 2017、
异常数还是 108,314,和下面的列表对不上。

- overview 接口接受 scope(all / affiliate_dash / work_order / manual);
- 商品、映射、成本项、生效版本、待处理异常、未映射订单项全部按来源过滤:
  商品看 canonical_sku 前缀(aff: / tpl:+wo: / man:),
  成本项与版本看 cost_model,异常与未映射订单项看 order_items.fulfillment_mode;
- 额外返回 productTotalCount(全局商品数),供「全部」徽标使用,
  避免切到单一来源后「全部」也被算成 49。

实测四种口径:
  all            商品 2017 / 映射 4897 / 成本项 3 / 版本 712 / 异常 108314 / 未映射 53901
  affiliate_dash 商品   49 / 映射    0 / 成本项 1 / 版本   0 / 异常  53204 / 未映射     0
  work_order     商品  773 / 映射 4897 / 成本项 1 / 版本 711 / 异常   1209 / 未映射     0
  manual         商品 1195 / 映射    0 / 成本项 1 / 版本   1 / 异常      0 / 未映射     0
This commit is contained in:
yml2213
2026-10-25 13:55:25 +08:00
parent 39fe448b48
commit 88ba87efff
4 changed files with 91 additions and 10 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ router.use('/cost-center', requireAdminRoles(['admin', 'operator', 'finance', 's
router.get(
'/cost-center/overview',
requireAdminPermission('finance.view'),
createJsonHandler(() => getAdminCostCenterOverview(), {
createJsonHandler((req) => getAdminCostCenterOverview({ scope: req.query.scope as string }), {
successMessage: 'ok',
errorMessage: '读取成本中心总览失败',
scope: '[admin/cost-center/overview]',
@@ -8,9 +8,24 @@
import { query } from '../../db/client.js'
import { PROVIDERS, productSourceOf, toNumber } from './cost-center-shared.js'
export async function getAdminCostCenterOverview() {
export type CostCenterScope = 'affiliate_dash' | 'work_order' | 'manual'
/**
* 成本中心总览。
*
* scope 为空表示「全部来源」;传入具体来源时,商品/映射/成本项/版本/异常/未映射
* 六项统计都按该来源收敛,页面顶部数字才会和下面的列表对得上。
*
* 来源与成本模型的对应关系:
* affiliate_dash → affiliate_purchase
* work_order → work_order_reward
* manual → manual_actual
*/
export async function getAdminCostCenterOverview(params: { scope?: string } = {}) {
const scope = normalizeScope(params.scope)
const result = await query<{
product_count: number
product_total_count: number
mapping_count: number
cost_item_count: number
active_version_count: number
@@ -18,14 +33,65 @@ export async function getAdminCostCenterOverview() {
unmapped_order_item_count: number
}>(
`
WITH scoped_products AS (
SELECT p.id
FROM product_catalog p
WHERE p.status = 'active'
AND (
$1::text IS NULL
OR ($1 = 'affiliate_dash' AND p.canonical_sku LIKE 'aff:%')
OR ($1 = 'work_order' AND (p.canonical_sku LIKE 'tpl:%' OR p.canonical_sku LIKE 'wo:%'))
OR ($1 = 'manual' AND p.canonical_sku LIKE 'man:%')
)
),
scoped_cost_models AS (
SELECT CASE $1
WHEN 'affiliate_dash' THEN 'affiliate_purchase'
WHEN 'work_order' THEN 'work_order_reward'
WHEN 'manual' THEN 'manual_actual'
END AS cost_model
)
SELECT
(SELECT COUNT(*)::int FROM product_catalog WHERE status = 'active') AS product_count,
(SELECT COUNT(*)::int FROM channel_product_mappings WHERE enabled = TRUE) AS mapping_count,
(SELECT COUNT(*)::int FROM cost_items WHERE status = 'active') AS cost_item_count,
(SELECT COUNT(*)::int FROM cost_versions WHERE status = 'active') AS active_version_count,
(SELECT COUNT(*)::int FROM cost_exceptions WHERE status = 'open') AS open_exception_count,
(SELECT COUNT(*)::int FROM order_items WHERE canonical_product_id IS NULL) AS unmapped_order_item_count
(SELECT COUNT(*)::int FROM scoped_products) AS product_count,
(SELECT COUNT(*)::int FROM product_catalog WHERE status = 'active') AS product_total_count,
(
SELECT COUNT(*)::int
FROM channel_product_mappings m
JOIN scoped_products p ON p.id = m.canonical_product_id
WHERE m.enabled = TRUE
) AS mapping_count,
(
SELECT COUNT(*)::int
FROM cost_items ci
WHERE ci.status = 'active'
AND ($1::text IS NULL OR ci.cost_model = (SELECT cost_model FROM scoped_cost_models))
) AS cost_item_count,
(
SELECT COUNT(*)::int
FROM cost_versions v
JOIN cost_items ci ON ci.id = v.cost_item_id
WHERE v.status = 'active'
AND ($1::text IS NULL OR ci.cost_model = (SELECT cost_model FROM scoped_cost_models))
) AS active_version_count,
(
SELECT COUNT(*)::int
FROM cost_exceptions ce
WHERE ce.status = 'open'
AND (
$1::text IS NULL
OR ce.order_item_id IN (
SELECT oi.id FROM order_items oi WHERE oi.fulfillment_mode = $1
)
)
) AS open_exception_count,
(
SELECT COUNT(*)::int
FROM order_items oi
WHERE oi.canonical_product_id IS NULL
AND ($1::text IS NULL OR oi.fulfillment_mode = $1)
) AS unmapped_order_item_count
`,
[scope],
)
const row = result.rows[0]
const [productSourceResult, costModelResult] = await Promise.all([
@@ -41,7 +107,9 @@ export async function getAdminCostCenterOverview() {
])
return {
scope: scope ?? 'all',
productCount: toNumber(row?.product_count),
productTotalCount: toNumber(row?.product_total_count),
mappingCount: toNumber(row?.mapping_count),
costItemCount: toNumber(row?.cost_item_count),
activeVersionCount: toNumber(row?.active_version_count),
@@ -52,6 +120,13 @@ export async function getAdminCostCenterOverview() {
}
}
const COST_CENTER_SCOPES = new Set(['affiliate_dash', 'work_order', 'manual'])
function normalizeScope(raw: unknown): CostCenterScope | null {
const value = String(raw ?? '').trim()
return COST_CENTER_SCOPES.has(value) ? (value as CostCenterScope) : null
}
/** 商品来源计数:按 canonical_sku 前缀归类,未分类单独成桶。 */
function productSourceCountsOf(rows: Array<{ canonical_sku: string; total: number }>) {
const counts: Record<string, number> = {
@@ -31,8 +31,10 @@ import type {
UnmappedMappingGroupListResult,
} from '@/types/admin'
export function fetchAdminCostCenterOverview() {
return apiGet<CostCenterOverview>('/api/v1/admin/cost-center/overview')
export function fetchAdminCostCenterOverview(params: { scope?: string } = {}) {
return apiGet<CostCenterOverview>('/api/v1/admin/cost-center/overview', {
scope: params.scope && params.scope !== 'all' ? params.scope : undefined,
})
}
export function fetchAdminCostCenterOptions() {
@@ -20,7 +20,11 @@ export type CostModel =
| 'other'
export interface CostCenterOverview {
/** 统计口径:all 或具体来源;服务端回传便于核对。 */
scope?: string
productCount: number
/** 全局商品总数(不随 scope 变化),用于「全部」徽标。 */
productTotalCount?: number
mappingCount: number
costItemCount: number
activeVersionCount: number