443 lines
16 KiB
TypeScript
443 lines
16 KiB
TypeScript
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.order_item_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
|
|
}
|