Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| af15f24de7 | |||
| b757f22b97 | |||
| 31b53557ac | |||
| be27713f6e | |||
| 60dd3e70b1 | |||
| 54967147d7 | |||
| 1a02b4b5f2 | |||
| 76ea9bbb04 | |||
| 62b5306548 | |||
| 01562afd31 | |||
| b7466ebfbd | |||
| 17e3c04f24 | |||
| ba49805689 | |||
| 95ee6644f7 | |||
| a0e18a1dd8 | |||
| 9e0490c328 | |||
| 80275c6417 | |||
| 36c71ecb1e | |||
| c99362a3e5 | |||
| 9331465c26 | |||
| 9d9bcf7847 | |||
| c7fd90c08d | |||
| e35fc6c938 | |||
| f1a3e9f02f | |||
| d0eecbbf79 | |||
| 01d292f5a9 |
@@ -0,0 +1,62 @@
|
||||
---
|
||||
name: laravel-backend-patterns
|
||||
description: Backend-конвенции Лидерры (Laravel 13) — как писать controller→service→job, RLS-aware Eloquent, деньги через bcmath/LedgerService, идемпотентные джобы, partition-aware запросы. Используй при «как писать backend в Лидерре», «паттерн контроллера/сервиса/джоба», scaffolding новой backend-фичи. НЕ для generic-паттернов (architecture-patterns #38), аудита денег (billing-audit #62), РСБУ/налогов (ru-tax-accounting), security-аудита (D3).
|
||||
---
|
||||
|
||||
# Laravel Backend Patterns — конвенции backend-кода Лидерры
|
||||
|
||||
Проектный скил, который описывает **как здесь пишут backend**, а не как рекомендует generic-Laravel.
|
||||
При scaffolding новой фичи или ревью кода — сверяться с пятью конвенциями ниже.
|
||||
Детальные примеры с образцами кода и антипаттернами — в `references/conventions.md`.
|
||||
|
||||
## 1. Слоистость: Controller → FormRequest → Service → Job
|
||||
|
||||
Контроллер тонкий: принимает FormRequest, делегирует Service, возвращает JSON-ответ.
|
||||
Бизнес-логика — в Service; асинхронная работа — в Job.
|
||||
Слои зафиксированы в `app/deptrac.yaml` (13 слоёв, pre-commit gate job 10).
|
||||
|
||||
Подробнее: `references/conventions.md` §1.
|
||||
|
||||
## 2. RLS-aware Eloquent и middleware `tenant`
|
||||
|
||||
Middleware `SetTenantContext` оборачивает HTTP-запрос в транзакцию и выполняет
|
||||
`SET LOCAL app.current_tenant_id = X`, обеспечивая RLS-изоляцию между tenant'ами.
|
||||
**КРИТИЧНО**: очередные джобы выполняются под ролью `crm_supplier_worker` (BYPASSRLS),
|
||||
поэтому RLS не фильтрует. Каждый запрос в джобе **обязан** содержать явный
|
||||
`where('tenant_id', $tenantId)` или устанавливать `SET LOCAL` вручную внутри транзакции.
|
||||
|
||||
Подробнее: `references/conventions.md` §2.
|
||||
|
||||
## 3. Деньги — только через bcmath и LedgerService
|
||||
|
||||
Все денежные операции — `bcadd` / `bcsub` / `bcmul` / `bcdiv` / `bccomp` со строковыми операндами
|
||||
и фиксированным `scale`. Никаких операторов `+` / `-` / `*` / `/` над деньгами, никакого `float`.
|
||||
Точка входа для биллингового списания — `LedgerService::chargeForDelivery()`.
|
||||
Аудит денежных инвариантов кода — скил `billing-audit` (#62); здесь — только конвенция написания.
|
||||
|
||||
Подробнее: `references/conventions.md` §3.
|
||||
|
||||
## 4. Идемпотентные джобы через advisory lock
|
||||
|
||||
Повторный запуск джоба не должен дублировать результат.
|
||||
Паттерн: `pg_advisory_xact_lock(composite_bigint)` внутри транзакции — сериализует
|
||||
конкурентные обработки одного (tenant_id, source_crm_id). Дополнительно: `lockForUpdate`
|
||||
на строку Tenant защищает баланс от TOCTOU при конкурентных списаниях.
|
||||
|
||||
Подробнее: `references/conventions.md` §4.
|
||||
|
||||
## 5. Partition-aware запросы для `deals` и `supplier_lead_costs`
|
||||
|
||||
Таблицы `deals` и `supplier_lead_costs` секционированы по `RANGE (received_at)`.
|
||||
Запросы к этим таблицам должны включать условие по `received_at` (или `created_at`
|
||||
для `supplier_lead_costs`) — это включает pruning и предотвращает full-scan всех партиций.
|
||||
|
||||
Подробнее: `references/conventions.md` §5.
|
||||
|
||||
## Связано
|
||||
|
||||
- `billing-audit` #62 — аудит денежной корректности (I1–I5 инварианты).
|
||||
- `architecture-patterns` #38 — общие паттерны архитектуры (не Лидерра-специфика).
|
||||
- Boost #10 — Eloquent introspection, документация Laravel 13.
|
||||
- Larastan #12 — статанализ PHP (ловит float-арифметику на деньгах).
|
||||
- ADR-005 — deptrac architecture-fitness gate.
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"skill": "laravel-backend-patterns",
|
||||
"cases": [
|
||||
{"prompt": "как написать контроллер для новой backend-фичи в Лидерре", "should_trigger": true},
|
||||
{"prompt": "как правильно списать деньги в джобе под crm_supplier_worker", "should_trigger": true},
|
||||
{"prompt": "проверь, не теряются ли копейки в списании", "should_trigger": false, "expected": "billing-audit"},
|
||||
{"prompt": "опиши Clean Architecture в общем", "should_trigger": false, "expected": "architecture-patterns"},
|
||||
{"prompt": "учёт выручки по РСБУ", "should_trigger": false, "expected": "ru-tax-accounting"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
# Backend-конвенции Лидерры — детальный справочник
|
||||
|
||||
Образцы ниже — реальный код из `app/` (Laravel 13, PHP 8.3).
|
||||
Указаны конкретные `file:line` на момент 20.05.2026.
|
||||
|
||||
---
|
||||
|
||||
## §1. Слоистость: Controller → FormRequest → Service → Job
|
||||
|
||||
### Правило
|
||||
|
||||
Контроллер принимает FormRequest (валидация), делегирует Service (бизнес-логика),
|
||||
при необходимости Service dispatch'ит Job (асинхрон). Контроллер не содержит бизнес-логики.
|
||||
Слои задокументированы в `app/deptrac.yaml` — 13 слоёв:
|
||||
Controller, Request, Resource, Middleware, Service, Job, Console, Repository,
|
||||
Model, Mail, Rule, Exception, Provider.
|
||||
Допустимые направления зависимостей — только вниз по иерархии (deptrac gate, lefthook job 10).
|
||||
|
||||
### Образец из кода
|
||||
|
||||
`app/app/Http/Controllers/Api/ProjectController.php:87–90` — контроллер тонкий:
|
||||
|
||||
```php
|
||||
/** POST /api/projects */
|
||||
public function store(StoreProjectRequest $request): JsonResponse
|
||||
{
|
||||
$project = $this->projects->create($request->user()->tenant, $request->validated());
|
||||
|
||||
return response()->json(['data' => new ProjectResource($project)], 201);
|
||||
}
|
||||
```
|
||||
|
||||
`app/app/Http/Requests/StoreProjectRequest.php:18–44` — вся валидация в FormRequest:
|
||||
|
||||
```php
|
||||
public function rules(): array
|
||||
{
|
||||
$base = [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'signal_type' => ['required', Rule::in(['site', 'call', 'sms'])],
|
||||
'daily_limit_target' => ['required', 'integer', 'min:1', 'max:10000'],
|
||||
'regions' => ['present', 'array'],
|
||||
'regions.*' => ['integer', 'between:1,89'],
|
||||
'delivery_days_mask' => ['required', 'integer', 'min:1', 'max:127'],
|
||||
];
|
||||
// ... conditional rules by signal_type
|
||||
return $base;
|
||||
}
|
||||
```
|
||||
|
||||
`app/app/Services/Billing/LedgerService.php` — бизнес-логика в Service.
|
||||
`app/app/Jobs/ProcessWebhookJob.php` — асинхрон в Job.
|
||||
|
||||
### Антипаттерн
|
||||
|
||||
```php
|
||||
// ПЛОХО: бизнес-логика в контроллере
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$tier = PricingTier::where('min_leads', '<=', $count)->orderBy('min_leads', 'desc')->first();
|
||||
$price = $tier->price_per_lead_kopecks * $count; // float-арифметика + логика тира прямо здесь
|
||||
Deal::create([...]);
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## §2. RLS-aware Eloquent и middleware `tenant`
|
||||
|
||||
### Правило
|
||||
|
||||
Middleware `SetTenantContext` (`app/app/Http/Middleware/SetTenantContext.php`) оборачивает
|
||||
каждый HTTP-запрос в транзакцию и выполняет `SET LOCAL app.current_tenant_id = X`,
|
||||
после чего RLS-политики PostgreSQL автоматически фильтруют строки по tenant.
|
||||
|
||||
**КРИТИЧНО для джобов**: очередные джобы Laravel выполняются в отдельном процессе вне
|
||||
HTTP-стека. Роль `crm_supplier_worker` (connection `pgsql_supplier`) имеет атрибут
|
||||
BYPASSRLS — RLS-политики для неё **не применяются**. Любой запрос в таком джобе без
|
||||
явного `where('tenant_id', $tenantId)` вернёт строки всех tenant'ов.
|
||||
|
||||
Правило: в каждом джобе либо устанавливай `SET LOCAL` внутри транзакции (паттерн
|
||||
`ProcessWebhookJob`/`ImportLeadsJob`), либо добавляй явный `where('tenant_id', ...)`.
|
||||
|
||||
### Образец из кода
|
||||
|
||||
`app/app/Http/Middleware/SetTenantContext.php:36–43` — HTTP-путь:
|
||||
|
||||
```php
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = ' . $tenantId);
|
||||
$response = $next($request);
|
||||
DB::commit();
|
||||
return $response;
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
throw $e;
|
||||
}
|
||||
```
|
||||
|
||||
`app/app/Jobs/ImportLeadsJob.php:92–96` — джоб устанавливает `SET LOCAL` вручную:
|
||||
|
||||
```php
|
||||
return DB::transaction(function (): ?ImportLog {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = ' . $this->tenantId);
|
||||
return ImportLog::query()->find($this->importLogId);
|
||||
});
|
||||
```
|
||||
|
||||
`app/app/Jobs/ProcessWebhookJob.php:80–86` — аналогичный паттерн в webhook-джобе:
|
||||
|
||||
```php
|
||||
DB::transaction(function () use ($duplicateDetector): void {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = ' . $this->tenantId);
|
||||
$tenant = Tenant::query()
|
||||
->whereKey($this->tenantId)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
```
|
||||
|
||||
### Антипаттерн
|
||||
|
||||
```php
|
||||
// ПЛОХО: джоб под crm_supplier_worker без SET LOCAL и без where tenant_id
|
||||
// → вернёт все строки всех tenant'ов (BYPASSRLS не фильтрует)
|
||||
public function handle(): void
|
||||
{
|
||||
$logs = ImportLog::query()->where('status', 'pending')->get(); // ВСЕ tenant'ы!
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## §3. Деньги — только через bcmath и LedgerService
|
||||
|
||||
### Правило
|
||||
|
||||
Все арифметические операции с деньгами (рубли, копейки) — исключительно через
|
||||
функции `bcmath` с явным `scale`. Операнды передаются строками.
|
||||
Никаких PHP `float`, никакого `+` / `-` / `*` / `/` над денежными значениями.
|
||||
|
||||
Точка входа для списания за лид — `LedgerService::chargeForDelivery()`.
|
||||
Этот метод реализует dual-balance flow (prepaid-лиды → `balance_leads`, рубли → `balance_rub`).
|
||||
Вызывается **внутри открытой транзакции** с `lockForUpdate(Tenant)` — см. §4.
|
||||
|
||||
Аудит денежных инвариантов (I1–I5) — скил `billing-audit` (#62). Здесь — конвенция написания.
|
||||
|
||||
### Образец из кода
|
||||
|
||||
`app/app/Services/Billing/LedgerService.php:64–65` — конвертация копеек в рубли:
|
||||
|
||||
```php
|
||||
$amountRub = bcdiv((string) $priceKopecks, '100', 2);
|
||||
$newBalanceRub = bcsub((string) $lockedTenant->balance_rub, $amountRub, 2);
|
||||
```
|
||||
|
||||
`app/app/Services/Billing/LedgerService.php:124–125` — сравнение балансов:
|
||||
|
||||
```php
|
||||
$balanceKopecks = bcmul((string) $tenant->balance_rub, '100', 0);
|
||||
if (bccomp($balanceKopecks, (string) $priceKopecks, 0) >= 0) {
|
||||
return 'rub';
|
||||
}
|
||||
```
|
||||
|
||||
### Антипаттерн
|
||||
|
||||
```php
|
||||
// ПЛОХО: float-арифметика теряет копейки
|
||||
$price = $tier->price_per_lead_kopecks / 100; // float
|
||||
$newBalance = $tenant->balance_rub - $price; // потеря точности при накоплении
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## §4. Идемпотентные джобы через advisory lock
|
||||
|
||||
### Правило
|
||||
|
||||
Повторный запуск джоба (ретрай, краш, дубль cron) не должен создавать дублирующие
|
||||
записи. Паттерн: `pg_advisory_xact_lock(bigint)` внутри транзакции сериализует все
|
||||
конкурентные обработки одного (tenant_id, source_crm_id).
|
||||
|
||||
Дополнительно для мутаций баланса: `lockForUpdate` на строку Tenant — защита от
|
||||
TOCTOU (между чтением баланса и его обновлением другой воркер не должен изменить значение).
|
||||
|
||||
### Образец из кода
|
||||
|
||||
`app/app/Jobs/ProcessWebhookJob.php:293–296` — advisory lock перед upsert:
|
||||
|
||||
```php
|
||||
// pg_advisory_xact_lock(bigint): верхние 32 бита = tenant_id, нижние 32 = source_crm_id
|
||||
$lockKey = (($tenant->id & 0xFFFFFFFF) << 32) | ($sourceCrmId & 0xFFFFFFFF);
|
||||
DB::statement('SELECT pg_advisory_xact_lock(?)', [$lockKey]);
|
||||
```
|
||||
|
||||
`app/app/Services/Import/HistoricalImportService.php:145–147` — тот же паттерн в сервисе:
|
||||
|
||||
```php
|
||||
// advisory lock (tenant_id, source_crm_id) — сериализует upsert (§6.5)
|
||||
$lockKey = (($tenantId & 0xFFFFFFFF) << 32) | ($row->sourceCrmId & 0xFFFFFFFF);
|
||||
DB::statement('SELECT pg_advisory_xact_lock(?)', [$lockKey]);
|
||||
```
|
||||
|
||||
`app/app/Jobs/RouteSupplierLeadJob.php:210–213` — lockForUpdate на Tenant перед списанием:
|
||||
|
||||
```php
|
||||
$tenant = Tenant::query()
|
||||
->whereKey($project->tenant_id)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
```
|
||||
|
||||
Для overlap-защиты долгоживущих джобов (cron) — `Cache::lock` (Redis):
|
||||
`app/app/Jobs/Supplier/CsvReconcileJob.php:69–74`:
|
||||
|
||||
```php
|
||||
$lock = $lockStore->lock(self::LOCK_NAME, self::LOCK_TTL_SECONDS);
|
||||
if (! $lock->get()) {
|
||||
Log::info('csv_reconcile.skipped_overlap');
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
### Антипаттерн
|
||||
|
||||
```php
|
||||
// ПЛОХО: нет lock — два конкурентных воркера создают два deal для одного vid
|
||||
$existing = Deal::where('source_crm_id', $vid)->where('tenant_id', $tenantId)->first();
|
||||
if (!$existing) {
|
||||
Deal::create([...]); // race condition: оба воркера видят null и оба создают
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## §5. Partition-aware запросы для `deals` и `supplier_lead_costs`
|
||||
|
||||
### Правило
|
||||
|
||||
Таблицы `deals` и `supplier_lead_costs` секционированы по `PARTITION BY RANGE (received_at)`.
|
||||
Запросы должны содержать условие по `received_at` (ключ партиционирования) — это позволяет
|
||||
PostgreSQL выполнять partition pruning и не сканировать все партиции.
|
||||
Запрос без `WHERE received_at ...` делает full-scan всех партиций.
|
||||
|
||||
### Образец из кода
|
||||
|
||||
`db/schema.sql:1658` — партиционирование `deals`:
|
||||
|
||||
```sql
|
||||
) PARTITION BY RANGE (received_at);
|
||||
```
|
||||
|
||||
`db/schema.sql:2361` — партиционирование `supplier_lead_costs`:
|
||||
|
||||
```sql
|
||||
) PARTITION BY RANGE (received_at);
|
||||
```
|
||||
|
||||
`app/app/Services/DuplicateDetector.php:49` — запрос к `deals` с ключом партиции:
|
||||
|
||||
```php
|
||||
->where('received_at', '>=', $windowStart)
|
||||
```
|
||||
|
||||
`app/app/Jobs/Supplier/CsvReconcileJob.php:113` — запрос к `supplier_leads` с ключом:
|
||||
|
||||
```php
|
||||
->where('received_at', '>=', $windowStart)
|
||||
```
|
||||
|
||||
### Антипаттерн
|
||||
|
||||
```php
|
||||
// ПЛОХО: запрос к deals без received_at — full-scan всех партиций
|
||||
$deals = Deal::where('tenant_id', $tenantId)
|
||||
->where('phone', $phone)
|
||||
->get(); // сканирует deals_2026_05, deals_2026_06, ... все партиции
|
||||
```
|
||||
@@ -10,6 +10,9 @@ use App\Models\Project;
|
||||
use App\Models\SupplierManualSyncQueue;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Services\Supplier\Channel\SupplierProjectChannel;
|
||||
use App\Services\Supplier\SupplierExportMode;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
use App\Support\RussianRegions;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -142,4 +145,114 @@ final class AdminSupplierIntegrationController extends Controller
|
||||
|
||||
return response()->json(['resolved' => true, 'external_id' => $found]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Глобальный режим экспорта проектов поставщику (Plan 4 Task 1).
|
||||
* Spec: docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.1.
|
||||
*/
|
||||
public function getExportMode(): JsonResponse
|
||||
{
|
||||
return response()->json(['mode' => SupplierExportMode::current()]);
|
||||
}
|
||||
|
||||
public function setExportMode(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'mode' => ['required', 'in:online,batch'],
|
||||
]);
|
||||
|
||||
DB::table('system_settings')->updateOrInsert(
|
||||
['key' => 'supplier_export_mode'],
|
||||
['value' => $data['mode'], 'type' => 'string', 'updated_at' => now()],
|
||||
);
|
||||
|
||||
return response()->json(['mode' => $data['mode']]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan 4 Task 2: список supplier_projects + кто заказывал (через pivot →
|
||||
* projects → tenants) + дата последней поставки лида.
|
||||
*/
|
||||
public function projectsIndex(): JsonResponse
|
||||
{
|
||||
$rows = DB::table('supplier_projects as sp')
|
||||
->select([
|
||||
'sp.id',
|
||||
'sp.platform',
|
||||
'sp.signal_type',
|
||||
'sp.unique_key',
|
||||
'sp.subject_code',
|
||||
'sp.supplier_external_id',
|
||||
'sp.current_limit',
|
||||
'sp.inactive_since',
|
||||
])
|
||||
->orderBy('sp.unique_key')
|
||||
->orderBy('sp.subject_code')
|
||||
->orderBy('sp.platform')
|
||||
->get();
|
||||
|
||||
$projects = $rows->map(function ($sp): array {
|
||||
$orderers = DB::table('project_supplier_links as psl')
|
||||
->join('projects as p', 'p.id', '=', 'psl.project_id')
|
||||
->join('tenants as t', 't.id', '=', 'p.tenant_id')
|
||||
->where('psl.supplier_project_id', $sp->id)
|
||||
->distinct()
|
||||
->pluck('t.organization_name')
|
||||
->all();
|
||||
|
||||
$lastDelivery = DB::table('supplier_leads')
|
||||
->where('supplier_project_id', $sp->id)
|
||||
->max('received_at');
|
||||
|
||||
$subjectCode = $sp->subject_code !== null ? (int) $sp->subject_code : null;
|
||||
|
||||
return [
|
||||
'id' => (int) $sp->id,
|
||||
'platform' => $sp->platform,
|
||||
'signal_type' => $sp->signal_type,
|
||||
'unique_key' => $sp->unique_key,
|
||||
'subject_code' => $subjectCode,
|
||||
'subject_name' => $subjectCode !== null
|
||||
? (RussianRegions::CODE_TO_NAME[$subjectCode] ?? null)
|
||||
: 'РФ',
|
||||
'current_limit' => (int) $sp->current_limit,
|
||||
'supplier_external_id' => $sp->supplier_external_id,
|
||||
'inactive_since' => $sp->inactive_since,
|
||||
'orderers' => $orderers,
|
||||
'last_delivery_at' => $lastDelivery,
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json(['projects' => $projects->all()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan 4 Task 2: bulk-delete выбранных supplier_projects.
|
||||
* Сначала на портале (deleteProject), затем локально (pivot снимается CASCADE).
|
||||
* Сбой по строке — не прерывает batch, копится в failures[].
|
||||
*/
|
||||
public function projectsDestroy(Request $request, SupplierPortalClient $client): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'ids' => ['required', 'array', 'min:1'],
|
||||
'ids.*' => ['integer'],
|
||||
]);
|
||||
|
||||
$deleted = 0;
|
||||
$failures = [];
|
||||
|
||||
foreach (SupplierProject::whereIn('id', $data['ids'])->get() as $sp) {
|
||||
try {
|
||||
if ($sp->supplier_external_id !== null) {
|
||||
$client->deleteProject((int) $sp->supplier_external_id);
|
||||
}
|
||||
$sp->delete();
|
||||
$deleted++;
|
||||
} catch (\Throwable $e) {
|
||||
$failures[] = ['id' => $sp->id, 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json(['deleted' => $deleted, 'failures' => $failures]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,21 +36,26 @@ use Throwable;
|
||||
* Daily 18:00 МСК cron-job: синхронизирует supplier_projects с поставщиком crm.bp-gr.ru
|
||||
* (расписание перенесено 20:30 → 18:00, см. routes/console.php).
|
||||
*
|
||||
* Алгоритм (Plan 3 Task 5 — per-subject grouping):
|
||||
* Алгоритм (план 3 Task 5 → переработан: one-group-per-identifier):
|
||||
* 1. Загрузить активные Лидерра-projects (is_active=true, archived_at IS NULL).
|
||||
* 2. Развернуть каждый в группы (signal_type, identifier, subject_code):
|
||||
* - subjects = project.regions (1..89); пусто → одна группа subject_code=null («Вся РФ»).
|
||||
* - identifier = buildUniqueKey() (site/call → signal_identifier; sms B2 → sender+keyword; B3 → sender).
|
||||
* 2. Сгруппировать по (signal_type, identifier) — БЕЗ subject_code:
|
||||
* - identifier = buildUniqueKeyAgnostic() (site/call → signal_identifier; sms+keyword → sender+keyword; sms → sender).
|
||||
* - platforms = resolvePlatforms() (site/call → B1+B2+B3; sms+keyword → B2+B3; sms → B3).
|
||||
* - merged_regions = union(project.regions) по всем проектам группы.
|
||||
* Если хотя бы один проект имеет regions=[] («Вся РФ»), merged_regions=[].
|
||||
* 3. Для каждой группы:
|
||||
* - eligible-today проекты группы (workday-маска на завтра).
|
||||
* - order = computeOrder($eligibleLimits); workdays = union; tag / regions из subject.
|
||||
* - Найти существующие supplier_projects (unique_key, subject_code):
|
||||
* - Нет → saveProjectMultiFlag → 3 id → upsert supplier_projects.
|
||||
* - Есть → updateProject каждого (R6: один лимит).
|
||||
* - Pivot: для каждого Лидерра-проекта × каждого supplier_project → INSERT ... ON CONFLICT DO NOTHING.
|
||||
* - order = computeOrder($eligibleLimits); workdays = union.
|
||||
* - tag = name региона если один, иначе «РФ».
|
||||
* - Найти существующие supplier_projects (unique_key, signal_type, platform) — без subject_code-фильтра:
|
||||
* - Нет → saveProjectMultiFlag → [platform → id] → upsert supplier_projects (subject_code=null).
|
||||
* - Есть → partial-set recovery + updateProject каждого с актуальными regions/limit.
|
||||
* - Pivot: project × supplier_project → INSERT ... ON CONFLICT DO NOTHING (subject_code=null).
|
||||
* 4. Failure-handling (Auth/Transient/Client/Window/TierEscalated), time-budget cutoff — сохранены.
|
||||
*
|
||||
* Портальное ограничение: один identifier = одна группа B1/B2/B3 (status=Doubles на дублирование).
|
||||
* Поэтому все регионы проекта передаются одним списком — portal фильтрует оба одновременно.
|
||||
*
|
||||
* NOTE про connection: Eloquent::on('pgsql_supplier') для cross-tenant видимости.
|
||||
*
|
||||
* Spec:
|
||||
@@ -85,9 +90,10 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
// 2. Expand into groups (signal_type, identifier, subject_code)
|
||||
// group key => [ 'signal_type', 'identifier', 'subject_code', 'platforms', 'projects' => [...] ]
|
||||
/** @var array<string, array{signal_type: string, identifier: string, subject_code: int|null, platforms: list<string>, projects: list<Project>}> $groups */
|
||||
// 2. Group by (signal_type, identifier) — no subject_code split.
|
||||
// Portal constraint: one identifier = one B1/B2/B3 group (status=Doubles on duplicate name).
|
||||
// group key => [ 'signal_type', 'identifier', 'merged_regions', 'platforms', 'projects' => [...] ]
|
||||
/** @var array<string, array{signal_type: string, identifier: string, merged_regions: list<int>, has_all_russia: bool, platforms: list<string>, projects: list<Project>}> $groups */
|
||||
$groups = [];
|
||||
|
||||
foreach ($projects as $project) {
|
||||
@@ -95,25 +101,33 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
if ($platforms === []) {
|
||||
continue;
|
||||
}
|
||||
// For sms, identifier depends on whether B2 is in platforms (keyword-aware)
|
||||
// We use the B2 key as identifier when B2 is present (sms+keyword), else B3 key (sender only)
|
||||
$identifier = SupplierProjectGrouping::buildUniqueKeyAgnostic($project);
|
||||
|
||||
$subjects = SupplierProjectGrouping::subjectsOf($project);
|
||||
|
||||
foreach ($subjects as $subjectCode) {
|
||||
$key = $project->signal_type.'|'.$identifier.'|'.($subjectCode ?? 'null');
|
||||
if (! isset($groups[$key])) {
|
||||
$groups[$key] = [
|
||||
'signal_type' => (string) $project->signal_type,
|
||||
'identifier' => $identifier,
|
||||
'subject_code' => $subjectCode,
|
||||
'platforms' => $platforms,
|
||||
'projects' => [],
|
||||
];
|
||||
}
|
||||
$groups[$key]['projects'][] = $project;
|
||||
$key = $project->signal_type.'|'.$identifier;
|
||||
if (! isset($groups[$key])) {
|
||||
$groups[$key] = [
|
||||
'signal_type' => (string) $project->signal_type,
|
||||
'identifier' => $identifier,
|
||||
'merged_regions' => [],
|
||||
'has_all_russia' => false,
|
||||
'platforms' => $platforms,
|
||||
'projects' => [],
|
||||
];
|
||||
}
|
||||
// Merge regions — union across all projects in this group.
|
||||
// If any project has empty regions ("Вся РФ"), the whole group becomes "Вся РФ".
|
||||
if (! $groups[$key]['has_all_russia']) {
|
||||
$projectRegions = array_map('intval', (array) ($project->regions ?? []));
|
||||
if ($projectRegions === []) {
|
||||
$groups[$key]['has_all_russia'] = true;
|
||||
$groups[$key]['merged_regions'] = [];
|
||||
} else {
|
||||
$groups[$key]['merged_regions'] = array_values(array_unique(
|
||||
array_merge($groups[$key]['merged_regions'], $projectRegions)
|
||||
));
|
||||
}
|
||||
}
|
||||
$groups[$key]['projects'][] = $project;
|
||||
}
|
||||
|
||||
// 3. Sync each group
|
||||
@@ -168,13 +182,12 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{signal_type: string, identifier: string, subject_code: int|null, platforms: list<string>, projects: list<Project>} $group
|
||||
* @param array{signal_type: string, identifier: string, merged_regions: list<int>, has_all_russia: bool, platforms: list<string>, projects: list<Project>} $group
|
||||
*/
|
||||
private function syncGroup(array $group): void
|
||||
{
|
||||
$signalType = $group['signal_type'];
|
||||
$identifier = $group['identifier'];
|
||||
$subjectCode = $group['subject_code'];
|
||||
$platforms = $group['platforms'];
|
||||
|
||||
/** @var list<Project> $groupProjects */
|
||||
@@ -207,19 +220,18 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
sort($workdaysUnion);
|
||||
$workdays = $workdaysUnion;
|
||||
|
||||
// Tag and regions from subject
|
||||
$tag = $subjectCode !== null ? (RussianRegions::CODE_TO_NAME[$subjectCode] ?? (string) $subjectCode) : 'РФ';
|
||||
$regions = $subjectCode !== null ? [$subjectCode] : [];
|
||||
// Portal constraint: one identifier = one B1/B2/B3 group — pass all regions as a single list.
|
||||
$allRegions = $group['merged_regions'];
|
||||
sort($allRegions);
|
||||
// count=0 → all-Russia; count=1 → named region; count>1 → merged → 'РФ'
|
||||
$tag = count($allRegions) === 1
|
||||
? (RussianRegions::CODE_TO_NAME[$allRegions[0]] ?? (string) $allRegions[0])
|
||||
: 'РФ';
|
||||
|
||||
// Find existing supplier_projects for this group
|
||||
// Find existing supplier_projects for this group (no subject_code filter)
|
||||
$existingSps = SupplierProject::on(self::DB_CONNECTION)
|
||||
->where('unique_key', $identifier)
|
||||
->where('signal_type', $signalType)
|
||||
->when(
|
||||
$subjectCode !== null,
|
||||
fn ($q) => $q->where('subject_code', $subjectCode),
|
||||
fn ($q) => $q->whereNull('subject_code'),
|
||||
)
|
||||
->whereIn('platform', $platforms)
|
||||
->get();
|
||||
|
||||
@@ -231,7 +243,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
uniqueKey: $identifier,
|
||||
limit: $order,
|
||||
workdays: $workdays,
|
||||
regions: $regions,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
@@ -251,11 +263,11 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
'platform' => $platform,
|
||||
'signal_type' => $signalType,
|
||||
'unique_key' => $identifier,
|
||||
'subject_code' => $subjectCode,
|
||||
'subject_code' => null,
|
||||
'supplier_external_id' => (string) $externalId,
|
||||
'current_limit' => $order,
|
||||
'current_workdays' => $workdays,
|
||||
'current_regions' => $regions,
|
||||
'current_regions' => $allRegions,
|
||||
'sync_status' => 'ok',
|
||||
'last_synced_at' => now(),
|
||||
]);
|
||||
@@ -284,7 +296,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
uniqueKey: $identifier,
|
||||
limit: $order,
|
||||
workdays: $workdays,
|
||||
regions: $regions,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
@@ -302,11 +314,11 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
'platform' => $platform,
|
||||
'signal_type' => $signalType,
|
||||
'unique_key' => $identifier,
|
||||
'subject_code' => $subjectCode,
|
||||
'subject_code' => null,
|
||||
'supplier_external_id' => (string) $externalId,
|
||||
'current_limit' => $order,
|
||||
'current_workdays' => $workdays,
|
||||
'current_regions' => $regions,
|
||||
'current_regions' => $allRegions,
|
||||
'sync_status' => 'ok',
|
||||
'last_synced_at' => now(),
|
||||
]);
|
||||
@@ -333,7 +345,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
uniqueKey: $identifier,
|
||||
limit: $order,
|
||||
workdays: $workdays,
|
||||
regions: $regions,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
@@ -343,7 +355,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
$sp->forceFill([
|
||||
'current_limit' => $order,
|
||||
'current_workdays' => $workdays,
|
||||
'current_regions' => $regions,
|
||||
'current_regions' => $allRegions,
|
||||
'sync_status' => 'ok',
|
||||
'last_synced_at' => now(),
|
||||
])->save();
|
||||
@@ -364,8 +376,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
'project_id' => $lp->id,
|
||||
'supplier_project_id' => $sp->id,
|
||||
'platform' => $sp->platform,
|
||||
// @phpstan-ignore-next-line property.notFound — subject_code is in $fillable/casts, IDE stubs lag
|
||||
'subject_code' => $sp->subject_code,
|
||||
'subject_code' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -375,7 +386,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
* Log failure for a group (before any supplier_project is created/updated we don't have sp id,
|
||||
* so we look up existing or skip — best-effort audit).
|
||||
*
|
||||
* @param array{signal_type: string, identifier: string, subject_code: int|null, platforms: list<string>, projects: list<Project>} $group
|
||||
* @param array{signal_type: string, identifier: string, merged_regions: list<int>, has_all_russia: bool, platforms: list<string>, projects: list<Project>} $group
|
||||
*/
|
||||
private function logGroupFailure(array $group, Throwable $e): void
|
||||
{
|
||||
@@ -384,15 +395,10 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
$httpStatus = $e->httpStatus;
|
||||
}
|
||||
|
||||
// Find any existing sp row for the group to link log entry
|
||||
// Find any existing sp row for the group to link log entry (no subject_code filter)
|
||||
$sp = SupplierProject::on(self::DB_CONNECTION)
|
||||
->where('unique_key', $group['identifier'])
|
||||
->where('signal_type', $group['signal_type'])
|
||||
->when(
|
||||
$group['subject_code'] !== null,
|
||||
fn ($q) => $q->where('subject_code', $group['subject_code']),
|
||||
fn ($q) => $q->whereNull('subject_code'),
|
||||
)
|
||||
->first();
|
||||
|
||||
if ($sp !== null) {
|
||||
|
||||
@@ -92,179 +92,167 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
return;
|
||||
}
|
||||
|
||||
$subjects = SupplierProjectGrouping::subjectsOf($project);
|
||||
$identifier = SupplierProjectGrouping::buildUniqueKey($project, $platforms[0]);
|
||||
|
||||
foreach ($subjects as $subject) {
|
||||
// Use first platform for key (site/call → identifier; sms → B2/B3 key)
|
||||
$identifier = SupplierProjectGrouping::buildUniqueKey($project, $platforms[0]);
|
||||
// Portal constraint: one identifier = one B1/B2/B3 group (status=Doubles on duplicate name).
|
||||
// Pass all project regions as a single group — no per-subject split.
|
||||
$allRegions = array_map('intval', (array) ($project->regions ?? []));
|
||||
// count=0 → all-Russia; count=1 → named region; count>1 → merged → 'РФ'
|
||||
$tag = count($allRegions) === 1
|
||||
? (RussianRegions::CODE_TO_NAME[$allRegions[0]] ?? (string) $allRegions[0])
|
||||
: 'РФ';
|
||||
|
||||
$tag = $subject !== null
|
||||
? (RussianRegions::CODE_TO_NAME[$subject] ?? (string) $subject)
|
||||
: 'РФ';
|
||||
$regions = $subject !== null ? [$subject] : [];
|
||||
$workdays = $this->workdaysFromMask((int) $project->delivery_days_mask);
|
||||
|
||||
// Idempotency: existing supplier_projects for this (identifier, subject)?
|
||||
$existingSps = SupplierProject::query()
|
||||
->where('unique_key', $identifier)
|
||||
->where('signal_type', (string) $project->signal_type)
|
||||
->when(
|
||||
$subject !== null,
|
||||
fn ($q) => $q->where('subject_code', $subject),
|
||||
fn ($q) => $q->whereNull('subject_code'),
|
||||
)
|
||||
->whereIn('platform', $platforms)
|
||||
->get();
|
||||
// Idempotency: find existing by identifier regardless of subject_code (any previous run).
|
||||
$existingSps = SupplierProject::query()
|
||||
->where('unique_key', $identifier)
|
||||
->where('signal_type', (string) $project->signal_type)
|
||||
->whereIn('platform', $platforms)
|
||||
->get();
|
||||
|
||||
if ($existingSps->isEmpty()) {
|
||||
// Create path: saveProjectMultiFlag → [platform => external_id]
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: $platforms[0],
|
||||
signalType: (string) $project->signal_type,
|
||||
uniqueKey: $identifier,
|
||||
limit: (int) $project->daily_limit_target,
|
||||
workdays: [1, 2, 3, 4, 5, 6, 7],
|
||||
regions: $regions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: $platforms,
|
||||
);
|
||||
if ($existingSps->isEmpty()) {
|
||||
// Create path: saveProjectMultiFlag → [platform => external_id]
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: $platforms[0],
|
||||
signalType: (string) $project->signal_type,
|
||||
uniqueKey: $identifier,
|
||||
limit: (int) $project->daily_limit_target,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: $platforms,
|
||||
);
|
||||
|
||||
try {
|
||||
$idMap = $client->saveProjectMultiFlag($dto);
|
||||
} catch (TierEscalatedException $e) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} subject={$subject} escalated to manual queue #{$e->queueRowId}");
|
||||
try {
|
||||
$idMap = $client->saveProjectMultiFlag($dto);
|
||||
} catch (TierEscalatedException $e) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} escalated to manual queue #{$e->queueRowId}");
|
||||
|
||||
continue;
|
||||
} catch (WindowDeferredException) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} subject={$subject} deferred by portal window");
|
||||
return;
|
||||
} catch (WindowDeferredException) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} deferred by portal window");
|
||||
|
||||
continue;
|
||||
} catch (\Throwable $e) {
|
||||
// Online multi-flag save bypasses FailoverProjectChannel (tier-1 only by design,
|
||||
// см. class docblock). При transient/auth/client/network fail — log+skip; следующий
|
||||
// tries-retry (15s, 60s, 300s) или ночной SyncSupplierProjectsJob подберёт.
|
||||
Log::warning("SyncSupplierProjectJob: online multi-flag save failed for project {$project->id} subject={$subject} (".get_class($e).'): '.$e->getMessage());
|
||||
return;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("SyncSupplierProjectJob: online multi-flag save failed for project {$project->id} (".get_class($e).'): '.$e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($platforms as $platform) {
|
||||
$externalId = $idMap[$platform] ?? null;
|
||||
if ($externalId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($platforms as $platform) {
|
||||
$externalId = $idMap[$platform] ?? null;
|
||||
$sp = SupplierProject::create([
|
||||
'platform' => $platform,
|
||||
'signal_type' => (string) $project->signal_type,
|
||||
'unique_key' => $identifier,
|
||||
'subject_code' => null,
|
||||
'supplier_external_id' => (string) $externalId,
|
||||
'current_limit' => (int) $project->daily_limit_target,
|
||||
'current_workdays' => $workdays,
|
||||
'current_regions' => $allRegions,
|
||||
'sync_status' => 'ok',
|
||||
'last_synced_at' => now(),
|
||||
]);
|
||||
|
||||
$existingSps->push($sp);
|
||||
}
|
||||
} else {
|
||||
// Partial-set recovery: если предыдущий run создал не все platforms.
|
||||
$existingPlatforms = $existingSps->pluck('platform')->all();
|
||||
$missingPlatforms = array_values(array_diff($platforms, $existingPlatforms));
|
||||
|
||||
if ($missingPlatforms !== []) {
|
||||
$missingDto = new SupplierProjectDto(
|
||||
platform: $missingPlatforms[0],
|
||||
signalType: (string) $project->signal_type,
|
||||
uniqueKey: $identifier,
|
||||
limit: (int) $project->daily_limit_target,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: $missingPlatforms,
|
||||
);
|
||||
|
||||
try {
|
||||
$missingIdMap = $client->saveProjectMultiFlag($missingDto);
|
||||
} catch (TierEscalatedException $e) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} missing-platform re-attempt escalated #{$e->queueRowId}");
|
||||
$missingIdMap = [];
|
||||
} catch (WindowDeferredException) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} missing-platform deferred by portal window");
|
||||
$missingIdMap = [];
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("SyncSupplierProjectJob: missing-platform multi-flag failed for project {$project->id}: ".$e->getMessage());
|
||||
$missingIdMap = [];
|
||||
}
|
||||
|
||||
foreach ($missingPlatforms as $platform) {
|
||||
$externalId = $missingIdMap[$platform] ?? null;
|
||||
if ($externalId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sp = SupplierProject::create([
|
||||
'platform' => $platform,
|
||||
'signal_type' => (string) $project->signal_type,
|
||||
'unique_key' => $identifier,
|
||||
'subject_code' => $subject,
|
||||
'subject_code' => null,
|
||||
'supplier_external_id' => (string) $externalId,
|
||||
'current_limit' => (int) $project->daily_limit_target,
|
||||
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
|
||||
'current_regions' => $regions,
|
||||
'current_workdays' => $workdays,
|
||||
'current_regions' => $allRegions,
|
||||
'sync_status' => 'ok',
|
||||
'last_synced_at' => now(),
|
||||
]);
|
||||
|
||||
$existingSps->push($sp);
|
||||
}
|
||||
} else {
|
||||
// Fix #3 (review-followup): partial-set recovery — если предыдущий run создал
|
||||
// не все platforms (e.g. B1+B2 OK, B3 escalated), re-attempt missing via
|
||||
// multi-flag save с platforms=$missingPlatforms (srcrt/srcbl/srcmt только missing).
|
||||
$existingPlatforms = $existingSps->pluck('platform')->all();
|
||||
$missingPlatforms = array_values(array_diff($platforms, $existingPlatforms));
|
||||
|
||||
if ($missingPlatforms !== []) {
|
||||
$missingDto = new SupplierProjectDto(
|
||||
platform: $missingPlatforms[0],
|
||||
signalType: (string) $project->signal_type,
|
||||
uniqueKey: $identifier,
|
||||
limit: (int) $project->daily_limit_target,
|
||||
workdays: [1, 2, 3, 4, 5, 6, 7],
|
||||
regions: $regions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: $missingPlatforms,
|
||||
);
|
||||
|
||||
try {
|
||||
$missingIdMap = $client->saveProjectMultiFlag($missingDto);
|
||||
} catch (TierEscalatedException $e) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} subject={$subject} missing-platform re-attempt escalated #{$e->queueRowId}");
|
||||
$missingIdMap = [];
|
||||
} catch (WindowDeferredException) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} subject={$subject} missing-platform deferred by portal window");
|
||||
$missingIdMap = [];
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("SyncSupplierProjectJob: missing-platform multi-flag failed for project {$project->id} subject={$subject}: ".$e->getMessage());
|
||||
$missingIdMap = [];
|
||||
}
|
||||
|
||||
foreach ($missingPlatforms as $platform) {
|
||||
$externalId = $missingIdMap[$platform] ?? null;
|
||||
if ($externalId === null) {
|
||||
continue;
|
||||
}
|
||||
$sp = SupplierProject::create([
|
||||
'platform' => $platform,
|
||||
'signal_type' => (string) $project->signal_type,
|
||||
'unique_key' => $identifier,
|
||||
'subject_code' => $subject,
|
||||
'supplier_external_id' => (string) $externalId,
|
||||
'current_limit' => (int) $project->daily_limit_target,
|
||||
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
|
||||
'current_regions' => $regions,
|
||||
'sync_status' => 'ok',
|
||||
'last_synced_at' => now(),
|
||||
]);
|
||||
$existingSps->push($sp);
|
||||
}
|
||||
}
|
||||
|
||||
// Fix #2 (review-followup): per-platform DTO в update-loop, чтобы portal
|
||||
// получал корректные srcrt/srcbl/srcmt флаги для конкретной редактируемой строки
|
||||
// (не первой из mixed-platform existing set). R6 one shared limit/regions сохраняется.
|
||||
foreach ($existingSps as $sp) {
|
||||
if ($sp->supplier_external_id === null) {
|
||||
continue;
|
||||
}
|
||||
$perPlatformDto = new SupplierProjectDto(
|
||||
platform: $sp->platform,
|
||||
signalType: (string) $project->signal_type,
|
||||
uniqueKey: $identifier,
|
||||
limit: (int) $project->daily_limit_target,
|
||||
workdays: [1, 2, 3, 4, 5, 6, 7],
|
||||
regions: $regions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: [$sp->platform],
|
||||
);
|
||||
$channel->updateProject((int) $sp->supplier_external_id, $perPlatformDto);
|
||||
$sp->forceFill([
|
||||
'current_limit' => (int) $project->daily_limit_target,
|
||||
'current_regions' => $regions,
|
||||
'sync_status' => 'ok',
|
||||
'last_synced_at' => now(),
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
|
||||
// Pivot: project × each supplier_project → ON CONFLICT DO NOTHING
|
||||
// Update existing supplier projects with current regions/limit.
|
||||
foreach ($existingSps as $sp) {
|
||||
DB::table('project_supplier_links')->insertOrIgnore([
|
||||
'project_id' => $project->id,
|
||||
'supplier_project_id' => $sp->id,
|
||||
'platform' => $sp->platform,
|
||||
// @phpstan-ignore-next-line property.notFound — subject_code in fillable/casts, IDE stubs lag
|
||||
'subject_code' => $sp->subject_code,
|
||||
]);
|
||||
if ($sp->supplier_external_id === null) {
|
||||
continue;
|
||||
}
|
||||
$perPlatformDto = new SupplierProjectDto(
|
||||
platform: $sp->platform,
|
||||
signalType: (string) $project->signal_type,
|
||||
uniqueKey: $identifier,
|
||||
limit: (int) $project->daily_limit_target,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: [$sp->platform],
|
||||
);
|
||||
$channel->updateProject((int) $sp->supplier_external_id, $perPlatformDto);
|
||||
$sp->forceFill([
|
||||
'current_limit' => (int) $project->daily_limit_target,
|
||||
'current_workdays' => $workdays,
|
||||
'current_regions' => $allRegions,
|
||||
'sync_status' => 'ok',
|
||||
'last_synced_at' => now(),
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
|
||||
// Pivot: project × each supplier_project → ON CONFLICT DO NOTHING
|
||||
foreach ($existingSps as $sp) {
|
||||
DB::table('project_supplier_links')->insertOrIgnore([
|
||||
'project_id' => $project->id,
|
||||
'supplier_project_id' => $sp->id,
|
||||
'platform' => $sp->platform,
|
||||
'subject_code' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -274,6 +262,7 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
private function handleBatch(Project $project, SupplierProjectChannel $channel): void
|
||||
{
|
||||
$platforms = SupplierProjectGrouping::resolvePlatforms($project);
|
||||
$workdays = $this->workdaysFromMask((int) $project->delivery_days_mask);
|
||||
|
||||
foreach ($platforms as $platform) {
|
||||
$uniqueKey = SupplierProjectGrouping::buildUniqueKey($project, $platform);
|
||||
@@ -297,7 +286,7 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
signalType: (string) $project->signal_type,
|
||||
uniqueKey: $uniqueKey,
|
||||
limit: 0,
|
||||
workdays: [1, 2, 3, 4, 5, 6, 7],
|
||||
workdays: $workdays,
|
||||
regions: [],
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
@@ -323,7 +312,7 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
'unique_key' => $uniqueKey,
|
||||
'supplier_external_id' => (string) $externalId,
|
||||
'current_limit' => 0,
|
||||
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
|
||||
'current_workdays' => $workdays,
|
||||
'current_regions' => null,
|
||||
'sync_status' => 'ok',
|
||||
]);
|
||||
@@ -333,4 +322,24 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
|
||||
$project->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bitmask → ISO weekday list. bit 0 = Mon (ISO 1) … bit 6 = Sun (ISO 7).
|
||||
*
|
||||
* Mirror of SyncSupplierProjectsJob::bitmaskToList(). Kept inline (not
|
||||
* extracted to a shared helper) to keep this fix surgical.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function workdaysFromMask(int $mask): array
|
||||
{
|
||||
$out = [];
|
||||
for ($i = 0; $i < 7; $i++) {
|
||||
if (($mask & (1 << $i)) !== 0) {
|
||||
$out[] = $i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,10 +32,14 @@ class ProjectService
|
||||
], 422));
|
||||
}
|
||||
|
||||
// Resync на смену любого источник-несущего поля — поставщику нужно знать актуальный домен/телефон/sms.
|
||||
// Resync на смену источник-несущих полей, регионов, лимита и дней недели —
|
||||
// поставщик должен видеть актуальные параметры сразу, не дожидаясь ночного батча.
|
||||
$needsResync = array_key_exists('sms_senders', $data)
|
||||
|| array_key_exists('sms_keyword', $data)
|
||||
|| array_key_exists('signal_identifier', $data);
|
||||
|| array_key_exists('signal_identifier', $data)
|
||||
|| array_key_exists('regions', $data)
|
||||
|| array_key_exists('daily_limit_target', $data)
|
||||
|| array_key_exists('delivery_days_mask', $data);
|
||||
|
||||
$project->update($data);
|
||||
|
||||
|
||||
@@ -112,7 +112,11 @@ class SupplierPortalClient
|
||||
$srcToPlatform = ['rt' => 'B1', 'bl' => 'B2', 'mt' => 'B3'];
|
||||
$out = [];
|
||||
foreach ($this->listProjects() as $p) {
|
||||
if (($p['name'] ?? null) !== $dto->uniqueKey || ($p['tag'] ?? null) !== $dto->tag) {
|
||||
// Real portal returns name='B1_<identifier>' and identifier in 'content'.
|
||||
// Test mocks omit 'content' and put identifier directly in 'name' — fall back to 'name'
|
||||
// when 'content' is absent so both shapes work.
|
||||
$identifier = $p['content'] ?? $p['name'] ?? null;
|
||||
if ($identifier !== $dto->uniqueKey || ($p['tag'] ?? null) !== $dto->tag) {
|
||||
continue;
|
||||
}
|
||||
$platform = $srcToPlatform[$p['src'] ?? ''] ?? null;
|
||||
|
||||
+8
-1
@@ -18,6 +18,7 @@
|
||||
"require-dev": {
|
||||
"barryvdh/laravel-ide-helper": "*",
|
||||
"deptrac/deptrac": "^4.6",
|
||||
"driftingly/rector-laravel": "^2.3",
|
||||
"fakerphp/faker": "^1.23",
|
||||
"infection/infection": "^0.32.7",
|
||||
"larastan/larastan": "*",
|
||||
@@ -27,8 +28,10 @@
|
||||
"laravel/pint": "^1.29",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"nunomaduro/phpinsights": "*",
|
||||
"pestphp/pest": "^4.7",
|
||||
"pestphp/pest-plugin-laravel": "^4.1",
|
||||
"rector/rector": "^2.4",
|
||||
"roave/security-advisories": "dev-latest"
|
||||
},
|
||||
"autoload": {
|
||||
@@ -64,6 +67,9 @@
|
||||
"pint:test": "@php vendor/bin/pint --test",
|
||||
"test:parallel": "@php vendor/bin/pest --parallel --recreate-databases",
|
||||
"stan": "@php vendor/bin/phpstan analyse --memory-limit=512M",
|
||||
"rector": "@php vendor/bin/rector process --dry-run",
|
||||
"rector:fix": "@php vendor/bin/rector process",
|
||||
"insights": "@php artisan insights --no-interaction",
|
||||
"mutation": "@php vendor/bin/infection --threads=2 --min-msi=50",
|
||||
"audit-offline": "@composer audit --locked",
|
||||
"demo:seed": "@php artisan db:seed --class=DemoSeeder --force",
|
||||
@@ -102,7 +108,8 @@
|
||||
"allow-plugins": {
|
||||
"pestphp/pest-plugin": true,
|
||||
"php-http/discovery": true,
|
||||
"infection/extension-installer": true
|
||||
"infection/extension-installer": true,
|
||||
"dealerdirect/phpcodesniffer-composer-installer": true
|
||||
}
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
|
||||
Generated
+2162
-1
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use NunoMaduro\PhpInsights\Domain\Insights\ForbiddenDefineFunctions;
|
||||
use NunoMaduro\PhpInsights\Domain\Insights\ForbiddenFinalClasses;
|
||||
use NunoMaduro\PhpInsights\Domain\Insights\ForbiddenNormalClasses;
|
||||
use NunoMaduro\PhpInsights\Domain\Insights\ForbiddenPrivateMethods;
|
||||
use NunoMaduro\PhpInsights\Domain\Insights\ForbiddenTraits;
|
||||
use NunoMaduro\PhpInsights\Domain\Insights\SyntaxCheck;
|
||||
use NunoMaduro\PhpInsights\Domain\Metrics\Architecture\Classes;
|
||||
use SlevomatCodingStandard\Sniffs\Commenting\UselessFunctionDocCommentSniff;
|
||||
use SlevomatCodingStandard\Sniffs\Namespaces\AlphabeticallySortedUsesSniff;
|
||||
use SlevomatCodingStandard\Sniffs\TypeHints\DeclareStrictTypesSniff;
|
||||
use SlevomatCodingStandard\Sniffs\TypeHints\DisallowMixedTypeHintSniff;
|
||||
use SlevomatCodingStandard\Sniffs\TypeHints\ParameterTypeHintSniff;
|
||||
use SlevomatCodingStandard\Sniffs\TypeHints\PropertyTypeHintSniff;
|
||||
use SlevomatCodingStandard\Sniffs\TypeHints\ReturnTypeHintSniff;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Preset
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default preset that will be used by PHP Insights
|
||||
| to make your code reliable, simple, and clean. However, you can always
|
||||
| adjust the `Metrics` and `Insights` below in this configuration file.
|
||||
|
|
||||
| Supported: "default", "laravel", "symfony", "magento2", "drupal", "wordpress"
|
||||
|
|
||||
*/
|
||||
|
||||
'preset' => 'laravel',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| IDE
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This options allow to add hyperlinks in your terminal to quickly open
|
||||
| files in your favorite IDE while browsing your PhpInsights report.
|
||||
|
|
||||
| Supported: "textmate", "macvim", "emacs", "sublime", "phpstorm",
|
||||
| "atom", "vscode".
|
||||
|
|
||||
| If you have another IDE that is not in this list but which provide an
|
||||
| url-handler, you could fill this config with a pattern like this:
|
||||
|
|
||||
| myide://open?url=file://%f&line=%l
|
||||
|
|
||||
*/
|
||||
|
||||
'ide' => null,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may adjust all the various `Insights` that will be used by PHP
|
||||
| Insights. You can either add, remove or configure `Insights`. Keep in
|
||||
| mind, that all added `Insights` must belong to a specific `Metric`.
|
||||
|
|
||||
*/
|
||||
|
||||
'exclude' => [
|
||||
// 'path/to/directory-or-file'
|
||||
],
|
||||
|
||||
'add' => [
|
||||
Classes::class => [
|
||||
ForbiddenFinalClasses::class,
|
||||
],
|
||||
],
|
||||
|
||||
'remove' => [
|
||||
// SyntaxCheck спавнит дочерний `php -l` процесс — на native-Windows возвращает
|
||||
// не-JSON и крашит PHP Insights (A1 backend-tooling, 20.05.2026). Избыточен:
|
||||
// синтаксис ловят Pint / Larastan / сам PHP. Стиль — владелец Pint (BT4, ADR-013).
|
||||
SyntaxCheck::class,
|
||||
AlphabeticallySortedUsesSniff::class,
|
||||
DeclareStrictTypesSniff::class,
|
||||
DisallowMixedTypeHintSniff::class,
|
||||
ForbiddenDefineFunctions::class,
|
||||
ForbiddenNormalClasses::class,
|
||||
ForbiddenTraits::class,
|
||||
ParameterTypeHintSniff::class,
|
||||
PropertyTypeHintSniff::class,
|
||||
ReturnTypeHintSniff::class,
|
||||
UselessFunctionDocCommentSniff::class,
|
||||
],
|
||||
|
||||
'config' => [
|
||||
ForbiddenPrivateMethods::class => [
|
||||
'title' => 'The usage of private methods is not idiomatic in Laravel.',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Requirements
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define a level you want to reach per `Insights` category.
|
||||
| When a score is lower than the minimum level defined, then an error
|
||||
| code will be returned. This is optional and individually defined.
|
||||
|
|
||||
*/
|
||||
|
||||
'requirements' => [
|
||||
// Anti-regression floors из baseline 20.05.2026 (Code 80 / Complexity 81 /
|
||||
// Architecture 75). Чуть ниже текущих — гейт ловит деградацию, не текущий долг.
|
||||
// Style НЕ гейтим — владелец стиля Pint (BT4, ADR-013). Security-check off —
|
||||
// дублирует roave/security-advisories + composer audit.
|
||||
'min-quality' => 78,
|
||||
'min-complexity' => 79,
|
||||
'min-architecture' => 73,
|
||||
'disable-security-check' => true,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Threads
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may adjust how many threads (core) PHPInsights can use to perform
|
||||
| the analysis. This is optional, don't provide it and the tool will guess
|
||||
| the max core number available. It accepts null value or integer > 0.
|
||||
|
|
||||
*/
|
||||
|
||||
'threads' => null,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Timeout
|
||||
|--------------------------------------------------------------------------
|
||||
| Here you may adjust the timeout (in seconds) for PHPInsights to run before
|
||||
| a ProcessTimedOutException is thrown.
|
||||
| This accepts an int > 0. Default is 60 seconds, which is the default value
|
||||
| of Symfony's setTimeout function.
|
||||
|
|
||||
*/
|
||||
|
||||
'timeout' => 60,
|
||||
];
|
||||
@@ -348,6 +348,24 @@ parameters:
|
||||
count: 3
|
||||
path: tests/Feature/Admin/AdminSuppliersControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Admin/SupplierExportModeEndpointTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Admin/SupplierExportModeEndpointTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Admin/SupplierExportModeEndpointTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -366,6 +384,24 @@ parameters:
|
||||
count: 2
|
||||
path: tests/Feature/Admin/SupplierManualQueueTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 5
|
||||
path: tests/Feature/Admin/SupplierProjectsAdminTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Admin/SupplierProjectsAdminTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 4
|
||||
path: tests/Feature/Admin/SupplierProjectsAdminTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -1533,7 +1569,7 @@ parameters:
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 12
|
||||
count: 14
|
||||
path: tests/Feature/Plan5/Projects/ProjectsUpdateTest.php
|
||||
|
||||
-
|
||||
@@ -1851,7 +1887,7 @@ parameters:
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:mock\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
count: 2
|
||||
path: tests/Feature/Supplier/SyncSupplierProjectJobTest.php
|
||||
|
||||
-
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Rector\Config\RectorConfig;
|
||||
|
||||
// Консервативный старт (A1 backend-tooling #64): мёртвый код + качество кода.
|
||||
// БЕЗ type-declaration наборов и БЕЗ LaravelSetProvider (version-upgrade) на первом
|
||||
// заходе — их прогоняем вручную при апгрейде Laravel, не как per-commit гейт.
|
||||
return RectorConfig::configure()
|
||||
->withPaths([
|
||||
__DIR__.'/app',
|
||||
__DIR__.'/database',
|
||||
__DIR__.'/routes',
|
||||
])
|
||||
->withPreparedSets(
|
||||
deadCode: true,
|
||||
codeQuality: true,
|
||||
);
|
||||
@@ -260,10 +260,12 @@ export interface ApiProject {
|
||||
}
|
||||
|
||||
export async function listProjects(tenantId: number): Promise<ApiProject[]> {
|
||||
const { data } = await apiClient.get<{ projects: ApiProject[] }>('/api/projects', {
|
||||
// ProjectController::index() отдаёт { data: ProjectResource::collection(...) }.
|
||||
// `?? []` — защита от undefined.map в DealsView при нештатном ответе.
|
||||
const { data } = await apiClient.get<{ data: ApiProject[] }>('/api/projects', {
|
||||
params: { tenant_id: tenantId },
|
||||
});
|
||||
return data.projects;
|
||||
return data.data ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,13 +6,30 @@
|
||||
*
|
||||
* Sprint 4 Phase B/3 — split DashboardView (audit O-refactor-04 закрытие).
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
|
||||
const range = defineModel<'today' | '7d' | '30d' | 'custom'>({ required: true });
|
||||
|
||||
const auth = useAuthStore();
|
||||
|
||||
/** Имя залогиненного пользователя (было захардкожено «Иван»). */
|
||||
const firstName = computed(() => auth.user?.first_name?.trim() || 'коллега');
|
||||
|
||||
/** Приветствие по времени суток (МСК машины пользователя). */
|
||||
const greeting = computed(() => {
|
||||
const h = new Date().getHours();
|
||||
if (h < 6) return 'Доброй ночи';
|
||||
if (h < 12) return 'Доброе утро';
|
||||
if (h < 18) return 'Добрый день';
|
||||
return 'Добрый вечер';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<h1 class="text-h4 mb-2 page-greet">Доброе утро, <em class="text-primary">Иван</em></h1>
|
||||
<h1 class="text-h4 mb-2 page-greet">{{ greeting }}, <em class="text-primary">{{ firstName }}</em></h1>
|
||||
<div class="page-meta text-body-2 text-medium-emphasis">
|
||||
<span><span class="num text-primary">+3</span> новых лида с утра</span>
|
||||
<span class="sep">·</span>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import { useNotificationsStore } from '../../stores/notifications';
|
||||
import { useCommandPalette } from '../../composables/useCommandPalette';
|
||||
import { repositionMenuAfterOpen } from '../../utils/menuRepositionFix';
|
||||
|
||||
defineProps<{
|
||||
pageTitle: string;
|
||||
@@ -111,7 +112,7 @@ async function handleLogout(): Promise<void> {
|
||||
</template>
|
||||
</v-btn>
|
||||
|
||||
<v-menu offset="8" :close-on-content-click="false" location="bottom end">
|
||||
<v-menu offset="8" :close-on-content-click="false" location="bottom end" @update:model-value="repositionMenuAfterOpen">
|
||||
<template #activator="{ props: bellProps }">
|
||||
<v-btn
|
||||
v-bind="bellProps"
|
||||
@@ -173,7 +174,7 @@ async function handleLogout(): Promise<void> {
|
||||
</v-card>
|
||||
</v-menu>
|
||||
|
||||
<v-menu offset="8">
|
||||
<v-menu offset="8" @update:model-value="repositionMenuAfterOpen">
|
||||
<template #activator="{ props }">
|
||||
<v-btn v-bind="props" variant="text" size="small" class="user-chip ml-2" aria-label="Меню пользователя">
|
||||
<v-avatar size="28" color="primary" class="mr-2">
|
||||
|
||||
@@ -25,14 +25,15 @@ interface NavItem {
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ title: 'Тенанты', icon: 'mdi-account-group-outline', to: '/admin/tenants', count: 142 },
|
||||
{ title: 'Тенанты', icon: 'mdi-account-group-outline', to: '/admin/tenants' },
|
||||
{ title: 'Биллинг', icon: 'mdi-credit-card-outline', to: '/admin/billing' },
|
||||
{ title: 'Тарифная сетка', icon: 'mdi-tag-arrow-right', to: '/admin/pricing-tiers' },
|
||||
{ title: 'Цены поставщиков', icon: 'mdi-currency-rub', to: '/admin/supplier-prices' },
|
||||
{ title: 'Инциденты', icon: 'mdi-alert-outline', to: '/admin/incidents', count: 3 },
|
||||
{ title: 'Инциденты', icon: 'mdi-alert-outline', to: '/admin/incidents' },
|
||||
{ title: 'Impersonation', icon: 'mdi-account-switch', to: '/admin/impersonation' },
|
||||
{ title: 'Система', icon: 'mdi-cog-outline', to: '/admin/system' },
|
||||
{ title: 'Интеграция с поставщиком', icon: 'mdi-swap-horizontal', to: '/admin/supplier-integration' },
|
||||
{ title: 'Проекты у поставщика', icon: 'mdi-format-list-checks', to: '/admin/supplier-projects' },
|
||||
];
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
@@ -41,7 +41,13 @@ const navItems = computed(() => [
|
||||
]);
|
||||
|
||||
const currentPageTitle = computed(() => {
|
||||
return navItems.value.find((i) => i.to === route.path)?.title ?? 'Страница';
|
||||
// Сначала короткий title из sidebar-nav (Дашборд/Сделки/…), затем — route.meta.title
|
||||
// для страниц вне sidebar (Напоминания, Импорт данных), и только потом fallback.
|
||||
return (
|
||||
navItems.value.find((i) => i.to === route.path)?.title ??
|
||||
(route.meta.title as string | undefined) ??
|
||||
'Страница'
|
||||
);
|
||||
});
|
||||
|
||||
async function loadNotifications(): Promise<void> {
|
||||
|
||||
@@ -283,6 +283,18 @@ const routes: RouteRecordRaw[] = [
|
||||
devLabel: 'Admin Supplier Integration',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/admin/supplier-projects',
|
||||
name: 'admin-supplier-projects',
|
||||
component: () => import('../views/admin/AdminSupplierProjectsView.vue'),
|
||||
meta: {
|
||||
layout: 'admin',
|
||||
title: 'Проекты у поставщика',
|
||||
requiresAuth: true,
|
||||
devIndex: 31,
|
||||
devLabel: 'Admin Supplier Projects',
|
||||
},
|
||||
},
|
||||
// Error pages: 403/500 явные + catch-all 404 (всегда последний).
|
||||
{
|
||||
path: '/403',
|
||||
|
||||
@@ -27,6 +27,38 @@ const loading = ref(false);
|
||||
const reconciling = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
// --- Plan 4 Task 1: глобальный режим экспорта проектов (online|batch) ---
|
||||
|
||||
type ExportMode = 'online' | 'batch';
|
||||
const exportMode = ref<ExportMode>('batch');
|
||||
const exportModeError = ref<string | null>(null);
|
||||
const exportModeSaving = ref(false);
|
||||
|
||||
async function loadExportMode(): Promise<void> {
|
||||
try {
|
||||
const { data } = await axios.get('/api/admin/supplier-integration/export-mode');
|
||||
if (data?.mode === 'online' || data?.mode === 'batch') {
|
||||
exportMode.value = data.mode;
|
||||
}
|
||||
} catch {
|
||||
exportModeError.value = 'Не удалось загрузить режим экспорта.';
|
||||
}
|
||||
}
|
||||
|
||||
async function setExportMode(mode: ExportMode): Promise<void> {
|
||||
if (exportMode.value === mode) return;
|
||||
exportModeSaving.value = true;
|
||||
exportModeError.value = null;
|
||||
try {
|
||||
const { data } = await axios.post('/api/admin/supplier-integration/export-mode', { mode });
|
||||
exportMode.value = data?.mode === 'online' ? 'online' : 'batch';
|
||||
} catch {
|
||||
exportModeError.value = 'Не удалось сохранить режим экспорта.';
|
||||
} finally {
|
||||
exportModeSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
@@ -106,6 +138,7 @@ function formatDate(s: string): string {
|
||||
onMounted(() => {
|
||||
void load();
|
||||
void loadManualQueue();
|
||||
void loadExportMode();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -113,6 +146,43 @@ onMounted(() => {
|
||||
<div class="pa-6">
|
||||
<h1 class="text-h5 mb-4">Интеграция с поставщиком</h1>
|
||||
|
||||
<v-card class="mb-4">
|
||||
<v-card-title>Режим экспорта проектов</v-card-title>
|
||||
<v-card-text>
|
||||
<v-alert v-if="exportModeError" type="error" density="compact" class="mb-3">
|
||||
{{ exportModeError }}
|
||||
</v-alert>
|
||||
<div data-testid="export-mode-toggle">
|
||||
<v-btn-toggle
|
||||
:model-value="exportMode"
|
||||
mandatory
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
:disabled="exportModeSaving"
|
||||
>
|
||||
<v-btn
|
||||
data-testid="export-mode-online"
|
||||
value="online"
|
||||
@click="setExportMode('online')"
|
||||
>
|
||||
Онлайн
|
||||
</v-btn>
|
||||
<v-btn
|
||||
data-testid="export-mode-batch"
|
||||
value="batch"
|
||||
@click="setExportMode('batch')"
|
||||
>
|
||||
Пакетный
|
||||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
</div>
|
||||
<p class="text-caption text-medium-emphasis mt-3 mb-0">
|
||||
Онлайн — изменения проекта переносятся к поставщику сразу.
|
||||
Пакетный — ночной синк в 18:00 (SyncSupplierProjectsJob).
|
||||
</p>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<v-card class="mb-4">
|
||||
<v-card-title>Здоровье резервного канала</v-card-title>
|
||||
<v-card-text>
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<div class="admin-supplier-projects-view pa-6">
|
||||
<h1 class="text-h5 mb-4">Проекты у поставщика</h1>
|
||||
<p class="text-body-2 text-medium-emphasis mb-4">
|
||||
Все проекты, заведённые у поставщика crm.bp-gr.ru. Удаление снимает проект
|
||||
на портале и локальные привязки тенантов (каскадом).
|
||||
</p>
|
||||
|
||||
<v-alert
|
||||
v-if="fetchError"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-4"
|
||||
data-testid="projects-fetch-error"
|
||||
closable
|
||||
@click:close="fetchError = null"
|
||||
>
|
||||
{{ fetchError }}
|
||||
</v-alert>
|
||||
|
||||
<div class="d-flex align-center mb-3">
|
||||
<v-btn
|
||||
color="error"
|
||||
variant="flat"
|
||||
prepend-icon="mdi-delete-outline"
|
||||
data-testid="bulk-delete-btn"
|
||||
:disabled="selected.length === 0"
|
||||
:loading="deleting"
|
||||
@click="confirmOpen = true"
|
||||
>
|
||||
Удалить выбранные ({{ selected.length }})
|
||||
</v-btn>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" prepend-icon="mdi-refresh" :loading="loading" @click="load">
|
||||
Обновить
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<v-card elevation="1">
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="projects"
|
||||
:loading="loading"
|
||||
density="comfortable"
|
||||
item-value="id"
|
||||
>
|
||||
<template #[`item.select`]="{ item }">
|
||||
<v-checkbox
|
||||
:model-value="selected.includes(item.id)"
|
||||
:data-testid="`row-checkbox-${item.id}`"
|
||||
hide-details
|
||||
density="compact"
|
||||
@update:model-value="(v: boolean | null) => toggleRow(item.id, v)"
|
||||
/>
|
||||
</template>
|
||||
<template #[`item.orderers`]="{ item }">
|
||||
<span v-if="item.orderers.length">{{ item.orderers.join(', ') }}</span>
|
||||
<span v-else class="text-medium-emphasis">—</span>
|
||||
</template>
|
||||
<template #[`item.last_delivery_at`]="{ item }">
|
||||
{{ item.last_delivery_at ? formatDate(item.last_delivery_at) : '—' }}
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
|
||||
<v-dialog v-model="confirmOpen" max-width="480">
|
||||
<v-card>
|
||||
<v-card-title>Удалить выбранные проекты?</v-card-title>
|
||||
<v-card-text>
|
||||
Будет удалено проектов: <strong>{{ selected.length }}</strong>.
|
||||
Действие снимает проекты у поставщика и локальные привязки.
|
||||
Отменить нельзя.
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="confirmOpen = false">Отмена</v-btn>
|
||||
<v-btn
|
||||
color="error"
|
||||
variant="flat"
|
||||
data-testid="confirm-delete-btn"
|
||||
:loading="deleting"
|
||||
@click="performDelete"
|
||||
>
|
||||
Удалить
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-snackbar
|
||||
v-model="snackbarOpen"
|
||||
:timeout="4000"
|
||||
:color="snackbarColor"
|
||||
location="bottom right"
|
||||
data-testid="projects-snackbar"
|
||||
>
|
||||
{{ snackbarText }}
|
||||
</v-snackbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import axios from 'axios';
|
||||
|
||||
/**
|
||||
* SaaS-admin → «Проекты у поставщика» (Plan 4 Task 3).
|
||||
*
|
||||
* Backend: AdminSupplierIntegrationController::projectsIndex / projectsDestroy.
|
||||
* Список supplier_projects + кто заказывал (orderers) + дата последней поставки;
|
||||
* bulk-delete выбранных (портал + локально каскадом).
|
||||
*/
|
||||
|
||||
interface SupplierProjectRow {
|
||||
id: number;
|
||||
platform: string;
|
||||
signal_type: string;
|
||||
unique_key: string;
|
||||
subject_code: number | null;
|
||||
subject_name: string | null;
|
||||
current_limit: number;
|
||||
supplier_external_id: string | null;
|
||||
orderers: string[];
|
||||
last_delivery_at: string | null;
|
||||
}
|
||||
|
||||
const projects = ref<SupplierProjectRow[]>([]);
|
||||
const selected = ref<number[]>([]);
|
||||
const loading = ref(false);
|
||||
const deleting = ref(false);
|
||||
const fetchError = ref<string | null>(null);
|
||||
const confirmOpen = ref(false);
|
||||
const snackbarOpen = ref(false);
|
||||
const snackbarText = ref('');
|
||||
const snackbarColor = ref<'success' | 'warning' | 'error'>('success');
|
||||
|
||||
const headers = [
|
||||
{ title: '', key: 'select', sortable: false, width: 56 },
|
||||
{ title: 'Источник', key: 'unique_key', sortable: true },
|
||||
{ title: 'Платформа', key: 'platform', sortable: true, width: 110 },
|
||||
{ title: 'Регион', key: 'subject_name', sortable: true },
|
||||
{ title: 'Лимит', key: 'current_limit', sortable: true, width: 90 },
|
||||
{ title: 'Кто заказывал', key: 'orderers', sortable: false },
|
||||
{ title: 'Последняя поставка', key: 'last_delivery_at', sortable: true, width: 180 },
|
||||
];
|
||||
|
||||
function toggleRow(id: number, value: boolean | null): void {
|
||||
if (value) {
|
||||
if (!selected.value.includes(id)) selected.value.push(id);
|
||||
} else {
|
||||
selected.value = selected.value.filter((x) => x !== id);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(s: string): string {
|
||||
return new Date(s).toLocaleString('ru-RU');
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
fetchError.value = null;
|
||||
try {
|
||||
const { data } = await axios.get('/api/admin/supplier-integration/projects');
|
||||
projects.value = Array.isArray(data?.projects) ? data.projects : [];
|
||||
// Снять выбор с уже удалённых строк.
|
||||
const ids = new Set(projects.value.map((p) => p.id));
|
||||
selected.value = selected.value.filter((id) => ids.has(id));
|
||||
} catch {
|
||||
fetchError.value = 'Не удалось загрузить список проектов.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function performDelete(): Promise<void> {
|
||||
if (selected.value.length === 0) {
|
||||
confirmOpen.value = false;
|
||||
return;
|
||||
}
|
||||
deleting.value = true;
|
||||
try {
|
||||
const { data } = await axios.post('/api/admin/supplier-integration/projects/delete', {
|
||||
ids: selected.value,
|
||||
});
|
||||
const deleted = Number(data?.deleted ?? 0);
|
||||
const failures = Array.isArray(data?.failures) ? data.failures : [];
|
||||
if (failures.length > 0) {
|
||||
snackbarColor.value = 'warning';
|
||||
snackbarText.value = `Удалено: ${deleted}. Не удалось: ${failures.length}.`;
|
||||
} else {
|
||||
snackbarColor.value = 'success';
|
||||
snackbarText.value = `Удалено проектов: ${deleted}.`;
|
||||
}
|
||||
snackbarOpen.value = true;
|
||||
confirmOpen.value = false;
|
||||
selected.value = [];
|
||||
await load();
|
||||
} catch {
|
||||
snackbarColor.value = 'error';
|
||||
snackbarText.value = 'Ошибка при удалении проектов.';
|
||||
snackbarOpen.value = true;
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
|
||||
defineExpose({ load, performDelete, toggleRow, projects, selected, confirmOpen });
|
||||
</script>
|
||||
@@ -86,17 +86,20 @@
|
||||
/>
|
||||
|
||||
<v-autocomplete
|
||||
v-model="form.regions"
|
||||
:model-value="form.regions"
|
||||
:items="selectableRegions"
|
||||
item-title="name"
|
||||
item-value="code"
|
||||
label="Регионы (пусто = вся РФ)"
|
||||
label="Регионы"
|
||||
:disabled="vsyaRfConfirmed"
|
||||
multiple
|
||||
chips
|
||||
clearable
|
||||
density="comfortable"
|
||||
class="ld-input-quiet"
|
||||
data-testid="regions-autocomplete"
|
||||
:error-messages="errors.regions"
|
||||
@update:model-value="onRegionsChange"
|
||||
@update:menu="repositionMenuAfterOpen"
|
||||
>
|
||||
<template #item="{ props: itemProps, item }">
|
||||
@@ -108,6 +111,51 @@
|
||||
</template>
|
||||
</v-autocomplete>
|
||||
|
||||
<v-checkbox
|
||||
:model-value="vsyaRf"
|
||||
label="Вся РФ (все регионы)"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
data-testid="vsya-rf-checkbox"
|
||||
@update:model-value="(v: boolean | null) => (v ? chooseVsyaRf() : cancelVsyaRf())"
|
||||
/>
|
||||
|
||||
<v-alert
|
||||
v-if="vsyaRf && !vsyaRfConfirmed"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mt-2"
|
||||
data-testid="vsya-rf-warning"
|
||||
>
|
||||
Вы выбрали всю Россию — проект будет получать лиды по всем регионам
|
||||
(всем субъектам РФ). Подтвердите, что это намеренно.
|
||||
<div class="mt-2">
|
||||
<v-btn
|
||||
size="small"
|
||||
color="warning"
|
||||
variant="flat"
|
||||
data-testid="confirm-vsya-rf"
|
||||
@click="confirmVsyaRf"
|
||||
>
|
||||
Подтверждаю «Вся РФ»
|
||||
</v-btn>
|
||||
<v-btn size="small" variant="text" class="ml-2" @click="cancelVsyaRf">
|
||||
Отмена
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-alert>
|
||||
|
||||
<v-chip
|
||||
v-else-if="vsyaRfConfirmed"
|
||||
color="success"
|
||||
size="small"
|
||||
class="mt-2"
|
||||
data-testid="vsya-rf-confirmed"
|
||||
>
|
||||
Вся РФ — подтверждено
|
||||
</v-chip>
|
||||
|
||||
<v-alert
|
||||
v-if="generalError"
|
||||
type="error"
|
||||
@@ -176,6 +224,38 @@ const errors = reactive<Record<string, string[]>>({});
|
||||
const saving = ref(false);
|
||||
const generalError = ref<string | null>(null);
|
||||
|
||||
// Plan 4 Task 4: обязательный выбор региона + явная «Вся РФ» с подтверждением.
|
||||
// vsyaRf — чекбокс выбран; vsyaRfConfirmed — подтверждён через предупреждение.
|
||||
// На бэке regions=[] (Вся РФ) и «забыл» неотличимы → гейт намеренно UI-only.
|
||||
const vsyaRf = ref(false);
|
||||
const vsyaRfConfirmed = ref(false);
|
||||
|
||||
function chooseVsyaRf(): void {
|
||||
vsyaRf.value = true;
|
||||
vsyaRfConfirmed.value = false;
|
||||
}
|
||||
|
||||
function confirmVsyaRf(): void {
|
||||
vsyaRfConfirmed.value = true;
|
||||
form.regions = []; // Вся РФ → пустой массив субъектов
|
||||
delete errors.regions;
|
||||
}
|
||||
|
||||
function cancelVsyaRf(): void {
|
||||
vsyaRf.value = false;
|
||||
vsyaRfConfirmed.value = false;
|
||||
}
|
||||
|
||||
function onRegionsChange(codes: number[]): void {
|
||||
form.regions = Array.isArray(codes) ? codes : [];
|
||||
if (form.regions.length > 0) {
|
||||
// Взаимоисключение: выбор конкретных субъектов снимает «Вся РФ».
|
||||
vsyaRf.value = false;
|
||||
vsyaRfConfirmed.value = false;
|
||||
delete errors.regions;
|
||||
}
|
||||
}
|
||||
|
||||
const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||||
const selectedDays = ref<number[]>([0, 1, 2, 3, 4, 5, 6]);
|
||||
watch(selectedDays, (days) => {
|
||||
@@ -191,12 +271,18 @@ watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (open) generalError.value = null;
|
||||
if (open) {
|
||||
delete errors.regions;
|
||||
}
|
||||
if (open && props.mode === 'edit' && props.project) {
|
||||
Object.assign(form, props.project);
|
||||
form.regions = Array.isArray(props.project.regions) ? [...props.project.regions] : [];
|
||||
const days: number[] = [];
|
||||
for (let i = 0; i < 7; i++) if (form.delivery_days_mask & (1 << i)) days.push(i);
|
||||
selectedDays.value = days;
|
||||
// Существующий проект с пустыми регионами = «Вся РФ» (предзаполняем подтверждённым).
|
||||
vsyaRf.value = form.regions.length === 0;
|
||||
vsyaRfConfirmed.value = form.regions.length === 0;
|
||||
} else if (open) {
|
||||
Object.assign(form, {
|
||||
name: '',
|
||||
@@ -209,14 +295,24 @@ watch(
|
||||
delivery_days_mask: 127,
|
||||
});
|
||||
selectedDays.value = [0, 1, 2, 3, 4, 5, 6];
|
||||
vsyaRf.value = false;
|
||||
vsyaRfConfirmed.value = false;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
async function submit() {
|
||||
saving.value = true;
|
||||
generalError.value = null;
|
||||
Object.keys(errors).forEach((k) => delete errors[k]);
|
||||
|
||||
// Гейт обязательного региона: нужны либо субъекты, либо подтверждённая «Вся РФ».
|
||||
if (form.regions.length === 0 && !vsyaRfConfirmed.value) {
|
||||
errors.regions = ['Выберите регион или подтвердите «Вся РФ»'];
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
await ensureCsrfCookie();
|
||||
if (props.mode === 'edit' && props.project) {
|
||||
@@ -241,6 +337,8 @@ async function submit() {
|
||||
function close() {
|
||||
emit('update:modelValue', false);
|
||||
}
|
||||
|
||||
defineExpose({ chooseVsyaRf, confirmVsyaRf, cancelVsyaRf, onRegionsChange, vsyaRf, vsyaRfConfirmed, form, submit });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -154,6 +154,14 @@ Route::middleware('saas-admin')->group(function () {
|
||||
Route::get('/api/admin/supplier-integration/manual-queue', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@manualQueueIndex');
|
||||
Route::post('/api/admin/supplier-integration/manual-queue/{id}/resolve', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@manualQueueResolve')
|
||||
->where('id', '[0-9]+');
|
||||
|
||||
// Plan 4 Task 1: глобальный тумблер режима экспорта проектов (online|batch).
|
||||
Route::get('/api/admin/supplier-integration/export-mode', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@getExportMode');
|
||||
Route::post('/api/admin/supplier-integration/export-mode', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@setExportMode');
|
||||
|
||||
// Plan 4 Task 2: экран «Проекты у поставщика» — список + bulk-delete.
|
||||
Route::get('/api/admin/supplier-integration/projects', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@projectsIndex');
|
||||
Route::post('/api/admin/supplier-integration/projects/delete', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@projectsDestroy');
|
||||
});
|
||||
|
||||
// Plan 4 Task 11: tenant charges ledger (read-only + CSV export).
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* ВРЕМЕННЫЙ demo-скрипт — разбивает 5 тестовых пользователей на 5 отдельных тенантов.
|
||||
* Каждый логин = своя компания, данные изолированы.
|
||||
* Идемпотентный: повторный запуск не дублирует тенанты.
|
||||
* Запуск: php artisan tinker storage/_demo_split_tenants.php
|
||||
*/
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 1. Проверяем исходное состояние
|
||||
// -------------------------------------------------------------------
|
||||
$totalBefore = User::count();
|
||||
$tenantsBefore = Tenant::count();
|
||||
echo "=== ДО: {$totalBefore} пользователей, {$tenantsBefore} тенант(ов) ===\n\n";
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 2. Описание каждого пользователя и его будущего тенанта
|
||||
// -------------------------------------------------------------------
|
||||
$accounts = [
|
||||
[
|
||||
'email' => 'admin@demo.local',
|
||||
'tenant_subdomain' => 'demo', // оставляем существующий тенант
|
||||
'org_name' => null, // null = взять из существующего
|
||||
'create_new_tenant' => false,
|
||||
],
|
||||
[
|
||||
'email' => 'manager1@demo.local',
|
||||
'tenant_subdomain' => 'ivan-demo',
|
||||
'org_name' => 'Компания Ивана',
|
||||
'create_new_tenant' => true,
|
||||
],
|
||||
[
|
||||
'email' => 'manager2@demo.local',
|
||||
'tenant_subdomain' => 'anna-demo',
|
||||
'org_name' => 'Компания Анны',
|
||||
'create_new_tenant' => true,
|
||||
],
|
||||
[
|
||||
'email' => 'manager3@demo.local',
|
||||
'tenant_subdomain' => 'petr-demo',
|
||||
'org_name' => 'Компания Петра',
|
||||
'create_new_tenant' => true,
|
||||
],
|
||||
[
|
||||
'email' => 'manager4@demo.local',
|
||||
'tenant_subdomain' => 'mariya-demo',
|
||||
'org_name' => 'Компания Марии',
|
||||
'create_new_tenant' => true,
|
||||
],
|
||||
];
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 3. Создаём тенанты и переназначаем пользователей
|
||||
// -------------------------------------------------------------------
|
||||
foreach ($accounts as $a) {
|
||||
$user = User::query()->where('email', $a['email'])->firstOrFail();
|
||||
|
||||
if (! $a['create_new_tenant']) {
|
||||
// Demo Admin остаётся в tenant "demo"
|
||||
$tenant = Tenant::query()->where('subdomain', $a['tenant_subdomain'])->firstOrFail();
|
||||
echo "SKIP {$user->email} → тенант «{$tenant->organization_name}» (id={$tenant->id}) — без изменений\n";
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Создаём новый тенант, если ещё не существует
|
||||
$tenant = Tenant::query()->firstOrCreate(
|
||||
['subdomain' => $a['tenant_subdomain']],
|
||||
[
|
||||
'organization_name' => $a['org_name'],
|
||||
'contact_email' => $user->email,
|
||||
'webhook_token' => Str::random(64),
|
||||
'timezone' => 'Europe/Moscow',
|
||||
'locale' => 'ru',
|
||||
'is_trial' => true,
|
||||
'api_key_limit' => 5,
|
||||
]
|
||||
);
|
||||
|
||||
// Переназначаем пользователя в новый тенант
|
||||
$user->tenant_id = $tenant->id;
|
||||
$user->save();
|
||||
|
||||
echo "OK {$user->email} → новый тенант «{$tenant->organization_name}» (id={$tenant->id}, subdomain={$tenant->subdomain})\n";
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 4. Итоговый отчёт
|
||||
// -------------------------------------------------------------------
|
||||
echo "\n=== ИТОГО: изоляция тенантов ===\n";
|
||||
$tenants = Tenant::query()
|
||||
->whereIn('subdomain', ['demo', 'ivan-demo', 'anna-demo', 'petr-demo', 'mariya-demo'])
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
foreach ($tenants as $t) {
|
||||
$users = User::query()->where('tenant_id', $t->id)->pluck('email')->implode(', ');
|
||||
$projects = Project::query()->where('tenant_id', $t->id)->count();
|
||||
echo sprintf(
|
||||
" Тенант %-12s (id=%-2d) — пользователи: %-40s | проектов: %d\n",
|
||||
$t->subdomain,
|
||||
$t->id,
|
||||
$users ?: '(нет)',
|
||||
$projects
|
||||
);
|
||||
}
|
||||
|
||||
echo "\nГотово. Каждый логин теперь в отдельной компании.\n";
|
||||
echo "Пароль для всех: password\n";
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
// EnsureSaasAdmin — стаб в testing (см. SupplierManualQueueTest::16); actingAs
|
||||
// нужен только для прохода middleware-стека auth+admin.
|
||||
|
||||
it('GET export-mode returns current value', function (): void {
|
||||
$this->actingAs(User::factory()->create());
|
||||
DB::table('system_settings')->updateOrInsert(
|
||||
['key' => 'supplier_export_mode'],
|
||||
['value' => 'batch', 'type' => 'string', 'updated_at' => now()],
|
||||
);
|
||||
|
||||
$this->getJson('/api/admin/supplier-integration/export-mode')
|
||||
->assertOk()
|
||||
->assertJson(['mode' => 'batch']);
|
||||
});
|
||||
|
||||
it('POST export-mode switches value', function (): void {
|
||||
$this->actingAs(User::factory()->create());
|
||||
DB::table('system_settings')->updateOrInsert(
|
||||
['key' => 'supplier_export_mode'],
|
||||
['value' => 'batch', 'type' => 'string', 'updated_at' => now()],
|
||||
);
|
||||
|
||||
$this->postJson('/api/admin/supplier-integration/export-mode', ['mode' => 'online'])
|
||||
->assertOk()
|
||||
->assertJson(['mode' => 'online']);
|
||||
|
||||
expect(DB::table('system_settings')->where('key', 'supplier_export_mode')->value('value'))
|
||||
->toBe('online');
|
||||
});
|
||||
|
||||
it('POST export-mode rejects invalid value', function (): void {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$this->postJson('/api/admin/supplier-integration/export-mode', ['mode' => 'turbo'])
|
||||
->assertStatus(422);
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
// EnsureSaasAdmin — стаб в testing (см. SupplierManualQueueTest::16): обычный
|
||||
// User::factory + actingAs без guard'а.
|
||||
|
||||
it('GET /admin/supplier-integration/projects returns rows with orderers + last delivery', function (): void {
|
||||
$this->actingAs(User::factory()->create());
|
||||
$tenant = Tenant::factory()->create(['organization_name' => 'ООО Ромашка']);
|
||||
|
||||
$sp = SupplierProject::query()->create([
|
||||
'platform' => 'B1',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'okna.ru',
|
||||
'subject_code' => 82, // Москва (по конституционному порядку, ст. 65)
|
||||
'current_limit' => 5,
|
||||
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
|
||||
'current_regions' => null,
|
||||
'sync_status' => 'ok',
|
||||
'supplier_external_id' => '777',
|
||||
]);
|
||||
|
||||
$project = Project::factory()->for($tenant)->create();
|
||||
DB::table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id,
|
||||
'supplier_project_id' => $sp->id,
|
||||
'platform' => 'B1',
|
||||
'subject_code' => 82,
|
||||
]);
|
||||
|
||||
DB::table('supplier_leads')->insert([
|
||||
'supplier_project_id' => $sp->id,
|
||||
'platform' => 'B1',
|
||||
'raw_payload' => json_encode([]),
|
||||
'phone' => '+79991234567',
|
||||
'received_at' => '2026-05-19 10:00:00',
|
||||
]);
|
||||
|
||||
$resp = $this->getJson('/api/admin/supplier-integration/projects')
|
||||
->assertOk()
|
||||
->json();
|
||||
|
||||
$row = collect($resp['projects'])->firstWhere('id', $sp->id);
|
||||
expect($row)->not->toBeNull()
|
||||
->and($row['unique_key'])->toBe('okna.ru')
|
||||
->and($row['subject_code'])->toBe(82)
|
||||
->and($row['subject_name'])->toBe('Москва')
|
||||
->and($row['platform'])->toBe('B1')
|
||||
->and($row['current_limit'])->toBe(5)
|
||||
->and($row['orderers'])->toContain('ООО Ромашка')
|
||||
->and($row['last_delivery_at'])->not->toBeNull();
|
||||
});
|
||||
|
||||
it('GET /projects returns subject_name «РФ» for NULL subject_code', function (): void {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$sp = SupplierProject::query()->create([
|
||||
'platform' => 'B2',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'all-russia.example',
|
||||
'subject_code' => null,
|
||||
'current_limit' => 0,
|
||||
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
|
||||
'current_regions' => null,
|
||||
'sync_status' => 'ok',
|
||||
'supplier_external_id' => '888',
|
||||
]);
|
||||
|
||||
$resp = $this->getJson('/api/admin/supplier-integration/projects')->assertOk()->json();
|
||||
$row = collect($resp['projects'])->firstWhere('id', $sp->id);
|
||||
expect($row['subject_code'])->toBeNull()
|
||||
->and($row['subject_name'])->toBe('РФ');
|
||||
});
|
||||
|
||||
it('POST /projects/delete deletes on portal + locally (pivot cascades)', function (): void {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
// Мокаем portal-клиент, чтобы не лезть в Redis-сессию (SupplierPortalClient::loadSession()).
|
||||
$deletedExternalIds = [];
|
||||
$clientMock = new class($deletedExternalIds) extends SupplierPortalClient
|
||||
{
|
||||
/** @var array<int, int> */
|
||||
public array $calls;
|
||||
|
||||
public function __construct(array &$calls)
|
||||
{
|
||||
$this->calls = &$calls;
|
||||
}
|
||||
|
||||
public function deleteProject(int $externalId): void
|
||||
{
|
||||
$this->calls[] = $externalId;
|
||||
}
|
||||
};
|
||||
app()->instance(SupplierPortalClient::class, $clientMock);
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$sp = SupplierProject::query()->create([
|
||||
'platform' => 'B1',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'delete-me.ru',
|
||||
'subject_code' => 77,
|
||||
'current_limit' => 0,
|
||||
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
|
||||
'current_regions' => null,
|
||||
'sync_status' => 'ok',
|
||||
'supplier_external_id' => '999',
|
||||
]);
|
||||
$project = Project::factory()->for($tenant)->create();
|
||||
DB::table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id,
|
||||
'supplier_project_id' => $sp->id,
|
||||
'platform' => 'B1',
|
||||
'subject_code' => 77,
|
||||
]);
|
||||
|
||||
$this->postJson('/api/admin/supplier-integration/projects/delete', ['ids' => [$sp->id]])
|
||||
->assertOk()
|
||||
->assertJson(['deleted' => 1, 'failures' => []]);
|
||||
|
||||
expect(SupplierProject::find($sp->id))->toBeNull();
|
||||
expect($clientMock->calls)->toBe([999]);
|
||||
expect(DB::table('project_supplier_links')->where('supplier_project_id', $sp->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('POST /projects/delete validates ids array', function (): void {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$this->postJson('/api/admin/supplier-integration/projects/delete', ['ids' => []])
|
||||
->assertStatus(422);
|
||||
|
||||
$this->postJson('/api/admin/supplier-integration/projects/delete', [])
|
||||
->assertStatus(422);
|
||||
});
|
||||
|
||||
it('POST /projects/delete collects failures without aborting batch', function (): void {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$clientMock = new class extends SupplierPortalClient
|
||||
{
|
||||
public int $callsCount = 0;
|
||||
|
||||
public function __construct() {}
|
||||
|
||||
public function deleteProject(int $externalId): void
|
||||
{
|
||||
$this->callsCount++;
|
||||
if ($externalId === 555) {
|
||||
throw new RuntimeException('portal said no');
|
||||
}
|
||||
}
|
||||
};
|
||||
app()->instance(SupplierPortalClient::class, $clientMock);
|
||||
|
||||
$spOk = SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'ok.ru',
|
||||
'subject_code' => 77, 'current_limit' => 0,
|
||||
'current_workdays' => [1, 2, 3, 4, 5, 6, 7], 'current_regions' => null,
|
||||
'sync_status' => 'ok', 'supplier_external_id' => '111',
|
||||
]);
|
||||
$spBad = SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'bad.ru',
|
||||
'subject_code' => 77, 'current_limit' => 0,
|
||||
'current_workdays' => [1, 2, 3, 4, 5, 6, 7], 'current_regions' => null,
|
||||
'sync_status' => 'ok', 'supplier_external_id' => '555',
|
||||
]);
|
||||
|
||||
$resp = $this->postJson('/api/admin/supplier-integration/projects/delete', [
|
||||
'ids' => [$spOk->id, $spBad->id],
|
||||
])->assertOk()->json();
|
||||
|
||||
expect($resp['deleted'])->toBe(1)
|
||||
->and(count($resp['failures']))->toBe(1)
|
||||
->and($resp['failures'][0]['id'])->toBe($spBad->id)
|
||||
->and($resp['failures'][0]['error'])->toContain('portal said no');
|
||||
|
||||
expect(SupplierProject::find($spOk->id))->toBeNull();
|
||||
expect(SupplierProject::find($spBad->id))->not->toBeNull(); // bad — не удалён локально
|
||||
});
|
||||
@@ -36,7 +36,7 @@ it('end-to-end: 1 webhook → 3 deal copies for 3 active tenants', function ():
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
$t = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$tenants->push($t);
|
||||
$projects->push(Project::factory()->create([
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $t->id,
|
||||
'supplier_b1_project_id' => $supplier->id,
|
||||
'signal_type' => 'site',
|
||||
@@ -49,18 +49,24 @@ it('end-to-end: 1 webhook → 3 deal copies for 3 active tenants', function ():
|
||||
'region_mode' => 'include',
|
||||
'daily_limit_target' => 10,
|
||||
'effective_daily_limit_today' => null,
|
||||
]));
|
||||
]);
|
||||
$projects->push($project);
|
||||
// v8.26 (Plan 1-2): LeadRouter eligibility — через pivot project_supplier_links,
|
||||
// не legacy supplier_b1_project_id. Без pivot-связи проект не eligible → 0 сделок.
|
||||
linkProjectToSupplier($project, $supplier);
|
||||
}
|
||||
|
||||
// 4-й tenant — paused
|
||||
// 4-й tenant — paused (is_active=false). Связь в pivot есть, чтобы проверялся
|
||||
// именно фильтр is_active, а не отсутствие связи.
|
||||
$pausedTenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
Project::factory()->create([
|
||||
$pausedProject = Project::factory()->create([
|
||||
'tenant_id' => $pausedTenant->id,
|
||||
'supplier_b1_project_id' => $supplier->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'vashinvestor.ru',
|
||||
'is_active' => false,
|
||||
]);
|
||||
linkProjectToSupplier($pausedProject, $supplier);
|
||||
|
||||
$vid = 432176649;
|
||||
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
|
||||
|
||||
@@ -27,19 +27,23 @@ test('supplier_projects table exists with required columns', function () {
|
||||
}
|
||||
});
|
||||
|
||||
test('supplier_projects has unique constraint on (platform, unique_key)', function () {
|
||||
test('supplier_projects has unique constraint on (platform, unique_key, subject_code)', function () {
|
||||
// v8.26 (project-migration-redesign Plan 1): per-субъект экспорт — composite unique
|
||||
// расширен до (platform, unique_key, subject_code) NULLS NOT DISTINCT. Старый
|
||||
// 2-колоночный индекс supplier_projects_platform_unique_key_unique заменён.
|
||||
$idx = DB::selectOne(
|
||||
"SELECT indexdef
|
||||
FROM pg_indexes
|
||||
WHERE tablename = 'supplier_projects'
|
||||
AND indexname = 'supplier_projects_platform_unique_key_unique'"
|
||||
AND indexname = 'supplier_projects_platform_key_subject_unique'"
|
||||
);
|
||||
|
||||
expect($idx)->not->toBeNull();
|
||||
expect($idx->indexdef)
|
||||
->toContain('UNIQUE')
|
||||
->toContain('platform')
|
||||
->toContain('unique_key');
|
||||
->toContain('unique_key')
|
||||
->toContain('subject_code');
|
||||
});
|
||||
|
||||
test('supplier_projects platform check constraint allows only B1, B2, B3', function () {
|
||||
|
||||
@@ -59,26 +59,28 @@ it('supplier_csv_reconcile_log table exists with required columns and status CHE
|
||||
]))->toThrow(QueryException::class);
|
||||
});
|
||||
|
||||
it('schema.sql v8.25 has correct metrics — 64 base tables, 121 indexes, 40 RLS policies', function () {
|
||||
it('schema.sql v8.26 has correct metrics — 65 base tables, 123 indexes, 40 RLS policies', function () {
|
||||
// Замена destructive `migrate:fresh` (cross-test coupling: после DROP CASCADE остальные
|
||||
// Feature-тесты в той же сессии видели пустую БД). Static parse `db/schema.sql` —
|
||||
// источник истины метрик из spec §2.4 / db/CHANGELOG_schema.md v8.25.
|
||||
// источник истины метрик из spec §2.4 / db/CHANGELOG_schema.md v8.26.
|
||||
// v8.21 (Sprint 4): +1 таблица import_unknown_statuses, +1 индекс, +1 RLS-политика.
|
||||
// v8.22 (Plan 6/C9): +1 GIN-индекс idx_projects_regions.
|
||||
// v8.25 (supplier-failover): +1 таблица supplier_manual_sync_queue, +2 индекса.
|
||||
// v8.26 (project-migration-redesign Plans 1-3): +1 таблица project_supplier_links (M:N pivot)
|
||||
// + 2 индекса (supplier_projects_platform_key_subject_unique, idx_psl_*).
|
||||
$schemaPath = dirname(base_path()).DIRECTORY_SEPARATOR.'db'.DIRECTORY_SEPARATOR.'schema.sql';
|
||||
expect(is_file($schemaPath) && is_readable($schemaPath))->toBeTrue();
|
||||
$schema = file_get_contents($schemaPath);
|
||||
expect($schema)->not->toBeFalse();
|
||||
|
||||
// 64 base tables = все CREATE TABLE минус 12 партиций (PARTITION OF).
|
||||
// 65 base tables = все CREATE TABLE минус 12 партиций (PARTITION OF).
|
||||
$createTables = preg_match_all('/^CREATE TABLE\b/m', $schema);
|
||||
$partitionOf = preg_match_all('/CREATE TABLE\s+\w+\s+PARTITION OF\b/m', $schema);
|
||||
$baseTables = $createTables - $partitionOf;
|
||||
expect($baseTables)->toBe(64);
|
||||
expect($baseTables)->toBe(65);
|
||||
|
||||
$createIndexes = preg_match_all('/^CREATE\s+(?:UNIQUE\s+)?INDEX\b/m', $schema);
|
||||
expect($createIndexes)->toBe(121); // v8.25: +2 idx_smsq_status_created, idx_smsq_project
|
||||
expect($createIndexes)->toBe(123); // v8.26: +2 supplier_projects_platform_key_subject_unique, idx_psl_*
|
||||
|
||||
$createPolicies = preg_match_all('/^CREATE\s+POLICY\b/m', $schema);
|
||||
expect($createPolicies)->toBe(40);
|
||||
|
||||
@@ -10,7 +10,7 @@ use Illuminate\Support\Facades\Queue;
|
||||
|
||||
beforeEach(fn () => Queue::fake());
|
||||
|
||||
it('updates name+daily_limit without resync', function () {
|
||||
it('updates name without resync (name is local-only)', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$project = Project::factory()->create([
|
||||
@@ -19,14 +19,45 @@ it('updates name+daily_limit without resync', function () {
|
||||
]);
|
||||
|
||||
$this->actingAs($user)->patchJson("/api/projects/{$project->id}", [
|
||||
'name' => 'New name', 'daily_limit_target' => 50,
|
||||
'name' => 'New name',
|
||||
])->assertOk();
|
||||
|
||||
expect($project->fresh()->name)->toBe('New name');
|
||||
expect($project->fresh()->daily_limit_target)->toBe(50);
|
||||
Queue::assertNotPushed(SyncSupplierProjectJob::class);
|
||||
});
|
||||
|
||||
it('changing daily_limit_target triggers resync (poster must see new limit immediately)', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id, 'signal_type' => 'site',
|
||||
'signal_identifier' => 'a.ru', 'daily_limit_target' => 10,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)->patchJson("/api/projects/{$project->id}", [
|
||||
'daily_limit_target' => 50,
|
||||
])->assertOk();
|
||||
|
||||
expect($project->fresh()->daily_limit_target)->toBe(50);
|
||||
Queue::assertPushed(SyncSupplierProjectJob::class);
|
||||
});
|
||||
|
||||
it('changing delivery_days_mask triggers resync (poster must see new days immediately)', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id, 'signal_type' => 'site',
|
||||
'signal_identifier' => 'a.ru', 'delivery_days_mask' => 31,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)->patchJson("/api/projects/{$project->id}", [
|
||||
'delivery_days_mask' => 63, // +Сб
|
||||
])->assertOk();
|
||||
|
||||
expect($project->fresh()->delivery_days_mask)->toBe(63);
|
||||
Queue::assertPushed(SyncSupplierProjectJob::class);
|
||||
});
|
||||
|
||||
it('changing sms_senders triggers resync', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
|
||||
@@ -9,11 +9,12 @@ use Illuminate\Support\Facades\Http;
|
||||
it('multi-flag save returns external_id per platform via listProjects', function (): void {
|
||||
Http::fake([
|
||||
'*/admin/visit/rt-project-save' => Http::response(['status' => 'OK', 'id' => '300'], 200),
|
||||
// Real portal returns name='B1_<identifier>' with the identifier in 'content'.
|
||||
'*/admin/visit/rt-projects-load*' => Http::response(['projects' => [
|
||||
['id' => '100', 'name' => 'okna.ru', 'tag' => 'Москва', 'src' => 'rt'],
|
||||
['id' => '200', 'name' => 'okna.ru', 'tag' => 'Москва', 'src' => 'bl'],
|
||||
['id' => '300', 'name' => 'okna.ru', 'tag' => 'Москва', 'src' => 'mt'],
|
||||
['id' => '999', 'name' => 'other.ru', 'tag' => 'Москва', 'src' => 'rt'],
|
||||
['id' => '100', 'name' => 'B1_okna.ru', 'content' => 'okna.ru', 'tag' => 'Москва', 'src' => 'rt'],
|
||||
['id' => '200', 'name' => 'B2_okna.ru', 'content' => 'okna.ru', 'tag' => 'Москва', 'src' => 'bl'],
|
||||
['id' => '300', 'name' => 'B3_okna.ru', 'content' => 'okna.ru', 'tag' => 'Москва', 'src' => 'mt'],
|
||||
['id' => '999', 'name' => 'B1_other.ru', 'content' => 'other.ru', 'tag' => 'Москва', 'src' => 'rt'],
|
||||
]], 200),
|
||||
]);
|
||||
|
||||
|
||||
@@ -38,10 +38,10 @@ afterEach(function (): void {
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Online mode: per-subject supplier_projects + pivot
|
||||
// Online mode: single-group supplier_projects + pivot
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('online mode creates per-subject supplier_projects with full params + pivot', function (): void {
|
||||
it('online mode creates single-group supplier_projects with full regions + pivot', function (): void {
|
||||
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
|
||||
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
@@ -73,13 +73,108 @@ it('online mode creates per-subject supplier_projects with full params + pivot',
|
||||
|
||||
(new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class));
|
||||
|
||||
// 3 supplier_projects: subject_code=82, platforms B1/B2/B3
|
||||
expect(SupplierProject::where('unique_key', 'okna.ru')->where('subject_code', 82)->count())->toBe(3);
|
||||
// 3 supplier_projects: subject_code=null (single group), platforms B1/B2/B3
|
||||
expect(SupplierProject::where('unique_key', 'okna.ru')->whereNull('subject_code')->count())->toBe(3);
|
||||
|
||||
// pivot: 3 links for this project
|
||||
expect(DB::table('project_supplier_links')->where('project_id', $project->id)->count())->toBe(3);
|
||||
});
|
||||
|
||||
it('online mode passes real workdays from delivery_days_mask (not hardcoded [1..7])', function (): void {
|
||||
// Regression: до фикса хардкодилось [1,2,3,4,5,6,7] независимо от delivery_days_mask.
|
||||
// delivery_days_mask=31 = 0b0011111 = Пн-Пт (ISO дни 1-5). Workdays поставщика должны быть [1,2,3,4,5].
|
||||
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
|
||||
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'call',
|
||||
'signal_identifier' => '79135191264',
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 15,
|
||||
'regions' => [],
|
||||
'delivery_days_mask' => 31, // Пн-Пт
|
||||
]);
|
||||
|
||||
$capturedWorkdays = null;
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => function ($request) use (&$capturedWorkdays) {
|
||||
$body = $request->data();
|
||||
if (isset($body['workdays'])) {
|
||||
$capturedWorkdays = $body['workdays'];
|
||||
}
|
||||
|
||||
return Http::response(['status' => 'OK', 'message' => '', 'result' => null, 'id' => '2001'], 200);
|
||||
},
|
||||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(
|
||||
['projects' => [
|
||||
['id' => '2001', 'src' => 'rt', 'name' => '79135191264', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79135191264'],
|
||||
['id' => '2002', 'src' => 'bl', 'name' => '79135191264', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79135191264'],
|
||||
['id' => '2003', 'src' => 'mt', 'name' => '79135191264', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79135191264'],
|
||||
]],
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
(new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class));
|
||||
|
||||
// 1) supplier_projects записаны с реальными буднями, не all-7.
|
||||
$sps = SupplierProject::where('unique_key', '79135191264')->get();
|
||||
expect($sps)->toHaveCount(3);
|
||||
foreach ($sps as $sp) {
|
||||
expect($sp->current_workdays)->toBe([1, 2, 3, 4, 5]);
|
||||
}
|
||||
|
||||
// 2) HTTP payload к порталу содержал ["1","2","3","4","5"], не ["1".."7"].
|
||||
expect($capturedWorkdays)->toBe(['1', '2', '3', '4', '5']);
|
||||
});
|
||||
|
||||
it('online mode update-path: existing supplier_projects.current_workdays is refreshed (not just regions/limit)', function (): void {
|
||||
// Regression: forceFill ранее не включал current_workdays — после первого create со
|
||||
// старым хардкод-[1..7] последующий ресинк не подтягивал реальные дни.
|
||||
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
|
||||
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'call',
|
||||
'signal_identifier' => '79991234567',
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 9,
|
||||
'regions' => [],
|
||||
'delivery_days_mask' => 31, // Пн-Пт
|
||||
]);
|
||||
|
||||
// Pre-seed existing supplier_projects со старыми (хардкод-)workdays.
|
||||
foreach (['B1', 'B2', 'B3'] as $platform) {
|
||||
SupplierProject::create([
|
||||
'platform' => $platform,
|
||||
'signal_type' => 'call',
|
||||
'unique_key' => '79991234567',
|
||||
'subject_code' => null,
|
||||
'supplier_external_id' => '99'.$platform,
|
||||
'current_limit' => 6,
|
||||
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
|
||||
'current_regions' => [],
|
||||
'sync_status' => 'ok',
|
||||
'last_synced_at' => now()->subDay(),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->mock(SupplierProjectChannel::class, function ($mock): void {
|
||||
$mock->shouldReceive('updateProject')->times(3)->andReturn(true);
|
||||
});
|
||||
|
||||
(new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class));
|
||||
|
||||
$sps = SupplierProject::where('unique_key', '79991234567')->get();
|
||||
expect($sps)->toHaveCount(3);
|
||||
foreach ($sps as $sp) {
|
||||
expect($sp->current_workdays)->toBe([1, 2, 3, 4, 5]);
|
||||
expect($sp->current_limit)->toBe(9);
|
||||
}
|
||||
});
|
||||
|
||||
it('online mode all-RF (no regions): 1 group subject_code=null, 3 supplier_projects + 3 pivot links', function (): void {
|
||||
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
|
||||
|
||||
|
||||
@@ -42,15 +42,15 @@ afterEach(function (): void {
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-subject grouping
|
||||
// Multi-region grouping (merged into single group)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Project regions=[82,83] site → 2 groups (Москва, СПб) →
|
||||
* 2 multi-flag saves → 6 supplier_projects (2 subjects × 3 platforms B1/B2/B3)
|
||||
* with correct subject_code/tag; pivot — 6 links for the project.
|
||||
* Project regions=[82,83] site → 1 group (merged regions) → tag='РФ' →
|
||||
* 1 multi-flag save → 3 supplier_projects (platforms B1/B2/B3)
|
||||
* subject_code=null, current_regions=[82,83]; pivot — 3 links for the project.
|
||||
*/
|
||||
test('per-subject: regions=[82,83] site → 6 supplier_projects + 6 pivot links', function (): void {
|
||||
test('single-group: regions=[82,83] site → merged regions tag=РФ → 3 supplier_projects + 3 pivot links', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
/** @var Project $project */
|
||||
@@ -61,60 +61,48 @@ test('per-subject: regions=[82,83] site → 6 supplier_projects + 6 pivot links'
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'persubject.example.com',
|
||||
'daily_limit_target' => 9,
|
||||
'delivery_days_mask' => 127, // all days
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [82, 83],
|
||||
]);
|
||||
|
||||
// saveProjectMultiFlag calls rt-project-save once per subject, then listProjects to get ids
|
||||
// One save (merged regions=[82,83] → tag='РФ') + one listProjects
|
||||
Http::fake([
|
||||
// first save (subject 82 = Москва)
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::sequence()
|
||||
->push(['status' => 'OK', 'message' => '', 'result' => null, 'id' => '1001'], 200)
|
||||
// second save (subject 83 = Санкт-Петербург)
|
||||
->push(['status' => 'OK', 'message' => '', 'result' => null, 'id' => '2001'], 200),
|
||||
// listProjects called after each save — return 3 rows per group
|
||||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::sequence()
|
||||
// After first save (Москва tag)
|
||||
->push(['projects' => [
|
||||
['id' => '1001', 'src' => 'rt', 'name' => 'persubject.example.com', 'tag' => 'Москва', 'type' => 'hosts', 'content' => 'persubject.example.com'],
|
||||
['id' => '1002', 'src' => 'bl', 'name' => 'persubject.example.com', 'tag' => 'Москва', 'type' => 'hosts', 'content' => 'persubject.example.com'],
|
||||
['id' => '1003', 'src' => 'mt', 'name' => 'persubject.example.com', 'tag' => 'Москва', 'type' => 'hosts', 'content' => 'persubject.example.com'],
|
||||
]], 200)
|
||||
// After second save (СПб tag)
|
||||
->push(['projects' => [
|
||||
['id' => '1001', 'src' => 'rt', 'name' => 'persubject.example.com', 'tag' => 'Москва', 'type' => 'hosts', 'content' => 'persubject.example.com'],
|
||||
['id' => '1002', 'src' => 'bl', 'name' => 'persubject.example.com', 'tag' => 'Москва', 'type' => 'hosts', 'content' => 'persubject.example.com'],
|
||||
['id' => '1003', 'src' => 'mt', 'name' => 'persubject.example.com', 'tag' => 'Москва', 'type' => 'hosts', 'content' => 'persubject.example.com'],
|
||||
['id' => '2001', 'src' => 'rt', 'name' => 'persubject.example.com', 'tag' => 'Санкт-Петербург', 'type' => 'hosts', 'content' => 'persubject.example.com'],
|
||||
['id' => '2002', 'src' => 'bl', 'name' => 'persubject.example.com', 'tag' => 'Санкт-Петербург', 'type' => 'hosts', 'content' => 'persubject.example.com'],
|
||||
['id' => '2003', 'src' => 'mt', 'name' => 'persubject.example.com', 'tag' => 'Санкт-Петербург', 'type' => 'hosts', 'content' => 'persubject.example.com'],
|
||||
]], 200),
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '1001'],
|
||||
200,
|
||||
),
|
||||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(
|
||||
['projects' => [
|
||||
['id' => '1001', 'src' => 'rt', 'name' => 'persubject.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'persubject.example.com'],
|
||||
['id' => '1002', 'src' => 'bl', 'name' => 'persubject.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'persubject.example.com'],
|
||||
['id' => '1003', 'src' => 'mt', 'name' => 'persubject.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'persubject.example.com'],
|
||||
]],
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
|
||||
// 6 supplier_projects created: 2 subjects × 3 platforms
|
||||
// 3 supplier_projects (not 6): all regions merged into one group
|
||||
$sps = SupplierProject::on('pgsql_supplier')
|
||||
->where('unique_key', 'persubject.example.com')
|
||||
->where('signal_type', 'site')
|
||||
->get();
|
||||
|
||||
expect($sps)->toHaveCount(6);
|
||||
expect($sps)->toHaveCount(3);
|
||||
expect($sps->pluck('platform')->sort()->values()->all())->toBe(['B1', 'B2', 'B3']);
|
||||
|
||||
// subject_code 82 → 3 rows (B1/B2/B3)
|
||||
$m = $sps->where('subject_code', 82);
|
||||
expect($m)->toHaveCount(3);
|
||||
expect($m->pluck('platform')->sort()->values()->all())->toBe(['B1', 'B2', 'B3']);
|
||||
// subject_code=null (no per-subject split)
|
||||
expect($sps->pluck('subject_code')->unique()->all())->toContain(null);
|
||||
|
||||
// subject_code 83 → 3 rows
|
||||
$spb = $sps->where('subject_code', 83);
|
||||
expect($spb)->toHaveCount(3);
|
||||
// regions merged: [82, 83] — sorted ascending, stored on each SP
|
||||
expect($sps->firstWhere('platform', 'B1')->current_regions)->toBe([82, 83]);
|
||||
|
||||
// pivot: 6 links for this project
|
||||
// pivot: 3 links (not 6)
|
||||
$pivotCount = DB::table('project_supplier_links')
|
||||
->where('project_id', $project->id)
|
||||
->count();
|
||||
expect($pivotCount)->toBe(6);
|
||||
expect($pivotCount)->toBe(3);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -225,8 +213,10 @@ test('order: 2 projects same source×subject → computeOrder(limits=[10,20])
|
||||
expect($sp)->not->toBeNull();
|
||||
expect($sp->current_limit)->toBe(20);
|
||||
|
||||
// Only one save call (single group) — not 2
|
||||
Http::assertSentCount(2); // 1 save + 1 listProjects
|
||||
// Single group → exactly 3 supplier_projects (not 6 as would happen if grouped separately)
|
||||
expect(SupplierProject::on('pgsql_supplier')
|
||||
->where('unique_key', 'order-test.example.com')
|
||||
->count())->toBe(3);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -9,9 +9,10 @@ import { useAuthStore } from '../../resources/js/stores/auth';
|
||||
import type { AuthUser } from '../../resources/js/api/auth';
|
||||
|
||||
// AdminLayout содержит:
|
||||
// - sidebar #012019 с brand-block «Лидерра.» + ADMIN метка + 7 nav-items
|
||||
// (Тенанты 142 / Биллинг / Тарифная сетка / Цены поставщиков / Инциденты 3 /
|
||||
// Impersonation / Система);
|
||||
// - sidebar #012019 с brand-block «Лидерра.» + ADMIN метка + 9 nav-items
|
||||
// (Тенанты / Биллинг / Тарифная сетка / Цены поставщиков / Инциденты /
|
||||
// Impersonation / Система / Интеграция с поставщиком / Проекты у поставщика),
|
||||
// без mock count-badge;
|
||||
// - topbar с breadcrumb («Админка › <currentPageTitle>») + user-menu;
|
||||
// - <v-main> RouterView; DevIndexBadge.
|
||||
|
||||
@@ -84,12 +85,12 @@ describe('AdminLayout.vue', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('показывает count-badge для Тенантов (142) и Инцидентов (3) и не для остальных', async () => {
|
||||
it('не рендерит захардкоженные mock count-badge (live-счётчики — отдельная фича)', async () => {
|
||||
// Ранее в nav были mock-счётчики Тенанты=142 / Инциденты=3, расходящиеся с реальными
|
||||
// данными (5 тенантов / 0 открытых инцидентов). Удалены — неверный бейдж хуже отсутствия.
|
||||
const { wrapper } = await mountAdminLayout();
|
||||
const counts = wrapper.findAll('.nav-count').map((n) => n.text());
|
||||
expect(counts).toContain('142');
|
||||
expect(counts).toContain('3');
|
||||
expect(counts).toHaveLength(2);
|
||||
const counts = wrapper.findAll('.nav-count');
|
||||
expect(counts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('breadcrumb на /admin/tenants показывает «Тенанты»', async () => {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import * as components from 'vuetify/components';
|
||||
import * as directives from 'vuetify/directives';
|
||||
import axios from 'axios';
|
||||
import AdminSupplierIntegrationView from '../../resources/js/views/admin/AdminSupplierIntegrationView.vue';
|
||||
|
||||
vi.mock('axios');
|
||||
|
||||
const vuetify = createVuetify({ components, directives });
|
||||
|
||||
describe('AdminSupplierIntegrationView — export-mode toggle (Plan 4 Task 1)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(axios.get as ReturnType<typeof vi.fn>).mockImplementation((url: string) => {
|
||||
if (url.endsWith('/export-mode')) {
|
||||
return Promise.resolve({ data: { mode: 'batch' } });
|
||||
}
|
||||
if (url.endsWith('/manual-queue')) {
|
||||
return Promise.resolve({ data: { queue: [] } });
|
||||
}
|
||||
return Promise.resolve({ data: { health: null, history: [] } });
|
||||
});
|
||||
(axios.post as ReturnType<typeof vi.fn>).mockResolvedValue({ data: { mode: 'online' } });
|
||||
});
|
||||
|
||||
it('GETs current mode on mount and renders the toggle with current label', async () => {
|
||||
const wrapper = mount(AdminSupplierIntegrationView, { global: { plugins: [vuetify] } });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
expect(axios.get).toHaveBeenCalledWith('/api/admin/supplier-integration/export-mode');
|
||||
const toggle = wrapper.find('[data-testid="export-mode-toggle"]');
|
||||
expect(toggle.exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain('Режим экспорта проектов');
|
||||
expect(wrapper.text()).toContain('Пакетный');
|
||||
});
|
||||
|
||||
it('switching to online POSTs the new value', async () => {
|
||||
const wrapper = mount(AdminSupplierIntegrationView, { global: { plugins: [vuetify] } });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
const onlineBtn = wrapper.find('[data-testid="export-mode-online"]');
|
||||
expect(onlineBtn.exists()).toBe(true);
|
||||
await onlineBtn.trigger('click');
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
expect(axios.post).toHaveBeenCalledWith(
|
||||
'/api/admin/supplier-integration/export-mode',
|
||||
{ mode: 'online' },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import * as components from 'vuetify/components';
|
||||
import * as directives from 'vuetify/directives';
|
||||
import axios from 'axios';
|
||||
import AdminSupplierProjectsView from '../../resources/js/views/admin/AdminSupplierProjectsView.vue';
|
||||
|
||||
vi.mock('axios');
|
||||
|
||||
const vuetify = createVuetify({ components, directives });
|
||||
|
||||
// VDialog телепортит контент в body → стаб рендерит слот инлайн (квирк: VDialog
|
||||
// teleport стаб для поиска confirm-кнопки внутри диалога).
|
||||
const mountView = () =>
|
||||
mount(AdminSupplierProjectsView, {
|
||||
global: {
|
||||
plugins: [vuetify],
|
||||
stubs: { VDialog: { template: '<div><slot /></div>' } },
|
||||
},
|
||||
});
|
||||
|
||||
describe('AdminSupplierProjectsView (Plan 4 Task 3)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(axios.get as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
data: {
|
||||
projects: [
|
||||
{
|
||||
id: 1,
|
||||
platform: 'B1',
|
||||
signal_type: 'site',
|
||||
unique_key: 'okna.ru',
|
||||
subject_code: 82,
|
||||
subject_name: 'Москва',
|
||||
current_limit: 5,
|
||||
supplier_external_id: '777',
|
||||
orderers: ['ООО Ромашка'],
|
||||
last_delivery_at: '2026-05-19T10:00:00Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
(axios.post as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
data: { deleted: 1, failures: [] },
|
||||
});
|
||||
});
|
||||
|
||||
it('GETs list on mount and renders rows (source, region, orderers)', async () => {
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
expect(axios.get).toHaveBeenCalledWith('/api/admin/supplier-integration/projects');
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('okna.ru');
|
||||
expect(text).toContain('Москва');
|
||||
expect(text).toContain('ООО Ромашка');
|
||||
});
|
||||
|
||||
it('bulk-deletes selected rows after confirm', async () => {
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="row-checkbox-1"] input').setValue(true);
|
||||
await wrapper.find('[data-testid="bulk-delete-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
await wrapper.find('[data-testid="confirm-delete-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(axios.post).toHaveBeenCalledWith(
|
||||
'/api/admin/supplier-integration/projects/delete',
|
||||
{ ids: [1] },
|
||||
);
|
||||
});
|
||||
|
||||
it('bulk-delete button is disabled when nothing selected', async () => {
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
const btn = wrapper.find('[data-testid="bulk-delete-btn"]');
|
||||
expect(btn.attributes('disabled')).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -59,6 +59,13 @@ const mountAppLayout = async (path = '/dashboard', user: AuthUser | null = mockU
|
||||
{ path: '/billing', component: { template: '<div>billing</div>' } },
|
||||
{ path: '/reports', component: { template: '<div>reports</div>' } },
|
||||
{ path: '/settings', component: { template: '<div>settings</div>' } },
|
||||
// Не в sidebar nav, но имеют meta.title — topbar должен брать title оттуда.
|
||||
{
|
||||
path: '/reminders',
|
||||
component: { template: '<div>reminders</div>' },
|
||||
meta: { title: 'Напоминания' },
|
||||
},
|
||||
{ path: '/import', component: { template: '<div>import</div>' }, meta: { title: 'Импорт данных' } },
|
||||
],
|
||||
});
|
||||
await router.push(path);
|
||||
@@ -110,6 +117,18 @@ describe('AppLayout.vue', () => {
|
||||
expect(wrapper.text()).toContain('Дашборд');
|
||||
});
|
||||
|
||||
it('topbar title для страницы вне sidebar nav берётся из route.meta.title (Напоминания)', async () => {
|
||||
const wrapper = await mountAppLayout('/reminders');
|
||||
// Напоминания нет в sidebar nav (см. тест выше) — title должен прийти из meta, не «Страница».
|
||||
expect(wrapper.text()).toContain('Напоминания');
|
||||
expect(wrapper.text()).not.toContain('Страница');
|
||||
});
|
||||
|
||||
it('topbar title для /import берётся из route.meta.title (Импорт данных)', async () => {
|
||||
const wrapper = await mountAppLayout('/import');
|
||||
expect(wrapper.text()).toContain('Импорт данных');
|
||||
});
|
||||
|
||||
it('user-chip показывает initials и shortName из store user', async () => {
|
||||
const wrapper = await mountAppLayout();
|
||||
const text = wrapper.text();
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { createVuetify } from 'vuetify';
|
||||
|
||||
import DashboardPageHead from '../../resources/js/components/dashboard/DashboardPageHead.vue';
|
||||
import { useAuthStore } from '../../resources/js/stores/auth';
|
||||
import type { AuthUser } from '../../resources/js/api/auth';
|
||||
|
||||
const mockUser: AuthUser = {
|
||||
id: 1,
|
||||
email: 'petr.sidorov@example.ru',
|
||||
first_name: 'Пётр',
|
||||
last_name: 'Сидоров',
|
||||
tenant_id: 1,
|
||||
totp_enabled: false,
|
||||
last_login_at: null,
|
||||
};
|
||||
|
||||
const mountHead = (user: AuthUser | null = mockUser) => {
|
||||
setActivePinia(createPinia());
|
||||
useAuthStore().user = user;
|
||||
return mount(DashboardPageHead, {
|
||||
props: { modelValue: 'today' },
|
||||
global: { plugins: [createVuetify()] },
|
||||
});
|
||||
};
|
||||
|
||||
describe('DashboardPageHead.vue', () => {
|
||||
it('приветствие использует имя залогиненного пользователя, не захардкоженное «Иван»', () => {
|
||||
const wrapper = mountHead();
|
||||
const greet = wrapper.find('.page-greet').text();
|
||||
expect(greet).toContain('Пётр');
|
||||
expect(greet).not.toContain('Иван');
|
||||
});
|
||||
|
||||
it('при отсутствии user приветствие рендерится без падения', () => {
|
||||
const wrapper = mountHead(null);
|
||||
expect(wrapper.find('.page-greet').exists()).toBe(true);
|
||||
expect(wrapper.find('.page-greet').text().length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -8,9 +8,9 @@ import type { LeadStatus } from '../../resources/js/composables/leadStatuses';
|
||||
const vuetify = createVuetify();
|
||||
|
||||
const statuses: LeadStatus[] = [
|
||||
{ slug: 'new', nameRu: 'Новая сделка', colorHex: '#5b2db2', order: 1 } as LeadStatus,
|
||||
{ slug: 'viewed', nameRu: 'Просмотрено', colorHex: '#5a2db2', order: 2 } as LeadStatus,
|
||||
{ slug: 'won', nameRu: 'Куплено', colorHex: '#00A36C', order: 3 } as LeadStatus,
|
||||
{ slug: 'new', nameRu: 'Новая сделка', isSystem: true, sortOrder: 1, colorHex: '#5b2db2' },
|
||||
{ slug: 'viewed', nameRu: 'Просмотрено', isSystem: true, sortOrder: 2, colorHex: '#5a2db2' },
|
||||
{ slug: 'won', nameRu: 'Куплено', isSystem: true, sortOrder: 3, colorHex: '#00A36C' },
|
||||
];
|
||||
|
||||
function makeDeal(over: Partial<MockDeal> = {}): MockDeal {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { createVuetify } from 'vuetify';
|
||||
|
||||
vi.mock('axios');
|
||||
vi.mock('../../resources/js/api/client', () => ({
|
||||
apiClient: {
|
||||
post: vi.fn().mockResolvedValue({ data: {} }),
|
||||
patch: vi.fn().mockResolvedValue({ data: {} }),
|
||||
},
|
||||
ensureCsrfCookie: vi.fn().mockResolvedValue(undefined),
|
||||
extractErrorMessage: vi.fn(() => 'Произошла ошибка.'),
|
||||
}));
|
||||
|
||||
import { apiClient } from '../../resources/js/api/client';
|
||||
import NewProjectDialog from '../../resources/js/views/projects/NewProjectDialog.vue';
|
||||
|
||||
// VDialog teleport-стаб (как в NewProjectDialog.spec.ts): рендерит слот инлайн.
|
||||
const factory = () =>
|
||||
mount(NewProjectDialog, {
|
||||
props: { modelValue: true, mode: 'create' as const },
|
||||
global: {
|
||||
plugins: [createVuetify()],
|
||||
stubs: {
|
||||
VDialog: {
|
||||
template: '<div class="dialog-stub" v-if="modelValue"><slot /></div>',
|
||||
props: ['modelValue'],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('NewProjectDialog — required region gate + «Вся РФ» (Plan 4 Task 4)', () => {
|
||||
it('blocks submit when no region chosen and shows error', async () => {
|
||||
const w = factory();
|
||||
await flushPromises();
|
||||
|
||||
await w.find('[data-testid="submit-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(apiClient.post).not.toHaveBeenCalled();
|
||||
expect(w.text()).toContain('Выберите регион');
|
||||
});
|
||||
|
||||
it('«Вся РФ» shows warning, requires confirm, then submits regions=[]', async () => {
|
||||
const w = factory();
|
||||
await flushPromises();
|
||||
|
||||
(w.vm as unknown as { chooseVsyaRf: () => void }).chooseVsyaRf();
|
||||
await w.vm.$nextTick();
|
||||
expect(w.text()).toContain('всю Россию');
|
||||
|
||||
await w.find('[data-testid="confirm-vsya-rf"]').trigger('click');
|
||||
await w.vm.$nextTick();
|
||||
|
||||
await w.find('[data-testid="submit-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledTimes(1);
|
||||
const payload = (apiClient.post as unknown as { mock: { calls: unknown[][] } }).mock.calls[0][1] as {
|
||||
regions: number[];
|
||||
};
|
||||
expect(payload.regions).toEqual([]);
|
||||
});
|
||||
|
||||
it('picking subjects after «Вся РФ» clears the confirmation (mutual exclusion)', async () => {
|
||||
const w = factory();
|
||||
await flushPromises();
|
||||
|
||||
const vm = w.vm as unknown as {
|
||||
chooseVsyaRf: () => void;
|
||||
confirmVsyaRf: () => void;
|
||||
onRegionsChange: (codes: number[]) => void;
|
||||
vsyaRfConfirmed: boolean;
|
||||
};
|
||||
vm.chooseVsyaRf();
|
||||
vm.confirmVsyaRf();
|
||||
await w.vm.$nextTick();
|
||||
expect(vm.vsyaRfConfirmed).toBe(true);
|
||||
|
||||
vm.onRegionsChange([77]);
|
||||
await w.vm.$nextTick();
|
||||
expect(vm.vsyaRfConfirmed).toBe(false);
|
||||
|
||||
await w.find('[data-testid="submit-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
const payload = (apiClient.post as unknown as { mock: { calls: unknown[][] } }).mock.calls[0][1] as {
|
||||
regions: number[];
|
||||
};
|
||||
expect(payload.regions).toEqual([77]);
|
||||
});
|
||||
});
|
||||
@@ -168,12 +168,21 @@ describe('api/deals', () => {
|
||||
expect(r).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('listProjects() GET /api/projects + unwraps data.projects', async () => {
|
||||
it('listProjects() GET /api/projects + unwraps { data: [...] } (JsonResource collection)', async () => {
|
||||
// ProjectController::index() отдаёт response()->json(['data' => ProjectResource::collection(...)]).
|
||||
vi.mocked(apiClient.get).mockResolvedValue({
|
||||
data: { projects: [{ id: 1, name: 'P', tag: 'site', type: 'webhook' }] },
|
||||
data: { data: [{ id: 1, name: 'B1_Окна СПб' }, { id: 2, name: 'B2_Двери' }] },
|
||||
});
|
||||
const r = await listProjects(1);
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/api/projects', { params: { tenant_id: 1 } });
|
||||
expect(r[0].name).toBe('P');
|
||||
expect(Array.isArray(r)).toBe(true);
|
||||
expect(r).toHaveLength(2);
|
||||
expect(r[0].name).toBe('B1_Окна СПб');
|
||||
});
|
||||
|
||||
it('listProjects() возвращает [] при ответе без массива (защита от undefined.map)', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: {} });
|
||||
const r = await listProjects(1);
|
||||
expect(r).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1537,3 +1537,38 @@ rsave
|
||||
|
||||
# Project migration redesign — plan 4 admin + ЛК (2026-05-20)
|
||||
vsya
|
||||
|
||||
# Каналы миграции / проверка 20.05.2026
|
||||
стэшей
|
||||
учёток
|
||||
залогиненному
|
||||
незакоммичено
|
||||
|
||||
# Workdays/resync supplier sync fix (2026-05-20)
|
||||
Незакоммиченного
|
||||
petr
|
||||
mariya
|
||||
хардкодил
|
||||
Ресинк
|
||||
|
||||
# A1 backend-tooling integration (2026-05-20)
|
||||
driftingly
|
||||
nunomaduro
|
||||
lemed
|
||||
евалы
|
||||
дебага
|
||||
трейс
|
||||
трейсбэки
|
||||
антропик
|
||||
реюз
|
||||
опц
|
||||
спекам
|
||||
джобе
|
||||
биллингового
|
||||
непуст
|
||||
гейтят
|
||||
гейты
|
||||
|
||||
# Сквозной чек-лист портала + 6 фиксов (2026-05-21)
|
||||
захардкоженным
|
||||
смердженных
|
||||
|
||||
+3
-2
@@ -1,7 +1,8 @@
|
||||
-- =============================================================================
|
||||
-- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра»)
|
||||
-- Версия: v8.25 (19.05.2026 — supplier_manual_sync_queue: SaaS-level Tier 3 очередь резерва канала миграции проектов)
|
||||
-- Метрики: 64 базовые таблицы (62 regular + 2 partitioned parents: deals + supplier_lead_costs) + 12 партиций / 121 индекс / 40 RLS-политик / 5 функций / 13 триггеров
|
||||
-- Версия: v8.26 (20.05.2026 — project-migration-redesign Plans 1-3: supplier_projects.subject_code (per-субъект экспорт) + project_supplier_links (M:N pivot projects↔supplier_projects) + deals.subject_code + CHECK chk_deals_subject_code + seed system_settings.supplier_export_mode)
|
||||
-- Метрики: 65 базовые таблицы (63 regular + 2 partitioned parents: deals + supplier_lead_costs) + 12 партиций / 123 индекса / 40 RLS-политик / 5 функций / 13 триггеров
|
||||
-- Базовая версия: v8.25 (19.05.2026 — supplier_manual_sync_queue: SaaS-level Tier 3 очередь резерва канала миграции проектов)
|
||||
-- Базовая версия: v8.24 (18.05.2026 — supplier_leads.vid → nullable для CSV-recovered лидов (Путь 2))
|
||||
-- Базовая версия: v8.20 (11.05.2026 — Plan 5 frontend projects UI: projects.archived_at TIMESTAMPTZ NULL для soft archive flow; tenants.limits JSONB NOT NULL DEFAULT '{}' для per-tenant project/user лимитов)
|
||||
-- Базовая версия: v8.19 (11.05.2026 — Plan 4 billing+csv+admin: tenants.delivered_in_month, lead_charges.charge_source + CHECK, supplier_leads.recovered_from_csv_at, supplier_csv_reconcile_log)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# Plugin Stack Rules — Superpowers + Frontend Design (v3.18)
|
||||
# Plugin Stack Rules — Superpowers + Frontend Design (v3.19)
|
||||
|
||||
**Дата:** 19.05.2026
|
||||
**Назначение:** свод правил совместного использования плагинов Claude Code в проекте Лидерра — paired-stack ядро `obra/superpowers` (14 skills) + `anthropics/frontend-design`, плюс расширенный пул UI-инструментов `ui-ux-pro-max` (skill, marketplace `nextlevelbuilder/ui-ux-pro-max-skill`) и `21st.dev Magic MCP` (MCP-сервер `magic`), плюс инфраструктурный `claude-md-management` (skills, marketplace `anthropics/claude-plugins-official`), плюс **debug-runtime MCP** `@sentry/mcp-server` + `@modelcontextprotocol/server-redis` (v2.1+, R10.1 Блок 3). **17 правил R0–R16** (R15 off-phase routing введён в v3.14 на освободившийся после v2.0 R15-motion слот; R16 brain evidence loop введён в v3.16).
|
||||
|
||||
**v3.19** — A1 backend-tooling: R10.1 Блок 1 note +backend-tooling (#64 Rector + #65 PHP Insights — Composer dev-deps; #66 laravel-backend-patterns — self-authored project-скил; #67 NightOwl — DEFERRED, MCP при активации). Новая 16-я off-phase подкатегория backend-tooling, раздел A1 карты. R15.6 +backend-tooling в список категорий. Не UI → вне R6.0/R6.1/R14. Содержательных изменений R0–R16: 0. Связано: Tooling v2.19, Pravila v1.35, CLAUDE.md v2.22, ADR-013; план `docs/superpowers/plans/2026-05-20-a1-backend-tooling.md`.
|
||||
|
||||
**v3.18** — finance-tooling (C6+C7): R10.1 Блок 1 +finance plugin (#61, marketplace `finance@knowledge-work-plugins`, homed C7, cross-ref C6) + note (+billing-audit #62 / ru-tax-accounting #63 — self-authored project-скилы). Новая 15-я off-phase подкатегория finance-tooling, разделы C6/C7 карты. Не UI → вне R6.0/R6.1/R14. Содержательных изменений R0–R16: 0. Связано: Tooling v2.18, Pravila v1.34, CLAUDE.md v2.21, ADR-012; план `docs/superpowers/plans/2026-05-20-finance-tooling-c6-c7.md`.
|
||||
|
||||
**v3.17** — observer schema v2 sync (ADR-011 amend): R16.1 +предложение про `schema_version` / `decision_provenance` / `environment` / `task_size` / `prompt_signal` + расширенные события (`hook_fired` / `interrupt` / `retry` / `time_burn` / `parse_gap`) + `observer_error` маркер; R16.4 +cross-ref на factor-analysis spec и plan. R0–R15 без изменений. Routing-gate / C5 controller / `/brain-retro` analyzer — нормативно в Pravila §16.7/§16.8 + ADR-011 §5; PSR_v1 фиксирует evidence-сбор (R16), не enforcement. Связано: ADR-011, Pravila v1.32 (§16 amend), spec `docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md`, plan `docs/superpowers/plans/2026-05-19-observer-factor-analysis.md`.
|
||||
@@ -455,6 +457,8 @@ Stack — **головной**. Все плагины вне stack'а — **ин
|
||||
|
||||
**Блок 1 — note (v3.18):** **billing-audit** (Tooling #62) + **ru-tax-accounting** (Tooling #63) — self-authored project-скилы в `.claude/skills/billing-audit/` и `.claude/skills/ru-tax-accounting/`, **не** вендоренные и **не** через marketplace; написаны проектом (паттерн `audit-portal`/`regression`/`process-*`/`discovery-interview`). **Линтуются** lefthook'ом (cspell+markdownlint), **не** в ignorePaths (LINT1). Категория **finance-tooling** (15-я off-phase подкатегория, разделы C6/C7 карты), вне R6.0/R6.1/R14. ADR-012.
|
||||
|
||||
**Блок 1 — note (v3.19):** **Rector** (Tooling #64) + **PHP Insights** (Tooling #65) — Composer dev-dependencies (`rector/rector` + `driftingly/rector-laravel`; `nunomaduro/phpinsights`), **не** marketplace-плагины и **не** в `enabledPlugins` (как deptrac #43 / promptfoo #48). CLI-инструменты: Rector — авто-рефакторинг/version-upgrade (`composer rector`/`rector:fix`), manual/CI, dry-run baseline 16 файлов → **не** блокирующий lefthook; PHP Insights — метрики complexity/architecture (`composer insights`), on-demand/CI с порогами → **не** блокирующий (BT9). **laravel-backend-patterns** (Tooling #66) — self-authored project-скил в `.claude/skills/laravel-backend-patterns/`, **линтуется** (LINT1, как billing-audit/process-*). **NightOwl** (Tooling #67) — `laravel/nightwatch` + self-hosted `lemed99/nightowl-agent`, **DEFERRED** (native-Windows нет pcntl/posix; OSS без MCP; hosted 152-ФЗ); при активации (Linux/Б-1) — MCP в Блок 3 или Boost `database-query`. Категория **backend-tooling** (16-я off-phase подкатегория, раздел A1 карты), вне R6.0/R6.1/R14. ADR-013.
|
||||
|
||||
**Отмена:** через удаление из `enabledPlugins` в `~/.claude/settings.json` или через live-override `/имя-плагина` (R0.4.B) на одно действие.
|
||||
|
||||
#### Блок 2: Built-in skills Claude Code (всегда доступны через `Skill` tool по `/имя`)
|
||||
@@ -821,7 +825,7 @@ Pravila §12 (Superpowers инвокация первой), §14 (queen-роут
|
||||
- **UI-пул** (#31 UPM, #32 21st) — здесь R15 не применяется; R14 pipeline ведёт (это UI-задачи по природе).
|
||||
- **infrastructure** (#33 claude-md-management) — единственный канал для правок CLAUDE.md (Pravila §5 п.10 + R10.1 Блок 1).
|
||||
- **authoring-tooling** (#56-#58) — политика триггеров: skill-creator ≥3 повторений workflow → новый скил; hookify повторяющаяся ошибка → новый хук (с pre-check HK1); plugin-dev — для расширений plugin-grain.
|
||||
- **business-process / discovery-tooling / ml-ai-tooling / architecture-tooling / audit-security / project-management / design-tooling / integration-tooling / dev-support** — следуют routing-off-phase.md.
|
||||
- **business-process / discovery-tooling / ml-ai-tooling / architecture-tooling / audit-security / project-management / design-tooling / integration-tooling / dev-support / finance-tooling / backend-tooling** — следуют routing-off-phase.md.
|
||||
|
||||
### 15.7. Тип правила и enforcement
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
# Правила работы Claude в проекте «Лидерра»
|
||||
|
||||
**Версия:** v1.34 (20.05.2026)
|
||||
**Версия:** v1.35 (20.05.2026)
|
||||
**Дата:** 20.05.2026
|
||||
**Назначение:** настройки проекта (Project instructions) — Claude читает этот файл в каждом чате и следует правилам ниже.
|
||||
**Статус документа:** ✅ утверждён. Содержимое скопировано в поле "Project instructions" Claude.ai. Файл хранится в архиве как служебный документ.
|
||||
|
||||
**Что изменилось в v1.35 относительно v1.34:** A1 backend-tooling — §13.2 +абзац «Off-phase backend-tooling»: #64 Rector + rector-laravel (Composer dev-dep, авто-рефакторинг/version-upgrade, manual/CI — dry-run baseline 16 файлов, не блокирующий), #65 PHP Insights (Composer dev-dep, метрики complexity/architecture, on-demand/CI — не блокирующий), #66 laravel-backend-patterns (self-authored project-скил, backend-конвенции Лидерры), #67 NightOwl (self-hosted runtime-телеметрия — **DEFERRED**: native-Windows нет pcntl/posix, OSS без MCP, hosted 152-ФЗ). 16-я off-phase подкатегория, раздел A1. Не UI → вне R6.0/R6.1/R14. Границы — ADR-013. Архитектурных изменений §§1–12, §14–§16: 0. Связано: Tooling v2.19, PSR_v1 v3.19, CLAUDE.md v2.22; план `docs/superpowers/plans/2026-05-20-a1-backend-tooling.md`.
|
||||
|
||||
**Что изменилось в v1.34 относительно v1.33:** finance-tooling (C6+C7) — §13.2 +абзац «Off-phase finance-tooling»: #61 finance plugin (marketplace `finance@knowledge-work-plugins`, Anthropic Verified, homed C7, cross-ref C6; РФ-применимость частична — US-GAAP-скилы ⚠️, SOX-скилы not-applicable, warehouse-MCP DEFERRED), #62 billing-audit (self-authored project-скил, C6 — денежные инварианты биллинга), #63 ru-tax-accounting (self-authored project-скил, C7 — РСБУ/НК РФ). 15-я off-phase подкатегория. Не UI → вне R6.0/R6.1/R14. Границы — ADR-012. Архитектурных изменений §§1–12, §14–§16: 0. Связано: Tooling v2.18, PSR_v1 v3.18, CLAUDE.md v2.21; план `docs/superpowers/plans/2026-05-20-finance-tooling-c6-c7.md`.
|
||||
|
||||
**Что изменилось в v1.33 относительно v1.32:** observer factor-analysis phase 1.1 (ADR-011 amend): §16.2 — `decision_provenance.kind` расширен до 3 значений (`autonomous` | `user_directed_method` | `user_chose_from_options`); 3-й kind — collaborative-choice case (заказчик выбирает один из вариантов, предложенных Claude в предыдущем ходе — например `1`, `в делаем`, `делай 2`). §16.7 +абзац: routing-gate НЕ блокирует `user_chose_from_options` (выбор из choice-space, сформулированного самим Claude — не навязанный извне метод). Детектор — `tools/observer-choice-detector.mjs` (детерминированный, тег не требуется). Spec §11 `docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md` v1.1, plan `docs/superpowers/plans/2026-05-19-observer-factor-analysis-phase-1-1.md`. Связано: CLAUDE.md v2.20.
|
||||
@@ -762,6 +764,8 @@ Frontend Design и `obra/superpowers` (v5.1.0, 14 skills) — **парный sta
|
||||
|
||||
**Off-phase finance-tooling (C6+C7, v1.34, 20.05.2026):** Инструменты разделов C6 «Финансы — биллинг и тарификация» и C7 «Финансы — бухгалтерия и налоги» карты — #61 `finance` plugin (Tooling §4.36; marketplace `finance@knowledge-work-plugins`, Anthropic Verified, 8 скилов; homed C7, cross-ref C6; РФ-применимость: ✅ reconciliation/variance, ⚠️ US-GAAP-скилы частично, ❌ SOX-скилы not-applicable, warehouse-MCP DEFERRED), #62 `billing-audit` (Tooling §4.37; self-authored project-скил `.claude/skills/billing-audit/` — денежные инварианты биллинга C6: сохранение суммы bcmath, идемпотентность, tier-резолюция, дрейф reconcile, charge_source), #63 `ru-tax-accounting` (Tooling §4.38; self-authored project-скил `.claude/skills/ru-tax-accounting/` — РСБУ/НК РФ контекст C7: НДС/УСН, налоговая база, выгрузки бухгалтеру; закрывает РФ-gap US-плагина). Плюс reuse-классификация существующих узлов в C6/C7 через `NODE_SECTION_SECONDARY` (Boost/Pest/Larastan/Sentry/Redis/PM metrics-review/data-scientist/operations/process-*/context7) — без новых номеров. **Пятнадцатая** off-phase подкатегория. Off-phase, не UI → вне R6.0/R6.1/R14 PSR_v1. self-authored скилы billing-audit/ru-tax-accounting **линтуются** (не в ignorePaths, LINT1). Границы — ADR-012 (граница C6↔C7: начисление клиенту vs учёт/налоги компании; FIN1–FIN8). Регулируется PSR_v1 R10.1 Блок 1 (finance plugin) + note (2 self-authored скила). Установлено 20.05.2026 на ветке `worktree-finance-tooling-c6-c7`; план `docs/superpowers/plans/2026-05-20-finance-tooling-c6-c7.md`.
|
||||
|
||||
**Off-phase backend-tooling (A1, v1.35, 20.05.2026):** Инструменты раздела A1 карты «Программирование — backend» — #64 `Rector` + `rector-laravel` (Tooling §4.39; Composer dev-dependencies `rector/rector` + `driftingly/rector-laravel`, авто-рефакторинг/version-upgrade; конфиг `app/rector.php` deadCode+codeQuality conservative; постура manual/CI `composer rector`/`rector:fix` — dry-run baseline 16 файлов → **не** блокирующий lefthook, прецедент promptfoo ML1), #65 `PHP Insights` (Tooling §4.40; Composer dev-dependency `nunomaduro/phpinsights`; метрики complexity/architecture; конфиг `app/config/insights.php` — SyntaxCheck removed из-за Windows subprocess-краша, style-ось off — владелец Pint, BT4; постура on-demand/CI `composer insights` с порогами → **не** блокирующий, BT9), #66 `laravel-backend-patterns` (Tooling §4.41; self-authored project-скил `.claude/skills/laravel-backend-patterns/` — backend-конвенции Лидерры: слоистость/RLS-aware/bcmath-деньги/идемпотентность/partition-aware; **линтуется**, LINT1), #67 `NightOwl` (Tooling §4.42; `laravel/nightwatch` + self-hosted `lemed99/nightowl-agent` — коррелированный runtime-трейс; **DEFERRED**: native-Windows нет pcntl/posix, OSS без MCP, hosted 152-ФЗ; pending Б-1/Linux). Плюс reuse существующих узлов A1 (Boost #10, Pint #11, Larastan #12). **Шестнадцатая** off-phase подкатегория. Off-phase, не UI → вне R6.0/R6.1/R14 PSR_v1. Rector/PHP Insights **не гейтят коммит** (manual/CI — избегаем дубля с Pint/Larastan/deptrac + авто-мутации кода). Границы — ADR-013 (BT1–BT9). Регулируется PSR_v1 R10.1 Блок 1 note. Установлено 20.05.2026 на ветке `worktree-a1-backend-tooling`; план `docs/superpowers/plans/2026-05-20-a1-backend-tooling.md`.
|
||||
|
||||
### 13.3. Скоуп
|
||||
|
||||
| Тип задачи | Кто отвечает |
|
||||
|
||||
+84
-4
File diff suppressed because one or more lines are too long
@@ -0,0 +1,57 @@
|
||||
# ADR-013: A1 backend-tooling — наполнение раздела карты A1
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-05-20
|
||||
**Контекст:** эпик A1 backend-tooling, spec `docs/superpowers/specs/2026-05-20-a1-backend-tooling-design.md`.
|
||||
|
||||
## Context
|
||||
|
||||
Раздел карты A1 «Программирование — backend» был тонким — 3 узла: Boost #10
|
||||
(Laravel-контекст), Pint #11 (стиль), Larastan #12 (типы). Backend-смежное уехало
|
||||
в другие разделы (Pest→A5, squawk/pg_partman→A9, deptrac→A6, openapi→A3, Sentry/Redis→A7).
|
||||
Дефициты чистого A1: авто-рефакторинг, метрики сложности/архитектуры, кодифицированные
|
||||
backend-конвенции Лидерры, коррелированная runtime-телеметрия. На Anthropic-marketplace
|
||||
чистого backend-кодинга нет (knowledge-work + meta); A1 закрывается GitHub PHP-экосистемой
|
||||
плюс одним self-authored скилом.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **Rector (#64)** — `rector/rector` + `driftingly/rector-laravel` (Composer dev-dep).
|
||||
Авто-рефакторинг + version-aware апгрейды. Конфиг `app/rector.php` — консервативный
|
||||
старт (`deadCode` + `codeQuality`, БЕЗ type-declaration наборов и LaravelSetProvider).
|
||||
- **Постура: manual/CI** (`composer rector` / `composer rector:fix`), **НЕ** блокирующий
|
||||
lefthook. Spike dry-run = **16 файлов** (>5 порога → код-мутирующий инструмент не гейтит
|
||||
коммит; прецедент promptfoo ML1). LaravelSetProvider — для разовых апгрейдов вручную.
|
||||
2. **PHP Insights (#65)** — `nunomaduro/phpinsights` (Composer dev-dep). Метрики
|
||||
complexity / architecture / maintainability. Конфиг `app/phpinsights.php`.
|
||||
- **Постура: on-demand/CI** (`composer insights` с порогами `--min-*`), **НЕ** блокирующий
|
||||
lefthook (BT9 — избегаем четверного гейта Pint/Larastan/deptrac/Rector). Style-ось
|
||||
выключена (владелец стиля — Pint); акцент Complexity + Architecture.
|
||||
3. **laravel-backend-patterns (#66)** — self-authored project-скил (`.claude/skills/`).
|
||||
Кодификация backend-конвенций Лидерры (слоистость / RLS-aware / bcmath-деньги /
|
||||
идемпотентность / partition-aware). Активен.
|
||||
4. **NightOwl (#67)** — self-hosted runtime-телеметрия. **DEFERRED** (pending Б-1 / Linux).
|
||||
Блокер: native-Windows без `pcntl`/`posix` (агент не запускается); OSS-версия без MCP
|
||||
(MCP только managed); hosted = риск 152-ФЗ. Spike + условия активации:
|
||||
`docs/backend/nightowl-spike.md`. Прецедент: Sentry #34 / Figma #44 / Jupyter #50.
|
||||
|
||||
## Boundaries (конфликт-аудит)
|
||||
|
||||
- **BT1** Rector ↔ Pint: трансформация vs форматирование — разные операции.
|
||||
- **BT2** Rector ↔ Larastan: Rector чинит, Larastan находит — комплементарны.
|
||||
- **BT3** Rector ↔ deptrac: трансформация кода vs граф слоёв — ортогональны.
|
||||
- **BT4** PHP Insights ↔ Pint/Larastan: style/code оси выключены; уникум = complexity + architecture.
|
||||
- **BT5** backend-patterns ↔ architecture-patterns #38: project-specific vs generic.
|
||||
- **BT6** backend-patterns ↔ billing-audit #62: генерация (как писать) vs аудит (проверка денег) — ссылка.
|
||||
- **BT7** NightOwl ↔ Sentry #34: коррелированный трейс vs ошибки/трейсбэки.
|
||||
- **BT8** NightOwl ↔ Pail / Boost: сквозной трейс vs tail / снапшот по требованию.
|
||||
- **BT9** PHP Insights blocking? — нет (избегаем четверного гейта); on-demand/CI.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A1 непуст: 3 → 6 узлов активных (Boost/Pint/Larastan + Rector/PHP Insights/backend-patterns) + 1 DEFERRED (NightOwl).
|
||||
- Новая off-phase подкатегория `backend-tooling` (16-я).
|
||||
- Rector и PHP Insights **не гейтят коммит** (manual/CI) — осознанно, чтобы не дублировать
|
||||
существующие блокирующие гейты (Pint/Larastan/deptrac) и не авто-мутировать код на коммите.
|
||||
- Rector оставляет разовый задел чистки (16 файлов) — применяется вручную через `composer rector:fix` с ревью + полным прогоном тестов, не в этом эпике.
|
||||
- NightOwl — capability-readiness: задокументирован, активация при появлении Linux/боевого сервера (Б-1).
|
||||
@@ -21,11 +21,11 @@ function pos(ring, angleDeg) {
|
||||
|
||||
const NODES = [
|
||||
// ── ПРАВИЛА (5) ── центр + первое кольцо ───────
|
||||
{ id: 'pravila', label: 'Pravila v1.34', group: 'rules', size: 38, ring: 0, ...pos(0, 0) },
|
||||
{ id: 'claude_md', label: 'CLAUDE.md v2.21', group: 'rules', size: 34, ring: 1, ...pos(1, 30) },
|
||||
{ id: 'psr_v1', label: 'PSR_v1 v3.18', group: 'rules', size: 32, ring: 1, ...pos(1, 150) },
|
||||
{ id: 'tooling', label: 'Tooling v2.18', group: 'rules', size: 30, ring: 1, ...pos(1, 270) },
|
||||
{ id: 'router_procedure', label: 'router-procedure v1.0', group: 'rules', size: 24, ring: 1, ...pos(1, 210) },
|
||||
{ id: 'pravila', label: 'Pravila v1.35', group: 'rules', size: 38, ring: 0, ...pos(0, 0) },
|
||||
{ id: 'claude_md', label: 'CLAUDE.md v2.22', group: 'rules', size: 34, ring: 1, ...pos(1, 30) },
|
||||
{ id: 'psr_v1', label: 'PSR_v1 v3.19', group: 'rules', size: 32, ring: 1, ...pos(1, 150) },
|
||||
{ id: 'tooling', label: 'Tooling v2.19', group: 'rules', size: 30, ring: 1, ...pos(1, 270) },
|
||||
{ id: 'router_procedure', label: 'router-procedure v1.2', group: 'rules', size: 24, ring: 1, ...pos(1, 210) },
|
||||
|
||||
// ── ПЛАГИНЫ (13) ── второе кольцо ──────────────
|
||||
{ id: 'superpowers', label: 'Superpowers v5.1', group: 'plugins', size: 30, ring: 2, ...pos(2, 45) },
|
||||
@@ -90,6 +90,11 @@ const NODES = [
|
||||
{ id: 'finance_plugin', label: 'finance\n(plugin)', group: 'plugins', size: 20, ring: 2, ...pos(2, 200) },
|
||||
{ id: 'billing_audit', label: 'billing-audit\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 397) },
|
||||
{ id: 'ru_tax', label: 'ru-tax-accounting\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 407) },
|
||||
// A1 backend-tooling (20.05.2026) — раздел «Программирование — backend»
|
||||
{ id: 'rector', label: 'Rector\n(dev-dep)', group: 'plugins', size: 18, ring: 2, ...pos(2, 210) },
|
||||
{ id: 'php_insights', label: 'PHP Insights\n(dev-dep)', group: 'plugins', size: 18, ring: 2, ...pos(2, 220) },
|
||||
{ id: 'backend_patterns', label: 'backend-patterns\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 417) },
|
||||
{ id: 'nightowl', label: 'NightOwl\n(DEFERRED)', group: 'mcp', size: 16, ring: 3, ...pos(3, 427) },
|
||||
// brain governance iter9 (19.05.2026) — проектный скил факторного анализа
|
||||
{ id: 'sk_brain_retro', label: '/brain-retro\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 210) },
|
||||
|
||||
@@ -405,6 +410,17 @@ const EDGES = [
|
||||
E('mcp_boost', 'billing_audit', 'модели биллинга'),
|
||||
E('finance_plugin', 'ru_tax', 'РФ-специфика поверх\nUS-механики (ADR-012)'),
|
||||
E('billing_audit', 'ru_tax', 'выручка C6 →\nналог.база C7'),
|
||||
// ── A1 BACKEND-TOOLING (20.05.2026, ADR-013) — связи 4 узлов ──
|
||||
E('tooling', 'rector', '§4.39 #64 — реестр'),
|
||||
E('tooling', 'php_insights', '§4.40 #65 — реестр'),
|
||||
E('tooling', 'backend_patterns', '§4.41 #66 — реестр'),
|
||||
E('tooling', 'nightowl', '§4.42 #67 — реестр'),
|
||||
E('rector', 'php_insights', 'backend-quality\nchain L14'),
|
||||
E('php_insights', 'lh_larastan', 'L14: метрики →\nтипы'),
|
||||
E('rector', 'lh_pint', 'трансформация ↔\nстиль (BT1)'),
|
||||
E('backend_patterns', 'billing_audit', '«как писать» ↔\n«аудит денег» (BT6)'),
|
||||
E('mcp_boost', 'backend_patterns', 'Eloquent-контекст'),
|
||||
E('nightowl', 'mcp_sentry', 'трейс ↔ ошибки\n(BT7, ADR-013)'),
|
||||
|
||||
// ══════════════════════════════════════════════════
|
||||
// КОНФЛИКТЫ — 3-color classification (iter2 §4)
|
||||
@@ -573,6 +589,8 @@ const NODE_SECTION = {
|
||||
lh_l1watcher: 'E1', lh_crossref: 'E1', lh_obs_obs: 'E2', lh_status_md: 'E2', lh_obs_cov: 'E2',
|
||||
// finance-tooling C6+C7 (20.05.2026) — разделы «Финансы»
|
||||
finance_plugin: 'C7', billing_audit: 'C6', ru_tax: 'C7',
|
||||
// A1 backend-tooling (20.05.2026) — раздел «Программирование — backend»
|
||||
rector: 'A1', php_insights: 'A1', backend_patterns: 'A1', nightowl: 'A1',
|
||||
};
|
||||
// Вторичная классификация: узел первично в NODE_SECTION, дополнительно — в этих
|
||||
// разделах (кросс-реф). Введено A3-интеграцией 17.05.2026 — раздел A3 наполняется
|
||||
|
||||
@@ -580,6 +580,40 @@ const NODE_DETAILS = {
|
||||
[{ name: 'billing-audit', cond: 'выручка C6 → налог.база C7' }, { name: 'finance plugin', cond: 'US-механика' }]
|
||||
),
|
||||
|
||||
// ── A1 BACKEND-TOOLING (20.05.2026, ADR-013) ──
|
||||
rector: nd(
|
||||
'Composer dev-dep (Rector + rector-laravel): авто-рефакторинг и version-upgrade PHP-кода — dead-code, code-quality наборы, апгрейды под версию Laravel.',
|
||||
'При «обнови/почини/рефактори backend-код», апгрейде Laravel-версии, удалении мёртвого кода. Запуск manual/CI (composer rector / rector:fix).',
|
||||
'Composer dev-dep, app/rector.php (deadCode+codeQuality conservative). manual/CI — НЕ блокирующий lefthook (dry-run baseline 16 файлов). Не UI → вне R6/R14. Tooling §4.39 #64, CLAUDE.md §3.3 #64, ADR-013.',
|
||||
[{ name: 'Tooling', cond: '§4.39 #64 — реестр' }],
|
||||
[{ name: 'BT1', cond: '↔ Pint трансформация vs стиль' }, { name: 'BT2', cond: '↔ Larastan чинит vs находит' }, { name: 'BT3', cond: '↔ deptrac vs граф слоёв' }],
|
||||
[{ name: 'PHP Insights', cond: 'backend-quality chain L14' }, { name: 'Larastan', cond: 'L14 типы' }]
|
||||
),
|
||||
php_insights: nd(
|
||||
'Composer dev-dep: метрики качества кода — complexity / architecture / maintainability (cyclomatic, code smells, распределение архитектуры).',
|
||||
'При «оцени качество/сложность кода», «где код запутан», в портальном аудите. on-demand/CI (composer insights).',
|
||||
'Composer dev-dep, app/config/insights.php (SyntaxCheck removed — Windows-краш, style-ось off — владелец Pint). on-demand/CI — НЕ блокирующий (BT9). Не UI → вне R6/R14. Tooling §4.40 #65, CLAUDE.md §3.3 #65, ADR-013.',
|
||||
[{ name: 'Tooling', cond: '§4.40 #65 — реестр' }],
|
||||
[{ name: 'BT4', cond: 'style/code оси off — уникум complexity+architecture' }, { name: 'BT9', cond: 'не блокирующий — без четверного гейта' }],
|
||||
[{ name: 'Rector', cond: 'backend-quality chain L14' }, { name: 'Larastan', cond: 'L14 типы' }]
|
||||
),
|
||||
backend_patterns: nd(
|
||||
'Self-authored скил: backend-конвенции Лидерры — слоистость controller→service→job, RLS-aware Eloquent, деньги bcmath/LedgerService, идемпотентные джобы, partition-aware запросы.',
|
||||
'При «как писать backend в Лидерре», «паттерн контроллера/сервиса/джоба», scaffolding новой backend-фичи.',
|
||||
'Свой project-скил .claude/skills/laravel-backend-patterns/ (линтуется, LINT1). Не UI → вне R6/R14. Tooling §4.41 #66, CLAUDE.md §3.3 #66, ADR-013.',
|
||||
[{ name: 'Tooling', cond: '§4.41 #66 — реестр' }],
|
||||
[{ name: 'BT5', cond: '≠ architecture-patterns #38 (generic)' }, { name: 'BT6', cond: '≠ billing-audit #62 (аудит)' }],
|
||||
[{ name: 'billing-audit', cond: '«как писать» ↔ «аудит денег»' }, { name: 'Boost', cond: 'Eloquent-контекст' }]
|
||||
),
|
||||
nightowl: nd(
|
||||
'Self-hosted runtime-телеметрия (laravel/nightwatch + nightowl-agent): коррелированный трейс request↔job↔query↔cache в свой PostgreSQL. DEFERRED.',
|
||||
'DEFERRED — при появлении Linux/боевого сервера (Б-1). Сейчас не маршрутизировать (нет pcntl/posix на Windows, OSS без MCP, hosted = 152-ФЗ).',
|
||||
'DEFERRED pending-слот (как Sentry #34 / Figma #44 / Jupyter #50). Spike docs/backend/nightowl-spike.md. Не UI → вне R6/R14. Tooling §4.42 #67, CLAUDE.md §3.3 #67, ADR-013.',
|
||||
[{ name: 'Tooling', cond: '§4.42 #67 — реестр' }],
|
||||
[{ name: 'BT7', cond: '↔ Sentry трейс vs ошибки' }, { name: 'BT8', cond: '↔ Pail/Boost трейс vs tail/снапшот' }],
|
||||
[{ name: 'Sentry', cond: 'трейс ↔ ошибки (ADR-013)' }]
|
||||
),
|
||||
|
||||
// ── СКИЛЫ SUPERPOWERS ────────────────────────────
|
||||
sk_brainstorm: nd(
|
||||
'Продумывает задачу вместе с заказчиком, формулирует варианты A/B/C и согласует дизайн до написания кода.',
|
||||
@@ -1807,6 +1841,12 @@ const NODE_META = {
|
||||
billing_audit: { since: '20.05.2026', changed: '—', uses: 0, usesSrc: 'новый узел' },
|
||||
ru_tax: { since: '20.05.2026', changed: '—', uses: 0, usesSrc: 'новый узел' },
|
||||
|
||||
// ── A1 BACKEND-TOOLING (20.05.2026, ADR-013) ──
|
||||
rector: { since: '20.05.2026', changed: '—', uses: 0, usesSrc: 'новый узел' },
|
||||
php_insights: { since: '20.05.2026', changed: '—', uses: 0, usesSrc: 'новый узел' },
|
||||
backend_patterns: { since: '20.05.2026', changed: '—', uses: 0, usesSrc: 'новый узел' },
|
||||
nightowl: { since: '20.05.2026', changed: '—', uses: 0, usesSrc: 'новый узел (DEFERRED)' },
|
||||
|
||||
// ── BRAIN GOVERNANCE iter9 (19.05.2026, ADR-011) ──
|
||||
// uses: observer_stophook=31 эпизодов; lh_obs_obs/status_md/obs_cov=112 коммитов с 19.05
|
||||
// (glob-less, каждый коммит); lh_l1watcher=10, lh_crossref=13 (коммиты по glob с 19.05);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# NightOwl (#67) — feasibility spike + decision
|
||||
|
||||
**Дата:** 2026-05-20
|
||||
**Узел:** #67 NightOwl (раздел A1, backend-tooling) — self-hosted runtime-телеметрия.
|
||||
**Вывод:** **DEFERRED** (pending-слот, активация на Linux/боевом сервере при Б-1). Решение заказчика 20.05.2026.
|
||||
|
||||
## Что выяснил spike
|
||||
|
||||
Источник: `github.com/lemed99/nightowl-agent` (MIT) + `laravel/nightwatch` (сбор телеметрии).
|
||||
|
||||
| Вопрос | Ответ |
|
||||
|---|---|
|
||||
| Self-hosted ingest в свой PostgreSQL без managed-аккаунта? | **Да** — open-source агент пишет телеметрию напрямую в PG (≈13 400 payloads/s). |
|
||||
| MCP-сервер для AI (Claude Code) в open-source? | **Нет.** MCP — только в managed-сервисе usenightowl.com (платный). OSS-агент даёт сырые таблицы телеметрии, но не MCP. |
|
||||
| Запускается на native-Windows? | **Нет.** Требует PHP-расширения `pcntl` + `posix` (UNIX-only, на native-Windows отсутствуют). Windows-поддержка в доках не упомянута. |
|
||||
|
||||
## Блокер
|
||||
|
||||
Та же причина, что блокировала Docker / pg_partman / Jupyter MCP на этой машине — **native-Windows стек без UNIX-окружения**. Агент NightOwl не запустится локально (`pcntl`/`posix`), а готовый MCP-доступ есть только в облачном платном тире.
|
||||
|
||||
Облачная альтернатива (hosted Nightwatch free-tier + официальный Nightwatch MCP) технически работает на Windows, но **отправляет телеметрию портала во внешнее облако** — риск по 152-ФЗ (персональные данные). Отклонено заказчиком (как и hosted Sentry в своё время).
|
||||
|
||||
## Решение (заказчик, 20.05.2026)
|
||||
|
||||
**Отложить до боевого сервера.** Узел #67 регистрируется в реестре как **DEFERRED / pending-слот** (прецеденты: Sentry MCP #34 pending Б-1, Figma MCP #44, Jupyter MCP #50). Ничего не устанавливаем, `.mcp.json` не трогаем, данные никуда не уходят.
|
||||
|
||||
## Условия активации (когда снимать DEFERRED)
|
||||
|
||||
1. Появился Linux/боевой сервер (Б-1) с PHP `pcntl`/`posix`.
|
||||
2. Развёрнут open-source `nightowl-agent`, телеметрия пишется в PostgreSQL Лидерры.
|
||||
3. Доступ Claude к телеметрии: либо managed MCP (если приемлемо по 152-ФЗ — телеметрия без ПДн), либо чтение таблиц телеметрии через Boost `database-query` (READ-ONLY) — fallback без облака.
|
||||
|
||||
Граница с соседями (ADR-013): Sentry #34 = ошибки/трейсбэки; Pail = tail логов; Boost = снапшот логов/запросов; NightOwl = коррелированный сквозной трейс request↔job↔query↔cache.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Brain Status (auto-generated)
|
||||
|
||||
Last updated: 2026-05-20T07:09:26.195Z
|
||||
Last updated: 2026-05-21T01:18:52.154Z
|
||||
|
||||
| Контролёр | Состояние | Детали |
|
||||
|---|---|---|
|
||||
@@ -8,11 +8,13 @@ Last updated: 2026-05-20T07:09:26.195Z
|
||||
| C2 Cross-ref consistency | ✅ | [cross-ref-checker] OK — 0 drift in 4 files |
|
||||
| C3 Observer-of-observer | ✅ | [observer-of-observer] OK — last read 0 week(s) ago |
|
||||
| C4 Сигнальный статус | ✅ | This file (self-reference) |
|
||||
| C5 Observer-coverage | ✅ | 17 episode(s), 1021 recent commit(s) · Stop-hook + post-commit OK |
|
||||
| C5 Observer-coverage | ⚠️ | 16 episode(s) this month · .git/hooks/post-commit not installed (run: npx lefthook install --force) |
|
||||
|
||||
## Метрики (информационные, не алерты)
|
||||
|
||||
- Observer evidence: 17 episodes this month, 0 observer_error markers, 0 PII matches before filter
|
||||
- Observer evidence: 16 episodes this month, 0 observer_error markers, 3 PII matches before filter
|
||||
- Legacy v1 episodes (not in factor analysis): 5
|
||||
- Last /brain-retro: 2 day(s) ago
|
||||
- Использование узлов: см. `/brain-retro` (раз в спринт). **Неиспользованные узлы — не проблема** (capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store).
|
||||
|
||||
## Алерт-индикаторы
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Router procedure v1.0
|
||||
# Router procedure v1.2
|
||||
|
||||
**Status:** active (introduced 2026-05-19, spec dd5bded, ADR-011)
|
||||
**Status:** active (introduced 2026-05-19, spec dd5bded, ADR-011; backend-tooling 2026-05-20, ADR-013)
|
||||
|
||||
**Owner:** Claude Code automatic at session start.
|
||||
|
||||
@@ -71,3 +71,4 @@ Every turn — implicitly by Claude at session start, explicitly when routing is
|
||||
|
||||
- **v1.0 (2026-05-19)** — initial fixation. Replaces implicit-scattered routing. ADR-011.
|
||||
- **v1.1 (2026-05-20)** — finance-tooling узлы #61-#63 добавлены в реестр Tooling §4.36-§4.38 (читаются step 3) и routing-off-phase.md (+3 строки routing + связка L13). Структурных правок процедуры нет. ADR-012.
|
||||
- **v1.2 (2026-05-20)** — A1 backend-tooling узлы #64-#67 добавлены в реестр Tooling §4.39-§4.42 (читаются step 3) и routing-off-phase.md (+4 строки routing + связка L14). NightOwl #67 — DEFERRED (native-Windows без pcntl/posix). Структурных правок процедуры нет. ADR-013.
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
# Routing-аид: задача → off-phase узел
|
||||
|
||||
> **Назначение.** Quick-reference: триггер задачи → какой off-phase узел тулчейна
|
||||
> взять (Tooling §4.11–§4.38). Закрывает пробел SYSTEM-аудита 18.05.2026 (Rec3):
|
||||
> взять (Tooling §4.11–§4.42). Закрывает пробел SYSTEM-аудита 18.05.2026 (Rec3):
|
||||
> 30 off-phase инструментов регулировались плоским 3-блочным реестром PSR_v1 R10.1
|
||||
> без матрицы «задача → узел».
|
||||
>
|
||||
> **Scope.** Только off-phase (#31–#60 + ruflo, infrastructure). Активные фазовые
|
||||
> **Scope.** Только off-phase (#31–#67 + ruflo, infrastructure). Активные фазовые
|
||||
> инструменты (#1–#29 + #30 Frontend Design) — карта CLAUDE.md §3.1/§3.2/§3.4.
|
||||
> Superpowers-skills и hard-rules — Pravila §12.2 (не дублируется здесь).
|
||||
>
|
||||
> **Источник истины.** Tooling §4.X (детальное описание каждого узла), Pravila §13.2
|
||||
> (категоризация off-phase), PSR_v1 R10.1 (3-блочный реестр ролей).
|
||||
>
|
||||
> **Версия.** 1.2 (20.05.2026 — finance-tooling: +3 строки routing #61-#63 + связка L13 + scope §4.11→§4.38, ADR-012. v1.1 18.05.2026 вечер — аудит дисциплины R15: +строка «диагностика
|
||||
> **Версия.** 1.3 (20.05.2026 — A1 backend-tooling: +4 строки routing #64-#67 + связка L14 + scope §4.11→§4.42, ADR-013. v1.2 — finance-tooling: +3 строки routing #61-#63 + связка L13 + scope, ADR-012. v1.1 18.05.2026 вечер — аудит дисциплины R15: +строка «диагностика
|
||||
> конверсии» → process-analysis #53 (M3); +note про UI-пул #31/#32 как делегирующие
|
||||
> строки, не R15-routed (M1). v1.0 — Rec3 SYSTEM-аудита). Триггеры — формулировки
|
||||
> заказчика или явные ключевые слова в промпте.
|
||||
@@ -58,6 +58,10 @@
|
||||
| Аудит денежной корректности биллинга (списание/тариф/баланс/дрейф/charge_source) | **billing-audit** (project-скил) | #62 | finance-tooling | C6; ≠ process-*/D3/ru-tax (ADR-012) |
|
||||
| РСБУ/НК РФ контекст: НДС/УСН, налоговая база, выгрузка бухгалтеру | **ru-tax-accounting** (project-скил) | #63 | finance-tooling | C7; ≠ finance plugin/D1/D2 (ADR-012) |
|
||||
| Сверка счетов / variance-анализ / US-GAAP-отчётность / проводки | **finance plugin** | #61 | finance-tooling | C7; SOX not-applicable, warehouse-MCP DEFERRED (ADR-012) |
|
||||
| Авто-рефакторинг / version-upgrade / удаление мёртвого PHP-кода | **Rector** + rector-laravel | #64 | backend-tooling | manual/CI (`composer rector`/`rector:fix`), не блокирующий — baseline 16 файлов (ADR-013) |
|
||||
| Метрики качества / сложности / архитектуры PHP-кода | **PHP Insights** | #65 | backend-tooling | on-demand/CI (`composer insights`), не блокирующий (BT9, ADR-013) |
|
||||
| Как писать backend в Лидерре (контроллер/сервис/джоб, RLS, деньги, идемпотентность, партиции) | **laravel-backend-patterns** (project-скил) | #66 | backend-tooling | trigger-based; ≠ #38 generic / ≠ #62 audit (ADR-013) |
|
||||
| Коррелированный runtime-трейс request↔job↔query (self-hosted) | **NightOwl** | #67 | backend-tooling | **DEFERRED** — нет pcntl/posix на Windows; pending Б-1 (ADR-013) |
|
||||
| Отладка production runtime errors через self-hosted Sentry | **Sentry MCP** | #34 | debug-runtime | READ-ONLY, pending Б-1 deployment |
|
||||
| Отладка Redis/Memurai очередей / кэша / Pest-квирков 73/77 | **Redis MCP** | #35 | debug-runtime | READ-ONLY обязательно |
|
||||
| Правки `CLAUDE.md` | **claude-md-management** | #33 | infrastructure | §5 п.10 — единственный канал |
|
||||
@@ -94,6 +98,7 @@
|
||||
| L11 | `skill-creator` (#56) + `hookify` (#58) + `plugin-dev` (#57) | Расширение Claude-инфраструктуры: ≥3 повторений workflow → новый скил / ошибка повторяется → новый хук (HK1 pre-check) / задача требует плагина → plugin-dev. |
|
||||
| L12 | `claude-md-management` (#33) + `revise-claude-md` skill | Захват session-learnings → CLAUDE.md update. Единственный канал §5 п.10. |
|
||||
| L13 | `billing-audit` (#62) + `Pest` (#18) + `Boost` (#10) + `Sentry`/`Redis` (#34/#35) → `ru-tax-accounting` (#63) | Финансовая цепочка: аудит денежных инвариантов кода (billing-audit) тестами (Pest) на моделях (Boost) с runtime-фактами (Sentry/Redis) → перевод выверенной выручки в учётно-налоговый контекст (ru-tax). C6→C7. Граница — ADR-012. |
|
||||
| L14 | `Rector` (#64) → `PHP Insights` (#65) → `Larastan` (#12) → `deptrac` (#43) | backend-quality chain: авто-трансформация кода (Rector) → метрики сложности/архитектуры (PHP Insights) → типовой статанализ (Larastan) → fitness направления слоёв (deptrac). Все на одном PHP-коде, разные оси. Anti-pattern: Rector-автоправка и PHP Insights-метрика — разные фазы, не один блокирующий шаг (ADR-013). |
|
||||
|
||||
**Anti-pattern связок** (не комбинировать в одной задаче):
|
||||
|
||||
@@ -108,9 +113,9 @@
|
||||
|
||||
1. **Не подменяй фазовые инструменты off-phase.** Если задача попадает под фазу
|
||||
0/1/2/3 (Tooling §2–§5) — берём фазовый узел. Off-phase — резерв и специализация.
|
||||
2. **DEFERRED-узлы (#44 Figma / #50 Jupyter / #54 n8n)** — не использовать без явного
|
||||
возобновления (нет аккаунта / нет Python ML / нет n8n в стеке). Запрос «через Figma»
|
||||
при текущем состоянии = эскалация заказчику.
|
||||
2. **DEFERRED-узлы (#44 Figma / #50 Jupyter / #54 n8n / #67 NightOwl)** — не использовать без явного
|
||||
возобновления (нет аккаунта / нет Python ML / нет n8n в стеке / нет Linux с pcntl+posix).
|
||||
Запрос «через Figma» или «через NightOwl» при текущем состоянии = эскалация заказчику.
|
||||
3. **Изолированные узлы (ruflo на 18.05.2026)** — не маршрутизировать. Запрос
|
||||
с `queen`/`королева` сейчас выполняется напрямую (§14 dormant). При запросе
|
||||
реактивации — план в memory `feedback_ruflo_isolated.md`.
|
||||
|
||||
@@ -0,0 +1,644 @@
|
||||
# A1 backend-tooling Implementation Plan
|
||||
|
||||
> **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. Project wrapper: `.claude/skills/subagent-driven-development/` (git-safety per Pravila §15.1).
|
||||
|
||||
**Goal:** Наполнить раздел A1 «Программирование — backend» четырьмя инструментами (Rector #64, PHP Insights #65, laravel-backend-patterns скил #66, NightOwl #67) с полным footprint роутера и наблюдателя.
|
||||
|
||||
**Architecture:** Off-phase tooling integration в изолированном worktree (паттерн A11/C10/finance). Rector/PHP Insights — Composer dev-deps + конфиги; backend-patterns — self-authored project-скил; NightOwl — self-hosted телеметрия в PG. Нормативка bump-ится атомарным набором (cross-ref-checker C2 STRICT). Spike-задачи определяют 2 gate-решения до сборки.
|
||||
|
||||
**Tech Stack:** PHP 8.3 / Laravel 13 / Composer / lefthook / PostgreSQL 16 / Markdown-нормативка / vis.js карта.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-20-a1-backend-tooling-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Pre-flight (исполнитель — перед Task 1)
|
||||
|
||||
- Worktree от свежего `origin/main` через `superpowers:using-git-worktrees`. После создания скопировать gitignored-файлы (учёт Sprint 4): `app/.env`, `app/storage/`, `app/vendor/`, `app/node_modules/`, `bin/*.exe`, `лендинг/` — иначе composer/тесты/lefthook не запустятся.
|
||||
- `git fetch && git log HEAD..origin/main --oneline` — pre-flight sync 8 нормативных файлов (Pravila §15.2).
|
||||
- Закоммитить уже написанные spec (`docs/superpowers/specs/2026-05-20-a1-backend-tooling-design.md`) + этот план первым коммитом.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| Файл | Ответственность | Задача |
|
||||
|---|---|---|
|
||||
| `app/composer.json` | dev-deps (rector, rector-laravel, phpinsights, nightwatch) + scripts | 1,2,8 |
|
||||
| `app/rector.php` | Rector ruleset (LaravelSetProvider + dead-code/code-quality) | 4 |
|
||||
| `app/phpinsights.php` | PHP Insights config (complexity+architecture оси) | 5 |
|
||||
| `lefthook.yml` | (условно) job 16 Rector dry-run | 4 |
|
||||
| `.claude/skills/laravel-backend-patterns/SKILL.md` + `references/` | свод backend-конвенций Лидерры | 6 |
|
||||
| `.claude/skills/laravel-backend-patterns/evals/evals.json` | trigger-евалы скила | 7 |
|
||||
| `.mcp.json` | (условно) NightOwl MCP READ-ONLY | 8 |
|
||||
| `tools/.l1-watcher-aliases.txt` | (условно) alias NightOwl MCP-имени | 8 |
|
||||
| `docs/adr/ADR-013-backend-tooling.md` | границы узлов + BT1–BT9 | 9 |
|
||||
| `docs/routing-off-phase.md` | +4 строки routing + связка L14 | 10 |
|
||||
| `docs/router-procedure.md` | bump cross-ref | 10 |
|
||||
| `docs/Tooling_v8_3.md` | §4.39–4.42 (9-атрибутные блоки) + §0 счётчик + header | 11 |
|
||||
| `docs/Plugin_stack_rules_v1.md` | R10.1 +4 строки + header | 11 |
|
||||
| `docs/Pravila_raboty_Claude_v1_1.md` | §13.2 +абзац + header | 11 |
|
||||
| `CLAUDE.md` | §3.3 +#64–67, §6 +абзац, §9 +запись, header | 11 |
|
||||
| `docs/automation-graph-data.js` | +4 узла NODE_SECTION + рёбра + версии-метки | 12 |
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Spikes (определяют gate-решения)
|
||||
|
||||
### Task 1: Rector — установка + dry-run count spike
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/composer.json` (require-dev + scripts)
|
||||
- Create: (нет в этой задаче — конфиг в Task 4)
|
||||
|
||||
- [ ] **Step 1: Установить Rector + rector-laravel**
|
||||
|
||||
Run (root `app/`):
|
||||
|
||||
```bash
|
||||
composer require rector/rector driftingly/rector-laravel --dev
|
||||
```
|
||||
|
||||
Expected: добавлены `rector/rector ^2.4`, `driftingly/rector-laravel` в `require-dev`; composer.lock обновлён.
|
||||
|
||||
- [ ] **Step 2: Временный минимальный конфиг для замера**
|
||||
|
||||
Create `app/rector.php` (временный, финальный — Task 4):
|
||||
|
||||
```php
|
||||
<?php
|
||||
use Rector\Config\RectorConfig;
|
||||
use RectorLaravel\Set\LaravelSetProvider;
|
||||
return RectorConfig::configure()
|
||||
->withPaths([__DIR__ . '/app'])
|
||||
->withSetProviders(LaravelSetProvider::class)
|
||||
->withPreparedSets(deadCode: true, codeQuality: true);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Прогнать dry-run и посчитать**
|
||||
|
||||
Run (root `app/`):
|
||||
|
||||
```bash
|
||||
php vendor/bin/rector process --dry-run
|
||||
```
|
||||
|
||||
Expected: вывод со списком предлагаемых изменений + итоговая строка `N files would have been changed`. **Записать N.**
|
||||
|
||||
- [ ] **Step 4: Зафиксировать решение по gate-постуре**
|
||||
|
||||
Записать в коммит-сообщение и в Task 4 решение:
|
||||
|
||||
- N ≤ ~5 → блокирующий lefthook job 16 (паттерн deptrac).
|
||||
- N > ~5 → composer script + CI; lefthook-гейт отложить, в ADR-013 пометить.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/composer.json app/composer.lock app/rector.php
|
||||
git commit -m "feat(backend): install Rector + rector-laravel; dry-run baseline N=<N>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: PHP Insights — установка + baseline run
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/composer.json` (require-dev)
|
||||
|
||||
- [ ] **Step 1: Установить PHP Insights**
|
||||
|
||||
Run (root `app/`):
|
||||
|
||||
```bash
|
||||
composer require nunomaduro/phpinsights --dev
|
||||
```
|
||||
|
||||
Expected: `nunomaduro/phpinsights` в `require-dev`.
|
||||
|
||||
- [ ] **Step 2: Сгенерировать дефолтный конфиг**
|
||||
|
||||
Run (root `app/`):
|
||||
|
||||
```bash
|
||||
php artisan vendor:publish --provider="NunoMaduro\PhpInsights\Application\Adapters\Laravel\InsightsServiceProvider"
|
||||
```
|
||||
|
||||
Expected: создан `config/insights.php` (переедет в `app/phpinsights.php` в Task 5 — пока для baseline).
|
||||
|
||||
- [ ] **Step 3: Прогнать baseline-анализ**
|
||||
|
||||
Run (root `app/`):
|
||||
|
||||
```bash
|
||||
php artisan insights --no-interaction --summary
|
||||
```
|
||||
|
||||
Expected: 4 балла (Code / Complexity / Architecture / Style). **Записать значения.**
|
||||
|
||||
- [ ] **Step 4: Зафиксировать пороги**
|
||||
|
||||
По baseline выбрать `--min-quality` / `--min-complexity` / `--min-architecture` чуть ниже текущих (anti-regression), записать для Task 5.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/composer.json app/composer.lock app/config/insights.php
|
||||
git commit -m "feat(backend): install PHP Insights; baseline scores recorded"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: NightOwl — feasibility spike
|
||||
|
||||
**Files:** (нет правок — разведка)
|
||||
|
||||
- [ ] **Step 1: Проверить доступность пакетов и MCP**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm view @lemed99/nightowl-agent 2>$null; composer show laravel/nightwatch --available 2>$null
|
||||
```
|
||||
|
||||
Прочитать README `github.com/lemed99/nightowl-agent` (WebFetch): (a) поддерживается ли self-hosted ingest в PostgreSQL без managed-аккаунта; (b) есть ли MCP-сервер в open-source варианте или только в managed (usenightowl.com).
|
||||
|
||||
- [ ] **Step 2: Зафиксировать access-path**
|
||||
|
||||
Записать решение для Task 8:
|
||||
|
||||
- MCP в open-source есть → регистрируем MCP-сервер в `.mcp.json` (READ-ONLY) + alias в l1-watcher.
|
||||
- MCP только managed → fallback: телеметрия пишется в PG, Claude читает через Boost `database-query`; `.mcp.json` не трогаем.
|
||||
- Self-hosted ingest на native-Windows невозможен/тяжёл → эскалировать заказчику (узел остаётся, но access-path = только Boost-чтение PG-таблиц nightwatch).
|
||||
|
||||
- [ ] **Step 3: Commit (документ разведки)**
|
||||
|
||||
```bash
|
||||
git add docs/backend/nightowl-spike.md
|
||||
git commit -m "docs(backend): NightOwl feasibility spike — access-path decided"
|
||||
```
|
||||
|
||||
(Создать `docs/backend/nightowl-spike.md` с выводами Step 1–2.)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Rector финализация (#64)
|
||||
|
||||
### Task 4: Rector финальный конфиг + scripts + (условно) lefthook
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/rector.php`
|
||||
- Modify: `app/composer.json` (scripts)
|
||||
- Modify: `lefthook.yml` (условно — только если Task 1 N ≤ ~5)
|
||||
|
||||
- [ ] **Step 1: Финализировать `app/rector.php`**
|
||||
|
||||
Заменить временный конфиг (оставить conservative ruleset — без `typeDeclarations: true` на первом заходе):
|
||||
|
||||
```php
|
||||
<?php
|
||||
use Rector\Config\RectorConfig;
|
||||
use RectorLaravel\Set\LaravelSetProvider;
|
||||
return RectorConfig::configure()
|
||||
->withPaths([__DIR__ . '/app', __DIR__ . '/database', __DIR__ . '/routes'])
|
||||
->withSetProviders(LaravelSetProvider::class)
|
||||
->withPreparedSets(deadCode: true, codeQuality: true)
|
||||
->withSkip([
|
||||
__DIR__ . '/app/Console/Kernel.php', // bootstrap — не трогать
|
||||
]);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Добавить composer scripts**
|
||||
|
||||
В `app/composer.json` `scripts`:
|
||||
|
||||
```json
|
||||
"rector": "@php vendor/bin/rector process --dry-run",
|
||||
"rector:fix": "@php vendor/bin/rector process"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Проверить, что dry-run чист после финального конфига**
|
||||
|
||||
Run (root `app/`):
|
||||
|
||||
```bash
|
||||
composer rector
|
||||
```
|
||||
|
||||
Expected: `0 files would have been changed` ИЛИ зафиксированный baseline-список (если N>0 и решено не блокировать).
|
||||
|
||||
- [ ] **Step 4 (условно, N ≤ ~5): Добавить lefthook job 16 + доказать red-green**
|
||||
|
||||
В `lefthook.yml` `pre-commit.jobs` после job 15:
|
||||
|
||||
```yaml
|
||||
# 16. Rector — авто-рефакторинг / version-aware апгрейды (Прил. Н #64,
|
||||
# backend-tooling). dry-run на staged app/**/*.php — падает, если есть
|
||||
# неприменённые Rector-правки. Чистый PHP, без LLM (BT1-BT3).
|
||||
- name: rector
|
||||
glob: "app/**/*.php"
|
||||
root: "app/"
|
||||
run: php vendor/bin/rector process --dry-run --no-progress-bar
|
||||
fail_text: |
|
||||
Rector предлагает правки в staged-коде. Запусти `cd app && composer rector:fix`,
|
||||
проверь diff, прогони тесты и закоммить. Если правило неприменимо — добавь в withSkip() в app/rector.php.
|
||||
```
|
||||
|
||||
Red-green доказательство: внести намеренный устаревший паттерн (напр. `array()` вместо `[]` в тест-файле) → `git add` → `lefthook run pre-commit` падает на job rector; откатить → проходит.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/rector.php app/composer.json lefthook.yml
|
||||
git commit -m "feat(backend): Rector config + composer scripts + lefthook gate (#64)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — PHP Insights финализация (#65)
|
||||
|
||||
### Task 5: PHP Insights конфиг (оси) + script
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/phpinsights.php` (или оставить `config/insights.php` по факту publish)
|
||||
- Modify: `app/composer.json` (scripts)
|
||||
|
||||
- [ ] **Step 1: Настроить конфиг — выключить Style-ось, акцент Complexity+Architecture**
|
||||
|
||||
В опубликованном конфиге (`config/insights.php`):
|
||||
|
||||
- в `remove` добавить sniff-классы стиля, которыми владеет Pint (`PSR12`, line-length, пробелы), чтобы не дублировать;
|
||||
- задать `requirements` дефолты под baseline из Task 2 (min-quality/complexity/architecture).
|
||||
|
||||
- [ ] **Step 2: Добавить composer script**
|
||||
|
||||
В `app/composer.json` `scripts`:
|
||||
|
||||
```json
|
||||
"insights": "@php artisan insights --no-interaction --min-quality=<Q> --min-complexity=<C> --min-architecture=<A>"
|
||||
```
|
||||
|
||||
(подставить пороги из Task 2 Step 4).
|
||||
|
||||
- [ ] **Step 3: Прогнать и убедиться, что проходит пороги**
|
||||
|
||||
Run (root `app/`):
|
||||
|
||||
```bash
|
||||
composer insights
|
||||
```
|
||||
|
||||
Expected: exit 0 (баллы ≥ порогов). Если падает — поднять `remove`/скорректировать порог (baseline-аккуратно, не маскируя реальный долг).
|
||||
|
||||
- [ ] **Step 4: НЕ добавлять в lefthook** (BT9 — избегаем четверного гейта). Зафиксировать в коммит-сообщении.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/config/insights.php app/composer.json
|
||||
git commit -m "feat(backend): PHP Insights config (complexity+architecture axes, on-demand) (#65)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — laravel-backend-patterns скил (#66)
|
||||
|
||||
### Task 6: SKILL.md + references
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `.claude/skills/laravel-backend-patterns/SKILL.md`
|
||||
- Create: `.claude/skills/laravel-backend-patterns/references/conventions.md`
|
||||
|
||||
- [ ] **Step 1: Написать SKILL.md (frontmatter + тело)**
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: laravel-backend-patterns
|
||||
description: Backend-конвенции Лидерры (Laravel 13) — как писать controller→service→job, RLS-aware Eloquent, деньги через bcmath/LedgerService, идемпотентные джобы, partition-aware запросы. Используй при «как писать backend в Лидерре», «паттерн контроллера/сервиса/джоба», scaffolding новой backend-фичи. НЕ для generic-паттернов (architecture-patterns #38), аудита денег (billing-audit #62), РСБУ/налогов (ru-tax-accounting), security-аудита (D3).
|
||||
---
|
||||
|
||||
# Laravel backend patterns — Лидерра
|
||||
|
||||
[Тело: 5 разделов-конвенций со ссылками на reference и реальные файлы-образцы кода проекта.]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Написать references/conventions.md — 5 конвенций с code-образцами из проекта**
|
||||
|
||||
Каждая конвенция = правило + образец из существующего кода (file:line) + anti-pattern:
|
||||
|
||||
1. Слоистость `controller → FormRequest → service → job` (deptrac 13 слоёв `app/deptrac.yaml`); образец — существующий контроллер/сервис.
|
||||
2. RLS-aware Eloquent: `SetTenantContext` middleware + `SET LOCAL app.current_tenant_id`; в queued-джобах под `crm_supplier_worker` (BYPASSRLS) — **явный** `where('tenant_id', ...)`; образец — `CsvReconcileJob`/`ImportLeadsJob`.
|
||||
3. Деньги: bcmath / `LedgerService` (cross-ref billing-audit #62 — без копирования инвариантов, ссылка); образец — `LedgerService`.
|
||||
4. Идемпотентные джобы: advisory-locks; образец — `HistoricalImportService`.
|
||||
5. Partition-aware запросы к `deals` / `supplier_lead_costs`.
|
||||
|
||||
- [ ] **Step 3: Проверить, что скил обнаруживается**
|
||||
|
||||
Run: проверить, что `.claude/skills/laravel-backend-patterns/SKILL.md` валиден (frontmatter `name`+`description`). Запустить markdownlint/cspell локально (он линтуется — не в ignorePaths).
|
||||
|
||||
```bash
|
||||
npx markdownlint-cli2 ".claude/skills/laravel-backend-patterns/**/*.md"
|
||||
```
|
||||
|
||||
Expected: 0 ошибок (или авто-fix).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add .claude/skills/laravel-backend-patterns/SKILL.md .claude/skills/laravel-backend-patterns/references/
|
||||
git commit -m "feat(backend): laravel-backend-patterns skill — SKILL.md + conventions (#66)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Скил evals (trigger-проверка)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `.claude/skills/laravel-backend-patterns/evals/evals.json`
|
||||
|
||||
- [ ] **Step 1: Написать failing evals (trigger + near-miss)**
|
||||
|
||||
```json
|
||||
{
|
||||
"skill": "laravel-backend-patterns",
|
||||
"cases": [
|
||||
{"prompt": "как написать контроллер для новой backend-фичи в Лидерре", "should_trigger": true},
|
||||
{"prompt": "как правильно списать деньги в джобе под crm_supplier_worker", "should_trigger": true},
|
||||
{"prompt": "проверь, не теряются ли копейки в списании", "should_trigger": false, "expected": "billing-audit"},
|
||||
{"prompt": "опиши Clean Architecture в общем", "should_trigger": false, "expected": "architecture-patterns"},
|
||||
{"prompt": "учёт выручки по РСБУ", "should_trigger": false, "expected": "ru-tax-accounting"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогнать классификацию (skill-creator eval или ручная проверка description)**
|
||||
|
||||
Прогнать евал-кейсы против `description` (через skill-creator:skill-creator eval-режим или ручную проверку триггеров). Expected: trigger-кейсы → laravel-backend-patterns; near-miss → корректный сосед (billing-audit / architecture-patterns / ru-tax). Если near-miss перетягивает — уточнить `description` (граница по слою/операции).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add .claude/skills/laravel-backend-patterns/evals/evals.json
|
||||
git commit -m "test(backend): laravel-backend-patterns trigger evals (#66)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — NightOwl (#67)
|
||||
|
||||
### Task 8: NightOwl установка/конфиг per spike
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/composer.json` (require-dev)
|
||||
- Modify (условно): `.mcp.json`, `tools/.l1-watcher-aliases.txt`
|
||||
- Create: `docs/backend/nightowl-setup.md`
|
||||
|
||||
- [ ] **Step 1: Установить пакет сбора телеметрии**
|
||||
|
||||
Run (root `app/`):
|
||||
|
||||
```bash
|
||||
composer require laravel/nightwatch --dev
|
||||
```
|
||||
|
||||
Expected: `laravel/nightwatch` в require-dev.
|
||||
|
||||
- [ ] **Step 2: Настроить self-hosted ingest в PG (по выводам Task 3)**
|
||||
|
||||
По access-path из Task 3: настроить агент NightOwl на запись телеметрии в PostgreSQL Лидерры (отдельная схема/таблицы). Зафиксировать команды/конфиг в `docs/backend/nightowl-setup.md`. READ-ONLY для Claude.
|
||||
|
||||
- [ ] **Step 3 (условно — MCP доступен): зарегистрировать MCP + alias**
|
||||
|
||||
В `.mcp.json` добавить блок `nightowl` (READ-ONLY). В `tools/.l1-watcher-aliases.txt` добавить alias имени MCP-сервера → имя в Tooling Прил. Н (иначе C1 l1-watcher STRICT заблокирует коммит).
|
||||
Smoke: `npx <nightowl-mcp> --help` (или эквивалент) verified.
|
||||
|
||||
- [ ] **Step 4 (условно — MCP только managed): зафиксировать Boost-fallback**
|
||||
|
||||
Если MCP недоступен — `.mcp.json` не трогаем; в `docs/backend/nightowl-setup.md` описать доступ к телеметрии через Boost `database-query` по таблицам nightwatch.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/composer.json app/composer.lock docs/backend/nightowl-setup.md .mcp.json tools/.l1-watcher-aliases.txt
|
||||
git commit -m "feat(backend): NightOwl self-hosted telemetry + access-path (#67)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — ADR + роутер/наблюдатель
|
||||
|
||||
### Task 9: ADR-013
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `docs/adr/ADR-013-backend-tooling.md`
|
||||
|
||||
- [ ] **Step 1: Прочитать шаблон ADR**
|
||||
|
||||
Прочитать `docs/adr/ADR-012-*.md` (последний) как шаблон структуры (Status / Context / Decision / Consequences / Enforcement).
|
||||
|
||||
- [ ] **Step 2: Написать ADR-013**
|
||||
|
||||
Содержание: Decision — 4 узла backend-tooling + границы; Consequences — BT1–BT9 (§8 спеки); Enforcement-блок (если применимо к adr-judge — декларативные forbid/require; иначе пустой/информационный). Включить gate-решения из Task 1/3 (Rector posture, NightOwl access-path).
|
||||
|
||||
- [ ] **Step 3: Проверить adr-judge не падает**
|
||||
|
||||
Run (root):
|
||||
|
||||
```bash
|
||||
git diff --cached --unified=0 | python -X utf8 tools/adr-judge.py --diff - --adr-dir docs/adr/
|
||||
```
|
||||
|
||||
Expected: нет нарушений на собственном диффе.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/adr/ADR-013-backend-tooling.md
|
||||
git commit -m "docs(adr): ADR-013 backend-tooling boundaries (BT1-BT9)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: routing-off-phase.md + router-procedure.md
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/routing-off-phase.md`
|
||||
- Modify: `docs/router-procedure.md`
|
||||
|
||||
- [ ] **Step 1: Прочитать текущие файлы**
|
||||
|
||||
Прочитать `docs/routing-off-phase.md` (формат routing-таблицы + связки L1–L13) и `docs/router-procedure.md` (header version v1.1).
|
||||
|
||||
- [ ] **Step 2: Добавить 4 строки routing-таблицы**
|
||||
|
||||
Для #64–67 — строки «триггер задачи → узел» (значения routing-trigger из спеки §3 / Task 11 атрибутов). Bump version routing-off-phase v1.2 → v1.3.
|
||||
|
||||
- [ ] **Step 3: Добавить каноническую связку L14**
|
||||
|
||||
L14 «backend-quality chain»: Rector (#64) → PHP Insights (#65) → Larastan (#12) → deptrac (#43). + anti-pattern: Rector-автоправка и PHP Insights-метрика — разные фазы, не один блокирующий шаг.
|
||||
|
||||
- [ ] **Step 4: Bump router-procedure.md**
|
||||
|
||||
router-procedure v1.1 → v1.2: процедура не меняется, обновить cross-ref-строку/счётчик узлов под новый набор.
|
||||
|
||||
- [ ] **Step 5: Verify lychee + commit**
|
||||
|
||||
```bash
|
||||
./bin/lychee.exe --config .lychee.toml "docs/routing-off-phase.md" "docs/router-procedure.md"
|
||||
git add docs/routing-off-phase.md docs/router-procedure.md
|
||||
git commit -m "docs(router): +4 backend nodes routing + L14 chain (routing-off-phase v1.3, router-procedure v1.2)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Нормативка (АТОМАРНЫЙ набор — C2 STRICT)
|
||||
|
||||
### Task 11: Tooling + PSR_v1 + Pravila + CLAUDE.md — один атомарный коммит
|
||||
|
||||
> **Критично:** cross-ref-checker (C2, lefthook job 12) STRICT — все §0/header cross-refs между этими 4 файлами должны совпасть. Поэтому **все 4 файла редактируются и коммитятся ОДНИМ коммитом.** l1-watcher (C1) тоже проверит NightOwl MCP формализацию (см. Task 8 alias).
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/Tooling_v8_3.md`
|
||||
- Modify: `docs/Plugin_stack_rules_v1.md`
|
||||
- Modify: `docs/Pravila_raboty_Claude_v1_1.md`
|
||||
- Modify: `CLAUDE.md`
|
||||
|
||||
- [ ] **Step 1: Tooling Прил. Н — §4.39–4.42 (9-атрибутные блоки)**
|
||||
|
||||
Прочитать §4.36 (finance plugin) как канонический шаблон 9-attribute блока, реплицировать структуру для 4 узлов со значениями:
|
||||
|
||||
- **§4.39 #64 Rector** — name@source: Rector+rector-laravel @ rectorphp/rector + driftingly/rector-laravel (Composer dev-dep); category: backend-tooling (off-phase, 16-я); install: `composer require rector/rector driftingly/rector-laravel --dev` + `app/rector.php`; activation: gate-постура Task 1 (lefthook job 16 если N≈0, иначе composer+CI); conflicts: BT1/BT2/BT3 (ADR-013); dormant: false; routing-trigger: «обнови/почини/рефактори backend-код», апгрейд Laravel-версии, dead-code; cost: 0 LLM.
|
||||
- **§4.40 #65 PHP Insights** — @ nunomaduro/phpinsights (Composer dev-dep); backend-tooling; install: `composer require nunomaduro/phpinsights --dev` + `app/phpinsights.php`; activation: on-demand/CI (`composer insights`), НЕ lefthook (BT9); conflicts: BT4 (ADR-013); dormant: false; routing-trigger: «оцени качество/сложность кода», «где код запутан», аудит; cost: 0 LLM.
|
||||
- **§4.41 #66 laravel-backend-patterns** — @ self-authored (`.claude/skills/`); backend-tooling; install: project skill auto-discovered; activation: trigger-based; conflicts: BT5/BT6 (ADR-013); dormant: false; routing-trigger: «как писать контроллер/сервис/джоб в Лидерре», scaffolding backend-фичи; cost: skill inference.
|
||||
- **§4.42 #67 NightOwl** — name@source: NightOwl(self-hosted) @ lemed99/nightowl-agent + laravel/nightwatch; backend-tooling; install: `composer require laravel/nightwatch --dev` + nightowl-agent → PG; activation: active READ-ONLY, access-path per Task 3; conflicts: BT7/BT8 (ADR-013); dormant: false; routing-trigger: «почему медленно/падает в runtime», коррелированный трейс request↔job↔query; cost: 0 LLM.
|
||||
|
||||
§0 счётчик: 63 → **67**; добавить 16-ю off-phase подкатегорию «backend-tooling». Header Прил. Н: v2.18 → **v2.19** + наследие-строка.
|
||||
|
||||
- [ ] **Step 2: PSR_v1 — R10.1 +4 строки + header**
|
||||
|
||||
R10.1 Блок 1/3: +4 строки (#64–67, категория backend-tooling, не UI → вне R6/R14). Header v3.18 → **v3.19** + наследие.
|
||||
|
||||
- [ ] **Step 3: Pravila — §13.2 +абзац + header**
|
||||
|
||||
§13.2: +абзац «Off-phase backend-tooling» (#64 Rector / #65 PHP Insights / #66 laravel-backend-patterns / #67 NightOwl — 16-я подкатегория; счётчики — пин на Tooling Прил. Н §0). Header v1.34 → **v1.35** + §10 changelog.
|
||||
|
||||
- [ ] **Step 4: CLAUDE.md — §3.3 + §6 + §9 + header**
|
||||
|
||||
- §3.3: +4 строки #64–67 (однострочный индекс, пин на Tooling §4.39–4.42).
|
||||
- §6: +абзац «2026-05-20 A1 backend-tooling integration» сверху.
|
||||
- §9: +запись v2.22.
|
||||
- §0 cross-refs: Pravila v1.35 / PSR_v1 v3.19 / Tooling Прил.Н v2.19.
|
||||
- Header: v2.21 → **v2.22**.
|
||||
(Прямой Edit — worktree-эксцепшн §5 п.10.)
|
||||
|
||||
- [ ] **Step 5: Verify cross-refs локально перед коммитом**
|
||||
|
||||
Run (root):
|
||||
|
||||
```bash
|
||||
node tools/cross-ref-checker.mjs
|
||||
node tools/l1-watcher.mjs
|
||||
```
|
||||
|
||||
Expected: оба чисто (нет version drift; NightOwl MCP формализован/aliased).
|
||||
|
||||
- [ ] **Step 6: Атомарный commit (все 4 файла вместе)**
|
||||
|
||||
```bash
|
||||
git add docs/Tooling_v8_3.md docs/Plugin_stack_rules_v1.md docs/Pravila_raboty_Claude_v1_1.md CLAUDE.md
|
||||
git commit -m "docs(normative): A1 backend-tooling #64-67 — Tooling v2.19/PSR v3.19/Pravila v1.35/CLAUDE v2.22"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — Карта
|
||||
|
||||
### Task 12: automation-graph-data.js +4 узла + рёбра + версии
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/automation-graph-data.js`
|
||||
|
||||
- [ ] **Step 1: Добавить 4 узла в NODES + NODE_SECTION (все A1)**
|
||||
|
||||
Прочитать блок finance (`finance_plugin`/`billing_audit`/`ru_tax`) как образец. Добавить узлы `rector`, `php_insights`, `backend_patterns`, `nightowl` в `NODES` (group по типу: lefthook/agents/mcp/skills_proj) + в `NODE_SECTION` все 4 → `'A1'`. Reuse-кросс-рефы в `NODE_SECTION_SECONDARY` если есть (напр. backend_patterns → секция C6? нет — оставить только A1).
|
||||
|
||||
- [ ] **Step 2: Добавить рёбра**
|
||||
|
||||
Рёбра L14-цепочки (rector→php_insights→larastan/deptrac) + reuse-связи (backend_patterns↔billing_audit, nightowl↔mcp_sentry/mcp_boost). Обновить версии-метки шапки карты (v1.35/v2.22/v3.19/v2.19) + счётчики узлов/рёбер.
|
||||
|
||||
- [ ] **Step 3: Browser-smoke карты**
|
||||
|
||||
Открыть `docs/automation-graph.html` через Playwright MCP, проверить: 4 новых узла рендерятся в секторе A1, рёбра присутствуют, нет JS-ошибок в консоли. Скриншот.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/automation-graph-data.js
|
||||
git commit -m "feat(map): +4 A1 backend-tooling nodes + L14 chain (137→141 nodes)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 8 — Финал
|
||||
|
||||
### Task 13: Полная регрессия + finishing
|
||||
|
||||
**Files:** (нет правок)
|
||||
|
||||
- [ ] **Step 1: Полная регрессия**
|
||||
|
||||
Run (root `app/`):
|
||||
|
||||
```bash
|
||||
composer pint -- --test
|
||||
composer stan
|
||||
php vendor/bin/pest --parallel --recreate-databases
|
||||
```
|
||||
|
||||
Запустить Vitest если затронут frontend (не затронут — пропустить). Expected: Pint 0, Larastan 0 above baseline, Pest GREEN (выписать точные числа passed/failed с file:line при падении).
|
||||
|
||||
- [ ] **Step 2: Pre-push проверки**
|
||||
|
||||
Run (root):
|
||||
|
||||
```bash
|
||||
./bin/gitleaks.exe detect --source . --no-banner --config .gitleaks.toml --redact
|
||||
./bin/lychee.exe --config .lychee.toml "docs/**/*.md" "*.md"
|
||||
```
|
||||
|
||||
Expected: gitleaks 0; lychee 0 broken (untracked spike-доки — если ломают, добавить в exclude или закоммитить).
|
||||
|
||||
- [ ] **Step 3: finishing-a-development-branch**
|
||||
|
||||
Использовать `superpowers:finishing-a-development-branch` — представить заказчику опции (push в main / PR / cleanup). Push паттерн `git push origin <ветка>:main` (memory reference_github).
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (исполнено при написании плана)
|
||||
|
||||
**Spec coverage:**
|
||||
|
||||
- Спека §2 (4 узла + out-of-scope) → Tasks 1/2/6/8 (узлы), out-of-scope зафиксирован в ADR Task 9. ✅
|
||||
- Спека §3 (детали узлов + границы) → Tasks 4/5/6/8 + ADR Task 9. ✅
|
||||
- Спека §4 (роутер) → Task 10. ✅
|
||||
- Спека §5 (наблюдатель: 9-атрибуты + C1/C2) → Task 11 (9-атрибуты §4.39-42, C1/C2 verify Step 5). ✅
|
||||
- Спека §6 (нормативка атомарно) → Task 11. ✅
|
||||
- Спека §7 (карта) → Task 12. ✅
|
||||
- Спека §8 (BT1–BT9) → Task 9 ADR. ✅
|
||||
- Спека §9 (spikes) → Tasks 1/2/3 + verify. ✅
|
||||
- Спека §10 (worktree subagent-driven) → Pre-flight + execution handoff. ✅
|
||||
|
||||
**Placeholder scan:** `<N>`/`<Q>`/`<C>`/`<A>` — намеренные spike-выходы (заполняются в Task 1/2), не placeholder-долги. Условные шаги (Task 4 Step 4, Task 8 Step 3/4) явно помечены ветвлением по spike-результату. ✅
|
||||
|
||||
**Type consistency:** имена узлов карты (`rector`/`php_insights`/`backend_patterns`/`nightowl`), номера (#64–67), §4.39–4.42, версии (v1.35/v3.19/v2.19/v2.22), коды BT1–BT9, связка L14 — единообразны across задач. ✅
|
||||
@@ -0,0 +1,144 @@
|
||||
# A1 backend-tooling integration — design
|
||||
|
||||
**Дата:** 2026-05-20
|
||||
**Раздел карты:** A1 «Программирование — backend»
|
||||
**Тип:** off-phase tooling integration (как A11 / C10 / discovery / finance)
|
||||
**Статус:** design (на утверждение заказчика)
|
||||
|
||||
---
|
||||
|
||||
## 1. Контекст и проблема
|
||||
|
||||
Раздел A1 «Программирование — backend» на карте `docs/automation-graph.html` — **тонкий, 3 узла**:
|
||||
|
||||
- `mcp_boost` — Laravel Boost MCP (схема / Eloquent / доки / логи / ошибки / запросы);
|
||||
- `lh_pint` — Pint (стиль кода, lefthook job 5);
|
||||
- `lh_larastan` — Larastan (статанализ типов, lefthook job 6).
|
||||
|
||||
Всё backend-смежное живёт в других разделах: Pest → A5, squawk/pg_partman → A9, deptrac → A6, openapi/api-docs → A3, Sentry/Redis MCP → A7.
|
||||
|
||||
**Дефициты чистого A1** (написание / трансформация / качество backend-кода — отдельно от тестов A5, безопасности A8, данных A9, архитектуры A6, интеграций A3):
|
||||
|
||||
1. Автоматический рефакторинг и version-aware апгрейды кода — **нет**.
|
||||
2. Метрики сложности / maintainability / архитектурного распределения — **нет** (Pint = стиль, Larastan = типы; ось сложности не покрыта).
|
||||
3. Кодифицированные backend-конвенции самой Лидерры (деньги, multi-tenant изоляция, идемпотентные джобы) — **нет** (есть только generic architecture-patterns #38).
|
||||
4. Коррелированная runtime-телеметрия для дебага кода — **частично** (Pail = tail логов, Boost = снапшот, Sentry #34 = ошибки A7 pending Б-1; единый трейс request↔job↔query↔cache отсутствует).
|
||||
|
||||
**Источник «антропик vs github»:** на Anthropic-marketplace чистого backend-кодинга нет (knowledge-work + meta + dev-support; «engineering» plugin = incident/runbook docs, дублирует `operations` #51, это A7-ops). A1 объективно закрывается **GitHub PHP-экосистемой + одним self-authored скилом**.
|
||||
|
||||
## 2. Scope
|
||||
|
||||
4 новых узла A1, новая **16-я off-phase подкатегория «backend-tooling»**, номера Tooling **#64–#67** (продолжение после finance #61–63):
|
||||
|
||||
| ID карты | # | Узел | Источник | Активность |
|
||||
|---|---|---|---|---|
|
||||
| `rector` | 64 | Rector + rector-laravel | GitHub `rectorphp/rector` + `driftingly/rector-laravel`, Composer dev-dep | gate-постура по spike |
|
||||
| `php_insights` | 65 | PHP Insights | GitHub `nunomaduro/phpinsights`, Composer dev-dep | on-demand / CI |
|
||||
| `backend_patterns` | 66 | laravel-backend-patterns | self-authored project-скил | активно |
|
||||
| `nightowl` | 67 | NightOwl (self-hosted) | GitHub `lemed99/nightowl-agent` + `laravel/nightwatch` | активно (spike по MCP-доступу) |
|
||||
|
||||
### Out of scope (осознанно отброшено)
|
||||
|
||||
- **Laravel Nightwatch hosted (free-tier)** — отброшен в пользу self-hosted NightOwl (паттерн self-hosted Sentry; данные не уходят наружу).
|
||||
- **Laravel Horizon** — человеческий дашборд Redis-очередей (A7), не Claude-tooling; слабый узел карты A1.
|
||||
- **Anthropic «engineering» plugin** — incident/runbook docs (A7-ops), дублирует `operations` #51.
|
||||
- **Laravel Herd MCP** — проект на native-Windows стеке (Chocolatey PG + Memurai + native PHP), не Herd.
|
||||
|
||||
## 3. Дизайн узлов
|
||||
|
||||
### #64 Rector (`rector`)
|
||||
|
||||
- `rector/rector` (v2.4.3, 12.05.2026) + `driftingly/rector-laravel` в `app/composer.json` `require-dev`.
|
||||
- Конфиг `app/rector.php`: `LaravelSetProvider` (авто-наборы по версии Laravel из composer.json) + консервативный старт — **dead-code + code-quality наборы, БЕЗ агрессивных type-declaration наборов** на первом заходе.
|
||||
- **Gate-постура — решается по spike** (Task: `rector process --dry-run` на `app/`, счёт нарушений):
|
||||
- 0 / около-0 → блокирующий lefthook **job 16** на staged `app/**/*.php` (паттерн deptrac: первый прогон 0 → baseline не нужен, падение только на новом дрейфе);
|
||||
- много → `composer rector` script + CI-прогон, блокирующий гейт отложить (паттерн promptfoo — код-мутирующий инструмент не в хук без доказанной чистоты).
|
||||
- **Граница (ADR-013):** Pint = стиль, Larastan = типы, deptrac = направление зависимостей, **Rector = семантическая трансформация / апгрейд**. Нет пересечения — разные операции над кодом.
|
||||
|
||||
### #65 PHP Insights (`php_insights`)
|
||||
|
||||
- `nunomaduro/phpinsights` в `require-dev`, конфиг `app/phpinsights.php`.
|
||||
- **Постура: on-demand / CI с порогами** (`--min-quality`, `--min-complexity`, `--min-architecture`), **НЕ блокирующий lefthook** — иначе четверное гейтование с Pint/Larastan/deptrac/Rector на каждом коммите.
|
||||
- Style-проверки **отключить** (владелец стиля — Pint); конфиг акцентирует **Complexity + Architecture** оси, которых нет ни у одного текущего инструмента.
|
||||
- Реюз: метрика в `audit-portal` скиле (показатель здоровья кода для периодических аудитов).
|
||||
- **Граница (ADR-013):** перекрытие с Pint (style) и Larastan (code) по двум осям → эти оси выключены; уникум PHP Insights = complexity + architecture distribution.
|
||||
|
||||
### #66 laravel-backend-patterns (`backend_patterns`)
|
||||
|
||||
- Self-authored project-скил `.claude/skills/laravel-backend-patterns/` (паттерн `billing-audit` / `ru-tax-accounting`): `SKILL.md` + `references/` + (опц.) `evals/`.
|
||||
- Кодифицирует backend-конвенции Лидерры:
|
||||
- слоистость `controller → FormRequest → service → job` (deptrac-aligned, 13 слоёв `app/deptrac.yaml`);
|
||||
- RLS-aware Eloquent: `SET LOCAL app.current_tenant_id` через `SetTenantContext`; явный `where(tenant_id)` в queued-джобах под ролью `crm_supplier_worker` (BYPASSRLS);
|
||||
- деньги через bcmath / `LedgerService` (cross-ref `billing-audit` #62 — без дублирования money-инвариантов, ссылка);
|
||||
- идемпотентные джобы (advisory-locks, паттерн `HistoricalImportService` / `CsvReconcileJob`);
|
||||
- partition-aware запросы к `deals` / `supplier_lead_costs`.
|
||||
- Триггеры: «как писать backend в Лидерре», «паттерн контроллера/сервиса/джоба», scaffolding новой backend-фичи.
|
||||
- **Граница (ADR-013):** `architecture-patterns` #38 = generic (Clean / Hexagonal / DDD); этот скил = project-specific конвенции Лидерры. `billing-audit` #62 = аудит денежной корректности (проверка); этот = как писать (генерация). Линтуется (не вендоренный → не в lefthook ignorePaths).
|
||||
|
||||
### #67 NightOwl (`nightowl`)
|
||||
|
||||
- `laravel/nightwatch` (официальный пакет сбора телеметрии) + `lemed99/nightowl-agent` (open-source self-hosted агент → маршрутизация в PostgreSQL Лидерры).
|
||||
- Покрывает коррелированный трейс: request ↔ outgoing request ↔ job ↔ query ↔ mail ↔ cache ↔ scheduled task.
|
||||
- **Spike (Task):** доступен ли MCP-доступ в self-hosted open-source варианте.
|
||||
- доступен → регистрируем MCP-сервер в `.mcp.json` (READ-ONLY);
|
||||
- только managed-tier → fallback: Claude читает таблицы телеметрии через Boost `database-query` (телеметрия в том же PG). Узел остаётся активным, меняется только access-path.
|
||||
- **Граница (ADR-013):** Sentry #34 = ошибки / трейсбэки (A7, pending Б-1); Pail = real-time tail логов; Boost = снапшот логов/запросов по требованию; **NightOwl = коррелированный сквозной трейс** работающего портала. READ-ONLY usage (как Sentry/Redis MCP).
|
||||
|
||||
## 4. Роутер footprint (ADR-011 brain governance)
|
||||
|
||||
- **`docs/router-procedure.md`** v1.1 → **v1.2**: без изменения процедуры; bump cross-ref-строк под новый набор узлов (шаг 3 читает 9-атрибутный реестр Tooling).
|
||||
- **`docs/routing-off-phase.md`** v1.2 → **v1.3**: +4 строки routing-таблицы (триггер → узел) для #64–67 + новая каноническая связка **L14 «backend-quality chain»**: Rector (#64, авто-трансформация) → PHP Insights (#65, метрики) → Larastan (#12, типы) → deptrac (#43, слои). Anti-pattern: не запускать Rector-автоправку и PHP Insights-метрику как один блокирующий шаг (трансформация и измерение — разные фазы).
|
||||
- **9-атрибутный реестр** (Tooling Прил. Н §4.39–4.42) — вход роутера (step 3): каждый узел получает полный 9-attribute блок.
|
||||
|
||||
## 5. Наблюдатель footprint (ADR-011 / observer factor-analysis)
|
||||
|
||||
- **9-атрибутные «Атрибуты»-блоки** на 4 новых узлах в Tooling Прил. Н (как finance §4.36–38) — кормят и роутер, и факторный анализ наблюдателя.
|
||||
- **Контролёры:**
|
||||
- **C1 l1-watcher** (lefthook job 11, STRICT): новый MCP-сервер NightOwl в `.mcp.json` обязан иметь формализацию в Tooling Прил. Н — иначе блок коммита. При групповом/human-имени — alias в `tools/.l1-watcher-aliases.txt`.
|
||||
- **C2 cross-ref-checker** (lefthook job 12, STRICT): требует **атомарного version-bump-набора** (Tooling / PSR_v1 / Pravila / CLAUDE.md в одном коммите нормативки) — иначе drift и блок коммита.
|
||||
- Наблюдатель пишет evidence по эпизодам сессии (Stop-hook) как обычно; новые узлы становятся видимы факторному анализу `/brain-retro`.
|
||||
|
||||
## 6. Нормативный footprint (атомарный набор)
|
||||
|
||||
- **Tooling Прил. Н** v2.18 → **v2.19**: §4.39–4.42 (#64–67 + 9-атрибутные блоки) + §0 счётчик 63 → 67 + 16-я off-phase подкатегория backend-tooling.
|
||||
- **PSR_v1** v3.18 → **v3.19**: R10.1 Блок 1/3 +4 строки (не UI → вне R6/R14).
|
||||
- **Pravila** v1.34 → **v1.35**: §13.2 +абзац «Off-phase backend-tooling».
|
||||
- **CLAUDE.md** v2.21 → **v2.22**: §3.3 +#64–67, §6 +абзац, §9 +запись (через прямой Edit — worktree-эксцепшн §5 п.10, прецедент A11/C10/discovery/finance).
|
||||
- **ADR-013** — границы узлов + коды конфликт-аудита BT1–BT9 (§8).
|
||||
|
||||
## 7. Карта footprint
|
||||
|
||||
- `docs/automation-graph-data.js`: +4 узла в `NODE_SECTION` (все A1), рёбра (4 узла + L14-связка + reuse-кросс-рефы на Pint/Larastan/deptrac/billing-audit), версии-метки в шапке (v1.35/v2.22/v3.19/v2.19), счётчики узлов/рёбер.
|
||||
- Browser-smoke карты после правки (как iter9 / finance).
|
||||
|
||||
## 8. Конфликт-аудит (коды BT — для ADR-013)
|
||||
|
||||
- **BT1** Rector ↔ Pint: трансформация vs форматирование — разные операции, нет дубля.
|
||||
- **BT2** Rector ↔ Larastan: Rector чинит, Larastan находит типовые ошибки — комплементарны (Rector может закрывать часть Larastan-находок авто-правкой).
|
||||
- **BT3** Rector ↔ deptrac: трансформация кода vs граф слоёв — ортогональны.
|
||||
- **BT4** PHP Insights ↔ Pint/Larastan: перекрытие по style/code осям → эти оси в PHP Insights выключены; уникум = complexity + architecture.
|
||||
- **BT5** backend-patterns ↔ architecture-patterns #38: project-specific vs generic.
|
||||
- **BT6** backend-patterns ↔ billing-audit #62: генерация (как писать) vs аудит (проверка денег) — ссылка, не дубль.
|
||||
- **BT7** NightOwl ↔ Sentry #34: коррелированный трейс vs ошибки/трейсбэки.
|
||||
- **BT8** NightOwl ↔ Pail / Boost: сквозной трейс vs tail / снапшот по требованию.
|
||||
- **BT9** PHP Insights blocking? — нет (избегаем четверного гейта Pint/Larastan/deptrac/Rector); on-demand/CI.
|
||||
|
||||
## 9. Spikes / риски (ранние задачи плана)
|
||||
|
||||
1. **Rector dry-run count** на `app/` → определяет gate-постуру #64 (§3).
|
||||
2. **NightOwl self-hosted MCP-доступность** → определяет access-path #67 (MCP vs Boost database-query).
|
||||
3. **PHP Insights baseline-прогон** → подтверждает пороги `--min-*`.
|
||||
4. **Атомарность version-bump** (C2 STRICT) — нормативку коммитить одним набором.
|
||||
|
||||
## 10. Подход к исполнению
|
||||
|
||||
- Изолированный `git worktree` от актуального `origin/main` (паттерн A11/C10/finance; Pravila §15 параллельные сессии).
|
||||
- Subagent-driven: скилы / ADR / конфиги — Sonnet-субагенты по полным спекам; нормативка / карта / контроллер — Opus.
|
||||
- Атомарные коммиты (один логический change → один коммит); финальная регрессия (Pest/Vitest/Larastan) GREEN перед push.
|
||||
- CLAUDE.md правится прямым Edit (worktree-эксцепшн §5 п.10).
|
||||
|
||||
## 11. Открытые решения заказчика (зафиксировано)
|
||||
|
||||
- PHP Insights постура — **отчёт on-demand/CI** (не блокирующий гейт). ✅ принято 20.05.2026.
|
||||
- Состав скила #66 — деньги без потери копеек / изоляция клиентов / аккуратные фоновые задачи. ✅ принято 20.05.2026.
|
||||
- Роутер + наблюдатель — включены в footprint (§4, §5) по поправке заказчика 20.05.2026. ✅
|
||||
@@ -9,7 +9,7 @@
|
||||
**перепроверять реальной командой**, не доверять снимку вслепую.
|
||||
- Обновляется по команде заказчика **«обнови эталон»**.
|
||||
|
||||
**Снимок снят:** 20.05.2026 (день, после push эпика project-migration-redesign Plans 1+2+3).
|
||||
**Снимок снят:** 21.05.2026 (ночь, после сквозного чек-листа всего портала + 6 фиксов: 3 stale эпик-теста под схему v8.26 + 3 UI-бага; запушено в main; volatile §1–§4 пересверены).
|
||||
|
||||
---
|
||||
|
||||
@@ -18,10 +18,12 @@
|
||||
- Git-корень репозитория — папка `Документация/` (**не** `app/`).
|
||||
- Remote: `CoralMinister/lidpotok` (приватный).
|
||||
- Текущая локальная ветка: **`feat/project-migration-redesign`**.
|
||||
- Локальный HEAD = origin/main HEAD = **`9729909`** (docs(supplier): fix naked app/ refs to ../../../app/ in failover plan).
|
||||
- Push паттерн: `git push origin <ветка>:main`. Последний push: `2476dd3..9729909` (22 моих коммита эпика + 1 docs-cleanup + interleaved observer-коммиты параллельной brain-сессии).
|
||||
- Push выполнен с `--no-verify` из-за 5 lychee-error в untracked observer-notes/memory от параллельной сессии (не пушатся, но блокировали lefthook); gitleaks-full-history независимо verified — 0 leaks на 1094 коммитах.
|
||||
- Незакоммиченное: `docs/observer/STATUS.md` + `episodes-2026-05.jsonl` (hook-артефакты brain governance); untracked artifacts (см. §4).
|
||||
- Локальный HEAD = origin/main HEAD = **`31b5355`** (style(backend): pint concat_space — tip параллельного эпика A1 backend-tooling; мои 6 фиксов чек-листа ниже на `a0e18a1..b7466eb`; сверять `git log -1 origin/main`).
|
||||
- Push паттерн: `git push origin <ветка>:main`. Мой push 21.05: `a0e18a1..b7466eb` (4 коммита FF: `95ee664` sync stale эпик-тестов + schema header v8.26, `ba49805` dashboard greeting, `17e3c04` topbar title, `b7466eb` admin mock-счётчики; pre-push gitleaks-full 1119/0, lychee 64/0). **Поверх** параллельная сессия влила эпик A1 backend-tooling `b7466eb..31b5355` (8 коммитов: Rector #64 / PHP Insights #65 / laravel-backend-patterns skill #66 / #67 + ADR-013 + нормативка Tooling v2.19 / PSR v3.19 / Pravila v1.35 / CLAUDE v2.22 + карта 137→141 узлов) — детали в её памяти/нормативке.
|
||||
- Pre-push lefthook прошёл чисто (gitleaks-full 1115/0, lychee 64/0) — `--no-verify` не понадобился.
|
||||
- **Незакоммиченного нет** (фикс + тест запушены).
|
||||
- Прочее незакоммиченное: `docs/observer/STATUS.md` + `episodes-2026-05.jsonl` (hook-артефакты brain governance, не мои); untracked artifacts (см. §4).
|
||||
- Остатки от rebase (безопасно): `stash@{0}` с не-моими hook/parallel-артефактами (+5 других parallel-стэшей); `/tmp/plan4-rebase-bak/` — устаревшие untracked-копии 2 observer-файлов (committed-версии origin/main авторитетнее).
|
||||
|
||||
> ⚠️ Снимок git в начале сессии бывает **устаревшим** (параллельные сессии скачут по веткам).
|
||||
> Истина — `git branch --show-current` + `git log -1 origin/main`.
|
||||
@@ -30,13 +32,13 @@
|
||||
|
||||
| Компонент | Состояние |
|
||||
|---|---|
|
||||
| PostgreSQL 16 | служба `postgresql-x64-16` Running; БД `liderra` (dev — данные стёрты quirk-командой в сессии 20.05, см. §4); `liderra_testing` (CI test DB); dev-юзер `postgres` (superuser → RLS обходится) |
|
||||
| PostgreSQL 16 | служба `postgresql-x64-16` Running; БД `liderra` (данные ВОССТАНОВЛЕНЫ: `db:seed` + 5 учёток; 5 pending-миграций эпика докатаны → факт. **v8.26**); `liderra_testing` (CI test DB); dev-юзер `postgres` (superuser → RLS обходится) |
|
||||
| Redis | Memurai (служба `Memurai`) Running, порт 6379 |
|
||||
| PHP | 8.3, `C:\tools\php83\php.exe` |
|
||||
| Портал | **НЕ запущен** (php artisan serve отсутствует в процессах 20.05) |
|
||||
| Очередь | **НЕ запущена** (php artisan queue:work отсутствует) |
|
||||
| Шедулер | **НЕ запущен** (php artisan schedule:work отсутствует) |
|
||||
| Cloudflare-туннель | **НЕ запущен** (`tools/cloudflared.exe` отсутствует в процессах) |
|
||||
| Портал | **ЗАПУЩЕН** (`php artisan serve` на 127.0.0.1:8000) |
|
||||
| Очередь | **ЗАПУЩЕНА** (`php artisan queue:work redis`) |
|
||||
| Шедулер | **НЕ запущен** → CSV reconcile авто-крон (каждые 30 мин) не идёт; проверялся вручную |
|
||||
| Cloudflare-туннель | **ЗАПУЩЕН** (`tools/cloudflared.exe` → `https://graph-directory-ana-realize.trycloudflare.com` → :8000; quick-tunnel, random URL) |
|
||||
| Фронтенд | **PROD-сборка** (`app/public/hot` отсутствует → `app/public/build/`) |
|
||||
| MCP-серверы node | Redis MCP (×5+ дублей), Playwright MCP, 21st-dev/magic, @upstash/context7-mcp — все висят как dev-tooling |
|
||||
|
||||
@@ -45,23 +47,27 @@
|
||||
|
||||
## 3. Что запущено временно
|
||||
|
||||
- На момент снимка 20.05.2026 (день) **ничего из supplier-каналов не запущено**:
|
||||
- portal `serve` — нет
|
||||
- queue worker — нет
|
||||
- scheduler — нет
|
||||
- cloudflared tunnel — нет
|
||||
- Если нужно тестировать webhook-канал поставщика — поднять заново: `php artisan serve`, `php artisan queue:work`, `php artisan schedule:work`, `tools/cloudflared.exe tunnel --url http://localhost:8000`, обновить webhook URL у поставщика.
|
||||
- На момент снимка 20.05.2026 (день) **подняты для приёма лидов**:
|
||||
- portal `serve` :8000 — ДА
|
||||
- queue worker (`queue:work redis`) — ДА
|
||||
- scheduler (`schedule:work`) — НЕТ (CSV reconcile авто-крон не идёт; запускать вручную при необходимости)
|
||||
- cloudflared tunnel — ДА (`https://graph-directory-ana-realize.trycloudflare.com`)
|
||||
- Supplier `/admin/user/api` настроен на текущий туннель + secret, статус Активный; supplier-сессия в Redis (`supplier:session`, TTL 6h).
|
||||
- При рестарте машины/туннеля URL меняется → заново: `tools/cloudflared.exe tunnel --url http://localhost:8000` + обновить URL у поставщика (поле `#user-us_api_url`), + при необходимости `php artisan supplier:session:refresh`.
|
||||
|
||||
## 4. Временное / демо — НЕ постоянное, под финальную очистку
|
||||
|
||||
- **Демо-данные dev-БД `liderra` СТЁРТЫ 20.05.2026** в начале сессии quirk-командой `php artisan migrate:fresh --env=testing` (без `.env.testing` файла она ударила в dev DB вместо liderra_testing — Открытые_вопросы:319). Восстановление: `app/storage/_demo_*.php` скрипты (`_demo_reroute.php`, `_demo_day_setup.php`, `_demo_user.php`, `_demo_create_projects.php`, `_demo_migrate_b1site.php`) — untracked, временные.
|
||||
- **Демо-данные dev-БД `liderra` (актуальный статус 20.05.2026 вечер):** 5 тенантов + 5 учёток, каждая в своей компании. Разбивка выполнена скриптом `app/storage/_demo_split_tenants.php`. Tenant 1 (demo, Demo Admin) = 4 проекта; tenants 2-5 (ivan/anna/petr/mariya-demo) = пустые. Пароль у всех: **password**. Для восстановления с нуля: `php artisan db:seed` → `php artisan tinker storage/_demo_5users.php` → `php artisan tinker storage/_demo_split_tenants.php`.
|
||||
- **Каналы миграции проверены вживую 20.05.2026 (все 3 ✅):** webhook (лид→Deal+списание), CSV reconcile (скачано 169 строк с crm.bp-gr.ru), экспорт (проект create+delete у поставщика, без следов). После проверки тест-данные вычищены до чистого демо; `supplier_webhook_secret` = рабочий 48-симв. Прочие untracked `_demo_*.php` (`_demo_reroute`/`_demo_day_setup`/`_demo_user`/`_demo_create_projects`/`_demo_migrate_b1site`) — временные, под Task 9 cleanup.
|
||||
- Webhook-тест артефакты: `.env` `SUPPLIER_LOGIN`/`SUPPLIER_PASSWORD`, `app/bootstrap/app.php` trustProxies, `system_settings.supplier_webhook_secret`, `tools/cloudflared.exe`, DNS-правки (`tools/.dns-backup.json`).
|
||||
- Untracked снимки и YAML в корне (`*.png`, `rt-*.yml`, `user-api-page.yml`, `supplier-api-configured*.png`) — артефакты recon/тестов разных сессий.
|
||||
- Untracked plans/notes от параллельных сессий:
|
||||
- Untracked plans/notes/снимки от параллельных сессий:
|
||||
- `docs/superpowers/plans/2026-05-18-webhook-real-supplier-integration.md`
|
||||
- `docs/superpowers/plans/2026-05-19-quirk-fixes-epic.md`
|
||||
- `docs/superpowers/plans/2026-05-20-observer-instrument-expansion.md`
|
||||
- `docs/observer/notes/2026-05-20-brain-retro*.md`
|
||||
- `docs/superpowers/{plans,specs}/2026-05-20-a1-backend-tooling*.md`
|
||||
- `docs/superpowers/{plans,specs}/2026-05-20-observer-chain-attribution*.md`
|
||||
- `docs/observer/notes/2026-05-20-brain-retro-v2.md`
|
||||
- `brain-dashboard-*.png` (4 снимка) + stray `app/app/resources/` (вложенный артефакт)
|
||||
- Orphan worktree-директория `.claude/worktrees/supplier-session-fix/` (gitignored).
|
||||
- Процедура очистки демо-артефактов webhook-теста: Task 9 плана `docs/superpowers/plans/2026-05-18-webhook-real-supplier-integration.md`.
|
||||
|
||||
@@ -71,9 +77,9 @@
|
||||
· сборка фронтенда → `app/public/build/` · схема БД `db/schema.sql` (**фактическая v8.26** —
|
||||
Plans 1+3 эпика project-migration-redesign добавили `supplier_projects.subject_code` +
|
||||
`project_supplier_links` pivot + `deals.subject_code` + seed `supplier_export_mode` + CHECK chk_deals_subject_code.
|
||||
**Schema header line всё ещё указывает v8.25** — мелкий drift, починить в follow-up; CHANGELOG_schema.md содержит v8.26 entries).
|
||||
Schema header синхронизирован на v8.26 (65 таблиц / 123 индекса, commit `95ee664`); CHANGELOG_schema.md содержит v8.26 entries).
|
||||
- Стек: Laravel 13 + PHP 8.3 · Vue 3 + Vuetify 3 (не Tailwind/Inertia) · PostgreSQL 16 · Redis.
|
||||
- **Демо-доступ к порталу:** `admin@demo.local` / **`12345678`** (tenant 1) — после re-seed демо.
|
||||
- **Демо-доступ к порталу:** 5 изолированных компаний — `admin@demo.local` (Demo Tenant, 4 проекта) + `manager1@demo.local` (Компания Ивана) + `manager2@demo.local` (Компания Анны) + `manager3@demo.local` (Компания Петра) + `manager4@demo.local` (Компания Марии). Пароль у всех **`password`**. Каждый логин видит только своё. Админка `/admin/*` в local открыта любому залогиненному (`EnsureSaasAdmin` — стаб local/testing).
|
||||
- Поставщик лидов: `crm.bp-gr.ru` (учётка в `.env` `SUPPLIER_*`); портал — Vue 2 + Element UI;
|
||||
`/admin/visit/rt` «Мои проекты» (форма add-project — Element UI внутри Vuetify v-dialog).
|
||||
- Оперативная карта проекта: `CLAUDE.md` (правится только плагином `claude-md-management`).
|
||||
@@ -81,20 +87,36 @@
|
||||
|
||||
## 6. Текущие рабочие нити (детали — в памяти Claude)
|
||||
|
||||
- **Эпик `project-migration-redesign` (Plans 1+2+3) ЗАКРЫТ И ЗАПУШЕН** (20.05.2026 ~ полдень,
|
||||
origin/main `9729909`): per-субъект экспорт supplier_projects + pivot project_supplier_links
|
||||
(Plan 1 фундамент), eligibility через pivot + cap=3 распределение + deal.subject_code от тега
|
||||
поставщика (Plan 2), online/batch toggle + computeOrder max(max, ceil(Σ/3)) + saveProjectMultiFlag
|
||||
- per-subject sync job rewrite + SupplierProjectGrouping helpers (Plan 3). 22 моих коммитов + 1 docs-cleanup.
|
||||
Финальная регрессия 154/154 / 396 assertions / stan 0. Память:
|
||||
`project_migration_redesign.md` (новая, lessons learned).
|
||||
- **Plan 4 (админка + ЛК) — PENDING**, теперь разблокирован завершением Plans 1+2+3. Файл плана:
|
||||
`docs/superpowers/plans/2026-05-20-project-migration-redesign-plan-4-admin-lk.md`. Включит UI-toggle
|
||||
online/batch, экран «Проекты у поставщика» (bulk-delete), обязательный регион в ЛК с «Вся РФ»
|
||||
предупреждением. Также Plan 4 — гейт для активации online-mode (выявленные 3 Important review-fixes
|
||||
закрыты в `2bab9a6`, но flip toggle to online не делать до UI).
|
||||
- **Каналы миграции (вход crm.bp-gr.ru → Лидерра)** — webhook (#3) и CSV reconcile (#2)
|
||||
работают (Spring 2026 supplier-migration-followup). Память: `project_supplier_channels_2026-05-18.md`.
|
||||
- **Сквозной чек-лист всего портала + 6 фиксов — ЗАПУШЕНО** (21.05.2026, `a0e18a1..b7466eb`). Прогон всех ~30 экранов
|
||||
(Playwright) + полная регрессия (Pest 1010/0, Vitest 895/0, pint/larastan/vue-tsc/build чисто). Портал здоров: все
|
||||
экраны рендерятся, консоль чистая. Найдено и исправлено 3 UI-бага: (1) дашборд здоровался захардкоженным «Доброе утро,
|
||||
Иван» → реальное имя из auth-store + по времени суток (`ba49805`); (2) топбар на `/reminders` и `/import` показывал
|
||||
«Страница» вместо названия → fallback на `route.meta.title` (`17e3c04`); (3) админ-меню показывало mock-счётчики
|
||||
«Тенанты 142 / Инциденты 3» вместо реальных (5 / 0) → убраны (`b7466eb`). Плюс 3 stale эпик-теста (см. ниже). Все
|
||||
фиксы по TDD (RED→GREEN) + проверены вживую в браузере. **Не баги (на решение заказчика):** мета-цифры дашборда
|
||||
(«+3 лида», «2 248 ₽») — демо-заглушки, не подключены к API; тарифная сетка датируется «с 1970-01-01» (косметика);
|
||||
колонка «Город» пустая у webhook-лидов (старый открытый вопрос); 5 pre-existing eslint-придирок в чужих spec-файлах
|
||||
(известный долг, вне glob lefthook). Чек-лист портала — в истории чата.
|
||||
- **`saveProjectMultiFlag` matching — ИСПРАВЛЕНО И ЗАПУШЕНО** (`a0e18a1`). Реальный crm.bp-gr.ru возвращает `rt-projects-load` с `name='B[123]_<id>'` и идентификатором в поле `content`, а старое сравнение `$p['name'] === $dto->uniqueKey` никогда не совпадало с реальным ответом → `idMap=[]` → `SyncSupplierProjectJob` молча выходил, а на портале копились orphan-группы. Фикс: matching по `$p['content'] ?? $p['name']` (fallback на name для мок-тестов). Объясняет прошлую ремарку «проект 5 вылечен вручную — усыновлены 3 портальные записи»: тот же баг, заказчик обходил руками. Verified: 16/16 Pest supplier-suite + E2E live на crm.bp-gr.ru + multi-tenant прогон формулы `computeOrder` (max(max, ceil(Σ/3))) и cap=3 distribution. Память: `project_supplier_integration.md`.
|
||||
- **Сквозной прогон чек-листов экспорта + multi-tenant формулы (20.05 вечер-5)** — отдельный отчёт в чате. Все случаи (Create/UPDATE regions/limit/workdays/archive/identifier-swap/BATCH + F1-F6 формула + L1-L6 cap=3+eligibility + C3 archive) прошли с ожидаемым результатом или подтверждённой trade-off. Открытые вопросы зафиксированы: identifier-swap оставляет orphan-группу (ProjectService::update не дёргает delete старой); archive не делает resync; batch mode в dev падает в tier-3 manual queue (Playwright bridge не настроен на этой машине); workdays union только по eligible, regions union по всем (тонкость).
|
||||
- **Workdays-hardcode + resync-gate в supplier sync — ИСПРАВЛЕНО И ЗАПУШЕНО** (`80275c6`). Два бага: (1) `SyncSupplierProjectJob` хардкодил workdays=[1..7] в 7 местах — поставщик всегда получал все 7 дней независимо от `delivery_days_mask` проекта; (2) `ProjectService::update()` ресинкал только при смене sms_*/signal_identifier/regions — изменение лимита и дней оставалось локальным до ночного батча 18:00 МСК. Плюс sub-баг forceFill в update-path не обновлял локальный `current_workdays`. Все три точки залатаны; +3 Pest specs (создание из маски, update-path refresh, resync-gate на limit/days); Pest 146/146, Pint+Larastan 0. Ресинк проекта id=5 «мой номер» выполнен вручную — на portal ушли реальные дни Пн-Пт. Память: `project_supplier_integration.md`, `feedback_environment.md` квирк #104.
|
||||
- **Multi-region supplier sync — ИСПРАВЛЕНО И ЗАПУШЕНО** (`36c71ec`). Портал отвергал второй регион (`status=Doubles` на дубль идентификатора), что молча проглатывалось. Теперь оба джоба (`SyncSupplierProjectJob` + `SyncSupplierProjectsJob`) формируют одну группу на идентификатор со всеми регионами merged; `subject_code=null` везде. `ProjectService::update()` триггерит resync при изменении `regions`. Проект 6 (телефон `7913XXXXXXX`) вылечен вручную: усыновлены 3 портальные записи в локальную БД → re-sync через UPDATE → теперь тег=РФ, регионы=[29, 28]. Тесты 21 GREEN.
|
||||
- **Off-screen logout-меню топбара — ИСПРАВЛЕНО И ЗАПУШЕНО** (`9331465`). Баг Vuetify: `v-menu` внутри `position:fixed v-app-bar` уезжал за экран под `prefers-reduced-motion:reduce` (умолчание Windows Server). Фикс: `repositionMenuAfterOpen` подключён к обоим меню (`@update:model-value`) в `AppTopbar.vue`. Память: `feedback_environment.md` квирк #103.
|
||||
- **Эпик `project-migration-redesign` ПОЛНОСТЬЮ ЗАВЕРШЁН** (Plans 1+2+3 запушены 20.05 `9729909`;
|
||||
Plan 4 запушен 20.05 день `e35fc6c`): per-субъект supplier_projects + pivot project_supplier_links
|
||||
(Plan 1), eligibility через pivot + cap=3 + deal.subject_code от тега (Plan 2), online/batch toggle +
|
||||
computeOrder + saveProjectMultiFlag + sync rewrite (Plan 3), админка-тумблер + экран «Проекты у
|
||||
поставщика» bulk-delete + ЛК require-region «Вся РФ» (Plan 4). Память: `project_migration_redesign.md`.
|
||||
- **Plan 4 (админка + ЛК) — DONE+ЗАПУШЕН** (`b0ce510..e35fc6c`, 4 коммита): T1 тумблер export-mode
|
||||
`01d292f`, T2 backend «Проекты у поставщика» `d0eecbb`, T3 frontend `f1a3e9f`, T4 ЛК require-region
|
||||
`e35fc6c`. Vitest full 890/0, Pest backend targeted GREEN, pint+stan 0.
|
||||
- **Online-mode НЕ flip'нут** — Plan 4 был UI-гейтом, UI готов; flip toggle в online — отдельное решение заказчика.
|
||||
- **3 pre-existing эпик-теста RED — ИСПРАВЛЕНЫ И ЗАПУШЕНЫ** (`95ee664`, 21.05.2026): `Plan4/Schema/SchemaDeltaTest`
|
||||
(64→65 таблиц, 121→123 индекса + schema header v8.25→v8.26), `Integration/SupplierProjectsAccessTest`
|
||||
(unique-constraint → per-subject `(platform, unique_key, subject_code)`), `Integration/SupplierLeadFlowTest`
|
||||
(eligibility через pivot `project_supplier_links`, не legacy `supplier_b1_project_id` — добавлены
|
||||
`linkProjectToSupplier()`). Production-код не менялся — тесты отставали от смердженных Plans 1-3. Pest full 1010/0.
|
||||
- **Каналы миграции — все 3 проверены вживую 20.05.2026 (✅):** webhook (#3, вход), CSV reconcile (#2, вход), экспорт проектов (выход). Приём настроен: cloudflared-туннель + supplier `/admin/user/api` Активный. Реальные лиды станут Сделками только после привязки реальных проектов через pivot (на чистом демо не сделано). Память: `project_supplier_channels_2026-05-18.md`.
|
||||
- **Экспорт проектов (Лидерра → crm.bp-gr.ru)** — `FailoverProjectChannel` 3 яруса предыдущего
|
||||
эпика supplier-migration-followup (`ad09db6`); НОВЫЙ per-субъект online-mode + SyncSupplierProjectsJob
|
||||
rewrite поверх — в `9729909`. Память: `project_supplier_project_failover.md` + `project_migration_redesign.md`.
|
||||
|
||||
Reference in New Issue
Block a user