496 lines
44 KiB
JavaScript
496 lines
44 KiB
JavaScript
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 => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[char]));
|
||
const clean = value => String(value ?? '').replace(/<br\s*\/?\s*>/gi, '\n').replace(/<[^>]*>/g, ' ').replace(/ | /gi, ' ').replace(/"/gi, '"').replace(/&/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(/&/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(/ | /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}`);
|