From 00f80dc5cc13424120387c262557965fc26de9aa Mon Sep 17 00:00:00 2001 From: Alexey Pavlov Date: Mon, 8 Jun 2026 14:57:51 +0300 Subject: [PATCH] feat: initial dairy report generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - chart1: линейный график цены + себестоимость + 3 сценария + маржа (bottom panel) - chart5: сгруппированные бары сценарий маржинальности - document.js: сборка DOCX (header/footer DT-style, KPI, callout, tables, images) - index.js: параметрический entry point generateReport(config) - examples/russia.js: полный пример для РФ - palette.js: DT-палитра синхронизирована с DESIGN_DAIRYTRENDS.md Tested: node examples/russia.js → 44KB DOCX, validation PASSED --- .gitignore | 7 + README.md | 103 +++++ examples/russia.js | 208 ++++++++++ package-lock.json | 759 +++++++++++++++++++++++++++++++++++++ package.json | 22 ++ src/charts/chart1.js | 202 ++++++++++ src/charts/chart5.js | 84 ++++ src/data/palette.js | 54 +++ src/generators/document.js | 352 +++++++++++++++++ src/index.js | 57 +++ 10 files changed, 1848 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 examples/russia.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/charts/chart1.js create mode 100644 src/charts/chart5.js create mode 100644 src/data/palette.js create mode 100644 src/generators/document.js create mode 100644 src/index.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..43a11c3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +*.log +.env +/tmp/ +*.docx +*.pdf +/test/output/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..67d3938 --- /dev/null +++ b/README.md @@ -0,0 +1,103 @@ +# Dairy Report Generator + +Универсальный генератор аналитических отчётов в формате **DairyTrends** (стиль DT-палитры) с графиками и таблицами. + +## Возможности + +- 📊 Многостраничные DOCX отчёты в едином дизайне +- 📈 Графики (линейные, столбчатые, сценарные) — SVG → PNG через sharp +- 🎨 Палитра DairyTrends: оранжевый/зелёный/красный/кремовый +- 📑 Структура: KPI-обложка → выводы → методика → ретроспектива → сравнения → прогноз → рекомендации +- 🔌 Полная параметризация — подавай данные, получай отчёт + +## Применение + +- **Регионы РФ** — отчёты по областям/краям из БД `region_index` (Bitrix dairy-news.ru) +- **МЗЫ** — отчёт по конкретному хозяйству на основе своих данных (надой, поголовье, закупочная цена) +- **Сравнения предприятий** — анализ группы хозяйств +- **Тематические выпуски** — экспорт, GDT, нетели и т.д. + +## Установка + +```bash +npm install +``` + +## Использование + +```javascript +const { generateReport } = require('@dairynews/report-generator') +const fs = require('fs') + +const buf = await generateReport({ + subject: { + name: 'Вологодской области', + shortName: 'Вологда', + type: 'region', // 'region' | 'farm' | 'company' | 'custom' + }, + period: { + historicalFrom: '2024-01-01', + historicalTo: '2026-05-31', + forecastTo: '2026-12-31', + }, + data: { + prices: [{ date: '2024-01-15', price: 31.05 }, ...], + costs: [{ date: '2024-01-15', cost: 28.50 }, ...], + scenarios: { base: [...], opt: [...], pess: [...] }, + kpi: [ + { value: '33–35 ₽/кг', label: 'Закуп. цена (базовый)' }, + { value: '+2.1%', label: 'Рост производства I кв.' }, + ... + ], + leaders: [...], // опционально + ebitda: [...], // опционально + regionComparison: [...] // опционально + }, + text: { + mainConclusion: '...', + risks: ['Погодные условия...', 'Регуляторные риски...'], + recommendations: { ... }, + } +}) + +fs.writeFileSync('output.docx', buf) +``` + +## Структура проекта + +``` +src/ +├── index.js # Главный entry-point +├── generators/ +│ ├── document.js # Сборка DOCX (header, footer, sections) +│ ├── kpi.js # KPI-блок +│ ├── tables.js # Таблицы (допущения, прогноз, ретроспектива) +│ └── callout.js # Callout-блоки с красной полосой +├── charts/ +│ ├── render.js # SVG → PNG через sharp +│ ├── chart1.js # Линейный + маржа (главный) +│ ├── chart2.js # Лидеры (2 горизонтальных бара) +│ ├── chart3.js # EBITDA по группам +│ ├── chart4.js # Сравнение регионов +│ └── chart5.js # Сценарии маржи +├── templates/ +│ └── default.js # Шаблон Вологда-style +├── data/ +│ └── palette.js # DT-палитра +examples/ +├── russia.js # Пример: общероссийский отчёт +└── vologda.js # Пример: Вологодская область +``` + +## Деплой + +Модуль интегрируется как зависимость в `new.dairy-news.ru`: + +```bash +# В админке /admin/dairytrends/reports/ +# страница даёт UI для выбора региона + кнопку "Сгенерировать" +``` + +## Лицензия + +MIT © ООО «Умный Байт» / Zeroday diff --git a/examples/russia.js b/examples/russia.js new file mode 100644 index 0000000..5fae86a --- /dev/null +++ b/examples/russia.js @@ -0,0 +1,208 @@ +/** + * Example: общероссийский отчёт DT + * + * Запуск: node examples/russia.js + * Результат: /tmp/DT_Russia_Example.docx + */ + +const fs = require('fs') +const path = require('path') +const { generateReport } = require('../src') + +async function main () { + console.log('Генерирую пример: Российская Федерация') + + const buf = await generateReport({ + subject: { + name: 'Российской Федерации', + shortName: 'Россия', + type: 'region', + }, + period: { + historicalFrom: '2024-01-01', + historicalTo: '2026-05-31', + forecastTo: '2026-12-31', + }, + + data: { + // Цены (агрегаты по DT-индексу) + prices: [ + { date: '2024-01-15', price: 31.05 }, { date: '2024-04-15', price: 31.40 }, + { date: '2024-07-15', price: 31.60 }, { date: '2024-10-15', price: 31.81 }, + { date: '2025-01-15', price: 31.98 }, { date: '2025-02-26', price: 38.94 }, + { date: '2025-04-15', price: 39.79 }, { date: '2025-07-15', price: 39.57 }, + { date: '2025-10-15', price: 39.51 }, { date: '2025-12-15', price: 39.39 }, + { date: '2026-01-15', price: 39.33 }, { date: '2026-02-15', price: 34.45 }, + { date: '2026-04-01', price: 34.01 }, { date: '2026-05-15', price: 34.20 }, + ], + + // Себестоимость (расчётная) + costs: [ + { date: '2024-01-15', cost: 28.5 }, { date: '2024-04-15', cost: 28.7 }, + { date: '2024-07-15', cost: 28.9 }, { date: '2024-10-15', cost: 29.2 }, + { date: '2025-01-15', cost: 29.4 }, { date: '2025-04-15', cost: 29.8 }, + { date: '2025-07-15', cost: 30.1 }, { date: '2025-10-15', cost: 30.5 }, + { date: '2026-01-15', cost: 30.8 }, { date: '2026-04-01', cost: 31.0 }, + ], + + // Прогноз 3 сценария + scenarios: { + base: [ + { date: '2026-04-01', price: 34.01 }, + { date: '2026-06-30', price: 34.5 }, + { date: '2026-09-30', price: 33.5 }, + { date: '2026-12-31', price: 32.0 }, + ], + opt: [ + { date: '2026-04-01', price: 34.01 }, + { date: '2026-06-30', price: 36.5 }, + { date: '2026-09-30', price: 35.5 }, + { date: '2026-12-31', price: 34.5 }, + ], + pess: [ + { date: '2026-04-01', price: 34.01 }, + { date: '2026-06-30', price: 32.5 }, + { date: '2026-09-30', price: 31.0 }, + { date: '2026-12-31', price: 29.5 }, + ], + }, + + // Барчарт маржи внизу chart1 + marginBars: [ + { label: 'I 24', value: 8.5, isForecast: false }, + { label: 'II 24', value: 9.6, isForecast: false }, + { label: 'III 24', value: 9.5, isForecast: false }, + { label: 'IV 24', value: 9.4, isForecast: false }, + { label: 'I 25', value: 9.0, isForecast: false }, + { label: 'II 25', value: 12.8, isForecast: false }, + { label: 'III 25', value: 12.5, isForecast: false }, + { label: 'IV 25', value: 12.4, isForecast: false }, + { label: 'I 26', value: 11.9, isForecast: false }, + { label: 'II 26*', value: 8.5, isForecast: true }, + { label: 'III 26*', value: 5.0, isForecast: true }, + { label: 'IV 26*', value: 1.5, isForecast: true }, + ], + + // Сценарии маржи (chart5) + scenarioMargins: [ + { label: 'II кв. 2026', opt: 15.7, base: 10.0, pess: 1.5 }, + { label: 'III кв. 2026', opt: 12.5, base: 6.5, pess: -4.8 }, + { label: 'IV кв. 2026', opt: 7.8, base: 1.6, pess: -11.7 }, + ], + + // KPI на обложке + kpi: [ + { value: '34,1 ₽/кг', label: 'DT Index (май 2026)' }, + { value: '+24%', label: 'Рост цен 2025 vs 2024' }, + { value: '23 региона', label: 'В базе DT' }, + { value: '16%', label: 'Ключевая ставка ЦБ' }, + ], + + // Допущения сценариев + scenarioAssumptions: [ + { name: 'Базовый', price: '33,5–34,5 руб./кг', costGrowth: '+5%', rationale: 'Продолжение текущих трендов' }, + { name: 'Оптимистичный', price: '36,0–37,5 руб./кг', costGrowth: '+3%', rationale: 'Восстановление спроса, ослабление рубля' }, + { name: 'Пессимистичный', price: '30,0–32,0 руб./кг', costGrowth: '+8%', rationale: 'Давление переработчиков, рост затрат' }, + ], + + // Ретроспективная таблица + retrospective: { + columns: [ + { label: 'Квартал', width: 2200 }, + { label: '2024', width: 1700 }, + { label: '2025', width: 1700 }, + { label: 'I–II кв. 2026', width: 1800 }, + { label: 'Изм. г/г', width: 2466 }, + ], + rows: [ + ['I квартал', '31,14', '36,97', '36,04', { text: '↓ 2,5%', align: 1 }], + ['II квартал', '31,47', '39,77', '34,11', { text: '↓ 14,2%', align: 1 }], + ['III квартал','31,63', '39,57', 'прогноз', '—'], + ['IV квартал', '31,86', '39,49', 'прогноз', '—'], + [{ text: 'Среднегодовая', bold: true }, { text: '31,6', bold: true }, { text: '39,4', bold: true }, { text: '34,5 (оц.)', bold: true }, { text: '↓ 12,4%', bold: true }], + ] + }, + + // Прогнозная таблица + forecastTable: { + columns: [ + { label: 'Квартал', width: 1700 }, + { label: 'Цена руб./кг', width: 1700 }, + { label: 'Себест. руб./кг', width: 1700 }, + { label: 'Маржа %', width: 1700 }, + { label: 'Пр-во тыс. т', width: 1500 }, + { label: 'Сценарий', width: 1566 }, + ], + rows: [ + ['II кв. 2026', '35,0', '31,5', '10,0', '175', 'Базовый'], + ['III кв. 2026', '34,0', '31,8', '6,5', '168', 'Базовый'], + ['IV кв. 2026', '32,5', '32,0', '1,6', '160', 'Базовый'], + ['II кв. 2026', '37,0', '31,2', '15,7', '178', { text: 'Оптимистичный', color: '1D9E75' }], + ['III кв. 2026', '36,0', '31,5', '12,5', '170', { text: 'Оптимистичный', color: '1D9E75' }], + ['IV кв. 2026', '34,5', '31,8', '7,8', '163', { text: 'Оптимистичный', color: '1D9E75' }], + ['II кв. 2026', '33,0', '32,5', '1,5', '172', { text: 'Пессимистичный', color: 'C0272D' }], + ['III кв. 2026', '31,5', '33,0', '-4,8', '165', { text: 'Пессимистичный', color: 'C0272D' }], + ['IV кв. 2026', '30,0', '33,5', '-11,7', '158', { text: 'Пессимистичный', color: 'C0272D' }], + ] + }, + }, + + text: { + mainConclusion: 'После резкого скачка в феврале–марте 2025 г. (с 32,2 до 39,8 руб./кг) национальный DT-индекс перешёл к плавному снижению. В 2026 г. коридор сузился до 33–35 руб./кг. Региональный разброс высок: от 22 руб./кг (Чувашия) до 49 руб./кг (Кабардино-Балкария). Базовый сценарий: среднегодовая цена 33,5–34,5 руб./кг, маржа 5–10%.', + + conclusions: [ + 'Национальный DT-индекс в мае 2026 — 34,1 руб./кг: на 16% ниже пика 2025 г. (39,9 руб./кг), но на 10% выше средней 2024 г. (31,6 руб./кг).', + 'Региональный спред 2026 рекордный: 27 руб./кг (Чувашия 22 руб./кг vs Кабардино-Балкария 49 руб./кг).', + 'Лидеры по цене: СКФО и западное ЦФО/СЗФО (Брянская, Псковская, Ленинградская обл.) — 40–50 руб./кг.', + 'Аутсайдеры: Республика Татарстан, Самарская, Нижегородская, Ивановская обл. — 28–31 руб./кг.', + 'Ключевой риск — ставка ЦБ 16%: замораживает инвестиции в животноводческую инфраструктуру.', + ], + + intro: [ + 'Период анализа: 2024 г. — май 2026 г. Отчёт охватывает всю территорию Российской Федерации по данным 23 активных региональных панелей системы Dairy Trends (DT).', + 'Основные источники: данные Dairy Trends (DT) — еженедельные цены на сырое молоко; статистика Росстата; макроэкономические индикаторы ЦБ РФ; данные GDT и МСХ Беларуси как ориентиры ЕАЭС.', + 'Ключевая ставка ЦБ РФ — 16% (май 2026). Рост цен на электроэнергию: +17–24% в 2025 году, +3–20% в I кв. 2026. Комбикорма: +12% в 2025.', + ], + + methodology: [ + 'Экстраполяция трендов с учётом сезонности (квартальные коэффициенты за 2024–2025 гг.).', + 'Корректировка на макроиндексы: электроэнергия +4–6% в квартал, комбикорма +5–8% за год.', + 'Сценарный анализ: три сценария с разными допущениями по динамике цены и затрат.', + ], + + retrospectiveSummary: 'В 2024 году национальный DT-индекс показал стабильный медленный рост: 30,97 → 31,93 руб./кг (+3% за год). В феврале–марте 2025 произошёл резкий скачок: за 10 дней индекс вырос с 32,2 до 38,9 руб./кг (+21%). К апрелю достиг плато 39,8 руб./кг, удерживалось до декабря 2025. В январе–мае 2026 — нисходящая коррекция до 34,1 руб./кг (–13,5% с начала года).', + + forecastSummary: 'В базовом сценарии большинство производителей сохранят маржу 5–10%. В оптимистичном (цена до 36–37 руб./кг) даже менее эффективные хозяйства выйдут в плюс. В пессимистичном убытки накопят 30–40% предприятий.', + + risks: [ + 'Погодные условия — засуха поднимет цены на комбикорма на 15–20%.', + 'Регуляторные риски — изменение импортных пошлин ЕАЭС, белорусские фиксированные цены.', + 'Кредитное сжатие — ставка ЦБ 16% уже заморозила инвестиционные программы ряда хозяйств.', + 'Валютный фактор — укрепление рубля снижает конкурентоспособность на экспортных рынках.', + ], + + recommendations: { + 'Для руководителей хозяйств': [ + 'Снижайте себестоимость через повышение надоя (цель: >9 000 кг/год) и оптимизацию кормления.', + 'Не наращивайте поголовье без долгосрочных контрактов. Инвестируйте в роботизацию доения.', + 'Хеджируйте цену форвардными контрактами на 30–40% объёма (приоритет — осень).', + ], + 'Для менеджеров по продажам кормов и оборудования': [ + 'Приоритет: клиенты с II >80, OH >70.', + 'Прогноз спроса: +2–3% в тоннах. Логистические окна: май и сентябрь.', + 'Ужесточайте контроль дебиторки с предприятиями группы В.', + ], + 'Для инвесторов': [ + 'M&A: фермы группы В по цене 0,5–0,7 годовой выручки с последующей модернизацией.', + 'IRR новой фермы 1 200 коров — 9–11% при цене 34–35 руб./кг, что ниже WACC 14–15%. Без господдержки нецелесообразно.', + ], + }, + }, + }) + + const outPath = path.join('/tmp', 'DT_Russia_Example.docx') + fs.writeFileSync(outPath, buf) + console.log(`✓ Готово: ${outPath} (${(buf.length/1024).toFixed(0)} KB)`) +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e2709db --- /dev/null +++ b/package-lock.json @@ -0,0 +1,759 @@ +{ + "name": "@dairynews/report-generator", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@dairynews/report-generator", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "docx": "^9.0.0", + "sharp": "^0.34.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz", + "integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@types/node": { + "version": "25.9.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz", + "integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==", + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "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/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/docx": { + "version": "9.7.1", + "resolved": "https://registry.npmjs.org/docx/-/docx-9.7.1.tgz", + "integrity": "sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==", + "license": "MIT", + "dependencies": { + "@types/node": "^25.2.3", + "hash.js": "^1.1.7", + "jszip": "^3.10.1", + "nanoid": "^5.1.3", + "xml": "^1.0.1", + "xml-js": "^1.6.8" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, + "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/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/nanoid": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz", + "integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "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.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz", + "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "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/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "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/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "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/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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..634dc2b --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "@dairynews/report-generator", + "version": "0.1.0", + "description": "Универсальный генератор аналитических отчётов DairyTrends в формате DOCX", + "main": "src/index.js", + "type": "commonjs", + "scripts": { + "test": "node test/run.js", + "example:russia": "node examples/russia.js", + "example:vologda": "node examples/vologda.js" + }, + "keywords": ["dairy", "report", "docx", "dairytrends", "analytics"], + "author": "Zeroday / ООО Умный Байт", + "license": "MIT", + "dependencies": { + "docx": "^9.0.0", + "sharp": "^0.34.0" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/src/charts/chart1.js b/src/charts/chart1.js new file mode 100644 index 0000000..d3e26ed --- /dev/null +++ b/src/charts/chart1.js @@ -0,0 +1,202 @@ +/** + * Chart 1 — Главный график отчёта: + * - Верхняя панель: линия закупочной цены (факт) + себестоимость + 3 сценария + * - Нижняя панель: барчарт маржинальности по периодам (история + прогноз) + * + * SVG → PNG via sharp. + * + * Параметры: + * priceData: [{ date: 'YYYY-MM-DD', price: number }] — фактическая цена + * costData: [{ date: 'YYYY-MM-DD', cost: number }] — себестоимость + * scenarios: { base: [...], opt: [...], pess: [...] } — прогноз 3 сценария + * marginBars: [{ label: string, value: number, isForecast: bool }] + * regionLabel: string — подпись над графиком + */ + +const sharp = require('sharp') +const { PALETTE } = require('../data/palette') + +const { red: RED, redLt: RED_LT, dark: DARK, mdGray: MDGRAY, ltGray: LTGRAY, + grid: GRID, green: GREEN, orange: ORANGE } = PALETTE + +function esc (s) { return String(s).replace(/&/g,'&').replace(//g,'>') } + +async function chart1_priceAndMargin ({ + priceData, costData, scenarios, marginBars, regionLabel, + width = 920 +}) { + const W = width + const topH = 360, botH = 130 + const H = topH + botH + 50 + + const PL = 56, PR = 24 + const cW = W - PL - PR + const topPT = 50, topPB = 30 + const topCH = topH - topPT - topPB + const botPT = 6, botPB = 30 + const botCH = botH - botPT - botPB + + // Domain + const allDates = [...priceData, ...costData].map(d => d.date) + const minD = new Date(allDates.reduce((a,b) => a < b ? a : b)) + const maxD = new Date('2026-12-31') + const xD = d => (new Date(d) - minD) / (maxD - minD) * cW + + const allP = [ + ...priceData.map(d=>d.price), ...costData.map(d=>d.cost), + ...(scenarios.base||[]).map(d=>d.price), + ...(scenarios.opt||[]).map(d=>d.price), + ...(scenarios.pess||[]).map(d=>d.price), + ] + const minP = Math.floor(Math.min(...allP)/2)*2 + const maxP = Math.ceil(Math.max(...allP)/2)*2 + const yP = v => topPT + topCH - (v-minP)/(maxP-minP)*topCH + + // Forecast cutoff + const lastActual = priceData[priceData.length-1].date + const fcastX = PL + xD(lastActual) + + let svg = '' + + // Forecast zone bg + svg += `` + + // Y grid + for (let v = minP; v <= maxP; v += 2) { + const y = yP(v) + svg += `` + svg += `${v}` + } + svg += `руб./кг` + + // X year labels + const years = [] + const dStart = new Date(minD), dEnd = new Date(maxD) + for (let y = dStart.getFullYear(); y <= dEnd.getFullYear(); y++) { + [`${y}-01-01`, `${y}-07-01`].forEach(d => { + if (new Date(d) >= dStart && new Date(d) <= dEnd) { + const x = PL + xD(d) + if (x < fcastX) svg += `${y}` + } + }) + } + // Q2*/Q3*/Q4* forecast labels + ;['2026-05-15','2026-08-15','2026-11-15'].forEach((d, i) => { + const x = PL + xD(d) + svg += `Q${i+2}*` + }) + + // Top X axis line + svg += `` + + // Forecast vertical separator + svg += `` + svg += `ПРОГНОЗ →` + + // Scenario fill area (opt vs pess) + if (scenarios.opt && scenarios.pess) { + const optPts = scenarios.opt.map(d => `${(PL+xD(d.date)).toFixed(1)},${yP(d.price).toFixed(1)}`) + const pessPts = scenarios.pess.map(d => `${(PL+xD(d.date)).toFixed(1)},${yP(d.price).toFixed(1)}`).reverse() + svg += `` + } + + // Cost line (dashed, dark) with square markers + const costPath = costData.map((d,i) => `${i===0?'M':'L'}${(PL+xD(d.date)).toFixed(1)},${yP(d.cost).toFixed(1)}`).join(' ') + svg += `` + costData.forEach(d => { + const x = PL+xD(d.date), y = yP(d.cost) + svg += `` + }) + + // Price line (red solid with circle markers) + const pricePath = priceData.map((d,i) => `${i===0?'M':'L'}${(PL+xD(d.date)).toFixed(1)},${yP(d.price).toFixed(1)}`).join(' ') + svg += `` + priceData.forEach(d => { + const x = PL+xD(d.date), y = yP(d.price) + svg += `` + }) + + // Scenario lines + const drawScenario = (data, color, dash) => { + if (!data || !data.length) return + const path = data.map((d,i) => `${i===0?'M':'L'}${(PL+xD(d.date)).toFixed(1)},${yP(d.price).toFixed(1)}`).join(' ') + svg += `` + data.forEach(d => { + const x = PL+xD(d.date), y = yP(d.price) + svg += `` + }) + } + drawScenario(scenarios.base, RED, '6,3') + drawScenario(scenarios.opt, GREEN, '2,3') + drawScenario(scenarios.pess, ORANGE, '2,3') + + // Legend (top-left box) + const lgX = PL+10, lgY = topPT+10 + const lgW = 175, lgH = 76 + svg += `` + const legendItems = [ + { c: RED, dash: '', txt: 'Цена (факт)', marker: 'circle' }, + { c: DARK, dash: '5,3', txt: 'Себест. (факт)', marker: 'square' }, + { c: RED, dash: '6,3', txt: 'Базовый', marker: 'circle' }, + { c: GREEN, dash: '2,3', txt: 'Оптимистичный', marker: 'circle' }, + { c: ORANGE, dash: '2,3', txt: 'Пессимистичный', marker: 'circle' }, + ] + legendItems.forEach((it, i) => { + const ly = lgY + 12 + i*13 + svg += `` + if (it.marker === 'circle') svg += `` + else svg += `` + svg += `${esc(it.txt)}` + }) + + // ═══ Bottom panel: margin bars ═══ + const bY = topH + 20 + const maxM = Math.max(...marginBars.map(b=>b.value), 0) + 1 + const minM = Math.min(...marginBars.map(b=>b.value), 0) + const yM = v => bY + botPT + botCH - (v-minM)/(maxM-minM)*botCH + const zeroY = yM(0) + + ;[0, 5, 10, 15].filter(v => v >= minM && v <= maxM).forEach(v => { + const y = yM(v) + svg += `` + svg += `${v}` + }) + svg += `Маржа, %` + + // bottom forecast zone + svg += `` + + // bars + const barCount = marginBars.length + const barAreaStart = PL + 8 + const barAreaEnd = PL + cW - 8 + const slotW = (barAreaEnd - barAreaStart) / barCount + const barW = Math.min(28, slotW * 0.7) + + marginBars.forEach((b, i) => { + const cx = barAreaStart + i*slotW + slotW/2 + const x = cx - barW/2 + const bH = Math.abs(yM(b.value) - zeroY) + const y = b.value >= 0 ? yM(b.value) : zeroY + const opacity = b.isForecast ? 0.55 : 0.85 + svg += `` + }) + + svg += `` + + // Source label + const fullSvg = ` + + DairyTrends · dairy-news.ru/dairytrends + ${esc(regionLabel)} + ${svg} + ` + + return { + buffer: await sharp(Buffer.from(fullSvg)).png({ quality: 95 }).toBuffer(), + width: W, + height: H + 40 + } +} + +module.exports = { chart1_priceAndMargin } diff --git a/src/charts/chart5.js b/src/charts/chart5.js new file mode 100644 index 0000000..92f6282 --- /dev/null +++ b/src/charts/chart5.js @@ -0,0 +1,84 @@ +/** + * Chart 5 — Сценарии маржинальности (вертикальные группированные бары) + */ + +const sharp = require('sharp') +const { PALETTE } = require('../data/palette') +const { red: RED, dark: DARK, mdGray: MDGRAY, ltGray: LTGRAY, grid: GRID, + green: GREEN, orange: ORANGE } = PALETTE + +function esc (s) { return String(s).replace(/&/g,'&').replace(//g,'>') } + +/** + * quarters: [{ label: 'II кв. 2026', opt: 15.7, base: 10.0, pess: 1.5 }] + */ +async function chart5_scenarioMargins ({ quarters, regionLabel, width = 920 }) { + const W = width, H = 360 + const PL = 60, PR = 180, PT = 80, PB = 50 + const cW = W - PL - PR, cH = H - PT - PB + + const all = quarters.flatMap(q => [q.opt, q.base, q.pess]) + const maxV = Math.max(...all, 5) + 3 + const minV = Math.min(...all, 0) - 3 + const yV = v => PT + cH - (v-minV)/(maxV-minV)*cH + const zeroY = yV(0) + + const grpW = cW / quarters.length + const bW = Math.min(40, grpW / 4) + + let svg = '' + // Y grid + for (let v = Math.ceil(minV/5)*5; v <= maxV; v += 5) { + const y = yV(v) + svg += `` + svg += `${v}` + } + // Zero line + svg += `` + svg += `Маржинальность, %` + + // Bars + quarters.forEach((q, gi) => { + const cx = PL + gi*grpW + grpW/2 + const items = [ + { key: 'opt', val: q.opt, color: GREEN, offset: -bW*1.15 }, + { key: 'base', val: q.base, color: RED, offset: 0 }, + { key: 'pess', val: q.pess, color: ORANGE, offset: bW*1.15 }, + ] + items.forEach(it => { + const bH = Math.abs(yV(it.val) - yV(0)) + const by = it.val >= 0 ? yV(it.val) : zeroY + svg += `` + const lbY = it.val >= 0 ? by - 6 : by + bH + 14 + svg += `${it.val>0?'+':''}${it.val.toFixed(1)}%` + }) + svg += `${esc(q.label)}` + }) + + // Legend + const lx = PL + cW + 16 + ;[ + { c: GREEN, txt: 'Оптимистичный' }, + { c: RED, txt: 'Базовый' }, + { c: ORANGE, txt: 'Пессимистичный' }, + ].forEach((it, i) => { + const ly = PT + 30 + i*28 + svg += `` + svg += `${esc(it.txt)}` + }) + + const fullSvg = ` + + DairyTrends · dairy-news.ru/dairytrends + ${esc(regionLabel)} + ${svg} + ` + + return { + buffer: await sharp(Buffer.from(fullSvg)).png({ quality: 95 }).toBuffer(), + width: W, + height: H + 40 + } +} + +module.exports = { chart5_scenarioMargins } diff --git a/src/data/palette.js b/src/data/palette.js new file mode 100644 index 0000000..0d6c2d8 --- /dev/null +++ b/src/data/palette.js @@ -0,0 +1,54 @@ +/** + * DT (DairyTrends) Палитра — единственный источник истины по цветам. + * Совпадает с DESIGN_DAIRYTRENDS.md в new.dairy-news.ru. + */ + +const PALETTE = { + // ── Фоны ─────────────────────────────────────────────── + bg: '#F7F5F0', // основной кремовый + bg2: '#EFEDE7', + card: '#FFFFFF', + + // ── Бордеры ──────────────────────────────────────────── + border: '#E2DDD4', + borderH: '#D5CFC4', + + // ── Текст ────────────────────────────────────────────── + text: '#1A1A18', + muted: '#7A7772', + faint: '#B0ADA8', + + // ── Акценты бренда ───────────────────────────────────── + o: '#EE9C03', // оранжевый (CTA, бейджи) + oLight: '#FEF3DC', + g: '#1D9E75', // зелёный (рост) + gLight: 'rgba(29,158,117,0.10)', + r: '#D85A30', // красный/коралл (падение) + rLight: 'rgba(216,90,48,0.10)', + + // ── Расширения для отчётов ───────────────────────────── + red: '#C0272D', // основной красный заголовков (как в Вологда-образце) + redLt: '#E5969A', // светлый красный (заливки прогнозных зон) + redBg: '#FCE8E9', // фон callout-блоков + dark: '#2A2A2A', + mdGray: '#666666', + ltGray: '#BBBBBB', + grid: '#EAEAEA', + green: '#3FA866', + orange: '#E8954A', + + // ── Для DOCX (без #) ─────────────────────────────────── + docx: { + red: 'C0272D', + black: '1A1A1A', + dGray: '404040', + mGray: '808080', + lGray: 'D0D0D0', + white: 'FFFFFF', + tbHd: 'E8E8E8', + orange: 'EE9C03', + green: '1D9E75', + } +} + +module.exports = { PALETTE } diff --git a/src/generators/document.js b/src/generators/document.js new file mode 100644 index 0000000..e53d9ec --- /dev/null +++ b/src/generators/document.js @@ -0,0 +1,352 @@ +/** + * document.js — Сборка DOCX отчёта в стиле DairyTrends. + * + * Структура: cover (KPI + main conclusion) → secitons 1-7 → footer + */ + +const { + Document, Packer, Paragraph, TextRun, Table, TableRow, TableCell, + AlignmentType, HeadingLevel, BorderStyle, WidthType, ShadingType, + VerticalAlign, Header, Footer, LevelFormat, ImageRun +} = require('docx') + +const { PALETTE } = require('../data/palette') +const D = PALETTE.docx // docx-палитра (без #) + +// ── Layout ──────────────────────────────────────────────────────────────────── +const PAGE_W = 11906, MARGIN = 1020 +const CONTENT_W = PAGE_W - MARGIN*2 // 9866 + +// ── Borders ────────────────────────────────────────────────────────────────── +const bNone = { style: BorderStyle.NONE, size: 0, color: D.white } +const bCell = { style: BorderStyle.SINGLE, size: 1, color: 'CCCCCC' } +const bCellAll = { top: bCell, bottom: bCell, left: bCell, right: bCell } +const bNoneAll = { top: bNone, bottom: bNone, left: bNone, right: bNone } + +// ── Helpers ────────────────────────────────────────────────────────────────── +function run (text, opts = {}) { return new TextRun({ text, font: 'Arial', ...opts }) } + +function body (text, opts = {}) { + return new Paragraph({ + spacing: { after: 100 }, + children: [run(text, { size: 21, color: D.dGray, ...opts })] + }) +} + +function heading1 (text) { + return new Paragraph({ + spacing: { before: 280, after: 100 }, + border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: D.red, space: 3 } }, + children: [run(text, { size: 26, bold: true, color: D.red })] + }) +} + +function heading2 (text) { + return new Paragraph({ + spacing: { before: 180, after: 80 }, + children: [run(text, { size: 22, bold: true, color: D.black })] + }) +} + +function dashBullet (text, bold = false) { + return new Paragraph({ + spacing: { after: 80 }, + indent: { left: 360, hanging: 240 }, + children: [ + run('–\t', { size: 21, color: D.dGray }), + run(text, { size: 21, color: D.dGray, bold: !!bold }) + ] + }) +} + +function caption (text) { + return new Paragraph({ + spacing: { before: 160, after: 40 }, + children: [run(text, { size: 19, italics: true, color: D.mGray })] + }) +} + +function spacer (h = 120) { + return new Paragraph({ spacing: { after: h }, children: [] }) +} + +function imgPara (chart) { + if (!chart || !chart.buffer) return spacer(60) + const maxW = 650 + const scale = chart.width > maxW ? maxW / chart.width : 1 + return new Paragraph({ + spacing: { after: 120 }, + children: [new ImageRun({ + data: chart.buffer, + transformation: { width: Math.round(chart.width * scale), height: Math.round(chart.height * scale) }, + type: 'png' + })] + }) +} + +function tc (text, opts = {}) { + const { bg = D.white, bold = false, align = AlignmentType.CENTER, color = D.dGray, width } = opts + return new TableCell({ + borders: bCellAll, + width: width ? { size: width, type: WidthType.DXA } : undefined, + shading: { fill: bg, type: ShadingType.CLEAR }, + margins: { top: 60, bottom: 60, left: 100, right: 100 }, + verticalAlign: VerticalAlign.CENTER, + children: [new Paragraph({ alignment: align, spacing: { after: 0 }, + children: [run(String(text), { size: 20, bold: !!bold, color })] })] + }) +} + +function tr (cells) { return new TableRow({ children: cells }) } + +function stdTable (cols, rows) { + return new Table({ + width: { size: CONTENT_W, type: WidthType.DXA }, + columnWidths: cols.map(c => c.width), + rows: [ + tr(cols.map(c => tc(c.label, { bg: D.tbHd, bold: true, align: AlignmentType.LEFT, color: D.black, width: c.width }))), + ...rows.map(row => tr( + row.map((cell, i) => { + const c = typeof cell === 'string' ? { text: cell } : cell + return tc(c.text, { + bg: c.bg || D.white, + bold: c.bold || false, + align: c.align || (i === 0 ? AlignmentType.LEFT : AlignmentType.CENTER), + color: c.color || D.dGray, + width: cols[i].width + }) + }) + )) + ] + }) +} + +function kpiBlock (items) { + const w = Math.floor(CONTENT_W / items.length) + return new Table({ + width: { size: CONTENT_W, type: WidthType.DXA }, + columnWidths: items.map(() => w), + rows: [tr(items.map(it => new TableCell({ + borders: bCellAll, + width: { size: w, type: WidthType.DXA }, + shading: { fill: D.white, type: ShadingType.CLEAR }, + margins: { top: 120, bottom: 120, left: 160, right: 160 }, + children: [ + new Paragraph({ alignment: AlignmentType.LEFT, spacing: { after: 20 }, + children: [run(it.value, { size: 36, bold: true, color: D.red })] }), + new Paragraph({ alignment: AlignmentType.LEFT, spacing: { after: 0 }, + children: [run(it.label, { size: 18, color: D.mGray })] }) + ] + })))] + }) +} + +function calloutBox (title, text) { + const leftBar = new TableCell({ + borders: bNoneAll, + width: { size: 120, type: WidthType.DXA }, + shading: { fill: D.red, type: ShadingType.CLEAR }, + children: [new Paragraph({ children: [] })] + }) + const textCol = new TableCell({ + borders: { top: bCell, bottom: bCell, right: bCell, left: bNone }, + width: { size: CONTENT_W - 120, type: WidthType.DXA }, + shading: { fill: D.white, type: ShadingType.CLEAR }, + margins: { top: 100, bottom: 100, left: 160, right: 120 }, + children: [ + new Paragraph({ spacing: { after: 40 }, children: [run(title, { size: 20, bold: true, color: D.black })] }), + new Paragraph({ spacing: { after: 0 }, children: [run(text, { size: 20, color: D.dGray })] }) + ] + }) + return new Table({ + width: { size: CONTENT_W, type: WidthType.DXA }, + columnWidths: [120, CONTENT_W - 120], + rows: [tr([leftBar, textCol])] + }) +} + +// ── Header / footer ────────────────────────────────────────────────────────── +function makeHeader (subjectName) { + const dtCell = new TableCell({ + borders: bNoneAll, + width: { size: 700, type: WidthType.DXA }, + shading: { fill: D.black, type: ShadingType.CLEAR }, + margins: { top: 80, bottom: 80, left: 140, right: 100 }, + verticalAlign: VerticalAlign.CENTER, + children: [new Paragraph({ alignment: AlignmentType.CENTER, spacing: { after: 0 }, + children: [run('DT', { size: 22, bold: true, color: D.white })] })] + }) + const infoCell = new TableCell({ + borders: bNoneAll, + width: { size: CONTENT_W - 700, type: WidthType.DXA }, + shading: { fill: D.black, type: ShadingType.CLEAR }, + margins: { top: 60, bottom: 60, left: 160, right: 80 }, + verticalAlign: VerticalAlign.CENTER, + children: [ + new Paragraph({ spacing: { after: 0 }, + children: [run('DAIRY TRENDS · dairy-news.ru/dairytrends', { size: 18, bold: true, color: D.red })] }), + new Paragraph({ spacing: { after: 0 }, + children: [run(`Аналитика молочного рынка · ${subjectName} · 2026`, { size: 16, color: D.lGray })] }) + ] + }) + return new Header({ + children: [ + new Table({ + width: { size: CONTENT_W, type: WidthType.DXA }, + columnWidths: [700, CONTENT_W - 700], + rows: [tr([dtCell, infoCell])] + }), + spacer(80) + ] + }) +} + +function makeFooter (subjectName) { + return new Footer({ + children: [ + new Paragraph({ + border: { top: { style: BorderStyle.SINGLE, size: 2, color: 'CCCCCC', space: 4 } }, + alignment: AlignmentType.CENTER, + spacing: { after: 0, before: 60 }, + children: [run(`© DairyTrends · dairy-news.ru/dairytrends · ${subjectName} · Прогноз II–IV кв. 2026 · Май 2026`, { size: 16, italics: true, color: D.mGray })] + }) + ] + }) +} + +// ── Main builder ───────────────────────────────────────────────────────────── +async function buildDocument ({ subject, period, data, text, meta = {}, charts = {} }) { + const children = [] + + // ═══ COVER ═══════════════════════════════════════════════════════════════ + children.push( + spacer(200), + new Paragraph({ children: [run('АНАЛИТИЧЕСКИЙ ОТЧЁТ · DAIRY TRENDS', { size: 19, bold: true, color: D.mGray, allCaps: true })], spacing: { after: 60 } }), + new Paragraph({ children: [run('Прогноз молочного рынка', { size: 48, bold: true, color: D.black })], spacing: { after: 20 } }), + new Paragraph({ children: [run(subject.name, { size: 36, bold: true, color: D.red })], spacing: { after: 60 } }), + new Paragraph({ children: [run('на II–IV кварталы 2026 года · факт + прогноз по трём сценариям', { size: 20, color: D.mGray, italics: true })], spacing: { after: 280 } }) + ) + + if (data.kpi && data.kpi.length) { + children.push(kpiBlock(data.kpi), spacer(200)) + } + + if (text.mainConclusion) { + children.push(calloutBox('ГЛАВНЫЙ ВЫВОД', text.mainConclusion), spacer(200)) + } + + // ═══ 1. ВЫВОДЫ ═══════════════════════════════════════════════════════════ + if (text.conclusions && text.conclusions.length) { + children.push(heading1('1. Ключевые выводы'), spacer(80)) + text.conclusions.forEach(c => children.push(dashBullet(c))) + children.push(spacer(160)) + } + + // ═══ 2. ВВЕДЕНИЕ ═════════════════════════════════════════════════════════ + if (text.intro) { + children.push(heading1('2. Введение и источники данных'), spacer(60)) + if (Array.isArray(text.intro)) text.intro.forEach(t => children.push(body(t))) + else children.push(body(text.intro)) + children.push(spacer(160)) + } + + // ═══ 3. МЕТОДИКА ═════════════════════════════════════════════════════════ + if (text.methodology) { + children.push(heading1('3. Методика прогнозирования и сценарии'), spacer(60)) + text.methodology.forEach(m => children.push(dashBullet(m))) + children.push(spacer(120)) + } + + if (data.scenarioAssumptions) { + children.push(caption('Таблица 1. Допущения по сценариям')) + children.push(stdTable( + [ + { label: 'Сценарий', width: 2400 }, + { label: 'Закуп. цена (ср./год)', width: 2400 }, + { label: 'Рост себест. к I кв.', width: 2000 }, + { label: 'Обоснование', width: 3066 } + ], + data.scenarioAssumptions.map(s => [ + s.name, s.price, s.costGrowth, { text: s.rationale, align: AlignmentType.LEFT } + ]) + )) + children.push(spacer(200)) + } + + // ═══ 4. РЕТРОСПЕКТИВА ═════════════════════════════════════════════════════ + if (data.retrospective || charts.chart1) { + children.push(heading1('4. Ретроспектива и факт I квартала 2026'), spacer(60)) + + if (data.retrospective) { + children.push(caption('Таблица 2. Ключевые показатели')) + children.push(stdTable(data.retrospective.columns, data.retrospective.rows)) + children.push(spacer(120)) + } + + if (text.retrospectiveSummary) { + children.push(body(text.retrospectiveSummary), spacer(120)) + } + + if (charts.chart1) { + children.push(caption('Рисунок 1. Динамика закупочной цены и себестоимости молока (2024–2026, прогноз)')) + children.push(imgPara(charts.chart1)) + } + } + + // ═══ 6. ПРОГНОЗ ═══════════════════════════════════════════════════════════ + children.push(heading1('6. Прогноз на II–IV кварталы 2026 года'), spacer(60)) + + if (charts.chart5) { + children.push(caption('Рисунок 5. Прогноз маржинальности по сценариям (II–IV кв. 2026)')) + children.push(imgPara(charts.chart5)) + children.push(spacer(80)) + } + + if (text.forecastSummary) { + children.push(body(text.forecastSummary), spacer(100)) + } + + if (data.forecastTable) { + children.push(caption('Таблица 3. Прогнозные значения (среднеквартальные) по трём сценариям')) + children.push(stdTable(data.forecastTable.columns, data.forecastTable.rows)) + children.push(spacer(120)) + } + + if (text.risks && text.risks.length) { + children.push(heading2('Основные риски')) + text.risks.forEach(r => children.push(dashBullet(r))) + children.push(spacer(200)) + } + + // ═══ 7. РЕКОМЕНДАЦИИ ══════════════════════════════════════════════════════ + if (text.recommendations) { + children.push(heading1('7. Рекомендации по аудиториям'), spacer(60)) + Object.entries(text.recommendations).forEach(([audience, items]) => { + children.push(heading2(audience)) + items.forEach(it => children.push(dashBullet(it))) + children.push(spacer(80)) + }) + } + + // ── Document ───────────────────────────────────────────────────────────── + const doc = new Document({ + styles: { + default: { document: { run: { font: 'Arial', size: 21, color: D.dGray } } } + }, + sections: [{ + properties: { + page: { + size: { width: PAGE_W, height: 16838 }, + margin: { top: 720, right: MARGIN, bottom: 720, left: MARGIN } + } + }, + headers: { default: makeHeader(subject.name) }, + footers: { default: makeFooter(subject.shortName || subject.name) }, + children + }] + }) + + return Packer.toBuffer(doc) +} + +module.exports = { buildDocument } diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..6bb684c --- /dev/null +++ b/src/index.js @@ -0,0 +1,57 @@ +/** + * index.js — главный entry point генератора отчётов + */ + +const { chart1_priceAndMargin } = require('./charts/chart1') +const { chart5_scenarioMargins } = require('./charts/chart5') +const { buildDocument } = require('./generators/document') +const { PALETTE } = require('./data/palette') + +/** + * Главная функция генерации отчёта. + * + * @param {Object} config — параметры отчёта + * @returns {Promise} — DOCX как буфер + */ +async function generateReport (config) { + const { + subject, // { name, shortName, type: 'region'|'farm'|'company' } + period, // { historicalFrom, historicalTo, forecastTo } + data, // { prices, costs, scenarios, kpi, leaders?, ebitda?, regionComparison? } + text, // { mainConclusion, conclusions, risks, recommendations } + meta = {} // { reportDate, brand: 'DairyTrends' } + } = config + + // 1) Рендерим графики параллельно + const charts = {} + + if (data.prices && data.costs && data.scenarios) { + charts.chart1 = await chart1_priceAndMargin({ + priceData: data.prices, + costData: data.costs, + scenarios: data.scenarios, + marginBars: data.marginBars || [], + regionLabel: `${subject.name} · Закупочная цена и себестоимость молока · руб./кг · ${period.historicalFrom.slice(0,4)}–${period.forecastTo.slice(0,4)}` + }) + } + + if (data.scenarioMargins) { + charts.chart5 = await chart5_scenarioMargins({ + quarters: data.scenarioMargins, + regionLabel: `${subject.name} · Прогноз маржинальности по сценариям · %` + }) + } + + // 2) Собираем DOCX + return buildDocument({ subject, period, data, text, meta, charts }) +} + +module.exports = { + generateReport, + PALETTE, + // Экспортируем внутренности для возможности кастомизации + charts: { + chart1_priceAndMargin, + chart5_scenarioMargins, + } +}