From b85d3e9833f44b8de6db0f0d1dd23a2ad975d31a Mon Sep 17 00:00:00 2001 From: Gowtham M Date: Wed, 20 May 2026 12:49:14 +0530 Subject: [PATCH] GWM : annux file export --- app/controllers/export.controller.js | 35 ++ app/routes/routes.js | 101 ++++++ app/services/annexExport.service.js | 482 +++++++++++++++++++++++++++ 3 files changed, 618 insertions(+) create mode 100644 app/controllers/export.controller.js create mode 100644 app/services/annexExport.service.js diff --git a/app/controllers/export.controller.js b/app/controllers/export.controller.js new file mode 100644 index 0000000..8a44ad3 --- /dev/null +++ b/app/controllers/export.controller.js @@ -0,0 +1,35 @@ +const annexExportService = require("../services/annexExport.service"); +const logger = require("../services/logger"); + +const handleExport = async (req, res, type) => { + try { + const { years, status, emirate_id, establishment_id } = req.query; + + await annexExportService.exportAnnex(res, { + type, + years, + statuses: status, + emirate_id, + establishment_id, + }); + } catch (err) { + logger.error(err.message); + logger.error(`Stack trace: ${err.stack}`); + + const statusCode = err.statusCode || 500; + if (!res.headersSent) { + return res.status(statusCode).json({ + status: "failed", + message: err.message || "Internal server error", + }); + } + } +}; + +exports.exportAnnexII = (req, res) => handleExport(req, res, "annex_ii"); + +exports.exportAnnexIIIQuantity = (req, res) => + handleExport(req, res, "annex_iii_quantity"); + +exports.exportAnnexIIIValues = (req, res) => + handleExport(req, res, "annex_iii_values"); diff --git a/app/routes/routes.js b/app/routes/routes.js index 5e652e6..2fa06ee 100644 --- a/app/routes/routes.js +++ b/app/routes/routes.js @@ -20,6 +20,7 @@ const quarterlyWindowsController = require("../controllers/quarterlyWindowsConfi const calculationController = require("../controllers/calculation.controller"); const ManufacturingIpiController = require("../controllers/manufacturingIPI.controller") const testController = require("../controllers/test.controller"); +const exportController = require("../controllers/export.controller"); const fs = require("fs"); const path = require("path"); const multer = require("multer"); @@ -2325,6 +2326,106 @@ router.get("/submissions/history/:establishment_id",[verifySignature, verifyToke */ router.get("/submissions",[verifySignature, verifyToken], submissionController.submissionList); +/** + * @swagger + * /api/exports/annex-ii: + * get: + * summary: Export Annex II establishment quarterly data (quantity and value) + * tags: [Exports] + * security: + * - appSignature: [] + * - bearerAuth: [] + * parameters: + * - in: query + * name: years + * schema: { type: string, example: "2022,2025,2026" } + * description: Comma-separated years to include (defaults to all years with data) + * - in: query + * name: status + * schema: { type: string, example: "Approved" } + * description: Comma-separated submission statuses (default Approved,Submitted,Resubmitted) + * - in: query + * name: emirate_id + * schema: { type: integer } + * - in: query + * name: establishment_id + * schema: { type: integer } + * responses: + * 200: + * description: Excel file download + * 404: + * description: No data found + */ +router.get( + "/exports/annex-ii", + [verifySignature, verifyToken], + exportController.exportAnnexII +); + +/** + * @swagger + * /api/exports/annex-iii-quantity: + * get: + * summary: Export Annex III item-level quantity data + * tags: [Exports] + * security: + * - appSignature: [] + * - bearerAuth: [] + * parameters: + * - in: query + * name: years + * schema: { type: string, example: "2022,2025" } + * - in: query + * name: status + * schema: { type: string, example: "Approved" } + * - in: query + * name: emirate_id + * schema: { type: integer } + * - in: query + * name: establishment_id + * schema: { type: integer } + * responses: + * 200: + * description: Excel file download + */ +router.get( + "/exports/annex-iii-quantity", + [verifySignature, verifyToken], + exportController.exportAnnexIIIQuantity +); + +/** + * @swagger + * /api/exports/annex-iii-values: + * get: + * summary: Export Annex III item-level value (cost) data + * tags: [Exports] + * security: + * - appSignature: [] + * - bearerAuth: [] + * parameters: + * - in: query + * name: years + * schema: { type: string, example: "2022,2025" } + * - in: query + * name: status + * schema: { type: string, example: "Approved" } + * - in: query + * name: emirate_id + * schema: { type: integer } + * - in: query + * name: establishment_id + * schema: { type: integer } + * responses: + * 200: + * description: Excel file download + */ +router.get( + "/exports/annex-iii-values", + [verifySignature, verifyToken], + exportController.exportAnnexIIIValues +); + /** * @swagger * /api/submissions/view/{id}: diff --git a/app/services/annexExport.service.js b/app/services/annexExport.service.js new file mode 100644 index 0000000..c07f8a4 --- /dev/null +++ b/app/services/annexExport.service.js @@ -0,0 +1,482 @@ +const ExcelJS = require("exceljs"); +const db = require("../models"); +const { Op } = require("sequelize"); + +const Submission = db.Submission; +const SubmissionProduct = db.SubmissionProduct; +const Establishment = db.Establishment; +const Product = db.Product; +const UnitMaster = db.UnitMaster; + +const QUARTERS = ["Q1", "Q2", "Q3", "Q4"]; +const BASE_YEAR = Number(process.env.BASE_YEAR) || 2022; + +const parseYears = (yearsParam) => { + if (!yearsParam) return null; + const years = String(yearsParam) + .split(",") + .map((y) => parseInt(y.trim(), 10)) + .filter((y) => !Number.isNaN(y)); + return years.length ? years : null; +}; + +const parseStatuses = (statusParam) => { + if (!statusParam) return ["Approved", "Submitted", "Resubmitted"]; + return String(statusParam) + .split(",") + .map((s) => s.trim()) + .filter(Boolean); +}; + +const parseNumber = (value) => { + if (value === null || value === undefined || value === "") return null; + const num = Number(value); + return Number.isNaN(num) ? null : num; +}; + +const sanitizeUom = (uom) => { + if (uom === null || uom === undefined) return ""; + const trimmed = String(uom).trim(); + if (!trimmed || trimmed.toLowerCase() === "not defined") return ""; + return trimmed; +}; + +const joinUnique = (set) => + [...set].map(sanitizeUom).filter(Boolean).sort().join(", "); + +const hasPeriodData = (cell) => { + if (!cell) return false; + return cell.quantity !== null || cell.value !== null; +}; + +const addPeriodValues = (target, source) => { + if (!source) return; + if (source.quantity !== null) { + target.quantity = (target.quantity ?? 0) + source.quantity; + } + if (source.value !== null) { + target.value = (target.value ?? 0) + source.value; + } +}; + +const aggregateRowsByHsCode = (rawRows) => { + const hsMap = new Map(); + + for (const row of rawRows) { + const hsKey = String(row.hs_code); + if (!hsMap.has(hsKey)) { + hsMap.set(hsKey, { + hs_code: row.hs_code, + establishments: new Set(), + units: new Set(), + periods: {}, + }); + } + + const entry = hsMap.get(hsKey); + const rowHasData = Object.values(row.periods).some(hasPeriodData); + if (row.establishment && rowHasData) entry.establishments.add(row.establishment); + if (row.unit && rowHasData) entry.units.add(row.unit); + + for (const [periodKey, cell] of Object.entries(row.periods)) { + if (!hasPeriodData(cell)) continue; + if (!entry.periods[periodKey]) { + entry.periods[periodKey] = { quantity: null, value: null }; + } + addPeriodValues(entry.periods[periodKey], cell); + } + } + + return [...hsMap.values()] + .map((entry) => ({ + establishment: joinUnique(entry.establishments), + hs_code: entry.hs_code, + unit: joinUnique(entry.units), + periods: entry.periods, + })) + .sort((a, b) => Number(a.hs_code) - Number(b.hs_code)); +}; + +const yearHasData = (year, rows) => { + const suffix = `-${year}`; + return rows.some((row) => + Object.entries(row.periods).some( + ([key, cell]) => key.endsWith(suffix) && hasPeriodData(cell) + ) + ); +}; + +const resolveExportYears = (discoveredYears, rows, requestedYears) => { + const yearsWithData = new Set(); + + for (const year of discoveredYears) { + if (yearHasData(year, rows)) yearsWithData.add(year); + } + + if (requestedYears?.length) { + for (const year of requestedYears) { + if (yearHasData(year, rows)) yearsWithData.add(year); + } + } + + yearsWithData.add(BASE_YEAR); + + const otherYears = [...yearsWithData] + .filter((y) => y !== BASE_YEAR) + .sort((a, b) => a - b); + + return [BASE_YEAR, ...otherYears].filter( + (year, index, arr) => arr.indexOf(year) === index + ); +}; + +const buildAnnexIIPeriods = (years, rows) => { + const periods = []; + + for (const year of years) { + if (year === BASE_YEAR) { + for (const quarter of QUARTERS) { + periods.push(`${quarter}-${year}`); + } + continue; + } + + for (const quarter of QUARTERS) { + const periodKey = `${quarter}-${year}`; + const hasData = rows.some((row) => hasPeriodData(row.periods[periodKey])); + if (hasData) periods.push(periodKey); + } + } + + return periods; +}; + +const buildAnnexIIYearPeriods = (years, rows) => { + const result = []; + for (const year of years) { + if (year === BASE_YEAR) { + result.push({ year, quarters: [...QUARTERS] }); + continue; + } + const quarters = QUARTERS.filter((q) => + rows.some((row) => hasPeriodData(row.periods[`${q}-${year}`])) + ); + if (quarters.length) result.push({ year, quarters }); + } + return result; +}; + +const styleHeaderRow = (row) => { + row.eachCell((cell) => { + cell.font = { bold: true }; + cell.alignment = { horizontal: "center", vertical: "middle", wrapText: true }; + cell.border = { + top: { style: "thin" }, + left: { style: "thin" }, + bottom: { style: "thin" }, + right: { style: "thin" }, + }; + }); +}; + +const setExcelResponseHeaders = (res, filename) => { + res.setHeader("X-Frame-Options", "DENY"); + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader( + "Content-Type", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ); + res.setHeader("Content-Disposition", `attachment; filename=${filename}`); +}; + +const fetchExportRows = async ({ years, statuses, emirate_id, establishment_id }) => { + const submissionWhere = { + status: { [Op.in]: statuses }, + }; + + if (years?.length) { + submissionWhere.year = { [Op.in]: years }; + } + + const establishmentWhere = {}; + if (emirate_id) { + establishmentWhere.establishment_emirate_id = emirate_id; + } + if (establishment_id) { + establishmentWhere.id = establishment_id; + } + + const submissions = await Submission.findAll({ + where: submissionWhere, + include: [ + { + model: Establishment, + as: "establishment", + attributes: ["id", "establishment_code", "factory_name"], + where: Object.keys(establishmentWhere).length ? establishmentWhere : undefined, + }, + { + model: SubmissionProduct, + as: "products", + where: { is_active: true }, + required: true, + include: [ + { + model: Product, + as: "product", + attributes: ["id", "hs_code", "product_name", "unit_id"], + include: [ + { + model: UnitMaster, + as: "unit", + attributes: ["uom"], + }, + ], + }, + ], + }, + ], + order: [ + ["year", "ASC"], + ["quarter", "ASC"], + ["id", "DESC"], + ], + }); + + const rawRows = []; + const discoveredYears = new Set(); + + for (const submission of submissions) { + const establishmentCode = + submission.establishment?.establishment_code || + submission.establishment?.factory_name || + `EST-${submission.establishment_id}`; + + const periodKey = `${submission.quarter}-${submission.year}`; + discoveredYears.add(submission.year); + + for (const sp of submission.products) { + const hsCode = sp.product?.hs_code; + if (!hsCode) continue; + + const unit = sanitizeUom(sp.product?.unit?.uom); + rawRows.push({ + establishment: establishmentCode, + hs_code: hsCode, + unit, + periods: { + [periodKey]: { + quantity: parseNumber(sp.current_quantity), + value: parseNumber(sp.current_cost), + }, + }, + }); + } + } + + const rows = aggregateRowsByHsCode(rawRows); + const resolvedYears = resolveExportYears( + [...discoveredYears], + rows, + years + ); + + return { rows, years: resolvedYears }; +}; + +const buildAnnexIIWorkbook = (rows, years) => { + const workbook = new ExcelJS.Workbook(); + const worksheet = workbook.addWorksheet("Sheet1"); + const periods = buildAnnexIIPeriods(years, rows); + + const headerRow1 = ["Establishment", "Product \n(HS 8Digit)", "Unit"]; + const headerRow2 = ["", "", ""]; + + for (const period of periods) { + headerRow1.push(period); + headerRow1.push(""); + headerRow2.push("Quantity", "Value"); + } + + worksheet.addRow(headerRow1); + worksheet.addRow(headerRow2); + + worksheet.mergeCells(1, 1, 2, 1); + worksheet.mergeCells(1, 2, 2, 2); + worksheet.mergeCells(1, 3, 2, 3); + + let colIndex = 4; + for (const period of periods) { + worksheet.mergeCells(1, colIndex, 1, colIndex + 1); + worksheet.getCell(1, colIndex).value = period; + colIndex += 2; + } + + styleHeaderRow(worksheet.getRow(1)); + styleHeaderRow(worksheet.getRow(2)); + + worksheet.getColumn(1).width = 24; + worksheet.getColumn(2).width = 18; + worksheet.getColumn(3).width = 20; + + for (const row of rows) { + const values = [row.establishment, row.hs_code, row.unit]; + for (const period of periods) { + const cell = row.periods[period] || {}; + values.push(cell.quantity ?? null, cell.value ?? null); + } + const excelRow = worksheet.addRow(values); + for (let c = 4; c < values.length; c += 2) { + const qtyCell = excelRow.getCell(c); + const valCell = excelRow.getCell(c + 1); + if (qtyCell.value !== null) qtyCell.numFmt = "#,##0.##"; + if (valCell.value !== null) valCell.numFmt = "#,##0.##"; + } + } + + return workbook; +}; + +const buildAnnexIIIColumns = (years, rows) => { + const columns = []; + for (const year of years) { + const quarters = + year === BASE_YEAR + ? [...QUARTERS] + : QUARTERS.filter((q) => + rows.some((row) => hasPeriodData(row.periods[`${q}-${year}`])) + ); + for (const quarter of quarters) { + columns.push({ + year, + quarter, + periodKey: `${quarter}-${year}`, + }); + } + } + return columns; +}; + +const buildAnnexIIIWorkbook = (rows, years, metric) => { + const workbook = new ExcelJS.Workbook(); + const worksheet = workbook.addWorksheet("Sheet1"); + const isQuantity = metric === "quantity"; + const columns = buildAnnexIIIColumns(years, rows); + + const yearRow = ["Establishment ", "Product(HS-8D)", "Unit"]; + const quarterRow = ["", "", ""]; + + for (const col of columns) { + yearRow.push(""); + quarterRow.push(col.quarter); + } + + worksheet.addRow(yearRow); + worksheet.addRow(quarterRow); + + worksheet.mergeCells(1, 1, 2, 1); + worksheet.mergeCells(1, 2, 2, 2); + worksheet.mergeCells(1, 3, 2, 3); + + let colIndex = 4; + let i = 0; + while (i < columns.length) { + const year = columns[i].year; + const yearLabel = + year === BASE_YEAR ? `BASE YEAR ${year}` : `AND ${year}`; + let j = i; + while (j < columns.length && columns[j].year === year) j++; + const span = j - i; + + worksheet.getCell(1, colIndex).value = yearLabel; + if (span > 1) { + worksheet.mergeCells(1, colIndex, 1, colIndex + span - 1); + } + + colIndex += span; + i = j; + } + + styleHeaderRow(worksheet.getRow(1)); + styleHeaderRow(worksheet.getRow(2)); + + worksheet.getColumn(1).width = 24; + worksheet.getColumn(2).width = 18; + worksheet.getColumn(3).width = 20; + for (let c = 4; c < 4 + columns.length; c++) { + worksheet.getColumn(c).width = 12; + } + + for (const row of rows) { + const values = [ + row.establishment, + row.hs_code, + isQuantity ? row.unit : "AED", + ]; + + for (const col of columns) { + const cell = row.periods[col.periodKey] || {}; + values.push(isQuantity ? (cell.quantity ?? null) : (cell.value ?? null)); + } + + const excelRow = worksheet.addRow(values); + for (let c = 4; c < 4 + columns.length; c++) { + const dataCell = excelRow.getCell(c); + if (dataCell.value !== null) dataCell.numFmt = "#,##0.##"; + } + } + + return workbook; +}; + +const exportAnnex = async (res, { type, years, statuses, emirate_id, establishment_id }) => { + const parsedYears = parseYears(years); + const parsedStatuses = parseStatuses(statuses); + + const { rows, years: resolvedYears } = await fetchExportRows({ + years: parsedYears, + statuses: parsedStatuses, + emirate_id, + establishment_id, + }); + + if (!rows.length) { + const err = new Error("No submission data found for the given filters"); + err.statusCode = 404; + throw err; + } + + let workbook; + let filename; + + if (type === "annex_ii") { + workbook = buildAnnexIIWorkbook(rows, resolvedYears); + filename = `Annex_II_Establishment_Quarterly_${Date.now()}.xlsx`; + } else if (type === "annex_iii_quantity") { + workbook = buildAnnexIIIWorkbook(rows, resolvedYears, "quantity"); + filename = `Annex_III_Item_Level_Quantity_${Date.now()}.xlsx`; + } else if (type === "annex_iii_values") { + workbook = buildAnnexIIIWorkbook(rows, resolvedYears, "value"); + filename = `Annex_III_Item_Level_Values_${Date.now()}.xlsx`; + } else { + const err = new Error("Invalid export type"); + err.statusCode = 400; + throw err; + } + + setExcelResponseHeaders(res, filename); + await workbook.xlsx.write(res); + res.end(); +}; + +module.exports = { + exportAnnex, + parseYears, + parseStatuses, + fetchExportRows, + aggregateRowsByHsCode, + resolveExportYears, + buildAnnexIIWorkbook, + buildAnnexIIIWorkbook, + BASE_YEAR, +};