Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a51d34cc5 | |||
| 7feab849a6 |
@@ -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 {
|
||||
|
||||
@@ -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 врёт). Кодовая фраза стены — «роутер-наставник».
|
||||
|
||||
Reference in New Issue
Block a user