276 lines
14 KiB
JavaScript
276 lines
14 KiB
JavaScript
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 '<span class="kt-ai-chip" data-tone="' + s.tone + '">' + s.label + "</span>";
|
||
}
|
||
|
||
// ИИ-подсказки по кодам ТРУ: для каждой заявки — 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);
|
||
});
|