bash -lc cat > /mnt/data/generate_legal_dashboard.py <<'PY'
from future import annotations
import json
import math
import re
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
import pandas as pd
INPUT_XLSX = Path('/mnt/data/Status_general.xlsx')
OUTPUT_HTML = Path('/mnt/data/legal_status_dashboard_v2.html')
SHEET_NAME = 'ЛУ'
TODAY = pd.Timestamp('2026-04-15')
def normalize_text(value: Any) -> str:
if pd.isna(value):
return ''
text = str(value).strip()
return '' if text.lower() == 'nan' else text
def parse_number(value: Any) -> Optional[float]:
if pd.isna(value):
return None
if isinstance(value, (int, float)):
return float(value)
text = str(value).strip().replace(' ', '').replace(',', '.')
if not text:
return None
numbers = re.findall(r'-?\d+(?:.\d+)?', text)
if not numbers:
return None
try:
return float(numbers[0])
except ValueError:
return None
def extract_deadline(text: str) -> Optional[pd.Timestamp]:
if not text:
return None
matches = re.findall(r'(\d{2}.\d{2}.\d{4})', text)
dates = []
for m in matches:
try:
dates.append(pd.to_datetime(m, format='%d.%m.%Y', dayfirst=True))
except Exception:
pass
if not dates:
return None
future_or_today = [d for d in dates if d >= TODAY.normalize()]
if future_or_today:
return min(future_or_today)
return max(dates)
def contains_any(text: str, needles: List[str]) -> bool:
low = text.lower()
return any(n.lower() in low for n in needles)
def derive_status(row: pd.Series) -> str:
eco = normalize_text(row.get('ЭКОЛОГИЧЕСКИЙ ОТЧЕТ'))
deps = normalize_text(row.get('ОТВЕТЫ ИЗ ДЕПАРТАМЕНТОВ'))
ugamp = normalize_text(row.get('УГАМП'))
exploration = normalize_text(row.get('РАЗРЕШЕНИЕ НА РАЗВЕДКУ'))
mina = normalize_text(row.get('СТАТУС MINA'))
extraction = normalize_text(row.get('РАЗРЕШЕНИЕ НА ДОБЫЧУ (ОКОНЧАТЕЛЬНАЯ КОНЦЕССИЯ)'))
comments = normalize_text(row.get('КОММЕНТАРИИ'))
joined = ' | '.join([eco, deps, ugamp, exploration, mina, extraction, comments]).lower()
text
if contains_any(joined, ['оопт', 'архив', 'заблок', 'нецелесообраз', 'проблему оопт']):
return 'Приостановлен'
if contains_any(extraction, ['получено']) or contains_any(exploration, ['процесс разведки', 'инсталяцион', 'минимальных работ']):
return 'Активен'
if contains_any(joined, ['возвращен на корректировку', 'не приступали', 'не подавали', 'срочно', 'редактировать', 'внести изменения', 'получить заключение']) or deps == '1 из 3':
return 'Риск'
if contains_any(joined, ['подан', 'ждем', 'ожидаем', 'готов', 'проведен', 'подали заявление', 'подали образцы']):
return 'В процессе'
return 'В процессе'
def risk_level(row: pd.Series) -> str:
status = row['EXEC_STATUS']
deadline = row['DEADLINE']
if status == 'Приостановлен':
return 'critical'
if status == 'Риск':
return 'high'
if pd.notna(deadline):
days = int((deadline.normalize() - TODAY.normalize()).days)
if days < 0:
return 'critical'
if days <= 14:
return 'high'
if days <= 30:
return 'medium'
if status == 'Активен':
return 'low'
return 'medium'
def human_date(ts: Optional[pd.Timestamp]) -> str:
if ts is None or pd.isna(ts):
return ''
return ts.strftime('%d.%m.%Y')
def fmt_area(value: Any) -> str:
num = parse_number(value)
if num is None:
return ''
return f'{num:,.0f}'.replace(',', ' ')
def prepare_data() -> pd.DataFrame:
df = pd.read_excel(INPUT_XLSX, sheet_name=SHEET_NAME)
df = df.copy()
text
for col in df.columns:
if df[col].dtype == object:
df[col] = df[col].map(normalize_text)
df['DEADLINE'] = df['КОММЕНТАРИИ'].map(extract_deadline)
df['EXEC_STATUS'] = df.apply(derive_status, axis=1)
df['RISK_LEVEL'] = df.apply(risk_level, axis=1)
df['AREA_NUM'] = df['ПЛОЩАДЬ (га)'].map(parse_number)
df['AREA_FMT'] = df['ПЛОЩАДЬ (га)'].map(fmt_area)
df['IS_ACTIVE'] = df['EXEC_STATUS'].eq('Активен')
df['IS_RISK'] = df['EXEC_STATUS'].eq('Риск')
df['IS_SUSPENDED'] = df['EXEC_STATUS'].eq('Приостановлен')
next_step = []
for _, row in df.iterrows():
comment = normalize_text(row.get('КОММЕНТАРИИ'))
if comment:
next_step.append(comment)
continue
exploration = normalize_text(row.get('РАЗРЕШЕНИЕ НА РАЗВЕДКУ'))
if exploration and exploration != 'отсутствует':
next_step.append(exploration)
continue
eco = normalize_text(row.get('ЭКОЛОГИЧЕСКИЙ ОТЧЕТ'))
next_step.append(eco or '—')
df['NEXT_STEP'] = next_step
return df
def build_summary(df: pd.DataFrame) -> Dict[str, Any]:
total = len(df)
active = int(df['IS_ACTIVE'].sum())
risk = int(df['IS_RISK'].sum())
suspended = int(df['IS_SUSPENDED'].sum())
in_progress = int((df['EXEC_STATUS'] == 'В процессе').sum())
text
deadlines = df[df['DEADLINE'].notna()].copy()
deadlines['DAYS_LEFT'] = (deadlines['DEADLINE'].dt.normalize() - TODAY.normalize()).dt.days
urgent = int((deadlines['DAYS_LEFT'] <= 14).sum())
province_summary = (
df.groupby('ПРОВИНЦИЯ', dropna=False)
.agg(total=('НОМЕР УЧАСТКА', 'count'),
risk=('IS_RISK', 'sum'),
suspended=('IS_SUSPENDED', 'sum'))
.reset_index()
.sort_values(['total', 'ПРОВИНЦИЯ'], ascending=[False, True])
)
status_order = ['Активен', 'В процессе', 'Риск', 'Приостановлен']
status_counts = df['EXEC_STATUS'].value_counts().reindex(status_order, fill_value=0)
trend_src = deadlines.copy()
if not trend_src.empty:
trend_src['MONTH'] = trend_src['DEADLINE'].dt.to_period('M').astype(str)
trend = trend_src.groupby(['MONTH', 'EXEC_STATUS']).size().unstack(fill_value=0).reset_index()
else:
trend = pd.DataFrame(columns=['MONTH'] + status_order)
top_items = df.copy()
top_items['SORT_DEADLINE'] = top_items['DEADLINE'].fillna(pd.Timestamp.max)
risk_rank = {'critical': 0, 'high': 1, 'medium': 2, 'low': 3}
top_items['RISK_SORT'] = top_items['RISK_LEVEL'].map(risk_rank)
top_items = top_items.sort_values(['RISK_SORT', 'SORT_DEADLINE', 'ПРОВИНЦИЯ', 'НОМЕР УЧАСТКА']).head(12)
return {
'kpis': {
'total': total,
'active': active,
'in_progress': in_progress,
'risk': risk,
'suspended': suspended,
'urgent': urgent,
'active_pct': round(active / total * 100, 1) if total else 0,
'risk_pct': round((risk + suspended) / total * 100, 1) if total else 0,
},
'status_counts': status_counts.to_dict(),
'province_summary': province_summary.to_dict(orient='records'),
'trend': trend.fillna(0).to_dict(orient='records'),
'top_items': top_items,
}
def build_records(df: pd.DataFrame) -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
for _, row in df.iterrows():
out.append({
'number': normalize_text(row.get('НОМЕР УЧАСТКА')),
'name': normalize_text(row.get('НАЗВАНИЕ УЧАСТКА')),
'type': normalize_text(row.get('ТИП УЧАСТКА')),
'party': normalize_text(row.get('ЗАИНТЕРЕСОВАННАЯ СТОРОНА')),
'province': normalize_text(row.get('ПРОВИНЦИЯ')),
'region': normalize_text(row.get('РЕГИОН')),
'area': row.get('AREA_FMT', ''),
'status': row.get('EXEC_STATUS', ''),
'riskLevel': row.get('RISK_LEVEL', ''),
'deadline': human_date(row.get('DEADLINE')),
'nextStep': normalize_text(row.get('NEXT_STEP')),
'eco': normalize_text(row.get('ЭКОЛОГИЧЕСКИЙ ОТЧЕТ')),
'deps': normalize_text(row.get('ОТВЕТЫ ИЗ ДЕПАРТАМЕНТОВ')),
'ugamp': normalize_text(row.get('УГАМП')),
'explorationPermit': normalize_text(row.get('РАЗРЕШЕНИЕ НА РАЗВЕДКУ')),
'minaStatus': normalize_text(row.get('СТАТУС MINA')),
'extractionPermit': normalize_text(row.get('РАЗРЕШЕНИЕ НА ДОБЫЧУ (ОКОНЧАТЕЛЬНАЯ КОНЦЕССИЯ)')),
'comments': normalize_text(row.get('КОММЕНТАРИИ')),
})
return out
def html_template(data_json: str, generated_at: str) -> str:
return f"""<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Дашборд юридического статуса лицензионных участков</title>
<script src="https://cdn.jsdelivr.net/npm/
[email protected]/dist/chart.umd.min.js"></script>
<style>
:root {{
--bg: #f6f8fb;
--card: #ffffff;
--line: #e7ebf3;
--text: #0f172a;
--muted: #64748b;
--green: #16a34a;
--yellow: #eab308;
--orange: #f97316;
--red: #dc2626;
--blue: #2563eb;
--shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
--radius: 18px;
}}
* {{ box-sizing: border-box; }}
body {{ margin: 0; font-family: Inter, Segoe UI, Arial, sans-serif; background: var(--bg); color: var(--text); }}
.wrap {{ max-width: 1440px; margin: 0 auto; padding: 28px; }}
.hero {{ display: flex; justify-content: space-between; gap: 24px; align-items: flex-start; margin-bottom: 24px; }}
.title {{ font-size: 32px; font-weight: 800; letter-spacing: -0.04em; margin: 0 0 8px; }}
.sub {{ color: var(--muted); font-size: 14px; line-height: 1.5; max-width: 760px; }}
.updated {{ background: rgba(255,255,255,0.75); border: 1px solid var(--line); border-radius: 14px; padding: 14px 16px; color: var(--muted); font-size: 13px; min-width: 190px; text-align: right; box-shadow: var(--shadow); }}
.section {{ margin-top: 26px; }}
.section-title {{ font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin: 0 0 14px; }}
.kpis {{ display: grid; grid-template-columns: repeat(6, minmax(0,1fr)); gap: 16px; }}
.card {{ background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }}
.kpi {{ padding: 18px 18px 16px; position: relative; overflow: hidden; }}
.kpi::before {{ content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: var(--accent, var(--blue)); }}
.kpi .value {{ font-size: 34px; font-weight: 800; letter-spacing: -0.05em; margin-bottom: 6px; }}
.kpi .label {{ font-size: 13px; color: var(--muted); margin-bottom: 8px; }}
.kpi .meta {{ font-size: 12px; color: var(--muted); }}
.grid {{ display: grid; grid-template-columns: 1.1fr 1fr 1fr; gap: 16px; }}
.panel {{ padding: 18px; }}
.panel h3 {{ margin: 0 0 12px; font-size: 14px; }}
.panel canvas {{ width: 100%; height: 300px; }}
.filters {{ display: grid; grid-template-columns: 2fr 1fr 1fr 1fr; gap: 12px; margin-bottom: 14px; }}
input, select {{ width: 100%; border: 1px solid var(--line); background: #fff; border-radius: 12px; padding: 12px 14px; font-size: 14px; color: var(--text); outline: none; }}
input:focus, select:focus {{ border-color: #93c5fd; box-shadow: 0 0 0 4px rgba(37,99,235,.08); }}
table {{ width: 100%; border-collapse: collapse; }}
thead th {{ text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); padding: 14px 16px; border-bottom: 1px solid var(--line); }}
tbody td {{ padding: 14px 16px; border-bottom: 1px solid var(--line); vertical-align: top; font-size: 14px; }}
tbody tr:hover {{ background: #f8fbff; }}
.num {{ font-weight: 700; color: var(--blue); white-space: nowrap; }}
.small {{ color: var(--muted); font-size: 12px; }}
.badge {{ display: inline-flex; align-items: center; gap: 8px; padding: 6px 10px; border-radius: 999px; font-size: 12px; font-weight: 700; white-space: nowrap; }}
.badge.green {{ background: #dcfce7; color: #166534; }}
.badge.yellow {{ background: #fef9c3; color: #854d0e; }}
.badge.orange {{ background: #ffedd5; color: #9a3412; }}
.badge.red {{ background: #fee2e2; color: #991b1b; }}
.dot {{ width: 8px; height: 8px; border-radius: 999px; background: currentColor; opacity: .85; }}
.two-col {{ display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }}
.list {{ padding: 6px 0 0; }}
.list-item {{ display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; padding: 12px 0; border-bottom: 1px solid var(--line); }}
.list-item:last-child {{ border-bottom: 0; }}
.list-item .left strong {{ display: block; font-size: 14px; margin-bottom: 4px; }}
.list-item .left span {{ font-size: 12px; color: var(--muted); }}
.list-item .right {{ text-align: right; }}
.summary-note {{ color: var(--muted); font-size: 13px; line-height: 1.6; }}
.footer {{ margin-top: 18px; color: var(--muted); font-size: 12px; }}
@media (max-width: 1200px) {{ .kpis {{ grid-template-columns: repeat(3, minmax(0,1fr)); }} .grid {{ grid-template-columns: 1fr; }} .filters {{ grid-template-columns: 1fr 1fr; }} .two-col {{ grid-template-columns: 1fr; }} }}
@media (max-width: 720px) {{ .wrap {{ padding: 16px; }} .hero {{ flex-direction: column; }} .kpis {{ grid-template-columns: repeat(2, minmax(0,1fr)); }} .filters {{ grid-template-columns: 1fr; }} .title {{ font-size: 26px; }} }}
</style>
</head>
<body>
<div class="wrap">
<div class="hero">
<div>
<h1 class="title">Юридический статус лицензионных участков</h1>
<div class="sub">Минималистичный executive-дашборд для руководства и инвесторов: акцент на общем портфеле, статусах, рисках, динамике дедлайнов и ключевых участках.</div>
</div>
<div class="updated">Обновлено<br><strong>{generated_at}</strong></div>
</div>
text
<div class="section">
<div class="section-title">Ключевые KPI</div>
<div class="kpis" id="kpiGrid"></div>
</div>
<div class="section">
<div class="section-title">Статусы и структура портфеля</div>
<div class="grid">
<div class="card panel">
<h3>Распределение по статусам</h3>
<canvas id="statusChart"></canvas>
</div>
<div class="card panel">
<h3>Портфель по провинциям</h3>
<canvas id="provinceChart"></canvas>
</div>
<div class="card panel">
<h3>Динамика дедлайнов</h3>
<canvas id="trendChart"></canvas>
</div>
</div>
</div>
<div class="section two-col">
<div class="card panel">
<h3>Приоритетные участки</h3>
<div id="priorityList" class="list"></div>
</div>
<div class="card panel">
<h3>Управленческий вывод</h3>
<div id="summaryNote" class="summary-note"></div>
</div>
</div>
<div class="section">
<div class="section-title">Ключевые участки</div>
<div class="filters">
<input id="searchInput" type="text" placeholder="Поиск по номеру, названию, комментарию" />
<select id="provinceFilter"></select>
<select id="statusFilter"></select>
<select id="typeFilter"></select>
</div>
<div class="card" style="overflow:auto;">
<table>
<thead>
<tr>
<th>Участок</th>
<th>Регион</th>
<th>Тип</th>
<th>Статус</th>
<th>Следующий шаг</th>
<th>Дедлайн</th>
<th>Площадь, га</th>
</tr>
</thead>
<tbody id="tableBody"></tbody>
</table>
</div>
<div class="footer">Источник данных: лист «ЛУ» из Excel-файла Status_general.xlsx.</div>
</div>
</div>
<script>
const DATA = {data_json};
const COLORS = {{
'Активен': '#16a34a',
'В процессе': '#eab308',
'Риск': '#f97316',
'Приостановлен': '#dc2626'
}};
function badgeClass(status) {{
if (status === 'Активен') return 'green';
if (status === 'В процессе') return 'yellow';
if (status === 'Риск') return 'orange';
return 'red';
}}
function buildKpis() {{
const k = DATA.summary.kpis;
const items = [
{{ value: k.total, label: 'Всего участков', meta: 'Полный портфель', accent: '#2563eb' }},
{{ value: k.active, label: 'Активные', meta: `${{k.active_pct}}% от портфеля`, accent: '#16a34a' }},
{{ value: k.in_progress, label: 'В процессе', meta: 'Работа идет, без блокировки', accent: '#eab308' }},
{{ value: k.risk, label: 'Риск', meta: 'Нужны действия / корректировки', accent: '#f97316' }},
{{ value: k.suspended, label: 'Приостановлены', meta: 'Есть стоп-факторы', accent: '#dc2626' }},
{{ value: k.urgent, label: 'Срочные дедлайны', meta: '≤ 14 дней', accent: '#7c3aed' }}
];
document.getElementById('kpiGrid').innerHTML = items.map(item => `
<div class="card kpi" style="--accent:${{item.accent}}">
<div class="value">${{item.value}}</div>
<div class="label">${{item.label}}</div>
<div class="meta">${{item.meta}}</div>
</div>
`).join('');
}}
function buildCharts() {{
const statusLabels = ['Активен', 'В процессе', 'Риск', 'Приостановлен'];
const statusValues = statusLabels.map(s => DATA.summary.status_counts[s] || 0);
new Chart(document.getElementById('statusChart'), {{
type: 'doughnut',
data: {{ labels: statusLabels, datasets: [{{ data: statusValues, backgroundColor: statusLabels.map(s => COLORS[s]), borderWidth: 0 }}] }},
options: {{ responsive: true, maintainAspectRatio: false, plugins: {{ legend: {{ position: 'bottom' }} }} }}
}});
const province = DATA.summary.province_summary;
new Chart(document.getElementById('provinceChart'), {{
type: 'bar',
data: {{
labels: province.map(x => x['ПРОВИНЦИЯ']),
datasets: [
{{ label: 'Всего', data: province.map(x => x.total), backgroundColor: '#cbd5e1', borderRadius: 8 }},
{{ label: 'Риск', data: province.map(x => x.risk), backgroundColor: '#f97316', borderRadius: 8 }},
{{ label: 'Приостановлен', data: province.map(x => x.suspended), backgroundColor: '#dc2626', borderRadius: 8 }}
]
}},
options: {{ responsive: true, maintainAspectRatio: false, plugins: {{ legend: {{ position: 'bottom' }} }}, scales: {{ y: {{ beginAtZero: true, ticks: {{ precision: 0 }} }} }} }}
}});
const trend = DATA.summary.trend;
new Chart(document.getElementById('trendChart'), {{
type: 'line',
data: {{
labels: trend.map(x => x.MONTH),
datasets: statusLabels.map(status => ({{
label: status,
data: trend.map(x => x[status] || 0),
borderColor: COLORS[status],
backgroundColor: COLORS[status],
tension: 0.35,
fill: false
}}))
}},
options: {{ responsive: true, maintainAspectRatio: false, plugins: {{ legend: {{ position: 'bottom' }} }}, scales: {{ y: {{ beginAtZero: true, ticks: {{ precision: 0 }} }} }} }}
}});
}}
function buildPriorityList() {{
const items = DATA.priority.map(item => `
<div class="list-item">
<div class="left">
<strong>${{item.number}}${{item.name ? ' · ' + item.name : ''}}</strong>
<span>${{item.province}} · ${{item.nextStep || '—'}}</span>
</div>
<div class="right">
<div class="badge ${{badgeClass(item.status)}}"><span class="dot"></span>${{item.status}}</div>
<div class="small" style="margin-top:6px;">${{item.deadline || 'без даты'}}</div>
</div>
</div>
`).join('');
document.getElementById('priorityList').innerHTML = items || '<div class="small">Нет данных.</div>';
}}
function buildSummary() {{
const k = DATA.summary.kpis;
const provinceTop = DATA.summary.province_summary[0];
const text = `В портфеле ${{k.total}} участков. Из них ${{k.active}} активных, ${{k.in_progress}} находятся в рабочем процессе, ${{k.risk}} требуют оперативного внимания и еще ${{k.suspended}} имеют признаки приостановки. Доля напряженного портфеля (риск + приостановленные) составляет ${{k.risk_pct}}%. Наиболее крупная концентрация участков сейчас в провинции ${{provinceTop ? provinceTop['ПРОВИНЦИЯ'] : '—'}}. Для руководства основной фокус — участки с корректировкой ОВОС, зависанием по UGAMP и явными стоп-факторами из комментариев.`;
document.getElementById('summaryNote').textContent = text;
}}
function fillSelect(selectId, values, label) {{
const select = document.getElementById(selectId);
select.innerHTML = `<option value="">${{label}}</option>` + values.map(v => `<option value="${{v}}">${{v}}</option>`).join('');
}}
function renderTable(records) {{
const body = document.getElementById('tableBody');
body.innerHTML = records.map(r => `
<tr>
<td>
<div class="num">${{r.number}}</div>
<div class="small">${{r.name || 'Без названия'}}</div>
<div class="small">${{r.party || ''}}</div>
</td>
<td>
<div>${{r.province || ''}}</div>
<div class="small">${{r.region || ''}}</div>
</td>
<td>${{r.type || ''}}</td>
<td><span class="badge ${{badgeClass(r.status)}}"><span class="dot"></span>${{r.status}}</span></td>
<td>
<div>${{r.nextStep || '—'}}</div>
<div class="small">ОВОС: ${{r.eco || '—'}} · UGAMP: ${{r.ugamp || '—'}}</div>
</td>
<td>${{r.deadline || '—'}}</td>
<td>${{r.area || '—'}}</td>
</tr>
`).join('');
}}
function applyFilters() {{
const q = document.getElementById('searchInput').value.trim().toLowerCase();
const province = document.getElementById('provinceFilter').value;
const status = document.getElementById('statusFilter').value;
const type = document.getElementById('typeFilter').value;
const filtered = DATA.records.filter(r => {{
const hay = [r.number, r.name, r.comments, r.nextStep, r.party, r.region].join(' ').toLowerCase();
return (!q || hay.includes(q))
&& (!province || r.province === province)
&& (!status || r.status === status)
&& (!type || r.type === type);
}});
renderTable(filtered);
}}
function initFilters() {{
const uniq = arr => [...new Set(arr.filter(Boolean))].sort((a,b) => a.localeCompare(b, 'ru'));
fillSelect('provinceFilter', uniq(DATA.records.map(x => x.province)), 'Все провинции');
fillSelect('statusFilter', ['Активен', 'В процессе', 'Риск', 'Приостановлен'], 'Все статусы');
fillSelect('typeFilter', uniq(DATA.records.map(x => x.type)), 'Все типы лицензий');
['searchInput', 'provinceFilter', 'statusFilter', 'typeFilter'].forEach(id => document.getElementById(id).addEventListener('input', applyFilters));
['provinceFilter', 'statusFilter', 'typeFilter'].forEach(id => document.getElementById(id).addEventListener('change', applyFilters));
}}
buildKpis();
buildCharts();
buildPriorityList();
buildSummary();
initFilters();
renderTable(DATA.records);
</script>
</body>
</html>
"""
def main() -> None:
df = prepare_data()
summary = build_summary(df)
records = build_records(df)
text
priority = []
for _, row in summary['top_items'].iterrows():
priority.append({
'number': normalize_text(row.get('НОМЕР УЧАСТКА')),
'name': normalize_text(row.get('НАЗВАНИЕ УЧАСТКА')),
'province': normalize_text(row.get('ПРОВИНЦИЯ')),
'status': normalize_text(row.get('EXEC_STATUS')),
'deadline': human_date(row.get('DEADLINE')),
'nextStep': normalize_text(row.get('NEXT_STEP')),
})
payload = {
'summary': {k: v for k, v in summary.items() if k != 'top_items'},
'priority': priority,
'records': records,
}
html = html_template(
data_json=json.dumps(payload, ensure_ascii=False),
generated_at=TODAY.strftime('%d.%m.%Y') + ' 09:00'
)
OUTPUT_HTML.write_text(html, encoding='utf-8')
print(f'[OK] Dashboard saved to: {OUTPUT_HTML}')
if name == 'main':
main()
PY
python /mnt/data/generate_legal_dashboard.py
Thought for 9m 13s