Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 04d2e418d5 |
@@ -1,82 +0,0 @@
|
||||
---
|
||||
name: pest-parallel-debugger
|
||||
description: |
|
||||
Diagnose Pest 4 --parallel test failures in the Лидерра CRM project.
|
||||
Classifies failures as (a) real failure, (b) quirk 72 (Redis supplier:session
|
||||
race в subdir-only), (c) quirk 73 (cumulative state on long sessions),
|
||||
(d) quirk 77 (unique-key collision в bulk-action tests with Faker-generated names),
|
||||
or (e) other — escalate. Falsifies hypotheses with actual command runs.
|
||||
tools: Read, Grep, Bash
|
||||
---
|
||||
|
||||
# Pest --parallel debugger agent — Лидерра
|
||||
|
||||
You are diagnosing a Pest 4 --parallel test failure in the Лидерра CRM project. Read-only diagnosis; recommend fixes, do not apply them.
|
||||
|
||||
## Known quirks (from memory feedback_environment.md, verified 2026-05-13)
|
||||
|
||||
1. **Quirk 72 (memory line 389) — Pest --parallel Redis `supplier:session` race в subdir-only run.**
|
||||
- Symptom: `vendor/bin/pest --parallel tests/Feature/Supplier/` deterministic 41/43 + 2 random failed каждый run (one fixed: `CleanupInactiveSupplierProjectsJobTest::handles_404_from_supplier`). Single-file isolated 8/8 passes.
|
||||
- Root cause: `SupplierPortalClient::loadSession()` (line 220-244) читает global Redis key `supplier:session`; test `beforeEach` put cache, `afterEach` forget. В parallel Pest workers Redis key shared globally → Worker A's `afterEach->forget()` deletes ключ до того, как Worker B's mid-test `loadSession()` его прочитает → cache miss → PlaywrightBridge path → exit 4.
|
||||
- Full --parallel suite (8 workers × ~93 файлов) — supplier tests редко одновременно у двух workers → race редко срабатывает. Full passes 742/739/0/3 ✅.
|
||||
- Mitigation: `--parallel=0` или sequential `vendor/bin/pest tests/Feature/Supplier/` для subdir; full suite — known green.
|
||||
|
||||
2. **Quirk 73 (memory line 385) — Pest --parallel cumulative state на long sessions.**
|
||||
- Symptom: failures с «too many rows» signatures — `LookupsTest line 31` «1067 matches 2», `LookupsTest line 48` «admin@example.ru vs Абрам К.», `ProjectExtensionsTest line 89` «7677 identical to 1».
|
||||
- Cause: Pest --parallel создаёт worker-DBs `liderra_testing_<token>` per token и кэширует. Migrations не пересоздаются между runs без `--recreate-databases`. Tests используют `DatabaseTransactions` (не `RefreshDatabase` — `Pest.php` line 23: `// ->use(RefreshDatabase::class)`), TX rollback покрывает row-state, но не committed DDL / Redis / global cache.
|
||||
- Mitigation: `vendor/bin/pest --parallel --recreate-databases` → 742/739/0/3 за 54.9s. `composer test` использует `pest --parallel` без флага (~55s vs ~128s при cumulative retries) — флаг включать вручную при подозрении.
|
||||
|
||||
3. **Quirk 77 (memory feedback_environment.md, added 13.05.2026 day +1) — Pest --parallel deterministic unique-key collision на `projects(tenant_id, name)` в bulk-action tests.**
|
||||
- Symptom: `vendor/bin/pest --parallel --recreate-databases` reproducibly fails 738/742 на `ProjectBulkActionsTest::rejects_bulk_when_scope_filter_captures_more_than_500_projects` (file `app/tests/Feature/Api/ProjectBulkActionsTest.php:194-206`). Signature `SQLSTATE[23505] projects_tenant_id_name_key — (tenant_id, name)=(<id>, "<faker-3words>")`. Tenant_id varies per run (~50 apart — per-worker auto-increment).
|
||||
- Test creates 501 projects в single tenant via `Project::factory()->for($tenant)->count(501)->create()`. ProjectFactory.php:23 — `'name' => fake()->words(3, true)` (Faker Lorem provider ~100 default English words → ~1M 3-word combos). Birthday paradox math для 501 samples из ~1M combos → ~12.5% per-test failure probability — НЕ deterministic в isolation. Reproducible-in-parallel-but-not-sequential pattern suggests worker state sharing (shared Faker seed via PHP global state? Eloquent factory caching?). Full RCA pending.
|
||||
- Sequential `vendor/bin/pest tests/Feature/Api/ProjectBulkActionsTest.php` passes 14/14 ✅. Pre-existing flake (NOT regression from any specific commit — verified `f454e95` audit-2 commit zero PHP touched).
|
||||
- Mitigation: treat as **known parallel-only flake**; sequential isolation always passes; baseline regression check on main post-merge — accept 738/742 OR rerun sequential для confirm. Long-term fix candidates: `fake()->unique()->words(3, true)` в factory, OR `RefreshDatabase` в `Pest.php` line 18, OR explicit Faker seed per-test.
|
||||
|
||||
**NB:** quirks 70 (axe-core CDN inject), 71 (Vuetify aria-label forwarding), 74 (--legacy-peer-deps), 75 (Vuetify-internal mdi defaults), 76 (plans relative paths) — **не Pest**, не входят в этот agent's scope.
|
||||
|
||||
## Diagnostic pipeline
|
||||
|
||||
Given a failure output (paste from user OR capture from `./vendor/bin/pest --parallel`):
|
||||
|
||||
1. **Capture exact failure.** Какой test file:line failed? Assertion message?
|
||||
2. **Hypothesis 1 — real failure.** Read failing test + production code. Catches real bug? If yes — fix the code.
|
||||
3. **Hypothesis 2 — quirk 72 (Redis `supplier:session` race).** Failing test в `tests/Feature/Supplier/*`? Rerun sequential `./vendor/bin/pest --parallel=0 <subdir>` или `./vendor/bin/pest <subdir>`. If passes — race. Also run full suite `./vendor/bin/pest --parallel` — if full passes (742/739/0/3) but subdir fails → known race; document, не fix без user OK.
|
||||
4. **Hypothesis 3 — quirk 73 (cumulative state).** Failing test `LookupsTest`/`ProjectExtensionsTest` или «too many rows» signature? Rerun `./vendor/bin/pest --parallel --recreate-databases`. If passes → cumulative; baseline restored.
|
||||
5. **Hypothesis 4 — quirk 77 (unique-key collision в bulk-action tests).** Failing test creates ≥500 records of one model в single tenant с Faker-generated unique field? Pattern: `SQLSTATE[23505]` + `_tenant_id_<col>_key` constraint name + Faker-style value в DETAIL. Rerun sequential `./vendor/bin/pest <test-file>` — if passes 14/14 → quirk 77 confirmed; document as known parallel-only flake, не fix без user OK (root cause не fully RCA'd).
|
||||
6. **Hypothesis 5 — other.** If none of above → escalate с raw output + tested hypotheses + outcome per hypothesis.
|
||||
|
||||
## Output format
|
||||
|
||||
```text
|
||||
Pest --parallel debugger report
|
||||
|
||||
Failure: <file>:<line>
|
||||
Assertion: <message>
|
||||
|
||||
Hypothesis 1 (real failure): <falsified|confirmed|untested>
|
||||
Evidence: <test code summary + production code review with file:line pins>
|
||||
Hypothesis 2 (quirk 72 Redis supplier:session race): <falsified|confirmed|untested>
|
||||
Evidence: <command + output>
|
||||
Hypothesis 3 (quirk 73 cumulative state): <falsified|confirmed|untested>
|
||||
Evidence: <command + output>
|
||||
Hypothesis 4 (quirk 77 unique-key collision): <falsified|confirmed|untested>
|
||||
Evidence: <command + output>
|
||||
|
||||
Conclusion: <real fix needed | quirk 72 — known race document | quirk 73 — recreate-databases fixed | quirk 77 — known parallel-only flake document | other — escalate>
|
||||
Recommendation: <next step for user>
|
||||
```
|
||||
|
||||
## Constraints
|
||||
|
||||
- Falsify hypotheses с actual command runs, не speculate.
|
||||
- Capture raw output, не summaries.
|
||||
- Никогда "should pass" — только "passed with `<cmd>`" or "failed with `<cmd>` + `<output>`".
|
||||
- Каждое утверждение про код — с `file:line` pin'ом.
|
||||
- If unsure — escalate, do not guess.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Не fix code — only diagnose + recommend.
|
||||
- Не run full --parallel for >5 min без user OK (полный прогон ~55-128s OK).
|
||||
- Vitest (frontend) failures — separate concern.
|
||||
- a11y / Vuetify quirks — see separate quirks 70-71 in memory; not this agent.
|
||||
@@ -1,103 +0,0 @@
|
||||
---
|
||||
name: rls-reviewer
|
||||
description: |
|
||||
Review RLS (Row-Level Security) compliance on migration commits/PRs.
|
||||
Use when reviewing changes to db/schema.sql or db/migrations/ that add
|
||||
or modify tables. Specialized for Лидерра's 5-role architecture
|
||||
(crm_app_user, crm_app_admin, crm_supplier_worker BYPASSRLS,
|
||||
crm_readonly, crm_migrator). Reports orphan policies, missing tenant_id
|
||||
columns, inconsistent GRANTs, missing CHANGELOG entries.
|
||||
For manually checking a single named table before commit - use the /rls-check skill.
|
||||
tools: Read, Grep, Glob, Bash
|
||||
---
|
||||
|
||||
# RLS reviewer agent — Лидерра
|
||||
|
||||
You are reviewing a database migration or schema change for RLS (Row-Level Security) compliance in the Лидерра CRM project. Read-only review — DO NOT edit files.
|
||||
|
||||
## Контекст проекта
|
||||
|
||||
PostgreSQL 16 с 5 ролями (db/00_create_roles.sql + db/02_grants.sql):
|
||||
|
||||
1. `crm_app_user` — regular tenant user; RLS enforced via `current_setting('app.current_tenant_id')`.
|
||||
2. `crm_app_admin` — tenant admin; RLS enforced, broader policies.
|
||||
3. `crm_supplier_worker` — SaaS-level worker (BYPASSRLS) для supplier integration jobs.
|
||||
4. `crm_readonly` — read-only для reports; RLS enforced.
|
||||
5. `crm_migrator` — DDL role для Laravel migrations; RLS bypassed via session.
|
||||
|
||||
Каждая tenant-scoped таблица должна иметь:
|
||||
|
||||
- `tenant_id UUID NOT NULL REFERENCES tenants(id)` колонка.
|
||||
- `ALTER TABLE <name> ENABLE ROW LEVEL SECURITY;`.
|
||||
- Минимум 2 политики: SELECT (tenant scope `tenant_id = current_setting('app.current_tenant_id')::uuid`), ALL (admin scope).
|
||||
- GRANT'ы для 5 ролей в `db/02_grants.sql`.
|
||||
|
||||
SaaS-level таблицы (e.g., `supplier_csv_reconcile_log`, `system_settings`) exempt от tenant_id; должны иметь explicit `-- SaaS-level` comment.
|
||||
|
||||
Каждое schema change требует записи в `db/CHANGELOG_schema.md` (CLAUDE.md §5 п.8).
|
||||
|
||||
## Граница со скилом /rls-check
|
||||
|
||||
`rls-reviewer` (этот агент) и скил `/rls-check`
|
||||
(`.claude/skills/rls-check/SKILL.md`) оба проверяют RLS. Правило выбора:
|
||||
|
||||
- Есть diff / ветка / PR с изменениями БД, набор таблиц заранее не известен →
|
||||
**этот агент**.
|
||||
- Знаешь имя одной конкретной таблицы, проверка вручную перед коммитом →
|
||||
**скил `/rls-check <table>`**.
|
||||
|
||||
Этот агент прогоняет **7 статических пунктов** чеклиста. Живой дымовой тест
|
||||
(`pest --filter RlsSmokeTest`) намеренно **не входит** в агентский чеклист:
|
||||
запуск Pest в ревью-субагенте медленный и задевает гонки `--parallel`
|
||||
(квирки 72/77, см. `.claude/agents/pest-parallel-debugger.md`). Живой дымовой
|
||||
тест — 8-я строка скила `/rls-check`. 7 пунктов агента === первые 7 строк
|
||||
вывода скила (общее статическое ядро).
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Read target migration файл OR `db/schema.sql` diff (use `git diff HEAD~1 -- db/schema.sql` или указанные изменения).
|
||||
2. Для каждой added/modified таблицы — run 7-item checklist:
|
||||
- tenant_id column (или SaaS-level comment).
|
||||
- ENABLE RLS.
|
||||
- SELECT policy для crm_app_user.
|
||||
- ALL policy для crm_app_admin (или per-convention).
|
||||
- 5-role GRANTs в db/02_grants.sql.
|
||||
- db/CHANGELOG_schema.md entry.
|
||||
- squawk passes (`./bin/squawk.exe <file>`).
|
||||
3. Cross-check `db/02_grants.sql` для matching GRANTs.
|
||||
4. Cross-check `db/CHANGELOG_schema.md` для entry.
|
||||
5. Run `./bin/squawk.exe db/schema.sql 2>&1 | tail -10` и capture issues.
|
||||
6. Output structured report:
|
||||
|
||||
```text
|
||||
RLS Review — <table_name>
|
||||
[✅/❌] tenant_id column present
|
||||
[✅/❌] ENABLE ROW LEVEL SECURITY
|
||||
[✅/❌] SELECT policy for crm_app_user
|
||||
[✅/❌] ALL policy for crm_app_admin
|
||||
[✅/❌] 5-role GRANTs in db/02_grants.sql
|
||||
[✅/❌] db/CHANGELOG_schema.md entry
|
||||
[✅/❌] squawk passes (0 issues)
|
||||
Issues:
|
||||
- <file>:<line>:<col> <message>
|
||||
Pass: <N>/7
|
||||
```
|
||||
|
||||
## Constraints
|
||||
|
||||
- READ-ONLY — не edit files, только report.
|
||||
- Falsify с actual command runs, не speculate.
|
||||
- SaaS-level exemption — accept если explicit comment present; flag если comment отсутствует.
|
||||
- Partitioned tables (e.g., `lead_charges` partitioned by month) — verify policy применяется к parent + children.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- General SQL style (squawk handles).
|
||||
- Business logic review (other agents).
|
||||
- Performance review (separate concern).
|
||||
- Проверка одной названной таблицы вручную перед коммитом + живой дымовой
|
||||
тест — сценарий скила `/rls-check`, не агента.
|
||||
|
||||
## Verification protocol
|
||||
|
||||
Каждое утверждение про код — с `file:line` как pin'ом. "Looks correct" / "should pass" — запрещено. Только "passed with command X — output Y" or "failed with command X — output Y".
|
||||
@@ -37,35 +37,6 @@
|
||||
]
|
||||
},
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"C:/моя/проекты/портал crm/Документация/tools/ruflo-recall-hook.mjs\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"C:/моя/проекты/портал crm/Документация/tools/ruflo-queen-hook.mjs\""
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node -e \"const f=process.env.CLAUDE_FILE_PATH||''; const pd=process.env.CLAUDE_PROJECT_DIR||''; const path=require('path'); if (f && pd && path.resolve(f) === path.resolve(pd, 'CLAUDE.md')) { process.stderr.write('\\n[hook] WARNING: Direct edit of root CLAUDE.md detected. Per CLAUDE.md §5 п.10, prefer /claude-md-management:revise-claude-md or /claude-md-management:claude-md-improver. If invoked via that skill, this warning is informational.\\n'); }\""
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write",
|
||||
@@ -75,15 +46,6 @@
|
||||
"command": "node -e \"const f=process.env.CLAUDE_FILE_PATH||''; if(/\\\\.md$/i.test(f) && !/CLAUDE\\\\.md$/i.test(f)) { require('child_process').spawnSync('npx',['-y','markdownlint-cli2','--fix',f],{stdio:'inherit',shell:true}); }\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node -e \"const f=process.env.CLAUDE_FILE_PATH||''; const n=f.replace(/\\\\\\\\/g,'/'); if (/(^|\\\\/)db\\\\/schema\\\\.sql$/i.test(n)) { process.stdout.write('\\n[hook] REMINDER: You modified db/schema.sql. Per CLAUDE.md §5 п.8, add a corresponding entry to db/CHANGELOG_schema.md before committing.\\n'); }\""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
---
|
||||
name: q-item-add
|
||||
description: |
|
||||
Add a new open question (Q-item) to the registry docs/Открытые_вопросы_v8_3.md.
|
||||
Use ONLY when customer explicitly requests adding a new business/CTO/legal/design/devops/OPEN
|
||||
question to the registry. Walks through 6-step workflow: detect section, find next number,
|
||||
insert entry, update §0 counters, bump header/footer/changelog version, sync §0 row in CLAUDE.md.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# Q-item-add — добавить новый Q-item в реестр Открытых_вопросов
|
||||
|
||||
## Когда использовать
|
||||
|
||||
ТОЛЬКО при явном запросе заказчика добавить новый вопрос. Pravila §2.2 — закрытие/добавление вопроса требует явного указания заказчика.
|
||||
|
||||
Invoke via `/q-item-add <Биз|CTO|Ю|Диз|DO|OPEN> "<question text>"`.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Detect section.** Открыть `docs/Открытые_вопросы_v8_3.md`, найти секцию по prefix:
|
||||
- `Биз-*` → section `## 13` (Бизнес).
|
||||
- `CTO-*` → section `## 3` (CTO/инженерные).
|
||||
- `Ю-*` → section `## 4` (Юридические).
|
||||
- `Диз-*` → section `## 5` (Дизайн).
|
||||
- `DO-*` → section `## 6` (DevOps/инфраструктура).
|
||||
- `OPEN-*` → section `## 7` (Прочие открытые).
|
||||
|
||||
2. **Find next number.** Grep последний номер в секции (e.g., max `Биз-31` → new = `Биз-32`).
|
||||
|
||||
```bash
|
||||
grep -oP '<prefix>-\d+' docs/Открытые_вопросы_v8_3.md | sort -t- -k2 -n | tail -1
|
||||
```
|
||||
|
||||
3. **Insert entry.** Добавить строку формата:
|
||||
|
||||
```markdown
|
||||
**<prefix>-N ⏸** от 2026-MM-DD: <question text>
|
||||
```
|
||||
|
||||
4. **Update §0 «Сводка».** Increment счётчик ⏸ для соответствующего prefix. Шапка `## 0` содержит таблицу типа `Биз 24 ✅ / 7 ⏸` — bump до `8 ⏸`. **Также** «Итого X / Y ✅ / Z ⏸» — bump соответствующие.
|
||||
|
||||
5. **Bump versions.** Header (`v1.83 от 13.05.2026 (day +1)` → `v1.84 от 13.05.2026 (day +1)`), footer (last line same), добавить запись в `## 9. История версий`.
|
||||
|
||||
6. **Sync CLAUDE.md.** В `CLAUDE.md` §0 row «Открытые вопросы» bump `v1.83+` → `v1.84+`. Помним: CLAUDE.md правится ТОЛЬКО через `/claude-md-management:revise-claude-md` (§5 п.10) — финальный шаг делегируем заказчику или этому skill'у через sub-invocation.
|
||||
|
||||
## Validation
|
||||
|
||||
После save:
|
||||
|
||||
```bash
|
||||
./bin/lychee.exe --config .lychee.toml docs/Открытые_вопросы_v8_3.md 2>&1 | tail -3
|
||||
```
|
||||
|
||||
Expected: 0 broken links.
|
||||
|
||||
Counter arithmetic check: sum of ✅ + ⏸ + 🟦 per prefix = total per prefix.
|
||||
|
||||
## Не использовать когда
|
||||
|
||||
- Заказчик говорит «закрываем X» — это closure (replace ⏸ → ✅ + дата), не addition. Skip skill, do targeted Edit.
|
||||
- Item уже существует с тем же текстом — duplicate; уточнить у заказчика или обновить existing.
|
||||
- Заказчик не давал явного «добавь X в реестр» — Pravila §2.2 запрещает proactive добавление.
|
||||
@@ -1,75 +0,0 @@
|
||||
---
|
||||
name: regression
|
||||
description: |
|
||||
Run the project regression sweep and report a canonical status line + GREEN/RED/RED-INCOMPLETE verdict.
|
||||
Two tiers: `quick` (lint/format/type-check — seconds) and `full` (everything incl.
|
||||
Pest --parallel, Larastan, Vitest, Vite build, lychee, gitleaks — minutes).
|
||||
Claude auto-runs only `quick` (e.g. during verification-before-completion);
|
||||
`full` runs only on explicit `/regression full` or with user confirmation.
|
||||
---
|
||||
|
||||
# Regression — канонический регрессионный свод
|
||||
|
||||
## Когда использовать
|
||||
|
||||
Перед закрытием задачи/спринта (`full`) или для быстрого фидбэка по ходу работы
|
||||
(`quick`). Скилл инкапсулирует ~12 команд свода, разбросанных по `package.json`,
|
||||
`app/package.json`, `app/composer.json` и `lefthook.yml`, в один вызов с
|
||||
детерминированной канонической строкой и машинным вердиктом.
|
||||
|
||||
Invoke via `/regression [quick|full]` (без аргумента → `full`).
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Определить уровень из аргумента: `quick`, `full`, либо `full` по умолчанию.
|
||||
2. Запустить через Bash из корня репозитория:
|
||||
|
||||
```bash
|
||||
node .claude/skills/regression/run.mjs <tier>
|
||||
```
|
||||
|
||||
3. Показать пользователю полный вывод скрипта (таблица + каноническая строка +
|
||||
вердикт + вывод упавших проверок).
|
||||
4. Интерпретировать вердикт:
|
||||
- `GREEN` — свод чист, exit-код 0.
|
||||
- `RED` — перечислены упавшие проверки, exit-код 1; полный вывод каждой —
|
||||
после вердикта.
|
||||
- `RED-INCOMPLETE` — проверка не прогналась (нет бинаря), exit-код 1; свод
|
||||
неполон, зелёным признать нельзя. Если одновременно есть упавшие проверки,
|
||||
они тоже перечислены в строке вердикта.
|
||||
|
||||
## Уровни
|
||||
|
||||
- **`quick`** (6 проверок, секунды): Pint, ESLint, Prettier, vue-tsc,
|
||||
markdownlint, cspell.
|
||||
- **`full`** (12 проверок, минуты): всё из `quick` + Larastan, Pest `--parallel`,
|
||||
Vitest, Vite build, lychee, gitleaks.
|
||||
|
||||
## Правила инвокации (self-restraint)
|
||||
|
||||
- Claude **авто-запускает только `quick`** — в том числе в рамках
|
||||
`superpowers:verification-before-completion` перед claim «готово» / «passed» /
|
||||
«closed».
|
||||
- `full` Claude **сам не запускает** — только по явному `/regression full` от
|
||||
пользователя ИЛИ запросив подтверждение («запускаю полный свод, ~5–10 мин — ок?»).
|
||||
- Скилл **не правит `CLAUDE.md`** — он только печатает каноническую строку в
|
||||
stdout; вставка строки в `CLAUDE.md` — отдельно, через канал
|
||||
`claude-md-management` (`CLAUDE.md` §5 п.10).
|
||||
|
||||
## Caveats
|
||||
|
||||
- **Pest `--parallel` flake (квирки 72/73/77).** Если Pest показал 1–3 ошибки,
|
||||
похожие на Redis-race / cumulative-state / unique-key-collision, — перепрогнать
|
||||
`full` один раз ИЛИ свериться с агентом `pest-parallel-debugger` до объявления
|
||||
реального RED.
|
||||
- **ruflo daemon (квирк 93).** Перед baseline-критичным `full` рассмотреть
|
||||
`pm2 stop ruflo-daemon` — worker-jitter усиливает Pest-flake.
|
||||
- gitleaks и lychee: на Windows берутся из `bin\*.exe`, на Linux/Mac CI — из
|
||||
`PATH`. Отсутствие бинаря → `[⚠] SKIPPED` + вердикт `RED-INCOMPLETE`.
|
||||
|
||||
## Не использовать когда
|
||||
|
||||
- Нужна одна конкретная проверка — запусти её npm/composer-скрипт напрямую
|
||||
(быстрее, чем весь свод).
|
||||
- Pa11y и Semgrep SAST — это CI-tier, в свод намеренно не входят (см. дизайн-спек
|
||||
`docs/superpowers/specs/2026-05-16-regression-skill-design.md` §5).
|
||||
@@ -1,258 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// .claude/skills/regression/run.mjs
|
||||
// Regression sweep orchestrator for the /regression skill.
|
||||
// Design: docs/superpowers/specs/2026-05-16-regression-skill-design.md
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import process from 'node:process';
|
||||
|
||||
// ── pure: platform binary resolution ───────────────────────────────
|
||||
export function resolveBinary(name, platform = process.platform) {
|
||||
return platform === 'win32' ? `bin\\${name}.exe` : name;
|
||||
}
|
||||
|
||||
// ── pure: output header line ───────────────────────────────────────
|
||||
export function buildHeader(tier) {
|
||||
const head = `─ /regression ${tier} `;
|
||||
return head + '─'.repeat(Math.max(3, 48 - head.length));
|
||||
}
|
||||
|
||||
// ── pure: exit-code token ──────────────────────────────────────────
|
||||
export function parseExit(label, code) {
|
||||
return `${label} ${code}`;
|
||||
}
|
||||
|
||||
// ── pure: test-count parsers ───────────────────────────────────────
|
||||
export function parsePest(stdout) {
|
||||
// pest --parallel emits a single JSON line: {"tool":"pest","result":...,"tests":N,"passed":N,"skipped":N,...}
|
||||
const jsonMatch = stdout.match(/\{"tool"\s*:\s*"pest"[^}]+\}/);
|
||||
if (jsonMatch) {
|
||||
try {
|
||||
const j = JSON.parse(jsonMatch[0]);
|
||||
const passed = Number(j.passed ?? 0);
|
||||
const skipped = Number(j.skipped ?? 0);
|
||||
const total = Number(j.tests ?? passed + skipped);
|
||||
const failed = total - passed - skipped;
|
||||
return `Pest ${total}/${passed}/${skipped}sk/${Math.max(0, failed)}`;
|
||||
} catch { /* fall through to regex */ }
|
||||
}
|
||||
const passed = Number(stdout.match(/(\d+)\s+passed/)?.[1] ?? 0);
|
||||
const skipped = Number(stdout.match(/(\d+)\s+skipped/)?.[1] ?? 0);
|
||||
const failed = Number(stdout.match(/(\d+)\s+failed/)?.[1] ?? 0);
|
||||
return `Pest ${passed + skipped + failed}/${passed}/${skipped}sk/${failed}`;
|
||||
}
|
||||
|
||||
export function parseVitest(stdout) {
|
||||
const filesLine = stdout.match(/^.*Test Files.+$/m)?.[0] ?? '';
|
||||
const files = Number(filesLine.match(/(\d+)\s+passed/)?.[1] ?? 0);
|
||||
const line = stdout.match(/^\s*Tests\s+.+$/m)?.[0] ?? '';
|
||||
const passed = Number(line.match(/(\d+)\s+passed/)?.[1] ?? 0);
|
||||
const skipped = Number(line.match(/(\d+)\s+skipped/)?.[1] ?? 0);
|
||||
const failed = Number(line.match(/(\d+)\s+failed/)?.[1] ?? 0);
|
||||
return `Vitest ${files}f/${passed}/${skipped}sk/${failed}`;
|
||||
}
|
||||
|
||||
// ── pure: content parsers ──────────────────────────────────────────
|
||||
export function parseViteBuild(stdout) {
|
||||
const m = stdout.match(/built in ([\d.]+)\s*s/i);
|
||||
return `Vite build ${m ? m[1] : '?'}s`;
|
||||
}
|
||||
|
||||
export function parseLarastan(stdout) {
|
||||
const m = stdout.match(/Found (\d+) error/i);
|
||||
return `Larastan ${m ? m[1] : 0}`;
|
||||
}
|
||||
|
||||
export function parseGitleaks(stdout, code) {
|
||||
const commits = stdout.match(/(\d+)\s+commits?\s+scanned/i)?.[1] ?? '?';
|
||||
const leaks = code === 0
|
||||
? '0'
|
||||
: (stdout.match(/(\d+)\s+leaks?\s+found/i)?.[1]
|
||||
?? stdout.match(/leaks?\s+found:?\s*(\d+)/i)?.[1]
|
||||
?? '≥1');
|
||||
return `gitleaks ${leaks}/${commits}`;
|
||||
}
|
||||
|
||||
export function parseLychee(stdout) {
|
||||
const ok = stdout.match(/(\d+)\s+OK/)?.[1] ?? '?';
|
||||
const errors = stdout.match(/(\d+)\s+Errors?/i)?.[1] ?? '0';
|
||||
return `lychee ${ok}/${errors}`;
|
||||
}
|
||||
|
||||
// ── pure: verdict ──────────────────────────────────────────────────
|
||||
export function computeVerdict(results) {
|
||||
const skipped = results.filter((r) => r.skipped).map((r) => r.label);
|
||||
const failed = results
|
||||
.filter((r) => !r.skipped && r.code !== 0)
|
||||
.map((r) => r.label);
|
||||
if (skipped.length) return { verdict: 'RED-INCOMPLETE', exitCode: 1, failed, skipped };
|
||||
if (failed.length) return { verdict: 'RED', exitCode: 1, failed, skipped };
|
||||
return { verdict: 'GREEN', exitCode: 0, failed, skipped };
|
||||
}
|
||||
|
||||
// ── pure: output formatting ────────────────────────────────────────
|
||||
export function buildCanonicalLine(results) {
|
||||
return results.map((r) => r.token).join(' / ');
|
||||
}
|
||||
|
||||
export function formatRow(r) {
|
||||
const mark = r.skipped ? '⚠' : r.code === 0 ? '✅' : '❌';
|
||||
const label = r.label.padEnd(14);
|
||||
const status = r.skipped
|
||||
? 'SKIPPED — binary not found'
|
||||
: `${r.code} ${(r.ms / 1000).toFixed(1)}s`;
|
||||
return `[${mark}] ${label}${status}`;
|
||||
}
|
||||
|
||||
export function verdictLine(v, total) {
|
||||
if (v.verdict === 'GREEN') {
|
||||
return `🟢 GREEN — все ${total} проверок passed`;
|
||||
}
|
||||
if (v.verdict === 'RED-INCOMPLETE') {
|
||||
const tail = v.failed.length ? `; провал: ${v.failed.join(', ')}` : '';
|
||||
return `🟠 RED-INCOMPLETE — не прогналось: ${v.skipped.join(', ')}${tail}`;
|
||||
}
|
||||
return `🔴 RED — ${v.failed.length}/${total} failed: ${v.failed.join(', ')}`;
|
||||
}
|
||||
|
||||
// ── data: checks registry ──────────────────────────────────────────
|
||||
// Script-based checks carry `cmd`; binary-based checks carry `bin` + `argv`.
|
||||
// `parse(combinedOutput, exitCode)` → canonical token. `cwd`: '.' = repo root,
|
||||
// 'app' = the Laravel app. Execution order: quick checks first, then heavy.
|
||||
export const CHECKS = [
|
||||
{
|
||||
id: 'pint', label: 'Pint', tiers: ['quick', 'full'], cwd: 'app',
|
||||
cmd: 'composer pint:test', parse: (_o, c) => parseExit('Pint', c),
|
||||
},
|
||||
{
|
||||
id: 'eslint', label: 'ESLint', tiers: ['quick', 'full'], cwd: 'app',
|
||||
cmd: 'npm run lint:vue', parse: (_o, c) => parseExit('ESLint', c),
|
||||
},
|
||||
{
|
||||
id: 'prettier', label: 'Prettier', tiers: ['quick', 'full'], cwd: 'app',
|
||||
cmd: 'npm run format:check', parse: (_o, c) => parseExit('Prettier', c),
|
||||
},
|
||||
{
|
||||
id: 'vue-tsc', label: 'vue-tsc', tiers: ['quick', 'full'], cwd: 'app',
|
||||
cmd: 'npm run type-check', parse: (_o, c) => parseExit('vue-tsc', c),
|
||||
},
|
||||
{
|
||||
id: 'markdownlint', label: 'markdownlint', tiers: ['quick', 'full'], cwd: '.',
|
||||
cmd: 'npm run lint:md', parse: (_o, c) => parseExit('markdownlint', c),
|
||||
},
|
||||
{
|
||||
id: 'cspell', label: 'cspell', tiers: ['quick', 'full'], cwd: '.',
|
||||
cmd: 'npm run spell', parse: (_o, c) => parseExit('cspell', c),
|
||||
},
|
||||
{
|
||||
id: 'larastan', label: 'Larastan', tiers: ['full'], cwd: 'app',
|
||||
cmd: 'composer stan', parse: (o) => parseLarastan(o),
|
||||
},
|
||||
{
|
||||
id: 'pest', label: 'Pest', tiers: ['full'], cwd: 'app',
|
||||
cmd: 'composer test:parallel', parse: (o) => parsePest(o),
|
||||
},
|
||||
{
|
||||
id: 'vitest', label: 'Vitest', tiers: ['full'], cwd: 'app',
|
||||
cmd: 'npm run test:vue', parse: (o) => parseVitest(o),
|
||||
},
|
||||
{
|
||||
id: 'vite-build', label: 'Vite build', tiers: ['full'], cwd: 'app',
|
||||
cmd: 'npm run build', parse: (o) => parseViteBuild(o),
|
||||
},
|
||||
{
|
||||
id: 'lychee', label: 'lychee', tiers: ['full'], cwd: '.',
|
||||
bin: 'lychee',
|
||||
argv: ['--config', '.lychee.toml', 'docs/**/*.md', 'db/**/*.md', '*.md'],
|
||||
parse: (o) => parseLychee(o),
|
||||
},
|
||||
{
|
||||
id: 'gitleaks', label: 'gitleaks', tiers: ['full'], cwd: '.',
|
||||
bin: 'gitleaks',
|
||||
argv: ['detect', '--source', '.', '--no-banner', '--config', '.gitleaks.toml', '--redact'],
|
||||
parse: (o, c) => parseGitleaks(o, c),
|
||||
},
|
||||
];
|
||||
|
||||
// ── I/O: run one check ─────────────────────────────────────────────
|
||||
function runCheck(check, repoRoot) {
|
||||
const cwd = check.cwd === '.' ? repoRoot : path.join(repoRoot, check.cwd);
|
||||
const start = Date.now();
|
||||
const skippedResult = (reason) => ({
|
||||
id: check.id, label: check.label, skipped: true, code: null,
|
||||
ms: Date.now() - start, token: `${check.label} SKIPPED`, stdout: '', stderr: reason,
|
||||
});
|
||||
|
||||
let command;
|
||||
if (check.bin) {
|
||||
const bin = resolveBinary(check.bin);
|
||||
// bin/ executables: existsSync pre-check on Windows (the project ships
|
||||
// bin\gitleaks.exe / bin\lychee.exe; on POSIX they come from PATH).
|
||||
if (process.platform === 'win32' && !existsSync(path.join(repoRoot, bin))) {
|
||||
return skippedResult(`${bin} not found`);
|
||||
}
|
||||
command = [bin, ...check.argv].join(' ');
|
||||
} else {
|
||||
command = check.cmd;
|
||||
}
|
||||
|
||||
const res = spawnSync(command, {
|
||||
cwd, shell: true, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
const ms = Date.now() - start;
|
||||
// ENOENT (POSIX missing binary), POSIX shell exit 127 ("command not found"),
|
||||
// or the Windows cmd.exe "is not recognized" message → SKIPPED.
|
||||
const notFound = (res.error && res.error.code === 'ENOENT')
|
||||
|| res.status === 127
|
||||
|| /is not recognized as an internal or external command/i.test(res.stderr ?? '');
|
||||
if (notFound) {
|
||||
return skippedResult(`command not found: ${command}`);
|
||||
}
|
||||
const stdout = res.stdout ?? '';
|
||||
const stderr = res.stderr ?? '';
|
||||
const code = res.status ?? 1;
|
||||
const token = check.parse(`${stdout}\n${stderr}`, code);
|
||||
return { id: check.id, label: check.label, skipped: false, code, ms, token, stdout, stderr };
|
||||
}
|
||||
|
||||
// ── orchestrator ───────────────────────────────────────────────────
|
||||
export function main(argv) {
|
||||
const tier = argv[0] ?? 'full';
|
||||
if (tier !== 'quick' && tier !== 'full') {
|
||||
process.stderr.write(
|
||||
`regression: unknown argument "${tier}". Usage: run.mjs [quick|full]\n`,
|
||||
);
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url));
|
||||
const checks = CHECKS.filter((c) => c.tiers.includes(tier));
|
||||
|
||||
process.stdout.write(`${buildHeader(tier)}\n`);
|
||||
const results = [];
|
||||
for (const check of checks) {
|
||||
const r = runCheck(check, repoRoot);
|
||||
results.push(r);
|
||||
process.stdout.write(`${formatRow(r)}\n`);
|
||||
}
|
||||
process.stdout.write(`${'─'.repeat(48)}\n`);
|
||||
process.stdout.write(`Canonical: ${buildCanonicalLine(results)}\n`);
|
||||
|
||||
const v = computeVerdict(results);
|
||||
process.stdout.write(`VERDICT: ${verdictLine(v, results.length)}\n`);
|
||||
|
||||
// Full output of failed checks, so failures are visible with file:line.
|
||||
for (const r of results) {
|
||||
if (!r.skipped && r.code !== 0) {
|
||||
process.stdout.write(`\n── ${r.label} output ──\n${r.stdout}\n${r.stderr}\n`);
|
||||
}
|
||||
}
|
||||
process.exitCode = v.exitCode;
|
||||
}
|
||||
|
||||
// Run main only when executed directly (not when imported by run.test.mjs).
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
|
||||
main(process.argv.slice(2));
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import process from 'node:process';
|
||||
import {
|
||||
resolveBinary, buildHeader, parseExit,
|
||||
parsePest, parseVitest,
|
||||
parseViteBuild, parseLarastan, parseGitleaks, parseLychee,
|
||||
computeVerdict,
|
||||
buildCanonicalLine, formatRow, verdictLine,
|
||||
CHECKS,
|
||||
} from './run.mjs';
|
||||
|
||||
test('resolveBinary: win32 → bin\\<name>.exe', () => {
|
||||
assert.equal(resolveBinary('gitleaks', 'win32'), 'bin\\gitleaks.exe');
|
||||
});
|
||||
test('resolveBinary: non-win32 → bare name on PATH', () => {
|
||||
assert.equal(resolveBinary('lychee', 'linux'), 'lychee');
|
||||
assert.equal(resolveBinary('lychee', 'darwin'), 'lychee');
|
||||
});
|
||||
|
||||
test('buildHeader: starts with the tier banner', () => {
|
||||
assert.ok(buildHeader('quick').startsWith('─ /regression quick '));
|
||||
assert.ok(buildHeader('full').startsWith('─ /regression full '));
|
||||
});
|
||||
test('buildHeader: is padded with dashes', () => {
|
||||
assert.ok(buildHeader('full').length >= 30);
|
||||
});
|
||||
|
||||
test('parseExit: builds "<label> <code>" token', () => {
|
||||
assert.equal(parseExit('Pint', 0), 'Pint 0');
|
||||
assert.equal(parseExit('ESLint', 1), 'ESLint 1');
|
||||
});
|
||||
|
||||
test('parsePest: passed + skipped, no failures → total derived', () => {
|
||||
const out = ' Tests: 3 skipped, 739 passed (2104 assertions)\n Duration: 71.23s';
|
||||
assert.equal(parsePest(out), 'Pest 742/739/3sk/0');
|
||||
});
|
||||
test('parsePest: with failures', () => {
|
||||
const out = ' Tests: 2 failed, 1 skipped, 736 passed (2090 assertions)';
|
||||
assert.equal(parsePest(out), 'Pest 739/736/1sk/2');
|
||||
});
|
||||
test('parsePest: passed only → zeros for skipped/failed', () => {
|
||||
assert.equal(parsePest(' Tests: 19 passed (44 assertions)'), 'Pest 19/19/0sk/0');
|
||||
});
|
||||
test('parsePest: JSON format (pest --parallel) passed + skipped', () => {
|
||||
const out = '{"tool":"pest","result":"passed","tests":793,"passed":790,"assertions":2391,"duration_ms":32200,"skipped":3}';
|
||||
assert.equal(parsePest(out), 'Pest 793/790/3sk/0');
|
||||
});
|
||||
test('parsePest: JSON format with failures', () => {
|
||||
const out = '{"tool":"pest","result":"failed","tests":793,"passed":788,"assertions":2380,"duration_ms":31000,"skipped":3}';
|
||||
assert.equal(parsePest(out), 'Pest 793/788/3sk/2');
|
||||
});
|
||||
test('parsePest: JSON format no skipped', () => {
|
||||
const out = '{"tool":"pest","result":"passed","tests":19,"passed":19,"assertions":44,"duration_ms":1711}';
|
||||
assert.equal(parsePest(out), 'Pest 19/19/0sk/0');
|
||||
});
|
||||
|
||||
test('parseVitest: files + passed + skipped', () => {
|
||||
const out = ' Test Files 92 passed (92)\n Tests 774 passed | 3 skipped (777)\n Duration 12.6s';
|
||||
assert.equal(parseVitest(out), 'Vitest 92f/774/3sk/0');
|
||||
});
|
||||
test('parseVitest: with failures, does not confuse "Test Files" with "Tests"', () => {
|
||||
const out = ' Test Files 2 failed | 90 passed (92)\n Tests 5 failed | 769 passed (774)';
|
||||
assert.equal(parseVitest(out), 'Vitest 90f/769/0sk/5');
|
||||
});
|
||||
|
||||
test('parseViteBuild: extracts build time', () => {
|
||||
assert.equal(parseViteBuild('✓ 312 modules transformed.\n✓ built in 2.03s'), 'Vite build 2.03s');
|
||||
});
|
||||
test('parseViteBuild: no match → "?"', () => {
|
||||
assert.equal(parseViteBuild('build crashed'), 'Vite build ?s');
|
||||
});
|
||||
|
||||
test('parseLarastan: clean → 0', () => {
|
||||
assert.equal(parseLarastan(' [OK] No errors'), 'Larastan 0');
|
||||
});
|
||||
test('parseLarastan: counts errors', () => {
|
||||
assert.equal(parseLarastan(' [ERROR] Found 2 errors'), 'Larastan 2');
|
||||
});
|
||||
|
||||
test('parseGitleaks: clean → 0 leaks', () => {
|
||||
const out = 'INF 442 commits scanned.\nINF no leaks found';
|
||||
assert.equal(parseGitleaks(out, 0), 'gitleaks 0/442');
|
||||
});
|
||||
test('parseGitleaks: leaks found (non-zero exit)', () => {
|
||||
const out = 'INF 442 commits scanned.\nWRN 3 leaks found';
|
||||
assert.equal(parseGitleaks(out, 1), 'gitleaks 3/442');
|
||||
});
|
||||
|
||||
test('parseLychee: OK + errors', () => {
|
||||
const out = '🔍 325 Total (in 9s)\n✅ 325 OK\n🚫 0 Errors';
|
||||
assert.equal(parseLychee(out), 'lychee 325/0');
|
||||
});
|
||||
test('parseLychee: with broken links', () => {
|
||||
const out = '🔍 327 Total\n✅ 325 OK\n🚫 2 Errors';
|
||||
assert.equal(parseLychee(out), 'lychee 325/2');
|
||||
});
|
||||
|
||||
test('computeVerdict: all exit 0 → GREEN, exit code 0', () => {
|
||||
const v = computeVerdict([
|
||||
{ label: 'Pint', code: 0, skipped: false },
|
||||
{ label: 'ESLint', code: 0, skipped: false },
|
||||
]);
|
||||
assert.equal(v.verdict, 'GREEN');
|
||||
assert.equal(v.exitCode, 0);
|
||||
assert.deepEqual(v.failed, []);
|
||||
});
|
||||
test('computeVerdict: one non-zero exit → RED, exit code 1', () => {
|
||||
const v = computeVerdict([
|
||||
{ label: 'Pint', code: 0, skipped: false },
|
||||
{ label: 'Larastan', code: 1, skipped: false },
|
||||
]);
|
||||
assert.equal(v.verdict, 'RED');
|
||||
assert.equal(v.exitCode, 1);
|
||||
assert.deepEqual(v.failed, ['Larastan']);
|
||||
});
|
||||
test('computeVerdict: a skipped check → RED-INCOMPLETE', () => {
|
||||
const v = computeVerdict([
|
||||
{ label: 'Pint', code: 0, skipped: false },
|
||||
{ label: 'gitleaks', code: null, skipped: true },
|
||||
]);
|
||||
assert.equal(v.verdict, 'RED-INCOMPLETE');
|
||||
assert.equal(v.exitCode, 1);
|
||||
assert.deepEqual(v.skipped, ['gitleaks']);
|
||||
});
|
||||
test('computeVerdict: skipped takes precedence over a failure', () => {
|
||||
const v = computeVerdict([
|
||||
{ label: 'Larastan', code: 1, skipped: false },
|
||||
{ label: 'lychee', code: null, skipped: true },
|
||||
]);
|
||||
assert.equal(v.verdict, 'RED-INCOMPLETE');
|
||||
assert.deepEqual(v.failed, ['Larastan']);
|
||||
assert.deepEqual(v.skipped, ['lychee']);
|
||||
});
|
||||
|
||||
test('buildCanonicalLine: joins tokens in result order with " / "', () => {
|
||||
const results = [
|
||||
{ token: 'Pint 0' }, { token: 'ESLint 0' }, { token: 'Pest 742/739/3sk/0' },
|
||||
];
|
||||
assert.equal(buildCanonicalLine(results), 'Pint 0 / ESLint 0 / Pest 742/739/3sk/0');
|
||||
});
|
||||
|
||||
test('formatRow: passed check → ✅ mark, label, code, time', () => {
|
||||
const row = formatRow({ label: 'Pint', code: 0, ms: 1800, skipped: false });
|
||||
assert.ok(row.startsWith('[✅] Pint'));
|
||||
assert.ok(row.includes('1.8s'));
|
||||
});
|
||||
test('formatRow: failed check → ❌ mark', () => {
|
||||
assert.ok(formatRow({ label: 'Larastan', code: 1, ms: 8400, skipped: false }).startsWith('[❌] Larastan'));
|
||||
});
|
||||
test('formatRow: skipped check → ⚠ mark + SKIPPED', () => {
|
||||
const row = formatRow({ label: 'gitleaks', code: null, ms: 0, skipped: true });
|
||||
assert.ok(row.startsWith('[⚠] gitleaks'));
|
||||
assert.ok(row.includes('SKIPPED'));
|
||||
});
|
||||
|
||||
test('verdictLine: GREEN', () => {
|
||||
const line = verdictLine({ verdict: 'GREEN', failed: [], skipped: [] }, 12);
|
||||
assert.ok(line.includes('🟢 GREEN'));
|
||||
assert.ok(line.includes('12'));
|
||||
});
|
||||
test('verdictLine: RED lists failed checks', () => {
|
||||
const line = verdictLine({ verdict: 'RED', failed: ['Larastan'], skipped: [] }, 12);
|
||||
assert.ok(line.includes('🔴 RED'));
|
||||
assert.ok(line.includes('Larastan'));
|
||||
});
|
||||
test('verdictLine: RED-INCOMPLETE lists skipped checks', () => {
|
||||
const line = verdictLine({ verdict: 'RED-INCOMPLETE', failed: [], skipped: ['gitleaks'] }, 12);
|
||||
assert.ok(line.includes('🟠 RED-INCOMPLETE'));
|
||||
assert.ok(line.includes('gitleaks'));
|
||||
});
|
||||
|
||||
test('CHECKS: quick tier has exactly 6 checks', () => {
|
||||
assert.equal(CHECKS.filter((c) => c.tiers.includes('quick')).length, 6);
|
||||
});
|
||||
test('CHECKS: full tier has exactly 12 checks', () => {
|
||||
assert.equal(CHECKS.filter((c) => c.tiers.includes('full')).length, 12);
|
||||
});
|
||||
test('CHECKS: quick is a strict subset of full', () => {
|
||||
const full = new Set(CHECKS.filter((c) => c.tiers.includes('full')).map((c) => c.id));
|
||||
for (const c of CHECKS.filter((c) => c.tiers.includes('quick'))) {
|
||||
assert.ok(full.has(c.id), `${c.id} in quick must also be in full`);
|
||||
}
|
||||
});
|
||||
test('CHECKS: every check has id, label, cwd, parse, and a command source', () => {
|
||||
for (const c of CHECKS) {
|
||||
assert.ok(c.id && c.label && c.cwd, `${c.id}: id/label/cwd`);
|
||||
assert.equal(typeof c.parse, 'function', `${c.id}: parse is a function`);
|
||||
assert.ok(c.cmd || (c.bin && Array.isArray(c.argv)), `${c.id}: has cmd or bin+argv`);
|
||||
}
|
||||
});
|
||||
test('CHECKS: ids are unique', () => {
|
||||
assert.equal(new Set(CHECKS.map((c) => c.id)).size, CHECKS.length);
|
||||
});
|
||||
|
||||
const RUN = fileURLToPath(new URL('./run.mjs', import.meta.url));
|
||||
|
||||
test('main: unknown argument → exit code 2 + error on stderr', () => {
|
||||
try {
|
||||
execFileSync(process.execPath, [RUN, 'bogus'], { encoding: 'utf8', stdio: 'pipe' });
|
||||
assert.fail('expected non-zero exit');
|
||||
} catch (err) {
|
||||
assert.equal(err.status, 2);
|
||||
assert.match(String(err.stderr), /unknown argument/i);
|
||||
}
|
||||
});
|
||||
test('main: importing run.mjs does not auto-run the sweep', () => {
|
||||
// If the import.meta guard were broken, importing run.mjs at the top of this
|
||||
// file would have spawned a full sweep. Reaching this assertion proves it did not.
|
||||
assert.ok(true);
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
---
|
||||
name: rls-check
|
||||
description: |
|
||||
Verify Row-Level Security on a new or modified table in db/schema.sql.
|
||||
Use when adding a new table, adding/removing tenant_id column, or modifying
|
||||
RLS policies. Walks through 7-step checklist (tenant_id, ENABLE RLS, 2+ policies,
|
||||
5-role GRANTs, db/CHANGELOG_schema.md entry, squawk, smoke test).
|
||||
For reviewing a diff, branch, or PR with DB changes - use the rls-reviewer agent.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# RLS-check — verify RLS на таблице
|
||||
|
||||
## Когда использовать
|
||||
|
||||
При добавлении или модификации таблицы в `db/schema.sql`, особенно перед коммитом. Инкапсулирует 7-item checklist; lefthook pre-commit job 7 (squawk) ловит только часть.
|
||||
|
||||
Invoke via `/rls-check <table_name>`.
|
||||
|
||||
## Граница с агентом rls-reviewer
|
||||
|
||||
`rls-check` (этот скил) и `rls-reviewer` (агент, `.claude/agents/rls-reviewer.md`)
|
||||
оба проверяют RLS, но в разных ситуациях. Правило выбора:
|
||||
|
||||
- Знаешь имя одной конкретной таблицы, проверка вручную перед коммитом →
|
||||
**`/rls-check <table>`** (этот скил).
|
||||
- Есть diff / ветка / PR с изменениями БД, набор таблиц заранее не известен →
|
||||
**агент `rls-reviewer`**.
|
||||
|
||||
Скил работает в основном контексте по одной названной таблице и прогоняет
|
||||
**8 строк вывода** — 7 статических пунктов + живой дымовой тест
|
||||
(`pest --filter RlsSmokeTest`, шаг 7). Агент работает в отдельном контексте
|
||||
субагента, разбирает diff/миграцию/PR и прогоняет только **7 статических**
|
||||
строк — дымовой тест намеренно не запускает.
|
||||
|
||||
Первые 7 строк вывода у обоих — общее статическое ядро (tenant_id, ENABLE RLS,
|
||||
SELECT/ALL политики, GRANT'ы 5 ролей, CHANGELOG, squawk). Это не дублирование:
|
||||
ядро проверок одно, сценарии вызова разные. Дымовой тест — только в скиле:
|
||||
запуск Pest в ревью-субагенте медленный и задевает гонки `--parallel`
|
||||
(квирки 72/77, см. `.claude/agents/pest-parallel-debugger.md`).
|
||||
|
||||
## Checklist
|
||||
|
||||
1. **tenant_id column.** Grep `db/schema.sql` для `CREATE TABLE <name>`. Verify:
|
||||
- `tenant_id UUID NOT NULL REFERENCES tenants(id)` присутствует, **OR**
|
||||
- SaaS-level exemption — explicit comment типа `-- SaaS-level: no tenant_id (justification)`.
|
||||
|
||||
```bash
|
||||
grep -A30 "CREATE TABLE.*\b<name>\b" db/schema.sql | grep -E "tenant_id|SaaS-level"
|
||||
```
|
||||
|
||||
2. **ENABLE RLS.** Должна быть строка `ALTER TABLE <name> ENABLE ROW LEVEL SECURITY;`.
|
||||
|
||||
```bash
|
||||
grep -E "ALTER TABLE\s+<name>\s+ENABLE ROW LEVEL SECURITY" db/schema.sql
|
||||
```
|
||||
|
||||
3. **Policies — минимум 2.**
|
||||
- SELECT для `crm_app_user`/`crm_app_admin` с tenant scope: `USING (tenant_id = current_setting('app.current_tenant_id')::uuid)`.
|
||||
- ALL для `crm_app_admin` (или per-table convention).
|
||||
- SaaS-level: BYPASSRLS role pattern (e.g., `crm_supplier_worker`).
|
||||
|
||||
```bash
|
||||
grep -B1 -A5 "ON <name>" db/schema.sql | grep "POLICY"
|
||||
```
|
||||
|
||||
4. **Role GRANTs.** В `db/02_grants.sql` должны быть GRANT'ы для 5 ролей. Проверить по pattern existing tables.
|
||||
|
||||
```bash
|
||||
grep -E "GRANT.*ON\s+<name>" db/02_grants.sql
|
||||
```
|
||||
|
||||
Expected: ≥5 GRANT statements (по одному на роль) или group GRANT.
|
||||
|
||||
5. **CHANGELOG entry.** В `db/CHANGELOG_schema.md` должна быть запись с датой + table name + summary (CLAUDE.md §5 п.8).
|
||||
|
||||
```bash
|
||||
grep "<name>" db/CHANGELOG_schema.md
|
||||
```
|
||||
|
||||
6. **squawk lint.**
|
||||
|
||||
```bash
|
||||
./bin/squawk.exe db/schema.sql 2>&1 | tail -10
|
||||
```
|
||||
|
||||
Expected: exit 0, no issues.
|
||||
|
||||
7. **Smoke test.** `tests/Feature/RlsSmokeTest.php` (или новый тест для конкретной таблицы) должен assert'ить, что user в tenant A не видит row из tenant B для новой таблицы.
|
||||
|
||||
```bash
|
||||
cd app && ./vendor/bin/pest --filter RlsSmokeTest 2>&1 | tail -10
|
||||
```
|
||||
|
||||
Expected: all assertions pass.
|
||||
|
||||
## Output
|
||||
|
||||
Print результат per item + total:
|
||||
|
||||
```text
|
||||
RLS-check: <table_name>
|
||||
[✅] tenant_id column
|
||||
[✅] ENABLE RLS
|
||||
[✅] SELECT policy
|
||||
[✅] ALL policy
|
||||
[✅] 5-role GRANTs
|
||||
[✅] CHANGELOG entry
|
||||
[✅] squawk passes
|
||||
[✅] smoke test passes
|
||||
Pass: 8/8
|
||||
```
|
||||
|
||||
Or failure listing: `[❌] tenant_id column missing — db/schema.sql:NNNN`.
|
||||
|
||||
## Не использовать когда
|
||||
|
||||
- Modifying existing well-RLS'd table без новых columns — overhead.
|
||||
- Tables explicitly outside RLS (e.g., Laravel `migrations`, `cache` — internal).
|
||||
- Проверяешь не одну названную таблицу, а diff/ветку/PR с изменениями БД —
|
||||
это сценарий агента `rls-reviewer`, не скила.
|
||||
@@ -1,85 +0,0 @@
|
||||
name: Accessibility (Pa11y live)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
a11y:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP 8.3
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
extensions: pdo, pdo_pgsql, redis, mbstring, intl, bcmath
|
||||
coverage: none
|
||||
|
||||
- name: Setup Node 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install root JS deps
|
||||
run: npm ci --no-audit --no-fund
|
||||
|
||||
- name: Install app composer deps
|
||||
working-directory: app
|
||||
run: composer install --no-progress --no-interaction --prefer-dist --optimize-autoloader
|
||||
|
||||
- name: Install app JS deps
|
||||
working-directory: app
|
||||
run: npm ci --no-audit --no-fund
|
||||
|
||||
- name: Bootstrap .env + key
|
||||
working-directory: app
|
||||
run: |
|
||||
cp .env.example .env
|
||||
php artisan key:generate --force
|
||||
|
||||
- name: Prepare SQLite for CI (avoid pg-on-CI fixture cost)
|
||||
working-directory: app
|
||||
run: |
|
||||
touch database/database.sqlite
|
||||
sed -i 's/DB_CONNECTION=.*/DB_CONNECTION=sqlite/' .env
|
||||
sed -i 's|DB_DATABASE=.*|DB_DATABASE=/home/runner/work/${{ github.event.repository.name }}/${{ github.event.repository.name }}/app/database/database.sqlite|' .env
|
||||
|
||||
- name: Build frontend assets
|
||||
working-directory: app
|
||||
run: npm run build
|
||||
|
||||
- name: Start Laravel dev-server
|
||||
working-directory: app
|
||||
run: nohup php artisan serve --host=127.0.0.1 --port=8000 > /tmp/laravel-serve.log 2>&1 &
|
||||
|
||||
- name: Wait for dev-server ready
|
||||
run: |
|
||||
for i in {1..30}; do
|
||||
if curl -s -o /dev/null http://127.0.0.1:8000/login; then
|
||||
echo "Dev-server up after ${i}s"
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "Dev-server did not start within 30s"
|
||||
tail -50 /tmp/laravel-serve.log
|
||||
exit 1
|
||||
|
||||
- name: Run Pa11y (live Vue)
|
||||
run: npm run a11y
|
||||
|
||||
- name: Upload Pa11y screenshots
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: a11y-screenshots
|
||||
path: bin/a11y-screenshots/
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
-48
@@ -139,51 +139,3 @@ 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/
|
||||
|
||||
# ── Ruflo big-bang integration (2026-05-15) ──────────────────────────────────
|
||||
# ruflo runtime scaffolding и local-only routing config
|
||||
.claude-flow/
|
||||
CLAUDE.local.md
|
||||
# ruflo runtime state (created on activation 2026-05-15: memory DB + RuVector bridge)
|
||||
.swarm/
|
||||
ruvector.db
|
||||
# CLAUDE.md / .claude/ backups перед npx ruflo init --force (плановые artifacts Task 2.1)
|
||||
CLAUDE.md.pre-ruflo.bak
|
||||
.claude.pre-ruflo.bak/
|
||||
# ruflo install/dry-run logs (transient)
|
||||
ruflo-init.log
|
||||
ruflo-init-dryrun.log
|
||||
ruflo-mcp-stdout.log
|
||||
ruflo-mcp-stderr.log
|
||||
# ruflo init --force regen'ит 23 subdirs из upstream IPFS-registry — auto-regenerable, не tracking
|
||||
.claude/agents/analysis/
|
||||
.claude/agents/architecture/
|
||||
.claude/agents/browser/
|
||||
.claude/agents/consensus/
|
||||
.claude/agents/core/
|
||||
.claude/agents/custom/
|
||||
.claude/agents/data/
|
||||
.claude/agents/development/
|
||||
.claude/agents/devops/
|
||||
.claude/agents/documentation/
|
||||
.claude/agents/flow-nexus/
|
||||
.claude/agents/github/
|
||||
.claude/agents/goal/
|
||||
.claude/agents/optimization/
|
||||
.claude/agents/payments/
|
||||
.claude/agents/sona/
|
||||
.claude/agents/sparc/
|
||||
.claude/agents/specialized/
|
||||
.claude/agents/sublinear/
|
||||
.claude/agents/swarm/
|
||||
.claude/agents/templates/
|
||||
.claude/agents/testing/
|
||||
.claude/agents/v3/
|
||||
.claude/commands/
|
||||
.claude/helpers/
|
||||
|
||||
@@ -87,12 +87,6 @@ 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)
|
||||
|
||||
@@ -23,25 +23,6 @@
|
||||
"command": "npx",
|
||||
"args": ["-y", "semgrep-mcp"],
|
||||
"comment": "Фаза 3 #25 — Semgrep MCP (SAST). Семантический поиск/анализ кода через Semgrep rules в Claude Code. Пакет: npmjs.com/package/semgrep-mcp — если 404, запустить 'npm search semgrep mcp' для актуального имени."
|
||||
},
|
||||
"sentry": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@sentry/mcp-server"],
|
||||
"env": {
|
||||
"SENTRY_URL": "${SENTRY_URL}",
|
||||
"SENTRY_AUTH_TOKEN": "${SENTRY_AUTH_TOKEN}"
|
||||
},
|
||||
"comment": "Off-phase tool — Sentry MCP для self-hosted экземпляра в Yandex Cloud (CLAUDE.md §2). Pending формализация в Tooling §3.3 #34 — sync нормативки отдельным планом. Package: @sentry/mcp-server@0.33.0+ (official sentry-bot, repo getsentry/sentry-mcp, bin sentry-mcp). Env vars: SENTRY_URL (https://sentry.<your-domain>.ru), SENTRY_AUTH_TOKEN (PAT scope: sentry:read). Credentials в .env.local (gitignored), Claude Code считывает env из shell startup. Если env пустые — MCP server fail gracefully."
|
||||
},
|
||||
"redis": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-redis", "redis://localhost:6379"],
|
||||
"comment": "Off-phase tool — Redis MCP для Memurai (Windows service, Redis 7-совместимый, localhost:6379). Pending формализация в Tooling §3.3 #35 — sync нормативки отдельным планом. Package: @modelcontextprotocol/server-redis@2025.4.25 — DEPRECATED по статусу npm («Package no longer supported»), но Anthropic source, простой протокол, рабочий. Post-MVP migration на community alternative (e.g., @easy-mcps/redis-mcp-server@1.0.8 или @wenit/redis-mcp-server@1.0.3) когда подтвердим trust. READ-ONLY use — отладка очередей, кэша, Pest --parallel race (memory quirk 72). НЕ для prod (нет prod). Если в будущем prod Redis с auth — отдельный entry redis-prod с url через env var."
|
||||
},
|
||||
"ruflo": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "ruflo@latest", "mcp", "start"],
|
||||
"comment": "Off-phase orchestration MCP — exposes ~210 ruflo tools (Core/Intelligence/Agents/Memory/DevTools). Package: ruflo v3.7.0-alpha.38+ MIT (npm `ruflo`, repo ruvnet/claude-flow legacy after rename Jan-2026; plugin namespace @claude-flow/*). Plugin discovery via IPFS (CID QmeXmAdbWVvT84GfDXPD2Vg1HWhiTW2VdZfRLhkS96KkX2) — Pinata+Cloudflare gateways flaky 2026-05-15, only ipfs.io reliable. stdio mode (no port-conflict). Big-bang integration per spec/plan 2026-05-15-ruflo-integration-design.md (commit a68a0a0+). Pending формализация в Tooling §4.10 — Phase 3 Task 3.4."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-334
@@ -1,334 +0,0 @@
|
||||
# Лидерра CRM — Production Deployment Runbook
|
||||
|
||||
**Version:** 1.0 от 2026-05-13
|
||||
**Stack:** Laravel 13 · Vue 3 + Vuetify 3 · PostgreSQL 16 · Redis 7 · PHP 8.3
|
||||
**Cloud:** Yandex Cloud, region `ru-central1`
|
||||
|
||||
---
|
||||
|
||||
## 1. System Requirements
|
||||
|
||||
| Component | Version | Notes |
|
||||
|---|---|---|
|
||||
| PHP | 8.3+ | Extensions: pdo_pgsql, pgsql, redis, bcmath, mbstring, openssl, tokenizer, xml, ctype, fileinfo, pcntl |
|
||||
| PostgreSQL | 16 | ICU collation support required (`--with-icu` compile flag) |
|
||||
| Redis | 7.x | Sessions, queues, cache |
|
||||
| Node.js | 20+ | Frontend build only |
|
||||
| Composer | 2.x | |
|
||||
| Supervisor | 4.x | Queue worker process management |
|
||||
|
||||
---
|
||||
|
||||
## 2. Environment Configuration
|
||||
|
||||
Copy `.env.example` to `.env` and set all required values:
|
||||
|
||||
```bash
|
||||
cp app/.env.example app/.env
|
||||
```
|
||||
|
||||
Critical variables:
|
||||
|
||||
```ini
|
||||
APP_ENV=production
|
||||
APP_KEY= # php artisan key:generate
|
||||
APP_URL=https://crm.example.com
|
||||
|
||||
DB_CONNECTION=pgsql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=5432
|
||||
DB_DATABASE=liderra
|
||||
DB_USERNAME=crm_migrator # migration role — full DDL rights
|
||||
DB_PASSWORD=<secret>
|
||||
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=<secret>
|
||||
|
||||
QUEUE_CONNECTION=redis
|
||||
SESSION_DRIVER=redis
|
||||
CACHE_STORE=redis
|
||||
|
||||
MAIL_MAILER=smtp
|
||||
MAIL_HOST=smtp.unisender.com
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=<unisender-go-api-key>
|
||||
MAIL_ENCRYPTION=tls
|
||||
MAIL_FROM_ADDRESS=noreply@liderra.ru
|
||||
MAIL_FROM_NAME="Лидерра"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Database Setup
|
||||
|
||||
### 3.1 Create database with ICU collation
|
||||
|
||||
```sql
|
||||
-- Run as PostgreSQL superuser
|
||||
CREATE DATABASE liderra
|
||||
ENCODING 'UTF8'
|
||||
LOCALE_PROVIDER 'icu'
|
||||
ICU_LOCALE 'ru-RU'
|
||||
TEMPLATE template0;
|
||||
```
|
||||
|
||||
### 3.2 Create application roles
|
||||
|
||||
```bash
|
||||
# Run as PostgreSQL superuser
|
||||
psql -U postgres liderra < db/00_create_roles.sql
|
||||
```
|
||||
|
||||
The script creates 5 roles: `crm_app_user`, `crm_app_admin`, `crm_readonly`, `crm_migrator`, `crm_supplier_worker` (BYPASSRLS).
|
||||
|
||||
### 3.3 Run migrations
|
||||
|
||||
```bash
|
||||
cd app
|
||||
php artisan migrate --force
|
||||
```
|
||||
|
||||
This loads `db/schema.sql` (v8.19+) via the single bootstrap migration `load_initial_schema.php`.
|
||||
|
||||
### 3.4 Apply grants
|
||||
|
||||
```bash
|
||||
psql -U postgres liderra < db/02_grants.sql
|
||||
```
|
||||
|
||||
### 3.5 Create initial partition tables
|
||||
|
||||
Partitioned tables (`lead_audit_log`, `supplier_session_log`, etc.) require month-based child partitions to exist before any inserts:
|
||||
|
||||
```bash
|
||||
cd app
|
||||
php artisan partitions:create-months
|
||||
```
|
||||
|
||||
Run this once after migration. The scheduler maintains partitions automatically thereafter (see §7).
|
||||
|
||||
### 3.6 Apply pg_audit extension (pre-prod)
|
||||
|
||||
```sql
|
||||
-- Run as PostgreSQL superuser
|
||||
CREATE EXTENSION IF NOT EXISTS pgaudit;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Application Bootstrap
|
||||
|
||||
```bash
|
||||
cd app
|
||||
|
||||
# Install PHP dependencies
|
||||
composer install --no-dev --optimize-autoloader
|
||||
|
||||
# Generate app key (first deploy only)
|
||||
php artisan key:generate --force
|
||||
|
||||
# Clear and cache config/routes/views
|
||||
php artisan config:cache
|
||||
php artisan route:cache
|
||||
php artisan view:cache
|
||||
|
||||
# Run storage symlink
|
||||
php artisan storage:link
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Frontend Build
|
||||
|
||||
```bash
|
||||
cd app
|
||||
npm ci
|
||||
npm run build
|
||||
```
|
||||
|
||||
Output goes to `app/public/build/`. Confirm `app/public/build/manifest.json` exists.
|
||||
|
||||
---
|
||||
|
||||
## 6. Queue Worker (Supervisor)
|
||||
|
||||
Create `/etc/supervisor/conf.d/liderra-worker.conf`:
|
||||
|
||||
```ini
|
||||
[program:liderra-worker]
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
command=php /path/to/app/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stopasgroup=true
|
||||
killasgroup=true
|
||||
user=www-data
|
||||
numprocs=2
|
||||
redirect_stderr=true
|
||||
stdout_logfile=/var/log/liderra-worker.log
|
||||
stopwaitsecs=3600
|
||||
```
|
||||
|
||||
```bash
|
||||
supervisorctl reread
|
||||
supervisorctl update
|
||||
supervisorctl start liderra-worker:*
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Scheduler (Cron)
|
||||
|
||||
Add to the deployment user's crontab (`crontab -e`):
|
||||
|
||||
```cron
|
||||
* * * * * cd /path/to/app && php artisan schedule:run >> /dev/null 2>&1
|
||||
```
|
||||
|
||||
The scheduler runs these jobs automatically:
|
||||
|
||||
| Command/Job | Schedule | Purpose |
|
||||
|---|---|---|
|
||||
| `projects:reset-delivered-today` | daily 00:00 MSK | Reset daily lead counter |
|
||||
| `projects:reset-monthly` | 1st of month 00:00 MSK | Reset monthly counter for tier lookup |
|
||||
| `partitions:create-months` | daily | Create PostgreSQL partition tables for upcoming months |
|
||||
| `RefreshSupplierSessionJob` | hourly + 20:15 MSK | Supplier API session tokens |
|
||||
| `SyncSupplierProjectsJob` | 20:30 MSK daily | Sync supplier project list |
|
||||
| `CleanupInactiveSupplierProjectsJob` | 02:00 MSK daily | Archive stale supplier projects |
|
||||
| `supplier:retry-failed` | hourly | Retry failed supplier lead deliveries |
|
||||
| `CsvReconcileJob` | hourly | CSV reconciliation (reserve lead intake channel) |
|
||||
|
||||
---
|
||||
|
||||
## 8. Web Server (Nginx)
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name crm.example.com;
|
||||
|
||||
root /path/to/app/public;
|
||||
index index.php;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
|
||||
fastcgi_index index.php;
|
||||
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
||||
include fastcgi_params;
|
||||
}
|
||||
|
||||
location ~* \.(js|css|png|jpg|svg|woff2)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Health Checks
|
||||
|
||||
```bash
|
||||
# PHP and Laravel bootstrap
|
||||
cd app && php artisan about
|
||||
|
||||
# Database connection
|
||||
php artisan db:show
|
||||
|
||||
# Scheduler registered entries
|
||||
php artisan schedule:list
|
||||
|
||||
# Queue worker status
|
||||
supervisorctl status liderra-worker:*
|
||||
|
||||
# Redis connection
|
||||
redis-cli -h 127.0.0.1 ping
|
||||
|
||||
# Application HTTP
|
||||
curl -I https://crm.example.com/login
|
||||
```
|
||||
|
||||
Expected responses:
|
||||
|
||||
- `php artisan about` — no errors, APP_ENV=production
|
||||
- `php artisan schedule:list` — 9 entries including `partitions:create-months`
|
||||
- Redis: `PONG`
|
||||
- HTTP `/login`: 200
|
||||
|
||||
---
|
||||
|
||||
## 10. Deployment Sequence (Rolling Update)
|
||||
|
||||
```bash
|
||||
# 1. Pull latest code
|
||||
git pull origin main
|
||||
|
||||
# 2. Install/update dependencies
|
||||
cd app && composer install --no-dev --optimize-autoloader
|
||||
npm ci && npm run build
|
||||
|
||||
# 3. Run new migrations (if any)
|
||||
php artisan migrate --force
|
||||
|
||||
# 4. Recache configuration
|
||||
php artisan config:cache
|
||||
php artisan route:cache
|
||||
php artisan view:cache
|
||||
|
||||
# 5. Restart queue workers (pick up new code)
|
||||
supervisorctl restart liderra-worker:*
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Rollback
|
||||
|
||||
```bash
|
||||
# Revert to previous release tag
|
||||
git checkout <previous-tag>
|
||||
cd app
|
||||
composer install --no-dev --optimize-autoloader
|
||||
npm ci && npm run build
|
||||
php artisan migrate:rollback # only if the migration is reversible
|
||||
php artisan config:cache
|
||||
supervisorctl restart liderra-worker:*
|
||||
```
|
||||
|
||||
> **Warning:** Schema migrations are not always reversible. Always take a PostgreSQL dump before deploying schema changes.
|
||||
|
||||
```bash
|
||||
pg_dump -U crm_migrator liderra > backup_$(date +%Y%m%d_%H%M%S).sql
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Development Seed
|
||||
|
||||
For staging/dev environments only:
|
||||
|
||||
```bash
|
||||
cd app
|
||||
php artisan db:seed --class=DemoSeeder --force
|
||||
```
|
||||
|
||||
Creates: `admin@demo.local` / `password`, 3 projects, 14 demo deals.
|
||||
|
||||
**Never run DemoSeeder on production.**
|
||||
|
||||
---
|
||||
|
||||
## 13. Common Issues
|
||||
|
||||
| Symptom | Likely Cause | Fix |
|
||||
|---|---|---|
|
||||
| `SQLSTATE[08006]` on boot | Wrong `DB_HOST` (use `127.0.0.1`, not `localhost` on Windows) | Set `DB_HOST=127.0.0.1` |
|
||||
| Partition insert error on new month | `partitions:create-months` not run | `php artisan partitions:create-months` |
|
||||
| Queue jobs not processing | Supervisor not running or wrong user | `supervisorctl status`; check `stdout_logfile` |
|
||||
| CSS/JS 404 after deploy | Frontend not rebuilt or `storage:link` missing | `npm run build` + `php artisan storage:link` |
|
||||
| `jwt expired` from supplier API | Supplier session not refreshed | `php artisan tinker` → `dispatch(new RefreshSupplierSessionJob)` |
|
||||
| Scheduler not running | Cron not set up | Verify crontab entry; `php artisan schedule:run --verbose` |
|
||||
+1
-1
@@ -50,7 +50,7 @@ SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=redis
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
CACHE_STORE=database
|
||||
# CACHE_PREFIX=
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
*.log
|
||||
.backups/
|
||||
.DS_Store
|
||||
.env
|
||||
.env.backup
|
||||
|
||||
@@ -56,29 +56,3 @@ If you discover a security vulnerability within Laravel, please send an e-mail t
|
||||
## License
|
||||
|
||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||
|
||||
## Демо-данные (dev)
|
||||
|
||||
Демо-tenant создаётся `DemoSeeder` автоматически при `composer setup` /
|
||||
`php artisan migrate --seed` в окружениях `local` и `testing`
|
||||
(см. `DatabaseSeeder` — в `production` DemoSeeder не запускается).
|
||||
|
||||
**Учётные данные демо-входа:**
|
||||
|
||||
- URL: `/login`
|
||||
- Email: `admin@demo.local`
|
||||
- Пароль: `password`
|
||||
|
||||
Что создаётся: demo-tenant (`subdomain=demo`, баланс 1000 ₽ / 100 лидов),
|
||||
admin-пользователь, 3 проекта (сайт/звонок/СМС) и ~14 демо-сделок.
|
||||
|
||||
**Пере-сидировать демо-данные** (идемпотентно — повторный запуск не создаёт дублей):
|
||||
|
||||
```bash
|
||||
composer demo:seed
|
||||
```
|
||||
|
||||
Эквивалент: `php artisan db:seed --class=DemoSeeder --force`.
|
||||
|
||||
Если при логине демо-аккаунта возвращается 422 — демо-данные не засеяны
|
||||
на текущей dev-БД (например, после `migrate:fresh`); запустите `composer demo:seed`.
|
||||
|
||||
@@ -4,9 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\MonthlyPartitionManager;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Создаёт ежемесячные партиции для `deals` и `supplier_lead_costs`
|
||||
@@ -30,7 +30,14 @@ class PartitionsCreateMonths extends Command
|
||||
/** @var string */
|
||||
protected $description = 'Создаёт ежемесячные партиции deals и supplier_lead_costs на N месяцев вперёд (idempotent)';
|
||||
|
||||
public function handle(MonthlyPartitionManager $manager): int
|
||||
/**
|
||||
* Список таблиц, которые партиционируются по received_at помесячно.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const PARTITIONED_TABLES = ['deals', 'supplier_lead_costs'];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$ahead = max(1, (int) $this->option('ahead'));
|
||||
$now = Carbon::now()->startOfMonth();
|
||||
@@ -40,17 +47,27 @@ class PartitionsCreateMonths extends Command
|
||||
|
||||
for ($i = 0; $i <= $ahead; $i++) {
|
||||
$monthStart = $now->copy()->addMonths($i);
|
||||
$monthEnd = $monthStart->copy()->addMonth();
|
||||
|
||||
foreach (MonthlyPartitionManager::PARTITIONED_TABLES as $table) {
|
||||
foreach (self::PARTITIONED_TABLES as $table) {
|
||||
$partitionName = sprintf('%s_%s', $table, $monthStart->format('Y_m'));
|
||||
|
||||
if ($manager->ensureMonth($table, $monthStart)) {
|
||||
$created++;
|
||||
$this->info(" create <fg=green>{$partitionName}</>");
|
||||
} else {
|
||||
if ($this->partitionExists($partitionName)) {
|
||||
$skipped++;
|
||||
$this->line(" skip <fg=gray>{$partitionName}</> (already exists)");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::statement(sprintf(
|
||||
"CREATE TABLE %s PARTITION OF %s FOR VALUES FROM ('%s') TO ('%s')",
|
||||
$partitionName,
|
||||
$table,
|
||||
$monthStart->format('Y-m-d'),
|
||||
$monthEnd->format('Y-m-d'),
|
||||
));
|
||||
$created++;
|
||||
$this->info(" create <fg=green>{$partitionName}</> [{$monthStart->format('Y-m-d')} → {$monthEnd->format('Y-m-d')})");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,4 +76,17 @@ class PartitionsCreateMonths extends Command
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка существования партиции через pg_class (быстрее information_schema).
|
||||
*/
|
||||
private function partitionExists(string $name): bool
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
"SELECT 1 AS exists FROM pg_class WHERE relname = ? AND relkind = 'r'",
|
||||
[$name],
|
||||
);
|
||||
|
||||
return $row !== null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Concerns\ResolvesAdminUserId;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\SaasAdminAuditLog;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -22,183 +19,6 @@ use Illuminate\Support\Facades\DB;
|
||||
*/
|
||||
class AdminBillingController extends Controller
|
||||
{
|
||||
use ResolvesAdminUserId;
|
||||
|
||||
/** GET /api/admin/billing/tariff-plans — список планов для диалога смены тарифа. */
|
||||
public function tariffPlans(): JsonResponse
|
||||
{
|
||||
$plans = DB::table('tariff_plans')
|
||||
->select(['id', 'name', 'price_monthly'])
|
||||
->orderBy('price_monthly')
|
||||
->get()
|
||||
->map(fn ($p) => [
|
||||
'id' => (int) $p->id,
|
||||
'name' => $p->name,
|
||||
'price_monthly' => (string) $p->price_monthly,
|
||||
]);
|
||||
|
||||
return response()->json(['plans' => $plans]);
|
||||
}
|
||||
|
||||
/** PATCH /api/admin/billing/tenants/{id}/status — приостановить/разблокировать тенанта. */
|
||||
public function updateStatus(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'status' => ['required', 'in:active,suspended'],
|
||||
'reason' => ['required', 'string', 'min:10', 'max:1000'],
|
||||
]);
|
||||
|
||||
$tenant = $this->findActiveTenant($id);
|
||||
$adminUserId = $this->resolveAdminUserId($request, 'system-billing@liderra.local', 'System Billing Bot');
|
||||
|
||||
DB::transaction(function () use ($tenant, $validated, $adminUserId, $request): void {
|
||||
// tenants / saas_admin_audit_log не под RLS — SET LOCAL не нужен (ср. refund()).
|
||||
DB::table('tenants')->where('id', $tenant->id)->update([
|
||||
'status' => $validated['status'],
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
SaasAdminAuditLog::create([
|
||||
'admin_user_id' => $adminUserId,
|
||||
'action' => $validated['status'] === 'suspended' ? 'tenant.suspend' : 'tenant.activate',
|
||||
'target_type' => 'tenant',
|
||||
'target_id' => $tenant->id,
|
||||
'target_tenant_id' => $tenant->id,
|
||||
'payload_before' => ['status' => $tenant->status],
|
||||
'payload_after' => ['status' => $validated['status']],
|
||||
'reason' => $validated['reason'],
|
||||
'ip_address' => $request->ip() ?? '127.0.0.1',
|
||||
'user_agent' => $request->userAgent(),
|
||||
]);
|
||||
});
|
||||
|
||||
return response()->json(['id' => $tenant->id, 'status' => $validated['status']]);
|
||||
}
|
||||
|
||||
/** POST /api/admin/billing/tenants/{id}/refund — возврат средств: списание с баланса + ledger-запись. */
|
||||
public function refund(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'amount_rub' => ['required', 'numeric', 'gt:0'],
|
||||
'reason' => ['required', 'string', 'min:10', 'max:1000'],
|
||||
]);
|
||||
|
||||
$this->findActiveTenant($id); // ранний 404; авторитетный баланс перечитывается под локом ниже
|
||||
$amount = number_format((float) $validated['amount_rub'], 2, '.', '');
|
||||
$adminUserId = $this->resolveAdminUserId($request, 'system-billing@liderra.local', 'System Billing Bot');
|
||||
|
||||
/** @var array{transaction_id:int, balance_rub:string} $result */
|
||||
$result = DB::transaction(function () use ($id, $amount, $validated, $adminUserId, $request): array {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$id);
|
||||
|
||||
// Баланс — money-колонка: перечитываем под row-lock внутри транзакции,
|
||||
// защита от lost-update (конвенция LedgerService — lockForUpdate на tenants).
|
||||
$tenant = DB::table('tenants')->where('id', $id)->whereNull('deleted_at')
|
||||
->lockForUpdate()->first();
|
||||
if ($tenant === null) {
|
||||
abort(404, 'tenant not found');
|
||||
}
|
||||
|
||||
$balance = (string) $tenant->balance_rub;
|
||||
if (bccomp($amount, $balance, 2) === 1) {
|
||||
abort(422, 'refund amount exceeds tenant balance');
|
||||
}
|
||||
$newBalance = bcsub($balance, $amount, 2);
|
||||
|
||||
DB::table('tenants')->where('id', $id)->update([
|
||||
'balance_rub' => $newBalance,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$tx = BalanceTransaction::create([
|
||||
'tenant_id' => $id,
|
||||
'type' => BalanceTransaction::TYPE_REFUND,
|
||||
'amount_rub' => '-'.$amount,
|
||||
'amount_leads' => 0,
|
||||
'balance_rub_after' => $newBalance,
|
||||
'description' => $validated['reason'],
|
||||
'admin_user_id' => $adminUserId,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
SaasAdminAuditLog::create([
|
||||
'admin_user_id' => $adminUserId,
|
||||
'action' => 'tenant.refund',
|
||||
'target_type' => 'tenant',
|
||||
'target_id' => $id,
|
||||
'target_tenant_id' => $id,
|
||||
'payload_before' => ['balance_rub' => $balance],
|
||||
'payload_after' => ['balance_rub' => $newBalance, 'amount_rub' => $amount, 'transaction_id' => $tx->id],
|
||||
'reason' => $validated['reason'],
|
||||
'ip_address' => $request->ip() ?? '127.0.0.1',
|
||||
'user_agent' => $request->userAgent(),
|
||||
]);
|
||||
|
||||
return ['transaction_id' => (int) $tx->id, 'balance_rub' => $newBalance];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'id' => $id,
|
||||
'balance_rub' => $result['balance_rub'],
|
||||
'transaction_id' => $result['transaction_id'],
|
||||
]);
|
||||
}
|
||||
|
||||
/** PATCH /api/admin/billing/tenants/{id}/tariff — сменить тарифный план тенанта. */
|
||||
public function changeTariff(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'tariff_id' => ['required', 'integer', 'exists:tariff_plans,id'],
|
||||
'reason' => ['required', 'string', 'min:10', 'max:1000'],
|
||||
]);
|
||||
|
||||
$tenant = $this->findActiveTenant($id);
|
||||
$tariff = DB::table('tariff_plans')->where('id', $validated['tariff_id'])->first();
|
||||
$adminUserId = $this->resolveAdminUserId($request, 'system-billing@liderra.local', 'System Billing Bot');
|
||||
|
||||
DB::transaction(function () use ($tenant, $tariff, $validated, $adminUserId, $request): void {
|
||||
// tenants / saas_admin_audit_log не под RLS — SET LOCAL не нужен (ср. refund()).
|
||||
DB::table('tenants')->where('id', $tenant->id)->update([
|
||||
'current_tariff_id' => $tariff->id,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
SaasAdminAuditLog::create([
|
||||
'admin_user_id' => $adminUserId,
|
||||
'action' => 'tenant.change_tariff',
|
||||
'target_type' => 'tenant',
|
||||
'target_id' => $tenant->id,
|
||||
'target_tenant_id' => $tenant->id,
|
||||
'payload_before' => ['current_tariff_id' => $tenant->current_tariff_id],
|
||||
'payload_after' => ['current_tariff_id' => (int) $tariff->id],
|
||||
'reason' => $validated['reason'],
|
||||
'ip_address' => $request->ip() ?? '127.0.0.1',
|
||||
'user_agent' => $request->userAgent(),
|
||||
]);
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'id' => $tenant->id,
|
||||
'tariff_id' => (int) $tariff->id,
|
||||
'tariff_name' => $tariff->name,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает не-удалённого тенанта либо abort(404).
|
||||
*
|
||||
* @return object{id:int,status:string,balance_rub:string,current_tariff_id:int|null}
|
||||
*/
|
||||
private function findActiveTenant(int $id): object
|
||||
{
|
||||
$tenant = DB::table('tenants')->where('id', $id)->whereNull('deleted_at')->first();
|
||||
if ($tenant === null) {
|
||||
abort(404, 'tenant not found');
|
||||
}
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
/** GET /api/admin/billing?search= */
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
|
||||
@@ -4,9 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Concerns\ResolvesAdminUserId;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\SaasAdminAuditLog;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -23,8 +21,6 @@ use Illuminate\Support\Facades\DB;
|
||||
*/
|
||||
class AdminIncidentsController extends Controller
|
||||
{
|
||||
use ResolvesAdminUserId;
|
||||
|
||||
/** GET /api/admin/incidents?type=&severity=&unresolved_only=&limit=&offset= */
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
@@ -87,116 +83,6 @@ class AdminIncidentsController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/** POST /api/admin/incidents/{id}/rkn-notify — зафиксировать уведомление РКН (G6, 152-ФЗ). */
|
||||
public function notifyRkn(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$row = DB::table('incidents_log')->where('id', $id)->first();
|
||||
if ($row === null) {
|
||||
abort(404, 'incident not found');
|
||||
}
|
||||
if ($row->type !== 'data_breach') {
|
||||
abort(422, 'РКН-уведомление применимо только к инцидентам типа data_breach');
|
||||
}
|
||||
if ($row->rkn_notified_at !== null) {
|
||||
abort(409, 'РКН уже уведомлён по этому инциденту');
|
||||
}
|
||||
|
||||
$adminUserId = $this->resolveAdminUserId($request, 'system-incidents@liderra.local', 'System Incidents Bot');
|
||||
|
||||
DB::transaction(function () use ($row, $adminUserId, $request): void {
|
||||
DB::table('incidents_log')->where('id', $row->id)->update([
|
||||
'rkn_notified_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
SaasAdminAuditLog::create([
|
||||
'admin_user_id' => $adminUserId,
|
||||
'action' => 'incident.rkn_notify',
|
||||
'target_type' => 'incident',
|
||||
'target_id' => $row->id,
|
||||
'payload_before' => ['rkn_notified_at' => null],
|
||||
'payload_after' => ['rkn_notified_at' => now()->toIso8601String()],
|
||||
'reason' => 'Роскомнадзор уведомлён об утечке ПДн через админ-интерфейс (152-ФЗ).',
|
||||
'ip_address' => $request->ip() ?? '127.0.0.1',
|
||||
'user_agent' => $request->userAgent(),
|
||||
]);
|
||||
});
|
||||
|
||||
return $this->show($id);
|
||||
}
|
||||
|
||||
/** GET /api/admin/incidents/{id} — полная карточка инцидента (drill-down G5). */
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$row = DB::table('incidents_log')->where('id', $id)->first();
|
||||
if ($row === null) {
|
||||
abort(404, 'incident not found');
|
||||
}
|
||||
|
||||
$tenantIds = is_array($row->affected_tenant_ids)
|
||||
? $row->affected_tenant_ids
|
||||
: ($row->affected_tenant_ids !== null ? $this->parsePgArrayValues((string) $row->affected_tenant_ids) : []);
|
||||
|
||||
$tenants = $tenantIds === []
|
||||
? collect()
|
||||
: DB::table('tenants')->whereIn('id', $tenantIds)
|
||||
->select(['id', 'organization_name'])->get();
|
||||
|
||||
$admins = DB::table('saas_admin_users')
|
||||
->whereIn('id', array_filter([$row->created_by_admin_id, $row->closed_by_admin_id]))
|
||||
->pluck('full_name', 'id');
|
||||
|
||||
return response()->json([
|
||||
'incident' => [
|
||||
'id' => (int) $row->id,
|
||||
'incident_id' => $this->formatIncidentId($row),
|
||||
'type' => $row->type,
|
||||
'severity' => $row->severity,
|
||||
'summary' => $row->summary,
|
||||
'root_cause' => $row->root_cause,
|
||||
'postmortem_url' => $row->postmortem_url,
|
||||
'started_at' => CarbonImmutable::parse($row->started_at)->toIso8601String(),
|
||||
'detected_at' => CarbonImmutable::parse($row->detected_at)->toIso8601String(),
|
||||
'resolved_at' => $row->resolved_at !== null
|
||||
? CarbonImmutable::parse($row->resolved_at)->toIso8601String() : null,
|
||||
'status' => $this->deriveStatus($row),
|
||||
'affected_tenants' => $tenants->map(fn ($t) => [
|
||||
'id' => (int) $t->id,
|
||||
'organization_name' => $t->organization_name,
|
||||
])->values(),
|
||||
'affected_users_count' => $row->affected_users_count !== null ? (int) $row->affected_users_count : null,
|
||||
'notification_sent_at' => $row->notification_sent_at !== null
|
||||
? CarbonImmutable::parse($row->notification_sent_at)->toIso8601String() : null,
|
||||
'rkn_notified' => $row->rkn_notified_at !== null,
|
||||
'rkn_notified_at' => $row->rkn_notified_at !== null
|
||||
? CarbonImmutable::parse($row->rkn_notified_at)->toIso8601String() : null,
|
||||
'rkn_deadline_at' => $row->type === 'data_breach' && $row->rkn_notified_at === null
|
||||
? CarbonImmutable::parse($row->detected_at)->addHours(24)->toIso8601String() : null,
|
||||
'created_by_admin' => $admins->get($row->created_by_admin_id),
|
||||
'closed_by_admin' => $row->closed_by_admin_id !== null ? $admins->get($row->closed_by_admin_id) : null,
|
||||
'created_at' => $row->created_at !== null
|
||||
? CarbonImmutable::parse($row->created_at)->toIso8601String() : null,
|
||||
'updated_at' => $row->updated_at !== null
|
||||
? CarbonImmutable::parse($row->updated_at)->toIso8601String() : null,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* PG-array literal '{1,2,3}' → массив int.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function parsePgArrayValues(string $literal): array
|
||||
{
|
||||
$trimmed = trim($literal, '{}');
|
||||
if ($trimmed === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map('intval', explode(',', $trimmed));
|
||||
}
|
||||
|
||||
/** Уникальный человеко-читаемый ID: INC-YYYY-MMDD-NNNN, NNNN = id padded. */
|
||||
private function formatIncidentId(object $row): string
|
||||
{
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ApiKey;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* API-ключи тенанта (audit D2/D3/J5). Endpoints под auth:sanctum + tenant.
|
||||
*
|
||||
* Полный ключ показывается ОДИН раз — в ответе regenerate(). В БД хранится
|
||||
* только bcrypt key_hash + key_prefix (первые 10 символов для UI). У тенанта
|
||||
* поддерживается один активный ключ: regenerate деактивирует прежние.
|
||||
*/
|
||||
class ApiKeyController extends Controller
|
||||
{
|
||||
private const KEY_PREFIX = 'lpkapi_';
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
|
||||
// Defense-in-depth: явный where даже при RLS — в тестах PG superuser BYPASSRLS.
|
||||
$keys = ApiKey::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('is_active', true)
|
||||
->where('expires_at', '>', now())
|
||||
->orderByDesc('created_at')
|
||||
->get(['id', 'name', 'key_prefix', 'last_used_at', 'expires_at', 'created_at']);
|
||||
|
||||
return response()->json(['data' => $keys]);
|
||||
}
|
||||
|
||||
public function regenerate(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
$userId = (int) $request->user()->id;
|
||||
|
||||
// Один активный ключ на тенанта — прежние деактивируются.
|
||||
ApiKey::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('is_active', true)
|
||||
->update(['is_active' => false]);
|
||||
|
||||
$plainKey = self::KEY_PREFIX.Str::random(48);
|
||||
|
||||
$key = ApiKey::query()->create([
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => $userId,
|
||||
'name' => 'API-ключ',
|
||||
'key_hash' => Hash::make($plainKey),
|
||||
'key_prefix' => substr($plainKey, 0, 10),
|
||||
'scopes' => ['read'],
|
||||
'expires_at' => now()->addYear(),
|
||||
'is_active' => true,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'id' => $key->id,
|
||||
'name' => $key->name,
|
||||
'key' => $plainKey,
|
||||
'key_prefix' => $key->key_prefix,
|
||||
], Response::HTTP_CREATED);
|
||||
}
|
||||
}
|
||||
@@ -228,31 +228,6 @@ class AuthController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/auth/me — обновление профиля текущего пользователя
|
||||
* (имя, фамилия, телефон, тайм-зона). Email менять нельзя (через support).
|
||||
*
|
||||
* Audit J6/D1 (ProfileTab). Зеркалит updateNotificationPreferences:
|
||||
* та же группа auth:sanctum, тот же inline-validate, тот же userResource.
|
||||
*/
|
||||
public function updateProfile(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'first_name' => ['required', 'string', 'max:255'],
|
||||
'last_name' => ['required', 'string', 'max:255'],
|
||||
'phone' => ['nullable', 'string', 'max:20'],
|
||||
'timezone' => ['required', 'timezone'],
|
||||
]);
|
||||
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
$user->update($validated);
|
||||
|
||||
return response()->json([
|
||||
'user' => $this->userResource($user->fresh()),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ключ throttle для login: email|ip — защищает email от брутфорса даже
|
||||
* за NAT'ом, и IP от перебора емейлов с одного источника.
|
||||
@@ -358,8 +333,6 @@ class AuthController extends Controller
|
||||
'email' => $user->email,
|
||||
'first_name' => $user->first_name,
|
||||
'last_name' => $user->last_name,
|
||||
'phone' => $user->phone,
|
||||
'timezone' => $user->timezone,
|
||||
'tenant_id' => $user->tenant_id,
|
||||
'totp_enabled' => $user->totp_enabled,
|
||||
'last_login_at' => $user->last_login_at,
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Billing\BillingTopupService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Биллинг тенанта — кошелёк, транзакции, счета, пополнение (audit E1/E3).
|
||||
*
|
||||
* Все эндпоинты под middleware [auth:sanctum, tenant] (RLS-контекст).
|
||||
* Отдельно от TenantChargesController (lead_charges ledger) и
|
||||
* AdminBillingController (SaaS-уровневые агрегаты).
|
||||
*
|
||||
* E1: POST /api/billing/topup — MVP-stub пополнения (без платёжного шлюза).
|
||||
* E3: GET wallet/transactions/invoices — данные для BillingView Overview.
|
||||
*/
|
||||
class BillingController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BillingTopupService $topupService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* POST /api/billing/topup — пополнить рублёвый баланс.
|
||||
*
|
||||
* MVP-stub: кредитует баланс немедленно (без ЮKassa — реальная оплата
|
||||
* post-Б-1). Записывает append-only строку balance_transactions(topup).
|
||||
*/
|
||||
public function topup(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'amount_rub' => ['required', 'numeric', 'min:100', 'max:1000000', 'decimal:0,2'],
|
||||
]);
|
||||
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
|
||||
// Нормализуем в DECIMAL-строку scale 2 для bcmath (НЕ float).
|
||||
$amountRub = bcadd((string) $validated['amount_rub'], '0', 2);
|
||||
|
||||
$tx = $this->topupService->topup((int) $user->tenant_id, $amountRub, (int) $user->id);
|
||||
|
||||
return response()->json([
|
||||
'transaction' => [
|
||||
'id' => $tx->id,
|
||||
'type' => $tx->type,
|
||||
'amount_rub' => $tx->amount_rub,
|
||||
'balance_rub_after' => $tx->balance_rub_after,
|
||||
'created_at' => $tx->created_at,
|
||||
],
|
||||
'balance_rub' => $tx->balance_rub_after,
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/billing/wallet — балансы тенанта + текущий тариф + runway.
|
||||
*/
|
||||
public function wallet(Request $request): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
/** @var Tenant $tenant */
|
||||
$tenant = Tenant::query()->with('tariff')->findOrFail((int) $user->tenant_id);
|
||||
|
||||
return response()->json([
|
||||
'balance_rub' => $tenant->balance_rub,
|
||||
'balance_leads' => $tenant->balance_leads,
|
||||
'runway_days' => $this->runwayDays($tenant),
|
||||
'tariff' => $tenant->tariff === null ? null : [
|
||||
'code' => $tenant->tariff->code,
|
||||
'name' => $tenant->tariff->name,
|
||||
'price_monthly' => $tenant->tariff->price_monthly,
|
||||
'billing_model' => $tenant->tariff->billing_model,
|
||||
'features' => $tenant->tariff->features ?? [],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/billing/transactions?type=topup|lead_charge|refund&page=N
|
||||
* — пагинированная история balance_transactions тенанта (20/страница).
|
||||
*/
|
||||
public function transactions(Request $request): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
$tenantId = (int) $user->tenant_id;
|
||||
|
||||
// Явный tenant_id фильтр — defense-in-depth поверх RLS (тесты идут
|
||||
// под superuser BYPASSRLS; паттерн TenantChargesController).
|
||||
$query = BalanceTransaction::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->orderBy('created_at', 'desc')
|
||||
->orderBy('id', 'desc');
|
||||
|
||||
$type = $request->query('type');
|
||||
if (is_string($type) && in_array($type, ['topup', 'lead_charge', 'refund'], true)) {
|
||||
$query->where('type', $type);
|
||||
}
|
||||
|
||||
$page = $query->paginate(20);
|
||||
|
||||
return response()->json([
|
||||
'data' => array_map(static fn (BalanceTransaction $tx): array => [
|
||||
'id' => $tx->id,
|
||||
'code' => 'TX-'.$tx->id,
|
||||
'type' => $tx->type,
|
||||
'description' => $tx->description,
|
||||
'amount_rub' => $tx->amount_rub,
|
||||
'amount_leads' => $tx->amount_leads,
|
||||
'balance_rub_after' => $tx->balance_rub_after,
|
||||
'created_at' => $tx->created_at,
|
||||
], $page->items()),
|
||||
'meta' => [
|
||||
'current_page' => $page->currentPage(),
|
||||
'last_page' => $page->lastPage(),
|
||||
'total' => $page->total(),
|
||||
'per_page' => $page->perPage(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/billing/invoices — счета тенанта (saas_invoices).
|
||||
*
|
||||
* Real-but-empty на MVP: saas_invoices.legal_entity_id NOT NULL требует
|
||||
* зарегистрированного юр-лица (блокируется Б-1). Read-only выборка через
|
||||
* DB::table — без Eloquent-модели (паттерн AdminBillingController).
|
||||
*/
|
||||
public function invoices(Request $request): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
$tenantId = (int) $user->tenant_id;
|
||||
|
||||
$rows = DB::table('saas_invoices')
|
||||
->where('tenant_id', $tenantId)
|
||||
->orderBy('issued_at', 'desc')
|
||||
->get(['id', 'invoice_number', 'amount_total', 'status', 'issued_at', 'pdf_path']);
|
||||
|
||||
return response()->json([
|
||||
'data' => $rows->map(static fn (\stdClass $r): array => [
|
||||
'id' => $r->id,
|
||||
'invoice_number' => $r->invoice_number,
|
||||
'amount_total' => $r->amount_total,
|
||||
'status' => $r->status,
|
||||
'issued_at' => $r->issued_at,
|
||||
'has_pdf' => $r->pdf_path !== null,
|
||||
])->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Прогноз «на сколько дней хватит баланса» — оценочный UX-показатель.
|
||||
*
|
||||
* = balance_rub / (рублёвые списания за 30 дней / 30). NULL, если списаний
|
||||
* не было. Float здесь допустим: грубая оценка для шапки, НЕ мутация
|
||||
* баланса (мутации баланса — строго bcmath, см. BillingTopupService).
|
||||
* Отрицательный баланс → 0 (тенант уже в минусе, runway не может быть < 0).
|
||||
*/
|
||||
private function runwayDays(Tenant $tenant): ?int
|
||||
{
|
||||
$spent = abs((float) DB::table('balance_transactions')
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('type', BalanceTransaction::TYPE_LEAD_CHARGE)
|
||||
->where('created_at', '>=', now()->subDays(30))
|
||||
->sum('amount_rub'));
|
||||
|
||||
if ($spent <= 0.0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$perDay = $spent / 30.0;
|
||||
|
||||
return max(0, (int) floor((float) $tenant->balance_rub / $perDay));
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tenant;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Дашборд — агрегат для DashboardView (audit C1/J3).
|
||||
*
|
||||
* GET /api/dashboard/summary?tenant_id={id}&range=today|7d|30d
|
||||
*
|
||||
* На MVP без auth-middleware (tenant_id параметром, как DealController).
|
||||
* Production: middleware('auth:sanctum','tenant') → tenant_id из user.
|
||||
*
|
||||
* Все агрегаты — tenant-scoped, deleted_at IS NULL, is_test=false.
|
||||
* RLS-обёртка SET LOCAL app.current_tenant_id (PgBouncer-safe), как DealController.
|
||||
*/
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
private const RU_WEEKDAYS = ['вс', 'пн', 'вт', 'ср', 'чт', 'пт', 'сб'];
|
||||
|
||||
public function summary(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = (int) $request->query('tenant_id', '0');
|
||||
if ($tenantId < 1) {
|
||||
return response()->json(['message' => 'Параметр tenant_id обязателен.'], 422);
|
||||
}
|
||||
|
||||
$tenant = Tenant::find($tenantId);
|
||||
if ($tenant === null) {
|
||||
return response()->json(['message' => 'Тенант не найден.'], 404);
|
||||
}
|
||||
|
||||
$range = in_array($request->query('range'), ['today', '7d', '30d'], true)
|
||||
? (string) $request->query('range')
|
||||
: '7d';
|
||||
|
||||
// MSK: activity-бакеты и range-границы должны совпадать с SQL
|
||||
// `AT TIME ZONE 'Europe/Moscow'`. config('app.timezone') = UTC.
|
||||
$now = CarbonImmutable::now('Europe/Moscow');
|
||||
[$windowStart, $prevStart] = match ($range) {
|
||||
'today' => [$now->startOfDay(), $now->startOfDay()->subDay()],
|
||||
'30d' => [$now->subDays(30), $now->subDays(60)],
|
||||
default => [$now->subDays(7), $now->subDays(14)],
|
||||
};
|
||||
|
||||
$data = DB::transaction(function () use ($tenantId, $tenant, $now, $range, $windowStart, $prevStart) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
$base = fn () => DB::table('deals')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNull('deleted_at')
|
||||
->where('is_test', false);
|
||||
|
||||
// --- leads received: текущее + предыдущее окно ---
|
||||
$curLeads = (clone $base())->whereBetween('received_at', [$windowStart, $now])->count();
|
||||
$prevLeads = (clone $base())->whereBetween('received_at', [$prevStart, $windowStart])->count();
|
||||
|
||||
// --- conversion: % статуса 'paid' в окне ---
|
||||
$curPaid = (clone $base())->where('status', 'paid')
|
||||
->whereBetween('received_at', [$windowStart, $now])->count();
|
||||
$prevPaid = (clone $base())->where('status', 'paid')
|
||||
->whereBetween('received_at', [$prevStart, $windowStart])->count();
|
||||
$curConv = $curLeads > 0 ? round($curPaid / $curLeads * 100, 1) : 0.0;
|
||||
$prevConv = $prevLeads > 0 ? round($prevPaid / $prevLeads * 100, 1) : 0.0;
|
||||
|
||||
// --- active projects ---
|
||||
$activeProjects = DB::table('projects')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNull('archived_at')
|
||||
->where('is_active', true)
|
||||
->count();
|
||||
$maxProjects = (int) (($tenant->limits['max_projects'] ?? 0));
|
||||
|
||||
// --- activity: 7 daily-бакетов по received_at (MSK) ---
|
||||
$activityStart = $now->subDays(6)->startOfDay();
|
||||
$byDay = (clone $base())
|
||||
->where('received_at', '>=', $activityStart)
|
||||
->selectRaw("to_char((received_at AT TIME ZONE 'Europe/Moscow')::date, 'YYYY-MM-DD') AS d, COUNT(*) AS c")
|
||||
->groupBy('d')
|
||||
->pluck('c', 'd');
|
||||
$points = [];
|
||||
$labels = [];
|
||||
for ($i = 6; $i >= 0; $i--) {
|
||||
$day = $now->subDays($i);
|
||||
$key = $day->format('Y-m-d');
|
||||
$points[] = (int) ($byDay[$key] ?? 0);
|
||||
$labels[] = $i === 0 ? 'сегодня' : self::RU_WEEKDAYS[(int) $day->format('w')];
|
||||
}
|
||||
$maxPoint = max(0, ...$points);
|
||||
$axisMax = max(10, (int) (ceil($maxPoint / 10) * 10));
|
||||
|
||||
// --- funnel: текущий снимок по статусам ---
|
||||
$funnel = (clone $base())
|
||||
->selectRaw('status, COUNT(*) AS c')
|
||||
->groupBy('status')
|
||||
->pluck('c', 'status')
|
||||
->map(fn ($c) => (int) $c)
|
||||
->toArray();
|
||||
|
||||
// --- runway ---
|
||||
// runway опирается на приток за фиксированное 7-дневное окно,
|
||||
// независимо от выбранного range (для today/30d $curLeads — не 7-дневный).
|
||||
$leads7d = (clone $base())->whereBetween('received_at', [$now->subDays(7), $now])->count();
|
||||
$avgDaily = $leads7d / 7.0;
|
||||
$balanceLeads = (int) ($tenant->balance_leads ?? 0);
|
||||
$runwayDays = $avgDaily > 0 ? (int) floor($balanceLeads / $avgDaily) : 0;
|
||||
|
||||
return [
|
||||
'range' => $range,
|
||||
'leads_received' => self::deltaBlock($curLeads, $prevLeads, 'delta_pct', self::pctDelta($curLeads, $prevLeads)),
|
||||
'conversion' => self::deltaBlock($curConv, $prevConv, 'delta_pp', round($curConv - $prevConv, 1)),
|
||||
'active_projects' => ['active' => $activeProjects, 'limit' => $maxProjects],
|
||||
'balance' => [
|
||||
'amount_rub' => (string) $tenant->balance_rub,
|
||||
'runway_days' => $runwayDays,
|
||||
'runway_leads' => $balanceLeads,
|
||||
],
|
||||
'activity' => ['points' => $points, 'labels' => $labels, 'max' => $axisMax],
|
||||
'funnel' => (object) $funnel,
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json($data);
|
||||
}
|
||||
|
||||
/** Процентная дельта current vs previous; 0.0 если previous=0. */
|
||||
private static function pctDelta(float $cur, float $prev): float
|
||||
{
|
||||
return $prev > 0 ? round(($cur - $prev) / $prev * 100, 1) : 0.0;
|
||||
}
|
||||
|
||||
/** Блок {value, <deltaKey>, delta_dir}. */
|
||||
private static function deltaBlock(float $value, float $prev, string $deltaKey, float $delta): array
|
||||
{
|
||||
$dir = $value > $prev ? 'up' : ($value < $prev ? 'down' : 'neutral');
|
||||
|
||||
return ['value' => $value, $deltaKey => $delta, 'delta_dir' => $dir];
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Deal;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -19,8 +20,6 @@ use Illuminate\Support\Facades\DB;
|
||||
* bulk + export + helpers). Этот класс отвечает только за многоразовые
|
||||
* массовые операции; single-resource действия остаются в DealController.
|
||||
*
|
||||
* J1 (Sprint 3F): auth:sanctum+tenant, tenant_id из auth()->user().
|
||||
*
|
||||
* O-perf-01: N+1 устранён.
|
||||
*
|
||||
* transition: сначала SELECT всех сделок tenant'а из ids, чтобы отфильтровать
|
||||
@@ -42,19 +41,23 @@ class DealBulkActionController extends Controller
|
||||
/**
|
||||
* POST /api/deals/transition — bulk status-update.
|
||||
*
|
||||
* Body: {ids: [int...], status: slug}.
|
||||
* Body: {tenant_id, ids: [int...], status: slug}.
|
||||
* Response: {updated, requested, status} (updated = реально изменённых,
|
||||
* без NO-OP).
|
||||
*/
|
||||
public function transition(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'tenant_id' => 'required|integer|min:1',
|
||||
'ids' => 'required|array|min:1|max:1000',
|
||||
'ids.*' => 'integer|min:1',
|
||||
'status' => 'required|string|max:50',
|
||||
]);
|
||||
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
$tenant = Tenant::find($validated['tenant_id']);
|
||||
if ($tenant === null) {
|
||||
return response()->json(['message' => 'Тенант не найден.'], 404);
|
||||
}
|
||||
|
||||
$statusExists = DB::table('lead_statuses')->where('slug', $validated['status'])->exists();
|
||||
if (! $statusExists) {
|
||||
@@ -64,14 +67,14 @@ class DealBulkActionController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$updated = DB::transaction(function () use ($validated, $tenantId) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
$updated = DB::transaction(function () use ($validated, $tenant) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
||||
|
||||
// Фаза 1: SELECT — нужны id и предыдущий status для каждой строки,
|
||||
// чтобы (а) отфильтровать NO-OP и (б) сохранить prev в context.from.
|
||||
// Defense-in-depth where(tenant_id) — защита от кросс-tenant id.
|
||||
$rows = Deal::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('tenant_id', $tenant->id)
|
||||
->whereIn('id', $validated['ids'])
|
||||
->get(['id', 'status']);
|
||||
|
||||
@@ -85,7 +88,7 @@ class DealBulkActionController extends Controller
|
||||
|
||||
// Фаза 2: bulk-UPDATE 1 запросом вместо N.
|
||||
Deal::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('tenant_id', $tenant->id)
|
||||
->whereIn('id', $changedIds)
|
||||
->update([
|
||||
'status' => $validated['status'],
|
||||
@@ -97,7 +100,7 @@ class DealBulkActionController extends Controller
|
||||
// массив сериализуем в JSON руками, остальные scalar-поля передаём
|
||||
// напрямую. Триггер audit_chain_hash() заполнит log_hash на уровне БД.
|
||||
$logRows = $changed->map(fn (Deal $d) => [
|
||||
'tenant_id' => $tenantId,
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => null,
|
||||
'deal_id' => $d->id,
|
||||
'event' => ActivityLog::EVENT_DEAL_STATUS_CHANGED,
|
||||
@@ -124,7 +127,7 @@ class DealBulkActionController extends Controller
|
||||
/**
|
||||
* DELETE /api/deals — bulk soft-delete.
|
||||
*
|
||||
* Body: {ids: [int...]}.
|
||||
* Body: {tenant_id, ids: [int...]}.
|
||||
* Response: {deleted, requested}.
|
||||
*
|
||||
* Soft-delete сохраняется (см. документацию в DealController.destroy на
|
||||
@@ -134,19 +137,23 @@ class DealBulkActionController extends Controller
|
||||
public function destroy(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'tenant_id' => 'required|integer|min:1',
|
||||
'ids' => 'required|array|min:1|max:1000',
|
||||
'ids.*' => 'integer|min:1',
|
||||
]);
|
||||
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
$tenant = Tenant::find($validated['tenant_id']);
|
||||
if ($tenant === null) {
|
||||
return response()->json(['message' => 'Тенант не найден.'], 404);
|
||||
}
|
||||
|
||||
$deleted = DB::transaction(function () use ($validated, $tenantId) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
$deleted = DB::transaction(function () use ($validated, $tenant) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
||||
|
||||
// SELECT id'шников живых сделок tenant'а из ids — для bulk-INSERT
|
||||
// в activity_log по списку реально удаляемых (NO-OP idempotency).
|
||||
$targetIds = Deal::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('tenant_id', $tenant->id)
|
||||
->whereIn('id', $validated['ids'])
|
||||
->whereNull('deleted_at')
|
||||
->pluck('id')
|
||||
@@ -159,7 +166,7 @@ class DealBulkActionController extends Controller
|
||||
$now = now();
|
||||
|
||||
Deal::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('tenant_id', $tenant->id)
|
||||
->whereIn('id', $targetIds)
|
||||
->whereNull('deleted_at')
|
||||
->update([
|
||||
@@ -168,7 +175,7 @@ class DealBulkActionController extends Controller
|
||||
]);
|
||||
|
||||
$logRows = array_map(fn (int $id) => [
|
||||
'tenant_id' => $tenantId,
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => null,
|
||||
'deal_id' => $id,
|
||||
'event' => ActivityLog::EVENT_DEAL_DELETED,
|
||||
@@ -190,26 +197,30 @@ class DealBulkActionController extends Controller
|
||||
/**
|
||||
* POST /api/deals/restore — bulk restore soft-deleted.
|
||||
*
|
||||
* Body: {ids: [int...]}.
|
||||
* Body: {tenant_id, ids: [int...]}.
|
||||
* Response: {restored, requested}.
|
||||
*/
|
||||
public function restore(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'tenant_id' => 'required|integer|min:1',
|
||||
'ids' => 'required|array|min:1|max:1000',
|
||||
'ids.*' => 'integer|min:1',
|
||||
]);
|
||||
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
$tenant = Tenant::find($validated['tenant_id']);
|
||||
if ($tenant === null) {
|
||||
return response()->json(['message' => 'Тенант не найден.'], 404);
|
||||
}
|
||||
|
||||
$restored = DB::transaction(function () use ($validated, $tenantId) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
$restored = DB::transaction(function () use ($validated, $tenant) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
||||
|
||||
// withTrashed обходит SoftDeletes global scope; whereNotNull —
|
||||
// NO-OP idempotency для уже живых.
|
||||
$targetIds = Deal::query()
|
||||
->withTrashed()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('tenant_id', $tenant->id)
|
||||
->whereIn('id', $validated['ids'])
|
||||
->whereNotNull('deleted_at')
|
||||
->pluck('id')
|
||||
@@ -223,7 +234,7 @@ class DealBulkActionController extends Controller
|
||||
|
||||
Deal::query()
|
||||
->withTrashed()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('tenant_id', $tenant->id)
|
||||
->whereIn('id', $targetIds)
|
||||
->whereNotNull('deleted_at')
|
||||
->update([
|
||||
@@ -232,7 +243,7 @@ class DealBulkActionController extends Controller
|
||||
]);
|
||||
|
||||
$logRows = array_map(fn (int $id) => [
|
||||
'tenant_id' => $tenantId,
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => null,
|
||||
'deal_id' => $id,
|
||||
'event' => ActivityLog::EVENT_DEAL_RESTORED,
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Models\ActivityLog;
|
||||
use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierLeadCost;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\SupplierResolver;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -26,7 +27,9 @@ use Illuminate\Support\Facades\DB;
|
||||
* `WebhookReceiveController` + `ProcessWebhookJob` (асинхронно через очередь
|
||||
* с advisory lock + dedup). Этот controller — для ручных action'ов из UI.
|
||||
*
|
||||
* J1 (Sprint 3F): auth:sanctum+tenant, tenant_id из auth()->user().
|
||||
* На MVP без auth-middleware (multi-tenant контекст резолвится по
|
||||
* `tenant_id` параметру). Production: middleware('auth:sanctum')+'tenant'
|
||||
* → tenant_id из request()->user()->tenant_id; user ID для manager/audit.
|
||||
*
|
||||
* Manual-create отличается от webhook'а:
|
||||
* - source_crm_id = NULL (не из webhook).
|
||||
@@ -39,7 +42,7 @@ use Illuminate\Support\Facades\DB;
|
||||
class DealController extends Controller
|
||||
{
|
||||
/**
|
||||
* GET /api/deals?status_in[]=...&project_id=...&manager_id=...&search=...&limit=...&offset=...
|
||||
* GET /api/deals?tenant_id={id}&status_in[]=...&project_id=...&manager_id=...&search=...&limit=...&offset=...
|
||||
*
|
||||
* Список сделок tenant'а с relations (project.name, manager.first/last/email).
|
||||
* Используется в `DealsView`/`KanbanView` вместо MOCK_DEALS.
|
||||
@@ -50,10 +53,20 @@ class DealController extends Controller
|
||||
* (received_at, id)).
|
||||
*
|
||||
* RLS: SET LOCAL app.current_tenant_id внутри транзакции (PgBouncer-safe).
|
||||
* Чужие сделки отфильтрует политика, даже если клиент подсунет чужой
|
||||
* tenant_id (без auth — на MVP, на prod — middleware).
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
$tenantId = (int) $request->query('tenant_id', '0');
|
||||
if ($tenantId < 1) {
|
||||
return response()->json(['message' => 'Параметр tenant_id обязателен.'], 422);
|
||||
}
|
||||
|
||||
$tenant = Tenant::find($tenantId);
|
||||
if ($tenant === null) {
|
||||
return response()->json(['message' => 'Тенант не найден.'], 404);
|
||||
}
|
||||
|
||||
$statuses = (array) $request->query('status_in', []);
|
||||
$projectId = $request->query('project_id') !== null ? (int) $request->query('project_id') : null;
|
||||
@@ -190,7 +203,7 @@ class DealController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/deals/{id} — детали сделки + recent activity events.
|
||||
* GET /api/deals/{id}?tenant_id={id} — детали сделки + recent activity events.
|
||||
*
|
||||
* Используется в DealDetailDrawer (правая панель). Возвращает deal с
|
||||
* relations + до 50 последних activity_log событий по этой сделке.
|
||||
@@ -200,7 +213,15 @@ class DealController extends Controller
|
||||
*/
|
||||
public function show(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
$tenantId = (int) $request->query('tenant_id', '0');
|
||||
if ($tenantId < 1) {
|
||||
return response()->json(['message' => 'Параметр tenant_id обязателен.'], 422);
|
||||
}
|
||||
|
||||
$tenant = Tenant::find($tenantId);
|
||||
if ($tenant === null) {
|
||||
return response()->json(['message' => 'Тенант не найден.'], 404);
|
||||
}
|
||||
|
||||
[$deal, $events] = DB::transaction(function () use ($tenantId, $id) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
@@ -270,7 +291,7 @@ class DealController extends Controller
|
||||
/**
|
||||
* PATCH /api/deals/{id} — частичное редактирование сделки из DealDetailDrawer.
|
||||
*
|
||||
* Body (все поля optional, должно быть хотя бы одно): {comment?,
|
||||
* Body (все поля optional, должно быть хотя бы одно): {tenant_id, comment?,
|
||||
* manager_id?, status?}.
|
||||
*
|
||||
* Каждое изменение пишется в ActivityLog с правильным event-type:
|
||||
@@ -288,12 +309,16 @@ class DealController extends Controller
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'tenant_id' => 'required|integer|min:1',
|
||||
'comment' => 'nullable|string|max:5000',
|
||||
'manager_id' => 'nullable|integer|min:1',
|
||||
'status' => 'nullable|string|max:50',
|
||||
]);
|
||||
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
$tenant = Tenant::find($validated['tenant_id']);
|
||||
if ($tenant === null) {
|
||||
return response()->json(['message' => 'Тенант не найден.'], 404);
|
||||
}
|
||||
|
||||
// Validate status slug если передан.
|
||||
if (array_key_exists('status', $validated) && $validated['status'] !== null) {
|
||||
@@ -310,7 +335,7 @@ class DealController extends Controller
|
||||
if (array_key_exists('manager_id', $validated) && $validated['manager_id'] !== null) {
|
||||
$managerExists = User::query()
|
||||
->where('id', $validated['manager_id'])
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('tenant_id', $tenant->id)
|
||||
->whereNull('deleted_at')
|
||||
->where('is_active', true)
|
||||
->exists();
|
||||
@@ -322,11 +347,11 @@ class DealController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$deal = DB::transaction(function () use ($validated, $tenantId, $id) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
$deal = DB::transaction(function () use ($validated, $tenant, $id) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
||||
|
||||
$deal = Deal::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('id', $id)
|
||||
->first();
|
||||
|
||||
@@ -338,7 +363,7 @@ class DealController extends Controller
|
||||
if (array_key_exists('comment', $validated) && $deal->comment !== $validated['comment']) {
|
||||
$deal->comment = $validated['comment'];
|
||||
ActivityLog::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => null,
|
||||
'deal_id' => $deal->id,
|
||||
'event' => 'deal.commented',
|
||||
@@ -351,7 +376,7 @@ class DealController extends Controller
|
||||
$deal->manager_id = $validated['manager_id'];
|
||||
$deal->assigned_at = $validated['manager_id'] !== null ? now() : null;
|
||||
ActivityLog::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => null,
|
||||
'deal_id' => $deal->id,
|
||||
'event' => ActivityLog::EVENT_DEAL_ASSIGNED,
|
||||
@@ -363,7 +388,7 @@ class DealController extends Controller
|
||||
$previousStatus = $deal->status;
|
||||
$deal->status = $validated['status'];
|
||||
ActivityLog::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => null,
|
||||
'deal_id' => $deal->id,
|
||||
'event' => ActivityLog::EVENT_DEAL_STATUS_CHANGED,
|
||||
@@ -400,6 +425,7 @@ class DealController extends Controller
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'tenant_id' => 'required|integer|min:1',
|
||||
'project_name' => 'required|string|max:255',
|
||||
'phone' => 'required|string|max:20',
|
||||
'contact_name' => 'nullable|string|max:255',
|
||||
@@ -408,14 +434,17 @@ class DealController extends Controller
|
||||
'comment' => 'nullable|string|max:5000',
|
||||
]);
|
||||
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
$tenant = Tenant::find($validated['tenant_id']);
|
||||
if ($tenant === null) {
|
||||
return response()->json(['message' => 'Тенант не найден.'], 404);
|
||||
}
|
||||
|
||||
// Manager FK guard: если manager_id передан, он должен принадлежать
|
||||
// этому tenant'у. Иначе можно назначить чужого менеджера на свою сделку.
|
||||
if (isset($validated['manager_id'])) {
|
||||
$managerExists = User::query()
|
||||
->where('id', $validated['manager_id'])
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('tenant_id', $tenant->id)
|
||||
->whereNull('deleted_at')
|
||||
->where('is_active', true)
|
||||
->exists();
|
||||
@@ -430,16 +459,16 @@ class DealController extends Controller
|
||||
$statusSlug = $validated['status'] ?? 'new';
|
||||
|
||||
// Транзакция + RLS: SET LOCAL внутри (PgBouncer-safe).
|
||||
$deal = DB::transaction(function () use ($validated, $tenantId, $statusSlug) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
$deal = DB::transaction(function () use ($validated, $tenant, $statusSlug) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
||||
|
||||
$project = Project::firstOrCreate(
|
||||
['tenant_id' => $tenantId, 'name' => $validated['project_name']],
|
||||
['tenant_id' => $tenant->id, 'name' => $validated['project_name']],
|
||||
['type' => 'manual'],
|
||||
);
|
||||
|
||||
$deal = Deal::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'tenant_id' => $tenant->id,
|
||||
'source_crm_id' => null, // manual
|
||||
'project_id' => $project->id,
|
||||
'phone' => $validated['phone'],
|
||||
@@ -470,7 +499,7 @@ class DealController extends Controller
|
||||
}
|
||||
|
||||
ActivityLog::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => null, // на prod — request()->user()->id
|
||||
'deal_id' => $deal->id,
|
||||
'event' => ActivityLog::EVENT_DEAL_CREATED,
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Deal;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use OpenSpout\Common\Entity\Row;
|
||||
@@ -20,15 +21,13 @@ use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
*
|
||||
* Извлечено из DealController (Sprint 3 Phase A, audit O-refactor-01).
|
||||
*
|
||||
* J1 (Sprint 3F): auth:sanctum+tenant, tenant_id из auth()->user().
|
||||
*
|
||||
* O-perf-05: streaming устраняет memory pressure. PhpSpreadsheet строил
|
||||
* полный объект .xlsx в памяти (для 10K сделок ≈ 100+ MB). OpenSpout пишет
|
||||
* в php://output постранично через Writer + Row::fromValues и chunkById(500)
|
||||
* по сделкам — пик памяти O(1) от размера экспорта.
|
||||
*
|
||||
* API контракт сохранён:
|
||||
* POST /api/deals/export {ids[], format?: csv|xlsx}
|
||||
* POST /api/deals/export {tenant_id, ids[], format?: csv|xlsx}
|
||||
* Headers Content-Type / Content-Disposition без изменений.
|
||||
* CSV: UTF-8 + BOM + ;-разделитель (Excel-friendly RU-локаль).
|
||||
* XLSX: bold-header + auto-size columns.
|
||||
@@ -44,12 +43,16 @@ class DealExportController extends Controller
|
||||
public function export(Request $request): StreamedResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'tenant_id' => 'required|integer|min:1',
|
||||
'ids' => 'required|array|min:1|max:10000',
|
||||
'ids.*' => 'integer|min:1',
|
||||
'format' => 'nullable|string|in:csv,xlsx',
|
||||
]);
|
||||
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
$tenant = Tenant::find($validated['tenant_id']);
|
||||
if ($tenant === null) {
|
||||
abort(404, 'Тенант не найден.');
|
||||
}
|
||||
|
||||
$format = $validated['format'] ?? 'csv';
|
||||
$filename = 'deals_export_'.now()->format('Y-m-d').'.'.$format;
|
||||
@@ -64,13 +67,13 @@ class DealExportController extends Controller
|
||||
'Content-Disposition' => 'attachment; filename="'.$filename.'"',
|
||||
];
|
||||
|
||||
return new StreamedResponse(function () use ($validated, $tenantId, $format) {
|
||||
return new StreamedResponse(function () use ($validated, $tenant, $format) {
|
||||
// RLS-контекст должен быть установлен внутри транзакции на момент
|
||||
// фактического SELECT. StreamedResponse callback вызывается уже
|
||||
// после Laravel-response pipeline'а, поэтому открываем транзакцию
|
||||
// прямо здесь.
|
||||
DB::transaction(function () use ($validated, $tenantId, $format) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
DB::transaction(function () use ($validated, $tenant, $format) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
||||
|
||||
$writer = $this->openWriter($format);
|
||||
$writer->openToFile('php://output');
|
||||
@@ -90,7 +93,7 @@ class DealExportController extends Controller
|
||||
// chunkById(500) — keyset-friendly; в нашем DealsView это
|
||||
// редкий тяжёлый action, экспортировать могут до 10K id.
|
||||
Deal::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('tenant_id', $tenant->id)
|
||||
->whereIn('id', $validated['ids'])
|
||||
->orderBy('id')
|
||||
->chunkById(500, function ($deals) use ($writer) {
|
||||
|
||||
@@ -123,21 +123,15 @@ class ImpersonationController extends Controller
|
||||
]);
|
||||
|
||||
// TODO: отправить email на $tenant->contact_email с $plainCode.
|
||||
$payload = [
|
||||
// На MVP возвращаем code в response для тестов / dev (на prod НЕ должно
|
||||
// возвращаться никогда — токен только в email клиента).
|
||||
return response()->json([
|
||||
'token_id' => $token->id,
|
||||
'expires_at' => $token->expires_at->toIso8601String(),
|
||||
'sent_to_email' => $token->sent_to_email,
|
||||
];
|
||||
|
||||
// Audit-fix A2: plain-код возвращается в API-ответе ТОЛЬКО на dev/testing
|
||||
// (для тестов и локальной разработки). На prod код уходит исключительно
|
||||
// в email клиента — env-guard исключает захват impersonation-сессии
|
||||
// через чтение ответа init.
|
||||
if (app()->environment('local', 'testing')) {
|
||||
$payload['_dev_plain_code'] = $plainCode;
|
||||
}
|
||||
|
||||
return response()->json($payload);
|
||||
// dev-only field — на prod исчезает после интеграции с MailService.
|
||||
'_dev_plain_code' => $plainCode,
|
||||
]);
|
||||
}
|
||||
|
||||
/** POST /api/admin/impersonation/verify */
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\ResolveUnknownStatusesRequest;
|
||||
use App\Http\Requests\StoreImportRequest;
|
||||
use App\Jobs\ImportLeadsJob;
|
||||
use App\Models\ImportLog;
|
||||
use App\Models\ImportUnknownStatus;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* CSV-импорт исторических лидов из crm.bp-gr.ru (ТЗ §6).
|
||||
*
|
||||
* Все маршруты — под auth:sanctum + tenant (RLS-контекст задан middleware).
|
||||
* tenant_id берётся из авторизованного пользователя, не из запроса.
|
||||
*/
|
||||
class ImportController extends Controller
|
||||
{
|
||||
/**
|
||||
* POST /api/imports — загрузка CSV, создание import_log, dispatch job'а.
|
||||
*/
|
||||
public function store(StoreImportRequest $request): JsonResponse
|
||||
{
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
$file = $request->file('file');
|
||||
$storedName = Str::uuid()->toString().'.csv';
|
||||
$path = $file->storeAs("imports/{$tenantId}", $storedName, 'local');
|
||||
|
||||
$log = ImportLog::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => $request->user()->id,
|
||||
'filename' => $file->getClientOriginalName(),
|
||||
'file_path' => $path,
|
||||
'status' => 'pending',
|
||||
'entity_type' => 'leads',
|
||||
'source_system' => 'crm.bp-gr.ru',
|
||||
'dry_run' => $request->boolean('dry_run'),
|
||||
]);
|
||||
|
||||
ImportLeadsJob::dispatch($log->id, $tenantId);
|
||||
|
||||
return response()->json(['data' => $this->toResource($log)], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/imports — история импортов тенанта (RLS отфильтрует по tenant).
|
||||
*
|
||||
* Defense-in-depth: явный where(tenant_id) поверх RLS — на dev через
|
||||
* `postgres` superuser RLS обходится BYPASSRLS, app-фильтр гарантирует
|
||||
* изоляцию (паттерн из DealController).
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
|
||||
$logs = ImportLog::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->orderByDesc('id')
|
||||
->limit(50)
|
||||
->get()
|
||||
->map(fn (ImportLog $log) => $this->toResource($log));
|
||||
|
||||
return response()->json(['data' => $logs]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/imports/{importLog} — прогресс одного импорта (для polling'а).
|
||||
*
|
||||
* Defense-in-depth: явная проверка tenant_id на принадлежность поверх RLS.
|
||||
*/
|
||||
public function show(Request $request, ImportLog $importLog): JsonResponse
|
||||
{
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
|
||||
abort_if($importLog->tenant_id !== $tenantId, 403, 'Доступ к импорту другого тенанта запрещён.');
|
||||
|
||||
return response()->json(['data' => $this->toResource($importLog)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/imports/unknown-statuses — незамапленные статусы (вход wizard'а §6.6).
|
||||
*
|
||||
* Defense-in-depth: явный where(tenant_id) поверх RLS.
|
||||
*/
|
||||
public function unknownStatuses(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
|
||||
$rows = ImportUnknownStatus::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->unresolved()
|
||||
->orderByDesc('occurrences')
|
||||
->get()
|
||||
->map(fn (ImportUnknownStatus $s) => [
|
||||
'id' => $s->id,
|
||||
'status_ru' => $s->status_ru,
|
||||
'occurrences' => $s->occurrences,
|
||||
]);
|
||||
|
||||
return response()->json(['data' => $rows]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/imports/unknown-statuses/resolve — ручной маппинг статусов.
|
||||
*
|
||||
* Defense-in-depth: явный where(tenant_id) поверх RLS.
|
||||
*/
|
||||
public function resolveUnknownStatuses(ResolveUnknownStatusesRequest $request): JsonResponse
|
||||
{
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
$userId = (int) $request->user()->id;
|
||||
|
||||
DB::transaction(function () use ($request, $tenantId, $userId): void {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
foreach ($request->validated()['mappings'] as $mapping) {
|
||||
ImportUnknownStatus::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('status_ru', $mapping['status_ru'])
|
||||
->update([
|
||||
'mapped_to_slug' => $mapping['slug'],
|
||||
'resolved_at' => now(),
|
||||
'resolved_by' => $userId,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
return response()->json(['data' => ['resolved' => count($request->validated()['mappings'])]]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function toResource(ImportLog $log): array
|
||||
{
|
||||
return [
|
||||
'id' => $log->id,
|
||||
'filename' => $log->filename,
|
||||
'status' => $log->status,
|
||||
'rows_total' => $log->rows_total,
|
||||
'rows_added' => $log->rows_added,
|
||||
'rows_updated' => $log->rows_updated,
|
||||
'rows_skipped' => $log->rows_skipped,
|
||||
'unknown_statuses_count' => $log->unknown_statuses_count,
|
||||
'dry_run' => $log->dry_run,
|
||||
'error_message' => $log->error_message,
|
||||
'started_at' => $log->started_at?->toIso8601String(),
|
||||
'finished_at' => $log->finished_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -5,160 +5,48 @@ 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\Services\Project\ProjectService;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Проекты tenant'а — расширенный API для ProjectsView + NewDealDialog.
|
||||
* Проекты tenant'а — для NewDealDialog dropdown'а и DealsView/Smart-filters.
|
||||
*
|
||||
* 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).
|
||||
* На MVP: tenant_id параметром. На prod: middleware('auth:sanctum')+'tenant'.
|
||||
*/
|
||||
class ProjectController extends Controller
|
||||
{
|
||||
public function __construct(private readonly ProjectService $projects) {}
|
||||
|
||||
/** GET /api/projects */
|
||||
/** GET /api/projects?tenant_id={id} */
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$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)]);
|
||||
$tenantId = (int) $request->query('tenant_id', '0');
|
||||
if ($tenantId < 1) {
|
||||
return response()->json(['message' => 'Параметр tenant_id обязателен.'], 422);
|
||||
}
|
||||
|
||||
// Фильтр по типу сигнала
|
||||
if ($type = $request->query('signal_type')) {
|
||||
$query->where('signal_type', $type);
|
||||
$tenant = Tenant::find($tenantId);
|
||||
if ($tenant === null) {
|
||||
return response()->json(['message' => 'Тенант не найден.'], 404);
|
||||
}
|
||||
|
||||
// Фильтр по статусу жизненного цикла
|
||||
$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();
|
||||
}
|
||||
$projects = DB::transaction(function () use ($tenantId) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
// Поиск по 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 Project::query()
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'tag', 'type']);
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'data' => ProjectResource::collection($projects->items()),
|
||||
'meta' => [
|
||||
'current_page' => $projects->currentPage(),
|
||||
'per_page' => $projects->perPage(),
|
||||
'total' => $projects->total(),
|
||||
],
|
||||
'projects' => $projects->map(fn (Project $p) => [
|
||||
'id' => $p->id,
|
||||
'name' => $p->name,
|
||||
'tag' => $p->tag,
|
||||
'type' => $p->type,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,7 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Reports API (schema §13.5). Все endpoint'ы под `auth:sanctum`.
|
||||
@@ -342,68 +340,6 @@ class ReportJobController extends Controller
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/reports/jobs/{id}/file?tenant=&expires=&signature= — скачать
|
||||
* готовый файл отчёта (F2, OPEN-И-20).
|
||||
*
|
||||
* Под `signed`-middleware (не auth:sanctum): подпись URL = capability-token.
|
||||
* `tenant` в подписи нужен для RLS-контекста (нет авторизованного user'а).
|
||||
* Подпись покрывает все query-параметры — `tenant`/`id` подделать нельзя.
|
||||
*/
|
||||
public function download(Request $request, int $id): Response
|
||||
{
|
||||
$tenantId = (int) $request->query('tenant', '0');
|
||||
|
||||
return DB::transaction(function () use ($id, $tenantId): Response {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
$job = ReportJob::query()
|
||||
->where('id', $id)
|
||||
->where('tenant_id', $tenantId)
|
||||
->first();
|
||||
|
||||
if ($job === null) {
|
||||
return response()->json(['message' => 'Отчёт не найден.'], 404);
|
||||
}
|
||||
|
||||
if ($job->status !== ReportJob::STATUS_DONE
|
||||
|| $job->file_path === null
|
||||
|| ($job->expires_at !== null && $job->expires_at->isPast())) {
|
||||
return response()->json(['message' => 'Файл отчёта недоступен или истёк.'], 410);
|
||||
}
|
||||
|
||||
if (! Storage::disk('local')->exists($job->file_path)) {
|
||||
return response()->json(['message' => 'Файл отчёта не найден в хранилище.'], 404);
|
||||
}
|
||||
|
||||
$extension = pathinfo($job->file_path, PATHINFO_EXTENSION);
|
||||
|
||||
return Storage::disk('local')->download(
|
||||
$job->file_path,
|
||||
sprintf('report-%d.%s', $job->id, $extension)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Signed URL (24 ч) на скачивание файла. NULL для не-готовых job'ов или
|
||||
* после истечения retention (file_path обнулён cron'ом reports:cleanup-expired).
|
||||
*/
|
||||
private function downloadUrl(ReportJob $job): ?string
|
||||
{
|
||||
if ($job->status !== ReportJob::STATUS_DONE
|
||||
|| $job->file_path === null
|
||||
|| ($job->expires_at !== null && $job->expires_at->isPast())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return URL::temporarySignedRoute(
|
||||
'reports.download',
|
||||
Carbon::now()->addHours(24),
|
||||
['id' => $job->id, 'tenant' => $job->tenant_id],
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function toResource(ReportJob $job): array
|
||||
{
|
||||
@@ -422,7 +358,6 @@ class ReportJobController extends Controller
|
||||
'is_expired' => $job->expires_at !== null && $job->expires_at->isPast(),
|
||||
'retry_count' => (int) ($job->parameters['retry_count'] ?? 0),
|
||||
'retry_max' => self::RETRY_MAX_ATTEMPTS,
|
||||
'download_url' => $this->downloadUrl($job),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ use App\Models\SupplierLead;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Symfony\Component\HttpFoundation\IpUtils;
|
||||
|
||||
/**
|
||||
@@ -41,9 +40,6 @@ use Symfony\Component\HttpFoundation\IpUtils;
|
||||
*/
|
||||
class SupplierWebhookController extends Controller
|
||||
{
|
||||
/** Audit-fix C2: per-IP rate-limit (DoS-guard), запросов в минуту. */
|
||||
private const RATE_LIMIT_PER_MINUTE = 600;
|
||||
|
||||
public function receive(Request $request, string $secret): JsonResponse
|
||||
{
|
||||
if (! $this->verifySecret($secret)) {
|
||||
@@ -54,20 +50,6 @@ class SupplierWebhookController extends Controller
|
||||
return response()->json(['message' => 'Not found.'], 404);
|
||||
}
|
||||
|
||||
// Audit-fix C2: per-IP rate-limit. Endpoint secret-gated, но защищаем
|
||||
// от flood даже с валидным secret (DoS-guard). Лимит с запасом для
|
||||
// легитимного stream'а лидов от crm.bp-gr.ru.
|
||||
$rateKey = 'supplier-webhook:'.($request->ip() ?? 'unknown');
|
||||
if (RateLimiter::tooManyAttempts($rateKey, self::RATE_LIMIT_PER_MINUTE)) {
|
||||
$retryAfter = RateLimiter::availableIn($rateKey);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Превышен лимит запросов.',
|
||||
'retry_after' => $retryAfter,
|
||||
], 429)->header('Retry-After', (string) $retryAfter);
|
||||
}
|
||||
RateLimiter::hit($rateKey, 60);
|
||||
|
||||
// Plan 2.6 fix #iii: timestamp partition guard. Партиции deals месячные
|
||||
// (deals_2026_MM); time за пределами текущего месяца → INSERT CRASH
|
||||
// "no partition of relation deals found for row" в RouteSupplierLeadJob.
|
||||
|
||||
@@ -117,19 +117,17 @@ class WebhookReceiveController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* HMAC-обязательность. Audit-fix B3: если ключ отсутствует в БД — default
|
||||
* TRUE (HMAC обязателен по умолчанию). Отключить можно только явной
|
||||
* установкой webhook_hmac_required=false. Неизвестное значение → fail-secure
|
||||
* (HMAC требуется).
|
||||
* HMAC-обязательность. Если ключ отсутствует в БД — default false
|
||||
* (backward-compat для существующих интеграций).
|
||||
*/
|
||||
private function isHmacRequired(): bool
|
||||
{
|
||||
$setting = SystemSetting::find('webhook_hmac_required');
|
||||
if ($setting === null) {
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
return ! in_array($setting->value, ['false', '0'], true);
|
||||
return in_array($setting->value, ['true', '1'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\OutboundWebhookSubscription;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Настройки исходящего webhook'а тенанта (audit D4/D5/J5).
|
||||
* Endpoints под auth:sanctum + tenant.
|
||||
*
|
||||
* Одна подписка-ряд на тенанта. Секрет генерируется при создании и
|
||||
* показывается ОДИН раз (в БД — bcrypt secret_hash + secret_prefix).
|
||||
*
|
||||
* test(): MVP делает unsigned connectivity-проверку (реальный POST на
|
||||
* target_url, отчёт по HTTP-статусу). HMAC-подписанная доставка событий —
|
||||
* отдельный пост-MVP эпик (outbound-pipeline пока не построен).
|
||||
*/
|
||||
class WebhookSettingsController extends Controller
|
||||
{
|
||||
private const SECRET_PREFIX = 'whsec_';
|
||||
|
||||
/** @var list<string> События по умолчанию для новой подписки. */
|
||||
private const DEFAULT_EVENTS = ['deal.created', 'deal.status_changed'];
|
||||
|
||||
public function show(Request $request): JsonResponse
|
||||
{
|
||||
$sub = $this->currentSubscription($request);
|
||||
|
||||
if ($sub === null) {
|
||||
return response()->json(['data' => null]);
|
||||
}
|
||||
|
||||
return response()->json(['data' => [
|
||||
'target_url' => $sub->target_url,
|
||||
'secret_prefix' => $sub->secret_prefix,
|
||||
'events' => $sub->events,
|
||||
'is_active' => $sub->is_active,
|
||||
]]);
|
||||
}
|
||||
|
||||
public function update(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'target_url' => ['required', 'string', 'url', 'max:2048', 'starts_with:https://'],
|
||||
]);
|
||||
|
||||
$sub = $this->currentSubscription($request);
|
||||
$plainSecret = null;
|
||||
|
||||
if ($sub === null) {
|
||||
$plainSecret = self::SECRET_PREFIX.Str::random(40);
|
||||
$sub = OutboundWebhookSubscription::query()->create([
|
||||
'tenant_id' => (int) $request->user()->tenant_id,
|
||||
'user_id' => (int) $request->user()->id,
|
||||
'name' => 'Webhook',
|
||||
'target_url' => $validated['target_url'],
|
||||
'secret_hash' => Hash::make($plainSecret),
|
||||
'secret_prefix' => substr($plainSecret, 0, 10),
|
||||
'events' => self::DEFAULT_EVENTS,
|
||||
'is_active' => true,
|
||||
]);
|
||||
} else {
|
||||
$sub->update(['target_url' => $validated['target_url']]);
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'target_url' => $sub->target_url,
|
||||
'secret_prefix' => $sub->secret_prefix,
|
||||
'events' => $sub->events,
|
||||
'is_active' => $sub->is_active,
|
||||
];
|
||||
if ($plainSecret !== null) {
|
||||
$payload['secret'] = $plainSecret;
|
||||
}
|
||||
|
||||
return response()->json(['data' => $payload]);
|
||||
}
|
||||
|
||||
public function test(Request $request): JsonResponse
|
||||
{
|
||||
$sub = $this->currentSubscription($request);
|
||||
|
||||
if ($sub === null) {
|
||||
return response()->json([
|
||||
'message' => 'Сначала сохраните URL webhook.',
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$testPayload = [
|
||||
'event' => 'webhook.test',
|
||||
'sent_at' => now()->toIso8601String(),
|
||||
'message' => 'Тестовая доставка webhook от Лидерра.',
|
||||
];
|
||||
|
||||
// MVP: unsigned connectivity-проверка. SSRF-харднинг (блок приватных
|
||||
// IP) — пост-MVP security-review; URL уже ограничен https:// валидацией.
|
||||
try {
|
||||
$response = Http::timeout(10)
|
||||
->withHeaders(['X-Webhook-Event' => 'webhook.test'])
|
||||
->post($sub->target_url, $testPayload);
|
||||
|
||||
return response()->json([
|
||||
'ok' => $response->successful(),
|
||||
'status' => $response->status(),
|
||||
'message' => $response->successful()
|
||||
? "Тестовый запрос доставлен (HTTP {$response->status()})."
|
||||
: "Endpoint ответил HTTP {$response->status()}.",
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'status' => null,
|
||||
'message' => 'Не удалось доставить тестовый запрос: '.$e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function currentSubscription(Request $request): ?OutboundWebhookSubscription
|
||||
{
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
|
||||
// Defense-in-depth: явный where даже при RLS — в тестах PG superuser BYPASSRLS.
|
||||
return OutboundWebhookSubscription::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Concerns;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Резолв saas_admin_users.id для audit-trail на MVP (saas-admin SSO ⏸ Б-1).
|
||||
*
|
||||
* Берёт admin_user_id из request-параметра; при отсутствии валидного —
|
||||
* создаёт/переиспользует системный стаб-аккаунт (не loginable, is_active=false),
|
||||
* чтобы соблюсти NOT NULL + FK на saas_admin_users в saas_admin_audit_log.
|
||||
*
|
||||
* Паттерн ранее дублировался в AdminPricingTiersController /
|
||||
* AdminSystemSettingsController; новый код использует этот трейт.
|
||||
*/
|
||||
trait ResolvesAdminUserId
|
||||
{
|
||||
protected function resolveAdminUserId(Request $request, string $stubEmail, string $stubName): 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', $stubEmail)->value('id');
|
||||
if ($existingId !== null) {
|
||||
return (int) $existingId;
|
||||
}
|
||||
|
||||
return (int) DB::table('saas_admin_users')->insertGetId([
|
||||
'email' => $stubEmail,
|
||||
'full_name' => $stubName,
|
||||
'password_hash' => '$2y$04$system-stub-not-loginable',
|
||||
'role' => 'super_admin',
|
||||
'is_active' => false,
|
||||
'sso_provider' => 'local',
|
||||
'is_break_glass' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Гейт SaaS-admin зоны (/api/admin/*) — audit-находка J2.
|
||||
*
|
||||
* СТАБ (Sprint 3F): полноценная авторизация saas-admin требует Yandex 360
|
||||
* SSO-входа, который гейтится Б-1 (регистрация ООО) + DO-4. До их закрытия
|
||||
* реального механизма аутентификации нет.
|
||||
*
|
||||
* Поведение стаба:
|
||||
* - dev / testing (local, testing) → пропускаем. Admin-панель работает на
|
||||
* dev; admin_user_id передаётся параметром (трейт ResolvesAdminUserId).
|
||||
* - прочие окружения (production / staging) → fail-closed 503: зона
|
||||
* закрыта до подключения реального SSO. Явный 503 лучше, чем тихо
|
||||
* открытый /api/admin/* в проде.
|
||||
*
|
||||
* TODO (после Б-1 + DO-4): заменить на проверку Yandex 360 SSO-сессии
|
||||
* saas-admin (отдельный guard) + роль (compliance и т.п. где требуется).
|
||||
*/
|
||||
class EnsureSaasAdmin
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! app()->environment('local', 'testing')) {
|
||||
abort(503, 'SaaS-admin авторизация не настроена (ожидает Б-1 + DO-4).');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -66,10 +66,7 @@ class SetTenantContext
|
||||
}
|
||||
}
|
||||
|
||||
// Audit-fix A3: X-Tenant-Id принимается ТОЛЬКО на dev/testing. На prod
|
||||
// заголовок игнорируется — иначе на любом роуте с `tenant`, но без
|
||||
// auth-middleware возможен спуфинг тенанта произвольным значением.
|
||||
if (app()->environment('local', 'testing') && $request->hasHeader('X-Tenant-Id')) {
|
||||
if ($request->hasHeader('X-Tenant-Id')) {
|
||||
$headerValue = $request->header('X-Tenant-Id');
|
||||
if (is_string($headerValue) && ctype_digit($headerValue)) {
|
||||
return (int) $headerValue;
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
<?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.');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* Валидация ручного маппинга неизвестных статусов воронки (§6.4 wizard).
|
||||
*/
|
||||
class ResolveUnknownStatusesRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'mappings' => ['required', 'array', 'min:1'],
|
||||
'mappings.*.status_ru' => ['required', 'string', 'max:100'],
|
||||
'mappings.*.slug' => ['required', 'string', Rule::exists('lead_statuses', 'slug')],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* Валидация загрузки CSV-файла импорта (ТЗ §6.2).
|
||||
*/
|
||||
class StoreImportRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
// mimes csv,txt — экспорт crm.bp-gr.ru отдаётся как text/csv или text/plain.
|
||||
'file' => ['required', 'file', 'mimes:csv,txt', 'max:10240'],
|
||||
'dry_run' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<?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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
<?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(),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Mail\ImportCompletedNotification;
|
||||
use App\Models\ImportLog;
|
||||
use App\Models\User;
|
||||
use App\Services\Import\CsvLeadsParser;
|
||||
use App\Services\Import\HistoricalImportService;
|
||||
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\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Асинхронная обработка CSV-импорта исторических лидов (ТЗ §6.6).
|
||||
*
|
||||
* Жизненный цикл import_log: pending → processing → done | failed.
|
||||
* RLS: каждый доступ к БД задаёт SET LOCAL app.current_tenant_id (воркер
|
||||
* вне middleware-контекста — паритет с ProcessWebhookJob).
|
||||
*/
|
||||
class ImportLeadsJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public int $timeout = 600;
|
||||
|
||||
public function __construct(
|
||||
public int $importLogId,
|
||||
public int $tenantId,
|
||||
) {}
|
||||
|
||||
public function handle(HistoricalImportService $service, CsvLeadsParser $parser): void
|
||||
{
|
||||
$log = $this->loadLog();
|
||||
if ($log === null) {
|
||||
Log::error('import.log_not_found', ['import_log_id' => $this->importLogId]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->updateLog($log->id, ['status' => 'processing', 'started_at' => now()]);
|
||||
|
||||
try {
|
||||
if (! Storage::disk('local')->exists($log->file_path)) {
|
||||
throw new RuntimeException("Файл импорта не найден: {$log->file_path}");
|
||||
}
|
||||
|
||||
$content = (string) Storage::disk('local')->get($log->file_path);
|
||||
$parsed = $parser->parse($content);
|
||||
|
||||
$result = $service->import($this->tenantId, $log->user_id, $log, $parsed->rows);
|
||||
|
||||
$this->updateLog($log->id, [
|
||||
'status' => 'done',
|
||||
'rows_total' => count($parsed->rows) + count($parsed->errors),
|
||||
'rows_added' => $result->added,
|
||||
'rows_updated' => $result->updated,
|
||||
'rows_skipped' => count($parsed->errors) + $result->skipped,
|
||||
'unknown_statuses_count' => count($result->unknownStatuses),
|
||||
'finished_at' => now(),
|
||||
]);
|
||||
|
||||
$this->notify($log->user_id, 'done');
|
||||
} catch (Throwable $e) {
|
||||
Log::error('import.job_failed', ['import_log_id' => $log->id, 'error' => $e->getMessage()]);
|
||||
$this->updateLog($log->id, [
|
||||
'status' => 'failed',
|
||||
'error_message' => $e->getMessage(),
|
||||
'finished_at' => now(),
|
||||
]);
|
||||
$this->notify($log->user_id, 'failed');
|
||||
}
|
||||
}
|
||||
|
||||
private function loadLog(): ?ImportLog
|
||||
{
|
||||
return DB::transaction(function (): ?ImportLog {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenantId);
|
||||
|
||||
return ImportLog::query()->find($this->importLogId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
private function updateLog(int $logId, array $attributes): void
|
||||
{
|
||||
DB::transaction(function () use ($logId, $attributes): void {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenantId);
|
||||
ImportLog::query()->whereKey($logId)->update($attributes);
|
||||
});
|
||||
}
|
||||
|
||||
private function notify(int $userId, string $outcome): void
|
||||
{
|
||||
$log = $this->loadLog();
|
||||
$user = DB::transaction(function () use ($userId): ?User {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenantId);
|
||||
|
||||
return User::query()->find($userId);
|
||||
});
|
||||
|
||||
if ($log === null || $user === null || $user->email === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Mail::to($user->email)->send(new ImportCompletedNotification($log, $outcome));
|
||||
} catch (Throwable $e) {
|
||||
// Отказ почтового канала не должен валить успешный импорт.
|
||||
Log::warning('import.mail_failed', ['import_log_id' => $log->id, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Финальный callback после исчерпания ретраев ($tries=1).
|
||||
*/
|
||||
public function failed(Throwable $e): void
|
||||
{
|
||||
$this->updateLog($this->importLogId, [
|
||||
'status' => 'failed',
|
||||
'error_message' => $e->getMessage(),
|
||||
'finished_at' => now(),
|
||||
]);
|
||||
|
||||
Log::error('import.job_failed_permanently', [
|
||||
'import_log_id' => $this->importLogId,
|
||||
'exception' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\ImportLog;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Уведомление о завершении CSV-импорта исторических лидов (ТЗ §6.6).
|
||||
*/
|
||||
class ImportCompletedNotification extends Mailable
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
/**
|
||||
* @param string $outcome 'done' | 'failed'
|
||||
*/
|
||||
public function __construct(
|
||||
public ImportLog $log,
|
||||
public string $outcome,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
$subject = $this->outcome === 'done'
|
||||
? 'Импорт данных завершён — Лидерра'
|
||||
: 'Импорт данных не удался — Лидерра';
|
||||
|
||||
return new Envelope(subject: $subject);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
markdown: 'mail.import-completed',
|
||||
with: [
|
||||
'log' => $this->log,
|
||||
'outcome' => $this->outcome,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\ApiKeyFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* API-ключ тенанта (таблица api_keys). Tenant-aware, RLS на уровне БД.
|
||||
*
|
||||
* key_hash — bcrypt-хэш; оригинал ключа показывается ОДИН раз при генерации
|
||||
* (ApiKeyController::regenerate). key_prefix (10 символов) — для отображения
|
||||
* в UI. Таблица имеет только created_at (без updated_at).
|
||||
*
|
||||
* @mixin IdeHelperApiKey
|
||||
*/
|
||||
class ApiKey extends Model
|
||||
{
|
||||
/** @use HasFactory<ApiKeyFactory> */
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'user_id',
|
||||
'name',
|
||||
'key_hash',
|
||||
'key_prefix',
|
||||
'scopes',
|
||||
'last_used_at',
|
||||
'last_used_ip',
|
||||
'expires_at',
|
||||
'is_active',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected $hidden = ['key_hash'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'scopes' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
'last_used_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Tenant, $this> */
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
/** @return BelongsTo<User, $this> */
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\BalanceTransactionFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
@@ -21,9 +19,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
*/
|
||||
class BalanceTransaction extends Model
|
||||
{
|
||||
/** @use HasFactory<BalanceTransactionFactory> */
|
||||
use HasFactory;
|
||||
|
||||
public const TYPE_TRIAL_BONUS = 'trial_bonus';
|
||||
|
||||
public const TYPE_TOPUP = 'topup';
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\ImportLogFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Журнал CSV-импорта (schema §6.7, Sprint 4).
|
||||
*
|
||||
* Tenant-aware модель с RLS: tenant_isolation по current_setting('app.current_tenant_id').
|
||||
* Sprint 4 enrichment: entity_type / source_system / mapping_config / unknown_statuses_count / dry_run.
|
||||
*
|
||||
* @mixin IdeHelperImportLog
|
||||
*/
|
||||
class ImportLog extends Model
|
||||
{
|
||||
/** @use HasFactory<ImportLogFactory> */
|
||||
use HasFactory;
|
||||
|
||||
public const UPDATED_AT = null;
|
||||
|
||||
public const CREATED_AT = null;
|
||||
|
||||
protected $table = 'import_log';
|
||||
|
||||
/** Зеркало DB DEFAULT'ов: Laravel не читает их из БД после INSERT без refresh(). */
|
||||
protected $attributes = [
|
||||
'status' => 'pending',
|
||||
'entity_type' => 'leads',
|
||||
'source_system' => 'crm.bp-gr.ru',
|
||||
'dry_run' => false,
|
||||
'unknown_statuses_count' => 0,
|
||||
'rows_total' => 0,
|
||||
'rows_added' => 0,
|
||||
'rows_updated' => 0,
|
||||
'rows_skipped' => 0,
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'user_id',
|
||||
'filename',
|
||||
'file_path',
|
||||
'rows_total',
|
||||
'rows_added',
|
||||
'rows_updated',
|
||||
'rows_skipped',
|
||||
'status',
|
||||
'error_message',
|
||||
'started_at',
|
||||
'finished_at',
|
||||
'entity_type',
|
||||
'source_system',
|
||||
'mapping_config',
|
||||
'unknown_statuses_count',
|
||||
'dry_run',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => 'integer',
|
||||
'user_id' => 'integer',
|
||||
'rows_total' => 'integer',
|
||||
'rows_added' => 'integer',
|
||||
'rows_updated' => 'integer',
|
||||
'rows_skipped' => 'integer',
|
||||
'unknown_statuses_count' => 'integer',
|
||||
'dry_run' => 'boolean',
|
||||
'mapping_config' => 'array',
|
||||
'started_at' => 'datetime',
|
||||
'finished_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Tenant, $this> */
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
/** @return BelongsTo<User, $this> */
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\ImportUnknownStatusFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Неизвестный статус воронки из CSV-импорта (schema §6.4, Sprint 4 H1).
|
||||
*
|
||||
* Tenant-aware модель с RLS. UNIQUE (tenant_id, status_ru): повторный импорт
|
||||
* инкрементит occurrences и переиспользует ранее проставленный mapped_to_slug.
|
||||
*
|
||||
* @mixin IdeHelperImportUnknownStatus
|
||||
*/
|
||||
class ImportUnknownStatus extends Model
|
||||
{
|
||||
/** @use HasFactory<ImportUnknownStatusFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'import_log_id',
|
||||
'status_ru',
|
||||
'occurrences',
|
||||
'mapped_to_slug',
|
||||
'resolved_at',
|
||||
'resolved_by',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => 'integer',
|
||||
'import_log_id' => 'integer',
|
||||
'occurrences' => 'integer',
|
||||
'resolved_by' => 'integer',
|
||||
'resolved_at' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Незамапленные статусы (mapped_to_slug IS NULL) — вход для wizard'а §6.6.
|
||||
*
|
||||
* @param Builder<ImportUnknownStatus> $query
|
||||
* @return Builder<ImportUnknownStatus>
|
||||
*/
|
||||
public function scopeUnresolved(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNull('mapped_to_slug');
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Tenant, $this> */
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\OutboundWebhookSubscriptionFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Исходящая webhook-подписка тенанта (таблица outbound_webhook_subscriptions).
|
||||
*
|
||||
* Tenant-aware, RLS на уровне БД.
|
||||
*
|
||||
* secret_hash — bcrypt-хэш; оригинал секрета показывается ОДИН раз при
|
||||
* создании. events — JSONB-массив, CHECK требует ≥1 элемента.
|
||||
*
|
||||
* NB: outbound-доставка событий (подписанные webhook'и) — пост-MVP; пока
|
||||
* подписка хранит URL + секрет, а WebhookSettingsController::test делает
|
||||
* unsigned connectivity-проверку.
|
||||
*
|
||||
* @mixin IdeHelperOutboundWebhookSubscription
|
||||
*/
|
||||
class OutboundWebhookSubscription extends Model
|
||||
{
|
||||
/** @use HasFactory<OutboundWebhookSubscriptionFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'user_id',
|
||||
'name',
|
||||
'target_url',
|
||||
'secret_hash',
|
||||
'secret_prefix',
|
||||
'events',
|
||||
'custom_headers',
|
||||
'is_active',
|
||||
'paused_at',
|
||||
];
|
||||
|
||||
protected $hidden = ['secret_hash'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'events' => 'array',
|
||||
'custom_headers' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
'consecutive_failures' => 'integer',
|
||||
'paused_at' => 'datetime',
|
||||
'last_delivery_at' => 'datetime',
|
||||
'last_failure_at' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Tenant, $this> */
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
/** @return BelongsTo<User, $this> */
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,11 @@ 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;
|
||||
|
||||
/**
|
||||
* Проект (лид-канал) внутри тенанта.
|
||||
@@ -38,8 +36,6 @@ 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',
|
||||
@@ -78,8 +74,6 @@ 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',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -132,113 +126,4 @@ 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Тарифный план SaaS-портала (каталог tariff_plans).
|
||||
*
|
||||
* Сидится из db/schema.sql (4 стартовых плана: start/basic/pro/enterprise).
|
||||
* Read-mostly: редактируется только админкой SaaS. Tenant ссылается через
|
||||
* tenants.current_tariff_id (см. Tenant::tariff()).
|
||||
*
|
||||
* Источник: db/schema.sql §20.2.1, table `tariff_plans`.
|
||||
*
|
||||
* @mixin IdeHelperTariffPlan
|
||||
*/
|
||||
class TariffPlan extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'code',
|
||||
'name',
|
||||
'description',
|
||||
'billing_model',
|
||||
'price_per_lead',
|
||||
'price_monthly',
|
||||
'included_leads',
|
||||
'limits',
|
||||
'features',
|
||||
'trial_bonus_leads',
|
||||
'is_active',
|
||||
'is_public',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'price_per_lead' => 'decimal:2',
|
||||
'price_monthly' => 'decimal:2',
|
||||
'included_leads' => 'integer',
|
||||
'limits' => 'array',
|
||||
'features' => 'array',
|
||||
'trial_bonus_leads' => 'integer',
|
||||
'is_active' => 'boolean',
|
||||
'is_public' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ namespace App\Models;
|
||||
use Database\Factories\TenantFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
@@ -45,7 +44,6 @@ class Tenant extends Model
|
||||
'desired_daily_numbers',
|
||||
'delivered_in_month',
|
||||
'api_key_limit',
|
||||
'limits',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
@@ -59,8 +57,6 @@ class Tenant extends Model
|
||||
'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',
|
||||
@@ -81,10 +77,4 @@ class Tenant extends Model
|
||||
{
|
||||
return $this->hasMany(Project::class);
|
||||
}
|
||||
|
||||
/** @return BelongsTo<TariffPlan, $this> */
|
||||
public function tariff(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(TariffPlan::class, 'current_tariff_id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\Tenant;
|
||||
|
||||
/**
|
||||
* Сервис пополнения рублёвого баланса тенанта (audit E1).
|
||||
*
|
||||
* MVP-stub: кредитует tenants.balance_rub немедленно и пишет строку
|
||||
* balance_transactions(type='topup'). Реальная оплата через платёжный
|
||||
* шлюз — post-Б-1 (требует реквизитов ООО), здесь НЕ интегрирована.
|
||||
*
|
||||
* Контракт: вызывается ВНУТРИ транзакции (middleware `tenant` оборачивает
|
||||
* HTTP-запрос в DB-транзакцию). lockForUpdate на строке tenant защищает от
|
||||
* lost-update при конкурентных topup/charge.
|
||||
*
|
||||
* balance_transactions защищена hash-chain триггером (BEFORE INSERT
|
||||
* audit_chain_hash) — log_hash заполняется автоматически. UPDATE/DELETE
|
||||
* на таблице запрещены триггером audit_block_mutation, поэтому каждое
|
||||
* пополнение — отдельная append-only строка; существующие не меняются.
|
||||
*/
|
||||
final class BillingTopupService
|
||||
{
|
||||
/**
|
||||
* Пополнить рублёвый баланс тенанта.
|
||||
*
|
||||
* @param int $tenantId ID тенанта.
|
||||
* @param string $amountRub Сумма пополнения, DECIMAL-строка («100.00»).
|
||||
* @param int|null $userId Кто инициировал (NULL — системное).
|
||||
* @return BalanceTransaction Созданная append-only строка ledger'а.
|
||||
*/
|
||||
public function topup(int $tenantId, string $amountRub, ?int $userId): BalanceTransaction
|
||||
{
|
||||
/** @var Tenant $tenant */
|
||||
$tenant = Tenant::query()->lockForUpdate()->findOrFail($tenantId);
|
||||
|
||||
// bcadd — DECIMAL-точность, НЕ PHP float (паттерн LedgerService).
|
||||
$newBalanceRub = bcadd((string) $tenant->balance_rub, $amountRub, 2);
|
||||
|
||||
// Eloquent decimal:2 cast сохраняет bcmath-строку без потери точности
|
||||
// при save() (в отличие от decrement(), который требует float|int —
|
||||
// именно поэтому LedgerService использует raw DB::table()->update();
|
||||
// здесь же присваивание уже посчитанной строки через модель безопасно).
|
||||
$tenant->balance_rub = $newBalanceRub;
|
||||
$tenant->save();
|
||||
|
||||
return BalanceTransaction::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'type' => BalanceTransaction::TYPE_TOPUP,
|
||||
'amount_rub' => $amountRub,
|
||||
'amount_leads' => 0,
|
||||
'balance_rub_after' => $newBalanceRub,
|
||||
'balance_leads_after' => (int) $tenant->balance_leads,
|
||||
'description' => 'Пополнение баланса',
|
||||
'user_id' => $userId,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Import;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Парсер CSV-выгрузки лидов из crm.bp-gr.ru (ТЗ §6.2/§6.3).
|
||||
*
|
||||
* Формат: UTF-8 с BOM, разделитель — запятая, дата `Y/m/d H:i:s`,
|
||||
* телефон `7XXXXXXXXXX`. Заголовок:
|
||||
* id,Проект,Тег проекта,Телефон,Создано,Напоминание,Комментарий,Состояние,Имя
|
||||
*
|
||||
* Невалидные строки не роняют парсинг — собираются в errors[].
|
||||
* Файл целиком загружается в память (MVP: ожидаемый объём — единицы тысяч строк).
|
||||
*/
|
||||
final class CsvLeadsParser
|
||||
{
|
||||
private const EXPECTED_COLUMNS = 9;
|
||||
|
||||
private const DATE_FORMAT = 'Y/m/d H:i:s';
|
||||
|
||||
public function parse(string $content): CsvParseResult
|
||||
{
|
||||
// Срезаем UTF-8 BOM.
|
||||
if (str_starts_with($content, "\xEF\xBB\xBF")) {
|
||||
$content = substr($content, 3);
|
||||
}
|
||||
|
||||
$lines = preg_split('/\r\n|\r|\n/', trim($content)) ?: [];
|
||||
$rows = [];
|
||||
$errors = [];
|
||||
|
||||
// Строка 1 — заголовок, пропускаем. dataLine — абсолютный номер строки файла (заголовок = 1).
|
||||
foreach (array_slice($lines, 1) as $index => $rawLine) {
|
||||
$dataLine = $index + 2; // +2: пропущен заголовок (index 0 → строка 2)
|
||||
|
||||
if (trim($rawLine) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$cells = str_getcsv($rawLine);
|
||||
|
||||
if (count($cells) < self::EXPECTED_COLUMNS) {
|
||||
$errors[] = ['line' => $dataLine, 'message' => 'Ожидалось '.self::EXPECTED_COLUMNS.' колонок, получено '.count($cells)];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$parsed = $this->parseRow($cells, $dataLine, $errors);
|
||||
if ($parsed !== null) {
|
||||
$rows[] = $parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return new CsvParseResult($rows, $errors);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $cells
|
||||
* @param array<int, array{line: int, message: string}> $errors
|
||||
*/
|
||||
private function parseRow(array $cells, int $dataLine, array &$errors): ?ParsedLeadRow
|
||||
{
|
||||
[$id, $project, $tag, $phone, $createdAt, $reminder, $comment, $status, $name] = $cells;
|
||||
|
||||
$phone = trim($phone);
|
||||
if (preg_match('/^7\d{10}$/', $phone) !== 1) {
|
||||
$errors[] = ['line' => $dataLine, 'message' => "Невалидный телефон: '{$phone}'"];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$receivedAt = $this->parseDate($createdAt);
|
||||
if ($receivedAt === null) {
|
||||
$errors[] = ['line' => $dataLine, 'message' => "Невалидная дата 'Создано': '{$createdAt}'"];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$reminderAt = trim($reminder) === '' ? null : $this->parseDate($reminder);
|
||||
if (trim($reminder) !== '' && $reminderAt === null) {
|
||||
$errors[] = ['line' => $dataLine, 'message' => "Невалидная дата 'Напоминание': '{$reminder}'"];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$status = trim($status);
|
||||
if ($status === '') {
|
||||
$errors[] = ['line' => $dataLine, 'message' => 'Пустое поле «Состояние»'];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Префикс B[123]_ из названия проекта срезается (паритет с ProcessWebhookJob).
|
||||
$projectName = (string) preg_replace('/^B[123]_/', '', trim($project));
|
||||
if ($projectName === '') {
|
||||
$errors[] = ['line' => $dataLine, 'message' => 'Пустое название проекта'];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ParsedLeadRow(
|
||||
sourceCrmId: (int) trim($id),
|
||||
projectName: $projectName,
|
||||
projectTag: trim($tag) === '' ? null : trim($tag),
|
||||
phone: $phone,
|
||||
receivedAt: $receivedAt,
|
||||
reminderAt: $reminderAt,
|
||||
comment: trim($comment) === '' ? null : trim($comment),
|
||||
statusRu: $status,
|
||||
contactName: trim($name) === '' ? null : trim($name),
|
||||
);
|
||||
}
|
||||
|
||||
private function parseDate(string $value): ?CarbonImmutable
|
||||
{
|
||||
try {
|
||||
$date = CarbonImmutable::createFromFormat(self::DATE_FORMAT, trim($value));
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// createFromFormat возвращает false при несовпадении формата.
|
||||
return $date instanceof CarbonImmutable ? $date : null;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Import;
|
||||
|
||||
/**
|
||||
* Результат парсинга CSV: валидные строки + ошибки по номеру строки.
|
||||
*/
|
||||
final readonly class CsvParseResult
|
||||
{
|
||||
/**
|
||||
* @param array<int, ParsedLeadRow> $rows
|
||||
* @param array<int, array{line: int, message: string}> $errors
|
||||
*/
|
||||
public function __construct(
|
||||
public array $rows,
|
||||
public array $errors,
|
||||
) {}
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Import;
|
||||
|
||||
use App\Models\Deal;
|
||||
use App\Models\ImportLog;
|
||||
use App\Models\ImportUnknownStatus;
|
||||
use App\Models\Project;
|
||||
use App\Models\Reminder;
|
||||
use App\Services\MonthlyPartitionManager;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Оркестрация исторической миграции лидов из CSV crm.bp-gr.ru (ТЗ §6).
|
||||
*
|
||||
* Идемпотентность — через webhook_dedup_keys (та же advisory-lock логика, что
|
||||
* ProcessWebhookJob). Баланс НЕ списывается: исторические данные не являются
|
||||
* новыми лидами (ТЗ §6.5) — фиксируется одна транзакция типа historical_import.
|
||||
*/
|
||||
final class HistoricalImportService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MonthlyPartitionManager $partitions,
|
||||
private readonly StatusRuToSlugMapper $statusMapper,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<int, ParsedLeadRow> $rows
|
||||
*/
|
||||
public function import(int $tenantId, int $userId, ImportLog $log, array $rows): ImportResult
|
||||
{
|
||||
$dryRun = $log->dry_run;
|
||||
|
||||
if ($rows === []) {
|
||||
return new ImportResult(0, 0, 0, [], []);
|
||||
}
|
||||
|
||||
// Партиции deals под исторический диапазон дат CSV (один раз заранее).
|
||||
if (! $dryRun) {
|
||||
$dates = array_map(fn (ParsedLeadRow $r) => $r->receivedAt, $rows);
|
||||
$this->partitions->ensureRange(
|
||||
'deals',
|
||||
min($dates),
|
||||
max($dates),
|
||||
);
|
||||
}
|
||||
|
||||
// Tenant-резолвленные переопределения неизвестных статусов.
|
||||
$overrides = $this->loadStatusOverrides($tenantId);
|
||||
|
||||
$added = 0;
|
||||
$updated = 0;
|
||||
$skipped = 0;
|
||||
$unknown = [];
|
||||
$errors = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$slug = $this->resolveStatus($row->statusRu, $overrides, $unknown);
|
||||
|
||||
if ($dryRun) {
|
||||
$added++; // проекция: для dry-run не различаем add/update
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$wasCreated = $this->upsertRow($tenantId, $userId, $row, $slug);
|
||||
$wasCreated ? $added++ : $updated++;
|
||||
} catch (Throwable $e) {
|
||||
$skipped++;
|
||||
$errors[] = ['source_crm_id' => $row->sourceCrmId, 'message' => $e->getMessage()];
|
||||
Log::warning('import.row_failed', ['source_crm_id' => $row->sourceCrmId, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
$this->persistUnknownStatuses($tenantId, $log->id, $unknown);
|
||||
$this->recordHistoricalTransaction($tenantId, $added + $updated);
|
||||
}
|
||||
|
||||
return new ImportResult($added, $updated, $skipped, $unknown, $errors);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string> status_ru => slug (только resolved)
|
||||
*/
|
||||
private function loadStatusOverrides(int $tenantId): array
|
||||
{
|
||||
return DB::transaction(function () use ($tenantId): array {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
// Явный where(tenant_id) — defense-in-depth: queue worker на prod
|
||||
// (crm_supplier_worker) — BYPASSRLS, SET LOCAL не фильтрует
|
||||
// (00_create_roles.sql §5). Без фильтра — cross-tenant утечка маппинга.
|
||||
return ImportUnknownStatus::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNotNull('mapped_to_slug')
|
||||
->pluck('mapped_to_slug', 'status_ru')
|
||||
->all();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Маппит статус: каноническая таблица §6.4 → tenant-override → fallback 'new'.
|
||||
* Неизвестный статус инкрементит счётчик в $unknown по ссылке.
|
||||
*
|
||||
* @param array<string, string> $overrides
|
||||
* @param array<string, int> $unknown
|
||||
*/
|
||||
private function resolveStatus(string $statusRu, array $overrides, array &$unknown): string
|
||||
{
|
||||
$slug = $this->statusMapper->toSlug($statusRu);
|
||||
if ($slug !== null) {
|
||||
return $slug;
|
||||
}
|
||||
|
||||
$key = trim($statusRu);
|
||||
if (isset($overrides[$key])) {
|
||||
return $overrides[$key];
|
||||
}
|
||||
|
||||
$unknown[$key] = ($unknown[$key] ?? 0) + 1;
|
||||
|
||||
return 'new';
|
||||
}
|
||||
|
||||
/**
|
||||
* Идемпотентный upsert одной строки в собственной транзакции.
|
||||
* Возвращает true — создана новая сделка, false — обновлена существующая.
|
||||
*/
|
||||
private function upsertRow(int $tenantId, int $userId, ParsedLeadRow $row, string $slug): bool
|
||||
{
|
||||
return DB::transaction(function () use ($tenantId, $userId, $row, $slug): bool {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
$project = Project::firstOrCreate(
|
||||
['tenant_id' => $tenantId, 'name' => $row->projectName],
|
||||
['tag' => $row->projectTag, 'type' => 'import'],
|
||||
);
|
||||
|
||||
// advisory lock (tenant_id, source_crm_id) — сериализует upsert (§6.5).
|
||||
$lockKey = (($tenantId & 0xFFFFFFFF) << 32) | ($row->sourceCrmId & 0xFFFFFFFF);
|
||||
DB::statement('SELECT pg_advisory_xact_lock(?)', [$lockKey]);
|
||||
|
||||
$existing = DB::selectOne(
|
||||
'SELECT deal_id, deal_received_at FROM webhook_dedup_keys WHERE tenant_id = ? AND source_crm_id = ?',
|
||||
[$tenantId, $row->sourceCrmId],
|
||||
);
|
||||
|
||||
if ($existing !== null) {
|
||||
$deal = Deal::query()
|
||||
->where('id', $existing->deal_id)
|
||||
->where('received_at', $existing->deal_received_at)
|
||||
->firstOrFail();
|
||||
// §6.5 стадия 3a: для исторической миграции status перезаписывается.
|
||||
$deal->update([
|
||||
'status' => $slug,
|
||||
'contact_name' => $row->contactName,
|
||||
'comment' => $row->comment,
|
||||
]);
|
||||
$this->syncReminder($tenantId, $userId, $deal, $row);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$deal = Deal::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'source_crm_id' => $row->sourceCrmId,
|
||||
'project_id' => $project->id,
|
||||
'phone' => $row->phone,
|
||||
'status' => $slug,
|
||||
'contact_name' => $row->contactName,
|
||||
'comment' => $row->comment,
|
||||
'received_at' => $row->receivedAt,
|
||||
]);
|
||||
|
||||
DB::table('webhook_dedup_keys')->insert([
|
||||
'tenant_id' => $tenantId,
|
||||
'source_crm_id' => $row->sourceCrmId,
|
||||
'deal_id' => $deal->id,
|
||||
'deal_received_at' => $deal->received_at,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$this->syncReminder($tenantId, $userId, $deal, $row);
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт reminders-строку для непустого «Напоминание» (ТЗ §6.3 — поле
|
||||
* deals.reminder_at удалено в v8.3, заменено таблицей reminders).
|
||||
* Идемпотентно: не дублирует напоминание при повторном импорте.
|
||||
*/
|
||||
private function syncReminder(int $tenantId, int $userId, Deal $deal, ParsedLeadRow $row): void
|
||||
{
|
||||
if ($row->reminderAt === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$exists = Reminder::query()
|
||||
->where('deal_id', $deal->id)
|
||||
->where('remind_at', $row->reminderAt)
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
Reminder::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'deal_id' => $deal->id,
|
||||
'text' => 'Импортировано из crm.bp-gr.ru',
|
||||
'remind_at' => $row->reminderAt,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* upsert import_unknown_statuses: инкремент occurrences, маппинг не трогаем.
|
||||
*
|
||||
* @param array<string, int> $unknown
|
||||
*/
|
||||
private function persistUnknownStatuses(int $tenantId, int $importLogId, array $unknown): void
|
||||
{
|
||||
if ($unknown === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($tenantId, $importLogId, $unknown): void {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
foreach ($unknown as $statusRu => $count) {
|
||||
// Явный where(tenant_id) — defense-in-depth под BYPASSRLS queue worker
|
||||
// (00_create_roles.sql §5): иначе increment мог бы попасть в строку
|
||||
// чужого тенанта с тем же status_ru.
|
||||
$existing = ImportUnknownStatus::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('status_ru', $statusRu)
|
||||
->first();
|
||||
|
||||
if ($existing !== null) {
|
||||
$existing->increment('occurrences', $count);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
ImportUnknownStatus::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'import_log_id' => $importLogId,
|
||||
'status_ru' => $statusRu,
|
||||
'occurrences' => $count,
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Одна информационная транзакция historical_import (баланс не меняется, ТЗ §6.5).
|
||||
*/
|
||||
private function recordHistoricalTransaction(int $tenantId, int $count): void
|
||||
{
|
||||
if ($count === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($tenantId, $count): void {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
DB::table('balance_transactions')->insert([
|
||||
'tenant_id' => $tenantId,
|
||||
'type' => 'historical_import',
|
||||
'amount_rub' => 0,
|
||||
'amount_leads' => 0,
|
||||
'description' => "Импортировано {$count} исторических сделок (баланс не списан)",
|
||||
'created_at' => now(),
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Import;
|
||||
|
||||
/**
|
||||
* Итог импорта одного файла.
|
||||
*/
|
||||
final readonly class ImportResult
|
||||
{
|
||||
/**
|
||||
* @param array<string, int> $unknownStatuses статус_ru => количество вхождений
|
||||
* @param array<int, array{source_crm_id: int, message: string}> $errors ошибки upsert'а по строке (идентификатор — source_crm_id)
|
||||
*/
|
||||
public function __construct(
|
||||
public int $added,
|
||||
public int $updated,
|
||||
public int $skipped,
|
||||
public array $unknownStatuses,
|
||||
public array $errors,
|
||||
) {}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Import;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
/**
|
||||
* Одна валидная строка CSV-импорта лидов (ТЗ §6.3).
|
||||
*/
|
||||
final readonly class ParsedLeadRow
|
||||
{
|
||||
public function __construct(
|
||||
public int $sourceCrmId,
|
||||
public string $projectName,
|
||||
public ?string $projectTag,
|
||||
public string $phone,
|
||||
public CarbonImmutable $receivedAt,
|
||||
public ?CarbonImmutable $reminderAt,
|
||||
public ?string $comment,
|
||||
public string $statusRu,
|
||||
public ?string $contactName,
|
||||
) {}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Import;
|
||||
|
||||
/**
|
||||
* Маппинг русских названий статусов воронки в slug (ТЗ §6.4).
|
||||
*
|
||||
* Чистый сервис без зависимостей. Tenant-специфичные переопределения
|
||||
* неизвестных статусов накладываются вызывающим кодом (HistoricalImportService).
|
||||
*/
|
||||
class StatusRuToSlugMapper
|
||||
{
|
||||
/** @var array<string, string> Канонический маппинг ТЗ §6.4 (14 статусов воронки). */
|
||||
private const STATUS_RU_TO_SLUG = [
|
||||
'Новые' => 'new',
|
||||
'Просмотрено' => 'viewed',
|
||||
'Проработан' => 'worked',
|
||||
'База' => 'base',
|
||||
'Недозвон' => 'missed',
|
||||
'Переговоры' => 'negotiations',
|
||||
'Ожидаем оплаты' => 'waiting_payment',
|
||||
'Партнерка' => 'partnership',
|
||||
'Оплачено' => 'paid',
|
||||
'Закрыто и не реализовано' => 'closed',
|
||||
'Тест драйв' => 'test_drive',
|
||||
'Горячий' => 'hot',
|
||||
'На замену' => 'replacement',
|
||||
'Конечный недозвон' => 'final_missed',
|
||||
];
|
||||
|
||||
/**
|
||||
* Возвращает slug или null, если статус не входит в каноническую таблицу.
|
||||
*/
|
||||
public function toSlug(string $statusRu): ?string
|
||||
{
|
||||
return self::STATUS_RU_TO_SLUG[trim($statusRu)] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Полная каноническая таблица — для UI wizard'а (показать варианты).
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function map(): array
|
||||
{
|
||||
return self::STATUS_RU_TO_SLUG;
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Создаёт месячные RANGE-партиции для таблиц, партиционированных по received_at.
|
||||
*
|
||||
* Native-замена pg_partman (расширение недоступно на Windows-стеке без сборки
|
||||
* из исходников). Идемпотентна: партиция, которая уже есть, пропускается.
|
||||
*
|
||||
* Используется:
|
||||
* - cron `partitions:create-months` — N месяцев вперёд;
|
||||
* - HistoricalImportService — под исторический диапазон дат CSV.
|
||||
*/
|
||||
class MonthlyPartitionManager
|
||||
{
|
||||
/** @var array<int, string> Таблицы, партиционированные по received_at помесячно. */
|
||||
public const PARTITIONED_TABLES = ['deals', 'supplier_lead_costs'];
|
||||
|
||||
/**
|
||||
* Гарантирует наличие месячных партиций таблицы для всех месяцев,
|
||||
* пересекающих [$from, $to] включительно.
|
||||
*
|
||||
* @return int Сколько партиций реально создано (0 — все уже были).
|
||||
*/
|
||||
public function ensureRange(string $table, CarbonInterface $from, CarbonInterface $to): int
|
||||
{
|
||||
if (! in_array($table, self::PARTITIONED_TABLES, true)) {
|
||||
throw new InvalidArgumentException("Таблица '{$table}' не партиционирована помесячно");
|
||||
}
|
||||
|
||||
$month = $from->copy()->startOfMonth();
|
||||
$last = $to->copy()->startOfMonth();
|
||||
$created = 0;
|
||||
|
||||
while ($month->lessThanOrEqualTo($last)) {
|
||||
$created += $this->ensureMonth($table, $month) ? 1 : 0;
|
||||
$month = $month->addMonth();
|
||||
}
|
||||
|
||||
return $created;
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт одну месячную партицию. Возвращает true, если партиция создана,
|
||||
* false — если уже существовала.
|
||||
*/
|
||||
public function ensureMonth(string $table, CarbonInterface $monthStart): bool
|
||||
{
|
||||
if (! in_array($table, self::PARTITIONED_TABLES, true)) {
|
||||
throw new InvalidArgumentException("Таблица '{$table}' не партиционирована помесячно");
|
||||
}
|
||||
|
||||
$start = $monthStart->copy()->startOfMonth();
|
||||
$end = $start->copy()->addMonth();
|
||||
$partition = sprintf('%s_%s', $table, $start->format('Y_m'));
|
||||
|
||||
$exists = DB::selectOne(
|
||||
"SELECT 1 AS ok FROM pg_class WHERE relname = ? AND relkind = 'r'",
|
||||
[$partition],
|
||||
);
|
||||
|
||||
if ($exists !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
DB::statement(sprintf(
|
||||
"CREATE TABLE %s PARTITION OF %s FOR VALUES FROM ('%s') TO ('%s')",
|
||||
$partition,
|
||||
$table,
|
||||
$start->format('Y-m-d'),
|
||||
$end->format('Y-m-d'),
|
||||
));
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Reports\Providers;
|
||||
|
||||
use App\Models\ReportJob;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* billing_summary — агрегат balance_transactions по типу операции (audit F1).
|
||||
*
|
||||
* Группировка по balance_transactions.type; count + SUM(amount_rub). Тип
|
||||
* операции переводится в человекочитаемую метку. parameters: date_from,
|
||||
* date_to (Y-m-d) — фильтр по created_at.
|
||||
*
|
||||
* RLS-обёртка SET LOCAL app.current_tenant_id (balance_transactions имеет RLS
|
||||
* tenant_isolation) + явный where('tenant_id') — паттерн BillingController.
|
||||
*/
|
||||
class BillingSummaryProvider implements ReportDataProvider
|
||||
{
|
||||
/** Канон-типы balance_transactions.type → RU-метка (schema §7.6 CHECK). */
|
||||
private const TYPE_LABELS = [
|
||||
'trial_bonus' => 'Стартовый бонус',
|
||||
'topup' => 'Пополнение',
|
||||
'lead_charge' => 'Списание за лиды',
|
||||
'refund' => 'Возврат',
|
||||
'manual_adjustment' => 'Ручная корректировка',
|
||||
'historical_import' => 'Импорт истории',
|
||||
'chargeback_writedown' => 'Chargeback — списание в долг',
|
||||
'chargeback_repayment' => 'Chargeback — погашение долга',
|
||||
];
|
||||
|
||||
public function headers(): array
|
||||
{
|
||||
return ['Тип операции', 'Количество', 'Сумма (₽)'];
|
||||
}
|
||||
|
||||
public function rows(ReportJob $job): array
|
||||
{
|
||||
$params = $job->parameters ?? [];
|
||||
$dateFrom = Carbon::parse($params['date_from'])->startOfDay();
|
||||
$dateTo = Carbon::parse($params['date_to'])->endOfDay();
|
||||
|
||||
return DB::transaction(function () use ($job, $dateFrom, $dateTo): array {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $job->tenant_id);
|
||||
|
||||
$rows = DB::table('balance_transactions')
|
||||
->where('tenant_id', $job->tenant_id)
|
||||
->whereBetween('created_at', [$dateFrom, $dateTo])
|
||||
->groupBy('type')
|
||||
->orderBy('type')
|
||||
->selectRaw('type, COUNT(*) AS cnt, COALESCE(SUM(amount_rub), 0) AS sum_rub')
|
||||
->get();
|
||||
|
||||
return $rows->map(function ($row): array {
|
||||
$label = self::TYPE_LABELS[$row->type] ?? (string) $row->type;
|
||||
|
||||
return [$label, (int) $row->cnt, (string) $row->sum_rub];
|
||||
})->all();
|
||||
});
|
||||
}
|
||||
|
||||
public function slug(): string
|
||||
{
|
||||
return 'billing';
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Reports\Providers;
|
||||
|
||||
use App\Models\ReportJob;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* managers_summary — агрегат сделок по менеджерам за период (audit F1).
|
||||
*
|
||||
* Группировка по deals.manager_id; неназначенные (manager_id IS NULL) сводятся
|
||||
* в строку «Не назначен». «Оплачено» = status='paid' (won-статус воронки, как
|
||||
* в DashboardController). Конверсия = paid / total * 100, округление до 0.1.
|
||||
*
|
||||
* parameters: date_from, date_to (Y-m-d). Исключаются soft-deleted
|
||||
* (deleted_at IS NULL) и тестовые (is_test=false) сделки. RLS-обёртка
|
||||
* SET LOCAL app.current_tenant_id — паттерн DealsExportProvider.
|
||||
*/
|
||||
class ManagersSummaryProvider implements ReportDataProvider
|
||||
{
|
||||
public function headers(): array
|
||||
{
|
||||
return ['Менеджер', 'Всего сделок', 'Оплачено', 'Конверсия (%)'];
|
||||
}
|
||||
|
||||
public function rows(ReportJob $job): array
|
||||
{
|
||||
$params = $job->parameters ?? [];
|
||||
$dateFrom = Carbon::parse($params['date_from'])->startOfDay();
|
||||
$dateTo = Carbon::parse($params['date_to'])->endOfDay();
|
||||
|
||||
return DB::transaction(function () use ($job, $dateFrom, $dateTo): array {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $job->tenant_id);
|
||||
|
||||
$rows = DB::table('deals')
|
||||
->leftJoin('users', 'deals.manager_id', '=', 'users.id')
|
||||
->where('deals.tenant_id', $job->tenant_id)
|
||||
->whereNull('deals.deleted_at')
|
||||
->where('deals.is_test', false)
|
||||
->whereBetween('deals.received_at', [$dateFrom, $dateTo])
|
||||
->groupBy('deals.manager_id', 'users.first_name', 'users.last_name', 'users.email')
|
||||
->orderByRaw('COUNT(*) DESC')
|
||||
->orderBy('deals.manager_id')
|
||||
->selectRaw(
|
||||
"deals.manager_id,
|
||||
users.first_name, users.last_name, users.email,
|
||||
COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE deals.status = 'paid') AS paid"
|
||||
)
|
||||
->get();
|
||||
|
||||
return $rows->map(function ($row): array {
|
||||
$name = trim(($row->first_name ?? '').' '.($row->last_name ?? ''));
|
||||
if ($name === '') {
|
||||
$name = (string) ($row->email ?? '');
|
||||
}
|
||||
if ($name === '') {
|
||||
$name = 'Не назначен';
|
||||
}
|
||||
$total = (int) $row->total;
|
||||
$paid = (int) $row->paid;
|
||||
$conversion = $total > 0 ? round($paid / $total * 100, 1) : 0.0;
|
||||
|
||||
return [$name, $total, $paid, $conversion];
|
||||
})->all();
|
||||
});
|
||||
}
|
||||
|
||||
public function slug(): string
|
||||
{
|
||||
return 'managers';
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Reports\Providers;
|
||||
|
||||
use App\Models\ReportJob;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* sources_summary — агрегат сделок по источнику (utm_source) за период (audit F1).
|
||||
*
|
||||
* Группировка по deals.utm_source; сделки без метки (NULL/пусто) сводятся в
|
||||
* строку «Прямые / без метки». «Оплачено» = status='paid'. Конверсия =
|
||||
* paid / total * 100, округление до 0.1.
|
||||
*
|
||||
* parameters: date_from, date_to (Y-m-d). Исключаются soft-deleted и тестовые
|
||||
* сделки. RLS-обёртка SET LOCAL app.current_tenant_id — паттерн DealsExportProvider.
|
||||
*/
|
||||
class SourcesSummaryProvider implements ReportDataProvider
|
||||
{
|
||||
public function headers(): array
|
||||
{
|
||||
return ['Источник', 'Всего сделок', 'Оплачено', 'Конверсия (%)'];
|
||||
}
|
||||
|
||||
public function rows(ReportJob $job): array
|
||||
{
|
||||
$params = $job->parameters ?? [];
|
||||
$dateFrom = Carbon::parse($params['date_from'])->startOfDay();
|
||||
$dateTo = Carbon::parse($params['date_to'])->endOfDay();
|
||||
|
||||
return DB::transaction(function () use ($job, $dateFrom, $dateTo): array {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $job->tenant_id);
|
||||
|
||||
$rows = DB::table('deals')
|
||||
->where('tenant_id', $job->tenant_id)
|
||||
->whereNull('deleted_at')
|
||||
->where('is_test', false)
|
||||
->whereBetween('received_at', [$dateFrom, $dateTo])
|
||||
->groupBy('utm_source')
|
||||
->orderByRaw('COUNT(*) DESC')
|
||||
->orderBy('utm_source')
|
||||
->selectRaw(
|
||||
"utm_source,
|
||||
COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE status = 'paid') AS paid"
|
||||
)
|
||||
->get();
|
||||
|
||||
return $rows->map(function ($row): array {
|
||||
$source = $row->utm_source !== null && trim((string) $row->utm_source) !== ''
|
||||
? (string) $row->utm_source
|
||||
: 'Прямые / без метки';
|
||||
$total = (int) $row->total;
|
||||
$paid = (int) $row->paid;
|
||||
$conversion = $total > 0 ? round($paid / $total * 100, 1) : 0.0;
|
||||
|
||||
return [$source, $total, $paid, $conversion];
|
||||
})->all();
|
||||
});
|
||||
}
|
||||
|
||||
public function slug(): string
|
||||
{
|
||||
return 'sources';
|
||||
}
|
||||
}
|
||||
@@ -10,28 +10,23 @@ use App\Services\Reports\Formatters\JsonFormatter;
|
||||
use App\Services\Reports\Formatters\PdfStubFormatter;
|
||||
use App\Services\Reports\Formatters\ReportFormatter;
|
||||
use App\Services\Reports\Formatters\XlsxFormatter;
|
||||
use App\Services\Reports\Providers\BillingSummaryProvider;
|
||||
use App\Services\Reports\Providers\DealsExportProvider;
|
||||
use App\Services\Reports\Providers\ManagersSummaryProvider;
|
||||
use App\Services\Reports\Providers\ReportDataProvider;
|
||||
use App\Services\Reports\Providers\SourcesSummaryProvider;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Резолвит ReportDataProvider по `type` и ReportFormatter по `format`.
|
||||
*
|
||||
* 4 provider'а (deals_export, managers_summary, sources_summary,
|
||||
* billing_summary) × 4 formatter'а (csv, xlsx, json, pdf). PDF на MVP —
|
||||
* stub: PdfStubFormatter кидает RuntimeException → GenerateReportJob
|
||||
* ловит → failed-job (intended, Post-MVP).
|
||||
* Этап 2 (текущий): 1 provider × 4 formatter = 4 комбинации
|
||||
* (deals_export × csv|xlsx|json|pdf-stub).
|
||||
*
|
||||
* Этап 2b расширит до 4 × 4 = 16 (managers_summary, sources_summary,
|
||||
* billing_summary). Для PDF на MVP — stub, fallback'ит в RuntimeException.
|
||||
*/
|
||||
class ReportGeneratorRegistry
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DealsExportProvider $dealsExport,
|
||||
private readonly ManagersSummaryProvider $managersSummary,
|
||||
private readonly SourcesSummaryProvider $sourcesSummary,
|
||||
private readonly BillingSummaryProvider $billingSummary,
|
||||
private readonly CsvFormatter $csv,
|
||||
private readonly XlsxFormatter $xlsx,
|
||||
private readonly JsonFormatter $json,
|
||||
@@ -42,9 +37,6 @@ class ReportGeneratorRegistry
|
||||
{
|
||||
return match ($type) {
|
||||
'deals_export' => $this->dealsExport,
|
||||
'managers_summary' => $this->managersSummary,
|
||||
'sources_summary' => $this->sourcesSummary,
|
||||
'billing_summary' => $this->billingSummary,
|
||||
default => throw new InvalidArgumentException("Тип отчёта не реализован: {$type}"),
|
||||
};
|
||||
}
|
||||
@@ -62,10 +54,18 @@ class ReportGeneratorRegistry
|
||||
|
||||
public function isSupported(string $type, string $format): bool
|
||||
{
|
||||
// Все 4 типа ReportJob::TYPES реализованы (F1, 2026-05-16).
|
||||
// PDF валидируется, но PdfStubFormatter кидает RuntimeException →
|
||||
// GenerateReportJob ловит → failed-job (intended, Post-MVP).
|
||||
return in_array($type, ReportJob::TYPES, true)
|
||||
&& in_array($format, ReportJob::FORMATS, true);
|
||||
if (! in_array($type, ReportJob::TYPES, true) || ! in_array($format, ReportJob::FORMATS, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Этап 2: только deals_export (этап 2b добавит остальные).
|
||||
$supportedTypes = ['deals_export'];
|
||||
if (! in_array($type, $supportedTypes, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// PDF — stub: validates, но генерация даёт failed-job (intended).
|
||||
// Считаем «поддерживается» — пусть GenerateReportJob сам catch'ит RuntimeException.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ 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;
|
||||
@@ -30,66 +29,12 @@ use Illuminate\Support\Facades\Cache;
|
||||
* Авторизация: PHPSESSID cookie + X-CSRF-Token header (Redis cache 'supplier:session').
|
||||
* На 401/403 — single retry через dispatch_sync(RefreshSupplierSessionJob).
|
||||
*/
|
||||
class SupplierPortalClient
|
||||
final 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>
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\EnsureSaasAdmin;
|
||||
use App\Http\Middleware\SetTenantContext;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
@@ -19,7 +18,6 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
|
||||
$middleware->alias([
|
||||
'tenant' => SetTenantContext::class,
|
||||
'saas-admin' => EnsureSaasAdmin::class,
|
||||
]);
|
||||
|
||||
// Webhook receive endpoint (POST /api/webhook/{token}) не должен требовать
|
||||
|
||||
@@ -61,11 +61,9 @@
|
||||
],
|
||||
"pint": "@php vendor/bin/pint",
|
||||
"pint:test": "@php vendor/bin/pint --test",
|
||||
"test:parallel": "@php vendor/bin/pest --parallel --recreate-databases",
|
||||
"stan": "@php vendor/bin/phpstan analyse --memory-limit=512M",
|
||||
"mutation": "@php vendor/bin/infection --threads=2 --min-msi=50",
|
||||
"audit-offline": "@composer audit --locked",
|
||||
"demo:seed": "@php artisan db:seed --class=DemoSeeder --force",
|
||||
"ide-helper": [
|
||||
"@php artisan ide-helper:generate",
|
||||
"@php artisan ide-helper:meta"
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\ApiKey;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<ApiKey>
|
||||
*/
|
||||
class ApiKeyFactory extends Factory
|
||||
{
|
||||
protected $model = ApiKey::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => Tenant::factory(),
|
||||
'user_id' => User::factory(),
|
||||
'name' => 'API-ключ',
|
||||
'key_hash' => Hash::make(Str::random(48)),
|
||||
'key_prefix' => 'lpkapi_'.Str::lower(Str::random(3)),
|
||||
'scopes' => ['read'],
|
||||
'last_used_at' => null,
|
||||
'expires_at' => now()->addYear(),
|
||||
'is_active' => true,
|
||||
'created_at' => now(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<BalanceTransaction>
|
||||
*/
|
||||
class BalanceTransactionFactory extends Factory
|
||||
{
|
||||
protected $model = BalanceTransaction::class;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => Tenant::factory(),
|
||||
'type' => BalanceTransaction::TYPE_TOPUP,
|
||||
'amount_rub' => '100.00',
|
||||
'amount_leads' => 0,
|
||||
'balance_rub_after' => '100.00',
|
||||
'balance_leads_after' => 0,
|
||||
'description' => 'Тестовая транзакция',
|
||||
'created_at' => now(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\ImportLog;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/** @extends Factory<ImportLog> */
|
||||
class ImportLogFactory extends Factory
|
||||
{
|
||||
protected $model = ImportLog::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => Tenant::factory(),
|
||||
'user_id' => User::factory(),
|
||||
'filename' => 'leads-export.csv',
|
||||
'file_path' => 'imports/1/'.$this->faker->uuid().'.csv',
|
||||
'status' => 'pending',
|
||||
'entity_type' => 'leads',
|
||||
'source_system' => 'crm.bp-gr.ru',
|
||||
'dry_run' => false,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\ImportUnknownStatus;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/** @extends Factory<ImportUnknownStatus> */
|
||||
class ImportUnknownStatusFactory extends Factory
|
||||
{
|
||||
protected $model = ImportUnknownStatus::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => Tenant::factory(),
|
||||
'status_ru' => $this->faker->unique()->word(),
|
||||
'occurrences' => $this->faker->numberBetween(1, 20),
|
||||
'mapped_to_slug' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\OutboundWebhookSubscription;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<OutboundWebhookSubscription>
|
||||
*/
|
||||
class OutboundWebhookSubscriptionFactory extends Factory
|
||||
{
|
||||
protected $model = OutboundWebhookSubscription::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => Tenant::factory(),
|
||||
'user_id' => User::factory(),
|
||||
'name' => 'Webhook',
|
||||
'target_url' => 'https://'.fake()->domainName().'/webhook',
|
||||
'secret_hash' => Hash::make('whsec_'.Str::random(40)),
|
||||
'secret_prefix' => 'whsec_'.Str::lower(Str::random(4)),
|
||||
'events' => ['deal.created', 'deal.status_changed'],
|
||||
'is_active' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ class ProjectFactory extends Factory
|
||||
{
|
||||
return [
|
||||
'tenant_id' => Tenant::factory(),
|
||||
'name' => fake()->unique()->words(3, true),
|
||||
'name' => fake()->words(3, true),
|
||||
'type' => 'webhook',
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 10,
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
<?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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,35 +0,0 @@
|
||||
<?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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,77 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Sprint 4 (H1+H2) — историческая миграция лидов §6.
|
||||
*
|
||||
* H1: новая таблица import_unknown_statuses (tenant-level resolved mappings).
|
||||
* H2: enrichment import_log — +5 колонок.
|
||||
*
|
||||
* Guard'ы: migrate:fresh грузит schema.sql v8.21+ (где delta уже есть) до миграций,
|
||||
* поэтому каждый кусок применяется только при отсутствии.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
foreach ([
|
||||
'entity_type' => "ALTER TABLE import_log ADD COLUMN entity_type VARCHAR(20) NOT NULL DEFAULT 'leads' CHECK (entity_type IN ('leads','projects'))",
|
||||
'source_system' => "ALTER TABLE import_log ADD COLUMN source_system VARCHAR(50) NOT NULL DEFAULT 'crm.bp-gr.ru'",
|
||||
'mapping_config' => 'ALTER TABLE import_log ADD COLUMN mapping_config JSONB',
|
||||
'unknown_statuses_count' => 'ALTER TABLE import_log ADD COLUMN unknown_statuses_count INT NOT NULL DEFAULT 0',
|
||||
'dry_run' => 'ALTER TABLE import_log ADD COLUMN dry_run BOOLEAN NOT NULL DEFAULT FALSE',
|
||||
] as $column => $ddl) {
|
||||
if (! Schema::hasColumn('import_log', $column)) {
|
||||
DB::statement($ddl);
|
||||
}
|
||||
}
|
||||
|
||||
if (! Schema::hasTable('import_unknown_statuses')) {
|
||||
DB::statement(<<<'SQL'
|
||||
CREATE TABLE import_unknown_statuses (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
import_log_id BIGINT REFERENCES import_log(id) ON DELETE SET NULL,
|
||||
status_ru VARCHAR(100) NOT NULL,
|
||||
occurrences INT NOT NULL DEFAULT 0,
|
||||
mapped_to_slug VARCHAR(50) REFERENCES lead_statuses(slug),
|
||||
resolved_at TIMESTAMPTZ,
|
||||
resolved_by BIGINT REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ,
|
||||
UNIQUE (tenant_id, status_ru)
|
||||
)
|
||||
SQL);
|
||||
|
||||
DB::statement(
|
||||
'CREATE INDEX idx_import_unknown_statuses_unresolved
|
||||
ON import_unknown_statuses (tenant_id) WHERE mapped_to_slug IS NULL'
|
||||
);
|
||||
|
||||
DB::statement('ALTER TABLE import_unknown_statuses ENABLE ROW LEVEL SECURITY');
|
||||
DB::statement(
|
||||
"CREATE POLICY tenant_isolation ON import_unknown_statuses
|
||||
USING (tenant_id = current_setting('app.current_tenant_id')::bigint)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// down() не симметричен: на проекте rollback применяется только после
|
||||
// migrate:fresh (см. add_archived_at_to_projects). Для отката v8.21 —
|
||||
// отдельный schema-bump, не эта миграция.
|
||||
DB::statement('DROP TABLE IF EXISTS import_unknown_statuses');
|
||||
|
||||
foreach (['entity_type', 'source_system', 'mapping_config', 'unknown_statuses_count', 'dry_run'] as $column) {
|
||||
if (Schema::hasColumn('import_log', $column)) {
|
||||
Schema::table('import_log', fn ($table) => $table->dropColumn($column));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -12,16 +12,13 @@ class DatabaseSeeder extends Seeder
|
||||
/**
|
||||
* Seed the application's database.
|
||||
*
|
||||
* PricingTierSeeder runs in all environments (prod нуждается в 7-tier
|
||||
* config bootstrap'е). DemoSeeder — только local+testing: создаёт demo
|
||||
* tenant + admin@demo.local + 3 проекта + ~14 demo сделок для UI smoke.
|
||||
* 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
|
||||
{
|
||||
$this->call(PricingTierSeeder::class);
|
||||
|
||||
if (app()->environment('local', 'testing')) {
|
||||
$this->call(DemoSeeder::class);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
<?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
|
||||
{
|
||||
// DemoSeeder создаёт демо-данные и НЕ должен исполняться в production.
|
||||
// DatabaseSeeder вызывает его только в local/testing — этот guard
|
||||
// дополнительно защищает прямой вызов `db:seed --class=DemoSeeder`
|
||||
// (в т.ч. через `composer demo:seed`).
|
||||
if (app()->isProduction()) {
|
||||
$this->command->warn('DemoSeeder пропущен: запрещён в production.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
-25271
File diff suppressed because it is too large
Load Diff
@@ -1,50 +0,0 @@
|
||||
{
|
||||
"$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
|
||||
}
|
||||
+3
-7
@@ -1,14 +1,10 @@
|
||||
import type { KnipConfig } from 'knip';
|
||||
|
||||
const config: KnipConfig = {
|
||||
entry: [
|
||||
'resources/js/app.ts',
|
||||
'resources/js/router/index.ts',
|
||||
'histoire.config.ts',
|
||||
'resources/js/histoire.setup.ts',
|
||||
],
|
||||
entry: ['resources/js/app.ts', 'resources/js/router/index.ts'],
|
||||
project: ['resources/js/**/*.{ts,vue}'],
|
||||
ignore: ['**/*.story.vue'],
|
||||
ignore: ['**/*.story.vue', 'tests/**'],
|
||||
ignoreDependencies: ['@vue/test-utils', 'jsdom', 'vitest'],
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
Generated
+138
-12
@@ -4,9 +4,6 @@
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"lucide-vue-next": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@histoire/plugin-vue": "^1.0.0-beta.1",
|
||||
@@ -15,6 +12,7 @@
|
||||
"@vue/eslint-config-typescript": "^14.7.0",
|
||||
"@vue/test-utils": "^2.4.10",
|
||||
"axios": "^1.16.0",
|
||||
"concurrently": "^9.0.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
@@ -4318,6 +4316,36 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk/node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/change-case": {
|
||||
"version": "5.4.4",
|
||||
"resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz",
|
||||
@@ -4363,6 +4391,21 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.1",
|
||||
"wrap-ansi": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -4424,6 +4467,31 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "9.2.1",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
|
||||
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "4.1.2",
|
||||
"rxjs": "7.8.2",
|
||||
"shell-quote": "1.8.3",
|
||||
"supports-color": "8.1.1",
|
||||
"tree-kill": "1.2.2",
|
||||
"yargs": "17.7.2"
|
||||
},
|
||||
"bin": {
|
||||
"conc": "dist/bin/concurrently.js",
|
||||
"concurrently": "dist/bin/concurrently.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/config-chain": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
|
||||
@@ -6899,15 +6967,6 @@
|
||||
"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",
|
||||
@@ -7904,6 +7963,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
@@ -9133,6 +9202,16 @@
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/tree-kill": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
||||
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"tree-kill": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/trim-lines": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
|
||||
@@ -10012,6 +10091,24 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs": {
|
||||
"name": "wrap-ansi",
|
||||
"version": "7.0.0",
|
||||
@@ -10113,6 +10210,35 @@
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "17.7.2",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^8.0.1",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"require-directory": "^2.1.1",
|
||||
"string-width": "^4.2.3",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^21.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "21.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
+1
-4
@@ -11,7 +11,6 @@
|
||||
"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"
|
||||
@@ -24,6 +23,7 @@
|
||||
"@vue/eslint-config-typescript": "^14.7.0",
|
||||
"@vue/test-utils": "^2.4.10",
|
||||
"axios": "^1.16.0",
|
||||
"concurrently": "^9.0.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
@@ -45,8 +45,5 @@
|
||||
"vue-tsc": "^3.2.8",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"vuetify": "^3.12.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-vue-next": "^1.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
+40
-502
@@ -96,18 +96,6 @@ parameters:
|
||||
count: 1
|
||||
path: app/Services/NotificationService.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\\BalanceTransactionFactory\:\:definition\(\) should be compatible with return type \(array\<model property of App\\Models\\BalanceTransaction, mixed\>\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\<App\\Models\\BalanceTransaction\>\:\:definition\(\)$#'
|
||||
identifier: method.childReturnType
|
||||
count: 1
|
||||
path: database/factories/BalanceTransactionFactory.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
|
||||
@@ -180,54 +168,12 @@ parameters:
|
||||
count: 3
|
||||
path: tests/Feature/Admin/AdminSuppliersControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/AdminBillingActionsTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:patchJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 7
|
||||
path: tests/Feature/AdminBillingActionsTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 3
|
||||
path: tests/Feature/AdminBillingActionsTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 10
|
||||
path: tests/Feature/AdminBillingIndexTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$adminId\.$#'
|
||||
identifier: property.notFound
|
||||
count: 4
|
||||
path: tests/Feature/AdminIncidentRknNotifyTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 4
|
||||
path: tests/Feature/AdminIncidentRknNotifyTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$adminId\.$#'
|
||||
identifier: property.notFound
|
||||
count: 5
|
||||
path: tests/Feature/AdminIncidentShowTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 5
|
||||
path: tests/Feature/AdminIncidentShowTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$adminId\.$#'
|
||||
identifier: property.notFound
|
||||
@@ -282,42 +228,6 @@ 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
|
||||
count: 7
|
||||
path: tests/Feature/ApiKeyControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 5
|
||||
path: tests/Feature/ApiKeyControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/ApiKeyControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 4
|
||||
path: tests/Feature/ApiKeyControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 3
|
||||
path: tests/Feature/ApiKeyControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
@@ -529,58 +439,16 @@ parameters:
|
||||
path: tests/Feature/Auth/TwoFactorTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
message: '#^Access to an undefined property App\\Models\\LeadCharge\:\:\$charge_source\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Auth/UpdateProfileTest.php
|
||||
count: 2
|
||||
path: tests/Feature/Billing/LedgerServiceTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$delivered_in_month\.$#'
|
||||
identifier: property.notFound
|
||||
count: 4
|
||||
path: tests/Feature/Auth/UpdateProfileTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Auth/UpdateProfileTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Auth/UpdateProfileTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:patchJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 6
|
||||
path: tests/Feature/Auth/UpdateProfileTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 12
|
||||
path: tests/Feature/Billing/BillingOverviewControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Billing/BillingOverviewControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Billing/BillingOverviewControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 18
|
||||
path: tests/Feature/Billing/BillingOverviewControllerTest.php
|
||||
count: 3
|
||||
path: tests/Feature/Billing/LedgerServiceTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$ledger\.$#'
|
||||
@@ -636,36 +504,6 @@ parameters:
|
||||
count: 1
|
||||
path: tests/Feature/Billing/TenantChargesControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 5
|
||||
path: tests/Feature/Billing/TopupControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Billing/TopupControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Billing/TopupControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:assertDatabaseHas\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Billing/TopupControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 8
|
||||
path: tests/Feature/Billing/TopupControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -678,34 +516,22 @@ 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: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 9
|
||||
path: tests/Feature/DashboardSummaryTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 15
|
||||
path: tests/Feature/DealCreateTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/DealCreateTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
count: 37
|
||||
path: tests/Feature/DealCreateTest.php
|
||||
|
||||
-
|
||||
@@ -729,19 +555,7 @@ parameters:
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 11
|
||||
path: tests/Feature/DealDestroyTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/DealDestroyTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
count: 20
|
||||
path: tests/Feature/DealDestroyTest.php
|
||||
|
||||
-
|
||||
@@ -783,25 +597,13 @@ parameters:
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 30
|
||||
path: tests/Feature/DealIndexTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/DealIndexTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
count: 50
|
||||
path: tests/Feature/DealIndexTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 21
|
||||
count: 22
|
||||
path: tests/Feature/DealIndexTest.php
|
||||
|
||||
-
|
||||
@@ -819,19 +621,7 @@ parameters:
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 9
|
||||
path: tests/Feature/DealRestoreTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/DealRestoreTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
count: 18
|
||||
path: tests/Feature/DealRestoreTest.php
|
||||
|
||||
-
|
||||
@@ -867,31 +657,19 @@ parameters:
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#'
|
||||
identifier: property.notFound
|
||||
count: 6
|
||||
count: 7
|
||||
path: tests/Feature/DealShowTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 13
|
||||
path: tests/Feature/DealShowTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/DealShowTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
count: 20
|
||||
path: tests/Feature/DealShowTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 7
|
||||
count: 8
|
||||
path: tests/Feature/DealShowTest.php
|
||||
|
||||
-
|
||||
@@ -909,19 +687,7 @@ parameters:
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 7
|
||||
path: tests/Feature/DealTransitionTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/DealTransitionTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
count: 12
|
||||
path: tests/Feature/DealTransitionTest.php
|
||||
|
||||
-
|
||||
@@ -945,31 +711,19 @@ parameters:
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#'
|
||||
identifier: property.notFound
|
||||
count: 9
|
||||
count: 10
|
||||
path: tests/Feature/DealUpdateTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 15
|
||||
path: tests/Feature/DealUpdateTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/DealUpdateTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
count: 24
|
||||
path: tests/Feature/DealUpdateTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:patchJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 9
|
||||
count: 10
|
||||
path: tests/Feature/DealUpdateTest.php
|
||||
|
||||
-
|
||||
@@ -1008,90 +762,6 @@ parameters:
|
||||
count: 17
|
||||
path: tests/Feature/ImpersonationTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$service\.$#'
|
||||
identifier: property.notFound
|
||||
count: 10
|
||||
path: tests/Feature/Import/HistoricalImportServiceTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 23
|
||||
path: tests/Feature/Import/HistoricalImportServiceTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 20
|
||||
path: tests/Feature/Import/HistoricalImportServiceTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Import/ImportCompletedNotificationTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Import/ImportCompletedNotificationTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 12
|
||||
path: tests/Feature/Import/ImportControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Import/ImportControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Import/ImportControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 4
|
||||
path: tests/Feature/Import/ImportControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 5
|
||||
path: tests/Feature/Import/ImportControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 9
|
||||
path: tests/Feature/Import/ImportLeadsJobTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 4
|
||||
path: tests/Feature/Import/ImportLeadsJobTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 4
|
||||
path: tests/Feature/Import/ImportModelsTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Import/ImportModelsTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -1119,19 +789,13 @@ parameters:
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 16
|
||||
path: tests/Feature/LookupsTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 3
|
||||
count: 20
|
||||
path: tests/Feature/LookupsTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 4
|
||||
count: 5
|
||||
path: tests/Feature/LookupsTest.php
|
||||
|
||||
-
|
||||
@@ -1188,36 +852,6 @@ 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: '#^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
|
||||
@@ -1269,49 +903,7 @@ parameters:
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 9
|
||||
path: tests/Feature/Reports/BillingSummaryProviderTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#'
|
||||
identifier: property.notFound
|
||||
count: 8
|
||||
path: tests/Feature/Reports/ManagersSummaryProviderTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 14
|
||||
path: tests/Feature/Reports/ManagersSummaryProviderTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 20
|
||||
path: tests/Feature/Reports/ReportDownloadTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 14
|
||||
path: tests/Feature/Reports/ReportDownloadTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Reports/ReportDownloadTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:get\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 8
|
||||
path: tests/Feature/Reports/ReportDownloadTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 31
|
||||
count: 25
|
||||
path: tests/Feature/Reports/ReportJobControllerTest.php
|
||||
|
||||
-
|
||||
@@ -1335,7 +927,7 @@ parameters:
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 14
|
||||
count: 12
|
||||
path: tests/Feature/Reports/ReportJobControllerTest.php
|
||||
|
||||
-
|
||||
@@ -1380,18 +972,6 @@ parameters:
|
||||
count: 12
|
||||
path: tests/Feature/Reports/ReportLifecycleTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#'
|
||||
identifier: property.notFound
|
||||
count: 8
|
||||
path: tests/Feature/Reports/SourcesSummaryProviderTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 12
|
||||
path: tests/Feature/Reports/SourcesSummaryProviderTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project1Id\.$#'
|
||||
identifier: property.notFound
|
||||
@@ -1416,18 +996,6 @@ parameters:
|
||||
count: 5
|
||||
path: tests/Feature/RlsSmokeTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$app\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/SaasAdminMiddlewareTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
path: tests/Feature/SaasAdminMiddlewareTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#'
|
||||
identifier: property.notFound
|
||||
@@ -1488,6 +1056,18 @@ 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
|
||||
@@ -1518,48 +1098,6 @@ parameters:
|
||||
count: 14
|
||||
path: tests/Feature/WebhookReceiveTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\Mixins\\Expectation\<mixed\>\:\:\$not\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/WebhookSettingsControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 7
|
||||
path: tests/Feature/WebhookSettingsControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 5
|
||||
path: tests/Feature/WebhookSettingsControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/WebhookSettingsControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 4
|
||||
path: tests/Feature/WebhookSettingsControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 3
|
||||
path: tests/Feature/WebhookSettingsControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:putJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 3
|
||||
path: tests/Feature/WebhookSettingsControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$resolver\.$#'
|
||||
identifier: property.notFound
|
||||
|
||||
@@ -23,54 +23,3 @@ 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;
|
||||
}
|
||||
|
||||
/*
|
||||
* A11y rescan 2026-05-14: Vuetify tonal-variant default text color produces
|
||||
* 2.0-4.4:1 contrast on ivory page background (#f6f3ec) — below WCAG 2.1 AA
|
||||
* 4.5:1 threshold. Pa11y rescan flagged across /billing /admin/billing
|
||||
* /admin/incidents /admin/system. Fix: darken text color inside .v-alert and
|
||||
* .v-chip tonal variants; also darken .text-warning utility used in count
|
||||
* badges (text-h6 text-warning «5» on ivory was 2.03:1).
|
||||
*/
|
||||
.v-alert--variant-tonal .v-alert__content,
|
||||
.v-alert--variant-tonal .v-alert__content strong,
|
||||
.v-alert--variant-tonal .v-alert__content code {
|
||||
color: #0a0700;
|
||||
}
|
||||
|
||||
.v-chip--variant-tonal.bg-success .v-chip__content,
|
||||
.v-chip--variant-tonal.text-success .v-chip__content {
|
||||
/* deep forest green, ≥4.5:1 on tonal pale-success bg */
|
||||
color: #1f5e3a;
|
||||
}
|
||||
|
||||
.v-chip--variant-tonal.bg-warning .v-chip__content,
|
||||
.v-chip--variant-tonal.text-warning .v-chip__content {
|
||||
/* dark amber, ≥4.5:1 on tonal pale-warning bg + on ivory page bg */
|
||||
color: #6a4504;
|
||||
}
|
||||
|
||||
/*
|
||||
* .text-warning is used both inside chips (covered above) and standalone
|
||||
* (text-h6 count badges on ivory background). Vuetify defines the utility as
|
||||
* `.v-theme--liderraForest .text-warning { color: rgb(var(--v-theme-warning)) !important }`
|
||||
* which has specificity 0,2,0 + !important — plain `.text-warning !important`
|
||||
* (0,1,0) loses on specificity even with !important. Match Vuetify's selector
|
||||
* exactly so our override wins on cascade-order (loaded after Vuetify CSS).
|
||||
*/
|
||||
.v-theme--liderraForest .text-warning,
|
||||
.v-theme--liderraForest.text-warning,
|
||||
.text-warning {
|
||||
color: #6a4504 !important;
|
||||
}
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
/* 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);
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -113,7 +113,7 @@ export interface AdminTenant {
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
interface AdminTenantsStats {
|
||||
export interface AdminTenantsStats {
|
||||
total: number;
|
||||
active: number;
|
||||
trial: number;
|
||||
@@ -182,7 +182,7 @@ export interface ApiTenantActivityEvent {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ApiTenantMetrics {
|
||||
export interface ApiTenantMetrics {
|
||||
leads_today: number;
|
||||
leads_this_week: number;
|
||||
leads_this_month: number;
|
||||
@@ -224,7 +224,7 @@ export interface ApiAdminBillingTenant {
|
||||
chargeback_unrecovered_rub: string;
|
||||
}
|
||||
|
||||
interface ApiAdminBillingSummary {
|
||||
export interface ApiAdminBillingSummary {
|
||||
total_mrr_rub: string;
|
||||
monthly_revenue_rub: string;
|
||||
overdue_count: number;
|
||||
@@ -262,7 +262,7 @@ export interface ApiAdminIncident {
|
||||
rkn_deadline_at: string | null;
|
||||
}
|
||||
|
||||
interface ApiAdminIncidentsSummary {
|
||||
export interface ApiAdminIncidentsSummary {
|
||||
open: number;
|
||||
investigating: number;
|
||||
rkn_pending: number;
|
||||
@@ -331,100 +331,3 @@ export async function updateSystemSetting(
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
// === SaaS-admin → Биллинг: row-actions (Sprint 3D G4) ===
|
||||
|
||||
export interface AdminTariffPlan {
|
||||
id: number;
|
||||
name: string;
|
||||
price_monthly: string;
|
||||
}
|
||||
|
||||
export async function listAdminTariffPlans(): Promise<AdminTariffPlan[]> {
|
||||
const { data } = await apiClient.get<{ plans: AdminTariffPlan[] }>('/api/admin/billing/tariff-plans');
|
||||
return data.plans;
|
||||
}
|
||||
|
||||
export async function updateTenantStatus(
|
||||
id: number,
|
||||
status: 'active' | 'suspended',
|
||||
reason: string,
|
||||
): Promise<{ id: number; status: string }> {
|
||||
await ensureCsrfCookie();
|
||||
const { data } = await apiClient.patch<{ id: number; status: string }>(
|
||||
`/api/admin/billing/tenants/${id}/status`,
|
||||
{ status, reason },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function refundTenant(
|
||||
id: number,
|
||||
amountRub: number,
|
||||
reason: string,
|
||||
): Promise<{ id: number; balance_rub: string; transaction_id: number }> {
|
||||
await ensureCsrfCookie();
|
||||
const { data } = await apiClient.post<{ id: number; balance_rub: string; transaction_id: number }>(
|
||||
`/api/admin/billing/tenants/${id}/refund`,
|
||||
{ amount_rub: amountRub, reason },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function changeTenantTariff(
|
||||
id: number,
|
||||
tariffId: number,
|
||||
reason: string,
|
||||
): Promise<{ id: number; tariff_id: number; tariff_name: string }> {
|
||||
await ensureCsrfCookie();
|
||||
const { data } = await apiClient.patch<{ id: number; tariff_id: number; tariff_name: string }>(
|
||||
`/api/admin/billing/tenants/${id}/tariff`,
|
||||
{ tariff_id: tariffId, reason },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
// === SaaS-admin → Инциденты: detail-view + РКН-notify (Sprint 3D G5/G6) ===
|
||||
|
||||
export interface ApiIncidentAffectedTenant {
|
||||
id: number;
|
||||
organization_name: string;
|
||||
}
|
||||
|
||||
export interface ApiAdminIncidentDetail {
|
||||
id: number;
|
||||
incident_id: string;
|
||||
type: string;
|
||||
severity: 'low' | 'medium' | 'high' | 'critical';
|
||||
summary: string;
|
||||
root_cause: string | null;
|
||||
postmortem_url: string | null;
|
||||
started_at: string;
|
||||
detected_at: string;
|
||||
resolved_at: string | null;
|
||||
status: 'open' | 'investigating' | 'resolved';
|
||||
affected_tenants: ApiIncidentAffectedTenant[];
|
||||
affected_users_count: number | null;
|
||||
notification_sent_at: string | null;
|
||||
rkn_notified: boolean;
|
||||
rkn_notified_at: string | null;
|
||||
rkn_deadline_at: string | null;
|
||||
created_by_admin: string | null;
|
||||
closed_by_admin: string | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export async function getAdminIncidentDetail(id: number): Promise<ApiAdminIncidentDetail> {
|
||||
const { data } = await apiClient.get<{ incident: ApiAdminIncidentDetail }>(`/api/admin/incidents/${id}`);
|
||||
return data.incident;
|
||||
}
|
||||
|
||||
export async function notifyIncidentRkn(id: number): Promise<ApiAdminIncidentDetail> {
|
||||
await ensureCsrfCookie();
|
||||
const { data } = await apiClient.post<{ incident: ApiAdminIncidentDetail }>(
|
||||
`/api/admin/incidents/${id}/rkn-notify`,
|
||||
{},
|
||||
);
|
||||
return data.incident;
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { apiClient, ensureCsrfCookie } from './client';
|
||||
|
||||
/**
|
||||
* API-ключи тенанта (audit D2/D3). Backend: ApiKeyController.
|
||||
* Полный ключ доступен только в ответе regenerateApiKey().
|
||||
*/
|
||||
export interface ApiKeyInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
key_prefix: string;
|
||||
last_used_at: string | null;
|
||||
expires_at: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface RegeneratedApiKey {
|
||||
id: number;
|
||||
name: string;
|
||||
key: string;
|
||||
key_prefix: string;
|
||||
}
|
||||
|
||||
export async function listApiKeys(): Promise<ApiKeyInfo[]> {
|
||||
const { data } = await apiClient.get<{ data: ApiKeyInfo[] }>('/api/api-keys');
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function regenerateApiKey(): Promise<RegeneratedApiKey> {
|
||||
await ensureCsrfCookie();
|
||||
const { data } = await apiClient.post<RegeneratedApiKey>('/api/api-keys/regenerate');
|
||||
return data;
|
||||
}
|
||||
@@ -25,8 +25,6 @@ export interface AuthUser {
|
||||
email: string;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
phone?: string | null;
|
||||
timezone?: string | null;
|
||||
tenant_id: number;
|
||||
totp_enabled: boolean;
|
||||
last_login_at: string | null;
|
||||
@@ -153,16 +151,3 @@ export async function updateNotificationPreferences(payload: UpdateNotificationP
|
||||
const { data } = await apiClient.patch<{ user: AuthUser }>('/api/auth/me/notification-preferences', payload);
|
||||
return data.user;
|
||||
}
|
||||
|
||||
export interface UpdateProfilePayload {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
phone: string | null;
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
export async function updateProfile(payload: UpdateProfilePayload): Promise<AuthUser> {
|
||||
await ensureCsrfCookie();
|
||||
const { data } = await apiClient.patch<{ user: AuthUser }>('/api/auth/me', payload);
|
||||
return data.user;
|
||||
}
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { apiClient, ensureCsrfCookie } from './client';
|
||||
|
||||
/**
|
||||
* API-модуль биллинга (Sprint 2 Plan C).
|
||||
*
|
||||
* Эндпоинты под [auth:sanctum, tenant]: GET wallet/transactions/invoices
|
||||
* (E3), POST topup (E1 — добавляется в Task 5). GET'ы не требуют CSRF-cookie.
|
||||
*/
|
||||
|
||||
/** Тариф в составе ответа GET /api/billing/wallet. */
|
||||
export interface WalletTariff {
|
||||
code: string;
|
||||
name: string;
|
||||
price_monthly: string | null;
|
||||
billing_model: string;
|
||||
features: string[];
|
||||
}
|
||||
|
||||
/** Ответ GET /api/billing/wallet — кошелёк тенанта. */
|
||||
export interface Wallet {
|
||||
balance_rub: string;
|
||||
balance_leads: number;
|
||||
runway_days: number | null;
|
||||
tariff: WalletTariff | null;
|
||||
}
|
||||
|
||||
/** GET /api/billing/wallet — балансы + текущий тариф + runway. */
|
||||
export async function getWallet(): Promise<Wallet> {
|
||||
const { data } = await apiClient.get<Wallet>('/api/billing/wallet');
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Строка истории транзакций (GET /api/billing/transactions). */
|
||||
export interface BillingTransaction {
|
||||
id: number;
|
||||
code: string;
|
||||
type: string;
|
||||
description: string | null;
|
||||
amount_rub: string;
|
||||
amount_leads: number;
|
||||
balance_rub_after: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** Пагинированный ответ GET /api/billing/transactions. */
|
||||
export interface TransactionsPage {
|
||||
data: BillingTransaction[];
|
||||
meta: { current_page: number; last_page: number; total: number; per_page: number };
|
||||
}
|
||||
|
||||
/** Счёт тенанта (GET /api/billing/invoices). */
|
||||
export interface BillingInvoice {
|
||||
id: number;
|
||||
invoice_number: string;
|
||||
amount_total: string;
|
||||
status: string;
|
||||
issued_at: string;
|
||||
has_pdf: boolean;
|
||||
}
|
||||
|
||||
/** GET /api/billing/transactions — пагинированная история транзакций. */
|
||||
export async function getTransactions(params: { page?: number; type?: string }): Promise<TransactionsPage> {
|
||||
const { data } = await apiClient.get<TransactionsPage>('/api/billing/transactions', { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
/** GET /api/billing/invoices — счета тенанта (real-but-empty до Б-1). */
|
||||
export async function getInvoices(): Promise<{ data: BillingInvoice[] }> {
|
||||
const { data } = await apiClient.get<{ data: BillingInvoice[] }>('/api/billing/invoices');
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Результат POST /api/billing/topup. */
|
||||
export interface TopupResult {
|
||||
transaction: {
|
||||
id: number;
|
||||
type: string;
|
||||
amount_rub: string;
|
||||
balance_rub_after: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
balance_rub: string;
|
||||
}
|
||||
|
||||
/** POST /api/billing/topup — пополнить рублёвый баланс (MVP-stub). */
|
||||
export async function topup(amountRub: number): Promise<TopupResult> {
|
||||
await ensureCsrfCookie();
|
||||
const { data } = await apiClient.post<TopupResult>('/api/billing/topup', { amount_rub: amountRub });
|
||||
return data;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { apiClient } from './client';
|
||||
|
||||
/**
|
||||
* API-клиент дашборда (audit C1/J3). Эндпоинт GET /api/dashboard/summary.
|
||||
* На MVP без auth — tenant_id параметром (на prod возьмётся из middleware).
|
||||
*/
|
||||
|
||||
export type DeltaDir = 'up' | 'down' | 'neutral';
|
||||
export type DashboardRange = 'today' | '7d' | '30d';
|
||||
|
||||
export interface DashboardSummary {
|
||||
range: string;
|
||||
leads_received: { value: number; delta_pct: number; delta_dir: DeltaDir };
|
||||
conversion: { value: number; delta_pp: number; delta_dir: DeltaDir };
|
||||
active_projects: { active: number; limit: number };
|
||||
balance: { amount_rub: string; runway_days: number; runway_leads: number };
|
||||
activity: { points: number[]; labels: string[]; max: number };
|
||||
funnel: Record<string, number>;
|
||||
}
|
||||
|
||||
export async function getDashboardSummary(tenantId: number, range: DashboardRange): Promise<DashboardSummary> {
|
||||
const { data } = await apiClient.get<DashboardSummary>('/api/dashboard/summary', {
|
||||
params: { tenant_id: tenantId, range },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { apiClient } from './client';
|
||||
|
||||
/**
|
||||
* API-клиент исторической миграции лидов (ТЗ §6).
|
||||
* Эндпоинты: POST/GET /api/imports, /api/imports/unknown-statuses, /api/imports/unknown-statuses/resolve.
|
||||
*/
|
||||
|
||||
export interface ImportLogResource {
|
||||
id: number;
|
||||
filename: string;
|
||||
status: 'pending' | 'processing' | 'done' | 'failed';
|
||||
rows_total: number;
|
||||
rows_added: number;
|
||||
rows_updated: number;
|
||||
rows_skipped: number;
|
||||
unknown_statuses_count: number;
|
||||
dry_run: boolean;
|
||||
error_message: string | null;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
}
|
||||
|
||||
export interface UnknownStatus {
|
||||
id: number;
|
||||
status_ru: string;
|
||||
occurrences: number;
|
||||
}
|
||||
|
||||
export interface StatusMapping {
|
||||
status_ru: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
/** POST /api/imports — загрузить CSV. */
|
||||
export async function uploadImport(file: File, dryRun = false): Promise<ImportLogResource> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
if (dryRun) {
|
||||
form.append('dry_run', '1');
|
||||
}
|
||||
const { data } = await apiClient.post<{ data: ImportLogResource }>('/api/imports', form);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
/** GET /api/imports — история импортов. */
|
||||
export async function listImports(): Promise<ImportLogResource[]> {
|
||||
const { data } = await apiClient.get<{ data: ImportLogResource[] }>('/api/imports');
|
||||
return data.data;
|
||||
}
|
||||
|
||||
/** GET /api/imports/{id} — прогресс одного импорта. */
|
||||
export async function getImport(id: number): Promise<ImportLogResource> {
|
||||
const { data } = await apiClient.get<{ data: ImportLogResource }>(`/api/imports/${id}`);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
/** GET /api/imports/unknown-statuses — незамапленные статусы. */
|
||||
export async function getUnknownStatuses(): Promise<UnknownStatus[]> {
|
||||
const { data } = await apiClient.get<{ data: UnknownStatus[] }>('/api/imports/unknown-statuses');
|
||||
return data.data;
|
||||
}
|
||||
|
||||
/** POST /api/imports/unknown-statuses/resolve — сохранить маппинг. */
|
||||
export async function resolveUnknownStatuses(mappings: StatusMapping[]): Promise<void> {
|
||||
await apiClient.post('/api/imports/unknown-statuses/resolve', { mappings });
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { apiClient, ensureCsrfCookie } from './client';
|
||||
* Mutating-вызовы (mark-read/mark-all-read/destroy) делают ensureCsrfCookie().
|
||||
*/
|
||||
|
||||
type NotificationEvent =
|
||||
export type NotificationEvent =
|
||||
| 'new_lead'
|
||||
| 'reminder'
|
||||
| 'low_balance'
|
||||
|
||||
@@ -12,11 +12,11 @@ import { apiClient, ensureCsrfCookie } from './client';
|
||||
|
||||
export type ApiReportStatus = 'pending' | 'processing' | 'done' | 'failed';
|
||||
|
||||
type ApiReportType = 'deals_export' | 'managers_summary' | 'sources_summary' | 'billing_summary';
|
||||
export type ApiReportType = 'deals_export' | 'managers_summary' | 'sources_summary' | 'billing_summary';
|
||||
|
||||
type ApiReportFormat = 'csv' | 'xlsx' | 'json' | 'pdf';
|
||||
export type ApiReportFormat = 'csv' | 'xlsx' | 'json' | 'pdf';
|
||||
|
||||
interface ApiReportParameters {
|
||||
export interface ApiReportParameters {
|
||||
format: ApiReportFormat;
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
@@ -32,7 +32,6 @@ export interface ApiReportJob {
|
||||
parameters: ApiReportParameters;
|
||||
status: ApiReportStatus;
|
||||
file_path: string | null;
|
||||
download_url: string | null;
|
||||
file_size: number | null;
|
||||
generation_seconds: number | null;
|
||||
error_message: string | null;
|
||||
@@ -44,14 +43,14 @@ export interface ApiReportJob {
|
||||
retry_max: number;
|
||||
}
|
||||
|
||||
interface ReportCounts {
|
||||
export interface ReportCounts {
|
||||
pending: number;
|
||||
processing: number;
|
||||
done: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
interface ReportQuota {
|
||||
export interface ReportQuota {
|
||||
active: number;
|
||||
max_active: number;
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { apiClient, ensureCsrfCookie } from './client';
|
||||
|
||||
/**
|
||||
* Настройки исходящего webhook'а тенанта (audit D4/D5). Backend:
|
||||
* WebhookSettingsController. Полный secret доступен только в ответе
|
||||
* saveWebhookSettings() при первом создании подписки.
|
||||
*/
|
||||
export interface WebhookSettings {
|
||||
target_url: string;
|
||||
secret_prefix: string;
|
||||
events: string[];
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface SavedWebhookSettings extends WebhookSettings {
|
||||
secret?: string;
|
||||
}
|
||||
|
||||
export interface WebhookTestResult {
|
||||
ok: boolean;
|
||||
status: number | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export async function getWebhookSettings(): Promise<WebhookSettings | null> {
|
||||
const { data } = await apiClient.get<{ data: WebhookSettings | null }>('/api/tenants/me/webhook-settings');
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function saveWebhookSettings(payload: { target_url: string }): Promise<SavedWebhookSettings> {
|
||||
await ensureCsrfCookie();
|
||||
const { data } = await apiClient.put<{ data: SavedWebhookSettings }>('/api/tenants/me/webhook-settings', payload);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function testWebhook(): Promise<WebhookTestResult> {
|
||||
await ensureCsrfCookie();
|
||||
const { data } = await apiClient.post<WebhookTestResult>('/api/webhooks/test');
|
||||
return data;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user