Compare commits

..

3 Commits

4 changed files with 787 additions and 60 deletions

View File

@ -19,11 +19,13 @@
.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 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:hover { background: #0056b3; }
.btn-success { background: #34C759; color: white; }
.btn-danger { background: #FF3B30; color: white; padding: 8px 16px; font-size: 13px; }
.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; }
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; }
@ -32,7 +34,6 @@
.checkbox-group input[type="checkbox"] { width: 20px; height: 20px; margin-top: 2px; }
.checkbox-group label { font-size: 14px; line-height: 1.4; cursor: pointer; }
.price-highlight { background: #d4edda; font-weight: 600; }
.price-low { background: #fff3cd; }
.empty { text-align: center; padding: 40px; color: #86868b; }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 16px; margin-bottom: 24px; }
.stat { background: white; padding: 20px; border-radius: 12px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
@ -41,8 +42,22 @@
.conclusion-box { background: #f0f8ff; border: 2px solid #007AFF; border-radius: 12px; padding: 20px; margin-top: 20px; }
.signature-block { margin-top: 40px; display: grid; grid-template-columns: 1fr 1fr; gap: 40px; }
.signature-line { border-top: 1px solid #1d1d1f; padding-top: 8px; margin-top: 40px; }
.file-list { margin-top: 16px; }
.file-item { display: flex; align-items: center; justify-content: space-between; padding: 12px; background: #f9f9fb; border-radius: 8px; margin-bottom: 8px; }
.file-item a { color: #007AFF; text-decoration: none; font-weight: 500; }
.file-item a:hover { text-decoration: underline; }
.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; margin-bottom: 16px; }
.upload-area:hover { border-color: #007AFF; background: #f0f8ff; }
.upload-area.dragover { border-color: #34C759; background: #e8f5e9; }
.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 {
.btn, .no-print { display: none !important; }
.btn, .no-print, .upload-area { display: none !important; }
.card { box-shadow: none; border: 1px solid #ddd; }
}
</style>
@ -56,7 +71,8 @@
<div class="container">
<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-success" onclick="exportToExcel()">📥 Выгрузить в Excel</button>
<button class="btn btn-success" onclick="generateDocx()">📄 Сформировать DOCX</button>
<button class="btn btn-outline" onclick="sendEmails()">✉️ Отправить запросы КП</button>
</div>
<!-- Раздел 1: Входные данные -->
@ -81,7 +97,19 @@
<label>Техническая спецификация</label>
<textarea name="techSpec" placeholder="описание товара/работы/услуги"></textarea>
</div>
<button type="submit" class="btn btn-primary">Сохранить</button>
<div class="form-group">
<label>📎 Прикрепить файлы (ТЗ, спецификации, письма)</label>
<div class="upload-area" id="uploadArea1" onclick="document.getElementById('fileInput1').click()">
<div class="upload-icon">📎</div>
<div>Нажмите для загрузки или перетащите файлы сюда</div>
<div style="font-size: 12px; color: #86868b; margin-top: 8px;">DOCX, XLSX, PDF до 10 МБ</div>
</div>
<input type="file" id="fileInput1" multiple accept=".doc,.docx,.xls,.xlsx,.pdf" style="display: none;" onchange="handleFiles(this.files, 'Входные данные')">
</div>
<div class="file-list" id="filesSection1"></div>
<button type="button" class="btn btn-primary" onclick="saveInputData()">💾 Сохранить входные данные</button>
</form>
<div id="inputDataDisplay" style="margin-top: 20px; padding: 16px; background: #f9f9fb; border-radius: 10px; display: none;"></div>
</div>
@ -89,6 +117,10 @@
<!-- Раздел 2: Список рассылки -->
<div class="card">
<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;">
<div class="form-row">
<div class="form-group">
@ -114,9 +146,20 @@
</div>
</div>
<div class="form-group">
<label>Контактные данные (веб-сайт, телефон, адрес, email)</label>
<input type="text" name="contacts" placeholder="+7 XXX XXX-XX-XX, example.kz, info@example.kz">
<label>Контактные данные (веб-сайт, телефон, адрес, <strong>email</strong>)</label>
<input type="text" name="contacts" placeholder="info@company.kz, +7 XXX XXX-XX-XX, example.kz">
</div>
<div class="form-group">
<label>📎 Прикрепить файлы (КП, уставные документы)</label>
<div class="upload-area" onclick="document.getElementById('fileInput2').click()">
<div class="upload-icon">📎</div>
<div>Нажмите для загрузки файлов</div>
</div>
<input type="file" id="fileInput2" multiple accept=".doc,.docx,.xls,.xlsx,.pdf" style="display: none;" onchange="handleFiles(this.files, 'Поставщики')">
</div>
<div class="file-list" id="filesSection2"></div>
<button type="submit" class="btn btn-success">Добавить поставщика</button>
</form>
@ -151,6 +194,17 @@
<input type="date" name="date">
</div>
</div>
<div class="form-group">
<label>📎 Прикрепить файлы (КП, письма)</label>
<div class="upload-area" onclick="document.getElementById('fileInput3').click()">
<div class="upload-icon">📎</div>
<div>Нажмите для загрузки файлов</div>
</div>
<input type="file" id="fileInput3" multiple accept=".doc,.docx,.xls,.xlsx,.pdf" style="display: none;" onchange="handleFiles(this.files, 'Предложения')">
</div>
<div class="file-list" id="filesSection3"></div>
<button type="submit" class="btn btn-success">Добавить предложение</button>
</form>
@ -168,15 +222,15 @@
<h2 class="section-title"><span class="section-num">4</span>Заключение по проверке (ст. 31 Порядка закупок)</h2>
<div class="checkbox-group">
<input type="checkbox" id="check1">
<label for="check1">Потенциальный поставщик или его субподрядчик (соисполнитель) либо участник консорциума НЕ состоит в реестре недобросовестных участников государственных закупок и (или) в реестре недобросовестных участников закупок, и (или) в перечне ненадежных потенциальных поставщиков (поставщиков) Фонда, и (или) в списке должников, в отношении которых вступило в законную силу решение суда о признании их банкротами</label>
<label for="check1">Потенциальный поставщик или его субподрядчик НЕ состоит в реестре недобросовестных участников</label>
</div>
<div class="checkbox-group">
<input type="checkbox" id="check2">
<label for="check2">Потенциальный поставщик и (или) привлекаемый им субподрядчик (соисполнитель), и (или) их руководитель, и (или) учредители (акционеры) НЕ включены в перечень организаций и лиц, связанных с финансированием терроризма и экстремизма</label>
<label for="check2">НЕ включены в перечень организаций, связанных с финансированием терроризма</label>
</div>
<div class="checkbox-group">
<input type="checkbox" id="check3">
<label for="check3">Потенциальный поставщик НЕ является юридическим лицом, местом регистрации которого является государство или территория, включенные в перечень государств с льготным налогообложением</label>
<label for="check3">НЕ является юридическим лицом из государства с льготным налогообложением</label>
</div>
<button class="btn btn-primary no-print" onclick="saveChecks()">Сохранить проверку</button>
</div>
@ -243,10 +297,24 @@
<p style="margin-top: 16px;">«___» __________ 2026 г.</p>
</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">
<h2 class="section-title">📎 Все файлы</h2>
<div class="file-list" id="allFilesList"></div>
</div>
</div>
<script>
let data = { inputData: null, suppliers: [], offers: [], checks: { c1: false, c2: false, c3: false } };
let data = { inputData: null, suppliers: [], offers: [], checks: { c1: false, c2: false, c3: false }, files: [], emails: [] };
async function loadData() {
const r = await fetch('/api/data');
@ -259,6 +327,8 @@
document.getElementById('check1').checked = data.checks.c1;
document.getElementById('check2').checked = data.checks.c2;
document.getElementById('check3').checked = data.checks.c3;
renderFiles();
renderEmails();
generateConclusion();
}
@ -323,6 +393,127 @@
generateConclusion(min, avg);
}
function renderFiles() {
const allFiles = document.getElementById('allFilesList');
const section1 = document.getElementById('filesSection1');
const section2 = document.getElementById('filesSection2');
const section3 = document.getElementById('filesSection3');
const inputFiles = data.files.filter(f => f.section === 'Входные данные');
const supplierFiles = 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) {
if (files.length === 0) { container.innerHTML = ''; return; }
container.innerHTML = files.map(f =>
'<div class="file-item">' +
'<a href="' + f.path + '" download>📄 ' + f.name + '</a>' +
'<span class="file-meta">' + new Date(f.date).toLocaleDateString() + '</span>' +
'<button class="btn btn-danger no-print" onclick="deleteFile(\'' + f.path.split('/').pop() + '\')">✕</button>' +
'</div>'
).join('');
}
renderFileList(inputFiles, section1);
renderFileList(supplierFiles, section2);
renderFileList(offerFiles, section3);
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) {
if (!files.length) return;
const formData = new FormData();
for (const f of files) {
formData.append('file', f);
}
formData.append('section', section);
const r = await fetch('/api/upload', { method: 'POST', body: formData });
const result = await r.json();
if (result.ok) {
loadData();
}
}
async function deleteFile(fileName) {
if (!confirm('Удалить файл?')) return;
await fetch('/api/files/' + fileName, { method: 'DELETE' });
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) {
const minOffer = data.offers.find(o => Number(o.price) === min);
const s = minOffer ? data.suppliers.find(x => x.id === minOffer.supplierId) : null;
@ -352,15 +543,31 @@
data.suppliers.map(s => '<option value="' + s.id + '">' + s.name + ' (' + s.status + ')</option>').join('');
}
document.getElementById('inputDataForm').addEventListener('submit', async (e) => {
e.preventDefault();
const f = e.target;
async function saveInputData() {
const f = document.getElementById('inputDataForm');
const data = {
ensCode: f.ensCode.value,
volume: f.volume.value,
deliveryPlace: f.deliveryPlace.value,
techSpec: f.techSpec.value
};
try {
await fetch('/api/inputData', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ensCode: f.ensCode.value, volume: f.volume.value, deliveryPlace: f.deliveryPlace.value, techSpec: f.techSpec.value })
body: JSON.stringify(data)
});
alert('✅ Входные данные сохранены!');
loadData();
} catch (e) {
alert('Ошибка сохранения: ' + e.message);
}
}
document.getElementById('inputDataForm').addEventListener('submit', (e) => {
e.preventDefault();
saveInputData();
});
document.getElementById('supplierForm').addEventListener('submit', async (e) => {
@ -413,46 +620,26 @@
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 || '']);
const uploadAreas = document.querySelectorAll('.upload-area');
uploadAreas.forEach(area => {
area.addEventListener('dragover', (e) => {
e.preventDefault();
area.classList.add('dragover');
});
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 + ' тенге']);
area.addEventListener('dragleave', () => {
area.classList.remove('dragover');
});
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]);
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();
area.addEventListener('drop', (e) => {
e.preventDefault();
area.classList.remove('dragover');
const input = area.parentElement.querySelector('input[type="file"]');
if (input) {
input.files = e.dataTransfer.files;
const event = new Event('change');
input.dispatchEvent(event);
}
});
});
loadData();
</script>

250
package-lock.json generated
View File

@ -4,7 +4,255 @@
"requires": true,
"packages": {
"": {
"name": "price-analysis"
"name": "price-analysis",
"dependencies": {
"docx": "^8.5.0",
"formidable": "^3.5.0"
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@paralleldrive/cuid2": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
"integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "^1.1.5"
}
},
"node_modules/@types/node": {
"version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/asap": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
"license": "MIT"
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/dezalgo": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz",
"integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==",
"license": "ISC",
"dependencies": {
"asap": "^2.0.0",
"wrappy": "1"
}
},
"node_modules/docx": {
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/docx/-/docx-8.5.0.tgz",
"integrity": "sha512-4SbcbedPXTciySXiSnNNLuJXpvxFe5nqivbiEHXyL8P/w0wx2uW7YXNjnYgjW0e2e6vy+L/tMISU/oAiXCl57Q==",
"license": "MIT",
"dependencies": {
"@types/node": "^20.3.1",
"jszip": "^3.10.1",
"nanoid": "^5.0.4",
"xml": "^1.0.1",
"xml-js": "^1.6.8"
},
"engines": {
"node": ">=10"
}
},
"node_modules/formidable": {
"version": "3.5.4",
"resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz",
"integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==",
"license": "MIT",
"dependencies": {
"@paralleldrive/cuid2": "^2.2.2",
"dezalgo": "^1.0.4",
"once": "^1.4.0"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"url": "https://ko-fi.com/tunnckoCore/commissions"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/nanoid": {
"version": "5.1.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz",
"integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^18 || >=20"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/sax": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz",
"integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=11.0.0"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"license": "MIT"
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/xml": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
"integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==",
"license": "MIT"
},
"node_modules/xml-js": {
"version": "1.6.11",
"resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz",
"integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==",
"license": "MIT",
"dependencies": {
"sax": "^1.2.4"
},
"bin": {
"xml-js": "bin/cli.js"
}
}
}
}

View File

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

290
server.js
View File

@ -1,12 +1,19 @@
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 } }; }
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));
@ -19,12 +26,293 @@ 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) => {
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();