feat: refactor invoice creation logic to improve validation and streamline item processing

This commit is contained in:
Astrian Zheng 2025-01-12 09:56:02 +11:00
parent 2905983bfd
commit 647e1e097a
Signed by: Astrian
SSH Key Fingerprint: SHA256:rVnhx3DAKjujCwWE13aDl7uV6+9U1MvydLkNRXJrBiA
6 changed files with 2512 additions and 43 deletions

2484
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -25,11 +25,14 @@
"description": "",
"dependencies": {
"@prisma/client": "^6.2.1",
"dayjs": "^1.11.13",
"debug": "^4.4.0",
"decimal.js": "^10.4.3",
"dotenv": "^16.4.7",
"install": "^0.13.0",
"koa": "^2.15.3",
"koa-body": "^6.0.1",
"koa-route": "^4.0.1"
"koa-route": "^4.0.1",
"npm": "^11.0.0"
}
}

View File

@ -6,6 +6,7 @@ import koaBody from 'koa-body'
import func from './func'
import { ErrorDescEnum, HttpError } from './classes/HttpError'
import { PrismaClient } from '@prisma/client'
import InvoiceItem from './classes/InvoiceItem'
dotenv.config()
console.log = Debug('invoiceIssuer:app.ts')
@ -77,32 +78,37 @@ app.use(route.post('/invoice', async (ctx) => {
// TODO: 请求头验证 bearer token
// 提取字段,并验证必填字段
const { payerId, period, items, dueDate, note } = ctx.request.body
if (!ctx.request.body) throw new HttpError(ErrorDescEnum.required_fields_missing, 400, ['payerId', 'period', 'items', 'dueDate'])
const { payerId, period: periodRaw, items: itemsRaw, dueDate: dueDateRaw, note } = ctx.request.body
let emptyFields = []
if (!payerId) emptyFields.push('payerId')
if (!period) emptyFields.push('period')
if (!items) emptyFields.push('items')
if (!dueDate) emptyFields.push('dueDate')
if (!periodRaw) emptyFields.push('period')
if (!itemsRaw) emptyFields.push('items')
if (!dueDateRaw) emptyFields.push('dueDate')
if (emptyFields.length > 0) throw new HttpError(ErrorDescEnum.required_fields_missing, 400, emptyFields)
// 验证 payerId 必须是数字
if (typeof payerId !== 'number') throw new HttpError(ErrorDescEnum.invalid_field_format, 400, ['payerId'])
// 验证 period 必须是两个日期的数组
if (!Array.isArray(period) || period.length !== 2) throw new HttpError(ErrorDescEnum.invalid_field_format, 400, ['period'])
if (!Array.isArray(periodRaw) || periodRaw.length !== 2) throw new HttpError(ErrorDescEnum.invalid_field_format, 400, ['period'])
// Period 中的日期格式为 YYYY-MM-DD且第一个日期早于第二个日期
const dateRegex = /^\d{4}-\d{2}-\d{2}$/
if (!dateRegex.test(period[0]) || !dateRegex.test(period[1])) throw new HttpError(ErrorDescEnum.invalid_field_format, 400, ['period'])
if (new Date(period[0]) > new Date(period[1])) throw new HttpError(ErrorDescEnum.invalid_field_format, 400, ['period'])
if (!dateRegex.test(periodRaw[0]) || !dateRegex.test(periodRaw[1])) throw new HttpError(ErrorDescEnum.invalid_field_format, 400, ['period'])
if (new Date(periodRaw[0]) > new Date(periodRaw[1])) throw new HttpError(ErrorDescEnum.invalid_field_format, 400, ['period'])
const period = [
new Date(periodRaw[0].split('-')[0], periodRaw[0].split('-')[1] - 1, periodRaw[0].split('-')[2]),
new Date(periodRaw[1].split('-')[0], periodRaw[1].split('-')[1] - 1, periodRaw[1].split('-')[2])
]
// 验证 items 必须是数组
if (!Array.isArray(items)) throw new HttpError(ErrorDescEnum.invalid_field_format, 400, ['items'])
if (!Array.isArray(itemsRaw)) throw new HttpError(ErrorDescEnum.invalid_field_format, 400, ['items'])
// 验证 items 中的每一项必须是指定格式的对象
// description: string, quantity: number, unit: string, unitPrice: Decimal
const itemFields = ['description', 'quantity', 'unit', 'unitPrice']
items.forEach((item, index) => {
itemsRaw.forEach((item, index) => {
itemFields.forEach((field) => {
if (!item[field]) throw new HttpError(ErrorDescEnum.required_fields_missing, 400, [`items[${index}].${field}`])
})
@ -113,13 +119,18 @@ app.use(route.post('/invoice', async (ctx) => {
})
// 验证 dueDate 必须是日期格式
if (!dateRegex.test(dueDate)) throw new HttpError(ErrorDescEnum.invalid_field_format, 400, ['dueDate'])
if (!dateRegex.test(dueDateRaw)) throw new HttpError(ErrorDescEnum.invalid_field_format, 400, ['dueDate'])
const dueDate = new Date(dueDateRaw.split('-')[0], dueDateRaw.split('-')[1] - 1, dueDateRaw.split('-')[2])
// 验证 note 必须是字符串
if (note && typeof note !== 'string') throw new HttpError(ErrorDescEnum.invalid_field_format, 400, ['note'])
// 创建新的收据
const items = itemsRaw.map(item => {
return new InvoiceItem(0, item['description'], item['quantity'], item['unit'], item['unitPrice'])
})
await func.issueInvoice(ctx.prisma, payerId, period, items, dueDate, note)
ctx.status = 204
}))
const port = parseInt(process.env.PORT ?? '3000')

View File

@ -1,5 +1,8 @@
import { PrismaClient } from "@prisma/client"
import InvoiceItem from "./InvoiceItem"
import Debug from 'debug'
console.log = Debug('invoiceIssuer:classes/Invoice.ts')
class Invoice {
/**
@ -11,11 +14,13 @@ class Invoice {
*
*/
constructor(suffixCode: number, issueDate: Date, period: Date[], items: InvoiceItem[], dueDate: Date, payer: { id: number; name: string; address: string; abn?: string; email?: string }) {
console.log("hello")
// 设置收据后缀
this.suffixCode = suffixCode
// 设置签发时间
this.issueTime = issueDate
// 设置账单周期
console.log(period)
// 首先验证周期有两个日期,且第一个日期早于第二个日期
if (period.length !== 2) throw new Error('Period should have two dates.')
if (period[0] > period[1]) throw new Error('The first date should be earlier than the second date.')
@ -23,6 +28,7 @@ class Invoice {
// 设置项目列表
this.items = items
// 设置收款日期
console.log(dueDate)
this.dueDate = dueDate
// 设置付款人
this.payer = payer
@ -133,6 +139,7 @@ class Invoice {
// 保存收据项目
await Promise.all(this.items.map(async (item) => {
const unitPrice = typeof item.unitPrice === 'number' ? item.unitPrice : Number(item.unitPrice);
await prisma.invoiceItem.create({
data: {
invoice_date: this.issueTime,
@ -141,7 +148,7 @@ class Invoice {
item_description: item.description ?? '',
item_quantity: item.quantity ?? 0,
item_unit: item.unit ?? '',
item_unit_price: item.unitPrice ? item.unitPrice.toNumber() : 0,
item_unit_price: unitPrice,
}
})
}))

View File

@ -46,8 +46,7 @@ class InvoiceItem {
*
*/
getSubtotal() {
if (!this.quantity || !this.unitPrice) return 0
return this.quantity * this.unitPrice.toNumber()
return (this.unitPrice ? Number(this.unitPrice) : 0) * (this.quantity ?? 0);
}
}

View File

@ -24,26 +24,9 @@ export default async (prisma: PrismaClient, payerId: number, period: Date[], ite
if (!payer) throw new HttpError(ErrorDescEnum.related_item_not_found, 400, ['payer_id'])
// 创建新收据
// 获取当天最大收据后缀
const today = new Date()
const todayStart = new Date(today.getFullYear(), today.getMonth(), today.getDate())
const todayEnd = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1)
const maxSuffix = await prisma.invoice.findFirst({
where: {
AND: [
{ invoice_date: { gte: todayStart } },
{ invoice_date: { lt: todayEnd } }
]
},
orderBy: {
invoice_suffix_code: 'desc'
}
})
// 创建收据实现
const newInvoice = new Invoice(
maxSuffix ? maxSuffix.invoice_suffix_code + 1 : 1,
today,
0,
new Date(),
period,
items,
dueDate,
@ -55,4 +38,6 @@ export default async (prisma: PrismaClient, payerId: number, period: Date[], ite
email: payer.payer_email
}
)
if (note) await newInvoice.setNote(note)
await newInvoice.save(prisma)
}