feat(cost-center): 收敛第三方卡券商品与成本链路
This commit is contained in:
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -737,6 +737,18 @@ export function ProductCatalogPanel() {
|
||||
>
|
||||
新增人工 SKU
|
||||
</Button>
|
||||
) : productSourceFilter === 'third_party_voucher' ? (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() =>
|
||||
void navigate(
|
||||
'/admin/cost-center?scope=third_party_voucher&tab=cost&voucherNew=1',
|
||||
)
|
||||
}
|
||||
>
|
||||
新增卡券 SKU
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateProduct}>
|
||||
新建商品
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 商品编码固定为 `tpv:<sku>`;成本写入 `sys_third_party_voucher_cost` 的
|
||||
* 商品级版本,由成本快照管道消费。
|
||||
*/
|
||||
import { DeleteOutlined, ReloadOutlined, SyncOutlined } from '@ant-design/icons'
|
||||
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -19,13 +19,14 @@ import {
|
||||
message,
|
||||
} from 'antd'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router'
|
||||
|
||||
import PercentTable from '@/components/common/PercentTable'
|
||||
import {
|
||||
deleteAdminVoucherProduct,
|
||||
ensureVoucherChannelRules,
|
||||
fetchAdminProductCatalog,
|
||||
saveAdminVoucherProduct,
|
||||
setAdminProductPrice,
|
||||
} from '@/services/admin'
|
||||
import type { ProductCatalogItem } from '@/types/admin'
|
||||
@@ -35,12 +36,20 @@ import { useCostCenter } from './CostCenterContext'
|
||||
|
||||
export default function VoucherSkuPanel() {
|
||||
const queryClient = useQueryClient()
|
||||
const [searchParams] = useSearchParams()
|
||||
const { canConfig, refreshAll } = useCostCenter()
|
||||
const handledCreateLinkRef = useRef(false)
|
||||
const voucherNewRequested = searchParams.get('voucherNew') === '1'
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
|
||||
const [selected, setSelected] = useState<ProductCatalogItem | null>(null)
|
||||
const [ensuringRules, setEnsuringRules] = useState(false)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [form] = Form.useForm<{ amountFen: number; changeReason?: string }>()
|
||||
const [createForm] = Form.useForm<{
|
||||
skuKey: string
|
||||
amountYuan: number
|
||||
note?: string
|
||||
}>()
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['cost-center-voucher-products', page, pageSize],
|
||||
@@ -68,6 +77,25 @@ export default function VoucherSkuPanel() {
|
||||
onError: (error) => message.error(error instanceof Error ? error.message : '保存失败'),
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (values: { skuKey: string; amountYuan: number; note?: string }) =>
|
||||
saveAdminVoucherProduct({
|
||||
skuKey: values.skuKey.trim(),
|
||||
amountFen: Math.round(values.amountYuan * 100),
|
||||
note: values.note || '',
|
||||
}),
|
||||
onSuccess: (response) => {
|
||||
message.success(
|
||||
`卡券 SKU 已保存并回填 ${response.data.linkedItemCount} 个订单项,补建成本快照 ${response.data.snapshotCreatedCount} 条`,
|
||||
)
|
||||
setCreateOpen(false)
|
||||
createForm.resetFields()
|
||||
void queryClient.invalidateQueries({ queryKey: ['cost-center-voucher-products'] })
|
||||
refreshAll()
|
||||
},
|
||||
onError: (error) => message.error(error instanceof Error ? error.message : '保存卡券 SKU 失败'),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (productId: number) => deleteAdminVoucherProduct(productId),
|
||||
onSuccess: (response) => {
|
||||
@@ -82,23 +110,15 @@ export default function VoucherSkuPanel() {
|
||||
onError: (error) => message.error(error instanceof Error ? error.message : '删除失败'),
|
||||
})
|
||||
|
||||
async function handleEnsureRules() {
|
||||
setEnsuringRules(true)
|
||||
try {
|
||||
const result = await ensureVoucherChannelRules()
|
||||
message.success(
|
||||
`卡券商品 ${result.data.productCount} 个,已补齐渠道规则 ${result.data.created} 条`,
|
||||
)
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '补齐卡券渠道规则失败')
|
||||
} finally {
|
||||
setEnsuringRules(false)
|
||||
}
|
||||
}
|
||||
|
||||
const items = query.data?.data.items || []
|
||||
const total = query.data?.data.pagination.total || 0
|
||||
|
||||
useEffect(() => {
|
||||
if (!canConfig || !voucherNewRequested || handledCreateLinkRef.current) return
|
||||
handledCreateLinkRef.current = true
|
||||
setCreateOpen(true)
|
||||
}, [canConfig, voucherNewRequested])
|
||||
|
||||
const columns = [
|
||||
{ title: '商品', dataIndex: 'canonicalName', ellipsis: true },
|
||||
{ title: '编码', dataIndex: 'canonicalSku', width: 220 },
|
||||
@@ -169,14 +189,11 @@ export default function VoucherSkuPanel() {
|
||||
title={`卡券商品采购价(${total})`}
|
||||
extra={
|
||||
<Space size={8}>
|
||||
<Button
|
||||
icon={<SyncOutlined />}
|
||||
loading={ensuringRules}
|
||||
disabled={!canConfig}
|
||||
onClick={() => void handleEnsureRules()}
|
||||
>
|
||||
批量生成渠道规则
|
||||
</Button>
|
||||
{canConfig ? (
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
|
||||
新增卡券 SKU
|
||||
</Button>
|
||||
) : null}
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void query.refetch()}>
|
||||
刷新
|
||||
</Button>
|
||||
@@ -184,8 +201,8 @@ export default function VoucherSkuPanel() {
|
||||
}
|
||||
>
|
||||
<Typography.Paragraph type="secondary">
|
||||
卡券渠道只统计:命中规则后不创建任务、不发领取页,只记收入与采购成本。 商品编码以{' '}
|
||||
<Typography.Text code>tpv:</Typography.Text> 开头。
|
||||
新增时只需填写内部 SKU 和采购价,系统会自动创建 <Typography.Text code>tpv:</Typography.Text>{' '}
|
||||
商品、卡券渠道规则并回填已有订单。卡券不创建任务、不发领取页,只记收入与采购成本。
|
||||
</Typography.Paragraph>
|
||||
<PercentTable
|
||||
rowKey="id"
|
||||
@@ -204,6 +221,50 @@ export default function VoucherSkuPanel() {
|
||||
})}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="新增第三方卡券 SKU"
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={() => createForm.submit()}
|
||||
confirmLoading={createMutation.isPending}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form
|
||||
form={createForm}
|
||||
layout="vertical"
|
||||
onFinish={(values) => createMutation.mutate(values)}
|
||||
>
|
||||
<Form.Item
|
||||
name="skuKey"
|
||||
label="内部 SKU"
|
||||
rules={[
|
||||
{ required: true, message: '请输入内部 SKU' },
|
||||
{
|
||||
validator: async (_, value) => {
|
||||
const text = String(value || '').trim()
|
||||
if (text && !/\p{Script=Han}/u.test(text)) {
|
||||
throw new Error('内部 SKU 必须包含中文')
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
extra="保存后自动生成 tpv:<内部 SKU> 标准商品和卡券渠道规则。"
|
||||
>
|
||||
<Input placeholder="例如:11月改名卡" maxLength={60} showCount />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="amountYuan"
|
||||
label="采购价(元)"
|
||||
rules={[{ required: true, message: '请输入采购价' }]}
|
||||
>
|
||||
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="note" label="备注">
|
||||
<Input.TextArea rows={2} maxLength={300} showCount />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`设置采购价:${selected?.canonicalName || ''}`}
|
||||
open={Boolean(selected)}
|
||||
|
||||
@@ -525,10 +525,21 @@ export function syncAdminProductPrice(productId: number) {
|
||||
}>(`/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) {
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
|
||||
@@ -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` 负责修复历史卡券商品归属。
|
||||
|
||||
## 五、成本版本接口
|
||||
|
||||
路径不变,字段不变:
|
||||
|
||||
Reference in New Issue
Block a user