v4: авто-рассылка КП + DOCX

This commit is contained in:
Ahtanova.D 2026-08-21 11:03:47 +00:00
parent 9b31f446bd
commit ee88cd9bad
3 changed files with 325 additions and 55 deletions

View File

@ -19,12 +19,13 @@
.form-group label { display: block; margin-bottom: 6px; font-weight: 500; font-size: 14px; color: #6e6e73; } .form-group label { display: block; margin-bottom: 6px; font-weight: 500; font-size: 14px; color: #6e6e73; }
.form-group input, .form-group select, .form-group textarea { width: 100%; padding: 12px; border: 1px solid #d2d2d7; border-radius: 10px; font-size: 15px; } .form-group input, .form-group select, .form-group textarea { width: 100%; padding: 12px; border: 1px solid #d2d2d7; border-radius: 10px; font-size: 15px; }
.form-group textarea { min-height: 100px; resize: vertical; } .form-group textarea { min-height: 100px; resize: vertical; }
.btn { padding: 12px 24px; border: none; border-radius: 10px; cursor: pointer; font-size: 14px; font-weight: 600; transition: all 0.2s; } .btn { padding: 12px 24px; border: none; border-radius: 10px; cursor: pointer; font-size: 14px; font-weight: 600; transition: all 0.2s; margin-right: 8px; margin-bottom: 8px; }
.btn-primary { background: #007AFF; color: white; } .btn-primary { background: #007AFF; color: white; }
.btn-primary:hover { background: #0056b3; } .btn-primary:hover { background: #0056b3; }
.btn-success { background: #34C759; color: white; } .btn-success { background: #34C759; color: white; }
.btn-danger { background: #FF3B30; color: white; padding: 8px 16px; font-size: 13px; } .btn-danger { background: #FF3B30; color: white; padding: 8px 16px; font-size: 13px; }
.btn-secondary { background: #8e8e93; color: white; } .btn-secondary { background: #8e8e93; color: white; }
.btn-outline { background: white; border: 2px solid #007AFF; color: #007AFF; }
table { width: 100%; border-collapse: collapse; margin-top: 16px; font-size: 14px; } table { width: 100%; border-collapse: collapse; margin-top: 16px; font-size: 14px; }
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #e5e5ea; } th, td { padding: 12px; text-align: left; border-bottom: 1px solid #e5e5ea; }
th { background: #f5f5f7; font-weight: 600; font-size: 13px; color: #6e6e73; text-transform: uppercase; } th { background: #f5f5f7; font-weight: 600; font-size: 13px; color: #6e6e73; text-transform: uppercase; }
@ -46,10 +47,15 @@
.file-item a { color: #007AFF; text-decoration: none; font-weight: 500; } .file-item a { color: #007AFF; text-decoration: none; font-weight: 500; }
.file-item a:hover { text-decoration: underline; } .file-item a:hover { text-decoration: underline; }
.file-meta { font-size: 12px; color: #86868b; margin-left: 12px; } .file-meta { font-size: 12px; color: #86868b; margin-left: 12px; }
.upload-area { border: 2px dashed #d2d2d7; border-radius: 12px; padding: 24px; text-align: center; background: #fafafa; cursor: pointer; transition: all 0.2s; } .upload-area { border: 2px dashed #d2d2d7; border-radius: 12px; padding: 24px; text-align: center; background: #fafafa; cursor: pointer; transition: all 0.2s; margin-bottom: 16px; }
.upload-area:hover { border-color: #007AFF; background: #f0f8ff; } .upload-area:hover { border-color: #007AFF; background: #f0f8ff; }
.upload-area.dragover { border-color: #34C759; background: #e8f5e9; } .upload-area.dragover { border-color: #34C759; background: #e8f5e9; }
.upload-icon { font-size: 48px; margin-bottom: 12px; } .upload-icon { font-size: 48px; margin-bottom: 12px; }
.email-log { margin-top: 16px; max-height: 300px; overflow-y: auto; }
.email-item { padding: 12px; background: #f0f8ff; border-left: 4px solid #007AFF; margin-bottom: 8px; border-radius: 4px; }
.email-item h4 { margin-bottom: 4px; font-size: 14px; }
.email-item p { font-size: 13px; color: #6e6e73; margin: 4px 0; }
.status-badge { display: inline-block; padding: 4px 8px; background: #34C759; color: white; border-radius: 4px; font-size: 11px; }
@media print { @media print {
.btn, .no-print, .upload-area { display: none !important; } .btn, .no-print, .upload-area { display: none !important; }
.card { box-shadow: none; border: 1px solid #ddd; } .card { box-shadow: none; border: 1px solid #ddd; }
@ -65,7 +71,8 @@
<div class="container"> <div class="container">
<div class="no-print" style="text-align: right; margin-bottom: 20px;"> <div class="no-print" style="text-align: right; margin-bottom: 20px;">
<button class="btn btn-primary" onclick="window.print()">🖨 Печать / PDF</button> <button class="btn btn-primary" onclick="window.print()">🖨 Печать / PDF</button>
<button class="btn btn-success" onclick="exportToExcel()">📤 Выгрузить в Excel</button> <button class="btn btn-success" onclick="generateDocx()">📄 Сформировать DOCX</button>
<button class="btn btn-outline" onclick="sendEmails()">✉️ Отправить запросы КП</button>
</div> </div>
<!-- Раздел 1: Входные данные --> <!-- Раздел 1: Входные данные -->
@ -110,6 +117,10 @@
<!-- Раздел 2: Список рассылки --> <!-- Раздел 2: Список рассылки -->
<div class="card"> <div class="card">
<h2 class="section-title"><span class="section-num">2</span>Список рассылки и поставщики</h2> <h2 class="section-title"><span class="section-num">2</span>Список рассылки и поставщики</h2>
<div class="no-print" style="margin-bottom: 16px; padding: 16px; background: #fff3cd; border-radius: 10px;">
<strong>✉️ Авто-рассылка:</strong> укажите email в контактах поставщика (например: info@company.kz) → нажмите «Отправить запросы КП» → письма сформируются для всех поставщиков.
</div>
<form id="supplierForm" style="margin-bottom: 20px;"> <form id="supplierForm" style="margin-bottom: 20px;">
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
@ -135,8 +146,8 @@
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Контактные данные (веб-сайт, телефон, адрес, email)</label> <label>Контактные данные (веб-сайт, телефон, адрес, <strong>email</strong>)</label>
<input type="text" name="contacts" placeholder="+7 XXX XXX-XX-XX, example.kz, info@example.kz"> <input type="text" name="contacts" placeholder="info@company.kz, +7 XXX XXX-XX-XX, example.kz">
</div> </div>
<div class="form-group"> <div class="form-group">
@ -287,15 +298,23 @@
</div> </div>
</div> </div>
<!-- Журнал рассылки -->
<div class="card no-print">
<h2 class="section-title">✉️ Журнал отправки запросов</h2>
<div id="emailLog" class="email-log">
<p style="color: #86868b;">Запросы ещё не отправлялись</p>
</div>
</div>
<!-- Все файлы --> <!-- Все файлы -->
<div class="card"> <div class="card">
<h2 class="section-title">📎 Все прикреплённые файлы</h2> <h2 class="section-title">📎 Все файлы</h2>
<div class="file-list" id="allFilesList"></div> <div class="file-list" id="allFilesList"></div>
</div> </div>
</div> </div>
<script> <script>
let data = { inputData: null, suppliers: [], offers: [], checks: { c1: false, c2: false, c3: false }, files: [] }; let data = { inputData: null, suppliers: [], offers: [], checks: { c1: false, c2: false, c3: false }, files: [], emails: [] };
async function loadData() { async function loadData() {
const r = await fetch('/api/data'); const r = await fetch('/api/data');
@ -309,6 +328,7 @@
document.getElementById('check2').checked = data.checks.c2; document.getElementById('check2').checked = data.checks.c2;
document.getElementById('check3').checked = data.checks.c3; document.getElementById('check3').checked = data.checks.c3;
renderFiles(); renderFiles();
renderEmails();
generateConclusion(); generateConclusion();
} }
@ -382,6 +402,7 @@
const inputFiles = data.files.filter(f => f.section === 'Входные данные'); const inputFiles = data.files.filter(f => f.section === 'Входные данные');
const supplierFiles = data.files.filter(f => f.section === 'Поставщики'); const supplierFiles = data.files.filter(f => f.section === 'Поставщики');
const offerFiles = data.files.filter(f => f.section === 'Предложения'); const offerFiles = data.files.filter(f => f.section === 'Предложения');
const outputFiles = data.files.filter(f => f.section === 'Выходные документы');
function renderFileList(files, container) { function renderFileList(files, container) {
if (files.length === 0) { container.innerHTML = ''; return; } if (files.length === 0) { container.innerHTML = ''; return; }
@ -397,7 +418,23 @@
renderFileList(inputFiles, section1); renderFileList(inputFiles, section1);
renderFileList(supplierFiles, section2); renderFileList(supplierFiles, section2);
renderFileList(offerFiles, section3); renderFileList(offerFiles, section3);
renderFileList(data.files, allFiles); renderFileList(outputFiles, allFiles);
}
function renderEmails() {
const log = document.getElementById('emailLog');
if (!data.emails || data.emails.length === 0) {
log.innerHTML = '<p style="color: #86868b;">Запросы ещё не отправлялись</p>';
return;
}
log.innerHTML = data.emails.slice().reverse().map(e =>
'<div class="email-item">' +
'<h4>✉️ ' + e.supplierName + ' <span class="status-badge">' + e.status + '</span></h4>' +
'<p><strong>Кому:</strong> ' + e.to + '</p>' +
'<p><strong>Тема:</strong> ' + e.subject + '</p>' +
'<p><strong>Дата:</strong> ' + new Date(e.date).toLocaleString() + '</p>' +
'</div>'
).join('');
} }
async function handleFiles(files, section) { async function handleFiles(files, section) {
@ -421,6 +458,62 @@
loadData(); loadData();
} }
async function generateDocx() {
const btn = event.target;
btn.disabled = true;
btn.textContent = 'Формирование...';
try {
const r = await fetch('/api/generate-docx', { method: 'POST' });
const result = await r.json();
if (result.ok) {
window.location.href = result.file.path;
loadData();
} else {
alert('Ошибка: ' + result.error);
}
} catch (e) {
alert('Ошибка формирования: ' + e.message);
} finally {
btn.disabled = false;
btn.textContent = '📄 Сформировать DOCX';
}
}
async function sendEmails() {
if (data.suppliers.length === 0) {
alert('Сначала добавьте поставщиков в раздел 2');
return;
}
const deadline = prompt('Укажите срок ответа (например: до 25.09.2026):', 'до ' + new Date(Date.now() + 7*24*60*60*1000).toLocaleDateString('ru-RU'));
if (!deadline) return;
const btn = event.target;
btn.disabled = true;
btn.textContent = 'Отправка...';
try {
const r = await fetch('/api/send-emails', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deadline })
});
const result = await r.json();
if (result.ok) {
alert('Запросы сформированы и отправлены ' + result.count + ' поставщикам!\n\n(В реальной системе письма уйдут на email из контактов. Сейчас — симуляция с записью в журнал.)');
loadData();
} else {
alert('Ошибка: ' + result.error);
}
} catch (e) {
alert('Ошибка отправки: ' + e.message);
} finally {
btn.disabled = false;
btn.textContent = '✉️ Отправить запросы КП';
}
}
function generateConclusion(min, avg) { function generateConclusion(min, avg) {
const minOffer = data.offers.find(o => Number(o.price) === min); const minOffer = data.offers.find(o => Number(o.price) === min);
const s = minOffer ? data.suppliers.find(x => x.id === minOffer.supplierId) : null; const s = minOffer ? data.suppliers.find(x => x.id === minOffer.supplierId) : null;
@ -511,51 +604,6 @@
alert('Проверка сохранена'); alert('Проверка сохранена');
} }
function exportToExcel() {
const head = ["Раздел", "Параметр", "Значение"];
const rows = [];
if (data.inputData) {
rows.push(["1. Входные данные", "Код ЕНС ТРУ", data.inputData.ensCode || '']);
rows.push(["1. Входные данные", "Объем", data.inputData.volume || '']);
rows.push(["1. Входные данные", "Место поставки", data.inputData.deliveryPlace || '']);
rows.push(["1. Входные данные", "Техническая спецификация", data.inputData.techSpec || '']);
}
data.suppliers.forEach((s, i) => {
rows.push(["2. Поставщики", "Компания " + (i+1), s.name]);
rows.push(["2. Поставщики", "Резидентство", s.resident]);
rows.push(["2. Поставщики", "Статус", s.status]);
rows.push(["2. Поставщики", "Контакты", s.contacts || '']);
});
data.offers.forEach((o, i) => {
const s = data.suppliers.find(x => x.id === o.supplierId);
rows.push(["3. Предложения", "Предложение " + (i+1) + " - " + (s ? s.name : ''), o.price + ' тенге']);
});
rows.push(["4. Проверка ст.31", "Пункт 1", data.checks.c1 ? 'Подтверждено' : 'Не проверено']);
rows.push(["4. Проверка ст.31", "Пункт 2", data.checks.c2 ? 'Подтверждено' : 'Не проверено']);
rows.push(["4. Проверка ст.31", "Пункт 3", data.checks.c3 ? 'Подтверждено' : 'Не проверено']);
const prices = data.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;
rows.push(["5. Анализ", "Минимальная цена", min.toLocaleString() + ' тенге']);
rows.push(["5. Анализ", "Средняя цена", avg.toLocaleString() + ' тенге']);
rows.push(["5. Анализ", "Количество предложений", data.offers.length]);
data.files.forEach(f => {
rows.push(["Файлы", f.name, f.section]);
});
const csv = '\uFEFF' + [head.join(';'), ...rows.map(r => r.map(v => '"' + String(v).replace(/"/g, '""') + '"').join(';'))].join('\n');
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }));
a.download = 'MAC_' + (data.inputData?.ensCode || 'otchet') + '.csv';
a.click();
}
const uploadAreas = document.querySelectorAll('.upload-area'); const uploadAreas = document.querySelectorAll('.upload-area');
uploadAreas.forEach(area => { uploadAreas.forEach(area => {
area.addEventListener('dragover', (e) => { area.addEventListener('dragover', (e) => {

View File

@ -2,5 +2,9 @@
"name": "price-analysis", "name": "price-analysis",
"private": true, "private": true,
"type": "commonjs", "type": "commonjs",
"scripts": { "start": "node server.js" } "scripts": { "start": "node server.js" },
"dependencies": {
"docx": "^8.5.0",
"formidable": "^3.5.0"
}
} }

220
server.js
View File

@ -2,15 +2,18 @@ const http = require("http");
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const formidable = require("formidable"); const formidable = require("formidable");
const docx = require("docx");
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
const DATA = path.join(__dirname, "data.json"); const DATA = path.join(__dirname, "data.json");
const UPLOADS = path.join(__dirname, "uploads"); const UPLOADS = path.join(__dirname, "uploads");
const OUTPUTS = path.join(__dirname, "outputs");
if (!fs.existsSync(UPLOADS)) fs.mkdirSync(UPLOADS); if (!fs.existsSync(UPLOADS)) fs.mkdirSync(UPLOADS);
if (!fs.existsSync(OUTPUTS)) fs.mkdirSync(OUTPUTS);
function readData() { function readData() {
try { return JSON.parse(fs.readFileSync(DATA, "utf8")); } catch (_) { return { inputData: null, suppliers: [], offers: [], checks: { c1: false, c2: false, c3: false }, files: [] }; } 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) { function writeData(data) {
fs.writeFileSync(DATA, JSON.stringify(data, null, 2)); fs.writeFileSync(DATA, JSON.stringify(data, null, 2));
@ -23,7 +26,141 @@ function body(req) {
}); });
} }
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) => { 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") { if (req.method === "GET" && req.url === "/api/data") {
res.writeHead(200, { "Content-Type": "application/json" }); res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify(readData())); return res.end(JSON.stringify(readData()));
@ -34,6 +171,12 @@ http.createServer(async (req, res) => {
res.writeHead(200, { "Content-Type": "application/json" }); res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify(data.files || [])); 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") { if (req.method === "POST" && req.url === "/api/upload") {
const form = formidable({ uploadDir: UPLOADS, keepExtensions: true }); const form = formidable({ uploadDir: UPLOADS, keepExtensions: true });
@ -80,6 +223,17 @@ http.createServer(async (req, res) => {
res.writeHead(404); res.end("File not found"); res.writeHead(404); res.end("File not found");
return; 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/")) { if (req.method === "DELETE" && req.url.startsWith("/api/files/")) {
const fileName = req.url.split("/")[3]; const fileName = req.url.split("/")[3];
@ -94,6 +248,70 @@ http.createServer(async (req, res) => {
res.writeHead(200, { "Content-Type": "application/json" }); res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ ok: true })); 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") { if (req.method === "POST" && req.url === "/api/inputData") {
const d = await body(req); const d = await body(req);