*
* 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
*/
}
}
+ /**
+ * Обеспечить наличие партиций для текущего и следующего месяца.
+ */
+ 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).
*/
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);
->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();
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();
}
$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();
[$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);
$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
--- /dev/null
+# 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 команда)