Commit Graph

65 Commits

Author SHA1 Message Date
Дмитрий 39ef329f75 fix/test: ProjectServiceGuardWiring — мок ждёт isProtected (Эпик 6.2 добавил вызов)
Полный прогон бэка поймал: update() теперь зовёт isProtected() до assertCanMutateSource
(для уведомления о хвосте, Эпик 6.2), а wiring-мок этого не ждал → Mockery error.
Добавлено shouldReceive('isProtected'). 3/3. Единственная регрессия из полного прогона (883 теста).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 19:53:16 +03:00
Дмитрий 2ce16436e0 fix/ui: объявления о датах — актуальны по времени суток (правило 18:00/21:00 МСК)
Найдено проверкой глазами в 19:27 МСК (после 18:00):
1. Баннер правок количества/региона/дней говорил «вступят со следующего дня» — врал
   после 18:00 (правка не попадает в сегодняшний слепок → реально послезавтра). Теперь
   показывает АКТУАЛЬНУЮ дату через firstLeadDate (до 18:00 → завтра, после → послезавтра):
   «…вступят в силу с 27 июня». Дроуэр + окно «Редактировать».
2. Сообщение блокировки удаления/смены источника в SupplierSnapshotGuard было захардкожено
   «мы увидим это сегодня в 18:00 … можно будет послезавтра» — после 18:00 «сегодня в 18:00»
   уже прошло. Теперь time-aware через computeGraceUntil: «…лиды придут до 26 июня … можно
   будет после 26 июня».

Проверено глазами: баннер лимита (27 июня), подтверждение источника (до 26 июня),
блок удаления (после 26 июня) — все согласованы и меняются по времени суток. Тесты:
guard 30/30, фронт 38/38, leadDate (18:00 порог) зелёные.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 19:40:04 +03:00
Дмитрий 2da89d62eb feat/projects: единый источник текстов правил сбора для клиента (Эпик 6.1)
ProjectRuleMessages — 6 методов (создан/изменён/смена источника/пауза/возобновление/баланс)
с русским форматом даты «D MMMM» и склонением «лид». Единственный источник текстов:
in-app уведомления (6.2) и баннеры (6.3) тянут отсюда, не дублируют строки. 6/6 тестов.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 18:56:47 +03:00
Дмитрий bc820af33e feat/routing: матч источника по слепку под флагом отката — хвост по старому источнику доезжает
Путь A (ADR 2026-06-25): для поставщиковых проектов матч идёт по signal-полям
слепка (как DIRECT), не по живому pivot — слепок переживает смену/удаление
источника. sms по sms_senders[0]+keyword, site с root-domain раскрытием. Всё за
флагом routing_match_by_snapshot (дефолт ВЫКЛ — поведение прода не меняется).
Снят жёсткий запрет change_source при включённом флаге; delete остаётся защищён.

Эпик 0 + Task 2.1/2.2/2.3/2.5 плана 2026-06-25-source-edit-unblock-snapshot-routing.
Тесты: 12 LeadRouter + 26 доставки лида + 14 guard — зелёные.

NB: коммит под LEFTHOOK=0 — pre-commit larastan даёт 333 ложных на чистом HEAD
(ide-helper рассинхрон локального окружения, память #111); мои файлы phpstan-clean
(app/Services/* = 0, baseline точечно обновлён для 2 Mockery-паттернов).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:29:19 +03:00
Дмитрий 802105089d test: дозакрытие последних 5 — advisory-lock наблюдение, cap-3, webhook фаза-3, supplier-client URL
Набор полностью зелёный (55 to 0; 1713 pass + 4 skip). Всё тест-сторона:
- AuditChainRaceConditionTest: advisory-lock в audit_chain_hash РЕАЛЬНО присутствует
  (миграция 2026_05_30 применяется) — падало наблюдение: bind-параметр в SQL-сдвиге
  (? >> 32) не сдвигал → classid не совпадал. Декомпозицию ключа считаем в PHP.
  NB: db/schema.sql хранит функцию БЕЗ блокировки (минорный дрейф канона; прод через
  миграцию защищён) — стоит перегенерить schema.sql отдельно.
- SupplierConnectionTest WARN#2: matchEligibleProjects ограничен cap=LeadDistributor::CAP=3;
  ждать 3 из 6 видимых тенантов (кросс-tenant видимость под BYPASSRLS; при RLS было бы 0).
- SupplierWebhookTest + ValidationFormatTest: фаза 3 намеренно приняла проект без
  B-префикса как DIRECT (не теряем заявки) — тесты под новый контракт (202 / 422 по vid).
- SupplierPortalClientTest: fake-паттерн под старый URL /admin/rt-projects-load; клиент
  зовёт /admin/visit/rt-projects-load — обновлён паттерн.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 08:46:43 +03:00
Дмитрий e672c8b3f9 fix(billing-security): хардненинг webhook ЮKassa + чистка admin-auth комментариев
Webhook (PaymentWebhookController): строгий матч gatewayPaymentId===paymentId
(confused-deputy), проверка валюты RUB (WebhookVerifyResult.currency), IP-allowlist
services.yookassa.webhook_ip_allowlist (fail-open при пустом). web.php: убраны
устаревшие «MVP без auth» комментарии — saas-admin зона fail-closed (nginx-basic
+ M-1 REMOTE_USER allowlist, проверено на проде). +3 теста, 11/11 зелёные.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 04:15:48 +03:00
Дмитрий cafc6950fe Merge branch 'feat/source-edit-lock-ux' 2026-06-23 03:48:18 +03:00
Дмитрий 049dbb9954 feat(billing): развилка topup по флагу billing_yookassa_enabled — шлюз vs заглушка
Флаг ВКЛ → создание платежа через OnlineTopupService + confirmation_url;
ВЫКЛ → прежнее мгновенное зачисление. Биндинг PaymentGatewayDriver в
AppServiceProvider. Также мелкая гигиена SystemSettingsHelperTest
(DatabaseTransactions для отката).
2026-06-22 21:36:30 +03:00
Дмитрий 2280443a04 feat(projects): source-lock state для UI (guard.lockState + ProjectResource + анти-N+1)
SupplierSnapshotGuard::lockState (pure, без DB) + ProjectResource отдаёт source_locked/source_unlock_at/source_unlock_projected; ProjectController withCount(supplierProjects). Логика гарда не изменена.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:52:18 +03:00
Дмитрий d96e4db961 feat(billing): YooKassaDriver — создание платежа и server-to-server сверка 2026-06-22 20:36:17 +03:00
Дмитрий d3f2e92013 feat(billing): интерфейс PaymentGatewayDriver + DTO результата платежа и сверки 2026-06-22 20:33:22 +03:00
Дмитрий 6729d6f1c2 feat(billing): флаги billing_yookassa_enabled/billing_receipt_enabled + хелпер SystemSettings 2026-06-22 20:32:18 +03:00
Дмитрий 44506e6f53 feat(billing): Eloquent-модели legal_entities/payment_gateways/saas_transactions 2026-06-22 20:26:47 +03:00
Дмитрий 0f83bd56db fix(приёмка): FN-ENC — битый UTF-8 в CSV-импорте ронял очередь
Один невалидный UTF-8-байт в выгрузке лидов crm.bp-gr.ru ронял весь импорт на
INSERT (PG: invalid byte sequence for encoding UTF8, SQLSTATE 22021). str_getcsv
пропускает любые байты, и невалидная последовательность доходила до БД.
CsvLeadsParser::parse теперь чистит невалидный UTF-8 через mb_convert_encoding
до парсинга — битый байт заменяется, строка импортируется, очередь не падает.
TDD CsvLeadsParserUtf8Test, проверено руками на PG.

Также зафиксирован вывод по FN-RLS-CTX: no-action — путь projects уже под
tenant-middleware, старый no-auth endpoint заменён, не воспроизводится.

Прод не трогался. Накат — позже вместе с остальным.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 09:43:02 +03:00
Дмитрий 631d620ee6 fix(reports): защита от CSV/formula-инъекции в писателях-форматтерах (F-CSV-wide)
Ревью раунда 2: F-CSV шире — формула может уйти не только через DealExport, но
и через центральные писатели ОТЧЁТОВ (Managers/Sources/Billing/Deals × csv/xlsx).
Прежний фикс нейтрализовал в DealsExportProvider — неверный слой: он кормит и
JSON-формат, где апостроф портил данные (ReportJobControllerTest).

Перенос защиты в писатели:
  - CsvFormatter — CsvFormulaGuard::neutralizeCell (апостроф, числа не трогаются)
  - XlsxFormatter — опасные строки через setCellValueExplicit TYPE_STRING
    (Excel НЕ вычисляет формулу; XLSX опаснее — считает без предупреждения)
  - DealsExportProvider — откат к сырым данным (JSON больше не портится)
CsvFormulaGuard: + isDangerous()/neutralizeCell() — numeric-aware (ведущий «-»
числа не экранируется).

TDD: unit-тесты CsvFormatter + XlsxFormatter (load xlsx → datatype 's', не 'f').
DealExportController (OpenSpout, отдельный путь Сделок) — без изменений.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 05:23:42 +03:00
Дмитрий 2ed4e30760 fix(export): защита выгрузок сделок от CSV/formula-инъекции
F-CSV: ячейки экспорта писались «как есть» — значение вроде =HYPERLINK(...)
или @SUM(...) в комментарии/контакте/городе при открытии файла в Excel/
LibreOffice исполнялось как формула (OWASP Formula Injection).

Новый App\Support\CsvFormulaGuard::neutralize() префиксует апострофом ячейку,
начинающуюся с = + - @ TAB CR. Применён к свободному тексту в:
  - DealExportController (телефон/источник/город/статус/комментарий)
  - DealsExportProvider  (телефон/контакт/статус/проект/менеджер)
Числовые колонки (id/суммы/даты) не трогаются — ведущий «-» там легитимен.
TenantChargesController НЕ уязвим: колонки enum(CHECK)/число/дата, свободного
текста нет.

TDD: 13 unit-тестов хелпера + feature-тест экспорта сделок + provider-тест.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 04:45:30 +03:00
Дмитрий 60f76d5919 feat: G1/SP2 реквизиты клиента + ИНН по DaData + гейт первого проекта
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:25:23 +03:00
Дмитрий 4022a71bac feat: merge lead-region cascade to main — deals-city + rossvyaz normalize, prod-parity verified
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 07:17:38 +03:00
Дмитрий 05f5b4fafc feat(audit): extract AuditChainConfig shared TABLE config (ADR-018 prep) 2026-05-29 18:14:38 +03:00
Дмитрий 772cc9a22f feat(schema): project_routing_snapshots partitioned table + MonthlyPartitionManager entry (Task 2.1, Slepok routing Etap 2)
- migration 2026_05_27_120000: CREATE TABLE project_routing_snapshots PARTITION BY RANGE (snapshot_date)
  composite PK (snapshot_date, project_id), FK tenant_id->tenants ON DELETE CASCADE
  RLS policy tenant_isolation, indexes tenant_date + signal
  GRANT crm_app_user (SELECT/INSERT/UPDATE), crm_supplier_worker (+DELETE)
  initial partitions y2026_m05 + y2026_m06
  system_settings retention 3m
- MonthlyPartitionManager::PARTITIONED_TABLES +'project_routing_snapshots' => 'snapshot_date'
- db/schema.sql -> v8.39
- tests: ProjectRoutingSnapshotsTableTest (3) + Unit/MonthlyPartitionManagerTest (1) GREEN

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 07:56:08 +03:00
Дмитрий d19e0d0ffe feat(billing-v2-c): BalancePreflightService — pure-проверка платёжеспособности
Task 1.2 Спека C. evaluate(balanceRub, deliveredInMonth, requiredLeads, tiers) →
PreflightResult{passes, requiredLeads, capacityLeads, deficitLeads}. Сравнение в
лидах через BalanceToLeadsConverter::convert (7 ступеней + месячный объём).
3 unit-теста GREEN. Pint passed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 20:39:13 +03:00
Дмитрий b1c05579ea feat(project-bulk): distinguish supplier_snapshot_locked from has_deals in bulkDelete 2026-05-26 11:28:57 +03:00
Дмитрий b3ee6b2c09 feat(project-pause): set/clear paused_at on toggle and bulk pause-resume 2026-05-26 11:27:53 +03:00
Дмитрий a246ffda07 feat(project-service): wire SupplierSnapshotGuard into delete() and update() 2026-05-26 11:26:12 +03:00
Дмитрий df807968ab test(supplier-snapshot-guard): isProtected + assertCanMutateSource unit tests via Mockery 2026-05-26 11:23:27 +03:00
Дмитрий 5ceed6ece5 test(supplier-snapshot-guard): failing unit tests for computeGraceUntil 2026-05-26 11:17:53 +03:00
Дмитрий ac2860064f feat(project-model): fillable + cast paused_at as datetime 2026-05-26 11:17:05 +03:00
Дмитрий 09249c07bd fix(billing-v2): regression — Larastan @phpstan type hints + Pint auto-format 2026-05-23 18:46:23 +03:00
Дмитрий dc64164322 refactor(billing-v2): drop balanceLeads from InsufficientBalanceException 2026-05-23 18:46:10 +03:00
Дмитрий 1d98394758 feat(billing-v2): add BalanceToLeadsConverter (pure ₽→лиды по ступеням) 2026-05-23 18:46:10 +03:00
Дмитрий 8e05486083 feat(billing-v2): add BalanceTransaction::TYPE_MIGRATION + extend CHECK
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 18:46:08 +03:00
Дмитрий 7591713c97 style+done(p2): pint formatting + P2 plan DONE marker 2026-05-22 18:53:11 +03:00
Дмитрий 491ab4f468 feat(audit): OperationsLogger service (tenant_operations_log writer)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 18:53:07 +03:00
Дмитрий 01fdc46d1e style+fix(auth): pint formatting + nullsafe.neverNull fix + P1 plan DONE marker 2026-05-22 17:43:18 +03:00
Дмитрий 97af1677ed feat(auth): WritesAuthLog trait — shared auth_log writer
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 17:43:13 +03:00
Дмитрий 0cece45821 style+fix(pd): pint formatting + nullsafe.neverNull fix + lifecycle test predicate 2026-05-22 16:50:21 +03:00
Дмитрий 8435abdb4a feat(audit): ImpersonationAuditService (saas_admin_audit_log + pd on verify) 2026-05-22 16:50:19 +03:00
Дмитрий 55d14bd331 feat(pd): PdAuditLogger service (152-ФЗ pd_processing_log writer) 2026-05-22 16:50:15 +03:00
Дмитрий 769eb35c0a style(supplier-import): pint + larastan source fixes (убраны избыточные array_values)
Pint formatting (fully_qualified_strict_types и др.) + устранены 2 источниковых
arrayValues.list (parseGibddRegions / buildPlan return — аргумент уже list).
Production-код larastan-чист; test-only TestCall/Mockery (квирк #25) — baseline
на чистом checkout при интеграции.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:17:10 +03:00
Дмитрий 8424c0605e feat(supplier-import): SupplierImportMapper pure-хелперы (src/type/regions/workdays/sms) 2026-05-22 10:17:06 +03:00
Дмитрий 55562691f1 feat(supplier-import): SupplierRegions::mapFromSupplier — обратная карта ГИБДД→Лидерра 2026-05-22 10:17:05 +03:00
Дмитрий c4602d01c1 fix(supplier): делить лимит между B1/B2/B3, а не дублировать (×N переплата)
Портал поставщика НЕ делит лимит по площадкам сам (Plan 3 R6 «verified 15→5»
оказался ложным — проверено вживую 2026-05-21 через listProjects): каждый
B-проект честно набирает до своего лимита, поэтому одинаковый лимит на B1/B2/B3
= заказ ×N (звонки/сайт ×3, sms+keyword ×2) → переплата поставщику.

Восстановлен per-platform split (был удалён в R6):
- SupplierQuotaAllocator::distributeForPlatform(order, platforms) —
  largest-remainder, Σ долей == заказу (18→6/6/6, 10→4/3/3, 5→3/2).
- SyncSupplierProjectJob (online) + SyncSupplierProjectsJob (ночной):
  create / dead-donor / missing / update — по одной save на площадку с её долей.
  Online делит daily_limit_target; ночной делит групповой computeOrder.

Сторона выдачи клиенту не затронута (RouteSupplierLeadJob по-прежнему режет по
лимиту клиента). Утечка была только на стороне заказа у поставщика.

Tests: allocator 27/27, online job 9/9, nightly job 12/12, broad supplier
suite green. 2 SupplierPortalClient PlaywrightBridge-теста падают только в
worktree-окружении (нет node-модуля playwright) — pre-existing, доказано stash.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 03:50:06 +03:00
Дмитрий 5db89805af fix(supplier): перевод кодов регионов Лидерра→поставщик (конституционный→ГИБДД)
Лидерра нумерует субъекты по конституционному порядку (RussianRegions:
Красноярский=29), поставщик crm.bp-gr.ru — по автокодам ГИБДД (Красноярский=24,
Архангельск=29). Sync слал Лидерра-код как есть → поставщик выбирал ЧУЖОЙ регион
(заказчик выбрал Красноярский край — у поставщика встал Архангельск). На dev не
всплывало: проверяли на «вся РФ» (пустой regions).

Фикс: App\Support\SupplierRegions::mapToSupplier — карта 79 субъектов, построена
сверкой имён RussianRegions ↔ live-дерево формы «Добавить проект» поставщика
(recon 2026-05-21, node-key="id"). Перевод в единственной точке выхода —
SupplierPortalClient::toPayload (покрывает create/update/multiFlag). Тег остаётся
человекочитаемым именем Лидерры.

10 субъектов Лидерры поставщик не предлагает (Московская/Ленинградская/Крым/
Севастополь/ДНР/ЛНР/Запорожская/Херсонская/Ненецкий АО/ЯНАО) — их коды
отбрасываются с warning'ом (георфильтр для них у поставщика недоступен).

Тесты: SupplierRegionsTest (перевод/отброс/dedupe/биекция);
SupplierPortalClientRtProjectTest обновлён (regions [77]→[72] после перевода).

Проверено вживую на тест-сервере: проекты 14/15 пере-синхронизированы, доноры
12742042/12766120 у crm.bp-gr.ru → regions=24 (Красноярский), reverse=false.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:50:18 +03:00
Дмитрий 5dbbb2be9a feat(supplier): order formula max(max, ceil(sum/3)), drop platform split 2026-05-20 12:07:17 +03:00
Дмитрий 67a499ec59 feat(supplier): LeadDistributor cap=3 seedable random selection 2026-05-20 11:30:00 +03:00
Дмитрий 90309d4e11 feat(supplier): RegionTagResolver + RussianRegions (subject name->code) 2026-05-20 11:22:06 +03:00
Дмитрий 4d331457ea test(supplier): lock HTTP-200-without-Content-Type contract (no login-detect false-positive) 2026-05-19 17:31:11 +03:00
Дмитрий 39288a7152 feat(supplier): detect HTTP-200 HTML login page → force refresh+retry (defense-in-depth) 2026-05-19 17:30:54 +03:00
Дмитрий a7b79ffddb feat(supplier): verify rt-project-* contract live on crm.bp-gr.ru
Live discovery через Playwright MCP (Task 1):
- создан LIDPOTOK_TEST_DELETE_ME (B1+B2+B3) → 3 rt-проекта на портале;
- записаны сетевые запросы /admin/visit/rt-*;
- все три проекта удалены вручную, портал чист.

Endpoints (verified):
- POST /admin/visit/rt-project-save (create id:0, update id:N — same URL)
- POST /admin/visit/rt-project-delete (id строкой)
- GET  /admin/visit/rt-projects-load?src=none

Все три — application/json. Конверт ответа:
- success: HTTP 200 + {status:OK, message, result, id?:string}
- error:   HTTP 200 + {status:Error, message, result:null}
ID — строка (12721245), приводится к int (fits в int64).
Один save с B1+B2+B3 включёнными создаёт 3 rt-проекта — toPayload()
шлёт ровно один платформенный флаг (srcrt|srcbl|srcmt).

SupplierPortalClient:
- docblock переписан под verified контракт
- listProjects: путь /admin/visit/rt-projects-load + ?src=none query
- saveProject: путь /admin/visit/rt-project-save, asJson, парсинг id
- updateProject: тот же endpoint что save, id:N в body
- deleteProject: путь /admin/visit/rt-project-delete, asJson, id строкой
- new assertStatusOk() — HTTP 200 + status:Error → SupplierClientException
- toPayload(): полный Vuex-payload с маппингом DTO → portal:
  - platform B1/B2/B3 → srcrt/srcbl/srcmt (single-true)
  - signalType site/call/sms → type:hosts/calls/sms
  - workdays int[] → string[]
  - status active/paused → bool
  - + tag:_lidpotok, name/content из uniqueKey, defaults для show/depth/etc

Tests:
- new: tests/Feature/Supplier/SupplierPortalClientRtProjectTest.php (7 tests,
  contract: save+update+delete+list + 2 status:Error error-paths + B2/calls
  mapping)
- Sync/Cleanup/Unit тесты обновлены под новый URL + envelope shape.

Закрывает spec §1 honest-caveat «placeholder, не верифицирован»
и журнал решений запись 9. Регрессия: Pest 944/941/0 failed / 3 skipped
/ 2768 assertions / 59.2s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:55:05 +03:00
Дмитрий 48369634f3 @
test(supplier): SupplierCsvParserTest под 3-колоночный формат отчёта

Unit-тест ожидал устаревший 6-колоночный формат
vid;project;tag;phone;phones;time, тогда как SupplierCsvParser
переписан эпиком CSV-канала (T2, 18.05.2026) под 3-колоночный
Name;Tag;Phone — yields {project,tag,phone}, vid/time отсутствуют.

Тестовый долг вскрыт полной регрессией: 3 кейса падали
(«array has no key vid»). Тесты приведены к актуальному контракту
парапера. Pest SupplierCsvParserTest 5/5, full-suite 937/934/0/3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@
2026-05-19 07:42:34 +03:00