feat(cost-center): 收敛第三方卡券商品与成本链路

This commit is contained in:
yml2213
2026-11-06 16:47:45 +08:00
parent 974044a4c6
commit e6b3835c9f
14 changed files with 763 additions and 110 deletions
@@ -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> 为唯一口径';
@@ -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<string, unknown>,
}),
}),
@@ -347,3 +347,16 @@ test('人工标准商品禁止绕过人工 SKU 配置手工创建', async () =>
/人工 SKU 配置自动创建/,
)
})
test('第三方卡券商品禁止绕过卡券 SKU 配置手工创建', async () => {
await assert.rejects(
createAdminProduct(
{
canonicalSku: 'tpv:11月改名卡',
canonicalName: '11月改名卡',
},
{} as never,
),
/卡券 SKU 配置自动创建/,
)
})
@@ -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) }
}
@@ -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(
@@ -191,7 +191,7 @@ async function ensureVoucherProductForItem(item: ItemRow): Promise<number | null
await attachFulfillmentConfigurations({
mappings: [{ orderItemId: Number(item.id), canonicalProductId: productId }],
onlyWhenCanonicalProductNull: true,
onlyWhenCanonicalProductDifferent: true,
})
await query(
@@ -244,7 +244,10 @@ export function resolveProductId(item: ItemRow, index: MappingIndex): number | n
// 第三方卡券:与自动发货同构,商品编码 tpv:<sku>。
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 }
@@ -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'"))
})
@@ -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<ProductRow>(
`
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<VoucherProductItemRow>(
`
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,
}
}
@@ -2388,60 +2388,3 @@ export async function ensureVoucherRuleForSku(skuCode: string): Promise<boolean>
})
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) }
}