docs(billing-v2): спек B — план реализации (политика дублей)
8 задач: baseline → таблица-замок supplier_lead_deliveries → раздача по клиентам (LeadRouter DISTINCT ON) → удаление DuplicateDetector из обоих джобов → замок insertOrIgnore → тесты (model-agnostic) → регрессия. Вариант B. Заякорено на always-rub LedgerService (Спек A в origin/main). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,757 @@
|
||||
# Биллинг v2 Спек B — политика дублей: план реализации
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Убрать наш телефонный антифрод-фильтр дублей (доверяем дедупу поставщика), но гарантировать на уровне БД, что одна поставка одному клиенту тарифицируется ровно один раз; лимит шеринга — 3 разных клиента.
|
||||
|
||||
**Architecture:** Удаляем `DuplicateDetector` из обоих job-путей. В шеринг-пути (`RouteSupplierLeadJob`) раздача переводится с лимита-по-проектам на лимит-по-клиентам (один проект на клиента — DISTINCT ON по `tenant_id`, выбор проекта с макс. остатком дневного лимита; cap=3 клиента). Новая таблица-замок `supplier_lead_deliveries` (PK `supplier_lead_id`+`tenant_id`) + `insertOrIgnore` внутри транзакции создания сделки гарантирует «одна поставка → один оплаченный лид на клиента» даже при гонках/перезапусках/CSV-восстановлении.
|
||||
|
||||
**Tech Stack:** Laravel 13, PostgreSQL 16 (партиционированная `deals`, RLS по `app.current_tenant_id`, 5 ролей), Pest 4 (`--parallel`), bcmath/`LedgerService`. Worktree `.claude/worktrees/billing-v2-spec-b/`, ветка `feat/billing-v2-spec-b` (база origin/main `ff2ee59e`, Спек A уже влит).
|
||||
|
||||
**Спека:** `docs/superpowers/specs/2026-05-23-billing-v2-spec-b-duplicates-design.md`
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Важный контекст базы (прочитать до старта)
|
||||
|
||||
1. **Спек A влит в origin/main.** `App\Services\Billing\LedgerService::chargeForDelivery` — always-rub: списывает `balance_rub` (bcmath), пишет `LeadCharge(charge_source='rub')` + `BalanceTransaction` + `supplier_lead_costs`; `balance_leads` НЕ трогает. Возвращает `ChargeResult`.
|
||||
2. **Тест-долг Спека A.** Часть существующих тестов (`app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php` ассертит `balance_leads → 99`; `RouteSupplierLeadJobBillingTest.php` имеет кейс `charge_source='prepaid'`) противоречит always-rub `LedgerService` и, вероятно, **уже красная на этой базе**. Это НЕ наша регрессия. Task 1 устанавливает фактический baseline. Новые тесты Спека B заякорены на **model-agnostic** ассерты (число `Deal` / `LeadCharge` на клиента + строки таблицы-замка) и сетап через хелпер `prepareSharingFlow` с достаточным `balance_rub`, чтобы не зависеть от prepaid/rub.
|
||||
3. **Два job-пути.** `ProcessWebhookJob` (прямой вебхук, `WebhookReceiveController`) — идемпотентность по `vid` через `webhook_dedup_keys (tenant_id, source_crm_id)`; замок там НЕ нужен. `RouteSupplierLeadJob` (шеринг, `SupplierWebhookController` + `CsvReconcileJob`) — замок нужен здесь.
|
||||
4. **Гранты — blanket.** `db/02_grants.sql` выдаёт `GRANT ... ON ALL TABLES` + `ALTER DEFAULT PRIVILEGES`. Новая tenant-таблица грантов отдельно не требует. На dev — `postgres` superuser.
|
||||
5. **`duplicate_detected` в origin/main отсутствует** (ни в `db/schema.sql`, ни во фронте, ни в backend) — чистить нечего, только verify-grep. Колонка `deals.duplicate_of_id` (schema.sql:1626) + индекс (schema.sql:1688) — есть.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| Файл | Действие | Ответственность |
|
||||
|---|---|---|
|
||||
| `db/migrations/2026_05_23_200_supplier_lead_deliveries.sql` | Create | DDL таблицы-замка (RLS + PK + FK) |
|
||||
| `app/database/migrations/2026_05_23_200000_create_supplier_lead_deliveries.php` | Create | парная Laravel-миграция (idempotency guard) |
|
||||
| `db/migrations/2026_05_23_201_drop_deals_duplicate_of_id_index.sql` | Create | DROP лишнего индекса |
|
||||
| `app/database/migrations/2026_05_23_201000_drop_deals_duplicate_of_id_index.php` | Create | парная Laravel-миграция |
|
||||
| `db/schema.sql` | Modify | +CREATE TABLE supplier_lead_deliveries; −CREATE INDEX deals(duplicate_of_id); header v8.32→v8.33 |
|
||||
| `db/CHANGELOG_schema.md` | Modify | +запись v8.33 |
|
||||
| `app/app/Models/SupplierLeadDelivery.php` | Create | Eloquent-модель замка |
|
||||
| `app/app/Services/DuplicateDetector.php` | Delete | сервис телефонного фильтра |
|
||||
| `app/app/Jobs/ProcessWebhookJob.php` | Modify | убрать findMaster + markAsDuplicate, всегда charge |
|
||||
| `app/app/Jobs/RouteSupplierLeadJob.php` | Modify | убрать DuplicateDetector из сигнатур; +замок insertOrIgnore; раздача по клиентам |
|
||||
| `app/app/Services/LeadRouter.php` | Modify | DISTINCT ON (tenant_id) — один проект на клиента (макс. остаток лимита) |
|
||||
| `app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php` | Create | тесты замка + раздачи по клиентам |
|
||||
| `app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php` | Modify | убрать DuplicateDetector из `runRouteJob`; удалить/переписать дубль-тесты |
|
||||
| `app/tests/Feature/ProcessWebhookJobTest.php` | Modify | убрать дубль-тесты; +тест «два vid, один телефон → оба charge» |
|
||||
| прочие тесты с `DuplicateDetector`/`runRouteJob` | Modify | привести сигнатуры к 6-арговому handle() |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Baseline — зафиксировать фактическое состояние
|
||||
|
||||
**Files:** нет правок (только прогон).
|
||||
|
||||
- [ ] **Step 1: Подготовить тестовую БД worktree**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd .claude/worktrees/billing-v2-spec-b/app
|
||||
php artisan migrate:fresh --env=testing
|
||||
php artisan partitions:create-months --env=testing
|
||||
```
|
||||
Expected: миграции проходят; партиции `deals_*`, `balance_transactions_*`, `supplier_lead_costs_*` за текущий/смежные месяцы созданы. (Квирк Спека A: при нехватке партиций тесты падают с partition-ошибкой — пересоздать.)
|
||||
|
||||
- [ ] **Step 2: Прогнать затронутые сюиты, записать baseline**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
php artisan test tests/Feature/Jobs/RouteSupplierLeadJobTest.php tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php tests/Feature/ProcessWebhookJobTest.php tests/Feature/Integration/SupplierLeadFlowTest.php tests/Feature/Supplier/AutoPauseFlowTest.php tests/Feature/Supplier/CsvReconcileJobTest.php tests/Feature/Pd/DealCreatePdLogTest.php
|
||||
```
|
||||
Expected: записать в заметку, какие тесты GREEN, какие RED. Ожидаемо красные (тест-долг Спека A, НЕ наша задача): `RouteSupplierLeadJobTest` (balance_leads ассерты), prepaid-кейс в `RouteSupplierLeadJobBillingTest`. Всё остальное должно быть GREEN.
|
||||
|
||||
- [ ] **Step 3: Подтвердить модель списания**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
grep -n "charge_source\|balance_rub\|balance_leads" app/Services/Billing/LedgerService.php
|
||||
```
|
||||
Expected: `charge_source` = `'rub'` хардкод, списывается `balance_rub`. Зафиксировать: новые тесты используют `balance_rub` и `LeadCharge::count()`.
|
||||
|
||||
- [ ] **Step 4: Коммит заметки baseline (опционально)**
|
||||
|
||||
Если ведёте журнал — зафиксируйте baseline-вывод в описании задачи. Кода-коммита нет.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Таблица-замок `supplier_lead_deliveries`
|
||||
|
||||
**Files:**
|
||||
- Create: `db/migrations/2026_05_23_200_supplier_lead_deliveries.sql`
|
||||
- Create: `app/database/migrations/2026_05_23_200000_create_supplier_lead_deliveries.php`
|
||||
- Modify: `db/schema.sql` (вставить CREATE TABLE; header v8.32→v8.33)
|
||||
- Modify: `db/CHANGELOG_schema.md`
|
||||
- Create: `app/app/Models/SupplierLeadDelivery.php`
|
||||
- Test: `app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php` (schema-часть)
|
||||
|
||||
- [ ] **Step 1: Написать падающий schema-тест**
|
||||
|
||||
Создать `app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php`:
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::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();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Запустить — убедиться, что падает**
|
||||
|
||||
Run: `php artisan test tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php`
|
||||
Expected: FAIL (таблицы нет).
|
||||
|
||||
- [ ] **Step 3: Написать DDL-файл миграции**
|
||||
|
||||
Создать `db/migrations/2026_05_23_200_supplier_lead_deliveries.sql`:
|
||||
```sql
|
||||
-- =============================================================================
|
||||
-- supplier_lead_deliveries — замок «одна поставка одному клиенту = один раз»
|
||||
-- (Billing v2 Spec B). Ключ по поставке (supplier_lead_id), НЕ по телефону —
|
||||
-- разные поставки с одним телефоном остаются отдельными платными лидами.
|
||||
-- Защищает шеринг-путь (RouteSupplierLeadJob) от наших собственных дублей
|
||||
-- при гонках / перезапусках задачи / CSV-восстановлении.
|
||||
-- =============================================================================
|
||||
CREATE TABLE supplier_lead_deliveries (
|
||||
supplier_lead_id BIGINT NOT NULL REFERENCES supplier_leads(id) ON DELETE CASCADE,
|
||||
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
deal_id BIGINT, -- созданная сделка; без FK (deals партиционирована)
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (supplier_lead_id, tenant_id)
|
||||
);
|
||||
|
||||
ALTER TABLE supplier_lead_deliveries ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON supplier_lead_deliveries
|
||||
USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Написать парную Laravel-миграцию**
|
||||
|
||||
Создать `app/database/migrations/2026_05_23_200000_create_supplier_lead_deliveries.php`:
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Idempotency: если schema.sql уже загружен (migrate:fresh), таблица есть — пропускаем.
|
||||
if (DB::selectOne('SELECT 1 AS ok FROM pg_class WHERE relname = ?', ['supplier_lead_deliveries']) !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sql = file_get_contents(base_path('../db/migrations/2026_05_23_200_supplier_lead_deliveries.sql'));
|
||||
if ($sql === false) {
|
||||
throw new RuntimeException('Migration SQL file not found.');
|
||||
}
|
||||
DB::unprepared($sql);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::unprepared('DROP TABLE IF EXISTS supplier_lead_deliveries CASCADE;');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Вставить CREATE TABLE в `db/schema.sql`**
|
||||
|
||||
Вставить блок из Step 3 (без комментария-шапки повторно — достаточно одного) в `db/schema.sql` сразу ПОСЛЕ блока `CREATE TABLE webhook_dedup_keys (...)` с его индексами/RLS (найти `grep -n "CREATE TABLE webhook_dedup_keys" db/schema.sql`). Обновить header-строку версии:
|
||||
```
|
||||
-- Версия: v8.33 (23.05.2026 — Billing v2 Spec B: +supplier_lead_deliveries замок поставка↔клиент; −индекс deals(duplicate_of_id))
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Запись в `db/CHANGELOG_schema.md`**
|
||||
|
||||
Добавить сверху списка изменений:
|
||||
```markdown
|
||||
## v8.33 (2026-05-23) — Billing v2 Spec B: политика дублей
|
||||
|
||||
- **+таблица `supplier_lead_deliveries`** (PK `supplier_lead_id`+`tenant_id`, FK на `supplier_leads` ON DELETE CASCADE, `deal_id` без FK, RLS `tenant_isolation`). Замок «одна поставка одному клиенту = один оплаченный лид» для шеринг-пути.
|
||||
- **−индекс `deals (duplicate_of_id) WHERE duplicate_of_id IS NOT NULL`** — концепция телефонного дедупа удалена (DuplicateDetector); колонка `deals.duplicate_of_id` оставлена спящей.
|
||||
- Метрики: +1 таблица, −1 индекс. (Сверять с header schema.sql.)
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Создать Eloquent-модель**
|
||||
|
||||
Создать `app/app/Models/SupplierLeadDelivery.php`:
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Замок «поставка ↔ клиент» (Billing v2 Spec B). Композитный PK без автоинкремента.
|
||||
* Пишется в шеринг-пути (RouteSupplierLeadJob) через insertOrIgnore под RLS-контекстом.
|
||||
*/
|
||||
class SupplierLeadDelivery extends Model
|
||||
{
|
||||
public $incrementing = false;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $primaryKey = null;
|
||||
|
||||
protected $fillable = ['supplier_lead_id', 'tenant_id', 'deal_id', 'created_at'];
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Пересоздать тестовую БД и прогнать schema-тест**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
php artisan migrate:fresh --env=testing && php artisan partitions:create-months --env=testing
|
||||
php artisan test tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php
|
||||
```
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 9: Коммит**
|
||||
|
||||
```bash
|
||||
git add db/migrations/2026_05_23_200_supplier_lead_deliveries.sql \
|
||||
app/database/migrations/2026_05_23_200000_create_supplier_lead_deliveries.php \
|
||||
db/schema.sql db/CHANGELOG_schema.md \
|
||||
app/app/Models/SupplierLeadDelivery.php \
|
||||
app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php
|
||||
git commit -m "feat(billing-v2): supplier_lead_deliveries lock table (Spec B)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Раздача по клиентам (LeadRouter — один проект на клиента)
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/app/Services/LeadRouter.php`
|
||||
- Test: `app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php` (добавить кейс)
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест «один клиент, 2 проекта → 1 сделка»**
|
||||
|
||||
Добавить в `SupplierLeadDeliveryGuardTest.php` (хелперы `prepareSharingFlow` / `linkProjectToSupplier` — из `tests/Pest.php`; сверить сигнатуру по `RouteSupplierLeadJobBillingTest.php`):
|
||||
```php
|
||||
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 Database\Seeders\PricingTierSeeder;
|
||||
|
||||
it('one delivery to a tenant with 2 eligible projects → exactly 1 deal + 1 charge', function (): void {
|
||||
$this->seed(PricingTierSeeder::class);
|
||||
|
||||
$sp = SupplierProject::factory()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'twoproj.ru',
|
||||
]);
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 0, 'balance_rub' => '100000.00']);
|
||||
|
||||
// Два подходящих проекта одного клиента, разный остаток лимита.
|
||||
$pLow = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id, 'is_active' => true,
|
||||
'daily_limit_target' => 10, 'delivered_today' => 9, 'delivery_days_mask' => 127,
|
||||
]);
|
||||
$pHigh = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id, 'is_active' => true,
|
||||
'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127,
|
||||
]);
|
||||
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()],
|
||||
]);
|
||||
|
||||
runRouteJob($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($pHigh->fresh()->delivered_today)->toBe(1);
|
||||
expect($pLow->fresh()->delivered_today)->toBe(9);
|
||||
});
|
||||
```
|
||||
NB: `runRouteJob` уже определён в `RouteSupplierLeadJobTest.php`, но это другой файл. Определить локальный хелпер в этом файле (после Task 4 он будет 6-арговым — см. ниже), либо вызвать job напрямую. Чтобы не зависеть от Task 4, в этом тесте вызвать job через `app()`-резолв 6 аргументов ПОСЛЕ Task 4. Поэтому: написать тело теста, но запускать его в Step 3 уже после правки LeadRouter, а полную зелёность по job — в Task 6.
|
||||
|
||||
- [ ] **Step 2: Переписать `LeadRouter::matchEligibleProjects` на DISTINCT ON (tenant_id)**
|
||||
|
||||
Заменить тело `matchEligibleProjects` в `app/app/Services/LeadRouter.php` — добавить `DISTINCT ON (projects.tenant_id)` с выбором проекта максимального остатка лимита:
|
||||
```php
|
||||
/** @var Collection<int, Project> $candidates */
|
||||
$candidates = Project::on('pgsql_supplier')
|
||||
->select('projects.*')
|
||||
->selectRaw('DISTINCT ON (projects.tenant_id) projects.id AS __distinct_marker')
|
||||
->whereExists(function ($q) use ($supplierProject): void {
|
||||
$q->selectRaw('1')
|
||||
->from('project_supplier_links')
|
||||
->whereColumn('project_supplier_links.project_id', 'projects.id')
|
||||
->where('project_supplier_links.supplier_project_id', $supplierProject->id);
|
||||
})
|
||||
->where('is_active', true)
|
||||
->whereRaw('(delivery_days_mask & ?) <> 0', [$todayBit])
|
||||
->whereRaw('delivered_today < COALESCE(effective_daily_limit_today, daily_limit_target)')
|
||||
->whereExists(function ($q): void {
|
||||
$q->selectRaw('1')
|
||||
->from('tenants')
|
||||
->whereColumn('tenants.id', 'projects.tenant_id')
|
||||
->where(function ($qq): void {
|
||||
$qq->where('tenants.balance_leads', '>', 0)
|
||||
->orWhere('tenants.balance_rub', '>', 0);
|
||||
});
|
||||
})
|
||||
->orderBy('projects.tenant_id')
|
||||
->orderByRaw('COALESCE(projects.effective_daily_limit_today, projects.daily_limit_target) - projects.delivered_today DESC')
|
||||
->orderBy('projects.created_at')
|
||||
->orderBy('projects.id')
|
||||
->get();
|
||||
|
||||
return $candidates->values();
|
||||
```
|
||||
NB: смешение `DISTINCT ON` + Eloquent `select('projects.*')` хрупко. **Предпочтительный вариант** — сырой select без маркера:
|
||||
```php
|
||||
$candidates = Project::on('pgsql_supplier')
|
||||
->fromRaw('projects')
|
||||
->whereExists(/* project_supplier_links ... */)
|
||||
->where('is_active', true)
|
||||
->whereRaw('(delivery_days_mask & ?) <> 0', [$todayBit])
|
||||
->whereRaw('delivered_today < COALESCE(effective_daily_limit_today, daily_limit_target)')
|
||||
->whereExists(/* tenants balance ... */)
|
||||
->orderByRaw('projects.tenant_id, COALESCE(projects.effective_daily_limit_today, projects.daily_limit_target) - projects.delivered_today DESC, projects.created_at, projects.id')
|
||||
->selectRaw('DISTINCT ON (projects.tenant_id) projects.*')
|
||||
->get();
|
||||
```
|
||||
Реализатор выбирает рабочий из двух (проверить SQL прогоном). Семантика обязательна: **ровно один Project на tenant_id, с максимальным остатком `COALESCE(effective_daily_limit_today, daily_limit_target) - delivered_today`; тай-брейк `created_at, id`**.
|
||||
|
||||
- [ ] **Step 3: Прогон существующих router-зависимых тестов**
|
||||
|
||||
Run: `php artisan test tests/Feature/Jobs/RouteSupplierLeadJobTest.php --filter="caps deal creation at 3"`
|
||||
Expected: тест cap=3 (5 клиентов по 1 проекту) остаётся GREEN (DISTINCT ON не меняет результат при одном проекте на клиента). Если упал из-за DuplicateDetector-аргумента — это чинится в Task 4; здесь убедиться, что SQL DISTINCT ON валиден (нет SQL-ошибки).
|
||||
|
||||
- [ ] **Step 4: Коммит**
|
||||
|
||||
```bash
|
||||
git add app/app/Services/LeadRouter.php app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php
|
||||
git commit -m "feat(billing-v2): LeadRouter — one project per tenant (max remaining limit)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Удалить `DuplicateDetector` из `RouteSupplierLeadJob`
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/app/Jobs/RouteSupplierLeadJob.php`
|
||||
- Modify: `app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php` (сигнатура `runRouteJob`, удалить дубль-тесты)
|
||||
|
||||
- [ ] **Step 1: Убрать DuplicateDetector из `handle()` и `createDealCopyForProject()`**
|
||||
|
||||
В `app/app/Jobs/RouteSupplierLeadJob.php`:
|
||||
- Удалить `use App\Services\DuplicateDetector;`.
|
||||
- Из сигнатуры `handle(...)` убрать параметр `DuplicateDetector $duplicateDetector,`.
|
||||
- Из вызова `$this->createDealCopyForProject($lead, $project, $duplicateDetector, $notifier, $ledger, $subjectCode)` убрать `$duplicateDetector`.
|
||||
- Из сигнатуры `createDealCopyForProject(...)` убрать параметр `DuplicateDetector $duplicateDetector,`.
|
||||
- Удалить блок поиска master + ветку дубля (строки ~274–306: `$master = $duplicateDetector->findMaster(...)` ... `if ($master !== null && $master->id !== $deal->id) { ... return false; }`). Сделка всегда идёт на `chargeForDelivery`.
|
||||
- Обновить doc-комментарии (убрать упоминания DuplicateDetector/Биз-19/duplicate_of_id).
|
||||
|
||||
- [ ] **Step 2: Обновить тест-хелпер и удалить дубль-тесты**
|
||||
|
||||
В `app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php`:
|
||||
- Убрать `use App\Services\DuplicateDetector;`.
|
||||
- В `runRouteJob()` и в инлайн-вызове теста «caps deal creation at 3» убрать аргумент `app(DuplicateDetector::class),` (handle() теперь 6-арговый).
|
||||
- Удалить тест `it('marks duplicate via DuplicateDetector — no charge ...')` (строки ~158–204) — концепция удалена.
|
||||
- Переписать тест `it('handles mixed routing: 3 projects, 1 with pre-existing master (dup), 2 clean')` → новое имя/поведение: pre-existing deal с тем же телефоном (другой `vid`) НЕ подавляет списание; ожидать `deals_created_count = 3`, все три баланса/счётчики списаны. (См. также Task 7 — там добавляются model-agnostic тесты; здесь достаточно убрать `duplicate_of_id`-ассерты и привести ожидание к «3 charged».)
|
||||
|
||||
- [ ] **Step 3: Прогон**
|
||||
|
||||
Run: `php artisan test tests/Feature/Jobs/RouteSupplierLeadJobTest.php`
|
||||
Expected: тесты, не завязанные на `balance_leads`-долг, GREEN; компиляция (6-арговый handle) проходит. Красные строго из-за `balance_leads`-ассертов (тест-долг Спека A) — допустимо; если задача включает их починку, мигрировать на `balance_rub` (см. Task 7 Step 4).
|
||||
|
||||
- [ ] **Step 4: Коммит**
|
||||
|
||||
```bash
|
||||
git add app/app/Jobs/RouteSupplierLeadJob.php app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php
|
||||
git commit -m "refactor(billing-v2): drop DuplicateDetector from RouteSupplierLeadJob (Spec B)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Удалить `DuplicateDetector` из `ProcessWebhookJob` + сам сервис
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/app/Jobs/ProcessWebhookJob.php`
|
||||
- Delete: `app/app/Services/DuplicateDetector.php`
|
||||
- Modify: `app/tests/Feature/ProcessWebhookJobTest.php`
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест «два vid, один телефон → оба charge»**
|
||||
|
||||
В `app/tests/Feature/ProcessWebhookJobTest.php` добавить (сверить сетап с существующими тестами файла — tenant с балансом, dispatch `ProcessWebhookJob`):
|
||||
```php
|
||||
it('charges both leads with same phone but different vid (no phone dedup)', function (): void {
|
||||
// Сетап tenant + project как в соседних тестах файла.
|
||||
// Прогнать ProcessWebhookJob дважды: тот же phone, разные vid.
|
||||
// Ожидать: 2 Deal, баланс списан дважды, ни у одной нет duplicate_of_id.
|
||||
// (точный сетап — по образцу существующих тестов ProcessWebhookJobTest)
|
||||
})->todo();
|
||||
```
|
||||
Затем заменить `->todo()` на полноценный тест по образцу существующего «новая сделка списывает баланс» из этого же файла (взять его сетап tenant/project/payload, продублировать вызов с двумя разными `vid`, одинаковым `phone`; ассертить 2 сделки + двойное списание).
|
||||
|
||||
- [ ] **Step 2: Запустить — убедиться, что падает (или показывает старое поведение)**
|
||||
|
||||
Run: `php artisan test tests/Feature/ProcessWebhookJobTest.php --filter="same phone but different vid"`
|
||||
Expected: при наличии DuplicateDetector второй лид помечается дублем (FAIL: ожидаем 2 charge, получаем 1).
|
||||
|
||||
- [ ] **Step 3: Убрать DuplicateDetector из `ProcessWebhookJob`**
|
||||
|
||||
В `app/app/Jobs/ProcessWebhookJob.php`:
|
||||
- Удалить `use App\Services\DuplicateDetector;`.
|
||||
- Удалить `$duplicateDetector = app(DuplicateDetector::class);` и его передачу в `DB::transaction`.
|
||||
- Удалить блок поиска master + ветку (строки ~119–133: `$master = $duplicateDetector->findMaster(...)` ... `if ($master !== null && ...) { $this->markAsDuplicate(...); return; }`). После проверки `wasRecentlyCreated` сразу `$this->chargeNewLead(...)`.
|
||||
- Удалить приватный метод `markAsDuplicate(...)` (строки ~144–165).
|
||||
- Обновить doc-комментарии (убрать абзац про Биз-19/DuplicateDetector).
|
||||
|
||||
- [ ] **Step 4: Удалить сервис и дубль-тесты**
|
||||
|
||||
```bash
|
||||
rm app/app/Services/DuplicateDetector.php
|
||||
```
|
||||
В `app/tests/Feature/ProcessWebhookJobTest.php` удалить тесты телефонного дедупа (master в 24ч → дубль / master старше 24ч / ActivityLog duplicate_of). Оставить/адаптировать только релевантные (vid-идемпотентность, zero-balance).
|
||||
|
||||
- [ ] **Step 5: Прогон**
|
||||
|
||||
Run: `php artisan test tests/Feature/ProcessWebhookJobTest.php`
|
||||
Expected: GREEN (включая новый тест из Step 1).
|
||||
|
||||
- [ ] **Step 6: Verify — нет висячих ссылок на DuplicateDetector**
|
||||
|
||||
Run: `grep -rn "DuplicateDetector\|findMaster\|markAsDuplicate" app/`
|
||||
Expected: 0 совпадений.
|
||||
|
||||
- [ ] **Step 7: Коммит**
|
||||
|
||||
```bash
|
||||
git add app/app/Jobs/ProcessWebhookJob.php app/tests/Feature/ProcessWebhookJobTest.php
|
||||
git rm app/app/Services/DuplicateDetector.php
|
||||
git commit -m "refactor(billing-v2): remove DuplicateDetector + phone dedup from ProcessWebhookJob (Spec B)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Замок в `RouteSupplierLeadJob::createDealCopyForProject`
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/app/Jobs/RouteSupplierLeadJob.php`
|
||||
- Test: `app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php`
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест замка (повторная выдача той же поставки клиенту)**
|
||||
|
||||
Добавить в `SupplierLeadDeliveryGuardTest.php` (определить локальный 6-арговый `runRouteJob`-хелпер в этом файле, без `DuplicateDetector`):
|
||||
```php
|
||||
use App\Services\LeadRouter;
|
||||
use App\Services\LeadDistributor;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\RegionTagResolver;
|
||||
use App\Services\Billing\LedgerService;
|
||||
use App\Services\SupplierProjects\SupplierProjectResolver;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
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('lock: re-running same delivery to same tenant does not double-charge', function (): void {
|
||||
$this->seed(PricingTierSeeder::class);
|
||||
|
||||
$sp = SupplierProject::factory()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'lock.ru',
|
||||
]);
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 0, 'balance_rub' => '100000.00']);
|
||||
$p = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id, 'is_active' => true,
|
||||
'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127,
|
||||
]);
|
||||
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);
|
||||
|
||||
// Сбросить processed_at, чтобы пройти мимо idempotency-guard и проверить ИМЕННО замок БД.
|
||||
$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);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Запустить — убедиться, что падает**
|
||||
|
||||
Run: `php artisan test tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php --filter="re-running same delivery"`
|
||||
Expected: FAIL (без замка второй прогон создаёт вторую сделку + второй charge).
|
||||
|
||||
- [ ] **Step 3: Вставить замок в `createDealCopyForProject`**
|
||||
|
||||
В `app/app/Jobs/RouteSupplierLeadJob.php`, внутри `DB::transaction` в `createDealCopyForProject`, ПОСЛЕ `SET LOCAL app.current_tenant_id`, lock'а tenant и recheck'а лимита проекта, но ДО `Deal::create`:
|
||||
```php
|
||||
// Spec B: замок «одна поставка одному клиенту = один раз».
|
||||
// insertOrIgnore вернёт 0, если строка (supplier_lead_id, tenant_id) уже есть —
|
||||
// эта поставка уже выдавалась этому клиенту (гонка / перезапуск / CSV). Без charge.
|
||||
$locked = DB::table('supplier_lead_deliveries')->insertOrIgnore([
|
||||
'supplier_lead_id' => $lead->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
if ($locked === 0) {
|
||||
Log::info('supplier_lead.delivery_already_locked', [
|
||||
'supplier_lead_id' => $lead->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
```
|
||||
После `Deal::create([...])` добавить проставление `deal_id` в замок:
|
||||
```php
|
||||
DB::table('supplier_lead_deliveries')
|
||||
->where('supplier_lead_id', $lead->id)
|
||||
->where('tenant_id', $tenant->id)
|
||||
->update(['deal_id' => $deal->id]);
|
||||
```
|
||||
NB: `insertOrIgnore` под RLS-политикой `tenant_isolation` — `app.current_tenant_id` уже выставлен в этой транзакции, WITH CHECK (= USING) пройдёт.
|
||||
|
||||
- [ ] **Step 4: Прогон**
|
||||
|
||||
Run: `php artisan test tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php`
|
||||
Expected: PASS (все кейсы файла, включая Task 3 «2 проекта → 1 сделка»).
|
||||
|
||||
- [ ] **Step 5: Коммит**
|
||||
|
||||
```bash
|
||||
git add app/app/Jobs/RouteSupplierLeadJob.php app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php
|
||||
git commit -m "feat(billing-v2): per-(delivery,tenant) lock guard in RouteSupplierLeadJob (Spec B)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Тесты политики дублей (model-agnostic) + reconcile прочих сюит
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php`
|
||||
- Modify: затронутые тесты с `DuplicateDetector`/`runRouteJob` / `balance_leads`-долгом
|
||||
|
||||
- [ ] **Step 1: Тест «два разных vid, один телефон, один клиент → оба charge»**
|
||||
|
||||
Добавить в `SupplierLeadDeliveryGuardTest.php`:
|
||||
```php
|
||||
it('same phone, two different deliveries to one tenant → both charged', function (): void {
|
||||
$this->seed(PricingTierSeeder::class);
|
||||
|
||||
$sp = SupplierProject::factory()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'twohit.ru',
|
||||
]);
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 0, 'balance_rub' => '100000.00']);
|
||||
$p = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id, 'is_active' => true,
|
||||
'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127,
|
||||
]);
|
||||
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);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Тест «5 клиентов под источник → ровно 3 списания у 3 клиентов»**
|
||||
|
||||
Добавить (сидируемый distributor для детерминизма, как в существующем cap-тесте):
|
||||
```php
|
||||
use Random\Engine\Mt19937;
|
||||
use Random\Randomizer;
|
||||
|
||||
it('cap = 3 distinct tenants: 5 eligible tenants → exactly 3 charged', 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_leads' => 0, 'balance_rub' => '100000.00']);
|
||||
$p = Project::factory()->create([
|
||||
'tenant_id' => $t->id, 'is_active' => true,
|
||||
'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127,
|
||||
]);
|
||||
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(LeadCharge::query()->where('tier_no', '>=', 0)->count())->toBe(3);
|
||||
// 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);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Прогон файла**
|
||||
|
||||
Run: `php artisan test tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php`
|
||||
Expected: PASS все кейсы.
|
||||
|
||||
- [ ] **Step 4: Reconcile прочих сюит, ломающихся сигнатурой/моделью**
|
||||
|
||||
Найти все вызовы 7-арговой `handle()` и ссылки на DuplicateDetector:
|
||||
```bash
|
||||
grep -rln "DuplicateDetector\|app(DuplicateDetector" app/tests
|
||||
```
|
||||
В каждом файле (`RouteSupplierLeadJobBillingTest.php`, `Integration/SupplierLeadFlowTest.php`, `AutoPauseFlowTest.php`, `Pd/DealCreatePdLogTest.php`, и т.п.):
|
||||
- убрать `app(DuplicateDetector::class),` из вызовов `handle()` (→ 6 аргументов);
|
||||
- убрать `use App\Services\DuplicateDetector;`;
|
||||
- удалить/переписать кейсы, проверявшие телефонный дедуп.
|
||||
Если эти тесты используют `balance_leads`-ассерты, несовместимые с always-rub (тест-долг Спека A) и попадают в зону правки — мигрировать на `balance_rub`/`LeadCharge` по образцу `RouteSupplierLeadJobBillingTest` rub-кейса. Тесты, которые мы не трогаем и которые были красны до Task 1, оставить как есть (вне scope Спека B; зафиксировать в отчёте).
|
||||
|
||||
- [ ] **Step 5: Прогон затронутых сюит**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
php artisan test tests/Feature/Jobs/RouteSupplierLeadJobTest.php tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php tests/Feature/Integration/SupplierLeadFlowTest.php tests/Feature/Supplier/AutoPauseFlowTest.php tests/Feature/Pd/DealCreatePdLogTest.php tests/Feature/Supplier/CsvReconcileJobTest.php
|
||||
```
|
||||
Expected: GREEN (кроме явно задокументированного pre-existing `balance_leads`-долга, если решено его не трогать).
|
||||
|
||||
- [ ] **Step 6: Коммит**
|
||||
|
||||
```bash
|
||||
git add app/tests
|
||||
git commit -m "test(billing-v2): dup-policy tests (no phone dedup, per-client cap, lock) + signature reconcile"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Финальная регрессия + чистка
|
||||
|
||||
**Files:** нет новых правок (verify).
|
||||
|
||||
- [ ] **Step 1: Verify — нет `duplicate_detected` / `duplicate_of_id`-записи**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
grep -rn "duplicate_detected" app/ db/ # ожидать 0
|
||||
grep -rn "duplicate_of_id" app/app # ожидать 0 (колонка спящая, код не пишет)
|
||||
```
|
||||
Expected: 0 совпадений в коде (комментарии/CHANGELOG допустимы).
|
||||
|
||||
- [ ] **Step 2: DROP лишнего индекса (миграция + schema уже правлены в Task 2 Step 5)**
|
||||
|
||||
Создать `db/migrations/2026_05_23_201_drop_deals_duplicate_of_id_index.sql`:
|
||||
```sql
|
||||
-- Индекс по deals(duplicate_of_id) больше не нужен — телефонный дедуп удалён (Spec B).
|
||||
DROP INDEX IF EXISTS deals_duplicate_of_id_idx;
|
||||
```
|
||||
NB: имя индекса автоген — уточнить: `grep -n "duplicate_of_id" db/schema.sql` + на dev `\di deals*` / `SELECT indexname FROM pg_indexes WHERE tablename='deals' AND indexdef ILIKE '%duplicate_of_id%'`. Подставить фактическое имя.
|
||||
Создать парную `app/database/migrations/2026_05_23_201000_drop_deals_duplicate_of_id_index.php` (паттерн как Task 2 Step 4, idempotent через `DROP INDEX IF EXISTS`; `up()` грузит .sql, `down()` — пусто или воссоздаёт индекс). Убедиться, что `CREATE INDEX ... deals (duplicate_of_id)` уже убран из `db/schema.sql` (Task 2 Step 5).
|
||||
|
||||
- [ ] **Step 3: Линт/статика**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
composer pint
|
||||
composer stan
|
||||
```
|
||||
Expected: Pint clean; Larastan 0 новых ошибок (для baseline в worktree скопировать `_ide_helper*.php` из основного чекаута — квирк A1-tooling).
|
||||
|
||||
- [ ] **Step 4: Полная backend-регрессия**
|
||||
|
||||
Run: `php artisan test --parallel`
|
||||
Expected: GREEN; кроме явно задокументированного pre-existing `balance_leads`-тест-долга Спека A, если он не входил в scope правок. Зафиксировать итог в отчёте.
|
||||
|
||||
- [ ] **Step 5: Финальный коммит миграции индекса**
|
||||
|
||||
```bash
|
||||
git add db/migrations/2026_05_23_201_drop_deals_duplicate_of_id_index.sql \
|
||||
app/database/migrations/2026_05_23_201000_drop_deals_duplicate_of_id_index.php
|
||||
git commit -m "chore(billing-v2): drop unused deals(duplicate_of_id) index (Spec B)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (выполнено автором плана)
|
||||
|
||||
- **Покрытие спека:** §3.1 убрать фильтр → Tasks 4,5; §3.2 раздача по клиентам → Task 3; §3.3 замок БД → Tasks 2,6; §3.4 чистка следов → Tasks 2 (индекс), 8 (verify; `duplicate_detected` отсутствует в base — подтверждено); §3.5 не трогаем (vid-идемпотентность/CSV-дедуп) → не затрагиваются; §4 крайние случаи → тесты Tasks 6,7; §5 тесты → Tasks 5,6,7; §6 выкатка одна-фазная + CHANGELOG → Task 2.
|
||||
- **Плейсхолдеры:** код приведён для всех правок; имя индекса в Task 8 — единственное «уточнить прогоном» (автоген PG-имя, нельзя знать без БД — дана точная команда выяснения).
|
||||
- **Согласованность типов:** `runRouteJobB` (6 арг, без DuplicateDetector) — единый хелпер новых тестов; `insertOrIgnore` возвращает int (кол-во вставленных); `LedgerService::chargeForDelivery` сигнатура неизменна; таблица `supplier_lead_deliveries` колонки совпадают между DDL, моделью и тестами.
|
||||
- **Scope:** один связный план; pre-existing `balance_leads`-тест-долг Спека A явно вынесен как «вне scope, по решению — мигрировать только затронутое».
|
||||
Reference in New Issue
Block a user