From: Aleksey Filippov Date: Thu, 30 Apr 2026 07:42:41 +0000 (+0300) Subject: test(ERP-372): добавить тесты для getSalesCountSum X-Git-Url: https://gitweb.erp-flowers.ru/?a=commitdiff_plain;h=dbf4e84e6fc2a44f5c884df25a4f9d0d212342fd;p=erp24_rep%2Fyii-erp24%2F.git test(ERP-372): добавить тесты для getSalesCountSum Unit-тесты на SQL-структуру, биндинг параметров, payType-ветку и пустой результат. Интеграционный тест на реальной БД проверяет, что метод исключает маркетплейс-продажи и записи, связанные через sales_check с доставкой. На текущем (нефиксенном) коде воспроизводят баг — RED-состояние перед применением правки. --- diff --git a/erp24/tests/unit/services/SalesServiceGetSalesCountSumIntegrationTest.php b/erp24/tests/unit/services/SalesServiceGetSalesCountSumIntegrationTest.php new file mode 100644 index 00000000..4582a3fd --- /dev/null +++ b/erp24/tests/unit/services/SalesServiceGetSalesCountSumIntegrationTest.php @@ -0,0 +1,203 @@ +testStoreId1c = 'erp-372-test-' . uniqid(); + // Берём гарантированно несуществующий store_id, чтобы не пересечься + // с реальными магазинами. Диапазон int достаточен. + $this->testStoreIdInt = random_int(900000000, 999999999); + + // Тестовый конфиг указывает на несуществующий erp24_test, поэтому + // подключаемся к рабочей БД приложения через env-переменные + // и оборачиваем всё в транзакцию с rollback. + $host = getenv('POSTGRES_HOSTNAME') ?: '127.0.0.1'; + $port = getenv('POSTGRES_PORT') ?: 5432; + $dbName = getenv('POSTGRES_DB') ?: 'erp24'; + $user = getenv('POSTGRES_USER') ?: 'postgres'; + $password = getenv('POSTGRES_PASSWORD') ?: ''; + + $devDb = new \yii\db\Connection([ + 'dsn' => "pgsql:host={$host};port={$port};dbname={$dbName}", + 'username' => $user, + 'password' => $password, + 'schemaMap' => [ + 'pgsql' => [ + 'class' => 'yii\db\pgsql\Schema', + 'defaultSchema' => 'erp24', + ], + ], + 'charset' => 'utf8', + 'enableSchemaCache' => false, + 'on afterOpen' => function ($event) { + // search_path по умолчанию у postgres-роли может быть public — + // но таблицы лежат в схеме erp24. + $event->sender->createCommand('SET search_path TO erp24, public;')->execute(); + }, + ]); + $devDb->open(); + + $this->originalDb = Yii::$app->getDb(); + Yii::$app->set('db', $devDb); + + $this->transaction = $devDb->beginTransaction(); + } + + protected function _after(): void + { + if (isset($this->transaction) && $this->transaction->getIsActive()) { + $this->transaction->rollBack(); + } + if ($this->originalDb !== null) { + Yii::$app->set('db', $this->originalDb); + } + } + + /** + * Минимальный INSERT в sales: задаёт все NOT NULL поля и опционально + * marketplace_order_id и sales_check. + */ + private function insertSale(array $overrides = []): string + { + $id = $overrides['id'] ?? ('test-sale-' . uniqid()); + + $row = array_merge([ + 'id' => $id, + 'date' => $this->testDate . ' 12:00:00+00', + 'operation' => Sales::OPERATION_SALE, + 'status' => 'paid', + 'summ' => 1000, + 'skidka' => 0, + 'number' => '0', + 'admin_id' => 0, + 'seller_id' => '', + 'store_id_1c' => $this->testStoreId1c, + 'store_id' => $this->testStoreIdInt, + 'sales_check' => '', + 'order_id' => '', + 'terminal_id' => '', + 'terminal' => '', + 'status_check' => 0, + 'matrix' => -1, + 'update_source' => 0, + ], $overrides); + + Yii::$app->db->createCommand()->insert('sales', $row)->execute(); + + return $id; + } + + /** + * Чистая продажа должна попадать в результат, маркетплейс и связанная + * с доставкой — нет. + */ + public function testGetSalesCountSum_ExcludesMarketplaceAndDeliveryLinkedSales(): void + { + // 1. Чистая продажа в магазине — должна попасть в счётчик. + $this->insertSale(['summ' => 1500]); + + // 2. Маркетплейс-продажа — должна быть исключена. + $this->insertSale([ + 'summ' => 9999, + 'marketplace_order_id' => 'mp-order-1', + 'marketplace_name' => 'yandex_market', + ]); + + // 3. Доставочная «головная» продажа (с непустым order_id). + $deliveryId = $this->insertSale([ + 'summ' => 5000, + 'order_id' => '12345', + ]); + + // 4. Продажа в магазине, но связанная с этой доставкой через sales_check — + // должна быть исключена JOIN-ом. + $this->insertSale([ + 'summ' => 7777, + 'sales_check' => $deliveryId, + ]); + + $service = new SalesService(); + $result = $service->getSalesCountSum($this->testDate, $this->testDate); + + $row = null; + foreach ($result as $r) { + if ((int)$r['store_id'] === $this->testStoreIdInt) { + $row = $r; + break; + } + } + + $this->assertNotNull($row, 'Должна вернуться строка по тестовому магазину'); + $this->assertSame(1, (int)$row['cnt'], + 'Должна посчитаться только одна чистая продажа (без маркетплейса и без связи с доставкой)'); + $this->assertSame('1500.00', (string)$row['summ'], + 'Сумма должна совпадать с summ чистой продажи'); + } + + /** + * Если в магазине только маркетплейс/доставка — метод не должен вернуть + * запись по этому магазину вовсе. + */ + public function testGetSalesCountSum_OnlyExcludedSales_NoStoreRow(): void + { + $this->insertSale([ + 'summ' => 5000, + 'marketplace_order_id' => 'mp-order-only', + ]); + + $deliveryId = $this->insertSale([ + 'summ' => 4000, + 'order_id' => '99999', + ]); + $this->insertSale([ + 'summ' => 3000, + 'sales_check' => $deliveryId, + ]); + + $service = new SalesService(); + $result = $service->getSalesCountSum($this->testDate, $this->testDate); + + foreach ($result as $r) { + $this->assertNotSame($this->testStoreIdInt, (int)$r['store_id'], + 'По магазину с одними маркетплейсом/доставкой записи быть не должно'); + } + } +} diff --git a/erp24/tests/unit/services/SalesServiceGetSalesCountSumTest.php b/erp24/tests/unit/services/SalesServiceGetSalesCountSumTest.php new file mode 100644 index 00000000..0c32430a --- /dev/null +++ b/erp24/tests/unit/services/SalesServiceGetSalesCountSumTest.php @@ -0,0 +1,198 @@ +originalDb = Yii::$app->getDb(); + $this->capturedSql = null; + $this->capturedParams = null; + } + + protected function _after(): void + { + // Восстанавливаем оригинальный db, иначе следующие тесты получат мок. + if ($this->originalDb !== null) { + Yii::$app->set('db', $this->originalDb); + } + } + + /** + * Собирает мок Connection/Command, перехватывающий SQL и параметры в свойства теста. + * + * @param array $rowsToReturn что вернёт queryAll() + */ + private function installDbMock(array $rowsToReturn = []): void + { + $command = $this->createMock(Command::class); + $command->method('queryAll')->willReturn($rowsToReturn); + + $self = $this; + $db = $this->createMock(Connection::class); + $db->method('createCommand') + ->willReturnCallback( + function ($sql, $params = []) use ($command, $self) { + $self->capturedSql = (string)$sql; + $self->capturedParams = (array)$params; + return $command; + } + ); + + Yii::$app->set('db', $db); + } + + // ========================================================= + // 1. SQL содержит JOIN + фильтры (главное против регресса) + // ========================================================= + + /** + * SQL должен содержать LEFT JOIN на сам себя через sales_check + * и фильтры исключения маркетплейса и связанных доставочных продаж. + */ + public function testGetSalesCountSum_DefaultParams_SqlContainsJoinAndFilters(): void + { + $this->installDbMock([]); + + $service = new SalesService(); + $service->getSalesCountSum('2026-04-28', '2026-04-28'); + + $this->assertNotNull($this->capturedSql, 'createCommand должен быть вызван'); + + $normalized = preg_replace('/\s+/', ' ', (string)$this->capturedSql); + + $this->assertStringContainsString('LEFT JOIN', $normalized, + 'Запрос должен содержать LEFT JOIN на sales для проверки sales_check'); + $this->assertMatchesRegularExpression( + '/sales\s+sc\s+ON\s+sc\.id\s*=\s*s\.sales_check/i', + $normalized, + 'JOIN должен идти по полю sales_check'); + $this->assertMatchesRegularExpression( + '/s\.\"?marketplace_order_id\"?\s+IS\s+NULL/i', + $normalized, + 'Должна быть проверка marketplace_order_id IS NULL'); + $this->assertMatchesRegularExpression( + '/sc\.\"?order_id\"?\s+IS\s+NULL/i', + $normalized, + 'Должна быть проверка sc.order_id IS NULL (отсутствие связанной доставки)'); + $this->assertMatchesRegularExpression( + '/s\.\"?order_id\"?\s*=\s*\'\'/i', + $normalized, + 'Должна быть проверка s.order_id = \'\' (не доставочный заказ)'); + } + + // ========================================================= + // 2. Биндинг параметров + // ========================================================= + + public function testGetSalesCountSum_BindsRequiredParameters(): void + { + $this->installDbMock([]); + + $service = new SalesService(); + $service->getSalesCountSum('2026-04-28', '2026-04-28', Sales::OPERATION_SALE); + + $params = $this->capturedParams; + $this->assertIsArray($params, 'Параметры должны передаваться в createCommand'); + $this->assertArrayHasKey(':date_from', $params); + $this->assertArrayHasKey(':date_to', $params); + $this->assertArrayHasKey(':operation', $params); + + $this->assertSame(Sales::OPERATION_SALE, $params[':operation']); + $this->assertStringStartsWith('2026-04-28', (string)$params[':date_from']); + $this->assertStringStartsWith('2026-04-28', (string)$params[':date_to']); + } + + public function testGetSalesCountSum_ReturnOperation_BindsReturnConstant(): void + { + $this->installDbMock([]); + + $service = new SalesService(); + $service->getSalesCountSum('2026-04-28', '2026-04-28', Sales::OPERATION_RETURN); + + $this->assertSame(Sales::OPERATION_RETURN, $this->capturedParams[':operation'] ?? null); + } + + // ========================================================= + // 3. Возврат пустого массива + // ========================================================= + + public function testGetSalesCountSum_DbReturnsEmpty_ReturnsEmptyArray(): void + { + $this->installDbMock([]); + + $service = new SalesService(); + $result = $service->getSalesCountSum('2026-04-28', '2026-04-28'); + + $this->assertSame([], $result); + } + + public function testGetSalesCountSum_DbReturnsRows_PassesThemThrough(): void + { + $expected = [ + ['cnt' => 13, 'bonus_clients_cnt' => 5, 'summ' => '49697.00', + 'store_id' => 'store-1', 'date_t' => '2026-04-28'], + ]; + $this->installDbMock($expected); + + $service = new SalesService(); + $result = $service->getSalesCountSum('2026-04-28', '2026-04-28'); + + $this->assertSame($expected, $result); + } + + // ========================================================= + // 4. payType-ветка (доп. фильтр) + // ========================================================= + + public function testGetSalesCountSum_WithPayType_AddsPayArrFilter(): void + { + $this->installDbMock([]); + + $service = new SalesService(); + $service->getSalesCountSum('2026-04-28', '2026-04-28', Sales::OPERATION_SALE, '2'); + + $normalized = preg_replace('/\s+/', ' ', (string)$this->capturedSql); + $this->assertMatchesRegularExpression('/pay_arr/i', $normalized, + 'При переданном payType в SQL должен быть фильтр по pay_arr'); + } + + public function testGetSalesCountSum_WithoutPayType_NoPayArrFilter(): void + { + $this->installDbMock([]); + + $service = new SalesService(); + $service->getSalesCountSum('2026-04-28', '2026-04-28'); + + $normalized = preg_replace('/\s+/', ' ', (string)$this->capturedSql); + $this->assertDoesNotMatchRegularExpression('/pay_arr/i', $normalized, + 'Без payType фильтра по pay_arr быть не должно'); + } +}