--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace tests\unit\services;
+
+use Codeception\Test\Unit;
+use app\services\StockHistoryService;
+
+/**
+ * ERP-33: Тесты StockHistoryService — ETL для сбора остатков.
+ *
+ * @group services
+ * @group stock-history
+ * @group etl
+ */
+class StockHistoryServiceTest extends Unit
+{
+ protected $tester;
+
+ /**
+ * @return array{0: \yii\db\Connection, 1: \yii\db\Command}
+ */
+ private function createMockDbAndCommand(): array
+ {
+ $db = $this->createMock(\yii\db\Connection::class);
+ $command = $this->createMock(\yii\db\Command::class);
+
+ $db->method('createCommand')->willReturn($command);
+ $command->method('setSql')->willReturnSelf();
+ $command->method('bindValues')->willReturnSelf();
+ $command->method('bindValue')->willReturnSelf();
+
+ return [$db, $command];
+ }
+
+ // =========================================================
+ // collect() tests
+ // =========================================================
+
+ public function testCollect_Success_InsertsRecordsAndReturnsResult(): void
+ {
+ [$db, $command] = $this->createMockDbAndCommand();
+
+ $command->method('queryScalar')
+ ->willReturnOnConsecutiveCalls(
+ true, // pg_try_advisory_lock
+ 5, // active stores (DQ-1)
+ 10, // current count (DQ-6 + DQ-5)
+ 5, // distinct stores in snapshot (DQ-1)
+ 0, // null required (DQ-2)
+ 0, // negative qty (DQ-3)
+ 0, // reserv > qty (DQ-4)
+ 10, // previous count (DQ-5)
+ true // pg_advisory_unlock
+ );
+
+ $command->method('queryAll')->willReturn(
+ array_fill(0, 10, [
+ 'store_id' => 'store-1', 'store_name' => 'Store',
+ 'product_id' => 'prod-1', 'product_name' => 'Rose',
+ 'articule' => 'ART-001', 'father_id' => null,
+ 'components' => null, 'quantity' => 5.00, 'reserv' => 1.00,
+ ])
+ );
+ $command->method('execute')->willReturn(10);
+
+ $service = new StockHistoryService($db);
+ $result = $service->collect('08:00');
+
+ $this->assertTrue($result->isSuccess());
+ $this->assertEquals(10, $result->getRowCount());
+ }
+
+ public function testCollect_LockTimeout_ThrowsLockException(): void
+ {
+ [$db, $command] = $this->createMockDbAndCommand();
+
+ // Lock always fails
+ $command->method('queryScalar')->willReturn(false);
+
+ $this->expectException(\RuntimeException::class);
+ $this->expectExceptionMessage('advisory lock');
+
+ // Анонимный класс с timeout=1 сек для быстрого теста
+ $service = new class($db) extends StockHistoryService {
+ protected function getLockTimeoutSec(): int { return 1; }
+ };
+ $service->collect('08:00');
+ }
+
+ public function testCollect_EmptyBalances_ReturnsZeroRows(): void
+ {
+ [$db, $command] = $this->createMockDbAndCommand();
+
+ $command->method('queryScalar')
+ ->willReturnOnConsecutiveCalls(
+ true, 0, 0, 0, 0, 0, 0, 0, true
+ );
+ $command->method('queryAll')->willReturn([]);
+ $command->method('execute')->willReturn(0);
+
+ $service = new StockHistoryService($db);
+ $result = $service->collect('08:00');
+
+ $this->assertTrue($result->isSuccess());
+ $this->assertEquals(0, $result->getRowCount());
+ }
+
+ public function testCollect_OnConflictUpdate_UsesUpsertQuery(): void
+ {
+ [$db, $command] = $this->createMockDbAndCommand();
+
+ $command->method('queryScalar')
+ ->willReturnOnConsecutiveCalls(true, 1, 1, 1, 0, 0, 0, 1, true);
+
+ $executedSql = [];
+ $command->method('setSql')->willReturnCallback(
+ function ($sql) use ($command, &$executedSql) {
+ $executedSql[] = $sql;
+ return $command;
+ }
+ );
+ $command->method('queryAll')->willReturn([
+ ['store_id' => 's1', 'store_name' => 'S', 'product_id' => 'p1',
+ 'product_name' => 'P', 'articule' => 'A', 'father_id' => null,
+ 'components' => null, 'quantity' => 1, 'reserv' => 0],
+ ]);
+ $command->method('execute')->willReturn(1);
+
+ $service = new StockHistoryService($db);
+ $service->collect('08:00');
+
+ $hasOnConflict = false;
+ foreach ($executedSql as $sql) {
+ if (stripos($sql, 'ON CONFLICT') !== false) {
+ $hasOnConflict = true;
+ }
+ }
+ $this->assertTrue($hasOnConflict, 'SQL должен содержать ON CONFLICT DO UPDATE');
+ }
+
+ // =========================================================
+ // DQ Assertions tests
+ // =========================================================
+
+ public function testDqAssertions_AllPass_ReturnsSuccess(): void
+ {
+ [$db, $command] = $this->createMockDbAndCommand();
+
+ $command->method('queryScalar')
+ ->willReturnOnConsecutiveCalls(5, 5, 0, 0, 0, 100, 95);
+
+ $service = new StockHistoryService($db);
+ $result = $service->runDqAssertions('2026-02-21', '08:00');
+
+ $this->assertTrue($result->allPassed());
+ $this->assertEmpty($result->getCriticalFailures());
+ }
+
+ public function testDqAssertions_MissingStores_ReturnsCritical(): void
+ {
+ [$db, $command] = $this->createMockDbAndCommand();
+
+ $command->method('queryScalar')
+ ->willReturnOnConsecutiveCalls(24, 20, 0, 0, 0, 100, 100);
+
+ $service = new StockHistoryService($db);
+ $result = $service->runDqAssertions('2026-02-21', '08:00');
+
+ $this->assertFalse($result->allPassed());
+ $criticals = $result->getCriticalFailures();
+ $this->assertNotEmpty($criticals);
+ $this->assertStringContainsString('store', strtolower($criticals[0]['message']));
+ }
+
+ public function testDqAssertions_NegativeQuantity_ReturnsMajor(): void
+ {
+ [$db, $command] = $this->createMockDbAndCommand();
+
+ $command->method('queryScalar')
+ ->willReturnOnConsecutiveCalls(5, 5, 0, 3, 0, 100, 100);
+
+ $service = new StockHistoryService($db);
+ $result = $service->runDqAssertions('2026-02-21', '08:00');
+
+ $majors = $result->getMajorFailures();
+ $this->assertNotEmpty($majors);
+ }
+
+ public function testDqAssertions_DeviationOver20Pct_ReturnsMajor(): void
+ {
+ [$db, $command] = $this->createMockDbAndCommand();
+
+ $command->method('queryScalar')
+ ->willReturnOnConsecutiveCalls(5, 5, 0, 0, 0, 100, 50);
+
+ $service = new StockHistoryService($db);
+ $result = $service->runDqAssertions('2026-02-21', '08:00');
+
+ $majors = $result->getMajorFailures();
+ $this->assertNotEmpty($majors);
+ }
+
+ public function testDqAssertions_EmptySnapshot_ReturnsCritical(): void
+ {
+ [$db, $command] = $this->createMockDbAndCommand();
+
+ $command->method('queryScalar')
+ ->willReturnOnConsecutiveCalls(5, 0, 0, 0, 0, 0, 100);
+
+ $service = new StockHistoryService($db);
+ $result = $service->runDqAssertions('2026-02-21', '08:00');
+
+ $this->assertFalse($result->allPassed());
+ $criticals = $result->getCriticalFailures();
+ $this->assertNotEmpty($criticals);
+ }
+
+ // =========================================================
+ // Partition management tests
+ // =========================================================
+
+ public function testCreatePartition_CreatesPartitionTable(): void
+ {
+ [$db, $command] = $this->createMockDbAndCommand();
+
+ $executedSql = [];
+ $command->method('setSql')->willReturnCallback(
+ function ($sql) use ($command, &$executedSql) {
+ $executedSql[] = $sql;
+ return $command;
+ }
+ );
+ $command->method('execute')->willReturn(0);
+
+ $service = new StockHistoryService($db);
+ $service->createPartition('2026-04');
+
+ $sql = implode(' ', $executedSql);
+ $this->assertStringContainsString('PARTITION OF', $sql);
+ $this->assertStringContainsString('2026-04-01', $sql);
+ $this->assertStringContainsString('2026-05-01', $sql);
+ }
+
+ public function testDropOldPartitions_DropsPartitionsOlderThanRetention(): void
+ {
+ [$db, $command] = $this->createMockDbAndCommand();
+
+ $command->method('queryAll')->willReturn([
+ ['tablename' => 'stock_history_2023_11'],
+ ['tablename' => 'stock_history_2023_12'],
+ ['tablename' => 'stock_history_2026_01'],
+ ]);
+
+ $droppedSql = [];
+ $command->method('setSql')->willReturnCallback(
+ function ($sql) use ($command, &$droppedSql) {
+ if (stripos($sql, 'DROP TABLE') !== false) {
+ $droppedSql[] = $sql;
+ }
+ return $command;
+ }
+ );
+ $command->method('execute')->willReturn(0);
+
+ $service = new StockHistoryService($db);
+ $service->dropOldPartitions(24);
+
+ $this->assertCount(2, $droppedSql, 'Должны быть удалены 2 старые партиции (2023_11 и 2023_12)');
+ }
+}