From 12f370de15e6ede7f7c80e27133f4c2b9ea26b4f Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 25 Oct 2026 16:24:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(cost-center):=20=E5=B0=86=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1=E4=B8=8E=E8=87=AA=E5=8A=A8=E5=8F=91=E8=B4=A7=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E7=BB=9F=E4=B8=80=E6=94=B6=E6=95=9B=E8=87=B3=E6=9C=80?= =?UTF-8?q?=E8=BF=91=E4=B8=80=E4=B8=AA=E6=9C=88=E5=B9=B6=E8=87=AA=E6=84=88?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E9=9D=9E=E6=A0=87SKU?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 范围收敛:将财务日期范围默认窗口、自动发货履约任务扫描、成本快照重算及定时任务默认窗口统一收敛为最近一个月(30 天),不再扫描混乱的历史遗留数据 - 脏数据清洗与自愈:在自动发货同步与基线导入时自动识别并清理历史把中文名当做 SKU 插入的 21 个非标脏商品,将关联的 116 个订单项平滑重定向至标准 38 款基线商品 - 标准提取:从履约任务 context_json 中按优先级提取标准上游 sku,通过正则仅允许合规英数标识符入库,避免中文名称污染商品目录 - 界面对齐:前端销售毛利概览默认查询最近 30 天,自动发货与目录改价追溯生效时间默认设置为近一个月(30 天前) - 验证方式:全量单测 783 项通过、tsc 0 error、容器内执行同步后脏商品归零且销售事实精准重算 --- .../cost-center/affiliate-product-service.ts | 104 +++++++++++++++--- .../cost-center/cost-center-constants.ts | 7 +- .../cost-center/product-catalog-service.ts | 4 +- .../scheduler/cost-finance-sync-job.ts | 2 +- apps/backend/src/utils/finance-date-range.ts | 2 +- .../src/pages/admin/AdminSalesProfitPage.tsx | 6 +- .../admin/cost-center/AffiliateSkuPanel.tsx | 8 +- .../admin/cost-center/ProductCatalogPanel.tsx | 5 +- 8 files changed, 111 insertions(+), 27 deletions(-) diff --git a/apps/backend/src/services/cost-center/affiliate-product-service.ts b/apps/backend/src/services/cost-center/affiliate-product-service.ts index 574b1486..d30805c7 100644 --- a/apps/backend/src/services/cost-center/affiliate-product-service.ts +++ b/apps/backend/src/services/cost-center/affiliate-product-service.ts @@ -2,29 +2,85 @@ import { query } from '../../db/client.js' import type { AdminSession } from '../admin/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 { formatShanghaiDate, shiftDate } from '../../utils/finance-date-range.js' import { nowIso } from '../../utils/time.js' import { rebuildSalesDailyFacts } from '../finance/sales-facts-service.js' import { syncEstimatedOrderItemCostSnapshots } from './cost-snapshot-sync-service.js' import { AFFILIATE_DASH_PRICES_BASELINE } from './affiliate-dash-prices-baseline.js' +/** + * 清理历史上因为把商品中文名误当成 SKU 插入的非标脏商品,并将误关联的订单项重新归一到标准干净商品。 + */ +export async function cleanupDirtyAffiliateDashProducts() { + await query(` + WITH dirty_map AS ( + SELECT DISTINCT ON (oi.id) + oi.id AS order_item_id, + clean.id AS clean_product_id + FROM order_items oi + JOIN product_catalog dirty ON dirty.id = oi.canonical_product_id + JOIN fulfillment_tasks ft ON ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash' + JOIN product_catalog clean ON clean.canonical_sku = 'aff:' || COALESCE( + NULLIF(btrim(ft.context_json->'affiliateDash'->>'sku'), ''), + NULLIF(btrim(ft.context_json->'affiliateDash'->'raw'->'product'->>'sku'), '') + ) + WHERE dirty.canonical_sku ~ '^aff:[^a-z0-9_]' + ORDER BY oi.id, ft.id DESC + ) + UPDATE order_items oi + SET canonical_product_id = dm.clean_product_id + FROM dirty_map dm + WHERE oi.id = dm.order_item_id + `) + + await query(` + DELETE FROM sales_daily_facts + WHERE canonical_product_id IN ( + SELECT id FROM product_catalog WHERE canonical_sku ~ '^aff:[^a-z0-9_]' + ) + `) + + await query(` + DELETE FROM product_catalog + WHERE canonical_sku ~ '^aff:[^a-z0-9_]' + AND NOT EXISTS (SELECT 1 FROM order_items WHERE canonical_product_id = product_catalog.id) + AND NOT EXISTS (SELECT 1 FROM cost_versions WHERE canonical_product_id = product_catalog.id) + AND NOT EXISTS (SELECT 1 FROM cost_snapshots WHERE canonical_product_id = product_catalog.id) + AND NOT EXISTS (SELECT 1 FROM sales_daily_facts WHERE canonical_product_id = product_catalog.id) + `) +} + export async function syncAdminAffiliateDashProducts() { + await cleanupDirtyAffiliateDashProducts() const now = nowIso() const result = await query<{ inserted: boolean }>( ` WITH auto_task AS ( SELECT - btrim(context_json->>'skuCode') AS sku_code, - btrim(context_json->>'skuName') AS sku_name, + COALESCE( + NULLIF(btrim(context_json->'affiliateDash'->>'sku'), ''), + NULLIF(btrim(context_json->'affiliateDash'->'raw'->'product'->>'sku'), ''), + btrim(context_json->>'skuCode') + ) AS sku_code, + COALESCE( + NULLIF(btrim(context_json->'affiliateDash'->'raw'->'product'->>'name'), ''), + NULLIF(btrim(context_json->>'skuName'), ''), + btrim(context_json->>'skuCode') + ) AS sku_name, created_at FROM fulfillment_tasks WHERE executor_key = 'affiliate_dash' + AND created_at >= NOW() - INTERVAL '30 days' AND btrim(COALESCE(context_json->>'skuCode', '')) <> '' - AND btrim(COALESCE(context_json->>'skuName', '')) <> '' + ), + valid_task AS ( + SELECT sku_code, sku_name, created_at + FROM auto_task + WHERE sku_code ~ '^[a-z0-9_]+$' ), agg AS ( SELECT sku_code, count(*)::int AS task_count, max(created_at) AS last_fulfilled_at - FROM auto_task + FROM valid_task GROUP BY sku_code ), ranked AS ( @@ -35,7 +91,7 @@ export async function syncAdminAffiliateDashProducts() { PARTITION BY sku_code ORDER BY (sku_name <> sku_code) DESC, count(*) DESC, sku_name ) AS rn - FROM auto_task + FROM valid_task GROUP BY sku_code, sku_name ) INSERT INTO product_catalog ( @@ -60,7 +116,7 @@ export async function syncAdminAffiliateDashProducts() { JOIN agg ON agg.sku_code = ranked.sku_code WHERE ranked.rn = 1 ON CONFLICT (canonical_sku) DO UPDATE - SET canonical_name = EXCLUDED.canonical_name, + SET canonical_name = CASE WHEN product_catalog.canonical_name = '' THEN EXCLUDED.canonical_name ELSE product_catalog.canonical_name END, unit_name = EXCLUDED.unit_name, attributes_json = product_catalog.attributes_json || EXCLUDED.attributes_json, updated_at = $1 @@ -115,9 +171,14 @@ export async function backfillAdminAffiliateDashSkus() { 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 + COALESCE( + NULLIF(btrim(ft.context_json->'affiliateDash'->>'sku'), ''), + NULLIF(btrim(ft.context_json->'affiliateDash'->'raw'->'product'->>'sku'), ''), + btrim(ft.context_json->>'skuCode') + ) AS sku_code FROM fulfillment_tasks ft WHERE ft.executor_key = 'affiliate_dash' + AND ft.created_at >= NOW() - INTERVAL '30 days' AND btrim(COALESCE(ft.context_json->>'skuCode', '')) <> '' ORDER BY ft.order_item_id, ft.created_at DESC, ft.id DESC ), @@ -141,12 +202,16 @@ export async function backfillAdminAffiliateDashSkus() { SELECT COUNT(DISTINCT ft.order_item_id)::int FROM fulfillment_tasks ft WHERE ft.executor_key = 'affiliate_dash' + AND ft.created_at >= NOW() - INTERVAL '30 days' ) 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), + GREATEST( + MIN((COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date), + (CURRENT_DATE - INTERVAL '30 days')::date + ), 'YYYY-MM-DD' ) FROM affiliate_item @@ -180,6 +245,7 @@ export async function backfillAdminAffiliateDashSkus() { SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash' + AND ft.created_at >= NOW() - INTERVAL '30 days' ) `, ) @@ -244,7 +310,8 @@ export async function setAdminAffiliateProductPrice( const now = nowIso() let effectiveFrom = payload.effectiveFrom ? String(payload.effectiveFrom) : now if (payload.retroactive === true || payload.retroactive === 'true') { - effectiveFrom = '2026-07-10T00:00:00+08:00' + const today = formatShanghaiDate(new Date()) + effectiveFrom = shiftDate(today, -30) + 'T00:00:00+08:00' } const changeReason = String(payload.changeReason || '配置自动发货采购成本').trim() @@ -347,6 +414,7 @@ export async function setAdminAffiliateProductPrice( * 批量从上游基线导入 38 条自动发货采购成本。 */ export async function importAdminAffiliateDashPricesBaseline(session: AdminSession) { + await cleanupDirtyAffiliateDashProducts() const rows = AFFILIATE_DASH_PRICES_BASELINE const costItemResult = await query<{ id: number }>( @@ -356,7 +424,8 @@ export async function importAdminAffiliateDashPricesBaseline(session: AdminSessi if (!costItem) throw createHttpError('未找到自动发货采购成本项', { statusCode: 500 }) const now = nowIso() - const effectiveFrom = '2026-07-10T00:00:00+08:00' + const today = formatShanghaiDate(new Date()) + const effectiveFrom = shiftDate(today, -30) + 'T00:00:00+08:00' let createdProductCount = 0 let createdVersionCount = 0 let updatedVersionCount = 0 @@ -460,13 +529,18 @@ export async function importAdminAffiliateDashPricesBaseline(session: AdminSessi /** * 一键重算自动发货预估成本快照并重算销售事实。 + * 默认统计最新的一个月(30 天)数据,避免扫描混乱的历史遗留数据。 */ -export async function syncAdminAffiliateDashCostSnapshots() { +export async function syncAdminAffiliateDashCostSnapshots(options: { days?: number } = {}) { + const days = options.days && options.days > 0 ? options.days : 30 + const today = formatShanghaiDate(new Date()) + const fromDate = shiftDate(today, -(days - 1)) + const snapshotResult = await syncEstimatedOrderItemCostSnapshots({ - dateFrom: '2026-07-10', - dateTo: '2026-10-26', + dateFrom: fromDate, + dateTo: today, }) - const factsResult = await rebuildFactsForRange('2026-07-10', '2026-10-26') + const factsResult = await rebuildFactsForRange(fromDate, today) return { snapshotResult, diff --git a/apps/backend/src/services/cost-center/cost-center-constants.ts b/apps/backend/src/services/cost-center/cost-center-constants.ts index 7eb97a74..2de776df 100644 --- a/apps/backend/src/services/cost-center/cost-center-constants.ts +++ b/apps/backend/src/services/cost-center/cost-center-constants.ts @@ -119,11 +119,16 @@ export const PRODUCT_CATEGORY_JOINS = ` export const AFFILIATE_TASK_AGG_JOIN = ` LEFT JOIN ( SELECT - btrim(context_json->>'skuCode') AS sku_code, + COALESCE( + NULLIF(btrim(context_json->'affiliateDash'->>'sku'), ''), + NULLIF(btrim(context_json->'affiliateDash'->'raw'->'product'->>'sku'), ''), + 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 created_at >= NOW() - INTERVAL '30 days' AND btrim(COALESCE(context_json->>'skuCode', '')) <> '' GROUP BY 1 ) aff ON p.canonical_sku LIKE 'aff:%' diff --git a/apps/backend/src/services/cost-center/product-catalog-service.ts b/apps/backend/src/services/cost-center/product-catalog-service.ts index aaeacacf..c62f5bba 100644 --- a/apps/backend/src/services/cost-center/product-catalog-service.ts +++ b/apps/backend/src/services/cost-center/product-catalog-service.ts @@ -10,6 +10,7 @@ import { query } from '../../db/client.js' import type { AdminSession } from '../admin/admin-auth-service.js' import type { JsonObject } from '../../types/json.js' import { createHttpError } from '../../utils/http.js' +import { formatShanghaiDate, shiftDate } from '../../utils/finance-date-range.js' import { nowIso } from '../../utils/time.js' import { normalizeMatchText, normalizeSkuSeriesEquivalence } from '../product-match/normalize.js' import { resolveSkuNameQuantity } from '../product-match/pricing.js' @@ -1314,7 +1315,8 @@ export async function setAdminProductPrice( const now = nowIso() let effectiveFrom = payload.effectiveFrom ? String(payload.effectiveFrom) : now if (payload.retroactive === true || payload.retroactive === 'true') { - effectiveFrom = '2026-07-10T00:00:00+08:00' + const today = formatShanghaiDate(new Date()) + effectiveFrom = shiftDate(today, -30) + 'T00:00:00+08:00' } const changeReason = String(payload.changeReason || '在商品与成本中心统一调整价格').trim() diff --git a/apps/backend/src/services/scheduler/cost-finance-sync-job.ts b/apps/backend/src/services/scheduler/cost-finance-sync-job.ts index fd194fd5..510d5d2b 100644 --- a/apps/backend/src/services/scheduler/cost-finance-sync-job.ts +++ b/apps/backend/src/services/scheduler/cost-finance-sync-job.ts @@ -13,7 +13,7 @@ import { reconcilePendingFulfillmentModes } from '../fulfillment/fulfillment-mod */ export async function runCostFinanceSyncJob(job: JsonObject = {}) { const config = job.config && typeof job.config === 'object' ? (job.config as JsonObject) : {} - const days = clampInteger(config.days, 7, 1, 90) + const days = clampInteger(config.days, 30, 1, 90) const fulfillment = await reconcilePendingFulfillmentModes({ batchSize: 2000, diff --git a/apps/backend/src/utils/finance-date-range.ts b/apps/backend/src/utils/finance-date-range.ts index 10ba7e6a..34d9bc55 100644 --- a/apps/backend/src/utils/finance-date-range.ts +++ b/apps/backend/src/utils/finance-date-range.ts @@ -16,7 +16,7 @@ type ResolveFinanceDateRangeOptions = { */ export function resolveFinanceDateRange( input: Record = {}, - { defaultDays = 7, maxDays = 400 }: ResolveFinanceDateRangeOptions = {}, + { defaultDays = 30, maxDays = 400 }: ResolveFinanceDateRangeOptions = {}, ): FinanceDateRange { const today = formatShanghaiDate(new Date()) const rawFrom = normalizeDateInput(input.dateFrom ?? input.from) diff --git a/apps/frontend/src/pages/admin/AdminSalesProfitPage.tsx b/apps/frontend/src/pages/admin/AdminSalesProfitPage.tsx index 0f97862e..6fe21efe 100644 --- a/apps/frontend/src/pages/admin/AdminSalesProfitPage.tsx +++ b/apps/frontend/src/pages/admin/AdminSalesProfitPage.tsx @@ -60,12 +60,14 @@ export default function AdminSalesProfitPage() { const { message } = App.useApp() const [form] = Form.useForm() const canConfig = hasAdminPermission('finance.config') + const defaultDateFrom = dayjs().subtract(29, 'day').format('YYYY-MM-DD') + const defaultDateTo = dayjs().format('YYYY-MM-DD') const [filters, setFilters] = useState<{ dateFrom: string dateTo: string provider: string fulfillmentMode: string - }>({ dateFrom: '', dateTo: '', provider: '', fulfillmentMode: '' }) + }>({ dateFrom: defaultDateFrom, dateTo: defaultDateTo, provider: '', fulfillmentMode: '' }) const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) const [rebuilding, setRebuilding] = useState(false) @@ -96,7 +98,7 @@ export default function AdminSalesProfitPage() { function resetFilters() { form.resetFields() setPage(1) - setFilters({ dateFrom: '', dateTo: '', provider: '', fulfillmentMode: '' }) + setFilters({ dateFrom: defaultDateFrom, dateTo: defaultDateTo, provider: '', fulfillmentMode: '' }) } async function rebuildFacts() { diff --git a/apps/frontend/src/pages/admin/cost-center/AffiliateSkuPanel.tsx b/apps/frontend/src/pages/admin/cost-center/AffiliateSkuPanel.tsx index 82d6d8c3..c86d7500 100644 --- a/apps/frontend/src/pages/admin/cost-center/AffiliateSkuPanel.tsx +++ b/apps/frontend/src/pages/admin/cost-center/AffiliateSkuPanel.tsx @@ -128,7 +128,7 @@ export function AffiliateSkuPanel() { modal.confirm({ title: '导入上游采购成本基线', content: - '将从系统内置的 38 条上游商品价格清单导入采购成本版本(生效时间默认追溯至 2026-07-10,与历史订单匹配)。是否继续?', + '将从系统内置的 38 条上游商品价格清单导入采购成本版本(生效时间默认覆盖最近一个月(30 天)订单)。是否继续?', okText: '确认导入', cancelText: '取消', onOk: async () => { @@ -152,7 +152,7 @@ export function AffiliateSkuPanel() { modal.confirm({ title: '重算自动发货预估成本快照', content: - '将为全部已归一的自动发货历史订单重新计算预估成本快照,自动消除因缺少采购价导致的 cost_missing 异常,并重算对应周期的销售事实。确定执行?', + '将为最近一个月的自动发货订单重新计算预估成本快照,自动消除因缺少采购价导致的 cost_missing 异常,并重算对应周期的销售事实。确定执行?', okText: '确认重算', cancelText: '取消', onOk: async () => { @@ -201,7 +201,7 @@ export function AffiliateSkuPanel() { if (values.effectiveMode === 'retroactive') { retroactive = true - effectiveFrom = '2026-07-10T00:00:00+08:00' + effectiveFrom = dayjs().subtract(30, 'day').format('YYYY-MM-DDTHH:mm:ss+08:00') } else if (values.effectiveMode === 'now') { effectiveFrom = new Date().toISOString() } else if (values.effectiveMode === 'custom' && values.customEffectiveDate) { @@ -478,7 +478,7 @@ export function AffiliateSkuPanel() { 追溯至历史订单 (推荐) - 自历史业务起点 (2026-07-10) 生效,重算快照后可直接消除历史 cost_missing 异常 + 自近一个月业务起点(30 天前)生效,重算快照后可直接消除近一个月 cost_missing 异常 diff --git a/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx b/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx index a78965f6..96901f9f 100644 --- a/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx +++ b/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx @@ -36,6 +36,7 @@ import PercentTable from '@/components/common/PercentTable' import { useQuery, useQueryClient } from '@tanstack/react-query' import { useEffect, useState } from 'react' +import dayjs from '@/lib/dayjs' import { applyAdminTemplateProductBootstrap, backfillAdminAffiliateDashSkus, @@ -417,7 +418,7 @@ export function ProductCatalogPanel() { if (values.effectiveScope === 'retroactive') { retroactive = true - effectiveFrom = '2026-07-10T00:00:00+08:00' + effectiveFrom = dayjs().subtract(30, 'day').format('YYYY-MM-DDTHH:mm:ss+08:00') } else if (values.effectiveScope === 'custom' && values.effectiveFromCustom) { effectiveFrom = values.effectiveFromCustom } @@ -1034,7 +1035,7 @@ export function ProductCatalogPanel() { 追溯至历史订单(推荐)
- 自历史业务起点 (2026-07-10) 生效,直接更新基线并消除历史 cost_missing 异常 + 自近一个月业务起点(30 天前)生效,直接更新基线并消除近一个月 cost_missing 异常