diff --git a/apps/backend/src/db/migrations/107_product_catalog_carrier.sql b/apps/backend/src/db/migrations/107_product_catalog_carrier.sql new file mode 100644 index 00000000..34320fae --- /dev/null +++ b/apps/backend/src/db/migrations/107_product_catalog_carrier.sql @@ -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:`。 +-- 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=多载体共用'; diff --git a/apps/backend/src/routes/admin/cost-center.ts b/apps/backend/src/routes/admin/cost-center.ts index ad9a6c78..75178baf 100644 --- a/apps/backend/src/routes/admin/cost-center.ts +++ b/apps/backend/src/routes/admin/cost-center.ts @@ -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, + }), + }), +) + 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'), diff --git a/apps/backend/src/services/admin/admin-cost-center-service.ts b/apps/backend/src/services/admin/admin-cost-center-service.ts index 11fde248..89de5912 100644 --- a/apps/backend/src/services/admin/admin-cost-center-service.ts +++ b/apps/backend/src/services/admin/admin-cost-center-service.ts @@ -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 = {} + 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:`, + * 重复同步只更新名称/证据,不会重复建商品。 + */ +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 || '', diff --git a/apps/frontend/src/pages/admin/AdminCostCenterPage.tsx b/apps/frontend/src/pages/admin/AdminCostCenterPage.tsx index 06c0bc49..13422277 100644 --- a/apps/frontend/src/pages/admin/AdminCostCenterPage.tsx +++ b/apps/frontend/src/pages/admin/AdminCostCenterPage.tsx @@ -1,4 +1,5 @@ import { + DeleteOutlined, EditOutlined, FolderOpenOutlined, PlusOutlined, @@ -45,6 +46,10 @@ import { createAdminCostSnapshot, createAdminCostVersion, createAdminProductCatalog, + deleteAdminChannelProductMapping, + deleteAdminCostItem, + deleteAdminCostVersion, + deleteAdminProductCatalog, fetchAdminChannelProductMappings, fetchAdminCostCenterOptions, fetchAdminCostCenterOverview, @@ -56,6 +61,7 @@ import { previewAdminTemplateProductBootstrap, resolveAdminCostException, retireAdminCostVersion, + syncAdminAffiliateDashProducts, updateAdminChannelProductMapping, updateAdminCostItem, updateAdminProductCatalog, @@ -63,6 +69,7 @@ import { import type { ChannelProductMapping, ChannelProductMappingGroup, + CostCarrierType, CostException, CostItem, CostPreview, @@ -70,6 +77,11 @@ import type { ProductCatalogItem, } from '@/types/admin' import { hasAdminPermission } from '@/utils/admin-auth' +import { + ADMIN_DEFAULT_PAGE_SIZE, + buildAdminLocalTablePagination, + buildAdminTablePagination, +} from '@/utils/admin-pagination' import { formatAdminDateTime } from '@/utils/admin-time' import { formatMoney } from './panels/shared' @@ -77,6 +89,7 @@ type ProductFormValues = { canonicalSku: string canonicalName: string category?: string + carrierType: CostCarrierType unitName?: string status?: 'active' | 'archived' } @@ -139,6 +152,47 @@ const CARRIER_OPTIONS = [ { value: 'shared', label: '通用' }, ] +const CARRIER_TAG_COLORS: Record = { + affiliate_dash: 'green', + work_order: 'blue', + manual: 'orange', + shared: 'default', +} + +type CarrierScope = 'all' | CostCarrierType + +const CARRIER_WORKSPACES: Array<{ + key: CarrierScope + label: string + hint: string +}> = [ + { + key: 'all', + label: '全部', + hint: '不区分载体,查看全部商品、映射、成本项与版本。', + }, + { + key: 'affiliate_dash', + label: '自动发货', + hint: '只按上游货源 SKU 配采购成本,不维护商品目录、游戏分类和渠道映射;那些只在接单平台使用。', + }, + { + key: 'work_order', + label: '接单平台', + hint: '需要打手接单履约的商品;成本以打手报酬为主,可按接单规则或商品配价。', + }, + { + key: 'manual', + label: '人工客服', + hint: '客服手工处理的商品;成本按人工服务计,当前没有商品时可直接新建并设为人工。', + }, + { + key: 'shared', + label: '共用成本', + hint: '平台服务费、支付通道费、售后损失等与履约载体无关的费用;只有成本项和版本。', + }, +] + const PRICING_OPTIONS = [ { value: 'per_item', label: '按件' }, { value: 'per_order', label: '按单' }, @@ -187,8 +241,6 @@ function formatRate(rateBps: number) { return `${((Number(rateBps || 0) || 0) / 100).toFixed(2)}%` } -const PAGE_SIZE = 20 - export default function AdminCostCenterPage() { const { message, modal } = App.useApp() const queryClient = useQueryClient() @@ -199,6 +251,8 @@ export default function AdminCostCenterPage() { const [costItemForm] = Form.useForm() const [versionForm] = Form.useForm() + const [activeCarrier, setActiveCarrier] = useState('all') + const [activeTab, setActiveTab] = useState('products') const [productKeyword, setProductKeyword] = useState('') const [productRootCategoryId, setProductRootCategoryId] = useState(null) const [productSubCategoryId, setProductSubCategoryId] = useState(null) @@ -210,17 +264,24 @@ export default function AdminCostCenterPage() { const [versionStatus, setVersionStatus] = useState('') const [exceptionStatus, setExceptionStatus] = useState('open') const [productPage, setProductPage] = useState(1) + const [productPageSize, setProductPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) const [mappingPage, setMappingPage] = useState(1) + const [mappingPageSize, setMappingPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) const [expandedMappingProductIds, setExpandedMappingProductIds] = useState([]) + const [mappingDetailPageSize, setMappingDetailPageSize] = useState(10) const [costItemPage, setCostItemPage] = useState(1) + const [costItemPageSize, setCostItemPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) const [versionPage, setVersionPage] = useState(1) + const [versionPageSize, setVersionPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) const [exceptionPage, setExceptionPage] = useState(1) + const [exceptionPageSize, setExceptionPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) const [previewOrderItemId, setPreviewOrderItemId] = useState(null) const [preview, setPreview] = useState(null) const [previewing, setPreviewing] = useState(false) const [snapshotting, setSnapshotting] = useState(false) const [bootstrapPreviewing, setBootstrapPreviewing] = useState(false) const [bootstrapApplying, setBootstrapApplying] = useState(false) + const [syncingAffiliate, setSyncingAffiliate] = useState(false) const versionScopeType = Form.useWatch('scopeType', versionForm) const versionCostItemId = Form.useWatch('costItemId', versionForm) @@ -240,59 +301,106 @@ export default function AdminCostCenterPage() { queryKey: ['admin-cost-center-options'], queryFn: fetchAdminCostCenterOptions, }) + const carrierFilter = activeCarrier === 'all' ? undefined : activeCarrier + const activeCarrierLabel = + activeCarrier === 'all' ? '全部载体' : labelOf(CARRIER_OPTIONS, activeCarrier) + // 商品目录 / 渠道映射只服务接单平台;自动发货只需上游 SKU + 采购成本。 + const showsCatalogTabs = activeCarrier === 'all' || activeCarrier === 'work_order' + const visibleTabKeys = [ + ...(showsCatalogTabs ? ['products', 'mappings'] : []), + ...(activeCarrier === 'affiliate_dash' ? ['affiliate-skus'] : []), + 'cost-items', + 'cost-versions', + 'exceptions', + 'preview', + ] + const productsQuery = useQuery({ queryKey: [ 'admin-cost-center-products', + activeCarrier, productKeyword, productRootCategoryId, productSubCategoryId, productPage, + productPageSize, ], queryFn: () => fetchAdminProductCatalog({ keyword: productKeyword || undefined, + carrierType: carrierFilter, rootCategoryId: productRootCategoryId ?? undefined, categoryId: productSubCategoryId ?? undefined, page: productPage, - pageSize: PAGE_SIZE, + pageSize: productPageSize, }), }) const mappingsQuery = useQuery({ - queryKey: ['admin-cost-center-mappings', mappingKeyword, mappingProvider, mappingPage], + queryKey: [ + 'admin-cost-center-mappings', + activeCarrier, + mappingKeyword, + mappingProvider, + mappingPage, + mappingPageSize, + ], queryFn: () => fetchAdminChannelProductMappings({ keyword: mappingKeyword || undefined, provider: mappingProvider || undefined, + carrierType: carrierFilter, page: mappingPage, - pageSize: PAGE_SIZE, + pageSize: mappingPageSize, }), }) const costItemsQuery = useQuery({ - queryKey: ['admin-cost-center-cost-items', costItemKeyword, costItemPage], + queryKey: [ + 'admin-cost-center-cost-items', + activeCarrier, + costItemKeyword, + costItemPage, + costItemPageSize, + ], queryFn: () => fetchAdminCostItems({ keyword: costItemKeyword || undefined, + carrierType: carrierFilter, page: costItemPage, - pageSize: PAGE_SIZE, + pageSize: costItemPageSize, }), }) const versionsQuery = useQuery({ - queryKey: ['admin-cost-center-cost-versions', versionKeyword, versionStatus, versionPage], + queryKey: [ + 'admin-cost-center-cost-versions', + activeCarrier, + versionKeyword, + versionStatus, + versionPage, + versionPageSize, + ], queryFn: () => fetchAdminCostVersions({ keyword: versionKeyword || undefined, status: versionStatus || undefined, + carrierType: carrierFilter, page: versionPage, - pageSize: PAGE_SIZE, + pageSize: versionPageSize, }), }) const exceptionsQuery = useQuery({ - queryKey: ['admin-cost-center-exceptions', exceptionStatus, exceptionPage], + queryKey: [ + 'admin-cost-center-exceptions', + activeCarrier, + exceptionStatus, + exceptionPage, + exceptionPageSize, + ], queryFn: () => fetchAdminCostExceptions({ status: exceptionStatus || undefined, + carrierType: carrierFilter, page: exceptionPage, - pageSize: PAGE_SIZE, + pageSize: exceptionPageSize, }), }) @@ -324,15 +432,19 @@ export default function AdminCostCenterPage() { )?.pricingMode const providers = options?.providers || [] const productOptions = - options?.products.map((item) => ({ - value: item.id, - label: `${item.canonicalName}(${item.canonicalSku})`, - })) || [] + options?.products + .filter((item) => activeCarrier === 'all' || item.carrierType === activeCarrier) + .map((item) => ({ + value: item.id, + label: `${item.canonicalName}(${item.canonicalSku})`, + })) || [] const costItemOptions = - options?.costItems.map((item) => ({ - value: item.id, - label: `${item.name}(${item.code})`, - })) || [] + options?.costItems + .filter((item) => activeCarrier === 'all' || item.carrierType === activeCarrier) + .map((item) => ({ + value: item.id, + label: `${item.name}(${item.code})`, + })) || [] const ruleOptions = options?.rules.map((item) => ({ value: item.id, @@ -352,7 +464,11 @@ export default function AdminCostCenterPage() { function openCreateProduct() { setEditingProduct(null) productForm.resetFields() - productForm.setFieldsValue({ unitName: '件', status: 'active' }) + productForm.setFieldsValue({ + unitName: '件', + status: 'active', + carrierType: activeCarrier === 'all' ? 'work_order' : activeCarrier, + }) setProductModalOpen(true) } @@ -452,12 +568,28 @@ export default function AdminCostCenterPage() { } } + async function syncAffiliateProducts() { + setSyncingAffiliate(true) + try { + const result = await syncAdminAffiliateDashProducts() + message.success( + `已同步 ${result.data.productCount} 个自动发货商品(新增 ${result.data.createdCount},更新 ${result.data.updatedCount})`, + ) + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '同步自动发货商品失败') + } finally { + setSyncingAffiliate(false) + } + } + function openEditProduct(item: ProductCatalogItem) { setEditingProduct(item) productForm.setFieldsValue({ canonicalSku: item.canonicalSku, canonicalName: item.canonicalName, category: item.category, + carrierType: item.carrierType, unitName: item.unitName, status: item.status, }) @@ -470,6 +602,7 @@ export default function AdminCostCenterPage() { await updateAdminProductCatalog(editingProduct.id, { canonicalName: values.canonicalName, category: values.category || '', + carrierType: values.carrierType, unitName: values.unitName || '件', status: values.status || 'active', }) @@ -552,8 +685,19 @@ export default function AdminCostCenterPage() { function openCreateCostItem() { setEditingCostItem(null) costItemForm.resetFields() + const defaultCostType = + activeCarrier === 'affiliate_dash' + ? 'auto_purchase' + : activeCarrier === 'work_order' + ? 'work_order_reward' + : activeCarrier === 'manual' + ? 'manual_service' + : activeCarrier === 'shared' + ? 'platform_fee' + : undefined costItemForm.setFieldsValue({ - carrierType: 'work_order', + carrierType: activeCarrier === 'all' ? 'work_order' : activeCarrier, + ...(defaultCostType ? { costType: defaultCostType } : {}), pricingMode: 'per_item', unitName: '件', status: 'active', @@ -603,7 +747,7 @@ export default function AdminCostCenterPage() { function openCreateVersion() { versionForm.resetFields() versionForm.setFieldsValue({ - scopeType: 'global', + scopeType: activeCarrier === 'affiliate_dash' ? 'product' : 'global', effectiveFrom: dayjs(), status: 'active', }) @@ -655,6 +799,86 @@ export default function AdminCostCenterPage() { }) } + function deleteProduct(item: ProductCatalogItem) { + const affiliateHint = + item.carrierType === 'affiliate_dash' + ? '这是自动发货 SKU,删除后再次同步会自动生成;只想隐藏它请改为归档。' + : '' + modal.confirm({ + title: '删除标准商品', + content: `确认删除「${item.canonicalName}(${item.canonicalSku})」?不可恢复;存在渠道映射、成本版本、成本快照或订单引用时会被拒绝,请改用归档。${affiliateHint}`, + okText: '删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await deleteAdminProductCatalog(item.id) + message.success('标准商品已删除') + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '删除标准商品失败') + } + }, + }) + } + + function deleteMapping(item: ChannelProductMapping) { + modal.confirm({ + title: '删除渠道映射', + content: `确认删除「${providerLabel(item.provider)} / ${item.shopId || '全局'} / ${item.platformSkuCode || item.platformSkuName || '-'}」这条映射?删除后该平台 SKU 将不再自动归一。`, + okText: '删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await deleteAdminChannelProductMapping(item.id) + message.success('渠道映射已删除') + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '删除渠道映射失败') + } + }, + }) + } + + function deleteCostItem(item: CostItem) { + modal.confirm({ + title: '删除成本项目', + content: `确认删除「${item.name}(${item.code})」?存在成本版本、成本快照或接单规则引用时会被拒绝,请改用归档。`, + okText: '删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await deleteAdminCostItem(item.id) + message.success('成本项目已删除') + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '删除成本项目失败') + } + }, + }) + } + + function deleteVersion(item: CostVersion) { + modal.confirm({ + title: '删除成本版本', + content: `确认删除「${item.costItemName} · ${item.scopeType}」这个版本?已被成本快照引用的版本不可删除。`, + okText: '删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await deleteAdminCostVersion(item.id) + message.success('成本版本已删除') + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '删除成本版本失败') + } + }, + }) + } + function resolveException(item: CostException, status: 'resolved' | 'ignored') { modal.confirm({ title: status === 'resolved' ? '标记异常已处理' : '忽略异常', @@ -791,6 +1015,14 @@ export default function AdminCostCenterPage() { ), }, + { + title: '载体', + dataIndex: 'carrierType', + width: 110, + render: (value: CostCarrierType) => ( + {labelOf(CARRIER_OPTIONS, value)} + ), + }, { title: '已绑定渠道', dataIndex: 'mappingCount', @@ -839,18 +1071,29 @@ export default function AdminCostCenterPage() { }, { title: '操作', - width: 70, + width: 100, fixed: 'right', render: (_, row) => canConfig ? ( - + + + + ) : null, }, ] @@ -924,6 +1172,14 @@ export default function AdminCostCenterPage() { ), }, + { + title: '载体', + dataIndex: 'carrierType', + width: 110, + render: (value: CostCarrierType) => ( + {labelOf(CARRIER_OPTIONS, value)} + ), + }, { title: '分类', dataIndex: 'category', @@ -1046,13 +1302,18 @@ export default function AdminCostCenterPage() { }, { title: '操作', - width: 90, + width: 140, fixed: 'right', render: (_, row) => canConfig ? ( - + + + + ) : null, }, ] @@ -1069,6 +1330,14 @@ export default function AdminCostCenterPage() { ), }, + { + title: '载体', + dataIndex: 'carrierType', + width: 110, + render: (value: CostCarrierType) => ( + {labelOf(CARRIER_OPTIONS, value)} + ), + }, { title: '范围', dataIndex: 'scopeType', @@ -1116,19 +1385,35 @@ export default function AdminCostCenterPage() { title: '操作', width: 90, fixed: 'right', - render: (_, row) => - canConfig && row.status === 'active' ? ( - + ) + } + return ( + - ) : null, + ) + }, }, ] const exceptionColumns: TableColumnsType = [ { title: '主体', dataIndex: 'subjectType', width: 110 }, { title: '订单项', dataIndex: 'orderItemId', width: 100, render: (v) => (v ? `#${v}` : '-') }, - { title: '载体', dataIndex: 'carrierType', width: 110 }, + { + title: '载体', + dataIndex: 'carrierType', + width: 110, + render: (value: CostCarrierType) => ( + {labelOf(CARRIER_OPTIONS, value)} + ), + }, { title: '原因', dataIndex: 'reason', @@ -1177,7 +1462,7 @@ export default function AdminCostCenterPage() {
} onClick={refreshAll} loading={overviewQuery.isFetching}> 刷新 @@ -1202,6 +1487,55 @@ export default function AdminCostCenterPage() { ))} + + { + const next = key as CarrierScope + setActiveCarrier(next) + setActiveTab( + next === 'all' || next === 'work_order' + ? 'products' + : next === 'affiliate_dash' + ? 'affiliate-skus' + : 'cost-items', + ) + setProductPage(1) + setMappingPage(1) + setCostItemPage(1) + setVersionPage(1) + setExceptionPage(1) + setProductRootCategoryId(null) + setProductSubCategoryId(null) + setExpandedMappingProductIds([]) + }} + items={CARRIER_WORKSPACES.map((workspace) => { + const productCount = + workspace.key === 'all' + ? overview?.productCount + : overview?.productCarrierCounts?.[workspace.key] + const costItemCount = + workspace.key === 'all' + ? overview?.costItemCount + : overview?.costItemCarrierCounts?.[workspace.key] + return { + key: workspace.key, + label: ( + + {workspace.label} + + {Number(productCount || 0)} 商品 / {Number(costItemCount || 0)} 成本项 + + + ), + children: {workspace.hint}, + } + })} + /> + + {!canConfig ? ( 标准商品目录 {productCatalogTotal} + {activeCarrier === 'all' ? null : ( + {activeCarrierLabel} + )} {productRootCategoryId ? 当前 {productTotal} : null} } @@ -1246,13 +1585,24 @@ export default function AdminCostCenterPage() { /> {canConfig ? ( <> - + {activeCarrier === 'all' || activeCarrier === 'work_order' ? ( + + ) : null} + {activeCarrier === 'all' || activeCarrier === 'affiliate_dash' ? ( + + ) : null} @@ -1344,15 +1694,17 @@ export default function AdminCostCenterPage() { dataSource={products} loading={productsQuery.isLoading} rowClassName={(row) => (row.mappingCount > 0 ? 'product-rule-row-mapped' : '')} - scroll={{ x: 960 }} - pagination={{ + scroll={{ x: 1080 }} + pagination={buildAdminTablePagination({ current: productPage, - pageSize: PAGE_SIZE, + pageSize: productPageSize, total: productTotal, - showSizeChanger: false, showTotal: (total) => `共 ${total} 个商品`, - onChange: setProductPage, - }} + onChange: (page, pageSize) => { + setProductPage(page) + setProductPageSize(pageSize) + }, + })} /> ), @@ -1367,6 +1719,9 @@ export default function AdminCostCenterPage() { 渠道映射目录 {mappingTotal} 条映射 {mappingGroupTotal} 个标准商品 + {activeCarrier === 'all' ? null : ( + {activeCarrierLabel} + )} } extra={ @@ -1430,7 +1785,7 @@ export default function AdminCostCenterPage() { columns={mappingGroupColumns} dataSource={mappingGroups} loading={mappingsQuery.isLoading} - scroll={{ x: 1100 }} + scroll={{ x: 1210 }} expandable={{ expandedRowKeys: expandedMappingProductIds, onExpandedRowsChange: (keys) => @@ -1446,26 +1801,160 @@ export default function AdminCostCenterPage() { size="small" columns={mappingDetailColumns} dataSource={group.mappings} - pagination={{ - pageSize: 20, + pagination={buildAdminLocalTablePagination({ + pageSize: mappingDetailPageSize, size: 'small', - showSizeChanger: false, - }} + onChange: (_page, pageSize) => setMappingDetailPageSize(pageSize), + })} scroll={{ x: 980 }} /> ), }} - pagination={{ + pagination={buildAdminTablePagination({ current: mappingPage, - pageSize: PAGE_SIZE, + pageSize: mappingPageSize, total: mappingGroupTotal, - showSizeChanger: false, - onChange: (nextPage) => { - setMappingPage(nextPage) + showTotal: (total) => `共 ${total} 个标准商品`, + onChange: (page, pageSize) => { + setMappingPage(page) + setMappingPageSize(pageSize) setExpandedMappingProductIds([]) }, - }} + })} + /> + + ), + }, + { + key: 'affiliate-skus', + label: '自动发货 SKU', + children: ( + + 自动发货 SKU + {productCatalogTotal} + + } + extra={ + canConfig ? ( + + + + + ) : null + } + > + + `共 ${total} 个 SKU`, + onChange: (page, pageSize) => { + setProductPage(page) + setProductPageSize(pageSize) + }, + })} + columns={[ + { + title: 'SKU 名称', + dataIndex: 'canonicalName', + render: (value: string, row) => ( + + {value} + + {String(row.attributes?.skuCode || row.canonicalSku)} + + + ), + }, + { + title: '履约任务', + width: 110, + render: (_, row) => `${Number(row.attributes?.taskCount || 0)} 单`, + }, + { + title: '最近履约', + width: 180, + render: (_, row) => + row.attributes?.lastFulfilledAt + ? formatAdminDateTime(String(row.attributes.lastFulfilledAt)) + : '-', + }, + { + title: '成本版本', + dataIndex: 'costVersionCount', + width: 110, + render: (value: number) => (value > 0 ? `${value} 个` : 未配置), + }, + { + title: '状态', + dataIndex: 'status', + width: 120, + render: (value: string, row) => ( + + void toggleProductStatus(row, checked)} + /> + + {value === 'active' ? '启用' : '归档'} + + + ), + }, + { + title: '操作', + width: 140, + render: (_, row) => + canConfig ? ( + + + + + ) : null, + }, + ]} /> ), @@ -1475,7 +1964,14 @@ export default function AdminCostCenterPage() { label: '成本项目', children: ( + 成本项目 + {activeCarrier === 'all' ? null : ( + {activeCarrierLabel} + )} + + } extra={ canConfig ? (