Skip to content

Commit

Permalink
#3254 - CAS AP Invoice - Invoices and Batches - File Download (#4315)
Browse files Browse the repository at this point in the history
- 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.
  • Loading branch information
andrewsignori-aot authored Feb 5, 2025
1 parent 129f923 commit b9c9ee0
Show file tree
Hide file tree
Showing 17 changed files with 362 additions and 53 deletions.
2 changes: 2 additions & 0 deletions sources/packages/backend/apps/api/src/app.aest.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
ApplicationRestrictionBypassService,
CRAIncomeVerificationService,
CASInvoiceBatchService,
CASInvoiceBatchReportService,
} from "./services";
import {
SupportingUserAESTController,
Expand Down Expand Up @@ -200,6 +201,7 @@ import { ECertIntegrationModule } from "@sims/integrations/esdc-integration";
SupportingUserService,
CASSupplierSharedService,
CASInvoiceBatchService,
CASInvoiceBatchReportService,
],
})
export class AppAESTModule {}
1 change: 1 addition & 0 deletions sources/packages/backend/apps/api/src/auth/roles.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Original file line number Diff line number Diff line change
@@ -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();
}

Expand All @@ -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<PaginatedResultsAPIOutDTO<CASInvoiceBatchAPIOutDTO>> {
Expand All @@ -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<void> {
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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./file-download-utils";
export * from "./address-utils";
export * from "./custom-validation-pipe";
Loading

0 comments on commit b9c9ee0

Please sign in to comment.