- 平台退款路由从通用订单路径改为 POST /api/v1/admin/kuaishou-industry/orders/:orderId/refunds/approve-and-record, 服务改名 approveAndRecordKuaishouRefund,明确仅快手 91 - 总账同步改为未关账期间按日整体重建销售凭证,解决收入回填/退款重算后 旧凭证金额不更新的问题;删除与重建放在同一事务,已关账期间保持不动 - 文档明确 revenue:backfill 是存量订单退款的前置条件: revenue_amount_fen=0 会导致退款登记按钮置灰 - 本地生产副本已执行 revenue:backfill:375,447 行全部回填, order 375483 可退金额恢复为 237.00 元 验证:backend check 844(840 pass / 4 skip);frontend check 172 + build; 集成测试通过(代录、总账平衡、关账阻断、对账)。
616 lines
20 KiB
TypeScript
616 lines
20 KiB
TypeScript
import type { PoolClient } from 'pg'
|
||
|
||
import { query, withTransaction } from '../../db/client.js'
|
||
import type { JsonObject } from '../../types/json.js'
|
||
import { createHttpError } from '../../utils/http.js'
|
||
import { formatShanghaiDate, shiftDate } from '../../utils/finance-date-range.js'
|
||
import { randomId } from '../../utils/random.js'
|
||
import { nowIso } from '../../utils/time.js'
|
||
import type { AdminSession } from '../admin/admin-auth-service.js'
|
||
|
||
/**
|
||
* 财务总账(影子账)。
|
||
*
|
||
* 设计:
|
||
* * 只做复式记账与试算平衡,不接管 worker_wallets / fund_accounts 的运行时余额;
|
||
* * 销售事实按日同步为收入/退款/成本凭证(幂等);
|
||
* * 允许财务手工录入调整凭证;
|
||
* * 期间 closed 后禁止新增该期间凭证。
|
||
*/
|
||
|
||
export type JournalLineInput = {
|
||
accountCode: string
|
||
debitFen?: number
|
||
creditFen?: number
|
||
memo?: string
|
||
dimensions?: JsonObject
|
||
}
|
||
|
||
export type PostJournalInput = {
|
||
eventType: string
|
||
refType: string
|
||
refId: string
|
||
occurredAt: string
|
||
memo?: string
|
||
metadata?: JsonObject
|
||
lines: JournalLineInput[]
|
||
}
|
||
|
||
export type JournalEntry = {
|
||
id: number
|
||
entryNo: string
|
||
eventType: string
|
||
refType: string
|
||
refId: string
|
||
occurredAt: string
|
||
periodKey: string
|
||
status: string
|
||
memo: string
|
||
metadata: JsonObject
|
||
lines: Array<{
|
||
lineNo: number
|
||
accountCode: string
|
||
accountName: string
|
||
debitFen: number
|
||
creditFen: number
|
||
memo: string
|
||
}>
|
||
}
|
||
|
||
export async function postJournalEntry(
|
||
input: PostJournalInput,
|
||
client?: PoolClient,
|
||
): Promise<{ created: boolean; entryId: number; entryNo: string }> {
|
||
const lines = normalizeLines(input.lines)
|
||
if (!client) {
|
||
return withTransaction((tx) => postJournalEntry(input, tx))
|
||
}
|
||
const occurredDate = normalizeTimestampDate(input.occurredAt)
|
||
const occurredAt = occurredDate.toISOString()
|
||
const periodKey = formatShanghaiDate(occurredDate).slice(0, 7)
|
||
await assertPeriodOpen(client, periodKey)
|
||
|
||
const entryNo = `FJ-${periodKey.replace('-', '')}-${randomId('', 8)}`
|
||
const inserted = await client.query<{ id: number; entry_no: string }>(
|
||
`
|
||
INSERT INTO finance_journal_entries (
|
||
entry_no, event_type, ref_type, ref_id, occurred_at, period_key,
|
||
status, memo, metadata_json, created_at, updated_at
|
||
) VALUES ($1, $2, $3, $4, $5, $6, 'posted', $7, $8::jsonb, $9, $9)
|
||
ON CONFLICT (event_type, ref_type, ref_id) DO NOTHING
|
||
RETURNING id, entry_no
|
||
`,
|
||
[
|
||
entryNo,
|
||
input.eventType,
|
||
input.refType,
|
||
input.refId,
|
||
occurredAt,
|
||
periodKey,
|
||
String(input.memo || '').slice(0, 500),
|
||
JSON.stringify(input.metadata || {}),
|
||
nowIso(),
|
||
],
|
||
)
|
||
const entry = inserted.rows[0]
|
||
if (!entry) {
|
||
const existing = await client.query<{ id: number; entry_no: string }>(
|
||
`SELECT id, entry_no FROM finance_journal_entries
|
||
WHERE event_type = $1 AND ref_type = $2 AND ref_id = $3
|
||
LIMIT 1`,
|
||
[input.eventType, input.refType, input.refId],
|
||
)
|
||
return {
|
||
created: false,
|
||
entryId: Number(existing.rows[0]?.id || 0),
|
||
entryNo: existing.rows[0]?.entry_no || '',
|
||
}
|
||
}
|
||
|
||
for (const [index, line] of lines.entries()) {
|
||
await client.query(
|
||
`
|
||
INSERT INTO finance_journal_lines (
|
||
entry_id, line_no, account_code, debit_fen, credit_fen,
|
||
memo, dimensions_json, created_at
|
||
) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8)
|
||
`,
|
||
[
|
||
entry.id,
|
||
index + 1,
|
||
line.accountCode,
|
||
line.debitFen,
|
||
line.creditFen,
|
||
line.memo,
|
||
JSON.stringify(line.dimensions || {}),
|
||
nowIso(),
|
||
],
|
||
)
|
||
}
|
||
|
||
return { created: true, entryId: Number(entry.id), entryNo: entry.entry_no }
|
||
}
|
||
|
||
export async function postManualVoucher(
|
||
input: {
|
||
occurredAt?: string
|
||
memo?: string
|
||
reference?: string
|
||
lines: JournalLineInput[]
|
||
},
|
||
_session: AdminSession,
|
||
) {
|
||
const occurredAt = normalizeTimestampDate(input.occurredAt || nowIso()).toISOString()
|
||
const refId = String(input.reference || '').trim() || randomId('MAN-', 8)
|
||
return postJournalEntry({
|
||
eventType: 'manual_voucher',
|
||
refType: 'manual',
|
||
refId,
|
||
occurredAt,
|
||
memo: input.memo || '手工调整凭证',
|
||
lines: input.lines,
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 把销售事实按日同步为总账凭证:
|
||
* 收入:借 1101 应收平台 / 贷 4001 商品销售收入
|
||
* 退款:借 4101 退款冲减 / 贷 1101 应收平台
|
||
* 成本:借 5001 履约成本 / 贷 2001 应付履约款
|
||
* 幂等键:event_type + sales_fact + 日期。
|
||
*/
|
||
export async function syncFinanceLedgerFromFacts(input: { dateFrom: string; dateTo: string }) {
|
||
const dateFrom = normalizeDate(input.dateFrom)
|
||
const dateTo = normalizeDate(input.dateTo)
|
||
if (dateFrom > dateTo) {
|
||
throw createHttpError('开始日期不能晚于结束日期', {
|
||
statusCode: 400,
|
||
errorCode: 'finance_ledger_date_range_invalid',
|
||
})
|
||
}
|
||
const rows = await query<{
|
||
stat_date: string
|
||
gross_revenue_fen: string
|
||
refund_amount_fen: string
|
||
cost_amount_fen: string
|
||
}>(
|
||
`
|
||
SELECT
|
||
stat_date::text AS stat_date,
|
||
COALESCE(SUM(gross_revenue_fen), 0)::bigint AS gross_revenue_fen,
|
||
COALESCE(SUM(refund_amount_fen), 0)::bigint AS refund_amount_fen,
|
||
COALESCE(SUM(cost_amount_fen), 0)::bigint AS cost_amount_fen
|
||
FROM sales_daily_facts
|
||
WHERE stat_date BETWEEN $1::date AND $2::date
|
||
GROUP BY stat_date
|
||
ORDER BY stat_date
|
||
`,
|
||
[dateFrom, dateTo],
|
||
)
|
||
|
||
let created = 0
|
||
let replaced = 0
|
||
let skipped = 0
|
||
for (const row of rows.rows) {
|
||
const occurredAt = `${row.stat_date}T12:00:00+08:00`
|
||
const periodKey = formatShanghaiDate(new Date(occurredAt)).slice(0, 7)
|
||
const period = await query<{ status: string }>(
|
||
`SELECT status FROM finance_periods WHERE period_key = $1 LIMIT 1`,
|
||
[periodKey],
|
||
)
|
||
if (period.rows[0]?.status === 'closed') {
|
||
// 已关账期间不可改账;即使销售事实后来变化,也保留关账时点的凭证。
|
||
skipped += 1
|
||
continue
|
||
}
|
||
// 未关账期间:销售事实可能因退款/收入回填而变化,按日整体重建,
|
||
// 避免旧的幂等凭证一直停留在旧金额;删除与重建放在同一事务内。
|
||
const revenue = Number(row.gross_revenue_fen || 0)
|
||
const refund = Number(row.refund_amount_fen || 0)
|
||
const cost = Number(row.cost_amount_fen || 0)
|
||
const entries: PostJournalInput[] = []
|
||
if (revenue > 0) {
|
||
entries.push({
|
||
eventType: 'sales_revenue',
|
||
refType: 'sales_fact',
|
||
refId: row.stat_date,
|
||
occurredAt,
|
||
memo: `${row.stat_date} 销售收入`,
|
||
lines: [
|
||
{ accountCode: '1101', debitFen: revenue, memo: '应收平台' },
|
||
{ accountCode: '4001', creditFen: revenue, memo: '商品销售收入' },
|
||
],
|
||
})
|
||
}
|
||
if (refund > 0) {
|
||
entries.push({
|
||
eventType: 'sales_refund',
|
||
refType: 'sales_fact',
|
||
refId: row.stat_date,
|
||
occurredAt,
|
||
memo: `${row.stat_date} 退款冲减`,
|
||
lines: [
|
||
{ accountCode: '4101', debitFen: refund, memo: '退款冲减收入' },
|
||
{ accountCode: '1101', creditFen: refund, memo: '应收平台冲减' },
|
||
],
|
||
})
|
||
}
|
||
if (cost > 0) {
|
||
entries.push({
|
||
eventType: 'sales_cost',
|
||
refType: 'sales_fact',
|
||
refId: row.stat_date,
|
||
occurredAt,
|
||
memo: `${row.stat_date} 履约成本`,
|
||
lines: [
|
||
{ accountCode: '5001', debitFen: cost, memo: '履约成本' },
|
||
{ accountCode: '2001', creditFen: cost, memo: '应付履约款' },
|
||
],
|
||
})
|
||
}
|
||
const dayCreated = await withTransaction(async (client) => {
|
||
const deleted = await client.query(
|
||
`DELETE FROM finance_journal_entries WHERE ref_type = 'sales_fact' AND ref_id = $1`,
|
||
[row.stat_date],
|
||
)
|
||
replaced += deleted.rowCount || 0
|
||
let count = 0
|
||
for (const entry of entries) {
|
||
const result = await postJournalEntry(entry, client)
|
||
if (result.created) count += 1
|
||
}
|
||
return count
|
||
})
|
||
created += dayCreated
|
||
}
|
||
return { range: { fromDate: dateFrom, toDate: dateTo }, created, replaced, skipped }
|
||
}
|
||
|
||
export async function listJournalEntries(input: Record<string, unknown> = {}) {
|
||
const page = Math.max(1, Number(input.page) || 1)
|
||
const pageSize = Math.min(100, Math.max(1, Number(input.pageSize ?? input.page_size) || 20))
|
||
const params: unknown[] = []
|
||
const clauses: string[] = []
|
||
const fromDate = optionalDate(input.dateFrom ?? input.date_from)
|
||
const toDate = optionalDate(input.dateTo ?? input.date_to)
|
||
if (fromDate) {
|
||
params.push(fromDate)
|
||
clauses.push(
|
||
`e.occurred_at >= ($${params.length}::date::timestamp AT TIME ZONE 'Asia/Shanghai')`,
|
||
)
|
||
}
|
||
if (toDate) {
|
||
params.push(toDate)
|
||
clauses.push(
|
||
`e.occurred_at < (($${params.length}::date + interval '1 day')::timestamp AT TIME ZONE 'Asia/Shanghai')`,
|
||
)
|
||
}
|
||
const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : ''
|
||
const count = await query<{ total: number }>(
|
||
`SELECT COUNT(*)::int AS total FROM finance_journal_entries e ${where}`,
|
||
params,
|
||
)
|
||
const entries = await query<Record<string, unknown>>(
|
||
`
|
||
SELECT e.*, COALESCE(SUM(l.debit_fen), 0)::bigint AS debit_total_fen
|
||
FROM finance_journal_entries e
|
||
LEFT JOIN finance_journal_lines l ON l.entry_id = e.id
|
||
${where}
|
||
GROUP BY e.id
|
||
ORDER BY e.occurred_at DESC, e.id DESC
|
||
LIMIT $${params.length + 1} OFFSET $${params.length + 2}
|
||
`,
|
||
[...params, pageSize, (page - 1) * pageSize],
|
||
)
|
||
const ids = entries.rows.map((row) => Number(row.id))
|
||
const lines =
|
||
ids.length > 0
|
||
? await query<Record<string, unknown>>(
|
||
`
|
||
SELECT l.*, a.account_name
|
||
FROM finance_journal_lines l
|
||
JOIN finance_accounts a ON a.account_code = l.account_code
|
||
WHERE l.entry_id = ANY($1::bigint[])
|
||
ORDER BY l.entry_id, l.line_no
|
||
`,
|
||
[ids],
|
||
)
|
||
: { rows: [] }
|
||
const linesByEntry = new Map<number, Array<Record<string, unknown>>>()
|
||
for (const line of lines.rows) {
|
||
const entryId = Number(line.entry_id)
|
||
const list = linesByEntry.get(entryId) || []
|
||
list.push(line)
|
||
linesByEntry.set(entryId, list)
|
||
}
|
||
return {
|
||
items: entries.rows.map((row) => ({
|
||
id: Number(row.id),
|
||
entryNo: String(row.entry_no || ''),
|
||
eventType: String(row.event_type || ''),
|
||
refType: String(row.ref_type || ''),
|
||
refId: String(row.ref_id || ''),
|
||
occurredAt: String(row.occurred_at || ''),
|
||
periodKey: String(row.period_key || ''),
|
||
status: String(row.status || ''),
|
||
memo: String(row.memo || ''),
|
||
metadata: row.metadata_json || {},
|
||
debitTotalFen: Number(row.debit_total_fen || 0),
|
||
lines: (linesByEntry.get(Number(row.id)) || []).map((line) => ({
|
||
lineNo: Number(line.line_no),
|
||
accountCode: String(line.account_code || ''),
|
||
accountName: String(line.account_name || ''),
|
||
debitFen: Number(line.debit_fen || 0),
|
||
creditFen: Number(line.credit_fen || 0),
|
||
memo: String(line.memo || ''),
|
||
})),
|
||
})) satisfies JournalEntry[],
|
||
pagination: { page, pageSize, total: Number(count.rows[0]?.total || 0) },
|
||
}
|
||
}
|
||
|
||
export async function getTrialBalance(input: { dateFrom: string; dateTo: string }) {
|
||
const dateFrom = normalizeDate(input.dateFrom)
|
||
const dateTo = normalizeDate(input.dateTo)
|
||
const rows = await query<Record<string, unknown>>(
|
||
`
|
||
SELECT
|
||
a.account_code, a.account_name, a.account_type, a.normal_side,
|
||
COALESCE(SUM(l.debit_fen), 0)::bigint AS debit_fen,
|
||
COALESCE(SUM(l.credit_fen), 0)::bigint AS credit_fen,
|
||
CASE
|
||
WHEN a.normal_side = 'debit'
|
||
THEN COALESCE(SUM(l.debit_fen), 0)::bigint - COALESCE(SUM(l.credit_fen), 0)::bigint
|
||
ELSE COALESCE(SUM(l.credit_fen), 0)::bigint - COALESCE(SUM(l.debit_fen), 0)::bigint
|
||
END AS balance_fen
|
||
FROM finance_accounts a
|
||
LEFT JOIN finance_journal_lines l ON l.account_code = a.account_code
|
||
LEFT JOIN finance_journal_entries e
|
||
ON e.id = l.entry_id AND e.status = 'posted'
|
||
AND e.occurred_at >= ($1::date::timestamp AT TIME ZONE 'Asia/Shanghai')
|
||
AND e.occurred_at < (($2::date + interval '1 day')::timestamp AT TIME ZONE 'Asia/Shanghai')
|
||
WHERE a.status = 'active'
|
||
AND (e.id IS NOT NULL OR (l.id IS NULL))
|
||
GROUP BY a.account_code, a.account_name, a.account_type, a.normal_side
|
||
ORDER BY a.account_code
|
||
`,
|
||
[dateFrom, dateTo],
|
||
)
|
||
const items = rows.rows.map((row) => ({
|
||
accountCode: String(row.account_code || ''),
|
||
accountName: String(row.account_name || ''),
|
||
accountType: String(row.account_type || ''),
|
||
normalSide: String(row.normal_side || ''),
|
||
debitFen: Number(row.debit_fen || 0),
|
||
creditFen: Number(row.credit_fen || 0),
|
||
balanceFen: Number(row.balance_fen || 0),
|
||
}))
|
||
const totals = items.reduce(
|
||
(acc, item) => ({
|
||
debitFen: acc.debitFen + item.debitFen,
|
||
creditFen: acc.creditFen + item.creditFen,
|
||
balanceFen: acc.balanceFen + item.balanceFen,
|
||
}),
|
||
{ debitFen: 0, creditFen: 0, balanceFen: 0 },
|
||
)
|
||
return {
|
||
range: { dateFrom, dateTo },
|
||
items,
|
||
totals,
|
||
balanced: totals.debitFen === totals.creditFen,
|
||
}
|
||
}
|
||
|
||
export async function listFinancePeriods() {
|
||
const result = await query<Record<string, unknown>>(
|
||
`SELECT * FROM finance_periods ORDER BY period_key DESC LIMIT 60`,
|
||
)
|
||
return {
|
||
items: result.rows.map(mapPeriod),
|
||
}
|
||
}
|
||
|
||
export async function closeFinancePeriod(periodKeyInput: unknown, session: AdminSession) {
|
||
const periodKey = normalizePeriodKey(periodKeyInput)
|
||
const { startsAt, endsAt, dateFrom, dateTo } = periodRange(periodKey)
|
||
// 先补齐该期间的凭证,避免漏记。
|
||
await syncFinanceLedgerFromFacts({ dateFrom, dateTo })
|
||
const trial = await getTrialBalance({ dateFrom, dateTo })
|
||
const now = nowIso()
|
||
await query(
|
||
`
|
||
INSERT INTO finance_periods (
|
||
period_key, starts_at, ends_at, status, summary_json,
|
||
closed_at, closed_by_admin_user_id, created_at, updated_at
|
||
) VALUES ($1, $2, $3, 'closed', $4::jsonb, $5, $6, $5, $5)
|
||
ON CONFLICT (period_key) DO UPDATE
|
||
SET status = 'closed',
|
||
starts_at = EXCLUDED.starts_at,
|
||
ends_at = EXCLUDED.ends_at,
|
||
summary_json = EXCLUDED.summary_json,
|
||
closed_at = EXCLUDED.closed_at,
|
||
closed_by_admin_user_id = EXCLUDED.closed_by_admin_user_id,
|
||
updated_at = EXCLUDED.updated_at
|
||
`,
|
||
[
|
||
periodKey,
|
||
startsAt,
|
||
endsAt,
|
||
JSON.stringify({
|
||
totals: trial.totals,
|
||
balanced: trial.balanced,
|
||
itemCount: trial.items.length,
|
||
}),
|
||
now,
|
||
Number(session.userId || 0) || null,
|
||
],
|
||
)
|
||
return { periodKey, trial }
|
||
}
|
||
|
||
export async function reopenFinancePeriod(periodKeyInput: unknown) {
|
||
const periodKey = normalizePeriodKey(periodKeyInput)
|
||
const result = await query<Record<string, unknown>>(
|
||
`
|
||
UPDATE finance_periods
|
||
SET status = 'open', closed_at = NULL, closed_by_admin_user_id = NULL, updated_at = $2
|
||
WHERE period_key = $1
|
||
RETURNING *
|
||
`,
|
||
[periodKey, nowIso()],
|
||
)
|
||
if (!result.rows[0]) {
|
||
throw createHttpError('期间不存在或尚未关账', {
|
||
statusCode: 404,
|
||
errorCode: 'finance_period_not_found',
|
||
})
|
||
}
|
||
return mapPeriod(result.rows[0])
|
||
}
|
||
|
||
export async function listFinanceAccounts() {
|
||
const result = await query<Record<string, unknown>>(
|
||
`SELECT * FROM finance_accounts WHERE status = 'active' ORDER BY account_code`,
|
||
)
|
||
return {
|
||
items: result.rows.map((row) => ({
|
||
accountCode: String(row.account_code || ''),
|
||
accountName: String(row.account_name || ''),
|
||
accountType: String(row.account_type || ''),
|
||
normalSide: String(row.normal_side || ''),
|
||
description: String(row.description || ''),
|
||
})),
|
||
}
|
||
}
|
||
|
||
function normalizeLines(lines: JournalLineInput[]) {
|
||
if (!Array.isArray(lines) || lines.length < 2) {
|
||
throw createHttpError('凭证至少需要两条分录', {
|
||
statusCode: 400,
|
||
errorCode: 'finance_journal_lines_required',
|
||
})
|
||
}
|
||
let debit = 0
|
||
let credit = 0
|
||
const normalized = lines.map((line) => {
|
||
const accountCode = String(line.accountCode || '').trim()
|
||
if (!accountCode) {
|
||
throw createHttpError('凭证分录缺少科目', {
|
||
statusCode: 400,
|
||
errorCode: 'finance_journal_account_required',
|
||
})
|
||
}
|
||
const debitFen = Math.trunc(Number(line.debitFen || 0))
|
||
const creditFen = Math.trunc(Number(line.creditFen || 0))
|
||
if (
|
||
!Number.isFinite(debitFen) ||
|
||
!Number.isFinite(creditFen) ||
|
||
debitFen < 0 ||
|
||
creditFen < 0 ||
|
||
(debitFen > 0 && creditFen > 0) ||
|
||
(debitFen === 0 && creditFen === 0)
|
||
) {
|
||
throw createHttpError('凭证分录借贷金额无效', {
|
||
statusCode: 400,
|
||
errorCode: 'finance_journal_amount_invalid',
|
||
})
|
||
}
|
||
debit += debitFen
|
||
credit += creditFen
|
||
return {
|
||
accountCode,
|
||
debitFen,
|
||
creditFen,
|
||
memo: String(line.memo || '').slice(0, 300),
|
||
dimensions: line.dimensions || {},
|
||
}
|
||
})
|
||
if (debit !== credit) {
|
||
throw createHttpError(`凭证借贷不平衡(借 ${debit} / 贷 ${credit})`, {
|
||
statusCode: 400,
|
||
errorCode: 'finance_journal_unbalanced',
|
||
})
|
||
}
|
||
return normalized
|
||
}
|
||
|
||
async function assertPeriodOpen(client: PoolClient, periodKey: string) {
|
||
const result = await client.query<{ status: string }>(
|
||
`SELECT status FROM finance_periods WHERE period_key = $1 LIMIT 1`,
|
||
[periodKey],
|
||
)
|
||
if (result.rows[0]?.status === 'closed') {
|
||
throw createHttpError(`会计期间 ${periodKey} 已关账,不能新增凭证`, {
|
||
statusCode: 409,
|
||
errorCode: 'finance_period_closed',
|
||
})
|
||
}
|
||
}
|
||
|
||
function isPeriodClosedError(error: unknown) {
|
||
return (
|
||
error instanceof Error &&
|
||
(error as Error & { errorCode?: string }).errorCode === 'finance_period_closed'
|
||
)
|
||
}
|
||
|
||
function periodRange(periodKey: string) {
|
||
const [year, month] = periodKey.split('-').map(Number)
|
||
const startsAt = `${periodKey}-01T00:00:00+08:00`
|
||
const nextMonth = new Date(Date.UTC(year || 1970, (month || 1) - 1 + 1, 1))
|
||
const nextKey = nextMonth.toISOString().slice(0, 7)
|
||
const endsAt = `${nextKey}-01T00:00:00+08:00`
|
||
const lastDay = shiftDate(`${nextKey}-01`, -1)
|
||
return { startsAt, endsAt, dateFrom: `${periodKey}-01`, dateTo: lastDay }
|
||
}
|
||
|
||
function mapPeriod(row: Record<string, unknown>) {
|
||
return {
|
||
periodKey: String(row.period_key || ''),
|
||
startsAt: row.starts_at,
|
||
endsAt: row.ends_at,
|
||
status: String(row.status || 'open'),
|
||
summary: row.summary_json || {},
|
||
closedAt: row.closed_at,
|
||
closedByAdminUserId: row.closed_by_admin_user_id ? Number(row.closed_by_admin_user_id) : null,
|
||
}
|
||
}
|
||
|
||
function normalizeTimestampDate(value: unknown) {
|
||
const date = new Date(String(value || ''))
|
||
if (!Number.isFinite(date.getTime())) {
|
||
throw createHttpError('时间格式无效', {
|
||
statusCode: 400,
|
||
errorCode: 'finance_journal_time_invalid',
|
||
})
|
||
}
|
||
return date
|
||
}
|
||
|
||
function normalizeDate(value: unknown) {
|
||
const text = String(value || '').trim()
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) {
|
||
throw createHttpError('日期格式无效(YYYY-MM-DD)', {
|
||
statusCode: 400,
|
||
errorCode: 'finance_ledger_date_invalid',
|
||
})
|
||
}
|
||
return text
|
||
}
|
||
|
||
function optionalDate(value: unknown) {
|
||
const text = String(value || '').trim()
|
||
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : ''
|
||
}
|
||
|
||
function normalizePeriodKey(value: unknown) {
|
||
const text = String(value || '').trim()
|
||
if (!/^\d{4}-\d{2}$/.test(text)) {
|
||
throw createHttpError('期间格式无效(YYYY-MM)', {
|
||
statusCode: 400,
|
||
errorCode: 'finance_period_invalid',
|
||
})
|
||
}
|
||
return text
|
||
}
|