From 54d2eb2f593f28d648b1f8f6c6c36cc7b46768af Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 22 Oct 2026 18:41:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(finance):=20=E8=87=AA=E5=8A=A8=E5=8F=91?= =?UTF-8?q?=E8=B4=A7=E8=AE=A2=E5=8D=95=E9=A1=B9=E6=8C=89=E4=B8=8A=E6=B8=B8?= =?UTF-8?q?=20SKU=20=E7=9B=B4=E6=8E=A5=E5=BD=92=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自动发货只有 affiliate-dash 单一上游渠道,订单项直接按 fulfillment_tasks.context_json.skuCode 归一为 aff:,不写 channel_product_mappings。\n\n新增 /cost-center/affiliate-products/backfill:按 order_item_id 取最新任务 SKU 回填 canonical_product_id,只更新有变化的订单项,并自动重算受影响日期的销售事实;返回 updatedCount/skippedCount/remainingCount。\n\n自动发货 SKU 列表改为实时统计履约任务数与最近履约时间,不读会被价格导入覆盖的 attributes_json。\n\n本地实测 41,676 个订单项全部归一,自动发货未归一收入 5,089,712.26 元降至 0;整体未归一占比 79.67% 降至 60.47%。 --- apps/backend/src/routes/admin/cost-center.ts | 17 +++ .../admin/admin-cost-center-service.ts | 144 +++++++++++++++++- .../src/pages/admin/AdminCostCenterPage.tsx | 40 ++++- .../src/services/admin/cost-center.ts | 17 +++ apps/frontend/src/types/admin/cost-center.ts | 3 + docs/README.md | 2 +- docs/成本中心与三块履约模型.md | 14 +- 7 files changed, 227 insertions(+), 10 deletions(-) diff --git a/apps/backend/src/routes/admin/cost-center.ts b/apps/backend/src/routes/admin/cost-center.ts index 1406b28d..2a6bcaac 100644 --- a/apps/backend/src/routes/admin/cost-center.ts +++ b/apps/backend/src/routes/admin/cost-center.ts @@ -3,6 +3,7 @@ import { Router } from 'express' import { applyAdminTemplateProductBootstrap, + backfillAdminAffiliateDashSkus, backfillAdminNormalizedQuantities, backfillAdminProductMappings, createAdminChannelMapping, @@ -116,6 +117,22 @@ router.post( }), ) +router.post( + '/cost-center/affiliate-products/backfill', + requireAdminPermission('finance.config'), + createJsonHandler(() => backfillAdminAffiliateDashSkus(), { + successMessage: '自动发货 SKU 已回填', + errorMessage: '回填自动发货 SKU 失败', + scope: '[admin/cost-center/affiliate-products/backfill]', + audit: (_req, data) => ({ + action: 'cost_affiliate_skus_backfilled', + targetType: 'order_items', + targetId: '', + data: data as Record, + }), + }), +) + router.get( '/cost-center/products', requireAdminPermission('finance.view'), diff --git a/apps/backend/src/services/admin/admin-cost-center-service.ts b/apps/backend/src/services/admin/admin-cost-center-service.ts index 4f7cb5f4..9ef9afa9 100644 --- a/apps/backend/src/services/admin/admin-cost-center-service.ts +++ b/apps/backend/src/services/admin/admin-cost-center-service.ts @@ -2,7 +2,9 @@ import { query, withTransaction } from '../../db/client.js' import type { AdminSession } from './admin-auth-service.js' import type { JsonObject } from '../../types/json.js' import { createHttpError } from '../../utils/http.js' +import { shiftDate } from '../../utils/finance-date-range.js' import { nowIso } from '../../utils/time.js' +import { rebuildSalesDailyFacts } from '../finance/sales-facts-service.js' import { normalizeMatchText, normalizeSkuSeriesEquivalence } from '../product-match/normalize.js' import { resolveSkuNameQuantity } from '../product-match/pricing.js' @@ -59,6 +61,24 @@ const PRODUCT_CATEGORY_JOINS = ` ) c ON TRUE LEFT JOIN work_categories root ON root.id = COALESCE(c.parent_id, c.id) ` +/** + * 自动发货的履约证据按上游 SKU 聚合一次,再挂到 aff: 商品上。 + * 商品表里的 attributes_json 会被价格导入覆盖,不能作为统计源。 + */ +const AFFILIATE_TASK_AGG_JOIN = ` + LEFT JOIN ( + SELECT + btrim(context_json->>'skuCode') AS sku_code, + COUNT(*)::int AS task_count, + MAX(created_at) AS last_fulfilled_at + FROM fulfillment_tasks + WHERE executor_key = 'affiliate_dash' + AND btrim(COALESCE(context_json->>'skuCode', '')) <> '' + GROUP BY 1 + ) aff ON p.carrier_type = 'affiliate_dash' + AND p.canonical_sku LIKE 'aff:%' + AND aff.sku_code = substring(p.canonical_sku from 5) +` type ProductRow = { [column: string]: unknown @@ -78,6 +98,8 @@ type ProductRow = { updated_at: string mapping_count?: number cost_version_count?: number + affiliate_task_count?: number + affiliate_last_fulfilled_at?: string | null } type ProductCategoryStatRow = { @@ -444,9 +466,12 @@ export async function listAdminProducts(payload: JsonObject = {}) { root.id AS root_category_id, root.name AS root_category_name, (SELECT COUNT(*)::int FROM channel_product_mappings m WHERE m.canonical_product_id = p.id) AS mapping_count, - (SELECT COUNT(*)::int FROM cost_versions v WHERE v.canonical_product_id = p.id AND v.status = 'active') AS cost_version_count + (SELECT COUNT(*)::int FROM cost_versions v WHERE v.canonical_product_id = p.id AND v.status = 'active') AS cost_version_count, + COALESCE(aff.task_count, 0) AS affiliate_task_count, + aff.last_fulfilled_at AS affiliate_last_fulfilled_at FROM product_catalog p ${PRODUCT_CATEGORY_JOINS} + ${AFFILIATE_TASK_AGG_JOIN} ${where} ORDER BY p.status ASC, @@ -1524,6 +1549,121 @@ export async function syncAdminAffiliateDashProducts() { } } +type AffiliateSkuBackfillRow = { + affiliate_item_count: number + matched_count: number + updated_count: number + from_date: string | null + to_date: string | null +} + +/** + * 按自动发货履约证据直接回填订单项标准商品。 + * + * 自动发货只有 affiliate-dash 一个上游渠道,SKU 固定归一为 `aff:`, + * 不写 channel_product_mappings。多个任务共用同一订单项时取最新 SKU。 + */ +export async function backfillAdminAffiliateDashSkus() { + const now = nowIso() + const result = await query( + ` + WITH affiliate_item AS ( + SELECT DISTINCT ON (ft.order_item_id) + ft.order_item_id AS id, + btrim(ft.context_json->>'skuCode') AS sku_code + FROM fulfillment_tasks ft + WHERE ft.executor_key = 'affiliate_dash' + AND btrim(COALESCE(ft.context_json->>'skuCode', '')) <> '' + ORDER BY ft.order_item_id, ft.created_at DESC, ft.id DESC + ), + target AS ( + SELECT ai.id, p.id AS canonical_product_id + FROM affiliate_item ai + JOIN product_catalog p + ON p.canonical_sku = 'aff:' || ai.sku_code + ), + updated AS ( + UPDATE order_items oi + SET canonical_product_id = target.canonical_product_id, + updated_at = $1 + FROM target + WHERE oi.id = target.id + AND oi.canonical_product_id IS DISTINCT FROM target.canonical_product_id + RETURNING oi.id + ) + SELECT + ( + SELECT COUNT(DISTINCT ft.order_item_id)::int + FROM fulfillment_tasks ft + WHERE ft.executor_key = 'affiliate_dash' + ) AS affiliate_item_count, + (SELECT COUNT(*)::int FROM target) AS matched_count, + (SELECT COUNT(*)::int FROM updated) AS updated_count, + ( + SELECT to_char( + MIN((COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date), + 'YYYY-MM-DD' + ) + FROM target + JOIN order_items oi ON oi.id = target.id + JOIN orders o ON o.id = oi.order_id + ) AS from_date, + ( + SELECT to_char( + MAX((COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date), + 'YYYY-MM-DD' + ) + FROM target + JOIN order_items oi ON oi.id = target.id + JOIN orders o ON o.id = oi.order_id + ) AS to_date + `, + [now], + ) + + const row = result.rows[0] + const affiliateItemCount = Number(row?.affiliate_item_count || 0) + const matchedCount = Number(row?.matched_count || 0) + const updatedCount = Number(row?.updated_count || 0) + const skippedCount = Math.max(0, matchedCount - updatedCount) + const remainingCount = Math.max(0, affiliateItemCount - matchedCount) + + let factsRebuild: { + fromDate: string + toDate: string + deletedCount: number + insertedCount: number + batchCount: number + } | null = null + if (matchedCount > 0 && row?.from_date && row?.to_date) { + const fromDate = row.from_date + const toDate = row.to_date + let cursor = fromDate + let deletedCount = 0 + let insertedCount = 0 + let batchCount = 0 + while (cursor <= toDate) { + const windowEnd = shiftDate(cursor, 399) + const chunkEnd = windowEnd > toDate ? toDate : windowEnd + const rebuilt = await rebuildSalesDailyFacts({ dateFrom: cursor, dateTo: chunkEnd }) + deletedCount += rebuilt.deletedCount + insertedCount += rebuilt.insertedCount + batchCount += 1 + cursor = shiftDate(chunkEnd, 1) + } + factsRebuild = { fromDate, toDate, deletedCount, insertedCount, batchCount } + } + + return { + updatedCount, + skippedCount, + remainingCount, + affiliateItemCount, + matchedCount, + factsRebuild, + } +} + type TemplateBootstrapTemplateRow = { id: number rule_key: string @@ -3297,6 +3437,8 @@ function mapProduct(row: ProductRow) { attributes: parseJsonObject(row.attributes_json), mappingCount: Number(row.mapping_count || 0), costVersionCount: Number(row.cost_version_count || 0), + affiliateTaskCount: Number(row.affiliate_task_count || 0), + affiliateLastFulfilledAt: row.affiliate_last_fulfilled_at || null, createdAt: row.created_at, updatedAt: row.updated_at, } diff --git a/apps/frontend/src/pages/admin/AdminCostCenterPage.tsx b/apps/frontend/src/pages/admin/AdminCostCenterPage.tsx index 214c32b8..fb8cfbb4 100644 --- a/apps/frontend/src/pages/admin/AdminCostCenterPage.tsx +++ b/apps/frontend/src/pages/admin/AdminCostCenterPage.tsx @@ -40,6 +40,7 @@ import { usePersistedState } from '@/hooks/usePersistedState' import dayjs from '@/lib/dayjs' import { applyAdminTemplateProductBootstrap, + backfillAdminAffiliateDashSkus, backfillAdminNormalizedQuantities, backfillAdminProductMappings, createAdminChannelProductMapping, @@ -366,6 +367,7 @@ export default function AdminCostCenterPage() { const [bootstrapPreviewing, setBootstrapPreviewing] = useState(false) const [bootstrapApplying, setBootstrapApplying] = useState(false) const [syncingAffiliate, setSyncingAffiliate] = useState(false) + const [backfillingAffiliateSkus, setBackfillingAffiliateSkus] = useState(false) const versionScopeType = Form.useWatch('scopeType', versionForm) const versionCostItemId = Form.useWatch('costItemId', versionForm) @@ -669,6 +671,29 @@ export default function AdminCostCenterPage() { } } + async function backfillAffiliateSkus() { + setBackfillingAffiliateSkus(true) + try { + const response = await backfillAdminAffiliateDashSkus() + const result = response.data + const factsText = result.factsRebuild + ? `;已重算 ${result.factsRebuild.fromDate} ~ ${result.factsRebuild.toDate} 的销售事实` + : '' + const remainingText = + result.remainingCount > 0 + ? `;仍有 ${result.remainingCount} 个订单项缺少可匹配的自动发货 SKU` + : '' + message.success( + `已回填 ${result.updatedCount} 个订单项,${result.skippedCount} 个已是归一状态${factsText}${remainingText}`, + ) + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '回填自动发货 SKU 失败') + } finally { + setBackfillingAffiliateSkus(false) + } + } + function openEditProduct(item: ProductCatalogItem) { setEditingProduct(item) productForm.setFieldsValue({ @@ -1970,6 +1995,13 @@ export default function AdminCostCenterPage() { > 同步自动发货商品 +