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

- 新增迁移 107_product_catalog_carrier:product_catalog 增加 carrier_type,
  自动发货只认 fulfillment_tasks.executor_key='affiliate_dash' 的履约证据
  (28 个上游 SKU),接单模板商品统一归为 work_order(662 个)
- 页面按「全部 / 自动发货 / 接单平台 / 人工客服 / 共用成本」分区;
  自动发货只展示自动发货 SKU 列表与采购成本,不展示商品目录/渠道映射
- 新增「同步自动发货商品」接口与按钮,按 aff:<skuCode> 幂等补建
- 列表统一接入公共分页 buildAdminTablePagination,支持 10/20/50/100 每页条数
- 标准商品、自动发货 SKU、渠道映射、成本项目、成本版本支持删除,
  带引用保护(映射/版本/快照/订单/接单规则)与生效版本先停用约束
This commit is contained in:
yml2213
2026-10-22 14:05:34 +08:00
parent 8f3babdabe
commit b520a078b0
7 changed files with 1150 additions and 99 deletions
@@ -0,0 +1,95 @@
-- 107_product_catalog_carrier.sql —— 标准商品增加「默认履约载体」。
--
-- 背景:商品主数据此前只有游戏/品类分类,成本项目与成本版本却按
-- 自动发货 / 接单平台 / 人工 三类执行载体配置,后台配置时找不到对应商品。
--
-- 口径(2026-10-22 修正):
-- 1. 只有真正走 affiliate-dash(自动发货执行器)的商品才算 affiliate_dash;
-- 以 fulfillment_tasks.executor_key = 'affiliate_dash' 的履约证据为准,
-- 按 context_json.skuCode / skuName 建标准商品,SKU 固定为 `aff:<skuCode>`。
-- 2. 其余标准商品(接单模板初始化出来的 tpl:*)全部是 work_order;
-- 渠道映射只代表「平台 SKU 能归一到标准商品」,不代表自动发货。
-- 3. manual / shared 不回填,由后台按需手工调整。
--
-- 2026-10-22 生产快照口径:自动发货任务 41,676 条,涉及 28 个 skuCode。
ALTER TABLE product_catalog
ADD COLUMN IF NOT EXISTS carrier_type TEXT NOT NULL DEFAULT 'work_order';
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_product_catalog_carrier_type'
) THEN
ALTER TABLE product_catalog
ADD CONSTRAINT chk_product_catalog_carrier_type
CHECK (carrier_type IN ('affiliate_dash', 'work_order', 'manual', 'shared'));
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_product_catalog_status_carrier
ON product_catalog (status, carrier_type, canonical_name);
-- 1) 先把非自动发货商品统一归到接单平台(模板商品、历史误标都回到 work_order)。
UPDATE product_catalog
SET carrier_type = 'work_order',
updated_at = NOW()
WHERE carrier_type IS DISTINCT FROM 'work_order'
AND canonical_sku NOT LIKE 'aff:%';
-- 2) 按自动发货履约证据建商品:每个 skuCode 取一个最像人名的名称。
WITH auto_task AS (
SELECT
btrim(context_json->>'skuCode') AS sku_code,
btrim(context_json->>'skuName') AS sku_name,
created_at
FROM fulfillment_tasks
WHERE executor_key = 'affiliate_dash'
AND btrim(COALESCE(context_json->>'skuCode', '')) <> ''
AND btrim(COALESCE(context_json->>'skuName', '')) <> ''
),
agg AS (
SELECT sku_code, count(*)::int AS task_count, max(created_at) AS last_fulfilled_at
FROM auto_task
GROUP BY sku_code
),
ranked AS (
SELECT
sku_code,
sku_name,
row_number() OVER (
PARTITION BY sku_code
ORDER BY (sku_name <> sku_code) DESC, count(*) DESC, sku_name
) AS rn
FROM auto_task
GROUP BY sku_code, sku_name
)
INSERT INTO product_catalog (
canonical_sku, canonical_name, category, unit_name, carrier_type, status,
attributes_json, created_at, updated_at
)
SELECT
'aff:' || ranked.sku_code,
ranked.sku_name,
'',
CASE WHEN ranked.sku_name LIKE '%个' THEN '个' ELSE '件' END,
'affiliate_dash',
'active',
jsonb_build_object(
'source', 'affiliate_dash_task',
'skuCode', ranked.sku_code,
'taskCount', agg.task_count,
'lastFulfilledAt', agg.last_fulfilled_at
),
NOW(),
NOW()
FROM ranked
JOIN agg ON agg.sku_code = ranked.sku_code
WHERE ranked.rn = 1
ON CONFLICT (canonical_sku) DO UPDATE
SET carrier_type = 'affiliate_dash',
attributes_json = EXCLUDED.attributes_json,
updated_at = NOW();
COMMENT ON COLUMN product_catalog.carrier_type IS
'默认履约载体:affiliate_dash=自动发货,work_order=接单平台,manual=人工,shared=多载体共用';