--- /dev/null
+<?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'],
+ 'По магазину с одними маркетплейсом/доставкой записи быть не должно');
+ }
+ }
+}
--- /dev/null
+<?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 быть не должно');
+ }
+}