381 lines
16 KiB
JavaScript
381 lines
16 KiB
JavaScript
const http = require("http");
|
||
const fs = require("fs");
|
||
const path = require("path");
|
||
const formidable = require("formidable");
|
||
const docx = require("docx");
|
||
|
||
const PORT = process.env.PORT || 3000;
|
||
const DATA = path.join(__dirname, "data.json");
|
||
const UPLOADS = path.join(__dirname, "uploads");
|
||
const OUTPUTS = path.join(__dirname, "outputs");
|
||
|
||
if (!fs.existsSync(UPLOADS)) fs.mkdirSync(UPLOADS);
|
||
if (!fs.existsSync(OUTPUTS)) fs.mkdirSync(OUTPUTS);
|
||
|
||
function readData() {
|
||
try { return JSON.parse(fs.readFileSync(DATA, "utf8")); } catch (_) { return { inputData: null, suppliers: [], offers: [], checks: { c1: false, c2: false, c3: false }, files: [], emails: [] }; }
|
||
}
|
||
function writeData(data) {
|
||
fs.writeFileSync(DATA, 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({}); } });
|
||
});
|
||
}
|
||
|
||
function generateDocx(data) {
|
||
const d = data;
|
||
const prices = d.offers.map(o => Number(o.price));
|
||
const min = prices.length ? Math.min(...prices) : 0;
|
||
const avg = prices.length ? Math.round(prices.reduce((a,b) => a+b, 0) / prices.length) : 0;
|
||
const minOffer = d.offers.find(o => Number(o.price) === min);
|
||
const minSupplier = minOffer ? d.suppliers.find(x => x.id === minOffer.supplierId) : null;
|
||
|
||
const doc = new docx.Document({
|
||
sections: [{
|
||
properties: {},
|
||
children: [
|
||
new docx.Paragraph({
|
||
text: "МАРКЕТИНГОВОЕ ЗАКЛЮЧЕНИЕ",
|
||
heading: docx.HeadingLevel.HEADING_1,
|
||
alignment: docx.AlignmentType.CENTER,
|
||
spacing: { after: 400 }
|
||
}),
|
||
new docx.Paragraph({
|
||
text: "Утверждено",
|
||
alignment: docx.AlignmentType.RIGHT,
|
||
spacing: { after: 200 }
|
||
}),
|
||
new docx.Paragraph({
|
||
text: "_______________",
|
||
spacing: { after: 100 }
|
||
}),
|
||
new docx.Paragraph({
|
||
text: '«___» __________ 2026г.',
|
||
spacing: { after: 400 }
|
||
}),
|
||
|
||
new docx.Paragraph({
|
||
text: "1. Входные данные",
|
||
heading: docx.HeadingLevel.HEADING_2,
|
||
spacing: { before: 200, after: 200 }
|
||
}),
|
||
new docx.Paragraph({ text: "Код ЕНС ТРУ: " + (d.inputData?.ensCode || "—") }),
|
||
new docx.Paragraph({ text: "Объем: " + (d.inputData?.volume || "—") }),
|
||
new docx.Paragraph({ text: "Место поставки: " + (d.inputData?.deliveryPlace || "—") }),
|
||
new docx.Paragraph({ text: "Техническая спецификация: " + (d.inputData?.techSpec || "—") }),
|
||
|
||
new docx.Paragraph({
|
||
text: "2. Список поставщиков",
|
||
heading: docx.HeadingLevel.HEADING_2,
|
||
spacing: { before: 200, after: 200 }
|
||
}),
|
||
...d.suppliers.map(s => new docx.Paragraph({
|
||
text: `${s.name} (${s.status}, ${s.resident}) - ${s.contacts || "—"}`
|
||
})),
|
||
|
||
new docx.Paragraph({
|
||
text: "3. Ценовые предложения",
|
||
heading: docx.HeadingLevel.HEADING_2,
|
||
spacing: { before: 200, after: 200 }
|
||
}),
|
||
new docx.Table({
|
||
width: { size: 100, type: docx.WidthType.PERCENTAGE },
|
||
rows: [
|
||
new docx.TableRow({
|
||
children: [
|
||
new docx.TableCell({ children: [new docx.Paragraph("Поставщик")] }),
|
||
new docx.TableCell({ children: [new docx.Paragraph("№ запроса")] }),
|
||
new docx.TableCell({ children: [new docx.Paragraph("Цена (тенге)")] }),
|
||
new docx.TableCell({ children: [new docx.Paragraph("Дата")] })
|
||
]
|
||
}),
|
||
...d.offers.map(o => {
|
||
const s = d.suppliers.find(x => x.id === o.supplierId);
|
||
return new docx.TableRow({
|
||
children: [
|
||
new docx.TableCell({ children: [new docx.Paragraph(s ? s.name : "—")] }),
|
||
new docx.TableCell({ children: [new docx.Paragraph(o.requestNumber || "—")] }),
|
||
new docx.TableCell({ children: [new docx.Paragraph(Number(o.price).toLocaleString())] }),
|
||
new docx.TableCell({ children: [new docx.Paragraph(o.date || "—")] })
|
||
]
|
||
});
|
||
})
|
||
]
|
||
}),
|
||
|
||
new docx.Paragraph({
|
||
text: "4. Проверка по ст. 31",
|
||
heading: docx.HeadingLevel.HEADING_2,
|
||
spacing: { before: 200, after: 200 }
|
||
}),
|
||
new docx.Paragraph({ text: "☐ Поставщик НЕ в реестре недобросовестных: " + (d.checks.c1 ? "✓" : "☐") }),
|
||
new docx.Paragraph({ text: "☐ НЕ в перечне терроризма/экстремизма: " + (d.checks.c2 ? "✓" : "☐") }),
|
||
new docx.Paragraph({ text: "☐ НЕ из офшорной зоны: " + (d.checks.c3 ? "✓" : "☐") }),
|
||
|
||
new docx.Paragraph({
|
||
text: "5. Анализ цен",
|
||
heading: docx.HeadingLevel.HEADING_2,
|
||
spacing: { before: 200, after: 200 }
|
||
}),
|
||
new docx.Paragraph({ text: "Количество предложений: " + d.offers.length }),
|
||
new docx.Paragraph({ text: "Минимальная цена: " + min.toLocaleString() + " тенге" }),
|
||
new docx.Paragraph({ text: "Максимальная цена: " + (Math.max(...prices) || 0).toLocaleString() + " тенге" }),
|
||
new docx.Paragraph({ text: "Средняя цена: " + avg.toLocaleString() + " тенге" }),
|
||
minSupplier ? new docx.Paragraph({ text: "Лучшее предложение: " + minSupplier.name + " - " + min.toLocaleString() + " тенге", shading: { fill: "d4edda" } }) : null,
|
||
|
||
new docx.Paragraph({
|
||
text: "6. Заключение",
|
||
heading: docx.HeadingLevel.HEADING_2,
|
||
spacing: { before: 200, after: 200 }
|
||
}),
|
||
new docx.Paragraph({
|
||
text: "На основании цен потенциальных поставщиков/производителей определены маркетинговые цены без учета НДС (КП представлены)."
|
||
}),
|
||
minSupplier ? new docx.Paragraph({
|
||
text: "РЕКОМЕНДАЦИЯ: " + minSupplier.name + " (" + minSupplier.status.toLowerCase() + ", " + minSupplier.resident.toLowerCase() + ") по цене " + min.toLocaleString() + " тенге.",
|
||
spacing: { after: 200 }
|
||
}) : null,
|
||
|
||
new docx.Paragraph({ text: "Исполнитель: __________________ / _________________", spacing: { before: 400 } }),
|
||
new docx.Paragraph({ text: "Инициатор потребности: __________________ / _________________", spacing: { before: 200 } }),
|
||
new docx.Paragraph({ text: "Утверждено: __________________ / _________________", spacing: { before: 400, after: 200 } }),
|
||
new docx.Paragraph({ text: '«___» __________ 2026г.', spacing: { after: 200 } })
|
||
].filter(Boolean)
|
||
}]
|
||
});
|
||
|
||
return docx.Packer.toBuffer(doc);
|
||
}
|
||
|
||
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);
|
||
return res.end();
|
||
}
|
||
|
||
if (req.method === "GET" && req.url === "/api/data") {
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
return res.end(JSON.stringify(readData()));
|
||
}
|
||
|
||
if (req.method === "GET" && req.url === "/api/files") {
|
||
const data = readData();
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
return res.end(JSON.stringify(data.files || []));
|
||
}
|
||
|
||
if (req.method === "GET" && req.url === "/api/emails") {
|
||
const data = readData();
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
return res.end(JSON.stringify(data.emails || []));
|
||
}
|
||
|
||
if (req.method === "POST" && req.url === "/api/upload") {
|
||
const form = formidable({ uploadDir: UPLOADS, keepExtensions: true });
|
||
form.parse(req, async (err, fields, files) => {
|
||
if (err) {
|
||
res.writeHead(500, { "Content-Type": "application/json" });
|
||
return res.end(JSON.stringify({ error: err.message }));
|
||
}
|
||
const data = readData();
|
||
const uploaded = [];
|
||
if (files.file && Array.isArray(files.file)) {
|
||
for (const f of files.file) {
|
||
const fileName = Date.now() + "_" + path.basename(f.filepath);
|
||
const newPath = path.join(UPLOADS, fileName);
|
||
fs.renameSync(f.filepath, newPath);
|
||
const fileRec = {
|
||
id: Date.now() + Math.random(),
|
||
name: f.originalFilename || fileName,
|
||
path: "/api/files/" + fileName,
|
||
section: fields.section ? fields.section[0] : "other",
|
||
size: f.size,
|
||
date: new Date().toISOString()
|
||
};
|
||
data.files.push(fileRec);
|
||
uploaded.push(fileRec);
|
||
}
|
||
}
|
||
writeData(data);
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
return res.end(JSON.stringify({ ok: true, files: uploaded }));
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (req.method === "GET" && req.url.startsWith("/api/files/")) {
|
||
const fileName = req.url.split("/")[3];
|
||
const filePath = path.join(UPLOADS, fileName);
|
||
if (fs.existsSync(filePath)) {
|
||
const ext = path.extname(fileName).toLowerCase();
|
||
const mime = ext === ".pdf" ? "application/pdf" : ext === ".docx" ? "application/vnd.openxmlformats-officedocument.wordprocessingml.document" : ext === ".xlsx" ? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" : "application/octet-stream";
|
||
res.writeHead(200, { "Content-Type": mime, "Content-Disposition": "attachment; filename=\"" + fileName + "\"" });
|
||
return res.end(fs.readFileSync(filePath));
|
||
}
|
||
res.writeHead(404); res.end("File not found");
|
||
return;
|
||
}
|
||
|
||
if (req.method === "GET" && req.url.startsWith("/api/outputs/")) {
|
||
const fileName = req.url.split("/")[3];
|
||
const filePath = path.join(OUTPUTS, fileName);
|
||
if (fs.existsSync(filePath)) {
|
||
res.writeHead(200, { "Content-Type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "Content-Disposition": "attachment; filename=\"" + fileName + "\"" });
|
||
return res.end(fs.readFileSync(filePath));
|
||
}
|
||
res.writeHead(404); res.end("File not found");
|
||
return;
|
||
}
|
||
|
||
if (req.method === "DELETE" && req.url.startsWith("/api/files/")) {
|
||
const fileName = req.url.split("/")[3];
|
||
const data = readData();
|
||
const fileRec = data.files.find(f => f.path.endsWith(fileName));
|
||
if (fileRec) {
|
||
const filePath = path.join(UPLOADS, fileName);
|
||
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||
data.files = data.files.filter(f => f.path !== fileRec.path);
|
||
writeData(data);
|
||
}
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
return res.end(JSON.stringify({ ok: true }));
|
||
}
|
||
|
||
if (req.method === "POST" && req.url === "/api/generate-docx") {
|
||
try {
|
||
const data = readData();
|
||
const buffer = await generateDocx(data);
|
||
const fileName = "MAC_" + (data.inputData?.ensCode?.replace(/[^a-zA-Z0-9]/g, "") || "otchet") + "_" + Date.now() + ".docx";
|
||
const filePath = path.join(OUTPUTS, fileName);
|
||
fs.writeFileSync(filePath, buffer);
|
||
|
||
const fileRec = {
|
||
id: Date.now(),
|
||
name: fileName.replace(/_\d+\.docx$/, ".docx"),
|
||
path: "/api/outputs/" + fileName,
|
||
section: "Выходные документы",
|
||
size: buffer.length,
|
||
date: new Date().toISOString()
|
||
};
|
||
data.files.push(fileRec);
|
||
writeData(data);
|
||
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
return res.end(JSON.stringify({ ok: true, file: fileRec }));
|
||
} catch (e) {
|
||
console.error("DOCX error:", e);
|
||
res.writeHead(500, { "Content-Type": "application/json" });
|
||
return res.end(JSON.stringify({ error: e.message }));
|
||
}
|
||
}
|
||
|
||
if (req.method === "POST" && req.url === "/api/send-emails") {
|
||
try {
|
||
const d = await body(req);
|
||
const data = readData();
|
||
const inputData = data.inputData || {};
|
||
|
||
const emails = [];
|
||
for (const supplier of data.suppliers) {
|
||
const emailData = {
|
||
id: Date.now() + Math.random(),
|
||
to: supplier.contacts || "",
|
||
subject: "Запрос коммерческого предложения: " + (inputData.ensCode || "ТРУ"),
|
||
body: `Уважаемые партнёры!\n\nПросим предоставить коммерческое предложение на поставку:\n\n` +
|
||
`Код ЕНС: ${inputData.ensCode || "—"}\n` +
|
||
`Объем: ${inputData.volume || "—"}\n` +
|
||
`Место поставки: ${inputData.deliveryPlace || "—"}\n` +
|
||
`Техническая спецификация: ${inputData.techSpec || "—"}\n\n` +
|
||
`Просим ответить до: ${d.deadline || "укажите срок"}\n\n` +
|
||
`С уважением,\nОтдел закупок`,
|
||
supplierName: supplier.name,
|
||
date: new Date().toISOString(),
|
||
status: "Отправлено (симуляция)"
|
||
};
|
||
data.emails.push(emailData);
|
||
emails.push(emailData);
|
||
}
|
||
writeData(data);
|
||
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
return res.end(JSON.stringify({ ok: true, count: emails.length, emails }));
|
||
} catch (e) {
|
||
res.writeHead(500, { "Content-Type": "application/json" });
|
||
return res.end(JSON.stringify({ error: e.message }));
|
||
}
|
||
}
|
||
|
||
if (req.method === "POST" && req.url === "/api/inputData") {
|
||
const d = await body(req);
|
||
const data = readData();
|
||
data.inputData = d;
|
||
writeData(data);
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
return res.end(JSON.stringify({ ok: true }));
|
||
}
|
||
|
||
if (req.method === "POST" && req.url === "/api/suppliers") {
|
||
const s = await body(req);
|
||
const data = readData();
|
||
data.suppliers.push({ id: Date.now(), ...s });
|
||
writeData(data);
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
return res.end(JSON.stringify({ ok: true }));
|
||
}
|
||
|
||
if (req.method === "POST" && req.url === "/api/offers") {
|
||
const o = await body(req);
|
||
const data = readData();
|
||
data.offers.push({ id: Date.now(), ...o });
|
||
writeData(data);
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
return res.end(JSON.stringify({ ok: true }));
|
||
}
|
||
|
||
if (req.method === "POST" && req.url === "/api/checks") {
|
||
const c = await body(req);
|
||
const data = readData();
|
||
data.checks = c;
|
||
writeData(data);
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
return res.end(JSON.stringify({ ok: true }));
|
||
}
|
||
|
||
if (req.method === "DELETE" && req.url.startsWith("/api/suppliers/")) {
|
||
const id = parseInt(req.url.split("/")[3]);
|
||
const data = readData();
|
||
data.suppliers = data.suppliers.filter(s => s.id !== id);
|
||
data.offers = data.offers.filter(o => o.supplierId !== id);
|
||
writeData(data);
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
return res.end(JSON.stringify({ ok: true }));
|
||
}
|
||
|
||
if (req.method === "DELETE" && req.url.startsWith("/api/offers/")) {
|
||
const id = parseInt(req.url.split("/")[3]);
|
||
const data = readData();
|
||
data.offers = data.offers.filter(o => o.id !== id);
|
||
writeData(data);
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
return res.end(JSON.stringify({ ok: true }));
|
||
}
|
||
|
||
let file = req.url === "/" ? "/index.html" : req.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" : ext === ".js" ? "application/javascript" : "text/html";
|
||
res.writeHead(200, { "Content-Type": type + "; charset=utf-8" });
|
||
return res.end(fs.readFileSync(full));
|
||
}
|
||
res.writeHead(404); res.end("Not found");
|
||
}).listen(PORT, () => console.log("Сервер запущен на порту " + PORT));
|