feat(finance): 完成成本快照运行层、销售事实与人工支出登记

迁移 108-111:快照业务时间与预估转实际、销售事实表、系统成本项、公司人工支出登记。接单平台按工单实际 reward 入账;人工支出支持登记/审批/驳回/报销并写 actual 快照。新增销售毛利页、人工支出页与订单入口。
This commit is contained in:
yml2213
2026-10-22 16:41:03 +08:00
parent 15989af87c
commit 0889235870
39 changed files with 4824 additions and 143 deletions
@@ -200,6 +200,7 @@ type OrderItemCostContext = {
normalizedQuantity: number
canonicalProductId: number | null
revenueFen: number
basisAt: string
workOrderId: number | null
workProductRuleId: number | null
hasAffiliateDashTask: boolean
@@ -2632,6 +2633,8 @@ export async function previewAdminCost(payload: JsonObject = {}) {
const mapping = await findMappingForContext(context)
const productId = context.canonicalProductId || mapping?.canonical_product_id || null
const product = productId ? mapProduct(await getProductRow(productId)) : null
const asOf =
normalizeIsoDate(payload.asOf ?? payload.costBasisAt, '成本基准时间', true) || context.basisAt
const resolution: CostResolution = productId
? await resolveCostLines({
provider: context.provider,
@@ -2641,6 +2644,7 @@ export async function previewAdminCost(payload: JsonObject = {}) {
workProductRuleId: context.workProductRuleId,
quantity: context.normalizedQuantity,
revenueFen: context.revenueFen,
asOf,
})
: { lines: [], missingCostItems: [], applicableCostItemCount: 0 }
@@ -2677,6 +2681,7 @@ export async function previewAdminCost(payload: JsonObject = {}) {
return {
context: {
...context,
basisAt: asOf,
carrierType,
},
mapping: mapping ? mapMapping(mapping) : null,
@@ -2698,23 +2703,61 @@ export async function createAdminCostSnapshot(payload: JsonObject = {}, _session
})
}
const now = nowIso()
const stage = payload.snapshotStage === 'actual' ? 'actual' : 'estimated'
const snapshots: Array<Record<string, unknown>> = []
let exceptionCreatedCount = 0
let supersededCount = 0
await withTransaction(async (client) => {
for (const line of preview.lines) {
const currentResult = await client.query<{
id: number
snapshot_stage: string
total_amount_fen: number
}>(
`
SELECT id, snapshot_stage, total_amount_fen
FROM cost_snapshots
WHERE subject_type = 'order_item'
AND subject_id = $1
AND cost_item_id = $2
AND superseded_at IS NULL
FOR UPDATE
`,
[orderItemId, line.costItemId],
)
const current = currentResult.rows[0]
if (current) {
// 预估快照一旦生成就代表支付时点的成本,不因后续改价重写;
// 只有实际成本允许替代当前有效行。
if (stage === 'estimated') continue
if (
current.snapshot_stage === 'actual' &&
Number(current.total_amount_fen) === line.totalAmountFen
) {
continue
}
await client.query(
`UPDATE cost_snapshots SET superseded_at = $1 WHERE id = $2 AND superseded_at IS NULL`,
[now, current.id],
)
supersededCount += 1
}
const result = await client.query<{ [column: string]: unknown }>(
`
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, resolved_at, created_at
metadata_json, cost_basis_at, resolved_at, created_at
) VALUES (
'order_item', $1, $2, $1, $3, $4, $5, $6, $7,
$8, $9, $10, $11, 'resolved', $12::jsonb, $13, $13
$8, $9, $10, $11, 'resolved', $12::jsonb, $13, $14, $14
)
ON CONFLICT (subject_type, subject_id, cost_item_id) DO NOTHING
ON CONFLICT (subject_type, subject_id, cost_item_id)
WHERE superseded_at IS NULL
DO NOTHING
RETURNING *
`,
[
@@ -2728,14 +2771,16 @@ export async function createAdminCostSnapshot(payload: JsonObject = {}, _session
line.quantity,
line.unitAmountFen,
line.totalAmountFen,
payload.snapshotStage === 'actual' ? 'actual' : 'estimated',
stage,
JSON.stringify({
skuCode: preview.context.skuCode,
skuName: preview.context.skuName,
provider: preview.context.provider,
shopId: preview.context.shopId,
normalizedQuantity: preview.context.normalizedQuantity,
basisAt: preview.context.basisAt,
}),
preview.context.basisAt,
now,
],
)
@@ -2768,7 +2813,13 @@ export async function createAdminCostSnapshot(payload: JsonObject = {}, _session
}
})
return { preview, snapshots, createdCount: snapshots.length, exceptionCreatedCount }
return {
preview,
snapshots,
createdCount: snapshots.length,
supersededCount,
exceptionCreatedCount,
}
}
async function resolveCostContext(payload: JsonObject): Promise<OrderItemCostContext> {
@@ -2787,6 +2838,7 @@ async function resolveCostContext(payload: JsonObject): Promise<OrderItemCostCon
normalized_quantity: number | null
canonical_product_id: number | null
revenue_fen: number
basis_at: string
work_order_id: number | null
work_product_rule_id: number | null
has_affiliate_dash_task: boolean
@@ -2804,7 +2856,8 @@ async function resolveCostContext(payload: JsonObject): Promise<OrderItemCostCon
oi.quantity,
oi.normalized_quantity,
oi.canonical_product_id,
o.total_amount AS revenue_fen,
oi.revenue_amount_fen AS revenue_fen,
COALESCE(o.paid_at, o.created_at) AS basis_at,
wo.id AS work_order_id,
wo.product_rule_id AS work_product_rule_id,
EXISTS (
@@ -2813,7 +2866,13 @@ async function resolveCostContext(payload: JsonObject): Promise<OrderItemCostCon
) AS has_affiliate_dash_task
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
LEFT JOIN LATERAL (
SELECT id, product_rule_id
FROM work_orders
WHERE order_item_id = oi.id
ORDER BY id DESC
LIMIT 1
) wo ON TRUE
WHERE oi.id = $1
LIMIT 1
`,
@@ -2844,6 +2903,7 @@ async function resolveCostContext(payload: JsonObject): Promise<OrderItemCostCon
: Math.max(1, resolveSkuNameQuantity(row.sku_name || row.sku_code)),
canonicalProductId: row.canonical_product_id ? Number(row.canonical_product_id) : null,
revenueFen: Math.max(0, Number(row.revenue_fen) || 0),
basisAt: new Date(row.basis_at).toISOString(),
workOrderId: row.work_order_id ? Number(row.work_order_id) : null,
workProductRuleId: row.work_product_rule_id ? Number(row.work_product_rule_id) : null,
hasAffiliateDashTask: Boolean(row.has_affiliate_dash_task),
@@ -2869,6 +2929,8 @@ async function resolveCostContext(payload: JsonObject): Promise<OrderItemCostCon
payload.canonicalProductId ?? payload.canonical_product_id,
),
revenueFen: Math.max(0, Math.trunc(Number(payload.revenueFen ?? payload.revenue_fen) || 0)),
basisAt:
normalizeIsoDate(payload.asOf ?? payload.costBasisAt, '成本基准时间', true) || nowIso(),
workOrderId: null,
workProductRuleId: normalizeOptionalId(
payload.workProductRuleId ?? payload.work_product_rule_id,
@@ -2933,6 +2995,7 @@ async function resolveCostLines(input: {
workProductRuleId: number | null
quantity: number
revenueFen: number
asOf?: string
}): Promise<CostResolution> {
const result = await query<
CostItemRow & {
@@ -2981,7 +3044,7 @@ async function resolveCostLines(input: {
ORDER BY ci.id
`,
[
nowIso(),
input.asOf || nowIso(),
input.provider,
input.shopId,
input.canonicalProductId,
@@ -0,0 +1,580 @@
import type { PoolClient } from 'pg'
import { withTransaction } from '../../db/client.js'
import { resolveFinanceDateRange } from '../../utils/finance-date-range.js'
export type CostSnapshotSyncInput = {
dateFrom?: unknown
dateTo?: unknown
from?: unknown
to?: unknown
}
export type CostSnapshotSyncResult = {
range: { fromDate: string; toDate: string }
createdCount: number
exceptionCreatedCount: number
resolvedExceptionCount: number
}
export type ActualCostSnapshotSyncResult = {
range: { fromDate: string; toDate: string }
createdCount: number
upgradedCount: number
directRewardCount: number
}
/**
* 按订单项批量生成预估成本快照。
*
* 只处理已有 canonical_product_id 的订单项;未归一订单项进入
* product_mapping_missing 异常,不在这里猜测商品。
*/
export async function syncEstimatedOrderItemCostSnapshots(
input: CostSnapshotSyncInput = {},
): Promise<CostSnapshotSyncResult> {
const range = resolveFinanceDateRange(input, { defaultDays: 7, maxDays: 400 })
return withTransaction(async (client) => {
const inserted = await client.query(
`
WITH item_scope AS (
SELECT
oi.id AS order_item_id,
oi.order_id,
o.provider,
o.shop_id,
oi.sku_code,
oi.sku_name,
oi.canonical_product_id,
COALESCE(o.paid_at, o.created_at) AS basis_at,
GREATEST(COALESCE(oi.normalized_quantity, oi.quantity, 1), 1)::numeric AS quantity,
COALESCE(oi.revenue_amount_fen, 0)::bigint AS revenue_fen,
wo.id AS work_order_id,
wo.product_rule_id AS work_product_rule_id,
CASE
WHEN wo.id IS NOT NULL THEN 'work_order'
WHEN EXISTS (
SELECT 1 FROM fulfillment_tasks ft
WHERE ft.order_item_id = oi.id
AND ft.executor_key = 'affiliate_dash'
) THEN 'affiliate_dash'
ELSE 'manual'
END AS carrier_type
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
LEFT JOIN LATERAL (
SELECT id, product_rule_id
FROM work_orders
WHERE order_item_id = oi.id
ORDER BY id DESC
LIMIT 1
) wo ON TRUE
WHERE o.pay_status IN ('paid', 'refunded')
AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
BETWEEN $1::date AND $2::date
),
resolved AS (
SELECT
s.*,
ci.id AS cost_item_id,
ci.pricing_mode,
v.id AS cost_version_id,
v.amount_fen,
v.rate_bps
FROM item_scope s
JOIN cost_items ci ON ci.status = 'active'
AND ci.carrier_type IN (s.carrier_type, 'shared')
AND ci.pricing_mode IN ('per_item', 'per_order', 'percent_of_revenue')
AND ci.code <> 'sys_work_order_reward'
JOIN LATERAL (
SELECT v.id, v.amount_fen, v.rate_bps
FROM cost_versions v
WHERE v.cost_item_id = ci.id
AND v.status = 'active'
AND v.effective_from <= s.basis_at
AND (v.effective_to IS NULL OR v.effective_to > s.basis_at)
AND (
(v.scope_type = 'rule' AND s.work_product_rule_id IS NOT NULL
AND v.work_product_rule_id = s.work_product_rule_id)
OR (v.scope_type = 'product' AND s.canonical_product_id IS NOT NULL
AND v.canonical_product_id = s.canonical_product_id)
OR (v.scope_type = 'shop' AND v.provider = s.provider AND v.shop_id = s.shop_id)
OR (v.scope_type = 'provider' AND v.provider = s.provider)
OR (v.scope_type = 'global')
)
ORDER BY
CASE v.scope_type
WHEN 'rule' THEN 1
WHEN 'product' THEN 2
WHEN 'shop' THEN 3
WHEN 'provider' THEN 4
ELSE 5
END,
v.effective_from DESC,
v.id DESC
LIMIT 1
) v ON TRUE
WHERE s.canonical_product_id IS NOT NULL
),
calculated AS (
SELECT
r.*,
CASE r.pricing_mode
WHEN 'per_item' THEN ROUND(r.amount_fen::numeric * r.quantity)::bigint
WHEN 'per_order' THEN r.amount_fen
WHEN 'percent_of_revenue'
THEN ROUND((r.revenue_fen::numeric * r.rate_bps::numeric) / 10000.0)::bigint
ELSE r.amount_fen
END AS total_amount_fen
FROM resolved r
)
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', c.order_item_id, c.order_id, c.order_item_id, c.work_order_id,
c.carrier_type, c.cost_item_id, c.cost_version_id, c.canonical_product_id,
CASE WHEN c.pricing_mode = 'per_item' THEN c.quantity ELSE 1 END,
CASE WHEN c.pricing_mode = 'percent_of_revenue' THEN c.total_amount_fen ELSE c.amount_fen END,
c.total_amount_fen,
'estimated', 'resolved',
jsonb_build_object(
'provider', c.provider,
'shopId', c.shop_id,
'skuCode', c.sku_code,
'skuName', c.sku_name,
'pricingMode', c.pricing_mode,
'revenueFen', c.revenue_fen,
'normalizedQuantity', c.quantity,
'basisAt', c.basis_at
),
c.basis_at, NOW(), NOW()
FROM calculated c
ON CONFLICT (subject_type, subject_id, cost_item_id)
WHERE superseded_at IS NULL
DO NOTHING
`,
[range.fromDate, range.toDate],
)
const productExceptions = await insertAndResolveProductMappingExceptions(
client,
range.fromDate,
range.toDate,
)
const costExceptions = await insertAndResolveCostMissingExceptions(
client,
range.fromDate,
range.toDate,
)
return {
range,
createdCount: inserted.rowCount || 0,
exceptionCreatedCount: productExceptions.created + costExceptions.created,
resolvedExceptionCount: productExceptions.resolved + costExceptions.resolved,
}
})
}
/**
* 工单验收后,把订单项当前预估快照升级为实际快照。
*
* 如果配置了 work_order_reward 成本项,则用工单实际 reward_amount 覆盖金额;
* 否则仍冻结当前版本金额,但把阶段标为 actual。旧快照保留 superseded_at。
*/
export async function syncActualWorkOrderCostSnapshots(
input: CostSnapshotSyncInput = {},
): Promise<ActualCostSnapshotSyncResult> {
const range = resolveFinanceDateRange(input, { defaultDays: 30, maxDays: 400 })
return withTransaction(async (client) => {
const directReward = await syncDirectWorkOrderRewardSnapshots(
client,
range.fromDate,
range.toDate,
)
const upgraded = await client.query(
`
WITH accepted 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,
COALESCE(wo.accepted_at, wo.updated_at, o.paid_at, o.created_at, NOW()) AS accepted_at
FROM work_orders wo
JOIN order_items oi ON oi.id = wo.order_item_id
JOIN orders o ON o.id = oi.order_id
WHERE wo.status = 'accepted'
AND wo.order_item_id IS NOT NULL
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
),
active AS (
SELECT
cs.*,
a.work_order_id AS actual_work_order_id,
a.reward_amount,
a.accepted_at,
ci.cost_type
FROM cost_snapshots cs
JOIN accepted a ON cs.subject_type = 'order_item' AND cs.subject_id = a.order_item_id
JOIN cost_items ci ON ci.id = cs.cost_item_id
WHERE cs.superseded_at IS NULL
AND cs.snapshot_stage IN ('estimated', 'actual')
AND ci.code <> 'sys_work_order_reward'
),
target AS (
SELECT *
FROM active
WHERE snapshot_stage = 'estimated'
OR total_amount_fen <> CASE
WHEN cost_type = 'work_order_reward' AND reward_amount > 0 THEN reward_amount
ELSE total_amount_fen
END
),
superseded AS (
UPDATE cost_snapshots cs
SET superseded_at = NOW()
FROM target t
WHERE cs.id = t.id
AND cs.superseded_at IS NULL
RETURNING
cs.id, cs.subject_id, cs.order_id, cs.order_item_id, cs.carrier_type,
cs.cost_item_id, cs.cost_version_id, cs.canonical_product_id,
cs.quantity, cs.unit_amount_fen, cs.total_amount_fen, cs.metadata_json
)
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', s.subject_id, s.order_id, s.order_item_id, a.work_order_id,
s.carrier_type, s.cost_item_id, s.cost_version_id, s.canonical_product_id,
CASE WHEN ci.cost_type = 'work_order_reward' AND a.reward_amount > 0 THEN 1
ELSE s.quantity END,
CASE WHEN ci.cost_type = 'work_order_reward' AND a.reward_amount > 0
THEN a.reward_amount ELSE s.unit_amount_fen END,
CASE WHEN ci.cost_type = 'work_order_reward' AND a.reward_amount > 0
THEN a.reward_amount ELSE s.total_amount_fen END,
'actual', 'resolved',
s.metadata_json || jsonb_build_object(
'actualWorkOrderId', a.work_order_id,
'actualRewardFen', a.reward_amount,
'basisAt', a.accepted_at
),
a.accepted_at, NOW(), NOW()
FROM superseded s
JOIN accepted a ON a.order_item_id = s.subject_id
JOIN cost_items ci ON ci.id = s.cost_item_id
ON CONFLICT (subject_type, subject_id, cost_item_id)
WHERE superseded_at IS NULL
DO NOTHING
`,
[range.fromDate, range.toDate],
)
return {
range,
createdCount: directReward.inserted + (upgraded.rowCount || 0),
upgradedCount: upgraded.rowCount || 0,
directRewardCount: directReward.inserted,
}
})
}
/**
* 接单平台的实际成本不依赖预估快照:工单验收后直接按 reward_amount 写 actual。
* 系统成本项 sys_work_order_reward 的版本金额只是占位,不会进入预估阶段。
*/
async function syncDirectWorkOrderRewardSnapshots(
client: PoolClient,
fromDate: string,
toDate: string,
) {
const superseded = 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'
),
accepted 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,
COALESCE(wo.accepted_at, wo.updated_at, o.paid_at, o.created_at, NOW()) AS accepted_at
FROM work_orders wo
JOIN order_items oi ON oi.id = wo.order_item_id
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.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
)
UPDATE cost_snapshots cs
SET superseded_at = NOW()
FROM accepted a
JOIN system_item si ON TRUE
JOIN LATERAL (
SELECT id, snapshot_stage, total_amount_fen
FROM cost_snapshots
WHERE subject_type = 'order_item'
AND subject_id = a.order_item_id
AND cost_item_id = si.cost_item_id
AND superseded_at IS NULL
ORDER BY id DESC
LIMIT 1
FOR UPDATE
) current ON TRUE
WHERE cs.id = current.id
AND cs.superseded_at IS NULL
AND (current.snapshot_stage <> 'actual' OR current.total_amount_fen <> a.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'
),
accepted 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,
COALESCE(wo.accepted_at, wo.updated_at, o.paid_at, o.created_at, NOW()) AS accepted_at
FROM work_orders wo
JOIN order_items oi ON oi.id = wo.order_item_id
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.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
)
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', a.order_item_id, oi.order_id, a.order_item_id, a.work_order_id,
'work_order', si.cost_item_id, si.cost_version_id, oi.canonical_product_id,
1, a.reward_amount, a.reward_amount, 'actual', 'resolved',
jsonb_build_object(
'source', 'system_work_order_reward',
'actualWorkOrderId', a.work_order_id,
'actualRewardFen', a.reward_amount,
'basisAt', a.accepted_at
),
a.accepted_at, NOW(), NOW()
FROM accepted a
CROSS JOIN system_item si
JOIN order_items oi ON oi.id = a.order_item_id
WHERE NOT EXISTS (
SELECT 1 FROM cost_snapshots cs
WHERE cs.subject_type = 'order_item'
AND cs.subject_id = a.order_item_id
AND cs.cost_item_id = si.cost_item_id
AND cs.superseded_at IS NULL
AND cs.snapshot_stage = 'actual'
AND cs.total_amount_fen = a.reward_amount
)
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,
toDate: string,
) {
const inserted = await client.query(
`
WITH item_scope AS (
SELECT
oi.id AS order_item_id,
o.provider,
o.shop_id,
oi.sku_code,
oi.sku_name,
oi.canonical_product_id,
COALESCE(o.paid_at, o.created_at) AS basis_at,
wo.id AS work_order_id,
CASE
WHEN wo.id IS NOT NULL THEN 'work_order'
WHEN EXISTS (
SELECT 1 FROM fulfillment_tasks ft
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'
) THEN 'affiliate_dash'
ELSE 'manual'
END AS carrier_type
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
LEFT JOIN LATERAL (
SELECT id FROM work_orders WHERE order_item_id = oi.id ORDER BY id DESC LIMIT 1
) wo ON TRUE
WHERE o.pay_status IN ('paid', 'refunded')
AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
BETWEEN $1::date AND $2::date
)
INSERT INTO cost_exceptions (
subject_type, subject_id, order_item_id, work_order_id,
carrier_type, reason, status, detail_json, created_at
)
SELECT
'order_item', s.order_item_id, s.order_item_id, s.work_order_id,
s.carrier_type, 'product_mapping_missing', 'open',
jsonb_build_object(
'provider', s.provider,
'shopId', s.shop_id,
'skuCode', s.sku_code,
'skuName', s.sku_name,
'basisAt', s.basis_at,
'message', '订单项没有归一到标准商品,销售事实会进入未归一桶'
),
NOW()
FROM item_scope s
WHERE s.canonical_product_id IS NULL
ON CONFLICT (subject_type, subject_id, reason) DO NOTHING
`,
[fromDate, toDate],
)
const resolved = await client.query(
`
UPDATE cost_exceptions ce
SET status = 'resolved', resolved_at = NOW()
WHERE ce.status = 'open'
AND ce.subject_type = 'order_item'
AND ce.reason = 'product_mapping_missing'
AND EXISTS (
SELECT 1 FROM order_items oi
WHERE oi.id = ce.subject_id AND oi.canonical_product_id IS NOT NULL
)
`,
)
return { created: inserted.rowCount || 0, resolved: resolved.rowCount || 0 }
}
async function insertAndResolveCostMissingExceptions(
client: PoolClient,
fromDate: string,
toDate: string,
) {
const inserted = await client.query(
`
WITH item_scope AS (
SELECT
oi.id AS order_item_id,
oi.canonical_product_id,
COALESCE(o.paid_at, o.created_at) AS basis_at,
wo.id AS work_order_id,
CASE
WHEN wo.id IS NOT NULL THEN 'work_order'
WHEN EXISTS (
SELECT 1 FROM fulfillment_tasks ft
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'
) THEN 'affiliate_dash'
ELSE 'manual'
END AS carrier_type
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
LEFT JOIN LATERAL (
SELECT id FROM work_orders WHERE order_item_id = oi.id ORDER BY id DESC LIMIT 1
) wo ON TRUE
WHERE o.pay_status IN ('paid', 'refunded')
AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
BETWEEN $1::date AND $2::date
)
INSERT INTO cost_exceptions (
subject_type, subject_id, order_item_id, work_order_id,
carrier_type, reason, status, detail_json, created_at
)
SELECT
'order_item', s.order_item_id, s.order_item_id, s.work_order_id,
s.carrier_type, 'cost_missing', 'open',
jsonb_build_object(
'canonicalProductId', s.canonical_product_id,
'carrierType', s.carrier_type,
'basisAt', s.basis_at,
'message', '订单项没有匹配到任何成本版本'
),
NOW()
FROM item_scope s
WHERE s.canonical_product_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM cost_snapshots cs
WHERE cs.subject_type = 'order_item'
AND cs.subject_id = s.order_item_id
AND cs.superseded_at IS NULL
)
ON CONFLICT (subject_type, subject_id, reason) DO NOTHING
`,
[fromDate, toDate],
)
const resolved = await client.query(
`
UPDATE cost_exceptions ce
SET status = 'resolved', resolved_at = NOW()
WHERE ce.status = 'open'
AND ce.subject_type = 'order_item'
AND ce.reason = 'cost_missing'
AND EXISTS (
SELECT 1 FROM cost_snapshots cs
WHERE cs.subject_type = 'order_item'
AND cs.subject_id = ce.subject_id
AND cs.superseded_at IS NULL
)
`,
)
return { created: inserted.rowCount || 0, resolved: resolved.rowCount || 0 }
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,443 @@
import { query, withTransaction } from '../../db/client.js'
import { createHttpError } from '../../utils/http.js'
import { resolveFinanceDateRange } from '../../utils/finance-date-range.js'
import { nowIso } from '../../utils/time.js'
export type SalesFactsInput = {
dateFrom?: unknown
dateTo?: unknown
from?: unknown
to?: unknown
}
export type SalesFactsRebuildResult = {
range: { fromDate: string; toDate: string }
deletedCount: number
insertedCount: number
computedAt: string
}
type SalesFactRow = {
[column: string]: unknown
id: number
stat_date: string | Date
provider: string
shop_id: string
canonical_product_id: number | null
canonical_sku: string | null
canonical_name: string | null
carrier_type: string
order_count: number
item_count: number
quantity: string | number
gross_revenue_fen: number
refund_amount_fen: number
net_revenue_fen: number
cost_amount_fen: number
cost_missing_count: number
cost_stage: string
gross_profit_fen: number
computed_at: string
}
/**
* 重算指定日期范围的销售事实。日期统一按 Asia/Shanghai 归属。
* 未归一商品允许 canonical_product_id 为空,汇总时形成“未归一”桶。
*/
export async function rebuildSalesDailyFacts(
input: SalesFactsInput = {},
): Promise<SalesFactsRebuildResult> {
const range = resolveFinanceDateRange(input, { defaultDays: 30, maxDays: 400 })
const computedAt = nowIso()
return withTransaction(async (client) => {
const deleted = await client.query(
`DELETE FROM sales_daily_facts WHERE stat_date BETWEEN $1::date AND $2::date`,
[range.fromDate, range.toDate],
)
const inserted = await client.query(
`
WITH item_base AS (
SELECT
(COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date AS stat_date,
o.provider,
o.shop_id,
oi.canonical_product_id,
CASE
WHEN wo.id IS NOT NULL THEN 'work_order'
WHEN EXISTS (
SELECT 1 FROM fulfillment_tasks ft
WHERE ft.order_item_id = oi.id
AND ft.executor_key = 'affiliate_dash'
) THEN 'affiliate_dash'
ELSE 'manual'
END AS carrier_type,
oi.order_id,
COALESCE(oi.revenue_amount_fen, 0)::bigint AS revenue_fen,
CASE
WHEN o.pay_status = 'refunded' THEN COALESCE(oi.revenue_amount_fen, 0)::bigint
ELSE 0::bigint
END AS refund_fen,
GREATEST(COALESCE(oi.normalized_quantity, oi.quantity, 1), 1)::numeric AS quantity,
COALESCE(cost.cost_fen, 0)::bigint AS cost_fen,
COALESCE(cost.snapshot_count, 0)::int AS snapshot_count,
COALESCE(cost.actual_count, 0)::int AS actual_count
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
LEFT JOIN LATERAL (
SELECT id FROM work_orders WHERE order_item_id = oi.id ORDER BY id DESC LIMIT 1
) wo ON TRUE
LEFT JOIN LATERAL (
SELECT
SUM(cs.total_amount_fen)::bigint AS cost_fen,
COUNT(*)::int AS snapshot_count,
COUNT(*) FILTER (WHERE cs.snapshot_stage = 'actual')::int AS actual_count
FROM cost_snapshots cs
WHERE cs.subject_type = 'order_item'
AND cs.subject_id = oi.id
AND cs.superseded_at IS NULL
) cost ON TRUE
WHERE o.pay_status IN ('paid', 'refunded')
AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
BETWEEN $1::date AND $2::date
)
INSERT INTO sales_daily_facts (
stat_date, provider, shop_id, canonical_product_id, carrier_type,
order_count, item_count, quantity,
gross_revenue_fen, refund_amount_fen, net_revenue_fen,
cost_amount_fen, cost_missing_count, cost_stage, gross_profit_fen,
computed_at, created_at, updated_at
)
SELECT
stat_date,
provider,
shop_id,
canonical_product_id,
carrier_type,
COUNT(DISTINCT order_id)::int,
COUNT(*)::int,
SUM(quantity),
SUM(revenue_fen),
SUM(refund_fen),
SUM(revenue_fen - refund_fen),
SUM(cost_fen),
COUNT(*) FILTER (WHERE snapshot_count = 0)::int,
CASE
WHEN COUNT(*) FILTER (WHERE snapshot_count = 0) = COUNT(*) THEN 'missing'
WHEN COUNT(*) FILTER (WHERE snapshot_count = 0) > 0 THEN 'partial'
WHEN COUNT(*) FILTER (WHERE snapshot_count > 0 AND actual_count < snapshot_count) > 0
THEN 'estimated'
ELSE 'actual'
END,
SUM(revenue_fen - refund_fen) - SUM(cost_fen),
$3::timestamptz,
$3::timestamptz,
$3::timestamptz
FROM item_base
GROUP BY stat_date, provider, shop_id, canonical_product_id, carrier_type
`,
[range.fromDate, range.toDate, computedAt],
)
return {
range,
deletedCount: deleted.rowCount || 0,
insertedCount: inserted.rowCount || 0,
computedAt,
}
})
}
export async function getSalesProfitOverview(input: SalesFactsInput = {}) {
const range = resolveFinanceDateRange(input, { defaultDays: 30, maxDays: 400 })
const [summaryResult, dailyResult, carrierResult, productResult, orderResult] = await Promise.all(
[
query<{
item_count: number
gross_revenue_fen: number
refund_amount_fen: number
net_revenue_fen: number
cost_amount_fen: number
cost_missing_count: number
gross_profit_fen: number
unmapped_revenue_fen: number
}>(
`
SELECT
COALESCE(SUM(item_count), 0)::int AS item_count,
COALESCE(SUM(gross_revenue_fen), 0)::bigint AS gross_revenue_fen,
COALESCE(SUM(refund_amount_fen), 0)::bigint AS refund_amount_fen,
COALESCE(SUM(net_revenue_fen), 0)::bigint AS net_revenue_fen,
COALESCE(SUM(cost_amount_fen), 0)::bigint AS cost_amount_fen,
COALESCE(SUM(cost_missing_count), 0)::int AS cost_missing_count,
COALESCE(SUM(gross_profit_fen), 0)::bigint AS gross_profit_fen,
COALESCE(SUM(gross_revenue_fen) FILTER (WHERE canonical_product_id IS NULL), 0)::bigint
AS unmapped_revenue_fen
FROM sales_daily_facts
WHERE stat_date BETWEEN $1::date AND $2::date
`,
[range.fromDate, range.toDate],
),
query<{
stat_date: string
item_count: number
gross_revenue_fen: number
refund_amount_fen: number
net_revenue_fen: number
cost_amount_fen: number
cost_missing_count: number
gross_profit_fen: number
}>(
`
SELECT
stat_date::text,
SUM(item_count)::int AS item_count,
SUM(gross_revenue_fen)::bigint AS gross_revenue_fen,
SUM(refund_amount_fen)::bigint AS refund_amount_fen,
SUM(net_revenue_fen)::bigint AS net_revenue_fen,
SUM(cost_amount_fen)::bigint AS cost_amount_fen,
SUM(cost_missing_count)::int AS cost_missing_count,
SUM(gross_profit_fen)::bigint AS gross_profit_fen
FROM sales_daily_facts
WHERE stat_date BETWEEN $1::date AND $2::date
GROUP BY stat_date
ORDER BY stat_date ASC
`,
[range.fromDate, range.toDate],
),
query<{
carrier_type: string
item_count: number
gross_revenue_fen: number
cost_amount_fen: number
cost_missing_count: number
gross_profit_fen: number
}>(
`
SELECT
carrier_type,
SUM(item_count)::int AS item_count,
SUM(gross_revenue_fen)::bigint AS gross_revenue_fen,
SUM(cost_amount_fen)::bigint AS cost_amount_fen,
SUM(cost_missing_count)::int AS cost_missing_count,
SUM(gross_profit_fen)::bigint AS gross_profit_fen
FROM sales_daily_facts
WHERE stat_date BETWEEN $1::date AND $2::date
GROUP BY carrier_type
ORDER BY gross_revenue_fen DESC
`,
[range.fromDate, range.toDate],
),
query<{
canonical_product_id: number | null
canonical_sku: string | null
canonical_name: string | null
item_count: number
gross_revenue_fen: number
cost_amount_fen: number
cost_missing_count: number
gross_profit_fen: number
}>(
`
SELECT
f.canonical_product_id,
p.canonical_sku,
p.canonical_name,
SUM(f.item_count)::int AS item_count,
SUM(f.gross_revenue_fen)::bigint AS gross_revenue_fen,
SUM(f.cost_amount_fen)::bigint AS cost_amount_fen,
SUM(f.cost_missing_count)::int AS cost_missing_count,
SUM(f.gross_profit_fen)::bigint AS gross_profit_fen
FROM sales_daily_facts f
LEFT JOIN product_catalog p ON p.id = f.canonical_product_id
WHERE f.stat_date BETWEEN $1::date AND $2::date
GROUP BY f.canonical_product_id, p.canonical_sku, p.canonical_name
ORDER BY gross_revenue_fen DESC
LIMIT 30
`,
[range.fromDate, range.toDate],
),
query<{
order_count: number
orders_without_items: number
revenue_without_items: number
}>(
`
SELECT
COUNT(*)::int AS order_count,
COUNT(*) FILTER (
WHERE NOT EXISTS (SELECT 1 FROM order_items oi WHERE oi.order_id = orders.id)
)::int AS orders_without_items,
COALESCE(SUM(total_amount) FILTER (
WHERE NOT EXISTS (SELECT 1 FROM order_items oi WHERE oi.order_id = orders.id)
), 0)::bigint AS revenue_without_items
FROM orders
WHERE pay_status IN ('paid', 'refunded')
AND (COALESCE(paid_at, created_at) AT TIME ZONE 'Asia/Shanghai')::date
BETWEEN $1::date AND $2::date
`,
[range.fromDate, range.toDate],
),
],
)
const summary = summaryResult.rows[0]
const grossRevenue = Number(summary?.gross_revenue_fen || 0)
const unmappedRevenue = Number(summary?.unmapped_revenue_fen || 0)
return {
range,
overview: {
orderCount: Number(orderResult.rows[0]?.order_count || 0),
ordersWithoutItems: Number(orderResult.rows[0]?.orders_without_items || 0),
revenueWithoutItemsFen: Number(orderResult.rows[0]?.revenue_without_items || 0),
itemCount: Number(summary?.item_count || 0),
grossRevenueFen: grossRevenue,
refundAmountFen: Number(summary?.refund_amount_fen || 0),
netRevenueFen: Number(summary?.net_revenue_fen || 0),
costAmountFen: Number(summary?.cost_amount_fen || 0),
costMissingCount: Number(summary?.cost_missing_count || 0),
grossProfitFen: Number(summary?.gross_profit_fen || 0),
unmappedRevenueFen: unmappedRevenue,
unmappedRevenuePct:
grossRevenue > 0 ? Math.round((unmappedRevenue / grossRevenue) * 10000) / 100 : 0,
},
daily: dailyResult.rows.map((row) => ({
statDate: row.stat_date,
itemCount: Number(row.item_count || 0),
grossRevenueFen: Number(row.gross_revenue_fen || 0),
refundAmountFen: Number(row.refund_amount_fen || 0),
netRevenueFen: Number(row.net_revenue_fen || 0),
costAmountFen: Number(row.cost_amount_fen || 0),
costMissingCount: Number(row.cost_missing_count || 0),
grossProfitFen: Number(row.gross_profit_fen || 0),
})),
byCarrier: carrierResult.rows.map((row) => ({
carrierType: row.carrier_type,
itemCount: Number(row.item_count || 0),
grossRevenueFen: Number(row.gross_revenue_fen || 0),
costAmountFen: Number(row.cost_amount_fen || 0),
costMissingCount: Number(row.cost_missing_count || 0),
grossProfitFen: Number(row.gross_profit_fen || 0),
})),
byProduct: productResult.rows.map((row) => ({
canonicalProductId: row.canonical_product_id ? Number(row.canonical_product_id) : null,
canonicalSku: row.canonical_sku || '',
canonicalName: row.canonical_name || '未归一',
itemCount: Number(row.item_count || 0),
grossRevenueFen: Number(row.gross_revenue_fen || 0),
costAmountFen: Number(row.cost_amount_fen || 0),
costMissingCount: Number(row.cost_missing_count || 0),
grossProfitFen: Number(row.gross_profit_fen || 0),
})),
}
}
export async function listSalesDailyFacts(input: Record<string, unknown> = {}) {
const range = resolveFinanceDateRange(input, { defaultDays: 30, maxDays: 400 })
const page = normalizePositiveInteger(input.page, 1, '页码')
const pageSize = Math.min(
200,
normalizePositiveInteger(input.pageSize ?? input.page_size, 20, '每页数量'),
)
const provider = String(input.provider || '').trim()
const carrierType = String(input.carrierType || input.carrier_type || '').trim()
const canonicalProductId = Number(input.canonicalProductId || input.canonical_product_id || 0)
const conditions = ['f.stat_date BETWEEN $1::date AND $2::date']
const params: unknown[] = [range.fromDate, range.toDate]
if (provider) {
params.push(provider)
conditions.push(`f.provider = $${params.length}`)
}
if (carrierType) {
params.push(carrierType)
conditions.push(`f.carrier_type = $${params.length}`)
}
if (Number.isInteger(canonicalProductId) && canonicalProductId > 0) {
params.push(canonicalProductId)
conditions.push(`f.canonical_product_id = $${params.length}`)
}
const where = conditions.join(' AND ')
const countResult = await query<{ total: number }>(
`SELECT COUNT(*)::int AS total FROM sales_daily_facts f WHERE ${where}`,
params,
)
const listParams = [...params, pageSize, (page - 1) * pageSize]
const result = await query<SalesFactRow>(
`
SELECT
f.*,
p.canonical_sku,
p.canonical_name
FROM sales_daily_facts f
LEFT JOIN product_catalog p ON p.id = f.canonical_product_id
WHERE ${where}
ORDER BY f.stat_date DESC, f.gross_revenue_fen DESC, f.id DESC
LIMIT $${listParams.length - 1} OFFSET $${listParams.length}
`,
listParams,
)
return {
range,
items: result.rows.map(mapSalesFact),
pagination: {
page,
pageSize,
total: Number(countResult.rows[0]?.total || 0),
},
computedAt: result.rows[0]?.computed_at || '',
}
}
function mapSalesFact(row: SalesFactRow) {
return {
id: Number(row.id),
statDate: formatDateOnly(row.stat_date),
provider: row.provider,
shopId: row.shop_id,
canonicalProductId: row.canonical_product_id ? Number(row.canonical_product_id) : null,
canonicalSku: row.canonical_sku || '',
canonicalName: row.canonical_name || '未归一',
carrierType: row.carrier_type,
orderCount: Number(row.order_count || 0),
itemCount: Number(row.item_count || 0),
quantity: Number(row.quantity || 0),
grossRevenueFen: Number(row.gross_revenue_fen || 0),
refundAmountFen: Number(row.refund_amount_fen || 0),
netRevenueFen: Number(row.net_revenue_fen || 0),
costAmountFen: Number(row.cost_amount_fen || 0),
costMissingCount: Number(row.cost_missing_count || 0),
costStage: row.cost_stage,
grossProfitFen: Number(row.gross_profit_fen || 0),
computedAt: row.computed_at,
}
}
function formatDateOnly(value: string | Date): string {
if (value instanceof Date) {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(value)
}
return String(value || '').slice(0, 10)
}
function normalizePositiveInteger(value: unknown, fallback: number, label: string) {
const parsed = Number(value)
if (!value || !Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1) {
if (!value) return fallback
throw createHttpError(`${label}必须是正整数`, {
statusCode: 400,
errorCode: 'finance_pagination_invalid',
})
}
return parsed
}
@@ -3,7 +3,10 @@ import {
findLatestOrderByPlatformOrderIdWithExecutor,
updateOrderWithExecutor,
} from '../../repositories/order-repo.js'
import { replaceOrderItemsWithExecutor } from '../../repositories/order-item-repo.js'
import {
applyOrderItemRevenueAllocationWithExecutor,
replaceOrderItemsWithExecutor,
} from '../../repositories/order-item-repo.js'
import { withTransaction } from '../../db/client.js'
import { syncDeliveryTasksForOrder } from './delivery-task-service.js'
import { resolveOrderItemForFulfillment } from '../fulfillment/product-resolution-service.js'
@@ -182,6 +185,11 @@ export async function upsertOrderFromSource(
updatedAt: now,
})),
)
await applyOrderItemRevenueAllocationWithExecutor(
executor,
order.id,
Number(order.total_amount) || 0,
)
return { order, orderItems }
})
@@ -41,4 +41,5 @@ test('normalizeScheduledJobsConfig ensures default jobs exist', () => {
assert.ok(ids.includes('deposit-unfreeze'))
assert.ok(ids.includes('data-retention'))
assert.ok(ids.includes('pdd_order_sync'))
assert.ok(ids.includes('cost-finance-sync'))
})
@@ -10,6 +10,7 @@ const WORK_ORDER_TIMEOUT_JOB_ID = 'work-order-timeout'
const DEPOSIT_UNFREEZE_JOB_ID = 'deposit-unfreeze'
const DATA_RETENTION_JOB_ID = 'data-retention'
const PDD_ORDER_SYNC_JOB_ID = 'pdd_order_sync'
const COST_FINANCE_SYNC_JOB_ID = 'cost-finance-sync'
export function getScheduledJobsFilePath() {
return SCHEDULED_JOBS_FILE_PATH
@@ -75,6 +76,7 @@ export function normalizeScheduledJobsConfig(rawValue: unknown) {
const hasDepositUnfreeze = jobs.some((item) => item.id === DEPOSIT_UNFREEZE_JOB_ID)
const hasDataRetention = jobs.some((item) => item.id === DATA_RETENTION_JOB_ID)
const hasPddOrderSync = jobs.some((item) => item.id === PDD_ORDER_SYNC_JOB_ID)
const hasCostFinanceSync = jobs.some((item) => item.id === COST_FINANCE_SYNC_JOB_ID)
if (!hasWorkOrderTimeout) {
jobs.push(createDefaultWorkOrderTimeoutJob())
@@ -88,6 +90,9 @@ export function normalizeScheduledJobsConfig(rawValue: unknown) {
if (!hasPddOrderSync) {
jobs.push(createDefaultPddOrderSyncJob())
}
if (!hasCostFinanceSync) {
jobs.push(createDefaultCostFinanceSyncJob())
}
return {
enabled: typeof source.enabled === 'boolean' ? source.enabled : true,
@@ -117,6 +122,9 @@ function normalizeScheduledJob(rawValue: unknown) {
if (type === 'pdd_order_sync') {
return normalizePddOrderSyncJob(rawValue)
}
if (type === 'cost_finance_sync') {
return normalizeCostFinanceSyncJob(rawValue)
}
return null
}
@@ -135,6 +143,20 @@ function normalizePddOrderSyncJob(rawValue: JsonObject) {
}
}
function normalizeCostFinanceSyncJob(rawValue: JsonObject) {
const config = isPlainObject(rawValue.config) ? rawValue.config : {}
return {
id: COST_FINANCE_SYNC_JOB_ID,
type: 'cost_finance_sync',
enabled: rawValue.enabled !== false,
intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 300, 60, 3600),
config: {
days: normalizeRangeInteger(config.days, 7, 1, 90),
},
}
}
function normalizeDepositUnfreezeJob(rawValue: JsonObject) {
const config = isPlainObject(rawValue.config) ? rawValue.config : {}
@@ -181,6 +203,7 @@ function createDefaultScheduledJobsConfig() {
createDefaultDepositUnfreezeJob(),
createDefaultDataRetentionJob(),
createDefaultPddOrderSyncJob(),
createDefaultCostFinanceSyncJob(),
],
}
}
@@ -197,6 +220,18 @@ function createDefaultPddOrderSyncJob() {
}
}
function createDefaultCostFinanceSyncJob() {
return {
id: COST_FINANCE_SYNC_JOB_ID,
type: 'cost_finance_sync',
enabled: true,
intervalSeconds: 300,
config: {
days: 7,
},
}
}
function createDefaultDataRetentionJob() {
return {
id: DATA_RETENTION_JOB_ID,
@@ -0,0 +1,62 @@
import type { JsonObject } from '../../types/json.js'
import {
syncActualWorkOrderCostSnapshots,
syncEstimatedOrderItemCostSnapshots,
} from '../cost-center/cost-snapshot-sync-service.js'
import { rebuildSalesDailyFacts } from '../finance/sales-facts-service.js'
/**
* 财务闭环同步:预估成本快照 -> 工单实际快照 -> 销售事实重算。
* 只处理最近 N 天,避免定时任务扫全量历史。
*/
export async function runCostFinanceSyncJob(job: JsonObject = {}) {
const config = job.config && typeof job.config === 'object' ? (job.config as JsonObject) : {}
const days = clampInteger(config.days, 7, 1, 90)
const estimated = await syncEstimatedOrderItemCostSnapshots({
dateFrom: shiftDays(-(days - 1)),
dateTo: today(),
})
const actual = await syncActualWorkOrderCostSnapshots({
dateFrom: shiftDays(-(days - 1)),
dateTo: today(),
})
const facts = await rebuildSalesDailyFacts({
dateFrom: shiftDays(-(days - 1)),
dateTo: today(),
})
return {
status: 'ok',
message: `成本快照 ${estimated.createdCount} 预估 / ${actual.createdCount} 实际,销售事实 ${facts.insertedCount} 行`,
days,
estimatedCreated: estimated.createdCount,
actualCreated: actual.createdCount,
exceptionsCreated: estimated.exceptionCreatedCount,
factsInserted: facts.insertedCount,
dateFrom: facts.range.fromDate,
dateTo: facts.range.toDate,
}
}
function today() {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date())
}
function shiftDays(days: number) {
const [year, month, day] = today().split('-').map(Number)
const date = new Date(Date.UTC(year || 1970, (month || 1) - 1, day || 1))
date.setUTCDate(date.getUTCDate() + days)
return date.toISOString().slice(0, 10)
}
function clampInteger(value: unknown, fallback: number, min: number, max: number) {
const parsed = Number(value)
if (!Number.isInteger(parsed)) return fallback
return Math.min(max, Math.max(min, parsed))
}
@@ -6,6 +6,7 @@ import { runDepositUnfreezeJob } from './deposit-unfreeze-job.js'
import { runWorkOrderTimeoutJob } from './work-order-timeout-job.js'
import { runDataRetentionJob } from './data-retention-job.js'
import { runPddOrderSyncJob } from './pdd-order-sync-job.js'
import { runCostFinanceSyncJob } from './cost-finance-sync-job.js'
const timers = new Map<string, NodeJS.Timeout>()
const jobStates = new Map<string, JsonObject>()
@@ -204,6 +205,9 @@ function dispatchJob(job: JsonObject) {
if (job.type === 'pdd_order_sync') {
return runPddOrderSyncJob(job)
}
if (job.type === 'cost_finance_sync') {
return runCostFinanceSyncJob(job)
}
throw createHttpError(`不支持的定时任务类型:${job.type}`, {
statusCode: 400,