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