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:
@@ -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,
|
||||
createAdminCostVersion,
|
||||
createAdminProduct,
|
||||
deleteAdminChannelMapping,
|
||||
deleteAdminCostItem,
|
||||
deleteAdminCostVersion,
|
||||
deleteAdminProduct,
|
||||
getAdminCostCenterOptions,
|
||||
getAdminCostCenterOverview,
|
||||
listAdminChannelMappings,
|
||||
@@ -21,6 +25,7 @@ import {
|
||||
previewAdminTemplateProductBootstrap,
|
||||
resolveAdminCostException,
|
||||
retireAdminCostVersion,
|
||||
syncAdminAffiliateDashProducts,
|
||||
updateAdminChannelMapping,
|
||||
updateAdminCostItem,
|
||||
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(
|
||||
'/cost-center/products',
|
||||
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(
|
||||
'/cost-center/mappings',
|
||||
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(
|
||||
'/cost-center/mappings/backfill',
|
||||
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(
|
||||
'/cost-center/cost-versions',
|
||||
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(
|
||||
'/cost-center/exceptions',
|
||||
requireAdminPermission('finance.view'),
|
||||
|
||||
@@ -56,6 +56,7 @@ type ProductRow = {
|
||||
canonical_name: string
|
||||
category: string
|
||||
unit_name: string
|
||||
carrier_type: string
|
||||
status: string
|
||||
root_category_id: number | null
|
||||
root_category_name: string
|
||||
@@ -137,6 +138,7 @@ type CostVersionRow = {
|
||||
cost_item_id: number
|
||||
cost_item_code: string
|
||||
cost_item_name: string
|
||||
cost_item_carrier_type: string
|
||||
pricing_mode: string
|
||||
scope_type: string
|
||||
provider: string
|
||||
@@ -270,6 +272,14 @@ export async function getAdminCostCenterOverview() {
|
||||
`,
|
||||
)
|
||||
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 {
|
||||
productCount: toNumber(row?.product_count),
|
||||
mappingCount: toNumber(row?.mapping_count),
|
||||
@@ -277,14 +287,32 @@ export async function getAdminCostCenterOverview() {
|
||||
activeVersionCount: toNumber(row?.active_version_count),
|
||||
openExceptionCount: toNumber(row?.open_exception_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() {
|
||||
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
|
||||
WHERE status = 'active'
|
||||
ORDER BY canonical_name, id
|
||||
@@ -324,6 +352,7 @@ export async function getAdminCostCenterOptions() {
|
||||
canonicalSku: row.canonical_sku,
|
||||
canonicalName: row.canonical_name,
|
||||
unitName: row.unit_name,
|
||||
carrierType: row.carrier_type,
|
||||
})),
|
||||
costItems: costItemsResult.rows.map((row) => ({
|
||||
id: Number(row.id),
|
||||
@@ -362,6 +391,12 @@ export async function listAdminProducts(payload: JsonObject = {}) {
|
||||
baseParams.push(status)
|
||||
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 rootCategoryId = normalizeCategoryFilter(payload.rootCategoryId ?? payload.root_category_id)
|
||||
@@ -401,7 +436,16 @@ export async function listAdminProducts(payload: JsonObject = {}) {
|
||||
FROM product_catalog p
|
||||
${PRODUCT_CATEGORY_JOINS}
|
||||
${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}
|
||||
`,
|
||||
pageParams,
|
||||
@@ -489,6 +533,12 @@ export async function createAdminProduct(payload: JsonObject, session: AdminSess
|
||||
const category = normalizeOptionalText(payload.category, 64)
|
||||
const unitName = normalizeOptionalText(payload.unitName ?? payload.unit_name, 16) || '件'
|
||||
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 now = nowIso()
|
||||
|
||||
@@ -496,12 +546,21 @@ export async function createAdminProduct(payload: JsonObject, session: AdminSess
|
||||
`
|
||||
INSERT INTO product_catalog (
|
||||
canonical_sku, canonical_name, category, unit_name, status,
|
||||
attributes_json, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $7)
|
||||
carrier_type, attributes_json, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $8)
|
||||
ON CONFLICT (canonical_sku) DO NOTHING
|
||||
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]
|
||||
if (!inserted) {
|
||||
@@ -531,6 +590,15 @@ export async function updateAdminProduct(productId: number | string, payload: Js
|
||||
payload.status === undefined
|
||||
? 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 =
|
||||
payload.attributes === undefined && payload.attributesJson === undefined
|
||||
? parseJsonObject(current.attributes_json)
|
||||
@@ -540,21 +608,73 @@ export async function updateAdminProduct(productId: number | string, payload: Js
|
||||
`
|
||||
UPDATE product_catalog
|
||||
SET canonical_name = $1, category = $2, unit_name = $3, status = $4,
|
||||
attributes_json = $5::jsonb, updated_at = $6
|
||||
WHERE id = $7
|
||||
carrier_type = $5, attributes_json = $6::jsonb, updated_at = $7
|
||||
WHERE id = $8
|
||||
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], '商品不存在')) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除标准商品:只允许删除没有任何引用的商品。
|
||||
* 有渠道映射/成本版本/成本快照/订单项时返回 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 = {
|
||||
canonical_product_id: number
|
||||
canonical_sku: string
|
||||
canonical_name: string
|
||||
category: string
|
||||
unit_name: string
|
||||
carrier_type: string
|
||||
status: string
|
||||
}
|
||||
|
||||
@@ -583,6 +703,12 @@ export async function listAdminChannelMappings(payload: JsonObject = {}) {
|
||||
params.push(canonicalProductId)
|
||||
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 !== '') {
|
||||
params.push(normalizeBoolean(payload.enabled))
|
||||
clauses.push(`m.enabled = $${params.length}`)
|
||||
@@ -599,6 +725,7 @@ export async function listAdminChannelMappings(payload: JsonObject = {}) {
|
||||
p.canonical_name,
|
||||
p.category,
|
||||
p.unit_name,
|
||||
p.carrier_type,
|
||||
p.status
|
||||
FROM channel_product_mappings m
|
||||
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.category,
|
||||
p.unit_name,
|
||||
p.carrier_type,
|
||||
p.status
|
||||
ORDER BY p.canonical_name ASC, p.id ASC
|
||||
LIMIT $${groupParams.length - 1} OFFSET $${groupParams.length}
|
||||
@@ -677,6 +805,7 @@ export async function listAdminChannelMappings(payload: JsonObject = {}) {
|
||||
canonicalName: row.canonical_name,
|
||||
category: row.category || '',
|
||||
unitName: row.unit_name || '件',
|
||||
carrierType: row.carrier_type,
|
||||
status: row.status,
|
||||
mappings: mappingsByProduct.get(Number(row.canonical_product_id)) || [],
|
||||
})),
|
||||
@@ -873,6 +1002,18 @@ export async function updateAdminChannelMapping(mappingId: number | string, payl
|
||||
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 = {}) {
|
||||
const provider = normalizeProviderKey(text(payload.provider))
|
||||
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 = {
|
||||
id: number
|
||||
rule_key: string
|
||||
@@ -2016,6 +2234,42 @@ export async function updateAdminCostItem(costItemId: number | string, payload:
|
||||
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 = {}) {
|
||||
const page = normalizePage(payload.page)
|
||||
const pageSize = normalizePageSize(payload.pageSize)
|
||||
@@ -2044,6 +2298,12 @@ export async function listAdminCostVersions(payload: JsonObject = {}) {
|
||||
params.push(status)
|
||||
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)
|
||||
if (keyword) {
|
||||
params.push(`%${keyword}%`)
|
||||
@@ -2071,6 +2331,7 @@ export async function listAdminCostVersions(payload: JsonObject = {}) {
|
||||
v.*,
|
||||
ci.code AS cost_item_code,
|
||||
ci.name AS cost_item_name,
|
||||
ci.carrier_type AS cost_item_carrier_type,
|
||||
ci.pricing_mode,
|
||||
p.canonical_sku,
|
||||
p.canonical_name
|
||||
@@ -2230,6 +2491,33 @@ export async function retireAdminCostVersion(versionId: number | string, payload
|
||||
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 = {}) {
|
||||
const page = normalizePage(payload.page)
|
||||
const pageSize = normalizePageSize(payload.pageSize)
|
||||
@@ -2249,6 +2537,12 @@ export async function listAdminCostExceptions(payload: JsonObject = {}) {
|
||||
params.push(subjectType)
|
||||
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 countResult = await query<{ total: number }>(
|
||||
@@ -2881,6 +3175,7 @@ async function getCostVersionById(id: number) {
|
||||
v.*,
|
||||
ci.code AS cost_item_code,
|
||||
ci.name AS cost_item_name,
|
||||
ci.carrier_type AS cost_item_carrier_type,
|
||||
ci.pricing_mode,
|
||||
p.canonical_sku,
|
||||
p.canonical_name
|
||||
@@ -2901,6 +3196,7 @@ function mapProduct(row: ProductRow) {
|
||||
canonicalSku: row.canonical_sku,
|
||||
canonicalName: row.canonical_name,
|
||||
category: row.category || '',
|
||||
carrierType: row.carrier_type || 'work_order',
|
||||
rootCategoryId: row.root_category_id ? Number(row.root_category_id) : null,
|
||||
rootCategoryName: row.root_category_name || row.category || '',
|
||||
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),
|
||||
costItemCode: row.cost_item_code,
|
||||
costItemName: row.cost_item_name,
|
||||
carrierType: row.cost_item_carrier_type || 'shared',
|
||||
pricingMode: row.pricing_mode,
|
||||
scopeType: row.scope_type,
|
||||
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 {
|
||||
ChannelProductMapping,
|
||||
ChannelProductMappingListResult,
|
||||
@@ -46,6 +46,12 @@ export function createAdminProductCatalog(payload: Record<string, unknown>) {
|
||||
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>) {
|
||||
return apiPatch<{ product: ProductCatalogItem }>(
|
||||
`/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>) {
|
||||
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>) {
|
||||
return apiPost<{
|
||||
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>) {
|
||||
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>) {
|
||||
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 {
|
||||
productCount: number
|
||||
mappingCount: number
|
||||
@@ -5,6 +8,8 @@ export interface CostCenterOverview {
|
||||
activeVersionCount: number
|
||||
openExceptionCount: number
|
||||
unmappedOrderItemCount: number
|
||||
productCarrierCounts?: Record<string, number>
|
||||
costItemCarrierCounts?: Record<string, number>
|
||||
}
|
||||
|
||||
export interface ProductCatalogItem {
|
||||
@@ -12,6 +17,7 @@ export interface ProductCatalogItem {
|
||||
canonicalSku: string
|
||||
canonicalName: string
|
||||
category: string
|
||||
carrierType: CostCarrierType
|
||||
rootCategoryId: number | null
|
||||
rootCategoryName: string
|
||||
subCategoryId: number | null
|
||||
@@ -74,6 +80,7 @@ export interface CostVersion {
|
||||
costItemId: number
|
||||
costItemCode: string
|
||||
costItemName: string
|
||||
carrierType: CostCarrierType
|
||||
pricingMode: CostItem['pricingMode']
|
||||
scopeType: 'global' | 'provider' | 'shop' | 'product' | 'rule'
|
||||
provider: string
|
||||
@@ -117,6 +124,7 @@ export interface CostCenterOptions {
|
||||
canonicalSku: string
|
||||
canonicalName: string
|
||||
unitName: string
|
||||
carrierType: CostCarrierType
|
||||
}>
|
||||
costItems: Array<{
|
||||
id: number
|
||||
@@ -255,6 +263,7 @@ export interface ChannelProductMappingGroup {
|
||||
canonicalName: string
|
||||
category: string
|
||||
unitName: string
|
||||
carrierType: CostCarrierType
|
||||
status: 'active' | 'archived'
|
||||
mappings: ChannelProductMapping[]
|
||||
}
|
||||
|
||||
@@ -132,10 +132,13 @@ product_catalog
|
||||
product_name -- 展示名
|
||||
category -- 皮肤/礼包/密钥/改名卡/其他
|
||||
game -- 和平精英等
|
||||
carrier_type -- 默认履约载体:affiliate_dash / work_order / manual / shared
|
||||
status
|
||||
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 映射
|
||||
|
||||
```text
|
||||
|
||||
Reference in New Issue
Block a user