Commit Graph

60 Commits

Author SHA1 Message Date
Дмитрий 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
Дмитрий 8cc097574b feat(supplier): SupplierPortalClient — async-флоу заказа отчёта «Запрос номеров» 2026-05-18 17:36:27 +03:00
Дмитрий 51ac2bbdb0 chore(deals): post-review cleanup — refresh stale §6.4 docs + mapper count assertion 2026-05-18 03:42:41 +03:00
Дмитрий 939c2f8081 feat(deals): StatusRuToSlugMapper — remap supplier RU statuses to 5-slug funnel 2026-05-18 03:42:41 +03:00
Дмитрий 3e23773359 feat(import): CsvLeadsParser + DTO ParsedLeadRow/CsvParseResult
Парсер CSV-выгрузки лидов crm.bp-gr.ru (ТЗ §6.2/§6.3): срезает UTF-8 BOM,
разбирает строки через str_getcsv, валидирует телефон (7XXXXXXXXXX) и даты
(Y/m/d H:i:s), срезает префикс B[123]_ из названия проекта. Невалидные
строки не роняют парсинг — собираются в errors[] с абсолютным номером строки.
Тесты: 5/5 (unit, без DB).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:47:15 +03:00
Дмитрий f4241e1f09 feat(import): сервис StatusRuToSlugMapper (ТЗ §6.4)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:42:49 +03:00