This commit is contained in:
Sergey Yarkov 2026-03-02 16:30:21 +03:00
parent 8c5d52926d
commit 7529760dfb
11 changed files with 250 additions and 66 deletions

View File

@ -1,3 +1,3 @@
import { AsyncLocalStorage } from "node:async_hooks"; import { AsyncLocalStorage } from "node:async_hooks";
export const httpStorage = new AsyncLocalStorage(); export const ctxStorage = new AsyncLocalStorage();

6
db.js
View File

@ -5,12 +5,12 @@ const connectionConfig = {
user: process.env.MSSQL_USER, user: process.env.MSSQL_USER,
password: process.env.MSSQL_PASSWORD, password: process.env.MSSQL_PASSWORD,
database: process.env.MSSQL_DB, database: process.env.MSSQL_DB,
server: process.env.MSSQL_HOST, server: process.env.MSSQL_HOST || "localhost",
port: Number.parseInt(process.env.MSSQL_PORT, 10) || 1433, port: Number.parseInt(process.env.MSSQL_PORT || "1433", 10),
pool: { pool: {
max: 10, max: 10,
min: 0, min: 0,
idleTimeoutMillis: 5000 idleTimeoutMillis: 30000
}, },
options: { options: {
trustServerCertificate: true trustServerCertificate: true

37
errors/index.js Normal file
View File

@ -0,0 +1,37 @@
export class HttpError extends Error {
/**
* @param {{ name?: string, message: string, statusCode: number }} msg
*/
constructor({ name, message, statusCode = 500 }) {
super(message);
this.name = name || this.constructor.name;
this.statusCode = statusCode;
Error.captureStackTrace(this);
}
}
export class BadRequestError extends HttpError {
/**
* @param {string} [message]
*/
constructor(message) {
super({
name: "E_BAD_REQUEST",
message: message || "Bad Request",
statusCode: 400
});
}
}
export class NotFoundError extends HttpError {
/**
* @param {string} [message]
*/
constructor(message) {
super({
name: "E_NOT_FOUND",
message: message || "Not found",
statusCode: 404
});
}
}

1
index.d.ts vendored
View File

@ -19,6 +19,7 @@ declare global {
type HTTPResponse = { type HTTPResponse = {
sendJson: (data: Record<string, unknown>, statusCode?: number = 200) => http.ServerResponse; sendJson: (data: Record<string, unknown>, statusCode?: number = 200) => http.ServerResponse;
sendSSEJson: (data: Record<string, unknown>, eventName: string, statusCode?: number = 200) => void;
sendText: (text: string, statusCode?: number = 200) => http.ServerResponse; sendText: (text: string, statusCode?: number = 200) => http.ServerResponse;
res: http.ServerResponse; res: http.ServerResponse;
}; };

View File

@ -1,31 +1,26 @@
import http from "node:http"; import http from "node:http";
import { routeHandler } from "./router/index.js"; import { routeHandler } from "./router/index.js";
import packageJson from "./package.json" with { type: "json" }; import packageJson from "./package.json" with { type: "json" };
import { httpStorage } from "./context.js"; import { ctxStorage } from "./context.js";
// import db from "./db.js"; import db from "./db.js";
import response from "./response.js"; import { createResponse } from "./response.js";
import request from "./request.js"; import { createRequest } from "./request.js";
const SERVER_PORT = 3000; const SERVER_PORT = 3000;
// await db.connect().then(() => console.log("Database connected!")); function application() {
const server = http.createServer((req, res) => {
http
.createServer((req, res) => {
try { try {
const url = new URL(`http://localhost:${SERVER_PORT}${req.url}`); const url = new URL(`http://localhost:${SERVER_PORT}${req.url}`);
/** @type {HTTPContext} */ /** @type {HTTPContext} */
const ctx = { const ctx = {
data: null, data: null,
req: Object.create(request), req: createRequest(req),
res: Object.create(response) res: createResponse(res)
}; };
ctx.req.req = req; ctxStorage.run(ctx, () =>
ctx.res.res = res;
httpStorage.run(ctx, () =>
routeHandler(url).then(() => { routeHandler(url).then(() => {
console.log(`${new Date().toISOString()} Request: ${url.pathname}`); console.log(`${new Date().toISOString()} Request: ${url.pathname}`);
}) })
@ -33,7 +28,19 @@ http
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }
})
.listen(SERVER_PORT, () => {
console.log(`Service ${packageJson.name} is running at port ${SERVER_PORT}.`);
}); });
return {
/**
* @param {number} port
*/
async run(port = 3000) {
await db.connect().then(() => console.log("Database connected!"));
server.listen(port, () => {
console.log(`Service ${packageJson.name} is running at port ${SERVER_PORT}.`);
});
}
};
}
application().run(SERVER_PORT);

56
package-lock.json generated
View File

@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"formidable": "^3.5.4", "formidable": "^3.5.4",
"mssql": "^12.2.0", "mssql": "^12.2.0",
"serialize-error": "^13.0.1",
"xlsx": "^0.18.5", "xlsx": "^0.18.5",
"yup": "^1.7.1" "yup": "^1.7.1"
}, },
@ -1612,6 +1613,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/non-error": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/non-error/-/non-error-0.1.0.tgz",
"integrity": "sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/once": { "node_modules/once": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@ -1839,6 +1852,37 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/serialize-error": {
"version": "13.0.1",
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-13.0.1.tgz",
"integrity": "sha512-bBZaRwLH9PN5HbLCjPId4dP5bNGEtumcErgOX952IsvOhVPrm3/AeK1y0UHA/QaPG701eg0yEnOKsCOC6X/kaA==",
"license": "MIT",
"dependencies": {
"non-error": "^0.1.0",
"type-fest": "^5.4.1"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/serialize-error/node_modules/type-fest": {
"version": "5.4.4",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.4.4.tgz",
"integrity": "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==",
"license": "(MIT OR CC0-1.0)",
"dependencies": {
"tagged-tag": "^1.0.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -1905,6 +1949,18 @@
"url": "https://opencollective.com/synckit" "url": "https://opencollective.com/synckit"
} }
}, },
"node_modules/tagged-tag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
"integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/tarn": { "node_modules/tarn": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/tarn/-/tarn-3.0.2.tgz", "resolved": "https://registry.npmjs.org/tarn/-/tarn-3.0.2.tgz",

View File

@ -15,6 +15,7 @@
"dependencies": { "dependencies": {
"formidable": "^3.5.4", "formidable": "^3.5.4",
"mssql": "^12.2.0", "mssql": "^12.2.0",
"serialize-error": "^13.0.1",
"xlsx": "^0.18.5", "xlsx": "^0.18.5",
"yup": "^1.7.1" "yup": "^1.7.1"
}, },

View File

@ -1,19 +1,29 @@
/** @type {HTTPRequest} */ /**
export default { * @param {import('node:http').IncomingMessage} req
req: null, * @returns {HTTPRequest}
async parseJson() { */
return new Promise((resolve, reject) => { export function createRequest(req) {
let body = []; return {
this.req.on("data", (chunk) => body.push(chunk)); get req() {
this.req.on("end", () => { return req;
try { },
const parsedJson = JSON.parse(Buffer.concat(body).toString());
resolve(parsedJson); async parseJson() {
} catch (error) { return new Promise((resolve, reject) => {
reject(error); /** @type {Buffer[]} */
} const body = [];
this.req.on("data", (chunk) => body.push(chunk));
this.req.on("end", () => {
try {
const parsedJson = JSON.parse(Buffer.concat(body).toString());
resolve(parsedJson);
} catch (error) {
reject(error);
}
});
this.req.on("error", (err) => reject(err));
}); });
this.req.on("error", (err) => reject(err)); }
}); };
} }
};

View File

@ -1,14 +1,29 @@
/** @type {HTTPResponse} */ /**
export default { * @param {import('node:http').ServerResponse} res
res: null, * @returns {HTTPResponse}
sendJson(data, statusCode = 200) { */
this.res.writeHead(statusCode, { "content-type": "application/json" }); export function createResponse(res) {
return this.res.end(JSON.stringify(data, null, 2)); return {
}, get res() {
sendText(text, statusCode = 200) { return res;
this.res.writeHead(statusCode, { },
"content-type": "text/plain; charset=utf-8"
}); sendSSEJson(data, eventName) {
return this.res.end(text); this.res.write(`event: ${eventName}\n`);
} this.res.write(`data: ${JSON.stringify(data)}`);
}; this.res.write("\n\n");
},
sendJson(data, statusCode = 200) {
this.res.writeHead(statusCode, { "content-type": "application/json" });
return this.res.end(JSON.stringify(data, null, 2));
},
sendText(text, statusCode = 200) {
this.res.writeHead(statusCode, {
"content-type": "text/plain; charset=utf-8"
});
return this.res.end(text);
}
};
}

View File

@ -1,7 +1,9 @@
import path from "node:path"; import path from "node:path";
import formidable from "formidable"; import formidable from "formidable";
import { httpStorage } from "#root/context.js"; import { ctxStorage } from "#root/context.js";
import { handlers as reportsHandlers } from "./reports/index.js"; import { handlers as reportsHandlers } from "./reports/index.js";
import { BadRequestError, HttpError, NotFoundError } from "#root/errors/index.js";
import { serializeError } from "serialize-error";
/** /**
* @type {HTTPRouteMap} * @type {HTTPRouteMap}
@ -16,7 +18,7 @@ export const ROUTE_MAP = {
filename: (_, ext) => `gpp-reports${ext}`, filename: (_, ext) => `gpp-reports${ext}`,
keepExtensions: true, keepExtensions: true,
uploadDir: path.join(process.cwd(), "./uploads"), uploadDir: path.join(process.cwd(), "./uploads"),
filter: ({ mimetype }) => mimetype && mimetype.includes("application/vnd.ms-excel") filter: ({ mimetype }) => !!mimetype && mimetype.includes("application/vnd.ms-excel")
}) })
}, },
"/api/reports_sync": { "/api/reports_sync": {
@ -30,7 +32,7 @@ export const ROUTE_MAP = {
*/ */
export async function routeHandler(url) { export async function routeHandler(url) {
/** @type {HTTPContext} */ /** @type {HTTPContext} */
const ctx = httpStorage.getStore(); const ctx = ctxStorage.getStore();
const { req, res } = ctx; const { req, res } = ctx;
try { try {
@ -42,28 +44,47 @@ export async function routeHandler(url) {
} }
if (req.req.headers["content-type"]?.includes("multipart/form-data")) { if (req.req.headers["content-type"]?.includes("multipart/form-data")) {
if (!route.incomingForm) throw new Error("Request cannot be processed without defined incoming form."); if (!route.incomingForm) {
throw new BadRequestError("Request cannot be processed without defined incoming form.");
}
ctx.data = await route.incomingForm.parse(req.req); ctx.data = await route.incomingForm.parse(req.req);
} }
if (route.method === req.req.method) { if (route.method === req.req.method) {
await route.handle(ctx); await route.handle(ctx);
return;
} }
}
throw new NotFoundError("Route not found");
} catch (error) {
console.error(error);
if (error instanceof HttpError) {
res.sendJson(
{
error: {
name: error.name,
message: error.message || "Internal Server Error",
stack: error.stack || null
}
},
error.statusCode || 500
);
return; return;
} }
res.sendJson({ error: { message: "Route not found." } }, 404); const serializedError = serializeError(error);
} catch (error) {
console.error(error);
res.sendJson( res.sendJson(
{ {
error: { error: {
message: error?.message || "Internal Server Error", ...serializedError,
stack: error?.stack || null stack: process.env.NODE_ENV !== "production" ? serializedError.stack : undefined
} }
}, },
error?.cause?.statusCode || 500 500
); );
} }
} }

View File

@ -1,3 +1,5 @@
import { BadRequestError } from "#root/errors/index.js";
import db from "#root/db.js";
import xlsx from "xlsx"; import xlsx from "xlsx";
const sheetName = "Отчеты"; const sheetName = "Отчеты";
@ -12,18 +14,34 @@ const xlsxParseOptions = {
}; };
/** /**
* Читает Excel файл отчетов и возвращает его в формате JSON * URL: /api/reports_excel_file_parse
* Method: POST
* Description: Читает Excel файл отчетов и возвращает его в формате JSON. Принимает на вход файл
* отчетов в формате Excel.
* *
* @type {HTTPRouteHandler} * @type {HTTPRouteHandler}
*/ */
export default async (ctx) => { export default async (ctx) => {
const { res } = ctx; const { res } = ctx;
if (!ctx.data) throw new BadRequestError("Cannot proceed uploaded file");
const [, files] = ctx.data; const [, files] = ctx.data;
const excelFilePath = files?.report?.at(0)?.filepath; const excelFilePath = files?.report?.at(0)?.filepath;
/** @type {{ markedReports: ReportModel[], reports: ReportModel[] }} */ /** @type {{ markedReports: ReportModel[], reports: ReportModel[] }} */
const ret = { reports: [], markedReports: [] }; const ret = { reports: [], markedReports: [] };
let foundEqupments = 0;
ctx.res.res.setHeaders(
new Headers({
Connection: "keep-alive",
"Cache-Control": "no-cache",
"Content-Type": "text/event-stream"
})
);
if (excelFilePath) { if (excelFilePath) {
const workbook = xlsx.readFile(excelFilePath, xlsxParseOptions); const workbook = xlsx.readFile(excelFilePath, xlsxParseOptions);
const sheet = workbook.Sheets[sheetName]; const sheet = workbook.Sheets[sheetName];
@ -63,11 +81,29 @@ export default async (ctx) => {
executorNames: parseNames(data[i][cols[4]]) executorNames: parseNames(data[i][cols[4]])
}); });
} }
/** @param {ReportModel} report */
const checkReportAssigments = async (report) => {
const equipment = await db
.request()
.query(`select id, name from dbo.asu_system_api_subsystemlist where name like N'%${report.equipment}%';`);
if (equipment.recordset.length !== 0) foundEqupments++;
res.sendSSEJson(report, "reportParseProgress");
return equipment;
};
console.log("Total reports:", ret.reports.concat(ret.markedReports).length);
await Promise.allSettled(ret.reports.concat(ret.markedReports).map((r) => checkReportAssigments(r)));
ctx.res.sendSSEJson({ foundEqupments }, "reportParseDone");
console.log("Parse done");
} else { } else {
throw new Error("Bad request", { cause: { statusCode: 400 } }); throw new BadRequestError();
} }
res.sendJson({ data: ret }); res.res.end();
}; };
function parseReasonCallAndJobDesc(str = "") { function parseReasonCallAndJobDesc(str = "") {