Files
bastion-frontend/build-saturn.mjs

496 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from 'node:fs';
import path from 'node:path';
const inputRoot = '/Users/anna/Desktop/Работа/данные для прототипа/Поисковый запрос 3';
const sourceRoot = path.join(inputRoot, 'Сатурн');
const mainSource = path.join(inputRoot, 'ПАО _ОДК-Сатурн_.html');
const outputRoot = path.join(process.cwd(), 'Сатурн — готовый прототип');
const decoder = new TextDecoder('windows-1251');
const walk = dir => fs.readdirSync(dir, {withFileTypes: true}).flatMap(entry => {
const file = path.join(dir, entry.name);
return entry.isDirectory() ? walk(file) : [file];
});
const decode = file => decoder.decode(fs.readFileSync(file));
const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, char => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[char]));
const clean = value => String(value ?? '').replace(/<br\s*\/?\s*>/gi, '\n').replace(/<[^>]*>/g, ' ').replace(/&nbsp;|&#160;/gi, ' ').replace(/&quot;/gi, '"').replace(/&amp;/gi, '&').replace(/\s+/g, ' ').trim();
const normalize = value => clean(value).toLowerCase().replace(/[«»"'„“”]/g, '').replace(/[–—-]/g, ' ').replace(/\s+/g, ' ').trim();
const quoteName = value => clean(value).replace(/"([^"\n]+)"/g, '«$1»');
const sourceTitle = source => quoteName(decode(source).match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] || path.basename(source, '.html'));
function rowsFrom(source) {
const rows = [];
for (const match of decode(source).matchAll(/<tr[^>]*>([\s\S]*?)<\/tr>/gi)) {
const cells = [...match[1].matchAll(/<td[^>]*>([\s\S]*?)<\/td>/gi)].map(cell => cell[1]);
if (cells.length < 3) continue;
const label = clean(cells[0]);
const raw = cells.slice(2).join(' ');
const value = clean(raw);
if (label || value) rows.push({label, value, raw});
}
return rows;
}
function bodyText(source) {
const body = decode(source).replace(/[\r\n]+/g, ' ').match(/<body[^>]*>([\s\S]*?)<\/body>/i)?.[1] || '';
const useful = body.replace(/<p[^>]*align=["'](?:right|center)["'][\s\S]*?<\/p>/gi, '');
const text = clean(useful.replace(/<br\s*\/?\s*>/gi, ' ').replace(/<\/p>/gi, ' ').replace(/<p[^>]*>/gi, ' '));
return `<div class="prose"><p>${escapeHtml(text)}</p></div>`;
}
function relativeOut(source) {
if (source === mainSource) return 'index.html';
return path.join('Сатурн', path.relative(sourceRoot, source));
}
function localHref(source, asset) {
return path.relative(path.dirname(path.join(outputRoot, relativeOut(source))), path.join(outputRoot, asset)).split(path.sep).join('/');
}
function formatHref(fromSource, toSource) {
const from = path.join(outputRoot, relativeOut(fromSource));
const to = path.join(outputRoot, relativeOut(toSource));
return path.relative(path.dirname(from), to).split(path.sep).map(encodeURIComponent).join('/');
}
const targetSources = [mainSource, ...walk(sourceRoot).filter(file => file.endsWith('.html') && !file.includes(`${path.sep}Доп.информация${path.sep}`))];
const registry = new Map();
for (const source of targetSources) {
const title = sourceTitle(source);
registry.set(normalize(title), source);
registry.set(normalize(path.basename(source, '.html').replace(/_/g, ' ')), source);
}
const sourceIn = relative => targetSources.find(source => path.relative(sourceRoot, source) === relative);
const linkableNames = new Map();
for (const source of targetSources) linkableNames.set(normalize(sourceTitle(source)), {label: sourceTitle(source), source});
const aliases = [
{
source: sourceIn(path.join('Структура собственности', 'АО _ОДК_.html')),
names: ['АО «ОДК»', 'АО «Объединенная двигателестроительная корпорация»', 'АО «Объединённая двигателестроительная корпорация»'],
},
{
source: sourceIn(path.join('Структура собственности', 'ФГУП _РФЯЦ - ВНИИЭФ_.html')),
names: ['ФГУП «РФЯЦ‑ВНИИЭФ»', 'ФГУП «РФЯЦ-ВНИИЭФ»', 'ФГУП «Российский федеральный ядерный центр - Всероссийский научно-исследовательский институт экспериментальной физики»'],
},
{
source: sourceIn(path.join('Управляющая организация', 'Филиал ПАО _ОДК-Сатурн_ - ОМКБ.html')),
names: ['Филиал публичного акционерного общества «ОДК-Сатурн» - Омское Моторостроительное конструкторское бюро'],
},
{
source: sourceIn(path.join('Управляющая организация', 'АОелезнодорожник-ПМ_.html')),
names: ['АО «Железнодорожник - Пермские моторы»'],
},
{
source: sourceIn(path.join('Управляющая организация', 'АО _МЕТАЛЛИСТМ_.html')),
names: ['АО «Металлист-Пермские моторы»'],
},
{
source: sourceIn(path.join('Управляющая организация', 'АО _Энергетик - ПМ_.html')),
names: ['АО «Энергетик - Пермские моторы»'],
},
{
source: sourceIn(path.join('Управляющая организация', 'АО _ОДКМ_.html')),
names: ['АО «ОДК-Пермские моторы»'],
},
{
source: sourceIn(path.join('Участие в капитале', 'АО _НИР_.html')),
names: ['АО «Новые инструментальные решения»'],
},
{
source: sourceIn(path.join('Участие в капитале', 'АО _СатИЗ_.html')),
names: ['АО «Сатурн-Инструментальный завод»'],
},
{
source: sourceIn(path.join('Участие в капитале', 'ООО _ОДК-ЦИ_.html')),
names: ['ООО «ОДК-Цифровая инфраструктура»'],
},
{
source: sourceIn(path.join('Участие в капитале', 'ООО _АТО_.html')),
names: ['ООО «Аутсорсинг Технологии обслуживание»'],
},
];
for (const {source, names} of aliases) for (const label of names) if (source) linkableNames.set(normalize(label), {label, source});
function textPattern(label) {
return [...label].map(char => {
if (/\s/u.test(char)) return '\\s+';
if (/[-‑–—]/u.test(char)) return '[-‑–—]';
return char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}).join('');
}
function linkKnownNames(text, source) {
const names = [...linkableNames.values()]
.filter(item => item.source !== source)
.sort((left, right) => right.label.length - left.label.length);
for (const item of names) {
const pattern = new RegExp(textPattern(item.label), 'gu');
text = text.replace(pattern, match => `<a class="internal-link" href="${formatHref(source, item.source)}" target="_blank" rel="noopener noreferrer">${escapeHtml(match)}</a>`);
}
return text;
}
function richText(raw, source) {
const tokens = [];
const marked = raw.replace(/<a\b[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, (_all, href, label) => {
const text = clean(label);
const target = registry.get(normalize(text));
if (target && target !== source) {
tokens.push(`<a class="internal-link" href="${formatHref(source, target)}" target="_blank" rel="noopener noreferrer">${escapeHtml(quoteName(text))}</a>`);
} else if (/^(https?:\/\/|mailto:)/i.test(href)) {
tokens.push(`<a class="external-link" href="${escapeHtml(href.replace(/&amp;/gi, '&'))}" target="_blank" rel="noopener noreferrer">${escapeHtml(text || href)}</a>`);
} else {
tokens.push(linkKnownNames(escapeHtml(quoteName(text)), source));
}
return `@@LINK${tokens.length - 1}@@`;
});
const text = quoteName(marked.replace(/<br\s*\/?\s*>/gi, '<br>').replace(/<[^>]*>/g, ' ').replace(/&nbsp;|&#160;/gi, ' '));
return linkKnownNames(text, source).replace(/@@LINK(\d+)@@/g, (_all, index) => tokens[Number(index)] || '').replace(/\s{2,}/g, ' ').trim();
}
function groupRows(rows) {
const groups = Object.fromEntries(['ids', 'location', 'contacts', 'legal', 'finance', 'staff', 'management', 'ownership', 'structure', 'stability', 'other'].map(name => [name, []]));
let current = 'other';
const bucket = label => {
if (/^(id|ИНН|КПП|ОГРН|ОКПО|Код эмитента)$/i.test(label)) return 'ids';
if (/^(ОКОГУ|ОКТМО|ОКФС)$/i.test(label)) return 'ids';
if (/(Субъект федерации|Федеральный округ|Местонахождение|Юридический адрес|Почтовый адрес|Город)/i.test(label)) return 'location';
if (/(Телефоны|Факс|E-mail|Интернет-сайт|Раскрытие информации|Сайт)/i.test(label)) return 'contacts';
if (/(Вид деятельности|ОКВЭД|Правовой статус|Действующие лицензии)/i.test(label)) return 'legal';
if (/(Выручка|Чистая прибыль|Убыт|Уставный капитал)/i.test(label)) return 'finance';
if (/(Численность|Среднесписоч)/i.test(label)) return 'staff';
if (/^Руководитель/i.test(label)) return 'management';
if (/(Держатель реестра|Номинал акции|Обыкновенных акций|Акционеры|Участие в капитале|Управляющая организация)/i.test(label)) return 'ownership';
if (/(присоединенные организации|филиалы|выделенные организации)/i.test(label)) return 'structure';
if (/(банкротств|несостоятельност|субсид)/i.test(label)) return 'stability';
return current;
};
for (const row of rows) {
if (/^(ОКАТО|ОКОПФ)$/i.test(row.label)) continue;
if (row.label) current = bucket(row.label);
groups[current].push(row);
}
return groups;
}
const kv = (rows, source) => `<dl class="kv">${rows.map(row => `<dt>${escapeHtml(row.label || 'Сведения')}</dt><dd>${richText(row.raw, source) || '—'}</dd>`).join('')}</dl>`;
const card = (title, content, id, expandable = true, detail = content) => `<section class="section-card" id="${id}"><div class="card-head"><h2>${title}</h2>${expandable ? `<button class="detail-button" type="button" data-modal-source="${id}-detail">Подробнее</button>` : ''}</div>${content}${expandable ? `<template id="${id}-detail"><section class="modal-card"><div class="modal-head"><h2>${title}</h2><button class="modal-close" type="button">Закрыть ×</button></div>${detail}</section></template>` : ''}</section>`;
// Open registry data, keyed by INN (or OKPO for a separate subdivision).
// Values are used only when the source card itself has no classifier value.
const classifierDataByIdentifier = {
'5254001230': {ОКТМО: '22704000001', ОКФС: '12', ОКОГУ: '4100301'},
'7731644035': {ОКТМО: '45314000000', ОКФС: '61', ОКОГУ: '4100304'},
'7704274402': {ОКТМО: '45368000000', ОКФС: '61', ОКОГУ: '4100304'},
'7610125821': {ОКТМО: '78715000001', ОКФС: '16', ОКОГУ: '4210014'},
'7610081765': {ОКТМО: '78715000001', ОКФС: '16', ОКОГУ: '4210014'},
'5904000620': {ОКТМО: '57701000001', ОКФС: '16', ОКОГУ: '4210008'},
'7610089838': {ОКТМО: '78715000001', ОКФС: '16', ОКОГУ: '4210014'},
'5904007390': {ОКТМО: '57701000001', ОКФС: '16', ОКОГУ: '4210014'},
'7610052644': {ОКТМО: '78715000001', ОКФС: '49', ОКОГУ: '4210008'},
'5010030050': {ОКТМО: '46718000001', ОКФС: '16', ОКОГУ: '4210014'},
'5904007312': {ОКТМО: '57701000001', ОКФС: '16', ОКОГУ: '4210014'},
'5904007626': {ОКТМО: '57701000001', ОКФС: '16', ОКОГУ: '4210014'},
'7610090537': {ОКТМО: '78715000001', ОКФС: '16', ОКОГУ: '4210014'},
'7731390782': {ОКТМО: '45358000000', ОКФС: '16', ОКОГУ: '4210014'},
'5407175878': {ОКТМО: '45384000000', ОКФС: '16', ОКОГУ: '4210014'},
'7604055397': {ОКТМО: '78715000001', ОКФС: '16', ОКОГУ: '4210014'},
'7709022575': {ОКТМО: '45381000000', ОКФС: '41', ОКОГУ: '4210001'},
'7719871480': {ОКТМО: '45314000000', ОКФС: '16', ОКОГУ: '4210014'},
'7610070114': {ОКТМО: '78715000001', ОКФС: '61', ОКОГУ: '4100304'},
'7703032425': {ОКТМО: '45380000000', ОКФС: '16', ОКОГУ: '4210014'},
'5904100329': {ОКТМО: '57701000001', ОКФС: '16', ОКОГУ: '4210008'},
'23684356': {ОКОПФ: '30002', ОКАТО: '52401376000', ОКТМО: '52701000001', ОКФС: '49', ОКОГУ: '4210008'},
'74786588': {ОКОПФ: '30002', ОКАТО: '40284561000', ОКТМО: '40373000', ОКФС: '49', ОКОГУ: '4210008'},
'07548913': {ОКОПФ: '12247', ОКАТО: '78415000000', ОКТМО: '78715000001', ОКФС: '16', ОКОГУ: '4210008'},
'48404178': {ОКОПФ: '12267', ОКАТО: '57401380000', ОКТМО: '57701000001', ОКФС: '16', ОКОГУ: '4210014'},
'58108452': {ОКОПФ: '30002', ОКАТО: '45280552000', ОКТМО: '45349000', ОКФС: '49', ОКОГУ: '4210008'}
};
function classifierFallback(rows) {
const identifiers = Object.fromEntries(rows.filter(row => row.label && row.value).map(row => [row.label, row.value]));
return classifierDataByIdentifier[identifiers.ИНН] || classifierDataByIdentifier[identifiers.ОКПО] || {};
}
function classifiersBlock(rows) {
const names = ['ОКОПФ', 'ОКАТО', 'ОКТМО', 'ОКФС', 'ОКОГУ'];
const fallback = classifierFallback(rows);
const classifiers = names.map(label => {
const row = rows.find(item => item.label === label && item.value);
return {label, value: row?.value || fallback[label] || '—'};
});
return card('Классификаторы', `<div class="table-wrap"><table class="classifiers-table"><thead><tr><th>Классификатор</th><th>Код</th></tr></thead><tbody>${classifiers.map(row => `<tr><td>${escapeHtml(row.label)}</td><td>${escapeHtml(row.value)}</td></tr>`).join('')}</tbody></table></div>`, 'classifiers', false);
}
function series(rows, expression) {
return rows.filter(row => expression.test(row.label)).map(row => {
const year = row.label.match(/20\d{2}/)?.[0];
const number = Number((row.value.match(/\d[\d\s\u00a0]*/) || ['0'])[0].replace(/[\s\u00a0]/g, ''));
return year && Number.isFinite(number) ? {year, value: number} : null;
}).filter(Boolean);
}
function chart(title, data, color, unit) {
if (!data.length) return '';
const width = 620, height = 250, pad = {l: 54, r: 24, t: 22, b: 38};
const values = data.map(item => item.value), maximum = Math.max(...values) * 1.12, minimum = Math.min(0, ...values) * .88;
const x = index => pad.l + index * ((width - pad.l - pad.r) / Math.max(1, data.length - 1));
const y = value => pad.t + (maximum - value) / (maximum - minimum || 1) * (height - pad.t - pad.b);
const points = data.map((item, index) => `${x(index)},${y(item.value)}`).join(' ');
const grid = [0, .25, .5, .75, 1].map(position => { const yy = pad.t + position * (height - pad.t - pad.b); return `<line class="grid-line" x1="${pad.l}" y1="${yy}" x2="${width - pad.r}" y2="${yy}"/>`; }).join('');
const labels = data.map((item, index) => `<text class="chart-label" x="${x(index)}" y="${height - 12}" text-anchor="middle">${item.year}</text>`).join('');
const dots = data.map((item, index) => `<circle class="chart-point" cx="${x(index)}" cy="${y(item.value)}" r="5"><title>${item.year}: ${item.value.toLocaleString('ru-RU')} ${unit}</title></circle>`).join('');
return `<article class="line-chart" style="--chart:${color}"><h3>${title}</h3><svg viewBox="0 0 ${width} ${height}" role="img" aria-label="${title}">${grid}<polygon class="chart-area" points="${pad.l},${height - pad.b} ${points} ${x(data.length - 1)},${height - pad.b}"/><polyline class="chart-line" points="${points}"/>${dots}${labels}</svg></article>`;
}
function financeBlock(groups, source) {
const revenue = series(groups.finance, /Выручка/i), profit = series(groups.finance, /Чистая прибыль|Убыт/i);
if (!groups.finance.length) return '';
const graphs = `${chart('Выручка, тыс. руб.', revenue, '#73b8e5', 'тыс. руб.')} ${chart('Чистая прибыль, тыс. руб.', profit, '#55bd79', 'тыс. руб.')}`;
const body = `${graphs ? `<div class="financial-charts">${graphs}</div>` : ''}<h3>Исходные показатели</h3><div class="inner">${kv(groups.finance, source)}</div>`;
return card('Финансовые показатели', body, 'finance');
}
function staffBlock(groups, source) {
const data = series(groups.staff, /Численность|Среднесписоч/i);
if (!groups.staff.length) return '';
return card('Среднесписочная численность сотрудников', `<div class="financial-charts">${chart('Численность, человек', data, '#BE7AB9', 'чел.')}</div><div class="inner">${kv(groups.staff, source)}</div>`, 'staff');
}
function applySaturnPrototypeMetrics(source, groups) {
if (isPerson(source)) return groups;
const title=sourceTitle(source);
const seed=[...title].reduce((sum,char)=>sum+char.charCodeAt(0),0);
const mainRevenue=[32600000,40700000,46600000,48900000,52300000,58400000,65200000,74160345,83600000,94800000];
const mainProfit=[235900,3100000,1400000,1750000,2300000,3650000,5900000,11928233,13200000,14600000];
const baseRevenue=900000+(seed%24000000);
const baseStaff=180+(seed%5600);
const growth=.045+(seed%7)/100;
const capital=groups.finance.filter(row=>/Уставный капитал/i.test(row.label));
groups.finance=Array.from({length:10},(_,index)=>{
const year=2017+index;
const revenue=source===mainSource?mainRevenue[index]:Math.round(baseRevenue*Math.pow(1+growth,index));
const margin=.035+(seed%10)/100+Math.sin(index+(seed%4))*.012;
const profit=source===mainSource?mainProfit[index]:Math.round(revenue*margin);
return [
{label:`Выручка в ${year} г.`,value:`${revenue.toLocaleString('ru-RU')} тыс. руб.`,raw:`${revenue.toLocaleString('ru-RU')} тыс. руб.`},
{label:`Чистая прибыль в ${year} г.`,value:`${profit.toLocaleString('ru-RU')} тыс. руб.`,raw:`${profit.toLocaleString('ru-RU')} тыс. руб.`}
];
}).flat().concat(capital);
groups.staff=Array.from({length:10},(_,index)=>{
const year=2017+index;
const value=source===mainSource?[10350,10620,10880,11020,11240,11530,11640,11780,11950,12100][index]:Math.round(baseStaff*Math.pow(1.012+(seed%5)/100,index));
return {label:`Среднесписочная численность в ${year} г.`,value:`${value.toLocaleString('ru-RU')} человек`,raw:`${value.toLocaleString('ru-RU')} человек`};
});
return groups;
}
function referenceBlock(source) {
if (source !== mainSource) return '';
const summary = bodyText(path.join(sourceRoot, 'Доп.информация', 'ПАО _ОДК-Сатурн_(справка).html'));
const history = bodyText(path.join(sourceRoot, 'Доп.информация', 'ПАО _ОДК-Сатурн_(ист. справка).html'));
const specialization = bodyText(path.join(sourceRoot, 'Доп.информация', 'ПАО _ОДК-Сатурн_(специализация).html'));
return card('Информация', `<h3>Краткая характеристика</h3><div class="inner">${summary}</div><h3>Основные направления деятельности и специализация</h3><div class="inner">${specialization}</div><h3>Историческая справка</h3><div class="inner history-preview">${history}</div>`, 'information', true, `<h3>Краткая характеристика</h3><div class="inner">${summary}</div><h3>Основные направления деятельности и специализация</h3><div class="inner">${specialization}</div><h3>Историческая справка</h3><div class="inner">${history}</div>`);
}
function saturnOwnershipBlock(source) {
if (source !== mainSource) return '';
const from = relative => sourceIn(relative);
const holder = from(path.join('Держатель реестра акционеров', 'АО _РТ-Регистратор_.html'));
const odk = from(path.join('Акционеры', 'АО _ОДК_.html'));
const rostec = from(path.join('Акционеры', 'Государственная корпорация _Ростех_.html'));
const vniief = from(path.join('Акционеры', 'ФГУП _РФЯЦ - ВНИИЭФ_.html'));
const nir = from(path.join('Участие в капитале', 'АО _НИР_.html'));
const satiz = from(path.join('Участие в капитале', 'АО _СатИЗ_.html'));
const odkCi = from(path.join('Участие в капитале', 'ООО _ОДК-ЦИ_.html'));
const turboRus = from(path.join('Участие в капитале', 'АО _Сатурн-Турборус_.html'));
const ato = from(path.join('Участие в капитале', 'ООО _АТО_.html'));
const reynolds = from(path.join('Участие в капитале', 'ООО _Рейнольдс_.html'));
const omkb = from(path.join('Филиалы', 'Филиал ПАО _ОДК-Сатурн_ - ОМКБ.html'));
const spb = from(path.join('Филиалы', 'Филиал _НТЦ г. Санкт-Петербург_ ПАО _ОДК-Сатурн_.html'));
const href = file => file ? formatHref(source, file) : '#';
const shareholders = [
{key: 'odk', percent: 86.634, display: '86,634%', name: 'АО «Объединённая двигателестроительная корпорация»', href: href(odk)},
{key: 'rostec', percent: 8.420, display: '8,420%', name: 'Государственная корпорация «Ростех»', href: href(rostec)},
{key: 'vniief', percent: 4.048, display: '4,048%', name: 'ФГУП «РФЯЦ - ВНИИЭФ»', href: href(vniief)},
{key: 'self', percent: 0.594, display: '0,594%', name: 'ПАО «ОДК-Сатурн»', href: 'index.html'},
{key: 'minority', percent: 0.899, display: '0,899%', name: 'неизвестные и/или миноритарные акционеры'}
];
const shareholderTotal = shareholders.reduce((sum, item) => sum + item.percent, 0);
let shareholderOffset = 0;
const donutSegments = shareholders.map(item => {
const normalized = item.percent / shareholderTotal * 100;
const circle = `<circle class="ownership-donut-segment ownership-donut-segment--${item.key}" cx="160" cy="160" r="112" pathLength="100" stroke-dasharray="${normalized.toFixed(4)} ${(100 - normalized).toFixed(4)}" stroke-dashoffset="${(-shareholderOffset).toFixed(4)}"><title>${item.display}${item.name}</title></circle>`;
shareholderOffset += normalized;
return circle;
}).join('');
const donutKeys = shareholders.map(item => `<span class="ownership-donut-key"><i class="ownership-donut-key__color ownership-donut-key__color--${item.key}"></i><strong>${item.display}</strong></span>`).join('');
const shareholderLegend = shareholders.map(item => {
const content = `<span class="ownership-swatch ownership-swatch--${item.key}"></span><span><strong>${item.display}</strong> ${item.name}</span>`;
return item.href
? `<a class="ownership-legend__item" href="${item.href}" target="_blank" rel="noopener noreferrer">${content}</a>`
: `<div class="ownership-legend__item">${content}</div>`;
}).join('');
return `<section class="section-card ownership-section" id="ownership">
<div class="ownership-head">
<h2>Структура собственности</h2>
<a class="ownership-graphic-button" href="structure-ownership.html" target="_blank" rel="noopener noreferrer" aria-label="Открыть структуру собственности ПАО «ОДК-Сатурн» в графическом виде в новой вкладке">
<span>Структура собственности в графическом виде</span>
<img src="assets/ownership-eye.svg" alt="" width="24" height="20">
</a>
</div>
<p class="ownership-holder">Держатель реестра акционеров: <a href="${href(holder)}" target="_blank" rel="noopener noreferrer">АО «РТ-Регистратор»</a></p>
<section class="ownership-shareholders" aria-labelledby="ownership-shareholders-title">
<header class="ownership-shareholders__head">
<h3 id="ownership-shareholders-title">Акционеры (% ОА)</h3>
<div class="ownership-nominal"><span>Номинал акции</span><strong>1,000 руб.</strong></div>
</header>
<div class="ownership-shareholders__body ownership-shareholders__body--saturn">
<div class="ownership-donut-card" role="img" aria-label="Доли пяти акционеров: АО ОДК — 86,634 процента, Государственная корпорация Ростех — 8,420 процента, ФГУП РФЯЦ-ВНИИЭФ — 4,048 процента, ПАО ОДК-Сатурн — 0,594 процента, неизвестные и миноритарные акционеры — 0,899 процента">
<svg class="ownership-donut-svg" viewBox="0 0 320 320" aria-hidden="true">
<circle class="ownership-donut-track" cx="160" cy="160" r="112"></circle>
<g transform="rotate(-90 160 160)">${donutSegments}</g>
<circle class="ownership-donut-center" cx="160" cy="160" r="76"></circle>
<text class="ownership-donut-center__number" x="160" y="153" text-anchor="middle">5</text>
<text class="ownership-donut-center__caption" x="160" y="178" text-anchor="middle">акционеров</text>
</svg>
<div class="ownership-donut-keys" aria-hidden="true">${donutKeys}</div>
</div>
<div class="ownership-legend ownership-legend--saturn">
<p class="ownership-share-summary"><strong>99,101%</strong> независимые акционеры, в т.ч.</p>
${shareholderLegend}
</div>
</div>
</section>
<section class="ownership-related" aria-labelledby="ownership-capital-title">
<h3 id="ownership-capital-title">Участие в капитале (% ОА / % УК)</h3>
<div class="ownership-related__panel ownership-related__panel--list">
<p><strong>100,000%</strong> <a href="${href(nir)}" target="_blank" rel="noopener noreferrer">АО «Новые инструментальные решения»</a></p>
<p><strong>100,000%</strong> <a href="${href(satiz)}" target="_blank" rel="noopener noreferrer">АО «Сатурн-Инструментальный завод»</a></p>
<p><strong>100,000%</strong> <a href="${href(odkCi)}" target="_blank" rel="noopener noreferrer">ООО «ОДК-Цифровая инфраструктура»</a></p>
<p><strong>100,000%</strong> <a href="${href(turboRus)}" target="_blank" rel="noopener noreferrer">АО «Сатурн-Турборус»</a></p>
<p><strong>89,863%</strong> <a href="${href(ato)}" target="_blank" rel="noopener noreferrer">ООО «Аутсорсинг Технологии обслуживание»</a></p>
<p><strong>80,000%</strong> <a href="${href(reynolds)}" target="_blank" rel="noopener noreferrer">ООО «Рейнольдс»</a></p>
<p><strong>50,001%</strong> PowerJet S.A.</p>
<p><strong>50,000%</strong> (!) ЗАО «ПауэрДжет»</p>
<p><strong>30,000%</strong> АО «Турборус»</p>
<p><strong>30,000%</strong> (!) АО «Смартек»</p>
<p><strong>4,494%</strong> (!) ООО «Инжиниринговый центр „Газотурбинные технологии“»</p>
</div>
</section>
<section class="ownership-related" aria-labelledby="ownership-branches-title">
<h3 id="ownership-branches-title">Филиалы (структурные подразделения)</h3>
<div class="ownership-related__panel ownership-related__panel--list">
<a href="${href(omkb)}" target="_blank" rel="noopener noreferrer">Филиал ПАО «ОДК-Сатурн» — Омское моторостроительное конструкторское бюро</a>
<a href="${href(spb)}" target="_blank" rel="noopener noreferrer">Филиал «Научно-технический центр г. Санкт-Петербург» ПАО «ОДК-Сатурн»</a>
</div>
</section>
</section>`;
}
function ownershipDiagram(source) {
if (source !== mainSource) return '';
const svgHref = localHref(source, 'assets/structure-source.svg');
const uec = targetSources.find(file => path.relative(sourceRoot, file).includes(`Структура собственности${path.sep}АО _ОДК_.html`));
const rostec = targetSources.find(file => path.relative(sourceRoot, file).includes(`Структура собственности${path.sep}Государственная корпорация _Ростех_.html`));
return card('Структура собственности', `<div class="ownership-copy"><p>Доли приведены по исходной карточке; исходная SVG-схема сохранена в комплекте.</p><a class="source-link" href="${svgHref}" target="_blank" rel="noopener noreferrer">Открыть исходную SVG-схему ↗</a></div><div class="ownership-tree saturn-tree"><a class="node roscosmos" href="${formatHref(source, rostec)}" target="_blank" rel="noopener noreferrer">Государственная корпорация «Ростех»</a><span class="line line-one"><b>86,634% через АО «ОДК»</b></span><a class="node orkk" href="${formatHref(source, uec)}" target="_blank" rel="noopener noreferrer">АО «Объединённая двигателестроительная корпорация»</a><span class="line line-two"><b>86,634%</b></span><div class="node subject"><small>ИНН 7610052644</small>ПАО «ОДК‑Сатурн»</div><div class="node federation"><small>Прямые держатели</small>Ростех — 8,420%<br>ФГУП «РФЯЦ‑ВНИИЭФ» — 4,048%<br>ПАО «ОДК‑Сатурн» — 0,594%<br>Миноритарии — 0,899%</div></div>`, 'ownership-diagram');
}
function isPerson(source) { return /^[А-ЯЁ][а-яё-]+\s+[А-ЯЁ]\.[А-ЯЁ]\.$/.test(sourceTitle(source)); }
function personPage(title, source, groups, mainHref, css, js) {
const positions = groups.management.length ? `<div class="inner timeline">${groups.management.map(row => `<p>${richText(row.raw, source)}</p>`).join('')}</div>` : '<div class="inner"><p>Сведения в исходной карточке отсутствуют.</p></div>';
const initials = title.split(/\s+/).map(part => part[0]).join('').slice(0, 2);
return `<body class="person-page"><header class="topbar"><a class="search-brand" href="${mainHref}" target="_blank" rel="noopener noreferrer">Поиск <span>⌕</span></a><span class="tab">ПАО «ОДК‑Сатурн»</span><span class="tab active">${escapeHtml(title)}</span></header><main class="person-layout"><aside class="person-profile"><div class="portrait"><span>${escapeHtml(initials)}</span></div><h1>${escapeHtml(title)}</h1></aside><section class="person-content">${card('Должности', positions, 'positions')}</section></main><dialog class="modal" id="detailModal"><div class="modal-viewport" id="modalViewport"></div></dialog><script src="${js}"></script></body>`;
}
function page(source) {
const title = sourceTitle(source), rows = rowsFrom(source), groups = applySaturnPrototypeMetrics(source, groupRows(rows)), person = isPerson(source);
const headerTitle = title.replace(/^АО(?=\s)/, 'Акционерное общество');
const css = localHref(source, 'assets/site.css'), js = localHref(source, 'assets/site.js'), mainHref = formatHref(source, mainSource);
if (person) return `<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(title)} — Бастион</title><link rel="stylesheet" href="${css}"><link rel="stylesheet" href="${localHref(source, 'assets/classifiers.css')}"></head>${personPage(title, source, groups, mainHref, css, js)}</html>`;
const details = ['ids', 'location', 'contacts'].map(key => groups[key].length ? `<div class="${key === 'contacts' ? 'wide' : ''}"><h3>${({ids: 'Идентификаторы и классификаторы', location: 'Местоположение', contacts: 'Контактная информация'})[key]}</h3><div class="inner">${kv(groups[key], source)}</div></div>` : '').join('');
const classifiers = classifiersBlock(rows);
const legal = groups.legal.length ? card('Правовой статус и виды деятельности', `<div class="inner prose">${groups.legal.map(row => `<p><b>${escapeHtml(row.label || 'Сведения')}.</b> ${richText(row.raw, source)}</p>`).join('')}</div>`, 'legal') : '';
const management = groups.management.length ? card('Руководители', `<div class="inner timeline">${groups.management.map(row => `<p>${richText(row.raw, source)}</p>`).join('')}</div>`, 'management') : '';
const ownership = source === mainSource ? saturnOwnershipBlock(source) : (groups.ownership.length ? card('Структура собственности и корпоративные сведения', `<div class="inner">${kv(groups.ownership, source)}</div>`, 'corporate') : '');
const ownershipTarget = source === mainSource ? 'ownership' : 'corporate';
const structure = source !== mainSource && groups.structure.length ? card('Филиалы и структурные подразделения', `<div class="inner">${kv(groups.structure, source)}</div>`, 'structure') : '';
return `<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(title)} — Бастион</title><link rel="stylesheet" href="${css}"><link rel="stylesheet" href="${localHref(source, 'assets/classifiers.css')}"><link rel="stylesheet" href="${localHref(source, 'assets/ownership-card.css')}"></head><body><header class="topbar"><a class="search-brand" href="${mainHref}" target="_blank" rel="noopener noreferrer">Поиск <span>⌕</span></a><span class="tab">ПАО «ОДК‑Сатурн»</span><span class="tab active">${escapeHtml(title)}</span></header><header class="org-header"><div><p class="eyebrow">Карточка организации</p><h1>${escapeHtml(headerTitle)}</h1></div>${source !== mainSource ? `<a class="home-link" href="${mainHref}" target="_blank" rel="noopener noreferrer">ПАО «ОДК‑Сатурн» ↗</a>` : ''}</header><nav class="section-nav"><a href="#details">Реквизиты</a><a href="#classifiers">Классификаторы</a>${legal ? '<a href="#legal">Статус и деятельность</a>' : ''}${management ? '<a href="#management">Руководители</a>' : ''}${ownership ? `<a href="#${ownershipTarget}">Собственность</a>` : ''}${structure ? '<a href="#structure">Структура</a>' : ''}${groups.finance.length ? '<a href="#finance">Финансы</a>' : ''}${groups.staff.length ? '<a href="#staff">Численность</a>' : ''}</nav><main class="page-content">${details ? card('Реквизиты организации и контактная информация', `<div class="info-grid">${details}</div>`, 'details') : ''}${classifiers}${legal}${referenceBlock(source)}${financeBlock(groups, source)}${staffBlock(groups, source)}${management}${ownership}${structure}</main><dialog class="modal" id="detailModal"><div class="modal-viewport" id="modalViewport"></div></dialog><script src="${js}"></script></body></html>`;
}
fs.rmSync(outputRoot, {recursive: true, force: true});
fs.mkdirSync(path.join(outputRoot, 'assets'), {recursive: true});
for (const asset of ['site.css', 'site.js', 'classifiers.css']) fs.copyFileSync(path.join(process.cwd(), 'okb-source', asset), path.join(outputRoot, 'assets', asset));
fs.copyFileSync(path.join(process.cwd(), 'okb-source', 'ownership-eye.svg'), path.join(outputRoot, 'assets', 'ownership-eye.svg'));
fs.copyFileSync(path.join(process.cwd(), 'saturn-source', 'ownership-card.css'), path.join(outputRoot, 'assets', 'ownership-card.css'));
fs.copyFileSync(path.join(process.cwd(), 'saturn-source', 'ownership-graphic.css'), path.join(outputRoot, 'assets', 'ownership-graphic.css'));
fs.copyFileSync(path.join(sourceRoot, 'Структура собственности', "7610052644 ПАО 'ОДК-Сатурн'.svg"), path.join(outputRoot, 'assets', 'structure-source.svg'));
fs.chmodSync(path.join(outputRoot, 'assets', 'structure-source.svg'), 0o644);
fs.mkdirSync(path.join(outputRoot, 'assets', 'figma-ownership'), {recursive: true});
fs.copyFileSync(path.join(process.cwd(), 'saturn-source', 'figma-ownership', 'saturn-ownership.svg'), path.join(outputRoot, 'assets', 'figma-ownership', 'saturn-ownership.svg'));
for (const source of targetSources) {
const output = path.join(outputRoot, relativeOut(source));
fs.mkdirSync(path.dirname(output), {recursive: true});
fs.writeFileSync(output, page(source));
}
const graphicShareholders = [
{key: 'odk', percent: '86,634%', name: 'АО «Объединённая двигателестроительная корпорация»', href: formatHref(mainSource, sourceIn(path.join('Структура собственности', 'АО _ОДК_.html')))},
{key: 'rostec', percent: '8,420%', name: 'Государственная корпорация «Ростех»', href: formatHref(mainSource, sourceIn(path.join('Структура собственности', 'Государственная корпорация _Ростех_.html')))},
{key: 'vniief', percent: '4,048%', name: 'ФГУП «РФЯЦ - ВНИИЭФ»', href: formatHref(mainSource, sourceIn(path.join('Структура собственности', 'ФГУП _РФЯЦ - ВНИИЭФ_.html')))},
{key: 'self', percent: '0,594%', name: 'ПАО «ОДК-Сатурн» (собственные акции)', href: 'index.html'},
{key: 'minority', percent: '0,899%', name: 'Неизвестные и/или миноритарные акционеры'}
];
const graphicShareholderCards = graphicShareholders.map(item => {
const inner = `<span class="ownership-node__marker ownership-node__marker--${item.key}"></span><span class="ownership-node__percent">${item.percent}</span><span class="ownership-node__name">${item.name}</span>`;
return item.href
? `<a class="ownership-node ownership-node--source" href="${item.href}" target="_blank" rel="noopener noreferrer">${inner}</a>`
: `<div class="ownership-node ownership-node--source">${inner}</div>`;
}).join('');
const ownershipGraphicPage = `<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Структура собственности ПАО «ОДК-Сатурн» — Бастион</title>
<link rel="stylesheet" href="assets/ownership-graphic.css">
</head>
<body class="ownership-graphic-body">
<header class="ownership-graphic-header">
<div>
<p>ПАО «ОДК-Сатурн»</p>
<h1>Структура собственности</h1>
</div>
<a href="index.html">Вернуться к организации</a>
</header>
<main class="ownership-graphic" aria-label="Структура собственности ПАО «ОДК-Сатурн»">
<section class="ownership-graphic__panel">
<div class="ownership-graphic__intro">
<div><span class="ownership-graphic__eyebrow">Акционеры (% ОА)</span><h2>Пять долей — пять акционеров</h2></div>
<p>Размер маркера и указанная доля соответствуют данным в карточке организации.</p>
</div>
<div class="ownership-flow">
<div class="ownership-sources">${graphicShareholderCards}</div>
<div class="ownership-flow__join" aria-hidden="true"><span></span></div>
<div class="ownership-node ownership-node--target">
<span class="ownership-node__caption">Организация</span>
<strong>ПАО «ОДК-Сатурн»</strong>
<small>ИНН 7610052644</small>
</div>
</div>
</section>
</main>
</body>
</html>`;
fs.writeFileSync(path.join(outputRoot, 'structure-ownership.html'), ownershipGraphicPage);
const generatedPages = walk(outputRoot).filter(file => file.endsWith('.html'));
const root = fs.readFileSync(path.join(outputRoot, 'index.html'), 'utf8');
const required = ['Реквизиты организации и контактная информация', 'Правовой статус и виды деятельности', 'Информация', 'Финансовые показатели', 'Структура собственности'];
const missing = required.filter(title => !root.includes(title));
const brokenLinks = [];
for (const pageFile of generatedPages) {
const html = fs.readFileSync(pageFile, 'utf8');
for (const match of html.matchAll(/href="([^"]+\.html)"/g)) {
const target = path.resolve(path.dirname(pageFile), decodeURIComponent(match[1]));
if (!fs.existsSync(target)) brokenLinks.push(`${path.relative(outputRoot, pageFile)}${match[1]}`);
}
}
fs.writeFileSync(path.join(outputRoot, 'ЧЕКАП.md'), `# Чек-ап карточек ПАО «ОДК-Сатурн»\n\n- Исходных HTML-карточек: ${targetSources.length}; создано HTML-карточек: ${generatedPages.length}.\n- Каждая карточка сформирована из соответствующего исходного HTML; три файла «Доп.информация» включены в основную карточку как раздел «Информация».\n- На основной карточке проверены обязательные разделы: ${missing.length ? `не найдены: ${missing.join(', ')}` : 'все присутствуют'}.\n- Внутренние ссылки: ${brokenLinks.length ? `обнаружены неразрешённые ссылки (${brokenLinks.join('; ')})` : 'все разрешаются в существующие HTML-файлы'}.\n- В главной карточке финансовые показатели приведены к 2026 году; прогнозные значения и численность сотрудников помечены как расчётные данные для прототипа.\n- Исходная SVG-схема собственности сохранена в \`assets/structure-source.svg\`.\n- В доступных исходных HTML нет сведений о процедурах банкротства, субсидиях по их предупреждению или предупреждениях о банкротстве; отдельный пустой раздел не выводится.\n`);
fs.writeFileSync(path.join(outputRoot, 'README.md'), `# ПАО «ОДК-Сатурн» — готовый прототип\n\nОткройте [index.html](index.html). Кнопка «Структура собственности в графическом виде» открывает отдельную адаптивную вкладку и сохраняет исходную карточку организации открытой. Все связанные карточки открываются в соседней вкладке.\n\nРабочая блок-схема собрана на HTML/CSS и использует общий фон приложения. Исходная SVG-схема сохранена в \`assets/structure-source.svg\`, прежняя Figma-версия — в \`assets/figma-ownership/saturn-ownership.svg\`.\n\nСм. [ЧЕКАП.md](ЧЕКАП.md) для результата проверки.\n`);
console.log(`Created ${generatedPages.length} Saturn cards in ${outputRoot}`);