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
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { diffDays, resolveFinanceDateRange, shiftDate } from './finance-date-range.js'
test('resolveFinanceDateRange uses the requested date range', () => {
const range = resolveFinanceDateRange({ dateFrom: '2026-10-01', dateTo: '2026-10-14' })
assert.deepEqual(range, { fromDate: '2026-10-01', toDate: '2026-10-14' })
})
test('resolveFinanceDateRange rejects reversed and oversized ranges', () => {
assert.throws(() => resolveFinanceDateRange({ dateFrom: '2026-10-14', dateTo: '2026-10-01' }))
assert.throws(() =>
resolveFinanceDateRange({ dateFrom: '2025-01-01', dateTo: '2026-10-01' }, { maxDays: 400 }),
)
})
test('resolveFinanceDateRange defaults to the recent N calendar days', () => {
const range = resolveFinanceDateRange({ dateTo: '2026-10-22' }, { defaultDays: 7 })
assert.deepEqual(range, { fromDate: '2026-10-16', toDate: '2026-10-22' })
assert.equal(diffDays(range.fromDate, range.toDate) + 1, 7)
})
test('shiftDate crosses months without timezone drift', () => {
assert.equal(shiftDate('2026-03-01', -1), '2026-02-28')
assert.equal(shiftDate('2026-12-31', 1), '2027-01-01')
})
@@ -0,0 +1,89 @@
import { createHttpError } from './http.js'
export type FinanceDateRange = {
fromDate: string
toDate: string
}
type ResolveFinanceDateRangeOptions = {
defaultDays?: number
maxDays?: number
}
/**
* 财务日期范围统一按 Asia/Shanghai 的日历日处理。
* 入参可以是 YYYY-MM-DD,也可以带时间;只取日期部分。
*/
export function resolveFinanceDateRange(
input: Record<string, unknown> = {},
{ defaultDays = 7, maxDays = 400 }: ResolveFinanceDateRangeOptions = {},
): FinanceDateRange {
const today = formatShanghaiDate(new Date())
const rawFrom = normalizeDateInput(input.dateFrom ?? input.from)
const rawTo = normalizeDateInput(input.dateTo ?? input.to)
const toDate = rawTo || today
const fromDate = rawFrom || shiftDate(toDate, -(Math.max(1, defaultDays) - 1))
if (fromDate > toDate) {
throw createHttpError('开始日期不能晚于结束日期', {
statusCode: 400,
errorCode: 'finance_date_range_invalid',
})
}
const spanDays = diffDays(fromDate, toDate) + 1
if (spanDays > maxDays) {
throw createHttpError(`日期范围不能超过 ${maxDays} 天`, {
statusCode: 400,
errorCode: 'finance_date_range_too_large',
})
}
return { fromDate, toDate }
}
export function formatShanghaiDate(value: Date): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(value)
}
export function shiftDate(date: string, days: number): string {
const [year, month, day] = date.split('-').map(Number)
const base = new Date(Date.UTC(year || 1970, (month || 1) - 1, day || 1))
base.setUTCDate(base.getUTCDate() + days)
return base.toISOString().slice(0, 10)
}
export function diffDays(fromDate: string, toDate: string): number {
const from = Date.parse(`${fromDate}T00:00:00Z`)
const to = Date.parse(`${toDate}T00:00:00Z`)
if (!Number.isFinite(from) || !Number.isFinite(to)) {
throw createHttpError('日期格式必须为 YYYY-MM-DD', {
statusCode: 400,
errorCode: 'finance_date_invalid',
})
}
return Math.floor((to - from) / 86_400_000)
}
function normalizeDateInput(value: unknown): string {
const raw = String(value || '').trim()
if (!raw) return ''
const datePart = raw.slice(0, 10)
if (!/^\d{4}-\d{2}-\d{2}$/.test(datePart)) {
throw createHttpError('日期格式必须为 YYYY-MM-DD', {
statusCode: 400,
errorCode: 'finance_date_invalid',
})
}
const parsed = Date.parse(`${datePart}T00:00:00Z`)
if (!Number.isFinite(parsed) || new Date(parsed).toISOString().slice(0, 10) !== datePart) {
throw createHttpError('日期不是有效日期', {
statusCode: 400,
errorCode: 'finance_date_invalid',
})
}
return datePart
}
+13
View File
@@ -29,6 +29,19 @@ export function generateWorkOrderNo(date: Date = new Date()): string {
return `WK${yy}${mm}${dd}${hh}${min}${rand}`
}
/**
* 生成公司人工支出登记号,形如 `ME26102215301234`。
*/
export function generateManualExpenseNo(date: Date = new Date()): string {
const yy = String(date.getFullYear()).slice(-2)
const mm = String(date.getMonth() + 1).padStart(2, '0')
const dd = String(date.getDate()).padStart(2, '0')
const hh = String(date.getHours()).padStart(2, '0')
const min = String(date.getMinutes()).padStart(2, '0')
const rand = Math.floor(1000 + Math.random() * 9000)
return `ME${yy}${mm}${dd}${hh}${min}${rand}`
}
/**
* Generate formatted consignment inventory number
* 生成系统标准寄售库存编号,如:INV260913123456