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',
|
other: 'other_cost',
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 履约方式:唯一真源是 order_items.fulfillment_mode。 */
|
/** 履约渠道:唯一真源是 order_items.fulfillment_channel + fulfillment_status。 */
|
||||||
export type FulfillmentMode = 'work_order' | 'affiliate_dash' | 'legacy_platform' | 'manual'
|
export type FulfillmentMode = 'work_order' | 'affiliate_dash' | 'legacy_platform' | 'manual'
|
||||||
|
|
||||||
export const FULFILLMENT_MODES = new Set<string>([
|
export const FULFILLMENT_MODES = new Set<string>([
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export async function getAdminCostCenterOverview(params: { scope?: string } = {}
|
|||||||
AND (
|
AND (
|
||||||
$1::text IS NULL
|
$1::text IS NULL
|
||||||
OR ce.order_item_id IN (
|
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,
|
) AS open_exception_count,
|
||||||
@@ -89,7 +89,7 @@ export async function getAdminCostCenterOverview(params: { scope?: string } = {}
|
|||||||
FROM order_items oi
|
FROM order_items oi
|
||||||
WHERE oi.canonical_product_id IS NULL
|
WHERE oi.canonical_product_id IS NULL
|
||||||
AND oi.fulfillment_status <> 'archived'
|
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
|
) AS unmapped_order_item_count
|
||||||
`,
|
`,
|
||||||
[scope],
|
[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 {
|
function normalizeScope(raw: unknown): CostCenterScope | null {
|
||||||
const value = String(raw ?? '').trim()
|
const value = String(raw ?? '').trim()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* 成本中心共享层入口。
|
* 成本中心共享层入口。
|
||||||
*
|
*
|
||||||
* 口径(2026-10-23 重写):
|
* 口径(2026-10-23 重写):
|
||||||
* 1. 履约方式只有 order_items.fulfillment_mode 一个真源;
|
* 1. 履约渠道只有 order_items.fulfillment_channel/status 一个真源;
|
||||||
* 2. 成本项用 cost_model 表达「成本怎么算」,与履约方式正交;
|
* 2. 成本项用 cost_model 表达「成本怎么算」,与履约方式正交;
|
||||||
* 3. 商品来源由 canonical_sku 前缀推导,商品表不再存履约载体;
|
* 3. 商品来源由 canonical_sku 前缀推导,商品表不再存履约载体;
|
||||||
* 4. 快照与异常表都不再存履约载体,join order_items 即可拿到。
|
* 4. 快照与异常表都不再存履约载体,join order_items 即可拿到。
|
||||||
|
|||||||
@@ -567,7 +567,7 @@ export async function listAdminCostExceptions(payload: JsonObject = {}) {
|
|||||||
params.push(since)
|
params.push(since)
|
||||||
clauses.push(`ce.created_at >= $${params.length}::timestamptz`)
|
clauses.push(`ce.created_at >= $${params.length}::timestamptz`)
|
||||||
}
|
}
|
||||||
// 履约方式筛选:异常表不再存载体,改为 join order_items 读 fulfillment_mode。
|
// 履约渠道筛选:异常表不存渠道,改为 join order_items 读 channel/status。
|
||||||
// 用 IN 半连接而不是 EXISTS:248k 异常 × 316k 订单项下,EXISTS 会逐行回表,
|
// 用 IN 半连接而不是 EXISTS:248k 异常 × 316k 订单项下,EXISTS 会逐行回表,
|
||||||
// 半连接能一次物化候选集合(实测 2.7s -> 0.4s)。
|
// 半连接能一次物化候选集合(实测 2.7s -> 0.4s)。
|
||||||
const fulfillmentMode = text(payload.fulfillmentMode ?? payload.fulfillment_mode)
|
const fulfillmentMode = text(payload.fulfillmentMode ?? payload.fulfillment_mode)
|
||||||
@@ -576,7 +576,7 @@ export async function listAdminCostExceptions(payload: JsonObject = {}) {
|
|||||||
params.push(fulfillmentMode)
|
params.push(fulfillmentMode)
|
||||||
clauses.push(
|
clauses.push(
|
||||||
`ce.order_item_id IN (
|
`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
|
SELECT
|
||||||
ce.*,
|
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
|
FROM cost_exceptions ce
|
||||||
LEFT JOIN order_items oi ON oi.id = ce.order_item_id
|
LEFT JOIN order_items oi ON oi.id = ce.order_item_id
|
||||||
${where}
|
${where}
|
||||||
@@ -635,7 +635,7 @@ export async function summarizeAdminCostExceptions(
|
|||||||
params.push(fulfillmentMode)
|
params.push(fulfillmentMode)
|
||||||
clauses.push(
|
clauses.push(
|
||||||
`ce.order_item_id IN (
|
`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,
|
subject_type, subject_id, order_item_id, work_order_id,
|
||||||
reason, status, detail_json, created_at
|
reason, status, detail_json, created_at
|
||||||
) VALUES ('order_item', $1, $1, $2, $3, 'open', $4::jsonb, $5)
|
) 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,
|
orderItemId,
|
||||||
@@ -315,7 +315,7 @@ async function resolveCostContext(payload: JsonObject): Promise<OrderItemCostCon
|
|||||||
COALESCE(o.paid_at, o.created_at) AS basis_at,
|
COALESCE(o.paid_at, o.created_at) AS basis_at,
|
||||||
wo.id AS work_order_id,
|
wo.id AS work_order_id,
|
||||||
wo.product_rule_id AS work_product_rule_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
|
FROM order_items oi
|
||||||
JOIN orders o ON o.id = oi.order_id
|
JOIN orders o ON o.id = oi.order_id
|
||||||
LEFT JOIN LATERAL (
|
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 或核销码兜底。
|
* 订单项还没判定时按人工处理,不允许用 provider 或核销码兜底。
|
||||||
*/
|
*/
|
||||||
function resolveFulfillmentMode(value: unknown): FulfillmentMode {
|
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 { nowIso } from '../../utils/time.js'
|
||||||
import { normalizeMatchText, normalizeSkuSeriesEquivalence } from '../product-match/normalize.js'
|
import { normalizeMatchText, normalizeSkuSeriesEquivalence } from '../product-match/normalize.js'
|
||||||
import { resolveSkuNameQuantity } from '../product-match/pricing.js'
|
import { resolveSkuNameQuantity } from '../product-match/pricing.js'
|
||||||
import {
|
import { setAdminAffiliateProductPrice } from './affiliate-product-service.js'
|
||||||
setAdminAffiliateProductPrice,
|
|
||||||
syncAdminAffiliateDashCostSnapshots,
|
|
||||||
} from './affiliate-product-service.js'
|
|
||||||
import { syncAdminWorkProductRulePrice } from '../worker-platform/admin-worker-catalog-service.js'
|
import { syncAdminWorkProductRulePrice } from '../worker-platform/admin-worker-catalog-service.js'
|
||||||
import {
|
import {
|
||||||
AFFILIATE_TASK_AGG_JOIN,
|
AFFILIATE_TASK_AGG_JOIN,
|
||||||
@@ -60,7 +57,12 @@ import {
|
|||||||
} from './cost-center-shared.js'
|
} from './cost-center-shared.js'
|
||||||
|
|
||||||
/** 商品来源的筛选取值:与 canonical_sku 前缀一一对应。 */
|
/** 商品来源的筛选取值:与 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 前缀。
|
* 校验新建商品的 SKU 前缀。
|
||||||
@@ -625,13 +627,13 @@ export async function getAdminChannelMappingDiagnostics(payload: JsonObject = {}
|
|||||||
ce.detail_json->>'provider' AS provider,
|
ce.detail_json->>'provider' AS provider,
|
||||||
ce.detail_json->>'shopId' AS shop_id,
|
ce.detail_json->>'shopId' AS shop_id,
|
||||||
COALESCE(ce.detail_json->>'skuCode', '') AS sku_code,
|
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
|
COUNT(*)::int AS item_count
|
||||||
FROM cost_exceptions ce
|
FROM cost_exceptions ce
|
||||||
JOIN order_items oi ON oi.id = ce.order_item_id
|
JOIN order_items oi ON oi.id = ce.order_item_id
|
||||||
WHERE ce.reason = 'product_mapping_missing'
|
WHERE ce.reason = 'product_mapping_missing'
|
||||||
AND ce.status = 'open'
|
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
|
GROUP BY 1, 2, 3, 4
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
@@ -716,7 +718,7 @@ export async function listAdminUnmappedMappingGroups(payload: JsonObject = {}) {
|
|||||||
FROM cost_exceptions ce
|
FROM cost_exceptions ce
|
||||||
JOIN order_items oi ON oi.id = ce.order_item_id
|
JOIN order_items oi ON oi.id = ce.order_item_id
|
||||||
WHERE ${clauses.join(' AND ')}
|
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
|
GROUP BY 1, 2, 3
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
@@ -744,7 +746,9 @@ export async function listAdminUnmappedMappingGroups(payload: JsonObject = {}) {
|
|||||||
const pageIndex = params.length + (modeIndex ? 2 : 1)
|
const pageIndex = params.length + (modeIndex ? 2 : 1)
|
||||||
const offsetIndex = pageIndex + 1
|
const offsetIndex = pageIndex + 1
|
||||||
const listParams = [...params, ...(fulfillmentMode ? [fulfillmentMode] : []), pageSize, offset]
|
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<{
|
const result = await query<{
|
||||||
provider: string
|
provider: string
|
||||||
shop_id: string
|
shop_id: string
|
||||||
@@ -760,7 +764,7 @@ export async function listAdminUnmappedMappingGroups(payload: JsonObject = {}) {
|
|||||||
ce.detail_json->>'provider' AS provider,
|
ce.detail_json->>'provider' AS provider,
|
||||||
ce.detail_json->>'shopId' AS shop_id,
|
ce.detail_json->>'shopId' AS shop_id,
|
||||||
COALESCE(ce.detail_json->>'skuCode', '') AS sku_code,
|
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(ce.detail_json->>'skuName') AS sku_name,
|
||||||
MAX(NULLIF(ce.detail_json->>'basisAt', '')) AS last_seen_at,
|
MAX(NULLIF(ce.detail_json->>'basisAt', '')) AS last_seen_at,
|
||||||
COUNT(*)::int AS item_count
|
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:')
|
const costItemCode = product.canonical_sku.startsWith('wo:')
|
||||||
? 'sys_work_order_reward'
|
? '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 }>(
|
const costItemResult = await query<{ id: number }>(
|
||||||
'SELECT id FROM cost_items WHERE code = $1 LIMIT 1',
|
'SELECT id FROM cost_items WHERE code = $1 LIMIT 1',
|
||||||
[costItemCode],
|
[costItemCode],
|
||||||
@@ -1647,13 +1653,12 @@ export async function syncAdminProductPrice(productIdInput: unknown, session: Ad
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (product.canonical_sku.startsWith('aff:')) {
|
if (product.canonical_sku.startsWith('aff:')) {
|
||||||
const syncRes = await syncAdminAffiliateDashCostSnapshots()
|
// 成本快照由成本快照管道按缺补算,这里不再同步触发。
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
type: 'affiliate_snapshots_synced',
|
type: 'affiliate_price_saved',
|
||||||
product,
|
product,
|
||||||
snapshots: syncRes,
|
message: '价格已保存,成本快照会在下一轮管道运行后自动补齐',
|
||||||
message: `已重算成本快照(生成 ${syncRes.snapshotResult.createdCount} 笔快照,消除 ${syncRes.snapshotResult.resolvedExceptionCount} 笔异常),并重算销售事实`,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ const index = buildMappingIndex(
|
|||||||
mappings,
|
mappings,
|
||||||
[{ sku: 'aff-sku', productId: 21 }],
|
[{ sku: 'aff-sku', productId: 21 }],
|
||||||
[{ ruleKey: 'rule-abc', productId: 31 }],
|
[{ ruleKey: 'rule-abc', productId: 31 }],
|
||||||
|
[{ sku: 'voucher-sku', productId: 41 }],
|
||||||
)
|
)
|
||||||
|
|
||||||
function item(patch: Record<string, unknown> = {}) {
|
function item(patch: Record<string, unknown> = {}) {
|
||||||
@@ -123,3 +124,13 @@ test('商品归一:人工渠道直接使用配置里指定的商品', () => {
|
|||||||
44,
|
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>
|
bySkuSeries: Map<string, number>
|
||||||
byNameSeries: Map<string, number>
|
byNameSeries: Map<string, number>
|
||||||
affSkus: Map<string, number>
|
affSkus: Map<string, number>
|
||||||
|
voucherSkus: Map<string, number>
|
||||||
templateRules: Map<string, number>
|
templateRules: Map<string, number>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@ export function buildMappingIndex(
|
|||||||
mappings: MappingRow[],
|
mappings: MappingRow[],
|
||||||
affiliateProducts: Array<{ sku: string; productId: number }>,
|
affiliateProducts: Array<{ sku: string; productId: number }>,
|
||||||
templateProducts: Array<{ ruleKey: string; productId: number }> = [],
|
templateProducts: Array<{ ruleKey: string; productId: number }> = [],
|
||||||
|
voucherProducts: Array<{ sku: string; productId: number }> = [],
|
||||||
): MappingIndex {
|
): MappingIndex {
|
||||||
const index: MappingIndex = {
|
const index: MappingIndex = {
|
||||||
bySku: new Map(),
|
bySku: new Map(),
|
||||||
@@ -63,6 +65,7 @@ export function buildMappingIndex(
|
|||||||
bySkuSeries: new Map(),
|
bySkuSeries: new Map(),
|
||||||
byNameSeries: new Map(),
|
byNameSeries: new Map(),
|
||||||
affSkus: new Map(),
|
affSkus: new Map(),
|
||||||
|
voucherSkus: new Map(),
|
||||||
templateRules: new Map(),
|
templateRules: new Map(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +102,10 @@ export function buildMappingIndex(
|
|||||||
index.templateRules.set(String(product.ruleKey).trim(), product.productId)
|
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
|
return index
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +133,12 @@ export function resolveProductId(item: ItemRow, index: MappingIndex): number | n
|
|||||||
if (aff) return aff
|
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))
|
const exactWithShop = index.bySku.get(key(provider, shopId, skuCode))
|
||||||
if (exactWithShop) return exactWithShop
|
if (exactWithShop) return exactWithShop
|
||||||
const exactGlobal = index.bySku.get(key(provider, '', skuCode))
|
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(
|
return buildMappingIndex(
|
||||||
mappings.rows,
|
mappings.rows,
|
||||||
affiliateProducts.rows.map((row) => ({
|
affiliateProducts.rows.map((row) => ({
|
||||||
@@ -275,5 +296,9 @@ async function loadMappingIndex(): Promise<MappingIndex> {
|
|||||||
ruleKey: String(row.canonical_sku).slice(4),
|
ruleKey: String(row.canonical_sku).slice(4),
|
||||||
productId: Number(row.id),
|
productId: Number(row.id),
|
||||||
})),
|
})),
|
||||||
|
voucherProducts.rows.map((row) => ({
|
||||||
|
sku: String(row.canonical_sku).slice(4),
|
||||||
|
productId: Number(row.id),
|
||||||
|
})),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,15 +38,20 @@ const FULFILLMENT_MODE_OPTIONS = [
|
|||||||
{ value: '', label: '全部履约方式' },
|
{ value: '', label: '全部履约方式' },
|
||||||
{ value: 'work_order', label: '接单平台' },
|
{ value: 'work_order', label: '接单平台' },
|
||||||
{ value: 'affiliate_dash', label: '自动发货' },
|
{ value: 'affiliate_dash', label: '自动发货' },
|
||||||
{ value: 'legacy_platform', label: '历史平台' },
|
{ value: 'third_party_voucher', label: '第三方卡券' },
|
||||||
{ value: 'manual', label: '人工客服' },
|
{ value: 'manual', label: '人工客服' },
|
||||||
|
{ value: 'unclassified', label: '未分类' },
|
||||||
|
{ value: 'archived', label: '历史归档' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const FULFILLMENT_MODE_LABEL: Record<string, string> = {
|
const FULFILLMENT_MODE_LABEL: Record<string, string> = {
|
||||||
work_order: '接单平台',
|
work_order: '接单平台',
|
||||||
affiliate_dash: '自动发货',
|
affiliate_dash: '自动发货',
|
||||||
legacy_platform: '历史平台',
|
third_party_voucher: '第三方卡券',
|
||||||
|
legacy_platform: '历史归档',
|
||||||
manual: '人工客服',
|
manual: '人工客服',
|
||||||
|
archived: '历史归档',
|
||||||
|
unclassified: '未分类',
|
||||||
}
|
}
|
||||||
|
|
||||||
const COST_STAGE_LABEL: Record<string, { label: string; color: string }> = {
|
const COST_STAGE_LABEL: Record<string, { label: string; color: string }> = {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export type CostCenterContextValue = {
|
|||||||
/** 商品/映射按 canonical_sku 前缀筛选。 */
|
/** 商品/映射按 canonical_sku 前缀筛选。 */
|
||||||
productSourceFilter?: ProductSource
|
productSourceFilter?: ProductSource
|
||||||
/** 成本项/版本/异常按履约方式筛选。 */
|
/** 成本项/版本/异常按履约方式筛选。 */
|
||||||
fulfillmentModeFilter?: 'affiliate_dash' | 'work_order' | 'manual'
|
fulfillmentModeFilter?: 'affiliate_dash' | 'work_order' | 'third_party_voucher' | 'manual'
|
||||||
|
|
||||||
options?: {
|
options?: {
|
||||||
providers: string[]
|
providers: string[]
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ const SCOPES: Array<{ key: CostScope; label: string; hint: string }> = [
|
|||||||
label: '接单平台',
|
label: '接单平台',
|
||||||
hint: '需要打手接单履约的商品;成本以打手报酬为主,按接单模板配价。',
|
hint: '需要打手接单履约的商品;成本以打手报酬为主,按接单模板配价。',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'third_party_voucher',
|
||||||
|
label: '第三方卡券',
|
||||||
|
hint: '只统计渠道:不建任务、不发领取页;成本按商品采购价版本核算。',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'manual',
|
key: 'manual',
|
||||||
label: '人工客服',
|
label: '人工客服',
|
||||||
@@ -104,7 +109,9 @@ export default function CostCenterShell() {
|
|||||||
const scopeHint = SCOPES.find((item) => item.key === scope)?.hint ?? ''
|
const scopeHint = SCOPES.find((item) => item.key === scope)?.hint ?? ''
|
||||||
const productSourceFilter = scope === 'all' ? undefined : scope
|
const productSourceFilter = scope === 'all' ? undefined : scope
|
||||||
const fulfillmentModeFilter =
|
const fulfillmentModeFilter =
|
||||||
scope === 'all' ? undefined : (scope as 'affiliate_dash' | 'work_order' | 'manual')
|
scope === 'all'
|
||||||
|
? undefined
|
||||||
|
: (scope as 'affiliate_dash' | 'work_order' | 'third_party_voucher' | 'manual')
|
||||||
const mappingsApplicable = scope === 'all' || scope === 'work_order'
|
const mappingsApplicable = scope === 'all' || scope === 'work_order'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -4,9 +4,10 @@
|
|||||||
* 三个来源的成本形态完全不同,所以这里不做统一表格,而是按来源切换:
|
* 三个来源的成本形态完全不同,所以这里不做统一表格,而是按来源切换:
|
||||||
* 自动发货 → 上游 SKU 采购价(按 aff:<skuCode> 归一)
|
* 自动发货 → 上游 SKU 采购价(按 aff:<skuCode> 归一)
|
||||||
* 接单平台 → 接单模板发单金额(只读核对,改价去接单模板页)
|
* 接单平台 → 接单模板发单金额(只读核对,改价去接单模板页)
|
||||||
|
* 第三方卡券 → 卡券商品采购价(按 tpv:<sku> 归一,只统计)
|
||||||
* 人工客服 → 内部 SKU 的履约渠道与消耗积分
|
* 人工客服 → 内部 SKU 的履约渠道与消耗积分
|
||||||
*
|
*
|
||||||
* 来源在页面顶部工具栏统一选择;选「全部」时这里给出三个入口,
|
* 来源在页面顶部工具栏统一选择;选「全部」时这里给出四个入口,
|
||||||
* 避免把三套配置竖着堆在一页里。
|
* 避免把三套配置竖着堆在一页里。
|
||||||
*/
|
*/
|
||||||
import { Button, Card, Col, Row, Space, Typography } from 'antd'
|
import { Button, Card, Col, Row, Space, Typography } from 'antd'
|
||||||
@@ -14,6 +15,7 @@ import { Button, Card, Col, Row, Space, Typography } from 'antd'
|
|||||||
import { AffiliateSkuPanel } from './AffiliateSkuPanel'
|
import { AffiliateSkuPanel } from './AffiliateSkuPanel'
|
||||||
import { useCostCenter } from './CostCenterContext'
|
import { useCostCenter } from './CostCenterContext'
|
||||||
import { ManualSkuConfigPanel } from './ManualSkuConfigPanel'
|
import { ManualSkuConfigPanel } from './ManualSkuConfigPanel'
|
||||||
|
import VoucherSkuPanel from './VoucherSkuPanel'
|
||||||
import { WorkOrderPricePanel } from './WorkOrderPricePanel'
|
import { WorkOrderPricePanel } from './WorkOrderPricePanel'
|
||||||
import type { CostScope } from './shared'
|
import type { CostScope } from './shared'
|
||||||
|
|
||||||
@@ -32,6 +34,11 @@ const CONFIG_ENTRIES: Array<{
|
|||||||
title: '接单平台发单金额',
|
title: '接单平台发单金额',
|
||||||
description: '接单模板 → 发单金额的当前生效版本,只读核对;改价请改模板后重新同步。',
|
description: '接单模板 → 发单金额的当前生效版本,只读核对;改价请改模板后重新同步。',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'third_party_voucher',
|
||||||
|
title: '第三方卡券采购价',
|
||||||
|
description: '按 tpv:<SKU> 配置卡券采购价;该渠道只统计收入与成本,不产生履约动作。',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'manual',
|
key: 'manual',
|
||||||
title: '人工客服 SKU 消耗',
|
title: '人工客服 SKU 消耗',
|
||||||
@@ -44,6 +51,7 @@ export default function CostConfigPanel() {
|
|||||||
|
|
||||||
if (scope === 'affiliate_dash') return <AffiliateSkuPanel />
|
if (scope === 'affiliate_dash') return <AffiliateSkuPanel />
|
||||||
if (scope === 'work_order') return <WorkOrderPricePanel />
|
if (scope === 'work_order') return <WorkOrderPricePanel />
|
||||||
|
if (scope === 'third_party_voucher') return <VoucherSkuPanel />
|
||||||
if (scope === 'manual') return <ManualSkuConfigPanel />
|
if (scope === 'manual') return <ManualSkuConfigPanel />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -52,8 +52,11 @@ const FULFILLMENT_MODE_LABELS: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const REASON_LABELS: Record<string, string> = {
|
const REASON_LABELS: Record<string, string> = {
|
||||||
|
classification_missing: '待分类',
|
||||||
|
routing_conflict: '渠道规则冲突',
|
||||||
product_mapping_missing: '商品未归一',
|
product_mapping_missing: '商品未归一',
|
||||||
cost_missing: '成本缺失',
|
cost_missing: '成本缺失',
|
||||||
|
manual_config_missing: '人工 SKU 未配置',
|
||||||
manual_cost_missing: '人工成本未记录',
|
manual_cost_missing: '人工成本未记录',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* 第三方卡券采购价:只统计渠道,不产生履约动作。
|
||||||
|
*
|
||||||
|
* 商品编码固定为 `tpv:<sku>`;成本写入 `sys_third_party_voucher_cost` 的
|
||||||
|
* 商品级版本,由成本快照管道消费。
|
||||||
|
*/
|
||||||
|
import { ReloadOutlined } from '@ant-design/icons'
|
||||||
|
import { Button, Card, Form, Input, InputNumber, Modal, Tag, Typography, message } from 'antd'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import PercentTable from '@/components/common/PercentTable'
|
||||||
|
import { fetchAdminProductCatalog, setAdminProductPrice } from '@/services/admin'
|
||||||
|
import type { ProductCatalogItem } from '@/types/admin'
|
||||||
|
import { useCostCenter } from './CostCenterContext'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 100
|
||||||
|
|
||||||
|
export default function VoucherSkuPanel() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { canConfig, refreshAll } = useCostCenter()
|
||||||
|
const [page] = useState(1)
|
||||||
|
const [selected, setSelected] = useState<ProductCatalogItem | null>(null)
|
||||||
|
const [form] = Form.useForm<{ amountFen: number; changeReason?: string }>()
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ['cost-center-voucher-products', page],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchAdminProductCatalog({
|
||||||
|
carrierType: 'third_party_voucher',
|
||||||
|
page,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: (values: { amountFen: number; changeReason?: string }) =>
|
||||||
|
setAdminProductPrice(selected!.id, {
|
||||||
|
amountFen: Math.round(values.amountFen * 100),
|
||||||
|
changeReason: values.changeReason || '第三方卡券采购价调整',
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('采购价已保存')
|
||||||
|
setSelected(null)
|
||||||
|
form.resetFields()
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['cost-center-voucher-products'] })
|
||||||
|
refreshAll()
|
||||||
|
},
|
||||||
|
onError: (error) => message.error(error instanceof Error ? error.message : '保存失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const items = query.data?.data.items || []
|
||||||
|
const total = query.data?.data.pagination.total || 0
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: '商品', dataIndex: 'canonicalName', ellipsis: true },
|
||||||
|
{ title: '编码', dataIndex: 'canonicalSku', width: 220 },
|
||||||
|
{
|
||||||
|
title: '采购价',
|
||||||
|
width: 140,
|
||||||
|
render: (_: unknown, row: ProductCatalogItem) =>
|
||||||
|
row.activeCostAmountFen === null || row.activeCostAmountFen === undefined ? (
|
||||||
|
<Tag color="orange">未配价</Tag>
|
||||||
|
) : (
|
||||||
|
`¥${(row.activeCostAmountFen / 100).toFixed(2)}`
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '生效时间',
|
||||||
|
dataIndex: 'activeCostEffectiveFrom',
|
||||||
|
width: 180,
|
||||||
|
render: (value?: string | null) => value || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 110,
|
||||||
|
render: (_: unknown, row: ProductCatalogItem) => (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
disabled={!canConfig}
|
||||||
|
onClick={() => {
|
||||||
|
setSelected(row)
|
||||||
|
form.setFieldsValue({
|
||||||
|
amountFen:
|
||||||
|
row.activeCostAmountFen === null || row.activeCostAmountFen === undefined
|
||||||
|
? 0
|
||||||
|
: Number((row.activeCostAmountFen / 100).toFixed(2)),
|
||||||
|
changeReason: '第三方卡券采购价调整',
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
改价
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title={`卡券商品采购价(${total})`}
|
||||||
|
extra={
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={() => void query.refetch()}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Typography.Paragraph type="secondary">
|
||||||
|
卡券渠道只统计:命中规则后不创建任务、不发领取页,只记收入与采购成本。 商品编码以{' '}
|
||||||
|
<Typography.Text code>tpv:</Typography.Text> 开头。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<PercentTable
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
loading={query.isFetching}
|
||||||
|
dataSource={items}
|
||||||
|
columns={columns}
|
||||||
|
pagination={{ pageSize: 20, showSizeChanger: false }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={`设置采购价:${selected?.canonicalName || ''}`}
|
||||||
|
open={Boolean(selected)}
|
||||||
|
onCancel={() => setSelected(null)}
|
||||||
|
onOk={() => form.submit()}
|
||||||
|
confirmLoading={saveMutation.isPending}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" onFinish={(values) => saveMutation.mutate(values)}>
|
||||||
|
<Form.Item
|
||||||
|
name="amountFen"
|
||||||
|
label="采购价(元)"
|
||||||
|
rules={[{ required: true, message: '请输入采购价' }]}
|
||||||
|
>
|
||||||
|
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="changeReason" label="变更原因">
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -16,8 +16,8 @@ import type {
|
|||||||
ProductSource,
|
ProductSource,
|
||||||
} from '@/types/admin'
|
} from '@/types/admin'
|
||||||
|
|
||||||
/** 顶部来源切换:全部 / 自动发货 / 接单平台 / 人工。 */
|
/** 顶部来源切换:全部 / 自动发货 / 接单平台 / 第三方卡券 / 人工。 */
|
||||||
export type CostScope = 'all' | 'affiliate_dash' | 'work_order' | 'manual'
|
export type CostScope = 'all' | 'affiliate_dash' | 'work_order' | 'third_party_voucher' | 'manual'
|
||||||
|
|
||||||
export type CostItemPreset = {
|
export type CostItemPreset = {
|
||||||
key: string
|
key: string
|
||||||
@@ -89,12 +89,14 @@ export const COST_MODEL_OPTIONS = [
|
|||||||
export const PRODUCT_SOURCE_OPTIONS = [
|
export const PRODUCT_SOURCE_OPTIONS = [
|
||||||
{ value: 'affiliate_dash', label: '自动发货' },
|
{ value: 'affiliate_dash', label: '自动发货' },
|
||||||
{ value: 'work_order', label: '接单平台' },
|
{ value: 'work_order', label: '接单平台' },
|
||||||
|
{ value: 'third_party_voucher', label: '第三方卡券' },
|
||||||
{ value: 'manual', label: '人工客服' },
|
{ value: 'manual', label: '人工客服' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export const PRODUCT_SOURCE_TAG_COLORS: Record<ProductSource, string> = {
|
export const PRODUCT_SOURCE_TAG_COLORS: Record<ProductSource, string> = {
|
||||||
affiliate_dash: 'green',
|
affiliate_dash: 'green',
|
||||||
work_order: 'blue',
|
work_order: 'blue',
|
||||||
|
third_party_voucher: 'cyan',
|
||||||
manual: 'orange',
|
manual: 'orange',
|
||||||
'': 'default',
|
'': 'default',
|
||||||
}
|
}
|
||||||
@@ -125,15 +127,21 @@ export type MappingPrefill = {
|
|||||||
export const FULFILLMENT_MODE_LABELS: Record<string, string> = {
|
export const FULFILLMENT_MODE_LABELS: Record<string, string> = {
|
||||||
work_order: '接单平台',
|
work_order: '接单平台',
|
||||||
affiliate_dash: '自动发货',
|
affiliate_dash: '自动发货',
|
||||||
legacy_platform: '历史平台',
|
third_party_voucher: '第三方卡券',
|
||||||
|
legacy_platform: '历史归档',
|
||||||
manual: '人工客服',
|
manual: '人工客服',
|
||||||
|
archived: '历史归档',
|
||||||
|
unclassified: '未分类',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FULFILLMENT_MODE_COLORS: Record<string, string> = {
|
export const FULFILLMENT_MODE_COLORS: Record<string, string> = {
|
||||||
work_order: 'blue',
|
work_order: 'blue',
|
||||||
affiliate_dash: 'green',
|
affiliate_dash: 'green',
|
||||||
|
third_party_voucher: 'cyan',
|
||||||
legacy_platform: 'default',
|
legacy_platform: 'default',
|
||||||
manual: 'orange',
|
manual: 'orange',
|
||||||
|
archived: 'default',
|
||||||
|
unclassified: 'gold',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PROVIDER_LABELS: Record<string, string> = {
|
export const PROVIDER_LABELS: Record<string, string> = {
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* 履约方式:唯一真源是 order_items.fulfillment_mode。
|
* 履约渠道:v2 唯一真源是 order_items.fulfillment_channel;
|
||||||
* 核销码不参与履约判定。
|
* legacy_platform 仅历史数据兜底显示。
|
||||||
*/
|
*/
|
||||||
export type FulfillmentMode = 'work_order' | 'affiliate_dash' | 'legacy_platform' | 'manual'
|
export type FulfillmentMode =
|
||||||
|
| 'work_order'
|
||||||
|
| 'affiliate_dash'
|
||||||
|
| 'third_party_voucher'
|
||||||
|
| 'legacy_platform'
|
||||||
|
| 'manual'
|
||||||
|
|
||||||
/** 商品来源:由 canonical_sku 前缀推导,空串表示未分类。 */
|
/** 商品来源:由 canonical_sku 前缀推导,空串表示未分类。 */
|
||||||
export type ProductSource = 'affiliate_dash' | 'work_order' | 'manual' | ''
|
export type ProductSource = 'affiliate_dash' | 'work_order' | 'third_party_voucher' | 'manual' | ''
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 成本模型:只表达「这笔成本怎么算」,与履约方式正交。
|
* 成本模型:只表达「这笔成本怎么算」,与履约方式正交。
|
||||||
|
|||||||
Reference in New Issue
Block a user