From c8de205cc2970a332168dce85a94e9d86d9bcd8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Thu, 9 Jul 2026 12:14:56 +0300 Subject: [PATCH] =?UTF-8?q?reconcile:=20=D0=BA=D0=B0=D0=BD=D0=BE=D0=BD=20m?= =?UTF-8?q?ain=20=3D=20=D0=B1=D0=BE=D0=B5=D0=B2=D0=BE=D0=B9=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B4=20(deploy-tariff=20+=20=D1=84=D0=B8=D1=87=D0=B0=20?= =?UTF-8?q?+=203=20=D1=84=D0=B8=D0=BA=D1=81=D0=B0=2009.07)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- app/app/Jobs/Supplier/CsvReconcileJob.php | 75 +++++++++---------- app/app/Services/Audit/AuditChainConfig.php | 15 ++-- .../Services/External/SmtpLivenessProbe.php | 65 ++++++++++++++++ .../Supplier/SupplierPortalClient.php | 75 +++++++++++++++++++ app/app/Support/SupplierProjectName.php | 35 +++++++++ 5 files changed, 220 insertions(+), 45 deletions(-) create mode 100644 app/app/Services/External/SmtpLivenessProbe.php create mode 100644 app/app/Support/SupplierProjectName.php diff --git a/app/app/Jobs/Supplier/CsvReconcileJob.php b/app/app/Jobs/Supplier/CsvReconcileJob.php index 321dc9ee..ffca4177 100644 --- a/app/app/Jobs/Supplier/CsvReconcileJob.php +++ b/app/app/Jobs/Supplier/CsvReconcileJob.php @@ -100,36 +100,32 @@ final class CsvReconcileJob implements ShouldQueue 'created_at' => now(), ]); - $reportId = $portal->requestNumbersReport($windowStart, $windowEnd); - $portal->waitReportReady($reportId); - $csv = $portal->downloadReport($reportId); + // Гарантированный канал (Путь 2, переработан 09.07.2026): сверяемся с журналом + // ОТДАННОГО («Мои сделки», по vid), а НЕ с пулом «Запрос номеров» (phones_cnt). + // Инцидент roistat✓ 09.07: пул отдавал 157 собранных, из них поставщик отгрузил 15, + // а старый reconcile лепил из пула 15 фантомов + выедал лимит клиента. Сверка по vid + // (точная личность лида) исключает фантомы и ловит подмену при совпадении количества. + $delivered = $portal->fetchDeliveredLeads($windowStart, $windowEnd); + $totalCsvRows = count($delivered); - // CSV-строки по ключу phone|project (последняя строка с тем же ключом перетирает). - /** @var array $csvByKey */ - $csvByKey = []; - foreach ($parser->parse($csv) as $row) { - $csvByKey[$this->dedupKey((string) $row['phone'], (string) $row['project'])] = $row; + // Уже принятые нами vid'ы — проверяем ГЛОБАЛЬНО и точечно по самим отданным vid + // (не по окну): idx_supplier_leads_vid_unique глобальный, а точечный whereIn ограничен + // числом отданных (сотни). Так дедуп исключает любой существующий vid ДО вставки — + // unique-violation в норме не наступает (иначе abort транзакции). + $existingVids = []; + $deliveredVids = array_map('intval', array_keys($delivered)); + if ($deliveredVids !== []) { + DB::connection(self::DB_CONNECTION) + ->table('supplier_leads') + ->whereIn('vid', $deliveredVids) + ->pluck('vid') + ->each(function ($vid) use (&$existingVids): void { + $existingVids[(int) $vid] = true; + }); } - $totalCsvRows = count($csvByKey); - // Существующие лиды за окно → set ключей phone|project. - $existingKeys = []; - DB::connection(self::DB_CONNECTION) - ->table('supplier_leads') - ->where('received_at', '>=', $windowStart) - ->select('phone', 'raw_payload') - ->orderBy('id') - ->chunk(500, function ($leads) use (&$existingKeys): void { - foreach ($leads as $lead) { - $payload = is_string($lead->raw_payload) - ? json_decode($lead->raw_payload, true) - : (array) $lead->raw_payload; - $project = (string) ($payload['project'] ?? ''); - $existingKeys[$this->dedupKey((string) $lead->phone, $project)] = true; - } - }); - - $missing = array_diff_key($csvByKey, $existingKeys); + // Недостача = отданные поставщиком vid'ы, которых у нас нет (webhook потерял). + $missing = array_diff_key($delivered, $existingVids); $recoveredCount = 0; $unparseableCount = 0; @@ -149,11 +145,18 @@ final class CsvReconcileJob implements ShouldQueue } try { + // vid — НАСТОЯЩИЙ (из журнала отданного). idx_supplier_leads_vid_unique делает + // recovery идемпотентным с поздним webhook: SupplierWebhookController при том же + // vid вернёт существующий лид, второй сделки не будет. $lead = SupplierLead::create([ - 'vid' => null, + 'vid' => $row['vid'], 'platform' => $platform, 'phone' => (string) $row['phone'], - 'raw_payload' => $row, + 'raw_payload' => [ + 'project' => $row['project'], + 'phone' => (string) $row['phone'], + 'vid' => $row['vid'], + ], 'received_at' => now(), 'recovered_from_csv_at' => now(), 'source' => 'csv_recovery', @@ -162,8 +165,10 @@ final class CsvReconcileJob implements ShouldQueue RouteSupplierLeadJob::dispatch($lead->id); $recoveredCount++; } catch (QueryException $e) { - Log::warning('csv_reconcile.lead_insert_failed', [ - 'phone' => $row['phone'], + // Гонка с webhook (тот же vid успел вставиться между chunk-снимком и insert'ом) + // ловится unique-индексом. Это не потеря — лид уже у нас, добор не нужен. + Log::info('csv_reconcile.lead_insert_skipped_race', [ + 'vid' => $row['vid'], 'project' => $row['project'], 'error' => $e->getMessage(), ]); @@ -256,14 +261,6 @@ final class CsvReconcileJob implements ShouldQueue } } - /** - * Ключ дедупа: нормализованный phone + project. - */ - private function dedupKey(string $phone, string $project): string - { - return trim($phone).'|'.trim($project); - } - /** Был ли алерт о падении сверки за последнее окно троттла (анти-спам). */ private function failureAlertRecentlySent(): bool { diff --git a/app/app/Services/Audit/AuditChainConfig.php b/app/app/Services/Audit/AuditChainConfig.php index f8b91ee7..a6d19ce6 100644 --- a/app/app/Services/Audit/AuditChainConfig.php +++ b/app/app/Services/Audit/AuditChainConfig.php @@ -13,8 +13,11 @@ use InvalidArgumentException; * verify (App\Console\Commands\VerifyAuditChains) and rebuild * (App\Console\Commands\AuditRebuildChain). * - * ADR-018: per-tenant via RLS scope for tenant tables, - * global for BYPASSRLS tables. + * ADR-021 (supersedes ADR-018): global-within-partition для ВСЕХ таблиц. + * Эмпирика на проде 2026-07-09 (все 4 бывшие «per-tenant» таблицы, включая + * balance_transactions): триггер `audit_chain_hash()` де-факто пишет глобальную + * цепочку (RLS не скоупит его SELECT) — per-tenant изоляция ADR-018 никогда не + * была реализована. Verify/rebuild приведены к реальности (partition => ''). * * columns: list in ordinal_position order from db/schema.sql. * '__log_hash__' -- marker for log_hash position -> NULL::bytea in ROW(). @@ -43,7 +46,7 @@ final class AuditChainConfig 'old_value', 'new_value', 'context', 'ip_address', 'user_agent', '__log_hash__', 'created_at', ], - 'partition' => 'PARTITION BY tenant_id', + 'partition' => '', // ADR-021: global-within-partition (per-tenant never реализован) ], 'tenant_operations_log' => [ 'columns' => [ @@ -51,7 +54,7 @@ final class AuditChainConfig 'event', 'payload_before', 'payload_after', 'ip_address', 'user_agent', '__log_hash__', 'created_at', ], - 'partition' => 'PARTITION BY tenant_id', + 'partition' => '', // ADR-021: global-within-partition (per-tenant never реализован) ], 'balance_transactions' => [ 'columns' => [ @@ -60,7 +63,7 @@ final class AuditChainConfig 'related_type', 'related_id', 'user_id', 'admin_user_id', '__log_hash__', 'created_at', ], - 'partition' => 'PARTITION BY tenant_id', + 'partition' => '', // ADR-021: global-within-partition (per-tenant never реализован) ], 'pd_processing_log' => [ 'columns' => [ @@ -68,7 +71,7 @@ final class AuditChainConfig 'purpose', 'actor_tenant_user_id', 'actor_admin_user_id', 'ip_address', '__log_hash__', 'created_at', ], - 'partition' => 'PARTITION BY tenant_id', + 'partition' => '', // ADR-021: global-within-partition (per-tenant never реализован) ], 'saas_admin_audit_log' => [ 'columns' => [ diff --git a/app/app/Services/External/SmtpLivenessProbe.php b/app/app/Services/External/SmtpLivenessProbe.php new file mode 100644 index 00000000..aebcd5c8 --- /dev/null +++ b/app/app/Services/External/SmtpLivenessProbe.php @@ -0,0 +1,65 @@ +connector = $connector; + } + + public function serviceKey(): string + { + return 'email'; + } + + public function check(): LivenessReading + { + try { + $banner = ($this->connector ?? $this->defaultConnector())(); + if (! str_starts_with(ltrim($banner), '220')) { + return LivenessReading::down('email', 'SMTP-баннер не 220: '.mb_substr(trim($banner), 0, 120)); + } + + return LivenessReading::alive('email', 'SMTP отвечает'); + } catch (\Throwable $e) { + return LivenessReading::down('email', $e->getMessage()); + } + } + + /** @return callable():string */ + private function defaultConnector(): callable + { + return function (): string { + $host = (string) config('services.smtp_probe.host'); + $port = (int) config('services.smtp_probe.port'); + $timeout = (int) config('services.smtp_probe.timeout', 5); + // 465 — implicit TLS; ssl:// нужен на connect. + $scheme = $port === 465 ? 'ssl://' : 'tcp://'; + $fp = @stream_socket_client($scheme.$host.':'.$port, $errno, $errstr, $timeout); + if ($fp === false) { + throw new \RuntimeException($errstr !== '' ? $errstr : 'Connection refused (errno '.$errno.')'); + } + try { + stream_set_timeout($fp, $timeout); + $line = fgets($fp, 512); + + return $line === false ? '' : $line; + } finally { + fclose($fp); + } + }; + } +} diff --git a/app/app/Services/Supplier/SupplierPortalClient.php b/app/app/Services/Supplier/SupplierPortalClient.php index 2afe7e46..32178eed 100644 --- a/app/app/Services/Supplier/SupplierPortalClient.php +++ b/app/app/Services/Supplier/SupplierPortalClient.php @@ -69,6 +69,81 @@ class SupplierPortalClient return is_array($projects) ? array_values($projects) : []; } + /** Строк на странице «Мои сделки» (index-visit) — портал отдаёт по 50. */ + private const DELIVERED_PAGE_SIZE = 50; + + /** Backstop от бесконечной пагинации (аномалия портала). */ + private const DELIVERED_MAX_PAGES = 500; + + /** + * Журнал ОТДАННОГО («Мои сделки») за окно [from, to] — гарантированный канал доставки. + * + * В отличие от «Запрос номеров» (selectType=49), который отдаёт ПУЛ собранных номеров + * (`phones_cnt`, `prophones:'curr'`, `state_id:0`), эта страница содержит только реально + * ОТГРУЖЕННЫЕ поставщиком лиды — каждый с настоящим `vid` (= checkbox value / view?id). + * Сверка по `vid` даёт точную личность лида: фантомы из пула невозможны, а совпадение + * количества при подменённых номерах (инцидент roistat✓ 09.07.2026: отдано 15 = сделок 15, + * но все 15 — пул) ловится, чего потолок по `crms_cnt` не умеет. + * + * GET /admin/visit/index-visit?visit=rt&date_from&date_to&page=N — server-rendered HTML, + * 50 строк/страница, newest-first. Пагинируем до страницы с < 50 строк. + * Verified live 2026-07-09: строка = `name="visit-checbox" value=""` + + * `B{1,2,3}_` (проект) + `7\d{10}` (телефон). date_from/date_to формат d.m.Y. + * + * @return array ключ = vid (дедуп) + */ + public function fetchDeliveredLeads(CarbonInterface $from, CarbonInterface $to): array + { + $byVid = []; + $page = 1; + + do { + $response = $this->request('GET', '/admin/visit/index-visit', [ + 'visit' => 'rt', + 'date_from' => $from->format('d.m.Y'), + 'date_to' => $to->format('d.m.Y'), + 'page' => $page, + ]); + + $rows = $this->parseDeliveredRows($response->body()); + foreach ($rows as $row) { + $byVid[$row['vid']] = $row; + } + + $page++; + } while (count($rows) >= self::DELIVERED_PAGE_SIZE && $page <= self::DELIVERED_MAX_PAGES); + + return $byVid; + } + + /** + * Парсит строки таблицы «Мои сделки» → [vid, phone, project]. + * Строки без vid (шапка/служебные) или без телефона пропускаются. + * + * @return list + */ + private function parseDeliveredRows(string $html): array + { + $out = []; + foreach (preg_split('/]/', $html) ?: [] as $row) { + if (preg_match('/name="visit-checbox"\s+value="(\d+)"/', $row, $vid) !== 1) { + continue; + } + if (preg_match('/(7\d{10})/', $row, $phone) !== 1) { + continue; + } + $project = preg_match('#(B[123]_[^\s<"]+)#', $row, $proj) === 1 ? $proj[1] : ''; + + $out[] = [ + 'vid' => (int) $vid[1], + 'phone' => $phone[1], + 'project' => $project, + ]; + } + + return $out; + } + public function saveProject(SupplierProjectDto $dto): int { $response = $this->request( diff --git a/app/app/Support/SupplierProjectName.php b/app/app/Support/SupplierProjectName.php new file mode 100644 index 00000000..c002858a --- /dev/null +++ b/app/app/Support/SupplierProjectName.php @@ -0,0 +1,35 @@ +_). + * Клиенту этот префикс показывать нельзя: он раскрывает нашу внутреннюю схему каналов и то, + * что лиды перекупаются. Срезаем префикс во ВСЕХ клиентских ответах СЕРВЕРНО (API, экспорт), + * а не только на фронте — иначе прямой API-потребитель и скачанный CSV/XLSX всё равно видят «B1_…». + * + * Серверный аналог resources/js/composables/projectName.ts::stripChannelPrefix. + * Данные в БД (`supplier_projects.name` / `projects.name`) НЕ трогаем — только вывод. + */ +final class SupplierProjectName +{ + /** Любой B + одна-или-более цифр + подчёркивание в начале (B1_/B6_/B8_/B10_…), но не буква (BX_). */ + private const CHANNEL_PREFIX_RE = '/^B\d+_/i'; + + /** + * Срезает канальный префикс из начала имени проекта. + * null → null (не ломаем nullable-контракт API), '' → '', остальное — без префикса. + */ + public static function strip(?string $name): ?string + { + if ($name === null || $name === '') { + return $name; + } + + return preg_replace(self::CHANNEL_PREFIX_RE, '', $name) ?? $name; + } +}