--- /dev/null
+<!DOCTYPE html>
+<html lang="ru">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>ERP-33: Stock History ETL — Code Review</title>
+<style>
+ :root {
+ --bg-primary: #1a1a2e;
+ --bg-secondary: #16213e;
+ --bg-card: #0f3460;
+ --bg-card-hover: #1a4a80;
+ --accent-blue: #4fc3f7;
+ --accent-green: #81c784;
+ --accent-yellow: #ffb74d;
+ --accent-orange: #ff8a65;
+ --accent-red: #ef5350;
+ --text-primary: #e0e0e0;
+ --text-secondary: #b0bec5;
+ --text-muted: #78909c;
+ --border: #1e5f8e;
+ --border-light: #2a6fa0;
+ --shadow: 0 4px 20px rgba(0,0,0,0.4);
+ }
+
+ * { box-sizing: border-box; margin: 0; padding: 0; }
+
+ body {
+ font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ line-height: 1.6;
+ min-height: 100vh;
+ }
+
+ .page-wrapper {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 32px 24px 64px;
+ }
+
+ /* ============ HEADER ============ */
+ .header {
+ background: linear-gradient(135deg, #0f3460 0%, #16213e 50%, #1a1a2e 100%);
+ border: 1px solid var(--border);
+ border-radius: 16px;
+ padding: 40px 48px;
+ margin-bottom: 32px;
+ position: relative;
+ overflow: hidden;
+ box-shadow: var(--shadow);
+ }
+
+ .header::before {
+ content: '';
+ position: absolute;
+ top: -60px; right: -60px;
+ width: 200px; height: 200px;
+ background: radial-gradient(circle, rgba(79,195,247,0.12) 0%, transparent 70%);
+ border-radius: 50%;
+ }
+
+ .header::after {
+ content: '';
+ position: absolute;
+ bottom: -40px; left: -40px;
+ width: 160px; height: 160px;
+ background: radial-gradient(circle, rgba(129,199,132,0.08) 0%, transparent 70%);
+ border-radius: 50%;
+ }
+
+ .header-top {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 20px;
+ position: relative;
+ z-index: 1;
+ }
+
+ .header-title {
+ font-size: 2.2rem;
+ font-weight: 700;
+ color: var(--accent-blue);
+ letter-spacing: -0.5px;
+ text-shadow: 0 0 30px rgba(79,195,247,0.3);
+ }
+
+ .header-subtitle {
+ font-size: 1.1rem;
+ color: var(--text-secondary);
+ margin-top: 6px;
+ }
+
+ .header-badges {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ align-items: flex-end;
+ }
+
+ .badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 14px;
+ border-radius: 20px;
+ font-size: 0.85rem;
+ font-weight: 600;
+ letter-spacing: 0.3px;
+ }
+
+ .badge-success {
+ background: rgba(129,199,132,0.15);
+ border: 1px solid rgba(129,199,132,0.4);
+ color: var(--accent-green);
+ }
+
+ .badge-info {
+ background: rgba(79,195,247,0.12);
+ border: 1px solid rgba(79,195,247,0.35);
+ color: var(--accent-blue);
+ }
+
+ .badge-warning {
+ background: rgba(255,183,77,0.12);
+ border: 1px solid rgba(255,183,77,0.35);
+ color: var(--accent-yellow);
+ }
+
+ .header-meta {
+ display: flex;
+ gap: 24px;
+ margin-top: 24px;
+ flex-wrap: wrap;
+ position: relative;
+ z-index: 1;
+ }
+
+ .meta-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 0.9rem;
+ color: var(--text-secondary);
+ }
+
+ .meta-label {
+ color: var(--text-muted);
+ font-size: 0.8rem;
+ text-transform: uppercase;
+ letter-spacing: 0.8px;
+ }
+
+ .meta-value {
+ color: var(--text-primary);
+ font-weight: 500;
+ font-family: 'Consolas', 'Courier New', monospace;
+ background: rgba(255,255,255,0.06);
+ padding: 2px 8px;
+ border-radius: 4px;
+ border: 1px solid rgba(255,255,255,0.08);
+ }
+
+ /* ============ SECTIONS ============ */
+ .section {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ margin-bottom: 24px;
+ overflow: hidden;
+ box-shadow: var(--shadow);
+ transition: border-color 0.2s;
+ }
+
+ .section:hover {
+ border-color: var(--border-light);
+ }
+
+ .section-header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 20px 28px;
+ background: rgba(79,195,247,0.05);
+ border-bottom: 1px solid var(--border);
+ }
+
+ .section-icon {
+ width: 36px; height: 36px;
+ background: linear-gradient(135deg, rgba(79,195,247,0.2), rgba(79,195,247,0.05));
+ border: 1px solid rgba(79,195,247,0.3);
+ border-radius: 8px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 1.1rem;
+ flex-shrink: 0;
+ }
+
+ .section-num {
+ font-size: 0.75rem;
+ font-weight: 700;
+ color: var(--accent-blue);
+ text-transform: uppercase;
+ letter-spacing: 1.5px;
+ opacity: 0.7;
+ }
+
+ .section-title {
+ font-size: 1.15rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ }
+
+ .section-body {
+ padding: 28px;
+ }
+
+ /* ============ MERMAID ============ */
+ .mermaid-wrap {
+ background: rgba(0,0,0,0.25);
+ border: 1px solid rgba(79,195,247,0.15);
+ border-radius: 10px;
+ padding: 24px;
+ overflow-x: auto;
+ }
+
+ .mermaid {
+ display: flex;
+ justify-content: center;
+ }
+
+ /* ============ TABLES ============ */
+ .table-wrap {
+ overflow-x: auto;
+ border-radius: 10px;
+ border: 1px solid var(--border);
+ }
+
+ table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.9rem;
+ }
+
+ thead tr {
+ background: linear-gradient(90deg, rgba(79,195,247,0.12), rgba(79,195,247,0.05));
+ }
+
+ thead th {
+ padding: 14px 18px;
+ text-align: left;
+ font-weight: 600;
+ font-size: 0.8rem;
+ text-transform: uppercase;
+ letter-spacing: 0.8px;
+ color: var(--accent-blue);
+ border-bottom: 1px solid var(--border);
+ white-space: nowrap;
+ }
+
+ tbody tr {
+ border-bottom: 1px solid rgba(30,95,142,0.4);
+ transition: background 0.15s;
+ }
+
+ tbody tr:last-child {
+ border-bottom: none;
+ }
+
+ tbody tr:hover {
+ background: rgba(79,195,247,0.05);
+ }
+
+ tbody td {
+ padding: 12px 18px;
+ color: var(--text-primary);
+ vertical-align: top;
+ }
+
+ .td-code {
+ font-family: 'Consolas', 'Courier New', monospace;
+ font-size: 0.85rem;
+ color: var(--accent-blue);
+ white-space: nowrap;
+ }
+
+ .td-type {
+ font-family: 'Consolas', 'Courier New', monospace;
+ font-size: 0.82rem;
+ color: var(--accent-yellow);
+ white-space: nowrap;
+ }
+
+ .td-muted {
+ color: var(--text-secondary);
+ font-size: 0.88rem;
+ }
+
+ /* ============ DQ LEVELS ============ */
+ .dq-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 3px 10px;
+ border-radius: 12px;
+ font-size: 0.78rem;
+ font-weight: 600;
+ white-space: nowrap;
+ }
+
+ .dq-critical {
+ background: rgba(239,83,80,0.15);
+ border: 1px solid rgba(239,83,80,0.4);
+ color: #ef5350;
+ }
+
+ .dq-major {
+ background: rgba(255,183,77,0.12);
+ border: 1px solid rgba(255,183,77,0.35);
+ color: #ffb74d;
+ }
+
+ .dq-warning {
+ background: rgba(255,138,101,0.12);
+ border: 1px solid rgba(255,138,101,0.35);
+ color: #ff8a65;
+ }
+
+ /* ============ CRONTAB ============ */
+ .cron-block {
+ background: rgba(0,0,0,0.3);
+ border: 1px solid rgba(79,195,247,0.15);
+ border-radius: 10px;
+ padding: 20px 24px;
+ font-family: 'Consolas', 'Courier New', monospace;
+ font-size: 0.9rem;
+ line-height: 2;
+ }
+
+ .cron-line {
+ display: flex;
+ gap: 0;
+ align-items: baseline;
+ flex-wrap: wrap;
+ padding: 4px 0;
+ border-bottom: 1px solid rgba(255,255,255,0.04);
+ transition: background 0.15s;
+ border-radius: 4px;
+ padding: 6px 8px;
+ }
+
+ .cron-line:last-child {
+ border-bottom: none;
+ }
+
+ .cron-line:hover {
+ background: rgba(79,195,247,0.05);
+ }
+
+ .cron-schedule {
+ color: var(--accent-yellow);
+ min-width: 110px;
+ flex-shrink: 0;
+ }
+
+ .cron-cmd {
+ color: var(--accent-green);
+ flex: 1;
+ padding: 0 16px;
+ }
+
+ .cron-comment {
+ color: var(--text-muted);
+ font-size: 0.82rem;
+ }
+
+ /* ============ TODO LIST ============ */
+ .todo-list {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ }
+
+ .todo-item {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ padding: 14px 18px;
+ border-radius: 8px;
+ border: 1px solid rgba(239,83,80,0.2);
+ background: rgba(239,83,80,0.05);
+ transition: background 0.15s;
+ }
+
+ .todo-item:hover {
+ background: rgba(239,83,80,0.1);
+ border-color: rgba(239,83,80,0.35);
+ }
+
+ .todo-icon {
+ font-size: 1rem;
+ flex-shrink: 0;
+ margin-top: 1px;
+ }
+
+ .todo-text {
+ color: var(--text-primary);
+ font-size: 0.92rem;
+ }
+
+ .todo-tag {
+ display: inline-block;
+ margin-left: 8px;
+ padding: 1px 8px;
+ background: rgba(79,195,247,0.12);
+ border: 1px solid rgba(79,195,247,0.25);
+ border-radius: 10px;
+ font-size: 0.75rem;
+ color: var(--accent-blue);
+ font-family: 'Consolas', 'Courier New', monospace;
+ vertical-align: middle;
+ }
+
+ /* ============ TESTS RESULT ============ */
+ .tests-result {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 16px 20px;
+ background: rgba(129,199,132,0.08);
+ border: 1px solid rgba(129,199,132,0.3);
+ border-radius: 10px;
+ margin-top: 20px;
+ }
+
+ .tests-result-icon {
+ font-size: 1.8rem;
+ }
+
+ .tests-result-text {
+ font-size: 1.1rem;
+ font-weight: 600;
+ color: var(--accent-green);
+ }
+
+ .tests-result-meta {
+ font-size: 0.85rem;
+ color: var(--text-muted);
+ margin-top: 2px;
+ }
+
+ /* ============ INDEX BADGES IN SECTION 3 ============ */
+ .indexes-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-top: 20px;
+ }
+
+ .indexes-title {
+ font-size: 0.8rem;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ color: var(--accent-blue);
+ margin-bottom: 4px;
+ font-weight: 600;
+ }
+
+ .index-item {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ padding: 10px 14px;
+ background: rgba(0,0,0,0.2);
+ border: 1px solid rgba(79,195,247,0.1);
+ border-radius: 6px;
+ font-size: 0.85rem;
+ transition: border-color 0.15s;
+ }
+
+ .index-item:hover {
+ border-color: rgba(79,195,247,0.25);
+ }
+
+ .index-dot {
+ width: 6px; height: 6px;
+ background: var(--accent-blue);
+ border-radius: 50%;
+ flex-shrink: 0;
+ margin-top: 6px;
+ opacity: 0.7;
+ }
+
+ .index-name {
+ font-family: 'Consolas', 'Courier New', monospace;
+ color: var(--accent-yellow);
+ font-size: 0.82rem;
+ white-space: nowrap;
+ flex-shrink: 0;
+ min-width: 320px;
+ }
+
+ .index-desc {
+ color: var(--text-muted);
+ font-size: 0.82rem;
+ }
+
+ .partitions-row {
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+ margin-top: 16px;
+ padding-top: 16px;
+ border-top: 1px solid rgba(255,255,255,0.06);
+ }
+
+ .partition-badge {
+ padding: 4px 12px;
+ background: rgba(129,199,132,0.1);
+ border: 1px solid rgba(129,199,132,0.25);
+ border-radius: 6px;
+ font-family: 'Consolas', 'Courier New', monospace;
+ font-size: 0.82rem;
+ color: var(--accent-green);
+ }
+
+ .partitions-label {
+ font-size: 0.8rem;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.8px;
+ align-self: center;
+ }
+
+ /* ============ SCROLLBAR ============ */
+ ::-webkit-scrollbar { width: 8px; height: 8px; }
+ ::-webkit-scrollbar-track { background: var(--bg-primary); }
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
+ ::-webkit-scrollbar-thumb:hover { background: var(--border-light); }
+
+ /* ============ RESPONSIVE ============ */
+ @media (max-width: 768px) {
+ .header { padding: 28px 24px; }
+ .header-title { font-size: 1.6rem; }
+ .header-top { flex-direction: column; }
+ .header-badges { align-items: flex-start; }
+ .section-body { padding: 20px 16px; }
+ .index-name { min-width: 0; }
+ .cron-schedule { min-width: 80px; }
+ }
+</style>
+</head>
+<body>
+<div class="page-wrapper">
+
+ <!-- ======== SECTION 1: HEADER ======== -->
+ <div class="header">
+ <div class="header-top">
+ <div>
+ <div class="header-title">ERP-33: Stock History ETL</div>
+ <div class="header-subtitle">Сервис сбора исторических данных остатков товаров</div>
+ </div>
+ <div class="header-badges">
+ <span class="badge badge-success">✓ Готово к ревью</span>
+ <span class="badge badge-success">✓ Тесты: 11/11</span>
+ <span class="badge badge-info">🕐 Code Review</span>
+ </div>
+ </div>
+ <div class="header-meta">
+ <div class="meta-item">
+ <span class="meta-label">Ветка</span>
+ <span class="meta-value">feature_filippov_ERP-33_stock_history</span>
+ </div>
+ <div class="meta-item">
+ <span class="meta-label">Задача</span>
+ <span class="meta-value">ERP-33</span>
+ </div>
+ <div class="meta-item">
+ <span class="meta-label">Дата</span>
+ <span class="meta-value">2026-02-25</span>
+ </div>
+ </div>
+ </div>
+
+ <!-- ======== SECTION 2: ARCHITECTURE ======== -->
+ <div class="section">
+ <div class="section-header">
+ <div class="section-icon">📈</div>
+ <div>
+ <div class="section-num">Секция 2</div>
+ <div class="section-title">Архитектура системы</div>
+ </div>
+ </div>
+ <div class="section-body">
+ <div class="mermaid-wrap">
+ <div class="mermaid">
+flowchart TD
+ A["⏳ Crontab\n0 8,20 * * *"] --> B["StockHistoryController\n::actionCollect()"]
+ B --> C["StockHistoryService\n::collect()"]
+ C --> D["pg_try_advisory_lock"]
+ C --> E["fetchBalances()\nbalances LEFT JOIN products_1c"]
+ E --> F["batchInsert\nBATCH_SIZE=1000\nON CONFLICT DO UPDATE"]
+ F --> G["insertChunkWithRetry\nMAX_RETRY=3\nexponential backoff"]
+ C --> H["runDqAssertions()"]
+ H --> I["DQ-1 .. DQ-6"]
+ H --> J["sendAlerts\nTelegram"]
+ C --> K["pg_advisory_unlock"]
+ C --> L["CollectResult"]
+
+ style A fill:#1a3a5c,stroke:#4fc3f7,color:#e0e0e0
+ style B fill:#0f3460,stroke:#4fc3f7,color:#e0e0e0
+ style C fill:#0f3460,stroke:#81c784,color:#e0e0e0
+ style D fill:#1a2a1a,stroke:#81c784,color:#b0bec5
+ style E fill:#1a2a1a,stroke:#81c784,color:#b0bec5
+ style F fill:#1a3a1a,stroke:#81c784,color:#e0e0e0
+ style G fill:#1a3a1a,stroke:#ffb74d,color:#e0e0e0
+ style H fill:#3a1a0f,stroke:#ffb74d,color:#e0e0e0
+ style I fill:#3a1a0f,stroke:#ef5350,color:#e0e0e0
+ style J fill:#3a1a0f,stroke:#ff8a65,color:#b0bec5
+ style K fill:#1a2a1a,stroke:#81c784,color:#b0bec5
+ style L fill:#0f3460,stroke:#4fc3f7,color:#81c784
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- ======== SECTION 3: DB TABLE ======== -->
+ <div class="section">
+ <div class="section-header">
+ <div class="section-icon">📐</div>
+ <div>
+ <div class="section-num">Секция 3</div>
+ <div class="section-title">Таблица БД: stock_history (partitioned)</div>
+ </div>
+ </div>
+ <div class="section-body">
+ <div class="table-wrap">
+ <table>
+ <thead>
+ <tr>
+ <th>Поле</th>
+ <th>Тип</th>
+ <th>Описание</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr>
+ <td class="td-code">id</td>
+ <td class="td-type">BIGSERIAL</td>
+ <td class="td-muted">PK (часть составного PK с snapshot_date)</td>
+ </tr>
+ <tr>
+ <td class="td-code">snapshot_date</td>
+ <td class="td-type">DATE NOT NULL</td>
+ <td class="td-muted">Дата среза, ключ партиционирования</td>
+ </tr>
+ <tr>
+ <td class="td-code">snapshot_time</td>
+ <td class="td-type">TIME NOT NULL</td>
+ <td class="td-muted">Время среза (08:00 / 20:00)</td>
+ </tr>
+ <tr>
+ <td class="td-code">store_id</td>
+ <td class="td-type">VARCHAR(36)</td>
+ <td class="td-muted">ID магазина</td>
+ </tr>
+ <tr>
+ <td class="td-code">store_name</td>
+ <td class="td-type">VARCHAR(255)</td>
+ <td class="td-muted">Название магазина</td>
+ </tr>
+ <tr>
+ <td class="td-code">product_id</td>
+ <td class="td-type">VARCHAR(36)</td>
+ <td class="td-muted">ID продукта</td>
+ </tr>
+ <tr>
+ <td class="td-code">product_name</td>
+ <td class="td-type">VARCHAR(255)</td>
+ <td class="td-muted">Название продукта</td>
+ </tr>
+ <tr>
+ <td class="td-code">articule</td>
+ <td class="td-type">VARCHAR(36)</td>
+ <td class="td-muted">Артикул</td>
+ </tr>
+ <tr>
+ <td class="td-code">father_id</td>
+ <td class="td-type">VARCHAR(36)</td>
+ <td class="td-muted">ID группы/родителя</td>
+ </tr>
+ <tr>
+ <td class="td-code">components</td>
+ <td class="td-type">JSONB</td>
+ <td class="td-muted">Состав букета</td>
+ </tr>
+ <tr>
+ <td class="td-code">quantity</td>
+ <td class="td-type">NUMERIC(12,2)</td>
+ <td class="td-muted">Остаток, DEFAULT 0</td>
+ </tr>
+ <tr>
+ <td class="td-code">reserv</td>
+ <td class="td-type">NUMERIC(12,2)</td>
+ <td class="td-muted">Резерв, DEFAULT 0</td>
+ </tr>
+ <tr>
+ <td class="td-code">created_at</td>
+ <td class="td-type">TIMESTAMP</td>
+ <td class="td-muted">Время записи</td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+
+ <div class="indexes-list" style="margin-top:24px;">
+ <div class="indexes-title">Индексы</div>
+ <div class="index-item">
+ <div class="index-dot"></div>
+ <div class="index-name">UNIQUE idx_stock_history_unique</div>
+ <div class="index-desc">(snapshot_date, snapshot_time, store_id, product_id) — для ON CONFLICT</div>
+ </div>
+ <div class="index-item">
+ <div class="index-dot"></div>
+ <div class="index-name">idx_stock_history_store</div>
+ <div class="index-desc">(store_id)</div>
+ </div>
+ <div class="index-item">
+ <div class="index-dot"></div>
+ <div class="index-name">idx_stock_history_product</div>
+ <div class="index-desc">(product_id)</div>
+ </div>
+ <div class="index-item">
+ <div class="index-dot"></div>
+ <div class="index-name">idx_stock_history_father</div>
+ <div class="index-desc">(father_id)</div>
+ </div>
+ <div class="index-item">
+ <div class="index-dot"></div>
+ <div class="index-name">idx_stock_history_date_time</div>
+ <div class="index-desc">(snapshot_date, snapshot_time)</div>
+ </div>
+ <div class="index-item">
+ <div class="index-dot"></div>
+ <div class="index-name">idx_stock_history_father_date</div>
+ <div class="index-desc">(father_id, snapshot_date)</div>
+ </div>
+ </div>
+
+ <div class="partitions-row">
+ <span class="partitions-label">Партиции:</span>
+ <span class="partition-badge">stock_history_2026_02</span>
+ <span class="partition-badge">stock_history_2026_03</span>
+ <span class="partition-badge">...</span>
+ </div>
+ </div>
+ </div>
+
+ <!-- ======== SECTION 4: DQ ASSERTIONS ======== -->
+ <div class="section">
+ <div class="section-header">
+ <div class="section-icon">✅</div>
+ <div>
+ <div class="section-num">Секция 4</div>
+ <div class="section-title">DQ Assertions — проверки качества данных</div>
+ </div>
+ </div>
+ <div class="section-body">
+ <div class="table-wrap">
+ <table>
+ <thead>
+ <tr>
+ <th>Код</th>
+ <th>Уровень</th>
+ <th>Проверка</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr>
+ <td class="td-code">DQ-1</td>
+ <td><span class="dq-badge dq-critical">🔴 CRITICAL</span></td>
+ <td class="td-muted">Кол-во магазинов в снимке >= активных магазинов</td>
+ </tr>
+ <tr>
+ <td class="td-code">DQ-2</td>
+ <td><span class="dq-badge dq-critical">🔴 CRITICAL</span></td>
+ <td class="td-muted">Нет NULL в обязательных полях (store_id, product_id, quantity)</td>
+ </tr>
+ <tr>
+ <td class="td-code">DQ-3</td>
+ <td><span class="dq-badge dq-major">🟡 MAJOR</span></td>
+ <td class="td-muted">Нет отрицательных quantity</td>
+ </tr>
+ <tr>
+ <td class="td-code">DQ-4</td>
+ <td><span class="dq-badge dq-warning">🟠 WARNING</span></td>
+ <td class="td-muted">reserv <= quantity</td>
+ </tr>
+ <tr>
+ <td class="td-code">DQ-5</td>
+ <td><span class="dq-badge dq-major">🟡 MAJOR</span></td>
+ <td class="td-muted">Отклонение row_count от предыдущего снимка <= 20%</td>
+ </tr>
+ <tr>
+ <td class="td-code">DQ-6</td>
+ <td><span class="dq-badge dq-critical">🔴 CRITICAL</span></td>
+ <td class="td-muted">Снимок не пустой (> 0 строк)</td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ <div style="margin-top:16px; padding:12px 16px; background:rgba(239,83,80,0.06); border:1px solid rgba(239,83,80,0.2); border-radius:8px; font-size:0.88rem; color:var(--text-secondary);">
+ <strong style="color:#ef5350;">CRITICAL</strong> и <strong style="color:#ffb74d;">MAJOR</strong> — автоматический Telegram alert при срабатывании.
+ </div>
+ </div>
+ </div>
+
+ <!-- ======== SECTION 5: CRONTAB ======== -->
+ <div class="section">
+ <div class="section-header">
+ <div class="section-icon">⏱</div>
+ <div>
+ <div class="section-num">Секция 5</div>
+ <div class="section-title">Расписание (Crontab)</div>
+ </div>
+ </div>
+ <div class="section-body">
+ <div class="cron-block">
+ <div class="cron-line">
+ <span class="cron-schedule">0 8,20 * * *</span>
+ <span class="cron-cmd">stock-history/collect</span>
+ <span class="cron-comment"># Сбор остатков (08:00 и 20:00)</span>
+ </div>
+ <div class="cron-line">
+ <span class="cron-schedule">0 1 1 * *</span>
+ <span class="cron-cmd">stock-history/create-partition</span>
+ <span class="cron-comment"># Создание партиции следующего месяца</span>
+ </div>
+ <div class="cron-line">
+ <span class="cron-schedule">0 2 1 * *</span>
+ <span class="cron-cmd">stock-history/drop-old-partitions --months=24</span>
+ <span class="cron-comment"># Удаление старых партиций</span>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- ======== SECTION 6: TESTS ======== -->
+ <div class="section">
+ <div class="section-header">
+ <div class="section-icon">🧪</div>
+ <div>
+ <div class="section-num">Секция 6</div>
+ <div class="section-title">Покрытие тестами</div>
+ </div>
+ </div>
+ <div class="section-body">
+ <div class="table-wrap">
+ <table>
+ <thead>
+ <tr>
+ <th>Тест</th>
+ <th>Метод</th>
+ <th>Что проверяет</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr>
+ <td class="td-code">testCollect_Success</td>
+ <td class="td-type">collect()</td>
+ <td class="td-muted">Полный цикл: lock → SELECT → batch insert → DQ → unlock</td>
+ </tr>
+ <tr>
+ <td class="td-code">testCollect_LockTimeout</td>
+ <td class="td-type">collect()</td>
+ <td class="td-muted">Advisory lock таймаут → RuntimeException</td>
+ </tr>
+ <tr>
+ <td class="td-code">testCollect_EmptyBalances</td>
+ <td class="td-type">collect()</td>
+ <td class="td-muted">Пустой результат → 0 строк</td>
+ </tr>
+ <tr>
+ <td class="td-code">testCollect_OnConflictUpdate</td>
+ <td class="td-type">collect()</td>
+ <td class="td-muted">SQL содержит ON CONFLICT DO UPDATE</td>
+ </tr>
+ <tr>
+ <td class="td-code">testDqAssertions_AllPass</td>
+ <td class="td-type">runDqAssertions()</td>
+ <td class="td-muted">Все DQ проходят → allPassed() = true</td>
+ </tr>
+ <tr>
+ <td class="td-code">testDqAssertions_MissingStores</td>
+ <td class="td-type">runDqAssertions()</td>
+ <td class="td-muted">DQ-1 fail → CRITICAL</td>
+ </tr>
+ <tr>
+ <td class="td-code">testDqAssertions_NegativeQuantity</td>
+ <td class="td-type">runDqAssertions()</td>
+ <td class="td-muted">DQ-3 fail → MAJOR</td>
+ </tr>
+ <tr>
+ <td class="td-code">testDqAssertions_DeviationOver20Pct</td>
+ <td class="td-type">runDqAssertions()</td>
+ <td class="td-muted">DQ-5 fail → MAJOR</td>
+ </tr>
+ <tr>
+ <td class="td-code">testDqAssertions_EmptySnapshot</td>
+ <td class="td-type">runDqAssertions()</td>
+ <td class="td-muted">DQ-6 fail → CRITICAL</td>
+ </tr>
+ <tr>
+ <td class="td-code">testCreatePartition</td>
+ <td class="td-type">createPartition()</td>
+ <td class="td-muted">SQL содержит PARTITION OF, правильные даты</td>
+ </tr>
+ <tr>
+ <td class="td-code">testDropOldPartitions</td>
+ <td class="td-type">dropOldPartitions()</td>
+ <td class="td-muted">Удаляет только старые партиции (2023_11, 2023_12)</td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ <div class="tests-result">
+ <div class="tests-result-icon">✔</div>
+ <div>
+ <div class="tests-result-text">11/11 тестов пройдено</div>
+ <div class="tests-result-meta">0.573s · 16MB · Codeception Unit + PHPUnit mocks</div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- ======== SECTION 7: TODO ======== -->
+ <div class="section">
+ <div class="section-header">
+ <div class="section-icon">📋</div>
+ <div>
+ <div class="section-num">Секция 7</div>
+ <div class="section-title">Что не реализовано (TODO — Step 6)</div>
+ </div>
+ </div>
+ <div class="section-body">
+ <div class="todo-list">
+ <div class="todo-item">
+ <div class="todo-icon">❌</div>
+ <div class="todo-text">
+ Crontab не прописан в scheduler — нужно добавить в конфиг
+ </div>
+ </div>
+ <div class="todo-item">
+ <div class="todo-icon">❌</div>
+ <div class="todo-text">
+ Логирование в scheduler_task_log (start/end/row_count)
+ <span class="todo-tag">ERP-56</span>
+ </div>
+ </div>
+ <div class="todo-item">
+ <div class="todo-icon">❌</div>
+ <div class="todo-text">
+ VACUUM/ANALYZE настройка для stock_history
+ <span class="todo-tag">ERP-52</span>
+ </div>
+ </div>
+ <div class="todo-item">
+ <div class="todo-icon">❌</div>
+ <div class="todo-text">
+ Миграция не применена на dev — нужно запустить:
+ <code style="font-family:monospace; font-size:0.85rem; color:var(--accent-yellow); background:rgba(0,0,0,0.3); padding:2px 8px; border-radius:4px; margin-left:6px;">php yii migrate</code>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+</div>
+
+<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
+<script>
+ mermaid.initialize({
+ startOnLoad: true,
+ theme: 'dark',
+ flowchart: {
+ curve: 'basis',
+ padding: 20
+ },
+ themeVariables: {
+ primaryColor: '#0f3460',
+ primaryTextColor: '#e0e0e0',
+ primaryBorderColor: '#4fc3f7',
+ lineColor: '#4fc3f7',
+ secondaryColor: '#16213e',
+ tertiaryColor: '#1a1a2e',
+ background: '#1a1a2e',
+ mainBkg: '#0f3460',
+ nodeBorder: '#4fc3f7',
+ clusterBkg: '#16213e',
+ titleColor: '#e0e0e0',
+ edgeLabelBackground: '#1a1a2e',
+ attributeBackgroundColorEven: '#16213e',
+ attributeBackgroundColorOdd: '#1a1a2e'
+ }
+ });
+</script>
+</body>
+</html>