feat(cost-center): 将统计与自动发货同步统一收敛至最近一个月并自愈历史非标SKU
- 范围收敛:将财务日期范围默认窗口、自动发货履约任务扫描、成本快照重算及定时任务默认窗口统一收敛为最近一个月(30 天),不再扫描混乱的历史遗留数据 - 脏数据清洗与自愈:在自动发货同步与基线导入时自动识别并清理历史把中文名当做 SKU 插入的 21 个非标脏商品,将关联的 116 个订单项平滑重定向至标准 38 款基线商品 - 标准提取:从履约任务 context_json 中按优先级提取标准上游 sku,通过正则仅允许合规英数标识符入库,避免中文名称污染商品目录 - 界面对齐:前端销售毛利概览默认查询最近 30 天,自动发货与目录改价追溯生效时间默认设置为近一个月(30 天前) - 验证方式:全量单测 783 项通过、tsc 0 error、容器内执行同步后脏商品归零且销售事实精准重算
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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:%'
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -16,7 +16,7 @@ type ResolveFinanceDateRangeOptions = {
|
||||
*/
|
||||
export function resolveFinanceDateRange(
|
||||
input: Record<string, unknown> = {},
|
||||
{ defaultDays = 7, maxDays = 400 }: ResolveFinanceDateRangeOptions = {},
|
||||
{ defaultDays = 30, maxDays = 400 }: ResolveFinanceDateRangeOptions = {},
|
||||
): FinanceDateRange {
|
||||
const today = formatShanghaiDate(new Date())
|
||||
const rawFrom = normalizeDateInput(input.dateFrom ?? input.from)
|
||||
|
||||
@@ -60,12 +60,14 @@ export default function AdminSalesProfitPage() {
|
||||
const { message } = App.useApp()
|
||||
const [form] = Form.useForm<FilterForm>()
|
||||
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() {
|
||||
|
||||
@@ -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() {
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Text strong>追溯至历史订单 (推荐)</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
自历史业务起点 (2026-07-10) 生效,重算快照后可直接消除历史 cost_missing 异常
|
||||
自近一个月业务起点(30 天前)生效,重算快照后可直接消除近一个月 cost_missing 异常
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
</Radio>
|
||||
|
||||
@@ -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() {
|
||||
<Radio value="retroactive">
|
||||
<strong>追溯至历史订单(推荐)</strong>
|
||||
<div style={{ color: '#64748b', fontSize: 12, marginTop: 2 }}>
|
||||
自历史业务起点 (2026-07-10) 生效,直接更新基线并消除历史 cost_missing 异常
|
||||
自近一个月业务起点(30 天前)生效,直接更新基线并消除近一个月 cost_missing 异常
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="immediate">
|
||||
|
||||
Reference in New Issue
Block a user