feat(finance): 补齐自动发货与接单平台成本和对账

自动发货:价格导入支持 --effective-from,把首次基线版本回填到历史起点;41,676 个订单项全部归一并生成采购成本快照,缺失 0。\n\n接单平台:新增按工单 product_rule_id 回填商品的接口与按钮,长尾项按 provider+shop_id+sku_code+normalized_sku_name 补建 wo: 商品和 sku_code_name 渠道映射;118,053 个订单项全部归一。\n\n成本:已验收工单按 reward_amount 写 actual;未验收但已确定报酬的工单写 estimated,验收后升级。全量 95,870 actual + 21,217 estimated,actual 与 reward 0 差异。\n\n剩余 966 条取消/零报酬工单保留在成本异常队列,不自动记 0;人工块未改动。\n\n实测自动发货与接单平台未归一收入均为 0;整体未归一 79.67% 降至 57.54%,全部来自人工块。
This commit is contained in:
yml2213
2026-10-22 18:59:53 +08:00
parent 54d2eb2f59
commit 72e1141c8a
9 changed files with 566 additions and 57 deletions
@@ -2,12 +2,13 @@
* 按上游自动发货商品价格,批量生成「自动发货采购成本」成本版本。
*
* 用法:
* npx tsx scripts/sync-affiliate-dash-prices.ts [价格 JSON 路径] [--cost-item-id=5] [--dry-run]
* npx tsx scripts/sync-affiliate-dash-prices.ts [价格 JSON 路径] [--cost-item-id=5] [--effective-from=YYYY-MM-DD] [--dry-run]
*
* 规则:
* - 标准商品编码固定为 `aff:<sku>`,载体 affiliate_dash;
* - 成本版本范围 = 标准商品,金额 = 上游 cost_amount(分);
* - 同一商品已有相同金额的生效版本时跳过;
* - 同一商品已有相同金额的生效版本时跳过;指定 --effective-from 时,
* 若现版本生效时间更晚,则把首次基线版本的生效时间回填到该日期;
* - 金额变化时先停用旧版本,再插入新版本(历史快照不变)。
*/
import fs from 'node:fs'
@@ -33,6 +34,7 @@ type CostItemRow = {
function parseArgs(argv: string[]) {
let filePath = ''
let costItemId: number | null = null
let effectiveFrom = ''
let dryRun = false
for (const arg of argv) {
if (arg === '--dry-run') {
@@ -44,11 +46,16 @@ function parseArgs(argv: string[]) {
if (Number.isFinite(parsed) && parsed > 0) costItemId = parsed
continue
}
if (arg.startsWith('--effective-from=')) {
effectiveFrom = arg.slice('--effective-from='.length).trim()
continue
}
if (!arg.startsWith('--') && !filePath) filePath = arg
}
return {
filePath: path.resolve(filePath || 'scripts/data/affiliate-dash-prices.json'),
costItemId,
effectiveFrom,
dryRun,
}
}
@@ -90,18 +97,21 @@ async function resolveCostItem(costItemId: number | null): Promise<CostItemRow>
}
async function main() {
const { filePath, costItemId, dryRun } = parseArgs(process.argv.slice(2))
const { filePath, costItemId, effectiveFrom, dryRun } = parseArgs(process.argv.slice(2))
const effectiveFromIso = normalizeEffectiveFrom(effectiveFrom)
const rows = JSON.parse(fs.readFileSync(filePath, 'utf8')) as PriceRow[]
const costItem = await resolveCostItem(costItemId)
console.log(
`[affiliate-price] 成本项 #${costItem.id} ${costItem.name}(${costItem.code});价格条目 ${rows.length} 条${dryRun ? '(dry-run)' : ''}`,
`[affiliate-price] 成本项 #${costItem.id} ${costItem.name}(${costItem.code});价格条目 ${rows.length} 条` +
`${effectiveFromIso ? `;生效时间 ${effectiveFromIso}` : ''}${dryRun ? '(dry-run)' : ''}`,
)
let createdProductCount = 0
let updatedProductCount = 0
let createdVersionCount = 0
let retiredVersionCount = 0
let backdatedVersionCount = 0
let skippedCount = 0
for (const row of rows) {
@@ -166,14 +176,27 @@ async function main() {
if (product.inserted) createdProductCount += 1
else updatedProductCount += 1
const currentResult = await query<{ id: number; amount_fen: number }>(
`SELECT id, amount_fen FROM cost_versions
const currentResult = await query<{ id: number; amount_fen: number; effective_from: string }>(
`SELECT id, amount_fen, effective_from FROM cost_versions
WHERE cost_item_id = $1 AND scope_type = 'product' AND canonical_product_id = $2 AND status = 'active'
ORDER BY effective_from DESC, id DESC LIMIT 1`,
[costItem.id, product.id],
)
const current = currentResult.rows[0]
if (current && Number(current.amount_fen) === row.costAmountFen) {
if (
effectiveFromIso &&
new Date(current.effective_from).getTime() > new Date(effectiveFromIso).getTime()
) {
await query(
`UPDATE cost_versions
SET effective_from = $1, change_reason = '上游基线价格回填', updated_at = $2
WHERE id = $3`,
[effectiveFromIso, now, current.id],
)
backdatedVersionCount += 1
continue
}
skippedCount += 1
continue
}
@@ -194,18 +217,31 @@ async function main() {
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, 'upstream_sync', 'active',
'上游自动发货价格同步', NULL, NULL, $4, $4)`,
[costItem.id, product.id, row.costAmountFen, now],
'上游自动发货价格同步', NULL, NULL, $5, $5)`,
[costItem.id, product.id, row.costAmountFen, effectiveFromIso || now, now],
)
createdVersionCount += 1
}
console.log(
`[affiliate-price] 完成:新增商品 ${createdProductCount}、更新商品 ${updatedProductCount}、` +
`新增版本 ${createdVersionCount}、停用旧版本 ${retiredVersionCount}、金额未变跳过 ${skippedCount}`,
`新增版本 ${createdVersionCount}、停用旧版本 ${retiredVersionCount}、` +
`回填生效时间 ${backdatedVersionCount}、金额未变跳过 ${skippedCount}`,
)
}
function normalizeEffectiveFrom(value: string) {
if (!value) return ''
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
throw new Error('--effective-from 必须是 YYYY-MM-DD')
}
const parsed = new Date(`${value}T00:00:00+08:00`)
if (!Number.isFinite(parsed.getTime())) {
throw new Error('--effective-from 日期无效')
}
return parsed.toISOString()
}
main()
.then(async () => {
await closeDb()
+26 -2
View File
@@ -6,6 +6,7 @@ import {
backfillAdminAffiliateDashSkus,
backfillAdminNormalizedQuantities,
backfillAdminProductMappings,
backfillAdminWorkOrderProductSkus,
createAdminChannelMapping,
createAdminCostItem,
createAdminCostSnapshot,
@@ -133,6 +134,22 @@ router.post(
}),
)
router.post(
'/cost-center/work-order-products/backfill',
requireAdminPermission('finance.config'),
createJsonHandler(() => backfillAdminWorkOrderProductSkus(), {
successMessage: '接单平台商品已回填',
errorMessage: '回填接单平台商品失败',
scope: '[admin/cost-center/work-order-products/backfill]',
audit: (_req, data) => ({
action: 'cost_work_order_products_backfilled',
targetType: 'order_items',
targetId: '',
data: data as Record<string, unknown>,
}),
}),
)
router.get(
'/cost-center/products',
requireAdminPermission('finance.view'),
@@ -530,7 +547,12 @@ router.post(
const result = data as {
mode?: string
estimated?: { createdCount?: number; exceptionCreatedCount?: number } | null
actual?: { createdCount?: number } | null
actual?: {
createdCount?: number
directRewardCount?: number
upgradedCount?: number
estimatedRewardCount?: number
} | null
}
return {
action: 'cost_snapshot_synced',
@@ -539,7 +561,9 @@ router.post(
data: {
mode: result.mode || '',
estimatedCreated: result.estimated?.createdCount || 0,
actualCreated: result.actual?.createdCount || 0,
actualCreated:
(result.actual?.directRewardCount || 0) + (result.actual?.upgradedCount || 0),
estimatedRewardCreated: result.actual?.estimatedRewardCount || 0,
exceptions: result.estimated?.exceptionCreatedCount || 0,
dateFrom: req.body?.dateFrom || '',
dateTo: req.body?.dateTo || '',
@@ -1557,6 +1557,24 @@ type AffiliateSkuBackfillRow = {
to_date: string | null
}
/** 销售事实单次重算最多 400 天,长历史按窗口分批。 */
async function rebuildFactsForRange(fromDate: string, toDate: string) {
let cursor = fromDate
let deletedCount = 0
let insertedCount = 0
let batchCount = 0
while (cursor <= toDate) {
const windowEnd = shiftDate(cursor, 399)
const chunkEnd = windowEnd > toDate ? toDate : windowEnd
const rebuilt = await rebuildSalesDailyFacts({ dateFrom: cursor, dateTo: chunkEnd })
deletedCount += rebuilt.deletedCount
insertedCount += rebuilt.insertedCount
batchCount += 1
cursor = shiftDate(chunkEnd, 1)
}
return { fromDate, toDate, deletedCount, insertedCount, batchCount }
}
/**
* 按自动发货履约证据直接回填订单项标准商品。
*
@@ -1604,8 +1622,8 @@ export async function backfillAdminAffiliateDashSkus() {
MIN((COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date),
'YYYY-MM-DD'
)
FROM target
JOIN order_items oi ON oi.id = target.id
FROM affiliate_item
JOIN order_items oi ON oi.id = affiliate_item.id
JOIN orders o ON o.id = oi.order_id
) AS from_date,
(
@@ -1613,8 +1631,8 @@ export async function backfillAdminAffiliateDashSkus() {
MAX((COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date),
'YYYY-MM-DD'
)
FROM target
JOIN order_items oi ON oi.id = target.id
FROM affiliate_item
JOIN order_items oi ON oi.id = affiliate_item.id
JOIN orders o ON o.id = oi.order_id
) AS to_date
`,
@@ -1626,32 +1644,23 @@ export async function backfillAdminAffiliateDashSkus() {
const matchedCount = Number(row?.matched_count || 0)
const updatedCount = Number(row?.updated_count || 0)
const skippedCount = Math.max(0, matchedCount - updatedCount)
const remainingCount = Math.max(0, affiliateItemCount - matchedCount)
const remainingResult = await query<{ total: number }>(
`
SELECT COUNT(*)::int AS total
FROM order_items oi
WHERE oi.canonical_product_id IS NULL
AND EXISTS (
SELECT 1 FROM fulfillment_tasks ft
WHERE ft.order_item_id = oi.id
AND ft.executor_key = 'affiliate_dash'
)
`,
)
const remainingCount = Number(remainingResult.rows[0]?.total || 0)
let factsRebuild: {
fromDate: string
toDate: string
deletedCount: number
insertedCount: number
batchCount: number
} | null = null
let factsRebuild: Awaited<ReturnType<typeof rebuildFactsForRange>> | null = null
if (matchedCount > 0 && row?.from_date && row?.to_date) {
const fromDate = row.from_date
const toDate = row.to_date
let cursor = fromDate
let deletedCount = 0
let insertedCount = 0
let batchCount = 0
while (cursor <= toDate) {
const windowEnd = shiftDate(cursor, 399)
const chunkEnd = windowEnd > toDate ? toDate : windowEnd
const rebuilt = await rebuildSalesDailyFacts({ dateFrom: cursor, dateTo: chunkEnd })
deletedCount += rebuilt.deletedCount
insertedCount += rebuilt.insertedCount
batchCount += 1
cursor = shiftDate(chunkEnd, 1)
}
factsRebuild = { fromDate, toDate, deletedCount, insertedCount, batchCount }
factsRebuild = await rebuildFactsForRange(row.from_date, row.to_date)
}
return {
@@ -1664,6 +1673,240 @@ export async function backfillAdminAffiliateDashSkus() {
}
}
type WorkOrderSkuBackfillRow = {
work_order_item_count: number
matched_count: number
updated_count: number
from_date: string | null
to_date: string | null
}
/**
* 回填接单平台订单项的标准商品。
*
* 第一步用工单真实使用的 `product_rule_id` 指向 `tpl:<rule_key>`;
* 仍无法归一的长尾项,再按 `provider + shop_id + sku_code + normalized_sku_name`
* 补建 `wo:` 商品和 `sku_code_name` 渠道映射,保证后续同类订单能自动命中。
*/
export async function backfillAdminWorkOrderProductSkus() {
const now = nowIso()
const result = await query<WorkOrderSkuBackfillRow>(
`
WITH work_order_item AS (
SELECT DISTINCT ON (wo.order_item_id)
wo.order_item_id AS id,
wo.product_rule_id
FROM work_orders wo
WHERE wo.order_item_id IS NOT NULL
ORDER BY wo.order_item_id, wo.id DESC
),
target AS (
SELECT woi.id, p.id AS canonical_product_id
FROM work_order_item woi
JOIN work_product_rules wpr ON wpr.id = woi.product_rule_id
JOIN product_catalog p ON p.canonical_sku = 'tpl:' || wpr.rule_key
),
updated AS (
UPDATE order_items oi
SET canonical_product_id = target.canonical_product_id,
updated_at = $1
FROM target
WHERE oi.id = target.id
AND oi.canonical_product_id IS DISTINCT FROM target.canonical_product_id
RETURNING oi.id
)
SELECT
(
SELECT COUNT(DISTINCT wo.order_item_id)::int
FROM work_orders wo
WHERE wo.order_item_id IS NOT NULL
) AS work_order_item_count,
(SELECT COUNT(*)::int FROM target) AS matched_count,
(SELECT COUNT(*)::int FROM updated) AS updated_count,
(
SELECT to_char(
MIN((COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date),
'YYYY-MM-DD'
)
FROM work_order_item
JOIN order_items oi ON oi.id = work_order_item.id
JOIN orders o ON o.id = oi.order_id
) AS from_date,
(
SELECT to_char(
MAX((COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date),
'YYYY-MM-DD'
)
FROM work_order_item
JOIN order_items oi ON oi.id = work_order_item.id
JOIN orders o ON o.id = oi.order_id
) AS to_date
`,
[now],
)
const tailProducts = await query<{ inserted: boolean }>(
`
WITH tail AS (
SELECT
oi.normalized_sku_name AS name,
MIN(oi.sku_name) AS sample_name
FROM order_items oi
WHERE oi.canonical_product_id IS NULL
AND btrim(COALESCE(oi.normalized_sku_name, '')) <> ''
AND EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id)
GROUP BY 1
),
missing AS (
SELECT t.name, t.sample_name
FROM tail t
WHERE NOT EXISTS (
SELECT 1 FROM product_catalog p
WHERE p.status = 'active' AND p.canonical_name = t.name
)
)
INSERT INTO product_catalog (
canonical_sku, canonical_name, category, unit_name, carrier_type, status,
attributes_json, created_at, updated_at
)
SELECT
'wo:' || substr(md5(m.name), 1, 12),
m.name,
'',
CASE
WHEN m.sample_name LIKE '%颗%' THEN '颗'
WHEN m.sample_name LIKE '%个%' THEN '个'
ELSE '件'
END,
'work_order',
'active',
jsonb_build_object('source', 'work_order_sku_name', 'normalizedName', m.name),
$1,
$1
FROM missing m
ON CONFLICT (canonical_sku) DO UPDATE
SET carrier_type = 'work_order',
updated_at = $1
RETURNING (xmax = 0) AS inserted
`,
[now],
)
const tailMappings = await query<{ id: number }>(
`
WITH tail AS (
SELECT
o.provider,
o.shop_id,
oi.sku_code,
MIN(oi.sku_name) AS sku_name,
oi.normalized_sku_name
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE oi.canonical_product_id IS NULL
AND btrim(COALESCE(oi.normalized_sku_name, '')) <> ''
AND EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id)
GROUP BY 1, 2, 3, 5
),
resolved AS (
SELECT t.*, p.id AS canonical_product_id
FROM tail t
JOIN LATERAL (
SELECT id
FROM product_catalog
WHERE status = 'active' AND canonical_name = t.normalized_sku_name
ORDER BY (canonical_sku LIKE 'tpl:%') DESC, id
LIMIT 1
) p ON TRUE
)
INSERT INTO channel_product_mappings (
provider, shop_id, platform_sku_code, platform_sku_name,
platform_sku_name_key, match_type, canonical_product_id,
quantity_multiplier, confidence, source, enabled, note, created_at, updated_at
)
SELECT
r.provider, r.shop_id, r.sku_code, r.sku_name,
r.normalized_sku_name, 'sku_code_name', r.canonical_product_id,
1, 'exact', 'import', TRUE, '按历史工单 SKU 名称自动补齐', $1, $1
FROM resolved r
ON CONFLICT (
provider, shop_id, platform_sku_code, match_type, platform_sku_name_key
) DO NOTHING
RETURNING id
`,
[now],
)
const tailUpdated = await query<{ id: number }>(
`
WITH tail AS (
SELECT oi.id, oi.normalized_sku_name
FROM order_items oi
WHERE oi.canonical_product_id IS NULL
AND btrim(COALESCE(oi.normalized_sku_name, '')) <> ''
AND EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id)
),
resolved AS (
SELECT t.id, p.id AS canonical_product_id
FROM tail t
JOIN LATERAL (
SELECT id
FROM product_catalog
WHERE status = 'active' AND canonical_name = t.normalized_sku_name
ORDER BY (canonical_sku LIKE 'tpl:%') DESC, id
LIMIT 1
) p ON TRUE
)
UPDATE order_items oi
SET canonical_product_id = r.canonical_product_id,
updated_at = $1
FROM resolved r
WHERE oi.id = r.id
AND oi.canonical_product_id IS NULL
RETURNING oi.id
`,
[now],
)
const row = result.rows[0]
const workOrderItemCount = Number(row?.work_order_item_count || 0)
const ruleMatchedCount = Number(row?.matched_count || 0)
const ruleUpdatedCount = Number(row?.updated_count || 0)
const tailProductCount = tailProducts.rows.filter((item) => item.inserted).length
const tailMappingCount = tailMappings.rows.length
const tailUpdatedCount = tailUpdated.rows.length
const updatedCount = ruleUpdatedCount + tailUpdatedCount
const remainingResult = await query<{ total: number }>(
`
SELECT COUNT(*)::int AS total
FROM order_items oi
WHERE oi.canonical_product_id IS NULL
AND EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id)
`,
)
const remainingCount = Number(remainingResult.rows[0]?.total || 0)
const matchedCount = Math.max(0, workOrderItemCount - remainingCount)
const skippedCount = Math.max(0, matchedCount - updatedCount)
let factsRebuild: Awaited<ReturnType<typeof rebuildFactsForRange>> | null = null
if (matchedCount > 0 && row?.from_date && row?.to_date) {
factsRebuild = await rebuildFactsForRange(row.from_date, row.to_date)
}
return {
updatedCount,
skippedCount,
remainingCount,
workOrderItemCount,
matchedCount,
ruleMatchedCount,
tailUpdatedCount,
tailProductCount,
tailMappingCount,
factsRebuild,
}
}
type TemplateBootstrapTemplateRow = {
id: number
rule_key: string
@@ -22,6 +22,7 @@ export type ActualCostSnapshotSyncResult = {
createdCount: number
upgradedCount: number
directRewardCount: number
estimatedRewardCount: number
}
/**
@@ -198,6 +199,11 @@ export async function syncActualWorkOrderCostSnapshots(
range.fromDate,
range.toDate,
)
const estimatedReward = await syncCommittedWorkOrderRewardSnapshots(
client,
range.fromDate,
range.toDate,
)
const upgraded = await client.query(
`
WITH accepted AS (
@@ -283,9 +289,10 @@ export async function syncActualWorkOrderCostSnapshots(
return {
range,
createdCount: directReward.inserted + (upgraded.rowCount || 0),
createdCount: directReward.inserted + estimatedReward.inserted + (upgraded.rowCount || 0),
upgradedCount: upgraded.rowCount || 0,
directRewardCount: directReward.inserted,
estimatedRewardCount: estimatedReward.inserted,
}
})
}
@@ -327,7 +334,7 @@ async function syncDirectWorkOrderRewardSnapshots(
JOIN orders o ON o.id = oi.order_id
WHERE wo.status = 'accepted'
AND wo.order_item_id IS NOT NULL
AND COALESCE(wo.reward_amount, 0) > 0
AND COALESCE(wo.reward_amount, 0) >= 0
AND (COALESCE(wo.accepted_at, wo.updated_at, o.paid_at, o.created_at)
AT TIME ZONE 'Asia/Shanghai')::date BETWEEN $1::date AND $2::date
ORDER BY wo.order_item_id, wo.id DESC
@@ -382,7 +389,7 @@ async function syncDirectWorkOrderRewardSnapshots(
JOIN orders o ON o.id = oi.order_id
WHERE wo.status = 'accepted'
AND wo.order_item_id IS NOT NULL
AND COALESCE(wo.reward_amount, 0) > 0
AND COALESCE(wo.reward_amount, 0) >= 0
AND (COALESCE(wo.accepted_at, wo.updated_at, o.paid_at, o.created_at)
AT TIME ZONE 'Asia/Shanghai')::date BETWEEN $1::date AND $2::date
ORDER BY wo.order_item_id, wo.id DESC
@@ -429,6 +436,138 @@ async function syncDirectWorkOrderRewardSnapshots(
}
}
/**
* 未验收工单的报酬在创建时已确定,先按 reward_amount 写 estimated 快照。
* 验收后由 syncDirectWorkOrderRewardSnapshots 升级为 actual;取消工单不计成本。
*/
async function syncCommittedWorkOrderRewardSnapshots(
client: PoolClient,
fromDate: string,
toDate: string,
) {
const superseded = await client.query(
`
WITH system_item AS (
SELECT ci.id AS cost_item_id
FROM cost_items ci
WHERE ci.code = 'sys_work_order_reward'
AND ci.status = 'active'
),
latest AS (
SELECT DISTINCT ON (wo.order_item_id)
wo.id AS work_order_id,
wo.order_item_id,
GREATEST(COALESCE(wo.reward_amount, 0), 0)::bigint AS reward_amount
FROM work_orders wo
JOIN orders o ON o.id = wo.order_id
WHERE wo.order_item_id IS NOT NULL
AND wo.status IN (
'pending_material', 'in_progress', 'unassigned', 'open',
'pending_acceptance', 'problem'
)
AND COALESCE(wo.reward_amount, 0) > 0
AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
BETWEEN $1::date AND $2::date
ORDER BY wo.order_item_id, wo.id DESC
)
UPDATE cost_snapshots cs
SET superseded_at = NOW()
FROM latest l
JOIN system_item si ON TRUE
WHERE cs.subject_type = 'order_item'
AND cs.subject_id = l.order_item_id
AND cs.cost_item_id = si.cost_item_id
AND cs.superseded_at IS NULL
AND cs.snapshot_stage = 'estimated'
AND (
cs.work_order_id IS DISTINCT FROM l.work_order_id
OR cs.total_amount_fen <> l.reward_amount
)
`,
[fromDate, toDate],
)
const inserted = await client.query(
`
WITH system_item AS (
SELECT ci.id AS cost_item_id, v.id AS cost_version_id
FROM cost_items ci
JOIN LATERAL (
SELECT id
FROM cost_versions
WHERE cost_item_id = ci.id
AND status = 'active'
AND effective_from <= NOW()
ORDER BY effective_from DESC, id DESC
LIMIT 1
) v ON TRUE
WHERE ci.code = 'sys_work_order_reward'
AND ci.status = 'active'
),
latest AS (
SELECT DISTINCT ON (wo.order_item_id)
wo.id AS work_order_id,
wo.order_item_id,
wo.status,
GREATEST(COALESCE(wo.reward_amount, 0), 0)::bigint AS reward_amount,
COALESCE(wo.created_at, o.paid_at, o.created_at, NOW()) AS basis_at
FROM work_orders wo
JOIN orders o ON o.id = wo.order_id
WHERE wo.order_item_id IS NOT NULL
AND wo.status IN (
'pending_material', 'in_progress', 'unassigned', 'open',
'pending_acceptance', 'problem'
)
AND COALESCE(wo.reward_amount, 0) > 0
AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
BETWEEN $1::date AND $2::date
ORDER BY wo.order_item_id, wo.id DESC
)
INSERT INTO cost_snapshots (
subject_type, subject_id, order_id, order_item_id, work_order_id,
carrier_type, 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
)
SELECT
'order_item', l.order_item_id, oi.order_id, l.order_item_id, l.work_order_id,
'work_order', si.cost_item_id, si.cost_version_id, oi.canonical_product_id,
1, l.reward_amount, l.reward_amount, 'estimated', 'resolved',
jsonb_build_object(
'source', 'work_order_reward_committed',
'workOrderStatus', l.status,
'estimatedRewardFen', l.reward_amount,
'basisAt', l.basis_at
),
l.basis_at, NOW(), NOW()
FROM latest l
CROSS JOIN system_item si
JOIN order_items oi ON oi.id = l.order_item_id
WHERE NOT EXISTS (
SELECT 1 FROM work_orders accepted
WHERE accepted.order_item_id = l.order_item_id
AND accepted.status = 'accepted'
)
AND NOT EXISTS (
SELECT 1 FROM cost_snapshots cs
WHERE cs.subject_type = 'order_item'
AND cs.subject_id = l.order_item_id
AND cs.cost_item_id = si.cost_item_id
AND cs.superseded_at IS NULL
)
ON CONFLICT (subject_type, subject_id, cost_item_id)
WHERE superseded_at IS NULL
DO NOTHING
`,
[fromDate, toDate],
)
return {
inserted: inserted.rowCount || 0,
superseded: superseded.rowCount || 0,
}
}
async function insertAndResolveProductMappingExceptions(
client: PoolClient,
fromDate: string,
@@ -28,10 +28,14 @@ export async function runCostFinanceSyncJob(job: JsonObject = {}) {
return {
status: 'ok',
message: `成本快照 ${estimated.createdCount} 预估 / ${actual.createdCount} 实际,销售事实 ${facts.insertedCount} 行`,
message:
`成本快照 ${estimated.createdCount} 商品预估 / ` +
`${actual.directRewardCount + actual.upgradedCount} 工单实际 / ` +
`${actual.estimatedRewardCount} 工单在途,销售事实 ${facts.insertedCount} 行`,
days,
estimatedCreated: estimated.createdCount,
actualCreated: actual.createdCount,
actualCreated: actual.directRewardCount + actual.upgradedCount,
estimatedRewardCreated: actual.estimatedRewardCount,
exceptionsCreated: estimated.exceptionCreatedCount,
factsInserted: facts.insertedCount,
dateFrom: facts.range.fromDate,
@@ -43,6 +43,7 @@ import {
backfillAdminAffiliateDashSkus,
backfillAdminNormalizedQuantities,
backfillAdminProductMappings,
backfillAdminWorkOrderProductSkus,
createAdminChannelProductMapping,
createAdminCostItem,
createAdminCostSnapshot,
@@ -368,6 +369,7 @@ export default function AdminCostCenterPage() {
const [bootstrapApplying, setBootstrapApplying] = useState(false)
const [syncingAffiliate, setSyncingAffiliate] = useState(false)
const [backfillingAffiliateSkus, setBackfillingAffiliateSkus] = useState(false)
const [backfillingWorkOrderSkus, setBackfillingWorkOrderSkus] = useState(false)
const versionScopeType = Form.useWatch('scopeType', versionForm)
const versionCostItemId = Form.useWatch('costItemId', versionForm)
@@ -694,6 +696,33 @@ export default function AdminCostCenterPage() {
}
}
async function backfillWorkOrderProducts() {
setBackfillingWorkOrderSkus(true)
try {
const response = await backfillAdminWorkOrderProductSkus()
const result = response.data
const factsText = result.factsRebuild
? `;已重算 ${result.factsRebuild.fromDate} ~ ${result.factsRebuild.toDate} 的销售事实`
: ''
const remainingText =
result.remainingCount > 0
? `;仍有 ${result.remainingCount} 个订单项没有可用的工单模板`
: ''
const tailText =
result.tailUpdatedCount > 0
? `,其中长尾商品 ${result.tailUpdatedCount} 个(新建商品 ${result.tailProductCount}、映射 ${result.tailMappingCount})`
: ''
message.success(
`已按工单模板回填 ${result.updatedCount} 个订单项${tailText},${result.skippedCount} 个已一致${factsText}${remainingText}`,
)
refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '回填接单平台商品失败')
} finally {
setBackfillingWorkOrderSkus(false)
}
}
function openEditProduct(item: ProductCatalogItem) {
setEditingProduct(item)
productForm.setFieldsValue({
@@ -1731,13 +1760,22 @@ export default function AdminCostCenterPage() {
{canConfig ? (
<>
{activeCarrier === 'all' || activeCarrier === 'work_order' ? (
<Button
icon={<SyncOutlined />}
loading={bootstrapPreviewing || bootstrapApplying}
onClick={runTemplateBootstrap}
>
从接单模板初始化
</Button>
<>
<Button
icon={<SyncOutlined />}
loading={bootstrapPreviewing || bootstrapApplying}
onClick={runTemplateBootstrap}
>
从接单模板初始化
</Button>
<Button
icon={<SyncOutlined />}
loading={backfillingWorkOrderSkus}
onClick={() => void backfillWorkOrderProducts()}
>
按工单回填商品
</Button>
</>
) : null}
{activeCarrier === 'all' || activeCarrier === 'affiliate_dash' ? (
<Button
@@ -38,6 +38,27 @@ export function applyAdminTemplateProductBootstrap() {
)
}
export function backfillAdminWorkOrderProductSkus() {
return apiPost<{
updatedCount: number
skippedCount: number
remainingCount: number
workOrderItemCount: number
matchedCount: number
ruleMatchedCount: number
tailUpdatedCount: number
tailProductCount: number
tailMappingCount: number
factsRebuild: {
fromDate: string
toDate: string
deletedCount: number
insertedCount: number
batchCount: number
} | null
}>('/api/v1/admin/cost-center/work-order-products/backfill')
}
export function fetchAdminProductCatalog(params?: Record<string, unknown>) {
return apiGet<ProductCatalogListResult>('/api/v1/admin/cost-center/products', params)
}
+1 -1
View File
@@ -55,7 +55,7 @@
| 客服备用金台账 | **已实施 P0** | `fund_accounts` / `fund_entries` / `manual_expenses` / `fund_reconciliations`;后台页面、权限、审计已完成 |
| 后台用户中文显示名 | **已实施** | `admin_users.display_name`,备用金姓名触发器同步;审计、登录记录、任务处理人优先显示中文名 |
| 商品主数据 + 渠道 SKU 映射 | **已实施 P0(dev 分支)** | 迁移 `103`(映射消歧在 `106`);`product_catalog` / `channel_product_mappings`,后台「商品与成本」页支持维护、历史订单项回填与按载体分区 |
| 成本中心 | **已实施 P0(dev 分支)** | 迁移 `104/105/107/108/110`;`cost_items` / `cost_versions` / `cost_snapshots` / `cost_exceptions` 支持版本、预览、快照、异常队列、自动发货价格导入;自动发货订单项按履约任务 SKU 直接归一为 `aff:<skuCode>`(不建渠道映射),接单平台按工单实际报酬、人工按支出登记写入实际快照 |
| 成本中心 | **已实施 P0(dev 分支)** | 迁移 `104/105/107/108/110`;`cost_items` / `cost_versions` / `cost_snapshots` / `cost_exceptions` 支持版本、预览、快照、异常队列、自动发货价格导入;自动发货订单项按履约任务 SKU 直接归一为 `aff:<skuCode>`(不建渠道映射),接单平台按工单模板回填并补齐长尾商品/映射,人工按支出登记写入实际快照 |
| 销售事实 / 订单毛利 | **已实施 P0(dev 分支)** | 迁移 `109`;`sales_daily_facts` 按店铺×日期×标准商品×载体汇总,未归一商品单独成行;后台新增「销售毛利」页,成本缺失会显式告警 |
| 公司人工支出登记 | **已实施 P0(dev 分支)** | 迁移 `111`;`manual_expense_records` / `manual_expense_events`,后台「人工支出」页支持登记、审批、驳回、报销;与客服备用金 `manual_expenses` 通过 `fund_expense_id` 关联 |
| 影子总账 | 未实施 | 现有财务页仍是 `UNION ALL` 业务事件拼装,不是复式总账 |
+9 -5
View File
@@ -140,11 +140,11 @@ product_catalog
`carrier_type`(迁移 `107_product_catalog_carrier.sql`)是后台「商品与成本中心」的分区维度:自动发货 / 接单平台 / 人工 / 共用。回填口径(2026-10-22 修正):只把**真正走 affiliate-dash 执行器**的商品算作 `affiliate_dash`——以 `fulfillment_tasks.executor_key='affiliate_dash'` 的履约证据为准,按 `context_json.skuCode` 建标准商品(`aff:<skuCode>`,生产快照 41,676 条任务 / 28 个 SKU);接单模板初始化出来的 `tpl:*` 商品全部是 `work_order`(662 个)。**渠道映射只代表平台 SKU 能归一到标准商品,不代表自动发货**。自动发货是单一上游渠道,订单项直接取最新 `fulfillment_tasks.context_json.skuCode` 归一为 `aff:<skuCode>`,**不写 `channel_product_mappings`**;页面「回填自动发货 SKU」会回填 `order_items.canonical_product_id` 并自动重算受影响日期的销售事实。人工/共用由后台按需手工调整;新增自动发货 SKU 可在页面点「同步自动发货商品」补建。
上游自动发货商品价格用 `apps/backend/scripts/sync-affiliate-dash-prices.ts` 批量导入(价格清单见 `apps/backend/scripts/data/affiliate-dash-prices.json`):按 `aff:<skuCode>` 补建/更新商品,并给指定成本项生成 `scope=product` 的按件版本;金额取上游 `cost_amount`(分),价格变化时先停用旧版本再插入新版本,历史快照不变。示例:`npx tsx scripts/sync-affiliate-dash-prices.ts --cost-item-id=5`。
上游自动发货商品价格用 `apps/backend/scripts/sync-affiliate-dash-prices.ts` 批量导入(价格清单见 `apps/backend/scripts/data/affiliate-dash-prices.json`):按 `aff:<skuCode>` 补建/更新商品,并给指定成本项生成 `scope=product` 的按件版本;金额取上游 `cost_amount`(分),价格变化时先停用旧版本再插入新版本,历史快照不变。首次导入历史基线用 `--effective-from=2026-07-10`,脚本会把「金额相同但生效时间更晚」的版本回填到该日期。示例:`npx tsx scripts/sync-affiliate-dash-prices.ts --cost-item-id=5 --effective-from=2026-07-10`。
接单平台与人工客服各有一个系统成本项(迁移 `110_system_cost_items.sql`):
- `sys_work_order_reward`:工单验收后直接按 `work_orders.reward_amount` 写 `actual` 快照,版本金额 0 只是占位,不进入预估阶段;本地最近 30 天已回填 38,023 条实际快照,金额与工单 reward 逐条一致。
- `sys_work_order_reward`:已验收工单按 `work_orders.reward_amount` 写 `actual`;未验收但已确定报酬的工单先按同一金额写 `estimated`(metadata `source=work_order_reward_committed`),验收后升级为 `actual`;取消工单不计成本。版本金额 0 只是占位,不进入商品预估。本地全量已回填 95,870 条 `actual` + 21,217 条 `estimated`。
- `sys_manual_service`:人工支出登记通过后按登记金额写 `actual` 快照;成本版本先为 `draft` 占位,不参与预估,但允许人工支出使用实际金额入账。财务填写正式按单单价后可将版本改为 `active`。
### 3.2 渠道 SKU 映射
@@ -364,7 +364,7 @@ orders + order_items + channel_product_mappings + cost_snapshots
后台入口:`/admin/sales-profit`。回填脚本:`npm run finance:sync -- --days=14 --mode=all`;定时任务 `cost-finance-sync` 每 5 分钟处理最近 7 天。成本缺失会写入 `cost_missing_count`,毛利在补齐成本前只作参考。
数据校验:本地 2026-07-10 ~ 2026-10-14 共 316,597 个已支付订单,其中 12 单没有订单项(¥2,480.60),无法归集到商品/成本,销售事实会排除并在页面单独提示;其余 316,585 个订单项收入与订单总额完全对平。
最近 30 天回填后,接单平台实际成本快照 38,023 条、金额 1,633,917.74 元;其中落在销售事实日期范围内的成本 1,563,232.98 元,与工单 reward 逐条一致。自动发货收入已于 2026-10-22 全量归一(41,676 个订单项),但自动发货采购成本快照仍为 0 条,需要按 `aff:<skuCode>` 的成本版本补跑成本同步;人工客服仍因缺少映射/正式单价显示成本缺失。
全量回填后(2026-07-10 ~ 2026-10-10):接单平台 95,870 条 `actual`(¥3,773,401.32)+ 21,217 条 `estimated`(¥754,912.45);自动发货 41,676 条 `estimated`(¥4,974,059.00)。自动发货与接单平台收入归一率均为 100%,未归一收入只剩人工块 145,507 项 / ¥15,253,660.86。接单平台仍有 966 个订单项没有成本快照:601 个已取消工单 + 365 个零报酬未验收工单,保留在成本异常队列等业务确认,不自动记 0。人工客服仍因缺少商品与正式单价显示成本缺失。
---
@@ -388,7 +388,7 @@ orders + order_items + channel_product_mappings + cost_snapshots
- 店铺/日期/皮肤统计必须走 `canonical_product_id`,不能按原始 `sku_code`。
- 统计件数要解析 SKU 文本中的数量。
实测(本地 2026-10-22):41,676 个自动发货订单项全部回填,自动发货未归一收入 ¥5,089,712.26 → ¥0.00(未归一占比 100% → 0%)。整体未归一收入占比 79.67% → 60.47%,剩余几乎全部来自人工块(¥15,253,660.86,94.84%),需要业务先确定人工 SKU/商品口径。
实测(本地 2026-10-22):41,676 个自动发货订单项全部回填,未归一收入 ¥5,089,712.26 → ¥0.00;采购成本 ¥4,974,059.00(毛利率 2.27%)。整体未归一收入占比 79.67% → 57.54%,剩余 100% 来自人工块(145,507 项 / ¥15,253,660.86),需要业务先确定人工 SKU/商品口径。
---
@@ -405,9 +405,13 @@ orders + order_items + channel_product_mappings + cost_snapshots
- 工单报酬是主成本;押金是负债,不是成本。
- 模板改价必须生成 `cost_versions`,不能直接改历史工单。
- 工单实际结算金额写入 `cost_snapshot(actual)`。
- 工单实际结算金额写入 `cost_snapshot(actual)`;未验收但已确定报酬的工单先写 `actual` 以外的 `estimated`,验收后升级。
- 商品归一入口:后台「接单平台」→「按工单回填商品」,接口 `POST /api/v1/admin/cost-center/work-order-products/backfill`。优先按工单真实使用的 `work_orders.product_rule_id` 指向 `tpl:<rule_key>`;仍无法归一的 341 个长尾订单项,再按 `provider + shop_id + sku_code + normalized_sku_name` 自动补 45 个 `wo:` 标准商品和 115 条 `sku_code_name` 渠道映射。两次回填后接单平台归一率 100%。
- 成本异常:966 个订单项没有成本快照(601 个已取消工单 + 365 个零报酬未验收工单),保留在异常队列;取消/零报酬不等于 0 成本,不自动写 0。
- 影子总账负责收入/成本/押金/提现/售后的复式记录;成本中心负责“金额是多少、用了哪个版本”。
本地实测(2026-10-22):接单平台 118,053 个订单项全部归一;未归一收入 ¥775,641.19(14.54%)→ ¥0.00。成本 95,870 条 `actual`(¥3,773,401.32)+ 21,217 条 `estimated`(¥754,912.45),合计 ¥4,528,313.77;销售收入 ¥5,335,734.45,毛利率 15.13%。
生产数据提示:91/PDD/抖音已验收工单的报酬/订单金额分别约 86%/80%/96.5%。在收入口径未确认前,这个比例只能作为量级参考,不能直接当毛利率。
---