]> gitweb.erp-flowers.ru Git - erp24_rep/yii-erp24/.git/commitdiff
test(ERP-372): добавить тесты для getSalesCountSum
authorAleksey Filippov <Aleksey.Filippov@erp-flowers.ru>
Thu, 30 Apr 2026 07:42:41 +0000 (10:42 +0300)
committerAleksey Filippov <Aleksey.Filippov@erp-flowers.ru>
Thu, 30 Apr 2026 07:42:41 +0000 (10:42 +0300)
Unit-тесты на SQL-структуру, биндинг параметров, payType-ветку
и пустой результат. Интеграционный тест на реальной БД проверяет,
что метод исключает маркетплейс-продажи и записи, связанные через
sales_check с доставкой. На текущем (нефиксенном) коде воспроизводят
баг — RED-состояние перед применением правки.

erp24/tests/unit/services/SalesServiceGetSalesCountSumIntegrationTest.php [new file with mode: 0644]
erp24/tests/unit/services/SalesServiceGetSalesCountSumTest.php [new file with mode: 0644]

diff --git a/erp24/tests/unit/services/SalesServiceGetSalesCountSumIntegrationTest.php b/erp24/tests/unit/services/SalesServiceGetSalesCountSumIntegrationTest.php
new file mode 100644 (file)
index 0000000..4582a3f
--- /dev/null
@@ -0,0 +1,203 @@
+<?php
+
+declare(strict_types=1);
+
+namespace tests\unit\services;
+
+use Codeception\Test\Unit;
+use Yii;
+use yii_app\records\Sales;
+use yii_app\services\SalesService;
+
+/**
+ * Интеграционный тест на реальной БД для SalesService::getSalesCountSum (ERP-372).
+ *
+ * Проверяет, что метод исключает:
+ * 1. Записи с заполненным marketplace_order_id (продажи с маркетплейсов).
+ * 2. Записи, у которых sales_check ссылается на доставочную продажу
+ *    (sc.order_id отличен от пустой строки и '0').
+ *
+ * Все данные пишутся в транзакции и откатываются — таблица sales остаётся
+ * нетронутой.
+ *
+ * @group services
+ * @group sales
+ * @group erp-372
+ * @group integration
+ */
+class SalesServiceGetSalesCountSumIntegrationTest extends Unit
+{
+    /** Уникальный store_id_1c (varchar) — для записей в sales. */
+    private string $testStoreId1c;
+
+    /** Уникальный store_id (integer) — по нему идёт GROUP BY в getSalesCountSum. */
+    private int $testStoreIdInt;
+
+    /** Дата в далёком будущем, чтобы не пересечься с продакшен-датами. */
+    private string $testDate = '2099-12-31';
+
+    private \yii\db\Transaction $transaction;
+    private ?\yii\db\Connection $originalDb = null;
+
+    protected function _before(): void
+    {
+        $this->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 (file)
index 0000000..0c32430
--- /dev/null
@@ -0,0 +1,198 @@
+<?php
+
+declare(strict_types=1);
+
+namespace tests\unit\services;
+
+use Codeception\Test\Unit;
+use Yii;
+use yii\db\Command;
+use yii\db\Connection;
+use yii_app\records\Sales;
+use yii_app\services\SalesService;
+
+/**
+ * Unit-тесты для SalesService::getSalesCountSum (ERP-372).
+ *
+ * Покрывает баг расхождения «среднего чека» на дашборде /dashboard/sales:
+ * метод обязан фильтровать те же записи, что и getSalesSum (доставка через
+ * sales_check, маркетплейс через marketplace_order_id), иначе колонка avg
+ * расходится с колонкой «сумма продаж».
+ *
+ * @group services
+ * @group sales
+ * @group erp-372
+ */
+class SalesServiceGetSalesCountSumTest extends Unit
+{
+    private ?Connection $originalDb = null;
+    private ?string $capturedSql = null;
+    private ?array $capturedParams = null;
+
+    protected function _before(): void
+    {
+        // Сохраняем оригинальный db-компонент, чтобы вернуть его после теста.
+        $this->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 быть не должно');
+    }
+}