Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cfc67fbc26 | |||
| 737a78f251 | |||
| d82b1bf17c | |||
| 8be1f9d172 | |||
| c9f25cd833 | |||
| fc2b517edc | |||
| bb6f2ae0d6 | |||
| 7ffd79299f | |||
| 1cf4c53d8d | |||
| 5bb3f9c3dd | |||
| 77d8a9dfa8 | |||
| 7b0a61803c | |||
| f4e152de15 |
@@ -83,7 +83,7 @@ class SupplierWebhookController extends Controller
|
||||
|
||||
$validated = $request->validate([
|
||||
'vid' => 'required|integer|min:1',
|
||||
'project' => ['required', 'string', 'max:255', 'regex:/^B[123]_.+$/'],
|
||||
'project' => ['required', 'string', 'max:255'], // Phase 3: regex /^B[123]_.+$/ снят — non-B → platform=DIRECT
|
||||
'phone' => ['required', 'string', 'regex:/^7\d{10}$/'],
|
||||
'time' => ['required', 'integer', "min:{$minTime}", "max:{$maxTime}"],
|
||||
'tag' => 'nullable|string|max:255',
|
||||
@@ -182,8 +182,12 @@ class SupplierWebhookController extends Controller
|
||||
|
||||
private function parsePlatform(string $project): string
|
||||
{
|
||||
preg_match('/^(B[123])_/', $project, $m);
|
||||
// Phase 3: проекты без B-префикса → DIRECT (раньше silent fallback на 'B1'
|
||||
// приводил к неверной маршрутизации).
|
||||
if (preg_match('/^(B[123])_/', $project, $m) === 1) {
|
||||
return $m[1];
|
||||
}
|
||||
|
||||
return $m[1] ?? 'B1';
|
||||
return 'DIRECT';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,11 +171,16 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
*/
|
||||
private function parseProjectField(string $project): array
|
||||
{
|
||||
if (preg_match('/^(B[123])_(.+)$/', $project, $m) !== 1) {
|
||||
throw new RuntimeException("Cannot parse supplier project field: '{$project}'");
|
||||
if (preg_match('/^(B[123])_(.+)$/', $project, $m) === 1) {
|
||||
$platform = $m[1];
|
||||
$rest = $m[2];
|
||||
} else {
|
||||
// Phase 3: проекты без B-префикса попадают в DIRECT.
|
||||
// Весь project считается identifier-частью; signal_type определяется
|
||||
// тем же regex'ом, что для $rest у B-префиксных.
|
||||
$platform = 'DIRECT';
|
||||
$rest = $project;
|
||||
}
|
||||
$platform = $m[1];
|
||||
$rest = $m[2];
|
||||
|
||||
// Домен с латинским TLD ≥2 букв (последний сегмент — только буквы), допускается
|
||||
// в любой позиции строки. Соответствует чистому rest и встроенному в текст домену.
|
||||
@@ -245,6 +250,57 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
}
|
||||
$project = $lockedProject;
|
||||
|
||||
// Phase 2 fix: merge с CSV-recovered deal если webhook догоняет.
|
||||
// Идемпотентность race condition между CsvReconcileJob (vid=NULL, recovered
|
||||
// from CSV) и webhook (vid=int, реальный supplier-id). До этой проверки они
|
||||
// создавали 2 deal'a (DD снят Spec B Phase 1). Merge выполняется только если:
|
||||
// - webhook ЕСТЬ настоящий vid (lead.vid !== null) — без vid merge'ить нечего;
|
||||
// - csv-recovered deal существует за последние 24h, тот же phone+project+tenant;
|
||||
// - csv-recovered deal БЕЗ source_crm_id (т.е. он именно CSV-recovered, не другой webhook).
|
||||
// При merge: UPDATE existing.source_crm_id, INSERT supplier_lead_deliveries,
|
||||
// БЕЗ chargeForDelivery (LeadCharge уже есть с момента CSV recovery).
|
||||
$existingMergeable = null;
|
||||
if ($lead->vid !== null) {
|
||||
$existingMergeable = Deal::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('phone', (string) $lead->phone)
|
||||
->where('project_id', $project->id)
|
||||
->whereNull('source_crm_id')
|
||||
->where('received_at', '>=', now()->subDay())
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
}
|
||||
if ($existingMergeable !== null) {
|
||||
// Заполняем supplier_lead.id у обоих SupplierLead → одному Deal
|
||||
DB::table('supplier_lead_deliveries')->insert([
|
||||
'supplier_lead_id' => $lead->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'deal_id' => $existingMergeable->id,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
// Обновляем source_crm_id и опционально received_at через
|
||||
// DB::table (надёжнее Eloquent save() на партиционированной таблице).
|
||||
$newReceivedAt = ($lead->received_at !== null && $lead->received_at->gt($existingMergeable->received_at))
|
||||
? $lead->received_at
|
||||
: null;
|
||||
$updateData = ['source_crm_id' => $lead->vid, 'updated_at' => now()];
|
||||
if ($newReceivedAt !== null) {
|
||||
$updateData['received_at'] = $newReceivedAt;
|
||||
}
|
||||
DB::table('deals')
|
||||
->where('id', $existingMergeable->id)
|
||||
->where('received_at', $existingMergeable->received_at)
|
||||
->update($updateData);
|
||||
|
||||
Log::info('supplier_lead.merged_into_csv_recovered', [
|
||||
'supplier_lead_id' => $lead->id,
|
||||
'merged_into_deal_id' => $existingMergeable->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
]);
|
||||
|
||||
return true; // считаем «доставленным», но без второго списания
|
||||
}
|
||||
|
||||
// Spec B: per-(supplier_lead, tenant) lock — одна поставка одному клиенту = один раз.
|
||||
// insertOrIgnore вернёт 0, если строка уже существует (повтор/гонка/CSV-recovery).
|
||||
$locked = DB::table('supplier_lead_deliveries')->insertOrIgnore([
|
||||
|
||||
@@ -231,14 +231,23 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает platform (B1/B2/B3) из имени проекта формата `B[123]_<rest>`.
|
||||
* Возвращает null если не парсится — caller пропустит строку с warning.
|
||||
* Извлекает platform из имени проекта:
|
||||
* - `B[123]_<rest>` → 'B1' / 'B2' / 'B3';
|
||||
* - Phase 3: иначе, если строка непустая и состоит из identifier-символов
|
||||
* (домены / телефоны / SMS-отправители) → 'DIRECT';
|
||||
* - откровенный мусор (только спец-символы, пусто) → null (unparseable).
|
||||
*/
|
||||
private function extractPlatform(string $project): ?string
|
||||
{
|
||||
if (preg_match('/^(B[123])_/', $project, $m) === 1) {
|
||||
return $m[1];
|
||||
}
|
||||
// Phase 3: всё что выглядит как разумный identifier (домен / телефон / SMS-sender) → DIRECT.
|
||||
// unparseable_count теперь только для откровенного мусора (пустые / только спец-символы).
|
||||
$trimmed = trim($project);
|
||||
if ($trimmed !== '' && preg_match('/^[\w\-.а-яА-Я0-9\/() +]+$/u', $trimmed) === 1) {
|
||||
return 'DIRECT';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -128,10 +128,17 @@ final class LedgerService
|
||||
{
|
||||
if ($lead->supplier_project_id !== null) {
|
||||
$sp = DB::table('supplier_projects')->where('id', $lead->supplier_project_id)->first();
|
||||
if ($sp !== null && in_array($sp->platform, ['B1', 'B2', 'B3'], true)) {
|
||||
$supplier = Supplier::where('code', strtolower($sp->platform))->first();
|
||||
if ($supplier !== null) {
|
||||
return (int) $supplier->id;
|
||||
if ($sp !== null) {
|
||||
if (in_array($sp->platform, ['B1', 'B2', 'B3'], true)) {
|
||||
$supplier = Supplier::where('code', strtolower($sp->platform))->first();
|
||||
if ($supplier !== null) {
|
||||
return (int) $supplier->id;
|
||||
}
|
||||
}
|
||||
if ($sp->platform === 'DIRECT') {
|
||||
$supplier = Supplier::where('code', 'direct')->first();
|
||||
|
||||
return $supplier?->id;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,6 +150,12 @@ final class LedgerService
|
||||
|
||||
return $supplier?->id;
|
||||
}
|
||||
// Phase 3: project без B-префикса (и не пустой) → DIRECT.
|
||||
if ($project !== '') {
|
||||
$supplier = Supplier::where('code', 'direct')->first();
|
||||
|
||||
return $supplier?->id;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,39 @@ class LeadRouter
|
||||
// МСК-aligned ISO day-of-week (reset-cron тоже 00:00 МСК).
|
||||
$todayBit = 1 << (Carbon::now('Europe/Moscow')->isoWeekday() - 1);
|
||||
|
||||
// Phase 3: для DIRECT-supplier_project — fallback на signal_type+signal_identifier
|
||||
// match с Лидерра-проектами, потому что project_supplier_links для DIRECT-row'ов
|
||||
// не создаются (новые DIRECT supplier_projects создаются автоматически при
|
||||
// получении webhook'а без B-префикса; explicit psl-link для них не настраивается).
|
||||
if ($supplierProject->platform === 'DIRECT') {
|
||||
$directSql = <<<'SQL'
|
||||
SELECT DISTINCT ON (projects.tenant_id) projects.*
|
||||
FROM projects
|
||||
WHERE projects.signal_type = ?
|
||||
AND LOWER(projects.signal_identifier) = LOWER(?)
|
||||
AND projects.is_active = true
|
||||
AND (projects.delivery_days_mask & ?) <> 0
|
||||
AND projects.delivered_today < COALESCE(projects.effective_daily_limit_today, projects.daily_limit_target)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM tenants
|
||||
WHERE tenants.id = projects.tenant_id
|
||||
AND (tenants.balance_leads > 0 OR tenants.balance_rub > 0)
|
||||
)
|
||||
ORDER BY
|
||||
projects.tenant_id,
|
||||
(COALESCE(projects.effective_daily_limit_today, projects.daily_limit_target) - projects.delivered_today) DESC,
|
||||
projects.created_at,
|
||||
projects.id
|
||||
SQL;
|
||||
$directRows = DB::connection('pgsql_supplier')->select(
|
||||
$directSql,
|
||||
[$supplierProject->signal_type, $supplierProject->unique_key, $todayBit]
|
||||
);
|
||||
|
||||
return Project::hydrate($directRows)->values();
|
||||
}
|
||||
|
||||
// Existing B1/B2/B3 path — explicit project_supplier_links pivot.
|
||||
$sql = <<<'SQL'
|
||||
SELECT DISTINCT ON (projects.tenant_id) projects.*
|
||||
FROM projects
|
||||
|
||||
@@ -21,7 +21,7 @@ use InvalidArgumentException;
|
||||
*/
|
||||
class SupplierProjectResolver
|
||||
{
|
||||
private const ALLOWED_PLATFORMS = ['B1', 'B2', 'B3'];
|
||||
private const ALLOWED_PLATFORMS = ['B1', 'B2', 'B3', 'DIRECT'];
|
||||
|
||||
private const ALLOWED_SIGNAL_TYPES = ['site', 'call', 'sms'];
|
||||
|
||||
|
||||
@@ -47,4 +47,18 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
|
||||
return null; // default render for non-JSON
|
||||
});
|
||||
|
||||
// Supplier webhook always returns JSON, even when client omits Accept header.
|
||||
// Without this render, Laravel's default ValidationException handler returns
|
||||
// 302 redirect to /, which strips POST body — losing supplier leads.
|
||||
// Confirmed 2026-05-25: 76 of 234 webhook hits today got 302 instead of 422.
|
||||
$exceptions->render(function (\Illuminate\Validation\ValidationException $e, Request $request) {
|
||||
if ($request->is('api/webhook/supplier/*')) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed',
|
||||
'errors' => $e->errors(),
|
||||
], 422);
|
||||
}
|
||||
return null; // default render for other routes
|
||||
});
|
||||
})->create();
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Phase 3 supplier webhook reliability — расширяет platform enum в
|
||||
* supplier_projects и project_supplier_links до (B1,B2,B3,DIRECT).
|
||||
*
|
||||
* DIRECT — это «прямая» платформа поставщика без B-префикса в имени
|
||||
* проекта (e.g. `client.carmoney.ru`, `cashmotor.ru`, числовые телефоны).
|
||||
* До Phase 3 такие webhook'и отвергались с 302-редиректом и терялись:
|
||||
* наблюдалось 67 потерь/день на проде 25.05.2026 для tenant client1.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-25-supplier-webhook-reliability-design.md §3 Phase 3
|
||||
*
|
||||
* NB: chk_supplier_projects_b1_not_for_sms (B1+SMS deny) НЕ трогаем —
|
||||
* DIRECT+SMS этим constraint'ом не блокируется (он специфичен для B1).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// 1) Расширить platform-колонки до VARCHAR(8) (было VARCHAR(4): "DIRECT" не вмещается).
|
||||
// supplier_manual_sync_queue.platform уже VARCHAR(8) — пропускаем.
|
||||
DB::statement('ALTER TABLE supplier_projects ALTER COLUMN platform TYPE VARCHAR(8)');
|
||||
DB::statement('ALTER TABLE project_supplier_links ALTER COLUMN platform TYPE VARCHAR(8)');
|
||||
DB::statement('ALTER TABLE supplier_leads ALTER COLUMN platform TYPE VARCHAR(8)');
|
||||
|
||||
// 2) Расширить CHECK constraints на enum значения.
|
||||
DB::statement('ALTER TABLE supplier_projects DROP CONSTRAINT chk_supplier_projects_platform');
|
||||
DB::statement("ALTER TABLE supplier_projects ADD CONSTRAINT chk_supplier_projects_platform CHECK (platform IN ('B1','B2','B3','DIRECT'))");
|
||||
|
||||
DB::statement('ALTER TABLE project_supplier_links DROP CONSTRAINT chk_psl_platform');
|
||||
DB::statement("ALTER TABLE project_supplier_links ADD CONSTRAINT chk_psl_platform CHECK (platform IN ('B1','B2','B3','DIRECT'))");
|
||||
|
||||
DB::statement('ALTER TABLE supplier_leads DROP CONSTRAINT chk_supplier_leads_platform');
|
||||
DB::statement("ALTER TABLE supplier_leads ADD CONSTRAINT chk_supplier_leads_platform CHECK (platform IN ('B1','B2','B3','DIRECT'))");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Перед откатом — убедиться что в БД нет rows с platform='DIRECT',
|
||||
// иначе constraint провалится при ADD. Это ответственность того, кто
|
||||
// запускает migrate:rollback. На prod — отдельный cleanup SQL до отката:
|
||||
// DELETE FROM project_supplier_links WHERE platform='DIRECT';
|
||||
// DELETE FROM supplier_projects WHERE platform='DIRECT';
|
||||
// DELETE FROM supplier_leads WHERE platform='DIRECT';
|
||||
DB::statement('ALTER TABLE supplier_projects DROP CONSTRAINT chk_supplier_projects_platform');
|
||||
DB::statement("ALTER TABLE supplier_projects ADD CONSTRAINT chk_supplier_projects_platform CHECK (platform IN ('B1','B2','B3'))");
|
||||
|
||||
DB::statement('ALTER TABLE project_supplier_links DROP CONSTRAINT chk_psl_platform');
|
||||
DB::statement("ALTER TABLE project_supplier_links ADD CONSTRAINT chk_psl_platform CHECK (platform IN ('B1','B2','B3'))");
|
||||
|
||||
DB::statement('ALTER TABLE supplier_leads DROP CONSTRAINT chk_supplier_leads_platform');
|
||||
DB::statement("ALTER TABLE supplier_leads ADD CONSTRAINT chk_supplier_leads_platform CHECK (platform IN ('B1','B2','B3'))");
|
||||
|
||||
// Сужение TYPE обратно к VARCHAR(4) — только если все значения помещаются (B1/B2/B3 = 2 символа).
|
||||
DB::statement('ALTER TABLE supplier_leads ALTER COLUMN platform TYPE VARCHAR(4)');
|
||||
DB::statement('ALTER TABLE project_supplier_links ALTER COLUMN platform TYPE VARCHAR(4)');
|
||||
DB::statement('ALTER TABLE supplier_projects ALTER COLUMN platform TYPE VARCHAR(4)');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Phase 3 — DIRECT supplier row (used by LedgerService::resolveSupplierId
|
||||
* fallback for platform='DIRECT'). cost_rub matches B1 (same supplier,
|
||||
* different routing).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-25-supplier-webhook-reliability-design.md §3 Phase 3
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$b1 = DB::table('suppliers')->where('code', 'b1')->first();
|
||||
if ($b1 === null) {
|
||||
// Если B1 нет — significant prod drift, не должно произойти.
|
||||
// Создаём с дефолтным cost_rub=1.00 (как на prod 25.05.2026).
|
||||
$costRub = '1.00';
|
||||
} else {
|
||||
$costRub = (string) $b1->cost_rub;
|
||||
}
|
||||
|
||||
// Используем raw SQL чтобы корректно сериализовать PG-array для accepts_types.
|
||||
DB::insert(
|
||||
"INSERT INTO suppliers (code, name, accepts_types, cost_rub, channel, is_active, sort_order, created_at)
|
||||
VALUES (?, ?, ARRAY['websites','calls','sms'], ?, ?, true, 4, NOW())
|
||||
ON CONFLICT (code) DO NOTHING",
|
||||
[
|
||||
'direct',
|
||||
'DIRECT — Прямые проекты',
|
||||
$costRub,
|
||||
'sites', // принимает любые сигналы; channel='sites' допустим в suppliers_channel_check
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::table('suppliers')->where('code', 'direct')->delete();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\SystemSetting;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
SystemSetting::query()
|
||||
->where('key', 'supplier_webhook_secret')
|
||||
->update(['value' => 'test-secret-32chars-aaaaaaaaaaaaaa']);
|
||||
SystemSetting::query()
|
||||
->where('key', 'supplier_ip_allowlist')
|
||||
->update(['value' => '[]']);
|
||||
});
|
||||
|
||||
it('returns 422 JSON when supplier posts invalid payload WITHOUT Accept: application/json header', function () {
|
||||
// Воспроизводит реальное поведение crm.bp-gr.ru: POST без Accept-JSON.
|
||||
// До фикса (302→422) Laravel редиректил на / с Set-Cookie, поставщик
|
||||
// терял тело запроса. После фикса всегда JSON.
|
||||
$response = $this->call(
|
||||
'POST',
|
||||
'/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa',
|
||||
[], // params
|
||||
[], // cookies
|
||||
[], // files
|
||||
['HTTP_CONTENT_TYPE' => 'application/x-www-form-urlencoded'], // server: НЕТ Accept JSON
|
||||
http_build_query([
|
||||
'vid' => 1,
|
||||
'project' => 'invalid_no_b_prefix',
|
||||
'phone' => '79991234567',
|
||||
'time' => time(),
|
||||
])
|
||||
);
|
||||
|
||||
$response->assertStatus(422);
|
||||
expect($response->headers->get('Content-Type'))->toContain('application/json');
|
||||
$response->assertJsonStructure(['message', 'errors' => ['project']]);
|
||||
});
|
||||
|
||||
it('still works correctly for postJson clients (regression)', function () {
|
||||
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
|
||||
'vid' => 1,
|
||||
'project' => 'invalid_no_b_prefix',
|
||||
'phone' => '79991234567',
|
||||
'time' => time(),
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)->assertJsonValidationErrors('project');
|
||||
});
|
||||
|
||||
it('non-webhook routes still use default render (no JSON forced)', function () {
|
||||
// Регрессионный тест: дефолтный render остальных routes не сломан
|
||||
// (например /login — должен возвращать redirect, а не JSON).
|
||||
$response = $this->call(
|
||||
'POST',
|
||||
'/login',
|
||||
['email' => 'bad', 'password' => ''],
|
||||
[], [], [],
|
||||
);
|
||||
// Любой не-200 кроме 422-JSON допустим — главное чтобы наш fix не перехватил
|
||||
expect($response->headers->get('Content-Type'))->not->toContain('application/json');
|
||||
});
|
||||
@@ -272,14 +272,16 @@ it('unparseable CSV rows excluded from drift: 100 matched + 10 junk-project rows
|
||||
]);
|
||||
}
|
||||
|
||||
// CSV: те же 100 (matched) + 10 строк с мусорным project (extractPlatform = null).
|
||||
// Это реальный паттерн поставщика — телефон в поле «Name» вместо проекта (см. 22.05 в ПИЛОТ).
|
||||
// CSV: те же 100 (matched) + 10 строк с настоящим мусорным project (extractPlatform = null).
|
||||
// Phase 3 (2026-05-25): расширили DIRECT-распознавание — теперь цифровые callback-проекты
|
||||
// (79135551234) — валидный DIRECT, не junk. Реальный junk — это символы вне whitelist regex.
|
||||
$rows = [];
|
||||
for ($i = 0; $i < 100; $i++) {
|
||||
$rows[] = ['project' => 'B1_a.com', 'phone' => '79993'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)];
|
||||
}
|
||||
for ($j = 0; $j < 10; $j++) {
|
||||
$rows[] = ['project' => '79135551234', 'phone' => '7999500000'.$j];
|
||||
$junkProjects = ['???', '!@#', '%%%', '$$$', '???!!!', '~~~', '***', '|||', '^^^', '&&&'];
|
||||
foreach ($junkProjects as $j => $junk) {
|
||||
$rows[] = ['project' => $junk, 'phone' => '7999500000'.$j];
|
||||
}
|
||||
fakeReportFlow(csvBody($rows));
|
||||
|
||||
@@ -314,8 +316,10 @@ it('mixed: 95 matched + 5 junk + 3 real-missing → unparseable_count=5, recover
|
||||
for ($i = 0; $i < 95; $i++) {
|
||||
$rows[] = ['project' => 'B1_a.com', 'phone' => '79994'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)];
|
||||
}
|
||||
for ($j = 0; $j < 5; $j++) {
|
||||
$rows[] = ['project' => 'https://junk.example/'.$j, 'phone' => '7999600000'.$j];
|
||||
// Phase 3: реальный junk — символы вне whitelist (не \w/.-/cyrillic/digits/slash/parens/space/plus).
|
||||
$junkProjects = ['???', '!!!@@@', '%%%', '****', '???!!!'];
|
||||
foreach ($junkProjects as $j => $junk) {
|
||||
$rows[] = ['project' => $junk, 'phone' => '7999600000'.$j];
|
||||
}
|
||||
for ($k = 0; $k < 3; $k++) {
|
||||
$rows[] = ['project' => 'B1_a.com', 'phone' => '7999700000'.$k];
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
<?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 Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
/**
|
||||
* Phase 2 — webhook ↔ CSV-recovered idempotency.
|
||||
*
|
||||
* Сценарий (наблюдался на prod 2026-05-25, 37 дублей tenant client1):
|
||||
* 1. Поставщик шлёт webhook → 302 (теряется тело) — Phase 1 уже починила.
|
||||
* 2. CsvReconcileJob через 30 мин видит лид в CSV, не находит supplier_lead
|
||||
* по (phone, project) → создаёт recovered SupplierLead (vid=NULL,
|
||||
* source='csv_recovery') → RouteSupplierLeadJob → Deal с source_crm_id=NULL.
|
||||
* 3. Поставщик ретраит webhook (ещё 15 мин) → новый SupplierLead с vid=<int>
|
||||
* → RouteSupplierLeadJob → создаёт второй Deal с тем же phone+project
|
||||
* → биллинг списывает второй раз.
|
||||
*
|
||||
* Phase 2 fix: шаг 3 находит существующий CSV-recovered deal, обновляет
|
||||
* source_crm_id, привязывает webhook supplier_lead к существующему deal через
|
||||
* supplier_lead_deliveries, НЕ создаёт второй Deal, НЕ списывает повторно.
|
||||
*/
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->seed(PricingTierSeeder::class);
|
||||
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
|
||||
|
||||
// Shared supplier_project для всех тестов (B1, site, domain race-csv.ru).
|
||||
$this->sp = SupplierProject::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'race-csv.ru',
|
||||
]);
|
||||
|
||||
$this->tenant = Tenant::factory()->create([
|
||||
'balance_rub' => '10000.00',
|
||||
'delivered_in_month' => 0,
|
||||
]);
|
||||
|
||||
$this->project = Project::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'race-csv.ru',
|
||||
'supplier_b1_project_id' => $this->sp->id,
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 100,
|
||||
'effective_daily_limit_today' => 100,
|
||||
'delivered_today' => 0,
|
||||
'delivery_days_mask' => 127,
|
||||
'region_mask' => 255,
|
||||
]);
|
||||
|
||||
linkProjectToSupplier($this->project, $this->sp);
|
||||
});
|
||||
|
||||
/**
|
||||
* Dispatch helper — mirrors runRouteJob() / dispatchJob() from other test files.
|
||||
*/
|
||||
function runRaceJob(int $supplierLeadId): void
|
||||
{
|
||||
(new RouteSupplierLeadJob($supplierLeadId))->handle(
|
||||
app(LeadRouter::class),
|
||||
app(SupplierProjectResolver::class),
|
||||
app(NotificationService::class),
|
||||
app(LedgerService::class),
|
||||
app(LeadDistributor::class),
|
||||
app(RegionTagResolver::class),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 1 — Main bug reproduction: CSV-recovery followed by webhook retry
|
||||
// ДОЛЖЕН дать 1 deal + 1 charge (сейчас даёт 2+2 → FAILING).
|
||||
// ---------------------------------------------------------------------------
|
||||
it('webhook after CSV-recovered merges into existing deal (no duplicate, no double-charge)', function (): void {
|
||||
$phone = '79991000001';
|
||||
|
||||
// ── Step 1: CSV-recovered SupplierLead (vid=null, source='csv_recovery') ──
|
||||
// Это то, что CsvReconcileJob создаёт: звонок найден в CSV поставщика,
|
||||
// но настоящего webhook_log'а нет → вид неизвестен (vid=null).
|
||||
$csvLead = SupplierLead::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'phone' => $phone,
|
||||
'vid' => null,
|
||||
'supplier_project_id' => $this->sp->id,
|
||||
'raw_payload' => [
|
||||
'project' => 'B1_race-csv.ru',
|
||||
'phone' => $phone,
|
||||
'time' => now()->subHour()->getTimestamp(),
|
||||
],
|
||||
'received_at' => now()->subHour(),
|
||||
'recovered_from_csv_at' => now()->subHour(),
|
||||
'source' => 'csv_recovery',
|
||||
'processed_at' => null,
|
||||
]);
|
||||
|
||||
// RouteSupplierLeadJob обрабатывает CSV-recovered лид → создаёт Deal с source_crm_id=NULL.
|
||||
runRaceJob($csvLead->id);
|
||||
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant->id}'");
|
||||
$csvDeal = Deal::where('phone', $phone)->first();
|
||||
expect($csvDeal)->not->toBeNull('CSV recovery должен был создать Deal');
|
||||
expect($csvDeal->source_crm_id)->toBeNull('CSV-recovered deal должен иметь source_crm_id=NULL');
|
||||
|
||||
$chargesAfterCsv = LeadCharge::where('deal_id', $csvDeal->id)->count();
|
||||
expect($chargesAfterCsv)->toBe(1, 'После CSV-recovery должен быть ровно 1 LeadCharge');
|
||||
|
||||
$balanceAfterCsv = (string) $this->tenant->fresh()->balance_rub;
|
||||
|
||||
// ── Step 2: поставщик ретраит webhook 15 мин спустя с настоящим vid ──
|
||||
// Это то, что создаёт дубль на проде: новый SupplierLead с vid != null,
|
||||
// phone + project те же → RouteSupplierLeadJob создаёт ВТОРОЙ Deal.
|
||||
$webhookLead = SupplierLead::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'phone' => $phone,
|
||||
'vid' => 1672819986,
|
||||
'supplier_project_id' => $this->sp->id,
|
||||
'raw_payload' => [
|
||||
'vid' => 1672819986,
|
||||
'project' => 'B1_race-csv.ru',
|
||||
'phone' => $phone,
|
||||
'time' => now()->subMinutes(15)->getTimestamp(),
|
||||
],
|
||||
'received_at' => now()->subMinutes(15),
|
||||
'source' => 'webhook',
|
||||
'processed_at' => null,
|
||||
]);
|
||||
|
||||
runRaceJob($webhookLead->id);
|
||||
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant->id}'");
|
||||
|
||||
// ── Assertions ──
|
||||
|
||||
// Assertion 1: по-прежнему ОДИН deal, но source_crm_id теперь заполнен.
|
||||
$deals = Deal::where('phone', $phone)->get();
|
||||
expect($deals)->toHaveCount(1, 'Phase 2: webhook после CSV-recovery должен ОБНОВИТЬ существующий deal, а не создать второй');
|
||||
expect($deals->first()->source_crm_id)->toBe(1672819986, 'source_crm_id должен быть обновлён от webhook vid');
|
||||
|
||||
// Assertion 2: НЕТ второго LeadCharge — биллинг не списывается дважды.
|
||||
$chargesAfterWebhook = LeadCharge::where('deal_id', $csvDeal->id)->count();
|
||||
expect($chargesAfterWebhook)->toBe(1, 'Phase 2: второй LeadCharge создан не должен быть');
|
||||
|
||||
// Assertion 3: баланс НЕ списан второй раз.
|
||||
$balanceAfterWebhook = (string) $this->tenant->fresh()->balance_rub;
|
||||
expect($balanceAfterWebhook)->toBe($balanceAfterCsv, 'Phase 2: баланс после webhook не должен уменьшиться');
|
||||
|
||||
// Assertion 4: supplier_lead_deliveries содержит ОБА supplier_lead_id,
|
||||
// привязанных к ОДНОМУ deal_id.
|
||||
$deliveries = DB::table('supplier_lead_deliveries')
|
||||
->where('deal_id', $csvDeal->id)
|
||||
->get();
|
||||
expect($deliveries)->toHaveCount(2, 'Оба SupplierLead (csv + webhook) должны быть в supplier_lead_deliveries');
|
||||
$deliveredLeadIds = $deliveries->pluck('supplier_lead_id')->sort()->values()->all();
|
||||
expect($deliveredLeadIds)->toContain($csvLead->id);
|
||||
expect($deliveredLeadIds)->toContain($webhookLead->id);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 2 — Spec B regression: два webhook с РАЗНЫМИ vid → два deal (by-design).
|
||||
// Наш Phase 2 fix НЕ должен блокировать это.
|
||||
// ---------------------------------------------------------------------------
|
||||
it('two webhooks with DIFFERENT vids both create deals (Spec B — за повторы поставщика берём)', function (): void {
|
||||
$phone = '79991000002';
|
||||
|
||||
// Первый webhook, vid=100.
|
||||
$lead1 = SupplierLead::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'phone' => $phone,
|
||||
'vid' => 100,
|
||||
'supplier_project_id' => $this->sp->id,
|
||||
'raw_payload' => [
|
||||
'vid' => 100,
|
||||
'project' => 'B1_race-csv.ru',
|
||||
'phone' => $phone,
|
||||
'time' => now()->subHour()->getTimestamp(),
|
||||
],
|
||||
'received_at' => now()->subHour(),
|
||||
'source' => 'webhook',
|
||||
'processed_at' => null,
|
||||
]);
|
||||
runRaceJob($lead1->id);
|
||||
|
||||
// Второй webhook, vid=200 (другой лид поставщика, тот же телефон+проект).
|
||||
$lead2 = SupplierLead::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'phone' => $phone,
|
||||
'vid' => 200,
|
||||
'supplier_project_id' => $this->sp->id,
|
||||
'raw_payload' => [
|
||||
'vid' => 200,
|
||||
'project' => 'B1_race-csv.ru',
|
||||
'phone' => $phone,
|
||||
'time' => now()->subMinutes(30)->getTimestamp(),
|
||||
],
|
||||
'received_at' => now()->subMinutes(30),
|
||||
'source' => 'webhook',
|
||||
'processed_at' => null,
|
||||
]);
|
||||
runRaceJob($lead2->id);
|
||||
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant->id}'");
|
||||
|
||||
// Spec B: оба webhook'а имеют source_crm_id != null.
|
||||
// Условие merge (source_crm_id IS NULL) не срабатывает → два deal,
|
||||
// два LeadCharge. Spec B Phase 1 (commit ccfecd5e) за повторы поставщика берём.
|
||||
$deals = Deal::where('phone', $phone)->get();
|
||||
expect($deals)->toHaveCount(2, 'Два webhook с разными vid должны создавать два deal (Spec B)');
|
||||
$sourceCrmIds = $deals->pluck('source_crm_id')->sort()->values()->all();
|
||||
expect($sourceCrmIds)->toContain(100);
|
||||
expect($sourceCrmIds)->toContain(200);
|
||||
expect(LeadCharge::whereIn('deal_id', $deals->pluck('id'))->count())->toBe(2);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 3 — Boundary: CSV-recovered deal старше 24h НЕ мержится с новым webhook.
|
||||
// Окно merge — 24h. Старый лид не считается «активным» duplicate.
|
||||
// ---------------------------------------------------------------------------
|
||||
it('csv-recovered deal older than 24h is NOT merged with new webhook', function (): void {
|
||||
$phone = '79991000003';
|
||||
|
||||
// CSV-recovered SupplierLead, обработанный 2 дня назад.
|
||||
$csvLead = SupplierLead::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'phone' => $phone,
|
||||
'vid' => null,
|
||||
'supplier_project_id' => $this->sp->id,
|
||||
'raw_payload' => [
|
||||
'project' => 'B1_race-csv.ru',
|
||||
'phone' => $phone,
|
||||
'time' => now()->subDays(2)->getTimestamp(),
|
||||
],
|
||||
'received_at' => now()->subDays(2),
|
||||
'recovered_from_csv_at' => now()->subDays(2),
|
||||
'source' => 'csv_recovery',
|
||||
'processed_at' => null,
|
||||
]);
|
||||
runRaceJob($csvLead->id);
|
||||
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant->id}'");
|
||||
$csvDeal = Deal::where('phone', $phone)->first();
|
||||
expect($csvDeal)->not->toBeNull('CSV-recovered deal должен существовать');
|
||||
|
||||
// Сбросим processed_at у tenant-level проекта: delivered_today накопился,
|
||||
// нужно сбросить счётчик чтобы второй deal тоже прошёл лимит.
|
||||
$this->project->update(['delivered_today' => 0]);
|
||||
|
||||
// Webhook приходит сейчас — deal CSV-recovery старше 24h → не мержится.
|
||||
$webhookLead = SupplierLead::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'phone' => $phone,
|
||||
'vid' => 999,
|
||||
'supplier_project_id' => $this->sp->id,
|
||||
'raw_payload' => [
|
||||
'vid' => 999,
|
||||
'project' => 'B1_race-csv.ru',
|
||||
'phone' => $phone,
|
||||
'time' => now()->getTimestamp(),
|
||||
],
|
||||
'received_at' => now(),
|
||||
'source' => 'webhook',
|
||||
'processed_at' => null,
|
||||
]);
|
||||
runRaceJob($webhookLead->id);
|
||||
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant->id}'");
|
||||
|
||||
// Два deal: старый CSV-recovered (2 дня назад) + новый от webhook.
|
||||
// Merge НЕ происходит — CSV-recovered вне 24h окна.
|
||||
$deals = Deal::where('phone', $phone)->get();
|
||||
expect($deals)->toHaveCount(2, 'CSV-recovered deal старше 24h — merge не происходит, создаётся новый deal от webhook');
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\RouteSupplierLeadJob;
|
||||
use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\SupplierLead;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\SystemSetting;
|
||||
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 Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
/**
|
||||
* Phase 3 — DIRECT platform end-to-end.
|
||||
*
|
||||
* Supplier crm.bp-gr.ru шлёт часть лидов на проекты БЕЗ B[123]_ префикса
|
||||
* (e.g. `client.carmoney.ru`, `cashmotor.ru`, числовой callback `79135191264`).
|
||||
* До Phase 3 такие webhook'и отвергались с 302 redirect и терялись —
|
||||
* наблюдалось 67 потерь/день для tenant client1 на проде 25.05.2026.
|
||||
*
|
||||
* Phase 3 принимает их как platform='DIRECT' end-to-end:
|
||||
* - controller regex снят, parsePlatform возвращает 'DIRECT' для не-B;
|
||||
* - SupplierProjectResolver принимает DIRECT;
|
||||
* - RouteSupplierLeadJob.parseProjectField парсит без B-префикса;
|
||||
* - LeadRouter для DIRECT использует signal_type+identifier match напрямую
|
||||
* (без project_supplier_links pivot — psl-rows для DIRECT не созданы).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-25-supplier-webhook-reliability-design.md §3 Phase 3
|
||||
*/
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->seed(PricingTierSeeder::class);
|
||||
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
|
||||
|
||||
SystemSetting::query()
|
||||
->where('key', 'supplier_webhook_secret')
|
||||
->update(['value' => 'test-secret-32chars-aaaaaaaaaaaaaa']);
|
||||
SystemSetting::query()
|
||||
->where('key', 'supplier_ip_allowlist')
|
||||
->update(['value' => '[]']);
|
||||
});
|
||||
|
||||
function directDispatchJob(int $supplierLeadId): void
|
||||
{
|
||||
(new RouteSupplierLeadJob($supplierLeadId))->handle(
|
||||
app(LeadRouter::class),
|
||||
app(SupplierProjectResolver::class),
|
||||
app(NotificationService::class),
|
||||
app(LedgerService::class),
|
||||
app(LeadDistributor::class),
|
||||
app(RegionTagResolver::class),
|
||||
);
|
||||
}
|
||||
|
||||
it('webhook with non-B-prefix project is accepted (202) and platform=DIRECT', function (): void {
|
||||
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
|
||||
'vid' => 9999001,
|
||||
'project' => 'client.carmoney.ru',
|
||||
'phone' => '79991234567',
|
||||
'time' => time(),
|
||||
]);
|
||||
|
||||
$response->assertStatus(202);
|
||||
$lead = SupplierLead::where('vid', 9999001)->first();
|
||||
expect($lead)->not->toBeNull();
|
||||
expect($lead->platform)->toBe('DIRECT');
|
||||
});
|
||||
|
||||
it('SupplierProjectResolver creates DIRECT supplier_project for non-B project', function (): void {
|
||||
$resolver = app(SupplierProjectResolver::class);
|
||||
$sp = $resolver->resolveOrStub('DIRECT', 'site', 'client.carmoney.ru');
|
||||
|
||||
expect($sp->platform)->toBe('DIRECT');
|
||||
expect($sp->unique_key)->toBe('client.carmoney.ru');
|
||||
expect($sp->signal_type)->toBe('site');
|
||||
});
|
||||
|
||||
it('RouteSupplierLeadJob delivers DIRECT lead to matching project via signal_identifier fallback', function (): void {
|
||||
// Создаём Лидерра-проект с тем же signal_identifier, что и DIRECT-supplier_project.
|
||||
// ВАЖНО: НЕ создаём project_supplier_links — Phase 3 fallback должен матчить
|
||||
// только по signal_type+signal_identifier.
|
||||
$tenant = Tenant::factory()->create([
|
||||
'balance_leads' => 0,
|
||||
'balance_rub' => '1000.00',
|
||||
'delivered_in_month' => 0,
|
||||
]);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'client.carmoney.ru',
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 10,
|
||||
'effective_daily_limit_today' => 10,
|
||||
'delivered_today' => 0,
|
||||
'delivery_days_mask' => 127,
|
||||
'region_mask' => 255,
|
||||
]);
|
||||
|
||||
$lead = SupplierLead::factory()->create([
|
||||
'platform' => 'DIRECT',
|
||||
'phone' => '79991234567',
|
||||
'vid' => 9999002,
|
||||
'raw_payload' => ['vid' => 9999002, 'project' => 'client.carmoney.ru', 'phone' => '79991234567', 'time' => time()],
|
||||
'received_at' => now(),
|
||||
]);
|
||||
|
||||
directDispatchJob($lead->id);
|
||||
|
||||
$deal = Deal::where('tenant_id', $tenant->id)
|
||||
->where('phone', '79991234567')
|
||||
->first();
|
||||
expect($deal)->not->toBeNull();
|
||||
expect($deal->project_id)->toBe($project->id);
|
||||
expect($deal->source_crm_id)->toBe(9999002);
|
||||
});
|
||||
|
||||
it('numeric-only project (e.g. 79135191264 callback) accepted as DIRECT', function (): void {
|
||||
// Поставщик иногда шлёт project=телефонный номер для callback-проектов.
|
||||
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
|
||||
'vid' => 9999003,
|
||||
'project' => '79135191264',
|
||||
'phone' => '79991234567',
|
||||
'time' => time(),
|
||||
]);
|
||||
|
||||
$response->assertStatus(202);
|
||||
$lead = SupplierLead::where('vid', 9999003)->first();
|
||||
expect($lead->platform)->toBe('DIRECT');
|
||||
});
|
||||
|
||||
it('existing B1 webhooks still work as platform=B1 (regression)', function (): void {
|
||||
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
|
||||
'vid' => 9999004,
|
||||
'project' => 'B1_krk-finance.ru',
|
||||
'phone' => '79991234567',
|
||||
'time' => time(),
|
||||
]);
|
||||
|
||||
$response->assertStatus(202);
|
||||
expect(SupplierLead::where('vid', 9999004)->first()->platform)->toBe('B1');
|
||||
});
|
||||
|
||||
it('SupplierProjectResolver still rejects unknown platforms other than DIRECT', function (): void {
|
||||
$resolver = app(SupplierProjectResolver::class);
|
||||
|
||||
expect(fn () => $resolver->resolveOrStub('UNKNOWN', 'site', 'foo.ru'))
|
||||
->toThrow(InvalidArgumentException::class);
|
||||
});
|
||||
+38
-1
@@ -2,7 +2,44 @@
|
||||
|
||||
**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит тридцать записей в обратном хронологическом порядке (v8.33 → v8.32 → v8.31 → v8.30 → v8.29 → v8.28 → v8.27 → v8.26 → v8.25 → v8.24 → v8.23 → v8.22 → v8.21 → v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog.
|
||||
|
||||
**Файл схемы:** `schema.sql` (текущая версия — v8.36, консолидированная — разворачивает БД с нуля).
|
||||
**Файл схемы:** `schema.sql` (текущая версия — v8.37, консолидированная — разворачивает БД с нуля).
|
||||
|
||||
## v8.37 (2026-05-25) — supplier_*.platform: VARCHAR(4)→VARCHAR(8) + ENUM расширен на DIRECT
|
||||
|
||||
Phase 3 supplier webhook reliability — приём проектов без B[123]_ префикса как
|
||||
платформа `DIRECT`. На проде 25.05.2026 для tenant `client1` зафиксировано ~67
|
||||
потерянных лидов/сутки из-за того, что webhook-validation regex `'^B[123]_.+$'`
|
||||
отвергал проекты вида `client.carmoney.ru`, `cashmotor.ru`, `cabinet.caranga.ru`
|
||||
и числовые callback-IDs. Phase 3 принимает их end-to-end под новой платформой `DIRECT`.
|
||||
|
||||
**Изменено:**
|
||||
|
||||
- **`supplier_projects.platform` VARCHAR(4)→VARCHAR(8)** — `DIRECT` (6 символов) не вмещался.
|
||||
- **`project_supplier_links.platform` VARCHAR(4)→VARCHAR(8)** — то же.
|
||||
- **`supplier_leads.platform` VARCHAR(4)→VARCHAR(8)** — то же.
|
||||
- **`chk_supplier_projects_platform`**: `IN ('B1','B2','B3')` → `IN ('B1','B2','B3','DIRECT')`.
|
||||
- **`chk_psl_platform`**: то же расширение enum.
|
||||
- **`chk_supplier_leads_platform`**: то же расширение enum.
|
||||
|
||||
**Добавлено:**
|
||||
|
||||
- **`suppliers` row `code='direct'`** — `DIRECT — Прямые проекты`, `cost_rub=1.00`,
|
||||
`accepts_types={websites,calls,sms}`, `channel='sites'`. Используется
|
||||
`LedgerService::resolveSupplierId` fallback'ом для DIRECT-платформенных лидов.
|
||||
|
||||
**Не изменено:**
|
||||
|
||||
- `chk_supplier_projects_b1_not_for_sms` — деноминирует B1+SMS, DIRECT+SMS не блокирует.
|
||||
- Индексы, FK, RLS-политики — без изменений.
|
||||
|
||||
**Метрики:** 0 новых таблиц, 0 новых индексов; 3 CHECK расширены, 3 колонки расширены, 1 seed-row.
|
||||
|
||||
**Миграции:**
|
||||
|
||||
- `2026_05_25_120000_add_direct_platform_to_supplier_projects` — DDL (idempotent через DROP+ADD CHECK).
|
||||
- `2026_05_25_120100_seed_direct_supplier` — seed `suppliers.code='direct'` через raw SQL INSERT ON CONFLICT DO NOTHING.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-25-supplier-webhook-reliability-design.md` §3 Phase 3.
|
||||
|
||||
## v8.36 (2026-05-25) — supplier_csv_reconcile_log.unparseable_count: drift-формула без junk-строк
|
||||
|
||||
|
||||
+8
-7
@@ -1,6 +1,7 @@
|
||||
-- =============================================================================
|
||||
-- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра»)
|
||||
-- Версия: v8.36 (25.05.2026 — supplier_csv_reconcile_log.unparseable_count: учёт мусорных CSV-строк, вычитание из drift-формулы → убирает false-positive drift_alert от телефонов/URL в поле project)
|
||||
-- Версия: v8.37 (25.05.2026 — supplier_*.platform VARCHAR(4)→VARCHAR(8) + chk_supplier_projects_platform / chk_psl_platform / chk_supplier_leads_platform расширены до IN(B1,B2,B3,DIRECT); +seed suppliers.code='direct'. Phase 3 supplier webhook reliability — приём проектов без B-префикса end-to-end)
|
||||
-- Базовая версия: v8.36 (25.05.2026 — supplier_csv_reconcile_log.unparseable_count: учёт мусорных CSV-строк, вычитание из drift-формулы → убирает false-positive drift_alert от телефонов/URL в поле project)
|
||||
-- Базовая версия: v8.35 (24.05.2026 — legacy direct webhook removal: DROP webhook_log (partitioned) + rejected_deals_log + tenants.webhook_token/webhook_token_rotated_at; webhook_dedup_keys сохранена (CSV-канал))
|
||||
-- Базовая версия: v8.34 (23.05.2026 — Billing v2 Spec B: −индекс deals(duplicate_of_id) — телефонный дедуп удалён)
|
||||
-- Базовая версия: v8.31 (23.05.2026 — партиционирование 7 audit-таблиц помесячно (hole #2): auth_log / activity_log / tenant_operations_log / balance_transactions / pd_processing_log / saas_admin_audit_log; PK → (id, created_at|received_at); retention defaults в system_settings)
|
||||
@@ -907,7 +908,7 @@ COMMENT ON COLUMN projects.regions IS
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE supplier_projects (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
platform VARCHAR(4) NOT NULL, -- B1 / B2 / B3
|
||||
platform VARCHAR(8) NOT NULL, -- B1 / B2 / B3 / DIRECT (Phase 3, 2026-05-25)
|
||||
signal_type VARCHAR(16) NOT NULL, -- site / call / sms
|
||||
unique_key TEXT NOT NULL, -- domain / phone / sender+keyword / sender
|
||||
supplier_external_id VARCHAR(64), -- внутренний id у поставщика
|
||||
@@ -923,7 +924,7 @@ CREATE TABLE supplier_projects (
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT chk_supplier_projects_platform
|
||||
CHECK (platform IN ('B1','B2','B3')),
|
||||
CHECK (platform IN ('B1','B2','B3','DIRECT')),
|
||||
CONSTRAINT chk_supplier_projects_signal_type
|
||||
CHECK (signal_type IN ('site','call','sms')),
|
||||
CONSTRAINT chk_supplier_projects_sync_status
|
||||
@@ -964,10 +965,10 @@ CREATE TABLE project_supplier_links (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
supplier_project_id BIGINT NOT NULL REFERENCES supplier_projects(id) ON DELETE CASCADE,
|
||||
platform VARCHAR(4) NOT NULL,
|
||||
platform VARCHAR(8) NOT NULL, -- B1 / B2 / B3 / DIRECT (Phase 3, 2026-05-25)
|
||||
subject_code SMALLINT, -- субъект РФ 1..89; NULL = пул «Вся РФ»
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT chk_psl_platform CHECK (platform IN ('B1','B2','B3')),
|
||||
CONSTRAINT chk_psl_platform CHECK (platform IN ('B1','B2','B3','DIRECT')),
|
||||
CONSTRAINT uq_psl_project_supplier UNIQUE (project_id, supplier_project_id)
|
||||
);
|
||||
CREATE INDEX idx_psl_supplier_project ON project_supplier_links(supplier_project_id);
|
||||
@@ -1979,7 +1980,7 @@ CREATE INDEX idx_failed_webhook_jobs_log ON failed_webhook_jobs(webhook_log_id);
|
||||
CREATE TABLE supplier_leads (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
supplier_project_id BIGINT REFERENCES supplier_projects(id) ON DELETE SET NULL,
|
||||
platform VARCHAR(4) NOT NULL,
|
||||
platform VARCHAR(8) NOT NULL, -- B1 / B2 / B3 / DIRECT (Phase 3, 2026-05-25)
|
||||
raw_payload JSONB NOT NULL,
|
||||
vid BIGINT, -- nullable: NULL у CSV-recovered лидов (Путь 2)
|
||||
phone VARCHAR(20) NOT NULL,
|
||||
@@ -1993,7 +1994,7 @@ CREATE TABLE supplier_leads (
|
||||
error TEXT,
|
||||
|
||||
CONSTRAINT chk_supplier_leads_platform
|
||||
CHECK (platform IN ('B1','B2','B3')),
|
||||
CHECK (platform IN ('B1','B2','B3','DIRECT')),
|
||||
CONSTRAINT chk_supplier_leads_source
|
||||
CHECK (source IN ('webhook','csv_recovery')),
|
||||
CONSTRAINT chk_supplier_leads_deals_count_nonneg
|
||||
|
||||
Reference in New Issue
Block a user