tehnicheskoe-zadanie-na-razr/server.js

590 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const http = require("http");
const fs = require("fs");
const path = require("path");
const formidable = require("formidable");
const pdfParse = require("pdf-parse");
const mammoth = require("mammoth");
const XLSX = require("xlsx");
const PORT = process.env.PORT || 3000;
const DATA_FILE = path.join(__dirname, "data.json");
const UPLOADS_DIR = path.join(__dirname, "uploads");
if (!fs.existsSync(UPLOADS_DIR)) {
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
}
function loadData() {
try {
return JSON.parse(fs.readFileSync(DATA_FILE, "utf8"));
} catch (_) {
return {
checks: [],
templates: [
{
id: 1,
name: "Рассмотрение штрафных санкций",
requiredDocs: [
"Служебная записка",
"Договор",
"Дополнительное соглашение",
"Расчёт",
"Подтверждение нарушения",
"Переписка с подрядчиком",
"Уведомление",
"Акт",
"Заключение ответственного подразделения"
]
},
{
id: 2,
name: "Заключение договора",
requiredDocs: [
"Служебная записка",
"Проект договора",
"Обоснование цены",
"Согласование контрагента",
"Лицензии (при необходимости)"
]
},
{
id: 3,
name: "Дополнительное соглашение",
requiredDocs: [
"Служебная записка",
"Проект дополнительного соглашения",
"Обоснование изменений",
"Расчёт изменений (если есть)"
]
}
],
typicalRemarks: [
{ id: 1, category: "КРИТИЧНО", type: "Расчёт", text: "Расчёт не подтверждён документально", blocking: true },
{ id: 2, category: "КРИТИЧНО", type: "Договор", text: "В представленных документах имеются расхождения в размере суммы", blocking: true },
{ id: 3, category: "ТРЕБУЕТ ДОРАБОТКИ", type: "Акт", text: "Отсутствует документ, подтверждающий дату возникновения обязательства", blocking: false },
{ id: 4, category: "ТРЕБУЕТ ДОРАБОТКИ", type: "Расчёт", text: "Количество дней просрочки не соответствует представленному расчёту", blocking: false },
{ id: 5, category: "ТЕХНИЧЕСКОЕ", type: "Любой", text: "Опечатка в наименовании организации", blocking: false }
]
};
}
}
function saveData(data) {
fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2));
}
function body(req) {
return new Promise((resolve) => {
let s = "";
req.on("data", (c) => (s += c));
req.on("end", () => {
try {
resolve(JSON.parse(s || "{}"));
} catch (_) {
resolve({});
}
});
});
}
async function extractText(filePath, originalName) {
try {
const ext = path.extname(originalName).toLowerCase();
if (ext === ".pdf") {
const dataBuffer = fs.readFileSync(filePath);
const data = await pdfParse(dataBuffer);
return { text: data.text, pages: data.numpages };
}
if (ext === ".docx") {
const result = await mammoth.extractRawText({ path: filePath });
return { text: result.value, pages: 1 };
}
if (ext === ".xlsx" || ext === ".xls") {
const wb = XLSX.readFile(filePath);
let text = "";
wb.SheetNames.forEach((sheetName) => {
const sheet = wb.Sheets[sheetName];
text += `=== ${sheetName} ===\n`;
text += XLSX.utils.sheet_to_csv(sheet) + "\n";
});
return { text, pages: wb.SheetNames.length };
}
if (ext === ".txt") {
const text = fs.readFileSync(filePath, "utf8");
return { text, pages: 1 };
}
return { text: "", pages: 0 };
} catch (err) {
console.error("Error extracting text:", err);
return { text: "", pages: 0 };
}
}
function classifyDocument(text, filename) {
const lowerText = text.toLowerCase();
const lowerName = filename.toLowerCase();
const classifiers = [
{ type: "Служебная записка", keywords: ["служебная записка", "докладываю", "предлагаю", "прошу рассмотреть"] },
{ type: "Договор", keywords: ["договор", "контракт", "стороны обязуются", "предмет договора", "условия договора"] },
{ type: "Дополнительное соглашение", keywords: ["дополнительное соглашение", "изменить договор", "внести изменения"] },
{ type: "Акт выполненных работ", keywords: ["акт выполненных работ", "акт приёмки", "работы выполнены", "услуги оказаны"] },
{ type: "Расчёт", keywords: ["расчёт", "калькуляция", "сумма", "итого", "всего"] },
{ type: "Претензия", keywords: ["претензия", "требую", "нарушение обязательств", "неустойка"] },
{ type: "Уведомление", keywords: ["уведомляем", "уведомление", "извещаем"] },
{ type: "Протокол", keywords: ["протокол", "заседание", "решили", "постановили"] },
{ type: "Пояснительная записка", keywords: ["пояснительная записка", "пояснения", "обоснование"] },
{ type: "Справка", keywords: ["справка", "подтверждает", "выдана"] },
{ type: "Заключение", keywords: ["заключение", "вывод", "рекомендация"] },
{ type: "Письмо", keywords: ["исх.", "вх.", "сообщаем", "информируем"] },
{ type: "Доверенность", keywords: ["доверенность", "уполномочиваю", "представлять интересы"] },
{ type: "Приказ", keywords: ["приказ", "приказываю", "распоряжение"] },
{ type: "Таблица", keywords: ["№ п/п", "наименование", "количество", "цена"] }
];
let bestMatch = { type: "Иной документ", score: 0 };
for (const classifier of classifiers) {
let score = 0;
for (const keyword of classifier.keywords) {
if (lowerText.includes(keyword)) score += 2;
if (lowerName.includes(keyword.toLowerCase())) score += 3;
}
if (score > bestMatch.score) {
bestMatch = { type: classifier.type, score };
}
}
return bestMatch.score > 0 ? bestMatch.type : "Иной документ";
}
function extractRequisites(text) {
const requisites = {};
const contractNumberMatch = text.match(/договор\s*(?:№|N|N-o)?\s*(\d+(?:\/[\d-]+)?)/i);
if (contractNumberMatch) requisites.contractNumber = contractNumberMatch[1];
const contractDateMatch = text.match(/(?:от|даты)\s*(\d{1,2}\.\d{1,2}\.\d{2,4})/i);
if (contractDateMatch) requisites.contractDate = contractDateMatch[1];
const amountMatch = text.match(/(\d{1,3}(?:\s?\d{3})*(?:\.\d+)?\s*(?:тенге|₸|KZT|руб\.?|рублей))/i);
if (amountMatch) requisites.amount = amountMatch[1];
const counterpartyMatch = text.match(/(?:ООО|ТОО|АО|ИП|КГУ)\s*["']?([^"'\n]{5,100})["']?/i);
if (counterpartyMatch) requisites.counterparty = counterpartyMatch[1].trim();
const iinMatch = text.match(/(?:ИИН|БИН)\s*(\d{9,12})/i);
if (iinMatch) requisites.iin = iinMatch[1];
const periodMatch = text.match(/(?:период|срок)\s*(?:с|по|до)?\s*(\d{1,2}\.\d{1,2}\.\d{2,4})\s*(?:по|до|по)\s*(\d{1,2}\.\d{1,2}\.\d{2,4})/i);
if (periodMatch) {
requisites.periodFrom = periodMatch[1];
requisites.periodTo = periodMatch[2];
}
const daysMatch = text.match(/(\d+)\s*(?:дн\.?|дней|дня|день|календарных?\s*дней)/i);
if (daysMatch) requisites.days = parseInt(daysMatch[1]);
const penaltyMatch = text.match(/(?:неустойка|пеня|штраф)\s*(?:в размере|составляет)?\s*(\d{1,3}(?:\s?\d{3})*(?:\.\d+)?\s*(?:тенге|₸|KZT|%|процент))/i);
if (penaltyMatch) requisites.penalty = penaltyMatch[1];
const signatoryMatch = text.match(/(?:должность|подписал|руководитель)[\s\n:]+([^\n]{5,80})/i);
if (signatoryMatch) requisites.signatory = signatoryMatch[1].trim();
return requisites;
}
function checkArithmetic(text) {
const errors = [];
const lines = text.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const multiplicationMatch = line.match(/(\d+(?:\.\d+)?)\s*[\*x×]\s*(\d+(?:\.\d+)?)\s*=?\s*(\d+(?:\.\d+)?)/i);
if (multiplicationMatch) {
const a = parseFloat(multiplicationMatch[1]);
const b = parseFloat(multiplicationMatch[2]);
const result = parseFloat(multiplicationMatch[3]);
const expected = a * b;
if (Math.abs(expected - result) / expected > 0.01) {
errors.push({
line: i + 1,
type: "Умножение",
formula: `${a} × ${b}`,
indicated: result,
calculated: expected,
difference: Math.abs(expected - result)
});
}
}
const additionMatch = line.match(/(\d+(?:\.\d+)?)\s*\+\s*(\d+(?:\.\d+)?)\s*=?\s*(\d+(?:\.\d+)?)/i);
if (additionMatch) {
const a = parseFloat(additionMatch[1]);
const b = parseFloat(additionMatch[2]);
const result = parseFloat(additionMatch[3]);
const expected = a + b;
if (Math.abs(expected - result) / (expected || 1) > 0.01) {
errors.push({
line: i + 1,
type: "Сложение",
formula: `${a} + ${b}`,
indicated: result,
calculated: expected,
difference: Math.abs(expected - result)
});
}
}
}
return errors;
}
function compareRequisites(docs) {
const allReqs = docs.map(d => ({ name: d.name, type: d.docType, ...d.requisites }));
const discrepancies = [];
const fields = ["contractNumber", "contractDate", "counterparty", "amount", "iin"];
const fieldNames = {
contractNumber: "Номер договора",
contractDate: "Дата договора",
counterparty: "Контрагент",
amount: "Сумма",
iin: "ИИН/БИН"
};
for (const field of fields) {
const values = {};
for (const req of allReqs) {
if (req[field]) {
const key = String(req[field]).trim();
if (!values[key]) values[key] = [];
values[key].push(req.name);
}
}
if (Object.keys(values).length > 1) {
const entries = Object.entries(values);
discrepancies.push({
field: fieldNames[field],
values: entries.map(([val, docs]) => ({ value: val, documents: docs })),
severity: field === "amount" || field === "contractNumber" ? "КРИТИЧНО" : "ТРЕБУЕТ ДОРАБОТКИ"
});
}
}
return discrepancies;
}
function generateRemarks(docs, discrepancies, arithmeticErrors) {
const remarks = [];
const existingIds = new Set();
for (const disc of discrepancies) {
const id = Date.now() + Math.random() * 1000;
if (!existingIds.has(id)) {
existingIds.add(id);
remarks.push({
id,
category: disc.severity,
type: "Расхождение",
document: "Сравнение документов",
text: `Выявлено расхождение в поле "${disc.field}": ${disc.values.map(v => `${v.documents.join(", ")} — ${v.value}`).join("; ")}`,
recommendation: "Уточнить корректное значение и привести документы к единому значению",
confirmed: true,
page: null
});
}
}
for (const err of arithmeticErrors) {
const id = Date.now() + Math.random() * 1000 + remarks.length;
if (!existingIds.has(id)) {
existingIds.add(id);
remarks.push({
id,
category: "КРИТИЧНО",
type: "Арифметика",
document: err.document || "Не определён",
text: `Ошибка в расчёте: ${err.formula} = ${err.calculated}, указано ${err.indicated}`,
recommendation: "Проверить расчёт и исправить значение",
confirmed: true,
page: err.line
});
}
}
return remarks;
}
http.createServer(async (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
if (req.method === "OPTIONS") {
res.writeHead(200);
res.end();
return;
}
const url = req.url.split("?")[0];
if (req.method === "GET" && url === "/api/data") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(loadData()));
return;
}
if (req.method === "POST" && url === "/api/checks") {
const data = await body(req);
const db = loadData();
const newCheck = {
id: Date.now(),
...data,
status: "Новая",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
version: 1,
documents: [],
remarks: [],
conclusion: null
};
db.checks.push(newCheck);
saveData(db);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, check: newCheck }));
return;
}
if (req.method === "GET" && url.startsWith("/api/checks/")) {
const id = parseInt(url.split("/")[3]);
const db = loadData();
const check = db.checks.find(c => c.id === id);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(check || {}));
return;
}
if (req.method === "PUT" && url.startsWith("/api/checks/")) {
const id = parseInt(url.split("/")[3]);
const updateData = await body(req);
const db = loadData();
const idx = db.checks.findIndex(c => c.id === id);
if (idx !== -1) {
db.checks[idx] = { ...db.checks[idx], ...updateData, updatedAt: new Date().toISOString() };
saveData(db);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, check: db.checks[idx] }));
} else {
res.writeHead(404);
res.end(JSON.stringify({ error: "Not found" }));
}
return;
}
if (req.method === "DELETE" && url.startsWith("/api/checks/")) {
const id = parseInt(url.split("/")[3]);
const db = loadData();
db.checks = db.checks.filter(c => c.id !== id);
saveData(db);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true }));
return;
}
if (req.method === "POST" && url === "/api/upload") {
const checkId = parseInt(req.url.split("?")[1]?.split("=")[1] || "0");
const form = formidable({
uploadDir: UPLOADS_DIR,
keepExtensions: true,
maxFileSize: 50 * 1024 * 1024
});
form.parse(req, async (err, fields, files) => {
if (err) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: err.message }));
return;
}
const file = files.file && files.file[0];
if (!file) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "No file uploaded" }));
return;
}
const originalName = file.originalFilename || file.filepath.split("/").pop();
const ext = path.extname(originalName).toLowerCase();
const supportedExts = [".pdf", ".docx", ".xlsx", ".xls", ".jpg", ".jpeg", ".png", ".txt"];
if (!supportedExts.includes(ext)) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Unsupported format" }));
return;
}
const fileSize = fs.statSync(file.filepath).size;
const { text, pages } = await extractText(file.filepath, originalName);
const docType = classifyDocument(text, originalName);
const requisites = extractRequisites(text);
const arithmeticErrors = checkArithmetic(text);
const docRecord = {
id: Date.now(),
name: originalName,
format: ext.slice(1).toUpperCase(),
size: fileSize,
uploadedAt: new Date().toISOString(),
pages,
docType,
status: "Обработан",
text,
requisites,
arithmeticErrors
};
if (checkId) {
const db = loadData();
const check = db.checks.find(c => c.id === checkId);
if (check) {
check.documents.push(docRecord);
check.updatedAt = new Date().toISOString();
// Сохраняем ручные замечания (у которых нет типа "Расхождение" или "Арифметика")
const manualRemarks = (check.remarks || []).filter(r =>
r.type !== "Расхождение" && r.type !== "Арифметика"
);
const discrepancies = compareRequisites(check.documents);
const allArithmeticErrors = check.documents.flatMap(d =>
(d.arithmeticErrors || []).map(e => ({ ...e, document: d.name }))
);
const autoRemarks = generateRemarks(check.documents, discrepancies, allArithmeticErrors);
check.remarks = [...manualRemarks, ...autoRemarks];
saveData(db);
}
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, document: docRecord }));
});
return;
}
if (req.method === "POST" && url === "/api/analyze") {
const { checkId } = await body(req);
const db = loadData();
const check = db.checks.find(c => c.id === checkId);
if (!check) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Check not found" }));
return;
}
const discrepancies = compareRequisites(check.documents);
const allArithmeticErrors = check.documents.flatMap(d =>
(d.arithmeticErrors || []).map(e => ({ ...e, document: d.name }))
);
// Сохраняем только ручные замечания, автоматические перегенерируем
const manualRemarks = (check.remarks || []).filter(r =>
r.type !== "Расхождение" && r.type !== "Арифметика" && r.type !== "Комплектность"
);
const autoRemarks = generateRemarks(check.documents, discrepancies, allArithmeticErrors);
const template = db.templates.find(t => t.name === check.questionType);
if (template) {
const providedTypes = check.documents.map(d => d.docType);
for (const required of template.requiredDocs) {
const isProvided = providedTypes.some(t => t.includes(required) || required.includes(t));
if (!isProvided) {
autoRemarks.push({
id: Date.now() + Math.random() * 1000,
category: "КРИТИЧНО",
type: "Комплектность",
document: required,
text: `Отсутствует обязательный документ: ${required}`,
recommendation: "Представить недостающий документ",
confirmed: true,
page: null
});
}
}
}
check.remarks = [...manualRemarks, ...autoRemarks];
const criticalCount = check.remarks.filter(r => r.category === "КРИТИЧНО" && r.confirmed).length;
check.status = criticalCount > 0 ? "На доработке" : "Готово к комиссии";
check.updatedAt = new Date().toISOString();
saveData(db);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, remarks: check.remarks, status: check.status }));
return;
}
if (req.method === "GET" && url === "/api/templates") {
const db = loadData();
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(db.templates));
return;
}
if (req.method === "POST" && url === "/api/templates") {
const data = await body(req);
const db = loadData();
const newTemplate = { id: Date.now(), ...data };
db.templates.push(newTemplate);
saveData(db);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, template: newTemplate }));
return;
}
if (req.method === "GET" && url === "/api/remarks") {
const db = loadData();
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(db.typicalRemarks));
return;
}
if (req.method === "POST" && url === "/api/remarks") {
const data = await body(req);
const db = loadData();
const newRemark = { id: Date.now(), ...data };
db.typicalRemarks.push(newRemark);
saveData(db);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, remark: newRemark }));
return;
}
let file = url === "/" ? "/index.html" : url.split("?")[0];
const full = path.join(__dirname, file);
if (full.startsWith(__dirname) && fs.existsSync(full) && fs.statSync(full).isFile()) {
const ext = path.extname(full);
const type = ext === ".css" ? "text/css; charset=utf-8" : ext === ".js" ? "application/javascript; charset=utf-8" : "text/html; charset=utf-8";
res.writeHead(200, { "Content-Type": type });
res.end(fs.readFileSync(full));
return;
}
res.writeHead(404);
res.end("Not found");
}).listen(PORT, () => console.log("Сервер запущен на порту " + PORT));