const http = require("http"); const fs = require("fs"); const path = require("path"); const XLSX = require("xlsx"); const PORT = process.env.PORT || 3000; const DATA = path.join(__dirname, "data.json"); const ROOT = __dirname; // ---------- справочники ---------- const STATUS = { new: { label: "Новая", tone: "blue" }, checking: { label: "На проверке", tone: "orange" }, toLegal: { label: "На согласовании", tone: "orange" }, toSign: { label: "На подписании", tone: "purple" }, sent: { label: "Отправлена в 1С", tone: "green" }, rejected: { label: "Возвращена", tone: "red" }, disputed: { label: "Спорный случай", tone: "red" } }; function statusChip(st) { var s = STATUS[st] || STATUS.checking; return '' + s.label + ""; } // ИИ-подсказки по кодам ТРУ: для каждой заявки — 2 кандидата const TRU_HINTS = { "ЗК-0918": [ { c: "ТРУ-442.01", n: "Оптические модули, передача данных", conf: 96 }, { c: "ТРУ-442.05", n: "Кабель оптический, внешняя прокладка", conf: 54 } ], "ЗК-0919": [ { c: "ТРУ-101.22", n: "Оборудование для обслуживания АТС", conf: 91 }, { c: "ТРУ-101.27", n: "Монтажный инструмент для линий", conf: 47 } ], "ЗК-0920": [ { c: "ТРУ-310.14", n: "Провод медный, ВВГнг 3х25", conf: 98 }, { c: "ТРУ-310.11", n: "Провод медный, ВВГнг 3х16", conf: 61 } ], "ЗК-0912": [ { c: "ТРУ-501.03", n: "Сухие строительные смеси, оптом", conf: 88 }, { c: "ТРУ-501.07", n: "Краски и лаки, фасовка 10 кг", conf: 40 } ], "ЗК-0913": [ { c: "ТРУ-071.19", n: "Услуги связи, прокат линии", conf: 94 }, { c: "ТРУ-071.11", n: "Аренда коммутаторов", conf: 38 } ], "ЗК-0914": [ { c: "ТРУ-442.01", n: "Оптические модули, передача данных", conf: 52 }, { c: "ТРУ-440.02", n: "Сетевые коммутаторы", conf: 49 } ], "ЗК-0915": [ { c: "ТРУ-610.05", n: "Хостинг вычислительных мощностей", conf: 93 }, { c: "ТРУ-610.09", n: "Облачные услуги, хранилище", conf: 58 } ] }; // ---------- seed ---------- const SEED = [ { id: "ЗК-0918", init: "Ахметов С. Т.", sum: 2480000, src: "1С", in: "2026-09-22", done: "", st: "checking", trr: "ТРУ-440.12", plan: "Да", codeTru: false, dispute: false, subj: "Оптические модули для узла связи", justif: "Замена изношенных модулей на магистральном узле, срок годности запаса истёк." }, { id: "ЗК-0919", init: "Байжанова Ш. К.", sum: 1120000, src: "Почта", in: "2026-09-22", done: "", st: "checking", trr: "ТРУ-101.20", plan: "Да", codeTru: false, dispute: false, subj: "Монтажный инструмент", justif: "Дополнение оснастки бригады по заявке на обслуживание линий." }, { id: "ЗК-0920", init: "Дюсембаев Н. Б.", sum: 560000, src: "1С", in: "2026-09-21", done: "", st: "new", trr: "ТРУ-310.09", plan: "Да", codeTru: false, dispute: false, subj: "Провод ВВГнг 3х25, 1 200 м", justif: "Стройка внутреннего контура в техническом помещении." }, { id: "ЗК-0915", init: "Ермек А. Д.", sum: 1490000, src: "1С", in: "2026-09-20", done: "2026-09-21", st: "toSign", trr: "ТРУ-610.05", plan: "Да", codeTru: true, dispute: false, subj: "Хостинг дополнительных мощностей", justif: "Временное расширение хранилища до завершения строительства стойки." }, { id: "ЗК-0916", init: "Исаев Г. М.", sum: 3840000, src: "Почта", in: "2026-09-20", done: "2026-09-21", st: "toLegal", trr: "ТРУ-220.31", plan: "Нет", codeTru: true, dispute: false, subj: "Панели управления для ЩУ-2", justif: "Панели не включены в план закупок: потребность подтверждена актом осмотра." }, { id: "ЗК-0917", init: "Кадырова Ж. С.", sum: 2960000, src: "1С", in: "2026-09-19", done: "2026-09-22", st: "toSign", trr: "ТРУ-442.01", plan: "Да", codeTru: true, dispute: false, subj: "Оборудование передачи данных", justif: "Поставка по утверждённому плану на вторую половину года." }, { id: "ЗК-0914", init: "Мухитов Р. О.", sum: 1730000, src: "1С", in: "2026-09-18", done: "", st: "disputed", trr: "ТРУ-440.12", plan: "Да", codeTru: true, dispute: true, subj: "Коммутаторы сетевые, 8 шт", justif: "В заявке указано оборудование, не совпадающее с артикулом плана закупки." }, { id: "ЗК-0913", init: "Нурлан С. А.", sum: 640000, src: "Почта", in: "2026-09-17", done: "2026-09-17", st: "rejected", trr: "ТРУ-071.19", plan: "Нет", codeTru: true, dispute: false, subj: "Прокат телефонной линии", justif: "Возвращена: услуга уже приобретена по договору № 4542/2026." }, { id: "ЗК-0912", init: "Оспанова Л. Т.", sum: 1250000, src: "1С", in: "2026-09-16", done: "2026-09-18", st: "sent", trr: "ТРУ-501.03", plan: "Да", codeTru: true, dispute: false, subj: "Стройматериалы для ремонта серверной", justif: "Ремонт по смете, согласованной в плановом периоде." }, { id: "ЗК-0911", init: "Павлов К. И.", sum: 480000, src: "1С", in: "2026-09-15", done: "2026-09-16", st: "sent", trr: "ТРУ-118.40", plan: "Да", codeTru: true, dispute: true, subj: "Комплект ЗИП для линии связи", justif: "Подтверждён вручную: ИИ предложил неверный код (ТРУ-118.44), закупщик исправил." }, { id: "ЗК-0910", init: "Сафин Д. Р.", sum: 910000, src: "Почта", in: "2026-09-14", done: "2026-09-15", st: "sent", trr: "ТРУ-330.08", plan: "Да", codeTru: true, dispute: false, subj: "Эксплуатационные материалы", justif: "Запас на третий квартал, остатки ниже минимального уровня." }, { id: "ЗК-0909", init: "Тлеуберди А. М.", sum: 3200000, src: "1С", in: "2026-09-12", done: "2026-09-16", st: "sent", trr: "ТРУ-443.02", plan: "Да", codeTru: true, dispute: false, subj: "Кабель оптический, 48 волокон", justif: "Резервирующая линия между узлами, включена в план." } ]; // ---------- data.json ---------- function loadData() { let d = null; try { d = JSON.parse(fs.readFileSync(DATA, "utf8")); } catch (_) {} if (!d || !Array.isArray(d.requests)) { d = { requests: JSON.parse(JSON.stringify(SEED)), log: [] }; saveData(d); } return d; } function saveData(d) { d.updatedAt = new Date().toISOString(); fs.writeFileSync(DATA, JSON.stringify(d, null, 2)); } function logIt(d, what, who) { d.log.push({ when: new Date().toISOString(), what: what, who: who || "система" }); if (d.log.length > 500) d.log = d.log.slice(-500); } // ---------- утилиты ---------- function isoToDdMm(dd) { if (!dd) return ""; var p = dd.split("-"); return p[2] + "." + p[1] + "." + p[0]; } function todayIso() { return new Date().toISOString().slice(0, 10); } function nextId(reqs) { var max = 920; for (var i = 0; i < reqs.length; i++) { var m = /ЗК-(\d+)\/(\d+)/.exec(reqs[i].id); if (m) max = Math.max(max, parseInt(m[2], 10)); } return "ЗК-09/" + String(max + 1).padStart(2, "0"); } function body(req) { return new Promise(function (resolve) { var s = ""; req.on("data", function (c) { s += c; }); req.on("end", function () { try { resolve(JSON.parse(s || "{}")); } catch (_) { resolve({}); } }); }); } function sendJson(res, code, obj) { var s = JSON.stringify(obj); res.writeHead(code, { "Content-Type": "application/json; charset=utf-8", "Content-Length": Buffer.byteLength(s) }); res.end(s); } function daysBetween(inIso, doneIso) { if (!inIso) return 0; var a = new Date(inIso + "T00:00:00"); var b = doneIso ? new Date(doneIso + "T00:00:00") : new Date(); var diff = Math.round((b - a) / 86400000); return diff < 0 ? 0 : diff; } // ---------- HTTP ---------- http.createServer(async function (req, res) { var url = req.url.split("?")[0]; // ===== API ===== if (req.method === "GET" && url === "/api/requests") { var d = loadData(); var out = d.requests.map(function (r) { return { id: r.id, init: r.init, sum: r.sum, src: r.src, in: isoToDdMm(r.in), done: r.done ? isoToDdMm(r.done) : "", st: r.st, trr: r.trr, plan: r.plan, codeTru: !!r.codeTru, dispute: !!r.dispute, subj: r.subj, justif: r.justif, days: daysBetween(r.in, r.done), hints: TRU_HINTS[r.id] || (r.codeTru ? [{ c: r.trr, n: r.subj, conf: 100 }] : null), confirmedAt: r.confirmedAt || "" }; }); return sendJson(res, 200, out); } if (req.method === "POST" && url === "/api/requests/new") { var d2 = loadData(); var b = await body(req); var id2 = nextId(d2.requests); var item = { id: id2, init: String(b.init || "—").slice(0, 80), sum: parseInt(b.sum, 10) || 0, src: b.src === "Почта" ? "Почта" : "1С", in: todayIso(), done: "", st: "new", trr: String(b.trr || "—").slice(0, 30), plan: b.plan === "Нет" ? "Нет" : "Да", codeTru: false, dispute: false, subj: String(b.subj || "—").slice(0, 140), justif: String(b.justif || "").slice(0, 300) }; d2.requests.push(item); logIt(d2, id2 + " создана", "закупщик"); saveData(d2); return sendJson(res, 200, { ok: true, id: id2 }); } if (req.method === "PATCH" && url === "/api/requests") { var d3 = loadData(); var b3 = await body(req); var id3 = b3.id; var who3 = b3.who || "система"; var found = null; for (var i = 0; i < d3.requests.length; i++) { if (d3.requests[i].id === id3) { found = d3.requests[i]; break; } } if (!found) return sendJson(res, 404, { ok: false, error: "Заявка не найдена" }); var what = ""; if (b3.st) { found.st = b3.st; what = id3 + " — статус → " + STATUS[b3.st].label; } if (b3.codeTru) { found.codeTru = true; found.trr = b3.trr || found.trr; found.confirmedAt = new Date().toISOString(); what += (what ? " · " : "") + "код ТРУ подтверждён (" + found.trr + ")"; } if (b3.dispute) { found.dispute = true; what += (what ? " · " : "") + "в спорные"; } if (b3.done) { found.done = todayIso(); what += (what ? " · " : "") + "обработана"; } if (b3.justif) { found.justif = String(b3.justif).slice(0, 500); } if (!what) what = id3 + " — обновлена"; logIt(d3, what, who3); saveData(d3); return sendJson(res, 200, { ok: true }); } if (req.method === "GET" && url === "/api/log") { var d4 = loadData(); return sendJson(res, 200, d4.log.slice().reverse().slice(0, 100)); } if (req.method === "GET" && url === "/api/reset") { var d5 = { requests: JSON.parse(JSON.stringify(SEED)), log: [] }; saveData(d5); return sendJson(res, 200, { ok: true }); } if (url === "/api/export.csv" || url === "/api/export.xlsx") { var d6 = loadData(); var aoa = [ ["Номер", "Инициатор", "Сумма", "Статус", "Срок (дней)", "Поступила", "Обработана", "Источник", "Предмет", "Обоснование"] ]; d6.requests.forEach(function (r) { var st = STATUS[r.st] ? STATUS[r.st].label : r.st; var days = daysBetween(r.in, r.done); aoa.push([r.id, r.init, r.sum, st, days, isoToDdMm(r.in), r.done ? isoToDdMm(r.done) : "", r.src, r.subj || "", r.justif || ""]); }); if (url === "/api/export.xlsx") { var ws = XLSX.utils.aoa_to_sheet(aoa); var wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, "Реестр заявок"); var buf = XLSX.write(wb, { type: "buffer", bookType: "xlsx" }); res.writeHead(200, { "Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "Content-Disposition": 'attachment; filename="reestr-zakupki.xlsx"', "Content-Length": buf.length }); return res.end(buf); } // CSV var csv = "\uFEFF" + aoa.map(function (row) { return row.map(function (v) { return '"' + String(v == null ? "" : v).replace(/"/g, '""') + '"'; }).join(";"); }).join("\n"); res.writeHead(200, { "Content-Type": "text/csv; charset=utf-8", "Content-Disposition": 'attachment; filename="reestr-zakupki.csv"', "Content-Length": Buffer.byteLength(csv) }); return res.end(csv); } // ===== статика ===== var file = url === "/" ? "/index.html" : url; var full = path.join(ROOT, file); if (full.startsWith(ROOT) && fs.existsSync(full) && fs.statSync(full).isFile()) { var ext = path.extname(full).toLowerCase(); var types = { ".html": "text/html", ".css": "text/css", ".js": "application/javascript", ".json": "application/json", ".png": "image/png", ".svg": "image/svg+xml", ".woff2": "font/woff2", ".ico": "image/x-icon" }; res.writeHead(200, { "Content-Type": (types[ext] || "application/octet-stream") + "; charset=utf-8" }); return res.end(fs.readFileSync(full)); } res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); res.end("Not found"); }).listen(PORT, function () { console.log("Сервер согласования заявок на закупку запущен на порту " + PORT); });