]> gitweb.erp-flowers.ru Git - erp24_rep/yii-erp24/.git/commitdiff
feat(task-JIRA-ERP-33-20260324070448): Сбор данных по остаткам на день
authorAuto-Claude Orchestrator <orchestrator@auto-claude.local>
Tue, 24 Mar 2026 07:18:45 +0000 (07:18 +0000)
committerAuto-Claude Orchestrator <orchestrator@auto-claude.local>
Tue, 24 Mar 2026 07:18:45 +0000 (07:18 +0000)
erp24/commands/StockHistoryController.php
erp24/services/StockHistoryService.php
erp24/tests/unit/services/StockHistoryServiceTest.php
specs/JIRA-ERP-33-20260324070448.md [new file with mode: 0644]

index 5f3986446cd4f3026b06db2b7b090362b7f1c24c..c0ec9ef69f9da1f01e27304462b17c5af4de4ade 100644 (file)
@@ -14,6 +14,7 @@ use Yii;
  *
  * Crontab:
  *   0 8,20 * * * cd /www && php yii stock-history/collect >> /var/log/stock-history.log 2>&1
+ *   0 0 1 * * cd /www && php yii stock-history/ensure-partitions >> /var/log/stock-history.log 2>&1
  *   0 1 1 * * cd /www && php yii stock-history/create-partition >> /var/log/stock-history.log 2>&1
  *   0 2 1 * * cd /www && php yii stock-history/drop-old-partitions >> /var/log/stock-history.log 2>&1
  */
@@ -86,6 +87,23 @@ class StockHistoryController extends Controller
         }
     }
 
+    /**
+     * Обеспечить наличие партиций для текущего и следующего месяца.
+     */
+    public function actionEnsurePartitions(): int
+    {
+        $this->stdout("Ensuring partitions for current and next month...\n");
+
+        try {
+            $this->createService()->ensurePartitions();
+            $this->stdout("Partitions ensured.\n");
+            return ExitCode::OK;
+        } catch (\Throwable $e) {
+            $this->stderr("ERROR: {$e->getMessage()}\n");
+            return ExitCode::SOFTWARE;
+        }
+    }
+
     /**
      * Удалить старые партиции.
      * @param int $months Retention в месяцах (default 24).
index 6e5b9dde45b5f9dca6736260ce4266cdc3f71a11..e8fc063e4281b3080e23bbed8c789abd1edfff2e 100644 (file)
@@ -40,22 +40,29 @@ class StockHistoryService
      */
     public function collect(string $snapshotTime): CollectResult
     {
+        if (!preg_match('/^\d{2}:\d{2}$/', $snapshotTime)) {
+            throw new \InvalidArgumentException("Invalid snapshotTime format: {$snapshotTime}, expected HH:MM");
+        }
+
         $snapshotDate = date('Y-m-d');
 
         // 1. Advisory lock
         $this->acquireLock();
 
         try {
-            // 2. SELECT данные
+            // 2. Обеспечить наличие партиций
+            $this->ensurePartitions();
+
+            // 3. SELECT данные
             $rows = $this->fetchBalances();
 
-            // 3. Batch INSERT
+            // 4. Batch INSERT
             $insertedCount = $this->batchInsert($rows, $snapshotDate, $snapshotTime);
 
-            // 4. DQ assertions
+            // 5. DQ assertions
             $dqResult = $this->runDqAssertions($snapshotDate, $snapshotTime);
 
-            // 5. Log
+            // 6. Log
             $this->logResult($snapshotDate, $snapshotTime, $insertedCount, $dqResult);
 
             return new CollectResult(true, $insertedCount, $dqResult);
@@ -138,7 +145,16 @@ class StockHistoryService
             ->queryScalar();
 
         $previousCount = (int)$this->db->createCommand()
-            ->setSql("SELECT COUNT(*) FROM stock_history WHERE (snapshot_date, snapshot_time) < (:date, :time) ORDER BY snapshot_date DESC, snapshot_time DESC LIMIT 1")
+            ->setSql("
+                SELECT COUNT(*) FROM stock_history
+                WHERE (snapshot_date, snapshot_time) = (
+                    SELECT snapshot_date, snapshot_time FROM stock_history
+                    WHERE (snapshot_date, snapshot_time) < (:date, :time)
+                    GROUP BY snapshot_date, snapshot_time
+                    ORDER BY snapshot_date DESC, snapshot_time DESC
+                    LIMIT 1
+                )
+            ")
             ->bindValues([':date' => $date, ':time' => $time])
             ->queryScalar();
 
@@ -171,17 +187,34 @@ class StockHistoryService
         return $dqResult;
     }
 
+    /**
+     * Обеспечить наличие партиций для текущего и следующего месяца.
+     */
+    public function ensurePartitions(): void
+    {
+        $currentMonth = date('Y-m');
+        $nextMonth = date('Y-m', strtotime('+1 month'));
+
+        $this->createPartition($currentMonth);
+        $this->createPartition($nextMonth);
+    }
+
     /**
      * Создание партиции на указанный месяц.
      */
     public function createPartition(string $yearMonth): void
     {
+        if (!preg_match('/^\d{4}-\d{2}$/', $yearMonth)) {
+            throw new \InvalidArgumentException("Invalid yearMonth format: {$yearMonth}, expected YYYY-MM");
+        }
+
         $start = $yearMonth . '-01';
         $end = date('Y-m-d', strtotime($start . ' +1 month'));
         $suffix = str_replace('-', '_', $yearMonth);
 
         $this->db->createCommand()
-            ->setSql("CREATE TABLE IF NOT EXISTS stock_history_{$suffix} PARTITION OF stock_history FOR VALUES FROM ('{$start}') TO ('{$end}')")
+            ->setSql("CREATE TABLE IF NOT EXISTS stock_history_{$suffix} PARTITION OF stock_history FOR VALUES FROM (:start) TO (:end)")
+            ->bindValues([':start' => $start, ':end' => $end])
             ->execute();
     }
 
index 89f963ac9e3ee4d2fdd34f04d5a1ce38d10aaffd..2ffdfaf3d9e56ab15c9887a5926f0b8dffa2e8ed 100644 (file)
@@ -89,6 +89,17 @@ class StockHistoryServiceTest extends Unit
         $service->collect('08:00');
     }
 
+    public function testCollect_InvalidTimeFormat_ThrowsException(): void
+    {
+        [$db, $command] = $this->createMockDbAndCommand();
+
+        $this->expectException(\InvalidArgumentException::class);
+        $this->expectExceptionMessage('snapshotTime');
+
+        $service = new StockHistoryService($db);
+        $service->collect('invalid');
+    }
+
     public function testCollect_EmptyBalances_ReturnsZeroRows(): void
     {
         [$db, $command] = $this->createMockDbAndCommand();
@@ -226,12 +237,19 @@ class StockHistoryServiceTest extends Unit
         [$db, $command] = $this->createMockDbAndCommand();
 
         $executedSql = [];
+        $boundParams = [];
         $command->method('setSql')->willReturnCallback(
             function ($sql) use ($command, &$executedSql) {
                 $executedSql[] = $sql;
                 return $command;
             }
         );
+        $command->method('bindValues')->willReturnCallback(
+            function ($values) use ($command, &$boundParams) {
+                $boundParams = array_merge($boundParams, $values);
+                return $command;
+            }
+        );
         $command->method('execute')->willReturn(0);
 
         $service = new StockHistoryService($db);
@@ -239,8 +257,40 @@ class StockHistoryServiceTest extends Unit
 
         $sql = implode(' ', $executedSql);
         $this->assertStringContainsString('PARTITION OF', $sql);
-        $this->assertStringContainsString('2026-04-01', $sql);
-        $this->assertStringContainsString('2026-05-01', $sql);
+        $this->assertStringContainsString('stock_history_2026_04', $sql);
+        $this->assertEquals('2026-04-01', $boundParams[':start']);
+        $this->assertEquals('2026-05-01', $boundParams[':end']);
+    }
+
+    public function testCreatePartition_InvalidFormat_ThrowsException(): void
+    {
+        [$db, $command] = $this->createMockDbAndCommand();
+
+        $this->expectException(\InvalidArgumentException::class);
+
+        $service = new StockHistoryService($db);
+        $service->createPartition('invalid');
+    }
+
+    public function testEnsurePartitions_CreatesCurrentAndNextMonth(): void
+    {
+        [$db, $command] = $this->createMockDbAndCommand();
+
+        $executedSql = [];
+        $command->method('setSql')->willReturnCallback(
+            function ($sql) use ($command, &$executedSql) {
+                $executedSql[] = $sql;
+                return $command;
+            }
+        );
+        $command->method('bindValues')->willReturnSelf();
+        $command->method('execute')->willReturn(0);
+
+        $service = new StockHistoryService($db);
+        $service->ensurePartitions();
+
+        $partitionSql = array_filter($executedSql, fn($s) => stripos($s, 'PARTITION OF') !== false);
+        $this->assertCount(2, $partitionSql, 'Должны быть созданы 2 партиции (текущий и следующий месяц)');
     }
 
     public function testDropOldPartitions_DropsPartitionsOlderThanRetention(): void
diff --git a/specs/JIRA-ERP-33-20260324070448.md b/specs/JIRA-ERP-33-20260324070448.md
new file mode 100644 (file)
index 0000000..079376c
--- /dev/null
@@ -0,0 +1,81 @@
+# JIRA-ERP-33: Сбор данных по остаткам на день
+
+## Requirements
+
+Автоматическое формирование исторических срезов остатков товаров по магазинам 2 раза в сутки (08:00 и 20:00 МСК).
+
+### Функциональные требования
+- ETL-процесс: SELECT из `balances` + `products_1c` → INSERT в `stock_history`
+- Partitioned таблица `stock_history` (RANGE по `snapshot_date`)
+- Idempotent upsert через `ON CONFLICT DO UPDATE`
+- Advisory lock для предотвращения параллельного запуска
+- Batch INSERT по 1000 строк с retry (exponential backoff)
+- 6 DQ assertions (stores count, NULL checks, negative qty, reserv > qty, deviation, empty snapshot)
+- Telegram-алерты при CRITICAL/MAJOR DQ failures
+- Автоматическое создание/удаление партиций (retention 24 месяца)
+- Console команда `stock-history/collect` с опцией `--time`
+
+### Нефункциональные требования
+- Время выполнения job < 10 минут
+- Все 24 магазина в каждом срезе
+- 2 года hot storage
+
+## Implementation
+
+### Существующие файлы (уже реализованы)
+
+| Файл | Статус | Описание |
+|------|--------|----------|
+| `erp24/migrations/m260221_100000_create_stock_history_table.php` | Done | Partitioned таблица + индексы |
+| `erp24/records/StockHistory.php` | Done | ActiveRecord модель |
+| `erp24/services/StockHistoryService.php` | Done | ETL-сервис (collect, DQ, partitions) |
+| `erp24/services/CollectResult.php` | Done | DTO результата сбора |
+| `erp24/services/DqResult.php` | Done | DTO результата DQ |
+| `erp24/commands/StockHistoryController.php` | Done | Console команды |
+| `erp24/tests/unit/services/StockHistoryServiceTest.php` | Done | 11 unit-тестов |
+
+### Архитектура
+
+```
+cron (08:00, 20:00 МСК)
+  → StockHistoryController::actionCollect()
+    → StockHistoryService::collect(snapshotTime)
+      1. pg_try_advisory_lock (предотвращение дублей)
+      2. SELECT balances LEFT JOIN products_1c
+      3. Batch INSERT ON CONFLICT DO UPDATE (1000 строк, retry x3)
+      4. DQ assertions (6 проверок)
+      5. Telegram-алерты при CRITICAL/MAJOR
+      6. pg_advisory_unlock
+```
+
+### Подзадачи
+
+#### Subtask 1: Запуск и верификация тестов
+- Запустить существующие 11 unit-тестов через `docker exec yii_erp24-php-yii_erp24-1 vendor/bin/codecept run unit --no-ansi`
+- Убедиться что все тесты проходят
+- Исправить найденные ошибки
+
+#### Subtask 2: Code review и доработка
+- Проверить SQL-запрос DQ-5 (`previousCount`) — подзапрос с `ORDER BY` + `LIMIT` внутри скалярного `SELECT COUNT(*)` некорректен, нужен fix
+- Проверить корректность `createPartition()` — SQL injection через string interpolation `$suffix`
+- Убедиться что `SendTelegramMessageJob` существует в проекте
+- Валидация `$snapshotTime` формата в `collect()`
+
+#### Subtask 3: Партиция на текущий месяц (2026-03)
+- Миграция создаёт партиции только для 2026-02 и 2026-03
+- Добавить `actionCreatePartition` в cron или создать миграцию для автоматического создания будущих партиций
+- Проверить что партиция на текущий месяц (2026-03) существует
+
+## Acceptance Criteria
+
+- [ ] Таблица `stock_history` создана со всеми полями и индексами (partitioned by RANGE snapshot_date)
+- [ ] Console команда `php yii stock-history/collect` выполняется без ошибок
+- [ ] Scheduler запускается в 08:00 и 20:00 МСК (cron конфигурация)
+- [ ] Все 24 магазина присутствуют в каждом срезе (DQ-1 assertion)
+- [ ] Data Quality checks (6 assertions) проходят без ошибок
+- [ ] Время выполнения job < 10 мин
+- [ ] Все 14 unit-тестов проходят (добавлены 3 теста: валидация + ensurePartitions)
+- [x] SQL-запрос DQ-5 для `previousCount` исправлен (subquery вместо COUNT + ORDER BY)
+- [x] Input validation для `collect()` и `createPartition()` добавлена
+- [x] SQL injection в `createPartition()` устранена (parameterized values)
+- [x] Партиции автоматически создаются (ensurePartitions в collect + actionEnsurePartitions команда)