Compare commits
406 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 54b1de78b8 | |||
| ee5bc56f2d | |||
| df2d091174 | |||
| 4c9a1e9ccb | |||
| 65c2c5e471 | |||
| f6ba9bc1e7 | |||
| 05076c4f1d | |||
| f943b229c0 | |||
| 28671cb012 | |||
| d86d375ce4 | |||
| 4f5cf263f6 | |||
| af15f24de7 | |||
| b757f22b97 | |||
| 31b53557ac | |||
| be27713f6e | |||
| 60dd3e70b1 | |||
| 54967147d7 | |||
| 1a02b4b5f2 | |||
| 76ea9bbb04 | |||
| 62b5306548 | |||
| 01562afd31 | |||
| b7466ebfbd | |||
| 17e3c04f24 | |||
| ba49805689 | |||
| 95ee6644f7 | |||
| a0e18a1dd8 | |||
| 9e0490c328 | |||
| 80275c6417 | |||
| 36c71ecb1e | |||
| c99362a3e5 | |||
| 9331465c26 | |||
| 9d9bcf7847 | |||
| c7fd90c08d | |||
| e35fc6c938 | |||
| f1a3e9f02f | |||
| d0eecbbf79 | |||
| 01d292f5a9 | |||
| b0ce510155 | |||
| 76d13d699a | |||
| be9571353a | |||
| 147200ff8e | |||
| 492a4fc969 | |||
| 5742c92449 | |||
| e846de6012 | |||
| a007295abe | |||
| 5d3e29669b | |||
| ef4cc825bf | |||
| f54c82d682 | |||
| 884169e847 | |||
| f8b32a7d3a | |||
| ffaeb8f37b | |||
| c0e3e901d0 | |||
| 0663479bb8 | |||
| 52728dfc12 | |||
| dbe2252421 | |||
| 8e5eaecf6a | |||
| 47c03a9e18 | |||
| 752ff8b9a9 | |||
| c7197a263c | |||
| 9729909c31 | |||
| 2bab9a61b9 | |||
| 082968ea1c | |||
| 2d7201f063 | |||
| 96f4a6601d | |||
| 48b0e35cd1 | |||
| c89895e039 | |||
| 3cf8fbdfb9 | |||
| d6364dcde1 | |||
| d631646167 | |||
| 2706166f55 | |||
| b584ce43dd | |||
| 6b7f0035ef | |||
| 3e16c1e656 | |||
| e6d6babb38 | |||
| 2476dd3c1b | |||
| 3ec638cbd2 | |||
| c5ec9a0875 | |||
| 3b7e549e02 | |||
| 7fe9f89574 | |||
| c5def50e31 | |||
| c386361881 | |||
| 94f831f7d1 | |||
| 1ba8b6e590 | |||
| 030bdc65ab | |||
| 148262a78e | |||
| 787c38ad82 | |||
| 79d3f2ef3d | |||
| 82c0aeef41 | |||
| 5f17ca51ac | |||
| fdd8247527 | |||
| d1ddd28250 | |||
| 34458df474 | |||
| 467f1cdbf2 | |||
| cd2353b57d | |||
| 17e34a6d5e | |||
| 063436670a | |||
| 2f9f0a0900 | |||
| c44394ea0c | |||
| 3177072e1d | |||
| 71022ad3f1 | |||
| 6d9c1d2464 | |||
| de11da2b06 | |||
| d984165af1 | |||
| 7df4786499 | |||
| 162fe010fe | |||
| 426983ffaa | |||
| 87c5eb6323 | |||
| cb864b18a5 | |||
| 4b4c8d94b9 | |||
| dd0a9ffea6 | |||
| 353b1599b6 | |||
| 97388cf840 | |||
| 8f5a399a25 | |||
| efd3e73aa2 | |||
| 0f1b604554 | |||
| 48d7303963 | |||
| b9e72e6231 | |||
| 80c5f6289a | |||
| 895975482d | |||
| e81cd8ed2c | |||
| bff5faf02b | |||
| 8df5a3fe00 | |||
| 83295a25f3 | |||
| 0fad4305d4 | |||
| 2f60910b09 | |||
| f48d5115ce | |||
| 774763c21c | |||
| c1b690edd3 | |||
| e34b11aca5 | |||
| b4f4f441b5 | |||
| 475e233c2a | |||
| 3e289479f0 | |||
| 0cee520f0d | |||
| c3392bef13 | |||
| 7fed5bc18b | |||
| 43028228c8 | |||
| f1092772fb | |||
| 702c2ff7b5 | |||
| b75f9e3d21 | |||
| 2e26edbb3a | |||
| 643e1a5dcf | |||
| 21f1d7833b | |||
| 9e1a07aad3 | |||
| b2b9a75731 | |||
| 287332eddf | |||
| 8550ba243d | |||
| ad09db606a | |||
| c27539ca29 | |||
| 9b4bff48f0 | |||
| 6c30c248bc | |||
| 9443b5b446 | |||
| 25088e4a33 | |||
| fcd06afcb2 | |||
| 2f55632792 | |||
| 54365015d8 | |||
| 4dd40f609f | |||
| d760036972 | |||
| 0e27844a28 | |||
| d369383c7d | |||
| 54fcc4b094 | |||
| e87b1385cf | |||
| 66ca57f187 | |||
| 430efe624d | |||
| dc6d2dd358 | |||
| 4969363f78 | |||
| 0e3938f845 | |||
| 7f379bd6a2 | |||
| f751ded65b | |||
| 0c8d0fa8d1 | |||
| f7f37fb4e4 | |||
| d484e60c46 | |||
| a6f44e5bb4 | |||
| 363357bff4 | |||
| 843123bbdb | |||
| 1d76d930bd | |||
| cde9478899 | |||
| d080198220 | |||
| 35231d8b96 | |||
| 2e11c452a9 | |||
| 02bff371c1 | |||
| 375c3e2d1f | |||
| 57d6495271 | |||
| 6ca3b0d6fa | |||
| 85a95aa2d0 | |||
| 2501b00079 | |||
| e0a25ff629 | |||
| d2b344ea24 | |||
| 99c7bac99b | |||
| 59d3dd06b6 | |||
| 0f6f38a70e | |||
| 2a2ded7a53 | |||
| cb681dbd68 | |||
| 8ae0ecef25 | |||
| bffdaa9f57 | |||
| 9ef5227f0f | |||
| a250ea605f | |||
| a70d5a4bdb | |||
| ce2333e309 | |||
| 0c9661d694 | |||
| a780959de9 | |||
| 4382de3a79 | |||
| 0a45fcbdfd | |||
| 747caaf3e7 | |||
| 0cf1406314 | |||
| a8257001a7 | |||
| 4616308402 | |||
| 910c2d0e37 | |||
| d4520ff6b0 | |||
| 1b899e024d | |||
| 8170527ee4 | |||
| 3e733969dc | |||
| 39231ef856 | |||
| ca4da6932e | |||
| 16f7f1c340 | |||
| 0718e41cc5 | |||
| 1f77134597 | |||
| 8a2e701ff2 | |||
| 2ef4ac4b9c | |||
| 06a3bd532d | |||
| 544c8f3081 | |||
| ca93cf7652 | |||
| dd5bdedf0a | |||
| 1a553ab287 | |||
| ecfeddb34a | |||
| 1cd47211a5 | |||
| 66320166b8 | |||
| 989ee58481 | |||
| dd1f72bf58 | |||
| 0b6937973c | |||
| 5e804a35f1 | |||
| 3e70f87d88 | |||
| 7e8560ae58 | |||
| ed8ec89bcc | |||
| 868e57ee0c | |||
| 3b59bd499a | |||
| a8e0cc9195 | |||
| 616f1d98a1 | |||
| aab7345590 | |||
| e3ef9d70be | |||
| a03fb99242 | |||
| bca6d55684 | |||
| 5dc95098ea | |||
| e5ec754abc | |||
| ec4069ce38 | |||
| f248e27702 | |||
| 32006a2bda | |||
| 1412d3fefd | |||
| 9fcefa3ab9 | |||
| e6dbbb49a1 | |||
| 789e7dcdb6 | |||
| 3bedf10449 | |||
| 183c719614 | |||
| 36ea9cde04 | |||
| 1e4278ffb2 | |||
| 515acb654c | |||
| 7bc9ded118 | |||
| 30d1a3c756 | |||
| 7e167cf943 | |||
| cb5bb7dbaf | |||
| 942f5364e8 | |||
| fcba06172a | |||
| 947290f1dc | |||
| 14f405a84a | |||
| 781a59cbf6 | |||
| b1765e98f7 | |||
| c2c9210317 | |||
| 07eacdbceb | |||
| ef5da8def8 | |||
| 78bae4addf | |||
| 049eaf0dfc | |||
| 1ab84d8038 | |||
| 83a8d58096 | |||
| 8dbdd5aac0 | |||
| 235b1d4e8c | |||
| b40f2c8ffb | |||
| 63337b418d | |||
| 2ebc776cc9 | |||
| a0691e8857 | |||
| 50fc188f01 | |||
| 14f92d5147 | |||
| 802cda1b34 | |||
| 33d9c43450 | |||
| afcff10892 | |||
| 1a49d7b127 | |||
| a816c2413b | |||
| b22b76f96e | |||
| ea5e475f32 | |||
| 626baa65ec | |||
| bcba3a153c | |||
| 3e389365d5 | |||
| e29f38280e | |||
| 0f4f7161c8 | |||
| b4138bbc82 | |||
| 80c1cfd9e4 | |||
| 37518e6aa2 | |||
| a2b6293566 | |||
| 77cc535ab2 | |||
| 5e73e0cf0f | |||
| 90be402106 | |||
| e9ae43a81b | |||
| 78333da3d5 | |||
| fc7d34a131 | |||
| efc6dbeb0a | |||
| d78a72c286 | |||
| ba12fecc5c | |||
| 74cc4408c7 | |||
| ccf194ed8a | |||
| a2bfeafcea | |||
| f98a3bf109 | |||
| 3981fdcbf3 | |||
| 5234e46d92 | |||
| a3167d5783 | |||
| 7bcfbf6bd4 | |||
| ad2c8f1704 | |||
| 55a34af986 | |||
| 54451d2ea6 | |||
| 9cf0f0c0c7 | |||
| de66b8b316 | |||
| 008c8a3ad0 | |||
| 18603f6881 | |||
| d7aa5efe30 | |||
| 21f5047640 | |||
| a539b08499 | |||
| 05706ef429 | |||
| 35b48c1b0c | |||
| 046c8b6efa | |||
| fc5f58a992 | |||
| b51d5fb31d | |||
| 10b19df1c4 | |||
| df4532d2fd | |||
| d85b9391cc | |||
| 2018959fdc | |||
| ff3979d527 | |||
| 756a8838d6 | |||
| a319e4f98a | |||
| 1313d89525 | |||
| bcce4d9986 | |||
| a718bb951f | |||
| 621498acc9 | |||
| cafa8dfe2d | |||
| 8d9183c3ac | |||
| 0cea2cc320 | |||
| 9b63e27825 | |||
| 0c98524357 | |||
| 431117087f | |||
| 5deff727a4 | |||
| 554b59359c | |||
| 507c4d869a | |||
| f9bedb6aad | |||
| 88eac07116 | |||
| b1e903f31a | |||
| ec6ebc57e0 | |||
| fad1c895a1 | |||
| 7b04e7e752 | |||
| 822e5346d8 | |||
| 4bdb996c6c | |||
| 830e7fc3d7 | |||
| c1ecefafc0 | |||
| f467409baf | |||
| c4876410ea |
+20
-18
@@ -37,24 +37,6 @@
|
||||
]
|
||||
},
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"C:/моя/проекты/портал crm/Документация/tools/ruflo-recall-hook.mjs\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"C:/моя/проекты/портал crm/Документация/tools/ruflo-queen-hook.mjs\""
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write",
|
||||
@@ -64,6 +46,15 @@
|
||||
"command": "node -e \"const f=process.env.CLAUDE_FILE_PATH||''; const pd=process.env.CLAUDE_PROJECT_DIR||''; const path=require('path'); if (f && pd && path.resolve(f) === path.resolve(pd, 'CLAUDE.md')) { process.stderr.write('\\n[hook] WARNING: Direct edit of root CLAUDE.md detected. Per CLAUDE.md §5 п.10, prefer /claude-md-management:revise-claude-md or /claude-md-management:claude-md-improver. If invoked via that skill, this warning is informational.\\n'); }\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Task",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"C:/моя/проекты/портал crm/Документация/tools/subagent-prompt-prefix.mjs\""
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
@@ -85,6 +76,17 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/observer-stop-hook.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
name: billing-audit
|
||||
description: Аудит денежной корректности биллинг-кода Лидерры — money-инварианты при правке/ревью списаний, тарифов и баланса. Используй при «проверь списание», «аудит биллинга», «не теряются ли копейки», «идемпотентно ли списание», «корректна ли тарифная ступень», «что значит дрейф CsvReconcile», «провенанс charge_source». НЕ для моделирования процесса (process-modeling), поиска узких мест (process-analysis), security-аудита (D3), РСБУ/налогов (ru-tax-accounting), метрик выручки (product-management).
|
||||
---
|
||||
|
||||
# Billing Audit — аудит денежной корректности биллинга Лидерры
|
||||
|
||||
Проектный скил раздела C6 карты «Финансы — биллинг и тарификация». Проверяет
|
||||
**денежные инварианты** биллинг-подсистемы при правке или ревью кода. Объект —
|
||||
корректность *начисления* (не процесс, не безопасность, не учёт/налоги).
|
||||
|
||||
## Когда использовать
|
||||
|
||||
- Правка/ревью кода в `app/app/Services/Billing/**`, `app/app/Jobs/Supplier/CsvReconcileJob.php`,
|
||||
моделей `PricingTier`/`LeadCharge`, контроллеров биллинга.
|
||||
- Вопрос «безопасно ли это денежно?» по списанию, тарифу, балансу, сверке.
|
||||
|
||||
## Процедура аудита (5 инвариантов)
|
||||
|
||||
Полный чек-лист с проверками и ссылками на файлы — `references/invariants.md`.
|
||||
|
||||
1. **Сохранение суммы** — все денежные операции через `bcmath` (bcadd/bcsub/bcmul/bcdiv,
|
||||
scale фиксирован), никаких float; prepaid→₽ конвертация без потери копеек.
|
||||
2. **Идемпотентность списания** — один лид = одно списание; повтор/ретрай джоба
|
||||
не дублирует начисление (проверить уникальный ключ / advisory-lock / upsert).
|
||||
3. **Корректность тарифной ступени** — `PricingTierResolver` выбирает верную из 7
|
||||
ступеней по объёму; границы ступеней (включительно/исключительно) однозначны.
|
||||
4. **Дрейф сверки** — `CsvReconcileJob` порог >5%: что сравнивается, что значит дрейф,
|
||||
куда смотреть (рассинхрон поставки vs ошибка тарифа).
|
||||
5. **Провенанс charge_source** — каждое списание имеет прослеживаемый источник
|
||||
(`charge_source`); ручные/авто/CSV-восстановленные различимы.
|
||||
|
||||
## Границы
|
||||
|
||||
- ≠ `process-modeling` #52 / `process-analysis` #53 — те про *поток/процесс*; billing-audit про *деньги в коде*.
|
||||
- ≠ D3 audit-security (#39/#40) — те про *безопасность*; billing-audit про *денежную корректность*.
|
||||
- ≠ `ru-tax-accounting` #63 — тот про *учёт/налоги* (выход биллинга → налоговая база); billing-audit про *начисление*.
|
||||
- ≠ `product-management:metrics-review` #42 — тот про *метрики выручки*; billing-audit про *корректность*.
|
||||
|
||||
## Связано
|
||||
|
||||
- Reuse: Boost #10 (модели), Pest #18 (тесты инвариантов), Larastan #12 (bcmath/без float), Sentry #34 / Redis #35 (runtime/очередь).
|
||||
- ADR-012 (граница finance-tooling C6/C7).
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"skill": "billing-audit",
|
||||
"positive": [
|
||||
"проверь корректность списания за лид",
|
||||
"аудит денежной логики биллинга",
|
||||
"не теряются ли копейки в prepaid→рублёвом балансе",
|
||||
"идемпотентно ли списание при ретрае",
|
||||
"правильно ли резолвится тарифная ступень",
|
||||
"что значит дрейф >5% в CsvReconcile",
|
||||
"проверь провенанс charge_source",
|
||||
"ревью PricingTierResolver на ошибки округления",
|
||||
"ledger двойной баланс — где может утечь сумма",
|
||||
"audit charge invariants before merge"
|
||||
],
|
||||
"near_miss": [
|
||||
{"prompt": "смоделируй BPMN процесса списания", "expect": "process-modeling #52"},
|
||||
{"prompt": "где узкое место в воронке оплат", "expect": "process-analysis #53"},
|
||||
{"prompt": "security-аудит платёжного эндпоинта", "expect": "D3 audit-security / Semgrep"},
|
||||
{"prompt": "посчитай РСБУ-проводки по выручке", "expect": "ru-tax-accounting #63"},
|
||||
{"prompt": "метрика MRR за месяц", "expect": "product-management metrics-review #42"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
# Денежные инварианты биллинга Лидерры — чек-лист аудита
|
||||
|
||||
Объект-файлы (на момент 20.05.2026):
|
||||
|
||||
- `app/app/Services/Billing/PricingTierResolver.php` — резолюция 7 ступеней (pure).
|
||||
- `app/app/Services/Billing/LedgerService.php` — двойной баланс prepaid→₽ (bcmath).
|
||||
- `app/app/Services/Billing/BillingTopupService.php` — пополнение.
|
||||
- `app/app/Services/Billing/ChargeResult.php` — DTO результата списания.
|
||||
- `app/app/Models/PricingTier.php`, `app/app/Models/LeadCharge.php`.
|
||||
- `app/app/Repositories/PricingTierRepository.php`.
|
||||
- `app/app/Jobs/Supplier/CsvReconcileJob.php` — hourly сверка, алерт дрейфа >5%.
|
||||
- `app/app/Http/Controllers/Api/{AdminPricingTiersController,AdminBillingController,BillingController,TenantChargesController}.php`.
|
||||
|
||||
## I1. Сохранение суммы (bcmath, без float)
|
||||
|
||||
- [ ] Все арифметические операции с деньгами — `bcadd`/`bcsub`/`bcmul`/`bcdiv`/`bccomp` с явным `scale`.
|
||||
- [ ] Нет `+`/`-`/`*`/`/` над денежными значениями (Larastan/grep на float-арифметику в Billing).
|
||||
- [ ] prepaid→₽: конвертация округляет детерминированно (TRUNC/округление вниз в пользу tenant — свериться с кодом), сумма prepaid + ₽ не «исчезает».
|
||||
- [ ] Денежные колонки — целочисленные копейки или DECIMAL, не float/double.
|
||||
|
||||
## I2. Идемпотентность списания
|
||||
|
||||
- [ ] Один лид → одно списание: уникальность по (lead_id) или advisory-lock в `LedgerService`.
|
||||
- [ ] Ретрай `ImportLeadsJob`/`CsvReconcileJob` не создаёт дубль `lead_charges`.
|
||||
- [ ] Транзакция + `lockForUpdate` на балансе при мутации (TOCTOU — см. Sprint 3 lockForUpdate).
|
||||
|
||||
## I3. Корректность тарифной ступени
|
||||
|
||||
- [ ] `PricingTierResolver` выбирает ступень по объёму `delivered_in_month` верно на границах.
|
||||
- [ ] Границы ступеней непрерывны (нет дыр/перекрытий между 7 ступенями).
|
||||
- [ ] Pest покрывает граничные значения (ступень N → N+1).
|
||||
|
||||
## I4. Дрейф сверки CsvReconcile
|
||||
|
||||
- [ ] Порог >5% — что сравнивается (поставка поставщика vs начислено) → `supplier_csv_reconcile_log`.
|
||||
- [ ] Дрейф = рассинхрон поставки (норм) ИЛИ ошибка тарифа (баг) — различить по `charge_source`.
|
||||
|
||||
## I5. Провенанс charge_source
|
||||
|
||||
- [ ] Каждое `lead_charges.charge_source` заполнено и прослеживаемо.
|
||||
- [ ] Авто/ручное/CSV-восстановленное (`recovered_from_csv_at`) различимы.
|
||||
|
||||
## Reuse-инструменты
|
||||
|
||||
Boost #10 (Eloquent-introspection), Pest #18 + pest-parallel-debugger (тесты + race),
|
||||
Larastan #12 (статанализ bcmath), Sentry MCP #34 (runtime списаний), Redis MCP #35 (очередь сверки), context7 #60 (доки bcmath).
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
name: brain-retro
|
||||
description: Use ONCE PER SPRINT (or by explicit user invocation "брейн-ретро") to aggregate evidence from docs/observer/episodes-*.jsonl + notes/*.md and propose regulatory candidates. Read-only — never edits Tooling/Pravila/PSR_v1 automatically; only proposes.
|
||||
---
|
||||
|
||||
# Brain Retro
|
||||
|
||||
Aggregator over observer evidence. Reads JSONL + optional MD notes, surfaces candidates for normative updates. User decides what to apply.
|
||||
|
||||
## When to invoke
|
||||
|
||||
- Explicit user request: «брейн-ретро» / «сделай brain-retro» / `/brain-retro`.
|
||||
- Periodic — owner discretion (e.g. end of sprint).
|
||||
- NOT auto-invoked.
|
||||
|
||||
## What it does NOT do
|
||||
|
||||
- Does NOT edit `docs/Tooling_v8_3.md`, `docs/Pravila_raboty_Claude_v1_1.md`, `docs/Plugin_stack_rules_v1.md`, `CLAUDE.md`, or any normative file.
|
||||
- Does NOT write to `docs/observer/episodes-*.jsonl` (read-only).
|
||||
- Does NOT trigger automatic memory updates.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. **Determine period**: ask user «за какой период» or default to «since last brain-retro» (find latest `docs/observer/notes/YYYY-MM-DD-brain-retro-*.md`).
|
||||
2. **Read evidence**: glob `docs/observer/episodes-YYYY-MM.jsonl` for the period; read all lines as JSON.
|
||||
3. **Read optional notes**: glob `docs/observer/notes/*.md` filtered by date.
|
||||
4. **Update read-counter**: run `node tools/observer-of-observer.mjs record`. This atomically bumps `docs/observer/.read-counter.json` `last_read_at` to now and increments `read_count_last_period`. (Side-effect — used by C3 observer-of-observer for 54-week self-prune detection.)
|
||||
5. **Run the deterministic analyzer**: `node tools/brain-retro-analyzer.mjs docs/observer/episodes-YYYY-MM.jsonl` (pass every monthly file in the period). It returns JSON with `episodeCount`, `observerErrorCount`, `tasks` (episodes grouped into tasks), `causalChains` (error→fix candidates) and `factorMatrix` (outcome distribution per factor). The analyzer deduplicates the routing-gate double-write and infers the true `outcome` of each episode from the next episode's `prompt_signal` — never trust the stored `outcome` (it is `unknown` at write time).
|
||||
6. **Aggregate** per `references/aggregation-template.md` — fill the Factor analysis matrix from the analyzer's `factorMatrix`, the task groups from `tasks`, the causal-chain candidates from `causalChains`.
|
||||
7. **Propose candidates** — clearly separated section «Candidates for owner review». Each candidate has rationale + suggested edit + rejection-option.
|
||||
8. **Save retro note**: `docs/observer/notes/YYYY-MM-DD-brain-retro.md` with full aggregation.
|
||||
8a. **Refresh STATUS.md**: `node tools/status-md-generator.mjs` — auto-rebuild dashboard so it reflects the just-finished retro (`Last /brain-retro: 0 day(s) ago`, current episode count, refreshed C1–C5 controller statuses). Without this, STATUS.md only updates on the next git commit.
|
||||
9. **Report to user**: high-signal summary.
|
||||
|
||||
## Output anatomy
|
||||
|
||||
See `references/aggregation-template.md`.
|
||||
|
||||
## Behavioral rule reminders
|
||||
|
||||
- **«Не использован ≠ проблема» (условное, 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.
|
||||
@@ -0,0 +1,142 @@
|
||||
# Brain-retro aggregation template
|
||||
|
||||
## Period
|
||||
|
||||
YYYY-MM-DD .. YYYY-MM-DD ({N} sessions)
|
||||
|
||||
## Path-type distribution
|
||||
|
||||
| path_type | count | % |
|
||||
|---|---|---|
|
||||
| regulated | A | x% |
|
||||
| improvised | B | y% |
|
||||
| alternative | C | z% |
|
||||
| mixed | D | w% |
|
||||
|
||||
## Outcome distribution
|
||||
|
||||
| outcome | count |
|
||||
|---|---|
|
||||
| success | M |
|
||||
| partial | N |
|
||||
| failure | O |
|
||||
| aborted | P |
|
||||
|
||||
## Top nodes used (from `skill_invoked` events)
|
||||
|
||||
| node | times used | first / last |
|
||||
|---|---|---|
|
||||
|
||||
## Factor analysis matrix (v2 — from `tools/brain-retro-analyzer.mjs`)
|
||||
|
||||
Outcome distribution per factor value. Source: the analyzer’s `factorMatrix`.
|
||||
Outcome is the *inferred* outcome (next-prompt sentiment), not the stored
|
||||
`unknown`. The factor `decision_provenance` directly answers the owner’s
|
||||
question — "is the rework mine or the router’s?"
|
||||
|
||||
For each factor below, render a table: factor value × outcome counts
|
||||
(`success` / `partial` / `rework` / `unknown`).
|
||||
|
||||
### decision_provenance (autonomous vs user_directed_method)
|
||||
|
||||
| provenance | success | partial | rework | unknown |
|
||||
|---|---|---|---|---|
|
||||
|
||||
### economy_level
|
||||
|
||||
| economy_level | success | partial | rework | unknown |
|
||||
|---|---|---|---|---|
|
||||
|
||||
### model · post_compaction · task_size bucket
|
||||
|
||||
(one table each — same columns)
|
||||
|
||||
### node_chosen · task_classification
|
||||
|
||||
(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 |
|
||||
|---|---|---|
|
||||
|
||||
## Causal-chain candidates (from analyzer `causalChains`)
|
||||
|
||||
| from (errored episode) | to (later episode) | shared files |
|
||||
|---|---|---|
|
||||
|
||||
## Observer health
|
||||
|
||||
- `observerErrorCount` from the analyzer — observer_error markers in the period.
|
||||
Non-zero = the observer failed silently somewhere; investigate.
|
||||
|
||||
## Canonical chains L1–L13+ hit rate (from analyzer `factorMatrix.chain_ref`)
|
||||
|
||||
| chain | times | outcome split | notes |
|
||||
|---|---|---|---|
|
||||
|
||||
Each node may belong to several L (a multi-chain episode is counted in each).
|
||||
`null` = episodes outside any chain (`direct` + nodes not in L1–L13+) — **not a
|
||||
problem** per `memory/feedback_brain_unused_tools_not_problem`.
|
||||
|
||||
## Improvised chains (path_type=improvised, repeated ≥2)
|
||||
|
||||
| node-set | times | candidate L13+? |
|
||||
|---|---|---|
|
||||
|
||||
## chain_divergence cases
|
||||
|
||||
| canonical | chosen | reason | recurring? |
|
||||
|---|---|---|---|
|
||||
|
||||
## Top error classes
|
||||
|
||||
| error class | count | recovery pattern |
|
||||
|---|---|---|
|
||||
|
||||
## confusion_marker hot-spots
|
||||
|
||||
| context | count |
|
||||
|---|---|
|
||||
|
||||
## Candidates for owner review
|
||||
|
||||
### Candidate 1: `<title>`
|
||||
|
||||
- **Type**: new canonical chain L13+ / new ADR / boundary clarification / etc.
|
||||
- **Evidence**: refs to JSONL lines (file:line).
|
||||
- **Suggested action**: `<concrete edit>`.
|
||||
- **Cost / risk**: `<brief>`.
|
||||
|
||||
(repeat for each candidate; could be 0)
|
||||
|
||||
## Informational metrics (NOT alerts)
|
||||
|
||||
- Nodes used at least once this period: K / 60+
|
||||
- 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,224 @@
|
||||
---
|
||||
name: data-scientist
|
||||
description: Expert data scientist for advanced analytics, machine learning, and statistical modeling. Handles complex data analysis, predictive modeling, and business intelligence.
|
||||
---
|
||||
|
||||
## Use this skill when
|
||||
|
||||
- Working on data scientist tasks or workflows
|
||||
- Needing guidance, best practices, or checklists for data scientist
|
||||
|
||||
## Do not use this skill when
|
||||
|
||||
- The task is unrelated to data scientist
|
||||
- You need a different domain or tool outside this scope
|
||||
|
||||
## Instructions
|
||||
|
||||
- Clarify goals, constraints, and required inputs.
|
||||
- Apply relevant best practices and validate outcomes.
|
||||
- Provide actionable steps and verification.
|
||||
|
||||
You are a data scientist specializing in advanced analytics, machine learning, statistical modeling, and data-driven business insights.
|
||||
|
||||
## Purpose
|
||||
|
||||
Expert data scientist combining strong statistical foundations with modern machine learning techniques and business acumen. Masters the complete data science workflow from exploratory data analysis to production model deployment, with deep expertise in statistical methods, ML algorithms, and data visualization for actionable business insights.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### Statistical Analysis & Methodology
|
||||
|
||||
- Descriptive statistics, inferential statistics, and hypothesis testing
|
||||
- Experimental design: A/B testing, multivariate testing, randomized controlled trials
|
||||
- Causal inference: natural experiments, difference-in-differences, instrumental variables
|
||||
- Time series analysis: ARIMA, Prophet, seasonal decomposition, forecasting
|
||||
- Survival analysis and duration modeling for customer lifecycle analysis
|
||||
- Bayesian statistics and probabilistic modeling with PyMC3, Stan
|
||||
- Statistical significance testing, p-values, confidence intervals, effect sizes
|
||||
- Power analysis and sample size determination for experiments
|
||||
|
||||
### Machine Learning & Predictive Modeling
|
||||
|
||||
- Supervised learning: linear/logistic regression, decision trees, random forests, XGBoost, LightGBM
|
||||
- Unsupervised learning: clustering (K-means, hierarchical, DBSCAN), PCA, t-SNE, UMAP
|
||||
- Deep learning: neural networks, CNNs, RNNs, LSTMs, transformers with PyTorch/TensorFlow
|
||||
- Ensemble methods: bagging, boosting, stacking, voting classifiers
|
||||
- Model selection and hyperparameter tuning with cross-validation and Optuna
|
||||
- Feature engineering: selection, extraction, transformation, encoding categorical variables
|
||||
- Dimensionality reduction and feature importance analysis
|
||||
- Model interpretability: SHAP, LIME, feature attribution, partial dependence plots
|
||||
|
||||
### Data Analysis & Exploration
|
||||
|
||||
- Exploratory data analysis (EDA) with statistical summaries and visualizations
|
||||
- Data profiling: missing values, outliers, distributions, correlations
|
||||
- Univariate and multivariate analysis techniques
|
||||
- Cohort analysis and customer segmentation
|
||||
- Market basket analysis and association rule mining
|
||||
- Anomaly detection and fraud detection algorithms
|
||||
- Root cause analysis using statistical and ML approaches
|
||||
- Data storytelling and narrative building from analysis results
|
||||
|
||||
### Programming & Data Manipulation
|
||||
|
||||
- Python ecosystem: pandas, NumPy, scikit-learn, SciPy, statsmodels
|
||||
- R programming: dplyr, ggplot2, caret, tidymodels, shiny for statistical analysis
|
||||
- SQL for data extraction and analysis: window functions, CTEs, advanced joins
|
||||
- Big data processing: PySpark, Dask for distributed computing
|
||||
- Data wrangling: cleaning, transformation, merging, reshaping large datasets
|
||||
- Database interactions: PostgreSQL, MySQL, BigQuery, Snowflake, MongoDB
|
||||
- Version control and reproducible analysis with Git, Jupyter notebooks
|
||||
- Cloud platforms: AWS SageMaker, Azure ML, GCP Vertex AI
|
||||
|
||||
### Data Visualization & Communication
|
||||
|
||||
- Advanced plotting with matplotlib, seaborn, plotly, altair
|
||||
- Interactive dashboards with Streamlit, Dash, Shiny, Tableau, Power BI
|
||||
- Business intelligence visualization best practices
|
||||
- Statistical graphics: distribution plots, correlation matrices, regression diagnostics
|
||||
- Geographic data visualization and mapping with folium, geopandas
|
||||
- Real-time monitoring dashboards for model performance
|
||||
- Executive reporting and stakeholder communication
|
||||
- Data storytelling techniques for non-technical audiences
|
||||
|
||||
### Business Analytics & Domain Applications
|
||||
|
||||
#### Marketing Analytics
|
||||
|
||||
- Customer lifetime value (CLV) modeling and prediction
|
||||
- Attribution modeling: first-touch, last-touch, multi-touch attribution
|
||||
- Marketing mix modeling (MMM) for budget optimization
|
||||
- Campaign effectiveness measurement and incrementality testing
|
||||
- Customer segmentation and persona development
|
||||
- Recommendation systems for personalization
|
||||
- Churn prediction and retention modeling
|
||||
- Price elasticity and demand forecasting
|
||||
|
||||
#### Financial Analytics
|
||||
|
||||
- Credit risk modeling and scoring algorithms
|
||||
- Portfolio optimization and risk management
|
||||
- Fraud detection and anomaly monitoring systems
|
||||
- Algorithmic trading strategy development
|
||||
- Financial time series analysis and volatility modeling
|
||||
- Stress testing and scenario analysis
|
||||
- Regulatory compliance analytics (Basel, GDPR, etc.)
|
||||
- Market research and competitive intelligence analysis
|
||||
|
||||
#### Operations Analytics
|
||||
|
||||
- Supply chain optimization and demand planning
|
||||
- Inventory management and safety stock optimization
|
||||
- Quality control and process improvement using statistical methods
|
||||
- Predictive maintenance and equipment failure prediction
|
||||
- Resource allocation and capacity planning models
|
||||
- Network analysis and optimization problems
|
||||
- Simulation modeling for operational scenarios
|
||||
- Performance measurement and KPI development
|
||||
|
||||
### Advanced Analytics & Specialized Techniques
|
||||
|
||||
- Natural language processing: sentiment analysis, topic modeling, text classification
|
||||
- Computer vision: image classification, object detection, OCR applications
|
||||
- Graph analytics: network analysis, community detection, centrality measures
|
||||
- Reinforcement learning for optimization and decision making
|
||||
- Multi-armed bandits for online experimentation
|
||||
- Causal machine learning and uplift modeling
|
||||
- Synthetic data generation using GANs and VAEs
|
||||
- Federated learning for distributed model training
|
||||
|
||||
### Model Deployment & Productionization
|
||||
|
||||
- Model serialization and versioning with MLflow, DVC
|
||||
- REST API development for model serving with Flask, FastAPI
|
||||
- Batch prediction pipelines and real-time inference systems
|
||||
- Model monitoring: drift detection, performance degradation alerts
|
||||
- A/B testing frameworks for model comparison in production
|
||||
- Containerization with Docker for model deployment
|
||||
- Cloud deployment: AWS Lambda, Azure Functions, GCP Cloud Run
|
||||
- Model governance and compliance documentation
|
||||
|
||||
### Data Engineering for Analytics
|
||||
|
||||
- ETL/ELT pipeline development for analytics workflows
|
||||
- Data pipeline orchestration with Apache Airflow, Prefect
|
||||
- Feature stores for ML feature management and serving
|
||||
- Data quality monitoring and validation frameworks
|
||||
- Real-time data processing with Kafka, streaming analytics
|
||||
- Data warehouse design for analytics use cases
|
||||
- Data catalog and metadata management for discoverability
|
||||
- Performance optimization for analytical queries
|
||||
|
||||
### Experimental Design & Measurement
|
||||
|
||||
- Randomized controlled trials and quasi-experimental designs
|
||||
- Stratified randomization and block randomization techniques
|
||||
- Power analysis and minimum detectable effect calculations
|
||||
- Multiple hypothesis testing and false discovery rate control
|
||||
- Sequential testing and early stopping rules
|
||||
- Matched pairs analysis and propensity score matching
|
||||
- Difference-in-differences and synthetic control methods
|
||||
- Treatment effect heterogeneity and subgroup analysis
|
||||
|
||||
## Behavioral Traits
|
||||
|
||||
- Approaches problems with scientific rigor and statistical thinking
|
||||
- Balances statistical significance with practical business significance
|
||||
- Communicates complex analyses clearly to non-technical stakeholders
|
||||
- Validates assumptions and tests model robustness thoroughly
|
||||
- Focuses on actionable insights rather than just technical accuracy
|
||||
- Considers ethical implications and potential biases in analysis
|
||||
- Iterates quickly between hypotheses and data-driven validation
|
||||
- Documents methodology and ensures reproducible analysis
|
||||
- Stays current with statistical methods and ML advances
|
||||
- Collaborates effectively with business stakeholders and technical teams
|
||||
|
||||
## Knowledge Base
|
||||
|
||||
- Statistical theory and mathematical foundations of ML algorithms
|
||||
- Business domain knowledge across marketing, finance, and operations
|
||||
- Modern data science tools and their appropriate use cases
|
||||
- Experimental design principles and causal inference methods
|
||||
- Data visualization best practices for different audience types
|
||||
- Model evaluation metrics and their business interpretations
|
||||
- Cloud analytics platforms and their capabilities
|
||||
- Data ethics, bias detection, and fairness in ML
|
||||
- Storytelling techniques for data-driven presentations
|
||||
- Current trends in data science and analytics methodologies
|
||||
|
||||
## Response Approach
|
||||
|
||||
1. **Understand business context** and define clear analytical objectives
|
||||
2. **Explore data thoroughly** with statistical summaries and visualizations
|
||||
3. **Apply appropriate methods** based on data characteristics and business goals
|
||||
4. **Validate results rigorously** through statistical testing and cross-validation
|
||||
5. **Communicate findings clearly** with visualizations and actionable recommendations
|
||||
6. **Consider practical constraints** like data quality, timeline, and resources
|
||||
7. **Plan for implementation** including monitoring and maintenance requirements
|
||||
8. **Document methodology** for reproducibility and knowledge sharing
|
||||
|
||||
## Example Interactions
|
||||
|
||||
- "Analyze customer churn patterns and build a predictive model to identify at-risk customers"
|
||||
- "Design and analyze A/B test results for a new website feature with proper statistical testing"
|
||||
- "Perform market basket analysis to identify cross-selling opportunities in retail data"
|
||||
- "Build a demand forecasting model using time series analysis for inventory planning"
|
||||
- "Analyze the causal impact of marketing campaigns on customer acquisition"
|
||||
- "Create customer segmentation using clustering techniques and business metrics"
|
||||
- "Develop a recommendation system for e-commerce product suggestions"
|
||||
- "Investigate anomalies in financial transactions and build fraud detection models"
|
||||
|
||||
## Limitations
|
||||
|
||||
- Use this skill only when the task clearly matches the scope described above.
|
||||
- Do not treat the output as a substitute for environment-specific validation, testing, or expert review.
|
||||
- Stop and ask for clarification if required inputs, permissions, safety boundaries, or success criteria are missing.
|
||||
|
||||
---
|
||||
|
||||
> **Provenance (A11 «ML / AI-разработка»):** vendored into Лидерра 2026-05-17 from
|
||||
> [`sickn33/antigravity-awesome-skills`](https://github.com/sickn33/antigravity-awesome-skills)
|
||||
> `skills/data-scientist`. Skill content is licensed **CC BY 4.0**; repository
|
||||
> tooling is MIT. Aggregator frontmatter (`risk`/`source`/`date_added`) dropped on
|
||||
> vendor. See `docs/ml/README.md` for the A11 toolset and boundaries.
|
||||
@@ -0,0 +1,142 @@
|
||||
---
|
||||
name: discovery-interview
|
||||
description: Структурированное интервью-discovery ПЕРЕД проектированием. Два режима. FEATURE — заказчик описывает проблему, боль или цель без готового решения («менеджеры жалуются на…», «сделки теряются», «хочу чтобы…»): JTBD-интервью вскрывает проблему до решения и отдаёт discovery-brief в brainstorming. SYSTEM — запрос ориентации по проекту («сориентируй», «где мы сейчас», «что в тулчейне / на карте», «catch-up по…»): синтез по мета-слою (карта, CLAUDE.md, MEMORY, Открытые_вопросы, Tooling, git log). SKIP — чёткий директив на реализацию («интегрируй X», «закрой находку Y», «поправь Z»): это не discovery. SKIP — анализ бизнес-процесса из кода или диагностика просадки измеримой метрики/конверсии («как устроен процесс X», «process discovery», «где узкое место», «почему просела конверсия»): это skill process-analysis. Используй при «discovery interview», «проведи discovery», «сориентируй по проекту» и при расплывчатом проблемном запросе, даже если слово «discovery» не названо.
|
||||
---
|
||||
|
||||
# Discovery Interview
|
||||
|
||||
Структурированное интервью, которое вскрывает **проблему** прежде, чем кто-либо
|
||||
начнёт проектировать решение. Два режима — FEATURE (интервью заказчика перед
|
||||
фичей) и SYSTEM (интервью-ориентация по состоянию проекта).
|
||||
|
||||
Зачем скил существует: запрос вида «менеджеры жалуются на X» или «хочу, чтобы Y» —
|
||||
это симптом, не задача. Уйдёшь сразу в дизайн — спроектируешь решение не той
|
||||
проблемы. Discovery interview удерживает разговор в проблемном поле ровно столько,
|
||||
сколько нужно, чтобы понять *настоящую* потребность, и только потом передаёт
|
||||
эстафету проектированию.
|
||||
|
||||
## Когда какой режим
|
||||
|
||||
| Запрос | Действие |
|
||||
|---|---|
|
||||
| Заказчик описал проблему / боль / цель без решения | режим **FEATURE** |
|
||||
| Заказчик просит сориентировать по проекту | режим **SYSTEM** |
|
||||
| Заказчик дал чёткий директив («сделай X», «интегрируй Y») | скил не нужен — работай напрямую |
|
||||
| Вопрос про устройство бизнес-процесса из кода | скил `process-analysis`, не этот |
|
||||
|
||||
## Несущий принцип — три слоя-источника
|
||||
|
||||
Этот скил соседствует со скилом `process-analysis` (раздел C10 карты). Чтобы не
|
||||
дублировать его, способности разведены по **слою данных**, с которым работают:
|
||||
|
||||
| Способность | Слой-источник | Метод |
|
||||
|---|---|---|
|
||||
| `process-analysis` | app-код — `routes/`, `app/Jobs`, `audit_*` | реконструкция бизнес-процесса из кода |
|
||||
| discovery-interview **FEATURE** | голова заказчика | интервью человека |
|
||||
| discovery-interview **SYSTEM** | мета-слой — карта, CLAUDE.md, MEMORY, Открытые_вопросы, Tooling, git log | интервью + синтез |
|
||||
|
||||
Правило разведения: если ответ добывается **чтением кода** — это `process-analysis`.
|
||||
Если ответ лежит в голове заказчика или в управляющих документах — это
|
||||
discovery-interview.
|
||||
|
||||
## Режим FEATURE
|
||||
|
||||
### Триггер
|
||||
|
||||
Заказчик описывает проблему, боль, раздражение или цель — но НЕ готовое решение.
|
||||
Признаки: «менеджеры жалуются…», «X теряется», «неудобно делать Y», «хочу, чтобы…»,
|
||||
«было бы хорошо, если…».
|
||||
|
||||
### SKIP
|
||||
|
||||
Не запускай FEATURE, если запрос — чёткий директив на реализацию: «интегрируй X»,
|
||||
«закрой находку Y», «поправь Z», «добавь endpoint». Проблема уже понята заказчиком,
|
||||
discovery только затормозит. Работай напрямую — или через `brainstorming`, если
|
||||
дизайн решения нетривиален.
|
||||
|
||||
Не запускай FEATURE и если запрос — **диагностика просадки измеримой метрики или
|
||||
конверсии** («почему падает конверсия B2», «где теряем в воронке», «почему лиды не
|
||||
доходят до оплаты»). Ответ там добывается анализом кода и audit-данных — это скил
|
||||
`process-analysis`. FEATURE — про UX-боль и желаемые возможности, не про диагностику
|
||||
чисел.
|
||||
|
||||
### Процесс
|
||||
|
||||
1. **Один вопрос за раз.** Не вываливай список — это интервью, не анкета. Ответ на
|
||||
первый вопрос определяет второй.
|
||||
2. **Спрашивай про прошлое поведение, не про гипотетику.** «Расскажи, как ты делал
|
||||
это в последний раз» сильнее, чем «как бы ты хотел». Люди плохо предсказывают
|
||||
своё поведение и точно помнят прошлое.
|
||||
3. **Копай до корня — «5 почему».** Первая названная проблема обычно симптом.
|
||||
4. **Не задавай наводящих вопросов.** «Тебе мешает отсутствие фильтра?» подсказывает
|
||||
ответ. Спроси открыто: «что именно замедляет тебя на этом экране?».
|
||||
5. **Поняв проблему — собери discovery-brief и остановись.** Не проектируй решение —
|
||||
это работа `brainstorming`.
|
||||
|
||||
Банк вопросов по шагам JTBD — `references/jtbd-questions.md`.
|
||||
|
||||
### Артефакт — discovery-brief
|
||||
|
||||
Проблема · JTBD (какую работу заказчик «нанимает» решение сделать) · Текущий обходной
|
||||
путь · Цена боли (время / деньги / частота) · Сигнал успеха (как поймём, что закрыто)
|
||||
· Ограничения. Шаблон — `docs/discovery/templates/discovery-brief.md`.
|
||||
|
||||
### Хэндофф
|
||||
|
||||
discovery-brief — это вход для `brainstorming`. Передай brief как готовую проблемную
|
||||
секцию: `brainstorming` берёт её и переходит к решению — он **не перезадаёт** уже
|
||||
выясненные вопросы. discovery-interview отвечает за «что за проблема», brainstorming —
|
||||
за «что построим». Отдельным файлом FEATURE-brief не сохраняется — он вливается в
|
||||
спеку brainstorming.
|
||||
|
||||
## Режим SYSTEM
|
||||
|
||||
### Триггер
|
||||
|
||||
Заказчик просит сориентировать его по состоянию проекта: «сориентируй», «где мы
|
||||
сейчас», «что у нас по X», «что в тулчейне / на карте», «catch-up».
|
||||
|
||||
### SKIP
|
||||
|
||||
Не запускай SYSTEM, если вопрос про устройство **бизнес-процесса** («как устроен
|
||||
процесс сделок», «process discovery», «где узкое место в воронке») — это скил
|
||||
`process-analysis`, он читает код. SYSTEM отвечает на «где мы в проекте», не «как
|
||||
работает процесс X».
|
||||
|
||||
### Процесс
|
||||
|
||||
1. **Короткое уточнение scope** — что именно ориентировать? Весь проект, конкретный
|
||||
раздел, тулчейн, открытые вопросы? Без scope ответ будет рыхлым.
|
||||
2. **Синтез по мета-слою:** карта `docs/automation-graph.html`, `CLAUDE.md`, MEMORY,
|
||||
`docs/Открытые_вопросы_*.md`, `docs/Tooling_*.md`, `git log`.
|
||||
3. **Запрет:** не читай `app/`-код для реконструкции процессов — это исключительный
|
||||
метод `process-analysis`. SYSTEM работает только с мета-слоем.
|
||||
4. **Выдай синтез**, а не пересказ документа целиком — ответ на запрос ориентации с
|
||||
пинами на источники.
|
||||
|
||||
### Артефакт — system-snapshot
|
||||
|
||||
Если ориентация существенная — сохрани `docs/discovery/YYYY-MM-DD-<тема>.md` по
|
||||
шаблону `docs/discovery/templates/system-snapshot.md`. Мелкий устный ответ файла не
|
||||
требует.
|
||||
|
||||
## JTBD-дисциплина (общая для обоих режимов)
|
||||
|
||||
- **Один вопрос за раз** — интервью, не анкета.
|
||||
- **Прошлое, не гипотетика** — «когда это случилось в последний раз?».
|
||||
- **«5 почему»** — корень, не симптом.
|
||||
- **Не наводи** — открытые вопросы, без подсказанного ответа.
|
||||
- **Слушай, не защищай** — если заказчик критикует существующее, не оправдывай его,
|
||||
копай дальше.
|
||||
|
||||
## Границы
|
||||
|
||||
- **`brainstorming`** — проектирование решения. discovery-interview вскрывает проблему
|
||||
и передаёт brief; brainstorming проектирует. Не дублируй его вопросы.
|
||||
- **`process-analysis`** (раздел C10) — анализ as-is бизнес-процесса из кода и
|
||||
диагностика метрик/конверсии. Если ответ требует чтения `routes/` / `app/Jobs` /
|
||||
`audit_*` или расчёта метрик процесса — это `process-analysis`, не этот скил.
|
||||
- **`audit-portal`** — качественный вердикт о здоровье портала. SYSTEM даёт
|
||||
ориентацию («где мы»), не вердикт («здорово ли»).
|
||||
- **Интервью конечных пользователей Лидерры** — вне этого скила (defer post-Б-1; для
|
||||
методологии user research — `design:user-research`).
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"skill_name": "discovery-interview",
|
||||
"note": "Триггер-eval: should_trigger=true → должен вызваться discovery-interview; false → должен сработать другой инструмент (expected_skill). Особое внимание — near-miss к process-analysis (C10).",
|
||||
"evals": [
|
||||
{ "id": 1, "should_trigger": true, "expected_skill": "discovery-interview/FEATURE", "prompt": "менеджеры жалуются что не видят, какие сделки сегодня надо обзвонить — каждое утро роются в фильтрах вручную" },
|
||||
{ "id": 2, "should_trigger": false, "expected_skill": "process-analysis", "prompt": "у меня ощущение что лиды из B2 проседают по конверсии, но не пойму почему — хочу разобраться" },
|
||||
{ "id": 3, "should_trigger": true, "expected_skill": "discovery-interview/FEATURE", "prompt": "хочу чтобы поставщики сами видели свой баланс, а то постоянно пишут в поддержку спрашивают" },
|
||||
{ "id": 4, "should_trigger": true, "expected_skill": "discovery-interview/FEATURE", "prompt": "проведи discovery interview по идее напоминаний — я пока сам не уверен что именно нужно" },
|
||||
{ "id": 5, "should_trigger": true, "expected_skill": "discovery-interview/FEATURE", "prompt": "не нравится как сейчас сделана выгрузка отчётов, неудобно, давай покопаем что не так" },
|
||||
{ "id": 6, "should_trigger": true, "expected_skill": "discovery-interview/FEATURE", "prompt": "клиенты часто отваливаются на этапе оплаты, надо понять что там за проблема" },
|
||||
{ "id": 7, "should_trigger": true, "expected_skill": "discovery-interview/SYSTEM", "prompt": "сориентируй меня — где мы сейчас по проекту, что закрыто что нет" },
|
||||
{ "id": 8, "should_trigger": true, "expected_skill": "discovery-interview/SYSTEM", "prompt": "что у нас вообще в тулчейне по безопасности, я запутался" },
|
||||
{ "id": 9, "should_trigger": true, "expected_skill": "discovery-interview/SYSTEM", "prompt": "вернулся после недели отсутствия, сделай catch-up что произошло по проекту" },
|
||||
{ "id": 10, "should_trigger": true, "expected_skill": "discovery-interview/SYSTEM", "prompt": "что там на карте в разделе биллинга, какие узлы" },
|
||||
{ "id": 11, "should_trigger": false, "expected_skill": "process-analysis", "prompt": "как устроен процесс обработки сделки от создания до закрытия — пройди по коду" },
|
||||
{ "id": 12, "should_trigger": false, "expected_skill": "process-analysis", "prompt": "где узкое место в воронке лидов, какой шаг тормозит" },
|
||||
{ "id": 13, "should_trigger": false, "expected_skill": "process-analysis", "prompt": "сделай process discovery по джобам импорта лидов" },
|
||||
{ "id": 14, "should_trigger": false, "expected_skill": "process-analysis", "prompt": "посчитай метрики процесса: cycle time по статусам сделок" },
|
||||
{ "id": 15, "should_trigger": false, "expected_skill": "directive (no skill)", "prompt": "интегрируй openapi-mcp-server в .mcp.json" },
|
||||
{ "id": 16, "should_trigger": false, "expected_skill": "directive (no skill)", "prompt": "закрой находку аудита G7 по AdminBillingController" },
|
||||
{ "id": 17, "should_trigger": false, "expected_skill": "systematic-debugging", "prompt": "поправь падающий тест RlsSmokeTest, он валится на teardown" },
|
||||
{ "id": 18, "should_trigger": false, "expected_skill": "directive (no skill)", "prompt": "добавь endpoint POST /api/deals/{id}/archive" },
|
||||
{ "id": 19, "should_trigger": false, "expected_skill": "write-spec / brainstorming", "prompt": "напиши спеку для фичи мультивалютного биллинга" },
|
||||
{ "id": 20, "should_trigger": false, "expected_skill": "audit-portal", "prompt": "проведи полный аудит портала перед релизом" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
# Банк вопросов JTBD — режим FEATURE
|
||||
|
||||
Вопросы для discovery-интервью. Задавать **по одному**, адаптируя формулировку под
|
||||
контекст. Все вопросы — про прошлое поведение, без подсказанного ответа.
|
||||
|
||||
## 1. Вскрыть проблему
|
||||
|
||||
- Расскажи, что произошло в последний раз, когда [ситуация]?
|
||||
- Что именно тебя в этом раздражало или замедляло?
|
||||
- Как часто это случается?
|
||||
|
||||
## 2. Текущий обходной путь
|
||||
|
||||
- Как ты решаешь это сейчас?
|
||||
- Что делаешь, когда [проблема] происходит?
|
||||
- Кто ещё это делает и как?
|
||||
|
||||
## 3. Цена боли
|
||||
|
||||
- Сколько времени это съедает за неделю?
|
||||
- Что случается, если не сделать это вовремя?
|
||||
- Были случаи, когда из-за этого что-то сорвалось?
|
||||
|
||||
## 4. JTBD — какую работу «нанимают» решение сделать
|
||||
|
||||
- Если бы это работало идеально — что бы ты перестал делать руками?
|
||||
- Какого результата ты на самом деле добиваешься?
|
||||
|
||||
## 5. Сигнал успеха
|
||||
|
||||
- Как ты поймёшь, что проблема закрыта?
|
||||
- Что должно стать видимо иначе?
|
||||
|
||||
## 6. Ограничения
|
||||
|
||||
- Что нельзя ломать или менять?
|
||||
- Есть ли срок?
|
||||
|
||||
## Антипаттерны
|
||||
|
||||
- **Наводящий вопрос** («тебе мешает отсутствие X?») — подсказывает ответ; заказчик
|
||||
согласится из вежливости.
|
||||
- **Гипотетика** («как бы ты хотел?») — люди плохо предсказывают своё поведение.
|
||||
- **Список вопросов разом** — это анкета, не интервью; теряется ветвление по ответам.
|
||||
- **Принять первый ответ за корень** — копай «5 почему» до настоящей причины.
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
name: laravel-backend-patterns
|
||||
description: Backend-конвенции Лидерры (Laravel 13) — как писать controller→service→job, RLS-aware Eloquent, деньги через bcmath/LedgerService, идемпотентные джобы, partition-aware запросы. Используй при «как писать backend в Лидерре», «паттерн контроллера/сервиса/джоба», scaffolding новой backend-фичи. НЕ для generic-паттернов (architecture-patterns #38), аудита денег (billing-audit #62), РСБУ/налогов (ru-tax-accounting), security-аудита (D3).
|
||||
---
|
||||
|
||||
# Laravel Backend Patterns — конвенции backend-кода Лидерры
|
||||
|
||||
Проектный скил, который описывает **как здесь пишут backend**, а не как рекомендует generic-Laravel.
|
||||
При scaffolding новой фичи или ревью кода — сверяться с пятью конвенциями ниже.
|
||||
Детальные примеры с образцами кода и антипаттернами — в `references/conventions.md`.
|
||||
|
||||
## 1. Слоистость: Controller → FormRequest → Service → Job
|
||||
|
||||
Контроллер тонкий: принимает FormRequest, делегирует Service, возвращает JSON-ответ.
|
||||
Бизнес-логика — в Service; асинхронная работа — в Job.
|
||||
Слои зафиксированы в `app/deptrac.yaml` (13 слоёв, pre-commit gate job 10).
|
||||
|
||||
Подробнее: `references/conventions.md` §1.
|
||||
|
||||
## 2. RLS-aware Eloquent и middleware `tenant`
|
||||
|
||||
Middleware `SetTenantContext` оборачивает HTTP-запрос в транзакцию и выполняет
|
||||
`SET LOCAL app.current_tenant_id = X`, обеспечивая RLS-изоляцию между tenant'ами.
|
||||
**КРИТИЧНО**: очередные джобы выполняются под ролью `crm_supplier_worker` (BYPASSRLS),
|
||||
поэтому RLS не фильтрует. Каждый запрос в джобе **обязан** содержать явный
|
||||
`where('tenant_id', $tenantId)` или устанавливать `SET LOCAL` вручную внутри транзакции.
|
||||
|
||||
Подробнее: `references/conventions.md` §2.
|
||||
|
||||
## 3. Деньги — только через bcmath и LedgerService
|
||||
|
||||
Все денежные операции — `bcadd` / `bcsub` / `bcmul` / `bcdiv` / `bccomp` со строковыми операндами
|
||||
и фиксированным `scale`. Никаких операторов `+` / `-` / `*` / `/` над деньгами, никакого `float`.
|
||||
Точка входа для биллингового списания — `LedgerService::chargeForDelivery()`.
|
||||
Аудит денежных инвариантов кода — скил `billing-audit` (#62); здесь — только конвенция написания.
|
||||
|
||||
Подробнее: `references/conventions.md` §3.
|
||||
|
||||
## 4. Идемпотентные джобы через advisory lock
|
||||
|
||||
Повторный запуск джоба не должен дублировать результат.
|
||||
Паттерн: `pg_advisory_xact_lock(composite_bigint)` внутри транзакции — сериализует
|
||||
конкурентные обработки одного (tenant_id, source_crm_id). Дополнительно: `lockForUpdate`
|
||||
на строку Tenant защищает баланс от TOCTOU при конкурентных списаниях.
|
||||
|
||||
Подробнее: `references/conventions.md` §4.
|
||||
|
||||
## 5. Partition-aware запросы для `deals` и `supplier_lead_costs`
|
||||
|
||||
Таблицы `deals` и `supplier_lead_costs` секционированы по `RANGE (received_at)`.
|
||||
Запросы к этим таблицам должны включать условие по `received_at` (или `created_at`
|
||||
для `supplier_lead_costs`) — это включает pruning и предотвращает full-scan всех партиций.
|
||||
|
||||
Подробнее: `references/conventions.md` §5.
|
||||
|
||||
## Связано
|
||||
|
||||
- `billing-audit` #62 — аудит денежной корректности (I1–I5 инварианты).
|
||||
- `architecture-patterns` #38 — общие паттерны архитектуры (не Лидерра-специфика).
|
||||
- Boost #10 — Eloquent introspection, документация Laravel 13.
|
||||
- Larastan #12 — статанализ PHP (ловит float-арифметику на деньгах).
|
||||
- ADR-005 — deptrac architecture-fitness gate.
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"skill": "laravel-backend-patterns",
|
||||
"cases": [
|
||||
{"prompt": "как написать контроллер для новой backend-фичи в Лидерре", "should_trigger": true},
|
||||
{"prompt": "как правильно списать деньги в джобе под crm_supplier_worker", "should_trigger": true},
|
||||
{"prompt": "проверь, не теряются ли копейки в списании", "should_trigger": false, "expected": "billing-audit"},
|
||||
{"prompt": "опиши Clean Architecture в общем", "should_trigger": false, "expected": "architecture-patterns"},
|
||||
{"prompt": "учёт выручки по РСБУ", "should_trigger": false, "expected": "ru-tax-accounting"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
# Backend-конвенции Лидерры — детальный справочник
|
||||
|
||||
Образцы ниже — реальный код из `app/` (Laravel 13, PHP 8.3).
|
||||
Указаны конкретные `file:line` на момент 20.05.2026.
|
||||
|
||||
---
|
||||
|
||||
## §1. Слоистость: Controller → FormRequest → Service → Job
|
||||
|
||||
### Правило
|
||||
|
||||
Контроллер принимает FormRequest (валидация), делегирует Service (бизнес-логика),
|
||||
при необходимости Service dispatch'ит Job (асинхрон). Контроллер не содержит бизнес-логики.
|
||||
Слои задокументированы в `app/deptrac.yaml` — 13 слоёв:
|
||||
Controller, Request, Resource, Middleware, Service, Job, Console, Repository,
|
||||
Model, Mail, Rule, Exception, Provider.
|
||||
Допустимые направления зависимостей — только вниз по иерархии (deptrac gate, lefthook job 10).
|
||||
|
||||
### Образец из кода
|
||||
|
||||
`app/app/Http/Controllers/Api/ProjectController.php:87–90` — контроллер тонкий:
|
||||
|
||||
```php
|
||||
/** POST /api/projects */
|
||||
public function store(StoreProjectRequest $request): JsonResponse
|
||||
{
|
||||
$project = $this->projects->create($request->user()->tenant, $request->validated());
|
||||
|
||||
return response()->json(['data' => new ProjectResource($project)], 201);
|
||||
}
|
||||
```
|
||||
|
||||
`app/app/Http/Requests/StoreProjectRequest.php:18–44` — вся валидация в FormRequest:
|
||||
|
||||
```php
|
||||
public function rules(): array
|
||||
{
|
||||
$base = [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'signal_type' => ['required', Rule::in(['site', 'call', 'sms'])],
|
||||
'daily_limit_target' => ['required', 'integer', 'min:1', 'max:10000'],
|
||||
'regions' => ['present', 'array'],
|
||||
'regions.*' => ['integer', 'between:1,89'],
|
||||
'delivery_days_mask' => ['required', 'integer', 'min:1', 'max:127'],
|
||||
];
|
||||
// ... conditional rules by signal_type
|
||||
return $base;
|
||||
}
|
||||
```
|
||||
|
||||
`app/app/Services/Billing/LedgerService.php` — бизнес-логика в Service.
|
||||
`app/app/Jobs/ProcessWebhookJob.php` — асинхрон в Job.
|
||||
|
||||
### Антипаттерн
|
||||
|
||||
```php
|
||||
// ПЛОХО: бизнес-логика в контроллере
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$tier = PricingTier::where('min_leads', '<=', $count)->orderBy('min_leads', 'desc')->first();
|
||||
$price = $tier->price_per_lead_kopecks * $count; // float-арифметика + логика тира прямо здесь
|
||||
Deal::create([...]);
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## §2. RLS-aware Eloquent и middleware `tenant`
|
||||
|
||||
### Правило
|
||||
|
||||
Middleware `SetTenantContext` (`app/app/Http/Middleware/SetTenantContext.php`) оборачивает
|
||||
каждый HTTP-запрос в транзакцию и выполняет `SET LOCAL app.current_tenant_id = X`,
|
||||
после чего RLS-политики PostgreSQL автоматически фильтруют строки по tenant.
|
||||
|
||||
**КРИТИЧНО для джобов**: очередные джобы Laravel выполняются в отдельном процессе вне
|
||||
HTTP-стека. Роль `crm_supplier_worker` (connection `pgsql_supplier`) имеет атрибут
|
||||
BYPASSRLS — RLS-политики для неё **не применяются**. Любой запрос в таком джобе без
|
||||
явного `where('tenant_id', $tenantId)` вернёт строки всех tenant'ов.
|
||||
|
||||
Правило: в каждом джобе либо устанавливай `SET LOCAL` внутри транзакции (паттерн
|
||||
`ProcessWebhookJob`/`ImportLeadsJob`), либо добавляй явный `where('tenant_id', ...)`.
|
||||
|
||||
### Образец из кода
|
||||
|
||||
`app/app/Http/Middleware/SetTenantContext.php:36–43` — HTTP-путь:
|
||||
|
||||
```php
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = ' . $tenantId);
|
||||
$response = $next($request);
|
||||
DB::commit();
|
||||
return $response;
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
throw $e;
|
||||
}
|
||||
```
|
||||
|
||||
`app/app/Jobs/ImportLeadsJob.php:92–96` — джоб устанавливает `SET LOCAL` вручную:
|
||||
|
||||
```php
|
||||
return DB::transaction(function (): ?ImportLog {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = ' . $this->tenantId);
|
||||
return ImportLog::query()->find($this->importLogId);
|
||||
});
|
||||
```
|
||||
|
||||
`app/app/Jobs/ProcessWebhookJob.php:80–86` — аналогичный паттерн в webhook-джобе:
|
||||
|
||||
```php
|
||||
DB::transaction(function () use ($duplicateDetector): void {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = ' . $this->tenantId);
|
||||
$tenant = Tenant::query()
|
||||
->whereKey($this->tenantId)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
```
|
||||
|
||||
### Антипаттерн
|
||||
|
||||
```php
|
||||
// ПЛОХО: джоб под crm_supplier_worker без SET LOCAL и без where tenant_id
|
||||
// → вернёт все строки всех tenant'ов (BYPASSRLS не фильтрует)
|
||||
public function handle(): void
|
||||
{
|
||||
$logs = ImportLog::query()->where('status', 'pending')->get(); // ВСЕ tenant'ы!
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## §3. Деньги — только через bcmath и LedgerService
|
||||
|
||||
### Правило
|
||||
|
||||
Все арифметические операции с деньгами (рубли, копейки) — исключительно через
|
||||
функции `bcmath` с явным `scale`. Операнды передаются строками.
|
||||
Никаких PHP `float`, никакого `+` / `-` / `*` / `/` над денежными значениями.
|
||||
|
||||
Точка входа для списания за лид — `LedgerService::chargeForDelivery()`.
|
||||
Этот метод реализует dual-balance flow (prepaid-лиды → `balance_leads`, рубли → `balance_rub`).
|
||||
Вызывается **внутри открытой транзакции** с `lockForUpdate(Tenant)` — см. §4.
|
||||
|
||||
Аудит денежных инвариантов (I1–I5) — скил `billing-audit` (#62). Здесь — конвенция написания.
|
||||
|
||||
### Образец из кода
|
||||
|
||||
`app/app/Services/Billing/LedgerService.php:64–65` — конвертация копеек в рубли:
|
||||
|
||||
```php
|
||||
$amountRub = bcdiv((string) $priceKopecks, '100', 2);
|
||||
$newBalanceRub = bcsub((string) $lockedTenant->balance_rub, $amountRub, 2);
|
||||
```
|
||||
|
||||
`app/app/Services/Billing/LedgerService.php:124–125` — сравнение балансов:
|
||||
|
||||
```php
|
||||
$balanceKopecks = bcmul((string) $tenant->balance_rub, '100', 0);
|
||||
if (bccomp($balanceKopecks, (string) $priceKopecks, 0) >= 0) {
|
||||
return 'rub';
|
||||
}
|
||||
```
|
||||
|
||||
### Антипаттерн
|
||||
|
||||
```php
|
||||
// ПЛОХО: float-арифметика теряет копейки
|
||||
$price = $tier->price_per_lead_kopecks / 100; // float
|
||||
$newBalance = $tenant->balance_rub - $price; // потеря точности при накоплении
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## §4. Идемпотентные джобы через advisory lock
|
||||
|
||||
### Правило
|
||||
|
||||
Повторный запуск джоба (ретрай, краш, дубль cron) не должен создавать дублирующие
|
||||
записи. Паттерн: `pg_advisory_xact_lock(bigint)` внутри транзакции сериализует все
|
||||
конкурентные обработки одного (tenant_id, source_crm_id).
|
||||
|
||||
Дополнительно для мутаций баланса: `lockForUpdate` на строку Tenant — защита от
|
||||
TOCTOU (между чтением баланса и его обновлением другой воркер не должен изменить значение).
|
||||
|
||||
### Образец из кода
|
||||
|
||||
`app/app/Jobs/ProcessWebhookJob.php:293–296` — advisory lock перед upsert:
|
||||
|
||||
```php
|
||||
// pg_advisory_xact_lock(bigint): верхние 32 бита = tenant_id, нижние 32 = source_crm_id
|
||||
$lockKey = (($tenant->id & 0xFFFFFFFF) << 32) | ($sourceCrmId & 0xFFFFFFFF);
|
||||
DB::statement('SELECT pg_advisory_xact_lock(?)', [$lockKey]);
|
||||
```
|
||||
|
||||
`app/app/Services/Import/HistoricalImportService.php:145–147` — тот же паттерн в сервисе:
|
||||
|
||||
```php
|
||||
// advisory lock (tenant_id, source_crm_id) — сериализует upsert (§6.5)
|
||||
$lockKey = (($tenantId & 0xFFFFFFFF) << 32) | ($row->sourceCrmId & 0xFFFFFFFF);
|
||||
DB::statement('SELECT pg_advisory_xact_lock(?)', [$lockKey]);
|
||||
```
|
||||
|
||||
`app/app/Jobs/RouteSupplierLeadJob.php:210–213` — lockForUpdate на Tenant перед списанием:
|
||||
|
||||
```php
|
||||
$tenant = Tenant::query()
|
||||
->whereKey($project->tenant_id)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
```
|
||||
|
||||
Для overlap-защиты долгоживущих джобов (cron) — `Cache::lock` (Redis):
|
||||
`app/app/Jobs/Supplier/CsvReconcileJob.php:69–74`:
|
||||
|
||||
```php
|
||||
$lock = $lockStore->lock(self::LOCK_NAME, self::LOCK_TTL_SECONDS);
|
||||
if (! $lock->get()) {
|
||||
Log::info('csv_reconcile.skipped_overlap');
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
### Антипаттерн
|
||||
|
||||
```php
|
||||
// ПЛОХО: нет lock — два конкурентных воркера создают два deal для одного vid
|
||||
$existing = Deal::where('source_crm_id', $vid)->where('tenant_id', $tenantId)->first();
|
||||
if (!$existing) {
|
||||
Deal::create([...]); // race condition: оба воркера видят null и оба создают
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## §5. Partition-aware запросы для `deals` и `supplier_lead_costs`
|
||||
|
||||
### Правило
|
||||
|
||||
Таблицы `deals` и `supplier_lead_costs` секционированы по `PARTITION BY RANGE (received_at)`.
|
||||
Запросы должны содержать условие по `received_at` (ключ партиционирования) — это позволяет
|
||||
PostgreSQL выполнять partition pruning и не сканировать все партиции.
|
||||
Запрос без `WHERE received_at ...` делает full-scan всех партиций.
|
||||
|
||||
### Образец из кода
|
||||
|
||||
`db/schema.sql:1658` — партиционирование `deals`:
|
||||
|
||||
```sql
|
||||
) PARTITION BY RANGE (received_at);
|
||||
```
|
||||
|
||||
`db/schema.sql:2361` — партиционирование `supplier_lead_costs`:
|
||||
|
||||
```sql
|
||||
) PARTITION BY RANGE (received_at);
|
||||
```
|
||||
|
||||
`app/app/Services/DuplicateDetector.php:49` — запрос к `deals` с ключом партиции:
|
||||
|
||||
```php
|
||||
->where('received_at', '>=', $windowStart)
|
||||
```
|
||||
|
||||
`app/app/Jobs/Supplier/CsvReconcileJob.php:113` — запрос к `supplier_leads` с ключом:
|
||||
|
||||
```php
|
||||
->where('received_at', '>=', $windowStart)
|
||||
```
|
||||
|
||||
### Антипаттерн
|
||||
|
||||
```php
|
||||
// ПЛОХО: запрос к deals без received_at — full-scan всех партиций
|
||||
$deals = Deal::where('tenant_id', $tenantId)
|
||||
->where('phone', $phone)
|
||||
->get(); // сканирует deals_2026_05, deals_2026_06, ... все партиции
|
||||
```
|
||||
@@ -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: process-analysis
|
||||
description: Анализ и оптимизация существующего бизнес-процесса — process discovery (реконструкция as-is процесса из кода Laravel и audit-логов), поиск узких мест, трассировка требование→процесс, метрики и KPI процесса. Триггеры — «проанализируй процесс», «где узкое место», «process discovery», «как устроен процесс X», «метрики процесса», «оптимизируй процесс». Раздел C10 карты «Бизнес-процессы (общее)».
|
||||
---
|
||||
|
||||
# Process Analysis
|
||||
|
||||
Разбирает **существующий** бизнес-процесс: восстанавливает фактическую модель,
|
||||
находит узкие места, считает метрики. Парный скил к `process-modeling` — тот
|
||||
проектирует to-be, этот вскрывает as-is.
|
||||
|
||||
## Четыре режима
|
||||
|
||||
### 1. Process discovery — реконструкция as-is
|
||||
|
||||
Восстановить фактический процесс из артефактов кода (карта источников —
|
||||
`references/discovery.md`): маршруты + контроллеры (точки входа), джобы/события
|
||||
(асинхронные шаги), enum статусов + переходы (state-машина), audit-таблицы
|
||||
(фактические следы), cron/scheduler (периодические шаги). Итог — модель,
|
||||
которую можно передать `process-modeling` для отрисовки.
|
||||
|
||||
### 2. Bottleneck — поиск узких мест
|
||||
|
||||
Паттерны: ручной шаг между авто-шагами; шаг с ожиданием внешней системы; точка
|
||||
сериализации (advisory-lock, `lockForUpdate`); N+1 внутри шага; ретраи/таймауты;
|
||||
шаг с наибольшей долей исключений.
|
||||
Граница: это **процессные** узкие места. Runtime/код-производительность —
|
||||
`perf-analyzer` / скил `analysis:bottleneck-detect` (PA1).
|
||||
|
||||
### 3. Трассировка требование→процесс
|
||||
|
||||
Связать пункт ТЗ / `Открытые_вопросы` → шаги процесса → код (file:line) →
|
||||
тесты. Выявить шаги без требования (скрытая логика) и требования без
|
||||
реализации.
|
||||
|
||||
### 4. Метрики процесса
|
||||
|
||||
Определить KPI: throughput, cycle time, конверсия между статусами, доля
|
||||
исключений, объём ручного труда. Числа берутся из БД через `Boost`, не
|
||||
выдумываются.
|
||||
Граница: продуктовые метрики — плагин `product-management` (`/metrics-review`).
|
||||
|
||||
## Рабочий процесс
|
||||
|
||||
1. Определить режим (1-4) по запросу.
|
||||
2. Собрать факты из кода / БД / логов — никаких допущений без пинов (file:line).
|
||||
3. Выдать находки: модель / список узких мест / матрицу трассировки / таблицу
|
||||
метрик.
|
||||
4. Рекомендации направить в `process-modeling` (to-be) или в задачи. Этот скил
|
||||
код не правит.
|
||||
|
||||
## Границы
|
||||
|
||||
- **Проектирование to-be модели** — скил `process-modeling`.
|
||||
- **Runtime / код-производительность** — `perf-analyzer`,
|
||||
`analysis:bottleneck-detect` (PA1).
|
||||
- **Продуктовые метрики** — плагин `product-management`.
|
||||
- **Документ / change-request процесса** — плагин `operations`.
|
||||
- **Интервью заказчика про будущую фичу / ориентация по проекту** — скил
|
||||
`discovery-interview`. Тот вскрывает проблему до решения через интервью человека
|
||||
(режим FEATURE) и синтезирует мета-слой проекта (режим SYSTEM); этот скил — про
|
||||
вскрытие as-is процесса из app-кода. «process discovery», «как устроен процесс X»,
|
||||
«где узкое место» — сюда; «проведи discovery interview», «сориентируй по проекту» —
|
||||
в `discovery-interview`.
|
||||
- **Генерик-методология оптимизации процесса** — скил `process-optimization`
|
||||
плагина `operations`. Этот скил — про code-grounded discovery конкретного
|
||||
процесса Лидерры (вскрытие as-is), не про общую методологию и не про
|
||||
проектирование to-be.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Process discovery — карта источников as-is процесса в Лидерре
|
||||
|
||||
Где в коде Лидерры лежат факты о фактическом бизнес-процессе.
|
||||
|
||||
## Источники
|
||||
|
||||
| Артефакт процесса | Где искать |
|
||||
|---|---|
|
||||
| Точки входа процесса | `app/routes/*.php` + `app/app/Http/Controllers/**` |
|
||||
| Синхронные шаги | методы контроллеров + `app/app/Services/**` |
|
||||
| Асинхронные шаги | `app/app/Jobs/**`, `app/app/Events/**` + listeners |
|
||||
| State-машина | enum/константы статусов + `db/schema.sql` (воронка — 14 статусов) |
|
||||
| Фактические следы выполнения | `audit_*` таблицы, `audit_chain_hash` (событийный лог) |
|
||||
| Периодические шаги | `app/app/Console/**` + scheduler (`partitions:create-months` и пр.) |
|
||||
| Бизнес-правила в шагах | `calc_lead_score` (SQL), `PricingTierResolver`, `LedgerService` |
|
||||
|
||||
## Метод
|
||||
|
||||
1. От **точки входа** (route → controller) пройти по вызовам до терминального
|
||||
состояния.
|
||||
2. Каждый `dispatch()` / событие — асинхронная ветка; проследить listener/job.
|
||||
3. Переход статуса = ребро state-машины; собрать все переходы в автомат.
|
||||
4. Свериться с **audit-логом**: фактический порядок событий в `audit_*` может
|
||||
расходиться с «проектным» — расхождение само по себе находка.
|
||||
5. Зафиксировать каждый шаг пином `file:line`; без пина — это допущение, не факт.
|
||||
|
||||
## Антипаттерны при discovery
|
||||
|
||||
- Принять «happy path» за весь процесс — исключения (catch, failed jobs,
|
||||
таймауты) тоже шаги.
|
||||
- Пропустить cron-шаги — они не видны из route-графа.
|
||||
- Доверять имени метода вместо его тела.
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
name: process-modeling
|
||||
description: Моделирование бизнес-процесса — BPMN 2.0 (пулы, дорожки, задачи, гейтвеи, события), карты процессов, customer-journey / value-stream, RACI-матрицы, state-машины. Триггеры — «смоделируй процесс», «нарисуй BPMN», «карта процесса», «swimlane / дорожки», «customer journey», «RACI», проектирование state-машины (воронка сделок, цепочка джобов). Раздел C10 карты «Бизнес-процессы (общее)».
|
||||
---
|
||||
|
||||
# Process Modeling
|
||||
|
||||
Превращает словесное описание бизнес-процесса в формальную модель. Скил даёт
|
||||
**нотацию и методологию** — рендер диаграмм делегируется скилу `mermaid`
|
||||
(process-modeling не рендерит сам — конфликт-граница OPS1/BPMN1: mermaid
|
||||
остаётся рендер-SoT).
|
||||
|
||||
## Когда какой артефакт
|
||||
|
||||
| Нужно | Артефакт |
|
||||
|---|---|
|
||||
| Кто-что-в-каком-порядке делает, с ветвлениями | BPMN 2.0 / swimlane |
|
||||
| Сквозной поток end-to-end крупными блоками | Карта процесса (flowchart) |
|
||||
| Опыт клиента/лида по этапам + точки боли | Customer-journey map |
|
||||
| Поток создания ценности + потери и ожидания | Value-stream map |
|
||||
| Распределение ответственности по шагам | RACI-матрица |
|
||||
| Конечный автомат (статусы + переходы) | State-диаграмма |
|
||||
|
||||
## Рабочий процесс
|
||||
|
||||
1. **Собрать процесс** — уточнить: триггер (что запускает), участники (роли),
|
||||
шаги по порядку, ветвления и условия, итог, исключения. Неясное — один
|
||||
вопрос за раз.
|
||||
2. **Выбрать артефакт** по таблице выше.
|
||||
3. **Построить модель** в нотации (BPMN — см. `references/bpmn.md`).
|
||||
4. **Отрендерить** — передать исходник скилу `mermaid`.
|
||||
5. **Свериться** — модель не должна противоречить ТЗ / `db/schema.sql` /
|
||||
`Открытые_вопросы`. Процесс вне ТЗ И не в реестре открытых вопросов —
|
||||
hard-стоп (Pravila §7): не моделировать молча, поднять вопрос.
|
||||
|
||||
## BPMN 2.0 — ядро
|
||||
|
||||
Полная нотация и маппинг на mermaid — `references/bpmn.md`. Кратко:
|
||||
|
||||
- **Pool** — организация/система; **Lane** — роль внутри pool.
|
||||
- **Task** — атомарное действие; **Sub-process** — свёрнутый под-поток.
|
||||
- **Gateway** — ветвление: exclusive (XOR — один путь), parallel (AND — все
|
||||
пути), inclusive (OR — один и более).
|
||||
- **Event** — start / intermediate / end; типы: timer, message, error.
|
||||
- **Sequence flow** — порядок внутри pool; **Message flow** — между pool'ами.
|
||||
|
||||
## Границы
|
||||
|
||||
- **Рендер диаграмм** — скил `mermaid` (C10 OPS1/BPMN1). Этот скил исходник не
|
||||
рисует — отдаёт его mermaid.
|
||||
- **DDD-границы доменных процессов** — скил `architecture-patterns` (bounded
|
||||
context = граница бизнес-процесса).
|
||||
- **Документ процесса, change-request, оптимизация** — плагин `operations`
|
||||
(скилы `process-doc`, `change-request`, `process-optimization`).
|
||||
- **Анализ as-is процесса** (discovery, узкие места) — скил `process-analysis`.
|
||||
- Этот скил — про проектирование **to-be модели**, не про вскрытие as-is.
|
||||
@@ -0,0 +1,56 @@
|
||||
# BPMN 2.0 — справочник нотации и рендер в mermaid
|
||||
|
||||
mermaid не имеет нативного BPMN-рендера. BPMN-модель выражается через mermaid
|
||||
`flowchart` (swimlane через `subgraph` = дорожки) или `stateDiagram-v2`.
|
||||
|
||||
## Элементы BPMN → mermaid
|
||||
|
||||
| BPMN | Смысл | mermaid-выражение |
|
||||
|---|---|---|
|
||||
| Pool / Lane | организация / роль | `subgraph Роль ... end` |
|
||||
| Task | действие | прямоугольник `id[Текст]` |
|
||||
| Sub-process | свёрнутый поток | `id[[Текст]]` |
|
||||
| Start event | старт | `id((Старт))` |
|
||||
| End event | конец | `id((Конец))` |
|
||||
| Exclusive gateway (XOR) | один путь | ромб `id{Условие?}` + подписи на рёбрах |
|
||||
| Parallel gateway (AND) | все пути | ромб `id{И}` с несколькими исходящими |
|
||||
| Sequence flow | порядок | `-->` |
|
||||
| Message flow | между pool | `-.->` |
|
||||
|
||||
## Шаблон swimlane
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Менеджер
|
||||
A((Старт)) --> B[Принять лид]
|
||||
B --> C{Лид валиден?}
|
||||
end
|
||||
subgraph Система
|
||||
C -->|да| D[Создать сделку]
|
||||
C -->|нет| E((Отклонён))
|
||||
D --> F((Сделка создана))
|
||||
end
|
||||
```
|
||||
|
||||
## State-машина
|
||||
|
||||
Для конечных автоматов (воронка сделок — 14 статусов из `db/schema.sql`)
|
||||
использовать `stateDiagram-v2`:
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> new
|
||||
new --> in_progress
|
||||
in_progress --> won
|
||||
in_progress --> lost
|
||||
won --> [*]
|
||||
lost --> [*]
|
||||
```
|
||||
|
||||
Статус-слаги — из `db/schema.sql` (источник истины воронки), не выдумывать.
|
||||
|
||||
## Правила
|
||||
|
||||
- Один gateway — один вопрос; каждое исходящее ребро подписано условием.
|
||||
- Каждый путь оканчивается end-событием (нет «висящих» задач).
|
||||
- Исключения (timer/error) моделировать явно, не прятать в «happy path».
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
name: ru-tax-accounting
|
||||
description: Контекст РСБУ и налогов РФ (НК РФ, НДС/УСН) применительно к SaaS-выручке Лидерры за лиды. Используй при «учёт выручки по РСБУ», «НДС или УСН», «налоговая база по выручке», «налогооблагаемое событие», «выгрузка для бухгалтера», «проводки РСБУ». НЕ для денежной корректности кода (billing-audit), US-GAAP-отчётности (finance plugin), договоров (D1 право), ПДн (D2), сверки с банком (finance reconciliation).
|
||||
---
|
||||
|
||||
# RU Tax & Accounting — РСБУ/НК РФ контекст для выручки Лидерры
|
||||
|
||||
Проектный скил раздела C7 карты «Финансы — бухгалтерия и налоги». Переводит
|
||||
billing-выручку (выход C6) в **российский учётно-налоговый контекст** (РСБУ + НК РФ).
|
||||
Закрывает gap, который US-GAAP-плагин `finance` (#61) не покрывает.
|
||||
|
||||
## Когда использовать
|
||||
|
||||
- Вопрос «как это учесть/обложить по РФ-правилам?» по выручке/пополнениям/возвратам.
|
||||
- Подготовка выгрузок/пояснений для бухгалтера из billing-данных.
|
||||
- Определение налогооблагаемого события и налоговой базы.
|
||||
|
||||
## Содержание (см. references/ru-tax-context.md)
|
||||
|
||||
1. **Налоговые режимы РФ** — НДС (ОСНО) vs УСН (доходы / доходы-расходы); что применимо к SaaS за лиды.
|
||||
2. **Налогооблагаемое событие** — пополнение баланса (аванс) vs списание за лид (реализация) vs возврат.
|
||||
3. **Маппинг billing→база** — `lead_charges`/`LedgerService` → выручка → налоговая база; роль `charge_source`.
|
||||
4. **РСБУ vs управленческий** — отличие от US-GAAP-отчётов плагина finance; первичка/документы.
|
||||
5. **Выгрузки для бухгалтера** — какие данные и в каком разрезе извлечь (Boost/Pest как инструменты выгрузки).
|
||||
|
||||
## Границы
|
||||
|
||||
- ≠ `billing-audit` #62 — тот про *корректность начисления в коде*; ru-tax про *учёт/налог результата*.
|
||||
- ≠ `finance` plugin #61 — тот US-GAAP-механика (проводки/отчёты/сверка); ru-tax — РФ-специфика РСБУ/НК.
|
||||
- ≠ D1 «Юриспруденция/договорная» — там договоры/право; ru-tax — налоги.
|
||||
- ≠ D2 «Защита ПДн (152-ФЗ)» — там персональные данные; ru-tax — налоги.
|
||||
|
||||
## Ограничение
|
||||
|
||||
Бухгалтерия компании ведётся вне dev-репо (1С/аутсорс). Скил даёт **контекст и выгрузки**,
|
||||
не заменяет бухгалтера и не является налоговой консультацией. Реальный платёжный
|
||||
провайдер — DEFERRED (Б-1).
|
||||
|
||||
## Связано
|
||||
|
||||
- Вход: выручка из C6 (`lead_charges`, `LedgerService`).
|
||||
- Reuse: data-scientist #49 (финмодели), Boost #10 / Pest #18 (выгрузка), finance plugin #61 (US-механика).
|
||||
- ADR-012 (граница finance-tooling C6/C7).
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"skill": "ru-tax-accounting",
|
||||
"positive": [
|
||||
"как учесть выручку за лиды по РСБУ",
|
||||
"НДС или УСН для SaaS-выручки",
|
||||
"переведи billing-выручку в налоговую базу",
|
||||
"какое налогооблагаемое событие при пополнении баланса",
|
||||
"выгрузка lead_charges для бухгалтера",
|
||||
"проводки по РСБУ за списания",
|
||||
"налоговый режим для подписочной выручки портала",
|
||||
"что с НДС при возврате на баланс tenant",
|
||||
"налоговая база УСН доходы по выручке за лиды"
|
||||
],
|
||||
"near_miss": [
|
||||
{"prompt": "проверь идемпотентность списания", "expect": "billing-audit #62"},
|
||||
{"prompt": "US-GAAP financial statement", "expect": "finance plugin #61 financial-statements"},
|
||||
{"prompt": "договор с поставщиком лидов", "expect": "D1 юриспруденция"},
|
||||
{"prompt": "обработка ПДн при выгрузке", "expect": "D2 ПДн 152-ФЗ"},
|
||||
{"prompt": "сверка ledger с банком", "expect": "finance plugin #61 reconciliation"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
# РСБУ / НК РФ — контекст для выручки Лидерры за лиды
|
||||
|
||||
> Не налоговая консультация. Контекст для подготовки данных бухгалтеру.
|
||||
|
||||
## 1. Налоговые режимы РФ
|
||||
|
||||
- **ОСНО + НДС (НК РФ гл. 21)** — НДС 20% на реализацию услуг РФ. Электронные/рекламные
|
||||
услуги — проверить место реализации и применимые льготы.
|
||||
- **УСН (НК РФ гл. 26.2)** — «доходы» (6%) или «доходы минус расходы» (15%). Без НДС
|
||||
(кроме исключений). Типичный режим для раннего SaaS.
|
||||
- Применимый режим зависит от регистрации ООО (Б-1) — до закрытия Б-1 фиксируем как параметр.
|
||||
|
||||
## 2. Налогооблагаемое событие
|
||||
|
||||
- **Пополнение баланса** = аванс (предоплата). По НДС — момент определения базы может
|
||||
возникать на аванс; по УСН-доходы — доход по поступлению (кассовый метод).
|
||||
- **Списание за лид** = реализация услуги (закрытие аванса).
|
||||
- **Возврат на баланс / с баланса** = корректировка базы.
|
||||
- Различать по `lead_charges.charge_source` и операциям `LedgerService`.
|
||||
|
||||
## 3. Маппинг billing → налоговая база
|
||||
|
||||
| Billing-сущность | Учётный смысл |
|
||||
|---|---|
|
||||
| Пополнение (`BillingTopupService`) | Аванс / поступление |
|
||||
| Списание (`LedgerService`, `lead_charges`) | Реализация (выручка) |
|
||||
| `delivered_in_month` (`tenants`) | Объём для tier — не налог напрямую |
|
||||
| Возврат | Корректировка |
|
||||
|
||||
## 4. РСБУ vs управленческий / US-GAAP
|
||||
|
||||
- РСБУ — российский план счетов, первичные документы (акт/УПД), кассовый/начисление.
|
||||
- US-GAAP-скилы плагина `finance` (#61) — иная форма (income statement / balance sheet);
|
||||
применимы для *внутренней управленки*, не для РФ-отчётности.
|
||||
|
||||
## 5. Выгрузки для бухгалтера
|
||||
|
||||
- Реестр списаний за период: `lead_charges` (period, tenant, сумма, charge_source).
|
||||
- Реестр пополнений: операции `LedgerService` / `BillingTopupService`.
|
||||
- Инструменты выгрузки: Boost #10 (Eloquent/SQL), Pest #18 (фикстуры/проверки), `BillingSummaryProvider` (готовый отчёт-провайдер).
|
||||
@@ -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,27 @@
|
||||
---
|
||||
name: subagent-driven-development
|
||||
description: Project-local wrapper для superpowers:subagent-driven-development — добавляет обязательный git-safety verify-протокол per Pravila §15.1. Использовать вместо marketplace-варианта при работе с git-коммит-задачами в субагентах.
|
||||
---
|
||||
|
||||
# Subagent-Driven Development (project wrapper)
|
||||
|
||||
Этот скил — проектная обёртка над marketplace-скилом `superpowers:subagent-driven-development`. Дополняет его обязательным git-safety verify-протоколом per Pravila §15.1.
|
||||
|
||||
## Когда использовать
|
||||
|
||||
Когда нужно делегировать задачу субагенту через Task tool — особенно git-коммит-задачи (Sprint 6 прецедент: Haiku-субагенты угнали ветку параллельной сессии).
|
||||
|
||||
## Что делать
|
||||
|
||||
1. **Откройте marketplace-скил** `superpowers:subagent-driven-development` для общего workflow (fresh subagent per task + two-stage review).
|
||||
2. **Перед каждой Task-инвокацией** прочитайте и выполните pre-spawn-чеклист — [references/git-safety-checklist.md](references/git-safety-checklist.md) §A.
|
||||
3. **После каждой Task-инвокации** прочитайте и выполните post-subagent-чеклист — там же §B.
|
||||
4. **Hard-rule §15.1** — git-коммит-задача = модель Sonnet/Opus, никогда Haiku. Read-only git-операции (`log`, `status`, `diff`, `rev-parse`, `branch --show-current`, `worktree list`) разрешены любой модели.
|
||||
|
||||
Хук `tools/subagent-prompt-prefix.mjs` (зарегистрирован в `.claude/settings.json`) автоматически инжектит git-safety заголовок в каждый Task-prompt — это **первая** линия защиты. Чеклист из этого скила — **вторая** линия (защита со стороны контроллера).
|
||||
|
||||
## Cross-refs
|
||||
|
||||
- Pravila §15.1 — hard-rule субагенты + git.
|
||||
- Spec: `docs/superpowers/specs/2026-05-18-parallel-sessions-coordination-design.md` §5.
|
||||
- Memory: `memory/feedback_subagent_git_reliability.md`.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Git-safety Checklist для контроллера субагентов
|
||||
|
||||
Per Pravila §15.1 — выполнять каждый раз при делегировании задачи через Task tool.
|
||||
|
||||
## §A. Pre-spawn чеклист (до Task-инвокации)
|
||||
|
||||
1. **Резолвите 4 значения** (запишите у себя для post-check):
|
||||
|
||||
```bash
|
||||
git branch --show-current # → ожидаемая ветка
|
||||
git rev-parse HEAD # → pre-spawn parent SHA
|
||||
git rev-parse --show-toplevel # → worktree root
|
||||
pwd # → cwd
|
||||
```
|
||||
|
||||
2. **Выберите модель** субагенту:
|
||||
- Задача требует `git commit`/`push`/`stage`/`checkout`/`switch`/`merge`/`rebase`? → **Sonnet или Opus**, никогда Haiku (§15.1).
|
||||
- Только read-только `git log`/`status`/`diff`/`rev-parse` ИЛИ только Edit/Read/Grep? → любая модель.
|
||||
3. **Если задача правит нормативку из списка §15.2** (Pravila / CLAUDE.md / Tooling / PSR_v1 / MEMORY.md / Открытые_вопросы / docs/adr/* / db/schema.sql):
|
||||
|
||||
```bash
|
||||
git fetch origin && git log HEAD..origin/main --oneline
|
||||
```
|
||||
|
||||
Не пусто → **ребейз/merge до инвокации**, не после. Pre-flight также проверить `docs/sessions/CURRENT.md` на конфликт scope-files / version-claims.
|
||||
|
||||
## §B. Post-subagent чеклист (сразу после возврата субагента)
|
||||
|
||||
1. **`git rev-parse HEAD`** — сравнить с pre-spawn parent SHA.
|
||||
- Равно → субагент не коммитил (OK для Edit-задач без commit).
|
||||
- Отличается ровно одним коммитом, чей parent = pre-spawn HEAD → OK для commit-задач.
|
||||
- **Иначе → STOP, разбор инцидента.**
|
||||
2. **`git branch --show-current`** — сравнить с pre-spawn branch.
|
||||
- Не равно → **STOP, разбор инцидента** (Sprint 6 паттерн).
|
||||
3. **`git log -1 --format='%s%n%P'`** — проверить subject + parent последнего коммита.
|
||||
- Subject соответствует задаче?
|
||||
- Parent = pre-spawn HEAD?
|
||||
4. Если несколько коммитов — ручная проверка subject'ов каждого.
|
||||
|
||||
## §C. Red-flag-список — любой = hard-stop разбор
|
||||
|
||||
- `branch ≠ ожидаемая`;
|
||||
- `parent коммита ≠ pre-spawn HEAD` (висячий коммит / попадание на чужую ветку);
|
||||
- HEAD двинулся, но субагент в отчёте об этом не упомянул;
|
||||
- в diff'е есть файлы вне scope задачи.
|
||||
|
||||
## §D. Обязательный формат отчёта субагента
|
||||
|
||||
Субагент в конце ответа выписывает блок:
|
||||
|
||||
```
|
||||
=== GIT REPORT ===
|
||||
cwd: <pwd>
|
||||
branch: <git branch --show-current>
|
||||
HEAD: <git rev-parse HEAD>
|
||||
HEAD^: <git rev-parse HEAD^>
|
||||
status: <git status --short>
|
||||
=== END GIT REPORT ===
|
||||
```
|
||||
|
||||
Отсутствие блока = контроллер считает результат недостоверным и запускает §B-чеклист сам через Bash.
|
||||
|
||||
## §E. Соотношение с code-review
|
||||
|
||||
Двухстадийное review (Pravila §4.5 / PSR_v1 R10) сохраняется. Git-safety-чеклист **не заменяет** code-review — он стоит **до** него (нет смысла ревьюить diff, если он не в той ветке).
|
||||
@@ -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,5 @@
|
||||
# Normalize line endings for Node ESM tooling files.
|
||||
# Keep LF in the working tree regardless of core.autocrlf — CRLF .mjs files
|
||||
# break vitest module loading (SyntaxError: Invalid or unexpected token,
|
||||
# no file:line). See memory quirk #100 (2026-05-19).
|
||||
*.mjs text eol=lf
|
||||
@@ -0,0 +1,31 @@
|
||||
name: brain-l1-watcher (weekly)
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 6 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
drift:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: run l1-watcher
|
||||
id: l1
|
||||
run: node tools/l1-watcher.mjs
|
||||
continue-on-error: true
|
||||
- name: open issue on drift
|
||||
if: steps.l1.outcome == 'failure'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.create({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
title: `[l1-watcher] drift detected (weekly cron ${new Date().toISOString().slice(0,10)})`,
|
||||
body: `Run failed. Check workflow logs and run /claude-md-management:claude-md-improver.`,
|
||||
labels: ['brain', 'drift']
|
||||
});
|
||||
+4
-1
@@ -98,7 +98,10 @@ paths = [
|
||||
# Vitest-тесты с assertion на mock-данные (mock-телефоны из mockDeals)
|
||||
'''app/tests/Frontend/.*\.(spec|test)\.ts''',
|
||||
# Settings-вкладки с фиктивными mock-данными (профиль/сессии — UI-разводка)
|
||||
'''app/resources/js/views/settings/.*\.vue'''
|
||||
'''app/resources/js/views/settings/.*\.vue''',
|
||||
# Test fixtures for the observer PII filter — contains synthetic JWT / AWS /
|
||||
# Yandex tokens that the filter is supposed to redact. Not real secrets.
|
||||
'''tools/observer-pii-filter\.test\.mjs'''
|
||||
]
|
||||
regexTarget = "match"
|
||||
regexes = [
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# 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
|
||||
@@ -4,3 +4,4 @@ bin/
|
||||
CLAUDE.md
|
||||
.claude/skills/mermaid/
|
||||
.claude/skills/ccpm/
|
||||
.claude/skills/data-scientist/
|
||||
|
||||
@@ -39,10 +39,20 @@
|
||||
"args": ["-y", "@modelcontextprotocol/server-redis", "redis://localhost:6379"],
|
||||
"comment": "Off-phase tool — Redis MCP для Memurai (Windows service, Redis 7-совместимый, localhost:6379). Pending формализация в Tooling §3.3 #35 — sync нормативки отдельным планом. Package: @modelcontextprotocol/server-redis@2025.4.25 — DEPRECATED по статусу npm («Package no longer supported»), но Anthropic source, простой протокол, рабочий. Post-MVP migration на community alternative (e.g., @easy-mcps/redis-mcp-server@1.0.8 или @wenit/redis-mcp-server@1.0.3) когда подтвердим trust. READ-ONLY use — отладка очередей, кэша, Pest --parallel race (memory quirk 72). НЕ для prod (нет prod). Если в будущем prod Redis с auth — отдельный entry redis-prod с url через env var."
|
||||
},
|
||||
"ruflo": {
|
||||
"_ruflo_isolated_note": "ruflo MCP-сервер отключён 18.05.2026 (заказчик: «изолируй, не удаляй»). Чтобы вернуть — восстановить блок 'ruflo': { command: 'npx', args: ['-y','ruflo@latest','mcp','start'], comment: ... }. См. memory feedback_ruflo_isolated.md, Tooling §4.10, CLAUDE.md §3.5.",
|
||||
"universal-icons": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "ruflo@latest", "mcp", "start"],
|
||||
"comment": "Off-phase orchestration MCP — exposes ~210 ruflo tools (Core/Intelligence/Agents/Memory/DevTools). Package: ruflo v3.7.0-alpha.38+ MIT (npm `ruflo`, repo ruvnet/claude-flow legacy after rename Jan-2026; plugin namespace @claude-flow/*). Plugin discovery via IPFS (CID QmeXmAdbWVvT84GfDXPD2Vg1HWhiTW2VdZfRLhkS96KkX2) — Pinata+Cloudflare gateways flaky 2026-05-15, only ipfs.io reliable. stdio mode (no port-conflict). Big-bang integration per spec/plan 2026-05-15-ruflo-integration-design.md (commit a68a0a0+). Pending формализация в Tooling §4.10 — Phase 3 Task 3.4."
|
||||
"args": ["-y", "mcp-universal-icons"],
|
||||
"comment": "Off-phase A4 design-tooling #45 — Universal Icons MCP (npm mcp-universal-icons, awssat, MIT). Поиск/вставка SVG-иконок из 10 коллекций, включая Lucide (проектный icon-set, CTO-19). Tools: search_icons / get_icon / health_check. SVG framework-neutral по умолчанию — НЕ запрашивать jsx/Tailwind-формат (PSR_v1 R6.0). Формализация — Tooling §4.20. ADR-006 граница UI2: иконки UI; бренд-логотипы — за 21st logo_search. План docs/superpowers/plans/2026-05-17-a4-design-tooling-integration.md."
|
||||
},
|
||||
"openapi": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@ivotoby/openapi-mcp-server"],
|
||||
"env": {
|
||||
"API_BASE_URL": "http://localhost",
|
||||
"OPENAPI_SPEC_PATH": "./docs/api/openapi.yaml"
|
||||
},
|
||||
"comment": "A3 integration-tooling #47 — OpenAPI MCP (ivo-toby/mcp-openapi-server, @ivotoby/openapi-mcp-server v1.14.0, MIT). Exposes Лидерра REST API endpoints (docs/api/openapi.yaml) as MCP tools. Config via env-vars API_BASE_URL + OPENAPI_SPEC_PATH (stdio transport default). READ scope: API discovery/introspection for Claude Code. Формализован в Tooling §4.22, PSR_v1 R10.1 блок 3, Pravila §13.2."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
.env.production
|
||||
.phpactor.json
|
||||
.phpunit.result.cache
|
||||
/.deptrac.cache
|
||||
/.codex
|
||||
/.cursor/
|
||||
/.idea
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Casts;
|
||||
|
||||
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Eloquent cast for PostgreSQL native INT[] columns.
|
||||
*
|
||||
* Laravel stock 'array' cast uses json_encode/json_decode and sends `[1,2,3]`
|
||||
* (JSON), which Postgres rejects on INT[] columns (expects `{1,2,3}` array
|
||||
* literal). This cast:
|
||||
*
|
||||
* - get(): parses Postgres array literal `{1,2,3}` (or empty `{}`) into PHP
|
||||
* int array.
|
||||
* - set(): serializes PHP array `[1,2,3]` into Postgres literal `{1,2,3}`.
|
||||
*
|
||||
* Used for projects.regions INT[] (Plan 6).
|
||||
*
|
||||
* @implements CastsAttributes<list<int>, list<int>|null>
|
||||
*/
|
||||
class PostgresIntArray implements CastsAttributes
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
* @return list<int>
|
||||
*/
|
||||
public function get(Model $model, string $key, mixed $value, array $attributes): array
|
||||
{
|
||||
if ($value === null || $value === '' || $value === '{}') {
|
||||
return [];
|
||||
}
|
||||
|
||||
// PG returns literal like "{1,2,3}".
|
||||
if (is_string($value)) {
|
||||
$trimmed = trim($value, '{}');
|
||||
|
||||
if ($trimmed === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map('intval', explode(',', $trimmed));
|
||||
}
|
||||
|
||||
// Defensive: if driver already gave array.
|
||||
if (is_array($value)) {
|
||||
return array_values(array_map('intval', $value));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
public function set(Model $model, string $key, mixed $value, array $attributes): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Defensive: interface phpdoc says list<int>|null, but $value is mixed at PHP level;
|
||||
// protect against runtime misuse (e.g., string passed mistakenly).
|
||||
// @phpstan-ignore function.alreadyNarrowedType
|
||||
if (! is_array($value)) {
|
||||
throw new \InvalidArgumentException(
|
||||
"PostgresIntArray cast expects array for key '{$key}', got ".gettype($value)
|
||||
);
|
||||
}
|
||||
|
||||
if ($value === []) {
|
||||
return '{}';
|
||||
}
|
||||
|
||||
$ints = array_map('intval', $value);
|
||||
|
||||
return '{'.implode(',', $ints).'}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\Supplier\CsvReconcileJob;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierManualSyncQueue;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Services\Supplier\Channel\SupplierProjectChannel;
|
||||
use App\Services\Supplier\SupplierExportMode;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
use App\Support\RussianRegions;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* SaaS-admin → Интеграция с поставщиком: здоровье резервного CSV-канала (Путь 2).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-18-supplier-csv-reconcile-channel-design.md §4.4
|
||||
*/
|
||||
final class AdminSupplierIntegrationController extends Controller
|
||||
{
|
||||
private const HISTORY_LIMIT = 20;
|
||||
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$rows = DB::connection('pgsql_supplier')
|
||||
->table('supplier_csv_reconcile_log')
|
||||
->orderByDesc('id')
|
||||
->limit(self::HISTORY_LIMIT)
|
||||
->get();
|
||||
|
||||
$last = $rows->first();
|
||||
|
||||
$webhookState = ($last !== null && $last->status === 'drift_alert') ? 'down' : 'live';
|
||||
|
||||
return response()->json([
|
||||
'health' => [
|
||||
'last_run_at' => $last !== null ? ($last->finished_at ?? $last->started_at) : null,
|
||||
'last_status' => $last?->status,
|
||||
'drift_ratio' => $last !== null ? (float) $last->drift_ratio : null,
|
||||
'webhook_state' => $webhookState,
|
||||
],
|
||||
'history' => $rows->map(fn ($r): array => [
|
||||
'started_at' => $r->started_at,
|
||||
'finished_at' => $r->finished_at,
|
||||
'window_start' => $r->window_start,
|
||||
'window_end' => $r->window_end,
|
||||
'status' => $r->status,
|
||||
'total_csv_rows' => (int) $r->total_csv_rows,
|
||||
'matched_count' => (int) $r->matched_count,
|
||||
'recovered_count' => (int) $r->recovered_count,
|
||||
'drift_ratio' => (float) $r->drift_ratio,
|
||||
])->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function reconcile(): JsonResponse
|
||||
{
|
||||
CsvReconcileJob::dispatch();
|
||||
|
||||
return response()->json(['dispatched' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Очередь яруса 3 резерва канала миграции проектов — pending-список для
|
||||
* оператора админ-экрана. Spec §4.6.
|
||||
*/
|
||||
public function manualQueueIndex(): JsonResponse
|
||||
{
|
||||
$rows = SupplierManualSyncQueue::where('status', 'pending')
|
||||
->orderByDesc('id')
|
||||
->limit(100)
|
||||
->get(['id', 'project_id', 'platform', 'operation', 'external_id', 'payload_snapshot', 'failure_reason', 'created_at']);
|
||||
|
||||
return response()->json(['queue' => $rows]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Оператор вручную создал проект на портале → reconcile: сверяем через
|
||||
* listProjects(), ставим FK supplier_b{1,2,3}_project_id, помечаем resolved.
|
||||
* 409 если проект на портале не найден (оператор не создал / другие параметры).
|
||||
* Spec §4.6.
|
||||
*/
|
||||
public function manualQueueResolve(int $id, Request $request, SupplierProjectChannel $channel): JsonResponse
|
||||
{
|
||||
$row = SupplierManualSyncQueue::findOrFail($id);
|
||||
if ($row->status !== 'pending') {
|
||||
return response()->json(['message' => 'already resolved or cancelled'], 409);
|
||||
}
|
||||
|
||||
$payload = $row->payload_snapshot;
|
||||
$signalType = (string) ($payload['signal_type'] ?? '');
|
||||
$uniqueKey = (string) ($payload['unique_key'] ?? '');
|
||||
|
||||
$found = null;
|
||||
foreach ($channel->listProjects() as $r) {
|
||||
if (
|
||||
($r['platform'] ?? null) === $row->platform
|
||||
&& ($r['signal_type'] ?? null) === $signalType
|
||||
&& ($r['unique_key'] ?? null) === $uniqueKey
|
||||
) {
|
||||
$found = (int) ($r['id'] ?? 0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($found === null) {
|
||||
return response()->json([
|
||||
'message' => 'Проект не найден на портале поставщика. Проверьте, что вы действительно его создали с теми же параметрами.',
|
||||
], 409);
|
||||
}
|
||||
|
||||
// FK projects.supplier_b{1,2,3}_project_id ведёт на local supplier_projects.id,
|
||||
// не на portal external_id. Find-or-create local row с verified external_id.
|
||||
$sp = SupplierProject::firstOrCreate(
|
||||
[
|
||||
'platform' => $row->platform,
|
||||
'signal_type' => $signalType,
|
||||
'unique_key' => $uniqueKey,
|
||||
],
|
||||
[
|
||||
'supplier_external_id' => (string) $found,
|
||||
'current_limit' => 0,
|
||||
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
|
||||
'current_regions' => null,
|
||||
'sync_status' => 'ok',
|
||||
],
|
||||
);
|
||||
|
||||
Project::where('id', $row->project_id)->update([
|
||||
'supplier_'.strtolower($row->platform).'_project_id' => $sp->id,
|
||||
]);
|
||||
|
||||
$row->update([
|
||||
'status' => 'resolved',
|
||||
'resolved_by_user_id' => $request->user()->id,
|
||||
'resolved_at' => now(),
|
||||
'external_id' => (string) $found,
|
||||
]);
|
||||
|
||||
return response()->json(['resolved' => true, 'external_id' => $found]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Глобальный режим экспорта проектов поставщику (Plan 4 Task 1).
|
||||
* Spec: docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.1.
|
||||
*/
|
||||
public function getExportMode(): JsonResponse
|
||||
{
|
||||
return response()->json(['mode' => SupplierExportMode::current()]);
|
||||
}
|
||||
|
||||
public function setExportMode(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'mode' => ['required', 'in:online,batch'],
|
||||
]);
|
||||
|
||||
DB::table('system_settings')->updateOrInsert(
|
||||
['key' => 'supplier_export_mode'],
|
||||
['value' => $data['mode'], 'type' => 'string', 'updated_at' => now()],
|
||||
);
|
||||
|
||||
return response()->json(['mode' => $data['mode']]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan 4 Task 2: список supplier_projects + кто заказывал (через pivot →
|
||||
* projects → tenants) + дата последней поставки лида.
|
||||
*/
|
||||
public function projectsIndex(): JsonResponse
|
||||
{
|
||||
$rows = DB::table('supplier_projects as sp')
|
||||
->select([
|
||||
'sp.id',
|
||||
'sp.platform',
|
||||
'sp.signal_type',
|
||||
'sp.unique_key',
|
||||
'sp.subject_code',
|
||||
'sp.supplier_external_id',
|
||||
'sp.current_limit',
|
||||
'sp.inactive_since',
|
||||
])
|
||||
->orderBy('sp.unique_key')
|
||||
->orderBy('sp.subject_code')
|
||||
->orderBy('sp.platform')
|
||||
->get();
|
||||
|
||||
$projects = $rows->map(function ($sp): array {
|
||||
$orderers = DB::table('project_supplier_links as psl')
|
||||
->join('projects as p', 'p.id', '=', 'psl.project_id')
|
||||
->join('tenants as t', 't.id', '=', 'p.tenant_id')
|
||||
->where('psl.supplier_project_id', $sp->id)
|
||||
->distinct()
|
||||
->pluck('t.organization_name')
|
||||
->all();
|
||||
|
||||
$lastDelivery = DB::table('supplier_leads')
|
||||
->where('supplier_project_id', $sp->id)
|
||||
->max('received_at');
|
||||
|
||||
$subjectCode = $sp->subject_code !== null ? (int) $sp->subject_code : null;
|
||||
|
||||
return [
|
||||
'id' => (int) $sp->id,
|
||||
'platform' => $sp->platform,
|
||||
'signal_type' => $sp->signal_type,
|
||||
'unique_key' => $sp->unique_key,
|
||||
'subject_code' => $subjectCode,
|
||||
'subject_name' => $subjectCode !== null
|
||||
? (RussianRegions::CODE_TO_NAME[$subjectCode] ?? null)
|
||||
: 'РФ',
|
||||
'current_limit' => (int) $sp->current_limit,
|
||||
'supplier_external_id' => $sp->supplier_external_id,
|
||||
'inactive_since' => $sp->inactive_since,
|
||||
'orderers' => $orderers,
|
||||
'last_delivery_at' => $lastDelivery,
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json(['projects' => $projects->all()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan 4 Task 2: bulk-delete выбранных supplier_projects.
|
||||
* Сначала на портале (deleteProject), затем локально (pivot снимается CASCADE).
|
||||
* Сбой по строке — не прерывает batch, копится в failures[].
|
||||
*/
|
||||
public function projectsDestroy(Request $request, SupplierPortalClient $client): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'ids' => ['required', 'array', 'min:1'],
|
||||
'ids.*' => ['integer'],
|
||||
]);
|
||||
|
||||
$deleted = 0;
|
||||
$failures = [];
|
||||
|
||||
foreach (SupplierProject::whereIn('id', $data['ids'])->get() as $sp) {
|
||||
try {
|
||||
if ($sp->supplier_external_id !== null) {
|
||||
$client->deleteProject((int) $sp->supplier_external_id);
|
||||
}
|
||||
$sp->delete();
|
||||
$deleted++;
|
||||
} catch (\Throwable $e) {
|
||||
$failures[] = ['id' => $sp->id, 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json(['deleted' => $deleted, 'failures' => $failures]);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
@@ -63,10 +62,10 @@ class DashboardController extends Controller
|
||||
$curLeads = (clone $base())->whereBetween('received_at', [$windowStart, $now])->count();
|
||||
$prevLeads = (clone $base())->whereBetween('received_at', [$prevStart, $windowStart])->count();
|
||||
|
||||
// --- conversion: % статуса 'paid' в окне ---
|
||||
$curPaid = (clone $base())->where('status', 'paid')
|
||||
// --- conversion: % статуса 'won' в окне ---
|
||||
$curPaid = (clone $base())->where('status', 'won')
|
||||
->whereBetween('received_at', [$windowStart, $now])->count();
|
||||
$prevPaid = (clone $base())->where('status', 'paid')
|
||||
$prevPaid = (clone $base())->where('status', 'won')
|
||||
->whereBetween('received_at', [$prevStart, $windowStart])->count();
|
||||
$curConv = $curLeads > 0 ? round($curPaid / $curLeads * 100, 1) : 0.0;
|
||||
$prevConv = $prevLeads > 0 ? round($prevPaid / $prevLeads * 100, 1) : 0.0;
|
||||
@@ -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));
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Models\User;
|
||||
use App\Services\SupplierResolver;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
@@ -55,6 +56,11 @@ class DealController extends Controller
|
||||
{
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
|
||||
$request->validate([
|
||||
'received_from' => 'nullable|date',
|
||||
'received_to' => 'nullable|date',
|
||||
]);
|
||||
|
||||
$statuses = (array) $request->query('status_in', []);
|
||||
$projectId = $request->query('project_id') !== null ? (int) $request->query('project_id') : null;
|
||||
$managerId = $request->query('manager_id') !== null ? (int) $request->query('manager_id') : null;
|
||||
@@ -64,6 +70,8 @@ class DealController extends Controller
|
||||
$onlyDeleted = $request->boolean('only_deleted');
|
||||
$countOnly = $request->boolean('count_only');
|
||||
$cursorRaw = (string) $request->query('cursor', '');
|
||||
$receivedFrom = trim((string) $request->query('received_from', ''));
|
||||
$receivedTo = trim((string) $request->query('received_to', ''));
|
||||
|
||||
// Sprint 4 Phase A (audit O-perf-04): keyset pagination через cursor.
|
||||
// При передаче cursor — keyset через PG row constructor (received_at, id) < (?, ?),
|
||||
@@ -81,7 +89,7 @@ class DealController extends Controller
|
||||
$cursor = ['r' => (string) $parsed['r'], 'i' => (int) $parsed['i']];
|
||||
}
|
||||
|
||||
[$deals, $total, $nextCursor] = DB::transaction(function () use ($tenantId, $statuses, $projectId, $managerId, $search, $limit, $offset, $onlyDeleted, $cursor, $countOnly) {
|
||||
[$deals, $total, $nextCursor] = DB::transaction(function () use ($tenantId, $statuses, $projectId, $managerId, $search, $limit, $offset, $onlyDeleted, $cursor, $countOnly, $receivedFrom, $receivedTo) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
// Defense-in-depth: явный where(tenant_id) поверх RLS — на тестах
|
||||
@@ -92,8 +100,16 @@ class DealController extends Controller
|
||||
// withTrashed() обходит global scope SoftDeletes; явный
|
||||
// whereNotNull('deleted_at') фильтрует только удалённые.
|
||||
$query = Deal::query()
|
||||
->select('deals.*')
|
||||
->addSelect(['next_reminder_at' => DB::table('reminders')
|
||||
->select('remind_at')
|
||||
->whereColumn('reminders.deal_id', 'deals.id')
|
||||
->whereNull('reminders.completed_at')
|
||||
->orderBy('remind_at')
|
||||
->limit(1),
|
||||
])
|
||||
->where('tenant_id', $tenantId)
|
||||
->with(['project:id,name', 'manager:id,email,first_name,last_name']);
|
||||
->with(['project:id,name,signal_type,signal_identifier,sms_keyword,sms_senders', 'manager:id,email,first_name,last_name']);
|
||||
|
||||
if ($onlyDeleted) {
|
||||
$query->withTrashed()->whereNotNull('deleted_at');
|
||||
@@ -115,6 +131,13 @@ class DealController extends Controller
|
||||
->orWhere('contact_name', 'ilike', $like);
|
||||
});
|
||||
}
|
||||
if ($receivedFrom !== '') {
|
||||
$query->where('received_at', '>=', Carbon::parse($receivedFrom)->startOfDay());
|
||||
}
|
||||
if ($receivedTo !== '') {
|
||||
// received_to включительно — до конца дня (+1 день, строгое <).
|
||||
$query->where('received_at', '<', Carbon::parse($receivedTo)->addDay()->startOfDay());
|
||||
}
|
||||
|
||||
// Audit B2: count_only — отдаём только COUNT(*), пропуская SELECT строк
|
||||
// и cursor/offset-логику (лёгкий запрос для бейджа в сайдбаре).
|
||||
@@ -187,6 +210,15 @@ class DealController extends Controller
|
||||
? ManagerController::formatInitials($d->manager->first_name, $d->manager->last_name, $d->manager->email)
|
||||
: null,
|
||||
'received_at' => $d->received_at?->toIso8601String(),
|
||||
'comment' => $d->comment,
|
||||
'city' => $d->city,
|
||||
'project_signal_type' => $d->project?->signal_type,
|
||||
'project_signal_identifier' => $d->project?->signal_identifier,
|
||||
'project_sms_keyword' => $d->project?->sms_keyword,
|
||||
'project_sms_senders' => $d->project?->sms_senders,
|
||||
'next_reminder_at' => $d->next_reminder_at
|
||||
? Carbon::parse($d->next_reminder_at)->toIso8601String()
|
||||
: null,
|
||||
]),
|
||||
'limit' => $limit,
|
||||
'next_cursor' => $nextCursor,
|
||||
@@ -219,7 +251,7 @@ class DealController extends Controller
|
||||
$deal = Deal::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('id', $id)
|
||||
->with(['project:id,name', 'manager:id,email,first_name,last_name'])
|
||||
->with(['project:id,name,signal_type,signal_identifier,sms_keyword,sms_senders', 'manager:id,email,first_name,last_name'])
|
||||
->first();
|
||||
|
||||
if ($deal === null) {
|
||||
@@ -261,6 +293,10 @@ class DealController extends Controller
|
||||
: null,
|
||||
'received_at' => $deal->received_at?->toIso8601String(),
|
||||
'assigned_at' => $deal->assigned_at?->toIso8601String(),
|
||||
'project_signal_type' => $deal->project?->signal_type,
|
||||
'project_signal_identifier' => $deal->project?->signal_identifier,
|
||||
'project_sms_keyword' => $deal->project?->sms_keyword,
|
||||
'project_sms_senders' => $deal->project?->sms_senders,
|
||||
],
|
||||
'events' => $events->map(fn (ActivityLog $e) => [
|
||||
'id' => $e->id,
|
||||
@@ -403,6 +439,10 @@ class DealController extends Controller
|
||||
'manager_id' => $deal->manager_id,
|
||||
'received_at' => $deal->received_at?->toIso8601String(),
|
||||
'assigned_at' => $deal->assigned_at?->toIso8601String(),
|
||||
'project_signal_type' => $deal->project?->signal_type,
|
||||
'project_signal_identifier' => $deal->project?->signal_identifier,
|
||||
'project_sms_keyword' => $deal->project?->sms_keyword,
|
||||
'project_sms_senders' => $deal->project?->sms_senders,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Deal;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use OpenSpout\Common\Entity\Row;
|
||||
use OpenSpout\Common\Entity\Style\Style;
|
||||
@@ -16,44 +17,45 @@ use OpenSpout\Writer\XLSX\Writer as XlsxWriter;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
/**
|
||||
* Export сделок в CSV / XLSX через OpenSpout streaming.
|
||||
* Экспорт сделок в CSV / XLSX через OpenSpout streaming.
|
||||
*
|
||||
* Извлечено из DealController (Sprint 3 Phase A, audit O-refactor-01).
|
||||
* Редизайн «Сделки» (2026-05-17, Task A5): экспорт по ДИАПАЗОНУ ДАТ поставки
|
||||
* (received_at), не по списку id. Окно задаётся received_from/received_to;
|
||||
* оба опциональны (пусто = весь период). Колонки соответствуют таблице
|
||||
* страницы (без чекбокса и без «Напоминание» — экспорт = дамп лидов).
|
||||
*
|
||||
* RLS-обёртка SET LOCAL внутри транзакции (PgBouncer-safe).
|
||||
*
|
||||
* J1 (Sprint 3F): auth:sanctum+tenant, tenant_id из auth()->user().
|
||||
*
|
||||
* O-perf-05: streaming устраняет memory pressure. PhpSpreadsheet строил
|
||||
* полный объект .xlsx в памяти (для 10K сделок ≈ 100+ MB). OpenSpout пишет
|
||||
* O-perf-05: streaming устраняет memory pressure. OpenSpout пишет
|
||||
* в php://output постранично через Writer + Row::fromValues и chunkById(500)
|
||||
* по сделкам — пик памяти O(1) от размера экспорта.
|
||||
*
|
||||
* API контракт сохранён:
|
||||
* POST /api/deals/export {ids[], format?: csv|xlsx}
|
||||
* Headers Content-Type / Content-Disposition без изменений.
|
||||
* CSV: UTF-8 + BOM + ;-разделитель (Excel-friendly RU-локаль).
|
||||
* XLSX: bold-header + auto-size columns.
|
||||
*
|
||||
* RLS-обёртка SET LOCAL внутри транзакции (PgBouncer-safe). Чужие id
|
||||
* отфильтрует where(tenant_id) defense-in-depth.
|
||||
*/
|
||||
class DealExportController extends Controller
|
||||
{
|
||||
/** Заголовки таблицы — общие для CSV и XLSX. */
|
||||
private const HEADERS = ['ID', 'Имя', 'Телефон', 'Статус', 'Проект ID', 'Менеджер ID', 'Получено'];
|
||||
/** Заголовки — общие для CSV и XLSX. */
|
||||
private const HEADERS = ['Телефон', 'Источник', 'Город', 'Статус', 'Комментарий', 'Поставлен'];
|
||||
|
||||
/** signal_type → русская метка для колонки «Источник». */
|
||||
private const SIGNAL_LABELS = ['call' => 'Звонки', 'site' => 'Сайт', 'sms' => 'СМС'];
|
||||
|
||||
public function export(Request $request): StreamedResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'ids' => 'required|array|min:1|max:10000',
|
||||
'ids.*' => 'integer|min:1',
|
||||
'received_from' => 'nullable|date',
|
||||
'received_to' => 'nullable|date',
|
||||
'format' => 'nullable|string|in:csv,xlsx',
|
||||
]);
|
||||
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
|
||||
$format = $validated['format'] ?? 'csv';
|
||||
$filename = 'deals_export_'.now()->format('Y-m-d').'.'.$format;
|
||||
$from = isset($validated['received_from']) && $validated['received_from'] !== ''
|
||||
? Carbon::parse($validated['received_from'])->startOfDay() : null;
|
||||
$to = isset($validated['received_to']) && $validated['received_to'] !== ''
|
||||
? Carbon::parse($validated['received_to'])->addDay()->startOfDay() : null;
|
||||
|
||||
$filename = 'deals_export_'.now()->format('Y-m-d').'.'.$format;
|
||||
$headers = $format === 'xlsx'
|
||||
? [
|
||||
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
@@ -64,14 +66,16 @@ class DealExportController extends Controller
|
||||
'Content-Disposition' => 'attachment; filename="'.$filename.'"',
|
||||
];
|
||||
|
||||
return new StreamedResponse(function () use ($validated, $tenantId, $format) {
|
||||
return new StreamedResponse(function () use ($tenantId, $format, $from, $to) {
|
||||
// RLS-контекст должен быть установлен внутри транзакции на момент
|
||||
// фактического SELECT. StreamedResponse callback вызывается уже
|
||||
// после Laravel-response pipeline'а, поэтому открываем транзакцию
|
||||
// прямо здесь.
|
||||
DB::transaction(function () use ($validated, $tenantId, $format) {
|
||||
DB::transaction(function () use ($tenantId, $format, $from, $to) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
$statusNames = DB::table('lead_statuses')->pluck('name_ru', 'slug');
|
||||
|
||||
$writer = $this->openWriter($format);
|
||||
$writer->openToFile('php://output');
|
||||
|
||||
@@ -81,32 +85,41 @@ class DealExportController extends Controller
|
||||
if ($format === 'xlsx') {
|
||||
/** @var XlsxWriter $writer */
|
||||
$writer->getCurrentSheet()->setName('Сделки');
|
||||
$headerStyle = (new Style)->withFontBold(true);
|
||||
$writer->addRow(Row::fromValuesWithStyle(self::HEADERS, $headerStyle));
|
||||
$writer->addRow(Row::fromValuesWithStyle(self::HEADERS, (new Style)->withFontBold(true)));
|
||||
} else {
|
||||
$writer->addRow(Row::fromValues(self::HEADERS));
|
||||
}
|
||||
|
||||
// chunkById(500) — keyset-friendly; в нашем DealsView это
|
||||
// редкий тяжёлый action, экспортировать могут до 10K id.
|
||||
Deal::query()
|
||||
$query = Deal::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('id', $validated['ids'])
|
||||
->orderBy('id')
|
||||
->chunkById(500, function ($deals) use ($writer) {
|
||||
foreach ($deals as $deal) {
|
||||
/** @var Deal $deal */
|
||||
$writer->addRow(Row::fromValues([
|
||||
$deal->id,
|
||||
(string) ($deal->contact_name ?? ''),
|
||||
(string) $deal->phone,
|
||||
(string) $deal->status,
|
||||
$deal->project_id,
|
||||
$deal->manager_id ?? '',
|
||||
$deal->received_at->toDateTimeString(),
|
||||
]));
|
||||
}
|
||||
});
|
||||
->with('project:id,name,signal_type')
|
||||
->orderByDesc('received_at');
|
||||
|
||||
if ($from !== null) {
|
||||
$query->where('received_at', '>=', $from);
|
||||
}
|
||||
if ($to !== null) {
|
||||
$query->where('received_at', '<', $to);
|
||||
}
|
||||
|
||||
// chunkById(500) — keyset-friendly; deals.id — BIGSERIAL (unique),
|
||||
// корректно для чанкинга даже при партиционированной PK (id, received_at).
|
||||
$query->chunkById(500, function ($deals) use ($writer, $statusNames) {
|
||||
foreach ($deals as $deal) {
|
||||
/** @var Deal $deal */
|
||||
$signal = $deal->project?->signal_type;
|
||||
$source = trim(($deal->project?->name ?? '—').' · '
|
||||
.(self::SIGNAL_LABELS[$signal] ?? '—'));
|
||||
$writer->addRow(Row::fromValues([
|
||||
(string) $deal->phone,
|
||||
$source,
|
||||
(string) ($deal->city ?? ''),
|
||||
(string) ($statusNames[$deal->status] ?? $deal->status),
|
||||
(string) ($deal->comment ?? ''),
|
||||
$deal->received_at?->toDateTimeString() ?? '',
|
||||
]));
|
||||
}
|
||||
}, 'id');
|
||||
|
||||
$writer->close();
|
||||
});
|
||||
@@ -120,12 +133,10 @@ class DealExportController extends Controller
|
||||
}
|
||||
|
||||
// CSV: ;-разделитель + UTF-8 BOM (Excel-friendly RU-локаль).
|
||||
$options = new CsvOptions(
|
||||
return new CsvWriter(new CsvOptions(
|
||||
FIELD_DELIMITER: ';',
|
||||
FIELD_ENCLOSURE: '"',
|
||||
SHOULD_ADD_BOM: true,
|
||||
);
|
||||
|
||||
return new CsvWriter($options);
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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,14 +28,21 @@ 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'],
|
||||
];
|
||||
|
||||
if ($action === 'update_regions' || $action === 'update_days') {
|
||||
$maxMask = $action === 'update_regions' ? 255 : 127;
|
||||
$rules['add'] = ['nullable', 'integer', 'min:0', "max:{$maxMask}"];
|
||||
$rules['remove'] = ['nullable', 'integer', 'min:0', "max:{$maxMask}"];
|
||||
if ($action === 'update_regions') {
|
||||
// Plan 6.5: субъект-уровневые коды 1..89 (см. resources/js/constants/regions.ts).
|
||||
$rules['add_regions'] = ['nullable', 'array'];
|
||||
$rules['add_regions.*'] = ['integer', 'between:1,89'];
|
||||
$rules['remove_regions'] = ['nullable', 'array'];
|
||||
$rules['remove_regions.*'] = ['integer', 'between:1,89'];
|
||||
}
|
||||
|
||||
if ($action === 'update_days') {
|
||||
$rules['add'] = ['nullable', 'integer', 'min:0', 'max:127'];
|
||||
$rules['remove'] = ['nullable', 'integer', 'min:0', 'max:127'];
|
||||
}
|
||||
|
||||
if ($action === 'update_limit') {
|
||||
|
||||
@@ -22,8 +22,11 @@ class StoreProjectRequest extends FormRequest
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'signal_type' => ['required', Rule::in(['site', 'call', 'sms'])],
|
||||
'daily_limit_target' => ['required', 'integer', 'min:1', 'max:10000'],
|
||||
'region_mask' => ['required', 'integer', 'min:0'],
|
||||
'region_mode' => ['required', Rule::in(['include', 'exclude'])],
|
||||
// Plan 6: subject-level regions[] заменил region_mask/region_mode на API-уровне.
|
||||
// Empty array = "вся РФ" (паритет с legacy region_mask=255 + region_mode='include').
|
||||
// present = поле должно быть в payload (даже если []), enforces explicit choice.
|
||||
'regions' => ['present', 'array'],
|
||||
'regions.*' => ['integer', 'between:1,89'],
|
||||
'delivery_days_mask' => ['required', 'integer', 'min:1', 'max:127'],
|
||||
];
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\Project;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateProjectRequest extends FormRequest
|
||||
{
|
||||
@@ -17,15 +17,35 @@ class UpdateProjectRequest extends FormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
// signal_type immutable: не валидируется в правилах, controller игнорирует поле
|
||||
return [
|
||||
$rules = [
|
||||
'name' => ['sometimes', 'string', 'max:255'],
|
||||
'daily_limit_target' => ['sometimes', 'integer', 'min:1', 'max:10000'],
|
||||
'region_mask' => ['sometimes', 'integer', 'min:0'],
|
||||
'region_mode' => ['sometimes', Rule::in(['include', 'exclude'])],
|
||||
// Plan 6: subject-level regions[] заменил region_mask/region_mode на API-уровне.
|
||||
// sometimes = поле omit-able (preserves prior DB value), массив + each 1..89.
|
||||
'regions' => ['sometimes', 'array'],
|
||||
'regions.*' => ['integer', 'between:1,89'],
|
||||
'delivery_days_mask' => ['sometimes', 'integer', 'min:1', 'max:127'],
|
||||
'sms_senders' => ['sometimes', 'array', 'min:1'],
|
||||
'sms_senders.*' => ['string', 'max:11'],
|
||||
'sms_keyword' => ['sometimes', 'nullable', 'string', 'min:1', 'max:50'],
|
||||
];
|
||||
|
||||
// 18.05.2026 UX: редактирование источника (signal_identifier) для site/call.
|
||||
// Регулярки соответствуют StoreProjectRequest (domain + 7\d{10}).
|
||||
// signal_type immutable — берём из текущего проекта по route id.
|
||||
$projectId = $this->route('id');
|
||||
if ($projectId !== null) {
|
||||
$project = Project::find($projectId);
|
||||
if ($project !== null) {
|
||||
if ($project->signal_type === 'site') {
|
||||
$rules['signal_identifier'] = ['sometimes', 'string', 'regex:/^[a-z0-9][a-z0-9\-]*(\.[a-z0-9][a-z0-9\-]*)*\.[a-z]{2,}$/i'];
|
||||
} elseif ($project->signal_type === 'call') {
|
||||
$rules['signal_identifier'] = ['sometimes', 'string', 'regex:/^7\d{10}$/'];
|
||||
}
|
||||
// sms: signal_identifier меняется через sms_senders/sms_keyword (см. выше)
|
||||
}
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,9 +25,9 @@ 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,
|
||||
'delivery_days_mask' => $this->delivery_days_mask,
|
||||
'sync_status' => $this->aggregateSyncStatus(),
|
||||
'last_synced_at' => $this->aggregateLastSyncedAt(),
|
||||
|
||||
@@ -12,8 +12,10 @@ use App\Models\SupplierLead;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Billing\LedgerService;
|
||||
use App\Services\DuplicateDetector;
|
||||
use App\Services\LeadDistributor;
|
||||
use App\Services\LeadRouter;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\RegionTagResolver;
|
||||
use App\Services\SupplierProjects\SupplierProjectResolver;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
@@ -86,6 +88,8 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
DuplicateDetector $duplicateDetector,
|
||||
NotificationService $notifier,
|
||||
LedgerService $ledger,
|
||||
LeadDistributor $distributor,
|
||||
RegionTagResolver $tagResolver,
|
||||
): void {
|
||||
$lead = SupplierLead::findOrFail($this->supplierLeadId);
|
||||
|
||||
@@ -108,20 +112,19 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
$supplier = $resolver->resolveOrStub($platform, $signalType, $identifier);
|
||||
$lead->update(['supplier_project_id' => $supplier->id]);
|
||||
|
||||
$matched = $router->matchEligibleProjects($supplier, (string) $lead->phone);
|
||||
$matched = $router->matchEligibleProjects($supplier);
|
||||
$selected = $distributor->selectRecipients($matched); // cap=3 случайных
|
||||
|
||||
$subjectCode = $tagResolver->resolve((string) ($lead->raw_payload['tag'] ?? ''));
|
||||
|
||||
$createdCount = 0;
|
||||
$failures = [];
|
||||
foreach ($matched as $project) {
|
||||
foreach ($selected as $project) {
|
||||
try {
|
||||
if ($this->createDealCopyForProject($lead, $project, $duplicateDetector, $notifier, $ledger)) {
|
||||
if ($this->createDealCopyForProject($lead, $project, $duplicateDetector, $notifier, $ledger, $subjectCode)) {
|
||||
$createdCount++;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// Per-Project failure isolation (Plan 2 code-review Important).
|
||||
// Sharing-model: один сбой проекта не должен абортить routing других tenant'ов.
|
||||
// Логируем и продолжаем; final failed() callback зафиксирует общий проблемный лид
|
||||
// только если ВСЕ Projects упали (через handle() rethrow ниже).
|
||||
$failures[] = ['project_id' => $project->id, 'tenant_id' => $project->tenant_id, 'error' => $e->getMessage()];
|
||||
Log::warning('supplier_lead.per_project_routing_failed', [
|
||||
'supplier_lead_id' => $lead->id,
|
||||
@@ -132,9 +135,7 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
// Если ВСЕ Projects упали (а matched был непустой) — пробрасываем последнюю ошибку,
|
||||
// чтобы failed() callback сработал и проблема ушла в failed_webhook_jobs.
|
||||
if ($matched->isNotEmpty() && $createdCount === 0 && count($failures) === $matched->count()) {
|
||||
if ($selected->isNotEmpty() && $createdCount === 0 && count($failures) === $selected->count()) {
|
||||
throw new RuntimeException(
|
||||
'All eligible projects failed routing for supplier_lead='.$lead->id.
|
||||
'; last error: '.($failures[array_key_last($failures)]['error'] ?? 'unknown')
|
||||
@@ -150,7 +151,10 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
/**
|
||||
* Парсит поле raw_payload['project'] (формат `B[123]_<rest>`):
|
||||
* - rest вида `7\d{10}` → call (телефон-номер для звонка-сигнала);
|
||||
* - rest вида `^[a-z0-9-]+(\.[a-z0-9-]+)+$` → site (домен сайта-сигнала);
|
||||
* - rest вида `^[a-z0-9-]+(\.[a-z0-9-]+)+$` → site (rest целиком — домен);
|
||||
* - rest со встроенным доменом в свободном тексте → site (identifier =
|
||||
* извлечённый домен; поставщик иногда шлёт имя вида `заявка carmoney.ru/`
|
||||
* или `Платежи cabinet.caranga.ru/login` — регрессия 18.05.2026, 21 лид);
|
||||
* - иначе → sms (короткое имя отправителя SMS-шлюза).
|
||||
*
|
||||
* @return array{0: string, 1: string, 2: string} [platform, signal_type, identifier]
|
||||
@@ -163,15 +167,26 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
$platform = $m[1];
|
||||
$rest = $m[2];
|
||||
|
||||
// Домен с латинским TLD ≥2 букв (последний сегмент — только буквы), допускается
|
||||
// в любой позиции строки. Соответствует чистому rest и встроенному в текст домену.
|
||||
$domainRe = '/(?<![a-z0-9.\-])([a-z0-9][a-z0-9\-]*(?:\.[a-z0-9][a-z0-9\-]*)*\.[a-z]{2,})/i';
|
||||
|
||||
if (preg_match('/^7\d{10}$/', $rest) === 1) {
|
||||
$signalType = 'call';
|
||||
$identifier = $rest;
|
||||
} elseif (preg_match('/^[a-z0-9-]+(\.[a-z0-9-]+)+$/i', $rest) === 1) {
|
||||
$signalType = 'site';
|
||||
$identifier = $rest;
|
||||
} elseif (preg_match($domainRe, $rest, $dm) === 1) {
|
||||
// Домен извлечён из свободного текста — это сайт-сигнал.
|
||||
$signalType = 'site';
|
||||
$identifier = mb_strtolower($dm[1]);
|
||||
} else {
|
||||
$signalType = 'sms';
|
||||
$identifier = $rest;
|
||||
}
|
||||
|
||||
return [$platform, $signalType, $rest];
|
||||
return [$platform, $signalType, $identifier];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -185,9 +200,10 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
DuplicateDetector $duplicateDetector,
|
||||
NotificationService $notifier,
|
||||
LedgerService $ledger,
|
||||
?int $subjectCode,
|
||||
): bool {
|
||||
try {
|
||||
return DB::transaction(function () use ($lead, $project, $duplicateDetector, $notifier, $ledger): bool {
|
||||
return DB::transaction(function () use ($lead, $project, $duplicateDetector, $notifier, $ledger, $subjectCode): bool {
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$project->tenant_id}'");
|
||||
|
||||
/** @var Tenant $tenant */
|
||||
@@ -238,6 +254,7 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
'phones' => $phones,
|
||||
'status' => 'new',
|
||||
'received_at' => $receivedAt,
|
||||
'subject_code' => $subjectCode,
|
||||
]);
|
||||
|
||||
$master = $duplicateDetector->findMaster(
|
||||
|
||||
@@ -24,21 +24,20 @@ use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Hourly CSV reconciliation с порталом поставщика.
|
||||
* Резервный CSV-канал (Путь 2): сверка отчёта поставщика «Запрос номеров»
|
||||
* с принятыми webhook-лидами; recovery пропущенного + drift-детект.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §5.3
|
||||
* Spec: docs/superpowers/specs/2026-05-18-supplier-csv-reconcile-channel-design.md
|
||||
*
|
||||
* Алгоритм:
|
||||
* 1. Cache::lock на 600s — overlap-защита.
|
||||
* 1. Cache::lock — overlap-защита.
|
||||
* 2. INSERT supplier_csv_reconcile_log (status='running').
|
||||
* 3. Download CSV за окно 25h.
|
||||
* 4. Parse → собираем ['vid' => row].
|
||||
* 5. SELECT existing vid'ы из supplier_leads (BYPASSRLS).
|
||||
* 6. Diff = missing.
|
||||
* 7. Для каждой missing — INSERT supplier_leads (recovered_from_csv_at) + dispatch RouteJob.
|
||||
* 8. UPDATE log с метриками + status.
|
||||
* 9. drift > 5% → CsvDriftAlertMail + alert_email_sent_at.
|
||||
* 10. На exception — status='failed', throw.
|
||||
* 3. Заказать отчёт «Запрос номеров» за окно (2 кал. дня) → дождаться → скачать.
|
||||
* 4. Parse CSV (Name;Tag;Phone).
|
||||
* 5. Дедуп по (phone, project): SELECT existing supplier_leads за окно.
|
||||
* 6. Diff = missing → INSERT supplier_leads (vid=NULL, source='csv_recovery') + RouteJob.
|
||||
* 7. UPDATE log + drift; drift > 5% → CsvDriftAlertMail.
|
||||
* 8. На exception — status='failed', throw (cron повторит через 30 мин).
|
||||
*/
|
||||
final class CsvReconcileJob implements ShouldQueue
|
||||
{
|
||||
@@ -55,7 +54,7 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
|
||||
private const DRIFT_THRESHOLD = 0.05;
|
||||
|
||||
private const WINDOW_HOURS = 25;
|
||||
private const WINDOW_DAYS = 2;
|
||||
|
||||
private const LOCK_NAME = 'supplier:csv_reconcile';
|
||||
|
||||
@@ -75,47 +74,63 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
return;
|
||||
}
|
||||
|
||||
// Окно: начало (сегодня − (WINDOW_DAYS−1) дней) 00:00 .. сейчас.
|
||||
$windowEnd = Carbon::now();
|
||||
$windowStart = (clone $windowEnd)->subHours(self::WINDOW_HOURS);
|
||||
$windowStart = Carbon::today()->subDays(self::WINDOW_DAYS - 1);
|
||||
|
||||
$logId = DB::connection(self::DB_CONNECTION)
|
||||
->table('supplier_csv_reconcile_log')
|
||||
->insertGetId([
|
||||
'started_at' => now(),
|
||||
'window_start' => $windowStart,
|
||||
'window_end' => $windowEnd,
|
||||
'status' => 'running',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
// $logId инициализируется внутри try: если сам insertGetId упадёт (БД недоступна),
|
||||
// catch обязан НЕ обращаться к неинициализированному $logId, а finally — освободить
|
||||
// lock (иначе lock висит LOCK_TTL_SECONDS и пропускает следующие запуски).
|
||||
$logId = null;
|
||||
|
||||
try {
|
||||
$csv = $portal->downloadLeadsCsv($windowStart, $windowEnd);
|
||||
$logId = DB::connection(self::DB_CONNECTION)
|
||||
->table('supplier_csv_reconcile_log')
|
||||
->insertGetId([
|
||||
'started_at' => now(),
|
||||
'window_start' => $windowStart,
|
||||
'window_end' => $windowEnd,
|
||||
'status' => 'running',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
/** @var array<string, array<string, mixed>> $csvByVid */
|
||||
$csvByVid = [];
|
||||
$reportId = $portal->requestNumbersReport($windowStart, $windowEnd);
|
||||
$portal->waitReportReady($reportId);
|
||||
$csv = $portal->downloadReport($reportId);
|
||||
|
||||
// CSV-строки по ключу phone|project (последняя строка с тем же ключом перетирает).
|
||||
/** @var array<string, array{project: string, tag: string, phone: string}> $csvByKey */
|
||||
$csvByKey = [];
|
||||
foreach ($parser->parse($csv) as $row) {
|
||||
$csvByVid[(string) $row['vid']] = $row;
|
||||
$csvByKey[$this->dedupKey((string) $row['phone'], (string) $row['project'])] = $row;
|
||||
}
|
||||
$totalCsvRows = count($csvByVid);
|
||||
$totalCsvRows = count($csvByKey);
|
||||
|
||||
$existing = DB::connection(self::DB_CONNECTION)
|
||||
// Существующие лиды за окно → set ключей phone|project.
|
||||
$existingKeys = [];
|
||||
DB::connection(self::DB_CONNECTION)
|
||||
->table('supplier_leads')
|
||||
->where('received_at', '>=', $windowStart)
|
||||
->where('received_at', '<', $windowEnd->copy()->addHour())
|
||||
->pluck('vid')
|
||||
->map(fn ($v) => (string) $v)
|
||||
->all();
|
||||
->select('phone', 'raw_payload')
|
||||
->orderBy('id')
|
||||
->chunk(500, function ($leads) use (&$existingKeys): void {
|
||||
foreach ($leads as $lead) {
|
||||
$payload = is_string($lead->raw_payload)
|
||||
? json_decode($lead->raw_payload, true)
|
||||
: (array) $lead->raw_payload;
|
||||
$project = (string) ($payload['project'] ?? '');
|
||||
$existingKeys[$this->dedupKey((string) $lead->phone, $project)] = true;
|
||||
}
|
||||
});
|
||||
|
||||
$existingMap = array_flip($existing);
|
||||
$missing = array_diff_key($csvByVid, $existingMap);
|
||||
$missing = array_diff_key($csvByKey, $existingKeys);
|
||||
|
||||
$recoveredCount = 0;
|
||||
foreach ($missing as $vid => $row) {
|
||||
$platform = $this->extractPlatform((string) ($row['project'] ?? ''));
|
||||
foreach ($missing as $row) {
|
||||
$platform = $this->extractPlatform((string) $row['project']);
|
||||
if ($platform === null) {
|
||||
Log::warning('csv_reconcile.unparseable_project_skipped', [
|
||||
'vid' => $vid,
|
||||
'project' => $row['project'] ?? null,
|
||||
'project' => $row['project'],
|
||||
]);
|
||||
|
||||
continue;
|
||||
@@ -123,24 +138,23 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
|
||||
try {
|
||||
$lead = SupplierLead::create([
|
||||
'vid' => (int) $vid,
|
||||
'vid' => null,
|
||||
'platform' => $platform,
|
||||
'phone' => (string) $row['phone'],
|
||||
'raw_payload' => $row,
|
||||
'received_at' => Carbon::createFromTimestamp((int) $row['time']),
|
||||
'received_at' => now(),
|
||||
'recovered_from_csv_at' => now(),
|
||||
'source' => 'csv_recovery',
|
||||
'supplier_project_id' => null, // ResolverStub разрезолвит при RouteJob run
|
||||
'supplier_project_id' => null,
|
||||
]);
|
||||
RouteSupplierLeadJob::dispatch($lead->id);
|
||||
$recoveredCount++;
|
||||
} catch (QueryException $e) {
|
||||
if (str_contains($e->getMessage(), 'unique')) {
|
||||
Log::info('csv_reconcile.duplicate_vid_skipped', ['vid' => $vid]);
|
||||
|
||||
continue;
|
||||
}
|
||||
throw $e;
|
||||
Log::warning('csv_reconcile.lead_insert_failed', [
|
||||
'phone' => $row['phone'],
|
||||
'project' => $row['project'],
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,14 +191,17 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
->update($update);
|
||||
|
||||
} catch (Throwable $e) {
|
||||
DB::connection(self::DB_CONNECTION)
|
||||
->table('supplier_csv_reconcile_log')
|
||||
->where('id', $logId)
|
||||
->update([
|
||||
'finished_at' => now(),
|
||||
'status' => 'failed',
|
||||
'error_message' => substr($e->getMessage(), 0, 1000),
|
||||
]);
|
||||
// $logId === null — упал сам insertGetId, log-строки нет, обновлять нечего.
|
||||
if ($logId !== null) {
|
||||
DB::connection(self::DB_CONNECTION)
|
||||
->table('supplier_csv_reconcile_log')
|
||||
->where('id', $logId)
|
||||
->update([
|
||||
'finished_at' => now(),
|
||||
'status' => 'failed',
|
||||
'error_message' => substr($e->getMessage(), 0, 1000),
|
||||
]);
|
||||
}
|
||||
throw $e;
|
||||
} finally {
|
||||
$lock->release();
|
||||
@@ -192,8 +209,15 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает platform (B1/B2/B3) из поля raw_payload['project'] CSV-строки.
|
||||
* Формат project: `B[123]_<rest>` (например `B1_a.com`, `B2_79991234567`).
|
||||
* Ключ дедупа: нормализованный phone + project.
|
||||
*/
|
||||
private function dedupKey(string $phone, string $project): string
|
||||
{
|
||||
return trim($phone).'|'.trim($project);
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает platform (B1/B2/B3) из имени проекта формата `B[123]_<rest>`.
|
||||
* Возвращает null если не парсится — caller пропустит строку с warning.
|
||||
*/
|
||||
private function extractPlatform(string $project): ?string
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,46 +12,55 @@ use App\Mail\SupplierCriticalAlertMail;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\SupplierSyncLog;
|
||||
use App\Services\Supplier\Channel\Exceptions\TierEscalatedException;
|
||||
use App\Services\Supplier\Channel\Exceptions\WindowDeferredException;
|
||||
use App\Services\Supplier\Channel\SupplierProjectChannel;
|
||||
use App\Services\Supplier\Dto\SupplierProjectDto;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
use App\Services\Supplier\SupplierProjectGrouping;
|
||||
use App\Services\Supplier\SupplierQuotaAllocator;
|
||||
use App\Support\RussianRegions;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use stdClass;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Daily 20:30 МСК cron-job: синхронизирует supplier_projects с поставщиком crm.bp-gr.ru.
|
||||
* Daily 18:00 МСК cron-job: синхронизирует supplier_projects с поставщиком crm.bp-gr.ru
|
||||
* (расписание перенесено 20:30 → 18:00, см. routes/console.php).
|
||||
*
|
||||
* Алгоритм (per spec §4.3):
|
||||
* 1. Итерация по всем активным (inactive_since IS NULL) supplier_projects.
|
||||
* 2. Для каждого:
|
||||
* a. Подтянуть активные Лидерра-projects через FK supplier_b{1,2,3}_project_id.
|
||||
* b. Адаптировать в plain stdClass с полями daily_limit/workdays/regions.
|
||||
* c. Вызвать SupplierQuotaAllocator::allocate() — pure distribution.
|
||||
* d. Сравнить с current state через SupplierProjectDto::equals(); skip if no diff.
|
||||
* e. saveProject() при supplier_external_id=null, иначе updateProject().
|
||||
* f. Записать audit row в supplier_sync_log.
|
||||
* 3. Failure-handling:
|
||||
* - SupplierAuthException → SupplierCriticalAlertMail('sticky_auth') + Sentry + throw.
|
||||
* - SupplierTransientException → log + continue. После 50 подряд → mass_transient alert + break.
|
||||
* - SupplierClientException → log + continue.
|
||||
* 4. Time budget cutoff: после 20:55 МСК прервать loop (буфер 5 мин до 21:00).
|
||||
* Алгоритм (план 3 Task 5 → переработан: one-group-per-identifier):
|
||||
* 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).
|
||||
* - merged_regions = union(project.regions) по всем проектам группы.
|
||||
* Если хотя бы один проект имеет regions=[] («Вся РФ»), merged_regions=[].
|
||||
* 3. Для каждой группы:
|
||||
* - eligible-today проекты группы (workday-маска на завтра).
|
||||
* - order = computeOrder($eligibleLimits); workdays = union.
|
||||
* - tag = name региона если один, иначе «РФ».
|
||||
* - Найти существующие supplier_projects (unique_key, signal_type, platform) — без subject_code-фильтра:
|
||||
* - Нет → saveProjectMultiFlag → [platform → id] → upsert supplier_projects (subject_code=null).
|
||||
* - Есть → partial-set recovery + updateProject каждого с актуальными regions/limit.
|
||||
* - Pivot: project × supplier_project → INSERT ... ON CONFLICT DO NOTHING (subject_code=null).
|
||||
* 4. Failure-handling (Auth/Transient/Client/Window/TierEscalated), time-budget cutoff — сохранены.
|
||||
*
|
||||
* NOTE про connection: Job's $connection — это queue connection, не DB. Используем
|
||||
* Eloquent::on('pgsql_supplier') для cross-tenant видимости (Plan 3 Task 3 learning).
|
||||
* Портальное ограничение: один identifier = одна группа B1/B2/B3 (status=Doubles на дублирование).
|
||||
* Поэтому все регионы проекта передаются одним списком — portal фильтрует оба одновременно.
|
||||
*
|
||||
* NOTE про connection: Eloquent::on('pgsql_supplier') для cross-tenant видимости.
|
||||
*
|
||||
* Spec:
|
||||
* - docs/superpowers/specs/2026-05-10-supplier-integration-design.md §4.3-§4.4
|
||||
* - docs/superpowers/specs/2026-05-11-plan3-supplier-sync-design.md §4
|
||||
* - docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.3
|
||||
* - docs/superpowers/plans/2026-05-20-project-migration-redesign-plan-3-export.md Task 5
|
||||
*/
|
||||
class SyncSupplierProjectsJob implements ShouldQueue
|
||||
{
|
||||
@@ -63,27 +72,83 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
|
||||
public const DB_CONNECTION = 'pgsql_supplier';
|
||||
|
||||
public function handle(?SupplierPortalClient $client = null): void
|
||||
private SupplierProjectChannel $channel;
|
||||
|
||||
private SupplierPortalClient $client;
|
||||
|
||||
public function handle(?SupplierProjectChannel $channel = null): void
|
||||
{
|
||||
$client ??= app(SupplierPortalClient::class);
|
||||
$this->channel = $channel ?? app(SupplierProjectChannel::class);
|
||||
$this->client = app(SupplierPortalClient::class);
|
||||
$consecutiveTransient = 0;
|
||||
|
||||
$projects = SupplierProject::on(self::DB_CONNECTION)
|
||||
->whereNull('inactive_since')
|
||||
// 1. Load active Лидерра-projects via pgsql_supplier
|
||||
/** @var Collection<int, Project> $projects */
|
||||
$projects = Project::on(self::DB_CONNECTION)
|
||||
->where('is_active', true)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
foreach ($projects as $sp) {
|
||||
// 2. Group by (signal_type, identifier) — no subject_code split.
|
||||
// Portal constraint: one identifier = one B1/B2/B3 group (status=Doubles on duplicate name).
|
||||
// group key => [ 'signal_type', 'identifier', 'merged_regions', 'platforms', 'projects' => [...] ]
|
||||
/** @var array<string, array{signal_type: string, identifier: string, merged_regions: list<int>, has_all_russia: bool, platforms: list<string>, projects: list<Project>}> $groups */
|
||||
$groups = [];
|
||||
|
||||
foreach ($projects as $project) {
|
||||
$platforms = SupplierProjectGrouping::resolvePlatforms($project);
|
||||
if ($platforms === []) {
|
||||
continue;
|
||||
}
|
||||
$identifier = SupplierProjectGrouping::buildUniqueKeyAgnostic($project);
|
||||
|
||||
$key = $project->signal_type.'|'.$identifier;
|
||||
if (! isset($groups[$key])) {
|
||||
$groups[$key] = [
|
||||
'signal_type' => (string) $project->signal_type,
|
||||
'identifier' => $identifier,
|
||||
'merged_regions' => [],
|
||||
'has_all_russia' => false,
|
||||
'platforms' => $platforms,
|
||||
'projects' => [],
|
||||
];
|
||||
}
|
||||
// Merge regions — union across all projects in this group.
|
||||
// If any project has empty regions ("Вся РФ"), the whole group becomes "Вся РФ".
|
||||
if (! $groups[$key]['has_all_russia']) {
|
||||
$projectRegions = array_map('intval', (array) ($project->regions ?? []));
|
||||
if ($projectRegions === []) {
|
||||
$groups[$key]['has_all_russia'] = true;
|
||||
$groups[$key]['merged_regions'] = [];
|
||||
} else {
|
||||
$groups[$key]['merged_regions'] = array_values(array_unique(
|
||||
array_merge($groups[$key]['merged_regions'], $projectRegions)
|
||||
));
|
||||
}
|
||||
}
|
||||
$groups[$key]['projects'][] = $project;
|
||||
}
|
||||
|
||||
// 3. Sync each group
|
||||
foreach ($groups as $group) {
|
||||
if (now()->timezone('Europe/Moscow')->format('H:i') >= self::TIME_BUDGET_CUTOFF) {
|
||||
Log::warning('supplier.sync.time_budget_reached', [
|
||||
'processed_until' => $sp->id,
|
||||
'group' => $group['identifier'],
|
||||
]);
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->syncOne($sp, $client);
|
||||
$this->syncGroup($group);
|
||||
$consecutiveTransient = 0;
|
||||
} catch (TierEscalatedException $e) {
|
||||
Log::info("SyncSupplierProjectsJob: group {$group['identifier']} escalated to manual queue #{$e->queueRowId}, reason: {$e->reason}");
|
||||
|
||||
continue;
|
||||
} catch (WindowDeferredException) {
|
||||
Log::info("SyncSupplierProjectsJob: group {$group['identifier']} deferred by portal window");
|
||||
|
||||
continue;
|
||||
} catch (SupplierAuthException $e) {
|
||||
Mail::to((string) config('services.supplier.alert_email'))
|
||||
->queue(new SupplierCriticalAlertMail(
|
||||
@@ -94,7 +159,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
throw $e;
|
||||
} catch (SupplierTransientException $e) {
|
||||
$consecutiveTransient++;
|
||||
$this->logSyncFailure($sp, $e);
|
||||
$this->logGroupFailure($group, $e);
|
||||
if ($consecutiveTransient >= self::MASS_FAIL_THRESHOLD) {
|
||||
Mail::to((string) config('services.supplier.alert_email'))
|
||||
->queue(new SupplierCriticalAlertMail(
|
||||
@@ -107,7 +172,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
|
||||
continue;
|
||||
} catch (SupplierClientException $e) {
|
||||
$this->logSyncFailure($sp, $e);
|
||||
$this->logGroupFailure($group, $e);
|
||||
report($e);
|
||||
|
||||
continue;
|
||||
@@ -115,123 +180,287 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
private function syncOne(SupplierProject $sp, SupplierPortalClient $client): void
|
||||
/**
|
||||
* @param array{signal_type: string, identifier: string, merged_regions: list<int>, has_all_russia: bool, platforms: list<string>, projects: list<Project>} $group
|
||||
*/
|
||||
private function syncGroup(array $group): void
|
||||
{
|
||||
$fkColumn = $this->fkColumnForPlatform($sp->platform);
|
||||
$signalType = $group['signal_type'];
|
||||
$identifier = $group['identifier'];
|
||||
$platforms = $group['platforms'];
|
||||
|
||||
/** @var EloquentCollection<int, Project> $liderraProjects */
|
||||
$liderraProjects = Project::on(self::DB_CONNECTION)
|
||||
->where($fkColumn, $sp->id)
|
||||
->where('is_active', true)
|
||||
/** @var list<Project> $groupProjects */
|
||||
$groupProjects = $group['projects'];
|
||||
|
||||
// Eligible-today: workday-mask for tomorrow
|
||||
$targetDate = Carbon::tomorrow('Europe/Moscow');
|
||||
$targetWeekday = $targetDate->isoWeekday();
|
||||
|
||||
/** @var list<Project> $eligible */
|
||||
$eligible = array_values(array_filter(
|
||||
$groupProjects,
|
||||
fn (Project $p) => ($p->delivery_days_mask & (1 << ($targetWeekday - 1))) !== 0
|
||||
));
|
||||
|
||||
if ($eligible === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute order and union workdays
|
||||
$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) {
|
||||
$workdaysUnion[$d] = $d;
|
||||
}
|
||||
}
|
||||
sort($workdaysUnion);
|
||||
$workdays = $workdaysUnion;
|
||||
|
||||
// Portal constraint: one identifier = one B1/B2/B3 group — pass all regions as a single list.
|
||||
$allRegions = $group['merged_regions'];
|
||||
sort($allRegions);
|
||||
// count=0 → all-Russia; count=1 → named region; count>1 → merged → 'РФ'
|
||||
$tag = count($allRegions) === 1
|
||||
? (RussianRegions::CODE_TO_NAME[$allRegions[0]] ?? (string) $allRegions[0])
|
||||
: 'РФ';
|
||||
|
||||
// Find existing supplier_projects for this group (no subject_code filter)
|
||||
$existingSps = SupplierProject::on(self::DB_CONNECTION)
|
||||
->where('unique_key', $identifier)
|
||||
->where('signal_type', $signalType)
|
||||
->whereIn('platform', $platforms)
|
||||
->get();
|
||||
|
||||
if ($liderraProjects->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if ($existingSps->isEmpty()) {
|
||||
// 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],
|
||||
);
|
||||
|
||||
$adapted = $this->adaptProjectsForAllocator($liderraProjects);
|
||||
$idMap = $this->client->saveProjectMultiFlag($dto);
|
||||
$externalId = $idMap[$platform] ?? null;
|
||||
if ($externalId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$allocation = SupplierQuotaAllocator::allocate(
|
||||
platform: $sp->platform,
|
||||
signalType: $sp->signal_type,
|
||||
uniqueKey: $sp->unique_key,
|
||||
activeLiderraProjects: $adapted,
|
||||
targetDate: Carbon::tomorrow('Europe/Moscow'),
|
||||
);
|
||||
$sp = SupplierProject::on(self::DB_CONNECTION)->forceCreate([
|
||||
'platform' => $platform,
|
||||
'signal_type' => $signalType,
|
||||
'unique_key' => $identifier,
|
||||
'subject_code' => null,
|
||||
'supplier_external_id' => (string) $externalId,
|
||||
'current_limit' => $shares[$platform] ?? 0,
|
||||
'current_workdays' => $workdays,
|
||||
'current_regions' => $allRegions,
|
||||
'sync_status' => 'ok',
|
||||
'last_synced_at' => now(),
|
||||
]);
|
||||
|
||||
if ($allocation === null) {
|
||||
return;
|
||||
}
|
||||
SupplierSyncLog::on(self::DB_CONNECTION)->create([
|
||||
'supplier_project_id' => $sp->id,
|
||||
'action' => 'create',
|
||||
'http_status' => 200,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$current = SupplierProjectDto::fromModel($sp);
|
||||
if ($allocation->equals($current)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$isCreate = $sp->supplier_external_id === null;
|
||||
|
||||
// NOTE: НЕ оборачиваем в DB::transaction() — HTTP-call к supplier выходит за
|
||||
// границы транзакционного контекста, атомарности всё равно нет. Два DB-write
|
||||
// (supplier_project update + supplier_sync_log insert) на одной connection
|
||||
// выполняются последовательно; ошибка между ними — recoverable through retry
|
||||
// на следующем cron-tick'е (supplier_external_id уже записан, скип через equals()).
|
||||
if ($isCreate) {
|
||||
$externalId = $client->saveProject($allocation);
|
||||
$sp->forceFill([
|
||||
'supplier_external_id' => (string) $externalId,
|
||||
'current_limit' => $allocation->limit,
|
||||
'current_workdays' => $allocation->workdays,
|
||||
'current_regions' => $allocation->regions,
|
||||
'sync_status' => 'ok',
|
||||
'last_synced_at' => now(),
|
||||
])->save();
|
||||
$existingSps->push($sp);
|
||||
}
|
||||
} else {
|
||||
$client->updateProject((int) $sp->supplier_external_id, $allocation);
|
||||
$sp->forceFill([
|
||||
'current_limit' => $allocation->limit,
|
||||
'current_workdays' => $allocation->workdays,
|
||||
'current_regions' => $allocation->regions,
|
||||
'sync_status' => 'ok',
|
||||
'last_synced_at' => now(),
|
||||
])->save();
|
||||
// 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
|
||||
// (SupplierAuth/Transient/Client) — full failover-counter semantics сохраняется.
|
||||
$existingPlatforms = $existingSps->pluck('platform')->all();
|
||||
$missingPlatforms = array_values(array_diff($platforms, $existingPlatforms));
|
||||
|
||||
if ($missingPlatforms !== []) {
|
||||
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;
|
||||
}
|
||||
$sp = SupplierProject::on(self::DB_CONNECTION)->forceCreate([
|
||||
'platform' => $platform,
|
||||
'signal_type' => $signalType,
|
||||
'unique_key' => $identifier,
|
||||
'subject_code' => null,
|
||||
'supplier_external_id' => (string) $externalId,
|
||||
'current_limit' => $shares[$platform] ?? 0,
|
||||
'current_workdays' => $workdays,
|
||||
'current_regions' => $allRegions,
|
||||
'sync_status' => 'ok',
|
||||
'last_synced_at' => now(),
|
||||
]);
|
||||
SupplierSyncLog::on(self::DB_CONNECTION)->create([
|
||||
'supplier_project_id' => $sp->id,
|
||||
'action' => 'create',
|
||||
'http_status' => 200,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
$existingSps->push($sp);
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
$perPlatformDto = 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],
|
||||
);
|
||||
$this->channel->updateProject((int) $sp->supplier_external_id, $perPlatformDto);
|
||||
$sp->forceFill([
|
||||
'current_limit' => $shares[$sp->platform] ?? 0,
|
||||
'current_workdays' => $workdays,
|
||||
'current_regions' => $allRegions,
|
||||
'sync_status' => 'ok',
|
||||
'last_synced_at' => now(),
|
||||
])->save();
|
||||
|
||||
SupplierSyncLog::on(self::DB_CONNECTION)->create([
|
||||
'supplier_project_id' => $sp->id,
|
||||
'action' => 'update',
|
||||
'http_status' => 200,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
SupplierSyncLog::on(self::DB_CONNECTION)->create([
|
||||
'supplier_project_id' => $sp->id,
|
||||
'action' => $isCreate ? 'create' : 'update',
|
||||
'http_status' => 200,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
// Pivot: for each contributing Лидерра-project × each supplier_project → ON CONFLICT DO NOTHING
|
||||
foreach ($groupProjects as $lp) {
|
||||
foreach ($existingSps as $sp) {
|
||||
DB::connection(self::DB_CONNECTION)->table('project_supplier_links')->insertOrIgnore([
|
||||
'project_id' => $lp->id,
|
||||
'supplier_project_id' => $sp->id,
|
||||
'platform' => $sp->platform,
|
||||
'subject_code' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function logSyncFailure(SupplierProject $sp, Throwable $e): void
|
||||
/**
|
||||
* Log failure for a group (before any supplier_project is created/updated we don't have sp id,
|
||||
* so we look up existing or skip — best-effort audit).
|
||||
*
|
||||
* @param array{signal_type: string, identifier: string, merged_regions: list<int>, has_all_russia: bool, platforms: list<string>, projects: list<Project>} $group
|
||||
*/
|
||||
private function logGroupFailure(array $group, Throwable $e): void
|
||||
{
|
||||
$httpStatus = null;
|
||||
if ($e instanceof SupplierException) {
|
||||
$httpStatus = $e->httpStatus;
|
||||
}
|
||||
|
||||
SupplierSyncLog::on(self::DB_CONNECTION)->create([
|
||||
'supplier_project_id' => $sp->id,
|
||||
'action' => $sp->supplier_external_id === null ? 'create' : 'update',
|
||||
'http_status' => $httpStatus,
|
||||
'error_message' => substr($e->getMessage(), 0, 1000),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
// Find any existing sp row for the group to link log entry (no subject_code filter)
|
||||
$sp = SupplierProject::on(self::DB_CONNECTION)
|
||||
->where('unique_key', $group['identifier'])
|
||||
->where('signal_type', $group['signal_type'])
|
||||
->first();
|
||||
|
||||
if ($sp !== null) {
|
||||
SupplierSyncLog::on(self::DB_CONNECTION)->create([
|
||||
'supplier_project_id' => $sp->id,
|
||||
'action' => $sp->supplier_external_id === null ? 'create' : 'update',
|
||||
'http_status' => $httpStatus,
|
||||
'error_message' => substr($e->getMessage(), 0, 1000),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Адаптер Eloquent Project → stdClass с полями daily_limit/workdays/regions,
|
||||
* которые ожидает SupplierQuotaAllocator (pure function, не вяжется к Eloquent).
|
||||
*
|
||||
* Маппинг:
|
||||
* daily_limit ← daily_limit_target
|
||||
* workdays ← биты delivery_days_mask (bit 0=Пн, …, bit 6=Вс) → ISO 1..7
|
||||
* regions ← биты region_mask (bit 0=Центральный, …, bit 7=Дальневосточный) → 1..8
|
||||
*
|
||||
* @param EloquentCollection<int, Project> $projects
|
||||
* @return Collection<int, stdClass>
|
||||
*/
|
||||
private function adaptProjectsForAllocator(EloquentCollection $projects): Collection
|
||||
{
|
||||
return $projects->map(function (Project $p): stdClass {
|
||||
$obj = new stdClass;
|
||||
$obj->daily_limit = (int) $p->daily_limit_target;
|
||||
$obj->workdays = $this->bitmaskToList((int) $p->delivery_days_mask, 7);
|
||||
|
||||
// region_mask=255 (все 8 ФО, default) — catch-all семантика → пустой массив
|
||||
// у supplier ("без региональных ограничений"). Иначе — список выставленных битов.
|
||||
$regionMask = (int) $p->region_mask;
|
||||
$obj->regions = $regionMask === 255
|
||||
? []
|
||||
: $this->bitmaskToList($regionMask, 8);
|
||||
|
||||
return $obj;
|
||||
})->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bitmask → ordered list 1..maxBits для bits, выставленных в 1.
|
||||
* Bitmask → ordered list 1..maxBits.
|
||||
*
|
||||
* @return array<int, int>
|
||||
*/
|
||||
@@ -246,14 +475,4 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function fkColumnForPlatform(string $platform): string
|
||||
{
|
||||
return match ($platform) {
|
||||
'B1' => 'supplier_b1_project_id',
|
||||
'B2' => 'supplier_b2_project_id',
|
||||
'B3' => 'supplier_b3_project_id',
|
||||
default => throw new \InvalidArgumentException("Unknown supplier platform: {$platform}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,28 +5,52 @@ declare(strict_types=1);
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Services\Supplier\Channel\Exceptions\TierEscalatedException;
|
||||
use App\Services\Supplier\Channel\Exceptions\WindowDeferredException;
|
||||
use App\Services\Supplier\Channel\FailoverProjectChannel;
|
||||
use App\Services\Supplier\Channel\SupplierProjectChannel;
|
||||
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;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Синхронизирует Лидерра-проект с supplier_projects на B1/B2/B3
|
||||
* в зависимости от signal_type.
|
||||
* в зависимости от signal_type и текущего SupplierExportMode.
|
||||
*
|
||||
* Семантика:
|
||||
* site / call → B1 + B2 + B3
|
||||
* sms с keyword → B2 + B3
|
||||
* sms без keyword → B3
|
||||
* Режимы:
|
||||
* online → для каждой (subject × platform-set) группы проекта:
|
||||
* saveProjectMultiFlag с полными параметрами (limit, regions, tag)
|
||||
* → upsert supplier_projects + pivot project_supplier_links.
|
||||
* batch → «каркас»: создаёт supplier_projects с limit=0, без регионов
|
||||
* (старый путь); ночной SyncSupplierProjectsJob дольёт полные параметры.
|
||||
*
|
||||
* Записывает полученные supplier_projects.id в projects.supplier_b{1,2,3}_project_id.
|
||||
* Канал миграции:
|
||||
* batch mode — SupplierProjectChannel (FailoverProjectChannel: ярус 1 AJAX
|
||||
* → ярус 2 browser-form → ярус 3 manual queue) для createProject.
|
||||
* online mode — multi-flag save идёт напрямую через SupplierPortalClient
|
||||
* (tier-1 AJAX only — multi-flag нет в tier-2 form по архитектуре
|
||||
* портала). При любом transient/auth fail → log warning + skip
|
||||
* subject; Laravel retry (tries=3 backoff [15s,60s,300s]) → ночной
|
||||
* SyncSupplierProjectsJob подберёт с полным failover каналом.
|
||||
* updateProject в online остаётся через $channel (полная схема failover).
|
||||
* При эскалации на ярус 3 / переносе по окну портала — platform/subject пропускается
|
||||
* (FK/pivot остаётся пустым; ночной SyncSupplierProjectsJob восстанавливает).
|
||||
*
|
||||
* Retry: 3 попытки с backoff [15s, 60s, 300s].
|
||||
*
|
||||
* Spec: docs/superpowers/plans/2026-05-11-plan5-frontend-projects-ui-plan.md Task 4
|
||||
* Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §5
|
||||
* Plan: docs/superpowers/plans/2026-05-20-project-migration-redesign-plan-3-export.md Task 6
|
||||
*/
|
||||
class SyncSupplierProjectJob implements ShouldQueue
|
||||
{
|
||||
@@ -37,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(SupplierPortalClient $client): void
|
||||
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");
|
||||
@@ -49,57 +85,327 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
return;
|
||||
}
|
||||
|
||||
$platforms = $this->resolvePlatforms($project);
|
||||
if (SupplierExportMode::isOnline()) {
|
||||
$this->handleOnline($project, $channel);
|
||||
} else {
|
||||
$this->handleBatch($project, $channel);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Online mode: per-subject full-param sync
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private function handleOnline(Project $project, SupplierProjectChannel $channel): void
|
||||
{
|
||||
$client = app(SupplierPortalClient::class);
|
||||
|
||||
$platforms = SupplierProjectGrouping::resolvePlatforms($project);
|
||||
if ($platforms === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$identifier = SupplierProjectGrouping::buildUniqueKey($project, $platforms[0]);
|
||||
|
||||
// Portal constraint: one identifier = one B1/B2/B3 group (status=Doubles on duplicate name).
|
||||
// Pass all project regions as a single group — no per-subject split.
|
||||
$allRegions = array_map('intval', (array) ($project->regions ?? []));
|
||||
// count=0 → all-Russia; count=1 → named region; count>1 → merged → 'РФ'
|
||||
$tag = count($allRegions) === 1
|
||||
? (RussianRegions::CODE_TO_NAME[$allRegions[0]] ?? (string) $allRegions[0])
|
||||
: 'РФ';
|
||||
|
||||
$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::on(self::DB_CONNECTION)
|
||||
->where('unique_key', $identifier)
|
||||
->where('signal_type', (string) $project->signal_type)
|
||||
->whereIn('platform', $platforms)
|
||||
->get();
|
||||
|
||||
if ($existingSps->isEmpty()) {
|
||||
// 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;
|
||||
if ($externalId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$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' => $shares[$platform] ?? 0,
|
||||
'current_workdays' => $workdays,
|
||||
'current_regions' => $allRegions,
|
||||
'sync_status' => 'ok',
|
||||
'last_synced_at' => now(),
|
||||
]);
|
||||
|
||||
$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 !== []) {
|
||||
$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::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' => $shares[$platform] ?? 0,
|
||||
'current_workdays' => $workdays,
|
||||
'current_regions' => $allRegions,
|
||||
'sync_status' => 'ok',
|
||||
'last_synced_at' => now(),
|
||||
]);
|
||||
$existingSps->push($sp);
|
||||
}
|
||||
}
|
||||
|
||||
// Update existing supplier projects with current regions/limit.
|
||||
foreach ($existingSps as $sp) {
|
||||
if ($sp->supplier_external_id === null) {
|
||||
continue;
|
||||
}
|
||||
$perPlatformDto = new SupplierProjectDto(
|
||||
platform: $sp->platform,
|
||||
signalType: (string) $project->signal_type,
|
||||
uniqueKey: $identifier,
|
||||
limit: $shares[$sp->platform] ?? 0,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: [$sp->platform],
|
||||
);
|
||||
$channel->updateProject((int) $sp->supplier_external_id, $perPlatformDto);
|
||||
$sp->forceFill([
|
||||
'current_limit' => $shares[$sp->platform] ?? 0,
|
||||
'current_workdays' => $workdays,
|
||||
'current_regions' => $allRegions,
|
||||
'sync_status' => 'ok',
|
||||
'last_synced_at' => now(),
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
|
||||
// Pivot: project × each supplier_project → ON CONFLICT DO NOTHING
|
||||
foreach ($existingSps as $sp) {
|
||||
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();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Batch mode: каркас (limit=0, no regions) — backward-compat
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private function handleBatch(Project $project, SupplierProjectChannel $channel): void
|
||||
{
|
||||
$platforms = SupplierProjectGrouping::resolvePlatforms($project);
|
||||
$workdays = $this->workdaysFromMask((int) $project->delivery_days_mask);
|
||||
|
||||
foreach ($platforms as $platform) {
|
||||
$uniqueKey = $this->buildUniqueKey($project, $platform);
|
||||
$supplierProjectId = $client->ensureSupplierProject($platform, $project->signal_type, $uniqueKey);
|
||||
$uniqueKey = SupplierProjectGrouping::buildUniqueKey($project, $platform);
|
||||
$column = 'supplier_'.strtolower($platform).'_project_id';
|
||||
$project->{$column} = $supplierProjectId;
|
||||
|
||||
// Idempotency: local supplier_projects-запись уже есть?
|
||||
$existing = SupplierProject::on(self::DB_CONNECTION)
|
||||
->where('platform', $platform)
|
||||
->where('signal_type', $project->signal_type)
|
||||
->where('unique_key', $uniqueKey)
|
||||
->first();
|
||||
|
||||
if ($existing !== null) {
|
||||
$project->{$column} = $existing->id;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: $platform,
|
||||
signalType: (string) $project->signal_type,
|
||||
uniqueKey: $uniqueKey,
|
||||
limit: 0,
|
||||
workdays: $workdays,
|
||||
regions: [],
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
);
|
||||
|
||||
try {
|
||||
$externalId = $channel instanceof FailoverProjectChannel
|
||||
? $channel->createProjectForLiderra($project, $dto)
|
||||
: $channel->createProject($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;
|
||||
}
|
||||
|
||||
$sp = SupplierProject::on(self::DB_CONNECTION)->create([
|
||||
'platform' => $platform,
|
||||
'signal_type' => $project->signal_type,
|
||||
'unique_key' => $uniqueKey,
|
||||
'supplier_external_id' => (string) $externalId,
|
||||
'current_limit' => 0,
|
||||
'current_workdays' => $workdays,
|
||||
'current_regions' => null,
|
||||
'sync_status' => 'ok',
|
||||
]);
|
||||
|
||||
$project->{$column} = $sp->id;
|
||||
}
|
||||
|
||||
$project->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает список uppercase platform-кодов для данного project.
|
||||
* Коды соответствуют CHECK constraint: 'B1' / 'B2' / 'B3'.
|
||||
* Создаёт проекты на портале ПО ОДНОМУ на платформу с её долей лимита ($shares).
|
||||
*
|
||||
* @return array<int, string>
|
||||
* Один 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 resolvePlatforms(Project $project): array
|
||||
{
|
||||
if (in_array($project->signal_type, ['site', 'call'], true)) {
|
||||
return ['B1', 'B2', 'B3'];
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
||||
if ($project->signal_type === 'sms') {
|
||||
return $project->sms_keyword ? ['B2', 'B3'] : ['B3'];
|
||||
}
|
||||
|
||||
return [];
|
||||
return $idMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Строит unique_key для пары (project, platform):
|
||||
* site/call → signal_identifier (домен / телефон)
|
||||
* sms B2 → sender + '+' + keyword
|
||||
* sms B3 → sender
|
||||
* Bitmask → ISO weekday list. bit 0 = Mon (ISO 1) … bit 6 = Sun (ISO 7).
|
||||
*
|
||||
* Mirror of SyncSupplierProjectsJob::bitmaskToList(). Kept inline (not
|
||||
* extracted to a shared helper) to keep this fix surgical.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function buildUniqueKey(Project $project, string $platform): string
|
||||
private function workdaysFromMask(int $mask): array
|
||||
{
|
||||
if (in_array($project->signal_type, ['site', 'call'], true)) {
|
||||
return (string) $project->signal_identifier;
|
||||
$out = [];
|
||||
for ($i = 0; $i < 7; $i++) {
|
||||
if (($mask & (1 << $i)) !== 0) {
|
||||
$out[] = $i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// sms
|
||||
$sender = (string) ($project->sms_senders[0] ?? '');
|
||||
|
||||
if ($platform === 'B2') {
|
||||
return $sender.'+'.($project->sms_keyword ?? '');
|
||||
}
|
||||
|
||||
// B3
|
||||
return $sender;
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ class Deal extends Model
|
||||
'utm_campaign',
|
||||
'utm_content',
|
||||
'region_code',
|
||||
'subject_code',
|
||||
'city',
|
||||
'time_in_form_seconds',
|
||||
'lead_score',
|
||||
@@ -72,6 +73,7 @@ class Deal extends Model
|
||||
'duplicate_of_id' => 'integer',
|
||||
'escalated_count' => 'integer',
|
||||
'time_in_form_seconds' => 'integer',
|
||||
'subject_code' => 'integer',
|
||||
'lead_score' => 'decimal:2',
|
||||
'phones' => 'array',
|
||||
'is_test' => 'boolean',
|
||||
|
||||
+18
-31
@@ -4,12 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Casts\PostgresIntArray;
|
||||
use Carbon\CarbonInterface;
|
||||
use Database\Factories\ProjectFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
@@ -38,13 +40,14 @@ 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',
|
||||
'region_mask',
|
||||
'region_mode',
|
||||
// Plan 6 (schema v8.20): Subject-level regions array (89 codes из resources/js/constants/regions.ts).
|
||||
// Источник истины с Plan 6+; region_mask/region_mode — DEPRECATED (Plan 6.5 cleanup).
|
||||
'regions',
|
||||
'delivery_days_mask',
|
||||
'assignment_strategy',
|
||||
'ttfr_target_minutes',
|
||||
@@ -69,6 +72,10 @@ class Project extends Model
|
||||
'daily_limit_target' => 'integer',
|
||||
'effective_daily_limit_today' => 'integer',
|
||||
'region_mask' => 'integer',
|
||||
// Plan 6: Subject-level regions array (89 codes). Используется кастомный
|
||||
// PostgresIntArray cast — Laravel stock 'array' посылает JSON `[1,2,3]`,
|
||||
// что Postgres отвергает на INT[] (ожидает literal `{1,2,3}`).
|
||||
'regions' => PostgresIntArray::class,
|
||||
'delivery_days_mask' => 'integer',
|
||||
'ttfr_target_minutes' => 'integer',
|
||||
'effective_limit_calculated_at' => 'datetime',
|
||||
@@ -78,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',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -107,6 +112,15 @@ class Project extends Model
|
||||
return $this->belongsTo(SupplierProject::class, 'supplier_b3_project_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<SupplierProject, $this>
|
||||
*/
|
||||
public function supplierProjects(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(SupplierProject::class, 'project_supplier_links')
|
||||
->withPivot(['platform', 'subject_code']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Активные проекты, у которых сегодняшний день включён в delivery_days_mask.
|
||||
*
|
||||
@@ -133,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 отношений.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Очередь яруса 3 резерва канала миграции проектов.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.5
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $project_id
|
||||
* @property string $platform
|
||||
* @property string $operation
|
||||
* @property string|null $external_id
|
||||
* @property array<string, mixed> $payload_snapshot
|
||||
* @property string $failure_reason
|
||||
* @property string $status
|
||||
* @property int|null $resolved_by_user_id
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $resolved_at
|
||||
*/
|
||||
class SupplierManualSyncQueue extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'supplier_manual_sync_queue';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'project_id', 'platform', 'operation', 'external_id',
|
||||
'payload_snapshot', 'failure_reason', 'status',
|
||||
'resolved_by_user_id', 'created_at', 'resolved_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'payload_snapshot' => 'array',
|
||||
'created_at' => 'datetime',
|
||||
'resolved_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
}
|
||||
|
||||
public function resolver(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'resolved_by_user_id');
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use Database\Factories\SupplierProjectFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
/**
|
||||
* Supplier-уровневый агрегат проекта у поставщика crm.bp-gr.ru.
|
||||
@@ -40,6 +41,7 @@ class SupplierProject extends Model
|
||||
'sync_status',
|
||||
'last_synced_at',
|
||||
'inactive_since',
|
||||
'subject_code',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
@@ -50,6 +52,7 @@ class SupplierProject extends Model
|
||||
'current_limit' => 'integer',
|
||||
'last_synced_at' => 'datetime',
|
||||
'inactive_since' => 'datetime',
|
||||
'subject_code' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -81,6 +84,15 @@ class SupplierProject extends Model
|
||||
return $query->where('signal_type', $signalType)->where('unique_key', $uniqueKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<Project, $this>
|
||||
*/
|
||||
public function projects(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Project::class, 'project_supplier_links')
|
||||
->withPivot(['platform', 'subject_code']);
|
||||
}
|
||||
|
||||
protected static function newFactory(): SupplierProjectFactory
|
||||
{
|
||||
return SupplierProjectFactory::new();
|
||||
|
||||
@@ -2,8 +2,13 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Services\Supplier\Channel\AjaxProjectChannel;
|
||||
use App\Services\Supplier\Channel\FailoverProjectChannel;
|
||||
use App\Services\Supplier\Channel\FormProjectChannel;
|
||||
use App\Services\Supplier\Channel\SupplierProjectChannel;
|
||||
use App\Services\Supplier\ProcessFactory;
|
||||
use App\Services\Supplier\SymfonyProcessFactory;
|
||||
use Illuminate\Contracts\Mail\Mailer;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
@@ -17,6 +22,18 @@ class AppServiceProvider extends ServiceProvider
|
||||
ProcessFactory::class,
|
||||
SymfonyProcessFactory::class,
|
||||
);
|
||||
|
||||
// Резерв канала миграции проектов: SupplierProjectChannel резолвится в
|
||||
// декоратор-оркестратор (ярус 1 AJAX → ярус 2 browser-form → ярус 3 queue).
|
||||
// Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.4
|
||||
$this->app->bind(
|
||||
SupplierProjectChannel::class,
|
||||
fn ($app) => new FailoverProjectChannel(
|
||||
$app->make(AjaxProjectChannel::class),
|
||||
$app->make(FormProjectChannel::class),
|
||||
$app->make(Mailer::class),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -105,7 +105,7 @@ final class HistoricalImportService
|
||||
}
|
||||
|
||||
/**
|
||||
* Маппит статус: каноническая таблица §6.4 → tenant-override → fallback 'new'.
|
||||
* Маппит статус: StatusRuToSlugMapper → tenant-override → fallback 'new'.
|
||||
* Неизвестный статус инкрементит счётчик в $unknown по ссылке.
|
||||
*
|
||||
* @param array<string, string> $overrides
|
||||
|
||||
@@ -5,29 +5,36 @@ declare(strict_types=1);
|
||||
namespace App\Services\Import;
|
||||
|
||||
/**
|
||||
* Маппинг русских названий статусов воронки в slug (ТЗ §6.4).
|
||||
* Маппинг русских названий статусов (старые 14 названий поставщика + новые 5)
|
||||
* в slug 5-статусной воронки (редизайн 2026-05-17).
|
||||
*
|
||||
* Чистый сервис без зависимостей. Tenant-специфичные переопределения
|
||||
* неизвестных статусов накладываются вызывающим кодом (HistoricalImportService).
|
||||
*/
|
||||
class StatusRuToSlugMapper
|
||||
{
|
||||
/** @var array<string, string> Канонический маппинг ТЗ §6.4 (14 статусов воронки). */
|
||||
/** @var array<string, string> Русские названия → 5 slug'ов воронки (редизайн 2026-05-17). */
|
||||
private const STATUS_RU_TO_SLUG = [
|
||||
'Новые' => 'new',
|
||||
// Новые названия 5-статусной воронки.
|
||||
'Новая сделка' => 'new',
|
||||
'Просмотрено' => 'viewed',
|
||||
'Проработан' => 'worked',
|
||||
'База' => 'base',
|
||||
'Недозвон' => 'missed',
|
||||
'Переговоры' => 'negotiations',
|
||||
'Ожидаем оплаты' => 'waiting_payment',
|
||||
'Партнерка' => 'partnership',
|
||||
'Оплачено' => 'paid',
|
||||
'Закрыто и не реализовано' => 'closed',
|
||||
'Тест драйв' => 'test_drive',
|
||||
'Горячий' => 'hot',
|
||||
'На замену' => 'replacement',
|
||||
'Конечный недозвон' => 'final_missed',
|
||||
'В работе' => 'in_progress',
|
||||
'Сделка' => 'won',
|
||||
'Не реализовано' => 'lost',
|
||||
// Старые 14 названий поставщика → новые slug'и (исторический CSV-импорт).
|
||||
'Новые' => 'new',
|
||||
'Проработан' => 'in_progress',
|
||||
'База' => 'in_progress',
|
||||
'Недозвон' => 'in_progress',
|
||||
'Переговоры' => 'in_progress',
|
||||
'Ожидаем оплаты' => 'in_progress',
|
||||
'Партнерка' => 'in_progress',
|
||||
'Оплачено' => 'won',
|
||||
'Закрыто и не реализовано' => 'lost',
|
||||
'Тест драйв' => 'in_progress',
|
||||
'Горячий' => 'in_progress',
|
||||
'На замену' => 'in_progress',
|
||||
'Конечный недозвон' => 'in_progress',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -39,7 +46,8 @@ class StatusRuToSlugMapper
|
||||
}
|
||||
|
||||
/**
|
||||
* Полная каноническая таблица — для UI wizard'а (показать варианты).
|
||||
* Полная таблица соответствия: русское название → slug 5-статусной воронки
|
||||
* (18 ключей — старые и новые названия схлопываются в 5 slug'ов).
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Random\Randomizer;
|
||||
|
||||
/**
|
||||
* Отбор получателей входящего лида: ≤ CAP случайных из eligible (sharing cap).
|
||||
*
|
||||
* cap=3 — защита владельца номера-донора (лид продаётся максимум 3 раза).
|
||||
* Eligible уже отфильтрован LeadRouter (есть остаток лимита) → отбор лимит не
|
||||
* превышает. Рандом через инъектируемый \Random\Randomizer (тесты сидируют
|
||||
* Mt19937 для детерминизма; прод — CSPRNG по умолчанию).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.6.
|
||||
*/
|
||||
final class LeadDistributor
|
||||
{
|
||||
public const CAP = 3;
|
||||
|
||||
public function __construct(private readonly Randomizer $randomizer = new Randomizer) {}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
*
|
||||
* @param Collection<int, T> $eligible
|
||||
* @return Collection<int, T>
|
||||
*/
|
||||
public function selectRecipients(Collection $eligible): Collection
|
||||
{
|
||||
$items = $eligible->values()->all();
|
||||
|
||||
if (count($items) <= self::CAP) {
|
||||
return collect($items);
|
||||
}
|
||||
|
||||
$keys = $this->randomizer->pickArrayKeys($items, self::CAP);
|
||||
|
||||
return collect($keys)->map(fn (int $k) => $items[$k])->values();
|
||||
}
|
||||
}
|
||||
@@ -8,70 +8,45 @@ use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Подбор eligible Лидерра-проектов для входящего лида (sharing-model §6).
|
||||
*
|
||||
* Алгоритм:
|
||||
* 1. SELECT projects WHERE supplier_b{1,2,3}_project_id = $supplier->id (по platform).
|
||||
* 2. Фильтр: is_active=true.
|
||||
* 3. Workdays: (delivery_days_mask & today_bit) <> 0, today_bit = 1 << (ISO_DOW - 1).
|
||||
* 4. delivered_today < COALESCE(effective_daily_limit_today, daily_limit_target).
|
||||
* 5. tenants.balance_leads > 0 OR tenants.balance_rub > 0 (через WHERE EXISTS;
|
||||
* Plan 4 Task 4: dual-balance — rub-only tenant ДОЛЖЕН пройти, LedgerService
|
||||
* сам резолвит prepaid/rub и кидает InsufficientBalanceException, если оба = 0).
|
||||
* 6. Region match через PhonePrefixService::phoneMatchesRegions (в PHP, не в SQL —
|
||||
* district-bit резолвится по 3/4-значному коду в PHP-словаре).
|
||||
* 7. Сортировка: created_at ASC, id ASC (детерминированно — spec §6 step 4).
|
||||
* Eligibility — структурно через pivot project_supplier_links: проект eligible,
|
||||
* если связан с пришедшим supplier_project (= источник × субъект) + активен +
|
||||
* сегодня рабочий день + есть остаток лимита + у тенанта есть баланс.
|
||||
*
|
||||
* Plan 3 Task 3: запрос идёт через connection `pgsql_supplier` (BYPASSRLS-роль
|
||||
* crm_supplier_worker). Это закрывает WARN #2 — в sharing-flow tenant ещё не
|
||||
* определён, SELECT обходит RLS-фильтрацию и видит проекты ВСЕХ tenant'ов
|
||||
* параллельно. WHERE-фильтры (is_active, FK на supplier_project, workdays, лимиты,
|
||||
* balance) сохраняются как defense-in-depth.
|
||||
* Регион сопоставляется самим supplier_project (тег = субъект) — phone-prefix
|
||||
* фильтр убран (эпик миграции проектов, Q5): для мобильных он no-op, а регион
|
||||
* гарантирован тем, через какой supplier_project пришёл лид.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §6 +
|
||||
* docs/superpowers/specs/2026-05-11-plan3-supplier-sync-design.md §1.
|
||||
* Запрос через connection pgsql_supplier (BYPASSRLS crm_supplier_worker) — в
|
||||
* sharing-flow tenant ещё не определён, SELECT видит проекты всех tenant'ов.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.5.
|
||||
*/
|
||||
class LeadRouter
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PhonePrefixService $phonePrefix,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Project>
|
||||
*/
|
||||
public function matchEligibleProjects(SupplierProject $supplierProject, string $phone): Collection
|
||||
public function matchEligibleProjects(SupplierProject $supplierProject): Collection
|
||||
{
|
||||
$fkColumn = match ($supplierProject->platform) {
|
||||
'B1' => 'supplier_b1_project_id',
|
||||
'B2' => 'supplier_b2_project_id',
|
||||
'B3' => 'supplier_b3_project_id',
|
||||
// Unreachable per CHECK chk_supplier_projects_platform; defensive for static analysis.
|
||||
default => throw new InvalidArgumentException(
|
||||
"Unknown supplier platform: {$supplierProject->platform}"
|
||||
),
|
||||
};
|
||||
|
||||
// МСК-aligned ISO day-of-week: Plan 2 Task 9 reset cron also runs at 00:00 МСК,
|
||||
// so workday-mask check must use same timezone to avoid off-by-one near midnight.
|
||||
// МСК-aligned ISO day-of-week (reset-cron тоже 00:00 МСК).
|
||||
$todayBit = 1 << (Carbon::now('Europe/Moscow')->isoWeekday() - 1);
|
||||
|
||||
/** @var Collection<int, Project> $candidates */
|
||||
$candidates = Project::on('pgsql_supplier')
|
||||
->where($fkColumn, $supplierProject->id)
|
||||
->whereExists(function ($q) use ($supplierProject): void {
|
||||
$q->selectRaw('1')
|
||||
->from('project_supplier_links')
|
||||
->whereColumn('project_supplier_links.project_id', 'projects.id')
|
||||
->where('project_supplier_links.supplier_project_id', $supplierProject->id);
|
||||
})
|
||||
->where('is_active', true)
|
||||
->whereRaw('(delivery_days_mask & ?) <> 0', [$todayBit])
|
||||
->whereRaw(
|
||||
'delivered_today < COALESCE(effective_daily_limit_today, daily_limit_target)'
|
||||
)
|
||||
->whereRaw('delivered_today < COALESCE(effective_daily_limit_today, daily_limit_target)')
|
||||
->whereExists(function ($q): void {
|
||||
// Plan 4 Task 4: dual-balance — допускаем rub-only tenant'ов.
|
||||
// LedgerService::chargeForDelivery сам выбирает prepaid (balance_leads--)
|
||||
// или rub (balance_rub -= tier_price) и кидает InsufficientBalanceException,
|
||||
// если ОБА = 0. До Plan 4 фильтр был строгий balance_leads > 0 (prepaid only).
|
||||
$q->selectRaw('1')
|
||||
->from('tenants')
|
||||
->whereColumn('tenants.id', 'projects.tenant_id')
|
||||
@@ -84,12 +59,6 @@ class LeadRouter
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
return $candidates->filter(
|
||||
fn (Project $p): bool => $this->phonePrefix->phoneMatchesRegions(
|
||||
$phone,
|
||||
(int) $p->region_mask,
|
||||
(string) $p->region_mode,
|
||||
)
|
||||
)->values();
|
||||
return $candidates->values();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,21 +4,23 @@ 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
|
||||
{
|
||||
public function update(Project $project, array $data): Project
|
||||
{
|
||||
// Immutable fields — silently drop (don't 422)
|
||||
// signal_identifier — теперь editable (18.05.2026 ux), валидируется в UpdateProjectRequest.
|
||||
unset(
|
||||
$data['tenant_id'], $data['signal_type'], $data['signal_identifier'],
|
||||
$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) {
|
||||
@@ -31,7 +33,26 @@ class ProjectService
|
||||
], 422));
|
||||
}
|
||||
|
||||
$needsResync = array_key_exists('sms_senders', $data) || array_key_exists('sms_keyword', $data);
|
||||
// Resync на смену источник-несущих полей, регионов, лимита и дней недели —
|
||||
// поставщик должен видеть актуальные параметры сразу, не дожидаясь ночного батча.
|
||||
$needsResync = array_key_exists('sms_senders', $data)
|
||||
|| array_key_exists('sms_keyword', $data)
|
||||
|| array_key_exists('signal_identifier', $data)
|
||||
|| array_key_exists('regions', $data)
|
||||
|| 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);
|
||||
|
||||
@@ -42,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
|
||||
@@ -75,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,
|
||||
};
|
||||
}
|
||||
@@ -100,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),
|
||||
@@ -114,15 +143,64 @@ 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[].
|
||||
*
|
||||
* Для каждого проекта: regions := unique(regions ∪ add_regions) \ remove_regions,
|
||||
* отсортировано по возрастанию. `regions[]` — источник истины региональной
|
||||
* фильтрации с Plan 6 (outbound SyncSupplierProjectsJob читает именно его).
|
||||
* Legacy `region_mask` здесь не трогается — как и в одиночном PATCH
|
||||
* /api/projects/{id}; его удаление — Plan 6.5 cleanup.
|
||||
*
|
||||
* NB: проект с regions=[] («вся РФ») при add_regions сужается до выбранных
|
||||
* субъектов — это осознанное действие оператора bulk-диалога.
|
||||
*
|
||||
* Обновление идёт через model-инстанс (не query-builder mass update): каст
|
||||
* PostgresIntArray::set() сериализует PHP-массив в PG-литерал `{1,2,3}`, а
|
||||
* mass update каст не применяет. count ≤ BULK_MAX (500) — допустимо.
|
||||
*/
|
||||
private function bulkUpdateRegions($query, array $payload): array
|
||||
{
|
||||
$add = (int) ($payload['add'] ?? 0);
|
||||
$remove = (int) ($payload['remove'] ?? 0);
|
||||
$add = array_map('intval', $payload['add_regions'] ?? []);
|
||||
$remove = array_map('intval', $payload['remove_regions'] ?? []);
|
||||
|
||||
// region_mask = (region_mask | add) & ~remove, clamped to 8 bits (0–255)
|
||||
$updated = $query->update([
|
||||
'region_mask' => \DB::raw("(region_mask | {$add}) & ~{$remove} & 255"),
|
||||
]);
|
||||
if ($add === [] && $remove === []) {
|
||||
return ['updated' => 0, 'skipped' => [], 'warnings' => []];
|
||||
}
|
||||
|
||||
$projects = (clone $query)->get(['id', 'regions']);
|
||||
$updated = 0;
|
||||
|
||||
foreach ($projects as $project) {
|
||||
$next = array_values(array_unique([...($project->regions ?? []), ...$add]));
|
||||
$next = array_values(array_diff($next, $remove));
|
||||
sort($next);
|
||||
$project->update(['regions' => $next]);
|
||||
$updated++;
|
||||
}
|
||||
|
||||
return ['updated' => $updated, 'skipped' => [], 'warnings' => []];
|
||||
}
|
||||
@@ -179,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}). Смените тариф.",
|
||||
@@ -191,6 +319,15 @@ class ProjectService
|
||||
|
||||
$data['tenant_id'] = $tenant->id;
|
||||
$data['is_active'] = true;
|
||||
$data['regions'] = $data['regions'] ?? [];
|
||||
// Plan 6 dual-write: regions[] источник истины; region_mask/mode — legacy для
|
||||
// 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,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Support\RussianRegions;
|
||||
|
||||
/**
|
||||
* Резолвит регион-тег поставщика (raw_payload['tag'] = имя субъекта или «РФ»)
|
||||
* в код субъекта 1..89. «РФ»/пусто/неизвестно → null (пул «Вся РФ»/неизвестно).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.4.
|
||||
*/
|
||||
final class RegionTagResolver
|
||||
{
|
||||
public function resolve(string $tag): ?int
|
||||
{
|
||||
$tag = trim($tag);
|
||||
|
||||
if ($tag === '' || $tag === 'РФ') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return RussianRegions::nameToCode()[$tag] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,8 @@ use Illuminate\Support\Facades\DB;
|
||||
* managers_summary — агрегат сделок по менеджерам за период (audit F1).
|
||||
*
|
||||
* Группировка по deals.manager_id; неназначенные (manager_id IS NULL) сводятся
|
||||
* в строку «Не назначен». «Оплачено» = status='paid' (won-статус воронки, как
|
||||
* в DashboardController). Конверсия = paid / total * 100, округление до 0.1.
|
||||
* в строку «Не назначен». «Оплачено» = status='won' (won-статус воронки, как
|
||||
* в DashboardController). Конверсия = won / total * 100, округление до 0.1.
|
||||
*
|
||||
* parameters: date_from, date_to (Y-m-d). Исключаются soft-deleted
|
||||
* (deleted_at IS NULL) и тестовые (is_test=false) сделки. RLS-обёртка
|
||||
@@ -48,7 +48,7 @@ class ManagersSummaryProvider implements ReportDataProvider
|
||||
"deals.manager_id,
|
||||
users.first_name, users.last_name, users.email,
|
||||
COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE deals.status = 'paid') AS paid"
|
||||
COUNT(*) FILTER (WHERE deals.status = 'won') AS paid"
|
||||
)
|
||||
->get();
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ use Illuminate\Support\Facades\DB;
|
||||
* sources_summary — агрегат сделок по источнику (utm_source) за период (audit F1).
|
||||
*
|
||||
* Группировка по deals.utm_source; сделки без метки (NULL/пусто) сводятся в
|
||||
* строку «Прямые / без метки». «Оплачено» = status='paid'. Конверсия =
|
||||
* paid / total * 100, округление до 0.1.
|
||||
* строку «Прямые / без метки». «Оплачено» = status='won'. Конверсия =
|
||||
* won / total * 100, округление до 0.1.
|
||||
*
|
||||
* parameters: date_from, date_to (Y-m-d). Исключаются soft-deleted и тестовые
|
||||
* сделки. RLS-обёртка SET LOCAL app.current_tenant_id — паттерн DealsExportProvider.
|
||||
@@ -45,7 +45,7 @@ class SourcesSummaryProvider implements ReportDataProvider
|
||||
->selectRaw(
|
||||
"utm_source,
|
||||
COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE status = 'paid') AS paid"
|
||||
COUNT(*) FILTER (WHERE status = 'won') AS paid"
|
||||
)
|
||||
->get();
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Supplier\Channel;
|
||||
|
||||
use App\Services\Supplier\Dto\SupplierProjectDto;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
|
||||
/**
|
||||
* Ярус 1: тонкий адаптер над SupplierPortalClient (rt-project-* AJAX).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.2
|
||||
*/
|
||||
final class AjaxProjectChannel implements SupplierProjectChannel
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SupplierPortalClient $client,
|
||||
) {}
|
||||
|
||||
public function createProject(SupplierProjectDto $dto): int
|
||||
{
|
||||
return $this->client->saveProject($dto);
|
||||
}
|
||||
|
||||
public function updateProject(int $externalId, SupplierProjectDto $dto): void
|
||||
{
|
||||
$this->client->updateProject($externalId, $dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Сырые rt-строки портала → контрактная форма SupplierProjectChannel.
|
||||
*
|
||||
* Портал не отдаёт platform/signal_type/unique_key напрямую. Маппинг
|
||||
* (verified live 2026-05-19, см. SupplierPortalClient::listProjects docblock):
|
||||
* - platform ← префикс name "B<n>_..." (B1/B2/B3); иначе null;
|
||||
* - signal_type ← type: hosts→site, calls→call, sms→sms;
|
||||
* - unique_key ← content (домен / телефон / sender).
|
||||
* Сырые поля остаются (id, tag, name, type, content, ...) — для дебага.
|
||||
*/
|
||||
public function listProjects(): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($this->client->listProjects() as $row) {
|
||||
if (! is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = (string) ($row['name'] ?? '');
|
||||
$platform = preg_match('/^(B[123])_/', $name, $m) === 1 ? $m[1] : null;
|
||||
|
||||
$signalType = match ($row['type'] ?? null) {
|
||||
'hosts' => 'site',
|
||||
'calls' => 'call',
|
||||
'sms' => 'sms',
|
||||
default => null,
|
||||
};
|
||||
|
||||
$out[] = $row + [
|
||||
'platform' => $platform,
|
||||
'signal_type' => $signalType,
|
||||
'unique_key' => (string) ($row['content'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Supplier\Channel\Exceptions;
|
||||
|
||||
/**
|
||||
* Брошен FailoverProjectChannel когда операция эскалирована на ярус 3.
|
||||
*
|
||||
* Job-уровень ловит и помечает текущую попытку как отложенную к ручному вмешательству.
|
||||
*
|
||||
* Spec §4.4 ("manual_required").
|
||||
*/
|
||||
final class TierEscalatedException extends \RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $queueRowId,
|
||||
public readonly string $reason,
|
||||
string $message = '',
|
||||
) {
|
||||
parent::__construct($message ?: "Escalated to manual queue (row #{$queueRowId}, reason: {$reason})");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Supplier\Channel\Exceptions;
|
||||
|
||||
/**
|
||||
* Маркер «портал отказал по причине окна редактирования» (22:00-00:00 МСК).
|
||||
*
|
||||
* НЕ сбой канала — операция переносится. FailoverProjectChannel пропускает
|
||||
* эскалацию ярусов и не пишет в supplier_manual_sync_queue. Job-уровень
|
||||
* получает исключение и помечает попытку как deferred.
|
||||
*
|
||||
* Spec §8.
|
||||
*/
|
||||
final class WindowDeferredException extends \RuntimeException {}
|
||||
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Supplier\Channel;
|
||||
|
||||
use App\Exceptions\Supplier\SupplierAuthException;
|
||||
use App\Exceptions\Supplier\SupplierClientException;
|
||||
use App\Exceptions\Supplier\SupplierTransientException;
|
||||
use App\Mail\SupplierCriticalAlertMail;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierManualSyncQueue;
|
||||
use App\Services\Supplier\Channel\Exceptions\TierEscalatedException;
|
||||
use App\Services\Supplier\Channel\Exceptions\WindowDeferredException;
|
||||
use App\Services\Supplier\Dto\SupplierProjectDto;
|
||||
use Illuminate\Contracts\Mail\Mailer;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Декоратор-оркестратор: ярус 1 (AJAX) → ярус 2 (form-driving) → ярус 3 (manual queue).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.4
|
||||
*
|
||||
* Bridge-методы createProjectForLiderra/updateProjectForLiderra принимают Project
|
||||
* (нужен для project_id в очереди яруса 3). Прямые createProject/updateProject
|
||||
* сохраняются для интерфейс-совместимости (без эскалации).
|
||||
*/
|
||||
final class FailoverProjectChannel implements SupplierProjectChannel
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SupplierProjectChannel $tier1,
|
||||
private readonly SupplierProjectChannel $tier2,
|
||||
private readonly Mailer $mailer,
|
||||
) {}
|
||||
|
||||
public function createProject(SupplierProjectDto $dto): int
|
||||
{
|
||||
return $this->tier1->createProject($dto);
|
||||
}
|
||||
|
||||
public function updateProject(int $externalId, SupplierProjectDto $dto): void
|
||||
{
|
||||
$this->tier1->updateProject($externalId, $dto);
|
||||
}
|
||||
|
||||
public function listProjects(): array
|
||||
{
|
||||
return $this->tier1->listProjects();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create с эскалацией: использует Project для project_id в очереди яруса 3.
|
||||
*/
|
||||
public function createProjectForLiderra(Project $project, SupplierProjectDto $dto): int
|
||||
{
|
||||
// Spec §4.4 шаг 2: портальная сверка через listProjects() до любого create.
|
||||
// Защита от дубля при полу-успехе яруса 1 в прошлом запуске.
|
||||
try {
|
||||
$existing = $this->findOnPortal($dto);
|
||||
if ($existing !== null) {
|
||||
return $existing;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// listProjects недоступен — продолжаем (ярус-эскалация покроет сбой),
|
||||
// но провал дедупа логируем: иначе при полу-успехе яруса 1 в прошлом
|
||||
// прогоне молча создастся дубль rt-проекта.
|
||||
Log::warning('FailoverProjectChannel: dedup-сверка listProjects провалена', [
|
||||
'platform' => $dto->platform,
|
||||
'unique_key' => $dto->uniqueKey,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->tier1->createProject($dto);
|
||||
} catch (WindowDeferredException $e) {
|
||||
throw $e;
|
||||
} catch (SupplierTransientException $e) {
|
||||
$this->escalateToTier3($project, 'create', null, $dto, 'portal_unreachable', $e);
|
||||
} catch (SupplierClientException|SupplierAuthException $e) {
|
||||
try {
|
||||
$id = $this->tier2->createProject($dto);
|
||||
$this->alertFailoverToForm($project, 'create', $e);
|
||||
|
||||
return $id;
|
||||
} catch (Throwable $tier2Error) {
|
||||
$this->escalateToTier3(
|
||||
$project, 'create', null, $dto,
|
||||
$this->classifyTier2Failure($tier2Error), $tier2Error,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Все ветки выше терминируют (return / throw / escalateToTier3(): never) —
|
||||
// явный «unreachable»-throw не нужен (deadCode.unreachable).
|
||||
}
|
||||
|
||||
public function updateProjectForLiderra(Project $project, int $externalId, SupplierProjectDto $dto): void
|
||||
{
|
||||
try {
|
||||
$this->tier1->updateProject($externalId, $dto);
|
||||
|
||||
return;
|
||||
} catch (WindowDeferredException $e) {
|
||||
throw $e;
|
||||
} catch (SupplierTransientException $e) {
|
||||
$this->escalateToTier3($project, 'update', $externalId, $dto, 'portal_unreachable', $e);
|
||||
} catch (SupplierClientException|SupplierAuthException $e) {
|
||||
try {
|
||||
$this->tier2->updateProject($externalId, $dto);
|
||||
$this->alertFailoverToForm($project, 'update', $e);
|
||||
|
||||
return;
|
||||
} catch (Throwable $tier2Error) {
|
||||
$this->escalateToTier3(
|
||||
$project, 'update', $externalId, $dto,
|
||||
$this->classifyTier2Failure($tier2Error), $tier2Error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function escalateToTier3(
|
||||
Project $project,
|
||||
string $operation,
|
||||
?int $externalId,
|
||||
SupplierProjectDto $dto,
|
||||
string $reason,
|
||||
Throwable $cause,
|
||||
): never {
|
||||
$row = SupplierManualSyncQueue::create([
|
||||
'project_id' => $project->id,
|
||||
'platform' => $dto->platform,
|
||||
'operation' => $operation,
|
||||
'external_id' => $externalId !== null ? (string) $externalId : null,
|
||||
'payload_snapshot' => [
|
||||
'signal_type' => $dto->signalType,
|
||||
'unique_key' => $dto->uniqueKey,
|
||||
'limit' => $dto->limit,
|
||||
'workdays' => $dto->workdays,
|
||||
'regions' => $dto->regions,
|
||||
'regions_reverse' => $dto->regionsReverse,
|
||||
'status' => $dto->status,
|
||||
],
|
||||
'failure_reason' => $reason,
|
||||
'status' => 'pending',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$this->mailer->to((string) config('services.supplier.alert_email'))
|
||||
->queue(new SupplierCriticalAlertMail(
|
||||
alertType: 'manual_required',
|
||||
details: "Project #{$project->id} ({$dto->platform}/{$dto->uniqueKey}) — {$operation} queued #{$row->id}, reason: {$reason}. Cause: ".mb_substr($cause->getMessage(), 0, 300),
|
||||
));
|
||||
|
||||
throw new TierEscalatedException($row->id, $reason);
|
||||
}
|
||||
|
||||
private function alertFailoverToForm(Project $project, string $operation, Throwable $cause): void
|
||||
{
|
||||
$this->mailer->to((string) config('services.supplier.alert_email'))
|
||||
->queue(new SupplierCriticalAlertMail(
|
||||
alertType: 'failover_to_form',
|
||||
details: "Project #{$project->id} {$operation}: Tier 1 (AJAX) failed, Tier 2 (browser) succeeded. Cause: ".mb_substr($cause->getMessage(), 0, 300),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Портальная сверка: ищет уже существующий проект на портале по тройке
|
||||
* (platform, signal_type, unique_key). Возвращает external_id найденного
|
||||
* или null. Spec §4.4 шаг 2, §7.
|
||||
*/
|
||||
private function findOnPortal(SupplierProjectDto $dto): ?int
|
||||
{
|
||||
foreach ($this->tier1->listProjects() as $row) {
|
||||
if (
|
||||
($row['platform'] ?? null) === $dto->platform
|
||||
&& ($row['signal_type'] ?? null) === $dto->signalType
|
||||
&& ($row['unique_key'] ?? null) === $dto->uniqueKey
|
||||
) {
|
||||
return (int) ($row['id'] ?? 0) ?: null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function classifyTier2Failure(Throwable $e): string
|
||||
{
|
||||
$msg = mb_strtolower($e->getMessage());
|
||||
if (str_contains($msg, 'auth') || str_contains($msg, 'login')) {
|
||||
return 'auth_failure';
|
||||
}
|
||||
if (str_contains($msg, 'selector') || str_contains($msg, 'form')) {
|
||||
return 'form_selector_break';
|
||||
}
|
||||
|
||||
return 'form_save_error';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Supplier\Channel;
|
||||
|
||||
use App\Services\Supplier\Dto\SupplierProjectDto;
|
||||
use App\Services\Supplier\PlaywrightBridge;
|
||||
|
||||
/**
|
||||
* Ярус 2: водит форму «Мои проекты» supplier-портала через manage-project.js.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.3
|
||||
*/
|
||||
final class FormProjectChannel implements SupplierProjectChannel
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PlaywrightBridge $bridge,
|
||||
) {}
|
||||
|
||||
public function createProject(SupplierProjectDto $dto): int
|
||||
{
|
||||
$out = $this->callBridge('create', null, $dto);
|
||||
$id = (int) ($out['external_id'] ?? 0);
|
||||
if ($id === 0) {
|
||||
throw new \RuntimeException('FormProjectChannel: create returned empty external_id');
|
||||
}
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
public function updateProject(int $externalId, SupplierProjectDto $dto): void
|
||||
{
|
||||
$out = $this->callBridge('update', $externalId, $dto);
|
||||
if (($out['ok'] ?? false) !== true) {
|
||||
throw new \RuntimeException('FormProjectChannel: update did not return ok=true');
|
||||
}
|
||||
}
|
||||
|
||||
public function listProjects(): array
|
||||
{
|
||||
$out = $this->callBridge('list', null, null);
|
||||
|
||||
return (array) ($out['projects'] ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function callBridge(string $operation, ?int $externalId, ?SupplierProjectDto $dto): array
|
||||
{
|
||||
return $this->bridge->run([
|
||||
'script' => 'manage-project.js',
|
||||
'operation' => $operation,
|
||||
'externalId' => $externalId,
|
||||
'dto' => $dto !== null ? $this->mapDto($dto) : null,
|
||||
'login' => (string) config('services.supplier.login'),
|
||||
'password' => (string) config('services.supplier.password'),
|
||||
'url' => (string) config('services.supplier.portal_url'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function mapDto(SupplierProjectDto $dto): array
|
||||
{
|
||||
return [
|
||||
'tag' => $dto->uniqueKey,
|
||||
'name' => $dto->uniqueKey,
|
||||
'platforms' => [$dto->platform],
|
||||
'signal_type' => $dto->signalType,
|
||||
'limit' => $dto->limit,
|
||||
'workdays' => $dto->workdays,
|
||||
'regions' => $dto->regions,
|
||||
'region_mode' => $dto->regionsReverse ? 'exclude' : 'include',
|
||||
'domains' => $dto->signalType === 'site' ? [$dto->uniqueKey] : [],
|
||||
'active' => $dto->status === 'active',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Supplier\Channel;
|
||||
|
||||
use App\Services\Supplier\Dto\SupplierProjectDto;
|
||||
|
||||
/**
|
||||
* Контракт миграции проекта Лидерра → поставщик crm.bp-gr.ru.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.1
|
||||
*
|
||||
* Реализации (ярусы резерва):
|
||||
* - AjaxProjectChannel — rt-project-* HTTP (primary, быстрый).
|
||||
* - FormProjectChannel — Playwright водит форму «Мои проекты» (fallback).
|
||||
* - FailoverProjectChannel — декоратор-оркестратор (ярус 1 → ярус 2 → ярус 3 queue).
|
||||
*/
|
||||
interface SupplierProjectChannel
|
||||
{
|
||||
/**
|
||||
* Создаёт проект на портале, возвращает supplier external_id.
|
||||
*/
|
||||
public function createProject(SupplierProjectDto $dto): int;
|
||||
|
||||
/**
|
||||
* Обновляет существующий проект (квота/дни/регионы).
|
||||
*/
|
||||
public function updateProject(int $externalId, SupplierProjectDto $dto): void;
|
||||
|
||||
/**
|
||||
* Список проектов с портала — для дедуп-сверки и закрытия яруса 3.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function listProjects(): array;
|
||||
}
|
||||
@@ -33,6 +33,9 @@ final readonly class SupplierProjectDto
|
||||
array $regions,
|
||||
public bool $regionsReverse, // false = include (default), true = exclude
|
||||
public string $status, // active / paused
|
||||
public string $tag = '_lidpotok',
|
||||
/** @var array<int, string> */
|
||||
public array $platforms = [],
|
||||
) {
|
||||
// Canonical order for deterministic equals() vs PG jsonb non-deterministic order.
|
||||
// sort() reorders in-place AND re-indexes keys 0..N-1 (PHP guarantees list-semantics).
|
||||
|
||||
@@ -52,4 +52,46 @@ class PlaywrightBridge
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic Node-скрипт runner: запускает playwright/<script> с JSON stdin,
|
||||
* возвращает декодированный JSON stdout. Используется FormProjectChannel
|
||||
* (manage-project.js — ярус 2 резерва канала миграции проектов).
|
||||
*
|
||||
* @param array<string, mixed> $args обязательный ключ 'script'; остальное — payload на stdin.
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function run(array $args): array
|
||||
{
|
||||
$script = $args['script'] ?? null;
|
||||
if (! is_string($script) || $script === '') {
|
||||
throw new \InvalidArgumentException('PlaywrightBridge::run requires non-empty "script" key');
|
||||
}
|
||||
|
||||
$payload = $args;
|
||||
unset($payload['script']);
|
||||
|
||||
$process = $this->processFactory->create(
|
||||
['node', 'playwright/'.$script],
|
||||
base_path(),
|
||||
);
|
||||
$process->setInput(json_encode($payload, JSON_THROW_ON_ERROR));
|
||||
$process->setTimeoutSeconds(self::TIMEOUT_SECONDS);
|
||||
$process->run();
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
throw new \RuntimeException(
|
||||
"PlaywrightBridge::run({$script}) exit code {$process->getExitCode()}: {$process->getErrorOutput()}",
|
||||
);
|
||||
}
|
||||
|
||||
$output = json_decode($process->getOutput(), true);
|
||||
if (! is_array($output)) {
|
||||
throw new \RuntimeException(
|
||||
"PlaywrightBridge::run({$script}) returned non-array output: {$process->getOutput()}",
|
||||
);
|
||||
}
|
||||
|
||||
return $output;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,21 +7,19 @@ namespace App\Services\Supplier;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Streaming-парсер CSV-экспорта `/admin/report/index?type=49` поставщика.
|
||||
* Streaming-парсер CSV-отчёта «Запрос номеров» supplier-портала crm.bp-gr.ru.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §5.2
|
||||
* Ожидаемые столбцы: vid;project;tag;phone;phones;time (placeholder; уточнится
|
||||
* после Plan 3 Tasks 1-2 discovery с credentials поставщика).
|
||||
* Spec: docs/superpowers/specs/2026-05-18-supplier-csv-reconcile-channel-design.md §4.1
|
||||
* Столбцы: Name;Tag;Phone — 3 колонки. vid и время в этом отчёте отсутствуют.
|
||||
*
|
||||
* Возвращает Generator — вызывающий (CsvReconcileJob) сам решает, сколько
|
||||
* копить в памяти. BOM + CRLF поддерживаются. Malformed rows skip + log.
|
||||
* Возвращает Generator. BOM + CRLF поддерживаются. Malformed rows skip + log.
|
||||
*/
|
||||
final class SupplierCsvParser
|
||||
{
|
||||
private const EXPECTED_COLUMNS = 6;
|
||||
private const EXPECTED_COLUMNS = 3;
|
||||
|
||||
/**
|
||||
* @return iterable<int, array{vid: string, project: string, phone: string, time: int}>
|
||||
* @return iterable<int, array{project: string, tag: string, phone: string}>
|
||||
*/
|
||||
public function parse(string $rawCsv): iterable
|
||||
{
|
||||
@@ -29,7 +27,7 @@ final class SupplierCsvParser
|
||||
return;
|
||||
}
|
||||
|
||||
// Убираем BOM (UTF-8 BOM = EF BB BF)
|
||||
// Убираем UTF-8 BOM (EF BB BF)
|
||||
if (str_starts_with($rawCsv, "\xEF\xBB\xBF")) {
|
||||
$rawCsv = substr($rawCsv, 3);
|
||||
}
|
||||
@@ -65,10 +63,9 @@ final class SupplierCsvParser
|
||||
}
|
||||
|
||||
yield [
|
||||
'vid' => (string) $cols[0],
|
||||
'project' => (string) $cols[1],
|
||||
'phone' => (string) $cols[3],
|
||||
'time' => (int) $cols[5],
|
||||
'project' => (string) $cols[0],
|
||||
'tag' => (string) $cols[1],
|
||||
'phone' => (string) $cols[2],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Supplier;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Глобальный режим экспорта проектов поставщику (system_settings).
|
||||
* 'online' — sync сразу при create/edit с полными параметрами;
|
||||
* 'batch' — каркас сразу + полные параметры ночным SyncSupplierProjectsJob (18:00).
|
||||
* Spec: docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.1.
|
||||
*/
|
||||
final class SupplierExportMode
|
||||
{
|
||||
public const ONLINE = 'online';
|
||||
|
||||
public const BATCH = 'batch';
|
||||
|
||||
public static function current(): string
|
||||
{
|
||||
$value = DB::table('system_settings')->where('key', 'supplier_export_mode')->value('value');
|
||||
|
||||
return $value === self::ONLINE ? self::ONLINE : self::BATCH;
|
||||
}
|
||||
|
||||
public static function isOnline(): bool
|
||||
{
|
||||
return self::current() === self::ONLINE;
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,8 @@ use App\Exceptions\Supplier\SupplierAuthException;
|
||||
use App\Exceptions\Supplier\SupplierClientException;
|
||||
use App\Exceptions\Supplier\SupplierTransientException;
|
||||
use App\Jobs\Supplier\RefreshSupplierSessionJob;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Services\Supplier\Dto\SupplierProjectDto;
|
||||
use App\Support\SupplierRegions;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\Factory as HttpFactory;
|
||||
@@ -21,14 +21,25 @@ use Illuminate\Support\Facades\Cache;
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §4.4
|
||||
*
|
||||
* Endpoints (placeholder, точные имена адаптируются после Task 1 discovery):
|
||||
* - GET /admin/rt-projects-load — список проектов
|
||||
* - POST /admin/rt-project-save — создание
|
||||
* - POST /admin/rt-project-update — обновление
|
||||
* - POST /admin/rt-project-delete — удаление
|
||||
* Endpoints (verified live 2026-05-19 через Playwright MCP recon —
|
||||
* создан LIDPOTOK_TEST_DELETE_ME, записаны сеть-запросы, проект удалён;
|
||||
* см. план Task 1 docs/superpowers/plans/2026-05-19-supplier-project-channel-failover.md):
|
||||
* - GET /admin/visit/rt-projects-load?src=none — массив всех rt-проектов tenant'а.
|
||||
* - POST /admin/visit/rt-project-save — create (id:0) ИЛИ update (id:N).
|
||||
* Body: application/json, большой Vuex-state. Минимально требуемые поля
|
||||
* описаны в toPayload(). Response:
|
||||
* success → HTTP 200 + {"status":"OK","message":"","result":null,"id":"<string>"}
|
||||
* error → HTTP 200 + {"status":"Error","message":"<reason>","result":null}
|
||||
* ID в ответе — строка (например, "12721245"); приводим к int (fits в int64).
|
||||
* Один save c B1+B2+B3 (несколько включённых src*-флагов) создаёт N rt-проектов
|
||||
* (по одному на каждый включённый канал); `id` в response — последний из созданных.
|
||||
* В нашем use case toPayload() отправляет ровно один платформенный флаг.
|
||||
* - POST /admin/visit/rt-project-delete — удаление по id.
|
||||
* Body: application/json {"id":"<string>"}. Response: тот же конверт {status,message,result}.
|
||||
*
|
||||
* Авторизация: PHPSESSID cookie + X-CSRF-Token header (Redis cache 'supplier:session').
|
||||
* На 401/403 — single retry через dispatch_sync(RefreshSupplierSessionJob).
|
||||
* На HTTP 200 + status:"Error" — выбрасываем SupplierClientException с message портала.
|
||||
*/
|
||||
class SupplierPortalClient
|
||||
{
|
||||
@@ -37,106 +48,236 @@ class SupplierPortalClient
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Идемпотентно обеспечивает наличие supplier_project-записи для переданной
|
||||
* тройки (platform, signalType, uniqueKey). Если запись уже существует —
|
||||
* возвращает её id. Иначе — создаёт проект на стороне поставщика через
|
||||
* saveProject() и сохраняет новую запись supplier_projects.
|
||||
* Сырые строки rt-проектов с портала.
|
||||
*
|
||||
* Используется SyncSupplierProjectJob (Plan 5 Task 4).
|
||||
* Verified live 2026-05-19: GET /admin/visit/rt-projects-load?src=none
|
||||
* возвращает объект-конверт {projects:[...], tags, users, tokens, categories}
|
||||
* — НЕ голый массив. Извлекаем `projects`. Строка проекта:
|
||||
* {id:string, tag, src, name:"B<n>_<key>", type:"hosts|calls|sms", lim,
|
||||
* workdays, regions, regions_reverse, content, ...}.
|
||||
* Приведение к контрактной форме SupplierProjectChannel — в AjaxProjectChannel.
|
||||
*
|
||||
* В тестах метод мокируется через $this->mock(SupplierPortalClient::class) —
|
||||
* реальное тело не вызывается.
|
||||
*
|
||||
* @param string $platform B1 / B2 / B3
|
||||
* @param string $signalType site / call / sms
|
||||
* @param string $uniqueKey domain / phone / sender+keyword / sender
|
||||
*/
|
||||
public function ensureSupplierProject(string $platform, string $signalType, string $uniqueKey): int
|
||||
{
|
||||
$existing = SupplierProject::query()
|
||||
->where('platform', $platform)
|
||||
->where('signal_type', $signalType)
|
||||
->where('unique_key', $uniqueKey)
|
||||
->first();
|
||||
|
||||
if ($existing !== null) {
|
||||
return $existing->id;
|
||||
}
|
||||
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: $platform,
|
||||
signalType: $signalType,
|
||||
uniqueKey: $uniqueKey,
|
||||
limit: 0,
|
||||
workdays: [1, 2, 3, 4, 5, 6, 7],
|
||||
regions: [],
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
);
|
||||
|
||||
$externalId = $this->saveProject($dto);
|
||||
|
||||
$sp = SupplierProject::query()->create([
|
||||
'platform' => $platform,
|
||||
'signal_type' => $signalType,
|
||||
'unique_key' => $uniqueKey,
|
||||
'supplier_external_id' => (string) $externalId,
|
||||
'current_limit' => 0,
|
||||
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
|
||||
'current_regions' => null,
|
||||
'sync_status' => 'ok',
|
||||
]);
|
||||
|
||||
return $sp->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, mixed>
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function listProjects(): array
|
||||
{
|
||||
$response = $this->request('GET', '/admin/rt-projects-load');
|
||||
$response = $this->request('GET', '/admin/visit/rt-projects-load', ['src' => 'none']);
|
||||
|
||||
return $response->json() ?? [];
|
||||
$body = $response->json();
|
||||
$projects = is_array($body) ? ($body['projects'] ?? []) : [];
|
||||
|
||||
return is_array($projects) ? array_values($projects) : [];
|
||||
}
|
||||
|
||||
public function saveProject(SupplierProjectDto $dto): int
|
||||
{
|
||||
$response = $this->request('POST', '/admin/rt-project-save', $this->toPayload($dto));
|
||||
$response = $this->request(
|
||||
'POST',
|
||||
'/admin/visit/rt-project-save',
|
||||
$this->toPayload($dto, externalId: 0),
|
||||
asJson: true,
|
||||
);
|
||||
|
||||
$this->assertStatusOk($response, '/admin/visit/rt-project-save');
|
||||
|
||||
return (int) ($response->json('id') ?? 0);
|
||||
}
|
||||
|
||||
public function updateProject(int $externalId, SupplierProjectDto $dto): void
|
||||
{
|
||||
$this->request('POST', '/admin/rt-project-update', array_merge(
|
||||
['id' => $externalId],
|
||||
$this->toPayload($dto)
|
||||
));
|
||||
$response = $this->request(
|
||||
'POST',
|
||||
'/admin/visit/rt-project-save',
|
||||
$this->toPayload($dto, externalId: $externalId),
|
||||
asJson: true,
|
||||
);
|
||||
|
||||
$this->assertStatusOk($response, '/admin/visit/rt-project-save');
|
||||
}
|
||||
|
||||
/**
|
||||
* R5: один save с флагами всех dto->platforms → портал создаёт N rt-проектов,
|
||||
* портал делит лимит сам (R6). Ответ rt-project-save отдаёт id последнего →
|
||||
* дочитываем listProjects и матчим по name+tag (R-SAVE вариант а, Task 1 finding).
|
||||
*
|
||||
* @return array<string, int> [platform => external_id]
|
||||
*/
|
||||
public function saveProjectMultiFlag(SupplierProjectDto $dto): array
|
||||
{
|
||||
$response = $this->request(
|
||||
'POST', '/admin/visit/rt-project-save',
|
||||
$this->toPayload($dto, externalId: 0), asJson: true,
|
||||
);
|
||||
$this->assertStatusOk($response, '/admin/visit/rt-project-save');
|
||||
|
||||
$srcToPlatform = ['rt' => 'B1', 'bl' => 'B2', 'mt' => 'B3'];
|
||||
$out = [];
|
||||
foreach ($this->listProjects() as $p) {
|
||||
// Real portal returns name='B1_<identifier>' and identifier in 'content'.
|
||||
// Test mocks omit 'content' and put identifier directly in 'name' — fall back to 'name'
|
||||
// when 'content' is absent so both shapes work.
|
||||
$identifier = $p['content'] ?? $p['name'] ?? null;
|
||||
if ($identifier !== $dto->uniqueKey || ($p['tag'] ?? null) !== $dto->tag) {
|
||||
continue;
|
||||
}
|
||||
$platform = $srcToPlatform[$p['src'] ?? ''] ?? null;
|
||||
if ($platform !== null && in_array($platform, $dto->platforms !== [] ? $dto->platforms : [$dto->platform], true)) {
|
||||
$out[$platform] = (int) $p['id'];
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
public function deleteProject(int $externalId): void
|
||||
{
|
||||
$this->request('POST', '/admin/rt-project-delete', ['id' => $externalId]);
|
||||
$response = $this->request(
|
||||
'POST',
|
||||
'/admin/visit/rt-project-delete',
|
||||
['id' => (string) $externalId],
|
||||
asJson: true,
|
||||
);
|
||||
|
||||
$this->assertStatusOk($response, '/admin/visit/rt-project-delete');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /admin/report/index?type=49 — CSV-экспорт лидов за окно [from, to].
|
||||
* Auth/retry семантика наследуется от request() (PHPSESSID + X-CSRF-Token +
|
||||
* 401 → RefreshSession + 5xx → SupplierTransientException + 4xx → SupplierClientException).
|
||||
*
|
||||
* Возвращает raw CSV-body (UTF-8 + BOM, CRLF). Парсинг — снаружи через
|
||||
* SupplierCsvParser (streaming через generator).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §5.1
|
||||
* Portal-конверт ответа: HTTP 200 + {"status":"OK"|"Error", "message":"...", ...}.
|
||||
* Текстовая бизнес-ошибка приходит с HTTP 200 — HTTP-уровень обрабатывает только
|
||||
* 401/403/4xx/5xx; status=Error превращаем в SupplierClientException здесь.
|
||||
*/
|
||||
public function downloadLeadsCsv(CarbonInterface $from, CarbonInterface $to): string
|
||||
private function assertStatusOk(Response $response, string $path): void
|
||||
{
|
||||
$response = $this->request('GET', '/admin/report/index', [
|
||||
'type' => 49,
|
||||
'from' => $from->format('Y-m-d H:i:s'),
|
||||
'to' => $to->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
$status = $response->json('status');
|
||||
|
||||
if ($status === 'OK') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($status === 'Error') {
|
||||
$message = (string) ($response->json('message') ?? 'unknown');
|
||||
throw new SupplierClientException(
|
||||
"Supplier rejected {$path}: {$message}",
|
||||
httpStatus: $response->status(),
|
||||
responseBody: $response->body(),
|
||||
);
|
||||
}
|
||||
|
||||
// Неконвертный ответ — считаем как client-error (контракт сломан).
|
||||
throw new SupplierClientException(
|
||||
"Supplier returned unexpected envelope on {$path}: status={$status}",
|
||||
httpStatus: $response->status(),
|
||||
responseBody: $response->body(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Заказывает у поставщика отчёт «Запрос номеров» за диапазон [from, to].
|
||||
* Возвращает report_id для последующего waitReportReady / downloadReport.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-18-supplier-csv-reconcile-channel-design.md §4.3.
|
||||
*
|
||||
* Discovery T3 verified 2026-05-19 (Playwright MCP, см. snapshot
|
||||
* `supplier-api-configured-2026-05-19.png`):
|
||||
* - POST /admin/report/save-report принимает JSON {reportForm:{selectType:49},
|
||||
* reportFilter:{dateFrom, dateTo, ...defaults}} и возвращает строку "OK"
|
||||
* (НЕ JSON с id).
|
||||
* - id извлекается отдельным GET /admin/report/load-reports — это массив
|
||||
* отчётов в DESC-порядке, ищем первый с title
|
||||
* "Запрос номеров с {from} по {to}".
|
||||
*/
|
||||
public function requestNumbersReport(CarbonInterface $from, CarbonInterface $to): int
|
||||
{
|
||||
$this->request('POST', '/admin/report/save-report', [
|
||||
'reportForm' => ['selectType' => 49],
|
||||
'reportFilter' => [
|
||||
'dateFrom' => $from->format('Y-m-d'),
|
||||
'dateTo' => $to->format('Y-m-d'),
|
||||
'slug' => null,
|
||||
'rate' => 'all',
|
||||
'dnss' => '',
|
||||
'phones' => '',
|
||||
'prophones' => 'curr',
|
||||
'users' => [],
|
||||
'domains' => [],
|
||||
'utcs' => [],
|
||||
'types' => ['phones'],
|
||||
'xls' => false,
|
||||
'project_id' => null,
|
||||
'state_id' => 0,
|
||||
'gck_tech' => 'gck',
|
||||
],
|
||||
], asJson: true);
|
||||
|
||||
$expectedTitle = sprintf(
|
||||
'Запрос номеров с %s по %s',
|
||||
$from->format('Y-m-d'),
|
||||
$to->format('Y-m-d'),
|
||||
);
|
||||
|
||||
$list = $this->request('GET', '/admin/report/load-reports')->json();
|
||||
if (! is_array($list)) {
|
||||
throw new SupplierClientException('load-reports returned non-array response');
|
||||
}
|
||||
|
||||
foreach ($list as $row) {
|
||||
if (! is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
if (($row['title'] ?? null) === $expectedTitle) {
|
||||
return (int) ($row['id'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
throw new SupplierClientException(
|
||||
"Report just queued (title '{$expectedTitle}') not found in load-reports",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Опрашивает статус отчёта до значения «Обработан» (status="1").
|
||||
* На таймаут — SupplierTransientException.
|
||||
*
|
||||
* Discovery T3 verified: status — строка "0" (в обработке) / "1" (готов);
|
||||
* endpoint — общий GET /admin/report/load-reports (не /status?id=N).
|
||||
*/
|
||||
public function waitReportReady(int $reportId): void
|
||||
{
|
||||
$maxAttempts = 20;
|
||||
$delaySeconds = 3;
|
||||
|
||||
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
|
||||
$list = $this->request('GET', '/admin/report/load-reports')->json();
|
||||
if (is_array($list)) {
|
||||
foreach ($list as $row) {
|
||||
if (! is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
if ((int) ($row['id'] ?? 0) === $reportId && (string) ($row['status'] ?? '') === '1') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($attempt < $maxAttempts) {
|
||||
sleep($delaySeconds);
|
||||
}
|
||||
}
|
||||
|
||||
throw new SupplierTransientException(
|
||||
"Report {$reportId} not ready after {$maxAttempts} polls"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Скачивает готовый отчёт как raw CSV-body (UTF-8 + BOM, CRLF).
|
||||
* Парсинг — снаружи через SupplierCsvParser.
|
||||
*
|
||||
* Discovery T3 verified: endpoint GET /admin/report/getfile?id=N — совпадает с placeholder.
|
||||
*/
|
||||
public function downloadReport(int $reportId): string
|
||||
{
|
||||
$response = $this->request('GET', '/admin/report/getfile', ['id' => $reportId]);
|
||||
|
||||
return $response->body();
|
||||
}
|
||||
@@ -144,7 +285,7 @@ class SupplierPortalClient
|
||||
/**
|
||||
* @param array<string, mixed> $body
|
||||
*/
|
||||
private function request(string $method, string $path, array $body = [], bool $isRetry = false): Response
|
||||
private function request(string $method, string $path, array $body = [], bool $isRetry = false, bool $asJson = false): Response
|
||||
{
|
||||
$session = $this->loadSession();
|
||||
$portalUrl = (string) config('services.supplier.portal_url');
|
||||
@@ -159,11 +300,14 @@ class SupplierPortalClient
|
||||
$request = $this->http
|
||||
->withCookies(['PHPSESSID' => $session['phpsessid']], $host)
|
||||
->withHeaders(['X-CSRF-Token' => $session['csrf']])
|
||||
->timeout(30);
|
||||
->connectTimeout(30)
|
||||
->timeout(60);
|
||||
|
||||
try {
|
||||
if ($method === 'GET') {
|
||||
$response = $request->get($url, $body);
|
||||
} elseif ($asJson) {
|
||||
$response = $request->asJson()->post($url, $body);
|
||||
} else {
|
||||
$response = $request->asForm()->post($url, $body);
|
||||
}
|
||||
@@ -211,9 +355,43 @@ class SupplierPortalClient
|
||||
);
|
||||
}
|
||||
|
||||
// Defense-in-depth: портал отдаёт логин-страницу с HTTP 200 при истекшей
|
||||
// сессии middle-of-use (вместо 401/403). Детектим Yii2-маркер и форсим
|
||||
// refresh+retry. Verified 2026-05-19: refresh-session.js ловит #loginform-username.
|
||||
if ($this->isHtmlLoginPage($response)) {
|
||||
if ($isRetry) {
|
||||
throw new SupplierAuthException(
|
||||
"Portal returned login page after refresh on {$path}",
|
||||
httpStatus: $response->status(),
|
||||
responseBody: $response->body(),
|
||||
);
|
||||
}
|
||||
try {
|
||||
dispatch_sync(app(RefreshSupplierSessionJob::class));
|
||||
} catch (\Throwable $e) {
|
||||
throw new SupplierAuthException(
|
||||
"Session refresh failed during HTML-login retry on {$path}: {$e->getMessage()}",
|
||||
httpStatus: $response->status(),
|
||||
previous: $e,
|
||||
);
|
||||
}
|
||||
|
||||
return $this->request($method, $path, $body, isRetry: true, asJson: $asJson);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function isHtmlLoginPage(Response $response): bool
|
||||
{
|
||||
$contentType = $response->header('Content-Type');
|
||||
if (! str_starts_with(mb_strtolower($contentType), 'text/html')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return preg_match('~loginform-(username|password)~i', $response->body()) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{phpsessid: string, csrf: string, refreshed_at?: string}
|
||||
*/
|
||||
@@ -244,23 +422,72 @@ class SupplierPortalClient
|
||||
}
|
||||
|
||||
/**
|
||||
* NOTE: payload-shape — placeholder. Точные поля будут адаптированы
|
||||
* после Task 1 discovery + Task 2 spec §4.4 (отдельный fixup commit
|
||||
* перед Task 6 при расхождении).
|
||||
* Payload-shape для /admin/visit/rt-project-save (create + update).
|
||||
* Verified live 2026-05-19 (Playwright MCP recon — записан реальный JSON body
|
||||
* админ-формы «Добавить проект»; create=id:0, update=id:N).
|
||||
*
|
||||
* Mappings (наш DTO ↔ portal Vuex-state):
|
||||
* - platform: B1 → srcrt=true; B2 → srcbl=true; B3 → srcmt=true (single-true,
|
||||
* остальные false). Только один платформа за save — чтобы получить ровно
|
||||
* один rt-проект (множественные флаги создают N проектов, мы привязываемся
|
||||
* к одному external_id).
|
||||
* - signalType: site → type:"hosts"; call → type:"calls"; sms → type:"sms".
|
||||
* - uniqueKey → одновременно `name` (label проекта на портале — портал
|
||||
* префиксует "B<n>_" автоматически) и `content` (домен/телефон в полях
|
||||
* сбора).
|
||||
* - workdays: int[1..7] → string["1".."7"] (portal принимает строки).
|
||||
* - regions: int[]; regions_reverse: bool.
|
||||
* - status: "active" → true; "paused" → false.
|
||||
*
|
||||
* Дополнительно отправляем `tag:"_lidpotok"` для маркировки автоматизированных
|
||||
* проектов в админке портала + минимальный набор Vuex-defaults (show/depth/
|
||||
* multisignals/multigroup), которые портал ожидает в state-валидаторе.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function toPayload(SupplierProjectDto $dto): array
|
||||
private function toPayload(SupplierProjectDto $dto, int $externalId): array
|
||||
{
|
||||
$type = match ($dto->signalType) {
|
||||
'site' => 'hosts',
|
||||
'call' => 'calls',
|
||||
'sms' => 'sms',
|
||||
default => $dto->signalType,
|
||||
};
|
||||
|
||||
$platforms = $dto->platforms !== [] ? $dto->platforms : [$dto->platform];
|
||||
$srcrt = in_array('B1', $platforms, true);
|
||||
$srcbl = in_array('B2', $platforms, true);
|
||||
$srcmt = in_array('B3', $platforms, true);
|
||||
|
||||
// workdays: int → string (portal: ["1","2",...,"7"]).
|
||||
$workdays = array_map(static fn (int $d): string => (string) $d, $dto->workdays);
|
||||
|
||||
return [
|
||||
'platform' => $dto->platform,
|
||||
'signal_type' => $dto->signalType,
|
||||
'unique_key' => $dto->uniqueKey,
|
||||
'id' => $externalId,
|
||||
'tag' => $dto->tag,
|
||||
'name' => $dto->uniqueKey,
|
||||
'type' => $type,
|
||||
'content' => $dto->uniqueKey,
|
||||
'srcrt' => $srcrt,
|
||||
'srcbl' => $srcbl,
|
||||
'srcmt' => $srcmt,
|
||||
'srcmg' => false,
|
||||
'srclal' => false,
|
||||
'srcdop' => false,
|
||||
'srcwz' => false,
|
||||
'srcseg' => false,
|
||||
'limit' => $dto->limit,
|
||||
'workdays' => $dto->workdays,
|
||||
'regions' => $dto->regions,
|
||||
'regions_reverse' => $dto->regionsReverse ? 1 : 0,
|
||||
'status' => $dto->status,
|
||||
'workdays' => $workdays,
|
||||
// DTO несёт Лидерра-коды (конституционный порядок); поставщик ждёт
|
||||
// свои коды (ГИБДД). Без перевода уходил чужой регион (Красноярский 29
|
||||
// → Архангельск 29). См. App\Support\SupplierRegions.
|
||||
'regions' => SupplierRegions::mapToSupplier($dto->regions),
|
||||
'regions_reverse' => $dto->regionsReverse,
|
||||
'status' => $dto->status === 'active',
|
||||
'show' => true,
|
||||
'multisignals' => false,
|
||||
'multigroup' => false,
|
||||
'depth' => 1,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Supplier;
|
||||
|
||||
use App\Models\Project;
|
||||
|
||||
/**
|
||||
* DRY-хелперы для группировки Лидерра-проектов по (subject × platform-set).
|
||||
*
|
||||
* Используется в:
|
||||
* - SyncSupplierProjectJob (онлайн-режим, один проект)
|
||||
* - SyncSupplierProjectsJob (ночной батч, все проекты)
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.3
|
||||
* Plan: docs/superpowers/plans/2026-05-20-project-migration-redesign-plan-3-export.md Task 6
|
||||
*/
|
||||
final class SupplierProjectGrouping
|
||||
{
|
||||
/**
|
||||
* Строит unique_key для пары (project, platform):
|
||||
* site/call → signal_identifier (домен / телефон)
|
||||
* sms B2 → sender + '+' + keyword
|
||||
* sms B3 → sender
|
||||
*
|
||||
* Для ночного батч-джоба используйте buildUniqueKeyNoplatform() — он
|
||||
* выбирает B2-ключ автоматически при наличии keyword.
|
||||
*/
|
||||
public static function buildUniqueKey(Project $project, string $platform): string
|
||||
{
|
||||
if (in_array($project->signal_type, ['site', 'call'], true)) {
|
||||
return (string) $project->signal_identifier;
|
||||
}
|
||||
|
||||
// sms
|
||||
$sender = (string) ($project->sms_senders[0] ?? '');
|
||||
|
||||
if ($platform === 'B2') {
|
||||
return $sender.'+'.($project->sms_keyword ?? '');
|
||||
}
|
||||
|
||||
// B3
|
||||
return $sender;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unique identifier key без привязки к конкретной платформе
|
||||
* (для группировки в ночном батч-джобе):
|
||||
* site/call → signal_identifier
|
||||
* sms+keyword → sender+keyword (B2 ключ)
|
||||
* sms без keyword → sender (B3 ключ)
|
||||
*/
|
||||
public static function buildUniqueKeyAgnostic(Project $project): string
|
||||
{
|
||||
if (in_array($project->signal_type, ['site', 'call'], true)) {
|
||||
return (string) $project->signal_identifier;
|
||||
}
|
||||
|
||||
$sender = (string) ($project->sms_senders[0] ?? '');
|
||||
if ($project->sms_keyword !== null && $project->sms_keyword !== '') {
|
||||
return $sender.'+'.$project->sms_keyword;
|
||||
}
|
||||
|
||||
return $sender;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает список uppercase platform-кодов для данного project.
|
||||
* Коды соответствуют CHECK constraint: 'B1' / 'B2' / 'B3'.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function resolvePlatforms(Project $project): array
|
||||
{
|
||||
if (in_array($project->signal_type, ['site', 'call'], true)) {
|
||||
return ['B1', 'B2', 'B3'];
|
||||
}
|
||||
|
||||
if ($project->signal_type === 'sms') {
|
||||
return ($project->sms_keyword !== null && $project->sms_keyword !== '')
|
||||
? ['B2', 'B3']
|
||||
: ['B3'];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns subjects (region codes 1..89) for a project.
|
||||
* Empty regions → [null] (one group, "Вся РФ" pool).
|
||||
*
|
||||
* @return list<int|null>
|
||||
*/
|
||||
public static function subjectsOf(Project $project): array
|
||||
{
|
||||
$regions = array_values((array) $project->regions);
|
||||
// @phpstan-ignore-next-line identical.alwaysFalse — PostgresIntArray PHPDoc non-empty, runtime can be empty
|
||||
if (count($regions) === 0) {
|
||||
return [null];
|
||||
}
|
||||
|
||||
return array_map(fn ($r) => (int) $r, $regions);
|
||||
}
|
||||
}
|
||||
@@ -9,26 +9,29 @@ use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Pure function: распределение квоты daily_limit между platform B1/B2/B3.
|
||||
* Pure function: формула заказа у поставщика на (источник × субъект).
|
||||
*
|
||||
* Используется SyncSupplierProjectsJob для агрегирования daily_limit_target
|
||||
* всех активных Лидерра-проектов на одного supplier_project и распределения
|
||||
* суммарной квоты между B1/B2/B3 платформами.
|
||||
* Заказ группы eligible-клиентов:
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §4.3-§4.4
|
||||
* order = max(наибольший_лимит, ceil(Σ_лимитов / 3))
|
||||
*
|
||||
* Distribution-формулы:
|
||||
* site/call:
|
||||
* B1 = ceil(total/3)
|
||||
* B2 = ceil((total - B1) / 2)
|
||||
* B3 = total - B1 - B2
|
||||
* sms-with-keyword (B1 не поддерживает СМС):
|
||||
* B1 = 0
|
||||
* B2 = ceil(total/2)
|
||||
* B3 = floor(total/2)
|
||||
* 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.
|
||||
*
|
||||
* Workdays и regions — союзы (deduplicated, sorted) активных Лидерра-проектов,
|
||||
* eligible на targetDate (фильтр по weekday в Europe/Moscow).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.5.
|
||||
*/
|
||||
final class SupplierQuotaAllocator
|
||||
{
|
||||
@@ -56,7 +59,9 @@ final class SupplierQuotaAllocator
|
||||
$workdaysUnion = self::unionInts($eligibleProjects->pluck('workdays'));
|
||||
$regionsUnion = self::unionInts($eligibleProjects->pluck('regions'));
|
||||
|
||||
$platformLimit = self::distributeForPlatform($signalType, $platform, $totalQuota);
|
||||
$platformLimit = self::computeOrder(
|
||||
$eligibleProjects->pluck('daily_limit')->map(fn ($v) => (int) $v)->all()
|
||||
);
|
||||
|
||||
return new SupplierProjectDto(
|
||||
platform: $platform,
|
||||
@@ -70,28 +75,60 @@ final class SupplierQuotaAllocator
|
||||
);
|
||||
}
|
||||
|
||||
private static function distributeForPlatform(string $signalType, string $platform, int $total): int
|
||||
/**
|
||||
* Заказ у поставщика на (источник × субъект): max(наибольший лимит, ceil(Σ/3)).
|
||||
*
|
||||
* ceil(Σ/3) — ёмкость шаринга (лид продаётся ≤3 раз).
|
||||
* наиб — крупнейший клиент должен иметь шанс добрать.
|
||||
*
|
||||
* Возвращает заказ ГРУППЫ; деление между B1/B2/B3 — distributeForPlatform().
|
||||
*
|
||||
* @param array<int, int> $dailyLimits лимиты eligible-сегодня клиентов группы
|
||||
*/
|
||||
public static function computeOrder(array $dailyLimits): int
|
||||
{
|
||||
if ($signalType === 'sms') {
|
||||
if ($platform === 'B1') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return $platform === 'B2'
|
||||
? (int) ceil($total / 2)
|
||||
: (int) floor($total / 2);
|
||||
if ($dailyLimits === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$b1 = (int) ceil($total / 3);
|
||||
$b2 = (int) ceil(($total - $b1) / 2);
|
||||
$b3 = $total - $b1 - $b2;
|
||||
$sum = array_sum($dailyLimits);
|
||||
$max = max($dailyLimits);
|
||||
|
||||
return match ($platform) {
|
||||
'B1' => $b1,
|
||||
'B2' => $b2,
|
||||
'B3' => $b3,
|
||||
default => 0,
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* Канонический справочник субъектов РФ (1..89) — PHP-зеркало
|
||||
* resources/js/constants/regions.ts (конституционный порядок, ст. 65).
|
||||
* Sentinel 0 «Вся РФ» не входит (= NULL subject_code / пустой regions).
|
||||
*
|
||||
* ВАЖНО: при правке regions.ts синхронно править этот файл (тест RegionTagResolverTest
|
||||
* «mirrors regions.ts — exactly 89» ловит расхождение по count, но не по именам —
|
||||
* сверять имена вручную при изменениях).
|
||||
*/
|
||||
final class RussianRegions
|
||||
{
|
||||
/** @var array<int, string> code(1..89) => официальное имя субъекта */
|
||||
public const CODE_TO_NAME = [
|
||||
// 24 республики
|
||||
1 => 'Республика Адыгея',
|
||||
2 => 'Республика Алтай',
|
||||
3 => 'Республика Башкортостан',
|
||||
4 => 'Республика Бурятия',
|
||||
5 => 'Республика Дагестан',
|
||||
6 => 'Донецкая Народная Республика',
|
||||
7 => 'Республика Ингушетия',
|
||||
8 => 'Кабардино-Балкарская Республика',
|
||||
9 => 'Республика Калмыкия',
|
||||
10 => 'Карачаево-Черкесская Республика',
|
||||
11 => 'Республика Карелия',
|
||||
12 => 'Республика Коми',
|
||||
13 => 'Республика Крым',
|
||||
14 => 'Луганская Народная Республика',
|
||||
15 => 'Республика Марий Эл',
|
||||
16 => 'Республика Мордовия',
|
||||
17 => 'Республика Саха (Якутия)',
|
||||
18 => 'Республика Северная Осетия — Алания',
|
||||
19 => 'Республика Татарстан',
|
||||
20 => 'Республика Тыва',
|
||||
21 => 'Удмуртская Республика',
|
||||
22 => 'Республика Хакасия',
|
||||
23 => 'Чеченская Республика',
|
||||
24 => 'Чувашская Республика',
|
||||
// 9 краёв
|
||||
25 => 'Алтайский край',
|
||||
26 => 'Забайкальский край',
|
||||
27 => 'Камчатский край',
|
||||
28 => 'Краснодарский край',
|
||||
29 => 'Красноярский край',
|
||||
30 => 'Пермский край',
|
||||
31 => 'Приморский край',
|
||||
32 => 'Ставропольский край',
|
||||
33 => 'Хабаровский край',
|
||||
// 48 областей
|
||||
34 => 'Амурская область',
|
||||
35 => 'Архангельская область',
|
||||
36 => 'Астраханская область',
|
||||
37 => 'Белгородская область',
|
||||
38 => 'Брянская область',
|
||||
39 => 'Владимирская область',
|
||||
40 => 'Волгоградская область',
|
||||
41 => 'Вологодская область',
|
||||
42 => 'Воронежская область',
|
||||
43 => 'Запорожская область',
|
||||
44 => 'Ивановская область',
|
||||
45 => 'Иркутская область',
|
||||
46 => 'Калининградская область',
|
||||
47 => 'Калужская область',
|
||||
48 => 'Кемеровская область',
|
||||
49 => 'Кировская область',
|
||||
50 => 'Костромская область',
|
||||
51 => 'Курганская область',
|
||||
52 => 'Курская область',
|
||||
53 => 'Ленинградская область',
|
||||
54 => 'Липецкая область',
|
||||
55 => 'Магаданская область',
|
||||
56 => 'Московская область',
|
||||
57 => 'Мурманская область',
|
||||
58 => 'Нижегородская область',
|
||||
59 => 'Новгородская область',
|
||||
60 => 'Новосибирская область',
|
||||
61 => 'Омская область',
|
||||
62 => 'Оренбургская область',
|
||||
63 => 'Орловская область',
|
||||
64 => 'Пензенская область',
|
||||
65 => 'Псковская область',
|
||||
66 => 'Ростовская область',
|
||||
67 => 'Рязанская область',
|
||||
68 => 'Самарская область',
|
||||
69 => 'Саратовская область',
|
||||
70 => 'Сахалинская область',
|
||||
71 => 'Свердловская область',
|
||||
72 => 'Смоленская область',
|
||||
73 => 'Тамбовская область',
|
||||
74 => 'Тверская область',
|
||||
75 => 'Томская область',
|
||||
76 => 'Тульская область',
|
||||
77 => 'Тюменская область',
|
||||
78 => 'Ульяновская область',
|
||||
79 => 'Херсонская область',
|
||||
80 => 'Челябинская область',
|
||||
81 => 'Ярославская область',
|
||||
// 3 города федерального значения
|
||||
82 => 'Москва',
|
||||
83 => 'Санкт-Петербург',
|
||||
84 => 'Севастополь',
|
||||
// 1 автономная область
|
||||
85 => 'Еврейская автономная область',
|
||||
// 4 автономных округа
|
||||
86 => 'Ненецкий автономный округ',
|
||||
87 => 'Ханты-Мансийский автономный округ — Югра',
|
||||
88 => 'Чукотский автономный округ',
|
||||
89 => 'Ямало-Ненецкий автономный округ',
|
||||
];
|
||||
|
||||
/** @return array<string, int> name => code (обратный индекс) */
|
||||
public static function nameToCode(): array
|
||||
{
|
||||
return array_flip(self::CODE_TO_NAME);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
+9
-1
@@ -17,6 +17,8 @@
|
||||
},
|
||||
"require-dev": {
|
||||
"barryvdh/laravel-ide-helper": "*",
|
||||
"deptrac/deptrac": "^4.6",
|
||||
"driftingly/rector-laravel": "^2.3",
|
||||
"fakerphp/faker": "^1.23",
|
||||
"infection/infection": "^0.32.7",
|
||||
"larastan/larastan": "*",
|
||||
@@ -26,8 +28,10 @@
|
||||
"laravel/pint": "^1.29",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"nunomaduro/phpinsights": "*",
|
||||
"pestphp/pest": "^4.7",
|
||||
"pestphp/pest-plugin-laravel": "^4.1",
|
||||
"rector/rector": "^2.4",
|
||||
"roave/security-advisories": "dev-latest"
|
||||
},
|
||||
"autoload": {
|
||||
@@ -63,6 +67,9 @@
|
||||
"pint:test": "@php vendor/bin/pint --test",
|
||||
"test:parallel": "@php vendor/bin/pest --parallel --recreate-databases",
|
||||
"stan": "@php vendor/bin/phpstan analyse --memory-limit=512M",
|
||||
"rector": "@php vendor/bin/rector process --dry-run",
|
||||
"rector:fix": "@php vendor/bin/rector process",
|
||||
"insights": "@php artisan insights --no-interaction",
|
||||
"mutation": "@php vendor/bin/infection --threads=2 --min-msi=50",
|
||||
"audit-offline": "@composer audit --locked",
|
||||
"demo:seed": "@php artisan db:seed --class=DemoSeeder --force",
|
||||
@@ -101,7 +108,8 @@
|
||||
"allow-plugins": {
|
||||
"pestphp/pest-plugin": true,
|
||||
"php-http/discovery": true,
|
||||
"infection/extension-installer": true
|
||||
"infection/extension-installer": true,
|
||||
"dealerdirect/phpcodesniffer-composer-installer": true
|
||||
}
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
|
||||
Generated
+2588
-1
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use NunoMaduro\PhpInsights\Domain\Insights\ForbiddenDefineFunctions;
|
||||
use NunoMaduro\PhpInsights\Domain\Insights\ForbiddenFinalClasses;
|
||||
use NunoMaduro\PhpInsights\Domain\Insights\ForbiddenNormalClasses;
|
||||
use NunoMaduro\PhpInsights\Domain\Insights\ForbiddenPrivateMethods;
|
||||
use NunoMaduro\PhpInsights\Domain\Insights\ForbiddenTraits;
|
||||
use NunoMaduro\PhpInsights\Domain\Insights\SyntaxCheck;
|
||||
use NunoMaduro\PhpInsights\Domain\Metrics\Architecture\Classes;
|
||||
use SlevomatCodingStandard\Sniffs\Commenting\UselessFunctionDocCommentSniff;
|
||||
use SlevomatCodingStandard\Sniffs\Namespaces\AlphabeticallySortedUsesSniff;
|
||||
use SlevomatCodingStandard\Sniffs\TypeHints\DeclareStrictTypesSniff;
|
||||
use SlevomatCodingStandard\Sniffs\TypeHints\DisallowMixedTypeHintSniff;
|
||||
use SlevomatCodingStandard\Sniffs\TypeHints\ParameterTypeHintSniff;
|
||||
use SlevomatCodingStandard\Sniffs\TypeHints\PropertyTypeHintSniff;
|
||||
use SlevomatCodingStandard\Sniffs\TypeHints\ReturnTypeHintSniff;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Preset
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default preset that will be used by PHP Insights
|
||||
| to make your code reliable, simple, and clean. However, you can always
|
||||
| adjust the `Metrics` and `Insights` below in this configuration file.
|
||||
|
|
||||
| Supported: "default", "laravel", "symfony", "magento2", "drupal", "wordpress"
|
||||
|
|
||||
*/
|
||||
|
||||
'preset' => 'laravel',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| IDE
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This options allow to add hyperlinks in your terminal to quickly open
|
||||
| files in your favorite IDE while browsing your PhpInsights report.
|
||||
|
|
||||
| Supported: "textmate", "macvim", "emacs", "sublime", "phpstorm",
|
||||
| "atom", "vscode".
|
||||
|
|
||||
| If you have another IDE that is not in this list but which provide an
|
||||
| url-handler, you could fill this config with a pattern like this:
|
||||
|
|
||||
| myide://open?url=file://%f&line=%l
|
||||
|
|
||||
*/
|
||||
|
||||
'ide' => null,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may adjust all the various `Insights` that will be used by PHP
|
||||
| Insights. You can either add, remove or configure `Insights`. Keep in
|
||||
| mind, that all added `Insights` must belong to a specific `Metric`.
|
||||
|
|
||||
*/
|
||||
|
||||
'exclude' => [
|
||||
// 'path/to/directory-or-file'
|
||||
],
|
||||
|
||||
'add' => [
|
||||
Classes::class => [
|
||||
ForbiddenFinalClasses::class,
|
||||
],
|
||||
],
|
||||
|
||||
'remove' => [
|
||||
// SyntaxCheck спавнит дочерний `php -l` процесс — на native-Windows возвращает
|
||||
// не-JSON и крашит PHP Insights (A1 backend-tooling, 20.05.2026). Избыточен:
|
||||
// синтаксис ловят Pint / Larastan / сам PHP. Стиль — владелец Pint (BT4, ADR-013).
|
||||
SyntaxCheck::class,
|
||||
AlphabeticallySortedUsesSniff::class,
|
||||
DeclareStrictTypesSniff::class,
|
||||
DisallowMixedTypeHintSniff::class,
|
||||
ForbiddenDefineFunctions::class,
|
||||
ForbiddenNormalClasses::class,
|
||||
ForbiddenTraits::class,
|
||||
ParameterTypeHintSniff::class,
|
||||
PropertyTypeHintSniff::class,
|
||||
ReturnTypeHintSniff::class,
|
||||
UselessFunctionDocCommentSniff::class,
|
||||
],
|
||||
|
||||
'config' => [
|
||||
ForbiddenPrivateMethods::class => [
|
||||
'title' => 'The usage of private methods is not idiomatic in Laravel.',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Requirements
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define a level you want to reach per `Insights` category.
|
||||
| When a score is lower than the minimum level defined, then an error
|
||||
| code will be returned. This is optional and individually defined.
|
||||
|
|
||||
*/
|
||||
|
||||
'requirements' => [
|
||||
// Anti-regression floors из baseline 20.05.2026 (Code 80 / Complexity 81 /
|
||||
// Architecture 75). Чуть ниже текущих — гейт ловит деградацию, не текущий долг.
|
||||
// Style НЕ гейтим — владелец стиля Pint (BT4, ADR-013). Security-check off —
|
||||
// дублирует roave/security-advisories + composer audit.
|
||||
'min-quality' => 78,
|
||||
'min-complexity' => 79,
|
||||
'min-architecture' => 73,
|
||||
'disable-security-check' => true,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Threads
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may adjust how many threads (core) PHPInsights can use to perform
|
||||
| the analysis. This is optional, don't provide it and the tool will guess
|
||||
| the max core number available. It accepts null value or integer > 0.
|
||||
|
|
||||
*/
|
||||
|
||||
'threads' => null,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Timeout
|
||||
|--------------------------------------------------------------------------
|
||||
| Here you may adjust the timeout (in seconds) for PHPInsights to run before
|
||||
| a ProcessTimedOutException is thrown.
|
||||
| This accepts an int > 0. Default is 60 seconds, which is the default value
|
||||
| of Symfony's setTimeout function.
|
||||
|
|
||||
*/
|
||||
|
||||
'timeout' => 60,
|
||||
];
|
||||
@@ -7,6 +7,7 @@ namespace Database\Factories;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<Project>
|
||||
@@ -20,7 +21,11 @@ class ProjectFactory extends Factory
|
||||
{
|
||||
return [
|
||||
'tenant_id' => Tenant::factory(),
|
||||
'name' => fake()->unique()->words(3, true),
|
||||
// Квирк #77: fake()->unique() создаёт новый UniqueGenerator на каждый
|
||||
// definition()-call → history между вызовами не сохраняется, uniqueness
|
||||
// внутри batch не гарантирована (коллизия (tenant_id, name) UNIQUE в
|
||||
// pest --parallel). Str::random(8) суффикс (62^8 ≈ 2e14) гасит коллизию.
|
||||
'name' => fake()->words(3, true).' '.Str::random(8),
|
||||
'type' => 'webhook',
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 10,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Plan 6 (C9) — subject-level regions.
|
||||
*
|
||||
* +1 колонка projects.regions INT[] (1..89 коды субъектов РФ; пустой массив = вся РФ).
|
||||
* +1 GIN-индекс idx_projects_regions для outbound regions queries.
|
||||
* region_mask/region_mode остаются (dual-write) — удаление в Plan 6.5.
|
||||
*
|
||||
* Guard'ы: migrate:fresh грузит schema.sql v8.22 (где delta уже есть) до миграций,
|
||||
* поэтому каждый кусок применяется только при отсутствии (как Sprint 4 миграция).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasColumn('projects', 'regions')) {
|
||||
DB::statement("ALTER TABLE projects ADD COLUMN regions INT[] NOT NULL DEFAULT '{}'::INT[]");
|
||||
}
|
||||
|
||||
DB::statement('CREATE INDEX IF NOT EXISTS idx_projects_regions ON projects USING GIN (regions)');
|
||||
|
||||
DB::statement(
|
||||
'COMMENT ON COLUMN projects.regions IS '
|
||||
."'Subject-level region filter (1..89 коды субъектов РФ). Пустой массив = вся РФ. Plan 6 (v8.22).'"
|
||||
);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('DROP INDEX IF EXISTS idx_projects_regions');
|
||||
|
||||
if (Schema::hasColumn('projects', 'regions')) {
|
||||
Schema::table('projects', fn ($table) => $table->dropColumn('regions'));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Воронка статусов 14 → 5 (редизайн «Сделки» 2026-05-17).
|
||||
*
|
||||
* Новые 5: new / viewed / in_progress / won / lost. Slug'и `new` и `viewed`
|
||||
* сохраняются (RouteSupplierLeadJob / DealController@store default'ят 'new').
|
||||
* Ремап старых 14 → 5 в deals.status и import_unknown_statuses.mapped_to_slug
|
||||
* перед DELETE устаревших lead_statuses (FK-safe). tenant_status_overrides
|
||||
* со старыми slug'ами удаляются (кастомные ярлыки схлопнутых статусов
|
||||
* обсолетны + исключает PK-коллизию при ремапе).
|
||||
*
|
||||
* На migrate:fresh schema.sql уже сеет 5 — UPDATE/DELETE здесь no-op.
|
||||
* down() необратима (схлопывание lossy).
|
||||
*
|
||||
* Спека: docs/superpowers/specs/2026-05-17-deals-page-redesign-design.md §3.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
/** Старый slug → новый. new/viewed не меняются (отсутствуют в карте). */
|
||||
private const REMAP = [
|
||||
'worked' => 'in_progress', 'base' => 'in_progress', 'missed' => 'in_progress',
|
||||
'negotiations' => 'in_progress', 'waiting_payment' => 'in_progress',
|
||||
'partnership' => 'in_progress', 'test_drive' => 'in_progress', 'hot' => 'in_progress',
|
||||
'replacement' => 'in_progress', 'final_missed' => 'in_progress',
|
||||
'paid' => 'won', 'closed' => 'lost',
|
||||
];
|
||||
|
||||
private const KEEP = ['new', 'viewed', 'in_progress', 'won', 'lost'];
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
DB::transaction(function () {
|
||||
// 1) Новые slug'и обязаны существовать до ремапа FK-ссылок.
|
||||
DB::table('lead_statuses')->upsert([
|
||||
['slug' => 'new', 'name_ru' => 'Новая сделка', 'is_system' => true, 'sort_order' => 1, 'color_hex' => '#3B82F6'],
|
||||
['slug' => 'viewed', 'name_ru' => 'Просмотрено', 'is_system' => true, 'sort_order' => 2, 'color_hex' => '#8B5CF6'],
|
||||
['slug' => 'in_progress', 'name_ru' => 'В работе', 'is_system' => true, 'sort_order' => 3, 'color_hex' => '#06B6D4'],
|
||||
['slug' => 'won', 'name_ru' => 'Сделка', 'is_system' => true, 'sort_order' => 4, 'color_hex' => '#10B981'],
|
||||
['slug' => 'lost', 'name_ru' => 'Не реализовано', 'is_system' => true, 'sort_order' => 5, 'color_hex' => '#6B7280'],
|
||||
], ['slug'], ['name_ru', 'is_system', 'sort_order', 'color_hex']);
|
||||
|
||||
// 2) Ремап ссылок на старые slug'и.
|
||||
foreach (self::REMAP as $old => $new) {
|
||||
DB::table('deals')->where('status', $old)->update(['status' => $new]);
|
||||
DB::table('import_unknown_statuses')->where('mapped_to_slug', $old)->update(['mapped_to_slug' => $new]);
|
||||
}
|
||||
|
||||
// 3) Обсолетные кастомные ярлыки статусов — удалить (FK на lead_statuses).
|
||||
DB::table('tenant_status_overrides')->whereNotIn('status_slug', self::KEEP)->delete();
|
||||
|
||||
// 4) Удалить устаревшие статусы (все FK-ссылки перенаправлены).
|
||||
DB::table('lead_statuses')->whereNotIn('slug', self::KEEP)->delete();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
throw new RuntimeException('Воронка 14→5 необратима (схлопывание статусов lossy).');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Guard: после migrate:fresh schema.sql загружается первой (load_initial_schema).
|
||||
// Если schema.sql уже отдаёт vid как nullable — миграция no-op (idempotent).
|
||||
$isNullable = DB::selectOne(
|
||||
"SELECT is_nullable FROM information_schema.columns
|
||||
WHERE table_name = 'supplier_leads' AND column_name = 'vid'"
|
||||
);
|
||||
if ($isNullable !== null && $isNullable->is_nullable === 'YES') {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::statement('ALTER TABLE supplier_leads ALTER COLUMN vid DROP NOT NULL');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Внимание: down() не симметричен после migrate:fresh со свежей schema.sql.
|
||||
// Не использовать как откат schema-bump — нужна отдельная schema-правка.
|
||||
DB::statement('ALTER TABLE supplier_leads ALTER COLUMN vid SET NOT NULL');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Создаёт SaaS-level очередь яруса 3 резерва канала миграции проектов.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.5
|
||||
*
|
||||
* Без tenant_id / RLS (как supplier_csv_reconcile_log) — доступ только SaaS-admin.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Guard: после migrate:fresh schema.sql даёт таблицу первой. Idempotent.
|
||||
$exists = DB::selectOne(
|
||||
"SELECT to_regclass('public.supplier_manual_sync_queue') AS r"
|
||||
);
|
||||
if ($exists !== null && $exists->r !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// unprepared — multi-statement (PG prepared statements не разрешают `;`).
|
||||
DB::unprepared(<<<'SQL'
|
||||
CREATE TABLE supplier_manual_sync_queue (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
platform VARCHAR(8) NOT NULL,
|
||||
operation VARCHAR(16) NOT NULL,
|
||||
external_id VARCHAR(64),
|
||||
payload_snapshot JSONB NOT NULL,
|
||||
failure_reason VARCHAR(64) NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'pending',
|
||||
resolved_by_user_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
resolved_at TIMESTAMPTZ,
|
||||
CONSTRAINT chk_smsq_platform CHECK (platform IN ('B1', 'B2', 'B3')),
|
||||
CONSTRAINT chk_smsq_operation CHECK (operation IN ('create', 'update')),
|
||||
CONSTRAINT chk_smsq_status CHECK (status IN ('pending', 'resolved', 'cancelled'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_smsq_status_created ON supplier_manual_sync_queue (status, created_at DESC);
|
||||
CREATE INDEX idx_smsq_project ON supplier_manual_sync_queue (project_id);
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('DROP TABLE IF EXISTS supplier_manual_sync_queue');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Per-субъект supplier_projects (эпик переделки миграции проектов, v8.26).
|
||||
*
|
||||
* +subject_code SMALLINT NULL (1..89 субъект РФ; NULL = пул «Вся РФ»).
|
||||
* Старый unique (platform, unique_key) → (platform, unique_key, subject_code)
|
||||
* NULLS NOT DISTINCT — пул «Вся РФ» уникален per (platform, unique_key).
|
||||
*
|
||||
* Guard: migrate:fresh грузит schema.sql v8.26 (delta уже там) до миграций.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasColumn('supplier_projects', 'subject_code')) {
|
||||
DB::statement('ALTER TABLE supplier_projects ADD COLUMN subject_code SMALLINT');
|
||||
}
|
||||
|
||||
DB::statement(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_supplier_projects_subject_code'
|
||||
) THEN
|
||||
ALTER TABLE supplier_projects
|
||||
ADD CONSTRAINT chk_supplier_projects_subject_code
|
||||
CHECK (subject_code IS NULL OR (subject_code BETWEEN 1 AND 89)) NOT VALID;
|
||||
END IF;
|
||||
END $$;
|
||||
SQL);
|
||||
DB::statement('ALTER TABLE supplier_projects VALIDATE CONSTRAINT chk_supplier_projects_subject_code');
|
||||
|
||||
DB::statement('DROP INDEX IF EXISTS supplier_projects_platform_unique_key_unique');
|
||||
DB::statement(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS supplier_projects_platform_key_subject_unique '
|
||||
.'ON supplier_projects (platform, unique_key, subject_code) NULLS NOT DISTINCT'
|
||||
);
|
||||
|
||||
DB::statement(
|
||||
'COMMENT ON COLUMN supplier_projects.subject_code IS '
|
||||
."'Субъект РФ 1..89 (resources/js/constants/regions.ts). NULL = пул «Вся РФ». Эпик миграции проектов v8.26.'"
|
||||
);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('DROP INDEX IF EXISTS supplier_projects_platform_key_subject_unique');
|
||||
DB::statement(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS supplier_projects_platform_unique_key_unique '
|
||||
.'ON supplier_projects (platform, unique_key)'
|
||||
);
|
||||
DB::statement('ALTER TABLE supplier_projects DROP CONSTRAINT IF EXISTS chk_supplier_projects_subject_code');
|
||||
|
||||
if (Schema::hasColumn('supplier_projects', 'subject_code')) {
|
||||
Schema::table('supplier_projects', fn ($t) => $t->dropColumn('subject_code'));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* M:N pivot между projects (tenant) и supplier_projects (SaaS, shared) — v8.26.
|
||||
*
|
||||
* Заменяет 3 FK-слота projects.supplier_b{1,2,3}_project_id (которые не вмещают
|
||||
* per-субъект модель: N субъектов × до 3 платформ = до 3N связей).
|
||||
* SaaS-level (без RLS, как supplier_projects): пишется sync-флоу, читается
|
||||
* sharing-флоу через BYPASSRLS-роль crm_supplier_worker.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$exists = DB::selectOne("SELECT to_regclass('public.project_supplier_links') AS r");
|
||||
if ($exists !== null && $exists->r !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::unprepared(<<<'SQL'
|
||||
CREATE TABLE project_supplier_links (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
supplier_project_id BIGINT NOT NULL REFERENCES supplier_projects(id) ON DELETE CASCADE,
|
||||
platform VARCHAR(4) NOT NULL,
|
||||
subject_code SMALLINT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT chk_psl_platform CHECK (platform IN ('B1', 'B2', 'B3')),
|
||||
CONSTRAINT uq_psl_project_supplier UNIQUE (project_id, supplier_project_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_psl_supplier_project ON project_supplier_links (supplier_project_id);
|
||||
CREATE INDEX idx_psl_project ON project_supplier_links (project_id);
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('DROP TABLE IF EXISTS project_supplier_links');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* deals.subject_code — субъект РФ из тега поставщика (raw_payload['tag']) — v8.26.
|
||||
*
|
||||
* Источник истины региона сделки = тег проекта у поставщика (надёжнее phone-prefix
|
||||
* для мобильных). Отдельно от deals.region_code (ISO-3166, phone-derived).
|
||||
* deals партиционирована — ADD COLUMN наследуется партициями.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasColumn('deals', 'subject_code')) {
|
||||
DB::statement('ALTER TABLE deals ADD COLUMN subject_code SMALLINT');
|
||||
}
|
||||
|
||||
DB::statement(
|
||||
'COMMENT ON COLUMN deals.subject_code IS '
|
||||
."'Субъект РФ 1..89 из тега поставщика (raw_payload[tag]). NULL = «Вся РФ»/неизвестно. v8.26.'"
|
||||
);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (Schema::hasColumn('deals', 'subject_code')) {
|
||||
Schema::table('deals', fn ($t) => $t->dropColumn('subject_code'));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Глобальный тумблер режима экспорта проектов поставщику (v8.26).
|
||||
* 'batch' (default, прод-безопасно) | 'online'. Резолвится SupplierExportMode (План 3).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$exists = DB::table('system_settings')->where('key', 'supplier_export_mode')->exists();
|
||||
if ($exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('system_settings')->insert([
|
||||
'key' => 'supplier_export_mode',
|
||||
'value' => 'batch',
|
||||
'type' => 'string',
|
||||
'description' => 'Режим экспорта проектов поставщику: batch (ночной 18:00) | online (сразу при правке).',
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::table('system_settings')->where('key', 'supplier_export_mode')->delete();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Бэкофилл pivot project_supplier_links из legacy supplier_b{1,2,3}_project_id (v8.26).
|
||||
*
|
||||
* Для каждого ненулевого слота → строка pivot (subject_code=NULL: legacy-записи без
|
||||
* субъекта). Идемпотентно — ON CONFLICT DO NOTHING по uq_psl_project_supplier.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
foreach (['B1' => 'supplier_b1_project_id', 'B2' => 'supplier_b2_project_id', 'B3' => 'supplier_b3_project_id'] as $platform => $col) {
|
||||
DB::statement(
|
||||
'INSERT INTO project_supplier_links (project_id, supplier_project_id, platform, subject_code, created_at) '
|
||||
."SELECT id, {$col}, ?, NULL, NOW() FROM projects WHERE {$col} IS NOT NULL "
|
||||
.'ON CONFLICT (project_id, supplier_project_id) DO NOTHING',
|
||||
[$platform]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Бэкофилл-данные не откатываем точечно (pivot живёт дальше); no-op.
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* deals.subject_code range CHECK 1..89 — defensive parity с supplier_projects.subject_code (v8.26).
|
||||
*
|
||||
* Reviewer-finding (Plan 1 code-quality): supplier_projects.subject_code имеет CHECK 1..89,
|
||||
* deals.subject_code — только COMMENT. Malformed webhook tag → silent garbage в deals →
|
||||
* downstream report-by-region undercounts. NOT VALID + VALIDATE (squawk-safe), idempotent.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::statement(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_deals_subject_code'
|
||||
) THEN
|
||||
ALTER TABLE deals
|
||||
ADD CONSTRAINT chk_deals_subject_code
|
||||
CHECK (subject_code IS NULL OR (subject_code BETWEEN 1 AND 89)) NOT VALID;
|
||||
END IF;
|
||||
END $$;
|
||||
SQL);
|
||||
DB::statement('ALTER TABLE deals VALIDATE CONSTRAINT chk_deals_subject_code');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('ALTER TABLE deals DROP CONSTRAINT IF EXISTS chk_deals_subject_code');
|
||||
}
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user