reconcile: канон main = боевой прод (deploy-tariff + фича + 3 фикса 09.07)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user