}
/**
- * Проверка существования индекса.
+ * Проверка существования индекса через pg_indexes.
*
* @param string $table Название таблицы
* @param string|array $columns Колонка(и)
*/
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;
}
}
}
// 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;
}
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);
+ }
}
/**
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,
];
public static function processingUpload($result)
{
+ $propTypeCache = new ProductPropTypeCacheService();
$mess = [];
$request = json_encode($result, JSON_UNESCAPED_UNICODE);
$mess['request'] = $result;
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;
JSON_UNESCAPED_UNICODE));
continue;
}
+ $propTypeCache->invalidate();
}
$additionalCharacteristic = Products1cAdditionalCharacteristics::findOne([
'product_id' => $arr["id"],
}
}
+ // Проверяем/создаём типы свойств из кеша
+ $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"];
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));
}
}
}
}
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));
}
}
}
--- /dev/null
+<?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);
+ }
+}
--- /dev/null
+<?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'));
+ }
+}