208 lines
8.8 KiB
PHP
208 lines
8.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Jobs\RouteSupplierLeadJob;
|
|
use App\Models\Deal;
|
|
use App\Models\LeadCharge;
|
|
use App\Models\Project;
|
|
use App\Models\SupplierLead;
|
|
use App\Models\SupplierProject;
|
|
use App\Models\Tenant;
|
|
use App\Services\Billing\LedgerService;
|
|
use App\Services\LeadDistributor;
|
|
use App\Services\LeadRouter;
|
|
use App\Services\NotificationService;
|
|
use App\Services\RegionTagResolver;
|
|
use App\Services\SupplierProjects\SupplierProjectResolver;
|
|
use Database\Seeders\PricingTierSeeder;
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Random\Engine\Mt19937;
|
|
use Random\Randomizer;
|
|
use Tests\Concerns\SharesSupplierPdo;
|
|
|
|
uses(DatabaseTransactions::class);
|
|
uses(SharesSupplierPdo::class);
|
|
|
|
function runRouteJobB(int $id): void
|
|
{
|
|
(new RouteSupplierLeadJob($id))->handle(
|
|
app(LeadRouter::class),
|
|
app(SupplierProjectResolver::class),
|
|
app(NotificationService::class),
|
|
app(LedgerService::class),
|
|
app(LeadDistributor::class),
|
|
app(RegionTagResolver::class),
|
|
);
|
|
}
|
|
|
|
it('supplier_lead_deliveries table exists with PK (supplier_lead_id, tenant_id) and RLS', function (): void {
|
|
$cols = collect(DB::select(
|
|
"SELECT column_name FROM information_schema.columns WHERE table_name = 'supplier_lead_deliveries'"
|
|
))->pluck('column_name')->all();
|
|
expect($cols)->toContain('supplier_lead_id')
|
|
->toContain('tenant_id')
|
|
->toContain('deal_id')
|
|
->toContain('created_at');
|
|
|
|
$pk = collect(DB::select(
|
|
"SELECT a.attname FROM pg_index i
|
|
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
|
WHERE i.indrelid = 'supplier_lead_deliveries'::regclass AND i.indisprimary"
|
|
))->pluck('attname')->sort()->values()->all();
|
|
expect($pk)->toBe(['supplier_lead_id', 'tenant_id']);
|
|
|
|
$rls = DB::selectOne(
|
|
"SELECT relrowsecurity FROM pg_class WHERE relname = 'supplier_lead_deliveries'"
|
|
);
|
|
expect($rls->relrowsecurity)->toBeTrue();
|
|
});
|
|
|
|
it('one delivery to a tenant with 2 eligible projects → exactly 1 deal + 1 charge (max-remaining-limit tie-break)', function (): void {
|
|
$this->seed(PricingTierSeeder::class);
|
|
|
|
$sp = SupplierProject::factory()->create([
|
|
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'twoproj.ru',
|
|
]);
|
|
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
|
|
|
|
// Two eligible projects for the SAME tenant, different remaining limit.
|
|
$pLow = Project::factory()->create([
|
|
'tenant_id' => $tenant->id, 'is_active' => true,
|
|
'supplier_b1_project_id' => $sp->id,
|
|
'signal_type' => 'site', 'signal_identifier' => 'twoproj.ru',
|
|
'daily_limit_target' => 10, 'effective_daily_limit_today' => 10,
|
|
'delivered_today' => 9, 'delivery_days_mask' => 127, 'region_mask' => 255,
|
|
]);
|
|
$pHigh = Project::factory()->create([
|
|
'tenant_id' => $tenant->id, 'is_active' => true,
|
|
'supplier_b1_project_id' => $sp->id,
|
|
'signal_type' => 'site', 'signal_identifier' => 'twoproj.ru',
|
|
'daily_limit_target' => 10, 'effective_daily_limit_today' => 10,
|
|
'delivered_today' => 0, 'delivery_days_mask' => 127, 'region_mask' => 255,
|
|
]);
|
|
linkProjectToSupplier($pLow, $sp);
|
|
linkProjectToSupplier($pHigh, $sp);
|
|
|
|
$vid = 600001;
|
|
$lead = SupplierLead::factory()->create([
|
|
'supplier_project_id' => null, 'platform' => 'B1', 'vid' => $vid,
|
|
'phone' => '79991234567',
|
|
'raw_payload' => ['vid' => $vid, 'project' => 'B1_twoproj.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()],
|
|
]);
|
|
|
|
runRouteJobB($lead->id);
|
|
|
|
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
|
|
expect(Deal::query()->where('source_crm_id', $vid)->count())->toBe(1);
|
|
expect(LeadCharge::query()->where('tenant_id', $tenant->id)->count())->toBe(1);
|
|
// The project with most remaining limit was chosen.
|
|
expect($pHigh->fresh()->delivered_today)->toBe(1);
|
|
expect($pLow->fresh()->delivered_today)->toBe(9);
|
|
});
|
|
|
|
it('lock: re-running same delivery to same tenant does not double-charge (Spec B)', function (): void {
|
|
$this->seed(PricingTierSeeder::class);
|
|
|
|
$sp = SupplierProject::factory()->create([
|
|
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'lock.ru',
|
|
]);
|
|
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
|
|
$p = Project::factory()->create([
|
|
'tenant_id' => $tenant->id, 'is_active' => true,
|
|
'supplier_b1_project_id' => $sp->id,
|
|
'signal_type' => 'site', 'signal_identifier' => 'lock.ru',
|
|
'daily_limit_target' => 10, 'effective_daily_limit_today' => 10,
|
|
'delivered_today' => 0, 'delivery_days_mask' => 127, 'region_mask' => 255,
|
|
]);
|
|
linkProjectToSupplier($p, $sp);
|
|
|
|
$vid = 610001;
|
|
$lead = SupplierLead::factory()->create([
|
|
'supplier_project_id' => null, 'platform' => 'B1', 'vid' => $vid,
|
|
'phone' => '79991234567',
|
|
'raw_payload' => ['vid' => $vid, 'project' => 'B1_lock.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()],
|
|
]);
|
|
|
|
runRouteJobB($lead->id);
|
|
|
|
// Reset processed_at to force a SECOND pass (bypass the existing $lead->processed_at idempotency
|
|
// guard so we are testing the DB-level lock specifically).
|
|
$lead->update(['processed_at' => null]);
|
|
runRouteJobB($lead->id);
|
|
|
|
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
|
|
expect(Deal::query()->where('source_crm_id', $vid)->count())->toBe(1);
|
|
expect(LeadCharge::query()->where('tenant_id', $tenant->id)->count())->toBe(1);
|
|
expect(DB::table('supplier_lead_deliveries')
|
|
->where('supplier_lead_id', $lead->id)->where('tenant_id', $tenant->id)->count())->toBe(1);
|
|
// Balance debited exactly once.
|
|
expect((string) $tenant->fresh()->balance_rub)->toBe('99500.00');
|
|
});
|
|
|
|
it('same phone, two different deliveries to one tenant → both charged (no phone dedup, Spec B)', function (): void {
|
|
$this->seed(PricingTierSeeder::class);
|
|
|
|
$sp = SupplierProject::factory()->create([
|
|
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'twohit.ru',
|
|
]);
|
|
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
|
|
$p = Project::factory()->create([
|
|
'tenant_id' => $tenant->id, 'is_active' => true,
|
|
'supplier_b1_project_id' => $sp->id,
|
|
'signal_type' => 'site', 'signal_identifier' => 'twohit.ru',
|
|
'daily_limit_target' => 10, 'effective_daily_limit_today' => 10,
|
|
'delivered_today' => 0, 'delivery_days_mask' => 127, 'region_mask' => 255,
|
|
]);
|
|
linkProjectToSupplier($p, $sp);
|
|
|
|
foreach ([700001, 700002] as $vid) {
|
|
$lead = SupplierLead::factory()->create([
|
|
'supplier_project_id' => null, 'platform' => 'B1', 'vid' => $vid,
|
|
'phone' => '79991234567',
|
|
'raw_payload' => ['vid' => $vid, 'project' => 'B1_twohit.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()],
|
|
]);
|
|
runRouteJobB($lead->id);
|
|
}
|
|
|
|
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
|
|
expect(Deal::query()->where('tenant_id', $tenant->id)->whereIn('source_crm_id', [700001, 700002])->count())->toBe(2);
|
|
expect(LeadCharge::query()->where('tenant_id', $tenant->id)->count())->toBe(2);
|
|
expect((string) $tenant->fresh()->balance_rub)->toBe('99000.00');
|
|
});
|
|
|
|
it('cap = 3 distinct tenants: 5 eligible tenants → exactly 3 charged (Spec B)', function (): void {
|
|
$this->seed(PricingTierSeeder::class);
|
|
app()->bind(LeadDistributor::class, fn () => new LeadDistributor(new Randomizer(new Mt19937(7))));
|
|
|
|
$sp = SupplierProject::factory()->create([
|
|
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'cap3.ru',
|
|
]);
|
|
foreach (range(1, 5) as $i) {
|
|
$t = Tenant::factory()->create(['balance_rub' => '100000.00']);
|
|
$p = Project::factory()->create([
|
|
'tenant_id' => $t->id, 'is_active' => true,
|
|
'supplier_b1_project_id' => $sp->id,
|
|
'signal_type' => 'site', 'signal_identifier' => 'cap3.ru',
|
|
'daily_limit_target' => 10, 'effective_daily_limit_today' => 10,
|
|
'delivered_today' => 0, 'delivery_days_mask' => 127, 'region_mask' => 255,
|
|
]);
|
|
linkProjectToSupplier($p, $sp);
|
|
}
|
|
|
|
$vid = 710001;
|
|
$lead = SupplierLead::factory()->create([
|
|
'supplier_project_id' => null, 'platform' => 'B1', 'vid' => $vid,
|
|
'phone' => '79991234567',
|
|
'raw_payload' => ['vid' => $vid, 'project' => 'B1_cap3.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()],
|
|
]);
|
|
|
|
runRouteJobB($lead->id);
|
|
|
|
$lead->refresh();
|
|
expect($lead->deals_created_count)->toBe(3);
|
|
expect(DB::table('supplier_lead_deliveries')->where('supplier_lead_id', $lead->id)->count())->toBe(3);
|
|
expect(DB::table('supplier_lead_deliveries')->where('supplier_lead_id', $lead->id)->distinct()->count('tenant_id'))->toBe(3);
|
|
});
|