From b9c9ee0889786c15d9439d77eb7a181f4c92adfd Mon Sep 17 00:00:00 2001 From: Andrew Boni Signori <61259237+andrewsignori-aot@users.noreply.github.com> Date: Wed, 5 Feb 2025 11:09:29 -0800 Subject: [PATCH] #3254 - CAS AP Invoice - Invoices and Batches - File Download (#4315) - Enabled the download of a CSV file that represents a batch and its invoice details. - Each row represents awards added to one invoice and its distribution accounts. - Created a new role, `aest-cas-invoicing`, and added it globally to the CAS invoice controller and UI links. ### Report Download Benchmark Data for an outputted report with 1122 invoice details. - File size output: 287Kb. - Report generation total: 456.248ms - Data retrieval: 394.036ms (SQL query and typeorm objects creation). - Report generation: 53.758ms (Typeorm objects to 2D objects for CSV generation). - Report CSV generation: 5.309ms (2D objects to CSV format). ## Sample report for one invoice with two awards (color manually added) ![image](https://github.com/user-attachments/assets/8f9fdf4a-1549-4b5a-9b23-647f6a28ae08) ## Minor refactor Method `streamFile` moved to a utils file to be shared between reports, CAS invoice reports, and others in the future. --- .../backend/apps/api/src/app.aest.module.ts | 2 + .../backend/apps/api/src/auth/roles.enum.ts | 1 + .../api/src/constants/error-code.constants.ts | 5 + .../cas-invoice-batch.aest.controller.ts | 64 +++++- .../report/report.controller.service.ts | 39 +--- .../utils/file-download-utils.ts | 32 +++ .../api/src/route-controllers/utils/index.ts | 3 + .../cas-invoice-batch-report.service.ts | 192 ++++++++++++++++++ .../cas-invoice-batch.models.ts | 5 + .../backend/apps/api/src/services/index.ts | 1 + .../backend/libs/utilities/src/date-utils.ts | 7 +- .../layouts/aest/AESTHomeSideBar.vue | 3 +- .../web/src/composables/useFileUtils.ts | 1 + .../src/services/CASInvoiceBatchService.ts | 16 ++ .../src/services/http/CASInvoiceBatchApi.ts | 13 ++ .../web/src/types/contracts/aest/roles.ts | 1 + .../web/src/views/aest/CASInvoices.vue | 30 ++- 17 files changed, 362 insertions(+), 53 deletions(-) create mode 100644 sources/packages/backend/apps/api/src/route-controllers/utils/file-download-utils.ts create mode 100644 sources/packages/backend/apps/api/src/route-controllers/utils/index.ts create mode 100644 sources/packages/backend/apps/api/src/services/cas-invoice-batch/cas-invoice-batch-report.service.ts create mode 100644 sources/packages/backend/apps/api/src/services/cas-invoice-batch/cas-invoice-batch.models.ts diff --git a/sources/packages/backend/apps/api/src/app.aest.module.ts b/sources/packages/backend/apps/api/src/app.aest.module.ts index 4fc7acfca0..0d24d249cc 100644 --- a/sources/packages/backend/apps/api/src/app.aest.module.ts +++ b/sources/packages/backend/apps/api/src/app.aest.module.ts @@ -35,6 +35,7 @@ import { ApplicationRestrictionBypassService, CRAIncomeVerificationService, CASInvoiceBatchService, + CASInvoiceBatchReportService, } from "./services"; import { SupportingUserAESTController, @@ -200,6 +201,7 @@ import { ECertIntegrationModule } from "@sims/integrations/esdc-integration"; SupportingUserService, CASSupplierSharedService, CASInvoiceBatchService, + CASInvoiceBatchReportService, ], }) export class AppAESTModule {} diff --git a/sources/packages/backend/apps/api/src/auth/roles.enum.ts b/sources/packages/backend/apps/api/src/auth/roles.enum.ts index 735886933d..5c965cfc9d 100644 --- a/sources/packages/backend/apps/api/src/auth/roles.enum.ts +++ b/sources/packages/backend/apps/api/src/auth/roles.enum.ts @@ -9,6 +9,7 @@ export enum Role { AESTReports = "aest-reports", AESTCreateInstitution = "aest-create-institution", AESTEditCASSupplierInfo = "aest-edit-cas-supplier-info", + AESTCASInvoicing = "aest-cas-invoicing", AESTBypassStudentRestriction = "aest-bypass-student-restriction", StudentAddRestriction = "student-add-restriction", StudentResolveRestriction = "student-resolve-restriction", diff --git a/sources/packages/backend/apps/api/src/constants/error-code.constants.ts b/sources/packages/backend/apps/api/src/constants/error-code.constants.ts index b8ac697319..4e20da13a0 100644 --- a/sources/packages/backend/apps/api/src/constants/error-code.constants.ts +++ b/sources/packages/backend/apps/api/src/constants/error-code.constants.ts @@ -244,3 +244,8 @@ export const APPLICATION_RESTRICTION_BYPASS_IS_NOT_ACTIVE = * Student not found error code. */ export const STUDENT_NOT_FOUND = "STUDENT_NOT_FOUND"; + +/** + * CAS invoice batch not found error code. + */ +export const CAS_INVOICE_BATCH_NOT_FOUND = "CAS_INVOICE_BATCH_NOT_FOUND"; diff --git a/sources/packages/backend/apps/api/src/route-controllers/cas-invoice-batch/cas-invoice-batch.aest.controller.ts b/sources/packages/backend/apps/api/src/route-controllers/cas-invoice-batch/cas-invoice-batch.aest.controller.ts index bcfc0f2e5e..c1d16a8be2 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/cas-invoice-batch/cas-invoice-batch.aest.controller.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/cas-invoice-batch/cas-invoice-batch.aest.controller.ts @@ -1,23 +1,45 @@ -import { Controller, Get, Query } from "@nestjs/common"; -import { ApiTags } from "@nestjs/swagger"; +import { + Controller, + Get, + NotFoundException, + Param, + ParseIntPipe, + Query, + Res, +} from "@nestjs/common"; +import { ApiNotFoundResponse, ApiTags } from "@nestjs/swagger"; import { AuthorizedParties, Role, UserGroups } from "../../auth"; import { AllowAuthorizedParty, Groups, Roles } from "../../auth/decorators"; import BaseController from "../BaseController"; import { ClientTypeBaseRoute } from "../../types"; -import { CASInvoiceBatchService } from "../../services"; +import { + CASInvoiceBatchReportService, + CASInvoiceBatchService, +} from "../../services"; import { CASInvoiceBatchAPIOutDTO } from "./models/cas-invoice-batch.dto"; import { getUserFullName } from "../../utilities"; import { CASInvoiceBatchesPaginationOptionsAPIInDTO, PaginatedResultsAPIOutDTO, } from "../models/pagination.dto"; +import { + CustomNamedError, + getFileNameAsCurrentTimestamp, +} from "@sims/utilities"; +import { Response } from "express"; +import { streamFile } from "../utils"; +import { CAS_INVOICE_BATCH_NOT_FOUND } from "../../constants"; @AllowAuthorizedParty(AuthorizedParties.aest) +@Roles(Role.AESTCASInvoicing) @Groups(UserGroups.AESTUser) @Controller("cas-invoice-batch") @ApiTags(`${ClientTypeBaseRoute.AEST}-cas-invoice-batch`) export class CASInvoiceBatchAESTController extends BaseController { - constructor(private readonly casInvoiceBatchService: CASInvoiceBatchService) { + constructor( + private readonly casInvoiceBatchService: CASInvoiceBatchService, + private readonly casInvoiceBatchReportService: CASInvoiceBatchReportService, + ) { super(); } @@ -27,7 +49,6 @@ export class CASInvoiceBatchAESTController extends BaseController { * @returns list of all invoice batches. */ @Get() - @Roles(Role.AESTEditCASSupplierInfo) // TODO: Create a new role. async getInvoiceBatches( @Query() paginationOptions: CASInvoiceBatchesPaginationOptionsAPIInDTO, ): Promise> { @@ -47,4 +68,37 @@ export class CASInvoiceBatchAESTController extends BaseController { count: pagination.count, }; } + + /** + * Batch invoices report with information to be reviewed by the Ministry + * to support the batch approval and allow invoices to be sent to CAS. + * @param casInvoiceBatchId batch ID to have the report generated for. + * @returns list of all invoices in the batch. + */ + @Get(":casInvoiceBatchId/report") + @ApiNotFoundResponse({ description: "CAS invoice batch not found." }) + async getCASInvoiceBatchReport( + @Param("casInvoiceBatchId", ParseIntPipe) casInvoiceBatchId: number, + @Res() response: Response, + ): Promise { + try { + const invoiceReport = + await this.casInvoiceBatchReportService.getCASInvoiceBatchReport( + casInvoiceBatchId, + ); + const batchDate = getFileNameAsCurrentTimestamp(invoiceReport.batchDate); + const filename = `${invoiceReport.batchName}_${batchDate}.csv`; + streamFile(response, filename, { + fileContent: invoiceReport.reportCSVContent, + }); + } catch (error: unknown) { + if ( + error instanceof CustomNamedError && + error.name === CAS_INVOICE_BATCH_NOT_FOUND + ) { + throw new NotFoundException(error.message); + } + throw error; + } + } } diff --git a/sources/packages/backend/apps/api/src/route-controllers/report/report.controller.service.ts b/sources/packages/backend/apps/api/src/route-controllers/report/report.controller.service.ts index cec3371f76..581498c689 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/report/report.controller.service.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/report/report.controller.service.ts @@ -11,13 +11,12 @@ import { import { getFileNameAsCurrentTimestamp, CustomNamedError, - appendByteOrderMark, } from "@sims/utilities"; import { Response } from "express"; -import { Readable } from "stream"; import { FormService, ProgramYearService } from "../../services"; import { ReportsFilterAPIInDTO } from "./models/report.dto"; import { FormNames } from "../../services/form/constants"; +import { streamFile } from "../utils"; /** * Controller Service layer for reports. @@ -71,7 +70,9 @@ export class ReportControllerService { const reportData = await this.reportService.getReportDataAsCSV( submissionResult.data.data, ); - this.streamFile(response, payload.reportName, reportData); + const timestamp = getFileNameAsCurrentTimestamp(); + const filename = `${payload.reportName}_${timestamp}.csv`; + streamFile(response, filename, { fileContent: reportData }); } catch (error: unknown) { if (error instanceof CustomNamedError) { switch (error.name) { @@ -83,36 +84,4 @@ export class ReportControllerService { throw error; } } - - /** - * Stream file as downloadable response. - * @param response http response as file. - * @param reportName report name. - * @param fileContent content of the file. - */ - private streamFile( - response: Response, - reportName: string, - fileContent: string, - ) { - const timestamp = getFileNameAsCurrentTimestamp(); - const filename = `${reportName}_${timestamp}.csv`; - // Adding byte order mark characters to the original file content as applications - // like excel would look for BOM characters to view the file as UTF8 encoded. - // Append byte order mark characters only if the file content is not empty. - const responseBuffer = fileContent - ? appendByteOrderMark(fileContent) - : Buffer.from(""); - response.setHeader( - "Content-Disposition", - `attachment; filename=${filename}`, - ); - response.setHeader("Content-Type", "text/csv"); - response.setHeader("Content-Length", responseBuffer.byteLength); - - const stream = new Readable(); - stream.push(responseBuffer); - stream.push(null); - stream.pipe(response); - } } diff --git a/sources/packages/backend/apps/api/src/route-controllers/utils/file-download-utils.ts b/sources/packages/backend/apps/api/src/route-controllers/utils/file-download-utils.ts new file mode 100644 index 0000000000..fa591d794b --- /dev/null +++ b/sources/packages/backend/apps/api/src/route-controllers/utils/file-download-utils.ts @@ -0,0 +1,32 @@ +import { appendByteOrderMark } from "@sims/utilities"; +import { Response } from "express"; +import { Readable } from "stream"; + +/** + * Streams a file to the response. + * @param response express response. + * @param fileName file name that will be presented to the user for download. + * @param options optional parameters. + * - `fileContent` file content to be downloaded. + * - `contentType - MIME type of the file. Defaults to 'text/csv'. + */ +export function streamFile( + response: Response, + fileName: string, + options?: { fileContent?: string; contentType?: string }, +): void { + const contentType = options?.contentType ?? "text/csv"; + // Adding byte order mark characters to the original file content as applications + // like excel would look for BOM characters to view the file as UTF8 encoded. + // Append byte order mark characters only if the file content is not empty. + const responseBuffer = options?.fileContent + ? appendByteOrderMark(options.fileContent) + : Buffer.from(""); + response.setHeader("Content-Disposition", `attachment; filename=${fileName}`); + response.setHeader("Content-Type", contentType); + response.setHeader("Content-Length", responseBuffer.byteLength); + const stream = new Readable(); + stream.push(responseBuffer); + stream.push(null); + stream.pipe(response); +} diff --git a/sources/packages/backend/apps/api/src/route-controllers/utils/index.ts b/sources/packages/backend/apps/api/src/route-controllers/utils/index.ts new file mode 100644 index 0000000000..1a639af52d --- /dev/null +++ b/sources/packages/backend/apps/api/src/route-controllers/utils/index.ts @@ -0,0 +1,3 @@ +export * from "./file-download-utils"; +export * from "./address-utils"; +export * from "./custom-validation-pipe"; diff --git a/sources/packages/backend/apps/api/src/services/cas-invoice-batch/cas-invoice-batch-report.service.ts b/sources/packages/backend/apps/api/src/services/cas-invoice-batch/cas-invoice-batch-report.service.ts new file mode 100644 index 0000000000..25aa36c0d8 --- /dev/null +++ b/sources/packages/backend/apps/api/src/services/cas-invoice-batch/cas-invoice-batch-report.service.ts @@ -0,0 +1,192 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { CASInvoice, CASInvoiceBatch } from "@sims/sims-db"; +import { Repository } from "typeorm"; +import { unparse } from "papaparse"; +import { CustomNamedError, getPSTPDTDateTime } from "@sims/utilities"; +import { CASInvoiceBatchReport } from "./cas-invoice-batch.models"; +import { CAS_INVOICE_BATCH_NOT_FOUND } from "../../constants"; + +@Injectable() +export class CASInvoiceBatchReportService { + constructor( + @InjectRepository(CASInvoiceBatch) + private readonly casInvoiceBatchRepo: Repository, + ) {} + + /** + * Generates a report for a specific CAS invoice batch. + * Retrieves the batch data and its invoices, then creates a detailed + * report with information on each invoice and its related awards. + * This report is meant to represent a snapshot of the batch data that + * will be later submitted to CAS. + * @param invoiceBatchId ID of the CAS invoice batch to generate the report for. + * @returns an object containing batch data and a CSV formatted string + * data for the specified invoice batch. + */ + async getCASInvoiceBatchReport( + invoiceBatchId: number, + ): Promise { + const reportData = await this.getCASInvoiceBatchesReportData( + invoiceBatchId, + ); + if (!reportData) { + throw new CustomNamedError( + `CAS invoice batch with ID ${invoiceBatchId} not found.`, + CAS_INVOICE_BATCH_NOT_FOUND, + ); + } + const report = reportData.casInvoices.flatMap((invoice) => { + // Rows for a single invoice. + // One row per award will be generated. + const invoiceReportRows: Record[] = []; + // Invoice master details. Each award will have this information. + const reportInvoiceMaster = this.createReportInvoiceMaster(invoice); + // Get all unique awards for the invoice. Each award will be a row in the report. + const awardCodes = new Set( + invoice.casInvoiceDetails.map( + (detail) => detail.casDistributionAccount.awardValueCode, + ), + ); + // Get the awards distribution details to be appended to the same row. + awardCodes.forEach((awardCode) => { + const awardAccounts = invoice.casInvoiceDetails.filter( + (detail) => + detail.casDistributionAccount.awardValueCode === awardCode, + ); + const reportInvoiceRow = { + ...reportInvoiceMaster, + "Award Type": awardCode, + }; + awardAccounts.forEach((account) => { + // Append the values for each operation code. + const operation = account.casDistributionAccount.operationCode; + reportInvoiceRow[`${operation} Amount`] = + account.valueAmount.toString(); + reportInvoiceRow[`${operation} Account`] = + account.casDistributionAccount.distributionAccount; + }); + invoiceReportRows.push(reportInvoiceRow); + }); + return invoiceReportRows; + }); + const reportCSVContent = unparse(report); + return { + batchName: reportData.batchName, + batchDate: reportData.batchDate, + reportCSVContent, + }; + } + + /** + * Creates a row containing the master details for an invoice. + * These details are common to all awards on the invoice. + * @param invoice the CAS invoice to generate the report row for. + * @returns a plain object with the report row data. + */ + private createReportInvoiceMaster( + invoice: CASInvoice, + ): Record { + // Student data for easy access. + const student = + invoice.disbursementReceipt.disbursementSchedule.studentAssessment + .application.student; + // Schedule data for easy access. + const schedule = invoice.disbursementReceipt.disbursementSchedule; + return { + "Invoice Number": invoice.invoiceNumber, + "Given Names": student.user.firstName, + "Last Name": student.user.lastName, + SIN: student.sinValidation.sin, + "Supplier Number": invoice.casSupplier.supplierNumber, + "Document Number": schedule.documentNumber.toString(), + "GL Date": getPSTPDTDateTime(invoice.disbursementReceipt.createdAt), + }; + } + + /** + * Retrieve a CAS invoice batch data required to be part of the report generation. + * @param invoiceBatchId ID of the CAS invoice batch to be retrieved. + * @returns the CAS invoice batch with all its invoices and related data. + */ + private async getCASInvoiceBatchesReportData( + invoiceBatchId: number, + ): Promise { + return this.casInvoiceBatchRepo.findOne({ + select: { + id: true, + batchName: true, + batchDate: true, + casInvoices: { + id: true, + invoiceNumber: true, + invoiceStatus: true, + casSupplier: { id: true, supplierNumber: true }, + casInvoiceDetails: { + id: true, + valueAmount: true, + casDistributionAccount: { + id: true, + operationCode: true, + awardValueCode: true, + distributionAccount: true, + }, + }, + disbursementReceipt: { + id: true, + createdAt: true, + disbursementSchedule: { + id: true, + documentNumber: true, + studentAssessment: { + id: true, + application: { + id: true, + student: { + id: true, + user: { id: true, firstName: true, lastName: true }, + sinValidation: { id: true, sin: true }, + }, + }, + }, + }, + }, + }, + }, + relations: { + casInvoices: { + casSupplier: true, + casInvoiceDetails: { casDistributionAccount: true }, + disbursementReceipt: { + disbursementSchedule: { + studentAssessment: { + application: { + student: { user: true, sinValidation: true }, + }, + }, + }, + }, + }, + }, + where: { id: invoiceBatchId }, + order: { + casInvoices: { + casInvoiceDetails: { + casDistributionAccount: { + awardValueCode: "ASC", + operationCode: "ASC", + }, + }, + casSupplier: { + supplierNumber: "ASC", + }, + disbursementReceipt: { + disbursementSchedule: { + documentNumber: "ASC", + }, + }, + }, + }, + }); + } +} diff --git a/sources/packages/backend/apps/api/src/services/cas-invoice-batch/cas-invoice-batch.models.ts b/sources/packages/backend/apps/api/src/services/cas-invoice-batch/cas-invoice-batch.models.ts new file mode 100644 index 0000000000..a95f5cbc2d --- /dev/null +++ b/sources/packages/backend/apps/api/src/services/cas-invoice-batch/cas-invoice-batch.models.ts @@ -0,0 +1,5 @@ +export interface CASInvoiceBatchReport { + batchName: string; + batchDate: Date; + reportCSVContent: string; +} diff --git a/sources/packages/backend/apps/api/src/services/index.ts b/sources/packages/backend/apps/api/src/services/index.ts index dabc311a9d..18d62e34d9 100644 --- a/sources/packages/backend/apps/api/src/services/index.ts +++ b/sources/packages/backend/apps/api/src/services/index.ts @@ -52,3 +52,4 @@ export * from "./audit/audit.service"; export * from "./audit/audit-event.enum"; export * from "./application-restriction-bypass/application-restriction-bypass.models"; export * from "./cas-invoice-batch/cas-invoice-batch.service"; +export * from "./cas-invoice-batch/cas-invoice-batch-report.service"; diff --git a/sources/packages/backend/libs/utilities/src/date-utils.ts b/sources/packages/backend/libs/utilities/src/date-utils.ts index 855de84c1f..9e2661e169 100644 --- a/sources/packages/backend/libs/utilities/src/date-utils.ts +++ b/sources/packages/backend/libs/utilities/src/date-utils.ts @@ -244,10 +244,13 @@ export function dateEqualTo(date: Date): FindOperator { /** * Return a PST timestamp with date and time in continuous format * mainly used to append in filename. + * @param date date to be converted, if not provided, the current date will be used. * @returns timestamp. */ -export function getFileNameAsCurrentTimestamp(): string { - return dayjs(new Date()).tz(PST_TIMEZONE).format(TIMESTAMP_CONTINUOUS_FORMAT); +export function getFileNameAsCurrentTimestamp(date?: Date): string { + return dayjs(date ?? new Date()) + .tz(PST_TIMEZONE) + .format(TIMESTAMP_CONTINUOUS_FORMAT); } /** diff --git a/sources/packages/web/src/components/layouts/aest/AESTHomeSideBar.vue b/sources/packages/web/src/components/layouts/aest/AESTHomeSideBar.vue index ea201bc50b..4876083d05 100644 --- a/sources/packages/web/src/components/layouts/aest/AESTHomeSideBar.vue +++ b/sources/packages/web/src/components/layouts/aest/AESTHomeSideBar.vue @@ -17,8 +17,7 @@ color="primary" nav > - - +