diff --git a/backend/package-lock.json b/backend/package-lock.json index e877cc0..e5efdd0 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@prisma/client": "^6.2.1", "debug": "^4.4.0", + "decimal.js": "^10.4.3", "dotenv": "^16.4.7", "koa": "^2.15.3", "koa-body": "^6.0.1", @@ -661,6 +662,11 @@ } } }, + "node_modules/decimal.js": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", + "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==" + }, "node_modules/deep-equal": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", diff --git a/backend/package.json b/backend/package.json index a61bca6..b5d1d0b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -26,6 +26,7 @@ "dependencies": { "@prisma/client": "^6.2.1", "debug": "^4.4.0", + "decimal.js": "^10.4.3", "dotenv": "^16.4.7", "koa": "^2.15.3", "koa-body": "^6.0.1", diff --git a/backend/prisma/migrations/20250111215310_init/migration.sql b/backend/prisma/migrations/20250111215310_init/migration.sql new file mode 100644 index 0000000..ed190dc --- /dev/null +++ b/backend/prisma/migrations/20250111215310_init/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `invoice_id` on the `InvoiceItem` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE `InvoiceItem` DROP COLUMN `invoice_id`; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 4c742d5..c4d92ec 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -48,7 +48,6 @@ model InvoiceItem { item_quantity Int item_unit_price Decimal @db.Decimal(10, 2) item_unit String @db.VarChar(50) - invoice_id Int invoice Invoice @relation(fields: [invoice_date, invoice_suffix_code], references: [invoice_date, invoice_suffix_code]) @@id([item_id, invoice_date, invoice_suffix_code]) diff --git a/backend/src/app.ts b/backend/src/app.ts index d5efbdc..460ded3 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -4,7 +4,7 @@ import route from 'koa-route' import Debug from 'debug' import koaBody from 'koa-body' import func from './func' -import { ErrorDescEnum, HttpError } from './types/HttpError' +import { ErrorDescEnum, HttpError } from './classes/HttpError' import { PrismaClient } from '@prisma/client' dotenv.config() @@ -73,6 +73,10 @@ app.use(route.post('/payer', async (ctx) => { ctx.status = 204 })) +app.use(route.post('/invoice', async (ctx) => { + +})) + const port = parseInt(process.env.PORT ?? '3000') app.listen(port, () => { console.log(`Server is running at http://localhost:${port}`) diff --git a/backend/src/types/HttpError.ts b/backend/src/classes/HttpError.ts similarity index 74% rename from backend/src/types/HttpError.ts rename to backend/src/classes/HttpError.ts index c709b30..e46b5a0 100644 --- a/backend/src/types/HttpError.ts +++ b/backend/src/classes/HttpError.ts @@ -48,7 +48,22 @@ enum ErrorDescEnum { /** * 项目已存在。通常与 HTTP 状态码 400 Bad Request 一起使用。 */ - item_exists = 'item_exists' + item_exists = 'item_exists', + + /** + * 项目不存在。通常与 HTTP 状态码 404 Not Found 一起使用。 + */ + item_not_found = 'item_not_found', + + /** + * 未授权。通常与 HTTP 状态码 401 Unauthorized 一起使用。 + */ + unauthorized = 'unauthorized', + + /** + * 关联项目不存在。例如,创建新收据时,指定的付款人不存在。通常与 HTTP 状态码 400 Bad Request 一起使用。 + */ + related_item_not_found = 'related_item_not_found' } export { HttpError, ErrorDescEnum } \ No newline at end of file diff --git a/backend/src/classes/Invoice.ts b/backend/src/classes/Invoice.ts new file mode 100644 index 0000000..f1725ae --- /dev/null +++ b/backend/src/classes/Invoice.ts @@ -0,0 +1,196 @@ +import { PrismaClient } from "@prisma/client" +import InvoiceItem from "./InvoiceItem" + +class Invoice { + /** + * 创建新的收据实体 + */ + 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() + })() + } + + /** + * 收据后缀。仅在当日唯一。 + */ + suffixCode!: number; + + /** + * 签发时间。与后缀一起构成发票的唯一标识。 + */ + issueTime!: Date; + + /** + * 账单周期 + */ + period!: Date[]; + + /** + * 项目列表 + */ + items!: InvoiceItem[]; + + /** + * 收款日期 + */ + dueDate!: Date; + + /** + * 付款人 + */ + payer!: { + id: number; + name: string; + address: string; + abn?: string; + email?: string; + } + + /** + * 备注 + */ + note?: string; + + /** + * 获取账单总额 + */ + getTotal() { + let total = 0 + this.items.forEach((item) => { + total += item.getSubtotal() + }) + return total + } + + /** + * 更改备注 + * @param note 新的备注 + */ + 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 diff --git a/backend/src/classes/InvoiceItem.ts b/backend/src/classes/InvoiceItem.ts new file mode 100644 index 0000000..33942ac --- /dev/null +++ b/backend/src/classes/InvoiceItem.ts @@ -0,0 +1,54 @@ +import { Decimal } from 'decimal.js'; + +class InvoiceItem { + /** + * 创建一个新的收据项目实体。 + * @param id 收据项目的 ID。 + * @param description 项目的名称/描述。 + * @param quantity 项目的数量。 + * @param unit 项目的单位。 + * @param unitPrice 项目的单价。 + */ + constructor(id: number, description: string, quantity: number, unit: string, unitPrice: Decimal) { + this.id = id + this.description = description + this.quantity = quantity + this.unit = unit + this.unitPrice = unitPrice + } + + /** + * 收据项目的 ID。 + */ + id: number + + /** + * 项目的名称/描述。 + */ + description?: string + + /** + * 项目的数量。 + */ + quantity?: number + + /** + * 项目的单位。 + */ + unit?: string + + /** + * 项目的单价。 + */ + unitPrice?: Decimal + + /** + * 获取项目的小计。 + */ + getSubtotal() { + if (!this.quantity || !this.unitPrice) return 0 + return this.quantity * this.unitPrice.toNumber() + } +} + +export default InvoiceItem \ No newline at end of file diff --git a/backend/src/func/createPayer.ts b/backend/src/func/createPayer.ts index dedf362..3d93ff7 100644 --- a/backend/src/func/createPayer.ts +++ b/backend/src/func/createPayer.ts @@ -1,6 +1,6 @@ import Debug from 'debug' -import { ErrorDescEnum, HttpError } from '../types/HttpError' -import { PrismaClient, Prisma } from '@prisma/client' +import { ErrorDescEnum, HttpError } from '../classes/HttpError' +import { PrismaClient } from '@prisma/client' console.log = Debug('invoiceIssuer:func/createPayer.ts') @@ -12,7 +12,7 @@ console.log = Debug('invoiceIssuer:func/createPayer.ts') * @param {string} email - 付款人的电子邮件地址 * @param {string} abn - 付款人的澳大利亚商业号码 */ -export default async (prisma: PrismaClient, name: string, address: string[], email: string, abn?: string) => { +export default async (prisma: PrismaClient, name: string, address: string[], email: string, abn?: string) => { // 验证 ABN 是否已存在 const existingPayer = await prisma.payer.findFirst({ where: { diff --git a/backend/src/func/issueInvoice.ts b/backend/src/func/issueInvoice.ts new file mode 100644 index 0000000..28ff20c --- /dev/null +++ b/backend/src/func/issueInvoice.ts @@ -0,0 +1,17 @@ +import Debug from 'debug' +import { ErrorDescEnum, HttpError } from '../classes/HttpError' +import { PrismaClient, InvoiceItem } from '@prisma/client' + +console.log = Debug('invoiceIssuer:func/issueInvoice.ts') + +export default async (prisma: PrismaClient, payerId: number, period: Date[], items: InvoiceItem[]) => { + // 验证付款人是否存在 + const payer = await prisma.payer.findUnique({ + where: { + payer_id: payerId + } + }) + if (!payer) throw new HttpError(ErrorDescEnum.related_item_not_found, 400, ['payer_id']) + + // 创建新收据 +} \ No newline at end of file