Compare commits
254 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 67861270d6 | |||
| 65381f2b24 | |||
| 4a851a2d40 | |||
| ad9fb9dfde | |||
| eebcaf1912 | |||
| e0a3fb8d28 | |||
| 346c4843b0 | |||
| 0fa1a7394b | |||
| 6e1d437f21 | |||
| 9b1ac10309 | |||
| ffcb9b2f8e | |||
| a55ac9dee4 | |||
| 93bfda42c9 | |||
| 658f4be133 | |||
| d55890bec2 | |||
| d9ce953e53 | |||
| 0465b91cac | |||
| 1405e00f4c | |||
| bd27047aad | |||
| db8aa06f52 | |||
| 9fd1d7cdf5 | |||
| ee4969dffa | |||
| c9672e81e6 | |||
| e81cb5a1e5 | |||
| c2cb3af4c6 | |||
| 4a7c7cdddf | |||
| 47f83dac12 | |||
| 5bc6a029f2 | |||
| 0ef093f7c5 | |||
| ac2c794542 | |||
| f924e4413c | |||
| 21debac6c4 | |||
| b2f12cbe06 | |||
| 2723261033 | |||
| fe9ac213b7 | |||
| 112cdc82cd | |||
| cc624543e9 | |||
| 00937b7765 | |||
| e822925ded | |||
| 65c5178c29 | |||
| 5e6b1b651a | |||
| 040d25423d | |||
| 7bee35768d | |||
| c4370f6a2c | |||
| 44dc1025ec | |||
| c46d6264f3 | |||
| 6819238508 | |||
| c693d03a75 | |||
| 298a7fa9de | |||
| dc9cab300c | |||
| 3266909346 | |||
| a26f5af2da | |||
| a5e2bbbbe8 | |||
| 2c59a00714 | |||
| 075a661c62 | |||
| b40a76e0ff | |||
| f23a71b670 | |||
| d8d2f37598 | |||
| 772cdf4109 | |||
| 61e1cffb98 | |||
| 012053a783 | |||
| 70508b6675 | |||
| 7a4f8c2793 | |||
| 1fd6f7f597 | |||
| cc7ec0d749 | |||
| 8b47aa5a4d | |||
| fff25605d0 | |||
| 2722f60420 | |||
| 3b8a5184c7 | |||
| efd588f661 | |||
| 1f9b9ab788 | |||
| fb883148b6 | |||
| 22056baabc | |||
| dc6caea99f | |||
| d21b6556d2 | |||
| cce3baea49 | |||
| 0f94c21332 | |||
| d18b60f4ae | |||
| fcdd5b5f14 | |||
| 6c3640c45b | |||
| 21f81ed6ea | |||
| cd04cd6336 | |||
| 7e87324dde | |||
| 08d5ff1151 | |||
| ef71bce0a2 | |||
| b8ef4a0a7e | |||
| be755dd8eb | |||
| 1052ddfc97 | |||
| c6c6e8c0cc | |||
| a6649e4696 | |||
| 55696e5b40 | |||
| 5ac2961698 | |||
| 6287561fce | |||
| f65a8d79ec | |||
| 5df88a1310 | |||
| d30cbeba10 | |||
| 9c3057b473 | |||
| 9bd1baedef | |||
| 796d814e62 | |||
| 55c49c9889 | |||
| a68a0a0ccb | |||
| 0ae92e2937 | |||
| 18c4463ddd | |||
| e0bbf4d134 | |||
| 0047aa4ccd | |||
| e55572e22c | |||
| 72a00641fa | |||
| e8d5025656 | |||
| 061532c53a | |||
| 9068005566 | |||
| c09c52ea76 | |||
| 4e779471fd | |||
| 3d32ed52bd | |||
| e978b33cdd | |||
| aa3976380d | |||
| 8a22cc45c5 | |||
| f0d3d492a7 | |||
| a37d32d3f7 | |||
| b9917a90d4 | |||
| d2fa107d11 | |||
| ac2d173089 | |||
| 0bd55b2dbd | |||
| 0b6694e802 | |||
| ef88435348 | |||
| e8cc1f1105 | |||
| 700814c389 | |||
| b1c3efa1e1 | |||
| f9820460fa | |||
| 159ed3eb86 | |||
| 4f60add187 | |||
| 0d7f505185 | |||
| 2ad35cac72 | |||
| c5814ecc9c | |||
| bfdab40d88 | |||
| ae6a370b06 | |||
| 8aca5b1ba9 | |||
| 86b18fc396 | |||
| f47ace40f4 | |||
| 66d0d48adf | |||
| fa01951d27 | |||
| 7d77187eb3 | |||
| fb235e9d8d | |||
| 9d889558d3 | |||
| 3cd4ac7c59 | |||
| 8b0da60114 | |||
| 32396d97de | |||
| cec1a0c979 | |||
| 93ca58896f | |||
| f6cd79ccb9 | |||
| db7f798a64 | |||
| 718a6e6ff3 | |||
| 797a17978d | |||
| 2db5bd8709 | |||
| bcdcca01a5 | |||
| 97da018724 | |||
| abaeebbde6 | |||
| c18cc93c78 | |||
| f936944237 | |||
| 8e75951edc | |||
| b73ddaaedd | |||
| e39a42cfdf | |||
| 398f6bcf5a | |||
| 6387706be6 | |||
| 667befde96 | |||
| 35387e8b17 | |||
| a650484b11 | |||
| 54ee37c54e | |||
| d75b3b85d3 | |||
| 0da8dbf042 | |||
| a19bee28be | |||
| 0634426c30 | |||
| ee958f884a | |||
| 2b38e7be32 | |||
| 413803e569 | |||
| 1a7cd90c32 | |||
| 40b437ccb7 | |||
| aa258e1ad0 | |||
| 5c2556b73f | |||
| e3974482a9 | |||
| b747880ddc | |||
| ae20033652 | |||
| c5242271d7 | |||
| c5c0e76950 | |||
| e746b3c9a4 | |||
| 0c36b7a28d | |||
| 8ba9c55724 | |||
| f9d2452386 | |||
| 301334c288 | |||
| abb8a5135e | |||
| 4b4705295c | |||
| 9d27783729 | |||
| 51664a0aa4 | |||
| ad89473331 | |||
| 8fa545e113 | |||
| 8ec7a8c116 | |||
| 1f43beacc3 | |||
| 9e2914a72d | |||
| 93a3c667e0 | |||
| af97885266 | |||
| 4a5ecb085a | |||
| 823da293de | |||
| 362af8c981 | |||
| 85d79499e9 | |||
| 07a483333c | |||
| 08605cf640 | |||
| 9a45346205 | |||
| 7ee78a9ad0 | |||
| 9b21bbc1fd | |||
| 7007379b40 | |||
| bf84568837 | |||
| b241c79773 | |||
| 9530d17981 | |||
| 219f262655 | |||
| e280edd431 | |||
| 58986a2d74 | |||
| 9e175a1fd6 | |||
| ec0dd00a93 | |||
| 43f9c257bc | |||
| 845477603a | |||
| 31f804581d | |||
| e81b9c45b4 | |||
| 17d530f669 | |||
| 75dc375da3 | |||
| 22d8613578 | |||
| 51440f4e6d | |||
| 66ebb22043 | |||
| db167c1beb | |||
| a0fbe53eea | |||
| 318aed4f2c | |||
| c1f9719d67 | |||
| 763aeae0a4 | |||
| d7d70ccb4d | |||
| 2ece232fda | |||
| cc5f63b456 | |||
| c0a5fd1807 | |||
| 0e3f6b2301 | |||
| 00eb8ad235 | |||
| 7db4075107 | |||
| 4822610df5 | |||
| a2b5126d19 | |||
| 995886f73f | |||
| 99a242c9ed | |||
| c5b0cdfe6f | |||
| e9880a1c1b | |||
| e642cfeb53 | |||
| bd4ec48f05 | |||
| 6f7e7d72fa | |||
| f454e95a2d | |||
| d0460f6d20 | |||
| 1efd25dc8c | |||
| fc07529c4c | |||
| 982c79d6d2 | |||
| c435e2727b | |||
| 21262efedf |
@@ -0,0 +1,82 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,103 @@
|
||||
---
|
||||
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,6 +37,35 @@
|
||||
]
|
||||
},
|
||||
"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",
|
||||
@@ -46,6 +75,15 @@
|
||||
"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'); }\""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
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 добавление.
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
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).
|
||||
@@ -0,0 +1,258 @@
|
||||
#!/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));
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
---
|
||||
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`, не скила.
|
||||
@@ -0,0 +1,85 @@
|
||||
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
|
||||
+42
@@ -145,3 +145,45 @@ app/playwright/node_modules/
|
||||
|
||||
# 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/
|
||||
|
||||
@@ -23,6 +23,25 @@
|
||||
"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
@@ -0,0 +1,334 @@
|
||||
# Лидерра 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=database
|
||||
QUEUE_CONNECTION=redis
|
||||
|
||||
CACHE_STORE=database
|
||||
# CACHE_PREFIX=
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<?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,6 +228,31 @@ 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 от перебора емейлов с одного источника.
|
||||
@@ -333,6 +358,8 @@ 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,
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
<?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));
|
||||
}
|
||||
}
|
||||
@@ -123,15 +123,21 @@ class ImpersonationController extends Controller
|
||||
]);
|
||||
|
||||
// TODO: отправить email на $tenant->contact_email с $plainCode.
|
||||
// На MVP возвращаем code в response для тестов / dev (на prod НЕ должно
|
||||
// возвращаться никогда — токен только в email клиента).
|
||||
return response()->json([
|
||||
$payload = [
|
||||
'token_id' => $token->id,
|
||||
'expires_at' => $token->expires_at->toIso8601String(),
|
||||
'sent_to_email' => $token->sent_to_email,
|
||||
// dev-only field — на prod исчезает после интеграции с MailService.
|
||||
'_dev_plain_code' => $plainCode,
|
||||
]);
|
||||
];
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
/** POST /api/admin/impersonation/verify */
|
||||
|
||||
@@ -10,6 +10,7 @@ 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;
|
||||
|
||||
/**
|
||||
@@ -40,6 +41,9 @@ 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)) {
|
||||
@@ -50,6 +54,20 @@ 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,17 +117,19 @@ class WebhookReceiveController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* HMAC-обязательность. Если ключ отсутствует в БД — default false
|
||||
* (backward-compat для существующих интеграций).
|
||||
* HMAC-обязательность. Audit-fix B3: если ключ отсутствует в БД — default
|
||||
* TRUE (HMAC обязателен по умолчанию). Отключить можно только явной
|
||||
* установкой webhook_hmac_required=false. Неизвестное значение → fail-secure
|
||||
* (HMAC требуется).
|
||||
*/
|
||||
private function isHmacRequired(): bool
|
||||
{
|
||||
$setting = SystemSetting::find('webhook_hmac_required');
|
||||
if ($setting === null) {
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($setting->value, ['true', '1'], true);
|
||||
return ! in_array($setting->value, ['false', '0'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,10 @@ class SetTenantContext
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->hasHeader('X-Tenant-Id')) {
|
||||
// Audit-fix A3: X-Tenant-Id принимается ТОЛЬКО на dev/testing. На prod
|
||||
// заголовок игнорируется — иначе на любом роуте с `tenant`, но без
|
||||
// auth-middleware возможен спуфинг тенанта произвольным значением.
|
||||
if (app()->environment('local', 'testing') && $request->hasHeader('X-Tenant-Id')) {
|
||||
$headerValue = $request->header('X-Tenant-Id');
|
||||
if (is_string($headerValue) && ctype_digit($headerValue)) {
|
||||
return (int) $headerValue;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?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,6 +4,8 @@ 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;
|
||||
|
||||
@@ -19,6 +21,9 @@ 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';
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?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,6 +7,7 @@ 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;
|
||||
|
||||
@@ -80,4 +81,10 @@ 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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<?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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,7 @@
|
||||
],
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?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()->words(3, true),
|
||||
'name' => fake()->unique()->words(3, true),
|
||||
'type' => 'webhook',
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 10,
|
||||
|
||||
@@ -12,13 +12,16 @@ class DatabaseSeeder extends Seeder
|
||||
/**
|
||||
* Seed the application's database.
|
||||
*
|
||||
* Note: the Laravel scaffold default User::factory() seed was removed —
|
||||
* наша схема использует first_name/last_name (а не "name"), и заранее
|
||||
* не было сценария, где этот seed реально вызывался. PricingTierSeeder
|
||||
* (Plan 4) — единственный текущий seed для dev/testing.
|
||||
* PricingTierSeeder runs in all environments (prod нуждается в 7-tier
|
||||
* config bootstrap'е). DemoSeeder — только local+testing: создаёт demo
|
||||
* tenant + admin@demo.local + 3 проекта + ~14 demo сделок для UI smoke.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$this->call(PricingTierSeeder::class);
|
||||
|
||||
if (app()->environment('local', 'testing')) {
|
||||
$this->call(DemoSeeder::class);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+7
-3
@@ -1,10 +1,14 @@
|
||||
import type { KnipConfig } from 'knip';
|
||||
|
||||
const config: KnipConfig = {
|
||||
entry: ['resources/js/app.ts', 'resources/js/router/index.ts'],
|
||||
entry: [
|
||||
'resources/js/app.ts',
|
||||
'resources/js/router/index.ts',
|
||||
'histoire.config.ts',
|
||||
'resources/js/histoire.setup.ts',
|
||||
],
|
||||
project: ['resources/js/**/*.{ts,vue}'],
|
||||
ignore: ['**/*.story.vue', 'tests/**'],
|
||||
ignoreDependencies: ['@vue/test-utils', 'jsdom', 'vitest'],
|
||||
ignore: ['**/*.story.vue'],
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
Generated
-138
@@ -15,7 +15,6 @@
|
||||
"@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",
|
||||
@@ -4319,36 +4318,6 @@
|
||||
"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",
|
||||
@@ -4394,21 +4363,6 @@
|
||||
"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",
|
||||
@@ -4470,31 +4424,6 @@
|
||||
"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",
|
||||
@@ -7975,16 +7904,6 @@
|
||||
"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",
|
||||
@@ -9214,16 +9133,6 @@
|
||||
"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",
|
||||
@@ -10103,24 +10012,6 @@
|
||||
"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",
|
||||
@@ -10222,35 +10113,6 @@
|
||||
"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",
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
"@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",
|
||||
|
||||
+156
-42
@@ -78,12 +78,6 @@ parameters:
|
||||
count: 1
|
||||
path: app/Http/Middleware/SetTenantContext.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\Project\:\:\$archived_at\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: app/Http/Resources/ProjectResource.php
|
||||
|
||||
-
|
||||
message: '#^Using nullsafe property access "\?\-\>name" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
|
||||
identifier: nullsafe.neverNull
|
||||
@@ -102,18 +96,18 @@ parameters:
|
||||
count: 1
|
||||
path: app/Services/NotificationService.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\Project\:\:\$archived_at\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: app/Services/Project/ProjectService.php
|
||||
|
||||
-
|
||||
message: '#^Match expression does not handle remaining value\: string$#'
|
||||
identifier: match.unhandled
|
||||
count: 1
|
||||
path: app/Services/Project/ProjectService.php
|
||||
|
||||
-
|
||||
message: '#^Return type \(array\<string, mixed\>\) of method Database\\Factories\\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
|
||||
@@ -252,6 +246,36 @@ parameters:
|
||||
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
|
||||
@@ -463,16 +487,58 @@ parameters:
|
||||
path: tests/Feature/Auth/TwoFactorTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\LeadCharge\:\:\$charge_source\.$#'
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Billing/LedgerServiceTest.php
|
||||
count: 1
|
||||
path: tests/Feature/Auth/UpdateProfileTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$delivered_in_month\.$#'
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Billing/LedgerServiceTest.php
|
||||
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
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$ledger\.$#'
|
||||
@@ -528,6 +594,36 @@ 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
|
||||
@@ -540,12 +636,6 @@ 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
|
||||
@@ -882,12 +972,6 @@ parameters:
|
||||
count: 6
|
||||
path: tests/Feature/Plan5/Jobs/SyncSupplierProjectJobTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\Project\:\:\$archived_at\.$#'
|
||||
identifier: property.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Plan5/Projects/ProjectsActionsTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -1116,18 +1200,6 @@ 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
|
||||
@@ -1158,6 +1230,48 @@ 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
|
||||
|
||||
@@ -34,3 +34,43 @@ body {
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ export interface AdminTenant {
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface AdminTenantsStats {
|
||||
interface AdminTenantsStats {
|
||||
total: number;
|
||||
active: number;
|
||||
trial: number;
|
||||
@@ -182,7 +182,7 @@ export interface ApiTenantActivityEvent {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ApiTenantMetrics {
|
||||
interface ApiTenantMetrics {
|
||||
leads_today: number;
|
||||
leads_this_week: number;
|
||||
leads_this_month: number;
|
||||
@@ -224,7 +224,7 @@ export interface ApiAdminBillingTenant {
|
||||
chargeback_unrecovered_rub: string;
|
||||
}
|
||||
|
||||
export interface ApiAdminBillingSummary {
|
||||
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;
|
||||
}
|
||||
|
||||
export interface ApiAdminIncidentsSummary {
|
||||
interface ApiAdminIncidentsSummary {
|
||||
open: number;
|
||||
investigating: number;
|
||||
rkn_pending: number;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
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,6 +25,8 @@ 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;
|
||||
@@ -151,3 +153,16 @@ 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
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;
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { apiClient, ensureCsrfCookie } from './client';
|
||||
* Mutating-вызовы (mark-read/mark-all-read/destroy) делают ensureCsrfCookie().
|
||||
*/
|
||||
|
||||
export type NotificationEvent =
|
||||
type NotificationEvent =
|
||||
| 'new_lead'
|
||||
| 'reminder'
|
||||
| 'low_balance'
|
||||
|
||||
@@ -12,11 +12,11 @@ import { apiClient, ensureCsrfCookie } from './client';
|
||||
|
||||
export type ApiReportStatus = 'pending' | 'processing' | 'done' | 'failed';
|
||||
|
||||
export type ApiReportType = 'deals_export' | 'managers_summary' | 'sources_summary' | 'billing_summary';
|
||||
type ApiReportType = 'deals_export' | 'managers_summary' | 'sources_summary' | 'billing_summary';
|
||||
|
||||
export type ApiReportFormat = 'csv' | 'xlsx' | 'json' | 'pdf';
|
||||
type ApiReportFormat = 'csv' | 'xlsx' | 'json' | 'pdf';
|
||||
|
||||
export interface ApiReportParameters {
|
||||
interface ApiReportParameters {
|
||||
format: ApiReportFormat;
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
@@ -43,14 +43,14 @@ export interface ApiReportJob {
|
||||
retry_max: number;
|
||||
}
|
||||
|
||||
export interface ReportCounts {
|
||||
interface ReportCounts {
|
||||
pending: number;
|
||||
processing: number;
|
||||
done: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
export interface ReportQuota {
|
||||
interface ReportQuota {
|
||||
active: number;
|
||||
max_active: number;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
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;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
/**
|
||||
* Корневой shell приложения. Мапит meta.layout текущего route'а на layout-компонент.
|
||||
*
|
||||
* meta.layout = 'auth' → AuthLayout (двухпанельный для login/register/2fa/forgot/recovery).
|
||||
* meta.layout = 'auth' → AuthLayout (двухпанельный для login/register/2fa/forgot/recovery-use).
|
||||
* meta.layout = 'error' → RouterView напрямую (ErrorView сам предоставляет v-app + теало-нуар bg).
|
||||
* meta.layout не задан или 'app' → AppLayout (sidebar + topbar для авторизованных страниц).
|
||||
*
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Глобальный индикатор активных impersonation-сессий (audit B5 / Ю-1).
|
||||
*
|
||||
* Размещён в AdminLayout над <RouterView> — виден на всех /admin/* страницах.
|
||||
* На MVP saas-admin auth нет и реального переключения сессии нет, поэтому
|
||||
* показываем счётчик ВСЕХ активных сессий (impersonationActive() =
|
||||
* used_at != null AND session_ended_at == null). Polling 30 c — сессия может
|
||||
* стартовать/завершиться, пока админ остаётся в админке (AdminLayout
|
||||
* persistent, перемонтируется только <RouterView>).
|
||||
*
|
||||
* Если активных сессий 0 — компонент не рендерит ничего.
|
||||
*/
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { impersonationActive, type ImpersonationActiveSession } from '../../api/admin';
|
||||
import { usePolling } from '../../composables/usePolling';
|
||||
|
||||
const sessions = ref<ImpersonationActiveSession[]>([]);
|
||||
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
sessions.value = await impersonationActive();
|
||||
} catch {
|
||||
// Баннер не критичен — ошибку детально покажет AdminImpersonationView.
|
||||
// Сохраняем прежнее значение sessions, не падаем.
|
||||
}
|
||||
}
|
||||
|
||||
const count = computed(() => sessions.value.length);
|
||||
|
||||
const label = computed(() => {
|
||||
if (count.value === 1) {
|
||||
const s = sessions.value[0];
|
||||
return `Активна impersonation-сессия: ${s.tenant_name ?? `тенант #${s.tenant_id}`}`;
|
||||
}
|
||||
return `Активны impersonation-сессии: ${count.value}`;
|
||||
});
|
||||
|
||||
onMounted(load);
|
||||
usePolling(load, { intervalMs: 30_000 });
|
||||
|
||||
defineExpose({ sessions, load });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="count > 0" class="impersonation-banner" role="status" data-testid="impersonation-banner">
|
||||
<v-icon size="16" class="impersonation-banner__icon">mdi-account-switch</v-icon>
|
||||
<span class="impersonation-banner__label">{{ label }}</span>
|
||||
<RouterLink
|
||||
to="/admin/impersonation"
|
||||
class="impersonation-banner__link"
|
||||
data-testid="impersonation-banner-link"
|
||||
>
|
||||
Открыть
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.impersonation-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #fff4e0;
|
||||
border-bottom: 1px solid #f0d8a8;
|
||||
color: #8a5a00;
|
||||
font-size: 13px;
|
||||
padding: 8px 24px;
|
||||
}
|
||||
.impersonation-banner__icon {
|
||||
color: #b87400;
|
||||
}
|
||||
.impersonation-banner__label {
|
||||
flex: 1;
|
||||
}
|
||||
.impersonation-banner__link {
|
||||
color: #0f6e56;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.impersonation-banner__link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -117,7 +117,8 @@ const emit = defineEmits<{
|
||||
align-items: center;
|
||||
}
|
||||
.page-meta .sep {
|
||||
color: #92907b;
|
||||
/* WCAG2AA 4.5:1 fix (was #92907b → 2.92:1 on ivory; #6b6356 → 5.33:1). */
|
||||
color: #6b6356;
|
||||
}
|
||||
.head-actions {
|
||||
display: flex;
|
||||
|
||||
@@ -81,7 +81,8 @@ function formatRub(v: number): string {
|
||||
align-items: center;
|
||||
}
|
||||
.page-stats .sep {
|
||||
color: #92907b;
|
||||
/* WCAG2AA 4.5:1 fix (was #92907b → 2.92:1 on ivory; #6b6356 → 5.33:1). */
|
||||
color: #6b6356;
|
||||
}
|
||||
.num {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
|
||||
@@ -40,7 +40,7 @@ function statusColor(s: TenantStatus): string {
|
||||
{ title: 'Желаем×факт сегодня', key: 'today', align: 'end', sortable: false },
|
||||
{ title: 'MRR', key: 'mrrRub', align: 'end', sortable: false },
|
||||
{ title: 'Активность', key: 'activitySince', sortable: false },
|
||||
{ title: '', key: 'actions', align: 'end', sortable: false, width: 56 },
|
||||
{ title: 'Действия', key: 'actions', align: 'end', sortable: false, width: 56 },
|
||||
]"
|
||||
items-per-page="-1"
|
||||
hide-default-footer
|
||||
@@ -78,7 +78,11 @@ function statusColor(s: TenantStatus): string {
|
||||
<span class="num text-medium-emphasis">{{ item.activitySince }}</span>
|
||||
</template>
|
||||
<template #[`item.actions`]="{ item }: { item: AdminTenant }">
|
||||
<v-tooltip text="Войти как клиент (impersonation)" location="top">
|
||||
<v-tooltip
|
||||
text="Войти как клиент (impersonation)"
|
||||
location="top"
|
||||
aria-label="Войти как клиент (impersonation)"
|
||||
>
|
||||
<template #activator="{ props: tipProps }">
|
||||
<v-btn
|
||||
v-bind="tipProps"
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* BalanceCard — 3 wallet-cards в одной строке: Кошелёк ₽ (primary, dark) +
|
||||
* Баланс лидов + Тариф. Sprint 4 Phase B/2 — split BillingView (audit O-refactor-04 хвост).
|
||||
* BalanceCard — 3 wallet-cards в одной строке: Кошелёк ₽ (dark) +
|
||||
* Баланс лидов + Тариф. Данные — из GET /api/billing/wallet (E3).
|
||||
* tariff* допускают null (тенант без назначенного тарифа — trial).
|
||||
*/
|
||||
defineProps<{
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
walletRub: number;
|
||||
leadsBalance: number;
|
||||
tariffName: string;
|
||||
tariffPrice: number;
|
||||
tariffName: string | null;
|
||||
tariffPrice: string | null;
|
||||
tariffFeatures: string[];
|
||||
}>();
|
||||
|
||||
defineEmits<{ topup: [] }>();
|
||||
|
||||
const walletText = computed(() => new Intl.NumberFormat('ru-RU').format(props.walletRub));
|
||||
|
||||
const tariffPriceText = computed(() => {
|
||||
if (props.tariffPrice === null) return 'по запросу';
|
||||
return new Intl.NumberFormat('ru-RU').format(Number(props.tariffPrice)) + ' ₽/мес';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -21,12 +33,19 @@ defineProps<{
|
||||
<v-chip size="x-small" color="primary" variant="elevated">LIVE</v-chip>
|
||||
</div>
|
||||
<div class="wallet-amount mt-2">
|
||||
<span class="num">{{ new Intl.NumberFormat('ru-RU').format(walletRub) }}</span>
|
||||
<span class="num">{{ walletText }}</span>
|
||||
<span class="ru"> ₽</span>
|
||||
</div>
|
||||
<div class="wallet-foot mt-3">мин. пополнение <strong>100 ₽</strong> · округление вниз ₽→лиды</div>
|
||||
<div class="wallet-actions mt-3">
|
||||
<v-btn color="primary" variant="flat" prepend-icon="mdi-plus" size="small">Пополнить</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
prepend-icon="mdi-plus"
|
||||
size="small"
|
||||
@click="$emit('topup')"
|
||||
>Пополнить</v-btn
|
||||
>
|
||||
<v-btn variant="outlined" prepend-icon="mdi-autorenew" size="small"> Автопополнение </v-btn>
|
||||
</div>
|
||||
</v-card>
|
||||
@@ -41,22 +60,24 @@ defineProps<{
|
||||
<span class="num">{{ leadsBalance }}</span>
|
||||
<span class="ru-text"> лидов</span>
|
||||
</div>
|
||||
<div class="wallet-foot mt-3">средняя цена <strong>50 ₽/лид</strong> · потрачено за месяц 412</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="4">
|
||||
<v-card variant="outlined" class="wallet-card pa-4 d-flex flex-column">
|
||||
<span class="wallet-label">Тариф</span>
|
||||
<div class="tariff-name mt-1">
|
||||
{{ tariffName }}
|
||||
<span class="tariff-price">· {{ tariffPrice }} ₽/мес</span>
|
||||
</div>
|
||||
<ul class="tariff-feats mt-3">
|
||||
<li v-for="f in tariffFeatures" :key="f">
|
||||
<v-icon size="14" color="success" class="mr-1">mdi-check</v-icon>{{ f }}
|
||||
</li>
|
||||
</ul>
|
||||
<template v-if="tariffName">
|
||||
<div class="tariff-name mt-1">
|
||||
{{ tariffName }}
|
||||
<span class="tariff-price">· {{ tariffPriceText }}</span>
|
||||
</div>
|
||||
<ul v-if="tariffFeatures.length" class="tariff-feats mt-3">
|
||||
<li v-for="f in tariffFeatures" :key="f">
|
||||
<v-icon size="14" color="success" class="mr-1">mdi-check</v-icon>{{ f }}
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<div v-else class="tariff-empty mt-2">Тариф не выбран</div>
|
||||
<v-btn variant="outlined" size="small" class="mt-auto">Сменить тариф →</v-btn>
|
||||
</v-card>
|
||||
</v-col>
|
||||
@@ -137,6 +158,10 @@ defineProps<{
|
||||
font-weight: 500;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.tariff-empty {
|
||||
color: #66635c;
|
||||
font-size: 14px;
|
||||
}
|
||||
.tariff-feats {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
|
||||
@@ -1,29 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* InvoicesTable — список счетов и УПД (PDF / 1С 8.3 XML).
|
||||
* Sprint 4 Phase B/2 — split BillingView.
|
||||
* InvoicesTable — список счетов тенанта. Данные — GET /api/billing/invoices
|
||||
* (E3). Real-but-empty до Б-1: на MVP saas_invoices пуста (нужно
|
||||
* зарегистрированное юр-лицо), компонент показывает empty-state.
|
||||
*/
|
||||
import { MOCK_INVOICES } from '../../composables/mockBilling';
|
||||
import { formatIcon, formatLabel, formatPlain } from '../../composables/billingFormatters';
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { getInvoices, type BillingInvoice } from '../../api/billing';
|
||||
import { formatPlain } from '../../composables/billingFormatters';
|
||||
|
||||
const invoices = ref<BillingInvoice[]>([]);
|
||||
const loading = ref(true);
|
||||
const loadError = ref<string | null>(null);
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
draft: 'Черновик',
|
||||
issued: 'Выставлен',
|
||||
paid: 'Оплачен',
|
||||
overdue: 'Просрочен',
|
||||
cancelled: 'Отменён',
|
||||
};
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
return STATUS_LABELS[status] ?? status;
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('ru-RU', { timeZone: 'Europe/Moscow' });
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
loadError.value = null;
|
||||
try {
|
||||
invoices.value = (await getInvoices()).data;
|
||||
} catch {
|
||||
loadError.value = 'Не удалось загрузить счета.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
|
||||
defineExpose({ load, invoices });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card variant="outlined" class="mt-4 panel">
|
||||
<div class="panel-h pa-4">
|
||||
<h2 class="text-h6 panel-title ma-0">Счета и УПД</h2>
|
||||
<v-btn variant="outlined" size="small" prepend-icon="mdi-download">Реестр XLSX</v-btn>
|
||||
<h2 class="text-h6 panel-title ma-0">Счета</h2>
|
||||
</div>
|
||||
<v-divider />
|
||||
<ul class="invoices-list pa-2 ma-0">
|
||||
<li v-for="inv in MOCK_INVOICES" :key="inv.id" class="inv-row">
|
||||
<span class="inv-when num">{{ inv.when }}</span>
|
||||
|
||||
<div v-if="loading" class="py-8 d-flex justify-center">
|
||||
<v-progress-circular indeterminate color="primary" size="28" />
|
||||
</div>
|
||||
|
||||
<v-alert v-else-if="loadError" type="error" variant="tonal" density="compact" class="ma-4" role="alert">
|
||||
{{ loadError }}
|
||||
</v-alert>
|
||||
|
||||
<div v-else-if="invoices.length === 0" class="empty pa-8 text-center text-medium-emphasis">
|
||||
Счета появятся после первой оплаты.
|
||||
</div>
|
||||
|
||||
<ul v-else class="invoices-list pa-2 ma-0">
|
||||
<li v-for="inv in invoices" :key="inv.id" class="inv-row">
|
||||
<span class="inv-when num">{{ formatDate(inv.issued_at) }}</span>
|
||||
<span class="inv-name">
|
||||
{{ inv.title }}
|
||||
<span class="sub">{{ inv.sub }}</span>
|
||||
{{ inv.invoice_number }}
|
||||
<span class="sub">{{ statusLabel(inv.status) }}</span>
|
||||
</span>
|
||||
<span class="inv-amount num">{{ formatPlain(inv.amountRub) }}</span>
|
||||
<v-btn variant="text" size="small" :prepend-icon="formatIcon(inv.format)">
|
||||
{{ formatLabel(inv.format) }}
|
||||
<span class="inv-amount num">{{ formatPlain(Number(inv.amount_total)) }}</span>
|
||||
<v-btn
|
||||
variant="text"
|
||||
size="small"
|
||||
prepend-icon="mdi-file-pdf-box"
|
||||
:disabled="!inv.has_pdf"
|
||||
>
|
||||
PDF
|
||||
</v-btn>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -52,6 +107,10 @@ import { formatIcon, formatLabel, formatPlain } from '../../composables/billingF
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.empty {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.invoices-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* TopupDialog — диалог пополнения рублёвого баланса (audit E1).
|
||||
*
|
||||
* MVP-stub: POST /api/billing/topup кредитует баланс немедленно (без
|
||||
* платёжного шлюза — реальная оплата post-Б-1). При успехе эмитит
|
||||
* `success` с новым балансом и закрывается.
|
||||
*/
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { topup } from '../../api/billing';
|
||||
import { extractErrorMessage, extractValidationErrors } from '../../api/client';
|
||||
|
||||
const model = defineModel<boolean>({ required: true });
|
||||
const emit = defineEmits<{ success: [balanceRub: string] }>();
|
||||
|
||||
const PRESETS = [1000, 5000, 10000, 25000];
|
||||
|
||||
const amount = ref<number | null>(null);
|
||||
const submitting = ref(false);
|
||||
const errorMsg = ref<string | null>(null);
|
||||
|
||||
const amountError = computed<string | null>(() => {
|
||||
if (amount.value === null || !Number.isFinite(amount.value)) return null;
|
||||
if (amount.value < 100) return 'Минимум 100 ₽';
|
||||
if (amount.value > 1000000) return 'Максимум 1 000 000 ₽';
|
||||
return null;
|
||||
});
|
||||
|
||||
const canSubmit = computed(
|
||||
() => Number.isFinite(amount.value) && amountError.value === null && !submitting.value,
|
||||
);
|
||||
|
||||
// Сброс состояния при каждом открытии диалога (паттерн ReminderDialog/
|
||||
// NewDealDialog) — нет префилла прошлой суммы и нет всплытия устаревшей ошибки.
|
||||
watch(model, (open) => {
|
||||
if (open) {
|
||||
amount.value = null;
|
||||
errorMsg.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
function setPreset(value: number): void {
|
||||
amount.value = value;
|
||||
}
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
if (!canSubmit.value || amount.value === null) return;
|
||||
submitting.value = true;
|
||||
errorMsg.value = null;
|
||||
try {
|
||||
const res = await topup(amount.value);
|
||||
emit('success', res.balance_rub);
|
||||
model.value = false;
|
||||
amount.value = null;
|
||||
} catch (e) {
|
||||
const validation = extractValidationErrors(e);
|
||||
errorMsg.value = validation?.amount_rub?.[0] ?? extractErrorMessage(e);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
if (submitting.value) return;
|
||||
model.value = false;
|
||||
errorMsg.value = null;
|
||||
}
|
||||
|
||||
defineExpose({ amount, submit, canSubmit, errorMsg });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-dialog v-model="model" max-width="460">
|
||||
<v-card>
|
||||
<v-card-title class="text-h6">Пополнить баланс</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model.number="amount"
|
||||
type="number"
|
||||
label="Сумма пополнения"
|
||||
suffix="₽"
|
||||
density="comfortable"
|
||||
:error-messages="amountError ?? undefined"
|
||||
autofocus
|
||||
/>
|
||||
|
||||
<div class="presets mb-2">
|
||||
<v-chip
|
||||
v-for="p in PRESETS"
|
||||
:key="p"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
@click="setPreset(p)"
|
||||
>
|
||||
{{ new Intl.NumberFormat('ru-RU').format(p) }} ₽
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<v-alert type="info" variant="tonal" density="compact" class="mt-2">
|
||||
Платёжный шлюз подключается после регистрации юр. лица — на текущем этапе баланс
|
||||
пополняется сразу.
|
||||
</v-alert>
|
||||
|
||||
<v-alert v-if="errorMsg" type="error" variant="tonal" density="compact" class="mt-3" role="alert">
|
||||
{{ errorMsg }}
|
||||
</v-alert>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" :disabled="submitting" @click="close">Отмена</v-btn>
|
||||
<v-btn color="primary" variant="flat" :loading="submitting" :disabled="!canSubmit" @click="submit">
|
||||
Пополнить
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.presets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,63 +1,155 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* TransactionsTable — VDataTable истории транзакций с табами фильтрации
|
||||
* (Все / Пополнения / Списания / Возвраты). Sprint 4 Phase B/2 — split BillingView.
|
||||
* TransactionsTable — server-driven история транзакций с табами
|
||||
* (Все / Пополнения / Списания / Возвраты). Данные — GET
|
||||
* /api/billing/transactions (E3). Паттерн self-fetching из ChargesTab.
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
import { BILLING_TABS, MOCK_TRANSACTIONS, type BillingTransaction } from '../../composables/mockBilling';
|
||||
import { formatCost, statusChipColor, statusLabel, txAmountClass } from '../../composables/billingFormatters';
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { getTransactions, type BillingTransaction } from '../../api/billing';
|
||||
import { formatCost, txAmountClass } from '../../composables/billingFormatters';
|
||||
|
||||
const activeTab = ref<(typeof BILLING_TABS)[number]['id']>('all');
|
||||
interface Tab {
|
||||
id: string;
|
||||
label: string;
|
||||
type: string | null;
|
||||
}
|
||||
|
||||
const filteredTransactions = computed<BillingTransaction[]>(() => {
|
||||
const tab = BILLING_TABS.find((t) => t.id === activeTab.value);
|
||||
const types = tab?.types;
|
||||
if (!types) return MOCK_TRANSACTIONS;
|
||||
return MOCK_TRANSACTIONS.filter((tx) => types.includes(tx.type));
|
||||
});
|
||||
const TABS: Tab[] = [
|
||||
{ id: 'all', label: 'Все', type: null },
|
||||
{ id: 'topup', label: 'Пополнения', type: 'topup' },
|
||||
{ id: 'lead_charge', label: 'Списания', type: 'lead_charge' },
|
||||
{ id: 'refund', label: 'Возвраты', type: 'refund' },
|
||||
];
|
||||
|
||||
const activeTab = ref<string>('all');
|
||||
const rows = ref<BillingTransaction[]>([]);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const loadError = ref<string | null>(null);
|
||||
const page = ref(1);
|
||||
|
||||
const headers = [
|
||||
{ title: 'Дата', key: 'created_at', sortable: false },
|
||||
{ title: 'Операция', key: 'description', sortable: false },
|
||||
{ title: 'ID', key: 'code', sortable: false, width: 120 },
|
||||
{ title: 'Сумма', key: 'amount_rub', align: 'end' as const, sortable: false, width: 140 },
|
||||
];
|
||||
|
||||
function formatWhen(iso: string): string {
|
||||
return new Date(iso).toLocaleString('ru-RU', {
|
||||
timeZone: 'Europe/Moscow',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
/** Числовое значение движения: рубли приоритетно, иначе лиды. */
|
||||
function txAmountValue(tx: BillingTransaction): number {
|
||||
const rub = Number(tx.amount_rub);
|
||||
return rub !== 0 ? rub : tx.amount_leads;
|
||||
}
|
||||
|
||||
/** Текст суммы: «+ 5 000 ₽» / «− 1 лид.» / «0 ₽». */
|
||||
function txAmountText(tx: BillingTransaction): string {
|
||||
const rub = Number(tx.amount_rub);
|
||||
if (rub !== 0) return formatCost(rub);
|
||||
if (tx.amount_leads !== 0) {
|
||||
const sign = tx.amount_leads > 0 ? '+ ' : '− ';
|
||||
return sign + Math.abs(tx.amount_leads) + ' лид.';
|
||||
}
|
||||
return '0 ₽';
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
loadError.value = null;
|
||||
try {
|
||||
const tab = TABS.find((t) => t.id === activeTab.value);
|
||||
const params: { page: number; type?: string } = { page: page.value };
|
||||
if (tab?.type) params.type = tab.type;
|
||||
const res = await getTransactions(params);
|
||||
rows.value = res.data;
|
||||
total.value = res.meta.total;
|
||||
} catch {
|
||||
loadError.value = 'Не удалось загрузить транзакции.';
|
||||
rows.value = [];
|
||||
total.value = 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function changeTab(id: string): Promise<void> {
|
||||
activeTab.value = id;
|
||||
page.value = 1;
|
||||
await load();
|
||||
}
|
||||
|
||||
async function loadOptions(opts: { page: number }): Promise<void> {
|
||||
page.value = opts.page;
|
||||
await load();
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
page.value = 1;
|
||||
await load();
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
|
||||
defineExpose({ load, refresh, changeTab, activeTab, total, rows });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card variant="outlined" class="mt-4 panel">
|
||||
<div class="panel-h pa-4">
|
||||
<h2 class="text-h6 panel-title ma-0">История транзакций</h2>
|
||||
<v-btn-toggle v-model="activeTab" mandatory color="primary" density="comfortable" variant="text">
|
||||
<v-btn v-for="tab in BILLING_TABS" :key="tab.id" :value="tab.id" size="small">
|
||||
<v-btn-toggle
|
||||
:model-value="activeTab"
|
||||
mandatory
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
variant="text"
|
||||
>
|
||||
<v-btn
|
||||
v-for="tab in TABS"
|
||||
:key="tab.id"
|
||||
:value="tab.id"
|
||||
size="small"
|
||||
@click="changeTab(tab.id)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
</div>
|
||||
|
||||
<v-data-table
|
||||
:items="filteredTransactions"
|
||||
:headers="[
|
||||
{ title: 'Дата', key: 'when', sortable: false },
|
||||
{ title: 'Операция', key: 'description', sortable: false },
|
||||
{ title: 'ID', key: 'code', sortable: false },
|
||||
{ title: 'Статус', key: 'status', sortable: false },
|
||||
{ title: 'Сумма', key: 'amount', align: 'end', sortable: false },
|
||||
]"
|
||||
items-per-page="-1"
|
||||
hide-default-footer
|
||||
<v-alert v-if="loadError" type="error" variant="tonal" density="compact" class="mx-4 mb-4" role="alert">
|
||||
{{ loadError }}
|
||||
</v-alert>
|
||||
|
||||
<v-data-table-server
|
||||
:headers="headers"
|
||||
:items="rows"
|
||||
:items-length="total"
|
||||
:loading="loading"
|
||||
:items-per-page="20"
|
||||
density="comfortable"
|
||||
@update:options="loadOptions"
|
||||
>
|
||||
<template #[`item.when`]="{ item }">
|
||||
<span class="tx-when num">{{ item.when }}</span>
|
||||
<template #[`item.created_at`]="{ item }">
|
||||
<span class="tx-when num">{{ formatWhen(item.created_at) }}</span>
|
||||
</template>
|
||||
<template #[`item.code`]="{ item }">
|
||||
<span class="tx-id">#{{ item.code }}</span>
|
||||
</template>
|
||||
<template #[`item.status`]="{ item }">
|
||||
<v-chip size="small" variant="tonal" :color="statusChipColor(item.status)">
|
||||
{{ statusLabel(item.status) }}
|
||||
</v-chip>
|
||||
</template>
|
||||
<template #[`item.amount`]="{ item }">
|
||||
<span class="num" :class="txAmountClass(item)">
|
||||
{{ item.status === 'rejected' ? '— 0 ₽' : formatCost(item.amount) }}
|
||||
<template #[`item.amount_rub`]="{ item }">
|
||||
<span class="num" :class="txAmountClass(txAmountValue(item))">
|
||||
{{ txAmountText(item) }}
|
||||
</span>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-data-table-server>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -29,7 +29,11 @@ defineProps<{
|
||||
<span class="ru"> ₽</span>
|
||||
</div>
|
||||
<div class="runway mt-3">
|
||||
<div class="runway-bar" role="img" :aria-label="`Хватит на ${balance.runwayDays} дня из ${balance.runwayMax}`">
|
||||
<div
|
||||
class="runway-bar"
|
||||
role="img"
|
||||
:aria-label="`Хватит на ${balance.runwayDays} дня из ${balance.runwayMax}`"
|
||||
>
|
||||
<span
|
||||
v-for="i in balance.runwayMax"
|
||||
:key="i"
|
||||
|
||||
@@ -55,7 +55,8 @@ const range = defineModel<'today' | '7d' | '30d' | 'custom'>({ required: true })
|
||||
align-items: center;
|
||||
}
|
||||
.page-meta .sep {
|
||||
color: #92907b;
|
||||
/* WCAG2AA 4.5:1 fix (was #92907b → 2.92:1 on ivory; #6b6356 → 5.33:1). */
|
||||
color: #6b6356;
|
||||
}
|
||||
.num {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
|
||||
@@ -87,7 +87,8 @@ function formatRelative(minutes: number): string {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.hero-meta .sep {
|
||||
color: #92907b;
|
||||
/* WCAG2AA 4.5:1 fix (was #92907b → 2.92:1 on ivory; #6b6356 → 5.33:1). */
|
||||
color: #6b6356;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
|
||||
@@ -87,7 +87,11 @@ async function handleLogout(): Promise<void> {
|
||||
|
||||
<template>
|
||||
<v-app-bar :elevation="0" color="surface" class="app-topbar" :height="56">
|
||||
<v-app-bar-nav-icon class="d-md-none" @click="emit('toggle-drawer')" />
|
||||
<v-app-bar-nav-icon
|
||||
class="d-md-none"
|
||||
aria-label="Открыть меню навигации"
|
||||
@click="emit('toggle-drawer')"
|
||||
/>
|
||||
|
||||
<div class="crumb">
|
||||
<strong>{{ pageTitle }}</strong>
|
||||
|
||||
@@ -55,6 +55,19 @@
|
||||
:count="store.selectedIds.size"
|
||||
@apply="(p) => runBulk({ action: 'update_limit', ...p })"
|
||||
/>
|
||||
|
||||
<v-snackbar
|
||||
v-model="skipToastOpen"
|
||||
:timeout="6000"
|
||||
color="warning"
|
||||
location="bottom right"
|
||||
data-testid="bulk-skip-toast"
|
||||
>
|
||||
{{ skipToastText }}
|
||||
<template #actions>
|
||||
<v-btn variant="text" @click="skipToastOpen = false">Закрыть</v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
@@ -72,6 +85,10 @@ const regionsOpen = ref(false);
|
||||
const daysOpen = ref(false);
|
||||
const limitOpen = ref(false);
|
||||
|
||||
// Sprint 1 C5: window.alert → v-snackbar (non-blocking, accessible, не breaks браузерный automation).
|
||||
const skipToastOpen = ref(false);
|
||||
const skipToastText = ref('');
|
||||
|
||||
const messages: Record<string, string> = {
|
||||
pause: 'Приостановить выбранные проекты?',
|
||||
resume: 'Возобновить выбранные проекты?',
|
||||
@@ -87,13 +104,12 @@ async function confirmAndRun(action: 'pause' | 'resume' | 'archive') {
|
||||
async function runBulk(payload: Parameters<typeof store.bulkUpdate>[0]) {
|
||||
const result = await store.bulkUpdate(payload);
|
||||
if (result.skipped.length > 0) {
|
||||
window.alert(
|
||||
`Применено: ${result.updated}. Пропущено: ${result.skipped.length} (конфликт с уже доставленными лидами).`,
|
||||
);
|
||||
skipToastText.value = `Применено: ${result.updated}. Пропущено: ${result.skipped.length} (конфликт с уже доставленными лидами).`;
|
||||
skipToastOpen.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ regionsOpen, daysOpen, limitOpen });
|
||||
defineExpose({ regionsOpen, daysOpen, limitOpen, skipToastOpen, skipToastText, runBulk });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import type { Project } from '../../stores/projectsStore';
|
||||
import { useProjectsStore } from '../../stores/projectsStore';
|
||||
import { REGIONS } from '../../constants/regions';
|
||||
|
||||
const props = defineProps<{ project: Project | null }>();
|
||||
const emit = defineEmits<{ close: []; saved: [] }>();
|
||||
|
||||
interface FormState {
|
||||
name: string;
|
||||
daily_limit_target: number;
|
||||
region_mask: number;
|
||||
region_mode: 'include' | 'exclude';
|
||||
delivery_days_mask: number;
|
||||
sms_senders: string[];
|
||||
sms_keyword: string;
|
||||
}
|
||||
|
||||
const form = reactive<FormState>({
|
||||
name: '',
|
||||
daily_limit_target: 50,
|
||||
region_mask: 0,
|
||||
region_mode: 'include',
|
||||
delivery_days_mask: 127,
|
||||
sms_senders: [],
|
||||
sms_keyword: '',
|
||||
});
|
||||
|
||||
const selectedRegions = ref<number[]>([]);
|
||||
const selectableRegions = REGIONS.filter((r) => r.code !== 0);
|
||||
|
||||
function maskToCodes(mask: number): number[] {
|
||||
const codes: number[] = [];
|
||||
for (let i = 1; i <= 31; i++) if (mask & (1 << i)) codes.push(i);
|
||||
return codes;
|
||||
}
|
||||
|
||||
function reseedFromProject(p: Project | null): void {
|
||||
if (!p) return;
|
||||
form.name = p.name;
|
||||
form.daily_limit_target = p.daily_limit_target;
|
||||
form.region_mask = p.region_mask ?? 0;
|
||||
form.region_mode = (p.region_mode ?? 'include') as 'include' | 'exclude';
|
||||
form.delivery_days_mask = p.delivery_days_mask ?? 127;
|
||||
form.sms_senders = p.sms_senders ?? [];
|
||||
form.sms_keyword = p.sms_keyword ?? '';
|
||||
selectedRegions.value = maskToCodes(form.region_mask);
|
||||
}
|
||||
reseedFromProject(props.project);
|
||||
|
||||
watch(() => props.project?.id, () => {
|
||||
reseedFromProject(props.project);
|
||||
});
|
||||
|
||||
watch(selectedRegions, (codes) => {
|
||||
if (codes.length === 0) {
|
||||
form.region_mask = 0;
|
||||
form.region_mode = 'include';
|
||||
} else {
|
||||
form.region_mask = codes.reduce((acc, c) => (c >= 1 && c <= 31 ? acc | (1 << c) : acc), 0);
|
||||
form.region_mode = 'exclude';
|
||||
}
|
||||
});
|
||||
|
||||
const saving = ref(false);
|
||||
const errors = reactive<Record<string, string[]>>({});
|
||||
|
||||
const store = useProjectsStore();
|
||||
|
||||
async function onPause(): Promise<void> {
|
||||
if (!props.project) return;
|
||||
await store.toggleActive(props.project);
|
||||
}
|
||||
|
||||
async function onDelete(): Promise<void> {
|
||||
if (!props.project) return;
|
||||
const ok = window.confirm('Архивировать проект? Действие необратимо в Plan 5 (восстановление потребует ручного запроса).');
|
||||
if (!ok) return;
|
||||
await store.archive(props.project.id);
|
||||
emit('close');
|
||||
}
|
||||
|
||||
async function onSave(): Promise<void> {
|
||||
if (!props.project) return;
|
||||
saving.value = true;
|
||||
Object.keys(errors).forEach((k) => delete errors[k]);
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
name: form.name,
|
||||
daily_limit_target: form.daily_limit_target,
|
||||
region_mask: form.region_mask,
|
||||
region_mode: form.region_mode,
|
||||
delivery_days_mask: form.delivery_days_mask,
|
||||
};
|
||||
if (props.project.signal_type === 'sms') {
|
||||
payload.sms_senders = form.sms_senders;
|
||||
payload.sms_keyword = form.sms_keyword;
|
||||
}
|
||||
await axios.patch(`/api/projects/${props.project.id}`, payload);
|
||||
emit('saved');
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { status?: number; data?: { errors?: Record<string, string[]> } } };
|
||||
if (err.response?.status === 422 && err.response.data?.errors) {
|
||||
Object.assign(errors, err.response.data.errors);
|
||||
}
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent): void {
|
||||
if (e.key === 'Escape' && props.project) emit('close');
|
||||
}
|
||||
onMounted(() => document.addEventListener('keydown', onKey));
|
||||
onBeforeUnmount(() => document.removeEventListener('keydown', onKey));
|
||||
|
||||
const activeDays = computed<boolean[]>(() => {
|
||||
const mask = form.delivery_days_mask;
|
||||
return Array.from({ length: 7 }, (_, i) => (mask & (1 << i)) !== 0);
|
||||
});
|
||||
|
||||
function toggleDay(i: number): void {
|
||||
form.delivery_days_mask ^= (1 << i);
|
||||
}
|
||||
|
||||
const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="project-details-drawer" :class="{ open: project !== null }">
|
||||
<div v-if="project" class="pdd-content">
|
||||
<header class="pdd-head">
|
||||
<div class="pdd-title">{{ project.name }}</div>
|
||||
<button class="pdd-close" data-testid="pdd-close" @click="$emit('close')">✕</button>
|
||||
</header>
|
||||
|
||||
<div class="pdd-body">
|
||||
<label class="pdd-field">
|
||||
<span class="pdd-label">Название</span>
|
||||
<input v-model="form.name" data-testid="pdd-name" class="pdd-input" />
|
||||
<div v-if="errors.name" class="pdd-error" data-testid="pdd-error-name">{{ errors.name[0] }}</div>
|
||||
</label>
|
||||
|
||||
<label class="pdd-field">
|
||||
<span class="pdd-label">Лимит лидов в день</span>
|
||||
<input
|
||||
v-model.number="form.daily_limit_target"
|
||||
type="number"
|
||||
min="1"
|
||||
max="10000"
|
||||
data-testid="pdd-limit"
|
||||
class="pdd-input"
|
||||
/>
|
||||
<div v-if="errors.daily_limit_target" class="pdd-error">{{ errors.daily_limit_target[0] }}</div>
|
||||
</label>
|
||||
|
||||
<div class="pdd-field">
|
||||
<span class="pdd-label">Регионы (пусто = вся РФ)</span>
|
||||
<v-autocomplete
|
||||
v-model="selectedRegions"
|
||||
:items="selectableRegions"
|
||||
item-title="name"
|
||||
item-value="code"
|
||||
multiple
|
||||
chips
|
||||
clearable
|
||||
density="comfortable"
|
||||
hide-details
|
||||
data-testid="pdd-regions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="pdd-field">
|
||||
<span class="pdd-label">Дни недели приёма</span>
|
||||
<div class="pdd-days">
|
||||
<button
|
||||
v-for="(label, i) in dayLabels"
|
||||
:key="i"
|
||||
type="button"
|
||||
:data-testid="`pdd-day-${i}`"
|
||||
:class="['pdd-day', { active: activeDays[i] }]"
|
||||
@click="toggleDay(i)"
|
||||
>
|
||||
{{ label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="pdd-foot">
|
||||
<div class="pdd-foot-left">
|
||||
<button class="pdd-btn pdd-btn-warning" data-testid="pdd-pause" @click="onPause">
|
||||
{{ project.is_active ? '⏸ Приостановить' : '▶ Возобновить' }}
|
||||
</button>
|
||||
<button class="pdd-btn pdd-btn-error" data-testid="pdd-delete" @click="onDelete">🗄 Удалить</button>
|
||||
</div>
|
||||
<div class="pdd-foot-right">
|
||||
<button class="pdd-btn pdd-btn-text" data-testid="pdd-cancel" @click="$emit('close')">Отмена</button>
|
||||
<button
|
||||
class="pdd-btn pdd-btn-primary"
|
||||
data-testid="pdd-save"
|
||||
:disabled="saving"
|
||||
@click="onSave"
|
||||
>Сохранить</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.project-details-drawer {
|
||||
position: fixed; top: 0; right: 0; bottom: 0;
|
||||
width: 480px;
|
||||
background: var(--liderra-surface, #ffffff);
|
||||
border-left: 1px solid var(--liderra-line, #e6e2d6);
|
||||
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.06);
|
||||
transform: translateX(100%);
|
||||
transition: transform 240ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
display: flex; flex-direction: column;
|
||||
z-index: 5;
|
||||
}
|
||||
.project-details-drawer.open { transform: translateX(0); }
|
||||
.pdd-content { display: flex; flex-direction: column; height: 100%; }
|
||||
.pdd-head { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; border-bottom: 1px solid var(--liderra-line, #e6e2d6); }
|
||||
.pdd-title { font-weight: 600; font-size: 16px; }
|
||||
.pdd-close { background: none; border: 0; cursor: pointer; font-size: 18px; padding: 4px; }
|
||||
.pdd-body { padding: 16px 20px; display: flex; flex-direction: column; gap: 14px; flex: 1; overflow-y: auto; }
|
||||
.pdd-field { display: flex; flex-direction: column; gap: 4px; }
|
||||
.pdd-label { font-size: 12px; color: #6b6f72; }
|
||||
.pdd-input { padding: 8px 10px; border: 1px solid var(--liderra-line, #e6e2d6); border-radius: 6px; font: inherit; }
|
||||
.pdd-days { display: flex; gap: 4px; }
|
||||
.pdd-day { padding: 6px 10px; border: 1px solid var(--liderra-line, #e6e2d6); background: #ffffff; border-radius: 4px; cursor: pointer; font: inherit; }
|
||||
.pdd-day.active { background: #0f6e56; color: #ffffff; border-color: #0f6e56; }
|
||||
.pdd-foot { display: flex; justify-content: space-between; padding: 12px 20px; border-top: 1px solid var(--liderra-line, #e6e2d6); }
|
||||
.pdd-foot-left, .pdd-foot-right { display: flex; gap: 8px; }
|
||||
.pdd-btn { padding: 6px 14px; border: 0; border-radius: 6px; cursor: pointer; font: inherit; }
|
||||
.pdd-btn-text { background: transparent; color: #081319; }
|
||||
.pdd-btn-primary { background: #0f6e56; color: #ffffff; }
|
||||
.pdd-btn-warning { background: #f59e0b; color: #ffffff; }
|
||||
.pdd-btn-error { background: #dc2626; color: #ffffff; }
|
||||
.pdd-error { color: #dc2626; font-size: 12px; margin-top: 4px; }
|
||||
</style>
|
||||
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* Форматтеры для биллинга. Экспортируются для использования в нескольких
|
||||
* sub-components BillingView (BalanceCard, TransactionsTable, InvoicesTable).
|
||||
* Sprint 4 Phase B/2 — split BillingView.
|
||||
* Форматтеры биллинга — BillingView + TransactionsTable + InvoicesTable.
|
||||
*
|
||||
* Sprint 2 Plan C: status/format-функции (statusChipColor/statusLabel/
|
||||
* formatLabel/formatIcon) удалены — real-API транзакции не имеют статуса
|
||||
* (append-only ledger), счета — отдельный формат. txAmountClass
|
||||
* перетипизирован под знак суммы.
|
||||
*/
|
||||
import type { BillingTransaction, InvoiceFormat, TxStatus } from './mockBilling';
|
||||
|
||||
/** «5000» → «5 000 ₽» (без знака). */
|
||||
export function formatPlain(cost: number): string {
|
||||
@@ -16,36 +18,25 @@ export function formatCost(cost: number): string {
|
||||
return sign + new Intl.NumberFormat('ru-RU').format(Math.abs(cost)) + ' ₽';
|
||||
}
|
||||
|
||||
/** CSS-класс для суммы транзакции по статусу/знаку. */
|
||||
export function txAmountClass(tx: BillingTransaction): string {
|
||||
if (tx.status === 'rejected') return 'tx-amount-neutral';
|
||||
if (tx.amount > 0) return 'tx-amount-up';
|
||||
if (tx.amount < 0) return 'tx-amount-down';
|
||||
/** CSS-класс суммы транзакции по знаку. */
|
||||
export function txAmountClass(amount: number): string {
|
||||
if (amount > 0) return 'tx-amount-up';
|
||||
if (amount < 0) return 'tx-amount-down';
|
||||
return 'tx-amount-neutral';
|
||||
}
|
||||
|
||||
/** Vuetify-цвет чипа статуса транзакции. */
|
||||
export function statusChipColor(status: TxStatus): string {
|
||||
if (status === 'pending') return 'warning';
|
||||
if (status === 'completed') return 'success';
|
||||
return 'error';
|
||||
}
|
||||
/** Человекочитаемые лейблы для feature-слагов tariff_plans.features. */
|
||||
export const FEATURE_LABELS: Record<string, string> = {
|
||||
webhook: 'Webhook',
|
||||
kanban: 'Канбан',
|
||||
basic_analytics: 'Базовая аналитика',
|
||||
advanced_analytics: 'Расширенная аналитика',
|
||||
api: 'API',
|
||||
'2fa': 'Двухфакторная аутентификация',
|
||||
custom_domain: 'Свой домен',
|
||||
};
|
||||
|
||||
/** Локализованный лейбл статуса транзакции. */
|
||||
export function statusLabel(status: TxStatus): string {
|
||||
if (status === 'pending') return 'В обработке';
|
||||
if (status === 'completed') return 'Проведён';
|
||||
return 'Отклонено';
|
||||
}
|
||||
|
||||
/** Лейбл формата файла счёта/УПД (PDF / 1С 8.3 XML). */
|
||||
export function formatLabel(format: InvoiceFormat): string {
|
||||
if (format === 'pdf') return 'PDF';
|
||||
return '1С 8.3 XML';
|
||||
}
|
||||
|
||||
/** Иконка формата файла счёта/УПД. */
|
||||
export function formatIcon(format: InvoiceFormat): string {
|
||||
if (format === 'pdf') return 'mdi-file-pdf-box';
|
||||
return 'mdi-xml';
|
||||
/** Лейбл feature-слага; неизвестный слаг возвращается как есть. */
|
||||
export function featureLabel(slug: string): string {
|
||||
return FEATURE_LABELS[slug] ?? slug;
|
||||
}
|
||||
|
||||
@@ -1,167 +1,16 @@
|
||||
/**
|
||||
* Mock-данные для BillingView. Заменятся на API-fetch:
|
||||
* GET /api/billing/wallet — баланс ₽ + леды + tariff.
|
||||
* GET /api/billing/transactions?type={all|topup|charge|refund} — `balance_transactions`.
|
||||
* GET /api/billing/invoices — `invoices` table (счета + УПД).
|
||||
* Мок платежа «в обработке» для pending-баннера BillingView.
|
||||
*
|
||||
* Mock-структуры соответствуют схеме v8.7:
|
||||
* - balance_transactions (§4.4): type ∈ {topup, lead_charge, refund, tariff_charge, manager_addon}.
|
||||
* - invoices (§4.5): type ∈ {invoice, upd}, format ∈ {pdf, xml_1c83}.
|
||||
* Кошелёк / транзакции / счета подключены к real API (api/billing.ts) в
|
||||
* Sprint 2 Plan C (E3). Pending-баннер — отдельный эпик E4 (Sprint 5);
|
||||
* до его реализации остаётся mock.
|
||||
*/
|
||||
|
||||
export type TxType = 'topup' | 'lead_charge' | 'refund' | 'tariff_charge';
|
||||
export type TxStatus = 'pending' | 'completed' | 'rejected';
|
||||
|
||||
export interface BillingTransaction {
|
||||
id: number;
|
||||
code: string; // 'TX-89421'
|
||||
when: string; // '07.05 · 14:21'
|
||||
type: TxType;
|
||||
description: string;
|
||||
status: TxStatus;
|
||||
amount: number; // signed (+ topup/refund, − charge)
|
||||
}
|
||||
|
||||
export const MOCK_TRANSACTIONS: BillingTransaction[] = [
|
||||
{
|
||||
id: 89421,
|
||||
code: 'TX-89421',
|
||||
when: '07.05 · 14:21',
|
||||
type: 'topup',
|
||||
description: 'Пополнение через ЮKassa',
|
||||
status: 'pending',
|
||||
amount: 5000,
|
||||
},
|
||||
{
|
||||
id: 89384,
|
||||
code: 'TX-89384',
|
||||
when: '07.05 · 11:14',
|
||||
type: 'lead_charge',
|
||||
description: 'Списание · 3 лида проект «Окна Москва»',
|
||||
status: 'completed',
|
||||
amount: -6600,
|
||||
},
|
||||
{
|
||||
id: 89370,
|
||||
code: 'TX-89370',
|
||||
when: '07.05 · 09:48',
|
||||
type: 'refund',
|
||||
description: 'Возврат лида #1018 · дубликат',
|
||||
status: 'completed',
|
||||
amount: 2200,
|
||||
},
|
||||
{
|
||||
id: 89312,
|
||||
code: 'TX-89312',
|
||||
when: '06.05 · 22:06',
|
||||
type: 'topup',
|
||||
description: 'Пополнение через ЮKassa',
|
||||
status: 'completed',
|
||||
amount: 10000,
|
||||
},
|
||||
{
|
||||
id: 89286,
|
||||
code: 'TX-89286',
|
||||
when: '06.05 · 18:32',
|
||||
type: 'lead_charge',
|
||||
description: 'Списание · 5 лидов проект «Натяжные потолки»',
|
||||
status: 'completed',
|
||||
amount: -9250,
|
||||
},
|
||||
{
|
||||
id: 89108,
|
||||
code: 'TX-89108',
|
||||
when: '05.05 · 12:00',
|
||||
type: 'tariff_charge',
|
||||
description: 'Списание абонентской платы тарифа «Команда»',
|
||||
status: 'completed',
|
||||
amount: -990,
|
||||
},
|
||||
{
|
||||
id: 88937,
|
||||
code: 'TX-88937',
|
||||
when: '04.05 · 16:42',
|
||||
type: 'topup',
|
||||
description: 'Попытка пополнения через банковский перевод',
|
||||
status: 'rejected',
|
||||
amount: 0,
|
||||
},
|
||||
{
|
||||
id: 88714,
|
||||
code: 'TX-88714',
|
||||
when: '03.05 · 09:18',
|
||||
type: 'refund',
|
||||
description: 'Возврат лида #998 · спам',
|
||||
status: 'completed',
|
||||
amount: 1850,
|
||||
},
|
||||
];
|
||||
|
||||
export interface BillingTab {
|
||||
id: 'all' | 'topup' | 'lead_charge' | 'refund';
|
||||
label: string;
|
||||
types: TxType[] | null;
|
||||
}
|
||||
|
||||
export const BILLING_TABS: BillingTab[] = [
|
||||
{ id: 'all', label: 'Все', types: null },
|
||||
{ id: 'topup', label: 'Пополнения', types: ['topup'] },
|
||||
{ id: 'lead_charge', label: 'Списания', types: ['lead_charge', 'tariff_charge'] },
|
||||
{ id: 'refund', label: 'Возвраты', types: ['refund'] },
|
||||
];
|
||||
|
||||
export type InvoiceFormat = 'pdf' | 'xml_1c83';
|
||||
|
||||
export interface Invoice {
|
||||
id: number;
|
||||
when: string; // '07.05.2026'
|
||||
title: string; // 'Счёт № 2026-0512'
|
||||
sub: string; // 'Тариф «Команда» · май 2026'
|
||||
amountRub: number;
|
||||
format: InvoiceFormat;
|
||||
}
|
||||
|
||||
export const MOCK_INVOICES: Invoice[] = [
|
||||
{
|
||||
id: 1,
|
||||
when: '07.05.2026',
|
||||
title: 'Счёт № 2026-0512',
|
||||
sub: 'Тариф «Команда» · май 2026',
|
||||
amountRub: 990,
|
||||
format: 'pdf',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
when: '06.05.2026',
|
||||
title: 'УПД № УПД-2026-0492',
|
||||
sub: 'Списания за апрель · 18 лидов',
|
||||
amountRub: 29850,
|
||||
format: 'xml_1c83',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
when: '05.05.2026',
|
||||
title: 'УПД № УПД-2026-0488',
|
||||
sub: 'Списания за март · 24 лида',
|
||||
amountRub: 38100,
|
||||
format: 'xml_1c83',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
when: '01.04.2026',
|
||||
title: 'Счёт № 2026-0498',
|
||||
sub: 'Тариф «Команда» · апрель 2026',
|
||||
amountRub: 990,
|
||||
format: 'pdf',
|
||||
},
|
||||
];
|
||||
|
||||
export interface PendingPayment {
|
||||
code: string;
|
||||
amount: number;
|
||||
method: string; // 'ЮKassa'
|
||||
startedAt: string; // '14:21'
|
||||
autoCancelAt: string; // '14:51'
|
||||
method: string;
|
||||
startedAt: string;
|
||||
autoCancelAt: string;
|
||||
timeoutMinutes: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ export const STATUS_PILL_SLUGS = [
|
||||
'archived',
|
||||
] as const;
|
||||
|
||||
export type StatusPillSlug = (typeof STATUS_PILL_SLUGS)[number];
|
||||
type StatusPillSlug = (typeof STATUS_PILL_SLUGS)[number];
|
||||
|
||||
const STYLES: Record<StatusPillSlug, PillStyle> = {
|
||||
new: { bg: 'rgba(15,110,86,0.12)', color: '#0F6E56' },
|
||||
|
||||
@@ -12,7 +12,6 @@ export const setupVue3 = defineSetupVue3(({ app }) => {
|
||||
{ path: '/register', component: { template: '<div />' } },
|
||||
{ path: '/forgot', component: { template: '<div />' } },
|
||||
{ path: '/2fa', component: { template: '<div />' } },
|
||||
{ path: '/recovery', component: { template: '<div />' } },
|
||||
{ path: '/recovery-use', component: { template: '<div />' } },
|
||||
{ path: '/dashboard', component: { template: '<div />' } },
|
||||
{ path: '/deals', component: { template: '<div />' } },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Layout админки SaaS — отдельный sidebar с пометкой ADMIN, 4 nav-пункта,
|
||||
* Layout админки SaaS — отдельный sidebar с пометкой ADMIN, 7 nav-пунктов,
|
||||
* без user-chip как в обычной AppLayout.
|
||||
*
|
||||
* Источник дизайна: liderra_v8_handoff/concepts/v8_admin.html.
|
||||
@@ -8,7 +8,6 @@
|
||||
*
|
||||
* Не входит в этот коммит:
|
||||
* - Auth-guard на /admin/* — должен проверять `super_admin` role + 2FA.
|
||||
* - Impersonation banner (когда admin вошёл «как клиент» — Ю-1: 15 мин / 5 попыток).
|
||||
* - Audit-log записей для всех action'ов admin (по schema v8.7 §10
|
||||
* `saas_admin_audit_log`).
|
||||
*/
|
||||
@@ -16,6 +15,7 @@ import { useAuthStore } from '../stores/auth';
|
||||
import { computed } from 'vue';
|
||||
import { RouterView, useRoute, useRouter } from 'vue-router';
|
||||
import DevIndexBadge from '../components/DevIndexBadge.vue';
|
||||
import ImpersonationBanner from '../components/admin/ImpersonationBanner.vue';
|
||||
|
||||
interface NavItem {
|
||||
title: string;
|
||||
@@ -27,6 +27,8 @@ interface NavItem {
|
||||
const navItems: NavItem[] = [
|
||||
{ title: 'Тенанты', icon: 'mdi-account-group-outline', to: '/admin/tenants', count: 142 },
|
||||
{ title: 'Биллинг', icon: 'mdi-credit-card-outline', to: '/admin/billing' },
|
||||
{ title: 'Тарифная сетка', icon: 'mdi-tag-arrow-right', to: '/admin/pricing-tiers' },
|
||||
{ title: 'Цены поставщиков', icon: 'mdi-currency-rub', to: '/admin/supplier-prices' },
|
||||
{ title: 'Инциденты', icon: 'mdi-alert-outline', to: '/admin/incidents', count: 3 },
|
||||
{ title: 'Impersonation', icon: 'mdi-account-switch', to: '/admin/impersonation' },
|
||||
{ title: 'Система', icon: 'mdi-cog-outline', to: '/admin/system' },
|
||||
@@ -129,6 +131,7 @@ const currentPageTitle = computed(() => {
|
||||
</v-app-bar>
|
||||
|
||||
<v-main class="admin-main">
|
||||
<ImpersonationBanner />
|
||||
<RouterView />
|
||||
</v-main>
|
||||
<DevIndexBadge :index="route.meta.devIndex" :label="route.meta.devLabel" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Двухпанельный layout для экранов аутентификации (login/register/2fa/forgot/recovery).
|
||||
* Двухпанельный layout для экранов аутентификации (login/register/2fa/forgot/recovery-use).
|
||||
*
|
||||
* Источник дизайна: liderra_v8_handoff/concepts/v8_login.html (CSS .layout grid 1fr 1fr,
|
||||
* .brand-pane слева тёмный теало-нуар + radial-gradient'ы, .form-pane справа warm ivory).
|
||||
@@ -44,8 +44,8 @@ const route = useRoute();
|
||||
</p>
|
||||
<div class="bp-foot">
|
||||
<span>v8 · Forest</span>
|
||||
<a href="/legal/offer">Оферта</a>
|
||||
<a href="/legal/privacy">Политика</a>
|
||||
<RouterLink to="/legal/offer">Оферта</RouterLink>
|
||||
<RouterLink to="/legal/privacy">Политика</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</v-col>
|
||||
|
||||
@@ -4,18 +4,94 @@ import { h, type Component } from 'vue';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import type { ThemeDefinition, IconSet, IconProps } from 'vuetify';
|
||||
import {
|
||||
Activity, AlertCircle, AlertTriangle, Archive, ArrowDown, ArrowLeft, ArrowRightLeft,
|
||||
ArrowUp, Bell, BellOff, Calendar, CalendarDays, Camera, Check, CheckCircle, ChevronDown,
|
||||
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, ChevronsUpDown, ChevronUp,
|
||||
Circle, CircleDot, CircleStop, Clock, Code,
|
||||
Columns3, Copy, CreditCard, Download, Eye, EyeOff, FilterX, FileText, FlaskConical,
|
||||
Folder, Folders, Globe, HelpCircle, Info, Key, KeyRound, LayoutDashboard, List, LogOut, Mail,
|
||||
Megaphone, Menu, MessageSquare, MessageSquareText, Minus, MoreVertical, Paperclip, Pause,
|
||||
Pencil, Phone, Play, Plus,
|
||||
PlusCircle, Puzzle, ReceiptText, RefreshCw, RotateCcw, RotateCw, RussianRuble, Save, Search,
|
||||
Settings, Shield, ShieldCheck, ShieldOff, Square, SquareCheck, SquareMinus, Star, StarHalf,
|
||||
Tag, Trash2, User, UserCheck, UserCog, UserPlus,
|
||||
Users, Wallet, Webhook, X, XCircle,
|
||||
Activity,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Archive,
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRightLeft,
|
||||
ArrowUp,
|
||||
Bell,
|
||||
BellOff,
|
||||
Calendar,
|
||||
CalendarDays,
|
||||
Camera,
|
||||
Check,
|
||||
CheckCircle,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
ChevronsUpDown,
|
||||
ChevronUp,
|
||||
Circle,
|
||||
CircleDot,
|
||||
CircleStop,
|
||||
Clock,
|
||||
Code,
|
||||
Columns3,
|
||||
Copy,
|
||||
CreditCard,
|
||||
Download,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FilterX,
|
||||
FileText,
|
||||
FlaskConical,
|
||||
Folder,
|
||||
Folders,
|
||||
Globe,
|
||||
HelpCircle,
|
||||
Info,
|
||||
Key,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
List,
|
||||
LogOut,
|
||||
Mail,
|
||||
Megaphone,
|
||||
Menu,
|
||||
MessageSquare,
|
||||
MessageSquareText,
|
||||
Minus,
|
||||
MoreVertical,
|
||||
Paperclip,
|
||||
Pause,
|
||||
Pencil,
|
||||
Phone,
|
||||
Play,
|
||||
Plus,
|
||||
PlusCircle,
|
||||
Puzzle,
|
||||
ReceiptText,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
RotateCw,
|
||||
RussianRuble,
|
||||
Save,
|
||||
Search,
|
||||
Settings,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
Square,
|
||||
SquareCheck,
|
||||
SquareMinus,
|
||||
Star,
|
||||
StarHalf,
|
||||
Tag,
|
||||
Trash2,
|
||||
User,
|
||||
UserCheck,
|
||||
UserCog,
|
||||
UserPlus,
|
||||
Users,
|
||||
Wallet,
|
||||
Webhook,
|
||||
X,
|
||||
XCircle,
|
||||
} from 'lucide-vue-next';
|
||||
|
||||
/**
|
||||
|
||||
@@ -58,18 +58,18 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('../views/auth/ForgotPasswordView.vue'),
|
||||
meta: { layout: 'auth', title: 'Сброс пароля', guestOnly: true, devIndex: 4, devLabel: 'Forgot password' },
|
||||
},
|
||||
{
|
||||
path: '/recovery',
|
||||
name: 'recovery',
|
||||
component: () => import('../views/auth/RecoveryCodesView.vue'),
|
||||
meta: { layout: 'auth', title: 'Резервные коды', devIndex: 6, devLabel: 'Recovery codes' },
|
||||
},
|
||||
{
|
||||
path: '/recovery-use',
|
||||
name: 'recovery-use',
|
||||
component: () => import('../views/auth/UseRecoveryCodeView.vue'),
|
||||
meta: { layout: 'auth', title: 'Вход по резервному коду', devIndex: 7, devLabel: 'Use recovery' },
|
||||
},
|
||||
{
|
||||
path: '/legal/:doc(offer|privacy)',
|
||||
name: 'legal',
|
||||
component: () => import('../views/legal/LegalDocView.vue'),
|
||||
meta: { layout: 'auth', title: 'Правовые документы' },
|
||||
},
|
||||
{
|
||||
path: '/reset/:token',
|
||||
name: 'reset-password',
|
||||
|
||||
@@ -1,47 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Биллинг и тарифы — финансовый экран. Кошелёк ₽, баланс лидов,
|
||||
* текущий тариф, история транзакций и счета/УПД.
|
||||
* текущий тариф, история транзакций и счета.
|
||||
*
|
||||
* Источник дизайна: liderra_v8_handoff/concepts/v8_billing.html.
|
||||
* MVP: page-head + pending banner + 3 wallet-cards (BalanceCard) +
|
||||
* transactions table с табами (TransactionsTable) + invoices list (InvoicesTable).
|
||||
* Mock-данные из composables/mockBilling.ts.
|
||||
* Sprint 2 Plan C (E3): Overview-таб подвязан на real API
|
||||
* (GET /api/billing/wallet → BalanceCard + шапка; TransactionsTable и
|
||||
* InvoicesTable тянут данные сами). Списания — ChargesTab (Plan 4).
|
||||
*
|
||||
* Sprint 4 Phase B/2 — split на shell + 3 sub-components (audit O-refactor-04 хвост).
|
||||
*
|
||||
* Plan 4 Task 11 — добавлен top-level v-tabs split:
|
||||
* - "Обзор" — существующий контент (mock-balance, transactions, invoices).
|
||||
* - "Списания" — ChargesTab, real backend ledger (GET /api/billing/charges).
|
||||
*
|
||||
* Не входит в MVP:
|
||||
* - TopupDialog (диалог настройки автопополнения через ЮKassa).
|
||||
* - Tariff change wizard (диалог смены тарифа с расчётом разницы).
|
||||
* - Tariff comparison table (4 тарифа: Solo/Команда/Бизнес/Корпоративный).
|
||||
* - Refund-request dialog (заявка на возврат).
|
||||
*
|
||||
* Backend (отдельный коммит):
|
||||
* - GET /api/billing/wallet — балансы.
|
||||
* - GET /api/billing/transactions?type=...&page=... — пагинация.
|
||||
* - POST /api/billing/topup → ЮKassa-checkout.
|
||||
* - GET /api/billing/invoices/{id}/file — PDF/XML download.
|
||||
* Pending-баннер остаётся mock (MOCK_PENDING) — это отдельный эпик E4
|
||||
* (Sprint 5). TopupDialog «Пополнить баланс» — Task 5 (E1).
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import BalanceCard from '../components/billing/BalanceCard.vue';
|
||||
import TransactionsTable from '../components/billing/TransactionsTable.vue';
|
||||
import InvoicesTable from '../components/billing/InvoicesTable.vue';
|
||||
import TopupDialog from '../components/billing/TopupDialog.vue';
|
||||
import ChargesTab from './billing/ChargesTab.vue';
|
||||
import { MOCK_PENDING } from '../composables/mockBilling';
|
||||
import { formatPlain } from '../composables/billingFormatters';
|
||||
|
||||
const walletRub = 14250;
|
||||
const leadsBalance = 285;
|
||||
const runwayDays = 4;
|
||||
const tariffName = 'Команда';
|
||||
const tariffPrice = 990;
|
||||
const tariffFeatures = ['до 10 проектов', '4 менеджера + расширение', 'Канбан, Webhook, API'];
|
||||
import { formatPlain, featureLabel } from '../composables/billingFormatters';
|
||||
import { getWallet, type Wallet } from '../api/billing';
|
||||
import { extractErrorMessage } from '../api/client';
|
||||
|
||||
const activeView = ref<'overview' | 'charges'>('overview');
|
||||
|
||||
const wallet = ref<Wallet | null>(null);
|
||||
const loading = ref(true);
|
||||
const loadError = ref<string | null>(null);
|
||||
const topupOpen = ref(false);
|
||||
const topupSnackbar = ref(false);
|
||||
const txTableRef = ref<InstanceType<typeof TransactionsTable> | null>(null);
|
||||
|
||||
const walletRub = computed(() => Number(wallet.value?.balance_rub ?? 0));
|
||||
const leadsBalance = computed(() => wallet.value?.balance_leads ?? 0);
|
||||
const runwayDays = computed(() => wallet.value?.runway_days ?? null);
|
||||
const tariffName = computed(() => wallet.value?.tariff?.name ?? null);
|
||||
const tariffPrice = computed(() => wallet.value?.tariff?.price_monthly ?? null);
|
||||
const tariffFeatures = computed<string[]>(() => (wallet.value?.tariff?.features ?? []).map(featureLabel));
|
||||
|
||||
async function loadWallet(): Promise<void> {
|
||||
loading.value = true;
|
||||
loadError.value = null;
|
||||
try {
|
||||
wallet.value = await getWallet();
|
||||
} catch (e) {
|
||||
// Сброс устаревших данных: при неудачном повторе не оставляем
|
||||
// прошлый успешный wallet в памяти (защита от ложного рендера).
|
||||
wallet.value = null;
|
||||
loadError.value = extractErrorMessage(e, 'Не удалось загрузить данные биллинга.');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onTopupSuccess(): Promise<void> {
|
||||
// success-событие несёт новый баланс, но мы намеренно перезапрашиваем
|
||||
// кошелёк (loadWallet) — единый источник истины надёжнее точечного патча.
|
||||
topupOpen.value = false;
|
||||
topupSnackbar.value = true;
|
||||
await loadWallet();
|
||||
txTableRef.value?.refresh();
|
||||
}
|
||||
|
||||
onMounted(loadWallet);
|
||||
|
||||
defineExpose({ loadWallet, wallet, topupOpen });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -49,7 +71,7 @@ const activeView = ref<'overview' | 'charges'>('overview');
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<h1 class="text-h4 mb-2 page-title">Биллинг и тарифы</h1>
|
||||
<div class="page-stats text-body-2 text-medium-emphasis">
|
||||
<div v-if="wallet" class="page-stats text-body-2 text-medium-emphasis">
|
||||
<span
|
||||
><span class="num text-primary">{{ formatPlain(walletRub) }}</span> кошелёк</span
|
||||
>
|
||||
@@ -57,13 +79,17 @@ const activeView = ref<'overview' | 'charges'>('overview');
|
||||
<span
|
||||
><span class="num">{{ leadsBalance }}</span> лидов запас</span
|
||||
>
|
||||
<span class="sep">·</span>
|
||||
<span
|
||||
>хватит на <span class="num">{{ runwayDays }} дня</span></span
|
||||
>
|
||||
<template v-if="runwayDays !== null">
|
||||
<span class="sep">·</span>
|
||||
<span
|
||||
>хватит на <span class="num">{{ runwayDays }}</span> дн.</span
|
||||
>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<v-btn color="primary" variant="flat" prepend-icon="mdi-plus">Пополнить баланс</v-btn>
|
||||
<v-btn color="primary" variant="flat" prepend-icon="mdi-plus" @click="topupOpen = true"
|
||||
>Пополнить баланс</v-btn
|
||||
>
|
||||
</header>
|
||||
|
||||
<v-tabs v-model="activeView" color="primary" class="mt-4">
|
||||
@@ -73,30 +99,56 @@ const activeView = ref<'overview' | 'charges'>('overview');
|
||||
|
||||
<v-tabs-window v-model="activeView">
|
||||
<v-tabs-window-item value="overview">
|
||||
<v-alert v-if="MOCK_PENDING" type="info" variant="tonal" density="compact" class="mt-4" role="status">
|
||||
<strong>1 платёж в обработке</strong> — {{ formatPlain(MOCK_PENDING.amount) }} от
|
||||
{{ MOCK_PENDING.method }}, начат {{ MOCK_PENDING.startedAt }}. Авто-восстановление в
|
||||
{{ MOCK_PENDING.autoCancelAt }} ({{ MOCK_PENDING.timeoutMinutes }} мин). Кнопки «Отменить» нет — это
|
||||
техническое решение.
|
||||
<div v-if="loading" class="py-12 d-flex justify-center">
|
||||
<v-progress-circular indeterminate color="primary" />
|
||||
</div>
|
||||
|
||||
<v-alert v-else-if="loadError" type="error" variant="tonal" class="mt-4" role="alert">
|
||||
{{ loadError }}
|
||||
<template #append>
|
||||
<v-btn size="small" variant="text" @click="loadWallet">Повторить</v-btn>
|
||||
</template>
|
||||
</v-alert>
|
||||
|
||||
<BalanceCard
|
||||
:wallet-rub="walletRub"
|
||||
:leads-balance="leadsBalance"
|
||||
:tariff-name="tariffName"
|
||||
:tariff-price="tariffPrice"
|
||||
:tariff-features="tariffFeatures"
|
||||
/>
|
||||
<template v-else-if="wallet">
|
||||
<v-alert
|
||||
v-if="MOCK_PENDING"
|
||||
type="info"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mt-4"
|
||||
role="status"
|
||||
>
|
||||
<strong>1 платёж в обработке</strong> — {{ formatPlain(MOCK_PENDING.amount) }} от
|
||||
{{ MOCK_PENDING.method }}, начат {{ MOCK_PENDING.startedAt }}. Авто-восстановление в
|
||||
{{ MOCK_PENDING.autoCancelAt }} ({{ MOCK_PENDING.timeoutMinutes }} мин).
|
||||
</v-alert>
|
||||
|
||||
<TransactionsTable />
|
||||
<BalanceCard
|
||||
:wallet-rub="walletRub"
|
||||
:leads-balance="leadsBalance"
|
||||
:tariff-name="tariffName"
|
||||
:tariff-price="tariffPrice"
|
||||
:tariff-features="tariffFeatures"
|
||||
@topup="topupOpen = true"
|
||||
/>
|
||||
|
||||
<InvoicesTable />
|
||||
<TransactionsTable ref="txTableRef" />
|
||||
|
||||
<InvoicesTable />
|
||||
</template>
|
||||
</v-tabs-window-item>
|
||||
|
||||
<v-tabs-window-item value="charges">
|
||||
<ChargesTab />
|
||||
</v-tabs-window-item>
|
||||
</v-tabs-window>
|
||||
|
||||
<TopupDialog v-model="topupOpen" @success="onTopupSuccess" />
|
||||
|
||||
<v-snackbar v-model="topupSnackbar" color="success" :timeout="4000">
|
||||
Баланс пополнен.
|
||||
</v-snackbar>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
@@ -123,7 +175,8 @@ const activeView = ref<'overview' | 'charges'>('overview');
|
||||
align-items: center;
|
||||
}
|
||||
.page-stats .sep {
|
||||
color: #92907b;
|
||||
/* WCAG2AA 4.5:1: #6b6356 → 5.33:1 on ivory. */
|
||||
color: #6b6356;
|
||||
}
|
||||
|
||||
.num {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
*
|
||||
* Источник статусов — composables/leadStatuses.ts (snapshot из db/schema.sql:2130).
|
||||
*/
|
||||
import { computed, defineAsyncComponent, onMounted, reactive, ref } from 'vue';
|
||||
import { computed, defineAsyncComponent, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { DEALS_TABS, MOCK_DEALS, type MockDeal } from '../composables/mockDeals';
|
||||
import { mapApiDeal } from '../composables/dealsApiMapper';
|
||||
import { usePolling } from '../composables/usePolling';
|
||||
@@ -37,11 +37,61 @@ import { buildCsvString, triggerBlobDownload, triggerCsvDownload } from '../comp
|
||||
// Task 15: density-toggle composable (persists в localStorage, влияет на row height).
|
||||
const { rowHeight } = useDensity();
|
||||
|
||||
// Task 15: stub-обработчики redesign-filter-chip'ов. На I1 — popover'ы Проект/Менеджер
|
||||
// не реализованы; chiprow служит quiet-luxury визуальной заменой для status-summary'ов.
|
||||
// Не ломает существующие VSelect'ы в DealsFilters — те остаются как полноценный filter UI.
|
||||
// Sprint 1 C2: popovers для Проект/Менеджер chip'ов. Draft-state накапливает
|
||||
// выбор внутри v-menu; копируется из filterProjects/filterManagers при открытии
|
||||
// (watch на menu open=true); переносится обратно в filterProjects/Managers на
|
||||
// «Применить» button. Status chip — read-only (P2 backlog Sprint 5).
|
||||
const projectMenuOpen = ref(false);
|
||||
const managerMenuOpen = ref(false);
|
||||
const projectMenuDraft = ref<string[]>([]);
|
||||
const managerMenuDraft = ref<string[]>([]);
|
||||
|
||||
// При открытии меню — копируем текущий filter в draft (snapshot-on-open).
|
||||
// При закрытии без apply — draft остаётся, но не влияет на filterProjects
|
||||
// (apply нужен явно).
|
||||
watch(projectMenuOpen, (isOpen) => {
|
||||
if (isOpen) projectMenuDraft.value = [...filterProjects.value];
|
||||
});
|
||||
watch(managerMenuOpen, (isOpen) => {
|
||||
if (isOpen) managerMenuDraft.value = [...filterManagers.value];
|
||||
});
|
||||
|
||||
function onRedesignFilterClick(name: string): void {
|
||||
console.log(`[redesign filterbar] ${name} clicked — popover TBD`);
|
||||
// Status chip — read-only summary (P2 backlog Sprint 5).
|
||||
// Project/Manager managed by v-menu activator (no manual click handler needed).
|
||||
if (name === 'Статус') {
|
||||
// no-op — placeholder для будущей реализации
|
||||
}
|
||||
}
|
||||
|
||||
function applyProjectFilter(): void {
|
||||
filterProjects.value = [...projectMenuDraft.value];
|
||||
projectMenuOpen.value = false;
|
||||
}
|
||||
|
||||
function applyManagerFilter(): void {
|
||||
filterManagers.value = [...managerMenuDraft.value];
|
||||
managerMenuOpen.value = false;
|
||||
}
|
||||
|
||||
function clearProjectDraft(): void {
|
||||
projectMenuDraft.value = [];
|
||||
}
|
||||
|
||||
function clearManagerDraft(): void {
|
||||
managerMenuDraft.value = [];
|
||||
}
|
||||
|
||||
function toggleProjectDraft(proj: string): void {
|
||||
projectMenuDraft.value = projectMenuDraft.value.includes(proj)
|
||||
? projectMenuDraft.value.filter((p) => p !== proj)
|
||||
: [...projectMenuDraft.value, proj];
|
||||
}
|
||||
|
||||
function toggleManagerDraft(name: string): void {
|
||||
managerMenuDraft.value = managerMenuDraft.value.includes(name)
|
||||
? managerMenuDraft.value.filter((m) => m !== name)
|
||||
: [...managerMenuDraft.value, name];
|
||||
}
|
||||
|
||||
const auth = useAuthStore();
|
||||
@@ -350,6 +400,16 @@ defineExpose({
|
||||
trashMode,
|
||||
toggleTrashMode,
|
||||
applyBulkRestoreFromTrash,
|
||||
projectMenuOpen,
|
||||
managerMenuOpen,
|
||||
projectMenuDraft,
|
||||
managerMenuDraft,
|
||||
applyProjectFilter,
|
||||
applyManagerFilter,
|
||||
clearProjectDraft,
|
||||
clearManagerDraft,
|
||||
toggleProjectDraft,
|
||||
toggleManagerDraft,
|
||||
});
|
||||
|
||||
const leadStatuses = computed(() => leadStatusesStore.statuses);
|
||||
@@ -463,10 +523,9 @@ const waitingPay = computed(() => dealsState.filter((d) => d.statusSlug === 'wai
|
||||
@clear-filters="clearFilters"
|
||||
/>
|
||||
|
||||
<!-- Task 15: redesign-filterbar (quiet luxury chiprow + density toggle).
|
||||
Минимальный набор: 3 FilterChip-ярлыка (Статус/Проект/Менеджер) + DensityToggle справа.
|
||||
Клики на I1 — stub'ы (popover'ы — TBD); полноценные multi-select'ы остаются в DealsFilters выше.
|
||||
Status-legend ниже визуализирует пул цветов StatusPill'ов воронки. -->
|
||||
<!-- Sprint 1 C2: redesign-filterbar с popover'ами для Проект/Менеджер.
|
||||
Status chip остаётся read-only (P2 backlog Sprint 5).
|
||||
Полноценные multi-select'ы в DealsFilters выше сохранены. -->
|
||||
<div v-if="!trashMode" class="ld-filterbar mt-3">
|
||||
<div class="ld-filterbar__chips">
|
||||
<FilterChip
|
||||
@@ -475,18 +534,99 @@ const waitingPay = computed(() => dealsState.filter((d) => d.statusSlug === 'wai
|
||||
:active="false"
|
||||
@click="onRedesignFilterClick('Статус')"
|
||||
/>
|
||||
<FilterChip
|
||||
label="Проект"
|
||||
:count="filterProjects.length"
|
||||
:active="filterProjects.length > 0"
|
||||
@click="onRedesignFilterClick('Проект')"
|
||||
/>
|
||||
<FilterChip
|
||||
label="Менеджер"
|
||||
:count="filterManagers.length"
|
||||
:active="filterManagers.length > 0"
|
||||
@click="onRedesignFilterClick('Менеджер')"
|
||||
/>
|
||||
<v-menu v-model="projectMenuOpen" :close-on-content-click="false" location="bottom start">
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<span v-bind="activatorProps">
|
||||
<FilterChip
|
||||
label="Проект"
|
||||
:count="filterProjects.length"
|
||||
:active="filterProjects.length > 0"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
<v-card min-width="260" max-width="320" data-testid="project-menu-card">
|
||||
<v-card-text class="pa-2">
|
||||
<v-list density="compact" class="pa-0">
|
||||
<v-list-item v-if="availableProjects.length === 0" class="text-medium-emphasis">
|
||||
<v-list-item-title>Нет проектов в текущем списке</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
v-for="proj in availableProjects"
|
||||
:key="proj"
|
||||
class="py-1"
|
||||
@click="toggleProjectDraft(proj)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-checkbox-btn :model-value="projectMenuDraft.includes(proj)" />
|
||||
</template>
|
||||
<v-list-item-title>{{ proj }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
<v-card-actions class="px-3 pb-2">
|
||||
<v-btn variant="text" size="small" data-testid="project-menu-clear" @click="clearProjectDraft">
|
||||
Очистить
|
||||
</v-btn>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
size="small"
|
||||
data-testid="project-menu-apply"
|
||||
@click="applyProjectFilter"
|
||||
>
|
||||
Применить
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
<v-menu v-model="managerMenuOpen" :close-on-content-click="false" location="bottom start">
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<span v-bind="activatorProps">
|
||||
<FilterChip
|
||||
label="Менеджер"
|
||||
:count="filterManagers.length"
|
||||
:active="filterManagers.length > 0"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
<v-card min-width="260" max-width="320" data-testid="manager-menu-card">
|
||||
<v-card-text class="pa-2">
|
||||
<v-list density="compact" class="pa-0">
|
||||
<v-list-item v-if="availableManagers.length === 0" class="text-medium-emphasis">
|
||||
<v-list-item-title>Нет менеджеров в текущем списке</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
v-for="mgr in availableManagers"
|
||||
:key="mgr.name"
|
||||
class="py-1"
|
||||
@click="toggleManagerDraft(mgr.name)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-checkbox-btn :model-value="managerMenuDraft.includes(mgr.name)" />
|
||||
</template>
|
||||
<v-list-item-title>{{ mgr.name }}</v-list-item-title>
|
||||
<v-list-item-subtitle class="text-caption">{{ mgr.initials }}</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
<v-card-actions class="px-3 pb-2">
|
||||
<v-btn variant="text" size="small" data-testid="manager-menu-clear" @click="clearManagerDraft">
|
||||
Очистить
|
||||
</v-btn>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
size="small"
|
||||
data-testid="manager-menu-apply"
|
||||
@click="applyManagerFilter"
|
||||
>
|
||||
Применить
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</div>
|
||||
<DensityToggle class="ld-filterbar__density" />
|
||||
</div>
|
||||
@@ -607,7 +747,8 @@ const waitingPay = computed(() => dealsState.filter((d) => d.statusSlug === 'wai
|
||||
align-items: center;
|
||||
}
|
||||
.page-stats .sep {
|
||||
color: #92907b;
|
||||
/* WCAG2AA 4.5:1 fix (was #92907b → 2.92:1 on ivory; #6b6356 → 5.33:1). */
|
||||
color: #6b6356;
|
||||
}
|
||||
|
||||
.num {
|
||||
|
||||
@@ -5,13 +5,16 @@
|
||||
* Источник дизайна: liderra_v8_handoff/concepts/v8_kanban.html.
|
||||
* DnD реализован через vuedraggable@4 (обёртка SortableJS) — карточки можно
|
||||
* перетаскивать между колонками. При drop:
|
||||
* - событие 'added' в целевой колонке → меняем `statusSlug` сделки.
|
||||
* - событие 'added' в целевой колонке → optimistic update statusSlug +
|
||||
* POST /api/deals/transition (через dealsApi). На failure — revert:
|
||||
* карточка возвращается в исходную колонку + toast «Не удалось переместить».
|
||||
* Без auth.user.tenant_id — local-only mode (API не зовётся).
|
||||
* - событие 'removed' в исходной колонке → ничего не делаем (обработано в added).
|
||||
* - событие 'moved' внутри одной колонки → только смена порядка (statusSlug
|
||||
* не меняется; на API будущем — PATCH /api/deals/{id} {sort_order}).
|
||||
*
|
||||
* Не входит в этот коммит:
|
||||
* - PATCH /api/deals/{id} {status_slug} при drop — backend.
|
||||
* - PATCH /api/deals/{id} {sort_order} при moved (intra-column reorder) — backend.
|
||||
* - Filters (Проект/Менеджер) — общий filter-bar с DealsView.
|
||||
* - DealDetailDrawer на click по карточке (event @open-deal).
|
||||
*/
|
||||
@@ -50,14 +53,44 @@ const dealsByStatus = reactive<Record<string, MockDeal[]>>(
|
||||
}, {}),
|
||||
);
|
||||
|
||||
function onColumnChange(targetSlug: MockDeal['statusSlug'], event: DraggableChangeEvent) {
|
||||
if (event.added) {
|
||||
// Карточка переехала в эту колонку → синхронизируем statusSlug.
|
||||
// На production будет POST /api/deals/{id}/transition с проверкой allowed-переходов.
|
||||
event.added.element.statusSlug = targetSlug;
|
||||
async function onColumnChange(targetSlug: MockDeal['statusSlug'], event: DraggableChangeEvent) {
|
||||
if (!event.added) {
|
||||
// 'removed' и 'moved' — vuedraggable мутирует array; reactive triggers re-render.
|
||||
return;
|
||||
}
|
||||
|
||||
const dealItem = event.added.element;
|
||||
const previousSlug = dealItem.statusSlug;
|
||||
|
||||
// Optimistic: меняем статус в local state сразу (UX отвечает мгновенно).
|
||||
dealItem.statusSlug = targetSlug;
|
||||
|
||||
// Без auth — local-only mode (dev/demo без tenant context). API не зовём.
|
||||
if (!auth.user?.tenant_id) return;
|
||||
|
||||
try {
|
||||
await dealsApi.transitionDeals({
|
||||
tenant_id: auth.user.tenant_id,
|
||||
ids: [dealItem.id],
|
||||
status: targetSlug,
|
||||
});
|
||||
// success — статус уже применён, тостить не нужно.
|
||||
} catch {
|
||||
// Revert на исходный статус + переместить карточку обратно в исходную колонку.
|
||||
dealItem.statusSlug = previousSlug;
|
||||
// Найдём целевую колонку и удалим из неё карточку, потом вернём в исходную.
|
||||
const targetCol = dealsByStatus[targetSlug];
|
||||
if (targetCol) {
|
||||
const idx = targetCol.findIndex((d) => d.id === dealItem.id);
|
||||
if (idx >= 0) targetCol.splice(idx, 1);
|
||||
}
|
||||
const sourceCol = dealsByStatus[previousSlug];
|
||||
if (sourceCol && !sourceCol.find((d) => d.id === dealItem.id)) {
|
||||
sourceCol.push(dealItem);
|
||||
}
|
||||
transitionToastText.value = `Не удалось переместить сделку #${dealItem.id} — восстановлен исходный статус.`;
|
||||
transitionToastOpen.value = true;
|
||||
}
|
||||
// 'removed' и 'moved' — обрабатываются автоматически через v-model
|
||||
// (vuedraggable мутирует array; reactive triggers re-render).
|
||||
}
|
||||
|
||||
const drawerOpen = ref(false);
|
||||
@@ -80,6 +113,10 @@ const fetchError = ref(false);
|
||||
|
||||
const newDealOpen = ref(false);
|
||||
|
||||
// Sprint 1 C4: revert-on-fail toast при DnD-fail.
|
||||
const transitionToastOpen = ref(false);
|
||||
const transitionToastText = ref('');
|
||||
|
||||
function onDealCreated(deal: MockDeal) {
|
||||
if (!dealsByStatus[deal.statusSlug]) dealsByStatus[deal.statusSlug] = [];
|
||||
dealsByStatus[deal.statusSlug].unshift(deal);
|
||||
@@ -113,7 +150,17 @@ onMounted(() => {
|
||||
|
||||
usePolling(loadDeals);
|
||||
|
||||
defineExpose({ dealsByStatus, totalDeals, newDealOpen, onDealCreated, fetchError, loadDeals });
|
||||
defineExpose({
|
||||
dealsByStatus,
|
||||
totalDeals,
|
||||
newDealOpen,
|
||||
onDealCreated,
|
||||
fetchError,
|
||||
loadDeals,
|
||||
onColumnChange,
|
||||
transitionToastOpen,
|
||||
transitionToastText,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -176,6 +223,16 @@ defineExpose({ dealsByStatus, totalDeals, newDealOpen, onDealCreated, fetchError
|
||||
<DealDetailDrawer v-model:open="drawerOpen" :deal="selectedDeal" :tenant-id="auth.user?.tenant_id" />
|
||||
|
||||
<NewDealDialog v-model="newDealOpen" :tenant-id="auth.user?.tenant_id" @created="onDealCreated" />
|
||||
|
||||
<v-snackbar
|
||||
v-model="transitionToastOpen"
|
||||
:timeout="4000"
|
||||
color="warning"
|
||||
location="bottom right"
|
||||
data-testid="kanban-transition-toast"
|
||||
>
|
||||
{{ transitionToastText }}
|
||||
</v-snackbar>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
@@ -207,7 +264,8 @@ defineExpose({ dealsByStatus, totalDeals, newDealOpen, onDealCreated, fetchError
|
||||
align-items: center;
|
||||
}
|
||||
.page-stats .sep {
|
||||
color: #92907b;
|
||||
/* WCAG2AA 4.5:1 fix (was #92907b → 2.92:1 on ivory; #6b6356 → 5.33:1). */
|
||||
color: #6b6356;
|
||||
}
|
||||
|
||||
.num {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="projects-view">
|
||||
<div class="projects-view" :class="{ 'has-drawer': singleSelectedProject !== null }">
|
||||
<div class="d-flex justify-space-between align-center mb-4">
|
||||
<h1 class="text-h4">Проекты</h1>
|
||||
<v-btn color="primary" prepend-icon="mdi-plus" @click="openCreate">+ Создать проект</v-btn>
|
||||
@@ -75,16 +75,23 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BulkActionsBar v-if="store.selectedIds.size > 0" />
|
||||
<BulkActionsBar v-if="store.selectedIds.size >= 2" />
|
||||
|
||||
<ProjectDetailsDrawer
|
||||
:project="singleSelectedProject"
|
||||
@close="onDrawerClose"
|
||||
@saved="onDrawerSaved"
|
||||
/>
|
||||
<NewProjectDialog v-model="createOpen" mode="create" @saved="store.fetch()" />
|
||||
<EditProjectDialog v-model="editOpen" :project="editing" @saved="store.fetch()" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { ref, onMounted, onUnmounted, watch, computed } from 'vue';
|
||||
import { useProjectsStore, type Project } from '../stores/projectsStore';
|
||||
import ProjectCard from '../components/projects/ProjectCard.vue';
|
||||
import ProjectDetailsDrawer from '../components/projects/ProjectDetailsDrawer.vue';
|
||||
import BulkActionsBar from '../components/projects/BulkActionsBar.vue';
|
||||
import NewProjectDialog from './projects/NewProjectDialog.vue';
|
||||
import EditProjectDialog from './projects/EditProjectDialog.vue';
|
||||
@@ -94,6 +101,19 @@ const createOpen = ref(false);
|
||||
const editOpen = ref(false);
|
||||
const editing = ref<Project | null>(null);
|
||||
|
||||
const singleSelectedProject = computed<Project | null>(() => {
|
||||
if (store.selectedIds.size !== 1) return null;
|
||||
const [id] = store.selectedIds;
|
||||
return store.items.find((p: Project) => p.id === id) ?? null;
|
||||
});
|
||||
|
||||
function onDrawerClose(): void {
|
||||
store.clearSelection();
|
||||
}
|
||||
function onDrawerSaved(): void {
|
||||
void store.fetch();
|
||||
}
|
||||
|
||||
const typeFilters = [
|
||||
{ title: 'Сайт', value: 'site' },
|
||||
{ title: 'Звонок', value: 'call' },
|
||||
@@ -223,4 +243,10 @@ onUnmounted(() => store.stopPolling());
|
||||
height: 2px;
|
||||
background: #fff;
|
||||
}
|
||||
.projects-view {
|
||||
transition: padding-right 240ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.projects-view.has-drawer {
|
||||
padding-right: 480px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -245,7 +245,8 @@ const canSubmit = computed(() => quotaActive.value < quotaMax.value && !submitti
|
||||
align-items: center;
|
||||
}
|
||||
.page-stats .sep {
|
||||
color: #92907b;
|
||||
/* WCAG2AA 4.5:1 fix (was #92907b → 2.92:1 on ivory; #6b6356 → 5.33:1). */
|
||||
color: #6b6356;
|
||||
}
|
||||
|
||||
.num {
|
||||
|
||||
@@ -207,7 +207,8 @@ function tariffLabel(t: string): string {
|
||||
<h2 class="text-h6 ma-0">Тенанты</h2>
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
placeholder="Поиск по названию или ИНН"
|
||||
label="Поиск"
|
||||
placeholder="по названию или ИНН"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Универсальный placeholder для ещё-не-реализованных admin-разделов
|
||||
* (Биллинг / Инциденты / Система). Конфигурация через `route.meta.title`
|
||||
* и `route.meta.description`.
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const title = computed(() => (route.meta.title as string | undefined) ?? 'Раздел');
|
||||
const description = computed(
|
||||
() => (route.meta.description as string | undefined) ?? 'Раздел в разработке. Реализуется в следующих коммитах.',
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-container fluid class="admin-placeholder pa-6">
|
||||
<header class="page-head mb-4">
|
||||
<h1 class="text-h4 page-title">{{ title }}</h1>
|
||||
</header>
|
||||
<v-alert type="info" variant="tonal" density="compact">
|
||||
<strong>В разработке.</strong> {{ description }}
|
||||
</v-alert>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-placeholder {
|
||||
max-width: 1200px;
|
||||
}
|
||||
.page-title {
|
||||
font-variation-settings: 'opsz' 28;
|
||||
letter-spacing: -0.018em;
|
||||
}
|
||||
</style>
|
||||
@@ -2,6 +2,19 @@
|
||||
<div class="admin-pricing-tiers-view">
|
||||
<h1 class="text-h4 mb-6">Тарифная сетка</h1>
|
||||
|
||||
<v-alert
|
||||
v-if="errorMessage"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
class="mb-4"
|
||||
density="compact"
|
||||
data-testid="pricing-error-alert"
|
||||
closable
|
||||
@click:close="errorMessage = null"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</v-alert>
|
||||
|
||||
<v-card class="mb-6" elevation="1">
|
||||
<v-card-title>
|
||||
Текущая активная сетка
|
||||
@@ -88,21 +101,31 @@
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-snackbar
|
||||
v-model="successToastOpen"
|
||||
:timeout="4000"
|
||||
color="success"
|
||||
location="bottom right"
|
||||
data-testid="pricing-success-toast"
|
||||
>
|
||||
{{ successMessage }}
|
||||
</v-snackbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { extractErrorMessage } from '../../api/client';
|
||||
|
||||
/**
|
||||
* SaaS-admin → Тарифная сетка (Plan 4 Task 9).
|
||||
* SaaS-admin → Тарифная сетка (Plan 4 Task 9, Sprint 1 G1 error handling).
|
||||
*
|
||||
* Backend: AdminPricingTiersController (GET/POST/DELETE).
|
||||
* Палитра Forest + JetBrains Mono для tnum-цифр.
|
||||
*
|
||||
* defineExpose ниже — для Vitest unit-тестов (`load`/`submit`/`confirmDelete`/
|
||||
* `editorOpen`/`active`/`scheduled`/`editor`). На прод-сборку это не влияет.
|
||||
* defineExpose ниже — для Vitest unit-тестов.
|
||||
*/
|
||||
|
||||
interface Tier {
|
||||
@@ -122,6 +145,11 @@ const scheduled = ref<Record<string, Tier[]>>({});
|
||||
const editorOpen = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
// Sprint 1 G1: error/success state для UI feedback.
|
||||
const errorMessage = ref<string | null>(null);
|
||||
const successMessage = ref<string | null>(null);
|
||||
const successToastOpen = ref(false);
|
||||
|
||||
const defaultEditor: EditorRow[] = [
|
||||
{ tier_no: 1, leads_in_tier: 100, price_rub: '500.00' },
|
||||
{ tier_no: 2, leads_in_tier: 200, price_rub: '450.00' },
|
||||
@@ -149,17 +177,28 @@ const nextMonthStart = computed(() => {
|
||||
const hasScheduled = computed(() => Object.keys(scheduled.value).length > 0);
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const { data } = await axios.get('/api/admin/pricing-tiers');
|
||||
active.value = data.data.active;
|
||||
scheduled.value = data.data.scheduled || {};
|
||||
try {
|
||||
const { data } = await axios.get('/api/admin/pricing-tiers');
|
||||
active.value = data.data.active;
|
||||
scheduled.value = data.data.scheduled || {};
|
||||
} catch (err) {
|
||||
errorMessage.value = extractErrorMessage(err, 'Не удалось загрузить тарифную сетку.');
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
saving.value = true;
|
||||
errorMessage.value = null;
|
||||
successMessage.value = null;
|
||||
try {
|
||||
await axios.post('/api/admin/pricing-tiers', { tiers: editor.value });
|
||||
editorOpen.value = false;
|
||||
successMessage.value = `Сохранено: новая сетка вступит в силу с ${nextMonthStart.value}.`;
|
||||
successToastOpen.value = true;
|
||||
await load();
|
||||
} catch (err) {
|
||||
errorMessage.value = extractErrorMessage(err, 'Не удалось сохранить тарифную сетку.');
|
||||
// Диалог остаётся открытым — пользователь может исправить и повторить.
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
@@ -169,13 +208,33 @@ async function confirmDelete(effectiveFrom: string): Promise<void> {
|
||||
if (!window.confirm(`Удалить запланированный набор с ${effectiveFrom}?`)) {
|
||||
return;
|
||||
}
|
||||
await axios.delete(`/api/admin/pricing-tiers/scheduled/${effectiveFrom}`);
|
||||
await load();
|
||||
errorMessage.value = null;
|
||||
successMessage.value = null;
|
||||
try {
|
||||
await axios.delete(`/api/admin/pricing-tiers/scheduled/${effectiveFrom}`);
|
||||
successMessage.value = `Удалено: запланированный набор с ${effectiveFrom}.`;
|
||||
successToastOpen.value = true;
|
||||
await load();
|
||||
} catch (err) {
|
||||
errorMessage.value = extractErrorMessage(err, 'Не удалось удалить запланированный набор.');
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
|
||||
defineExpose({ load, submit, confirmDelete, editorOpen, active, scheduled, editor });
|
||||
defineExpose({
|
||||
load,
|
||||
submit,
|
||||
confirmDelete,
|
||||
editorOpen,
|
||||
active,
|
||||
scheduled,
|
||||
editor,
|
||||
errorMessage,
|
||||
successMessage,
|
||||
successToastOpen,
|
||||
saving,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
<template>
|
||||
<div class="admin-supplier-prices-view">
|
||||
<h1 class="text-h4 mb-6">Цены поставщиков (закупка)</h1>
|
||||
|
||||
<v-alert
|
||||
v-if="fetchError"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
class="mb-4"
|
||||
density="compact"
|
||||
data-testid="suppliers-fetch-error"
|
||||
closable
|
||||
@click:close="fetchError = null"
|
||||
>
|
||||
{{ fetchError }}
|
||||
</v-alert>
|
||||
|
||||
<v-card elevation="1">
|
||||
<v-data-table :headers="headers" :items="suppliers" density="comfortable" class="numeric-tnum">
|
||||
<template #[`item.cost_rub`]="{ item }">
|
||||
@@ -38,27 +52,52 @@
|
||||
/>
|
||||
</template>
|
||||
<template #[`item.actions`]="{ item }">
|
||||
<v-btn size="small" color="primary" :loading="!!saving[item.id]" @click="save(item)">
|
||||
Сохранить
|
||||
</v-btn>
|
||||
<div class="d-flex flex-column align-end ga-1">
|
||||
<v-btn size="small" color="primary" :loading="!!saving[item.id]" @click="save(item)">
|
||||
Сохранить
|
||||
</v-btn>
|
||||
<v-tooltip v-if="errorMessages[item.id]" location="left">
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<v-icon
|
||||
v-bind="tooltipProps"
|
||||
color="error"
|
||||
size="small"
|
||||
:data-testid="`supplier-error-${item.id}`"
|
||||
>
|
||||
mdi-alert-circle
|
||||
</v-icon>
|
||||
</template>
|
||||
<span>{{ errorMessages[item.id] }}</span>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
|
||||
<v-snackbar
|
||||
v-model="successToastOpen"
|
||||
:timeout="3000"
|
||||
color="success"
|
||||
location="bottom right"
|
||||
data-testid="supplier-success-toast"
|
||||
>
|
||||
{{ successToastText }}
|
||||
</v-snackbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, reactive } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { extractErrorMessage } from '../../api/client';
|
||||
|
||||
/**
|
||||
* SaaS-admin → Цены поставщиков (Plan 4 Task 10).
|
||||
* SaaS-admin → Цены поставщиков (Plan 4 Task 10, Sprint 1 G2 error handling).
|
||||
*
|
||||
* Backend: AdminSuppliersController (GET/PATCH).
|
||||
* Палитра Forest + JetBrains Mono для tnum-цифр.
|
||||
*
|
||||
* defineExpose ниже — для Vitest unit-тестов (`load`/`save`/`suppliers`/
|
||||
* `saving`). На прод-сборку это не влияет.
|
||||
* defineExpose ниже — для Vitest unit-тестов.
|
||||
*/
|
||||
|
||||
interface SupplierRow {
|
||||
@@ -72,6 +111,10 @@ interface SupplierRow {
|
||||
|
||||
const suppliers = ref<SupplierRow[]>([]);
|
||||
const saving = reactive<Record<number, boolean>>({});
|
||||
const errorMessages = reactive<Record<number, string>>({});
|
||||
const fetchError = ref<string | null>(null);
|
||||
const successToastOpen = ref(false);
|
||||
const successToastText = ref('');
|
||||
|
||||
const headers = [
|
||||
{ title: 'Code', key: 'code', sortable: false, width: 80 },
|
||||
@@ -79,22 +122,32 @@ const headers = [
|
||||
{ title: 'Cost (₽)', key: 'cost_rub', sortable: false, width: 140 },
|
||||
{ title: 'Quality', key: 'quality_score', sortable: false, width: 100 },
|
||||
{ title: 'Active', key: 'is_active', sortable: false, width: 100 },
|
||||
{ title: '', key: 'actions', sortable: false, width: 120 },
|
||||
{ title: 'Действия', key: 'actions', sortable: false, width: 120 },
|
||||
];
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const { data } = await axios.get('/api/admin/suppliers');
|
||||
suppliers.value = data.data;
|
||||
fetchError.value = null;
|
||||
try {
|
||||
const { data } = await axios.get('/api/admin/suppliers');
|
||||
suppliers.value = data.data;
|
||||
} catch (err) {
|
||||
fetchError.value = extractErrorMessage(err, 'Не удалось загрузить список поставщиков.');
|
||||
}
|
||||
}
|
||||
|
||||
async function save(s: SupplierRow): Promise<void> {
|
||||
saving[s.id] = true;
|
||||
delete errorMessages[s.id]; // очистить предыдущую ошибку перед retry
|
||||
try {
|
||||
await axios.patch(`/api/admin/suppliers/${s.id}`, {
|
||||
cost_rub: s.cost_rub,
|
||||
quality_score: s.quality_score,
|
||||
is_active: s.is_active,
|
||||
});
|
||||
successToastText.value = `Сохранено: ${s.name} (${s.code}).`;
|
||||
successToastOpen.value = true;
|
||||
} catch (err) {
|
||||
errorMessages[s.id] = extractErrorMessage(err, 'Не удалось сохранить изменения.');
|
||||
} finally {
|
||||
saving[s.id] = false;
|
||||
}
|
||||
@@ -102,7 +155,7 @@ async function save(s: SupplierRow): Promise<void> {
|
||||
|
||||
onMounted(load);
|
||||
|
||||
defineExpose({ load, save, suppliers, saving });
|
||||
defineExpose({ load, save, suppliers, saving, errorMessages, fetchError, successToastOpen, successToastText });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -129,7 +129,8 @@ defineExpose({ settingsState, editOpen, editSetting, openEdit, onSettingUpdated,
|
||||
<v-spacer />
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
placeholder="Поиск по ключу или описанию"
|
||||
label="Поиск"
|
||||
placeholder="по ключу или описанию"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import RecoveryCodesView from './RecoveryCodesView.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Auth / RecoveryCodesView" :layout="{ type: 'single', iframe: true }">
|
||||
<Variant title="default">
|
||||
<v-app>
|
||||
<v-main class="story-form-pane">
|
||||
<v-container class="d-flex justify-center align-center fill-height">
|
||||
<RecoveryCodesView />
|
||||
</v-container>
|
||||
</v-main>
|
||||
</v-app>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.story-form-pane {
|
||||
background: #f6f3ec;
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
@@ -1,106 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Экран резервных кодов (RecoveryCodesView).
|
||||
*
|
||||
* Источник дизайна: liderra_v8_handoff/concepts/v8_login.html секция #form-recovery.
|
||||
* Источник логики: ТЗ v8.5 §1.6 / Прил. Г.4.2 — 8 одноразовых 8-символьных кодов;
|
||||
* после использования код в БД удаляется (recovery_codes table); генерируются один
|
||||
* раз при включении 2FA, посмотреть повторно нельзя — только перегенерация.
|
||||
*
|
||||
* MVP: коды-заглушки. Реальные коды будут с backend через POST /2fa/recovery-codes.
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
|
||||
// TODO(phase2): получать из API после step1 настройки 2FA.
|
||||
const codes = ref([
|
||||
'A4FX-91KZ',
|
||||
'9MRT-2P3D',
|
||||
'QH7B-XK4N',
|
||||
'5VLW-T8RY',
|
||||
'B2ZJ-N6FP',
|
||||
'D3WK-Q9MX',
|
||||
'7YHC-8GVB',
|
||||
'RP4S-K1NA',
|
||||
]);
|
||||
|
||||
function downloadTxt() {
|
||||
const blob = new Blob([codes.value.join('\n') + '\n'], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'liderra-recovery-codes.txt';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function copyAll() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(codes.value.join('\n'));
|
||||
} catch {
|
||||
// Fallback на legacy execCommand добавим только если будут жалобы — у нас HTTPS-only.
|
||||
}
|
||||
}
|
||||
|
||||
function handleContinue() {
|
||||
// TODO(phase2): редирект на /dashboard.
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card variant="flat" :max-width="420" width="100%" color="transparent" class="recovery-card">
|
||||
<header class="recovery-header">
|
||||
<h1 class="text-h5 mb-1">Резервные коды</h1>
|
||||
<p class="text-body-2 text-medium-emphasis ma-0">
|
||||
Сохраните эти 8 одноразовых кодов в безопасном месте. Каждый можно использовать только раз вместо 2FA.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="codes-grid">
|
||||
<span v-for="code in codes" :key="code" class="code-item">{{ code }}</span>
|
||||
</div>
|
||||
|
||||
<v-alert type="warning" variant="tonal" density="compact">
|
||||
<strong>После закрытия страницы коды нельзя посмотреть снова</strong>. Скачайте файл или сделайте скриншот.
|
||||
</v-alert>
|
||||
|
||||
<div class="d-flex ga-2">
|
||||
<v-btn variant="outlined" prepend-icon="mdi-download" @click="downloadTxt"> Скачать .txt </v-btn>
|
||||
<v-btn variant="outlined" prepend-icon="mdi-content-copy" @click="copyAll"> Копировать </v-btn>
|
||||
</div>
|
||||
|
||||
<v-btn color="primary" block size="large" variant="flat" @click="handleContinue"> Понятно — продолжить </v-btn>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.recovery-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.recovery-header h1 {
|
||||
font-variation-settings: 'opsz' 26;
|
||||
letter-spacing: -0.018em;
|
||||
}
|
||||
|
||||
.codes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.code-item {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
padding: 10px 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #d9d5cd;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Правовые документы — заглушки оферты и политики конфиденциальности.
|
||||
*
|
||||
* Audit A7: ссылки /legal/offer и /legal/privacy в подвале AuthLayout вели
|
||||
* на 404 (catch-all). Финальные тексты документов требуют юридической
|
||||
* редактуры (реестр K3 / блокер Б-1) — до этого страницы показывают честную
|
||||
* заглушку «документ готовится», а не фейк-текст (юридический риск).
|
||||
*
|
||||
* Один view на оба документа (DRY): контент выбирается по route.params.doc.
|
||||
* Маршрут /legal/:doc(offer|privacy) — иные значения отсекает regex-constraint,
|
||||
* уходя в catch-all 404.
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
interface LegalDoc {
|
||||
title: string;
|
||||
intro: string;
|
||||
}
|
||||
|
||||
const DOCS: Record<'offer' | 'privacy', LegalDoc> = {
|
||||
offer: {
|
||||
title: 'Договор-оферта',
|
||||
intro: 'Публичная оферта на оказание услуг сервиса «Лидерра» — условия использования платформы, права и обязанности сторон, порядок оплаты.',
|
||||
},
|
||||
privacy: {
|
||||
title: 'Политика конфиденциальности',
|
||||
intro: 'Порядок обработки и защиты персональных данных пользователей сервиса «Лидерра» в соответствии с Федеральным законом № 152-ФЗ «О персональных данных».',
|
||||
},
|
||||
};
|
||||
|
||||
const route = useRoute();
|
||||
const doc = computed<LegalDoc>(() => (String(route.params.doc) === 'privacy' ? DOCS.privacy : DOCS.offer));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card variant="flat" :max-width="480" width="100%" color="transparent" class="legal-card">
|
||||
<header class="legal-header">
|
||||
<h1 class="text-h5 mb-1">{{ doc.title }}</h1>
|
||||
<p class="text-body-2 text-medium-emphasis ma-0">{{ doc.intro }}</p>
|
||||
</header>
|
||||
|
||||
<v-alert type="info" variant="tonal" density="compact" role="note" data-testid="legal-stub-notice">
|
||||
Финальная редакция документа готовится и будет опубликована до запуска сервиса.
|
||||
</v-alert>
|
||||
|
||||
<RouterLink to="/login" class="text-body-2 text-primary legal-back">← Вернуться ко входу</RouterLink>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.legal-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.legal-header h1 {
|
||||
font-variation-settings: 'opsz' 24;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.legal-back {
|
||||
text-decoration: none;
|
||||
}
|
||||
.legal-back:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -76,16 +76,17 @@
|
||||
:error-messages="errors.daily_limit_target"
|
||||
/>
|
||||
|
||||
<v-autocomplete
|
||||
v-model="selectedRegions"
|
||||
:items="REGIONS"
|
||||
item-title="name"
|
||||
item-value="code"
|
||||
label="Регионы (пусто = вся РФ)"
|
||||
multiple
|
||||
chips
|
||||
clearable
|
||||
/>
|
||||
<v-alert
|
||||
v-if="generalError"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
closable
|
||||
@click:close="generalError = null"
|
||||
>
|
||||
{{ generalError }}
|
||||
</v-alert>
|
||||
|
||||
<div class="mt-3">
|
||||
<span class="text-caption">Дни недели приёма</span>
|
||||
@@ -112,8 +113,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { REGIONS } from '../../constants/regions';
|
||||
import { apiClient, ensureCsrfCookie, extractErrorMessage } from '../../api/client';
|
||||
import type { Project } from '../../stores/projectsStore';
|
||||
import DevIndexBadge from '../../components/DevIndexBadge.vue';
|
||||
|
||||
@@ -124,6 +124,9 @@ const props = defineProps<{
|
||||
}>();
|
||||
const emit = defineEmits(['update:modelValue', 'saved']);
|
||||
|
||||
// region_mask=255 = все 8 ФО (schema default, см. db/schema.sql §projects).
|
||||
// PDD regions UI отключён до закрытия Plan 6 — конфликт с 8-битной ФО-маской
|
||||
// в PhonePrefixService.php (1 phone prefix ↔ 1 ФО, не субъект).
|
||||
const form = reactive({
|
||||
name: '',
|
||||
signal_type: 'site' as 'site' | 'call' | 'sms',
|
||||
@@ -131,26 +134,13 @@ const form = reactive({
|
||||
sms_senders: [] as string[],
|
||||
sms_keyword: '',
|
||||
daily_limit_target: 50,
|
||||
region_mask: 0,
|
||||
region_mask: 255,
|
||||
region_mode: 'include' as 'include' | 'exclude',
|
||||
delivery_days_mask: 127,
|
||||
});
|
||||
const errors = reactive<Record<string, string[]>>({});
|
||||
const saving = ref(false);
|
||||
|
||||
const selectedRegions = ref<number[]>([]);
|
||||
watch(selectedRegions, (codes) => {
|
||||
if (codes.length === 0) {
|
||||
form.region_mask = 0;
|
||||
form.region_mode = 'include';
|
||||
} else {
|
||||
// 32-bit JS bitwise limit — region codes >31 не помещаются в Int32 mask.
|
||||
// На MVP покрываем 1-31 (см. constants/regions.ts); для >31 нужен bigint
|
||||
// или array-колонка (Plan 6 — schema delta).
|
||||
form.region_mask = codes.reduce((acc, c) => (c <= 31 ? acc | (1 << c) : acc), 0);
|
||||
form.region_mode = 'exclude';
|
||||
}
|
||||
});
|
||||
const generalError = ref<string | null>(null);
|
||||
|
||||
const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||||
const selectedDays = ref<number[]>([0, 1, 2, 3, 4, 5, 6]);
|
||||
@@ -166,10 +156,9 @@ function setWorkdays(preset: 'weekdays' | 'all') {
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (open) generalError.value = null;
|
||||
if (open && props.mode === 'edit' && props.project) {
|
||||
Object.assign(form, props.project);
|
||||
// TODO: разобрать region_mask обратно в codes (Plan 6 ↑).
|
||||
selectedRegions.value = [];
|
||||
const days: number[] = [];
|
||||
for (let i = 0; i < 7; i++) if (form.delivery_days_mask & (1 << i)) days.push(i);
|
||||
selectedDays.value = days;
|
||||
@@ -181,11 +170,10 @@ watch(
|
||||
sms_senders: [],
|
||||
sms_keyword: '',
|
||||
daily_limit_target: 50,
|
||||
region_mask: 0,
|
||||
region_mask: 255,
|
||||
region_mode: 'include',
|
||||
delivery_days_mask: 127,
|
||||
});
|
||||
selectedRegions.value = [];
|
||||
selectedDays.value = [0, 1, 2, 3, 4, 5, 6];
|
||||
}
|
||||
},
|
||||
@@ -193,12 +181,14 @@ watch(
|
||||
|
||||
async function submit() {
|
||||
saving.value = true;
|
||||
generalError.value = null;
|
||||
Object.keys(errors).forEach((k) => delete errors[k]);
|
||||
try {
|
||||
await ensureCsrfCookie();
|
||||
if (props.mode === 'edit' && props.project) {
|
||||
await axios.patch(`/api/projects/${props.project.id}`, { ...form });
|
||||
await apiClient.patch(`/api/projects/${props.project.id}`, { ...form });
|
||||
} else {
|
||||
await axios.post('/api/projects', { ...form });
|
||||
await apiClient.post('/api/projects', { ...form });
|
||||
}
|
||||
emit('saved');
|
||||
close();
|
||||
@@ -206,6 +196,8 @@ async function submit() {
|
||||
const err = e as { response?: { status?: number; data?: { errors?: Record<string, string[]> } } };
|
||||
if (err.response?.status === 422 && err.response.data?.errors) {
|
||||
Object.assign(errors, err.response.data.errors);
|
||||
} else {
|
||||
generalError.value = extractErrorMessage(e);
|
||||
}
|
||||
} finally {
|
||||
saving.value = false;
|
||||
|
||||
@@ -1,18 +1,150 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Settings → API и Webhook. Token + endpoint URL + signing secret + история webhook-вызовов.
|
||||
* Источник дизайна: liderra_v8_handoff/concepts/v8_settings.html секция #api.
|
||||
* Settings → API и Webhook (audit D2/D3/D4/D5 + J5).
|
||||
*
|
||||
* Реальная логика по ТЗ §5/§5.5 + schema v8.7 webhook_dedup_keys (CTO-17 addendum).
|
||||
* Token + secret НЕ показываются в открытом виде после генерации (single-time view).
|
||||
* API-ключ: GET /api/api-keys показывает key_prefix; «Копировать» — clipboard
|
||||
* + toast; «Перегенерировать» — POST /api/api-keys/regenerate, полный ключ
|
||||
* показывается ОДИН раз (затем доступен только префикс).
|
||||
* Webhook: GET/PUT /api/tenants/me/webhook-settings (target_url + secret_prefix),
|
||||
* «Тестовый webhook» — POST /api/webhooks/test (реальный unsigned POST).
|
||||
*
|
||||
* Полный ключ и полный секрет показываются один раз — в БД только bcrypt-хэши.
|
||||
* Подписанная outbound-доставка событий — пост-MVP.
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { listApiKeys, regenerateApiKey } from '../../api/apiKeys';
|
||||
import { getWebhookSettings, saveWebhookSettings, testWebhook } from '../../api/webhooks';
|
||||
import { extractErrorMessage } from '../../api/client';
|
||||
|
||||
const apiToken = ref('lpkapi_7g8h********************************2klm');
|
||||
const webhookUrl = ref('https://crm.example.ru/api/webhook/leads');
|
||||
const signingSecret = ref('whsec_********************************************');
|
||||
// --- API-ключ ---
|
||||
const apiKeyExists = ref(false);
|
||||
const apiTokenDisplay = ref('');
|
||||
const fullKeyShown = ref(false);
|
||||
const tokenVisible = ref(false);
|
||||
const apiKeyError = ref<string | null>(null);
|
||||
const regenDialogOpen = ref(false);
|
||||
const regenerating = ref(false);
|
||||
|
||||
// --- Webhook ---
|
||||
const webhookUrl = ref('');
|
||||
const secretDisplay = ref('');
|
||||
const fullSecretShown = ref(false);
|
||||
const secretVisible = ref(false);
|
||||
const webhookError = ref<string | null>(null);
|
||||
const webhookSuccess = ref<string | null>(null);
|
||||
const savingWebhook = ref(false);
|
||||
const testingWebhook = ref(false);
|
||||
|
||||
// --- общий toast (D2 copy + D5 test) ---
|
||||
const toastOpen = ref(false);
|
||||
const toastText = ref('');
|
||||
const toastColor = ref<'success' | 'error'>('success');
|
||||
|
||||
function showToast(text: string, color: 'success' | 'error' = 'success'): void {
|
||||
toastText.value = text;
|
||||
toastColor.value = color;
|
||||
toastOpen.value = true;
|
||||
}
|
||||
|
||||
async function loadApiKey(): Promise<void> {
|
||||
apiKeyError.value = null;
|
||||
try {
|
||||
const keys = await listApiKeys();
|
||||
const active = keys[0];
|
||||
if (active) {
|
||||
apiKeyExists.value = true;
|
||||
apiTokenDisplay.value = active.key_prefix;
|
||||
fullKeyShown.value = false;
|
||||
} else {
|
||||
apiKeyExists.value = false;
|
||||
apiTokenDisplay.value = '';
|
||||
}
|
||||
} catch (err) {
|
||||
apiKeyError.value = extractErrorMessage(err, 'Не удалось загрузить API-ключ.');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWebhook(): Promise<void> {
|
||||
webhookError.value = null;
|
||||
try {
|
||||
const settings = await getWebhookSettings();
|
||||
if (settings) {
|
||||
webhookUrl.value = settings.target_url;
|
||||
secretDisplay.value = settings.secret_prefix;
|
||||
fullSecretShown.value = false;
|
||||
}
|
||||
} catch (err) {
|
||||
webhookError.value = extractErrorMessage(err, 'Не удалось загрузить настройки webhook.');
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadApiKey();
|
||||
void loadWebhook();
|
||||
});
|
||||
|
||||
async function copyToken(): Promise<void> {
|
||||
if (apiTokenDisplay.value === '') return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(apiTokenDisplay.value);
|
||||
showToast('Скопировано в буфер обмена.', 'success');
|
||||
} catch {
|
||||
showToast('Не удалось скопировать — выделите и скопируйте вручную.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRegenerate(): Promise<void> {
|
||||
regenerating.value = true;
|
||||
apiKeyError.value = null;
|
||||
try {
|
||||
const result = await regenerateApiKey();
|
||||
apiTokenDisplay.value = result.key;
|
||||
fullKeyShown.value = true;
|
||||
apiKeyExists.value = true;
|
||||
tokenVisible.value = true;
|
||||
regenDialogOpen.value = false;
|
||||
} catch (err) {
|
||||
apiKeyError.value = extractErrorMessage(err, 'Не удалось перегенерировать ключ.');
|
||||
regenDialogOpen.value = false;
|
||||
} finally {
|
||||
regenerating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveWebhook(): Promise<void> {
|
||||
if (savingWebhook.value) return;
|
||||
savingWebhook.value = true;
|
||||
webhookError.value = null;
|
||||
webhookSuccess.value = null;
|
||||
try {
|
||||
const result = await saveWebhookSettings({ target_url: webhookUrl.value });
|
||||
if (result.secret) {
|
||||
secretDisplay.value = result.secret;
|
||||
fullSecretShown.value = true;
|
||||
secretVisible.value = true;
|
||||
} else {
|
||||
secretDisplay.value = result.secret_prefix;
|
||||
}
|
||||
webhookSuccess.value = 'Настройки webhook сохранены.';
|
||||
} catch (err) {
|
||||
webhookError.value = extractErrorMessage(err, 'Не удалось сохранить webhook.');
|
||||
} finally {
|
||||
savingWebhook.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runWebhookTest(): Promise<void> {
|
||||
if (testingWebhook.value) return;
|
||||
testingWebhook.value = true;
|
||||
try {
|
||||
const result = await testWebhook();
|
||||
showToast(result.message, result.ok ? 'success' : 'error');
|
||||
} catch (err) {
|
||||
showToast(extractErrorMessage(err, 'Не удалось отправить тестовый запрос.'), 'error');
|
||||
} finally {
|
||||
testingWebhook.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -25,18 +157,60 @@ const secretVisible = ref(false);
|
||||
Используется для подписи запросов в публичный API CRM. После регенерации старый ключ перестаёт работать
|
||||
немедленно.
|
||||
</p>
|
||||
|
||||
<v-alert
|
||||
v-if="apiKeyError"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
closable
|
||||
data-testid="api-key-error"
|
||||
@click:close="apiKeyError = null"
|
||||
>
|
||||
{{ apiKeyError }}
|
||||
</v-alert>
|
||||
<v-alert
|
||||
v-if="fullKeyShown"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
data-testid="api-key-once-notice"
|
||||
>
|
||||
Новый ключ показывается <strong>один раз</strong>. Скопируйте и сохраните его сейчас.
|
||||
</v-alert>
|
||||
|
||||
<v-text-field
|
||||
:model-value="apiToken"
|
||||
:model-value="apiTokenDisplay"
|
||||
:type="tokenVisible ? 'text' : 'password'"
|
||||
:placeholder="apiKeyExists ? '' : 'Ключ ещё не создан — нажмите «Перегенерировать»'"
|
||||
readonly
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
data-testid="api-key-field"
|
||||
:append-inner-icon="tokenVisible ? 'mdi-eye-off' : 'mdi-eye'"
|
||||
@click:append-inner="tokenVisible = !tokenVisible"
|
||||
/>
|
||||
<div class="d-flex ga-2 mt-2">
|
||||
<v-btn variant="outlined" size="small" prepend-icon="mdi-content-copy">Копировать</v-btn>
|
||||
<v-btn variant="outlined" size="small" color="warning" prepend-icon="mdi-refresh">
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
size="small"
|
||||
prepend-icon="mdi-content-copy"
|
||||
:disabled="!apiTokenDisplay"
|
||||
data-testid="api-key-copy-btn"
|
||||
@click="copyToken"
|
||||
>
|
||||
Копировать
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
size="small"
|
||||
color="warning"
|
||||
prepend-icon="mdi-refresh"
|
||||
data-testid="api-key-regen-btn"
|
||||
@click="regenDialogOpen = true"
|
||||
>
|
||||
Перегенерировать
|
||||
</v-btn>
|
||||
</div>
|
||||
@@ -48,22 +222,117 @@ const secretVisible = ref(false);
|
||||
URL источника лидов отправляет POST с подписью HMAC-SHA256. Дедуп по
|
||||
<code>(tenant_id, source_crm_id)</code> в окне 24 ч (антифрод по phone — §10.8.1).
|
||||
</p>
|
||||
<v-text-field v-model="webhookUrl" label="Endpoint URL" variant="outlined" density="comfortable" />
|
||||
|
||||
<v-alert
|
||||
v-if="webhookError"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
closable
|
||||
data-testid="webhook-error"
|
||||
@click:close="webhookError = null"
|
||||
>
|
||||
{{ webhookError }}
|
||||
</v-alert>
|
||||
<v-alert
|
||||
v-if="webhookSuccess"
|
||||
type="success"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
closable
|
||||
data-testid="webhook-success"
|
||||
@click:close="webhookSuccess = null"
|
||||
>
|
||||
{{ webhookSuccess }}
|
||||
</v-alert>
|
||||
<v-alert
|
||||
v-if="fullSecretShown"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
data-testid="webhook-secret-once-notice"
|
||||
>
|
||||
Signing secret показывается <strong>один раз</strong>. Сохраните его в настройках вашего приёмника.
|
||||
</v-alert>
|
||||
|
||||
<v-text-field
|
||||
:model-value="signingSecret"
|
||||
v-model="webhookUrl"
|
||||
label="Endpoint URL"
|
||||
placeholder="https://..."
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
data-testid="webhook-url-field"
|
||||
/>
|
||||
<v-text-field
|
||||
:model-value="secretDisplay"
|
||||
:type="secretVisible ? 'text' : 'password'"
|
||||
label="Signing secret (HMAC)"
|
||||
:placeholder="secretDisplay ? '' : 'Появится после первого сохранения URL'"
|
||||
readonly
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
data-testid="webhook-secret-field"
|
||||
:append-inner-icon="secretVisible ? 'mdi-eye-off' : 'mdi-eye'"
|
||||
@click:append-inner="secretVisible = !secretVisible"
|
||||
/>
|
||||
<div class="d-flex ga-2 mt-2">
|
||||
<v-btn color="primary" variant="flat" size="small">Сохранить</v-btn>
|
||||
<v-btn variant="outlined" size="small" prepend-icon="mdi-test-tube"> Тестовый webhook </v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
size="small"
|
||||
:loading="savingWebhook"
|
||||
data-testid="webhook-save-btn"
|
||||
@click="saveWebhook"
|
||||
>
|
||||
Сохранить
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
size="small"
|
||||
prepend-icon="mdi-test-tube"
|
||||
:loading="testingWebhook"
|
||||
data-testid="webhook-test-btn"
|
||||
@click="runWebhookTest"
|
||||
>
|
||||
Тестовый webhook
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
<v-dialog v-model="regenDialogOpen" :max-width="440" data-testid="regen-dialog">
|
||||
<v-card>
|
||||
<v-card-title>Перегенерация API-ключа</v-card-title>
|
||||
<v-card-text>
|
||||
Текущий ключ перестанет работать немедленно. Все интеграции с ним нужно будет обновить. Продолжить?
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" :disabled="regenerating" @click="regenDialogOpen = false">Отмена</v-btn>
|
||||
<v-btn
|
||||
color="warning"
|
||||
variant="flat"
|
||||
:loading="regenerating"
|
||||
data-testid="regen-confirm-btn"
|
||||
@click="confirmRegenerate"
|
||||
>
|
||||
Перегенерировать
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-snackbar
|
||||
v-model="toastOpen"
|
||||
:timeout="4000"
|
||||
:color="toastColor"
|
||||
location="bottom right"
|
||||
data-testid="api-tab-toast"
|
||||
>
|
||||
{{ toastText }}
|
||||
</v-snackbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,38 +1,129 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Settings → Профиль. Данные текущего user'а: имя, email, телефон, тайм-зона.
|
||||
* Источник дизайна: liderra_v8_handoff/concepts/v8_settings.html секция #profile.
|
||||
* Settings → Профиль. Имя, фамилия, телефон, тайм-зона текущего пользователя
|
||||
* из auth-store; сохранение через PATCH /api/auth/me (audit D1 / J6).
|
||||
*
|
||||
* MVP: form-fields без save (TODO: PATCH /api/me).
|
||||
* Источник дизайна: liderra_v8_handoff/concepts/v8_settings.html секция #profile.
|
||||
* Email — read-only (меняется через support). Поле «Роль» убрано: в таблице
|
||||
* users нет колонки роли (было декоративной mock-заглушкой). Кнопка смены
|
||||
* аватара пока без обработчика — загрузка аватара вне scope audit D1.
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import { updateProfile } from '../../api/auth';
|
||||
import { extractErrorMessage } from '../../api/client';
|
||||
|
||||
const fullName = ref('Иван Петров');
|
||||
const email = ref('ivan.petrov@example.ru');
|
||||
const phone = ref('+7 (916) 871-23-45');
|
||||
const timezone = ref('Europe/Moscow');
|
||||
const role = ref('Владелец');
|
||||
const auth = useAuthStore();
|
||||
|
||||
// auth.user гарантированно не null здесь: router beforeEach дожидается
|
||||
// fetchMe() до навигации на requiresAuth-маршруты (см. router/index.ts).
|
||||
// Поэтому watch-ресинк (как в NotificationsTab) не нужен.
|
||||
const firstName = ref(auth.user?.first_name ?? '');
|
||||
const lastName = ref(auth.user?.last_name ?? '');
|
||||
const phone = ref(auth.user?.phone ?? '');
|
||||
const timezone = ref(auth.user?.timezone ?? 'Europe/Moscow');
|
||||
const email = computed(() => auth.user?.email ?? '');
|
||||
|
||||
const initials = computed(() => {
|
||||
const f = (auth.user?.first_name ?? '').charAt(0);
|
||||
const l = (auth.user?.last_name ?? '').charAt(0);
|
||||
return (f + l).toUpperCase() || '—';
|
||||
});
|
||||
|
||||
const saving = ref(false);
|
||||
const saveSuccess = ref(false);
|
||||
const saveError = ref<string | null>(null);
|
||||
|
||||
function resetForm(): void {
|
||||
firstName.value = auth.user?.first_name ?? '';
|
||||
lastName.value = auth.user?.last_name ?? '';
|
||||
phone.value = auth.user?.phone ?? '';
|
||||
timezone.value = auth.user?.timezone ?? 'Europe/Moscow';
|
||||
saveSuccess.value = false;
|
||||
saveError.value = null;
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
if (saving.value) return;
|
||||
saving.value = true;
|
||||
saveSuccess.value = false;
|
||||
saveError.value = null;
|
||||
try {
|
||||
const updated = await updateProfile({
|
||||
first_name: firstName.value,
|
||||
last_name: lastName.value,
|
||||
phone: phone.value.trim() === '' ? null : phone.value.trim(),
|
||||
timezone: timezone.value,
|
||||
});
|
||||
auth.user = { ...auth.user!, ...updated };
|
||||
saveSuccess.value = true;
|
||||
} catch (err) {
|
||||
saveError.value = extractErrorMessage(err, 'Не удалось сохранить профиль.');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tab-content">
|
||||
<h2 class="tab-title text-h6 mb-4">Профиль</h2>
|
||||
|
||||
<v-alert
|
||||
v-if="saveSuccess"
|
||||
type="success"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
closable
|
||||
data-testid="profile-save-success"
|
||||
@click:close="saveSuccess = false"
|
||||
>
|
||||
Профиль сохранён.
|
||||
</v-alert>
|
||||
<v-alert
|
||||
v-if="saveError"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
closable
|
||||
data-testid="profile-save-error"
|
||||
@click:close="saveError = null"
|
||||
>
|
||||
{{ saveError }}
|
||||
</v-alert>
|
||||
|
||||
<v-row class="profile-row">
|
||||
<v-col cols="auto">
|
||||
<v-avatar size="80" color="primary">
|
||||
<span class="text-h5">ИП</span>
|
||||
<span class="text-h5">{{ initials }}</span>
|
||||
</v-avatar>
|
||||
<v-btn variant="text" size="small" class="mt-2" prepend-icon="mdi-camera"> Сменить </v-btn>
|
||||
</v-col>
|
||||
<v-col>
|
||||
<v-row dense>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field v-model="fullName" label="Полное имя" variant="outlined" density="comfortable" />
|
||||
<v-text-field
|
||||
v-model="firstName"
|
||||
label="Имя"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
data-testid="profile-first-name"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="email"
|
||||
v-model="lastName"
|
||||
label="Фамилия"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
data-testid="profile-last-name"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
:model-value="email"
|
||||
label="Email"
|
||||
type="email"
|
||||
variant="outlined"
|
||||
@@ -43,7 +134,13 @@ const role = ref('Владелец');
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field v-model="phone" label="Телефон" variant="outlined" density="comfortable" />
|
||||
<v-text-field
|
||||
v-model="phone"
|
||||
label="Телефон"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
data-testid="profile-phone"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
@@ -53,16 +150,24 @@ const role = ref('Владелец');
|
||||
density="comfortable"
|
||||
persistent-hint
|
||||
hint="Используется в логах и напоминаниях"
|
||||
data-testid="profile-timezone"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field v-model="role" label="Роль" variant="outlined" density="comfortable" disabled />
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<div class="d-flex ga-2 mt-4">
|
||||
<v-btn color="primary" variant="flat">Сохранить</v-btn>
|
||||
<v-btn variant="text">Отмена</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
:loading="saving"
|
||||
data-testid="profile-save-btn"
|
||||
@click="save"
|
||||
>
|
||||
Сохранить
|
||||
</v-btn>
|
||||
<v-btn variant="text" :disabled="saving" data-testid="profile-cancel-btn" @click="resetForm">
|
||||
Отмена
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
@@ -28,6 +28,12 @@ Schedule::command('projects:reset-monthly')
|
||||
->monthlyOn(1, '00:00')
|
||||
->timezone('Europe/Moscow');
|
||||
|
||||
// Audit #2 Phase 14 P2: partition maintenance — создаёт разделы на 3 месяца вперёд.
|
||||
// Без этой записи partitions:create-months не запускается автоматически.
|
||||
Schedule::command('partitions:create-months')
|
||||
->daily()
|
||||
->timezone('Europe/Moscow');
|
||||
|
||||
// Plan 3 Task 8: 5 Schedule entries для supplier-flow.
|
||||
//
|
||||
// NB: ->onOneServer() требует cache_locks таблицу, которой у нас нет
|
||||
|
||||
+25
-1
@@ -35,6 +35,7 @@ Route::prefix('/api/auth')->group(function () {
|
||||
Route::post('/reset-password', 'App\Http\Controllers\Api\PasswordResetController@resetPassword');
|
||||
Route::middleware('auth:sanctum')->group(function () {
|
||||
Route::get('/me', 'App\Http\Controllers\Api\AuthController@me');
|
||||
Route::patch('/me', 'App\Http\Controllers\Api\AuthController@updateProfile');
|
||||
Route::post('/logout', 'App\Http\Controllers\Api\AuthController@logout');
|
||||
Route::patch('/me/notification-preferences', 'App\Http\Controllers\Api\AuthController@updateNotificationPreferences');
|
||||
});
|
||||
@@ -124,6 +125,28 @@ Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/billing/charges')->g
|
||||
Route::post('/export', 'App\Http\Controllers\Api\TenantChargesController@export');
|
||||
});
|
||||
|
||||
// Биллинг тенанта: пополнение/кошелёк/транзакции/счета (audit E1/E3).
|
||||
// RLS на balance_transactions / saas_invoices требует tenant middleware.
|
||||
Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/billing')->group(function () {
|
||||
Route::post('/topup', 'App\Http\Controllers\Api\BillingController@topup');
|
||||
Route::get('/wallet', 'App\Http\Controllers\Api\BillingController@wallet');
|
||||
Route::get('/transactions', 'App\Http\Controllers\Api\BillingController@transactions');
|
||||
Route::get('/invoices', 'App\Http\Controllers\Api\BillingController@invoices');
|
||||
});
|
||||
|
||||
// API-ключи тенанта (audit D2/D3/J5). RLS на api_keys требует tenant middleware.
|
||||
Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/api-keys')->group(function () {
|
||||
Route::get('/', 'App\Http\Controllers\Api\ApiKeyController@index');
|
||||
Route::post('/regenerate', 'App\Http\Controllers\Api\ApiKeyController@regenerate');
|
||||
});
|
||||
|
||||
// Настройки исходящего webhook'а тенанта (audit D4/D5/J5).
|
||||
Route::middleware(['auth:sanctum', 'tenant'])->group(function () {
|
||||
Route::get('/api/tenants/me/webhook-settings', 'App\Http\Controllers\Api\WebhookSettingsController@show');
|
||||
Route::put('/api/tenants/me/webhook-settings', 'App\Http\Controllers\Api\WebhookSettingsController@update');
|
||||
Route::post('/api/webhooks/test', 'App\Http\Controllers\Api\WebhookSettingsController@test');
|
||||
});
|
||||
|
||||
// Сделки — manual create через UI (NewDealDialog). На prod: middleware
|
||||
// 'auth:sanctum' + 'tenant', tenant_id берётся из user'а. На MVP — параметром.
|
||||
//
|
||||
@@ -194,8 +217,9 @@ Route::view('/register', 'welcome');
|
||||
Route::view('/forgot', 'welcome');
|
||||
Route::view('/reset', 'welcome'); // SPA-router рендерит ResetPasswordView для /reset/{token}
|
||||
Route::view('/2fa', 'welcome');
|
||||
Route::view('/recovery', 'welcome');
|
||||
Route::view('/recovery-use', 'welcome');
|
||||
Route::view('/legal/offer', 'welcome');
|
||||
Route::view('/legal/privacy', 'welcome');
|
||||
Route::view('/dashboard', 'welcome');
|
||||
Route::view('/deals', 'welcome');
|
||||
Route::view('/kanban', 'welcome');
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
// Parse rollup-plugin-visualizer HTML output → Top-15 chunks + critical-path total.
|
||||
// Reads storage/bundle-analyze.html, extracts the inline `const data = {...};` JSON,
|
||||
// computes per-chunk raw + gzip + brotli totals by walking each top-level chunk's tree
|
||||
// and summing the corresponding nodeParts entries.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const htmlPath = resolve(__dirname, '..', 'storage', 'bundle-analyze.html');
|
||||
const html = readFileSync(htmlPath, 'utf8');
|
||||
|
||||
// Locate the data assignment.
|
||||
const marker = 'const data = ';
|
||||
const start = html.indexOf(marker);
|
||||
if (start === -1) {
|
||||
console.error('FATAL: marker "const data = " not found');
|
||||
process.exit(2);
|
||||
}
|
||||
// Find the terminating `};\n` that closes the object literal.
|
||||
// The line ends with `}};` (closing data) — find first `};` after start.
|
||||
const afterMarker = start + marker.length;
|
||||
// Trailing semicolon: search for `};` then back up. Simpler: capture until matching brace.
|
||||
let depth = 0;
|
||||
let i = afterMarker;
|
||||
let inString = false;
|
||||
let escape = false;
|
||||
let firstBraceFound = false;
|
||||
for (; i < html.length; i++) {
|
||||
const ch = html[i];
|
||||
if (escape) { escape = false; continue; }
|
||||
if (ch === '\\') { escape = true; continue; }
|
||||
if (ch === '"') { inString = !inString; continue; }
|
||||
if (inString) continue;
|
||||
if (ch === '{') { depth++; firstBraceFound = true; }
|
||||
else if (ch === '}') {
|
||||
depth--;
|
||||
if (firstBraceFound && depth === 0) { i++; break; }
|
||||
}
|
||||
}
|
||||
const jsonStr = html.slice(afterMarker, i);
|
||||
const data = JSON.parse(jsonStr);
|
||||
|
||||
// Build uid -> nodePart map (raw=renderedLength, gzip=gzipLength, brotli=brotliLength).
|
||||
// In visualizer schema v2, each leaf uid lives in `nodeParts`.
|
||||
const nodeParts = data.nodeParts || {};
|
||||
|
||||
// Walk a tree node, collect all uids in subtree.
|
||||
function collectUids(node, out) {
|
||||
if (!node) return;
|
||||
if (node.uid) {
|
||||
out.push(node.uid);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(node.children)) {
|
||||
for (const c of node.children) collectUids(c, out);
|
||||
}
|
||||
}
|
||||
|
||||
// Top-level: data.tree.children = array of chunks (name = "assets/<chunk>.js" or "assets/<chunk>.css").
|
||||
const chunks = [];
|
||||
for (const chunkNode of data.tree.children) {
|
||||
const uids = [];
|
||||
collectUids(chunkNode, uids);
|
||||
let raw = 0;
|
||||
let gzip = 0;
|
||||
let brotli = 0;
|
||||
for (const uid of uids) {
|
||||
const part = nodeParts[uid];
|
||||
if (!part) continue;
|
||||
raw += part.renderedLength || 0;
|
||||
gzip += part.gzipLength || 0;
|
||||
brotli += part.brotliLength || 0;
|
||||
}
|
||||
chunks.push({
|
||||
name: chunkNode.name,
|
||||
raw,
|
||||
gzip,
|
||||
brotli,
|
||||
moduleCount: uids.length,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by raw size descending.
|
||||
chunks.sort((a, b) => b.raw - a.raw);
|
||||
|
||||
// Format helper.
|
||||
const fmt = (n) => `${(n / 1024).toFixed(2)} kB`;
|
||||
|
||||
console.log('=== Top-15 chunks (sorted by raw size) ===');
|
||||
console.log('| # | name | raw | gzip | brotli | modules |');
|
||||
console.log('|---|---|---|---|---|---|');
|
||||
chunks.slice(0, 15).forEach((c, idx) => {
|
||||
console.log(`| ${idx + 1} | ${c.name} | ${fmt(c.raw)} | ${fmt(c.gzip)} | ${fmt(c.brotli)} | ${c.moduleCount} |`);
|
||||
});
|
||||
|
||||
console.log('\n=== All chunks (full list, for critical-path identification) ===');
|
||||
console.log('| # | name | raw | gzip | brotli |');
|
||||
console.log('|---|---|---|---|---|');
|
||||
chunks.forEach((c, idx) => {
|
||||
console.log(`| ${idx + 1} | ${c.name} | ${fmt(c.raw)} | ${fmt(c.gzip)} | ${fmt(c.brotli)} |`);
|
||||
});
|
||||
|
||||
// Critical path: eager chunks loaded on initial page render.
|
||||
// For an SPA with vite-plugin + dynamic-import lazy routes, the eager set = entry chunks
|
||||
// (app-*.js + app-*.css) plus any chunks they statically import.
|
||||
// The entry is `resources/js/app.ts` → produces `app-<hash>.js`.
|
||||
// Static imports of entry from manifest = critical path; dynamic imports = lazy.
|
||||
//
|
||||
// Read the manifest.json (Laravel Vite plugin) to find the entry's static imports.
|
||||
const manifestPath = resolve(__dirname, '..', 'public', 'build', 'manifest.json');
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
||||
|
||||
// Find entry (isEntry: true).
|
||||
const entries = Object.entries(manifest).filter(([, v]) => v.isEntry);
|
||||
console.log('\n=== Manifest entries (isEntry=true) ===');
|
||||
for (const [key, val] of entries) {
|
||||
console.log(` ${key} -> ${val.file}`);
|
||||
if (val.imports) console.log(` imports: ${JSON.stringify(val.imports)}`);
|
||||
if (val.css) console.log(` css: ${JSON.stringify(val.css)}`);
|
||||
if (val.dynamicImports) console.log(` dynamicImports.count: ${val.dynamicImports.length}`);
|
||||
}
|
||||
|
||||
// Resolve critical-path file set: entry.file + recursive entry.imports[].file + entry.css[].
|
||||
const eagerFiles = new Set();
|
||||
function addEager(manifestKey) {
|
||||
const entry = manifest[manifestKey];
|
||||
if (!entry) return;
|
||||
if (entry.file && !eagerFiles.has(entry.file)) {
|
||||
eagerFiles.add(entry.file);
|
||||
// Recurse into static imports only (NOT dynamicImports).
|
||||
if (Array.isArray(entry.imports)) {
|
||||
for (const imp of entry.imports) addEager(imp);
|
||||
}
|
||||
if (Array.isArray(entry.css)) {
|
||||
for (const c of entry.css) eagerFiles.add(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [key, val] of entries) {
|
||||
if (val.isEntry) addEager(key);
|
||||
}
|
||||
|
||||
console.log('\n=== Critical-path eager file set (entry + static imports + css) ===');
|
||||
for (const f of eagerFiles) console.log(` - ${f}`);
|
||||
|
||||
// Sum the corresponding chunks' gzip + raw.
|
||||
let eagerRaw = 0;
|
||||
let eagerGzip = 0;
|
||||
let eagerBrotli = 0;
|
||||
for (const f of eagerFiles) {
|
||||
// Chunk name in visualizer is "assets/<file>" stripping "assets/" prefix in manifest path.
|
||||
// Manifest file path is e.g. "assets/app-XXXX.js" already.
|
||||
const target = chunks.find((c) => c.name === f);
|
||||
if (target) {
|
||||
eagerRaw += target.raw;
|
||||
eagerGzip += target.gzip;
|
||||
eagerBrotli += target.brotli;
|
||||
} else {
|
||||
// CSS files are not in tree (visualizer only tracks JS bundle internals).
|
||||
// Read CSS size directly from disk.
|
||||
try {
|
||||
const cssPath = resolve(__dirname, '..', 'public', 'build', f);
|
||||
const css = readFileSync(cssPath);
|
||||
const zlib = await import('node:zlib');
|
||||
const gzSize = zlib.gzipSync(css).length;
|
||||
const brSize = zlib.brotliCompressSync(css).length;
|
||||
eagerRaw += css.length;
|
||||
eagerGzip += gzSize;
|
||||
eagerBrotli += brSize;
|
||||
console.log(` (CSS direct measure) ${f}: raw=${fmt(css.length)} gzip=${fmt(gzSize)} brotli=${fmt(brSize)}`);
|
||||
} catch (e) {
|
||||
console.log(` WARN: could not measure ${f}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n=== Critical-path eager total (visualizer-attribution; pre-minify source bytes) ===');
|
||||
console.log(`raw: ${fmt(eagerRaw)}`);
|
||||
console.log(`gzip: ${fmt(eagerGzip)}`);
|
||||
console.log(`brotli: ${fmt(eagerBrotli)}`);
|
||||
console.log(`(${eagerFiles.size} files)`);
|
||||
|
||||
// ==========================================================================
|
||||
// DISK-TRUTH VIEW: measure emitted files directly. Visualizer's renderedLength
|
||||
// is pre-minify source-byte attribution; on-disk JS is post-minify. Vite's
|
||||
// build stdout reports on-disk truth. Re-compute Top-15 + critical-path from
|
||||
// disk for an apples-to-apples view against the Vite log.
|
||||
// ==========================================================================
|
||||
|
||||
import { readdirSync, statSync } from 'node:fs';
|
||||
import { gzipSync, brotliCompressSync } from 'node:zlib';
|
||||
|
||||
const buildDir = resolve(__dirname, '..', 'public', 'build', 'assets');
|
||||
const allFiles = readdirSync(buildDir);
|
||||
const diskJs = [];
|
||||
const diskCss = [];
|
||||
for (const f of allFiles) {
|
||||
const full = resolve(buildDir, f);
|
||||
const st = statSync(full);
|
||||
if (!st.isFile()) continue;
|
||||
const content = readFileSync(full);
|
||||
const entry = {
|
||||
name: `assets/${f}`,
|
||||
raw: content.length,
|
||||
gzip: gzipSync(content).length,
|
||||
brotli: brotliCompressSync(content).length,
|
||||
};
|
||||
if (f.endsWith('.js')) diskJs.push(entry);
|
||||
else if (f.endsWith('.css')) diskCss.push(entry);
|
||||
}
|
||||
diskJs.sort((a, b) => b.raw - a.raw);
|
||||
diskCss.sort((a, b) => b.raw - a.raw);
|
||||
|
||||
console.log('\n=== Top-15 chunks (DISK-TRUTH, .js only, sorted by raw on-disk size) ===');
|
||||
console.log('| # | name | raw | gzip | brotli |');
|
||||
console.log('|---|---|---|---|---|');
|
||||
diskJs.slice(0, 15).forEach((c, idx) => {
|
||||
console.log(`| ${idx + 1} | ${c.name} | ${fmt(c.raw)} | ${fmt(c.gzip)} | ${fmt(c.brotli)} |`);
|
||||
});
|
||||
|
||||
console.log('\n=== Top-10 CSS files (DISK-TRUTH) ===');
|
||||
console.log('| # | name | raw | gzip | brotli |');
|
||||
console.log('|---|---|---|---|---|');
|
||||
diskCss.slice(0, 10).forEach((c, idx) => {
|
||||
console.log(`| ${idx + 1} | ${c.name} | ${fmt(c.raw)} | ${fmt(c.gzip)} | ${fmt(c.brotli)} |`);
|
||||
});
|
||||
|
||||
// Critical-path eager total — DISK TRUTH using same manifest set.
|
||||
let diskEagerRaw = 0;
|
||||
let diskEagerGzip = 0;
|
||||
let diskEagerBrotli = 0;
|
||||
const diskEagerDetails = [];
|
||||
for (const f of eagerFiles) {
|
||||
const target = [...diskJs, ...diskCss].find((c) => c.name === f);
|
||||
if (target) {
|
||||
diskEagerRaw += target.raw;
|
||||
diskEagerGzip += target.gzip;
|
||||
diskEagerBrotli += target.brotli;
|
||||
diskEagerDetails.push(target);
|
||||
} else {
|
||||
console.log(` WARN: ${f} not found on disk`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n=== Critical-path eager files (DISK TRUTH details) ===');
|
||||
console.log('| # | name | raw | gzip | brotli |');
|
||||
console.log('|---|---|---|---|---|');
|
||||
diskEagerDetails.forEach((c, idx) => {
|
||||
console.log(`| ${idx + 1} | ${c.name} | ${fmt(c.raw)} | ${fmt(c.gzip)} | ${fmt(c.brotli)} |`);
|
||||
});
|
||||
|
||||
console.log('\n=== Critical-path eager total (DISK TRUTH = sum of emitted files) ===');
|
||||
console.log(`raw: ${fmt(diskEagerRaw)}`);
|
||||
console.log(`gzip: ${fmt(diskEagerGzip)}`);
|
||||
console.log(`brotli: ${fmt(diskEagerBrotli)}`);
|
||||
console.log(`(${eagerFiles.size} files)`);
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ApiKey;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
|
||||
$this->actingAs($this->user);
|
||||
});
|
||||
|
||||
test('GET /api/api-keys возвращает активные ключи тенанта', function () {
|
||||
ApiKey::factory()->create(['tenant_id' => $this->tenant->id, 'user_id' => $this->user->id]);
|
||||
|
||||
$response = $this->getJson('/api/api-keys');
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('data'))->toHaveCount(1);
|
||||
expect($response->json('data.0'))->toHaveKeys(['id', 'name', 'key_prefix', 'last_used_at', 'created_at']);
|
||||
expect($response->json('data.0'))->not->toHaveKey('key_hash');
|
||||
});
|
||||
|
||||
test('GET /api/api-keys без auth: 401', function () {
|
||||
auth()->logout();
|
||||
$this->getJson('/api/api-keys')->assertStatus(401);
|
||||
});
|
||||
|
||||
test('GET /api/api-keys изолирован по тенанту', function () {
|
||||
$otherTenant = Tenant::factory()->create();
|
||||
$otherUser = User::factory()->create(['tenant_id' => $otherTenant->id]);
|
||||
ApiKey::factory()->create(['tenant_id' => $otherTenant->id, 'user_id' => $otherUser->id]);
|
||||
|
||||
$response = $this->getJson('/api/api-keys');
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('data'))->toHaveCount(0);
|
||||
});
|
||||
|
||||
test('GET /api/api-keys не возвращает истёкшие ключи', function () {
|
||||
ApiKey::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'is_active' => true,
|
||||
'expires_at' => now()->subDay(),
|
||||
]);
|
||||
ApiKey::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'is_active' => true,
|
||||
'expires_at' => now()->addYear(),
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/api-keys');
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('data'))->toHaveCount(1);
|
||||
});
|
||||
|
||||
test('POST /api/api-keys/regenerate создаёт ключ и возвращает plaintext один раз', function () {
|
||||
$response = $this->postJson('/api/api-keys/regenerate');
|
||||
|
||||
$response->assertStatus(201);
|
||||
expect($response->json('key'))->toStartWith('lpkapi_');
|
||||
expect($response->json('key_prefix'))->toBe(substr($response->json('key'), 0, 10));
|
||||
expect($response->json())->toHaveKeys(['id', 'name', 'key', 'key_prefix']);
|
||||
|
||||
$row = ApiKey::query()->where('tenant_id', $this->tenant->id)->where('is_active', true)->first();
|
||||
expect($row)->not->toBeNull();
|
||||
expect($row->key_hash)->not->toBe($response->json('key'));
|
||||
expect(Hash::check($response->json('key'), $row->key_hash))->toBeTrue();
|
||||
});
|
||||
|
||||
test('POST /api/api-keys/regenerate деактивирует предыдущий активный ключ', function () {
|
||||
$old = ApiKey::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->postJson('/api/api-keys/regenerate')->assertStatus(201);
|
||||
|
||||
$old->refresh();
|
||||
expect($old->is_active)->toBeFalse();
|
||||
expect(ApiKey::query()->where('tenant_id', $this->tenant->id)->where('is_active', true)->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('POST /api/api-keys/regenerate без auth: 401', function () {
|
||||
auth()->logout();
|
||||
$this->postJson('/api/api-keys/regenerate')->assertStatus(401);
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->user = User::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'first_name' => 'Новый',
|
||||
'last_name' => 'Пользователь',
|
||||
'phone' => null,
|
||||
'timezone' => 'Europe/Moscow',
|
||||
]);
|
||||
$this->actingAs($this->user);
|
||||
});
|
||||
|
||||
test('PATCH /api/auth/me обновляет профиль и возвращает user', function () {
|
||||
$response = $this->patchJson('/api/auth/me', [
|
||||
'first_name' => 'Иван',
|
||||
'last_name' => 'Петров',
|
||||
'phone' => '+7 916 000-00-00',
|
||||
'timezone' => 'Asia/Yekaterinburg',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('user.first_name'))->toBe('Иван');
|
||||
expect($response->json('user.last_name'))->toBe('Петров');
|
||||
expect($response->json('user.phone'))->toBe('+7 916 000-00-00');
|
||||
expect($response->json('user.timezone'))->toBe('Asia/Yekaterinburg');
|
||||
|
||||
$this->user->refresh();
|
||||
expect($this->user->first_name)->toBe('Иван');
|
||||
expect($this->user->timezone)->toBe('Asia/Yekaterinburg');
|
||||
});
|
||||
|
||||
test('PATCH /api/auth/me без auth: 401', function () {
|
||||
auth()->logout();
|
||||
$this->patchJson('/api/auth/me', [
|
||||
'first_name' => 'Иван',
|
||||
'last_name' => 'Петров',
|
||||
'timezone' => 'Europe/Moscow',
|
||||
])->assertStatus(401);
|
||||
});
|
||||
|
||||
test('PATCH /api/auth/me: 422 при пустом first_name', function () {
|
||||
$this->patchJson('/api/auth/me', [
|
||||
'first_name' => '',
|
||||
'last_name' => 'Петров',
|
||||
'timezone' => 'Europe/Moscow',
|
||||
])->assertStatus(422)->assertJsonValidationErrorFor('first_name');
|
||||
});
|
||||
|
||||
test('PATCH /api/auth/me: 422 при пустом last_name', function () {
|
||||
$this->patchJson('/api/auth/me', [
|
||||
'first_name' => 'Иван',
|
||||
'last_name' => '',
|
||||
'timezone' => 'Europe/Moscow',
|
||||
])->assertStatus(422)->assertJsonValidationErrorFor('last_name');
|
||||
});
|
||||
|
||||
test('PATCH /api/auth/me: 422 при невалидной timezone', function () {
|
||||
$this->patchJson('/api/auth/me', [
|
||||
'first_name' => 'Иван',
|
||||
'last_name' => 'Петров',
|
||||
'timezone' => 'Mars/Olympus',
|
||||
])->assertStatus(422)->assertJsonValidationErrorFor('timezone');
|
||||
});
|
||||
|
||||
test('PATCH /api/auth/me: phone опционален (nullable)', function () {
|
||||
$response = $this->patchJson('/api/auth/me', [
|
||||
'first_name' => 'Иван',
|
||||
'last_name' => 'Петров',
|
||||
'timezone' => 'Europe/Moscow',
|
||||
]);
|
||||
$response->assertOk();
|
||||
expect($response->json('user.phone'))->toBeNull();
|
||||
});
|
||||
|
||||
test('GET /api/auth/me возвращает phone и timezone', function () {
|
||||
$response = $this->getJson('/api/auth/me');
|
||||
$response->assertOk();
|
||||
expect($response->json('user'))->toHaveKeys(['phone', 'timezone']);
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create([
|
||||
'balance_rub' => '14250.00',
|
||||
'balance_leads' => 285,
|
||||
]);
|
||||
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
|
||||
$this->actingAs($this->user);
|
||||
});
|
||||
|
||||
// ---- wallet ----
|
||||
|
||||
test('GET /api/billing/wallet возвращает баланс тенанта', function () {
|
||||
$this->getJson('/api/billing/wallet')
|
||||
->assertOk()
|
||||
->assertJsonPath('balance_rub', '14250.00')
|
||||
->assertJsonPath('balance_leads', 285);
|
||||
});
|
||||
|
||||
test('GET /api/billing/wallet возвращает тариф, если он назначен', function () {
|
||||
$tariffId = DB::table('tariff_plans')->where('code', 'pro')->value('id');
|
||||
$this->tenant->update(['current_tariff_id' => $tariffId]);
|
||||
|
||||
$response = $this->getJson('/api/billing/wallet');
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('tariff.code', 'pro')
|
||||
->assertJsonPath('tariff.name', 'Про');
|
||||
expect($response->json('tariff.features'))->toBeArray();
|
||||
});
|
||||
|
||||
test('GET /api/billing/wallet возвращает tariff=null без назначенного тарифа', function () {
|
||||
$this->getJson('/api/billing/wallet')
|
||||
->assertOk()
|
||||
->assertJsonPath('tariff', null);
|
||||
});
|
||||
|
||||
test('GET /api/billing/wallet: runway_days = null без списаний', function () {
|
||||
$this->getJson('/api/billing/wallet')
|
||||
->assertOk()
|
||||
->assertJsonPath('runway_days', null);
|
||||
});
|
||||
|
||||
test('GET /api/billing/wallet: runway_days рассчитан при наличии списаний', function () {
|
||||
BalanceTransaction::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'type' => 'lead_charge',
|
||||
'amount_rub' => '-3000.00',
|
||||
'created_at' => now()->subDays(10),
|
||||
]);
|
||||
|
||||
// 3000 ₽ / 30 дн = 100 ₽/день; баланс 14250 → floor(142.5) = 142.
|
||||
expect($this->getJson('/api/billing/wallet')->json('runway_days'))->toBe(142);
|
||||
});
|
||||
|
||||
test('GET /api/billing/wallet: runway_days = 0 при отрицательном балансе', function () {
|
||||
$this->tenant->update(['balance_rub' => '-500.00']);
|
||||
BalanceTransaction::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'type' => 'lead_charge',
|
||||
'amount_rub' => '-3000.00',
|
||||
'created_at' => now()->subDays(10),
|
||||
]);
|
||||
|
||||
// Баланс уже отрицательный → runway не может быть отрицательным, клампится в 0.
|
||||
expect($this->getJson('/api/billing/wallet')->json('runway_days'))->toBe(0);
|
||||
});
|
||||
|
||||
test('GET /api/billing/wallet без auth: 401', function () {
|
||||
auth()->logout();
|
||||
$this->getJson('/api/billing/wallet')->assertStatus(401);
|
||||
});
|
||||
|
||||
// ---- transactions ----
|
||||
|
||||
test('GET /api/billing/transactions возвращает транзакции тенанта', function () {
|
||||
BalanceTransaction::factory()->count(3)->create(['tenant_id' => $this->tenant->id]);
|
||||
|
||||
$response = $this->getJson('/api/billing/transactions');
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('data'))->toHaveCount(3);
|
||||
expect($response->json('meta.total'))->toBe(3);
|
||||
expect($response->json('data.0'))->toHaveKeys(['id', 'code', 'type', 'amount_rub', 'created_at']);
|
||||
});
|
||||
|
||||
test('GET /api/billing/transactions изолирован по тенанту', function () {
|
||||
BalanceTransaction::factory()->create(['tenant_id' => $this->tenant->id]);
|
||||
$other = Tenant::factory()->create();
|
||||
BalanceTransaction::factory()->create(['tenant_id' => $other->id]);
|
||||
|
||||
expect($this->getJson('/api/billing/transactions')->json('data'))->toHaveCount(1);
|
||||
});
|
||||
|
||||
test('GET /api/billing/transactions фильтрует по type', function () {
|
||||
BalanceTransaction::factory()->create(['tenant_id' => $this->tenant->id, 'type' => 'topup']);
|
||||
BalanceTransaction::factory()->create(['tenant_id' => $this->tenant->id, 'type' => 'lead_charge', 'amount_rub' => '-50.00']);
|
||||
BalanceTransaction::factory()->create(['tenant_id' => $this->tenant->id, 'type' => 'refund', 'amount_rub' => '10.00']);
|
||||
|
||||
$this->getJson('/api/billing/transactions?type=topup')
|
||||
->assertJsonCount(1, 'data')->assertJsonPath('data.0.type', 'topup');
|
||||
$this->getJson('/api/billing/transactions?type=lead_charge')
|
||||
->assertJsonCount(1, 'data')->assertJsonPath('data.0.type', 'lead_charge');
|
||||
$this->getJson('/api/billing/transactions?type=refund')
|
||||
->assertJsonCount(1, 'data')->assertJsonPath('data.0.type', 'refund');
|
||||
});
|
||||
|
||||
test('GET /api/billing/transactions: пагинация 20/страница', function () {
|
||||
BalanceTransaction::factory()->count(25)->create(['tenant_id' => $this->tenant->id]);
|
||||
|
||||
expect($this->getJson('/api/billing/transactions?page=1')->json('data'))->toHaveCount(20);
|
||||
expect($this->getJson('/api/billing/transactions?page=2')->json('data'))->toHaveCount(5);
|
||||
});
|
||||
|
||||
test('GET /api/billing/transactions без auth: 401', function () {
|
||||
auth()->logout();
|
||||
$this->getJson('/api/billing/transactions')->assertStatus(401);
|
||||
});
|
||||
|
||||
// ---- invoices ----
|
||||
|
||||
test('GET /api/billing/invoices возвращает пустой список без счетов', function () {
|
||||
$this->getJson('/api/billing/invoices')
|
||||
->assertOk()
|
||||
->assertJsonCount(0, 'data');
|
||||
});
|
||||
|
||||
test('GET /api/billing/invoices возвращает счета тенанта и изолирует чужие', function () {
|
||||
$leId = DB::table('legal_entities')->insertGetId([
|
||||
'code' => 'ooo_test_'.uniqid(),
|
||||
'name' => 'ООО Тест',
|
||||
'legal_form' => 'OOO',
|
||||
'inn' => '7700000000',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
DB::table('saas_invoices')->insert([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'legal_entity_id' => $leId,
|
||||
'invoice_number' => 'СЧ-2026-00001',
|
||||
'payer_type' => 'legal',
|
||||
'amount_net' => '990.00',
|
||||
'amount_total' => '990.00',
|
||||
'status' => 'issued',
|
||||
'issued_at' => now(),
|
||||
'expires_at' => now()->addDays(5),
|
||||
]);
|
||||
$other = Tenant::factory()->create();
|
||||
DB::table('saas_invoices')->insert([
|
||||
'tenant_id' => $other->id,
|
||||
'legal_entity_id' => $leId,
|
||||
'invoice_number' => 'СЧ-2026-00002',
|
||||
'payer_type' => 'legal',
|
||||
'amount_net' => '500.00',
|
||||
'amount_total' => '500.00',
|
||||
'status' => 'issued',
|
||||
'issued_at' => now(),
|
||||
'expires_at' => now()->addDays(5),
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/billing/invoices');
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('data'))->toHaveCount(1);
|
||||
expect($response->json('data.0.invoice_number'))->toBe('СЧ-2026-00001');
|
||||
});
|
||||
|
||||
test('GET /api/billing/invoices без auth: 401', function () {
|
||||
auth()->logout();
|
||||
$this->getJson('/api/billing/invoices')->assertStatus(401);
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Billing\BillingTopupService;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
test('topup кредитует balance_rub и пишет append-only строку topup', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '500.00', 'balance_leads' => 7]);
|
||||
|
||||
$tx = app(BillingTopupService::class)->topup($tenant->id, '250.00', null);
|
||||
|
||||
expect($tx->type)->toBe('topup')
|
||||
->and($tx->amount_rub)->toBe('250.00')
|
||||
->and($tx->amount_leads)->toBe(0)
|
||||
->and($tx->balance_rub_after)->toBe('750.00')
|
||||
->and($tx->balance_leads_after)->toBe(7);
|
||||
|
||||
expect((string) $tenant->fresh()->balance_rub)->toBe('750.00');
|
||||
});
|
||||
|
||||
test('topup использует bcmath — нет float-дрейфа на 0.1 + 0.2', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '0.10']);
|
||||
|
||||
app(BillingTopupService::class)->topup($tenant->id, '0.20', null);
|
||||
|
||||
expect((string) $tenant->fresh()->balance_rub)->toBe('0.30');
|
||||
});
|
||||
|
||||
test('topup фиксирует user_id инициатора', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '0.00']);
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
|
||||
$tx = app(BillingTopupService::class)->topup($tenant->id, '100.00', $user->id);
|
||||
|
||||
expect($tx->user_id)->toBe($user->id);
|
||||
});
|
||||
|
||||
test('topup-строка получает log_hash через append-only hash-chain триггер', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '0.00']);
|
||||
|
||||
$tx = app(BillingTopupService::class)->topup($tenant->id, '100.00', null);
|
||||
|
||||
$hasHash = DB::table('balance_transactions')
|
||||
->where('id', $tx->id)
|
||||
->whereNotNull('log_hash')
|
||||
->exists();
|
||||
expect($hasHash)->toBeTrue();
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create(['balance_rub' => '500.00', 'balance_leads' => 12]);
|
||||
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
|
||||
$this->actingAs($this->user);
|
||||
});
|
||||
|
||||
test('POST /api/billing/topup кредитует баланс и возвращает 201', function () {
|
||||
$response = $this->postJson('/api/billing/topup', ['amount_rub' => 250]);
|
||||
|
||||
$response->assertStatus(201)
|
||||
->assertJsonPath('balance_rub', '750.00')
|
||||
->assertJsonPath('transaction.type', 'topup')
|
||||
->assertJsonPath('transaction.amount_rub', '250.00');
|
||||
|
||||
expect((string) $this->tenant->fresh()->balance_rub)->toBe('750.00');
|
||||
});
|
||||
|
||||
test('POST /api/billing/topup пишет строку balance_transactions с user_id', function () {
|
||||
$this->postJson('/api/billing/topup', ['amount_rub' => 100])->assertStatus(201);
|
||||
|
||||
$this->assertDatabaseHas('balance_transactions', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'type' => 'topup',
|
||||
'amount_rub' => '100.00',
|
||||
'balance_rub_after' => '600.00',
|
||||
]);
|
||||
});
|
||||
|
||||
test('POST /api/billing/topup использует bcmath-точность', function () {
|
||||
$this->tenant->update(['balance_rub' => '0.10']);
|
||||
|
||||
$this->postJson('/api/billing/topup', ['amount_rub' => 100.20])->assertStatus(201);
|
||||
|
||||
expect((string) $this->tenant->fresh()->balance_rub)->toBe('100.30');
|
||||
});
|
||||
|
||||
test('POST /api/billing/topup не затрагивает баланс чужого тенанта', function () {
|
||||
$otherTenant = Tenant::factory()->create(['balance_rub' => '777.00']);
|
||||
|
||||
$this->postJson('/api/billing/topup', ['amount_rub' => 100])->assertStatus(201);
|
||||
|
||||
// Топап эндпоинт не принимает tenant_id — резолвит из auth-пользователя;
|
||||
// баланс чужого тенанта неприкосновенен (defense-in-depth, паттерн проекта).
|
||||
expect((string) $otherTenant->fresh()->balance_rub)->toBe('777.00');
|
||||
});
|
||||
|
||||
test('POST /api/billing/topup отклоняет сумму ниже минимума 100 ₽', function () {
|
||||
$this->postJson('/api/billing/topup', ['amount_rub' => 50])
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors('amount_rub');
|
||||
});
|
||||
|
||||
test('POST /api/billing/topup отклоняет отсутствующую сумму', function () {
|
||||
$this->postJson('/api/billing/topup', [])
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors('amount_rub');
|
||||
});
|
||||
|
||||
test('POST /api/billing/topup отклоняет более 2 знаков после запятой', function () {
|
||||
$this->postJson('/api/billing/topup', ['amount_rub' => 100.123])
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors('amount_rub');
|
||||
});
|
||||
|
||||
test('POST /api/billing/topup без auth: 401', function () {
|
||||
auth()->logout();
|
||||
$this->postJson('/api/billing/topup', ['amount_rub' => 100])->assertStatus(401);
|
||||
});
|
||||
@@ -4,10 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
|
||||
@@ -5,10 +5,10 @@ declare(strict_types=1);
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('Project has signal_type, signal_identifier in fillable', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Cache\ArrayStore;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Quirk 72 regression guard — see tests/TestCase.php::setUp().
|
||||
*
|
||||
* Supplier code (SupplierPortalClient::loadSession, RefreshSupplierSessionJob,
|
||||
* CsvReconcileJob, RouteSupplierLeadJob) hardcodes Cache::store('redis'),
|
||||
* bypassing phpunit.xml's CACHE_STORE=array. The Redis store is a shared
|
||||
* external service: under `pest --parallel` every worker collides on the
|
||||
* global `supplier:session` key, so one worker's afterEach forget()/flush()
|
||||
* races another worker's mid-test loadSession().
|
||||
*
|
||||
* TestCase::setUp() repoints the `redis` cache store at the in-process
|
||||
* `array` driver, making it hermetic and worker-local. This test fails
|
||||
* (RedisStore) if that override is ever removed.
|
||||
*/
|
||||
test('redis cache store resolves to the in-process array driver under tests', function (): void {
|
||||
expect(Cache::store('redis')->getStore())->toBeInstanceOf(ArrayStore::class);
|
||||
});
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
use App\Exceptions\Supplier\SupplierTransientException;
|
||||
use App\Jobs\RouteSupplierLeadJob;
|
||||
use App\Jobs\Supplier\CsvReconcileJob;
|
||||
use App\Jobs\Supplier\RefreshSupplierSessionJob;
|
||||
use App\Mail\CsvDriftAlertMail;
|
||||
use App\Models\SupplierLead;
|
||||
use App\Models\SupplierProject;
|
||||
@@ -47,7 +48,18 @@ function putSupplierSession(): void
|
||||
|
||||
beforeEach(function () {
|
||||
Mail::fake();
|
||||
Bus::fake();
|
||||
// Partial fake: only RouteSupplierLeadJob is intercepted (what we assert on).
|
||||
// RefreshSupplierSessionJob must NOT be faked — it must run our mock below
|
||||
// so that loadSession() can recover if a concurrent afterEach wipes the session.
|
||||
Bus::fake([RouteSupplierLeadJob::class]);
|
||||
// Bind a mock that re-puts the session when dispatch_sync triggers it during a race.
|
||||
app()->bind(RefreshSupplierSessionJob::class, fn () => new class
|
||||
{
|
||||
public function handle(): void
|
||||
{
|
||||
putSupplierSession();
|
||||
}
|
||||
});
|
||||
// NB: NOT Cache::store('redis')->flush() — flush wipes session keys belonging to
|
||||
// OTHER parallel tests (cross-pollution). Just forget our reserved keys + re-put.
|
||||
Cache::store('redis')->forget('supplier:csv_reconcile');
|
||||
|
||||
@@ -22,6 +22,8 @@ uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-12 10:00:00', 'Europe/Moscow'));
|
||||
|
||||
Cache::store('redis')->put('supplier:session', [
|
||||
'phpsessid' => 'sess',
|
||||
'csrf' => 'csrf',
|
||||
|
||||
@@ -19,6 +19,15 @@ beforeEach(function () {
|
||||
// Чистим RateLimiter между тестами — иначе lockout из одного теста
|
||||
// загрязняет следующий.
|
||||
RateLimiter::clear("webhook:{$this->tenant->id}");
|
||||
|
||||
// Audit-fix B3: дефолт isHmacRequired() изменён на true. Тесты, проверяющие
|
||||
// НЕ-HMAC аспекты (payload-валидация, rate-limit, CSRF), явно ставят флаг в
|
||||
// false — иначе запрос без подписи получит 401 ещё до этих проверок.
|
||||
SystemSetting::firstOrCreate(
|
||||
['key' => 'webhook_hmac_required'],
|
||||
['value' => 'false', 'type' => 'bool', 'description' => 'test default', 'updated_at' => now()],
|
||||
);
|
||||
SystemSetting::where('key', 'webhook_hmac_required')->update(['value' => 'false']);
|
||||
});
|
||||
|
||||
test('POST /api/webhook/{token} с валидным payload возвращает 202 + dispatch ProcessWebhookJob', function () {
|
||||
@@ -154,15 +163,20 @@ test('HMAC: невалидная подпись → 401, dispatch НЕ прои
|
||||
Bus::assertNothingDispatched();
|
||||
});
|
||||
|
||||
test('HMAC: отсутствие header → пропускаем (backward-compat) → 202', function () {
|
||||
test('HMAC: настройка отсутствует → HMAC обязателен по умолчанию (B3) → 401', function () {
|
||||
Bus::fake();
|
||||
// Audit-fix B3: code-default isHmacRequired() = true. Удаляем настройку,
|
||||
// чтобы проверить именно отсутствие ключа в system_settings.
|
||||
SystemSetting::where('key', 'webhook_hmac_required')->delete();
|
||||
|
||||
$r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", [
|
||||
'vid' => 1,
|
||||
'project' => 'X',
|
||||
'phone' => '+7 (999) 000-00-00',
|
||||
'time' => time(),
|
||||
]);
|
||||
$r->assertStatus(202);
|
||||
$r->assertStatus(401);
|
||||
Bus::assertNothingDispatched();
|
||||
});
|
||||
|
||||
test('rate-limit: системный лимит RPS×60 в минуту, 429 + Retry-After на превышении', function () {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\OutboundWebhookSubscription;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
|
||||
$this->actingAs($this->user);
|
||||
});
|
||||
|
||||
test('GET webhook-settings: null когда подписки нет', function () {
|
||||
$response = $this->getJson('/api/tenants/me/webhook-settings');
|
||||
$response->assertOk();
|
||||
expect($response->json('data'))->toBeNull();
|
||||
});
|
||||
|
||||
test('GET webhook-settings возвращает подписку тенанта', function () {
|
||||
OutboundWebhookSubscription::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'target_url' => 'https://crm.example.ru/hook',
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/tenants/me/webhook-settings');
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('data.target_url'))->toBe('https://crm.example.ru/hook');
|
||||
expect($response->json('data'))->toHaveKeys(['target_url', 'secret_prefix', 'events', 'is_active']);
|
||||
expect($response->json('data'))->not->toHaveKey('secret_hash');
|
||||
});
|
||||
|
||||
test('GET webhook-settings изолирован по тенанту', function () {
|
||||
$otherTenant = Tenant::factory()->create();
|
||||
$otherUser = User::factory()->create(['tenant_id' => $otherTenant->id]);
|
||||
OutboundWebhookSubscription::factory()->create([
|
||||
'tenant_id' => $otherTenant->id,
|
||||
'user_id' => $otherUser->id,
|
||||
'target_url' => 'https://other.example.ru/hook',
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/tenants/me/webhook-settings');
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('data'))->toBeNull();
|
||||
});
|
||||
|
||||
test('PUT webhook-settings создаёт подписку и возвращает secret один раз', function () {
|
||||
$response = $this->putJson('/api/tenants/me/webhook-settings', [
|
||||
'target_url' => 'https://crm.example.ru/hook',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('data.target_url'))->toBe('https://crm.example.ru/hook');
|
||||
expect($response->json('data.secret'))->toStartWith('whsec_');
|
||||
expect($response->json('data.events'))->toBeArray()->not->toBeEmpty();
|
||||
|
||||
$row = OutboundWebhookSubscription::query()->where('tenant_id', $this->tenant->id)->first();
|
||||
expect($row)->not->toBeNull();
|
||||
expect(Hash::check($response->json('data.secret'), $row->secret_hash))->toBeTrue();
|
||||
});
|
||||
|
||||
test('PUT webhook-settings обновляет URL существующей подписки без нового secret', function () {
|
||||
OutboundWebhookSubscription::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'target_url' => 'https://old.example.ru/hook',
|
||||
]);
|
||||
|
||||
$response = $this->putJson('/api/tenants/me/webhook-settings', [
|
||||
'target_url' => 'https://new.example.ru/hook',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('data.target_url'))->toBe('https://new.example.ru/hook');
|
||||
expect($response->json('data'))->not->toHaveKey('secret');
|
||||
expect(OutboundWebhookSubscription::query()->where('tenant_id', $this->tenant->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('PUT webhook-settings: 422 при не-https URL', function () {
|
||||
$this->putJson('/api/tenants/me/webhook-settings', [
|
||||
'target_url' => 'http://insecure.example.ru/hook',
|
||||
])->assertStatus(422)->assertJsonValidationErrorFor('target_url');
|
||||
});
|
||||
|
||||
test('POST webhooks/test отправляет запрос и возвращает результат', function () {
|
||||
Http::fake(['*' => Http::response(['ok' => true], 200)]);
|
||||
OutboundWebhookSubscription::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'target_url' => 'https://crm.example.ru/hook',
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/webhooks/test');
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('ok'))->toBeTrue();
|
||||
expect($response->json('status'))->toBe(200);
|
||||
Http::assertSent(fn ($req) => $req->url() === 'https://crm.example.ru/hook');
|
||||
});
|
||||
|
||||
test('POST webhooks/test возвращает ok=false при ошибке endpoint', function () {
|
||||
Http::fake(['*' => Http::response([], 500)]);
|
||||
OutboundWebhookSubscription::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'target_url' => 'https://crm.example.ru/hook',
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/webhooks/test');
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('ok'))->toBeFalse();
|
||||
expect($response->json('status'))->toBe(500);
|
||||
});
|
||||
|
||||
test('POST webhooks/test: 422 когда подписки нет', function () {
|
||||
$this->postJson('/api/webhooks/test')->assertStatus(422);
|
||||
});
|
||||
|
||||
test('GET webhook-settings без auth: 401', function () {
|
||||
auth()->logout();
|
||||
$this->getJson('/api/tenants/me/webhook-settings')->assertStatus(401);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user