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:
Alexey Pavlov
2026-06-08 14:57:51 +03:00
commit 00f80dc5cc
10 changed files with 1848 additions and 0 deletions
+202
View File
@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') }
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 }
+84
View File
@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') }
/**
* 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 }
+54
View File
@@ -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 }
+352
View File
@@ -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 }
+57
View File
@@ -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,
}
}