feat(supplier): R-05 — business-drift second pass in CsvReconcileJob
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Email алерт админу Лидерры о business-shortfall'е тенанта: snapshot ожидал
|
||||
* объём X, фактически доставили Y и (X-Y)/X > порога (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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="UTF-8"><title>Tenant business drift alert</title></head>
|
||||
<body style="font-family: Arial, sans-serif;">
|
||||
<h3>Business-shortfall тенанта Лидерры</h3>
|
||||
<p>Тенант <strong>#{{ $tenantId }}</strong>, дата слепка: <strong>{{ $snapshotDate }}</strong></p>
|
||||
<ul>
|
||||
<li>Ожидалось по слепку: <strong>{{ $expected }}</strong> лидов</li>
|
||||
<li>Доставлено фактически: <strong>{{ $delivered }}</strong> лидов</li>
|
||||
<li>Shortfall ratio: <strong>{{ number_format($shortfallRatio * 100, 1, ',', ' ') }}%</strong> (порог 20%)</li>
|
||||
</ul>
|
||||
<p>Окно сверки: <strong>{{ $windowStart->format('Y-m-d H:i') }} — {{ $windowEnd->format('Y-m-d H:i') }}</strong></p>
|
||||
<p>Проверь причину — поставщик не закрывает заказ, расхождение масок workdays или regions, либо проект потерял eligibility внутри slepok'а.</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user