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