feat(finance): 历史订单退款按退款日入账
- 迁移 116 新增 refund_adjustment 渠道值,并允许该类行净收入为负 - 销售事实按退款日聚合 archived 订单的已确认退款,写冲减行 - 退款后同时回填原销售日与退款日的事实与总账 - 退款同时重算相关订单项的费用快照(比例费按净收入) - 前端渠道筛选与标签支持「历史退款冲减」
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
-- 116_refund_adjustment_facts.sql
|
||||
--
|
||||
-- 断代后历史订单仍会退款:这些订单项已 archived、不参与正常销售事实,
|
||||
-- 需要在「退款发生日」记一笔冲减,否则退款在财务体系里完全丢失。
|
||||
-- 这里新增事实渠道值 refund_adjustment 承载这类冲减行。
|
||||
|
||||
ALTER TABLE sales_daily_facts
|
||||
DROP CONSTRAINT IF EXISTS chk_sales_daily_facts_fulfillment_mode;
|
||||
ALTER TABLE sales_daily_facts
|
||||
DROP CONSTRAINT IF EXISTS chk_sales_daily_facts_channel;
|
||||
|
||||
ALTER TABLE sales_daily_facts
|
||||
ADD CONSTRAINT chk_sales_daily_facts_channel
|
||||
CHECK (
|
||||
channel IN (
|
||||
'work_order', 'affiliate_dash', 'third_party_voucher',
|
||||
'manual', 'unclassified', 'archived', 'refund_adjustment'
|
||||
)
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN sales_daily_facts.channel IS
|
||||
'渠道维度:work_order / affiliate_dash / third_party_voucher / manual / unclassified / archived / refund_adjustment';
|
||||
|
||||
-- 退款冲减行允许净收入为负(其余渠道仍要求非负)。
|
||||
ALTER TABLE sales_daily_facts
|
||||
DROP CONSTRAINT IF EXISTS chk_sales_daily_facts_amounts;
|
||||
|
||||
ALTER TABLE sales_daily_facts
|
||||
ADD CONSTRAINT chk_sales_daily_facts_amounts
|
||||
CHECK (
|
||||
order_count >= 0
|
||||
AND item_count >= 0
|
||||
AND quantity >= 0
|
||||
AND gross_revenue_fen >= 0
|
||||
AND refund_amount_fen >= 0
|
||||
AND (net_revenue_fen >= 0 OR channel = 'refund_adjustment')
|
||||
AND cost_amount_fen >= 0
|
||||
AND cost_missing_count >= 0
|
||||
);
|
||||
@@ -67,6 +67,7 @@ export type FulfillmentChannel =
|
||||
| 'pending'
|
||||
| 'archived'
|
||||
| 'unclassified'
|
||||
| 'refund_adjustment'
|
||||
|
||||
export const FULFILLMENT_CHANNELS = new Set<string>([
|
||||
'work_order',
|
||||
@@ -76,6 +77,7 @@ export const FULFILLMENT_CHANNELS = new Set<string>([
|
||||
'pending',
|
||||
'archived',
|
||||
'unclassified',
|
||||
'refund_adjustment',
|
||||
])
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,6 +15,7 @@ import { createHttpError } from '../../utils/http.js'
|
||||
import { generateRefundNo } from '../../utils/random.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { rebuildChannelSalesDailyFacts } from './sales-facts-channel-service.js'
|
||||
import { syncChannelCostSnapshots } from '../cost-center/cost-snapshot-service.js'
|
||||
import { approveKuaishouIndustryRefund } from '../platforms/kuaishou-industry/refund-service.js'
|
||||
import { syncFinanceLedgerFromFacts } from './finance-ledger-service.js'
|
||||
|
||||
@@ -326,12 +327,26 @@ export async function createManualRefund(
|
||||
}
|
||||
})
|
||||
|
||||
const basisDate = toChinaDate(written.basisAt)
|
||||
if (basisDate) {
|
||||
await rebuildChannelSalesDailyFacts({ dateFrom: basisDate, dateTo: basisDate })
|
||||
await syncFinanceLedgerFromFacts({ dateFrom: basisDate, dateTo: basisDate })
|
||||
// 比例类费用按净收入计:退款后重算相关订单项的费用快照,再回填事实。
|
||||
try {
|
||||
const refundedItems = await query<{ order_item_id: number }>(
|
||||
'SELECT DISTINCT order_item_id FROM order_refunds WHERE id = ANY($1::bigint[])',
|
||||
[written.insertedIds],
|
||||
)
|
||||
for (const row of refundedItems.rows) {
|
||||
await syncChannelCostSnapshots({
|
||||
afterId: Math.max(0, Number(row.order_item_id) - 1),
|
||||
batchSize: 1,
|
||||
maxBatches: 1,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// 快照重算失败不影响退款本身,成本快照管道会兜底。
|
||||
}
|
||||
|
||||
const basisDate = toChinaDate(written.basisAt)
|
||||
await refreshRefundDates(basisDate)
|
||||
|
||||
const refunds = await listOrderRefunds(orderId)
|
||||
return {
|
||||
batchNo: written.batchNo,
|
||||
@@ -441,10 +456,7 @@ export async function voidOrderRefund(
|
||||
})
|
||||
|
||||
const rebuiltDate = toChinaDate(context.basisAt)
|
||||
if (rebuiltDate) {
|
||||
await rebuildChannelSalesDailyFacts({ dateFrom: rebuiltDate, dateTo: rebuiltDate })
|
||||
await syncFinanceLedgerFromFacts({ dateFrom: rebuiltDate, dateTo: rebuiltDate })
|
||||
}
|
||||
await refreshRefundDates(rebuiltDate)
|
||||
const refunds = await listOrderRefunds(context.orderId)
|
||||
const refund = refunds.find((item) => item.id === id)
|
||||
if (!refund) {
|
||||
@@ -540,6 +552,20 @@ function toChinaDate(value: string): string {
|
||||
return CHINA_DATE_FORMATTER.format(date)
|
||||
}
|
||||
|
||||
/**
|
||||
* 退款后回填事实与总账:
|
||||
* - 原销售日:更新该订单项的收入/费用;
|
||||
* - 退款日:生成历史订单(archived)的退款冲减行。
|
||||
*/
|
||||
async function refreshRefundDates(basisDate: string): Promise<void> {
|
||||
const refundDate = CHINA_DATE_FORMATTER.format(new Date())
|
||||
const dates = [basisDate, refundDate].filter(Boolean)
|
||||
for (const date of [...new Set(dates)]) {
|
||||
await rebuildChannelSalesDailyFacts({ dateFrom: date, dateTo: date })
|
||||
await syncFinanceLedgerFromFacts({ dateFrom: date, dateTo: date })
|
||||
}
|
||||
}
|
||||
|
||||
function formatFen(value: number): string {
|
||||
return `¥${(Number(value || 0) / 100).toFixed(2)}`
|
||||
}
|
||||
|
||||
@@ -143,9 +143,47 @@ export async function rebuildChannelSalesDailyFacts(input: {
|
||||
[dateFrom, dateTo, computedAt],
|
||||
)
|
||||
|
||||
// 断代后历史订单的退款:按退款发生日记冲减行,否则这类退款会彻底丢失。
|
||||
const adjustments = await client.query(
|
||||
`
|
||||
WITH adj AS (
|
||||
SELECT
|
||||
(r.refunded_at AT TIME ZONE 'Asia/Shanghai')::date AS stat_date,
|
||||
o.provider,
|
||||
o.shop_id,
|
||||
COUNT(DISTINCT o.id)::int AS order_count,
|
||||
COUNT(*)::int AS item_count,
|
||||
SUM(r.amount_fen)::bigint AS refund_fen
|
||||
FROM order_refunds r
|
||||
JOIN order_items oi ON oi.id = r.order_item_id
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE r.status = 'confirmed'
|
||||
AND oi.fulfillment_status = 'archived'
|
||||
AND (r.refunded_at AT TIME ZONE 'Asia/Shanghai')::date
|
||||
BETWEEN $1::date AND $2::date
|
||||
GROUP BY 1, 2, 3
|
||||
)
|
||||
INSERT INTO sales_daily_facts (
|
||||
stat_date, provider, shop_id, canonical_product_id, channel,
|
||||
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, NULL, 'refund_adjustment',
|
||||
order_count, item_count, 0,
|
||||
0, refund_fen, -refund_fen,
|
||||
0, 0, 'actual',
|
||||
-refund_fen, $3::timestamptz, $3::timestamptz, $3::timestamptz
|
||||
FROM adj
|
||||
`,
|
||||
[dateFrom, dateTo, computedAt],
|
||||
)
|
||||
|
||||
return {
|
||||
deleted: deleted.rowCount || 0,
|
||||
inserted: inserted.rowCount || 0,
|
||||
inserted: (inserted.rowCount || 0) + (adjustments.rowCount || 0),
|
||||
skipped,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -41,6 +41,7 @@ const FULFILLMENT_MODE_OPTIONS = [
|
||||
{ value: 'third_party_voucher', label: '第三方卡券' },
|
||||
{ value: 'manual', label: '人工客服' },
|
||||
{ value: 'unclassified', label: '未分类' },
|
||||
{ value: 'refund_adjustment', label: '历史退款冲减' },
|
||||
{ value: 'archived', label: '历史归档' },
|
||||
]
|
||||
|
||||
@@ -52,6 +53,7 @@ const FULFILLMENT_MODE_LABEL: Record<string, string> = {
|
||||
manual: '人工客服',
|
||||
archived: '历史归档',
|
||||
unclassified: '未分类',
|
||||
refund_adjustment: '历史退款冲减',
|
||||
}
|
||||
|
||||
const COST_STAGE_LABEL: Record<string, { label: string; color: string }> = {
|
||||
|
||||
@@ -52,6 +52,7 @@ const FULFILLMENT_MODE_LABELS: Record<string, string> = {
|
||||
pending: '待分类',
|
||||
archived: '历史归档',
|
||||
unclassified: '未分类',
|
||||
refund_adjustment: '历史退款冲减',
|
||||
}
|
||||
|
||||
const REASON_LABELS: Record<string, string> = {
|
||||
|
||||
@@ -8,6 +8,7 @@ export type FulfillmentChannel =
|
||||
| 'third_party_voucher'
|
||||
| 'legacy_platform'
|
||||
| 'manual'
|
||||
| 'refund_adjustment'
|
||||
|
||||
/** 商品来源:由 canonical_sku 前缀推导,空串表示未分类。 */
|
||||
export type ProductSource = 'affiliate_dash' | 'work_order' | 'third_party_voucher' | 'manual' | ''
|
||||
|
||||
Reference in New Issue
Block a user