mirror of
https://github.com/sergeyyarkov/asu_tools_service.git
synced 2026-10-11 02:19:09 +03:00
refactor & added parse status
This commit is contained in:
parent
38ca3af0a1
commit
821ee79873
@ -1,3 +0,0 @@
|
|||||||
import { AsyncLocalStorage } from "node:async_hooks";
|
|
||||||
|
|
||||||
export const ctxStorage = new AsyncLocalStorage();
|
|
||||||
1
handlers/index.js
Normal file
1
handlers/index.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./reports/index.js";
|
||||||
5
handlers/reports/index.js
Normal file
5
handlers/reports/index.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import parseExcelHandler from "./parseExcel.handler.js";
|
||||||
|
import syncParsedHandler from "./syncParsed.handler.js";
|
||||||
|
import statusParseHandler from "./statusParse.handler.js";
|
||||||
|
|
||||||
|
export const reportsHandlers = { parseExcelHandler, statusParseHandler, syncParsedHandler };
|
||||||
216
handlers/reports/parseExcel.handler.js
Normal file
216
handlers/reports/parseExcel.handler.js
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
import { BadRequestError, ServiceUnavailableError } from "#root/http-server/index.js";
|
||||||
|
import db from "#root/db.js";
|
||||||
|
import xlsx from "xlsx";
|
||||||
|
import pLimit from "p-limit";
|
||||||
|
import { clients } from "#root/index.js";
|
||||||
|
import { serializeError } from "serialize-error";
|
||||||
|
|
||||||
|
const sheetName = "Отчеты";
|
||||||
|
const limit = pLimit(10);
|
||||||
|
|
||||||
|
/** @type {xlsx.ParsingOptions} */
|
||||||
|
const xlsxParseOptions = {
|
||||||
|
sheets: sheetName,
|
||||||
|
cellHTML: false,
|
||||||
|
cellFormula: false,
|
||||||
|
cellDates: true,
|
||||||
|
dateNF: "yyyy-mm-dd"
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL: /api/reports_excel_file_parse
|
||||||
|
* Method: POST
|
||||||
|
* Description: Принимает на вход файл отчетов в формате Excel, парсит его, проверяет связи с БД,
|
||||||
|
* отдает идентификатор на статус парсинга.
|
||||||
|
*
|
||||||
|
* @type {import("#root/http-server/types/http-server.js").HttpRouteHandler}
|
||||||
|
*/
|
||||||
|
export default async (ctx) => {
|
||||||
|
const { res, params } = ctx;
|
||||||
|
const clientId = params.get("clientId");
|
||||||
|
|
||||||
|
if (!ctx.data) throw new BadRequestError("Cannot proceed uploaded file");
|
||||||
|
if (!clientId) throw new BadRequestError('"clientId" parameter is required');
|
||||||
|
|
||||||
|
const [, files] = ctx.data;
|
||||||
|
const excelFilePath = files?.report?.at(0)?.filepath;
|
||||||
|
const sseClientRes = clients.get(clientId);
|
||||||
|
|
||||||
|
if (!sseClientRes) throw new ServiceUnavailableError("Cannot proceed request due to unknown client");
|
||||||
|
|
||||||
|
let isSseClientClosed = false;
|
||||||
|
|
||||||
|
sseClientRes.res.on("close", () => (isSseClientClosed = true));
|
||||||
|
|
||||||
|
/** @type {ReportModel[]} */
|
||||||
|
const reports = [];
|
||||||
|
|
||||||
|
let parseResultCount = { equipments: 0, applicants: 0, executors: 0, reportsFullfilled: 0 };
|
||||||
|
|
||||||
|
// res.res.setHeaders(
|
||||||
|
// new Headers({
|
||||||
|
// Connection: "keep-alive",
|
||||||
|
// "Cache-Control": "no-cache",
|
||||||
|
// "Content-Type": "text/event-stream"
|
||||||
|
// })
|
||||||
|
// );
|
||||||
|
|
||||||
|
// ctx.res.res.on("close", () => (isReqClosed = true));
|
||||||
|
|
||||||
|
if (excelFilePath) {
|
||||||
|
const workbook = xlsx.readFile(excelFilePath, xlsxParseOptions);
|
||||||
|
const sheet = workbook.Sheets[sheetName];
|
||||||
|
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
if (!sheet) throw new Error("Invalid reports workbook.", { cause: { statusCode: 400 } });
|
||||||
|
|
||||||
|
res.sendJson({ message: "OK" });
|
||||||
|
|
||||||
|
const data = xlsx.utils.sheet_to_json(sheet, { raw: false, blankrows: true });
|
||||||
|
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
const cols = Object.keys(data[i]);
|
||||||
|
|
||||||
|
if (i === 0) continue;
|
||||||
|
|
||||||
|
/** Несинхронизированные помеченные отчеты */
|
||||||
|
if ("__EMPTY_3" in data[i] && data[i]["__EMPTY_3"] === "r") {
|
||||||
|
reports.push({
|
||||||
|
date: data[i][cols[0]],
|
||||||
|
equipment: data[i][cols[1]],
|
||||||
|
reason_call: data[i][cols[3]].split("\r\r\n")[1] || "",
|
||||||
|
job_description: data[i][cols[4]].split("\r\r\n")[1] || "",
|
||||||
|
root_cause: data[i]["__EMPTY_2"] || "",
|
||||||
|
applicantName: data[i][cols[3]].split("\r\r\n")[0] || "",
|
||||||
|
executorNames: data[i][cols[4]].split("\r\r\n")[0] || "",
|
||||||
|
isMarked: true
|
||||||
|
});
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Остальные отчеты */
|
||||||
|
reports.push({
|
||||||
|
date: data[i][cols[0]],
|
||||||
|
equipment: data[i][cols[1]],
|
||||||
|
reason_call: parseReasonCallAndJobDesc(data[i][cols[3]]).trim(),
|
||||||
|
job_description: parseReasonCallAndJobDesc(data[i][cols[4]]).trim(),
|
||||||
|
root_cause: data[i]["__EMPTY_2"] || "",
|
||||||
|
applicantName: parseNames(data[i][cols[3]]).trim(),
|
||||||
|
executorNames: parseNames(data[i][cols[4]]).trim()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const requests = reports.map((report) =>
|
||||||
|
limit(() => {
|
||||||
|
return checkReportAssigments(
|
||||||
|
report,
|
||||||
|
(checkedReport) => {
|
||||||
|
const isEquipmentLinked = checkedReport.equipment instanceof Object;
|
||||||
|
const isApplicantLinked = checkedReport.applicant instanceof Object;
|
||||||
|
const isExecutorsLinked = checkedReport.executors && checkedReport.executors.length !== 0;
|
||||||
|
|
||||||
|
if (isEquipmentLinked) parseResultCount.equipments++;
|
||||||
|
if (isApplicantLinked) parseResultCount.applicants++;
|
||||||
|
if (isExecutorsLinked) parseResultCount.executors += checkedReport.executors?.length || 0;
|
||||||
|
if (isEquipmentLinked && isApplicantLinked && isExecutorsLinked) parseResultCount.reportsFullfilled++;
|
||||||
|
|
||||||
|
sseClientRes.sendSSEJson(checkedReport, "progress");
|
||||||
|
},
|
||||||
|
isSseClientClosed
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(requests)
|
||||||
|
.then(() => {
|
||||||
|
console.log(parseResultCount);
|
||||||
|
sseClientRes.sendSSEJson(parseResultCount, "done");
|
||||||
|
sseClientRes.res.end();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
sseClientRes.sendSSEJson({ error: serializeError(error) }, "error");
|
||||||
|
sseClientRes.res.end();
|
||||||
|
})
|
||||||
|
.finally(() => res.res.end());
|
||||||
|
} else {
|
||||||
|
throw new BadRequestError();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ReportModel} report
|
||||||
|
* @param {(report: ReportModel) => void} callback
|
||||||
|
* @param {boolean} rejectCond
|
||||||
|
*/
|
||||||
|
async function checkReportAssigments(report, callback, rejectCond) {
|
||||||
|
if (rejectCond) return Promise.reject(`Report check is rejected`);
|
||||||
|
|
||||||
|
const executorSurnames = extractSurnamesFromExecutorField(report.executorNames);
|
||||||
|
|
||||||
|
const equipment = await db
|
||||||
|
.request()
|
||||||
|
.query(`select id, name from dbo.asu_system_api_subsystemlist where name like N'%${report.equipment}%';`);
|
||||||
|
|
||||||
|
const applicant = await db
|
||||||
|
.request()
|
||||||
|
.query(
|
||||||
|
`select id, name from dbo.gpp_report_api_applicant where name like N'${report.applicantName.split(" ")[0]}%';`
|
||||||
|
);
|
||||||
|
|
||||||
|
/** @type {ExecutorModel[]} */
|
||||||
|
const executors = [];
|
||||||
|
|
||||||
|
for (const eSurname of executorSurnames) {
|
||||||
|
if (eSurname) {
|
||||||
|
const executor = await db
|
||||||
|
.request()
|
||||||
|
.query(
|
||||||
|
`select id, surname + ' ' + name + ' ' + patronymic as fullname from user_user where surname like N'%${eSurname}%';`
|
||||||
|
);
|
||||||
|
if (executor.recordset.length !== 0) {
|
||||||
|
executors.push({ id: executor.recordset[0].id, fullname: executor.recordset[0].fullname });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasEquipmentLink = equipment.recordset.length !== 0;
|
||||||
|
const hasApplicantLink = applicant.recordset.length !== 0;
|
||||||
|
|
||||||
|
callback({
|
||||||
|
...report,
|
||||||
|
executors,
|
||||||
|
equipment: hasEquipmentLink ? { id: equipment.recordset[0].id, name: equipment.recordset[0].name } : null,
|
||||||
|
applicant: hasApplicantLink ? { id: applicant.recordset[0].id, name: applicant.recordset[0].name } : null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseReasonCallAndJobDesc(str = "") {
|
||||||
|
const splitted = splitLongSpacedSentence(str);
|
||||||
|
if (splitted.length > 1) return splitted.slice(1).join(" ");
|
||||||
|
return str.split("\r\n").slice(1).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNames(str = "") {
|
||||||
|
const splitted = splitLongSpacedSentence(str);
|
||||||
|
if (splitted.length > 1) return splitted[0];
|
||||||
|
return str.split("\r\n")[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} str */
|
||||||
|
function extractSurnamesFromExecutorField(str) {
|
||||||
|
return str
|
||||||
|
.replaceAll(",", "")
|
||||||
|
.split(/(?:.\..\.)|(?:.\..\s)|(?:.\.\s.\.)|(?:.\..)/g)
|
||||||
|
.map((s) => s.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} str
|
||||||
|
* @param {number} min
|
||||||
|
*/
|
||||||
|
function splitLongSpacedSentence(str, min = 5) {
|
||||||
|
return str.split(new RegExp(`\\s{${min},}`));
|
||||||
|
}
|
||||||
26
handlers/reports/statusParse.handler.js
Normal file
26
handlers/reports/statusParse.handler.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { clients } from "#root/index.js";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
|
/** @type {import("#root/http-server/types/http-server.js").HttpRouteHandler} */
|
||||||
|
export default (ctx) => {
|
||||||
|
const { res } = ctx;
|
||||||
|
const clientId = crypto.randomUUID();
|
||||||
|
|
||||||
|
clients.set(clientId, ctx.res);
|
||||||
|
|
||||||
|
res.res.setHeaders(
|
||||||
|
new Headers({
|
||||||
|
Connection: "keep-alive",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Content-Type": "text/event-stream; charset=utf-8"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
res.sendSSEJson({ message: "Connected!", clientId }, "data");
|
||||||
|
|
||||||
|
res.res.on("close", () => {
|
||||||
|
clients.delete(clientId);
|
||||||
|
console.log(clients);
|
||||||
|
res.res.end();
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { BadRequestError } from "#root/errors/index.js";
|
import { BadRequestError } from "#root/http-server/index.js";
|
||||||
import yup from "yup";
|
import yup from "yup";
|
||||||
|
|
||||||
var reportItemSchema = yup.object({
|
var reportItemSchema = yup.object({
|
||||||
@ -11,7 +11,7 @@ var reportItemSchema = yup.object({
|
|||||||
executor_ids: yup.array(yup.number()).required()
|
executor_ids: yup.array(yup.number()).required()
|
||||||
});
|
});
|
||||||
|
|
||||||
/** @type {HTTPRouteHandler} */
|
/** @type {import("#root/http-server/types/http-server.js").HttpRouteHandler} */
|
||||||
export default async ({ res, data }) => {
|
export default async ({ res, data }) => {
|
||||||
const inputSchema = yup.object({ reports: yup.array(reportItemSchema).required() }).required();
|
const inputSchema = yup.object({ reports: yup.array(reportItemSchema).required() }).required();
|
||||||
|
|
||||||
@ -16,7 +16,7 @@ export class BadRequestError extends HttpError {
|
|||||||
*/
|
*/
|
||||||
constructor(message) {
|
constructor(message) {
|
||||||
super({
|
super({
|
||||||
name: "E_BAD_REQUEST",
|
name: "ERR_BAD_REQUEST",
|
||||||
message: message || "Bad Request",
|
message: message || "Bad Request",
|
||||||
statusCode: 400
|
statusCode: 400
|
||||||
});
|
});
|
||||||
@ -29,9 +29,22 @@ export class NotFoundError extends HttpError {
|
|||||||
*/
|
*/
|
||||||
constructor(message) {
|
constructor(message) {
|
||||||
super({
|
super({
|
||||||
name: "E_NOT_FOUND",
|
name: "ERR_NOT_FOUND",
|
||||||
message: message || "Not found",
|
message: message || "Not found",
|
||||||
statusCode: 404
|
statusCode: 404
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ServiceUnavailableError extends HttpError {
|
||||||
|
/**
|
||||||
|
* @param {string} [message]
|
||||||
|
*/
|
||||||
|
constructor(message) {
|
||||||
|
super({
|
||||||
|
name: "ERR_SERVICE_UNAVAILABLE",
|
||||||
|
message: message || "Service unavailable",
|
||||||
|
statusCode: 503
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
63
http-server/http-server.js
Normal file
63
http-server/http-server.js
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import http from "node:http";
|
||||||
|
import { AsyncLocalStorage } from "node:async_hooks";
|
||||||
|
import { createRequest } from "./request.js";
|
||||||
|
import { createResponse } from "./response.js";
|
||||||
|
import { routeHandler } from "./route.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import('./types/http-server.js').HttpServer} HttpServer
|
||||||
|
* @typedef {import('./types/http-server.js').HttpServerOptions} HttpServerOptions
|
||||||
|
* @typedef {import('./types/http-server.js').HttpContext} HttpContext
|
||||||
|
* @typedef {import('./types/http-server.js').HttpRouteMap} HttpRouteMap
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {HttpServerOptions} options
|
||||||
|
* @returns {HttpServer}
|
||||||
|
*/
|
||||||
|
export function createServer(options) {
|
||||||
|
const { routeMap, enableCors } = options;
|
||||||
|
const ctxStorage = new AsyncLocalStorage();
|
||||||
|
let serverPort = 3000;
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(`http://${req.headers.host || `localhost:${serverPort}`}${req.url}`);
|
||||||
|
|
||||||
|
/** @type {HttpContext} */
|
||||||
|
const ctx = {
|
||||||
|
data: null,
|
||||||
|
req: createRequest(req),
|
||||||
|
res: createResponse(res),
|
||||||
|
params: url.searchParams,
|
||||||
|
local: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (enableCors) {
|
||||||
|
res.setHeaders(
|
||||||
|
new Headers({
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "GET,POST,DELETE,UPDATE,PUT,PATCH",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type,Accept"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctxStorage.run(ctx, () =>
|
||||||
|
routeHandler(url, ctxStorage, routeMap).then(() => {
|
||||||
|
console.log(`${new Date().toISOString()} Request: ${url.pathname}`);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
listen(port = 3000, cb) {
|
||||||
|
serverPort = port;
|
||||||
|
server.listen(port, cb);
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
2
http-server/index.js
Normal file
2
http-server/index.js
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./http-server.js";
|
||||||
|
export * from "./errors.js";
|
||||||
@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @param {import('node:http').IncomingMessage} req
|
* @param {import('node:http').IncomingMessage} req
|
||||||
* @returns {HTTPRequest}
|
* @returns {import('./types/http-server.js').HttpRequest}
|
||||||
*/
|
*/
|
||||||
export function createRequest(req) {
|
export function createRequest(req) {
|
||||||
return {
|
return {
|
||||||
@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @param {import('node:http').ServerResponse} res
|
* @param {import('node:http').ServerResponse} res
|
||||||
* @returns {HTTPResponse}
|
* @returns {import('./types/http-server.js').HttpResponse}
|
||||||
*/
|
*/
|
||||||
export function createResponse(res) {
|
export function createResponse(res) {
|
||||||
return {
|
return {
|
||||||
@ -1,43 +1,22 @@
|
|||||||
import path from "node:path";
|
|
||||||
import formidable from "formidable";
|
|
||||||
import { ctxStorage } from "#root/context.js";
|
|
||||||
import { handlers as reportsHandlers } from "./reports/index.js";
|
|
||||||
import { BadRequestError, HttpError, NotFoundError } from "#root/errors/index.js";
|
|
||||||
import { serializeError } from "serialize-error";
|
import { serializeError } from "serialize-error";
|
||||||
|
import formidable from "formidable";
|
||||||
/**
|
import { BadRequestError, HttpError, NotFoundError } from "./errors.js";
|
||||||
* @type {HTTPRouteMap}
|
|
||||||
*/
|
|
||||||
export const ROUTE_MAP = {
|
|
||||||
"/api/reports_excel_file_parse": {
|
|
||||||
method: "POST",
|
|
||||||
handle: reportsHandlers.parseExcelHandler,
|
|
||||||
incomingForm: formidable({
|
|
||||||
maxFields: 1,
|
|
||||||
maxFiles: 1,
|
|
||||||
filename: (_, ext) => `gpp-reports${ext}`,
|
|
||||||
keepExtensions: true,
|
|
||||||
uploadDir: path.join(process.cwd(), "./uploads"),
|
|
||||||
filter: ({ mimetype }) => !!mimetype && mimetype.includes("application/vnd.ms-excel")
|
|
||||||
})
|
|
||||||
},
|
|
||||||
"/api/reports_sync": {
|
|
||||||
method: "POST",
|
|
||||||
handle: reportsHandlers.syncParsedHandler
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {URL} url
|
* @param {URL} url
|
||||||
|
* @param {import('node:async_hooks').AsyncLocalStorage<import('./http-server.js').HttpContext>} ctxStorage
|
||||||
|
* @param {import("./http-server.js").HttpRouteMap} routeMap
|
||||||
*/
|
*/
|
||||||
export async function routeHandler(url) {
|
export async function routeHandler(url, ctxStorage, routeMap) {
|
||||||
/** @type {HTTPContext} */
|
|
||||||
const ctx = ctxStorage.getStore();
|
const ctx = ctxStorage.getStore();
|
||||||
|
|
||||||
|
if (!ctx) throw new Error("HTTP context storage is undefined!");
|
||||||
|
|
||||||
const { req, res } = ctx;
|
const { req, res } = ctx;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (url.pathname in ROUTE_MAP) {
|
if (url.pathname in routeMap) {
|
||||||
const route = ROUTE_MAP[url.pathname];
|
const route = routeMap[url.pathname];
|
||||||
|
|
||||||
if (req.req.headers["content-type"] === "application/json") {
|
if (req.req.headers["content-type"] === "application/json") {
|
||||||
ctx.data = await ctx.req.parseJson();
|
ctx.data = await ctx.req.parseJson();
|
||||||
@ -48,7 +27,7 @@ export async function routeHandler(url) {
|
|||||||
throw new BadRequestError("Request cannot be processed without defined incoming form.");
|
throw new BadRequestError("Request cannot be processed without defined incoming form.");
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.data = await route.incomingForm.parse(req.req);
|
ctx.data = await formidable(route.incomingForm).parse(req.req);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (route.method === req.req.method) {
|
if (route.method === req.req.method) {
|
||||||
@ -88,5 +67,3 @@ export async function routeHandler(url) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export * from "./reports/parseExcel.handler.js";
|
|
||||||
42
http-server/types/http-server.d.ts
vendored
Normal file
42
http-server/types/http-server.d.ts
vendored
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import type http from "node:http";
|
||||||
|
import type IncomingForm from "formidable/Formidable.js";
|
||||||
|
import formidable from "formidable";
|
||||||
|
|
||||||
|
export interface HttpServer {
|
||||||
|
listen: (port: number, cb: () => void) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HttpServerOptions {
|
||||||
|
routeMap: HttpRouteMap;
|
||||||
|
enableCors?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HttpRoute {
|
||||||
|
method: "POST" | "GET" | "PUT" | "DELETE" | "PATCH";
|
||||||
|
handle: HttpRouteHandler;
|
||||||
|
incomingForm?: formidable.Options;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HttpRequest {
|
||||||
|
parseJson: () => Promise<any>;
|
||||||
|
req: http.IncomingMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HttpContext {
|
||||||
|
data: any;
|
||||||
|
res: HttpResponse;
|
||||||
|
req: HttpRequest;
|
||||||
|
params: URLSearchParams;
|
||||||
|
local: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HttpResponse {
|
||||||
|
sendJson: (data: Record<string, unknown>, statusCode?: number) => http.ServerResponse;
|
||||||
|
sendSSEJson: (data: Record<string, unknown>, eventName: string, statusCode?: number) => void;
|
||||||
|
sendText: (text: string, statusCode?: number) => http.ServerResponse;
|
||||||
|
res: http.ServerResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HttpRouteHandler = (ctx: HttpContext) => any;
|
||||||
|
export type HttpRouteMap = Record<string, HttpRoute>;
|
||||||
|
export type HttpCreateServer = (options: { routeMap: HttpRouteMap }) => HttpServer;
|
||||||
32
index.d.ts
vendored
32
index.d.ts
vendored
@ -1,32 +0,0 @@
|
|||||||
import type http from "node:http";
|
|
||||||
import type IncomingForm from "formidable/Formidable.js";
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
type ReportModel = {
|
|
||||||
date: string;
|
|
||||||
equipment: string;
|
|
||||||
reason_call: string;
|
|
||||||
job_description: string;
|
|
||||||
root_cause: string;
|
|
||||||
applicantName: string;
|
|
||||||
executorNames: string;
|
|
||||||
isMarked?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type HTTPRequest = {
|
|
||||||
parseJson: () => Promise<any>;
|
|
||||||
req: http.IncomingMessage;
|
|
||||||
};
|
|
||||||
|
|
||||||
type HTTPResponse = {
|
|
||||||
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;
|
|
||||||
res: http.ServerResponse;
|
|
||||||
};
|
|
||||||
|
|
||||||
type HTTPRoute = { method: "POST" | "GET"; handle: HTTPRouteHandler; incomingForm?: IncomingForm };
|
|
||||||
type HTTPRouteHandler = (ctx: HTTPContext) => Promise<void>;
|
|
||||||
type HTTPRouteMap = Record<string, HTTPRoute>;
|
|
||||||
type HTTPContext = { data: any; res: HTTPResponse; req: HTTPRequest };
|
|
||||||
}
|
|
||||||
77
index.js
77
index.js
@ -1,54 +1,45 @@
|
|||||||
import http from "node:http";
|
import path from "node:path";
|
||||||
import { routeHandler } from "./router/index.js";
|
import { createServer } from "./http-server/index.js";
|
||||||
|
import { reportsHandlers } from "./handlers/index.js";
|
||||||
import packageJson from "./package.json" with { type: "json" };
|
import packageJson from "./package.json" with { type: "json" };
|
||||||
import { ctxStorage } from "./context.js";
|
|
||||||
import db from "./db.js";
|
import db from "./db.js";
|
||||||
import { createResponse } from "./response.js";
|
|
||||||
import { createRequest } from "./request.js";
|
|
||||||
|
|
||||||
const SERVER_PORT = 3000;
|
const SERVER_PORT = 3000;
|
||||||
|
|
||||||
function application() {
|
/** @type {Map<string, import("./http-server/types/http-server.js").HttpResponse>} */
|
||||||
const server = http.createServer((req, res) => {
|
export const clients = new Map();
|
||||||
try {
|
|
||||||
const url = new URL(`http://localhost:${SERVER_PORT}${req.url}`);
|
|
||||||
|
|
||||||
res.setHeaders(
|
|
||||||
new Headers({
|
|
||||||
"Access-Control-Allow-Origin": "*",
|
|
||||||
"Access-Control-Allow-Methods": "GET, POST",
|
|
||||||
"Access-Control-Allow-Headers": "Content-Type"
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
/** @type {HTTPContext} */
|
|
||||||
const ctx = {
|
|
||||||
data: null,
|
|
||||||
req: createRequest(req),
|
|
||||||
res: createResponse(res)
|
|
||||||
};
|
|
||||||
|
|
||||||
ctxStorage.run(ctx, () =>
|
|
||||||
routeHandler(url).then(() => {
|
|
||||||
console.log(`${new Date().toISOString()} Request: ${url.pathname}`);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
/**
|
/**
|
||||||
* @param {number} port
|
* @type {import("#root/http-server/http-server.js").HttpRouteMap}
|
||||||
*/
|
*/
|
||||||
async run(port = 3000) {
|
export const routeMap = {
|
||||||
await db.connect().then(() => console.log("Database connected!"));
|
"/": {
|
||||||
server.listen(port, () => {
|
method: "GET",
|
||||||
console.log(`Service ${packageJson.name} is running at port ${SERVER_PORT}.`);
|
handle: (ctx) => ctx.res.sendText("Сервис инструментов таблицы АСУ ТП.")
|
||||||
});
|
},
|
||||||
|
"/api/reports_status_parse": {
|
||||||
|
method: "GET",
|
||||||
|
handle: reportsHandlers.statusParseHandler
|
||||||
|
},
|
||||||
|
"/api/reports_excel_file_parse": {
|
||||||
|
method: "POST",
|
||||||
|
handle: reportsHandlers.parseExcelHandler,
|
||||||
|
incomingForm: {
|
||||||
|
maxFields: 1,
|
||||||
|
maxFiles: 1,
|
||||||
|
filename: (_, ext) => `gpp-reports${ext}`,
|
||||||
|
keepExtensions: true,
|
||||||
|
uploadDir: path.join(process.cwd(), "./uploads"),
|
||||||
|
filter: ({ mimetype }) => !!mimetype && mimetype.includes("application/vnd.ms-excel")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/reports_sync": {
|
||||||
|
method: "POST",
|
||||||
|
handle: reportsHandlers.syncParsedHandler
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
application().run(SERVER_PORT);
|
createServer({ routeMap }).listen(SERVER_PORT, async () => {
|
||||||
|
console.log(`Service ${packageJson.name} is running at port ${SERVER_PORT}.`);
|
||||||
|
await db.connect().then(() => console.log("Database connected!"));
|
||||||
|
});
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"checkJs": true,
|
"checkJs": true,
|
||||||
|
"allowJs": true,
|
||||||
"lib": ["ESNext"],
|
"lib": ["ESNext"],
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"module": "nodenext",
|
"module": "nodenext",
|
||||||
@ -9,5 +10,6 @@
|
|||||||
"paths": {
|
"paths": {
|
||||||
"#root/*": ["./*"]
|
"#root/*": ["./*"]
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"exclude": ["./http-server/types/*.d.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
14
models.d.ts
vendored
Normal file
14
models.d.ts
vendored
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
type ReportModel = {
|
||||||
|
date: string;
|
||||||
|
equipment?: string | { id: string; name: string } | null;
|
||||||
|
applicant?: string | { id: string; name: string } | null;
|
||||||
|
reason_call: string;
|
||||||
|
job_description: string;
|
||||||
|
root_cause: string;
|
||||||
|
executors?: ExecutorModel[];
|
||||||
|
applicantName: string;
|
||||||
|
executorNames: string;
|
||||||
|
isMarked?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExecutorModel = { id: string; fullname: string };
|
||||||
@ -1,4 +0,0 @@
|
|||||||
import parseExcelHandler from "./parseExcel.handler.js";
|
|
||||||
import syncParsedHandler from "./syncParsed.handler.js";
|
|
||||||
|
|
||||||
export const handlers = { parseExcelHandler, syncParsedHandler };
|
|
||||||
@ -1,157 +0,0 @@
|
|||||||
import { BadRequestError } from "#root/errors/index.js";
|
|
||||||
import db from "#root/db.js";
|
|
||||||
import xlsx from "xlsx";
|
|
||||||
import pLimit from "p-limit";
|
|
||||||
import { serializeError } from "serialize-error";
|
|
||||||
|
|
||||||
const sheetName = "Отчеты";
|
|
||||||
const limit = pLimit(10);
|
|
||||||
|
|
||||||
/** @type {xlsx.ParsingOptions} */
|
|
||||||
const xlsxParseOptions = {
|
|
||||||
sheets: sheetName,
|
|
||||||
cellHTML: false,
|
|
||||||
cellFormula: false,
|
|
||||||
cellDates: true,
|
|
||||||
dateNF: "yyyy-mm-dd"
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* URL: /api/reports_excel_file_parse
|
|
||||||
* Method: POST
|
|
||||||
* Description: Принимает на вход файл отчетов в формате Excel, парсит его, проверяет связи с БД,
|
|
||||||
* отдает отчет в формате JSON частями.
|
|
||||||
*
|
|
||||||
* @type {HTTPRouteHandler}
|
|
||||||
*/
|
|
||||||
export default async (ctx) => {
|
|
||||||
const { res } = ctx;
|
|
||||||
|
|
||||||
if (!ctx.data) throw new BadRequestError("Cannot proceed uploaded file");
|
|
||||||
|
|
||||||
const [, files] = ctx.data;
|
|
||||||
const excelFilePath = files?.report?.at(0)?.filepath;
|
|
||||||
|
|
||||||
/** @type {ReportModel[]} */
|
|
||||||
const reports = [];
|
|
||||||
|
|
||||||
let parseResultCount = { equipments: 0, applicants: 0, executors: 0 };
|
|
||||||
let isReqClosed = false;
|
|
||||||
|
|
||||||
res.res.setHeaders(
|
|
||||||
new Headers({
|
|
||||||
Connection: "keep-alive",
|
|
||||||
"Cache-Control": "no-cache",
|
|
||||||
"Content-Type": "text/event-stream"
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
res.res.on("close", () => (isReqClosed = true));
|
|
||||||
|
|
||||||
if (excelFilePath) {
|
|
||||||
const workbook = xlsx.readFile(excelFilePath, xlsxParseOptions);
|
|
||||||
const sheet = workbook.Sheets[sheetName];
|
|
||||||
|
|
||||||
if (!sheet) throw new Error("Invalid reports workbook.", { cause: { statusCode: 400 } });
|
|
||||||
|
|
||||||
const data = xlsx.utils.sheet_to_json(sheet, { raw: false, blankrows: true });
|
|
||||||
|
|
||||||
for (let i = 0; i < data.length; i++) {
|
|
||||||
const cols = Object.keys(data[i]);
|
|
||||||
|
|
||||||
if (i === 0) continue;
|
|
||||||
|
|
||||||
/** Несинхронизированные помеченные отчеты */
|
|
||||||
if ("__EMPTY_3" in data[i] && data[i]["__EMPTY_3"] === "r") {
|
|
||||||
reports.push({
|
|
||||||
date: data[i][cols[0]],
|
|
||||||
equipment: data[i][cols[1]],
|
|
||||||
reason_call: data[i][cols[3]].split("\r\r\n")[1] || "",
|
|
||||||
job_description: data[i][cols[4]].split("\r\r\n")[1] || "",
|
|
||||||
root_cause: data[i]["__EMPTY_2"] || "",
|
|
||||||
applicantName: data[i][cols[3]].split("\r\r\n")[0] || "",
|
|
||||||
executorNames: data[i][cols[4]].split("\r\r\n")[0] || "",
|
|
||||||
isMarked: true
|
|
||||||
});
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Остальные отчеты */
|
|
||||||
reports.push({
|
|
||||||
date: data[i][cols[0]],
|
|
||||||
equipment: data[i][cols[1]],
|
|
||||||
reason_call: parseReasonCallAndJobDesc(data[i][cols[3]]).trim(),
|
|
||||||
job_description: parseReasonCallAndJobDesc(data[i][cols[4]]).trim(),
|
|
||||||
root_cause: data[i]["__EMPTY_2"] || "",
|
|
||||||
applicantName: parseNames(data[i][cols[3]]).trim(),
|
|
||||||
executorNames: parseNames(data[i][cols[4]]).trim()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param {ReportModel} report */
|
|
||||||
const checkReportAssigments = async (report) => {
|
|
||||||
if (isReqClosed) {
|
|
||||||
return Promise.reject("Client closed the connection.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const equipment = await db
|
|
||||||
.request()
|
|
||||||
.query(`select id from dbo.asu_system_api_subsystemlist where name like N'%${report.equipment}%';`);
|
|
||||||
|
|
||||||
const applicant = await db
|
|
||||||
.request()
|
|
||||||
.query(
|
|
||||||
`select id from dbo.gpp_report_api_applicant where name like N'${report.applicantName.split(" ")[0]}%';`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (equipment.recordset.length !== 0) parseResultCount.equipments++;
|
|
||||||
if (applicant.recordset.length !== 0) parseResultCount.applicants++;
|
|
||||||
|
|
||||||
res.sendSSEJson(
|
|
||||||
{
|
|
||||||
report: {
|
|
||||||
...report,
|
|
||||||
equipment: equipment.recordset.at(0)?.id || null,
|
|
||||||
applicant: applicant.recordset.at(0)?.id || null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"progress"
|
|
||||||
);
|
|
||||||
|
|
||||||
return equipment;
|
|
||||||
};
|
|
||||||
|
|
||||||
const requests = reports.map((r) => limit(() => checkReportAssigments(r)));
|
|
||||||
|
|
||||||
await Promise.all(requests)
|
|
||||||
.then(() => res.sendSSEJson(parseResultCount, "done"))
|
|
||||||
.catch((error) => {
|
|
||||||
console.error(error);
|
|
||||||
res.sendSSEJson({ error: serializeError(error) }, "error");
|
|
||||||
})
|
|
||||||
.finally(() => res.res.end());
|
|
||||||
} else {
|
|
||||||
throw new BadRequestError();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function parseReasonCallAndJobDesc(str = "") {
|
|
||||||
const splitted = splitLongSpacedSentence(str);
|
|
||||||
if (splitted.length > 1) return splitted.slice(1).join(" ");
|
|
||||||
return str.split("\r\n").slice(1).join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseNames(str = "") {
|
|
||||||
const splitted = splitLongSpacedSentence(str);
|
|
||||||
if (splitted.length > 1) return splitted[0];
|
|
||||||
return str.split("\r\n")[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} str
|
|
||||||
* @param {number} min
|
|
||||||
*/
|
|
||||||
function splitLongSpacedSentence(str, min = 5) {
|
|
||||||
return str.split(new RegExp(`\\s{${min},}`));
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user