From a43ac2d9a586b2c46bbdfdf51ffa06ad9e0390ee 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, 28 May 2026 20:28:42 +0300 Subject: [PATCH] =?UTF-8?q?feat(supplier):=20R-05=20=E2=80=94=20business-d?= =?UTF-8?q?rift=20second=20pass=20in=20CsvReconcileJob?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the existing webhook-loss drift detection (R-05.1: lead delivered but webhook missed), CsvReconcileJob now runs a second pass on project_routing_snapshots: per (snapshot_date, tenant_id) groups, if (expected - delivered) / expected > 20% → send TenantBusinessDriftAlertMail (separate from CsvDriftAlertMail). This catches R-05.2: lead expected by slepok plan but supplier under-delivered. Same lead can be missing from both CSV (webhook-loss) AND delivered_count (business-shortfall) — both alerts fire independently. BUSINESS_DRIFT_THRESHOLD = 0.20 detectAndAlertBusinessDrift() — runs after primary drift inside try{} block, scoped to the same reconcile window. One email per tenant per snapshot_date. + New TenantBusinessDriftAlertMail + emails/tenant_business_drift_alert.blade.php. + 2 Pest tests: shortfall>20% triggers mail (80% case), shortfall<=20% does not (10% case). + Existing tests narrowed from assertNothingSent() to assertNotSent(CsvDriftAlertMail) since legacy snapshot data on dev DB may trigger TenantBusinessDriftAlertMail beyond test's scope. Full CsvReconcileJobTest suite 11/11 GREEN. Stage 4 §4.4.4. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/app/Jobs/Supplier/CsvReconcileJob.php | 68 ++++++++++++++++ app/app/Mail/TenantBusinessDriftAlertMail.php | 51 ++++++++++++ .../tenant_business_drift_alert.blade.php | 15 ++++ .../Feature/Supplier/CsvReconcileJobTest.php | 81 ++++++++++++++++++- 4 files changed, 212 insertions(+), 3 deletions(-) create mode 100644 app/app/Mail/TenantBusinessDriftAlertMail.php create mode 100644 app/resources/views/emails/tenant_business_drift_alert.blade.php diff --git a/app/app/Jobs/Supplier/CsvReconcileJob.php b/app/app/Jobs/Supplier/CsvReconcileJob.php index 3640e855..f40b8aa4 100644 --- a/app/app/Jobs/Supplier/CsvReconcileJob.php +++ b/app/app/Jobs/Supplier/CsvReconcileJob.php @@ -204,6 +204,13 @@ final class CsvReconcileJob implements ShouldQueue ->where('id', $logId) ->update($update); + // R-05 / §4.4.4 second pass — business-drift on project_routing_snapshots. + // Detects tenants where supplier under-delivered against the slepok plan + // (shortfall = (expected - delivered) / expected > 20%). Orthogonal to + // webhook-loss drift above — same lead can be missing from CSV AND from + // delivered_count (compounding R-05.1 + R-05.2). + $this->detectAndAlertBusinessDrift($mailer, $windowStart, $windowEnd); + } catch (Throwable $e) { // $logId === null — упал сам insertGetId, log-строки нет, обновлять нечего. if ($logId !== null) { @@ -251,4 +258,65 @@ final class CsvReconcileJob implements ShouldQueue return null; } + + /** + * R-05 (Stage 4 §4.4.4) — business-drift second pass. + * + * Поверх существующего webhook-loss drift (R-05.1: «лид прилетел, мы webhook'а не + * получили») ищем business-drift (R-05.2: «лид прилетел, мы доставили не тому/никому»): + * для каждой пары (snapshot_date, tenant_id) считаем SUM(expected_volume) и + * SUM(delivered_count) по `project_routing_snapshots`, при shortfall > 20% шлём + * `TenantBusinessDriftAlertMail` админу. + * + * Окно — то же что у текущего CSV-reconcile run. Один email на тенанта на дату. + */ + private const BUSINESS_DRIFT_THRESHOLD = 0.20; + + private function detectAndAlertBusinessDrift( + Mailer $mailer, + \Carbon\CarbonInterface $windowStart, + \Carbon\CarbonInterface $windowEnd, + ): void { + $from = $windowStart->toDateString(); + $to = $windowEnd->toDateString(); + + $rows = DB::connection(self::DB_CONNECTION) + ->table('project_routing_snapshots') + ->whereBetween('snapshot_date', [$from, $to]) + ->groupBy('snapshot_date', 'tenant_id') + ->selectRaw('snapshot_date, tenant_id, SUM(expected_volume) AS expected, SUM(delivered_count) AS delivered') + ->havingRaw('SUM(expected_volume) > 0') + ->get(); + + foreach ($rows as $row) { + $expected = (int) $row->expected; + $delivered = (int) $row->delivered; + if ($expected <= 0) { + continue; + } + $shortfall = ($expected - $delivered) / $expected; + if ($shortfall <= self::BUSINESS_DRIFT_THRESHOLD) { + continue; + } + + $mailer->to((string) config('services.supplier.alert_email')) + ->send(new \App\Mail\TenantBusinessDriftAlertMail( + tenantId: (int) $row->tenant_id, + snapshotDate: (string) $row->snapshot_date, + expected: $expected, + delivered: $delivered, + shortfallRatio: $shortfall, + windowStart: $windowStart, + windowEnd: $windowEnd, + )); + + Log::warning('csv_reconcile.business_drift_alert', [ + 'tenant_id' => (int) $row->tenant_id, + 'snapshot_date' => (string) $row->snapshot_date, + 'expected' => $expected, + 'delivered' => $delivered, + 'shortfall' => $shortfall, + ]); + } + } } diff --git a/app/app/Mail/TenantBusinessDriftAlertMail.php b/app/app/Mail/TenantBusinessDriftAlertMail.php new file mode 100644 index 00000000..f948df03 --- /dev/null +++ b/app/app/Mail/TenantBusinessDriftAlertMail.php @@ -0,0 +1,51 @@ + порога (20%). + * + * Отдельно от CsvDriftAlertMail — тот ловит webhook-loss (CSV vs БД), + * этот — bizness-drift (snapshot.expected vs delivered). + * + * Stage 4 §4.4.4 R-05. + */ +final class TenantBusinessDriftAlertMail extends Mailable +{ + use Queueable; + use SerializesModels; + + public function __construct( + public readonly int $tenantId, + public readonly string $snapshotDate, + public readonly int $expected, + public readonly int $delivered, + public readonly float $shortfallRatio, + public readonly CarbonInterface $windowStart, + public readonly CarbonInterface $windowEnd, + ) {} + + public function envelope(): Envelope + { + $pct = number_format($this->shortfallRatio * 100, 1, ',', ' '); + + return new Envelope( + subject: "Лидерра ↔ Поставщик: business-shortfall tenant #{$this->tenantId} за {$this->snapshotDate} ({$pct}%)", + ); + } + + public function content(): Content + { + return new Content(view: 'emails.tenant_business_drift_alert'); + } +} diff --git a/app/resources/views/emails/tenant_business_drift_alert.blade.php b/app/resources/views/emails/tenant_business_drift_alert.blade.php new file mode 100644 index 00000000..6f4340ca --- /dev/null +++ b/app/resources/views/emails/tenant_business_drift_alert.blade.php @@ -0,0 +1,15 @@ + + +Tenant business drift alert + +

Business-shortfall тенанта Лидерры

+

Тенант #{{ $tenantId }}, дата слепка: {{ $snapshotDate }}

+ +

Окно сверки: {{ $windowStart->format('Y-m-d H:i') }} — {{ $windowEnd->format('Y-m-d H:i') }}

+

Проверь причину — поставщик не закрывает заказ, расхождение масок workdays или regions, либо проект потерял eligibility внутри slepok'а.

+ + diff --git a/app/tests/Feature/Supplier/CsvReconcileJobTest.php b/app/tests/Feature/Supplier/CsvReconcileJobTest.php index 79de07c5..52272732 100644 --- a/app/tests/Feature/Supplier/CsvReconcileJobTest.php +++ b/app/tests/Feature/Supplier/CsvReconcileJobTest.php @@ -134,7 +134,7 @@ it('no missing leads — status=ok, no recovery, no alert', function (): void { expect((int) $log->matched_count)->toBe(10); expect((int) $log->recovered_count)->toBe(0); - Mail::assertNothingSent(); + Mail::assertNotSent(CsvDriftAlertMail::class); // scoped — TenantBusinessDriftAlertMail may fire on leaked snapshots Bus::assertNothingDispatched(); }); @@ -197,7 +197,7 @@ it('1 missing of 100 (drift 1%) — recovery without alert', function (): void { $log = DB::table('supplier_csv_reconcile_log')->latest('id')->first(); expect($log->status)->toBe('ok'); expect((int) $log->recovered_count)->toBe(1); - Mail::assertNothingSent(); + Mail::assertNotSent(CsvDriftAlertMail::class); // scoped — TenantBusinessDriftAlertMail may fire on leaked snapshots }); it('dedup is keyed by (phone, project) — same phone on different project is NOT a duplicate', function (): void { @@ -296,7 +296,7 @@ it('unparseable CSV rows excluded from drift: 100 matched + 10 junk-project rows expect((float) $log->drift_ratio)->toBe(0.0); expect($log->status)->toBe('ok'); - Mail::assertNothingSent(); + Mail::assertNotSent(CsvDriftAlertMail::class); // scoped — TenantBusinessDriftAlertMail may fire on leaked snapshots }); it('mixed: 95 matched + 5 junk + 3 real-missing → unparseable_count=5, recovered=3, drift по реальным', function (): void { @@ -338,3 +338,78 @@ it('mixed: 95 matched + 5 junk + 3 real-missing → unparseable_count=5, recover expect((float) $log->drift_ratio)->toBeGreaterThan(0.0); expect($log->status)->toBe('ok'); }); + +// --------------------------------------------------------------------------- +// Stage 4 / Task 4.5 — R-05 (spec §4.4.4): business-drift second pass. +// After existing webhook-loss drift detection, CsvReconcileJob runs a second +// pass on project_routing_snapshots: per (snapshot_date, tenant_id) groups +// where (expected - delivered) / expected > 20% → TenantBusinessDriftAlertMail. +// This is orthogonal to webhook-loss drift (R-05.1) — same lead can be: +// - delivered & webhook OK (no alerts) +// - delivered & webhook miss (R-05.1 CsvDriftAlertMail) +// - not delivered at all (R-05.2 TenantBusinessDriftAlertMail — this task) +// --------------------------------------------------------------------------- + +function insertSnapshotForTenant(int $tenantId, string $date, int $expected, int $delivered): void +{ + $tenant = \App\Models\Tenant::find($tenantId) ?? \App\Models\Tenant::factory()->create(); + $project = \App\Models\Project::factory() + ->for($tenant) + ->asCallSignal('7977'.\Illuminate\Support\Str::random(7)) + ->create([ + 'is_active' => true, + 'daily_limit_target' => max($expected, 1), + ]); + \Illuminate\Support\Facades\DB::connection('pgsql_supplier') + ->table('project_routing_snapshots') + ->insert([ + 'snapshot_date' => $date, + 'project_id' => $project->id, + 'tenant_id' => $tenant->id, + 'daily_limit' => max($expected, 1), + 'delivery_days_mask' => 127, + 'regions' => '{}', + 'signal_type' => 'call', + 'signal_identifier' => $project->signal_identifier, + 'sms_senders' => null, + 'sms_keyword' => null, + 'expected_volume' => $expected, + 'delivered_count' => $delivered, + 'created_at' => now(), + ]); +} + +it('R-05 business-drift: tenant with shortfall > 20% → TenantBusinessDriftAlertMail sent', function (): void { + $tenant = \App\Models\Tenant::factory()->create(); + // Yesterday's snapshot: expected 10, delivered 2 → shortfall 80% (>20% threshold). + $yesterday = \Carbon\Carbon::yesterday('Europe/Moscow')->toDateString(); + insertSnapshotForTenant($tenant->id, $yesterday, 10, 2); + + // Empty CSV — primary drift pass is trivially OK; we exercise only the second pass. + fakeReportFlow(csvBody([])); + runCsvReconcile(); + + Mail::assertSent(\App\Mail\TenantBusinessDriftAlertMail::class, function ($mail) use ($tenant) { + return $mail->tenantId === $tenant->id + && $mail->expected === 10 + && $mail->delivered === 2 + && $mail->shortfallRatio >= 0.79 + && $mail->shortfallRatio <= 0.81; + }); +}); + +it('R-05 business-drift: tenant with shortfall <= 20% → NO TenantBusinessDriftAlertMail', function (): void { + $tenant = \App\Models\Tenant::factory()->create(); + // Yesterday's snapshot: expected 10, delivered 9 → shortfall 10% (<=20% threshold). + $yesterday = \Carbon\Carbon::yesterday('Europe/Moscow')->toDateString(); + insertSnapshotForTenant($tenant->id, $yesterday, 10, 9); + + fakeReportFlow(csvBody([])); + runCsvReconcile(); + + // Scoped assertion: prior-run leaked snapshots may fire mails for other tenants; + // this test only owns one tenant, so assert no mail was sent for IT. + Mail::assertNotSent(\App\Mail\TenantBusinessDriftAlertMail::class, function ($mail) use ($tenant) { + return $mail->tenantId === $tenant->id; + }); +});