Compare commits
113 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 68f1ccbf47 | |||
| 3f7c1e4069 | |||
| 9fa187780b | |||
| cf9c082af1 | |||
| b9f4f73311 | |||
| 9e749ef24b | |||
| f64c70501d | |||
| b7f65865b1 | |||
| 06df563ddf | |||
| c1e7384437 | |||
| d19842afb3 | |||
| ccd2419432 | |||
| b55faf79d2 | |||
| 8e910d024c | |||
| 640ee51520 | |||
| a575d55e9a | |||
| bc09186299 | |||
| 8e732fa855 | |||
| 79309c7595 | |||
| c4e6691b28 | |||
| 791bc1bfae | |||
| 25790f3f9d | |||
| 5d7d7af00c | |||
| d3b3a4f436 | |||
| e2b2bc7487 | |||
| 20e5752c68 | |||
| 38914fc779 | |||
| 09fa3b6a40 | |||
| c3e6ddbe22 | |||
| 9bf97efb0b | |||
| 4d37402bc7 | |||
| e605303e02 | |||
| ce65df27e2 | |||
| 218a6738fa | |||
| 61ee04d3e6 | |||
| c5d360fc59 | |||
| 365d1a0a93 | |||
| 000822d687 | |||
| 01bd9977b4 | |||
| 2f14169360 | |||
| 1cc1fc292a | |||
| b1ce3d1f36 | |||
| ded8e3758d | |||
| 391607cadd | |||
| d5b3406860 | |||
| 9fd8f35ca4 | |||
| ede7b97a4f | |||
| 9cabe8ded4 | |||
| 16edd922ed | |||
| 4772ae78ad | |||
| 9ae505b490 | |||
| 0374612444 | |||
| eeb76712eb | |||
| 0c9357af7a | |||
| 4c80a5823f | |||
| 029b19a091 | |||
| 4ff3d3ed1e | |||
| db287d19a8 | |||
| b32dfbcdc1 | |||
| 3657e18e16 | |||
| a1296707e0 | |||
| 8a8b860c61 | |||
| 351186cee9 | |||
| 438c066b8e | |||
| bce8789951 | |||
| 527a779d9b | |||
| e6beff6aeb | |||
| 6933ddc538 | |||
| 2a34ee880a | |||
| 1dc696cef6 | |||
| b29bfe2ac6 | |||
| 3fc5501dc5 | |||
| 55684e80b2 | |||
| 1345ce2ddf | |||
| 3280aad059 | |||
| 4ccb06c900 | |||
| a27b31efa6 | |||
| ca292d44a9 | |||
| 08d3ae35d8 | |||
| 2138270af0 | |||
| eef21ba04b | |||
| 05437ba79a | |||
| 1933129497 | |||
| 1bbedf2f95 | |||
| b35a8c4311 | |||
| 68f42ad385 | |||
| 83613b4509 | |||
| cf0be8ac0f | |||
| 5e3d20fa61 | |||
| 65722c76cb | |||
| 906ae4f587 | |||
| 20cc132777 | |||
| 4d7e9ca0e4 | |||
| 6174830311 | |||
| 3ef1e625eb | |||
| 2c28f1cb86 | |||
| 6dec34403f | |||
| 4f16cc3c83 | |||
| 45691d0324 | |||
| 8c350572df | |||
| 22e81cc896 | |||
| 3bbd7787d8 | |||
| 07d73870ba | |||
| 7408bc4232 | |||
| 9d68fc0ad6 | |||
| e2fb20ef05 | |||
| 5427cdc740 | |||
| f3250ce178 | |||
| 472ea8c75c | |||
| b053796182 | |||
| 3b6992d8e9 | |||
| 233f9984fc | |||
| 4f5cf263f6 |
@@ -38,5 +38,7 @@ See `references/aggregation-template.md`.
|
||||
|
||||
## Behavioral rule reminders
|
||||
|
||||
- **«Не использован ≠ проблема»** — when reporting node usage counts, NEVER mark unused nodes as «zombie» / «removal candidate». Cite `memory/feedback_brain_unused_tools_not_problem.md`.
|
||||
- **«Не использован ≠ проблема» (условное, Pravila §16.4 v1.36)** — when reporting node usage counts, distinguish two cases:
|
||||
1. **Unused + no profile task in episodes** → capability-readiness, do NOT flag.
|
||||
2. **Unused + profile task present (missed activation)** → mandatory section in the report. Cite `tools/observer-classification-map.json` for the classification→node mapping and `tools/.node-dormancy.json` for DEFERRED exclusions. NEVER mark unused-by-design nodes as «zombie» / «removal candidate».
|
||||
- **No auto-edit** — every regulatory suggestion is a candidate, not an action.
|
||||
|
||||
@@ -55,6 +55,32 @@ For each factor below, render a table: factor value × outcome counts
|
||||
|
||||
(one table each — same columns)
|
||||
|
||||
## Missed Activations (Pravila §16.4 v1.36)
|
||||
|
||||
Surface candidates where a profile-classified task ran with `node_chosen === 'direct'` and at least one non-dormant recommended node was available. The analyzer returns `missedActivations: { totalMissed, byNode, byClassification }` — render the two breakdowns below.
|
||||
|
||||
**Source:** `analyze(episodes, { classificationMap, dormancy }).missedActivations`.
|
||||
|
||||
### By node
|
||||
|
||||
| Node | Episodes missed | Classifications hit |
|
||||
|---|---|---|
|
||||
| #NN | N | refactor (a), bugfix (b) |
|
||||
|
||||
### By classification
|
||||
|
||||
| Classification | Missed episodes | Top recommended nodes (non-dormant) |
|
||||
|---|---|---|
|
||||
| refactor | N | #11, #12, #43 |
|
||||
|
||||
**Interpretation guide:**
|
||||
|
||||
- High count on one node → router-miss pattern. Suggest updating `tools/observer-classification-map.json` or a workflow nudge.
|
||||
- Spread across many nodes with classification leaning to `other` → the classification dictionary may need refinement (separate concern, not a missed activation).
|
||||
- All zero → either no profile work this period, or the router is operating cleanly.
|
||||
|
||||
**NOT to be auto-applied:** these are candidates for human review in retro, not commits or hook blocks.
|
||||
|
||||
## Episodes → tasks (from analyzer `tasks`)
|
||||
|
||||
| task_ref | episodes | turns that are rework |
|
||||
@@ -113,4 +139,4 @@ problem** per `memory/feedback_brain_unused_tools_not_problem`.
|
||||
## Informational metrics (NOT alerts)
|
||||
|
||||
- Nodes used at least once this period: K / 60+
|
||||
- Nodes never used since beginning of observer logs: L / 60+ — **not a problem** per [feedback_brain_unused_tools_not_problem](../../../memory/feedback_brain_unused_tools_not_problem.md)
|
||||
- Nodes never used since beginning of observer logs: L / 67 — **not a problem if there was no profile task** per Pravila §16.4 v1.36 and [feedback_brain_unused_tools_not_problem](../../../memory/feedback_brain_unused_tools_not_problem.md). See `## Missed Activations` above for profile-task-present cases.
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
name: pdn-152fz-audit
|
||||
description: Аудит защиты персональных данных Лидерры и соответствие 152-ФЗ. Режим 1 — техника (где лежат ПДн в схеме/коде, RLS, маскирование pg_anonymizer, утечки в логах/Sentry/CSV-экспортах, шифрование). Режим 2 — закон (хранение в РФ, согласия, сроки/удаление, реестр обработки, уведомление РКН, права субъекта pd_subject_request). Используй при «проверь ПДн», «утекают ли персональные данные», «соответствие 152-ФЗ», «где хранятся телефоны лидов», «маскируются ли данные в дампах». НЕ для денежной корректности (billing-audit), security-аудита кода (D3/Semgrep), юридического оформления договоров/политик (D2 право), generic-угроз (threat-model #72).
|
||||
---
|
||||
|
||||
# ПДн 152-ФЗ Аудит — защита персональных данных Лидерры
|
||||
|
||||
Проектный скил раздела A8 карты «Информационная безопасность». Проверяет
|
||||
**защиту персональных данных** и соответствие Федеральному закону №152-ФЗ
|
||||
«О персональных данных» для SaaS-портала, обрабатывающего телефоны лидов
|
||||
и данные клиентов-компаний перед выходом в продакшен.
|
||||
|
||||
## Когда использовать
|
||||
|
||||
- Вопрос «не утекают ли ПДн в логи / Sentry / CSV-экспорты?»
|
||||
- Проверка технической защиты ПДн перед запуском (RLS, маскирование, шифрование).
|
||||
- Оценка соответствия 152-ФЗ: хранение в РФ, согласия, права субъекта, реестр.
|
||||
- Ревью кода, затрагивающего `deals`, `users`, `pd_subject_requests`,
|
||||
`pd_processing_log`, `supplier_leads` или CSV-импорт/экспорт лидов.
|
||||
|
||||
## Два режима
|
||||
|
||||
### Режим 1 — Технический аудит ПДн
|
||||
|
||||
Проверяет, что персональные данные физически защищены в коде и схеме БД.
|
||||
|
||||
Вопросы:
|
||||
|
||||
- Какие таблицы/колонки содержат ПДн? Под RLS ли они?
|
||||
- Маскируются ли ПДн в дампах (pg_anonymizer)?
|
||||
- Не утекают ли phone/email/ФИО в Laravel-логи, Sentry, `activity_log.context`,
|
||||
`auth_log`, `supplier_leads.raw_payload`?
|
||||
- Зашифрованы ли чувствительные поля в покое (totp_secret)?
|
||||
- Защищены ли CSV-экспорты лидов (signed URL + аудит в `pd_processing_log`)?
|
||||
|
||||
**Запустить:** пройти по чек-листу `references/checklist.md` → Раздел 1.
|
||||
|
||||
### Режим 2 — Соответствие 152-ФЗ
|
||||
|
||||
Проверяет правовую и процессную сторону обработки ПДн.
|
||||
|
||||
Вопросы:
|
||||
|
||||
- Хранятся ли ПДн на территории РФ?
|
||||
- Зафиксированы ли согласия субъектов ПДн (`tenant_consents`)?
|
||||
- Есть ли механизм обращений субъектов (`pd_subject_requests` + дедлайн 30 дней)?
|
||||
- Ведётся ли журнал обработки ПДн (`pd_processing_log`)?
|
||||
- Уведомлен ли РКН? Есть ли реестр обработки?
|
||||
- Реализовано ли право на ограничение обработки (`processing_restricted`)?
|
||||
|
||||
**Запустить:** пройти по чек-листу `references/checklist.md` → Раздел 2.
|
||||
|
||||
## Границы
|
||||
|
||||
- ≠ `billing-audit` #62 — тот про *денежную корректность начислений*; pdn-152fz-audit про *персональные данные*.
|
||||
- ≠ D3 «audit-security» (#39/#40 Trail of Bits / Semgrep) — те про *security-уязвимости кода*; pdn-152fz-audit про *данные субъектов ПДн*.
|
||||
- ≠ D2 «Право / договоры» — там юридическое оформление (политика обработки, договор с оператором); pdn-152fz-audit про *технику и процедуры*.
|
||||
- ≠ `threat-model` #72 — тот про *моделирование угроз*; pdn-152fz-audit про *конкретные ПДн в конкретных таблицах*.
|
||||
|
||||
## Связано
|
||||
|
||||
- Reuse: Boost #10 (SQL-запросы к схеме), Semgrep #25 (статанализ кода на утечки),
|
||||
Sentry MCP #34 (проверка runtime-маскирования), pg_anonymizer #29 (дампы).
|
||||
- ADR-013 (infosec-tooling A8).
|
||||
- Нормативная основа: ФЗ-152 ст.18 (уведомление РКН), ст.21 ч.5 (ограничение
|
||||
обработки), ст.22 (реестр операторов), ст.14 (права субъекта).
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"skill": "pdn-152fz-audit",
|
||||
"cases": [
|
||||
{"prompt": "проверь, не утекают ли телефоны лидов в логи", "should_trigger": true},
|
||||
{"prompt": "соответствует ли портал 152-ФЗ перед запуском", "should_trigger": true},
|
||||
{"prompt": "проверь, не теряются ли копейки в списании", "should_trigger": false, "expected": "billing-audit"},
|
||||
{"prompt": "смоделируй угрозы при выходе портала в интернет", "should_trigger": false, "expected": "threat-model"},
|
||||
{"prompt": "составь договор обработки персональных данных", "should_trigger": false, "expected": "D2 право"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
# ПДн 152-ФЗ — чек-лист аудита Лидерры
|
||||
|
||||
Основан на реальных артефактах проекта (db/schema.sql v8.26, 21.05.2026).
|
||||
|
||||
## Таблицы-носители ПДн (инвентарь)
|
||||
|
||||
| Таблица | ПДн-колонки | Тип субъекта |
|
||||
|---|---|---|
|
||||
| `deals` | `phone`, `phones` (JSONB), `contact_name`, `city` | лид (физлицо) |
|
||||
| `supplier_leads` | `phone`, `raw_payload` (JSONB — весь payload поставщика) | лид (физлицо) |
|
||||
| `users` | `email`, `first_name`, `last_name`, `phone`, `totp_secret` | пользователь-клиент |
|
||||
| `tenants` | `contact_email`, `organization_name` | организация-клиент |
|
||||
| `auth_log` | `email` (при login_failed для неизвестного пользователя) | пользователь |
|
||||
| `pd_subject_requests` | `subject_email`, `subject_phone`, `subject_full_name` | субъект ПДн |
|
||||
| `impersonation_tokens` | косвенно (связь user — admin) | пользователь |
|
||||
| `import_log` | `filename`, `file_path` (может содержать имя файла с ПДн) | лид (косвенно) |
|
||||
|
||||
---
|
||||
|
||||
## Раздел 1 — Технический аудит ПДн
|
||||
|
||||
### Т1. RLS на таблицах-носителях ПДн
|
||||
|
||||
- [ ] `deals` — `ENABLE ROW LEVEL SECURITY` ✅ (подтверждено schema.sql:2780).
|
||||
Проверить: `FORCE ROW LEVEL SECURITY` не выставлен (только у `lead_charges`
|
||||
— там сильнее). Убедиться, что `crm_app_user` не BYPASSRLS.
|
||||
- [ ] `users` — RLS включён (schema.sql:2778). Политика `tenant_isolation` по
|
||||
`tenant_id`. Проверить: нет прямого SELECT * без `SET LOCAL app.current_tenant_id`.
|
||||
- [ ] `supplier_leads` — **RLS не включён** (таблица SaaS-уровня, schema.sql:1948).
|
||||
Это осознанное решение. Проверить: доступ только из воркера
|
||||
(`crm_supplier_worker` BYPASSRLS) с явным `WHERE tenant_id`.
|
||||
- [ ] `pd_subject_requests` — **RLS не включён** намеренно (saas-уровневая,
|
||||
schema.sql:2483). Доступ только через `crm_admin_user` BYPASSRLS.
|
||||
Проверить: tenant-приложение к таблице не обращается.
|
||||
- [ ] `auth_log` — RLS включён (schema.sql:2810). Политика `tenant_isolation`.
|
||||
Проверить: поле `email` в строке `login_failed` — не утекает ли email
|
||||
несуществующего пользователя в посторонний тенант.
|
||||
- [ ] `import_log` — RLS включён (schema.sql:2790).
|
||||
|
||||
### Т2. Маскирование ПДн в дампах (pg_anonymizer #29)
|
||||
|
||||
- [ ] **Проверить вручную:** OPEN-И-24 (schema.sql:113) — «pg_anonymizer процедура,
|
||||
документация в Прил. И, без изменений схемы». Расширение ставится в фазе 3
|
||||
(db/CHANGELOG_schema.md:625). На момент аудита — **расширение может быть не
|
||||
установлено**. Выполнить: `psql -c "SELECT extname FROM pg_extension WHERE extname='anon';"`.
|
||||
- [ ] Если pg_anonymizer установлен: проверить наличие `SECURITY LABEL` /
|
||||
`anon.mask_column` на колонках `deals.phone`, `deals.contact_name`,
|
||||
`users.email`, `users.first_name`, `users.last_name`.
|
||||
- [ ] Если pg_anonymizer **не установлен**: дампы (`pg_dump`) содержат ПДн в открытом
|
||||
виде — критический риск перед продакшеном. Требуется: либо установить
|
||||
расширение и настроить маски, либо запретить дампы с ПДн вне зашифрованного
|
||||
хранилища.
|
||||
|
||||
### Т3. Утечки ПДн в логи и Sentry
|
||||
|
||||
- [ ] **Sentry PII-scrubbing** (OPEN-И-16, schema.sql:68): конфигурация в
|
||||
`app/config/sentry.php` (narrative §22 «Sentry PII-scrubbing»).
|
||||
Проверить: whitelist событий задан; regex-маска `phone`/`email`/`password`/
|
||||
`secret`/`token`/`api_key` включена. Тест: намеренно вызвать ошибку с
|
||||
телефоном в payload и проверить Sentry-событие.
|
||||
- [ ] **Laravel-логи (`storage/logs/`)**: нет ли `Log::info`/`Log::debug` с
|
||||
`$deal->phone`, `$lead->phone`, `request()->all()` в необработанном виде.
|
||||
Grep: `Log::` + `phone\|email\|contact_name` в `app/app/`.
|
||||
- [ ] **`activity_log.context`** (JSONB, schema.sql:1775): поле `context` журнала
|
||||
действий по сделкам. Проверить: не пишется ли туда `phone`/`contact_name`
|
||||
полностью (должны быть только ID и маскированные значения).
|
||||
- [ ] **`supplier_leads.raw_payload`** (JSONB, schema.sql:1966): хранит весь
|
||||
webhook-payload от поставщика, включая телефон. Это осознанное хранение
|
||||
(нужно для дебага/реконсайла). Проверить: доступ ограничен только
|
||||
`crm_supplier_worker` + `crm_admin_user`; не отдаётся в tenant API.
|
||||
- [ ] **`auth_log.email`** (schema.sql:1458): email попадает в лог при `login_failed`
|
||||
для неизвестного адреса. Проверить: колонка не индексируется publicly,
|
||||
доступна только под RLS tenant-политикой.
|
||||
|
||||
### Т4. Шифрование чувствительных полей в покое
|
||||
|
||||
- [ ] **`users.totp_secret`** (schema.sql:723): комментарий «ШИФРУЕТСЯ `Crypt::encrypt`».
|
||||
Проверить: в коде Laravel используется `Crypt::encrypt`/`decrypt`, не plain TEXT.
|
||||
Grep: `totp_secret` в моделях/сервисах — нет ли прямого assignment без encrypt.
|
||||
- [ ] **`tenants.webhook_token`** (schema.sql:628): хранится в открытом виде как
|
||||
уникальный токен. Допустимо (по дизайну — это API-ключ, не пароль), но
|
||||
проверить: не логируется ли при ротации (`webhook_token_rotated_at`).
|
||||
- [ ] **Encryption at rest (диск/облако)**: Yandex Cloud `ru-central1` — проверить,
|
||||
включено ли шифрование диска/объектного хранилища на уровне YC-консоли.
|
||||
Это вне кода, но обязательно для 152-ФЗ.
|
||||
|
||||
### Т5. CSV-экспорт лидов и signed URL
|
||||
|
||||
- [ ] **`report_jobs`** (schema.sql:2313): `file_path` = `s3://bucket/path/file.xlsx`.
|
||||
Триггер `trg_report_jobs_export_log` (schema.sql:3096) автоматически пишет
|
||||
запись в `pd_processing_log` при INSERT. Проверить: триггер активен в prod.
|
||||
SQL: `SELECT tgname, tgenabled FROM pg_trigger WHERE tgname = 'trg_report_jobs_export_log';`
|
||||
- [ ] **Signed URL TTL**: schema.sql:3182 — «доступ через signed URL TTL 1 ч».
|
||||
Проверить в коде: `Storage::temporaryUrl(...)` с `now()->addHour()`.
|
||||
Файлы экспорта не доступны без аутентификации.
|
||||
- [ ] **`report_jobs.expires_at`**: автоудаление файла. Проверить: есть ли
|
||||
scheduled command / cleanup job, удаляющий S3-файл и обнуляющий `file_path`
|
||||
после `expires_at`.
|
||||
|
||||
### Т6. CSV-импорт исторических лидов
|
||||
|
||||
- [ ] **`import_log.file_path`** (schema.sql:1544): путь к загруженному CSV-файлу с
|
||||
ПДн. Проверить: файл хранится во временном/приватном location, не в
|
||||
публично доступном URL; удаляется после обработки.
|
||||
- [ ] **Проверить вручную:** содержит ли исторический CSV телефоны лидов в открытом
|
||||
виде в `storage/`? Если да — нужен cleanup после импорта.
|
||||
|
||||
---
|
||||
|
||||
## Раздел 2 — Соответствие 152-ФЗ
|
||||
|
||||
### З1. Хранение ПДн на территории РФ (ст.18.1 152-ФЗ)
|
||||
|
||||
- [ ] Облако: Yandex Cloud, регион `ru-central1` (Москва) — **✅ РФ**.
|
||||
Подтверждено в CLAUDE.md §2.
|
||||
- [ ] S3-хранилище файлов экспорта (`report_jobs.file_path`): убедиться, что
|
||||
Yandex Object Storage используется (не AWS S3 / GCS). Проверить
|
||||
`app/config/filesystems.php`.
|
||||
- [ ] Self-hosted Sentry: Yandex Cloud `ru-central1` — ✅ РФ (CLAUDE.md §2).
|
||||
Проверить: Sentry не проксирует события в eu.sentry.io / sentry.io (US).
|
||||
- [ ] Unisender Go (email): **Проверить вручную** — уточнить у Unisender
|
||||
расположение серверов; письма с ПДн (email адреса) передаются провайдеру.
|
||||
|
||||
### З2. Согласия субъектов ПДн (ст.6, ст.9 152-ФЗ)
|
||||
|
||||
- [ ] **`tenant_consents`** (schema.sql:2430): таблица согласий. Проверить:
|
||||
при регистрации тенанта записывается `consent_type='pd_processing'` с
|
||||
`document_version`, `ip_address`, `user_agent`, `given_at`.
|
||||
- [ ] Проверить: согласие на обработку ПДн лидов (телефоны физлиц) — не пользователя-
|
||||
клиента, а лидов. Лиды приходят от поставщика (crm.bp-gr.ru) — проверить
|
||||
договор с поставщиком (правовое основание обработки ст.6 ч.1 п.5 или п.4).
|
||||
**Проверить вручную** — вне schema (юридический документ).
|
||||
- [ ] `consent_type` значения: `pd_processing`, `marketing`, `oferta_v1` — убедиться,
|
||||
что consent_type='pd_processing' обязателен при регистрации (нет bypass).
|
||||
|
||||
### З3. Сроки хранения и удаление (ст.21 152-ФЗ)
|
||||
|
||||
- [ ] **Soft-delete в `deals`** (schema.sql:1648 `deleted_at`): после soft-delete
|
||||
данные остаются. Проверить: есть ли политика retention (hard-delete или
|
||||
анонимизация `phone`/`contact_name` через N дней после `deleted_at`).
|
||||
**Проверить вручную:** scheduled command для hard-delete сделок.
|
||||
- [ ] **`users.deleted_at`** (schema.sql:751): комментарий «soft delete + анонимизация».
|
||||
Проверить в коде: при soft-delete пользователя анонимизируются ли
|
||||
`email`/`first_name`/`last_name`/`phone`? Grep: `UserObserver` / `UserService`
|
||||
метод delete/anonymize.
|
||||
- [ ] **Право на удаление** (ст.21): обращение типа `request_type='deletion'` в
|
||||
`pd_subject_requests`. Проверить: есть ли процедура исполнения (скрипт/ручной
|
||||
процесс) удаления ПДн конкретного субъекта по `subject_phone`/`subject_email`
|
||||
из `deals`, `supplier_leads`, `activity_log`.
|
||||
|
||||
### З4. Журнал обработки ПДн (ст.18.1 152-ФЗ)
|
||||
|
||||
- [ ] **`pd_processing_log`** (schema.sql:2449): таблица журнала. RLS включён
|
||||
(schema.sql:2806), политика `tenant_isolation` (schema.sql:2846).
|
||||
Проверить: `subject_type`, `action`, `purpose` заполняются при
|
||||
ключевых операциях (просмотр сделки, экспорт, удаление).
|
||||
- [ ] **Триггер экспорта** `trg_report_jobs_export_log` (schema.sql:3096): AFTER
|
||||
INSERT на `report_jobs` → INSERT `pd_processing_log` с `action='exported'`.
|
||||
Закрывает требование ст.18 (учёт трансграничной передачи / выгрузки).
|
||||
- [ ] **Append-only hash chain** (schema.sql:63): `log_hash BYTEA` + триггеры
|
||||
`BEFORE UPDATE/DELETE` с `RAISE EXCEPTION`. Проверить: цепочка целостна.
|
||||
SQL: `SELECT id, log_hash IS NULL AS broken FROM pd_processing_log ORDER BY id DESC LIMIT 10;`
|
||||
|
||||
### З5. Обращения субъектов ПДн (ст.14 152-ФЗ)
|
||||
|
||||
- [ ] **`pd_subject_requests`** (schema.sql:2491): таблица обращений. Поля:
|
||||
`subject_email`, `subject_phone`, `subject_full_name`, `request_type`
|
||||
(`access`/`rectification`/`deletion`/`objection`), `deadline_at` (30 дней),
|
||||
`processing_restricted`.
|
||||
- [ ] **Триггер дедлайна** `trg_pd_subject_requests_deadline` (schema.sql:3165):
|
||||
функция `set_pd_subject_request_deadline()` заполняет `deadline_at =
|
||||
received_at + INTERVAL '30 days'` при INSERT/UPDATE.
|
||||
Проверить: `SELECT COUNT(*) FROM pd_subject_requests WHERE deadline_at IS NULL;`
|
||||
— должно быть 0.
|
||||
- [ ] **`processing_restricted`** (schema.sql:2514, ст.21 ч.5): при `TRUE`
|
||||
`ProcessingRestrictedException` блокирует операции с ПДн субъекта.
|
||||
Проверить в коде: `ProcessingRestrictionGuard` вызывается в сервисах
|
||||
перед mutable-операциями с `deals`/`users`.
|
||||
- [ ] Индекс (schema.sql:2519): `idx_pd_requests_restricted` — эффективный поиск
|
||||
активных ограничений. Проверить: он используется в `ProcessingRestrictionGuard`.
|
||||
|
||||
### З6. Уведомление РКН и реестр обработки (ст.22 152-ФЗ)
|
||||
|
||||
- [ ] **Проверить вручную:** подана ли заявка оператора в реестр Роскомнадзора
|
||||
на сайте pd.rkn.gov.ru? Это организационная мера, вне кода.
|
||||
- [ ] **Проверить вручную:** составлен ли внутренний реестр обработки ПДн
|
||||
(перечень категорий субъектов, целей, сроков, мер защиты)?
|
||||
Требование ст.22.1 ФЗ-152.
|
||||
- [ ] **`incidents_log`** (schema.sql:2535): при утечке ПДн — поле
|
||||
`related_pd_subject_request_ids BIGINT[]`. Проверить: есть ли внутренняя
|
||||
процедура уведомления РКН в течение 24 ч (ст.21.1, с 01.03.2023)?
|
||||
|
||||
### З7. Передача ПДн третьим лицам
|
||||
|
||||
- [ ] **Поставщик crm.bp-gr.ru**: получает запросы с телефонами лидов обратно
|
||||
при синхронизации статусов (`supplier_sync_log`). Проверить наличие договора
|
||||
на обработку ПДн по поручению (ст.6 ч.3 152-ФЗ).
|
||||
**Проверить вручную** — юридический документ.
|
||||
- [ ] **Unisender Go** (email-рассылки с именами пользователей):
|
||||
**Проверить вручную** — договор поручения на обработку ПДн.
|
||||
- [ ] **JivoSite** (helpdesk): передаются ли туда email/ФИО клиентов?
|
||||
**Проверить вручную**.
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
name: security-go-live
|
||||
description: Единый go-live security-gate Лидерры перед публикацией в интернете — один воспроизводимый прогон всех проверок безопасности и вердикт «можно/нельзя в прод». Оркеструет ZAP (#68), Nuclei (#69), Ward (#70), pdn-152fz-audit (#71), threat-model (#72) + Semgrep #25 / Trivy #26 / gitleaks #8 / Trail of Bits #39. Используй при «прогон безопасности перед релизом», «можно ли выкатывать», «go-live security check», «финальная проверка безопасности». НЕ для полного 14-фазного аудита портала (audit-portal), отдельной проверки ПДн (pdn-152fz-audit #71) или угроз (threat-model #72).
|
||||
---
|
||||
|
||||
# Security Go-Live — единый gate безопасности перед публикацией
|
||||
|
||||
Проектный скил раздела A8 карты «Информационная безопасность». Запускает
|
||||
**один воспроизводимый прогон всех security-проверок** и выдаёт вердикт
|
||||
**GO / NO-GO** перед тем, как портал Лидерры становится доступным из интернета.
|
||||
|
||||
## Когда использовать
|
||||
|
||||
- «Прогони все проверки безопасности перед релизом»
|
||||
- «Можно ли выкатывать портал в прод по безопасности?»
|
||||
- «Go-live security check» / «финальная проверка безопасности»
|
||||
- «Готов ли портал к публикации со стороны ИБ?»
|
||||
|
||||
## Что это и чем НЕ является
|
||||
|
||||
**Это:** операционный gate — воспроизводимый чек-лист, который прогоняется
|
||||
каждый раз перед go-live и выдаёт конкретный вердикт с перечнем блокеров.
|
||||
|
||||
**Это НЕ:**
|
||||
|
||||
- ≠ `audit-portal` — тот 14-фазный сквозной аудит качества всего портала
|
||||
(статанализ, тесты, схема БД, UI-smoke, a11y, coverage, bundle и пр.);
|
||||
security-go-live — security-only срез, занимает часть дня, не несколько дней.
|
||||
- ≠ `pdn-152fz-audit` #71 — тот глубокий аудит персональных данных и 152-ФЗ;
|
||||
security-go-live вызывает его как один шаг, не заменяет.
|
||||
- ≠ `threat-model` #72 — тот строит модель угроз как документ (STRIDE, карта
|
||||
точек входа); security-go-live проверяет, что выявленные угрозы ЗАКРЫТЫ.
|
||||
|
||||
## Порядок прогона
|
||||
|
||||
Полная процедура — `references/gate.md`. Кратко:
|
||||
|
||||
1. **Статика** — gitleaks, Semgrep, Ward (config/env/deps/code), Trail of Bits.
|
||||
2. **ПДн / 152-ФЗ** — вызвать `pdn-152fz-audit` #71.
|
||||
3. **Угрозы** — вызвать `threat-model` #72, убедиться что топ-угрозы закрыты.
|
||||
4. **Динамика (локальная цель по умолчанию)** — Nuclei (`bin/nuclei.exe`),
|
||||
затем ZAP (spider + active scan). Боевой сервер — только по явной команде.
|
||||
5. **Вердикт** — GO / NO-GO с явным списком блокеров.
|
||||
|
||||
## Выход
|
||||
|
||||
```
|
||||
=== SECURITY GO-LIVE REPORT ===
|
||||
Дата: YYYY-MM-DD
|
||||
Версия схемы: <schema-version>
|
||||
Commit: <HEAD>
|
||||
|
||||
[ШАГИ 1-4 — результаты по каждому инструменту]
|
||||
|
||||
=== ВЕРДИКТ: GO ✅ / NO-GO ❌ ===
|
||||
Блокеры (critical/high): <список или "нет">
|
||||
Предупреждения (medium): <список или "нет">
|
||||
=== END ===
|
||||
```
|
||||
|
||||
## Связано
|
||||
|
||||
- `references/gate.md` — подробная процедура прогона + формат вердикта.
|
||||
- `pdn-152fz-audit` #71, `threat-model` #72 — вызываются как подшаги.
|
||||
- ZAP #68 (OWASP, DAST), Nuclei #69 (CLI `bin/nuclei.exe`), Ward #70 (Go CLI).
|
||||
- gitleaks #8, Semgrep #25, Trivy #26, Trail of Bits #39 — статика.
|
||||
- ADR-013 (infosec-tooling A8), `docs/security/nuclei-setup.md`,
|
||||
`docs/security/infosec-vet.md`.
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"skill": "security-go-live",
|
||||
"cases": [
|
||||
{"prompt": "прогони все проверки безопасности перед релизом", "should_trigger": true},
|
||||
{"prompt": "можно ли выкатывать портал в прод по безопасности", "should_trigger": true},
|
||||
{"prompt": "проведи полный аудит портала", "should_trigger": false, "expected": "audit-portal"},
|
||||
{"prompt": "проверь только персональные данные", "should_trigger": false, "expected": "pdn-152fz-audit"},
|
||||
{"prompt": "смоделируй угрозы", "should_trigger": false, "expected": "threat-model"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
# Security Go-Live Gate — процедура прогона и формат вердикта
|
||||
|
||||
Подробная пошаговая процедура для скила `security-go-live` (#73).
|
||||
Цель — один воспроизводимый прогон перед каждым выходом портала в интернет.
|
||||
|
||||
---
|
||||
|
||||
## Гарды
|
||||
|
||||
**IS8 — цель по умолчанию локальная.** Все динамические проверки (Nuclei, ZAP)
|
||||
направляются на локальную или тестовую копию портала (`127.0.0.1`). Боевой
|
||||
(`crm.bp-gr.ru` или любой публичный IP) — только по явной команде заказчика:
|
||||
«сканируй прод» / «сканируй боевой».
|
||||
|
||||
**IS7 — граница с `audit-portal`.** `security-go-live` — security-only gate:
|
||||
выдаёт GO/NO-GO по безопасности. Он не заменяет 14-фазный `audit-portal`
|
||||
(тесты, схема, UI-smoke, a11y, coverage, bundle и пр.). Перед первым
|
||||
production-деплоем рекомендуется прогнать `audit-portal` **и** `security-go-live`
|
||||
как два отдельных прогона; при плановых go-live (хотфикс/фича) — достаточно
|
||||
`security-go-live`.
|
||||
|
||||
---
|
||||
|
||||
## Шаг 1 — Статика (static analysis)
|
||||
|
||||
Запустить последовательно. Каждый инструмент фиксирует результат в разделе
|
||||
отчёта.
|
||||
|
||||
### 1.1 gitleaks — поиск секретов в истории
|
||||
|
||||
```powershell
|
||||
# Полная история
|
||||
.\bin\gitleaks.exe detect --source . --log-opts "--all"
|
||||
# Только staged/unstaged (перед коммитом)
|
||||
.\bin\gitleaks.exe protect --staged
|
||||
```
|
||||
|
||||
Ожидаемо: **0 утечек**. Любой leak = NO-GO (critical).
|
||||
|
||||
### 1.2 Semgrep — статический анализ кода
|
||||
|
||||
```powershell
|
||||
npm run sast
|
||||
```
|
||||
|
||||
Ожидаемо: **0 critical/high**. Medium — предупреждение (не блокер).
|
||||
|
||||
### 1.3 Ward — Laravel config / env / deps / code
|
||||
|
||||
Ward (#70) — Go-бинарь, замена заброшенного Enlightn. Сканирует:
|
||||
`.env` (8 проверок), `config/*.php` (13 проверок), зависимости Composer
|
||||
(через OSV.dev), код (секреты, injection, XSS, debug-артефакты, crypto,
|
||||
CORS/CSRF/mass-assignment, auth).
|
||||
|
||||
```powershell
|
||||
# Если Ward установлен (pending — нет тегов-релизов, pin по commit SHA)
|
||||
.\bin\ward.exe scan --path app/
|
||||
```
|
||||
|
||||
Если Ward **не установлен** (pending `docs/security/ward-setup.md`) — отметить
|
||||
в отчёте как `PENDING` и продолжить. Ward — не блокер установки gate,
|
||||
но должен быть установлен до первого реального go-live.
|
||||
|
||||
Ожидаемо: **0 critical**. High — разобрать вручную. Ошибки конфигурации
|
||||
(APP_DEBUG=true, слабые ключи, открытые CORS) = NO-GO если critical.
|
||||
|
||||
### 1.4 Trail of Bits — глубокий on-demand аудит (#39)
|
||||
|
||||
Вызывается вручную перед первым публичным релизом или при значительных
|
||||
изменениях security-периметра. Не требуется при каждом хотфиксе.
|
||||
|
||||
```
|
||||
/differential-review:diff-review # если ревьюим конкретный diff
|
||||
/audit-context-building:audit-context # для supply-chain аудита
|
||||
```
|
||||
|
||||
Результаты фиксируются в `docs/security/trail-of-bits-YYYY-MM-DD.md`.
|
||||
|
||||
---
|
||||
|
||||
## Шаг 2 — ПДн / 152-ФЗ
|
||||
|
||||
Вызвать скил `pdn-152fz-audit` (#71).
|
||||
|
||||
```
|
||||
/pdn-152fz-audit
|
||||
```
|
||||
|
||||
Прогнать оба режима:
|
||||
|
||||
- **Режим 1 (технический):** RLS на таблицах ПДн, маскирование pg_anonymizer,
|
||||
отсутствие phone/email в логах, pg_anonymizer в дампах.
|
||||
- **Режим 2 (соответствие 152-ФЗ):** хранение в РФ, согласия, права субъекта
|
||||
(`pd_subject_requests`), журнал обработки (`pd_processing_log`), уведомление РКН.
|
||||
|
||||
Итог: список нарушений (если есть). Нарушения Режима 1 уровня critical (ПДн
|
||||
в открытых логах/Sentry) = NO-GO.
|
||||
|
||||
---
|
||||
|
||||
## Шаг 3 — Угрозы (threat model)
|
||||
|
||||
Вызвать скил `threat-model` (#72) или открыть последний файл
|
||||
`docs/security/threat-model-YYYY-MM-DD.md`.
|
||||
|
||||
Цель: убедиться, что **топ-приоритетные угрозы из STRIDE** закрыты контрмерами
|
||||
(rate-limit на login, HMAC на webhook, Sanctum token-auth, CSRF, RLS).
|
||||
|
||||
Если актуальная модель угроз отсутствует (нет файла за последние 30 дней) —
|
||||
запустить `threat-model` перед динамикой.
|
||||
|
||||
---
|
||||
|
||||
## Шаг 4 — Динамика (dynamic analysis, локальная цель)
|
||||
|
||||
> **IS8:** по умолчанию цель — локальная копия. Убедиться, что приложение
|
||||
> запущено: `php artisan serve` → `http://127.0.0.1:8000`.
|
||||
|
||||
### 4.1 Nuclei — широкое сканирование (#69)
|
||||
|
||||
Nuclei установлен как CLI-бинарь `bin/nuclei.exe` (MIT, projectdiscovery,
|
||||
v3.8.0). **Не MCP-сервер.**
|
||||
|
||||
**Квирки native-Windows (обязательно соблюдать):**
|
||||
|
||||
1. **Цель — `127.0.0.1`, НЕ `localhost`.** Резолвер Nuclei не разрешает
|
||||
`localhost` на этой машине — цель будет пропущена (квирк зафиксирован в
|
||||
`docs/security/nuclei-setup.md`).
|
||||
2. **Низкий rate-limit для dev-сервера.** `php artisan serve` однопоточный;
|
||||
без ограничений Nuclei перегружает его ложными connection-ошибками.
|
||||
Всегда использовать `-rate-limit 20 -c 5`.
|
||||
|
||||
```powershell
|
||||
# Стандартный прогон (medium+)
|
||||
bin\nuclei.exe -u "http://127.0.0.1:8000" `
|
||||
-rate-limit 20 -c 5 -timeout 5 -duc `
|
||||
-severity medium,high,critical
|
||||
|
||||
# Только технологический стек (быстрый smoke)
|
||||
bin\nuclei.exe -u "http://127.0.0.1:8000" -tags tech `
|
||||
-rate-limit 20 -c 5 -timeout 5 -duc
|
||||
```
|
||||
|
||||
Если `bin/nuclei.exe` отсутствует — отметить `PENDING` и продолжить.
|
||||
Детали установки: `docs/security/nuclei-setup.md`.
|
||||
|
||||
Ожидаемо: **0 critical/high**. Medium — разобрать вручную.
|
||||
|
||||
### 4.2 ZAP — глубокое DAST (#68)
|
||||
|
||||
ZAP (#68) — официальный MCP add-on (`zaproxy/zap-extensions`, Apache-2.0),
|
||||
alpha v0.1.0. Требует Java 17+ и запущенного ZAP-демона.
|
||||
|
||||
Если ZAP **не установлен** (pending Java) — отметить `PENDING` и продолжить.
|
||||
Детали: `docs/security/zap-setup.md` (когда будет создан).
|
||||
|
||||
```
|
||||
# Через ZAP MCP (когда ZAP установлен)
|
||||
# 1. Запустить ZAP-демон: zaproxy -daemon -port 8080 -config api.key=<key>
|
||||
# 2. Spider
|
||||
ZapStartSpiderTool(url="http://127.0.0.1:8000", contextId=...)
|
||||
# 3. Active scan
|
||||
ZapStartActiveScanTool(url="http://127.0.0.1:8000", contextId=...)
|
||||
# 4. Отчёт
|
||||
ZapGenerateReportTool(...)
|
||||
```
|
||||
|
||||
Ожидаемо: **0 critical/high**. Medium — разобрать вручную.
|
||||
Critical/high из ZAP active scan = NO-GO.
|
||||
|
||||
---
|
||||
|
||||
## Шаг 5 — Сбор находок и вердикт
|
||||
|
||||
### Severity → статус
|
||||
|
||||
| Severity | Источник | Статус gate |
|
||||
|---|---|---|
|
||||
| critical | любой инструмент | **NO-GO** (блокер) |
|
||||
| high | любой инструмент | **NO-GO** (блокер) |
|
||||
| medium | любой инструмент | Предупреждение (не блокирует go-live, фиксируется) |
|
||||
| low / info | любой инструмент | Информационно |
|
||||
| PENDING | ZAP / Ward / Nuclei не установлены | Условный GO — инструменты должны быть установлены до публичного деплоя |
|
||||
|
||||
### Формат отчёта
|
||||
|
||||
```
|
||||
=== SECURITY GO-LIVE REPORT ===
|
||||
Дата: YYYY-MM-DD
|
||||
Версия схемы: vX.XX
|
||||
Commit: <git rev-parse HEAD>
|
||||
Цель: http://127.0.0.1:<port> (локальная копия)
|
||||
|
||||
--- ШАГ 1: СТАТИКА ---
|
||||
gitleaks: OK (0 утечек) / FAIL (<N> утечек)
|
||||
Semgrep: OK (0 critical/high) / FAIL (<список>)
|
||||
Ward: OK / FAIL (<список>) / PENDING (не установлен)
|
||||
Trail of Bits: OK / SKIP (не применимо к этому прогону)
|
||||
|
||||
--- ШАГ 2: ПДн / 152-ФЗ ---
|
||||
pdn-152fz-audit Режим 1: OK / FAIL (<список>)
|
||||
pdn-152fz-audit Режим 2: OK / ПРЕДУПРЕЖДЕНИЯ (<список>)
|
||||
|
||||
--- ШАГ 3: УГРОЗЫ ---
|
||||
threat-model: ЗАКРЫТЫ (файл docs/security/threat-model-YYYY-MM-DD.md)
|
||||
Незакрытые топ-угрозы: <список или "нет">
|
||||
|
||||
--- ШАГ 4: ДИНАМИКА ---
|
||||
Nuclei: OK (0 critical/high) / FAIL (<список>) / PENDING (не установлен)
|
||||
ZAP: OK (0 critical/high) / FAIL (<список>) / PENDING (не установлен)
|
||||
|
||||
=== ВЕРДИКТ: GO ✅ / NO-GO ❌ ===
|
||||
Блокеры (critical/high):
|
||||
- <инструмент>: <описание> — <рекомендация>
|
||||
(или "Блокеров нет")
|
||||
|
||||
Предупреждения (medium):
|
||||
- <инструмент>: <описание>
|
||||
(или "Предупреждений нет")
|
||||
|
||||
PENDING-инструменты (должны быть закрыты до публичного деплоя):
|
||||
- Ward #70: установка — docs/security/ward-setup.md
|
||||
- ZAP #68: установка — docs/security/zap-setup.md (pending Java)
|
||||
(или "Все инструменты установлены")
|
||||
=== END ===
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Типичные блокеры и действия
|
||||
|
||||
| Находка | Источник | Действие |
|
||||
|---|---|---|
|
||||
| APP\_DEBUG=true | Ward / Semgrep | Исправить `.env` перед деплоем |
|
||||
| Секрет в git-истории | gitleaks | Rotate + `git filter-repo`; НЕ деплоить |
|
||||
| ПДн в логах Laravel | pdn-152fz-audit | Убрать из LogChannel + Sentry scrubbing |
|
||||
| CSRF отключён | Ward | Проверить `VerifyCsrfToken` middleware |
|
||||
| Слабый APP\_KEY | Ward | `php artisan key:generate` |
|
||||
| Критическая CVE в зависимости | Semgrep / Ward | `composer update` или `npm update` |
|
||||
| SQL injection / XSS | ZAP / Nuclei | Исправить код, перепрогнать |
|
||||
| Незакрытая STRIDE-угроза | threat-model | Реализовать контрмеру или принять риск с заказчиком |
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
name: threat-model
|
||||
description: Моделирование угроз портала Лидерра по STRIDE — карта точек входа, что меняется при выходе в интернет, приоритизация защиты. Используй при «смоделируй угрозы», «откуда могут атаковать», «что защищать в первую очередь перед публикацией», «карта точек входа», «threat model / STRIDE». НЕ для аудита ПДн/152-ФЗ (pdn-152fz-audit #71), статического security-аудита кода (D3/Semgrep/Trail of Bits), generic архитектурных паттернов (architecture-patterns), go-live прогона (security-go-live #73).
|
||||
---
|
||||
|
||||
# Threat Model — моделирование угроз портала Лидерра
|
||||
|
||||
Проектный скил раздела A8 карты «Информационная безопасность». Применяет методологию
|
||||
**STRIDE** к реальным точкам входа портала и отвечает на главный вопрос перед
|
||||
публикацией: **что именно меняется, когда в систему может зайти любой из интернета**.
|
||||
|
||||
## Когда использовать
|
||||
|
||||
- «Смоделируй угрозы» / «откуда могут атаковать» / «что защищать в первую очередь»
|
||||
- Подготовка к go-live — составление модели угроз как артефакта (отдельно от
|
||||
чек-листа запуска, который — в `security-go-live #73`)
|
||||
- Анализ конкретного эндпоинта: «насколько опасен открытый `/api/webhook/{token}`?»
|
||||
- Ответ на вопрос заказчика / регулятора «покажи модель угроз»
|
||||
|
||||
## Процедура STRIDE для Лидерры
|
||||
|
||||
Полный разбор точек входа и таблица угроз — `references/stride-portal.md`.
|
||||
|
||||
### Шаги
|
||||
|
||||
1. **Определить периметр** — что сейчас открыто наружу vs что будет открыто после
|
||||
публикации. Основа: список точек входа в `references/stride-portal.md`.
|
||||
2. **Пройти по STRIDE для каждой точки** — заполнить 6 строк (S/T/R/I/D/E).
|
||||
Опираться на таблицу в `references/stride-portal.md`; при новых эндпоинтах
|
||||
добавлять строки по тому же шаблону.
|
||||
3. **Оценить вероятность × ущерб** — приоритизировать по матрице из `references/stride-portal.md`.
|
||||
4. **Сформировать список контрмер** — что уже есть (RLS, HMAC, Sanctum, rate-limit),
|
||||
чего не хватает (rate-limit на login, WAF, 2FA enforcement, и т.д.).
|
||||
5. **Сохранить результат** в `docs/security/threat-model-YYYY-MM-DD.md`.
|
||||
|
||||
## Выход
|
||||
|
||||
Файл `docs/security/threat-model-<дата>.md` со структурой:
|
||||
|
||||
- Область действия (дата, версия схемы, commit)
|
||||
- Карта точек входа (таблица)
|
||||
- STRIDE по каждой точке
|
||||
- Дельта «был закрытый круг → стал интернет»
|
||||
- Приоритизированный список рисков с контрмерами
|
||||
|
||||
## Границы
|
||||
|
||||
- ≠ `pdn-152fz-audit` #71 — тот про *персональные данные и 152-ФЗ* (конкретные
|
||||
таблицы, согласия, права субъекта); threat-model про *вектора атак и защиту
|
||||
эндпоинтов*.
|
||||
- ≠ D3 audit-security (#39/#40 Trail of Bits / Semgrep) — те про *статический
|
||||
анализ кода на уязвимости*; threat-model про *архитектурную карту угроз*.
|
||||
- ≠ `architecture-patterns` #38 — тот generic-паттерны; threat-model — конкретный
|
||||
портал, конкретные маршруты.
|
||||
- ≠ `security-go-live` #73 — тот *прогоняет конкретный чек-лист* перед релизом
|
||||
(Nmap, заголовки, CVE, gitleaks, DAST); threat-model *строит модель угроз как
|
||||
документ* (вход для чек-листа и приоритизации работ).
|
||||
|
||||
## Связано
|
||||
|
||||
- `references/stride-portal.md` — детальная карта точек входа и STRIDE-таблица.
|
||||
- `pdn-152fz-audit` #71 — смежный аудит ПДн; часто запускается вместе с threat-model.
|
||||
- `security-go-live` #73 — операционный прогон после threat-model завершён.
|
||||
- D3 / Semgrep #25 / Trail of Bits #39 — статический анализ; дополняет threat-model
|
||||
на уровне кода.
|
||||
- ADR-013 (infosec-tooling A8).
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"skill": "threat-model",
|
||||
"cases": [
|
||||
{"prompt": "смоделируй угрозы при выходе портала в интернет", "should_trigger": true},
|
||||
{"prompt": "что защищать в первую очередь перед публикацией", "should_trigger": true},
|
||||
{"prompt": "откуда могут атаковать портал", "should_trigger": true},
|
||||
{"prompt": "составь карту точек входа", "should_trigger": true},
|
||||
{"prompt": "сделай threat model по STRIDE", "should_trigger": true},
|
||||
{"prompt": "проверь соответствие 152-ФЗ", "should_trigger": false, "expected": "pdn-152fz-audit"},
|
||||
{"prompt": "прогони все проверки безопасности перед релизом", "should_trigger": false, "expected": "security-go-live"},
|
||||
{"prompt": "просканируй код на уязвимости семгрепом", "should_trigger": false, "expected": "D3/Semgrep"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
# STRIDE — карта угроз портала Лидерра
|
||||
|
||||
Основан на реальных маршрутах `app/routes/web.php` (v8.26, 21.05.2026).
|
||||
Стек: Laravel 13 + Vue 3 + PostgreSQL 16 RLS + Redis, Yandex Cloud `ru-central1`.
|
||||
|
||||
---
|
||||
|
||||
## Карта точек входа
|
||||
|
||||
| # | Точка входа | Маршрут(ы) | Аутентификация |
|
||||
|---|---|---|---|
|
||||
| E1 | Вход / регистрация | `POST /api/auth/login`, `POST /api/auth/register` | Публичный |
|
||||
| E2 | 2FA и коды восстановления | `POST /api/auth/2fa/verify`, `POST /api/auth/2fa/recovery-use` | Публичный (pending-session) |
|
||||
| E3 | Сброс пароля | `POST /api/auth/forgot`, `POST /api/auth/reset-password` | Публичный |
|
||||
| E4 | Входящий webhook поставщика | `POST /api/webhook/supplier/{secret}` | URL-secret + IP-allowlist |
|
||||
| E5 | Входящий webhook тенанта | `POST /api/webhook/{token}` | URL-token + (prod: HMAC X-Webhook-Signature + rate-limit) |
|
||||
| E6 | API сделок | `GET/POST/PATCH/DELETE /api/deals`, `/api/deals/export`, `/api/deals/transition`, `/api/deals/restore` | Sanctum SPA + tenant |
|
||||
| E7 | API проектов | `GET/POST/PATCH/DELETE /api/projects/{id}`, `/api/projects/bulk`, `/api/projects/{id}/sync` | Sanctum SPA + tenant |
|
||||
| E8 | API импорта CSV | `POST /api/imports`, `GET /api/imports/{importLog}`, `/api/imports/unknown-statuses` | Sanctum SPA + tenant |
|
||||
| E9 | Lookup-эндпоинты | `GET /api/managers`, `GET /api/lead-statuses` | **Без auth** (открытые) |
|
||||
| E10 | Биллинг тенанта | `POST /api/billing/topup`, `GET /api/billing/wallet`, `/transactions`, `/invoices` | Sanctum SPA + tenant |
|
||||
| E11 | Charges ledger | `GET /api/billing/charges`, `POST /api/billing/charges/export` | Sanctum SPA + tenant |
|
||||
| E12 | API-ключи тенанта | `GET /api/api-keys`, `POST /api/api-keys/regenerate` | Sanctum SPA + tenant |
|
||||
| E13 | Webhook-настройки тенанта | `GET/PUT /api/tenants/me/webhook-settings`, `POST /api/webhooks/test` | Sanctum SPA + tenant |
|
||||
| E14 | Напоминания | `GET/POST/PATCH/DELETE /api/reminders/{id}` | Sanctum SPA + tenant |
|
||||
| E15 | Уведомления | `GET/PATCH/POST/DELETE /api/notifications/{id}` | Sanctum SPA + tenant |
|
||||
| E16 | Отчёты | `GET/POST/DELETE /api/reports/jobs/{id}`, `POST /{id}/retry`, `POST /{id}/cancel` | Sanctum SPA + tenant |
|
||||
| E17 | Скачивание отчёта | `GET /api/reports/jobs/{id}/file` | Signed URL (без Sanctum) |
|
||||
| E18 | Дашборд | `GET /api/dashboard/summary` | **Без auth** (MVP-заглушка) |
|
||||
| E19 | Профиль / уведомления-настройки | `GET/PATCH /api/auth/me`, `PATCH /api/auth/me/notification-preferences` | Sanctum SPA |
|
||||
| E20 | SaaS-admin: тенанты, биллинг, инциденты, система | `GET/PATCH /api/admin/**` | `saas-admin` middleware |
|
||||
| E21 | SaaS-admin: импersonation | `POST /api/admin/impersonation/init`, `/verify`, `/end` | `saas-admin` middleware |
|
||||
| E22 | SaaS-admin: supplier-integration | `GET/POST /api/admin/supplier-integration/**` | `saas-admin` middleware |
|
||||
| E23 | 2FA setup (авторизованный) | `POST /api/2fa/init`, `/confirm`, `/disable`, `/regenerate-recovery-codes` | Sanctum SPA |
|
||||
| E24 | SPA-оболочка | `GET /`, `/login`, `/register`, `/deals`, … (20+ маршрутов) | Без auth (Vue shell) |
|
||||
|
||||
---
|
||||
|
||||
## Дельта «закрытый круг → интернет»
|
||||
|
||||
До публикации портал доступен только команде (VPN или фиксированные IP).
|
||||
После публикации **любой актор из интернета** может обратиться к каждому публичному
|
||||
эндпоинту. Критические изменения:
|
||||
|
||||
| Изменение | Затронутые точки | Почему важно |
|
||||
|---|---|---|
|
||||
| Брутфорс и credential stuffing | E1 (login) | Нет rate-limit на `/api/auth/login` (на момент анализа) |
|
||||
| Энумерация пользователей | E1, E3 | Разные ответы на «существующий / несуществующий email» создают oracle |
|
||||
| Replay и forgery webhook | E4, E5 | Secret в URL виден в логах прокси/nginx; HMAC на E5 — «prod» (не в dev) |
|
||||
| Открытые lookup-эндпоинты | E9 | `GET /api/managers`, `GET /api/lead-statuses` без auth — раскрывают ФИО менеджеров |
|
||||
| Открытый дашборд | E18 | `GET /api/dashboard/summary` без auth — раскрывает KPI текущего тенанта |
|
||||
| DoS на artisan-сервере | Все | `php artisan serve` не держит нагрузку; нужен nginx/Octane |
|
||||
| SSRF через webhook-test | E13 | `POST /api/webhooks/test` отправляет запрос на URL из тела — риск SSRF во внутреннюю сеть YC |
|
||||
| Impersonation без prod-auth | E21 | `saas-admin` middleware в dev-режиме пропускает без проверки (`SAAS_ADMIN_TEST_BYPASS`) |
|
||||
| Signed URL без срока инвалидации | E17 | Отчёт с ПДн доступен 24 ч по ссылке без повторной аутентификации |
|
||||
|
||||
---
|
||||
|
||||
## STRIDE по точкам входа
|
||||
|
||||
### E1 — Вход / Регистрация (`POST /api/auth/login`, `POST /api/auth/register`)
|
||||
|
||||
| Угроза | Описание | Текущий контроль | Пробел |
|
||||
|---|---|---|---|
|
||||
| **S** Spoofing | Брутфорс пароля, credential stuffing | Bcrypt-хеш пароля | Нет rate-limit на login |
|
||||
| **T** Tampering | Подмена `tenant_id` в теле запроса | `tenant_id` берётся из `auth()->user()`, не из тела | — |
|
||||
| **R** Repudiation | Отрицание входа | `auth_log` пишет login/logout | Нет IP + User-Agent в каждой записи |
|
||||
| **I** Info disclosure | Энумерация email через разные ответы | Unified-ответ на forgot (E3) | Login может раскрывать «нет такого пользователя» |
|
||||
| **D** DoS | Флуд регистраций, засорение БД | — | Нет captcha / email-верификации на register |
|
||||
| **E** Elevation | Регистрация с `is_admin=true` в теле | Mass-assignment guard (fillable) | Проверить `$fillable` в `User` — нет ли `role` |
|
||||
|
||||
### E2 — 2FA (`POST /api/auth/2fa/verify`, `POST /api/auth/2fa/recovery-use`)
|
||||
|
||||
| Угроза | Описание | Текущий контроль | Пробел |
|
||||
|---|---|---|---|
|
||||
| **S** Spoofing | Брутфорс 6-значного TOTP | TOTP 30-сек окно | Нет rate-limit на `/2fa/verify` |
|
||||
| **T** Tampering | Подмена `pending_user_id` в session | Серверная session | Проверить изоляцию session между тенантами |
|
||||
| **R** Repudiation | Использование кода восстановления | `auth_log` | Фиксируется ли `recovery_used` событие? |
|
||||
| **I** Info disclosure | Тайминг-атака на сравнение TOTP | TOTP библиотека (constant-time?) | Проверить реализацию `verifyTwoFactor` |
|
||||
| **D** DoS | Флуд на `/2fa/verify` истощает session-store | — | Нет rate-limit |
|
||||
| **E** Elevation | Обход 2FA через `recovery-use` | Коды — одноразовые, хранятся hashed | Если коды в открытом виде — критично |
|
||||
|
||||
### E3 — Сброс пароля (`POST /api/auth/forgot`, `POST /api/auth/reset-password`)
|
||||
|
||||
| Угроза | Описание | Текущий контроль | Пробел |
|
||||
|---|---|---|---|
|
||||
| **S** Spoofing | Захват аккаунта через сброс пароля чужого email | Токен по email | Нет rate-limit на `/forgot` |
|
||||
| **T** Tampering | Подмена токена сброса | Cryptographic token (Laravel default) | Проверить срок жизни токена (1 ч?) |
|
||||
| **R** Repudiation | — | — | — |
|
||||
| **I** Info disclosure | Энумерация email через тайминг ответа | Unified-ответ задокументирован в роутах | Проверить фактическую реализацию ответа |
|
||||
| **D** DoS | Флуд `/forgot` → очередь email | — | Нет rate-limit → перегрузка Unisender Go |
|
||||
| **E** Elevation | — | — | — |
|
||||
|
||||
### E4 — Webhook поставщика (`POST /api/webhook/supplier/{secret}`)
|
||||
|
||||
| Угроза | Описание | Текущий контроль | Пробел |
|
||||
|---|---|---|---|
|
||||
| **S** Spoofing | Подделка запроса от crm.bp-gr.ru | URL-secret + IP allowlist (`system_settings.supplier_ip_allowlist`) | Secret виден в логах nginx/прокси |
|
||||
| **T** Tampering | Подмена payload (телефон, стоимость лида) | — | Нет HMAC на тело; только secret в URL |
|
||||
| **R** Repudiation | Отрицание доставки лида | `supplier_leads.raw_payload` | Нет timestamp-подписи для доказательства |
|
||||
| **I** Info disclosure | Secret в URL → в access-логах сервера | IP allowlist сужает круг | Ротация secret при компрометации? |
|
||||
| **D** DoS | Флуд поддельных лидов → списание баланса | IP allowlist | Если allowlist обходится (SSRF) |
|
||||
| **E** Elevation | Подмена `tenant_id` в payload | Берётся из `system_settings` глобально | Архитектурно корректно; проверить lookup |
|
||||
|
||||
### E5 — Webhook тенанта (`POST /api/webhook/{token}`)
|
||||
|
||||
| Угроза | Описание | Текущий контроль | Пробел |
|
||||
|---|---|---|---|
|
||||
| **S** Spoofing | Запрос от неавторизованного источника | URL-token из `tenants.webhook_token`; HMAC X-Webhook-Signature (prod) | HMAC только в prod; dev уязвим |
|
||||
| **T** Tampering | Изменение payload в transit | HMAC-валидация (prod) | В dev отключена — нельзя тестировать на prod-данных |
|
||||
| **R** Repudiation | — | `supplier_leads.raw_payload` | — |
|
||||
| **I** Info disclosure | Token в URL виден в логах | Per-token rate-limit | Нет ротации token при смене API-ключа |
|
||||
| **D** DoS | Replay flood | Per-token rate-limit (prod) | Нет в dev |
|
||||
| **E** Elevation | Лид с завышенной ценой | Стоимость берётся из `PricingTierResolver`, не из payload | Архитектурно защищено |
|
||||
|
||||
### E9 — Открытые lookup-эндпоинты (`GET /api/managers`, `GET /api/lead-statuses`)
|
||||
|
||||
| Угроза | Описание | Текущий контроль | Пробел |
|
||||
|---|---|---|---|
|
||||
| **S** Spoofing | — | — | — |
|
||||
| **T** Tampering | — | — | — |
|
||||
| **R** Repudiation | — | — | — |
|
||||
| **I** Info disclosure | ФИО менеджеров без аутентификации | — | **Нет auth** — любой из интернета получает список менеджеров |
|
||||
| **D** DoS | Флуд запросами | — | Нет rate-limit |
|
||||
| **E** Elevation | — | — | — |
|
||||
|
||||
### E18 — Дашборд без auth (`GET /api/dashboard/summary`)
|
||||
|
||||
| Угроза | Описание | Текущий контроль | Пробел |
|
||||
|---|---|---|---|
|
||||
| **I** Info disclosure | KPI, баланс, активность тенанта без аутентификации | — | **MVP-заглушка**: auth не включён; в prod обязателен |
|
||||
| **D** DoS | Тяжёлый агрегационный запрос без auth | — | Доступен без токена |
|
||||
|
||||
### E20 — SaaS-admin (`GET/PATCH /api/admin/**`)
|
||||
|
||||
| Угроза | Описание | Текущий контроль | Пробел |
|
||||
|---|---|---|---|
|
||||
| **S** Spoofing | Доступ к admin-панели без Yandex 360 SSO | `saas-admin` middleware (fail-closed 503 в prod) | SSO не реализован до Б-1; `SAAS_ADMIN_TEST_BYPASS` в prod = полный доступ |
|
||||
| **T** Tampering | Изменение тарифа, статуса тенанта без аудита | `saas_admin_audit_log` | — |
|
||||
| **R** Repudiation | Отрицание действий admin | `saas_admin_audit_log` | Нет подписи/2FA для деструктивных операций |
|
||||
| **I** Info disclosure | Данные всех тенантов | `saas-admin` middleware | SAAS_ADMIN_TEST_BYPASS=true в production = полный дамп |
|
||||
| **D** DoS | Bulk-delete тенантов | — | Нет подтверждения для деструктивных bulk-операций |
|
||||
| **E** Elevation | Impersonation любого тенанта | `saas-admin` middleware | Та же уязвимость через bypass |
|
||||
|
||||
### E21 — Impersonation (`POST /api/admin/impersonation/init`, `/verify`, `/end`)
|
||||
|
||||
| Угроза | Описание | Текущий контроль | Пробел |
|
||||
|---|---|---|---|
|
||||
| **S** Spoofing | Имперсонация без реального admin-права | `saas-admin` middleware | Bypass в dev/test режиме |
|
||||
| **T** Tampering | Изменение `admin_user_id` в токене | Token-based flow | Проверить, что token не forgeble |
|
||||
| **R** Repudiation | Отрицание сессии имперсонации | `impersonation_tokens` логирует | Нет нотификации целевому тенанту |
|
||||
| **E** Elevation | Получение прав тенанта через impersonation | Scope ограничен tenant-контекстом | Если RLS bypass во время импersонации |
|
||||
|
||||
### E13 — SSRF через webhook-test (`POST /api/webhooks/test`)
|
||||
|
||||
| Угроза | Описание | Текущий контроль | Пробел |
|
||||
|---|---|---|---|
|
||||
| **T** Tampering | Отправка запроса на внутренний адрес YC | — | **Нет фильтрации URL** — SSRF во внутреннюю сеть Yandex Cloud (metadata service 169.254.169.254) |
|
||||
| **I** Info disclosure | YC instance metadata (IAM-токен, настройки сети) | — | Критично: SSRF → metadata API → IAM credentials |
|
||||
|
||||
---
|
||||
|
||||
## Приоритизация рисков
|
||||
|
||||
Матрица: **Вероятность** (В — высокая / С — средняя / Н — низкая) ×
|
||||
**Ущерб** (К — критический / В — высокий / С — средний / Н — низкий).
|
||||
|
||||
| Приоритет | Риск | Точка | Вероятность | Ущерб | Контрмера |
|
||||
|---|---|---|---|---|---|
|
||||
| 🔴 P0 | SAAS_ADMIN_TEST_BYPASS=true в prod | E20, E21 | В | К | Убедиться, что флаг false в `.env.production`; fail-closed middleware |
|
||||
| 🔴 P0 | SSRF через `/api/webhooks/test` | E13 | С | К | Валидировать URL: запрещать RFC1918 + link-local + metadata-IP; использовать DNS-rebind защиту |
|
||||
| 🔴 P0 | `GET /api/dashboard/summary` без auth | E18 | В | В | Добавить `auth:sanctum + tenant` middleware до prod |
|
||||
| 🔴 P0 | `GET /api/managers`, `GET /api/lead-statuses` без auth | E9 | В | С | Добавить `auth:sanctum + tenant` |
|
||||
| 🟠 P1 | Нет rate-limit на login / forgot / 2fa/verify | E1, E2, E3 | В | В | Laravel Throttle middleware (e.g. `throttle:5,1`) |
|
||||
| 🟠 P1 | URL-secret поставщика виден в access-логах | E4 | С | В | Перевести на HMAC-заголовок; ротировать secret; закрыть логи |
|
||||
| 🟠 P1 | Флуд поддельных лидов → списание баланса | E4, E5 | С | В | IP allowlist жёсткий; HMAC на тело (E4); idempotency-key |
|
||||
| 🟡 P2 | Энумерация email на login (не только forgot) | E1 | В | С | Unified-ответ на login тоже |
|
||||
| 🟡 P2 | Флуд регистраций без email-верификации | E1 | С | С | Email verification или captcha |
|
||||
| 🟡 P2 | Signed URL отчёта 24 ч без аутентификации | E17 | Н | С | Сократить TTL; добавить revocation при logout |
|
||||
| 🟡 P2 | Нет нотификации тенанту при impersonation | E21 | Н | С | Email/in-app уведомление при входе admin |
|
||||
| 🟢 P3 | Тайминг-атака на TOTP | E2 | Н | С | Проверить constant-time compare в TwoFactorController |
|
||||
| 🟢 P3 | Тайминг-атака на email в forgot | E3 | Н | Н | Unified-ответ + jitter sleep |
|
||||
|
||||
---
|
||||
|
||||
## Что уже защищает портал (baseline)
|
||||
|
||||
- **RLS PostgreSQL** — 39 политик; кросс-tenant утечка через SQL закрыта.
|
||||
- **Sanctum SPA auth** — все бизнес-эндпоинты под `auth:sanctum + tenant`.
|
||||
- **Per-token rate-limit** — на входящих webhook'ах тенанта (E5).
|
||||
- **IP allowlist** — на webhook поставщика (E4).
|
||||
- **HMAC X-Webhook-Signature** — на E5 в prod (не в dev).
|
||||
- **`auth_log`** — фиксирует login/logout события.
|
||||
- **`saas_admin_audit_log`** — фиксирует admin-действия.
|
||||
- **Bcrypt** — хеш пароля; коды восстановления 2FA — hashed.
|
||||
- **`saas-admin` middleware** — fail-closed 503 в prod (если `SAAS_ADMIN_TEST_BYPASS=false`).
|
||||
- **Signed URL** — для скачивания отчётов (E17).
|
||||
- **gitleaks** — pre-commit/pre-push; секреты не должны попасть в репозиторий.
|
||||
@@ -0,0 +1,26 @@
|
||||
# gitleaks false-positive allowlist (fingerprints).
|
||||
# Format: one fingerprint per line. `gitleaks detect --report-format json` outputs them.
|
||||
|
||||
# Nuclei docs `-u http://...` — nuclei's -u flag is "target URL", not curl basic-auth.
|
||||
# Rule `curl-auth-user` matches the pattern but it's not authentication.
|
||||
f696ca50266eb1c2974b5fc89f6fa585edaf4b6b:docs/security/nuclei-setup.md:curl-auth-user:27
|
||||
|
||||
# 2026-05-22 evening — rt-add-project-form.yml в stash (untracked файл captured при stash push -u
|
||||
# до checkout main). Стэш не пушится, но gitleaks-full-history сканит refs/stash. Эти телефоны —
|
||||
# реальные данные supplier-формы, не наша утечка; rt-add-project-form.yml в .gitignore.
|
||||
229beb2d4ef190f6e29dd9ad31a642535601154f:rt-add-project-form.yml:ru-phone-unmasked:912
|
||||
229beb2d4ef190f6e29dd9ad31a642535601154f:rt-add-project-form.yml:ru-phone-unmasked:921
|
||||
229beb2d4ef190f6e29dd9ad31a642535601154f:rt-add-project-form.yml:ru-phone-unmasked:941
|
||||
229beb2d4ef190f6e29dd9ad31a642535601154f:rt-add-project-form.yml:ru-phone-unmasked:950
|
||||
229beb2d4ef190f6e29dd9ad31a642535601154f:rt-add-project-form.yml:ru-phone-unmasked:970
|
||||
229beb2d4ef190f6e29dd9ad31a642535601154f:rt-add-project-form.yml:ru-phone-unmasked:979
|
||||
229beb2d4ef190f6e29dd9ad31a642535601154f:rt-add-project-form.yml:ru-phone-unmasked:3811
|
||||
229beb2d4ef190f6e29dd9ad31a642535601154f:rt-add-project-form.yml:ru-phone-unmasked:3820
|
||||
229beb2d4ef190f6e29dd9ad31a642535601154f:rt-add-project-form.yml:ru-phone-unmasked:3840
|
||||
229beb2d4ef190f6e29dd9ad31a642535601154f:rt-add-project-form.yml:ru-phone-unmasked:3849
|
||||
229beb2d4ef190f6e29dd9ad31a642535601154f:rt-add-project-form.yml:ru-phone-unmasked:3869
|
||||
229beb2d4ef190f6e29dd9ad31a642535601154f:rt-add-project-form.yml:ru-phone-unmasked:3878
|
||||
|
||||
# 2026-05-22 — nuclei-setup.md curl-auth-user тот же FP что и раньше (f696ca5),
|
||||
# но коммит другой (05437ba) — параллельная сессия пере-коммитила тот же файл.
|
||||
05437ba79a26a7a7bbbe0ffb2f2573c432a9a4d1:docs/security/nuclei-setup.md:curl-auth-user:27
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\Supplier\Import\SupplierProjectImporter;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Разовый импорт активных проектов поставщика (аккаунт lkomega) как проектов
|
||||
* Лидерры под тенантом владельца. По умолчанию dry-run (печатает план, ничего
|
||||
* не пишет). С --commit пишет в БД через pgsql_supplier (BYPASSRLS), портал НЕ
|
||||
* трогает. Идемпотентна.
|
||||
*
|
||||
* Plan: docs/superpowers/plans/2026-05-22-supplier-projects-import-lkomega.md
|
||||
*/
|
||||
class ImportSupplierProjectsCommand extends Command
|
||||
{
|
||||
protected $signature = 'supplier:import-projects
|
||||
{--tenant= : email пользователя тенанта (напр. info@lkomega.ru)}
|
||||
{--commit : выполнить запись (без флага — только dry-run)}';
|
||||
|
||||
protected $description = 'Усыновить активные проекты поставщика как проекты Лидерры под тенантом (dry-run по умолчанию)';
|
||||
|
||||
public function handle(SupplierProjectImporter $importer): int
|
||||
{
|
||||
$email = (string) $this->option('tenant');
|
||||
if ($email === '') {
|
||||
$this->error('Укажите --tenant=<email>');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$tenantId = User::on('pgsql_supplier')->where('email', $email)->value('tenant_id');
|
||||
if ($tenantId === null) {
|
||||
$this->error("Тенант для email '{$email}' не найден.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$plan = $importer->buildPlan((int) $tenantId);
|
||||
|
||||
$this->info(sprintf('Тенант %s (id=%d). К созданию: %d проектов. Пропущено строк/групп: %d.',
|
||||
$email, $tenantId, count($plan['planned']), count($plan['skipped'])));
|
||||
|
||||
$this->table(
|
||||
['Тип', 'Идентификатор', 'Тег', 'Регионы', 'Лимит', 'Площадки (external_id)'],
|
||||
array_map(fn (array $p): array => [
|
||||
$p['signal_type'],
|
||||
$this->mask($p['signal_identifier'] ?? ($p['sms_senders'][0] ?? '')),
|
||||
mb_substr((string) $p['tag'], 0, 30),
|
||||
$p['regions'] === [] ? 'вся РФ' : implode(',', $p['regions']),
|
||||
(string) $p['daily_limit_target'],
|
||||
collect($p['platforms'])->map(fn (array $pl): string => $pl['platform'].':'.$pl['external_id'])->implode(' '),
|
||||
], $plan['planned']),
|
||||
);
|
||||
|
||||
if ($plan['skipped'] !== []) {
|
||||
$this->warn('Пропуски:');
|
||||
foreach ($plan['skipped'] as $s) {
|
||||
$this->line(sprintf(' - [%s] %s', $s['reason'], $this->mask($s['label'])));
|
||||
}
|
||||
}
|
||||
|
||||
if (! $this->option('commit')) {
|
||||
$this->comment('DRY-RUN: ничего не записано. Повторите с --commit для реальной записи.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$result = $importer->commit($plan, (int) $tenantId);
|
||||
$this->info(sprintf('Создано: проектов=%d, supplier_projects=%d, связок=%d.',
|
||||
$result['created_projects'], $result['created_supplier_projects'], $result['created_links']));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/** Маскирует цифровые хвосты (телефоны) для вывода (152-ФЗ). */
|
||||
private function mask(string $value): string
|
||||
{
|
||||
return (string) preg_replace_callback('/\d{4,}/', static fn (array $m): string => substr($m[0], 0, 2).str_repeat('*', max(0, strlen($m[0]) - 4)).substr($m[0], -2), $value);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\ReportJob;
|
||||
use App\Services\Pd\PdAuditLogger;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
@@ -69,6 +70,16 @@ class ReportsCleanupExpired extends Command
|
||||
|
||||
if (! $dryRun) {
|
||||
Storage::disk('local')->delete($job->file_path);
|
||||
app(PdAuditLogger::class)->record(
|
||||
action: 'deleted',
|
||||
subjectType: 'lead',
|
||||
subjectId: null,
|
||||
purpose: 'report_cleanup_expired_'.$job->id,
|
||||
tenantId: $job->tenant_id,
|
||||
actorTenantUserId: null,
|
||||
actorAdminUserId: null,
|
||||
ip: null,
|
||||
);
|
||||
$job->update(['file_path' => null]);
|
||||
}
|
||||
$count++;
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Concerns\WritesAuthLog;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\LoginRequest;
|
||||
use App\Http\Requests\Auth\RegisterRequest;
|
||||
@@ -47,6 +48,8 @@ use Illuminate\Support\Facades\RateLimiter;
|
||||
*/
|
||||
class AuthController extends Controller
|
||||
{
|
||||
use WritesAuthLog;
|
||||
|
||||
/** Лимит попыток входа в окне (ТЗ §22.4.4 + system_settings.login_max_attempts=5). */
|
||||
private const LOGIN_MAX_ATTEMPTS = 5;
|
||||
|
||||
@@ -78,7 +81,7 @@ class AuthController extends Controller
|
||||
|
||||
if (! $user || ! Hash::check($credentials['password'], $user->password_hash)) {
|
||||
RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS);
|
||||
$this->logAuthEvent('login_failed', $user, $credentials['email'], $ip, $request->userAgent(),
|
||||
$this->logAuthEvent('login_failed', $user?->id, $user?->tenant_id, $credentials['email'], $ip, $request->userAgent(),
|
||||
$user ? 'invalid_password' : 'unknown_email');
|
||||
$this->maybeNotifySuspiciousLogin($user, $ip);
|
||||
|
||||
@@ -90,7 +93,7 @@ class AuthController extends Controller
|
||||
|
||||
if (! $user->is_active) {
|
||||
RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS);
|
||||
$this->logAuthEvent('login_failed', $user, $credentials['email'], $ip, $request->userAgent(),
|
||||
$this->logAuthEvent('login_failed', $user->id, $user->tenant_id, $credentials['email'], $ip, $request->userAgent(),
|
||||
'account_locked');
|
||||
|
||||
return response()->json([
|
||||
@@ -120,7 +123,7 @@ class AuthController extends Controller
|
||||
|
||||
$user->update(['last_login_at' => now()]);
|
||||
|
||||
$this->logAuthEvent('login_success', $user, $user->email, $ip, $request->userAgent(), null);
|
||||
$this->logAuthEvent('login_success', $user->id, $user->tenant_id, $user->email, $ip, $request->userAgent(), null);
|
||||
|
||||
return response()->json([
|
||||
'user' => $this->userResource($user),
|
||||
@@ -152,6 +155,8 @@ class AuthController extends Controller
|
||||
Auth::login($user);
|
||||
$request->session()->regenerate();
|
||||
|
||||
$this->logAuthEvent('register_success', $user->id, $user->tenant_id, $user->email, $request->ip(), $request->userAgent(), null);
|
||||
|
||||
return response()->json([
|
||||
'user' => $this->userResource($user),
|
||||
'requires_2fa' => false,
|
||||
@@ -170,11 +175,17 @@ class AuthController extends Controller
|
||||
|
||||
public function logout(Request $request): JsonResponse
|
||||
{
|
||||
$userId = $request->user()?->id;
|
||||
$tenantId = $request->user()?->tenant_id;
|
||||
$email = $request->user()?->email;
|
||||
|
||||
Auth::guard('web')->logout();
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
$this->logAuthEvent('logout', $userId, $tenantId, $email, $request->ip(), $request->userAgent(), null);
|
||||
|
||||
return response()->json(['message' => 'Вы вышли из системы.']);
|
||||
}
|
||||
|
||||
@@ -311,34 +322,6 @@ class AuthController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Запись события auth_log.
|
||||
*
|
||||
* Через DB::table — auth_log имеет hash-chain trigger BEFORE INSERT,
|
||||
* который заполняет log_hash. Eloquent-модели для этой таблицы нет.
|
||||
* RLS USING без WITH CHECK — INSERT не фильтруется.
|
||||
*/
|
||||
private function logAuthEvent(
|
||||
string $event,
|
||||
?User $user,
|
||||
?string $email,
|
||||
?string $ip,
|
||||
?string $userAgent,
|
||||
?string $failureReason,
|
||||
): void {
|
||||
DB::table('auth_log')->insert([
|
||||
'actor_type' => 'tenant_user',
|
||||
'tenant_id' => $user?->tenant_id,
|
||||
'user_id' => $user?->id,
|
||||
'email' => $email,
|
||||
'event' => $event,
|
||||
'ip_address' => $ip,
|
||||
'user_agent' => $userAgent,
|
||||
'failure_reason' => $failureReason,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/** 429 Too Many Requests + Retry-After header (секунды до следующей попытки). */
|
||||
private function lockoutResponse(string $throttleKey): JsonResponse
|
||||
{
|
||||
|
||||
@@ -28,10 +28,9 @@ class DashboardController extends Controller
|
||||
|
||||
public function summary(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = (int) $request->query('tenant_id', '0');
|
||||
if ($tenantId < 1) {
|
||||
return response()->json(['message' => 'Параметр tenant_id обязателен.'], 422);
|
||||
}
|
||||
// Go-live (audit J3): tenant_id из authed-user (auth:sanctum + tenant
|
||||
// middleware), НЕ из параметра запроса — закрывает кросс-tenant утечку KPI.
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
|
||||
$tenant = Tenant::find($tenantId);
|
||||
if ($tenant === null) {
|
||||
@@ -74,7 +73,6 @@ class DashboardController extends Controller
|
||||
// --- active projects ---
|
||||
$activeProjects = DB::table('projects')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNull('archived_at')
|
||||
->where('is_active', true)
|
||||
->count();
|
||||
$maxProjects = (int) (($tenant->limits['max_projects'] ?? 0));
|
||||
|
||||
@@ -64,7 +64,7 @@ class DealBulkActionController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$updated = DB::transaction(function () use ($validated, $tenantId) {
|
||||
$updated = DB::transaction(function () use ($validated, $tenantId, $request) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
// Фаза 1: SELECT — нужны id и предыдущий status для каждой строки,
|
||||
@@ -98,7 +98,7 @@ class DealBulkActionController extends Controller
|
||||
// напрямую. Триггер audit_chain_hash() заполнит log_hash на уровне БД.
|
||||
$logRows = $changed->map(fn (Deal $d) => [
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => null,
|
||||
'user_id' => (int) $request->user()->id,
|
||||
'deal_id' => $d->id,
|
||||
'event' => ActivityLog::EVENT_DEAL_STATUS_CHANGED,
|
||||
'context' => json_encode([
|
||||
@@ -106,6 +106,8 @@ class DealBulkActionController extends Controller
|
||||
'to' => $validated['status'],
|
||||
'source' => 'bulk',
|
||||
], JSON_UNESCAPED_UNICODE),
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
'created_at' => $now,
|
||||
])->all();
|
||||
|
||||
@@ -140,7 +142,7 @@ class DealBulkActionController extends Controller
|
||||
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
|
||||
$deleted = DB::transaction(function () use ($validated, $tenantId) {
|
||||
$deleted = DB::transaction(function () use ($validated, $tenantId, $request) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
// SELECT id'шников живых сделок tenant'а из ids — для bulk-INSERT
|
||||
@@ -169,10 +171,12 @@ class DealBulkActionController extends Controller
|
||||
|
||||
$logRows = array_map(fn (int $id) => [
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => null,
|
||||
'user_id' => (int) $request->user()->id,
|
||||
'deal_id' => $id,
|
||||
'event' => ActivityLog::EVENT_DEAL_DELETED,
|
||||
'context' => json_encode(['source' => 'bulk'], JSON_UNESCAPED_UNICODE),
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
'created_at' => $now,
|
||||
], $targetIds);
|
||||
|
||||
@@ -202,7 +206,7 @@ class DealBulkActionController extends Controller
|
||||
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
|
||||
$restored = DB::transaction(function () use ($validated, $tenantId) {
|
||||
$restored = DB::transaction(function () use ($validated, $tenantId, $request) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
// withTrashed обходит SoftDeletes global scope; whereNotNull —
|
||||
@@ -233,10 +237,12 @@ class DealBulkActionController extends Controller
|
||||
|
||||
$logRows = array_map(fn (int $id) => [
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => null,
|
||||
'user_id' => (int) $request->user()->id,
|
||||
'deal_id' => $id,
|
||||
'event' => ActivityLog::EVENT_DEAL_RESTORED,
|
||||
'context' => json_encode(['source' => 'bulk'], JSON_UNESCAPED_UNICODE),
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
'created_at' => $now,
|
||||
], $targetIds);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierLeadCost;
|
||||
use App\Models\User;
|
||||
use App\Services\Pd\PdAuditLogger;
|
||||
use App\Services\SupplierResolver;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -241,7 +242,7 @@ class DealController extends Controller
|
||||
* RLS-обёртка + defense-in-depth `where(tenant_id)`. Если сделка не
|
||||
* принадлежит tenant'у (или не существует) — 404.
|
||||
*/
|
||||
public function show(Request $request, int $id): JsonResponse
|
||||
public function show(Request $request, int $id, PdAuditLogger $pdLog): JsonResponse
|
||||
{
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
|
||||
@@ -274,6 +275,17 @@ class DealController extends Controller
|
||||
return response()->json(['message' => 'Сделка не найдена.'], 404);
|
||||
}
|
||||
|
||||
$pdLog->record(
|
||||
action: 'viewed',
|
||||
subjectType: 'lead',
|
||||
subjectId: $deal->id,
|
||||
purpose: 'lead_card_view',
|
||||
tenantId: (int) $request->user()->tenant_id,
|
||||
actorTenantUserId: (int) $request->user()->id,
|
||||
actorAdminUserId: null,
|
||||
ip: $request->ip(),
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'deal' => [
|
||||
'id' => $deal->id,
|
||||
@@ -386,10 +398,12 @@ class DealController extends Controller
|
||||
$deal->comment = $validated['comment'];
|
||||
ActivityLog::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => null,
|
||||
'user_id' => request()->user()?->id,
|
||||
'deal_id' => $deal->id,
|
||||
'event' => 'deal.commented',
|
||||
'context' => ['text' => $validated['comment'] ?? ''],
|
||||
'ip_address' => request()->ip(),
|
||||
'user_agent' => request()->userAgent(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -399,10 +413,12 @@ class DealController extends Controller
|
||||
$deal->assigned_at = $validated['manager_id'] !== null ? now() : null;
|
||||
ActivityLog::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => null,
|
||||
'user_id' => request()->user()?->id,
|
||||
'deal_id' => $deal->id,
|
||||
'event' => ActivityLog::EVENT_DEAL_ASSIGNED,
|
||||
'context' => ['from' => $previousManager, 'to' => $validated['manager_id']],
|
||||
'ip_address' => request()->ip(),
|
||||
'user_agent' => request()->userAgent(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -411,10 +427,12 @@ class DealController extends Controller
|
||||
$deal->status = $validated['status'];
|
||||
ActivityLog::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => null,
|
||||
'user_id' => request()->user()?->id,
|
||||
'deal_id' => $deal->id,
|
||||
'event' => ActivityLog::EVENT_DEAL_STATUS_CHANGED,
|
||||
'context' => ['from' => $previousStatus, 'to' => $validated['status'], 'source' => 'manual'],
|
||||
'ip_address' => request()->ip(),
|
||||
'user_agent' => request()->userAgent(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -448,7 +466,7 @@ class DealController extends Controller
|
||||
}
|
||||
|
||||
/** POST /api/deals — manual create */
|
||||
public function store(Request $request): JsonResponse
|
||||
public function store(Request $request, PdAuditLogger $pdLog): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'project_name' => 'required|string|max:255',
|
||||
@@ -522,15 +540,24 @@ class DealController extends Controller
|
||||
|
||||
ActivityLog::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => null, // на prod — request()->user()->id
|
||||
'user_id' => request()->user()?->id,
|
||||
'deal_id' => $deal->id,
|
||||
'event' => ActivityLog::EVENT_DEAL_CREATED,
|
||||
'context' => ['source' => 'manual'],
|
||||
'ip_address' => request()->ip(),
|
||||
'user_agent' => request()->userAgent(),
|
||||
]);
|
||||
|
||||
return $deal;
|
||||
});
|
||||
|
||||
$pdLog->record(
|
||||
action: 'created', subjectType: 'lead', subjectId: $deal->id,
|
||||
purpose: 'lead_create_manual', tenantId: (int) $deal->tenant_id,
|
||||
actorTenantUserId: (int) $request->user()->id,
|
||||
actorAdminUserId: null, ip: $request->ip(),
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'deal' => [
|
||||
'id' => $deal->id,
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Deal;
|
||||
use App\Services\Pd\PdAuditLogger;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -55,6 +56,17 @@ class DealExportController extends Controller
|
||||
$to = isset($validated['received_to']) && $validated['received_to'] !== ''
|
||||
? Carbon::parse($validated['received_to'])->addDay()->startOfDay() : null;
|
||||
|
||||
app(PdAuditLogger::class)->record(
|
||||
action: 'exported',
|
||||
subjectType: 'lead',
|
||||
subjectId: null,
|
||||
purpose: 'deals_export_'.$format,
|
||||
tenantId: $tenantId,
|
||||
actorTenantUserId: (int) $request->user()->id,
|
||||
actorAdminUserId: null,
|
||||
ip: $request->ip(),
|
||||
);
|
||||
|
||||
$filename = 'deals_export_'.now()->format('Y-m-d').'.'.$format;
|
||||
$headers = $format === 'xlsx'
|
||||
? [
|
||||
|
||||
@@ -7,9 +7,9 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ImpersonationToken;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Pd\ImpersonationAuditService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
/**
|
||||
@@ -39,10 +39,20 @@ class ImpersonationController extends Controller
|
||||
|
||||
private const MAX_FAILED_ATTEMPTS = 5;
|
||||
|
||||
/**
|
||||
* SaaS-admin — кросс-тенантная зона: запросы к impersonation_tokens / tenants
|
||||
* идут через BYPASSRLS-подключение pgsql_supplier (роль crm_supplier_worker).
|
||||
* Иначе на проде (роль crm_app_user, RLS on) без выставленного GUC
|
||||
* app.current_tenant_id запрос падает SQLSTATE 42704 — у saas-admin нет
|
||||
* tenant-контекста (middleware 'tenant' на /api/admin/* не висит). На dev
|
||||
* pgsql_supplier = fallback на postgres-superuser, поведение идентично.
|
||||
*/
|
||||
private const DB_CONNECTION = 'pgsql_supplier';
|
||||
|
||||
/** GET /api/admin/impersonation/active — активные сессии (used_at != null AND session_ended_at == null) */
|
||||
public function active(): JsonResponse
|
||||
{
|
||||
$rows = ImpersonationToken::query()
|
||||
$rows = ImpersonationToken::on(self::DB_CONNECTION)
|
||||
->whereNotNull('used_at')
|
||||
->whereNull('session_ended_at')
|
||||
->with(['tenant'])
|
||||
@@ -67,7 +77,7 @@ class ImpersonationController extends Controller
|
||||
/** GET /api/admin/impersonation/recent — последние 20 завершённых */
|
||||
public function recent(): JsonResponse
|
||||
{
|
||||
$rows = ImpersonationToken::query()
|
||||
$rows = ImpersonationToken::on(self::DB_CONNECTION)
|
||||
->whereNotNull('used_at')
|
||||
->whereNotNull('session_ended_at')
|
||||
->with(['tenant'])
|
||||
@@ -92,7 +102,7 @@ class ImpersonationController extends Controller
|
||||
}
|
||||
|
||||
/** POST /api/admin/impersonation/init */
|
||||
public function init(Request $request): JsonResponse
|
||||
public function init(Request $request, ImpersonationAuditService $audit): JsonResponse
|
||||
{
|
||||
$tenantId = (int) $request->input('tenant_id');
|
||||
$requestedBy = (int) $request->input('requested_by'); // TODO: $request->user()->id когда saas-admin auth готов
|
||||
@@ -105,7 +115,7 @@ class ImpersonationController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$tenant = Tenant::find($tenantId);
|
||||
$tenant = Tenant::on(self::DB_CONNECTION)->find($tenantId);
|
||||
if (! $tenant) {
|
||||
return response()->json(['message' => 'Тенант не найден.'], 404);
|
||||
}
|
||||
@@ -113,7 +123,7 @@ class ImpersonationController extends Controller
|
||||
// 6-значный код. Числа от 100000 до 999999.
|
||||
$plainCode = (string) random_int(100_000, 999_999);
|
||||
|
||||
$token = ImpersonationToken::create([
|
||||
$token = ImpersonationToken::on(self::DB_CONNECTION)->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'requested_by' => $requestedBy,
|
||||
'code_hash' => Hash::make($plainCode),
|
||||
@@ -122,6 +132,8 @@ class ImpersonationController extends Controller
|
||||
'expires_at' => now()->addMinutes(self::TOKEN_TTL_MINUTES),
|
||||
]);
|
||||
|
||||
$audit->recordInit($token, adminId: $requestedBy, ip: $request->ip());
|
||||
|
||||
// TODO: отправить email на $tenant->contact_email с $plainCode.
|
||||
$payload = [
|
||||
'token_id' => $token->id,
|
||||
@@ -141,12 +153,12 @@ class ImpersonationController extends Controller
|
||||
}
|
||||
|
||||
/** POST /api/admin/impersonation/verify */
|
||||
public function verify(Request $request): JsonResponse
|
||||
public function verify(Request $request, ImpersonationAuditService $audit): JsonResponse
|
||||
{
|
||||
$tokenId = (int) $request->input('token_id');
|
||||
$code = $request->string('code')->toString();
|
||||
|
||||
$token = ImpersonationToken::find($tokenId);
|
||||
$token = ImpersonationToken::on(self::DB_CONNECTION)->find($tokenId);
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Токен не найден.'], 404);
|
||||
}
|
||||
@@ -164,12 +176,13 @@ class ImpersonationController extends Controller
|
||||
}
|
||||
|
||||
if (! Hash::check($code, $token->code_hash)) {
|
||||
DB::transaction(function () use ($token) {
|
||||
$token->increment('failed_attempts');
|
||||
if ($token->failed_attempts >= self::MAX_FAILED_ATTEMPTS) {
|
||||
$token->update(['invalidated_at' => now()]);
|
||||
}
|
||||
});
|
||||
// increment атомарен на уровне SQL, а isUsable() независимо гейтит
|
||||
// failed_attempts >= 5 — поэтому отдельная транзакция не нужна
|
||||
// (и ломала бы общий PDO в тестах под SharesSupplierPdo).
|
||||
$token->increment('failed_attempts');
|
||||
if ($token->failed_attempts >= self::MAX_FAILED_ATTEMPTS) {
|
||||
$token->update(['invalidated_at' => now()]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Неверный код.',
|
||||
@@ -183,6 +196,8 @@ class ImpersonationController extends Controller
|
||||
'used_at' => now(),
|
||||
]);
|
||||
|
||||
$audit->recordVerify($token, adminId: (int) $token->requested_by, ip: $request->ip());
|
||||
|
||||
return response()->json([
|
||||
'token_id' => $token->id,
|
||||
'tenant_id' => $token->tenant_id,
|
||||
@@ -192,11 +207,11 @@ class ImpersonationController extends Controller
|
||||
}
|
||||
|
||||
/** POST /api/admin/impersonation/end */
|
||||
public function end(Request $request): JsonResponse
|
||||
public function end(Request $request, ImpersonationAuditService $audit): JsonResponse
|
||||
{
|
||||
$tokenId = (int) $request->input('token_id');
|
||||
|
||||
$token = ImpersonationToken::find($tokenId);
|
||||
$token = ImpersonationToken::on(self::DB_CONNECTION)->find($tokenId);
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Токен не найден.'], 404);
|
||||
}
|
||||
@@ -215,6 +230,8 @@ class ImpersonationController extends Controller
|
||||
|
||||
$token->update(['session_ended_at' => now()]);
|
||||
|
||||
$audit->recordEnd($token, adminId: (int) $token->requested_by, ip: $request->ip());
|
||||
|
||||
// TODO: уведомление клиенту по email о завершении (как и в init flow).
|
||||
|
||||
return response()->json([
|
||||
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -23,20 +22,18 @@ class ManagerController extends Controller
|
||||
/** GET /api/managers?tenant_id={id} */
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = (int) $request->query('tenant_id', '0');
|
||||
if ($tenantId < 1) {
|
||||
return response()->json(['message' => 'Параметр tenant_id обязателен.'], 422);
|
||||
}
|
||||
|
||||
$tenant = Tenant::find($tenantId);
|
||||
if ($tenant === null) {
|
||||
return response()->json(['message' => 'Тенант не найден.'], 404);
|
||||
}
|
||||
// Go-live: tenant_id из authed-user (auth:sanctum + tenant middleware),
|
||||
// НЕ из параметра запроса — закрывает кросс-tenant утечку списка пользователей.
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
|
||||
$users = DB::transaction(function () use ($tenantId) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
// Явный where(tenant_id) — defense-in-depth поверх RLS: роли с
|
||||
// BYPASSRLS (crm_supplier_worker / dev-superuser) RLS не применяют,
|
||||
// поэтому tenant-scope нельзя оставлять только на SET LOCAL.
|
||||
return User::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNull('deleted_at')
|
||||
->where('is_active', true)
|
||||
->orderBy('first_name')
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Concerns\WritesAuthLog;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\ForgotPasswordRequest;
|
||||
use App\Http\Requests\Auth\ResetPasswordRequest;
|
||||
@@ -29,6 +30,8 @@ use Illuminate\Support\Facades\RateLimiter;
|
||||
*/
|
||||
class PasswordResetController extends Controller
|
||||
{
|
||||
use WritesAuthLog;
|
||||
|
||||
/** Лимит попыток в окне (ТЗ §22.4.4 + system_settings.login_max_attempts=5). */
|
||||
private const LOGIN_MAX_ATTEMPTS = 5;
|
||||
|
||||
@@ -69,6 +72,17 @@ class PasswordResetController extends Controller
|
||||
|
||||
Password::sendResetLink(['email' => $email]);
|
||||
|
||||
$userId = User::where('email', $email)->value('id');
|
||||
$this->logAuthEvent(
|
||||
'password_reset_requested',
|
||||
$userId,
|
||||
null,
|
||||
$email,
|
||||
$request->ip(),
|
||||
$request->userAgent(),
|
||||
$userId === null ? 'unknown_email' : null,
|
||||
);
|
||||
|
||||
// Unified ответ независимо от наличия user'а.
|
||||
return response()->json([
|
||||
'message' => 'Если такой email зарегистрирован — мы отправили ссылку для сброса пароля.',
|
||||
@@ -120,12 +134,33 @@ class PasswordResetController extends Controller
|
||||
if ($status !== Password::PASSWORD_RESET) {
|
||||
RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS);
|
||||
|
||||
$this->logAuthEvent(
|
||||
'password_reset_failed',
|
||||
null,
|
||||
null,
|
||||
$email,
|
||||
$request->ip(),
|
||||
$request->userAgent(),
|
||||
(string) $status,
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Ссылка для сброса недействительна или истекла. Запросите новую.',
|
||||
'errors' => ['email' => ['Ссылка для сброса недействительна или истекла.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$completedUserId = User::where('email', $email)->value('id');
|
||||
$this->logAuthEvent(
|
||||
'password_reset_completed',
|
||||
$completedUserId,
|
||||
null,
|
||||
$email,
|
||||
$request->ip(),
|
||||
$request->userAgent(),
|
||||
null,
|
||||
);
|
||||
|
||||
RateLimiter::clear($throttleKey);
|
||||
|
||||
return response()->json([
|
||||
|
||||
@@ -52,16 +52,12 @@ class ProjectController extends Controller
|
||||
|
||||
// Фильтр по статусу жизненного цикла
|
||||
$status = $request->query('status');
|
||||
if ($status === 'archived') {
|
||||
$query->archived();
|
||||
} elseif ($status === 'active') {
|
||||
$query->active()->where('is_active', true);
|
||||
if ($status === 'active') {
|
||||
$query->where('is_active', true);
|
||||
} elseif ($status === 'paused') {
|
||||
$query->active()->where('is_active', false);
|
||||
} else {
|
||||
// По умолчанию: все не архивированные (active + paused)
|
||||
$query->active();
|
||||
$query->where('is_active', false);
|
||||
}
|
||||
// default → no extra filter
|
||||
|
||||
// Поиск по name и signal_identifier
|
||||
if ($search = $request->query('search')) {
|
||||
@@ -111,11 +107,11 @@ class ProjectController extends Controller
|
||||
return response()->json(['data' => new ProjectResource($project)]);
|
||||
}
|
||||
|
||||
/** DELETE /api/projects/{id} — soft-archive (sets archived_at, is_active=false) */
|
||||
/** DELETE /api/projects/{id} — hard delete (guard по сделкам: 422 если есть сделки) */
|
||||
public function destroy(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id);
|
||||
$this->projects->archive($project);
|
||||
$this->projects->delete($project);
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
@@ -139,7 +135,7 @@ class ProjectController extends Controller
|
||||
return response()->json(['data' => new ProjectResource($project->fresh())]);
|
||||
}
|
||||
|
||||
/** POST /api/projects/bulk — batch pause/resume/archive/update_regions/update_days/update_limit */
|
||||
/** POST /api/projects/bulk — batch pause/resume/delete/update_regions/update_days/update_limit */
|
||||
public function bulk(BulkProjectActionRequest $request): JsonResponse
|
||||
{
|
||||
$tenantId = $request->user()->tenant_id;
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Jobs\GenerateReportJob;
|
||||
use App\Models\ReportJob;
|
||||
use App\Models\User;
|
||||
use App\Services\Pd\PdAuditLogger;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
@@ -305,12 +306,12 @@ class ReportJobController extends Controller
|
||||
/**
|
||||
* DELETE /api/reports/jobs/{id} — удалить terminal job + файл.
|
||||
*/
|
||||
public function destroy(Request $request, int $id): JsonResponse
|
||||
public function destroy(Request $request, int $id, PdAuditLogger $pdLog): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
|
||||
return DB::transaction(function () use ($user, $id): JsonResponse {
|
||||
return DB::transaction(function () use ($user, $id, $request, $pdLog): JsonResponse {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);
|
||||
|
||||
$job = ReportJob::query()
|
||||
@@ -335,6 +336,16 @@ class ReportJobController extends Controller
|
||||
|
||||
if ($job->file_path !== null) {
|
||||
Storage::disk('local')->delete($job->file_path);
|
||||
$pdLog->record(
|
||||
action: 'deleted',
|
||||
subjectType: 'lead',
|
||||
subjectId: null,
|
||||
purpose: 'report_file_'.$job->id,
|
||||
tenantId: (int) $job->tenant_id,
|
||||
actorTenantUserId: (int) $user->id,
|
||||
actorAdminUserId: null,
|
||||
ip: $request->ip(),
|
||||
);
|
||||
}
|
||||
$job->delete();
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Concerns\WritesAuthLog;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\UseRecoveryCodeRequest;
|
||||
use App\Http\Requests\Auth\VerifyTwoFactorRequest;
|
||||
@@ -32,6 +33,8 @@ use PragmaRX\Google2FA\Google2FA;
|
||||
*/
|
||||
class TwoFactorController extends Controller
|
||||
{
|
||||
use WritesAuthLog;
|
||||
|
||||
/** Лимит попыток в окне (ТЗ §22.4.4 + system_settings.login_max_attempts=5). */
|
||||
private const LOGIN_MAX_ATTEMPTS = 5;
|
||||
|
||||
@@ -70,6 +73,16 @@ class TwoFactorController extends Controller
|
||||
if (! $valid) {
|
||||
RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS);
|
||||
|
||||
$this->logAuthEvent(
|
||||
'2fa_verify_failed',
|
||||
$user->id,
|
||||
$user->tenant_id,
|
||||
$user->email,
|
||||
$request->ip(),
|
||||
$request->userAgent(),
|
||||
'invalid_code',
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Неверный код. Проверьте время на устройстве и попробуйте снова.',
|
||||
'errors' => ['code' => ['Неверный код.']],
|
||||
@@ -85,6 +98,16 @@ class TwoFactorController extends Controller
|
||||
|
||||
$user->update(['last_login_at' => now()]);
|
||||
|
||||
$this->logAuthEvent(
|
||||
'2fa_verify_success',
|
||||
$user->id,
|
||||
$user->tenant_id,
|
||||
$user->email,
|
||||
$request->ip(),
|
||||
$request->userAgent(),
|
||||
null,
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'user' => $this->userResource($user),
|
||||
'requires_2fa' => false,
|
||||
@@ -151,6 +174,16 @@ class TwoFactorController extends Controller
|
||||
if (! $matched) {
|
||||
RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS);
|
||||
|
||||
$this->logAuthEvent(
|
||||
'2fa_recovery_failed',
|
||||
$user->id,
|
||||
$user->tenant_id,
|
||||
$user->email,
|
||||
$request->ip(),
|
||||
$request->userAgent(),
|
||||
'invalid_or_used',
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Резервный код недействителен или уже использован.',
|
||||
'errors' => ['code' => ['Резервный код недействителен или уже использован.']],
|
||||
@@ -168,6 +201,16 @@ class TwoFactorController extends Controller
|
||||
|
||||
$user->update(['last_login_at' => now()]);
|
||||
|
||||
$this->logAuthEvent(
|
||||
'2fa_recovery_used',
|
||||
$user->id,
|
||||
$user->tenant_id,
|
||||
$user->email,
|
||||
$request->ip(),
|
||||
$request->userAgent(),
|
||||
null,
|
||||
);
|
||||
|
||||
// Кол-во оставшихся неиспользованных кодов — для UI-warning'а
|
||||
// ("осталось 3 из 8 — рекомендуем перегенерировать").
|
||||
$remaining = UserRecoveryCode::query()
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Concerns\WritesAuthLog;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\UserRecoveryCode;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -26,6 +27,8 @@ use PragmaRX\Google2FA\Google2FA;
|
||||
*/
|
||||
class TwoFactorSetupController extends Controller
|
||||
{
|
||||
use WritesAuthLog;
|
||||
|
||||
private const RECOVERY_CODES_COUNT = 8;
|
||||
|
||||
/**
|
||||
@@ -54,6 +57,9 @@ class TwoFactorSetupController extends Controller
|
||||
|
||||
$request->session()->put('auth.pending_totp_secret', $secret);
|
||||
|
||||
$this->logAuthEvent('2fa_setup_init', $user->id, $user->tenant_id, $user->email,
|
||||
$request->ip(), $request->userAgent(), null);
|
||||
|
||||
// QR-URL формата `otpauth://totp/...` — user сканирует через приложение.
|
||||
// По стандарту RFC 6238: issuer + label + secret + period.
|
||||
$qrUrl = $google2fa->getQRCodeUrl(
|
||||
@@ -121,6 +127,9 @@ class TwoFactorSetupController extends Controller
|
||||
|
||||
$request->session()->forget('auth.pending_totp_secret');
|
||||
|
||||
$this->logAuthEvent('2fa_setup_confirmed', $user->id, $user->tenant_id, $user->email,
|
||||
$request->ip(), $request->userAgent(), null);
|
||||
|
||||
return response()->json([
|
||||
'recovery_codes' => $plainCodes,
|
||||
'message' => '2FA включена. Сохраните резервные коды — они показываются один раз.',
|
||||
@@ -139,6 +148,9 @@ class TwoFactorSetupController extends Controller
|
||||
|
||||
$password = $request->string('password')->toString();
|
||||
if ($password === '' || ! Hash::check($password, $user->password_hash)) {
|
||||
$this->logAuthEvent('2fa_disable_failed', $user->id, $user->tenant_id, $user->email,
|
||||
$request->ip(), $request->userAgent(), 'invalid_password');
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Неверный пароль.',
|
||||
'errors' => ['password' => ['Неверный пароль.']],
|
||||
@@ -154,6 +166,9 @@ class TwoFactorSetupController extends Controller
|
||||
UserRecoveryCode::query()->where('user_id', $user->id)->delete();
|
||||
});
|
||||
|
||||
$this->logAuthEvent('2fa_disabled', $user->id, $user->tenant_id, $user->email,
|
||||
$request->ip(), $request->userAgent(), null);
|
||||
|
||||
return response()->json(['message' => '2FA отключена.']);
|
||||
}
|
||||
|
||||
@@ -187,6 +202,9 @@ class TwoFactorSetupController extends Controller
|
||||
return $this->generateRecoveryCodes($user->id);
|
||||
});
|
||||
|
||||
$this->logAuthEvent('2fa_recovery_regenerated', $user->id, $user->tenant_id, $user->email,
|
||||
$request->ip(), $request->userAgent(), null);
|
||||
|
||||
return response()->json([
|
||||
'recovery_codes' => $plainCodes,
|
||||
'message' => 'Резервные коды перегенерированы.',
|
||||
|
||||
@@ -6,11 +6,13 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\OutboundWebhookSubscription;
|
||||
use App\Support\WebhookUrlGuard;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
@@ -53,6 +55,16 @@ class WebhookSettingsController extends Controller
|
||||
'target_url' => ['required', 'string', 'url', 'max:2048', 'starts_with:https://'],
|
||||
]);
|
||||
|
||||
// SSRF-гард на сохранении: не даём записать URL во внутреннюю/служебную
|
||||
// сеть — тогда любой будущий потребитель (test() + будущая outbound-доставка
|
||||
// событий) читает из БД только безопасные адреса. NB: будущая доставка
|
||||
// обязана ВДОБАВОК звать WebhookUrlGuard перед отправкой (защита от
|
||||
// DNS-rebinding: хост сохранён публичным, позже переразрешается в приватный).
|
||||
$blockReason = WebhookUrlGuard::blockReason($validated['target_url']);
|
||||
if ($blockReason !== null) {
|
||||
throw ValidationException::withMessages(['target_url' => [$blockReason]]);
|
||||
}
|
||||
|
||||
$sub = $this->currentSubscription($request);
|
||||
$plainSecret = null;
|
||||
|
||||
@@ -95,14 +107,25 @@ class WebhookSettingsController extends Controller
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
// SSRF-гард: target_url задаёт админ тенанта; блокируем адреса во
|
||||
// внутренней/зарезервированной сети (cloud-metadata 169.254.169.254,
|
||||
// loopback, RFC1918), которые https://-валидация на сохранении не ловит.
|
||||
$blockReason = WebhookUrlGuard::blockReason($sub->target_url);
|
||||
if ($blockReason !== null) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'status' => null,
|
||||
'message' => $blockReason,
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$testPayload = [
|
||||
'event' => 'webhook.test',
|
||||
'sent_at' => now()->toIso8601String(),
|
||||
'message' => 'Тестовая доставка webhook от Лидерра.',
|
||||
];
|
||||
|
||||
// MVP: unsigned connectivity-проверка. SSRF-харднинг (блок приватных
|
||||
// IP) — пост-MVP security-review; URL уже ограничен https:// валидацией.
|
||||
// Unsigned connectivity-проверка (HMAC-подписанная доставка — отдельный эпик).
|
||||
try {
|
||||
$response = Http::timeout(10)
|
||||
->withHeaders(['X-Webhook-Event' => 'webhook.test'])
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Concerns;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Запись в auth_log (защищён hash-chain тригером).
|
||||
* Используется в AuthController, TwoFactorController,
|
||||
* TwoFactorSetupController, PasswordResetController — единственная
|
||||
* точка записи auth-событий.
|
||||
*
|
||||
* Канонические event-strings (расширяемо):
|
||||
* login_success, login_failed, logout, register_success,
|
||||
* 2fa_verify_success, 2fa_verify_failed, 2fa_recovery_used, 2fa_recovery_failed,
|
||||
* 2fa_setup_init, 2fa_setup_confirmed, 2fa_disabled, 2fa_recovery_regenerated,
|
||||
* password_reset_requested, password_reset_completed, password_reset_failed
|
||||
*/
|
||||
trait WritesAuthLog
|
||||
{
|
||||
protected function logAuthEvent(
|
||||
string $event,
|
||||
?int $userId,
|
||||
?int $tenantId,
|
||||
?string $email,
|
||||
?string $ip,
|
||||
?string $userAgent,
|
||||
?string $failureReason,
|
||||
): void {
|
||||
DB::table('auth_log')->insert([
|
||||
'actor_type' => 'tenant_user',
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => $userId,
|
||||
'email' => $email,
|
||||
'event' => $event,
|
||||
'ip_address' => $ip,
|
||||
'user_agent' => $userAgent,
|
||||
'failure_reason' => $failureReason,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ class BulkProjectActionRequest extends FormRequest
|
||||
|
||||
$rules = [
|
||||
'action' => ['required', Rule::in([
|
||||
'pause', 'resume', 'archive',
|
||||
'pause', 'resume', 'delete',
|
||||
'update_regions', 'update_days', 'update_limit',
|
||||
])],
|
||||
'ids' => ['nullable', 'array', 'max:500'],
|
||||
@@ -28,7 +28,7 @@ class BulkProjectActionRequest extends FormRequest
|
||||
'scope' => ['nullable', 'array'],
|
||||
'scope.filter' => ['nullable', 'array'],
|
||||
'scope.filter.signal_type' => ['nullable', 'string', Rule::in(['site', 'call', 'sms'])],
|
||||
'scope.filter.status' => ['nullable', 'string', Rule::in(['active', 'paused', 'archived'])],
|
||||
'scope.filter.status' => ['nullable', 'string', Rule::in(['active', 'paused'])],
|
||||
'scope.filter.search' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
|
||||
|
||||
@@ -13,9 +13,6 @@ class ProjectResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
/** @var Project $project */
|
||||
$project = $this->resource;
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
@@ -28,7 +25,6 @@ class ProjectResource extends JsonResource
|
||||
'delivered_today' => $this->delivered_today,
|
||||
'delivered_in_month' => $this->delivered_in_month,
|
||||
'is_active' => $this->is_active,
|
||||
'archived_at' => $project->archived_at?->toIso8601String(),
|
||||
'region_mask' => $this->region_mask,
|
||||
'region_mode' => $this->region_mode,
|
||||
'regions' => $this->regions,
|
||||
|
||||
@@ -15,6 +15,7 @@ use App\Models\SystemSetting;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\DuplicateDetector;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\Pd\PdAuditLogger;
|
||||
use App\Services\SupplierResolver;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
@@ -155,6 +156,12 @@ class ProcessWebhookJob implements ShouldQueue
|
||||
],
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
app(PdAuditLogger::class)->record(
|
||||
action: 'created', subjectType: 'lead', subjectId: $deal->id,
|
||||
purpose: 'lead_create_webhook', tenantId: (int) $deal->tenant_id,
|
||||
actorTenantUserId: null, actorAdminUserId: null, ip: null,
|
||||
);
|
||||
}
|
||||
|
||||
private function logRejection(Tenant $tenant, string $reason): void
|
||||
@@ -238,6 +245,12 @@ class ProcessWebhookJob implements ShouldQueue
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
app(PdAuditLogger::class)->record(
|
||||
action: 'created', subjectType: 'lead', subjectId: $deal->id,
|
||||
purpose: 'lead_create_webhook', tenantId: (int) $deal->tenant_id,
|
||||
actorTenantUserId: null, actorAdminUserId: null, ip: null,
|
||||
);
|
||||
|
||||
// Уведомление о новом лиде (ТЗ §18.5). Отправляется ПОСЛЕ всех записей
|
||||
// в БД, чтобы при ошибке отправки транзакция уже была зафиксирована.
|
||||
// NotificationService сам ловит Throwable от Mail::send и логирует —
|
||||
|
||||
@@ -15,6 +15,7 @@ use App\Services\DuplicateDetector;
|
||||
use App\Services\LeadDistributor;
|
||||
use App\Services\LeadRouter;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\Pd\PdAuditLogger;
|
||||
use App\Services\RegionTagResolver;
|
||||
use App\Services\SupplierProjects\SupplierProjectResolver;
|
||||
use Illuminate\Bus\Queueable;
|
||||
@@ -91,7 +92,20 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
LeadDistributor $distributor,
|
||||
RegionTagResolver $tagResolver,
|
||||
): void {
|
||||
$lead = SupplierLead::findOrFail($this->supplierLeadId);
|
||||
$lead = SupplierLead::find($this->supplierLeadId);
|
||||
|
||||
// Терминальный случай: лид удалён/не существует — это НЕ транзиентная ошибка,
|
||||
// повтор бессмыслен. НЕ бросаем ModelNotFoundException: иначе queue->failed()
|
||||
// пишет строку в failed_webhook_jobs, а RetryFailedSupplierJobsCommand
|
||||
// бесконечно перезапускает job (retry-шторм, инцидент 21-22.05.2026 —
|
||||
// 25k+ записей по удалённому лиду №1).
|
||||
if ($lead === null) {
|
||||
Log::warning('supplier_lead.not_found_terminal', [
|
||||
'supplier_lead_id' => $this->supplierLeadId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Idempotency guard для retry-сценария ($tries = 3).
|
||||
// Если лид уже обработан — выходим, не создаём ghost duplicate'ы deal'ов.
|
||||
@@ -282,6 +296,12 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
app(PdAuditLogger::class)->record(
|
||||
action: 'created', subjectType: 'lead', subjectId: $deal->id,
|
||||
purpose: 'lead_create_supplier', tenantId: (int) $deal->tenant_id,
|
||||
actorTenantUserId: null, actorAdminUserId: null, ip: null,
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -304,6 +324,12 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
app(PdAuditLogger::class)->record(
|
||||
action: 'created', subjectType: 'lead', subjectId: $deal->id,
|
||||
purpose: 'lead_create_supplier', tenantId: (int) $deal->tenant_id,
|
||||
actorTenantUserId: null, actorAdminUserId: null, ip: null,
|
||||
);
|
||||
|
||||
// ProcessWebhookJob-pattern: setRelation чтобы NotificationService
|
||||
// мог подтянуть deal->project без N+1 lookup'а под RLS.
|
||||
$deal->setRelation('project', $project);
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs\Supplier;
|
||||
|
||||
use App\Models\SupplierProject;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Удаление/пере-синк доноров у поставщика после удаления Лидерра-проекта.
|
||||
*
|
||||
* Для каждого supplier_project S (донора), к которому был привязан удалённый проект:
|
||||
* - остались другие потребители (project_supplier_links) → донор нужен другим клиентам:
|
||||
* НЕ удаляем у поставщика, пере-синкаем агрегат (SyncSupplierProjectsJob).
|
||||
* - потребителей не осталось → удаляем у поставщика (deleteProject) + локальную запись S.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-21-project-delete-dedup-errors-design.md §Решение 2.
|
||||
*/
|
||||
class DeleteSupplierProjectJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public int $backoff = 60;
|
||||
|
||||
public const string DB_CONNECTION = 'pgsql_supplier';
|
||||
|
||||
/** @param array<int,int> $supplierProjectIds */
|
||||
public function __construct(public array $supplierProjectIds) {}
|
||||
|
||||
public function handle(SupplierPortalClient $client): void
|
||||
{
|
||||
$needsResync = false;
|
||||
|
||||
foreach ($this->supplierProjectIds as $id) {
|
||||
$sp = SupplierProject::on(self::DB_CONNECTION)->find($id);
|
||||
if ($sp === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$remaining = DB::connection(self::DB_CONNECTION)
|
||||
->table('project_supplier_links')
|
||||
->where('supplier_project_id', $id)
|
||||
->count();
|
||||
|
||||
if ($remaining > 0) {
|
||||
$needsResync = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($sp->supplier_external_id !== null && $sp->supplier_external_id !== '') {
|
||||
try {
|
||||
$client->deleteProject((int) $sp->supplier_external_id);
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('supplier.delete_donor_failed', [
|
||||
'supplier_project_id' => $id, 'error' => $e->getMessage(),
|
||||
]);
|
||||
throw $e; // retry the job
|
||||
}
|
||||
}
|
||||
|
||||
$sp->delete();
|
||||
}
|
||||
|
||||
if ($needsResync) {
|
||||
SyncSupplierProjectsJob::dispatch();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ use Throwable;
|
||||
* (расписание перенесено 20:30 → 18:00, см. routes/console.php).
|
||||
*
|
||||
* Алгоритм (план 3 Task 5 → переработан: one-group-per-identifier):
|
||||
* 1. Загрузить активные Лидерра-projects (is_active=true, archived_at IS NULL).
|
||||
* 1. Загрузить активные Лидерра-projects (is_active=true).
|
||||
* 2. Сгруппировать по (signal_type, identifier) — БЕЗ subject_code:
|
||||
* - identifier = buildUniqueKeyAgnostic() (site/call → signal_identifier; sms+keyword → sender+keyword; sms → sender).
|
||||
* - platforms = resolvePlatforms() (site/call → B1+B2+B3; sms+keyword → B2+B3; sms → B3).
|
||||
@@ -86,7 +86,6 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
/** @var Collection<int, Project> $projects */
|
||||
$projects = Project::on(self::DB_CONNECTION)
|
||||
->where('is_active', true)
|
||||
->whereNull('archived_at')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
@@ -211,6 +210,10 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
$eligibleLimits = array_map(fn (Project $p) => (int) $p->daily_limit_target, $eligible);
|
||||
$order = SupplierQuotaAllocator::computeOrder($eligibleLimits);
|
||||
|
||||
// Split the group order across platforms so Σ per-platform == order. The portal does
|
||||
// NOT divide (verified live 2026-05-21) — the full order on each B = order ×N overspend.
|
||||
$shares = SupplierQuotaAllocator::distributeForPlatform($order, $platforms);
|
||||
|
||||
$workdaysUnion = [];
|
||||
foreach ($eligible as $p) {
|
||||
foreach ($this->bitmaskToList((int) $p->delivery_days_mask, 7) as $d) {
|
||||
@@ -236,24 +239,25 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
->get();
|
||||
|
||||
if ($existingSps->isEmpty()) {
|
||||
// Create path: saveProjectMultiFlag → [platform => external_id]
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: $platforms[0],
|
||||
signalType: $signalType,
|
||||
uniqueKey: $identifier,
|
||||
limit: $order,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: $platforms,
|
||||
);
|
||||
|
||||
$idMap = $this->client->saveProjectMultiFlag($dto);
|
||||
|
||||
// Upsert supplier_projects rows (one per platform)
|
||||
// Create path: one save PER platform with that platform's divided share
|
||||
// (single-flag save → exactly one rt-project, reliable id via listProjects match).
|
||||
// Throws propagate to handle() catch (failover-counter); rows persisted for earlier
|
||||
// platforms before a throw are recovered next run via the missing-set recovery below.
|
||||
foreach ($platforms as $platform) {
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: $platform,
|
||||
signalType: $signalType,
|
||||
uniqueKey: $identifier,
|
||||
limit: $shares[$platform] ?? 0,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: [$platform],
|
||||
);
|
||||
|
||||
$idMap = $this->client->saveProjectMultiFlag($dto);
|
||||
$externalId = $idMap[$platform] ?? null;
|
||||
if ($externalId === null) {
|
||||
continue;
|
||||
@@ -265,7 +269,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
'unique_key' => $identifier,
|
||||
'subject_code' => null,
|
||||
'supplier_external_id' => (string) $externalId,
|
||||
'current_limit' => $order,
|
||||
'current_limit' => $shares[$platform] ?? 0,
|
||||
'current_workdays' => $workdays,
|
||||
'current_regions' => $allRegions,
|
||||
'sync_status' => 'ok',
|
||||
@@ -282,6 +286,50 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
$existingSps->push($sp);
|
||||
}
|
||||
} else {
|
||||
// External-deletion recovery: донор мог быть удалён на портале → external_id
|
||||
// в нашей БД мёртв, updateProject его молча no-op'ит. Сверяемся со списком живых
|
||||
// проектов портала и пересоздаём недостающих in-place (НЕ удаляя записи — на них
|
||||
// могут висеть лиды/списания). Throws пропагируют в outer handle() catch
|
||||
// (SupplierAuth/Transient/Client) — failover-counter semantics сохраняется.
|
||||
$livePortalIds = collect($this->client->listProjects())
|
||||
->map(fn ($p) => (string) ($p['id'] ?? ''))
|
||||
->filter()
|
||||
->all();
|
||||
|
||||
$deadSps = $existingSps->filter(
|
||||
fn (SupplierProject $sp) => $sp->supplier_external_id !== null
|
||||
&& ! in_array((string) $sp->supplier_external_id, $livePortalIds, true)
|
||||
);
|
||||
|
||||
if ($deadSps->isNotEmpty()) {
|
||||
foreach ($deadSps as $sp) {
|
||||
$recreateDto = new SupplierProjectDto(
|
||||
platform: $sp->platform,
|
||||
signalType: $signalType,
|
||||
uniqueKey: $identifier,
|
||||
limit: $shares[$sp->platform] ?? 0,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: [$sp->platform],
|
||||
);
|
||||
|
||||
$recreatedIdMap = $this->client->saveProjectMultiFlag($recreateDto);
|
||||
$newId = $recreatedIdMap[$sp->platform] ?? null;
|
||||
if ($newId !== null) {
|
||||
$sp->forceFill(['supplier_external_id' => (string) $newId])->save();
|
||||
SupplierSyncLog::on(self::DB_CONNECTION)->create([
|
||||
'supplier_project_id' => $sp->id,
|
||||
'action' => 'create',
|
||||
'http_status' => 200,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fix #3 (review-followup): partial-set recovery — если предыдущий run создал
|
||||
// не все platforms (e.g. B1+B2 OK, B3 escalated), re-attempt missing via multi-flag
|
||||
// save с platforms=$missingPlatforms. Throws пропагируют в outer handle() catch
|
||||
@@ -290,22 +338,21 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
$missingPlatforms = array_values(array_diff($platforms, $existingPlatforms));
|
||||
|
||||
if ($missingPlatforms !== []) {
|
||||
$missingDto = new SupplierProjectDto(
|
||||
platform: $missingPlatforms[0],
|
||||
signalType: $signalType,
|
||||
uniqueKey: $identifier,
|
||||
limit: $order,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: $missingPlatforms,
|
||||
);
|
||||
|
||||
$missingIdMap = $this->client->saveProjectMultiFlag($missingDto);
|
||||
|
||||
foreach ($missingPlatforms as $platform) {
|
||||
$missingDto = new SupplierProjectDto(
|
||||
platform: $platform,
|
||||
signalType: $signalType,
|
||||
uniqueKey: $identifier,
|
||||
limit: $shares[$platform] ?? 0,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: [$platform],
|
||||
);
|
||||
|
||||
$missingIdMap = $this->client->saveProjectMultiFlag($missingDto);
|
||||
$externalId = $missingIdMap[$platform] ?? null;
|
||||
if ($externalId === null) {
|
||||
continue;
|
||||
@@ -316,7 +363,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
'unique_key' => $identifier,
|
||||
'subject_code' => null,
|
||||
'supplier_external_id' => (string) $externalId,
|
||||
'current_limit' => $order,
|
||||
'current_limit' => $shares[$platform] ?? 0,
|
||||
'current_workdays' => $workdays,
|
||||
'current_regions' => $allRegions,
|
||||
'sync_status' => 'ok',
|
||||
@@ -332,9 +379,9 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
// Fix #2 (review-followup): per-platform DTO в update-loop, чтобы portal получал
|
||||
// правильные srcrt/srcbl/srcmt для конкретной редактируемой строки (не first()
|
||||
// из mixed-platform existing set). R6 one shared limit/regions сохраняется.
|
||||
// per-platform DTO в update-loop: portal получает правильные srcrt/srcbl/srcmt для
|
||||
// конкретной строки + её долю лимита ($shares), чтобы Σ по площадкам == order
|
||||
// (а не order на каждой). Regions/workdays общие для группы.
|
||||
foreach ($existingSps as $sp) {
|
||||
if ($sp->supplier_external_id === null) {
|
||||
continue;
|
||||
@@ -343,7 +390,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
platform: $sp->platform,
|
||||
signalType: $signalType,
|
||||
uniqueKey: $identifier,
|
||||
limit: $order,
|
||||
limit: $shares[$sp->platform] ?? 0,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
@@ -353,7 +400,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
);
|
||||
$this->channel->updateProject((int) $sp->supplier_external_id, $perPlatformDto);
|
||||
$sp->forceFill([
|
||||
'current_limit' => $order,
|
||||
'current_limit' => $shares[$sp->platform] ?? 0,
|
||||
'current_workdays' => $workdays,
|
||||
'current_regions' => $allRegions,
|
||||
'sync_status' => 'ok',
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Services\Supplier\Dto\SupplierProjectDto;
|
||||
use App\Services\Supplier\SupplierExportMode;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
use App\Services\Supplier\SupplierProjectGrouping;
|
||||
use App\Services\Supplier\SupplierQuotaAllocator;
|
||||
use App\Support\RussianRegions;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
@@ -60,11 +61,23 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
/** @var array<int, int> */
|
||||
public array $backoff = [15, 60, 300];
|
||||
|
||||
/**
|
||||
* BYPASSRLS-роль crm_supplier_worker для всех DB-операций (как у всех supplier-flow
|
||||
* джобов: SyncSupplierProjectsJob/DeleteSupplierProjectJob/CsvReconcileJob/…).
|
||||
*
|
||||
* Джоб запускается из очереди, где SetTenantContext-прослойка не отрабатывает и
|
||||
* app.current_tenant_id GUC не установлен. Под обычной ролью crm_app_user первый же
|
||||
* SELECT по projects падает 42704 (unrecognized configuration parameter
|
||||
* "app.current_tenant_id"). На dev не всплывало — там DB_USERNAME=postgres (superuser,
|
||||
* RLS обходится). Plan 3 Task 3 learning.
|
||||
*/
|
||||
public const DB_CONNECTION = 'pgsql_supplier';
|
||||
|
||||
public function __construct(public int $projectId) {}
|
||||
|
||||
public function handle(SupplierProjectChannel $channel): void
|
||||
{
|
||||
$project = Project::find($this->projectId);
|
||||
$project = Project::on(self::DB_CONNECTION)->find($this->projectId);
|
||||
|
||||
if ($project === null) {
|
||||
Log::warning("SyncSupplierProjectJob: project {$this->projectId} not found — skipping");
|
||||
@@ -104,43 +117,22 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
|
||||
$workdays = $this->workdaysFromMask((int) $project->delivery_days_mask);
|
||||
|
||||
// Split the limit across the platforms so Σ per-platform limits == project limit.
|
||||
// The portal does NOT divide (verified live 2026-05-21) — replicating the full limit
|
||||
// to B1/B2/B3 = order ×N (overspend). See SupplierQuotaAllocator::distributeForPlatform.
|
||||
$shares = SupplierQuotaAllocator::distributeForPlatform((int) $project->daily_limit_target, $platforms);
|
||||
|
||||
// Idempotency: find existing by identifier regardless of subject_code (any previous run).
|
||||
$existingSps = SupplierProject::query()
|
||||
$existingSps = SupplierProject::on(self::DB_CONNECTION)
|
||||
->where('unique_key', $identifier)
|
||||
->where('signal_type', (string) $project->signal_type)
|
||||
->whereIn('platform', $platforms)
|
||||
->get();
|
||||
|
||||
if ($existingSps->isEmpty()) {
|
||||
// Create path: saveProjectMultiFlag → [platform => external_id]
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: $platforms[0],
|
||||
signalType: (string) $project->signal_type,
|
||||
uniqueKey: $identifier,
|
||||
limit: (int) $project->daily_limit_target,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: $platforms,
|
||||
);
|
||||
|
||||
try {
|
||||
$idMap = $client->saveProjectMultiFlag($dto);
|
||||
} catch (TierEscalatedException $e) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} escalated to manual queue #{$e->queueRowId}");
|
||||
|
||||
return;
|
||||
} catch (WindowDeferredException) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} deferred by portal window");
|
||||
|
||||
return;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("SyncSupplierProjectJob: online multi-flag save failed for project {$project->id} (".get_class($e).'): '.$e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
// Create path: one save PER platform with that platform's divided share
|
||||
// (single-flag save → exactly one rt-project, reliable id via listProjects match).
|
||||
$idMap = $this->createPerPlatform($client, $project, $identifier, $tag, $workdays, $allRegions, $shares, $platforms);
|
||||
|
||||
foreach ($platforms as $platform) {
|
||||
$externalId = $idMap[$platform] ?? null;
|
||||
@@ -148,13 +140,13 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
continue;
|
||||
}
|
||||
|
||||
$sp = SupplierProject::create([
|
||||
$sp = SupplierProject::on(self::DB_CONNECTION)->create([
|
||||
'platform' => $platform,
|
||||
'signal_type' => (string) $project->signal_type,
|
||||
'unique_key' => $identifier,
|
||||
'subject_code' => null,
|
||||
'supplier_external_id' => (string) $externalId,
|
||||
'current_limit' => (int) $project->daily_limit_target,
|
||||
'current_limit' => $shares[$platform] ?? 0,
|
||||
'current_workdays' => $workdays,
|
||||
'current_regions' => $allRegions,
|
||||
'sync_status' => 'ok',
|
||||
@@ -164,49 +156,52 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
$existingSps->push($sp);
|
||||
}
|
||||
} else {
|
||||
// External-deletion recovery: донор мог быть удалён на портале (вручную или
|
||||
// прошлым hard-delete). Тогда external_id в нашей БД мёртв, а updateProject
|
||||
// такого id портал молча принимает (no-op) — донор не пересоздаётся. Поэтому
|
||||
// сверяемся со списком живых проектов портала и пересоздаём недостающих
|
||||
// in-place (НЕ удаляя записи — на supplier_project могут висеть лиды/списания).
|
||||
$livePortalIds = collect($client->listProjects())
|
||||
->map(fn ($p) => (string) ($p['id'] ?? ''))
|
||||
->filter()
|
||||
->all();
|
||||
|
||||
$deadSps = $existingSps->filter(
|
||||
fn (SupplierProject $sp) => $sp->supplier_external_id !== null
|
||||
&& ! in_array((string) $sp->supplier_external_id, $livePortalIds, true)
|
||||
);
|
||||
|
||||
if ($deadSps->isNotEmpty()) {
|
||||
$deadPlatforms = array_values($deadSps->pluck('platform')->all());
|
||||
$recreatedIdMap = $this->createPerPlatform($client, $project, $identifier, $tag, $workdays, $allRegions, $shares, $deadPlatforms);
|
||||
|
||||
foreach ($deadSps as $sp) {
|
||||
$newId = $recreatedIdMap[$sp->platform] ?? null;
|
||||
if ($newId !== null) {
|
||||
$sp->forceFill(['supplier_external_id' => (string) $newId])->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Partial-set recovery: если предыдущий run создал не все platforms.
|
||||
$existingPlatforms = $existingSps->pluck('platform')->all();
|
||||
$missingPlatforms = array_values(array_diff($platforms, $existingPlatforms));
|
||||
|
||||
if ($missingPlatforms !== []) {
|
||||
$missingDto = new SupplierProjectDto(
|
||||
platform: $missingPlatforms[0],
|
||||
signalType: (string) $project->signal_type,
|
||||
uniqueKey: $identifier,
|
||||
limit: (int) $project->daily_limit_target,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: $missingPlatforms,
|
||||
);
|
||||
|
||||
try {
|
||||
$missingIdMap = $client->saveProjectMultiFlag($missingDto);
|
||||
} catch (TierEscalatedException $e) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} missing-platform re-attempt escalated #{$e->queueRowId}");
|
||||
$missingIdMap = [];
|
||||
} catch (WindowDeferredException) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} missing-platform deferred by portal window");
|
||||
$missingIdMap = [];
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("SyncSupplierProjectJob: missing-platform multi-flag failed for project {$project->id}: ".$e->getMessage());
|
||||
$missingIdMap = [];
|
||||
}
|
||||
$missingIdMap = $this->createPerPlatform($client, $project, $identifier, $tag, $workdays, $allRegions, $shares, $missingPlatforms);
|
||||
|
||||
foreach ($missingPlatforms as $platform) {
|
||||
$externalId = $missingIdMap[$platform] ?? null;
|
||||
if ($externalId === null) {
|
||||
continue;
|
||||
}
|
||||
$sp = SupplierProject::create([
|
||||
$sp = SupplierProject::on(self::DB_CONNECTION)->create([
|
||||
'platform' => $platform,
|
||||
'signal_type' => (string) $project->signal_type,
|
||||
'unique_key' => $identifier,
|
||||
'subject_code' => null,
|
||||
'supplier_external_id' => (string) $externalId,
|
||||
'current_limit' => (int) $project->daily_limit_target,
|
||||
'current_limit' => $shares[$platform] ?? 0,
|
||||
'current_workdays' => $workdays,
|
||||
'current_regions' => $allRegions,
|
||||
'sync_status' => 'ok',
|
||||
@@ -225,7 +220,7 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
platform: $sp->platform,
|
||||
signalType: (string) $project->signal_type,
|
||||
uniqueKey: $identifier,
|
||||
limit: (int) $project->daily_limit_target,
|
||||
limit: $shares[$sp->platform] ?? 0,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
@@ -235,7 +230,7 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
);
|
||||
$channel->updateProject((int) $sp->supplier_external_id, $perPlatformDto);
|
||||
$sp->forceFill([
|
||||
'current_limit' => (int) $project->daily_limit_target,
|
||||
'current_limit' => $shares[$sp->platform] ?? 0,
|
||||
'current_workdays' => $workdays,
|
||||
'current_regions' => $allRegions,
|
||||
'sync_status' => 'ok',
|
||||
@@ -246,13 +241,22 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
|
||||
// Pivot: project × each supplier_project → ON CONFLICT DO NOTHING
|
||||
foreach ($existingSps as $sp) {
|
||||
DB::table('project_supplier_links')->insertOrIgnore([
|
||||
DB::connection(self::DB_CONNECTION)->table('project_supplier_links')->insertOrIgnore([
|
||||
'project_id' => $project->id,
|
||||
'supplier_project_id' => $sp->id,
|
||||
'platform' => $sp->platform,
|
||||
'subject_code' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
// Mirror the link into the legacy FK columns (supplier_b{1,2,3}_project_id) so the
|
||||
// UI sync-status (ProjectResource → aggregateSyncStatus, which reads supplierB1/B2/B3)
|
||||
// reflects the synced stack in online mode too — online primarily uses the pivot.
|
||||
foreach ($existingSps as $sp) {
|
||||
$column = 'supplier_'.strtolower((string) $sp->platform).'_project_id';
|
||||
$project->{$column} = $sp->id;
|
||||
}
|
||||
$project->save();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -269,7 +273,7 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
$column = 'supplier_'.strtolower($platform).'_project_id';
|
||||
|
||||
// Idempotency: local supplier_projects-запись уже есть?
|
||||
$existing = SupplierProject::query()
|
||||
$existing = SupplierProject::on(self::DB_CONNECTION)
|
||||
->where('platform', $platform)
|
||||
->where('signal_type', $project->signal_type)
|
||||
->where('unique_key', $uniqueKey)
|
||||
@@ -306,7 +310,7 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
continue;
|
||||
}
|
||||
|
||||
$sp = SupplierProject::query()->create([
|
||||
$sp = SupplierProject::on(self::DB_CONNECTION)->create([
|
||||
'platform' => $platform,
|
||||
'signal_type' => $project->signal_type,
|
||||
'unique_key' => $uniqueKey,
|
||||
@@ -323,6 +327,68 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
$project->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт проекты на портале ПО ОДНОМУ на платформу с её долей лимита ($shares).
|
||||
*
|
||||
* Один single-flag save = ровно один rt-проект → надёжный id через listProjects-матч.
|
||||
* Так per-platform лимит = доля (Σ == заказу), а не полный лимит на каждой площадке.
|
||||
* Per-platform tolerance: tier-escalation / window-defer / прочая ошибка одной площадки
|
||||
* не валит остальные — пропускаем, следующий run (или ночной батч) подберёт недостающее.
|
||||
*
|
||||
* @param array<string, int> $shares [platform => лимит площадки]
|
||||
* @param list<string> $platformsToCreate
|
||||
* @return array<string, int> [platform => external_id] для успешно созданных
|
||||
*/
|
||||
private function createPerPlatform(
|
||||
SupplierPortalClient $client,
|
||||
Project $project,
|
||||
string $identifier,
|
||||
string $tag,
|
||||
array $workdays,
|
||||
array $allRegions,
|
||||
array $shares,
|
||||
array $platformsToCreate,
|
||||
): array {
|
||||
$idMap = [];
|
||||
|
||||
foreach ($platformsToCreate as $platform) {
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: $platform,
|
||||
signalType: (string) $project->signal_type,
|
||||
uniqueKey: $identifier,
|
||||
limit: $shares[$platform] ?? 0,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: [$platform],
|
||||
);
|
||||
|
||||
try {
|
||||
$result = $client->saveProjectMultiFlag($dto);
|
||||
} catch (TierEscalatedException $e) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} {$platform} escalated to manual queue #{$e->queueRowId}");
|
||||
|
||||
continue;
|
||||
} catch (WindowDeferredException) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} {$platform} deferred by portal window");
|
||||
|
||||
continue;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("SyncSupplierProjectJob: online per-platform save failed for project {$project->id} {$platform} (".get_class($e).'): '.$e->getMessage());
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($result[$platform])) {
|
||||
$idMap[$platform] = $result[$platform];
|
||||
}
|
||||
}
|
||||
|
||||
return $idMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bitmask → ISO weekday list. bit 0 = Mon (ISO 1) … bit 6 = Sun (ISO 7).
|
||||
*
|
||||
|
||||
@@ -40,8 +40,6 @@ class Project extends Model
|
||||
'tag',
|
||||
'type',
|
||||
'is_active',
|
||||
// Plan 5 Task 1 (schema v8.20): soft archive flow — lifecycle-state рядом с is_active.
|
||||
'archived_at',
|
||||
'daily_limit_target',
|
||||
'effective_daily_limit_today',
|
||||
'effective_limit_calculated_at',
|
||||
@@ -87,8 +85,6 @@ class Project extends Model
|
||||
'sms_senders' => 'array',
|
||||
'delivered_in_month' => 'integer',
|
||||
'delivered_today' => 'integer',
|
||||
// Plan 5 Task 1 (schema v8.20): soft archive.
|
||||
'archived_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -151,33 +147,6 @@ class Project extends Model
|
||||
return $query->where('signal_type', $signalType)->where('signal_identifier', $identifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Не архивированные проекты (archived_at IS NULL).
|
||||
*
|
||||
* Внимание: scope не фильтрует is_active. Приостановленные (is_active=false)
|
||||
* проекты сюда попадают — это разные lifecycle-состояния. Если нужны только
|
||||
* «работающие» (не архив И не на паузе) — комбинируйте:
|
||||
* ->active()->where('is_active', true).
|
||||
*
|
||||
* @param Builder<Project> $query
|
||||
* @return Builder<Project>
|
||||
*/
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNull('archived_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* Архивированные проекты (archived_at IS NOT NULL).
|
||||
*
|
||||
* @param Builder<Project> $query
|
||||
* @return Builder<Project>
|
||||
*/
|
||||
public function scopeArchived(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNotNull('archived_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* Все связанные SupplierProject из eager-loaded BelongsTo отношений.
|
||||
*
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Models\ImportUnknownStatus;
|
||||
use App\Models\Project;
|
||||
use App\Models\Reminder;
|
||||
use App\Services\MonthlyPartitionManager;
|
||||
use App\Services\Pd\PdAuditLogger;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
@@ -26,6 +27,7 @@ final class HistoricalImportService
|
||||
public function __construct(
|
||||
private readonly MonthlyPartitionManager $partitions,
|
||||
private readonly StatusRuToSlugMapper $statusMapper,
|
||||
private readonly PdAuditLogger $pdLog,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -68,7 +70,7 @@ final class HistoricalImportService
|
||||
}
|
||||
|
||||
try {
|
||||
$wasCreated = $this->upsertRow($tenantId, $userId, $row, $slug);
|
||||
$wasCreated = $this->upsertRow($tenantId, $userId, $row, $slug, $log->id);
|
||||
$wasCreated ? $added++ : $updated++;
|
||||
} catch (Throwable $e) {
|
||||
$skipped++;
|
||||
@@ -132,9 +134,9 @@ final class HistoricalImportService
|
||||
* Идемпотентный upsert одной строки в собственной транзакции.
|
||||
* Возвращает true — создана новая сделка, false — обновлена существующая.
|
||||
*/
|
||||
private function upsertRow(int $tenantId, int $userId, ParsedLeadRow $row, string $slug): bool
|
||||
private function upsertRow(int $tenantId, int $userId, ParsedLeadRow $row, string $slug, int $importLogId): bool
|
||||
{
|
||||
return DB::transaction(function () use ($tenantId, $userId, $row, $slug): bool {
|
||||
return DB::transaction(function () use ($tenantId, $userId, $row, $slug, $importLogId): bool {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
$project = Project::firstOrCreate(
|
||||
@@ -188,6 +190,17 @@ final class HistoricalImportService
|
||||
|
||||
$this->syncReminder($tenantId, $userId, $deal, $row);
|
||||
|
||||
$this->pdLog->record(
|
||||
action: 'created',
|
||||
subjectType: 'lead',
|
||||
subjectId: $deal->id,
|
||||
purpose: 'lead_create_import_'.$importLogId,
|
||||
tenantId: $tenantId,
|
||||
actorTenantUserId: $userId,
|
||||
actorAdminUserId: null,
|
||||
ip: null,
|
||||
);
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Pd;
|
||||
|
||||
use App\Models\ImpersonationToken;
|
||||
use App\Models\SaasAdminAuditLog;
|
||||
|
||||
/**
|
||||
* Оркестратор аудита impersonation: пишет защищённый saas_admin_audit_log
|
||||
* на init/verify/end и ПДн-след (pd_processing_log) на verify — вход админа
|
||||
* в кабинет тенанта = массовый доступ к ПДн (152-ФЗ).
|
||||
*/
|
||||
final class ImpersonationAuditService
|
||||
{
|
||||
public function __construct(private readonly PdAuditLogger $pd) {}
|
||||
|
||||
public function recordInit(ImpersonationToken $t, int $adminId, ?string $ip): void
|
||||
{
|
||||
SaasAdminAuditLog::create([
|
||||
'admin_user_id' => $adminId,
|
||||
'action' => 'impersonation.init',
|
||||
'target_type' => 'tenant',
|
||||
'target_id' => $t->tenant_id,
|
||||
'target_tenant_id' => $t->tenant_id,
|
||||
'payload_before' => null,
|
||||
'payload_after' => ['token_id' => $t->id, 'expires_at' => $t->expires_at->toIso8601String()],
|
||||
'reason' => $t->reason,
|
||||
'ip_address' => $ip ?? '127.0.0.1',
|
||||
'user_agent' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function recordVerify(ImpersonationToken $t, int $adminId, ?string $ip): void
|
||||
{
|
||||
SaasAdminAuditLog::create([
|
||||
'admin_user_id' => $adminId,
|
||||
'action' => 'impersonation.verify',
|
||||
'target_type' => 'tenant',
|
||||
'target_id' => $t->tenant_id,
|
||||
'target_tenant_id' => $t->tenant_id,
|
||||
'payload_before' => ['used_at' => null],
|
||||
'payload_after' => ['used_at' => now()->toIso8601String()],
|
||||
'reason' => $t->reason,
|
||||
'ip_address' => $ip ?? '127.0.0.1',
|
||||
'user_agent' => null,
|
||||
]);
|
||||
// ПДн-след: вход админа в кабинет = массовый доступ к ПДн тенанта.
|
||||
$this->pd->record(
|
||||
action: 'viewed', subjectType: 'tenant', subjectId: $t->tenant_id,
|
||||
purpose: 'impersonation_session_'.$t->id,
|
||||
tenantId: $t->tenant_id,
|
||||
actorTenantUserId: null, actorAdminUserId: $adminId, ip: $ip,
|
||||
);
|
||||
}
|
||||
|
||||
public function recordEnd(ImpersonationToken $t, int $adminId, ?string $ip): void
|
||||
{
|
||||
SaasAdminAuditLog::create([
|
||||
'admin_user_id' => $adminId,
|
||||
'action' => 'impersonation.end',
|
||||
'target_type' => 'tenant',
|
||||
'target_id' => $t->tenant_id,
|
||||
'target_tenant_id' => $t->tenant_id,
|
||||
'payload_before' => ['session_ended_at' => null],
|
||||
'payload_after' => ['session_ended_at' => now()->toIso8601String()],
|
||||
'reason' => $t->reason,
|
||||
'ip_address' => $ip ?? '127.0.0.1',
|
||||
'user_agent' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Pd;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Запись в pd_processing_log (152-ФЗ ст.18 ч.2). Hash-chain trigger
|
||||
* audit_chain_hash() автоматически заполняет log_hash; append-only
|
||||
* защита — триггер audit_block_mutation (UPDATE/DELETE заблокированы).
|
||||
*
|
||||
* chk_pd_actor: ровно один актор из tenant_user/admin, либо оба NULL
|
||||
* (системное действие — cron / триггер).
|
||||
*/
|
||||
final class PdAuditLogger
|
||||
{
|
||||
/** @param string $action one of 'created','viewed','updated','deleted','exported' */
|
||||
public function record(
|
||||
string $action,
|
||||
?string $subjectType,
|
||||
?int $subjectId,
|
||||
string $purpose,
|
||||
?int $tenantId,
|
||||
?int $actorTenantUserId,
|
||||
?int $actorAdminUserId,
|
||||
?string $ip,
|
||||
): void {
|
||||
DB::table('pd_processing_log')->insert([
|
||||
'tenant_id' => $tenantId,
|
||||
'subject_type' => $subjectType,
|
||||
'subject_id' => $subjectId,
|
||||
'action' => $action,
|
||||
'purpose' => $purpose,
|
||||
'actor_tenant_user_id' => $actorTenantUserId,
|
||||
'actor_admin_user_id' => $actorAdminUserId,
|
||||
'ip_address' => $ip,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Project;
|
||||
|
||||
use App\Jobs\Supplier\DeleteSupplierProjectJob;
|
||||
use App\Jobs\SyncSupplierProjectJob;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ProjectService
|
||||
{
|
||||
@@ -19,7 +21,6 @@ class ProjectService
|
||||
$data['tenant_id'], $data['signal_type'],
|
||||
$data['delivered_today'], $data['delivered_in_month'],
|
||||
$data['supplier_b1_project_id'], $data['supplier_b2_project_id'], $data['supplier_b3_project_id'],
|
||||
$data['archived_at'],
|
||||
);
|
||||
|
||||
if (isset($data['daily_limit_target']) && $data['daily_limit_target'] < $project->delivered_today) {
|
||||
@@ -41,6 +42,18 @@ class ProjectService
|
||||
|| array_key_exists('daily_limit_target', $data)
|
||||
|| array_key_exists('delivery_days_mask', $data);
|
||||
|
||||
if (array_key_exists('signal_identifier', $data) || array_key_exists('sms_senders', $data) || array_key_exists('sms_keyword', $data)) {
|
||||
$this->assertSourceUnique($project->tenant_id, array_merge([
|
||||
'signal_type' => $project->signal_type,
|
||||
'signal_identifier' => $project->signal_identifier,
|
||||
'sms_senders' => $project->sms_senders,
|
||||
'sms_keyword' => $project->sms_keyword,
|
||||
], $data), exceptId: $project->id);
|
||||
}
|
||||
if (array_key_exists('name', $data)) {
|
||||
$this->assertNameUnique($project->tenant_id, (string) $data['name'], exceptId: $project->id);
|
||||
}
|
||||
|
||||
$project->update($data);
|
||||
|
||||
if ($needsResync) {
|
||||
@@ -50,17 +63,26 @@ class ProjectService
|
||||
return $project->fresh();
|
||||
}
|
||||
|
||||
public function archive(Project $project): void
|
||||
public function delete(Project $project): void
|
||||
{
|
||||
if ($project->archived_at !== null) {
|
||||
$hasDeals = DB::table('deals')->where('project_id', $project->id)->exists();
|
||||
if ($hasDeals) {
|
||||
throw new HttpResponseException(response()->json([
|
||||
'message' => 'Project уже архивирован.',
|
||||
], 409));
|
||||
'errors' => ['project' => ['Нельзя удалить проект: по нему есть сделки. Поставьте приём на паузу, чтобы скрыть проект из работы.']],
|
||||
], 422));
|
||||
}
|
||||
|
||||
// Капчим доноров ДО удаления — pivot уйдёт каскадом.
|
||||
$supplierProjectIds = DB::table('project_supplier_links')
|
||||
->where('project_id', $project->id)
|
||||
->pluck('supplier_project_id')
|
||||
->all();
|
||||
|
||||
$project->delete(); // hard delete (Project без SoftDeletes); cascade чистит pivot + служебные.
|
||||
|
||||
if ($supplierProjectIds !== []) {
|
||||
DeleteSupplierProjectJob::dispatch(array_map('intval', $supplierProjectIds));
|
||||
}
|
||||
$project->update([
|
||||
'is_active' => false,
|
||||
'archived_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function triggerSync(Project $project): void
|
||||
@@ -83,9 +105,8 @@ class ProjectService
|
||||
}
|
||||
if (! empty($filter['status'])) {
|
||||
match ($filter['status']) {
|
||||
'active' => $query->where('is_active', true)->whereNull('archived_at'),
|
||||
'paused' => $query->where('is_active', false)->whereNull('archived_at'),
|
||||
'archived' => $query->whereNotNull('archived_at'),
|
||||
'active' => $query->where('is_active', true),
|
||||
'paused' => $query->where('is_active', false),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
@@ -108,7 +129,7 @@ class ProjectService
|
||||
return match ($action) {
|
||||
'pause' => $this->bulkSimpleUpdate($query, ['is_active' => false]),
|
||||
'resume' => $this->bulkSimpleUpdate($query, ['is_active' => true]),
|
||||
'archive' => $this->bulkSimpleUpdate($query, ['is_active' => false, 'archived_at' => now()]),
|
||||
'delete' => $this->bulkDelete($query),
|
||||
'update_regions' => $this->bulkUpdateRegions($query, $payload),
|
||||
'update_days' => $this->bulkUpdateDays($query, $payload),
|
||||
'update_limit' => $this->bulkUpdateLimit($query, $payload),
|
||||
@@ -122,6 +143,29 @@ class ProjectService
|
||||
return ['updated' => $updated, 'skipped' => [], 'warnings' => []];
|
||||
}
|
||||
|
||||
private function bulkDelete($query): array
|
||||
{
|
||||
$projects = (clone $query)->get(['id']);
|
||||
$deleted = 0;
|
||||
$skipped = [];
|
||||
|
||||
foreach ($projects as $p) {
|
||||
$model = Project::find($p->id);
|
||||
if ($model === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->delete($model);
|
||||
$deleted++;
|
||||
} catch (HttpResponseException) {
|
||||
$skipped[] = ['id' => $p->id, 'reason' => 'has_deals'];
|
||||
}
|
||||
}
|
||||
|
||||
return ['updated' => $deleted, 'skipped' => $skipped, 'warnings' => []];
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan 6.5: субъект-уровневый bulk-edit `regions` INT[].
|
||||
*
|
||||
@@ -213,10 +257,60 @@ class ProjectService
|
||||
return ['updated' => $updated, 'skipped' => $skipped, 'warnings' => []];
|
||||
}
|
||||
|
||||
private function assertNameUnique(int $tenantId, string $name, ?int $exceptId = null): void
|
||||
{
|
||||
$q = Project::where('tenant_id', $tenantId)->where('name', $name);
|
||||
if ($exceptId !== null) {
|
||||
$q->where('id', '!=', $exceptId);
|
||||
}
|
||||
if ($q->exists()) {
|
||||
throw new HttpResponseException(response()->json([
|
||||
'errors' => ['name' => ['Проект с таким названием у вас уже есть. Выберите другое название.']],
|
||||
], 422));
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
private function assertSourceUnique(int $tenantId, array $data, ?int $exceptId = null): void
|
||||
{
|
||||
$signalType = $data['signal_type'] ?? null;
|
||||
$q = Project::where('tenant_id', $tenantId)->where('signal_type', $signalType);
|
||||
if ($exceptId !== null) {
|
||||
$q->where('id', '!=', $exceptId);
|
||||
}
|
||||
|
||||
if (in_array($signalType, ['call', 'site'], true)) {
|
||||
$identifier = (string) ($data['signal_identifier'] ?? '');
|
||||
if ($identifier === '') {
|
||||
return;
|
||||
}
|
||||
$q->where('signal_identifier', $identifier);
|
||||
} elseif ($signalType === 'sms') {
|
||||
$senders = (array) ($data['sms_senders'] ?? []);
|
||||
$norm = collect($senders)->map(fn ($s) => mb_strtolower(trim((string) $s)))->sort()->values()->all();
|
||||
if ($norm === []) {
|
||||
return;
|
||||
}
|
||||
$keyword = $data['sms_keyword'] ?? null;
|
||||
$q->where('sms_keyword', $keyword)
|
||||
->whereJsonContains('sms_senders', $norm)
|
||||
->whereRaw('jsonb_array_length(sms_senders::jsonb) = ?', [count($norm)]);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
$existing = $q->first();
|
||||
if ($existing !== null) {
|
||||
throw new HttpResponseException(response()->json([
|
||||
'errors' => ['signal_identifier' => ["У вас уже есть проект с этим источником: «{$existing->name}»."]],
|
||||
], 422));
|
||||
}
|
||||
}
|
||||
|
||||
public function create(Tenant $tenant, array $data): Project
|
||||
{
|
||||
$limit = (int) ($tenant->limits['max_projects'] ?? 10);
|
||||
$current = Project::where('tenant_id', $tenant->id)->active()->count();
|
||||
$current = Project::where('tenant_id', $tenant->id)->count();
|
||||
if ($current >= $limit) {
|
||||
throw new HttpResponseException(response()->json([
|
||||
'message' => "Достигнут лимит проектов ({$limit}). Смените тариф.",
|
||||
@@ -230,6 +324,10 @@ class ProjectService
|
||||
// PhonePrefixService / LeadRouter, удаляются в Plan 6.5 после переключения читателей.
|
||||
$data['region_mask'] = 255;
|
||||
$data['region_mode'] = 'include';
|
||||
|
||||
$this->assertNameUnique($tenant->id, (string) $data['name']);
|
||||
$this->assertSourceUnique($tenant->id, $data);
|
||||
|
||||
$project = Project::create($data);
|
||||
|
||||
SyncSupplierProjectJob::dispatch($project->id);
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Supplier\Import;
|
||||
|
||||
/**
|
||||
* Pure-хелперы перевода полей строки rt-проекта поставщика → поля Лидерры.
|
||||
* Без побочных эффектов и зависимостей — только статические функции.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-22-supplier-projects-import-lkomega-design.md §4
|
||||
*/
|
||||
final class SupplierImportMapper
|
||||
{
|
||||
private const SRC_TO_PLATFORM = ['rt' => 'B1', 'bl' => 'B2', 'mt' => 'B3'];
|
||||
|
||||
private const TYPE_TO_SIGNAL = ['calls' => 'call', 'hosts' => 'site', 'sms' => 'sms'];
|
||||
|
||||
public static function platformFromSrc(string $src): ?string
|
||||
{
|
||||
return self::SRC_TO_PLATFORM[$src] ?? null;
|
||||
}
|
||||
|
||||
public static function signalTypeFromType(string $type): ?string
|
||||
{
|
||||
return self::TYPE_TO_SIGNAL[$type] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Строку ГИБДД-кодов («24», «24,77», «24, 77 78») → list<int>.
|
||||
* Пусто/null → [].
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
public static function parseGibddRegions(?string $regions): array
|
||||
{
|
||||
if ($regions === null) {
|
||||
return [];
|
||||
}
|
||||
$parts = preg_split('/[,\s]+/', trim($regions), -1, PREG_SPLIT_NO_EMPTY);
|
||||
if ($parts === false || $parts === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(static fn (string $p): int => (int) $p, $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Список дней-строк ["1".."7"] (1=Пн..7=Вс ISO) → битовая маска (bit0=Пн).
|
||||
* Пусто → 127 (все дни).
|
||||
*
|
||||
* @param list<int|string> $workdays
|
||||
*/
|
||||
public static function workdaysToMask(array $workdays): int
|
||||
{
|
||||
if ($workdays === []) {
|
||||
return 127;
|
||||
}
|
||||
$mask = 0;
|
||||
foreach ($workdays as $d) {
|
||||
$day = (int) $d;
|
||||
if ($day >= 1 && $day <= 7) {
|
||||
$mask |= (1 << ($day - 1));
|
||||
}
|
||||
}
|
||||
|
||||
return $mask === 0 ? 127 : $mask;
|
||||
}
|
||||
|
||||
/**
|
||||
* sms-content: «sender+keyword» → ['sender'=>…, 'keyword'=>…];
|
||||
* «sender» (без плюса) → ['sender'=>…, 'keyword'=>null].
|
||||
*
|
||||
* @return array{sender: string, keyword: string|null}
|
||||
*/
|
||||
public static function parseSmsContent(string $content): array
|
||||
{
|
||||
$plus = strpos($content, '+');
|
||||
if ($plus === false) {
|
||||
return ['sender' => $content, 'keyword' => null];
|
||||
}
|
||||
|
||||
return [
|
||||
'sender' => substr($content, 0, $plus),
|
||||
'keyword' => substr($content, $plus + 1),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Supplier\Import;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\SupplierSyncLog;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
use App\Services\Supplier\SupplierProjectGrouping;
|
||||
use App\Support\SupplierRegions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Усыновление активных проектов поставщика (аккаунт lkomega) как проектов
|
||||
* Лидерры. Читает listProjects (read-only), группирует площадки B1/B2/B3 в один
|
||||
* проект, реверс-маппит регионы, считает лимит как сумму площадок.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-22-supplier-projects-import-lkomega-design.md
|
||||
*/
|
||||
class SupplierProjectImporter
|
||||
{
|
||||
private const DB_CONNECTION = 'pgsql_supplier';
|
||||
|
||||
public function __construct(
|
||||
private readonly SupplierPortalClient $client,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{planned: list<array<string, mixed>>, skipped: list<array{reason: string, label: string}>}
|
||||
*/
|
||||
public function buildPlan(int $tenantId): array
|
||||
{
|
||||
$rows = $this->client->listProjects();
|
||||
|
||||
/** @var list<array{reason: string, label: string}> $skipped */
|
||||
$skipped = [];
|
||||
|
||||
/** @var array<string, array<string, mixed>> $groups */
|
||||
$groups = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
if (($row['status'] ?? false) !== true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$platform = SupplierImportMapper::platformFromSrc((string) ($row['src'] ?? ''));
|
||||
if ($platform === null) {
|
||||
$skipped[] = ['reason' => 'unsupported_source', 'label' => (string) ($row['name'] ?? $row['content'] ?? '?')];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$signalType = SupplierImportMapper::signalTypeFromType((string) ($row['type'] ?? ''));
|
||||
if ($signalType === null) {
|
||||
$skipped[] = ['reason' => 'unsupported_type', 'label' => (string) ($row['name'] ?? '?')];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($signalType === 'sms') {
|
||||
$parsed = SupplierImportMapper::parseSmsContent((string) ($row['content'] ?? ''));
|
||||
$sender = $parsed['sender'];
|
||||
if ($sender === '') {
|
||||
$skipped[] = ['reason' => 'sms_unparseable', 'label' => (string) ($row['name'] ?? '?')];
|
||||
|
||||
continue;
|
||||
}
|
||||
$key = 'sms|'.$sender;
|
||||
if (! isset($groups[$key])) {
|
||||
$groups[$key] = [
|
||||
'signal_type' => 'sms',
|
||||
'signal_identifier' => null,
|
||||
'sms_senders' => [$sender],
|
||||
'sms_keyword' => null,
|
||||
'tag' => '',
|
||||
'regions' => [],
|
||||
'has_all_russia' => false,
|
||||
'workdays_mask' => 0,
|
||||
'daily_limit_target' => 0,
|
||||
'platforms' => [],
|
||||
];
|
||||
}
|
||||
if ($parsed['keyword'] !== null && $parsed['keyword'] !== '' && $groups[$key]['sms_keyword'] === null) {
|
||||
$groups[$key]['sms_keyword'] = $parsed['keyword'];
|
||||
}
|
||||
if (($row['regions_reverse'] ?? false) === true) {
|
||||
$skipped[] = ['reason' => 'regions_exclude', 'label' => $sender];
|
||||
$groups[$key]['__excluded'] = true;
|
||||
}
|
||||
$this->accumulateRow($groups[$key], $row, $platform);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$identifier = (string) ($row['content'] ?? '');
|
||||
$key = $signalType.'|'.$identifier;
|
||||
|
||||
if (! isset($groups[$key])) {
|
||||
$groups[$key] = [
|
||||
'signal_type' => $signalType,
|
||||
'signal_identifier' => $identifier,
|
||||
'sms_senders' => [],
|
||||
'sms_keyword' => null,
|
||||
'tag' => '',
|
||||
'regions' => [],
|
||||
'has_all_russia' => false,
|
||||
'workdays_mask' => 0,
|
||||
'daily_limit_target' => 0,
|
||||
'platforms' => [],
|
||||
];
|
||||
}
|
||||
|
||||
if (($row['regions_reverse'] ?? false) === true) {
|
||||
$skipped[] = ['reason' => 'regions_exclude', 'label' => $identifier];
|
||||
$groups[$key]['__excluded'] = true;
|
||||
}
|
||||
$this->accumulateRow($groups[$key], $row, $platform);
|
||||
}
|
||||
|
||||
$planned = [];
|
||||
foreach ($groups as $g) {
|
||||
if (($g['__excluded'] ?? false) === true) {
|
||||
continue;
|
||||
}
|
||||
unset($g['__excluded']);
|
||||
unset($g['has_all_russia']);
|
||||
$g['delivery_days_mask'] = $g['workdays_mask'] === 0 ? 127 : $g['workdays_mask'];
|
||||
unset($g['workdays_mask']);
|
||||
if ($g['tag'] === '') {
|
||||
$g['tag'] = 'РФ';
|
||||
}
|
||||
$g['name'] = $this->deriveName($g);
|
||||
|
||||
if ($this->projectExists($tenantId, $g)) {
|
||||
$skipped[] = ['reason' => 'already_exists', 'label' => $this->groupLabel($g)];
|
||||
|
||||
continue;
|
||||
}
|
||||
$planned[] = $g;
|
||||
}
|
||||
|
||||
return ['planned' => $planned, 'skipped' => $skipped];
|
||||
}
|
||||
|
||||
/**
|
||||
* Пишет план в БД: Project + supplier_projects (external_id с портала) + pivot.
|
||||
* НЕ обращается к порталу. Каждый проект — в своей транзакции.
|
||||
*
|
||||
* @param array{planned: list<array<string, mixed>>, skipped: list<array{reason: string, label: string}>} $plan
|
||||
* @return array{created_projects: int, created_supplier_projects: int, created_links: int}
|
||||
*/
|
||||
public function commit(array $plan, int $tenantId): array
|
||||
{
|
||||
$createdProjects = 0;
|
||||
$createdSps = 0;
|
||||
$createdLinks = 0;
|
||||
|
||||
$conn = DB::connection(self::DB_CONNECTION);
|
||||
|
||||
foreach ($plan['planned'] as $item) {
|
||||
$writeItem = function () use ($item, $tenantId, &$createdProjects, &$createdSps, &$createdLinks): void {
|
||||
/** @var Project $project */
|
||||
$project = Project::on(self::DB_CONNECTION)->create([
|
||||
'tenant_id' => $tenantId,
|
||||
'name' => $item['name'],
|
||||
'tag' => $item['tag'],
|
||||
'is_active' => true,
|
||||
'signal_type' => $item['signal_type'],
|
||||
'signal_identifier' => $item['signal_identifier'],
|
||||
'sms_senders' => $item['sms_senders'] !== [] ? $item['sms_senders'] : null,
|
||||
'sms_keyword' => $item['sms_keyword'],
|
||||
'regions' => $item['regions'],
|
||||
'region_mode' => 'include',
|
||||
'delivery_days_mask' => $item['delivery_days_mask'],
|
||||
'daily_limit_target' => $item['daily_limit_target'],
|
||||
]);
|
||||
$createdProjects++;
|
||||
|
||||
foreach ($item['platforms'] as $pl) {
|
||||
$platform = (string) $pl['platform'];
|
||||
$uniqueKey = SupplierProjectGrouping::buildUniqueKey($project, $platform);
|
||||
|
||||
/** @var SupplierProject $sp */
|
||||
$sp = SupplierProject::on(self::DB_CONNECTION)->firstOrCreate(
|
||||
['platform' => $platform, 'unique_key' => $uniqueKey, 'subject_code' => null],
|
||||
[
|
||||
'signal_type' => $item['signal_type'],
|
||||
'supplier_external_id' => (string) $pl['external_id'],
|
||||
'current_limit' => (int) $pl['lim'],
|
||||
'current_workdays' => $this->maskToList((int) $item['delivery_days_mask']),
|
||||
'current_regions' => $item['regions'],
|
||||
'sync_status' => 'ok',
|
||||
'last_synced_at' => now(),
|
||||
],
|
||||
);
|
||||
if ($sp->wasRecentlyCreated) {
|
||||
$createdSps++;
|
||||
SupplierSyncLog::on(self::DB_CONNECTION)->create([
|
||||
'supplier_project_id' => $sp->id,
|
||||
'action' => 'create',
|
||||
'http_status' => 200,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$inserted = DB::connection(self::DB_CONNECTION)->table('project_supplier_links')->insertOrIgnore([
|
||||
'project_id' => $project->id,
|
||||
'supplier_project_id' => $sp->id,
|
||||
'platform' => $platform,
|
||||
'subject_code' => null,
|
||||
]);
|
||||
$createdLinks += $inserted;
|
||||
}
|
||||
};
|
||||
|
||||
// Per-project atomicity (spec §8): сбой посреди группы не должен оставить
|
||||
// orphan-Project без supplier_projects/pivot. В проде оборачиваем в транзакцию.
|
||||
// Под тестовым харнессом (SharesSupplierPdo + DatabaseTransactions) общий PDO
|
||||
// уже в транзакции — повторный BEGIN бросил бы «already active», поэтому пишем
|
||||
// напрямую (внешняя транзакция теста сама откатится).
|
||||
if ($conn->getPdo()->inTransaction()) {
|
||||
$writeItem();
|
||||
} else {
|
||||
$conn->transaction($writeItem);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'created_projects' => $createdProjects,
|
||||
'created_supplier_projects' => $createdSps,
|
||||
'created_links' => $createdLinks,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Маска дней (bit0=Пн) → list<int> [1..7].
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function maskToList(int $mask): array
|
||||
{
|
||||
$out = [];
|
||||
for ($i = 0; $i < 7; $i++) {
|
||||
if (($mask & (1 << $i)) !== 0) {
|
||||
$out[] = $i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $group
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
private function accumulateRow(array &$group, array $row, string $platform): void
|
||||
{
|
||||
$lim = (int) ($row['lim'] ?? 0);
|
||||
$group['daily_limit_target'] += $lim;
|
||||
$group['platforms'][] = [
|
||||
'platform' => $platform,
|
||||
'external_id' => (int) ($row['id'] ?? 0),
|
||||
'lim' => $lim,
|
||||
];
|
||||
|
||||
$rowTag = trim((string) ($row['tag'] ?? ''));
|
||||
if ($group['tag'] === '' && $rowTag !== '' && $rowTag !== 'РФ') {
|
||||
$group['tag'] = $rowTag;
|
||||
}
|
||||
|
||||
$group['workdays_mask'] |= SupplierImportMapper::workdaysToMask((array) ($row['workdays'] ?? []));
|
||||
|
||||
if (! $group['has_all_russia']) {
|
||||
$gibdd = SupplierImportMapper::parseGibddRegions(
|
||||
is_string($row['regions'] ?? null) ? $row['regions'] : ''
|
||||
);
|
||||
if ($gibdd === []) {
|
||||
$group['has_all_russia'] = true;
|
||||
$group['regions'] = [];
|
||||
} else {
|
||||
$liderra = SupplierRegions::mapFromSupplier($gibdd);
|
||||
$group['regions'] = array_values(array_unique(array_merge($group['regions'], $liderra)));
|
||||
sort($group['regions']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $group
|
||||
*/
|
||||
private function projectExists(int $tenantId, array $group): bool
|
||||
{
|
||||
$query = Project::on('pgsql_supplier')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('signal_type', $group['signal_type']);
|
||||
|
||||
if ($group['signal_type'] === 'sms') {
|
||||
$sender = $group['sms_senders'][0] ?? '';
|
||||
$keyword = $group['sms_keyword'];
|
||||
|
||||
return $query
|
||||
->whereJsonContains('sms_senders', $sender)
|
||||
->where(fn ($q) => $keyword === null ? $q->whereNull('sms_keyword') : $q->where('sms_keyword', $keyword))
|
||||
->exists();
|
||||
}
|
||||
|
||||
return $query->where('signal_identifier', $group['signal_identifier'])->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $group
|
||||
*/
|
||||
private function groupLabel(array $group): string
|
||||
{
|
||||
return $group['signal_type'] === 'sms'
|
||||
? (string) ($group['sms_senders'][0] ?? '?')
|
||||
: (string) ($group['signal_identifier'] ?? '?');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $group
|
||||
*/
|
||||
private function deriveName(array $group): string
|
||||
{
|
||||
$tag = trim((string) $group['tag']);
|
||||
$identifier = $group['signal_type'] === 'sms'
|
||||
? (string) ($group['sms_senders'][0] ?? '')
|
||||
: (string) ($group['signal_identifier'] ?? '');
|
||||
|
||||
// projects has UNIQUE(tenant_id, name): несколько групп с одинаковым тегом
|
||||
// («КРК» приходит на десятки разных телефонов) обязаны иметь разные имена.
|
||||
// Поэтому комбинируем тег + идентификатор. «РФ» — placeholder тега, не часть имени.
|
||||
$tagPart = ($tag !== '' && $tag !== 'РФ') ? $tag : '';
|
||||
if ($tagPart !== '' && $identifier !== '') {
|
||||
$name = $tagPart.' · '.$identifier;
|
||||
} elseif ($tagPart !== '') {
|
||||
$name = $tagPart;
|
||||
} elseif ($identifier !== '') {
|
||||
$name = $identifier;
|
||||
} else {
|
||||
$name = 'проект';
|
||||
}
|
||||
|
||||
return mb_substr($name, 0, 255);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use App\Exceptions\Supplier\SupplierClientException;
|
||||
use App\Exceptions\Supplier\SupplierTransientException;
|
||||
use App\Jobs\Supplier\RefreshSupplierSessionJob;
|
||||
use App\Services\Supplier\Dto\SupplierProjectDto;
|
||||
use App\Support\SupplierRegions;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\Factory as HttpFactory;
|
||||
@@ -477,7 +478,10 @@ class SupplierPortalClient
|
||||
'srcseg' => false,
|
||||
'limit' => $dto->limit,
|
||||
'workdays' => $workdays,
|
||||
'regions' => $dto->regions,
|
||||
// DTO несёт Лидерра-коды (конституционный порядок); поставщик ждёт
|
||||
// свои коды (ГИБДД). Без перевода уходил чужой регион (Красноярский 29
|
||||
// → Архангельск 29). См. App\Support\SupplierRegions.
|
||||
'regions' => SupplierRegions::mapToSupplier($dto->regions),
|
||||
'regions_reverse' => $dto->regionsReverse,
|
||||
'status' => $dto->status === 'active',
|
||||
'show' => true,
|
||||
|
||||
@@ -11,14 +11,19 @@ use Illuminate\Support\Collection;
|
||||
/**
|
||||
* Pure function: формула заказа у поставщика на (источник × субъект).
|
||||
*
|
||||
* Эпик миграции проектов (Plan 3): platform-split B1/B2/B3 удалён — портал
|
||||
* делит лимит сам (R6). Один лимит на группу eligible-клиентов:
|
||||
* Заказ группы eligible-клиентов:
|
||||
*
|
||||
* order = max(наибольший_лимит, ceil(Σ_лимитов / 3))
|
||||
*
|
||||
* ceil(Σ/3) — ёмкость шаринга (лид продаётся ≤3 раз).
|
||||
* ceil(Σ/3) — ёмкость шаринга (лид продаётся ≤3 раз клиентам Лидерры).
|
||||
* наиб — крупнейший клиент должен иметь шанс добрать.
|
||||
*
|
||||
* Этот `order` затем ДЕЛИТСЯ между площадками B1/B2/B3 через distributeForPlatform()
|
||||
* так, чтобы Σ per-platform лимитов == order. Портал НЕ делит сам: проверено вживую
|
||||
* 2026-05-21 (listProjects) — каждый B-проект честно набирает до своего лимита
|
||||
* независимо, поэтому одинаковый лимит на 3 площадках = заказ ×3 (переплата).
|
||||
* Plan 3 R6 («портал делит, verified 15→5») оказался ложным — split восстановлен.
|
||||
*
|
||||
* `allocate()` оставлен с прежней сигнатурой для временной совместимости
|
||||
* c SyncSupplierProjectsJob — внутри использует computeOrder, возвращает
|
||||
* DTO с одинаковым limit на любую platform/signalType.
|
||||
@@ -76,7 +81,7 @@ final class SupplierQuotaAllocator
|
||||
* ceil(Σ/3) — ёмкость шаринга (лид продаётся ≤3 раз).
|
||||
* наиб — крупнейший клиент должен иметь шанс добрать.
|
||||
*
|
||||
* Один лимит на группу; портал делит на B1/B2/B3 сам (R6 — наш split убран).
|
||||
* Возвращает заказ ГРУППЫ; деление между B1/B2/B3 — distributeForPlatform().
|
||||
*
|
||||
* @param array<int, int> $dailyLimits лимиты eligible-сегодня клиентов группы
|
||||
*/
|
||||
@@ -92,6 +97,40 @@ final class SupplierQuotaAllocator
|
||||
return max($max, (int) ceil($sum / 3));
|
||||
}
|
||||
|
||||
/**
|
||||
* Делит групповой заказ между площадками так, чтобы СУММА per-platform лимитов == order.
|
||||
*
|
||||
* Largest-remainder: каждой площадке floor(order/N), затем по +1 первым (order mod N)
|
||||
* площадкам в порядке списка. Сумма всегда точно равна order — ни переплаты, ни недобора.
|
||||
*
|
||||
* Восстанавливает поведение, удалённое в Plan 3 R6 (ошибочное допущение «портал делит сам»).
|
||||
* Портал НЕ делит — каждый B-проект набирает до своего лимита независимо; одинаковый
|
||||
* лимит на N площадках = заказ ×N (переплата). Verified live 2026-05-21.
|
||||
*
|
||||
* @param list<string> $platforms площадки в каноническом порядке (B1<B2<B3)
|
||||
* @return array<string, int> [platform => лимит этой площадки]
|
||||
*/
|
||||
public static function distributeForPlatform(int $order, array $platforms): array
|
||||
{
|
||||
$count = count($platforms);
|
||||
if ($count === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$order = max(0, $order);
|
||||
$base = intdiv($order, $count);
|
||||
$remainder = $order % $count;
|
||||
|
||||
$shares = [];
|
||||
$i = 0;
|
||||
foreach ($platforms as $platform) {
|
||||
$shares[$platform] = $base + ($i < $remainder ? 1 : 0);
|
||||
$i++;
|
||||
}
|
||||
|
||||
return $shares;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, mixed> $arrays
|
||||
* @return array<int, int>
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Перевод кодов регионов: Лидерра → поставщик crm.bp-gr.ru.
|
||||
*
|
||||
* Лидерра нумерует субъекты РФ по конституционному порядку (ст. 65), 1..89 —
|
||||
* см. {@see RussianRegions}: Красноярский край = 29, Архангельская обл. = 35.
|
||||
* Поставщик нумерует по автомобильным кодам (ГИБДД): Красноярский = 24,
|
||||
* Архангельская = 29. Без перевода Sync отправлял Лидерра-код «как есть»
|
||||
* (`regions => [29]` для Красноярского), а поставщик понимал его как СВОЙ № 29 =
|
||||
* Архангельск → у поставщика выбирался ЧУЖОЙ регион. На dev не всплывало —
|
||||
* проверяли на «вся РФ» (пустой regions).
|
||||
*
|
||||
* Карта построена сверкой имён {@see RussianRegions::CODE_TO_NAME} ↔ live-дерево
|
||||
* регионов формы «Добавить проект» поставщика (recon 2026-05-21: node-key="id",
|
||||
* 79 субъектов-листьев). Все 79 кодов поставщика покрыты (биекция на 79).
|
||||
*
|
||||
* 10 субъектов Лидерры поставщик НЕ предлагает (нет в дереве) — их коды
|
||||
* отбрасываются при переводе (с warning'ом): Московская обл. (56),
|
||||
* Ленинградская обл. (53), Крым (13), Севастополь (84), ДНР (6), ЛНР (14),
|
||||
* Запорожская (43), Херсонская (79), Ненецкий АО (86), Ямало-Ненецкий АО (89).
|
||||
* Если у проекта это был ЕДИНСТВЕННЫЙ регион — у поставщика проект окажется без
|
||||
* георфильтра (вся РФ). Это ограничение покрытия поставщика, не баг перевода.
|
||||
*/
|
||||
final class SupplierRegions
|
||||
{
|
||||
/**
|
||||
* Лидерра-код (конституционный 1..89) => код поставщика (ГИБДД).
|
||||
*
|
||||
* @var array<int, int>
|
||||
*/
|
||||
public const LIDERRA_TO_SUPPLIER = [
|
||||
// Республики
|
||||
1 => 1, // Республика Адыгея
|
||||
2 => 4, // Республика Алтай
|
||||
3 => 2, // Республика Башкортостан
|
||||
4 => 3, // Республика Бурятия
|
||||
5 => 5, // Республика Дагестан
|
||||
7 => 6, // Республика Ингушетия
|
||||
8 => 7, // Кабардино-Балкарская Республика
|
||||
9 => 8, // Республика Калмыкия
|
||||
10 => 9, // Карачаево-Черкесская Республика
|
||||
11 => 10, // Республика Карелия
|
||||
12 => 11, // Республика Коми
|
||||
15 => 12, // Республика Марий Эл
|
||||
16 => 13, // Республика Мордовия
|
||||
17 => 14, // Республика Саха (Якутия)
|
||||
18 => 15, // Республика Северная Осетия — Алания
|
||||
19 => 16, // Республика Татарстан
|
||||
20 => 17, // Республика Тыва
|
||||
21 => 18, // Удмуртская Республика
|
||||
22 => 19, // Республика Хакасия
|
||||
23 => 20, // Чеченская Республика
|
||||
24 => 21, // Чувашская Республика
|
||||
// Края
|
||||
25 => 22, // Алтайский край
|
||||
26 => 75, // Забайкальский край
|
||||
27 => 41, // Камчатский край
|
||||
28 => 23, // Краснодарский край
|
||||
29 => 24, // Красноярский край
|
||||
30 => 59, // Пермский край
|
||||
31 => 25, // Приморский край
|
||||
32 => 26, // Ставропольский край
|
||||
33 => 27, // Хабаровский край
|
||||
// Области
|
||||
34 => 28, // Амурская область
|
||||
35 => 29, // Архангельская область
|
||||
36 => 30, // Астраханская область
|
||||
37 => 31, // Белгородская область
|
||||
38 => 32, // Брянская область
|
||||
39 => 33, // Владимирская область
|
||||
40 => 34, // Волгоградская область
|
||||
41 => 35, // Вологодская область
|
||||
42 => 36, // Воронежская область
|
||||
44 => 37, // Ивановская область
|
||||
45 => 38, // Иркутская область
|
||||
46 => 39, // Калининградская область
|
||||
47 => 40, // Калужская область
|
||||
48 => 42, // Кемеровская область
|
||||
49 => 43, // Кировская область
|
||||
50 => 44, // Костромская область
|
||||
51 => 45, // Курганская область
|
||||
52 => 46, // Курская область
|
||||
54 => 48, // Липецкая область
|
||||
55 => 49, // Магаданская область
|
||||
57 => 51, // Мурманская область
|
||||
58 => 52, // Нижегородская область
|
||||
59 => 53, // Новгородская область
|
||||
60 => 54, // Новосибирская область
|
||||
61 => 55, // Омская область
|
||||
62 => 56, // Оренбургская область
|
||||
63 => 57, // Орловская область
|
||||
64 => 58, // Пензенская область
|
||||
65 => 60, // Псковская область
|
||||
66 => 61, // Ростовская область
|
||||
67 => 62, // Рязанская область
|
||||
68 => 63, // Самарская область
|
||||
69 => 64, // Саратовская область
|
||||
70 => 65, // Сахалинская область
|
||||
71 => 66, // Свердловская область
|
||||
72 => 67, // Смоленская область
|
||||
73 => 68, // Тамбовская область
|
||||
74 => 69, // Тверская область
|
||||
75 => 70, // Томская область
|
||||
76 => 71, // Тульская область
|
||||
77 => 72, // Тюменская область
|
||||
78 => 73, // Ульяновская область
|
||||
80 => 74, // Челябинская область
|
||||
81 => 76, // Ярославская область
|
||||
// Города федерального значения
|
||||
82 => 77, // Москва
|
||||
83 => 78, // Санкт-Петербург
|
||||
// Автономная область / округа
|
||||
85 => 79, // Еврейская автономная область
|
||||
87 => 86, // Ханты-Мансийский автономный округ — Югра
|
||||
88 => 87, // Чукотский автономный округ
|
||||
];
|
||||
|
||||
/**
|
||||
* Переводит Лидерра-коды регионов в коды поставщика. Неизвестные (нет у
|
||||
* поставщика) отбрасываются с warning'ом; sentinel 0 («Вся РФ») игнорируется.
|
||||
* Результат — уникальные коды поставщика по возрастанию.
|
||||
*
|
||||
* @param list<int>|array<int|string, int|string> $liderraCodes
|
||||
* @return list<int>
|
||||
*/
|
||||
public static function mapToSupplier(array $liderraCodes): array
|
||||
{
|
||||
$out = [];
|
||||
$dropped = [];
|
||||
|
||||
foreach ($liderraCodes as $code) {
|
||||
$code = (int) $code;
|
||||
if ($code === 0) {
|
||||
continue; // sentinel «Вся РФ»
|
||||
}
|
||||
if (isset(self::LIDERRA_TO_SUPPLIER[$code])) {
|
||||
$out[self::LIDERRA_TO_SUPPLIER[$code]] = true;
|
||||
} else {
|
||||
$dropped[] = $code;
|
||||
}
|
||||
}
|
||||
|
||||
if ($dropped !== []) {
|
||||
Log::warning('supplier.regions.unmapped', [
|
||||
'liderra_codes' => $dropped,
|
||||
'note' => 'supplier does not offer these subjects — geo-filter dropped for them',
|
||||
]);
|
||||
}
|
||||
|
||||
$codes = array_keys($out);
|
||||
sort($codes);
|
||||
|
||||
return $codes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Инверсия {@see mapToSupplier}: коды поставщика (ГИБДД) → Лидерра-коды
|
||||
* (конституционный порядок). Неизвестные коды поставщика отбрасываются
|
||||
* с warning'ом. Результат — уникальные Лидерра-коды по возрастанию.
|
||||
*
|
||||
* @param list<int>|array<int|string, int|string> $supplierCodes
|
||||
* @return list<int>
|
||||
*/
|
||||
public static function mapFromSupplier(array $supplierCodes): array
|
||||
{
|
||||
/** @var array<int, int> $supplierToLiderra */
|
||||
$supplierToLiderra = array_flip(self::LIDERRA_TO_SUPPLIER);
|
||||
|
||||
$out = [];
|
||||
$dropped = [];
|
||||
|
||||
foreach ($supplierCodes as $code) {
|
||||
$code = (int) $code;
|
||||
if (isset($supplierToLiderra[$code])) {
|
||||
$out[$supplierToLiderra[$code]] = true;
|
||||
} else {
|
||||
$dropped[] = $code;
|
||||
}
|
||||
}
|
||||
|
||||
if ($dropped !== []) {
|
||||
Log::warning('supplier.regions.unmapped_reverse', [
|
||||
'supplier_codes' => $dropped,
|
||||
'note' => 'supplier code has no Liderra equivalent — dropped on import',
|
||||
]);
|
||||
}
|
||||
|
||||
$codes = array_keys($out);
|
||||
sort($codes);
|
||||
|
||||
return $codes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* SSRF-гард для исходящих webhook-URL.
|
||||
*
|
||||
* Webhook target_url задаёт авторизованный админ тенанта. Без проверки он может
|
||||
* указать внутренний адрес (`https://169.254.169.254/` cloud-metadata,
|
||||
* `https://127.0.0.1/`, `https://10.0.0.0/8`) и через кнопку «тест» получить
|
||||
* ответ внутренней службы (SSRF + info-leak). starts_with:https:// этого не ловит.
|
||||
*
|
||||
* Политика: блокируем, только если хост РАЗРЕШАЕТСЯ в приватный/зарезервированный
|
||||
* IP. Неразрешимый хост (NXDOMAIN) — не SSRF-вектор, пропускаем (реальный запрос
|
||||
* упадёт сам). Проверяются все A/AAAA-записи (защита от hostname→private).
|
||||
*/
|
||||
final class WebhookUrlGuard
|
||||
{
|
||||
/**
|
||||
* @return string|null Причина блокировки (человекочитаемая) или null, если адрес безопасен.
|
||||
*/
|
||||
public static function blockReason(string $url): ?string
|
||||
{
|
||||
$host = parse_url($url, PHP_URL_HOST);
|
||||
if (! is_string($host) || $host === '') {
|
||||
return 'Некорректный URL webhook.';
|
||||
}
|
||||
$host = trim($host, '[]'); // снять скобки IPv6-литерала
|
||||
|
||||
foreach (self::resolve($host) as $ip) {
|
||||
if (! self::isPublicIp($ip)) {
|
||||
return 'URL webhook ведёт во внутреннюю/зарезервированную сеть — запрещено.';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @return list<string> Все IP, в которые разрешается хост (пусто, если не разрешается). */
|
||||
private static function resolve(string $host): array
|
||||
{
|
||||
if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
|
||||
return [$host]; // IP-литерал — без DNS
|
||||
}
|
||||
|
||||
$ips = [];
|
||||
$v4 = gethostbynamel($host);
|
||||
if (is_array($v4)) {
|
||||
$ips = array_merge($ips, $v4);
|
||||
}
|
||||
$aaaa = @dns_get_record($host, DNS_AAAA);
|
||||
if (is_array($aaaa)) {
|
||||
foreach ($aaaa as $rec) {
|
||||
if (isset($rec['ipv6']) && is_string($rec['ipv6'])) {
|
||||
$ips[] = $rec['ipv6'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($ips));
|
||||
}
|
||||
|
||||
private static function isPublicIp(string $ip): bool
|
||||
{
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) {
|
||||
return filter_var(
|
||||
$ip,
|
||||
FILTER_VALIDATE_IP,
|
||||
FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
|
||||
) !== false;
|
||||
}
|
||||
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) {
|
||||
$lower = strtolower($ip);
|
||||
// loopback / unspecified
|
||||
if ($lower === '::1' || $lower === '::') {
|
||||
return false;
|
||||
}
|
||||
// link-local fe80::/10
|
||||
if (preg_match('/^fe[89ab]/', $lower) === 1) {
|
||||
return false;
|
||||
}
|
||||
// unique-local fc00::/7
|
||||
if ($lower[0] === 'f' && in_array($lower[1], ['c', 'd'], true)) {
|
||||
return false;
|
||||
}
|
||||
// IPv4-mapped ::ffff:a.b.c.d — проверить встроенный IPv4
|
||||
if (str_contains($lower, '::ffff:')) {
|
||||
$v4 = substr($lower, (int) strrpos($lower, ':') + 1);
|
||||
if (filter_var($v4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) {
|
||||
return self::isPublicIp($v4);
|
||||
}
|
||||
}
|
||||
|
||||
return filter_var(
|
||||
$ip,
|
||||
FILTER_VALIDATE_IP,
|
||||
FILTER_FLAG_IPV6 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
|
||||
) !== false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+17
-1
@@ -2,9 +2,12 @@
|
||||
|
||||
use App\Http\Middleware\EnsureSaasAdmin;
|
||||
use App\Http\Middleware\SetTenantContext;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
@@ -30,5 +33,18 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
//
|
||||
$exceptions->render(function (QueryException $e, Request $request) {
|
||||
Log::error('db.query_exception', [
|
||||
'message' => $e->getMessage(),
|
||||
'sql' => $e->getSql(),
|
||||
'path' => $request->path(),
|
||||
]);
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'message' => 'Не удалось сохранить. Проверьте данные или попробуйте ещё раз.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
return null; // default render for non-JSON
|
||||
});
|
||||
})->create();
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::statement('ALTER TABLE projects DROP COLUMN IF EXISTS archived_at');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('ALTER TABLE projects ADD COLUMN archived_at TIMESTAMPTZ NULL');
|
||||
}
|
||||
};
|
||||
+115
-1
@@ -258,6 +258,90 @@ parameters:
|
||||
count: 1
|
||||
path: app/Services/Supplier/SupplierProjectGrouping.php
|
||||
|
||||
-
|
||||
message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\ForbiddenDefineFunctions not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\ForbiddenFinalClasses not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\ForbiddenNormalClasses not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\ForbiddenPrivateMethods not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\ForbiddenTraits not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\SyntaxCheck not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Metrics\\Architecture\\Classes not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class SlevomatCodingStandard\\Sniffs\\Commenting\\UselessFunctionDocCommentSniff not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class SlevomatCodingStandard\\Sniffs\\Namespaces\\AlphabeticallySortedUsesSniff not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class SlevomatCodingStandard\\Sniffs\\TypeHints\\DeclareStrictTypesSniff not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class SlevomatCodingStandard\\Sniffs\\TypeHints\\DisallowMixedTypeHintSniff not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class SlevomatCodingStandard\\Sniffs\\TypeHints\\ParameterTypeHintSniff not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class SlevomatCodingStandard\\Sniffs\\TypeHints\\PropertyTypeHintSniff not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class SlevomatCodingStandard\\Sniffs\\TypeHints\\ReturnTypeHintSniff not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.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
|
||||
@@ -1263,7 +1347,7 @@ parameters:
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
count: 3
|
||||
path: tests/Feature/ImpersonationTest.php
|
||||
|
||||
-
|
||||
@@ -1572,6 +1656,12 @@ parameters:
|
||||
count: 14
|
||||
path: tests/Feature/Plan5/Projects/ProjectsUpdateTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Project/QueryExceptionRenderTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
@@ -1848,6 +1938,12 @@ parameters:
|
||||
count: 2
|
||||
path: tests/Feature/Supplier/FailoverProjectChannelLiveSmokeTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:once\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Supplier/DeleteSupplierProjectJobTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -2015,3 +2111,21 @@ parameters:
|
||||
identifier: argument.type
|
||||
count: 1
|
||||
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 6
|
||||
path: tests/Feature/Project/ProjectCreateDedupTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:fail\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Project/ProjectCreateDedupTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Symfony\\Component\\HttpFoundation\\Response\:\:getData\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Project/ProjectCreateDedupTest.php
|
||||
|
||||
@@ -29,11 +29,11 @@
|
||||
|
||||
<v-btn
|
||||
color="error"
|
||||
prepend-icon="mdi-archive"
|
||||
data-testid="bulk-archive"
|
||||
@click="confirmAndRun('archive')"
|
||||
prepend-icon="mdi-delete"
|
||||
data-testid="bulk-delete"
|
||||
@click="confirmAndRun('delete')"
|
||||
>
|
||||
Архивировать
|
||||
Удалить
|
||||
</v-btn>
|
||||
|
||||
<v-spacer />
|
||||
@@ -92,11 +92,10 @@ const skipToastText = ref('');
|
||||
const messages: Record<string, string> = {
|
||||
pause: 'Приостановить выбранные проекты?',
|
||||
resume: 'Возобновить выбранные проекты?',
|
||||
archive:
|
||||
'Архивировать выбранные проекты?\nДействие необратимо в Plan 5 (восстановление потребует ручного запроса).',
|
||||
delete: 'Удалить выбранные проекты? Действие необратимо. Проекты со сделками будут пропущены.',
|
||||
};
|
||||
|
||||
async function confirmAndRun(action: 'pause' | 'resume' | 'archive') {
|
||||
async function confirmAndRun(action: 'pause' | 'resume' | 'delete') {
|
||||
if (!window.confirm(messages[action])) return;
|
||||
await runBulk({ action });
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ const base = {
|
||||
daily_limit_target: 50,
|
||||
delivered_today: 32,
|
||||
is_active: true,
|
||||
archived_at: null,
|
||||
sync_status: 'ok' as const,
|
||||
};
|
||||
|
||||
|
||||
@@ -48,9 +48,9 @@
|
||||
<template #prepend><v-icon>mdi-refresh</v-icon></template>
|
||||
<v-list-item-title>Синхронизировать</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click="$emit('archive', project)">
|
||||
<template #prepend><v-icon>mdi-archive</v-icon></template>
|
||||
<v-list-item-title>Архивировать</v-list-item-title>
|
||||
<v-list-item @click="$emit('delete', project)">
|
||||
<template #prepend><v-icon>mdi-delete</v-icon></template>
|
||||
<v-list-item-title>Удалить</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
@@ -97,7 +97,7 @@ defineEmits<{
|
||||
edit: [project: Project];
|
||||
'toggle-active': [project: Project];
|
||||
'sync-now': [project: Project];
|
||||
archive: [project: Project];
|
||||
delete: [project: Project];
|
||||
}>();
|
||||
|
||||
const typeLabel = computed(() => ({ site: 'Сайт', call: 'Звонок', sms: 'СМС' })[props.project.signal_type]);
|
||||
|
||||
@@ -63,10 +63,10 @@ async function onPause(): Promise<void> {
|
||||
async function onDelete(): Promise<void> {
|
||||
if (!props.project) return;
|
||||
const ok = window.confirm(
|
||||
'Архивировать проект? Действие необратимо в Plan 5 (восстановление потребует ручного запроса).',
|
||||
'Удалить проект? Действие необратимо. Если по проекту есть сделки — удаление будет заблокировано.',
|
||||
);
|
||||
if (!ok) return;
|
||||
await store.archive(props.project.id);
|
||||
await store.del(props.project.id);
|
||||
emit('close');
|
||||
}
|
||||
|
||||
|
||||
@@ -168,6 +168,7 @@ const lucideMap: Record<string, Component> = {
|
||||
'mdi-content-save-outline': Save,
|
||||
'mdi-credit-card-outline': CreditCard,
|
||||
'mdi-currency-rub': RussianRuble,
|
||||
'mdi-delete': Trash2,
|
||||
'mdi-delete-outline': Trash2,
|
||||
'mdi-dots-vertical': MoreVertical,
|
||||
'mdi-download': Download,
|
||||
|
||||
@@ -13,7 +13,6 @@ export interface Project {
|
||||
delivered_today: number;
|
||||
delivered_in_month?: number;
|
||||
is_active: boolean;
|
||||
archived_at: string | null;
|
||||
region_mask?: number;
|
||||
region_mode?: string;
|
||||
regions?: number[]; // Plan 6 — subject codes 1..89; пустой массив = вся РФ
|
||||
@@ -65,7 +64,7 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
return data.data;
|
||||
}
|
||||
|
||||
async function archive(id: number) {
|
||||
async function del(id: number) {
|
||||
await axios.delete(`/api/projects/${id}`);
|
||||
await fetch();
|
||||
}
|
||||
@@ -94,7 +93,7 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
selectedIds.value.clear();
|
||||
}
|
||||
|
||||
async function bulkAction(action: 'pause' | 'resume' | 'archive') {
|
||||
async function bulkAction(action: 'pause' | 'resume' | 'delete') {
|
||||
const ids = Array.from(selectedIds.value);
|
||||
if (!ids.length) return;
|
||||
await axios.post('/api/projects/bulk', { action, ids });
|
||||
@@ -103,7 +102,7 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
}
|
||||
|
||||
interface BulkPayload {
|
||||
action: 'pause' | 'resume' | 'archive' | 'update_regions' | 'update_days' | 'update_limit';
|
||||
action: 'pause' | 'resume' | 'delete' | 'update_regions' | 'update_days' | 'update_limit';
|
||||
add?: number;
|
||||
remove?: number;
|
||||
// Plan 6.5 — update_regions оперирует кодами субъектов (1..89), не bitmask ФО.
|
||||
@@ -200,7 +199,7 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
fetch,
|
||||
create,
|
||||
update,
|
||||
archive,
|
||||
del,
|
||||
syncNow,
|
||||
toggleActive,
|
||||
toggleSelect,
|
||||
|
||||
@@ -5,6 +5,31 @@
|
||||
<v-btn color="primary" prepend-icon="mdi-plus" @click="openCreate">+ Создать проект</v-btn>
|
||||
</div>
|
||||
|
||||
<v-alert
|
||||
v-if="showCutoffBanner"
|
||||
data-testid="cutoff-banner"
|
||||
type="info"
|
||||
variant="tonal"
|
||||
border="start"
|
||||
class="mb-4"
|
||||
>
|
||||
<div class="d-flex justify-space-between align-start gap-2">
|
||||
<span>
|
||||
Важно: изменения по проектам (добавление, удаление, лимиты, рабочие дни, регионы)
|
||||
вносите <strong>до 18:00 МСК</strong>. Изменения после 18:00 применяются при следующей
|
||||
синхронизации — на следующий день.
|
||||
</span>
|
||||
<v-btn
|
||||
data-testid="cutoff-banner-close"
|
||||
icon="mdi-close"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
aria-label="Скрыть уведомление"
|
||||
@click="dismissCutoffBanner"
|
||||
/>
|
||||
</div>
|
||||
</v-alert>
|
||||
|
||||
<div class="d-flex gap-3 mb-4">
|
||||
<v-select
|
||||
v-model="store.filters.signal_type"
|
||||
@@ -71,7 +96,7 @@
|
||||
@edit="openEdit"
|
||||
@toggle-active="store.toggleActive"
|
||||
@sync-now="(p: Project) => store.syncNow(p.id)"
|
||||
@archive="(p: Project) => store.archive(p.id)"
|
||||
@delete="(p: Project) => store.del(p.id)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -101,6 +126,15 @@ const createOpen = ref(false);
|
||||
const editOpen = ref(false);
|
||||
const editing = ref<Project | null>(null);
|
||||
|
||||
// Информационный баннер о сроке внесения изменений (синхронизация с поставщиком в 18:00 МСК).
|
||||
// Закрытие запоминается, чтобы не показывать повторно.
|
||||
const CUTOFF_BANNER_KEY = 'projects.cutoffBannerDismissed';
|
||||
const showCutoffBanner = ref(localStorage.getItem(CUTOFF_BANNER_KEY) !== '1');
|
||||
function dismissCutoffBanner(): void {
|
||||
showCutoffBanner.value = false;
|
||||
localStorage.setItem(CUTOFF_BANNER_KEY, '1');
|
||||
}
|
||||
|
||||
const singleSelectedProject = computed<Project | null>(() => {
|
||||
if (store.selectedIds.size !== 1) return null;
|
||||
const [id] = store.selectedIds;
|
||||
@@ -123,7 +157,6 @@ const typeFilters = [
|
||||
const statusFilters = [
|
||||
{ title: 'Активные', value: 'active' },
|
||||
{ title: 'На паузе', value: 'paused' },
|
||||
{ title: 'Архивные', value: 'archived' },
|
||||
];
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
@@ -25,7 +25,6 @@ const sampleProject = {
|
||||
daily_limit_target: 50,
|
||||
delivered_today: 12,
|
||||
is_active: true,
|
||||
archived_at: null,
|
||||
sync_status: 'ok' as const,
|
||||
region_mask: 0,
|
||||
region_mode: 'include' as const,
|
||||
|
||||
+15
-5
@@ -194,9 +194,12 @@ Route::middleware(['auth:sanctum', 'tenant'])->group(function () {
|
||||
Route::post('/api/webhooks/test', 'App\Http\Controllers\Api\WebhookSettingsController@test');
|
||||
});
|
||||
|
||||
// Дашборд — агрегат KPI/баланса/активности/воронки (audit J3). На MVP без
|
||||
// auth-middleware (tenant_id параметром); production: middleware('auth:sanctum','tenant').
|
||||
Route::get('/api/dashboard/summary', 'App\Http\Controllers\Api\DashboardController@summary');
|
||||
// Дашборд — агрегат KPI/баланса/активности/воронки (audit J3). Go-live: auth:sanctum
|
||||
// + tenant; tenant_id из auth()->user()->tenant_id (SetTenantContext), НЕ из параметра
|
||||
// запроса — закрывает кросс-tenant утечку KPI (как DealController J1).
|
||||
Route::middleware(['auth:sanctum', 'tenant'])->group(function () {
|
||||
Route::get('/api/dashboard/summary', 'App\Http\Controllers\Api\DashboardController@summary');
|
||||
});
|
||||
|
||||
// Сделки — single-resource CRUD + bulk + export. J1 (Sprint 3F, audit):
|
||||
// auth:sanctum + tenant. tenant_id берётся из auth()->user()->tenant_id
|
||||
@@ -228,8 +231,15 @@ Route::middleware(['auth:sanctum', 'tenant'])->group(function () {
|
||||
});
|
||||
|
||||
// Lookup endpoints — заполняют v-select'ы (NewDealDialog, smart-filters).
|
||||
Route::get('/api/managers', 'App\Http\Controllers\Api\ManagerController@index');
|
||||
Route::get('/api/lead-statuses', 'App\Http\Controllers\Api\LeadStatusController@index');
|
||||
// Go-live: auth:sanctum. /api/managers — tenant-scoped (tenant_id из authed-user, НЕ из
|
||||
// параметра — закрывает кросс-tenant утечку списка пользователей); /api/lead-statuses —
|
||||
// глобальная таблица (без tenant_id), нужен только auth:sanctum.
|
||||
Route::middleware(['auth:sanctum', 'tenant'])->group(function () {
|
||||
Route::get('/api/managers', 'App\Http\Controllers\Api\ManagerController@index');
|
||||
});
|
||||
Route::middleware('auth:sanctum')->group(function () {
|
||||
Route::get('/api/lead-statuses', 'App\Http\Controllers\Api\LeadStatusController@index');
|
||||
});
|
||||
|
||||
// Plan 5 Task 2: Projects CRUD — расширенный API с auth:sanctum + RLS.
|
||||
// Заменяет старый GET /api/projects?tenant_id={id} (без auth, MVP-версия).
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use PragmaRX\Google2FA\Google2FA;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
/**
|
||||
* Reset the Auth manager's default guard and cached guard instances back to
|
||||
* the 'web' SessionGuard.
|
||||
*
|
||||
* Necessary because auth:sanctum middleware calls Auth::shouldUse('sanctum')
|
||||
* on every successfully-authenticated request, which permanently changes
|
||||
* config('auth.defaults.guard') to 'sanctum' in the shared test application
|
||||
* instance. Laravel feature tests reuse the same $this->app between HTTP calls,
|
||||
* so this pollution persists across requests. Any subsequent call to
|
||||
* Auth::login() (which internally calls Auth::guard()->login()) then resolves
|
||||
* to the Sanctum RequestGuard — which has no login() method — and throws a
|
||||
* BadMethodCallException.
|
||||
*
|
||||
* The reset must happen *before* any request whose controller calls Auth::login()
|
||||
* without an explicit guard argument (i.e. login and 2fa/verify routes).
|
||||
*/
|
||||
function resetAuthToWebGuard(): void
|
||||
{
|
||||
app('auth')->forgetGuards();
|
||||
app('auth')->setDefaultDriver('web');
|
||||
}
|
||||
|
||||
it('full auth-flow writes all expected auth_log events', function () {
|
||||
Notification::fake();
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
// ── Step 1: Register ─────────────────────────────────────────────────────
|
||||
$this->postJson('/api/auth/register', [
|
||||
'email' => 'flow-test@example.ru',
|
||||
'password' => 'secure-pass-1234',
|
||||
'accept_offer' => true,
|
||||
'accept_pdn' => true,
|
||||
])->assertStatus(201);
|
||||
// logs: register_success
|
||||
|
||||
$user = User::where('email', 'flow-test@example.ru')->first();
|
||||
expect($user)->not->toBeNull();
|
||||
|
||||
// ── Step 2: Login (no 2FA yet) — establish session auth ──────────────────
|
||||
// No prior auth:sanctum request, so no reset needed here.
|
||||
$this->postJson('/api/auth/login', [
|
||||
'email' => 'flow-test@example.ru',
|
||||
'password' => 'secure-pass-1234',
|
||||
])->assertOk();
|
||||
// logs: login_success (first direct login, 2FA not yet enabled)
|
||||
|
||||
// ── Step 3: 2FA init (session-authenticated via web guard) ───────────────
|
||||
// auth:sanctum middleware → shouldUse('sanctum') → default becomes 'sanctum'
|
||||
$this->postJson('/api/2fa/init')->assertOk();
|
||||
// logs: 2fa_setup_init
|
||||
$secret = session('auth.pending_totp_secret');
|
||||
expect($secret)->not->toBeNull();
|
||||
|
||||
// ── Step 4: 2FA confirm ───────────────────────────────────────────────────
|
||||
$google2fa = new Google2FA;
|
||||
$code = $google2fa->getCurrentOtp($secret);
|
||||
// auth:sanctum middleware → shouldUse('sanctum') again
|
||||
$this->postJson('/api/2fa/confirm', ['code' => $code])->assertOk();
|
||||
// logs: 2fa_setup_confirmed (totp_enabled now true)
|
||||
|
||||
// ── Step 5: Logout ────────────────────────────────────────────────────────
|
||||
// auth:sanctum middleware → shouldUse('sanctum') again
|
||||
$this->postJson('/api/auth/logout')->assertOk();
|
||||
// logs: logout
|
||||
|
||||
// ── Step 6: Login with 2FA enabled ────────────────────────────────────────
|
||||
// auth.defaults.guard is now 'sanctum' from previous auth:sanctum requests.
|
||||
// Reset to 'web' so Auth::login() inside AuthController::login() finds the
|
||||
// SessionGuard (which implements login()) rather than the RequestGuard.
|
||||
resetAuthToWebGuard();
|
||||
|
||||
$this->postJson('/api/auth/login', [
|
||||
'email' => 'flow-test@example.ru',
|
||||
'password' => 'secure-pass-1234',
|
||||
])->assertOk();
|
||||
// requires_2fa=true, pending_user_id stored in session
|
||||
|
||||
// ── Step 7: 2FA verify — completes login ─────────────────────────────────
|
||||
// No auth:sanctum request happened since the last reset, so no reset needed.
|
||||
$validCode = $google2fa->getCurrentOtp($secret);
|
||||
$this->postJson('/api/auth/2fa/verify', ['code' => $validCode])->assertOk();
|
||||
// logs: 2fa_verify_success
|
||||
|
||||
// ── Step 8: 2FA disable (session-authenticated from step 7) ──────────────
|
||||
// auth:sanctum middleware → shouldUse('sanctum') again
|
||||
$this->postJson('/api/2fa/disable', ['password' => 'secure-pass-1234'])->assertOk();
|
||||
// logs: 2fa_disabled
|
||||
|
||||
// ── Step 9: Logout ────────────────────────────────────────────────────────
|
||||
// auth:sanctum middleware → shouldUse('sanctum') again
|
||||
$this->postJson('/api/auth/logout')->assertOk();
|
||||
|
||||
// ── Step 10: Login without 2FA — direct login_success ────────────────────
|
||||
// Reset again: auth.defaults.guard is 'sanctum' from Step 8+9 auth:sanctum.
|
||||
resetAuthToWebGuard();
|
||||
|
||||
$this->postJson('/api/auth/login', [
|
||||
'email' => 'flow-test@example.ru',
|
||||
'password' => 'secure-pass-1234',
|
||||
])->assertOk();
|
||||
// logs: login_success (direct login, 2FA now disabled)
|
||||
|
||||
// ── Step 11: Forgot password ──────────────────────────────────────────────
|
||||
$this->postJson('/api/auth/logout')->assertOk();
|
||||
|
||||
$this->postJson('/api/auth/forgot', [
|
||||
'email' => 'flow-test@example.ru',
|
||||
])->assertOk();
|
||||
// logs: password_reset_requested
|
||||
|
||||
// ── Step 12: Reset password ───────────────────────────────────────────────
|
||||
$token = Password::createToken($user);
|
||||
$this->postJson('/api/auth/reset-password', [
|
||||
'token' => $token,
|
||||
'email' => 'flow-test@example.ru',
|
||||
'password' => 'new-secure-pass-5678',
|
||||
'password_confirmation' => 'new-secure-pass-5678',
|
||||
])->assertOk();
|
||||
// logs: password_reset_completed
|
||||
|
||||
// ── Assert all expected events were recorded for this user ────────────────
|
||||
$events = DB::table('auth_log')
|
||||
->where('user_id', $user->id)
|
||||
->pluck('event')
|
||||
->all();
|
||||
|
||||
expect($events)->toContain(
|
||||
'register_success',
|
||||
'2fa_setup_init',
|
||||
'2fa_setup_confirmed',
|
||||
'logout',
|
||||
'login_success',
|
||||
'2fa_verify_success',
|
||||
'2fa_disabled',
|
||||
'password_reset_requested',
|
||||
'password_reset_completed',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,449 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use PragmaRX\Google2FA\Google2FA;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
it('logout writes auth_log event=logout', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'email' => 'logout-log@example.ru',
|
||||
'password_hash' => Hash::make('secret-pass-123'),
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->postJson('/api/auth/login', [
|
||||
'email' => 'logout-log@example.ru',
|
||||
'password' => 'secret-pass-123',
|
||||
])->assertOk();
|
||||
|
||||
$this->postJson('/api/auth/logout')->assertOk();
|
||||
|
||||
$row = DB::table('auth_log')
|
||||
->where('event', 'logout')
|
||||
->where('user_id', $user->id)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($row)->not->toBeNull()
|
||||
->and((int) $row->tenant_id)->toBe($tenant->id);
|
||||
});
|
||||
|
||||
it('register writes auth_log event=register_success', function () {
|
||||
Tenant::factory()->create();
|
||||
|
||||
$response = $this->postJson('/api/auth/register', [
|
||||
'email' => 'reg-log-test@example.ru',
|
||||
'password' => 'fresh-pass-123',
|
||||
'accept_offer' => true,
|
||||
'accept_pdn' => true,
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
|
||||
$user = User::where('email', 'reg-log-test@example.ru')->first();
|
||||
|
||||
$row = DB::table('auth_log')
|
||||
->where('event', 'register_success')
|
||||
->where('user_id', $user->id)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($row)->not->toBeNull()
|
||||
->and($row->email)->toBe('reg-log-test@example.ru');
|
||||
});
|
||||
|
||||
it('2fa verify success writes auth_log event=2fa_verify_success', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$google2fa = new Google2FA;
|
||||
$secret = $google2fa->generateSecretKey();
|
||||
|
||||
$user = User::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'email' => '2fa-log-success@example.ru',
|
||||
'password_hash' => Hash::make('secret-pass-123'),
|
||||
'is_active' => true,
|
||||
'totp_enabled' => true,
|
||||
'totp_secret' => $secret,
|
||||
]);
|
||||
|
||||
// Step 1: login to set pending_user_id in session.
|
||||
$this->postJson('/api/auth/login', [
|
||||
'email' => '2fa-log-success@example.ru',
|
||||
'password' => 'secret-pass-123',
|
||||
])->assertOk();
|
||||
|
||||
// Step 2: verify with valid code.
|
||||
$validCode = $google2fa->getCurrentOtp($secret);
|
||||
$this->postJson('/api/auth/2fa/verify', [
|
||||
'code' => $validCode,
|
||||
])->assertOk();
|
||||
|
||||
$row = DB::table('auth_log')
|
||||
->where('event', '2fa_verify_success')
|
||||
->where('user_id', $user->id)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($row)->not->toBeNull()
|
||||
->and((int) $row->tenant_id)->toBe($tenant->id);
|
||||
});
|
||||
|
||||
it('2fa verify failed writes auth_log event=2fa_verify_failed', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$google2fa = new Google2FA;
|
||||
$secret = $google2fa->generateSecretKey();
|
||||
|
||||
$user = User::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'email' => '2fa-log-fail@example.ru',
|
||||
'password_hash' => Hash::make('secret-pass-123'),
|
||||
'is_active' => true,
|
||||
'totp_enabled' => true,
|
||||
'totp_secret' => $secret,
|
||||
]);
|
||||
|
||||
// Step 1: login to set pending_user_id in session.
|
||||
$this->postJson('/api/auth/login', [
|
||||
'email' => '2fa-log-fail@example.ru',
|
||||
'password' => 'secret-pass-123',
|
||||
])->assertOk();
|
||||
|
||||
// Step 2: verify with wrong code.
|
||||
$this->postJson('/api/auth/2fa/verify', [
|
||||
'code' => '000000',
|
||||
])->assertStatus(422);
|
||||
|
||||
$row = DB::table('auth_log')
|
||||
->where('event', '2fa_verify_failed')
|
||||
->where('user_id', $user->id)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($row)->not->toBeNull()
|
||||
->and($row->failure_reason)->toBe('invalid_code');
|
||||
});
|
||||
|
||||
it('2fa recovery used writes auth_log event=2fa_recovery_used', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$user = User::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'email' => '2fa-recovery-log@example.ru',
|
||||
'password_hash' => Hash::make('secret-pass-123'),
|
||||
'is_active' => true,
|
||||
'totp_enabled' => true,
|
||||
'totp_secret' => 'JBSWY3DPEHPK3PXP',
|
||||
]);
|
||||
|
||||
DB::table('user_recovery_codes')->insert([
|
||||
'user_id' => $user->id,
|
||||
'code_hash' => Hash::make('abcd1234'),
|
||||
'used_at' => null,
|
||||
]);
|
||||
|
||||
// Login to set pending_user_id.
|
||||
$this->postJson('/api/auth/login', [
|
||||
'email' => '2fa-recovery-log@example.ru',
|
||||
'password' => 'secret-pass-123',
|
||||
])->assertOk();
|
||||
|
||||
$this->postJson('/api/auth/2fa/recovery-use', [
|
||||
'code' => 'ABCD-1234',
|
||||
])->assertOk();
|
||||
|
||||
$row = DB::table('auth_log')
|
||||
->where('event', '2fa_recovery_used')
|
||||
->where('user_id', $user->id)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($row)->not->toBeNull()
|
||||
->and((int) $row->tenant_id)->toBe($tenant->id);
|
||||
});
|
||||
|
||||
it('2fa recovery failed writes auth_log event=2fa_recovery_failed', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$user = User::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'email' => '2fa-recovery-fail-log@example.ru',
|
||||
'password_hash' => Hash::make('secret-pass-123'),
|
||||
'is_active' => true,
|
||||
'totp_enabled' => true,
|
||||
'totp_secret' => 'JBSWY3DPEHPK3PXP',
|
||||
]);
|
||||
|
||||
DB::table('user_recovery_codes')->insert([
|
||||
'user_id' => $user->id,
|
||||
'code_hash' => Hash::make('abcd1234'),
|
||||
'used_at' => null,
|
||||
]);
|
||||
|
||||
// Login to set pending_user_id.
|
||||
$this->postJson('/api/auth/login', [
|
||||
'email' => '2fa-recovery-fail-log@example.ru',
|
||||
'password' => 'secret-pass-123',
|
||||
])->assertOk();
|
||||
|
||||
$this->postJson('/api/auth/2fa/recovery-use', [
|
||||
'code' => 'WRONG-9999',
|
||||
])->assertStatus(422);
|
||||
|
||||
$row = DB::table('auth_log')
|
||||
->where('event', '2fa_recovery_failed')
|
||||
->where('user_id', $user->id)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($row)->not->toBeNull()
|
||||
->and($row->failure_reason)->toBe('invalid_or_used');
|
||||
});
|
||||
|
||||
it('2fa setup init writes auth_log event=2fa_setup_init', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'email' => '2fa-init-log@example.ru',
|
||||
'password_hash' => Hash::make('secret-pass-123'),
|
||||
'is_active' => true,
|
||||
'totp_enabled' => false,
|
||||
'totp_secret' => null,
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$this->postJson('/api/2fa/init')->assertOk();
|
||||
|
||||
$row = DB::table('auth_log')
|
||||
->where('event', '2fa_setup_init')
|
||||
->where('user_id', $user->id)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($row)->not->toBeNull()
|
||||
->and((int) $row->tenant_id)->toBe($tenant->id);
|
||||
});
|
||||
|
||||
it('2fa setup confirm writes auth_log event=2fa_setup_confirmed', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'email' => '2fa-confirm-log@example.ru',
|
||||
'password_hash' => Hash::make('secret-pass-123'),
|
||||
'is_active' => true,
|
||||
'totp_enabled' => false,
|
||||
'totp_secret' => null,
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$this->postJson('/api/2fa/init')->assertOk();
|
||||
$secret = session('auth.pending_totp_secret');
|
||||
|
||||
$google2fa = new Google2FA;
|
||||
$code = $google2fa->getCurrentOtp($secret);
|
||||
|
||||
$this->postJson('/api/2fa/confirm', ['code' => $code])->assertOk();
|
||||
|
||||
$row = DB::table('auth_log')
|
||||
->where('event', '2fa_setup_confirmed')
|
||||
->where('user_id', $user->id)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($row)->not->toBeNull()
|
||||
->and((int) $row->tenant_id)->toBe($tenant->id);
|
||||
});
|
||||
|
||||
it('2fa disable success writes auth_log event=2fa_disabled', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$google2fa = new Google2FA;
|
||||
$secret = $google2fa->generateSecretKey();
|
||||
|
||||
$user = User::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'email' => '2fa-disabled-log@example.ru',
|
||||
'password_hash' => Hash::make('secret-pass-123'),
|
||||
'is_active' => true,
|
||||
'totp_enabled' => true,
|
||||
'totp_secret' => $secret,
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$this->postJson('/api/2fa/disable', ['password' => 'secret-pass-123'])->assertOk();
|
||||
|
||||
$row = DB::table('auth_log')
|
||||
->where('event', '2fa_disabled')
|
||||
->where('user_id', $user->id)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($row)->not->toBeNull()
|
||||
->and((int) $row->tenant_id)->toBe($tenant->id);
|
||||
});
|
||||
|
||||
it('2fa disable wrong password writes auth_log event=2fa_disable_failed', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$google2fa = new Google2FA;
|
||||
$secret = $google2fa->generateSecretKey();
|
||||
|
||||
$user = User::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'email' => '2fa-disable-fail-log@example.ru',
|
||||
'password_hash' => Hash::make('secret-pass-123'),
|
||||
'is_active' => true,
|
||||
'totp_enabled' => true,
|
||||
'totp_secret' => $secret,
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$this->postJson('/api/2fa/disable', ['password' => 'wrong-password'])->assertStatus(422);
|
||||
|
||||
$row = DB::table('auth_log')
|
||||
->where('event', '2fa_disable_failed')
|
||||
->where('user_id', $user->id)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($row)->not->toBeNull()
|
||||
->and($row->failure_reason)->toBe('invalid_password');
|
||||
});
|
||||
|
||||
it('2fa regenerate recovery codes writes auth_log event=2fa_recovery_regenerated', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$google2fa = new Google2FA;
|
||||
$secret = $google2fa->generateSecretKey();
|
||||
|
||||
$user = User::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'email' => '2fa-regen-log@example.ru',
|
||||
'password_hash' => Hash::make('secret-pass-123'),
|
||||
'is_active' => true,
|
||||
'totp_enabled' => true,
|
||||
'totp_secret' => $secret,
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$this->postJson('/api/2fa/regenerate-recovery-codes', ['password' => 'secret-pass-123'])->assertOk();
|
||||
|
||||
$row = DB::table('auth_log')
|
||||
->where('event', '2fa_recovery_regenerated')
|
||||
->where('user_id', $user->id)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($row)->not->toBeNull()
|
||||
->and((int) $row->tenant_id)->toBe($tenant->id);
|
||||
});
|
||||
|
||||
it('password_reset_requested writes auth_log with user_id for known email', function () {
|
||||
Notification::fake();
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'email' => 'pr-known-log@example.ru',
|
||||
'password_hash' => Hash::make('old-pass-1234'),
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->postJson('/api/auth/forgot', [
|
||||
'email' => 'pr-known-log@example.ru',
|
||||
])->assertOk();
|
||||
|
||||
$row = DB::table('auth_log')
|
||||
->where('event', 'password_reset_requested')
|
||||
->where('email', 'pr-known-log@example.ru')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($row)->not->toBeNull()
|
||||
->and((int) $row->user_id)->toBe($user->id)
|
||||
->and($row->failure_reason)->toBeNull();
|
||||
});
|
||||
|
||||
it('password_reset_requested writes auth_log with unknown_email failure_reason for unknown email', function () {
|
||||
Notification::fake();
|
||||
|
||||
$this->postJson('/api/auth/forgot', [
|
||||
'email' => 'no-such-pr-log@example.ru',
|
||||
])->assertOk();
|
||||
|
||||
$row = DB::table('auth_log')
|
||||
->where('event', 'password_reset_requested')
|
||||
->where('email', 'no-such-pr-log@example.ru')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($row)->not->toBeNull()
|
||||
->and($row->user_id)->toBeNull()
|
||||
->and($row->failure_reason)->toBe('unknown_email');
|
||||
});
|
||||
|
||||
it('password_reset_completed writes auth_log on successful token reset', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'email' => 'pr-completed-log@example.ru',
|
||||
'password_hash' => Hash::make('old-pass-1234'),
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$token = Password::createToken($user);
|
||||
|
||||
$this->postJson('/api/auth/reset-password', [
|
||||
'token' => $token,
|
||||
'email' => 'pr-completed-log@example.ru',
|
||||
'password' => 'new-strong-pass-1234',
|
||||
'password_confirmation' => 'new-strong-pass-1234',
|
||||
])->assertOk();
|
||||
|
||||
$row = DB::table('auth_log')
|
||||
->where('event', 'password_reset_completed')
|
||||
->where('user_id', $user->id)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($row)->not->toBeNull()
|
||||
->and($row->email)->toBe('pr-completed-log@example.ru');
|
||||
});
|
||||
|
||||
it('password_reset_failed writes auth_log on invalid token', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
User::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'email' => 'pr-failed-log@example.ru',
|
||||
'password_hash' => Hash::make('old-pass-1234'),
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->postJson('/api/auth/reset-password', [
|
||||
'token' => 'invalid-token-zzz',
|
||||
'email' => 'pr-failed-log@example.ru',
|
||||
'password' => 'new-strong-pass-1234',
|
||||
'password_confirmation' => 'new-strong-pass-1234',
|
||||
])->assertStatus(422);
|
||||
|
||||
$row = DB::table('auth_log')
|
||||
->where('event', 'password_reset_failed')
|
||||
->where('email', 'pr-failed-log@example.ru')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($row)->not->toBeNull()
|
||||
->and($row->failure_reason)->not->toBeNull();
|
||||
});
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
@@ -36,12 +37,14 @@ function makeDashboardDeal(
|
||||
]);
|
||||
}
|
||||
|
||||
it('422 без tenant_id', function () {
|
||||
$this->getJson('/api/dashboard/summary')->assertStatus(422);
|
||||
});
|
||||
/** Авторизоваться как пользователь данного тенанта (auth:sanctum + tenant). */
|
||||
function actingForTenant(Tenant $tenant): void
|
||||
{
|
||||
test()->actingAs(User::factory()->for($tenant)->create());
|
||||
}
|
||||
|
||||
it('404 для несуществующего тенанта', function () {
|
||||
$this->getJson('/api/dashboard/summary?tenant_id=999999')->assertStatus(404);
|
||||
it('401 без авторизации', function () {
|
||||
$this->getJson('/api/dashboard/summary')->assertStatus(401);
|
||||
});
|
||||
|
||||
it('возвращает структуру summary с range по умолчанию 7d', function () {
|
||||
@@ -50,7 +53,8 @@ it('возвращает структуру summary с range по умолчан
|
||||
'balance_rub' => '14250.00',
|
||||
'balance_leads' => 285,
|
||||
]);
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
actingForTenant($tenant);
|
||||
$this->getJson('/api/dashboard/summary')
|
||||
->assertOk()
|
||||
->assertJsonPath('range', '7d')
|
||||
->assertJsonPath('balance.amount_rub', '14250.00')
|
||||
@@ -67,6 +71,7 @@ it('возвращает структуру summary с range по умолчан
|
||||
|
||||
it('leads_received считает только сделки окна, без deleted и is_test', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
actingForTenant($tenant);
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
|
||||
// 3 живые сделки в окне 7d + 1 deleted + 1 is_test + 1 вне окна (8 дней назад)
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
@@ -76,31 +81,32 @@ it('leads_received считает только сделки окна, без del
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1), isTest: true);
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(8));
|
||||
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}&range=7d")
|
||||
$this->getJson('/api/dashboard/summary?range=7d')
|
||||
->assertOk()
|
||||
->assertJsonPath('leads_received.value', 3);
|
||||
});
|
||||
|
||||
it('conversion = доля статуса won в окне', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
actingForTenant($tenant);
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
|
||||
makeDashboardDeal($tenant, $project, 'won', now()->subDays(1));
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
// 1 won из 4 → 25.0%; PHP json_encode кодирует 25.0 как 25 (без дроби)
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
$this->getJson('/api/dashboard/summary')
|
||||
->assertOk()
|
||||
->assertJsonPath('conversion.value', 25);
|
||||
});
|
||||
|
||||
it('active_projects считает archived_at IS NULL AND is_active=true + limit из limits', function () {
|
||||
it('active_projects считает is_active=true + limit из limits', function () {
|
||||
$tenant = Tenant::factory()->create(['limits' => ['max_projects' => 10]]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => null, 'is_active' => true]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => null, 'is_active' => true]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => now(), 'is_active' => true]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => null, 'is_active' => false]);
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
actingForTenant($tenant);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => false]);
|
||||
$this->getJson('/api/dashboard/summary')
|
||||
->assertOk()
|
||||
->assertJsonPath('active_projects.active', 2)
|
||||
->assertJsonPath('active_projects.limit', 10);
|
||||
@@ -108,11 +114,12 @@ it('active_projects считает archived_at IS NULL AND is_active=true + limi
|
||||
|
||||
it('funnel группирует живые сделки по статусу', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
actingForTenant($tenant);
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDashboardDeal($tenant, $project, 'won', now()->subDays(1));
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
$this->getJson('/api/dashboard/summary')
|
||||
->assertOk()
|
||||
->assertJsonPath('funnel.new', 2)
|
||||
->assertJsonPath('funnel.won', 1);
|
||||
@@ -120,7 +127,8 @@ it('funnel группирует живые сделки по статусу', fu
|
||||
|
||||
it('activity возвращает 7 точек и 7 меток', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
actingForTenant($tenant);
|
||||
$this->getJson('/api/dashboard/summary')
|
||||
->assertOk()
|
||||
->assertJsonCount(7, 'activity.points')
|
||||
->assertJsonCount(7, 'activity.labels');
|
||||
@@ -130,11 +138,12 @@ it('runway_days использует фикс. 7д-окно независимо
|
||||
// balance_leads = 70; 7 сделок за последние 7 дней → avgDaily=1 → runway=70.
|
||||
// Баг: range=today → $curLeads=1 → avgDaily=1/7≈0.143 → runway≈490 (неверно).
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 70]);
|
||||
actingForTenant($tenant);
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
|
||||
for ($i = 0; $i <= 6; $i++) {
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays($i));
|
||||
}
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}&range=today")
|
||||
$this->getJson('/api/dashboard/summary?range=today')
|
||||
->assertOk()
|
||||
->assertJsonPath('balance.runway_days', 70);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
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_leads' => 100,
|
||||
]);
|
||||
$this->user = User::factory()->for($this->tenant)->create();
|
||||
$this->actingAs($this->user);
|
||||
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
$this->project = Project::factory()->for($this->tenant)->create();
|
||||
});
|
||||
|
||||
test('ActivityLog deal.created содержит user_id, ip_address, user_agent актора', function () {
|
||||
$r = $this->withServerVariables(['REMOTE_ADDR' => '10.1.2.3', 'HTTP_USER_AGENT' => 'TestBrowser/1.0'])
|
||||
->postJson('/api/deals', [
|
||||
'project_name' => 'Тест Attribution',
|
||||
'phone' => '+7 (999) 000-11-22',
|
||||
]);
|
||||
|
||||
$r->assertStatus(201);
|
||||
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
$row = ActivityLog::where('deal_id', $r->json('deal.id'))
|
||||
->where('event', ActivityLog::EVENT_DEAL_CREATED)
|
||||
->first();
|
||||
|
||||
expect($row)->not->toBeNull();
|
||||
expect($row->user_id)->toBe($this->user->id);
|
||||
expect($row->ip_address)->toBe('10.1.2.3');
|
||||
expect($row->user_agent)->toBe('TestBrowser/1.0');
|
||||
});
|
||||
|
||||
test('ActivityLog deal.commented содержит user_id, ip_address, user_agent актора', function () {
|
||||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create(['comment' => 'old']);
|
||||
|
||||
$r = $this->withServerVariables(['REMOTE_ADDR' => '10.1.2.4', 'HTTP_USER_AGENT' => 'TestBrowser/2.0'])
|
||||
->patchJson('/api/deals/'.$deal->id, [
|
||||
'comment' => 'Новый комментарий',
|
||||
]);
|
||||
|
||||
$r->assertStatus(200);
|
||||
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
$row = ActivityLog::where('deal_id', $deal->id)
|
||||
->where('event', 'deal.commented')
|
||||
->first();
|
||||
|
||||
expect($row)->not->toBeNull();
|
||||
expect($row->user_id)->toBe($this->user->id);
|
||||
expect($row->ip_address)->toBe('10.1.2.4');
|
||||
expect($row->user_agent)->toBe('TestBrowser/2.0');
|
||||
});
|
||||
|
||||
test('ActivityLog deal.assigned содержит user_id, ip_address, user_agent актора', function () {
|
||||
$manager = User::factory()->for($this->tenant)->create(['is_active' => true]);
|
||||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create([
|
||||
'manager_id' => null,
|
||||
'assigned_at' => null,
|
||||
]);
|
||||
|
||||
$r = $this->withServerVariables(['REMOTE_ADDR' => '10.1.2.5', 'HTTP_USER_AGENT' => 'TestBrowser/3.0'])
|
||||
->patchJson('/api/deals/'.$deal->id, [
|
||||
'manager_id' => $manager->id,
|
||||
]);
|
||||
|
||||
$r->assertStatus(200);
|
||||
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
$row = ActivityLog::where('deal_id', $deal->id)
|
||||
->where('event', ActivityLog::EVENT_DEAL_ASSIGNED)
|
||||
->first();
|
||||
|
||||
expect($row)->not->toBeNull();
|
||||
expect($row->user_id)->toBe($this->user->id);
|
||||
expect($row->ip_address)->toBe('10.1.2.5');
|
||||
expect($row->user_agent)->toBe('TestBrowser/3.0');
|
||||
});
|
||||
|
||||
test('ActivityLog deal.status_changed содержит user_id, ip_address, user_agent актора', function () {
|
||||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
|
||||
|
||||
$r = $this->withServerVariables(['REMOTE_ADDR' => '10.1.2.6', 'HTTP_USER_AGENT' => 'TestBrowser/4.0'])
|
||||
->patchJson('/api/deals/'.$deal->id, [
|
||||
'status' => 'won',
|
||||
]);
|
||||
|
||||
$r->assertStatus(200);
|
||||
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
$row = ActivityLog::where('deal_id', $deal->id)
|
||||
->where('event', ActivityLog::EVENT_DEAL_STATUS_CHANGED)
|
||||
->first();
|
||||
|
||||
expect($row)->not->toBeNull();
|
||||
expect($row->user_id)->toBe($this->user->id);
|
||||
expect($row->ip_address)->toBe('10.1.2.6');
|
||||
expect($row->user_agent)->toBe('TestBrowser/4.0');
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Task 7 (audit-p1-auth): bulk activity_log rows must carry
|
||||
* user_id, ip_address, user_agent from the current request.
|
||||
*
|
||||
* Three operations: transition / destroy / restore.
|
||||
*/
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->user = User::factory()->for($this->tenant)->create();
|
||||
$this->actingAs($this->user);
|
||||
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
$this->project = Project::factory()->for($this->tenant)->create();
|
||||
});
|
||||
|
||||
it('bulk transition записывает user_id и ip_address в activity_log', function () {
|
||||
$deals = Deal::factory()->count(3)
|
||||
->for($this->tenant)
|
||||
->for($this->project)
|
||||
->create(['status' => 'new']);
|
||||
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '10.9.8.7'])
|
||||
->postJson('/api/deals/transition', [
|
||||
'ids' => $deals->pluck('id')->all(),
|
||||
'status' => 'won',
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$rows = DB::table('activity_log')
|
||||
->where('event', 'deal.status_changed')
|
||||
->whereIn('deal_id', $deals->pluck('id'))
|
||||
->get();
|
||||
|
||||
expect($rows)->toHaveCount(3);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
expect((int) $row->user_id)->toBe($this->user->id)
|
||||
->and((string) $row->ip_address)->toBe('10.9.8.7');
|
||||
}
|
||||
});
|
||||
|
||||
it('bulk destroy записывает user_id и ip_address в activity_log', function () {
|
||||
$deals = Deal::factory()->count(2)
|
||||
->for($this->tenant)
|
||||
->for($this->project)
|
||||
->create();
|
||||
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '192.168.1.1'])
|
||||
->deleteJson('/api/deals', [
|
||||
'ids' => $deals->pluck('id')->all(),
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$rows = DB::table('activity_log')
|
||||
->where('event', 'deal.deleted')
|
||||
->whereIn('deal_id', $deals->pluck('id'))
|
||||
->get();
|
||||
|
||||
expect($rows)->toHaveCount(2);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
expect((int) $row->user_id)->toBe($this->user->id)
|
||||
->and((string) $row->ip_address)->toBe('192.168.1.1');
|
||||
}
|
||||
});
|
||||
|
||||
it('bulk restore записывает user_id и ip_address в activity_log', function () {
|
||||
$deals = Deal::factory()->count(2)
|
||||
->for($this->tenant)
|
||||
->for($this->project)
|
||||
->create();
|
||||
|
||||
// Soft-delete first
|
||||
$this->deleteJson('/api/deals', [
|
||||
'ids' => $deals->pluck('id')->all(),
|
||||
])->assertOk();
|
||||
|
||||
// Now restore
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '172.16.0.5'])
|
||||
->postJson('/api/deals/restore', [
|
||||
'ids' => $deals->pluck('id')->all(),
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$rows = DB::table('activity_log')
|
||||
->where('event', 'deal.restored')
|
||||
->whereIn('deal_id', $deals->pluck('id'))
|
||||
->get();
|
||||
|
||||
expect($rows)->toHaveCount(2);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
expect((int) $row->user_id)->toBe($this->user->id)
|
||||
->and((string) $row->ip_address)->toBe('172.16.0.5');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
/**
|
||||
* Go-live security: lookup/дашборд эндпоинты до этого были открыты (без
|
||||
* auth-middleware, tenant_id параметром) — любой неавторизованный мог получить
|
||||
* KPI/список пользователей произвольного тенанта по ?tenant_id={чужой}.
|
||||
*
|
||||
* Закрытие: auth:sanctum + tenant, tenant_id из authed-user (как DealController J1).
|
||||
*/
|
||||
|
||||
// --- 401 без авторизации ---
|
||||
|
||||
test('GET /api/dashboard/summary без авторизации возвращает 401', function () {
|
||||
$this->getJson('/api/dashboard/summary')->assertStatus(401);
|
||||
});
|
||||
|
||||
test('GET /api/managers без авторизации возвращает 401', function () {
|
||||
$this->getJson('/api/managers')->assertStatus(401);
|
||||
});
|
||||
|
||||
test('GET /api/lead-statuses без авторизации возвращает 401', function () {
|
||||
$this->getJson('/api/lead-statuses')->assertStatus(401);
|
||||
});
|
||||
|
||||
// --- cross-tenant: tenant_id из user, параметр чужого тенанта игнорируется ---
|
||||
|
||||
test('dashboard/summary берёт tenant из authed-user, игнорирует ?tenant_id чужого', function () {
|
||||
$mine = Tenant::factory()->create(['balance_rub' => '111.00', 'balance_leads' => 11]);
|
||||
$other = Tenant::factory()->create(['balance_rub' => '999.00', 'balance_leads' => 99]);
|
||||
$this->actingAs(User::factory()->for($mine)->create());
|
||||
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$other->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('balance.amount_rub', '111.00');
|
||||
});
|
||||
|
||||
test('managers берёт tenant из authed-user, не отдаёт пользователей чужого тенанта', function () {
|
||||
$mine = Tenant::factory()->create();
|
||||
$other = Tenant::factory()->create();
|
||||
$me = User::factory()->for($mine)->create(['first_name' => 'Свой', 'last_name' => 'Менеджер', 'is_active' => true]);
|
||||
User::factory()->for($other)->create(['first_name' => 'Чужой', 'last_name' => 'Менеджер', 'is_active' => true]);
|
||||
$this->actingAs($me);
|
||||
|
||||
$names = $this->getJson("/api/managers?tenant_id={$other->id}")
|
||||
->assertOk()
|
||||
->json('managers.*.name');
|
||||
|
||||
expect($names)->toContain('Свой М.');
|
||||
expect($names)->not->toContain('Чужой М.');
|
||||
});
|
||||
@@ -7,8 +7,13 @@ use App\Models\Tenant;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
// SaaS-admin impersonation запрашивает impersonation_tokens/tenants через
|
||||
// BYPASSRLS-подключение pgsql_supplier (RLS-фикс). Под DatabaseTransactions
|
||||
// данные default-подключения не видны pgsql_supplier до commit'а → SharesSupplierPdo
|
||||
// шарит PDO между подключениями (как в tests/Feature/Supplier/*).
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create([
|
||||
@@ -67,6 +72,20 @@ test('GET /api/admin/impersonation/active возвращает активные
|
||||
expect($sessions[0]['reason'])->toContain('active session');
|
||||
});
|
||||
|
||||
test('active() читает impersonation_tokens через BYPASSRLS-подключение pgsql_supplier (regression RLS-фикс)', function () {
|
||||
$connections = [];
|
||||
DB::listen(function ($query) use (&$connections) {
|
||||
if (str_contains($query->sql, 'impersonation_tokens')) {
|
||||
$connections[] = $query->connectionName;
|
||||
}
|
||||
});
|
||||
|
||||
$this->getJson('/api/admin/impersonation/active')->assertStatus(200);
|
||||
|
||||
expect($connections)->not->toBeEmpty();
|
||||
expect(array_values(array_unique($connections)))->toBe(['pgsql_supplier']);
|
||||
});
|
||||
|
||||
test('GET /api/admin/impersonation/recent возвращает завершённые сессии с длительностью', function () {
|
||||
ImpersonationToken::create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
|
||||
@@ -48,6 +48,21 @@ function runRouteJob(int $supplierLeadId): void
|
||||
|
||||
// `linkProjectToSupplier` helper now lives in tests/Pest.php — single source.
|
||||
|
||||
it('is terminal (does not throw / re-queue) when the supplier lead does not exist', function (): void {
|
||||
// Регрессия retry-шторма 21-22.05.2026: RouteSupplierLeadJob для удалённого лида №1
|
||||
// бросал ModelNotFoundException -> queue->failed() писал в failed_webhook_jobs ->
|
||||
// RetryFailedSupplierJobsCommand бесконечно перезапускал (25k+ записей).
|
||||
// «Лид не найден» — терминальная (не транзиентная) ошибка: повтор бессмыслен.
|
||||
$missingId = 999999;
|
||||
expect(SupplierLead::find($missingId))->toBeNull();
|
||||
|
||||
// Не должно бросать исключение (иначе сработает failed() -> retry-цикл).
|
||||
runRouteJob($missingId);
|
||||
|
||||
// Никаких побочных эффектов.
|
||||
expect(Deal::count())->toBe(0);
|
||||
});
|
||||
|
||||
it('routes 1 lead to N tenants — creates N deal copies (sharing-model)', function (): void {
|
||||
$supplier = SupplierProject::factory()->create([
|
||||
'platform' => 'B1',
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
@@ -9,11 +11,23 @@ use Illuminate\Support\Facades\DB;
|
||||
* Тесты GET /api/lead-statuses — глобальный lookup статусов воронки.
|
||||
*
|
||||
* Таблица lead_statuses не tenant-aware, seeded в schema.sql (5 системных
|
||||
* статусов воронки: new/viewed/in_progress/won/lost).
|
||||
* статусов воронки: new/viewed/in_progress/won/lost). Go-live: эндпоинт за
|
||||
* auth:sanctum (глобальная таблица — tenant-middleware не нужен).
|
||||
*/
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
/** Авторизоваться любым пользователем (lead-statuses требует только auth:sanctum). */
|
||||
function authLeadStatuses(): void
|
||||
{
|
||||
test()->actingAs(User::factory()->for(Tenant::factory())->create());
|
||||
}
|
||||
|
||||
test('GET /api/lead-statuses без авторизации возвращает 401', function () {
|
||||
$this->getJson('/api/lead-statuses')->assertStatus(401);
|
||||
});
|
||||
|
||||
test('GET /api/lead-statuses возвращает 200 и не пустой список', function () {
|
||||
authLeadStatuses();
|
||||
$r = $this->getJson('/api/lead-statuses');
|
||||
|
||||
$r->assertStatus(200);
|
||||
@@ -22,6 +36,7 @@ test('GET /api/lead-statuses возвращает 200 и не пустой сп
|
||||
});
|
||||
|
||||
test('GET /api/lead-statuses возвращает все 5 системных статусов из seed', function () {
|
||||
authLeadStatuses();
|
||||
$r = $this->getJson('/api/lead-statuses');
|
||||
|
||||
$slugs = collect($r->json('lead_statuses'))->pluck('slug')->all();
|
||||
@@ -32,6 +47,7 @@ test('GET /api/lead-statuses возвращает все 5 системных с
|
||||
});
|
||||
|
||||
test('GET /api/lead-statuses возвращает поля slug, name_ru, color_hex, sort_order, is_system', function () {
|
||||
authLeadStatuses();
|
||||
$r = $this->getJson('/api/lead-statuses');
|
||||
|
||||
$first = $r->json('lead_statuses.0');
|
||||
@@ -42,6 +58,7 @@ test('GET /api/lead-statuses возвращает поля slug, name_ru, color_
|
||||
});
|
||||
|
||||
test('GET /api/lead-statuses сортирует по sort_order', function () {
|
||||
authLeadStatuses();
|
||||
$r = $this->getJson('/api/lead-statuses');
|
||||
|
||||
$sortOrders = collect($r->json('lead_statuses'))->pluck('sort_order')->all();
|
||||
@@ -51,6 +68,7 @@ test('GET /api/lead-statuses сортирует по sort_order', function () {
|
||||
});
|
||||
|
||||
test('GET /api/lead-statuses включает кастомный slug, добавленный после seed', function () {
|
||||
authLeadStatuses();
|
||||
DB::table('lead_statuses')->insert([
|
||||
'slug' => 'custom_test_'.bin2hex(random_bytes(3)),
|
||||
'name_ru' => 'Кастомный тест',
|
||||
|
||||
@@ -15,7 +15,8 @@ beforeEach(function () {
|
||||
|
||||
test('GET /api/managers возвращает active users тенанта', function () {
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
User::factory()->for($this->tenant)->create([
|
||||
// actingAs одного из активных пользователей тенанта — он сам входит в список.
|
||||
$ivan = User::factory()->for($this->tenant)->create([
|
||||
'first_name' => 'Иван', 'last_name' => 'Петров', 'is_active' => true,
|
||||
]);
|
||||
User::factory()->for($this->tenant)->create([
|
||||
@@ -25,7 +26,8 @@ test('GET /api/managers возвращает active users тенанта', funct
|
||||
'first_name' => 'Удалённый', 'is_active' => false,
|
||||
]);
|
||||
|
||||
$r = $this->getJson('/api/managers?tenant_id='.$this->tenant->id);
|
||||
$this->actingAs($ivan);
|
||||
$r = $this->getJson('/api/managers');
|
||||
$r->assertStatus(200);
|
||||
$managers = $r->json('managers');
|
||||
expect($managers)->toHaveCount(2);
|
||||
@@ -35,28 +37,23 @@ test('GET /api/managers возвращает active users тенанта', funct
|
||||
|
||||
test('GET /api/managers возвращает initials с fallback на email', function () {
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
User::factory()->for($this->tenant)->create([
|
||||
$admin = User::factory()->for($this->tenant)->create([
|
||||
'email' => 'admin@example.ru',
|
||||
'first_name' => null,
|
||||
'last_name' => null,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$r = $this->getJson('/api/managers?tenant_id='.$this->tenant->id);
|
||||
$this->actingAs($admin);
|
||||
$r = $this->getJson('/api/managers');
|
||||
$r->assertStatus(200);
|
||||
$manager = $r->json('managers.0');
|
||||
expect($manager['name'])->toBe('admin@example.ru');
|
||||
expect($manager['initials'])->toBe('AD');
|
||||
});
|
||||
|
||||
test('GET /api/managers 422 без tenant_id', function () {
|
||||
$r = $this->getJson('/api/managers');
|
||||
$r->assertStatus(422);
|
||||
});
|
||||
|
||||
test('GET /api/managers 404 unknown tenant', function () {
|
||||
$r = $this->getJson('/api/managers?tenant_id=999999');
|
||||
$r->assertStatus(404);
|
||||
test('GET /api/managers без авторизации возвращает 401', function () {
|
||||
$this->getJson('/api/managers')->assertStatus(401);
|
||||
});
|
||||
|
||||
test('POST /api/deals 422 если manager_id не принадлежит tenant\'у', function () {
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* 152-ФЗ: pd_processing_log 'created' записывается при создании сделки
|
||||
* по всем трём путям — ручной API, поставщик (RouteSupplierLeadJob),
|
||||
* вебхук (ProcessWebhookJob).
|
||||
*/
|
||||
|
||||
use App\Jobs\ProcessWebhookJob;
|
||||
use App\Jobs\RouteSupplierLeadJob;
|
||||
use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierLead;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Billing\LedgerService;
|
||||
use App\Services\DuplicateDetector;
|
||||
use App\Services\LeadDistributor;
|
||||
use App\Services\LeadRouter;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\RegionTagResolver;
|
||||
use App\Services\SupplierProjects\SupplierProjectResolver;
|
||||
use Database\Seeders\PricingTierSeeder;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->seed(PricingTierSeeder::class);
|
||||
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Path A: manual deal creation via DealController::store()
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('writes pd_processing_log created (manual) when deal created via API', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$user = User::factory()->for($tenant)->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$before = DB::table('pd_processing_log')->where('purpose', 'lead_create_manual')->count();
|
||||
|
||||
$r = $this->postJson('/api/deals', [
|
||||
'project_name' => 'Тест ПД',
|
||||
'phone' => '+7 (999) 111-22-33',
|
||||
]);
|
||||
$r->assertStatus(201);
|
||||
|
||||
$dealId = $r->json('deal.id');
|
||||
|
||||
$rows = DB::table('pd_processing_log')
|
||||
->where('action', 'created')
|
||||
->where('purpose', 'lead_create_manual')
|
||||
->where('subject_type', 'lead')
|
||||
->where('subject_id', $dealId)
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('actor_tenant_user_id', $user->id)
|
||||
->whereNull('actor_admin_user_id')
|
||||
->count();
|
||||
|
||||
expect($rows)->toBe(1);
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Path B: supplier integration via RouteSupplierLeadJob
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('writes pd_processing_log created (supplier) when deal created via RouteSupplierLeadJob', function () {
|
||||
$supplier = SupplierProject::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'pd-test.ru',
|
||||
]);
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'supplier_b1_project_id' => $supplier->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'pd-test.ru',
|
||||
'is_active' => true,
|
||||
'delivered_today' => 0,
|
||||
'delivered_in_month' => 0,
|
||||
]);
|
||||
linkProjectToSupplier($project, $supplier);
|
||||
|
||||
$vid = 77741;
|
||||
$lead = SupplierLead::factory()->create([
|
||||
'supplier_project_id' => null,
|
||||
'platform' => 'B1',
|
||||
'vid' => $vid,
|
||||
'phone' => '79992223344',
|
||||
'raw_payload' => [
|
||||
'vid' => $vid,
|
||||
'project' => 'B1_pd-test.ru',
|
||||
'phone' => '79992223344',
|
||||
'time' => now()->getTimestamp(),
|
||||
],
|
||||
]);
|
||||
|
||||
(new RouteSupplierLeadJob($lead->id))->handle(
|
||||
app(LeadRouter::class),
|
||||
app(SupplierProjectResolver::class),
|
||||
app(DuplicateDetector::class),
|
||||
app(NotificationService::class),
|
||||
app(LedgerService::class),
|
||||
app(LeadDistributor::class),
|
||||
app(RegionTagResolver::class),
|
||||
);
|
||||
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
|
||||
$deal = Deal::query()->where('tenant_id', $tenant->id)->where('source_crm_id', $vid)->first();
|
||||
expect($deal)->not->toBeNull();
|
||||
|
||||
$rows = DB::table('pd_processing_log')
|
||||
->where('action', 'created')
|
||||
->where('purpose', 'lead_create_supplier')
|
||||
->where('subject_type', 'lead')
|
||||
->where('subject_id', $deal->id)
|
||||
->where('tenant_id', $tenant->id)
|
||||
->whereNull('actor_tenant_user_id')
|
||||
->whereNull('actor_admin_user_id')
|
||||
->count();
|
||||
|
||||
expect($rows)->toBe(1);
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Path C: webhook via ProcessWebhookJob
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('writes pd_processing_log created (webhook) when deal created via ProcessWebhookJob', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
|
||||
$vid = 55566;
|
||||
(new ProcessWebhookJob($tenant->id, [
|
||||
'vid' => $vid,
|
||||
'project' => 'B2_PdWebhookTest',
|
||||
'tag' => 'PdWebhookTest',
|
||||
'phone' => '79001112233',
|
||||
'phones' => ['79001112233'],
|
||||
'time' => time(),
|
||||
]))->handle();
|
||||
|
||||
$deal = Deal::query()->where('tenant_id', $tenant->id)->where('source_crm_id', $vid)->first();
|
||||
expect($deal)->not->toBeNull();
|
||||
|
||||
$rows = DB::table('pd_processing_log')
|
||||
->where('action', 'created')
|
||||
->where('purpose', 'lead_create_webhook')
|
||||
->where('subject_type', 'lead')
|
||||
->where('subject_id', $deal->id)
|
||||
->where('tenant_id', $tenant->id)
|
||||
->whereNull('actor_tenant_user_id')
|
||||
->whereNull('actor_admin_user_id')
|
||||
->count();
|
||||
|
||||
expect($rows)->toBe(1);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
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();
|
||||
$this->user = User::factory()->for($this->tenant)->create();
|
||||
$this->actingAs($this->user);
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
$this->project = Project::factory()->for($this->tenant)->create();
|
||||
Deal::factory()->count(3)->for($this->tenant)->for($this->project)->create();
|
||||
});
|
||||
|
||||
it('pd exported on deals CSV export', function () {
|
||||
$r = $this->post('/api/deals/export', ['format' => 'csv']);
|
||||
$r->assertStatus(200);
|
||||
$pd = DB::table('pd_processing_log')->where('action', 'exported')->latest('id')->first();
|
||||
expect($pd)->not->toBeNull()
|
||||
->and($pd->subject_type)->toBe('lead')
|
||||
->and($pd->subject_id)->toBeNull()
|
||||
->and($pd->purpose)->toBe('deals_export_csv')
|
||||
->and((int) $pd->actor_tenant_user_id)->toBe($this->user->id);
|
||||
});
|
||||
|
||||
it('pd exported with xlsx purpose', function () {
|
||||
$r = $this->post('/api/deals/export', ['format' => 'xlsx']);
|
||||
$r->assertStatus(200);
|
||||
$pd = DB::table('pd_processing_log')->where('action', 'exported')->latest('id')->first();
|
||||
expect($pd)->not->toBeNull()
|
||||
->and($pd->subject_type)->toBe('lead')
|
||||
->and($pd->subject_id)->toBeNull()
|
||||
->and($pd->purpose)->toBe('deals_export_xlsx')
|
||||
->and((int) $pd->actor_tenant_user_id)->toBe($this->user->id);
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* 152-ФЗ: pd_processing_log 'created' записывается при создании сделки
|
||||
* через исторический импорт (HistoricalImportService).
|
||||
*/
|
||||
|
||||
use App\Models\ImportLog;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Import\CsvLeadsParser;
|
||||
use App\Services\Import\HistoricalImportService;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
it('writes pd_processing_log created on historical import for each new deal', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->for($tenant)->create();
|
||||
DB::statement('SET app.current_tenant_id = '.$tenant->id);
|
||||
|
||||
$log = ImportLog::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $user->id,
|
||||
'filename' => 'leads.csv',
|
||||
'file_path' => 'imports/x.csv',
|
||||
'dry_run' => false,
|
||||
]);
|
||||
|
||||
$header = "\xEF\xBB\xBF".'id,Проект,Тег проекта,Телефон,Создано,Напоминание,Комментарий,Состояние,Имя';
|
||||
$rows = array_merge(
|
||||
(new CsvLeadsParser)->parse($header."\n".'9901,Окна,окна,79161000001,2023/07/10 10:00:00,,,Новые,')->rows,
|
||||
(new CsvLeadsParser)->parse($header."\n".'9902,Окна,окна,79161000002,2023/07/10 10:00:00,,,Новые,')->rows,
|
||||
);
|
||||
|
||||
app(HistoricalImportService::class)->import($tenant->id, $user->id, $log, $rows);
|
||||
|
||||
$pd = DB::table('pd_processing_log')
|
||||
->where('action', 'created')
|
||||
->where('purpose', 'lead_create_import_'.$log->id)
|
||||
->get();
|
||||
|
||||
expect($pd)->toHaveCount(2);
|
||||
|
||||
foreach ($pd as $r) {
|
||||
expect($r->subject_type)->toBe('lead')
|
||||
->and((int) $r->actor_tenant_user_id)->toBe($user->id)
|
||||
->and($r->actor_admin_user_id)->toBeNull()
|
||||
->and($r->subject_id)->not->toBeNull()
|
||||
->and((int) $r->tenant_id)->toBe($tenant->id);
|
||||
}
|
||||
});
|
||||
|
||||
it('does NOT write pd_processing_log on dry_run import', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->for($tenant)->create();
|
||||
DB::statement('SET app.current_tenant_id = '.$tenant->id);
|
||||
|
||||
$log = ImportLog::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $user->id,
|
||||
'filename' => 'leads.csv',
|
||||
'file_path' => 'imports/x.csv',
|
||||
'dry_run' => true,
|
||||
]);
|
||||
|
||||
$header = "\xEF\xBB\xBF".'id,Проект,Тег проекта,Телефон,Создано,Напоминание,Комментарий,Состояние,Имя';
|
||||
$rows = (new CsvLeadsParser)->parse($header."\n".'9903,Окна,окна,79161000003,2023/07/10 10:00:00,,,Новые,')->rows;
|
||||
|
||||
app(HistoricalImportService::class)->import($tenant->id, $user->id, $log, $rows);
|
||||
|
||||
$count = DB::table('pd_processing_log')
|
||||
->where('purpose', 'lead_create_import_'.$log->id)
|
||||
->count();
|
||||
|
||||
expect($count)->toBe(0);
|
||||
});
|
||||
|
||||
it('does NOT write pd_processing_log on import UPDATE (idempotent re-import)', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->for($tenant)->create();
|
||||
DB::statement('SET app.current_tenant_id = '.$tenant->id);
|
||||
|
||||
$header = "\xEF\xBB\xBF".'id,Проект,Тег проекта,Телефон,Создано,Напоминание,Комментарий,Состояние,Имя';
|
||||
|
||||
// First import — creates the deal
|
||||
$log1 = ImportLog::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $user->id,
|
||||
'filename' => 'leads.csv',
|
||||
'file_path' => 'imports/x.csv',
|
||||
'dry_run' => false,
|
||||
]);
|
||||
$rows1 = (new CsvLeadsParser)->parse($header."\n".'9904,Окна,окна,79161000004,2023/07/10 10:00:00,,,Новые,')->rows;
|
||||
app(HistoricalImportService::class)->import($tenant->id, $user->id, $log1, $rows1);
|
||||
|
||||
// Second import — updates the same deal
|
||||
$log2 = ImportLog::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $user->id,
|
||||
'filename' => 'leads2.csv',
|
||||
'file_path' => 'imports/x2.csv',
|
||||
'dry_run' => false,
|
||||
]);
|
||||
$rows2 = (new CsvLeadsParser)->parse($header."\n".'9904,Окна,окна,79161000004,2023/07/10 10:00:00,,,Оплачено,')->rows;
|
||||
app(HistoricalImportService::class)->import($tenant->id, $user->id, $log2, $rows2);
|
||||
|
||||
// Only the first import wrote a pd log entry
|
||||
$countLog1 = DB::table('pd_processing_log')
|
||||
->where('action', 'created')
|
||||
->where('purpose', 'lead_create_import_'.$log1->id)
|
||||
->count();
|
||||
$countLog2 = DB::table('pd_processing_log')
|
||||
->where('action', 'created')
|
||||
->where('purpose', 'lead_create_import_'.$log2->id)
|
||||
->count();
|
||||
|
||||
expect($countLog1)->toBe(1)
|
||||
->and($countLog2)->toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
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();
|
||||
$this->user = User::factory()->for($this->tenant)->create();
|
||||
$this->actingAs($this->user);
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
$this->project = Project::factory()->for($this->tenant)->create();
|
||||
$this->deal = Deal::factory()->for($this->tenant)->for($this->project)->create();
|
||||
});
|
||||
|
||||
it('writes pd_processing_log viewed when deal card opened', function () {
|
||||
$this->getJson("/api/deals/{$this->deal->id}")->assertOk();
|
||||
|
||||
$row = DB::table('pd_processing_log')->where('action', 'viewed')->latest('id')->first();
|
||||
expect($row)->not->toBeNull()
|
||||
->and($row->subject_type)->toBe('lead')
|
||||
->and((int) $row->subject_id)->toBe($this->deal->id)
|
||||
->and((int) $row->actor_tenant_user_id)->toBe($this->user->id)
|
||||
->and($row->purpose)->toBe('lead_card_view');
|
||||
});
|
||||
|
||||
it('does not write pd_processing_log for 404 lookups', function () {
|
||||
$before = DB::table('pd_processing_log')->count();
|
||||
$this->getJson('/api/deals/999999')->assertNotFound();
|
||||
expect(DB::table('pd_processing_log')->count())->toBe($before);
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ImpersonationToken;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create(['contact_email' => 'tenant-admin@example.ru']);
|
||||
$this->adminId = DB::table('saas_admin_users')->insertGetId([
|
||||
'email' => 'admin-saas-'.uniqid().'@liderra.ru',
|
||||
'full_name' => 'SaaS Admin',
|
||||
'password_hash' => '$2y$04$dummy-hash-for-test',
|
||||
'role' => 'support',
|
||||
'is_active' => true,
|
||||
'sso_provider' => 'local',
|
||||
'is_break_glass' => false,
|
||||
]);
|
||||
});
|
||||
|
||||
it('init writes saas_admin_audit_log impersonation.init', function () {
|
||||
$reason = 'support investigation '.str_repeat('x', 30);
|
||||
$r = $this->postJson('/api/admin/impersonation/init', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'requested_by' => $this->adminId,
|
||||
'reason' => $reason,
|
||||
])->assertOk();
|
||||
|
||||
$row = DB::table('saas_admin_audit_log')->where('action', 'impersonation.init')->latest('id')->first();
|
||||
expect($row)->not->toBeNull()
|
||||
->and((int) $row->admin_user_id)->toBe($this->adminId)
|
||||
->and((int) $row->target_id)->toBe($this->tenant->id)
|
||||
->and($row->reason)->toBe($reason);
|
||||
});
|
||||
|
||||
it('verify writes saas_audit impersonation.verify + pd_processing_log viewed', function () {
|
||||
$token = ImpersonationToken::create([
|
||||
'tenant_id' => $this->tenant->id, 'requested_by' => $this->adminId,
|
||||
'code_hash' => Hash::make('123456'),
|
||||
'reason' => 'verify case '.str_repeat('y', 30),
|
||||
'sent_to_email' => 'a@b.ru', 'expires_at' => now()->addMinutes(15),
|
||||
]);
|
||||
|
||||
$this->postJson('/api/admin/impersonation/verify', ['token_id' => $token->id, 'code' => '123456'])->assertOk();
|
||||
|
||||
expect(DB::table('saas_admin_audit_log')->where('action', 'impersonation.verify')->count())->toBe(1)
|
||||
->and(DB::table('pd_processing_log')
|
||||
->where('action', 'viewed')
|
||||
->where('purpose', 'impersonation_session_'.$token->id)
|
||||
->where('actor_admin_user_id', $this->adminId)
|
||||
->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('end writes saas_admin_audit_log impersonation.end', function () {
|
||||
$token = ImpersonationToken::create([
|
||||
'tenant_id' => $this->tenant->id, 'requested_by' => $this->adminId,
|
||||
'code_hash' => Hash::make('123456'),
|
||||
'reason' => 'end case '.str_repeat('z', 30),
|
||||
'sent_to_email' => 'a@b.ru', 'expires_at' => now()->addMinutes(15),
|
||||
'used_at' => now()->subMinutes(5),
|
||||
]);
|
||||
|
||||
$this->postJson('/api/admin/impersonation/end', ['token_id' => $token->id])->assertOk();
|
||||
|
||||
expect(DB::table('saas_admin_audit_log')->where('action', 'impersonation.end')->count())->toBe(1);
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* 152-ФЗ integration: pd_processing_log captures the full deal lifecycle
|
||||
* for one tenant — create → view → export → delete.
|
||||
*
|
||||
* Uses the deterministic manual-API path (no supplier/webhook jobs) so the
|
||||
* test is robust and self-contained.
|
||||
*
|
||||
* Convention mirrors: DealCreateTest / DealExportPdLogTest /
|
||||
* DealViewAccessLogTest / ReportFileDeletePdLogTest
|
||||
*/
|
||||
|
||||
use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
use App\Models\ReportJob;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Storage::fake('local');
|
||||
|
||||
$this->tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$this->user = User::factory()->for($this->tenant)->create();
|
||||
$this->actingAs($this->user);
|
||||
DB::statement('SET app.current_tenant_id = '.(int) $this->tenant->id);
|
||||
$this->project = Project::factory()->for($this->tenant)->create(['name' => 'PD Flow Test']);
|
||||
});
|
||||
|
||||
it('records pd events through the whole deal lifecycle (create → view → export → delete)', function () {
|
||||
|
||||
// ── 1. CREATE (manual) → pd action='created', purpose='lead_create_manual' ──
|
||||
$created = $this->postJson('/api/deals', [
|
||||
'project_name' => $this->project->name,
|
||||
'phone' => '+7 (999) 123-45-67',
|
||||
]);
|
||||
$created->assertStatus(201);
|
||||
$dealId = (int) $created->json('deal.id');
|
||||
expect($dealId)->toBeGreaterThan(0);
|
||||
|
||||
// ── 2. VIEW → pd action='viewed', purpose='lead_card_view' ──
|
||||
$this->getJson("/api/deals/{$dealId}")->assertOk();
|
||||
|
||||
// ── 3. EXPORT → pd action='exported', purpose='deals_export_csv' ──
|
||||
// Mirror: DealExportPdLogTest — POST /api/deals/export with format=csv
|
||||
// We need at least one deal in the tenant for a non-empty export; the
|
||||
// deal we just created qualifies.
|
||||
$exported = $this->post('/api/deals/export', ['format' => 'csv']);
|
||||
$exported->assertStatus(200);
|
||||
|
||||
// ── 4. DELETE report file → pd action='deleted', purpose='report_file_{id}' ──
|
||||
// Mirror: ReportFileDeletePdLogTest — create a DONE ReportJob with file_path,
|
||||
// then DELETE /api/reports/jobs/{id}.
|
||||
$job = ReportJob::create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'type' => 'deals_export',
|
||||
'parameters' => ['format' => 'csv', 'date_from' => '2026-01-01', 'date_to' => '2026-12-31'],
|
||||
'status' => ReportJob::STATUS_DONE,
|
||||
'file_path' => 'reports/'.(int) $this->tenant->id.'/pd_flow_test.csv',
|
||||
]);
|
||||
|
||||
$this->deleteJson("/api/reports/jobs/{$job->id}")->assertOk();
|
||||
|
||||
// ── ASSERT — scoped to THIS tenant ────────────────────────────────────────
|
||||
$rows = DB::table('pd_processing_log')
|
||||
->where('tenant_id', $this->tenant->id)
|
||||
->get();
|
||||
$byAction = $rows->groupBy('action');
|
||||
|
||||
// All four lifecycle actions must be present.
|
||||
expect($byAction->has('created'))->toBeTrue()
|
||||
->and($byAction->has('viewed'))->toBeTrue()
|
||||
->and($byAction->has('exported'))->toBeTrue()
|
||||
->and($byAction->has('deleted'))->toBeTrue();
|
||||
|
||||
// Correct purpose for each action.
|
||||
expect($rows->firstWhere('action', 'created')->purpose)->toBe('lead_create_manual');
|
||||
expect($rows->firstWhere('action', 'viewed')->purpose)->toBe('lead_card_view');
|
||||
expect($rows->contains(fn ($r) => $r->action === 'exported' && $r->purpose === 'deals_export_csv'))->toBeTrue();
|
||||
expect($rows->firstWhere('action', 'deleted')->purpose)->toBe('report_file_'.$job->id);
|
||||
|
||||
// 'created' and 'viewed' rows are tied to the deal we created.
|
||||
expect((int) $rows->firstWhere('action', 'created')->subject_id)->toBe($dealId);
|
||||
expect((int) $rows->firstWhere('action', 'viewed')->subject_id)->toBe($dealId);
|
||||
|
||||
// All rows carry the correct actor.
|
||||
foreach (['created', 'viewed', 'exported', 'deleted'] as $action) {
|
||||
$row = $rows->firstWhere('action', $action);
|
||||
expect((int) $row->actor_tenant_user_id)->toBe($this->user->id);
|
||||
expect($row->actor_admin_user_id)->toBeNull();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ReportJob;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Storage::fake('local');
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
|
||||
$this->actingAs($this->user);
|
||||
DB::statement('SET app.current_tenant_id = '.(int) $this->tenant->id);
|
||||
});
|
||||
|
||||
it('writes pd deleted when a report file is destroyed', function () {
|
||||
$job = ReportJob::create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'type' => 'deals_export',
|
||||
'parameters' => ['format' => 'csv', 'date_from' => '2026-04-01', 'date_to' => '2026-04-30'],
|
||||
'status' => ReportJob::STATUS_DONE,
|
||||
'file_path' => 'reports/'.(int) $this->tenant->id.'/test.csv',
|
||||
]);
|
||||
|
||||
$this->deleteJson("/api/reports/jobs/{$job->id}")->assertOk();
|
||||
|
||||
$pd = DB::table('pd_processing_log')
|
||||
->where('action', 'deleted')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
expect($pd)->not->toBeNull()
|
||||
->and($pd->subject_type)->toBe('lead')
|
||||
->and($pd->purpose)->toBe('report_file_'.$job->id)
|
||||
->and((int) $pd->actor_tenant_user_id)->toBe($this->user->id)
|
||||
->and((int) $pd->tenant_id)->toBe((int) $this->tenant->id);
|
||||
});
|
||||
|
||||
it('writes pd deleted (system actor) when cron cleanup-expired runs', function () {
|
||||
Storage::disk('local')->put('reports/'.(int) $this->tenant->id.'/cron1.csv', 'data');
|
||||
Storage::disk('local')->put('reports/'.(int) $this->tenant->id.'/cron2.csv', 'data');
|
||||
|
||||
ReportJob::create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'type' => 'deals_export',
|
||||
'parameters' => ['format' => 'csv'],
|
||||
'status' => ReportJob::STATUS_DONE,
|
||||
'file_path' => 'reports/'.(int) $this->tenant->id.'/cron1.csv',
|
||||
'expires_at' => now()->subDay(),
|
||||
]);
|
||||
ReportJob::create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'type' => 'deals_export',
|
||||
'parameters' => ['format' => 'csv'],
|
||||
'status' => ReportJob::STATUS_DONE,
|
||||
'file_path' => 'reports/'.(int) $this->tenant->id.'/cron2.csv',
|
||||
'expires_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$this->artisan('reports:cleanup-expired')->assertExitCode(0);
|
||||
|
||||
$rows = DB::table('pd_processing_log')
|
||||
->where('action', 'deleted')
|
||||
->where('purpose', 'like', 'report_cleanup_expired_%')
|
||||
->where('tenant_id', $this->tenant->id)
|
||||
->get();
|
||||
|
||||
expect($rows)->toHaveCount(2);
|
||||
foreach ($rows as $r) {
|
||||
expect($r->actor_tenant_user_id)->toBeNull()
|
||||
->and($r->actor_admin_user_id)->toBeNull()
|
||||
->and($r->subject_type)->toBe('lead');
|
||||
}
|
||||
});
|
||||
@@ -8,10 +8,13 @@ use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Supplier\Channel\SupplierProjectChannel;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
// TestCase auto-bound via tests/Pest.php (->in('Feature')).
|
||||
// DatabaseTransactions — per-test isolation.
|
||||
uses(DatabaseTransactions::class);
|
||||
// SharesSupplierPdo — SyncSupplierProjectJob теперь пишет через pgsql_supplier (BYPASSRLS);
|
||||
// без шаринга PDO записи джоба не видны default-connection ассертам под DatabaseTransactions.
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||||
|
||||
/**
|
||||
* Хелпер: разрешает SupplierProjectChannel из контейнера и вызывает Job.handle().
|
||||
|
||||
@@ -6,28 +6,34 @@ use App\Jobs\SyncSupplierProjectJob;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
beforeEach(fn () => Queue::fake());
|
||||
|
||||
it('destroy archives project (sets archived_at, is_active=false)', function () {
|
||||
it('destroy hard-deletes a project with no deals', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]);
|
||||
|
||||
$this->actingAs($user)->deleteJson("/api/projects/{$project->id}")->assertNoContent();
|
||||
|
||||
$project->refresh();
|
||||
expect($project->is_active)->toBeFalse();
|
||||
expect($project->archived_at)->not->toBeNull();
|
||||
expect(Project::find($project->id))->toBeNull();
|
||||
});
|
||||
|
||||
it('destroy returns 409 if already archived', function () {
|
||||
it('destroy returns 422 if project has deals', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => now()]);
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]);
|
||||
DB::table('deals')->insert([
|
||||
'tenant_id' => $tenant->id, 'project_id' => $project->id,
|
||||
'phone' => '79990001100', 'status' => 'new',
|
||||
'received_at' => now(), 'created_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)->deleteJson("/api/projects/{$project->id}")->assertStatus(409);
|
||||
$this->actingAs($user)->deleteJson("/api/projects/{$project->id}")->assertStatus(422);
|
||||
|
||||
expect(Project::find($project->id))->not->toBeNull();
|
||||
});
|
||||
|
||||
it('sync re-dispatches SyncSupplierProjectJob', function () {
|
||||
@@ -81,16 +87,16 @@ it('bulk filters out cross-tenant ids silently', function () {
|
||||
expect($pB->fresh()->is_active)->toBeTrue();
|
||||
});
|
||||
|
||||
it('bulk archive sets archived_at on multiple', function () {
|
||||
it('bulk delete removes project with no deals', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$p1 = Project::factory()->create(['tenant_id' => $tenant->id]);
|
||||
|
||||
$this->actingAs($user)->postJson('/api/projects/bulk', [
|
||||
'action' => 'archive', 'ids' => [$p1->id],
|
||||
])->assertOk();
|
||||
'action' => 'delete', 'ids' => [$p1->id],
|
||||
])->assertOk()->assertJsonPath('updated', 1);
|
||||
|
||||
expect($p1->fresh()->archived_at)->not->toBeNull();
|
||||
expect(Project::find($p1->id))->toBeNull();
|
||||
});
|
||||
|
||||
it('bulk rejects > 500 ids', function () {
|
||||
|
||||
@@ -16,7 +16,7 @@ it('returns paginated list of active projects for current tenant', function () {
|
||||
$response->assertOk();
|
||||
$response->assertJsonStructure([
|
||||
'data' => [['id', 'name', 'signal_type', 'signal_identifier', 'daily_limit_target',
|
||||
'delivered_today', 'is_active', 'archived_at', 'sync_status']],
|
||||
'delivered_today', 'is_active', 'sync_status']],
|
||||
'meta' => ['current_page', 'per_page', 'total'],
|
||||
]);
|
||||
expect($response->json('meta.total'))->toBe(3);
|
||||
@@ -45,23 +45,24 @@ it('isolates projects per tenant (RLS)', function () {
|
||||
expect($response->json('meta.total'))->toBe(2);
|
||||
});
|
||||
|
||||
it('excludes archived projects by default', function () {
|
||||
it('returns all projects by default (archive feature removed in v8.27)', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => now()]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id]);
|
||||
|
||||
$response = $this->actingAs($user)->getJson('/api/projects');
|
||||
|
||||
expect($response->json('meta.total'))->toBe(1);
|
||||
expect($response->json('meta.total'))->toBe(2);
|
||||
});
|
||||
|
||||
it('returns archived when status=archived requested', function () {
|
||||
it('status=active returns only is_active=true projects', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => now()]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => false]);
|
||||
|
||||
$response = $this->actingAs($user)->getJson('/api/projects?status=archived');
|
||||
$response = $this->actingAs($user)->getJson('/api/projects?status=active');
|
||||
|
||||
expect($response->json('meta.total'))->toBe(1);
|
||||
});
|
||||
@@ -140,19 +141,18 @@ it('search is case-insensitive for Cyrillic substrings', function () {
|
||||
expect($partial->json('meta.total'))->toBe(1);
|
||||
});
|
||||
|
||||
it('show returns 200 for archived project (read access preserved)', function () {
|
||||
it('show returns 200 for any project by id', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'archived_at' => now(),
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'archived.ru',
|
||||
'signal_identifier' => 'myproject.ru',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->getJson("/api/projects/{$project->id}");
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('data.id'))->toBe($project->id);
|
||||
expect($response->json('data.archived_at'))->not->toBeNull();
|
||||
expect($response->json('data'))->not->toHaveKey('archived_at');
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
@@ -11,15 +10,6 @@ use Illuminate\Support\Facades\Schema;
|
||||
// DatabaseTransactions — изоляция; also ensures DB connection is bootstrapped.
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
it('projects table has archived_at column nullable timestamp', function () {
|
||||
expect(Schema::hasColumn('projects', 'archived_at'))->toBeTrue();
|
||||
$type = Schema::getColumnType('projects', 'archived_at');
|
||||
// PostgreSQL TIMESTAMPTZ → Doctrine/Laravel reports 'timestamptz' (not 'timestamp').
|
||||
expect($type)->toBe('timestamptz');
|
||||
});
|
||||
|
||||
it('Project model has archived_at in fillable and casts it to datetime', function () {
|
||||
$project = new Project;
|
||||
expect(in_array('archived_at', $project->getFillable(), true))->toBeTrue();
|
||||
expect($project->getCasts()['archived_at'] ?? null)->toBe('datetime');
|
||||
it('projects table does NOT have archived_at column (feature removed in v8.27)', function () {
|
||||
expect(Schema::hasColumn('projects', 'archived_at'))->toBeFalse();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Project\ProjectService;
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
beforeEach(function () {
|
||||
Queue::fake();
|
||||
$this->tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
});
|
||||
|
||||
function makeCall(array $over = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'name' => 'Проект A', 'signal_type' => 'call', 'signal_identifier' => '79991110000',
|
||||
'daily_limit_target' => 5, 'regions' => [], 'delivery_days_mask' => 31,
|
||||
], $over);
|
||||
}
|
||||
|
||||
it('blocks duplicate source within tenant with human message', function () {
|
||||
app(ProjectService::class)->create($this->tenant, makeCall());
|
||||
expect(fn () => app(ProjectService::class)
|
||||
->create($this->tenant, makeCall(['name' => 'Проект B'])))
|
||||
->toThrow(HttpResponseException::class);
|
||||
});
|
||||
|
||||
it('allows same source for a different tenant (sharing)', function () {
|
||||
$other = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
app(ProjectService::class)->create($this->tenant, makeCall());
|
||||
$p = app(ProjectService::class)->create($other, makeCall(['name' => 'Проект B']));
|
||||
expect($p)->toBeInstanceOf(Project::class);
|
||||
});
|
||||
|
||||
it('blocks duplicate name within tenant with human message (not SQL)', function () {
|
||||
app(ProjectService::class)->create($this->tenant, makeCall());
|
||||
try {
|
||||
app(ProjectService::class)
|
||||
->create($this->tenant, makeCall(['name' => 'Проект A', 'signal_identifier' => '79992220000']));
|
||||
$this->fail('expected HttpResponseException');
|
||||
} catch (HttpResponseException $e) {
|
||||
$body = $e->getResponse()->getData(true);
|
||||
expect($body['errors']['name'][0] ?? '')->not->toContain('SQLSTATE');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Project\ProjectService;
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
beforeEach(fn () => Queue::fake());
|
||||
|
||||
it('hard-deletes an empty project', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$project = app(ProjectService::class)->create($tenant, [
|
||||
'name' => 'Empty', 'signal_type' => 'call', 'signal_identifier' => '79991110000',
|
||||
'daily_limit_target' => 5, 'regions' => [], 'delivery_days_mask' => 31,
|
||||
]);
|
||||
|
||||
app(ProjectService::class)->delete($project);
|
||||
|
||||
expect(Project::find($project->id))->toBeNull();
|
||||
});
|
||||
|
||||
it('blocks delete when project has deals', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$project = app(ProjectService::class)->create($tenant, [
|
||||
'name' => 'WithDeals', 'signal_type' => 'call', 'signal_identifier' => '79991110000',
|
||||
'daily_limit_target' => 5, 'regions' => [], 'delivery_days_mask' => 31,
|
||||
]);
|
||||
DB::table('deals')->insert([
|
||||
'tenant_id' => $tenant->id, 'project_id' => $project->id, 'phone' => '79990001122',
|
||||
'status' => 'new', 'received_at' => now(), 'created_at' => now(),
|
||||
]);
|
||||
|
||||
expect(fn () => app(ProjectService::class)->delete($project))
|
||||
->toThrow(HttpResponseException::class);
|
||||
expect(Project::find($project->id))->not->toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Project\ProjectService;
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
beforeEach(fn () => Queue::fake());
|
||||
|
||||
it('blocks update that collides source with another project of same tenant', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$svc = app(ProjectService::class);
|
||||
$a = $svc->create($tenant, ['name' => 'A', 'signal_type' => 'call', 'signal_identifier' => '79991110000', 'daily_limit_target' => 5, 'regions' => [], 'delivery_days_mask' => 31]);
|
||||
$b = $svc->create($tenant, ['name' => 'B', 'signal_type' => 'call', 'signal_identifier' => '79992220000', 'daily_limit_target' => 5, 'regions' => [], 'delivery_days_mask' => 31]);
|
||||
|
||||
expect(fn () => $svc->update($b, ['signal_identifier' => '79991110000']))
|
||||
->toThrow(HttpResponseException::class);
|
||||
});
|
||||
|
||||
it('allows update keeping same source on the same project', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$svc = app(ProjectService::class);
|
||||
$a = $svc->create($tenant, ['name' => 'A', 'signal_type' => 'call', 'signal_identifier' => '79991110000', 'daily_limit_target' => 5, 'regions' => [], 'delivery_days_mask' => 31]);
|
||||
$updated = $svc->update($a, ['signal_identifier' => '79991110000', 'daily_limit_target' => 7]);
|
||||
expect($updated->daily_limit_target)->toBe(7);
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
it('renders QueryException as human JSON message, not SQLSTATE', function () {
|
||||
Route::get('/_test/boom-query', function () {
|
||||
throw new QueryException('pgsql', 'SELECT 1', [], new Exception('SQLSTATE[23505] duplicate key'));
|
||||
});
|
||||
|
||||
$res = $this->getJson('/_test/boom-query');
|
||||
$res->assertStatus(422);
|
||||
expect($res->json('message'))->not->toContain('SQLSTATE');
|
||||
expect($res->json('message'))->toContain('Не удалось');
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\Supplier\DeleteSupplierProjectJob;
|
||||
use App\Jobs\Supplier\SyncSupplierProjectsJob;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||||
|
||||
it('deletes donor at supplier when no consumers remain', function (): void {
|
||||
$sp = SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'call', 'unique_key' => '79991110000',
|
||||
'supplier_external_id' => '555', 'current_limit' => 1,
|
||||
]);
|
||||
|
||||
$mock = Mockery::mock(SupplierPortalClient::class);
|
||||
$mock->shouldReceive('deleteProject')->once()->with(555);
|
||||
app()->instance(SupplierPortalClient::class, $mock);
|
||||
|
||||
(new DeleteSupplierProjectJob([$sp->id]))->handle(app(SupplierPortalClient::class));
|
||||
|
||||
expect(SupplierProject::find($sp->id))->toBeNull();
|
||||
});
|
||||
|
||||
it('does NOT delete donor at supplier when other consumers remain; re-syncs', function (): void {
|
||||
Bus::fake([SyncSupplierProjectsJob::class]);
|
||||
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$sp = SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'call', 'unique_key' => '79991110001',
|
||||
'supplier_external_id' => '556', 'current_limit' => 1,
|
||||
]);
|
||||
$other = Project::factory()->create(['tenant_id' => $tenant->id]);
|
||||
|
||||
DB::table('project_supplier_links')->insert([
|
||||
'project_id' => $other->id,
|
||||
'supplier_project_id' => $sp->id,
|
||||
'platform' => 'B1',
|
||||
'subject_code' => null,
|
||||
]);
|
||||
|
||||
$mock = Mockery::mock(SupplierPortalClient::class);
|
||||
$mock->shouldNotReceive('deleteProject');
|
||||
app()->instance(SupplierPortalClient::class, $mock);
|
||||
|
||||
(new DeleteSupplierProjectJob([$sp->id]))->handle(app(SupplierPortalClient::class));
|
||||
|
||||
expect(SupplierProject::find($sp->id))->not->toBeNull();
|
||||
Bus::assertDispatched(SyncSupplierProjectsJob::class);
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
User::factory()->create(['tenant_id' => $this->tenant->id, 'email' => 'info@lkomega.ru']);
|
||||
|
||||
$client = Mockery::mock(SupplierPortalClient::class);
|
||||
$client->shouldReceive('listProjects')->andReturn([
|
||||
['id' => '4001', 'src' => 'rt', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '', 'workdays' => ['1', '2', '3', '4', '5']],
|
||||
['id' => '4002', 'src' => 'bl', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '', 'workdays' => ['1', '2', '3', '4', '5']],
|
||||
['id' => '4003', 'src' => 'mt', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '', 'workdays' => ['1', '2', '3', '4', '5']],
|
||||
]);
|
||||
$this->app->instance(SupplierPortalClient::class, $client);
|
||||
});
|
||||
|
||||
test('dry-run prints plan and writes nothing', function (): void {
|
||||
Http::fake();
|
||||
|
||||
$this->artisan('supplier:import-projects', ['--tenant' => 'info@lkomega.ru'])
|
||||
->assertExitCode(0);
|
||||
|
||||
expect(Project::on('pgsql_supplier')->where('tenant_id', $this->tenant->id)->count())->toBe(0);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('--commit writes projects', function (): void {
|
||||
Http::fake();
|
||||
|
||||
$this->artisan('supplier:import-projects', ['--tenant' => 'info@lkomega.ru', '--commit' => true])
|
||||
->assertExitCode(0);
|
||||
|
||||
expect(Project::on('pgsql_supplier')
|
||||
->where('tenant_id', $this->tenant->id)
|
||||
->where('signal_identifier', '79991112233')->count())->toBe(1);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('unknown tenant email → non-zero exit, no write', function (): void {
|
||||
$this->artisan('supplier:import-projects', ['--tenant' => 'nobody@nowhere.ru', '--commit' => true])
|
||||
->assertExitCode(1);
|
||||
});
|
||||
@@ -21,6 +21,7 @@ declare(strict_types=1);
|
||||
*/
|
||||
|
||||
use App\Jobs\RouteSupplierLeadJob;
|
||||
use App\Jobs\SyncSupplierProjectJob;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
@@ -46,6 +47,14 @@ test('RouteSupplierLeadJob declares DB_CONNECTION = pgsql_supplier (Plan 3 Task
|
||||
expect(RouteSupplierLeadJob::DB_CONNECTION)->toBe('pgsql_supplier');
|
||||
});
|
||||
|
||||
test('SyncSupplierProjectJob declares DB_CONNECTION = pgsql_supplier (queue worker has no tenant GUC)', function (): void {
|
||||
// Дублирует RouteSupplierLeadJob: создание/правка проекта тоже запускается из очереди,
|
||||
// где SetTenantContext-прослойка не отработала. Под обычной ролью crm_app_user
|
||||
// SELECT по projects падает 42704 (unrecognized configuration parameter
|
||||
// "app.current_tenant_id"). Все DB-операции джоба обязаны идти через pgsql_supplier (BYPASSRLS).
|
||||
expect(SyncSupplierProjectJob::DB_CONNECTION)->toBe('pgsql_supplier');
|
||||
});
|
||||
|
||||
test('failed_webhook_jobs INSERT с tenant_id=NULL проходит под pgsql_supplier (BLOCKER #6)', function (): void {
|
||||
// Под обычной ролью policy tenant_isolation USING (tenant_id = current_setting('app.current_tenant_id')::bigint)
|
||||
// отвергает NULL (NULL :: bigint = NULL, NULL = '0'::bigint → NULL → false).
|
||||
|
||||
@@ -99,7 +99,9 @@ it('saveProject maps signalType call → type:"calls" and B2 → srcbl=true (sin
|
||||
&& $request['srcrt'] === false
|
||||
&& $request['srcbl'] === true
|
||||
&& $request['srcmt'] === false
|
||||
&& $request['regions'] === [77]
|
||||
// Лидерра-код 77 (Тюменская обл., конституционный порядок) переводится
|
||||
// в код поставщика 72 (ГИБДД). См. App\Support\SupplierRegions.
|
||||
&& $request['regions'] === [72]
|
||||
&& $request['regions_reverse'] === true
|
||||
&& $request['status'] === false;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Supplier\Import\SupplierProjectImporter;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $rows
|
||||
*/
|
||||
function importerWithRows(array $rows): SupplierProjectImporter
|
||||
{
|
||||
$client = Mockery::mock(SupplierPortalClient::class);
|
||||
$client->shouldReceive('listProjects')->andReturn($rows);
|
||||
|
||||
return new SupplierProjectImporter($client);
|
||||
}
|
||||
|
||||
test('buildPlan groups B1/B2/B3 call rows into one planned project, limit = sum', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$plan = importerWithRows([
|
||||
['id' => '4001', 'src' => 'rt', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '', 'workdays' => ['1', '2', '3', '4', '5']],
|
||||
['id' => '4002', 'src' => 'bl', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '', 'workdays' => ['1', '2', '3', '4', '5']],
|
||||
['id' => '4003', 'src' => 'mt', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '', 'workdays' => ['1', '2', '3', '4', '5']],
|
||||
])->buildPlan($tenant->id);
|
||||
|
||||
expect($plan['planned'])->toHaveCount(1);
|
||||
$p = $plan['planned'][0];
|
||||
expect($p['signal_type'])->toBe('call');
|
||||
expect($p['signal_identifier'])->toBe('79991112233');
|
||||
expect($p['daily_limit_target'])->toBe(18);
|
||||
expect($p['delivery_days_mask'])->toBe(31);
|
||||
expect($p['tag'])->toBe('Каранга');
|
||||
expect($p['regions'])->toBe([]);
|
||||
expect(collect($p['platforms'])->pluck('platform')->sort()->values()->all())->toBe(['B1', 'B2', 'B3']);
|
||||
expect(collect($p['platforms'])->firstWhere('platform', 'B1')['external_id'])->toBe(4001);
|
||||
});
|
||||
|
||||
test('buildPlan skips inactive rows (status=false)', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$plan = importerWithRows([
|
||||
['id' => '5001', 'src' => 'rt', 'type' => 'calls', 'content' => '79995550000', 'tag' => 'X', 'lim' => '5', 'status' => false, 'regions' => '', 'workdays' => []],
|
||||
])->buildPlan($tenant->id);
|
||||
|
||||
expect($plan['planned'])->toHaveCount(0);
|
||||
});
|
||||
|
||||
test('buildPlan skips dop2 (unsupported source) and reports it', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$plan = importerWithRows([
|
||||
['id' => '6001', 'src' => 'dop2', 'type' => 'calls', 'content' => '79996660000', 'tag' => 'X', 'lim' => '5', 'status' => true, 'regions' => '', 'workdays' => []],
|
||||
])->buildPlan($tenant->id);
|
||||
|
||||
expect($plan['planned'])->toHaveCount(0);
|
||||
expect(collect($plan['skipped'])->pluck('reason'))->toContain('unsupported_source');
|
||||
});
|
||||
|
||||
test('buildPlan reverse-maps regions and unions across platforms', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$plan = importerWithRows([
|
||||
['id' => '7001', 'src' => 'rt', 'type' => 'hosts', 'content' => 'okna.ru', 'tag' => 'Окна', 'lim' => '3', 'status' => true, 'regions' => '24', 'workdays' => [], 'regions_reverse' => false],
|
||||
['id' => '7002', 'src' => 'bl', 'type' => 'hosts', 'content' => 'okna.ru', 'tag' => 'Окна', 'lim' => '3', 'status' => true, 'regions' => '77', 'workdays' => [], 'regions_reverse' => false],
|
||||
['id' => '7003', 'src' => 'mt', 'type' => 'hosts', 'content' => 'okna.ru', 'tag' => 'Окна', 'lim' => '3', 'status' => true, 'regions' => '24', 'workdays' => [], 'regions_reverse' => false],
|
||||
])->buildPlan($tenant->id);
|
||||
|
||||
expect($plan['planned'][0]['regions'])->toBe([29, 82]);
|
||||
});
|
||||
|
||||
test('buildPlan treats any empty-regions platform as all-Russia', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$plan = importerWithRows([
|
||||
['id' => '7101', 'src' => 'rt', 'type' => 'hosts', 'content' => 'all.ru', 'tag' => 'A', 'lim' => '3', 'status' => true, 'regions' => '24', 'workdays' => [], 'regions_reverse' => false],
|
||||
['id' => '7102', 'src' => 'bl', 'type' => 'hosts', 'content' => 'all.ru', 'tag' => 'A', 'lim' => '3', 'status' => true, 'regions' => '', 'workdays' => [], 'regions_reverse' => false],
|
||||
])->buildPlan($tenant->id);
|
||||
|
||||
expect($plan['planned'][0]['regions'])->toBe([]);
|
||||
});
|
||||
|
||||
test('buildPlan skips group when any active row has regions_reverse=true', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$plan = importerWithRows([
|
||||
['id' => '7201', 'src' => 'rt', 'type' => 'hosts', 'content' => 'excl.ru', 'tag' => 'A', 'lim' => '3', 'status' => true, 'regions' => '24', 'workdays' => [], 'regions_reverse' => true],
|
||||
['id' => '7202', 'src' => 'bl', 'type' => 'hosts', 'content' => 'excl.ru', 'tag' => 'A', 'lim' => '3', 'status' => true, 'regions' => '24', 'workdays' => [], 'regions_reverse' => false],
|
||||
])->buildPlan($tenant->id);
|
||||
|
||||
expect($plan['planned'])->toHaveCount(0);
|
||||
expect(collect($plan['skipped'])->pluck('reason'))->toContain('regions_exclude');
|
||||
});
|
||||
|
||||
test('buildPlan groups sms by sender: B2 (sender+keyword) and B3 (sender)', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$plan = importerWithRows([
|
||||
['id' => '8001', 'src' => 'bl', 'type' => 'sms', 'content' => '79001234567+KVARTIRA', 'tag' => 'СМС', 'lim' => '4', 'status' => true, 'regions' => '', 'workdays' => []],
|
||||
['id' => '8002', 'src' => 'mt', 'type' => 'sms', 'content' => '79001234567', 'tag' => 'СМС', 'lim' => '4', 'status' => true, 'regions' => '', 'workdays' => []],
|
||||
])->buildPlan($tenant->id);
|
||||
|
||||
expect($plan['planned'])->toHaveCount(1);
|
||||
$p = $plan['planned'][0];
|
||||
expect($p['signal_type'])->toBe('sms');
|
||||
expect($p['signal_identifier'])->toBeNull();
|
||||
expect($p['sms_senders'])->toBe(['79001234567']);
|
||||
expect($p['sms_keyword'])->toBe('KVARTIRA');
|
||||
expect($p['daily_limit_target'])->toBe(8);
|
||||
expect(collect($p['platforms'])->pluck('platform')->sort()->values()->all())->toBe(['B2', 'B3']);
|
||||
});
|
||||
|
||||
test('buildPlan handles sms B3-only (no keyword)', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$plan = importerWithRows([
|
||||
['id' => '8101', 'src' => 'mt', 'type' => 'sms', 'content' => '79009998877', 'tag' => 'СМС', 'lim' => '5', 'status' => true, 'regions' => '', 'workdays' => []],
|
||||
])->buildPlan($tenant->id);
|
||||
|
||||
expect($plan['planned'])->toHaveCount(1);
|
||||
expect($plan['planned'][0]['sms_senders'])->toBe(['79009998877']);
|
||||
expect($plan['planned'][0]['sms_keyword'])->toBeNull();
|
||||
expect($plan['planned'][0]['platforms'][0]['platform'])->toBe('B3');
|
||||
});
|
||||
|
||||
test('buildPlan skips a group whose Project already exists for the tenant', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'call',
|
||||
'signal_identifier' => '79993332211',
|
||||
]);
|
||||
|
||||
$plan = importerWithRows([
|
||||
['id' => '9001', 'src' => 'rt', 'type' => 'calls', 'content' => '79993332211', 'tag' => 'X', 'lim' => '6', 'status' => true, 'regions' => '', 'workdays' => []],
|
||||
])->buildPlan($tenant->id);
|
||||
|
||||
expect($plan['planned'])->toHaveCount(0);
|
||||
expect(collect($plan['skipped'])->pluck('reason'))->toContain('already_exists');
|
||||
});
|
||||
|
||||
test('commit creates Project + supplier_projects (external_id from portal) + pivot, no portal write', function (): void {
|
||||
Http::fake(); // ловушка: НИ один HTTP не должен уйти на портал
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$importer = importerWithRows([
|
||||
['id' => '4001', 'src' => 'rt', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '24', 'workdays' => ['1', '2', '3', '4', '5']],
|
||||
['id' => '4002', 'src' => 'bl', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '24', 'workdays' => ['1', '2', '3', '4', '5']],
|
||||
['id' => '4003', 'src' => 'mt', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '24', 'workdays' => ['1', '2', '3', '4', '5']],
|
||||
]);
|
||||
|
||||
$plan = $importer->buildPlan($tenant->id);
|
||||
$result = $importer->commit($plan, $tenant->id);
|
||||
|
||||
expect($result['created_projects'])->toBe(1);
|
||||
|
||||
$project = Project::on('pgsql_supplier')
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('signal_identifier', '79991112233')
|
||||
->first();
|
||||
expect($project)->not->toBeNull();
|
||||
expect($project->daily_limit_target)->toBe(18);
|
||||
expect($project->is_active)->toBeTrue();
|
||||
expect($project->regions)->toBe([29]);
|
||||
expect($project->delivery_days_mask)->toBe(31);
|
||||
|
||||
$sps = SupplierProject::on('pgsql_supplier')->where('unique_key', '79991112233')->get();
|
||||
expect($sps)->toHaveCount(3);
|
||||
expect($sps->pluck('supplier_external_id')->sort()->values()->all())->toBe(['4001', '4002', '4003']);
|
||||
expect($sps->pluck('sync_status')->unique()->all())->toBe(['ok']);
|
||||
expect($sps->firstWhere('platform', 'B1')->current_limit)->toBe(6);
|
||||
|
||||
$pivot = DB::connection('pgsql_supplier')->table('project_supplier_links')
|
||||
->where('project_id', $project->id)->count();
|
||||
expect($pivot)->toBe(3);
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('commit reuses an existing supplier_project row instead of duplicating', function (): void {
|
||||
Http::fake();
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
// supplier_project уже есть (например, создан webhook resolveOrStub ранее)
|
||||
SupplierProject::on('pgsql_supplier')->forceCreate([
|
||||
'platform' => 'B1',
|
||||
'signal_type' => 'call',
|
||||
'unique_key' => '79994445566',
|
||||
'subject_code' => null,
|
||||
'supplier_external_id' => 'EXIST1',
|
||||
'current_limit' => 6,
|
||||
'current_workdays' => [1, 2, 3, 4, 5],
|
||||
'current_regions' => [],
|
||||
'sync_status' => 'ok',
|
||||
'last_synced_at' => now(),
|
||||
]);
|
||||
|
||||
$importer = importerWithRows([
|
||||
['id' => '4500', 'src' => 'rt', 'type' => 'calls', 'content' => '79994445566', 'tag' => 'Y', 'lim' => '6', 'status' => true, 'regions' => '', 'workdays' => ['1', '2', '3', '4', '5']],
|
||||
]);
|
||||
$plan = $importer->buildPlan($tenant->id);
|
||||
$importer->commit($plan, $tenant->id);
|
||||
|
||||
// по-прежнему ровно 1 supplier_project с этим ключом+платформой (реюз, не дубль)
|
||||
expect(SupplierProject::on('pgsql_supplier')
|
||||
->where('unique_key', '79994445566')->where('platform', 'B1')->count())->toBe(1);
|
||||
|
||||
// pivot привязал существующую строку к новому проекту
|
||||
$project = Project::on('pgsql_supplier')->where('signal_identifier', '79994445566')->first();
|
||||
$sp = SupplierProject::on('pgsql_supplier')->where('unique_key', '79994445566')->first();
|
||||
expect(DB::connection('pgsql_supplier')->table('project_supplier_links')
|
||||
->where('project_id', $project->id)->where('supplier_project_id', $sp->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('buildPlan unions workdays across platforms with different schedules', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
// B1 = Пн-Ср [1,2,3] → mask 0b0000111 = 7; B2 = Чт-Пт [4,5] → mask 0b0011000 = 24;
|
||||
// union = 31 (Пн-Пт). Тест проверяет реальный OR-merge, не одинаковые расписания.
|
||||
$plan = importerWithRows([
|
||||
['id' => '5001', 'src' => 'rt', 'type' => 'calls', 'content' => '79992223344', 'tag' => 'W', 'lim' => '4', 'status' => true, 'regions' => '', 'workdays' => ['1', '2', '3']],
|
||||
['id' => '5002', 'src' => 'bl', 'type' => 'calls', 'content' => '79992223344', 'tag' => 'W', 'lim' => '4', 'status' => true, 'regions' => '', 'workdays' => ['4', '5']],
|
||||
])->buildPlan($tenant->id);
|
||||
|
||||
expect($plan['planned'])->toHaveCount(1);
|
||||
expect($plan['planned'][0]['delivery_days_mask'])->toBe(31);
|
||||
});
|
||||
|
||||
test('buildPlan skips sms group when any active row has regions_reverse=true', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$plan = importerWithRows([
|
||||
['id' => '6001', 'src' => 'bl', 'type' => 'sms', 'content' => '79007776655+CODE', 'tag' => 'СМС', 'lim' => '3', 'status' => true, 'regions' => '24', 'workdays' => [], 'regions_reverse' => true],
|
||||
['id' => '6002', 'src' => 'mt', 'type' => 'sms', 'content' => '79007776655', 'tag' => 'СМС', 'lim' => '3', 'status' => true, 'regions' => '24', 'workdays' => [], 'regions_reverse' => false],
|
||||
])->buildPlan($tenant->id);
|
||||
|
||||
expect($plan['planned'])->toHaveCount(0);
|
||||
expect(collect($plan['skipped'])->pluck('reason'))->toContain('regions_exclude');
|
||||
});
|
||||
|
||||
test('deriveName uses sms sender as fallback when tag is empty', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
// tag='РФ' → попадает в fallback; sms → должен взять sender, а не 'проект'.
|
||||
$plan = importerWithRows([
|
||||
['id' => '7001', 'src' => 'mt', 'type' => 'sms', 'content' => '79001112222', 'tag' => 'РФ', 'lim' => '2', 'status' => true, 'regions' => '', 'workdays' => []],
|
||||
])->buildPlan($tenant->id);
|
||||
|
||||
expect($plan['planned'][0]['name'])->toBe('79001112222');
|
||||
});
|
||||
@@ -80,6 +80,56 @@ it('online mode creates single-group supplier_projects with full regions + pivot
|
||||
expect(DB::table('project_supplier_links')->where('project_id', $project->id)->count())->toBe(3);
|
||||
});
|
||||
|
||||
it('online create DIVIDES the limit across B1/B2/B3 so supplier total == project limit (not ×3)', function (): void {
|
||||
// Money-loss regression (owner-reported 2026-05-21, verified live): the limit was
|
||||
// replicated full to all 3 platforms (18 → 18/18/18 = supplier could deliver up to 54).
|
||||
// The portal does NOT divide — each B-project honours its own limit independently.
|
||||
// Fix: split the limit so Σ per-platform == project limit (18 → 6/6/6).
|
||||
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
|
||||
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'call',
|
||||
'signal_identifier' => '79991110000',
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 18,
|
||||
'regions' => [],
|
||||
'delivery_days_mask' => 127,
|
||||
]);
|
||||
|
||||
$capturedLimits = [];
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => function ($request) use (&$capturedLimits) {
|
||||
$body = $request->data();
|
||||
$capturedLimits[] = $body['limit'] ?? null;
|
||||
|
||||
return Http::response(['status' => 'OK', 'message' => '', 'id' => '3000'], 200);
|
||||
},
|
||||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => [
|
||||
['id' => '3001', 'src' => 'rt', 'name' => '79991110000', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79991110000'],
|
||||
['id' => '3002', 'src' => 'bl', 'name' => '79991110000', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79991110000'],
|
||||
['id' => '3003', 'src' => 'mt', 'name' => '79991110000', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79991110000'],
|
||||
]], 200),
|
||||
]);
|
||||
|
||||
(new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class));
|
||||
|
||||
$sps = SupplierProject::where('unique_key', '79991110000')->get();
|
||||
expect($sps)->toHaveCount(3);
|
||||
// Σ per-platform limits == the project limit — the loss-prevention invariant.
|
||||
expect($sps->sum('current_limit'))->toBe(18);
|
||||
foreach ($sps as $sp) {
|
||||
expect($sp->current_limit)->toBe(6); // 18 / 3 platforms
|
||||
}
|
||||
// Every limit pushed to the portal is the divided share, never the full 18.
|
||||
$sent = array_values(array_filter($capturedLimits, fn ($l) => $l !== null));
|
||||
expect($sent)->not->toBeEmpty();
|
||||
foreach ($sent as $l) {
|
||||
expect((int) $l)->toBe(6);
|
||||
}
|
||||
});
|
||||
|
||||
it('online mode passes real workdays from delivery_days_mask (not hardcoded [1..7])', function (): void {
|
||||
// Regression: до фикса хардкодилось [1,2,3,4,5,6,7] независимо от delivery_days_mask.
|
||||
// delivery_days_mask=31 = 0b0011111 = Пн-Пт (ISO дни 1-5). Workdays поставщика должны быть [1,2,3,4,5].
|
||||
@@ -161,6 +211,16 @@ it('online mode update-path: existing supplier_projects.current_workdays is refr
|
||||
]);
|
||||
}
|
||||
|
||||
// listProjects (dead-donor liveness check) must see the seeded donors as alive,
|
||||
// so the update path runs without recreating (and without hitting the real portal).
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => [
|
||||
['id' => '99B1', 'src' => 'rt', 'name' => '79991234567', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79991234567'],
|
||||
['id' => '99B2', 'src' => 'bl', 'name' => '79991234567', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79991234567'],
|
||||
['id' => '99B3', 'src' => 'mt', 'name' => '79991234567', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79991234567'],
|
||||
]], 200),
|
||||
]);
|
||||
|
||||
$this->mock(SupplierProjectChannel::class, function ($mock): void {
|
||||
$mock->shouldReceive('updateProject')->times(3)->andReturn(true);
|
||||
});
|
||||
@@ -169,9 +229,11 @@ it('online mode update-path: existing supplier_projects.current_workdays is refr
|
||||
|
||||
$sps = SupplierProject::where('unique_key', '79991234567')->get();
|
||||
expect($sps)->toHaveCount(3);
|
||||
// 9 split across B1/B2/B3 = 3/3/3 (Σ == 9 = project limit, not 9 on each = 27).
|
||||
expect($sps->sum('current_limit'))->toBe(9);
|
||||
foreach ($sps as $sp) {
|
||||
expect($sp->current_workdays)->toBe([1, 2, 3, 4, 5]);
|
||||
expect($sp->current_limit)->toBe(9);
|
||||
expect($sp->current_limit)->toBe(3);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -213,6 +275,103 @@ it('online mode all-RF (no regions): 1 group subject_code=null, 3 supplier_proje
|
||||
expect(DB::table('project_supplier_links')->where('project_id', $project->id)->count())->toBe(3);
|
||||
});
|
||||
|
||||
it('online mode re-creates donor on portal when its external_id no longer exists there', function (): void {
|
||||
// Regression: если донора удалили на портале, в нашей БД остаются supplier_projects
|
||||
// с мёртвыми external_id. Раньше джоб шёл по update-ветке → updateProject мёртвого id
|
||||
// портал молча принимает (no-op) → донор не пересоздаётся. Фикс: проверять, жив ли
|
||||
// external_id на портале (listProjects), и пересоздавать недостающих in-place
|
||||
// (НЕ удаляя записи — на них могут висеть лиды/списания).
|
||||
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
|
||||
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'call',
|
||||
'signal_identifier' => '79990001122',
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 10,
|
||||
'regions' => [],
|
||||
'delivery_days_mask' => 31,
|
||||
]);
|
||||
|
||||
// Pre-seed supplier_projects, чьи external_id указывают на удалённых с портала доноров.
|
||||
foreach (['B1', 'B2', 'B3'] as $platform) {
|
||||
SupplierProject::create([
|
||||
'platform' => $platform,
|
||||
'signal_type' => 'call',
|
||||
'unique_key' => '79990001122',
|
||||
'subject_code' => null,
|
||||
'supplier_external_id' => 'DEAD'.$platform,
|
||||
'current_limit' => 10,
|
||||
'current_workdays' => [1, 2, 3, 4, 5],
|
||||
'current_regions' => [],
|
||||
'sync_status' => 'ok',
|
||||
'last_synced_at' => now()->subDay(),
|
||||
]);
|
||||
}
|
||||
|
||||
$loadCalls = 0;
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(['status' => 'OK', 'message' => '', 'id' => '7003'], 200),
|
||||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => function () use (&$loadCalls) {
|
||||
$loadCalls++;
|
||||
// Первый load = проверка существования → донор удалён (пусто).
|
||||
if ($loadCalls === 1) {
|
||||
return Http::response(['projects' => []], 200);
|
||||
}
|
||||
|
||||
// Последующие load (внутри saveProjectMultiFlag) = свежесозданные доноры.
|
||||
return Http::response(['projects' => [
|
||||
['id' => '7001', 'src' => 'rt', 'name' => '79990001122', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79990001122'],
|
||||
['id' => '7002', 'src' => 'bl', 'name' => '79990001122', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79990001122'],
|
||||
['id' => '7003', 'src' => 'mt', 'name' => '79990001122', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79990001122'],
|
||||
]], 200);
|
||||
},
|
||||
]);
|
||||
|
||||
(new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class));
|
||||
|
||||
// external_id переписаны на свежесозданных доноров (не DEAD*), записи не удалены.
|
||||
$sps = SupplierProject::where('unique_key', '79990001122')->orderBy('platform')->get();
|
||||
expect($sps)->toHaveCount(3);
|
||||
expect($sps->pluck('supplier_external_id')->all())->toBe(['7001', '7002', '7003']);
|
||||
});
|
||||
|
||||
it('online mode also populates legacy supplier_b{1,2,3}_project_id so UI sync-status is not stuck pending', function (): void {
|
||||
// Regression: online mode writes the link to the pivot, but ProjectResource/aggregateSyncStatus
|
||||
// read the legacy FK columns (supplierB1/B2/B3). They stayed NULL in online → "Sync pending"
|
||||
// forever even though the stack is synced. Online must populate them too.
|
||||
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
|
||||
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'uisync.example.com',
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 5,
|
||||
'regions' => [],
|
||||
'delivery_days_mask' => 127,
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(['status' => 'OK', 'message' => '', 'id' => '9003'], 200),
|
||||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => [
|
||||
['id' => '9001', 'src' => 'rt', 'name' => 'uisync.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'uisync.example.com'],
|
||||
['id' => '9002', 'src' => 'bl', 'name' => 'uisync.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'uisync.example.com'],
|
||||
['id' => '9003', 'src' => 'mt', 'name' => 'uisync.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'uisync.example.com'],
|
||||
]], 200),
|
||||
]);
|
||||
|
||||
(new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class));
|
||||
|
||||
$project->refresh();
|
||||
expect($project->supplier_b1_project_id)->not->toBeNull();
|
||||
expect($project->supplier_b2_project_id)->not->toBeNull();
|
||||
expect($project->supplier_b3_project_id)->not->toBeNull();
|
||||
expect($project->aggregateSyncStatus())->toBe('ok');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Batch mode: keeps каркас (limit 0, no per-subject save, no pivot)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -250,3 +409,53 @@ it('batch mode keeps каркас (limit=0, sets supplier_b{1,2,3}_project_id, n
|
||||
// Batch: no pivot rows (nightly job fills them)
|
||||
expect(DB::table('project_supplier_links')->where('project_id', $project->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connection: must use pgsql_supplier (BYPASSRLS) — queue worker has no tenant GUC
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('runs every projects query on the pgsql_supplier (BYPASSRLS) connection', function (): void {
|
||||
// Regression: job ran on the default RLS-enforced connection. On a real queue worker
|
||||
// (role crm_app_user, no SetTenantContext middleware → no app.current_tenant_id GUC)
|
||||
// the very first Project::find() dies with SQLSTATE 42704 before any supplier contact,
|
||||
// so the supplier project is never created and the UI sticks on "Sync pending".
|
||||
// Every sibling supplier job (SyncSupplierProjectsJob/DeleteSupplierProjectJob/…) uses
|
||||
// pgsql_supplier; this one must too. On dev (postgres superuser) RLS is bypassed, so we
|
||||
// assert the *connection* the queries run on rather than RLS enforcement.
|
||||
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
|
||||
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'conn-test.example.com',
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 10,
|
||||
'regions' => [],
|
||||
'delivery_days_mask' => 127,
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(['status' => 'OK', 'message' => '', 'id' => '8003'], 200),
|
||||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => [
|
||||
['id' => '8001', 'src' => 'rt', 'name' => 'conn-test.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'conn-test.example.com'],
|
||||
['id' => '8002', 'src' => 'bl', 'name' => 'conn-test.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'conn-test.example.com'],
|
||||
['id' => '8003', 'src' => 'mt', 'name' => 'conn-test.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'conn-test.example.com'],
|
||||
]], 200),
|
||||
]);
|
||||
|
||||
// Listen only during the job run (factory queries above are already done).
|
||||
$projectConnections = [];
|
||||
DB::listen(function ($query) use (&$projectConnections): void {
|
||||
// '"projects"' (quoted table) does NOT match '"supplier_projects"' or
|
||||
// '"project_supplier_links"', so this captures only the projects table.
|
||||
if (str_contains($query->sql, '"projects"')) {
|
||||
$projectConnections[] = $query->connectionName;
|
||||
}
|
||||
});
|
||||
|
||||
(new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class));
|
||||
|
||||
expect($projectConnections)->not->toBeEmpty();
|
||||
expect(array_values(array_unique($projectConnections)))->toBe(['pgsql_supplier']);
|
||||
});
|
||||
|
||||
@@ -57,7 +57,6 @@ test('single-group: regions=[82,83] site → merged regions tag=РФ → 3 suppl
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'persubject.example.com',
|
||||
'daily_limit_target' => 9,
|
||||
@@ -116,7 +115,6 @@ test('all-RF pool: regions=[] → 1 group subject_code=null tag=РФ → 3 suppl
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'rf-pool.example.com',
|
||||
'daily_limit_target' => 6,
|
||||
@@ -161,13 +159,12 @@ test('all-RF pool: regions=[] → 1 group subject_code=null tag=РФ → 3 suppl
|
||||
// Order: 2 projects on one (source × subject) → computeOrder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('order: 2 projects same source×subject → computeOrder(limits=[10,20]) → limit=20', function (): void {
|
||||
test('order: 2 projects same source×subject → computeOrder([10,20])=20 split across B1/B2/B3 = 7/7/6', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'order-test.example.com',
|
||||
'daily_limit_target' => 10,
|
||||
@@ -178,7 +175,6 @@ test('order: 2 projects same source×subject → computeOrder(limits=[10,20])
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'order-test.example.com',
|
||||
'daily_limit_target' => 20,
|
||||
@@ -204,19 +200,49 @@ test('order: 2 projects same source×subject → computeOrder(limits=[10,20])
|
||||
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
|
||||
// computeOrder([10, 20]) = max(20, ceil(30/3)=10) = 20
|
||||
$sp = SupplierProject::on('pgsql_supplier')
|
||||
// computeOrder([10, 20]) = max(20, ceil(30/3)=10) = 20 (the GROUP order), then split
|
||||
// across B1/B2/B3 = 7/7/6 (Σ == 20 — NOT 20 on each = 60, which would be the ×3 overspend).
|
||||
$sps = SupplierProject::on('pgsql_supplier')
|
||||
->where('unique_key', 'order-test.example.com')
|
||||
->where('platform', 'B1')
|
||||
->first();
|
||||
|
||||
expect($sp)->not->toBeNull();
|
||||
expect($sp->current_limit)->toBe(20);
|
||||
->get();
|
||||
|
||||
// Single group → exactly 3 supplier_projects (not 6 as would happen if grouped separately)
|
||||
expect(SupplierProject::on('pgsql_supplier')
|
||||
->where('unique_key', 'order-test.example.com')
|
||||
->count())->toBe(3);
|
||||
expect($sps)->toHaveCount(3);
|
||||
expect($sps->sum('current_limit'))->toBe(20);
|
||||
expect($sps->firstWhere('platform', 'B1')->current_limit)->toBe(7);
|
||||
});
|
||||
|
||||
test('limit is DIVIDED across B1/B2/B3 so supplier total == project limit (owner-reported ×3 bug)', function (): void {
|
||||
// The owner reported (and we verified live 2026-05-21): call limit 18 → 18/18/18 on the
|
||||
// portal = supplier could deliver up to 54. The portal does NOT divide. Fix splits 18 → 6/6/6.
|
||||
$tenant = Tenant::factory()->create();
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'signal_type' => 'call',
|
||||
'signal_identifier' => '79135161263',
|
||||
'daily_limit_target' => 18,
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [],
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(['status' => 'OK', 'message' => '', 'id' => '4000'], 200),
|
||||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => [
|
||||
['id' => '4001', 'src' => 'rt', 'name' => '79135161263', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79135161263'],
|
||||
['id' => '4002', 'src' => 'bl', 'name' => '79135161263', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79135161263'],
|
||||
['id' => '4003', 'src' => 'mt', 'name' => '79135161263', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79135161263'],
|
||||
]], 200),
|
||||
]);
|
||||
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
|
||||
// Assert only THIS group's rows (the nightly job syncs every active project in the DB).
|
||||
$sps = SupplierProject::on('pgsql_supplier')->where('unique_key', '79135161263')->get();
|
||||
expect($sps)->toHaveCount(3);
|
||||
expect($sps->sum('current_limit'))->toBe(18); // Σ == project limit (not 54)
|
||||
expect($sps->sortBy('platform')->pluck('current_limit', 'platform')->all())
|
||||
->toBe(['B1' => 6, 'B2' => 6, 'B3' => 6]); // 18 / 3
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -229,7 +255,6 @@ test('sms+keyword → platforms B2+B3 (2 supplier_projects per subject)', functi
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'sms',
|
||||
'signal_identifier' => null,
|
||||
'sms_senders' => ['79001234567'],
|
||||
@@ -271,7 +296,6 @@ test('sms without keyword → platform B3 only (1 supplier_project)', function (
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'sms',
|
||||
'signal_identifier' => null,
|
||||
'sms_senders' => ['79009876543'],
|
||||
@@ -314,7 +338,6 @@ test('idempotent: repeat run with no changes → updateProject not duplicate', f
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'idempotent.example.com',
|
||||
'daily_limit_target' => 9,
|
||||
@@ -375,7 +398,6 @@ test('respects time budget by stopping at 20:55 МСК', function (): void {
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'time-budget.example.com',
|
||||
'daily_limit_target' => 9,
|
||||
@@ -397,7 +419,6 @@ test('sticky auth error throws and sends critical alert email', function (): voi
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'auth-fail.example.com',
|
||||
'daily_limit_target' => 9,
|
||||
@@ -425,7 +446,6 @@ test('aborts after 50 consecutive transient failures and sends alert', function
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => "host{$i}.abort.com",
|
||||
'daily_limit_target' => 9,
|
||||
@@ -449,7 +469,6 @@ test('writes supplier_sync_log row for each successful action', function (): voi
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'audit-log.example.com',
|
||||
'daily_limit_target' => 9,
|
||||
@@ -491,3 +510,57 @@ test('writes supplier_sync_log row for each successful action', function (): voi
|
||||
->and($log->http_status)->toBe(200)
|
||||
->and($log->error_message)->toBeNull();
|
||||
});
|
||||
|
||||
test('nightly: re-creates donor on portal when its external_id no longer exists there', function (): void {
|
||||
// Regression mirror of SyncSupplierProjectJobTest: donor deleted on portal → stale
|
||||
// external_id in our DB → updateProject is a silent no-op → donor never re-created.
|
||||
// Nightly reconciler must detect missing donors (listProjects) and re-create in-place.
|
||||
$tenant = Tenant::factory()->create();
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'signal_type' => 'call',
|
||||
'signal_identifier' => '79993334455',
|
||||
'daily_limit_target' => 10,
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [],
|
||||
]);
|
||||
|
||||
foreach (['B1', 'B2', 'B3'] as $platform) {
|
||||
SupplierProject::on('pgsql_supplier')->forceCreate([
|
||||
'platform' => $platform,
|
||||
'signal_type' => 'call',
|
||||
'unique_key' => '79993334455',
|
||||
'subject_code' => null,
|
||||
'supplier_external_id' => 'GONE'.$platform,
|
||||
'current_limit' => 10,
|
||||
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
|
||||
'current_regions' => [],
|
||||
'sync_status' => 'ok',
|
||||
'last_synced_at' => now()->subDay(),
|
||||
]);
|
||||
}
|
||||
|
||||
$loadCalls = 0;
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(['status' => 'OK', 'message' => '', 'id' => '8003'], 200),
|
||||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => function () use (&$loadCalls) {
|
||||
$loadCalls++;
|
||||
if ($loadCalls === 1) {
|
||||
return Http::response(['projects' => []], 200);
|
||||
}
|
||||
|
||||
return Http::response(['projects' => [
|
||||
['id' => '8001', 'src' => 'rt', 'name' => '79993334455', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79993334455'],
|
||||
['id' => '8002', 'src' => 'bl', 'name' => '79993334455', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79993334455'],
|
||||
['id' => '8003', 'src' => 'mt', 'name' => '79993334455', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79993334455'],
|
||||
]], 200);
|
||||
},
|
||||
]);
|
||||
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
|
||||
$sps = SupplierProject::on('pgsql_supplier')->where('unique_key', '79993334455')->orderBy('platform')->get();
|
||||
expect($sps)->toHaveCount(3);
|
||||
expect($sps->pluck('supplier_external_id')->all())->toBe(['8001', '8002', '8003']);
|
||||
});
|
||||
|
||||
@@ -27,13 +27,13 @@ test('GET webhook-settings возвращает подписку тенанта'
|
||||
OutboundWebhookSubscription::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'target_url' => 'https://crm.example.ru/hook',
|
||||
'target_url' => 'https://93.184.216.34/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.target_url'))->toBe('https://93.184.216.34/hook');
|
||||
expect($response->json('data'))->toHaveKeys(['target_url', 'secret_prefix', 'events', 'is_active']);
|
||||
expect($response->json('data'))->not->toHaveKey('secret_hash');
|
||||
});
|
||||
@@ -55,11 +55,11 @@ test('GET webhook-settings изолирован по тенанту', function (
|
||||
|
||||
test('PUT webhook-settings создаёт подписку и возвращает secret один раз', function () {
|
||||
$response = $this->putJson('/api/tenants/me/webhook-settings', [
|
||||
'target_url' => 'https://crm.example.ru/hook',
|
||||
'target_url' => 'https://93.184.216.34/hook',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('data.target_url'))->toBe('https://crm.example.ru/hook');
|
||||
expect($response->json('data.target_url'))->toBe('https://93.184.216.34/hook');
|
||||
expect($response->json('data.secret'))->toStartWith('whsec_');
|
||||
expect($response->json('data.events'))->toBeArray()->not->toBeEmpty();
|
||||
|
||||
@@ -72,15 +72,15 @@ test('PUT webhook-settings обновляет URL существующей по
|
||||
OutboundWebhookSubscription::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'target_url' => 'https://old.example.ru/hook',
|
||||
'target_url' => 'https://8.8.8.8/hook',
|
||||
]);
|
||||
|
||||
$response = $this->putJson('/api/tenants/me/webhook-settings', [
|
||||
'target_url' => 'https://new.example.ru/hook',
|
||||
'target_url' => 'https://1.1.1.1/hook',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('data.target_url'))->toBe('https://new.example.ru/hook');
|
||||
expect($response->json('data.target_url'))->toBe('https://1.1.1.1/hook');
|
||||
expect($response->json('data'))->not->toHaveKey('secret');
|
||||
expect(OutboundWebhookSubscription::query()->where('tenant_id', $this->tenant->id)->count())->toBe(1);
|
||||
});
|
||||
@@ -91,12 +91,20 @@ test('PUT webhook-settings: 422 при не-https URL', function () {
|
||||
])->assertStatus(422)->assertJsonValidationErrorFor('target_url');
|
||||
});
|
||||
|
||||
test('PUT webhook-settings: 422 для приватного/служебного IP в target_url (SSRF), не сохраняет', function () {
|
||||
$this->putJson('/api/tenants/me/webhook-settings', [
|
||||
'target_url' => 'https://169.254.169.254/hook',
|
||||
])->assertStatus(422)->assertJsonValidationErrorFor('target_url');
|
||||
|
||||
expect(OutboundWebhookSubscription::query()->where('tenant_id', $this->tenant->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
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',
|
||||
'target_url' => 'https://93.184.216.34/hook',
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/webhooks/test');
|
||||
@@ -104,7 +112,7 @@ test('POST 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');
|
||||
Http::assertSent(fn ($req) => $req->url() === 'https://93.184.216.34/hook');
|
||||
});
|
||||
|
||||
test('POST webhooks/test возвращает ok=false при ошибке endpoint', function () {
|
||||
@@ -112,7 +120,7 @@ test('POST webhooks/test возвращает ok=false при ошибке endpo
|
||||
OutboundWebhookSubscription::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'target_url' => 'https://crm.example.ru/hook',
|
||||
'target_url' => 'https://93.184.216.34/hook',
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/webhooks/test');
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\OutboundWebhookSubscription;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\WebhookUrlGuard;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->user = User::factory()->for($this->tenant)->create();
|
||||
$this->actingAs($this->user);
|
||||
});
|
||||
|
||||
// --- unit: WebhookUrlGuard (IP-литералы, без DNS) ---
|
||||
|
||||
test('WebhookUrlGuard блокирует приватные/зарезервированные/loopback IP', function (string $url) {
|
||||
expect(WebhookUrlGuard::blockReason($url))->not->toBeNull();
|
||||
})->with([
|
||||
'https://127.0.0.1/hook', // loopback
|
||||
'https://10.0.0.1/hook', // private A
|
||||
'https://172.16.0.1/hook', // private B
|
||||
'https://192.168.1.1/hook', // private C
|
||||
'https://169.254.169.254/hook', // link-local / cloud metadata
|
||||
'https://[::1]/hook', // IPv6 loopback
|
||||
]);
|
||||
|
||||
test('WebhookUrlGuard пропускает публичный IP', function () {
|
||||
expect(WebhookUrlGuard::blockReason('https://93.184.216.34/hook'))->toBeNull();
|
||||
});
|
||||
|
||||
test('WebhookUrlGuard отклоняет битый URL', function () {
|
||||
expect(WebhookUrlGuard::blockReason('not-a-url'))->not->toBeNull();
|
||||
});
|
||||
|
||||
// --- endpoint: webhooks/test не должен бить во внутреннюю сеть ---
|
||||
|
||||
test('POST webhooks/test блокирует приватный IP target_url (SSRF) и не шлёт запрос', function () {
|
||||
Http::fake();
|
||||
OutboundWebhookSubscription::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'target_url' => 'https://169.254.169.254/hook',
|
||||
]);
|
||||
|
||||
$this->postJson('/api/webhooks/test')->assertStatus(422);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('POST webhooks/test пропускает публичный target_url', function () {
|
||||
Http::fake(['*' => Http::response(['ok' => true], 200)]);
|
||||
OutboundWebhookSubscription::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'target_url' => 'https://93.184.216.34/hook',
|
||||
]);
|
||||
|
||||
$this->postJson('/api/webhooks/test')
|
||||
->assertOk()
|
||||
->assertJsonPath('ok', true);
|
||||
Http::assertSentCount(1);
|
||||
});
|
||||
@@ -169,7 +169,7 @@ describe('BulkActionsBar — extended', () => {
|
||||
expect((wrapper.vm as unknown as { regionsOpen: boolean }).regionsOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps existing pause/resume/archive buttons', async () => {
|
||||
it('keeps existing pause/resume/delete buttons', async () => {
|
||||
setActivePinia(createPinia());
|
||||
vi.mocked(axios.post).mockResolvedValue({ data: { updated: 1, skipped: [], warnings: [] } });
|
||||
vi.mocked(axios.get).mockResolvedValue({ data: { data: [], meta: { total: 0 } } });
|
||||
@@ -184,6 +184,6 @@ describe('BulkActionsBar — extended', () => {
|
||||
});
|
||||
expect(wrapper.find('[data-testid="bulk-pause"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="bulk-resume"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="bulk-archive"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="bulk-delete"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,6 @@ const sampleProject = {
|
||||
daily_limit_target: 10,
|
||||
delivered_today: 0,
|
||||
is_active: true,
|
||||
archived_at: null,
|
||||
sync_status: 'ok' as const,
|
||||
region_mask: 0,
|
||||
region_mode: 'include',
|
||||
|
||||
@@ -12,7 +12,6 @@ const baseProject = {
|
||||
daily_limit_target: 50,
|
||||
delivered_today: 32,
|
||||
is_active: true,
|
||||
archived_at: null,
|
||||
sync_status: 'ok' as const,
|
||||
};
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ const sample = {
|
||||
is_active: true,
|
||||
daily_limit_target: 50,
|
||||
delivered_today: 12,
|
||||
archived_at: null,
|
||||
sync_status: 'ok' as const,
|
||||
};
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ const sampleProject: Project = {
|
||||
daily_limit_target: 30,
|
||||
delivered_today: 0,
|
||||
is_active: true,
|
||||
archived_at: null,
|
||||
region_mask: 0,
|
||||
region_mode: 'include',
|
||||
regions: [],
|
||||
@@ -152,10 +151,10 @@ describe('ProjectDetailsDrawer', () => {
|
||||
expect(wrapper.get('[data-testid="pdd-pause"]').text()).toContain('Приостановить');
|
||||
});
|
||||
|
||||
it('Delete: confirm=true → archive + close emit', async () => {
|
||||
it('Delete: confirm=true → del + close emit', async () => {
|
||||
const wrapper = mount(ProjectDetailsDrawer, { props: { project: sampleProject } });
|
||||
const store = useProjectsStore();
|
||||
const spy = vi.spyOn(store, 'archive').mockResolvedValueOnce(undefined);
|
||||
const spy = vi.spyOn(store, 'del').mockResolvedValueOnce(undefined);
|
||||
vi.stubGlobal('confirm', () => true);
|
||||
|
||||
await wrapper.get('[data-testid="pdd-delete"]').trigger('click');
|
||||
@@ -166,10 +165,10 @@ describe('ProjectDetailsDrawer', () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('Delete: confirm=false → no archive, no close', async () => {
|
||||
it('Delete: confirm=false → no del, no close', async () => {
|
||||
const wrapper = mount(ProjectDetailsDrawer, { props: { project: sampleProject } });
|
||||
const store = useProjectsStore();
|
||||
const spy = vi.spyOn(store, 'archive').mockResolvedValueOnce(undefined);
|
||||
const spy = vi.spyOn(store, 'del').mockResolvedValueOnce(undefined);
|
||||
vi.stubGlobal('confirm', () => false);
|
||||
|
||||
await wrapper.get('[data-testid="pdd-delete"]').trigger('click');
|
||||
|
||||
@@ -52,7 +52,6 @@ describe('ProjectsView', () => {
|
||||
daily_limit_target: 10,
|
||||
delivered_today: 0,
|
||||
is_active: true,
|
||||
archived_at: null,
|
||||
sync_status: 'ok',
|
||||
},
|
||||
],
|
||||
@@ -82,7 +81,6 @@ describe('ProjectsView', () => {
|
||||
daily_limit_target: 10,
|
||||
delivered_today: 0,
|
||||
is_active: true,
|
||||
archived_at: null,
|
||||
sync_status: 'ok',
|
||||
},
|
||||
{
|
||||
@@ -93,7 +91,6 @@ describe('ProjectsView', () => {
|
||||
daily_limit_target: 10,
|
||||
delivered_today: 0,
|
||||
is_active: true,
|
||||
archived_at: null,
|
||||
sync_status: 'ok',
|
||||
},
|
||||
],
|
||||
@@ -127,7 +124,6 @@ describe('ProjectsView × ProjectDetailsDrawer integration', () => {
|
||||
daily_limit_target: 10,
|
||||
delivered_today: 0,
|
||||
is_active: true,
|
||||
archived_at: null,
|
||||
sync_status: 'ok' as const,
|
||||
};
|
||||
const projectB = {
|
||||
@@ -138,7 +134,6 @@ describe('ProjectsView × ProjectDetailsDrawer integration', () => {
|
||||
daily_limit_target: 10,
|
||||
delivered_today: 0,
|
||||
is_active: true,
|
||||
archived_at: null,
|
||||
sync_status: 'ok' as const,
|
||||
};
|
||||
|
||||
@@ -244,3 +239,36 @@ describe('ProjectsView × ProjectDetailsDrawer integration', () => {
|
||||
expect(wrapper.find('.projects-view').classes()).not.toContain('has-drawer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProjectsView 18:00 cutoff banner', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
(axios.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
data: { data: [], meta: { total: 0, current_page: 1, per_page: 20 } },
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the cutoff banner with the 18:00 deadline by default', async () => {
|
||||
const wrapper = factory();
|
||||
await flushPromises();
|
||||
const banner = wrapper.find('[data-testid="cutoff-banner"]');
|
||||
expect(banner.exists()).toBe(true);
|
||||
expect(banner.text()).toContain('18:00');
|
||||
});
|
||||
|
||||
it('hides the banner after the close button and remembers it in localStorage', async () => {
|
||||
const wrapper = factory();
|
||||
await flushPromises();
|
||||
await wrapper.find('[data-testid="cutoff-banner-close"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find('[data-testid="cutoff-banner"]').exists()).toBe(false);
|
||||
expect(localStorage.getItem('projects.cutoffBannerDismissed')).toBe('1');
|
||||
});
|
||||
|
||||
it('stays hidden on next mount when previously dismissed', async () => {
|
||||
localStorage.setItem('projects.cutoffBannerDismissed', '1');
|
||||
const wrapper = factory();
|
||||
await flushPromises();
|
||||
expect(wrapper.find('[data-testid="cutoff-banner"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,7 +20,6 @@ const mountView = async () => {
|
||||
daily_limit_target: 100,
|
||||
delivered_today: 0,
|
||||
is_active: true,
|
||||
archived_at: null,
|
||||
region_mask: 1,
|
||||
region_mode: 'include',
|
||||
delivery_days_mask: 31,
|
||||
@@ -34,7 +33,6 @@ const mountView = async () => {
|
||||
daily_limit_target: 100,
|
||||
delivered_today: 0,
|
||||
is_active: true,
|
||||
archived_at: null,
|
||||
region_mask: 1,
|
||||
region_mode: 'include',
|
||||
delivery_days_mask: 31,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user