From cd1766db0ffdc70607f0f2466222a00fea8c28b1 Mon Sep 17 00:00:00 2001 From: Astrian Zheng Date: Sun, 12 Jan 2025 09:31:03 +1100 Subject: [PATCH] feat: enhance /invoice route with comprehensive validation for required fields and item formats, and implement invoice creation logic --- backend/src/app.ts | 45 ++++++ backend/src/classes/Invoice.ts | 231 ++++++++++++------------------- backend/src/func/index.ts | 4 +- backend/src/func/issueInvoice.ts | 45 +++++- 4 files changed, 183 insertions(+), 142 deletions(-) diff --git a/backend/src/app.ts b/backend/src/app.ts index 460ded3..3ead483 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -74,7 +74,52 @@ app.use(route.post('/payer', async (ctx) => { })) app.use(route.post('/invoice', async (ctx) => { + // TODO: 请求头验证 bearer token + // 提取字段,并验证必填字段 + const { payerId, period, items, dueDate, 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 (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']) + + // 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']) + + // 验证 items 必须是数组 + if (!Array.isArray(items)) 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) => { + itemFields.forEach((field) => { + if (!item[field]) throw new HttpError(ErrorDescEnum.required_fields_missing, 400, [`items[${index}].${field}`]) + }) + if (typeof item['description'] !== 'string') throw new HttpError(ErrorDescEnum.invalid_field_format, 400, [`items[${index}].description`]) + if (typeof item['quantity'] !== 'number') throw new HttpError(ErrorDescEnum.invalid_field_format, 400, [`items[${index}].quantity`]) + if (typeof item['unit'] !== 'string') throw new HttpError(ErrorDescEnum.invalid_field_format, 400, [`items[${index}].unit`]) + if (typeof item['unitPrice'] !== 'number') throw new HttpError(ErrorDescEnum.invalid_field_format, 400, [`items[${index}].unitPrice`]) + }) + + // 验证 dueDate 必须是日期格式 + if (!dateRegex.test(dueDate)) throw new HttpError(ErrorDescEnum.invalid_field_format, 400, ['dueDate']) + + // 验证 note 必须是字符串 + if (note && typeof note !== 'string') throw new HttpError(ErrorDescEnum.invalid_field_format, 400, ['note']) + + // 创建新的收据 + await func.issueInvoice(ctx.prisma, payerId, period, items, dueDate, note) })) const port = parseInt(process.env.PORT ?? '3000') diff --git a/backend/src/classes/Invoice.ts b/backend/src/classes/Invoice.ts index f1725ae..21f2a8c 100644 --- a/backend/src/classes/Invoice.ts +++ b/backend/src/classes/Invoice.ts @@ -4,158 +4,64 @@ import InvoiceItem from "./InvoiceItem" class Invoice { /** * 创建新的收据实体 + * @param suffixCode 收据 ID + * @param issueDate 签发时间 + * @param newInvoiceInfo 新收据信息 + * @returns 新的收据实体 + * */ - constructor(invoiceId?: number, issueDate?: Date, newInvoiceInfo?: { period: Date[], items: InvoiceItem[], payerId: number, dueDate?: Date }) { - (async () => { - const prismaClient = new PrismaClient() - - if (invoiceId && issueDate) { - // 从数据库查找收据 - const invoice = await prismaClient.invoice.findUnique({ - where: { - invoice_date_invoice_suffix_code: { - invoice_suffix_code: invoiceId % 1000, - invoice_date: new Date(invoiceId / 1000) - } - } - }) - if (!invoice) throw new Error('invoice not found') - - // 设置收据后缀 - this.suffixCode = invoice.invoice_suffix_code - - // 设置签发时间 - this.issueTime = invoice.invoice_date - - // 设置账单周期 - this.period = [invoice.invoice_billing_period_start, invoice.invoice_billing_period_end] - - // 设置项目列表 - const items = await prismaClient.invoiceItem.findMany({ - where: { - invoice_date: this.issueTime, - invoice_suffix_code: this.suffixCode - } - }) - if (!items) this.items = [] - else { - this.items = [] - items.forEach((item) => { - this.items.push(new InvoiceItem(item.item_id, item.item_description, item.item_quantity, item.item_unit, item.item_unit_price)) - }) - } - - // 设置收款日期 - this.dueDate = invoice.invoice_due_date - - // 设置付款人 - this.payer = { - id: invoice.payer_id, - name: invoice.invoice_payer_name, - address: invoice.invoice_payer_address, - abn: invoice.invoice_payer_abn ? invoice.invoice_payer_abn : undefined, - } - - // 设置备注 - this.note = invoice.invoice_note ?? undefined - } else if (newInvoiceInfo) { - // 设置账单签发时间 - this.issueTime = new Date() - - // 设置账单周期 - if (newInvoiceInfo.period.length !== 2) throw new Error('period should contain two dates') - this.period = newInvoiceInfo.period - - // 设置项目列表 - this.items = newInvoiceInfo.items - - // 设置收款日期 - this.dueDate = newInvoiceInfo.dueDate || new Date() - - // 设置付款人 - const payer = await prismaClient.payer.findUnique({ - where: { - payer_id: newInvoiceInfo.payerId - } - }) - if (!payer) throw new Error('payer not found') - this.payer = { - id: payer.payer_id, - name: payer.payer_name, - address: payer.payer_address, - abn: payer.payer_abn ? payer.payer_abn : undefined, - email: payer.payer_email - } - - // 将收据保存到数据库 - await prismaClient.invoice.create({ - data: { - invoice_suffix_code: this.suffixCode, - invoice_date: this.issueTime, - invoice_billing_period_start: this.period[0], - invoice_billing_period_end: this.period[1], - invoice_due_date: this.dueDate, - payer_id: this.payer.id, - invoice_payer_address: this.payer.address, - invoice_payer_abn: this.payer.abn, - invoice_payer_name: this.payer.name, - invoice_total_amount: this.getTotal() - } - }) - - // 将项目保存到数据库 - await Promise.all(this.items.map(async (item) => { - await prismaClient.invoiceItem.create({ - data: { - invoice_suffix_code: this.suffixCode, - invoice_date: this.issueTime, - item_description: item.description ?? '', - item_quantity: item.quantity ?? 0, - item_unit: item.unit ?? '', - item_unit_price: item.unitPrice?.toNumber() ?? 0, - item_id: item.id - } - }) - })) - } - prismaClient.$disconnect() - })() + constructor(suffixCode: number, issueDate: Date, period: Date[], items: InvoiceItem[], dueDate: Date, payer: { id: number; name: string; address: string; abn?: string; email?: string }) { + // 设置收据后缀 + this.suffixCode = suffixCode + // 设置签发时间 + this.issueTime = issueDate + // 设置账单周期 + // 首先验证周期有两个日期,且第一个日期早于第二个日期 + 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.') + this.period = period + // 设置项目列表 + this.items = items + // 设置收款日期 + this.dueDate = dueDate + // 设置付款人 + this.payer = payer } /** * 收据后缀。仅在当日唯一。 */ - suffixCode!: number; + suffixCode: number; /** * 签发时间。与后缀一起构成发票的唯一标识。 */ - issueTime!: Date; + issueTime: Date; /** * 账单周期 */ - period!: Date[]; + period: Date[] /** * 项目列表 */ - items!: InvoiceItem[]; + items: InvoiceItem[] /** * 收款日期 */ - dueDate!: Date; + dueDate: Date /** * 付款人 */ - payer!: { - id: number; - name: string; - address: string; - abn?: string; - email?: string; + payer: { + id: number + name: string + address: string + abn?: string + email?: string } /** @@ -180,17 +86,64 @@ class Invoice { */ async setNote(note: string) { this.note = note - const prismaClient = new PrismaClient() - await prismaClient.invoice.update({ - where: { - invoice_date_invoice_suffix_code: { - invoice_suffix_code: this.suffixCode, - invoice_date: this.issueTime - } - }, - data: { - invoice_note: note - } - }) } -} \ No newline at end of file + + /** + * 保存收据到数据库 + * @param prisma Prisma 客户端 + */ + async save(prisma: PrismaClient) { + if (this.suffixCode === 0) { + // 创建新收据 + // 获取当天最大收据后缀 + 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 = await prisma.invoice.create({ + data: { + invoice_suffix_code: maxSuffix ? maxSuffix.invoice_suffix_code + 1 : 1, + invoice_date: this.issueTime, + invoice_billing_period_start: this.period[0], + invoice_billing_period_end: this.period[1], + invoice_due_date: this.dueDate, + payer_id: this.payer.id, + invoice_payer_address: this.payer.address, + invoice_payer_name: this.payer.name, + invoice_payer_abn: this.payer.abn, + invoice_note: this.note, + invoice_total_amount: this.getTotal() + } + }) + + // 保存收据项目 + await Promise.all(this.items.map(async (item) => { + await prisma.invoiceItem.create({ + data: { + invoice_date: this.issueTime, + invoice_suffix_code: newInvoice.invoice_suffix_code, + item_id: item.id, + item_description: item.description ?? '', + item_quantity: item.quantity ?? 0, + item_unit: item.unit ?? '', + item_unit_price: item.unitPrice ? item.unitPrice.toNumber() : 0, + } + }) + })) + } + } +} + +export default Invoice \ No newline at end of file diff --git a/backend/src/func/index.ts b/backend/src/func/index.ts index 2a94fb1..4054074 100644 --- a/backend/src/func/index.ts +++ b/backend/src/func/index.ts @@ -1,5 +1,7 @@ import createPayer from "./createPayer" +import issueInvoice from "./issueInvoice" export default { - createPayer + createPayer, + issueInvoice } \ No newline at end of file diff --git a/backend/src/func/issueInvoice.ts b/backend/src/func/issueInvoice.ts index 28ff20c..8ab3925 100644 --- a/backend/src/func/issueInvoice.ts +++ b/backend/src/func/issueInvoice.ts @@ -1,10 +1,20 @@ import Debug from 'debug' import { ErrorDescEnum, HttpError } from '../classes/HttpError' -import { PrismaClient, InvoiceItem } from '@prisma/client' +import { PrismaClient } from '@prisma/client' +import InvoiceItem from '../classes/InvoiceItem' +import Invoice from '../classes/Invoice' console.log = Debug('invoiceIssuer:func/issueInvoice.ts') -export default async (prisma: PrismaClient, payerId: number, period: Date[], items: InvoiceItem[]) => { +/** + * POST /invoice + * @summary 创建一个新的收据 + * @param {number} payerId - 付款人的 ID + * @param {Date[]} period - 收据的账单周期 + * @param {InvoiceItem[]} items - 收据的项目列表 + * @return {string} - 新收据的 ID + */ +export default async (prisma: PrismaClient, payerId: number, period: Date[], items: InvoiceItem[], dueDate: Date, note?: string) => { // 验证付款人是否存在 const payer = await prisma.payer.findUnique({ where: { @@ -14,4 +24,35 @@ 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, + period, + items, + dueDate, + { + id: payer.payer_id, + name: payer.payer_name, + address: payer.payer_address, + abn: payer.payer_abn ? payer.payer_abn : undefined, + email: payer.payer_email + } + ) } \ No newline at end of file