feat(cost-center): 商品与成本按履约载体分区,补充分页与删除能力

- 新增迁移 107_product_catalog_carrier:product_catalog 增加 carrier_type,
  自动发货只认 fulfillment_tasks.executor_key='affiliate_dash' 的履约证据
  (28 个上游 SKU),接单模板商品统一归为 work_order(662 个)
- 页面按「全部 / 自动发货 / 接单平台 / 人工客服 / 共用成本」分区;
  自动发货只展示自动发货 SKU 列表与采购成本,不展示商品目录/渠道映射
- 新增「同步自动发货商品」接口与按钮,按 aff:<skuCode> 幂等补建
- 列表统一接入公共分页 buildAdminTablePagination,支持 10/20/50/100 每页条数
- 标准商品、自动发货 SKU、渠道映射、成本项目、成本版本支持删除,
  带引用保护(映射/版本/快照/订单/接单规则)与生效版本先停用约束
This commit is contained in:
yml2213
2026-10-22 14:05:34 +08:00
parent 8f3babdabe
commit b520a078b0
7 changed files with 1150 additions and 99 deletions
@@ -0,0 +1,95 @@
-- 107_product_catalog_carrier.sql —— 标准商品增加「默认履约载体」。
--
-- 背景:商品主数据此前只有游戏/品类分类,成本项目与成本版本却按
-- 自动发货 / 接单平台 / 人工 三类执行载体配置,后台配置时找不到对应商品。
--
-- 口径(2026-10-22 修正):
-- 1. 只有真正走 affiliate-dash(自动发货执行器)的商品才算 affiliate_dash;
-- 以 fulfillment_tasks.executor_key = 'affiliate_dash' 的履约证据为准,
-- 按 context_json.skuCode / skuName 建标准商品,SKU 固定为 `aff:<skuCode>`。
-- 2. 其余标准商品(接单模板初始化出来的 tpl:*)全部是 work_order;
-- 渠道映射只代表「平台 SKU 能归一到标准商品」,不代表自动发货。
-- 3. manual / shared 不回填,由后台按需手工调整。
--
-- 2026-10-22 生产快照口径:自动发货任务 41,676 条,涉及 28 个 skuCode。
ALTER TABLE product_catalog
ADD COLUMN IF NOT EXISTS carrier_type TEXT NOT NULL DEFAULT 'work_order';
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_product_catalog_carrier_type'
) THEN
ALTER TABLE product_catalog
ADD CONSTRAINT chk_product_catalog_carrier_type
CHECK (carrier_type IN ('affiliate_dash', 'work_order', 'manual', 'shared'));
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_product_catalog_status_carrier
ON product_catalog (status, carrier_type, canonical_name);
-- 1) 先把非自动发货商品统一归到接单平台(模板商品、历史误标都回到 work_order)。
UPDATE product_catalog
SET carrier_type = 'work_order',
updated_at = NOW()
WHERE carrier_type IS DISTINCT FROM 'work_order'
AND canonical_sku NOT LIKE 'aff:%';
-- 2) 按自动发货履约证据建商品:每个 skuCode 取一个最像人名的名称。
WITH auto_task AS (
SELECT
btrim(context_json->>'skuCode') AS sku_code,
btrim(context_json->>'skuName') AS sku_name,
created_at
FROM fulfillment_tasks
WHERE executor_key = 'affiliate_dash'
AND btrim(COALESCE(context_json->>'skuCode', '')) <> ''
AND btrim(COALESCE(context_json->>'skuName', '')) <> ''
),
agg AS (
SELECT sku_code, count(*)::int AS task_count, max(created_at) AS last_fulfilled_at
FROM auto_task
GROUP BY sku_code
),
ranked AS (
SELECT
sku_code,
sku_name,
row_number() OVER (
PARTITION BY sku_code
ORDER BY (sku_name <> sku_code) DESC, count(*) DESC, sku_name
) AS rn
FROM auto_task
GROUP BY sku_code, sku_name
)
INSERT INTO product_catalog (
canonical_sku, canonical_name, category, unit_name, carrier_type, status,
attributes_json, created_at, updated_at
)
SELECT
'aff:' || ranked.sku_code,
ranked.sku_name,
'',
CASE WHEN ranked.sku_name LIKE '%个' THEN '个' ELSE '件' END,
'affiliate_dash',
'active',
jsonb_build_object(
'source', 'affiliate_dash_task',
'skuCode', ranked.sku_code,
'taskCount', agg.task_count,
'lastFulfilledAt', agg.last_fulfilled_at
),
NOW(),
NOW()
FROM ranked
JOIN agg ON agg.sku_code = ranked.sku_code
WHERE ranked.rn = 1
ON CONFLICT (canonical_sku) DO UPDATE
SET carrier_type = 'affiliate_dash',
attributes_json = EXCLUDED.attributes_json,
updated_at = NOW();
COMMENT ON COLUMN product_catalog.carrier_type IS
'默认履约载体:affiliate_dash=自动发货,work_order=接单平台,manual=人工,shared=多载体共用';
@@ -10,6 +10,10 @@ import {
createAdminCostSnapshot, createAdminCostSnapshot,
createAdminCostVersion, createAdminCostVersion,
createAdminProduct, createAdminProduct,
deleteAdminChannelMapping,
deleteAdminCostItem,
deleteAdminCostVersion,
deleteAdminProduct,
getAdminCostCenterOptions, getAdminCostCenterOptions,
getAdminCostCenterOverview, getAdminCostCenterOverview,
listAdminChannelMappings, listAdminChannelMappings,
@@ -21,6 +25,7 @@ import {
previewAdminTemplateProductBootstrap, previewAdminTemplateProductBootstrap,
resolveAdminCostException, resolveAdminCostException,
retireAdminCostVersion, retireAdminCostVersion,
syncAdminAffiliateDashProducts,
updateAdminChannelMapping, updateAdminChannelMapping,
updateAdminCostItem, updateAdminCostItem,
updateAdminProduct, updateAdminProduct,
@@ -91,6 +96,22 @@ router.post(
}), }),
) )
router.post(
'/cost-center/affiliate-products/sync',
requireAdminPermission('finance.config'),
createJsonHandler(() => syncAdminAffiliateDashProducts(), {
successMessage: '自动发货商品已同步',
errorMessage: '同步自动发货商品失败',
scope: '[admin/cost-center/affiliate-products/sync]',
audit: (_req, data) => ({
action: 'cost_affiliate_products_synced',
targetType: 'product_catalog',
targetId: '',
data: data as Record<string, unknown>,
}),
}),
)
router.get( router.get(
'/cost-center/products', '/cost-center/products',
requireAdminPermission('finance.view'), requireAdminPermission('finance.view'),
@@ -139,6 +160,25 @@ router.patch(
}), }),
) )
router.delete(
'/cost-center/products/:productId',
requireAdminPermission('finance.config'),
createJsonHandler((req) => deleteAdminProduct(idOf(req, 'productId')), {
successMessage: '标准商品已删除',
errorMessage: '删除标准商品失败',
scope: '[admin/cost-center/products/:productId]',
audit: (req, data) => {
const product = (data as { product?: { id?: number; canonicalSku?: string } })?.product
return {
action: 'cost_product_deleted',
targetType: 'product_catalog',
targetId: String(product?.id || req.params.productId || ''),
data: { canonicalSku: product?.canonicalSku || '' },
}
},
}),
)
router.get( router.get(
'/cost-center/mappings', '/cost-center/mappings',
requireAdminPermission('finance.view'), requireAdminPermission('finance.view'),
@@ -197,6 +237,25 @@ router.patch(
}), }),
) )
router.delete(
'/cost-center/mappings/:mappingId',
requireAdminPermission('finance.config'),
createJsonHandler((req) => deleteAdminChannelMapping(idOf(req, 'mappingId')), {
successMessage: '渠道映射已删除',
errorMessage: '删除渠道映射失败',
scope: '[admin/cost-center/mappings/:mappingId]',
audit: (req, data) => {
const mappingId = (data as { mappingId?: number })?.mappingId
return {
action: 'cost_mapping_deleted',
targetType: 'channel_product_mapping',
targetId: String(mappingId || req.params.mappingId || ''),
data: {},
}
},
}),
)
router.post( router.post(
'/cost-center/mappings/backfill', '/cost-center/mappings/backfill',
requireAdminPermission('finance.config'), requireAdminPermission('finance.config'),
@@ -277,6 +336,25 @@ router.patch(
}), }),
) )
router.delete(
'/cost-center/cost-items/:costItemId',
requireAdminPermission('finance.config'),
createJsonHandler((req) => deleteAdminCostItem(idOf(req, 'costItemId')), {
successMessage: '成本项目已删除',
errorMessage: '删除成本项目失败',
scope: '[admin/cost-center/cost-items/:costItemId]',
audit: (req, data) => {
const item = (data as { costItem?: { id?: number; code?: string } })?.costItem
return {
action: 'cost_item_deleted',
targetType: 'cost_item',
targetId: String(item?.id || req.params.costItemId || ''),
data: { code: item?.code || '' },
}
},
}),
)
router.get( router.get(
'/cost-center/cost-versions', '/cost-center/cost-versions',
requireAdminPermission('finance.view'), requireAdminPermission('finance.view'),
@@ -325,6 +403,25 @@ router.patch(
}), }),
) )
router.delete(
'/cost-center/cost-versions/:versionId',
requireAdminPermission('finance.config'),
createJsonHandler((req) => deleteAdminCostVersion(idOf(req, 'versionId')), {
successMessage: '成本版本已删除',
errorMessage: '删除成本版本失败',
scope: '[admin/cost-center/cost-versions/:versionId]',
audit: (req, data) => {
const version = (data as { version?: { id?: number } })?.version
return {
action: 'cost_version_deleted',
targetType: 'cost_version',
targetId: String(version?.id || req.params.versionId || ''),
data: {},
}
},
}),
)
router.get( router.get(
'/cost-center/exceptions', '/cost-center/exceptions',
requireAdminPermission('finance.view'), requireAdminPermission('finance.view'),
@@ -56,6 +56,7 @@ type ProductRow = {
canonical_name: string canonical_name: string
category: string category: string
unit_name: string unit_name: string
carrier_type: string
status: string status: string
root_category_id: number | null root_category_id: number | null
root_category_name: string root_category_name: string
@@ -137,6 +138,7 @@ type CostVersionRow = {
cost_item_id: number cost_item_id: number
cost_item_code: string cost_item_code: string
cost_item_name: string cost_item_name: string
cost_item_carrier_type: string
pricing_mode: string pricing_mode: string
scope_type: string scope_type: string
provider: string provider: string
@@ -270,6 +272,14 @@ export async function getAdminCostCenterOverview() {
`, `,
) )
const row = result.rows[0] const row = result.rows[0]
const [productCarrierResult, costItemCarrierResult] = await Promise.all([
query<{ carrier_type: string; total: number }>(
`SELECT carrier_type, COUNT(*)::int AS total FROM product_catalog WHERE status = 'active' GROUP BY carrier_type`,
),
query<{ carrier_type: string; total: number }>(
`SELECT carrier_type, COUNT(*)::int AS total FROM cost_items WHERE status = 'active' GROUP BY carrier_type`,
),
])
return { return {
productCount: toNumber(row?.product_count), productCount: toNumber(row?.product_count),
mappingCount: toNumber(row?.mapping_count), mappingCount: toNumber(row?.mapping_count),
@@ -277,14 +287,32 @@ export async function getAdminCostCenterOverview() {
activeVersionCount: toNumber(row?.active_version_count), activeVersionCount: toNumber(row?.active_version_count),
openExceptionCount: toNumber(row?.open_exception_count), openExceptionCount: toNumber(row?.open_exception_count),
unmappedOrderItemCount: toNumber(row?.unmapped_order_item_count), unmappedOrderItemCount: toNumber(row?.unmapped_order_item_count),
productCarrierCounts: carrierCountsOf(productCarrierResult.rows),
costItemCarrierCounts: carrierCountsOf(costItemCarrierResult.rows),
} }
} }
function carrierCountsOf(rows: Array<{ carrier_type: string; total: number }>) {
const counts: Record<string, number> = {}
for (const carrierType of CARRIER_TYPES) counts[carrierType] = 0
for (const row of rows) {
if (!row.carrier_type) continue
counts[row.carrier_type] = toNumber(row.total)
}
return counts
}
export async function getAdminCostCenterOptions() { export async function getAdminCostCenterOptions() {
const [productsResult, costItemsResult, rulesResult] = await Promise.all([ const [productsResult, costItemsResult, rulesResult] = await Promise.all([
query<{ id: number; canonical_sku: string; canonical_name: string; unit_name: string }>( query<{
id: number
canonical_sku: string
canonical_name: string
unit_name: string
carrier_type: string
}>(
` `
SELECT id, canonical_sku, canonical_name, unit_name SELECT id, canonical_sku, canonical_name, unit_name, carrier_type
FROM product_catalog FROM product_catalog
WHERE status = 'active' WHERE status = 'active'
ORDER BY canonical_name, id ORDER BY canonical_name, id
@@ -324,6 +352,7 @@ export async function getAdminCostCenterOptions() {
canonicalSku: row.canonical_sku, canonicalSku: row.canonical_sku,
canonicalName: row.canonical_name, canonicalName: row.canonical_name,
unitName: row.unit_name, unitName: row.unit_name,
carrierType: row.carrier_type,
})), })),
costItems: costItemsResult.rows.map((row) => ({ costItems: costItemsResult.rows.map((row) => ({
id: Number(row.id), id: Number(row.id),
@@ -362,6 +391,12 @@ export async function listAdminProducts(payload: JsonObject = {}) {
baseParams.push(status) baseParams.push(status)
baseClauses.push(`p.status = $${baseParams.length}`) baseClauses.push(`p.status = $${baseParams.length}`)
} }
const carrierType = text(payload.carrierType ?? payload.carrier_type)
if (carrierType) {
assertOneOf(carrierType, CARRIER_TYPES, '商品载体无效')
baseParams.push(carrierType)
baseClauses.push(`p.carrier_type = $${baseParams.length}`)
}
const categoryId = normalizeCategoryFilter(payload.categoryId ?? payload.category_id) const categoryId = normalizeCategoryFilter(payload.categoryId ?? payload.category_id)
const rootCategoryId = normalizeCategoryFilter(payload.rootCategoryId ?? payload.root_category_id) const rootCategoryId = normalizeCategoryFilter(payload.rootCategoryId ?? payload.root_category_id)
@@ -401,7 +436,16 @@ export async function listAdminProducts(payload: JsonObject = {}) {
FROM product_catalog p FROM product_catalog p
${PRODUCT_CATEGORY_JOINS} ${PRODUCT_CATEGORY_JOINS}
${where} ${where}
ORDER BY p.status ASC, p.canonical_name ASC, p.id ASC ORDER BY
p.status ASC,
CASE p.carrier_type
WHEN 'affiliate_dash' THEN 1
WHEN 'work_order' THEN 2
WHEN 'manual' THEN 3
ELSE 4
END,
p.canonical_name ASC,
p.id ASC
LIMIT $${pageParams.length - 1} OFFSET $${pageParams.length} LIMIT $${pageParams.length - 1} OFFSET $${pageParams.length}
`, `,
pageParams, pageParams,
@@ -489,6 +533,12 @@ export async function createAdminProduct(payload: JsonObject, session: AdminSess
const category = normalizeOptionalText(payload.category, 64) const category = normalizeOptionalText(payload.category, 64)
const unitName = normalizeOptionalText(payload.unitName ?? payload.unit_name, 16) || '件' const unitName = normalizeOptionalText(payload.unitName ?? payload.unit_name, 16) || '件'
const status = normalizeStatus(payload.status, PRODUCT_STATUSES, 'active', '商品状态无效') const status = normalizeStatus(payload.status, PRODUCT_STATUSES, 'active', '商品状态无效')
const carrierType = normalizeStatus(
payload.carrierType ?? payload.carrier_type,
CARRIER_TYPES,
'work_order',
'商品载体无效',
)
const attributes = normalizeJsonObject(payload.attributes ?? payload.attributesJson) const attributes = normalizeJsonObject(payload.attributes ?? payload.attributesJson)
const now = nowIso() const now = nowIso()
@@ -496,12 +546,21 @@ export async function createAdminProduct(payload: JsonObject, session: AdminSess
` `
INSERT INTO product_catalog ( INSERT INTO product_catalog (
canonical_sku, canonical_name, category, unit_name, status, canonical_sku, canonical_name, category, unit_name, status,
attributes_json, created_at, updated_at carrier_type, attributes_json, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $7) ) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $8)
ON CONFLICT (canonical_sku) DO NOTHING ON CONFLICT (canonical_sku) DO NOTHING
RETURNING * RETURNING *
`, `,
[canonicalSku, canonicalName, category, unitName, status, JSON.stringify(attributes), now], [
canonicalSku,
canonicalName,
category,
unitName,
status,
carrierType,
JSON.stringify(attributes),
now,
],
) )
const inserted = result.rows[0] const inserted = result.rows[0]
if (!inserted) { if (!inserted) {
@@ -531,6 +590,15 @@ export async function updateAdminProduct(productId: number | string, payload: Js
payload.status === undefined payload.status === undefined
? current.status ? current.status
: normalizeStatus(payload.status, PRODUCT_STATUSES, current.status, '商品状态无效') : normalizeStatus(payload.status, PRODUCT_STATUSES, current.status, '商品状态无效')
const carrierType =
payload.carrierType === undefined && payload.carrier_type === undefined
? current.carrier_type
: normalizeStatus(
payload.carrierType ?? payload.carrier_type,
CARRIER_TYPES,
current.carrier_type,
'商品载体无效',
)
const attributes = const attributes =
payload.attributes === undefined && payload.attributesJson === undefined payload.attributes === undefined && payload.attributesJson === undefined
? parseJsonObject(current.attributes_json) ? parseJsonObject(current.attributes_json)
@@ -540,21 +608,73 @@ export async function updateAdminProduct(productId: number | string, payload: Js
` `
UPDATE product_catalog UPDATE product_catalog
SET canonical_name = $1, category = $2, unit_name = $3, status = $4, SET canonical_name = $1, category = $2, unit_name = $3, status = $4,
attributes_json = $5::jsonb, updated_at = $6 carrier_type = $5, attributes_json = $6::jsonb, updated_at = $7
WHERE id = $7 WHERE id = $8
RETURNING * RETURNING *
`, `,
[canonicalName, category, unitName, status, JSON.stringify(attributes), nowIso(), id], [
canonicalName,
category,
unitName,
status,
carrierType,
JSON.stringify(attributes),
nowIso(),
id,
],
) )
return { product: mapProduct(requiredRow(result.rows[0], '商品不存在')) } return { product: mapProduct(requiredRow(result.rows[0], '商品不存在')) }
} }
/**
* 删除标准商品:只允许删除没有任何引用的商品。
* 有渠道映射/成本版本/成本快照/订单项时返回 409,提示改为归档。
*/
export async function deleteAdminProduct(productId: number | string) {
const id = normalizeId(productId, '商品 ID')
const current = await getProductRow(id)
const usageResult = await query<{
mapping_count: number
version_count: number
snapshot_count: number
order_item_count: number
}>(
`
SELECT
(SELECT COUNT(*)::int FROM channel_product_mappings WHERE canonical_product_id = $1) AS mapping_count,
(SELECT COUNT(*)::int FROM cost_versions WHERE canonical_product_id = $1) AS version_count,
(SELECT COUNT(*)::int FROM cost_snapshots WHERE canonical_product_id = $1) AS snapshot_count,
(SELECT COUNT(*)::int FROM order_items WHERE canonical_product_id = $1) AS order_item_count
`,
[id],
)
const usage = usageResult.rows[0]
const blockers: string[] = []
if (toNumber(usage?.mapping_count) > 0)
blockers.push(`${toNumber(usage?.mapping_count)} 条渠道映射`)
if (toNumber(usage?.version_count) > 0)
blockers.push(`${toNumber(usage?.version_count)} 个成本版本`)
if (toNumber(usage?.snapshot_count) > 0)
blockers.push(`${toNumber(usage?.snapshot_count)} 条成本快照`)
if (toNumber(usage?.order_item_count) > 0)
blockers.push(`${toNumber(usage?.order_item_count)} 个订单项`)
if (blockers.length > 0) {
throw createHttpError(`商品仍被引用(${blockers.join('、')}),不能删除;请改为归档`, {
statusCode: 409,
errorCode: 'cost_product_in_use',
})
}
await query('DELETE FROM product_catalog WHERE id = $1', [id])
return { deleted: true, product: mapProduct(current) }
}
type MappingProductGroupRow = { type MappingProductGroupRow = {
canonical_product_id: number canonical_product_id: number
canonical_sku: string canonical_sku: string
canonical_name: string canonical_name: string
category: string category: string
unit_name: string unit_name: string
carrier_type: string
status: string status: string
} }
@@ -583,6 +703,12 @@ export async function listAdminChannelMappings(payload: JsonObject = {}) {
params.push(canonicalProductId) params.push(canonicalProductId)
clauses.push(`m.canonical_product_id = $${params.length}`) clauses.push(`m.canonical_product_id = $${params.length}`)
} }
const carrierType = text(payload.carrierType ?? payload.carrier_type)
if (carrierType) {
assertOneOf(carrierType, CARRIER_TYPES, '商品载体无效')
params.push(carrierType)
clauses.push(`p.carrier_type = $${params.length}`)
}
if (payload.enabled !== undefined && payload.enabled !== '') { if (payload.enabled !== undefined && payload.enabled !== '') {
params.push(normalizeBoolean(payload.enabled)) params.push(normalizeBoolean(payload.enabled))
clauses.push(`m.enabled = $${params.length}`) clauses.push(`m.enabled = $${params.length}`)
@@ -599,6 +725,7 @@ export async function listAdminChannelMappings(payload: JsonObject = {}) {
p.canonical_name, p.canonical_name,
p.category, p.category,
p.unit_name, p.unit_name,
p.carrier_type,
p.status p.status
FROM channel_product_mappings m FROM channel_product_mappings m
JOIN product_catalog p ON p.id = m.canonical_product_id JOIN product_catalog p ON p.id = m.canonical_product_id
@@ -609,6 +736,7 @@ export async function listAdminChannelMappings(payload: JsonObject = {}) {
p.canonical_name, p.canonical_name,
p.category, p.category,
p.unit_name, p.unit_name,
p.carrier_type,
p.status p.status
ORDER BY p.canonical_name ASC, p.id ASC ORDER BY p.canonical_name ASC, p.id ASC
LIMIT $${groupParams.length - 1} OFFSET $${groupParams.length} LIMIT $${groupParams.length - 1} OFFSET $${groupParams.length}
@@ -677,6 +805,7 @@ export async function listAdminChannelMappings(payload: JsonObject = {}) {
canonicalName: row.canonical_name, canonicalName: row.canonical_name,
category: row.category || '', category: row.category || '',
unitName: row.unit_name || '件', unitName: row.unit_name || '件',
carrierType: row.carrier_type,
status: row.status, status: row.status,
mappings: mappingsByProduct.get(Number(row.canonical_product_id)) || [], mappings: mappingsByProduct.get(Number(row.canonical_product_id)) || [],
})), })),
@@ -873,6 +1002,18 @@ export async function updateAdminChannelMapping(mappingId: number | string, payl
return { mapping: mapMapping(mapping) } return { mapping: mapMapping(mapping) }
} }
export async function deleteAdminChannelMapping(mappingId: number | string) {
const id = normalizeId(mappingId, '映射 ID')
const result = await query<{ id: number }>(
'DELETE FROM channel_product_mappings WHERE id = $1 RETURNING id',
[id],
)
if (!result.rows[0]) {
throw createHttpError('映射不存在', { statusCode: 404, errorCode: 'cost_mapping_not_found' })
}
return { deleted: true, mappingId: Number(result.rows[0].id) }
}
export async function backfillAdminProductMappings(payload: JsonObject = {}) { export async function backfillAdminProductMappings(payload: JsonObject = {}) {
const provider = normalizeProviderKey(text(payload.provider)) const provider = normalizeProviderKey(text(payload.provider))
const limit = Math.min(20000, Math.max(1, Math.trunc(Number(payload.limit) || 5000))) const limit = Math.min(20000, Math.max(1, Math.trunc(Number(payload.limit) || 5000)))
@@ -1294,6 +1435,83 @@ export async function applyAdminTemplateProductBootstrap(
} }
} }
/**
* 按自动发货履约证据同步标准商品。
*
* 只有真正走 affiliate-dash 执行器的订单项才算自动发货;渠道映射只是
* 平台 SKU 归一,不能作为自动发货依据。SKU 固定为 `aff:<skuCode>`,
* 重复同步只更新名称/证据,不会重复建商品。
*/
export async function syncAdminAffiliateDashProducts() {
const now = nowIso()
const result = await query<{ inserted: boolean }>(
`
WITH auto_task AS (
SELECT
btrim(context_json->>'skuCode') AS sku_code,
btrim(context_json->>'skuName') AS sku_name,
created_at
FROM fulfillment_tasks
WHERE executor_key = 'affiliate_dash'
AND btrim(COALESCE(context_json->>'skuCode', '')) <> ''
AND btrim(COALESCE(context_json->>'skuName', '')) <> ''
),
agg AS (
SELECT sku_code, count(*)::int AS task_count, max(created_at) AS last_fulfilled_at
FROM auto_task
GROUP BY sku_code
),
ranked AS (
SELECT
sku_code,
sku_name,
row_number() OVER (
PARTITION BY sku_code
ORDER BY (sku_name <> sku_code) DESC, count(*) DESC, sku_name
) AS rn
FROM auto_task
GROUP BY sku_code, sku_name
)
INSERT INTO product_catalog (
canonical_sku, canonical_name, category, unit_name, carrier_type, status,
attributes_json, created_at, updated_at
)
SELECT
'aff:' || ranked.sku_code,
ranked.sku_name,
'',
CASE WHEN ranked.sku_name LIKE '%个' THEN '个' ELSE '件' END,
'affiliate_dash',
'active',
jsonb_build_object(
'source', 'affiliate_dash_task',
'skuCode', ranked.sku_code,
'taskCount', agg.task_count,
'lastFulfilledAt', agg.last_fulfilled_at
),
$1,
$1
FROM ranked
JOIN agg ON agg.sku_code = ranked.sku_code
WHERE ranked.rn = 1
ON CONFLICT (canonical_sku) DO UPDATE
SET carrier_type = 'affiliate_dash',
canonical_name = EXCLUDED.canonical_name,
unit_name = EXCLUDED.unit_name,
attributes_json = EXCLUDED.attributes_json,
updated_at = $1
RETURNING (xmax = 0) AS inserted
`,
[now],
)
const createdCount = result.rows.filter((row) => row.inserted).length
return {
createdCount,
updatedCount: result.rows.length - createdCount,
productCount: result.rows.length,
}
}
type TemplateBootstrapTemplateRow = { type TemplateBootstrapTemplateRow = {
id: number id: number
rule_key: string rule_key: string
@@ -2016,6 +2234,42 @@ export async function updateAdminCostItem(costItemId: number | string, payload:
return { costItem: mapCostItem(requiredRow(result.rows[0], '成本项不存在')) } return { costItem: mapCostItem(requiredRow(result.rows[0], '成本项不存在')) }
} }
/**
* 删除成本项:存在成本版本、成本快照或接单规则引用时拒绝删除。
*/
export async function deleteAdminCostItem(costItemId: number | string) {
const id = normalizeId(costItemId, '成本项 ID')
const current = await getCostItemRow(id)
const usageResult = await query<{
version_count: number
snapshot_count: number
rule_count: number
}>(
`
SELECT
(SELECT COUNT(*)::int FROM cost_versions WHERE cost_item_id = $1) AS version_count,
(SELECT COUNT(*)::int FROM cost_snapshots WHERE cost_item_id = $1) AS snapshot_count,
(SELECT COUNT(*)::int FROM work_product_rules WHERE cost_item_id = $1) AS rule_count
`,
[id],
)
const usage = usageResult.rows[0]
const blockers: string[] = []
if (toNumber(usage?.version_count) > 0)
blockers.push(`${toNumber(usage?.version_count)} 个成本版本`)
if (toNumber(usage?.snapshot_count) > 0)
blockers.push(`${toNumber(usage?.snapshot_count)} 条成本快照`)
if (toNumber(usage?.rule_count) > 0) blockers.push(`${toNumber(usage?.rule_count)} 个接单规则`)
if (blockers.length > 0) {
throw createHttpError(`成本项仍被引用(${blockers.join('、')}),不能删除;请改为归档`, {
statusCode: 409,
errorCode: 'cost_item_in_use',
})
}
await query('DELETE FROM cost_items WHERE id = $1', [id])
return { deleted: true, costItem: mapCostItem(current) }
}
export async function listAdminCostVersions(payload: JsonObject = {}) { export async function listAdminCostVersions(payload: JsonObject = {}) {
const page = normalizePage(payload.page) const page = normalizePage(payload.page)
const pageSize = normalizePageSize(payload.pageSize) const pageSize = normalizePageSize(payload.pageSize)
@@ -2044,6 +2298,12 @@ export async function listAdminCostVersions(payload: JsonObject = {}) {
params.push(status) params.push(status)
clauses.push(`v.status = $${params.length}`) clauses.push(`v.status = $${params.length}`)
} }
const carrierType = text(payload.carrierType ?? payload.carrier_type)
if (carrierType) {
assertOneOf(carrierType, CARRIER_TYPES, '成本载体无效')
params.push(carrierType)
clauses.push(`ci.carrier_type = $${params.length}`)
}
const keyword = text(payload.keyword) const keyword = text(payload.keyword)
if (keyword) { if (keyword) {
params.push(`%${keyword}%`) params.push(`%${keyword}%`)
@@ -2071,6 +2331,7 @@ export async function listAdminCostVersions(payload: JsonObject = {}) {
v.*, v.*,
ci.code AS cost_item_code, ci.code AS cost_item_code,
ci.name AS cost_item_name, ci.name AS cost_item_name,
ci.carrier_type AS cost_item_carrier_type,
ci.pricing_mode, ci.pricing_mode,
p.canonical_sku, p.canonical_sku,
p.canonical_name p.canonical_name
@@ -2230,6 +2491,33 @@ export async function retireAdminCostVersion(versionId: number | string, payload
return { version: await getCostVersionById(id) } return { version: await getCostVersionById(id) }
} }
/**
* 删除成本版本:生效中的版本必须先停用;已被成本快照引用的版本不可删除。
*/
export async function deleteAdminCostVersion(versionId: number | string) {
const id = normalizeId(versionId, '成本版本 ID')
const current = await getCostVersionById(id)
if (current.status === 'active') {
throw createHttpError('生效中的成本版本不能删除,请先停用', {
statusCode: 409,
errorCode: 'cost_version_active',
})
}
const snapshotResult = await query<{ total: number }>(
'SELECT COUNT(*)::int AS total FROM cost_snapshots WHERE cost_version_id = $1',
[id],
)
const snapshotCount = toNumber(snapshotResult.rows[0]?.total)
if (snapshotCount > 0) {
throw createHttpError(`该成本版本已被 ${snapshotCount} 条成本快照引用,不能删除`, {
statusCode: 409,
errorCode: 'cost_version_in_use',
})
}
await query('DELETE FROM cost_versions WHERE id = $1', [id])
return { deleted: true, version: current }
}
export async function listAdminCostExceptions(payload: JsonObject = {}) { export async function listAdminCostExceptions(payload: JsonObject = {}) {
const page = normalizePage(payload.page) const page = normalizePage(payload.page)
const pageSize = normalizePageSize(payload.pageSize) const pageSize = normalizePageSize(payload.pageSize)
@@ -2249,6 +2537,12 @@ export async function listAdminCostExceptions(payload: JsonObject = {}) {
params.push(subjectType) params.push(subjectType)
clauses.push(`ce.subject_type = $${params.length}`) clauses.push(`ce.subject_type = $${params.length}`)
} }
const carrierType = text(payload.carrierType ?? payload.carrier_type)
if (carrierType) {
assertOneOf(carrierType, CARRIER_TYPES, '异常载体无效')
params.push(carrierType)
clauses.push(`ce.carrier_type = $${params.length}`)
}
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '' const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''
const countResult = await query<{ total: number }>( const countResult = await query<{ total: number }>(
@@ -2881,6 +3175,7 @@ async function getCostVersionById(id: number) {
v.*, v.*,
ci.code AS cost_item_code, ci.code AS cost_item_code,
ci.name AS cost_item_name, ci.name AS cost_item_name,
ci.carrier_type AS cost_item_carrier_type,
ci.pricing_mode, ci.pricing_mode,
p.canonical_sku, p.canonical_sku,
p.canonical_name p.canonical_name
@@ -2901,6 +3196,7 @@ function mapProduct(row: ProductRow) {
canonicalSku: row.canonical_sku, canonicalSku: row.canonical_sku,
canonicalName: row.canonical_name, canonicalName: row.canonical_name,
category: row.category || '', category: row.category || '',
carrierType: row.carrier_type || 'work_order',
rootCategoryId: row.root_category_id ? Number(row.root_category_id) : null, rootCategoryId: row.root_category_id ? Number(row.root_category_id) : null,
rootCategoryName: row.root_category_name || row.category || '', rootCategoryName: row.root_category_name || row.category || '',
subCategoryId: row.sub_category_id ? Number(row.sub_category_id) : null, subCategoryId: row.sub_category_id ? Number(row.sub_category_id) : null,
@@ -2961,6 +3257,7 @@ function mapCostVersion(row: CostVersionRow) {
costItemId: Number(row.cost_item_id), costItemId: Number(row.cost_item_id),
costItemCode: row.cost_item_code, costItemCode: row.cost_item_code,
costItemName: row.cost_item_name, costItemName: row.cost_item_name,
carrierType: row.cost_item_carrier_type || 'shared',
pricingMode: row.pricing_mode, pricingMode: row.pricing_mode,
scopeType: row.scope_type, scopeType: row.scope_type,
provider: row.provider || '', provider: row.provider || '',
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
import { apiGet, apiPatch, apiPost } from '@/lib/http' import { apiDelete, apiGet, apiPatch, apiPost } from '@/lib/http'
import type { import type {
ChannelProductMapping, ChannelProductMapping,
ChannelProductMappingListResult, ChannelProductMappingListResult,
@@ -46,6 +46,12 @@ export function createAdminProductCatalog(payload: Record<string, unknown>) {
return apiPost<{ product: ProductCatalogItem }>('/api/v1/admin/cost-center/products', payload) return apiPost<{ product: ProductCatalogItem }>('/api/v1/admin/cost-center/products', payload)
} }
export function syncAdminAffiliateDashProducts() {
return apiPost<{ createdCount: number; updatedCount: number; productCount: number }>(
'/api/v1/admin/cost-center/affiliate-products/sync',
)
}
export function updateAdminProductCatalog(productId: number, payload: Record<string, unknown>) { export function updateAdminProductCatalog(productId: number, payload: Record<string, unknown>) {
return apiPatch<{ product: ProductCatalogItem }>( return apiPatch<{ product: ProductCatalogItem }>(
`/api/v1/admin/cost-center/products/${productId}`, `/api/v1/admin/cost-center/products/${productId}`,
@@ -53,6 +59,12 @@ export function updateAdminProductCatalog(productId: number, payload: Record<str
) )
} }
export function deleteAdminProductCatalog(productId: number) {
return apiDelete<{ deleted: boolean; product: ProductCatalogItem }>(
`/api/v1/admin/cost-center/products/${productId}`,
)
}
export function fetchAdminChannelProductMappings(params?: Record<string, unknown>) { export function fetchAdminChannelProductMappings(params?: Record<string, unknown>) {
return apiGet<ChannelProductMappingListResult>('/api/v1/admin/cost-center/mappings', params) return apiGet<ChannelProductMappingListResult>('/api/v1/admin/cost-center/mappings', params)
} }
@@ -71,6 +83,12 @@ export function updateAdminChannelProductMapping(
) )
} }
export function deleteAdminChannelProductMapping(mappingId: number) {
return apiDelete<{ deleted: boolean; mappingId: number }>(
`/api/v1/admin/cost-center/mappings/${mappingId}`,
)
}
export function backfillAdminProductMappings(payload: Record<string, unknown>) { export function backfillAdminProductMappings(payload: Record<string, unknown>) {
return apiPost<{ return apiPost<{
updatedCount: number updatedCount: number
@@ -104,6 +122,12 @@ export function updateAdminCostItem(costItemId: number, payload: Record<string,
) )
} }
export function deleteAdminCostItem(costItemId: number) {
return apiDelete<{ deleted: boolean; costItem: CostItem }>(
`/api/v1/admin/cost-center/cost-items/${costItemId}`,
)
}
export function fetchAdminCostVersions(params?: Record<string, unknown>) { export function fetchAdminCostVersions(params?: Record<string, unknown>) {
return apiGet<CostVersionListResult>('/api/v1/admin/cost-center/cost-versions', params) return apiGet<CostVersionListResult>('/api/v1/admin/cost-center/cost-versions', params)
} }
@@ -119,6 +143,12 @@ export function retireAdminCostVersion(versionId: number, payload: Record<string
) )
} }
export function deleteAdminCostVersion(versionId: number) {
return apiDelete<{ deleted: boolean; version: CostVersion }>(
`/api/v1/admin/cost-center/cost-versions/${versionId}`,
)
}
export function fetchAdminCostExceptions(params?: Record<string, unknown>) { export function fetchAdminCostExceptions(params?: Record<string, unknown>) {
return apiGet<CostExceptionListResult>('/api/v1/admin/cost-center/exceptions', params) return apiGet<CostExceptionListResult>('/api/v1/admin/cost-center/exceptions', params)
} }
@@ -1,3 +1,6 @@
/** 履约载体:自动发货 / 接单平台 / 人工 / 多载体共用。 */
export type CostCarrierType = 'affiliate_dash' | 'work_order' | 'manual' | 'shared'
export interface CostCenterOverview { export interface CostCenterOverview {
productCount: number productCount: number
mappingCount: number mappingCount: number
@@ -5,6 +8,8 @@ export interface CostCenterOverview {
activeVersionCount: number activeVersionCount: number
openExceptionCount: number openExceptionCount: number
unmappedOrderItemCount: number unmappedOrderItemCount: number
productCarrierCounts?: Record<string, number>
costItemCarrierCounts?: Record<string, number>
} }
export interface ProductCatalogItem { export interface ProductCatalogItem {
@@ -12,6 +17,7 @@ export interface ProductCatalogItem {
canonicalSku: string canonicalSku: string
canonicalName: string canonicalName: string
category: string category: string
carrierType: CostCarrierType
rootCategoryId: number | null rootCategoryId: number | null
rootCategoryName: string rootCategoryName: string
subCategoryId: number | null subCategoryId: number | null
@@ -74,6 +80,7 @@ export interface CostVersion {
costItemId: number costItemId: number
costItemCode: string costItemCode: string
costItemName: string costItemName: string
carrierType: CostCarrierType
pricingMode: CostItem['pricingMode'] pricingMode: CostItem['pricingMode']
scopeType: 'global' | 'provider' | 'shop' | 'product' | 'rule' scopeType: 'global' | 'provider' | 'shop' | 'product' | 'rule'
provider: string provider: string
@@ -117,6 +124,7 @@ export interface CostCenterOptions {
canonicalSku: string canonicalSku: string
canonicalName: string canonicalName: string
unitName: string unitName: string
carrierType: CostCarrierType
}> }>
costItems: Array<{ costItems: Array<{
id: number id: number
@@ -255,6 +263,7 @@ export interface ChannelProductMappingGroup {
canonicalName: string canonicalName: string
category: string category: string
unitName: string unitName: string
carrierType: CostCarrierType
status: 'active' | 'archived' status: 'active' | 'archived'
mappings: ChannelProductMapping[] mappings: ChannelProductMapping[]
} }
@@ -132,10 +132,13 @@ product_catalog
product_name -- 展示名 product_name -- 展示名
category -- 皮肤/礼包/密钥/改名卡/其他 category -- 皮肤/礼包/密钥/改名卡/其他
game -- 和平精英等 game -- 和平精英等
carrier_type -- 默认履约载体:affiliate_dash / work_order / manual / shared
status status
attributes_json attributes_json
``` ```
`carrier_type`(迁移 `107_product_catalog_carrier.sql`)是后台「商品与成本中心」的分区维度:自动发货 / 接单平台 / 人工 / 共用。回填口径(2026-10-22 修正):只把**真正走 affiliate-dash 执行器**的商品算作 `affiliate_dash`——以 `fulfillment_tasks.executor_key='affiliate_dash'` 的履约证据为准,按 `context_json.skuCode` 建标准商品(`aff:<skuCode>`,生产快照 41,676 条任务 / 28 个 SKU);接单模板初始化出来的 `tpl:*` 商品全部是 `work_order`(662 个)。**渠道映射只代表平台 SKU 能归一到标准商品,不代表自动发货**。人工/共用由后台按需手工调整;新增自动发货 SKU 可在页面点「同步自动发货商品」补建。
### 3.2 渠道 SKU 映射 ### 3.2 渠道 SKU 映射
```text ```text