Compare commits
146 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 19d12c9f95 | |||
| f6e1e64bee | |||
| 0832997b6e | |||
| 1f5aa0b103 | |||
| cffed5e979 | |||
| 9bc041992d | |||
| 323957ad34 | |||
| d1b2f5d6cf | |||
| c5ae923027 | |||
| b6f44d9c80 | |||
| 52e9a46f2b | |||
| 54c69a64e8 | |||
| 093b1af059 | |||
| f2627e4d3e | |||
| c09bff3799 | |||
| 918c962b26 | |||
| 4c6d593776 | |||
| 0a37aadd20 | |||
| e79fe95267 | |||
| 484504b78f | |||
| c8005e0cfc | |||
| d9fc3d92e4 | |||
| a5e99ba0e9 | |||
| 95f5f94a6b | |||
| 143cc458c1 | |||
| 420dd26c08 | |||
| 02d3506803 | |||
| ac73c88371 | |||
| 15e1c6d34f | |||
| b27259e7c5 | |||
| 5cebe2450d | |||
| fff2dff499 | |||
| ebebfacab4 | |||
| 1da23b8253 | |||
| 57f0b8e64c | |||
| b9038bc3eb | |||
| cb05657f30 | |||
| 6988e80137 | |||
| 245b76ec43 | |||
| 55a9d3fe00 | |||
| 3a8229a4c7 | |||
| b5849bbd2a | |||
| 4ee718e668 | |||
| 3fc90f12df | |||
| ab47ad250b | |||
| 84530d55bf | |||
| b2a5a6e18a | |||
| 50816403bb | |||
| 7d86971e9d | |||
| 88a13e2001 | |||
| 8f40ea441d | |||
| df92ac02ff | |||
| 4b6ab8f113 | |||
| 4c470813b4 | |||
| 3b254fb56f | |||
| 95bba384a1 | |||
| a46e63bdd3 | |||
| 2d6eb88ce0 | |||
| cb36a52171 | |||
| 64d8daede7 | |||
| c6eae16282 | |||
| c025ec4b69 | |||
| 8220a85a5d | |||
| 08f02100fe | |||
| 40202caf34 | |||
| 5c8ad2738a | |||
| 4e27db63a3 | |||
| 1d6d1f2671 | |||
| 9a7615b257 | |||
| e3804cd12b | |||
| d238ca5f4a | |||
| d8c33b4cd6 | |||
| 901530ae41 | |||
| c771192db2 | |||
| b182dae89b | |||
| f27ccc0081 | |||
| 8edd720395 | |||
| 1f834bfac3 | |||
| baf51bd2cf | |||
| 611506faa1 | |||
| f90ddb09c1 | |||
| f4ec5dcafa | |||
| 43250b6773 | |||
| 3ce52fc52f | |||
| e2669270f3 | |||
| 22e6bdf8b8 | |||
| 2f46a3e5ec | |||
| 35662f7b56 | |||
| a09434eca0 | |||
| 3f956224bd | |||
| 2707ff64ab | |||
| 0b2ec5b802 | |||
| 52cc64c9e6 | |||
| ff3bc8bcc1 | |||
| 7322c7f33a | |||
| eda13679b4 | |||
| cdd1b5efdb | |||
| ea4570dafe | |||
| b858df569e | |||
| baf27bd02d | |||
| 688d9cfb24 | |||
| 38b985a473 | |||
| 17e07fbe69 | |||
| 615db99547 | |||
| 0fd93fd686 | |||
| 0245f12b51 | |||
| 76b1562593 | |||
| 1c3989a6df | |||
| 92082606e3 | |||
| 8bc7838f0c | |||
| c9ee8d866e | |||
| 458fa0b84d | |||
| 32135e62d2 | |||
| 6238b8b580 | |||
| 85f8e9e7a0 | |||
| 51019c5aee | |||
| 2ffbb49faa | |||
| 9d2e7270de | |||
| e242e7d7fc | |||
| 35310b5517 | |||
| 622773f929 | |||
| 144d4cbb98 | |||
| 48f27b41e5 | |||
| 1ca4378d14 | |||
| 4bc488e940 | |||
| 8681040479 | |||
| fded2ee392 | |||
| 174dbae808 | |||
| 0f820c4569 | |||
| ed5e3f495d | |||
| deca81c2d7 | |||
| cb86065588 | |||
| ce87936f44 | |||
| dadfdcaa7e | |||
| e401491947 | |||
| d2030f9121 | |||
| 1e0c0ab90a | |||
| e07d025efd | |||
| 1e3c157603 | |||
| e5ee9dce0d | |||
| a907fea031 | |||
| 0802f7cf4c | |||
| 901cf98281 | |||
| 926fee9435 | |||
| 734b0ab5db | |||
| 896565087d |
@@ -0,0 +1,2 @@
|
||||
brain-v1.0
|
||||
sha: 52584df34e1a8b2117b3f254731b06f52d84645c
|
||||
@@ -139,3 +139,9 @@ app/infection-summary.log
|
||||
|
||||
# Plan 3 Task 5 — Playwright Node subprocess (~200MB chromium downloads on prod)
|
||||
app/playwright/node_modules/
|
||||
|
||||
# Superpowers using-git-worktrees — локальные worktrees вне репо
|
||||
.claude/worktrees/
|
||||
|
||||
# Vitest coverage output (app/coverage/) — генерируется npm run test:coverage
|
||||
/app/coverage/
|
||||
|
||||
@@ -87,6 +87,12 @@ paths = [
|
||||
'''app/composer\.lock''',
|
||||
# Pest-тесты с фиктивными data-фикстурами (не реальные ПДн)
|
||||
'''app/tests/.*\.php''',
|
||||
# Database seeders с демо-данными (admin@demo.local + +7916123XXXX демо-телефоны)
|
||||
'''app/database/seeders/.*\.php''',
|
||||
# Audit-internal docs (findings/blocked/report/plan) — содержат демо-телефоны и
|
||||
# script-смешанные artifacts как finding'и для review (не реальные ПДн)
|
||||
'''docs/superpowers/audits/.*\.md''',
|
||||
'''docs/superpowers/plans/.*\.md''',
|
||||
# Mock-данные для UI-разводки фронтенда (фиктивные имена/телефоны)
|
||||
'''app/resources/js/composables/mockDeals\.ts''',
|
||||
# Vitest-тесты с assertion на mock-данные (mock-телефоны из mockDeals)
|
||||
|
||||
+28
-2
@@ -53,7 +53,22 @@ exclude = [
|
||||
# web/v8/*.html — статические концепты, root-relative ссылки на будущие маршруты Vue
|
||||
"^/(login|register|legal|dashboard|deals|admin|reports|reminders|billing|impersonation|notifications)(/|$|\\?)",
|
||||
# Корневой `/` в концептах (логотип-якорь для будущей главной)
|
||||
"^/$"
|
||||
"^/$",
|
||||
# Plan 3 spec/plan files (commits 1a265b5 + 989256b) используют project-root-
|
||||
# relative ссылки на app/, db/, lefthook.yml, CLAUDE.md. GitHub UI рендерит
|
||||
# корректно; lychee local resolution relative к markdown file directory
|
||||
# ломается (resolved как docs/superpowers/specs/<path>). Links валидны
|
||||
# семантически, исключаем file://-URLs ведущие в несуществующий docs/-prefix.
|
||||
"^file://.*/docs/(app|db|lefthook|CLAUDE)",
|
||||
# Brain-extraction untracked files (другая session, project-root-relative
|
||||
# paths не resolve'ятся через relative из docs/superpowers/*/). Untracked
|
||||
# на disk — не входят в commits Plan 3. Исключаем broken file:// URLs.
|
||||
"^file://.*\\.claude/projects/.*/memory/",
|
||||
"^file:///C:/[^/]+/[^/]+/CLAUDE\\.md$",
|
||||
# Brain-extraction file имеет project-root-relative paths типа docs/architecture.md
|
||||
# которые resolve через docs/superpowers/plans/docs/... — broken для lychee.
|
||||
"^file://.*docs/superpowers/plans/docs/",
|
||||
"^file://.*docs/superpowers/plans/CLAUDE\\.md"
|
||||
]
|
||||
|
||||
# Игнорировать файлы вне аудита
|
||||
@@ -67,7 +82,18 @@ exclude_path = [
|
||||
# --root-dir, плюс маршруты появятся только после реализации в фазе 2+.
|
||||
# Линки внутри концептов проверяем визуально при ревью handoff'а.
|
||||
"liderra_v8_handoff/concepts",
|
||||
"web/v8"
|
||||
"web/v8",
|
||||
# Plan 3 spec + plan files используют project-root-relative paths
|
||||
# (intended для GitHub UI rendering); lychee resolution относительно
|
||||
# markdown file directory не работает. Links указывают на реальные
|
||||
# project files, проверяются визуально при ревью.
|
||||
"docs/superpowers/specs/2026-05-11-plan3-supplier-sync-design.md",
|
||||
"docs/superpowers/plans/2026-05-11-supplier-sync-plan3.md",
|
||||
# Brain-extraction files (другая session, untracked в working tree) —
|
||||
# имеют ссылки на `docs/...` relative из своей dir + memory/-paths
|
||||
# вне репозитория. Не относятся к Plan 3 push.
|
||||
"docs/superpowers/plans/2026-05-10-claude-brain-extraction.md",
|
||||
"docs/superpowers/specs/2026-05-10-claude-brain-extraction-design.md"
|
||||
]
|
||||
|
||||
# User-Agent — некоторые сайты режут пустой
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# CLAUDE.md — техконтекст Лидерры
|
||||
|
||||
**Версия:** 1.86 от 10.05.2026 (поздний вечер) — закрытие 13 находок третьего аудита правил использования плагинов и скилов (4 P0 + 5 P1 + 2 P2 + 2 sync-правки в README/README_АРХИВ). Через `/claude-md-management:claude-md-improver`. Ключевые правки: **P0-01 §3 header «Карта 28 инструментов» → «33 инструментов»** (header застрял с pre-FD эпохи, в то время как контент включает #1–#33); **P0-02 §3.4 header «(+5, итого 28)» → «итого 29»** (после добавления #30 в фазу 2 фаза 3 cumulative должна быть 29, не 28); **P0-03 §3.3 footer «из 30 номеров минус #1 = 29 active» → «из 33 номеров (29 phase-slot + 3 off-phase + 1 historic)»** (формулировка предшествовала формализации #31/#32/#33); **P0-04 §6 «Активно: 19 инструментов из 29» + «(19/29 активны по фазам)» → «24 / (24/29)»** (внутренний арифметический конфликт: тут же раскладка 9+8+7=24, но числовая метка застряла на 19 с эпохи когда фаза 2 имела ~4 активных); **P1-06 §5 п.5 «PSR_v1 v1.5+» → «v1.7+»** (sync после bump'а PSR_v1); **P2-02 §3.3 #33 «вне Pravila §13» → «вне UI-пула §13»** (Pravila §13.2 v1.10 включает claude-md-management как infrastructure subsection — текущая формулировка вводила в заблуждение). Связанные обновления: **PSR_v1 v1.6 → v1.7** (sync cross-refs шапки на v1.86/v1.10/v1.15; description-fix описки «slot уровня 2.5» → «2b» внутри changelog'а v1.6, фактическое R0.1 всегда содержало «2b»). **Tooling v1.14 → v1.15** (sync cross-refs шапки на v1.86/v1.10/v1.7; §11.5/§12 «28 инструментов» → «33 формализованные позиции»). Pravila v1.10 — без изменений. Предыдущая v1.85 — закрытие 15 находок второго аудита (детали в [docs/CHANGELOG_claude_md.md](docs/CHANGELOG_claude_md.md)).
|
||||
**Версия:** 1.91 от 13.05.2026 (day +1) — Session-end documentation hygiene после CTO-19 ✅ closure via Lucide migration. Содержание: (1) §0 cross-ref row Pravila v1.11 → **v1.12** (sync: §4.6 +visual smoke methodology для UI-refactor; §4.7 +п.4 plans/specs relative paths `../../../`); (2) §9 +v1.91 entry. Связано: реестр v1.82→v1.83 (CTO-19 closure в commit `0832997`); audit `docs/superpowers/audits/2026-05-12-portal-full-audit-findings.md` Q.INFO.001 +audit methodology gap note (Phase 4 SAST checks must begin с `ls .github/workflows/`); memory quirks 74-76 (Lucide+Histoire peerDep / Vuetify-internal mdi defaults gap / plans-relative-paths). Регрессия зелёная (verified в commit `0832997`): Pest --parallel 742/739/0/3 ✅, Vitest 88 files / 683 passed + 3 skipped, Vite build 3.52s, axe-core /admin/billing 0 iconography violations, lychee 252 OK / 0 errors, gitleaks 0 (372+ commits). Workflow learning: `superpowers:brainstorming` → `:writing-plans` → `:subagent-driven-development` efficient для mechanical UI-refactor (icon migration). Через `/claude-md-management:revise-claude-md`. **v1.90 наследие:** Merge R15 motion-runtime removal cleanup из `origin/main` в `plan5-frontend-projects` (commits `0fd93fd` planning + `615db99` нормативная правка). Plan5 ветка форкнулась 12.05 утром от `48f27b4` ДО появления `615db99` на main; после 113 атомарных коммитов на plan5 (audit fixes, Plan 5 frontend Tasks 7-11, Quiet Luxury portal redesign, Q.DEFER.002/003/004 closures, audit-cleanup tail) — merge синхронизирует R15 changes. **§5 п.12** → маркер «Резерв (снят 12.05.2026, см. CHANGELOG)» (нумерация п.1–11 сохранена, чтобы cross-refs в memory не сломать). **§2 строка «Animation default stack»** переписана с regulatory denylist на guidance recommendation (motion-v/gsap/anime.js/lottie-web/popmotion/@motionone/dom — ✅ разрешены без обоснования; framer-motion остаётся technical block — React-only peerDep, runtime crash в Vue, не regulatory rule). **§0 cross-refs** обновлены — Pravila v1.10 → **v1.11**, PSR_v1 v1.7 → **v2.0**, Tooling v1.15 → **v1.16**. **§6 фаза** + **§8 self-review** строки (Plan 4/5 + Quiet Luxury + Q.DEFER closures context + schema baseline v8.19 + dev-actual factual) — preserved из plan5 v1.88/v1.89 base. Plan5 v1.89 factual fix §6 (615db99 = R15 removal, ≠ Plan 4) подтверждён и сохранён. NB: §9 содержит **две v1.88 entries** — plan5 audit schema-sync + origin/main R15-removal — это collision версионной нумерации parallel-branch bump'ов; обе валидны исторически, явно labelled в §9. Files fast-forwarded без conflict: `Plugin_stack_rules_v1.md` (R15 удалён, 162 lines diff), `Pravila_raboty_Claude_v1_1.md` (§11.5/§13.2 счётчик 16→15 + cross-refs), `Tooling_v8_3.md` (§9.2 reformulated). Через ручное conflict resolution на 2 файлах (CLAUDE.md + CHANGELOG_claude_md.md) + post-merge `/claude-md-management:revise-claude-md` polish (per §5 п.10). **v1.89 наследие:** factual fix §6 + шапка v1.88 changelog (615db99 ≠ Plan 4). **v1.88 наследие (plan5 branch):** audit-driven sync §0/§2/§6/§8 после полного аудита портала. Schema-метрики §0/§2/§8 разделены на «commit baseline v8.19» (62/12/117/39/5/13/5) + «dev-actual factual» (75/102/289/39/5/19/0). **v1.88 наследие (origin/main):** снятие R15 motion-runtime restrictions per user decision 12.05.2026 («сними все запреты на использование framer motion»); conscious rollback v1.83 audited construction. **v1.87 наследие:** sync schema-метрик после Plan 4 (Billing+CSV+Admin) на ветке `plan4-billing`. Schema **v8.11 → v8.19**. Предыдущая v1.86 — закрытие 13 находок третьего аудита (детали в [docs/CHANGELOG_claude_md.md](docs/CHANGELOG_claude_md.md)).
|
||||
**Назначение:** оперативная карта для Claude Code. Не первоисточник — первоисточники указаны в §0.
|
||||
**Владелец и режим правок:** все изменения этого файла — **только** через плагин `claude-md-management` (skills `/claude-md-management:claude-md-improver` для audit/targeted-updates и `/claude-md-management:revise-claude-md` для capture session-learnings). Прямые правки запрещены — см. §5 п.11.
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
|
||||
| Тема | Документ |
|
||||
|---|---|
|
||||
| Продуктовые правила работы Claude | [docs/Pravila_raboty_Claude_v1_1.md](docs/Pravila_raboty_Claude_v1_1.md) (**v1.10 от 10.05.2026 вечер** — §0 +note про §11 локальное override-исключение над §2.2/§4.5/§8.4; §11.5 «10 правил» → «v1.6, 16»; §13.2 «v1.4 (15 правил)» → «v1.6 (16)»; §13.9/§13.10 PSR_v1 v1.4 → v1.6; v1.9 наследие — §12.3 SoT, §13.2 +claude-md-management off-pool, §13.6 hard-rule tier-структура) |
|
||||
| **Правила совместного использования плагинов Claude** | [docs/Plugin_stack_rules_v1.md](docs/Plugin_stack_rules_v1.md) (**v1.7 от 10.05.2026 поздний вечер** — sync cross-refs шапки на актуальные версии связанных документов после bump'ов CLAUDE.md v1.85 → v1.86 и Tooling v1.14 → v1.15; description-fix описки «slot уровня 2.5» → «slot уровня 2b» внутри changelog'а v1.6 (фактическое R0.1 line 33 всегда содержало «2b»); v1.6 наследие — R0.4.A свёрнут до cross-ref на Pravila §12.3 SoT, R0.6 пронумерован 1–11; v1.5 наследие — R10.1 разбит на 3 блока (enabledPlugins/built-in/MCP), R10.4/R14.7 tier-метки, R8 +тай-брейкер FD↔21st) |
|
||||
| Полный реестр 33 формализованных позиций тулчейна (29 active + 3 off-phase + 1 historic) | [docs/Tooling_v8_3.md](docs/Tooling_v8_3.md) (**Прил. Н v1.15 от 10.05.2026 поздний вечер** — sync cross-refs шапки на актуальные версии связанных документов после bump'ов CLAUDE.md v1.85 → v1.86 и PSR_v1 v1.6 → v1.7 («Pravila v1.9+» → «v1.10+», «PSR_v1 v1.5+» → «v1.7+», «CLAUDE.md v1.84+» → «v1.86+»); §11.5/§12 «28 инструментов» → «33 формализованные позиции» (DevOps-раздел застрял с эпохи v1.0 — фразы «не входят в 28», «вне 28»); v1.14 наследие — §10.3 шаг 2 «3 skills» → «14», §13 +v1.13 +v1.14 entries, §7 +Tooling explicit slot 2b alongside CLAUDE.md; v1.13 наследие — §7 +PSR_v1 уровнем 3, §4.7 +#33 claude-md-management, §6 +5 конфликтов v1.4, §4.6 settings → .claude.json, §0 счётчик 33) |
|
||||
| Продуктовые правила работы Claude | [docs/Pravila_raboty_Claude_v1_1.md](docs/Pravila_raboty_Claude_v1_1.md) (**v1.12 от 13.05.2026 day +1** — methodology additions: §4.6 +«Для UI-refactor (icon migration / palette swap / layout overhaul)» subsection (visual smoke verification обязательна, unit tests jsdom недостаточны, Vuetify-internal default mdi-* gap learning от CTO-19); §4.7 +п.4 plans/specs относительные пути `../../../<target>` (lychee catches broken paths, прецедент CTO-19 fixup `f6e1e64`). v1.11 наследие — sync после PSR_v1 v2.0 (R15 снят): §11.5/§13.2 счётчик 16→15 правил R0–R14; §13.9/§13.10 cross-ref v1.6→v2.0; §13.10 НЕ удалено — про R14, не R15. v1.10 наследие — §0 +note про §11 override) |
|
||||
| **Правила совместного использования плагинов Claude** | [docs/Plugin_stack_rules_v1.md](docs/Plugin_stack_rules_v1.md) (**v2.0 от 12.05.2026** — major bump: removal of R15 motion-runtime restrictions per user decision; conscious rollback v1.4 audited construction. Удалено: R15 целиком (R15.1–R15.7), R0.6 п.11, R8 motion тай-брейкеры (3), R11.6 motion иерархия, R13 motion-сценарии (5). Шапка count: «16 правил R0–R15» → «15 правил R0–R14». framer-motion переведён из regulatory hard-запрета в technical-guidance уровень: peerDep на react+react-dom, не работает в Vue физически; v1.7 наследие — sync cross-refs; v1.6 наследие — R0.4.A свёрнут до cross-ref на Pravila §12.3 SoT, R0.6 пронумерован 1–11) |
|
||||
| Полный реестр 33 формализованных позиций тулчейна (29 active + 3 off-phase + 1 historic) | [docs/Tooling_v8_3.md](docs/Tooling_v8_3.md) (**Прил. Н v1.16 от 12.05.2026** — §9.2 «Motion runtime библиотеки» переформулирован из regulatory denylist в technical guidance синхронно с PSR_v1 v2.0 (R15 снят): motion-v/gsap/anime.js/lottie-web/popmotion/@motionone/dom — ✅ разрешено без обоснования; framer-motion + react-spring — ❌ technical block (React-only peerDep), не regulatory rule. Cross-refs шапки sync: PSR_v1 v1.7+ → v2.0+, CLAUDE.md v1.86+ → v1.88+, Pravila v1.10+ → v1.11+; v1.15 наследие — sync cross-refs + «28 инструментов» → «33 формализованные позиции») |
|
||||
| Главное ТЗ | [docs/CRM_bp-gr_Инструкция_v8_5.md](docs/CRM_bp-gr_Инструкция_v8_5.md) (v8.5 от 07.05.2026 — реализация 27 решений аудита C; in-place hygiene v1.20 от 08.05.2026 поздний вечер: §2.4/§5.5/§5.6/§6.5/§11/§20.12.3/§21.1/§27.1 синхронизированы под schema v8.6 двустадийный dedup) |
|
||||
| Схема БД | [db/schema.sql](db/schema.sql) (**v8.11 от 09.05.2026** — Sprint 1 Phase A: RLS на `impersonation_tokens` + 2 missing FK indices (audit P0-02 + O-perf-02/03). Метрики: 56 базовых таблиц + 12 партиций + 97 индексов + 38 RLS + 5 функций + 13 триггеров) |
|
||||
| Открытые вопросы | [docs/Открытые_вопросы_v8_3.md](docs/Открытые_вопросы_v8_3.md) (**v1.75 от 09.05.2026 — Post-MVP Reports backend закрыт** (4 этапа); MVP по Claude-зоне закрыт в v1.74; финал-метрики Pest 403/403 + Vitest 393/393 + Histoire 21/43) |
|
||||
| Схема БД | [db/schema.sql](db/schema.sql) (**v8.19 от 11.05.2026** — Plan 4 (Billing+CSV+Admin): +1 таблица `supplier_csv_reconcile_log` SaaS-level, +3 колонки `tenants.delivered_in_month` / `lead_charges.charge_source` / `supplier_leads.recovered_from_csv_at`, +3 индекса, +2 CHECK. **Schema baseline (commit-факт):** 62 базовые таблицы + 12 партиций + 117 индексов + 39 RLS-политик + 5 функций (`audit_block_mutation`, `audit_chain_hash`, `calc_lead_score`, `report_jobs_log_export`, `set_pd_subject_request_deadline`) + 13 триггеров. **Dev `liderra` factual** после `migrate:fresh` + накопленных `partitions:create-months`: **75 root tables + 102 partition children + 289 indexes + 39 RLS + 5 user funcs + 19 triggers + 0 dev roles** (на prod 5 ролей через `db/00_create_roles.sql`). Verified 2026-05-12 audit Phase 3.) |
|
||||
| Открытые вопросы | [docs/Открытые_вопросы_v8_3.md](docs/Открытые_вопросы_v8_3.md) (**v1.83 от 13.05.2026 (day +1) — CTO-19 ✅ closed** через Lucide migration: `npm i lucide-vue-next ^1.0.0` + custom Vuetify `IconSet` в `app/resources/js/plugins/vuetify.ts` с 103-entry mapping (78 user-grep'нутых mdi-* + 25 Vuetify-internal defaults). 51 view untouched. CLAUDE.md §2 «Иконки: Lucide» бренд-spec compliance achieved. **Сводка §0 после v1.83: 87 продуктовых / 71 ✅ / 5 🟦 / 11 ⏸ / 1 P0 + 5 P1 + 3 P2 + 2 P3**. **Регрессия: Pest --parallel 742/739/0/3 / Vitest 88 files / 683 passed + 3 skipped / Vite build 3.52s / axe-core /admin/billing 0 iconography violations**. Spec/plan в docs/superpowers/. v1.82 — Catch-up bump v1.77 → v1.82. v1.77 — Sprint 4 «Audit tail» (Pest 421 / Vitest 416). Section ## 13 collision fixed: Plan 4 → ## 14, Аудит C ## 13) |
|
||||
| **Брендбук** | [liderra_v8_handoff/docs/BRANDBOOK_v2.md](liderra_v8_handoff/docs/BRANDBOOK_v2.md) **(v2 Forest от 07.05.2026)** — старый `docs/brandbook.md` v1.1 удалён 08.05.2026 |
|
||||
| **Дизайн-handoff (токены, компоненты, 25 экранов)** | [liderra_v8_handoff/docs/DEVELOPER_HANDOFF.md](liderra_v8_handoff/docs/DEVELOPER_HANDOFF.md) (v8 Forest от 07.05.2026) — **только дизайн/токены/компоненты**; функционал и состав экранов — по ТЗ v8.5 |
|
||||
| Анализ оригинала | [docs/Analiz_originala_v8_3.md](docs/Analiz_originala_v8_3.md) (Прил. М v1.1) |
|
||||
@@ -64,7 +64,7 @@
|
||||
|---|---|
|
||||
| Backend | PHP 8.3 + **Laravel 13** (мажор переоткрыт 08.05.2026 поздний вечер: при `composer create-project laravel/laravel` без `^11` Composer подтянул свежайшую 13.7; live-проверка совместимости — Boost v2.4.6, Larastan v3.9.6, Pest v4.7.0, IDE Helper v3.7.0, Pint v1.29 работают; принято заказчиком) |
|
||||
| Frontend | Vue 3 + **Vuetify 3** (НЕ Tailwind, НЕ Inertia, НЕ Livewire, НЕ Filament) |
|
||||
| БД | PostgreSQL 16 (**56 базовых таблиц + 12 партиций, 97 индексов, 38 RLS-политик, 4 роли БД, 13 триггеров, 5 функций** — schema v8.11 от 09.05.2026; backend multi-tenant фундамент развернут на dev `liderra` через `php artisan migrate:fresh`) |
|
||||
| БД | PostgreSQL 16. **Schema baseline (v8.19 commit-факт):** 62 базовые таблицы + 12 партиций, 117 индексов, 39 RLS-политик, 5 ролей БД, 13 триггеров, 5 user-функций. **Dev `liderra` factual** (после `migrate:fresh` + накопленных partition'ов от `partitions:create-months`): **75 root tables + 102 partition children, 289 indexes, 39 RLS, 5 user funcs, 19 triggers, 0 dev roles** (на prod 5 ролей через `db/00_create_roles.sql`). 5-я роль `crm_supplier_worker` BYPASSRLS введена в Plan 3 для sharing-flow + используется Plan 4 ResetMonthlyCountersCommand + CsvReconcileJob. Audit-verified 2026-05-12 (Phase 3). |
|
||||
| Кэш / очереди | Redis 7 |
|
||||
| Pooler | PgBouncer (transaction pooling) |
|
||||
| Облако | Yandex Cloud, регион `ru-central1` (Москва) |
|
||||
@@ -73,7 +73,7 @@
|
||||
| Sentry | self-hosted в Yandex Cloud |
|
||||
| Helpdesk | JivoSite |
|
||||
|
||||
**Шрифты:** Inter (UI, axis `opsz` 14..32), JetBrains Mono (numerics с `tnum`, код). **Иконки:** Lucide. **Палитра v8 Forest:** Teal `#0F6E56` (primary, неоспариваемый), `#F6F3EC` warm ivory (page bg), `#012019` теало-нуар (sidebar). 14 OKLCH-статусов в [BRANDBOOK_v2 §3.6](liderra_v8_handoff/docs/BRANDBOOK_v2.md) — **палитра используется**, но мапить на 14 slug'ов из [db/schema.sql:2076](db/schema.sql#L2076) (источник истины для статусов воронки — schema/ТЗ §6.4, не handoff). **A11y:** WCAG 2.1 AA. **Animation default stack** (R11.6 + R15 PSR_v1): Vue native `<Transition>` / `<TransitionGroup>` + Vuetify transitions (`v-fade`, `v-slide-y`, `v-scale`, `v-expand`, `v-dialog-transition`) + CSS `@keyframes` + `prefers-reduced-motion` + View Transitions API (Chrome 111+ / Safari 18+). motion-v / framer-motion / gsap / anime.js / lottie-web — **не установлены** и условно разрешены только по R15.2 (4 триггера), см. §5 п.12.
|
||||
**Шрифты:** Inter (UI, axis `opsz` 14..32), JetBrains Mono (numerics с `tnum`, код). **Иконки:** Lucide. **Палитра v8 Forest:** Teal `#0F6E56` (primary, неоспариваемый), `#F6F3EC` warm ivory (page bg), `#012019` теало-нуар (sidebar). 14 OKLCH-статусов в [BRANDBOOK_v2 §3.6](liderra_v8_handoff/docs/BRANDBOOK_v2.md) — **палитра используется**, но мапить на 14 slug'ов из [db/schema.sql:2076](db/schema.sql#L2076) (источник истины для статусов воронки — schema/ТЗ §6.4, не handoff). **A11y:** WCAG 2.1 AA. **Animation default stack (рекомендация, не hard-rule с v1.88):** Vue native `<Transition>` / `<TransitionGroup>` + Vuetify transitions (`v-fade`, `v-slide-y`, `v-scale`, `v-expand`, `v-dialog-transition`) + CSS `@keyframes` + `prefers-reduced-motion` + View Transitions API (Chrome 111+ / Safari 18+). Motion-runtime библиотеки (`motion-v`, `gsap`, `anime.js`, `lottie-web`, `popmotion`, `@motionone/dom`) — разрешены к установке без обоснования. `framer-motion` — **technical block** (React-only peerDep на `react+react-dom`, runtime crash в Vue физически), не regulatory rule — см. [Tooling §9.2](docs/Tooling_v8_3.md) technical guidance.
|
||||
|
||||
---
|
||||
|
||||
@@ -184,7 +184,7 @@ trivy image liderra:latest
|
||||
2. **Не использовать Inertia / Livewire / Tailwind / Filament / Flux UI / Nova / Folio / Volt / Wayfinder guidelines** Boost'а — у нас Vue + Vuetify.
|
||||
3. **Не запускать a11y через Lighthouse** — единственный источник истины Pa11y.
|
||||
4. **Не помещать ПДн / токены / API-ключи в коммиты.** Правило §5.2 правил Claude. Защита — gitleaks в pre-commit.
|
||||
5. **Расширенный пул UI-инструментов — координируется через [PSR_v1](docs/Plugin_stack_rules_v1.md) v1.7+.** Кратко: paired-stack ядро (**Superpowers** = процесс / **Frontend Design** = решатель UI), плюс два инструмента **в роли материала, не решателя**: UPM (резерв-библиотека, R10.1/R11.5/R14.3) и 21st Magic MCP (генератор шаблонов, R10.1/R14.4). Все четыре проходят **R6.0 фильтр стека** (срезать React/Tailwind/shadcn/JSX → Vue 3 + Vuetify 3) и **R6.1 hard-override Forest** (палитра/шрифты/иконки/aesthetic — Brandbook, не плагины). UPM и 21st **не параллельно** с FD и друг с другом (R14.5). **A11y технический** — за Pa11y (п.3); плагины покрывают только a11y-принципы. **Детали — PSR_v1 R6/R10/R11/R14** (не копировать сюда — оперативная карта остаётся компактной).
|
||||
5. **Расширенный пул UI-инструментов — координируется через [PSR_v1](docs/Plugin_stack_rules_v1.md) v2.0+.** Кратко: paired-stack ядро (**Superpowers** = процесс / **Frontend Design** = решатель UI), плюс два инструмента **в роли материала, не решателя**: UPM (резерв-библиотека, R10.1/R11.5/R14.3) и 21st Magic MCP (генератор шаблонов, R10.1/R14.4). Все четыре проходят **R6.0 фильтр стека** (срезать React/Tailwind/shadcn/JSX → Vue 3 + Vuetify 3) и **R6.1 hard-override Forest** (палитра/шрифты/иконки/aesthetic — Brandbook, не плагины). UPM и 21st **не параллельно** с FD и друг с другом (R14.5). **A11y технический** — за Pa11y (п.3); плагины покрывают только a11y-принципы. **Детали — PSR_v1 R6/R10/R11/R14** (не копировать сюда — оперативная карта остаётся компактной).
|
||||
6. **Не ставить два инструмента на одну задачу** — список 10+ запрещённых дублей в [docs/Tooling_v8_3.md](docs/Tooling_v8_3.md) §9.
|
||||
7. **Не редактировать этот `CLAUDE.md` без обновления** [docs/Pravila_raboty_Claude_v1_1.md](docs/Pravila_raboty_Claude_v1_1.md) и [docs/Tooling_v8_3.md](docs/Tooling_v8_3.md) — иначе три источника разойдутся (применяется ВНУТРИ flow п.10; пропуск синхронизации — отдельная ошибка даже при работающем плагине).
|
||||
8. **Не править `db/schema.sql`** без записи в [db/CHANGELOG_schema.md](db/CHANGELOG_schema.md) — правило §4.2 правил Claude.
|
||||
@@ -196,7 +196,7 @@ trivy image liderra:latest
|
||||
Плагин — **единственный** интерфейс ведения файла; он отвечает за содержание и качество (по `references/quality-criteria.md` плагина: commands/architecture/non-obvious patterns/conciseness/currency/actionability). Прямые `Edit`/`Write` по `CLAUDE.md` без вызова skill'а — нарушение, фиксировать в feedback. Внутри flow плагина продолжают действовать пп.7 (синхронизация Pravila + Tooling) и общие §4 правил Claude.
|
||||
11. **Не пропускать инвокацию Superpowers skill'а** для задачи, попадающей под карту §12.2 правил Claude (TDD, debug, plan, parallel, review, verify, brainstorm, worktree, finishing PR, subagent, writing-skills). Это **hard rule** (§12 правил Claude), §9 «Отступления» к нему **не применяется**. Рационализация типа «эта задача проще, чем требует skill» / «сейчас быстрее без skill'а» — нарушение того же уровня, что игнорирование §5 ПДн. **Список exclusions — Pravila §12.3 (Single Source of Truth, v1.9+)**: при расширении правок здесь — править только Pravila §12.3, не дублировать список текстом сюда. Запрос заказчика «не используй superpowers сейчас» — единственная отмена, и **только** на текущее действие. См. Pravila §12.4.
|
||||
|
||||
12. **Не устанавливать motion runtime библиотеки без прохождения R15.2 PSR_v1** (4 условия триггера) — `framer-motion` (R15.1 hard-запрет навсегда: React-only архитектурно, не работает в Vue), `react-spring` (R15.1-аналог: тоже React-only), `motion-v` (R15.2: Vue 3 порт framer-motion, условно по 4 триггерам), `gsap`, `anime.js`, `react-spring`, `lottie-web`, `popmotion`, `@motionone/dom` (R15.7: аналогично). **Default motion stack** — Vue native `<Transition>` / `<TransitionGroup>` + Vuetify transitions + CSS `@keyframes` + View Transitions API (R11.6 уровни 3–6). Большинство задач закрываются на уровнях 1–2. Установка любой animation runtime библиотеки в `package.json` — **R0.6 пункт 11 hard-стоп**, Auto mode не отменяет. Подробности — PSR_v1 v1.6 R15 + Tooling Прил. Н v1.14 §9.2.
|
||||
12. **Резерв.** Был «не устанавливать motion runtime библиотеки без прохождения R15.2 PSR_v1». Снят 12.05.2026 (CLAUDE.md v1.88 + PSR_v1 v2.0). Motion-runtime библиотеки разрешены без обоснования; `framer-motion` остаётся technical block (React-only peerDep). Подробности — [docs/CHANGELOG_claude_md.md](docs/CHANGELOG_claude_md.md) запись v1.88, [docs/Tooling_v8_3.md](docs/Tooling_v8_3.md) §9.2.
|
||||
|
||||
---
|
||||
|
||||
@@ -205,7 +205,9 @@ trivy image liderra:latest
|
||||
**Post-MVP (10.05.2026).** Фазы 0/1/2 по тулчейну закрыты (24/29 активны по фазам после установки Frontend Design plugin v1.78). **+3 off-phase tools формализованы 10.05.2026:** #31 UI UX Pro Max (skill, резерв-библиотека UI, формализован v1.83), #32 21st.dev Magic MCP (генератор шаблонов UI, формализован v1.83), **#33 claude-md-management** (skills, инфраструктура CLAUDE.md edits — формализован в v1.84 после audit находки «5-й включённый плагин без номера в реестре»). UPM + 21st активируются только через PSR_v1 v1.6 R14 pipeline; claude-md-management — обязательный канал правок CLAUDE.md (§5 п.10), регулируется PSR_v1 R10.1 блок 1. Итого формализованных позиций тулчейна: **33** (19/29 активных по фазам + 3 off-phase + 1 заменённый PG MCP исторически — слот #1, заменён #10 Boost в фазе 1, формально остаётся в реестре). Без «+1 historic» арифметика «33» не сходится — это правка v1.85. MVP Claude-зоны закрыт в v1.72; затем закрыт Reports backend epic (v1.73, 4 этапа `19f319c..e0ffe7e`). 13/13 экранов handoff покрыты UI + 3 ErrorView (404/403/500); landing ⏸ Б-1. Backend: auth (login/register/2FA/recovery/forgot/reset) + deals API (index/show/store/update/transition/destroy/restore/export-CSV+XLSX) + 3 lookup-API + reminders CRUD + cron + in_app_notifications + bell-UI polling + notification-preferences PATCH + admin (tenants/billing/incidents/system) + impersonation + webhook receive (HMAC + per-token rate-limit). Все 8 schema-default событий уведомлений интегрированы. **Pest 403/403, Vitest 393/393, Histoire 21/43.**
|
||||
|
||||
- Активно: **24 инструмента из 29 phase-slot** — 9 из фазы 0 (см. §3.1) + 8 из фазы 1: **#10 Boost v2.4.6**, **#11 Pint v1.29**, **#12 Larastan v3.9.6**, **#13 Roave/SecurityAdvisories**, **#14 IDE Helper v3.7.0**, **#15 squawk v2.51.0**, **#16 pgFormatter v5.9**, **#18 Pest v4.7.0** + 7 из фазы 2 (закрыта по тулчейну, см. ниже): #19 Superpowers + #20–24 + **#30 Frontend Design plugin** (paired stack). 9+8+7=24. Off-phase tools (#31 UPM + #32 21st + #33 claude-md-management) — также активны в `~/.claude/settings.json`/`~/.claude.json`, но регулируются отдельной механикой R10/R14 PSR_v1 / §5 п.10 (не входят в фазовую раскладку).
|
||||
- Готово в фазе 1: Laravel 13.7 в `app/`, predis 3.4.2, **schema.sql v8.6 развёрнута через `migrate:fresh` (870 ms, 1 миграция `load_initial_schema.php` — raw SQL через `DB::unprepared(file_get_contents(...))`)**, 3 default Laravel-миграции удалены (users/cache/jobs дублировались с нашей schema), smoke-test'ы (**Pest 19/19 за 1711 ms** — 4 RLS smoke + 8 model smoke + 5 middleware + 2 default; Pint passed, PHPStan analyse passed с baseline, ide-helper:generate OK + ide-helper:models -W -M -N для @mixin IdeHelper*, squawk 0 issues с конфигом, pgFormatter dry-run OK), MCP-сервер `boost:mcp` через Roster auto-detect (9 tools, JSON-RPC 2024-11-05). **Eloquent-модели**: `Tenant`, `User`, `Project` (+ factories) — `User` переписан под нашу схему (`password_hash` вместо `password`, override `getAuthPassword()`), Soft Deletes на Tenant + User. **Middleware `SetTenantContext`** (alias `tenant`): резолюция tenant_id из `auth()->user()`, subdomain или `X-Tenant-Id` header → `SET LOCAL app.current_tenant_id` в обёртке транзакции (PgBouncer-safe). **Deployment-скрипты ролей БД** для production: `db/00_create_roles.sql`, `db/02_grants.sql`. На dev — `postgres` superuser. **CTO-13 RLS smoke-test реализован**: `tests/Feature/RlsSmokeTest.php` + `TenantModelsTest.php` + `SetTenantContextTest.php`.
|
||||
- **Plan 4 (Billing + CSV Reconcile + Admin) MERGED в `origin/main`** — Plan 4 closure marker `8681040` («docs: Plan 4 closure — CLAUDE.md v1.87 + Открытые_вопросы v1.78»); backend task-коммиты `a907fea..174dbae` (Tasks 9-11) merged ранее. **Post-Plan-4 на origin/main отдельно подъехала R15 motion-runtime removal история:** `0fd93fd` (design+plan) + `615db99` («chore(rules): remove R15 motion-runtime restrictions (PSR_v1 v2.0)») — НЕ часть Plan 4. Schema v8.18 → v8.19 (новая таблица `supplier_csv_reconcile_log`, +3 колонки, +3 индекса, +2 CHECK). Активирован 7-ступенчатый pricing-tier биллинг (`PricingTierResolver` pure + `LedgerService` dual-balance prepaid→rub через bcmath); `CsvReconcileJob` hourly с drift>5% алертом; auto-pause flow `ZeroBalancePausedMail` 1/час/tenant; 3 UI экрана (`AdminPricingTiersView` + `AdminSupplierPricesView` + `ChargesTab` в `BillingView`).
|
||||
- **Plan 5 frontend (Tasks 7-11) + Quiet Luxury portal redesign (20 commits) + dev-indices (10 commits) в ветке `plan5-frontend-projects`** (85+ commits ahead of `origin/main` на 12.05.2026 после audit-fix-серии): backend ProjectController 8 методов + schema v8.20 (post-merge) + 41 Pest; frontend 6 commits + Vitest delta +25 specs; Quiet Luxury foundation CSS (tokens/typography/motion) + 3 composables + 4 UI primitives + AppSidebar rewrite + 4 view applications; DevIndexBadge temporary feedback feature. **Post-merge factual baseline 12.05.2026:** Pest 742 / Vitest 614 + 3 skipped / Histoire 35 stories / 63 variants / Vite build 1.80s / 0 lychee broken / 0 gitleaks. +7 новых **Биз-25..31** в реестре (Plan 4). Drive-by closure: Plan 1 deferred WARNING #7 (SupplierProjectFactory random race) — fixed в Task 10 `0f820c4`.
|
||||
- Готово в фазе 1: Laravel 13.7 в `app/`, predis 3.4.2, **schema.sql v8.19 развёрнута через `migrate:fresh` (871 ms, 1 миграция `load_initial_schema.php` — raw SQL через `DB::unprepared(file_get_contents(...))`)**, 3 default Laravel-миграции удалены (users/cache/jobs дублировались с нашей schema), smoke-test'ы (**Pest 19/19 за 1711 ms** — 4 RLS smoke + 8 model smoke + 5 middleware + 2 default; Pint passed, PHPStan analyse passed с baseline, ide-helper:generate OK + ide-helper:models -W -M -N для @mixin IdeHelper*, squawk 0 issues с конфигом, pgFormatter dry-run OK), MCP-сервер `boost:mcp` через Roster auto-detect (9 tools, JSON-RPC 2024-11-05). **Eloquent-модели**: `Tenant`, `User`, `Project` (+ factories) — `User` переписан под нашу схему (`password_hash` вместо `password`, override `getAuthPassword()`), Soft Deletes на Tenant + User. **Middleware `SetTenantContext`** (alias `tenant`): резолюция tenant_id из `auth()->user()`, subdomain или `X-Tenant-Id` header → `SET LOCAL app.current_tenant_id` в обёртке транзакции (PgBouncer-safe). **Deployment-скрипты ролей БД** для production: `db/00_create_roles.sql`, `db/02_grants.sql`. На dev — `postgres` superuser. **CTO-13 RLS smoke-test реализован**: `tests/Feature/RlsSmokeTest.php` + `TenantModelsTest.php` + `SetTenantContextTest.php`.
|
||||
- Артефакты фазы 0 без изменений: 17 файлов архива (narrative v8.5 финал 07.05.2026), **13 концептов v8 Forest в [web/v8/](web/v8/)**.
|
||||
- **Стек dev**: native Windows. PostgreSQL 16 (Chocolatey, Windows-сервис) + Memurai Developer (Redis 7-совместимый, Windows-сервис) + native PHP 8.3 + Composer. **Без Docker, без WSL2** — машина OpenStack-VPS не пробрасывает nested virtualization. Подробности — `memory/project_phase1_strategy.md`.
|
||||
- **Не применимо**: **#17 pg_partman** заменён ручным cron'ом — на native Windows-PG расширение недоступно (см. project_phase1_strategy). Pre-commit хуки для Pint/Larastan/squawk — в `lefthook.yml` (jobs 5/6/7).
|
||||
@@ -240,7 +242,7 @@ trivy image liderra:latest
|
||||
|
||||
| Файл | Что проверять |
|
||||
|---|---|
|
||||
| `db/schema.sql` | 0 orphan-FK, целостность RLS, метрики сверять с текущей версией (v8.11 = 56 базовых таблиц + 12 партиций + 97 индексов + 38 RLS-политик + 5 функций + 13 триггеров), 0 дубликатов `CREATE TABLE` |
|
||||
| `db/schema.sql` | 0 orphan-FK, целостность RLS, метрики сверять с **schema baseline v8.19** (62 базовые таблицы + 12 партиций + 117 индексов + 39 RLS-политик + 5 функций + 13 триггеров) ИЛИ с **dev-actual фактом** (75 + 102 + 289 + 39 + 5 + 19 — varies от partition accumulation, audit-verified 2026-05-12), 0 дубликатов `CREATE TABLE` |
|
||||
| narrative `.md` | Версии в шапке/колонтитуле, 0 «готовится»/«TBD», кросс-ссылки на актуальные имена файлов |
|
||||
| Прил. А–Н | Версия совпадает с narrative; все упомянутые подразделы существуют |
|
||||
| Прил. Н (этот реестр инструментов) | Ровно 29 в активном наборе; 0 дублей; синхронность с этим CLAUDE.md |
|
||||
@@ -253,6 +255,13 @@ trivy image liderra:latest
|
||||
|
||||
Полная история — [docs/CHANGELOG_claude_md.md](docs/CHANGELOG_claude_md.md) (вынесена 09.05.2026 при правке v1.73→v1.74 ради лаконичности шапки). Здесь — последние правки:
|
||||
|
||||
- **v1.91 от 13.05.2026 (day +1)** — Session-end documentation hygiene после CTO-19 ✅ closure via Lucide migration. **§0 row Pravila** bumped v1.11 → v1.12 (methodology additions: §4.6 +UI-refactor visual smoke; §4.7 +п.4 plans/specs relative paths). **Связано:** реестр v1.82→v1.83 (CTO-19 closure в commit `0832997`, `f6e1e64` link fixup); audit `findings.md` Q.INFO.001 +audit methodology gap note (Phase 4 SAST coverage check must begin с `ls .github/workflows/` — пропустил `.github/workflows/sast.yml` 12.05.2026); memory quirks 74-76 (Lucide+Histoire `--legacy-peer-deps` / Vuetify-internal default mdi-* gap / plans-relative-paths `../../../`). **Без изменений:** §0 cross-refs PSR_v1 v2.0 / Tooling v1.16 / реестр v1.83 (актуальные); §2-§8 контент invariant; код / schema / migrations / тесты — нетронуты. Регрессия (фактическая, не verified в этом bump'е — verified в предыдущем commit `0832997`): Pest --parallel 742/739/0/3, Vitest 88 files / 683 / 3 skipped, Vite build 3.52s, axe-core 0 iconography violations. **Через:** `superpowers:brainstorming` (F-option scope clarification) → `:writing-plans` → `/claude-md-management:revise-claude-md` (для этого CLAUDE.md bump per §5 п.10) + ручные Edit (Pravila §4.6/§4.7 + audit findings.md). Workflow learning (capture для future sessions): для mechanical UI-refactor пайплайн brainstorming → writing-plans → subagent-driven-development efficient (CTO-19 case).
|
||||
- **v1.90 от 13.05.2026 (day)** — Merge R15 motion-runtime removal cleanup из `origin/main` в `plan5-frontend-projects`. Merge-base `48f27b4`; plan5 был 113 ahead / 2 behind. Origin/main за этот период получила 2 коммита: `0fd93fd` (planning artefacts spec+plan, +2 files) + `615db99` (нормативная правка 5 файлов: PSR_v1 v1.7→v2.0, Pravila v1.10→v1.11, Tooling v1.15→v1.16, CLAUDE.md v1.87→v1.88, CHANGELOG entry). `git merge-tree` показал ровно 2 conflict'а: CLAUDE.md (шапка version + §9 entries) и CHANGELOG_claude_md.md (entries). Остальные 3 нормативных файла fast-forward без conflict'а (plan5 не редактировал их после fork). **Конфликт-resolution:** шапка → v1.90 unified; §0 cross-refs → take origin/main (Pravila v1.11 / PSR_v1 v2.0 / Tooling v1.16); §2 Animation default stack → take origin/main (motion-runtime guidance); §5 п.12 → take origin/main (marker «Резерв (снят 12.05.2026)»); §6 фаза + §8 self-review → keep plan5 (Plan 4 MERGED + Plan 5 frontend + Quiet Luxury context); §9 история версий → keep both v1.88 entries explicitly labelled (plan5 audit schema-sync + origin/main R15 removal — distinct concerns, version-number collision result of parallel-branch bump'ов), plus v1.89 plan5 factual fix + new v1.90 merge entry. **Через ручное conflict resolution + post-merge `/claude-md-management:revise-claude-md` polish (per §5 п.10).** Memory updates после push: `feedback_plugin_paired_stack.md` (remove branch-divergent note + bump tier-структуру к v2.0), `project_state.md` (branch counters), `reference_archive.md` (file version refs).
|
||||
- **v1.89 от 12.05.2026 (ночь, post-audit continuation)** — factual fix §6 + шапка v1.88 changelog: коммит `615db99` ошибочно представлен как Plan 4 merge (фактически `615db99` это R15 motion-runtime removal commit «chore(rules): remove R15 motion-runtime restrictions (PSR_v1 v2.0)»; правильный Plan 4 closure marker на origin/main — `8681040` «docs: Plan 4 closure — CLAUDE.md v1.87 + Открытые_вопросы v1.78», backend task-коммиты Plan 4 `a907fea..174dbae` (Tasks 9-11) merged ранее). Дополнительно: коммит `f4ec5dc` («fix(redesign): sidebar position:fixed + main padding-left — restore main content visibility» — Quiet Luxury hotfix на ветке `plan5-frontend-projects`) ошибочно представлен в v1.88 §6 как PSR_v1 R15 removal — убран из §6 формулировки (Quiet Luxury hotfix не связан с R15 motion-runtime removal и не находится на origin/main). Связанные документы НЕ требуют изменений: Pravila v1.10 / PSR_v1 v1.7 / Tooling v1.15 / реестр v1.77 на ветке `plan5-frontend-projects` остаются как есть; фактологический фикс локален в CLAUDE.md. Verified через `git show 615db99 --stat` (subject «chore(rules): remove R15 motion-runtime restrictions (PSR_v1 v2.0)») + `git show 8681040` (subject «docs: Plan 4 closure — CLAUDE.md v1.87 + Открытые_вопросы v1.78») + `git show f4ec5dc` (subject «fix(redesign): sidebar position:fixed + main padding-left — restore main content visibility»). Заказчик: «доделывать аудит, поправить ошибку в CLAUDE.md». Через `/claude-md-management:claude-md-improver`. *(NB v1.90 post-merge: связанные документы Pravila/PSR_v1/Tooling всё-таки обновились — но не из-за фактологического фикса плана5, а из-за подтянутого R15 removal из origin/main. Этот NB не отменяет v1.89 logic — он добавляет post-merge context.)*
|
||||
- **v1.88 от 12.05.2026 (ночь) — plan5 branch (audit schema-sync)** — audit-driven sync §0/§2/§6/§8 после полного аудита портала (`docs/superpowers/audits/2026-05-12-portal-full-audit-*.md`). Заказчик: «проведи полный аудит всего портала ... исправь все что сможешь в моё отсутствие». Через `/claude-md-management:revise-claude-md`. **Ключевые правки:** **§0 row «Схема БД»** — добавлено «schema baseline v8.19» metrics + «dev-actual factual» 75/102/289/39/5/19/0 (после `migrate:fresh` + накопленных `partitions:create-months`), 5 user-функций перечислены поимённо (audit_block_mutation, audit_chain_hash, calc_lead_score, report_jobs_log_export, set_pd_subject_request_deadline). **§0 row «Открытые_вопросы»** — v1.75 → v1.77 (Sprint 4 Audit tail close); добавлено note о post-v1.77 deviation (Plan 4/5 + Quiet Luxury merged без registry bump). **§2 row «БД»** — аналогично §0 schema-row, baseline + factual split. **§6 фаза** — «Plan 4 ready for FF-merge» → «Plan 4 MERGED в origin/main `8681040`» + новый параграф про Plan 5 frontend Tasks 7-11 + Quiet Luxury portal redesign + dev-indices в `plan5-frontend-projects` ветке (85+ commits ahead). *(NB v1.89: исходная v1.88 формулировка указывала `615db99` для Plan 4 merge — factual error, по факту `615db99` это R15 motion-runtime removal commit; исправлено post-audit в v1.89.)* **§8 self-review row** — добавлено разделение «baseline ИЛИ dev-actual». **Audit-fixes batch** (commits `3a8229a..audit-final`): Histoire build broken (P0 BulkActionsBar.story Pinia) fixed → 35 stories / 63 variants build OK; vue-tsc 9 errors fixed (AppSidebar NavItem.countKey + Project type unify); ESLint 17 errors fixed (test mocks any → unknown + vitest/no-disabled-tests cleanup + unused beforeEach); Prettier --write 37 files; markdownlint --fix 165 → 1 left (untracked design.md); cspell +79 words в `cspell-words.txt` 187 → 18 issues; routes/web.php +explicit Route::view для `/projects, /reminders, /admin/*`. **Регрессии:** 0. Final factual baseline: Pest 742 / Vitest 614 + 3 skipped / vue-tsc 0 / ESLint 0 / markdownlint 1 (untracked) / cspell 18 (mixed-script artifacts) / lychee 0 broken / gitleaks 0.
|
||||
- **v1.88 от 12.05.2026 — origin/main (R15 motion-runtime removal)** — снятие R15 motion-runtime restrictions per user decision 12.05.2026 («сними все запреты на использование framer motion»). Conscious rollback v1.83 audited construction (10.05.2026, R15 двухуровневая motion-конструкция была введена через brainstorming → «двухуровневый» подтверждение заказчика; v1.88 — namesake rollback). **§5 п.12** → маркер «Резерв (снят 12.05.2026, см. CHANGELOG)» (нумерация п.1–11 сохранена, чтобы cross-refs в memory `feedback_environment.md` / `feedback_plugin_paired_stack.md` не сломать); **§2 строка «Animation default stack»** переписана с regulatory denylist на guidance recommendation; **§0 cross-refs** обновлены — Pravila v1.10 → v1.11, PSR_v1 v1.7 → v2.0, Tooling v1.15 → v1.16. **framer-motion** — technical block (peerDep react+react-dom, не работает в Vue физически), не regulatory rule. Связано: PSR_v1 v1.7 → v2.0 (R15 удалено целиком: R15.1 framer-motion + R15.2 motion-v 4 условия + R15.3 default стойка + R15.4 проверка + R15.5 hard-запрет дублирования + R15.6 live-override + R15.7 gsap/anime/lottie; R0.6 п.11 удалён; R8 motion тай-брейкеры удалены; R11.6 motion иерархия удалена; R13 motion-сценарии удалены), Pravila v1.10 → v1.11 (§11.5/§13.2 счётчик 16→15 правил; §13.9/§13.10 cross-refs на PSR_v1 v1.6→v2.0; §13.10 НЕ удалено — оно про R14, не R15), Tooling v1.15 → v1.16 (§9.2 reformulated в technical guidance), CHANGELOG_claude_md.md + MEMORY sync. Через `superpowers:brainstorming` → 3 варианта → выбор B (полная отмена R15) → `superpowers:writing-plans` → `superpowers:executing-plans` + `/claude-md-management:claude-md-improver` + ручные Edit (PSR_v1/Tooling/Pravila). v1.87→v1.88. **NB version-number collision:** на ветке plan5 также присутствует другая v1.88 entry (audit-driven schema-sync) — обе валидны, обе 12.05.2026, обе явно labelled.
|
||||
- **v1.87 от 11.05.2026** — sync schema-метрик после Plan 4 (Billing+CSV+Admin). Schema **v8.11 → v8.19** (накопленный drift от Plans 1+2+3+4): §0 «Источник истины» row «Схема БД», §2 «Стек» строка БД, §6 «Текущая фаза», §8 self-review триггеры — все обновлены до 62 базовых таблиц / 12 партиций / 117 индексов / 39 RLS / 5 функций / 13 триггеров / 5 ролей БД. §6 расширен Plan 4 closure summary: 15 коммитов на ветке `plan4-billing` (14 task-коммитов `a907fea..174dbae` + lychee CV-fix `fded2ee`), Pest 687/684 passed + 3 skipped/0 failed (2090 assertions), Vitest 49 files / 428 passed, Histoire 24 stories / 31 variants, lychee 0 broken, gitleaks 0 leaks. Активированы 7-ступенчатый pricing-tier биллинг + CsvReconcileJob hourly + auto-pause flow + 3 UI экрана. +7 новых Биз-25..31 в реестре (раздел 13 Открытые_вопросы v1.78). Drive-by closure: Plan 1 deferred WARNING #7 (SupplierProjectFactory random race) — fixed в Task 10 `0f820c4`. Через `/claude-md-management:revise-claude-md`.
|
||||
|
||||
- **v1.86 от 10.05.2026 (поздний вечер)** — закрытие 13 находок третьего аудита правил использования плагинов и скилов (4 P0 + 5 P1 + 2 P2 + 2 sync-правки в README/README_АРХИВ). Заказчик: «проведи аудит правил использования плагинов и скилов на предмет конфликта и запутаностей» → Claude через `/claude-md-management:claude-md-improver` нашёл 12 формальных находок + 4 sync-побочки, представил quality report, получил «исправь все, только при выполнении руководствуйся правилом, прежде чем вносить изменения тебе надо проанализировать как оно влияет на другие правила, что исправляю одно не делать других ошибок», применил с cross-impact-анализом перед каждой группой. **P0 (4 — реальные арифметические конфликты в CLAUDE.md, прошли мимо второго аудита):** §3 header «Карта 28 инструментов» → «33» (header застрял с pre-FD эпохи); §3.4 header «(+5, итого 28)» → «итого 29» (после добавления #30 в фазу 2 cumulative должна быть 29); §3.3 footer «из 30 номеров минус #1 = 29 active» → расширенная формулировка «33 номеров: 29 phase-active + 3 off-phase + 1 historic»; §6 «Активно: 19 инструментов из 29» + «(19/29 активны)» → «24» в обоих местах (внутренний арифметический конфликт: тут же раскладка 9+8+7=24, но числовая метка застряла на 19 с эпохи когда фаза 2 имела ~4 активных). **P1 (5 — обновление stale `+`-refs на актуальные версии):** PSR_v1 шапка cross-refs «CLAUDE.md v1.84+/Pravila v1.9+» → «v1.86+/v1.10+»; Tooling шапка cross-refs «Pravila v1.9+/PSR_v1 v1.5+/CLAUDE.md v1.84+» → «v1.10+/v1.7+/v1.86+»; CLAUDE.md §5 п.5 «PSR_v1 v1.5+» → «v1.7+». **P2 (2 — внутренние несогласованности формулировок):** PSR_v1 line 4 «slot уровня 2.5» → «уровня 2b» (описка внутри changelog'а v1.6, фактическое R0.1 line 33 всегда содержало «2b»); CLAUDE.md §3.3 #33 «вне Pravila §13» → «вне UI-пула §13» (Pravila §13.2 v1.10 включает claude-md-management как infrastructure subsection; «вне §13» вводило в заблуждение). **Побочки sync:** README.md и README_АРХИВ_v8_5.md «карта 28 инструментов» → «33 инструмента»; Tooling §11.5/§12 «не входят в 28» → «33 формализованные позиции». Связано: **PSR_v1 v1.6→v1.7**, **Tooling v1.14→v1.15**. Pravila v1.10 — без изменений. Через `/claude-md-management:claude-md-improver`.
|
||||
|
||||
- **v1.85 от 10.05.2026 (вечер)** — закрытие 15 находок аудита правил использования плагинов и скилов (4 P0 + 7 P1 + 4 P2). Заказчик: «проведи аудит правил использования плагинов и скилов на предмет конфликта и запутанностей» → Claude через `/claude-md-management:claude-md-improver` нашёл 16 находок, представил quality report, получил «все 15» (P0+P1+P2), применил батчем. **P0:** §6 арифметика «33» исправлена (+1 historic PG MCP); Tooling §10.3 шаг 2 sync с §4.1 (3→14 skills); Pravila §13.2 «(15 правил)»→«(16)»; Tooling §13 +v1.13 +v1.14 entries. **P1:** массовый stale-refs дрейф v1.4→v1.6 + v1.12→v1.14 в 7 местах (CLAUDE.md #31/#32/§5п.12, Pravila §11.5/§13.2/§13.9/§13.10). **P2:** Tooling Прил. Н добавлен explicit-слотом уровня 2b (раньше PSR_v1 R0.1 говорил «stack ниже Tooling», но Tooling не было в priority chain ни одного из 4 файлов); PSR_v1 R0.4.A свёрнут до cross-ref на Pravila §12.3 SoT (раньше параллелил список разной формулировкой — риск дрейфа); Pravila §0 +note про §11 override-приоритет (раньше §11 формально стоял ниже §9 в цепочке вопреки фактическому override §2.2/§4.5/§8.4); PSR_v1 R0.6 пронумерован 1–11 для надёжности cross-refs. Связано: **Pravila v1.9→v1.10**, **PSR_v1 v1.5→v1.6**, **Tooling v1.13→v1.14**. Через `/claude-md-management:claude-md-improver`.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
*.log
|
||||
.backups/
|
||||
.DS_Store
|
||||
.env
|
||||
.env.backup
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Сброс tenants.delivered_in_month + projects.delivered_in_month = 0.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §4.1
|
||||
* Расписание: 1-го числа каждого месяца в 00:00 Europe/Moscow.
|
||||
*
|
||||
* Идёт через connection `pgsql_supplier` (BYPASSRLS-роль crm_supplier_worker) —
|
||||
* паттерн ResetDeliveredTodayCommand. Один statement на таблицу, без SET LOCAL.
|
||||
*/
|
||||
class ResetMonthlyCountersCommand extends Command
|
||||
{
|
||||
protected $signature = 'projects:reset-monthly';
|
||||
|
||||
protected $description = 'Сброс tenants.delivered_in_month + projects.delivered_in_month = 0 (1-го числа в 00:00 МСК, Plan 4)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
// Без оборачивающего DB::transaction: два UPDATE'а независимо идемпотентны
|
||||
// (WHERE delivered_in_month <> 0). Если один упадёт, следующий cron-запуск
|
||||
// довершит сброс. Атомарность не требуется. Дополнительно — оборачивание
|
||||
// ломает тесты с shared PDO (SharesSupplierPdo trait): PostgreSQL не
|
||||
// допускает nested transactions без savepoints, см. precedent
|
||||
// ResetDeliveredTodayCommand (тоже без обёртки).
|
||||
$tenants = DB::connection('pgsql_supplier')
|
||||
->update('UPDATE tenants SET delivered_in_month = 0 WHERE delivered_in_month <> 0');
|
||||
$projects = DB::connection('pgsql_supplier')
|
||||
->update('UPDATE projects SET delivered_in_month = 0 WHERE delivered_in_month <> 0');
|
||||
|
||||
$this->info("Monthly reset: {$tenants} tenants, {$projects} projects.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Billing;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Выбрасывается LedgerService::chargeForDelivery, когда tenant не имеет
|
||||
* ни prepaid-лидов (balance_leads >= 1), ни рублей под текущую tier-цену
|
||||
* (balance_rub * 100 >= priceKopecks).
|
||||
*
|
||||
* Ловится в RouteSupplierLeadJob::createDealCopyForProject — инициирует
|
||||
* auto-pause flow (см. spec §4.2).
|
||||
*/
|
||||
final class InsufficientBalanceException extends RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $priceKopecks,
|
||||
public readonly string $balanceRub,
|
||||
public readonly int $balanceLeads,
|
||||
?\Throwable $previous = null,
|
||||
) {
|
||||
parent::__construct(
|
||||
sprintf(
|
||||
'Insufficient balance: price_kopecks=%d, balance_rub=%s, balance_leads=%d',
|
||||
$priceKopecks, $balanceRub, $balanceLeads,
|
||||
),
|
||||
previous: $previous,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PricingTier;
|
||||
use App\Models\SaasAdminAuditLog;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* SaaS-admin CRUD для pricing_tiers (7-ступенчатый тариф).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §6.1.
|
||||
* Без auth-middleware на MVP (паритет с другими /api/admin/*; gated на Б-1).
|
||||
* Audit trail в saas_admin_audit_log (action='pricing_tiers.create_scheduled' /
|
||||
* 'pricing_tiers.delete_scheduled').
|
||||
*
|
||||
* Конструкция audit-log:
|
||||
* - schema.saas_admin_audit_log требует NOT NULL admin_user_id и ip_address;
|
||||
* на MVP admin_user_id берётся из request param (как в
|
||||
* AdminSystemSettingsController), либо создаётся системный стаб
|
||||
* `system-pricing@liderra.local`. ip_address — $request->ip() или '127.0.0.1'.
|
||||
* - payload_after хранит {effective_from, tiers} для воспроизводимости.
|
||||
*/
|
||||
final class AdminPricingTiersController extends Controller
|
||||
{
|
||||
/** GET /api/admin/pricing-tiers */
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$today = Carbon::now('Europe/Moscow')->toDateString();
|
||||
|
||||
$active = PricingTier::query()->where('is_active', true)
|
||||
->where('effective_from', '<=', $today)
|
||||
->orderBy('tier_no')->orderBy('effective_from', 'desc')
|
||||
->get()
|
||||
->groupBy('tier_no')
|
||||
->map(fn ($g) => $g->first())
|
||||
->values();
|
||||
|
||||
$scheduled = PricingTier::query()->where('is_active', true)
|
||||
->where('effective_from', '>', $today)
|
||||
->orderBy('effective_from')->orderBy('tier_no')
|
||||
->get()
|
||||
->groupBy(fn (PricingTier $t) => $t->effective_from->toDateString());
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'active' => $active,
|
||||
'scheduled' => $scheduled,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/** POST /api/admin/pricing-tiers */
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'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'],
|
||||
]);
|
||||
|
||||
/** @var array<int, array{tier_no:int, leads_in_tier:?int, price_rub:string|float}> $tiers */
|
||||
$tiers = $request->input('tiers');
|
||||
|
||||
$tierNos = array_column($tiers, 'tier_no');
|
||||
if (count(array_unique($tierNos)) !== 7) {
|
||||
abort(422, 'tier_no must be unique 1..7');
|
||||
}
|
||||
if (array_diff([1, 2, 3, 4, 5, 6, 7], $tierNos) !== []) {
|
||||
abort(422, 'all 7 tier_no values required');
|
||||
}
|
||||
|
||||
$tier7 = collect($tiers)->firstWhere('tier_no', 7);
|
||||
if ($tier7['leads_in_tier'] !== null) {
|
||||
abort(422, 'tier_no=7 leads_in_tier must be null');
|
||||
}
|
||||
|
||||
foreach ($tiers as $tier) {
|
||||
if ($tier['tier_no'] !== 7 && ($tier['leads_in_tier'] === null || $tier['leads_in_tier'] < 1)) {
|
||||
abort(422, "tier_no={$tier['tier_no']} leads_in_tier must be >= 1");
|
||||
}
|
||||
}
|
||||
|
||||
$effectiveFrom = Carbon::now('Europe/Moscow')->startOfMonth()->addMonth()->toDateString();
|
||||
$adminUserId = $this->resolveAdminUserId($request);
|
||||
|
||||
DB::transaction(function () use ($tiers, $effectiveFrom, $adminUserId, $request): void {
|
||||
foreach ($tiers as $tier) {
|
||||
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),
|
||||
'is_active' => true,
|
||||
'effective_from' => $effectiveFrom,
|
||||
]);
|
||||
}
|
||||
|
||||
SaasAdminAuditLog::create([
|
||||
'admin_user_id' => $adminUserId,
|
||||
'action' => 'pricing_tiers.create_scheduled',
|
||||
'target_type' => 'pricing_tiers',
|
||||
'target_id' => null,
|
||||
'payload_before' => null,
|
||||
'payload_after' => ['effective_from' => $effectiveFrom, 'tiers' => $tiers],
|
||||
'reason' => 'Scheduled pricing-tier update via admin UI.',
|
||||
'ip_address' => $request->ip() ?? '127.0.0.1',
|
||||
'user_agent' => $request->userAgent(),
|
||||
]);
|
||||
});
|
||||
|
||||
return response()->json(['effective_from' => $effectiveFrom], Response::HTTP_CREATED);
|
||||
}
|
||||
|
||||
/** DELETE /api/admin/pricing-tiers/scheduled/{effective_from} */
|
||||
public function deleteScheduled(Request $request, string $effectiveFrom): JsonResponse
|
||||
{
|
||||
if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $effectiveFrom)) {
|
||||
abort(400, 'invalid date format');
|
||||
}
|
||||
|
||||
$todayMsk = Carbon::now('Europe/Moscow')->toDateString();
|
||||
if ($effectiveFrom <= $todayMsk) {
|
||||
abort(409, 'cannot delete past or active set');
|
||||
}
|
||||
|
||||
$adminUserId = $this->resolveAdminUserId($request);
|
||||
|
||||
DB::transaction(function () use ($effectiveFrom, $adminUserId, $request): void {
|
||||
$deleted = PricingTier::where('effective_from', $effectiveFrom)->delete();
|
||||
|
||||
SaasAdminAuditLog::create([
|
||||
'admin_user_id' => $adminUserId,
|
||||
'action' => 'pricing_tiers.delete_scheduled',
|
||||
'target_type' => 'pricing_tiers',
|
||||
'target_id' => null,
|
||||
'payload_before' => ['effective_from' => $effectiveFrom],
|
||||
'payload_after' => ['rows_deleted' => $deleted],
|
||||
'reason' => 'Cancelled scheduled pricing-tier set via admin UI.',
|
||||
'ip_address' => $request->ip() ?? '127.0.0.1',
|
||||
'user_agent' => $request->userAgent(),
|
||||
]);
|
||||
});
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Резолвит admin_user_id: из request input (на MVP) либо создаёт
|
||||
* системный стаб-аккаунт `system-pricing@liderra.local`, чтобы соблюсти
|
||||
* NOT NULL constraint + FK на saas_admin_users.
|
||||
*/
|
||||
private function resolveAdminUserId(Request $request): int
|
||||
{
|
||||
$requested = $request->input('admin_user_id');
|
||||
if (is_int($requested) || (is_string($requested) && ctype_digit($requested))) {
|
||||
$existing = DB::table('saas_admin_users')->where('id', (int) $requested)->value('id');
|
||||
if ($existing !== null) {
|
||||
return (int) $existing;
|
||||
}
|
||||
}
|
||||
|
||||
$existingId = DB::table('saas_admin_users')
|
||||
->where('email', 'system-pricing@liderra.local')
|
||||
->value('id');
|
||||
if ($existingId !== null) {
|
||||
return (int) $existingId;
|
||||
}
|
||||
|
||||
return (int) DB::table('saas_admin_users')->insertGetId([
|
||||
'email' => 'system-pricing@liderra.local',
|
||||
'full_name' => 'System Pricing Bot',
|
||||
'password_hash' => '$2y$04$system-stub-not-loginable',
|
||||
'role' => 'super_admin',
|
||||
'is_active' => false,
|
||||
'sso_provider' => 'local',
|
||||
'is_break_glass' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\SaasAdminAuditLog;
|
||||
use App\Models\Supplier;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* SaaS-admin CRUD для suppliers (B1/B2/B3 закупочные цены).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §6.2.
|
||||
* Без auth-middleware на MVP (паритет с AdminPricingTiersController; gated на Б-1).
|
||||
* Audit trail в saas_admin_audit_log (action='suppliers.update').
|
||||
*
|
||||
* Reuses pattern from AdminPricingTiersController::resolveAdminUserId(), но с
|
||||
* системным стабом 'system-supplier@liderra.local' (отдельный actor для
|
||||
* фильтрации audit-event'ов по этой подсистеме).
|
||||
*/
|
||||
final class AdminSuppliersController extends Controller
|
||||
{
|
||||
/** GET /api/admin/suppliers */
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'data' => Supplier::query()->orderBy('sort_order')->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
/** PATCH /api/admin/suppliers/{id} */
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'cost_rub' => ['sometimes', 'numeric', 'min:0'],
|
||||
'quality_score' => ['sometimes', 'numeric', 'between:0,9.99'],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
]);
|
||||
|
||||
$supplier = Supplier::findOrFail($id);
|
||||
/** @var array<string, mixed> $changes */
|
||||
$changes = $request->only(['cost_rub', 'quality_score', 'is_active']);
|
||||
|
||||
DB::transaction(function () use ($supplier, $changes, $request): void {
|
||||
/** @var array<string, mixed> $before */
|
||||
$before = $supplier->only(array_keys($changes));
|
||||
$supplier->update($changes);
|
||||
|
||||
SaasAdminAuditLog::create([
|
||||
'admin_user_id' => $this->resolveAdminUserId($request),
|
||||
'action' => 'suppliers.update',
|
||||
'target_type' => 'suppliers',
|
||||
'target_id' => $supplier->id,
|
||||
'payload_before' => $before,
|
||||
'payload_after' => $changes,
|
||||
'reason' => 'Supplier cost/quality update via admin UI.',
|
||||
'ip_address' => $request->ip() ?? '127.0.0.1',
|
||||
'user_agent' => $request->userAgent(),
|
||||
]);
|
||||
});
|
||||
|
||||
return response()->json(['data' => $supplier->fresh()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Резолвит admin_user_id: из request input (на MVP) либо создаёт
|
||||
* системный стаб-аккаунт `system-supplier@liderra.local`, чтобы соблюсти
|
||||
* NOT NULL constraint + FK на saas_admin_users.
|
||||
*
|
||||
* Идентичная логика — AdminPricingTiersController::resolveAdminUserId();
|
||||
* отличается только email системного стаба (для фильтрации audit-event'ов).
|
||||
*/
|
||||
private function resolveAdminUserId(Request $request): int
|
||||
{
|
||||
$requested = $request->input('admin_user_id');
|
||||
if (is_int($requested) || (is_string($requested) && ctype_digit($requested))) {
|
||||
$existing = DB::table('saas_admin_users')->where('id', (int) $requested)->value('id');
|
||||
if ($existing !== null) {
|
||||
return (int) $existing;
|
||||
}
|
||||
}
|
||||
|
||||
$existingId = DB::table('saas_admin_users')
|
||||
->where('email', 'system-supplier@liderra.local')
|
||||
->value('id');
|
||||
if ($existingId !== null) {
|
||||
return (int) $existingId;
|
||||
}
|
||||
|
||||
return (int) DB::table('saas_admin_users')->insertGetId([
|
||||
'email' => 'system-supplier@liderra.local',
|
||||
'full_name' => 'System Supplier Bot',
|
||||
'password_hash' => '$2y$04$system-stub-not-loginable',
|
||||
'role' => 'super_admin',
|
||||
'is_active' => false,
|
||||
'sso_provider' => 'local',
|
||||
'is_break_glass' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -5,48 +5,160 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\BulkProjectActionRequest;
|
||||
use App\Http\Requests\StoreProjectRequest;
|
||||
use App\Http\Requests\UpdateProjectRequest;
|
||||
use App\Http\Resources\ProjectResource;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Project\ProjectService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Проекты tenant'а — для NewDealDialog dropdown'а и DealsView/Smart-filters.
|
||||
* Проекты tenant'а — расширенный API для ProjectsView + NewDealDialog.
|
||||
*
|
||||
* На MVP: tenant_id параметром. На prod: middleware('auth:sanctum')+'tenant'.
|
||||
* index: фильтры по signal_type/status/search, пагинация, batch-fetch по ids.
|
||||
* show: детальная карточка проекта с supplier_links.
|
||||
*
|
||||
* Auth: auth:sanctum + tenant middleware (устанавливает app.current_tenant_id для RLS).
|
||||
* Task 2 Plan 5 заменяет MVP-версию (tenant_id параметром, без auth).
|
||||
*/
|
||||
class ProjectController extends Controller
|
||||
{
|
||||
/** GET /api/projects?tenant_id={id} */
|
||||
public function __construct(private readonly ProjectService $projects) {}
|
||||
|
||||
/** GET /api/projects */
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = (int) $request->query('tenant_id', '0');
|
||||
if ($tenantId < 1) {
|
||||
return response()->json(['message' => 'Параметр tenant_id обязателен.'], 422);
|
||||
$query = Project::query()
|
||||
->with(['supplierB1', 'supplierB2', 'supplierB3']) // eager-load to avoid N+1 in aggregation helpers
|
||||
->where('tenant_id', $request->user()->tenant_id);
|
||||
|
||||
// Batch-fetch по ids — возвращает без пагинации (для dropdown'ов и т.п.)
|
||||
if ($ids = $request->query('ids')) {
|
||||
// '?ids=' batch fetch. Non-numeric and zero values silently dropped via intval+filter
|
||||
// (intval('abc')=0 → array_filter drops 0). Acceptable for a read-only dropdown:
|
||||
// invalid input produces empty result, not 422.
|
||||
$idArray = array_filter(array_map('intval', explode(',', (string) $ids)));
|
||||
$items = $query->whereIn('id', $idArray)->get();
|
||||
|
||||
return response()->json(['data' => ProjectResource::collection($items)]);
|
||||
}
|
||||
|
||||
$tenant = Tenant::find($tenantId);
|
||||
if ($tenant === null) {
|
||||
return response()->json(['message' => 'Тенант не найден.'], 404);
|
||||
// Фильтр по типу сигнала
|
||||
if ($type = $request->query('signal_type')) {
|
||||
$query->where('signal_type', $type);
|
||||
}
|
||||
|
||||
$projects = DB::transaction(function () use ($tenantId) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
// Фильтр по статусу жизненного цикла
|
||||
$status = $request->query('status');
|
||||
if ($status === 'archived') {
|
||||
$query->archived();
|
||||
} elseif ($status === 'active') {
|
||||
$query->active()->where('is_active', true);
|
||||
} elseif ($status === 'paused') {
|
||||
$query->active()->where('is_active', false);
|
||||
} else {
|
||||
// По умолчанию: все не архивированные (active + paused)
|
||||
$query->active();
|
||||
}
|
||||
|
||||
return Project::query()
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'tag', 'type']);
|
||||
});
|
||||
// Поиск по name и signal_identifier
|
||||
if ($search = $request->query('search')) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'ilike', "%{$search}%")
|
||||
->orWhere('signal_identifier', 'ilike', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$perPage = min((int) $request->query('per_page', '20'), 100);
|
||||
$projects = $query->orderBy('created_at', 'desc')->paginate($perPage);
|
||||
|
||||
return response()->json([
|
||||
'projects' => $projects->map(fn (Project $p) => [
|
||||
'id' => $p->id,
|
||||
'name' => $p->name,
|
||||
'tag' => $p->tag,
|
||||
'type' => $p->type,
|
||||
]),
|
||||
'data' => ProjectResource::collection($projects->items()),
|
||||
'meta' => [
|
||||
'current_page' => $projects->currentPage(),
|
||||
'per_page' => $projects->perPage(),
|
||||
'total' => $projects->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/** POST /api/projects */
|
||||
public function store(StoreProjectRequest $request): JsonResponse
|
||||
{
|
||||
$project = $this->projects->create($request->user()->tenant, $request->validated());
|
||||
|
||||
return response()->json(['data' => new ProjectResource($project)], 201);
|
||||
}
|
||||
|
||||
/** PATCH /api/projects/{id} */
|
||||
public function update(UpdateProjectRequest $request, int $id): JsonResponse
|
||||
{
|
||||
$project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id);
|
||||
$updated = $this->projects->update($project, $request->validated());
|
||||
|
||||
return response()->json(['data' => new ProjectResource($updated)]);
|
||||
}
|
||||
|
||||
/** GET /api/projects/{id} */
|
||||
public function show(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$project = Project::with(['supplierB1', 'supplierB2', 'supplierB3']) // eager-load to avoid N+1
|
||||
->where('tenant_id', $request->user()->tenant_id)
|
||||
->findOrFail($id);
|
||||
|
||||
return response()->json(['data' => new ProjectResource($project)]);
|
||||
}
|
||||
|
||||
/** DELETE /api/projects/{id} — soft-archive (sets archived_at, is_active=false) */
|
||||
public function destroy(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id);
|
||||
$this->projects->archive($project);
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/** POST /api/projects/{id}/sync — re-dispatch SyncSupplierProjectJob */
|
||||
public function sync(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id);
|
||||
$this->projects->triggerSync($project);
|
||||
|
||||
return response()->json(['queued' => true, 'sync_status' => 'pending'], 202);
|
||||
}
|
||||
|
||||
/** PATCH /api/projects/{id}/toggle-active — flip is_active flag */
|
||||
public function toggleActive(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$request->validate(['is_active' => ['required', 'boolean']]);
|
||||
$project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id);
|
||||
$project->update(['is_active' => $request->boolean('is_active')]);
|
||||
|
||||
return response()->json(['data' => new ProjectResource($project->fresh())]);
|
||||
}
|
||||
|
||||
/** POST /api/projects/bulk — batch pause/resume/archive/update_regions/update_days/update_limit */
|
||||
public function bulk(BulkProjectActionRequest $request): JsonResponse
|
||||
{
|
||||
$tenantId = $request->user()->tenant_id;
|
||||
$ids = $this->projects->resolveBulkScope(
|
||||
$tenantId,
|
||||
$request->validated('ids'),
|
||||
$request->validated('scope.filter'),
|
||||
);
|
||||
|
||||
if (count($ids) > ProjectService::BULK_MAX) {
|
||||
return response()->json([
|
||||
'errors' => ['scope' => ['Слишком много проектов под фильтр (>500). Уточните фильтры или выберите вручную.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$payload = array_merge($request->validated(), ['ids' => $ids]);
|
||||
|
||||
$result = $this->projects->bulkAction($tenantId, $request->validated('action'), $payload);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\LeadCharge;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
/**
|
||||
* Tenant-scoped доступ к lead_charges (read-only ledger) — Plan 4 Task 11.
|
||||
*
|
||||
* RLS защищает изоляцию через SetTenantContext middleware
|
||||
* (auth:sanctum + tenant). См. spec §6.3:
|
||||
* docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md.
|
||||
*
|
||||
* Эндпоинты:
|
||||
* GET /api/billing/charges?page=N&period=current_month|last_month|90d&charge_source=prepaid|rub
|
||||
* POST /api/billing/charges/export — CSV через StreamedResponse + chunkById(500).
|
||||
*
|
||||
* RLS-контекст устанавливается на уровне middleware `tenant` (SetTenantContext),
|
||||
* который оборачивает HTTP-запрос в транзакцию с `SET LOCAL app.current_tenant_id`.
|
||||
* Для StreamedResponse callback'а транзакция middleware'а уже закрыта на момент
|
||||
* вызова — поэтому export открывает свою транзакцию внутри callback'а с явным
|
||||
* SET LOCAL (паттерн из DealExportController).
|
||||
*/
|
||||
class TenantChargesController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
|
||||
// Explicit tenant_id фильтр — defense-in-depth поверх RLS. В тестах PG
|
||||
// superuser BYPASSRLS, и без явного where() запрос вернул бы строки
|
||||
// других тенантов (см. InAppNotificationController паттерн).
|
||||
$query = LeadCharge::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->orderBy('charged_at', 'desc');
|
||||
|
||||
$this->applyFilters($query, $request);
|
||||
|
||||
$page = $query->paginate(20);
|
||||
|
||||
return response()->json([
|
||||
'data' => $page->items(),
|
||||
'meta' => [
|
||||
'current_page' => $page->currentPage(),
|
||||
'last_page' => $page->lastPage(),
|
||||
'total' => $page->total(),
|
||||
'per_page' => $page->perPage(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function export(Request $request): StreamedResponse
|
||||
{
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
$period = $request->input('period');
|
||||
$source = $request->input('charge_source');
|
||||
|
||||
$filename = 'charges_'.now()->format('Y-m-d_His').'.csv';
|
||||
|
||||
return new StreamedResponse(function () use ($tenantId, $period, $source) {
|
||||
// RLS-контекст должен быть установлен внутри транзакции на момент
|
||||
// фактического SELECT. StreamedResponse callback вызывается уже после
|
||||
// закрытия middleware-транзакции, поэтому открываем свою явную.
|
||||
// Паттерн из DealExportController.
|
||||
DB::transaction(function () use ($tenantId, $period, $source) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
$out = fopen('php://output', 'w');
|
||||
if ($out === false) {
|
||||
return;
|
||||
}
|
||||
// BOM для Excel (CSV в UTF-8).
|
||||
fwrite($out, "\xEF\xBB\xBF");
|
||||
fputcsv($out, ['charged_at', 'deal_id', 'tier_no', 'charge_source', 'price_rub', 'balance_rub_after']);
|
||||
|
||||
// Explicit tenant_id фильтр — defense-in-depth поверх RLS
|
||||
// (см. комментарий в index()).
|
||||
$query = LeadCharge::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->orderBy('charged_at', 'desc');
|
||||
$this->applyPeriodFilter($query, $period);
|
||||
if ($source !== null && $source !== '') {
|
||||
$query->where('charge_source', $source);
|
||||
}
|
||||
|
||||
$query->chunkById(500, function ($charges) use ($out) {
|
||||
foreach ($charges as $c) {
|
||||
/** @var LeadCharge $c */
|
||||
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 оставляем пустым.
|
||||
'',
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
fclose($out);
|
||||
});
|
||||
}, 200, [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<LeadCharge> $query
|
||||
*/
|
||||
private function applyFilters($query, Request $request): void
|
||||
{
|
||||
$this->applyPeriodFilter($query, $request->query('period'));
|
||||
|
||||
$source = $request->query('charge_source');
|
||||
if (is_string($source) && $source !== '') {
|
||||
$query->where('charge_source', $source);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<LeadCharge> $query
|
||||
*/
|
||||
private function applyPeriodFilter($query, mixed $period): void
|
||||
{
|
||||
if (! is_string($period) || $period === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$now = Carbon::now('Europe/Moscow');
|
||||
|
||||
if ($period === 'current_month') {
|
||||
$query->where('charged_at', '>=', $now->copy()->startOfMonth());
|
||||
} elseif ($period === 'last_month') {
|
||||
$query->whereBetween('charged_at', [
|
||||
$now->copy()->subMonth()->startOfMonth(),
|
||||
$now->copy()->subMonth()->endOfMonth(),
|
||||
]);
|
||||
} elseif ($period === '90d') {
|
||||
$query->where('charged_at', '>=', $now->copy()->subDays(90));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class BulkProjectActionRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$action = $this->input('action');
|
||||
|
||||
$rules = [
|
||||
'action' => ['required', Rule::in([
|
||||
'pause', 'resume', 'archive',
|
||||
'update_regions', 'update_days', 'update_limit',
|
||||
])],
|
||||
'ids' => ['nullable', 'array', 'max:500'],
|
||||
'ids.*' => ['integer', 'min:1'],
|
||||
'scope' => ['nullable', 'array'],
|
||||
'scope.filter' => ['nullable', 'array'],
|
||||
'scope.filter.signal_type' => ['nullable', 'string', Rule::in(['site', 'call', 'sms'])],
|
||||
'scope.filter.status' => ['nullable', 'string', Rule::in(['active', 'paused', 'archived'])],
|
||||
'scope.filter.search' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
|
||||
if ($action === 'update_regions' || $action === 'update_days') {
|
||||
$maxMask = $action === 'update_regions' ? 255 : 127;
|
||||
$rules['add'] = ['nullable', 'integer', 'min:0', "max:{$maxMask}"];
|
||||
$rules['remove'] = ['nullable', 'integer', 'min:0', "max:{$maxMask}"];
|
||||
}
|
||||
|
||||
if ($action === 'update_limit') {
|
||||
$rules['delta'] = ['nullable', 'integer'];
|
||||
$rules['replace'] = ['nullable', 'integer', 'min:0'];
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
public function withValidator($validator): void
|
||||
{
|
||||
$validator->after(function ($v) {
|
||||
$hasIds = ! empty($this->input('ids'));
|
||||
$hasScope = $this->has('scope.filter') && is_array($this->input('scope.filter'));
|
||||
if (! $hasIds && ! $hasScope) {
|
||||
$v->errors()->add('ids', 'Either ids or scope.filter is required.');
|
||||
}
|
||||
|
||||
if ($this->input('action') === 'update_limit') {
|
||||
$hasDelta = $this->has('delta');
|
||||
$hasReplace = $this->has('replace');
|
||||
if ($hasDelta && $hasReplace) {
|
||||
$v->errors()->add('delta', 'Cannot use both delta and replace.');
|
||||
}
|
||||
if (! $hasDelta && ! $hasReplace) {
|
||||
$v->errors()->add('delta', 'Either delta or replace is required for update_limit.');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreProjectRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$signalType = $this->input('signal_type');
|
||||
|
||||
$base = [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'signal_type' => ['required', Rule::in(['site', 'call', 'sms'])],
|
||||
'daily_limit_target' => ['required', 'integer', 'min:1', 'max:10000'],
|
||||
'region_mask' => ['required', 'integer', 'min:0'],
|
||||
'region_mode' => ['required', Rule::in(['include', 'exclude'])],
|
||||
'delivery_days_mask' => ['required', 'integer', 'min:1', 'max:127'],
|
||||
];
|
||||
|
||||
if ($signalType === 'site') {
|
||||
$base['signal_identifier'] = ['required', 'string', 'regex:/^[a-z0-9][a-z0-9\-]*(\.[a-z0-9][a-z0-9\-]*)*\.[a-z]{2,}$/i'];
|
||||
} elseif ($signalType === 'call') {
|
||||
$base['signal_identifier'] = ['required', 'string', 'regex:/^7\d{10}$/'];
|
||||
} elseif ($signalType === 'sms') {
|
||||
$base['sms_senders'] = ['required', 'array', 'min:1'];
|
||||
$base['sms_senders.*'] = ['string', 'max:11'];
|
||||
$base['sms_keyword'] = ['nullable', 'string', 'min:1', 'max:50'];
|
||||
}
|
||||
|
||||
return $base;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateProjectRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
// signal_type immutable: не валидируется в правилах, controller игнорирует поле
|
||||
return [
|
||||
'name' => ['sometimes', 'string', 'max:255'],
|
||||
'daily_limit_target' => ['sometimes', 'integer', 'min:1', 'max:10000'],
|
||||
'region_mask' => ['sometimes', 'integer', 'min:0'],
|
||||
'region_mode' => ['sometimes', Rule::in(['include', 'exclude'])],
|
||||
'delivery_days_mask' => ['sometimes', 'integer', 'min:1', 'max:127'],
|
||||
'sms_senders' => ['sometimes', 'array', 'min:1'],
|
||||
'sms_senders.*' => ['string', 'max:11'],
|
||||
'sms_keyword' => ['sometimes', 'nullable', 'string', 'min:1', 'max:50'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Models\Project;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/** @mixin Project */
|
||||
class ProjectResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
/** @var Project $project */
|
||||
$project = $this->resource;
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'signal_type' => $this->signal_type,
|
||||
'signal_identifier' => $this->signal_identifier,
|
||||
'sms_senders' => $this->sms_senders,
|
||||
'sms_keyword' => $this->sms_keyword,
|
||||
'daily_limit_target' => $this->daily_limit_target,
|
||||
'effective_daily_limit_today' => $this->effective_daily_limit_today,
|
||||
'delivered_today' => $this->delivered_today,
|
||||
'delivered_in_month' => $this->delivered_in_month,
|
||||
'is_active' => $this->is_active,
|
||||
'archived_at' => $project->archived_at?->toIso8601String(),
|
||||
'region_mask' => $this->region_mask,
|
||||
'region_mode' => $this->region_mode,
|
||||
'delivery_days_mask' => $this->delivery_days_mask,
|
||||
'sync_status' => $this->aggregateSyncStatus(),
|
||||
'last_synced_at' => $this->aggregateLastSyncedAt(),
|
||||
'supplier_links' => $this->when(
|
||||
$request->routeIs('projects.show'),
|
||||
fn () => $this->getSupplierLinks(),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Exceptions\Billing\InsufficientBalanceException;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierLead;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Billing\LedgerService;
|
||||
use App\Services\DuplicateDetector;
|
||||
use App\Services\LeadRouter;
|
||||
use App\Services\NotificationService;
|
||||
@@ -20,6 +21,7 @@ use Illuminate\Foundation\Queue\Queueable as FoundationQueueable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
@@ -29,6 +31,7 @@ use Throwable;
|
||||
* Routing входящего supplier_lead к eligible Лидерра-проектам (sharing-model).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §5–§6.
|
||||
* docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §3 (Plan 4 Task 4).
|
||||
*
|
||||
* Алгоритм:
|
||||
* 1. Загрузить SupplierLead.
|
||||
@@ -40,13 +43,13 @@ use Throwable;
|
||||
* - Создать Deal (source_crm_id=vid).
|
||||
* - DuplicateDetector::findMaster — если найден master !== deal, mark
|
||||
* duplicate_of_id (без charge/counter/notify, ActivityLog с duplicate_of).
|
||||
* - Иначе: balance_leads--, delivered_today/month++, BalanceTransaction
|
||||
* (TYPE_LEAD_CHARGE, balance_leads_after из refresh'а), ActivityLog
|
||||
* (EVENT_DEAL_CREATED), NotificationService::notifyNewLead.
|
||||
* - Иначе: 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
|
||||
* (auto-pause flow приходит в Task 6). delivered_today/month++ на проекте,
|
||||
* ActivityLog (EVENT_DEAL_CREATED), NotificationService::notifyNewLead.
|
||||
* 6. Обновить SupplierLead.processed_at=now() + deals_created_count.
|
||||
*
|
||||
* Биллинг (LeadCharge tier-snapshot per Plan 4) — отдельный шаг: текущий job
|
||||
* создаёт BalanceTransaction по фиксированной ставке -1 lead, без cost_rub.
|
||||
*/
|
||||
class RouteSupplierLeadJob implements ShouldQueue
|
||||
{
|
||||
@@ -82,6 +85,7 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
SupplierProjectResolver $resolver,
|
||||
DuplicateDetector $duplicateDetector,
|
||||
NotificationService $notifier,
|
||||
LedgerService $ledger,
|
||||
): void {
|
||||
$lead = SupplierLead::findOrFail($this->supplierLeadId);
|
||||
|
||||
@@ -110,7 +114,7 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
$failures = [];
|
||||
foreach ($matched as $project) {
|
||||
try {
|
||||
if ($this->createDealCopyForProject($lead, $project, $duplicateDetector, $notifier)) {
|
||||
if ($this->createDealCopyForProject($lead, $project, $duplicateDetector, $notifier, $ledger)) {
|
||||
$createdCount++;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
@@ -180,71 +184,96 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
Project $project,
|
||||
DuplicateDetector $duplicateDetector,
|
||||
NotificationService $notifier,
|
||||
LedgerService $ledger,
|
||||
): bool {
|
||||
return DB::transaction(function () use ($lead, $project, $duplicateDetector, $notifier): bool {
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$project->tenant_id}'");
|
||||
try {
|
||||
return DB::transaction(function () use ($lead, $project, $duplicateDetector, $notifier, $ledger): bool {
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$project->tenant_id}'");
|
||||
|
||||
/** @var Tenant $tenant */
|
||||
$tenant = Tenant::query()
|
||||
->whereKey($project->tenant_id)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
/** @var Tenant $tenant */
|
||||
$tenant = Tenant::query()
|
||||
->whereKey($project->tenant_id)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
// Concurrency recheck: lockForUpdate(Project) + recheck delivered_today
|
||||
// против лимита под блокировкой. Closes CV.11 audit BLOCKER #2 (Plan 2.5).
|
||||
// matchEligibleProjects делал SELECT без lock'а — между snapshot'ом и
|
||||
// этой транзакцией concurrent webhook мог инкрементить счётчик до limit.
|
||||
// Если лимит уже исчерпан — return false (deal не создаём, баланс не списываем).
|
||||
/** @var Project $lockedProject */
|
||||
$lockedProject = Project::query()
|
||||
->whereKey($project->id)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
$effectiveLimit = $lockedProject->effective_daily_limit_today ?? $lockedProject->daily_limit_target;
|
||||
if ($lockedProject->delivered_today >= $effectiveLimit) {
|
||||
Log::info('supplier_lead.project_at_limit_skipped', [
|
||||
'supplier_lead_id' => $lead->id,
|
||||
'project_id' => $lockedProject->id,
|
||||
// Concurrency recheck: lockForUpdate(Project) + recheck delivered_today
|
||||
// против лимита под блокировкой. Closes CV.11 audit BLOCKER #2 (Plan 2.5).
|
||||
// matchEligibleProjects делал SELECT без lock'а — между snapshot'ом и
|
||||
// этой транзакцией concurrent webhook мог инкрементить счётчик до limit.
|
||||
// Если лимит уже исчерпан — return false (deal не создаём, баланс не списываем).
|
||||
/** @var Project $lockedProject */
|
||||
$lockedProject = Project::query()
|
||||
->whereKey($project->id)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
$effectiveLimit = $lockedProject->effective_daily_limit_today ?? $lockedProject->daily_limit_target;
|
||||
if ($lockedProject->delivered_today >= $effectiveLimit) {
|
||||
Log::info('supplier_lead.project_at_limit_skipped', [
|
||||
'supplier_lead_id' => $lead->id,
|
||||
'project_id' => $lockedProject->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'delivered_today' => $lockedProject->delivered_today,
|
||||
'effective_limit' => $effectiveLimit,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
$project = $lockedProject;
|
||||
|
||||
$payload = $lead->raw_payload ?? [];
|
||||
$receivedAt = isset($payload['time'])
|
||||
? Carbon::createFromTimestamp((int) $payload['time'])
|
||||
: ($lead->received_at ?? Carbon::now());
|
||||
|
||||
/** @var array<int, string> $phones */
|
||||
$phones = isset($payload['phones']) && is_array($payload['phones'])
|
||||
? array_values(array_map('strval', $payload['phones']))
|
||||
: [(string) $lead->phone];
|
||||
|
||||
$deal = Deal::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'delivered_today' => $lockedProject->delivered_today,
|
||||
'effective_limit' => $effectiveLimit,
|
||||
'source_crm_id' => $lead->vid,
|
||||
'project_id' => $project->id,
|
||||
'phone' => (string) $lead->phone,
|
||||
'phones' => $phones,
|
||||
'status' => 'new',
|
||||
'received_at' => $receivedAt,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
$project = $lockedProject;
|
||||
$master = $duplicateDetector->findMaster(
|
||||
tenantId: (int) $tenant->id,
|
||||
phone: (string) $lead->phone,
|
||||
now: $receivedAt,
|
||||
);
|
||||
|
||||
$payload = $lead->raw_payload ?? [];
|
||||
$receivedAt = isset($payload['time'])
|
||||
? Carbon::createFromTimestamp((int) $payload['time'])
|
||||
: ($lead->received_at ?? Carbon::now());
|
||||
// Только что созданный $deal сам попадает в выборку DuplicateDetector
|
||||
// (он уже в БД к моменту lookup'а), поэтому master может ===$deal.
|
||||
// Дубль — только если master найден И это НЕ сам deal.
|
||||
if ($master !== null && $master->id !== $deal->id) {
|
||||
$deal->update(['duplicate_of_id' => $master->id]);
|
||||
|
||||
/** @var array<int, string> $phones */
|
||||
$phones = isset($payload['phones']) && is_array($payload['phones'])
|
||||
? array_values(array_map('strval', $payload['phones']))
|
||||
: [(string) $lead->phone];
|
||||
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(),
|
||||
]);
|
||||
|
||||
$deal = Deal::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'source_crm_id' => $lead->vid,
|
||||
'project_id' => $project->id,
|
||||
'phone' => (string) $lead->phone,
|
||||
'phones' => $phones,
|
||||
'status' => 'new',
|
||||
'received_at' => $receivedAt,
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
|
||||
$master = $duplicateDetector->findMaster(
|
||||
tenantId: (int) $tenant->id,
|
||||
phone: (string) $lead->phone,
|
||||
now: $receivedAt,
|
||||
);
|
||||
// Task 6: $ledger->chargeForDelivery бросит InsufficientBalanceException —
|
||||
// транзакция откатится, и outer catch ниже отловит для auto-pause flow.
|
||||
$ledger->chargeForDelivery($tenant, $deal, $lead);
|
||||
|
||||
// Только что созданный $deal сам попадает в выборку DuplicateDetector
|
||||
// (он уже в БД к моменту lookup'а), поэтому master может ===$deal.
|
||||
// Дубль — только если master найден И это НЕ сам deal.
|
||||
if ($master !== null && $master->id !== $deal->id) {
|
||||
$deal->update(['duplicate_of_id' => $master->id]);
|
||||
$project->increment('delivered_today');
|
||||
$project->increment('delivered_in_month');
|
||||
|
||||
ActivityLog::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
@@ -253,50 +282,67 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
'event' => ActivityLog::EVENT_DEAL_CREATED,
|
||||
'context' => [
|
||||
'source' => 'supplier_webhook',
|
||||
'duplicate_of' => $master->id,
|
||||
'supplier_lead_id' => $lead->id,
|
||||
],
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
// ProcessWebhookJob-pattern: setRelation чтобы NotificationService
|
||||
// мог подтянуть deal->project без N+1 lookup'а под RLS.
|
||||
$deal->setRelation('project', $project);
|
||||
$notifier->notifyNewLead($tenant, $deal);
|
||||
|
||||
$tenant->decrement('balance_leads');
|
||||
$tenant->refresh();
|
||||
return true;
|
||||
});
|
||||
} catch (InsufficientBalanceException $e) {
|
||||
// Транзакция уже rolled back — Deal не создан, balance не тронут.
|
||||
// Запускаем auto-pause flow (Plan 4 Task 6 §4.4) и возвращаем false,
|
||||
// чтобы handle()-loop продолжил routing к остальным tenant'ам без rethrow.
|
||||
$this->handleInsufficientBalance($lead, $project, $e);
|
||||
|
||||
$project->increment('delivered_today');
|
||||
$project->increment('delivered_in_month');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
]);
|
||||
/**
|
||||
* Auto-pause flow при недостатке баланса (Plan 4 Task 6 §4.4):
|
||||
*
|
||||
* 1. UPDATE projects.is_active=false через pgsql_supplier (BYPASSRLS), потому
|
||||
* что текущая транзакция уже rolled back — её SET LOCAL app.current_tenant_id
|
||||
* отвалился и обычный pgsql connection не сможет апдейтить чужой tenant'овый
|
||||
* row под политикой tenant_isolation.
|
||||
* 2. Email-алерт ZeroBalancePausedMail с rate-limit 1/час/tenant — через
|
||||
* Redis SETNX (Cache::add вернёт true только при первой попытке за час).
|
||||
* 3. Log::warning с диагностикой суммы баланса и цены тарифа.
|
||||
*/
|
||||
private function handleInsufficientBalance(
|
||||
SupplierLead $lead,
|
||||
Project $project,
|
||||
InsufficientBalanceException $e,
|
||||
): void {
|
||||
// 1) UPDATE projects.is_active=false через pgsql_supplier (BYPASSRLS).
|
||||
DB::connection(self::DB_CONNECTION)
|
||||
->update('UPDATE projects SET is_active = false WHERE id = ?', [$project->id]);
|
||||
|
||||
ActivityLog::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => null,
|
||||
'deal_id' => $deal->id,
|
||||
'event' => ActivityLog::EVENT_DEAL_CREATED,
|
||||
'context' => [
|
||||
'source' => 'supplier_webhook',
|
||||
'supplier_lead_id' => $lead->id,
|
||||
],
|
||||
'created_at' => now(),
|
||||
]);
|
||||
// 2) Email-алерт с rate-limit 1/час/tenant через Redis SETNX (Cache::add).
|
||||
$cacheKey = "billing:zero_balance_alert:{$project->tenant_id}";
|
||||
if (Cache::store('redis')->add($cacheKey, true, now()->addHour())) {
|
||||
$project->loadMissing('tenant');
|
||||
app(NotificationService::class)->notifyZeroBalancePaused(
|
||||
$project->tenant,
|
||||
$project,
|
||||
$e->priceKopecks,
|
||||
);
|
||||
}
|
||||
|
||||
// ProcessWebhookJob-pattern: setRelation чтобы NotificationService
|
||||
// мог подтянуть deal->project без N+1 lookup'а под RLS.
|
||||
$deal->setRelation('project', $project);
|
||||
$notifier->notifyNewLead($tenant, $deal);
|
||||
|
||||
return true;
|
||||
});
|
||||
Log::warning('billing.project_paused_insufficient_balance', [
|
||||
'tenant_id' => $project->tenant_id,
|
||||
'project_id' => $project->id,
|
||||
'supplier_lead_id' => $lead->id,
|
||||
'price_kopecks' => $e->priceKopecks,
|
||||
'balance_rub' => $e->balanceRub,
|
||||
'balance_leads' => $e->balanceLeads,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs\Supplier;
|
||||
|
||||
use App\Jobs\RouteSupplierLeadJob;
|
||||
use App\Mail\CsvDriftAlertMail;
|
||||
use App\Models\SupplierLead;
|
||||
use App\Services\Supplier\SupplierCsvParser;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Cache\LockProvider;
|
||||
use Illuminate\Contracts\Mail\Mailer;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Foundation\Queue\Queueable as FoundationQueueable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Hourly CSV reconciliation с порталом поставщика.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §5.3
|
||||
*
|
||||
* Алгоритм:
|
||||
* 1. Cache::lock на 600s — overlap-защита.
|
||||
* 2. INSERT supplier_csv_reconcile_log (status='running').
|
||||
* 3. Download CSV за окно 25h.
|
||||
* 4. Parse → собираем ['vid' => row].
|
||||
* 5. SELECT existing vid'ы из supplier_leads (BYPASSRLS).
|
||||
* 6. Diff = missing.
|
||||
* 7. Для каждой missing — INSERT supplier_leads (recovered_from_csv_at) + dispatch RouteJob.
|
||||
* 8. UPDATE log с метриками + status.
|
||||
* 9. drift > 5% → CsvDriftAlertMail + alert_email_sent_at.
|
||||
* 10. На exception — status='failed', throw.
|
||||
*/
|
||||
final class CsvReconcileJob implements ShouldQueue
|
||||
{
|
||||
use FoundationQueueable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public int $timeout = 300;
|
||||
|
||||
private const DB_CONNECTION = 'pgsql_supplier';
|
||||
|
||||
private const DRIFT_THRESHOLD = 0.05;
|
||||
|
||||
private const WINDOW_HOURS = 25;
|
||||
|
||||
private const LOCK_NAME = 'supplier:csv_reconcile';
|
||||
|
||||
private const LOCK_TTL_SECONDS = 600;
|
||||
|
||||
public function handle(
|
||||
SupplierPortalClient $portal,
|
||||
SupplierCsvParser $parser,
|
||||
Mailer $mailer,
|
||||
): void {
|
||||
/** @var LockProvider $lockStore */
|
||||
$lockStore = Cache::store('redis');
|
||||
$lock = $lockStore->lock(self::LOCK_NAME, self::LOCK_TTL_SECONDS);
|
||||
if (! $lock->get()) {
|
||||
Log::info('csv_reconcile.skipped_overlap');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$windowEnd = Carbon::now();
|
||||
$windowStart = (clone $windowEnd)->subHours(self::WINDOW_HOURS);
|
||||
|
||||
$logId = DB::connection(self::DB_CONNECTION)
|
||||
->table('supplier_csv_reconcile_log')
|
||||
->insertGetId([
|
||||
'started_at' => now(),
|
||||
'window_start' => $windowStart,
|
||||
'window_end' => $windowEnd,
|
||||
'status' => 'running',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
try {
|
||||
$csv = $portal->downloadLeadsCsv($windowStart, $windowEnd);
|
||||
|
||||
/** @var array<string, array<string, mixed>> $csvByVid */
|
||||
$csvByVid = [];
|
||||
foreach ($parser->parse($csv) as $row) {
|
||||
$csvByVid[(string) $row['vid']] = $row;
|
||||
}
|
||||
$totalCsvRows = count($csvByVid);
|
||||
|
||||
$existing = DB::connection(self::DB_CONNECTION)
|
||||
->table('supplier_leads')
|
||||
->where('received_at', '>=', $windowStart)
|
||||
->where('received_at', '<', $windowEnd->copy()->addHour())
|
||||
->pluck('vid')
|
||||
->map(fn ($v) => (string) $v)
|
||||
->all();
|
||||
|
||||
$existingMap = array_flip($existing);
|
||||
$missing = array_diff_key($csvByVid, $existingMap);
|
||||
|
||||
$recoveredCount = 0;
|
||||
foreach ($missing as $vid => $row) {
|
||||
$platform = $this->extractPlatform((string) ($row['project'] ?? ''));
|
||||
if ($platform === null) {
|
||||
Log::warning('csv_reconcile.unparseable_project_skipped', [
|
||||
'vid' => $vid,
|
||||
'project' => $row['project'] ?? null,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$lead = SupplierLead::create([
|
||||
'vid' => (int) $vid,
|
||||
'platform' => $platform,
|
||||
'phone' => (string) $row['phone'],
|
||||
'raw_payload' => $row,
|
||||
'received_at' => Carbon::createFromTimestamp((int) $row['time']),
|
||||
'recovered_from_csv_at' => now(),
|
||||
'source' => 'csv_recovery',
|
||||
'supplier_project_id' => null, // ResolverStub разрезолвит при RouteJob run
|
||||
]);
|
||||
RouteSupplierLeadJob::dispatch($lead->id);
|
||||
$recoveredCount++;
|
||||
} catch (QueryException $e) {
|
||||
if (str_contains($e->getMessage(), 'unique')) {
|
||||
Log::info('csv_reconcile.duplicate_vid_skipped', ['vid' => $vid]);
|
||||
|
||||
continue;
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
$matchedCount = $totalCsvRows - count($missing);
|
||||
$driftRatio = $totalCsvRows > 0 ? count($missing) / $totalCsvRows : 0.0;
|
||||
$status = $driftRatio > self::DRIFT_THRESHOLD ? 'drift_alert' : 'ok';
|
||||
|
||||
$update = [
|
||||
'finished_at' => now(),
|
||||
'total_csv_rows' => $totalCsvRows,
|
||||
'matched_count' => $matchedCount,
|
||||
'recovered_count' => $recoveredCount,
|
||||
'drift_ratio' => $driftRatio,
|
||||
'status' => $status,
|
||||
];
|
||||
|
||||
if ($status === 'drift_alert') {
|
||||
$mailer->to((string) config('services.supplier.alert_email'))
|
||||
->send(new CsvDriftAlertMail(
|
||||
reconcileLogId: $logId,
|
||||
totalCsvRows: $totalCsvRows,
|
||||
missingCount: count($missing),
|
||||
recoveredCount: $recoveredCount,
|
||||
driftRatio: $driftRatio,
|
||||
windowStart: $windowStart,
|
||||
windowEnd: $windowEnd,
|
||||
));
|
||||
$update['alert_email_sent_at'] = now();
|
||||
}
|
||||
|
||||
DB::connection(self::DB_CONNECTION)
|
||||
->table('supplier_csv_reconcile_log')
|
||||
->where('id', $logId)
|
||||
->update($update);
|
||||
|
||||
} catch (Throwable $e) {
|
||||
DB::connection(self::DB_CONNECTION)
|
||||
->table('supplier_csv_reconcile_log')
|
||||
->where('id', $logId)
|
||||
->update([
|
||||
'finished_at' => now(),
|
||||
'status' => 'failed',
|
||||
'error_message' => substr($e->getMessage(), 0, 1000),
|
||||
]);
|
||||
throw $e;
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает platform (B1/B2/B3) из поля raw_payload['project'] CSV-строки.
|
||||
* Формат project: `B[123]_<rest>` (например `B1_a.com`, `B2_79991234567`).
|
||||
* Возвращает null если не парсится — caller пропустит строку с warning.
|
||||
*/
|
||||
private function extractPlatform(string $project): ?string
|
||||
{
|
||||
if (preg_match('/^(B[123])_/', $project, $m) === 1) {
|
||||
return $m[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Синхронизирует Лидерра-проект с supplier_projects на B1/B2/B3
|
||||
* в зависимости от signal_type.
|
||||
*
|
||||
* Семантика:
|
||||
* site / call → B1 + B2 + B3
|
||||
* sms с keyword → B2 + B3
|
||||
* sms без keyword → B3
|
||||
*
|
||||
* Записывает полученные supplier_projects.id в projects.supplier_b{1,2,3}_project_id.
|
||||
*
|
||||
* Retry: 3 попытки с backoff [15s, 60s, 300s].
|
||||
*
|
||||
* Spec: docs/superpowers/plans/2026-05-11-plan5-frontend-projects-ui-plan.md Task 4
|
||||
*/
|
||||
class SyncSupplierProjectJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
/** @var array<int, int> */
|
||||
public array $backoff = [15, 60, 300];
|
||||
|
||||
public function __construct(public int $projectId) {}
|
||||
|
||||
public function handle(SupplierPortalClient $client): void
|
||||
{
|
||||
$project = Project::find($this->projectId);
|
||||
|
||||
if ($project === null) {
|
||||
Log::warning("SyncSupplierProjectJob: project {$this->projectId} not found — skipping");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$platforms = $this->resolvePlatforms($project);
|
||||
|
||||
foreach ($platforms as $platform) {
|
||||
$uniqueKey = $this->buildUniqueKey($project, $platform);
|
||||
$supplierProjectId = $client->ensureSupplierProject($platform, $project->signal_type, $uniqueKey);
|
||||
$column = 'supplier_'.strtolower($platform).'_project_id';
|
||||
$project->{$column} = $supplierProjectId;
|
||||
}
|
||||
|
||||
$project->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает список uppercase platform-кодов для данного project.
|
||||
* Коды соответствуют CHECK constraint: 'B1' / 'B2' / 'B3'.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function resolvePlatforms(Project $project): array
|
||||
{
|
||||
if (in_array($project->signal_type, ['site', 'call'], true)) {
|
||||
return ['B1', 'B2', 'B3'];
|
||||
}
|
||||
|
||||
if ($project->signal_type === 'sms') {
|
||||
return $project->sms_keyword ? ['B2', 'B3'] : ['B3'];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Строит unique_key для пары (project, platform):
|
||||
* site/call → signal_identifier (домен / телефон)
|
||||
* sms B2 → sender + '+' + keyword
|
||||
* sms B3 → sender
|
||||
*/
|
||||
private function buildUniqueKey(Project $project, string $platform): string
|
||||
{
|
||||
if (in_array($project->signal_type, ['site', 'call'], true)) {
|
||||
return (string) $project->signal_identifier;
|
||||
}
|
||||
|
||||
// sms
|
||||
$sender = (string) ($project->sms_senders[0] ?? '');
|
||||
|
||||
if ($platform === 'B2') {
|
||||
return $sender.'+'.($project->sms_keyword ?? '');
|
||||
}
|
||||
|
||||
// B3
|
||||
return $sender;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Email алерт админу Лидерры о расхождении CSV-сверки > 5%.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §5.6
|
||||
*/
|
||||
final class CsvDriftAlertMail extends Mailable
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly int $reconcileLogId,
|
||||
public readonly int $totalCsvRows,
|
||||
public readonly int $missingCount,
|
||||
public readonly int $recoveredCount,
|
||||
public readonly float $driftRatio,
|
||||
public readonly CarbonInterface $windowStart,
|
||||
public readonly CarbonInterface $windowEnd,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
$pct = number_format($this->driftRatio * 100, 2, ',', ' ');
|
||||
$window = $this->windowStart->format('Y-m-d H:i').' — '.$this->windowEnd->format('Y-m-d H:i');
|
||||
|
||||
return new Envelope(
|
||||
subject: "Лидерра ↔ Поставщик: расхождение CSV > 5% за {$window} ({$pct}%)",
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(view: 'emails.csv_drift_alert');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Email клиенту Лидерры о приостановке проекта из-за недостаточного баланса.
|
||||
*
|
||||
* Триггер: RouteSupplierLeadJob::handleInsufficientBalance ловит
|
||||
* InsufficientBalanceException, помечает projects.is_active=false и шлёт это
|
||||
* письмо с rate-limit 1/час/tenant (Redis SETNX).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §4.4
|
||||
*/
|
||||
final class ZeroBalancePausedMail extends Mailable
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly Tenant $tenant,
|
||||
public readonly Project $project,
|
||||
public readonly int $requiredPriceKopecks,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: "Проект «{$this->project->name}» приостановлен — недостаточно средств",
|
||||
to: [$this->tenant->contact_email],
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(view: 'emails.zero_balance_paused');
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ class LeadCharge extends Model
|
||||
'deal_id',
|
||||
'deal_received_at',
|
||||
'tier_no',
|
||||
'charge_source',
|
||||
'price_per_lead_kopecks',
|
||||
'charged_at',
|
||||
'created_at',
|
||||
|
||||
@@ -4,11 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Carbon\CarbonInterface;
|
||||
use Database\Factories\ProjectFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Проект (лид-канал) внутри тенанта.
|
||||
@@ -36,6 +38,8 @@ class Project extends Model
|
||||
'tag',
|
||||
'type',
|
||||
'is_active',
|
||||
// Plan 5 Task 1 (schema v8.20): soft archive flow — lifecycle-state рядом с is_active.
|
||||
'archived_at',
|
||||
'daily_limit_target',
|
||||
'effective_daily_limit_today',
|
||||
'effective_limit_calculated_at',
|
||||
@@ -74,6 +78,8 @@ class Project extends Model
|
||||
'sms_senders' => 'array',
|
||||
'delivered_in_month' => 'integer',
|
||||
'delivered_today' => 'integer',
|
||||
// Plan 5 Task 1 (schema v8.20): soft archive.
|
||||
'archived_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -126,4 +132,113 @@ class Project extends Model
|
||||
{
|
||||
return $query->where('signal_type', $signalType)->where('signal_identifier', $identifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Не архивированные проекты (archived_at IS NULL).
|
||||
*
|
||||
* Внимание: scope не фильтрует is_active. Приостановленные (is_active=false)
|
||||
* проекты сюда попадают — это разные lifecycle-состояния. Если нужны только
|
||||
* «работающие» (не архив И не на паузе) — комбинируйте:
|
||||
* ->active()->where('is_active', true).
|
||||
*
|
||||
* @param Builder<Project> $query
|
||||
* @return Builder<Project>
|
||||
*/
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNull('archived_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* Архивированные проекты (archived_at IS NOT NULL).
|
||||
*
|
||||
* @param Builder<Project> $query
|
||||
* @return Builder<Project>
|
||||
*/
|
||||
public function scopeArchived(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNotNull('archived_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* Все связанные SupplierProject из eager-loaded BelongsTo отношений.
|
||||
*
|
||||
* Используется внутри aggregateSyncStatus(), aggregateLastSyncedAt(),
|
||||
* getSupplierLinks() — устраняет N+1 (каждый из трёх методов вызывал
|
||||
* SupplierProject::find() независимо; теперь читает из уже загруженных
|
||||
* $this->supplierB1 / supplierB2 / supplierB3).
|
||||
*
|
||||
* Требует eager-load: Project::with(['supplierB1', 'supplierB2', 'supplierB3']).
|
||||
*
|
||||
* @return Collection<int, SupplierProject>
|
||||
*/
|
||||
private function resolvedSupplierProjects(): Collection
|
||||
{
|
||||
return collect([$this->supplierB1, $this->supplierB2, $this->supplierB3])->filter()->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Агрегированный статус синхронизации по всем связанным SupplierProject.
|
||||
*
|
||||
* Логика: если нет ни одного — pending; если есть failed — failed;
|
||||
* если есть pending — pending; иначе — ok.
|
||||
*
|
||||
* Читает из eager-loaded отношений (см. resolvedSupplierProjects()).
|
||||
*/
|
||||
public function aggregateSyncStatus(): string
|
||||
{
|
||||
$statuses = $this->resolvedSupplierProjects()->pluck('sync_status');
|
||||
|
||||
if ($statuses->isEmpty()) {
|
||||
return 'pending';
|
||||
}
|
||||
if ($statuses->contains('failed')) {
|
||||
return 'failed';
|
||||
}
|
||||
if ($statuses->contains('pending')) {
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
/**
|
||||
* Минимальная дата последней синхронизации по всем связанным SupplierProject.
|
||||
*
|
||||
* Использует sortBy по timestamp вместо Collection::min() на Carbon-объектах
|
||||
* (min() сравнивает строковое представление, что ненадёжно для Carbon).
|
||||
*
|
||||
* Читает из eager-loaded отношений (см. resolvedSupplierProjects()).
|
||||
*/
|
||||
public function aggregateLastSyncedAt(): ?string
|
||||
{
|
||||
$ts = $this->resolvedSupplierProjects()
|
||||
->pluck('last_synced_at')
|
||||
->filter()
|
||||
->sortBy(fn (CarbonInterface $c) => $c->timestamp)
|
||||
->first();
|
||||
|
||||
return $ts?->toIso8601String();
|
||||
}
|
||||
|
||||
/**
|
||||
* Массив ссылок на связанные SupplierProject (для show endpoint).
|
||||
*
|
||||
* Читает из eager-loaded отношений (см. resolvedSupplierProjects()).
|
||||
*
|
||||
* @return array<int, array{platform: string, supplier_project_id: int, sync_status: string|null, last_synced_at: string|null}>
|
||||
*/
|
||||
public function getSupplierLinks(): array
|
||||
{
|
||||
return collect(['b1' => $this->supplierB1, 'b2' => $this->supplierB2, 'b3' => $this->supplierB3])
|
||||
->filter()
|
||||
->map(fn (SupplierProject $sp, string $platform) => [
|
||||
'platform' => $platform,
|
||||
'supplier_project_id' => $sp->id,
|
||||
'sync_status' => $sp->sync_status,
|
||||
'last_synced_at' => $sp->last_synced_at?->toIso8601String(),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ class SupplierLead extends Model
|
||||
'received_at',
|
||||
'source',
|
||||
'processed_at',
|
||||
'recovered_from_csv_at',
|
||||
'deals_created_count',
|
||||
'error',
|
||||
];
|
||||
@@ -48,6 +49,7 @@ class SupplierLead extends Model
|
||||
'raw_payload' => 'array',
|
||||
'received_at' => 'datetime',
|
||||
'processed_at' => 'datetime',
|
||||
'recovered_from_csv_at' => 'datetime',
|
||||
'vid' => 'integer',
|
||||
'deals_created_count' => 'integer',
|
||||
];
|
||||
|
||||
@@ -42,7 +42,9 @@ class Tenant extends Model
|
||||
'last_activity_at',
|
||||
'last_webhook_at',
|
||||
'desired_daily_numbers',
|
||||
'delivered_in_month',
|
||||
'api_key_limit',
|
||||
'limits',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
@@ -54,7 +56,10 @@ class Tenant extends Model
|
||||
'balance_leads' => 'integer',
|
||||
'trial_leads_used' => 'integer',
|
||||
'desired_daily_numbers' => 'integer',
|
||||
'delivered_in_month' => 'integer',
|
||||
'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',
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
use App\Models\PricingTier;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
/**
|
||||
* DB-обёртка для текущей активной сетки pricing_tiers.
|
||||
*
|
||||
* Логика: для данной даты `$at` возвращаются все ступени с
|
||||
* MAX(effective_from) WHERE effective_from <= $at AND is_active=true,
|
||||
* сгруппированные по tier_no.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §3.1
|
||||
*/
|
||||
final class PricingTierRepository
|
||||
{
|
||||
/**
|
||||
* @return Collection<int, PricingTier>
|
||||
*/
|
||||
public function activeAt(CarbonInterface $at): Collection
|
||||
{
|
||||
// Для каждого tier_no берём строку с MAX(effective_from) <= $at
|
||||
// (учёт сценария «новая сетка перекрывает старую»).
|
||||
/** @var Collection<int, PricingTier> $result */
|
||||
$result = PricingTier::query()
|
||||
->where('is_active', true)
|
||||
->where('effective_from', '<=', $at->toDateString())
|
||||
->orderBy('tier_no')
|
||||
->orderBy('effective_from', 'desc')
|
||||
->get()
|
||||
->groupBy('tier_no')
|
||||
->map(fn (Collection $group) => $group->first())
|
||||
->values()
|
||||
->sortBy('tier_no')
|
||||
->values();
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use App\Models\PricingTier;
|
||||
|
||||
/**
|
||||
* Read-only DTO с результатом charge'а: source (prepaid/rub), снимок ступени, цена в копейках.
|
||||
*/
|
||||
final readonly class ChargeResult
|
||||
{
|
||||
public function __construct(
|
||||
public string $source,
|
||||
public PricingTier $tier,
|
||||
public int $priceKopecks,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use App\Exceptions\Billing\InsufficientBalanceException;
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\Deal;
|
||||
use App\Models\LeadCharge;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\SupplierLead;
|
||||
use App\Models\Tenant;
|
||||
use App\Repositories\PricingTierRepository;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Командный сервис биллинга на горячем пути доставки лида.
|
||||
*
|
||||
* Контракт: вызывается ВНУТРИ открытой DB-транзакции под lockForUpdate(Tenant).
|
||||
* Применяет dual-balance flow:
|
||||
* 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 движения баланса)
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §3
|
||||
*/
|
||||
final class LedgerService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PricingTierResolver $resolver,
|
||||
private readonly PricingTierRepository $tiers,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws InsufficientBalanceException когда balance_leads=0 AND balance_rub*100<priceKopecks.
|
||||
* До throw НЕ модифицирует tenant/charges/transactions/costs.
|
||||
*
|
||||
* @precondition caller wraps in DB::transaction with lockForUpdate($lockedTenant).
|
||||
* Атомарность всех INSERT'ов (lead_charges + balance_transactions + supplier_lead_costs)
|
||||
* обеспечивается этой внешней транзакцией.
|
||||
*/
|
||||
public function chargeForDelivery(
|
||||
Tenant $lockedTenant,
|
||||
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);
|
||||
$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]);
|
||||
}
|
||||
$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,
|
||||
'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,
|
||||
'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) {
|
||||
/** @var Supplier $supplier */
|
||||
$supplier = Supplier::findOrFail($supplierId);
|
||||
DB::table('supplier_lead_costs')->insert([
|
||||
'deal_id' => $deal->id,
|
||||
'received_at' => $deal->received_at,
|
||||
'supplier_id' => $supplierId,
|
||||
'cost_rub' => $supplier->cost_rub,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* supplier_id из $lead->supplier_project->platform → suppliers.code (lowercase).
|
||||
* supplier_projects не имеет колонки supplier_id (см. schema.sql §2.3, sharing-model);
|
||||
* supplier_id выводится из platform (B1/B2/B3) через лookup suppliers WHERE code='b1'/'b2'/'b3'.
|
||||
*
|
||||
* Fallback: парсим platform из raw_payload['project'] (B1_xxx → 'b1'), если у lead'а нет
|
||||
* supplier_project_id (legacy/orphan webhook).
|
||||
*/
|
||||
private function resolveSupplierId(SupplierLead $lead): ?int
|
||||
{
|
||||
if ($lead->supplier_project_id !== null) {
|
||||
$sp = DB::table('supplier_projects')->where('id', $lead->supplier_project_id)->first();
|
||||
if ($sp !== null && in_array($sp->platform, ['B1', 'B2', 'B3'], true)) {
|
||||
$supplier = Supplier::where('code', strtolower($sp->platform))->first();
|
||||
if ($supplier !== null) {
|
||||
return (int) $supplier->id;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: парсим platform из raw_payload['project'] (B1_xxx → 'b1')
|
||||
$project = trim((string) ($lead->raw_payload['project'] ?? ''));
|
||||
if (preg_match('/^(B[123])_/', $project, $m) === 1) {
|
||||
$code = strtolower($m[1]);
|
||||
$supplier = Supplier::where('code', $code)->first();
|
||||
|
||||
return $supplier?->id;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use App\Models\PricingTier;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Pure resolver: «в какую ступень pricing_tiers попадает N-й лид» (1-based).
|
||||
*
|
||||
* Логика: tier 1 покрывает 1..leads_in_tier_1; tier 2 — следующие leads_in_tier_2;
|
||||
* tier 7 с leads_in_tier=NULL ловит всё свыше суммарного объёма tiers 1-6.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §3.1
|
||||
*/
|
||||
final class PricingTierResolver
|
||||
{
|
||||
/**
|
||||
* @param Collection<int, PricingTier> $tiers активные ступени (не обязательно отсортированы)
|
||||
* @param int $leadOrdinal номер лида в текущем месяце (1-based)
|
||||
*/
|
||||
public function resolveForCount(Collection $tiers, int $leadOrdinal): PricingTier
|
||||
{
|
||||
if ($leadOrdinal < 1) {
|
||||
throw new \InvalidArgumentException("leadOrdinal must be >= 1, got {$leadOrdinal}");
|
||||
}
|
||||
|
||||
if ($tiers->isEmpty()) {
|
||||
throw new RuntimeException('No active pricing tiers — cannot resolve');
|
||||
}
|
||||
|
||||
/** @var Collection<int, PricingTier> $sorted */
|
||||
$sorted = $tiers->sortBy('tier_no')->values();
|
||||
|
||||
$cumulative = 0;
|
||||
foreach ($sorted as $tier) {
|
||||
// tier 7 (или любой с leads_in_tier=NULL) — «всё свыше»
|
||||
if ($tier->leads_in_tier === null) {
|
||||
return $tier;
|
||||
}
|
||||
|
||||
$cumulative += (int) $tier->leads_in_tier;
|
||||
if ($leadOrdinal <= $cumulative) {
|
||||
return $tier;
|
||||
}
|
||||
}
|
||||
|
||||
// Если ни одна ступень не покрыла (leadOrdinal > сумма всех leads_in_tier
|
||||
// И ни у одной не было NULL) — возвращаем последнюю как fallback.
|
||||
return $sorted->last();
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,9 @@ use InvalidArgumentException;
|
||||
* 2. Фильтр: is_active=true.
|
||||
* 3. Workdays: (delivery_days_mask & today_bit) <> 0, today_bit = 1 << (ISO_DOW - 1).
|
||||
* 4. delivered_today < COALESCE(effective_daily_limit_today, daily_limit_target).
|
||||
* 5. tenants.balance_leads > 0 (через WHERE EXISTS).
|
||||
* 5. tenants.balance_leads > 0 OR tenants.balance_rub > 0 (через WHERE EXISTS;
|
||||
* Plan 4 Task 4: dual-balance — rub-only tenant ДОЛЖЕН пройти, LedgerService
|
||||
* сам резолвит prepaid/rub и кидает InsufficientBalanceException, если оба = 0).
|
||||
* 6. Region match через PhonePrefixService::phoneMatchesRegions (в PHP, не в SQL —
|
||||
* district-bit резолвится по 3/4-значному коду в PHP-словаре).
|
||||
* 7. Сортировка: created_at ASC, id ASC (детерминированно — spec §6 step 4).
|
||||
@@ -66,10 +68,17 @@ class LeadRouter
|
||||
'delivered_today < COALESCE(effective_daily_limit_today, daily_limit_target)'
|
||||
)
|
||||
->whereExists(function ($q): void {
|
||||
// Plan 4 Task 4: dual-balance — допускаем rub-only tenant'ов.
|
||||
// LedgerService::chargeForDelivery сам выбирает prepaid (balance_leads--)
|
||||
// или rub (balance_rub -= tier_price) и кидает InsufficientBalanceException,
|
||||
// если ОБА = 0. До Plan 4 фильтр был строгий balance_leads > 0 (prepaid only).
|
||||
$q->selectRaw('1')
|
||||
->from('tenants')
|
||||
->whereColumn('tenants.id', 'projects.tenant_id')
|
||||
->where('tenants.balance_leads', '>', 0);
|
||||
->where(function ($qq): void {
|
||||
$qq->where('tenants.balance_leads', '>', 0)
|
||||
->orWhere('tenants.balance_rub', '>', 0);
|
||||
});
|
||||
})
|
||||
->orderBy('created_at')
|
||||
->orderBy('id')
|
||||
|
||||
@@ -10,8 +10,10 @@ 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;
|
||||
use App\Models\Project;
|
||||
use App\Models\Reminder;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
@@ -191,6 +193,23 @@ class NotificationService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Уведомление об auto-pause проекта на нулевом балансе (Plan 4 Task 6).
|
||||
*
|
||||
* В отличие от notifyZeroBalance (per-user prefs, rejected-flow), это
|
||||
* прямое письмо tenant.contact_email — caller (RouteSupplierLeadJob::
|
||||
* handleInsufficientBalance) уже применил rate-limit 1/час/tenant через
|
||||
* Cache::store('redis')->add() (SETNX).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §4.4.
|
||||
*/
|
||||
public function notifyZeroBalancePaused(Tenant $tenant, Project $project, int $requiredPriceKopecks): void
|
||||
{
|
||||
Mail::to($tenant->contact_email)->send(
|
||||
new ZeroBalancePausedMail($tenant, $project, $requiredPriceKopecks)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Уведомление об успешном пополнении. Триггер: после INSERT в
|
||||
* balance_transactions с type='topup' (endpoint пополнения отдельным
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Project;
|
||||
|
||||
use App\Jobs\SyncSupplierProjectJob;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
|
||||
class ProjectService
|
||||
{
|
||||
public function update(Project $project, array $data): Project
|
||||
{
|
||||
// Immutable fields — silently drop (don't 422)
|
||||
unset(
|
||||
$data['tenant_id'], $data['signal_type'], $data['signal_identifier'],
|
||||
$data['delivered_today'], $data['delivered_in_month'],
|
||||
$data['supplier_b1_project_id'], $data['supplier_b2_project_id'], $data['supplier_b3_project_id'],
|
||||
$data['archived_at'],
|
||||
);
|
||||
|
||||
if (isset($data['daily_limit_target']) && $data['daily_limit_target'] < $project->delivered_today) {
|
||||
throw new HttpResponseException(response()->json([
|
||||
'errors' => [
|
||||
'daily_limit_target' => [
|
||||
"Лимит не может быть меньше уже доставленных лидов сегодня ({$project->delivered_today}).",
|
||||
],
|
||||
],
|
||||
], 422));
|
||||
}
|
||||
|
||||
$needsResync = array_key_exists('sms_senders', $data) || array_key_exists('sms_keyword', $data);
|
||||
|
||||
$project->update($data);
|
||||
|
||||
if ($needsResync) {
|
||||
SyncSupplierProjectJob::dispatch($project->id);
|
||||
}
|
||||
|
||||
return $project->fresh();
|
||||
}
|
||||
|
||||
public function archive(Project $project): void
|
||||
{
|
||||
if ($project->archived_at !== null) {
|
||||
throw new HttpResponseException(response()->json([
|
||||
'message' => 'Project уже архивирован.',
|
||||
], 409));
|
||||
}
|
||||
$project->update([
|
||||
'is_active' => false,
|
||||
'archived_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function triggerSync(Project $project): void
|
||||
{
|
||||
SyncSupplierProjectJob::dispatch($project->id);
|
||||
}
|
||||
|
||||
public const BULK_MAX = 500;
|
||||
|
||||
public function resolveBulkScope(int $tenantId, ?array $ids, ?array $filter): array
|
||||
{
|
||||
if (! empty($ids)) {
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
$query = Project::where('tenant_id', $tenantId);
|
||||
|
||||
if (! empty($filter['signal_type'])) {
|
||||
$query->where('signal_type', $filter['signal_type']);
|
||||
}
|
||||
if (! empty($filter['status'])) {
|
||||
match ($filter['status']) {
|
||||
'active' => $query->where('is_active', true)->whereNull('archived_at'),
|
||||
'paused' => $query->where('is_active', false)->whereNull('archived_at'),
|
||||
'archived' => $query->whereNotNull('archived_at'),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
if (! empty($filter['search'])) {
|
||||
$query->where('name', 'ilike', '%'.$filter['search'].'%');
|
||||
}
|
||||
|
||||
return $query->pluck('id')->all();
|
||||
}
|
||||
|
||||
public function bulkAction(int $tenantId, string $action, array $payload): array
|
||||
{
|
||||
$ids = $payload['ids'] ?? [];
|
||||
if (empty($ids)) {
|
||||
return ['updated' => 0, 'skipped' => [], 'warnings' => []];
|
||||
}
|
||||
|
||||
$query = Project::where('tenant_id', $tenantId)->whereIn('id', $ids);
|
||||
|
||||
return match ($action) {
|
||||
'pause' => $this->bulkSimpleUpdate($query, ['is_active' => false]),
|
||||
'resume' => $this->bulkSimpleUpdate($query, ['is_active' => true]),
|
||||
'archive' => $this->bulkSimpleUpdate($query, ['is_active' => false, 'archived_at' => now()]),
|
||||
'update_regions' => $this->bulkUpdateRegions($query, $payload),
|
||||
'update_days' => $this->bulkUpdateDays($query, $payload),
|
||||
'update_limit' => $this->bulkUpdateLimit($query, $payload),
|
||||
};
|
||||
}
|
||||
|
||||
private function bulkSimpleUpdate($query, array $update): array
|
||||
{
|
||||
$updated = $query->update($update);
|
||||
|
||||
return ['updated' => $updated, 'skipped' => [], 'warnings' => []];
|
||||
}
|
||||
|
||||
private function bulkUpdateRegions($query, array $payload): array
|
||||
{
|
||||
$add = (int) ($payload['add'] ?? 0);
|
||||
$remove = (int) ($payload['remove'] ?? 0);
|
||||
|
||||
// region_mask = (region_mask | add) & ~remove, clamped to 8 bits (0–255)
|
||||
$updated = $query->update([
|
||||
'region_mask' => \DB::raw("(region_mask | {$add}) & ~{$remove} & 255"),
|
||||
]);
|
||||
|
||||
return ['updated' => $updated, 'skipped' => [], 'warnings' => []];
|
||||
}
|
||||
|
||||
private function bulkUpdateDays($query, array $payload): array
|
||||
{
|
||||
$add = (int) ($payload['add'] ?? 0);
|
||||
$remove = (int) ($payload['remove'] ?? 0);
|
||||
|
||||
$updated = $query->update([
|
||||
'delivery_days_mask' => \DB::raw("(delivery_days_mask | {$add}) & ~{$remove} & 127"),
|
||||
]);
|
||||
|
||||
return ['updated' => $updated, 'skipped' => [], 'warnings' => []];
|
||||
}
|
||||
|
||||
private function bulkUpdateLimit($query, array $payload): array
|
||||
{
|
||||
$delta = $payload['delta'] ?? null;
|
||||
$replace = $payload['replace'] ?? null;
|
||||
|
||||
$projects = (clone $query)->select(['id', 'daily_limit_target', 'delivered_today'])->get();
|
||||
|
||||
$updatableIds = [];
|
||||
$skipped = [];
|
||||
|
||||
foreach ($projects as $p) {
|
||||
$newValue = $replace !== null
|
||||
? (int) $replace
|
||||
: (int) $p->daily_limit_target + (int) $delta;
|
||||
|
||||
if ($newValue < (int) $p->delivered_today) {
|
||||
$skipped[] = ['id' => $p->id, 'reason' => 'below_delivered_today'];
|
||||
} else {
|
||||
$updatableIds[$p->id] = $newValue;
|
||||
}
|
||||
}
|
||||
|
||||
$updated = 0;
|
||||
|
||||
if (! empty($updatableIds)) {
|
||||
if ($replace !== null) {
|
||||
$updated = Project::whereIn('id', array_keys($updatableIds))
|
||||
->update(['daily_limit_target' => (int) $replace]);
|
||||
} else {
|
||||
// delta — обновляем по одному (count bounded by MAX 500).
|
||||
foreach ($updatableIds as $id => $newValue) {
|
||||
Project::where('id', $id)->update(['daily_limit_target' => $newValue]);
|
||||
$updated++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ['updated' => $updated, 'skipped' => $skipped, 'warnings' => []];
|
||||
}
|
||||
|
||||
public function create(Tenant $tenant, array $data): Project
|
||||
{
|
||||
$limit = (int) ($tenant->limits['max_projects'] ?? 10);
|
||||
$current = Project::where('tenant_id', $tenant->id)->active()->count();
|
||||
if ($current >= $limit) {
|
||||
throw new HttpResponseException(response()->json([
|
||||
'message' => "Достигнут лимит проектов ({$limit}). Смените тариф.",
|
||||
], 403));
|
||||
}
|
||||
|
||||
$data['tenant_id'] = $tenant->id;
|
||||
$data['is_active'] = true;
|
||||
$project = Project::create($data);
|
||||
|
||||
SyncSupplierProjectJob::dispatch($project->id);
|
||||
|
||||
return $project->fresh();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Supplier;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Streaming-парсер CSV-экспорта `/admin/report/index?type=49` поставщика.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §5.2
|
||||
* Ожидаемые столбцы: vid;project;tag;phone;phones;time (placeholder; уточнится
|
||||
* после Plan 3 Tasks 1-2 discovery с credentials поставщика).
|
||||
*
|
||||
* Возвращает Generator — вызывающий (CsvReconcileJob) сам решает, сколько
|
||||
* копить в памяти. BOM + CRLF поддерживаются. Malformed rows skip + log.
|
||||
*/
|
||||
final class SupplierCsvParser
|
||||
{
|
||||
private const EXPECTED_COLUMNS = 6;
|
||||
|
||||
/**
|
||||
* @return iterable<int, array{vid: string, project: string, phone: string, time: int}>
|
||||
*/
|
||||
public function parse(string $rawCsv): iterable
|
||||
{
|
||||
if ($rawCsv === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Убираем BOM (UTF-8 BOM = EF BB BF)
|
||||
if (str_starts_with($rawCsv, "\xEF\xBB\xBF")) {
|
||||
$rawCsv = substr($rawCsv, 3);
|
||||
}
|
||||
|
||||
// Нормализуем CRLF → LF
|
||||
$rawCsv = str_replace("\r\n", "\n", $rawCsv);
|
||||
|
||||
$lines = explode("\n", $rawCsv);
|
||||
$headerSkipped = false;
|
||||
$lineNo = 0;
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$lineNo++;
|
||||
if ($line === '') {
|
||||
continue;
|
||||
}
|
||||
if (! $headerSkipped) {
|
||||
$headerSkipped = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$cols = str_getcsv($line, separator: ';');
|
||||
if (count($cols) < self::EXPECTED_COLUMNS) {
|
||||
Log::warning('supplier_csv_parser.malformed_row', [
|
||||
'line_no' => $lineNo,
|
||||
'columns_found' => count($cols),
|
||||
'expected' => self::EXPECTED_COLUMNS,
|
||||
'sample' => substr($line, 0, 100),
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
yield [
|
||||
'vid' => (string) $cols[0],
|
||||
'project' => (string) $cols[1],
|
||||
'phone' => (string) $cols[3],
|
||||
'time' => (int) $cols[5],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,9 @@ use App\Exceptions\Supplier\SupplierAuthException;
|
||||
use App\Exceptions\Supplier\SupplierClientException;
|
||||
use App\Exceptions\Supplier\SupplierTransientException;
|
||||
use App\Jobs\Supplier\RefreshSupplierSessionJob;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Services\Supplier\Dto\SupplierProjectDto;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\Factory as HttpFactory;
|
||||
use Illuminate\Http\Client\Response;
|
||||
@@ -28,12 +30,66 @@ use Illuminate\Support\Facades\Cache;
|
||||
* Авторизация: PHPSESSID cookie + X-CSRF-Token header (Redis cache 'supplier:session').
|
||||
* На 401/403 — single retry через dispatch_sync(RefreshSupplierSessionJob).
|
||||
*/
|
||||
final class SupplierPortalClient
|
||||
class SupplierPortalClient
|
||||
{
|
||||
public function __construct(
|
||||
private readonly HttpFactory $http,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Идемпотентно обеспечивает наличие supplier_project-записи для переданной
|
||||
* тройки (platform, signalType, uniqueKey). Если запись уже существует —
|
||||
* возвращает её id. Иначе — создаёт проект на стороне поставщика через
|
||||
* saveProject() и сохраняет новую запись supplier_projects.
|
||||
*
|
||||
* Используется SyncSupplierProjectJob (Plan 5 Task 4).
|
||||
*
|
||||
* В тестах метод мокируется через $this->mock(SupplierPortalClient::class) —
|
||||
* реальное тело не вызывается.
|
||||
*
|
||||
* @param string $platform B1 / B2 / B3
|
||||
* @param string $signalType site / call / sms
|
||||
* @param string $uniqueKey domain / phone / sender+keyword / sender
|
||||
*/
|
||||
public function ensureSupplierProject(string $platform, string $signalType, string $uniqueKey): int
|
||||
{
|
||||
$existing = SupplierProject::query()
|
||||
->where('platform', $platform)
|
||||
->where('signal_type', $signalType)
|
||||
->where('unique_key', $uniqueKey)
|
||||
->first();
|
||||
|
||||
if ($existing !== null) {
|
||||
return $existing->id;
|
||||
}
|
||||
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: $platform,
|
||||
signalType: $signalType,
|
||||
uniqueKey: $uniqueKey,
|
||||
limit: 0,
|
||||
workdays: [1, 2, 3, 4, 5, 6, 7],
|
||||
regions: [],
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
);
|
||||
|
||||
$externalId = $this->saveProject($dto);
|
||||
|
||||
$sp = SupplierProject::query()->create([
|
||||
'platform' => $platform,
|
||||
'signal_type' => $signalType,
|
||||
'unique_key' => $uniqueKey,
|
||||
'supplier_external_id' => (string) $externalId,
|
||||
'current_limit' => 0,
|
||||
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
|
||||
'current_regions' => null,
|
||||
'sync_status' => 'ok',
|
||||
]);
|
||||
|
||||
return $sp->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, mixed>
|
||||
*/
|
||||
@@ -64,6 +120,27 @@ final class SupplierPortalClient
|
||||
$this->request('POST', '/admin/rt-project-delete', ['id' => $externalId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /admin/report/index?type=49 — CSV-экспорт лидов за окно [from, to].
|
||||
* Auth/retry семантика наследуется от request() (PHPSESSID + X-CSRF-Token +
|
||||
* 401 → RefreshSession + 5xx → SupplierTransientException + 4xx → SupplierClientException).
|
||||
*
|
||||
* Возвращает raw CSV-body (UTF-8 + BOM, CRLF). Парсинг — снаружи через
|
||||
* SupplierCsvParser (streaming через generator).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §5.1
|
||||
*/
|
||||
public function downloadLeadsCsv(CarbonInterface $from, CarbonInterface $to): string
|
||||
{
|
||||
$response = $this->request('GET', '/admin/report/index', [
|
||||
'type' => 49,
|
||||
'from' => $from->format('Y-m-d H:i:s'),
|
||||
'to' => $to->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
return $response->body();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $body
|
||||
*/
|
||||
|
||||
@@ -39,7 +39,7 @@ return [
|
||||
'login' => env('SUPPLIER_LOGIN'),
|
||||
'password' => env('SUPPLIER_PASSWORD'),
|
||||
'portal_url' => env('SUPPLIER_PORTAL_URL', 'https://crm.bp-gr.ru'),
|
||||
'alert_email' => env('SUPPLIER_ALERT_EMAIL'),
|
||||
'alert_email' => env('SUPPLIER_ALERT_EMAIL', 'ops@liderra.ru'),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -26,8 +26,20 @@ class LeadChargeFactory extends Factory
|
||||
'deal_received_at' => now(),
|
||||
'tier_no' => fake()->numberBetween(1, 7),
|
||||
'price_per_lead_kopecks' => fake()->numberBetween(2000, 6000),
|
||||
'charge_source' => 'rub',
|
||||
'charged_at' => now(),
|
||||
'created_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* State для prepaid-списания (price=0).
|
||||
*/
|
||||
public function prepaid(): self
|
||||
{
|
||||
return $this->state(fn () => [
|
||||
'charge_source' => 'prepaid',
|
||||
'price_per_lead_kopecks' => 0,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,13 @@ class SupplierProjectFactory extends Factory
|
||||
public function definition(): array
|
||||
{
|
||||
$platform = fake()->randomElement(['B1', 'B2', 'B3']);
|
||||
// B1 не поддерживает СМС у поставщика (chk_supplier_projects_b1_not_for_sms).
|
||||
$signal = fake()->randomElement($platform === 'B1' ? ['site', 'call'] : ['site', 'call', 'sms']);
|
||||
// Default signal_type ограничен ['site','call'] — это безопасно для
|
||||
// всех трёх платформ (B1 не поддерживает sms по
|
||||
// chk_supplier_projects_b1_not_for_sms). Тесты, которым нужен 'sms',
|
||||
// должны явно передавать ['signal_type' => 'sms'] вместе с B2/B3.
|
||||
// Иначе при ->create(['platform' => 'B1']) signal_type 'sms' из
|
||||
// оригинального randomElement остаётся и нарушает CHECK constraint.
|
||||
$signal = fake()->randomElement(['site', 'call']);
|
||||
|
||||
return [
|
||||
'platform' => $platform,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Guard: schema.sql v8.20+ already contains this column; skip if present
|
||||
// (prevents "duplicate column" error after `migrate:fresh` which loads schema.sql first).
|
||||
if (Schema::hasColumn('projects', 'archived_at')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('projects', function (Blueprint $table) {
|
||||
$table->timestampTz('archived_at')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Внимание: down() не симметричен up()'у. Если schema.sql v8.20 уже добавил
|
||||
// archived_at (через migrate:fresh → load_initial_schema), rollback этой
|
||||
// миграции удалит колонку, что создаст drift с schema.sql. На проекте rollback
|
||||
// применяется только после migrate:fresh, поэтому это приемлемо — но не
|
||||
// используйте миграцию как способ отката v8.19 (нужна отдельная schema-bump).
|
||||
Schema::table('projects', function (Blueprint $table) {
|
||||
$table->dropColumn('archived_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Plan 5 Task 3: добавить limits JSONB в tenants.
|
||||
*
|
||||
* Используется ProjectService::create() для проверки лимита max_projects.
|
||||
* Default '{}' → (int)($tenant->limits['max_projects'] ?? 10) = 10 из сервиса.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (Schema::hasColumn('tenants', 'limits')) {
|
||||
return;
|
||||
}
|
||||
Schema::table('tenants', function (Blueprint $table) {
|
||||
// limits JSONB: {"max_users":5,"max_projects":10,"api_rps":60}
|
||||
// Аналог limits в tariff_plans — per-tenant override лимитов тарифа.
|
||||
$table->jsonb('limits')->default('{}')->after('api_key_limit');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('tenants', function (Blueprint $table) {
|
||||
$table->dropColumn('limits');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
@@ -12,14 +11,14 @@ class DatabaseSeeder extends Seeder
|
||||
|
||||
/**
|
||||
* Seed the application's database.
|
||||
*
|
||||
* Note: the Laravel scaffold default User::factory() seed was removed —
|
||||
* наша схема использует first_name/last_name (а не "name"), и заранее
|
||||
* не было сценария, где этот seed реально вызывался. PricingTierSeeder
|
||||
* (Plan 4) — единственный текущий seed для dev/testing.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// User::factory(10)->create();
|
||||
|
||||
User::factory()->create([
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
$this->call(PricingTierSeeder::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class DemoSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$tenant = Tenant::query()->where('subdomain', 'demo')->first()
|
||||
?? Tenant::factory()->create([
|
||||
'subdomain' => 'demo',
|
||||
'organization_name' => 'Demo Tenant',
|
||||
'contact_email' => 'admin@demo.local',
|
||||
'status' => 'active',
|
||||
'balance_rub' => '1000.00',
|
||||
'balance_leads' => 100,
|
||||
'is_trial' => false,
|
||||
]);
|
||||
|
||||
$admin = User::query()->updateOrCreate(
|
||||
['email' => 'admin@demo.local'],
|
||||
[
|
||||
'tenant_id' => $tenant->id,
|
||||
'password_hash' => Hash::make('password'),
|
||||
'first_name' => 'Demo',
|
||||
'last_name' => 'Admin',
|
||||
'timezone' => 'Europe/Moscow',
|
||||
'is_active' => true,
|
||||
'totp_enabled' => false,
|
||||
'sound_enabled' => true,
|
||||
'email_verified_at' => now(),
|
||||
'notification_preferences' => [
|
||||
'new_lead' => ['inapp' => true, 'push' => true, 'email' => false],
|
||||
'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],
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$this->seedProjects($tenant->id);
|
||||
$this->seedDeals($tenant->id, $admin->id);
|
||||
|
||||
$this->command->info("Demo tenant id={$tenant->id} subdomain=demo");
|
||||
$this->command->info('Login: admin@demo.local / password');
|
||||
}
|
||||
|
||||
private function seedProjects(int $tenantId): void
|
||||
{
|
||||
$now = now();
|
||||
|
||||
$projects = [
|
||||
[
|
||||
'tag' => 'site',
|
||||
'name' => 'Окна СПб (сайт)',
|
||||
'type' => 'webhook',
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'okna-konkurent.ru',
|
||||
'sms_senders' => null,
|
||||
'sms_keyword' => null,
|
||||
'daily_limit_target' => 50,
|
||||
],
|
||||
[
|
||||
'tag' => 'call',
|
||||
'name' => 'Натяжные потолки (звонок)',
|
||||
'type' => 'webhook',
|
||||
'signal_type' => 'call',
|
||||
'signal_identifier' => '79161112233',
|
||||
'sms_senders' => null,
|
||||
'sms_keyword' => null,
|
||||
'daily_limit_target' => 30,
|
||||
],
|
||||
[
|
||||
'tag' => 'sms',
|
||||
'name' => 'Доставка еды (СМС)',
|
||||
'type' => 'webhook',
|
||||
'signal_type' => 'sms',
|
||||
'signal_identifier' => null,
|
||||
'sms_senders' => json_encode(['EDA-PROMO', 'YAEDA']),
|
||||
'sms_keyword' => 'скидка',
|
||||
'daily_limit_target' => 20,
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($projects as $p) {
|
||||
DB::table('projects')->updateOrInsert(
|
||||
['tenant_id' => $tenantId, 'name' => $p['name']],
|
||||
[
|
||||
'tenant_id' => $tenantId,
|
||||
'name' => $p['name'],
|
||||
'tag' => $p['tag'],
|
||||
'type' => $p['type'],
|
||||
'signal_type' => $p['signal_type'],
|
||||
'signal_identifier' => $p['signal_identifier'],
|
||||
'sms_senders' => $p['sms_senders'],
|
||||
'sms_keyword' => $p['sms_keyword'],
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => $p['daily_limit_target'],
|
||||
'delivered_today' => 0,
|
||||
'delivered_in_month' => 0,
|
||||
'region_mask' => 0,
|
||||
'region_mode' => 'include',
|
||||
'delivery_days_mask' => 127,
|
||||
'assignment_strategy' => 'manual',
|
||||
'ttfr_target_minutes' => 60,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function seedDeals(int $tenantId, int $managerId): void
|
||||
{
|
||||
$statuses = DB::table('lead_statuses')->orderBy('sort_order')->get();
|
||||
$projects = DB::table('projects')
|
||||
->where('tenant_id', $tenantId)
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->keyBy('signal_type');
|
||||
|
||||
$samplePool = [
|
||||
'site' => [
|
||||
['name' => 'Иван Петров', 'phone' => '+79161234501', 'utm' => ['source' => 'yandex', 'medium' => 'cpc', 'campaign' => 'okna-spb']],
|
||||
['name' => 'Анна Смирнова', 'phone' => '+79161234502', 'utm' => ['source' => 'google', 'medium' => 'organic', 'campaign' => null]],
|
||||
],
|
||||
'call' => [
|
||||
['name' => 'Сергей Иванов', 'phone' => '+79161234503', 'utm' => ['source' => 'call', 'medium' => 'direct', 'campaign' => null]],
|
||||
['name' => 'Мария Кузнецова', 'phone' => '+79161234504', 'utm' => ['source' => 'call', 'medium' => 'direct', 'campaign' => null]],
|
||||
],
|
||||
'sms' => [
|
||||
['name' => 'Дмитрий Соколов', 'phone' => '+79161234505', 'utm' => ['source' => 'sms', 'medium' => 'promo', 'campaign' => 'eda-skidka']],
|
||||
['name' => 'Елена Морозова', 'phone' => '+79161234506', 'utm' => ['source' => 'sms', 'medium' => 'promo', 'campaign' => 'eda-skidka']],
|
||||
],
|
||||
];
|
||||
|
||||
$now = now();
|
||||
$signalCycle = ['site', 'call', 'sms'];
|
||||
$i = 0;
|
||||
|
||||
foreach ($statuses as $status) {
|
||||
$signal = $signalCycle[$i % 3];
|
||||
$sample = $samplePool[$signal][$i % 2];
|
||||
$project = $projects[$signal];
|
||||
|
||||
$existing = DB::table('deals')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('phone', $sample['phone'])
|
||||
->where('status', $status->slug)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
$i++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::table('deals')->insert([
|
||||
'tenant_id' => $tenantId,
|
||||
'project_id' => $project->id,
|
||||
'phone' => $sample['phone'],
|
||||
'phones' => json_encode([$sample['phone']]),
|
||||
'status' => $status->slug,
|
||||
'contact_name' => $sample['name'],
|
||||
'comment' => "Демо-сделка статуса «{$status->name_ru}» ({$signal})",
|
||||
'manager_id' => $managerId,
|
||||
'assigned_at' => $now,
|
||||
'escalated_count' => 0,
|
||||
'utm_source' => $sample['utm']['source'],
|
||||
'utm_medium' => $sample['utm']['medium'],
|
||||
'utm_campaign' => $sample['utm']['campaign'],
|
||||
'region_code' => $i % 2 === 0 ? '77' : '78',
|
||||
'city' => $i % 2 === 0 ? 'Москва' : 'Санкт-Петербург',
|
||||
'time_in_form_seconds' => 30 + $i * 5,
|
||||
'lead_score' => number_format(50.0 + $i * 3, 2, '.', ''),
|
||||
'is_test' => false,
|
||||
'received_at' => $now->copy()->subMinutes($i * 7),
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\PricingTier;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class PricingTierSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* 7 ступеней дефолтного тарифа (Plan 4 spec §2.3 — placeholder, ожидает
|
||||
* подтверждения заказчика. Открытый вопрос #1).
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$tiers = [
|
||||
['tier_no' => 1, 'leads_in_tier' => 100, 'price_per_lead_kopecks' => 50000],
|
||||
['tier_no' => 2, 'leads_in_tier' => 200, 'price_per_lead_kopecks' => 45000],
|
||||
['tier_no' => 3, 'leads_in_tier' => 400, 'price_per_lead_kopecks' => 40000],
|
||||
['tier_no' => 4, 'leads_in_tier' => 800, 'price_per_lead_kopecks' => 35000],
|
||||
['tier_no' => 5, 'leads_in_tier' => 1500, 'price_per_lead_kopecks' => 30000],
|
||||
['tier_no' => 6, 'leads_in_tier' => 3000, 'price_per_lead_kopecks' => 27000],
|
||||
['tier_no' => 7, 'leads_in_tier' => null, 'price_per_lead_kopecks' => 25000],
|
||||
];
|
||||
|
||||
foreach ($tiers as $tier) {
|
||||
PricingTier::updateOrCreate(
|
||||
['tier_no' => $tier['tier_no'], 'effective_from' => '1970-01-01'],
|
||||
array_merge($tier, ['is_active' => true, 'effective_from' => '1970-01-01']),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+25271
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "Dev Element Indices Manifest",
|
||||
"type": "object",
|
||||
"required": ["version", "lastId", "entries", "deleted"],
|
||||
"properties": {
|
||||
"$schema": { "type": "string" },
|
||||
"version": { "const": 1 },
|
||||
"lastId": { "type": "integer", "minimum": 0 },
|
||||
"entries": {
|
||||
"type": "object",
|
||||
"patternProperties": {
|
||||
"^[0-9]+$": {
|
||||
"type": "object",
|
||||
"required": ["file", "line", "tag", "parentChain", "signature", "createdAt"],
|
||||
"properties": {
|
||||
"file": { "type": "string" },
|
||||
"line": { "type": "integer", "minimum": 1 },
|
||||
"tag": { "type": "string" },
|
||||
"parentChain": { "type": "array", "items": { "type": "string" } },
|
||||
"signature": { "type": "string" },
|
||||
"text": { "type": ["string", "null"] },
|
||||
"key": { "type": ["string", "null"] },
|
||||
"ref": { "type": ["string", "null"] },
|
||||
"createdAt": { "type": "string", "format": "date-time" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"deleted": {
|
||||
"type": "object",
|
||||
"patternProperties": {
|
||||
"^[0-9]+$": {
|
||||
"type": "object",
|
||||
"required": ["lastSignature", "lastFile", "deletedAt"],
|
||||
"properties": {
|
||||
"lastSignature": { "type": "string" },
|
||||
"lastFile": { "type": "string" },
|
||||
"deletedAt": { "type": "string", "format": "date-time" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
Generated
+12
@@ -4,6 +4,9 @@
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"lucide-vue-next": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@histoire/plugin-vue": "^1.0.0-beta.1",
|
||||
@@ -6967,6 +6970,15 @@
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-vue-next": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-1.0.0.tgz",
|
||||
"integrity": "sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"vue": ">=3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"format:check": "prettier --check \"resources/js/**/*.{ts,vue,css}\" \"tests/Frontend/**/*.ts\"",
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
"test:vue": "vitest run",
|
||||
"dx": "node scripts/dev-indices-lookup.mjs",
|
||||
"story": "histoire dev",
|
||||
"story:build": "histoire build",
|
||||
"story:preview": "histoire preview"
|
||||
@@ -45,5 +46,8 @@
|
||||
"vue-tsc": "^3.2.8",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"vuetify": "^3.12.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-vue-next": "^1.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
+236
-2
@@ -78,6 +78,12 @@ parameters:
|
||||
count: 1
|
||||
path: app/Http/Middleware/SetTenantContext.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\Project\:\:\$archived_at\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: app/Http/Resources/ProjectResource.php
|
||||
|
||||
-
|
||||
message: '#^Using nullsafe property access "\?\-\>name" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
|
||||
identifier: nullsafe.neverNull
|
||||
@@ -96,6 +102,18 @@ parameters:
|
||||
count: 1
|
||||
path: app/Services/NotificationService.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\Project\:\:\$archived_at\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: app/Services/Project/ProjectService.php
|
||||
|
||||
-
|
||||
message: '#^Match expression does not handle remaining value\: string$#'
|
||||
identifier: match.unhandled
|
||||
count: 1
|
||||
path: app/Services/Project/ProjectService.php
|
||||
|
||||
-
|
||||
message: '#^Return type \(array\<string, mixed\>\) of method Database\\Factories\\ProjectFactory\:\:definition\(\) should be compatible with return type \(array\<model property of App\\Models\\Project, mixed\>\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\<App\\Models\\Project\>\:\:definition\(\)$#'
|
||||
identifier: method.childReturnType
|
||||
@@ -132,6 +150,42 @@ parameters:
|
||||
count: 1
|
||||
path: tests/Concerns/SharesSupplierPdo.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:deleteJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Admin/AdminPricingTiersControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Admin/AdminPricingTiersControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 6
|
||||
path: tests/Feature/Admin/AdminPricingTiersControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Admin/AdminPricingTiersControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Admin/AdminSuppliersControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:patchJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Admin/AdminSuppliersControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -192,6 +246,12 @@ parameters:
|
||||
count: 13
|
||||
path: tests/Feature/AdminTenantsIndexTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 14
|
||||
path: tests/Feature/Api/ProjectBulkActionsTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
@@ -402,6 +462,72 @@ parameters:
|
||||
count: 11
|
||||
path: tests/Feature/Auth/TwoFactorTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\LeadCharge\:\:\$charge_source\.$#'
|
||||
identifier: property.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Billing/LedgerServiceTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$delivered_in_month\.$#'
|
||||
identifier: property.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Billing/LedgerServiceTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$ledger\.$#'
|
||||
identifier: property.notFound
|
||||
count: 8
|
||||
path: tests/Feature/Billing/LedgerServiceTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Billing/LedgerServiceTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$repo\.$#'
|
||||
identifier: property.notFound
|
||||
count: 6
|
||||
path: tests/Feature/Billing/PricingTierRepositoryTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 12
|
||||
path: tests/Feature/Billing/TenantChargesControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Billing/TenantChargesControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Billing/TenantChargesControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 8
|
||||
path: tests/Feature/Billing/TenantChargesControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Billing/TenantChargesControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Billing/TenantChargesControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -414,6 +540,18 @@ parameters:
|
||||
count: 2
|
||||
path: tests/Feature/Console/ResetDeliveredTodayCommandTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$delivered_in_month\.$#'
|
||||
identifier: property.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Console/ResetMonthlyCountersCommandTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Console/ResetMonthlyCountersCommandTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
@@ -654,6 +792,18 @@ parameters:
|
||||
count: 2
|
||||
path: tests/Feature/Integration/SupplierLeadFlowTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Integration/SupplierLeadFlowTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Jobs/RouteSupplierLeadJobTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -663,13 +813,13 @@ parameters:
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 20
|
||||
count: 16
|
||||
path: tests/Feature/LookupsTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 5
|
||||
count: 4
|
||||
path: tests/Feature/LookupsTest.php
|
||||
|
||||
-
|
||||
@@ -726,6 +876,42 @@ parameters:
|
||||
count: 2
|
||||
path: tests/Feature/PartitionsCreateMonthsTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:mock\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 6
|
||||
path: tests/Feature/Plan5/Jobs/SyncSupplierProjectJobTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\Project\:\:\$archived_at\.$#'
|
||||
identifier: property.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Plan5/Projects/ProjectsActionsTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 9
|
||||
path: tests/Feature/Plan5/Projects/ProjectsActionsTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 12
|
||||
path: tests/Feature/Plan5/Projects/ProjectsListShowTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 9
|
||||
path: tests/Feature/Plan5/Projects/ProjectsStoreTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 6
|
||||
path: tests/Feature/Plan5/Projects/ProjectsUpdateTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
@@ -900,6 +1086,12 @@ parameters:
|
||||
count: 3
|
||||
path: tests/Feature/SetTenantContextTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Supplier/AutoPauseFlowTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -924,6 +1116,24 @@ parameters:
|
||||
count: 7
|
||||
path: tests/Feature/Supplier/RetryFailedSupplierJobsCommandTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\LeadCharge\:\:\$charge_source\.$#'
|
||||
identifier: property.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$delivered_in_month\.$#'
|
||||
identifier: property.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method App\\Services\\Supplier\\PlaywrightBridge\:\:shouldReceive\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -948,6 +1158,18 @@ parameters:
|
||||
count: 14
|
||||
path: tests/Feature/WebhookReceiveTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$resolver\.$#'
|
||||
identifier: property.notFound
|
||||
count: 10
|
||||
path: tests/Unit/Billing/PricingTierResolverTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tiers\.$#'
|
||||
identifier: property.notFound
|
||||
count: 9
|
||||
path: tests/Unit/Billing/PricingTierResolverTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertTrue\(\) with true will always evaluate to true\.$#'
|
||||
identifier: method.alreadyNarrowedType
|
||||
@@ -972,6 +1194,18 @@ parameters:
|
||||
count: 2
|
||||
path: tests/Unit/Supplier/RefreshSupplierSessionJobTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$parser\.$#'
|
||||
identifier: property.notFound
|
||||
count: 6
|
||||
path: tests/Unit/Supplier/SupplierCsvParserTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined static method Illuminate\\Support\\Facades\\Log\:\:shouldHaveReceived\(\)\.$#'
|
||||
identifier: staticMethod.notFound
|
||||
count: 1
|
||||
path: tests/Unit/Supplier/SupplierCsvParserTest.php
|
||||
|
||||
-
|
||||
message: '#^Parameter \#4 \$activeLiderraProjects of static method App\\Services\\Supplier\\SupplierQuotaAllocator\:\:allocate\(\) expects Illuminate\\Support\\Collection\<int, stdClass\>, Illuminate\\Support\\Collection\<int, \(object\{daily_limit\: 5, workdays\: array\{1, 2, 3, 4, 5\}, regions\: array\{77, 50\}\}&stdClass\)\|\(object\{daily_limit\: 5, workdays\: array\{1, 2, 3, 4, 5\}, regions\: array\{50, 78\}\}&stdClass\)\> given\.$#'
|
||||
identifier: argument.type
|
||||
|
||||
@@ -23,3 +23,14 @@ body {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-feature-settings: 'tnum' 1;
|
||||
}
|
||||
|
||||
/*
|
||||
* A11y override: Vuetify .v-messages helper-text + .v-field-label opacity
|
||||
* (~0.52 default) рендерится ≈#7a7a7a/#767471 → contrast 4.20-4.29 fails
|
||||
* WCAG 2.1 AA 4.5:1. Q.DEFER.002 fix (12.05.2026 audit): локально bump до 0.7
|
||||
* → rendered ≈#595959 → 7.9:1+.
|
||||
*/
|
||||
.v-messages,
|
||||
.v-field-label {
|
||||
--v-medium-emphasis-opacity: 0.7;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/* app/resources/css/motion.css
|
||||
* Liderra motion-инфраструктура. 7 паттернов + reduced-motion wrapper.
|
||||
* Spec: §9.
|
||||
*/
|
||||
|
||||
/* === keyframes === */
|
||||
@keyframes ld-fadeup {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
@keyframes ld-slideup {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
@keyframes ld-shimmer {
|
||||
0% { background-position: -200px 0; }
|
||||
100% { background-position: 200px 0; }
|
||||
}
|
||||
|
||||
@keyframes ld-pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.6); opacity: 0.4; }
|
||||
}
|
||||
|
||||
@keyframes ld-dialog-in {
|
||||
0% { opacity: 0; transform: scale(0.94) translateY(8px); }
|
||||
100% { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
/* === Utilities === */
|
||||
|
||||
/* motion #4 — Hover lift */
|
||||
.ld-hover-lift {
|
||||
transition:
|
||||
transform 200ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
box-shadow 200ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.ld-hover-lift:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-2);
|
||||
}
|
||||
|
||||
/* motion #2 — Stagger list (применяется к строкам таблиц/списков; mount-only) */
|
||||
.ld-stagger-row {
|
||||
animation: ld-slideup 400ms cubic-bezier(0.16, 1, 0.3, 1) backwards;
|
||||
}
|
||||
.ld-stagger-row:nth-child(1) { animation-delay: 0ms; }
|
||||
.ld-stagger-row:nth-child(2) { animation-delay: 50ms; }
|
||||
.ld-stagger-row:nth-child(3) { animation-delay: 100ms; }
|
||||
.ld-stagger-row:nth-child(4) { animation-delay: 150ms; }
|
||||
.ld-stagger-row:nth-child(5) { animation-delay: 200ms; }
|
||||
.ld-stagger-row:nth-child(6) { animation-delay: 250ms; }
|
||||
.ld-stagger-row:nth-child(7) { animation-delay: 300ms; }
|
||||
.ld-stagger-row:nth-child(8) { animation-delay: 350ms; }
|
||||
.ld-stagger-row:nth-child(9) { animation-delay: 400ms; }
|
||||
.ld-stagger-row:nth-child(10) { animation-delay: 450ms; }
|
||||
|
||||
/* motion #5 — Skeleton shimmer */
|
||||
.ld-skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(1, 32, 25, 0.06) 0%,
|
||||
rgba(1, 32, 25, 0.12) 50%,
|
||||
rgba(1, 32, 25, 0.06) 100%
|
||||
);
|
||||
background-size: 400px 100%;
|
||||
animation: ld-shimmer 1400ms infinite linear;
|
||||
border-radius: var(--radius-6);
|
||||
}
|
||||
|
||||
/* motion #10 (auxiliary) — Live pulse */
|
||||
.ld-pulse {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--liderra-teal);
|
||||
}
|
||||
|
||||
.ld-pulse::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--liderra-teal);
|
||||
animation: ld-pulse 1800ms infinite cubic-bezier(0.4, 0, 0.6, 1);
|
||||
}
|
||||
|
||||
/* motion #6 — Page transition (View Transitions API + CSS fallback) */
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation-duration: 280ms;
|
||||
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
::view-transition-old(root) {
|
||||
animation-name: ld-fadeout-up;
|
||||
}
|
||||
|
||||
::view-transition-new(root) {
|
||||
animation-name: ld-fadeup;
|
||||
}
|
||||
|
||||
@keyframes ld-fadeout-up {
|
||||
from { opacity: 1; transform: none; }
|
||||
to { opacity: 0; transform: translateY(-4px); }
|
||||
}
|
||||
|
||||
/* CSS fallback для router transition */
|
||||
.ld-route-fadeup-enter-active,
|
||||
.ld-route-fadeup-leave-active {
|
||||
transition: opacity 280ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
transform 280ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.ld-route-fadeup-enter-from { opacity: 0; transform: translateY(4px); }
|
||||
.ld-route-fadeup-leave-to { opacity: 0; transform: translateY(-4px); }
|
||||
|
||||
/* === Reduced motion — отключаем всё === */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/* app/resources/css/tokens.css
|
||||
* Liderra Forest design tokens (Iteration 1 — Quiet Luxury).
|
||||
* Spec: docs/superpowers/specs/2026-05-12-portal-redesign-quiet-luxury-design.md
|
||||
*/
|
||||
:root {
|
||||
/* ===== Палитра (12 токенов) ===== */
|
||||
--liderra-teal: #0F6E56;
|
||||
--liderra-teal-deep: #0A5A47;
|
||||
--liderra-noir: #012019;
|
||||
--liderra-ivory: #F6F3EC;
|
||||
--liderra-surface: #FFFFFF;
|
||||
--liderra-muted: #6B6356;
|
||||
--liderra-success: #2E8B57;
|
||||
--liderra-saffron: #D9A441;
|
||||
--liderra-error: #B83A3A;
|
||||
--liderra-info: #3F7C95;
|
||||
--liderra-plum: #7A5BA3;
|
||||
--liderra-salmon: #CC6E50;
|
||||
|
||||
/* ===== Тонкие поверхности ===== */
|
||||
--liderra-line: rgba(1, 32, 25, 0.08);
|
||||
--liderra-line-strong: rgba(1, 32, 25, 0.14);
|
||||
|
||||
/* ===== Spacing (4pt grid) ===== */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
--space-12: 48px;
|
||||
--space-16: 64px;
|
||||
|
||||
/* ===== Радиусы ===== */
|
||||
--radius-6: 6px;
|
||||
--radius-8: 8px;
|
||||
--radius-10: 10px;
|
||||
--radius-12: 12px;
|
||||
--radius-14: 14px;
|
||||
--radius-full: 999px;
|
||||
|
||||
/* ===== Shadows (ambient + key, двухслойные) ===== */
|
||||
--shadow-1: 0 1px 2px rgba(1, 32, 25, 0.04);
|
||||
--shadow-2: 0 4px 12px rgba(1, 32, 25, 0.06), 0 1px 2px rgba(1, 32, 25, 0.04);
|
||||
--shadow-3: 0 12px 28px rgba(1, 32, 25, 0.10);
|
||||
--shadow-4: 0 24px 48px rgba(1, 32, 25, 0.16);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/* app/resources/css/typography.css
|
||||
* Liderra typography — Inter (UI) + JetBrains Mono (numerics) с tnum.
|
||||
*/
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,300..700&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap');
|
||||
|
||||
html,
|
||||
body {
|
||||
font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
font-feature-settings: 'tnum' 1, 'cv11' 1;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.ld-mono {
|
||||
font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||
font-feature-settings: 'tnum' 1;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* Шкала (см. spec §4) */
|
||||
.ld-label {
|
||||
font-size: 11px;
|
||||
line-height: 14px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.10em;
|
||||
text-transform: uppercase;
|
||||
color: var(--liderra-muted);
|
||||
}
|
||||
|
||||
.ld-body {
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.ld-body-strong {
|
||||
font-size: 15px;
|
||||
line-height: 22px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ld-h3 {
|
||||
font-size: 17px;
|
||||
line-height: 24px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.ld-h2 {
|
||||
font-size: 22px;
|
||||
line-height: 28px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
.ld-h1 {
|
||||
font-size: 28px;
|
||||
line-height: 36px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.ld-hero {
|
||||
font-size: clamp(30px, 5vw, 48px);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.ld-mono-xl {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.02em;
|
||||
font-feature-settings: 'tnum' 1;
|
||||
}
|
||||
|
||||
.ld-mono-s {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
font-feature-settings: 'tnum' 1;
|
||||
}
|
||||
@@ -2,6 +2,9 @@ import { createPinia } from 'pinia';
|
||||
import { createApp } from 'vue';
|
||||
import AppShell from './components/AppShell.vue';
|
||||
import { vuetify } from './plugins/vuetify';
|
||||
import '../css/tokens.css';
|
||||
import '../css/typography.css';
|
||||
import '../css/motion.css';
|
||||
import { router } from './router';
|
||||
|
||||
// Точка входа Vue 3 + Vuetify 3 + Vue Router 4 + Pinia (фаза 2, CLAUDE.md §3.3).
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* Источник дизайна: liderra_v8_handoff/concepts/v8_login.html (auth),
|
||||
* v8_dashboard.html (app), v8_errors.html (error).
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
import { computed, defineAsyncComponent, type Component } from 'vue';
|
||||
import { RouterView, useRoute } from 'vue-router';
|
||||
import AdminLayout from '../layouts/AdminLayout.vue';
|
||||
import AppLayout from '../layouts/AppLayout.vue';
|
||||
@@ -17,6 +17,11 @@ import AuthLayout from '../layouts/AuthLayout.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const layoutName = computed(() => route.meta.layout ?? 'app');
|
||||
|
||||
// Dev-only overlay: tree-shaken from production bundle via import.meta.env.DEV guard.
|
||||
const DevIndexOverlay: Component | null = import.meta.env.DEV
|
||||
? defineAsyncComponent(() => import('./DevIndexOverlay.vue'))
|
||||
: null;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -24,4 +29,5 @@ const layoutName = computed(() => route.meta.layout ?? 'app');
|
||||
<RouterView v-else-if="layoutName === 'error'" />
|
||||
<AdminLayout v-else-if="layoutName === 'admin'" />
|
||||
<AppLayout v-else />
|
||||
<component :is="DevIndexOverlay" v-if="DevIndexOverlay" />
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div v-if="index" class="dev-index-badge" :class="{ 'is-dialog': dialogMode }">
|
||||
<span class="dev-index-num">{{ index }}</span>
|
||||
<span class="dev-index-label">{{ label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Dev-only визуальный badge: показывает индекс и название текущего экрана/компонента
|
||||
* для упрощения обратной связи на localhost («элемент 16: бага X»).
|
||||
*
|
||||
* Использование:
|
||||
* - Layout-уровень: `<DevIndexBadge :index="route.meta.devIndex" :label="route.meta.devLabel" />`
|
||||
* - Inline (диалоги/sub-компоненты): `<DevIndexBadge :index="18" label="NewProjectDialog" :dialog-mode="true" />`
|
||||
*
|
||||
* Не отображается если `index` falsy (null/undefined/0/'').
|
||||
* `dialogMode` переключает position: fixed → absolute для встраивания внутрь карточек.
|
||||
*/
|
||||
defineProps<{
|
||||
index: number | string | null | undefined;
|
||||
label?: string;
|
||||
dialogMode?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dev-index-badge {
|
||||
position: fixed;
|
||||
top: 64px;
|
||||
right: 8px;
|
||||
z-index: 9000;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: #0f6e56;
|
||||
color: #fff;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
pointer-events: none;
|
||||
opacity: 0.92;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.dev-index-badge.is-dialog {
|
||||
position: absolute;
|
||||
z-index: 10000;
|
||||
}
|
||||
.dev-index-num {
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
min-width: 22px;
|
||||
text-align: center;
|
||||
}
|
||||
.dev-index-label {
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,221 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="currentId !== null && currentTarget"
|
||||
class="dx-badge"
|
||||
:class="{ 'dx-badge--copied': justCopied }"
|
||||
:style="badgePosition"
|
||||
@click.stop="copyToClipboard"
|
||||
>
|
||||
<span class="dx-badge__num">#{{ currentId }}</span>
|
||||
<span class="dx-badge__meta">{{ tagLabel }} · "{{ textPreview }}"</span>
|
||||
</div>
|
||||
</Teleport>
|
||||
<Teleport to="body">
|
||||
<div v-if="overlayMode" class="dx-mini-layer">
|
||||
<div v-for="el in overlayElements" :key="el.id" class="dx-mini" :style="miniStyleFor(el.rect)">
|
||||
#{{ el.id }}
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { useDevIndices } from '../composables/useDevIndices';
|
||||
|
||||
const {
|
||||
currentId,
|
||||
currentTarget,
|
||||
hoverEnabled,
|
||||
overlayMode,
|
||||
setTarget,
|
||||
reset,
|
||||
pauseHover,
|
||||
walkToParent,
|
||||
walkToChild,
|
||||
toggleOverlay,
|
||||
} = useDevIndices();
|
||||
|
||||
const cursorX = ref(0);
|
||||
const cursorY = ref(0);
|
||||
const justCopied = ref(false);
|
||||
let mousemoveRAF: number | null = null;
|
||||
|
||||
const tagLabel = computed(() => {
|
||||
const t = currentTarget.value;
|
||||
if (!t) return '';
|
||||
return t.tagName.toLowerCase();
|
||||
});
|
||||
|
||||
const textPreview = computed(() => {
|
||||
const t = currentTarget.value;
|
||||
if (!t) return '';
|
||||
const text = (t.textContent ?? '').trim().slice(0, 24);
|
||||
return text || '—';
|
||||
});
|
||||
|
||||
const badgePosition = computed(() => ({
|
||||
left: `${cursorX.value + 12}px`,
|
||||
top: `${cursorY.value + 12}px`,
|
||||
}));
|
||||
|
||||
interface OverlayItem {
|
||||
id: number;
|
||||
rect: DOMRect;
|
||||
}
|
||||
|
||||
const overlayElements = ref<OverlayItem[]>([]);
|
||||
|
||||
function refreshOverlayElements() {
|
||||
const nodes = Array.from(document.querySelectorAll<HTMLElement>('[data-dx]'));
|
||||
overlayElements.value = nodes
|
||||
.map((el) => {
|
||||
const idAttr = el.getAttribute('data-dx');
|
||||
const id = Number(idAttr);
|
||||
if (!Number.isFinite(id)) return null;
|
||||
return { id, rect: el.getBoundingClientRect() };
|
||||
})
|
||||
.filter((x): x is OverlayItem => x !== null);
|
||||
}
|
||||
|
||||
function miniStyleFor(rect: DOMRect) {
|
||||
return {
|
||||
left: `${rect.left}px`,
|
||||
top: `${rect.top}px`,
|
||||
};
|
||||
}
|
||||
|
||||
watch(overlayMode, (on) => {
|
||||
if (on) {
|
||||
refreshOverlayElements();
|
||||
window.addEventListener('resize', refreshOverlayElements);
|
||||
window.addEventListener('scroll', refreshOverlayElements, true);
|
||||
} else {
|
||||
overlayElements.value = [];
|
||||
window.removeEventListener('resize', refreshOverlayElements);
|
||||
window.removeEventListener('scroll', refreshOverlayElements, true);
|
||||
}
|
||||
});
|
||||
|
||||
function onMousemove(e: MouseEvent) {
|
||||
if (!hoverEnabled.value) return;
|
||||
cursorX.value = e.clientX;
|
||||
cursorY.value = e.clientY;
|
||||
|
||||
if (mousemoveRAF !== null) return;
|
||||
mousemoveRAF = requestAnimationFrame(() => {
|
||||
mousemoveRAF = null;
|
||||
const el = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null;
|
||||
if (!el) {
|
||||
setTarget(null);
|
||||
return;
|
||||
}
|
||||
const withDx = el.closest('[data-dx]') as HTMLElement | null;
|
||||
setTarget(withDx);
|
||||
});
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.altKey && e.shiftKey && (e.key === 'I' || e.key === 'i')) {
|
||||
e.preventDefault();
|
||||
toggleOverlay();
|
||||
return;
|
||||
}
|
||||
if (e.altKey && e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
walkToParent();
|
||||
pauseHover(800);
|
||||
return;
|
||||
}
|
||||
if (e.altKey && e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
walkToChild();
|
||||
pauseHover(800);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
reset();
|
||||
pauseHover(2000);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToClipboard() {
|
||||
if (currentId.value === null) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(`#${currentId.value}`);
|
||||
justCopied.value = true;
|
||||
setTimeout(() => (justCopied.value = false), 400);
|
||||
} catch {
|
||||
// clipboard may be unavailable in some contexts; silent fail OK in dev tool
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('mousemove', onMousemove);
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('mousemove', onMousemove);
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
if (mousemoveRAF !== null) cancelAnimationFrame(mousemoveRAF);
|
||||
if (overlayMode.value) {
|
||||
window.removeEventListener('resize', refreshOverlayElements);
|
||||
window.removeEventListener('scroll', refreshOverlayElements, true);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dx-badge {
|
||||
position: fixed;
|
||||
z-index: 999999;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 10px;
|
||||
background: #0f6e56;
|
||||
color: #fff;
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.24);
|
||||
user-select: none;
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
.dx-badge--copied {
|
||||
background: #21a16e;
|
||||
}
|
||||
.dx-badge__num {
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.dx-badge__meta {
|
||||
letter-spacing: 0.02em;
|
||||
opacity: 0.92;
|
||||
}
|
||||
.dx-mini-layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 999998;
|
||||
}
|
||||
.dx-mini {
|
||||
position: fixed;
|
||||
background: #0f6e56;
|
||||
color: #fff;
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 9px;
|
||||
line-height: 1;
|
||||
padding: 1px 3px;
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
</style>
|
||||
@@ -57,7 +57,10 @@ const emit = defineEmits<{
|
||||
</td>
|
||||
<td class="num text-caption text-medium-emphasis">{{ tx.id }}</td>
|
||||
<td>{{ tx.description }}</td>
|
||||
<td class="text-end num" :class="{ 'text-error': tx.amount < 0, 'text-success': tx.amount > 0 }">
|
||||
<td
|
||||
class="text-end num"
|
||||
:class="{ 'text-error': tx.amount < 0, 'text-success': tx.amount > 0 }"
|
||||
>
|
||||
{{ formatRub(tx.amount) }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -26,16 +26,25 @@ function formatRub(v: number): string {
|
||||
<div>
|
||||
<h1 class="text-h4 mb-2 page-title">Тенанты</h1>
|
||||
<div class="page-stats text-body-2 text-medium-emphasis">
|
||||
<span><span class="num">{{ stats.total }}</span> всего</span>
|
||||
<span
|
||||
><span class="num">{{ stats.total }}</span> всего</span
|
||||
>
|
||||
<span class="sep">·</span>
|
||||
<span><span class="num text-success">{{ stats.active }}</span> активны</span>
|
||||
<span
|
||||
><span class="num text-success">{{ stats.active }}</span> активны</span
|
||||
>
|
||||
<span class="sep">·</span>
|
||||
<span><span class="num">{{ stats.trial }}</span> trial</span>
|
||||
<span
|
||||
><span class="num">{{ stats.trial }}</span> trial</span
|
||||
>
|
||||
<span class="sep">·</span>
|
||||
<span><span class="num text-warning">{{ stats.overdue }}</span> просрочка</span>
|
||||
<span
|
||||
><span class="num text-warning">{{ stats.overdue }}</span> просрочка</span
|
||||
>
|
||||
<span class="sep">·</span>
|
||||
<span>выручка месяц
|
||||
<span class="num text-primary">{{ formatRub(stats.monthlyRevenueRub) }}</span></span>
|
||||
<span
|
||||
>выручка месяц <span class="num text-primary">{{ formatRub(stats.monthlyRevenueRub) }}</span></span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex ga-2">
|
||||
|
||||
@@ -86,6 +86,7 @@ function statusColor(s: TenantStatus): string {
|
||||
variant="text"
|
||||
size="small"
|
||||
density="comfortable"
|
||||
:aria-label="`Войти как клиент (impersonation) для ${item.name}`"
|
||||
:disabled="item.status === 'suspended'"
|
||||
:data-testid="`impersonate-btn-${item.id}`"
|
||||
@click.stop="emit('impersonate', item)"
|
||||
|
||||
@@ -4,17 +4,8 @@
|
||||
* (Все / Пополнения / Списания / Возвраты). Sprint 4 Phase B/2 — split BillingView.
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
import {
|
||||
BILLING_TABS,
|
||||
MOCK_TRANSACTIONS,
|
||||
type BillingTransaction,
|
||||
} from '../../composables/mockBilling';
|
||||
import {
|
||||
formatCost,
|
||||
statusChipColor,
|
||||
statusLabel,
|
||||
txAmountClass,
|
||||
} from '../../composables/billingFormatters';
|
||||
import { BILLING_TABS, MOCK_TRANSACTIONS, type BillingTransaction } from '../../composables/mockBilling';
|
||||
import { formatCost, statusChipColor, statusLabel, txAmountClass } from '../../composables/billingFormatters';
|
||||
|
||||
const activeTab = ref<(typeof BILLING_TABS)[number]['id']>('all');
|
||||
|
||||
@@ -102,7 +93,7 @@ const filteredTransactions = computed<BillingTransaction[]>(() => {
|
||||
color: #66635c;
|
||||
}
|
||||
.tx-amount-up {
|
||||
color: #2e8b57;
|
||||
color: #1b6e3b;
|
||||
}
|
||||
.tx-amount-down {
|
||||
color: #b83a3a;
|
||||
|
||||
@@ -29,10 +29,7 @@ defineProps<{
|
||||
<span class="ru"> ₽</span>
|
||||
</div>
|
||||
<div class="runway mt-3">
|
||||
<div
|
||||
class="runway-bar"
|
||||
:aria-label="`Хватит на ${balance.runwayDays} дня из ${balance.runwayMax}`"
|
||||
>
|
||||
<div class="runway-bar" role="img" :aria-label="`Хватит на ${balance.runwayDays} дня из ${balance.runwayMax}`">
|
||||
<span
|
||||
v-for="i in balance.runwayMax"
|
||||
:key="i"
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* DashboardKpiRow — 3 KPI-карты (получено лидов / конверсия / активные проекты).
|
||||
* Numerics через JetBrains Mono с tabular-nums.
|
||||
* Numerics через JetBrains Mono с tabular-nums + count-up анимация (motion #1).
|
||||
*
|
||||
* Sprint 4 Phase B/3 — split DashboardView (audit O-refactor-04 закрытие).
|
||||
* Task 14 (Quiet Luxury) — добавлены ld-kpi__value/ld-kpi__label классы и
|
||||
* count-up через useCountUp композабл. Respects prefers-reduced-motion.
|
||||
*/
|
||||
import { onMounted, ref, watch, type Ref } from 'vue';
|
||||
import { useCountUp } from '../../composables/useCountUp';
|
||||
|
||||
export interface Kpi {
|
||||
label: string;
|
||||
value: string;
|
||||
@@ -13,17 +18,85 @@ export interface Kpi {
|
||||
sub: string;
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
kpis: Kpi[];
|
||||
}>();
|
||||
|
||||
/**
|
||||
* Парсит KPI value-строку в число. Поддерживает:
|
||||
* - целые ('247', '8')
|
||||
* - дробные ('18.4')
|
||||
* - с пробелами как тысячными ('14 250')
|
||||
*/
|
||||
function parseNumeric(raw: string): { value: number; precision: number } {
|
||||
const cleaned = raw.replace(/\s+/g, '').replace(',', '.');
|
||||
const value = parseFloat(cleaned);
|
||||
if (Number.isNaN(value)) return { value: 0, precision: 0 };
|
||||
const dotIdx = cleaned.indexOf('.');
|
||||
const precision = dotIdx === -1 ? 0 : cleaned.length - dotIdx - 1;
|
||||
return { value, precision };
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует число обратно с пробелами как тысячными
|
||||
* (чтобы '14 250' выводилось так же, а не '14250').
|
||||
*/
|
||||
function formatNumber(value: number, precision: number): string {
|
||||
const fixed = precision === 0 ? Math.round(value).toString() : value.toFixed(precision);
|
||||
const [intPart, decPart] = fixed.split('.');
|
||||
const withSpaces = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
|
||||
return decPart === undefined ? withSpaces : `${withSpaces}.${decPart}`;
|
||||
}
|
||||
|
||||
interface AnimationSlot {
|
||||
target: Ref<number>;
|
||||
display: Ref<number>;
|
||||
start: () => void;
|
||||
precision: number;
|
||||
}
|
||||
|
||||
const slots: AnimationSlot[] = [];
|
||||
|
||||
function rebuildSlots(): void {
|
||||
slots.length = 0;
|
||||
for (const kpi of props.kpis) {
|
||||
const { value, precision } = parseNumeric(kpi.value);
|
||||
const target = ref(value);
|
||||
const { display, start } = useCountUp(target, { duration: 600, precision });
|
||||
slots.push({ target, display, start, precision });
|
||||
}
|
||||
}
|
||||
|
||||
rebuildSlots();
|
||||
|
||||
// Если props.kpis сменился (новый range / refetch) — пересобираем слоты
|
||||
// и перезапускаем анимацию.
|
||||
watch(
|
||||
() => props.kpis,
|
||||
() => {
|
||||
rebuildSlots();
|
||||
slots.forEach((s) => s.start());
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
slots.forEach((s) => s.start());
|
||||
});
|
||||
|
||||
function displayFor(idx: number): string {
|
||||
const slot = slots[idx];
|
||||
if (!slot) return '';
|
||||
return formatNumber(slot.display.value, slot.precision);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-col v-for="kpi in kpis" :key="kpi.label" cols="12" sm="6" md="3">
|
||||
<v-col v-for="(kpi, idx) in kpis" :key="kpi.label" cols="12" sm="6" md="3">
|
||||
<v-card variant="outlined" class="kpi-card pa-4">
|
||||
<div class="kpi-label text-body-2 text-medium-emphasis">{{ kpi.label }}</div>
|
||||
<div class="kpi-value">
|
||||
{{ kpi.value }}
|
||||
<div class="kpi-label ld-kpi__label ld-label text-body-2 text-medium-emphasis">{{ kpi.label }}</div>
|
||||
<div class="kpi-value ld-kpi__value ld-mono">
|
||||
{{ displayFor(idx) }}
|
||||
<span v-if="kpi.unit" class="kpi-unit">{{ kpi.unit }}</span>
|
||||
</div>
|
||||
<div class="kpi-foot text-caption text-medium-emphasis mt-2">
|
||||
@@ -89,7 +162,7 @@ defineProps<{
|
||||
font-weight: 500;
|
||||
}
|
||||
.delta-up {
|
||||
color: #2e8b57;
|
||||
color: #1b6e3b;
|
||||
}
|
||||
.delta-down {
|
||||
color: #b83a3a;
|
||||
|
||||
@@ -29,13 +29,7 @@ function formatRelative(minutes: number): string {
|
||||
<div class="hero-eyebrow text-caption text-medium-emphasis">Сделка #{{ deal.id }}</div>
|
||||
<div class="hero-row mt-1">
|
||||
<h2 class="hero-name text-h5">{{ deal.name }}</h2>
|
||||
<v-btn
|
||||
icon="mdi-close"
|
||||
variant="text"
|
||||
size="small"
|
||||
aria-label="Закрыть панель"
|
||||
@click="$emit('close')"
|
||||
/>
|
||||
<v-btn icon="mdi-close" variant="text" size="small" aria-label="Закрыть панель" @click="$emit('close')" />
|
||||
</div>
|
||||
<div class="hero-meta mt-2">
|
||||
<a :href="`tel:${deal.phone.replace(/[^+\d]/g, '')}`" class="phone-link">{{ deal.phone }}</a>
|
||||
@@ -47,11 +41,7 @@ function formatRelative(minutes: number): string {
|
||||
</div>
|
||||
|
||||
<div v-if="status" class="status-row mt-3">
|
||||
<v-chip
|
||||
size="small"
|
||||
variant="tonal"
|
||||
:style="{ color: status.colorHex, borderColor: status.colorHex }"
|
||||
>
|
||||
<v-chip size="small" variant="tonal" :style="{ color: status.colorHex, borderColor: status.colorHex }">
|
||||
<span class="status-dot" :style="{ background: status.colorHex }" />
|
||||
{{ status.nameRu }}
|
||||
</v-chip>
|
||||
|
||||
@@ -16,12 +16,18 @@
|
||||
*/
|
||||
import type { MockDeal } from '../../composables/mockDeals';
|
||||
import type { LeadStatus } from '../../composables/leadStatuses';
|
||||
import StatusPill from '../ui/StatusPill.vue';
|
||||
|
||||
defineProps<{
|
||||
deals: MockDeal[];
|
||||
selectedIds: number[];
|
||||
statusBySlug: Map<string, LeadStatus>;
|
||||
}>();
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
deals: MockDeal[];
|
||||
selectedIds: number[];
|
||||
statusBySlug: Map<string, LeadStatus>;
|
||||
// Task 15: row height from density toggle (44 comfortable / 36 compact).
|
||||
rowHeight?: number;
|
||||
}>(),
|
||||
{ rowHeight: 44 },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:selectedIds': [value: number[]];
|
||||
@@ -61,7 +67,8 @@ function formatCost(cost: number): string {
|
||||
items-per-page="-1"
|
||||
hide-default-footer
|
||||
hover
|
||||
density="comfortable"
|
||||
:density="rowHeight && rowHeight < 40 ? 'compact' : 'comfortable'"
|
||||
:row-props="() => ({ class: 'ld-hover-lift ld-stagger-row', style: { height: rowHeight + 'px' } })"
|
||||
@update:model-value="onSelectedUpdate"
|
||||
@click:row="(_e: Event, { item }: { item: MockDeal }) => emit('row-click', item)"
|
||||
>
|
||||
@@ -85,23 +92,18 @@ function formatCost(cost: number): string {
|
||||
</v-avatar>
|
||||
<div>
|
||||
<div class="deal-name">{{ item.name }}</div>
|
||||
<div class="deal-phone text-caption text-medium-emphasis">{{ item.phone }}</div>
|
||||
<div class="deal-phone text-caption text-medium-emphasis ld-mono-s">{{ item.phone }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #[`item.statusSlug`]="{ item }: { item: MockDeal }">
|
||||
<v-chip
|
||||
size="small"
|
||||
variant="tonal"
|
||||
:style="{
|
||||
color: statusBySlug.get(item.statusSlug)?.colorHex,
|
||||
borderColor: statusBySlug.get(item.statusSlug)?.colorHex,
|
||||
}"
|
||||
>
|
||||
<span class="status-dot" :style="{ background: statusBySlug.get(item.statusSlug)?.colorHex }" />
|
||||
{{ statusBySlug.get(item.statusSlug)?.nameRu }}
|
||||
</v-chip>
|
||||
<!-- Task 15: StatusPill заменяет v-chip + ручной dot. Label fallback на slug
|
||||
если nameRu отсутствует (leadStatuses store ещё не загружен). -->
|
||||
<StatusPill
|
||||
:slug="item.statusSlug"
|
||||
:label="statusBySlug.get(item.statusSlug)?.nameRu ?? item.statusSlug"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #[`item.manager`]="{ item }: { item: MockDeal }">
|
||||
@@ -114,11 +116,28 @@ function formatCost(cost: number): string {
|
||||
</template>
|
||||
|
||||
<template #[`item.cost`]="{ item }: { item: MockDeal }">
|
||||
<span class="num">{{ formatCost(item.cost) }}</span>
|
||||
<span class="num ld-mono">{{ formatCost(item.cost) }}</span>
|
||||
</template>
|
||||
|
||||
<template #[`item.receivedMinutesAgo`]="{ item }: { item: MockDeal }">
|
||||
<span class="num text-medium-emphasis">{{ formatRelative(item.receivedMinutesAgo) }}</span>
|
||||
<span class="num ld-mono-s text-medium-emphasis">{{ formatRelative(item.receivedMinutesAgo) }}</span>
|
||||
</template>
|
||||
|
||||
<template #[`header.data-table-select`]="{ allSelected, selectAll, someSelected }">
|
||||
<v-checkbox-btn
|
||||
:model-value="allSelected"
|
||||
:indeterminate="someSelected && !allSelected"
|
||||
aria-label="Выбрать все сделки"
|
||||
@update:model-value="selectAll"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #[`item.data-table-select`]="{ isSelected, toggleSelect, internalItem, item }">
|
||||
<v-checkbox-btn
|
||||
:model-value="isSelected(internalItem)"
|
||||
:aria-label="`Выбрать сделку «${(item as MockDeal).name}»`"
|
||||
@update:model-value="(v: boolean | null) => toggleSelect(internalItem)"
|
||||
/>
|
||||
</template>
|
||||
</v-data-table>
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ defineProps<{
|
||||
|
||||
<template>
|
||||
<h1 class="err-code">
|
||||
{{ code[0] }}<span class="accent">{{ code[1] }}</span>{{ code[2] }}
|
||||
{{ code[0] }}<span class="accent">{{ code[1] }}</span
|
||||
>{{ code[2] }}
|
||||
</h1>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ function statusColor(s: string): string {
|
||||
|
||||
<p v-if="code === '404'" class="err-help text-caption">
|
||||
Что-то не так? Напишите в
|
||||
<a href="mailto:support@liderra.app" class="text-primary">support@liderra.app</a>
|
||||
<a href="mailto:support@liderra.app" class="err-help__link">support@liderra.app</a>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
@@ -99,4 +99,11 @@ function statusColor(s: string): string {
|
||||
color: #7a8c87;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.err-help__link {
|
||||
color: #d3dad8;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.err-help__link:hover {
|
||||
color: #ffffff;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,7 +18,12 @@ function formatCost(cost: number): string {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card variant="outlined" class="kanban-card pa-3 mb-2" density="compact" @click="emit('open', deal.id)">
|
||||
<v-card
|
||||
variant="outlined"
|
||||
class="kanban-card ld-hover-lift pa-3 mb-2"
|
||||
density="compact"
|
||||
@click="emit('open', deal.id)"
|
||||
>
|
||||
<div class="card-name">{{ deal.name }}</div>
|
||||
<div class="card-phone text-caption text-medium-emphasis">{{ deal.phone }}</div>
|
||||
<div class="card-meta mt-2">
|
||||
|
||||
@@ -54,7 +54,7 @@ function onDraggableChange(event: DraggableChangeEvent) {
|
||||
<div class="kanban-column">
|
||||
<header class="column-head" :style="{ '--accent': status.colorHex }">
|
||||
<div class="column-head-row">
|
||||
<span class="column-name">{{ status.nameRu }}</span>
|
||||
<span class="column-name ld-label">{{ status.nameRu }}</span>
|
||||
<span class="column-count">{{ deals.length }}</span>
|
||||
</div>
|
||||
<div class="column-total">{{ formatTotal(total) }}</div>
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Sprint 4 Phase B/1 (audit O-refactor-04 хвост): sidebar выделен из AppLayout.
|
||||
* Task 12 (Portal Redesign Quiet Luxury): двухтоновый shell + ⌘K stub + group-eyebrows
|
||||
* + active-marker pseudo-element + JetBrains Mono badges.
|
||||
*
|
||||
* Brand mark + nav-tree (3 группы: Работа, Финансы, Команда).
|
||||
* Counts для «Напоминания» — живой из remindersStore; «Сделки»/«Менеджеры» — mock.
|
||||
* Counts для «Сделки» — mock.
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useRemindersStore } from '../../stores/reminders';
|
||||
import Kbd from '../ui/Kbd.vue';
|
||||
|
||||
interface NavItem {
|
||||
title: string;
|
||||
icon: string;
|
||||
to: string;
|
||||
countKey?: 'deals' | 'reminders' | 'managers';
|
||||
count?: number;
|
||||
countKey?: string;
|
||||
}
|
||||
interface NavGroup {
|
||||
eyebrow: string;
|
||||
@@ -23,22 +26,15 @@ interface NavGroup {
|
||||
const drawerOpen = defineModel<boolean>('drawerOpen', { default: true });
|
||||
|
||||
const route = useRoute();
|
||||
const reminders = useRemindersStore();
|
||||
|
||||
const navGroups = computed<NavGroup[]>(() => [
|
||||
{
|
||||
eyebrow: 'Работа',
|
||||
items: [
|
||||
{ title: 'Дашборд', icon: 'mdi-view-dashboard-outline', to: '/dashboard' },
|
||||
{ title: 'Проекты', icon: 'mdi-folder-multiple-outline', to: '/projects' },
|
||||
{ title: 'Сделки', icon: 'mdi-format-list-bulleted', to: '/deals', count: 247 },
|
||||
{ title: 'Канбан', icon: 'mdi-view-column-outline', to: '/kanban' },
|
||||
{
|
||||
title: 'Напоминания',
|
||||
icon: 'mdi-clock-outline',
|
||||
to: '/reminders',
|
||||
countKey: 'reminders',
|
||||
count: reminders.counts.active,
|
||||
},
|
||||
{ title: 'Дашборд', icon: 'mdi-view-dashboard-outline', to: '/dashboard' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -50,106 +46,174 @@ const navGroups = computed<NavGroup[]>(() => [
|
||||
},
|
||||
{
|
||||
eyebrow: 'Команда',
|
||||
items: [
|
||||
{ title: 'Менеджеры', icon: 'mdi-account-group-outline', to: '/managers', count: 4 },
|
||||
{ title: 'Настройки', icon: 'mdi-cog-outline', to: '/settings' },
|
||||
],
|
||||
items: [{ title: 'Настройки', icon: 'mdi-cog-outline', to: '/settings' }],
|
||||
},
|
||||
]);
|
||||
|
||||
function resolveCount(item: NavItem): number {
|
||||
return item.count ?? 0;
|
||||
}
|
||||
|
||||
defineExpose({ navGroups });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-navigation-drawer v-model="drawerOpen" color="secondary" theme="dark" :width="240" :rail="false" class="app-drawer">
|
||||
<div class="brand-block">
|
||||
<span class="brand-mark" aria-hidden="true">
|
||||
<svg viewBox="0 0 48 48" width="22" height="22">
|
||||
<path
|
||||
d="M16 14 L16 34 L32 34"
|
||||
stroke="#012019"
|
||||
stroke-width="4.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<circle cx="32" cy="34" r="3.5" fill="#0F6E56" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="brand-text">Лидерра<span class="brand-dot">.</span></span>
|
||||
<aside class="ld-sidebar" :data-open="drawerOpen">
|
||||
<div class="ld-sidebar__brand">
|
||||
<span class="ld-sidebar__brand-name">Лидерра<span class="ld-sidebar__brand-dot">.</span></span>
|
||||
</div>
|
||||
|
||||
<v-list nav density="comfortable" class="app-nav">
|
||||
<template v-for="group in navGroups" :key="group.eyebrow">
|
||||
<v-list-subheader class="nav-eyebrow">{{ group.eyebrow }}</v-list-subheader>
|
||||
<v-list-item
|
||||
<div class="ld-cmdk-stub" role="button" tabindex="0">
|
||||
<span class="ld-cmdk-stub__placeholder">Поиск, команды…</span>
|
||||
<Kbd dark>⌘K</Kbd>
|
||||
</div>
|
||||
|
||||
<nav class="ld-sidebar__nav">
|
||||
<div v-for="(group, gi) in navGroups" :key="gi" class="ld-nav-group">
|
||||
<div class="ld-nav-group__eyebrow">{{ group.eyebrow }}</div>
|
||||
<RouterLink
|
||||
v-for="item in group.items"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
:prepend-icon="item.icon"
|
||||
:active="route.path === item.to"
|
||||
rounded="lg"
|
||||
class="nav-item"
|
||||
class="ld-nav-item"
|
||||
:class="{ 'ld-nav-item--active': route.path === item.to }"
|
||||
>
|
||||
<v-list-item-title>{{ item.title }}</v-list-item-title>
|
||||
<template v-if="item.count !== undefined && item.count > 0" #append>
|
||||
<span
|
||||
class="nav-count"
|
||||
:data-testid="item.countKey ? `nav-count-${item.countKey}` : undefined"
|
||||
>{{ item.count }}</span>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-list>
|
||||
</v-navigation-drawer>
|
||||
<span class="ld-nav-item__title">{{ item.title }}</span>
|
||||
<span
|
||||
v-if="resolveCount(item) > 0"
|
||||
class="ld-nav-item__badge ld-mono"
|
||||
:data-testid="item.countKey ? `nav-count-${item.countKey}` : undefined"
|
||||
>{{ resolveCount(item) }}</span
|
||||
>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-drawer {
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.06);
|
||||
.ld-sidebar {
|
||||
background: linear-gradient(180deg, var(--liderra-noir) 0%, #04261e 100%);
|
||||
color: #e8e2d4;
|
||||
padding: 20px 14px;
|
||||
width: 232px;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1006;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.brand-block {
|
||||
|
||||
.ld-sidebar__brand {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
padding: 0 8px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.ld-sidebar__brand-name {
|
||||
color: var(--liderra-ivory);
|
||||
}
|
||||
.ld-sidebar__brand-dot {
|
||||
color: var(--liderra-teal);
|
||||
}
|
||||
|
||||
.ld-cmdk-stub {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
padding: 8px 11px;
|
||||
border-radius: var(--radius-8);
|
||||
font-size: 12px;
|
||||
color: #9b9484;
|
||||
margin-bottom: 18px;
|
||||
cursor: pointer;
|
||||
transition: background 200ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.ld-cmdk-stub:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.ld-sidebar__nav {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ld-nav-group {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.ld-nav-group__eyebrow {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
color: #6b7470;
|
||||
margin: 14px 8px 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ld-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 18px 20px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
padding: 7px 10px;
|
||||
border-radius: var(--radius-6);
|
||||
font-size: 13px;
|
||||
color: #b8b0a0;
|
||||
text-decoration: none;
|
||||
position: relative;
|
||||
transition:
|
||||
color 200ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
background 200ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
.brand-mark {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
.ld-nav-item:hover {
|
||||
color: #e8e2d4;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.brand-text {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
letter-spacing: -0.01em;
|
||||
color: #fff;
|
||||
.ld-nav-item--active {
|
||||
color: var(--liderra-ivory);
|
||||
background: rgba(15, 110, 86, 0.22);
|
||||
}
|
||||
.brand-dot {
|
||||
color: #32c8a9;
|
||||
.ld-nav-item--active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 6px;
|
||||
bottom: 6px;
|
||||
width: 2px;
|
||||
background: var(--liderra-teal);
|
||||
border-radius: 2px;
|
||||
transform-origin: center;
|
||||
animation: ld-marker-grow 250ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.nav-eyebrow {
|
||||
font-size: 11px !important;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #7a8c87 !important;
|
||||
@keyframes ld-marker-grow {
|
||||
from {
|
||||
transform: scaleY(0);
|
||||
}
|
||||
to {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
.ld-nav-item__title {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ld-nav-item__badge {
|
||||
font-size: 10px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
color: #b8b0a0;
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
padding-top: 16px !important;
|
||||
font-feature-settings: 'tnum' 1;
|
||||
}
|
||||
.nav-count {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-feature-settings: 'tnum';
|
||||
font-size: 11px;
|
||||
color: #7a8c87;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 2px 7px;
|
||||
border-radius: 10px;
|
||||
.ld-nav-item--active .ld-nav-item__badge {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--liderra-ivory);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -90,8 +90,6 @@ async function handleLogout(): Promise<void> {
|
||||
<v-app-bar-nav-icon class="d-md-none" @click="emit('toggle-drawer')" />
|
||||
|
||||
<div class="crumb">
|
||||
<span class="text-medium-emphasis">Рабочая область</span>
|
||||
<v-icon size="14" class="mx-1">mdi-chevron-right</v-icon>
|
||||
<strong>{{ pageTitle }}</strong>
|
||||
</div>
|
||||
|
||||
@@ -168,13 +166,7 @@ async function handleLogout(): Promise<void> {
|
||||
|
||||
<v-menu offset="8">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
variant="text"
|
||||
size="small"
|
||||
class="user-chip ml-2"
|
||||
aria-label="Меню пользователя"
|
||||
>
|
||||
<v-btn v-bind="props" variant="text" size="small" class="user-chip ml-2" aria-label="Меню пользователя">
|
||||
<v-avatar size="28" color="primary" class="mr-2">
|
||||
<span class="text-caption">{{ userInitials }}</span>
|
||||
</v-avatar>
|
||||
@@ -193,7 +185,16 @@ async function handleLogout(): Promise<void> {
|
||||
|
||||
<style scoped>
|
||||
.app-topbar {
|
||||
border-bottom: 1px solid #d9d5cd !important;
|
||||
background: linear-gradient(180deg, var(--liderra-noir) 0%, #04261e 100%) !important;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08) !important;
|
||||
color: #e8e2d4 !important;
|
||||
}
|
||||
.app-topbar :deep(.v-toolbar__content) {
|
||||
padding-left: 240px;
|
||||
color: #e8e2d4;
|
||||
}
|
||||
.app-topbar :deep(.v-icon) {
|
||||
color: #b8b0a0;
|
||||
}
|
||||
.crumb {
|
||||
display: flex;
|
||||
@@ -201,20 +202,30 @@ async function handleLogout(): Promise<void> {
|
||||
gap: 4px;
|
||||
font-size: 14px;
|
||||
margin-left: 8px;
|
||||
color: #e8e2d4;
|
||||
}
|
||||
.crumb strong {
|
||||
color: var(--liderra-ivory);
|
||||
font-weight: 600;
|
||||
}
|
||||
.searchbar {
|
||||
text-transform: none;
|
||||
color: #b8b0a0 !important;
|
||||
border-color: rgba(255, 255, 255, 0.12) !important;
|
||||
}
|
||||
.search-kbd {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
border: 1px solid #d9d5cd;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
border-radius: 3px;
|
||||
background: #f0ede4;
|
||||
color: #66635c;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #9b9484;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.user-chip :deep(.v-btn__content) {
|
||||
color: #e8e2d4;
|
||||
}
|
||||
.notification-pip {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<Story title="BulkActionsBar">
|
||||
<Variant title="1 selected">
|
||||
<BulkActionsBar />
|
||||
</Variant>
|
||||
<Variant title="Many selected">
|
||||
<BulkActionsBar />
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue';
|
||||
import BulkActionsBar from './BulkActionsBar.vue';
|
||||
import { useProjectsStore } from '../../stores/projectsStore';
|
||||
|
||||
const store = useProjectsStore();
|
||||
onMounted(() => {
|
||||
store.selectedIds.add(1);
|
||||
store.selectedIds.add(2);
|
||||
store.selectedIds.add(3);
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<v-card class="bulk-actions-bar" elevation="6">
|
||||
<DevIndexBadge :index="20" label="BulkActionsBar" :dialog-mode="true" style="top: 4px; right: 4px" />
|
||||
<v-card-text class="d-flex align-center gap-3 flex-wrap">
|
||||
<strong>Выбрано: {{ store.selectedIds.size }}</strong>
|
||||
|
||||
<v-divider vertical />
|
||||
|
||||
<v-btn color="primary" variant="outlined" data-testid="bulk-regions" @click="regionsOpen = true">
|
||||
🌍 Регионы…
|
||||
</v-btn>
|
||||
<v-btn color="primary" variant="outlined" data-testid="bulk-days" @click="daysOpen = true">
|
||||
📅 Дни сбора…
|
||||
</v-btn>
|
||||
<v-btn color="primary" variant="outlined" data-testid="bulk-limit" @click="limitOpen = true">
|
||||
🎯 Лимит лидов…
|
||||
</v-btn>
|
||||
|
||||
<v-divider vertical />
|
||||
|
||||
<v-btn color="warning" prepend-icon="mdi-pause" data-testid="bulk-pause" @click="confirmAndRun('pause')">
|
||||
Приостановить
|
||||
</v-btn>
|
||||
<v-btn color="success" prepend-icon="mdi-play" data-testid="bulk-resume" @click="confirmAndRun('resume')">
|
||||
Возобновить
|
||||
</v-btn>
|
||||
|
||||
<v-divider vertical />
|
||||
|
||||
<v-btn
|
||||
color="error"
|
||||
prepend-icon="mdi-archive"
|
||||
data-testid="bulk-archive"
|
||||
@click="confirmAndRun('archive')"
|
||||
>
|
||||
Архивировать
|
||||
</v-btn>
|
||||
|
||||
<v-spacer />
|
||||
<v-btn variant="text" data-testid="bulk-clear" @click="store.clearSelection">Снять выбор</v-btn>
|
||||
</v-card-text>
|
||||
|
||||
<RegionsBulkDialog
|
||||
v-model="regionsOpen"
|
||||
:count="store.selectedIds.size"
|
||||
@apply="(p) => runBulk({ action: 'update_regions', ...p })"
|
||||
/>
|
||||
<DaysBulkDialog
|
||||
v-model="daysOpen"
|
||||
:count="store.selectedIds.size"
|
||||
@apply="(p) => runBulk({ action: 'update_days', ...p })"
|
||||
/>
|
||||
<LimitBulkDialog
|
||||
v-model="limitOpen"
|
||||
:count="store.selectedIds.size"
|
||||
@apply="(p) => runBulk({ action: 'update_limit', ...p })"
|
||||
/>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useProjectsStore } from '../../stores/projectsStore';
|
||||
import DevIndexBadge from '../DevIndexBadge.vue';
|
||||
import RegionsBulkDialog from './RegionsBulkDialog.vue';
|
||||
import DaysBulkDialog from './DaysBulkDialog.vue';
|
||||
import LimitBulkDialog from './LimitBulkDialog.vue';
|
||||
|
||||
const store = useProjectsStore();
|
||||
|
||||
const regionsOpen = ref(false);
|
||||
const daysOpen = ref(false);
|
||||
const limitOpen = ref(false);
|
||||
|
||||
const messages: Record<string, string> = {
|
||||
pause: 'Приостановить выбранные проекты?',
|
||||
resume: 'Возобновить выбранные проекты?',
|
||||
archive:
|
||||
'Архивировать выбранные проекты?\nДействие необратимо в Plan 5 (восстановление потребует ручного запроса).',
|
||||
};
|
||||
|
||||
async function confirmAndRun(action: 'pause' | 'resume' | 'archive') {
|
||||
if (!window.confirm(messages[action])) return;
|
||||
await runBulk({ action });
|
||||
}
|
||||
|
||||
async function runBulk(payload: Parameters<typeof store.bulkUpdate>[0]) {
|
||||
const result = await store.bulkUpdate(payload);
|
||||
if (result.skipped.length > 0) {
|
||||
window.alert(
|
||||
`Применено: ${result.updated}. Пропущено: ${result.skipped.length} (конфликт с уже доставленными лидами).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ regionsOpen, daysOpen, limitOpen });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bulk-actions-bar {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 10;
|
||||
max-width: calc(100vw - 48px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import DaysBulkDialog from './DaysBulkDialog.vue';
|
||||
const open = ref(true);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="projects/DaysBulkDialog">
|
||||
<Variant title="open (5 projects)">
|
||||
<DaysBulkDialog v-model="open" :count="5" />
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<v-dialog v-model="open" max-width="560">
|
||||
<v-card>
|
||||
<v-card-title>Дни сбора лидов — для {{ count }} проектов</v-card-title>
|
||||
<v-card-text>
|
||||
<div class="mb-4">
|
||||
<div class="text-caption text-success font-weight-medium mb-2">➕ Добавить дни</div>
|
||||
<div class="d-flex gap-2">
|
||||
<v-btn
|
||||
v-for="d in WEEKDAYS"
|
||||
:key="`add-${d.bit}`"
|
||||
:data-testid="`day-add-${d.bit}`"
|
||||
:color="addMask & d.bit ? 'success' : undefined"
|
||||
:variant="addMask & d.bit ? 'flat' : 'outlined'"
|
||||
size="small"
|
||||
@click="toggleAdd(d.bit)"
|
||||
>{{ d.short }}</v-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-caption text-error font-weight-medium mb-2">➖ Убрать дни</div>
|
||||
<div class="d-flex gap-2">
|
||||
<v-btn
|
||||
v-for="d in WEEKDAYS"
|
||||
:key="`remove-${d.bit}`"
|
||||
:data-testid="`day-remove-${d.bit}`"
|
||||
:color="removeMask & d.bit ? 'error' : undefined"
|
||||
:variant="removeMask & d.bit ? 'flat' : 'outlined'"
|
||||
size="small"
|
||||
@click="toggleRemove(d.bit)"
|
||||
>{{ d.short }}</v-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn data-testid="cancel" @click="open = false">Отмена</v-btn>
|
||||
<v-btn color="primary" data-testid="apply" :disabled="addMask === 0 && removeMask === 0" @click="apply"
|
||||
>Применить к {{ count }}</v-btn
|
||||
>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { WEEKDAYS } from '../../constants/weekdays';
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; count: number }>();
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean];
|
||||
apply: [payload: { add: number; remove: number }];
|
||||
}>();
|
||||
|
||||
const open = ref(props.modelValue);
|
||||
const addMask = ref(0);
|
||||
const removeMask = ref(0);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
open.value = val;
|
||||
if (val) {
|
||||
addMask.value = 0;
|
||||
removeMask.value = 0;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(open, (val) => {
|
||||
emit('update:modelValue', val);
|
||||
});
|
||||
|
||||
function toggleAdd(bit: number) {
|
||||
addMask.value ^= bit;
|
||||
if (addMask.value & bit) removeMask.value &= ~bit;
|
||||
}
|
||||
|
||||
function toggleRemove(bit: number) {
|
||||
removeMask.value ^= bit;
|
||||
if (removeMask.value & bit) addMask.value &= ~bit;
|
||||
}
|
||||
|
||||
function apply() {
|
||||
emit('apply', { add: addMask.value, remove: removeMask.value });
|
||||
addMask.value = 0;
|
||||
removeMask.value = 0;
|
||||
open.value = false;
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import LimitBulkDialog from './LimitBulkDialog.vue';
|
||||
const open = ref(true);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="projects/LimitBulkDialog">
|
||||
<Variant title="open (5 projects)">
|
||||
<LimitBulkDialog v-model="open" :count="5" />
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<v-dialog v-model="open" max-width="480">
|
||||
<v-card>
|
||||
<v-card-title>Лимит лидов — для {{ count }} проектов</v-card-title>
|
||||
<v-card-text>
|
||||
<template v-if="!useReplace">
|
||||
<v-text-field
|
||||
v-model.number="addValue"
|
||||
type="number"
|
||||
min="0"
|
||||
label="➕ Прибавить к лимиту"
|
||||
suffix="лидов/день"
|
||||
data-testid="add-input"
|
||||
density="compact"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model.number="removeValue"
|
||||
type="number"
|
||||
min="0"
|
||||
label="➖ Убавить лимит"
|
||||
suffix="лидов/день"
|
||||
data-testid="remove-input"
|
||||
density="compact"
|
||||
class="mt-2"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<v-text-field
|
||||
v-model.number="replaceValue"
|
||||
type="number"
|
||||
min="0"
|
||||
label="Установить лимит"
|
||||
suffix="лидов/день"
|
||||
data-testid="replace-input"
|
||||
density="compact"
|
||||
/>
|
||||
</template>
|
||||
<v-checkbox
|
||||
v-model="useReplace"
|
||||
label="Заменить на абсолютное значение"
|
||||
data-testid="replace-toggle"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="mt-3"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn data-testid="cancel" @click="open = false">Отмена</v-btn>
|
||||
<v-btn color="primary" data-testid="apply" :disabled="!canApply" @click="apply"
|
||||
>Применить к {{ count }}</v-btn
|
||||
>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; count: number }>();
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean];
|
||||
apply: [payload: { delta?: number; replace?: number }];
|
||||
}>();
|
||||
|
||||
const open = ref(props.modelValue);
|
||||
const useReplace = ref(false);
|
||||
const addValue = ref<number | null>(null);
|
||||
const removeValue = ref<number | null>(null);
|
||||
const replaceValue = ref<number | null>(null);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
open.value = val;
|
||||
if (val) {
|
||||
useReplace.value = false;
|
||||
addValue.value = null;
|
||||
removeValue.value = null;
|
||||
replaceValue.value = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(open, (val) => {
|
||||
emit('update:modelValue', val);
|
||||
});
|
||||
|
||||
const canApply = computed(() => {
|
||||
if (useReplace.value) return replaceValue.value !== null && replaceValue.value >= 0;
|
||||
return (addValue.value ?? 0) > 0 || (removeValue.value ?? 0) > 0;
|
||||
});
|
||||
|
||||
function apply() {
|
||||
if (useReplace.value && replaceValue.value !== null) {
|
||||
emit('apply', { replace: replaceValue.value });
|
||||
} else {
|
||||
const delta = (addValue.value ?? 0) - (removeValue.value ?? 0);
|
||||
emit('apply', { delta });
|
||||
}
|
||||
addValue.value = null;
|
||||
removeValue.value = null;
|
||||
replaceValue.value = null;
|
||||
open.value = false;
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import ProjectCard from './ProjectCard.vue';
|
||||
|
||||
const base = {
|
||||
id: 1,
|
||||
name: 'Окна СПб',
|
||||
signal_type: 'site' as const,
|
||||
signal_identifier: 'okna.ru',
|
||||
daily_limit_target: 50,
|
||||
delivered_today: 32,
|
||||
is_active: true,
|
||||
archived_at: null,
|
||||
sync_status: 'ok' as const,
|
||||
};
|
||||
|
||||
const okProject = base;
|
||||
const pendingProject = { ...base, sync_status: 'pending' as const };
|
||||
const failedProject = { ...base, sync_status: 'failed' as const };
|
||||
const pausedProject = { ...base, is_active: false };
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Projects / ProjectCard" :layout="{ type: 'single', iframe: true }">
|
||||
<Variant title="Sync OK (active)">
|
||||
<v-app>
|
||||
<v-main class="story-pane">
|
||||
<div class="card-wrap">
|
||||
<ProjectCard :project="okProject" :selected="false" />
|
||||
</div>
|
||||
</v-main>
|
||||
</v-app>
|
||||
</Variant>
|
||||
<Variant title="Sync pending">
|
||||
<v-app>
|
||||
<v-main class="story-pane">
|
||||
<div class="card-wrap">
|
||||
<ProjectCard :project="pendingProject" :selected="false" />
|
||||
</div>
|
||||
</v-main>
|
||||
</v-app>
|
||||
</Variant>
|
||||
<Variant title="Sync failed">
|
||||
<v-app>
|
||||
<v-main class="story-pane">
|
||||
<div class="card-wrap">
|
||||
<ProjectCard :project="failedProject" :selected="false" />
|
||||
</div>
|
||||
</v-main>
|
||||
</v-app>
|
||||
</Variant>
|
||||
<Variant title="Paused">
|
||||
<v-app>
|
||||
<v-main class="story-pane">
|
||||
<div class="card-wrap">
|
||||
<ProjectCard :project="pausedProject" :selected="false" />
|
||||
</div>
|
||||
</v-main>
|
||||
</v-app>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.story-pane {
|
||||
background: #f6f3ec;
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
}
|
||||
.card-wrap {
|
||||
width: 360px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<v-card class="project-card ld-hover-lift" :class="{ paused: !project.is_active }" elevation="1">
|
||||
<v-card-item>
|
||||
<template #prepend>
|
||||
<label class="card-check" data-testid="card-select">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selected"
|
||||
:aria-label="`Выбрать проект «${project.name}»`"
|
||||
@change="$emit('toggle-select', project.id)"
|
||||
/>
|
||||
<span class="card-check__box" />
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<v-card-title>
|
||||
{{ project.name }}
|
||||
<v-chip size="x-small" :color="typeColor" class="ml-2">{{ typeLabel }}</v-chip>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-subtitle>{{ identifierDisplay }}</v-card-subtitle>
|
||||
|
||||
<template #append>
|
||||
<v-menu>
|
||||
<template #activator="{ props: menuProps }">
|
||||
<v-btn
|
||||
icon="mdi-dots-vertical"
|
||||
variant="text"
|
||||
size="small"
|
||||
:aria-label="`Меню действий проекта «${project.name}»`"
|
||||
v-bind="menuProps"
|
||||
/>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item @click="$emit('edit', project)">
|
||||
<template #prepend><v-icon>mdi-pencil</v-icon></template>
|
||||
<v-list-item-title>Редактировать</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click="$emit('toggle-active', project)">
|
||||
<template #prepend
|
||||
><v-icon>{{ project.is_active ? 'mdi-pause' : 'mdi-play' }}</v-icon></template
|
||||
>
|
||||
<v-list-item-title>{{
|
||||
project.is_active ? 'Приостановить' : 'Возобновить'
|
||||
}}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click="$emit('sync-now', project)">
|
||||
<template #prepend><v-icon>mdi-refresh</v-icon></template>
|
||||
<v-list-item-title>Синхронизировать</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click="$emit('archive', project)">
|
||||
<template #prepend><v-icon>mdi-archive</v-icon></template>
|
||||
<v-list-item-title>Архивировать</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</template>
|
||||
</v-card-item>
|
||||
|
||||
<v-card-text>
|
||||
<div v-if="project.is_active" class="mb-2">
|
||||
<div class="d-flex justify-space-between">
|
||||
<span class="text-caption"
|
||||
><span class="ld-mono">{{ project.delivered_today }}</span> /
|
||||
<span class="ld-mono">{{ project.daily_limit_target }}</span> лидов</span
|
||||
>
|
||||
<span class="text-caption text-medium-emphasis"
|
||||
><span class="ld-mono">{{ progressPercent }}</span
|
||||
>%</span
|
||||
>
|
||||
</div>
|
||||
<v-progress-linear
|
||||
:model-value="progressPercent"
|
||||
:color="progressColor"
|
||||
height="6"
|
||||
rounded
|
||||
:aria-label="`Прогресс дневной нормы: ${progressPercent}%`"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="text-caption text-medium-emphasis mb-2">На паузе</div>
|
||||
|
||||
<v-chip :color="syncStatusColor" size="x-small" variant="tonal">
|
||||
<v-icon start size="x-small">{{ syncStatusIcon }}</v-icon>
|
||||
{{ syncStatusLabel }}
|
||||
</v-chip>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { Project } from '../../stores/projectsStore';
|
||||
|
||||
const props = defineProps<{ project: Project; selected: boolean }>();
|
||||
defineEmits<{
|
||||
'toggle-select': [id: number];
|
||||
edit: [project: Project];
|
||||
'toggle-active': [project: Project];
|
||||
'sync-now': [project: Project];
|
||||
archive: [project: Project];
|
||||
}>();
|
||||
|
||||
const typeLabel = computed(() => ({ site: 'Сайт', call: 'Звонок', sms: 'СМС' })[props.project.signal_type]);
|
||||
const typeColor = computed(
|
||||
() => ({ site: 'blue-lighten-4', call: 'orange-lighten-4', sms: 'purple-lighten-4' })[props.project.signal_type],
|
||||
);
|
||||
const identifierDisplay = computed(() => {
|
||||
if (props.project.signal_type === 'sms') {
|
||||
return [(props.project.sms_senders ?? []).join(', '), props.project.sms_keyword].filter(Boolean).join(' · ');
|
||||
}
|
||||
return props.project.signal_identifier ?? '';
|
||||
});
|
||||
const progressPercent = computed(() =>
|
||||
Math.min(100, Math.round((props.project.delivered_today / props.project.daily_limit_target) * 100)),
|
||||
);
|
||||
const progressColor = computed(() => (progressPercent.value >= 90 ? 'success' : 'primary'));
|
||||
const syncStatusLabel = computed(
|
||||
() => ({ ok: 'Sync OK', pending: 'Sync pending', failed: 'Sync failed' })[props.project.sync_status],
|
||||
);
|
||||
const syncStatusIcon = computed(
|
||||
() =>
|
||||
({ ok: 'mdi-check-circle', pending: 'mdi-clock-outline', failed: 'mdi-alert-circle' })[
|
||||
props.project.sync_status
|
||||
],
|
||||
);
|
||||
const syncStatusColor = computed(
|
||||
() => ({ ok: 'success', pending: 'warning', failed: 'error' })[props.project.sync_status],
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.project-card.paused {
|
||||
opacity: 0.75;
|
||||
}
|
||||
.card-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
}
|
||||
.card-check input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.card-check__box {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1px solid var(--liderra-line);
|
||||
border-radius: var(--radius-6);
|
||||
background: var(--liderra-surface);
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
transition:
|
||||
border-color 200ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
background-color 200ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.card-check:hover .card-check__box {
|
||||
border-color: var(--liderra-line-strong);
|
||||
}
|
||||
.card-check input:focus-visible + .card-check__box {
|
||||
outline: 2px solid var(--liderra-teal);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.card-check input:checked + .card-check__box {
|
||||
background: rgba(15, 110, 86, 0.1);
|
||||
border-color: var(--liderra-teal);
|
||||
}
|
||||
.card-check input:checked + .card-check__box::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 0;
|
||||
width: 5px;
|
||||
height: 9px;
|
||||
border: solid var(--liderra-teal);
|
||||
border-width: 0 1.5px 1.5px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import RegionsBulkDialog from './RegionsBulkDialog.vue';
|
||||
const open = ref(true);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="projects/RegionsBulkDialog">
|
||||
<Variant title="open (5 projects)">
|
||||
<RegionsBulkDialog v-model="open" :count="5" />
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<v-dialog v-model="open" max-width="560">
|
||||
<v-card>
|
||||
<v-card-title>Регионы — для {{ count }} проектов</v-card-title>
|
||||
<v-card-text>
|
||||
<div class="mb-4">
|
||||
<div class="text-caption text-success font-weight-medium mb-2">➕ Добавить</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<v-chip
|
||||
v-for="r in FEDERAL_DISTRICTS"
|
||||
:key="`add-${r.bit}`"
|
||||
:data-testid="`region-add-${r.bit}`"
|
||||
:color="addMask & r.bit ? 'success' : undefined"
|
||||
:variant="addMask & r.bit ? 'flat' : 'outlined'"
|
||||
size="small"
|
||||
@click="toggleAdd(r.bit)"
|
||||
>{{ r.label }}</v-chip
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-caption text-error font-weight-medium mb-2">➖ Убрать</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<v-chip
|
||||
v-for="r in FEDERAL_DISTRICTS"
|
||||
:key="`remove-${r.bit}`"
|
||||
:data-testid="`region-remove-${r.bit}`"
|
||||
:color="removeMask & r.bit ? 'error' : undefined"
|
||||
:variant="removeMask & r.bit ? 'flat' : 'outlined'"
|
||||
size="small"
|
||||
@click="toggleRemove(r.bit)"
|
||||
>{{ r.label }}</v-chip
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn data-testid="cancel" @click="open = false">Отмена</v-btn>
|
||||
<v-btn color="primary" data-testid="apply" :disabled="addMask === 0 && removeMask === 0" @click="apply"
|
||||
>Применить к {{ count }}</v-btn
|
||||
>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { FEDERAL_DISTRICTS } from '../../constants/federal-districts';
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; count: number }>();
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean];
|
||||
apply: [payload: { add: number; remove: number }];
|
||||
}>();
|
||||
|
||||
const open = ref(props.modelValue);
|
||||
const addMask = ref(0);
|
||||
const removeMask = ref(0);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
open.value = val;
|
||||
if (val) {
|
||||
addMask.value = 0;
|
||||
removeMask.value = 0;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(open, (val) => {
|
||||
emit('update:modelValue', val);
|
||||
});
|
||||
|
||||
function toggleAdd(bit: number) {
|
||||
addMask.value ^= bit;
|
||||
if (addMask.value & bit) removeMask.value &= ~bit;
|
||||
}
|
||||
|
||||
function toggleRemove(bit: number) {
|
||||
removeMask.value ^= bit;
|
||||
if (removeMask.value & bit) addMask.value &= ~bit;
|
||||
}
|
||||
|
||||
function apply() {
|
||||
emit('apply', { add: addMask.value, remove: removeMask.value });
|
||||
addMask.value = 0;
|
||||
removeMask.value = 0;
|
||||
open.value = false;
|
||||
}
|
||||
</script>
|
||||
@@ -134,7 +134,7 @@ function formatAbsolute(iso: string | null): string {
|
||||
.empty-hint {
|
||||
font-size: 12px;
|
||||
margin-top: 6px;
|
||||
color: #9a9690;
|
||||
color: #6b6356;
|
||||
}
|
||||
|
||||
.reminder-row {
|
||||
|
||||
@@ -33,9 +33,7 @@ const sessions: Session[] = [
|
||||
эта сессия
|
||||
</v-chip>
|
||||
</div>
|
||||
<div class="session-meta text-caption text-medium-emphasis">
|
||||
{{ s.location }} · {{ s.when }}
|
||||
</div>
|
||||
<div class="session-meta text-caption text-medium-emphasis">{{ s.location }} · {{ s.when }}</div>
|
||||
</div>
|
||||
<v-btn v-if="!s.current" variant="text" size="small" color="error"> Завершить </v-btn>
|
||||
</li>
|
||||
|
||||
@@ -99,8 +99,8 @@ async function confirmDisable(): Promise<void> {
|
||||
безопасном месте.
|
||||
</template>
|
||||
<template v-else>
|
||||
Защитите аккаунт двухфакторной авторизацией. Поддерживаются Google Authenticator, Yandex Key,
|
||||
1Password и другие TOTP-приложения.
|
||||
Защитите аккаунт двухфакторной авторизацией. Поддерживаются Google Authenticator, Yandex Key, 1Password
|
||||
и другие TOTP-приложения.
|
||||
</template>
|
||||
</p>
|
||||
<div class="d-flex ga-2 flex-wrap">
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import DensityToggle from './DensityToggle.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="UI/DensityToggle">
|
||||
<Variant title="Default"><DensityToggle /></Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { useDensity, type Density } from '../../composables/useDensity';
|
||||
|
||||
const { density, setDensity } = useDensity();
|
||||
|
||||
const emit = defineEmits<{ change: [Density] }>();
|
||||
|
||||
function pick(d: Density): void {
|
||||
setDensity(d);
|
||||
emit('change', d);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ld-density-toggle" role="group" aria-label="Плотность таблицы">
|
||||
<button
|
||||
type="button"
|
||||
class="ld-density-toggle__btn"
|
||||
:class="{ 'ld-density-toggle__btn--active': density === 'compact' }"
|
||||
@click="pick('compact')"
|
||||
>
|
||||
Компакт
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ld-density-toggle__btn"
|
||||
:class="{ 'ld-density-toggle__btn--active': density === 'comfortable' }"
|
||||
@click="pick('comfortable')"
|
||||
>
|
||||
Комфорт
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ld-density-toggle {
|
||||
display: inline-flex;
|
||||
background: var(--liderra-surface);
|
||||
border: 1px solid var(--liderra-line);
|
||||
border-radius: var(--radius-8);
|
||||
padding: 2px;
|
||||
}
|
||||
.ld-density-toggle__btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 5px 10px;
|
||||
border-radius: var(--radius-6);
|
||||
font-size: 11px;
|
||||
color: var(--liderra-muted);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition:
|
||||
background 200ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
color 200ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.ld-density-toggle__btn--active {
|
||||
background: rgba(1, 32, 25, 0.08);
|
||||
color: var(--liderra-noir);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import FilterChip from './FilterChip.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="UI/FilterChip">
|
||||
<Variant title="Default">
|
||||
<FilterChip label="Статус" />
|
||||
<FilterChip label="Проект" :count="2" />
|
||||
<FilterChip label="Менеджер" :active="true" :count="3" />
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
label: string;
|
||||
count?: number;
|
||||
active?: boolean;
|
||||
}>();
|
||||
|
||||
defineEmits<{ click: [] }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button type="button" class="ld-filter-chip" :class="{ 'ld-filter-chip--active': active }" @click="$emit('click')">
|
||||
<span>{{ label }}</span>
|
||||
<span v-if="count && count > 0" class="ld-filter-chip__count">{{ count }}</span>
|
||||
<span class="ld-filter-chip__caret">▾</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ld-filter-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 12px;
|
||||
border-radius: var(--radius-8);
|
||||
background: var(--liderra-surface);
|
||||
border: 1px solid var(--liderra-line);
|
||||
font-size: 12px;
|
||||
color: var(--liderra-noir);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: border-color 200ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.ld-filter-chip:hover {
|
||||
border-color: var(--liderra-line-strong);
|
||||
}
|
||||
.ld-filter-chip--active {
|
||||
border-color: var(--liderra-teal);
|
||||
background: rgba(15, 110, 86, 0.06);
|
||||
color: var(--liderra-teal);
|
||||
}
|
||||
.ld-filter-chip__count {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
background: rgba(15, 110, 86, 0.12);
|
||||
color: var(--liderra-teal);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.ld-filter-chip__caret {
|
||||
font-size: 9px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import Kbd from './Kbd.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="UI/Kbd">
|
||||
<Variant title="Light"><Kbd>⌘K</Kbd> <Kbd>Esc</Kbd> <Kbd>/</Kbd></Variant>
|
||||
<Variant title="Dark (sidebar)">
|
||||
<div style="background: #012019; padding: 14px; border-radius: 8px"><Kbd dark>⌘K</Kbd></div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ dark?: boolean }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<kbd class="ld-kbd" :class="{ 'ld-kbd--dark': dark }">
|
||||
<slot />
|
||||
</kbd>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ld-kbd {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(1, 32, 25, 0.06);
|
||||
color: var(--liderra-muted);
|
||||
border: 1px solid var(--liderra-line);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.ld-kbd--dark {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(232, 226, 212, 0.85);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import StatusPill from './StatusPill.vue';
|
||||
import { STATUS_PILL_SLUGS } from '../../composables/useStatusPill';
|
||||
|
||||
const labelMap: Record<string, string> = {
|
||||
new: 'Новый',
|
||||
in_progress: 'В работе',
|
||||
callback: 'Перезвонить',
|
||||
quality: 'Качественный',
|
||||
meeting_set: 'Встреча',
|
||||
won: 'Продано',
|
||||
refund: 'Возврат',
|
||||
duplicate: 'Дубль',
|
||||
junk: 'Спам',
|
||||
no_answer: 'Нет ответа',
|
||||
cancelled: 'Отменено',
|
||||
closed: 'Закрыто',
|
||||
postponed: 'Отложено',
|
||||
archived: 'Архив',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="UI/StatusPill" :layout="{ type: 'grid', width: 200 }">
|
||||
<Variant v-for="slug in STATUS_PILL_SLUGS" :key="slug" :title="slug">
|
||||
<StatusPill :slug="slug" :label="labelMap[slug]" />
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useStatusPill } from '../../composables/useStatusPill';
|
||||
|
||||
const props = defineProps<{
|
||||
slug: string;
|
||||
label?: string;
|
||||
}>();
|
||||
|
||||
const style = computed<Record<string, string>>(() => {
|
||||
const s = useStatusPill(props.slug);
|
||||
const css: Record<string, string> = {
|
||||
background: s.bg,
|
||||
color: s.color,
|
||||
};
|
||||
if (s.fontWeight) css['font-weight'] = String(s.fontWeight);
|
||||
if (s.textDecoration) css['text-decoration'] = s.textDecoration;
|
||||
return css;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="ld-status-pill" :style="style">{{ label ?? slug }}</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ld-status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 9px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
transition:
|
||||
background 300ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
color 300ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
</style>
|
||||
@@ -56,4 +56,3 @@ export interface AdminTenantDetail extends AdminTenant {
|
||||
avgLeadCost: number;
|
||||
runwayDays: number; // balance / avgDailySpend
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* useCountUp — RAF-tween анимация числа (Quiet Luxury KPI cards).
|
||||
*
|
||||
* - easeOutQuint easing
|
||||
* - respects prefers-reduced-motion (instant value)
|
||||
* - re-animates when target ref changes
|
||||
*
|
||||
* Spec: docs/superpowers/plans/2026-05-12-portal-redesign-quiet-luxury-plan.md (Task 6).
|
||||
*/
|
||||
import { ref, watch, type Ref } from 'vue';
|
||||
|
||||
export interface CountUpOptions {
|
||||
duration?: number; // ms
|
||||
precision?: number; // знаков после запятой
|
||||
}
|
||||
|
||||
export interface CountUpHandle {
|
||||
display: Ref<number>;
|
||||
start: () => void;
|
||||
}
|
||||
|
||||
const easeOutQuint = (t: number): number => 1 - Math.pow(1 - t, 5);
|
||||
|
||||
function prefersReducedMotion(): boolean {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return false;
|
||||
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
}
|
||||
|
||||
export function useCountUp(target: Ref<number>, opts: CountUpOptions = {}): CountUpHandle {
|
||||
const duration = opts.duration ?? 600;
|
||||
const precision = opts.precision ?? 0;
|
||||
const display = ref(0);
|
||||
let raf: number | null = null;
|
||||
let startTime = 0;
|
||||
let fromValue = 0;
|
||||
|
||||
function tick(now: number): void {
|
||||
const elapsed = now - startTime;
|
||||
const t = Math.min(elapsed / duration, 1);
|
||||
const eased = easeOutQuint(t);
|
||||
const value = fromValue + (target.value - fromValue) * eased;
|
||||
display.value = precision === 0 ? Math.round(value) : parseFloat(value.toFixed(precision));
|
||||
if (t < 1) {
|
||||
raf = requestAnimationFrame(tick);
|
||||
} else {
|
||||
display.value = target.value;
|
||||
raf = null;
|
||||
}
|
||||
}
|
||||
|
||||
function start(): void {
|
||||
if (prefersReducedMotion()) {
|
||||
display.value = target.value;
|
||||
return;
|
||||
}
|
||||
if (raf !== null) cancelAnimationFrame(raf);
|
||||
fromValue = display.value;
|
||||
startTime = performance.now();
|
||||
raf = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
watch(target, () => {
|
||||
if (display.value !== target.value) start();
|
||||
});
|
||||
|
||||
return { display, start };
|
||||
}
|
||||
@@ -46,10 +46,7 @@ export function triggerCsvDownload(csv: string, filename: string): void {
|
||||
* BOM нужен чтобы Excel корректно распознавал UTF-8.
|
||||
*/
|
||||
export function buildCsvString(headers: string[], rows: (string | number)[][]): string {
|
||||
const lines = [
|
||||
headers.join(';'),
|
||||
...rows.map((row) => row.map((v) => csvEscape(String(v))).join(';')),
|
||||
];
|
||||
const lines = [headers.join(';'), ...rows.map((row) => row.map((v) => csvEscape(String(v))).join(';'))];
|
||||
// String.fromCharCode(0xfeff) вместо литерального BOM — иначе ESLint
|
||||
// no-irregular-whitespace.
|
||||
return String.fromCharCode(0xfeff) + lines.join('\r\n');
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { computed, ref, watch, type ComputedRef, type Ref } from 'vue';
|
||||
|
||||
export type Density = 'comfortable' | 'compact';
|
||||
|
||||
export const DENSITY_KEY = 'liderra:density';
|
||||
|
||||
export interface DensityHandle {
|
||||
density: Ref<Density>;
|
||||
rowHeight: ComputedRef<number>;
|
||||
setDensity: (d: Density) => void;
|
||||
toggle: () => void;
|
||||
}
|
||||
|
||||
function loadInitial(): Density {
|
||||
if (typeof localStorage === 'undefined') return 'comfortable';
|
||||
const raw = localStorage.getItem(DENSITY_KEY);
|
||||
return raw === 'compact' ? 'compact' : 'comfortable';
|
||||
}
|
||||
|
||||
export function useDensity(): DensityHandle {
|
||||
const density = ref<Density>(loadInitial());
|
||||
|
||||
const rowHeight = computed<number>(() => (density.value === 'compact' ? 36 : 44));
|
||||
|
||||
function setDensity(d: Density): void {
|
||||
density.value = d;
|
||||
}
|
||||
|
||||
function toggle(): void {
|
||||
density.value = density.value === 'comfortable' ? 'compact' : 'comfortable';
|
||||
}
|
||||
|
||||
watch(
|
||||
density,
|
||||
(v) => {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem(DENSITY_KEY, v);
|
||||
}
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
);
|
||||
|
||||
return { density, rowHeight, setDensity, toggle };
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { ref, type Ref } from 'vue';
|
||||
|
||||
export interface DevIndicesApi {
|
||||
currentTarget: Ref<HTMLElement | null>;
|
||||
currentId: Ref<number | null>;
|
||||
overlayMode: Ref<boolean>;
|
||||
hoverEnabled: Ref<boolean>;
|
||||
setTarget(el: HTMLElement | null): void;
|
||||
toggleOverlay(): void;
|
||||
walkToParent(): void;
|
||||
walkToChild(): void;
|
||||
pauseHover(ms: number): void;
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
// Module-level singleton state — shared across all consumers
|
||||
const currentTarget = ref<HTMLElement | null>(null);
|
||||
const currentId = ref<number | null>(null);
|
||||
const overlayMode = ref(false);
|
||||
const hoverEnabled = ref(true);
|
||||
let pauseTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function parseId(el: HTMLElement | null): number | null {
|
||||
if (!el) return null;
|
||||
const raw = el.getAttribute('data-dx');
|
||||
if (raw == null) return null;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function setTarget(el: HTMLElement | null): void {
|
||||
if (el == null) {
|
||||
currentTarget.value = null;
|
||||
currentId.value = null;
|
||||
return;
|
||||
}
|
||||
const id = parseId(el);
|
||||
if (id == null) return;
|
||||
currentTarget.value = el;
|
||||
currentId.value = id;
|
||||
}
|
||||
|
||||
function toggleOverlay(): void {
|
||||
overlayMode.value = !overlayMode.value;
|
||||
}
|
||||
|
||||
function findAncestorWithDx(el: HTMLElement | null): HTMLElement | null {
|
||||
let cur: HTMLElement | null = el?.parentElement ?? null;
|
||||
while (cur) {
|
||||
if (cur.hasAttribute('data-dx')) return cur;
|
||||
cur = cur.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findFirstDescendantWithDx(el: HTMLElement | null): HTMLElement | null {
|
||||
if (!el) return null;
|
||||
// BFS: find first descendant with data-dx
|
||||
const queue: HTMLElement[] = Array.from(el.children) as HTMLElement[];
|
||||
while (queue.length) {
|
||||
const cur = queue.shift()!;
|
||||
if (cur.hasAttribute('data-dx')) return cur;
|
||||
queue.push(...(Array.from(cur.children) as HTMLElement[]));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function walkToParent(): void {
|
||||
const parent = findAncestorWithDx(currentTarget.value);
|
||||
if (parent) setTarget(parent);
|
||||
}
|
||||
|
||||
function walkToChild(): void {
|
||||
const child = findFirstDescendantWithDx(currentTarget.value);
|
||||
if (child) setTarget(child);
|
||||
}
|
||||
|
||||
function pauseHover(ms: number): void {
|
||||
hoverEnabled.value = false;
|
||||
if (pauseTimer) clearTimeout(pauseTimer);
|
||||
pauseTimer = setTimeout(() => {
|
||||
hoverEnabled.value = true;
|
||||
pauseTimer = null;
|
||||
}, ms);
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
currentTarget.value = null;
|
||||
currentId.value = null;
|
||||
overlayMode.value = false;
|
||||
hoverEnabled.value = true;
|
||||
if (pauseTimer) {
|
||||
clearTimeout(pauseTimer);
|
||||
pauseTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function useDevIndices(): DevIndicesApi {
|
||||
return {
|
||||
currentTarget,
|
||||
currentId,
|
||||
overlayMode,
|
||||
hoverEnabled,
|
||||
setTarget,
|
||||
toggleOverlay,
|
||||
walkToParent,
|
||||
walkToChild,
|
||||
pauseHover,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Маппинг slug'ов lead_statuses → стилевые токены пилюли.
|
||||
* Slugs синхронизированы с db/schema.sql:2076 (источник истины).
|
||||
*
|
||||
* Spec §8. Используется компонентом StatusPill.vue.
|
||||
*/
|
||||
export interface PillStyle {
|
||||
bg: string;
|
||||
color: string;
|
||||
fontWeight?: number;
|
||||
textDecoration?: 'line-through' | 'none';
|
||||
}
|
||||
|
||||
export const STATUS_PILL_SLUGS = [
|
||||
'new',
|
||||
'in_progress',
|
||||
'callback',
|
||||
'quality',
|
||||
'meeting_set',
|
||||
'won',
|
||||
'refund',
|
||||
'duplicate',
|
||||
'junk',
|
||||
'no_answer',
|
||||
'cancelled',
|
||||
'closed',
|
||||
'postponed',
|
||||
'archived',
|
||||
] as const;
|
||||
|
||||
export type StatusPillSlug = (typeof STATUS_PILL_SLUGS)[number];
|
||||
|
||||
const STYLES: Record<StatusPillSlug, PillStyle> = {
|
||||
new: { bg: 'rgba(15,110,86,0.12)', color: '#0F6E56' },
|
||||
in_progress: { bg: 'rgba(63,124,149,0.12)', color: '#2A5A6E' },
|
||||
callback: { bg: 'rgba(217,164,65,0.18)', color: '#A07820' },
|
||||
quality: { bg: 'rgba(46,139,87,0.15)', color: '#2E8B57' },
|
||||
meeting_set: { bg: 'rgba(122,91,163,0.15)', color: '#7A5BA3' },
|
||||
won: { bg: 'rgba(46,139,87,0.22)', color: '#1F6940', fontWeight: 600 },
|
||||
refund: { bg: 'rgba(204,110,80,0.15)', color: '#B0563D' },
|
||||
duplicate: { bg: 'rgba(1,32,25,0.08)', color: '#3A3A3A' },
|
||||
junk: { bg: 'rgba(184,58,58,0.10)', color: '#B83A3A' },
|
||||
no_answer: { bg: 'rgba(107,99,86,0.15)', color: '#6B6356' },
|
||||
cancelled: { bg: 'rgba(107,99,86,0.18)', color: '#6B6356', textDecoration: 'line-through' },
|
||||
closed: { bg: 'rgba(1,32,25,0.10)', color: '#3A3A3A' },
|
||||
postponed: { bg: 'rgba(15,110,86,0.06)', color: '#6B6356' },
|
||||
archived: { bg: '#012019', color: '#E8E2D4' },
|
||||
};
|
||||
|
||||
const FALLBACK: PillStyle = { bg: 'rgba(1,32,25,0.08)', color: '#3A3A3A' };
|
||||
|
||||
export function useStatusPill(slug: string): PillStyle {
|
||||
return STYLES[slug as StatusPillSlug] ?? FALLBACK;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export interface FederalDistrict {
|
||||
bit: number; // 1, 2, 4, ..., 128
|
||||
label: string;
|
||||
}
|
||||
|
||||
// 8 ФО РФ — соответствует schema `projects.region_mask BETWEEN 0 AND 255`.
|
||||
// Используется в bulk-операциях по проектам (грубое выделение).
|
||||
// Для тонкого pick'а subject-level см. constants/regions.ts.
|
||||
export const FEDERAL_DISTRICTS: FederalDistrict[] = [
|
||||
{ bit: 1, label: 'Центральный' },
|
||||
{ bit: 2, label: 'Северо-Западный' },
|
||||
{ bit: 4, label: 'Южный' },
|
||||
{ bit: 8, label: 'Северо-Кавказский' },
|
||||
{ bit: 16, label: 'Приволжский' },
|
||||
{ bit: 32, label: 'Уральский' },
|
||||
{ bit: 64, label: 'Сибирский' },
|
||||
{ bit: 128, label: 'Дальневосточный' },
|
||||
];
|
||||
@@ -0,0 +1,42 @@
|
||||
export interface Region {
|
||||
code: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// MVP: 31 региона (коды 1..31) ограничены 32-bit region_mask из Plan 5 Task 9.
|
||||
// Sentinel code:0 = «Вся РФ» (включает все регионы, эквивалент пустой маски).
|
||||
// Имена — официальные субъекты РФ по конституционному порядку нумерации.
|
||||
export const REGIONS: Region[] = [
|
||||
{ code: 0, name: 'Вся РФ' },
|
||||
{ code: 1, name: 'Республика Адыгея' },
|
||||
{ code: 2, name: 'Республика Башкортостан' },
|
||||
{ code: 3, name: 'Республика Бурятия' },
|
||||
{ code: 4, name: 'Республика Алтай' },
|
||||
{ code: 5, name: 'Республика Дагестан' },
|
||||
{ code: 6, name: 'Республика Ингушетия' },
|
||||
{ code: 7, name: 'Кабардино-Балкарская Республика' },
|
||||
{ code: 8, name: 'Республика Калмыкия' },
|
||||
{ code: 9, name: 'Карачаево-Черкесская Республика' },
|
||||
{ code: 10, name: 'Республика Карелия' },
|
||||
{ code: 11, name: 'Республика Коми' },
|
||||
{ code: 12, name: 'Республика Марий Эл' },
|
||||
{ code: 13, name: 'Республика Мордовия' },
|
||||
{ code: 14, name: 'Республика Саха (Якутия)' },
|
||||
{ code: 15, name: 'Республика Северная Осетия — Алания' },
|
||||
{ code: 16, name: 'Республика Татарстан' },
|
||||
{ code: 17, name: 'Республика Тыва' },
|
||||
{ code: 18, name: 'Удмуртская Республика' },
|
||||
{ code: 19, name: 'Республика Хакасия' },
|
||||
{ code: 20, name: 'Чеченская Республика' },
|
||||
{ code: 21, name: 'Чувашская Республика' },
|
||||
{ code: 22, name: 'Алтайский край' },
|
||||
{ code: 23, name: 'Краснодарский край' },
|
||||
{ code: 24, name: 'Красноярский край' },
|
||||
{ code: 25, name: 'Приморский край' },
|
||||
{ code: 26, name: 'Ставропольский край' },
|
||||
{ code: 27, name: 'Хабаровский край' },
|
||||
{ code: 28, name: 'Амурская область' },
|
||||
{ code: 29, name: 'Архангельская область' },
|
||||
{ code: 30, name: 'Астраханская область' },
|
||||
{ code: 31, name: 'Белгородская область' },
|
||||
];
|
||||
@@ -0,0 +1,16 @@
|
||||
export interface Weekday {
|
||||
bit: number; // 1, 2, 4, ..., 64
|
||||
label: string;
|
||||
short: string;
|
||||
}
|
||||
|
||||
// Соответствует schema `projects.delivery_days_mask BETWEEN 0 AND 127` (7 бит).
|
||||
export const WEEKDAYS: Weekday[] = [
|
||||
{ bit: 1, label: 'Понедельник', short: 'Пн' },
|
||||
{ bit: 2, label: 'Вторник', short: 'Вт' },
|
||||
{ bit: 4, label: 'Среда', short: 'Ср' },
|
||||
{ bit: 8, label: 'Четверг', short: 'Чт' },
|
||||
{ bit: 16, label: 'Пятница', short: 'Пт' },
|
||||
{ bit: 32, label: 'Суббота', short: 'Сб' },
|
||||
{ bit: 64, label: 'Воскресенье', short: 'Вс' },
|
||||
];
|
||||
@@ -1,18 +1,8 @@
|
||||
import { defineSetupVue3 } from '@histoire/plugin-vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import { createMemoryHistory, createRouter } from 'vue-router';
|
||||
import { vuetify } from './plugins/vuetify';
|
||||
|
||||
/**
|
||||
* Histoire setup — регистрирует Vuetify + Vue Router (memory-history) для каждой story.
|
||||
*
|
||||
* - Vuetify: без него VApp/VBtn/VCard не рендерятся (требует createVuetify-инстанс).
|
||||
* - vue-router: компоненты используют RouterLink/useRoute. В Histoire-iframe
|
||||
* нет HTML5 history API — используем memory-history с минимальным набором
|
||||
* stub-маршрутов (story-context, не реальные пути).
|
||||
*
|
||||
* vuetify/styles импортируется внутри plugins/vuetify.ts — повторно
|
||||
* импортировать здесь не нужно (TS 6 strict не видит side-effect d.ts).
|
||||
*/
|
||||
export const setupVue3 = defineSetupVue3(({ app }) => {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
@@ -23,9 +13,11 @@ export const setupVue3 = defineSetupVue3(({ app }) => {
|
||||
{ path: '/forgot', component: { template: '<div />' } },
|
||||
{ path: '/2fa', component: { template: '<div />' } },
|
||||
{ path: '/recovery', component: { template: '<div />' } },
|
||||
{ path: '/recovery-use', component: { template: '<div />' } },
|
||||
{ path: '/dashboard', component: { template: '<div />' } },
|
||||
{ path: '/deals', component: { template: '<div />' } },
|
||||
{ path: '/kanban', component: { template: '<div />' } },
|
||||
{ path: '/projects', component: { template: '<div />' } },
|
||||
{ path: '/reminders', component: { template: '<div />' } },
|
||||
{ path: '/billing', component: { template: '<div />' } },
|
||||
{ path: '/reports', component: { template: '<div />' } },
|
||||
@@ -35,4 +27,5 @@ export const setupVue3 = defineSetupVue3(({ app }) => {
|
||||
});
|
||||
app.use(vuetify);
|
||||
app.use(router);
|
||||
app.use(createPinia());
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user