feat(cost-center): 第三方卡券渠道归一与采购价配置
- 归一新增 tpv:<SKU> 直达,卡券单无需手工映射 - 成本配置/总览/异常支持 third_party_voucher 与 archived 维度 - 新增卡券采购价面板,写入 sys_third_party_voucher_cost - 修正 man: 商品改价误用不存在的 sys_manual_cost
This commit is contained in:
@@ -55,7 +55,7 @@ export const COST_MODEL_CODE_BASE: Record<string, string> = {
|
||||
other: 'other_cost',
|
||||
}
|
||||
|
||||
/** 履约方式:唯一真源是 order_items.fulfillment_mode。 */
|
||||
/** 履约渠道:唯一真源是 order_items.fulfillment_channel + fulfillment_status。 */
|
||||
export type FulfillmentMode = 'work_order' | 'affiliate_dash' | 'legacy_platform' | 'manual'
|
||||
|
||||
export const FULFILLMENT_MODES = new Set<string>([
|
||||
|
||||
@@ -80,7 +80,7 @@ export async function getAdminCostCenterOverview(params: { scope?: string } = {}
|
||||
AND (
|
||||
$1::text IS NULL
|
||||
OR ce.order_item_id IN (
|
||||
SELECT oi.id FROM order_items oi WHERE oi.fulfillment_mode = $1
|
||||
SELECT oi.id FROM order_items oi WHERE COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) = $1
|
||||
)
|
||||
)
|
||||
) AS open_exception_count,
|
||||
@@ -89,7 +89,7 @@ export async function getAdminCostCenterOverview(params: { scope?: string } = {}
|
||||
FROM order_items oi
|
||||
WHERE oi.canonical_product_id IS NULL
|
||||
AND oi.fulfillment_status <> 'archived'
|
||||
AND ($1::text IS NULL OR oi.fulfillment_mode = $1)
|
||||
AND ($1::text IS NULL OR COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) = $1)
|
||||
) AS unmapped_order_item_count
|
||||
`,
|
||||
[scope],
|
||||
@@ -121,7 +121,12 @@ export async function getAdminCostCenterOverview(params: { scope?: string } = {}
|
||||
}
|
||||
}
|
||||
|
||||
const COST_CENTER_SCOPES = new Set(['affiliate_dash', 'work_order', 'manual'])
|
||||
const COST_CENTER_SCOPES = new Set([
|
||||
'affiliate_dash',
|
||||
'work_order',
|
||||
'third_party_voucher',
|
||||
'manual',
|
||||
])
|
||||
|
||||
function normalizeScope(raw: unknown): CostCenterScope | null {
|
||||
const value = String(raw ?? '').trim()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 成本中心共享层入口。
|
||||
*
|
||||
* 口径(2026-10-23 重写):
|
||||
* 1. 履约方式只有 order_items.fulfillment_mode 一个真源;
|
||||
* 1. 履约渠道只有 order_items.fulfillment_channel/status 一个真源;
|
||||
* 2. 成本项用 cost_model 表达「成本怎么算」,与履约方式正交;
|
||||
* 3. 商品来源由 canonical_sku 前缀推导,商品表不再存履约载体;
|
||||
* 4. 快照与异常表都不再存履约载体,join order_items 即可拿到。
|
||||
|
||||
@@ -567,7 +567,7 @@ export async function listAdminCostExceptions(payload: JsonObject = {}) {
|
||||
params.push(since)
|
||||
clauses.push(`ce.created_at >= $${params.length}::timestamptz`)
|
||||
}
|
||||
// 履约方式筛选:异常表不再存载体,改为 join order_items 读 fulfillment_mode。
|
||||
// 履约渠道筛选:异常表不存渠道,改为 join order_items 读 channel/status。
|
||||
// 用 IN 半连接而不是 EXISTS:248k 异常 × 316k 订单项下,EXISTS 会逐行回表,
|
||||
// 半连接能一次物化候选集合(实测 2.7s -> 0.4s)。
|
||||
const fulfillmentMode = text(payload.fulfillmentMode ?? payload.fulfillment_mode)
|
||||
@@ -576,7 +576,7 @@ export async function listAdminCostExceptions(payload: JsonObject = {}) {
|
||||
params.push(fulfillmentMode)
|
||||
clauses.push(
|
||||
`ce.order_item_id IN (
|
||||
SELECT oi.id FROM order_items oi WHERE oi.fulfillment_mode = $${params.length}
|
||||
SELECT oi.id FROM order_items oi WHERE COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) = $${params.length}
|
||||
)`,
|
||||
)
|
||||
}
|
||||
@@ -591,7 +591,7 @@ export async function listAdminCostExceptions(payload: JsonObject = {}) {
|
||||
`
|
||||
SELECT
|
||||
ce.*,
|
||||
oi.fulfillment_mode
|
||||
COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) AS fulfillment_mode
|
||||
FROM cost_exceptions ce
|
||||
LEFT JOIN order_items oi ON oi.id = ce.order_item_id
|
||||
${where}
|
||||
@@ -635,7 +635,7 @@ export async function summarizeAdminCostExceptions(
|
||||
params.push(fulfillmentMode)
|
||||
clauses.push(
|
||||
`ce.order_item_id IN (
|
||||
SELECT oi.id FROM order_items oi WHERE oi.fulfillment_mode = $${params.length}
|
||||
SELECT oi.id FROM order_items oi WHERE COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) = $${params.length}
|
||||
)`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -250,7 +250,7 @@ export async function createAdminCostSnapshot(payload: JsonObject = {}, _session
|
||||
subject_type, subject_id, order_item_id, work_order_id,
|
||||
reason, status, detail_json, created_at
|
||||
) VALUES ('order_item', $1, $1, $2, $3, 'open', $4::jsonb, $5)
|
||||
ON CONFLICT (subject_type, subject_id, reason) DO NOTHING
|
||||
ON CONFLICT (scope_key) WHERE status <> 'resolved' DO NOTHING
|
||||
`,
|
||||
[
|
||||
orderItemId,
|
||||
@@ -315,7 +315,7 @@ async function resolveCostContext(payload: JsonObject): Promise<OrderItemCostCon
|
||||
COALESCE(o.paid_at, o.created_at) AS basis_at,
|
||||
wo.id AS work_order_id,
|
||||
wo.product_rule_id AS work_product_rule_id,
|
||||
oi.fulfillment_mode
|
||||
COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) AS fulfillment_mode
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
LEFT JOIN LATERAL (
|
||||
@@ -395,7 +395,7 @@ async function resolveCostContext(payload: JsonObject): Promise<OrderItemCostCon
|
||||
}
|
||||
|
||||
/**
|
||||
* 履约方式只读 order_items.fulfillment_mode。
|
||||
* 履约渠道只读 order_items.fulfillment_channel / fulfillment_status。
|
||||
* 订单项还没判定时按人工处理,不允许用 provider 或核销码兜底。
|
||||
*/
|
||||
function resolveFulfillmentMode(value: unknown): FulfillmentMode {
|
||||
|
||||
@@ -15,10 +15,7 @@ import { formatShanghaiDate, shiftDate } from '../../utils/finance-date-range.js
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { normalizeMatchText, normalizeSkuSeriesEquivalence } from '../product-match/normalize.js'
|
||||
import { resolveSkuNameQuantity } from '../product-match/pricing.js'
|
||||
import {
|
||||
setAdminAffiliateProductPrice,
|
||||
syncAdminAffiliateDashCostSnapshots,
|
||||
} from './affiliate-product-service.js'
|
||||
import { setAdminAffiliateProductPrice } from './affiliate-product-service.js'
|
||||
import { syncAdminWorkProductRulePrice } from '../worker-platform/admin-worker-catalog-service.js'
|
||||
import {
|
||||
AFFILIATE_TASK_AGG_JOIN,
|
||||
@@ -60,7 +57,12 @@ import {
|
||||
} from './cost-center-shared.js'
|
||||
|
||||
/** 商品来源的筛选取值:与 canonical_sku 前缀一一对应。 */
|
||||
const PRODUCT_SOURCE_FILTERS = new Set(['affiliate_dash', 'work_order', 'manual'])
|
||||
const PRODUCT_SOURCE_FILTERS = new Set([
|
||||
'affiliate_dash',
|
||||
'work_order',
|
||||
'third_party_voucher',
|
||||
'manual',
|
||||
])
|
||||
|
||||
/**
|
||||
* 校验新建商品的 SKU 前缀。
|
||||
@@ -625,13 +627,13 @@ export async function getAdminChannelMappingDiagnostics(payload: JsonObject = {}
|
||||
ce.detail_json->>'provider' AS provider,
|
||||
ce.detail_json->>'shopId' AS shop_id,
|
||||
COALESCE(ce.detail_json->>'skuCode', '') AS sku_code,
|
||||
oi.fulfillment_mode AS fulfillment_mode,
|
||||
COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) AS fulfillment_mode,
|
||||
COUNT(*)::int AS item_count
|
||||
FROM cost_exceptions ce
|
||||
JOIN order_items oi ON oi.id = ce.order_item_id
|
||||
WHERE ce.reason = 'product_mapping_missing'
|
||||
AND ce.status = 'open'
|
||||
AND oi.fulfillment_mode = $1
|
||||
AND COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) = $1
|
||||
GROUP BY 1, 2, 3, 4
|
||||
)
|
||||
SELECT
|
||||
@@ -716,7 +718,7 @@ export async function listAdminUnmappedMappingGroups(payload: JsonObject = {}) {
|
||||
FROM cost_exceptions ce
|
||||
JOIN order_items oi ON oi.id = ce.order_item_id
|
||||
WHERE ${clauses.join(' AND ')}
|
||||
AND oi.fulfillment_mode = $${totalParams.length}
|
||||
AND COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) = $${totalParams.length}
|
||||
GROUP BY 1, 2, 3
|
||||
)
|
||||
SELECT
|
||||
@@ -744,7 +746,9 @@ export async function listAdminUnmappedMappingGroups(payload: JsonObject = {}) {
|
||||
const pageIndex = params.length + (modeIndex ? 2 : 1)
|
||||
const offsetIndex = pageIndex + 1
|
||||
const listParams = [...params, ...(fulfillmentMode ? [fulfillmentMode] : []), pageSize, offset]
|
||||
const modeClause = modeIndex ? `AND oi.fulfillment_mode = $${modeIndex}` : ''
|
||||
const modeClause = modeIndex
|
||||
? `AND COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) = $${modeIndex}`
|
||||
: ''
|
||||
const result = await query<{
|
||||
provider: string
|
||||
shop_id: string
|
||||
@@ -760,7 +764,7 @@ export async function listAdminUnmappedMappingGroups(payload: JsonObject = {}) {
|
||||
ce.detail_json->>'provider' AS provider,
|
||||
ce.detail_json->>'shopId' AS shop_id,
|
||||
COALESCE(ce.detail_json->>'skuCode', '') AS sku_code,
|
||||
oi.fulfillment_mode AS fulfillment_mode,
|
||||
COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) AS fulfillment_mode,
|
||||
MAX(ce.detail_json->>'skuName') AS sku_name,
|
||||
MAX(NULLIF(ce.detail_json->>'basisAt', '')) AS last_seen_at,
|
||||
COUNT(*)::int AS item_count
|
||||
@@ -1522,10 +1526,12 @@ export async function setAdminProductPrice(
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 其他标准商品 (wo: 或 man:)
|
||||
// 3. 其他标准商品 (wo: / tpv: / man:)
|
||||
const costItemCode = product.canonical_sku.startsWith('wo:')
|
||||
? 'sys_work_order_reward'
|
||||
: 'sys_manual_cost'
|
||||
: product.canonical_sku.startsWith('tpv:')
|
||||
? 'sys_third_party_voucher_cost'
|
||||
: 'sys_manual_service'
|
||||
const costItemResult = await query<{ id: number }>(
|
||||
'SELECT id FROM cost_items WHERE code = $1 LIMIT 1',
|
||||
[costItemCode],
|
||||
@@ -1647,13 +1653,12 @@ export async function syncAdminProductPrice(productIdInput: unknown, session: Ad
|
||||
}
|
||||
|
||||
if (product.canonical_sku.startsWith('aff:')) {
|
||||
const syncRes = await syncAdminAffiliateDashCostSnapshots()
|
||||
// 成本快照由成本快照管道按缺补算,这里不再同步触发。
|
||||
return {
|
||||
success: true,
|
||||
type: 'affiliate_snapshots_synced',
|
||||
type: 'affiliate_price_saved',
|
||||
product,
|
||||
snapshots: syncRes,
|
||||
message: `已重算成本快照(生成 ${syncRes.snapshotResult.createdCount} 笔快照,消除 ${syncRes.snapshotResult.resolvedExceptionCount} 笔异常),并重算销售事实`,
|
||||
message: '价格已保存,成本快照会在下一轮管道运行后自动补齐',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ const index = buildMappingIndex(
|
||||
mappings,
|
||||
[{ sku: 'aff-sku', productId: 21 }],
|
||||
[{ ruleKey: 'rule-abc', productId: 31 }],
|
||||
[{ sku: 'voucher-sku', productId: 41 }],
|
||||
)
|
||||
|
||||
function item(patch: Record<string, unknown> = {}) {
|
||||
@@ -123,3 +124,13 @@ test('商品归一:人工渠道直接使用配置里指定的商品', () => {
|
||||
44,
|
||||
)
|
||||
})
|
||||
|
||||
test('商品归一:第三方卡券按 tpv:SKU 直接命中', () => {
|
||||
assert.equal(
|
||||
resolveProductId(
|
||||
item({ fulfillment_channel: 'third_party_voucher', sku_code: 'voucher-sku' }),
|
||||
index,
|
||||
),
|
||||
41,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -32,6 +32,7 @@ export type MappingIndex = {
|
||||
bySkuSeries: Map<string, number>
|
||||
byNameSeries: Map<string, number>
|
||||
affSkus: Map<string, number>
|
||||
voucherSkus: Map<string, number>
|
||||
templateRules: Map<string, number>
|
||||
}
|
||||
|
||||
@@ -56,6 +57,7 @@ export function buildMappingIndex(
|
||||
mappings: MappingRow[],
|
||||
affiliateProducts: Array<{ sku: string; productId: number }>,
|
||||
templateProducts: Array<{ ruleKey: string; productId: number }> = [],
|
||||
voucherProducts: Array<{ sku: string; productId: number }> = [],
|
||||
): MappingIndex {
|
||||
const index: MappingIndex = {
|
||||
bySku: new Map(),
|
||||
@@ -63,6 +65,7 @@ export function buildMappingIndex(
|
||||
bySkuSeries: new Map(),
|
||||
byNameSeries: new Map(),
|
||||
affSkus: new Map(),
|
||||
voucherSkus: new Map(),
|
||||
templateRules: new Map(),
|
||||
}
|
||||
|
||||
@@ -99,6 +102,10 @@ export function buildMappingIndex(
|
||||
index.templateRules.set(String(product.ruleKey).trim(), product.productId)
|
||||
}
|
||||
|
||||
for (const product of voucherProducts) {
|
||||
index.voucherSkus.set(String(product.sku).trim(), product.productId)
|
||||
}
|
||||
|
||||
return index
|
||||
}
|
||||
|
||||
@@ -126,6 +133,12 @@ export function resolveProductId(item: ItemRow, index: MappingIndex): number | n
|
||||
if (aff) return aff
|
||||
}
|
||||
|
||||
// 第三方卡券:与自动发货同构,商品编码 tpv:<sku>。
|
||||
if (item.fulfillment_channel === 'third_party_voucher' && skuCode) {
|
||||
const voucher = index.voucherSkus.get(skuCode)
|
||||
if (voucher) return voucher
|
||||
}
|
||||
|
||||
const exactWithShop = index.bySku.get(key(provider, shopId, skuCode))
|
||||
if (exactWithShop) return exactWithShop
|
||||
const exactGlobal = index.bySku.get(key(provider, '', skuCode))
|
||||
@@ -265,6 +278,14 @@ async function loadMappingIndex(): Promise<MappingIndex> {
|
||||
`,
|
||||
)
|
||||
|
||||
const voucherProducts = await query<{ canonical_sku: string; id: number }>(
|
||||
`
|
||||
SELECT id, canonical_sku
|
||||
FROM product_catalog
|
||||
WHERE status = 'active' AND canonical_sku LIKE 'tpv:%'
|
||||
`,
|
||||
)
|
||||
|
||||
return buildMappingIndex(
|
||||
mappings.rows,
|
||||
affiliateProducts.rows.map((row) => ({
|
||||
@@ -275,5 +296,9 @@ async function loadMappingIndex(): Promise<MappingIndex> {
|
||||
ruleKey: String(row.canonical_sku).slice(4),
|
||||
productId: Number(row.id),
|
||||
})),
|
||||
voucherProducts.rows.map((row) => ({
|
||||
sku: String(row.canonical_sku).slice(4),
|
||||
productId: Number(row.id),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user