2 Commits

Author SHA1 Message Date
Дмитрий 6a51d34cc5 docs(pilot): снимок боевого 12.07 — метка канала B_n_ + хвосты прогона + сверка main==прод
Accessibility (Pa11y live) / a11y (push) Waiting to run
Две записи в ПИЛОТ.md:

1. Кабинет поставщика стирал метку канала при обновлении строки. Разбор письма робота
   «нашёл расхождение»: тревога ложная, но за ней пряталась потеря атрибуции канала —
   лид от строки без метки писался как DIRECT вместо B1/B2/B3. Корень, фикс, выкат,
   результат на бою 81 строка починена, 138 строк вместо 57, 0 расхождений, откат.

2. Хвосты полного прогона: нарезчик партиций не делал прошлый месяц, три аудит-теста
   ADR-021 не доехали в main. Плюс итог сверки боевой против gitea/main — 569 из 569
   файлов бэкенда совпали побайтово, различий ноль.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:38:57 +03:00
Дмитрий 7feab849a6 fix(tests): партиция за прошлый месяц + возврат аудит-тестов, отставших от ADR-021
Accessibility (Pa11y live) / a11y (push) Waiting to run
SAST — Semgrep / Semgrep SAST scan (push) Waiting to run
Разбор хвостов полного прогона: было 2407/2418, стало 2417/2418 — единственное
оставшееся падение ExampleTest воспроизводится только там, где не собран фронт
Vite manifest not found, к коду отношения не имеет.

1. partitions:create-months — новый флаг --behind, по умолчанию 1.
   Нарезалось только «текущий месяц и вперёд», поэтому на свежей базе партиции за
   прошлый месяц не было НИКОГДА. Между тем строки с датой в прошлом месяце — штатное
   явление: тридцатидневное окно списаний метрика runway и вставки на стыке месяцев.
   На боевом не всплывало — там партиции копятся с самого начала, проверено: нарезаны
   до января 2027, крон жив. А на свежей базе и в CI INSERT падал «no partition found»
   в первой половине КАЖДОГО месяца. Ловилось двумя тестами AdminTenantShowTest.runway
   и роутингом лида.

2. Возвращены три аудит-теста, потерянных при сведении main 09.07.
   ADR-021 сделал цепочки журналов общими и AuditChainConfig в main это уже отражает,
   а тесты остались на старом per-tenant поведении ADR-018 и падали. Правильные версии
   лежали на ветке fix/audit-chain-global-scope, но в main не доехали — тот же класс
   потери, что и CsvReconcileJobTest.

Тесты: полный прогон 2417/2418 зелёный, Pint чисто.

Известное и НЕ тронутое, вынесено в отдельный список:
  - Larastan на main даёт 30 замечаний уровня типов, ни одного живого бага:
    мёртвые ветки в AuditRebuildChain, оставшиеся от ADR-021; ресурсы автоподбора без
    аннотации модели; ProjectController читает виртуальные пометки gate_payload и
    launch_deferred, которых нет в таблице.
  - Тест лимита входов флакует только в длинном прогоне, в одиночку зелёный.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:02:21 +03:00
6 changed files with 100 additions and 138 deletions
@@ -10,10 +10,16 @@ use Illuminate\Support\Carbon;
/**
* Создаёт ежемесячные партиции для всех таблиц в MonthlyPartitionManager::PARTITIONED_TABLES
* на N месяцев вперёд от текущей даты.
* на N месяцев вперёд от текущей даты и на M месяцев назад.
*
* Hole #2 (23.05.2026): расширен с 2 бизнес-таблиц до 9 (+ 7 audit-таблиц).
*
* behind=1 (12.07.2026): нарезалось только «текущий + вперёд», поэтому на свежей базе
* партиции за прошлый месяц не было никогда. Между тем строки с датой в прошлом месяце
* штатное явление: тридцатидневное окно списаний (runway) и вставки на стыке месяцев.
* На проде не всплывало (партиции копятся с самого начала), но на свежей базе и в CI
* INSERT падал «no partition found» в первой половине каждого месяца.
*
* Замена `pg_partman` на native Windows-стеке (расширение недоступно
* без сборки из исходников). Запускается ежесуточно через Windows Task
* Scheduler / cron идемпотентна (проверяет pg_class перед CREATE).
@@ -27,20 +33,23 @@ use Illuminate\Support\Carbon;
class PartitionsCreateMonths extends Command
{
/** @var string */
protected $signature = 'partitions:create-months {--ahead=2 : Сколько месяцев вперёд создать партиций}';
protected $signature = 'partitions:create-months
{--ahead=2 : Сколько месяцев вперёд создать партиций}
{--behind=1 : Сколько месяцев назад создать партиций (окно последних 30 дней, стык месяцев)}';
/** @var string */
protected $description = 'Создаёт ежемесячные партиции для всех партиционированных таблиц на N месяцев вперёд (idempotent)';
protected $description = 'Создаёт ежемесячные партиции для всех партиционированных таблиц: N месяцев вперёд и M назад (idempotent)';
public function handle(MonthlyPartitionManager $manager): int
{
$ahead = max(1, (int) $this->option('ahead'));
$behind = max(0, (int) $this->option('behind'));
$now = Carbon::now()->startOfMonth();
$created = 0;
$skipped = 0;
for ($i = 0; $i <= $ahead; $i++) {
for ($i = -$behind; $i <= $ahead; $i++) {
$monthStart = $now->copy()->addMonths($i);
foreach (array_keys(MonthlyPartitionManager::PARTITIONED_TABLES) as $table) {
@@ -57,7 +66,7 @@ class PartitionsCreateMonths extends Command
}
$this->newLine();
$this->info("Done: {$created} created, {$skipped} skipped (ahead={$ahead}).");
$this->info("Done: {$created} created, {$skipped} skipped (ahead={$ahead}, behind={$behind}).");
return self::SUCCESS;
}
@@ -232,7 +232,7 @@ it('audit:rebuild-chain rejects unknown partition names', function (): void {
// Column list for auth_log (must match AuditChainConfig::TABLES['auth_log']).
const AUTH_LOG_ROW_EXPR = 'ROW(t.id, t.actor_type, t.tenant_id, t.user_id, t.saas_admin_user_id, t.email, t.event, t.ip_address, t.user_agent, t.failure_reason, NULL::bytea, t.created_at)';
it('audit:rebuild-chain produces per-tenant chain matching trigger semantics в activity_log', function (): void {
it('audit:rebuild-chain produces global chain matching trigger semantics в activity_log (ADR-021)', function (): void {
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
@@ -253,15 +253,10 @@ it('audit:rebuild-chain produces per-tenant chain matching trigger semantics в
$partition = 'activity_log_y'.now()->format('Y').'_m'.now()->format('m');
$firstId = (int) DB::connection('pgsql_supplier')->table($partition)->min('id');
// NB: pre-rebuild sanity-check на trigger output опущен намеренно — в test env
// `SharesSupplierPdo` trait + postgres superuser обходят RLS, и trigger пишет
// global chain, а не per-tenant. На prod RLS активен и trigger пишет per-tenant
// (валидация — live `audit:verify-chains` на проде, не в этом тесте).
//
// Что тестируется здесь: AFTER rebuild чейн должен match семантике своего
// partition_clause (self-consistency). Pre-Task-4 rebuild делает global LAG →
// verify с PARTITION BY tenant_id обнаруживает mismatch → RED. Post-Task-4
// rebuild делает per-tenant LAG → verify с PARTITION BY tenant_id match → GREEN.
// ADR-021 (supersedes ADR-018): канон — global-within-partition для всех таблиц.
// На проде (проверено 2026-07-09) и в test env (postgres superuser) trigger пишет
// GLOBAL chain. rebuild читает partition='' из AuditChainConfig → тоже global →
// verify под global scope match → GREEN.
$exit = Artisan::call('audit:rebuild-chain', [
'--partition' => $partition,
@@ -270,8 +265,8 @@ it('audit:rebuild-chain produces per-tenant chain matching trigger semantics в
]);
expect($exit)->toBe(0);
$postMismatches = checkPartitionIntegrity($partition, 'PARTITION BY tenant_id', ACTIVITY_LOG_ROW_EXPR);
expect($postMismatches)->toBe(0, 'Rebuild должен produce per-tenant chain matching PARTITION BY tenant_id semantics (ADR-018)');
$postMismatches = checkPartitionIntegrity($partition, '', ACTIVITY_LOG_ROW_EXPR);
expect($postMismatches)->toBe(0, 'Rebuild должен produce global chain (ADR-021, supersedes ADR-018 per-tenant)');
});
it('audit:rebuild-chain produces global chain for BYPASSRLS auth_log', function (): void {
@@ -151,117 +151,18 @@ test('multiple rows in auth_log all pass intact', function () {
});
// ===========================================================================
// MULTI-TENANT REGRESSION: per-scope validator must not false-positive on
// data with multiple tenants.
// MULTI-TENANT REGRESSION — SUPERSEDED by ADR-021.
//
// Problem reproduced on prod: the old global validator computed LAG OVER
// (ORDER BY id) across ALL tenants. When tenant B's first row followed
// tenant A's last row, its stored_hash was SHA256(A_last.log_hash || B_row),
// but the global recompute gave the same thing — so it was fine globally.
// However, on PROD (crm_app_user, NOT BYPASSRLS) the trigger's SELECT only
// sees the current tenant's rows, so B_row[0] is chained off '' (no prev),
// not off A_last. The global validator then reported a false breach at every
// tenant boundary.
//
// This test replicates PROD conditions by bypassing the trigger and manually
// inserting rows with CORRECTLY per-tenant-chained hashes (what the trigger
// would produce under RLS on prod). The per-scope validator must then report
// INTACT, confirming the partition logic is correct.
//
// Why bypass the trigger for this test:
// On dev (postgres = superuser), the trigger's SELECT has no RLS filter and
// sees ALL rows globally. Inserting via the trigger would produce a GLOBAL
// chain regardless of the app.current_tenant_id GUC. To test the validator's
// per-partition recompute, we need rows whose stored hashes were computed
// with per-tenant prev_hash — exactly what the trigger produces on prod.
// We reproduce that by disabling triggers and computing the hashes ourselves
// using the same digest(COALESCE(prev,'')||row::text,'sha256') formula with
// per-partition LAG, matching the validator's own recompute SQL.
// The former test here ('per-tenant chained tenant_operations_log validates
// intact') manually seeded PER-TENANT-chained hashes and asserted the per-tenant
// validator accepted them. Its premise — that the trigger produces a per-tenant
// chain on prod — was empirically disproven 2026-07-09: the trigger produces a
// GLOBAL chain for all tables (incl. balance_transactions). ADR-021 makes global
// the canonical scope. The real, prod-accurate scenario (mixed-tenant rows
// chained BY THE TRIGGER = global) is covered by
// 'cross-tenant rows chained by the trigger (global) verify intact — ADR-021'.
// ===========================================================================
test('per-tenant chained tenant_operations_log validates intact (prod-behaviour regression)', function () {
$tid1 = ensureTenant(1);
$tid2 = ensureTenant(2);
// Disable triggers so we can insert rows with manually computed hashes
// that simulate per-tenant chaining (what the trigger produces on prod).
DB::statement('ALTER TABLE tenant_operations_log DISABLE TRIGGER USER');
try {
// Insert 3 rows for tenant 1 and 2 rows for tenant 2, interleaved by id.
// We insert WITHOUT log_hash first, then update with per-tenant-correct hashes.
// (INSERT with log_hash=NULL, then fill via SQL digest to avoid PHP bytea handling.)
$rows = [
['tenant_id' => $tid1, 'entity_type' => 'project', 'entity_id' => 10, 'event' => 'project.created', 'created_at' => now()],
['tenant_id' => $tid2, 'entity_type' => 'api_key', 'entity_id' => 20, 'event' => 'api_key.regenerated', 'created_at' => now()],
['tenant_id' => $tid1, 'entity_type' => 'project', 'entity_id' => 11, 'event' => 'project.updated', 'created_at' => now()],
['tenant_id' => $tid2, 'entity_type' => 'project', 'entity_id' => 21, 'event' => 'project.created', 'created_at' => now()],
['tenant_id' => $tid1, 'entity_type' => 'project', 'entity_id' => 12, 'event' => 'project.deleted', 'created_at' => now()],
];
$ids = [];
foreach ($rows as $row) {
// log_hash left NULL; we will fill it below via SQL
$ids[] = (int) DB::table('tenant_operations_log')->insertGetId($row);
}
// Now fill log_hash for each inserted row using per-tenant-partitioned prev_hash,
// mirroring exactly what the trigger produces under RLS on prod:
// log_hash = digest(COALESCE(prev_tenant_hash, ''::bytea) || ROW(...)::text::bytea, 'sha256')
// where prev_tenant_hash = last log_hash of the SAME tenant ordered by id.
//
// We do this in id-order one row at a time so each hash feeds the next.
$idList = implode(',', $ids);
DB::statement(<<<SQL
WITH ranked AS (
SELECT
id,
tenant_id,
ROW(id, tenant_id, user_id, entity_type, entity_id, event,
payload_before, payload_after, ip_address, user_agent,
NULL::bytea, created_at) AS row_val,
ROW_NUMBER() OVER (PARTITION BY tenant_id ORDER BY id) AS rn
FROM tenant_operations_log
WHERE id IN ({$idList})
)
UPDATE tenant_operations_log tgt
SET log_hash = (
WITH RECURSIVE chain(id, tenant_id, rn, hash) AS (
-- Base: first row per tenant (rn=1), prev_hash = ''
SELECT r.id, r.tenant_id, r.rn,
digest(''::bytea || r.row_val::text::bytea, 'sha256')
FROM ranked r
WHERE r.rn = 1
UNION ALL
-- Recursive: each subsequent row chains off previous
SELECT r.id, r.tenant_id, r.rn,
digest(c.hash || r.row_val::text::bytea, 'sha256')
FROM chain c
JOIN ranked r ON r.tenant_id = c.tenant_id AND r.rn = c.rn + 1
)
SELECT hash FROM chain WHERE id = tgt.id
)
WHERE id IN ({$idList})
SQL);
} finally {
DB::statement('ALTER TABLE tenant_operations_log ENABLE TRIGGER USER');
}
// Verify all 5 rows got their hashes set (sanity check on the SQL above)
$nullCount = DB::table('tenant_operations_log')
->whereIn('id', $ids)
->whereNull('log_hash')
->count();
expect($nullCount)->toBe(0, 'All inserted rows must have log_hash set by the chain SQL');
// The per-scope validator must report INTACT — no false breach at tenant boundary
$this->artisan('audit:verify-chains')->assertSuccessful();
Mail::assertNothingSent();
});
// ===========================================================================
// Tampering detection
// ===========================================================================
@@ -500,3 +401,36 @@ test('exit code is FAILURE on breach even when no active admin exists for incide
// But email alert must still be sent
Mail::assertSent(AuditChainBreachMail::class);
});
// ===========================================================================
// ADR-021: audit hash-chain canonical is GLOBAL-within-partition for all tables.
// The trigger produces a global chain (verified on prod 2026-07-09 for all 4
// formerly-"per-tenant" tables incl. balance_transactions; also true on
// dev/superuser). Mixed-tenant rows inserted VIA THE TRIGGER must therefore
// verify intact — the old per-tenant validator (ADR-018) false-positived at
// every tenant boundary. Supersedes the per-tenant regression test above.
// ===========================================================================
test('cross-tenant rows chained by the trigger (global) verify intact — ADR-021', function () {
$t1 = ensureTenant(1);
$t2 = ensureTenant(2);
// Insert via the normal path (trigger fills log_hash). On dev/superuser the
// trigger chains GLOBALLY — exactly what prod produces. Interleave tenants so
// the chain crosses tenant boundaries (id2:t2 after id1:t1, etc.).
insertTenantOpsRow($t1, 'project.created');
insertTenantOpsRow($t2, 'api_key.regenerated');
insertTenantOpsRow($t1, 'project.updated');
insertTenantOpsRow($t2, 'project.created');
insertTenantOpsRow($t1, 'project.deleted');
// Under global scope (ADR-021) this is a valid intact chain → no breach.
$this->artisan('audit:verify-chains')->assertSuccessful();
$count = DB::connection('pgsql_supplier')
->table('incidents_log')
->where('summary', 'like', '%chain%tenant_operations_log%')
->count();
expect($count)->toBe(0);
Mail::assertNothingSent();
});
@@ -77,15 +77,36 @@ test('идемпотентность: повторный запуск не па
expect($afterSecond)->toBe($afterFirst);
// Output второго запуска должен сказать «0 created» по всем партиционированным таблицам × 6 месяцев
// (текущий + ahead=5). Число таблиц берём из PARTITIONED_TABLES — тест не ломается при добавлении новых.
$expectedSkipped = count(MonthlyPartitionManager::PARTITIONED_TABLES) * 6;
// Output второго запуска должен сказать «0 created» по всем партиционированным таблицам × 7 месяцев
// (behind=1 + текущий + ahead=5). Число таблиц берём из PARTITIONED_TABLES — тест не ломается
// при добавлении новых.
$expectedSkipped = count(MonthlyPartitionManager::PARTITIONED_TABLES) * 7;
$output = Artisan::output();
expect($output)->toContain("0 created, {$expectedSkipped} skipped");
});
test('--ahead=0 создаёт только текущий месяц', function () {
Artisan::call('partitions:create-months', ['--ahead' => 0]);
/*
* Прошлый месяц (12.07.2026). Нарезчик делал только «текущий + вперёд», поэтому на свежей
* базе партиции за прошлый месяц не было НИКОГДА. А система штатно пишет строки с датой в
* прошлом месяце: тридцатидневное окно списаний (метрика runway) и вставки на стыке месяцев
* (джоб стартовал 31-го в 23:59, вставил после полуночи с ранее взятым временем). На проде
* это не всплывало там партиции копятся с самого начала; падало в тестах в первой половине
* каждого месяца и упало бы в CI.
*/
test('создаёт партицию за ПРОШЛЫЙ месяц — окно последних 30 дней всегда в него попадает', function () {
$exitCode = Artisan::call('partitions:create-months');
expect($exitCode)->toBe(0);
$prevMonth = now()->startOfMonth()->subMonth();
$name = 'balance_transactions_y'.$prevMonth->format('Y').'_m'.$prevMonth->format('m');
$row = DB::selectOne("SELECT 1 AS x FROM pg_class WHERE relname = ? AND relkind = 'r'", [$name]);
expect($row)->not->toBeNull();
});
test('--ahead=0 --behind=0 создаёт только текущий месяц', function () {
Artisan::call('partitions:create-months', ['--ahead' => 0, '--behind' => 0]);
$currentMonth = now()->startOfMonth();
$name = 'deals_y'.$currentMonth->format('Y').'_m'.$currentMonth->format('m');
@@ -15,14 +15,13 @@ it('exposes all 6 audit tables', function (): void {
]);
});
it('activity_log uses PARTITION BY tenant_id', function (): void {
expect(AuditChainConfig::TABLES['activity_log']['partition'])
->toEqual('PARTITION BY tenant_id');
});
it('auth_log and saas_admin_audit_log use global chain (empty partition)', function (): void {
expect(AuditChainConfig::TABLES['auth_log']['partition'])->toEqual('');
expect(AuditChainConfig::TABLES['saas_admin_audit_log']['partition'])->toEqual('');
it('all 6 audit tables use global-within-partition chain (ADR-021, supersedes ADR-018)', function (): void {
// ADR-021: триггер де-факто пишет глобальную цепочку для ВСЕХ таблиц (проверено на
// проде 2026-07-09, включая balance_transactions). per-tenant scope ADR-018 никогда
// не был реализован → все partition-clause пустые (global-within-partition).
foreach (AuditChainConfig::TABLES as $table => $cfg) {
expect($cfg['partition'])->toEqual('', "table {$table} must use global chain");
}
});
it('rowExpression builds ROW(...) with NULL::bytea at __log_hash__ position', function (): void {
+4
View File
@@ -27,6 +27,10 @@
> **(4) Кнопка «Отказаться» на актуализации** (`43012c98`+`f888c4ba`): мягкое отклонение находки нового сайта/адреса — фирма поля остаётся, находка в архив, её ключи запоминаются у фирмы поля (**новая колонка `dismissed_actualize_keys`**, миграция `2026_07_07_130000` ролью `crm_migrator` — прошла на проде), `ProposalClassifier` их вычитает → та же находка больше не всплывает. Кнопка названа «Отказаться» (не «Удалить» — чтобы клиент не путал с удалением конкурента).
> **🔴 Деньги tenant 2:** мои тестовые прогоны + баг пустых списаний = ~260₽ его баланса; владелец сказал НЕ возвращать. Баланс 499 680₽, «+499 700 ручная корректировка» — админ (id=1), НЕ Claude. **Follow-up:** живая приёмка кнопки «Отказаться» под клиентом (нужна карточка актуализации); ротация пароля crm_migrator не требовалась. Память: `project-autopodbor-post-hotfix-fixes-2026-07-07`.
> 🏷️🔴→🟢 **АКТУАЛЬНО (12.07.2026) — КАБИНЕТ ПОСТАВЩИКА СТИРАЛ МЕТКУ КАНАЛА `B<n>_`, ПОЧИНЕНО И ВЫКАЧЕНО.** Повод — письмо робота итоговой проверки «нашёл расхождение» (11.07 и 12.07, kind=`missing`). **Тревога была ложной** — строки в кабинете были, включены, лимит верный. **🔑 Корень (главное знание про кабинет crm.lead.store):** кабинет дописывает метку канала к имени строки **ТОЛЬКО при СОЗДАНИИ** (`id=0`); при **обновлении** сохраняет имя ровно как прислали. Наш ночной `updateProject` слал голый ключ → каждый прогон 18:05 стирал метку. Два следствия: (1) `VerifySupplierOrderJob` выводил площадку из **префикса имени** → строка без префикса выпадала из `live` → ложное «нет заказа» (`live_rows` 60→57); (2) 🔴 **лид от такой строки приходил с `project` без метки** → webhook не мог определить канал → `supplier_leads.platform=DIRECT` вместо B1/B2/B3 (**сделки создавались и списывались верно, терялась только атрибуция канала**). На момент разбора **81 из 138** наших строк в кабинете были с потерянной меткой. **Фикс (gitea main `8c1d1710`, 3 файла оверлеем, миграций нет):** `SupplierPortalClient::toPayload` — на update имя уходит **с меткой** (на create остаётся голым — метку ставит кабинет, а один save с 3 флагами рождает 3 строки); `VerifySupplierOrderJob::normalizeLive` — площадка берётся из служебного поля **`src`** (`rt`→B1, `bl`→B2, `mt`→B3), а НЕ из имени; новая команда **`supplier:repair-project-names`** (`--apply`, `--only-active`; по умолчанию сухой прогон; payload собирается **из живой строки кабинета** — меняется только `name`, регионы оттуда уже в кодах поставщика и повторно НЕ переводятся). **Выкат:** предполёт 8/8 GREEN, `queue:restart` + reload php-fpm (**`config:cache` НЕ трогали**, квирк 107). **Результат на бою:** починена **81 строка, 0 ошибок**; робот прогнан вживую → **138 наших строк вместо 57, расхождений 0**; двум лидам (368, 372) восстановлен канал (B3 / B1) — вытащен из журнала выдач поставщика по `vid` уже ПОСЛЕ починки имён (в самом webhook-payload канала нет). Успели до снимка поставщика 21:00 МСК. **Откат:** `/var/www/liderra/_bak-namefix-20260712-160742`. 🟢 Закрыт follow-up 10.07 — фича итоговой проверки **сведена в `main`**. Память: `project-supplier-channel-label-strip-fix-2026-07-12`.
> 🧹🟢 **АКТУАЛЬНО (12.07.2026) — ХВОСТЫ ПРОГОНА + СВЕРКА «main == БОЕВОЙ».** (1) **`partitions:create-months` не нарезал ПРОШЛЫЙ месяц** — делал только «текущий + вперёд», поэтому на свежей базе партиции за прошлый месяц не было никогда, и INSERT падал `no partition found` **в первой половине КАЖДОГО месяца** (окно списаний 30 дней, стык месяцев). Добавлен флаг **`--behind`, по умолчанию 1**; выкачен на прод — **поведение боевого НЕ изменилось** (`0 created, 40 skipped`), партиции там нарезаны до **2027-01**, крон 21:00 UTC жив. (2) Возвращены **три аудит-теста ADR-021**, потерянных при сведении main 09.07 (код уехал, тесты — нет; **тот же класс потери, что `CsvReconcileJobTest`** — проверять после каждого сведения веток). (3) Полный прогон **2407 → 2417 из 2418**; единственное падение `ExampleTest` — только там, где не собран фронт (`Vite manifest not found`), к коду отношения не имеет. (4) **Larastan: 30 замечаний, ни одного живого бага** (уровень типов); 🔑 локальный счётчик зависит от способа генерации заглушки — правильный `ide-helper:models --write-mixin -n`, неправильный `-N` даёт сотни ложных. **✅ СВЕРКА ПРОВЕДЕНА:** боевой ↔ `gitea/main` по хэшам каждого файла бэкенда — **569/569 совпали побайтово, различий 0**; лишние на бою 5 файлов = служебные кэши `bootstrap/cache/*` (в гите их и не должно быть). `gitea/main` = **`7feab849`**. Память: `project-main-tails-cleanup-2026-07-12`.
**Снимок снят:** 29.06.2026 (~11:00 UTC) — **💳🟢 ВЫКАЧЕНА фича «ОПЛАТА ПО СЧЁТУ» (Этап 1) на боевой liderra.ru.** По командам владельца «ок коммить и пуш, обнови память и деплой только со скилом». **Что выкачено (gitea main `cdfae077`, деплой `bin/deploy-source-edit.sh`):** клиент в кабинете Биллинг → отдельная вкладка **«Счета»** + в «Пополнить» способ «По счёту (для юрлиц)» → формирует **PDF-счёт** по своим реквизитам; админ **`/admin/invoices`** (новый пункт меню «Счета») отмечает оплату одной кнопкой → атомарно зачисляет баланс + формирует **Акт** (без НДС, УСН) + шлёт клиенту письмо «Счёт оплачен» **с вложением PDF-акта**; PDF открываются inline. Просрочка `invoices:expire` (cron 03:40 МСК). Наименование услуги — «**Оплата генерации рекламных лидов**». Зависимость **dompdf** установлена на проде, **«Nothing to migrate»** (схему БД не меняли — таблицы saas_invoices/items/upd уже были). **Реквизиты ИП заполнены в боевом `legal_entities` id=1** (`ip_kondratev`, is_default): банк **Филиал «Центральный» ВТБ (ПАО) Красноярск**, р/с **40802810690810008032**, корр 30101810145250000411, БИК 044525411, ОКПО 2052921109, адрес Красноярск ул.Новосибирская 5-97 (было bank=null). **Предполёт 8/8 GREEN** (prod-deploy-validator), финиш через superpowers:finishing-a-development-branch. **Проверка:** приложение live, HTTPS /login 200, новых ошибок в логе нет, **деньги целы t2 = 1 839 210 ₽ / 1019 сделок**, счетов на проде 0 (чисто, клиенты ещё не выставляли). **ЮKassa для оплаты по счёту НЕ годится** (её B2B только через Сбербанк, обе стороны в Сбере; наш ИП в ВТБ) — деньги по счёту всегда идут прямой платёжкой на р/с ВТБ. **Этап 2** (автозачисление через ВТБ API «Интеграционный Банк-Клиент», только poll, без вебхуков) — отдельно, после вопросов менеджеру ВТБ. Спека/план — `docs/superpowers/specs|plans/2026-06-29-oplata-po-schetu-*`. Память: `project-oplata-po-schetu-etap1`, `reference-ip-requisites-kondratev`.
**Снимок снят:** 28.06.2026 (~13:30 UTC) — **🔍🟢 СКВОЗНАЯ СВЕРКА БАЙТ-В-БАЙТ прод↔git↔бэкап + экран «Тенанты» на 1000 + фронт-стенд в зелёный. Прод приведён к git 1:1.** По командам владельца «сверь прод/локалку/бэкап байт-в-байт» и «да» (выкатить). **Сверка (метод: на Linux `rsync -rclni` checksum, LF без CRLF-шума, свежий клон gitea-main ↔ `/var/www/liderra/app` ↔ распакованный `/tmp/app-backup-0907.tgz`):** исходники прода = **gitea-main `84dfbc85` один-в-один** (контент-расхождений 0 после выката тест-правок); бэкап цел, разница прод↔бэкап = ровно выкат «Тенанты»; **потерянных/изменённых правок НЕТ.** На проде сверх git только не-git артефакты: `*.bak-precutover` ×2 (страховка отката Managed PG — **держать до ~03.07**), `bootstrap/cache/*` ×5 (кэш Laravel, генерится), `tests/Frontend/menuRepositionFix.spec.ts` (старый удалённый тест, висит — деплой без `--delete`; мусор, в рантайме не участвует). **Выкачено в этот заход:** (1) экран «Тенанты» на серверную пагинацию/поиск/фильтры (статус производный CASE + тариф) — демо показано владельцу локально, прод `c92d498b`; (2) фронт-тест-стенд приведён в зелёный — 22 протухших теста в 10 спеках обновлены под актуальные компоненты (только тесты, компоненты не тронуты), `84dfbc85`, выкачены чтобы прод==git. **Полный прогон фронта: 127 файлов / 992 теста зелёные.** **Деньги целы: t2 = 1 839 405 ₽ / 990 сделок** (деплои код-только, БД не тронута). Сайт 200. Рецепт сверки на будущее: rsync-checksum на проде / `git hash-object`, НЕ raw-sha с Windows (CRLF врёт). Кодовая фраза стены — «роутер-наставник».