reconcile: канон main = боевой прод (deploy-tariff + фича + 3 фикса 09.07)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-09 12:14:56 +03:00
parent 97c75407ab
commit c8de205cc2
5 changed files with 220 additions and 45 deletions
+36 -39
View File
@@ -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<string, array{project: string, tag: string, phone: string}> $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
{
+9 -6
View File
@@ -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' => [
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Services\External;
/**
* Живость почты: TCP/TLS-connect к SMTP-порту Yandex 360 + чтение приветственного
* баннера (должен начинаться с «220»). Без логина/отправки денег/квоты не тратит.
* Соединитель инъектируется (тестируемость): возвращает первую строку баннера или бросает.
*/
class SmtpLivenessProbe implements LivenessProbe
{
/** @var (callable():string)|null */
private $connector;
/** @param (callable():string)|null $connector фейковый соединитель для тестов */
public function __construct(?callable $connector = null)
{
$this->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);
}
};
}
}
@@ -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="<vid>"` +
* `B{1,2,3}_<ident>` (проект) + `7\d{10}` (телефон). date_from/date_to формат d.m.Y.
*
* @return array<int, array{vid: int, phone: string, project: string}> ключ = 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<array{vid: int, phone: string, project: string}>
*/
private function parseDeliveredRows(string $html): array
{
$out = [];
foreach (preg_split('/<tr[ >]/', $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(
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Support;
/**
* Утилита отображения имён проектов поставщика display-only.
*
* Поставщик префиксует имена проектов кодом канала-провайдера (B1_/B2_/B3_/B6_/B8_/B<N>_).
* Клиенту этот префикс показывать нельзя: он раскрывает нашу внутреннюю схему каналов и то,
* что лиды перекупаются. Срезаем префикс во ВСЕХ клиентских ответах СЕРВЕРНО (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;
}
}