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,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 }
|
||||
Reference in New Issue
Block a user