Merge branch 'main' into dev_match

This commit is contained in:
2026-11-06 17:39:08 +08:00
59 changed files with 3319 additions and 956 deletions
@@ -0,0 +1,82 @@
-- 核销任务直接保存目标券码,供后台“最近核销任务”展示和检索。
-- 任务创建时会先解析候选券码;执行时再刷新一次,兼容券码晚于任务到达的场景。
CREATE EXTENSION IF NOT EXISTS pg_trgm;
ALTER TABLE voucher_consume_jobs
ADD COLUMN IF NOT EXISTS voucher_codes TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
COMMENT ON COLUMN voucher_consume_jobs.voucher_codes IS
'核销任务关联的券码;快手电子凭证保存 voucher_code,抖音码速达保存我方与平台侧券码';
-- array_to_string 在 PostgreSQL 中被标记为 STABLE,不能直接用于表达式索引;
-- text[] 的拼接是本项目的确定性展示口径,用 IMMUTABLE 包装支持核销码模糊检索。
CREATE OR REPLACE FUNCTION fulfillment_voucher_codes_search_text(codes TEXT[])
RETURNS TEXT
LANGUAGE sql
IMMUTABLE
PARALLEL SAFE
RETURN ARRAY_TO_STRING(codes, ' ');
COMMENT ON FUNCTION fulfillment_voucher_codes_search_text(TEXT[]) IS
'核销任务券码模糊搜索文本(用于 GIN trigram 表达式索引)';
CREATE INDEX IF NOT EXISTS idx_voucher_consume_jobs_voucher_codes
ON voucher_consume_jobs
USING GIN ((fulfillment_voucher_codes_search_text(voucher_codes)) gin_trgm_ops);
-- 回填已有任务。快手优先按任务/单元定位;没有任务时退回订单号维度。
UPDATE voucher_consume_jobs job
SET voucher_codes = COALESCE((
SELECT ARRAY_AGG(DISTINCT voucher.voucher_code ORDER BY voucher.voucher_code)
FROM kuaishou_industry_vouchers voucher
WHERE job.provider = 'kuaishou_industry'
AND voucher.oid = job.platform_order_id
AND BTRIM(voucher.voucher_code) <> ''
AND (
job.task_id IS NULL
OR voucher.task_id = job.task_id
OR voucher.unit_index = (
SELECT task.unit_index FROM fulfillment_tasks task WHERE task.id = job.task_id
)
)
), ARRAY[]::TEXT[])
WHERE job.provider = 'kuaishou_industry'
AND CARDINALITY(job.voucher_codes) = 0;
-- 抖音码速达可能发生平台侧换码,因此两个券码都保留并可搜索。
UPDATE voucher_consume_jobs job
SET voucher_codes = COALESCE((
SELECT ARRAY_AGG(DISTINCT code ORDER BY code)
FROM (
SELECT certificate.cert_no AS code
FROM redeem_certificates certificate
WHERE certificate.provider = 'douyin_mashida'
AND (
certificate.local_order_id = job.order_id
OR (job.order_id IS NULL AND certificate.order_id = job.platform_order_id)
)
AND BTRIM(certificate.cert_no) <> ''
AND (
job.order_item_id IS NULL
OR certificate.order_item_id IS NULL
OR certificate.order_item_id = job.order_item_id
)
UNION
SELECT certificate.platform_cert_no AS code
FROM redeem_certificates certificate
WHERE certificate.provider = 'douyin_mashida'
AND (
certificate.local_order_id = job.order_id
OR (job.order_id IS NULL AND certificate.order_id = job.platform_order_id)
)
AND BTRIM(certificate.platform_cert_no) <> ''
AND (
job.order_item_id IS NULL
OR certificate.order_item_id IS NULL
OR certificate.order_item_id = job.order_item_id
)
) codes
), ARRAY[]::TEXT[])
WHERE job.provider = 'douyin_mashida'
AND CARDINALITY(job.voucher_codes) = 0;
@@ -0,0 +1,173 @@
-- 143_repair_third_party_voucher_products.sql
--
-- 第三方卡券订单项必须关联 tpv:<内部 SKU>。历史商品归一在缺少 tpv 商品时
-- 会回退到同名 man 商品,导致卡券订单使用人工商品维度而无法生成卡券采购成本。
-- 本迁移只修正商品归属、渠道规则和能力,不删除任何商品、订单或历史快照。
-- 部署后由 cost_snapshot / sales_facts 管道补建成本快照并重算受影响日期。
DROP TABLE IF EXISTS pg_temp.tmp_repair_voucher_items;
CREATE TEMP TABLE tmp_repair_voucher_items ON COMMIT DROP AS
SELECT
oi.id AS order_item_id,
oi.order_id,
resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku,
(COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date::text AS basis_date
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
LEFT JOIN product_catalog current_product ON current_product.id = oi.canonical_product_id
WHERE oi.fulfillment_channel = 'third_party_voucher'
AND BTRIM(resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)) <> ''
AND char_length(
'tpv:' || resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)
) <= 64
AND (
current_product.id IS NULL
OR current_product.canonical_sku IS DISTINCT FROM
'tpv:' || resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)
);
CREATE UNIQUE INDEX ON tmp_repair_voucher_items (order_item_id);
INSERT INTO product_catalog (
canonical_sku, canonical_name, category, unit_name, status,
attributes_json, created_at, updated_at
)
SELECT DISTINCT
'tpv:' || item.internal_sku,
item.internal_sku,
'卡券',
'张',
'active',
'{"source":"third_party_voucher","auto_created":true,"repaired":true}'::jsonb,
NOW(),
NOW()
FROM tmp_repair_voucher_items item
ON CONFLICT (canonical_sku) DO UPDATE SET
status = 'active',
canonical_name = CASE
WHEN BTRIM(product_catalog.canonical_name) = '' THEN EXCLUDED.canonical_name
ELSE product_catalog.canonical_name
END,
attributes_json = product_catalog.attributes_json || EXCLUDED.attributes_json,
updated_at = EXCLUDED.updated_at;
UPDATE order_items oi
SET canonical_product_id = product.id,
updated_at = NOW()
FROM tmp_repair_voucher_items item
JOIN product_catalog product
ON product.canonical_sku = 'tpv:' || item.internal_sku
AND product.status = 'active'
WHERE oi.id = item.order_item_id;
UPDATE cost_snapshots snapshot
SET canonical_product_id = product.id
FROM tmp_repair_voucher_items item
JOIN product_catalog product
ON product.canonical_sku = 'tpv:' || item.internal_sku
WHERE snapshot.order_item_id = item.order_item_id
AND snapshot.superseded_at IS NULL
AND snapshot.reversed_at IS NULL;
INSERT INTO product_channel_capabilities (
product_id, channel, priority, is_fallback, enabled, source, created_at, updated_at
)
SELECT DISTINCT
product.id,
'third_party_voucher',
10,
FALSE,
TRUE,
'voucher',
NOW(),
NOW()
FROM tmp_repair_voucher_items item
JOIN product_catalog product
ON product.canonical_sku = 'tpv:' || item.internal_sku
ON CONFLICT (product_id, channel) DO UPDATE SET
priority = 10,
is_fallback = FALSE,
enabled = TRUE,
source = 'voucher',
updated_at = EXCLUDED.updated_at;
-- 卡券没有外部履约载体,规则命中即完成渠道判定;同 SKU 其他能力保留为兜底。
UPDATE internal_sku_channels channel
SET is_fallback = TRUE,
priority = GREATEST(channel.priority, 90),
updated_at = NOW()
FROM (
SELECT DISTINCT internal_sku
FROM tmp_repair_voucher_items
) repaired
WHERE canonicalize_internal_sku(channel.internal_sku) = repaired.internal_sku
AND channel.channel <> 'third_party_voucher'
AND channel.enabled = TRUE
AND channel.is_fallback = FALSE;
INSERT INTO internal_sku_channels (
internal_sku, channel, priority, is_fallback, enabled,
canonical_product_id, config_json, source, created_at, updated_at
)
SELECT DISTINCT
item.internal_sku,
'third_party_voucher',
10,
FALSE,
TRUE,
product.id,
jsonb_build_object('voucherSku', item.internal_sku, 'repaired', TRUE),
'voucher',
NOW(),
NOW()
FROM tmp_repair_voucher_items item
JOIN product_catalog product
ON product.canonical_sku = 'tpv:' || item.internal_sku
ON CONFLICT (internal_sku, channel) DO UPDATE SET
priority = 10,
is_fallback = FALSE,
enabled = TRUE,
canonical_product_id = EXCLUDED.canonical_product_id,
config_json = internal_sku_channels.config_json || EXCLUDED.config_json,
source = EXCLUDED.source,
updated_at = EXCLUDED.updated_at;
-- 修复商品归属后,商品缺失和旧成本缺失异常由 cost_snapshot 管道重新计算并自动收口。
WITH removed_links AS (
DELETE FROM cost_exception_items
WHERE order_item_id IN (
SELECT order_item_id FROM tmp_repair_voucher_items
)
RETURNING exception_id
),
affected_heads AS (
SELECT DISTINCT exception_id FROM removed_links
)
UPDATE cost_exceptions exception
SET status = 'resolved',
resolved_at = NOW(),
item_count = 0,
last_seen_at = NOW()
WHERE exception.id IN (SELECT exception_id FROM affected_heads)
AND NOT EXISTS (
SELECT 1
FROM cost_exception_items item
WHERE item.exception_id = exception.id
);
UPDATE cost_exceptions exception
SET status = 'resolved',
resolved_at = NOW(),
item_count = 0,
last_seen_at = NOW()
WHERE exception.order_item_id IN (
SELECT order_item_id FROM tmp_repair_voucher_items
)
AND NOT EXISTS (
SELECT 1
FROM cost_exception_items item
WHERE item.exception_id = exception.id
);
COMMENT ON COLUMN product_catalog.attributes_json IS
'标准商品扩展信息;第三方卡券修复记录 repaired=true,商品归属以 tpv:<内部 SKU> 为唯一口径';
@@ -0,0 +1,120 @@
-- 144_fulfillment_shadow_configuration_sync.sql
--
-- 统一履约视图会读取人工配置和 internal_sku_channels 的派生状态。
-- 这两张配置表变化时也必须刷新影子模型,否则影子表会保留旧载体状态和旧异常。
CREATE OR REPLACE FUNCTION sync_fulfillment_shadow_for_manual_config()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
old_key TEXT;
new_key TEXT;
BEGIN
IF TG_OP <> 'INSERT' THEN
old_key := canonicalize_internal_sku(OLD.sku_key);
END IF;
IF TG_OP <> 'DELETE' THEN
new_key := canonicalize_internal_sku(NEW.sku_key);
END IF;
PERFORM sync_fulfillment_shadow(item.id)
FROM order_items item
WHERE item.fulfillment_status IN ('pending', 'assigned')
AND canonicalize_internal_sku(
resolve_internal_sku(item.manual_sku_key, item.sku_name, item.sku_code)
) IN (old_key, new_key);
RETURN COALESCE(NEW, OLD);
END;
$$;
DROP TRIGGER IF EXISTS trg_manual_fulfillment_config_shadow ON manual_fulfillment_configs;
CREATE TRIGGER trg_manual_fulfillment_config_shadow
AFTER INSERT OR UPDATE OR DELETE ON manual_fulfillment_configs
FOR EACH ROW
EXECUTE FUNCTION sync_fulfillment_shadow_for_manual_config();
CREATE OR REPLACE FUNCTION sync_fulfillment_shadow_for_internal_channel()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
old_key TEXT;
new_key TEXT;
BEGIN
IF TG_OP <> 'INSERT' THEN
old_key := canonicalize_internal_sku(OLD.internal_sku);
END IF;
IF TG_OP <> 'DELETE' THEN
new_key := canonicalize_internal_sku(NEW.internal_sku);
END IF;
PERFORM sync_fulfillment_shadow(item.id)
FROM order_items item
WHERE item.fulfillment_status IN ('pending', 'assigned')
AND canonicalize_internal_sku(
resolve_internal_sku(item.manual_sku_key, item.sku_name, item.sku_code)
) IN (old_key, new_key);
RETURN COALESCE(NEW, OLD);
END;
$$;
DROP TRIGGER IF EXISTS trg_internal_sku_channel_shadow ON internal_sku_channels;
CREATE TRIGGER trg_internal_sku_channel_shadow
AFTER INSERT OR UPDATE OR DELETE ON internal_sku_channels
FOR EACH ROW
EXECUTE FUNCTION sync_fulfillment_shadow_for_internal_channel();
CREATE OR REPLACE FUNCTION assert_manual_config_product_source()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.canonical_product_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM product_catalog product
WHERE product.id = NEW.canonical_product_id
AND product.status = 'active'
AND product.canonical_sku LIKE 'man:%'
)
THEN
RAISE EXCEPTION
'manual fulfillment config product % must be an active man: product',
NEW.canonical_product_id
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_manual_config_product_source ON manual_fulfillment_configs;
CREATE TRIGGER trg_manual_config_product_source
BEFORE INSERT OR UPDATE ON manual_fulfillment_configs
FOR EACH ROW
EXECUTE FUNCTION assert_manual_config_product_source();
-- 已启用的配置仍可能指向后来被归档的人工商品。这里恢复其 man: 商品,
-- 避免迁移后归一继续把后续订单挂到不可用商品上。
UPDATE product_catalog product
SET status = 'active',
updated_at = NOW()
FROM manual_fulfillment_configs config
WHERE config.canonical_product_id = product.id
AND config.enabled = TRUE
AND product.canonical_sku LIKE 'man:%'
AND product.status <> 'active';
-- 修正迁移 141 后由配置变化造成的存量陈旧影子记录。
SELECT sync_fulfillment_shadow(item.id)
FROM order_items item
WHERE item.fulfillment_status IN ('pending', 'assigned');
COMMENT ON FUNCTION sync_fulfillment_shadow_for_manual_config() IS
'人工配置变更后重算同内部 SKU 的履约影子';
COMMENT ON FUNCTION sync_fulfillment_shadow_for_internal_channel() IS
'统一渠道规则变更后重算同内部 SKU 的履约影子';
COMMENT ON FUNCTION assert_manual_config_product_source() IS
'人工履约配置只能关联有效的 man: 标准商品';
@@ -0,0 +1,38 @@
-- 145_fulfillment_current_attempt_latest.sql
--
-- 统一读模型按 task.id DESC 取当前尝试。为保持与 is_current 指针一致,
-- 从现在开始禁止把较旧任务重新标记为 current;新尝试仍由业务先退休旧指针。
CREATE OR REPLACE FUNCTION assert_fulfillment_current_is_latest()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.is_current = TRUE
AND EXISTS (
SELECT 1
FROM fulfillment_tasks newer
WHERE newer.order_item_id = NEW.order_item_id
AND newer.unit_index = NEW.unit_index
AND newer.id > COALESCE(NEW.id, 0)
)
THEN
RAISE EXCEPTION
'fulfillment current attempt must be the latest attempt for order_item % unit %',
NEW.order_item_id,
NEW.unit_index
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_fulfillment_current_is_latest ON fulfillment_tasks;
CREATE TRIGGER trg_fulfillment_current_is_latest
BEFORE INSERT OR UPDATE OF is_current, order_item_id, unit_index
ON fulfillment_tasks
FOR EACH ROW
EXECUTE FUNCTION assert_fulfillment_current_is_latest();
COMMENT ON FUNCTION assert_fulfillment_current_is_latest() IS
'当前履约尝试必须是同订单项单元中 id 最大的尝试,保证统一视图 id DESC 与 is_current 一致';
@@ -1,5 +1,6 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import {
preserveAssignedOrderItemState,
@@ -202,3 +203,19 @@ test('已分配订单重推写库时不会用平台新 SKU 覆盖冻结值', asy
assert.equal(updateParams[0]?.[0], 'lucky_coin_x2')
assert.equal(updateParams[0]?.[1], '幸运币2个')
})
test('所有替换订单项入口都在同一事务内分摊订单收入', async () => {
const source = await readFile(new URL('./order-item-repo.ts', import.meta.url), 'utf8')
const replaceSource = source.slice(
source.indexOf('export async function replaceOrderItems('),
source.indexOf('export async function applyOrderItemRevenueAllocationWithExecutor('),
)
assert.ok(replaceSource.includes('withTransaction'))
assert.ok(replaceSource.includes('applyOrderItemRevenueAllocationWithExecutor'))
assert.ok(replaceSource.includes('SELECT total_amount FROM orders'))
assert.ok(
replaceSource.indexOf('replaceOrderItemsWithExecutor') <
replaceSource.indexOf('applyOrderItemRevenueAllocationWithExecutor'),
)
})
@@ -42,9 +42,22 @@ export async function replaceOrderItems(
orderId: number | string,
items: OrderItemReplaceInput[],
): Promise<OrderItemRow[]> {
return withTransaction((client: PoolClient) =>
replaceOrderItemsWithExecutor(client.query.bind(client), orderId, items),
)
return withTransaction(async (client: PoolClient) => {
const executor = client.query.bind(client)
await replaceOrderItemsWithExecutor(executor, orderId, items)
const order = await client.query<{ total_amount: string | number }>(
`SELECT total_amount FROM orders WHERE id = $1`,
[orderId],
)
if (order.rows[0]) {
await applyOrderItemRevenueAllocationWithExecutor(
executor,
orderId,
Number(order.rows[0].total_amount) || 0,
)
}
return listOrderItemsByOrderIdWithExecutor(executor, orderId)
})
}
export async function applyOrderItemRevenueAllocationWithExecutor(
@@ -1,16 +1,12 @@
/**
* 成本中心 · 商品与映射路由。
*
* 覆盖:接单模板初始化、自动发货/接单平台商品同步与回填、人工商品归一、
* 覆盖:接单模板初始化、自动发货/接单平台商品同步与回填、人工商品自动投影、
* 标准商品 CRUD、渠道映射 CRUD、未归一队列、归一数量回填。
*/
import { Router } from 'express'
import { ensureVoucherChannelRules } from '../../services/fulfillment/channel-assign-service.js'
import {
archiveHistoricalManualProducts,
archiveSupersededManualProducts,
backfillAdminNormalizedQuantities,
backfillAdminProductMappings,
createAdminChannelMapping,
@@ -27,6 +23,7 @@ import {
updateAdminChannelMapping,
updateAdminProduct,
} from '../../services/cost-center/product-catalog-service.js'
import { upsertAdminVoucherProduct } from '../../services/cost-center/voucher-product-service.js'
import {
importAdminAffiliateDashPricesBaseline,
setAdminAffiliateProductPrice,
@@ -164,38 +161,6 @@ router.post(
}),
)
router.post(
'/cost-center/products/archive-historical-manual',
requireAdminPermission('finance.config'),
createJsonHandler((req) => archiveHistoricalManualProducts(bodyOf(req)), {
successMessage: '历史人工商品已归档',
errorMessage: '归档历史人工商品失败',
scope: '[admin/cost-center/products/archive-historical-manual]',
audit: (_req, data) => ({
action: 'cost_historical_manual_products_archived',
targetType: 'product_catalog',
targetId: '',
data: data as Record<string, unknown>,
}),
}),
)
router.post(
'/cost-center/products/archive-superseded-manual',
requireAdminPermission('finance.config'),
createJsonHandler((req) => archiveSupersededManualProducts(bodyOf(req)), {
successMessage: '重复人工归一商品已归档',
errorMessage: '归档重复人工归一商品失败',
scope: '[admin/cost-center/products/archive-superseded-manual]',
audit: (_req, data) => ({
action: 'cost_superseded_manual_products_archived',
targetType: 'product_catalog',
targetId: '',
data: data as Record<string, unknown>,
}),
}),
)
router.patch(
'/cost-center/products/:productId',
requireAdminPermission('finance.config'),
@@ -402,16 +367,16 @@ router.post(
)
router.post(
'/cost-center/voucher-products/ensure-rules',
'/cost-center/voucher-products',
requireAdminPermission('finance.config'),
createJsonHandler(() => ensureVoucherChannelRules(), {
successMessage: '卡券渠道规则已补齐',
errorMessage: '补齐卡券渠道规则失败',
scope: '[admin/cost-center/voucher-products/ensure-rules]',
createJsonHandler((req) => upsertAdminVoucherProduct(bodyOf(req), sessionOf(req)), {
successMessage: '卡券 SKU 已保存',
errorMessage: '保存卡券 SKU 失败',
scope: '[admin/cost-center/voucher-products]',
audit: (_req, data) => ({
action: 'voucher_internal_channels_ensured',
targetType: 'internal_sku_channels',
targetId: '',
action: 'voucher_product_upserted',
targetType: 'product_catalog',
targetId: String((data as { product?: { id?: number } })?.product?.id || ''),
data: data as Record<string, unknown>,
}),
}),
+44
View File
@@ -13,6 +13,10 @@ import {
confirmManualFulfillment,
listManualFulfillmentPending,
} from '../../services/finance/manual-fulfillment-service.js'
import {
listOnAccountBills,
reconcileOnAccountBill,
} from '../../services/finance/on-account-bill-service.js'
import {
createJsonHandler,
requireAdminPermission,
@@ -152,4 +156,44 @@ router.post(
}),
)
router.get(
'/finance/on-account-bills',
requireAdminRoles(['admin', 'operator', 'support', 'finance']),
createJsonHandler((req) => listOnAccountBills(req.query as Record<string, unknown>), {
successMessage: 'ok',
errorMessage: '读取统一结算账单失败',
scope: '[admin/finance/on-account-bills]',
}),
)
router.post(
'/finance/on-account-bills/:billId/reconcile',
requireAdminPermission('finance.reconcile'),
createJsonHandler(
(req) => reconcileOnAccountBill(req.params.billId, bodyOf(req), sessionOf(req)),
{
successMessage: '统一结算账单已对账',
errorMessage: '统一结算账单对账失败',
scope: '[admin/finance/on-account-bills/:billId/reconcile]',
audit: (req, data) => {
const result = data as {
bill?: { id?: number; billNo?: string }
amountFen?: number
snapshotId?: number
}
return {
action: 'on_account_bill_reconciled',
targetType: 'on_account_bill',
targetId: String(result.bill?.id || req.params.billId || ''),
data: {
billNo: result.bill?.billNo || '',
amountFen: result.amountFen || 0,
costSnapshotId: result.snapshotId || null,
},
}
},
},
),
)
export default router
@@ -9,6 +9,7 @@ import {
listAdminKuaishouIndustryRefunds,
listAdminKuaishouIndustryShops,
listAdminKuaishouIndustryVouchers,
reissueAndConsumeAdminKuaishouIndustryVoucher,
resendAdminKuaishouIndustryVoucherCode,
reverseAdminKuaishouIndustryVoucher,
} from '../../services/admin/kuaishou-industry-admin-service.js'
@@ -21,6 +22,7 @@ import type {
AdminKuaishouIndustryVoucherCheckAvailableRouteBody,
AdminKuaishouIndustryVoucherConsumeRouteBody,
AdminKuaishouIndustryVoucherDestroyRouteBody,
AdminKuaishouIndustryVoucherReissueConsumeRouteBody,
AdminKuaishouIndustryVoucherResendRouteBody,
AdminKuaishouIndustryVoucherReverseRouteBody,
} from '../../types/admin/route-inputs.js'
@@ -204,6 +206,24 @@ router.post(
),
)
router.post(
'/kuaishou-industry/vouchers/reissue-consume',
requireAdminRoles(['admin', 'operator']),
createJsonHandler(
(req) =>
reissueAndConsumeAdminKuaishouIndustryVoucher(
req.body as AdminKuaishouIndustryVoucherReissueConsumeRouteBody,
),
{
successMessage: '历史券补发并核销已执行',
errorMessage: '历史券补发并核销失败',
scope: '[admin/kuaishou-industry/vouchers/reissue-consume]',
audit: (req, data) =>
buildKuaishouIndustryAudit('kuaishou_industry_voucher_reissue_consume', req.body, data),
},
),
)
router.post(
'/kuaishou-industry/vouchers/destroy',
requireAdminRoles(['admin', 'operator']),
@@ -238,6 +258,13 @@ function buildKuaishouIndustryAudit(action: string, body: unknown, data: unknown
success: Boolean(result.success),
httpStatus: Number(result.httpStatus || 0) || 0,
error: String(result.error || ''),
stage: String(result.stage || ''),
reason: String(result.reason || ''),
validMinutes: Number(result.validMinutes || 0) || 0,
originalValidEndTime: Number(result.originalValidEndTime || 0) || 0,
validEndTime: Number(result.validEndTime || 0) || 0,
sendSuccess: Boolean(result.sendSuccess),
consumeSuccess: Boolean(result.consumeSuccess),
},
}
}
@@ -27,6 +27,7 @@ import { resendKuaishouIndustryVoucherSendCallback } from '../platforms/kuaishou
import {
consumeKuaishouIndustryVoucher,
destroyKuaishouIndustryVoucher,
isKuaishouIndustryVoucherSendCallbackSuccess,
resolveKuaishouIndustryEticketType,
} from '../platforms/kuaishou-industry/voucher-service.js'
import {
@@ -39,6 +40,8 @@ import { attachKuaishouIndustryVoucherToTask } from '../platforms/kuaishou-indus
import type { KuaishouIndustryOpenApiCallResult } from '../platforms/kuaishou-industry/openapi-client.js'
import type { KuaishouIndustryVoucherRow, TaskRow } from '../../types/repository/rows.js'
const historicalReissueInFlight = new Set<string>()
export async function listAdminKuaishouIndustryVouchers(
input: KuaishouIndustryVoucherAdminListQuery = {},
) {
@@ -201,11 +204,204 @@ export async function resendAdminKuaishouIndustryVoucherCode(input: JsonObject =
return {
success: result.success,
response: 'response' in result ? result.response || null : null,
error: result.error || '',
error:
result.error ||
resolveKuaishouOpenApiErrorMessage('response' in result ? result.response : null),
voucher: result.voucher ? mapAdminKuaishouIndustryVoucher(result.voucher) : null,
}
}
/**
* 历史发码失败券的受控补偿入口。
*
* 只允许未使用券进入;先把本地有效期临时改到未来,再复用原券码发码。
* 发码成功后立即核销,避免为了补回调把历史券重新暴露给买家。
*/
export async function reissueAndConsumeAdminKuaishouIndustryVoucher(input: JsonObject = {}) {
const voucher = await resolveRequiredVoucher(input)
const voucherCode = String(voucher.voucher_code || '').trim()
const accountStatus = String(voucher.status || '')
.trim()
.toUpperCase()
const nowMs = Date.now()
const originalValidEndTime = Number(voucher.valid_end_time || 0) || 0
if (accountStatus !== 'UNUSED') {
throw createHttpError('仅未使用电子凭证可以补发并核销', {
statusCode: 409,
errorCode: 'admin_kuaishou_industry_reissue_voucher_not_unused',
})
}
if (isKuaishouIndustryVoucherSendCallbackSuccess(voucher) && originalValidEndTime > nowMs) {
throw createHttpError('该电子凭证已发码成功且仍在有效期,请直接执行手动核销', {
statusCode: 409,
errorCode: 'admin_kuaishou_industry_reissue_voucher_already_active',
})
}
if (historicalReissueInFlight.has(voucherCode)) {
throw createHttpError('该电子凭证正在补发,请勿重复提交', {
statusCode: 409,
errorCode: 'admin_kuaishou_industry_reissue_in_progress',
})
}
const validity = resolveKuaishouIndustryHistoricalReissueWindow(input.validMinutes, nowMs)
const reason = normalizeKuaishouIndustryHistoricalReissueReason(input.reason)
const originalValidStartTime = Number(voucher.valid_start_time || 0) || 0
const validStartTime =
originalValidStartTime > 0 ? Math.min(originalValidStartTime, nowMs - 1_000) : nowMs - 1_000
historicalReissueInFlight.add(voucherCode)
try {
const prepared = await updateKuaishouIndustryVoucherByCode(voucherCode, {
validStartTime,
validEndTime: validity.validEndTime,
sendCallbackStatus: 'pending',
sendCallbackLastError: '',
sendCallbackResponseJson: {},
sendCallbackSentAt: null,
updatedAt: new Date().toISOString(),
})
if (!prepared) {
throw createHttpError('准备历史券补发时未找到电子凭证', {
statusCode: 404,
errorCode: 'admin_kuaishou_industry_reissue_voucher_not_found',
})
}
const sendResult = await resendAdminKuaishouIndustryVoucherCode({
voucherCode,
oid: voucher.oid,
orderId: voucher.oid,
})
if (!sendResult.success) {
return {
success: false,
stage: 'send',
reason,
validMinutes: validity.validMinutes,
originalValidEndTime,
validEndTime: validity.validEndTime,
sendSuccess: false,
consumeSuccess: false,
response: sendResult.response,
error: sendResult.error || '电子凭证发码回调失败',
voucher: sendResult.voucher,
task: null,
}
}
const sentVoucher = await findKuaishouIndustryVoucherByCode(voucherCode, voucher.oid)
if (!sentVoucher || !isKuaishouIndustryVoucherSendCallbackSuccess(sentVoucher)) {
return {
success: false,
stage: 'send',
reason,
validMinutes: validity.validMinutes,
originalValidEndTime,
validEndTime: validity.validEndTime,
sendSuccess: false,
consumeSuccess: false,
response: sendResult.response,
error: '电子凭证发码回调返回成功,但本地状态未更新',
voucher: sentVoucher ? mapAdminKuaishouIndustryVoucher(sentVoucher) : null,
task: null,
}
}
const task = sentVoucher.task_id ? await getTaskById(sentVoucher.task_id) : null
const consumeType = String(input.consumeType || '').trim()
const consumeInput: Parameters<typeof consumeKuaishouIndustryVoucher>[1] = {
source: 'admin_historical_reissue_consume',
token: String(input.token || sentVoucher.token || '').trim(),
consumeTime: Date.now(),
...(consumeType ? { consumeType } : {}),
...(input.serialNum ? { serialNum: String(input.serialNum).trim() } : {}),
...(input.storeName ? { storeName: String(input.storeName).trim() } : {}),
...(input.storeAddress ? { storeAddress: String(input.storeAddress).trim() } : {}),
...(input.expressCode ? { expressCode: String(input.expressCode).trim() } : {}),
...(input.expressNo ? { expressNo: String(input.expressNo).trim() } : {}),
}
if (task) {
consumeInput.task = task
}
const consumeResult = await consumeKuaishouIndustryVoucher(sentVoucher, consumeInput)
if (!consumeResult.ok || !consumeResult.voucher) {
return {
success: false,
stage: 'consume',
reason,
validMinutes: validity.validMinutes,
originalValidEndTime,
validEndTime: validity.validEndTime,
sendSuccess: true,
consumeSuccess: false,
response: sendResult.response,
error: consumeResult.errorMessage || '电子凭证发码成功,但核销失败',
voucher: consumeResult.voucher
? mapAdminKuaishouIndustryVoucher(consumeResult.voucher)
: mapAdminKuaishouIndustryVoucher(sentVoucher),
task: task ? mapTaskReference(task) : null,
}
}
return {
success: true,
stage: 'completed',
reason,
validMinutes: validity.validMinutes,
originalValidEndTime,
validEndTime: validity.validEndTime,
sendSuccess: true,
consumeSuccess: true,
response: sendResult.response,
error: '',
voucher: mapAdminKuaishouIndustryVoucher(consumeResult.voucher),
task: task ? mapTaskReference(task) : null,
}
} finally {
historicalReissueInFlight.delete(voucherCode)
}
}
export function resolveKuaishouIndustryHistoricalReissueWindow(
rawMinutes: unknown,
nowMs = Date.now(),
) {
const validMinutes = Number(rawMinutes)
if (!Number.isInteger(validMinutes) || validMinutes < 1 || validMinutes > 60) {
throw createHttpError('临时有效期必须是 1 到 60 分钟的整数', {
statusCode: 400,
errorCode: 'admin_kuaishou_industry_reissue_valid_minutes_invalid',
})
}
return {
validMinutes,
validEndTime: nowMs + validMinutes * 60_000,
}
}
export function normalizeKuaishouIndustryHistoricalReissueReason(rawReason: unknown) {
const reason = String(rawReason || '').trim()
if (!reason) {
throw createHttpError('补发原因不能为空', {
statusCode: 400,
errorCode: 'admin_kuaishou_industry_reissue_reason_required',
})
}
if (reason.length > 200) {
throw createHttpError('补发原因不能超过 200 个字符', {
statusCode: 400,
errorCode: 'admin_kuaishou_industry_reissue_reason_too_long',
})
}
return reason
}
export async function destroyAdminKuaishouIndustryVoucherByCode(input: JsonObject = {}) {
const voucher = await resolveRequiredVoucher(input)
const checkPayload = buildVoucherOpenApiPayload(input, voucher)
@@ -485,6 +681,14 @@ function mapOpenApiResult(result: KuaishouIndustryOpenApiCallResult) {
}
}
function resolveKuaishouOpenApiErrorMessage(response: unknown) {
if (!response || typeof response !== 'object' || Array.isArray(response)) {
return ''
}
const current = response as Record<string, unknown>
return String(current.error_msg || current.sub_msg || current.message || current.msg || '').trim()
}
function sanitizeOpenApiRequest(request: JsonObject | undefined): JsonObject | null {
if (!request) {
return null
@@ -0,0 +1,76 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
import {
normalizeKuaishouIndustryHistoricalReissueReason,
resolveKuaishouIndustryHistoricalReissueWindow,
} from './kuaishou-industry-admin-service.js'
test('历史券补发窗口只允许 1 到 60 分钟', () => {
const nowMs = 1_800_000_000_000
assert.deepEqual(resolveKuaishouIndustryHistoricalReissueWindow(10, nowMs), {
validMinutes: 10,
validEndTime: nowMs + 10 * 60_000,
})
assert.throws(() => resolveKuaishouIndustryHistoricalReissueWindow(0, nowMs), /1 到 60 分钟/)
assert.throws(() => resolveKuaishouIndustryHistoricalReissueWindow(61, nowMs), /1 到 60 分钟/)
assert.throws(() => resolveKuaishouIndustryHistoricalReissueWindow('1.5', nowMs), /1 到 60 分钟/)
})
test('历史券补发原因必填且限制长度', () => {
assert.equal(
normalizeKuaishouIndustryHistoricalReissueReason(' 历史发码失败补发 '),
'历史发码失败补发',
)
assert.throws(() => normalizeKuaishouIndustryHistoricalReissueReason(' '), /不能为空/)
assert.throws(
() => normalizeKuaishouIndustryHistoricalReissueReason('x'.repeat(201)),
/不能超过 200/,
)
})
test('受控补发必须先重置有效期,再发码,最后立即核销', async () => {
const source = await readFile(
new URL('./kuaishou-industry-admin-service.ts', import.meta.url),
'utf8',
)
const start = source.indexOf(
'export async function reissueAndConsumeAdminKuaishouIndustryVoucher',
)
assert.ok(start > 0, '未找到历史券补发并核销服务')
const body = source.slice(
start,
source.indexOf('\nexport function resolveKuaishouIndustryHistoricalReissueWindow', start),
)
const prepareIndex = body.indexOf('updateKuaishouIndustryVoucherByCode')
const sendIndex = body.indexOf('resendAdminKuaishouIndustryVoucherCode')
const consumeIndex = body.indexOf('consumeKuaishouIndustryVoucher(sentVoucher')
assert.ok(prepareIndex > 0, '必须先更新本地临时有效期')
assert.ok(sendIndex > prepareIndex, '必须先重置有效期再重发发码')
assert.ok(consumeIndex > sendIndex, '必须等发码成功后再执行核销')
assert.ok(body.includes("source: 'admin_historical_reissue_consume'"), '核销来源必须可审计')
assert.ok(body.includes("sendCallbackStatus: 'pending'"), '重发前必须重置发码状态')
assert.ok(body.includes("stage: 'send'"), '发码失败必须返回明确阶段')
assert.ok(body.includes("stage: 'consume'"), '核销失败必须返回明确阶段')
assert.ok(body.includes("stage: 'completed'"), '成功必须返回完成阶段')
})
test('补发并核销路由只允许管理员和运营执行', async () => {
const source = await readFile(
new URL('../../routes/admin/kuaishou-industry.ts', import.meta.url),
'utf8',
)
const routeIndex = source.indexOf("'/kuaishou-industry/vouchers/reissue-consume'")
assert.ok(routeIndex > 0, '未注册历史券补发并核销路由')
const routeBody = source.slice(routeIndex, source.indexOf('\nrouter.post(', routeIndex + 1))
assert.ok(routeBody.includes("requireAdminRoles(['admin', 'operator'])"), '必须限制管理员和运营')
assert.ok(
routeBody.includes("'kuaishou_industry_voucher_reissue_consume'"),
'必须写入独立审计动作',
)
})
@@ -34,28 +34,12 @@ export function productSourceOf(canonicalSku: string): string {
}
/** 商品来源筛选的 SQL 片段:按 canonical_sku 前缀过滤。 */
export function productSourceFilterSql(
paramIndex: number,
column = 'p.canonical_sku',
nameColumn = 'p.canonical_name',
): string {
export function productSourceFilterSql(paramIndex: number, column = 'p.canonical_sku'): string {
return `(
($${paramIndex} = 'affiliate_dash' AND ${column} LIKE 'aff:%')
OR ($${paramIndex} = 'work_order' AND (${column} LIKE 'tpl:%' OR ${column} LIKE 'wo:%'))
OR ($${paramIndex} = 'third_party_voucher' AND ${column} LIKE 'tpv:%')
OR (
$${paramIndex} = 'manual'
AND ${column} LIKE 'man:%'
AND NOT EXISTS (
SELECT 1
FROM internal_sku_channels live_channel
WHERE live_channel.enabled = TRUE
AND live_channel.is_fallback = FALSE
AND live_channel.channel <> 'manual'
AND canonicalize_internal_sku(live_channel.internal_sku)
= canonicalize_internal_sku(${nameColumn})
)
)
OR ($${paramIndex} = 'manual' AND ${column} LIKE 'man:%')
)`
}
@@ -6,6 +6,7 @@
* 2. 成本项统计按 cost_model 分组,不再读 cost_items.carrier_type。
*/
import { query } from '../../db/client.js'
import { productSourceFilterSql } from './cost-center-mappers.js'
import { PROVIDERS, productSourceOf, toNumber } from './cost-center-shared.js'
export type CostCenterScope = 'affiliate_dash' | 'work_order' | 'manual'
@@ -37,25 +38,7 @@ export async function getAdminCostCenterOverview(params: { scope?: string } = {}
SELECT p.id
FROM product_catalog p
WHERE p.status = 'active'
AND (
$1::text IS NULL
OR ($1 = 'affiliate_dash' AND p.canonical_sku LIKE 'aff:%')
OR ($1 = 'work_order' AND (p.canonical_sku LIKE 'tpl:%' OR p.canonical_sku LIKE 'wo:%'))
OR ($1 = 'third_party_voucher' AND p.canonical_sku LIKE 'tpv:%')
OR (
$1 = 'manual'
AND p.canonical_sku LIKE 'man:%'
AND NOT EXISTS (
SELECT 1
FROM internal_sku_channels live_channel
WHERE live_channel.enabled = TRUE
AND live_channel.is_fallback = FALSE
AND live_channel.channel <> 'manual'
AND canonicalize_internal_sku(live_channel.internal_sku)
= canonicalize_internal_sku(p.canonical_name)
)
)
)
AND ($1::text IS NULL OR ${productSourceFilterSql(1)})
),
scoped_cost_models AS (
SELECT CASE $1
@@ -8,7 +8,7 @@ import {
resolveMappingNameKey,
} from './cost-center-shared.js'
import { calculateCostTotal, validateCostVersionScope } from './cost-resolution-service.js'
import { resolveMappingProductId } from './product-catalog-service.js'
import { createAdminProduct, resolveMappingProductId } from './product-catalog-service.js'
import { productSourceFilterSql } from './cost-center-mappers.js'
import {
buildTemplateMappingPlan,
@@ -310,14 +310,11 @@ test('第三方卡券删除:无历史引用硬删除,有历史引用自动
assert.ok(routeSource.includes("'/cost-center/voucher-products/:productId'"))
})
test('人工客服筛选排除已有其他主渠道的旧 man 商品', () => {
test('人工客服筛选保留所有可维护的 man 商品和显式兜底配置', () => {
const sql = productSourceFilterSql(1)
assert.ok(sql.includes(`$1 = 'manual'`))
assert.ok(sql.includes("live_channel.channel <> 'manual'"))
assert.ok(
sql.includes('canonicalize_internal_sku(live_channel.internal_sku)') &&
sql.includes('canonicalize_internal_sku(p.canonical_name)'),
)
assert.ok(sql.includes("LIKE 'man:%'"))
assert.ok(!sql.includes('live_channel'))
})
test('成本中心总览异常统计必须走异常表关联,不能全表扫描订单项子查询', async () => {
@@ -328,6 +325,7 @@ test('成本中心总览异常统计必须走异常表关联,不能全表扫
)
assert.ok(source.includes('JOIN order_items oi ON oi.id = ce.order_item_id'))
assert.ok(!source.includes('ce.order_item_id IN ('))
assert.ok(source.includes('productSourceFilterSql(1)'))
})
test('tpv: 商品来源归为第三方卡券', async () => {
@@ -336,3 +334,29 @@ test('tpv: 商品来源归为第三方卡券', async () => {
assert.equal(productSourceOf('tpv:模拟卡券'), 'third_party_voucher')
assert.equal(productSourceOf('man:模拟人工'), 'manual')
})
test('人工标准商品禁止绕过人工 SKU 配置手工创建', async () => {
await assert.rejects(
createAdminProduct(
{
canonicalSku: 'man:破片手榴弹-兔兔白日梦',
canonicalName: '破片手榴弹-兔兔白日梦',
},
{} as never,
),
/人工 SKU 配置自动创建/,
)
})
test('第三方卡券商品禁止绕过卡券 SKU 配置手工创建', async () => {
await assert.rejects(
createAdminProduct(
{
canonicalSku: 'tpv:11月改名卡',
canonicalName: '11月改名卡',
},
{} as never,
),
/卡券 SKU 配置自动创建/,
)
})
@@ -1,3 +1,5 @@
import type { PoolClient } from 'pg'
import { query } from '../../db/client.js'
import { nowIso } from '../../utils/time.js'
@@ -19,6 +21,7 @@ export const COST_EXCEPTION_REASONS = [
'cost_missing',
'manual_config_missing',
'manual_cost_missing',
'on_account_cost_pending',
'carrier_missing',
] as const
@@ -29,14 +32,16 @@ export type UpsertExceptionInput = {
scopeKey: string
orderItemId: number
detail?: Record<string, unknown>
client?: Pick<PoolClient, 'query'>
}
/** 幂等上浮一条聚合问题:已存在则累加明细并刷新 last_seen。 */
export async function upsertAggregatedException(input: UpsertExceptionInput): Promise<void> {
const now = nowIso()
const detail = JSON.stringify(input.detail || {})
const run = input.client ? input.client.query.bind(input.client) : query
const result = await query<{ id: number }>(
const result = await run<{ id: number }>(
`
INSERT INTO cost_exceptions (
subject_type, subject_id, order_item_id, reason, status, detail_json,
@@ -55,7 +60,7 @@ export async function upsertAggregatedException(input: UpsertExceptionInput): Pr
const exceptionId = result.rows[0]?.id
if (!exceptionId) return
await query(
await run(
`
INSERT INTO cost_exception_items (exception_id, order_item_id, created_at)
VALUES ($1, $2, $3)
@@ -64,7 +69,7 @@ export async function upsertAggregatedException(input: UpsertExceptionInput): Pr
[exceptionId, input.orderItemId, now],
)
await query(
await run(
`
UPDATE cost_exceptions ce
SET item_count = (
@@ -1,84 +0,0 @@
/** 旧人工归一商品清理集成测试(真实 PostgreSQL)。 */
import assert from 'node:assert/strict'
import test from 'node:test'
const TEST_DATABASE_URL = String(
process.env.LEGACY_MANUAL_PRODUCT_CLEANUP_TEST_DATABASE_URL || '',
).trim()
test(
'已被其他主渠道接管的历史 man 商品只归档并修正兜底指向',
{ skip: !TEST_DATABASE_URL ? '未设置 LEGACY_MANUAL_PRODUCT_CLEANUP_TEST_DATABASE_URL' : false },
async () => {
process.env.DATABASE_URL = TEST_DATABASE_URL
const { closeDb, query } = await import('../../db/client.js')
const { archiveSupersededManualProducts } = await import('./product-catalog-service.js')
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`
const internalSku = `清理测试人工商品-${suffix}`
const manSku = `man:${internalSku}`
const liveSku = `aff:cleanup-${suffix}`
let manProductId = 0
let liveProductId = 0
try {
const inserted = await query<{ id: number; canonical_sku: string }>(
`
INSERT INTO product_catalog (
canonical_sku, canonical_name, category, unit_name, status,
attributes_json, created_at, updated_at
) VALUES
($1, $3, '人工客服', '件', 'active', '{"source":"manual_normalization"}'::jsonb, NOW(), NOW()),
($2, $3, '自动发货', '件', 'active', '{}'::jsonb, NOW(), NOW())
RETURNING id, canonical_sku
`,
[manSku, liveSku, internalSku],
)
manProductId = Number(inserted.rows.find((row) => row.canonical_sku === manSku)?.id)
liveProductId = Number(inserted.rows.find((row) => row.canonical_sku === liveSku)?.id)
assert.ok(manProductId > 0)
assert.ok(liveProductId > 0)
await query(
`
INSERT INTO internal_sku_channels (
internal_sku, channel, priority, is_fallback, enabled,
canonical_product_id, config_json, source, created_at, updated_at
) VALUES
($1, 'affiliate_dash', 10, FALSE, TRUE, $3, '{}'::jsonb, 'test', NOW(), NOW()),
($1, 'manual', 90, TRUE, TRUE, $2, '{}'::jsonb, 'test', NOW(), NOW())
`,
[internalSku, manProductId, liveProductId],
)
const preview = await archiveSupersededManualProducts()
assert.equal(preview.candidateCount, 1)
assert.ok(preview.sample.some((row) => Number(row.id) === manProductId))
const result = await archiveSupersededManualProducts({ execute: true })
assert.equal(result.archivedCount, 1)
assert.equal(result.repointedChannelCount, 1)
const products = await query<{ canonical_sku: string; status: string }>(
'SELECT canonical_sku, status FROM product_catalog WHERE id = ANY($1::bigint[])',
[[manProductId, liveProductId]],
)
assert.equal(products.rows.find((row) => row.canonical_sku === manSku)?.status, 'archived')
assert.equal(products.rows.find((row) => row.canonical_sku === liveSku)?.status, 'active')
const fallback = await query<{ canonical_product_id: number }>(
`SELECT canonical_product_id
FROM internal_sku_channels
WHERE internal_sku = $1 AND channel = 'manual'`,
[internalSku],
)
assert.equal(Number(fallback.rows[0]?.canonical_product_id), liveProductId)
} finally {
if (manProductId > 0 || liveProductId > 0) {
await query('DELETE FROM internal_sku_channels WHERE internal_sku = $1', [internalSku])
await query('DELETE FROM product_catalog WHERE id = ANY($1::bigint[])', [
[manProductId, liveProductId],
])
}
await closeDb()
}
},
)
@@ -1,10 +1,15 @@
import type { PoolClient } from 'pg'
import { query, withTransaction } from '../../db/client.js'
import { resolveExceptionsForItemsByReason } from './cost-exception-service.js'
import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
import { formatShanghaiDate, shiftDate } from '../../utils/finance-date-range.js'
import { nowIso } from '../../utils/time.js'
import { upsertInternalSkuChannel } from '../fulfillment/internal-sku-channel-service.js'
import {
upsertInternalSkuChannel,
upsertInternalSkuChannelWithClient,
} from '../fulfillment/internal-sku-channel-service.js'
import { reconcilePendingCarriers } from '../fulfillment/carrier-reconciliation-service.js'
import {
attachFulfillmentConfigurations,
@@ -463,6 +468,26 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) {
const existingId = Number(existing.rows[0]?.id || 0)
let resolvedProductId = canonicalProductId
if (resolvedProductId) {
const product = await client.query<{ canonical_sku: string; status: string }>(
`SELECT canonical_sku, status
FROM product_catalog
WHERE id = $1
LIMIT 1`,
[resolvedProductId],
)
if (
!product.rows[0] ||
product.rows[0].status !== 'active' ||
!String(product.rows[0].canonical_sku || '').startsWith('man:')
) {
throw createHttpError('人工履约配置只能关联有效的 man: 标准商品', {
statusCode: 400,
errorCode: 'manual_config_product_source_invalid',
})
}
}
// 人工配置本身就是该 SKU 的标准商品声明。没有显式关联时自动创建/复用
// man:<内部 SKU>,避免运营再去「标准商品」手工建一次。
if (!resolvedProductId) {
@@ -543,14 +568,14 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) {
now,
],
)
return requiredRow(result.rows[0], '保存人工履约配置失败')
const saved = requiredRow(result.rows[0], '保存人工履约配置失败')
await syncRegistryConfigId(saved.sku_key, saved.id, client)
await upsertManualRegistryConfig(saved, client)
// 标准商品、人工配置和渠道规则必须一起提交,避免保存成功后仍未生效。
await syncManualChannelRule(saved, client)
return saved
})
await syncRegistryConfigId(config.sku_key, config.id)
await upsertManualRegistryConfig(config)
// 同步渠道规则:人工配置就是「这个内部 SKU 走人工渠道」的唯一声明,
// 少了这一步,新配的 SKU 会一直停在待分类。
await syncManualChannelRule(config)
// 配置补全后,失败的 pending 会因 locked=true 无法被定时任务重试,这里主动解锁并重试。
const pendingAssigned = await retryPendingManualItems(config.sku_key)
// 配置保存后立刻回填该 SKU 最近 30 天的订单项,避免必须再点一次「重新匹配」。
@@ -938,26 +963,33 @@ async function attachCostVersionsForManualConfigs() {
}
/** 配置保存/删除后同步登记表上的 config_id。 */
async function syncRegistryConfigId(skuKey: string, configId: number | null) {
await query(
`UPDATE manual_sku_registry
SET config_id = $2, updated_at = $3
WHERE canonicalize_internal_sku(sku_key) = $1`,
[canonicalizeInternalSku(skuKey), configId, nowIso()],
)
async function syncRegistryConfigId(skuKey: string, configId: number | null, client?: PoolClient) {
const sql = `
UPDATE manual_sku_registry
SET config_id = $2, updated_at = $3
WHERE canonicalize_internal_sku(sku_key) = $1
`
const params = [canonicalizeInternalSku(skuKey), configId, nowIso()]
if (client) {
await client.query(sql, params)
return
}
await query(sql, params)
}
/** 配置保存后确保登记表仍有同一 SKU 的记录,兼容旧扫描清单和运维查询。 */
async function upsertManualRegistryConfig(config: {
id: number
sku_key: string
canonical_product_id: number | null
}) {
async function upsertManualRegistryConfig(
config: {
id: number
sku_key: string
canonical_product_id: number | null
},
client?: PoolClient,
) {
const key = canonicalizeInternalSku(config.sku_key)
if (!key) return
const now = nowIso()
await query(
`
const sql = `
INSERT INTO manual_sku_registry (
sku_key, display_name, config_id, created_at, updated_at
)
@@ -976,9 +1008,13 @@ async function upsertManualRegistryConfig(config: {
END,
config_id = EXCLUDED.config_id,
updated_at = EXCLUDED.updated_at
`,
[key, config.id, now, config.canonical_product_id],
)
`
const params = [key, config.id, now, config.canonical_product_id]
if (client) {
await client.query(sql, params)
return
}
await query(sql, params)
}
/** 读取单个 SKU 的配置详情(含平台标签与成本折算)。 */
@@ -1299,29 +1335,32 @@ function requiredRow<T>(row: T | undefined, message: string): T {
/**
* 人工配置 → internal_sku_channels(内部 SKU 精确匹配)。
*/
async function syncManualChannelRule(config: {
id: number
sku_key: string
canonical_product_id: number | null
enabled: boolean
note: string
}): Promise<void> {
async function syncManualChannelRule(
config: {
id: number
sku_key: string
canonical_product_id: number | null
enabled: boolean
note: string
},
client?: PoolClient,
): Promise<void> {
const skuKey = String(config.sku_key || '').trim()
if (!skuKey) return
const otherChannel = await query<{ exists: boolean }>(
`
const otherChannelSql = `
SELECT EXISTS (
SELECT 1 FROM internal_sku_channels
WHERE canonicalize_internal_sku(internal_sku) = $1
AND enabled = TRUE
AND channel <> 'manual'
) AS exists
`,
[skuKey],
)
`
const otherChannel = client
? await client.query<{ exists: boolean }>(otherChannelSql, [skuKey])
: await query<{ exists: boolean }>(otherChannelSql, [skuKey])
const hasOtherChannel = otherChannel.rows[0]?.exists === true
await upsertInternalSkuChannel({
const channelInput = {
internalSku: skuKey,
channel: 'manual',
priority: hasOtherChannel ? 90 : 10,
@@ -1331,5 +1370,10 @@ async function syncManualChannelRule(config: {
source: 'manual_config',
configJson: { manualConfigId: config.id, note: String(config.note || '').slice(0, 200) },
replacePriority: true,
})
} as const
if (client) {
await upsertInternalSkuChannelWithClient(client, channelInput)
return
}
await upsertInternalSkuChannel(channelInput)
}
@@ -17,7 +17,6 @@ import { normalizeMatchText, normalizeSkuSeriesEquivalence } from '../product-ma
import { resolveSkuNameQuantity } from '../product-match/pricing.js'
import { setAdminAffiliateProductPrice } from './affiliate-product-service.js'
import { resolvePriceEffectiveFrom } from './cost-center-normalizers.js'
import { ensureVoucherRuleForSku } from '../fulfillment/channel-assign-service.js'
import { syncCostSnapshotsForItems } from './cost-snapshot-service.js'
import { attachFulfillmentConfigurations } from '../fulfillment/fulfillment-state-service.js'
import {
@@ -319,6 +318,18 @@ export async function listAdminProducts(payload: JsonObject = {}) {
export async function createAdminProduct(payload: JsonObject, _session: AdminSession) {
const canonicalSku = normalizeRequiredText(payload.canonicalSku, '标准 SKU', 64)
if (canonicalSku.startsWith('man:')) {
throw createHttpError('人工商品由人工 SKU 配置自动创建,请从人工 SKU 配置入口新增', {
statusCode: 409,
errorCode: 'cost_manual_product_managed_by_config',
})
}
if (canonicalSku.startsWith('tpv:')) {
throw createHttpError('卡券商品由第三方卡券 SKU 配置自动创建,请从卡券配置入口新增', {
statusCode: 409,
errorCode: 'cost_voucher_product_managed_by_config',
})
}
if (!/^[A-Za-z0-9._:-]+$/.test(canonicalSku)) {
throw createHttpError('标准 SKU 只能包含字母、数字、点、下划线、冒号和短横线', {
statusCode: 400,
@@ -355,10 +366,6 @@ export async function createAdminProduct(payload: JsonObject, _session: AdminSes
errorCode: 'cost_product_sku_exists',
})
}
// 卡券没有载体证据,创建商品时顺手把渠道规则补上,避免新 SKU 停在待分类。
if (canonicalSku.startsWith('tpv:')) {
await ensureVoucherRuleForSku(canonicalSku.slice(4))
}
return { product: mapProduct(inserted) }
}
@@ -586,164 +593,6 @@ export async function deleteAdminVoucherProduct(productId: number | string) {
})
}
/**
* 批量归档旧人工归一商品。
*
* 只处理 attributes_json.source=manual_normalization、当前为 active,
* 且所有关联订单项都已 archived 的商品。只改 status,不删除任何数据。
*/
export async function archiveHistoricalManualProducts(payload: JsonObject = {}) {
const execute = payload.execute === true || payload.execute === 'true'
const candidates = await query<{ id: number; canonical_sku: string; canonical_name: string }>(
`
SELECT p.id, p.canonical_sku, p.canonical_name
FROM product_catalog p
WHERE p.status = 'active'
AND p.canonical_sku LIKE 'man:%'
AND p.attributes_json->>'source' = 'manual_normalization'
AND EXISTS (SELECT 1 FROM order_items oi WHERE oi.canonical_product_id = p.id)
AND NOT EXISTS (
SELECT 1 FROM order_items oi
WHERE oi.canonical_product_id = p.id
AND oi.fulfillment_status <> 'archived'
)
ORDER BY p.id
`,
)
const ids = candidates.rows.map((row) => Number(row.id))
if (!execute || ids.length === 0) {
return {
dryRun: !execute,
candidateCount: ids.length,
archivedCount: 0,
sample: candidates.rows.slice(0, 20),
}
}
const updated = await query(
`UPDATE product_catalog
SET status = 'archived', updated_at = NOW()
WHERE id = ANY($1::bigint[]) AND status = 'active'`,
[ids],
)
return {
dryRun: false,
candidateCount: ids.length,
archivedCount: updated.rowCount || 0,
sample: candidates.rows.slice(0, 20),
}
}
type SupersededManualProductRow = {
id: number
canonical_sku: string
canonical_name: string
primary_channel: string
target_product_id: number
target_product_sku: string
}
/**
* 归档已被其他渠道主规则接管的旧人工归一商品。
*
* `man:` 只表示旧人工来源,不代表当前主渠道。同名内部 SKU 已有自动发货、
* 接单平台或卡券主渠道后,旧 man: 商品不再参与新路由,只需从当前目录归档。
* 历史订单、成本版本和成本快照全部保留;仍指向旧商品的手动兜底改指当前主商品。
*/
export async function archiveSupersededManualProducts(payload: JsonObject = {}) {
const execute = payload.execute === true || payload.execute === 'true'
return withTransaction(async (client) => {
const candidates = await client.query<SupersededManualProductRow>(
`
SELECT
p.id,
p.canonical_sku,
p.canonical_name,
live.channel AS primary_channel,
target.id AS target_product_id,
target.canonical_sku AS target_product_sku
FROM product_catalog p
JOIN LATERAL (
SELECT c.channel, c.canonical_product_id
FROM internal_sku_channels c
WHERE c.enabled = TRUE
AND c.is_fallback = FALSE
AND c.channel <> 'manual'
AND canonicalize_internal_sku(c.internal_sku)
= canonicalize_internal_sku(p.canonical_name)
ORDER BY c.priority, c.channel
LIMIT 1
) live ON TRUE
JOIN product_catalog target
ON target.id = live.canonical_product_id
AND target.status = 'active'
WHERE p.status = 'active'
AND p.canonical_sku LIKE 'man:%'
AND p.attributes_json->>'source' = 'manual_normalization'
ORDER BY p.id
FOR UPDATE OF p
`,
)
const rows = candidates.rows.map((row) => ({
...row,
id: Number(row.id),
target_product_id: Number(row.target_product_id),
}))
if (!execute || rows.length === 0) {
return {
dryRun: !execute,
candidateCount: rows.length,
archivedCount: 0,
repointedChannelCount: 0,
sample: rows.slice(0, 20),
}
}
const repointed = await client.query(
`
UPDATE internal_sku_channels c
SET canonical_product_id = input.target_product_id,
updated_at = NOW()
FROM unnest($1::text[], $2::bigint[], $3::bigint[])
AS input(internal_sku, source_product_id, target_product_id)
WHERE canonicalize_internal_sku(c.internal_sku)
= canonicalize_internal_sku(input.internal_sku)
AND c.channel = 'manual'
AND c.canonical_product_id = input.source_product_id
`,
[
rows.map((row) => row.canonical_name),
rows.map((row) => row.id),
rows.map((row) => row.target_product_id),
],
)
const archived = await client.query(
`
UPDATE product_catalog
SET status = 'archived',
attributes_json = COALESCE(attributes_json, '{}'::jsonb) || $1::jsonb,
updated_at = NOW()
WHERE id = ANY($2::bigint[]) AND status = 'active'
`,
[
JSON.stringify({
legacyManualArchived: true,
legacyManualArchiveReason: 'superseded_by_active_primary_channel',
legacyManualArchivedAt: nowIso(),
}),
rows.map((row) => row.id),
],
)
return {
dryRun: false,
candidateCount: rows.length,
archivedCount: archived.rowCount || 0,
repointedChannelCount: repointed.rowCount || 0,
sample: rows.slice(0, 20),
}
})
}
type MappingProductGroupRow = {
canonical_product_id: number
canonical_sku: string
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
import {
@@ -63,9 +64,11 @@ function item(patch: Record<string, unknown> = {}) {
sku_name: '任意',
normalized_sku_name: null as string | null,
normalized_sku_series: null as string | null,
fulfillment_channel: 'work_order' as string | null,
fulfillment_channel: null as string | null,
canonical_product_id: null as number | null,
work_rule_key: null as string | null,
manual_config_product_id: null as number | null,
manual_config_product_sku: null as string | null,
...patch,
}
}
@@ -123,6 +126,7 @@ test('商品归一:人工渠道直接使用配置里指定的商品', () => {
fulfillment_channel: 'manual',
sku_code: 'unknown',
manual_config_product_id: 44,
manual_config_product_sku: 'man:配置商品',
}),
index,
),
@@ -144,11 +148,65 @@ test('商品归一:第三方卡券按 tpv:SKU 直接命中', () => {
)
})
test('商品归一:第三方卡券缺少 tpv 商品时不能串到同名人工商品', () => {
assert.equal(
resolveProductId(
item({
fulfillment_channel: 'third_party_voucher',
internal_sku: '套装-测试',
}),
index,
),
null,
)
})
test('商品归一:其他已确定渠道也不能借用同名其他来源商品', () => {
for (const fulfillmentChannel of ['manual', 'on_account', 'affiliate_dash', 'work_order']) {
assert.equal(
resolveProductId(
item({
fulfillment_channel: fulfillmentChannel,
sku_code: 'unknown',
internal_sku: '套装-测试',
}),
index,
),
null,
)
}
})
test('商品归一:人工配置也只能挂载 man: 商品', () => {
assert.equal(
resolveProductId(
item({
fulfillment_channel: 'manual',
manual_config_product_id: 99,
manual_config_product_sku: 'aff:错误商品',
}),
index,
),
null,
)
assert.equal(
resolveProductId(
item({
fulfillment_channel: 'on_account',
manual_config_product_id: 99,
manual_config_product_sku: 'man:正确商品',
}),
index,
),
99,
)
})
test('商品归一:中文内部 SKU 命中标准商品,不依赖平台 SKU', () => {
assert.equal(
resolveProductId(
item({
fulfillment_channel: 'affiliate_dash',
fulfillment_channel: null,
sku_code: 'unknown',
internal_sku: '套装-测试',
}),
@@ -178,3 +236,14 @@ test('三方卡券自动建商品时优先使用内部中文 SKU', () => {
assert.equal(voucherCanonicalSku({ manual_sku_key: '', sku_name: '', sku_code: 'x' }), null)
assert.equal(voucherCanonicalSku({}), null)
})
test('商品归一扫描会纠正所有渠道的错误来源商品', async () => {
const source = await readFile(new URL('./product-normalize-service.ts', import.meta.url), 'utf8')
assert.ok(source.includes("current_product.canonical_sku NOT LIKE 'aff:%'"))
assert.ok(source.includes("current_product.canonical_sku NOT LIKE 'man:%'"))
assert.ok(source.includes("current_product.canonical_sku NOT LIKE 'tpl:%'"))
assert.ok(source.includes("current_product.canonical_sku NOT LIKE 'tpv:%'"))
assert.ok(source.includes('canonicalProductId: null'))
assert.ok(source.includes('onlyWhenCanonicalProductDifferent: true'))
})
@@ -3,7 +3,10 @@ import { resolveExceptionsForItems, upsertAggregatedException } from './cost-exc
import { readWatermarkItemId } from '../watermark-store.js'
import { upsertInternalSkuChannel } from '../fulfillment/internal-sku-channel-service.js'
import { canonicalizeInternalSku, containsCjk } from '../fulfillment/internal-sku-resolver.js'
import { attachFulfillmentConfigurations } from '../fulfillment/fulfillment-state-service.js'
import {
attachFulfillmentConfigurations,
transitionFulfillmentState,
} from '../fulfillment/fulfillment-state-service.js'
/**
* 管道 B:商品归一。
@@ -51,8 +54,10 @@ type ItemRow = {
normalized_sku_name: string | null
normalized_sku_series: string | null
fulfillment_channel: string | null
canonical_product_id?: number | null
work_rule_key: string | null
manual_config_product_id: number | null
manual_config_product_sku?: string | null
}
function key(...parts: string[]): string {
@@ -145,6 +150,17 @@ export function voucherCanonicalSku(item: {
return internal ? `tpv:${internal}` : null
}
async function clearMismatchedProduct(item: ItemRow) {
if (!item.fulfillment_channel || !item.canonical_product_id) return
await transitionFulfillmentState({
orderItemId: Number(item.id),
expected: { status: 'assigned' },
patch: { canonicalProductId: null },
eventType: 'product_source_cleared',
reason: `渠道 ${item.fulfillment_channel} 与当前标准商品来源不一致`,
})
}
/**
* 三方卡券没有预先建库时,按内部 SKU 自动建 `tpv:` 商品并补渠道规则。
* 建完后该商品会出现在「商品与成本中心 → 第三方卡券」,运营直接设采购价。
@@ -191,7 +207,7 @@ async function ensureVoucherProductForItem(item: ItemRow): Promise<number | null
await attachFulfillmentConfigurations({
mappings: [{ orderItemId: Number(item.id), canonicalProductId: productId }],
onlyWhenCanonicalProductNull: true,
onlyWhenCanonicalProductDifferent: true,
})
await query(
@@ -225,26 +241,32 @@ export function resolveProductId(item: ItemRow, index: MappingIndex): number | n
const seriesKey = String(item.normalized_sku_series || '').trim()
const internalSku = canonicalizeInternalSku(item.internal_sku || item.manual_sku_key)
// 人工渠道:配置里已经指定商品,直接用,不需要再建映射。
if (item.fulfillment_channel === 'manual' && item.manual_config_product_id) {
return Number(item.manual_config_product_id)
// 已确定渠道必须命中本渠道商品,不能借用同名的其他来源商品。
if (item.fulfillment_channel === 'manual' || item.fulfillment_channel === 'on_account') {
return item.manual_config_product_id &&
String(item.manual_config_product_sku || '').startsWith('man:')
? Number(item.manual_config_product_id)
: null
}
// 接单平台:模板即商品来源(tpl:<rule_key>),无需渠道映射。
if (item.fulfillment_channel === 'work_order' && item.work_rule_key) {
if (item.fulfillment_channel === 'work_order') {
if (!item.work_rule_key) return null
const templateProduct = index.templateRules.get(String(item.work_rule_key).trim())
if (templateProduct) return templateProduct
return templateProduct || null
}
if (item.fulfillment_channel === 'affiliate_dash' && skuCode) {
const aff = index.affSkus.get(skuCode)
if (aff) return aff
if (item.fulfillment_channel === 'affiliate_dash') {
return skuCode ? index.affSkus.get(skuCode) || null : null
}
// 第三方卡券:与自动发货同构,商品编码 tpv:<sku>。
if (item.fulfillment_channel === 'third_party_voucher' && internalSku) {
const voucher = index.voucherSkus.get(internalSku)
if (voucher) return voucher
return voucher || null
}
if (item.fulfillment_channel === 'third_party_voucher') {
return null
}
// 中文内部 SKU 是标准商品键;平台 SKU 映射只作为历史数据兜底。
@@ -322,15 +344,52 @@ export async function normalizeOrderItemProducts(
oi.normalized_sku_name,
oi.normalized_sku_series,
oi.fulfillment_channel,
oi.canonical_product_id,
wpr.rule_key AS work_rule_key,
cfg.canonical_product_id AS manual_config_product_id
cfg.canonical_product_id AS manual_config_product_id,
cfg_product.canonical_sku AS manual_config_product_sku
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
LEFT JOIN work_orders wo ON wo.order_item_id = oi.id AND wo.status <> 'cancelled'
LEFT JOIN work_product_rules wpr ON wpr.id = wo.product_rule_id
LEFT JOIN manual_fulfillment_configs cfg ON cfg.id = oi.fulfillment_config_id
LEFT JOIN product_catalog cfg_product ON cfg_product.id = cfg.canonical_product_id
LEFT JOIN product_catalog current_product ON current_product.id = oi.canonical_product_id
WHERE oi.fulfillment_status = 'assigned'
AND oi.canonical_product_id IS NULL
AND (
oi.canonical_product_id IS NULL
OR (
oi.fulfillment_channel = 'third_party_voucher'
AND (
current_product.id IS NULL
OR current_product.canonical_sku NOT LIKE 'tpv:%'
)
)
OR (
oi.fulfillment_channel = 'affiliate_dash'
AND (
current_product.id IS NULL
OR current_product.canonical_sku NOT LIKE 'aff:%'
)
)
OR (
oi.fulfillment_channel IN ('manual', 'on_account')
AND (
current_product.id IS NULL
OR current_product.canonical_sku NOT LIKE 'man:%'
)
)
OR (
oi.fulfillment_channel = 'work_order'
AND (
current_product.id IS NULL
OR (
current_product.canonical_sku NOT LIKE 'tpl:%'
AND current_product.canonical_sku NOT LIKE 'wo:%'
)
)
)
)
AND oi.id > $1
ORDER BY oi.id
LIMIT $2
@@ -356,6 +415,7 @@ export async function normalizeOrderItemProducts(
}
if (!productId) {
result.unmatched += 1
await clearMismatchedProduct(item)
await upsertAggregatedException({
reason: 'product_mapping_missing',
scopeKey: `product_mapping_missing|-|-|${canonicalizeInternalSku(item.internal_sku || item.manual_sku_key || item.sku_code)}|${item.fulfillment_channel || '-'}`,
@@ -375,7 +435,7 @@ export async function normalizeOrderItemProducts(
canonicalProductId: row.productId,
})),
expected: { status: 'assigned' },
onlyWhenCanonicalProductNull: true,
onlyWhenCanonicalProductDifferent: true,
})
await resolveExceptionsForItems(attached.map((row) => Number(row.id)))
}
@@ -474,15 +534,52 @@ export async function normalizeProductsForItems(itemIds: number[]): Promise<{
oi.normalized_sku_name,
oi.normalized_sku_series,
oi.fulfillment_channel,
oi.canonical_product_id,
wpr.rule_key AS work_rule_key,
cfg.canonical_product_id AS manual_config_product_id
cfg.canonical_product_id AS manual_config_product_id,
cfg_product.canonical_sku AS manual_config_product_sku
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
LEFT JOIN work_orders wo ON wo.order_item_id = oi.id AND wo.status <> 'cancelled'
LEFT JOIN work_product_rules wpr ON wpr.id = wo.product_rule_id
LEFT JOIN manual_fulfillment_configs cfg ON cfg.id = oi.fulfillment_config_id
LEFT JOIN product_catalog cfg_product ON cfg_product.id = cfg.canonical_product_id
LEFT JOIN product_catalog current_product ON current_product.id = oi.canonical_product_id
WHERE oi.fulfillment_status = 'assigned'
AND oi.canonical_product_id IS NULL
AND (
oi.canonical_product_id IS NULL
OR (
oi.fulfillment_channel = 'third_party_voucher'
AND (
current_product.id IS NULL
OR current_product.canonical_sku NOT LIKE 'tpv:%'
)
)
OR (
oi.fulfillment_channel = 'affiliate_dash'
AND (
current_product.id IS NULL
OR current_product.canonical_sku NOT LIKE 'aff:%'
)
)
OR (
oi.fulfillment_channel IN ('manual', 'on_account')
AND (
current_product.id IS NULL
OR current_product.canonical_sku NOT LIKE 'man:%'
)
)
OR (
oi.fulfillment_channel = 'work_order'
AND (
current_product.id IS NULL
OR (
current_product.canonical_sku NOT LIKE 'tpl:%'
AND current_product.canonical_sku NOT LIKE 'wo:%'
)
)
)
)
AND oi.id = ANY($1::bigint[])
`,
[ids],
@@ -496,6 +593,7 @@ export async function normalizeProductsForItems(itemIds: number[]): Promise<{
}
if (!productId) {
unmatched += 1
await clearMismatchedProduct(item)
await upsertAggregatedException({
reason: 'product_mapping_missing',
scopeKey: `product_mapping_missing|-|-|${canonicalizeInternalSku(item.internal_sku || item.manual_sku_key || item.sku_code)}|${item.fulfillment_channel || '-'}`,
@@ -513,7 +611,7 @@ export async function normalizeProductsForItems(itemIds: number[]): Promise<{
canonicalProductId: row.productId,
})),
expected: { status: 'assigned' },
onlyWhenCanonicalProductNull: true,
onlyWhenCanonicalProductDifferent: true,
})
await resolveExceptionsForItems(attached.map((row) => Number(row.id)))
return { matched: attached.length, unmatched }
@@ -0,0 +1,35 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
test('卡券 SKU 一个入口完成商品、规则、成本版本和订单回填', async () => {
const service = await readFile(new URL('./voucher-product-service.ts', import.meta.url), 'utf8')
assert.ok(service.includes('withTransaction('))
assert.ok(service.includes("channel: 'third_party_voucher'"))
assert.ok(service.includes('sys_third_party_voucher_cost'))
assert.ok(service.includes('UPDATE order_items oi'))
assert.ok(service.includes('syncCostSnapshotsForItems'))
assert.ok(service.includes('refreshFactsForDates'))
assert.ok(service.includes("resolveExceptionsForItemsByReason(result.itemIds, 'cost_missing')"))
})
test('第三方卡券修复迁移和归一逻辑都禁止跨来源商品', async () => {
const [migration, normalizer, route] = await Promise.all([
readFile(
new URL('../../db/migrations/143_repair_third_party_voucher_products.sql', import.meta.url),
'utf8',
),
readFile(new URL('./product-normalize-service.ts', import.meta.url), 'utf8'),
readFile(new URL('../../routes/admin/cost-center-catalog.ts', import.meta.url), 'utf8'),
])
assert.ok(migration.includes('tmp_repair_voucher_items'))
assert.ok(migration.includes('current_product.canonical_sku IS DISTINCT FROM'))
assert.ok(migration.includes("product.canonical_sku = 'tpv:' || item.internal_sku"))
assert.ok(migration.includes("channel.channel <> 'third_party_voucher'"))
assert.ok(normalizer.includes('return voucher || null'))
assert.ok(normalizer.includes('onlyWhenCanonicalProductDifferent: true'))
assert.ok(!route.includes('/voucher-products/ensure-rules'))
assert.ok(route.includes("'/cost-center/voucher-products'"))
})
@@ -0,0 +1,321 @@
import { query, withTransaction } from '../../db/client.js'
import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
import { nowIso } from '../../utils/time.js'
import type { AdminSession } from '../admin/admin-auth-service.js'
import { orderItemBasisDates, refreshFactsForDates } from '../finance/refresh-facts.js'
import { upsertInternalSkuChannelWithClient } from '../fulfillment/internal-sku-channel-service.js'
import { canonicalizeInternalSku, containsCjk } from '../fulfillment/internal-sku-resolver.js'
import { resolvePriceEffectiveFrom } from './cost-center-normalizers.js'
import {
resolveExceptionsForItemsByReason,
resolveOrphanCostExceptions,
} from './cost-exception-service.js'
import { mapProduct, type ProductRow } from './cost-center-shared.js'
import { syncCostSnapshotsForItems } from './cost-snapshot-service.js'
type VoucherProductItemRow = {
order_item_id: number
basis_date: string
}
/**
* 新增或更新三方卡券 SKU。
*
* 一个入口同时保证 tpv 商品、卡券主渠道规则、采购成本版本和订单商品关联,
* 避免运营跨页面手工建商品后再补规则。
*/
export async function upsertAdminVoucherProduct(payload: JsonObject, session: AdminSession) {
const skuKey = canonicalizeInternalSku(
payload.skuKey ?? payload.sku_key ?? payload.internalSku ?? payload.internal_sku,
)
if (!skuKey || !containsCjk(skuKey)) {
throw createHttpError('卡券内部 SKU 必须包含中文', {
statusCode: 400,
errorCode: 'voucher_product_sku_required',
})
}
const canonicalSku = `tpv:${skuKey}`
if (canonicalSku.length > 64) {
throw createHttpError('内部 SKU 过长,无法生成卡券商品编码', {
statusCode: 400,
errorCode: 'voucher_product_sku_too_long',
})
}
const amountFen = Number(payload.amountFen ?? payload.amount_fen)
if (!Number.isFinite(amountFen) || amountFen <= 0 || !Number.isInteger(amountFen)) {
throw createHttpError('卡券采购价必须是大于 0 的整数分', {
statusCode: 400,
errorCode: 'voucher_product_amount_invalid',
})
}
const now = nowIso()
const requestedEffectiveFrom = payload.effectiveFrom ?? payload.effective_from
const initialEffectiveFrom = resolvePriceEffectiveFrom(payload)
const result = await withTransaction(async (client) => {
const productResult = await client.query<ProductRow>(
`
INSERT INTO product_catalog (
canonical_sku, canonical_name, category, unit_name, status,
attributes_json, created_at, updated_at
) VALUES (
$1, $2, '卡券', '张', 'active',
$3::jsonb, $4, $4
)
ON CONFLICT (canonical_sku) DO UPDATE SET
status = 'active',
canonical_name = EXCLUDED.canonical_name,
attributes_json = product_catalog.attributes_json || EXCLUDED.attributes_json,
updated_at = EXCLUDED.updated_at
RETURNING *
`,
[
canonicalSku,
skuKey,
JSON.stringify({
source: 'third_party_voucher',
auto_created: true,
managed_by_voucher_config: true,
}),
now,
],
)
const product = productResult.rows[0]
const productId = Number(product?.id || 0)
if (!product || !productId) {
throw createHttpError('卡券标准商品保存失败', { statusCode: 500 })
}
await client.query(
`
INSERT INTO product_channel_capabilities (
product_id, channel, priority, is_fallback, enabled, source, created_at, updated_at
) VALUES ($1, 'third_party_voucher', 10, FALSE, TRUE, 'voucher', $2, $2)
ON CONFLICT (product_id, channel) DO UPDATE SET
priority = 10,
is_fallback = FALSE,
enabled = TRUE,
source = 'voucher',
updated_at = EXCLUDED.updated_at
`,
[productId, now],
)
await upsertInternalSkuChannelWithClient(client, {
internalSku: skuKey,
channel: 'third_party_voucher',
priority: 10,
isFallback: false,
enabled: true,
canonicalProductId: productId,
configJson: { voucherSku: skuKey },
source: 'voucher',
note: String(payload.note || '').slice(0, 300),
adminUserId: session.userId,
replacePriority: true,
})
const repairedItems = await client.query<VoucherProductItemRow>(
`
UPDATE order_items oi
SET canonical_product_id = $2,
updated_at = $3
FROM orders o
WHERE o.id = oi.order_id
AND oi.fulfillment_channel = 'third_party_voucher'
AND resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) = $1
AND oi.canonical_product_id IS DISTINCT FROM $2
RETURNING
oi.id AS order_item_id,
(COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date::text
AS basis_date
`,
[skuKey, productId, now],
)
const itemIds = repairedItems.rows.map((row) => Number(row.order_item_id))
if (itemIds.length > 0) {
await client.query(
`UPDATE cost_snapshots
SET canonical_product_id = $2
WHERE order_item_id = ANY($1::bigint[])
AND superseded_at IS NULL AND reversed_at IS NULL`,
[itemIds, productId],
)
}
const costItemResult = await client.query<{ id: number }>(
`SELECT id FROM cost_items
WHERE code = 'sys_third_party_voucher_cost' AND status = 'active'
LIMIT 1`,
)
const costItemId = Number(costItemResult.rows[0]?.id || 0)
if (!costItemId) {
throw createHttpError('未找到第三方卡券采购成本项', {
statusCode: 500,
errorCode: 'voucher_cost_item_missing',
})
}
let effectiveFrom = initialEffectiveFrom
if (!requestedEffectiveFrom && repairedItems.rows.length > 0) {
const missingDates = await client.query<{ basis_date: string }>(
`
SELECT MIN(
(COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
)::text AS basis_date
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE oi.id = ANY($1::bigint[])
AND NOT EXISTS (
SELECT 1 FROM cost_snapshots cs
WHERE cs.order_item_id = oi.id
AND cs.cost_item_id = $2
AND cs.superseded_at IS NULL AND cs.reversed_at IS NULL
)
`,
[itemIds, costItemId],
)
const earliestDate = String(missingDates.rows[0]?.basis_date || '').trim()
if (earliestDate) effectiveFrom = `${earliestDate}T00:00:00+08:00`
}
const existingResult = await client.query<{
id: number
amount_fen: number
effective_from: string
}>(
`SELECT id, amount_fen, effective_from
FROM cost_versions
WHERE cost_item_id = $1
AND canonical_product_id = $2
AND status = 'active'
ORDER BY effective_from DESC, id DESC
LIMIT 1`,
[costItemId, productId],
)
const existing = existingResult.rows[0]
const targetTime = Date.parse(effectiveFrom)
let costVersionId = Number(existing?.id || 0)
if (!existing || targetTime < Date.parse(existing.effective_from)) {
if (existing) {
await client.query(
`UPDATE cost_versions
SET status = 'retired', updated_at = $1
WHERE id = $2`,
[now, existing.id],
)
}
const inserted = await client.query<{ id: number }>(
`INSERT INTO cost_versions (
cost_item_id, scope_type, provider, shop_id, canonical_product_id,
work_product_rule_id, amount_fen, rate_bps, effective_from, effective_to,
source, status, change_reason, created_by_admin_user_id,
approved_by_admin_user_id, created_at, updated_at
) VALUES (
$1, 'product', '', '', $2,
NULL, $3, 0, $4, NULL,
'manual', 'active', $5, $6,
$6, $7, $7
)
RETURNING id`,
[
costItemId,
productId,
amountFen,
effectiveFrom,
String(payload.changeReason || '第三方卡券 SKU 新增或采购价调整'),
session.userId,
now,
],
)
costVersionId = Number(inserted.rows[0]?.id || 0)
} else if (targetTime === Date.parse(existing.effective_from)) {
await client.query(
`UPDATE cost_versions
SET amount_fen = $1,
change_reason = $2,
updated_at = $3
WHERE id = $4`,
[
amountFen,
String(payload.changeReason || '第三方卡券 SKU 新增或采购价调整'),
now,
existing.id,
],
)
} else {
await client.query(
`UPDATE cost_versions
SET status = 'retired', effective_to = $1, updated_at = $2
WHERE id = $3`,
[effectiveFrom, now, existing.id],
)
const inserted = await client.query<{ id: number }>(
`INSERT INTO cost_versions (
cost_item_id, scope_type, provider, shop_id, canonical_product_id,
work_product_rule_id, amount_fen, rate_bps, effective_from, effective_to,
source, status, change_reason, created_by_admin_user_id,
approved_by_admin_user_id, created_at, updated_at
) VALUES (
$1, 'product', '', '', $2,
NULL, $3, 0, $4, NULL,
'manual', 'active', $5, $6,
$6, $7, $7
)
RETURNING id`,
[
costItemId,
productId,
amountFen,
effectiveFrom,
String(payload.changeReason || '第三方卡券 SKU 新增或采购价调整'),
session.userId,
now,
],
)
costVersionId = Number(inserted.rows[0]?.id || 0)
}
if (!costVersionId) {
throw createHttpError('卡券采购成本版本保存失败', { statusCode: 500 })
}
return {
product: mapProduct(product),
itemIds,
dates: [...new Set(repairedItems.rows.map((row) => row.basis_date).filter(Boolean))],
costVersionId,
effectiveFrom,
amountFen,
}
})
let snapshotResult = { created: 0, superseded: 0, missing: 0, affectedDates: [] as string[] }
if (result.itemIds.length > 0) {
snapshotResult = await syncCostSnapshotsForItems(result.itemIds).catch(() => snapshotResult)
await resolveExceptionsForItemsByReason(result.itemIds, 'product_mapping_missing').catch(
() => undefined,
)
await resolveExceptionsForItemsByReason(result.itemIds, 'cost_missing').catch(() => undefined)
}
const affectedDates = [
...new Set([
...result.dates,
...snapshotResult.affectedDates,
...(await orderItemBasisDates(result.itemIds).catch(() => [])),
]),
]
await refreshFactsForDates(affectedDates).catch(() => undefined)
await resolveOrphanCostExceptions().catch(() => undefined)
return {
...result,
linkedItemCount: result.itemIds.length,
snapshotCreatedCount: snapshotResult.created,
refreshedDays: affectedDates.length,
}
}
@@ -115,6 +115,14 @@ test('人工确认前必须自动匹配履约配置,配置保存/定时任务
configSource.includes('dateFrom: shiftDate(today, -29)'),
'保存人工 SKU 配置后必须立即回填最近 30 天订单项',
)
const saveStart = configSource.indexOf('export async function saveManualFulfillmentConfig')
const saveEnd = configSource.indexOf('async function retryPendingManualItems', saveStart)
const saveBody = configSource.slice(saveStart, saveEnd)
assert.ok(
saveBody.indexOf('withTransaction(') <
saveBody.indexOf('await syncManualChannelRule(saved, client)'),
'标准商品、人工配置和人工渠道规则必须同一事务提交',
)
assert.ok(
jobSource.includes("fulfillment_channel = 'manual'") &&
jobSource.includes('fulfillment_config_id'),
@@ -157,6 +165,15 @@ test('人工确认前必须自动匹配履约配置,配置保存/定时任务
)
})
test('统一结算金额未知时只生成待对账账单,不写 0 元实际成本', async () => {
const source = await readFile(new URL('./manual-fulfillment-service.ts', import.meta.url), 'utf8')
assert.ok(source.includes("platformKey === 'on_account' && costFen <= 0"))
assert.ok(source.includes('if (!onAccountCostPending)'))
assert.ok(source.includes("reason: 'on_account_cost_pending'"))
assert.ok(source.includes('costPending: onAccountCostPending'))
})
test('人工成本缺失由管道按配置与快照生成并收口', async () => {
const [pipelineSource, manualSource] = await Promise.all([
readFile(new URL('../scheduler/finance-pipeline-jobs.ts', import.meta.url), 'utf8'),
@@ -21,6 +21,7 @@ import {
attachFulfillmentConfigurationsWithExecutor,
transitionFulfillmentStateWithExecutor,
} from '../fulfillment/fulfillment-state-service.js'
import { upsertAggregatedException } from '../cost-center/cost-exception-service.js'
import {
calculateManualCostFen,
normalizeManualCreditQuantity,
@@ -411,6 +412,20 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
errorCode: 'manual_fulfillment_platform_required',
})
}
if (selectedChannel.platformKey === 'on_account') {
const existingBill = await client.query<{ id: number }>(
`SELECT id FROM on_account_bills
WHERE order_item_id = $1 AND status IN ('pending', 'reconciled')
LIMIT 1`,
[orderItemId],
)
if (existingBill.rows[0]) {
throw createHttpError('该订单项已生成统一结算账单,不能重复操作', {
statusCode: 409,
errorCode: 'manual_fulfillment_on_account_already_exists',
})
}
}
// 锁内复查:并发/双击的第二个请求会等锁,醒来后看到快照直接拒绝,不会重复扣款。
const existing = await client.query<{ id: number }>(
@@ -447,6 +462,7 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
providedCostFen,
})
const costFen = costPlan.costFen
const onAccountCostPending = platformKey === 'on_account' && costFen <= 0
const payeeName = optionalText(payload.payeeName ?? payload.payee_name, 120)
const note = optionalText(payload.note, 300)
const fulfillmentReference = buildManualFulfillmentReference({
@@ -455,16 +471,8 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
orderItemId,
})
// 先把成本项/版本解析完再扣钱:版本缺失不能发生在扣款之后。
const costItemId = await resolveManualCostItemIdWithClient(client)
const versionId = await resolveCostVersionIdWithClient(client, costItemId, row, now)
if (!versionId) {
throw createHttpError('该成本项没有可用的成本版本,请先在成本中心配置后重试', {
statusCode: 409,
errorCode: 'manual_fulfillment_cost_version_missing',
})
}
// 扣款前先确定最终成本模式。统一结算未填金额时只生成待对账账单,
// 不写 0 元 actual 成本快照,避免利润被虚增。
let fundExpenseId: number | null = null
let creditEntryId: number | null = null
let balanceAfter: number | null = null
@@ -522,11 +530,22 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
subjectSource = 'platform_credit'
}
// subject_id 固定为 order_item_id:不再与 manual_expenses.id /
// platform_credit_entries.id 混用,避免两张表序列撞车。
// subject_type 仍为 manual_case,资金流水只放在 metadata 里。
const snapshot = await client.query<{ id: number }>(
`
let snapshotId: number | null = null
if (!onAccountCostPending) {
const costItemId = await resolveManualCostItemIdWithClient(client)
const versionId = await resolveCostVersionIdWithClient(client, costItemId, row, now)
if (!versionId) {
throw createHttpError('该成本项没有可用的成本版本,请先在成本中心配置后重试', {
statusCode: 409,
errorCode: 'manual_fulfillment_cost_version_missing',
})
}
// subject_id 固定为 order_item_id:不再与 manual_expenses.id /
// platform_credit_entries.id 混用,资金流水只放在 metadata 里。
// subject_type 仍为 manual_case。
const snapshot = await client.query<{ id: number }>(
`
INSERT INTO cost_snapshots (
subject_type, subject_id, order_id, order_item_id, work_order_id,
cost_item_id, cost_version_id, canonical_product_id,
@@ -540,50 +559,51 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
)
ON CONFLICT DO NOTHING
RETURNING id
`,
[
orderItemId,
row.order_id_resolved,
row.work_order_id,
costItemId,
versionId,
row.canonical_product_id,
costFen,
JSON.stringify({
source: 'manual_fulfillment_confirm',
subjectSource,
configId: Number(row.config_id),
platformKey,
platformLabel: PLATFORM_LABELS[platformKey] || platformKey,
creditOwnerAdminUserId:
platformKey === 'fund_cash'
? null
: Number(payload.creditOwnerAdminUserId || 0) || null,
creditQuantity,
creditUnitPriceFen: unitPriceFen,
costMode: costPlan.costMode,
`,
[
orderItemId,
row.order_id_resolved,
row.work_order_id,
costItemId,
versionId,
row.canonical_product_id,
costFen,
fundExpenseId,
creditEntryId,
balanceAfter,
payeeName,
note,
fulfilledBy: {
userId: Number(session.userId || 0),
displayName: String(session.displayName || ''),
role: String(session.role || ''),
},
}),
now,
],
)
const snapshotId = snapshot.rows[0]?.id ?? null
if (!snapshotId) {
// 订单项唯一索引兜底:任何竞态都会走到这里,抛错回滚本次扣款。
throw createHttpError('该订单项已有人工成本快照,不能重复录入', {
statusCode: 409,
errorCode: 'manual_fulfillment_snapshot_conflict',
})
JSON.stringify({
source: 'manual_fulfillment_confirm',
subjectSource,
configId: Number(row.config_id),
platformKey,
platformLabel: PLATFORM_LABELS[platformKey] || platformKey,
creditOwnerAdminUserId:
platformKey === 'fund_cash'
? null
: Number(payload.creditOwnerAdminUserId || 0) || null,
creditQuantity,
creditUnitPriceFen: unitPriceFen,
costMode: costPlan.costMode,
costFen,
fundExpenseId,
creditEntryId,
balanceAfter,
payeeName,
note,
fulfilledBy: {
userId: Number(session.userId || 0),
displayName: String(session.displayName || ''),
role: String(session.role || ''),
},
}),
now,
],
)
snapshotId = snapshot.rows[0]?.id ?? null
if (!snapshotId) {
// 订单项唯一索引兜底:任何竞态都会走到这里,抛错回滚本次扣款。
throw createHttpError('该订单项已有人工成本快照,不能重复录入', {
statusCode: 409,
errorCode: 'manual_fulfillment_snapshot_conflict',
})
}
}
if (platformKey === 'on_account') {
@@ -641,30 +661,46 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
reason: platformKey,
})
// 人工缺成本异常立即收口,不等下一轮定时任务。
await client.query(
`UPDATE cost_exceptions
SET status = 'resolved', resolved_at = $2
WHERE subject_type = 'order_item'
AND subject_id = $1
AND reason = 'manual_cost_missing'
AND status = 'open'`,
[orderItemId, now],
)
if (onAccountCostPending) {
await upsertAggregatedException({
reason: 'on_account_cost_pending',
scopeKey: `on_account_cost_pending|-|-|${row.internal_sku}|on_account`,
orderItemId,
detail: {
billId: onAccountBillId,
internalSku: row.internal_sku,
platformOrderId: row.platform_order_id,
},
client,
})
} else {
// 人工缺成本异常立即收口,不等下一轮定时任务。
await client.query(
`UPDATE cost_exceptions
SET status = 'resolved', resolved_at = $2
WHERE subject_type = 'order_item'
AND subject_id = $1
AND reason = 'manual_cost_missing'
AND status = 'open'`,
[orderItemId, now],
)
}
// v2 聚合异常:删除本条明细,没人再引用的异常头自动关闭。
await client.query(`DELETE FROM cost_exception_items WHERE order_item_id = $1`, [orderItemId])
await client.query(
`
UPDATE cost_exceptions ce
SET status = 'resolved', resolved_at = $1, item_count = 0, last_seen_at = $1
WHERE ce.status <> 'resolved'
AND NOT EXISTS (
SELECT 1 FROM cost_exception_items i WHERE i.exception_id = ce.id
)
`,
[now],
)
if (!onAccountCostPending) {
// v2 聚合异常:成本已经落账后,删除本条明细并自动关闭空异常头。
await client.query(`DELETE FROM cost_exception_items WHERE order_item_id = $1`, [orderItemId])
await client.query(
`
UPDATE cost_exceptions ce
SET status = 'resolved', resolved_at = $1, item_count = 0, last_seen_at = $1
WHERE ce.status <> 'resolved'
AND NOT EXISTS (
SELECT 1 FROM cost_exception_items i WHERE i.exception_id = ce.id
)
`,
[now],
)
}
return {
orderItemId,
@@ -678,6 +714,7 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
creditEntryId,
balanceAfter,
onAccountBillId,
costPending: onAccountCostPending,
}
})
@@ -693,7 +730,7 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
* 事实任务默认只回看最近几天,确认一笔较早的订单时不会自动覆盖,
* 所以这里按该订单自己的业务日期精确重算。
*/
async function refreshFactsForOrderItem(orderItemId: number): Promise<void> {
export async function refreshFactsForOrderItem(orderItemId: number): Promise<void> {
try {
const basis = await query<{ stat_date: string | null }>(
`
@@ -841,7 +878,7 @@ async function matchManualFulfillmentConfigWithClient(client: PoolClient, orderI
return true
}
async function resolveManualCostItemIdWithClient(client: PoolClient) {
export async function resolveManualCostItemIdWithClient(client: PoolClient) {
const result = await client.query<{ id: number }>(
`SELECT id FROM cost_items WHERE code = 'sys_manual_service' AND status = 'active' LIMIT 1`,
)
@@ -855,7 +892,7 @@ async function resolveManualCostItemIdWithClient(client: PoolClient) {
return id
}
async function resolveCostVersionIdWithClient(
export async function resolveCostVersionIdWithClient(
client: PoolClient,
costItemId: number,
row: {
@@ -0,0 +1,14 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
test('统一结算对账用实际金额替代成本快照并重算事实', async () => {
const source = await readFile(new URL('./on-account-bill-service.ts', import.meta.url), 'utf8')
assert.ok(source.includes('withTransaction('))
assert.ok(source.includes("status = 'reconciled'"))
assert.ok(source.includes('SET superseded_at = $2'))
assert.ok(source.includes("source: 'on_account_reconcile'"))
assert.ok(source.includes("'on_account_cost_pending'"))
assert.ok(source.includes('refreshFactsForOrderItem'))
})
@@ -0,0 +1,306 @@
import { query, withTransaction } from '../../db/client.js'
import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
import { nowIso } from '../../utils/time.js'
import type { AdminSession } from '../admin/admin-auth-service.js'
import { resolveExceptionsForItemsByReason } from '../cost-center/cost-exception-service.js'
import {
refreshFactsForOrderItem,
resolveCostVersionIdWithClient,
resolveManualCostItemIdWithClient,
} from './manual-fulfillment-service.js'
type OnAccountBillRow = {
id: number
bill_no: string
order_id: number
order_item_id: number
sku_key: string
canonical_product_id: number | null
platform_order_id: string
amount_fen: string | number
status: 'pending' | 'reconciled' | 'void'
payee_name: string
note: string
metadata_json: Record<string, unknown>
created_by_admin_user_id: number | null
created_at: string
updated_at: string
reconciled_at: string | null
provider?: string
shop_id?: string
sku_name?: string
revenue_amount_fen?: string | number
}
function mapBill(row: OnAccountBillRow) {
return {
id: Number(row.id),
billNo: row.bill_no,
orderId: Number(row.order_id),
orderItemId: Number(row.order_item_id),
skuKey: row.sku_key,
skuName: row.sku_name || row.sku_key,
canonicalProductId: row.canonical_product_id ? Number(row.canonical_product_id) : null,
platformOrderId: row.platform_order_id,
provider: row.provider || '',
shopId: row.shop_id || '',
revenueFen: Number(row.revenue_amount_fen || 0),
amountFen: Number(row.amount_fen || 0),
status: row.status,
payeeName: row.payee_name || '',
note: row.note || '',
metadata: row.metadata_json || {},
createdByAdminUserId: row.created_by_admin_user_id
? Number(row.created_by_admin_user_id)
: null,
createdAt: row.created_at,
updatedAt: row.updated_at,
reconciledAt: row.reconciled_at,
}
}
const BILL_SELECT = `
SELECT
bill.*,
order_row.provider,
order_row.shop_id,
item.sku_name,
item.revenue_amount_fen
FROM on_account_bills bill
JOIN orders order_row ON order_row.id = bill.order_id
JOIN order_items item ON item.id = bill.order_item_id
`
export async function listOnAccountBills(input: JsonObject = {}) {
const page = Math.max(1, Math.trunc(Number(input.page) || 1))
const pageSize = Math.min(100, Math.max(1, Math.trunc(Number(input.pageSize) || 20)))
const keyword = String(input.keyword || '').trim()
const status = String(input.status || '').trim()
const where: string[] = []
const params: unknown[] = []
if (keyword) {
params.push(`%${keyword}%`)
where.push(`(
bill.bill_no ILIKE $${params.length}
OR bill.platform_order_id ILIKE $${params.length}
OR bill.sku_key ILIKE $${params.length}
OR item.sku_name ILIKE $${params.length}
)`)
}
if (['pending', 'reconciled', 'void'].includes(status)) {
params.push(status)
where.push(`bill.status = $${params.length}`)
}
const whereSql = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''
const total = await query<{ total: number }>(
`SELECT COUNT(*)::int AS total
FROM on_account_bills bill
JOIN order_items item ON item.id = bill.order_item_id
${whereSql}`,
params,
)
const listParams = [...params, pageSize, (page - 1) * pageSize]
const result = await query<OnAccountBillRow>(
`${BILL_SELECT}
${whereSql}
ORDER BY
CASE bill.status WHEN 'pending' THEN 0 WHEN 'reconciled' THEN 1 ELSE 2 END,
bill.created_at DESC,
bill.id DESC
LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
listParams,
)
return {
items: result.rows.map(mapBill),
pagination: {
page,
pageSize,
total: Number(total.rows[0]?.total || 0),
},
}
}
export async function reconcileOnAccountBill(
billIdInput: unknown,
payload: JsonObject,
session: AdminSession,
) {
const billId = Number(billIdInput)
if (!Number.isInteger(billId) || billId <= 0) {
throw createHttpError('统一结算账单 ID 无效', { statusCode: 400 })
}
const amountFen = Number(payload.amountFen ?? payload.amount_fen)
if (!Number.isFinite(amountFen) || amountFen <= 0 || !Number.isInteger(amountFen)) {
throw createHttpError('实际结算金额必须是大于 0 的整数分', {
statusCode: 400,
errorCode: 'on_account_amount_invalid',
})
}
const now = nowIso()
const result = await withTransaction(async (client) => {
const billResult = await client.query<OnAccountBillRow>(
`${BILL_SELECT}
WHERE bill.id = $1
LIMIT 1
FOR UPDATE OF bill, item`,
[billId],
)
const bill = billResult.rows[0]
if (!bill) {
throw createHttpError('统一结算账单不存在', { statusCode: 404 })
}
if (bill.status !== 'pending') {
throw createHttpError('只有待对账账单可以确认结算', {
statusCode: 409,
errorCode: 'on_account_bill_not_pending',
})
}
const orderResult = await client.query<{
provider: string
shop_id: string
paid_at: string | null
created_at: string
}>(
`SELECT provider, shop_id, paid_at, created_at
FROM orders WHERE id = $1`,
[bill.order_id],
)
const order = orderResult.rows[0]
if (!order) {
throw createHttpError('统一结算账单关联订单不存在', { statusCode: 409 })
}
const basisAt = order.paid_at || order.created_at
const period = await client.query<{ status: string }>(
`SELECT status FROM finance_periods
WHERE period_key = to_char($1::timestamptz AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM')
LIMIT 1`,
[basisAt],
)
if (period.rows[0]?.status === 'closed') {
throw createHttpError('该订单所属期间已关账,请先反关账后再对账', {
statusCode: 409,
errorCode: 'on_account_period_closed',
})
}
const workOrder = await client.query<{ product_rule_id: number | null }>(
`SELECT product_rule_id FROM work_orders
WHERE order_item_id = $1 AND status <> 'cancelled'
ORDER BY id DESC LIMIT 1`,
[bill.order_item_id],
)
const costItemId = await resolveManualCostItemIdWithClient(client)
const versionId = await resolveCostVersionIdWithClient(
client,
costItemId,
{
order_id_resolved: Number(bill.order_id),
provider: order.provider,
shop_id: order.shop_id,
canonical_product_id: bill.canonical_product_id,
work_product_rule_id: workOrder.rows[0]?.product_rule_id || null,
credit_unit_price_fen: '1',
},
basisAt,
)
if (!versionId) {
throw createHttpError('人工履约成本版本不可用,无法完成对账', {
statusCode: 409,
errorCode: 'on_account_cost_version_missing',
})
}
await client.query(
`UPDATE cost_snapshots
SET superseded_at = $2
WHERE order_item_id = $1
AND subject_type = 'manual_case'
AND superseded_at IS NULL
AND reversed_at IS NULL`,
[bill.order_item_id, now],
)
const snapshot = await client.query<{ id: number }>(
`INSERT INTO cost_snapshots (
subject_type, subject_id, order_id, order_item_id, work_order_id,
cost_item_id, cost_version_id, canonical_product_id,
quantity, unit_amount_fen, total_amount_fen, snapshot_stage, source,
metadata_json, cost_basis_at, resolved_at, created_at
) VALUES (
'manual_case', $1, $2, $1, NULL,
$3, $4, $5,
1, $6, $6, 'actual', 'manual_input',
$7::jsonb, $8, $9, $9
)
RETURNING id`,
[
bill.order_item_id,
bill.order_id,
costItemId,
versionId,
bill.canonical_product_id,
amountFen,
JSON.stringify({
source: 'on_account_reconcile',
billId: bill.id,
billNo: bill.bill_no,
platformKey: 'on_account',
previousAmountFen: Number(bill.amount_fen || 0),
reconciledBy: {
userId: Number(session.userId || 0),
displayName: String(session.displayName || ''),
},
}),
basisAt,
now,
],
)
const snapshotId = Number(snapshot.rows[0]?.id || 0)
if (!snapshotId) {
throw createHttpError('统一结算成本快照保存失败', { statusCode: 500 })
}
const updated = await client.query<OnAccountBillRow>(
`UPDATE on_account_bills
SET amount_fen = $1,
status = 'reconciled',
payee_name = $2,
note = $3,
metadata_json = metadata_json || $4::jsonb,
reconciled_at = $5,
updated_at = $5
WHERE id = $6
RETURNING *`,
[
amountFen,
String(payload.payeeName ?? payload.payee_name ?? bill.payee_name ?? '').slice(0, 120),
String(payload.note ?? bill.note ?? '').slice(0, 300),
JSON.stringify({
reconciledByAdminUserId: Number(session.userId || 0) || null,
costSnapshotId: snapshotId,
}),
now,
bill.id,
],
)
return {
bill: mapBill({ ...bill, ...updated.rows[0]!, amount_fen: amountFen }),
orderItemId: Number(bill.order_item_id),
snapshotId,
amountFen,
}
})
await Promise.all([
resolveExceptionsForItemsByReason([result.orderItemId], 'on_account_cost_pending'),
resolveExceptionsForItemsByReason([result.orderItemId], 'manual_cost_missing'),
resolveExceptionsForItemsByReason([result.orderItemId], 'cost_missing'),
]).catch(() => undefined)
await refreshFactsForOrderItem(result.orderItemId).catch(() => undefined)
return result
}
@@ -434,7 +434,10 @@ export async function assignPendingOrderItemChannels(
.filter((id) => id > 0)
.slice(0, 2000)
result.assigned += assignedItemIds.length
result.assignedItemIds = assignedItemIds
result.assignedItemIds = [...new Set([...result.assignedItemIds, ...assignedItemIds])].slice(
0,
2000,
)
await scheduleVoucherConsumeForOrderItems(assignedItemIds, 'order_received').catch(
(error) => {
logWarn('[channel-assign]', '订单收到后安排自动核销失败', {
@@ -2388,60 +2391,3 @@ export async function ensureVoucherRuleForSku(skuCode: string): Promise<boolean>
})
return true
}
/**
* 为卡券商品(tpv: 前缀)批量补渠道规则。
*
* 卡券没有履约载体证据,只能靠规则判定;商品目录建好后一次补齐,
* 避免运营按 SKU 逐条点「指定渠道」。
*/
export async function ensureVoucherChannelRules(): Promise<{
created: number
productCount: number
}> {
await query(
`UPDATE internal_sku_channels channel
SET is_fallback = TRUE,
priority = GREATEST(priority, 90),
updated_at = NOW()
FROM product_catalog product
WHERE product.status = 'active'
AND product.canonical_sku LIKE 'tpv:%'
AND canonicalize_internal_sku(channel.internal_sku) =
canonicalize_internal_sku(substring(product.canonical_sku from 5))
AND channel.channel <> 'third_party_voucher'
AND channel.enabled = TRUE
AND channel.is_fallback = FALSE`,
)
const created = await query<{ id: number }>(
`
INSERT INTO internal_sku_channels (
internal_sku, channel, priority, is_fallback, enabled,
canonical_product_id, config_json, source, created_at, updated_at
)
SELECT
canonicalize_internal_sku(substring(p.canonical_sku from 5)),
'third_party_voucher', 10, FALSE, TRUE,
p.id, jsonb_build_object('voucherSku', substring(p.canonical_sku from 5)),
'voucher', NOW(), NOW()
FROM product_catalog p
WHERE p.status = 'active'
AND p.canonical_sku LIKE 'tpv:%'
AND canonicalize_internal_sku(substring(p.canonical_sku from 5)) <> ''
ON CONFLICT (internal_sku, channel) DO UPDATE SET
priority = 10,
is_fallback = FALSE,
enabled = TRUE,
canonical_product_id = EXCLUDED.canonical_product_id,
config_json = internal_sku_channels.config_json || EXCLUDED.config_json,
source = EXCLUDED.source,
updated_at = NOW()
RETURNING id
`,
)
const total = await query<{ count: number }>(
`SELECT COUNT(*)::int AS count FROM product_catalog
WHERE status = 'active' AND canonical_sku LIKE 'tpv:%'`,
)
return { created: created.rowCount || 0, productCount: Number(total.rows[0]?.count || 0) }
}
@@ -16,6 +16,10 @@ const currentAttemptMigration = readFileSync(
new URL('../../db/migrations/138_fulfillment_current_attempt.sql', import.meta.url),
'utf8',
)
const currentLatestMigration = readFileSync(
new URL('../../db/migrations/145_fulfillment_current_attempt_latest.sql', import.meta.url),
'utf8',
)
test('自动发货历史非活跃任务默认阻止后台自动重开', () => {
assert.ok(carrierCreation.includes('INACTIVE_CARRIER_TASK_STATUSES.includes'))
@@ -55,3 +59,9 @@ test('创建新履约尝试时自动退休旧当前尝试', () => {
assert.ok(taskRepo.includes('$26, TRUE'))
assert.ok(taskRepo.includes('SELECT id FROM order_items WHERE id = $1 FOR UPDATE'))
})
test('当前尝试必须是同单元最新任务,统一视图不能读到已退休尝试', () => {
assert.ok(currentLatestMigration.includes('assert_fulfillment_current_is_latest'))
assert.ok(currentLatestMigration.includes('newer.id > COALESCE(NEW.id, 0)'))
assert.ok(currentLatestMigration.includes("ERRCODE = '23514'"))
})
@@ -10,6 +10,10 @@ const auditScript = readFileSync(
new URL('../../../scripts/audit-fulfillment-state.ts', import.meta.url),
'utf8',
)
const configSyncMigration = readFileSync(
new URL('../../db/migrations/144_fulfillment_shadow_configuration_sync.sql', import.meta.url),
'utf8',
)
test('正式履约影子模型包含履约、尝试和异常三张表', () => {
assert.ok(migration.includes('CREATE TABLE IF NOT EXISTS fulfillments'))
@@ -27,6 +31,21 @@ test('影子模型从现有真源回填并持续双写', () => {
assert.ok(migration.includes('assign_fulfillment_event_shadow'))
})
test('人工配置和统一渠道规则变化会刷新履约影子', () => {
assert.ok(configSyncMigration.includes('trg_manual_fulfillment_config_shadow'))
assert.ok(configSyncMigration.includes('trg_internal_sku_channel_shadow'))
assert.ok(configSyncMigration.includes('sync_fulfillment_shadow(item.id)'))
})
test('人工履约配置不能关联其他来源商品', () => {
assert.ok(configSyncMigration.includes('trg_manual_config_product_source'))
assert.ok(configSyncMigration.includes("product.canonical_sku LIKE 'man:%'"))
assert.ok(configSyncMigration.includes("ERRCODE = '23514'"))
assert.ok(configSyncMigration.includes('BEFORE INSERT OR UPDATE ON manual_fulfillment_configs'))
assert.ok(configSyncMigration.includes('config.enabled = TRUE'))
assert.ok(configSyncMigration.includes("SET status = 'active'"))
})
test('明确渠道即视为已路由,不用渠道配置缺失覆盖已选渠道', () => {
assert.ok(migration.includes('v_fulfillment_item_state_raw'))
assert.ok(migration.includes("WHEN raw.intended_channel IS NOT NULL THEN 'configured'"))
@@ -43,6 +43,10 @@ test('待分类查询使用统一读模型并返回异常数量', () => {
assert.ok(channelAssign.includes('issueCategoryInput'))
})
test('批量判渠保留所有批次的即时成本同步订单项', () => {
assert.ok(channelAssign.includes('...new Set([...result.assignedItemIds, ...assignedItemIds])'))
})
test('重试必须按可重试 issueCode 限定,不能按整个 SKU 处理售后和复核项', () => {
assert.ok(channelAssign.includes('RETRYABLE_CARRIER_ISSUES'))
assert.ok(channelAssign.includes('issueCodes?: string[]'))
@@ -1,3 +1,5 @@
import type { PoolClient } from 'pg'
import { query, withTransaction } from '../../db/client.js'
import type { FulfillmentChannel } from './channel-types.js'
import { canonicalizeInternalSku, compareFulfillmentChannel } from './internal-sku-resolver.js'
@@ -127,7 +129,7 @@ export function planInternalSkuChannels(input: InternalSkuChannelRow[]): Interna
}
}
export async function upsertInternalSkuChannel(input: {
type InternalSkuChannelUpsertInput = {
internalSku: string
channel: FulfillmentChannel
priority?: number
@@ -139,25 +141,29 @@ export async function upsertInternalSkuChannel(input: {
note?: string | undefined
adminUserId?: number | null | undefined
replacePriority?: boolean
}): Promise<number> {
}
async function upsertInternalSkuChannelWithExecutor(
client: Pick<PoolClient, 'query'>,
input: InternalSkuChannelUpsertInput,
): Promise<number> {
const internalSku = canonicalizeInternalSku(input.internalSku)
if (!internalSku) return 0
return withTransaction(async (client) => {
if (input.enabled !== false && input.isFallback !== true) {
await client.query(
`UPDATE internal_sku_channels
SET is_fallback = TRUE,
priority = GREATEST(priority, 90),
updated_at = NOW()
WHERE canonicalize_internal_sku(internal_sku) = $1
AND channel <> $2
AND enabled = TRUE
AND is_fallback = FALSE`,
[internalSku, input.channel],
)
}
const result = await client.query<{ id: number }>(
`
if (input.enabled !== false && input.isFallback !== true) {
await client.query(
`UPDATE internal_sku_channels
SET is_fallback = TRUE,
priority = GREATEST(priority, 90),
updated_at = NOW()
WHERE canonicalize_internal_sku(internal_sku) = $1
AND channel <> $2
AND enabled = TRUE
AND is_fallback = FALSE`,
[internalSku, input.channel],
)
}
const result = await client.query<{ id: number }>(
`
INSERT INTO internal_sku_channels (
internal_sku, channel, priority, is_fallback, enabled,
canonical_product_id, config_json, source, note, created_by_admin_user_id,
@@ -177,23 +183,35 @@ export async function upsertInternalSkuChannel(input: {
created_by_admin_user_id = COALESCE(EXCLUDED.created_by_admin_user_id, internal_sku_channels.created_by_admin_user_id),
updated_at = NOW()
RETURNING id
`,
[
internalSku,
input.channel,
Number.isFinite(Number(input.priority)) ? Number(input.priority) : 100,
input.isFallback === true,
input.enabled !== false,
input.canonicalProductId ?? null,
JSON.stringify(input.configJson || {}),
String(input.source || 'auto').trim() || 'auto',
String(input.note || '').slice(0, 300),
input.adminUserId ?? null,
input.replacePriority === true,
],
)
return Number(result.rows[0]?.id || 0)
})
`,
[
internalSku,
input.channel,
Number.isFinite(Number(input.priority)) ? Number(input.priority) : 100,
input.isFallback === true,
input.enabled !== false,
input.canonicalProductId ?? null,
JSON.stringify(input.configJson || {}),
String(input.source || 'auto').trim() || 'auto',
String(input.note || '').slice(0, 300),
input.adminUserId ?? null,
input.replacePriority === true,
],
)
return Number(result.rows[0]?.id || 0)
}
export async function upsertInternalSkuChannelWithClient(
client: PoolClient,
input: InternalSkuChannelUpsertInput,
): Promise<number> {
return upsertInternalSkuChannelWithExecutor(client, input)
}
export async function upsertInternalSkuChannel(
input: InternalSkuChannelUpsertInput,
): Promise<number> {
return withTransaction((client) => upsertInternalSkuChannelWithExecutor(client, input))
}
/**
@@ -3,6 +3,10 @@ import { readFileSync } from 'node:fs'
import test from 'node:test'
const source = readFileSync(new URL('./voucher-consume-queue-service.ts', import.meta.url), 'utf8')
const migration = readFileSync(
new URL('../../db/migrations/142_voucher_consume_job_codes.sql', import.meta.url),
'utf8',
)
test('核销任务列表支持关键词、平台、渠道和状态组合筛选', () => {
assert.ok(source.includes('keyword?: string'))
@@ -14,6 +18,7 @@ test('核销任务列表支持关键词、平台、渠道和状态组合筛选',
assert.ok(source.includes('status = $'))
assert.ok(source.includes('platform_order_id ILIKE'))
assert.ok(source.includes('target_key ILIKE'))
assert.ok(source.includes('fulfillment_voucher_codes_search_text(voucher_codes) ILIKE'))
})
test('核销任务列表使用服务端总数和分页参数', () => {
@@ -22,3 +27,15 @@ test('核销任务列表使用服务端总数和分页参数', () => {
assert.ok(source.includes('OFFSET $'))
assert.ok(source.includes('ORDER BY updated_at DESC, id DESC'))
})
test('核销任务保存并展示核销码,执行时刷新候选券码', () => {
assert.ok(migration.includes('voucher_codes TEXT[]'))
assert.ok(migration.includes('fulfillment_voucher_codes_search_text(voucher_codes)'))
assert.ok(migration.includes('gin_trgm_ops'))
assert.ok(migration.includes('kuaishou_industry_vouchers'))
assert.ok(migration.includes('redeem_certificates'))
assert.ok(source.includes('INSERT INTO voucher_consume_jobs'))
assert.ok(source.includes('candidates.voucherCodes'))
assert.ok(source.includes('updateVoucherConsumeJobCodes'))
assert.ok(source.includes('voucher_codes = $1::text[]'))
})
@@ -6,6 +6,7 @@ import { listKuaishouIndustryVouchersByOid } from '../../repositories/kuaishou-i
import {
findRedeemCertificateByOrder,
listRedeemCertificatesByLocalOrder,
resolveRedeemVerifyCertNo,
type RedeemCertificateRow,
} from '../../repositories/redeem-certificate-repo.js'
import type { WorkOrderRow } from '../../repositories/worker-platform/index.js'
@@ -70,6 +71,7 @@ export type VoucherConsumeJobRow = {
max_attempts: number
locked_until: string | null
last_error: string
voucher_codes: string[]
result_json: string | Record<string, unknown>
created_at: string
updated_at: string
@@ -331,13 +333,14 @@ async function scheduleVoucherConsumeTarget(
order_item_id,
task_id,
work_order_id,
voucher_codes,
due_at,
status,
attempt_count,
max_attempts,
updated_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10::text[], $11,
'pending', 0, 8, NOW()
)
ON CONFLICT (provider, target_key)
@@ -355,6 +358,7 @@ async function scheduleVoucherConsumeTarget(
target.orderItemId,
target.taskId,
target.workOrderId,
candidates.voucherCodes,
dueAt,
],
)
@@ -488,7 +492,8 @@ export async function listVoucherConsumeJobs(
`(CAST(id AS TEXT) ILIKE $${params.length}
OR platform_order_id ILIKE $${params.length}
OR target_key ILIKE $${params.length}
OR CAST(order_id AS TEXT) ILIKE $${params.length})`,
OR CAST(order_id AS TEXT) ILIKE $${params.length}
OR fulfillment_voucher_codes_search_text(voucher_codes) ILIKE $${params.length})`,
)
}
if (provider) {
@@ -580,6 +585,10 @@ async function executeKuaishouConsumeJob(job: VoucherConsumeJobRow) {
if (vouchers.length === 0) {
throw new Error('未找到待核销的快手电子凭证')
}
await updateVoucherConsumeJobCodes(
job.id,
vouchers.map((voucher) => String(voucher.voucher_code || '').trim()).filter(Boolean),
)
const task = job.task_id ? await getTaskById(job.task_id) : null
let consumedCount = 0
@@ -631,6 +640,18 @@ async function executeDouyinConsumeJob(job: VoucherConsumeJobRow) {
if (certificates.length === 0) {
throw new Error('未找到待核销的抖音券')
}
await updateVoucherConsumeJobCodes(
job.id,
Array.from(
new Set(
certificates.flatMap((certificate) => [
String(certificate.cert_no || '').trim(),
String(certificate.platform_cert_no || '').trim(),
resolveRedeemVerifyCertNo(certificate),
]),
),
).filter(Boolean),
)
let consumedCount = 0
let alreadyConsumedCount = 0
@@ -779,6 +800,7 @@ function buildSyntheticJob(
max_attempts: 8,
locked_until: null,
last_error: '',
voucher_codes: [],
result_json: {},
created_at: '',
updated_at: '',
@@ -837,6 +859,23 @@ async function finishVoucherConsumeJob(
)
}
async function updateVoucherConsumeJobCodes(jobId: number, voucherCodes: string[]) {
const codes = Array.from(new Set(voucherCodes.map((code) => String(code || '').trim()))).filter(
Boolean,
)
if (codes.length === 0) {
return
}
await query(
`
UPDATE voucher_consume_jobs
SET voucher_codes = $1::text[], updated_at = NOW()
WHERE id = $2
`,
[codes, jobId],
)
}
async function failVoucherConsumeJob(job: VoucherConsumeJobRow, error: unknown, retry: boolean) {
const message = error instanceof Error ? error.message : String(error)
if (!retry) {
@@ -20,6 +20,7 @@ import type {
AdminKuaishouIndustryVoucherCheckAvailableInput,
AdminKuaishouIndustryVoucherConsumeInput,
AdminKuaishouIndustryVoucherDestroyInput,
AdminKuaishouIndustryVoucherReissueConsumeInput,
AdminKuaishouIndustryVoucherResendInput,
AdminKuaishouIndustryVoucherReverseInput,
AdminCloudtentaclesTestLoginInput,
@@ -49,6 +50,8 @@ export type AdminKuaishouIndustryVoucherCheckAvailableRouteBody =
export type AdminKuaishouIndustryVoucherReverseRouteBody = AdminKuaishouIndustryVoucherReverseInput
export type AdminKuaishouIndustryVoucherConsumeRouteBody = AdminKuaishouIndustryVoucherConsumeInput
export type AdminKuaishouIndustryVoucherResendRouteBody = AdminKuaishouIndustryVoucherResendInput
export type AdminKuaishouIndustryVoucherReissueConsumeRouteBody =
AdminKuaishouIndustryVoucherReissueConsumeInput
export type AdminKuaishouIndustryVoucherDestroyRouteBody = AdminKuaishouIndustryVoucherDestroyInput
export type AdminNotificationConfigRouteBody = AdminNotificationConfigInput
export type AdminNotificationTestRouteBody = AdminNotificationTestInput
@@ -354,6 +354,25 @@ export type AdminKuaishouIndustryVoucherResendInput = {
voucherCode?: string
}
/**
* 历史券补偿:临时重建有效期,重发原券码发码回调,成功后立即核销。
* validMinutes 只允许较短窗口,避免发码成功但核销失败时长期暴露可用券。
*/
export type AdminKuaishouIndustryVoucherReissueConsumeInput = {
oid?: string
orderId?: string
taskId?: number | string
voucherCode?: string
validMinutes?: number | string
reason?: string
consumeType?: string
serialNum?: string
storeName?: string
storeAddress?: string
expressCode?: string
expressNo?: string
}
export type AdminKuaishouIndustryVoucherDestroyInput = {
oid?: string
orderId?: string
@@ -183,9 +183,11 @@ export function ManualFulfillmentConfirmModal({
onSuccess: (response) => {
const result = response.data
message.success(
result.onAccountBillId
? `履约已确认,已生成统一结算虚拟账单 ¥${(result.costFen / 100).toFixed(2)}`
: `履约已确认,扣 ${result.creditQuantity} 积分,成本 ¥${(result.costFen / 100).toFixed(2)}`,
result.costPending
? '履约已确认,已生成待对账的统一结算账单;实际成本待对账后入账'
: result.onAccountBillId
? `履约已确认,已生成统一结算虚拟账单 ¥${(result.costFen / 100).toFixed(2)}`
: `履约已确认,扣 ${result.creditQuantity} 积分,成本 ¥${(result.costFen / 100).toFixed(2)}`,
)
void queryClient.invalidateQueries({ queryKey: ['admin-manual-fulfillment-pending'] })
void queryClient.invalidateQueries({ queryKey: ['admin-order-detail'] })
@@ -484,7 +484,17 @@ function PendingPanel() {
保存渠道
</Button>
{canRunScopedActions && hasConfigIssue ? (
<Button size="small" onClick={() => void navigate('/admin/cost-center')}>
<Button
size="small"
disabled={!row.internal_sku}
onClick={() => {
const sku = String(row.internal_sku || '').trim()
if (!sku) return
void navigate(
`/admin/cost-center?scope=manual&tab=cost&manualSku=${encodeURIComponent(sku)}&returnTo=${encodeURIComponent('/admin/fulfillment-channel')}`,
)
}}
>
去补配置({row.config_issue_item_count})
</Button>
) : null}
@@ -6,15 +6,18 @@ import {
SearchOutlined,
SendOutlined,
SyncOutlined,
ThunderboltOutlined,
} from '@ant-design/icons'
import {
Alert,
Button,
Card,
Checkbox,
Collapse,
Descriptions,
Input,
InputNumber,
Modal,
Popconfirm,
Select,
Space,
@@ -39,6 +42,7 @@ import {
fetchAdminKuaishouIndustryShops,
fetchAdminKuaishouIndustryVouchers,
listAdminKuaishouIndustryRefunds,
reissueAndConsumeAdminKuaishouIndustryVoucher,
resendAdminKuaishouIndustryVoucherCode,
reverseAdminKuaishouIndustryVoucher,
} from '@/services/admin'
@@ -69,6 +73,7 @@ type IndustryActionKind =
| 'destroy'
| 'reverse'
| 'resend'
| 'reissue-consume'
| 'refund-list'
| 'refund-approve'
| 'refund-disagree'
@@ -120,6 +125,11 @@ export default function AdminKuaishouIndustryPage() {
const [actionLoading, setActionLoading] = useState('')
const [consumingVoucherId, setConsumingVoucherId] = useState<number | null>(null)
const [reversingVoucherId, setReversingVoucherId] = useState<number | null>(null)
const [reissueConsumeOpen, setReissueConsumeOpen] = useState(false)
const [reissueConsumeMinutes, setReissueConsumeMinutes] = useState<number>(10)
const [reissueConsumeReason, setReissueConsumeReason] =
useState('历史发码失败补偿,补发后立即核销')
const [reissueConsumeConfirmed, setReissueConsumeConfirmed] = useState(false)
const [vouchers, setVouchers] = useState<AdminKuaishouIndustryVoucher[]>([])
const [selectedVoucher, setSelectedVoucher] = useState<AdminKuaishouIndustryVoucher | null>(null)
const [voucherTotal, setVoucherTotal] = useState(0)
@@ -646,6 +656,64 @@ export default function AdminKuaishouIndustryPage() {
}
}
function openReissueConsumeModal() {
if (!selectedVoucher && !String(toolForm.voucherCode || '').trim()) {
showError('请先选择或填写本地券码')
return
}
if (selectedVoucher) {
const sendSucceeded = selectedVoucher.sendCallbackStatus === 'success'
const stillValid = Number(selectedVoucher.validEndTime || 0) > Date.now()
if (selectedVoucher.status !== 'UNUSED') {
showError('仅未使用电子凭证可以补发并核销')
return
}
if (sendSucceeded && stillValid) {
showError('该券已发码成功且仍在有效期,请直接执行手动核销')
return
}
}
setReissueConsumeConfirmed(false)
setReissueConsumeOpen(true)
}
async function runReissueConsume() {
if (!reissueConsumeConfirmed) {
showError('请先确认已核实订单仍需履约')
return
}
setActionLoading('reissue-consume')
try {
const response = await reissueAndConsumeAdminKuaishouIndustryVoucher({
...buildVoucherToolPayload(),
validMinutes: reissueConsumeMinutes,
reason: reissueConsumeReason,
})
publishActionResult(
buildIndustryActionResultView('reissue-consume', '历史券补发并核销', response.data),
)
if (response.data.success) {
showSuccess('原券码已同步到快手并完成核销')
setReissueConsumeOpen(false)
} else {
showError(response.data.error || '历史券补发并核销失败')
}
await loadVouchers()
} catch (error) {
const message = error instanceof Error ? error.message : '历史券补发并核销失败'
publishActionResult(
buildIndustryActionResultView('reissue-consume', '历史券补发并核销', {
success: false,
error: message,
}),
)
showError(message)
} finally {
setActionLoading('')
}
}
async function runDestroy() {
setActionLoading('destroy')
try {
@@ -870,6 +938,98 @@ export default function AdminKuaishouIndustryPage() {
onChange={(key) => setActiveTab(key as IndustryTab)}
items={tabItems}
/>
<Modal
title="历史券补发并核销"
open={reissueConsumeOpen}
okText="补发并核销"
cancelText="取消"
confirmLoading={actionLoading === 'reissue-consume'}
okButtonProps={{
disabled:
!reissueConsumeConfirmed ||
!String(reissueConsumeReason || '').trim() ||
reissueConsumeMinutes < 1 ||
reissueConsumeMinutes > 60,
}}
onOk={runReissueConsume}
onCancel={() => setReissueConsumeOpen(false)}
destroyOnHidden
>
<Space direction="vertical" size={16} className="full-width">
<Alert
type="warning"
showIcon
message="该操作会重发原券码并立即核销"
description="仅用于已确认订单仍需履约、但历史发码失败或凭证已过期的补偿场景。操作会先临时延长本地券有效期,发码成功后立即核销。"
/>
<Descriptions
size="small"
bordered
column={1}
items={[
{
key: 'voucherCode',
label: '本地券码',
children:
selectedVoucher?.voucherCode || String(toolForm.voucherCode || '').trim() || '-',
},
{
key: 'oid',
label: '平台订单',
children:
selectedVoucher?.oid ||
String(toolForm.oid || toolForm.orderId || '').trim() ||
'-',
},
{
key: 'validEndTime',
label: '原有效期至',
children: selectedVoucher
? formatAdminDateTime(
new Date(Number(selectedVoucher.validEndTime || 0)).toISOString(),
)
: '-',
},
{
key: 'sendError',
label: '最近发码错误',
children: selectedVoucher?.sendCallbackLastError || '-',
},
]}
/>
<label className="kuaishou-industry-tool-field">
<span>临时有效期(分钟)</span>
<InputNumber
min={1}
max={60}
precision={0}
value={reissueConsumeMinutes}
onChange={(value) => setReissueConsumeMinutes(Number(value || 10))}
style={{ width: '100%' }}
/>
</label>
<label className="kuaishou-industry-tool-field">
<span>补发原因</span>
<Input
maxLength={200}
showCount
value={reissueConsumeReason}
onChange={(event) => setReissueConsumeReason(event.target.value)}
/>
</label>
<Checkbox
checked={reissueConsumeConfirmed}
onChange={(event) => setReissueConsumeConfirmed(event.target.checked)}
>
我已核实该订单仍需履约,并确认补发后立即核销
</Checkbox>
</Space>
</Modal>
</div>
)
@@ -1210,6 +1370,15 @@ export default function AdminKuaishouIndustryPage() {
>
手动核销
</Button>
<Button
danger
icon={<ThunderboltOutlined />}
loading={actionLoading === 'reissue-consume'}
disabled={!hasVoucherTarget}
onClick={openReissueConsumeModal}
>
历史补发并核销
</Button>
<Button
danger
icon={<RollbackOutlined />}
@@ -1677,6 +1846,7 @@ function buildIndustryActionResultView(
const responseData = asRecord(response?.data)
const success = resolveActionSuccess(kind, record, openApi)
const platformMessage = pickFirstString([
...(kind === 'reissue-consume' ? [record.error] : []),
response?.error_msg,
response?.msg,
response?.sub_msg,
@@ -1788,6 +1958,24 @@ function buildIndustryActionResultView(
}
}
if (kind === 'reissue-consume') {
pushHighlight(highlights, '执行阶段', String(record.stage || '-'))
if (record.validMinutes) {
pushHighlight(highlights, '临时有效期', `${String(record.validMinutes)} 分钟`)
}
for (const [label, value] of [
['发码成功', record.sendSuccess],
['核销成功', record.consumeSuccess],
] as const) {
if (typeof value === 'boolean') {
pushHighlight(highlights, label, value ? '是' : '否')
}
}
if (record.reason) {
pushHighlight(highlights, '补发原因', String(record.reason))
}
}
if (platformMessage) {
pushHighlight(highlights, '平台消息', platformMessage)
}
@@ -1855,6 +2043,12 @@ function buildActionSummary(
: context.platformMessage || '重发发码失败。'
}
if (kind === 'reissue-consume') {
return success
? '原券码已同步到快手并完成核销。'
: context.platformMessage || '历史券补发并核销失败。'
}
if (kind === 'refund-list') {
return success
? `查询成功,共 ${context.refundCount ?? 0} 条售后单,已填充列表。`
@@ -23,7 +23,8 @@ import {
Typography,
} from 'antd'
import { keepPreviousData, useIsFetching, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect } from 'react'
import { useEffect, useRef } from 'react'
import { useSearchParams } from 'react-router'
import FinanceSubNav from '@/components/admin/FinanceSubNav'
import { usePersistedState } from '@/hooks/usePersistedState'
@@ -73,7 +74,11 @@ function isCostScope(value: unknown): value is CostScope {
export default function CostCenterShell() {
const queryClient = useQueryClient()
const [searchParams] = useSearchParams()
const canConfig = hasAdminPermission('finance.config')
const requestedScope = String(searchParams.get('scope') || '').trim()
const requestedTab = String(searchParams.get('tab') || '').trim()
const handledRouteRequestRef = useRef('')
const [scope, setScope] = usePersistedState<CostScope>(
'admin-cost-center:scope',
@@ -87,6 +92,20 @@ export default function CostCenterShell() {
)
const mappingsApplicable = scope === 'all' || scope === 'work_order'
useEffect(() => {
const routeRequest = `${requestedScope}\u0000${requestedTab}`
if (routeRequest === '\u0000' || handledRouteRequestRef.current === routeRequest) return
handledRouteRequestRef.current = routeRequest
if (isCostScope(requestedScope) && requestedScope !== scope) {
setScope(requestedScope)
}
const requestedTabAllowed =
isCostCenterTab(requestedTab) && (requestedScope !== 'manual' || requestedTab !== 'mappings')
if (requestedTabAllowed && requestedTab !== activeTab) {
setActiveTab(requestedTab)
}
}, [activeTab, requestedScope, requestedTab, scope, setActiveTab, setScope])
useEffect(() => {
if (!mappingsApplicable && activeTab === 'mappings') {
setActiveTab('products')
@@ -60,6 +60,7 @@ const REASON_LABELS: Record<string, string> = {
cost_missing: '成本缺失',
manual_config_missing: '人工 SKU 未配置',
manual_cost_missing: '人工成本未记录',
on_account_cost_pending: '统一结算待对账',
carrier_missing: '缺少履约载体',
}
@@ -38,7 +38,8 @@ import {
import PercentTable from '@/components/common/PercentTable'
import type { TableColumnsType } from 'antd'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router'
import {
deleteAdminManualSku,
@@ -55,7 +56,10 @@ import {
formatCreditUnitPriceYuan,
} from '@/utils/credit-unit-price'
import { useCostCenter } from './CostCenterContext'
type ConfigFormValues = {
skuKey: string
channels: Array<{
platformKey: string
creditQuantity: number
@@ -87,8 +91,15 @@ function platformOptionLabel(platformKey: string, label: string, creditUnitPrice
export function ManualSkuConfigPanel() {
const { message } = App.useApp()
const { canConfig } = useCostCenter()
const queryClient = useQueryClient()
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const [form] = Form.useForm<ConfigFormValues>()
const handledDeepLinkRef = useRef('')
const deepLinkSku = String(searchParams.get('manualSku') || searchParams.get('sku') || '').trim()
const manualNewRequested = searchParams.get('manualNew') === '1'
const returnTo = String(searchParams.get('returnTo') || '').trim()
const [keyword, setKeyword] = useState('')
const [status, setStatus] = useState<'all' | 'configured' | 'unconfigured'>('unconfigured')
@@ -125,7 +136,7 @@ export function ManualSkuConfigPanel() {
const saveMutation = useMutation({
mutationFn: (values: ConfigFormValues) =>
saveAdminManualConfig({
skuKey: editing?.skuKey || '',
skuKey: String(values.skuKey || editing?.skuKey || '').trim(),
channels: values.channels.map((channel) => ({
platformKey: channel.platformKey,
creditQuantity: Number(channel.creditQuantity || 0),
@@ -139,10 +150,14 @@ export function ManualSkuConfigPanel() {
: ''
message.success(`配置已保存${syncHint}`)
setModalOpen(false)
setEditing(null)
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center'] })
void queryClient.invalidateQueries({ queryKey: ['cost-center-voucher-products'] })
void queryClient.invalidateQueries({ queryKey: ['fulfillment-rules'] })
void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] })
if (returnTo.startsWith('/admin/fulfillment-channel')) {
void navigate(returnTo)
}
},
onError: (error) => {
message.error(error instanceof Error ? error.message : '保存失败')
@@ -180,6 +195,7 @@ export function ManualSkuConfigPanel() {
]
: [{ platformKey: '', creditQuantity: 0 }]
form.setFieldsValue({
skuKey: item.skuKey,
channels: configuredChannels.map((channel) => ({
platformKey: channel.platformKey,
creditQuantity: channel.creditQuantity,
@@ -189,6 +205,36 @@ export function ManualSkuConfigPanel() {
setModalOpen(true)
}
function openCreateConfig(skuKey = '') {
setEditing(null)
form.resetFields()
form.setFieldsValue({
skuKey: skuKey.trim(),
channels: [{ platformKey: '', creditQuantity: 0 }],
note: '',
})
setModalOpen(true)
}
useEffect(() => {
if (!canConfig) return
const deepLinkKey = deepLinkSku || (manualNewRequested ? '__new__' : '')
if (!deepLinkKey || handledDeepLinkRef.current === deepLinkKey) return
handledDeepLinkRef.current = deepLinkKey
if (deepLinkSku) {
setKeyword(deepLinkSku)
setStatus('all')
}
setEditing(null)
form.resetFields()
form.setFieldsValue({
skuKey: deepLinkSku,
channels: [{ platformKey: '', creditQuantity: 0 }],
note: '',
})
setModalOpen(true)
}, [canConfig, deepLinkSku, manualNewRequested, form])
const columns: TableColumnsType<ManualSkuItem> = [
{
title: '人工标准商品',
@@ -276,24 +322,27 @@ export function ManualSkuConfigPanel() {
key: 'actions',
width: 150,
fixed: 'right',
render: (_, row) => (
<Space size={4}>
<Button size="small" icon={<EditOutlined />} onClick={() => openConfig(row)}>
{row.configured ? '编辑' : '配置'}
</Button>
<Popconfirm
title="归档该人工标准商品?"
description="会归档标准商品、删除人工配置与渠道规则、清除已生成的人工成本快照,并把关联订单项退回待分类;原始订单不会删除。"
okText="确认归档"
cancelText="取消"
onConfirm={() => deleteMutation.mutate(row.skuKey)}
>
<Button size="small" danger icon={<DeleteOutlined />}>
归档
render: (_, row) =>
canConfig ? (
<Space size={4}>
<Button size="small" icon={<EditOutlined />} onClick={() => openConfig(row)}>
{row.configured ? '编辑' : '配置'}
</Button>
</Popconfirm>
</Space>
),
<Popconfirm
title="归档该人工标准商品?"
description="会归档标准商品、删除人工配置与渠道规则、清除已生成的人工成本快照,并把关联订单项退回待分类;原始订单不会删除。"
okText="确认归档"
cancelText="取消"
onConfirm={() => deleteMutation.mutate(row.skuKey)}
>
<Button size="small" danger icon={<DeleteOutlined />}>
归档
</Button>
</Popconfirm>
</Space>
) : (
<Typography.Text type="secondary">只读</Typography.Text>
),
},
]
@@ -303,13 +352,23 @@ export function ManualSkuConfigPanel() {
}
return (
<Card bordered={false} title="人工 SKU 配置">
<Card
bordered={false}
title="人工 SKU 配置"
extra={
canConfig ? (
<Button type="primary" icon={<PlusOutlined />} onClick={() => openCreateConfig()}>
新增人工 SKU
</Button>
) : null
}
>
<Alert
type="info"
showIcon
style={{ marginBottom: 14 }}
message="人工标准商品与成本配置一一对应"
description="这里直接列出人工客服范围内的 man: 标准商品。点“配置”选择实际履约渠道并填写每单成本即可;保存后会同步渠道规则、回填最近 30 天订单并重试待处理订单。消耗积分填 0 表示成本不固定,由客服确认时手填。"
message="人工标准商品由人工配置自动创建"
description="新增时只需填写内部 SKU、实际人工渠道和每单成本,系统会自动创建或复用 man: 标准商品。保存后同步渠道规则、回填最近 30 天订单并重试待处理订单。消耗积分填 0 表示成本不固定,由客服确认时手填。"
/>
<Row gutter={12} style={{ marginBottom: 12 }}>
@@ -399,14 +458,17 @@ export function ManualSkuConfigPanel() {
<Modal
open={modalOpen}
title={`配置人工履约 · ${editing?.skuKey || ''}`}
title={editing ? `配置人工履约 · ${editing.skuKey}` : '新增人工履约 SKU'}
okText="保存配置"
cancelText="取消"
confirmLoading={saveMutation.isPending}
onOk={() => {
void form.validateFields().then((values) => saveMutation.mutate(values))
}}
onCancel={() => setModalOpen(false)}
onCancel={() => {
setModalOpen(false)
setEditing(null)
}}
destroyOnHidden
width={560}
>
@@ -414,10 +476,37 @@ export function ManualSkuConfigPanel() {
type="warning"
showIcon
style={{ marginBottom: 14 }}
message={`商品名称:${editing?.displayName || '(无)'}(仅供识别)`}
message={
editing
? `商品名称:${editing.displayName || '(无)'}(仅供识别)`
: '保存时自动创建标准商品,不需要先去商品目录手工新建'
}
description="一个 SKU 可以配置多个渠道,客服确认履约时选择本次实际渠道。积分渠道填整数积分;备用金现金和统一结算填金额,最多两位小数。填 0 表示成本不固定,由客服确认时填写。"
/>
<Form<ConfigFormValues> form={form} layout="vertical">
<Form.Item
name="skuKey"
label="内部 SKU"
rules={[
{ required: true, message: '请输入内部 SKU' },
{
validator: async (_, value) => {
const text = String(value || '').trim()
if (text && !/\p{Script=Han}/u.test(text)) {
throw new Error('内部 SKU 必须包含中文')
}
},
},
]}
extra="保存后会生成 man:<内部 SKU> 标准商品,并统一用于订单渠道判定。"
>
<Input
disabled={Boolean(editing)}
placeholder="例如:破片手榴弹-兔兔白日梦"
maxLength={60}
showCount
/>
</Form.Item>
<Form.List
name="channels"
rules={[
@@ -5,7 +5,6 @@
* aff: 自动发货 / tpl: 接单模板 / wo: 工单证据 / man: 人工。
*/
import {
ClearOutlined,
DeleteOutlined,
DollarOutlined,
EditOutlined,
@@ -37,13 +36,12 @@ import PercentTable from '@/components/common/PercentTable'
import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import type { Dayjs } from 'dayjs'
import { useNavigate } from 'react-router'
import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
import dayjs from '@/lib/dayjs'
import {
applyAdminTemplateProductBootstrap,
archiveAdminHistoricalManualProducts,
archiveAdminSupersededManualProducts,
createAdminProductCatalog,
deleteAdminProductCatalog,
fetchAdminProductCatalog,
@@ -95,6 +93,7 @@ function DisabledTemplateSyncOption({
export function ProductCatalogPanel() {
const { message, modal } = App.useApp()
const queryClient = useQueryClient()
const navigate = useNavigate()
const { canConfig, productSourceFilter, refreshAll, setScope, setActiveTab } = useCostCenter()
const [productForm] = Form.useForm<ProductFormValues>()
// 历史修复只处理最近 30 天,避免很早以前的乱 SKU 再次进入商品与映射。
@@ -109,8 +108,6 @@ export function ProductCatalogPanel() {
const [productStatusUpdatingId, setProductStatusUpdatingId] = useState<number | null>(null)
const [bootstrapPreviewing, setBootstrapPreviewing] = useState(false)
const [bootstrapApplying, setBootstrapApplying] = useState(false)
const [archivingHistoricalManuals, setArchivingHistoricalManuals] = useState(false)
const [archivingSupersededManuals, setArchivingSupersededManuals] = useState(false)
const bootstrapSyncOptionsRef = useRef<BootstrapSyncOptions>({ includeDisabledTemplates: false })
const [productPage, setProductPage] = useState(1)
const [productPageSize, setProductPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
@@ -452,76 +449,6 @@ export function ProductCatalogPanel() {
}
}
async function archiveHistoricalManuals() {
setArchivingHistoricalManuals(true)
try {
const preview = (await archiveAdminHistoricalManualProducts()).data
if (preview.candidateCount === 0) {
message.info('没有需要归档的历史人工商品')
return
}
modal.confirm({
title: '归档历史人工商品',
content: `将归档 ${preview.candidateCount} 个仅被已归档订单引用的历史人工商品。不会删除订单、订单项、成本或工单数据,商品只是从当前目录隐藏。`,
okText: '确认归档',
cancelText: '取消',
onOk: async () => {
setArchivingHistoricalManuals(true)
try {
const result = (await archiveAdminHistoricalManualProducts({ execute: true })).data
message.success(`已归档 ${result.archivedCount} 个历史人工商品`)
await queryClient.invalidateQueries({ queryKey: ['admin-cost-center-products'] })
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-overview'] })
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-options'] })
} catch (error) {
message.error(error instanceof Error ? error.message : '归档历史人工商品失败')
} finally {
setArchivingHistoricalManuals(false)
}
},
})
} catch (error) {
message.error(error instanceof Error ? error.message : '读取历史人工商品失败')
} finally {
setArchivingHistoricalManuals(false)
}
}
async function archiveSupersededManuals() {
setArchivingSupersededManuals(true)
try {
const preview = (await archiveAdminSupersededManualProducts()).data
if (preview.candidateCount === 0) {
message.info('没有已被其他渠道接管的人工归一商品')
return
}
modal.confirm({
title: '归档跨渠道重复的人工归一商品',
content: `发现 ${preview.candidateCount} 个 man: 商品已有自动发货、接单平台或卡券主渠道。将归档这些旧商品,并把仍指向它们的人工兜底改指当前主商品;不会删除商品行、订单、订单项、成本版本或成本快照。`,
okText: '确认归档',
cancelText: '取消',
onOk: async () => {
setArchivingSupersededManuals(true)
try {
const result = (await archiveAdminSupersededManualProducts({ execute: true })).data
message.success(
`已归档 ${result.archivedCount} 个重复商品,修正 ${result.repointedChannelCount} 条人工兜底`,
)
refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '归档跨渠道重复商品失败')
} finally {
setArchivingSupersededManuals(false)
}
},
})
} catch (error) {
message.error(error instanceof Error ? error.message : '读取跨渠道重复商品失败')
} finally {
setArchivingSupersededManuals(false)
}
}
function deleteProduct(item: ProductCatalogItem) {
const affiliateHint =
item.source === 'affiliate_dash'
@@ -777,24 +704,6 @@ export function ProductCatalogPanel() {
extra={
canConfig ? (
<Space size={6} wrap>
{productSourceFilter === 'manual' ? (
<>
<Button
icon={<ClearOutlined />}
loading={archivingSupersededManuals}
onClick={() => void archiveSupersededManuals()}
>
归档跨渠道重复商品
</Button>
<Button
icon={<ClearOutlined />}
loading={archivingHistoricalManuals}
onClick={() => void archiveHistoricalManuals()}
>
归档纯历史人工商品
</Button>
</>
) : null}
{productSourceFilter === 'work_order' ? (
<>
<AdminRangePicker
@@ -818,13 +727,46 @@ export function ProductCatalogPanel() {
loading={productsQuery.isFetching}
onClick={() => void productsQuery.refetch()}
/>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateProduct}>
新建商品
</Button>
{productSourceFilter === 'manual' ? (
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() =>
void navigate('/admin/cost-center?scope=manual&tab=cost&manualNew=1')
}
>
新增人工 SKU
</Button>
) : productSourceFilter === 'third_party_voucher' ? (
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() =>
void navigate(
'/admin/cost-center?scope=third_party_voucher&tab=cost&voucherNew=1',
)
}
>
新增卡券 SKU
</Button>
) : (
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateProduct}>
新建商品
</Button>
)}
</Space>
) : null
}
>
{productSourceFilter === 'manual' ? (
<Alert
type="info"
showIcon
style={{ marginBottom: 14 }}
message="人工标准商品由人工 SKU 配置自动创建"
description="请到“成本配置 → 人工 SKU 配置”新增内部 SKU 并填写人工渠道和成本;保存时会自动创建或复用 man: 商品。"
/>
) : null}
{showCategoryBar ? (
<>
<div className="product-rules-category-tabs-bar">
@@ -4,7 +4,7 @@
* 商品编码固定为 `tpv:<sku>`;成本写入 `sys_third_party_voucher_cost` 的
* 商品级版本,由成本快照管道消费。
*/
import { DeleteOutlined, ReloadOutlined, SyncOutlined } from '@ant-design/icons'
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'
import {
Button,
Card,
@@ -19,13 +19,14 @@ import {
message,
} from 'antd'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useSearchParams } from 'react-router'
import PercentTable from '@/components/common/PercentTable'
import {
deleteAdminVoucherProduct,
ensureVoucherChannelRules,
fetchAdminProductCatalog,
saveAdminVoucherProduct,
setAdminProductPrice,
} from '@/services/admin'
import type { ProductCatalogItem } from '@/types/admin'
@@ -35,12 +36,20 @@ import { useCostCenter } from './CostCenterContext'
export default function VoucherSkuPanel() {
const queryClient = useQueryClient()
const [searchParams] = useSearchParams()
const { canConfig, refreshAll } = useCostCenter()
const handledCreateLinkRef = useRef(false)
const voucherNewRequested = searchParams.get('voucherNew') === '1'
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
const [selected, setSelected] = useState<ProductCatalogItem | null>(null)
const [ensuringRules, setEnsuringRules] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [form] = Form.useForm<{ amountFen: number; changeReason?: string }>()
const [createForm] = Form.useForm<{
skuKey: string
amountYuan: number
note?: string
}>()
const query = useQuery({
queryKey: ['cost-center-voucher-products', page, pageSize],
@@ -68,6 +77,25 @@ export default function VoucherSkuPanel() {
onError: (error) => message.error(error instanceof Error ? error.message : '保存失败'),
})
const createMutation = useMutation({
mutationFn: (values: { skuKey: string; amountYuan: number; note?: string }) =>
saveAdminVoucherProduct({
skuKey: values.skuKey.trim(),
amountFen: Math.round(values.amountYuan * 100),
note: values.note || '',
}),
onSuccess: (response) => {
message.success(
`卡券 SKU 已保存并回填 ${response.data.linkedItemCount} 个订单项,补建成本快照 ${response.data.snapshotCreatedCount} 条`,
)
setCreateOpen(false)
createForm.resetFields()
void queryClient.invalidateQueries({ queryKey: ['cost-center-voucher-products'] })
refreshAll()
},
onError: (error) => message.error(error instanceof Error ? error.message : '保存卡券 SKU 失败'),
})
const deleteMutation = useMutation({
mutationFn: (productId: number) => deleteAdminVoucherProduct(productId),
onSuccess: (response) => {
@@ -82,23 +110,15 @@ export default function VoucherSkuPanel() {
onError: (error) => message.error(error instanceof Error ? error.message : '删除失败'),
})
async function handleEnsureRules() {
setEnsuringRules(true)
try {
const result = await ensureVoucherChannelRules()
message.success(
`卡券商品 ${result.data.productCount} 个,已补齐渠道规则 ${result.data.created} 条`,
)
} catch (error) {
message.error(error instanceof Error ? error.message : '补齐卡券渠道规则失败')
} finally {
setEnsuringRules(false)
}
}
const items = query.data?.data.items || []
const total = query.data?.data.pagination.total || 0
useEffect(() => {
if (!canConfig || !voucherNewRequested || handledCreateLinkRef.current) return
handledCreateLinkRef.current = true
setCreateOpen(true)
}, [canConfig, voucherNewRequested])
const columns = [
{ title: '商品', dataIndex: 'canonicalName', ellipsis: true },
{ title: '编码', dataIndex: 'canonicalSku', width: 220 },
@@ -169,14 +189,11 @@ export default function VoucherSkuPanel() {
title={`卡券商品采购价(${total})`}
extra={
<Space size={8}>
<Button
icon={<SyncOutlined />}
loading={ensuringRules}
disabled={!canConfig}
onClick={() => void handleEnsureRules()}
>
批量生成渠道规则
</Button>
{canConfig ? (
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
新增卡券 SKU
</Button>
) : null}
<Button icon={<ReloadOutlined />} onClick={() => void query.refetch()}>
刷新
</Button>
@@ -184,8 +201,8 @@ export default function VoucherSkuPanel() {
}
>
<Typography.Paragraph type="secondary">
卡券渠道只统计:命中规则后不创建任务、不发领取页,只记收入与采购成本。 商品编码以{' '}
<Typography.Text code>tpv:</Typography.Text> 开头。
新增时只需填写内部 SKU 和采购价,系统会自动创建 <Typography.Text code>tpv:</Typography.Text>{' '}
商品、卡券渠道规则并回填已有订单。卡券不创建任务、不发领取页,只记收入与采购成本。
</Typography.Paragraph>
<PercentTable
rowKey="id"
@@ -204,6 +221,50 @@ export default function VoucherSkuPanel() {
})}
/>
<Modal
title="新增第三方卡券 SKU"
open={createOpen}
onCancel={() => setCreateOpen(false)}
onOk={() => createForm.submit()}
confirmLoading={createMutation.isPending}
destroyOnHidden
>
<Form
form={createForm}
layout="vertical"
onFinish={(values) => createMutation.mutate(values)}
>
<Form.Item
name="skuKey"
label="内部 SKU"
rules={[
{ required: true, message: '请输入内部 SKU' },
{
validator: async (_, value) => {
const text = String(value || '').trim()
if (text && !/\p{Script=Han}/u.test(text)) {
throw new Error('内部 SKU 必须包含中文')
}
},
},
]}
extra="保存后自动生成 tpv:<内部 SKU> 标准商品和卡券渠道规则。"
>
<Input placeholder="例如:11月改名卡" maxLength={60} showCount />
</Form.Item>
<Form.Item
name="amountYuan"
label="采购价(元)"
rules={[{ required: true, message: '请输入采购价' }]}
>
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="note" label="备注">
<Input.TextArea rows={2} maxLength={300} showCount />
</Form.Item>
</Form>
</Modal>
<Modal
title={`设置采购价:${selected?.canonicalName || ''}`}
open={Boolean(selected)}
@@ -1,5 +1,5 @@
import { PlusOutlined, ReloadOutlined, RollbackOutlined } from '@ant-design/icons'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Alert,
App,
@@ -34,18 +34,22 @@ import {
createAdminFundExpense,
createAdminFundReturn,
fetchAdminFundMyAccount,
fetchAdminOnAccountBills,
fetchAdminPlatformCreditAccounts,
fetchAdminPlatformCreditEntries,
reconcileAdminOnAccountBill,
} from '@/services/admin'
import type {
AdminFundEntry,
AdminFundExpense,
AdminFundExpenseCategory,
AdminFundPayeeType,
OnAccountBill,
PlatformCreditEntry,
} from '@/types/admin'
import type { UploadedFile } from '@/types/worker-platform'
import { formatAdminDateTime } from '@/utils/admin-time'
import { hasAdminPermission } from '@/utils/admin-auth'
import { formatCreditUnitPriceLabel } from '@/utils/credit-unit-price'
import {
FUND_ACCOUNT_STATUS_LABELS,
@@ -79,14 +83,23 @@ type ReturnFormValues = {
note?: string
}
type OnAccountReconcileFormValues = {
amountYuan: number
payeeName?: string
note?: string
}
export default function FundSupportView() {
const { message } = App.useApp()
const queryClient = useQueryClient()
const [expenseOpen, setExpenseOpen] = useState(false)
const [returnOpen, setReturnOpen] = useState(false)
const [reconcileBill, setReconcileBill] = useState<OnAccountBill | null>(null)
const [submitting, setSubmitting] = useState(false)
const [expenseForm] = Form.useForm<ExpenseFormValues>()
const [returnForm] = Form.useForm<ReturnFormValues>()
const [reconcileForm] = Form.useForm<OnAccountReconcileFormValues>()
const canReconcileOnAccount = hasAdminPermission('finance.reconcile')
const myQuery = useQuery({
queryKey: ['admin-fund-my'],
@@ -110,9 +123,35 @@ export default function FundSupportView() {
refetchOnMount: 'always',
refetchInterval: 15_000,
})
const onAccountBillsQuery = useQuery({
queryKey: ['admin-on-account-bills', 'pending'],
queryFn: () => fetchAdminOnAccountBills({ status: 'pending', pageSize: 100 }),
refetchOnMount: 'always',
})
const creditAccounts = creditAccountsQuery.data?.data.items || []
const creditEntries = creditEntriesQuery.data?.data.items || []
const creditPlatforms = creditAccountsQuery.data?.data.platforms || []
const onAccountBills = onAccountBillsQuery.data?.data.items || []
const reconcileMutation = useMutation({
mutationFn: (values: OnAccountReconcileFormValues) =>
reconcileAdminOnAccountBill(reconcileBill!.id, {
amountFen: toFen(values.amountYuan),
payeeName: values.payeeName || '',
note: values.note || '',
}),
onSuccess: () => {
message.success('统一结算账单已对账,实际成本已入账')
setReconcileBill(null)
reconcileForm.resetFields()
void queryClient.invalidateQueries({ queryKey: ['admin-on-account-bills'] })
void queryClient.invalidateQueries({ queryKey: ['admin-sales-profit'] })
void queryClient.invalidateQueries({ queryKey: ['admin-finance'] })
void queryClient.invalidateQueries({ queryKey: ['admin-orders'] })
void queryClient.invalidateQueries({ queryKey: ['admin-order-detail'] })
},
onError: (error) => message.error(error instanceof Error ? error.message : '统一结算对账失败'),
})
/** 客服自己的三个积分钱包,按平台顺序排好。 */
const myCreditWallets = creditPlatforms.map((platform) => {
@@ -132,6 +171,7 @@ export default function FundSupportView() {
void queryClient.invalidateQueries({ queryKey: ['admin-fund-expenses'] })
void queryClient.invalidateQueries({ queryKey: ['admin-platform-credit-accounts'] })
void queryClient.invalidateQueries({ queryKey: ['admin-platform-credit-entries'] })
void queryClient.invalidateQueries({ queryKey: ['admin-on-account-bills'] })
}
function openExpense() {
@@ -155,6 +195,15 @@ export default function FundSupportView() {
setReturnOpen(true)
}
function openReconcileBill(bill: OnAccountBill) {
setReconcileBill(bill)
reconcileForm.setFieldsValue({
amountYuan: bill.amountFen > 0 ? bill.amountFen / 100 : undefined,
payeeName: bill.payeeName || '',
note: bill.note || '',
})
}
async function submitExpense(values: ExpenseFormValues) {
if (!account) return
setSubmitting(true)
@@ -384,6 +433,56 @@ export default function FundSupportView() {
{ title: '说明', dataIndex: 'note', render: (value: string) => value || '-' },
]
const onAccountBillColumns: TableColumnsType<OnAccountBill> = [
{ title: '账单号', dataIndex: 'billNo', width: 180 },
{
title: '订单 / SKU',
width: 260,
render: (_, row) => (
<Space direction="vertical" size={0}>
<Typography.Text copyable={{ text: row.platformOrderId }}>
{row.platformOrderId || `#${row.orderId}`}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{row.skuName || row.skuKey}
</Typography.Text>
</Space>
),
},
{
title: '订单收入',
dataIndex: 'revenueFen',
width: 120,
render: (value: number) => formatFundAmount(value),
},
{
title: '待结算金额',
dataIndex: 'amountFen',
width: 130,
render: (value: number) => (value > 0 ? formatFundAmount(value) : '待填写'),
},
{
title: '创建时间',
dataIndex: 'createdAt',
width: 170,
render: (value: string) => formatAdminDateTime(value),
},
{
title: '操作',
width: 110,
render: (_, row) => (
<Button
size="small"
type="primary"
disabled={!canReconcileOnAccount}
onClick={() => openReconcileBill(row)}
>
对账
</Button>
),
},
]
return (
<section className="page-stack">
<PageHeader
@@ -475,6 +574,38 @@ export default function FundSupportView() {
</Card>
</div>
<Card
title={`统一结算待对账(${onAccountBillsQuery.data?.data.pagination.total || 0})`}
extra={
<Button
size="small"
icon={<ReloadOutlined />}
loading={onAccountBillsQuery.isFetching}
onClick={() => void onAccountBillsQuery.refetch()}
>
刷新
</Button>
}
>
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message="统一结算金额未确认前不会生成 actual 成本快照"
description="完成对账后,系统按实际金额生成成本快照并重算销售利润。"
/>
<PercentTable<OnAccountBill>
rowKey="id"
size="small"
columns={onAccountBillColumns}
dataSource={onAccountBills}
loading={onAccountBillsQuery.isLoading}
pagination={false}
scroll={{ x: 980 }}
locale={{ emptyText: '没有待对账的统一结算账单' }}
/>
</Card>
{account ? (
<Card>
<Descriptions column={2} size="small">
@@ -699,6 +830,43 @@ export default function FundSupportView() {
</Form.Item>
</Form>
</Modal>
<Modal
title={`统一结算对账${reconcileBill ? ` · ${reconcileBill.billNo}` : ''}`}
open={Boolean(reconcileBill)}
onCancel={() => setReconcileBill(null)}
onOk={() => reconcileForm.submit()}
confirmLoading={reconcileMutation.isPending}
okText="确认对账"
destroyOnHidden
>
<Alert
type="warning"
showIcon
style={{ marginBottom: 14 }}
message="确认后将按本次金额生成 actual 成本快照"
description="订单收入不会改变;如果此前已有固定成本快照,会用本次实际结算金额替代。"
/>
<Form<OnAccountReconcileFormValues>
form={reconcileForm}
layout="vertical"
onFinish={(values) => reconcileMutation.mutate(values)}
>
<Form.Item
name="amountYuan"
label="实际结算金额(元)"
rules={[{ required: true, message: '请输入实际结算金额' }]}
>
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="payeeName" label="实际收款方">
<Input maxLength={120} />
</Form.Item>
<Form.Item name="note" label="对账备注">
<Input.TextArea rows={2} maxLength={300} showCount />
</Form.Item>
</Form>
</Modal>
</section>
)
}
@@ -315,6 +315,31 @@ export default function VoucherConsumePolicyPanel() {
width: 170,
render: (value: string) => value || '-',
},
{
title: '核销码',
dataIndex: 'voucher_codes',
width: 210,
render: (values: string[] | null | undefined) => {
const codes = Array.isArray(values)
? values.map((value) => String(value || '').trim()).filter(Boolean)
: []
if (codes.length === 0) {
return '-'
}
return (
<Space direction="vertical" size={2}>
{codes.slice(0, 3).map((code) => (
<Typography.Text key={code} copyable={{ text: code }} ellipsis>
{code}
</Typography.Text>
))}
{codes.length > 3 ? (
<Typography.Text type="secondary">另有 {codes.length - 3} 个</Typography.Text>
) : null}
</Space>
)
},
},
{
title: '计划时间',
dataIndex: 'due_at',
@@ -415,7 +440,7 @@ export default function VoucherConsumePolicyPanel() {
<Input.Search
allowClear
prefix={<SearchOutlined />}
placeholder="任务号 / 订单号 / 平台单号"
placeholder="任务号 / 订单号 / 平台单号 / 核销码"
value={keywordDraft}
onChange={(event) => {
const value = event.target.value
@@ -492,7 +517,7 @@ export default function VoucherConsumePolicyPanel() {
setPageSize(nextPageSize)
},
})}
scroll={{ x: 1200 }}
scroll={{ x: 1420 }}
/>
</Space>
)
+40 -30
View File
@@ -24,6 +24,8 @@ import type {
ManualSkuListResult,
ManualSkuOverview,
ManualSkuScanResult,
OnAccountBill,
OnAccountBillListResult,
ProductCatalogItem,
ProductCatalogListResult,
TemplateProductBootstrapPreview,
@@ -96,32 +98,6 @@ export function createAdminProductCatalog(payload: Record<string, unknown>) {
return apiPost<{ product: ProductCatalogItem }>('/api/v1/admin/cost-center/products', payload)
}
export function archiveAdminHistoricalManualProducts(payload: { execute?: boolean } = {}) {
return apiPost<{
dryRun: boolean
candidateCount: number
archivedCount: number
sample: Array<{ id: number; canonical_sku: string; canonical_name: string }>
}>('/api/v1/admin/cost-center/products/archive-historical-manual', payload)
}
export function archiveAdminSupersededManualProducts(payload: { execute?: boolean } = {}) {
return apiPost<{
dryRun: boolean
candidateCount: number
archivedCount: number
repointedChannelCount: number
sample: Array<{
id: number
canonical_sku: string
canonical_name: string
primary_channel: string
target_product_id: number
target_product_sku: string
}>
}>('/api/v1/admin/cost-center/products/archive-superseded-manual', payload)
}
export function syncAdminAffiliateDashProducts() {
return apiPost<{ createdCount: number; updatedCount: number; productCount: number }>(
'/api/v1/admin/cost-center/affiliate-products/sync',
@@ -551,10 +527,21 @@ export function syncAdminProductPrice(productId: number) {
}>(`/api/v1/admin/cost-center/products/${productId}/sync-price`, {})
}
export function ensureVoucherChannelRules() {
return apiPost<{ created: number; productCount: number }>(
'/api/v1/admin/cost-center/voucher-products/ensure-rules',
)
export function saveAdminVoucherProduct(payload: {
skuKey: string
amountFen: number
effectiveFrom?: string
note?: string
}) {
return apiPost<{
product: ProductCatalogItem
costVersionId: number
amountFen: number
effectiveFrom: string
linkedItemCount: number
snapshotCreatedCount: number
refreshedDays: number
}>('/api/v1/admin/cost-center/voucher-products', payload)
}
export function deleteAdminVoucherProduct(productId: number) {
@@ -570,3 +557,26 @@ export function deleteAdminVoucherProduct(productId: number) {
}
}>(`/api/v1/admin/cost-center/voucher-products/${productId}`)
}
export function fetchAdminOnAccountBills(
params: {
page?: number
pageSize?: number
keyword?: string
status?: string
} = {},
) {
return apiGet<OnAccountBillListResult>('/api/v1/admin/finance/on-account-bills', params)
}
export function reconcileAdminOnAccountBill(
billId: number,
payload: { amountFen: number; payeeName?: string; note?: string },
) {
return apiPost<{
bill: OnAccountBill
orderItemId: number
snapshotId: number
amountFen: number
}>(`/api/v1/admin/finance/on-account-bills/${billId}/reconcile`, payload)
}
@@ -209,6 +209,7 @@ export type VoucherConsumeJob = {
attempt_count: number
max_attempts: number
last_error: string
voucher_codes: string[]
created_at: string
updated_at: string
finished_at: string | null
@@ -8,6 +8,8 @@ import type {
AdminKuaishouIndustryVoucherConsumeResult,
AdminKuaishouIndustryVoucherDestroyResult,
AdminKuaishouIndustryVoucherListResult,
AdminKuaishouIndustryVoucherReissueConsumePayload,
AdminKuaishouIndustryVoucherReissueConsumeResult,
AdminKuaishouIndustryVoucherResendResult,
AdminKuaishouIndustryVoucherToolPayload,
} from '@/types/admin'
@@ -84,6 +86,15 @@ export function resendAdminKuaishouIndustryVoucherCode(
)
}
export function reissueAndConsumeAdminKuaishouIndustryVoucher(
payload: AdminKuaishouIndustryVoucherReissueConsumePayload,
) {
return apiPost<AdminKuaishouIndustryVoucherReissueConsumeResult>(
'/api/v1/admin/kuaishou-industry/vouchers/reissue-consume',
payload,
)
}
export function destroyAdminKuaishouIndustryVoucher(
payload: AdminKuaishouIndustryVoucherToolPayload,
) {
+30 -1
View File
@@ -564,7 +564,7 @@ export interface ManualFulfillmentPendingListResult {
export interface ManualFulfillmentConfirmResult {
orderItemId: number
orderId: number
costSnapshotId: number
costSnapshotId: number | null
platformKey: string
platformLabel: string
creditQuantity: number
@@ -573,4 +573,33 @@ export interface ManualFulfillmentConfirmResult {
creditEntryId: number | null
balanceAfter: number | null
onAccountBillId: number | null
costPending: boolean
}
export interface OnAccountBill {
id: number
billNo: string
orderId: number
orderItemId: number
skuKey: string
skuName: string
canonicalProductId: number | null
platformOrderId: string
provider: string
shopId: string
revenueFen: number
amountFen: number
status: 'pending' | 'reconciled' | 'void'
payeeName: string
note: string
metadata: Record<string, unknown>
createdByAdminUserId: number | null
createdAt: string
updatedAt: string
reconciledAt: string | null
}
export interface OnAccountBillListResult {
items: OnAccountBill[]
pagination: { page: number; pageSize: number; total: number }
}
+2
View File
@@ -81,6 +81,8 @@ export type {
AdminKuaishouIndustryShopListResult,
AdminKuaishouIndustryVoucherConsumeResult,
AdminKuaishouIndustryVoucherDestroyResult,
AdminKuaishouIndustryVoucherReissueConsumeResult,
AdminKuaishouIndustryVoucherReissueConsumePayload,
AdminKuaishouIndustryVoucherResendResult,
AdminKuaishouIndustryRefundListPayload,
AdminKuaishouIndustryRefundApprovePayload,
@@ -94,6 +94,26 @@ export interface AdminKuaishouIndustryVoucherResendResult {
voucher: AdminKuaishouIndustryVoucher | null
}
export interface AdminKuaishouIndustryVoucherReissueConsumeResult {
success: boolean
stage: 'send' | 'consume' | 'completed' | string
reason: string
validMinutes: number
originalValidEndTime: number
validEndTime: number
sendSuccess: boolean
consumeSuccess: boolean
response: Record<string, unknown> | null
error: string
voucher: AdminKuaishouIndustryVoucher | null
task: null | {
taskId: number
taskNo: string
status: string
deliveryStatus: string
}
}
export interface AdminKuaishouIndustryRefundListPayload {
sellerId?: string
beginTime?: number | string
@@ -149,3 +169,8 @@ export interface AdminKuaishouIndustryVoucherToolPayload {
expressNo?: string
etickets?: Array<{ id?: string; code?: string; num?: number | string }>
}
export interface AdminKuaishouIndustryVoucherReissueConsumePayload extends AdminKuaishouIndustryVoucherToolPayload {
validMinutes: number | string
reason: string
}
@@ -11,3 +11,21 @@ test('客服我的钱包展示统一结算虚拟账单入口', async () => {
assert.ok(source.includes('生成虚拟账单,不扣积分或备用金'))
assert.ok(source.includes('support-wallet-grid'))
})
test('客服钱包提供统一结算待对账列表和实际金额确认', async () => {
const [source, service, confirmModal] = await Promise.all([
readFile(new URL('../src/pages/admin/fund/FundSupportView.tsx', import.meta.url), 'utf8'),
readFile(new URL('../src/services/admin/cost-center.ts', import.meta.url), 'utf8'),
readFile(
new URL('../src/components/admin/ManualFulfillmentConfirmModal.tsx', import.meta.url),
'utf8',
),
])
assert.ok(source.includes('统一结算待对账'))
assert.ok(source.includes('实际结算金额(元)'))
assert.ok(source.includes('确认对账'))
assert.ok(confirmModal.includes('costPending'))
assert.ok(service.includes('/api/v1/admin/finance/on-account-bills'))
assert.ok(service.includes('/reconcile'))
})
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
test('快手电子凭证页提供受控的历史券补发并核销入口', () => {
const page = readFileSync(
new URL('../src/pages/admin/AdminKuaishouIndustryPage.tsx', import.meta.url).pathname,
'utf8',
)
const service = readFileSync(
new URL('../src/services/admin/kuaishou-industry.ts', import.meta.url).pathname,
'utf8',
)
assert.ok(page.includes('历史补发并核销'), '页面必须提供补发并核销按钮')
assert.ok(page.includes('reissueAndConsumeAdminKuaishouIndustryVoucher'), '页面必须调用专用接口')
assert.ok(page.includes('reissueConsumeOpen'), '操作必须通过确认弹窗执行')
assert.ok(page.includes('reissueConsumeConfirmed'), '提交前必须显式勾选确认')
assert.ok(page.includes('临时有效期(分钟)'), '必须允许设置短期临时有效期')
assert.ok(
page.includes("source: 'admin_historical_reissue_consume'") === false,
'前端不能伪造核销来源',
)
assert.ok(
service.includes('/api/v1/admin/kuaishou-industry/vouchers/reissue-consume'),
'必须调用后端补发并核销接口',
)
})
test('历史券补发并核销结果必须展示发码、核销两个阶段', () => {
const page = readFileSync(
new URL('../src/pages/admin/AdminKuaishouIndustryPage.tsx', import.meta.url).pathname,
'utf8',
)
assert.ok(page.includes("kind === 'reissue-consume'"), '结果视图必须识别补发并核销动作')
assert.ok(
page.includes("...(kind === 'reissue-consume' ? [record.error] : [])"),
'核销阶段失败时必须优先展示消费错误,而不是发码成功的 success 响应',
)
assert.ok(page.includes("['发码成功', record.sendSuccess]"), '结果必须展示发码阶段状态')
assert.ok(page.includes("['核销成功', record.consumeSuccess]"), '结果必须展示核销阶段状态')
assert.ok(page.includes("pushHighlight(highlights, '补发原因'"), '结果必须展示补偿原因')
})
@@ -8,10 +8,56 @@ test('人工成本配置直接展示标准商品,不再要求按日期扫描
'utf8',
)
assert.ok(source.includes('人工标准商品与成本配置一一对应'))
assert.ok(source.includes('人工标准商品由人工配置自动创建'))
assert.ok(source.includes("title: '人工标准商品'"))
assert.ok(source.includes('SKU:{row.skuKey}'))
assert.ok(!source.includes('扫描内部 SKU'))
assert.ok(!source.includes('DatePicker.RangePicker'))
assert.ok(!source.includes('rematchAdminManualFulfillments'))
})
test('新增人工 SKU 直接保存配置,由后端自动创建标准商品', async () => {
const source = await readFile(
new URL('../src/pages/admin/cost-center/ManualSkuConfigPanel.tsx', import.meta.url),
'utf8',
)
assert.ok(source.includes('新增人工 SKU'))
assert.ok(source.includes('openCreateConfig'))
assert.ok(source.includes('name="skuKey"'))
assert.ok(source.includes('保存时自动创建标准商品'))
assert.ok(source.includes('manualSku'))
assert.ok(source.includes("returnTo.startsWith('/admin/fulfillment-channel')"))
})
test('人工商品不再允许手工创建或执行旧批量归档', async () => {
const [productSource, serviceSource, queueSource, shellSource] = await Promise.all([
readFile(
new URL('../src/pages/admin/cost-center/ProductCatalogPanel.tsx', import.meta.url),
'utf8',
),
readFile(
new URL('../../backend/src/services/cost-center/product-catalog-service.ts', import.meta.url),
'utf8',
),
readFile(
new URL('../src/pages/admin/AdminFulfillmentChannelPage.tsx', import.meta.url),
'utf8',
),
readFile(
new URL('../src/pages/admin/cost-center/CostCenterShell.tsx', import.meta.url),
'utf8',
),
])
assert.ok(!productSource.includes('归档跨渠道重复商品'))
assert.ok(!productSource.includes('归档纯历史人工商品'))
assert.ok(productSource.includes("productSourceFilter === 'manual'"))
assert.ok(productSource.includes('manualNew=1'))
assert.ok(productSource.includes('人工标准商品由人工 SKU 配置自动创建'))
assert.ok(serviceSource.includes('cost_manual_product_managed_by_config'))
assert.ok(queueSource.includes('manualSku=${encodeURIComponent(sku)}'))
assert.ok(queueSource.includes("returnTo=${encodeURIComponent('/admin/fulfillment-channel')}"))
assert.ok(shellSource.includes("searchParams.get('scope')"))
assert.ok(shellSource.includes("searchParams.get('tab')"))
})
@@ -0,0 +1,19 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
test('自动核销最近任务展示并搜索核销码', () => {
const page = readFileSync(
new URL('../src/pages/admin/panels/VoucherConsumePolicyPanel.tsx', import.meta.url).pathname,
'utf8',
)
const service = readFileSync(
new URL('../src/services/admin/fulfillment-channel.ts', import.meta.url).pathname,
'utf8',
)
assert.ok(page.includes("title: '核销码'"), '最近核销任务必须展示核销码列')
assert.ok(page.includes('copyable={{ text: code }}'), '核销码必须支持一键复制')
assert.ok(page.includes('任务号 / 订单号 / 平台单号 / 核销码'), '搜索框必须明确支持核销码')
assert.ok(service.includes('voucher_codes: string[]'), '任务接口类型必须包含核销码数组')
})
@@ -8,7 +8,7 @@ const panel = readFileSync(
)
test('最近核销任务提供关键词、平台、渠道和状态筛选', () => {
assert.ok(panel.includes('placeholder="任务号 / 订单号 / 平台单号"'))
assert.ok(panel.includes('placeholder="任务号 / 订单号 / 平台单号 / 核销码"'))
assert.ok(panel.includes('placeholder="全部平台"'))
assert.ok(panel.includes('placeholder="全部渠道"'))
assert.ok(panel.includes('placeholder="全部状态"'))
@@ -19,3 +19,30 @@ test('第三方卡券面板提供安全删除入口并格式化生效时间', as
assert.ok(service.includes('/api/v1/admin/cost-center/voucher-products/${productId}'))
assert.ok(service.includes("action: 'deleted' | 'archived'"))
})
test('第三方卡券新增 SKU 一次完成商品、规则和采购价', async () => {
const [panel, service, productCatalog] = await Promise.all([
readFile(
new URL('../src/pages/admin/cost-center/VoucherSkuPanel.tsx', import.meta.url),
'utf8',
),
readFile(new URL('../src/services/admin/cost-center.ts', import.meta.url), 'utf8'),
readFile(
new URL('../src/pages/admin/cost-center/ProductCatalogPanel.tsx', import.meta.url),
'utf8',
),
])
assert.ok(panel.includes('新增卡券 SKU'))
assert.ok(panel.includes('saveAdminVoucherProduct'))
assert.ok(panel.includes('保存后自动生成 tpv:<内部 SKU> 标准商品和卡券渠道规则'))
assert.ok(panel.includes('补建成本快照'))
assert.ok(!panel.includes('批量生成渠道规则'))
assert.ok(!panel.includes('ensureVoucherChannelRules'))
assert.ok(service.includes('apiPost<'))
assert.ok(service.includes("'/api/v1/admin/cost-center/voucher-products'"))
assert.ok(!service.includes('/voucher-products/ensure-rules'))
assert.ok(panel.includes("searchParams.get('voucherNew')"))
assert.ok(productCatalog.includes("productSourceFilter === 'third_party_voucher'"))
assert.ok(productCatalog.includes('voucherNew=1'))
})
+162 -86
View File
@@ -7,16 +7,16 @@
## 一、字段替换总表
| 旧字段 | 新字段 | 出现位置 |
| --- | --- | --- |
| `cost_items.carrier_type` | `cost_items.cost_model` | DB / 成本项接口 |
| `cost_items.cost_type` | (并入 `cost_model`) | DB / 成本项接口 |
| `cost_snapshots.carrier_type` | (删除) | DB |
| `cost_exceptions.carrier_type` | (删除) | DB |
| `product_catalog.carrier_type` | (删除,改由 `canonical_sku` 前缀推导) | DB / 商品接口 |
| `sales_daily_facts.carrier_type` | `sales_daily_facts.channel` | DB / 销售事实接口 |
| `work_product_rules.legacy_cost_source` | (删除) | DB |
| `manual_expense_records` / `manual_expense_events` | (删除,人工成本只走确认履约) | DB |
| 旧字段 | 新字段 | 出现位置 |
| -------------------------------------------------- | --------------------------------------- | ----------------- |
| `cost_items.carrier_type` | `cost_items.cost_model` | DB / 成本项接口 |
| `cost_items.cost_type` | (并入 `cost_model`) | DB / 成本项接口 |
| `cost_snapshots.carrier_type` | (删除) | DB |
| `cost_exceptions.carrier_type` | (删除) | DB |
| `product_catalog.carrier_type` | (删除,改由 `canonical_sku` 前缀推导) | DB / 商品接口 |
| `sales_daily_facts.carrier_type` | `sales_daily_facts.channel` | DB / 销售事实接口 |
| `work_product_rules.legacy_cost_source` | (删除) | DB |
| `manual_expense_records` / `manual_expense_events` | (删除,人工成本只走确认履约) | DB |
## 二、枚举映射
@@ -24,16 +24,16 @@
成本模型与履约方式正交:专属模型只对对应履约方式生效,通用模型对所有履约方式生效。
| 新值 | 含义 | 取代旧的 |
| --- | --- | --- |
| `affiliate_purchase` | 自动发货上游采购成本 | `cost_type=auto_purchase` |
| `work_order_reward` | 接单平台打手报酬 | `cost_type=work_order_reward` |
| `third_party_voucher_cost` | 第三方卡券采购成本 | 新增 |
| `manual_actual` | 人工客服实际支出 | `cost_type=manual_service` |
| `platform_fee` | 平台服务费 | `cost_type=platform_fee` |
| `payment_fee` | 支付通道费 | `cost_type=payment_fee` |
| `after_sales_loss` | 售后损失 | `cost_type=after_sales_loss` |
| `other` | 其他 | `cost_type=shipping_other` / `cost_type=other` |
| 新值 | 含义 | 取代旧的 |
| -------------------------- | -------------------- | ---------------------------------------------- |
| `affiliate_purchase` | 自动发货上游采购成本 | `cost_type=auto_purchase` |
| `work_order_reward` | 接单平台打手报酬 | `cost_type=work_order_reward` |
| `third_party_voucher_cost` | 第三方卡券采购成本 | 新增 |
| `manual_actual` | 人工客服实际支出 | `cost_type=manual_service` |
| `platform_fee` | 平台服务费 | `cost_type=platform_fee` |
| `payment_fee` | 支付通道费 | `cost_type=payment_fee` |
| `after_sales_loss` | 售后损失 | `cost_type=after_sales_loss` |
| `other` | 其他 | `cost_type=shipping_other` / `cost_type=other` |
### 2.2 `pricing_mode`(精简为 3 个)
@@ -67,7 +67,7 @@ DELETE /api/v1/admin/cost-center/cost-items/:id
"unitName": "件",
"currency": "CNY",
"status": "active | archived",
"note": ""
"note": "",
}
```
@@ -88,7 +88,7 @@ DELETE /api/v1/admin/cost-center/cost-items/:id
"note": "",
"snapshotCount": 0,
"createdAt": "...",
"updatedAt": "..."
"updatedAt": "...",
}
```
@@ -96,13 +96,13 @@ DELETE /api/v1/admin/cost-center/cost-items/:id
商品**不再返回 `carrierType`**。前端若要按来源分组,用 `canonicalSku` 前缀:
| 前缀 | 来源 | 建议展示名 |
| --- | --- | --- |
| `aff:` | 自动发货 | 自动发货 |
| `tpl:` | 接单模板 | 接单平台 |
| `wo:` | 工单证据 | 接单平台 |
| `man:` | 人工 | 人工 |
| 其他 | 未分类 | 未分类 |
| 前缀 | 来源 | 建议展示名 |
| ------ | -------- | ---------- |
| `aff:` | 自动发货 | 自动发货 |
| `tpl:` | 接单模板 | 接单平台 |
| `wo:` | 工单证据 | 接单平台 |
| `man:` | 人工 | 人工 |
| 其他 | 未分类 | 未分类 |
`GET /api/v1/admin/cost-center/products` 的 `carrierType` 查询参数**保留**,
但语义改为「按 `canonical_sku` 前缀筛选」,取值 `affiliate_dash | work_order | manual`。
@@ -118,11 +118,11 @@ AND (
### 4.1 渠道映射诊断与未归一队列
| 接口 | 说明 |
| --- | --- |
| `GET /api/v1/admin/cost-center/mappings/diagnostics` | 标准商品数 / 映射数 / 有映射商品数 / 未归一订单项 / 未归一 SKU 组;按 `channel` 拆分聚合 |
| `GET /api/v1/admin/cost-center/mappings/unmapped` | 未归一队列,按「来源 + 店铺 + 平台 SKU + 履约方式」聚合,支持 `keyword` / `provider` / `channel` 与分页 |
| `POST /api/v1/admin/cost-center/mappings/backfill` | 回填订单项映射;返回 `resolvedExceptionCount`(已归一的 `product_mapping_missing` 异常自动收口) |
| 接口 | 说明 |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `GET /api/v1/admin/cost-center/mappings/diagnostics` | 标准商品数 / 映射数 / 有映射商品数 / 未归一订单项 / 未归一 SKU 组;按 `channel` 拆分聚合 |
| `GET /api/v1/admin/cost-center/mappings/unmapped` | 未归一队列,按「来源 + 店铺 + 平台 SKU + 履约方式」聚合,支持 `keyword` / `provider` / `channel` 与分页 |
| `POST /api/v1/admin/cost-center/mappings/backfill` | 回填订单项映射;返回 `resolvedExceptionCount`(已归一的 `product_mapping_missing` 异常自动收口) |
### 4.2 人工商品归一(已删除)
@@ -150,6 +150,66 @@ AND (
商品 `canonical_sku = man:<内部SKU>`,`canonical_name` 优先取 `manual_sku_registry.display_name`,否则取订单里最常见的 `sku_name`。
每日 `cost_finance_sync` 任务会对最近 N 天执行同一归一(不重复收口异常)。
### 4.3 人工 SKU 配置(现行)
人工标准商品不允许通过通用 `POST /api/v1/admin/cost-center/products` 手工创建。
统一入口为:
```text
POST /api/v1/admin/cost-center/manual-configs
```
```jsonc
{
"skuKey": "破片手榴弹-兔兔白日梦",
"channels": [
{ "platformKey": "nanhang", "creditQuantity": 10 },
{ "platformKey": "fund_cash", "creditQuantity": 3.5 },
],
"note": "长期人工商品",
}
```
保存时后端自动创建或复用 `man:<skuKey>` 标准商品,再写人工配置和
`internal_sku_channels` 人工规则。待分类队列的“去补配置”会携带
`scope=manual&tab=cost&manualSku=<内部SKU>` 直接打开对应配置。
### 4.4 第三方卡券 SKU(现行)
卡券商品统一从“成本配置 → 第三方卡券 → 新增卡券 SKU”维护:
```text
POST /api/v1/admin/cost-center/voucher-products
```
```jsonc
{
"skuKey": "11月改名卡",
"amountFen": 500,
"note": "第三方卡券采购价"
}
```
保存时在同一事务内创建或复用 `tpv:<内部 SKU>`、卡券主渠道规则和采购成本版本,
并把同内部 SKU 的卡券订单项从错误的 `man:` 等商品归一到 `tpv:`。
通用 `POST /api/v1/admin/cost-center/products` 不再接受 `tpv:` 和 `man:`。
迁移 `143` 负责修复历史卡券商品归属。
### 4.5 商品来源隔离(现行)
订单项一旦已确定履约渠道,商品归一只能命中该渠道来源:
```text
affiliate_dash → aff:
work_order → tpl: / wo:
third_party_voucher → tpv:
manual → man:
on_account → man:
```
缺少渠道专属商品时进入 `product_mapping_missing`,不得继续按同名商品借用其他来源。
商品归一任务会清除已有错误来源关联并重新挂载正确商品。
## 五、成本版本接口
路径不变,字段不变:
@@ -163,20 +223,34 @@ AND (
"costModel": "affiliate_purchase",
"pricingMode": "per_item",
"scopeType": "product | rule | shop | provider | global",
"provider": "", "shopId": "",
"canonicalProductId": 3, "canonicalSku": "aff:SKU-1", "canonicalName": "...",
"workProductRuleId": null, "workProductRuleName": "", "workProductRuleKey": "",
"ruleRewardAmountFen": 0, "ruleUnitPriceFen": 0,
"revenueItemCount": 0, "revenueFen": 0, "revenueAvgFen": 0,
"rewardAvgFen": 0, "rewardFen": 0, "amountKind": "unit_price | fixed",
"provider": "",
"shopId": "",
"canonicalProductId": 3,
"canonicalSku": "aff:SKU-1",
"canonicalName": "...",
"workProductRuleId": null,
"workProductRuleName": "",
"workProductRuleKey": "",
"ruleRewardAmountFen": 0,
"ruleUnitPriceFen": 0,
"revenueItemCount": 0,
"revenueFen": 0,
"revenueAvgFen": 0,
"rewardAvgFen": 0,
"rewardFen": 0,
"amountKind": "unit_price | fixed",
"isSystemPlaceholder": false,
"amountFen": 1000, "rateBps": 0,
"effectiveFrom": "...", "effectiveTo": null,
"amountFen": 1000,
"rateBps": 0,
"effectiveFrom": "...",
"effectiveTo": null,
"source": "manual | upstream_sync | import",
"status": "draft | active | retired",
"changeReason": "",
"createdByAdminUserId": null, "approvedByAdminUserId": null,
"createdAt": "...", "updatedAt": "..."
"createdByAdminUserId": null,
"approvedByAdminUserId": null,
"createdAt": "...",
"updatedAt": "...",
}
```
@@ -213,7 +287,9 @@ AND (
"reason": "cost_missing",
"status": "open",
"detail": {},
"createdAt": "...", "resolvedAt": null, "resolvedByAdminUserId": null
"createdAt": "...",
"resolvedAt": null,
"resolvedByAdminUserId": null,
}
```
@@ -233,7 +309,7 @@ AND (
"grossRevenueFen": 1219202470,
"costAmountFen": 32000,
"costMissingCount": 102954,
"grossProfitFen": -1219200470
"grossProfitFen": -1219200470,
}
```
@@ -255,13 +331,13 @@ AND (
"affiliate_dash": 38,
"work_order": 707,
"manual": 0,
"uncategorized": 0
"uncategorized": 0,
},
"costModelCounts": {
"affiliate_purchase": 1,
"work_order_reward": 1,
"manual_actual": 1
}
"manual_actual": 1,
},
}
```
@@ -272,13 +348,13 @@ AND (
原 `apps/backend/src/services/admin/admin-cost-center-service.ts`(4,248 行)已拆为:
| 新文件 | 行数 | 负责的导出 |
| --- | ---: | --- |
| `services/cost-center/cost-center-shared.ts` | ~800 | 类型、常量、归一化函数、`mapXxx` 映射函数、provider 归一、`costModelScopeSql` |
| `services/cost-center/cost-center-overview-service.ts` | ~150 | 总览统计与选项 |
| `services/cost-center/product-catalog-service.ts` | ~1,000 | 商品与渠道映射的 CRUD、回填、选项 |
| `services/cost-center/template-bootstrap-service.ts` | ~1,380 | 接单模板引导、自动发货商品同步、工单证据回填、模板成本版本同步、`buildTemplateMappingPlan` |
| `services/cost-center/cost-definition-service.ts` | ~1,240 | 成本项 / 成本版本 / 成本异常 / 成本预览 / 成本快照写入 |
| 新文件 | 行数 | 负责的导出 |
| ------------------------------------------------------ | -----: | ------------------------------------------------------------------------------------------ |
| `services/cost-center/cost-center-shared.ts` | ~800 | 类型、常量、归一化函数、`mapXxx` 映射函数、provider 归一、`costModelScopeSql` |
| `services/cost-center/cost-center-overview-service.ts` | ~150 | 总览统计与选项 |
| `services/cost-center/product-catalog-service.ts` | ~1,000 | 商品与渠道映射的 CRUD、回填、选项 |
| `services/cost-center/template-bootstrap-service.ts` | ~1,380 | 接单模板引导、自动发货商品同步、工单证据回填、模板成本版本同步、`buildTemplateMappingPlan` |
| `services/cost-center/cost-definition-service.ts` | ~1,240 | 成本项 / 成本版本 / 成本异常 / 成本预览 / 成本快照写入 |
原文件**已删除**,路由 `apps/backend/src/routes/admin/cost-center.ts` 从新模块导入。
`routes/admin/worker-platform/config.ts` 的模板成本同步也从新模块导入。
@@ -287,19 +363,19 @@ AND (
原 `apps/frontend/src/pages/admin/AdminCostCenterPage.tsx`(2,887 行)已拆为:
| 新文件 | 内容 |
| --- | --- |
| `pages/admin/cost-center/CostCenterShell.tsx` | 页面骨架、顶部来源切换(全部/自动发货/接单平台/人工)、Tab 定义 |
| `pages/admin/cost-center/shared.ts` | 常量、类型、标签函数 |
| `pages/admin/cost-center/CostCenterContext.ts` | 页面上下文(`useCostCenter`) |
| `pages/admin/cost-center/ProductCatalogPanel.tsx` | 标准商品 Tab |
| `pages/admin/cost-center/ChannelMappingPanel.tsx` | 渠道映射 Tab |
| `pages/admin/cost-center/CostConfigPanel.tsx` | 成本配置 Tab(按来源切换下面三个) |
| `pages/admin/cost-center/AffiliateSkuPanel.tsx` | 自动发货采购价(成本配置内) |
| `pages/admin/cost-center/CostItemPanel.tsx` | 成本项 Tab |
| `pages/admin/cost-center/CostVersionPanel.tsx` | 成本版本 Tab |
| `pages/admin/cost-center/CostExceptionPanel.tsx` | 成本异常 Tab |
| `pages/admin/cost-center/CostPreviewPanel.tsx` | 成本预览 Tab |
| 新文件 | 内容 |
| ------------------------------------------------- | --------------------------------------------------------------- |
| `pages/admin/cost-center/CostCenterShell.tsx` | 页面骨架、顶部来源切换(全部/自动发货/接单平台/人工)、Tab 定义 |
| `pages/admin/cost-center/shared.ts` | 常量、类型、标签函数 |
| `pages/admin/cost-center/CostCenterContext.ts` | 页面上下文(`useCostCenter`) |
| `pages/admin/cost-center/ProductCatalogPanel.tsx` | 标准商品 Tab |
| `pages/admin/cost-center/ChannelMappingPanel.tsx` | 渠道映射 Tab |
| `pages/admin/cost-center/CostConfigPanel.tsx` | 成本配置 Tab(按来源切换下面三个) |
| `pages/admin/cost-center/AffiliateSkuPanel.tsx` | 自动发货采购价(成本配置内) |
| `pages/admin/cost-center/CostItemPanel.tsx` | 成本项 Tab |
| `pages/admin/cost-center/CostVersionPanel.tsx` | 成本版本 Tab |
| `pages/admin/cost-center/CostExceptionPanel.tsx` | 成本异常 Tab |
| `pages/admin/cost-center/CostPreviewPanel.tsx` | 成本预览 Tab |
`AdminCostCenterPage.tsx` 只保留 `export default` 转发到 `CostCenterShell`(18 行)。
@@ -322,21 +398,21 @@ export type CostModel =
## 八、总账 / 期间 / 对账接口(2026-10-26)
| 方法 | 路径 | 权限 | 说明 |
| --- | --- | --- | --- |
| GET | `/api/v1/admin/finance/ledger/accounts` | finance.view | 科目表 |
| GET | `/api/v1/admin/finance/ledger/trial-balance?dateFrom&dateTo` | finance.view | 试算平衡 |
| GET | `/api/v1/admin/finance/ledger/entries?dateFrom&dateTo&page&pageSize` | finance.view | 凭证列表(含分录) |
| POST | `/api/v1/admin/finance/ledger/sync` | finance.config | 按日期区间同步销售事实到总账 |
| POST | `/api/v1/admin/finance/ledger/manual-voucher` | finance.config | 手工凭证(借贷必须平衡) |
| GET | `/api/v1/admin/finance/periods` | finance.view | 会计期间列表 |
| POST | `/api/v1/admin/finance/periods/:periodKey/close` | finance.config | 关账(先补凭证) |
| POST | `/api/v1/admin/finance/periods/:periodKey/reopen` | finance.config | 反关账 |
| GET | `/api/v1/admin/finance/reconciliations` | finance.view | 对账批次 |
| POST | `/api/v1/admin/finance/reconciliations/import` | finance.config | 导入平台账单 JSON 并匹配 |
| GET | `/api/v1/admin/finance/reconciliations/:id` | finance.view | 对账明细 |
| POST | `/api/v1/admin/finance/reconciliations/:id/resolve` | finance.config | 标记差异已解决 |
| POST | `/api/v1/admin/kuaishou-industry/orders/:orderId/refunds/approve-and-record` | finance.config | 平台退款(当前仅快手 91) |
| 方法 | 路径 | 权限 | 说明 |
| ---- | ---------------------------------------------------------------------------- | -------------- | ---------------------------- |
| GET | `/api/v1/admin/finance/ledger/accounts` | finance.view | 科目表 |
| GET | `/api/v1/admin/finance/ledger/trial-balance?dateFrom&dateTo` | finance.view | 试算平衡 |
| GET | `/api/v1/admin/finance/ledger/entries?dateFrom&dateTo&page&pageSize` | finance.view | 凭证列表(含分录) |
| POST | `/api/v1/admin/finance/ledger/sync` | finance.config | 按日期区间同步销售事实到总账 |
| POST | `/api/v1/admin/finance/ledger/manual-voucher` | finance.config | 手工凭证(借贷必须平衡) |
| GET | `/api/v1/admin/finance/periods` | finance.view | 会计期间列表 |
| POST | `/api/v1/admin/finance/periods/:periodKey/close` | finance.config | 关账(先补凭证) |
| POST | `/api/v1/admin/finance/periods/:periodKey/reopen` | finance.config | 反关账 |
| GET | `/api/v1/admin/finance/reconciliations` | finance.view | 对账批次 |
| POST | `/api/v1/admin/finance/reconciliations/import` | finance.config | 导入平台账单 JSON 并匹配 |
| GET | `/api/v1/admin/finance/reconciliations/:id` | finance.view | 对账明细 |
| POST | `/api/v1/admin/finance/reconciliations/:id/resolve` | finance.config | 标记差异已解决 |
| POST | `/api/v1/admin/kuaishou-industry/orders/:orderId/refunds/approve-and-record` | finance.config | 平台退款(当前仅快手 91) |
确认履约接口新增代录字段:
@@ -344,8 +420,8 @@ export type CostModel =
{
"orderItemId": 123,
"costFen": 100,
"fundAccountId": 45, // 现金路径:管理员/财务指定客服备用金账户
"creditOwnerAdminUserId": 67 // 积分路径:管理员/财务指定客服账号
"fundAccountId": 45, // 现金路径:管理员/财务指定客服备用金账户
"creditOwnerAdminUserId": 67, // 积分路径:管理员/财务指定客服账号
}
```
+108 -101
View File
@@ -38,11 +38,11 @@ orders ──┬── order_items ──── fulfillment_channel + fulfillmen
2026-10-27 最终收敛为 3 个领域迁移(合计约 2,177 行):
| 迁移 | 内容 | 行数 |
| --- | --- | ---: |
| `103_finance_foundation.sql` | 商品目录、渠道映射、成本项/版本/快照/异常、订单项收入与履约方式、销售事实、退款单 | 861 |
| `104_finance_controls.sql` | 人工 SKU/配置、备用金/积分、影子总账、对账/关账、财务控制、L1 钱包硬化 | 1,119 |
| `105_work_order_consignment.sql` | 拼单多次参与、大厅/店铺索引、撤份额注释、寄卖对接客服 | 197 |
| 迁移 | 内容 | 行数 |
| -------------------------------- | --------------------------------------------------------------------------------- | ----: |
| `103_finance_foundation.sql` | 商品目录、渠道映射、成本项/版本/快照/异常、订单项收入与履约方式、销售事实、退款单 | 861 |
| `104_finance_controls.sql` | 人工 SKU/配置、备用金/积分、影子总账、对账/关账、财务控制、L1 钱包硬化 | 1,119 |
| `105_work_order_consignment.sql` | 拼单多次参与、大厅/店铺索引、撤份额注释、寄卖对接客服 | 197 |
原来 103–114 的 12 个补丁文件已删除;生产当前在 102,只需执行 103–105。
@@ -52,42 +52,47 @@ orders ──┬── order_items ──── fulfillment_channel + fulfillmen
### 1.1 字段替换对照
| 旧字段 | 新字段 | 出现位置 |
| --- | --- | --- |
| `cost_items.carrier_type` | `cost_items.cost_model` | DB / 成本项接口 |
| `cost_items.cost_type` | (并入 `cost_model`) | DB / 成本项接口 |
| `cost_snapshots.carrier_type` | (删除) | DB |
| `cost_exceptions.carrier_type` | (删除) | DB |
| `product_catalog.carrier_type` | (删除,改由 `canonical_sku` 前缀推导) | DB / 商品接口 |
| `sales_daily_facts.carrier_type` | `sales_daily_facts.channel` | DB / 销售事实接口 |
| `work_product_rules.legacy_cost_source` | (删除) | DB |
| `cost_versions.cost_item_carrier_type` | `cost_versions.cost_item_cost_model` | 成本版本接口 |
| `pricing_mode: per_hour / fixed_daily` | (删除,只剩三种) | 成本项接口 |
| `manual_expense_records` / `manual_expense_events` | (删除,人工成本只走确认履约) | DB |
| 旧字段 | 新字段 | 出现位置 |
| -------------------------------------------------- | --------------------------------------- | ----------------- |
| `cost_items.carrier_type` | `cost_items.cost_model` | DB / 成本项接口 |
| `cost_items.cost_type` | (并入 `cost_model`) | DB / 成本项接口 |
| `cost_snapshots.carrier_type` | (删除) | DB |
| `cost_exceptions.carrier_type` | (删除) | DB |
| `product_catalog.carrier_type` | (删除,改由 `canonical_sku` 前缀推导) | DB / 商品接口 |
| `sales_daily_facts.carrier_type` | `sales_daily_facts.channel` | DB / 销售事实接口 |
| `work_product_rules.legacy_cost_source` | (删除) | DB |
| `cost_versions.cost_item_carrier_type` | `cost_versions.cost_item_cost_model` | 成本版本接口 |
| `pricing_mode: per_hour / fixed_daily` | (删除,只剩三种) | 成本项接口 |
| `manual_expense_records` / `manual_expense_events` | (删除,人工成本只走确认履约) | DB |
### 1.2 成本模型映射
| 旧 `cost_type` | 新 `cost_model` |
| --- | --- |
| `auto_purchase` | `affiliate_purchase` |
| `work_order_reward` | `work_order_reward` |
| `manual_service` | `manual_actual` |
| `platform_fee` | `platform_fee` |
| `payment_fee` | `payment_fee` |
| `after_sales_loss` | `after_sales_loss` |
| `shipping_other` / `other` | `other` |
| 旧 `cost_type` | 新 `cost_model` |
| -------------------------- | -------------------- |
| `auto_purchase` | `affiliate_purchase` |
| `work_order_reward` | `work_order_reward` |
| `manual_service` | `manual_actual` |
| `platform_fee` | `platform_fee` |
| `payment_fee` | `payment_fee` |
| `after_sales_loss` | `after_sales_loss` |
| `shipping_other` / `other` | `other` |
### 1.3 商品来源前缀
| 前缀 | 来源 | 对应履约方式(仅参考,不作推导依据) |
| --- | --- | --- |
| `aff:` | 自动发货 | `affiliate_dash` |
| `tpl:` | 接单模板 | `work_order` |
| `wo:` | 历史工单证据 | `work_order` |
| `man:` | 人工 | `manual` |
| 其他 | 未分类 | 不得推断 |
| 前缀 | 来源 | 对应履约方式(仅参考,不作推导依据) |
| ------ | ------------ | ------------------------------------ |
| `aff:` | 自动发货 | `affiliate_dash` |
| `tpl:` | 接单模板 | `work_order` |
| `wo:` | 历史工单证据 | `work_order` |
| `man:` | 人工 | `manual` |
| 其他 | 未分类 | 不得推断 |
本地现状:`aff:` 38 个、`tpl:` 662 个、`wo:` 45 个,`man:` 0 个(P1 待建)。
人工标准商品 `man:<内部 SKU>` 不在商品目录手工创建。运营在「成本配置 → 人工 SKU 配置」
新增或保存配置时,系统在同一业务流程内创建或复用标准商品,并同步人工渠道规则。
商品目录中的 `man:` 商品因此是人工配置的派生结果。
已确定履约渠道的订单项必须使用对应来源商品,不能按同名商品跨渠道兜底。统一结算金额
未对账前只生成 `pending` 虚拟账单,不生成 0 元 actual 成本快照;确认对账后才写入实际成本。
---
@@ -188,15 +193,15 @@ normalized_sku_series -- 剥离数量并归一同义字后的名称系列
### 3.1 成本模型与适用履约方式
| `cost_model` | 适用履约方式 | 计价方式 | 成本来源 |
| --- | --- | --- | --- |
| `affiliate_purchase` | `affiliate_dash` | `per_item` | 上游 SKU 固定成本版本 |
| `work_order_reward` | `work_order` | `per_order` | 模板规则级版本 / 工单实际报酬 |
| `manual_actual` | `manual` | `per_order` | 订单页逐单录入的实际支出 |
| `platform_fee` | 全部 | `per_order` / `percent_of_revenue` | 未配置 |
| `payment_fee` | 全部 | `per_order` / `percent_of_revenue` | 未配置 |
| `after_sales_loss` | 全部 | `per_order` | 未配置 |
| `other` | 全部 | `per_order` | 未配置 |
| `cost_model` | 适用履约方式 | 计价方式 | 成本来源 |
| -------------------- | ---------------- | ---------------------------------- | ----------------------------- |
| `affiliate_purchase` | `affiliate_dash` | `per_item` | 上游 SKU 固定成本版本 |
| `work_order_reward` | `work_order` | `per_order` | 模板规则级版本 / 工单实际报酬 |
| `manual_actual` | `manual` | `per_order` | 订单页逐单录入的实际支出 |
| `platform_fee` | 全部 | `per_order` / `percent_of_revenue` | 未配置 |
| `payment_fee` | 全部 | `per_order` / `percent_of_revenue` | 未配置 |
| `after_sales_loss` | 全部 | `per_order` | 未配置 |
| `other` | 全部 | `per_order` | 未配置 |
后端判定见 `cost-center-shared.ts` 的 `costModelScopeSql()`;通用模型对所有履约方式生效,专属模型只对对应履约方式生效。
@@ -228,11 +233,11 @@ cost_versions
### 3.3 系统内置成本项
| 编码 | 成本模型 | 说明 |
| --- | --- | --- |
| `sys_work_order_reward` | `work_order_reward` | 每个可启用的接单模板各一条规则级版本;全局 0 元版本只作"按工单实际结算"的占位 |
| `sys_affiliate_purchase` | `affiliate_purchase` | 上游 SKU 固定成本版本,38 个商品级 |
| `sys_manual_service` | `manual_actual` | 订单页确认履约写入 `actual` 快照;版本为 0 元占位 |
| 编码 | 成本模型 | 说明 |
| ------------------------ | -------------------- | ----------------------------------------------------------------------------- |
| `sys_work_order_reward` | `work_order_reward` | 每个可启用的接单模板各一条规则级版本;全局 0 元版本只作"按工单实际结算"的占位 |
| `sys_affiliate_purchase` | `affiliate_purchase` | 上游 SKU 固定成本版本,38 个商品级 |
| `sys_manual_service` | `manual_actual` | 订单页确认履约写入 `actual` 快照;版本为 0 元占位 |
工单报酬的三种快照:
@@ -286,12 +291,12 @@ created_at, resolved_at, resolved_by
`order_items.revenue_amount_fen` / `revenue_basis` 在迁移 `103` 里固化:
| `revenue_basis` | 含义 |
| --- | --- |
| `order_total` | 一单一品,订单实付全部归该订单项 |
| `equal_split` | 多订单项按等额拆分,余数给最后一项 |
| `platform_item` | 有平台明细时替换 |
| `manual` / `unknown` | 未定 |
| `revenue_basis` | 含义 |
| -------------------- | ---------------------------------- |
| `order_total` | 一单一品,订单实付全部归该订单项 |
| `equal_split` | 多订单项按等额拆分,余数给最后一项 |
| `platform_item` | 有平台明细时替换 |
| `manual` / `unknown` | 未定 |
本地全量 316,585 项都是 `order_total`(当前一单一品),但模型按"订单项"设计,不把"一单一品"写死。
@@ -328,12 +333,12 @@ computed_at
### 5.2 本地重算结果(2026-10-23)
| 履约方式 | 事实行 | 订单项 | 收入 | 成本 | 缺失成本 |
| --- | ---: | ---: | ---: | ---: | ---: |
| `work_order` | 9,585 | 118,053 | ¥533.57 万 | ¥452.83 万 | 966 |
| `affiliate_dash` | 3,163 | 41,676 | ¥508.97 万 | ¥497.41 万 | 0 |
| `manual` | 1,849 | 102,955 | ¥1,219.20 万 | ¥0.32 万 | 102,954 |
| `legacy_platform` | 201 | 53,901 | ¥389.12 万 | ¥0 | 53,901 |
| 履约方式 | 事实行 | 订单项 | 收入 | 成本 | 缺失成本 |
| ----------------- | -----: | ------: | -----------: | ---------: | -------: |
| `work_order` | 9,585 | 118,053 | ¥533.57 万 | ¥452.83 万 | 966 |
| `affiliate_dash` | 3,163 | 41,676 | ¥508.97 万 | ¥497.41 万 | 0 |
| `manual` | 1,849 | 102,955 | ¥1,219.20 万 | ¥0.32 万 | 102,954 |
| `legacy_platform` | 201 | 53,901 | ¥389.12 万 | ¥0 | 53,901 |
---
@@ -396,7 +401,9 @@ fulfillment_decided_at / fulfillment_reason
4. **商品来源和平台 SKU 都不能反推渠道**:判定只认统一 SKU 配置与真实载体。
5. `voucher_state` 只做展示与核销流程,不参与任何成本/履约判断。
6. 多渠道证据同时命中必须先告警人工复核,不能静默取其一。
7. **旧人工来源清理只归档**:同名内部 SKU 已有其他非兜底主渠道时,旧 `man:` 商品从当前目录归档;历史订单、成本版本、成本快照和事实数据保持不动,人工兜底改指当前主商品。
7. **人工商品只从人工 SKU 配置创建**:普通“新建标准商品”不再接受 `man:`;
人工 SKU 配置保存时自动创建或复用 `man:<内部 SKU>`。历史商品清理入口已下线,
避免运营通过商品目录维护人工履约事实。
---
@@ -404,21 +411,21 @@ fulfillment_decided_at / fulfillment_reason
财务链路已改成六条独立管道,由定时任务驱动,不再有手工同步脚本:
| 管道 | 作用 | 默认间隔 |
| --- | --- | --- |
| `channel_assign` | 规则/证据判定履约渠道 | 60s |
| `carrier_dispatch` | 挂载人工配置、发现缺失载体 | 60s |
| `product_normalize` | 模板/人工配置/前缀直达归一 | 300s |
| `cost_snapshot` | 按渠道解析成本版本写快照 | 300s |
| `sales_facts` | 按渠道重算销售事实(含未分类桶) | 600s |
| `ledger_post` | 事实过账到总账 | 600s |
| 管道 | 作用 | 默认间隔 |
| ------------------- | -------------------------------- | -------- |
| `channel_assign` | 规则/证据判定履约渠道 | 60s |
| `carrier_dispatch` | 挂载人工配置、发现缺失载体 | 60s |
| `product_normalize` | 模板/人工配置/前缀直达归一 | 300s |
| `cost_snapshot` | 按渠道解析成本版本写快照 | 300s |
| `sales_facts` | 按渠道重算销售事实(含未分类桶) | 600s |
| `ledger_post` | 事实过账到总账 | 600s |
运维命令只剩断代与重建:
| 命令 | 作用 |
| --- | --- |
| 命令 | 作用 |
| -------------------------------------------------------------- | ----------------------------- |
| `npm run finance:cutover-v2 -- --execute --confirm=2026-10-31` | 断代:归档历史 + 清空派生数据 |
| `npm run finance:rebuild-cutover -- --date-from=2026-10-31` | 断代后重跑管道并生成期初分录 |
| `npm run finance:rebuild-cutover -- --date-from=2026-10-31` | 断代后重跑管道并生成期初分录 |
后台「履约渠道 → 管道状态」可查看每条管道的水位、上次成功时间与错误;
「立即执行」只用于排障补跑,日常无需干预。
@@ -427,11 +434,11 @@ fulfillment_decided_at / fulfillment_reason
生产数据库当前在 **102**。dev 已把原 103–114 收敛为 3 个领域迁移:
| 迁移 | 内容 |
| --- | --- |
| `103_finance_foundation.sql` | 商品目录、成本中心、成本项/版本/快照、销售事实、退款单 |
| `104_finance_controls.sql` | 人工履约、备用金/积分、影子总账、对账/关账、财务控制、L1 钱包硬化 |
| `105_work_order_consignment.sql` | 拼单多次参与、大厅/店铺索引、撤份额注释、寄卖对接客服 |
| 迁移 | 内容 |
| -------------------------------- | ----------------------------------------------------------------- |
| `103_finance_foundation.sql` | 商品目录、成本中心、成本项/版本/快照、销售事实、退款单 |
| `104_finance_controls.sql` | 人工履约、备用金/积分、影子总账、对账/关账、财务控制、L1 钱包硬化 |
| `105_work_order_consignment.sql` | 拼单多次参与、大厅/店铺索引、撤份额注释、寄卖对接客服 |
- 生产升级:**102 → 103 → 104 → 105**,只执行 3 个迁移文件。
- 全新库:`001 → 105` 一次跑完。
@@ -461,35 +468,35 @@ npm run revenue:backfill -- --batch-size=2000 --max-batches=100
## 九、索引与性能
| 索引 | 用途 |
| --- | --- |
| `idx_order_items_fulfillment_channel (fulfillment_channel, order_id)` | 按渠道筛订单项 |
| `idx_order_items_fulfillment_id (fulfillment_status, id)` | 管道按状态扫增量 |
| `idx_order_items_canonical_product` | 按标准商品查订单项 |
| `idx_order_items_normalized_sku_lookup` | 按归一名称/系列匹配映射 |
| `idx_channel_product_mappings_match_lookup` | 映射查找 |
| `uq_channel_product_mappings_match_key` | 映射唯一键 |
| `idx_cost_versions_lookup / product / rule` | 成本版本解析 |
| `uq_cost_snapshots_active_subject_item` | 每个主体 + 成本项只允许一条有效快照 |
| `idx_cost_exceptions_status_created` | 异常队列列表 |
| `idx_sales_daily_facts_date / shop / product / mode` | 销售事实各维度 |
| `idx_work_orders_rule_cover` | 成本版本页按接单规则聚合 |
| `idx_order_items_revenue_cover` | 收入聚合 |
| `idx_work_categories_name` | 商品目录按分类名 lateral join |
| `idx_cost_exceptions_archivable` | 归档任务扫描「已处理且超期」的异常(部分索引,`status <> 'open'`) |
| 索引 | 用途 |
| --------------------------------------------------------------------- | ------------------------------------------------------------------ |
| `idx_order_items_fulfillment_channel (fulfillment_channel, order_id)` | 按渠道筛订单项 |
| `idx_order_items_fulfillment_id (fulfillment_status, id)` | 管道按状态扫增量 |
| `idx_order_items_canonical_product` | 按标准商品查订单项 |
| `idx_order_items_normalized_sku_lookup` | 按归一名称/系列匹配映射 |
| `idx_channel_product_mappings_match_lookup` | 映射查找 |
| `uq_channel_product_mappings_match_key` | 映射唯一键 |
| `idx_cost_versions_lookup / product / rule` | 成本版本解析 |
| `uq_cost_snapshots_active_subject_item` | 每个主体 + 成本项只允许一条有效快照 |
| `idx_cost_exceptions_status_created` | 异常队列列表 |
| `idx_sales_daily_facts_date / shop / product / mode` | 销售事实各维度 |
| `idx_work_orders_rule_cover` | 成本版本页按接单规则聚合 |
| `idx_order_items_revenue_cover` | 收入聚合 |
| `idx_work_categories_name` | 商品目录按分类名 lateral join |
| `idx_cost_exceptions_archivable` | 归档任务扫描「已处理且超期」的异常(部分索引,`status <> 'open'`) |
### 9.1 成本异常归档(60 天)
`cost_exceptions` 只增不减:本地快照 260,253 行(resolved 151,939 / open 108,314)。已处理的行只是审计痕迹,继续留在热表会拖慢按状态+时间的分页,并让唯一索引持续膨胀。
| 项 | 规则 |
| --- | --- |
| 归档对象 | `status <> 'open'` 且 `COALESCE(resolved_at, created_at)` 超过保留期 |
| open 行 | **永不归档**——它是待办队列,超期也要留在页面上 |
| 保留期 | `COST_EXCEPTION_RETENTION_DAYS`,默认 **60** 天 |
| 落点 | `cost_exceptions_archive`(保留原 id 与时间字段,加 `archived_at`) |
| 执行 | `data-retention` 每日任务;每批 1000 行,`DELETE ... RETURNING` + `INSERT` 同语句,单批原子 |
| 手动 | `npm run cost-exceptions:archive -- --dry-run` / `-- --days=90` |
| 项 | 规则 |
| ---------- | ---------------------------------------------------------------------------------------------- |
| 归档对象 | `status <> 'open'` 且 `COALESCE(resolved_at, created_at)` 超过保留期 |
| open 行 | **永不归档**——它是待办队列,超期也要留在页面上 |
| 保留期 | `COST_EXCEPTION_RETENTION_DAYS`,默认 **60** 天 |
| 落点 | `cost_exceptions_archive`(保留原 id 与时间字段,加 `archived_at`) |
| 执行 | `data-retention` 每日任务;每批 1000 行,`DELETE ... RETURNING` + `INSERT` 同语句,单批原子 |
| 手动 | `npm run cost-exceptions:archive -- --dry-run` / `-- --days=90` |
| 一次性清理 | `npm run cost-exceptions:archive -- --days=0`(不按时间过滤,归档全部已处理行;open 仍不归档) |
归档不删除审计痕迹,但热表查询与页面上不会再出现这些历史行;需要追溯时查归档表。