feat(finance): 完成成本快照运行层、销售事实与人工支出登记
迁移 108-111:快照业务时间与预估转实际、销售事实表、系统成本项、公司人工支出登记。接单平台按工单实际 reward 入账;人工支出支持登记/审批/驳回/报销并写 actual 快照。新增销售毛利页、人工支出页与订单入口。
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user