Skip to content

Commit

Permalink
Added detailplan catalog to report
Browse files Browse the repository at this point in the history
  • Loading branch information
jlaamanen committed Mar 21, 2023
1 parent a5099e3 commit 2fb97f8
Show file tree
Hide file tree
Showing 11 changed files with 1,632 additions and 331 deletions.
1,686 changes: 1,425 additions & 261 deletions backend/package-lock.json

Large diffs are not rendered by default.

7 changes: 5 additions & 2 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
"scripts": {
"build": "tsc",
"start": "node -r tsconfig-paths/register dist/app.js",
"dev": "node-dev -r tsconfig-paths/register src/app.ts",
"dev": "concurrently 'npm:dev:*'",
"dev:tsc": "tsc --watch --noEmit --preserveWatchOutput --pretty",
"dev:server": "tsx watch --clear-screen=false src/app.ts",
"test": "vitest run",
"db-migrate": "PGOPTIONS='-c search_path=app,public' ts-node -r tsconfig-paths/register src/migration.ts",
"db-migrate:prod": "PGOPTIONS='-c search_path=app,public' node -r tsconfig-paths/register dist/migration.js"
Expand Down Expand Up @@ -37,11 +39,12 @@
"@types/nodemailer": "^6.4.7",
"@typescript-eslint/eslint-plugin": "^5.55.0",
"@typescript-eslint/parser": "^5.55.0",
"concurrently": "^7.6.0",
"eslint": "^8.36.0",
"node-dev": "^8.0.0",
"pino-pretty": "^10.0.0",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.1.2",
"tsx": "^3.12.6",
"typescript": "^5.0.2",
"vite-tsconfig-paths": "^4.0.7",
"vitest": "^0.29.3"
Expand Down
2 changes: 1 addition & 1 deletion backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { getClient } from '@backend/oidc';
import { appRouter, createContext } from '@backend/router';

import { initializeTaskQueue } from './components/taskQueue';
import { setupMailQueue, startSendMailJob } from './components/taskQueue/mailQueue';
import { setupMailQueue } from './components/taskQueue/mailQueue';
import { setupReportQueue } from './components/taskQueue/reportQueue';

async function run() {
Expand Down
58 changes: 58 additions & 0 deletions backend/src/components/report/detailplanProject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { Workbook } from 'excel4node';
import { z } from 'zod';

import { getFilterFragment } from '@backend/components/project/search';
import { getPool, sql } from '@backend/db';
import { logger } from '@backend/logging';

import { translations } from '@shared/language';
import { dateStringSchema } from '@shared/schema/common';
import { ProjectSearch } from '@shared/schema/project';

import { buildSheet } from '.';

const projectReportFragment = sql.fragment`
SELECT
project.project_name AS "detailplanProjectName",
project_detailplan.detailplan_id AS "detailplanProjectDetailplanId",
(SELECT text_fi FROM app.code WHERE id = project_detailplan.subtype) AS "detailplanProjectSubtype",
project_detailplan.diary_id AS "detailplanProjectDiaryId",
(SELECT name FROM app.user WHERE id = project_detailplan.preparer) AS "detailplanProjectPreparer",
(SELECT name FROM app.user WHERE id = project_detailplan.technical_planner) AS "detailplanProjectTechnicalPlanner",
project_detailplan.district AS "detailplanProjectDistrict",
project_detailplan.block_name AS "detailplanProjectBlockName",
project_detailplan.address_text AS "detailplanProjectAddressText",
project_detailplan.initiative_date AS "detailplanProjectInitiativeDate"
FROM app.project_detailplan
INNER JOIN app.project ON (project_detailplan.id = project.id AND project.deleted IS FALSE)
`;

const reportRowSchema = z.object({
detailplanProjectDetailplanId: z.number(),
detailplanProjectSubtype: z.string().nullish(),
detailplanProjectDiaryId: z.string().nullish(),
detailplanProjectPreparer: z.string(),
detailplanProjectTechnicalPlanner: z.string().nullish(),
detailplanProjectDistrict: z.string(),
detailplanProjectBlockName: z.string(),
detailplanProjectAddressText: z.string(),
detailplanProjectName: z.string(),
detailplanProjectInitiativeDate: dateStringSchema.nullish(),
});

export async function buildDetailplanCatalogSheet(workbook: Workbook, searchParams: ProjectSearch) {
const reportQuery = sql.type(reportRowSchema)`
${projectReportFragment}
${getFilterFragment(searchParams)}
`;

const reportResult = await getPool().any(reportQuery);
logger.debug(`Fetched ${reportResult.length} rows for the detailplan catalog`);
const rows = z.array(reportRowSchema).parse(reportResult);

buildSheet({
workbook,
sheetTitle: translations['fi']['report.detailplanCatalog'],
rows,
});
}
98 changes: 98 additions & 0 deletions backend/src/components/report/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { Workbook } from 'excel4node';

import { getPool, sql } from '@backend/db';
import { logger } from '@backend/logging';

import { TranslationKey, translations } from '@shared/language';
import { ProjectSearch } from '@shared/schema/project';
import { Suffix } from '@shared/util-types';

import { buildDetailplanCatalogSheet } from './detailplanProject';
import { buildInvestmentProjectReportSheet } from './investmentProject';

type ReportColumnKey = Partial<Suffix<TranslationKey, 'report.columns.'>>;
type ReportFieldValue = string | number | Date | null;

/**
* Generic function for building a worksheet into a workbook with given rows.
*
* All row keys must be defined in localizations under the prefix `"report.columns."`.
*/
export function buildSheet<ColumnKey extends ReportColumnKey>({
workbook,
sheetTitle,
rows,
}: {
workbook: Workbook;
sheetTitle: string;
rows: { [field in ColumnKey]?: ReportFieldValue }[];
}) {
const headerStyle = workbook.createStyle({
font: {
bold: true,
},
});

const reportFields = Object.keys(rows[0]) as ReportColumnKey[];
const headers = reportFields.map((field) => translations['fi'][`report.columns.${field}`]);
const worksheet = workbook.addWorksheet(sheetTitle);

headers.forEach((header, index) => {
worksheet
.cell(1, index + 1)
.string(header)
.style(headerStyle);
});

rows.forEach((row, rowIndex) => {
Object.values<ReportFieldValue | undefined>(row).forEach((value, column) => {
// Skip empty values
if (value == null) {
return;
}

const cell = worksheet.cell(rowIndex + 2, column + 1);
if (value instanceof Date) {
cell.date(value);
} else if (typeof value === 'number') {
cell.number(value);
} else {
cell.string(value);
}
});
});
}

/**
* Builds a report for given search parameters. Saves it temporarily into the database for downloading.
* @param jobId Job ID
* @param searchParams Search parameters
*/
export async function buildReport(jobId: string, searchParams: ProjectSearch) {
try {
const workbook = new Workbook({
dateFormat: 'd.m.yyyy',
});

logger.debug(
`Running report queries for job ${jobId} with data: ${JSON.stringify(searchParams)}`
);

// Build each sheet in desired order
await buildInvestmentProjectReportSheet(workbook, searchParams);
await buildDetailplanCatalogSheet(workbook, searchParams);

const buffer = await workbook.writeToBuffer();

logger.debug(`Saving report to database, ${buffer.length} bytes...`);
const queryResult = await getPool().query(sql.untyped`
INSERT INTO app.report_file (pgboss_job_id, report_filename, report_data)
VALUES (${jobId}, 'raportti.xlsx', ${sql.binary(buffer)})
`);
logger.debug(`Report saved to database, ${queryResult.rowCount} rows affected.`);
} catch (error) {
// Log and rethrow the error to make the job state failed
logger.error(error);
throw error;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ import { getPool, sql } from '@backend/db';
import { logger } from '@backend/logging';

import { translations } from '@shared/language';
import { dateStringSchema, datetimeSchema } from '@shared/schema/common';
import { ProjectSearch } from '@shared/schema/project';

const dateStringSchema = z.string().transform((value) => new Date(value));
const datetimeSchema = z.number().transform((value) => new Date(value));
import { buildSheet } from '.';

const projectReportFragment = sql.fragment`
SELECT
Expand Down Expand Up @@ -69,67 +69,22 @@ const reportRowSchema = z.object({
projectObjectSAPWBSId: z.string().nullish(),
});

type ReportRow = z.infer<typeof reportRowSchema>;

async function dbResultToXlsx(rows: ReportRow[]) {
const reportFields = Object.keys(rows[0]) as (keyof ReportRow)[];
const headers = reportFields.map((field) => translations['fi'][`report.columns.${field}`]);

const workbook = new Workbook({
dateFormat: 'd.m.yyyy',
});
const worksheet = workbook.addWorksheet('Raportti');
const headerStyle = workbook.createStyle({
font: {
bold: true,
},
});

headers.forEach((header, index) => {
worksheet
.cell(1, index + 1)
.string(header)
.style(headerStyle);
});

rows.forEach((row, rowIndex) => {
Object.values(row).forEach((value, column) => {
// Skip empty values
if (value == null) {
return;
}

const cell = worksheet.cell(rowIndex + 2, column + 1);
if (value instanceof Date) {
cell.date(value);
} else {
cell.string(value);
}
});
});

return await workbook.writeToBuffer();
}
export async function buildProjectReport(jobId: string, data: ProjectSearch) {
export async function buildInvestmentProjectReportSheet(
workbook: Workbook,
searchParams: ProjectSearch
) {
const reportQuery = sql.type(reportRowSchema)`
${projectReportFragment}
${getFilterFragment(data)}
${getFilterFragment(searchParams)}
`;

try {
logger.debug(`Running report query for job ${jobId} with data: ${JSON.stringify(data)}`);
const reportResult = await getPool().any(reportQuery);
const reportRows = z.array(reportRowSchema).parse(reportResult);
const buffer = await dbResultToXlsx(reportRows);
logger.debug(`Saving report to database, ${buffer.length} bytes...`);
const queryResult = await getPool().query(sql.untyped`
INSERT INTO app.report_file (pgboss_job_id, report_filename, report_data)
VALUES (${jobId}, 'raportti.xlsx', ${sql.binary(buffer)})
`);
logger.debug(`Report saved to database, ${queryResult.rowCount} rows affected.`);
} catch (error) {
// Log and rethrow the error to make the job state failed
logger.error(error);
throw error;
}
const reportResult = await getPool().any(reportQuery);
logger.debug(`Fetched ${reportResult.length} rows for the investment project report`);
const rows = z.array(reportRowSchema).parse(reportResult);

buildSheet({
workbook,
sheetTitle: translations['fi']['report.investmentProjects'],
rows,
});
}
4 changes: 2 additions & 2 deletions backend/src/components/taskQueue/reportQueue.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { buildProjectReport } from '@backend/components/report/projectReport';
import { buildReport } from '@backend/components/report';
import { env } from '@backend/env';

import { ProjectSearch } from '@shared/schema/project';
Expand All @@ -15,7 +15,7 @@ export async function setupReportQueue() {
teamConcurrency: env.report.queueConcurrency,
},
async ({ id, data }) => {
await buildProjectReport(id, data);
await buildReport(id, data);
}
);
}
Expand Down
5 changes: 1 addition & 4 deletions backend/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,5 @@
"@shared/*": ["node_modules/tre-hanna-shared/src/*", "node_modules/tre-hanna-shared/dist/*"]
}
},
"include": ["types", "src"],
"ts-node": {
"files": true
}
"include": ["types", "src"]
}
13 changes: 13 additions & 0 deletions shared/src/language/fi.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@
"projectRelations.addParentRelation": "Lisää hankkeelle ylähanke",
"projectRelations.addChildRelation": "Lisää hankkeelle alahanke",
"projectRelations.addRelatedRelation": "Lisää hankkeelle rinnakkaishanke",
"report.investmentProjects": "Investointihankkeet",
"report.detailplanCatalog": "Asemakaavaluettelo",
"report.columns.projectName": "Hankkeen nimi",
"report.columns.projectId": "Hankkeen tunniste",
"report.columns.projectDescription": "Hankkeen kuvaus",
Expand All @@ -112,6 +114,17 @@
"report.columns.projectObjectLandownership": "Kohteen maanomistuslaji",
"report.columns.projectObjectLocationOnProperty": "Kohteen sijainti kiinteistöllä",
"report.columns.projectObjectSAPWBSId": "Kohteen SAP-rakenneosa",
"report.columns.detailplanProjectName": "Hankkeen nimi",
"report.columns.detailplanProjectId": "Hankkeen tunniste",
"report.columns.detailplanProjectDetailplanId": "Kaavan numero",
"report.columns.detailplanProjectSubtype": "Hankkeen tyyppi",
"report.columns.detailplanProjectDiaryId": "Diaaritunnus",
"report.columns.detailplanProjectPreparer": "Valmistelija",
"report.columns.detailplanProjectTechnicalPlanner": "Tekninen suunnittelija",
"report.columns.detailplanProjectDistrict": "Alue/kaupunginosa",
"report.columns.detailplanProjectBlockName": "Kortteli/tontti",
"report.columns.detailplanProjectAddressText": "Osoitteet",
"report.columns.detailplanProjectInitiativeDate": "Aloitepvm",
"projectDelete.delete": "Poista hanke",
"projectDelete.notifyDelete": "Hanke poistettu",
"projectDelete.notifyDeleteFailed": "Hankkeen poisto epäonnistui",
Expand Down
4 changes: 4 additions & 0 deletions shared/src/schema/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,7 @@ export const nonEmptyString = z
const isoDateStringRegex = /\d{4}-\d{2}-\d{2}/;

export const isoDateString = z.string().regex(isoDateStringRegex);

export const dateStringSchema = z.string().transform((value) => new Date(value));

export const datetimeSchema = z.number().transform((value) => new Date(value));
9 changes: 9 additions & 0 deletions shared/src/util-types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* Extracts string literal type with given suffix from given base string literal.
*
* Example:
* `Suffix<"a.foo", "a.bar", "b.baz", "a.">` = `"foo" | "bar"`
*/
export type Suffix<Base extends string, Prefix extends string> = Base extends `${Prefix}${infer R}`
? R
: never;

0 comments on commit 2fb97f8

Please sign in to comment.