feat: initial dairy report generator
- 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
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
*.log
|
||||
.env
|
||||
/tmp/
|
||||
*.docx
|
||||
*.pdf
|
||||
/test/output/
|
||||
@@ -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
|
||||
@@ -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) })
|
||||
Generated
+759
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,'<').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 += `<rect x="${fcastX.toFixed(1)}" y="${topPT}" width="${(PL+cW-fcastX).toFixed(1)}" height="${topCH}" fill="${RED_LT}" opacity="0.12"/>`
|
||||
|
||||
// Y grid
|
||||
for (let v = minP; v <= maxP; v += 2) {
|
||||
const y = yP(v)
|
||||
svg += `<line x1="${PL}" y1="${y.toFixed(1)}" x2="${PL+cW}" y2="${y.toFixed(1)}" stroke="${GRID}" stroke-width="1"/>`
|
||||
svg += `<text x="${PL-8}" y="${(y+4).toFixed(1)}" text-anchor="end" font-size="11" fill="${MDGRAY}">${v}</text>`
|
||||
}
|
||||
svg += `<text x="14" y="${topPT+topCH/2}" text-anchor="middle" font-size="10" fill="${MDGRAY}" transform="rotate(-90,14,${topPT+topCH/2})">руб./кг</text>`
|
||||
|
||||
// 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 += `<text x="${x.toFixed(1)}" y="${topPT+topCH+18}" text-anchor="middle" font-size="11" fill="${DARK}">${y}</text>`
|
||||
}
|
||||
})
|
||||
}
|
||||
// Q2*/Q3*/Q4* forecast labels
|
||||
;['2026-05-15','2026-08-15','2026-11-15'].forEach((d, i) => {
|
||||
const x = PL + xD(d)
|
||||
svg += `<text x="${x.toFixed(1)}" y="${topPT+topCH+18}" text-anchor="middle" font-size="11" fill="${DARK}">Q${i+2}*</text>`
|
||||
})
|
||||
|
||||
// Top X axis line
|
||||
svg += `<line x1="${PL}" y1="${(topPT+topCH).toFixed(1)}" x2="${PL+cW}" y2="${(topPT+topCH).toFixed(1)}" stroke="${LTGRAY}" stroke-width="1"/>`
|
||||
|
||||
// Forecast vertical separator
|
||||
svg += `<line x1="${fcastX.toFixed(1)}" y1="${topPT}" x2="${fcastX.toFixed(1)}" y2="${topPT+topCH}" stroke="${LTGRAY}" stroke-width="1" stroke-dasharray="3,3"/>`
|
||||
svg += `<text x="${(fcastX+8).toFixed(1)}" y="${(topPT+22).toFixed(1)}" font-size="10" font-style="italic" fill="${MDGRAY}">ПРОГНОЗ →</text>`
|
||||
|
||||
// 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 += `<polygon points="${[...optPts, ...pessPts].join(' ')}" fill="${RED_LT}" opacity="0.25"/>`
|
||||
}
|
||||
|
||||
// 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 += `<path d="${costPath}" fill="none" stroke="${DARK}" stroke-width="1.5" stroke-dasharray="5,3"/>`
|
||||
costData.forEach(d => {
|
||||
const x = PL+xD(d.date), y = yP(d.cost)
|
||||
svg += `<rect x="${(x-3).toFixed(1)}" y="${(y-3).toFixed(1)}" width="6" height="6" fill="${DARK}"/>`
|
||||
})
|
||||
|
||||
// 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 += `<path d="${pricePath}" fill="none" stroke="${RED}" stroke-width="2.4" stroke-linejoin="round"/>`
|
||||
priceData.forEach(d => {
|
||||
const x = PL+xD(d.date), y = yP(d.price)
|
||||
svg += `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="3.5" fill="${RED}" stroke="white" stroke-width="0.8"/>`
|
||||
})
|
||||
|
||||
// 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 += `<path d="${path}" fill="none" stroke="${color}" stroke-width="2" stroke-dasharray="${dash}" stroke-linejoin="round"/>`
|
||||
data.forEach(d => {
|
||||
const x = PL+xD(d.date), y = yP(d.price)
|
||||
svg += `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="3" fill="${color}" stroke="white" stroke-width="0.8"/>`
|
||||
})
|
||||
}
|
||||
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 += `<rect x="${lgX}" y="${lgY}" width="${lgW}" height="${lgH}" fill="white" stroke="${LTGRAY}" stroke-width="0.8" rx="2"/>`
|
||||
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 += `<line x1="${lgX+10}" y1="${ly}" x2="${lgX+34}" y2="${ly}" stroke="${it.c}" stroke-width="2" stroke-dasharray="${it.dash}"/>`
|
||||
if (it.marker === 'circle') svg += `<circle cx="${lgX+22}" cy="${ly}" r="2.5" fill="${it.c}"/>`
|
||||
else svg += `<rect x="${lgX+20}" y="${ly-2.5}" width="5" height="5" fill="${it.c}"/>`
|
||||
svg += `<text x="${lgX+40}" y="${ly+3}" font-size="10" fill="${DARK}">${esc(it.txt)}</text>`
|
||||
})
|
||||
|
||||
// ═══ 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 += `<line x1="${PL}" y1="${y.toFixed(1)}" x2="${PL+cW}" y2="${y.toFixed(1)}" stroke="${GRID}" stroke-width="1"/>`
|
||||
svg += `<text x="${PL-8}" y="${(y+4).toFixed(1)}" text-anchor="end" font-size="10" fill="${MDGRAY}">${v}</text>`
|
||||
})
|
||||
svg += `<text x="14" y="${bY+botPT+botCH/2}" text-anchor="middle" font-size="10" fill="${MDGRAY}" transform="rotate(-90,14,${bY+botPT+botCH/2})">Маржа, %</text>`
|
||||
|
||||
// bottom forecast zone
|
||||
svg += `<rect x="${fcastX.toFixed(1)}" y="${bY+botPT}" width="${(PL+cW-fcastX).toFixed(1)}" height="${botCH}" fill="${RED_LT}" opacity="0.12"/>`
|
||||
|
||||
// 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 += `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${barW.toFixed(1)}" height="${bH.toFixed(1)}" fill="${RED}" opacity="${opacity}" rx="1"/>`
|
||||
})
|
||||
|
||||
svg += `<line x1="${PL}" y1="${zeroY.toFixed(1)}" x2="${PL+cW}" y2="${zeroY.toFixed(1)}" stroke="${LTGRAY}" stroke-width="1"/>`
|
||||
|
||||
// Source label
|
||||
const fullSvg = `<svg width="${W}" height="${H+40}" xmlns="http://www.w3.org/2000/svg" font-family="-apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif">
|
||||
<rect x="0" y="0" width="3" height="40" fill="${RED}"/>
|
||||
<text x="14" y="18" font-size="12" font-weight="bold" fill="${RED}">DairyTrends · dairy-news.ru/dairytrends</text>
|
||||
<text x="14" y="34" font-size="11" fill="${MDGRAY}">${esc(regionLabel)}</text>
|
||||
<g transform="translate(0,40)">${svg}</g>
|
||||
</svg>`
|
||||
|
||||
return {
|
||||
buffer: await sharp(Buffer.from(fullSvg)).png({ quality: 95 }).toBuffer(),
|
||||
width: W,
|
||||
height: H + 40
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { chart1_priceAndMargin }
|
||||
@@ -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,'<').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 += `<line x1="${PL}" y1="${y.toFixed(1)}" x2="${PL+cW}" y2="${y.toFixed(1)}" stroke="${GRID}" stroke-width="1"/>`
|
||||
svg += `<text x="${PL-8}" y="${(y+4).toFixed(1)}" text-anchor="end" font-size="11" fill="${MDGRAY}">${v}</text>`
|
||||
}
|
||||
// Zero line
|
||||
svg += `<line x1="${PL}" y1="${zeroY.toFixed(1)}" x2="${PL+cW}" y2="${zeroY.toFixed(1)}" stroke="${DARK}" stroke-width="1"/>`
|
||||
svg += `<text x="14" y="${PT+cH/2}" text-anchor="middle" font-size="11" fill="${MDGRAY}" transform="rotate(-90,14,${PT+cH/2})">Маржинальность, %</text>`
|
||||
|
||||
// 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 += `<rect x="${(cx+it.offset-bW/2).toFixed(1)}" y="${by.toFixed(1)}" width="${bW.toFixed(1)}" height="${bH.toFixed(1)}" fill="${it.color}" rx="2" opacity="0.9"/>`
|
||||
const lbY = it.val >= 0 ? by - 6 : by + bH + 14
|
||||
svg += `<text x="${(cx+it.offset).toFixed(1)}" y="${lbY.toFixed(1)}" text-anchor="middle" font-size="10" font-weight="bold" fill="${it.color}">${it.val>0?'+':''}${it.val.toFixed(1)}%</text>`
|
||||
})
|
||||
svg += `<text x="${cx.toFixed(1)}" y="${PT+cH+22}" text-anchor="middle" font-size="11" fill="${DARK}">${esc(q.label)}</text>`
|
||||
})
|
||||
|
||||
// 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 += `<rect x="${lx}" y="${ly}" width="16" height="12" fill="${it.c}" rx="2" opacity="0.9"/>`
|
||||
svg += `<text x="${lx+22}" y="${ly+10}" font-size="11" fill="${DARK}">${esc(it.txt)}</text>`
|
||||
})
|
||||
|
||||
const fullSvg = `<svg width="${W}" height="${H+40}" xmlns="http://www.w3.org/2000/svg" font-family="-apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif">
|
||||
<rect x="0" y="0" width="3" height="40" fill="${RED}"/>
|
||||
<text x="14" y="18" font-size="12" font-weight="bold" fill="${RED}">DairyTrends · dairy-news.ru/dairytrends</text>
|
||||
<text x="14" y="34" font-size="11" fill="${MDGRAY}">${esc(regionLabel)}</text>
|
||||
<g transform="translate(0,40)">${svg}</g>
|
||||
</svg>`
|
||||
|
||||
return {
|
||||
buffer: await sharp(Buffer.from(fullSvg)).png({ quality: 95 }).toBuffer(),
|
||||
width: W,
|
||||
height: H + 40
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { chart5_scenarioMargins }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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<Buffer>} — 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user