Compare commits
294 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
+11
-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",
|
||||
@@ -94,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,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,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,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
|
||||
@@ -39,11 +39,7 @@
|
||||
"args": ["-y", "@modelcontextprotocol/server-redis", "redis://localhost:6379"],
|
||||
"comment": "Off-phase tool — Redis MCP для Memurai (Windows service, Redis 7-совместимый, localhost:6379). Pending формализация в Tooling §3.3 #35 — sync нормативки отдельным планом. Package: @modelcontextprotocol/server-redis@2025.4.25 — DEPRECATED по статусу npm («Package no longer supported»), но Anthropic source, простой протокол, рабочий. Post-MVP migration на community alternative (e.g., @easy-mcps/redis-mcp-server@1.0.8 или @wenit/redis-mcp-server@1.0.3) когда подтвердим trust. READ-ONLY use — отладка очередей, кэша, Pest --parallel race (memory quirk 72). НЕ для prod (нет prod). Если в будущем prod Redis с auth — отдельный entry redis-prod с url через env var."
|
||||
},
|
||||
"ruflo": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "ruflo@latest", "mcp", "start"],
|
||||
"comment": "Off-phase orchestration MCP — exposes ~210 ruflo tools (Core/Intelligence/Agents/Memory/DevTools). Package: ruflo v3.7.0-alpha.38+ MIT (npm `ruflo`, repo ruvnet/claude-flow legacy after rename Jan-2026; plugin namespace @claude-flow/*). Plugin discovery via IPFS (CID QmeXmAdbWVvT84GfDXPD2Vg1HWhiTW2VdZfRLhkS96KkX2) — Pinata+Cloudflare gateways flaky 2026-05-15, only ipfs.io reliable. stdio mode (no port-conflict). Big-bang integration per spec/plan 2026-05-15-ruflo-integration-design.md (commit a68a0a0+). Pending формализация в Tooling §4.10 — Phase 3 Task 3.4."
|
||||
},
|
||||
"_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", "mcp-universal-icons"],
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,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));
|
||||
|
||||
@@ -109,7 +109,7 @@ class DealController extends Controller
|
||||
->limit(1),
|
||||
])
|
||||
->where('tenant_id', $tenantId)
|
||||
->with(['project:id,name,signal_type', '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');
|
||||
@@ -213,6 +213,9 @@ class DealController extends Controller
|
||||
'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,
|
||||
@@ -248,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) {
|
||||
@@ -290,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,
|
||||
@@ -432,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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -20,7 +20,7 @@ class BulkProjectActionRequest extends FormRequest
|
||||
|
||||
$rules = [
|
||||
'action' => ['required', Rule::in([
|
||||
'pause', 'resume', 'archive',
|
||||
'pause', 'resume', 'delete',
|
||||
'update_regions', 'update_days', 'update_limit',
|
||||
])],
|
||||
'ids' => ['nullable', 'array', 'max:500'],
|
||||
@@ -28,7 +28,7 @@ class BulkProjectActionRequest extends FormRequest
|
||||
'scope' => ['nullable', 'array'],
|
||||
'scope.filter' => ['nullable', 'array'],
|
||||
'scope.filter.signal_type' => ['nullable', 'string', Rule::in(['site', 'call', 'sms'])],
|
||||
'scope.filter.status' => ['nullable', 'string', Rule::in(['active', 'paused', 'archived'])],
|
||||
'scope.filter.status' => ['nullable', 'string', Rule::in(['active', 'paused'])],
|
||||
'scope.filter.search' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\Project;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateProjectRequest extends FormRequest
|
||||
@@ -16,7 +17,7 @@ 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'],
|
||||
// Plan 6: subject-level regions[] заменил region_mask/region_mode на API-уровне.
|
||||
@@ -28,5 +29,23 @@ class UpdateProjectRequest extends FormRequest
|
||||
'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,7 +25,6 @@ class ProjectResource extends JsonResource
|
||||
'delivered_today' => $this->delivered_today,
|
||||
'delivered_in_month' => $this->delivered_in_month,
|
||||
'is_active' => $this->is_active,
|
||||
'archived_at' => $project->archived_at?->toIso8601String(),
|
||||
'region_mask' => $this->region_mask,
|
||||
'region_mode' => $this->region_mode,
|
||||
'regions' => $this->regions,
|
||||
|
||||
@@ -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,122 +180,285 @@ 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);
|
||||
|
||||
$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: saveProjectMultiFlag → [platform => external_id]
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: $platforms[0],
|
||||
signalType: $signalType,
|
||||
uniqueKey: $identifier,
|
||||
limit: $order,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: $platforms,
|
||||
);
|
||||
|
||||
$adapted = $this->adaptProjectsForAllocator($liderraProjects);
|
||||
$idMap = $this->client->saveProjectMultiFlag($dto);
|
||||
|
||||
$allocation = SupplierQuotaAllocator::allocate(
|
||||
platform: $sp->platform,
|
||||
signalType: $sp->signal_type,
|
||||
uniqueKey: $sp->unique_key,
|
||||
activeLiderraProjects: $adapted,
|
||||
targetDate: Carbon::tomorrow('Europe/Moscow'),
|
||||
);
|
||||
// Upsert supplier_projects rows (one per platform)
|
||||
foreach ($platforms as $platform) {
|
||||
$externalId = $idMap[$platform] ?? null;
|
||||
if ($externalId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($allocation === null) {
|
||||
return;
|
||||
}
|
||||
$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' => $order,
|
||||
'current_workdays' => $workdays,
|
||||
'current_regions' => $allRegions,
|
||||
'sync_status' => 'ok',
|
||||
'last_synced_at' => now(),
|
||||
]);
|
||||
|
||||
$current = SupplierProjectDto::fromModel($sp);
|
||||
if ($allocation->equals($current)) {
|
||||
return;
|
||||
}
|
||||
SupplierSyncLog::on(self::DB_CONNECTION)->create([
|
||||
'supplier_project_id' => $sp->id,
|
||||
'action' => 'create',
|
||||
'http_status' => 200,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$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()) {
|
||||
$deadPlatforms = array_values($deadSps->pluck('platform')->all());
|
||||
$recreateDto = new SupplierProjectDto(
|
||||
platform: $deadPlatforms[0],
|
||||
signalType: $signalType,
|
||||
uniqueKey: $identifier,
|
||||
limit: $order,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: $deadPlatforms,
|
||||
);
|
||||
|
||||
$recreatedIdMap = $this->client->saveProjectMultiFlag($recreateDto);
|
||||
|
||||
foreach ($deadSps as $sp) {
|
||||
$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 !== []) {
|
||||
$missingDto = new SupplierProjectDto(
|
||||
platform: $missingPlatforms[0],
|
||||
signalType: $signalType,
|
||||
uniqueKey: $identifier,
|
||||
limit: $order,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: $missingPlatforms,
|
||||
);
|
||||
|
||||
$missingIdMap = $this->client->saveProjectMultiFlag($missingDto);
|
||||
|
||||
foreach ($missingPlatforms as $platform) {
|
||||
$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' => $order,
|
||||
'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);
|
||||
}
|
||||
}
|
||||
|
||||
// Fix #2 (review-followup): per-platform DTO в update-loop, чтобы portal получал
|
||||
// правильные srcrt/srcbl/srcmt для конкретной редактируемой строки (не first()
|
||||
// из mixed-platform existing set). R6 one shared limit/regions сохраняется.
|
||||
foreach ($existingSps as $sp) {
|
||||
if ($sp->supplier_external_id === null) {
|
||||
continue;
|
||||
}
|
||||
$perPlatformDto = new SupplierProjectDto(
|
||||
platform: $sp->platform,
|
||||
signalType: $signalType,
|
||||
uniqueKey: $identifier,
|
||||
limit: $order,
|
||||
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' => $order,
|
||||
'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 ← projects.regions INT[] (subject codes 1..89) direct copy
|
||||
*
|
||||
* @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);
|
||||
|
||||
// Plan 6: projects.regions[] напрямую копируется в supplier_projects.current_regions.
|
||||
// Empty array = "вся РФ" (паритет с supplier API semantics).
|
||||
// Legacy region_mask/region_mode игнорируются — они dual-write для PhonePrefixService,
|
||||
// outbound к supplier использует только regions[]. Cleanup в Plan 6.5.
|
||||
$obj->regions = array_values((array) $p->regions);
|
||||
|
||||
return $obj;
|
||||
})->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bitmask → ordered list 1..maxBits для bits, выставленных в 1.
|
||||
* Bitmask → ordered list 1..maxBits.
|
||||
*
|
||||
* @return array<int, int>
|
||||
*/
|
||||
@@ -245,14 +473,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,51 @@ 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\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
|
||||
{
|
||||
@@ -39,7 +62,7 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
|
||||
public function __construct(public int $projectId) {}
|
||||
|
||||
public function handle(SupplierPortalClient $client): void
|
||||
public function handle(SupplierProjectChannel $channel): void
|
||||
{
|
||||
$project = Project::find($this->projectId);
|
||||
|
||||
@@ -49,57 +72,334 @@ 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);
|
||||
|
||||
// Idempotency: find existing by identifier regardless of subject_code (any previous run).
|
||||
$existingSps = SupplierProject::query()
|
||||
->where('unique_key', $identifier)
|
||||
->where('signal_type', (string) $project->signal_type)
|
||||
->whereIn('platform', $platforms)
|
||||
->get();
|
||||
|
||||
if ($existingSps->isEmpty()) {
|
||||
// Create path: saveProjectMultiFlag → [platform => external_id]
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: $platforms[0],
|
||||
signalType: (string) $project->signal_type,
|
||||
uniqueKey: $identifier,
|
||||
limit: (int) $project->daily_limit_target,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: $platforms,
|
||||
);
|
||||
|
||||
try {
|
||||
$idMap = $client->saveProjectMultiFlag($dto);
|
||||
} catch (TierEscalatedException $e) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} escalated to manual queue #{$e->queueRowId}");
|
||||
|
||||
return;
|
||||
} catch (WindowDeferredException) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} deferred by portal window");
|
||||
|
||||
return;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("SyncSupplierProjectJob: online multi-flag save failed for project {$project->id} (".get_class($e).'): '.$e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($platforms as $platform) {
|
||||
$externalId = $idMap[$platform] ?? null;
|
||||
if ($externalId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sp = SupplierProject::create([
|
||||
'platform' => $platform,
|
||||
'signal_type' => (string) $project->signal_type,
|
||||
'unique_key' => $identifier,
|
||||
'subject_code' => null,
|
||||
'supplier_external_id' => (string) $externalId,
|
||||
'current_limit' => (int) $project->daily_limit_target,
|
||||
'current_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());
|
||||
$recreateDto = new SupplierProjectDto(
|
||||
platform: $deadPlatforms[0],
|
||||
signalType: (string) $project->signal_type,
|
||||
uniqueKey: $identifier,
|
||||
limit: (int) $project->daily_limit_target,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: $deadPlatforms,
|
||||
);
|
||||
|
||||
try {
|
||||
$recreatedIdMap = $client->saveProjectMultiFlag($recreateDto);
|
||||
} catch (TierEscalatedException $e) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} dead-donor re-create escalated #{$e->queueRowId}");
|
||||
$recreatedIdMap = [];
|
||||
} catch (WindowDeferredException) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} dead-donor re-create deferred by portal window");
|
||||
$recreatedIdMap = [];
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("SyncSupplierProjectJob: dead-donor re-create failed for project {$project->id}: ".$e->getMessage());
|
||||
$recreatedIdMap = [];
|
||||
}
|
||||
|
||||
foreach ($deadSps as $sp) {
|
||||
$newId = $recreatedIdMap[$sp->platform] ?? null;
|
||||
if ($newId !== null) {
|
||||
$sp->forceFill(['supplier_external_id' => (string) $newId])->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Partial-set recovery: если предыдущий run создал не все platforms.
|
||||
$existingPlatforms = $existingSps->pluck('platform')->all();
|
||||
$missingPlatforms = array_values(array_diff($platforms, $existingPlatforms));
|
||||
|
||||
if ($missingPlatforms !== []) {
|
||||
$missingDto = new SupplierProjectDto(
|
||||
platform: $missingPlatforms[0],
|
||||
signalType: (string) $project->signal_type,
|
||||
uniqueKey: $identifier,
|
||||
limit: (int) $project->daily_limit_target,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: $missingPlatforms,
|
||||
);
|
||||
|
||||
try {
|
||||
$missingIdMap = $client->saveProjectMultiFlag($missingDto);
|
||||
} catch (TierEscalatedException $e) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} missing-platform re-attempt escalated #{$e->queueRowId}");
|
||||
$missingIdMap = [];
|
||||
} catch (WindowDeferredException) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} missing-platform deferred by portal window");
|
||||
$missingIdMap = [];
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("SyncSupplierProjectJob: missing-platform multi-flag failed for project {$project->id}: ".$e->getMessage());
|
||||
$missingIdMap = [];
|
||||
}
|
||||
|
||||
foreach ($missingPlatforms as $platform) {
|
||||
$externalId = $missingIdMap[$platform] ?? null;
|
||||
if ($externalId === null) {
|
||||
continue;
|
||||
}
|
||||
$sp = SupplierProject::create([
|
||||
'platform' => $platform,
|
||||
'signal_type' => (string) $project->signal_type,
|
||||
'unique_key' => $identifier,
|
||||
'subject_code' => null,
|
||||
'supplier_external_id' => (string) $externalId,
|
||||
'current_limit' => (int) $project->daily_limit_target,
|
||||
'current_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: (int) $project->daily_limit_target,
|
||||
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' => (int) $project->daily_limit_target,
|
||||
'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::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::query()
|
||||
->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::query()->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'.
|
||||
* Bitmask → ISO weekday list. bit 0 = Mon (ISO 1) … bit 6 = Sun (ISO 7).
|
||||
*
|
||||
* @return array<int, string>
|
||||
* Mirror of SyncSupplierProjectsJob::bitmaskToList(). Kept inline (not
|
||||
* extracted to a shared helper) to keep this fix surgical.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function resolvePlatforms(Project $project): array
|
||||
private function workdaysFromMask(int $mask): array
|
||||
{
|
||||
if (in_array($project->signal_type, ['site', 'call'], true)) {
|
||||
return ['B1', 'B2', 'B3'];
|
||||
$out = [];
|
||||
for ($i = 0; $i < 7; $i++) {
|
||||
if (($mask & (1 << $i)) !== 0) {
|
||||
$out[] = $i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if ($project->signal_type === 'sms') {
|
||||
return $project->sms_keyword ? ['B2', 'B3'] : ['B3'];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Строит unique_key для пары (project, platform):
|
||||
* site/call → signal_identifier (домен / телефон)
|
||||
* sms B2 → sender + '+' + keyword
|
||||
* sms B3 → sender
|
||||
*/
|
||||
private function buildUniqueKey(Project $project, string $platform): string
|
||||
{
|
||||
if (in_array($project->signal_type, ['site', 'call'], true)) {
|
||||
return (string) $project->signal_identifier;
|
||||
}
|
||||
|
||||
// sms
|
||||
$sender = (string) ($project->sms_senders[0] ?? '');
|
||||
|
||||
if ($platform === 'B2') {
|
||||
return $sender.'+'.($project->sms_keyword ?? '');
|
||||
}
|
||||
|
||||
// B3
|
||||
return $sender;
|
||||
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',
|
||||
|
||||
+10
-31
@@ -11,6 +11,7 @@ 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;
|
||||
|
||||
/**
|
||||
@@ -39,8 +40,6 @@ class Project extends Model
|
||||
'tag',
|
||||
'type',
|
||||
'is_active',
|
||||
// Plan 5 Task 1 (schema v8.20): soft archive flow — lifecycle-state рядом с is_active.
|
||||
'archived_at',
|
||||
'daily_limit_target',
|
||||
'effective_daily_limit_today',
|
||||
'effective_limit_calculated_at',
|
||||
@@ -86,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',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -115,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.
|
||||
*
|
||||
@@ -141,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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,6 +143,29 @@ class ProjectService
|
||||
return ['updated' => $updated, 'skipped' => [], 'warnings' => []];
|
||||
}
|
||||
|
||||
private function bulkDelete($query): array
|
||||
{
|
||||
$projects = (clone $query)->get(['id']);
|
||||
$deleted = 0;
|
||||
$skipped = [];
|
||||
|
||||
foreach ($projects as $p) {
|
||||
$model = Project::find($p->id);
|
||||
if ($model === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->delete($model);
|
||||
$deleted++;
|
||||
} catch (HttpResponseException) {
|
||||
$skipped[] = ['id' => $p->id, 'reason' => 'has_deals'];
|
||||
}
|
||||
}
|
||||
|
||||
return ['updated' => $deleted, 'skipped' => $skipped, 'warnings' => []];
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan 6.5: субъект-уровневый bulk-edit `regions` INT[].
|
||||
*
|
||||
@@ -205,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}). Смените тариф.",
|
||||
@@ -222,6 +324,10 @@ class ProjectService
|
||||
// PhonePrefixService / LeadRouter, удаляются в Plan 6.5 после переключения читателей.
|
||||
$data['region_mask'] = 255;
|
||||
$data['region_mode'] = 'include';
|
||||
|
||||
$this->assertNameUnique($tenant->id, (string) $data['name']);
|
||||
$this->assertSourceUnique($tenant->id, $data);
|
||||
|
||||
$project = Project::create($data);
|
||||
|
||||
SyncSupplierProjectJob::dispatch($project->id);
|
||||
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
@@ -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,7 +8,6 @@ use App\Exceptions\Supplier\SupplierAuthException;
|
||||
use App\Exceptions\Supplier\SupplierClientException;
|
||||
use App\Exceptions\Supplier\SupplierTransientException;
|
||||
use App\Jobs\Supplier\RefreshSupplierSessionJob;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Services\Supplier\Dto\SupplierProjectDto;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
@@ -21,14 +20,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 +47,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 +284,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 +299,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 +354,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 +421,69 @@ 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,
|
||||
'workdays' => $workdays,
|
||||
'regions' => $dto->regions,
|
||||
'regions_reverse' => $dto->regionsReverse ? 1 : 0,
|
||||
'status' => $dto->status,
|
||||
'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,24 @@ 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 платформами.
|
||||
* Эпик миграции проектов (Plan 3): platform-split B1/B2/B3 удалён — портал
|
||||
* делит лимит сам (R6). Один лимит на группу 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 раз).
|
||||
* наиб — крупнейший клиент должен иметь шанс добрать.
|
||||
*
|
||||
* `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 +54,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 +70,26 @@ final class SupplierQuotaAllocator
|
||||
);
|
||||
}
|
||||
|
||||
private static function distributeForPlatform(string $signalType, string $platform, int $total): int
|
||||
/**
|
||||
* Заказ у поставщика на (источник × субъект): max(наибольший лимит, ceil(Σ/3)).
|
||||
*
|
||||
* ceil(Σ/3) — ёмкость шаринга (лид продаётся ≤3 раз).
|
||||
* наиб — крупнейший клиент должен иметь шанс добрать.
|
||||
*
|
||||
* Один лимит на группу; портал делит на B1/B2/B3 сам (R6 — наш split убран).
|
||||
*
|
||||
* @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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+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();
|
||||
|
||||
+8
-1
@@ -18,6 +18,7 @@
|
||||
"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": "*",
|
||||
@@ -27,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": {
|
||||
@@ -64,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",
|
||||
@@ -102,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
+2162
-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,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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::statement('ALTER TABLE projects DROP COLUMN IF EXISTS archived_at');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('ALTER TABLE projects ADD COLUMN archived_at TIMESTAMPTZ NULL');
|
||||
}
|
||||
};
|
||||
+225
-39
@@ -204,12 +204,6 @@ parameters:
|
||||
count: 3
|
||||
path: app/Jobs/ImportLeadsJob.php
|
||||
|
||||
-
|
||||
message: '#^Parameter \#1 \$array \(array\{string\}\) of array_values is already a list, call has no effect\.$#'
|
||||
identifier: arrayValues.list
|
||||
count: 1
|
||||
path: app/Jobs/Supplier/SyncSupplierProjectsJob.php
|
||||
|
||||
-
|
||||
message: '#^Using nullsafe property access "\?\-\>name" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
|
||||
identifier: nullsafe.neverNull
|
||||
@@ -252,6 +246,102 @@ parameters:
|
||||
count: 1
|
||||
path: app/Services/Project/ProjectService.php
|
||||
|
||||
-
|
||||
message: '#^Call to function is_array\(\) with array\<string, mixed\> will always evaluate to true\.$#'
|
||||
identifier: function.alreadyNarrowedType
|
||||
count: 1
|
||||
path: app/Services/Supplier/Channel/AjaxProjectChannel.php
|
||||
|
||||
-
|
||||
message: '#^Parameter \#1 \$array \(array\{string\}\) of array_values is already a list, call has no effect\.$#'
|
||||
identifier: arrayValues.list
|
||||
count: 1
|
||||
path: app/Services/Supplier/SupplierProjectGrouping.php
|
||||
|
||||
-
|
||||
message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\ForbiddenDefineFunctions not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\ForbiddenFinalClasses not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\ForbiddenNormalClasses not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\ForbiddenPrivateMethods not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\ForbiddenTraits not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\SyntaxCheck not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Metrics\\Architecture\\Classes not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class SlevomatCodingStandard\\Sniffs\\Commenting\\UselessFunctionDocCommentSniff not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class SlevomatCodingStandard\\Sniffs\\Namespaces\\AlphabeticallySortedUsesSniff not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class SlevomatCodingStandard\\Sniffs\\TypeHints\\DeclareStrictTypesSniff not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class SlevomatCodingStandard\\Sniffs\\TypeHints\\DisallowMixedTypeHintSniff not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class SlevomatCodingStandard\\Sniffs\\TypeHints\\ParameterTypeHintSniff not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class SlevomatCodingStandard\\Sniffs\\TypeHints\\PropertyTypeHintSniff not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class SlevomatCodingStandard\\Sniffs\\TypeHints\\ReturnTypeHintSniff not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Return type \(array\<string, mixed\>\) of method Database\\Factories\\BalanceTransactionFactory\:\:definition\(\) should be compatible with return type \(array\<model property of App\\Models\\BalanceTransaction, mixed\>\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\<App\\Models\\BalanceTransaction\>\:\:definition\(\)$#'
|
||||
identifier: method.childReturnType
|
||||
@@ -318,6 +408,18 @@ parameters:
|
||||
count: 1
|
||||
path: tests/Feature/Admin/AdminPricingTiersControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Admin/AdminSupplierIntegrationTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Admin/AdminSupplierIntegrationTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -330,6 +432,60 @@ parameters:
|
||||
count: 3
|
||||
path: tests/Feature/Admin/AdminSuppliersControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Admin/SupplierExportModeEndpointTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Admin/SupplierExportModeEndpointTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Admin/SupplierExportModeEndpointTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 4
|
||||
path: tests/Feature/Admin/SupplierManualQueueTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Admin/SupplierManualQueueTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Admin/SupplierManualQueueTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 5
|
||||
path: tests/Feature/Admin/SupplierProjectsAdminTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Admin/SupplierProjectsAdminTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 4
|
||||
path: tests/Feature/Admin/SupplierProjectsAdminTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -1059,7 +1215,7 @@ parameters:
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 13
|
||||
count: 20
|
||||
path: tests/Feature/DealShowTest.php
|
||||
|
||||
-
|
||||
@@ -1077,7 +1233,7 @@ parameters:
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 7
|
||||
count: 10
|
||||
path: tests/Feature/DealShowTest.php
|
||||
|
||||
-
|
||||
@@ -1497,9 +1653,15 @@ parameters:
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 8
|
||||
count: 14
|
||||
path: tests/Feature/Plan5/Projects/ProjectsUpdateTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Project/QueryExceptionRenderTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
@@ -1746,6 +1908,42 @@ parameters:
|
||||
count: 1
|
||||
path: tests/Feature/Supplier/AutoPauseFlowTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Services\\Supplier\\PlaywrightBridge\:\:\$lastArgs\.$#'
|
||||
identifier: property.notFound
|
||||
count: 7
|
||||
path: tests/Feature/Supplier/Channel/FormProjectChannelTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Illuminate\\Contracts\\Cache\\Repository\:\:lock\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Supplier/CsvReconcileJobTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:andThrow\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Supplier/FailoverProjectChannelLiveSmokeTest.php
|
||||
|
||||
-
|
||||
message: '#^Parameter \#1 \$tier1 of class App\\Services\\Supplier\\Channel\\FailoverProjectChannel constructor expects App\\Services\\Supplier\\Channel\\SupplierProjectChannel, Mockery\\MockInterface given\.$#'
|
||||
identifier: argument.type
|
||||
count: 2
|
||||
path: tests/Feature/Supplier/FailoverProjectChannelLiveSmokeTest.php
|
||||
|
||||
-
|
||||
message: '#^Parameter \#2 \$tier2 of class App\\Services\\Supplier\\Channel\\FailoverProjectChannel constructor expects App\\Services\\Supplier\\Channel\\SupplierProjectChannel, Mockery\\MockInterface given\.$#'
|
||||
identifier: argument.type
|
||||
count: 2
|
||||
path: tests/Feature/Supplier/FailoverProjectChannelLiveSmokeTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:once\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Supplier/DeleteSupplierProjectJobTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -1782,6 +1980,12 @@ parameters:
|
||||
count: 1
|
||||
path: tests/Feature/Supplier/SupplierSessionRefreshCommandTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:mock\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Supplier/SyncSupplierProjectJobTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
@@ -1902,18 +2106,6 @@ parameters:
|
||||
count: 1
|
||||
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
|
||||
|
||||
-
|
||||
message: '#^Parameter \#4 \$activeLiderraProjects of static method App\\Services\\Supplier\\SupplierQuotaAllocator\:\:allocate\(\) expects Illuminate\\Support\\Collection\<int, stdClass\>, Illuminate\\Support\\Collection\<int, object\{daily_limit\: 1, workdays\: array\{1, 2, 3, 4, 5\}, regions\: array\{\}\}&stdClass\> given\.$#'
|
||||
identifier: argument.type
|
||||
count: 3
|
||||
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
|
||||
|
||||
-
|
||||
message: '#^Parameter \#4 \$activeLiderraProjects of static method App\\Services\\Supplier\\SupplierQuotaAllocator\:\:allocate\(\) expects Illuminate\\Support\\Collection\<int, stdClass\>, Illuminate\\Support\\Collection\<int, object\{daily_limit\: 10, workdays\: array\{1, 2, 3, 4, 5\}, regions\: array\{\}\}&stdClass\> given\.$#'
|
||||
identifier: argument.type
|
||||
count: 6
|
||||
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
|
||||
|
||||
-
|
||||
message: '#^Parameter \#4 \$activeLiderraProjects of static method App\\Services\\Supplier\\SupplierQuotaAllocator\:\:allocate\(\) expects Illuminate\\Support\\Collection\<int, stdClass\>, Illuminate\\Support\\Collection\<int, object\{daily_limit\: 10, workdays\: array\{6, 7\}, regions\: array\{\}\}&stdClass\> given\.$#'
|
||||
identifier: argument.type
|
||||
@@ -1921,25 +2113,19 @@ parameters:
|
||||
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
|
||||
|
||||
-
|
||||
message: '#^Parameter \#4 \$activeLiderraProjects of static method App\\Services\\Supplier\\SupplierQuotaAllocator\:\:allocate\(\) expects Illuminate\\Support\\Collection\<int, stdClass\>, Illuminate\\Support\\Collection\<int, object\{daily_limit\: 30, workdays\: array\{1, 2, 3, 4, 5\}, regions\: array\{\}\}&stdClass\> given\.$#'
|
||||
identifier: argument.type
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 6
|
||||
path: tests/Feature/Project/ProjectCreateDedupTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:fail\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
|
||||
path: tests/Feature/Project/ProjectCreateDedupTest.php
|
||||
|
||||
-
|
||||
message: '#^Parameter \#4 \$activeLiderraProjects of static method App\\Services\\Supplier\\SupplierQuotaAllocator\:\:allocate\(\) expects Illuminate\\Support\\Collection\<int, stdClass\>, Illuminate\\Support\\Collection\<int, object\{daily_limit\: 4, workdays\: array\{1, 2, 3, 4, 5\}, regions\: array\{\}\}&stdClass\> given\.$#'
|
||||
identifier: argument.type
|
||||
count: 3
|
||||
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
|
||||
|
||||
-
|
||||
message: '#^Parameter \#4 \$activeLiderraProjects of static method App\\Services\\Supplier\\SupplierQuotaAllocator\:\:allocate\(\) expects Illuminate\\Support\\Collection\<int, stdClass\>, Illuminate\\Support\\Collection\<int, object\{daily_limit\: 5, workdays\: array\{1, 2, 3, 4, 5\}, regions\: array\{\}\}&stdClass\> given\.$#'
|
||||
identifier: argument.type
|
||||
message: '#^Call to an undefined method Symfony\\Component\\HttpFoundation\\Response\:\:getData\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
|
||||
|
||||
-
|
||||
message: '#^Parameter \#4 \$activeLiderraProjects of static method App\\Services\\Supplier\\SupplierQuotaAllocator\:\:allocate\(\) expects Illuminate\\Support\\Collection\<int, stdClass\>, Illuminate\\Support\\Collection\<int, object\{daily_limit\: 7, workdays\: array\{1, 2, 3, 4, 5\}, regions\: array\{\}\}&stdClass\> given\.$#'
|
||||
identifier: argument.type
|
||||
count: 3
|
||||
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
|
||||
path: tests/Feature/Project/ProjectCreateDedupTest.php
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>RT Project Form Fixture — Element UI + Vuetify dialog</title>
|
||||
<style>
|
||||
/* Minimal stubs so Playwright class-based locators work */
|
||||
.el-form-item { margin-bottom: 12px; }
|
||||
.el-form-item__label { display: inline-block; min-width: 140px; }
|
||||
.el-form-item__content { display: inline-block; }
|
||||
.el-input__inner { border: 1px solid #cccccc; padding: 4px 8px; }
|
||||
.el-checkbox { cursor: pointer; margin-right: 8px; }
|
||||
.el-checkbox__input.is-checked .el-checkbox__inner { background: #409eff; }
|
||||
.el-checkbox__inner { display: inline-block; width: 14px; height: 14px; border: 1px solid #cccccc; }
|
||||
.el-switch { cursor: pointer; }
|
||||
.el-switch.is-checked .el-switch__core { background: #409eff; }
|
||||
.el-switch__core { display: inline-block; width: 40px; height: 20px; border-radius: 10px; background: #cccccc; }
|
||||
.el-select-dropdown { position: absolute; background: #ffffff; border: 1px solid #cccccc; z-index: 9999; min-width: 120px; }
|
||||
.el-select-dropdown__item { padding: 6px 12px; cursor: pointer; }
|
||||
.el-select-dropdown__item:hover { background: #f5f7fa; }
|
||||
.el-button { padding: 6px 16px; cursor: pointer; border: 1px solid #cccccc; background: #ffffff; }
|
||||
.el-input-number .el-input__inner { width: 80px; }
|
||||
</style>
|
||||
</head><body>
|
||||
|
||||
<!-- Vuetify dialog wrapper — required by manage-project.js locator ".v-dialog--active button:has-text(...)" -->
|
||||
<div class="v-dialog v-dialog--active v-dialog--persistent" style="padding:16px;">
|
||||
|
||||
<form class="el-form el-form--label-left">
|
||||
|
||||
<!-- 1. Tag -->
|
||||
<div class="el-form-item">
|
||||
<label class="el-form-item__label" for="tag">Тег</label>
|
||||
<div class="el-form-item__content">
|
||||
<div class="el-input">
|
||||
<input type="text" class="el-input__inner" id="tag-fixture">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. Источник данных (B1/B2/B3 checkboxes) — label for="srcrt" -->
|
||||
<div class="el-form-item">
|
||||
<label class="el-form-item__label" for="srcrt">Источник данных</label>
|
||||
<div class="el-form-item__content" id="srcrt-container">
|
||||
<label class="el-checkbox is-checked" data-platform="B1">
|
||||
<span class="el-checkbox__input is-checked">
|
||||
<span class="el-checkbox__inner"></span>
|
||||
<input type="checkbox" class="el-checkbox__original" checked>
|
||||
</span>
|
||||
<span class="el-checkbox__label">B1</span>
|
||||
</label>
|
||||
<label class="el-checkbox is-checked" data-platform="B2">
|
||||
<span class="el-checkbox__input is-checked">
|
||||
<span class="el-checkbox__inner"></span>
|
||||
<input type="checkbox" class="el-checkbox__original" checked>
|
||||
</span>
|
||||
<span class="el-checkbox__label">B2</span>
|
||||
</label>
|
||||
<label class="el-checkbox is-checked" data-platform="B3">
|
||||
<span class="el-checkbox__input is-checked">
|
||||
<span class="el-checkbox__inner"></span>
|
||||
<input type="checkbox" class="el-checkbox__original" checked>
|
||||
</span>
|
||||
<span class="el-checkbox__label">B3</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. Name — label for="name" -->
|
||||
<div class="el-form-item">
|
||||
<label class="el-form-item__label" for="name">Название проекта</label>
|
||||
<div class="el-form-item__content">
|
||||
<div class="el-input">
|
||||
<input type="text" class="el-input__inner" id="name-fixture">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. Type select — label for="type" -->
|
||||
<div class="el-form-item">
|
||||
<label class="el-form-item__label" for="type">Источники сбора</label>
|
||||
<div class="el-form-item__content">
|
||||
<div class="el-select" id="type-select-container">
|
||||
<!-- readonly input that shows selected value; clicking it opens dropdown popup in body -->
|
||||
<div class="el-input">
|
||||
<input type="text" class="el-input__inner" id="type-select-input" readonly
|
||||
value="Сайты" placeholder="Выберите" data-current-value="Сайты">
|
||||
<span class="el-input__suffix"><span class="el-select__caret">▼</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 5. Slider «Период» — no label-for, no DTO field, leave default -->
|
||||
<div class="el-form-item">
|
||||
<label class="el-form-item__label">Период</label>
|
||||
<div class="el-form-item__content">
|
||||
<div class="el-slider" aria-valuemin="0" aria-valuemax="24" aria-valuetext="10-18">
|
||||
<span style="font-size:12px;color:#999999">10-18 (default)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 6. Switch «Включить» — no label-for; identified by .el-switch in form-item -->
|
||||
<div class="el-form-item" id="switch-form-item">
|
||||
<label class="el-form-item__label">Статус</label>
|
||||
<div class="el-form-item__content">
|
||||
<div class="el-switch" id="active-switch">
|
||||
<input type="checkbox" class="el-switch__input" id="active-switch-input">
|
||||
<span class="el-switch__core"></span>
|
||||
<span>Включить</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 7. Regions — label for="regions", el-select multiple -->
|
||||
<div class="el-form-item">
|
||||
<label class="el-form-item__label" for="regions">Регион</label>
|
||||
<div class="el-form-item__content">
|
||||
<div class="el-select el-select--multiple">
|
||||
<input type="text" class="el-input__inner" id="regions-input" placeholder="Выберите регионы">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 8. limit_off — no label-for, no DTO field -->
|
||||
<div class="el-form-item">
|
||||
<label class="el-form-item__label" for="limit_off">Разделять по проектам</label>
|
||||
<div class="el-form-item__content">
|
||||
<label class="el-checkbox">
|
||||
<span class="el-checkbox__input">
|
||||
<span class="el-checkbox__inner"></span>
|
||||
<input type="checkbox" class="el-checkbox__original">
|
||||
</span>
|
||||
<span class="el-checkbox__label">Да</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 9. Content (uniqueKey / domains) — label for="content", el-tabs -->
|
||||
<div class="el-form-item">
|
||||
<label class="el-form-item__label" for="content">Список сайтов</label>
|
||||
<div class="el-form-item__content">
|
||||
<div class="el-tabs">
|
||||
<div class="el-tabs__header">
|
||||
<div class="el-tabs__item is-active" data-tab="list">Список</div>
|
||||
<div class="el-tabs__item" data-tab="file">Файл</div>
|
||||
</div>
|
||||
<div class="el-tabs__content">
|
||||
<textarea class="el-textarea__inner" id="content-textarea" rows="4" style="width:100%"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 10. Limit — label for="limit", el-input-number -->
|
||||
<div class="el-form-item">
|
||||
<label class="el-form-item__label" for="limit">Лимит в день</label>
|
||||
<div class="el-form-item__content">
|
||||
<div class="el-input-number">
|
||||
<span class="el-input-number__decrease">-</span>
|
||||
<div class="el-input">
|
||||
<input type="text" class="el-input__inner" id="limit-input" value="10">
|
||||
</div>
|
||||
<span class="el-input-number__increase">+</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form><!-- end .el-form -->
|
||||
|
||||
<!-- Save/Cancel buttons OUTSIDE form, INSIDE .v-dialog--active -->
|
||||
<div style="margin-top:16px;">
|
||||
<button type="button" class="el-button" id="save-btn">Сохранить</button>
|
||||
<button type="button" class="el-button" id="cancel-btn">Отмена</button>
|
||||
</div>
|
||||
|
||||
</div><!-- end .v-dialog--active -->
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// ---- Checkbox toggle behaviour ----
|
||||
// Click on .el-checkbox toggles .is-checked on itself and .el-checkbox__input child
|
||||
document.querySelectorAll('#srcrt-container .el-checkbox').forEach(function(cb) {
|
||||
cb.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
var isChecked = cb.classList.contains('is-checked');
|
||||
cb.classList.toggle('is-checked', !isChecked);
|
||||
var cbInput = cb.querySelector('.el-checkbox__input');
|
||||
if (cbInput) cbInput.classList.toggle('is-checked', !isChecked);
|
||||
var rawInput = cb.querySelector('input.el-checkbox__original');
|
||||
if (rawInput) rawInput.checked = !isChecked;
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Switch toggle behaviour ----
|
||||
var switchEl = document.getElementById('active-switch');
|
||||
if (switchEl) {
|
||||
switchEl.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
var isChecked = switchEl.classList.contains('is-checked');
|
||||
switchEl.classList.toggle('is-checked', !isChecked);
|
||||
var inp = document.getElementById('active-switch-input');
|
||||
if (inp) inp.checked = !isChecked;
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Type select popup ----
|
||||
// When input#type-select-input is clicked, create a dropdown in body
|
||||
var typeInput = document.getElementById('type-select-input');
|
||||
var typeOptions = ['Сайты', 'Звонки', 'СМС', 'Ретро сайты', 'Ретро звонки'];
|
||||
|
||||
function removeDropdown() {
|
||||
var existing = document.querySelector('body > .el-select-dropdown');
|
||||
if (existing) existing.remove();
|
||||
}
|
||||
|
||||
if (typeInput) {
|
||||
typeInput.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
removeDropdown();
|
||||
var dropdown = document.createElement('div');
|
||||
dropdown.className = 'el-select-dropdown el-popper';
|
||||
dropdown.style.position = 'absolute';
|
||||
dropdown.style.left = '20px';
|
||||
dropdown.style.top = '200px';
|
||||
var ul = document.createElement('ul');
|
||||
ul.className = 'el-scrollbar__view el-select-dropdown__list';
|
||||
typeOptions.forEach(function(opt) {
|
||||
var li = document.createElement('li');
|
||||
li.className = 'el-select-dropdown__item';
|
||||
li.textContent = opt;
|
||||
li.addEventListener('click', function(e2) {
|
||||
e2.stopPropagation();
|
||||
typeInput.value = opt;
|
||||
typeInput.setAttribute('data-current-value', opt);
|
||||
removeDropdown();
|
||||
});
|
||||
ul.appendChild(li);
|
||||
});
|
||||
dropdown.appendChild(ul);
|
||||
document.body.appendChild(dropdown);
|
||||
});
|
||||
}
|
||||
|
||||
// Close dropdown on outside click
|
||||
document.addEventListener('click', function() {
|
||||
removeDropdown();
|
||||
});
|
||||
|
||||
// ---- Save button: POST to /admin/visit/rt-project-save on the same origin ----
|
||||
// NOTE: NO fetch mock here — the HTTP server (manage-project.test.js) handles
|
||||
// this route and returns {status:"OK",id:"99001"}. Playwright's waitForResponse
|
||||
// intercepts real network requests, not mocked fetch.
|
||||
document.getElementById('save-btn').addEventListener('click', function() {
|
||||
var payload = {
|
||||
tag: document.getElementById('tag-fixture') ? document.getElementById('tag-fixture').value : '',
|
||||
name: document.getElementById('name-fixture') ? document.getElementById('name-fixture').value : '',
|
||||
type: typeInput ? typeInput.getAttribute('data-current-value') : 'Сайты',
|
||||
limit: document.getElementById('limit-input') ? document.getElementById('limit-input').value : '10',
|
||||
};
|
||||
fetch('/admin/visit/rt-project-save', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
});
|
||||
|
||||
})();
|
||||
</script>
|
||||
</body></html>
|
||||
@@ -0,0 +1,405 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Headless Playwright водит UI «Мои проекты» supplier-портала crm.bp-gr.ru.
|
||||
*
|
||||
* Input (JSON через stdin):
|
||||
* {operation: "create"|"update"|"list", login, password, url, skipLogin?, dto?, externalId?}
|
||||
*
|
||||
* Output (JSON через stdout):
|
||||
* - create: {external_id: "12345"}
|
||||
* - update: {ok: true}
|
||||
* - list: {projects: [...]}
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — success
|
||||
* 1 — auth failed
|
||||
* 2 — DOM/селектор не найден (контракт UI сменился — escalation cause)
|
||||
* 3 — timeout
|
||||
* 4 — invalid input или другая ошибка
|
||||
*
|
||||
* Spec §4.3.
|
||||
*
|
||||
* KNOWN GAPS (Tier-2 MVP, зафиксированы по recon 2026-05-19):
|
||||
* - workdays: поле add-project форм НЕ содержит чекбоксы дней недели (только slider «Период»
|
||||
* часы 0-24). DTO.workdays игнорируется; портал применяет дефолт (все 7 дней).
|
||||
* Для точной настройки workdays используйте Tier-1 (AJAX).
|
||||
* - regions: форма требует имена регионов, DTO несёт int[] id. Mapping id→name не реализован.
|
||||
* Tier-2 всегда передаёт пустой массив регионов (нет фильтрации). Регионы должны быть
|
||||
* настроены вручную или через Tier-1.
|
||||
*/
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const TIMEOUT_MS = 90_000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Возвращает локатор form-item по значению атрибута for= у label.
|
||||
* Стратегия: .el-form-item:has(.el-form-item__label[for="<attrFor>"])
|
||||
*/
|
||||
function fieldByFor(page, attrFor) {
|
||||
return page.locator(`.el-form-item:has(.el-form-item__label[for="${attrFor}"])`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Login
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function login(page, args) {
|
||||
// skipLogin: args.url — статическая фикстура формы (тестовый режим),
|
||||
// открываем её напрямую и не логинимся.
|
||||
if (args.skipLogin) {
|
||||
await page.goto(args.url, { waitUntil: 'load', timeout: TIMEOUT_MS });
|
||||
return;
|
||||
}
|
||||
await page.goto(args.url, { waitUntil: 'load', timeout: TIMEOUT_MS });
|
||||
await page.fill('#loginform-username', args.login);
|
||||
await page.fill('#loginform-password', args.password);
|
||||
await Promise.all([
|
||||
page.waitForLoadState('networkidle', { timeout: TIMEOUT_MS }),
|
||||
page.click('button[type=submit]'),
|
||||
]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fillForm — Element UI label-for локаторы (recon 2026-05-19)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function fillForm(page, dto) {
|
||||
// NOTE: статус active/paused НЕ выставляется через форму. Единственный
|
||||
// .el-switch на форме — это include/exclude регионов («Включить/Исключить»,
|
||||
// recon 2026-05-19 row 6), НЕ статус проекта. Статус задаётся дефолтом
|
||||
// портала (active). dto.active игнорируется в Tier-2; switch не трогаем
|
||||
// (regions skip — см. ниже). Verified live 2026-05-19.
|
||||
|
||||
// --- 1. Tag ---
|
||||
if (dto.tag !== undefined && dto.tag !== null) {
|
||||
await fieldByFor(page, 'tag').locator('input.el-input__inner').fill(String(dto.tag));
|
||||
}
|
||||
|
||||
// --- 2. Platforms (srcrt) — B1/B2/B3 checkboxes ---
|
||||
// Initial: все три checked. Нужно включить только те, что в dto.platforms, остальные выключить.
|
||||
const platformContainer = fieldByFor(page, 'srcrt');
|
||||
for (const p of ['B1', 'B2', 'B3']) {
|
||||
const wanted = (dto.platforms || []).includes(p);
|
||||
// Identification — по `.el-checkbox__label` textContent (per recon-doc
|
||||
// 2026-05-19-rt-project-form-locators.md row 2: реальный портал НЕ имеет
|
||||
// `data-platform`-атрибута, inputs без `name`). Whitespace-tolerant `^\s*B1\s*$`.
|
||||
const cb = platformContainer.locator('.el-checkbox').filter({
|
||||
has: page.locator('.el-checkbox__label', { hasText: new RegExp(`^\\s*${p}\\s*$`) }),
|
||||
}).first();
|
||||
const cbClass = await cb.getAttribute('class').catch(() => '');
|
||||
const isChecked = (cbClass || '').includes('is-checked');
|
||||
if (!!isChecked !== wanted) {
|
||||
await cb.click();
|
||||
}
|
||||
}
|
||||
|
||||
// --- 3. Name (label for="name") ---
|
||||
// В реальном портале dto.name заполняется в поле «Название проекта»,
|
||||
// а dto.uniqueKey (список сайтов/номеров) — в textarea «content».
|
||||
// manage-project.js получает dto.name напрямую.
|
||||
if (dto.name !== undefined) {
|
||||
await fieldByFor(page, 'name').locator('input.el-input__inner').fill(String(dto.name));
|
||||
}
|
||||
|
||||
// --- 4. Type select (label for="type") ---
|
||||
// El-select readonly input. Клик открывает popup в body > .el-select-dropdown.
|
||||
const signalTypeMap = { site: 'Сайты', call: 'Звонки', sms: 'СМС' };
|
||||
const signalLabel = signalTypeMap[dto.signal_type];
|
||||
if (!signalLabel) {
|
||||
throw new Error(
|
||||
`Unsupported signal_type "${dto.signal_type}". Supported: site, call, sms. ` +
|
||||
'"Ретро сайты" / "Ретро звонки" are not supported in Tier-2 form channel.',
|
||||
);
|
||||
}
|
||||
// Тип меняем ТОЛЬКО если текущее значение ≠ нужное. Смена типа ремоунтит
|
||||
// content tab-pane (Сайты/Звонки/СМС — разные поля сбора) → если сразу
|
||||
// после type-select заполнять content, fill попадёт в detached textarea
|
||||
// (Vue ещё не закончил ре-рендер) → rt-project-save уходит с пустым
|
||||
// `content` → портал «Введите домены». Verified live 2026-05-19.
|
||||
const typeInput = fieldByFor(page, 'type').locator('.el-select input.el-input__inner');
|
||||
const currentType = (await typeInput.inputValue().catch(() => '')).trim();
|
||||
if (currentType !== signalLabel) {
|
||||
await typeInput.click();
|
||||
// Dropdown рендерится снаружи формы в body — ждём его появления
|
||||
const dropdownOption = page.locator('.el-select-dropdown__item', {
|
||||
hasText: new RegExp(`^${signalLabel}$`),
|
||||
});
|
||||
await dropdownOption.waitFor({ state: 'visible', timeout: TIMEOUT_MS });
|
||||
await dropdownOption.click();
|
||||
// Ждём, пока Vue завершит ре-рендер content tab-pane после смены типа.
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
// --- 7. Regions (label for="regions") — SKIP, gap зафиксирован в JSDoc ---
|
||||
// DTO несёт int[] id; форма требует имена. Mapping не реализован для MVP.
|
||||
if (dto.regions && dto.regions.length > 0) {
|
||||
process.stderr.write(
|
||||
JSON.stringify({
|
||||
warning: 'regions skipped in Tier-2 form channel: DTO carries int[] ids but form requires region names. ' +
|
||||
'Region filtering will not be applied. Configure regions manually or use Tier-1.',
|
||||
regions_received: dto.regions,
|
||||
}) + '\n',
|
||||
);
|
||||
}
|
||||
|
||||
// --- 9. Content — список сайтов/номеров/отправителей (label for="content") ---
|
||||
// Вкладка «Список» (default active). dto.domains — массив строк или dto.uniqueKey — строка.
|
||||
const contentLines = dto.domains && dto.domains.length
|
||||
? dto.domains.join('\n')
|
||||
: dto.uniqueKey
|
||||
? String(dto.uniqueKey)
|
||||
: null;
|
||||
if (contentLines) {
|
||||
const contentField = fieldByFor(page, 'content');
|
||||
// Вкладка «Список» — default active. Кликаем ТОЛЬКО если она НЕ активна:
|
||||
// клик по вкладке Element UI ремоунтит tab-pane → textarea детачится,
|
||||
// и последующий .fill() гонится с ре-рендером (домены теряются →
|
||||
// rt-project-save уходит с пустым `content` → портал «Введите домены»).
|
||||
// Verified live 2026-05-19: re-click активной вкладки ломал save.
|
||||
const listTab = contentField.locator('.el-tabs__item', { hasText: 'Список' }).first();
|
||||
if ((await listTab.count()) > 0) {
|
||||
const tabClass = (await listTab.getAttribute('class')) || '';
|
||||
if (!tabClass.includes('is-active')) {
|
||||
await listTab.click();
|
||||
await contentField.locator('textarea.el-textarea__inner')
|
||||
.waitFor({ state: 'visible', timeout: TIMEOUT_MS });
|
||||
}
|
||||
}
|
||||
const contentTa = contentField.locator('textarea.el-textarea__inner');
|
||||
await contentTa.fill(contentLines);
|
||||
// Defensive: убедиться, что значение действительно осело в textarea
|
||||
// (если поле детачнулось ре-рендером — fill уйдёт в пустоту).
|
||||
const filledValue = await contentTa.inputValue();
|
||||
if (filledValue.trim() === '') {
|
||||
throw new Error(
|
||||
'Content textarea empty after fill — likely tab/type re-render race; domains lost',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 10. Limit (label for="limit") ---
|
||||
if (dto.limit !== undefined) {
|
||||
await fieldByFor(page, 'limit').locator('input.el-input__inner').fill(String(dto.limit));
|
||||
}
|
||||
|
||||
// NOTE: workdays — gap зафиксирован в JSDoc. Форма add-project не содержит
|
||||
// чекбоксы дней недели. dto.workdays игнорируется.
|
||||
if (dto.workdays && dto.workdays.length !== 7) {
|
||||
process.stderr.write(
|
||||
JSON.stringify({
|
||||
warning: 'workdays ignored in Tier-2 form channel: add-project form has no workdays field. ' +
|
||||
'Portal will apply default (all 7 days). Configure workdays manually or use Tier-1.',
|
||||
workdays_received: dto.workdays,
|
||||
}) + '\n',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createOp
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function createOp(page, args) {
|
||||
await login(page, args);
|
||||
|
||||
if (!args.skipLogin) {
|
||||
await page.goto(
|
||||
args.url.replace(/\/$/, '') + '/admin/visit/rt',
|
||||
{ waitUntil: 'load', timeout: TIMEOUT_MS },
|
||||
);
|
||||
// Кнопка «Добавить проект» — recon: label [title="Добавить проект"]
|
||||
await page.locator('button:has-text("Добавить проект")').click();
|
||||
// Ждём появления формы — label for="name" внутри .el-form
|
||||
await page.locator('.el-form-item__label[for="name"]').waitFor({
|
||||
state: 'visible',
|
||||
timeout: TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
await fillForm(page, args.dto);
|
||||
|
||||
// Кликаем «Сохранить» + перехватываем ответ rt-project-save
|
||||
const [saveResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) => r.url().endsWith('/admin/visit/rt-project-save') && r.request().method() === 'POST',
|
||||
{ timeout: TIMEOUT_MS },
|
||||
),
|
||||
page.locator('.v-dialog--active button:has-text("Сохранить")').click(),
|
||||
]);
|
||||
|
||||
const body = await saveResponse.json();
|
||||
if (body.status !== 'OK') {
|
||||
// DIAG: дамп фактически отправленного тела — для расследования "Введите домены"
|
||||
const sentBody = saveResponse.request().postData();
|
||||
process.stderr.write(JSON.stringify({ diag_sent_body: sentBody }) + '\n');
|
||||
throw new Error(`Portal rejected save: ${body.message || 'unknown error'}`);
|
||||
}
|
||||
const externalId = String(body.id ?? '');
|
||||
if (!externalId) {
|
||||
throw new Error('Portal returned status=OK but empty id');
|
||||
}
|
||||
|
||||
return { external_id: externalId };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// updateOp
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function updateOp(page, args) {
|
||||
await login(page, args);
|
||||
|
||||
if (!args.skipLogin) {
|
||||
await page.goto(
|
||||
args.url.replace(/\/$/, '') + '/admin/visit/rt',
|
||||
{ waitUntil: 'load', timeout: TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
// Найти строку таблицы по externalId и кликнуть кнопку редактирования.
|
||||
// Реальная таблица портала — Vuetify data-table; строки по data-id или текстовому совпадению.
|
||||
// Стратегия 1: строка с атрибутом data-id
|
||||
const rowLocator = page.locator(`tr[data-id="${args.externalId}"], [data-id="${args.externalId}"]`);
|
||||
const rowCount = await rowLocator.count();
|
||||
if (rowCount > 0) {
|
||||
await rowLocator.first().locator('button').first().click();
|
||||
} else {
|
||||
// Стратегия 2: найти строку содержащую текст externalId и кликнуть edit-кнопку
|
||||
await page.locator(`tr:has-text("${args.externalId}")`).first().locator('button').first().click();
|
||||
}
|
||||
|
||||
// Дождаться формы
|
||||
await page.locator('.el-form-item__label[for="name"]').waitFor({
|
||||
state: 'visible',
|
||||
timeout: TIMEOUT_MS,
|
||||
});
|
||||
|
||||
await fillForm(page, args.dto);
|
||||
|
||||
// Перехватываем ответ rt-project-save при update (тот же endpoint)
|
||||
const [saveResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) => r.url().endsWith('/admin/visit/rt-project-save') && r.request().method() === 'POST',
|
||||
{ timeout: TIMEOUT_MS },
|
||||
),
|
||||
page.locator('.v-dialog--active button:has-text("Сохранить")').click(),
|
||||
]);
|
||||
|
||||
const body = await saveResponse.json();
|
||||
if (body.status !== 'OK') {
|
||||
throw new Error(`Portal rejected update: ${body.message || 'unknown error'}`);
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// listOp
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function listOp(page, args) {
|
||||
await login(page, args);
|
||||
|
||||
if (!args.skipLogin) {
|
||||
await page.goto(
|
||||
args.url.replace(/\/$/, '') + '/admin/visit/rt',
|
||||
{ waitUntil: 'load', timeout: TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
// Стратегия 1: Vuex state (если доступен)
|
||||
const projects = await page.evaluate(() => {
|
||||
try {
|
||||
if (window.app && window.app.$store && window.app.$store.state) {
|
||||
const st = window.app.$store.state;
|
||||
const list = st.projects || st.rtProjects || st.visitProjects || null;
|
||||
if (Array.isArray(list)) {
|
||||
return list.map((p) => ({
|
||||
id: parseInt(p.id, 10),
|
||||
name: p.name || p.title || null,
|
||||
platform: p.platform || null,
|
||||
signal_type: p.type || p.signal_type || null,
|
||||
unique_key: p.content || p.unique_key || null,
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (_) { /* Vuex недоступен */ }
|
||||
return null;
|
||||
});
|
||||
|
||||
if (projects !== null) {
|
||||
return { projects };
|
||||
}
|
||||
|
||||
// Стратегия 2: DOM-скрейп таблицы
|
||||
// Реальная таблица портала: строки tr с data-id или стандартные td
|
||||
const rows = await page.locator('table tbody tr[data-id], .v-data-table tbody tr[data-id]').evaluateAll(
|
||||
(nodes) => nodes.map((n) => ({
|
||||
id: parseInt(n.dataset.id || '0', 10),
|
||||
name: n.querySelector('td:nth-child(2)')
|
||||
? n.querySelector('td:nth-child(2)').textContent.trim()
|
||||
: null,
|
||||
})),
|
||||
);
|
||||
|
||||
if (rows.length > 0) {
|
||||
return { projects: rows };
|
||||
}
|
||||
|
||||
// Стратегия 3: фикстура / пустая страница — возвращаем пустой массив
|
||||
return { projects: [] };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function run(args) {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
let out;
|
||||
switch (args.operation) {
|
||||
case 'create': out = await createOp(page, args); break;
|
||||
case 'update': out = await updateOp(page, args); break;
|
||||
case 'list': out = await listOp(page, args); break;
|
||||
default: throw new Error('Unknown operation: ' + args.operation);
|
||||
}
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
process.stderr.write(JSON.stringify({ error: err.message }));
|
||||
if (err.message.includes('Timeout')) process.exit(3);
|
||||
if (
|
||||
err.message.toLowerCase().includes('selector') ||
|
||||
err.message.toLowerCase().includes('locator')
|
||||
) process.exit(2);
|
||||
if (
|
||||
err.message.toLowerCase().includes('login') ||
|
||||
err.message.toLowerCase().includes('auth')
|
||||
) process.exit(1);
|
||||
process.exit(4);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
let input = '';
|
||||
process.stdin.on('data', (c) => { input += c; });
|
||||
process.stdin.on('end', () => {
|
||||
let args;
|
||||
try { args = JSON.parse(input); } catch (e) {
|
||||
process.stderr.write(JSON.stringify({ error: 'invalid JSON on stdin' }));
|
||||
process.exit(4);
|
||||
}
|
||||
if (!args.operation || !args.url) {
|
||||
process.stderr.write(JSON.stringify({ error: 'missing required: operation, url' }));
|
||||
process.exit(4);
|
||||
}
|
||||
run(args);
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Фикстурный тест manage-project.js — против локального HTTP-сервера с Element UI фикстурой.
|
||||
*
|
||||
* Почему HTTP, не file://: manage-project.js перехватывает ответ page.waitForResponse()
|
||||
* с URL endsWith('/admin/visit/rt-project-save'). Браузер не шлёт network-запросы при
|
||||
* file://-origin fetch из-за CORS/same-origin ограничений в Chromium.
|
||||
*
|
||||
* Runner: встроенный node:test (Node 18+). Запуск: `node --test manage-project.test.js`.
|
||||
*/
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { execFile } = require('node:child_process');
|
||||
const http = require('node:http');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const SCRIPT = path.resolve(__dirname, 'manage-project.js');
|
||||
const FIXTURE_PATH = path.resolve(__dirname, 'fixtures', 'rt-form-element-ui.html');
|
||||
|
||||
/** Запустить ephemeral HTTP-сервер, отдающий фикстуру и обрабатывающий mock-эндпоинты. */
|
||||
function startFixtureServer() {
|
||||
return new Promise((resolve) => {
|
||||
const html = fs.readFileSync(FIXTURE_PATH, 'utf8');
|
||||
const server = http.createServer((req, res) => {
|
||||
// Mock rt-project-save — Playwright перехватывает реальный сетевой запрос
|
||||
if (req.url && req.url.includes('rt-project-save') && req.method === 'POST') {
|
||||
// Consume request body (important — don't hang connection)
|
||||
let body = '';
|
||||
req.on('data', (c) => { body += c; });
|
||||
req.on('end', () => {
|
||||
const payload = JSON.stringify({ status: 'OK', message: '', result: null, id: '99001' });
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(payload);
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Default: serve fixture HTML
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(html);
|
||||
});
|
||||
server.listen(0, '127.0.0.1', () => resolve(server));
|
||||
});
|
||||
}
|
||||
|
||||
/** Спавнить manage-project.js, подать JSON на stdin, вернуть {code, stdout, stderr}. */
|
||||
function runScript(input) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = execFile(
|
||||
'node',
|
||||
[SCRIPT],
|
||||
{ timeout: 90_000 },
|
||||
(err, stdout, stderr) => {
|
||||
if (err && err.killed) return reject(new Error('Process killed / timed out'));
|
||||
// err.code — exit code; treat as expected (tests assert on code)
|
||||
resolve({
|
||||
code: err ? err.code : 0,
|
||||
stdout: stdout.toString(),
|
||||
stderr: stderr.toString(),
|
||||
});
|
||||
},
|
||||
);
|
||||
child.stdin.write(JSON.stringify(input));
|
||||
child.stdin.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 1 — createProject через Element UI фикстуру → external_id из mock-response
|
||||
// ---------------------------------------------------------------------------
|
||||
test('createProject fills Element UI form and returns external_id from intercept response', async () => {
|
||||
const server = await startFixtureServer();
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const url = `http://127.0.0.1:${port}`;
|
||||
|
||||
const result = await runScript({
|
||||
operation: 'create',
|
||||
url,
|
||||
skipLogin: true,
|
||||
dto: {
|
||||
tag: '_lidpotok',
|
||||
name: 'example.com',
|
||||
platforms: ['B1'],
|
||||
signal_type: 'site',
|
||||
limit: 5,
|
||||
workdays: [1, 2, 3, 4, 5],
|
||||
domains: ['example.com'],
|
||||
region_mode: 'include',
|
||||
regions: [],
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
|
||||
assert.strictEqual(result.code, 0, `Expected exit 0, got ${result.code}. stderr: ${result.stderr}`);
|
||||
|
||||
let out;
|
||||
try {
|
||||
out = JSON.parse(result.stdout);
|
||||
} catch (e) {
|
||||
assert.fail(`stdout is not valid JSON: ${result.stdout}\nstderr: ${result.stderr}`);
|
||||
}
|
||||
assert.strictEqual(out.external_id, '99001', `expected external_id "99001", got ${JSON.stringify(out)}`);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 2 — listProjects в skipLogin-режиме возвращает массив projects
|
||||
// ---------------------------------------------------------------------------
|
||||
test('listProjects returns array (skipLogin mode, fixture page)', async () => {
|
||||
const server = await startFixtureServer();
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const url = `http://127.0.0.1:${port}`;
|
||||
|
||||
const result = await runScript({
|
||||
operation: 'list',
|
||||
url,
|
||||
skipLogin: true,
|
||||
});
|
||||
|
||||
// listOp в skipLogin-режиме не навигирует на /admin/visit/rt — просто открывает url.
|
||||
// Фикстура не содержит Vuex и таблицы с проектами → возвращает {projects: []}.
|
||||
assert.strictEqual(result.code, 0, `Expected exit 0. stderr: ${result.stderr}`);
|
||||
|
||||
let out;
|
||||
try {
|
||||
out = JSON.parse(result.stdout);
|
||||
} catch (e) {
|
||||
assert.fail(`stdout is not valid JSON: ${result.stdout}`);
|
||||
}
|
||||
assert.ok(Array.isArray(out.projects), `expected projects array, got: ${JSON.stringify(out)}`);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
@@ -27,17 +27,36 @@ async function refresh(args) {
|
||||
|
||||
await page.goto(args.url, { waitUntil: 'load', timeout: TIMEOUT_MS });
|
||||
|
||||
// DOM-селекторы — placeholder до Task 1 discovery
|
||||
const loginSelector = 'input[name=login]';
|
||||
const passwordSelector = 'input[name=password]';
|
||||
// DOM-селекторы crm.bp-gr.ru/login (Yii2 LoginForm) — verified live 2026-05-19 через Playwright MCP.
|
||||
const loginSelector = '#loginform-username';
|
||||
const passwordSelector = '#loginform-password';
|
||||
const submitSelector = 'button[type=submit]';
|
||||
|
||||
await page.fill(loginSelector, args.login);
|
||||
await page.fill(passwordSelector, args.password);
|
||||
await Promise.all([
|
||||
page.waitForLoadState('networkidle', { timeout: TIMEOUT_MS }),
|
||||
page.click(submitSelector),
|
||||
]);
|
||||
|
||||
// Сабмит + ОЖИДАНИЕ пост-логин перехода.
|
||||
// Старый Promise.all([waitForLoadState('networkidle'), click]) — гонка:
|
||||
// логин-страница уже в состоянии networkidle, поэтому waitForLoadState
|
||||
// резолвился мгновенно (ДО редиректа), и скрипт хватал PHPSESSID
|
||||
// неаутентифицированной логин-страницы. Ждём, пока логин-форма исчезнет
|
||||
// из DOM — waitForFunction опрашивает и переживает навигацию.
|
||||
await page.click(submitSelector);
|
||||
await page
|
||||
.waitForFunction(
|
||||
(sel) => !document.querySelector(sel),
|
||||
loginSelector,
|
||||
{ timeout: TIMEOUT_MS },
|
||||
)
|
||||
.catch(() => { /* форма осталась — логин отклонён, ловится guard'ом ниже */ });
|
||||
await page.waitForLoadState('networkidle', { timeout: TIMEOUT_MS }).catch(() => {});
|
||||
|
||||
// Verify: логин-форма всё ещё на странице → вход НЕ удался. Не возвращаем
|
||||
// мусорную (неаутентифицированную) сессию как «успех» (exit 0).
|
||||
if ((await page.locator(loginSelector).count()) > 0) {
|
||||
process.stderr.write(JSON.stringify({ error: 'login rejected: still on login page after submit' }));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let csrf = null;
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Rector\Config\RectorConfig;
|
||||
|
||||
// Консервативный старт (A1 backend-tooling #64): мёртвый код + качество кода.
|
||||
// БЕЗ type-declaration наборов и БЕЗ LaravelSetProvider (version-upgrade) на первом
|
||||
// заходе — их прогоняем вручную при апгрейде Laravel, не как per-commit гейт.
|
||||
return RectorConfig::configure()
|
||||
->withPaths([
|
||||
__DIR__.'/app',
|
||||
__DIR__.'/database',
|
||||
__DIR__.'/routes',
|
||||
])
|
||||
->withPreparedSets(
|
||||
deadCode: true,
|
||||
codeQuality: true,
|
||||
);
|
||||
@@ -165,6 +165,9 @@ export interface ApiDeal {
|
||||
comment: string | null;
|
||||
city: string | null;
|
||||
project_signal_type: string | null;
|
||||
project_signal_identifier?: string | null;
|
||||
project_sms_keyword?: string | null;
|
||||
project_sms_senders?: string[] | null;
|
||||
next_reminder_at: string | null;
|
||||
}
|
||||
|
||||
@@ -257,10 +260,12 @@ export interface ApiProject {
|
||||
}
|
||||
|
||||
export async function listProjects(tenantId: number): Promise<ApiProject[]> {
|
||||
const { data } = await apiClient.get<{ projects: ApiProject[] }>('/api/projects', {
|
||||
// ProjectController::index() отдаёт { data: ProjectResource::collection(...) }.
|
||||
// `?? []` — защита от undefined.map в DealsView при нештатном ответе.
|
||||
const { data } = await apiClient.get<{ data: ApiProject[] }>('/api/projects', {
|
||||
params: { tenant_id: tenantId },
|
||||
});
|
||||
return data.projects;
|
||||
return data.data ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,13 +6,30 @@
|
||||
*
|
||||
* Sprint 4 Phase B/3 — split DashboardView (audit O-refactor-04 закрытие).
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
|
||||
const range = defineModel<'today' | '7d' | '30d' | 'custom'>({ required: true });
|
||||
|
||||
const auth = useAuthStore();
|
||||
|
||||
/** Имя залогиненного пользователя (было захардкожено «Иван»). */
|
||||
const firstName = computed(() => auth.user?.first_name?.trim() || 'коллега');
|
||||
|
||||
/** Приветствие по времени суток (МСК машины пользователя). */
|
||||
const greeting = computed(() => {
|
||||
const h = new Date().getHours();
|
||||
if (h < 6) return 'Доброй ночи';
|
||||
if (h < 12) return 'Доброе утро';
|
||||
if (h < 18) return 'Добрый день';
|
||||
return 'Добрый вечер';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<h1 class="text-h4 mb-2 page-greet">Доброе утро, <em class="text-primary">Иван</em></h1>
|
||||
<h1 class="text-h4 mb-2 page-greet">{{ greeting }}, <em class="text-primary">{{ firstName }}</em></h1>
|
||||
<div class="page-meta text-body-2 text-medium-emphasis">
|
||||
<span><span class="num text-primary">+3</span> новых лида с утра</span>
|
||||
<span class="sep">·</span>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { computed, defineAsyncComponent, ref, watch } from 'vue';
|
||||
import type { MockDeal } from '../../composables/mockDeals';
|
||||
import { type DealEvent } from '../../composables/mockDealEvents';
|
||||
import { mapApiDealEvent } from '../../composables/dealsApiMapper';
|
||||
import { stripChannelPrefix } from '../../composables/projectName';
|
||||
import * as dealsApi from '../../api/deals';
|
||||
import * as remindersApi from '../../api/reminders';
|
||||
import type { ApiReminder } from '../../api/reminders';
|
||||
@@ -25,7 +26,13 @@ const props = defineProps<{
|
||||
tenantId?: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
// 18.05.2026 ux: статус меняется через inline picker в Hero.
|
||||
// Эмитим slug наверх — parent (DealDetailDrawer → DealsView/KanbanView)
|
||||
// делает optimistic update + API call + rollback.
|
||||
'status-changed': [slug: string];
|
||||
}>();
|
||||
|
||||
const status = computed(() => {
|
||||
if (!props.deal) return null;
|
||||
@@ -36,6 +43,26 @@ function formatCost(cost: number): string {
|
||||
return new Intl.NumberFormat('ru-RU').format(cost) + ' ₽';
|
||||
}
|
||||
|
||||
// Drawer-«легенда» (18.05.2026 ux): Тип + Источник проекта (read-only).
|
||||
// Редактирование — только в карточке проекта на /projects (см. план Task 5).
|
||||
const TYPE_LABELS: Record<string, string> = { site: 'Сайт', call: 'Звонок', sms: 'СМС' };
|
||||
const projectTypeLabel = computed((): string => {
|
||||
const t = props.deal?.projectSignalType;
|
||||
return t ? (TYPE_LABELS[t] ?? '—') : '—';
|
||||
});
|
||||
const projectSourceLabel = computed((): string => {
|
||||
if (!props.deal) return '—';
|
||||
const t = props.deal.projectSignalType;
|
||||
if (t === 'site' || t === 'call') return props.deal.projectSignalIdentifier ?? '—';
|
||||
if (t === 'sms') {
|
||||
const sender = props.deal.projectSmsSenders?.[0] ?? '';
|
||||
const kw = props.deal.projectSmsKeyword;
|
||||
if (sender && kw) return `${sender} (${kw})`;
|
||||
return sender || '—';
|
||||
}
|
||||
return '—';
|
||||
});
|
||||
|
||||
const events = ref<DealEvent[]>([]);
|
||||
const eventsLoading = ref(false);
|
||||
const eventsFetchError = ref(false);
|
||||
@@ -112,6 +139,12 @@ async function loadEvents() {
|
||||
}
|
||||
}
|
||||
|
||||
function onStatusChange(slug: string): void {
|
||||
if (!props.deal) return;
|
||||
if (props.deal.statusSlug === slug) return;
|
||||
emit('status-changed', slug);
|
||||
}
|
||||
|
||||
async function saveComment() {
|
||||
if (!props.deal || !props.tenantId) return;
|
||||
commentSaving.value = true;
|
||||
@@ -153,7 +186,13 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<div v-if="deal" class="drawer-content">
|
||||
<DealDetailHero :deal="deal" :status="status" @close="emit('close')" />
|
||||
<DealDetailHero
|
||||
:deal="deal"
|
||||
:status="status"
|
||||
:all-statuses="leadStatusesStore.statuses"
|
||||
@close="emit('close')"
|
||||
@change-status="onStatusChange"
|
||||
/>
|
||||
|
||||
<v-divider />
|
||||
|
||||
@@ -162,24 +201,19 @@ defineExpose({
|
||||
<dl class="params">
|
||||
<div class="param">
|
||||
<dt class="text-caption text-medium-emphasis">Проект</dt>
|
||||
<dd class="text-body-2">{{ deal.project }}</dd>
|
||||
<dd class="text-body-2">{{ stripChannelPrefix(deal.project) }}</dd>
|
||||
</div>
|
||||
<div class="param">
|
||||
<dt class="text-caption text-medium-emphasis">Стоимость лида</dt>
|
||||
<dd class="text-body-2 num">{{ formatCost(deal.cost) }}</dd>
|
||||
</div>
|
||||
<div class="param">
|
||||
<dt class="text-caption text-medium-emphasis">Менеджер</dt>
|
||||
<dd class="text-body-2">
|
||||
<v-avatar size="20" color="secondary" class="mr-1">
|
||||
<span class="text-caption">{{ deal.manager.initials }}</span>
|
||||
</v-avatar>
|
||||
{{ deal.manager.name }}
|
||||
</dd>
|
||||
<dt class="text-caption text-medium-emphasis">Тип</dt>
|
||||
<dd class="text-body-2">{{ projectTypeLabel }}</dd>
|
||||
</div>
|
||||
<div class="param">
|
||||
<dt class="text-caption text-medium-emphasis">Источник</dt>
|
||||
<dd class="text-body-2 link">Я.Директ → landing-1</dd>
|
||||
<dd class="text-body-2">{{ projectSourceLabel }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
@@ -19,7 +19,10 @@ const props = withDefaults(
|
||||
{ inline: false },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ 'update:open': [value: boolean] }>();
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean];
|
||||
'status-changed': [slug: string];
|
||||
}>();
|
||||
|
||||
const drawerOpen = computed({
|
||||
get: () => props.open,
|
||||
@@ -33,7 +36,12 @@ function close() {
|
||||
|
||||
<template>
|
||||
<aside v-if="inline" v-show="open" class="deal-detail-inline" data-testid="deal-detail-panel">
|
||||
<DealDetailBody :deal="deal" :tenant-id="tenantId" @close="close" />
|
||||
<DealDetailBody
|
||||
:deal="deal"
|
||||
:tenant-id="tenantId"
|
||||
@close="close"
|
||||
@status-changed="(s: string) => emit('status-changed', s)"
|
||||
/>
|
||||
</aside>
|
||||
<v-navigation-drawer
|
||||
v-else
|
||||
@@ -43,7 +51,12 @@ function close() {
|
||||
:width="480"
|
||||
class="deal-drawer"
|
||||
>
|
||||
<DealDetailBody :deal="deal" :tenant-id="tenantId" @close="close" />
|
||||
<DealDetailBody
|
||||
:deal="deal"
|
||||
:tenant-id="tenantId"
|
||||
@close="close"
|
||||
@status-changed="(s: string) => emit('status-changed', s)"
|
||||
/>
|
||||
</v-navigation-drawer>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -8,13 +8,20 @@
|
||||
import type { MockDeal } from '../../composables/mockDeals';
|
||||
import type { LeadStatus } from '../../composables/leadStatuses';
|
||||
|
||||
defineProps<{
|
||||
deal: MockDeal;
|
||||
status: LeadStatus | null;
|
||||
}>();
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
deal: MockDeal;
|
||||
status: LeadStatus | null;
|
||||
// 18.05.2026 ux: inline status picker — кликабельный chip с выпадающим
|
||||
// списком всех статусов. Если allStatuses не передан — chip read-only.
|
||||
allStatuses?: LeadStatus[];
|
||||
}>(),
|
||||
{ allStatuses: () => [] },
|
||||
);
|
||||
|
||||
defineEmits<{
|
||||
close: [];
|
||||
'change-status': [slug: string];
|
||||
}>();
|
||||
|
||||
function formatRelative(minutes: number): string {
|
||||
@@ -41,10 +48,34 @@ function formatRelative(minutes: number): string {
|
||||
</div>
|
||||
|
||||
<div v-if="status" class="status-row mt-3">
|
||||
<v-chip size="small" variant="tonal" :style="{ color: status.colorHex, borderColor: status.colorHex }">
|
||||
<span class="status-dot" :style="{ background: status.colorHex }" />
|
||||
{{ status.nameRu }}
|
||||
</v-chip>
|
||||
<v-menu :disabled="(allStatuses?.length ?? 0) === 0">
|
||||
<template #activator="{ props: a }">
|
||||
<v-chip
|
||||
v-bind="a"
|
||||
data-testid="status-chip-trigger"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
:style="{ color: status.colorHex, borderColor: status.colorHex, cursor: (allStatuses?.length ?? 0) > 0 ? 'pointer' : 'default' }"
|
||||
>
|
||||
<span class="status-dot" :style="{ background: status.colorHex }" />
|
||||
{{ status.nameRu }}
|
||||
<v-icon v-if="(allStatuses?.length ?? 0) > 0" size="14" class="ml-1">mdi-menu-down</v-icon>
|
||||
</v-chip>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
v-for="s in allStatuses"
|
||||
:key="s.slug"
|
||||
:data-testid="`status-option-${s.slug}`"
|
||||
@click="$emit('change-status', s.slug)"
|
||||
>
|
||||
<template #prepend>
|
||||
<span class="status-dot" :style="{ background: s.colorHex }" />
|
||||
</template>
|
||||
<v-list-item-title>{{ s.nameRu }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
import type { MockDeal } from '../../composables/mockDeals';
|
||||
import type { LeadStatus } from '../../composables/leadStatuses';
|
||||
import { stripChannelPrefix } from '../../composables/projectName';
|
||||
import StatusPill from '../ui/StatusPill.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -71,7 +72,7 @@ function rowProps(deal: MockDeal): Record<string, unknown> {
|
||||
|
||||
<template #[`item.project`]="{ item }: { item: MockDeal }">
|
||||
<div class="cell-source">
|
||||
<span class="source-project">{{ item.project }}</span>
|
||||
<span class="source-project">{{ stripChannelPrefix(item.project) }}</span>
|
||||
<span v-if="signalLabel(item.signalType)" class="source-signal">{{
|
||||
signalLabel(item.signalType)
|
||||
}}</span>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* Click → emit('open', deal.id) — TODO: правая панель DealDetailDrawer.
|
||||
*/
|
||||
import type { MockDeal } from '../../composables/mockDeals';
|
||||
import { stripChannelPrefix } from '../../composables/projectName';
|
||||
|
||||
defineProps<{ deal: MockDeal }>();
|
||||
const emit = defineEmits<{ open: [id: number] }>();
|
||||
@@ -27,7 +28,7 @@ function formatCost(cost: number): string {
|
||||
<div class="card-name">{{ deal.name }}</div>
|
||||
<div class="card-phone text-caption text-medium-emphasis">{{ deal.phone }}</div>
|
||||
<div class="card-meta mt-2">
|
||||
<span class="card-project text-caption">{{ deal.project }}</span>
|
||||
<span class="card-project text-caption">{{ stripChannelPrefix(deal.project) }}</span>
|
||||
<span class="card-cost num">{{ formatCost(deal.cost) }}</span>
|
||||
</div>
|
||||
<div class="card-foot mt-1">
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import { useNotificationsStore } from '../../stores/notifications';
|
||||
import { useCommandPalette } from '../../composables/useCommandPalette';
|
||||
import { repositionMenuAfterOpen } from '../../utils/menuRepositionFix';
|
||||
|
||||
defineProps<{
|
||||
pageTitle: string;
|
||||
@@ -111,7 +112,7 @@ async function handleLogout(): Promise<void> {
|
||||
</template>
|
||||
</v-btn>
|
||||
|
||||
<v-menu offset="8" :close-on-content-click="false" location="bottom end">
|
||||
<v-menu offset="8" :close-on-content-click="false" location="bottom end" @update:model-value="repositionMenuAfterOpen">
|
||||
<template #activator="{ props: bellProps }">
|
||||
<v-btn
|
||||
v-bind="bellProps"
|
||||
@@ -173,7 +174,7 @@ async function handleLogout(): Promise<void> {
|
||||
</v-card>
|
||||
</v-menu>
|
||||
|
||||
<v-menu offset="8">
|
||||
<v-menu offset="8" @update:model-value="repositionMenuAfterOpen">
|
||||
<template #activator="{ props }">
|
||||
<v-btn v-bind="props" variant="text" size="small" class="user-chip ml-2" aria-label="Меню пользователя">
|
||||
<v-avatar size="28" color="primary" class="mr-2">
|
||||
|
||||
@@ -29,11 +29,11 @@
|
||||
|
||||
<v-btn
|
||||
color="error"
|
||||
prepend-icon="mdi-archive"
|
||||
data-testid="bulk-archive"
|
||||
@click="confirmAndRun('archive')"
|
||||
prepend-icon="mdi-delete"
|
||||
data-testid="bulk-delete"
|
||||
@click="confirmAndRun('delete')"
|
||||
>
|
||||
Архивировать
|
||||
Удалить
|
||||
</v-btn>
|
||||
|
||||
<v-spacer />
|
||||
@@ -92,11 +92,10 @@ const skipToastText = ref('');
|
||||
const messages: Record<string, string> = {
|
||||
pause: 'Приостановить выбранные проекты?',
|
||||
resume: 'Возобновить выбранные проекты?',
|
||||
archive:
|
||||
'Архивировать выбранные проекты?\nДействие необратимо в Plan 5 (восстановление потребует ручного запроса).',
|
||||
delete: 'Удалить выбранные проекты? Действие необратимо. Проекты со сделками будут пропущены.',
|
||||
};
|
||||
|
||||
async function confirmAndRun(action: 'pause' | 'resume' | 'archive') {
|
||||
async function confirmAndRun(action: 'pause' | 'resume' | 'delete') {
|
||||
if (!window.confirm(messages[action])) return;
|
||||
await runBulk({ action });
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ const base = {
|
||||
daily_limit_target: 50,
|
||||
delivered_today: 32,
|
||||
is_active: true,
|
||||
archived_at: null,
|
||||
sync_status: 'ok' as const,
|
||||
};
|
||||
|
||||
|
||||
@@ -48,9 +48,9 @@
|
||||
<template #prepend><v-icon>mdi-refresh</v-icon></template>
|
||||
<v-list-item-title>Синхронизировать</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click="$emit('archive', project)">
|
||||
<template #prepend><v-icon>mdi-archive</v-icon></template>
|
||||
<v-list-item-title>Архивировать</v-list-item-title>
|
||||
<v-list-item @click="$emit('delete', project)">
|
||||
<template #prepend><v-icon>mdi-delete</v-icon></template>
|
||||
<v-list-item-title>Удалить</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
@@ -97,7 +97,7 @@ defineEmits<{
|
||||
edit: [project: Project];
|
||||
'toggle-active': [project: Project];
|
||||
'sync-now': [project: Project];
|
||||
archive: [project: Project];
|
||||
delete: [project: Project];
|
||||
}>();
|
||||
|
||||
const typeLabel = computed(() => ({ site: 'Сайт', call: 'Звонок', sms: 'СМС' })[props.project.signal_type]);
|
||||
|
||||
@@ -16,6 +16,7 @@ interface FormState {
|
||||
delivery_days_mask: number;
|
||||
sms_senders: string[];
|
||||
sms_keyword: string;
|
||||
signal_identifier: string;
|
||||
}
|
||||
|
||||
const form = reactive<FormState>({
|
||||
@@ -25,6 +26,7 @@ const form = reactive<FormState>({
|
||||
delivery_days_mask: 127,
|
||||
sms_senders: [],
|
||||
sms_keyword: '',
|
||||
signal_identifier: '',
|
||||
});
|
||||
|
||||
const selectableRegions = REGIONS.filter((r) => r.code !== 0);
|
||||
@@ -37,6 +39,7 @@ function reseedFromProject(p: Project | null): void {
|
||||
form.delivery_days_mask = p.delivery_days_mask ?? 127;
|
||||
form.sms_senders = p.sms_senders ?? [];
|
||||
form.sms_keyword = p.sms_keyword ?? '';
|
||||
form.signal_identifier = p.signal_identifier ?? '';
|
||||
}
|
||||
reseedFromProject(props.project);
|
||||
|
||||
@@ -60,10 +63,10 @@ async function onPause(): Promise<void> {
|
||||
async function onDelete(): Promise<void> {
|
||||
if (!props.project) return;
|
||||
const ok = window.confirm(
|
||||
'Архивировать проект? Действие необратимо в Plan 5 (восстановление потребует ручного запроса).',
|
||||
'Удалить проект? Действие необратимо. Если по проекту есть сделки — удаление будет заблокировано.',
|
||||
);
|
||||
if (!ok) return;
|
||||
await store.archive(props.project.id);
|
||||
await store.del(props.project.id);
|
||||
emit('close');
|
||||
}
|
||||
|
||||
@@ -78,6 +81,10 @@ async function onSave(): Promise<void> {
|
||||
regions: form.regions,
|
||||
delivery_days_mask: form.delivery_days_mask,
|
||||
};
|
||||
// 18.05.2026 ux: редактирование источника проекта.
|
||||
if (props.project.signal_type === 'site' || props.project.signal_type === 'call') {
|
||||
payload.signal_identifier = form.signal_identifier;
|
||||
}
|
||||
if (props.project.signal_type === 'sms') {
|
||||
payload.sms_senders = form.sms_senders;
|
||||
payload.sms_keyword = form.sms_keyword;
|
||||
@@ -127,6 +134,54 @@ const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||||
<div v-if="errors.name" class="pdd-error" data-testid="pdd-error-name">{{ errors.name[0] }}</div>
|
||||
</label>
|
||||
|
||||
<!-- 18.05.2026 ux: редактирование источника проекта (site/call/sms) -->
|
||||
<label v-if="project?.signal_type === 'site'" class="pdd-field">
|
||||
<span class="pdd-label">Источник — домен сайта-донора</span>
|
||||
<input
|
||||
v-model="form.signal_identifier"
|
||||
data-testid="pdd-signal-identifier"
|
||||
class="pdd-input"
|
||||
placeholder="okna-konkurent.ru"
|
||||
/>
|
||||
<div v-if="errors.signal_identifier" class="pdd-error" data-testid="pdd-error-signal">
|
||||
{{ errors.signal_identifier[0] }}
|
||||
</div>
|
||||
</label>
|
||||
<label v-else-if="project?.signal_type === 'call'" class="pdd-field">
|
||||
<span class="pdd-label">Источник — телефонный номер донора</span>
|
||||
<input
|
||||
v-model="form.signal_identifier"
|
||||
data-testid="pdd-signal-identifier"
|
||||
class="pdd-input"
|
||||
placeholder="79161234567"
|
||||
/>
|
||||
<div v-if="errors.signal_identifier" class="pdd-error" data-testid="pdd-error-signal">
|
||||
{{ errors.signal_identifier[0] }}
|
||||
</div>
|
||||
</label>
|
||||
<div v-else-if="project?.signal_type === 'sms'" class="pdd-field">
|
||||
<span class="pdd-label">Источник — отправители SMS</span>
|
||||
<v-combobox
|
||||
v-model="form.sms_senders"
|
||||
data-testid="pdd-sms-senders"
|
||||
multiple
|
||||
chips
|
||||
clearable
|
||||
density="comfortable"
|
||||
hide-details
|
||||
placeholder="MTS, BEELINE …"
|
||||
/>
|
||||
<div v-if="errors.sms_senders" class="pdd-error">{{ errors.sms_senders[0] }}</div>
|
||||
<span class="pdd-label mt-2">Ключевое слово (опционально)</span>
|
||||
<input
|
||||
v-model="form.sms_keyword"
|
||||
data-testid="pdd-sms-keyword"
|
||||
class="pdd-input"
|
||||
placeholder="КРЕДИТ"
|
||||
/>
|
||||
<div v-if="errors.sms_keyword" class="pdd-error">{{ errors.sms_keyword[0] }}</div>
|
||||
</div>
|
||||
|
||||
<label class="pdd-field">
|
||||
<span class="pdd-label">Лимит лидов в день</span>
|
||||
<input
|
||||
|
||||
@@ -78,5 +78,9 @@ export function mapApiDeal(api: ApiDeal, now: Date = new Date()): MockDeal {
|
||||
comment: api.comment,
|
||||
receivedAt: api.received_at,
|
||||
nextReminderAt: api.next_reminder_at,
|
||||
projectSignalType: (api.project_signal_type as MockDeal['projectSignalType']) ?? null,
|
||||
projectSignalIdentifier: api.project_signal_identifier ?? null,
|
||||
projectSmsKeyword: api.project_sms_keyword ?? null,
|
||||
projectSmsSenders: api.project_sms_senders ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,6 +22,11 @@ export interface MockDeal {
|
||||
comment?: string | null;
|
||||
receivedAt?: string | null; // ISO — колонка «Поставлен»
|
||||
nextReminderAt?: string | null; // ISO — колонка «Напоминание»
|
||||
// Drawer-«легенда» сделки (18.05.2026): Тип + Источник проекта (read-only).
|
||||
projectSignalType?: 'site' | 'call' | 'sms' | null;
|
||||
projectSignalIdentifier?: string | null;
|
||||
projectSmsKeyword?: string | null;
|
||||
projectSmsSenders?: string[] | null;
|
||||
}
|
||||
|
||||
export const MOCK_DEALS: MockDeal[] = [
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Утилиты отображения имён проектов crm.bp.
|
||||
*
|
||||
* Поставщик crm.bp префиксует имена проектов признаком канала-провайдера
|
||||
* (B1_/B2_/B3_ — три разных базы лидов). В UI Лидерры префикс — шум:
|
||||
* пользователю интересен сам проект, а не канал.
|
||||
*
|
||||
* Трансформация — **display-only**: данные в БД (`supplier_projects.name`)
|
||||
* не трогаем, фильтрация/поиск/маппинг идёт по сырому имени и `id`.
|
||||
*/
|
||||
|
||||
const CHANNEL_PREFIX_RE = /^B[123]_/i;
|
||||
|
||||
/**
|
||||
* Убирает префикс B1_/B2_/B3_ из начала имени проекта (case-insensitive).
|
||||
* Префикс внутри строки и другие буквы (B0/B4/Bx) не трогает.
|
||||
* null/undefined/'' -> ''.
|
||||
*/
|
||||
export function stripChannelPrefix(name: string | null | undefined): string {
|
||||
if (!name) return '';
|
||||
return name.replace(CHANNEL_PREFIX_RE, '');
|
||||
}
|
||||
@@ -25,13 +25,15 @@ interface NavItem {
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ title: 'Тенанты', icon: 'mdi-account-group-outline', to: '/admin/tenants', count: 142 },
|
||||
{ title: 'Тенанты', icon: 'mdi-account-group-outline', to: '/admin/tenants' },
|
||||
{ title: 'Биллинг', icon: 'mdi-credit-card-outline', to: '/admin/billing' },
|
||||
{ title: 'Тарифная сетка', icon: 'mdi-tag-arrow-right', to: '/admin/pricing-tiers' },
|
||||
{ title: 'Цены поставщиков', icon: 'mdi-currency-rub', to: '/admin/supplier-prices' },
|
||||
{ title: 'Инциденты', icon: 'mdi-alert-outline', to: '/admin/incidents', count: 3 },
|
||||
{ title: 'Инциденты', icon: 'mdi-alert-outline', to: '/admin/incidents' },
|
||||
{ title: 'Impersonation', icon: 'mdi-account-switch', to: '/admin/impersonation' },
|
||||
{ title: 'Система', icon: 'mdi-cog-outline', to: '/admin/system' },
|
||||
{ title: 'Интеграция с поставщиком', icon: 'mdi-swap-horizontal', to: '/admin/supplier-integration' },
|
||||
{ title: 'Проекты у поставщика', icon: 'mdi-format-list-checks', to: '/admin/supplier-projects' },
|
||||
];
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
@@ -41,7 +41,13 @@ const navItems = computed(() => [
|
||||
]);
|
||||
|
||||
const currentPageTitle = computed(() => {
|
||||
return navItems.value.find((i) => i.to === route.path)?.title ?? 'Страница';
|
||||
// Сначала короткий title из sidebar-nav (Дашборд/Сделки/…), затем — route.meta.title
|
||||
// для страниц вне sidebar (Напоминания, Импорт данных), и только потом fallback.
|
||||
return (
|
||||
navItems.value.find((i) => i.to === route.path)?.title ??
|
||||
(route.meta.title as string | undefined) ??
|
||||
'Страница'
|
||||
);
|
||||
});
|
||||
|
||||
async function loadNotifications(): Promise<void> {
|
||||
|
||||
@@ -168,6 +168,7 @@ const lucideMap: Record<string, Component> = {
|
||||
'mdi-content-save-outline': Save,
|
||||
'mdi-credit-card-outline': CreditCard,
|
||||
'mdi-currency-rub': RussianRuble,
|
||||
'mdi-delete': Trash2,
|
||||
'mdi-delete-outline': Trash2,
|
||||
'mdi-dots-vertical': MoreVertical,
|
||||
'mdi-download': Download,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user