]> gitweb.erp-flowers.ru Git - erp24_rep/yii-erp24/.git/commitdiff
docs(ERP-33): add visual review diagram for stock history ETL
authorAleksey Filippov <Aleksey.Filippov@erp-flowers.ru>
Wed, 25 Feb 2026 15:42:03 +0000 (18:42 +0300)
committerAleksey Filippov <Aleksey.Filippov@erp-flowers.ru>
Wed, 25 Feb 2026 15:42:03 +0000 (18:42 +0300)
Покрытие: архитектура, схема БД, DQ assertions, тесты, crontab, TODO.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
docs/diagrams/erp-33-stock-history-review.html [new file with mode: 0644]

diff --git a/docs/diagrams/erp-33-stock-history-review.html b/docs/diagrams/erp-33-stock-history-review.html
new file mode 100644 (file)
index 0000000..5406f5c
--- /dev/null
@@ -0,0 +1,1011 @@
+<!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">&#10003; Готово к ревью</span>
+        <span class="badge badge-success">&#10003; Тесты: 11/11</span>
+        <span class="badge badge-info">&#128336; 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">&#128200;</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["&#9203; 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">&#128208;</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">&#9989;</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">&#128308; CRITICAL</span></td>
+              <td class="td-muted">Кол-во магазинов в снимке >= активных магазинов</td>
+            </tr>
+            <tr>
+              <td class="td-code">DQ-2</td>
+              <td><span class="dq-badge dq-critical">&#128308; 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">&#128993; 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">&#128992; WARNING</span></td>
+              <td class="td-muted">reserv &lt;= quantity</td>
+            </tr>
+            <tr>
+              <td class="td-code">DQ-5</td>
+              <td><span class="dq-badge dq-major">&#128993; MAJOR</span></td>
+              <td class="td-muted">Отклонение row_count от предыдущего снимка &lt;= 20%</td>
+            </tr>
+            <tr>
+              <td class="td-code">DQ-6</td>
+              <td><span class="dq-badge dq-critical">&#128308; CRITICAL</span></td>
+              <td class="td-muted">Снимок не пустой (&gt; 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">&#9201;</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">&#129514;</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">&#10004;</div>
+        <div>
+          <div class="tests-result-text">11/11 тестов пройдено</div>
+          <div class="tests-result-meta">0.573s &nbsp;·&nbsp; 16MB &nbsp;·&nbsp; Codeception Unit + PHPUnit mocks</div>
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <!-- ======== SECTION 7: TODO ======== -->
+  <div class="section">
+    <div class="section-header">
+      <div class="section-icon">&#128203;</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">&#10060;</div>
+          <div class="todo-text">
+            Crontab не прописан в scheduler — нужно добавить в конфиг
+          </div>
+        </div>
+        <div class="todo-item">
+          <div class="todo-icon">&#10060;</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">&#10060;</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">&#10060;</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>