249 lines
8.0 KiB
TypeScript
249 lines
8.0 KiB
TypeScript
import { EntityManager } from "@mikro-orm/postgresql";
|
|
import { InjectQueue } from "@nestjs/bullmq";
|
|
import { BadRequestException, Injectable } from "@nestjs/common";
|
|
import { Queue } from "bullmq";
|
|
import dayjs from "dayjs";
|
|
import Decimal from "decimal.js";
|
|
import { Workbook } from "exceljs";
|
|
|
|
import { BillListQueryDto } from "./dto/bill-list-query.dto";
|
|
import { CreateChargeBillDto } from "./dto/create-charge-bill.dto";
|
|
import { CreateWaterBillDto, CreateWaterBillFromExcelDto } from "./dto/create-water-bill.dto";
|
|
import { Bill } from "./entities/bill.entity";
|
|
import { BillType } from "./enums/bill-type.enum";
|
|
import { BillsRepository } from "./repositories/bill.repository";
|
|
import { CompanyMessage } from "../../common/enums/message.enum";
|
|
import { Business } from "../businesses/entities/business.entity";
|
|
import { Company } from "../companies/entities/company.entity";
|
|
import { CompaniesService } from "../companies/services/companies.service";
|
|
import { INVOICE } from "../invoices/constants";
|
|
import { InvoiceItem } from "../invoices/entities/invoice-item.entity";
|
|
import { Invoice } from "../invoices/entities/invoice.entity";
|
|
import { NotificationQueue } from "../notifications/queue/notification.queue";
|
|
import { IFile } from "../utils/interfaces/IFile";
|
|
|
|
@Injectable()
|
|
export class BillsService {
|
|
readonly SEWAGE_RATE = 0.3;
|
|
readonly TAX_RATE = 0.1;
|
|
|
|
constructor(
|
|
private readonly em: EntityManager,
|
|
private readonly companiesService: CompaniesService,
|
|
private readonly billRepository: BillsRepository,
|
|
@InjectQueue(INVOICE.QUEUE_NAME)
|
|
private readonly invoiceQueue: Queue,
|
|
private readonly notificationQueue: NotificationQueue,
|
|
) {}
|
|
|
|
async createWaterBill(dto: CreateWaterBillDto, business: Business) {
|
|
const { values, waterRate, dueDays, title } = dto;
|
|
const em = this.em.fork();
|
|
|
|
try {
|
|
await em.begin();
|
|
|
|
const bill = em.create(Bill, { type: BillType.WATER_BILL, business, title });
|
|
em.persist(bill);
|
|
const dueDate = dayjs().add(dueDays, "day").toDate();
|
|
|
|
const uniqueCompanyIds = [...new Set(values.map((v) => v.companyId))];
|
|
const companies = await this.companiesService.findByIds(uniqueCompanyIds, em);
|
|
const companyMap = new Map<string, Company>(companies.map((c) => [c.id, c]));
|
|
|
|
for (const value of values) {
|
|
const waterCost = new Decimal(waterRate).mul(value.waterUsage);
|
|
const sewageCost = waterCost.mul(this.SEWAGE_RATE);
|
|
const subtotal = waterCost.plus(sewageCost);
|
|
|
|
const tax = subtotal.mul(this.TAX_RATE);
|
|
const totalPrice = subtotal.plus(tax);
|
|
const company = companyMap.get(value.companyId);
|
|
if (!company) {
|
|
throw new BadRequestException(CompanyMessage.COMPANY_NOT_FOUND);
|
|
}
|
|
|
|
if (totalPrice.eq(0)) {
|
|
continue;
|
|
}
|
|
|
|
const invoice = em.create(Invoice, {
|
|
company,
|
|
totalPrice,
|
|
originalPrice: totalPrice,
|
|
dueDate,
|
|
tax,
|
|
business,
|
|
bill,
|
|
});
|
|
|
|
const invoiceItem = em.create(InvoiceItem, {
|
|
name: title,
|
|
count: 1,
|
|
unitPrice: totalPrice,
|
|
discount: new Decimal(0),
|
|
totalPrice,
|
|
invoice,
|
|
});
|
|
|
|
invoice.items.add(invoiceItem);
|
|
em.persist(invoice);
|
|
}
|
|
|
|
await em.commit();
|
|
|
|
// Ensure bill.id is available
|
|
if (!bill.id) {
|
|
await em.refresh(bill);
|
|
}
|
|
|
|
await this.addInvoiceReminderJobs(bill.id);
|
|
|
|
return bill;
|
|
} catch (error) {
|
|
await em.rollback();
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private async addInvoiceReminderJobs(billId: string) {
|
|
const invoices = await this.em.find(Invoice, { bill: { id: billId } }, { populate: ["items", "company", "company.user"] });
|
|
|
|
for (const invoice of invoices) {
|
|
await this.notificationQueue.addInvoiceCreationNotification(invoice.company.user.id, {
|
|
invoiceId: invoice.numericId.toString(),
|
|
dueDate: invoice.dueDate,
|
|
createDate: invoice.createdAt,
|
|
price: new Decimal(invoice.totalPrice).toNumber(),
|
|
userPhone: invoice.company.user.phone,
|
|
userEmail: invoice.company.user.email,
|
|
items: invoice.items[0].name,
|
|
paidAt: invoice.paidAt,
|
|
});
|
|
|
|
await this.invoiceQueue.add(
|
|
INVOICE.REMINDER_JOB_NAME,
|
|
{ invoiceId: invoice.id },
|
|
{
|
|
delay: dayjs(invoice.dueDate).subtract(INVOICE.DAYS_BEFORE_OVERDUE, "day").diff(dayjs()),
|
|
attempts: INVOICE.REMINDER_JOB_ATTEMPTS,
|
|
backoff: { type: "exponential", delay: INVOICE.REMINDER_JOB_BACKOFF },
|
|
priority: INVOICE.REMINDER_JOB_PRIORITY,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
async createWaterBillByExcel(file: IFile, dto: CreateWaterBillFromExcelDto, business: Business) {
|
|
const values = await this.parseWaterBillExcel(file.buffer);
|
|
if (values.length === 0) {
|
|
throw new BadRequestException("Excel file contains no valid data rows");
|
|
}
|
|
return this.createWaterBill({ ...dto, values }, business);
|
|
}
|
|
|
|
private async parseWaterBillExcel(buffer: Buffer): Promise<{ companyId: string; waterUsage: number }[]> {
|
|
const workbook = new Workbook();
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
await workbook.xlsx.load(buffer as any);
|
|
const worksheet = workbook.worksheets[0];
|
|
if (!worksheet) {
|
|
return [];
|
|
}
|
|
const values: { companyId: string; waterUsage: number }[] = [];
|
|
worksheet.eachRow((row) => {
|
|
const companyIdRaw = row.getCell(1).value;
|
|
const waterUsageRaw = row.getCell(3).value;
|
|
const companyId = companyIdRaw != null ? String(companyIdRaw).trim() : "";
|
|
const waterUsage = typeof waterUsageRaw === "number" ? waterUsageRaw : Number(waterUsageRaw);
|
|
if (companyId && !Number.isNaN(waterUsage) && waterUsage >= 0) {
|
|
values.push({ companyId, waterUsage });
|
|
}
|
|
});
|
|
return values;
|
|
}
|
|
|
|
async createChargeBill(dto: CreateChargeBillDto, business: Business) {
|
|
const { chargeRate, dueDays, title } = dto;
|
|
const em = this.em.fork();
|
|
|
|
try {
|
|
await em.begin();
|
|
|
|
const bill = em.create(Bill, { type: BillType.MONTHLY_CHARGE, business, title });
|
|
em.persist(bill);
|
|
const dueDate = dayjs().add(dueDays, "day").toDate();
|
|
|
|
const companies = await this.companiesService.findAllBybusinessId(business.id, em);
|
|
|
|
for (const company of companies) {
|
|
const totalPrice = new Decimal(chargeRate).mul(company.metrage);
|
|
|
|
if (totalPrice.eq(0)) {
|
|
continue;
|
|
}
|
|
const invoice = em.create(Invoice, {
|
|
company,
|
|
totalPrice,
|
|
originalPrice: totalPrice,
|
|
dueDate,
|
|
tax: new Decimal(0),
|
|
business,
|
|
bill,
|
|
});
|
|
|
|
const invoiceItem = em.create(InvoiceItem, {
|
|
name: title,
|
|
count: 1,
|
|
unitPrice: totalPrice,
|
|
discount: new Decimal(0),
|
|
totalPrice,
|
|
invoice,
|
|
});
|
|
invoice.items.add(invoiceItem);
|
|
|
|
em.persist(invoice);
|
|
}
|
|
|
|
await em.commit();
|
|
// Ensure bill.id is available
|
|
if (!bill.id) {
|
|
await em.refresh(bill);
|
|
}
|
|
|
|
await this.addInvoiceReminderJobs(bill.id);
|
|
|
|
return bill;
|
|
} catch (error) {
|
|
await em.rollback();
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async findAll(queryDto: BillListQueryDto, businessId: string) {
|
|
const [bills, count] = await this.billRepository.getBillsList(queryDto, businessId);
|
|
return {
|
|
bills,
|
|
count,
|
|
paginate: true,
|
|
};
|
|
}
|
|
|
|
async findOne(id: string, businessId: string) {
|
|
const bill = await this.billRepository.findOne({ id, business: { id: businessId } }, { populate: ["invoices"] });
|
|
if (!bill) {
|
|
throw new BadRequestException("Bill not found");
|
|
}
|
|
return { bill };
|
|
}
|
|
|
|
async remove(id: string, businessId: string) {
|
|
const bill = await this.billRepository.findOne({ id, business: { id: businessId } }, { populate: ["invoices"] });
|
|
if (!bill) {
|
|
throw new BadRequestException("Bill not found");
|
|
}
|
|
await this.em.removeAndFlush(bill);
|
|
return { deleted: true };
|
|
}
|
|
}
|