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
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
}