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));