]*>([\s\S]*?)<\/tr>/gi)) {
const cells = [...match[1].matchAll(/]*>([\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(/]*>([\s\S]*?)<\/body>/i)?.[1] || '';
const useful = body.replace(/]*align=["'](?:right|center)["'][\s\S]*?<\/p>/gi, '');
const text = clean(useful.replace(/ /gi, ' ').replace(/<\/p>/gi, ' ').replace(/
]*>/gi, ' '));
return `
`;
}
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 => `${escapeHtml(match)} `);
}
return text;
}
function richText(raw, source) {
const tokens = [];
const marked = raw.replace(/]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, (_all, href, label) => {
const text = clean(label);
const target = registry.get(normalize(text));
if (target && target !== source) {
tokens.push(` ${escapeHtml(quoteName(text))} `);
} else if (/^(https?:\/\/|mailto:)/i.test(href)) {
tokens.push(`${escapeHtml(text || href)} `);
} else {
tokens.push(linkKnownNames(escapeHtml(quoteName(text)), source));
}
return `@@LINK${tokens.length - 1}@@`;
});
const text = quoteName(marked.replace(/ /gi, ' ').replace(/<[^>]*>/g, ' ').replace(/ | /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) => `${rows.map(row => `${escapeHtml(row.label || 'Сведения')} ${richText(row.raw, source) || '—'} `).join('')} `;
const card = (title, content, id, expandable = true, detail = content) => `
${title} ${expandable ? `Подробнее ` : ''}${content}${expandable ? `
${title} Закрыть × ${detail} ` : ''} `;
// 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('Классификаторы', `Классификатор Код ${classifiers.map(row => `${escapeHtml(row.label)} ${escapeHtml(row.value)} `).join('')}
`, '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 ` `; }).join('');
const labels = data.map((item, index) => `${item.year} `).join('');
const dots = data.map((item, index) => `${item.year}: ${item.value.toLocaleString('ru-RU')} ${unit} `).join('');
return `${title} ${grid} ${dots}${labels} `;
}
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 ? `${graphs}
` : ''}Исходные показатели ${kv(groups.finance, source)}
`;
return card('Финансовые показатели', body, 'finance');
}
function staffBlock(groups, source) {
const data = series(groups.staff, /Численность|Среднесписоч/i);
if (!groups.staff.length) return '';
return card('Среднесписочная численность сотрудников', `${chart('Численность, человек', data, '#BE7AB9', 'чел.')}
${kv(groups.staff, source)}
`, '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('Информация', `Краткая характеристика ${summary}
Основные направления деятельности и специализация ${specialization}
Историческая справка ${history}
`, 'information', true, `Краткая характеристика ${summary}
Основные направления деятельности и специализация ${specialization}
Историческая справка ${history}
`);
}
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 = `${item.display} — ${item.name} `;
shareholderOffset += normalized;
return circle;
}).join('');
const donutKeys = shareholders.map(item => `${item.display} `).join('');
const shareholderLegend = shareholders.map(item => {
const content = `${item.display} ${item.name} `;
return item.href
? `${content} `
: `${content}
`;
}).join('');
return `
Держатель реестра акционеров: АО «РТ-Регистратор»
Акционеры (% ОА)
Номинал акции 1,000 руб.
${donutSegments}
5
акционеров
${donutKeys}
99,101% независимые акционеры, в т.ч.
${shareholderLegend}
`;
}
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('Структура собственности', ``, 'ownership-diagram');
}
function isPerson(source) { return /^[А-ЯЁ][а-яё-]+\s+[А-ЯЁ]\.[А-ЯЁ]\.$/.test(sourceTitle(source)); }
function personPage(title, source, groups, mainHref, css, js) {
const positions = groups.management.length ? `${groups.management.map(row => `
${richText(row.raw, source)}
`).join('')}
` : 'Сведения в исходной карточке отсутствуют.
';
const initials = title.split(/\s+/).map(part => part[0]).join('').slice(0, 2);
return `Поиск ⌕ ПАО «ОДК‑Сатурн» ${escapeHtml(title)} ${escapeHtml(initials)}
${escapeHtml(title)} ${card('Должности', positions, 'positions')}
`;
}
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 `${escapeHtml(title)} — Бастион ${personPage(title, source, groups, mainHref, css, js)}`;
const details = ['ids', 'location', 'contacts'].map(key => groups[key].length ? `` : '').join('');
const classifiers = classifiersBlock(rows);
const legal = groups.legal.length ? card('Правовой статус и виды деятельности', `${groups.legal.map(row => `
${escapeHtml(row.label || 'Сведения')}. ${richText(row.raw, source)}
`).join('')}
`, 'legal') : '';
const management = groups.management.length ? card('Руководители', `${groups.management.map(row => `
${richText(row.raw, source)}
`).join('')}
`, 'management') : '';
const ownership = source === mainSource ? saturnOwnershipBlock(source) : (groups.ownership.length ? card('Структура собственности и корпоративные сведения', `${kv(groups.ownership, source)}
`, 'corporate') : '');
const ownershipTarget = source === mainSource ? 'ownership' : 'corporate';
const structure = source !== mainSource && groups.structure.length ? card('Филиалы и структурные подразделения', `${kv(groups.structure, source)}
`, 'structure') : '';
return `${escapeHtml(title)} — Бастион Поиск ⌕ ПАО «ОДК‑Сатурн» ${escapeHtml(title)} Реквизиты Классификаторы ${legal ? 'Статус и деятельность ' : ''}${management ? 'Руководители ' : ''}${ownership ? `Собственность ` : ''}${structure ? 'Структура ' : ''}${groups.finance.length ? 'Финансы ' : ''}${groups.staff.length ? 'Численность ' : ''}${details ? card('Реквизиты организации и контактная информация', `${details}
`, 'details') : ''}${classifiers}${legal}${referenceBlock(source)}${financeBlock(groups, source)}${staffBlock(groups, source)}${management}${ownership}${structure}
`;
}
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 = `${item.percent} ${item.name} `;
return item.href
? `${inner} `
: `${inner}
`;
}).join('');
const ownershipGraphicPage = `
Структура собственности ПАО «ОДК-Сатурн» — Бастион
Акционеры (% ОА)
Пять долей — пять акционеров
Размер маркера и указанная доля соответствуют данным в карточке организации.
${graphicShareholderCards}
Организация
ПАО «ОДК-Сатурн»
ИНН 7610052644
`;
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}`);