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,
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user