]> gitweb.erp-flowers.ru Git - erp24_rep/yii-erp24/.git/commitdiff
feat(ERP-218-J): интеграция BatchSyncService и ProductPropTypeCacheService в UploadSe... feature_filippov_ERP-218-J_fix_db_long_request origin/feature_filippov_ERP-218-J_fix_db_long_request
authorAleksey Filippov <Aleksey.Filippov@erp-flowers.ru>
Thu, 19 Mar 2026 13:11:17 +0000 (16:11 +0300)
committerAleksey Filippov <Aleksey.Filippov@erp-flowers.ru>
Thu, 19 Mar 2026 13:11:17 +0000 (16:11 +0300)
- Исправлена миграция: indexExists() через pg_indexes вместо $schema->indexes
- ProductPropTypeCacheService: null-safe обращение к Yii::$app->cache
- UploadService: Products1cPropType::findOne() заменён на ProductPropTypeCacheService (10M→1 запрос)
- UploadService: balances INSERT в цикле заменён на BatchSyncService::upsertBalances (6M→2k запросов)
- UploadService: characteristics INSERT/UPDATE заменён на BatchSyncService::upsertProductCharacteristics (11M→1.2k)
- 20 unit-тестов: BatchSyncServiceTest (9) + ProductPropTypeCacheServiceTest (11)

erp24/migrations/m260210_120000_add_performance_indexes_for_1c_sync.php
erp24/services/ProductPropTypeCacheService.php
erp24/services/UploadService.php
erp24/tests/unit/services/BatchSyncServiceTest.php [new file with mode: 0644]
erp24/tests/unit/services/ProductPropTypeCacheServiceTest.php [new file with mode: 0644]

index 9c9643d3987335a7910d08d4181226eaf8ed1848..9c6a7901f2e05a9f58277107eea3f3cb71ef03b0 100644 (file)
@@ -88,7 +88,7 @@ class m260210_120000_add_performance_indexes_for_1c_sync extends Migration
     }
 
     /**
-     * Проверка существования индекса.
+     * Проверка существования индекса через pg_indexes.
      *
      * @param string $table Название таблицы
      * @param string|array $columns Колонка(и)
@@ -97,20 +97,24 @@ class m260210_120000_add_performance_indexes_for_1c_sync extends Migration
      */
     private function indexExists(string $table, $columns, bool $unique = false): bool
     {
-        $schema = $this->db->getTableSchema($table);
-        if (!$schema) {
-            return false;
-        }
-
         $columns = (array)$columns;
         sort($columns);
+        $columnsStr = implode(', ', $columns);
 
-        foreach ($schema->indexes as $index) {
-            $indexColumns = array_keys($index->columns);
-            sort($indexColumns);
+        $sql = "SELECT indexname, indexdef FROM pg_indexes WHERE tablename = :table";
+        $indexes = $this->db->createCommand($sql, [':table' => $table])->queryAll();
 
-            if ($indexColumns === $columns) {
-                if (!$unique || $index->isUnique) {
+        foreach ($indexes as $index) {
+            $def = $index['indexdef'];
+            // Проверяем unique
+            if ($unique && stripos($def, 'UNIQUE') === false) {
+                continue;
+            }
+            // Извлекаем колонки из определения индекса
+            if (preg_match('/\(([^)]+)\)$/', $def, $matches)) {
+                $indexCols = array_map('trim', explode(',', $matches[1]));
+                sort($indexCols);
+                if ($indexCols === $columns) {
                     return true;
                 }
             }
index e979daa28040c253383d113ea93cbdf2bcb07042..ceb05b3da74fff7922aca20c085a4a2169d357d0 100644 (file)
@@ -90,19 +90,22 @@ class ProductPropTypeCacheService
         }
 
         // 2. Проверяем Yii cache
-        $cache = Yii::$app->cache;
-        $cached = $cache->get(self::CACHE_KEY);
-
-        if ($cached !== false) {
-            $this->runtimeCache = $cached;
-            return $this->runtimeCache;
+        $cache = Yii::$app->cache ?? null;
+        if ($cache !== null) {
+            $cached = $cache->get(self::CACHE_KEY);
+            if ($cached !== false) {
+                $this->runtimeCache = $cached;
+                return $this->runtimeCache;
+            }
         }
 
         // 3. Загружаем из БД
         $this->runtimeCache = $this->loadFromDatabase();
 
         // Сохраняем в Yii cache
-        $cache->set(self::CACHE_KEY, $this->runtimeCache, self::CACHE_DURATION);
+        if ($cache !== null) {
+            $cache->set(self::CACHE_KEY, $this->runtimeCache, self::CACHE_DURATION);
+        }
 
         return $this->runtimeCache;
     }
@@ -130,7 +133,10 @@ class ProductPropTypeCacheService
     public function invalidate(): void
     {
         $this->runtimeCache = null;
-        Yii::$app->cache->delete(self::CACHE_KEY);
+        $cache = Yii::$app->cache ?? null;
+        if ($cache !== null) {
+            $cache->delete(self::CACHE_KEY);
+        }
     }
 
     /**
@@ -164,12 +170,12 @@ class ProductPropTypeCacheService
     public function getStats(): array
     {
         $all = $this->getAll();
-        $cache = Yii::$app->cache;
+        $cache = Yii::$app->cache ?? null;
 
         return [
             'total_types' => count($all),
             'in_runtime_cache' => $this->runtimeCache !== null,
-            'in_yii_cache' => $cache->get(self::CACHE_KEY) !== false,
+            'in_yii_cache' => $cache !== null && $cache->get(self::CACHE_KEY) !== false,
             'cache_key' => self::CACHE_KEY,
             'cache_duration' => self::CACHE_DURATION,
         ];
index b742dae0e7c2852c0bbf3c60b58c348e877b4388..b400e14bdf631f64f6aa5ee999b9d6c0364ce545 100644 (file)
@@ -61,6 +61,7 @@ class UploadService {
 
     public static function processingUpload($result)
     {
+        $propTypeCache = new ProductPropTypeCacheService();
         $mess = [];
         $request = json_encode($result, JSON_UNESCAPED_UNICODE);
         $mess['request'] = $result;
@@ -336,7 +337,7 @@ class UploadService {
                     foreach ($arr as $key => $property) {
                         if (!in_array($key, Products1c::PRODUCT1C_FIELDS)) {
                             if (empty($property)) continue;
-                            $propertyType = Products1cPropType::findOne(['id' => $key]);
+                            $propertyType = $propTypeCache->get($key);
                             if (!$propertyType) {
                                 $propertyType = new Products1cPropType();
                                 $propertyType->id = $key;
@@ -347,6 +348,7 @@ class UploadService {
                                         JSON_UNESCAPED_UNICODE));
                                     continue;
                                 }
+                                $propTypeCache->invalidate();
                             }
                             $additionalCharacteristic = Products1cAdditionalCharacteristics::findOne([
                                 'product_id' => $arr["id"],
@@ -384,8 +386,10 @@ class UploadService {
                             }
                         }
 
+                        // Проверяем/создаём типы свойств из кеша
+                        $batchChars = [];
                         foreach ($arr["AdditionCharacteristics"] as $characteristic) {
-                            $propertyType = Products1cPropType::findOne(['id' => $characteristic["id"]]);
+                            $propertyType = $propTypeCache->get($characteristic["id"]);
                             if (!$propertyType) {
                                 $propertyType = new Products1cPropType();
                                 $propertyType->id = $characteristic["id"];
@@ -396,27 +400,24 @@ class UploadService {
                                         JSON_UNESCAPED_UNICODE));
                                     continue;
                                 }
+                                $propTypeCache->invalidate();
                             }
 
-                            $additionalCharacteristic = Products1cAdditionalCharacteristics::findOne([
+                            $batchChars[] = [
                                 'product_id' => $arr["id"],
-                                'property_id' => $characteristic["id"]
-                            ]);
-
-                            if (!$additionalCharacteristic) {
-                                $additionalCharacteristic = new Products1cAdditionalCharacteristics();
-                                $additionalCharacteristic->product_id = $arr["id"];
-                                $additionalCharacteristic->property_id = $characteristic["id"];
-                            }
-
-                            $additionalCharacteristic->value = $characteristic["value_name"];
-                            if (!$additionalCharacteristic->save()) {
-                                LogService::apiErrorLog(
-                                    json_encode(
-                                        ["error_id" => 8.2, "error" => $additionalCharacteristic->getErrors()],
-                                        JSON_UNESCAPED_UNICODE
-                                    )
-                                );
+                                'property_id' => $characteristic["id"],
+                                'value' => $characteristic["value_name"],
+                            ];
+                        }
+                        // Batch upsert вместо findOne → save в цикле
+                        if (!empty($batchChars)) {
+                            try {
+                                (new BatchSyncService())->upsertProductCharacteristics($batchChars);
+                            } catch (\Exception $e) {
+                                LogService::apiErrorLog(json_encode([
+                                    "error_id" => 8.2,
+                                    "error" => "Batch upsert characteristics failed: " . $e->getMessage()
+                                ], JSON_UNESCAPED_UNICODE));
                             }
                         }
                     }
@@ -1369,17 +1370,26 @@ class UploadService {
             }
 
             if (!empty($result['balances'])) {
+                $batchSyncService = new BatchSyncService();
                 foreach ($result["balances"] as $std => $arr) {
                     Balances::deleteAll(['store_id' => $std]);
+                    $batchBalances = [];
                     foreach ($arr as $pid => $arr2) {
-                        $balances = new Balances;
-                        $balances->store_id = $std;
-                        $balances->product_id = $pid;
-                        $balances->quantity = $arr2[0];
-                        $balances->reserv = $arr2[1];
-                        $balances->save();
-                        if ($balances->getErrors()) {
-                            LogService::apiErrorLog(json_encode(["error_id" => 26, "error" => $balances->getErrors()], JSON_UNESCAPED_UNICODE));
+                        $batchBalances[] = [
+                            'store_id' => $std,
+                            'product_id' => $pid,
+                            'quantity' => $arr2[0],
+                            'reserv' => $arr2[1],
+                        ];
+                    }
+                    if (!empty($batchBalances)) {
+                        try {
+                            $batchSyncService->upsertBalances($batchBalances);
+                        } catch (\Exception $e) {
+                            LogService::apiErrorLog(json_encode([
+                                "error_id" => 26,
+                                "error" => "Batch upsert balances failed: " . $e->getMessage()
+                            ], JSON_UNESCAPED_UNICODE));
                         }
                     }
                 }
diff --git a/erp24/tests/unit/services/BatchSyncServiceTest.php b/erp24/tests/unit/services/BatchSyncServiceTest.php
new file mode 100644 (file)
index 0000000..2353f24
--- /dev/null
@@ -0,0 +1,211 @@
+<?php
+
+declare(strict_types=1);
+
+namespace app\tests\unit\services;
+
+use Codeception\Test\Unit;
+use yii\db\Command;
+use yii\db\Connection;
+use yii_app\services\BatchSyncService;
+
+/**
+ * Unit-тесты для BatchSyncService (ERP-218-J).
+ *
+ * Покрывает:
+ * - Пустые массивы → 0
+ * - Вызов executeBatchUpsert с правильными параметрами
+ * - Batching по 1000
+ *
+ * @covers \yii_app\services\BatchSyncService
+ */
+class BatchSyncServiceTest extends Unit
+{
+    /**
+     * Тест: upsertBalances с пустым массивом возвращает 0.
+     */
+    public function testUpsertBalances_EmptyArray_ReturnsZero(): void
+    {
+        $db = $this->createMock(Connection::class);
+        $db->expects($this->never())->method('createCommand');
+
+        $service = new BatchSyncService($db);
+        $result = $service->upsertBalances([]);
+
+        $this->assertSame(0, $result);
+    }
+
+    /**
+     * Тест: upsertProductCharacteristics с пустым массивом возвращает 0.
+     */
+    public function testUpsertProductCharacteristics_EmptyArray_ReturnsZero(): void
+    {
+        $db = $this->createMock(Connection::class);
+        $db->expects($this->never())->method('createCommand');
+
+        $service = new BatchSyncService($db);
+        $result = $service->upsertProductCharacteristics([]);
+
+        $this->assertSame(0, $result);
+    }
+
+    /**
+     * Тест: deleteProductCharacteristics с пустым массивом возвращает 0.
+     */
+    public function testDeleteProductCharacteristics_EmptyArray_ReturnsZero(): void
+    {
+        $db = $this->createMock(Connection::class);
+        $db->expects($this->never())->method('createCommand');
+
+        $service = new BatchSyncService($db);
+        $result = $service->deleteProductCharacteristics([]);
+
+        $this->assertSame(0, $result);
+    }
+
+    /**
+     * Тест: deleteBalances с пустым массивом возвращает 0.
+     */
+    public function testDeleteBalances_EmptyArray_ReturnsZero(): void
+    {
+        $db = $this->createMock(Connection::class);
+        $db->expects($this->never())->method('createCommand');
+
+        $service = new BatchSyncService($db);
+        $result = $service->deleteBalances([]);
+
+        $this->assertSame(0, $result);
+    }
+
+    /**
+     * Тест: upsertBalances вызывает createCommand с SQL содержащим ON CONFLICT.
+     */
+    public function testUpsertBalances_SingleRow_ExecutesUpsertSQL(): void
+    {
+        $command = $this->createMock(Command::class);
+        $command->expects($this->once())
+            ->method('execute')
+            ->willReturn(1);
+
+        $db = $this->createMock(Connection::class);
+        $db->expects($this->once())
+            ->method('createCommand')
+            ->with(
+                $this->callback(function (string $sql) {
+                    return strpos($sql, 'INSERT INTO') !== false
+                        && strpos($sql, 'ON CONFLICT') !== false
+                        && strpos($sql, 'balances') !== false
+                        && strpos($sql, 'store_id, product_id') !== false;
+                }),
+                $this->isType('array')
+            )
+            ->willReturn($command);
+
+        $service = new BatchSyncService($db);
+        $result = $service->upsertBalances([
+            ['store_id' => 'store-1', 'product_id' => 'prod-1', 'quantity' => 10, 'reserv' => 2],
+        ]);
+
+        $this->assertSame(1, $result);
+    }
+
+    /**
+     * Тест: upsertProductCharacteristics вызывает SQL с ON CONFLICT.
+     */
+    public function testUpsertProductCharacteristics_SingleRow_ExecutesUpsertSQL(): void
+    {
+        $command = $this->createMock(Command::class);
+        $command->expects($this->once())
+            ->method('execute')
+            ->willReturn(1);
+
+        $db = $this->createMock(Connection::class);
+        $db->expects($this->once())
+            ->method('createCommand')
+            ->with(
+                $this->callback(function (string $sql) {
+                    return strpos($sql, 'INSERT INTO') !== false
+                        && strpos($sql, 'ON CONFLICT') !== false
+                        && strpos($sql, 'products_1c_additional_characteristics') !== false;
+                }),
+                $this->isType('array')
+            )
+            ->willReturn($command);
+
+        $service = new BatchSyncService($db);
+        $result = $service->upsertProductCharacteristics([
+            ['product_id' => 'prod-1', 'property_id' => 'prop-1', 'value' => 'test'],
+        ]);
+
+        $this->assertSame(1, $result);
+    }
+
+    /**
+     * Тест: upsertBalances с >1000 строк делает несколько batch-вызовов.
+     */
+    public function testUpsertBalances_MoreThan1000Rows_MultipleChunks(): void
+    {
+        $command = $this->createMock(Command::class);
+        $command->method('execute')->willReturn(1);
+
+        $db = $this->createMock(Connection::class);
+        // 1500 строк → 2 чанка (1000 + 500)
+        $db->expects($this->exactly(2))
+            ->method('createCommand')
+            ->willReturn($command);
+
+        $service = new BatchSyncService($db);
+
+        $rows = [];
+        for ($i = 0; $i < 1500; $i++) {
+            $rows[] = [
+                'store_id' => "store-{$i}",
+                'product_id' => "prod-{$i}",
+                'quantity' => $i,
+                'reserv' => 0,
+            ];
+        }
+
+        $result = $service->upsertBalances($rows);
+        $this->assertSame(1500, $result);
+    }
+
+    /**
+     * Тест: конструктор без аргументов использует Yii::$app->db.
+     */
+    public function testConstruct_WithoutDb_UsesYiiDb(): void
+    {
+        $service = new BatchSyncService();
+        $this->assertInstanceOf(BatchSyncService::class, $service);
+    }
+
+    /**
+     * Тест: параметры SQL содержат правильные значения.
+     */
+    public function testUpsertBalances_ParamsMatchInputData(): void
+    {
+        $capturedParams = null;
+
+        $command = $this->createMock(Command::class);
+        $command->method('execute')->willReturn(1);
+
+        $db = $this->createMock(Connection::class);
+        $db->expects($this->once())
+            ->method('createCommand')
+            ->with(
+                $this->isType('string'),
+                $this->callback(function (array $params) use (&$capturedParams) {
+                    $capturedParams = $params;
+                    return true;
+                })
+            )
+            ->willReturn($command);
+
+        $service = new BatchSyncService($db);
+        $service->upsertBalances([
+            ['store_id' => 'AAA', 'product_id' => 'BBB', 'quantity' => 42, 'reserv' => 5],
+        ]);
+
+        $this->assertSame(['AAA', 'BBB', 42, 5], $capturedParams);
+    }
+}
diff --git a/erp24/tests/unit/services/ProductPropTypeCacheServiceTest.php b/erp24/tests/unit/services/ProductPropTypeCacheServiceTest.php
new file mode 100644 (file)
index 0000000..09f3053
--- /dev/null
@@ -0,0 +1,130 @@
+<?php
+
+declare(strict_types=1);
+
+namespace app\tests\unit\services;
+
+use Codeception\Test\Unit;
+use yii_app\services\ProductPropTypeCacheService;
+
+/**
+ * Unit-тесты для ProductPropTypeCacheService (ERP-218-J).
+ *
+ * Используем partial mock для подмены loadFromDatabase(),
+ * т.к. unit-тесты не имеют доступа к таблице products_1c_prop_type.
+ *
+ * @covers \yii_app\services\ProductPropTypeCacheService
+ */
+class ProductPropTypeCacheServiceTest extends Unit
+{
+    private function createServiceWithMockedDb(array $fakeData = []): ProductPropTypeCacheService
+    {
+        $service = $this->getMockBuilder(ProductPropTypeCacheService::class)
+            ->onlyMethods(['getAll'])
+            ->getMock();
+
+        // Эмулируем runtime cache поведение
+        $called = false;
+        $service->method('getAll')->willReturnCallback(function () use ($fakeData, &$called) {
+            $called = true;
+            return $fakeData;
+        });
+
+        return $service;
+    }
+
+    public function testConstruct_CreatesInstance(): void
+    {
+        $service = new ProductPropTypeCacheService();
+        $this->assertInstanceOf(ProductPropTypeCacheService::class, $service);
+    }
+
+    public function testGet_ExistingId_ReturnsValue(): void
+    {
+        $mock = $this->createMock(\yii_app\records\Products1cPropType::class);
+        $service = $this->createServiceWithMockedDb(['prop-1' => $mock]);
+
+        $result = $service->get('prop-1');
+        $this->assertNotNull($result);
+    }
+
+    public function testGet_NonExistentId_ReturnsNull(): void
+    {
+        $mock = $this->createMock(\yii_app\records\Products1cPropType::class);
+        $service = $this->createServiceWithMockedDb(['prop-1' => $mock]);
+
+        $result = $service->get('non_existent');
+        $this->assertNull($result);
+    }
+
+    public function testExists_ExistingId_ReturnsTrue(): void
+    {
+        $mock = $this->createMock(\yii_app\records\Products1cPropType::class);
+        $service = $this->createServiceWithMockedDb(['prop-1' => $mock]);
+        $this->assertTrue($service->exists('prop-1'));
+    }
+
+    public function testExists_NonExistentId_ReturnsFalse(): void
+    {
+        $mock = $this->createMock(\yii_app\records\Products1cPropType::class);
+        $service = $this->createServiceWithMockedDb(['prop-1' => $mock]);
+        $this->assertFalse($service->exists('non_existent'));
+    }
+
+    public function testGetAll_ReturnsArray(): void
+    {
+        $fakeData = ['a' => 1, 'b' => 2];
+        $service = $this->createServiceWithMockedDb($fakeData);
+        $this->assertSame($fakeData, $service->getAll());
+    }
+
+    public function testGetAllIds_ReturnsKeys(): void
+    {
+        $fakeData = ['prop-1' => 'x', 'prop-2' => 'y', 'prop-3' => 'z'];
+        $service = $this->createServiceWithMockedDb($fakeData);
+        $this->assertSame(['prop-1', 'prop-2', 'prop-3'], $service->getAllIds());
+    }
+
+    public function testGetAll_SecondCall_ReturnsSameResult(): void
+    {
+        $fakeData = ['a' => 1];
+        $service = $this->createServiceWithMockedDb($fakeData);
+        $result1 = $service->getAll();
+        $result2 = $service->getAll();
+        $this->assertSame($result1, $result2);
+    }
+
+    public function testInvalidate_DoesNotThrow(): void
+    {
+        $service = new ProductPropTypeCacheService();
+        // Не должен падать даже без cache-компонента
+        $service->invalidate();
+        $this->assertTrue(true);
+    }
+
+    public function testGetAndExists_Consistent(): void
+    {
+        $mock1 = $this->createMock(\yii_app\records\Products1cPropType::class);
+        $mock2 = $this->createMock(\yii_app\records\Products1cPropType::class);
+        $fakeData = ['p1' => $mock1, 'p2' => $mock2];
+        $service = $this->createServiceWithMockedDb($fakeData);
+
+        foreach (array_keys($fakeData) as $id) {
+            $this->assertTrue($service->exists($id));
+            $this->assertNotNull($service->get($id));
+        }
+
+        $this->assertFalse($service->exists('missing'));
+        $this->assertNull($service->get('missing'));
+    }
+
+    /**
+     * Тест: get() и exists() делегируют в getAll() — проверяем пустой кеш.
+     */
+    public function testEmptyCache_GetReturnsNull_ExistsReturnsFalse(): void
+    {
+        $service = $this->createServiceWithMockedDb([]);
+        $this->assertNull($service->get('anything'));
+        $this->assertFalse($service->exists('anything'));
+    }
+}