From e6b3835c9f0a87dc3d9d2262ec7f499011437152 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 6 Nov 2026 16:47:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(cost-center):=20=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E7=AC=AC=E4=B8=89=E6=96=B9=E5=8D=A1=E5=88=B8=E5=95=86=E5=93=81?= =?UTF-8?q?=E4=B8=8E=E6=88=90=E6=9C=AC=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...43_repair_third_party_voucher_products.sql | 173 ++++++++++ .../src/routes/admin/cost-center-catalog.ts | 19 +- .../services/cost-center/cost-center.test.ts | 13 + .../cost-center/product-catalog-service.ts | 11 +- .../product-normalize-service.test.ts | 13 + .../cost-center/product-normalize-service.ts | 35 +- .../voucher-product-service.test.ts | 35 ++ .../cost-center/voucher-product-service.ts | 321 ++++++++++++++++++ .../fulfillment/channel-assign-service.ts | 57 ---- .../admin/cost-center/ProductCatalogPanel.tsx | 12 + .../admin/cost-center/VoucherSkuPanel.tsx | 117 +++++-- .../src/services/admin/cost-center.ts | 19 +- .../tests/voucher-sku-delete-ui.test.ts | 27 ++ docs/成本中心API契约.md | 21 ++ 14 files changed, 763 insertions(+), 110 deletions(-) create mode 100644 apps/backend/src/db/migrations/143_repair_third_party_voucher_products.sql create mode 100644 apps/backend/src/services/cost-center/voucher-product-service.test.ts create mode 100644 apps/backend/src/services/cost-center/voucher-product-service.ts diff --git a/apps/backend/src/db/migrations/143_repair_third_party_voucher_products.sql b/apps/backend/src/db/migrations/143_repair_third_party_voucher_products.sql new file mode 100644 index 00000000..cab4cf70 --- /dev/null +++ b/apps/backend/src/db/migrations/143_repair_third_party_voucher_products.sql @@ -0,0 +1,173 @@ +-- 143_repair_third_party_voucher_products.sql +-- +-- 第三方卡券订单项必须关联 tpv:<内部 SKU>。历史商品归一在缺少 tpv 商品时 +-- 会回退到同名 man 商品,导致卡券订单使用人工商品维度而无法生成卡券采购成本。 +-- 本迁移只修正商品归属、渠道规则和能力,不删除任何商品、订单或历史快照。 +-- 部署后由 cost_snapshot / sales_facts 管道补建成本快照并重算受影响日期。 + +DROP TABLE IF EXISTS pg_temp.tmp_repair_voucher_items; +CREATE TEMP TABLE tmp_repair_voucher_items ON COMMIT DROP AS +SELECT + oi.id AS order_item_id, + oi.order_id, + resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku, + (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date::text AS basis_date +FROM order_items oi +JOIN orders o ON o.id = oi.order_id +LEFT JOIN product_catalog current_product ON current_product.id = oi.canonical_product_id +WHERE oi.fulfillment_channel = 'third_party_voucher' + AND BTRIM(resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)) <> '' + AND char_length( + 'tpv:' || resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) + ) <= 64 + AND ( + current_product.id IS NULL + OR current_product.canonical_sku IS DISTINCT FROM + 'tpv:' || resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) + ); + +CREATE UNIQUE INDEX ON tmp_repair_voucher_items (order_item_id); + +INSERT INTO product_catalog ( + canonical_sku, canonical_name, category, unit_name, status, + attributes_json, created_at, updated_at +) +SELECT DISTINCT + 'tpv:' || item.internal_sku, + item.internal_sku, + '卡券', + '张', + 'active', + '{"source":"third_party_voucher","auto_created":true,"repaired":true}'::jsonb, + NOW(), + NOW() +FROM tmp_repair_voucher_items item +ON CONFLICT (canonical_sku) DO UPDATE SET + status = 'active', + canonical_name = CASE + WHEN BTRIM(product_catalog.canonical_name) = '' THEN EXCLUDED.canonical_name + ELSE product_catalog.canonical_name + END, + attributes_json = product_catalog.attributes_json || EXCLUDED.attributes_json, + updated_at = EXCLUDED.updated_at; + +UPDATE order_items oi +SET canonical_product_id = product.id, + updated_at = NOW() +FROM tmp_repair_voucher_items item +JOIN product_catalog product + ON product.canonical_sku = 'tpv:' || item.internal_sku + AND product.status = 'active' +WHERE oi.id = item.order_item_id; + +UPDATE cost_snapshots snapshot +SET canonical_product_id = product.id +FROM tmp_repair_voucher_items item +JOIN product_catalog product + ON product.canonical_sku = 'tpv:' || item.internal_sku +WHERE snapshot.order_item_id = item.order_item_id + AND snapshot.superseded_at IS NULL + AND snapshot.reversed_at IS NULL; + +INSERT INTO product_channel_capabilities ( + product_id, channel, priority, is_fallback, enabled, source, created_at, updated_at +) +SELECT DISTINCT + product.id, + 'third_party_voucher', + 10, + FALSE, + TRUE, + 'voucher', + NOW(), + NOW() +FROM tmp_repair_voucher_items item +JOIN product_catalog product + ON product.canonical_sku = 'tpv:' || item.internal_sku +ON CONFLICT (product_id, channel) DO UPDATE SET + priority = 10, + is_fallback = FALSE, + enabled = TRUE, + source = 'voucher', + updated_at = EXCLUDED.updated_at; + +-- 卡券没有外部履约载体,规则命中即完成渠道判定;同 SKU 其他能力保留为兜底。 +UPDATE internal_sku_channels channel +SET is_fallback = TRUE, + priority = GREATEST(channel.priority, 90), + updated_at = NOW() +FROM ( + SELECT DISTINCT internal_sku + FROM tmp_repair_voucher_items +) repaired +WHERE canonicalize_internal_sku(channel.internal_sku) = repaired.internal_sku + AND channel.channel <> 'third_party_voucher' + AND channel.enabled = TRUE + AND channel.is_fallback = FALSE; + +INSERT INTO internal_sku_channels ( + internal_sku, channel, priority, is_fallback, enabled, + canonical_product_id, config_json, source, created_at, updated_at +) +SELECT DISTINCT + item.internal_sku, + 'third_party_voucher', + 10, + FALSE, + TRUE, + product.id, + jsonb_build_object('voucherSku', item.internal_sku, 'repaired', TRUE), + 'voucher', + NOW(), + NOW() +FROM tmp_repair_voucher_items item +JOIN product_catalog product + ON product.canonical_sku = 'tpv:' || item.internal_sku +ON CONFLICT (internal_sku, channel) DO UPDATE SET + priority = 10, + is_fallback = FALSE, + enabled = TRUE, + canonical_product_id = EXCLUDED.canonical_product_id, + config_json = internal_sku_channels.config_json || EXCLUDED.config_json, + source = EXCLUDED.source, + updated_at = EXCLUDED.updated_at; + +-- 修复商品归属后,商品缺失和旧成本缺失异常由 cost_snapshot 管道重新计算并自动收口。 +WITH removed_links AS ( + DELETE FROM cost_exception_items + WHERE order_item_id IN ( + SELECT order_item_id FROM tmp_repair_voucher_items + ) + RETURNING exception_id +), +affected_heads AS ( + SELECT DISTINCT exception_id FROM removed_links +) +UPDATE cost_exceptions exception +SET status = 'resolved', + resolved_at = NOW(), + item_count = 0, + last_seen_at = NOW() +WHERE exception.id IN (SELECT exception_id FROM affected_heads) + AND NOT EXISTS ( + SELECT 1 + FROM cost_exception_items item + WHERE item.exception_id = exception.id + ); + +UPDATE cost_exceptions exception +SET status = 'resolved', + resolved_at = NOW(), + item_count = 0, + last_seen_at = NOW() +WHERE exception.order_item_id IN ( + SELECT order_item_id FROM tmp_repair_voucher_items + ) + AND NOT EXISTS ( + SELECT 1 + FROM cost_exception_items item + WHERE item.exception_id = exception.id + ); + +COMMENT ON COLUMN product_catalog.attributes_json IS + '标准商品扩展信息;第三方卡券修复记录 repaired=true,商品归属以 tpv:<内部 SKU> 为唯一口径'; diff --git a/apps/backend/src/routes/admin/cost-center-catalog.ts b/apps/backend/src/routes/admin/cost-center-catalog.ts index e9fe103f..4a38af96 100644 --- a/apps/backend/src/routes/admin/cost-center-catalog.ts +++ b/apps/backend/src/routes/admin/cost-center-catalog.ts @@ -6,8 +6,6 @@ */ import { Router } from 'express' -import { ensureVoucherChannelRules } from '../../services/fulfillment/channel-assign-service.js' - import { backfillAdminNormalizedQuantities, backfillAdminProductMappings, @@ -25,6 +23,7 @@ import { updateAdminChannelMapping, updateAdminProduct, } from '../../services/cost-center/product-catalog-service.js' +import { upsertAdminVoucherProduct } from '../../services/cost-center/voucher-product-service.js' import { importAdminAffiliateDashPricesBaseline, setAdminAffiliateProductPrice, @@ -368,16 +367,16 @@ router.post( ) router.post( - '/cost-center/voucher-products/ensure-rules', + '/cost-center/voucher-products', requireAdminPermission('finance.config'), - createJsonHandler(() => ensureVoucherChannelRules(), { - successMessage: '卡券渠道规则已补齐', - errorMessage: '补齐卡券渠道规则失败', - scope: '[admin/cost-center/voucher-products/ensure-rules]', + createJsonHandler((req) => upsertAdminVoucherProduct(bodyOf(req), sessionOf(req)), { + successMessage: '卡券 SKU 已保存', + errorMessage: '保存卡券 SKU 失败', + scope: '[admin/cost-center/voucher-products]', audit: (_req, data) => ({ - action: 'voucher_internal_channels_ensured', - targetType: 'internal_sku_channels', - targetId: '', + action: 'voucher_product_upserted', + targetType: 'product_catalog', + targetId: String((data as { product?: { id?: number } })?.product?.id || ''), data: data as Record, }), }), diff --git a/apps/backend/src/services/cost-center/cost-center.test.ts b/apps/backend/src/services/cost-center/cost-center.test.ts index 78f414b3..c7039c29 100644 --- a/apps/backend/src/services/cost-center/cost-center.test.ts +++ b/apps/backend/src/services/cost-center/cost-center.test.ts @@ -347,3 +347,16 @@ test('人工标准商品禁止绕过人工 SKU 配置手工创建', async () => /人工 SKU 配置自动创建/, ) }) + +test('第三方卡券商品禁止绕过卡券 SKU 配置手工创建', async () => { + await assert.rejects( + createAdminProduct( + { + canonicalSku: 'tpv:11月改名卡', + canonicalName: '11月改名卡', + }, + {} as never, + ), + /卡券 SKU 配置自动创建/, + ) +}) 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 ba75b9a8..e89a2453 100644 --- a/apps/backend/src/services/cost-center/product-catalog-service.ts +++ b/apps/backend/src/services/cost-center/product-catalog-service.ts @@ -17,7 +17,6 @@ import { normalizeMatchText, normalizeSkuSeriesEquivalence } from '../product-ma import { resolveSkuNameQuantity } from '../product-match/pricing.js' import { setAdminAffiliateProductPrice } from './affiliate-product-service.js' import { resolvePriceEffectiveFrom } from './cost-center-normalizers.js' -import { ensureVoucherRuleForSku } from '../fulfillment/channel-assign-service.js' import { syncCostSnapshotsForItems } from './cost-snapshot-service.js' import { attachFulfillmentConfigurations } from '../fulfillment/fulfillment-state-service.js' import { @@ -325,6 +324,12 @@ export async function createAdminProduct(payload: JsonObject, _session: AdminSes errorCode: 'cost_manual_product_managed_by_config', }) } + if (canonicalSku.startsWith('tpv:')) { + throw createHttpError('卡券商品由第三方卡券 SKU 配置自动创建,请从卡券配置入口新增', { + statusCode: 409, + errorCode: 'cost_voucher_product_managed_by_config', + }) + } if (!/^[A-Za-z0-9._:-]+$/.test(canonicalSku)) { throw createHttpError('标准 SKU 只能包含字母、数字、点、下划线、冒号和短横线', { statusCode: 400, @@ -361,10 +366,6 @@ export async function createAdminProduct(payload: JsonObject, _session: AdminSes errorCode: 'cost_product_sku_exists', }) } - // 卡券没有载体证据,创建商品时顺手把渠道规则补上,避免新 SKU 停在待分类。 - if (canonicalSku.startsWith('tpv:')) { - await ensureVoucherRuleForSku(canonicalSku.slice(4)) - } return { product: mapProduct(inserted) } } diff --git a/apps/backend/src/services/cost-center/product-normalize-service.test.ts b/apps/backend/src/services/cost-center/product-normalize-service.test.ts index 5463dacf..0ab25723 100644 --- a/apps/backend/src/services/cost-center/product-normalize-service.test.ts +++ b/apps/backend/src/services/cost-center/product-normalize-service.test.ts @@ -144,6 +144,19 @@ test('商品归一:第三方卡券按 tpv:SKU 直接命中', () => { ) }) +test('商品归一:第三方卡券缺少 tpv 商品时不能串到同名人工商品', () => { + assert.equal( + resolveProductId( + item({ + fulfillment_channel: 'third_party_voucher', + internal_sku: '套装-测试', + }), + index, + ), + null, + ) +}) + test('商品归一:中文内部 SKU 命中标准商品,不依赖平台 SKU', () => { assert.equal( resolveProductId( diff --git a/apps/backend/src/services/cost-center/product-normalize-service.ts b/apps/backend/src/services/cost-center/product-normalize-service.ts index d81a6480..552bb87c 100644 --- a/apps/backend/src/services/cost-center/product-normalize-service.ts +++ b/apps/backend/src/services/cost-center/product-normalize-service.ts @@ -191,7 +191,7 @@ async function ensureVoucherProductForItem(item: ItemRow): Promise。 if (item.fulfillment_channel === 'third_party_voucher' && internalSku) { const voucher = index.voucherSkus.get(internalSku) - if (voucher) return voucher + return voucher || null + } + if (item.fulfillment_channel === 'third_party_voucher') { + return null } // 中文内部 SKU 是标准商品键;平台 SKU 映射只作为历史数据兜底。 @@ -329,8 +332,18 @@ export async function normalizeOrderItemProducts( LEFT JOIN work_orders wo ON wo.order_item_id = oi.id AND wo.status <> 'cancelled' LEFT JOIN work_product_rules wpr ON wpr.id = wo.product_rule_id LEFT JOIN manual_fulfillment_configs cfg ON cfg.id = oi.fulfillment_config_id + LEFT JOIN product_catalog current_product ON current_product.id = oi.canonical_product_id WHERE oi.fulfillment_status = 'assigned' - AND oi.canonical_product_id IS NULL + AND ( + oi.canonical_product_id IS NULL + OR ( + oi.fulfillment_channel = 'third_party_voucher' + AND ( + current_product.id IS NULL + OR current_product.canonical_sku NOT LIKE 'tpv:%' + ) + ) + ) AND oi.id > $1 ORDER BY oi.id LIMIT $2 @@ -375,7 +388,7 @@ export async function normalizeOrderItemProducts( canonicalProductId: row.productId, })), expected: { status: 'assigned' }, - onlyWhenCanonicalProductNull: true, + onlyWhenCanonicalProductDifferent: true, }) await resolveExceptionsForItems(attached.map((row) => Number(row.id))) } @@ -481,8 +494,18 @@ export async function normalizeProductsForItems(itemIds: number[]): Promise<{ LEFT JOIN work_orders wo ON wo.order_item_id = oi.id AND wo.status <> 'cancelled' LEFT JOIN work_product_rules wpr ON wpr.id = wo.product_rule_id LEFT JOIN manual_fulfillment_configs cfg ON cfg.id = oi.fulfillment_config_id + LEFT JOIN product_catalog current_product ON current_product.id = oi.canonical_product_id WHERE oi.fulfillment_status = 'assigned' - AND oi.canonical_product_id IS NULL + AND ( + oi.canonical_product_id IS NULL + OR ( + oi.fulfillment_channel = 'third_party_voucher' + AND ( + current_product.id IS NULL + OR current_product.canonical_sku NOT LIKE 'tpv:%' + ) + ) + ) AND oi.id = ANY($1::bigint[]) `, [ids], @@ -513,7 +536,7 @@ export async function normalizeProductsForItems(itemIds: number[]): Promise<{ canonicalProductId: row.productId, })), expected: { status: 'assigned' }, - onlyWhenCanonicalProductNull: true, + onlyWhenCanonicalProductDifferent: true, }) await resolveExceptionsForItems(attached.map((row) => Number(row.id))) return { matched: attached.length, unmatched } diff --git a/apps/backend/src/services/cost-center/voucher-product-service.test.ts b/apps/backend/src/services/cost-center/voucher-product-service.test.ts new file mode 100644 index 00000000..03e01f07 --- /dev/null +++ b/apps/backend/src/services/cost-center/voucher-product-service.test.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +test('卡券 SKU 一个入口完成商品、规则、成本版本和订单回填', async () => { + const service = await readFile(new URL('./voucher-product-service.ts', import.meta.url), 'utf8') + + assert.ok(service.includes('withTransaction(')) + assert.ok(service.includes("channel: 'third_party_voucher'")) + assert.ok(service.includes('sys_third_party_voucher_cost')) + assert.ok(service.includes('UPDATE order_items oi')) + assert.ok(service.includes('syncCostSnapshotsForItems')) + assert.ok(service.includes('refreshFactsForDates')) + assert.ok(service.includes("resolveExceptionsForItemsByReason(result.itemIds, 'cost_missing')")) +}) + +test('第三方卡券修复迁移和归一逻辑都禁止跨来源商品', async () => { + const [migration, normalizer, route] = await Promise.all([ + readFile( + new URL('../../db/migrations/143_repair_third_party_voucher_products.sql', import.meta.url), + 'utf8', + ), + readFile(new URL('./product-normalize-service.ts', import.meta.url), 'utf8'), + readFile(new URL('../../routes/admin/cost-center-catalog.ts', import.meta.url), 'utf8'), + ]) + + assert.ok(migration.includes('tmp_repair_voucher_items')) + assert.ok(migration.includes('current_product.canonical_sku IS DISTINCT FROM')) + assert.ok(migration.includes("product.canonical_sku = 'tpv:' || item.internal_sku")) + assert.ok(migration.includes("channel.channel <> 'third_party_voucher'")) + assert.ok(normalizer.includes('return voucher || null')) + assert.ok(normalizer.includes('onlyWhenCanonicalProductDifferent: true')) + assert.ok(!route.includes('/voucher-products/ensure-rules')) + assert.ok(route.includes("'/cost-center/voucher-products'")) +}) diff --git a/apps/backend/src/services/cost-center/voucher-product-service.ts b/apps/backend/src/services/cost-center/voucher-product-service.ts new file mode 100644 index 00000000..66fd82da --- /dev/null +++ b/apps/backend/src/services/cost-center/voucher-product-service.ts @@ -0,0 +1,321 @@ +import { query, withTransaction } from '../../db/client.js' +import type { JsonObject } from '../../types/json.js' +import { createHttpError } from '../../utils/http.js' +import { nowIso } from '../../utils/time.js' +import type { AdminSession } from '../admin/admin-auth-service.js' +import { orderItemBasisDates, refreshFactsForDates } from '../finance/refresh-facts.js' +import { upsertInternalSkuChannelWithClient } from '../fulfillment/internal-sku-channel-service.js' +import { canonicalizeInternalSku, containsCjk } from '../fulfillment/internal-sku-resolver.js' +import { resolvePriceEffectiveFrom } from './cost-center-normalizers.js' +import { + resolveExceptionsForItemsByReason, + resolveOrphanCostExceptions, +} from './cost-exception-service.js' +import { mapProduct, type ProductRow } from './cost-center-shared.js' +import { syncCostSnapshotsForItems } from './cost-snapshot-service.js' + +type VoucherProductItemRow = { + order_item_id: number + basis_date: string +} + +/** + * 新增或更新三方卡券 SKU。 + * + * 一个入口同时保证 tpv 商品、卡券主渠道规则、采购成本版本和订单商品关联, + * 避免运营跨页面手工建商品后再补规则。 + */ +export async function upsertAdminVoucherProduct(payload: JsonObject, session: AdminSession) { + const skuKey = canonicalizeInternalSku( + payload.skuKey ?? payload.sku_key ?? payload.internalSku ?? payload.internal_sku, + ) + if (!skuKey || !containsCjk(skuKey)) { + throw createHttpError('卡券内部 SKU 必须包含中文', { + statusCode: 400, + errorCode: 'voucher_product_sku_required', + }) + } + + const canonicalSku = `tpv:${skuKey}` + if (canonicalSku.length > 64) { + throw createHttpError('内部 SKU 过长,无法生成卡券商品编码', { + statusCode: 400, + errorCode: 'voucher_product_sku_too_long', + }) + } + + const amountFen = Number(payload.amountFen ?? payload.amount_fen) + if (!Number.isFinite(amountFen) || amountFen <= 0 || !Number.isInteger(amountFen)) { + throw createHttpError('卡券采购价必须是大于 0 的整数分', { + statusCode: 400, + errorCode: 'voucher_product_amount_invalid', + }) + } + + const now = nowIso() + const requestedEffectiveFrom = payload.effectiveFrom ?? payload.effective_from + const initialEffectiveFrom = resolvePriceEffectiveFrom(payload) + const result = await withTransaction(async (client) => { + const productResult = await client.query( + ` + INSERT INTO product_catalog ( + canonical_sku, canonical_name, category, unit_name, status, + attributes_json, created_at, updated_at + ) VALUES ( + $1, $2, '卡券', '张', 'active', + $3::jsonb, $4, $4 + ) + ON CONFLICT (canonical_sku) DO UPDATE SET + status = 'active', + canonical_name = EXCLUDED.canonical_name, + attributes_json = product_catalog.attributes_json || EXCLUDED.attributes_json, + updated_at = EXCLUDED.updated_at + RETURNING * + `, + [ + canonicalSku, + skuKey, + JSON.stringify({ + source: 'third_party_voucher', + auto_created: true, + managed_by_voucher_config: true, + }), + now, + ], + ) + const product = productResult.rows[0] + const productId = Number(product?.id || 0) + if (!product || !productId) { + throw createHttpError('卡券标准商品保存失败', { statusCode: 500 }) + } + + await client.query( + ` + INSERT INTO product_channel_capabilities ( + product_id, channel, priority, is_fallback, enabled, source, created_at, updated_at + ) VALUES ($1, 'third_party_voucher', 10, FALSE, TRUE, 'voucher', $2, $2) + ON CONFLICT (product_id, channel) DO UPDATE SET + priority = 10, + is_fallback = FALSE, + enabled = TRUE, + source = 'voucher', + updated_at = EXCLUDED.updated_at + `, + [productId, now], + ) + + await upsertInternalSkuChannelWithClient(client, { + internalSku: skuKey, + channel: 'third_party_voucher', + priority: 10, + isFallback: false, + enabled: true, + canonicalProductId: productId, + configJson: { voucherSku: skuKey }, + source: 'voucher', + note: String(payload.note || '').slice(0, 300), + adminUserId: session.userId, + replacePriority: true, + }) + + const repairedItems = await client.query( + ` + UPDATE order_items oi + SET canonical_product_id = $2, + updated_at = $3 + FROM orders o + WHERE o.id = oi.order_id + AND oi.fulfillment_channel = 'third_party_voucher' + AND resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) = $1 + AND oi.canonical_product_id IS DISTINCT FROM $2 + RETURNING + oi.id AS order_item_id, + (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date::text + AS basis_date + `, + [skuKey, productId, now], + ) + const itemIds = repairedItems.rows.map((row) => Number(row.order_item_id)) + if (itemIds.length > 0) { + await client.query( + `UPDATE cost_snapshots + SET canonical_product_id = $2 + WHERE order_item_id = ANY($1::bigint[]) + AND superseded_at IS NULL AND reversed_at IS NULL`, + [itemIds, productId], + ) + } + + const costItemResult = await client.query<{ id: number }>( + `SELECT id FROM cost_items + WHERE code = 'sys_third_party_voucher_cost' AND status = 'active' + LIMIT 1`, + ) + const costItemId = Number(costItemResult.rows[0]?.id || 0) + if (!costItemId) { + throw createHttpError('未找到第三方卡券采购成本项', { + statusCode: 500, + errorCode: 'voucher_cost_item_missing', + }) + } + + let effectiveFrom = initialEffectiveFrom + if (!requestedEffectiveFrom && repairedItems.rows.length > 0) { + const missingDates = await client.query<{ basis_date: string }>( + ` + SELECT MIN( + (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date + )::text AS basis_date + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + WHERE oi.id = ANY($1::bigint[]) + AND NOT EXISTS ( + SELECT 1 FROM cost_snapshots cs + WHERE cs.order_item_id = oi.id + AND cs.cost_item_id = $2 + AND cs.superseded_at IS NULL AND cs.reversed_at IS NULL + ) + `, + [itemIds, costItemId], + ) + const earliestDate = String(missingDates.rows[0]?.basis_date || '').trim() + if (earliestDate) effectiveFrom = `${earliestDate}T00:00:00+08:00` + } + + const existingResult = await client.query<{ + id: number + amount_fen: number + effective_from: string + }>( + `SELECT id, amount_fen, effective_from + FROM cost_versions + WHERE cost_item_id = $1 + AND canonical_product_id = $2 + AND status = 'active' + ORDER BY effective_from DESC, id DESC + LIMIT 1`, + [costItemId, productId], + ) + const existing = existingResult.rows[0] + const targetTime = Date.parse(effectiveFrom) + let costVersionId = Number(existing?.id || 0) + + if (!existing || targetTime < Date.parse(existing.effective_from)) { + if (existing) { + await client.query( + `UPDATE cost_versions + SET status = 'retired', updated_at = $1 + WHERE id = $2`, + [now, existing.id], + ) + } + const inserted = await client.query<{ id: number }>( + `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, effective_to, + source, status, change_reason, created_by_admin_user_id, + approved_by_admin_user_id, created_at, updated_at + ) VALUES ( + $1, 'product', '', '', $2, + NULL, $3, 0, $4, NULL, + 'manual', 'active', $5, $6, + $6, $7, $7 + ) + RETURNING id`, + [ + costItemId, + productId, + amountFen, + effectiveFrom, + String(payload.changeReason || '第三方卡券 SKU 新增或采购价调整'), + session.userId, + now, + ], + ) + costVersionId = Number(inserted.rows[0]?.id || 0) + } else if (targetTime === Date.parse(existing.effective_from)) { + await client.query( + `UPDATE cost_versions + SET amount_fen = $1, + change_reason = $2, + updated_at = $3 + WHERE id = $4`, + [ + amountFen, + String(payload.changeReason || '第三方卡券 SKU 新增或采购价调整'), + now, + existing.id, + ], + ) + } else { + await client.query( + `UPDATE cost_versions + SET status = 'retired', effective_to = $1, updated_at = $2 + WHERE id = $3`, + [effectiveFrom, now, existing.id], + ) + const inserted = await client.query<{ id: number }>( + `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, effective_to, + source, status, change_reason, created_by_admin_user_id, + approved_by_admin_user_id, created_at, updated_at + ) VALUES ( + $1, 'product', '', '', $2, + NULL, $3, 0, $4, NULL, + 'manual', 'active', $5, $6, + $6, $7, $7 + ) + RETURNING id`, + [ + costItemId, + productId, + amountFen, + effectiveFrom, + String(payload.changeReason || '第三方卡券 SKU 新增或采购价调整'), + session.userId, + now, + ], + ) + costVersionId = Number(inserted.rows[0]?.id || 0) + } + + if (!costVersionId) { + throw createHttpError('卡券采购成本版本保存失败', { statusCode: 500 }) + } + + return { + product: mapProduct(product), + itemIds, + dates: [...new Set(repairedItems.rows.map((row) => row.basis_date).filter(Boolean))], + costVersionId, + effectiveFrom, + amountFen, + } + }) + + let snapshotResult = { created: 0, superseded: 0, missing: 0, affectedDates: [] as string[] } + if (result.itemIds.length > 0) { + snapshotResult = await syncCostSnapshotsForItems(result.itemIds).catch(() => snapshotResult) + await resolveExceptionsForItemsByReason(result.itemIds, 'product_mapping_missing').catch( + () => undefined, + ) + await resolveExceptionsForItemsByReason(result.itemIds, 'cost_missing').catch(() => undefined) + } + const affectedDates = [ + ...new Set([ + ...result.dates, + ...snapshotResult.affectedDates, + ...(await orderItemBasisDates(result.itemIds).catch(() => [])), + ]), + ] + await refreshFactsForDates(affectedDates).catch(() => undefined) + await resolveOrphanCostExceptions().catch(() => undefined) + + return { + ...result, + linkedItemCount: result.itemIds.length, + snapshotCreatedCount: snapshotResult.created, + refreshedDays: affectedDates.length, + } +} diff --git a/apps/backend/src/services/fulfillment/channel-assign-service.ts b/apps/backend/src/services/fulfillment/channel-assign-service.ts index 704a549a..97533ad0 100644 --- a/apps/backend/src/services/fulfillment/channel-assign-service.ts +++ b/apps/backend/src/services/fulfillment/channel-assign-service.ts @@ -2388,60 +2388,3 @@ export async function ensureVoucherRuleForSku(skuCode: string): Promise }) return true } - -/** - * 为卡券商品(tpv: 前缀)批量补渠道规则。 - * - * 卡券没有履约载体证据,只能靠规则判定;商品目录建好后一次补齐, - * 避免运营按 SKU 逐条点「指定渠道」。 - */ -export async function ensureVoucherChannelRules(): Promise<{ - created: number - productCount: number -}> { - await query( - `UPDATE internal_sku_channels channel - SET is_fallback = TRUE, - priority = GREATEST(priority, 90), - updated_at = NOW() - FROM product_catalog product - WHERE product.status = 'active' - AND product.canonical_sku LIKE 'tpv:%' - AND canonicalize_internal_sku(channel.internal_sku) = - canonicalize_internal_sku(substring(product.canonical_sku from 5)) - AND channel.channel <> 'third_party_voucher' - AND channel.enabled = TRUE - AND channel.is_fallback = FALSE`, - ) - const created = await query<{ id: number }>( - ` - INSERT INTO internal_sku_channels ( - internal_sku, channel, priority, is_fallback, enabled, - canonical_product_id, config_json, source, created_at, updated_at - ) - SELECT - canonicalize_internal_sku(substring(p.canonical_sku from 5)), - 'third_party_voucher', 10, FALSE, TRUE, - p.id, jsonb_build_object('voucherSku', substring(p.canonical_sku from 5)), - 'voucher', NOW(), NOW() - FROM product_catalog p - WHERE p.status = 'active' - AND p.canonical_sku LIKE 'tpv:%' - AND canonicalize_internal_sku(substring(p.canonical_sku from 5)) <> '' - ON CONFLICT (internal_sku, channel) DO UPDATE SET - priority = 10, - is_fallback = FALSE, - enabled = TRUE, - canonical_product_id = EXCLUDED.canonical_product_id, - config_json = internal_sku_channels.config_json || EXCLUDED.config_json, - source = EXCLUDED.source, - updated_at = NOW() - RETURNING id - `, - ) - const total = await query<{ count: number }>( - `SELECT COUNT(*)::int AS count FROM product_catalog - WHERE status = 'active' AND canonical_sku LIKE 'tpv:%'`, - ) - return { created: created.rowCount || 0, productCount: Number(total.rows[0]?.count || 0) } -} diff --git a/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx b/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx index 64384d67..ee9af673 100644 --- a/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx +++ b/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx @@ -737,6 +737,18 @@ export function ProductCatalogPanel() { > 新增人工 SKU + ) : productSourceFilter === 'third_party_voucher' ? ( + ) : ( + {canConfig ? ( + + ) : null} @@ -184,8 +201,8 @@ export default function VoucherSkuPanel() { } > - 卡券渠道只统计:命中规则后不创建任务、不发领取页,只记收入与采购成本。 商品编码以{' '} - tpv: 开头。 + 新增时只需填写内部 SKU 和采购价,系统会自动创建 tpv:{' '} + 商品、卡券渠道规则并回填已有订单。卡券不创建任务、不发领取页,只记收入与采购成本。 + setCreateOpen(false)} + onOk={() => createForm.submit()} + confirmLoading={createMutation.isPending} + destroyOnHidden + > +
createMutation.mutate(values)} + > + { + const text = String(value || '').trim() + if (text && !/\p{Script=Han}/u.test(text)) { + throw new Error('内部 SKU 必须包含中文') + } + }, + }, + ]} + extra="保存后自动生成 tpv:<内部 SKU> 标准商品和卡券渠道规则。" + > + + + + + + + + +
+
+ (`/api/v1/admin/cost-center/products/${productId}/sync-price`, {}) } -export function ensureVoucherChannelRules() { - return apiPost<{ created: number; productCount: number }>( - '/api/v1/admin/cost-center/voucher-products/ensure-rules', - ) +export function saveAdminVoucherProduct(payload: { + skuKey: string + amountFen: number + effectiveFrom?: string + note?: string +}) { + return apiPost<{ + product: ProductCatalogItem + costVersionId: number + amountFen: number + effectiveFrom: string + linkedItemCount: number + snapshotCreatedCount: number + refreshedDays: number + }>('/api/v1/admin/cost-center/voucher-products', payload) } export function deleteAdminVoucherProduct(productId: number) { diff --git a/apps/frontend/tests/voucher-sku-delete-ui.test.ts b/apps/frontend/tests/voucher-sku-delete-ui.test.ts index 688a5159..cf8958be 100644 --- a/apps/frontend/tests/voucher-sku-delete-ui.test.ts +++ b/apps/frontend/tests/voucher-sku-delete-ui.test.ts @@ -19,3 +19,30 @@ test('第三方卡券面板提供安全删除入口并格式化生效时间', as assert.ok(service.includes('/api/v1/admin/cost-center/voucher-products/${productId}')) assert.ok(service.includes("action: 'deleted' | 'archived'")) }) + +test('第三方卡券新增 SKU 一次完成商品、规则和采购价', async () => { + const [panel, service, productCatalog] = await Promise.all([ + readFile( + new URL('../src/pages/admin/cost-center/VoucherSkuPanel.tsx', import.meta.url), + 'utf8', + ), + readFile(new URL('../src/services/admin/cost-center.ts', import.meta.url), 'utf8'), + readFile( + new URL('../src/pages/admin/cost-center/ProductCatalogPanel.tsx', import.meta.url), + 'utf8', + ), + ]) + + assert.ok(panel.includes('新增卡券 SKU')) + assert.ok(panel.includes('saveAdminVoucherProduct')) + assert.ok(panel.includes('保存后自动生成 tpv:<内部 SKU> 标准商品和卡券渠道规则')) + assert.ok(panel.includes('补建成本快照')) + assert.ok(!panel.includes('批量生成渠道规则')) + assert.ok(!panel.includes('ensureVoucherChannelRules')) + assert.ok(service.includes('apiPost<')) + assert.ok(service.includes("'/api/v1/admin/cost-center/voucher-products'")) + assert.ok(!service.includes('/voucher-products/ensure-rules')) + assert.ok(panel.includes("searchParams.get('voucherNew')")) + assert.ok(productCatalog.includes("productSourceFilter === 'third_party_voucher'")) + assert.ok(productCatalog.includes('voucherNew=1')) +}) diff --git a/docs/成本中心API契约.md b/docs/成本中心API契约.md index 525cc69c..03469fc6 100644 --- a/docs/成本中心API契约.md +++ b/docs/成本中心API契约.md @@ -174,6 +174,27 @@ POST /api/v1/admin/cost-center/manual-configs `internal_sku_channels` 人工规则。待分类队列的“去补配置”会携带 `scope=manual&tab=cost&manualSku=<内部SKU>` 直接打开对应配置。 +### 4.4 第三方卡券 SKU(现行) + +卡券商品统一从“成本配置 → 第三方卡券 → 新增卡券 SKU”维护: + +```text +POST /api/v1/admin/cost-center/voucher-products +``` + +```jsonc +{ + "skuKey": "11月改名卡", + "amountFen": 500, + "note": "第三方卡券采购价" +} +``` + +保存时在同一事务内创建或复用 `tpv:<内部 SKU>`、卡券主渠道规则和采购成本版本, +并把同内部 SKU 的卡券订单项从错误的 `man:` 等商品归一到 `tpv:`。 +通用 `POST /api/v1/admin/cost-center/products` 不再接受 `tpv:` 和 `man:`。 +迁移 `143` 负责修复历史卡券商品归属。 + ## 五、成本版本接口 路径不变,字段不变: