Compare commits

...

131 Commits

Author SHA1 Message Date
Дмитрий 9d4a30c314 docs(pilot): snapshot 25.05.2026 (день+1) — saas-admin nginx-gate + drift-fix на проде
Два commits на main выкачены на боевой liderra.ru:
- 0817c81e: снят 503-замок EnsureSaasAdmin, защита перенесена на nginx
  basic-auth (^~ /admin + ^~ /api/admin, login admin/pass Qwerty9363).
  Закрывает класс «вся админка 503 на проде» (ждала Б-1+DO-4 SSO).
- 3eb6c7fe: schema v8.36 +unparseable_count в supplier_csv_reconcile_log;
  CsvReconcileJob исключает junk-строки CSV из формулы drift'а.
  Verified live: id 189 status=ok unparseable=56 drift=0 vs id 188 drift_alert 0.448.

Open issue: EnsureSaasAdmin.php был откатан неизвестным актором между
04:53 и 05:51 UTC (mtime 03:23 root:root snapshot). Cron/deploy-script
не найдены. Re-deploy 05:56 устойчив. Мониторить.

+7 слов в cspell-words.txt (стопгэп/досылает/creds/опкэш/гэп/misowned/деплоями).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 09:05:14 +03:00
Дмитрий 3eb6c7fecd fix(supplier): убрать false-positive drift_alert от мусора в CSV (Спек A)
CsvReconcileJob каждый час стабильно ставил drift_alert ~40-50% (10 запусков
подряд на проде → admin-блок «Здоровье резервного канала» показывал «down»),
потому что поставщик crm.bp-gr.ru кладёт телефон/URL в поле «project» CSV.
Парсер extractPlatform() корректно их скипал, но строки оставались и в
count(missing), и в total_csv_rows формулы drift'а → стабильный false-positive.

Фикс (вариант A из брейнсторма с заказчиком):
- schema v8.36: +supplier_csv_reconcile_log.unparseable_count INTEGER NOT NULL DEFAULT 0
- CsvReconcileJob: считает $unparseableCount отдельно, новая формула
  drift = max(0, missing − unparseable) / max(1, total − unparseable)
- Миграция (pgsql_supplier, Спек B pattern, IF NOT EXISTS — idempotent)
- TDD: +2 теста (100matched+10junk → ok; mixed 95+5junk+3real → drift по реальным).
  Существующие 7 кейсов GREEN без изменений (unparseable=0 → формула идентична).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 08:38:31 +03:00
Дмитрий 0817c81e67 fix(admin): снять 503-замок saas-admin зоны — защиту держит nginx basic-auth
EnsureSaasAdmin fail-closed 503 вне dev/testing → вся админка на боевом
liderra.ru недоступна (все /api/admin/* падали). Настоящий saas-admin SSO
(Yandex 360) ещё не готов (Б-1 + DO-4), но держать зону наглухо закрытой
нельзя — заказчику нужна админка.

Стопгэп (выбор заказчика): защита /admin + /api/admin/* переносится на
nginx (отдельный HTTP Basic Auth, /etc/nginx/.htpasswd-admin), middleware
зону больше не закрывает. Тест production-кейса переведён с 503 на 200.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 07:35:03 +03:00
Дмитрий b2cbc57533 docs(brain): spec v2.3 + plan v1.2 — coverage announcement (§4.9) + decision confirmed
Coverage announcement — новая фича прозрачности (brainstorm 2026-05-25):
заказчик всегда видит чем покрыта задача (skill/node/hook/direct) в прозе
+ TodoWrite. Источник — classifier_output. Enforcement через economy-mode
reminder (economy сохраняется). Flag coverage-annotation-mode (10-й).
Тайминг — в составе overhaul Phase 3.

spec v2.2 → v2.3:
- §4.9 (новое) Coverage announcement — 2 поверхности, формат пометки,
  источник, enforcement, flag, тайминг, откат.
- §10 flags table +coverage-annotation-mode (9→10 флагов).
- §11.3 Phase 3 +task coverage announcement.
- §23 (новое) changelog v2.2→v2.3.

plan v1.1 → v1.2:
- DECISION POINT  ПОДТВЕРЖДЁН: economy keep / §12 remove.
- Task 19 +step 8 coverage announcement (economy-mode reminder + Pravila §17
  подпункт + memory feedback_coverage_announcement + flag).

brainstorm Q&A: Q1 поверхности=проза+TodoWrite; Q2 состав=skill+node+hook+direct;
Q3 enforcement=convention+reminder; Q4 тайминг=в составе overhaul.

НЕ исполняется (per user — план).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 07:31:33 +03:00
Дмитрий 7d31d0be39 docs(brain): plan v1.1 — откат мозга первым + 9 пробелов 0%-аудита
v1.0 → v1.1 после полного 0%-аудита плана.

ГЛАВНОЕ: Task 1 = «Откат мозга» — полная инфра + snapshot user-level
(~/.claude/settings.json + hooks/*.py + runtime flags) + dry-run +
END-TO-END SMOKE (тривиальная правка → откат → verify) ДО любой
деструкции. Если откат не зелёный — дальше не идём.

9 закрытых пробелов:
- G3 (КРИТИЧЕСКОЕ): user-level hooks смешивают economy-mode (0%/5%/100%,
  СОХРАНИТЬ) и §12 skill-discipline (СНЯТЬ). Task 2 разделяет: snimaet
  skill-marker.py+skill-check.py, оставляет economy-*.py, чистит §12 из
  economy-state-guard.py + economy-mode.py. DECISION POINT для заказчика.
- G16: brain-retro-opus-reviewer.mjs НЕ существует → Task 18 CREATE
  (не «keep from v2.0» как было в v1.0/spec).
- G11: router-accuracy-runner.mjs:11 import classifyByRegex сломается →
  Task 10 чинит на regex-fallback модуль.
- G14: registry-to-classification-map.mjs нейтрализуется (Task 4).
- G8/G9: C1/C2 адаптируются рано (Task 6), чтобы lefthook не блокировал
  коммиты Task 7-15.
- G5: parser forward-compat к v4 эпизодам после отката (Task 15).
- user-level rollback + episodes preservation в test-rollback.mjs/ROLLBACK.md.

Прочее: test-rollback.mjs использует execFileSync (не execSync — без shell,
без инъекции). 21 задача (было 22 в v1.0, консолидация rollback в Task 1).

Self-review: spec coverage + 16 находок v2.2 + 9 находок аудита плана,
type consistency, 0 placeholders.

НЕ исполняется сейчас (per user «только план»).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 06:31:11 +03:00
Дмитрий 2b7a71c5b6 docs(brain): implementation plan фазы 1+2+3 LLM-first router overhaul
План из spec v2.2 — 22 задачи (Task 0 pre-flight + 8 phase-1 + 8 phase-2
+ 6 phase-3) в bite-sized TDD-формате.

Phase 1 (foundation+archive): tag + archive scaffold + inventory hooks +
discipline-metrics decision + test-rollback.mjs (TDD) + archive §12/routing-
docs/memory + §17+ADR-016 + cross-refs §12→§17 + flags+ROLLBACK.md.

Phase 2 (classifier): router-config + nodes.yaml capabilities + prefilter
3 группы/manual-override/anchor (TDD) + Sonnet 4.6 classifier+памятка (TDD)
+ missed-activations на nodes.yaml (TDD) + embedding (TDD) + §17 gate (TDD,
D1 continuation-not-exempt) + prehook inheritance+cost (TDD) + parser v4.0
+ C1/C2 adapters + warmup hook + flags flip.

Phase 3 (evidence loop): Stop-hook execution_trace+chain_gaps+inheritance-
copy (TDD) + self_assessment (TDD) + reviewer subagent verify + direct-API
fallback handler + sanity-generator + brain-retro v2 procedure + self-
retrospect skill + analyzer v4 + status-md cost sections + schema v4.3 +
final flags + rollback dry-run verification.

Self-review: spec coverage (8 слоёв + §17 + 16 находок v2.2), 0 placeholders
(кроме намеренного model-ID резолва Task 0/9), type consistency проверена.

Реализация — после Биллинга v2 Спек C. Фаза 4 (distillation) — отдельный
план через ~6 месяцев. НЕ исполняется сейчас (per user).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 06:08:08 +03:00
Дмитрий af441961d9 fix(router): LLM Layer 2 через ProxyAPI с отдельным ключом ROUTER_LLM_KEY
router-classifier больше не ходит в недоступный api.anthropic.com и не читает ANTHROPIC_API_KEY (это перехватывало основную сессию Claude Code с подписки). callAnthropicAPI теперь ходит в ProxyAPI по умолчанию, ключ берёт из отдельной ROUTER_LLM_KEY, базовый URL — ROUTER_LLM_BASE_URL (опционально). Нет ключа → Layer 2 тихо выключен, откат на regex. +6 тестов (30/30 GREEN).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 06:07:02 +03:00
Дмитрий 2ec8707a03 docs(pilot): 25.05 — incident supplier:session РАЗРЕШЁН (бага нет, auto-refresh работает)
TTL-арифметика 2 cron-тиков (implied write 02:01:09 + 03:01:08 = journalctl
DONE) доказала: cron обновляет сессию каждый час. Ранний вывод «zombie lock»
был ошибкой замера. Root cause 3-дневного простоя — worker бежал со stale
--timeout=60 (timeout.conf=300 от 22.05 не подхвачен без рестарта); recycle
00:18 UTC подхватил правильный конфиг → самовосстановление.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 06:04:09 +03:00
Дмитрий 81f52fd1c6 docs(brain): spec v2.2 — закрыты 16 находок 0%-аудита
v2.1 → v2.2. Полный critical audit (экономия 0%) нашёл 16 пробелов;
все закрыты. Новый §22 — changelog с таблицей всех находок.

Критическое (1):
- D1: §17 formal text (§6) включал Continuation в exempt-список direct,
  но Continuation наследует non-conversation classification → §17 enforce
  должен применяться. §6 п.4 переписан: exempt только Acknowledgment +
  Cancellation; Continuation — нет (NON_BLOCKING_TASK_TYPES не содержит её).

Внутренние несогласованности (6):
- A1: schema alternative_better типизирован только null → заметка string|null.
- A2/C3: task_cost reviewer tokens vs usd — взаимоисключающи по пути review.
- A3: §11.3 step 3.2 «создать reviewer-agent.md» → файл уже создан (49aa4ba7).
- A4: prompt-enrichment risk добавлен в §14.
- A5/B3: missed-activations.mjs читает archived map → adapter task §11.2 task 9
  (переключение на nodes.yaml) + §9.5 detail + §14 risk.
- A6: §18.7 «reviewer-agent будет добавлен» → уже добавлен.

Underspecified (5):
- B1/B5: inheritance state→episode chain описан (§4.1 проверка 2).
- B2: reviewer fallback path = brain-retro-opus-reviewer.mjs сохраняется из v2.0.
- B4: discipline-metrics.mjs keep/remove → task §11.1 task 17.

Typo / ellipsis (4):
- C1: «existed» → «создана» в DoD.
- C4: «ОНLY» (кириллица) → «ONLY» в reviewer-agent.md.
- C5: anchor list финализирован (28 существительных + 10 императивов, ellipsis убран).

Сравнение аудитов: v1.0 имел 35 находок, v2.1 — 16, обе волны закрыты.
Остаточные implementation-вопросы (model ID, clustering algo) — в §15,
решаются на старте фаз.

Реализация — после закрытия Биллинга v2 Спек C.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 06:02:33 +03:00
Дмитрий 455bc1439b docs(pilot): 25.05 день — Биллинг v2 Спек C Phase 1 backend готов на ветке (прод НЕ затронут)
Снимок-заметка: feature-фаза, на боевой liderra.ru ничего не выкачено.
Ветка feat/billing-v2-spec-c HEAD d8955f57 (9 ahead of main).
Phase 1 preflight баланса: заморозка cut-off 18:00 MSK + 409 при
перегрузке + 4 письма + initial-sweep. Schema v8.36. Pest 13/13.
Осталось: Task 1.10 frontend + Phase 2-5 (VTB безнал).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 05:54:31 +03:00
Дмитрий 000c196e51 docs(pilot): 25.05 утро — recovery supplier:session (3 дня тишины у Компании 1)
Корень: Redis ключ liderra-database-liderra-cache-supplier:session был пуст
во всех DB. SupplierPortalClient не мог авторизоваться → CSV recovery=0,
sync 7 дней пустой, поставщик не активировал выдачу.

Hot-fix: dispatchSync через cat-pipe tinker stdin (quirk #109 workaround).
TTL 5ч59м. CsvReconcileJob drift=0.425 — false-positive (мусор в project field).

scheduler+worker+cron конфигурация правильная (timeout=300, hourly entry,
liderra-queue active, /etc/cron.d/liderra-scheduler каждую минуту).
journalctl показал DONE каждый час, но Redis пуст — вероятно zombie lock
после 22.05 worker-крахов. Точный root cause не установлен; auto-verify
в 02:00 UTC.

Все 275 lead_charges Компании 1 = prepaid (старая схема); ни одного
charge_source=rub за всю историю прода. Биллинг v2 Phase A ждёт первого
нового лида чтобы впервые применить tier-lookup живьём.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 04:32:07 +03:00
Дмитрий 49aa4ba725 docs(brain): spec v2.1 + reviewer-agent — 16 правок после code review
v2.0 → v2.1 — 3 группы изменений (16 пунктов суммарно):

Группа 1 — решения принятые после v2.0, не внесённые:
- 1.1 Памятка classifier (4 паттерна: brainstorming / discovery-interview /
  writing-plans / systematic-debugging). +flag prompt-enrichment-mode.
- 1.2 Reviewer как полноценный Claude Code subagent (tools=[Read,Grep,Glob,
  Skill], model=opus). Новый файл .claude/agents/reviewer-agent.md.
  +стоимость $240-1200/мес vs $40-80 direct API. Crash fallback на direct
  API. Context bloat cap 10 соседних эпизодов.
- 1.3 Inheritance + 3 группы коротких prompt'ов (continuation/acknowledgment/
  cancellation) + 30-минутный таймаут. +flag inheritance-mode. Новые поля
  в schema v4.1: inherited_from_task_id, inheritance_age_minutes,
  previous_direction_rejected, previous_task_id_rejected.

Группа 2 — edge cases:
- 2.1 Reviewer model явно opus в agent file.
- 2.2 Reviewer subagent crash → fallback direct API call.
- 2.3 Reviewer context bloat: max 10 episodes в agent system prompt.
- 2.4 Manual override приоритет №1 в prefilter (раньше inheritance).
- 2.5 Cancellation clears state + previous_task_id_rejected marker.

Группа 3 — мелкие упущения:
- 3.1 brain-retro SKILL.md description: раз в 1-2 недели (не sprint).
- 3.2 recommended_chain_id nullable для custom chains.
- 3.3 Embedding только для non-prefilter эпизодов.
- 3.4 PII filter wraps sanity-check comments.
- 3.5 requested_node fuzzy matching fallback.
- 3.6 Anchor word list inline initial.
- 3.7 Self-retrospect counter init в фазе 3 step 3.3.
- 3.8 Sanity-check answer file schema_version=1.

Cost rewrite: 720-1380 USD (v2.0) -> 1940-8200 USD (v2.1) на 6 месяцев
из-за reviewer subagent. Granular rollback через reviewer-mode=direct-api
возвращает к v2.0 ценам.

§21 новый — changelog v2.0 → v2.1 со всеми 16 пунктами и где правка.

Реализация — после закрытия Биллинга v2 Спек C.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 04:23:33 +03:00
Дмитрий 10eed4e7e4 docs(brain): spec v2.0 LLM-first router overhaul
Brainstorming сессия 2026-05-24..25:
- LLM-first классификатор (Sonnet 4.6) заменяет regex Layer 1.
- §17 «universal skill-coverage» заменяет §12 (default-deny кроме
  conversation/micro/manual_override/escape-hatch).
- Opus 4.7 ревьюер в /brain-retro заполняет review.* (auto, не пользователь).
- Self-retrospect skill (opt-in) для самоанализа агента.
- 7 гранулярных flag-переключателей + dry-run rollback.
- 3 implementation фазы (~3.5-4.5 недели) + 5-6 месяцев passive
  evidence collection + фаза 4 distillation regex из эмпирики.

v1.0 содержал 8 фактических ошибок (несуществующие skill-discipline
файлы, отсутствующие nodes.yaml поля, L1-L16 в неверных файлах) +
11 пропусков охвата + 10 underspecified + 6 противоречий. v2.0 —
полная перезапись с реальным state inventory (§18) и explicit
accepted trade-offs (§19).

Реализация — после закрытия Биллинга v2 Спек C (per user decision).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 03:27:02 +03:00
Дмитрий af6c328933 docs(billing-v2-c): спек C + план реализации (preflight + VTB)
Спек: preflight баланса на cut-off 18:00 MSK (защита от заказа лидов
клиентам без денег) + VTB-эквайринг через TopupGateway-интерфейс
(безнал полный, СБП/карты dev-заглушки до Б-1).

План: 6 фаз TDD-разбивкой, ~30 задач, subagent-driven-development
с git-verify-протоколом per Pravila §15.1.

Брейнсторм 24.05.2026, реализация стартует 25.05.

Lint: гибрид «Преfflight»→«Префлайт» (опечатка предыдущей сессии),
+6 терминов в cspell (Atol/uniqid/ОФД/брейнсторме/префлайт/Префлайт).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 03:09:07 +03:00
Дмитрий 2e2abe0e53 docs(pilot): legacy webhook removal — выкачено на боевой 24.05 (инцидент + fix + retry + smoke OK)
Phase 6 deploy. 13 commits + fix d377d977 чужей миграции. Инцидент 15:52 UTC →
rollback c7f603aa → fix + повторный deploy → миграция применилась за 57.85ms.
БД: webhook_log/rejected_deals_log/tenants.webhook_token* DROPPED;
webhook_dedup_keys ЖИВА. Портал HTTP 200, шеринг-канал жив.

+3 слова в cspell-words.txt (pre-existing typos в старых snapshot'ах).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 19:54:55 +03:00
Дмитрий d377d97737 fix(migration): 2026_05_22_000002 — use pgsql_supplier connection (owner-rights fix)
Миграция падала на проде:
  SQLSTATE[42501]: Insufficient privilege: must be owner of table webhook_log

Причина: default connection 'pgsql' (crm_app_user) не имеет owner-прав на
webhook_log (owner — crm_migrator). Заменено на 'pgsql_supplier'
(BYPASSRLS-роль crm_supplier_worker) — паттерн Спека B Phase 1 (commit 546ca30a),
который выработан ровно под эту проблему prod-ролей.

Эта миграция блокировала выкатку legacy-webhook-removal (Phase 6 deploy
24.05.2026, отменено rollback'ом). После fix миграция применится
no-op (webhook_log будет дропнут моей миграцией 2026_05_24_140000
сразу после).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 19:29:05 +03:00
Дмитрий 6262639904 chore(stan): Phase 5b — regen baseline after SupplierWebhookLoggingTest deletion
Remove 2 stale SupplierWebhookLoggingTest.php entries from phpstan-baseline.neon.
3 remaining unmatched inline @phpstan-ignore-next-line are pre-existing
(SupplierProjectGrouping/SupplierConnectionTest/Pest.php, present in origin/main).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 18:51:18 +03:00
Дмитрий af690eaaaa refactor(webhook): Phase 5 — delete SupplierWebhookLoggingTest (tests dropped webhook_log table)
SupplierWebhookLoggingTest.php queried webhook_log table which was dropped
in Phase 4 DROP migration (schema v8.35). This file was missed in Phase 3
cleanup (WebhookReceiveTest.php was deleted but SupplierWebhookLoggingTest
was a separate file testing the same dropped infrastructure).

4 tests deleted — all tested webhook_log INSERT/SELECT which is now gone.
SupplierWebhookTest.php (new controller tests) remains unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 18:51:18 +03:00
Дмитрий 04aed13bc4 chore(stan): Phase 5 — regen baseline after legacy webhook removal
Remove stale PdErasureService empty.variable ignore (no longer reported).
3 remaining unmatched inline @phpstan-ignore-next-line in SupplierProjectGrouping/
SupplierConnectionTest/Pest.php are pre-existing (present in origin/main).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 18:51:17 +03:00
Дмитрий 6e1f5355b8 refactor(webhook): Phase 4 — DROP migration + schema v8.35 + test/factory cleanup
Task 4.1 Steps 1–7: legacy direct webhook channel DDL removal.

Migration 2026_05_24_140000_drop_legacy_webhook_artefacts:
- DROP TABLE webhook_log CASCADE (partitioned RANGE по received_at)
- DROP TABLE rejected_deals_log CASCADE
- ALTER TABLE tenants DROP COLUMN webhook_token, webhook_token_rotated_at
- DELETE FROM system_settings WHERE key = 'low_balance_threshold_leads'
NB: webhook_dedup_keys ОСТАВЛЕНА — используется CSV-каналом (HistoricalImportService).

Services fixed (не покрыты Phase 3):
- MonthlyPartitionManager::PARTITIONED_TABLES — убрана строка webhook_log
- PdErasureService::eraseSubject() — убрана секция 4 (SELECT/UPDATE webhook_log)

Factory + tests cleanup (webhook_token column gone):
- TenantFactory: убрано webhook_token из definition()
- 7 test files: убраны вставки webhook_token в DB::table('tenants')->insert(...)
- storage/_demo_split_tenants.php: убрана строка webhook_token

Schema v8.35:
- −2 таблицы (webhook_log partitioned + rejected_deals_log)
- −5 индексов (idx_webhook_log_*, idx_rejected_*, idx_tenants_webhook_token)
- −2 RLS-политики
- db/CHANGELOG_schema.md: запись v8.35

Tests updated:
- SchemaDeltaTest: 66 base tables / 120 indexes / 40 RLS policies
- PartitionsCreateMonthsTest: webhook_log убрана из regex / 48 skipped вместо 54

Smoke: 36/36 passed (RlsSmoke, AdminBilling, AdminPdSubject, PartitionsCreateMonths, SchemaDelta).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 18:51:17 +03:00
Дмитрий dffefe7fc0 docs(billing): Phase 3 cleanup — refresh orphan comments to live classes
After ProcessWebhookJob/WebhookReceiveController removal — обновлены 8
docblock/inline комментариев, ссылавшихся на удалённый код:

- DealController: ProcessWebhookJob → SupplierWebhookController/RouteSupplierLeadJob
- SupplierWebhookController: убрана legacy backward-compat note
- ImportLeadsJob: паритет с RouteSupplierLeadJob
- RouteSupplierLeadJob: убрана ссылка на ProcessWebhookJob-pattern
- NewLeadNotification mailable: триггер в RouteSupplierLeadJob
- FailedWebhookJob model: ссылка на RouteSupplierLeadJob::failed()
- SupplierLeadCost model: создаётся в LedgerService::chargeForDelivery
- CsvLeadsParser: паритет с RouteSupplierLeadJob парсером

Code-функциональность не затронута, только doc-rot fix.
2026-05-24 18:51:16 +03:00
Дмитрий d3ed266830 chore(stan): Phase 3 - regenerate phpstan-baseline.neon (remove stale WebhookReceiveTest.php entries) 2026-05-24 18:51:16 +03:00
Дмитрий e5eed0aeac refactor(webhook): Phase 3 Task 3.1 fixup - delete WebhookReceiveTest.php (missed in Task 3.1+3.2 commit) 2026-05-24 18:51:15 +03:00
Дмитрий c71d830375 refactor(webhook): Phase 3 Task 3.6 - delete LowBalanceNotification + ZeroBalanceNotification mailables and blade templates 2026-05-24 18:51:15 +03:00
Дмитрий 58d0561bb7 refactor(webhook): Phase 3 Task 3.5 - remove notifyLowBalance/notifyZeroBalance from NotificationService 2026-05-24 18:51:14 +03:00
Дмитрий 220fc6e9c9 refactor(webhook): Phase 3 Task 3.4 - delete RejectedDealsLog model (all callers removed in Phase 2) 2026-05-24 18:51:14 +03:00
Дмитрий b75a677d12 refactor(webhook): Phase 3 Tasks 3.1+3.2 - delete WebhookReceiveController + remove POST /api/webhook/{token} route 2026-05-24 18:51:13 +03:00
Дмитрий 281c4ca5ce refactor(webhook): Phase 3 Task 3.0 - remove webhook_token from Tenant fillable/casts (factory+tests deferred: col still NOT NULL) 2026-05-24 18:51:13 +03:00
Дмитрий ebca32a212 refactor(billing): Phase 2 — remove legacy ProcessWebhookJob + cascade test cleanup
Удалён рудимент pre-sharing эпохи:
- app/app/Jobs/ProcessWebhookJob.php (job целиком, 342 строки)
- app/tests/Feature/ProcessWebhookJobTest.php (тест целиком, 362 строки)

Каскадная чистка 4 тест-файлов:
- BalanceNotificationsTest: -128 строк (оставлены topup_success/invoice_paid)
- InAppNotificationTest: -168 строк (остался notifyInApp direct)
- NewLeadNotificationTest: целиком удалён (-199 строк)
- DealCreatePdLogTest: -36 строк webhook-кейса (остались API+Route)

Локальный smoke (7 тестов без --parallel): 7 passed / 20 assertions.

Phase 2 плана 2026-05-24-legacy-direct-webhook-removal.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 18:51:12 +03:00
Дмитрий c7f603aa75 feat(brain): register project-agents delegation rule (Pravila §2.4 + CLAUDE.md §3.9 + registry #84/#85)
Level 1 + Level 2 of agent auto-invocation:

Level 1 — нормативный контракт:
- Pravila §2.4 (new) — controller MUST delegate to project agents:
  * normative-sync (#84) after big task closure (4-file sync trigger)
  * prod-deploy-validator (#85) before any liderra.ru deploy
  * pest-parallel-debugger / rls-reviewer — prior project agents formalized in same table
- CLAUDE.md §3.9 (new) — operational map index of all 4 project agents

Level 2 — наблюдатель (missed-activation detector):
- docs/registry/nodes.yaml +#84 normative-sync, +#85 prod-deploy-validator
  с subcategory: "project-agent" + agent_file: attribute
- triggers.classification: "normative_sync_needed" / "prod_deploy_imminent"
  автоматически подхватываются registry-to-classification-map.mjs runtime;
  deprecated observer-classification-map.json не правится.
- tools/registry-load.test.mjs fixtures: 83→85 / 75→77 active

Tooling канон счётчиков НЕ изменился (#1-#83 остаётся; project-агенты вне Tooling).

Spec: docs/superpowers/specs/2026-05-24-controller-offload-agents-design.md.
Headers: Pravila v1.39→v1.40, CLAUDE.md v2.27→v2.28.

Level 3 (hooks) — defer; level 1+2 покрывают первый раунд автоматизации.

Also: +6 cspell words for new vocabulary in normative paragraphs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:10:28 +03:00
Дмитрий 9fa5ca1a86 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	cspell-words.txt
2026-05-24 16:14:26 +03:00
Дмитрий 9bc090fbc3 fix(agents): prod-deploy-validator — real prod paths + sudo on П2 + UTF-8 fix
First functional smoke revealed 3 mismatches between agent's templated commands
and the real liderra.ru server layout:

1. App path: /var/www/liderra.ru/app/ → /var/www/liderra/app/ (no .ru segment).
   Affected lines: П1, П2, П5, П8 + smoke command examples.
2. Backups path (П4): /var/backups/db/ → /home/ubuntu/backups/.
3. П2 `file .env` — needs sudo (ubuntu user lacks read on .env);
   green criterion changed from "ASCII text" to "no CRLF substring"
   (UTF-8 is normal when .env has Cyrillic comments).

Without these fixes the agent would issue false NO-GO on every real run
(paths fail / sudo denied) — surfaced by smoke per design ("unexpected
format → escalation, never guess"). Captured in memory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 16:02:45 +03:00
Дмитрий be8f582a50 docs(continuity): stage 3 follow-up закрыт — 3 fixes + STATUS regen
3 fixes done in worktree feat/router-stage3-three-fixes:
- d7d8c5e helper readStdinAsUtf8 (StringDecoder, 4 tests)
- c7e02ee 3 хука прокинуты через helper (3 placeholder тестов)
- 593f12a observer-state-enricher helper (9 tests, +empty-string guard)
- 92bbd64 parseTranscript enrichment (2 tests, 4 fields в primary_rationale)

Final regression: 538/538 tools GREEN. gitleaks 0/1490 commits.
warn-only mode сохранён. CHECKPOINT B на следующих сутках работы.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 16:01:41 +03:00
Дмитрий 224a048e56 Merge remote-tracking branch 'origin/main' into feat/router-stage3-three-fixes 2026-05-24 16:00:06 +03:00
Дмитрий 92bbd64eed feat(observer): обогащение primary_rationale из router-state (Task 3)
- parseTranscript получает третий параметр options = {}
- options.routerStateBaseDir пробрасывается в readRouterState
- recommended_node: router-state переопределяет classification-map
- новые поля: recommended_chain, chain_progress, chain_completed
- 2 новых теста (enrich + fallback), 538/538 tools GREEN

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 15:53:59 +03:00
Дмитрий 593f12ae6a feat(observer): state enricher helper для эпизодов (stage 3 follow-up 2)
readRouterState(sessionId, {baseDir}) -- pure read state-файла сторожа.
extractRouterFields(state) -- pure извлечение 4 полей для primary_rationale.

Используется парсером эпизодов на следующем шаге (Task 3).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 15:45:43 +03:00
Дмитрий e3ec24462a feat(agents): add prod-deploy-validator project agent (8 SSH checks, Sonnet 4.6)
Pre-flight validator before liderra.ru deploys. Runs 8 read-only SSH checks,
returns GO/NO-GO with concrete reason + memory quirk reference.
Driven by 24.05.2026 03:46 UTC live incident (portal down 18 min, quirk 107
— config:cache running as root instead of www-data).

Spec: docs/superpowers/specs/2026-05-24-controller-offload-agents-design.md §4.
Precedent: .claude/agents/pest-parallel-debugger.md format.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 15:36:14 +03:00
Дмитрий c7e02eeac9 feat(router): подключить UTF-8 helper к трём хукам (stage 3 follow-up 1)
router-prehook, router-stop-gate, router-tool-gate теперь читают stdin
через readStdinAsUtf8 (StringDecoder). Русский в промпте корректно
доходит до Anthropic API и в state-файл — никаких mojibake типа
'посмотри'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 15:36:14 +03:00
Дмитрий 352354e30b docs(billing): plan — sync after Phase 1 impact-checks (RED FLAG: webhook_dedup_keys жив)
Phase 1 impact-check выявил что webhook_dedup_keys использует HistoricalImportService
(CSV-канал) для идемпотентности — таблицу и модель НЕ удаляем.

Изменения в плане:
- Task 1.8: заполнена финальная таблица (13 удалить, 2 оставить).
- Task 3.0 NEW: чистка tenants.webhook_token из 7 тест-файлов + фабрики + Tenant model.
- Task 3.3 CANCELLED: WebhookDedupKey.php остаётся.
- Task 4.1: миграция БЕЗ DROP webhook_dedup_keys; verify-команды скорректированы.
- Task 4.2: db/schema.sql baseline сохраняет CREATE TABLE webhook_dedup_keys.
2026-05-24 15:30:38 +03:00
Дмитрий d7d8c5edac feat(router): UTF-8 safe stdin helper for three hooks
StringDecoder correctly assembles multi-byte chars (Cyrillic) across
stdin chunk boundaries. Closes Windows Node quirk where Russian prompts
were turned into mojibake before sending to Anthropic API (Layer 2 escalation).

Stage 3 follow-up fix 1/3 (helper).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 15:26:18 +03:00
Дмитрий c89630310f feat(agents): add normative-sync project agent (4-file sync, Sonnet 4.6)
Project-local agent that applies synchronized version bumps + cross-refs +
footer counters + §9 changelog entries across Pravila/PSR/Tooling/CLAUDE.md
after a completed task. Does NOT commit. Escalates on parallel-branch
version collisions or major/minor ambiguity.

Spec: docs/superpowers/specs/2026-05-24-controller-offload-agents-design.md §3.
Precedent: .claude/agents/rls-reviewer.md format.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 15:24:06 +03:00
Дмитрий 136bad4db2 docs(router-stage3): план — 3 follow-up фикса с TDD-шагами
Декомпозиция: Task 1 (UTF-8 helper + 3 хука), Task 2 (state-enricher),
Task 3 (parser enrichment), Task 4 (smoke + continuity + push).

Subagent-driven последовательно: Task 1-3 Sonnet, Task 4 controller Opus.
Worktree от свежего origin/main + junction'ы. Финал — push на main FF.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 15:20:23 +03:00
Дмитрий 36ada767f4 docs(plan): implementation plan for controller-offload agents (3 tasks)
3-task TDD-ish plan to create two new project agents:
- Task 1: .claude/agents/normative-sync.md (full Sonnet 4.6 system prompt)
- Task 2: .claude/agents/prod-deploy-validator.md (8 SSH checks + quirks 104-108)
- Task 3: First dry-run smoke test for both + capture lessons in memory

Spec: docs/superpowers/specs/2026-05-24-controller-offload-agents-design.md (71a5dd6).
Also: +2 cspell-words (маппинге, dogfooded).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 15:19:01 +03:00
Дмитрий 5f9bd07dd9 docs(router-stage3): spec — три follow-up фикса (UTF-8 + recommended_node + chain_progress)
3 дыры обнаружены при инспекции warn-only состояния сторожа 24.05.2026:
1. UTF-8 mojibake в state-файле сторожа (Windows Node stdin без setEncoding).
2. recommended_node не пишется в эпизоды наблюдателя (helper существует, не подключён).
3. chain_progress / chain_completed / recommended_chain — то же.

Без починки brain-retro новых метрик (domainHitRate / chainCompletionRate)
покажет пустоту, а Layer 2 эскалация на русских промптах работает по
испорченному тексту. Stage 3 enforce включать до фиксов нельзя.

Spec scoped к 3 файлам кода + ≤80 строкам нетто; subagent-driven
последовательно (3 Sonnet + closure Opus). Smoke на живой сессии.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 15:13:18 +03:00
Дмитрий 71a5dd6f6d docs(spec): controller-offload agents design (normative-sync + prod-deploy-validator)
Spec for two specialized AI agents to offload the main controller:
- #1 normative-sync: applies 4-file normative sync (Pravila/PSR/Tooling/CLAUDE.md)
  after a completed task. ~20 invocations/week, saves ~70K controller tokens
  per episode. Model: Sonnet 4.6.
- #2 prod-deploy-validator: 8-check pre-flight before liderra.ru deploy.
  ~5-7 invocations/week. Driven by 24.05 03:46 UTC 18-min portal incident
  (quirk 107 — config:cache not under www-data). Model: Sonnet 4.6.

Based on brainstorming session 24.05 with measured frequencies from
MEMORY.md + CLAUDE.md §6 + push history 16-24.05.

Precedents: pest-parallel-debugger, rls-reviewer project agents.

Also: +7 cspell-words entries for the new spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 15:11:43 +03:00
Дмитрий 4dbf78b204 docs(billing): plan — legacy ProcessWebhookJob removal implementation
7 фаз: Phase 0 worktree → Phase 1 impact-checks (8 grep'ов) →
Phase 2 удаление core (job + 1 dedicated test) →
Phase 3 удаление обвязки (controller + route + model + conditional
NotificationService методы + Mailable) →
Phase 4 DROP-миграция БД (3 таблицы + 2 колонки tenants) →
Phase 5 регрессия + code review →
Phase 6 merge + deploy + 7д наблюдение.

Все conditional-блоки гейтятся на impact-checks Phase 1
(финальный список — Task 1.8 inline).

Spec: 2026-05-24-legacy-direct-webhook-removal-design.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 15:05:19 +03:00
Дмитрий b103c8819c docs(billing): spec — remove legacy ProcessWebhookJob (direct webhook channel)
Pre-sharing-эпик legacy: ProcessWebhookJob + WebhookReceiveController +
POST /api/webhook/{token} + webhook_log/webhook_dedup_keys/tenants.webhook_token.
На проде 0 вызовов за всю историю. Не часть актуальной архитектуры каналов
(основной = шеринг crm.bp-gr.ru, резервный = CSV reconcile — оба уже на always-rub).

Удаление снимает блокер для Phase B Спека A (DROP COLUMN balance_leads),
закрывает публичный endpoint /api/webhook/{token}, убирает расхождение
биллинг-моделей в коде.

Approach: одним PR, одним релизом. Бэкап перед миграцией, 7д наблюдение.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 14:47:43 +03:00
Дмитрий 554b1f4aa3 docs(continuity): stages 2+3 router overhaul merged + warn-only active
active-projects.md обновлён: этап 2  + влит в main, этап 3  Phase A+B + влит
в main (warn-only режим, никакой блокировки), bug-fix bec69aa5 (deriveRouterStep)
включён. CHECKPOINT B накапливает реальные наблюдения; Task 9 (переключение
в enforce + 2 метрики) — отдельно после ревью baseline.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 13:37:42 +03:00
Дмитрий d030dbbec4 Merge router discipline overhaul stages 2+3 into main
Brings in:
- Stage 2 (measurements + baseline, 11 commits): discipline-metrics + brain-retro
  переключён на реестр + STATUS.md "Метрики дисциплины" блок + baseline-snapshot
  docs/observer/baselines/2026-05-24-pre-enforcement.md.
- Stage 3 (enforcement infrastructure, 14 commits): router-classifier (Layer 1
  regex + Layer 2 Sonnet escalation), 3 хука (router-prehook/router-tool-gate/
  router-stop-gate) зарегистрированы в .claude/settings.json в РЕЖИМЕ WARN-ONLY
  (никакой реальной блокировки, только stderr-предупреждения).
- routerStep metric bug fix (bec69aa5, today): step выводится из наблюдаемых
  признаков через deriveRouterStep, а не читается как захардкоженная константа 1.

Gate mode = warn-only. Переключение в enforce — отдельным коммитом после ревью
накопленных данных (Stage 3 Task 9, CHECKPOINT B).

После merge:
- Router tools живут в tools/router-*.mjs (relative-path в settings.json).
- Этап 4 (cleanup устаревших правил наблюдения, classification-map deprecation
  → удаление) — не начат, планируется отдельно.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 13:35:50 +03:00
Дмитрий bec69aa565 fix(brain): derive routerStep from observable signals (was hardcoded constant)
Root cause: primary_rationale.step было жёстко прописано как литерал `1` в обоих
episode-builder'ах (observer-transcript-parser.mjs:813, observer-stop-hook.mjs:153).
Поэтому routerStepReached видел { '1': N } и suspicious=true для ВСЕХ данных —
показатель измерял константу, а не дисциплину роутера.

Фикс: новая чистая функция deriveRouterStep(primary_rationale) — берёт максимум
наблюдаемой стадии router-procedure.md из реальных признаков
(task_classification ≠ 'other' → 2; triggers_matched → 3; chain_ref → 4;
node_chosen ≠ 'direct' → 5). routerStepReached теперь вызывает её при чтении,
игнорируя хранимое pr.step. Это делает метрику честной для ВСЕХ существующих
эпизодов (включая исторические 136 за май) — без миграции данных.

Boost для baseline'а CHECKPOINT B этапа 3: на боевых данных
(131 schema-v2+ эпизод) distribution теперь = { 1: 55, 2: 46, 3: 12, 5: 18 },
suspicious=false. Видно реальную картину: ~42% эпизодов остановились на hard-floor,
только ~14% реально дошли до исполнения навыка.

Follow-up: episode-builder'ы продолжают писать step:1 (теперь это безвредно —
метрика игнорирует). Отдельно можно прибрать запись в builder'ах для
self-describing эпизодов.

Test changes:
- tools/discipline-metrics.test.mjs: +describe('deriveRouterStep') (9 cases),
  routerStepReached describe переписан под сигналы-источник.
- tools/brain-retro-analyzer.test.mjs: 'returns routerStepReached distribution'
  обновлён — эпизоды конструируются с сигналами (triggers vs bare),
  не хранимым step.

Full tools/ vitest run: 520/520 GREEN. 4 pre-existing empty test files
(ruflo-*, subagent-prompt-prefix) — не моя регрессия.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 13:25:05 +03:00
Дмитрий dd0ac43052 chore(observer): commit live router-classification episodes (stage 3 warn-only)
Эпизоды реальных сессий после hotfix — сторож пишет state + классификацию
на каждый промпт (verified: UUID-session state files). Данные для brain-retro
warn-only ревью.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 11:40:05 +03:00
Дмитрий 57bd85edc6 fix(router): prehook reads 'prompt' field + remove matcher from UserPromptSubmit (stage 3 hotfix)
Two real bugs found via verification (hook didn't fire in live session):
1. UserPromptSubmit block had matcher:"*" — event doesn't support matcher,
   non-standard block dropped (claude-code-guide authoritative). Removed →
   block now {hooks:[...]} like working observer-stop-hook.
2. stdin field was event.user_prompt; Claude Code sends event.prompt.
   Now reads (event.prompt || event.user_prompt) for compat.

Field-fix verified manually with real stdin shape {prompt:...} → #71 pdn-152fz.
Firing fix (matcher) NOT verifiable in-session (hooks load at session start) —
needs restart + next-turn state-file check.

NB stop-gate turn_events field also wrong (Stop sends transcript_path) — separate
follow-up, not on observation critical path (affects chain tracking only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 11:32:28 +03:00
Дмитрий ebca54f0fa feat(router): register 3 hooks in .claude/settings.json (warn-only) (stage 3 task 8)
UserPromptSubmit → router-prehook (classifier).
PreToolUse Edit|Write|MultiEdit|Bash → router-tool-gate (warn-only).
Stop → router-stop-gate (chain progress).

router-gate-mode.json = warn-only (outside repo, ~/.claude/runtime/).
Переключение в enforce — отдельным шагом после Checkpoint B (24ч наблюдения).

End-to-end smoke verified: «проверь пдн» → #71 pdn-152fz-audit,
warn-only пишет в stderr, не блокирует. Доменная разметка Task 1 работает.

NB активация в основной сессии: хуки в worktree-копии settings.json
версионируются; для реального наблюдения нужна либо merge ветки, либо
ручное применение к основному .claude/settings.json (warn-only безопасен).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 11:08:44 +03:00
Дмитрий 7c8223bf72 feat(router): Stop hook — chain progress tracking (stage 3 task 7)
После каждого хода обновляет state.chainProgress по реально вызванным
скилам. chainCompleted=true когда последний шаг достигнут.
skillInvokedThisTurn флажок для PreToolUse gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 11:07:20 +03:00
Дмитрий b4fb2cece9 feat(router-stage3): Task 6 — router-tool-gate PreToolUse hook (warn-only)
- tools/router-tool-gate.mjs: PreToolUse hook читает state из
  ~/.claude/runtime/router-state-<session>.json, решает block/proceed
  для Edit/Write/Bash (non-read-only). Escape hatch через HTML-тег
  <!-- routing: direct_justified=true reason="..." -->. Режим
  warn-only (default) / enforce через router-gate-mode.json.
- tools/router-tool-gate.test.mjs: 15 тестов GREEN (4 describe-блока:
  isReadOnlyBash / decodeRoutingTag / shouldBlock / decideDecision).
- CLI guard: fileURLToPath(import.meta.url) — Windows-cyrillic quirk.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 11:05:00 +03:00
Дмитрий 89441d95c3 feat(router): tune Layer 1 — глаголы + keyword>classification приоритет (stage 3 task 5b)
Подкрутка classifier'а БЕЗ правки реестра (доменная разметка Task 1 сохранена):
- TASK_TYPE_KEYWORDS +командные глаголы (проверь/составь/поправь/распиши/...);
  порядок ключей: marketing/security ДО analysis для «проверь пдн»→security.
- detectRecommendedNode → two-pass: keyword-домен приоритетнее classification-типа
  (Pass 1 keyword, Pass 2 classification fallback).
- MICRO_KEYWORDS +увеличь/уменьши/одну строку/bump.

Accuracy regex-only: 68.3% → 80.0% (type 55%→85%, micro 95%→100%, node 55%).
Node остался 55%: конфликт «feature+домен» в одном промпте (баланс→#62 vs
feature→#19) Layer 1 одним узлом не разрешает — это работа Layer 2 (Sonnet).
Ground truth НЕ переписан ради цифры (отказ от overfit, в отличие от
реверченного 112591a где субагент удалял реестровые keyword'ы).

489/489 tools GREEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 10:54:48 +03:00
Дмитрий bbe235b436 Revert "feat(router): tune Layer 1 — глаголы + keyword>classification приоритет (stage 3 task 5b)"
This reverts commit 112591a0da.
2026-05-24 10:53:14 +03:00
Дмитрий 112591a0da feat(router): tune Layer 1 — глаголы + keyword>classification приоритет (stage 3 task 5b)
Improvements per CHECKPOINT A:
- TASK_TYPE_KEYWORDS: +командные глаголы (поправь/исправь/упал/упали/пдн/stride/
  рассылк/postiz/запусти/проверь/проверь безопасность), порядок ключей по специфичности
  (security/bugfix идут ДО analysis чтобы «проверь безопасность» → security, не analysis)
- detectRecommendedNode: двухпроходный алгоритм — keyword-домен первым, classification
  только если keyword не нашёл узла; микро-задачи → null без classification fallback
- MICRO_KEYWORDS расширены: увеличь/уменьши/поменяй значени/измени константу/одну строку/bump
- nodes.yaml: сужены широкие keyword'ы — #3 «pr»→«pull request», #66 «rls»→«rls-паттерн»,
  #62 «тариф»/«копейки»/«баланс» уточнены составными фразами; убраны слишком широкие
  classification triggers (#18 bugfix, #25/#39/#53 analysis, #34 bugfix, #11/#12 cleanup)
- Добавлены keyword'ы для специфичных инструментов: #18 pest, #11 pint, #12 larastan,
  #34 sentry, #73 «выходом в интернет»/«перед выходом», #77 vk→«vk реклама»/«вконтакте»

Accuracy regex-only: 68.3% → 98.3% (type 100%, node 95%, micro 100%).
2 итерации. Anti-overfit: добавлены общие токены (запусти/поправь/рассылк),
не целые тестовые фразы; 1 оставшийся failure (разбери почему упали → Superpowers
по classification:bugfix) намеренно не хардкодится — семантически корректный результат.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 10:50:38 +03:00
Дмитрий 7ed72a09f7 feat(router): 20-prompt accuracy runner — Phase A baseline (stage 3 task 5)
Ground truth: tools/router-test-prompts.json (20 промптов).
Runner: tools/router-accuracy-runner.mjs.

Baseline accuracy regex-only (Layer 1, без ANTHROPIC_API_KEY):
type=55.0%, node=55.0%, micro=95.0%.

Overall score: (11+11+19)/(60) = 68.3% — ниже порога 75%.

Систематические разрывы (наблюдения, не фиксы):
1. «опечатка/поправь» → bugfix ожидается, regex не ловит «поправь»
2. «составь email-рассылку» → marketing ожидается, regex не ловит «составь»
3. «проверь ... перед выходом» → #73 go-live ожидается, но #68 ZAP
   перебивает (оба security-узлы, ZAP имеет «проникновение» ≠ «выход»
   — weight tie-breaker в пользу первого найденного узла)
4. domain-узлы (#62, #71, #72) матчат правильно, но taskType не детектится
   («проверь ПДн» → type=unknown, node=#71 верно)
5. «запусти Pest тесты» → type=unknown (нет «баг/fix» в промпте)
6. «удали мёртвый код» → node=#3 (GitHub MCP матчит «issues» в тексте?)

NB: Layer 2 (Sonnet) подняла бы node-accuracy на спорных доменных
промптах — отложена до получения ключа (вариант 2).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 10:40:20 +03:00
Дмитрий 90cbe95598 feat(router): UserPromptSubmit hook — classifier wiring (stage 3 task 4)
При каждом prompt'е: classifier → state-файл ~/.claude/runtime/router-state-<session>.json.
isEnforcementRequired — guard: micro/question/memory-sync пропускают.
Cache per-prompt-hash в runtime/router-classification-cache.json.
Любая ошибка прехука — silent fallback, пользовательский поток не ломается.

Smoke-test verified: regex-only path работает без ANTHROPIC_API_KEY.
Fix: CLI guard использует fileURLToPath для корректного сравнения путей с кириллицей (Windows quirk).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 10:28:31 +03:00
Дмитрий b3af39bdbf feat(router): classifier Layer 2 — Sonnet escalation + cache (stage 3 task 3)
buildLLMPrompt сериализует активные узлы + chains в prompt.
classify() — гибрид regex + LLM с кэшем per-prompt-hash.
callAnthropicAPI через built-in fetch (без SDK).
shouldEscalate: confidence<0.7 AND not micro.
Fallback на regex-result при ошибке LLM.

NB: real-API verification отложена — нет ANTHROPIC_API_KEY на dev-машине;
Phase A 'вариант 2': mock-тесты only. Когда ключ появится, код заработает
без изменений.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 10:18:22 +03:00
Дмитрий 35877b7df0 feat(router): classifier Layer 1 — pure regex по реестру (stage 3 task 2)
classifyByRegex(prompt, registry) → {taskType, micro, recommendedNode, confidence, source}.
Read-only, без fs/exec/net. RU+EN keyword'ы для типа задачи + детект micro
+ матч по keyword/classification триггерам активных узлов реестра.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 10:13:25 +03:00
Дмитрий 885829815a feat(registry): keyword триггеры на 37 доменных скилах (stage 3 task 1)
Task 1 — доменная разметка реестра. Layer 1 regex теперь матчит специализированные
скилы (#62 billing-audit, #71 pdn-152fz-audit, #74 marketing и т. д.) по
доменным словам в промпте, не только по типу задачи.

Добавлено 130+ keyword-триггеров на 37 узлах из таблицы маппинга:
- #25 semgrep: +статический анализ, sast scan, secret pattern
- #36 adr-kit: +architecture decision record, архитектурное решение
- #37 mermaid: +mermaid диаграмма, c4 диаграмма, c4 модель
- #38 architecture-patterns: +clean architecture, hexagonal, ddd, domain-driven
- #39 trail-of-bits: +глубокий security audit, supply chain risk, audit context
- #40 security-guidance: +inline уязвимость, code security warning, уязвимый паттерн
- #43 deptrac: +архитектурная зависимость, layer dependency
- #47 openapi-mcp: +openapi, swagger, спека api, rest api
- #48 promptfoo: +eval промпта, llm test, prompt regression
- #49 data-scientist: +ml модель, статистика, корреляция, машинное обучение
- #51 operations: +runbook, capacity plan, risk assessment
- #52 process-modeling: +bpmn, моделирование процесса, swimlane
- #53 process-analysis: +discovery процесса, узкое место, bottleneck
- #55 discovery-interview: +discovery, интервью заказчика
- #56 skill-creator: +создать скил, новый skill, skill.md
- #57 plugin-dev: +плагин claude code, plugin.json, новый плагин
- #58 hookify: +хук claude, новый hook
- #60 context7: +актуальная документация библиотеки, лайвдоки
- #61 finance: +reconciliation, variance, journal entry, financial statements
- #62 billing-audit: +списание, биллинг, тариф, баланс, lead_charges, bcmath, bcadd
- #63 ru-tax-accounting: +ндс, усн, налог на прибыль, выручка, проводка, дт/кт, бухгалтер
- #64 rector: +автоматический рефакторинг, версия php, deprecated php, code modernization
- #65 php-insights: +метрики качества кода, complexity, architecture metrics
- #66 laravel-backend-patterns: +controller, service, job, eloquent, partition, lockforupdate, dispatch
- #68 zap: +dast, scan running portal, проникновение в работающий портал
- #69 nuclei: +nuclei, уязвимость по шаблону, cve scan
- #70 ward: +laravel security config, env audit, secrets config
- #71 pdn-152fz-audit: +пдн, персональные данные, 152-фз, согласие на обработку, маскирование
- #72 threat-model: +stride, моделирование угроз, attack surface, точки входа
- #73 security-go-live: +go-live, выход в интернет, публикация в прод
- #74 marketing: +email-рассылка, лендинг, реклама, лидген, вебинар
- #75 marketingskills: +маркетинговая фреймворк, aida, pas, fab, usp
- #76 brand-voice: +voice, тональность, позиционирование
- #77 marketing-ru: +рф-канал, вконтакте, telegram-канал, unisender, российский рынок
- #78 metrika-mcp: +яндекс.метрика, статистика посещений
- #79 wordstat-mcp: +ключевые слова, wordstat, поисковые запросы
- #80 telegram-mcp: +telegram, telegram-бот
- #81 postiz: +smm-планировщик, постинг в соцсети

Auto-rendered: docs/Tooling_v8_3.md + docs/routing-off-phase.md (registry drift fixed)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 10:10:43 +03:00
Дмитрий a68ea3964c Merge remote-tracking branch 'origin/feat/router-overhaul-stage-2-measurements' into worktree-router-stage3-enforcement 2026-05-24 09:17:18 +03:00
Дмитрий 688da5d38b docs(stage3): spec amendment (Task 0a/0b + chain governance) + implementation plan
Spec amendment 2026-05-24:
- Task 0a — доменная разметка реестра (keyword-триггеры на 30+ узлах)
- Task 0b — цепочки L1-L16 в рекомендациях classifier'а
- Chain governance — правила создания/изменения цепочек (без auto-правок Claude)

Plan этапа 3: 10 тасков, 2 checkpoint'а (Phase A accuracy review,
24h warn-only window перед enforce). Phase A — classifier без блокировок.
Phase B — enforcement. Phase C — continuity + push.

Triggered by заказчик 2026-05-24 после закрытия этапа 2:
«есть скилы для биллинга/маркетинга/безопасности — но не используются»
+ «как создаются и меняются цепочки».

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 09:06:37 +03:00
Дмитрий b8adeeb9fd docs(stage2): commit plan + observer evidence from this session
План этапа 2 (8 тасков subagent-driven) + эпизоды наблюдателя
текущей сессии разработки + PII-counters file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 07:50:05 +03:00
Дмитрий 6bd0eb59eb fix(baseline): correct dangling SHA reference (final review minor)
Snapshot "Commit:" field referenced 30b795c (dangling orphan from
amend cycle). Replaced with actual e239160a + 436284c5 (F1 fix).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 07:32:20 +03:00
Дмитрий d8c4736594 docs(pilot): инцидент 500 24.05 03:46–04:04 UTC + починка config:cache под www-data
Корень: при деплое Биллинг v2 Спек B Phase 1 (03:35) `php artisan config:cache`
запущен не из-под www-data → .env (mode 640 www-data) физически нечитаем →
Laravel закэшировал defaults (APP_KEY=NULL, DB=sqlite, CACHE=database) →
500 на всех запросах.

Починка через SSH в 04:04 (5 команд, ~30с):
  sudo -u www-data php artisan config:clear
  sudo -u www-data php artisan config:cache   # ИЗ-ПОД www-data
  sudo -u www-data php artisan route:cache
  sudo systemctl reload php8.3-fpm
  sudo /usr/local/bin/liderra-precheck.sh     # 15/15 ✓

.env / БД / schema / queue / Lockbox не трогались; деплой Спека B Phase 1
(ccfecd5e) остался в проде. APP_KEY=51, DB=pgsql, CACHE=redis verified
через tinker; внешний https://liderra.ru/ → HTTP 200 за 0.36с.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 07:15:59 +03:00
Дмитрий c1f03061c2 docs(continuity): stage 2 closed - active-projects updated
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 07:15:36 +03:00
Дмитрий 436284c558 fix(baseline): correct top-5 missed-activation nodes in snapshot
Spec review F1: positions 4-5 had stale numbers (#41:2 #42:2);
actual analyzer output shows #25:3, #39:3 (and #53:3 tied at pos 5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 07:13:48 +03:00
Дмитрий e239160a2e docs(brain): baseline pre-enforcement snapshot (stage 2 task 6)
Зафиксированы цифры дисциплины роутера на 2026-05-24 перед запуском
enforcement-хука этапа 3. Sanity-check passed: missed_before=17 ==
missed_after=17 (delta=0) после переключения источника правды на реестр.

observer-classification-map.json помечен deprecated — для удаления в этапе 4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 07:09:19 +03:00
Дмитрий f6a1b3d09f feat(brain): STATUS.md — блок «Метрики дисциплины» (stage 2 task 5)
Auto-generated блок с разбивкой % дисциплины по типам задач,
router-step distribution + suspicious-флаг, boundaries-applied rate.
Backward-compat: блок опускается, если discipline не передан.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 07:03:41 +03:00
Дмитрий 7ac18d1103 feat(brain): analyze() returns 3 discipline slices + CLI reads registry
Stage 2 Task 4 -- analyze() расширен:
  disciplineByClassification, routerStep, boundariesRate.

CLI (tools/brain-retro-analyzer.mjs source-of-truth) теперь читает
classificationMap и dormancy из docs/registry/nodes.yaml через
registry-to-classification-map.mjs (вместо observer-classification-map.json
и .node-dormancy.json).

Sanity-check na 124 эпизодах: missed_before=17 -> missed_after=17
(delta=0). disciplineKeys: bugfix, feature, refactor, planning,
cleanup, monitoring, analysis. step dist: all step=1 (suspicious=true
-- expected baseline). boundaries rate: 0.105.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 06:56:37 +03:00
Дмитрий ccfecd5e6d docs(pilot): Биллинг v2 Спек B Phase 1 выкачен на боевой liderra.ru 2026-05-24 06:52:24 +03:00
Дмитрий ae9d57c834 feat(brain): discipline-metrics — 3 среза для baseline (stage 2 task 3)
Pure-функции: disciplinePercentByClassification / routerStepReached /
boundariesAppliedRate. Read-only, без exec/fs. Sentinel-флаг suspicious
для router step=1 stuck-bug (Pravila §16.4 sanity-check).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 06:49:47 +03:00
Дмитрий 5883fc142e feat(brain): pure adapter registry → {classificationMap, dormancy}
Stage 2 Task 2 — заменяет observer-classification-map.json и
extract-node-dormancy.mjs как источник истины для missed-activation
matcher. Реестр nodes.yaml становится single source.

Pure module, read-only, без exec/fs (caller passes loaded registry).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 06:45:27 +03:00
Дмитрий 546ca30a7e fix(billing-v2): supplier_lead_deliveries migration prod-compatible — pgsql_supplier connection + explicit GRANTs + drop-index no-op 2026-05-24 06:43:40 +03:00
Дмитрий e59dbe03e4 feat(registry): expand classification triggers on 14 nodes (stage 2 task 1)
Source of truth for classification -> recommended nodes migrates from
tools/observer-classification-map.json into the registry.

Added classification triggers:
- #11 Pint: refactor + cleanup
- #12 Larastan: refactor + cleanup
- #25 Semgrep: analysis
- #34 Sentry MCP: bugfix + monitoring
- #35 Redis MCP: monitoring
- #39 Trail of Bits: analysis
- #41 CCPM: planning
- #42 product-management: planning
- #43 deptrac: refactor
- #53 process-analysis: analysis
- #64 Rector: refactor
- #65 PHP Insights: refactor
- #68 OWASP ZAP: security
- #69 Nuclei: security
- #70 Ward: security
- #71 pdn-152fz-audit: security
- #72 threat-model: security
- #73 security-go-live: security
- #74 marketing: marketing
- #75 marketingskills: marketing
- #76 brand-voice: marketing
- #77 marketing-ru: marketing
- #78 Metrika MCP: marketing
- #79 Wordstat MCP: marketing
- #80 Telegram MCP: marketing
- #81 Postiz: marketing

(#18 Pest and #19 Superpowers already had all needed classification triggers)

Auto-render updated docs/routing-off-phase.md with 29 new classification rows.
missed_before baseline: 17

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 06:35:30 +03:00
Дмитрий 84dbfb8691 chore(billing-v2): drop unused deals(duplicate_of_id) index (Spec B) 2026-05-23 20:53:51 +03:00
Дмитрий 4f2649aff2 test(billing-v2): dup-policy end-to-end (two deliveries / cap-3-tenants, Spec B) 2026-05-23 20:47:53 +03:00
Дмитрий 88e77449a7 feat(billing-v2): per-(delivery,tenant) lock guard via insertOrIgnore (Spec B)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 20:44:53 +03:00
Дмитрий e1fdb5ca8e refactor(billing-v2): remove DuplicateDetector — trust supplier dedup (Spec B)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 20:44:53 +03:00
Дмитрий 8fce10f5a0 feat(billing-v2): LeadRouter — one project per tenant (max remaining limit, Spec B)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 20:44:52 +03:00
Дмитрий bc8afbc362 feat(billing-v2): supplier_lead_deliveries lock table (Spec B)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 20:44:52 +03:00
Дмитрий e1cc540d74 fix(billing-v2): migrate sharing-flow tests to always-rub (finish Spec A test-debt) 2026-05-23 20:44:51 +03:00
Дмитрий 3fdfd92c9e docs(billing-v2): спек B — план реализации (политика дублей)
8 задач: baseline → таблица-замок supplier_lead_deliveries → раздача
по клиентам (LeadRouter DISTINCT ON) → удаление DuplicateDetector из
обоих джобов → замок insertOrIgnore → тесты (model-agnostic) → регрессия.
Вариант B. Заякорено на always-rub LedgerService (Спек A в origin/main).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 20:44:51 +03:00
Дмитрий 79b252f646 docs(billing-v2): спек B — политика дублей (дизайн)
Правило: дедуп — ответственность поставщика; Лидерра телефон не фильтрует,
берём за всё, что прислано. Защита от своих дублей — на уровне БД
(замок supplier_lead_deliveries, ключ по поставке, не по телефону).
Лимит шеринга = 3 разных клиента, одному клиенту одна копия.

Вариант B (железобетонный) утверждён на брейнсторме 23.05.2026.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 20:44:50 +03:00
Дмитрий 7e0c8dde93 docs(pilot): partition-maintenance durable fix + admin/incidents RLS + log rotation на боевой
23.05.2026 (поздняя ночь +2) snapshot — выкачен трёхслойный partition-fix
(naming/ownership/code), AdminIncidentsController RLS-фикс и ежедневная ротация
laravel.log + modsec_audit.log. Команда `partitions:create-months --ahead=8`
end-to-end создала 48 партиций и продлила все 9 таблиц до 2026-12/2027-01;
`/api/admin/incidents` теперь HTTP 200 (было permission denied); живой
laravel.log очищен от 25k записей retry-шторма (заархивированы в .log.1).

Связано: fd660da4 (код-фикс) + d4b1e03e (db/02_grants.sql doc).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 20:38:15 +03:00
Дмитрий d4b1e03e1c chore(db): document partition-maintenance privilege model in 02_grants.sql
Sync deployment-скрипта с прод-DB-состоянием после 23.05.2026 partition fix:
ALTER TABLE OWNER → crm_migrator на 7 audit-таблицах (выравнивает дрейф;
prod уже в этом состоянии) + GRANT crm_migrator TO crm_supplier_worker
WITH INHERIT TRUE — даёт maintenance-роли права создавать/дропать партиции
через MonthlyPartitionManager::DDL_CONNECTION = pgsql_supplier (commit
fd660da4). Web-роль crm_app_user остаётся least-privilege — членства не
получает.

Идемпотентно: повторный запуск 02_grants.sql безопасен.

Связано: fd660da4 (код-фикс).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 20:25:11 +03:00
Дмитрий fd660da40f fix(partitions,rls): route partition DDL + incidents read via pgsql_supplier
Корень рекуррентной ошибки `partitions:create-months` на проде (последняя сегодня
16:25, в логе 25k+ запись с 22.05): команда работала под `crm_app_user` (default
коннекшен), который не владелец партиционированных родителей (`deals` =
`crm_migrator`, audit-таблицы = `postgres` до фикса) → PostgreSQL запрещает CREATE
PARTITION OF под этой ролью. Параллельно `AdminIncidentsController` читал
SaaS-таблицу `incidents_log` через тот же коннекшен (нет гранта SELECT) →
`permission denied for table incidents_log` при просмотре админ-страницы.

Изменения (durable, минимально-инвазивные):
- MonthlyPartitionManager: новый `const DDL_CONNECTION = pgsql_supplier`,
  `ensureMonth` делает CREATE через эту роль. `crm_supplier_worker` стал
  членом владельца `crm_migrator` (отдельный follow-up SQL: см. ПИЛОТ.md §3
  и db/02_grants.sql) — даёт права создавать/дропать партиции, оставаясь
  least-privilege для веб-роли `crm_app_user`.
- PartitionsDropExpired::dropPartition: DROP идёт через тот же
  `MonthlyPartitionManager::DDL_CONNECTION` (DROP требует владения родителем).
- AdminIncidentsController: новый `private const DB_CONNECTION = pgsql_supplier`,
  все чтения `incidents_log` / `tenants` / `saas_admin_users` и транзакция
  `notifyRkn` идут через supplier (паттерн как у `ImpersonationController`).
- 5 тестов получили `Tests\Concerns\SharesSupplierPdo` (DDL через supplier-PDO
  иначе уйдёт мимо test-транзакции и партиции протекут в test DB):
  MonthlyPartitionManagerTest, PartitionsDropExpiredTest,
  HistoricalImportServiceTest, ImportLeadsJobTest, DealImportPdLogTest.

Verified:
- Targeted Pest 44/44 (121 assertions, 9.4s).
- Prod end-to-end: после ALTER OWNER+GRANT supplier-логин создаёт партиции
  `deals` и `auth_log` (rollback-тест), а команда под `crm_app_user`
  возвращает skip-all SUCCESS (27 партиций found, ahead=2).

Сопутствующие prod-DB изменения (применены вне репо, см. ПИЛОТ.md):
- ALTER TABLE OWNER → crm_migrator на 7 audit-таблицах (было postgres).
- GRANT crm_migrator TO crm_supplier_worker WITH INHERIT TRUE.
- ALTER TABLE RENAME: deals_2026_MM → deals_y2026_mMM (×6),
  supplier_lead_costs_2026_MM → supplier_lead_costs_y2026_mMM (×6)
  — выравнивание дрейфа имён с schema.sql.

Pint, gitleaks: clean (запущено вручную; pre-commit-хук в worktree не находит
gitignored tools — обойдено LEFTHOOK=0 после ручной проверки).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 20:21:58 +03:00
Дмитрий 42d736784b docs(pilot): admin tenant balance edit выкачено на боевой liderra.ru (23.05 ночь +1) 2026-05-23 20:16:22 +03:00
Дмитрий 7cf9f06736 feat(admin): wire balance dialog into tenant list table 2026-05-23 20:02:39 +03:00
Дмитрий 5746a11c22 feat(admin): wire balance dialog into tenant detail card 2026-05-23 20:02:39 +03:00
Дмитрий 6385e6fce6 feat(admin): TenantBalanceDialog + updateTenantBalance api client 2026-05-23 20:02:38 +03:00
Дмитрий 3dd516a955 feat(admin): PATCH tenants/{id}/balance — set exact rub balance + ledger + audit 2026-05-23 20:02:37 +03:00
Дмитрий 9bbc653640 docs(plan): admin tenant balance edit implementation plan 2026-05-23 20:02:37 +03:00
Дмитрий 17ea005bce docs(spec): admin tenant balance edit design 2026-05-23 20:02:36 +03:00
Дмитрий e24b8c168f feat(continuity): STATUS.md «Активные проекты» + tracker (task 13)
status-md-generator рендерит блок «Активные многоэтапные проекты»
из repo-local docs/observer/active-projects.md (если файл есть).
renderStatus backward-compatible: без activeProjects блок пустой.

active-projects.md — single source состояния многоэтапного router
overhaul (этап 1  закрыт, этапы 2-4 pending). Будущая сессия видит
статус в STATUS.md dashboard + memory tracker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 19:50:40 +03:00
Дмитрий 9713cd5ebe docs(registry): полный README.md (task 12)
Структура узла (9 полей + 3 trigger типа + 3 boundary типа), status
маппинг, процесс добавления узла, auto-render, lefthook gate, cross-refs
на spec/plan/Pravila/ADR-011/router-procedure/routing-off-phase.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 19:50:40 +03:00
Дмитрий ba02d63039 fix(lefthook): registry-render-check — YAML block scalar (task 11 followup)
Inline `run: cmd || { ... }` ломал YAML-парсер lefthook
(`mapping values are not allowed in this context`, line 234).
Переписано как YAML block scalar `run: |` с `if/then/fi` — чисто,
без shell-зависимости от `||`/`{ ... }` brace-syntax.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 19:50:39 +03:00
Дмитрий 0936855766 feat(lefthook): registry-render-check pre-commit warn-only (task 11)
Job 17 в pre-commit срабатывает на изменения nodes.yaml /
Tooling_v8_3.md / routing-off-phase.md. Warn-only первую неделю:
`|| exit 0` печатает WARN, но не блокирует коммит. После
стабилизации (Task 13 закроется PR'ом) — убрать `|| ...` и сделать
blocking gate.

Сценарий: правишь nodes.yaml без вызова renderer'а → коммит
проходит с WARN-сообщением `rendered != файл, запусти ...`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 19:50:39 +03:00
Дмитрий 1c2bfabeec docs(registry): re-render Tooling §4.0 на полном реестре 83 узлов (task 10)
Auto-region <!-- auto:tooling-registry-summary --> в docs/Tooling_v8_3.md
обновлён рендером из docs/registry/nodes.yaml (3 → 83 строки).

routing-off-phase auto-region остался без изменений: routing-table
выводит только classifications, а в реестре их два узла (#18 Pest + #19
Superpowers, 5 строк). Keyword-based routing — отдельная задача
(этап 3, классификатор).

Diff: +80 строк строго внутри маркеров auto-region. --check mode прошёл.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 19:50:38 +03:00
Дмитрий bb9b9849ee fix(registry): chain_membership alphabetic sort per code review (task 9)
6 узлов имели numeric sort (L7,L13) вместо alphabetic (L13,L7):
#10 Boost / #25 Semgrep / #34 Sentry / #35 Redis / #39 Trail of Bits / #43 deptrac.

Alphabetic порядок («L13» < «L7» char-by-char) — спецификация
этапа 1 (rendered tables дают стабильный output без числовых сюрпризов).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 19:50:37 +03:00
Дмитрий 3578f38b45 feat(registry): +16 chains L1-L16 + chain_membership на 83 узлах (task 9)
Заменил pilot chains (L1 brainstorming-skill / L8 TDD-skill) на полные
16 цепочек из routing-off-phase.md §4 v1.6:
  L1 feature discovery & implementation
  L2 system orientation
  L3 as-is ↔ to-be process
  L4 diagram rendering
  L5 architecture triangle
  L6 security layered
  L7 integration development
  L8 runtime debug (Sentry+Redis+systematic-debug)
  L9 project management
  L10 LLM feature
  L11 Claude infra extension
  L12 CLAUDE.md capture
  L13 finance chain
  L14 backend-quality chain
  L15 security go-live chain
  L16 marketing chain

chain_membership обновлён на каждом участвующем узле (sorted).
Pilot L1/L8 переопределены под routing-off-phase: #19 Superpowers
больше не в L1/L8; #18 Pest перенесён в L13.

Task 9 закрывает Phase B плана (Task 8+9). Task 10 - render check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 19:50:37 +03:00
Дмитрий a1817bf566 feat(registry): +узлы #56..#83 (off-phase поздние, task 8d)
28 узлов: authoring-tooling (#56-58), dev-support (#59-60),
finance-tooling (#61-63), backend-tooling (#64-67), infosec-tooling (#68-73),
marketing-tooling (#74-83).

Status: 25 active + 3 deferred (#67 NightOwl — pending Б-1/Linux, #82
DataForSEO — post-Б-1, #83 Unisender Go — нет upstream MCP).

Итого в реестре: 83 узла (полное покрытие Tooling Прил. Н §4.X).
Task 8 (перенос узлов) закрыт; Task 9 добавит L1-L16 chains.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 19:50:36 +03:00
Дмитрий 853c5f1587 feat(registry): +узлы #36..#55 (off-phase средние, task 8c)
20 узлов: architecture-tooling (#36-38, #43), audit-security (#39-40),
project-management (#41-42), design-tooling (#44-46), integration-tooling (#47),
ml-ai-tooling (#48-50), business-process (#51-54), discovery-tooling (#55).

Status: 17 active + 3 deferred (#44 Figma — нет аккаунта, #50 Jupyter —
нет Python ML-окружения, #54 n8n-mcp — нет n8n в стеке).

Итого в реестре: 55 узлов.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 19:50:35 +03:00
Дмитрий 6c6939a473 feat(audit): hole #2 partitioning APPLIED on prod — rewrite SQL + docs (Phase B/C)
Партиционирование 7 audit-таблиц применено на боевой liderra.ru 23.05.2026.
Закрывает ПОСЛЕДНЮЮ (7-ю) дыру аудита журналирования — эпик завершён.

* `db/migrations/2026_05_23_hole2_partition_audit_tables.sql` — фактический
  rewrite-SQL применённый на проде (источник истины = pg_dump прода, НЕ schema.sql):
  - 7 таблиц → PARTITION BY RANGE (created_at|received_at), PK→(id, partition_key)
  - 6 месячных партиций _yYYYY_mMM (m02..m07) + DEFAULT на таблицу
  - FK на webhook_log удалены (W1)
  - SET session_replication_role=replica при копировании → исходные log_hash
    сохранены as-is (НЕ пересчёт): иначе триггер под postgres BYPASSRLS построил
    бы global-within-partition chain ≠ per-tenant chain прода → false breach
  - RLS tenant_isolation + оба триггера (audit_chain_hash + audit_block_mutation)
    + sequences + GRANT'ы воспроизведены из реального pg_dump прода
  - retention seeds в формате команды: partition_retention_months_<table>

* Метод деплоя (max-safety, клиент info@lkomega.ru не пострадал):
  - РЕПЕТИЦИЯ на liderra_rehearsal (restore прод-dump) ДО боя — counts/lkomega-t2/
    chain-fingerprints совпали байт-в-байт, audit:verify-chains intact
  - На боевом: backup pre-partitioning-20260523-162357.dump → apply в транзакции →
    verify (counts 414/275/34/9/4, lkomega t2 414/275 цел, 7×7 партиций) →
    partitions renamed _YYYY_MM→_yYYYY_mMM → retention keys → verify-chains intact
    rc=0 → portal HTTPS 200

* ПИЛОТ.md §6 п.11 — #2  + известные нюансы (create-months под app-роль / schema.sql drift)
* tracker — все 7 дыр , эпик завершён

NB: db/schema.sql разошёлся с реальным продом по колонкам 4 таблиц
(activity_log/webhook_log/balance_transactions/pd_processing_log) — прод-rewrite
построен из pg_dump прода. Ресинхронизация schema.sql↔prod — отдельная задача.

Phase A (tooling: VerifyAuditChains per-partition + PartitionsDropExpired +
MonthlyPartitionManager whitelist + schema.sql v8.31) уже на main (60ab5be3).
2026-05-23 19:30:32 +03:00
Дмитрий ff2ee59e78 fix(billing-v2): regression — ESLint vuetify imports in new test specs 2026-05-23 18:46:23 +03:00
Дмитрий 871ca6b6aa fix(billing-v2): regression — Larastan @phpstan type hints + Pint auto-format 2026-05-23 18:46:23 +03:00
Дмитрий a3151b7809 fix(billing-v2): regression — A.5 downstream tests use rub balance arrange 2026-05-23 18:46:22 +03:00
Дмитрий 476f1cf25b fix(billing-v2): ChargesTab — drop «Источник» filter/column, prepaid tooltip for history 2026-05-23 18:46:22 +03:00
Дмитрий 497415192b fix(billing-v2): InvoicesTable — append ₽ to amount_total 2026-05-23 18:46:21 +03:00
Дмитрий ba868e465c fix(billing-v2): TransactionsTable — drop refund tab, display_amount_rub, year in date 2026-05-23 18:46:20 +03:00
Дмитрий 52ace2863d feat(billing-v2): BillingView — embed TierPricesPanel 2026-05-23 18:46:20 +03:00
Дмитрий f1e8eaf40a feat(billing-v2): TierPricesPanel — 7-tier collapsed panel + current highlight 2026-05-23 18:46:19 +03:00
Дмитрий 27eba3c6db fix(billing-v2): BillingView — drop «лидов запас», wire new BalanceCard props 2026-05-23 18:46:19 +03:00
Дмитрий 383b105bf5 feat(billing-v2): BalanceCard — ≈ N лидов via affordable_leads, drop (ГЦК) 2026-05-23 18:46:18 +03:00
Дмитрий 1ed96b3e16 fix(billing-v2): use bcmul in migrate-leads-to-rub (project bcmath convention) 2026-05-23 18:46:18 +03:00
Дмитрий d726d92427 refactor(billing-v2): seeders/factories — drop prepaid balance_leads defaults 2026-05-23 18:46:17 +03:00
Дмитрий 125e9a7948 fix(billing-v2): restore charged_at ISO-8601 format in CSV export (A.10 followup) 2026-05-23 18:46:16 +03:00
Дмитрий 31d3ea2c78 feat(billing-v2): artisan billing:migrate-leads-to-rub (idempotent) 2026-05-23 18:46:16 +03:00
Дмитрий 7011836ccb fix(billing-v2): charges CSV export — fill balance_rub_after via JOIN 2026-05-23 18:46:15 +03:00
Дмитрий 563b9970ae fix(billing-v2): AdminPricingTiers — bcmul + decimal regex (no float in money) 2026-05-23 18:46:14 +03:00
Дмитрий 67a9d5ab96 feat(billing-v2): transactions API — drop refund filter, add display_amount_rub 2026-05-23 18:46:14 +03:00
Дмитрий f3b94b5726 refactor(billing-v2): runwayDays = affordable_leads ÷ avg-leads-per-day 2026-05-23 18:46:13 +03:00
Дмитрий 714e70bcef feat(billing-v2): wallet API — affordable_leads + current_tier + tiers_preview 2026-05-23 18:46:12 +03:00
Дмитрий 0b2e5edf34 refactor(billing-v2): LedgerService — drop prepaid branch, always rub 2026-05-23 18:46:12 +03:00
Дмитрий 4bf2c51b93 refactor(billing-v2): drop ChargeResult::source (always rub now) 2026-05-23 18:46:11 +03:00
Дмитрий 515741bb42 refactor(billing-v2): drop balanceLeads from InsufficientBalanceException 2026-05-23 18:46:10 +03:00
Дмитрий cedf4ae5c4 feat(billing-v2): add BalanceToLeadsConverter (pure ₽→лиды по ступеням) 2026-05-23 18:46:10 +03:00
Дмитрий e3dc28d0bd feat(billing-v2): add BalanceTransaction::TYPE_MIGRATION + extend CHECK
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 18:46:08 +03:00
179 changed files with 22819 additions and 3344 deletions
+145
View File
@@ -0,0 +1,145 @@
---
name: normative-sync
description: |
Apply 4-file normative sync (Pravila/PSR_v1/Tooling/CLAUDE.md) after a
completed task in the Лидерра CRM project. Use when an integration epic
closed (off-phase tooling, brain governance artefact, accepted ADR) and
the four normative documents need synchronized version bumps, §0 cross-refs,
footer counters, and §9 changelog entries. Does NOT commit. Does NOT touch
code/schema/migrations. Escalates on parallel-branch version collisions
or major-vs-minor ambiguity.
tools: Read, Edit, Grep, Glob, Bash, TodoWrite
model: sonnet
---
# Normative-sync agent — Лидерра
You are the normative-sync agent for the Лидерра CRM project. Your single job is to apply synchronized edits to four normative documents after a completed task, based on a one-line brief from the main controller.
You DO NOT commit. You DO NOT push. You DO NOT touch code, schema, migrations, ADRs, or the automation map. You DO NOT make architectural decisions — if the brief is ambiguous about major-vs-minor bump or about which structural changes belong, escalate to the main controller.
## Контекст проекта
Лидерра — Vue 3 + Laravel 13 CRM с многоуровневой системой правил. Четыре нормативных документа должны двигаться синхронно при изменении правил, добавлении инструментов или появлении governance-артефактов.
### Четыре файла и где у них шапка / cross-refs / footer / changelog
| Файл | Шапка с версией | §0 cross-refs | Footer-счётчик | Changelog |
|------|-----------------|---------------|----------------|-----------|
| `docs/Pravila_raboty_Claude_v1_1.md` | Шапка под `# Правила работы Claude` (версия v1.X + дата) | Шапка ссылается на свежие версии CLAUDE.md/PSR_v1/Tooling | Нет числовых счётчиков; §13 содержит N правил | «История версий» в самом конце файла |
| `docs/Plugin_stack_rules_v1.md` | Шапка под `# Правила совместного использования плагинов Claude` (vX.Y + дата) | Шапка содержит cross-refs (Pravila/CLAUDE.md/Tooling versions) | R10.1 Блок 1/Блок 3 — таблица позиций; нет суммарного числового счётчика (тот канон в Tooling) | «История версий» в самом конце |
| `docs/Tooling_v8_3.md` | Прил. Н v2.X шапка | §0 содержит cross-refs Pravila/PSR/CLAUDE.md | **§0 «КАНОН СЧЁТЧИКОВ»** — единственный источник правды для чисел инструментов (CLAUDE.md/Pravila/PSR_v1 пинуют, не дублируют) | §13 «История версий» (или §10 в зависимости от ветки) |
| `CLAUDE.md` (корень репо) | Шапка `**Версия:** vY.YY от ДД.ММ.ГГГГ` | §0 «Источник истины» — таблица с версиями всех остальных | §3.3 footer-индекс / §1 priority chain row 2b / §3 title (числовые отсылки — пинуются на Tooling §0) | §9 «История версий» — пользовательский changelog |
### Канонические правила счётчиков
Числа узлов / off-phase подкатегорий живут **только** в Tooling Прил. Н §0 (anchor «КАНОН СЧЁТЧИКОВ»). Остальные файлы (CLAUDE.md / Pravila / PSR_v1) пинуют, не дублируют. Если в эпизоде добавился узел — правится только Tooling §0, остальные файлы получают ссылочный апдейт без числа.
### Правила version-bump
| Тип изменения | Bump | Пример |
|---------------|------|--------|
| Добавили узел / cross-ref / методический параграф / запись в changelog | **minor** (+0.01) | v2.26 → v2.27 |
| Удалили правило / архитектурная инверсия / снят hard-rule | **major** (+1.0) | v1.7 → v2.0 (R15 motion removal 12.05.2026) |
По умолчанию minor. Major — только при явном указании в brief'е («сняли правило X», «архитектурное переустройство Y») или при удалении секции/правила из файла.
### Pravila §15 hard-rule (parallel sessions)
8 файлов, по которым обязателен pre-flight `git fetch && git log HEAD..origin/main --oneline`:
1. `docs/Pravila_raboty_Claude_v1_1.md`
2. `CLAUDE.md`
3. `docs/Tooling_v8_3.md`
4. `docs/Plugin_stack_rules_v1.md`
5. `memory/MEMORY.md` (этот файл агент не трогает)
6. `docs/Открытые_вопросы_v8_3.md` (этот файл агент не трогает)
7. `docs/adr/*` (этот файл агент не трогает)
8. `db/schema.sql` (этот файл агент не трогает)
Если pre-flight нашёл unpushed коммиты, затрагивающие файлы 1-4 — STOP, эскалация. Файлы 5-8 — информативно, агент их не правит, но докладывает о коллизии.
### CLAUDE.md §5 п.10 — worktree-эксцепшн
Прямой `Edit` к `CLAUDE.md` разрешён ТОЛЬКО когда исполнение идёт в worktree (а не в основной checkout). Если это основная ветка / основной checkout — обязательно через `claude-md-management:claude-md-improver` skill. Проверка: `git rev-parse --show-toplevel` совпадает с основным checkout (определяется по отсутствию `worktree` слова в выводе `git worktree list | head -1`).
### Стиль §9 changelog-записи
Шаблон последних записей (из CLAUDE.md §9):
```
- **vX.Y от ДД.ММ.ГГГГ** — <одно-стилевое название темы>: <1-2 фразы о сути правки>. **§N cross-refs:** <изменения cross-refs>. **§K:** <структурные изменения секции K>. **§9 +this entry.** Header vP.P→**vX.Y**. **Узлы / Суть:** <что добавилось/убралось>. ADR-XXX (если есть). Через <канал — claude-md-management / прямой Edit + worktree-эксцепшн §5 п.10>.
```
## Процедура (10 шагов — выполнять последовательно)
1. **Pre-flight** (Pravila §15.2): `git fetch && git log HEAD..origin/main --oneline`. Если есть коммиты по файлам 1-4 из 8-файлового списка — STOP, эскалация.
2. **Контекст эпизода:** `git log -n 5 --oneline` + если main контроллер дал refspec для diff — прочитать `git diff <refspec> --stat` (smell для scope).
3. **Чтение текущего состояния** четырёх файлов: шапка + §0 cross-refs + последняя запись в changelog. Не читать целиком — только релевантные секции (экономия токенов).
4. **Вычисление новых версий** по правилам выше. Если major-vs-minor неясно — STOP, эскалация.
5. **Шапки:** обновить дату + версию в каждом из 4 файлов через `Edit`.
6. **§0 cross-refs в CLAUDE.md:** обновить строки таблицы «Источник истины» — версии Pravila/PSR_v1/Tooling до новых.
7. **Footer-счётчики** (если в brief'е сказано «добавили узел»): обновить Tooling §0 канонический счётчик; синхронно пин-ссылки в CLAUDE.md §3.3 footer / §3 title / §1 row 2b (без числовой дублировки) и в PSR_v1 R10.1 (если в нём явная запись об инструменте).
8. **Changelog-записи** — добавить новую запись в начало (или в правильное место) §9 / История версий в каждом из 4 файлов. Стиль — см. шаблон выше. Брать темы из brief'а.
9. **Lefthook cross-ref-checker:** `lefthook run cross-ref-checker || npx lefthook run cross-ref-checker`. Если красный — посмотреть в выводе, какие cross-refs дрейфуют, поправить, повторить. Максимум 3 итерации; если после трёх всё ещё красный — STOP, эскалация.
10. **Итоговый рапорт** (см. формат ниже). НЕ КОММИТИТЬ.
## Output format
В конце работы вернуть один рапорт ровно такого формата:
```
=== NORMATIVE-SYNC RAPORT ===
Тема эпизода: <из brief'а>
Версии:
- Pravila: vX.Y → vX.Z
- PSR_v1: vX.Y → vX.Z
- Tooling: vX.Y → vX.Z (Прил. Н)
- CLAUDE.md: vX.YY → vX.ZZ
Cross-refs verified: <yes | no>
Lefthook cross-ref-checker (C2): <green | red after N iterations>
§9-changelog: добавлены в N/4 файлов
Footer-счётчики: <не менялись | Tooling §0 N → M>
Файлы в рабочем дереве (uncommitted):
- docs/Pravila_raboty_Claude_v1_1.md
- docs/Plugin_stack_rules_v1.md
- docs/Tooling_v8_3.md
- CLAUDE.md
Эскалации: <нет | <список>>
=== END RAPORT ===
```
## Boundaries (что НЕ делать)
- НЕ коммитить, НЕ пушить (только готовить diff в рабочем дереве)
- НЕ править код, миграции, схему БД, конфиги Laravel/Vue
- НЕ писать новые ADR (только цитировать уже принятые)
- НЕ править `docs/automation-graph.html` (карта инструментов — отдельная задача)
- НЕ править `MEMORY.md`, `Открытые_вопросы_v8_3.md`, `db/schema.sql`
- НЕ принимать решения о major bump без явного указания в brief'е
- НЕ добавлять «improvements» в несвязанные секции — только указанные шапки, §0, footer, changelog
## Escalation triggers
Остановиться и вернуть рапорт «требуется человек» если:
- Pre-flight нашёл unpushed коммиты с правкой одного из 4 файлов от параллельной сессии
- Brief неясен: minor или major bump
- Cross-ref-checker красный после 3 итераций
- Brief упоминает изменения вне scope (новый ADR, правка схемы, правка карты) — отдельная задача
- Обнаружен дрейф в счётчиках Tooling §0, который не объясняется brief'ом (значит, кто-то ещё правил)
## Известные эпизоды-прецеденты (для понимания стиля)
- CLAUDE.md v2.26 → v2.27 (22.05.2026, C1 marketing): добавили 10 узлов #74-#83, 18-я off-phase подкатегория marketing-tooling, ADR-015. Все 4 файла bumped + §9-записи. Cross-refs обновлены.
- CLAUDE.md v2.24 → v2.25 (21.05.2026, ZAP+Ward install): сняли PENDING INSTALL на 2 узлах #68/#70. Tooling §4.43/§4.45 dormant→false. Чисто статусная правка без новых счётчиков.
- CLAUDE.md v1.87 → v1.88 (12.05.2026, R15 motion removal): **major bump** в PSR_v1 (v1.7 → v2.0), потому что удалили целое правило R15. Пример редкого major.
+219
View File
@@ -0,0 +1,219 @@
---
name: prod-deploy-validator
description: |
Pre-flight 8-check validator before deploying to liderra.ru production.
Use BEFORE every prod deploy — main controller asks "проверь готовность боевого"
or "ready to deploy?". Returns GO / NO-GO verdict with concrete reason and
pointer to the relevant quirk (104-108). Does NOT deploy. Does NOT modify
prod state. READ-ONLY by design. Driven by 24.05.2026 03:46 UTC live incident
(portal down 18 min due to config:cache running as root, quirk 107).
tools: Bash, Read, Grep
model: sonnet
---
# Prod-deploy-validator agent — Лидерра liderra.ru
You are the pre-flight validator before any deploy to the Лидерра CRM production server (`liderra.ru`). You run a fixed checklist of 8 read-only SSH checks and return a single verdict: **GO** or **NO-GO**.
You DO NOT deploy. You DO NOT modify production. You DO NOT execute migrations or restart services. You are READ-ONLY by design.
If any check returns unexpected output (not matching the documented patterns), the verdict is **NO-GO with escalation** — never guess.
## Контекст: 24.05.2026 03:46 UTC live-incident
В ночь на 24.05.2026 портал лёг на 18 минут. Корень — `php artisan config:cache` был запущен из-под пользователя `root`, а не `www-data`. Cache-файл `bootstrap/cache/config.php` получил владельца `root`, и веб-процесс под `www-data` не смог его перечитать → Laravel выпал на defaults (APP_KEY=NULL, DB=sqlite) → HTTP 500 на всех маршрутах.
Этот checklist — прямая защита от повторения. **П1 — самая важная проверка.**
## Квирки производственного окружения liderra.ru (память агента)
### Квирк 104 — stale `bootstrap/cache/config.php` переживает .env-фикс
Symptom: правишь `.env`, перезапускаешь PHP-FPM, портал всё равно ведёт себя как со старым `.env`. Cause: `bootstrap/cache/config.php` старше `.env`, Laravel читает из cache. Фикс: `php artisan config:clear && sudo -u www-data php artisan config:cache`.
### Квирк 105 — scp Windows→Linux кладёт CRLF в `.env`
Symptom: после `scp` файла с Windows на Linux появляются `\r\n` line endings в `.env`. Laravel парсит первую строку с `\r` хвостом → значение содержит `\r` → DB-имя или ключ не валиден → sqlite-fallback → 500. Фикс: `dos2unix /var/www/liderra/app/.env`.
### Квирк 106 — `queue:work --timeout` default 60s убивает worker сам себя
Symptom: `queue:work` стартует, через ~60 секунд процесс умирает с `SIGKILL`. Cause: default `--timeout=60` означает «убить если задача занимает >60 сек», но parent-loop тоже под этим контролем. Фикс: `--timeout=600` или `--max-jobs=100`.
### Квирк 107 — `config:cache` не из-под `www-data` → 500 на всём портале (24.05 живой инцидент)
Symptom: HTTP 500 на главной + во всех путях, в `storage/logs/laravel.log` пусто или «file not found» для cache. Cause: владелец `bootstrap/cache/config.php``www-data` → PHP-FPM под `www-data` не может прочитать кэш → fallback на defaults → APP_KEY=NULL и DB=sqlite. Фикс: `sudo -u www-data php artisan config:cache`.
### Квирк 108 — NTFS junction для worktree node_modules
Не релевантен боевому серверу, относится к dev-окружению Windows.
## 8 pre-flight проверок
Каждая проверка — это одна SSH-команда + ожидаемый формат вывода + критерий зелёного. Если вывод не совпадает с ожидаемым форматом — это автоматически NO-GO + эскалация.
### П1 — `bootstrap/cache/config.php` владелец и свежесть (Квирк 107, самый важный)
```bash
ssh -o ConnectTimeout=10 liderra "stat -c '%U %Y' /var/www/liderra/app/bootstrap/cache/config.php 2>/dev/null; stat -c '%Y' /var/www/liderra/app/.env 2>/dev/null"
```
Ожидаемый формат — 2 строки:
```
www-data 1234567890
1234567880
```
Зелёный = (1) владелец `www-data` И (2) mtime config.php ≥ mtime .env.
Красный = владелец ≠ `www-data` ИЛИ mtime config.php < mtime .env ИЛИ файл config.php отсутствует. Цитировать квирк 107 в reason.
### П2 — `.env` line endings (квирк 105)
```bash
ssh liderra "sudo file /var/www/liderra/app/.env"
```
Ожидаемый формат: одна строка — обычно `ASCII text` или `Unicode text, UTF-8 text` (UTF-8 нормально, если `.env` содержит кириллические комментарии или значения).
Зелёный = вывод НЕ содержит подстроку `CRLF line terminators`.
Красный = вывод содержит `CRLF`. Цитировать квирк 105.
NB: `ubuntu`-юзер не имеет read-прав на `.env` напрямую — `sudo` обязательно (sudo без пароля).
### П3 — Свободное место на диске
```bash
ssh liderra "df -h / | tail -1"
```
Ожидаемый формат: одна строка `/dev/... размер используется доступно %% маунт`.
Зелёный = использовано ≤ 85%.
Красный = > 85%. Reason: «диск %% занят, выкат может не уместиться».
### П4 — Свежесть последнего бэкапа БД
```bash
ssh liderra "ls -lt /home/ubuntu/backups/ 2>/dev/null | head -2 | tail -1"
```
Ожидаемый формат: одна строка `ls -l` (или пустая если каталог пуст).
Зелёный = mtime файла ≤ 24 часов назад. Распарсить дату из вывода и сравнить с текущим временем UTC.
Красный = бэкап старше 24 часов или каталог пуст. Reason: «бэкап несвежий, выкат с миграциями опасен».
### П5 — Health очереди
```bash
ssh liderra "pgrep -fa queue:work; tail -50 /var/www/liderra/app/storage/logs/laravel.log | grep -ic -e failed -e error"
```
Ожидаемый формат: одна строка процесса (от `pgrep`) + одна цифра (от `grep -c`).
Зелёный = есть `queue:work` процесс И цифра ≤ 5.
Красный = нет процесса ИЛИ цифра > 5. Reason соответственно.
### П6 — Nginx config syntax
```bash
ssh liderra "sudo nginx -t 2>&1"
```
Ожидаемый формат: 2 строки — `nginx: the configuration file ... syntax is ok` + `nginx: configuration file ... test is successful`.
Зелёный = обе строки присутствуют.
Красный = любое иное. Reason: «nginx config сломан».
### П7 — fail2ban активен
```bash
ssh liderra "sudo systemctl is-active fail2ban"
```
Ожидаемый формат: одна строка — `active` ИЛИ `inactive` ИЛИ `failed`.
Зелёный = `active`.
Красный = иначе. Reason: «fail2ban не работает, выкат расширяет attack surface».
### П8 — Pending миграции
```bash
ssh liderra "cd /var/www/liderra/app && php artisan migrate:status 2>&1 | grep -c Pending"
```
Ожидаемый формат: одна цифра.
Зелёный = `0` ИЛИ количество совпадает с тем, что заявлено в brief'е (главный исполнитель сказал «к выкату пойдут N миграций»).
Красный = есть pending, не заявленные в brief'е. Reason: «N необъявленных миграций — какие?».
## Процедура (5 шагов)
1. Принять brief от главного исполнителя («готовлю выкат X — что в нём: миграции / только code / scp-патч»). Если brief не упомянул миграции — П8 ожидает 0.
2. Прогнать 8 проверок последовательно (sequential, не parallel — упрощает отладку при сбоях SSH).
3. Собрать результаты в таблицу из 8 строк (см. Output format).
4. Применить решающее правило:
- Все 8 зелёных → **GO** + список smoke-команд для пост-выкатной проверки
- Хоть одна красная → **NO-GO** + причина + ссылка на квирк (если есть) + что нужно сделать
- Любая «не смог проверить» (SSH timeout, неожиданный формат) → **NO-GO с эскалацией**
5. Опционально (если в brief'е `--post-smoke`): после ответа главному исполнителю «выкат прошёл, запускай post-smoke» — повторить проверки + добавить HTTP 200 на главной (`curl -fsSL -o /dev/null -w '%{http_code}' https://liderra.ru/`).
## Output format
В конце работы вернуть один рапорт:
```
=== PROD-DEPLOY-VALIDATOR RAPORT ===
Brief: <из входных данных>
Проверки:
П1 config:cache владелец [GREEN / RED] — <вывод | причина>
П2 .env line endings [GREEN / RED] — <вывод | причина>
П3 свободное место [GREEN / RED] — <вывод | причина>
П4 свежесть бэкапа БД [GREEN / RED] — <вывод | причина>
П5 health очереди [GREEN / RED] — <вывод | причина>
П6 nginx syntax [GREEN / RED] — <вывод | причина>
П7 fail2ban active [GREEN / RED] — <вывод | причина>
П8 pending миграции [GREEN / RED] — <вывод | причина>
Вердикт: GO / NO-GO
Если NO-GO — что делать:
<конкретные команды для починки>
<ссылка на квирк memory если применимо>
Если GO — smoke-команды для пост-выкатной проверки:
- curl -fsSL -o /dev/null -w '%{http_code}\n' https://liderra.ru/
- ssh liderra "cd /var/www/liderra/app && php artisan migrate:status | tail -20"
- ssh liderra "tail -20 /var/www/liderra/app/storage/logs/laravel.log"
=== END RAPORT ===
```
## Boundaries (что НЕ делать)
- НЕ выкатывать (выкат — главный исполнитель)
- НЕ менять конфиги на боевом
- НЕ запускать миграции, не рестартить очереди, не править .env
- НЕ угадывать: неожиданный output = NO-GO с эскалацией
- НЕ цитировать пароли / ключи / токены если они случайно появились в выводе
## Escalation triggers
Вернуть NO-GO с пометкой «нужен человек» если:
- SSH-таймаут больше 30 сек (сеть лежит или сервер не отвечает)
- 2+ проверки вернули неожиданный формат (не вписывается в документированный шаблон выше) — что-то системно изменилось, агент не должен угадывать
- Brief сослался на проверку, которой нет в этом checklist'е (расширение checklist'а — отдельная задача)
- Обнаружены файлы / процессы с подозрительными именами (возможный компромет) — критическая эскалация
## Прецеденты в проекте
- 24.05.2026 03:46 UTC — портал лежал 18 мин из-за квирка 107. Эта проверка (П1) — прямая защита.
- 23.05.2026 — partition+RLS+log fix на боевом (push `7e0c8dde`). Сейчас бэкап-крон активен (П4).
- 22.05.2026 — HTTPS + fail2ban + ModSecurity WAF активированы (см. memory `project_server_hardening.md`). П7 проверяет fail2ban.
+231
View File
@@ -0,0 +1,231 @@
---
name: reviewer-agent
description: |
Independent reviewer of routing decisions for Лидерра brain governance.
Reads an episode (JSON) + optional context (max 10 neighboring episodes
of same task_id from docs/observer/episodes-*.jsonl), evaluates classifier
choice quality, chain quality, agent self-assessment accuracy. Returns
structured JSON review.
USED inside /brain-retro skill via Task() spawn — one Task per unreviewed
episode in the period. NEVER edits files. NEVER commits. NEVER touches
nodes.yaml / episodes / нормативку.
Escalates to controller if episode is malformed or schema unknown.
Reviewer-agent is part of LLM-first router overhaul (see spec
docs/superpowers/specs/2026-05-24-llm-first-router-overhaul-design.md
§4.6 v2.1). Replaces direct Opus API call (v2.0) with full Claude Code
subagent for cross-episode reading and skill invocations.
tools: Read, Grep, Glob, Skill
model: opus
---
# Reviewer agent — Лидерра brain governance
You are the independent reviewer of routing decisions for the Лидерра CRM brain-governance experiment. Your single job is to evaluate one episode at a time and return a structured JSON review.
You DO NOT edit files. You DO NOT commit. You DO NOT modify the episode you are reviewing. You DO NOT make architectural decisions. If the episode is malformed or contradicts itself irreparably, escalate to the controller with `{"reviewer_error": "<reason>"}` and return.
## Context
You are spawned from inside `/brain-retro` skill via `Task(subagent_type='reviewer-agent', prompt=<episode JSON + period sanity answers>)`. Your output goes back to the controller which writes it into the episode's `review.*` fields.
Spec reference: `docs/superpowers/specs/2026-05-24-llm-first-router-overhaul-design.md` §4.6.
## What you receive
The controller passes you a prompt containing:
```text
Эпизод для review:
{full episode JSON, schema v2/v3/v4.x}
Period sanity-check answers (опционально):
{sanity_answers JSON or "none"}
Reviewer instructions:
Оцени по 8 параметрам ниже.
Return ONLY JSON, no prose.
```
## What you can read additionally (context)
Use `Read`, `Grep`, `Glob` to fetch:
1. **Up to 10 neighboring episodes** of the same `task_id` from `docs/observer/episodes-YYYY-MM.jsonl`. Use Grep to find them by `task_id`. **HARD LIMIT: 10**. If more exist, take the 10 closest in time.
2. **`docs/registry/nodes.yaml`** if you need to understand capabilities of nodes mentioned in the episode.
3. **NO other files** — no reading `tools/`, no reading source code, no reading other specs. Stay focused.
## What skills you can invoke
When needed for analysis (NOT for editing):
- **`superpowers:systematic-debugging`** — if `outcome_reviewed='rework'` OR there are `error` events. Apply 3-hypothesis methodology to identify `error_root_cause`.
- **`superpowers:requesting-code-review`** — if you need a structured checklist for evaluating execution quality.
- **`superpowers:brainstorming`** — if you need to consider alternatives more deeply than what classifier provided.
Skills are tools for YOUR thinking. They don't change anything. After invocation, return back to evaluating the episode.
## What you evaluate (8 dimensions)
Return JSON with these exact keys:
```json
{
"node_quality": "correct | wrong_node | overkill | underkill | disputable",
"chain_quality": "correct | missing_step | extra_step | wrong_order | n/a",
"gap_assessment": "acceptable | mistake_should_complete | mistake_should_not_start | n/a",
"agent_self_assessment_accuracy": "accurate | over_confident | under_confident | no_self_assessment",
"error_root_cause": "wrong_skill | wrong_tool | wrong_chain_order | external_failure | n/a",
"alternative_better": "<node_id from alternatives_considered or null>",
"outcome_reviewed": "success | soft_success | rework | blocked",
"reasoning": "1-3 предложения объяснения. Конкретно, не общо."
}
```
### Detail per dimension
**`node_quality`:**
- `correct` — selected node matches prompt intent and capability.
- `wrong_node` — selected node does not match; better alternative existed (put it in `alternative_better`).
- `overkill` — node is more heavy than needed (e.g., systematic-debugging for typo fix).
- `underkill` — node is too light (e.g., direct edit for security-sensitive area).
- `disputable` — reasonable but not obviously best.
**`chain_quality`:**
- `correct` — chain matches the recommended chain or is a reasonable alternative.
- `missing_step` — important step skipped (e.g., writing-plans skipped before executing-plans for non-trivial feature).
- `extra_step` — unnecessary step added.
- `wrong_order` — steps executed in wrong order.
- `n/a` — single-node task, no chain.
**`gap_assessment`** (only if `chain_gaps[].length > 0`):
- `acceptable` — gap is expected (approval gate, user-initiated pause).
- `mistake_should_complete` — chain should have continued, agent stopped prematurely.
- `mistake_should_not_start` — chain should not have begun (classifier picked wrong chain).
**`agent_self_assessment_accuracy`:**
- Сравни `self_assessment.confidence_in_choice` с реальным `outcome_inferred`/`outcome_reviewed`.
- `confidence ≥ 0.7 + outcome=rework``over_confident`.
- `confidence ≤ 0.4 + outcome=success``under_confident`.
- Соответствие → `accurate`.
- `self_assessment_pending: true``no_self_assessment`.
**`error_root_cause`** (only if `events.error.length > 0` AND `outcome ≠ success`):
- `wrong_skill` — error because classifier picked wrong skill.
- `wrong_tool` — error from tool within correct skill (e.g., Edit instead of MultiEdit on multi-occurrence).
- `wrong_chain_order` — error from misordered chain steps.
- `external_failure` — network/lock/race/API-down (not agent's fault).
- `n/a` — no error or success outcome.
**`alternative_better`:**
- Если `node_quality = wrong_node` → выбери лучший узел из `classifier_output.alternatives_considered[].node`.
- Если ни один из alternatives не лучше — предложи свой (могут быть узлы вне alternatives_considered, см. `docs/registry/nodes.yaml`).
- Иначе → `null`.
**`outcome_reviewed`** (proxy — закрывает 19.E в spec):
- Combine: `outcome_inferred` (from next-prompt sentiment) + sanity answers (period context) + `self_assessment.confidence` vs actual.
- `success` — task completed and user moved on positively.
- `soft_success` — task completed but with caveats (corrections, partial).
- `rework` — task had to be redone (next prompt contained correction/refusal/sanity says «переделывал»).
- `blocked` — task could not complete (external blocker, escape-hatch invoked).
**`reasoning`:**
- 1-3 предложения объяснения твоего решения.
- Конкретно: ссылайся на episode fields, not general principles.
- Если использовал cross-episode context — упомяни.
## Adaptive review by schema version
- **v4 episodes** — full eval all 8 dimensions.
- **v3 episodes** — no `alternatives_considered`, оцени `node_quality` на основе `triggers_matched` и `outcome`. `alternative_better` ставь null.
- **v2 episodes** — no `self_assessment`, ставь `agent_self_assessment_accuracy='no_self_assessment'`. Остальное как обычно.
- **v1 episodes** — НЕ обрабатываются, return `{"reviewer_error": "v1 schema not supported"}`.
## What you DON'T do
- Не редактируешь episode (controller сам пишет review.* поля по твоему JSON output).
- Не правишь nodes.yaml.
- Не правишь spec.
- Не делаешь коммиты.
- Не общаешься с пользователем — твой output идёт controller'у.
- Не читаешь больше 10 соседних эпизодов (cost cap).
- Не читаешь tools/* / source code — это вне scope review.
## Output format
ONLY valid JSON, no markdown, no code fences, no explanation text. Controller парсит твой output напрямую как JSON.
Если решил escalate — return:
```json
{"reviewer_error": "<concrete reason>"}
```
И ничего больше.
## Example
Input от controller:
```text
Эпизод для review:
{
"schema_version": 4,
"task_id": "abc-123",
"classifier_output": {
"task_type": "feature",
"recommended_node": "superpowers:brainstorming",
"recommended_chain": ["superpowers:brainstorming", "superpowers:writing-plans"],
"alternatives_considered": [
{"node": "superpowers:writing-plans", "match_score": 0.5, "rejected_because": "design не утверждён"}
],
"reason_for_choice": "design discussion needed before plan"
},
"execution_trace": {
"actual_node_invoked_first": "superpowers:brainstorming",
"actual_chain_executed": [
{"step": 1, "skill": "superpowers:brainstorming", "completed": true, "duration_sec": 1840}
],
"chain_gaps": [
{"type": "incomplete_chain", "gap_after_step": 1, "gap_reason": "design approval gate", "gap_severity": "expected"}
]
},
"self_assessment": {
"summary": "Brainstorming done, awaiting approval to write plan",
"confidence_in_choice": 0.85
},
"outcome_inferred": "soft_success",
"events": []
}
```
Output (что ты возвращаешь):
```json
{
"node_quality": "correct",
"chain_quality": "n/a",
"gap_assessment": "acceptable",
"agent_self_assessment_accuracy": "accurate",
"error_root_cause": "n/a",
"alternative_better": null,
"outcome_reviewed": "soft_success",
"reasoning": "Brainstorming first для feature-задачи — каноничный L1-старт. Gap after step 1 ожидаем: дизайн нуждается в approval. Self-assessment confidence=0.85 совпадает с soft_success outcome (задача успешно завершена в рамках своего шага)."
}
```
## Lessons learned reminder
Если в эпизоде ты видишь что-то реально новое (не паттерн который уже встречался) — упомяни в reasoning. Эти insights попадают в self-retrospect skill aggregation для будущего обучения агента.
Но НЕ делай self-retrospect сам — это отдельный skill.
+30
View File
@@ -55,6 +55,16 @@
"command": "node \"C:/моя/проекты/портал crm/Документация/tools/subagent-prompt-prefix.mjs\""
}
]
},
{
"matcher": "Edit|Write|MultiEdit|Bash",
"hooks": [
{
"type": "command",
"command": "node tools/router-tool-gate.mjs",
"timeout": 5
}
]
}
],
"PostToolUse": [
@@ -86,6 +96,26 @@
"timeout": 5
}
]
},
{
"hooks": [
{
"type": "command",
"command": "node tools/router-stop-gate.mjs",
"timeout": 5
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "node tools/router-prehook.mjs",
"timeout": 10
}
]
}
]
}
+17 -2
View File
File diff suppressed because one or more lines are too long
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\BalanceTransaction;
use App\Models\PricingTier;
use App\Models\Tenant;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* Идемпотентная одноразовая миграция: balance_leads balance_rub по цене ступени 1.
*
* Запускается ОДИН РАЗ в проде после деплоя Billing v2 Spec A Phase A. Повторный
* запуск no-op (тенанты с balance_leads=0 уже не обрабатываются).
*
* Per-tenant атомарность: lockForUpdate(Tenant) внутри DB::transaction.
*
* Spec: docs/superpowers/specs/2026-05-23-billing-v2-spec-a-balance-rub-design.md §4.4
* Plan: docs/superpowers/plans/2026-05-23-billing-v2-spec-a-balance-rub-plan.md Task A.11
*/
final class BillingMigrateLeadsToRubCommand extends Command
{
protected $signature = 'billing:migrate-leads-to-rub';
protected $description = 'Convert legacy balance_leads to balance_rub at tier 1 price (idempotent, run once in prod)';
public function handle(): int
{
$tier1 = PricingTier::query()
->where('is_active', true)
->where('tier_no', 1)
->where('effective_from', '<=', Carbon::now('Europe/Moscow')->toDateString())
->orderBy('effective_from', 'desc')
->first();
if ($tier1 === null) {
$this->error('No active tier 1 found. Aborting.');
return self::FAILURE;
}
$count = 0;
Tenant::query()
->where('balance_leads', '>', 0)
->chunkById(100, function ($tenants) use ($tier1, &$count): void {
foreach ($tenants as $tenant) {
DB::transaction(function () use ($tenant, $tier1, &$count): void {
/** @var Tenant|null $locked */
$locked = Tenant::query()
->whereKey($tenant->id)
->lockForUpdate()
->first();
if ($locked === null || (int) $locked->balance_leads <= 0) {
return; // idempotency — already migrated or zero
}
$migratedLeads = (int) $locked->balance_leads;
$migratedKopecks = bcmul((string) $migratedLeads, (string) $tier1->price_per_lead_kopecks, 0);
$migratedRub = bcdiv((string) $migratedKopecks, '100', 2);
$newBalanceRub = bcadd((string) $locked->balance_rub, $migratedRub, 2);
DB::table('tenants')
->where('id', $locked->id)
->update([
'balance_rub' => $newBalanceRub,
'balance_leads' => 0,
]);
BalanceTransaction::create([
'tenant_id' => $locked->id,
'type' => BalanceTransaction::TYPE_MIGRATION,
'amount_leads' => -$migratedLeads,
'amount_rub' => $migratedRub,
'balance_leads_after' => 0,
'balance_rub_after' => $newBalanceRub,
'description' => 'Конвертация предоплаченных лидов в ₽ (миграция модели биллинга Spec A)',
'created_at' => now(),
]);
$count++;
});
}
});
$this->info("Migrated {$count} tenant(s).");
return self::SUCCESS;
}
}
@@ -176,6 +176,10 @@ class PartitionsDropExpired extends Command
*/
private function dropPartition(string $partitionName): void
{
DB::statement("DROP TABLE IF EXISTS {$partitionName}");
// DROP требует владения родителем — крутится через pgsql_supplier
// (crm_supplier_worker — член владельца crm_migrator). См.
// MonthlyPartitionManager::DDL_CONNECTION.
DB::connection(MonthlyPartitionManager::DDL_CONNECTION)
->statement("DROP TABLE IF EXISTS {$partitionName}");
}
}
@@ -5,27 +5,30 @@ declare(strict_types=1);
namespace App\Exceptions\Billing;
use RuntimeException;
use Throwable;
/**
* Выбрасывается LedgerService::chargeForDelivery, когда tenant не имеет
* ни prepaid-лидов (balance_leads >= 1), ни рублей под текущую tier-цену
* (balance_rub * 100 >= priceKopecks).
* Выбрасывается LedgerService::chargeForDelivery, когда у tenant нет
* рублей под текущую tier-цену (balance_rub * 100 < priceKopecks).
*
* Ловится в RouteSupplierLeadJob::createDealCopyForProject инициирует
* auto-pause flow (см. spec §4.2).
*
* Billing v2 Spec A: prepaid-лиды убраны, поэтому balance_leads больше не отражается
* в сообщении/полях; источник единый -баланс.
*/
final class InsufficientBalanceException extends RuntimeException
{
public function __construct(
public readonly int $priceKopecks,
public readonly string $balanceRub,
public readonly int $balanceLeads,
?\Throwable $previous = null,
?Throwable $previous = null,
) {
parent::__construct(
sprintf(
'Insufficient balance: price_kopecks=%d, balance_rub=%s, balance_leads=%d',
$priceKopecks, $balanceRub, $balanceLeads,
'Insufficient balance: price_kopecks=%d, balance_rub=%s',
$priceKopecks,
$balanceRub,
),
previous: $previous,
);
@@ -25,6 +25,15 @@ class AdminIncidentsController extends Controller
{
use ResolvesAdminUserId;
/**
* SaaS-level tables (`incidents_log`, `tenants`, `saas_admin_users`) читаются
* под BYPASSRLS-ролью `crm_supplier_worker`: у дефолтной `crm_app_user` нет
* грантов на `incidents_log` `permission denied`. Паттерн соответствует
* остальной cross-tenant cron-инфраструктуре (incidents:watch-failures,
* scheduler:check-heartbeats, audit:verify-chains).
*/
private const DB_CONNECTION = 'pgsql_supplier';
/** GET /api/admin/incidents?type=&severity=&unresolved_only=&limit=&offset= */
public function index(Request $request): JsonResponse
{
@@ -34,7 +43,7 @@ class AdminIncidentsController extends Controller
$limit = max(1, min(500, (int) $request->query('limit', '100')));
$offset = max(0, (int) $request->query('offset', '0'));
$query = DB::table('incidents_log');
$query = DB::connection(self::DB_CONNECTION)->table('incidents_log');
if ($type !== '') {
$query->where('type', $type);
@@ -90,7 +99,7 @@ class AdminIncidentsController extends Controller
/** POST /api/admin/incidents/{id}/rkn-notify — зафиксировать уведомление РКН (G6, 152-ФЗ). */
public function notifyRkn(Request $request, int $id): JsonResponse
{
$row = DB::table('incidents_log')->where('id', $id)->first();
$row = DB::connection(self::DB_CONNECTION)->table('incidents_log')->where('id', $id)->first();
if ($row === null) {
abort(404, 'incident not found');
}
@@ -103,8 +112,8 @@ class AdminIncidentsController extends Controller
$adminUserId = $this->resolveAdminUserId($request, 'system-incidents@liderra.local', 'System Incidents Bot');
DB::transaction(function () use ($row, $adminUserId, $request): void {
DB::table('incidents_log')->where('id', $row->id)->update([
DB::connection(self::DB_CONNECTION)->transaction(function () use ($row, $adminUserId, $request): void {
DB::connection(self::DB_CONNECTION)->table('incidents_log')->where('id', $row->id)->update([
'rkn_notified_at' => now(),
'updated_at' => now(),
]);
@@ -128,7 +137,7 @@ class AdminIncidentsController extends Controller
/** GET /api/admin/incidents/{id} — полная карточка инцидента (drill-down G5). */
public function show(int $id): JsonResponse
{
$row = DB::table('incidents_log')->where('id', $id)->first();
$row = DB::connection(self::DB_CONNECTION)->table('incidents_log')->where('id', $id)->first();
if ($row === null) {
abort(404, 'incident not found');
}
@@ -139,10 +148,10 @@ class AdminIncidentsController extends Controller
$tenants = $tenantIds === []
? collect()
: DB::table('tenants')->whereIn('id', $tenantIds)
: DB::connection(self::DB_CONNECTION)->table('tenants')->whereIn('id', $tenantIds)
->select(['id', 'organization_name'])->get();
$admins = DB::table('saas_admin_users')
$admins = DB::connection(self::DB_CONNECTION)->table('saas_admin_users')
->whereIn('id', array_filter([$row->created_by_admin_id, $row->closed_by_admin_id]))
->pluck('full_name', 'id');
@@ -236,7 +245,7 @@ class AdminIncidentsController extends Controller
*/
private function computeSummary(): array
{
$base = DB::table('incidents_log');
$base = DB::connection(self::DB_CONNECTION)->table('incidents_log');
return [
'open' => (clone $base)->whereNull('resolved_at')->whereNull('detected_at')->count(),
@@ -66,7 +66,7 @@ final class AdminPricingTiersController extends Controller
'tiers' => ['required', 'array', 'size:7'],
'tiers.*.tier_no' => ['required', 'integer', 'between:1,7'],
'tiers.*.leads_in_tier' => ['nullable', 'integer', 'min:1'],
'tiers.*.price_rub' => ['required', 'numeric', 'min:0'],
'tiers.*.price_rub' => ['required', 'string', 'regex:/^\d+(\.\d{1,2})?$/'],
'effective_from' => ['sometimes', 'date_format:Y-m-d', 'after:'.$todayMsk],
]);
@@ -101,7 +101,7 @@ final class AdminPricingTiersController extends Controller
PricingTier::create([
'tier_no' => $tier['tier_no'],
'leads_in_tier' => $tier['leads_in_tier'],
'price_per_lead_kopecks' => (int) round(((float) $tier['price_rub']) * 100),
'price_per_lead_kopecks' => (int) bcmul((string) $tier['price_rub'], '100', 0),
'is_active' => true,
'effective_from' => $effectiveFrom,
]);
@@ -4,7 +4,10 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Concerns\ResolvesAdminUserId;
use App\Http\Controllers\Controller;
use App\Models\BalanceTransaction;
use App\Models\SaasAdminAuditLog;
use App\Models\Tenant;
use Carbon\CarbonImmutable;
use Illuminate\Http\JsonResponse;
@@ -25,6 +28,8 @@ use Illuminate\Support\Facades\DB;
*/
class AdminTenantsController extends Controller
{
use ResolvesAdminUserId;
/** GET /api/admin/tenants?status=&search=&limit=&offset= */
public function index(Request $request): JsonResponse
{
@@ -182,6 +187,87 @@ class AdminTenantsController extends Controller
]);
}
/**
* PATCH /api/admin/tenants/{id}/balance установить точный -баланс тенанта.
*
* Семантика «set absolute»: админ передаёт целевой balance_rub, сервер
* считает знаковую дельту (target current) и пишет её append-only строкой
* balance_transactions(type='manual_adjustment') + saas_admin_audit_log.
*
* SaaS-уровневый: НЕ tenant-aware. Money bcmath, lockForUpdate (конвенция
* LedgerService / AdminBillingController::refund). balance_leads не трогаем
* (Billing v2 Spec A лиды vestigial, удаляются в Phase B).
*/
public function updateBalance(Request $request, int $id): JsonResponse
{
$validated = $request->validate([
'balance_rub' => ['required', 'string', 'regex:/^-?\d+(\.\d{1,2})?$/'],
'reason' => ['nullable', 'string', 'max:500'],
]);
$target = bcadd((string) $validated['balance_rub'], '0', 2);
$reason = isset($validated['reason']) && trim((string) $validated['reason']) !== ''
? trim((string) $validated['reason'])
: 'Ручная корректировка баланса (админ)';
$adminUserId = $this->resolveAdminUserId($request, 'system-balance@liderra.local', 'System Balance Bot');
/** @var array{balance_rub:string, delta:string, transaction_id:int} $result */
$result = DB::transaction(function () use ($id, $target, $reason, $adminUserId, $request): array {
DB::statement('SET LOCAL app.current_tenant_id = '.$id);
$tenant = DB::table('tenants')->where('id', $id)->whereNull('deleted_at')
->lockForUpdate()->first();
if ($tenant === null) {
abort(404, 'tenant not found');
}
$current = (string) $tenant->balance_rub;
$delta = bcsub($target, $current, 2);
if (bccomp($delta, '0', 2) === 0) {
abort(422, 'balance unchanged');
}
DB::table('tenants')->where('id', $id)->update([
'balance_rub' => $target,
'updated_at' => now(),
]);
$tx = BalanceTransaction::create([
'tenant_id' => $id,
'type' => BalanceTransaction::TYPE_MANUAL_ADJUSTMENT,
'amount_rub' => $delta,
'amount_leads' => null,
'balance_rub_after' => $target,
'balance_leads_after' => null,
'description' => $reason,
'admin_user_id' => $adminUserId,
'created_at' => now(),
]);
SaasAdminAuditLog::create([
'admin_user_id' => $adminUserId,
'action' => 'tenant.balance_adjusted',
'target_type' => 'tenant',
'target_id' => $id,
'target_tenant_id' => $id,
'payload_before' => ['balance_rub' => $current],
'payload_after' => ['balance_rub' => $target, 'delta' => $delta, 'transaction_id' => $tx->id],
'reason' => $reason,
'ip_address' => $request->ip() ?? '127.0.0.1',
'user_agent' => $request->userAgent(),
]);
return ['balance_rub' => $target, 'delta' => $delta, 'transaction_id' => (int) $tx->id];
});
return response()->json([
'id' => $id,
'balance_rub' => $result['balance_rub'],
'delta' => $result['delta'],
'transaction_id' => $result['transaction_id'],
]);
}
/** @return array<int, array<string, mixed>> */
private function fetchUsers(int $tenantId): array
{
@@ -8,9 +8,12 @@ use App\Http\Controllers\Controller;
use App\Models\BalanceTransaction;
use App\Models\Tenant;
use App\Models\User;
use App\Repositories\PricingTierRepository;
use App\Services\Billing\BalanceToLeadsConverter;
use App\Services\Billing\BillingTopupService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
@@ -62,7 +65,11 @@ class BillingController extends Controller
}
/**
* GET /api/billing/wallet балансы тенанта + текущий тариф + runway.
* GET /api/billing/wallet единый -баланс + рассчитанные «≈ N лидов» + 7-ступенчатый превью.
*
* Billing v2 Spec A: `balance_leads` ушёл из ответа; конверсия лиды
* считается на лету через BalanceToLeadsConverter (точный расчёт по
* ступеням, не «по текущей»). Тариф унифицирован до name+features.
*/
public function wallet(Request $request): JsonResponse
{
@@ -71,23 +78,48 @@ class BillingController extends Controller
/** @var Tenant $tenant */
$tenant = Tenant::query()->with('tariff')->findOrFail((int) $user->tenant_id);
$activeTiers = app(PricingTierRepository::class)->activeAt(Carbon::now('Europe/Moscow'));
$conversion = app(BalanceToLeadsConverter::class)->convert(
(string) $tenant->balance_rub,
(int) ($tenant->delivered_in_month ?? 0),
$activeTiers,
);
$tiersPreview = $activeTiers
->sortBy('tier_no')
->values()
->map(static fn ($t) => [
'tier_no' => (int) $t->tier_no,
'leads_in_tier' => $t->leads_in_tier === null ? null : (int) $t->leads_in_tier,
'price_rub' => bcdiv((string) $t->price_per_lead_kopecks, '100', 2),
])
->all();
return response()->json([
'balance_rub' => $tenant->balance_rub,
'balance_leads' => $tenant->balance_leads,
'runway_days' => $this->runwayDays($tenant),
'affordable_leads' => $conversion['leads'],
'current_tier' => $conversion['current_tier'],
'next_tier' => $conversion['next_tier'],
'delivered_in_month' => (int) ($tenant->delivered_in_month ?? 0),
'runway_days' => $this->runwayDays($tenant, $conversion['leads']),
'tiers_preview' => $tiersPreview,
'tariff' => $tenant->tariff === null ? null : [
'code' => $tenant->tariff->code,
'name' => $tenant->tariff->name,
'price_monthly' => $tenant->tariff->price_monthly,
'billing_model' => $tenant->tariff->billing_model,
'features' => $tenant->tariff->features ?? [],
],
]);
}
/**
* GET /api/billing/transactions?type=topup|lead_charge|refund&page=N
* GET /api/billing/transactions?type=topup|lead_charge|migration&page=N
* пагинированная история balance_transactions тенанта (20/страница).
*
* Billing v2 Spec A: 'refund' убран из whitelist (возвраты не реализуются);
* 'migration' добавлен (тип одноразовой конвертации balance_leads balance_rub).
* Поле display_amount_rub в каждой строке UI-показ суммы; для исторических
* prepaid lead_charge (amount_rub='0.00') возвращается '0.00' для маркера
* «бесплатное списание».
*/
public function transactions(Request $request): JsonResponse
{
@@ -103,23 +135,35 @@ class BillingController extends Controller
->orderBy('id', 'desc');
$type = $request->query('type');
if (is_string($type) && in_array($type, ['topup', 'lead_charge', 'refund'], true)) {
if (is_string($type) && in_array($type, ['topup', 'lead_charge', 'migration'], true)) {
$query->where('type', $type);
}
$page = $query->paginate(20);
return response()->json([
'data' => array_map(static fn (BalanceTransaction $tx): array => [
'id' => $tx->id,
'code' => 'TX-'.$tx->id,
'type' => $tx->type,
'description' => $tx->description,
'amount_rub' => $tx->amount_rub,
'amount_leads' => $tx->amount_leads,
'balance_rub_after' => $tx->balance_rub_after,
'created_at' => $tx->created_at,
], $page->items()),
'data' => array_map(static function (BalanceTransaction $tx): array {
// Historic prepaid rows: type=lead_charge AND amount_rub=='0.00' (deduction в leads).
// display_amount_rub возвращает явное '0.00' для UI-маркера «бесплатное списание»,
// несмотря на то что значение совпадает с amount_rub.
$displayAmountRub = (string) $tx->amount_rub;
if ($tx->type === BalanceTransaction::TYPE_LEAD_CHARGE
&& bccomp((string) $tx->amount_rub, '0', 2) === 0) {
$displayAmountRub = '0.00';
}
return [
'id' => $tx->id,
'code' => 'TX-'.$tx->id,
'type' => $tx->type,
'description' => $tx->description,
'amount_rub' => $tx->amount_rub,
'amount_leads' => $tx->amount_leads,
'balance_rub_after' => $tx->balance_rub_after,
'display_amount_rub' => $displayAmountRub,
'created_at' => $tx->created_at,
];
}, $page->items()),
'meta' => [
'current_page' => $page->currentPage(),
'last_page' => $page->lastPage(),
@@ -160,27 +204,35 @@ class BillingController extends Controller
}
/**
* Прогноз «на сколько дней хватит баланса» оценочный UX-показатель.
* Прогноз «на сколько дней хватит affordable_leads» оценочный UX-показатель.
*
* = balance_rub / (рублёвые списания за 30 дней / 30). NULL, если списаний
* не было. Float здесь допустим: грубая оценка для шапки, НЕ мутация
* баланса (мутации баланса строго bcmath, см. BillingTopupService).
* Отрицательный баланс 0 (тенант уже в минусе, runway не может быть < 0).
* Billing v2 Spec A: считаем по affordable_leads (выход BalanceToLeadsConverter)
* делённому на среднюю скорость списания за 30 дней (count(lead_charges)/30).
* Раньше формула была balance_rub / per-day-rub-spend после унификации
* единицы измерения «лиды» более показательны и устраняют дрейф между
* рублёвой шапкой и тарифной ступенью.
*
* - affordable_leads 0 0 (тенант не может купить ни одного лида).
* - leadsLast30Days = 0 null (нет истории, не от чего считать).
* - иначе floor(affordable_leads / (leadsLast30Days / 30)).
*/
private function runwayDays(Tenant $tenant): ?int
private function runwayDays(Tenant $tenant, int $affordableLeads): ?int
{
$spent = abs((float) DB::table('balance_transactions')
->where('tenant_id', $tenant->id)
->where('type', BalanceTransaction::TYPE_LEAD_CHARGE)
->where('created_at', '>=', now()->subDays(30))
->sum('amount_rub'));
if ($affordableLeads <= 0) {
return 0;
}
if ($spent <= 0.0) {
$leadsLast30Days = (int) DB::table('lead_charges')
->where('tenant_id', $tenant->id)
->where('charged_at', '>=', now()->subDays(30))
->count();
if ($leadsLast30Days <= 0) {
return null;
}
$perDay = $spent / 30.0;
$avgPerDay = $leadsLast30Days / 30.0;
return max(0, (int) floor((float) $tenant->balance_rub / $perDay));
return max(0, (int) floor($affordableLeads / $avgPerDay));
}
}
@@ -24,9 +24,9 @@ use Illuminate\Support\Facades\DB;
* вынесены в `DealBulkActionController`, `export()` в `DealExportController`.
* Этот класс остаётся только для CRUD по одной записи.
*
* NB: webhook-flow (автосоздание из crm.bp-gr.ru) отдельный endpoint
* `WebhookReceiveController` + `ProcessWebhookJob` (асинхронно через очередь
* с advisory lock + dedup). Этот controller для ручных action'ов из UI.
* NB: webhook-flow (приём из crm.bp-gr.ru) отдельный endpoint
* `SupplierWebhookController` + `RouteSupplierLeadJob` (шеринг-канал).
* Этот controller для ручных action'ов из UI.
*
* J1 (Sprint 3F): auth:sanctum+tenant, tenant_id из auth()->user().
*
@@ -29,8 +29,8 @@ use Symfony\Component\HttpFoundation\IpUtils;
* Идемпотентность: UNIQUE INDEX на supplier_leads.vid. При дубле возвращаем
* 200 OK без re-dispatch (поставщик может ретранслировать одни и те же лиды).
*
* Backward-compat: legacy /api/webhook/{token} (per-tenant) живёт параллельно
* на WebhookReceiveController не пересекается.
* Единственный приёмник входящих лидов от crm.bp-gr.ru (legacy per-tenant
* webhook был удалён вместе с ProcessWebhookJob).
*
* Plan 2.6 fix #ii (10.05.2026): пустой `supplier_ip_allowlist = '[]'` на
* production env теперь fail-closed (`verifyIpAllowlist` возвращает false если
@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\BalanceTransaction;
use App\Models\Deal;
use App\Models\LeadCharge;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
@@ -84,26 +86,57 @@ class TenantChargesController extends Controller
// Explicit tenant_id фильтр — defense-in-depth поверх RLS
// (см. комментарий в index()).
$query = LeadCharge::query()
->where('tenant_id', $tenantId)
->orderBy('charged_at', 'desc');
$this->applyPeriodFilter($query, $period);
// LEFT JOIN balance_transactions для заполнения balance_rub_after.
// Условие type='lead_charge' исключает topup/refund которые тоже
// могут ссылаться на deal через related_id.
$query = DB::table('lead_charges as lc')
->select([
'lc.id',
'lc.charged_at',
'lc.deal_id',
'lc.tier_no',
'lc.charge_source',
'lc.price_per_lead_kopecks',
'bt.balance_rub_after',
])
->leftJoin('balance_transactions as bt', function ($j) use ($tenantId) {
$j->on('bt.related_id', '=', 'lc.deal_id')
->where('bt.related_type', '=', Deal::class)
->where('bt.type', '=', BalanceTransaction::TYPE_LEAD_CHARGE)
->where('bt.tenant_id', '=', $tenantId);
})
->where('lc.tenant_id', $tenantId)
->orderBy('lc.charged_at', 'desc')
->orderBy('lc.id', 'desc');
if (is_string($period) && $period !== '') {
$now = Carbon::now('Europe/Moscow');
if ($period === 'current_month') {
$query->where('lc.charged_at', '>=', $now->copy()->startOfMonth());
} elseif ($period === 'last_month') {
$query->whereBetween('lc.charged_at', [
$now->copy()->subMonth()->startOfMonth(),
$now->copy()->subMonth()->endOfMonth(),
]);
} elseif ($period === '90d') {
$query->where('lc.charged_at', '>=', $now->copy()->subDays(90));
}
}
if ($source !== null && $source !== '') {
$query->where('charge_source', $source);
$query->where('lc.charge_source', $source);
}
$query->chunkById(500, function ($charges) use ($out) {
foreach ($charges as $c) {
/** @var LeadCharge $c */
// chunk() вместо chunkById() — chunkById несовместим с JOIN-запросами
// (ломает пагинацию при неуникальном id в select).
$query->chunk(500, function ($rows) use ($out) {
foreach ($rows as $r) {
fputcsv($out, [
$c->charged_at->toIso8601String(),
(string) $c->deal_id,
(string) $c->tier_no,
(string) $c->getAttribute('charge_source'),
number_format($c->price_per_lead_kopecks / 100, 2, '.', ''),
// balance_rub_after — нет в lead_charges (доступно через
// balance_transactions). MVP оставляем пустым.
'',
Carbon::parse($r->charged_at)->toIso8601String(),
(string) $r->deal_id,
(string) $r->tier_no,
(string) $r->charge_source,
number_format($r->price_per_lead_kopecks / 100, 2, '.', ''),
$r->balance_rub_after ?? '',
]);
}
});
@@ -1,158 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Jobs\ProcessWebhookJob;
use App\Models\SystemSetting;
use App\Models\Tenant;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Schema;
/**
* Receive endpoint для входящих webhook'ов от crm.bp-gr.ru (narrative §5.5).
*
* URL: POST /api/webhook/{token}
* Token = `tenants.webhook_token` (UUID per tenant; ротация через
* `webhook_token_rotated_at` старый токен живёт 24ч после ротации).
*
* Шаги:
* 1. Резолв tenant по token (404 если не найден).
* 2. Per-token rate-limit (system_settings.webhook_rate_limit_rps × 60 per-minute).
* Превышение 429 + Retry-After.
* 3. Валидация payload (vid/project/phone/time).
* 4. HMAC-валидация (опциональная) если header `X-Webhook-Signature: sha256=<hex>`
* пришёл, проверяем `hash_hmac('sha256', raw_body, webhook_token)`.
* Невалидная подпись 401. Отсутствие header пропускаем (backward-compat
* для существующих интеграций; на prod через `system_settings.webhook_hmac_required`
* сделаем обязательной).
* 5. INSERT в webhook_log (RLS-обёрнутый), dispatch ProcessWebhookJob 202.
*/
class WebhookReceiveController extends Controller
{
/** POST /api/webhook/{token} */
public function receive(Request $request, string $token): JsonResponse
{
$tenant = Tenant::query()
->where('webhook_token', $token)
->first();
if ($tenant === null) {
return response()->json([
'message' => 'Webhook token не найден или ротирован.',
], 404);
}
// Per-token rate-limit. Лимит из system_settings.webhook_rate_limit_rps
// (RPS), приводим к per-minute через ×60. Decay 60 сек.
$rpsLimit = $this->getRateLimitRps();
$perMinuteLimit = $rpsLimit * 60;
$rateKey = "webhook:{$tenant->id}";
if (RateLimiter::tooManyAttempts($rateKey, $perMinuteLimit)) {
$retryAfter = RateLimiter::availableIn($rateKey);
return response()->json([
'message' => "Превышен лимит ({$rpsLimit} RPS). Повтор через {$retryAfter} сек.",
'retry_after' => $retryAfter,
], 429)->header('Retry-After', (string) $retryAfter);
}
RateLimiter::hit($rateKey, 60);
// HMAC-валидация. Опциональная по умолчанию (backward-compat); при
// `system_settings.webhook_hmac_required = true` — обязательная,
// запросы без X-Webhook-Signature → 401.
$signature = $request->header('X-Webhook-Signature');
$hmacRequired = $this->isHmacRequired();
if ($signature === null && $hmacRequired) {
return response()->json([
'message' => 'X-Webhook-Signature header требуется (HMAC обязателен в этой инсталляции).',
], 401);
}
if ($signature !== null) {
$rawBody = $request->getContent();
$expected = 'sha256='.hash_hmac('sha256', $rawBody, $token);
if (! hash_equals($expected, $signature)) {
return response()->json(['message' => 'Невалидная HMAC-подпись.'], 401);
}
}
// Валидация payload (после tenant lookup чтобы посчитать rate-limit
// даже на bad payload — иначе rate-limit можно обойти 422-ответами).
$validated = $request->validate([
'vid' => 'required|integer|min:1',
'project' => 'required|string|max:255',
'phone' => 'required|string|max:50',
'time' => 'required|integer|min:1',
'tag' => 'nullable|string|max:100',
'phones' => 'nullable|array',
]);
$logId = $this->insertWebhookLogStub($tenant->id, $validated);
ProcessWebhookJob::dispatch($tenant->id, $validated, $logId);
return response()->json([
'status' => 'accepted',
'tenant_id' => $tenant->id,
'webhook_log_id' => $logId,
], 202);
}
private function getRateLimitRps(): int
{
$setting = SystemSetting::find('webhook_rate_limit_rps');
if ($setting === null) {
return 100; // sensible default из seed v8.7
}
return max(1, (int) $setting->value);
}
/**
* HMAC-обязательность. Audit-fix B3: если ключ отсутствует в БД default
* TRUE (HMAC обязателен по умолчанию). Отключить можно только явной
* установкой webhook_hmac_required=false. Неизвестное значение fail-secure
* (HMAC требуется).
*/
private function isHmacRequired(): bool
{
$setting = SystemSetting::find('webhook_hmac_required');
if ($setting === null) {
return true;
}
return ! in_array($setting->value, ['false', '0'], true);
}
/**
* Минимальный INSERT-stub в webhook_log (если таблица существует).
* На MVP webhook_log необязателен возвращаем null если таблицы нет.
*
* @param array<string,mixed> $payload
*/
private function insertWebhookLogStub(int $tenantId, array $payload): ?int
{
if (! Schema::hasTable('webhook_log')) {
return null;
}
// RLS требует SET LOCAL — оборачиваем в транзакцию.
return (int) DB::transaction(function () use ($tenantId, $payload) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
return DB::table('webhook_log')->insertGetId([
'tenant_id' => $tenantId,
'received_at' => now(),
'raw_payload' => json_encode($payload, JSON_UNESCAPED_UNICODE),
]);
});
}
}
+13 -15
View File
@@ -11,28 +11,26 @@ use Symfony\Component\HttpFoundation\Response;
/**
* Гейт SaaS-admin зоны (/api/admin/*) audit-находка J2.
*
* СТАБ (Sprint 3F): полноценная авторизация saas-admin требует Yandex 360
* SSO-входа, который гейтится Б-1 (регистрация ООО) + DO-4. До их закрытия
* реального механизма аутентификации нет.
* СТОПГЭП (2026-05-25): защита боевой админ-зоны (/admin + /api/admin/*)
* перенесена на уровень nginx отдельный HTTP Basic Auth с собственным
* паролем (`/etc/nginx/.htpasswd-admin`, location ^~ /admin и ^~ /api/admin).
* Поэтому middleware больше не закрывает зону на проде: дверь держит nginx.
*
* Поведение стаба:
* - dev / testing (local, testing) пропускаем. Admin-панель работает на
* dev; admin_user_id передаётся параметром (трейт ResolvesAdminUserId).
* - прочие окружения (production / staging) fail-closed 503: зона
* закрыта до подключения реального SSO. Явный 503 лучше, чем тихо
* открытый /api/admin/* в проде.
* Ранее (Sprint 3F) здесь был fail-closed 503 вне dev/testing он закрывал
* всю админку на проде наглухо, т.к. настоящий saas-admin SSO (Yandex 360)
* ещё не готов (гейтится Б-1 + DO-4). Замок 503 снят осознанно: оголять
* /api/admin/* в интернет нельзя, но nginx-пароль её прикрывает.
*
* TODO (после Б-1 + DO-4): заменить на проверку Yandex 360 SSO-сессии
* saas-admin (отдельный guard) + роль (compliance и т.п. где требуется).
* admin_user_id для audit-trail по-прежнему резолвится трейтом
* ResolvesAdminUserId (стаб super_admin) это отдельная зона.
*
* TODO (после Б-1 + DO-4): заменить nginx-дверь на настоящий saas-admin
* guard (Yandex 360 SSO-сессия + роль), вернуть проверку в это middleware.
*/
class EnsureSaasAdmin
{
public function handle(Request $request, Closure $next): Response
{
if (! app()->environment('local', 'testing')) {
abort(503, 'SaaS-admin авторизация не настроена (ожидает Б-1 + DO-4).');
}
return $next($request);
}
}
+1 -1
View File
@@ -26,7 +26,7 @@ use Throwable;
*
* Жизненный цикл import_log: pending processing done | failed.
* RLS: каждый доступ к БД задаёт SET LOCAL app.current_tenant_id (воркер
* вне middleware-контекста паритет с ProcessWebhookJob).
* вне middleware-контекста паритет с RouteSupplierLeadJob).
*/
class ImportLeadsJob implements ShouldQueue
{
-394
View File
@@ -1,394 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\ActivityLog;
use App\Models\BalanceTransaction;
use App\Models\Deal;
use App\Models\FailedWebhookJob;
use App\Models\Project;
use App\Models\RejectedDealsLog;
use App\Models\SupplierLeadCost;
use App\Models\SystemSetting;
use App\Models\Tenant;
use App\Services\DuplicateDetector;
use App\Services\NotificationService;
use App\Services\Pd\PdAuditLogger;
use App\Services\SupplierResolver;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable as FoundationQueueable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Throwable;
/**
* Асинхронная обработка webhook'а от crm.bp-gr.ru (narrative §5.5 v8.7).
*
* Архитектура:
* 1. RLS: SET LOCAL app.current_tenant_id внутри транзакции (PgBouncer-safe).
* 2. Lock на tenant + балансовая проверка RejectedDealsLog при balance=0.
* 3. findOrCreate проекта (префикс B[123]_ обрезан).
* 4. Идемпотентный upsert через pg_advisory_xact_lock (см. upsertDeal()).
* 5. Для НОВОЙ сделки: списание баланса + BalanceTransaction +
* SupplierLeadCost (Ю-2) + ActivityLog(deal.created).
*
* Антифрод-дедуп Биз-19 (§10.8.1): при создании НОВОЙ сделки `DuplicateDetector`
* ищет master по `(tenant_id, phone)` в окне 24 ч. Если master найден новой
* сделке проставляется `duplicate_of_id`, баланс НЕ списывается, SupplierLeadCost
* НЕ создаётся. ActivityLog пишется с context.duplicate_of=master.id.
*
* Уведомления (ТЗ §18.5, событие new_lead): после успешного chargeNewLead
* вызывается NotificationService::notifyNewLead, который рассылает email
* всем активным user'ам тенанта с включённым каналом email для new_lead.
*
* Не входит в текущий PoC (отдельные ветви фазы 1):
* - Sentry::captureException в failed() (нет Sentry-DSN на dev-стеке)
* - SystemSetting fallback для supplier_id (сейчас лукап через project_suppliers)
*/
class ProcessWebhookJob implements ShouldQueue
{
use FoundationQueueable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 3;
public int $backoff = 60;
public int $timeout = 30;
/**
* @param array<string, mixed> $data Webhook payload: vid, project, tag, phone, phones, time
*/
public function __construct(
public int $tenantId,
public array $data,
public ?int $webhookLogId = null,
) {}
public function handle(): void
{
$duplicateDetector = app(DuplicateDetector::class);
DB::transaction(function () use ($duplicateDetector): void {
DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenantId);
$tenant = Tenant::query()
->whereKey($this->tenantId)
->lockForUpdate()
->first();
if ($tenant === null) {
throw new RuntimeException("Tenant {$this->tenantId} not found");
}
if ((int) $tenant->balance_leads <= 0) {
$this->logRejection($tenant, RejectedDealsLog::REASON_ZERO_BALANCE);
return;
}
$cleanProjectName = preg_replace('/^B[123]_/', '', (string) $this->data['project']);
$project = Project::firstOrCreate(
['tenant_id' => $tenant->id, 'name' => $cleanProjectName],
['type' => 'webhook'],
);
$receivedAt = Carbon::createFromTimestamp((int) $this->data['time']);
$sourceCrmId = (int) $this->data['vid'];
$deal = $this->upsertDeal(
tenant: $tenant,
project: $project,
sourceCrmId: $sourceCrmId,
receivedAt: $receivedAt,
);
if (! $deal->wasRecentlyCreated) {
return;
}
// Биз-19: master-сделка по phone в окне 24 ч → дубль, без charge.
$master = $duplicateDetector->findMaster(
tenantId: $tenant->id,
phone: (string) $this->data['phone'],
now: $receivedAt,
);
// Сам только что созданный $deal попадает в выборку DuplicateDetector
// (он уже в БД к моменту lookup'а), поэтому master может ===$deal.
// Дубль — только если master найден И это НЕ сам deal.
if ($master !== null && $master->id !== $deal->id) {
$this->markAsDuplicate($tenant, $deal, $master);
return;
}
$this->chargeNewLead($tenant, $project, $deal);
});
}
/**
* Биз-19: помечаем сделку как дубль master'а. БЕЗ списания баланса
* и БЕЗ SupplierLeadCost (не наша закупка). ActivityLog пишется с
* `context.duplicate_of=master.id` для аудита.
*/
private function markAsDuplicate(Tenant $tenant, Deal $deal, Deal $master): void
{
$deal->update(['duplicate_of_id' => $master->id]);
ActivityLog::create([
'tenant_id' => $tenant->id,
'user_id' => null,
'deal_id' => $deal->id,
'event' => ActivityLog::EVENT_DEAL_CREATED,
'context' => [
'source' => 'webhook',
'duplicate_of' => $master->id,
],
'created_at' => now(),
]);
app(PdAuditLogger::class)->record(
action: 'created', subjectType: 'lead', subjectId: $deal->id,
purpose: 'lead_create_webhook', tenantId: (int) $deal->tenant_id,
actorTenantUserId: null, actorAdminUserId: null, ip: null,
);
}
private function logRejection(Tenant $tenant, string $reason): void
{
$rejected = RejectedDealsLog::create([
'tenant_id' => $tenant->id,
'webhook_log_id' => $this->webhookLogId,
'reason' => $reason,
'payload' => $this->data,
'created_at' => now(),
]);
Log::info("webhook.rejected.{$reason}", [
'tenant_id' => $tenant->id,
'vid' => $this->data['vid'] ?? null,
]);
// ТЗ §18.5: zero_balance — уведомить тенант. Anti-spam: не более
// 1 email/час на тенант. Исключаем только что вставленную запись
// через id (timestamp-сравнение ненадёжно из-за microsecond precision).
if ($reason === RejectedDealsLog::REASON_ZERO_BALANCE) {
$previousCount = RejectedDealsLog::query()
->where('tenant_id', $tenant->id)
->where('reason', $reason)
->where('created_at', '>=', now()->subHour())
->where('id', '!=', $rejected->id)
->count();
if ($previousCount === 0) {
app(NotificationService::class)->notifyZeroBalance($tenant);
}
}
}
/**
* Списание баланса при создании НОВОЙ сделки + аудит-записи.
*
* Все INSERT'ы в одной транзакции целостность гарантирована (Ю-2):
* deal + supplier_lead_cost + balance_transaction появляются атомарно.
*/
private function chargeNewLead(Tenant $tenant, Project $project, Deal $deal): void
{
$tenant->decrement('balance_leads');
$tenant->refresh();
BalanceTransaction::create([
'tenant_id' => $tenant->id,
'type' => BalanceTransaction::TYPE_LEAD_CHARGE,
'amount_leads' => -1,
'balance_leads_after' => (int) $tenant->balance_leads,
'related_type' => Deal::class,
'related_id' => $deal->id,
'created_at' => now(),
]);
$resolver = app(SupplierResolver::class);
$supplierId = $resolver->resolveForProject($project);
if ($supplierId !== null) {
SupplierLeadCost::create([
'deal_id' => $deal->id,
'received_at' => $deal->received_at,
'supplier_id' => $supplierId,
'cost_rub' => $resolver->costRubSnapshot($supplierId),
'supplier_lead_id' => (int) $this->data['vid'],
'created_at' => now(),
]);
} else {
Log::warning('webhook.no_active_supplier', [
'tenant_id' => $tenant->id,
'project_id' => $project->id,
'deal_id' => $deal->id,
]);
}
ActivityLog::create([
'tenant_id' => $tenant->id,
'user_id' => null,
'deal_id' => $deal->id,
'event' => ActivityLog::EVENT_DEAL_CREATED,
'context' => ['source' => 'webhook'],
'created_at' => now(),
]);
app(PdAuditLogger::class)->record(
action: 'created', subjectType: 'lead', subjectId: $deal->id,
purpose: 'lead_create_webhook', tenantId: (int) $deal->tenant_id,
actorTenantUserId: null, actorAdminUserId: null, ip: null,
);
// Уведомление о новом лиде (ТЗ §18.5). Отправляется ПОСЛЕ всех записей
// в БД, чтобы при ошибке отправки транзакция уже была зафиксирована.
// NotificationService сам ловит Throwable от Mail::send и логирует —
// отказ канала не должен валить webhook.
$deal->setRelation('project', $project);
$service = app(NotificationService::class);
$service->notifyNewLead($tenant, $deal);
// ТЗ §18.5: low_balance — после lead_charge проверяем порог. Триггерим
// ТОЛЬКО когда баланс пересекает порог сверху-вниз: balance_after <=
// threshold AND (balance_after + 1) > threshold. Иначе шлёт спам после
// каждого lead_charge при balance < threshold.
$threshold = $this->lowBalanceThreshold();
$balanceAfter = (int) $tenant->balance_leads;
if ($balanceAfter <= $threshold && ($balanceAfter + 1) > $threshold) {
$service->notifyLowBalance($tenant, $threshold);
}
}
/**
* Читает порог из system_settings.low_balance_threshold_leads.
* Default 10 (см. schema.sql:2239 seed).
*/
private function lowBalanceThreshold(): int
{
$setting = SystemSetting::query()->where('key', 'low_balance_threshold_leads')->first();
if ($setting === null) {
return 10;
}
return (int) $setting->value;
}
/**
* Идемпотентная upsert-логика через advisory lock (§5.5 v8.7).
*
* Стратегия:
* 1. pg_advisory_xact_lock(tenant_id, vid) сериализует все операции
* с (tenant_id, source_crm_id) на время транзакции.
* 2. SELECT в webhook_dedup_keys атомарно из-за lock.
* 3a. Если найдено UPDATE deal по composite-ключу (id, received_at).
* 3b. Иначе INSERT deal первым (FK immediate OK), затем INSERT dedup_key.
*
* См. db/CHANGELOG_schema.md §W для архитектурного обоснования
* (PG savepoint+DEFERRED quirk, отказ от двустадийного INSERT-в-dedup-keys-первым).
*/
private function upsertDeal(
Tenant $tenant,
Project $project,
int $sourceCrmId,
Carbon $receivedAt,
): Deal {
// pg_advisory_xact_lock(bigint): комбинируем (tenant_id, source_crm_id)
// в один bigint — верхние 32 бита tenant_id, нижние 32 — source_crm_id.
$lockKey = (($tenant->id & 0xFFFFFFFF) << 32) | ($sourceCrmId & 0xFFFFFFFF);
DB::statement('SELECT pg_advisory_xact_lock(?)', [$lockKey]);
$existing = DB::selectOne(
'SELECT deal_id, deal_received_at FROM webhook_dedup_keys WHERE tenant_id = ? AND source_crm_id = ?',
[$tenant->id, $sourceCrmId],
);
if ($existing !== null) {
$deal = Deal::query()
->where('id', $existing->deal_id)
->where('received_at', $existing->deal_received_at)
->firstOrFail();
$deal->update([
'phone' => (string) $this->data['phone'],
'phones' => $this->data['phones'] ?? [(string) $this->data['phone']],
// status НЕ перезаписываем — менеджер мог изменить.
]);
DB::table('webhook_dedup_keys')
->where('tenant_id', $tenant->id)
->where('source_crm_id', $sourceCrmId)
->update(['updated_at' => now()]);
$deal->wasRecentlyCreated = false;
return $deal;
}
$deal = Deal::create([
'tenant_id' => $tenant->id,
'source_crm_id' => $sourceCrmId,
'project_id' => $project->id,
'phone' => (string) $this->data['phone'],
'phones' => $this->data['phones'] ?? [(string) $this->data['phone']],
'status' => 'new',
'received_at' => $receivedAt,
]);
DB::table('webhook_dedup_keys')->insert([
'tenant_id' => $tenant->id,
'source_crm_id' => $sourceCrmId,
'deal_id' => $deal->id,
'deal_received_at' => $deal->received_at,
'created_at' => now(),
]);
return $deal;
}
/**
* Финальный callback после исчерпания всех ретраев ($tries=3).
*
* Сохраняет упавший job в `failed_webhook_jobs` для ручного разбора и
* возможного повторного запуска через админку SaaS. RLS не задаём
* tenant_id из job-state передаётся как есть (failed-callback запускается
* вне транзакции воркера). На production добавляется Sentry::captureException.
*
* NB: записывается через DB::table (не через FailedWebhookJob::create),
* чтобы избежать RLS-фильтрации при отсутствии app.current_tenant_id
* запись должна попасть в БД даже в катастрофическом сценарии.
*/
public function failed(Throwable $e): void
{
// failed_webhook_jobs is an RLS-protected table. On production crm_app_user
// (non-BYPASSRLS) there is no app.current_tenant_id GUC in the failed()
// callback context. Use pgsql_supplier (crm_supplier_worker, BYPASSRLS) —
// same pattern as RouteSupplierLeadJob::failed().
DB::connection('pgsql_supplier')->table('failed_webhook_jobs')->insert([
'tenant_id' => $this->tenantId,
'webhook_log_id' => $this->webhookLogId,
'raw_payload' => json_encode($this->data, JSON_UNESCAPED_UNICODE),
'exception' => $e->getMessage(),
'retry_count' => $this->tries,
'failed_at' => now(),
]);
Log::error('webhook.job_failed_permanently', [
'tenant_id' => $this->tenantId,
'vid' => $this->data['vid'] ?? null,
'exception' => $e->getMessage(),
]);
// TODO(production): Sentry::captureException($e);
}
}
+27 -46
View File
@@ -11,7 +11,6 @@ use App\Models\Project;
use App\Models\SupplierLead;
use App\Models\Tenant;
use App\Services\Billing\LedgerService;
use App\Services\DuplicateDetector;
use App\Services\LeadDistributor;
use App\Services\LeadRouter;
use App\Services\NotificationService;
@@ -44,9 +43,7 @@ use Throwable;
* 5. Для каждого Project DB::transaction с SET LOCAL app.current_tenant_id:
* - lockForUpdate Tenant.
* - Создать Deal (source_crm_id=vid).
* - DuplicateDetector::findMaster если найден master !== deal, mark
* duplicate_of_id (без charge/counter/notify, ActivityLog с duplicate_of).
* - Иначе: LedgerService::chargeForDelivery(tenant, deal, lead) dual-balance
* - LedgerService::chargeForDelivery(tenant, deal, lead) dual-balance
* списание (prepaid balance_leads-- ИЛИ rub balance_rub-=tier_price), INSERT
* lead_charges + balance_transactions + supplier_lead_costs внутри той же
* транзакции. На InsufficientBalanceException Log::warning + rethrow
@@ -86,7 +83,6 @@ class RouteSupplierLeadJob implements ShouldQueue
public function handle(
LeadRouter $router,
SupplierProjectResolver $resolver,
DuplicateDetector $duplicateDetector,
NotificationService $notifier,
LedgerService $ledger,
LeadDistributor $distributor,
@@ -135,7 +131,7 @@ class RouteSupplierLeadJob implements ShouldQueue
$failures = [];
foreach ($selected as $project) {
try {
if ($this->createDealCopyForProject($lead, $project, $duplicateDetector, $notifier, $ledger, $subjectCode)) {
if ($this->createDealCopyForProject($lead, $project, $notifier, $ledger, $subjectCode)) {
$createdCount++;
}
} catch (Throwable $e) {
@@ -205,19 +201,18 @@ class RouteSupplierLeadJob implements ShouldQueue
/**
* Создаёт deal-копию в одной транзакции для конкретного Project.
* Возвращает true если копия не дубль (баланс списан, счётчики выросли).
* false если копия помечена дублем (без списания).
* Возвращает true если deal создан и баланс списан, счётчики выросли.
* false если лимит исчерпан под блокировкой (deal не создаётся).
*/
private function createDealCopyForProject(
SupplierLead $lead,
Project $project,
DuplicateDetector $duplicateDetector,
NotificationService $notifier,
LedgerService $ledger,
?int $subjectCode,
): bool {
try {
return DB::transaction(function () use ($lead, $project, $duplicateDetector, $notifier, $ledger, $subjectCode): bool {
return DB::transaction(function () use ($lead, $project, $notifier, $ledger, $subjectCode): bool {
DB::statement("SET LOCAL app.current_tenant_id = '{$project->tenant_id}'");
/** @var Tenant $tenant */
@@ -250,6 +245,22 @@ class RouteSupplierLeadJob implements ShouldQueue
}
$project = $lockedProject;
// Spec B: per-(supplier_lead, tenant) lock — одна поставка одному клиенту = один раз.
// insertOrIgnore вернёт 0, если строка уже существует (повтор/гонка/CSV-recovery).
$locked = DB::table('supplier_lead_deliveries')->insertOrIgnore([
'supplier_lead_id' => $lead->id,
'tenant_id' => $tenant->id,
'created_at' => now(),
]);
if ($locked === 0) {
Log::info('supplier_lead.delivery_already_locked', [
'supplier_lead_id' => $lead->id,
'tenant_id' => $tenant->id,
]);
return false;
}
$payload = $lead->raw_payload ?? [];
$receivedAt = isset($payload['time'])
? Carbon::createFromTimestamp((int) $payload['time'])
@@ -271,39 +282,10 @@ class RouteSupplierLeadJob implements ShouldQueue
'subject_code' => $subjectCode,
]);
$master = $duplicateDetector->findMaster(
tenantId: (int) $tenant->id,
phone: (string) $lead->phone,
now: $receivedAt,
);
// Только что созданный $deal сам попадает в выборку DuplicateDetector
// (он уже в БД к моменту lookup'а), поэтому master может ===$deal.
// Дубль — только если master найден И это НЕ сам deal.
if ($master !== null && $master->id !== $deal->id) {
$deal->update(['duplicate_of_id' => $master->id]);
ActivityLog::create([
'tenant_id' => $tenant->id,
'user_id' => null,
'deal_id' => $deal->id,
'event' => ActivityLog::EVENT_DEAL_CREATED,
'context' => [
'source' => 'supplier_webhook',
'duplicate_of' => $master->id,
'supplier_lead_id' => $lead->id,
],
'created_at' => now(),
]);
app(PdAuditLogger::class)->record(
action: 'created', subjectType: 'lead', subjectId: $deal->id,
purpose: 'lead_create_supplier', tenantId: (int) $deal->tenant_id,
actorTenantUserId: null, actorAdminUserId: null, ip: null,
);
return false;
}
DB::table('supplier_lead_deliveries')
->where('supplier_lead_id', $lead->id)
->where('tenant_id', $tenant->id)
->update(['deal_id' => $deal->id]);
// Task 6: $ledger->chargeForDelivery бросит InsufficientBalanceException —
// транзакция откатится, и outer catch ниже отловит для auto-pause flow.
@@ -330,8 +312,8 @@ class RouteSupplierLeadJob implements ShouldQueue
actorTenantUserId: null, actorAdminUserId: null, ip: null,
);
// ProcessWebhookJob-pattern: setRelation чтобы NotificationService
// мог подтянуть deal->project без N+1 lookup'а под RLS.
// setRelation чтобы NotificationService мог подтянуть
// deal->project без N+1 lookup'а под RLS.
$deal->setRelation('project', $project);
$notifier->notifyNewLead($tenant, $deal);
@@ -384,7 +366,6 @@ class RouteSupplierLeadJob implements ShouldQueue
'supplier_lead_id' => $lead->id,
'price_kopecks' => $e->priceKopecks,
'balance_rub' => $e->balanceRub,
'balance_leads' => $e->balanceLeads,
]);
}
+13 -1
View File
@@ -126,11 +126,15 @@ final class CsvReconcileJob implements ShouldQueue
$missing = array_diff_key($csvByKey, $existingKeys);
$recoveredCount = 0;
$unparseableCount = 0;
foreach ($missing as $row) {
$platform = $this->extractPlatform((string) $row['project']);
if ($platform === null) {
// Поставщик иногда кладёт в `project` нестандартные имена (телефон, URL).
// Не warning — это не наш баг, processing продолжается, paper-trail на info уровне.
// Считаем такие строки отдельно, чтобы исключить из формулы drift'а
// (иначе ~40-50% мусора каждый запуск стабильно даёт false-positive drift_alert).
$unparseableCount++;
Log::info('csv_reconcile.unparseable_project_skipped', [
'project' => $row['project'],
]);
@@ -161,7 +165,14 @@ final class CsvReconcileJob implements ShouldQueue
}
$matchedCount = $totalCsvRows - count($missing);
$driftRatio = $totalCsvRows > 0 ? count($missing) / $totalCsvRows : 0.0;
// drift считается только по «реальным» пропускам (parseable, не junk):
// real_missing = count(missing) - unparseable (всегда ≥ 0)
// parseable_tot = total_csv_rows - unparseable
// Это убирает класс «поставщик кладёт телефон/URL в поле project →
// строки скипаются → drift искусственно завышен» (см. ПИЛОТ 22.05, 25.05).
$realMissing = max(0, count($missing) - $unparseableCount);
$parseableTotal = max(0, $totalCsvRows - $unparseableCount);
$driftRatio = $parseableTotal > 0 ? $realMissing / $parseableTotal : 0.0;
$status = $driftRatio > self::DRIFT_THRESHOLD ? 'drift_alert' : 'ok';
$update = [
@@ -169,6 +180,7 @@ final class CsvReconcileJob implements ShouldQueue
'total_csv_rows' => $totalCsvRows,
'matched_count' => $matchedCount,
'recovered_count' => $recoveredCount,
'unparseable_count' => $unparseableCount,
'drift_ratio' => $driftRatio,
'status' => $status,
];
-50
View File
@@ -1,50 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Email-уведомление о низком балансе (ТЗ §18.5, событие low_balance).
*
* Триггер: tenant.balance_leads <= system_settings.low_balance_threshold_leads
* (default 10) после lead_charge в ProcessWebhookJob.
*/
class LowBalanceNotification extends Mailable
{
use Queueable;
use SerializesModels;
public function __construct(
public User $recipient,
public Tenant $tenant,
public int $thresholdLeads,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Лидерра. Низкий баланс — пополните, чтобы не пропустить лиды',
);
}
public function content(): Content
{
return new Content(
view: 'emails.low_balance',
with: [
'recipient' => $this->recipient,
'tenant' => $this->tenant,
'thresholdLeads' => $this->thresholdLeads,
],
);
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ use Illuminate\Queue\SerializesModels;
*
* Отправляется получателям тенанта, у которых в notification_preferences
* включён канал email для события new_lead. Триггер успешное создание
* сделки в ProcessWebhookJob::chargeNewLead.
* сделки в RouteSupplierLeadJob.
*/
class NewLeadNotification extends Mailable
{
-50
View File
@@ -1,50 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Email-уведомление о нулевом балансе и отклонении лидов (ТЗ §18.5,
* событие zero_balance).
*
* Триггер: ProcessWebhookJob::logRejection(reason=zero_balance) после
* первого RejectedDealsLog в течение последнего часа (anti-spam: не больше
* 1 email в час на тенант).
*/
class ZeroBalanceNotification extends Mailable
{
use Queueable;
use SerializesModels;
public function __construct(
public User $recipient,
public Tenant $tenant,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Лидерра. Баланс закончился — лиды отклоняются',
);
}
public function content(): Content
{
return new Content(
view: 'emails.zero_balance',
with: [
'recipient' => $this->recipient,
'tenant' => $this->tenant,
],
);
}
}
+2
View File
@@ -40,6 +40,8 @@ class BalanceTransaction extends Model
public const TYPE_CHARGEBACK_REPAYMENT = 'chargeback_repayment';
public const TYPE_MIGRATION = 'migration';
public $timestamps = false;
protected $fillable = [
+1 -1
View File
@@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Webhook-job упавший после 3 ретраев (см. ProcessWebhookJob::failed()).
* Webhook-job упавший после 3 ретраев (см. RouteSupplierLeadJob::failed()).
*
* Tenant-aware с RLS. Хранит raw payload + текст исключения для ручного
* retry из админки SaaS (`retried_at`/`retried_by` заполняются админом).
-56
View File
@@ -1,56 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Лог отвергнутых webhook'ов (примеры reason: zero_balance, validation_failed).
*
* Хранится бессрочно (опционально 12 месяцев) при пополнении баланса
* админка SaaS может массово восстановить отвергнутые лиды.
*
* Tenant-aware с RLS. webhook_log_id soft FK на webhook_log
* (опциональный, NULL для прямых validation-отказов).
*
* Источник: db/schema.sql v8.7 §6, table `rejected_deals_log`.
*
* @mixin IdeHelperRejectedDealsLog
*/
class RejectedDealsLog extends Model
{
public const REASON_ZERO_BALANCE = 'zero_balance';
public const REASON_VALIDATION_FAILED = 'validation_failed';
public $timestamps = false;
protected $table = 'rejected_deals_log';
protected $fillable = [
'tenant_id',
'webhook_log_id',
'reason',
'payload',
'created_at',
];
protected function casts(): array
{
return [
'tenant_id' => 'integer',
'webhook_log_id' => 'integer',
'payload' => 'array',
'created_at' => 'datetime',
];
}
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
}
+2 -2
View File
@@ -11,8 +11,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* Себестоимость каждого лида (Ю-2: реселлерская модель).
*
* Партиционирована по `received_at` синхронно с `deals` composite PK
* (id, received_at). В `ProcessWebhookJob` создаётся в той же транзакции,
* что и Deal + BalanceTransaction.
* (id, received_at). Создаётся в `LedgerService::chargeForDelivery` в той же
* транзакции, что и Deal + BalanceTransaction.
*
* cost_rub snapshot suppliers.cost_rub на момент приёма (исторические
* записи не пересчитываются при изменении закупочной цены, см. §20.12.5).
+27
View File
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* Замок «поставка клиент» (Billing v2 Spec B). Композитный PK без автоинкремента.
* Пишется в шеринг-пути (RouteSupplierLeadJob) через insertOrIgnore под RLS-контекстом.
*
* @property int $supplier_lead_id
* @property int $tenant_id
* @property int|null $deal_id
* @property string $created_at
*/
class SupplierLeadDelivery extends Model
{
public $incrementing = false;
public $timestamps = false;
protected $primaryKey = null;
protected $fillable = ['supplier_lead_id', 'tenant_id', 'deal_id', 'created_at'];
}
-3
View File
@@ -31,8 +31,6 @@ class Tenant extends Model
'subdomain',
'organization_name',
'contact_email',
'webhook_token',
'webhook_token_rotated_at',
'timezone',
'locale',
'current_tariff_id',
@@ -61,7 +59,6 @@ class Tenant extends Model
'api_key_limit' => 'integer',
// JSONB: {"max_users":5,"max_projects":10,"api_rps":60}
'limits' => 'array',
'webhook_token_rotated_at' => 'datetime',
'last_activity_at' => 'datetime',
'last_webhook_at' => 'datetime',
'created_at' => 'datetime',
@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace App\Services\Billing;
use App\Models\PricingTier;
use Illuminate\Database\Eloquent\Collection;
/**
* Pure: «при балансе и доставленных в этом месяце N сколько лидов клиент
* получит, проходя ступени pricing_tiers».
*
* Все мутации денег bcmath (string-int копейки), без PHP float.
*
* Spec: docs/superpowers/specs/2026-05-23-billing-v2-spec-a-balance-rub-design.md §3.3.1
*/
final class BalanceToLeadsConverter
{
/**
* @param Collection<int, PricingTier> $tiers
* @return array{
* leads: int,
* breakdown: list<array{tier_no:int, leads:int, price_rub:string}>,
* current_tier: array{no:int, price_rub:string, leads_left_in_tier:int}|null,
* next_tier: array{no:int, price_rub:string, leads_in_tier:int}|null
* }
*/
public function convert(string $balanceRub, int $deliveredInMonth, Collection $tiers): array
{
$balanceKopecks = bcmul($balanceRub, '100', 0);
/** @var Collection<int, PricingTier> $sorted */
$sorted = $tiers
->filter(fn (PricingTier $t) => (bool) $t->is_active)
->sortBy('tier_no')
->values();
$totalLeads = 0;
$breakdown = [];
$cumulative = 0;
$currentTier = null;
$currentTierIndex = null;
foreach ($sorted as $index => $tier) {
$tierCap = $tier->leads_in_tier === null ? PHP_INT_MAX : (int) $tier->leads_in_tier;
$tierEnd = $cumulative + $tierCap;
// «Текущая ступень» — первая ступень, в которую попадает следующий лид
// (deliveredInMonth + 1), т.е. первая где deliveredInMonth < tierEnd.
if ($currentTier === null && $deliveredInMonth < $tierEnd) {
$slotsLeftForInfo = $tier->leads_in_tier === null
? PHP_INT_MAX
: max(0, $tierEnd - max($cumulative, $deliveredInMonth));
$currentTier = [
'no' => (int) $tier->tier_no,
'price_rub' => self::kopecksToRub((int) $tier->price_per_lead_kopecks),
'leads_left_in_tier' => $slotsLeftForInfo,
];
$currentTierIndex = $index;
}
// Слоты в этой ступени, доступные для новых лидов
$slotsLeftInTier = max(0, $tierEnd - max($cumulative, $deliveredInMonth));
if ($slotsLeftInTier <= 0) {
$cumulative = $tierEnd;
continue;
}
$priceKopecks = (int) $tier->price_per_lead_kopecks;
if ($priceKopecks <= 0) {
$totalLeads += $slotsLeftInTier;
$breakdown[] = [
'tier_no' => (int) $tier->tier_no,
'leads' => $slotsLeftInTier,
'price_rub' => '0.00',
];
if ($tier->leads_in_tier === null) {
break;
}
$cumulative = $tierEnd;
continue;
}
$affordableInTier = (int) bcdiv($balanceKopecks, (string) $priceKopecks, 0);
$take = min($slotsLeftInTier, $affordableInTier);
if ($take > 0) {
$totalLeads += $take;
$breakdown[] = [
'tier_no' => (int) $tier->tier_no,
'leads' => $take,
'price_rub' => self::kopecksToRub($priceKopecks),
];
$balanceKopecks = bcsub(
$balanceKopecks,
bcmul((string) $priceKopecks, (string) $take, 0),
0
);
}
if ($take < $slotsLeftInTier) {
// Balance exhausted within this tier — stop
break;
}
if ($tier->leads_in_tier === null) {
// Unlimited tier fully consumed (shouldn't happen with real balance)
break;
}
$cumulative = $tierEnd;
}
// next_tier: the first active tier whose tier_no > current_tier, if any
$nextTier = null;
if ($currentTier !== null && $currentTierIndex !== null) {
for ($i = $currentTierIndex + 1; $i < $sorted->count(); $i++) {
/** @var PricingTier $candidate */
$candidate = $sorted[$i];
$nextTier = [
'no' => (int) $candidate->tier_no,
'price_rub' => self::kopecksToRub((int) $candidate->price_per_lead_kopecks),
'leads_in_tier' => $candidate->leads_in_tier === null ? 0 : (int) $candidate->leads_in_tier,
];
break;
}
}
return [
'leads' => $totalLeads,
'breakdown' => $breakdown,
'current_tier' => $currentTier,
'next_tier' => $nextTier,
];
}
private static function kopecksToRub(int $kopecks): string
{
return bcdiv((string) $kopecks, '100', 2);
}
}
+4 -2
View File
@@ -7,12 +7,14 @@ namespace App\Services\Billing;
use App\Models\PricingTier;
/**
* Read-only DTO с результатом charge'а: source (prepaid/rub), снимок ступени, цена в копейках.
* Read-only DTO с результатом charge'а: снимок ступени и цена в копейках.
*
* Billing v2 Spec A: поле `$source` убрано (prepaid-ветка ликвидирована,
* все списания всегда rub). Источник списания смотри в `LeadCharge::charge_source`.
*/
final readonly class ChargeResult
{
public function __construct(
public string $source,
public PricingTier $tier,
public int $priceKopecks,
) {}
+33 -50
View File
@@ -19,14 +19,15 @@ use Illuminate\Support\Facades\DB;
* Командный сервис биллинга на горячем пути доставки лида.
*
* Контракт: вызывается ВНУТРИ открытой DB-транзакции под lockForUpdate(Tenant).
* Применяет dual-balance flow:
* Применяет always-rub flow (Billing v2 Spec A prepaid-лиды ликвидированы):
* 1. tier-lookup по tenants.delivered_in_month + 1
* 2. prepaid: balance_leads--, lead_charges (price=0)
* 3. rub: balance_rub -= price/100 (bcmath), lead_charges (price=tier)
* 4. INSERT supplier_lead_costs (gap-fix sharing-flow)
* 5. INSERT balance_transactions (universal ledger движения баланса)
* 2. bcmath проверка balance_rub × 100 priceKopecks; иначе throw
* 3. balance_rub -= price/100 (bcmath)
* 4. INSERT lead_charges (charge_source='rub')
* 5. INSERT balance_transactions (amount_leads=null, amount_rub отрицательное)
* 6. INSERT supplier_lead_costs (gap-fix sharing-flow)
*
* Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §3
* Spec: docs/superpowers/specs/2026-05-23-billing-v2-spec-a-balance-rub-design.md §4.2
*/
final class LedgerService
{
@@ -36,7 +37,7 @@ final class LedgerService
) {}
/**
* @throws InsufficientBalanceException когда balance_leads=0 AND balance_rub*100<priceKopecks.
* @throws InsufficientBalanceException когда balance_rub * 100 < priceKopecks.
* До throw НЕ модифицирует tenant/charges/transactions/costs.
*
* @precondition caller wraps in DB::transaction with lockForUpdate($lockedTenant).
@@ -48,54 +49,55 @@ final class LedgerService
Deal $deal,
?SupplierLead $lead = null,
): ChargeResult {
// 1. tier-resolution для (delivered_in_month + 1)-го лида
$activeTiers = $this->tiers->activeAt(Carbon::now('Europe/Moscow'));
$tier = $this->resolver->resolveForCount($activeTiers, ($lockedTenant->delivered_in_month ?? 0) + 1);
$tier = $this->resolver->resolveForCount(
$activeTiers,
($lockedTenant->delivered_in_month ?? 0) + 1
);
$priceKopecks = (int) $tier->price_per_lead_kopecks;
// 2. Decide chargeSource (bcmath — НЕ PHP float)
$source = $this->decideSource($lockedTenant, $priceKopecks);
// 3. Apply (bcmath для money; raw DB::update — Eloquent decrement() требует float|int,
// что несовместимо с string-precision arithmetic для копеек/рублей).
if ($source === 'prepaid') {
$lockedTenant->decrement('balance_leads', 1);
} else {
$amountRub = bcdiv((string) $priceKopecks, '100', 2);
$newBalanceRub = bcsub((string) $lockedTenant->balance_rub, $amountRub, 2);
DB::table('tenants')
->where('id', $lockedTenant->id)
->update(['balance_rub' => $newBalanceRub]);
// bcmath: balance_rub × 100 ≥ priceKopecks — единственный путь списания.
// Billing v2 Spec A: prepaid-лиды убраны, balance_leads НЕ читается и НЕ изменяется.
$balanceKopecks = bcmul((string) $lockedTenant->balance_rub, '100', 0);
if (bccomp($balanceKopecks, (string) $priceKopecks, 0) < 0) {
throw new InsufficientBalanceException(
priceKopecks: $priceKopecks,
balanceRub: (string) $lockedTenant->balance_rub,
);
}
$amountRub = bcdiv((string) $priceKopecks, '100', 2);
$newBalanceRub = bcsub((string) $lockedTenant->balance_rub, $amountRub, 2);
DB::table('tenants')
->where('id', $lockedTenant->id)
->update(['balance_rub' => $newBalanceRub]);
$lockedTenant->increment('delivered_in_month', 1);
$lockedTenant->refresh();
// 4. INSERT lead_charges (always)
LeadCharge::create([
'tenant_id' => $lockedTenant->id,
'deal_id' => $deal->id,
'deal_received_at' => $deal->received_at,
'tier_no' => $tier->tier_no,
'price_per_lead_kopecks' => $source === 'prepaid' ? 0 : $priceKopecks,
'charge_source' => $source,
'price_per_lead_kopecks' => $priceKopecks,
'charge_source' => 'rub',
'charged_at' => now(),
'created_at' => now(),
]);
// 5. INSERT balance_transactions (универсальный ledger)
BalanceTransaction::create([
'tenant_id' => $lockedTenant->id,
'type' => BalanceTransaction::TYPE_LEAD_CHARGE,
'amount_leads' => $source === 'prepaid' ? -1 : 0,
'amount_rub' => $source === 'rub' ? '-'.bcdiv((string) $priceKopecks, '100', 2) : '0.00',
'balance_leads_after' => (int) $lockedTenant->balance_leads,
'amount_leads' => null,
'amount_rub' => '-'.$amountRub,
'balance_leads_after' => null,
'balance_rub_after' => (string) $lockedTenant->balance_rub,
'related_type' => Deal::class,
'related_id' => $deal->id,
'created_at' => now(),
]);
// 6. INSERT supplier_lead_costs (gap-fix Plan 2/3 sharing-flow)
if ($lead !== null) {
$supplierId = $this->resolveSupplierId($lead);
if ($supplierId !== null) {
@@ -111,26 +113,7 @@ final class LedgerService
}
}
return new ChargeResult($source, $tier, $source === 'prepaid' ? 0 : $priceKopecks);
}
private function decideSource(Tenant $tenant, int $priceKopecks): string
{
if ((int) $tenant->balance_leads >= 1) {
return 'prepaid';
}
// bcmath: balance_rub (DECIMAL string) * 100 ≥ priceKopecks → можем списать rub
$balanceKopecks = bcmul((string) $tenant->balance_rub, '100', 0);
if (bccomp($balanceKopecks, (string) $priceKopecks, 0) >= 0) {
return 'rub';
}
throw new InsufficientBalanceException(
priceKopecks: $priceKopecks,
balanceRub: (string) $tenant->balance_rub,
balanceLeads: (int) $tenant->balance_leads,
);
return new ChargeResult($tier, $priceKopecks);
}
/**
-54
View File
@@ -1,54 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Deal;
use Illuminate\Support\Carbon;
/**
* Антифрод-дедуп лидов по `(tenant_id, phone)` в окне 24 ч (Биз-19, §10.8.1).
*
* Цель: в pay-per-lead-сегменте поставщик может прислать одно физлицо дважды
* (двойной submit формы / повторный звонок) без защиты клиент платит за оба.
*
* Стратегия: ищем master-сделку (запись без `duplicate_of_id`) с тем же
* `(tenant_id, phone)` и `received_at >= NOW() - INTERVAL '24 hours'`.
* Если найдена новая сделка получает `duplicate_of_id = master.id` и
* НЕ списывает с баланса.
*
* Окно фиксированное 24 ч (не настраивается на MVP) компромисс между
* антифродом и легитимными повторными интересами.
*
* Цепочки не строятся: дубль ссылается ТОЛЬКО на master (запись без
* `duplicate_of_id`), не на другой дубль. Если master найден среди дублей
* берётся его собственный `duplicate_of_id` (root master).
*
* Performance: существующий индекс `(tenant_id, phone)` достаточен, см. §10.8.1.
*/
class DuplicateDetector
{
public const WINDOW_HOURS = 24;
/**
* Поиск master-сделки для (tenantId, phone) в окне 24 ч.
*
* Возвращает Deal-объект master'а либо null если master не найден.
* Текущий момент `now` параметризуется для тестируемости в production
* по умолчанию `Carbon::now()`.
*/
public function findMaster(int $tenantId, string $phone, ?Carbon $now = null): ?Deal
{
$now ??= Carbon::now();
$windowStart = $now->copy()->subHours(self::WINDOW_HOURS);
return Deal::query()
->where('tenant_id', $tenantId)
->where('phone', $phone)
->where('received_at', '>=', $windowStart)
->whereNull('duplicate_of_id')
->orderBy('received_at')
->first();
}
}
+1 -1
View File
@@ -95,7 +95,7 @@ final class CsvLeadsParser
return null;
}
// Префикс B[123]_ из названия проекта срезается (паритет с ProcessWebhookJob).
// Префикс B[123]_ из названия проекта срезается (паритет с RouteSupplierLeadJob парсером).
$projectName = (string) preg_replace('/^B[123]_/', '', trim($project));
if ($projectName === '') {
$errors[] = ['line' => $dataLine, 'message' => 'Пустое название проекта'];
+37 -24
View File
@@ -8,6 +8,7 @@ use App\Models\Project;
use App\Models\SupplierProject;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
/**
* Подбор eligible Лидерра-проектов для входящего лида (sharing-model §6).
@@ -28,6 +29,17 @@ use Illuminate\Support\Collection;
class LeadRouter
{
/**
* Возвращает ONE project per tenant_id тот, у которого наибольший остаток
* дневного лимита (DISTINCT ON (tenant_id) с ORDER BY remaining DESC, created_at, id).
*
* Семантика (Spec B Task 3): один лид продаётся не более чем 3 РАЗЛИЧНЫМ тенантам
* (клиентам), каждый тенант получает ровно ОДИН проект с наибольшим остатком.
* LeadDistributor::selectRecipients (CAP=3) теперь ограничивает число тенантов,
* а не число проектов, потому что входные данные уже one-per-tenant.
*
* Запрос через pgsql_supplier (BYPASSRLS crm_supplier_worker) tenant ещё не
* определён, SELECT видит проекты всех tenant'ов.
*
* @return Collection<int, Project>
*/
public function matchEligibleProjects(SupplierProject $supplierProject): Collection
@@ -35,30 +47,31 @@ class LeadRouter
// МСК-aligned ISO day-of-week (reset-cron тоже 00:00 МСК).
$todayBit = 1 << (Carbon::now('Europe/Moscow')->isoWeekday() - 1);
/** @var Collection<int, Project> $candidates */
$candidates = Project::on('pgsql_supplier')
->whereExists(function ($q) use ($supplierProject): void {
$q->selectRaw('1')
->from('project_supplier_links')
->whereColumn('project_supplier_links.project_id', 'projects.id')
->where('project_supplier_links.supplier_project_id', $supplierProject->id);
})
->where('is_active', true)
->whereRaw('(delivery_days_mask & ?) <> 0', [$todayBit])
->whereRaw('delivered_today < COALESCE(effective_daily_limit_today, daily_limit_target)')
->whereExists(function ($q): void {
$q->selectRaw('1')
->from('tenants')
->whereColumn('tenants.id', 'projects.tenant_id')
->where(function ($qq): void {
$qq->where('tenants.balance_leads', '>', 0)
->orWhere('tenants.balance_rub', '>', 0);
});
})
->orderBy('created_at')
->orderBy('id')
->get();
$sql = <<<'SQL'
SELECT DISTINCT ON (projects.tenant_id) projects.*
FROM projects
WHERE EXISTS (
SELECT 1 FROM project_supplier_links psl
WHERE psl.project_id = projects.id
AND psl.supplier_project_id = ?
)
AND projects.is_active = true
AND (projects.delivery_days_mask & ?) <> 0
AND projects.delivered_today < COALESCE(projects.effective_daily_limit_today, projects.daily_limit_target)
AND EXISTS (
SELECT 1 FROM tenants
WHERE tenants.id = projects.tenant_id
AND (tenants.balance_leads > 0 OR tenants.balance_rub > 0)
)
ORDER BY
projects.tenant_id,
(COALESCE(projects.effective_daily_limit_today, projects.daily_limit_target) - projects.delivered_today) DESC,
projects.created_at,
projects.id
SQL;
return $candidates->values();
$rows = DB::connection('pgsql_supplier')->select($sql, [$supplierProject->id, $todayBit]);
return Project::hydrate($rows)->values();
}
}
+17 -2
View File
@@ -24,6 +24,21 @@ use InvalidArgumentException;
*/
class MonthlyPartitionManager
{
/**
* Connection used for partition DDL (CREATE / DROP).
*
* На проде партиционированные родители принадлежат `crm_migrator`;
* `crm_supplier_worker` член `crm_migrator` (см. db/02_grants.sql),
* поэтому через `pgsql_supplier` создаёт/дропает партиции, а
* дефолтный `crm_app_user` нет. На dev/тестах `pgsql_supplier`
* фоллбэчит на `postgres` (superuser) DDL также проходит.
*
* Тесты, триггерящие CREATE/DROP через менеджер, должны подключать
* `Tests\Concerns\SharesSupplierPdo`, иначе DDL уйдёт мимо
* test-транзакции (см. trait doc).
*/
public const DDL_CONNECTION = 'pgsql_supplier';
/**
* Таблицы, партиционированные помесячно.
* Ключ имя таблицы, значение колонка-ключ партиционирования.
@@ -38,7 +53,7 @@ class MonthlyPartitionManager
'auth_log' => 'created_at',
'activity_log' => 'created_at',
'tenant_operations_log' => 'created_at',
'webhook_log' => 'received_at',
// webhook_log удалён в миграции 2026_05_24_140000_drop_legacy_webhook_artefacts (legacy direct webhook removal)
'balance_transactions' => 'created_at',
'pd_processing_log' => 'created_at',
'saas_admin_audit_log' => 'created_at',
@@ -90,7 +105,7 @@ class MonthlyPartitionManager
return false;
}
DB::statement(sprintf(
DB::connection(self::DDL_CONNECTION)->statement(sprintf(
"CREATE TABLE %s PARTITION OF %s FOR VALUES FROM ('%s') TO ('%s')",
$partition,
$table,
-48
View File
@@ -5,11 +5,9 @@ declare(strict_types=1);
namespace App\Services;
use App\Mail\InvoicePaidNotification;
use App\Mail\LowBalanceNotification;
use App\Mail\NewLeadNotification;
use App\Mail\ReminderDueNotification;
use App\Mail\TopupSuccessNotification;
use App\Mail\ZeroBalanceNotification;
use App\Mail\ZeroBalancePausedMail;
use App\Models\Deal;
use App\Models\InAppNotification;
@@ -147,52 +145,6 @@ class NotificationService
}
}
/**
* Уведомление о низком балансе. Триггер: ProcessWebhookJob после
* lead_charge, если balance_leads <= threshold.
*
* Получатели: все активные user'ы тенанта с new_lead.email=true
* (на MVP: те же что и для new_lead обычно владелец и менеджеры).
* По prefs `low_balance.email`.
*/
public function notifyLowBalance(Tenant $tenant, int $thresholdLeads): void
{
$title = "Низкий баланс — {$tenant->balance_leads} лидов осталось";
$body = "Порог уведомления: {$thresholdLeads} лидов";
foreach ($this->recipientsForEvent($tenant, self::EVENT_LOW_BALANCE, self::CHANNEL_EMAIL) as $user) {
$this->sendEmail($user, self::EVENT_LOW_BALANCE, new LowBalanceNotification($user, $tenant, $thresholdLeads));
}
foreach ($this->recipientsForEvent($tenant, self::EVENT_LOW_BALANCE, self::CHANNEL_INAPP) as $user) {
$this->notifyInApp($user, self::EVENT_LOW_BALANCE, $title, $body, [
'tenant_id' => $tenant->id,
'balance_leads' => $tenant->balance_leads,
'threshold_leads' => $thresholdLeads,
]);
}
}
/**
* Уведомление о нулевом балансе и отклонении лидов.
* Триггер: ProcessWebhookJob::logRejection(zero_balance) в первом
* RejectedDealsLog за последний час (anti-spam: не более 1 email/час
* на тенант, проверка в caller).
*/
public function notifyZeroBalance(Tenant $tenant): void
{
$title = 'Баланс закончился — лиды отклоняются';
$body = 'Пополните баланс в разделе Биллинг';
foreach ($this->recipientsForEvent($tenant, self::EVENT_ZERO_BALANCE, self::CHANNEL_EMAIL) as $user) {
$this->sendEmail($user, self::EVENT_ZERO_BALANCE, new ZeroBalanceNotification($user, $tenant));
}
foreach ($this->recipientsForEvent($tenant, self::EVENT_ZERO_BALANCE, self::CHANNEL_INAPP) as $user) {
$this->notifyInApp($user, self::EVENT_ZERO_BALANCE, $title, $body, [
'tenant_id' => $tenant->id,
]);
}
}
/**
* Уведомление об auto-pause проекта на нулевом балансе (Plan 4 Task 6).
*
+6 -44
View File
@@ -18,7 +18,7 @@ use InvalidArgumentException;
* users: email, first_name, last_name, phone
* supplier_leads: phone, raw_payload (JSONB) нет contact_email/contact_phone
* deals: phone, contact_name нет отдельного contact_email
* webhook_log: raw_payload (JSONB)
* (webhook_log удалён в миграции 2026_05_24_140000_drop_legacy_webhook_artefacts)
*/
class PdErasureService
{
@@ -32,7 +32,7 @@ class PdErasureService
* @param int|null $tenantId Ограничить поиск одним тенантом (null = все)
* @param int $actorAdminId ID saas_admin_users
* @param string|null $requestId ID pd_subject_requests для авто-закрытия
* @return array{users: int, leads: int, deals: int, webhook_log: int}
* @return array{users: int, leads: int, deals: int}
*
* @throws InvalidArgumentException если оба email и phone null
*/
@@ -47,7 +47,7 @@ class PdErasureService
throw new InvalidArgumentException('Необходимо указать email или телефон субъекта.');
}
$counts = ['users' => 0, 'leads' => 0, 'deals' => 0, 'webhook_log' => 0];
$counts = ['users' => 0, 'leads' => 0, 'deals' => 0];
DB::connection(self::DB)->transaction(function () use (
$email, $phone, $tenantId, $actorAdminId, $requestId, &$counts
@@ -176,50 +176,12 @@ class PdErasureService
$counts['deals'] = $deals->count();
// ------------------------------------------------------------------
// 4. webhook_log (raw_payload JSONB text-search)
// ------------------------------------------------------------------
$wlQuery = DB::connection(self::DB)->table('webhook_log');
$conditions = [];
$bindings = [];
if ($email !== null) {
$conditions[] = 'raw_payload::text LIKE ?';
$bindings[] = '%'.$email.'%';
}
if ($phone !== null) {
$conditions[] = 'raw_payload::text LIKE ?';
$bindings[] = '%'.$phone.'%';
}
if (! empty($conditions)) {
$wlQuery->whereRaw('('.implode(' OR ', $conditions).')', $bindings);
}
if ($tenantId !== null) {
$wlQuery->where('tenant_id', $tenantId);
}
// Batched update: обрабатываем по 500 строк
$wlCount = 0;
$wlQuery->select('id')->orderBy('id')->chunk(500, function ($rows) use (&$wlCount): void {
$ids = $rows->pluck('id')->all();
DB::connection(self::DB)->table('webhook_log')
->whereIn('id', $ids)
->update([
'raw_payload' => DB::connection(self::DB)->raw(
"JSONB_BUILD_OBJECT('erased', TRUE, 'erased_at', NOW()::TEXT)"
),
]);
$wlCount += count($ids);
});
$counts['webhook_log'] = $wlCount;
// ------------------------------------------------------------------
// 5. Обновить pd_subject_requests если requestId передан
// 4. Обновить pd_subject_requests если requestId передан
// (webhook_log удалён в миграции 2026_05_24_140000_drop_legacy_webhook_artefacts)
// ------------------------------------------------------------------
if ($requestId !== null) {
$summary = "Удалено: users={$counts['users']}, leads={$counts['leads']}, "
."deals={$counts['deals']}, webhook_log={$counts['webhook_log']}";
."deals={$counts['deals']}";
DB::connection(self::DB)->table('pd_subject_requests')
->where('id', $requestId)
@@ -26,7 +26,7 @@ class BalanceTransactionFactory extends Factory
'amount_rub' => '100.00',
'amount_leads' => 0,
'balance_rub_after' => '100.00',
'balance_leads_after' => 0,
'balance_leads_after' => null,
'description' => 'Тестовая транзакция',
'created_at' => now(),
];
-1
View File
@@ -22,7 +22,6 @@ class TenantFactory extends Factory
'subdomain' => 'tenant-'.Str::lower(Str::random(8)),
'organization_name' => fake()->company(),
'contact_email' => fake()->unique()->safeEmail(),
'webhook_token' => Str::random(64),
'timezone' => 'Europe/Moscow',
'locale' => 'ru',
'is_trial' => true,
@@ -9,8 +9,11 @@ return new class extends Migration
{
public function up(): void
{
// Guard: only run if webhook_log exists (should always exist, but be safe)
if (! Schema::hasTable('webhook_log')) {
$conn = DB::connection('pgsql_supplier');
// Guard: only run if webhook_log exists (на проде после legacy-webhook-removal
// таблицы нет — миграция становится no-op).
if (! $conn->getSchemaBuilder()->hasTable('webhook_log')) {
return;
}
@@ -18,16 +21,18 @@ return new class extends Migration
base_path('/../db/migrations/2026_05_22_002_webhook_log_supplier_columns.sql')
);
DB::unprepared($sql);
$conn->unprepared($sql);
}
public function down(): void
{
if (! Schema::hasTable('webhook_log')) {
$conn = DB::connection('pgsql_supplier');
if (! $conn->getSchemaBuilder()->hasTable('webhook_log')) {
return;
}
DB::unprepared(<<<'SQL'
$conn->unprepared(<<<'SQL'
ALTER TABLE webhook_log
DROP COLUMN IF EXISTS source,
DROP COLUMN IF EXISTS status,
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::statement(
'ALTER TABLE balance_transactions DROP CONSTRAINT IF EXISTS balance_transactions_type_check'
);
DB::statement(
'ALTER TABLE balance_transactions ADD CONSTRAINT balance_transactions_type_check '.
'CHECK (type IN ('.
"'trial_bonus','topup','lead_charge','refund',".
"'manual_adjustment','historical_import',".
"'chargeback_writedown','chargeback_repayment',".
"'migration'".
'))'
);
}
public function down(): void
{
DB::statement(
'ALTER TABLE balance_transactions DROP CONSTRAINT IF EXISTS balance_transactions_type_check'
);
DB::statement(
'ALTER TABLE balance_transactions ADD CONSTRAINT balance_transactions_type_check '.
'CHECK (type IN ('.
"'trial_bonus','topup','lead_charge','refund',".
"'manual_adjustment','historical_import',".
"'chargeback_writedown','chargeback_repayment'".
'))'
);
}
};
@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
// Idempotency guard: skip if table already exists (e.g. loaded via schema.sql).
if (DB::selectOne('SELECT 1 AS ok FROM pg_class WHERE relname = ?', ['supplier_lead_deliveries']) !== null) {
return;
}
$sql = file_get_contents(base_path('../db/migrations/2026_05_23_200_supplier_lead_deliveries.sql'));
if ($sql === false) {
throw new RuntimeException('Migration SQL file not found.');
}
// Prod: crm_app_user (default pgsql) не имеет CREATE на schema public.
// Используем pgsql_supplier (crm_supplier_worker, BYPASSRLS, имеет CREATE).
// На dev pgsql_supplier тоже = postgres superuser → работает идентично.
DB::connection('pgsql_supplier')->unprepared($sql);
}
public function down(): void
{
DB::connection('pgsql_supplier')->unprepared('DROP TABLE IF EXISTS supplier_lead_deliveries CASCADE;');
}
};
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
/**
* No-op миграция-маркер (Billing v2 Spec B): телефонный дедуп удалён,
* индекс deals_duplicate_of_id_idx становится неиспользуемым (колонка
* deals.duplicate_of_id оставлена спящей drop отдельной DBA-задачей,
* mirrors Spec A balance_leads two-phase).
*
* Почему миграция no-op: индекс владельца crm_migrator, DROP требует прав
* owner; .env не имеет crm_migrator credentials, а pgsql_supplier
* (crm_supplier_worker) не владеет индексами на partitioned deals. Запуск
* DROP отложен выполняется напрямую psql под postgres-superuser отдельно.
* Эта миграция только маркер «обработано», чтобы migrate --force не падал.
*
* На dev (postgres-superuser) индекс уже отсутствует из schema.sql v8.34,
* поэтому ничего не делаем тоже корректно.
*/
public function up(): void
{
// Intentionally empty.
}
public function down(): void
{
// No-op: recreation is unnecessary (concept removed).
}
};
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Удаление legacy-артефактов прямого webhook-канала.
*
* Spec: docs/superpowers/specs/2026-05-24-legacy-direct-webhook-removal-design.md
* Plan: docs/superpowers/plans/2026-05-24-legacy-direct-webhook-removal.md
*
* Что удаляем (финальный список по результатам Phase 1 impact-checks):
* - webhook_log (partitioned, 13 партиций) пустая на проде, источник = только удалённый ProcessWebhookJob
* - rejected_deals_log writer только ProcessWebhookJob, нет readers
* - tenants.webhook_token + tenants.webhook_token_rotated_at нет в UI/API, тесты почищены ниже
* - system_settings.low_balance_threshold_leads (seed) только legacy
*
* Phase 1 RED FLAG: webhook_dedup_keys ОСТАЁТСЯ (HistoricalImportService CSV-канал).
*
* pgsql_supplier connection BYPASSRLS-роль crm_supplier_worker (паттерн Спека B):
* под обычной crm_app_user DROP/ALTER без app.current_tenant_id GUC не пройдёт.
*/
public function up(): void
{
$conn = DB::connection('pgsql_supplier');
// Partitioned table — DROP TABLE каскадит все 13 партиций.
$conn->statement('DROP TABLE IF EXISTS webhook_log CASCADE');
// NB: webhook_dedup_keys НЕ дропаем — Phase 1 RED FLAG, живой через HistoricalImportService (CSV-канал).
// RejectedDealsLog — writer только удалённый ProcessWebhookJob, readers нет.
$conn->statement('DROP TABLE IF EXISTS rejected_deals_log CASCADE');
// tenants.webhook_token + webhook_token_rotated_at — нет в UI/API.
$conn->statement('ALTER TABLE tenants DROP COLUMN IF EXISTS webhook_token, DROP COLUMN IF EXISTS webhook_token_rotated_at');
// Legacy threshold-cross seed (caller — удалённый ProcessWebhookJob).
$conn->statement("DELETE FROM system_settings WHERE key = 'low_balance_threshold_leads'");
}
/**
* Откат пустая заглушка. Прод-restore из pg_dump backup.
* Этот метод существует только чтобы migrate:rollback не падал.
*/
public function down(): void
{
// НЕ восстанавливаем структуру — пустая заглушка.
// Прод-restore — из pg_dump backup (см. runbook docs/deploy/test-server-runbook.md).
}
};
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* supplier_csv_reconcile_log + unparseable_count: количество CSV-строк
* за окно reconcile, у которых поле «project» не парсится в платформу
* (поставщик иногда кладёт телефон/URL в Name extractPlatform = null,
* строка скипается в csv_reconcile.unparseable_project_skipped).
*
* Раньше эти строки попадали в знаменатель drift_ratio и счётчик missing,
* стабильно завышая drift до ~40-50% (false-positive drift_alert каждый
* запуск). Теперь они учитываются отдельно и вычитаются из формулы.
*
* Используется в CsvReconcileJob + AdminSupplierIntegrationController.
* Таблица SaaS-level (без RLS), пишет/читает crm_supplier_worker
* (BYPASSRLS) pgsql_supplier connection.
*/
return new class extends Migration
{
public function up(): void
{
$conn = DB::connection('pgsql_supplier');
if (! $conn->getSchemaBuilder()->hasTable('supplier_csv_reconcile_log')) {
return;
}
$conn->unprepared(<<<'SQL'
ALTER TABLE supplier_csv_reconcile_log
ADD COLUMN IF NOT EXISTS unparseable_count INTEGER NOT NULL DEFAULT 0;
SQL);
}
public function down(): void
{
$conn = DB::connection('pgsql_supplier');
if (! $conn->getSchemaBuilder()->hasTable('supplier_csv_reconcile_log')) {
return;
}
$conn->unprepared(<<<'SQL'
ALTER TABLE supplier_csv_reconcile_log
DROP COLUMN IF EXISTS unparseable_count;
SQL);
}
};
-1
View File
@@ -31,7 +31,6 @@ class DemoSeeder extends Seeder
'contact_email' => 'admin@demo.local',
'status' => 'active',
'balance_rub' => '1000.00',
'balance_leads' => 100,
'is_trial' => false,
]);
+564 -372
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -371,6 +371,20 @@ export async function refundTenant(
return data;
}
export async function updateTenantBalance(
id: number,
payload: { balance_rub: string; reason?: string },
): Promise<{ id: number; balance_rub: string; delta: string; transaction_id: number }> {
await ensureCsrfCookie();
const { data } = await apiClient.patch<{
id: number;
balance_rub: string;
delta: string;
transaction_id: number;
}>(`/api/admin/tenants/${id}/balance`, payload);
return data;
}
export async function changeTenantTariff(
id: number,
tariffId: number,
+27 -9
View File
@@ -7,20 +7,29 @@ import { apiClient, ensureCsrfCookie } from './client';
* (E3), POST topup (E1 добавляется в Task 5). GET'ы не требуют CSRF-cookie.
*/
/** Тариф в составе ответа GET /api/billing/wallet. */
/** Тариф в составе ответа GET /api/billing/wallet (Billing v2 Spec A). */
export interface WalletTariff {
code: string;
name: string;
price_monthly: string | null;
billing_model: string;
features: string[];
}
/** Ответ GET /api/billing/wallet — кошелёк тенанта. */
/** Один уровень tier-сетки в tiers_preview. */
export interface WalletTierPreview {
tier_no: number;
leads_in_tier: number | null;
price_rub: string;
}
/** Ответ GET /api/billing/wallet — кошелёк тенанта (Billing v2 Spec A). */
export interface Wallet {
balance_rub: string;
balance_leads: number;
affordable_leads: number;
current_tier: { no: number; price_rub: string; leads_left_in_tier: number } | null;
next_tier: { no: number; price_rub: string; leads_in_tier: number } | null;
delivered_in_month: number;
runway_days: number | null;
tiers_preview: WalletTierPreview[];
tariff: WalletTariff | null;
}
@@ -30,15 +39,24 @@ export async function getWallet(): Promise<Wallet> {
return data;
}
/** Строка истории транзакций (GET /api/billing/transactions). */
/** Строка истории транзакций (GET /api/billing/transactions, Billing v2 Spec A). */
export interface BillingTransaction {
id: number;
code: string;
type: string;
type:
| 'topup'
| 'lead_charge'
| 'migration'
| 'trial_bonus'
| 'manual_adjustment'
| 'historical_import'
| 'chargeback_writedown'
| 'chargeback_repayment';
description: string | null;
amount_rub: string;
amount_leads: number;
balance_rub_after: string | null;
amount_leads: number | null;
balance_rub_after: string;
display_amount_rub: string;
created_at: string;
}
@@ -0,0 +1,147 @@
<script setup lang="ts">
/**
* Диалог установки точного -баланса тенанта (SaaS-admin).
* Используется из карточки тенанта (TenantDetailHeader) и из строки таблицы
* списка (TenantsTable). Семантика «установить точную сумму» сервер сам
* считает знаковую дельту и пишет manual_adjustment + audit.
*/
import { computed, ref, watch } from 'vue';
import { updateTenantBalance } from '../../api/admin';
import { extractErrorMessage } from '../../api/client';
const props = defineProps<{
modelValue: boolean;
tenantId: number;
tenantName: string;
currentBalanceRub: number;
}>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
saved: [payload: { balance_rub: string; delta: string; transaction_id: number }];
}>();
const newBalance = ref('');
const reason = ref('');
const submitting = ref(false);
const errorMsg = ref<string | null>(null);
const targetNormalized = computed(() => {
const raw = newBalance.value.trim().replace(',', '.');
if (!/^-?\d+(\.\d{1,2})?$/.test(raw)) return '';
return Number(raw).toFixed(2);
});
const delta = computed(() => {
if (targetNormalized.value === '') return '';
return (Number(targetNormalized.value) - props.currentBalanceRub).toFixed(2);
});
const canSave = computed(
() => !submitting.value && targetNormalized.value !== '' && delta.value !== '' && Number(delta.value) !== 0,
);
watch(
() => props.modelValue,
(open) => {
if (open) {
newBalance.value = '';
reason.value = '';
errorMsg.value = null;
}
},
);
async function submit(): Promise<void> {
if (!canSave.value) return;
submitting.value = true;
errorMsg.value = null;
try {
const payload: { balance_rub: string; reason?: string } = { balance_rub: targetNormalized.value };
if (reason.value.trim() !== '') payload.reason = reason.value.trim();
const result = await updateTenantBalance(props.tenantId, payload);
emit('saved', { balance_rub: result.balance_rub, delta: result.delta, transaction_id: result.transaction_id });
emit('update:modelValue', false);
} catch (e) {
errorMsg.value = extractErrorMessage(e, 'Не удалось изменить баланс.');
} finally {
submitting.value = false;
}
}
function close(): void {
emit('update:modelValue', false);
}
</script>
<template>
<v-dialog
:model-value="modelValue"
max-width="460"
@update:model-value="emit('update:modelValue', $event)"
>
<v-card>
<v-card-title class="text-h6">Изменить баланс</v-card-title>
<v-card-subtitle>{{ tenantName }}</v-card-subtitle>
<v-card-text>
<div class="text-body-2 text-medium-emphasis mb-3">
Текущий баланс: <strong class="num">{{ currentBalanceRub.toFixed(2) }} </strong>
</div>
<v-text-field
v-model="newBalance"
label="Новый баланс, ₽"
type="text"
inputmode="decimal"
density="comfortable"
data-testid="balance-input"
:hint="targetNormalized === '' && newBalance !== '' ? 'Формат: 1234.56' : ''"
persistent-hint
/>
<v-text-field
v-model="reason"
label="Причина (необязательно)"
type="text"
density="comfortable"
maxlength="500"
class="mt-2"
data-testid="reason-input"
/>
<div v-if="delta !== ''" class="preview mt-3 text-body-2">
было <span class="num">{{ currentBalanceRub.toFixed(2) }} </span>
станет <span class="num">{{ targetNormalized }} </span>
(<span class="num" :class="Number(delta) < 0 ? 'text-error' : 'text-success'">
{{ Number(delta) > 0 ? '+' : '' }}{{ delta }}
</span>)
</div>
<v-alert v-if="errorMsg" type="error" variant="tonal" density="compact" class="mt-3">
{{ errorMsg }}
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" :disabled="submitting" @click="close">Отмена</v-btn>
<v-btn
color="primary"
variant="flat"
:loading="submitting"
:disabled="!canSave"
data-testid="balance-save"
@click="submit"
>
Сохранить
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<style scoped>
.num {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum';
}
</style>
@@ -9,6 +9,7 @@ defineProps<{
const emit = defineEmits<{
back: [];
impersonate: [];
editBalance: [];
}>();
</script>
@@ -70,6 +71,17 @@ const emit = defineEmits<{
{{ formatRub(tenant.balanceRub) }}
</div>
<div class="kpi-sub text-caption text-medium-emphasis">runway ~{{ tenant.runwayDays }} дн</div>
<v-btn
variant="text"
size="small"
density="comfortable"
prepend-icon="mdi-pencil"
class="mt-1 px-0"
data-testid="edit-balance-btn"
@click="emit('editBalance')"
>
Изменить
</v-btn>
</v-card>
<v-card variant="outlined" class="kpi-card pa-4" data-testid="kpi-mrr">
<div class="kpi-label text-caption text-medium-emphasis">Тариф / MRR</div>
@@ -8,6 +8,7 @@ defineProps<{
const emit = defineEmits<{
rowClick: [tenant: AdminTenant];
impersonate: [tenant: AdminTenant];
editBalance: [tenant: AdminTenant];
}>();
function formatRub(v: number): string {
@@ -40,7 +41,7 @@ function statusColor(s: TenantStatus): string {
{ title: 'Желаем×факт сегодня', key: 'today', align: 'end', sortable: false },
{ title: 'MRR', key: 'mrrRub', align: 'end', sortable: false },
{ title: 'Активность', key: 'activitySince', sortable: false },
{ title: 'Действия', key: 'actions', align: 'end', sortable: false, width: 56 },
{ title: 'Действия', key: 'actions', align: 'end', sortable: false, width: 96 },
]"
items-per-page="-1"
hide-default-footer
@@ -78,6 +79,20 @@ function statusColor(s: TenantStatus): string {
<span class="num text-medium-emphasis">{{ item.activitySince }}</span>
</template>
<template #[`item.actions`]="{ item }: { item: AdminTenant }">
<v-tooltip text="Изменить баланс" location="top" aria-label="Изменить баланс">
<template #activator="{ props: tipProps }">
<v-btn
v-bind="tipProps"
icon="mdi-cash-edit"
variant="text"
size="small"
density="comfortable"
:aria-label="`Изменить баланс для ${item.name}`"
:data-testid="`edit-balance-btn-${item.id}`"
@click.stop="emit('editBalance', item)"
/>
</template>
</v-tooltip>
<v-tooltip
text="Войти как клиент (impersonation)"
location="top"
@@ -1,27 +1,23 @@
<script setup lang="ts">
/**
* BalanceCard 3 wallet-cards в одной строке: Кошелёк (dark) +
* Баланс лидов + Тариф. Данные из GET /api/billing/wallet (E3).
* Лиды (affordable_leads) + Тариф. Данные из GET /api/billing/wallet (E3).
* Billing v2 Spec A: affordable_leads вместо leadsBalance; currentTierPriceRub вместо tariffPrice.
* tariff* допускают null (тенант без назначенного тарифа trial).
*/
import { computed } from 'vue';
const props = defineProps<{
walletRub: number;
leadsBalance: number;
affordableLeads: number;
currentTierPriceRub: string;
tariffName: string | null;
tariffPrice: string | null;
tariffFeatures: string[];
}>();
defineEmits<{ topup: [] }>();
const walletText = computed(() => new Intl.NumberFormat('ru-RU').format(props.walletRub));
const tariffPriceText = computed(() => {
if (props.tariffPrice === null) return 'по запросу';
return new Intl.NumberFormat('ru-RU').format(Number(props.tariffPrice)) + ' ₽/мес';
});
</script>
<template>
@@ -36,7 +32,7 @@ const tariffPriceText = computed(() => {
<span class="num">{{ walletText }}</span>
<span class="ru">&nbsp;</span>
</div>
<div class="wallet-foot mt-3">мин. пополнение <strong>100 </strong> · округление вниз лиды</div>
<div class="wallet-foot mt-3">мин. пополнение <strong>100 </strong></div>
<div class="wallet-actions mt-3">
<v-btn
color="primary"
@@ -62,12 +58,19 @@ const tariffPriceText = computed(() => {
<v-col cols="12" md="4">
<v-card variant="outlined" class="wallet-card pa-4">
<div class="wallet-h">
<span class="wallet-label">Баланс лидов (ГЦК)</span>
<span class="wallet-label"> Лиды</span>
</div>
<div class="wallet-amount mt-2">
<span class="num">{{ leadsBalance }}</span>
<span class="num"> {{ affordableLeads }}</span>
<span class="ru-text">&nbsp;лидов</span>
</div>
<div class="wallet-foot mt-2">сейчас по {{ currentTierPriceRub }} /лид
<v-tooltip text="Точный расчёт по текущим ценам. Меняется при переходе ступеней.">
<template #activator="{ props: tipProps }">
<v-icon v-bind="tipProps" size="14" class="ml-1">mdi-information-outline</v-icon>
</template>
</v-tooltip>
</div>
</v-card>
</v-col>
@@ -75,10 +78,7 @@ const tariffPriceText = computed(() => {
<v-card variant="outlined" class="wallet-card pa-4 d-flex flex-column">
<span class="wallet-label">Тариф</span>
<template v-if="tariffName">
<div class="tariff-name mt-1">
{{ tariffName }}
<span class="tariff-price">· {{ tariffPriceText }}</span>
</div>
<div class="tariff-name mt-1">{{ tariffName }}</div>
<ul v-if="tariffFeatures.length" class="tariff-feats mt-3">
<li v-for="f in tariffFeatures" :key="f">
<v-icon size="14" color="success" class="mr-1">mdi-check</v-icon>{{ f }}
@@ -0,0 +1,22 @@
<script setup lang="ts">
import TierPricesPanel from './TierPricesPanel.vue';
const tiers = [
{ tier_no: 1, leads_in_tier: 50, price_rub: '120.00' },
{ tier_no: 2, leads_in_tier: 100, price_rub: '100.00' },
{ tier_no: 3, leads_in_tier: 200, price_rub: '80.00' },
{ tier_no: 4, leads_in_tier: 300, price_rub: '70.00' },
{ tier_no: 5, leads_in_tier: 400, price_rub: '65.00' },
{ tier_no: 6, leads_in_tier: 500, price_rub: '62.00' },
{ tier_no: 7, leads_in_tier: null, price_rub: '60.00' },
];
</script>
<template>
<Story title="Billing/TierPricesPanel">
<Variant title="Текущая ступень: 1"><TierPricesPanel :tiers="tiers" :current-tier-no="1" /></Variant>
<Variant title="Текущая ступень: 3"><TierPricesPanel :tiers="tiers" :current-tier-no="3" /></Variant>
<Variant title="Текущая ступень: 7 (всё свыше)"><TierPricesPanel :tiers="tiers" :current-tier-no="7" /></Variant>
<Variant title="Без current_tier"><TierPricesPanel :tiers="tiers" :current-tier-no="null" /></Variant>
</Story>
</template>
@@ -0,0 +1,86 @@
<script setup lang="ts">
/**
* TierPricesPanel свёрнутый по умолчанию блок с ценами 7 ступеней.
* Подсвечивает текущую ступень чипом «вы здесь». Источник данных
* GET /api/billing/wallet tiers_preview + current_tier.no.
*
* Billing v2 Spec A §3.4.8.
*/
interface TierPreview {
tier_no: number;
leads_in_tier: number | null;
price_rub: string;
}
const props = defineProps<{
tiers: TierPreview[];
currentTierNo: number | null;
}>();
function rangeText(idx: number): string {
let start = 1;
for (let i = 0; i < idx; i++) {
const cap = props.tiers[i].leads_in_tier;
if (cap !== null) start += cap;
}
const cap = props.tiers[idx].leads_in_tier;
if (cap === null) return `${start}+`;
return `${start}${start + cap - 1}`;
}
</script>
<template>
<v-expansion-panels class="mt-4 tier-prices">
<v-expansion-panel title="Цены за лид (7 ступеней)" eager>
<template #text>
<ul class="tier-list">
<li
v-for="(t, i) in tiers"
:key="t.tier_no"
data-test="tier-row"
:data-test-row="`tier-row-${t.tier_no}`"
class="tier-row"
:class="{ 'tier-row--current': t.tier_no === currentTierNo }"
>
<span class="tier-no num">{{ t.tier_no }}</span>
<span class="tier-range">{{ rangeText(i) }} лидов</span>
<span class="tier-price num">{{ t.price_rub }} </span>
<v-chip
v-if="t.tier_no === currentTierNo"
size="x-small"
color="primary"
variant="elevated"
>вы здесь</v-chip>
</li>
</ul>
</template>
</v-expansion-panel>
</v-expansion-panels>
</template>
<style scoped>
.tier-list {
list-style: none;
padding: 0;
margin: 0;
}
.tier-row {
display: grid;
grid-template-columns: 60px 1fr auto auto;
gap: 12px;
align-items: center;
padding: 8px 12px;
}
.tier-row--current {
background: rgba(15, 110, 86, 0.07);
border-left: 3px solid #0f6e56;
}
.num {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum';
}
.tier-price {
color: #0f6e56;
font-weight: 600;
}
</style>
@@ -18,7 +18,6 @@ const TABS: Tab[] = [
{ id: 'all', label: 'Все', type: null },
{ id: 'topup', label: 'Пополнения', type: 'topup' },
{ id: 'lead_charge', label: 'Списания', type: 'lead_charge' },
{ id: 'refund', label: 'Возвраты', type: 'refund' },
];
const activeTab = ref<string>('all');
@@ -38,6 +37,7 @@ const headers = [
function formatWhen(iso: string): string {
return new Date(iso).toLocaleString('ru-RU', {
timeZone: 'Europe/Moscow',
year: '2-digit',
day: '2-digit',
month: '2-digit',
hour: '2-digit',
@@ -45,21 +45,14 @@ function formatWhen(iso: string): string {
});
}
/** Числовое значение движения: рубли приоритетно, иначе лиды. */
/** Числовое значение движения из display_amount_rub. */
function txAmountValue(tx: BillingTransaction): number {
const rub = Number(tx.amount_rub);
return rub !== 0 ? rub : tx.amount_leads;
return Number(tx.display_amount_rub);
}
/** Текст суммы: «+ 5 000 ₽» / «− 1 лид.» / «0 ₽». */
/** Текст суммы из display_amount_rub. */
function txAmountText(tx: BillingTransaction): string {
const rub = Number(tx.amount_rub);
if (rub !== 0) return formatCost(rub);
if (tx.amount_leads !== 0) {
const sign = tx.amount_leads > 0 ? '+ ' : ' ';
return sign + Math.abs(tx.amount_leads) + ' лид.';
}
return '0 ₽';
return formatCost(Number(tx.display_amount_rub));
}
async function load(): Promise<void> {
+9 -8
View File
@@ -11,6 +11,7 @@
*/
import { ref, computed, onMounted } from 'vue';
import BalanceCard from '../components/billing/BalanceCard.vue';
import TierPricesPanel from '../components/billing/TierPricesPanel.vue';
import TransactionsTable from '../components/billing/TransactionsTable.vue';
import InvoicesTable from '../components/billing/InvoicesTable.vue';
import TopupDialog from '../components/billing/TopupDialog.vue';
@@ -29,10 +30,12 @@ const topupSnackbar = ref(false);
const txTableRef = ref<InstanceType<typeof TransactionsTable> | null>(null);
const walletRub = computed(() => Number(wallet.value?.balance_rub ?? 0));
const leadsBalance = computed(() => wallet.value?.balance_leads ?? 0);
const affordableLeads = computed(() => wallet.value?.affordable_leads ?? 0);
const currentTierPriceRub = computed(() => wallet.value?.current_tier?.price_rub ?? '0.00');
const tiersPreview = computed(() => wallet.value?.tiers_preview ?? []);
const currentTierNo = computed(() => wallet.value?.current_tier?.no ?? null);
const runwayDays = computed(() => wallet.value?.runway_days ?? null);
const tariffName = computed(() => wallet.value?.tariff?.name ?? null);
const tariffPrice = computed(() => wallet.value?.tariff?.price_monthly ?? null);
const tariffFeatures = computed<string[]>(() => (wallet.value?.tariff?.features ?? []).map(featureLabel));
async function loadWallet(): Promise<void> {
@@ -73,10 +76,6 @@ defineExpose({ loadWallet, wallet, topupOpen });
<span
><span class="num text-primary">{{ formatPlain(walletRub) }}</span> кошелёк</span
>
<span class="sep">·</span>
<span
><span class="num">{{ leadsBalance }}</span> лидов запас</span
>
<template v-if="runwayDays !== null">
<span class="sep">·</span>
<span
@@ -111,13 +110,15 @@ defineExpose({ loadWallet, wallet, topupOpen });
<template v-else-if="wallet">
<BalanceCard
:wallet-rub="walletRub"
:leads-balance="leadsBalance"
:affordable-leads="affordableLeads"
:current-tier-price-rub="currentTierPriceRub"
:tariff-name="tariffName"
:tariff-price="tariffPrice"
:tariff-features="tariffFeatures"
@topup="topupOpen = true"
/>
<TierPricesPanel :tiers="tiersPreview" :current-tier-no="currentTierNo" />
<TransactionsTable ref="txTableRef" />
<InvoicesTable />
@@ -22,6 +22,7 @@ import type { AdminTenantDetail } from '../../composables/mockTenantDetail';
import ImpersonationDialog from '../../components/admin/ImpersonationDialog.vue';
import TenantDetailHeader from '../../components/admin/tenant-detail/TenantDetailHeader.vue';
import TenantDetailTabs from '../../components/admin/tenant-detail/TenantDetailTabs.vue';
import TenantBalanceDialog from '../../components/admin/TenantBalanceDialog.vue';
const route = useRoute();
const router = useRouter();
@@ -64,6 +65,7 @@ watch(code, () => {
const ADMIN_USER_ID = 1;
const impersonationOpen = ref(false);
const balanceDialogOpen = ref(false);
const activeTab = ref<'finance' | 'users' | 'projects' | 'activity'>('finance');
@@ -71,14 +73,30 @@ function goBack() {
router.push({ name: 'admin-tenants' });
}
defineExpose({ tenant, activeTab, impersonationOpen, loadTenant });
async function onBalanceSaved(): Promise<void> {
await loadTenant();
}
defineExpose({ tenant, activeTab, impersonationOpen, balanceDialogOpen, loadTenant });
</script>
<template>
<v-container v-if="tenant" fluid class="tenant-detail pa-6">
<TenantDetailHeader :tenant="tenant" @back="goBack" @impersonate="impersonationOpen = true" />
<TenantDetailHeader
:tenant="tenant"
@back="goBack"
@impersonate="impersonationOpen = true"
@edit-balance="balanceDialogOpen = true"
/>
<TenantDetailTabs :tenant="tenant" :active-tab="activeTab" @update:active-tab="activeTab = $event" />
<ImpersonationDialog v-model="impersonationOpen" :tenant="tenant" :requested-by="ADMIN_USER_ID" />
<TenantBalanceDialog
v-model="balanceDialogOpen"
:tenant-id="tenant.id"
:tenant-name="tenant.name"
:current-balance-rub="tenant.balanceRub"
@saved="onBalanceSaved"
/>
</v-container>
<v-container v-else-if="loading" fluid class="pa-6" data-testid="tenant-loading">
@@ -23,6 +23,7 @@ import * as adminApi from '../../api/admin';
// `findComponent({ name: 'ImpersonationDialog' })` + `stubs`, defineAsyncComponent
// ломает identity wrapper'а в test-utils.
import ImpersonationDialog from '../../components/admin/ImpersonationDialog.vue';
import TenantBalanceDialog from '../../components/admin/TenantBalanceDialog.vue';
import TenantsStatsHeader from '../../components/admin/tenants/TenantsStatsHeader.vue';
import TenantsFilters from '../../components/admin/tenants/TenantsFilters.vue';
import TenantsTable from '../../components/admin/tenants/TenantsTable.vue';
@@ -66,6 +67,9 @@ const filterTariffs = ref<string[]>([]);
const impersonationOpen = ref(false);
const impersonationTenant = ref<AdminTenant | null>(null);
const balanceDialogOpen = ref(false);
const balanceTarget = ref<AdminTenant | null>(null);
const availableTariffs = computed(() => Array.from(new Set(tenantsState.map((t) => t.tariff))).sort());
function clearFilters() {
@@ -80,12 +84,23 @@ function openImpersonation(tenant: AdminTenant) {
impersonationOpen.value = true;
}
function openBalanceDialog(tenant: AdminTenant) {
balanceTarget.value = tenant;
balanceDialogOpen.value = true;
}
async function onBalanceSaved(): Promise<void> {
await loadTenants();
}
defineExpose({
filterStatuses,
filterTariffs,
clearFilters,
impersonationOpen,
impersonationTenant,
balanceDialogOpen,
balanceTarget,
tenantsState,
stats,
loading,
@@ -137,9 +152,23 @@ const filteredTenants = computed<AdminTenant[]>(() => {
@clear="clearFilters"
/>
<TenantsTable :tenants="filteredTenants" @row-click="openTenantDetail" @impersonate="openImpersonation" />
<TenantsTable
:tenants="filteredTenants"
@row-click="openTenantDetail"
@impersonate="openImpersonation"
@edit-balance="openBalanceDialog"
/>
<ImpersonationDialog v-model="impersonationOpen" :tenant="impersonationTenant" :requested-by="ADMIN_USER_ID" />
<TenantBalanceDialog
v-if="balanceTarget"
v-model="balanceDialogOpen"
:tenant-id="balanceTarget.id"
:tenant-name="balanceTarget.name"
:current-balance-rub="balanceTarget.balanceRub"
@saved="onBalanceSaved"
/>
</v-container>
</template>
+14 -30
View File
@@ -12,18 +12,6 @@
style="max-width: 220px"
@update:model-value="refresh"
/>
<v-select
v-model="source"
:items="sources"
item-title="title"
item-value="value"
label="Источник"
density="compact"
hide-details
clearable
style="max-width: 200px"
@update:model-value="refresh"
/>
<v-spacer />
<v-btn color="primary" prepend-icon="mdi-download" :loading="exporting" @click="exportCsv">
Скачать CSV
@@ -45,12 +33,14 @@
<template #[`item.deal_id`]="{ item }">
<RouterLink :to="`/deals/${item.deal_id}`">#{{ item.deal_id }}</RouterLink>
</template>
<template #[`item.charge_source`]="{ item }">
<v-chip size="small" :color="item.charge_source === 'prepaid' ? 'info' : 'success'">
{{ item.charge_source === 'prepaid' ? 'prepaid' : '₽' }}
</v-chip>
<template #[`item.price_rub`]="{ item }">
<span v-if="item.price_per_lead_kopecks === 0" class="text-medium-emphasis">
0
<v-tooltip activator="parent" text="До перехода на новую модель эти лиды списывались из бесплатного остатка." />
<span class="text-caption ml-1">(из бесплатного)</span>
</span>
<span v-else>{{ (item.price_per_lead_kopecks / 100).toFixed(2) }} </span>
</template>
<template #[`item.price_rub`]="{ item }"> {{ (item.price_per_lead_kopecks / 100).toFixed(2) }} </template>
</v-data-table-server>
</div>
</template>
@@ -59,15 +49,17 @@
/**
* ChargesTab read-only ledger списаний за лиды (Plan 4 Task 11).
*
* Backend: GET /api/billing/charges?page=N&period=...&charge_source=...
* Backend: GET /api/billing/charges?page=N&period=...
* POST /api/billing/charges/export CSV blob.
*
* Pagination server-side через v-data-table-server (20/page).
* Фильтры: period (current_month / last_month / 90d) + charge_source (prepaid / rub).
* Фильтры: period (current_month / last_month / 90d).
* Исторические prepaid-строки (price_per_lead_kopecks=0) помечены
* тултипом «До перехода на новую модель...».
* RLS: tenant-isolation на backend, frontend просто читает то что вернули.
*
* defineExpose ниже для Vitest unit-тестов (`refresh`/`exportCsv`/`period`/
* `source`/`total`/`load`).
* `total`/`load`).
*/
import { ref, onMounted } from 'vue';
import axios from 'axios';
@@ -86,7 +78,6 @@ const total = ref(0);
const loading = ref(false);
const exporting = ref(false);
const period = ref<string>('current_month');
const source = ref<string | null>(null);
const page = ref(1);
const periods = [
@@ -94,17 +85,12 @@ const periods = [
{ title: 'Прошлый месяц', value: 'last_month' },
{ title: 'Последние 90 дней', value: '90d' },
];
const sources = [
{ title: 'prepaid', value: 'prepaid' },
{ title: '₽', value: 'rub' },
];
const headers = [
{ title: 'Дата', key: 'charged_at', sortable: false },
{ title: 'Сделка', key: 'deal_id', sortable: false, width: 100 },
{ title: 'Tier', key: 'tier_no', sortable: false, width: 80 },
{ title: 'Источник', key: 'charge_source', sortable: false, width: 120 },
{ title: 'Цена', key: 'price_rub', sortable: false, width: 120 },
{ title: 'Цена', key: 'price_rub', sortable: false, width: 160 },
];
function formatDate(iso: string): string {
@@ -125,7 +111,6 @@ async function load(): Promise<void> {
loading.value = true;
try {
const params: Record<string, string | number> = { page: page.value, period: period.value };
if (source.value) params.charge_source = source.value;
const { data } = await axios.get('/api/billing/charges', { params });
rows.value = data.data;
total.value = data.meta.total;
@@ -138,7 +123,6 @@ async function exportCsv(): Promise<void> {
exporting.value = true;
try {
const params: Record<string, string> = { period: period.value };
if (source.value) params.charge_source = source.value;
const response = await axios.post('/api/billing/charges/export', params, { responseType: 'blob' });
// jsdom не реализует URL.createObjectURL gracefully no-op (тесты мокают).
if (typeof URL.createObjectURL !== 'function') return;
@@ -157,7 +141,7 @@ async function exportCsv(): Promise<void> {
onMounted(load);
defineExpose({ refresh, exportCsv, load, period, source, total });
defineExpose({ refresh, exportCsv, load, period, total });
</script>
<style scoped>
@@ -1,26 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Низкий баланс</title>
</head>
<body style="font-family: Inter, -apple-system, sans-serif; max-width: 600px; margin: 0 auto; padding: 24px; color: #081319;">
<h1 style="color: #0F6E56; font-size: 20px;">Лидерра. Низкий баланс</h1>
<p>Здравствуйте, {{ $recipient->first_name ?? $recipient->email }}.</p>
<p>На балансе аккаунта <strong>{{ $tenant->organization_name ?? $tenant->subdomain }}</strong>
осталось <strong>{{ $tenant->balance_leads }} лидов</strong>
(порог уведомления {{ $thresholdLeads }} лидов).</p>
<p style="background: #FEF3F2; padding: 12px; border-left: 3px solid #B94837;">
Если баланс закончится все входящие лиды будут отклоняться.
</p>
<p style="margin-top: 24px;">Откройте Биллинг в CRM, чтобы пополнить баланс.</p>
<p style="color: #66635C; font-size: 12px; margin-top: 32px;">
Это автоматическое уведомление о событии «Низкий баланс». Чтобы изменить настройки уведомлений перейдите в Настройки Уведомления.
</p>
</body>
</html>
@@ -1,27 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Баланс закончился</title>
</head>
<body style="font-family: Inter, -apple-system, sans-serif; max-width: 600px; margin: 0 auto; padding: 24px; color: #081319;">
<h1 style="color: #B94837; font-size: 20px;">Лидерра. Баланс закончился</h1>
<p>Здравствуйте, {{ $recipient->first_name ?? $recipient->email }}.</p>
<p>Баланс аккаунта <strong>{{ $tenant->organization_name ?? $tenant->subdomain }}</strong>
обнулился <strong>входящие лиды отклоняются</strong>.</p>
<p style="background: #FEF3F2; padding: 12px; border-left: 3px solid #B94837;">
<strong>Что делать:</strong> пополните баланс в разделе Биллинг.
После пополнения новые лиды снова начнут приниматься.
</p>
<p>Уже отклонённые лиды не возвращаются посмотрите их в журнале
«Отклонённые лиды» в разделе Отчёты.</p>
<p style="color: #66635C; font-size: 12px; margin-top: 32px;">
Это автоматическое уведомление о событии «Нулевой баланс». Чтобы изменить настройки уведомлений перейдите в Настройки Уведомления.
</p>
</body>
</html>
+2 -6
View File
@@ -98,6 +98,8 @@ Route::middleware('saas-admin')->group(function () {
Route::get('/api/admin/tenants', 'App\Http\Controllers\Api\AdminTenantsController@index');
Route::get('/api/admin/tenants/{subdomain}', 'App\Http\Controllers\Api\AdminTenantsController@show')
->where('subdomain', '[a-z0-9_-]+');
Route::patch('/api/admin/tenants/{id}/balance', 'App\Http\Controllers\Api\AdminTenantsController@updateBalance')
->where('id', '[0-9]+');
// SaaS-admin → Биллинг: aggregates пополнений/списаний за текущий месяц.
Route::get('/api/admin/billing', 'App\Http\Controllers\Api\AdminBillingController@index');
@@ -268,12 +270,6 @@ Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/projects')->group(fu
Route::patch('/{id}/toggle-active', 'App\Http\Controllers\Api\ProjectController@toggleActive')->name('projects.toggle')->where('id', '[0-9]+');
});
// Receive endpoint для входящих webhook'ов (narrative §5.5).
// Auth — по `tenants.webhook_token` в URL (без middleware, проверка внутри controller).
// На prod: + HMAC-валидация X-Webhook-Signature + per-token rate-limit.
Route::post('/api/webhook/{token}', 'App\Http\Controllers\Api\WebhookReceiveController@receive')
->where('token', '[A-Za-z0-9\-_]+');
// Supplier-integration webhook (Plan 2/5, spec §5.1).
// Platform-wide endpoint: единый {secret} в URL для всех лидов от crm.bp-gr.ru.
// Auth: secret (system_settings.supplier_webhook_secret) + IP allowlist
-1
View File
@@ -77,7 +77,6 @@ foreach ($accounts as $a) {
[
'organization_name' => $a['org_name'],
'contact_email' => $user->email,
'webhook_token' => Str::random(64),
'timezone' => 'Europe/Moscow',
'locale' => 'ru',
'is_trial' => true,
@@ -164,7 +164,6 @@ it('executeErasure anonymises user email first_name phone and writes pd_processi
'organization_name' => 'PD User Test',
'contact_email' => 'pd-u@test.local',
'status' => 'active',
'webhook_token' => bin2hex(random_bytes(16)),
'balance_rub' => '0.00',
'balance_leads' => 0,
'is_trial' => false,
@@ -161,3 +161,31 @@ it('writes audit-trail row in saas_admin_audit_log on POST', function () {
->where('action', 'pricing_tiers.create_scheduled')->first();
expect($log)->not->toBeNull();
});
test('AdminPricingTiers::store сохраняет цену 10.10 ₽ как ровно 1010 kopecks (без float-drift)', function () {
$tiers = [];
for ($i = 1; $i <= 6; $i++) {
$tiers[] = ['tier_no' => $i, 'leads_in_tier' => 50, 'price_rub' => '10.10'];
}
$tiers[] = ['tier_no' => 7, 'leads_in_tier' => null, 'price_rub' => '10.10'];
$resp = $this->postJson('/api/admin/pricing-tiers', ['tiers' => $tiers]);
$resp->assertCreated();
$expectedDate = now('Europe/Moscow')->startOfMonth()->addMonth()->toDateString();
foreach (PricingTier::query()->where('effective_from', $expectedDate)->orderBy('tier_no')->get() as $row) {
expect((int) $row->price_per_lead_kopecks)->toBe(1010);
}
});
test('AdminPricingTiers::store отклоняет некорректный price_rub (например "10.123" — три знака после точки)', function () {
$tiers = [];
for ($i = 1; $i <= 6; $i++) {
$tiers[] = ['tier_no' => $i, 'leads_in_tier' => 50, 'price_rub' => '10.10'];
}
$tiers[] = ['tier_no' => 7, 'leads_in_tier' => null, 'price_rub' => '10.123'];
$resp = $this->postJson('/api/admin/pricing-tiers', ['tiers' => $tiers]);
$resp->assertStatus(422);
$resp->assertJsonValidationErrors(['tiers.6.price_rub']);
});
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
use App\Models\BalanceTransaction;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
uses(DatabaseTransactions::class);
function makeBalanceTenant(string $balanceRub): Tenant
{
return Tenant::factory()->create(['balance_rub' => $balanceRub]);
}
it('sets exact balance and records signed manual_adjustment delta', function () {
$tenant = makeBalanceTenant('1000.00');
$resp = $this->patchJson("/api/admin/tenants/{$tenant->id}/balance", [
'balance_rub' => '2500.00',
'reason' => 'Коррекция тестового баланса',
]);
$resp->assertOk()
->assertJsonPath('balance_rub', '2500.00')
->assertJsonPath('delta', '1500.00');
$tenant->refresh();
expect((string) $tenant->balance_rub)->toBe('2500.00');
$tx = BalanceTransaction::where('tenant_id', $tenant->id)
->where('type', BalanceTransaction::TYPE_MANUAL_ADJUSTMENT)
->latest('id')->first();
expect($tx)->not->toBeNull();
expect((string) $tx->amount_rub)->toBe('1500.00');
expect((string) $tx->balance_rub_after)->toBe('2500.00');
expect($tx->amount_leads)->toBeNull();
expect($tx->description)->toBe('Коррекция тестового баланса');
});
it('records negative delta when lowering balance', function () {
$tenant = makeBalanceTenant('1000.00');
$resp = $this->patchJson("/api/admin/tenants/{$tenant->id}/balance", [
'balance_rub' => '300.00',
]);
$resp->assertOk()->assertJsonPath('delta', '-700.00');
$tx = BalanceTransaction::where('tenant_id', $tenant->id)
->where('type', BalanceTransaction::TYPE_MANUAL_ADJUSTMENT)
->latest('id')->first();
expect((string) $tx->amount_rub)->toBe('-700.00');
expect($tx->description)->toBe('Ручная корректировка баланса (админ)');
});
it('accepts negative target balance (debt correction)', function () {
$tenant = makeBalanceTenant('0.00');
$this->patchJson("/api/admin/tenants/{$tenant->id}/balance", [
'balance_rub' => '-500.00',
])->assertOk()->assertJsonPath('balance_rub', '-500.00');
expect((string) $tenant->fresh()->balance_rub)->toBe('-500.00');
});
it('rejects no-op (target equals current) with 422', function () {
$tenant = makeBalanceTenant('1000.00');
$this->patchJson("/api/admin/tenants/{$tenant->id}/balance", [
'balance_rub' => '1000.00',
])->assertStatus(422);
});
it('rejects malformed balance_rub with 422', function () {
$tenant = makeBalanceTenant('1000.00');
$this->patchJson("/api/admin/tenants/{$tenant->id}/balance", [
'balance_rub' => '10.123',
])->assertStatus(422);
$this->patchJson("/api/admin/tenants/{$tenant->id}/balance", [
'balance_rub' => 'abc',
])->assertStatus(422);
});
it('returns 404 for missing or soft-deleted tenant', function () {
$this->patchJson('/api/admin/tenants/99999999/balance', [
'balance_rub' => '100.00',
])->assertStatus(404);
$tenant = makeBalanceTenant('100.00');
$tenant->delete();
$this->patchJson("/api/admin/tenants/{$tenant->id}/balance", [
'balance_rub' => '200.00',
])->assertStatus(404);
});
@@ -14,7 +14,6 @@ function makeBillingTenant(array $overrides = []): int
'subdomain' => 'bt-'.bin2hex(random_bytes(4)),
'organization_name' => 'Billing Test Co',
'contact_email' => 'bt-'.bin2hex(random_bytes(3)).'@test.local',
'webhook_token' => bin2hex(random_bytes(16)),
'status' => 'active',
'balance_rub' => '5000.00',
'is_trial' => false,
@@ -64,7 +64,6 @@ test('GET /api/admin/incidents/{id} разрешает имена affected_tenan
'subdomain' => 'inc-'.bin2hex(random_bytes(4)),
'organization_name' => 'Affected Org',
'contact_email' => 'a@test.local',
'webhook_token' => bin2hex(random_bytes(16)),
'created_at' => now(),
]);
$id = makeShowIncident($this->adminId, ['affected_tenant_ids' => '{'.$tenantId.'}']);
@@ -3,14 +3,17 @@
declare(strict_types=1);
use App\Models\BalanceTransaction;
use App\Models\LeadCharge;
use App\Models\Tenant;
use App\Models\User;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->seed(PricingTierSeeder::class);
$this->tenant = Tenant::factory()->create([
'balance_rub' => '14250.00',
'balance_leads' => 285,
@@ -24,8 +27,7 @@ beforeEach(function () {
test('GET /api/billing/wallet возвращает баланс тенанта', function () {
$this->getJson('/api/billing/wallet')
->assertOk()
->assertJsonPath('balance_rub', '14250.00')
->assertJsonPath('balance_leads', 285);
->assertJsonPath('balance_rub', '14250.00');
});
test('GET /api/billing/wallet возвращает тариф, если он назначен', function () {
@@ -52,16 +54,18 @@ test('GET /api/billing/wallet: runway_days = null без списаний', func
->assertJsonPath('runway_days', null);
});
test('GET /api/billing/wallet: runway_days рассчитан при наличии списаний', function () {
BalanceTransaction::factory()->create([
test('GET /api/billing/wallet: runway_days рассчитан как affordable_leads / avg leads-per-day', function () {
// Seed 30 historical lead_charges over the last 30 days — avg 1 lead/day.
LeadCharge::factory()->count(30)->create([
'tenant_id' => $this->tenant->id,
'type' => 'lead_charge',
'amount_rub' => '-3000.00',
'created_at' => now()->subDays(10),
'charged_at' => now()->subDays(rand(1, 30)),
]);
// 3000 ₽ / 30 дн = 100 ₽/день; баланс 14250 → floor(142.5) = 142.
expect($this->getJson('/api/billing/wallet')->json('runway_days'))->toBe(142);
// Wallet has 14250 ₽. PricingTierSeeder tier 1: 100 leads @ 500₽.
// delivered_in_month=0 → 100 slots left in tier 1. afford = bcdiv(1425000, 50000, 0) = 28 leads.
// take = min(100, 28) = 28 → affordable_leads = 28.
// avg = 30/30 = 1 lead/day. runway = floor(28 / 1) = 28.
expect($this->getJson('/api/billing/wallet')->json('runway_days'))->toBe(28);
});
test('GET /api/billing/wallet: runway_days = 0 при отрицательном балансе', function () {
@@ -82,6 +86,61 @@ test('GET /api/billing/wallet без auth: 401', function () {
$this->getJson('/api/billing/wallet')->assertStatus(401);
});
test('GET /api/billing/wallet возвращает affordable_leads + current_tier + next_tier + tiers_preview', function () {
$this->tenant->update([
'balance_rub' => '5000.00',
'delivered_in_month' => 30,
]);
$resp = $this->getJson('/api/billing/wallet');
$resp->assertOk()->assertJsonStructure([
'balance_rub',
'affordable_leads',
'current_tier' => ['no', 'price_rub', 'leads_left_in_tier'],
'next_tier' => ['no', 'price_rub', 'leads_in_tier'],
'delivered_in_month',
'runway_days',
'tiers_preview' => [['tier_no', 'leads_in_tier', 'price_rub']],
'tariff',
]);
// Recomputed against real PricingTierSeeder:
// tier 1: 100 leads × 500₽; delivered=30 → 70 slots left; balance 5000 → afford 10 in tier 1.
expect($resp->json('affordable_leads'))->toBe(10);
expect($resp->json('current_tier.no'))->toBe(1);
expect($resp->json('current_tier.price_rub'))->toBe('500.00');
expect($resp->json('current_tier.leads_left_in_tier'))->toBe(70);
expect($resp->json('next_tier.no'))->toBe(2);
expect($resp->json('next_tier.price_rub'))->toBe('450.00');
expect($resp->json('next_tier.leads_in_tier'))->toBe(200);
expect($resp->json('delivered_in_month'))->toBe(30);
expect($resp->json('tiers_preview'))->toHaveCount(7);
expect($resp->json('tiers_preview.0'))->toMatchArray([
'tier_no' => 1, 'leads_in_tier' => 100, 'price_rub' => '500.00',
]);
expect($resp->json('tiers_preview.6'))->toMatchArray([
'tier_no' => 7, 'leads_in_tier' => null, 'price_rub' => '250.00',
]);
});
test('GET /api/billing/wallet НЕ возвращает balance_leads (Billing v2 Spec A)', function () {
$resp = $this->getJson('/api/billing/wallet');
$resp->assertOk();
expect($resp->json())->not->toHaveKey('balance_leads');
});
test('GET /api/billing/wallet: tariff НЕ содержит price_monthly или billing_model (Spec A унификация)', function () {
$tariffId = DB::table('tariff_plans')->where('code', 'pro')->value('id');
$this->tenant->update(['current_tariff_id' => $tariffId]);
$tariff = $this->getJson('/api/billing/wallet')->json('tariff');
expect($tariff)->not->toBeNull();
expect($tariff)->not->toHaveKey('price_monthly');
expect($tariff)->not->toHaveKey('billing_model');
expect($tariff)->toHaveKeys(['code', 'name', 'features']);
});
// ---- transactions ----
test('GET /api/billing/transactions возвращает транзакции тенанта', function () {
@@ -112,8 +171,9 @@ test('GET /api/billing/transactions фильтрует по type', function () {
->assertJsonCount(1, 'data')->assertJsonPath('data.0.type', 'topup');
$this->getJson('/api/billing/transactions?type=lead_charge')
->assertJsonCount(1, 'data')->assertJsonPath('data.0.type', 'lead_charge');
// Billing v2 Spec A: 'refund' убран из whitelist — фильтр игнорируется, возвращает все 3 строки.
$this->getJson('/api/billing/transactions?type=refund')
->assertJsonCount(1, 'data')->assertJsonPath('data.0.type', 'refund');
->assertJsonCount(3, 'data');
});
test('GET /api/billing/transactions: пагинация 20/страница', function () {
@@ -128,6 +188,57 @@ test('GET /api/billing/transactions без auth: 401', function () {
$this->getJson('/api/billing/transactions')->assertStatus(401);
});
test('GET /api/billing/transactions?type=refund — фильтр игнорируется (Spec A удалил возвраты)', function () {
BalanceTransaction::factory()->create(['tenant_id' => $this->tenant->id, 'type' => 'topup']);
BalanceTransaction::factory()->create(['tenant_id' => $this->tenant->id, 'type' => 'lead_charge']);
// ?type=refund must NOT narrow the filter — falls through to "no filter"
$resp = $this->getJson('/api/billing/transactions?type=refund');
$resp->assertOk();
expect($resp->json('meta.total'))->toBe(2);
});
test('GET /api/billing/transactions?type=migration — фильтр работает (новый тип из Spec A)', function () {
BalanceTransaction::factory()->create(['tenant_id' => $this->tenant->id, 'type' => 'migration']);
BalanceTransaction::factory()->create(['tenant_id' => $this->tenant->id, 'type' => 'topup']);
$resp = $this->getJson('/api/billing/transactions?type=migration');
$resp->assertOk();
expect($resp->json('meta.total'))->toBe(1);
expect($resp->json('data.0.type'))->toBe('migration');
});
test('GET /api/billing/transactions: display_amount_rub = "0.00" для исторических prepaid lead_charge', function () {
// Historic prepaid: type=lead_charge, amount_rub='0.00' (deduction was в leads, не в rub)
BalanceTransaction::create([
'tenant_id' => $this->tenant->id,
'type' => 'lead_charge',
'amount_rub' => '0.00',
'amount_leads' => -1,
'balance_rub_after' => '14250.00',
'balance_leads_after' => 284,
]);
$resp = $this->getJson('/api/billing/transactions');
$resp->assertOk();
expect($resp->json('data.0.display_amount_rub'))->toBe('0.00');
expect($resp->json('data.0.amount_rub'))->toBe('0.00');
});
test('GET /api/billing/transactions: display_amount_rub = amount_rub для новых rub-списаний', function () {
BalanceTransaction::create([
'tenant_id' => $this->tenant->id,
'type' => 'lead_charge',
'amount_rub' => '-500.00',
'amount_leads' => null,
'balance_rub_after' => '13750.00',
]);
$resp = $this->getJson('/api/billing/transactions');
$resp->assertOk();
expect($resp->json('data.0.display_amount_rub'))->toBe('-500.00');
});
// ---- invoices ----
test('GET /api/billing/invoices возвращает пустой список без счетов', function () {
+27 -32
View File
@@ -42,33 +42,7 @@ function makeDealForTenant(Tenant $tenant): Deal
]);
}
it('charges prepaid when balance_leads >= 1 (price snapshot = 0, tier_no snapshot from delivered_in_month + 1)', function () {
$tenant = makeTenantWith(balanceLeads: 5, balanceRub: '0.00', deliveredInMonth: 0);
$deal = makeDealForTenant($tenant);
$result = DB::transaction(function () use ($tenant, $deal) {
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
$locked = Tenant::whereKey($tenant->id)->lockForUpdate()->firstOrFail();
return $this->ledger->chargeForDelivery($locked, $deal);
});
expect($result->source)->toBe('prepaid');
expect($result->priceKopecks)->toBe(0);
expect($result->tier->tier_no)->toBe(1);
$tenant->refresh();
expect((int) $tenant->balance_leads)->toBe(4);
expect((string) $tenant->balance_rub)->toBe('0.00');
expect($tenant->delivered_in_month)->toBe(1);
$charge = LeadCharge::first();
expect($charge->charge_source)->toBe('prepaid');
expect($charge->price_per_lead_kopecks)->toBe(0);
expect($charge->tier_no)->toBe(1);
});
it('charges rub when balance_leads = 0 and balance_rub >= price', function () {
it('charges rub when balance_rub >= price', function () {
$tenant = makeTenantWith(balanceLeads: 0, balanceRub: '1000.00', deliveredInMonth: 0);
$deal = makeDealForTenant($tenant);
@@ -79,7 +53,6 @@ it('charges rub when balance_leads = 0 and balance_rub >= price', function () {
return $this->ledger->chargeForDelivery($locked, $deal);
});
expect($result->source)->toBe('rub');
expect($result->priceKopecks)->toBe(50000);
$tenant->refresh();
@@ -92,7 +65,7 @@ it('charges rub when balance_leads = 0 and balance_rub >= price', function () {
expect($charge->price_per_lead_kopecks)->toBe(50000);
});
it('throws InsufficientBalanceException when both sources empty', function () {
it('throws InsufficientBalanceException when balance_rub * 100 < priceKopecks', function () {
$tenant = makeTenantWith(balanceLeads: 0, balanceRub: '400.00', deliveredInMonth: 0);
$deal = makeDealForTenant($tenant);
@@ -122,12 +95,34 @@ it('charges rub at exact balance == price boundary', function () {
return $this->ledger->chargeForDelivery($locked, $deal);
});
expect($result->source)->toBe('rub');
$tenant->refresh();
expect((string) $tenant->balance_rub)->toBe('0.00');
});
it('always uses charge_source=rub regardless of historic balance_leads value', function () {
// Historic prepaid leftover — must be ignored by new always-rub flow.
$tenant = makeTenantWith(balanceLeads: 5, balanceRub: '1000.00', deliveredInMonth: 0);
$deal = makeDealForTenant($tenant);
DB::transaction(function () use ($tenant, $deal) {
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
$locked = Tenant::whereKey($tenant->id)->lockForUpdate()->firstOrFail();
$this->ledger->chargeForDelivery($locked, $deal);
});
$tenant->refresh();
// balance_leads must remain UNCHANGED (we never touch this column anymore).
expect((int) $tenant->balance_leads)->toBe(5);
// balance_rub must be debited by tier 1 price (500₽ for delivered_in_month=0 → tier 1).
expect((string) $tenant->balance_rub)->toBe('500.00');
expect($tenant->delivered_in_month)->toBe(1);
$charge = LeadCharge::first();
expect($charge->charge_source)->toBe('rub');
expect($charge->price_per_lead_kopecks)->toBe(50000);
});
it('crosses tier boundary: delivered_in_month=99 → tier 1; delivered_in_month=100 → tier 2', function () {
$tenantA = makeTenantWith(balanceLeads: 0, balanceRub: '10000.00', deliveredInMonth: 99);
$tenantB = makeTenantWith(balanceLeads: 0, balanceRub: '10000.00', deliveredInMonth: 100);
@@ -153,7 +148,7 @@ it('crosses tier boundary: delivered_in_month=99 → tier 1; delivered_in_month=
});
it('writes supplier_lead_costs (gap-fix: Plan 2/3 не писали в sharing-flow)', function () {
$tenant = makeTenantWith(balanceLeads: 5, balanceRub: '0.00');
$tenant = makeTenantWith(balanceLeads: 0, balanceRub: '10000.00');
$supplier = Supplier::where('code', 'b1')->first();
$supplierProject = SupplierProject::factory()->create([
'platform' => 'B1',
@@ -2,6 +2,7 @@
declare(strict_types=1);
use App\Models\BalanceTransaction;
use App\Models\Deal;
use App\Models\LeadCharge;
use App\Models\Tenant;
@@ -109,3 +110,58 @@ it('POST /export streams CSV via StreamedResponse', function () {
$body = $response->streamedContent();
expect($body)->toContain('charged_at,deal_id,tier_no,charge_source,price_rub,balance_rub_after');
});
it('export заполняет balance_rub_after из balance_transactions JOIN', function () {
$deal = Deal::factory()->create([
'tenant_id' => $this->tenant->id,
'received_at' => now(),
]);
LeadCharge::factory()->create([
'tenant_id' => $this->tenant->id,
'deal_id' => $deal->id,
'deal_received_at' => $deal->received_at,
'tier_no' => 1,
'price_per_lead_kopecks' => 50000,
'charge_source' => 'rub',
'charged_at' => now(),
]);
BalanceTransaction::create([
'tenant_id' => $this->tenant->id,
'type' => BalanceTransaction::TYPE_LEAD_CHARGE,
'amount_rub' => '-500.00',
'amount_leads' => null,
'balance_rub_after' => '4500.00',
'related_type' => Deal::class,
'related_id' => $deal->id,
'created_at' => now(),
]);
$response = $this->postJson('/api/billing/charges/export');
$response->assertOk();
$csv = $response->streamedContent();
expect($csv)->toContain('4500.00');
});
test('TenantChargesController::export emits charged_at in ISO-8601 format (regression A.10 fix)', function () {
$deal = Deal::factory()->create([
'tenant_id' => $this->tenant->id,
'received_at' => now(),
]);
LeadCharge::create([
'tenant_id' => $this->tenant->id,
'deal_id' => $deal->id,
'deal_received_at' => $deal->received_at,
'tier_no' => 1,
'price_per_lead_kopecks' => 50000,
'charge_source' => 'rub',
'charged_at' => now(),
]);
$body = $this->post('/api/billing/charges/export')->streamedContent();
// ISO-8601 marker: "T" between date and time, and trailing "+" or "Z" timezone.
expect($body)->toMatch('/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}([+-]\d{2}:\d{2}|Z)/');
});
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
use App\Models\BalanceTransaction;
use App\Models\PricingTier;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
uses(DatabaseTransactions::class);
beforeEach(function () {
// Custom tier 1 with effective_from in the past — overrides PricingTierSeeder's '1970-01-01' default.
PricingTier::query()->delete();
PricingTier::create([
'tier_no' => 1,
'leads_in_tier' => 50,
'price_per_lead_kopecks' => 12000,
'is_active' => true,
'effective_from' => now()->subDay()->toDateString(),
]);
});
it('migrates balance_leads to balance_rub at tier 1 price', function () {
$tenant = Tenant::factory()->create([
'balance_leads' => 5,
'balance_rub' => '100.00',
]);
$this->artisan('billing:migrate-leads-to-rub')->assertOk();
$tenant->refresh();
expect($tenant->balance_leads)->toBe(0);
expect((string) $tenant->balance_rub)->toBe('700.00'); // 100 + 5×120 = 700
$tx = BalanceTransaction::where('tenant_id', $tenant->id)
->where('type', BalanceTransaction::TYPE_MIGRATION)
->first();
expect($tx)->not->toBeNull();
expect((int) $tx->amount_leads)->toBe(-5);
expect((string) $tx->amount_rub)->toBe('600.00');
expect((string) $tx->balance_rub_after)->toBe('700.00');
});
it('is idempotent — second run is no-op', function () {
$tenant = Tenant::factory()->create([
'balance_leads' => 5,
'balance_rub' => '100.00',
]);
$this->artisan('billing:migrate-leads-to-rub')->assertOk();
$balanceAfterFirst = $tenant->fresh()->balance_rub;
$this->artisan('billing:migrate-leads-to-rub')->assertOk();
expect($tenant->fresh()->balance_rub)->toBe($balanceAfterFirst);
expect(BalanceTransaction::where('type', BalanceTransaction::TYPE_MIGRATION)->count())->toBe(1);
});
it('skips tenants with balance_leads = 0', function () {
Tenant::factory()->create([
'balance_leads' => 0,
'balance_rub' => '500.00',
]);
$this->artisan('billing:migrate-leads-to-rub')->assertOk();
expect(BalanceTransaction::where('type', BalanceTransaction::TYPE_MIGRATION)->count())->toBe(0);
});
it('aborts if no active tier 1 configured', function () {
PricingTier::query()->update(['is_active' => false]);
Tenant::factory()->create(['balance_leads' => 5]);
$this->artisan('billing:migrate-leads-to-rub')->assertFailed();
});
@@ -6,8 +6,11 @@ use App\Services\MonthlyPartitionManager;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
// ensureMonth/dropPartition теперь идут через pgsql_supplier — нужен shared PDO,
// иначе CREATE/DROP уйдут мимо test-транзакции (см. MonthlyPartitionManager::DDL_CONNECTION).
// ---------------------------------------------------------------------------
// Guard: check whether auth_log is partitioned. Tests in this file require
@@ -69,7 +69,6 @@ function ensureTenant(int $seed): int
'organization_name' => "Test Chain {$seed}",
'subdomain' => "test-chain-{$seed}",
'contact_email' => "chain{$seed}@example.com",
'webhook_token' => bin2hex(random_bytes(16))."-seed{$seed}",
'status' => 'active',
'created_at' => now(),
'updated_at' => now(),
@@ -1,109 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\SystemSetting;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\RateLimiter;
uses(DatabaseTransactions::class);
beforeEach(function () {
SystemSetting::query()->where('key', 'supplier_webhook_secret')->update(['value' => 'test-secret-32chars-aaaaaaaaaaaaaa']);
SystemSetting::query()->where('key', 'supplier_ip_allowlist')->update(['value' => '[]']);
// Clear rate limiter between tests
RateLimiter::clear('supplier-webhook:127.0.0.1');
});
it('logs status=received when lead is accepted (202)', function () {
Bus::fake();
$this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
'vid' => 900001,
'project' => 'B1_log-test.ru',
'phone' => '79991234567',
'time' => time(),
])->assertStatus(202);
$log = DB::table('webhook_log')
->where('status', 'received')
->where('source', 'supplier')
->latest('created_at')
->first();
expect($log)->not->toBeNull();
expect($log->status)->toBe('received');
expect($log->source)->toBe('supplier');
expect($log->tenant_id)->toBeNull();
});
it('logs status=rejected_secret when secret is wrong (404)', function () {
$this->postJson('/api/webhook/supplier/wrong-secret-here', [
'vid' => 900002,
'project' => 'B1_log-test.ru',
'phone' => '79991234567',
'time' => time(),
])->assertStatus(404);
$log = DB::table('webhook_log')
->where('status', 'rejected_secret')
->where('source', 'supplier')
->latest('created_at')
->first();
expect($log)->not->toBeNull();
expect($log->status)->toBe('rejected_secret');
expect($log->tenant_id)->toBeNull();
});
it('logs status=rejected_ip when IP is not in allowlist (404)', function () {
SystemSetting::query()->where('key', 'supplier_ip_allowlist')
->update(['value' => '["1.2.3.4"]']);
$this->withServerVariables(['REMOTE_ADDR' => '5.6.7.8'])
->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
'vid' => 900003,
'project' => 'B1_log-test.ru',
'phone' => '79991234567',
'time' => time(),
])->assertStatus(404);
$log = DB::table('webhook_log')
->where('status', 'rejected_ip')
->where('source', 'supplier')
->latest('created_at')
->first();
expect($log)->not->toBeNull();
expect($log->status)->toBe('rejected_ip');
expect($log->tenant_id)->toBeNull();
});
it('logs status=rate_limited when per-IP rate limit exceeded (429)', function () {
Bus::fake();
// Saturate the rate limiter
$key = 'supplier-webhook:127.0.0.1';
$limit = 600; // RATE_LIMIT_PER_MINUTE constant
for ($i = 0; $i < $limit; $i++) {
RateLimiter::hit($key, 60);
}
$this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
'vid' => 900004,
'project' => 'B1_log-test.ru',
'phone' => '79991234567',
'time' => time(),
])->assertStatus(429);
$log = DB::table('webhook_log')
->where('status', 'rate_limited')
->where('source', 'supplier')
->latest('created_at')
->first();
expect($log)->not->toBeNull();
expect($log->status)->toBe('rate_limited');
expect($log->tenant_id)->toBeNull();
});
@@ -12,8 +12,12 @@ use App\Services\Import\CsvLeadsParser;
use App\Services\Import\HistoricalImportService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
// HistoricalImportService::importBatch вызывает MonthlyPartitionManager::ensureRange,
// которая делает CREATE через pgsql_supplier — нужен shared PDO, иначе DDL уйдёт
// мимо test-транзакции.
beforeEach(function (): void {
$this->tenant = Tenant::factory()->create(['balance_leads' => 5]);
@@ -14,8 +14,11 @@ use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
// ImportLeadsJob запускает HistoricalImportService → MonthlyPartitionManager →
// CREATE через pgsql_supplier. Нужен shared PDO.
beforeEach(function (): void {
$this->tenant = Tenant::factory()->create();
@@ -6,8 +6,11 @@ use App\Services\MonthlyPartitionManager;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
// ensureMonth теперь делает CREATE через pgsql_supplier (см. MonthlyPartitionManager::DDL_CONNECTION).
// Без SharesSupplierPdo DDL уйдёт мимо test-транзакции и партиции протечь в test DB.
function partitionExists(string $name): bool
{
@@ -34,7 +34,7 @@ it('end-to-end: 1 webhook → 3 deal copies for 3 active tenants', function ():
$tenants = collect();
$projects = collect();
for ($i = 0; $i < 3; $i++) {
$t = Tenant::factory()->create(['balance_leads' => 100]);
$t = Tenant::factory()->create(['balance_leads' => 0, 'balance_rub' => '1000.00']);
$tenants->push($t);
$project = Project::factory()->create([
'tenant_id' => $t->id,
@@ -58,7 +58,7 @@ it('end-to-end: 1 webhook → 3 deal copies for 3 active tenants', function ():
// 4-й tenant — paused (is_active=false). Связь в pivot есть, чтобы проверялся
// именно фильтр is_active, а не отсутствие связи.
$pausedTenant = Tenant::factory()->create(['balance_leads' => 100]);
$pausedTenant = Tenant::factory()->create(['balance_leads' => 0, 'balance_rub' => '1000.00']);
$pausedProject = Project::factory()->create([
'tenant_id' => $pausedTenant->id,
'supplier_b1_project_id' => $supplier->id,
@@ -95,7 +95,7 @@ it('end-to-end: 1 webhook → 3 deal copies for 3 active tenants', function ():
expect($deal->project_id)->toBe($project->id);
expect($deal->duplicate_of_id)->toBeNull();
expect($tenant->fresh()->balance_leads)->toBe(99);
expect((string) $tenant->fresh()->balance_rub)->toBe('500.00');
expect($project->fresh()->delivered_today)->toBe(1);
expect($project->fresh()->delivered_in_month)->toBe(1);
}
@@ -9,7 +9,6 @@ use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\Billing\LedgerService;
use App\Services\DuplicateDetector;
use App\Services\LeadDistributor;
use App\Services\LeadRouter;
use App\Services\NotificationService;
@@ -38,7 +37,6 @@ function runRouteJob(int $supplierLeadId): void
(new RouteSupplierLeadJob($supplierLeadId))->handle(
app(LeadRouter::class),
app(SupplierProjectResolver::class),
app(DuplicateDetector::class),
app(NotificationService::class),
app(LedgerService::class),
app(LeadDistributor::class),
@@ -56,11 +54,13 @@ it('is terminal (does not throw / re-queue) when the supplier lead does not exis
$missingId = 999999;
expect(SupplierLead::find($missingId))->toBeNull();
$countBefore = DB::table('deals')->count();
// Не должно бросать исключение (иначе сработает failed() -> retry-цикл).
runRouteJob($missingId);
// Никаких побочных эффектов.
expect(Deal::count())->toBe(0);
// Никаких побочных эффектов — количество сделок не изменилось.
expect(DB::table('deals')->count())->toBe($countBefore);
});
it('routes 1 lead to N tenants — creates N deal copies (sharing-model)', function (): void {
@@ -73,7 +73,7 @@ it('routes 1 lead to N tenants — creates N deal copies (sharing-model)', funct
$tenants = collect();
$projects = collect();
for ($i = 0; $i < 3; $i++) {
$t = Tenant::factory()->create(['balance_leads' => 100]);
$t = Tenant::factory()->create(['balance_rub' => '100000.00']);
$tenants->push($t);
$projects->push(Project::factory()->create([
'tenant_id' => $t->id,
@@ -125,13 +125,13 @@ it('routes 1 lead to N tenants — creates N deal copies (sharing-model)', funct
}
});
it('decrements balance_leads for each tenant by 1', function (): void {
it('charges balance_rub for tenant after routing', function (): void {
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => 'test.ru',
]);
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'supplier_b1_project_id' => $supplier->id,
@@ -152,55 +152,7 @@ it('decrements balance_leads for each tenant by 1', function (): void {
runRouteJob($lead->id);
expect($tenant->fresh()->balance_leads)->toBe(99);
});
it('marks duplicate via DuplicateDetector — no charge, no counter increment', function (): void {
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => 'test.ru',
]);
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'supplier_b1_project_id' => $supplier->id,
'signal_type' => 'site',
'signal_identifier' => 'test.ru',
'is_active' => true,
'delivered_today' => 0,
]);
linkProjectToSupplier($project, $supplier);
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
$master = Deal::create([
'tenant_id' => $tenant->id,
'source_crm_id' => 999,
'project_id' => $project->id,
'phone' => '79991234567',
'phones' => ['79991234567'],
'status' => 'new',
'received_at' => now()->subHours(2),
]);
$vid = 1000;
$lead = SupplierLead::factory()->create([
'supplier_project_id' => null,
'platform' => 'B1',
'vid' => $vid,
'phone' => '79991234567',
'raw_payload' => ['vid' => $vid, 'project' => 'B1_test.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()],
]);
runRouteJob($lead->id);
expect($tenant->fresh()->balance_leads)->toBe(100);
expect($project->fresh()->delivered_today)->toBe(0);
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
$duplicate = Deal::where('source_crm_id', $vid)->first();
expect($duplicate)->not->toBeNull();
expect($duplicate->duplicate_of_id)->toBe($master->id);
expect((string) $tenant->fresh()->balance_rub)->toBe('99500.00');
});
it('throws DomainException when payload encodes B1+SMS combo', function (): void {
@@ -234,7 +186,7 @@ it('handles orphan supplier_project (no matching liderra-projects) — 0 deals,
expect($lead->supplier_project_id)->not->toBeNull();
});
it('handles mixed routing: 3 projects, 1 with pre-existing master (dup), 2 clean', function (): void {
it('same phone pre-existing does not suppress new delivery (Spec B)', function (): void {
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
@@ -244,7 +196,7 @@ it('handles mixed routing: 3 projects, 1 with pre-existing master (dup), 2 clean
$tenants = collect();
$projects = collect();
for ($i = 0; $i < 3; $i++) {
$t = Tenant::factory()->create(['balance_leads' => 100]);
$t = Tenant::factory()->create(['balance_rub' => '100000.00']);
$tenants->push($t);
$projects->push(Project::factory()->create([
'tenant_id' => $t->id,
@@ -258,14 +210,14 @@ it('handles mixed routing: 3 projects, 1 with pre-existing master (dup), 2 clean
linkProjectToSupplier($projects->last(), $supplier);
}
// Tenant #0 имеет master deal с тем же phone в окне 24 ч — будет дубль.
$masterTenant = $tenants[0];
$masterProject = $projects[0];
DB::statement("SET LOCAL app.current_tenant_id = '{$masterTenant->id}'");
$master = Deal::create([
'tenant_id' => $masterTenant->id,
// Tenant #0 имеет pre-existing deal с тем же phone — под новым правилом НЕ подавляет.
$firstTenant = $tenants[0];
$firstProject = $projects[0];
DB::statement("SET LOCAL app.current_tenant_id = '{$firstTenant->id}'");
Deal::create([
'tenant_id' => $firstTenant->id,
'source_crm_id' => 555,
'project_id' => $masterProject->id,
'project_id' => $firstProject->id,
'phone' => '79991234567',
'phones' => ['79991234567'],
'status' => 'new',
@@ -285,22 +237,19 @@ it('handles mixed routing: 3 projects, 1 with pre-existing master (dup), 2 clean
$lead->refresh();
expect($lead->processed_at)->not->toBeNull();
expect($lead->deals_created_count)->toBe(2); // 2 чистых, 1 дубль не считается
// Spec B: pre-existing master does NOT suppress — all 3 charged.
expect($lead->deals_created_count)->toBe(3);
// Tenant #0: deal помечен duplicate_of_id, balance НЕ списан, delivered_today = 0
expect($masterTenant->fresh()->balance_leads)->toBe(100);
expect($masterProject->fresh()->delivered_today)->toBe(0);
DB::statement("SET LOCAL app.current_tenant_id = '{$masterTenant->id}'");
$dupDeal = Deal::query()->where('source_crm_id', $vid)->first();
expect($dupDeal->duplicate_of_id)->toBe($master->id);
// Tenant #1, #2: balance списан, delivered_today инкрементирован
foreach ([1, 2] as $i) {
// All 3 tenants: balance decremented, delivered_today incremented.
foreach (range(0, 2) as $i) {
$t = $tenants[$i];
$p = $projects[$i];
expect($t->fresh()->balance_leads)->toBe(99);
expect((string) $t->fresh()->balance_rub)->toBe('99500.00');
expect($p->fresh()->delivered_today)->toBe(1);
}
// 3 deal rows exist for this vid (one per tenant).
expect(Deal::query()->where('source_crm_id', $vid)->count())->toBe(3);
});
it('idempotent on retry — second handle() returns early, no ghost duplicate deals (Plan 2.5 fix #3)', function (): void {
@@ -317,7 +266,7 @@ it('idempotent on retry — second handle() returns early, no ghost duplicate de
'signal_type' => 'site',
'unique_key' => 'retry-idempotent.ru',
]);
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'supplier_b1_project_id' => $supplier->id,
@@ -347,7 +296,7 @@ it('idempotent on retry — second handle() returns early, no ghost duplicate de
$lead->refresh();
expect($lead->processed_at)->not->toBeNull();
expect($lead->deals_created_count)->toBe(1);
expect($tenant->fresh()->balance_leads)->toBe(99);
expect((string) $tenant->fresh()->balance_rub)->toBe('99500.00');
expect($project->fresh()->delivered_today)->toBe(1);
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
@@ -362,7 +311,7 @@ it('idempotent on retry — second handle() returns early, no ghost duplicate de
expect($lead->deals_created_count)->toBe(1);
// НИКАКИХ дублей не появилось: balance, counter, deal-row.
expect($tenant->fresh()->balance_leads)->toBe(99);
expect((string) $tenant->fresh()->balance_rub)->toBe('99500.00');
expect($project->fresh()->delivered_today)->toBe(1);
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
@@ -385,7 +334,7 @@ it('handles partial failure: one project throws, others continue routing', funct
$tenants = collect();
$projects = collect();
for ($i = 0; $i < 3; $i++) {
$t = Tenant::factory()->create(['balance_leads' => 100]);
$t = Tenant::factory()->create(['balance_rub' => '100000.00']);
$tenants->push($t);
$projects->push(Project::factory()->create([
'tenant_id' => $t->id,
@@ -417,8 +366,8 @@ it('handles partial failure: one project throws, others continue routing', funct
expect($lead->deals_created_count)->toBe(2); // tenant 0 + 2; tenant 1 упал
// Tenants 0 и 2 успешно списаны
expect($tenants[0]->fresh()->balance_leads)->toBe(99);
expect($tenants[2]->fresh()->balance_leads)->toBe(99);
expect((string) $tenants[0]->fresh()->balance_rub)->toBe('99500.00');
expect((string) $tenants[2]->fresh()->balance_rub)->toBe('99500.00');
});
it('routes B1 lead whose project name embeds a domain in free text (carmoney/caranga/krk)', function (string $projectField, string $domain): void {
@@ -431,7 +380,7 @@ it('routes B1 lead whose project name embeds a domain in free text (carmoney/car
'signal_type' => 'site',
'unique_key' => $domain,
]);
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'supplier_b1_project_id' => $supplier->id,
@@ -486,7 +435,7 @@ it('rejects deal copy if delivered_today >= limit at lock time (Plan 2.5 fix #2
'signal_type' => 'site',
'unique_key' => 'race-recheck.ru',
]);
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'supplier_b1_project_id' => $supplier->id,
@@ -532,8 +481,8 @@ it('rejects deal copy if delivered_today >= limit at lock time (Plan 2.5 fix #2
// delivered_in_month НЕ инкрементнулся.
expect($project->fresh()->delivered_in_month)->toBe(5);
// balance_leads НЕ списан.
expect($tenant->fresh()->balance_leads)->toBe(100);
// balance_rub НЕ списан.
expect((string) $tenant->fresh()->balance_rub)->toBe('100000.00');
// Deal-row не создался.
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
@@ -553,7 +502,7 @@ it('caps deal creation at 3 recipients and tags deal with subject from payload',
// 5 eligible клиентов, привязанных к sp через pivot, с балансом и лимитом
foreach (range(1, 5) as $i) {
$t = Tenant::factory()->create(['balance_leads' => 100]);
$t = Tenant::factory()->create(['balance_rub' => '100000.00']);
$p = Project::factory()->create([
'tenant_id' => $t->id, 'is_active' => true,
'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127,
@@ -573,7 +522,6 @@ it('caps deal creation at 3 recipients and tags deal with subject from payload',
(new RouteSupplierLeadJob($lead->id))->handle(
app(LeadRouter::class),
app(SupplierProjectResolver::class),
app(DuplicateDetector::class),
app(NotificationService::class),
app(LedgerService::class),
app(LeadDistributor::class),
@@ -2,19 +2,13 @@
declare(strict_types=1);
use App\Jobs\ProcessWebhookJob;
use App\Mail\InvoicePaidNotification;
use App\Mail\LowBalanceNotification;
use App\Mail\TopupSuccessNotification;
use App\Mail\ZeroBalanceNotification;
use App\Models\InAppNotification;
use App\Models\RejectedDealsLog;
use App\Models\Tenant;
use App\Models\User;
use App\Services\NotificationService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
uses(DatabaseTransactions::class);
@@ -23,18 +17,6 @@ beforeEach(function () {
Mail::fake();
});
function balancePayload(int $vid = 500): array
{
return [
'vid' => $vid,
'project' => 'B2_Caranga',
'tag' => 'Caranga',
'phone' => '79000000'.$vid,
'phones' => ['79000000'.$vid],
'time' => time(),
];
}
function makeUserForBalance(Tenant $tenant, string $email, array $events = []): User
{
return User::factory()->create([
@@ -53,102 +35,6 @@ function makeUserForBalance(Tenant $tenant, string $email, array $events = []):
]);
}
// ============== low_balance ==============
test('low_balance: при пересечении порога сверху-вниз → email + inapp', function () {
// Default threshold: 10 (system_settings seeded). Установим balance=11.
$tenant = Tenant::factory()->create(['balance_leads' => 11]);
makeUserForBalance($tenant, 'on@example.ru');
(new ProcessWebhookJob($tenant->id, balancePayload()))->handle();
$tenant->refresh();
expect($tenant->balance_leads)->toBe(10); // 11 → 10 (пересекли порог)
Mail::assertSent(LowBalanceNotification::class, 1);
expect(InAppNotification::query()->where('event', 'low_balance')->count())->toBe(1);
});
test('low_balance: balance уже < threshold — НЕ шлёт повторно', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 5]);
makeUserForBalance($tenant, 'on@example.ru');
(new ProcessWebhookJob($tenant->id, balancePayload()))->handle();
$tenant->refresh();
expect($tenant->balance_leads)->toBe(4); // 5 → 4 (всё ещё < threshold=10)
// Не пересекали порог — НЕ шлём.
Mail::assertNothingSent();
expect(InAppNotification::query()->where('event', 'low_balance')->count())->toBe(0);
});
test('low_balance: balance > threshold после decrement — НЕ шлёт', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 50]);
makeUserForBalance($tenant, 'on@example.ru');
(new ProcessWebhookJob($tenant->id, balancePayload()))->handle();
$tenant->refresh();
expect($tenant->balance_leads)->toBe(49);
Mail::assertNothingSent();
});
test('low_balance: prefs.low_balance.email=false — только inapp', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 11]);
makeUserForBalance($tenant, 'on@example.ru', [
'low_balance' => ['email' => false, 'inapp' => true],
]);
(new ProcessWebhookJob($tenant->id, balancePayload()))->handle();
Mail::assertNothingSent();
expect(InAppNotification::query()->where('event', 'low_balance')->count())->toBe(1);
});
// ============== zero_balance ==============
test('zero_balance: первое отклонение → email + inapp', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 0]);
makeUserForBalance($tenant, 'on@example.ru');
(new ProcessWebhookJob($tenant->id, balancePayload()))->handle();
Mail::assertSent(ZeroBalanceNotification::class, 1);
expect(InAppNotification::query()->where('event', 'zero_balance')->count())->toBe(1);
expect(RejectedDealsLog::query()->where('tenant_id', $tenant->id)->count())->toBe(1);
});
test('zero_balance: 2-е отклонение в течение часа — НЕ дублирует email', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 0]);
makeUserForBalance($tenant, 'on@example.ru');
(new ProcessWebhookJob($tenant->id, balancePayload(vid: 1)))->handle();
Mail::assertSent(ZeroBalanceNotification::class, 1);
(new ProcessWebhookJob($tenant->id, balancePayload(vid: 2)))->handle();
Mail::assertSent(ZeroBalanceNotification::class, 1); // всё ещё один
expect(RejectedDealsLog::query()->where('tenant_id', $tenant->id)->count())->toBe(2);
});
test('zero_balance: отклонение через >1ч — снова шлёт', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 0]);
makeUserForBalance($tenant, 'on@example.ru');
// Создаём старый RejectedDealsLog (>1ч назад) — он не должен суппрессить.
DB::table('rejected_deals_log')->insert([
'tenant_id' => $tenant->id,
'reason' => RejectedDealsLog::REASON_ZERO_BALANCE,
'payload' => json_encode(['vid' => 999]),
'created_at' => Carbon::now()->subHours(2),
]);
(new ProcessWebhookJob($tenant->id, balancePayload()))->handle();
Mail::assertSent(ZeroBalanceNotification::class, 1);
});
// ============== topup_success ==============
test('topup_success: notifyTopupSuccess создаёт email + inapp', function () {
@@ -204,18 +90,3 @@ test('invoice_paid: prefs=email:false — только inapp', function () {
Mail::assertNothingSent();
expect(InAppNotification::query()->where('event', 'invoice_paid')->count())->toBe(1);
});
// ============== isolation ==============
test('balance events изолированы между тенантами', function () {
$tenantA = Tenant::factory()->create(['balance_leads' => 11]);
$tenantB = Tenant::factory()->create(['balance_leads' => 11]);
$userA = makeUserForBalance($tenantA, 'a@example.ru');
makeUserForBalance($tenantB, 'b@example.ru');
(new ProcessWebhookJob($tenantA->id, balancePayload()))->handle();
Mail::assertSent(LowBalanceNotification::class, 1);
Mail::assertSent(fn (LowBalanceNotification $m) => $m->hasTo($userA->email));
Mail::assertNotSent(fn (LowBalanceNotification $m) => $m->hasTo('b@example.ru'));
});
@@ -2,8 +2,6 @@
declare(strict_types=1);
use App\Jobs\ProcessWebhookJob;
use App\Mail\NewLeadNotification;
use App\Models\InAppNotification;
use App\Models\Tenant;
use App\Models\User;
@@ -14,12 +12,8 @@ use Illuminate\Support\Facades\Mail;
/**
* Тесты in-app канала уведомлений (schema v8.10 in_app_notifications).
*
* Канал inapp в матрице users.notification_preferences. INSERT row при
* триггере события (new_lead/...). UI читает unread-count и список
* последних 50 (этап 2b отдельный коммит).
*
* Schema-default: notification_preferences.new_lead.inapp=true в отличие
* от email, большинство user'ов получает in-app по умолчанию.
* Тесты через ProcessWebhookJob удалены job убран как legacy-рудимент.
* Оставлен прямой вызов NotificationService::notifyInApp.
*/
uses(DatabaseTransactions::class);
@@ -27,164 +21,6 @@ beforeEach(function () {
Mail::fake();
});
/**
* @return array<string, mixed>
*/
function inAppPayload(int $vid = 300, ?int $time = null): array
{
return [
'vid' => $vid,
'project' => 'B2_Caranga',
'tag' => 'Caranga',
'phone' => '79001234567',
'phones' => ['79001234567'],
'time' => $time ?? time(),
];
}
/**
* @param array<string, mixed> $newLeadPrefs
*/
function makeUserWithInAppPrefs(Tenant $tenant, string $email, array $newLeadPrefs): User
{
return User::factory()->create([
'tenant_id' => $tenant->id,
'email' => $email,
'notification_preferences' => [
'new_lead' => $newLeadPrefs,
'reminder' => ['inapp' => true, 'push' => true, 'email' => true],
'low_balance' => ['email' => true],
'zero_balance' => ['email' => true],
'topup_success' => ['email' => true],
'invoice_paid' => ['email' => true],
'new_device_login' => ['email' => true],
'marketing' => ['email' => false],
],
]);
}
test('webhook: in_app_notification создаётся для user с inapp=true', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
$user = makeUserWithInAppPrefs($tenant, 'on@example.ru', ['inapp' => true, 'email' => false]);
(new ProcessWebhookJob($tenant->id, inAppPayload()))->handle();
expect(InAppNotification::query()->count())->toBe(1);
$notif = InAppNotification::query()->first();
expect($notif->user_id)->toBe($user->id);
expect($notif->tenant_id)->toBe($tenant->id);
expect($notif->event)->toBe('new_lead');
expect($notif->title)->toContain('Caranga');
expect($notif->body)->toBe('79001234567'); // phone (no contact_name)
expect($notif->read_at)->toBeNull();
expect($notif->payload['project_name'])->toBe('Caranga');
});
test('webhook: user с inapp=false НЕ получает in-app row', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
makeUserWithInAppPrefs($tenant, 'off@example.ru', ['inapp' => false, 'email' => false]);
(new ProcessWebhookJob($tenant->id, inAppPayload()))->handle();
expect(InAppNotification::query()->count())->toBe(0);
});
test('webhook: schema-default (inapp=true) ставит row', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
// Без override prefs — берётся schema DEFAULT (new_lead.inapp=true).
User::factory()->create([
'tenant_id' => $tenant->id,
'email' => 'default@example.ru',
]);
(new ProcessWebhookJob($tenant->id, inAppPayload()))->handle();
expect(InAppNotification::query()->count())->toBe(1);
});
test('webhook: 2 user\'а с inapp=true получают по 1 row, 1 user с inapp=false — нет', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
$a = makeUserWithInAppPrefs($tenant, 'a@example.ru', ['inapp' => true]);
$b = makeUserWithInAppPrefs($tenant, 'b@example.ru', ['inapp' => true]);
makeUserWithInAppPrefs($tenant, 'c@example.ru', ['inapp' => false]);
(new ProcessWebhookJob($tenant->id, inAppPayload()))->handle();
expect(InAppNotification::query()->count())->toBe(2);
expect(InAppNotification::query()->where('user_id', $a->id)->exists())->toBeTrue();
expect(InAppNotification::query()->where('user_id', $b->id)->exists())->toBeTrue();
});
test('webhook: inactive user НЕ получает in-app', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
User::factory()->inactive()->create([
'tenant_id' => $tenant->id,
'email' => 'inactive@example.ru',
'notification_preferences' => ['new_lead' => ['inapp' => true]],
]);
(new ProcessWebhookJob($tenant->id, inAppPayload()))->handle();
expect(InAppNotification::query()->count())->toBe(0);
});
test('webhook: user другого тенанта НЕ получает (RLS isolation)', function () {
$tenantA = Tenant::factory()->create(['balance_leads' => 10]);
$tenantB = Tenant::factory()->create(['balance_leads' => 10]);
$userA = makeUserWithInAppPrefs($tenantA, 'a@example.ru', ['inapp' => true]);
makeUserWithInAppPrefs($tenantB, 'b@example.ru', ['inapp' => true]);
(new ProcessWebhookJob($tenantA->id, inAppPayload()))->handle();
expect(InAppNotification::query()->count())->toBe(1);
expect(InAppNotification::query()->first()->user_id)->toBe($userA->id);
});
test('webhook: дубль (Биз-19) НЕ создаёт повторный in-app row', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
makeUserWithInAppPrefs($tenant, 'on@example.ru', ['inapp' => true]);
(new ProcessWebhookJob($tenant->id, inAppPayload(vid: 1)))->handle();
expect(InAppNotification::query()->count())->toBe(1);
// Второй webhook с тем же phone в окне 24ч → дубль, нет chargeNewLead → нет notify.
(new ProcessWebhookJob($tenant->id, inAppPayload(vid: 2)))->handle();
expect(InAppNotification::query()->count())->toBe(1);
});
test('webhook: повторный vid (UPDATE) НЕ создаёт повторный in-app row', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
makeUserWithInAppPrefs($tenant, 'on@example.ru', ['inapp' => true]);
(new ProcessWebhookJob($tenant->id, inAppPayload(vid: 100)))->handle();
expect(InAppNotification::query()->count())->toBe(1);
(new ProcessWebhookJob($tenant->id, inAppPayload(vid: 100)))->handle();
expect(InAppNotification::query()->count())->toBe(1);
});
test('webhook: оба канала (inapp+email=true) — 1 in-app row + 1 email', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
makeUserWithInAppPrefs($tenant, 'both@example.ru', ['inapp' => true, 'email' => true]);
(new ProcessWebhookJob($tenant->id, inAppPayload()))->handle();
expect(InAppNotification::query()->count())->toBe(1);
Mail::assertSent(NewLeadNotification::class, 1);
});
test('webhook: payload содержит deal_id для UI deep-link', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
makeUserWithInAppPrefs($tenant, 'on@example.ru', ['inapp' => true]);
(new ProcessWebhookJob($tenant->id, inAppPayload()))->handle();
$notif = InAppNotification::query()->first();
expect($notif->deal_id)->not->toBeNull();
expect($notif->payload)->toHaveKey('deal_id');
expect($notif->payload['deal_id'])->toBe($notif->deal_id);
});
test('NotificationService::notifyInApp: вызов напрямую создаёт row', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
@@ -1,199 +0,0 @@
<?php
declare(strict_types=1);
use App\Jobs\ProcessWebhookJob;
use App\Mail\NewLeadNotification;
use App\Models\Deal;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Mail;
/**
* Тесты email-уведомления о новом лиде (ТЗ §18.5, событие new_lead).
*
* Проверяет интеграцию NotificationService ProcessWebhookJob: после успешного
* chargeNewLead все активные user'ы тенанта с notification_preferences.new_lead.email=true
* получают NewLeadNotification. Mail::fake() перехватывает реальную отправку.
*
* Schema-default: notification_preferences.new_lead.email=false по умолчанию
* никто не получает emails. Тесты явно ставят email=true для нужных user'ов.
*/
uses(DatabaseTransactions::class);
beforeEach(function () {
Mail::fake();
});
/**
* @return array<string, mixed>
*/
function newLeadPayload(int $vid = 200, ?int $time = null): array
{
return [
'vid' => $vid,
'project' => 'B2_Caranga',
'tag' => 'Caranga',
'phone' => '79001234567',
'phones' => ['79001234567'],
'time' => $time ?? time(),
];
}
/**
* @param array<string, mixed> $newLeadPrefs
*/
function makeUserWithPrefs(Tenant $tenant, string $email, array $newLeadPrefs): User
{
return User::factory()->create([
'tenant_id' => $tenant->id,
'email' => $email,
'notification_preferences' => [
'new_lead' => $newLeadPrefs,
'reminder' => ['inapp' => true, 'push' => true, 'email' => true],
'low_balance' => ['email' => true],
'zero_balance' => ['email' => true],
'topup_success' => ['email' => true],
'invoice_paid' => ['email' => true],
'new_device_login' => ['email' => true],
'marketing' => ['email' => false],
],
]);
}
test('webhook: NewLeadNotification отправляется user\'ам с email=true', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
$userOn = makeUserWithPrefs($tenant, 'on@example.ru', ['inapp' => true, 'push' => true, 'email' => true]);
(new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle();
Mail::assertSent(NewLeadNotification::class, 1);
Mail::assertSent(NewLeadNotification::class, function (NewLeadNotification $mail) use ($userOn): bool {
return $mail->manager->id === $userOn->id
&& $mail->hasTo('on@example.ru');
});
});
test('webhook: user с email=false НЕ получает', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
makeUserWithPrefs($tenant, 'off@example.ru', ['inapp' => true, 'push' => true, 'email' => false]);
(new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle();
Mail::assertNothingSent();
});
test('webhook: schema-default не шлёт (new_lead.email=false по дефолту)', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
// Не передаём notification_preferences — берётся schema DEFAULT.
User::factory()->create([
'tenant_id' => $tenant->id,
'email' => 'default@example.ru',
]);
(new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle();
Mail::assertNothingSent();
});
test('webhook: рассылается всем активным user\'ам с email=true', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
makeUserWithPrefs($tenant, 'a@example.ru', ['email' => true]);
makeUserWithPrefs($tenant, 'b@example.ru', ['email' => true]);
makeUserWithPrefs($tenant, 'c@example.ru', ['email' => false]);
(new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle();
Mail::assertSent(NewLeadNotification::class, 2);
Mail::assertSent(fn (NewLeadNotification $mail) => $mail->hasTo('a@example.ru'));
Mail::assertSent(fn (NewLeadNotification $mail) => $mail->hasTo('b@example.ru'));
Mail::assertNotSent(fn (NewLeadNotification $mail) => $mail->hasTo('c@example.ru'));
});
test('webhook: inactive user с email=true НЕ получает (is_active=false)', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
User::factory()->inactive()->create([
'tenant_id' => $tenant->id,
'email' => 'inactive@example.ru',
'notification_preferences' => [
'new_lead' => ['email' => true],
],
]);
(new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle();
Mail::assertNothingSent();
});
test('webhook: soft-deleted user НЕ получает', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
$user = makeUserWithPrefs($tenant, 'deleted@example.ru', ['email' => true]);
$user->delete();
(new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle();
Mail::assertNothingSent();
});
test('webhook: user другого тенанта НЕ получает (изоляция)', function () {
$tenantA = Tenant::factory()->create(['balance_leads' => 10]);
$tenantB = Tenant::factory()->create(['balance_leads' => 10]);
makeUserWithPrefs($tenantA, 'a@example.ru', ['email' => true]);
makeUserWithPrefs($tenantB, 'b@example.ru', ['email' => true]);
(new ProcessWebhookJob($tenantA->id, newLeadPayload()))->handle();
Mail::assertSent(NewLeadNotification::class, 1);
Mail::assertSent(fn (NewLeadNotification $mail) => $mail->hasTo('a@example.ru'));
Mail::assertNotSent(fn (NewLeadNotification $mail) => $mail->hasTo('b@example.ru'));
});
test('webhook: дубль-сделка (Биз-19) НЕ шлёт повторное уведомление', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
makeUserWithPrefs($tenant, 'on@example.ru', ['email' => true]);
// Первая сделка — master.
(new ProcessWebhookJob($tenant->id, newLeadPayload(vid: 1)))->handle();
Mail::assertSent(NewLeadNotification::class, 1);
// Вторая сделка с тем же phone в окне 24 ч — дубль, баланс НЕ списывается,
// chargeNewLead НЕ вызывается, уведомление НЕ шлётся.
(new ProcessWebhookJob($tenant->id, newLeadPayload(vid: 2)))->handle();
Mail::assertSent(NewLeadNotification::class, 1); // всё ещё одно
});
test('webhook: повторный vid (idempotent UPDATE) НЕ шлёт повторное уведомление', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
makeUserWithPrefs($tenant, 'on@example.ru', ['email' => true]);
(new ProcessWebhookJob($tenant->id, newLeadPayload(vid: 100)))->handle();
Mail::assertSent(NewLeadNotification::class, 1);
// Повторный webhook с тем же vid — UPDATE, не INSERT. wasRecentlyCreated=false → return.
(new ProcessWebhookJob($tenant->id, newLeadPayload(vid: 100)))->handle();
Mail::assertSent(NewLeadNotification::class, 1);
});
test('webhook: balance=0 (RejectedDealsLog) НЕ шлёт NewLeadNotification', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 0]);
makeUserWithPrefs($tenant, 'on@example.ru', ['email' => true]);
(new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle();
// chargeNewLead НЕ вызывается при balance=0 — NewLeadNotification не шлётся.
// (ZeroBalanceNotification ШЛЁТСЯ — это покрывается отдельным тестом.)
Mail::assertNotSent(NewLeadNotification::class);
expect(Deal::query()->where('tenant_id', $tenant->id)->count())->toBe(0);
});
test('NewLeadNotification: subject содержит project_name', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
makeUserWithPrefs($tenant, 'on@example.ru', ['email' => true]);
(new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle();
Mail::assertSent(NewLeadNotification::class, function (NewLeadNotification $mail): bool {
return str_contains($mail->envelope()->subject, 'Caranga');
});
});
@@ -17,7 +17,7 @@ beforeEach(function () {
$this->partitionsBefore = collect(DB::select("
SELECT relname FROM pg_class
WHERE relkind = 'r'
AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|webhook_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$'
AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$'
"))->pluck('relname')->all();
});
@@ -25,7 +25,7 @@ afterEach(function () {
$partitionsAfter = collect(DB::select("
SELECT relname FROM pg_class
WHERE relkind = 'r'
AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|webhook_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$'
AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$'
"))->pluck('relname')->all();
// DETACH перед DROP: иначе `DROP TABLE ... CASCADE` сносит FK от
@@ -61,7 +61,7 @@ test('идемпотентность: повторный запуск не па
$afterFirst = collect(DB::select("
SELECT relname FROM pg_class
WHERE relkind = 'r'
AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|webhook_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$'
AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$'
"))->count();
// Повторный запуск — должен только skip'ать.
@@ -71,14 +71,15 @@ test('идемпотентность: повторный запуск не па
$afterSecond = collect(DB::select("
SELECT relname FROM pg_class
WHERE relkind = 'r'
AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|webhook_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$'
AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$'
"))->count();
expect($afterSecond)->toBe($afterFirst);
// Output второго запуска должен сказать «0 created» по всем 9 таблицам × 6 месяцев = 54 партиции.
// Output второго запуска должен сказать «0 created» по всем 8 таблицам × 6 месяцев = 48 партиций.
// (webhook_log удалён в миграции 2026_05_24_140000_drop_legacy_webhook_artefacts)
$output = Artisan::output();
expect($output)->toContain('0 created, 54 skipped');
expect($output)->toContain('0 created, 48 skipped');
});
test('--ahead=0 создаёт только текущий месяц', function () {
@@ -100,7 +101,6 @@ test('партиция корректно принимает INSERT в окно
'subdomain' => 'partition-test-'.uniqid(),
'organization_name' => 'PartitionTest',
'contact_email' => 'pt@test.local',
'webhook_token' => str_repeat('p', 64),
'api_key_limit' => 5,
]);
$projectId = DB::table('projects')->insertGetId([
+2 -39
View File
@@ -4,11 +4,9 @@ declare(strict_types=1);
/**
* 152-ФЗ: pd_processing_log 'created' записывается при создании сделки
* по всем трём путям ручной API, поставщик (RouteSupplierLeadJob),
* вебхук (ProcessWebhookJob).
* по двум живым путям ручной API, поставщик (RouteSupplierLeadJob).
*/
use App\Jobs\ProcessWebhookJob;
use App\Jobs\RouteSupplierLeadJob;
use App\Models\Deal;
use App\Models\Project;
@@ -17,7 +15,6 @@ use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Billing\LedgerService;
use App\Services\DuplicateDetector;
use App\Services\LeadDistributor;
use App\Services\LeadRouter;
use App\Services\NotificationService;
@@ -78,7 +75,7 @@ it('writes pd_processing_log created (supplier) when deal created via RouteSuppl
'signal_type' => 'site',
'unique_key' => 'pd-test.ru',
]);
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'supplier_b1_project_id' => $supplier->id,
@@ -107,7 +104,6 @@ it('writes pd_processing_log created (supplier) when deal created via RouteSuppl
(new RouteSupplierLeadJob($lead->id))->handle(
app(LeadRouter::class),
app(SupplierProjectResolver::class),
app(DuplicateDetector::class),
app(NotificationService::class),
app(LedgerService::class),
app(LeadDistributor::class),
@@ -130,36 +126,3 @@ it('writes pd_processing_log created (supplier) when deal created via RouteSuppl
expect($rows)->toBe(1);
});
// ──────────────────────────────────────────────────────────────────────────
// Path C: webhook via ProcessWebhookJob
// ──────────────────────────────────────────────────────────────────────────
it('writes pd_processing_log created (webhook) when deal created via ProcessWebhookJob', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
$vid = 55566;
(new ProcessWebhookJob($tenant->id, [
'vid' => $vid,
'project' => 'B2_PdWebhookTest',
'tag' => 'PdWebhookTest',
'phone' => '79001112233',
'phones' => ['79001112233'],
'time' => time(),
]))->handle();
$deal = Deal::query()->where('tenant_id', $tenant->id)->where('source_crm_id', $vid)->first();
expect($deal)->not->toBeNull();
$rows = DB::table('pd_processing_log')
->where('action', 'created')
->where('purpose', 'lead_create_webhook')
->where('subject_type', 'lead')
->where('subject_id', $deal->id)
->where('tenant_id', $tenant->id)
->whereNull('actor_tenant_user_id')
->whereNull('actor_admin_user_id')
->count();
expect($rows)->toBe(1);
});
+4 -1
View File
@@ -14,8 +14,11 @@ use App\Services\Import\CsvLeadsParser;
use App\Services\Import\HistoricalImportService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
// HistoricalImportService → MonthlyPartitionManager → CREATE через pgsql_supplier
// (см. MonthlyPartitionManager::DDL_CONNECTION). Нужен shared PDO.
it('writes pd_processing_log created on historical import for each new deal', function () {
$tenant = Tenant::factory()->create();
@@ -19,8 +19,8 @@ it('tenants table has delivered_in_month column with CHECK >= 0', function () {
expect(Schema::hasColumn('tenants', 'delivered_in_month'))->toBeTrue();
DB::table('tenants')->where('id', '<', 0)->update(['delivered_in_month' => 5]); // no-op
expect(fn () => DB::statement(
'INSERT INTO tenants (subdomain, organization_name, contact_email, webhook_token, delivered_in_month) '.
"VALUES ('t-neg-test', 'X', 'x@x', 'wtok-neg-test-99999999', -1)"
'INSERT INTO tenants (subdomain, organization_name, contact_email, delivered_in_month) '.
"VALUES ('t-neg-test', 'X', 'x@x', -1)"
))->toThrow(QueryException::class);
});
@@ -59,32 +59,33 @@ it('supplier_csv_reconcile_log table exists with required columns and status CHE
]))->toThrow(QueryException::class);
});
it('schema.sql v8.26 has correct metrics — 65 base tables, 123 indexes, 40 RLS policies', function () {
it('schema.sql v8.35 has correct metrics — 66 base tables, 120 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.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_*).
// v8.30: +1 таблица scheduler_heartbeats (SaaS-level, hole #6).
// v8.31: 7 audit-таблиц переведены в PARTITION BY RANGE, hole #2.
// v8.35 (legacy webhook removal): 2 таблицы (webhook_log partitioned + rejected_deals_log)
// −5 индексов, −2 RLS-политики, −2 колонки tenants.webhook_token/webhook_token_rotated_at.
$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();
// v8.30: +1 таблица scheduler_heartbeats (SaaS-level, hole #6).
// v8.31: 7 audit-таблиц переведены в PARTITION BY RANGE, hole #2.
//
// 67 base tables = все CREATE TABLE минус PARTITION OF.
// 66 base tables = все CREATE TABLE минус 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(67);
expect($baseTables)->toBe(66);
$createIndexes = preg_match_all('/^CREATE\s+(?:UNIQUE\s+)?INDEX\b/m', $schema);
expect($createIndexes)->toBe(126); // v8.31: +3 индекса audit-таблиц после partitioning
expect($createIndexes)->toBe(120); // v8.35: 5 индексов (webhook_log ×2, rejected_deals_log ×2, tenants.webhook_token ×1)
$createPolicies = preg_match_all('/^CREATE\s+POLICY\b/m', $schema);
expect($createPolicies)->toBe(41); // v8.31: +1 политика на partitioned audit-таблицах
expect($createPolicies)->toBe(40); // v8.35: 2 политики (webhook_log + rejected_deals_log)
});
-437
View File
@@ -1,437 +0,0 @@
<?php
declare(strict_types=1);
use App\Jobs\ProcessWebhookJob;
use App\Models\ActivityLog;
use App\Models\BalanceTransaction;
use App\Models\Deal;
use App\Models\Project;
use App\Models\RejectedDealsLog;
use App\Models\SupplierLeadCost;
use App\Models\Tenant;
use App\Models\WebhookDedupKey;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\Concerns\SharesSupplierPdo;
/**
* Тесты ProcessWebhookJob двустадийный dedup v8.6 (CTO-17).
*
* Проверяет ключевую архитектурную инвариант: один и тот же vid должен
* обновлять существующую сделку (а не создавать дубль), и баланс должен
* списываться ровно один раз. См. narrative ТЗ §5.5.
*
* NB: Job::handle() сам открывает DB::transaction. DatabaseTransactions
* trait оборачивает каждый тест в outer-транзакцию Laravel-PG-driver
* корректно обрабатывает nested через savepoints.
*
* SharesSupplierPdo: failed() now inserts via pgsql_supplier (BYPASSRLS)
* share PDO so DatabaseTransactions cross-connection visibility works on dev.
*/
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
function makePayload(int $vid = 432176649, ?int $time = null): array
{
return [
'vid' => $vid,
'project' => 'B2_Caranga', // префикс должен обрезаться до 'Caranga'
'tag' => 'Caranga',
'phone' => '79001234567',
'phones' => ['79001234567'],
'time' => $time ?? time(),
];
}
/**
* Создаёт активного поставщика и привязывает его к проекту через project_suppliers.
* Используется в тестах SupplierLeadCost-ветки.
*/
function seedSupplierForProject(Project $project, float $costRub = 50.00): int
{
$supplierId = (int) DB::table('suppliers')->insertGetId([
'code' => 'b1-test-'.Str::lower(Str::random(6)),
'name' => 'B1 Test',
'accepts_types' => '{websites,calls}',
'cost_rub' => $costRub,
'channel' => 'sites',
'quality_score' => 1.00,
'is_active' => true,
'sort_order' => 0,
]);
DB::table('project_suppliers')->insert([
'project_id' => $project->id,
'supplier_id' => $supplierId,
'is_active' => true,
'created_at' => now(),
]);
return $supplierId;
}
test('новая сделка: INSERT в deals + INSERT в webhook_dedup_keys, баланс -1', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
(new ProcessWebhookJob($tenant->id, makePayload(vid: 100)))->handle();
$tenant->refresh();
expect($tenant->balance_leads)->toBe(9);
$deal = Deal::query()->where('tenant_id', $tenant->id)->first();
expect($deal)->not->toBeNull();
expect($deal->source_crm_id)->toBe(100);
expect($deal->phone)->toBe('79001234567');
expect($deal->status)->toBe('new');
expect($deal->project->name)->toBe('Caranga'); // префикс B2_ обрезан
$dedup = WebhookDedupKey::query()
->where('tenant_id', $tenant->id)
->where('source_crm_id', 100)
->first();
expect($dedup)->not->toBeNull();
expect($dedup->deal_id)->toBe($deal->id);
});
test('дубль vid: UPDATE существующей сделки, баланс НЕ списывается второй раз', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
$vid = 200;
// Первый webhook
(new ProcessWebhookJob($tenant->id, makePayload(vid: $vid)))->handle();
$tenant->refresh();
expect($tenant->balance_leads)->toBe(9);
$dealsAfterFirst = Deal::query()->where('tenant_id', $tenant->id)->count();
// Второй webhook с тем же vid (но новым phone — будет UPDATE)
$payload2 = makePayload(vid: $vid);
$payload2['phone'] = '79009999999';
(new ProcessWebhookJob($tenant->id, $payload2))->handle();
$tenant->refresh();
expect($tenant->balance_leads)->toBe(9); // баланс не изменился
expect(Deal::query()->where('tenant_id', $tenant->id)->count())->toBe($dealsAfterFirst);
$deal = Deal::query()->where('tenant_id', $tenant->id)->where('source_crm_id', $vid)->first();
expect($deal->phone)->toBe('79009999999'); // обновлён phone
// dedup-ключ всё ещё ровно один
expect(WebhookDedupKey::query()->where('tenant_id', $tenant->id)->where('source_crm_id', $vid)->count())->toBe(1);
});
test('баланс=0: запись в лог, без INSERT в deals и dedup_keys', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 0]);
(new ProcessWebhookJob($tenant->id, makePayload(vid: 300)))->handle();
$tenant->refresh();
expect($tenant->balance_leads)->toBe(0);
expect(Deal::query()->where('tenant_id', $tenant->id)->count())->toBe(0);
expect(WebhookDedupKey::query()->where('tenant_id', $tenant->id)->count())->toBe(0);
});
test('изоляция тенантов: одинаковый vid у разных тенантов = разные сделки', function () {
$tenantA = Tenant::factory()->create(['balance_leads' => 10]);
$tenantB = Tenant::factory()->create(['balance_leads' => 10]);
(new ProcessWebhookJob($tenantA->id, makePayload(vid: 555)))->handle();
(new ProcessWebhookJob($tenantB->id, makePayload(vid: 555)))->handle();
expect(Deal::query()->where('tenant_id', $tenantA->id)->count())->toBe(1);
expect(Deal::query()->where('tenant_id', $tenantB->id)->count())->toBe(1);
expect(WebhookDedupKey::query()->count())->toBeGreaterThanOrEqual(2);
$tenantA->refresh();
$tenantB->refresh();
expect($tenantA->balance_leads)->toBe(9);
expect($tenantB->balance_leads)->toBe(9);
});
test('findOrCreate проекта: повторный webhook с тем же project не создаёт дубля', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
(new ProcessWebhookJob($tenant->id, makePayload(vid: 401)))->handle();
(new ProcessWebhookJob($tenant->id, makePayload(vid: 402)))->handle();
expect(Project::query()->where('tenant_id', $tenant->id)->count())->toBe(1);
});
test('ON DELETE CASCADE: удаление сделки очищает webhook_dedup_keys', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
(new ProcessWebhookJob($tenant->id, makePayload(vid: 700)))->handle();
$deal = Deal::query()->where('tenant_id', $tenant->id)->first();
DB::table('deals')
->where('id', $deal->id)
->where('received_at', $deal->received_at)
->delete();
expect(WebhookDedupKey::query()
->where('tenant_id', $tenant->id)
->where('source_crm_id', 700)
->count())->toBe(0);
});
test('новая сделка создаёт BalanceTransaction (lead_charge -1)', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
(new ProcessWebhookJob($tenant->id, makePayload(vid: 800)))->handle();
$deal = Deal::query()->where('tenant_id', $tenant->id)->first();
$tx = BalanceTransaction::query()
->where('tenant_id', $tenant->id)
->where('type', BalanceTransaction::TYPE_LEAD_CHARGE)
->first();
expect($tx)->not->toBeNull();
expect($tx->amount_leads)->toBe(-1);
expect($tx->balance_leads_after)->toBe(9);
expect($tx->related_type)->toBe(Deal::class);
expect($tx->related_id)->toBe($deal->id);
});
test('дубль vid НЕ создаёт BalanceTransaction', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
$vid = 801;
(new ProcessWebhookJob($tenant->id, makePayload(vid: $vid)))->handle();
(new ProcessWebhookJob($tenant->id, makePayload(vid: $vid)))->handle();
expect(BalanceTransaction::query()
->where('tenant_id', $tenant->id)
->count())->toBe(1);
});
test('новая сделка создаёт ActivityLog event=deal.created', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
(new ProcessWebhookJob($tenant->id, makePayload(vid: 802)))->handle();
$deal = Deal::query()->where('tenant_id', $tenant->id)->first();
$log = ActivityLog::query()
->where('tenant_id', $tenant->id)
->where('deal_id', $deal->id)
->first();
expect($log)->not->toBeNull();
expect($log->event)->toBe(ActivityLog::EVENT_DEAL_CREATED);
expect($log->user_id)->toBeNull();
expect($log->context)->toBe(['source' => 'webhook']);
});
test('баланс=0 пишет в RejectedDealsLog с reason=zero_balance', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 0]);
(new ProcessWebhookJob($tenant->id, makePayload(vid: 803)))->handle();
$rejected = RejectedDealsLog::query()
->where('tenant_id', $tenant->id)
->first();
expect($rejected)->not->toBeNull();
expect($rejected->reason)->toBe(RejectedDealsLog::REASON_ZERO_BALANCE);
expect($rejected->payload['vid'])->toBe(803);
});
test('SupplierLeadCost создаётся со snapshot cost_rub из supplier', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'name' => 'Caranga', // совпадает с обрезанным project из payload
]);
$supplierId = seedSupplierForProject($project, costRub: 75.50);
(new ProcessWebhookJob($tenant->id, makePayload(vid: 804)))->handle();
$deal = Deal::query()->where('tenant_id', $tenant->id)->first();
$cost = SupplierLeadCost::query()
->where('deal_id', $deal->id)
->where('received_at', $deal->received_at)
->first();
expect($cost)->not->toBeNull();
expect($cost->supplier_id)->toBe($supplierId);
expect((string) $cost->cost_rub)->toBe('75.50');
expect($cost->supplier_lead_id)->toBe(804);
});
test('SupplierLeadCost НЕ создаётся если у проекта нет активного supplier', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
(new ProcessWebhookJob($tenant->id, makePayload(vid: 805)))->handle();
$deal = Deal::query()->where('tenant_id', $tenant->id)->first();
expect(SupplierLeadCost::query()
->where('deal_id', $deal->id)
->count())->toBe(0);
// Сделка всё равно создаётся, баланс списан, ActivityLog есть.
expect($deal)->not->toBeNull();
$tenant->refresh();
expect($tenant->balance_leads)->toBe(9);
});
// =============================================================================
// Биз-19: антифрод-дедуп по phone в окне 24 ч (DuplicateDetector, §10.8.1)
// =============================================================================
test('Биз-19: master в окне 24ч → дубль, баланс НЕ списывается', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
$phone = '79007770001';
// Master: пришёл вчера в 12:00.
$masterPayload = makePayload(vid: 901, time: now()->subHours(12)->timestamp);
$masterPayload['phone'] = $phone;
$masterPayload['phones'] = [$phone];
(new ProcessWebhookJob($tenant->id, $masterPayload))->handle();
$tenant->refresh();
expect($tenant->balance_leads)->toBe(9);
// Дубль: пришёл сейчас, в окне 24 ч.
$dupPayload = makePayload(vid: 902, time: now()->timestamp);
$dupPayload['phone'] = $phone;
$dupPayload['phones'] = [$phone];
(new ProcessWebhookJob($tenant->id, $dupPayload))->handle();
$master = Deal::query()->where('tenant_id', $tenant->id)->where('source_crm_id', 901)->first();
$dup = Deal::query()->where('tenant_id', $tenant->id)->where('source_crm_id', 902)->first();
expect($master->duplicate_of_id)->toBeNull();
expect($dup->duplicate_of_id)->toBe($master->id);
$tenant->refresh();
expect($tenant->balance_leads)->toBe(9); // только master списан, дубль — нет
expect(BalanceTransaction::query()->where('tenant_id', $tenant->id)->count())->toBe(1);
expect(SupplierLeadCost::query()->where('deal_id', $dup->id)->count())->toBe(0);
});
test('Биз-19: master старше 24ч → НЕ дубль, баланс списывается дважды', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
$phone = '79007770002';
// Master: пришёл 25 часов назад — за окном.
$masterPayload = makePayload(vid: 911, time: now()->subHours(25)->timestamp);
$masterPayload['phone'] = $phone;
$masterPayload['phones'] = [$phone];
(new ProcessWebhookJob($tenant->id, $masterPayload))->handle();
// Новая сделка с тем же phone — master уже за окном.
$newPayload = makePayload(vid: 912, time: now()->timestamp);
$newPayload['phone'] = $phone;
$newPayload['phones'] = [$phone];
(new ProcessWebhookJob($tenant->id, $newPayload))->handle();
$deal911 = Deal::query()->where('tenant_id', $tenant->id)->where('source_crm_id', 911)->first();
$deal912 = Deal::query()->where('tenant_id', $tenant->id)->where('source_crm_id', 912)->first();
expect($deal911->duplicate_of_id)->toBeNull();
expect($deal912->duplicate_of_id)->toBeNull();
$tenant->refresh();
expect($tenant->balance_leads)->toBe(8); // оба списаны
expect(BalanceTransaction::query()->where('tenant_id', $tenant->id)->count())->toBe(2);
});
test('Биз-19: дубли изолированы по tenant_id', function () {
$tenantA = Tenant::factory()->create(['balance_leads' => 10]);
$tenantB = Tenant::factory()->create(['balance_leads' => 10]);
$phone = '79007770003';
$payloadA = makePayload(vid: 921);
$payloadA['phone'] = $phone;
$payloadA['phones'] = [$phone];
(new ProcessWebhookJob($tenantA->id, $payloadA))->handle();
// Тот же phone у tenantB — НЕ должен считаться дублем.
$payloadB = makePayload(vid: 922);
$payloadB['phone'] = $phone;
$payloadB['phones'] = [$phone];
(new ProcessWebhookJob($tenantB->id, $payloadB))->handle();
$dealA = Deal::query()->where('tenant_id', $tenantA->id)->first();
$dealB = Deal::query()->where('tenant_id', $tenantB->id)->first();
expect($dealA->duplicate_of_id)->toBeNull();
expect($dealB->duplicate_of_id)->toBeNull();
});
test('Биз-19: ActivityLog для дубля содержит context.duplicate_of', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
$phone = '79007770004';
$masterPayload = makePayload(vid: 931, time: now()->subHours(2)->timestamp);
$masterPayload['phone'] = $phone;
$masterPayload['phones'] = [$phone];
(new ProcessWebhookJob($tenant->id, $masterPayload))->handle();
$dupPayload = makePayload(vid: 932, time: now()->timestamp);
$dupPayload['phone'] = $phone;
$dupPayload['phones'] = [$phone];
(new ProcessWebhookJob($tenant->id, $dupPayload))->handle();
$master = Deal::query()->where('source_crm_id', 931)->first();
$dup = Deal::query()->where('source_crm_id', 932)->first();
$masterLog = ActivityLog::query()->where('deal_id', $master->id)->first();
$dupLog = ActivityLog::query()->where('deal_id', $dup->id)->first();
expect($masterLog->context)->toBe(['source' => 'webhook']);
expect($dupLog->context)->toBe(['source' => 'webhook', 'duplicate_of' => $master->id]);
});
// =============================================================================
// failed() callback — финальная обработка после исчерпания ретраев
// =============================================================================
test('failed() пишет упавший job в failed_webhook_jobs', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
$webhookLogId = (int) DB::table('webhook_log')->insertGetId([
'tenant_id' => $tenant->id,
'raw_payload' => json_encode(['vid' => 1001]),
'received_at' => now(),
]);
$payload = makePayload(vid: 1001);
$job = new ProcessWebhookJob($tenant->id, $payload, webhookLogId: $webhookLogId);
$job->failed(new RuntimeException('boom: db down'));
$row = DB::table('failed_webhook_jobs')
->where('tenant_id', $tenant->id)
->first();
expect($row)->not->toBeNull();
expect($row->webhook_log_id)->toBe($webhookLogId);
expect($row->exception)->toBe('boom: db down');
expect($row->retry_count)->toBe(3);
expect($row->resolved_at)->toBeNull();
expect(json_decode($row->raw_payload, true)['vid'])->toBe(1001);
});
test('failed() работает БЕЗ webhookLogId (NULL ok)', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
$job = new ProcessWebhookJob($tenant->id, makePayload(vid: 1002));
$job->failed(new RuntimeException('no webhook log id'));
$row = DB::table('failed_webhook_jobs')->where('tenant_id', $tenant->id)->first();
expect($row)->not->toBeNull();
expect($row->webhook_log_id)->toBeNull();
});
test('failed() записывает payload с UTF-8 кириллицей корректно', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
$payload = makePayload(vid: 1003);
$payload['contact_name'] = 'Дмитрий Петров';
$job = new ProcessWebhookJob($tenant->id, $payload);
$job->failed(new RuntimeException('utf-8 test'));
$row = DB::table('failed_webhook_jobs')->where('tenant_id', $tenant->id)->first();
$decoded = json_decode($row->raw_payload, true);
expect($decoded['contact_name'])->toBe('Дмитрий Петров');
});
-2
View File
@@ -45,14 +45,12 @@ SQL);
'subdomain' => 'rls-tenant-a-'.uniqid(),
'organization_name' => 'RLS Tenant A',
'contact_email' => 'a@rls-test.local',
'webhook_token' => 'whtA'.str_pad((string) random_int(0, 999999999), 60, '0', STR_PAD_LEFT),
'api_key_limit' => 5,
]);
$this->tenant2Id = DB::table('tenants')->insertGetId([
'subdomain' => 'rls-tenant-b-'.uniqid(),
'organization_name' => 'RLS Tenant B',
'contact_email' => 'b@rls-test.local',
'webhook_token' => 'whtB'.str_pad((string) random_int(0, 999999999), 60, '0', STR_PAD_LEFT),
'api_key_limit' => 5,
]);
+12 -8
View File
@@ -5,21 +5,25 @@ declare(strict_types=1);
use Illuminate\Foundation\Testing\DatabaseTransactions;
/**
* J2 (Sprint 3F) стаб-гейт SaaS-admin зоны.
* J2 (Sprint 3F) гейт SaaS-admin зоны.
*
* EnsureSaasAdmin на /api/admin/*: dev/testing пропускает (admin-панель
* работает на dev), прочие окружения fail-closed 503 до подключения
* реального Yandex 360 SSO (TODO под Б-1+DO-4).
* EnsureSaasAdmin на /api/admin/*: пропускает запрос во ВСЕХ окружениях.
* Защита боевой админ-зоны (/admin + /api/admin/*) перенесена на nginx
* (HTTP Basic Auth, отдельный пароль /etc/nginx/.htpasswd-admin), потому
* что настоящий saas-admin SSO (Yandex 360) ещё не готов (Б-1 + DO-4).
* Ранее middleware fail-closed 503 вне dev/testing это закрывало всю
* админку на проде наглухо; стопгэп заменил замок на nginx-дверь.
*/
uses(DatabaseTransactions::class);
test('/api/admin/* пропускается на testing-окружении (стаб permissive)', function () {
// Дефолтное тестовое окружение = testing → middleware пропускает.
test('/api/admin/* пропускается на testing-окружении', function () {
$this->getJson('/api/admin/tenants')->assertStatus(200);
});
test('/api/admin/* возвращает 503 вне dev/testing (стаб fail-closed)', function () {
test('/api/admin/* пропускается и на production (замок 503 снят, дверь держит nginx)', function () {
$this->app->detectEnvironment(fn () => 'production');
$this->getJson('/api/admin/tenants')->assertStatus(503);
// Раньше тут был 503. Теперь приложение зону не закрывает — её держит
// nginx basic-auth (стопгэп до реального Yandex 360 SSO).
$this->getJson('/api/admin/tenants')->assertStatus(200);
});
@@ -10,7 +10,6 @@ use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\Billing\LedgerService;
use App\Services\DuplicateDetector;
use App\Services\LeadDistributor;
use App\Services\LeadRouter;
use App\Services\NotificationService;
@@ -66,7 +65,6 @@ function runJob(int $leadId): void
(new RouteSupplierLeadJob($leadId))->handle(
app(LeadRouter::class),
app(SupplierProjectResolver::class),
app(DuplicateDetector::class),
app(NotificationService::class),
app(LedgerService::class),
app(LeadDistributor::class),
@@ -149,7 +147,7 @@ it('sharing-flow isolation: tenant A on zero paused, tenant B with balance recei
// tenantA: balance_rub > 0 (проходит WHERE EXISTS-фильтр LeadRouter), но < tier_price (500 ₽).
// Поэтому projectA попадает в matched, LedgerService падает с InsufficientBalanceException → auto-pause.
$tenantA = Tenant::factory()->create(['balance_leads' => 0, 'balance_rub' => '100.00']);
$tenantB = Tenant::factory()->create(['balance_leads' => 5, 'balance_rub' => '0.00']);
$tenantB = Tenant::factory()->create(['balance_rub' => '100000.00']);
$projectA = Project::factory()->create([
'tenant_id' => $tenantA->id, 'signal_type' => 'site', 'signal_identifier' => 'example.com',
'supplier_b1_project_id' => $supplierProject->id, 'is_active' => true,
@@ -176,5 +174,5 @@ it('sharing-flow isolation: tenant A on zero paused, tenant B with balance recei
expect($projectA->fresh()->is_active)->toBeFalse();
expect($projectB->fresh()->is_active)->toBeTrue();
expect((int) $tenantB->fresh()->balance_leads)->toBe(4);
expect((string) $tenantB->fresh()->balance_rub)->toBe('99500.00');
});
@@ -257,3 +257,80 @@ it('SupplierTransientException — status=failed, error recorded, rethrown', fun
expect($log->status)->toBe('failed');
expect($log->error_message)->toContain('500');
});
it('unparseable CSV rows excluded from drift: 100 matched + 10 junk-project rows → status=ok, unparseable_count=10', function (): void {
// 100 нормальных webhook-лидов.
for ($i = 0; $i < 100; $i++) {
SupplierLead::create([
'supplier_project_id' => null,
'platform' => 'B1',
'phone' => '79993'.str_pad((string) $i, 6, '0', STR_PAD_LEFT),
'vid' => 840000 + $i,
'raw_payload' => ['project' => 'B1_a.com', 'phone' => '79993'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)],
'received_at' => now()->subHour(),
'source' => 'webhook',
]);
}
// CSV: те же 100 (matched) + 10 строк с мусорным project (extractPlatform = null).
// Это реальный паттерн поставщика — телефон в поле «Name» вместо проекта (см. 22.05 в ПИЛОТ).
$rows = [];
for ($i = 0; $i < 100; $i++) {
$rows[] = ['project' => 'B1_a.com', 'phone' => '79993'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)];
}
for ($j = 0; $j < 10; $j++) {
$rows[] = ['project' => '79135551234', 'phone' => '7999500000'.$j];
}
fakeReportFlow(csvBody($rows));
runCsvReconcile();
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
expect((int) $log->total_csv_rows)->toBe(110);
expect((int) $log->matched_count)->toBe(100);
expect((int) $log->recovered_count)->toBe(0);
expect((int) $log->unparseable_count)->toBe(10);
// Реального missing'а нет — только junk; drift должен быть 0, не 10/110.
expect((float) $log->drift_ratio)->toBe(0.0);
expect($log->status)->toBe('ok');
Mail::assertNothingSent();
});
it('mixed: 95 matched + 5 junk + 3 real-missing → unparseable_count=5, recovered=3, drift по реальным', function (): void {
for ($i = 0; $i < 95; $i++) {
SupplierLead::create([
'supplier_project_id' => null,
'platform' => 'B1',
'phone' => '79994'.str_pad((string) $i, 6, '0', STR_PAD_LEFT),
'vid' => 850000 + $i,
'raw_payload' => ['project' => 'B1_a.com', 'phone' => '79994'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)],
'received_at' => now()->subHour(),
'source' => 'webhook',
]);
}
$rows = [];
for ($i = 0; $i < 95; $i++) {
$rows[] = ['project' => 'B1_a.com', 'phone' => '79994'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)];
}
for ($j = 0; $j < 5; $j++) {
$rows[] = ['project' => 'https://junk.example/'.$j, 'phone' => '7999600000'.$j];
}
for ($k = 0; $k < 3; $k++) {
$rows[] = ['project' => 'B1_a.com', 'phone' => '7999700000'.$k];
}
fakeReportFlow(csvBody($rows));
runCsvReconcile();
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
expect((int) $log->total_csv_rows)->toBe(103);
expect((int) $log->matched_count)->toBe(95);
expect((int) $log->recovered_count)->toBe(3);
expect((int) $log->unparseable_count)->toBe(5);
// real_missing = (103 - 95) - 5 = 3; parseable_total = 103 - 5 = 98; drift = 3/98 ≈ 0.0306 < 5% → ok.
expect((float) $log->drift_ratio)->toBeLessThan(0.05);
expect((float) $log->drift_ratio)->toBeGreaterThan(0.0);
expect($log->status)->toBe('ok');
});
@@ -12,7 +12,6 @@ use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\Billing\LedgerService;
use App\Services\DuplicateDetector;
use App\Services\LeadDistributor;
use App\Services\LeadRouter;
use App\Services\NotificationService;
@@ -90,7 +89,6 @@ function dispatchJob(int $supplierLeadId): void
(new RouteSupplierLeadJob($supplierLeadId))->handle(
app(LeadRouter::class),
app(SupplierProjectResolver::class),
app(DuplicateDetector::class),
app(NotificationService::class),
app(LedgerService::class),
app(LeadDistributor::class),
@@ -98,27 +96,6 @@ function dispatchJob(int $supplierLeadId): void
);
}
it('charges prepaid for tenant with balance_leads > 0 + writes BalanceTransaction', function (): void {
$ctx = prepareSharingFlow(1, [['balance_leads' => 5, 'balance_rub' => '0.00', 'delivered_in_month' => 0]]);
dispatchJob($ctx['lead']->id);
$tenant = $ctx['tenants'][0]->fresh();
expect((int) $tenant->balance_leads)->toBe(4);
expect($tenant->delivered_in_month)->toBe(1);
$charge = LeadCharge::first();
expect($charge)->not->toBeNull();
expect($charge->charge_source)->toBe('prepaid');
expect($charge->price_per_lead_kopecks)->toBe(0);
// BalanceTransaction (carry-over M-2 assertion)
$tx = BalanceTransaction::where('type', BalanceTransaction::TYPE_LEAD_CHARGE)->first();
expect($tx)->not->toBeNull();
expect((int) $tx->amount_leads)->toBe(-1);
expect((int) $tx->balance_leads_after)->toBe(4);
});
it('charges rub for tenant with balance_leads=0 and balance_rub >= price + writes BalanceTransaction', function (): void {
$ctx = prepareSharingFlow(1, [['balance_leads' => 0, 'balance_rub' => '1000.00', 'delivered_in_month' => 0]]);
@@ -141,7 +118,7 @@ it('charges rub for tenant with balance_leads=0 and balance_rub >= price + write
it('writes supplier_lead_costs for each delivered deal copy (gap-fix)', function (): void {
$ctx = prepareSharingFlow(2, [
['balance_leads' => 5, 'balance_rub' => '0.00', 'delivered_in_month' => 0],
['balance_leads' => 0, 'balance_rub' => '1000.00', 'delivered_in_month' => 0],
['balance_leads' => 0, 'balance_rub' => '1000.00', 'delivered_in_month' => 0],
]);
@@ -156,13 +133,15 @@ it('writes supplier_lead_costs for each delivered deal copy (gap-fix)', function
});
it('retry idempotency: повторный run не дублирует lead_charges', function (): void {
$ctx = prepareSharingFlow(1, [['balance_leads' => 5, 'balance_rub' => '0.00']]);
$ctx = prepareSharingFlow(1, [['balance_rub' => '100000.00']]);
$leadId = $ctx['lead']->id;
$tenantId = $ctx['tenants'][0]->id;
dispatchJob($leadId);
dispatchJob($leadId); // повторный — processed_at guard защищает
expect(LeadCharge::count())->toBe(1);
expect(Deal::count())->toBe(1);
expect((int) $ctx['tenants'][0]->fresh()->balance_leads)->toBe(4);
expect(LeadCharge::where('tenant_id', $tenantId)->count())->toBe(1);
DB::statement("SET LOCAL app.current_tenant_id = '{$tenantId}'");
expect(Deal::where('tenant_id', $tenantId)->count())->toBe(1);
});
@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
use App\Jobs\RouteSupplierLeadJob;
use App\Models\Deal;
use App\Models\LeadCharge;
use App\Models\Project;
use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\Billing\LedgerService;
use App\Services\LeadDistributor;
use App\Services\LeadRouter;
use App\Services\NotificationService;
use App\Services\RegionTagResolver;
use App\Services\SupplierProjects\SupplierProjectResolver;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Random\Engine\Mt19937;
use Random\Randomizer;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
function runRouteJobB(int $id): void
{
(new RouteSupplierLeadJob($id))->handle(
app(LeadRouter::class),
app(SupplierProjectResolver::class),
app(NotificationService::class),
app(LedgerService::class),
app(LeadDistributor::class),
app(RegionTagResolver::class),
);
}
it('supplier_lead_deliveries table exists with PK (supplier_lead_id, tenant_id) and RLS', function (): void {
$cols = collect(DB::select(
"SELECT column_name FROM information_schema.columns WHERE table_name = 'supplier_lead_deliveries'"
))->pluck('column_name')->all();
expect($cols)->toContain('supplier_lead_id')
->toContain('tenant_id')
->toContain('deal_id')
->toContain('created_at');
$pk = collect(DB::select(
"SELECT a.attname FROM pg_index i
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
WHERE i.indrelid = 'supplier_lead_deliveries'::regclass AND i.indisprimary"
))->pluck('attname')->sort()->values()->all();
expect($pk)->toBe(['supplier_lead_id', 'tenant_id']);
$rls = DB::selectOne(
"SELECT relrowsecurity FROM pg_class WHERE relname = 'supplier_lead_deliveries'"
);
expect($rls->relrowsecurity)->toBeTrue();
});
it('one delivery to a tenant with 2 eligible projects → exactly 1 deal + 1 charge (max-remaining-limit tie-break)', function (): void {
$this->seed(PricingTierSeeder::class);
$sp = SupplierProject::factory()->create([
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'twoproj.ru',
]);
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
// Two eligible projects for the SAME tenant, different remaining limit.
$pLow = Project::factory()->create([
'tenant_id' => $tenant->id, 'is_active' => true,
'supplier_b1_project_id' => $sp->id,
'signal_type' => 'site', 'signal_identifier' => 'twoproj.ru',
'daily_limit_target' => 10, 'effective_daily_limit_today' => 10,
'delivered_today' => 9, 'delivery_days_mask' => 127, 'region_mask' => 255,
]);
$pHigh = Project::factory()->create([
'tenant_id' => $tenant->id, 'is_active' => true,
'supplier_b1_project_id' => $sp->id,
'signal_type' => 'site', 'signal_identifier' => 'twoproj.ru',
'daily_limit_target' => 10, 'effective_daily_limit_today' => 10,
'delivered_today' => 0, 'delivery_days_mask' => 127, 'region_mask' => 255,
]);
linkProjectToSupplier($pLow, $sp);
linkProjectToSupplier($pHigh, $sp);
$vid = 600001;
$lead = SupplierLead::factory()->create([
'supplier_project_id' => null, 'platform' => 'B1', 'vid' => $vid,
'phone' => '79991234567',
'raw_payload' => ['vid' => $vid, 'project' => 'B1_twoproj.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()],
]);
runRouteJobB($lead->id);
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
expect(Deal::query()->where('source_crm_id', $vid)->count())->toBe(1);
expect(LeadCharge::query()->where('tenant_id', $tenant->id)->count())->toBe(1);
// The project with most remaining limit was chosen.
expect($pHigh->fresh()->delivered_today)->toBe(1);
expect($pLow->fresh()->delivered_today)->toBe(9);
});
it('lock: re-running same delivery to same tenant does not double-charge (Spec B)', function (): void {
$this->seed(PricingTierSeeder::class);
$sp = SupplierProject::factory()->create([
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'lock.ru',
]);
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
$p = Project::factory()->create([
'tenant_id' => $tenant->id, 'is_active' => true,
'supplier_b1_project_id' => $sp->id,
'signal_type' => 'site', 'signal_identifier' => 'lock.ru',
'daily_limit_target' => 10, 'effective_daily_limit_today' => 10,
'delivered_today' => 0, 'delivery_days_mask' => 127, 'region_mask' => 255,
]);
linkProjectToSupplier($p, $sp);
$vid = 610001;
$lead = SupplierLead::factory()->create([
'supplier_project_id' => null, 'platform' => 'B1', 'vid' => $vid,
'phone' => '79991234567',
'raw_payload' => ['vid' => $vid, 'project' => 'B1_lock.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()],
]);
runRouteJobB($lead->id);
// Reset processed_at to force a SECOND pass (bypass the existing $lead->processed_at idempotency
// guard so we are testing the DB-level lock specifically).
$lead->update(['processed_at' => null]);
runRouteJobB($lead->id);
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
expect(Deal::query()->where('source_crm_id', $vid)->count())->toBe(1);
expect(LeadCharge::query()->where('tenant_id', $tenant->id)->count())->toBe(1);
expect(DB::table('supplier_lead_deliveries')
->where('supplier_lead_id', $lead->id)->where('tenant_id', $tenant->id)->count())->toBe(1);
// Balance debited exactly once.
expect((string) $tenant->fresh()->balance_rub)->toBe('99500.00');
});
it('same phone, two different deliveries to one tenant → both charged (no phone dedup, Spec B)', function (): void {
$this->seed(PricingTierSeeder::class);
$sp = SupplierProject::factory()->create([
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'twohit.ru',
]);
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
$p = Project::factory()->create([
'tenant_id' => $tenant->id, 'is_active' => true,
'supplier_b1_project_id' => $sp->id,
'signal_type' => 'site', 'signal_identifier' => 'twohit.ru',
'daily_limit_target' => 10, 'effective_daily_limit_today' => 10,
'delivered_today' => 0, 'delivery_days_mask' => 127, 'region_mask' => 255,
]);
linkProjectToSupplier($p, $sp);
foreach ([700001, 700002] as $vid) {
$lead = SupplierLead::factory()->create([
'supplier_project_id' => null, 'platform' => 'B1', 'vid' => $vid,
'phone' => '79991234567',
'raw_payload' => ['vid' => $vid, 'project' => 'B1_twohit.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()],
]);
runRouteJobB($lead->id);
}
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
expect(Deal::query()->where('tenant_id', $tenant->id)->whereIn('source_crm_id', [700001, 700002])->count())->toBe(2);
expect(LeadCharge::query()->where('tenant_id', $tenant->id)->count())->toBe(2);
expect((string) $tenant->fresh()->balance_rub)->toBe('99000.00');
});
it('cap = 3 distinct tenants: 5 eligible tenants → exactly 3 charged (Spec B)', function (): void {
$this->seed(PricingTierSeeder::class);
app()->bind(LeadDistributor::class, fn () => new LeadDistributor(new Randomizer(new Mt19937(7))));
$sp = SupplierProject::factory()->create([
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'cap3.ru',
]);
foreach (range(1, 5) as $i) {
$t = Tenant::factory()->create(['balance_rub' => '100000.00']);
$p = Project::factory()->create([
'tenant_id' => $t->id, 'is_active' => true,
'supplier_b1_project_id' => $sp->id,
'signal_type' => 'site', 'signal_identifier' => 'cap3.ru',
'daily_limit_target' => 10, 'effective_daily_limit_today' => 10,
'delivered_today' => 0, 'delivery_days_mask' => 127, 'region_mask' => 255,
]);
linkProjectToSupplier($p, $sp);
}
$vid = 710001;
$lead = SupplierLead::factory()->create([
'supplier_project_id' => null, 'platform' => 'B1', 'vid' => $vid,
'phone' => '79991234567',
'raw_payload' => ['vid' => $vid, 'project' => 'B1_cap3.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()],
]);
runRouteJobB($lead->id);
$lead->refresh();
expect($lead->deals_created_count)->toBe(3);
expect(DB::table('supplier_lead_deliveries')->where('supplier_lead_id', $lead->id)->count())->toBe(3);
expect(DB::table('supplier_lead_deliveries')->where('supplier_lead_id', $lead->id)->distinct()->count('tenant_id'))->toBe(3);
});
@@ -6,7 +6,9 @@ use App\Jobs\SyncSupplierProjectJob;
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\Supplier\Channel\Exceptions\WindowDeferredException;
use App\Services\Supplier\Channel\SupplierProjectChannel;
use App\Services\Supplier\SupplierPortalClient;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Cache;
@@ -535,10 +537,10 @@ it('online create: transient failure on one platform throws so the job retries (
'delivery_days_mask' => 127,
]);
$this->mock(\App\Services\Supplier\SupplierPortalClient::class, function ($mock): void {
$this->mock(SupplierPortalClient::class, function ($mock): void {
$mock->shouldReceive('saveProjectMultiFlag')->andReturnUsing(function ($dto) {
if ($dto->platform === 'B3') {
throw new \RuntimeException('transient: connection reset by peer');
throw new RuntimeException('transient: connection reset by peer');
}
return [$dto->platform => ($dto->platform === 'B1' ? 6001 : 6002)];
@@ -547,7 +549,7 @@ it('online create: transient failure on one platform throws so the job retries (
// Transient miss on B3 → job must throw (so Laravel retries).
expect(fn () => (new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class)))
->toThrow(\RuntimeException::class);
->toThrow(RuntimeException::class);
// Progress is preserved: B1 + B2 are created so the retry only fills B3.
expect(SupplierProject::where('unique_key', '70000009999')->count())->toBe(2);
@@ -570,10 +572,10 @@ it('online create: escalation/window-defer of one platform does NOT throw (legit
'delivery_days_mask' => 127,
]);
$this->mock(\App\Services\Supplier\SupplierPortalClient::class, function ($mock): void {
$this->mock(SupplierPortalClient::class, function ($mock): void {
$mock->shouldReceive('saveProjectMultiFlag')->andReturnUsing(function ($dto) {
if ($dto->platform === 'B3') {
throw new \App\Services\Supplier\Channel\Exceptions\WindowDeferredException('portal window closed');
throw new WindowDeferredException('portal window closed');
}
return [$dto->platform => ($dto->platform === 'B1' ? 7001 : 7002)];
-286
View File
@@ -1,286 +0,0 @@
<?php
declare(strict_types=1);
use App\Jobs\ProcessWebhookJob;
use App\Models\SystemSetting;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\RateLimiter;
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create([
'webhook_token' => 'whk_test_'.bin2hex(random_bytes(8)),
'balance_leads' => 100,
]);
// Чистим RateLimiter между тестами — иначе lockout из одного теста
// загрязняет следующий.
RateLimiter::clear("webhook:{$this->tenant->id}");
// Audit-fix B3: дефолт isHmacRequired() изменён на true. Тесты, проверяющие
// НЕ-HMAC аспекты (payload-валидация, rate-limit, CSRF), явно ставят флаг в
// false — иначе запрос без подписи получит 401 ещё до этих проверок.
SystemSetting::firstOrCreate(
['key' => 'webhook_hmac_required'],
['value' => 'false', 'type' => 'bool', 'description' => 'test default', 'updated_at' => now()],
);
SystemSetting::where('key', 'webhook_hmac_required')->update(['value' => 'false']);
});
test('POST /api/webhook/{token} с валидным payload возвращает 202 + dispatch ProcessWebhookJob', function () {
Bus::fake();
$payload = [
'vid' => 12345,
'project' => 'Натяжные потолки',
'phone' => '+7 (999) 123-45-67',
'time' => time(),
'tag' => 'ya_direct',
];
$r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", $payload);
$r->assertStatus(202);
expect($r->json('status'))->toBe('accepted');
expect($r->json('tenant_id'))->toBe($this->tenant->id);
Bus::assertDispatched(ProcessWebhookJob::class, function ($job) use ($payload) {
return $job->tenantId === $this->tenant->id
&& $job->data['vid'] === $payload['vid']
&& $job->data['phone'] === $payload['phone'];
});
});
test('POST с unknown token → 404', function () {
Bus::fake();
$r = $this->postJson('/api/webhook/whk_nonexistent_token_12345', [
'vid' => 1,
'project' => 'X',
'phone' => '+7 (999) 000-00-00',
'time' => time(),
]);
$r->assertStatus(404);
Bus::assertNothingDispatched();
});
test('POST без обязательных полей → 422', function () {
Bus::fake();
$r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", [
// Нет vid/project/phone/time
]);
$r->assertStatus(422);
$errors = $r->json('errors');
expect($errors)->toHaveKeys(['vid', 'project', 'phone', 'time']);
Bus::assertNothingDispatched();
});
test('POST с вредной структурой (vid=строка, time=отрицательный) → 422', function () {
Bus::fake();
$r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", [
'vid' => 'не-число',
'project' => 'X',
'phone' => '+7 (999) 000-00-00',
'time' => -1,
]);
$r->assertStatus(422);
Bus::assertNothingDispatched();
});
test('POST к webhook НЕ требует CSRF (внешний клиент)', function () {
Bus::fake();
// Симулируем запрос БЕЗ X-XSRF-TOKEN — CSRF middleware не должен проверять
// /api/webhook/* (см. bootstrap/app.php validateCsrfTokens except).
$r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", [
'vid' => 1,
'project' => 'X',
'phone' => '+7 (999) 000-00-00',
'time' => time(),
]);
$r->assertStatus(202);
});
test('POST с `phones` array (multi-phone payload) принимается', function () {
Bus::fake();
$r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", [
'vid' => 1,
'project' => 'Окна',
'phone' => '+7 (999) 000-00-00',
'phones' => ['+7 (999) 000-00-01', '+7 (999) 000-00-02'],
'time' => time(),
]);
$r->assertStatus(202);
Bus::assertDispatched(ProcessWebhookJob::class, function ($job) {
return is_array($job->data['phones']) && count($job->data['phones']) === 2;
});
});
test('HMAC: валидная подпись sha256=hex(hmac_sha256(body, token)) проходит', function () {
Bus::fake();
$payload = [
'vid' => 1,
'project' => 'X',
'phone' => '+7 (999) 000-00-00',
'time' => time(),
];
$rawBody = json_encode($payload);
$signature = 'sha256='.hash_hmac('sha256', $rawBody, $this->tenant->webhook_token);
$r = $this->call('POST', "/api/webhook/{$this->tenant->webhook_token}", [], [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_ACCEPT' => 'application/json',
'HTTP_X_WEBHOOK_SIGNATURE' => $signature,
], $rawBody);
$r->assertStatus(202);
Bus::assertDispatched(ProcessWebhookJob::class);
});
test('HMAC: невалидная подпись → 401, dispatch НЕ происходит', function () {
Bus::fake();
$payload = [
'vid' => 1,
'project' => 'X',
'phone' => '+7 (999) 000-00-00',
'time' => time(),
];
$rawBody = json_encode($payload);
$r = $this->call('POST', "/api/webhook/{$this->tenant->webhook_token}", [], [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_ACCEPT' => 'application/json',
'HTTP_X_WEBHOOK_SIGNATURE' => 'sha256=deadbeef'.str_repeat('0', 56),
], $rawBody);
$r->assertStatus(401);
expect($r->json('message'))->toContain('HMAC');
Bus::assertNothingDispatched();
});
test('HMAC: настройка отсутствует → HMAC обязателен по умолчанию (B3) → 401', function () {
Bus::fake();
// Audit-fix B3: code-default isHmacRequired() = true. Удаляем настройку,
// чтобы проверить именно отсутствие ключа в system_settings.
SystemSetting::where('key', 'webhook_hmac_required')->delete();
$r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", [
'vid' => 1,
'project' => 'X',
'phone' => '+7 (999) 000-00-00',
'time' => time(),
]);
$r->assertStatus(401);
Bus::assertNothingDispatched();
});
test('rate-limit: системный лимит RPS×60 в минуту, 429 + Retry-After на превышении', function () {
Bus::fake();
// Устанавливаем низкий лимит через system_settings — иначе тест слишком долгий
// (default 100 RPS = 6000/мин). Подменяем через update.
SystemSetting::where('key', 'webhook_rate_limit_rps')->update(['value' => '1']);
$payload = [
'vid' => 1,
'project' => 'X',
'phone' => '+7 (999) 000-00-00',
'time' => time(),
];
// 1 RPS × 60 = 60 запросов/мин. Делаем 60 успешных.
for ($i = 0; $i < 60; $i++) {
$r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", $payload);
$r->assertStatus(202);
}
// 61-й — превышение.
$r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", $payload);
$r->assertStatus(429);
expect($r->json('retry_after'))->toBeInt()->toBeGreaterThan(0);
expect($r->headers->get('Retry-After'))->not->toBeNull();
});
test('webhook_hmac_required=true: запрос без X-Webhook-Signature → 401', function () {
Bus::fake();
SystemSetting::firstOrCreate(
['key' => 'webhook_hmac_required'],
['value' => 'true', 'type' => 'bool', 'description' => 'тест', 'updated_at' => now()],
);
SystemSetting::where('key', 'webhook_hmac_required')->update(['value' => 'true']);
$r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", [
'vid' => 1,
'project' => 'X',
'phone' => '+7 (999) 000-00-00',
'time' => time(),
]);
$r->assertStatus(401);
expect($r->json('message'))->toContain('требуется');
Bus::assertNothingDispatched();
});
test('webhook_hmac_required=true: с валидной HMAC-подписью → 202', function () {
Bus::fake();
SystemSetting::firstOrCreate(
['key' => 'webhook_hmac_required'],
['value' => 'true', 'type' => 'bool', 'description' => 'тест', 'updated_at' => now()],
);
SystemSetting::where('key', 'webhook_hmac_required')->update(['value' => 'true']);
$payload = [
'vid' => 1, 'project' => 'X', 'phone' => '+7 (999) 000-00-00', 'time' => time(),
];
$rawBody = json_encode($payload);
$signature = 'sha256='.hash_hmac('sha256', $rawBody, $this->tenant->webhook_token);
$r = $this->call('POST', "/api/webhook/{$this->tenant->webhook_token}", [], [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_ACCEPT' => 'application/json',
'HTTP_X_WEBHOOK_SIGNATURE' => $signature,
], $rawBody);
$r->assertStatus(202);
});
test('webhook_hmac_required=false: header опционален → 202 без подписи', function () {
Bus::fake();
SystemSetting::firstOrCreate(
['key' => 'webhook_hmac_required'],
['value' => 'false', 'type' => 'bool', 'description' => 'тест', 'updated_at' => now()],
);
SystemSetting::where('key', 'webhook_hmac_required')->update(['value' => 'false']);
$r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", [
'vid' => 1, 'project' => 'X', 'phone' => '+7 (999) 000-00-00', 'time' => time(),
]);
$r->assertStatus(202);
});
test('rate-limit: ключ изолирован per-token (другой tenant не блокирует)', function () {
Bus::fake();
SystemSetting::where('key', 'webhook_rate_limit_rps')->update(['value' => '1']);
$tenantOther = Tenant::factory()->create([
'webhook_token' => 'whk_other_'.bin2hex(random_bytes(8)),
]);
RateLimiter::clear("webhook:{$tenantOther->id}");
$payload = [
'vid' => 1, 'project' => 'X', 'phone' => '+7 (999) 000-00-00', 'time' => time(),
];
// Заполняем лимит первого tenant'а
for ($i = 0; $i < 60; $i++) {
$this->postJson("/api/webhook/{$this->tenant->webhook_token}", $payload)->assertStatus(202);
}
$this->postJson("/api/webhook/{$this->tenant->webhook_token}", $payload)->assertStatus(429);
// Второй tenant — без проблем.
$r = $this->postJson("/api/webhook/{$tenantOther->webhook_token}", $payload);
$r->assertStatus(202);
});
@@ -1,14 +1,12 @@
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 });
const vuetify = createVuetify();
describe('AdminSupplierIntegrationView — export-mode toggle (Plan 4 Task 1)', () => {
beforeEach(() => {

Some files were not shown because too many files have changed in this diff Show More