From 1e99966ea062d8e868121ee13a9d56f16f49e1a5 Mon Sep 17 00:00:00 2001 From: Auto-Claude Orchestrator Date: Tue, 24 Mar 2026 07:18:45 +0000 Subject: [PATCH] =?utf8?q?feat(task-JIRA-ERP-33-20260324070448):=20=D0=A1?= =?utf8?q?=D0=B1=D0=BE=D1=80=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20?= =?utf8?q?=D0=BF=D0=BE=20=D0=BE=D1=81=D1=82=D0=B0=D1=82=D0=BA=D0=B0=D0=BC?= =?utf8?q?=20=D0=BD=D0=B0=20=D0=B4=D0=B5=D0=BD=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit --- erp24/commands/StockHistoryController.php | 18 +++++ erp24/services/StockHistoryService.php | 45 +++++++++-- .../unit/services/StockHistoryServiceTest.php | 54 ++++++++++++- specs/JIRA-ERP-33-20260324070448.md | 81 +++++++++++++++++++ 4 files changed, 190 insertions(+), 8 deletions(-) create mode 100644 specs/JIRA-ERP-33-20260324070448.md diff --git a/erp24/commands/StockHistoryController.php b/erp24/commands/StockHistoryController.php index 5f398644..c0ec9ef6 100644 --- a/erp24/commands/StockHistoryController.php +++ b/erp24/commands/StockHistoryController.php @@ -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). diff --git a/erp24/services/StockHistoryService.php b/erp24/services/StockHistoryService.php index 6e5b9dde..e8fc063e 100644 --- a/erp24/services/StockHistoryService.php +++ b/erp24/services/StockHistoryService.php @@ -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(); } diff --git a/erp24/tests/unit/services/StockHistoryServiceTest.php b/erp24/tests/unit/services/StockHistoryServiceTest.php index 89f963ac..2ffdfaf3 100644 --- a/erp24/tests/unit/services/StockHistoryServiceTest.php +++ b/erp24/tests/unit/services/StockHistoryServiceTest.php @@ -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 index 00000000..079376c6 --- /dev/null +++ b/specs/JIRA-ERP-33-20260324070448.md @@ -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 команда) -- 2.39.5