soglasovanie-zayavok-na-zaku/server.js

276 lines
14 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 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);
});