Compare commits

..

7 Commits

Author SHA1 Message Date
Дмитрий 6ce2f0058d fix(router-gate): session-lock skips readonly Bash (scope calibration)
The parallel-session-lock fired on every PreToolUse tool, blocking even
readonly Bash (git status/log/diff, cat, grep, ls) from a peer session.
The lock's purpose is to serialize concurrent FILE MUTATION on the same
worktree; readonly commands mutate nothing, so they are outside that scope.

isReadonlyBashEvent() reuses the router-gate Bash classifier (an allow-verdict
whose reason is readonly/reading), mirroring the LLM-judge readonly
calibration. main() short-circuits readonly Bash to allow without
acquiring/blocking. Mutating tools, git commit/push, dangerous Bash, and
every non-Bash tool still acquire/check the lock — same-worktree mutation
serialization is unchanged (scope fix, NOT a discipline drop).

TDD: +6 unit tests. Full tools-vitest 2038 passed / 2 skipped.
2026-06-01 07:46:26 +03:00
Дмитрий d35fefddd9 ci(a11y): bump Pa11y workflow Node 20 -> 22 (cspell@10 engine requirement)
The a11y (Pa11y live) PR check failed at "Install root JS deps": root `npm ci`
hits EBADENGINE because @cspell/cspell-*@10.0.0 require Node >=22.18.0 while the
workflow pinned Node 20. Pre-existing mismatch (cspell ^10 predates this branch
and fails identically on main), unrelated to the discipline-guard hook changes.
Node 22 satisfies both the repo engines (>=20) and cspell (>=22.18).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 19:00:05 +03:00
Дмитрий e56ddd6a1b fix(router-gate): coverage line honors cross-turn active skill (verify + remind)
Backlog item G. The `coverage:` line under-reported a skill chosen in a PRIOR turn:
enforce-coverage-verify credited channel=skill only if the Skill tool ran in the
CURRENT turn, so an honest `skill:X` continuation line was BLOCKED -> the controller
learned to under-report as direct/chain. Two-sided systemic fix, no weakening:

- enforce-coverage-verify: decide() also accepts skill:X when X was invoked anywhere
  earlier in THIS session (new priorSkillNames param; main() collects them via
  sessionToolUses). Still unforgeable -- a real Skill tool_use must exist in the
  transcript. The only residual is possibly-stale attribution, far better than the
  forced dishonest direct-reporting it replaces.
- enforce-prompt-injection: the §17 reminder now lists active skills carried over
  from earlier turns (read from the transcript) and tells the controller to report
  `coverage: skill:<name>` when work continues under one -- the proactive half, so
  the correct line is not merely allowed but prompted.

TDD: RED -> GREEN per behavior. tools-vitest 2032 passed / 2 skipped.
Plan docs/superpowers/plans/2026-05-31-discipline-guard-backlog.md (item G).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 18:37:44 +03:00
Дмитрий 53407a77cd feat(router-gate): tdd-gate credits delegated (subagent) TDD + transcript write-deny
Closes the TDD-gate cross-actor gap: when a subagent (spawned by a Task in the
controller's current turn) writes the failing test and confirms RED, the
controller's subsequent production edit was falsely blocked because the gate only
scanned the controller's own turn. Net strengthening, no discipline weakened.

- Part 1 (enforce-runtime-write-deny): block the Write tool from any
  ~/.claude/projects/**/*.jsonl (session/subagent transcripts). Memory *.md there
  stays writable (never matches .jsonl$). Resolving normalizer defeats ./.. evasion.
  This makes the agent-<id>.jsonl that Part 2 trusts unforgeable (it was the last
  ungated write channel; Bash/PowerShell/Read gates already covered it).
- Part 2 (enforce-tdd-gate): decide() also credits a subagent's matching test edit
  + RED via a new subagentEntriesList. turnTaskAgentIds() reads the hex agentId from
  the harness-written Task tool_result (the controller cannot forge its own
  tool_result; hex-only match blocks "agentId: ../../x" path traversal).
  subagentTranscriptPaths() derives <dir>/<controller-session>/subagents/agent-<id>.jsonl.
  main() reads them best-effort (missing/unreadable -> no extra credit = stricter).

No new weakening: a delegated subagent doing real TDD is legitimate; the only
forgery vector (overwriting the agent jsonl) is closed by Part 1. Existing
controller-turn behaviour is preserved (empty subagent list == old logic).

OWNER (settings.json, Claude can't edit it): enforce-tdd-gate is already a
registered PreToolUse hook -> Part 2 goes live on merge. enforce-runtime-write-deny
must be registered on PreToolUse(Edit|Write|MultiEdit|NotebookEdit) for Part 1 to be live.

TDD: RED -> GREEN per behavior. tools-vitest 2027 passed / 2 skipped.
Backlog item C (=Z); plan docs/superpowers/plans/2026-05-31-discipline-guard-backlog.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 18:18:44 +03:00
Дмитрий 6577c04a1f fix(router-gate): session-lock hygiene — clearer block message + stale-lock prune
Closes the remaining parallel-session-lock remarks on top of the keying fix
(7a469dc9), with NO weakening of same-worktree serialization:

- D: the block message now identifies the holder by its STABLE session_id and
  marks the recorded pid as transient ("may change between attempts"). Chasing
  the pid is what led to closing the wrong session. Decision logic is unchanged
  (text only) — existing /pid N/ triage assertion still holds.
- B: pruneStaleLocks() best-effort deletes leaked lock files that are ALREADY
  stale by the shared isStale() definition (now exported from the pure module —
  single source of truth). Active within-TTL locks are never touched, so the
  serialization guarantee is not weakened. Wired into the PreToolUse branch of
  main(), wrapped so hygiene can never break the gate (fail-open).
- C (no code): release-on-SessionEnd needs only a settings.json registration
  (owner action) — the existing !tool_name branch already releases. Documented
  in the plan. Until then, leaked locks self-heal via B + the 5-min TTL takeover.

TDD: RED -> GREEN per behavior. tools-vitest 2014 passed / 2 skipped.
Backlog items B/C/D; plan docs/superpowers/plans/2026-05-31-discipline-guard-backlog.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 17:43:03 +03:00
Дмитрий 7a469dc913 fix(router-gate): key session-lock by session work-tree root, not hook cwd
enforce-parallel-session-lock keyed the lock on the hook's process.cwd(),
which collapses to the main repo dir after a session resume — so sessions in
DIFFERENT git worktrees shared one lock and false-blocked each other (observed:
a brainrepo-worktree session blocked launching agents by a discipline-guard
session). New resolveWorkspacePath() keys on the session's stable cwd
(event.cwd) resolved to the git work-tree root (git -C <cwd> rev-parse
--show-toplevel), with fallback to process.cwd() so behaviour never regresses
when event.cwd is absent. Same-worktree concurrency stays serialized
(unchanged) — discipline not weakened; only cross-worktree false-blocks fixed.

TDD: RED (5 resolveWorkspacePath cases) -> GREEN -> tools-vitest 2003 passed /
2 skipped. Backlog item F; plan
docs/superpowers/plans/2026-05-31-discipline-guard-backlog.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 17:02:32 +03:00
Дмитрий be4e1a6123 feat(router-gate): whitelist npm ci in SAFE_EXACT (worktree dep restore)
`npm ci` does a clean install strictly from the committed lockfile
(deterministic, no version drift) — needed to restore junction node_modules
in a fresh worktree. Distinct from `npm install`/`npm i`, which stay
hard-blacklisted because they can pull new/updated versions; the blacklist
runs before the whitelist, so they remain blocked. Word boundary after `ci`
prevents `npm cider`-style prefix matches; chain semantics still block
`npm ci && <mutating>`.

TDD: RED (3 allow-cases failed default-deny) -> GREEN (/^npm\s+ci\b/) ->
tools-vitest 1998 passed / 2 skipped (2000). Backlog item A; plan
docs/superpowers/plans/2026-05-31-discipline-guard-backlog.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 14:46:58 +03:00
1840 changed files with 22914 additions and 598024 deletions
+8 -16
View File
@@ -41,7 +41,7 @@ Symptom: `queue:work` стартует, через ~60 секунд процес
### Квирк 107 — `config:cache` не из-под `www-data` → 500 на всём портале (24.05 живой инцидент)
Symptom: HTTP 500 на главной + во всех путях, в `storage/logs/laravel.log` пусто или «file not found» для cache. Cause: PHP-FPM под `www-data` **не может прочитать** `bootstrap/cache/config.php` (напр. owner=root без доступа группе) → fallback на defaults → APP_KEY=NULL и DB=sqlite. **Критерий — читаемость www-data, а не строгий владелец:** штатный `ubuntu:www-data` mode `775` читаем группой и НЕ вызывает 500 (проверено 25.06: портал HTTP 200). Фикс при NOT_READABLE: `sudo -u www-data php artisan config:cache` (пере-кэш под www-data) либо `sudo chmod 775 bootstrap/cache/config.php` + группа www-data.
Symptom: HTTP 500 на главной + во всех путях, в `storage/logs/laravel.log` пусто или «file not found» для cache. Cause: владелец `bootstrap/cache/config.php``www-data` → PHP-FPM под `www-data` не может прочитать кэш → fallback на defaults → APP_KEY=NULL и DB=sqlite. Фикс: `sudo -u www-data php artisan config:cache`.
### Квирк 108 — NTFS junction для worktree node_modules
@@ -51,30 +51,22 @@ Symptom: HTTP 500 на главной + во всех путях, в `storage/lo
Каждая проверка — это одна SSH-команда + ожидаемый формат вывода + критерий зелёного. Если вывод не совпадает с ожидаемым форматом — это автоматически NO-GO + эскалация.
### П1 — `bootstrap/cache/config.php` читаемость www-data и свежесть (Квирк 107, самый важный)
**ВАЖНО (исправлено 25.06.2026):** реальный корень инцидента 24.05 — PHP-FPM под `www-data`
**не смог ПРОЧИТАТЬ** cache-файл (был owner=root без доступа группе). Поэтому критерий —
**читаемость www-data**, а НЕ строгое «владелец == www-data». redeploy.sh штатно оставляет
config.php как `ubuntu:www-data` mode `775` — www-data читает его через группу, портал
работает (HTTP 200). Прежняя строгая проверка владельца давала **ложный NO-GO** (квирк
«лечили» зря несколько раз). Проверяем то, что реально важно: может ли www-data читать.
### П1 — `bootstrap/cache/config.php` владелец и свежесть (Квирк 107, самый важный)
```bash
ssh -o ConnectTimeout=10 liderra "sudo -u www-data test -r /var/www/liderra/app/bootstrap/cache/config.php && echo READABLE || echo NOT_READABLE; stat -c '%U:%G %a %Y' /var/www/liderra/app/bootstrap/cache/config.php 2>/dev/null; stat -c '%Y' /var/www/liderra/app/.env 2>/dev/null"
ssh -o ConnectTimeout=10 liderra "stat -c '%U %Y' /var/www/liderra/app/bootstrap/cache/config.php 2>/dev/null; stat -c '%Y' /var/www/liderra/app/.env 2>/dev/null"
```
Ожидаемый формат — 3 строки (1-я — вердикт читаемости, 2-я — владелец:группа режим mtime, 3-я — mtime .env):
Ожидаемый формат — 2 строки:
```
READABLE
ubuntu:www-data 775 1234567890
www-data 1234567890
1234567880
```
Зелёный = (1) `READABLE` (www-data может прочитать config.php) И (2) mtime config.php ≥ mtime .env.
Зелёный = (1) владелец `www-data` И (2) mtime config.php ≥ mtime .env.
Красный = `NOT_READABLE` (www-data НЕ может прочитать — настоящий риск 500) ИЛИ mtime config.php < mtime .env (квирк 104 — stale cache) ИЛИ файл config.php отсутствует. Цитировать квирк 107 в reason. NB: владелец `ubuntu` сам по себе **НЕ** красный, если файл читаем группой www-data.
Красный = владелец ≠ `www-data` ИЛИ mtime config.php < mtime .env ИЛИ файл config.php отсутствует. Цитировать квирк 107 в reason.
### П2 — `.env` line endings (квирк 105)
@@ -181,7 +173,7 @@ ssh liderra "cd /var/www/liderra/app && php artisan migrate:status 2>&1 | grep -
=== PROD-DEPLOY-VALIDATOR RAPORT ===
Brief: <из входных данных>
Проверки:
П1 config.php читаем www-data [GREEN / RED] — <вывод | причина>
П1 config:cache владелец [GREEN / RED] — <вывод | причина>
П2 .env line endings [GREEN / RED] — <вывод | причина>
П3 свободное место [GREEN / RED] — <вывод | причина>
П4 свежесть бэкапа БД [GREEN / RED] — <вывод | причина>
-5
View File
@@ -3,8 +3,3 @@
# break vitest module loading (SyntaxError: Invalid or unexpected token,
# no file:line). See memory quirk #100 (2026-05-19).
*.mjs text eol=lf
# Shell scripts must stay LF. CRLF breaks `set -euo pipefail` on the server
# (`set: pipefail: invalid option name`) when scp'd from a Windows working tree —
# деплой обрывается до рестарта. Инцидент 24.06.2026 (deploy/redeploy.sh).
*.sh text eol=lf
Binary file not shown.
+2 -2
View File
@@ -21,10 +21,10 @@ jobs:
extensions: pdo, pdo_pgsql, redis, mbstring, intl, bcmath
coverage: none
- name: Setup Node 20
- name: Setup Node 22
uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '22'
cache: 'npm'
- name: Install root JS deps
+2 -2
View File
@@ -45,10 +45,10 @@ jobs:
echo "Requested: '$CMD_TRIM'"
# Group 1 — read-only / dry-run / inspection: всегда разрешены
READ_ONLY_RE='^(migrate:status|route:list|schedule:list|queue:listen --help|about|env:show|config:show|cache:table|view:cache|optimize:status|snapshot:backfill( --date=20[2-9][0-9]-[0-1][0-9]-[0-3][0-9])?|scheduler:check-heartbeats|incidents:watch-failures( --threshold-spike=[0-9]+)?( --threshold-daily=[0-9]+)?( --persistent-hours=[0-9]+)?|supplier:rekey-orphans --dry-run|audit:verify-chains|audit:rebuild-chain --partition=[a-z_0-9]+ --from-id=[0-9]+ --dry-run|deals:backfill-region-city --dry-run)( *)$'
READ_ONLY_RE='^(migrate:status|route:list|schedule:list|queue:listen --help|about|env:show|config:show|cache:table|view:cache|optimize:status|snapshot:backfill( --date=20[2-9][0-9]-[0-1][0-9]-[0-3][0-9])?|scheduler:check-heartbeats|incidents:watch-failures( --threshold-spike=[0-9]+)?( --threshold-daily=[0-9]+)?( --persistent-hours=[0-9]+)?|supplier:rekey-orphans --dry-run|audit:verify-chains|audit:rebuild-chain --partition=[a-z_0-9]+ --from-id=[0-9]+ --dry-run)( *)$'
# Group 2 — mutating: требуют confirm_apply=true
MUTATING_RE='^(supplier:rekey-orphans|cache:clear|view:clear|config:clear|route:clear|optimize:clear|optimize|queue:restart|partitions:create-months( --months=[0-9]+)?|partitions:drop-old|audit:rebuild-chain --partition=[a-z_0-9]+ --from-id=[0-9]+( --force)?|deals:backfill-region-city)( *)$'
MUTATING_RE='^(supplier:rekey-orphans|cache:clear|view:clear|config:clear|route:clear|optimize:clear|optimize|queue:restart|partitions:create-months( --months=[0-9]+)?|partitions:drop-old|audit:rebuild-chain --partition=[a-z_0-9]+ --from-id=[0-9]+( --force)?)( *)$'
if [[ "$CMD_TRIM" =~ $READ_ONLY_RE ]]; then
echo "::notice::Command in read-only whitelist — proceeding."
-393
View File
@@ -1,393 +0,0 @@
name: Lead region — prod ops
# Самодостаточный launch-инструмент фичи lead-region-resolution.
# Один воркфлоу, переключатель op. НЕ трогает deploy.yml / artisan-run.yml.
#
# op:
# pre-migrate — пред-применить миграцию 2026_05_31_100000 через postgres
# superuser (crm_app_user не член crm_migrator → обычный migrate
# падает) + пометить применённой, чтобы deploy её пропустил.
# set-env — записать DADATA-ключи (из secrets) + LEAD_REGION_RESOLVER_ENABLED
# (input flag) в боевой .env, перекэшировать config, рестарт очереди.
# fetch-rossvyaz — скачать файл/архив реестра (input url) на прод в /var/www/liderra/rossvyaz.
# import — phone-ranges:import (input dry_run) под www-data (DDL-свап идёт
# через pgsql_supplier = crm_supplier_worker, член crm_migrator).
# smoke — phone-region:smoke --phone=<input phone> под www-data (нужны ключи).
#
# Secrets: LIDERRA_SSH_KEY, DADATA_API_KEY, DADATA_SECRET.
on:
workflow_dispatch:
inputs:
op:
description: 'Операция'
required: true
type: choice
options:
- pre-migrate
- set-env
- fetch-rossvyaz
- fetch-via-runner
- deliver-from-repo
- import
- smoke
flag:
description: 'set-env: LEAD_REGION_RESOLVER_ENABLED'
required: false
default: 'false'
type: choice
options:
- 'false'
- 'true'
url:
description: 'fetch-rossvyaz: прямая ссылка на CSV/ZIP реестра Россвязи'
required: false
type: string
dir:
description: 'import: каталог с CSV на проде'
required: false
default: '/var/www/liderra/rossvyaz'
type: string
dry_run:
description: 'import: только staging без swap'
required: false
default: true
type: boolean
phone:
description: 'smoke: телефон'
required: false
default: '79161234567'
type: string
jobs:
op:
name: ${{ github.event.inputs.op }}
runs-on: ubuntu-latest
timeout-minutes: 15
concurrency:
group: liderra-prod-deploy
cancel-in-progress: false
env:
LIDERRA_HOST: 111.88.246.137
LIDERRA_USER: ubuntu
APP_DIR: /var/www/liderra/app
OP: ${{ github.event.inputs.op }}
FLAG: ${{ github.event.inputs.flag }}
URL: ${{ github.event.inputs.url }}
DIR: ${{ github.event.inputs.dir }}
DRY: ${{ github.event.inputs.dry_run }}
PHONE: ${{ github.event.inputs.phone }}
steps:
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.LIDERRA_SSH_KEY }}" > ~/.ssh/liderra_deploy
chmod 600 ~/.ssh/liderra_deploy
ssh-keyscan -H "${LIDERRA_HOST}" >> ~/.ssh/known_hosts 2>/dev/null
- name: Checkout repo (for deliver-from-repo)
if: ${{ github.event.inputs.op == 'deliver-from-repo' }}
uses: actions/checkout@v4
- name: op=pre-migrate (superuser DDL + mark applied)
if: ${{ github.event.inputs.op == 'pre-migrate' }}
run: |
SQL_B64=$(cat <<'SQLEOF' | base64 -w0
BEGIN;
-- 1. phone_ranges_imports (FK target — создаём первым)
CREATE TABLE phone_ranges_imports (
id BIGSERIAL PRIMARY KEY,
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
source_url TEXT NOT NULL,
rows_inserted INTEGER NOT NULL DEFAULT 0,
rows_updated INTEGER NOT NULL DEFAULT 0,
checksum_sha256 TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'in_progress'
CHECK (status IN ('in_progress','completed','failed','rolled_back')),
error TEXT,
completed_at TIMESTAMPTZ
);
COMMENT ON TABLE phone_ranges_imports IS
'Журнал импортов реестра Россвязи (idempotency по checksum_sha256, atomic-swap откат).';
-- 2. phone_ranges (реестр диапазонов; SaaS-level, без RLS — публичные данные)
CREATE TABLE phone_ranges (
id BIGSERIAL PRIMARY KEY,
def_code SMALLINT NOT NULL,
from_num BIGINT NOT NULL,
to_num BIGINT NOT NULL,
operator TEXT NOT NULL,
region TEXT NOT NULL,
region_normalized TEXT,
subject_code SMALLINT,
imported_at TIMESTAMPTZ NOT NULL,
import_id BIGINT NOT NULL REFERENCES phone_ranges_imports(id),
CONSTRAINT chk_phone_ranges_def_code CHECK (def_code BETWEEN 300 AND 999),
CONSTRAINT chk_phone_ranges_subject_code CHECK (subject_code IS NULL OR subject_code BETWEEN 1 AND 89),
CONSTRAINT chk_phone_ranges_range_valid CHECK (from_num <= to_num)
);
CREATE INDEX idx_phone_ranges_lookup ON phone_ranges (def_code, from_num, to_num);
COMMENT ON TABLE phone_ranges IS
'Реестр диапазонов нумерации Россвязи (rossvyaz.gov.ru). Локальный fallback для LeadRegionResolver.';
GRANT SELECT ON phone_ranges, phone_ranges_imports TO crm_app_user, crm_supplier_worker;
-- 3. lead_region_resolution_log (SaaS-level, партиционирован по received_at)
CREATE TABLE lead_region_resolution_log (
id BIGSERIAL,
supplier_lead_id BIGINT NOT NULL,
received_at TIMESTAMPTZ NOT NULL,
phone_masked TEXT NOT NULL,
subject_code_resolved SMALLINT,
subject_code_from_tag SMALLINT,
region_source TEXT NOT NULL
CHECK (region_source IN ('dadata','rossvyaz','tag','unknown')),
dadata_qc SMALLINT,
dadata_provider TEXT,
dadata_type TEXT,
dadata_response_masked JSONB,
rossvyaz_matched BOOLEAN NOT NULL DEFAULT FALSE,
actual_subject_code SMALLINT
CHECK (actual_subject_code IS NULL OR actual_subject_code BETWEEN 1 AND 89),
substituted_subject_code SMALLINT
CHECK (substituted_subject_code IS NULL OR substituted_subject_code BETWEEN 1 AND 89),
routing_step SMALLINT
CHECK (routing_step IS NULL OR routing_step BETWEEN 1 AND 3),
phone_operator TEXT,
cache_hit BOOLEAN NOT NULL DEFAULT FALSE,
duration_ms INTEGER,
resolved_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (id, received_at)
) PARTITION BY RANGE (received_at);
CREATE INDEX idx_lrrl_lead_id ON lead_region_resolution_log (supplier_lead_id);
CREATE INDEX idx_lrrl_source ON lead_region_resolution_log (region_source, received_at);
COMMENT ON TABLE lead_region_resolution_log IS
'Аудит каждого резолва региона лида (источник, qc, оператор, шаг каскада). Партиции помесячно.';
GRANT SELECT, INSERT ON lead_region_resolution_log TO crm_supplier_worker;
GRANT SELECT ON lead_region_resolution_log TO crm_app_user;
CREATE TABLE lead_region_resolution_log_y2026_m05
PARTITION OF lead_region_resolution_log
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
CREATE TABLE lead_region_resolution_log_y2026_m06
PARTITION OF lead_region_resolution_log
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
-- 4. supplier_leads: +4 колонки
ALTER TABLE supplier_leads
ADD COLUMN resolved_subject_code SMALLINT
CHECK (resolved_subject_code IS NULL OR resolved_subject_code BETWEEN 1 AND 89),
ADD COLUMN region_source TEXT
CHECK (region_source IN ('dadata','rossvyaz','tag','unknown')),
ADD COLUMN dadata_qc SMALLINT,
ADD COLUMN phone_operator TEXT;
-- 5. deals: +2 колонки
ALTER TABLE deals
ADD COLUMN phone_operator TEXT,
ADD COLUMN region_substituted BOOLEAN NOT NULL DEFAULT FALSE;
-- ownership как у миграции (она шла бы под crm_migrator)
ALTER TABLE phone_ranges_imports OWNER TO crm_migrator;
ALTER TABLE phone_ranges OWNER TO crm_migrator;
ALTER TABLE lead_region_resolution_log OWNER TO crm_migrator;
ALTER TABLE lead_region_resolution_log_y2026_m05 OWNER TO crm_migrator;
ALTER TABLE lead_region_resolution_log_y2026_m06 OWNER TO crm_migrator;
-- retention (system_settings, 12 мес)
INSERT INTO system_settings (key, value, type, description, updated_at)
SELECT 'partition_retention_months_lead_region_resolution_log', '12', 'int',
'Retention в месяцах для lead_region_resolution_log (~365 дней)', NOW()
WHERE NOT EXISTS (
SELECT 1 FROM system_settings
WHERE key = 'partition_retention_months_lead_region_resolution_log');
COMMIT;
SQLEOF
)
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" "SQL_B64='$SQL_B64' bash -s" <<'REMOTE' | tee /tmp/op.log
set -euo pipefail
MIG_NAME='2026_05_31_100000_create_phone_ranges_and_resolution_log'
ALREADY=$(sudo -u postgres psql -d liderra -tAc "SELECT 1 FROM migrations WHERE migration='${MIG_NAME}' LIMIT 1")
if [ "${ALREADY}" = "1" ]; then
echo "Migration ${MIG_NAME} уже применена — пропускаю."
exit 0
fi
TABLE_EXISTS=$(sudo -u postgres psql -d liderra -tAc "SELECT 1 FROM information_schema.tables WHERE table_name='phone_ranges' LIMIT 1")
if [ "${TABLE_EXISTS}" != "1" ]; then
echo "Применяю lead-region DDL через postgres superuser..."
echo "$SQL_B64" | base64 -d | sudo -u postgres psql -d liderra -v ON_ERROR_STOP=1
else
echo "Таблица phone_ranges уже существует — только помечаю миграцию."
fi
NEXT_BATCH=$(sudo -u postgres psql -d liderra -tAc "SELECT COALESCE(MAX(batch),0)+1 FROM migrations")
sudo -u postgres psql -d liderra -c \
"INSERT INTO migrations (migration, batch) SELECT '${MIG_NAME}', ${NEXT_BATCH} WHERE NOT EXISTS (SELECT 1 FROM migrations WHERE migration='${MIG_NAME}')"
echo "Помечено ${MIG_NAME} применённой (batch ${NEXT_BATCH})."
echo "=== Проверка таблиц ==="
sudo -u postgres psql -d liderra -c "\dt phone_ranges|phone_ranges_imports|lead_region_resolution_log" || true
REMOTE
- name: op=set-env (keys from secrets + flag → prod .env)
if: ${{ github.event.inputs.op == 'set-env' }}
env:
DK: ${{ secrets.DADATA_API_KEY }}
DS: ${{ secrets.DADATA_SECRET }}
run: |
DK_B64=$(printf '%s' "$DK" | base64 -w0)
DS_B64=$(printf '%s' "$DS" | base64 -w0)
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" \
"DK_B64='$DK_B64' DS_B64='$DS_B64' FLAG='$FLAG' APP_DIR='$APP_DIR' bash -s" <<'REMOTE' | tee /tmp/op.log
set -euo pipefail
ENV="${APP_DIR}/.env"
DK=$(echo "$DK_B64" | base64 -d)
DS=$(echo "$DS_B64" | base64 -d)
upsert() {
local key="$1" val="$2"
sudo sed -i "/^${key}=/d" "$ENV"
echo "${key}=${val}" | sudo tee -a "$ENV" >/dev/null
}
upsert DADATA_API_KEY "$DK"
upsert DADATA_SECRET "$DS"
upsert LEAD_REGION_RESOLVER_ENABLED "$FLAG"
cd "$APP_DIR"
sudo -u www-data php artisan config:clear
sudo -u www-data php artisan config:cache
sudo systemctl restart liderra-queue
echo "set-env готово: flag=${FLAG}, ключи записаны."
echo "=== Проверка (значения скрыты) ==="
sudo grep -E '^(DADATA_API_KEY|DADATA_SECRET|LEAD_REGION_RESOLVER_ENABLED)=' "$ENV" | sed -E 's/=(.).*/=\1***/'
echo "=== queue status ==="
systemctl is-active liderra-queue || true
REMOTE
- name: op=fetch-rossvyaz (download registry on prod)
if: ${{ github.event.inputs.op == 'fetch-rossvyaz' }}
run: |
# Пустой url → качаем все 4 официальных файла Минцифры за один прогон.
# Непустой url → качаем только его (ручной режим).
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" \
"URL='$URL' bash -s" <<'REMOTE' | tee /tmp/op.log
set -euo pipefail
DEST=/var/www/liderra/rossvyaz
sudo mkdir -p "$DEST"
cd "$DEST"
if [ -n "$URL" ]; then
URLS="$URL"
else
URLS="https://opendata.digital.gov.ru/downloads/DEF-9xx.csv
https://opendata.digital.gov.ru/downloads/ABC-3xx.csv
https://opendata.digital.gov.ru/downloads/ABC-4xx.csv
https://opendata.digital.gov.ru/downloads/ABC-8xx.csv"
fi
for U in $URLS; do
FNAME=$(basename "${U%%\?*}")
[ -n "$FNAME" ] || FNAME="rossvyaz-download"
echo "Скачиваю $U -> $FNAME"
sudo curl -fSL --retry 3 --retry-delay 2 -e 'https://opendata.digital.gov.ru/registry/numeric/downloads/' -H 'Accept: text/csv,application/csv,application/octet-stream,*/*' -H 'Accept-Language: ru-RU,ru;q=0.9' -A 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36' -o "$FNAME" "$U"
case "$FNAME" in
*.zip|*.ZIP) echo "Распаковываю zip..."; sudo unzip -o "$FNAME" ;;
esac
done
sudo chown -R www-data:www-data "$DEST"
echo "=== Содержимое $DEST ==="
ls -lh "$DEST"
FIRST_CSV=$(ls "$DEST"/DEF-9xx.csv "$DEST"/*.csv "$DEST"/*.CSV 2>/dev/null | head -1 || true)
if [ -n "$FIRST_CSV" ]; then
echo "=== Первые строки $FIRST_CSV (cp1251→utf8) ==="
sudo head -3 "$FIRST_CSV" | iconv -f cp1251 -t utf-8 2>/dev/null || sudo head -3 "$FIRST_CSV"
fi
REMOTE
- name: op=fetch-via-runner (download on runner, ship to prod)
if: ${{ github.event.inputs.op == 'fetch-via-runner' }}
run: |
mkdir -p /tmp/rv && cd /tmp/rv && rm -f /tmp/rv/*.csv
for U in https://opendata.digital.gov.ru/downloads/DEF-9xx.csv https://opendata.digital.gov.ru/downloads/ABC-3xx.csv https://opendata.digital.gov.ru/downloads/ABC-4xx.csv https://opendata.digital.gov.ru/downloads/ABC-8xx.csv; do
FN=$(basename "${U%%\?*}")
echo "runner: скачиваю $U -> $FN"
curl -fSL --retry 3 --retry-delay 2 -e 'https://opendata.digital.gov.ru/registry/numeric/downloads/' -H 'Accept: text/csv,application/csv,*/*' -A 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36' -o "$FN" "$U"
done
echo "=== скачано на runner ==="
ls -lh /tmp/rv | tee /tmp/op.log
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" 'mkdir -p /tmp/rvup && rm -f /tmp/rvup/*.csv'
scp -i ~/.ssh/liderra_deploy /tmp/rv/*.csv "${LIDERRA_USER}@${LIDERRA_HOST}:/tmp/rvup/"
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" 'sudo mkdir -p /var/www/liderra/rossvyaz && sudo mv /tmp/rvup/*.csv /var/www/liderra/rossvyaz/ && sudo chown -R www-data:www-data /var/www/liderra/rossvyaz && echo "=== на проде /var/www/liderra/rossvyaz ===" && ls -lh /var/www/liderra/rossvyaz' | tee -a /tmp/op.log
- name: op=deliver-from-repo (scp repo CSV/ZIP to prod, unzip there)
if: ${{ github.event.inputs.op == 'deliver-from-repo' }}
run: |
# Ищем файлы реестра где угодно (корень или папка), .csv или .zip
mapfile -t FILES < <(find . -maxdepth 2 -type f \( \( -iname 'DEF-9xx*' -o -iname 'ABC-3xx*' -o -iname 'ABC-4xx*' -o -iname 'ABC-8xx*' \) -iname '*.csv' -o -iname '*.zip' \) ! -path './.git/*')
if [ ${#FILES[@]} -eq 0 ]; then
echo "::error::Не нашёл файлов реестра (DEF-9xx/ABC-*.csv|zip) ни в корне, ни в rossvyaz-data/. Проверь, что они закоммичены в репозиторий."; exit 1
fi
echo "=== файлы в репозитории (rossvyaz-data/) ==="
ls -lh "${FILES[@]}" | tee /tmp/op.log
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" 'mkdir -p /tmp/rvup && rm -f /tmp/rvup/*'
scp -i ~/.ssh/liderra_deploy "${FILES[@]}" "${LIDERRA_USER}@${LIDERRA_HOST}:/tmp/rvup/"
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" '
cd /tmp/rvup
for z in *.zip *.ZIP; do if [ -e "$z" ]; then echo "распаковываю $z"; unzip -o "$z"; rm -f "$z"; fi; done
sudo mkdir -p /var/www/liderra/rossvyaz
find . -iname "*.csv" -exec sudo mv {} /var/www/liderra/rossvyaz/ \;
sudo chown -R www-data:www-data /var/www/liderra/rossvyaz
echo "=== на проде /var/www/liderra/rossvyaz ==="
ls -lh /var/www/liderra/rossvyaz
' | tee -a /tmp/op.log
- name: op=import (phone-ranges:import)
if: ${{ github.event.inputs.op == 'import' }}
run: |
DRY_FLAG=""
if [ "${DRY}" = "true" ]; then DRY_FLAG="--dry-run"; fi
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" \
"APP_DIR='$APP_DIR' DIR='$DIR' DRY_FLAG='$DRY_FLAG' bash -s" <<'REMOTE' | tee /tmp/op.log
set -e
cd "$APP_DIR"
echo "=== phone-ranges:import --dir=${DIR} ${DRY_FLAG} ==="
sudo -u www-data php artisan phone-ranges:import --dir="$DIR" $DRY_FLAG 2>&1
echo "=== Счётчики ==="
sudo -u postgres psql -d liderra -c "SELECT count(*) AS phone_ranges FROM phone_ranges" 2>&1 || true
# staging-счётчик: 2 отдельных запроса, чтобы Postgres не парсил
# подзапрос к phone_ranges_staging, когда таблица уже свапнута (иначе
# ERROR relation "phone_ranges_staging" does not exist даже в ветке CASE).
STAGING_EXISTS=$(sudo -u postgres psql -d liderra -tAc "SELECT to_regclass('phone_ranges_staging') IS NOT NULL")
if [ "$STAGING_EXISTS" = "t" ]; then
sudo -u postgres psql -d liderra -c "SELECT count(*) AS staging_rows FROM phone_ranges_staging" 2>&1 || true
else
echo "staging: отсутствует (после свапа — норма)"
fi
echo "=== Последний импорт ==="
sudo -u postgres psql -d liderra -c \
"SELECT id, status, rows_inserted, rows_updated, imported_at FROM phone_ranges_imports ORDER BY id DESC LIMIT 3" 2>&1 || true
REMOTE
- name: op=smoke (phone-region:smoke)
if: ${{ github.event.inputs.op == 'smoke' }}
run: |
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" \
"APP_DIR='$APP_DIR' PHONE='$PHONE' bash -s" <<'REMOTE' | tee /tmp/op.log
set -e
cd "$APP_DIR"
echo "=== phone-region:smoke --phone=${PHONE} ==="
sudo -u www-data php artisan phone-region:smoke --phone="$PHONE" 2>&1
REMOTE
- name: Print summary
if: always()
run: |
{
echo "## lead-region-ops: \`${OP}\`"
echo
echo '```'
cat /tmp/op.log 2>/dev/null || echo "(нет вывода)"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
- name: Cleanup SSH key
if: always()
run: rm -f ~/.ssh/liderra_deploy
-22
View File
@@ -47,16 +47,6 @@ demo-*.jpeg
# gitleaks
gitleaks-report.json
# ward (security-сканер) — отчёты в корне
ward-report.*
lychee-links-report.txt
walk-*.png
# ZAP active scan — сырые отчёты (анализ коммитится как .md, сырьё локально:
# может содержать снимки ответов dev-приложения)
docs/security/*-zap-active-scan.json
docs/security/*-zap-active-scan.html
# ── IDE / редакторы ─────────────────────────────────────────────────────────
.vscode/*
!.vscode/extensions.json
@@ -139,7 +129,6 @@ c--Users-*/
# ── Временные файлы ─────────────────────────────────────────────────────────
*.tmp
*.bak
.mcp.json.bak-*
*.log
tmp/
.tmp/
@@ -213,14 +202,3 @@ ruflo-mcp-stderr.log
.claude/commands/*
!.claude/commands/security-review.md
.claude/helpers/
# ── Локальные бэкапы settings.json + эталон-снимки (M7 canon backups, local-only) ──
.claude/arh settings/
.claude/settings - *.json
.claude/settings эталон*.json
.claude/эталон/
.claude/scheduled_tasks.lock
/settings.json
settings copy.json
# Строчный Ctemp-дамп (CTemp* выше не ловит из-за регистра)
Ctemp*
+1 -19
View File
@@ -89,37 +89,19 @@ paths = [
'''app/tests/.*\.php''',
# Database seeders с демо-данными (admin@demo.local + +7916123XXXX демо-телефоны)
'''app/database/seeders/.*\.php''',
# Database factories — генераторы тестовых фикстур (фейковые телефоны/ИНН,
# напр. TenantFactory::withRequisites +79150000000), не реальные ПДн. Та же
# категория, что seeders/tests.
'''app/database/factories/.*\.php''',
# Audit-internal docs (findings/blocked/report/plan) — содержат демо-телефоны и
# script-смешанные artifacts как finding'и для review (не реальные ПДн)
'''docs/superpowers/audits/.*\.md''',
'''docs/superpowers/plans/.*\.md''',
# Приёмочные ранбуки (R0–R5) — синтетические тест-телефоны (79990001122 и
# пр.) в матрицах провижининга/инъекции, не реальные ПДн. Та же категория,
# что plans/specs/audits.
'''docs/superpowers/runbooks/.*\.md''',
# Internal design specs — внутренние проектные доки с демо-данными (демо-телефоны
# в примерах, напр. spec про log-PII-scrubbing), не реальные ПДн. Как plans/audits.
'''docs/superpowers/specs/.*\.md''',
# Mock-данные для UI-разводки фронтенда (фиктивные имена/телефоны)
'''app/resources/js/composables/mockDeals\.ts''',
# Vitest-тесты с assertion на mock-данные (mock-телефоны из mockDeals)
'''app/tests/Frontend/.*\.(spec|test)\.ts''',
# Settings-вкладки с фиктивными mock-данными (профиль/сессии — UI-разводка)
'''app/resources/js/views/settings/.*\.vue''',
# Публичные реквизиты ПРОДАВЦА (ИП) — единый источник для футера/оферты/цен.
# По требованию ЮKassa контакты продавца (телефон/почта) обязаны быть публично
# на сайте; это не клиентские ПДн, а опубликованные бизнес-реквизиты.
'''app/resources/js/constants/legal\.ts''',
# 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''',
# Test fixture for the secret-scanner / read-path-deny (M5) — PEM-header marker +
# AWS EXAMPLE key, used to verify detection. Not a real key; file deleted in brain split.
'''tools/enforce-read-path-deny\.test\.mjs'''
'''tools/observer-pii-filter\.test\.mjs'''
]
regexTarget = "match"
regexes = [
+2 -3
View File
@@ -54,9 +54,8 @@ exclude = [
# Sample/примерные адреса
"^https?://example\\.com",
"^https?://example\\.org",
# Покойный GitHub-аккаунт CoralMinister (suspended) — все ссылки на него мертвы:
# исторические compare/actions-runs в ПИЛОТ.md / handoffs / plans. Бэкап теперь Gitea.
"^https?://github\\.com/CoralMinister/",
# Приватный репозиторий проекта (404 для анонимных запросов — это норма)
"^https?://github\\.com/CoralMinister/liderra",
# web/v8/*.html — статические концепты, root-relative ссылки на будущие маршруты Vue
"^/(login|register|legal|dashboard|deals|admin|reports|reminders|billing|impersonation|notifications)(/|$|\\?)",
# Корневой `/` в концептах (логотип-якорь для будущей главной)
-1
View File
@@ -6,4 +6,3 @@ CLAUDE.md
.claude/skills/ccpm/
.claude/skills/data-scientist/
.claude/skills/marketingskills/
docs/superpowers/
-25
View File
@@ -54,31 +54,6 @@
},
"comment": "A3 integration-tooling #47 — OpenAPI MCP (ivo-toby/mcp-openapi-server, @ivotoby/openapi-mcp-server v1.14.0, MIT). Exposes Лидерра REST API endpoints (docs/api/openapi.yaml) as MCP tools. Config via env-vars API_BASE_URL + OPENAPI_SPEC_PATH (stdio transport default). READ scope: API discovery/introspection for Claude Code. Формализован в Tooling §4.22, PSR_v1 R10.1 блок 3, Pravila §13.2."
},
"perplexity": {
"command": "npx",
"args": ["-y", "@perplexity-ai/mcp-server"],
"env": {
"PERPLEXITY_API_KEY": "${PERPLEXITY_API_KEY}",
"PERPLEXITY_BASE_URL": "https://api.aitunnel.ru/v1"
},
"comment": "research-tooling (Perplexity Pack) #87 — research-канал. Официальный @perplexity-ai/mcp-server (репо perplexityai/modelcontextprotocol), MIT, подписанная сборка. Tools: perplexity_search/ask/research/reason (sonar-*). ПЛАТНЫЙ API; ключ PERPLEXITY_API_KEY только в user env (не в репо). Вет ПРИНЯТ — docs/research/research-vet.md. Перенос plan-v13 2026-06-14 (owner waiver, Вариант 2)."
},
"exa": {
"command": "npx",
"args": ["-y", "exa-mcp-server"],
"env": {
"EXA_API_KEY": "${EXA_API_KEY}"
},
"comment": "research-tooling (Perplexity Pack) #88 — Exa нейро/семантический поиск. exa-mcp-server (репо exa-labs), MIT (license-поле npm пусто — см. вет). Tools: web_search_exa / web_fetch_exa (default). ПЛАТНЫЙ API; ключ EXA_API_KEY только в user env. Вет ПРИНЯТ — docs/research/research-vet.md."
},
"firecrawl": {
"command": "npx",
"args": ["-y", "firecrawl-mcp"],
"env": {
"FIRECRAWL_API_KEY": "${FIRECRAWL_API_KEY}"
},
"comment": "research-tooling (Perplexity Pack) #89 — Firecrawl глубокое чтение/обход. firecrawl-mcp (репо firecrawl/firecrawl-mcp-server), MIT, очень активен. Tools: scrape/crawl/extract + firecrawl_agent. ПЛАТНЫЙ API; ключ FIRECRAWL_API_KEY только в user env. Вет ПРИНЯТ — docs/research/research-vet.md."
},
"_disabled_marketing_servers_note": "ОТКЛЮЧЕНЫ 2026-05-31 (владелец: «отрежь маркетинг»). Причина: их авто-генерируемые схемы (особенно wordstat — 128 tools из Яндекс.Директа) — главный подозреваемый в API 400 tools.110/113, ронявшем субагентов при bulk-load всех инструментов (subagent-driven-development). Серверы off-phase и без OAuth-токенов всё равно не стартовали. Полный конфиг — в git до этого коммита. Чтобы вернуть, восстановить три блока mcpServers: marketing-metrika (npx -y github:atomkraft/yandex-metrika-mcp; env YANDEX_OAUTH_TOKEN; READ-ONLY; Tooling §4.53), marketing-wordstat (npx -y github:SvechaPVL/yandex-mcp; env YANDEX_OAUTH_TOKEN; ТОЛЬКО Wordstat per IS9/MKT8; Tooling §4.54), marketing-telegram (npx -y github:chigwell/telegram-mcp; env TELEGRAM_API_ID/API_HASH/SESSION_STRING; выделенный аккаунт IS9; Tooling §4.51). См. docs/security/marketing-vet.md и docs/marketing/README.md.",
"_comment_postiz_skeleton": "TODO: C1 marketing-tooling #81 — Postiz MCP (gitroomhq/postiz-app self-host + antoniolg/postiz-mcp). Активировать ПОСЛЕ: 1) развернуть Postiz self-hosted (git clone https://github.com/gitroomhq/postiz-app + docker-compose, AGPL-3.0: internal-only, no modifications); 2) провести vet лицензии antoniolg/postiz-mcp (NOT YET VERIFIED — см. docs/marketing/README.md Open vet notes); 3) подключить соцсети в Postiz UI. Будущий entry: \"marketing-postiz\": { \"command\": \"npx\", \"args\": [\"-y\", \"postiz-mcp\"], \"env\": { \"POSTIZ_API_URL\": \"${POSTIZ_API_URL}\", \"POSTIZ_API_KEY\": \"${POSTIZ_API_KEY}\" }, \"comment\": \"C1 #81 post-activation\" }. Tooling §4.52. docs/marketing/README.md."
}
-69526
View File
File diff suppressed because it is too large Load Diff
-150000
View File
File diff suppressed because it is too large Load Diff
-142791
View File
File diff suppressed because it is too large Load Diff
-73783
View File
File diff suppressed because it is too large Load Diff
+289 -65
View File
File diff suppressed because one or more lines are too long
-16985
View File
File diff suppressed because it is too large Load Diff
-18
View File
@@ -42,18 +42,6 @@ SUPPLIER_PORTAL_URL=https://crm.bp-gr.ru
# Supplier alerts (email через Unisender Go relay)
SUPPLIER_ALERT_EMAIL=
# SaaS-admin fail-closed гейт (M-1). Логины nginx basic-auth (.htpasswd-admin),
# допущенные в /api/admin/*. CSV; дефолт совпадает с прод-.htpasswd.
ADMIN_ALLOWED_USERS=admin
# ADMIN_GATE_ENFORCED=true # авто: true вне local/testing; задать явно для override
# Системный admin-id для audit-trail (FK saas_admin_audit_log). На проде crm_app_user
# не имеет прав на saas_admin_users → задать id сид-стаба. dev/test — оставить пустым.
ADMIN_AUDIT_SYSTEM_USER_ID=
# Капча самозаписи (M-2). driver=null (dev) | yandex (prod). Для yandex нужен server-key.
CAPTCHA_DRIVER=null
YANDEX_SMARTCAPTCHA_SERVER_KEY=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
@@ -82,8 +70,6 @@ MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
SUPPORT_EMAIL=support@liderra.ru
JIVO_WIDGET_ID=
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
@@ -92,7 +78,3 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
# Клиентский ключ Yandex SmartCaptcha (M-2). Пусто → fallback-чекбокс (dev).
# На проде — клиентский ключ ysc1_… (для виджета на странице регистрации).
VITE_YANDEX_SMARTCAPTCHA_SITEKEY=
-1
View File
@@ -4,7 +4,6 @@
.env
.env.backup
.env.production
.env.testing
.phpactor.json
.phpunit.result.cache
/.deptrac.cache
@@ -1,75 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Support\RussianRegions;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Одноразовый бэкфилл: проставляет deals.city (имя субъекта) у уже существующих сделок,
* у которых city ещё пуст, по resolved_subject_code связанного лида
* (deals supplier_lead_deliveries supplier_leads). Идемпотентно (только city IS NULL).
*
* Запускается через .github/workflows/artisan-run.yml (mutating-whitelist, confirm_apply).
* Парная правка для RouteSupplierLeadJob, который заполняет city у новых сделок.
*/
final class DealsBackfillRegionCityCommand extends Command
{
protected $signature = 'deals:backfill-region-city {--dry-run : Только посчитать, ничего не записывать}';
protected $description = 'Дозаполнить deals.city именем региона по resolved_subject_code лида (одноразовый бэкфилл)';
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
// BYPASSRLS-роль: бэкфилл идёт по всем тенантам без SET app.current_tenant_id.
$conn = DB::connection('pgsql_supplier');
$map = RussianRegions::CODE_TO_NAME;
$rows = $conn->table('deals')
->join('supplier_lead_deliveries as dlv', 'dlv.deal_id', '=', 'deals.id')
->join('supplier_leads as sl', 'sl.id', '=', 'dlv.supplier_lead_id')
->whereNull('deals.city')
->whereNotNull('sl.resolved_subject_code')
->select('deals.id', 'deals.received_at', 'sl.resolved_subject_code')
->get();
$seen = [];
$updated = 0;
foreach ($rows as $r) {
$dealId = (int) $r->id;
if (isset($seen[$dealId])) {
continue; // у сделки несколько доставок — обрабатываем один раз
}
$seen[$dealId] = true;
$name = $map[(int) $r->resolved_subject_code] ?? null;
if ($name === null) {
continue; // код вне справочника 1..89 — пропускаем
}
if (! $dryRun) {
$conn->table('deals')
->where('id', $dealId)
->where('received_at', $r->received_at) // partition key
->whereNull('city') // идемпотентный страж
->update(['city' => $name]);
}
$updated++;
}
$prefix = $dryRun ? '[dry-run] ' : '';
$this->info("{$prefix}deals.city backfill: {$updated} обновлено из ".count($seen).' кандидатов.');
Log::info('deals.backfill_region_city', [
'updated' => $updated,
'candidates' => count($seen),
'dry_run' => $dryRun,
]);
return self::SUCCESS;
}
}
@@ -1,142 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Imitation;
use App\Jobs\RouteSupplierLeadJob;
use App\Models\Project;
use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Models\User;
use App\Support\RussianRegions;
use Carbon\Carbon;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* Populate a LOCAL portal with imitation clients and leads for hands-on UI review
* (Phase 1 imitation harness). It NEVER runs on production.
*
* Self-contained on purpose (it must not depend on test-harness helpers): it funds
* a few tenant balances locally, disables the external DaData call (region is taken
* from the lead tag), builds the routing snapshot for the active date, then injects
* synthetic leads through the real RouteSupplierLeadJob so deals, charges and
* notifications appear exactly as they would in production.
*
* Spec: docs/superpowers/specs/2026-06-03-portal-client-imitation-phase1-design.md
*/
final class ImitationSeedCommand extends Command
{
protected $signature = 'imitation:seed
{--leads=20 : Number of synthetic leads to inject}
{--clients=3 : Number of imitation clients to create}';
protected $description = 'Populate the LOCAL portal with imitation clients and leads for UI review (never on production)';
public function handle(): int
{
if ($this->getLaravel()->environment('production')) {
$this->error('imitation:seed is forbidden in production.');
return self::FAILURE;
}
$leads = max(1, (int) $this->option('leads'));
$clients = max(1, (int) $this->option('clients'));
// Region comes from the lead tag — no external (paid) DaData call.
config(['services.dadata.enabled' => false]);
// Reference data required by the ledger.
(new PricingTierSeeder)->run();
$moscow = RussianRegions::nameToCode()['Москва']; // ordinal 82
// One shared supplier source (B2 site signal). The unique_key must be a
// domain-like string: RouteSupplierLeadJob re-resolves the supplier from the
// lead payload by (platform, unique_key) and infers signal_type from the
// identifier shape (see parseProjectField/resolveOrStub) — a domain → 'site'.
$supplierKey = 'imitseed-'.strtolower(Str::random(8)).'.test';
$supplier = SupplierProject::factory()->create([
'platform' => 'B2',
'signal_type' => 'site',
'unique_key' => $supplierKey,
]);
// Funded imitation clients, all targeting Москва, full week, generous limit.
for ($i = 1; $i <= $clients; $i++) {
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
User::factory()->create(['tenant_id' => $tenant->id]);
$project = Project::factory()
->asSiteSignal('imitseed-'.$i.'-'.Str::random(6).'.test')
->create([
'name' => "IMIT-seed-client-{$i}",
'tenant_id' => $tenant->id,
'regions' => [$moscow],
'delivery_days_mask' => 127,
'daily_limit_target' => 1000,
'is_active' => true,
]);
DB::table('project_supplier_links')->insert([
'project_id' => $project->id,
'supplier_project_id' => $supplier->id,
'platform' => $supplier->platform,
'subject_code' => null,
]);
}
// Build the routing snapshot for the active date the router will query.
Artisan::call('snapshot:rebuild', ['--date' => $this->activeDate()]);
// Inject synthetic leads through the real routing + ledger pipeline.
$injected = 0;
for ($n = 1; $n <= $leads; $n++) {
$phone = '79'.str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
$vid = random_int(100_000_000, 999_999_999);
$lead = SupplierLead::factory()->create([
'supplier_project_id' => $supplier->id,
'platform' => $supplier->platform,
'phone' => $phone,
'vid' => $vid,
'raw_payload' => [
'vid' => $vid,
'project' => $supplier->platform.'_'.$supplierKey,
'tag' => 'Москва',
'phone' => $phone,
'phones' => [$phone],
'time' => now()->getTimestamp(),
],
'received_at' => now(),
'source' => 'webhook',
'processed_at' => null,
'deals_created_count' => null,
]);
RouteSupplierLeadJob::dispatchSync($lead->id);
$injected++;
}
$this->info("imitation:seed done — {$clients} clients, {$injected} leads injected (region from tag, DaData disabled).");
return self::SUCCESS;
}
/**
* Active snapshot date mirrors LeadRouter::activeSnapshotDate()
* (today before 21:00 MSK, tomorrow at/after).
*/
private function activeDate(): string
{
$msk = Carbon::now('Europe/Moscow');
return ($msk->hour >= 21 ? $msk->copy()->addDay() : $msk)->format('Y-m-d');
}
}
@@ -1,119 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Pd;
use App\Models\SystemSetting;
use Carbon\CarbonImmutable;
use Illuminate\Console\Command;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
/**
* F-P1 / 152-ФЗ ретеншен: анонимизирует ПДн soft-deleted сделок по истечении
* настраиваемого срока (спека 2026-06-17-fp1-deal-pii-retention-spec).
*
* Срок (дней) в system_settings, ключ `pd_scrub_soft_deleted_deals_days`.
* Отсутствие ключа или значение < 1 no-op (юридический срок не зашит в код,
* выставляется на проде). Паттерн безопасности идентичен PartitionsDropExpired.
*
* Значения анонимизации идентичны PdErasureService::eraseSubject. Работает
* cross-tenant через pgsql_supplier (BYPASSRLS). Идемпотентно: уже затёртые
* (phone = ANON_PHONE) исключаются из выборки.
*/
class ScrubSoftDeletedDealsCommand extends Command
{
private const DB = 'pgsql_supplier';
private const SETTING_KEY = 'pd_scrub_soft_deleted_deals_days';
private const ANON_PHONE = '+7000XXXXXXX';
private const ANON_NAME = 'Удалено';
/** @var string */
protected $signature = 'pd:scrub-soft-deleted-deals
{--dry-run : Показать число кандидатов, не анонимизировать}';
/** @var string */
protected $description = 'Анонимизирует ПДн (телефон/имя) soft-deleted сделок старше retention-срока (152-ФЗ, F-P1)';
public function handle(): int
{
$days = $this->resolveRetentionDays();
if ($days === null) {
$this->line('<fg=gray>skip</> retention не настроен (system_settings.'.self::SETTING_KEY.' отсутствует или < 1).');
return self::SUCCESS;
}
$cutoff = CarbonImmutable::now()->subDays($days);
$candidates = $this->candidateQuery($cutoff)->count();
if ($this->option('dry-run')) {
$this->line("<fg=yellow>[dry-run]</> кандидатов на анонимизацию: {$candidates} (deleted_at старше {$days} дн.)");
return self::SUCCESS;
}
if ($candidates === 0) {
$this->info("Кандидатов на анонимизацию нет (retention={$days} дн.).");
return self::SUCCESS;
}
$now = CarbonImmutable::now();
// Bulk-UPDATE атомарен одним SQL; лог — одна summary-запись. Явная
// транзакция не нужна и несовместима с shared-PDO в тестах
// (pgsql_supplier делит сессию с уже открытой транзакцией pgsql).
$this->candidateQuery($cutoff)->update([
'phone' => self::ANON_PHONE,
'contact_name' => self::ANON_NAME,
'phones' => null,
'updated_at' => $now,
]);
// Системное действие: оба actor-поля NULL (допускается chk_pd_actor).
// log_hash заполняется триггером цепочки целостности.
DB::connection(self::DB)->table('pd_processing_log')->insert([
'tenant_id' => null,
'subject_type' => 'deal',
'subject_id' => null,
'action' => 'deleted',
'purpose' => '152-FZ retention scrub',
'actor_tenant_user_id' => null,
'actor_admin_user_id' => null,
'created_at' => $now,
]);
$this->info("Анонимизировано сделок: {$candidates} (retention={$days} дн.).");
return self::SUCCESS;
}
/** Кандидаты: soft-deleted старше cutoff, ещё не анонимизированные. */
private function candidateQuery(CarbonImmutable $cutoff): Builder
{
return DB::connection(self::DB)->table('deals')
->whereNotNull('deleted_at')
->where('deleted_at', '<', $cutoff)
->where('phone', '<>', self::ANON_PHONE);
}
/** Срок ретеншена из system_settings; null если ключа нет или значение < 1. */
private function resolveRetentionDays(): ?int
{
$setting = SystemSetting::find(self::SETTING_KEY);
if ($setting === null) {
return null;
}
$value = (int) $setting->value;
return $value >= 1 ? $value : null;
}
}
@@ -1,446 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Support\RussianRegions;
use Illuminate\Console\Command;
use Illuminate\Database\Connection;
use Illuminate\Support\Facades\DB;
use OpenSpout\Reader\XLSX\Reader as XlsxReader;
/**
* Импорт реестра нумерации Россвязи в `phone_ranges` (spec §6).
*
* php artisan phone-ranges:import --file=<csv|xlsx> [--force] [--dry-run]
* php artisan phone-ranges:import --dir=<dir с пакетом файлов> [...]
*
* Алгоритм:
* 1. Резолв входных файлов (--file | --dir; --url отложен оператор качает пакет вручную).
* 2. Checksum-идемпотентность: совпал с предыдущим `completed` status='rolled_back', выход.
* 3. Парсинг (CSV через str_getcsv ';', XLSX через openspout) нормализованные строки.
* 4. Маппинг region subject_code через RussianRegions::nameToCode(). Несматчившиеся лог в error.
* 5. Сборка `phone_ranges_staging` (LIKE phone_ranges) + bulk INSERT.
* 6. --dry-run staging остаётся для инспекции, swap НЕ делается, status='rolled_back'.
* Иначе atomic RENAME swap + status='completed'.
*
* Запись идёт через `pgsql_supplier` (на проде crm_supplier_worker член crm_migrator,
* INHERIT даёт CREATE; SET ROLE crm_migrator выравнивает ownership. На dev/test postgres superuser).
*
* NB (swap operator-validated): committing-swap (шаг 6 else) НЕ покрыт автотестом
* RENAME коммитит и сломал бы общую тестовую БД. Свап проверяется первым реальным
* импортом оператора по runbook (Session 6). Тесты покрывают parse/map/dry-run/idempotency.
*/
class PhoneRangesImportCommand extends Command
{
/** @var string */
protected $signature = 'phone-ranges:import
{--file= : Путь к одному CSV/XLSX файлу реестра}
{--dir= : Каталог с пакетом файлов реестра (*.csv, *.xlsx)}
{--url= : (отложено) URL пакета скачать вручную и использовать --dir}
{--force : Игнорировать checksum-идемпотентность}
{--dry-run : Распарсить и собрать staging, но не делать atomic swap}';
/** @var string */
protected $description = 'Импорт реестра нумерации Россвязи в phone_ranges (idempotent, atomic swap)';
/** Connection для DDL/записи (на проде crm_migrator-capable, на dev/test — superuser fallback). */
private const DDL_CONNECTION = 'pgsql_supplier';
/** Размер пачки для bulk INSERT в staging. */
private const INSERT_CHUNK = 1000;
public function handle(): int
{
$files = $this->resolveFiles();
if ($files === null) {
return self::FAILURE;
}
$checksum = $this->computeChecksum($files);
$dryRun = (bool) $this->option('dry-run');
$force = (bool) $this->option('force');
// 2. Идемпотентность по checksum (если не --force).
if (! $force) {
$prev = DB::table('phone_ranges_imports')
->where('checksum_sha256', $checksum)
->where('status', 'completed')
->orderByDesc('id')
->first();
if ($prev !== null) {
DB::table('phone_ranges_imports')->insert([
'source_url' => $this->sourceLabel($files),
'checksum_sha256' => $checksum,
'status' => 'rolled_back',
'rows_inserted' => 0,
'rows_updated' => 0,
'error' => "Идентично импорту #{$prev->id} (checksum совпал) — пропуск.",
'imported_at' => now(),
'completed_at' => now(),
]);
$this->info("Реестр идентичен импорту #{$prev->id} — пропуск (используйте --force для принудительного импорта).");
return self::SUCCESS;
}
}
// 3. Журнал импорта (in_progress).
$importId = (int) DB::table('phone_ranges_imports')->insertGetId([
'source_url' => $this->sourceLabel($files),
'checksum_sha256' => $checksum,
'status' => 'in_progress',
'imported_at' => now(),
]);
try {
// 4. Парсинг + маппинг.
$unmatched = [];
$rows = [];
foreach ($files as $file) {
foreach ($this->parseFile($file) as $rec) {
$regionNormalized = RussianRegions::canonicalRegionName($rec['region']);
$subjectCode = $regionNormalized === null
? null
: (RussianRegions::nameToCode()[$regionNormalized] ?? null);
if ($subjectCode === null && trim($rec['region']) !== '') {
$unmatched[trim($rec['region'])] = true;
}
$rows[] = [
'def_code' => $rec['def_code'],
'from_num' => $rec['from_num'],
'to_num' => $rec['to_num'],
'operator' => $rec['operator'],
'region' => $rec['region'],
'region_normalized' => $regionNormalized,
'subject_code' => $subjectCode,
'imported_at' => now(),
'import_id' => $importId,
];
}
}
// 5. Сборка staging.
$this->buildStaging($rows, $importId);
$unmatchedNote = $unmatched === []
? ''
: 'Не сопоставлены регионы: '.implode(', ', array_keys($unmatched)).'.';
if ($dryRun) {
DB::table('phone_ranges_imports')->where('id', $importId)->update([
'status' => 'rolled_back',
'rows_inserted' => count($rows),
'error' => trim('dry-run (swap не выполнен). '.$unmatchedNote),
'completed_at' => now(),
]);
$this->info('dry-run: '.count($rows).' строк в phone_ranges_staging, swap не выполнен.');
if ($unmatchedNote !== '') {
$this->warn($unmatchedNote);
}
return self::SUCCESS;
}
// 6. Atomic swap (operator-validated — см. docblock).
$this->atomicSwap();
DB::table('phone_ranges_imports')->where('id', $importId)->update([
'status' => 'completed',
'rows_inserted' => count($rows),
'error' => $unmatchedNote !== '' ? $unmatchedNote : null,
'completed_at' => now(),
]);
$this->info('Импортировано '.count($rows).' строк в phone_ranges (atomic swap выполнен).');
if ($unmatchedNote !== '') {
$this->warn($unmatchedNote);
}
return self::SUCCESS;
} catch (\Throwable $e) {
DB::table('phone_ranges_imports')->where('id', $importId)->update([
'status' => 'failed',
'error' => mb_substr($e->getMessage(), 0, 2000),
'completed_at' => now(),
]);
$this->error('Импорт упал: '.$e->getMessage());
return self::FAILURE;
}
}
/**
* @return list<string>|null Список файлов или null при ошибке валидации опций.
*/
private function resolveFiles(): ?array
{
$file = $this->option('file');
$dir = $this->option('dir');
$url = $this->option('url');
if ($url !== null) {
$this->error('--url отложен (пакет ~500-600 файлов). Скачайте вручную и используйте --dir.');
return null;
}
if ($file !== null) {
if (! is_file($file)) {
$this->error("Файл не найден: {$file}");
return null;
}
return [$file];
}
if ($dir !== null) {
if (! is_dir($dir)) {
$this->error("Каталог не найден: {$dir}");
return null;
}
$found = glob(rtrim($dir, '/\\').DIRECTORY_SEPARATOR.'*.{csv,xlsx}', GLOB_BRACE) ?: [];
if ($found === []) {
$this->error("В каталоге нет *.csv / *.xlsx: {$dir}");
return null;
}
sort($found);
return array_values($found);
}
$this->error('Укажите --file=<путь> или --dir=<каталог>.');
return null;
}
/**
* @param list<string> $files
*/
private function computeChecksum(array $files): string
{
if (count($files) === 1) {
return (string) hash_file('sha256', $files[0]);
}
$hashes = array_map(static fn (string $f): string => (string) hash_file('sha256', $f), $files);
sort($hashes);
return hash('sha256', implode('|', $hashes));
}
/**
* @param list<string> $files
*/
private function sourceLabel(array $files): string
{
return $this->option('url')
?? $this->option('dir')
?? ($files[0] ?? 'unknown');
}
/**
* Парсит один файл реестра в нормализованные строки.
*
* @return list<array{def_code:int, from_num:int, to_num:int, operator:string, region:string}>
*/
private function parseFile(string $path): array
{
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
return $ext === 'xlsx'
? $this->parseXlsx($path)
: $this->parseCsv($path);
}
/**
* @return list<array{def_code:int, from_num:int, to_num:int, operator:string, region:string}>
*/
private function parseCsv(string $path): array
{
$content = (string) file_get_contents($path);
// BOM strip + split строк (CRLF/CR/LF).
$content = preg_replace('/^\xEF\xBB\xBF/', '', $content) ?? $content;
$lines = preg_split('/\r\n|\r|\n/', rtrim($content)) ?: [];
if ($lines === []) {
return [];
}
$header = str_getcsv((string) array_shift($lines), ';');
$cols = $this->resolveColumns($header);
$out = [];
foreach ($lines as $line) {
if (trim($line) === '') {
continue;
}
$cells = str_getcsv($line, ';');
$rec = $this->mapCells($cells, $cols);
if ($rec !== null) {
$out[] = $rec;
}
}
return $out;
}
/**
* Парсинг XLSX через openspout (operator-real-files; CSV-ветка покрыта тестом).
*
* @return list<array{def_code:int, from_num:int, to_num:int, operator:string, region:string}>
*/
private function parseXlsx(string $path): array
{
$reader = new XlsxReader;
$reader->open($path);
$out = [];
$cols = null;
foreach ($reader->getSheetIterator() as $sheet) {
foreach ($sheet->getRowIterator() as $row) {
$cells = array_map(static fn ($c): string => (string) $c, $row->toArray());
if ($cols === null) {
$cols = $this->resolveColumns($cells);
continue;
}
$rec = $this->mapCells($cells, $cols);
if ($rec !== null) {
$out[] = $rec;
}
}
break; // только первый лист
}
$reader->close();
return $out;
}
/**
* Сопоставляет индексы колонок по заголовку (русские имена Россвязи) с позиционным fallback.
*
* @param list<string> $header
* @return array{def:int, from:int, to:int, operator:int, region:int}
*/
private function resolveColumns(array $header): array
{
$cols = ['def' => 0, 'from' => 1, 'to' => 2, 'operator' => 4, 'region' => 5];
foreach ($header as $i => $cell) {
$n = preg_replace('/[\s\/]+/u', '', mb_strtolower(trim((string) $cell))) ?? '';
if (str_contains($n, 'def') || str_contains($n, 'авс')) {
$cols['def'] = $i;
} elseif ($n === 'от') {
$cols['from'] = $i;
} elseif ($n === 'до') {
$cols['to'] = $i;
} elseif (str_contains($n, 'оператор')) {
$cols['operator'] = $i;
} elseif (str_contains($n, 'регион')) {
$cols['region'] = $i;
}
}
return $cols;
}
/**
* @param list<string> $cells
* @param array{def:int, from:int, to:int, operator:int, region:int} $cols
* @return array{def_code:int, from_num:int, to_num:int, operator:string, region:string}|null
*/
private function mapCells(array $cells, array $cols): ?array
{
$def = (int) preg_replace('/\D+/', '', $cells[$cols['def']] ?? '');
if ($def === 0) {
return null; // пустая/битая строка
}
return [
'def_code' => $def,
'from_num' => (int) preg_replace('/\D+/', '', $cells[$cols['from']] ?? '0'),
'to_num' => (int) preg_replace('/\D+/', '', $cells[$cols['to']] ?? '0'),
'operator' => trim((string) ($cells[$cols['operator']] ?? '')),
'region' => trim((string) ($cells[$cols['region']] ?? '')),
];
}
/**
* Собирает phone_ranges_staging (LIKE phone_ranges) и заливает строки.
*
* id: НЕ копируем серийный default через INCLUDING DEFAULTS он ссылается на
* исходную последовательность phone_ranges, которую atomic-swap уничтожает
* (DROP phone_ranges_old CASCADE) после первого импорта, оставляя staging.id
* без default (NOT NULL violation на повторном импорте). Вместо этого даём
* staging собственную последовательность с уникальным по import_id именем,
* OWNED BY колонкой id она переезжает при RENAME и дропается вместе со
* старой таблицей (без коллизий имён и без утечки последовательностей).
*
* @param list<array<string, mixed>> $rows
*/
private function buildStaging(array $rows, int $importId): void
{
$c = DB::connection(self::DDL_CONNECTION);
$this->elevate($c);
$seq = "phone_ranges_stg_seq_{$importId}";
$c->statement('DROP TABLE IF EXISTS phone_ranges_staging CASCADE');
$c->statement('CREATE TABLE phone_ranges_staging (LIKE phone_ranges INCLUDING CONSTRAINTS)');
$c->statement("CREATE SEQUENCE {$seq}");
$c->statement("ALTER TABLE phone_ranges_staging ALTER COLUMN id SET DEFAULT nextval('{$seq}')");
$c->statement("ALTER SEQUENCE {$seq} OWNED BY phone_ranges_staging.id");
$c->statement('CREATE INDEX IF NOT EXISTS idx_phone_ranges_staging_lookup ON phone_ranges_staging (def_code, from_num, to_num)');
foreach (array_chunk($rows, self::INSERT_CHUNK) as $chunk) {
$c->table('phone_ranges_staging')->insert($chunk);
}
}
/**
* Atomic swap живого phone_ranges на staging (spec §6.2 шаг 6).
*
* NB: НЕ покрыт автотестом (committing RENAME сломал бы общую тестовую БД).
* Проверяется первым реальным импортом оператора (Session 6 runbook).
* Сохраняет одну предыдущую версию (phone_ranges_old) для `phone-ranges:rollback`.
* GRANT'ы переустанавливаются (RENAME их не переносит); lookup-индекс на новой
* таблице носит имя idx_phone_ranges_staging_lookup (косметика имя занято _old).
*/
private function atomicSwap(): void
{
$c = DB::connection(self::DDL_CONNECTION);
$this->elevate($c);
// Транзакция вокруг свапа (spec §6.2): PostgreSQL поддерживает транзакционный
// DDL, поэтому DROP+RENAME+RENAME+GRANT атомарны. Обрыв процесса между
// переименованиями не оставит phone_ranges несуществующей — откат вернёт
// живую таблицу (раньше 4 авто-коммит-statement'а оставляли окно, в котором
// Россвязь-lookup падал бы до ручного восстановления).
$c->transaction(function () use ($c) {
$c->statement('DROP TABLE IF EXISTS phone_ranges_old CASCADE');
$c->statement('ALTER TABLE phone_ranges RENAME TO phone_ranges_old');
$c->statement('ALTER TABLE phone_ranges_staging RENAME TO phone_ranges');
$c->statement('GRANT SELECT ON phone_ranges TO crm_app_user, crm_supplier_worker');
});
}
/**
* SET ROLE crm_migrator для корректного ownership на проде; на dev/test роль
* отсутствует RESET и работаем как superuser (зеркало миграционного паттерна).
*/
private function elevate(Connection $c): void
{
try {
$c->statement('SET ROLE crm_migrator');
$canCreate = $c->selectOne("SELECT has_schema_privilege('crm_migrator', 'public', 'CREATE') AS ok");
if (! $canCreate || ! $canCreate->ok) {
$c->statement('RESET ROLE');
}
} catch (\Throwable) {
// окружение без роли — продолжаем как superuser
}
}
}
@@ -1,78 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\SupplierLead;
use App\Services\LeadRegionResolver;
use App\Support\RussianRegions;
use Illuminate\Console\Command;
/**
* Staging-smoke резолва региона по телефону (spec §9.4): дёргает живой каскад
* DaData Россвязь tag и печатает решение. В БД ничего НЕ пишет.
*
* php artisan phone-region:smoke --phone=79161234567 [--tag=Москва]
*
* Принудительно включает services.dadata.enabled на время прогона (smoke всегда
* проверяет полный каскад, независимо от глобального feature-flag). С реальным
* DADATA_API_KEY делает платный вызов запускать осознанно.
*/
class PhoneRegionSmokeCommand extends Command
{
/** @var string */
protected $signature = 'phone-region:smoke
{--phone= : Телефон в формате 7XXXXXXXXXX}
{--tag= : Регион-тег поставщика (fallback-слой)}';
/** @var string */
protected $description = 'Прогон резолва региона по телефону (DaData→Россвязь→tag) без записи в БД (staging-smoke)';
public function handle(LeadRegionResolver $resolver): int
{
$phone = (string) $this->option('phone');
if ($phone === '') {
$this->error('Укажите --phone=7XXXXXXXXXX');
return self::FAILURE;
}
// Smoke всегда прогоняет полный каскад, даже если глобальный флаг выключен.
config(['services.dadata.enabled' => true]);
$lead = new SupplierLead([
'phone' => $phone,
'raw_payload' => ['tag' => (string) $this->option('tag')],
]);
$r = $resolver->resolve($lead);
$region = $r->subjectCode !== null
? (RussianRegions::CODE_TO_NAME[$r->subjectCode] ?? '?')
: '—';
$this->info('Телефон: '.$this->maskPhone($phone));
$this->line('Источник: '.$r->source);
$this->line('Субъект: '.($r->subjectCode ?? '—').' ('.$region.')');
$this->line('Оператор: '.($r->phoneOperator ?? '—'));
$this->line('DaData qc: '.($r->qc ?? '—'));
$this->line('Cache hit: '.($r->cacheHit ? 'да' : 'нет'));
$this->line('Россвязь: '.($r->rossvyazMatched ? 'совпала' : 'нет'));
$this->line('Длит., мс: '.($r->durationMs ?? '—'));
$this->newLine();
$this->comment('NB: запись в БД НЕ выполнялась (smoke).');
return self::SUCCESS;
}
private function maskPhone(string $phone): string
{
$digits = preg_replace('/\D+/', '', $phone) ?? '';
if (strlen($digits) < 8) {
return '***';
}
return substr($digits, 0, 4).'***'.substr($digits, -4);
}
}
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Reminder;
use App\Services\NotificationService;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* Cron-команда диспатча due-reminders.
*
* Идёт по `reminders` где `is_sent=false AND completed_at IS NULL AND
* remind_at <= NOW()`. Для каждой строки:
* 1) NotificationService::notifyReminder (email + inapp по prefs);
* 2) UPDATE is_sent=true, sent_at=NOW().
*
* RLS: SET LOCAL app.current_tenant_id = reminder.tenant_id внутри
* транзакции каждой обработки (по одному reminder в транзакции иначе
* нельзя переключить tenant между строками с разных tenant'ов).
*
* Запускается каждую минуту через Windows Task Scheduler / cron.
* Идемпотентна: повторный вызов на отправленных ($is_sent=true) skipаются.
*
* --dry-run печатает плановых получателей без реальных INSERT'ов.
*
* Источник: db/schema.sql §17.5; ТЗ §6.6 / §18.5.
*/
class RemindersDispatchDue extends Command
{
/** @var string */
protected $signature = 'reminders:dispatch-due
{--dry-run : Не отправлять, только напечатать список плановых получателей}
{--limit=500 : Максимум reminders за один запуск}';
/** @var string */
protected $description = 'Диспатч due-reminders: email/inapp уведомления + is_sent=true (ТЗ §18.5)';
public function handle(NotificationService $service): int
{
$dryRun = (bool) $this->option('dry-run');
$limit = max(1, (int) $this->option('limit'));
$now = Carbon::now();
// Cross-tenant gather via BYPASSRLS connection — on prod crm_app_user cannot
// call current_setting('app.current_tenant_id') without a GUC set first.
// pgsql_supplier (crm_supplier_worker, BYPASSRLS) is the canonical pattern
// for SaaS-admin cron queries (precedent: IncidentsWatchFailures, Reset*).
$rows = DB::connection('pgsql_supplier')
->table('reminders')
->select(['id', 'tenant_id', 'deal_id', 'remind_at'])
->where('is_sent', false)
->whereNull('completed_at')
->where('remind_at', '<=', $now)
->orderBy('remind_at')
->limit($limit)
->get();
if ($rows->isEmpty()) {
$this->info('Нет due-reminders.');
return self::SUCCESS;
}
$sent = 0;
$failed = 0;
foreach ($rows as $row) {
if ($dryRun) {
$this->line(sprintf(
' would dispatch <fg=yellow>id=%d</> tenant=%d deal=%d remind_at=%s',
$row->id,
$row->tenant_id,
$row->deal_id,
$row->remind_at ?? '-',
));
continue;
}
try {
DB::transaction(function () use ($row, $service): void {
// SET LOCAL scopes GUC to this transaction — PgBouncer-safe.
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $row->tenant_id);
// Fetch the full Eloquent model with tenant context active so
// relations (user, etc.) work correctly inside NotificationService.
$reminder = Reminder::query()->findOrFail((int) $row->id);
$service->notifyReminder($reminder);
$reminder->update([
'is_sent' => true,
'sent_at' => Carbon::now(),
]);
});
$sent++;
$this->info(" dispatched <fg=green>id={$row->id}</>");
} catch (\Throwable $e) {
$failed++;
$this->error(" failed <fg=red>id={$row->id}</>: {$e->getMessage()}");
}
}
$this->newLine();
$this->info("Done: {$sent} sent, {$failed} failed (limit={$limit}, dry-run=".($dryRun ? '1' : '0').').');
return self::SUCCESS;
}
}
@@ -28,7 +28,7 @@ final class SnapshotBackfillCommand extends Command
$weekdayBit = 1 << ($date->isoWeekday() - 1);
$count = DB::connection('pgsql_supplier')->transaction(function () use ($dateStr, $weekdayBit) {
return DB::connection('pgsql_supplier')->insert(<<<'SQL'
return DB::connection('pgsql_supplier')->insert(<<<SQL
INSERT INTO project_routing_snapshots (
snapshot_date, project_id, tenant_id,
daily_limit, delivery_days_mask, regions,
@@ -47,7 +47,7 @@ final class SnapshotRebuildCommand extends Command
->where('snapshot_date', $dateStr)
->delete();
$inserted = DB::connection('pgsql_supplier')->insert(<<<'SQL'
$inserted = DB::connection('pgsql_supplier')->insert(<<<SQL
INSERT INTO project_routing_snapshots (
snapshot_date, project_id, tenant_id,
daily_limit, delivery_days_mask, regions,
@@ -98,8 +98,6 @@ class VerifyAuditChains extends Command
$partitions = [$table];
}
$tableHadBreach = false;
foreach ($partitions as $partitionName) {
$breaches = $this->checkPartition($partitionName, $table, $config['partition']);
@@ -110,7 +108,6 @@ class VerifyAuditChains extends Command
}
$anyBreach = true;
$tableHadBreach = true;
$firstId = $breaches[0]->id;
$count = count($breaches);
@@ -125,18 +122,6 @@ class VerifyAuditChains extends Command
$this->sendAlert($table, $partitionName, $firstId, $count);
}
// Auto-resolve: a table whose chain is intact across ALL partitions
// closes any stale open chain incident left by a previous transient
// breach (e.g. acceptance test-tenant rows since removed by teardown).
// Best-effort: never let cleanup break the command or its exit code.
if (! $tableHadBreach) {
try {
$this->resolveOpenIncidents($table, $now);
} catch (\Throwable $e) {
$this->warn(" Incident auto-resolve failed for {$table}: {$e->getMessage()}");
}
}
}
// Exit FAILURE on ANY breach regardless of incident-write success.
@@ -296,38 +281,6 @@ class VerifyAuditChains extends Command
$this->warn(" Incident recorded for {$partitionName} (first broken id={$firstBrokenId})");
}
/**
* Авто-закрытие устаревших открытых инцидентов разрыва цепочки для таблицы,
* чья цепочка снова целостна во всех партициях.
*
* Закрывает класс «вечно-открытых» high-инцидентов после транзиентного
* разрыва (строки удалены/исправлены вне прогона напр. строки тест-тенантов
* приёмки, убранные teardown): без этого verify-chains накапливал бы открытые
* инциденты и слал бы по ним алёрты после истечения дедупа.
*
* Матчинг summary тот же per-table шаблон, что в recordIncident()
* (дедупликация и закрытие симметричны). Вызывается только когда таблица
* чиста во ВСЕХ партициях (guard $tableHadBreach в handle()).
*/
private function resolveOpenIncidents(string $table, Carbon $now): void
{
$resolved = DB::connection(self::DB_CONNECTION)
->table('incidents_log')
->where('type', 'other')
->where('severity', 'high')
->where('summary', 'like', '%chain%'.addcslashes($table, '%_\\').'%')
->whereNull('resolved_at')
->update([
'resolved_at' => $now,
'updated_at' => $now,
'root_cause' => "Автоматически закрыт: audit:verify-chains подтвердил целостность hash-chain таблицы {$table}.",
]);
if ($resolved > 0) {
$this->info("{$table}: auto-resolved {$resolved} stale chain incident(s).");
}
}
/**
* Отправляет email-алёрт на monitoring email.
*/
@@ -1,172 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Concerns\WritesAuthLog;
use App\Http\Controllers\Controller;
use App\Http\Requests\Account\ChangePasswordRequest;
use App\Models\User;
use App\Services\UserSessionTracker;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
/**
* Аккаунт пользователя вкладка «Безопасность» (UI-аудит 21.06.2026).
*
* Заменяет статичные mock-карточки (ChangePasswordCard/SessionsTable):
* - POST /api/account/change-password реальная смена пароля.
* - GET /api/account/security дата последней смены пароля + активные сессии.
* - DELETE /api/account/sessions/{id} отозвать сессию (UI-аудит 21.06.2026).
*
* Активные сессии берутся из user_sessions (запись при входе); отзыв реально
* убивает сессию (удаление из Redis по session_id). Заменяет прежний mock.
*/
class AccountController extends Controller
{
use WritesAuthLog;
/**
* POST /api/account/change-password смена пароля авторизованным пользователем.
*
* Проверяет текущий пароль (Hash::check против password_hash), пишет новый хэш,
* логирует password_changed в auth_log. На неверном текущем 422 + лог
* password_change_failed.
*/
public function changePassword(ChangePasswordRequest $request): JsonResponse
{
/** @var User $user */
$user = $request->user();
if (! Hash::check($request->string('current_password')->toString(), (string) $user->password_hash)) {
$this->logAuthEvent(
'password_change_failed',
$user->id,
$user->tenant_id,
$user->email,
$request->ip(),
$request->userAgent(),
'wrong_current_password',
);
throw ValidationException::withMessages([
'current_password' => ['Неверный текущий пароль.'],
]);
}
$user->forceFill([
'password_hash' => Hash::make($request->string('password')->toString()),
])->save();
$this->logAuthEvent(
'password_changed',
$user->id,
$user->tenant_id,
$user->email,
$request->ip(),
$request->userAgent(),
null,
);
return response()->json([
'message' => 'Пароль изменён.',
'last_password_change_at' => now()->toIso8601String(),
]);
}
/**
* GET /api/account/security данные вкладки «Безопасность».
*
* last_password_change_at max(created_at) по password-событиям в auth_log
* (null, если пароль ни разу не менялся через портал).
* recent_logins последние входы текущего пользователя (устройство/IP/время).
*/
public function security(Request $request): JsonResponse
{
/** @var User $user */
$user = $request->user();
$lastChange = DB::table('auth_log')
->where('user_id', $user->id)
->whereIn('event', ['password_changed', 'password_reset_completed'])
->max('created_at');
$currentSid = $request->session()->getId();
$rows = DB::table('user_sessions')
->where('user_id', $user->id)
->where('expires_at', '>', now())
->orderByDesc('created_at')
->limit(20)
->get(['id', 'token_hash', 'ip_address', 'user_agent', 'last_active_at', 'created_at']);
$sessions = $rows->map(fn ($row): array => [
'id' => $row->id,
'device' => $this->deviceLabel($row->user_agent),
'ip' => $row->ip_address,
'at' => Carbon::parse($row->last_active_at ?? $row->created_at)->toIso8601String(),
'current' => $row->token_hash === $currentSid,
])->all();
return response()->json([
'last_password_change_at' => $lastChange ? Carbon::parse($lastChange)->toIso8601String() : null,
'sessions' => $sessions,
]);
}
/** DELETE /api/account/sessions/{id} — отозвать конкретную сессию пользователя. */
public function revokeSession(Request $request, int $id): JsonResponse
{
/** @var User $user */
$user = $request->user();
$ok = app(UserSessionTracker::class)->revoke($user->id, $id);
if (! $ok) {
return response()->json(['message' => 'Сессия не найдена.'], 404);
}
$this->logAuthEvent(
'session_revoked',
$user->id,
$user->tenant_id,
$user->email,
$request->ip(),
$request->userAgent(),
null,
);
return response()->json(['message' => 'Сессия завершена.']);
}
/** Грубый человекочитаемый ярлык устройства из User-Agent (браузер + ОС). */
private function deviceLabel(?string $ua): string
{
if ($ua === null || $ua === '') {
return 'Неизвестное устройство';
}
$browser = match (true) {
str_contains($ua, 'Firefox/') => 'Firefox',
str_contains($ua, 'Edg/') => 'Edge',
str_contains($ua, 'OPR/') || str_contains($ua, 'Opera') => 'Opera',
str_contains($ua, 'Chrome/') => 'Chrome',
str_contains($ua, 'Safari/') => 'Safari',
default => 'Браузер',
};
$os = match (true) {
str_contains($ua, 'Windows') => 'Windows',
str_contains($ua, 'Android') => 'Android',
str_contains($ua, 'iPhone') || str_contains($ua, 'iPad') => 'iOS',
str_contains($ua, 'Mac OS') || str_contains($ua, 'Macintosh') => 'macOS',
str_contains($ua, 'Linux') => 'Linux',
default => '',
};
return $os !== '' ? "{$browser}, {$os}" : $browser;
}
}
@@ -1,57 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\LegalEntity;
use App\Models\PaymentGateway;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
/**
* SaaS-admin: ввод секретных ключей платёжного шлюза.
*
* config хранится Crypt::encrypt ключи не попадают в БД в открытом виде и
* не возвращаются обратно клиенту. На MVP без auth-middleware (как остальные
* /api/admin/* эндпоинты); production middleware('auth:saas-admin').
*/
class AdminPaymentGatewayController extends Controller
{
public function update(Request $request, string $code): JsonResponse
{
$validated = $request->validate([
'shop_id' => ['required', 'string', 'max:255'],
'secret_key' => ['required', 'string', 'max:255'],
'is_active' => ['required', 'boolean'],
'legal_entity_id' => ['nullable', 'integer', 'exists:legal_entities,id'],
]);
$gw = PaymentGateway::firstOrNew(['code' => $code]);
// legal_entity_id обязателен (NOT NULL FK). Берём из запроса, иначе первое юрлицо.
if ($gw->legal_entity_id === null) {
$legalEntityId = $validated['legal_entity_id'] ?? LegalEntity::query()->min('id');
if ($legalEntityId === null) {
return response()->json([
'message' => 'Сначала заведите юридическое лицо (реквизиты получателя платежей).',
], 422);
}
$gw->legal_entity_id = (int) $legalEntityId;
}
$gw->name ??= 'ЮKassa';
$gw->driver ??= $code;
$gw->config = Crypt::encrypt([
'shop_id' => $validated['shop_id'],
'secret_key' => $validated['secret_key'],
]);
$gw->is_active = $validated['is_active'];
$gw->min_amount_rub ??= '100.00';
$gw->save();
return response()->json(['status' => 'ok', 'code' => $gw->code, 'is_active' => $gw->is_active]);
}
}
@@ -67,7 +67,7 @@ final class AdminPricingTiersController extends Controller
'tiers.*.tier_no' => ['required', 'integer', 'between:1,7'],
'tiers.*.leads_in_tier' => ['nullable', 'integer', 'min:1'],
'tiers.*.price_rub' => ['required', 'string', 'regex:/^\d+(\.\d{1,2})?$/'],
'effective_from' => ['sometimes', 'date_format:Y-m-d', 'after_or_equal:'.$todayMsk],
'effective_from' => ['sometimes', 'date_format:Y-m-d', 'after:'.$todayMsk],
]);
/** @var array<int, array{tier_no:int, leads_in_tier:?int, price_rub:string|float}> $tiers */
@@ -163,13 +163,6 @@ final class AdminPricingTiersController extends Controller
*/
private function resolveAdminUserId(Request $request): int
{
// Прод: crm_app_user не имеет прав на saas_admin_users → берём системный
// admin-id из конфига, не обращаясь к таблице. null (dev/test) → fallback ниже.
$configured = config('admin.audit_system_user_id');
if ($configured !== null) {
return (int) $configured;
}
$requested = $request->input('admin_user_id');
if (is_int($requested) || (is_string($requested) && ctype_digit($requested))) {
$existing = DB::table('saas_admin_users')->where('id', (int) $requested)->value('id');
@@ -70,33 +70,6 @@ final class AdminSupplierIntegrationController extends Controller
return response()->json(['dispatched' => true]);
}
/**
* Эпик 5: история вечерних заливок проектов поставщику (supplier_sync_runs).
* SaaS-admin сверяет глазами, что заливка прошла ровно от этого зависят
* заказанные у поставщика лиды.
*/
public function syncRuns(): JsonResponse
{
$rows = DB::connection('pgsql_supplier')
->table('supplier_sync_runs')
->orderByDesc('id')
->limit(self::HISTORY_LIMIT)
->get();
return response()->json([
'runs' => $rows->map(fn ($r): array => [
'started_at' => $r->started_at,
'finished_at' => $r->finished_at,
'groups_total' => (int) $r->groups_total,
'synced_ok' => (int) $r->synced_ok,
'manual_queued' => (int) $r->manual_queued,
'deferred' => (int) $r->deferred,
'failed' => (int) $r->failed,
'status' => $r->status,
])->all(),
]);
}
/**
* Очередь яруса 3 резерва канала миграции проектов pending-список для
* оператора админ-экрана. Spec §4.6.
+36 -37
View File
@@ -7,12 +7,11 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Concerns\WritesAuthLog;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use App\Http\Requests\Auth\RegisterRequest;
use App\Mail\SuspiciousLoginNotification;
use App\Models\ImpersonationToken;
use App\Models\Tenant;
use App\Models\User;
use App\Services\NotificationService;
use App\Services\UserSessionTracker;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@@ -94,23 +93,6 @@ class AuthController extends Controller
if (! $user->is_active) {
RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS);
// Косяк 05: неподтверждённая почта — это НЕ блокировка. Новый клиент
// создаётся is_active=false до ввода кода из письма. Не пугаем
// «Аккаунт заблокирован», а зовём подтвердить почту.
if ($user->email_verified_at === null) {
$this->logAuthEvent('login_failed', $user->id, $user->tenant_id, $credentials['email'], $ip, $request->userAgent(),
'email_not_confirmed');
$msg = 'Подтвердите почту — мы отправили код на '.$user->email.'.';
return response()->json([
'message' => $msg,
'errors' => ['email' => [$msg]],
'email_not_confirmed' => true,
], 422);
}
$this->logAuthEvent('login_failed', $user->id, $user->tenant_id, $credentials['email'], $ip, $request->userAgent(),
'account_locked');
@@ -142,7 +124,6 @@ class AuthController extends Controller
$user->update(['last_login_at' => now()]);
$this->logAuthEvent('login_success', $user->id, $user->tenant_id, $user->email, $ip, $request->userAgent(), null);
app(UserSessionTracker::class)->record($request, $user->id);
return response()->json([
'user' => $this->userResource($user),
@@ -150,25 +131,46 @@ class AuthController extends Controller
]);
}
public function register(RegisterRequest $request): JsonResponse
{
// На MVP — attach нового user'а к первому tenant'у (для UI-разводки).
// Production: wizard с tenant_name + ИНН + создание Tenant + первый user owner-роли.
$tenant = Tenant::first();
if (! $tenant) {
return response()->json([
'message' => 'Tenants не настроены. Обратитесь к администратору.',
], 503);
}
$user = User::create([
'tenant_id' => $tenant->id,
'email' => $request->string('email')->toString(),
'password_hash' => Hash::make($request->string('password')->toString()),
'first_name' => 'Новый',
'last_name' => 'Пользователь',
'is_active' => true,
'totp_enabled' => false,
]);
Auth::login($user);
$request->session()->regenerate();
$this->logAuthEvent('register_success', $user->id, $user->tenant_id, $user->email, $request->ip(), $request->userAgent(), null);
return response()->json([
'user' => $this->userResource($user),
'requires_2fa' => false,
], 201);
}
public function me(Request $request): JsonResponse
{
/** @var User $user */
$user = $request->user();
$resource = $this->userResource($user);
$marker = $request->hasSession() ? $request->session()->get('impersonation') : null;
if ($marker !== null) {
$token = ImpersonationToken::on('pgsql_supplier')->find($marker['token_id']);
$tenant = $token?->tenant;
$resource['impersonation'] = [
'active' => true,
'tenant_name' => $tenant?->organization_name,
'started_at' => $marker['started_at'] ?? null,
'expires_at' => $token?->sessionExpiresAt()?->toIso8601String(),
];
}
return response()->json(['user' => $resource]);
return response()->json([
'user' => $this->userResource($user),
]);
}
public function logout(Request $request): JsonResponse
@@ -177,9 +179,6 @@ class AuthController extends Controller
$tenantId = $request->user()?->tenant_id;
$email = $request->user()?->email;
// Снять текущую сессию из списка «Активные» до инвалидации (id ещё прежний).
app(UserSessionTracker::class)->revokeCurrent($request);
Auth::guard('web')->logout();
$request->session()->invalidate();
@@ -13,10 +13,6 @@ use App\Models\User;
use App\Repositories\PricingTierRepository;
use App\Services\Billing\BalanceToLeadsConverter;
use App\Services\Billing\BillingTopupService;
use App\Services\Billing\Gateway\PaymentGatewayManager;
use App\Services\Billing\OnlineTopupService;
use App\Services\Billing\RunwayCalculator;
use App\Support\SystemSettings;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -42,9 +38,8 @@ class BillingController extends Controller
/**
* POST /api/billing/topup пополнить рублёвый баланс.
*
* Развилка: если флаг billing_yookassa_enabled ВКЛ создаём платёж через
* шлюз и возвращаем confirmation_url (баланс не меняется до webhook).
* Если ВЫКЛ MVP-stub мгновенного зачисления (текущее прод-поведение до Б-1).
* MVP-stub: кредитует баланс немедленно (без ЮKassa реальная оплата
* post-Б-1). Записывает append-only строку balance_transactions(topup).
*/
public function topup(Request $request): JsonResponse
{
@@ -54,25 +49,10 @@ class BillingController extends Controller
/** @var User $user */
$user = $request->user();
// Нормализуем в DECIMAL-строку scale 2 для bcmath (НЕ float).
$amountRub = bcadd((string) $validated['amount_rub'], '0', 2);
// Развилка: реальный шлюз (флаг ВКЛ) ИЛИ мгновенная заглушка (флаг ВЫКЛ).
if (SystemSettings::bool('billing_yookassa_enabled')) {
$manager = app(PaymentGatewayManager::class);
$gateway = $manager->activeGateway();
if ($gateway === null) {
return response()->json(['message' => 'Платёжный шлюз не настроен.'], 503);
}
$returnUrl = rtrim((string) config('app.url'), '/').'/billing?topup=return';
$result = app(OnlineTopupService::class)->start(
(int) $user->tenant_id, $amountRub, $gateway, $returnUrl, (int) $user->id
);
return response()->json(['confirmation_url' => $result->confirmationUrl], 201);
}
// Заглушка (текущее прод-поведение до Б-1): мгновенное зачисление.
$tx = $this->topupService->topup((int) $user->tenant_id, $amountRub, (int) $user->id);
return response()->json([
@@ -336,8 +316,21 @@ class BillingController extends Controller
*/
private function runwayDays(Tenant $tenant, int $affordableLeads): ?int
{
// F3 (17.06.2026): единый источник расчёта — RunwayCalculator (общий с дашбордом),
// чтобы прогноз «хватит на дни» не расходился между биллингом и дашбордом.
return app(RunwayCalculator::class)->daysLeft((int) $tenant->id, $affordableLeads);
if ($affordableLeads <= 0) {
return 0;
}
$leadsLast30Days = (int) DB::table('lead_charges')
->where('tenant_id', $tenant->id)
->where('charged_at', '>=', now()->subDays(30))
->count();
if ($leadsLast30Days <= 0) {
return null;
}
$avgPerDay = $leadsLast30Days / 30.0;
return max(0, (int) floor($affordableLeads / $avgPerDay));
}
}
@@ -6,10 +6,6 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Tenant;
use App\Repositories\PricingTierRepository;
use App\Services\Billing\BalanceToLeadsConverter;
use App\Services\Billing\RunwayCalculator;
use Carbon\Carbon;
use Carbon\CarbonImmutable;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -107,35 +103,13 @@ class DashboardController extends Controller
->map(fn ($c) => (int) $c)
->toArray();
// --- runway (F3, 17.06.2026: единый источник с биллингом) ---
// Раньше дашборд считал от legacy `balance_leads` (после Billing v2 ≈0
// для рублёвых тенантов) → расходился с биллингом «0 дней ↔ N дней».
// Теперь — affordable leads от рублёвого баланса по тарифу
// (BalanceToLeadsConverter) + общий RunwayCalculator.
$activeTiers = app(PricingTierRepository::class)
->activeAt(Carbon::now('Europe/Moscow'));
$conversion = app(BalanceToLeadsConverter::class)->convert(
(string) $tenant->balance_rub,
(int) ($tenant->delivered_in_month ?? 0),
$activeTiers,
);
$affordableLeads = (int) $conversion['leads'];
// B1-2 (UX-аудит 25.06): null (нет активных проектов) НЕ приводим к 0 —
// иначе дашборд врал «хватит на 0 дней» при полном балансе, расходясь с
// биллингом «∞». null → фронт показывает «нет активных проектов».
$runwayDays = app(RunwayCalculator::class)
->daysLeft($tenantId, $affordableLeads);
// --- средняя стоимость лида (F5): среднее фактически списанных rub-сумм
// за окно периода. Только charge_source='rub' (у prepaid цена 0 по CHECK —
// иначе среднее занижается); источник тот же, что у карточки сделки (F2).
// null, если в окне нет rub-списаний (ничего ещё не списано).
$avgKopecks = DB::table('lead_charges')
->where('tenant_id', $tenantId)
->where('charge_source', 'rub')
->whereBetween('charged_at', [$windowStart, $now])
->avg('price_per_lead_kopecks');
$avgLeadCostRub = $avgKopecks !== null ? round((float) $avgKopecks / 100, 2) : null;
// --- runway ---
// runway опирается на приток за фиксированное 7-дневное окно,
// независимо от выбранного range (для today/30d $curLeads — не 7-дневный).
$leads7d = (clone $base())->whereBetween('received_at', [$now->subDays(7), $now])->count();
$avgDaily = $leads7d / 7.0;
$balanceLeads = (int) ($tenant->balance_leads ?? 0);
$runwayDays = $avgDaily > 0 ? (int) floor($balanceLeads / $avgDaily) : 0;
return [
'range' => $range,
@@ -145,11 +119,10 @@ class DashboardController extends Controller
'balance' => [
'amount_rub' => (string) $tenant->balance_rub,
'runway_days' => $runwayDays,
'runway_leads' => $affordableLeads,
'runway_leads' => $balanceLeads,
],
'activity' => ['points' => $points, 'labels' => $labels, 'max' => $axisMax],
'funnel' => (object) $funnel,
'avg_lead_cost_rub' => $avgLeadCostRub,
];
});
+13 -32
View File
@@ -7,7 +7,6 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\ActivityLog;
use App\Models\Deal;
use App\Models\LeadCharge;
use App\Models\Project;
use App\Models\SupplierLeadCost;
use App\Models\User;
@@ -103,6 +102,13 @@ class DealController extends Controller
// whereNotNull('deleted_at') фильтрует только удалённые.
$query = Deal::query()
->select('deals.*')
->addSelect(['next_reminder_at' => DB::table('reminders')
->select('remind_at')
->whereColumn('reminders.deal_id', 'deals.id')
->whereNull('reminders.completed_at')
->orderBy('remind_at')
->limit(1),
])
->where('tenant_id', $tenantId)
->with(['project:id,name,signal_type,signal_identifier,sms_keyword,sms_senders', 'manager:id,email,first_name,last_name']);
@@ -188,24 +194,6 @@ class DealController extends Controller
return response()->json(['total' => $total]);
}
// U4 (UX-аудит 25.06): стоимость лида должна быть видна и в листинге
// (панель деталей и канбан рисуют из строки листинга, show отдельно не
// дозапрашивают). Запрос — в своей транзакции с app.current_tenant_id,
// иначе RLS на lead_charges вернёт 0 строк (как в show). rub-провенанс.
$costByDeal = collect();
$dealIds = $deals->pluck('id')->all();
if ($dealIds !== []) {
$costByDeal = DB::transaction(function () use ($tenantId, $dealIds) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
return LeadCharge::query()
->where('tenant_id', $tenantId)
->whereIn('deal_id', $dealIds)
->where('charge_source', 'rub')
->pluck('price_per_lead_kopecks', 'deal_id');
});
}
$payload = [
'deals' => $deals->map(fn (Deal $d) => [
'id' => $d->id,
@@ -229,7 +217,9 @@ class DealController extends Controller
'project_signal_identifier' => $d->project?->signal_identifier,
'project_sms_keyword' => $d->project?->sms_keyword,
'project_sms_senders' => $d->project?->sms_senders,
'cost_kopecks' => $costByDeal[$d->id] ?? null,
'next_reminder_at' => $d->next_reminder_at
? Carbon::parse($d->next_reminder_at)->toIso8601String()
: null,
]),
'limit' => $limit,
'next_cursor' => $nextCursor,
@@ -256,7 +246,7 @@ class DealController extends Controller
{
$tenantId = (int) $request->user()->tenant_id;
[$deal, $events, $charge] = DB::transaction(function () use ($tenantId, $id) {
[$deal, $events] = DB::transaction(function () use ($tenantId, $id) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
$deal = Deal::query()
@@ -266,7 +256,7 @@ class DealController extends Controller
->first();
if ($deal === null) {
return [null, [], null];
return [null, []];
}
$events = ActivityLog::query()
@@ -278,14 +268,7 @@ class DealController extends Controller
->limit(50)
->get();
// F2: реальная стоимость лида — снимок списания из lead_charges
// (rub-провенанс). Запрос в транзакции, где выставлен app.current_tenant_id.
$charge = LeadCharge::query()
->where('tenant_id', $tenantId)
->where('deal_id', $id)
->first();
return [$deal, $events, $charge];
return [$deal, $events];
});
if ($deal === null) {
@@ -326,8 +309,6 @@ class DealController extends Controller
'project_signal_identifier' => $deal->project?->signal_identifier,
'project_sms_keyword' => $deal->project?->sms_keyword,
'project_sms_senders' => $deal->project?->sms_senders,
// F2: стоимость лида = снимок rub-списания (копейки) или null (prepaid/не списано).
'cost_kopecks' => ($charge && $charge->charge_source === 'rub') ? $charge->price_per_lead_kopecks : null,
],
'events' => $events->map(fn (ActivityLog $e) => [
'id' => $e->id,
@@ -7,7 +7,6 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Deal;
use App\Services\Pd\PdAuditLogger;
use App\Support\CsvFormulaGuard;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
@@ -123,15 +122,12 @@ class DealExportController extends Controller
$signal = $deal->project?->signal_type;
$source = trim(($deal->project?->name ?? '—').' · '
.(self::SIGNAL_LABELS[$signal] ?? '—'));
// F-CSV: свободный текст (телефон/источник/город/статус/
// комментарий) экранируем от formula-инъекции. Дата —
// системная, не экранируется.
$writer->addRow(Row::fromValues([
CsvFormulaGuard::neutralize((string) $deal->phone),
CsvFormulaGuard::neutralize($source),
CsvFormulaGuard::neutralize((string) ($deal->city ?? '')),
CsvFormulaGuard::neutralize((string) ($statusNames[$deal->status] ?? $deal->status)),
CsvFormulaGuard::neutralize((string) ($deal->comment ?? '')),
(string) $deal->phone,
$source,
(string) ($deal->city ?? ''),
(string) ($statusNames[$deal->status] ?? $deal->status),
(string) ($deal->comment ?? ''),
$deal->received_at?->toDateTimeString() ?? '',
]));
}
@@ -5,19 +5,12 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Mail\ImpersonationCodeMail;
use App\Mail\ImpersonationEndedMail;
use App\Models\ImpersonationToken;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Pd\ImpersonationAuditService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
/**
* SaaS-admin impersonation flow (ТЗ §22.7 / Ю-1).
@@ -46,8 +39,6 @@ class ImpersonationController extends Controller
private const MAX_FAILED_ATTEMPTS = 5;
private const SESSION_TTL_MINUTES = 60;
/**
* SaaS-admin кросс-тенантная зона: запросы к impersonation_tokens / tenants
* идут через BYPASSRLS-подключение pgsql_supplier (роль crm_supplier_worker).
@@ -143,12 +134,7 @@ class ImpersonationController extends Controller
$audit->recordInit($token, adminId: $requestedBy, ip: $request->ip());
try {
Mail::to((string) $tenant->contact_email)
->queue(new ImpersonationCodeMail($plainCode, (string) $tenant->contact_email));
} catch (\Throwable $e) {
Log::warning('impersonation init: не удалось поставить письмо с кодом: '.$e->getMessage());
}
// TODO: отправить email на $tenant->contact_email с $plainCode.
$payload = [
'token_id' => $token->id,
'expires_at' => $token->expires_at->toIso8601String(),
@@ -204,33 +190,10 @@ class ImpersonationController extends Controller
], 422);
}
// Success: целевой пользователь тенанта = самый ранний активный.
$targetUser = User::on(self::DB_CONNECTION)
->where('tenant_id', $token->tenant_id)
->where('is_active', true)
->orderBy('id')
->first();
if ($targetUser === null) {
return response()->json(['message' => 'У тенанта нет активного пользователя для входа.'], 422);
}
// Машинный ключ для ИИ: lpimp_<id>_<secret>. Храним только хеш секрета.
$secret = Str::random(48);
$machineToken = 'lpimp_'.$token->id.'_'.$secret;
// Success: mark used. Создание saas_admin_session с
// impersonating_token_id — отдельный коммит после saas-admin auth.
$token->update([
'used_at' => now(),
'session_token_hash' => Hash::make($secret),
]);
// Путь человека: логиним браузер целевым пользователем + маркер impersonation в сессию.
Auth::login($targetUser);
$request->session()->put('impersonation', [
'token_id' => $token->id,
'tenant_id' => $token->tenant_id,
'target_user_id' => $targetUser->id,
'started_at' => now()->toIso8601String(),
]);
$audit->recordVerify($token, adminId: (int) $token->requested_by, ip: $request->ip());
@@ -239,8 +202,6 @@ class ImpersonationController extends Controller
'token_id' => $token->id,
'tenant_id' => $token->tenant_id,
'used_at' => $token->used_at->toIso8601String(),
'expires_at' => $token->sessionExpiresAt(self::SESSION_TTL_MINUTES)->toIso8601String(),
'machine_token' => $machineToken,
'message' => 'Impersonation начат. Сессия активна 1 час.',
]);
}
@@ -271,12 +232,7 @@ class ImpersonationController extends Controller
$audit->recordEnd($token, adminId: (int) $token->requested_by, ip: $request->ip());
try {
Mail::to((string) $token->sent_to_email)
->queue(new ImpersonationEndedMail((string) $token->sent_to_email));
} catch (\Throwable $e) {
Log::warning('impersonation end mail: '.$e->getMessage());
}
// TODO: уведомление клиенту по email о завершении (как и в init flow).
return response()->json([
'token_id' => $token->id,
@@ -284,35 +240,4 @@ class ImpersonationController extends Controller
'message' => 'Impersonation завершён.',
]);
}
/**
* POST /api/impersonation/leave завершить свою impersonation-сессию из кабинета.
*
* Маркер `impersonation` из сессии НЕ удаляется здесь намеренно:
* ImpersonationContext (global web middleware) на следующем запросе
* обнаружит isSessionActive()=false и вернёт 401 явно, не доходя до auth:sanctum.
* Это обеспечивает корректный 401 как в реальном браузере, так и в тест-среде
* (где Auth::guard('web')->logout() может не повлиять на кэш sanctum-guard).
*/
public function leave(Request $request, ImpersonationAuditService $audit): JsonResponse
{
$marker = $request->session()->get('impersonation');
if ($marker === null) {
return response()->json(['message' => 'Сессия impersonation не активна.'], 422);
}
$token = ImpersonationToken::on(self::DB_CONNECTION)->find($marker['token_id']);
if ($token !== null && $token->session_ended_at === null) {
$token->update(['session_ended_at' => now()]);
$audit->recordEnd($token, adminId: (int) $token->requested_by, ip: $request->ip());
try {
Mail::to((string) $token->sent_to_email)
->queue(new ImpersonationEndedMail((string) $token->sent_to_email));
} catch (\Throwable $e) {
Log::warning('impersonation leave mail: '.$e->getMessage());
}
}
return response()->json(['message' => 'Вы вышли из режима поддержки.']);
}
}
@@ -1,119 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\PaymentGateway;
use App\Models\SaasTransaction;
use App\Services\Billing\BillingTopupService;
use App\Services\Billing\Gateway\PaymentGatewayDriver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\IpUtils;
/**
* Приём webhook от платёжного шлюза (ЮKassa). Публичный роут (без auth/tenant),
* URL под маской api/webhook/* CSRF-exempt (bootstrap/app.php).
*
* Подлинность: НЕ доверяем телу webhook по object.id делаем server-to-server
* сверку через драйвер (GET /payments/{id}) и верим статусу из ответа шлюза.
*
* RLS: webhook вне tenant-сессии. Поиск платежа cross-tenant через
* BYPASSRLS-соединение pgsql_supplier (как джобы, Plan 3/4). Зачисление
* под app.current_tenant_id (SET LOCAL внутри транзакции, как BalancePreflightSweepJob).
*
* Идемпотентность: атомарный claim pending→success (UPDATE ... WHERE status='pending').
* Повторный webhook claimed=0 no-op, 200 OK без двойного зачисления.
*
* Зачисление денег делегируется BillingTopupService (lockForUpdate + append-only ledger).
*/
class PaymentWebhookController extends Controller
{
public function __construct(
private readonly PaymentGatewayDriver $driver,
private readonly BillingTopupService $topupService,
) {}
public function receive(Request $request): JsonResponse
{
// Defense-in-depth: IP-allowlist ЮKassa. Fail-open при пустом списке —
// не ломаем легитимный поток; на проде заполнить YOOKASSA_WEBHOOK_IPS
// опубликованными ЮKassa подсетями, чтобы аноним не дёргал endpoint.
$allowlist = array_values(array_filter((array) config('services.yookassa.webhook_ip_allowlist', [])));
if ($allowlist !== [] && ! IpUtils::checkIp((string) $request->ip(), $allowlist)) {
return response()->json(['status' => 'ignored'], 200);
}
$paymentId = (string) $request->input('object.id', '');
if ($paymentId === '') {
return response()->json(['status' => 'ignored'], 200);
}
// Cross-tenant поиск платежа под BYPASSRLS-ролью (tenant ещё неизвестен).
$tx = DB::connection('pgsql_supplier')->table('saas_transactions')
->where('gateway_payment_id', $paymentId)
->first();
if ($tx === null) {
return response()->json(['status' => 'unknown'], 200);
}
$gateway = $this->gatewayFor($tx);
// Server-to-server сверка — источник правды о статусе.
$verify = $this->driver->verifyPayment($gateway, $paymentId);
if (! $verify->isSucceeded()) {
return response()->json(['status' => 'not_paid'], 200);
}
// Confused-deputy: сверенный платёж должен быть РОВНО тем, что в webhook.
if ($verify->gatewayPaymentId !== $paymentId) {
return response()->json(['status' => 'ignored'], 200);
}
// Защита от чужой валюты с тем же числом — зачисляем только рубли.
if ($verify->currency !== 'RUB') {
return response()->json(['status' => 'currency_mismatch'], 200);
}
// Защита: оплаченная сумма должна совпасть с запрошенной (scale 2).
if (bccomp((string) $verify->amountRub, (string) $tx->amount_rub, 2) !== 0) {
return response()->json(['status' => 'amount_mismatch'], 200);
}
DB::transaction(function () use ($tx, $verify) {
// RLS-контекст для этой транзакции (PgBouncer-safe SET LOCAL).
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $tx->tenant_id);
// Атомарно занимаем pending→success; 0 строк = уже зачислено (дубль/гонка).
$claimed = SaasTransaction::where('id', $tx->id)
->where('status', SaasTransaction::STATUS_PENDING)
->update(['status' => SaasTransaction::STATUS_SUCCESS, 'completed_at' => now()]);
if ($claimed === 0) {
return; // идемпотентный no-op
}
$balanceTx = $this->topupService->topup(
(int) $tx->tenant_id, (string) $tx->amount_rub, null
);
SaasTransaction::where('id', $tx->id)->update([
'balance_rub_after' => $balanceTx->balance_rub_after,
'payment_method' => $verify->paymentMethod,
'balance_transaction_id' => $balanceTx->id, // provenance: оплата → строка журнала
]);
});
return response()->json(['status' => 'ok'], 200);
}
private function gatewayFor(object $tx): PaymentGateway
{
return $tx->gateway_id !== null
? PaymentGateway::findOrFail($tx->gateway_id)
: PaymentGateway::where('code', $tx->gateway_code)->firstOrFail();
}
}
@@ -10,12 +10,11 @@ use App\Http\Requests\StoreProjectRequest;
use App\Http\Requests\UpdateProjectRequest;
use App\Http\Resources\ProjectResource;
use App\Jobs\SyncSupplierProjectJob;
use App\Models\PricingTier;
use App\Models\Project;
use App\Models\Tenant;
use App\Repositories\PricingTierRepository;
use App\Services\Billing\BalancePreflightService;
use App\Services\Project\ProjectService;
use App\Services\Requisites\RequisitesService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -30,17 +29,13 @@ use Illuminate\Http\Request;
*/
class ProjectController extends Controller
{
public function __construct(
private readonly ProjectService $projects,
private readonly RequisitesService $requisites,
) {}
public function __construct(private readonly ProjectService $projects) {}
/** GET /api/projects */
public function index(Request $request): JsonResponse
{
$query = Project::query()
->with(['supplierB1', 'supplierB2', 'supplierB3']) // eager-load to avoid N+1 in aggregation helpers
->withCount('supplierProjects') // ProjectResource::source_locked — анти-N+1 (hasLinks без per-row запроса)
->where('tenant_id', $request->user()->tenant_id);
// Batch-fetch по ids — возвращает без пагинации (для dropdown'ов и т.п.)
@@ -127,13 +122,6 @@ class ProjectController extends Controller
{
$validated = $request->validated();
$tenant = $request->user()->tenant;
// G1/SP2: гейт первого проекта — нельзя создать первый проект без минимальных реквизитов.
if (Project::where('tenant_id', $tenant->id)->count() === 0
&& ! $this->requisites->isLightComplete($tenant)) {
return response()->json(['error' => 'requisites_required'], 422);
}
$forceSaveBlocked = (bool) ($validated['force_save_blocked'] ?? false);
unset($validated['force_save_blocked']);
@@ -162,7 +150,7 @@ class ProjectController extends Controller
$project = $this->projects->create($tenant, $validated);
return response()->json(['data' => new ProjectResource($project->loadCount('supplierProjects'))], 201);
return response()->json(['data' => new ProjectResource($project)], 201);
}
/** PATCH /api/projects/{id} */
@@ -203,7 +191,7 @@ class ProjectController extends Controller
$updated = $this->projects->update($project, $validated);
return response()->json(['data' => new ProjectResource($updated->loadCount('supplierProjects'))]);
return response()->json(['data' => new ProjectResource($updated)]);
}
/**
@@ -211,8 +199,7 @@ class ProjectController extends Controller
*/
private function runPreflight(Tenant $tenant, int $requiredLeads): array
{
// Косяк 01: действующая версия тарифа по дате (как списание/витрина), а не «по-простому».
$tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow'));
$tiers = PricingTier::query()->where('is_active', true)->get();
// Safe fallback: без активных pricing_tiers биллинг не настроен —
// преfflight не имеет смысла, пропускаем (legacy-окружения / тесты).
@@ -238,7 +225,6 @@ class ProjectController extends Controller
public function show(Request $request, int $id): JsonResponse
{
$project = Project::with(['supplierB1', 'supplierB2', 'supplierB3']) // eager-load to avoid N+1
->withCount('supplierProjects') // ProjectResource::source_locked — анти-N+1
->where('tenant_id', $request->user()->tenant_id)
->findOrFail($id);
@@ -279,13 +265,9 @@ class ProjectController extends Controller
// #10: pause/resume must reach the supplier. The job's group recompute pushes
// status=paused when no active project of the group remains (resume → active).
// G (балансовый блок): заблокированный за нехваткой баланса проект не
// возобновляется/синхронизируется у поставщика (зеркалит create-гард).
if ($project->preflight_blocked_at === null) {
SyncSupplierProjectJob::dispatch($project->id);
}
SyncSupplierProjectJob::dispatch($project->id);
return response()->json(['data' => new ProjectResource($project->fresh()->loadCount('supplierProjects'))]);
return response()->json(['data' => new ProjectResource($project->fresh())]);
}
/** POST /api/projects/bulk — batch pause/resume/delete/update_regions/update_days/update_limit */
@@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Repositories\PricingTierRepository;
use Carbon\CarbonImmutable;
use Illuminate\Http\JsonResponse;
/**
* Публичный (без auth) список текущей тарифной сетки для страницы цен.
*
* Read-only, без ПДн. Переиспользует PricingTierRepository::activeAt.
* Требование ЮKassa: цены должны быть публично доступны на сайте.
*/
class PublicPricingController extends Controller
{
public function index(PricingTierRepository $repo): JsonResponse
{
$tiers = $repo->activeAt(CarbonImmutable::now())
->map(fn ($t) => [
'tier_no' => (int) $t->tier_no,
'leads_in_tier' => $t->leads_in_tier === null ? null : (int) $t->leads_in_tier,
'price_rub' => number_format((int) $t->price_per_lead_kopecks / 100, 2, '.', ''),
])
->values();
return response()->json(['tiers' => $tiers]);
}
}
@@ -1,121 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Concerns\WritesAuthLog;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\ConfirmEmailRequest;
use App\Http\Requests\Auth\RegisterRequest;
use App\Http\Requests\Auth\ResendCodeRequest;
use App\Models\User;
use App\Services\Auth\RegistrationException;
use App\Services\Auth\RegistrationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
/**
* Самозапись клиента (G1/SP1): register confirm-email (вход).
* Подтверждение почты 6-значным кодом; новый тенант создаётся в статусе
* pending_email_confirm, активируется и получает 300 при подтверждении.
*/
class RegistrationController extends Controller
{
use WritesAuthLog;
public function register(RegisterRequest $request, RegistrationService $service): JsonResponse
{
try {
$result = $service->register(
$request->string('email')->toString(),
$request->string('password')->toString(),
$request->input('captcha_token'),
$request->ip(),
);
} catch (RegistrationException $e) {
return $this->registrationError($e);
}
$payload = [
'status' => $result['status'],
'email' => $result['user']->email,
'expires_at' => $result['verification']->expires_at->toIso8601String(),
];
if ($result['dev_code'] !== null) {
$payload['_dev_plain_code'] = $result['dev_code'];
}
return response()->json($payload, 201);
}
public function confirmEmail(ConfirmEmailRequest $request, RegistrationService $service): JsonResponse
{
try {
$user = $service->confirm(
$request->string('email')->toString(),
$request->string('code')->toString(),
);
} catch (RegistrationException $e) {
$payload = ['message' => 'Код подтверждения недействителен.', 'reason' => $e->reason];
if ($e->attemptsRemaining !== null) {
$payload['attempts_remaining'] = $e->attemptsRemaining;
}
return response()->json($payload, 422);
}
Auth::login($user);
$request->session()->regenerate();
$this->logAuthEvent('register_success', $user->id, $user->tenant_id, $user->email, $request->ip(), $request->userAgent(), null);
return response()->json([
'user' => $this->userResource($user),
'requires_2fa' => false,
]);
}
public function resendCode(ResendCodeRequest $request, RegistrationService $service): JsonResponse
{
$devCode = $service->resend($request->string('email')->toString());
$payload = ['message' => 'Если аккаунт ожидает подтверждения, мы отправили новый код на указанный email.'];
if ($devCode !== null) {
$payload['_dev_plain_code'] = $devCode;
}
return response()->json($payload);
}
private function registrationError(RegistrationException $e): JsonResponse
{
$map = [
'captcha_failed' => ['captcha_token', 'Проверка «я не робот» не пройдена.'],
'email_taken' => ['email', 'Аккаунт с таким email уже существует.'],
];
[$field, $message] = $map[$e->reason] ?? ['email', 'Не удалось зарегистрировать аккаунт.'];
return response()->json([
'message' => $message,
'errors' => [$field => [$message]],
], 422);
}
/** @return array<string, mixed> */
private function userResource(User $user): array
{
return [
'id' => $user->id,
'email' => $user->email,
'first_name' => $user->first_name,
'last_name' => $user->last_name,
'phone' => $user->phone,
'timezone' => $user->timezone,
'tenant_id' => $user->tenant_id,
'totp_enabled' => $user->totp_enabled,
'last_login_at' => $user->last_login_at,
'notification_preferences' => $user->notification_preferences,
'sound_enabled' => $user->sound_enabled,
];
}
}
@@ -0,0 +1,303 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Reminder;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* Reminders API (schema v8.10 §17.5). Все endpoint'ы под `auth:sanctum`.
*
* Фильтры filter= для GET /api/reminders:
* today completed_at IS NULL AND remind_at в (now-1d, now+1d)
* upcoming completed_at IS NULL AND remind_at > now+1d
* overdue completed_at IS NULL AND remind_at < now-1d
* completed completed_at IS NOT NULL
* active completed_at IS NULL (default)
*
* RLS: внутри транзакции SET LOCAL app.current_tenant_id = $user->tenant_id.
* Защита от кражи: явный where('tenant_id', $user->tenant_id) поверх RLS.
*/
class ReminderController extends Controller
{
private const FILTERS = ['active', 'today', 'upcoming', 'overdue', 'completed'];
/**
* GET /api/reminders?filter=&deal_id=&limit=
*/
public function index(Request $request): JsonResponse
{
$validated = $request->validate([
'filter' => 'nullable|string|in:'.implode(',', self::FILTERS),
'deal_id' => 'nullable|integer|min:1',
'limit' => 'nullable|integer|min:1|max:200',
]);
/** @var User $user */
$user = $request->user();
$filter = $validated['filter'] ?? 'active';
$limit = (int) ($validated['limit'] ?? 100);
return DB::transaction(function () use ($user, $filter, $validated, $limit): JsonResponse {
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);
$query = Reminder::query()
->with('creator:id,email,first_name,last_name')
->where('tenant_id', $user->tenant_id);
if (isset($validated['deal_id'])) {
$query->where('deal_id', (int) $validated['deal_id']);
}
$now = Carbon::now();
switch ($filter) {
case 'today':
$query->whereNull('completed_at')
->whereBetween('remind_at', [$now->copy()->subDay(), $now->copy()->addDay()]);
break;
case 'upcoming':
$query->whereNull('completed_at')
->where('remind_at', '>', $now->copy()->addDay());
break;
case 'overdue':
$query->whereNull('completed_at')
->where('remind_at', '<', $now->copy()->subDay());
break;
case 'completed':
$query->whereNotNull('completed_at');
break;
case 'active':
default:
$query->whereNull('completed_at');
break;
}
$items = $query->orderBy('remind_at')->limit($limit)->get();
// Counters для UI badges (today/upcoming/overdue) — отдельные SELECT'ы.
$base = Reminder::query()->where('tenant_id', $user->tenant_id);
$counts = [
'today' => (clone $base)->whereNull('completed_at')
->whereBetween('remind_at', [$now->copy()->subDay(), $now->copy()->addDay()])
->count(),
'upcoming' => (clone $base)->whereNull('completed_at')
->where('remind_at', '>', $now->copy()->addDay())
->count(),
'overdue' => (clone $base)->whereNull('completed_at')
->where('remind_at', '<', $now->copy()->subDay())
->count(),
'active' => (clone $base)->whereNull('completed_at')->count(),
];
return response()->json([
'items' => $items->map(fn (Reminder $r) => $this->toResource($r))->all(),
'counts' => $counts,
]);
});
}
/**
* POST /api/reminders {deal_id, text?, remind_at}.
*/
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'deal_id' => 'required|integer|min:1',
'text' => 'nullable|string|max:255',
'remind_at' => 'required|date',
'assignee_id' => 'nullable|integer|min:1',
]);
/** @var User $user */
$user = $request->user();
// Manager FK guard для assignee_id: должен принадлежать тому же tenant'у.
if (isset($validated['assignee_id'])) {
$exists = User::query()
->where('id', $validated['assignee_id'])
->where('tenant_id', $user->tenant_id)
->whereNull('deleted_at')
->where('is_active', true)
->exists();
if (! $exists) {
return response()->json([
'message' => 'Менеджер не найден в этом тенанте.',
'errors' => ['assignee_id' => ['Не принадлежит вашему тенанту или не активен.']],
], 422);
}
}
return DB::transaction(function () use ($user, $validated): JsonResponse {
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);
$reminder = Reminder::create([
'tenant_id' => $user->tenant_id,
'deal_id' => (int) $validated['deal_id'],
'text' => $validated['text'] ?? null,
'remind_at' => Carbon::parse($validated['remind_at']),
'created_by' => $user->id,
'assignee_id' => $validated['assignee_id'] ?? null,
'is_sent' => false,
]);
return response()->json([
'reminder' => $this->toResource($reminder->load('creator:id,email,first_name,last_name')),
], 201);
});
}
/**
* PATCH /api/reminders/{id} {text?, remind_at?, assignee_id?}.
*/
public function update(Request $request, int $id): JsonResponse
{
$validated = $request->validate([
'text' => 'nullable|string|max:255',
'remind_at' => 'nullable|date',
'assignee_id' => 'nullable|integer|min:1',
]);
if (count($validated) === 0) {
return response()->json([
'message' => 'Передайте хотя бы одно поле.',
'errors' => ['_general' => ['Нужно хотя бы одно поле для обновления.']],
], 422);
}
/** @var User $user */
$user = $request->user();
if (isset($validated['assignee_id'])) {
$exists = User::query()
->where('id', $validated['assignee_id'])
->where('tenant_id', $user->tenant_id)
->whereNull('deleted_at')
->where('is_active', true)
->exists();
if (! $exists) {
return response()->json([
'message' => 'Менеджер не найден.',
'errors' => ['assignee_id' => ['Не принадлежит вашему тенанту или не активен.']],
], 422);
}
}
return DB::transaction(function () use ($user, $id, $validated): JsonResponse {
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);
$reminder = Reminder::query()
->where('id', $id)
->where('tenant_id', $user->tenant_id)
->first();
if ($reminder === null) {
return response()->json(['message' => 'Напоминание не найдено.'], 404);
}
$update = [];
if (array_key_exists('text', $validated)) {
$update['text'] = $validated['text'];
}
if (isset($validated['remind_at'])) {
$update['remind_at'] = Carbon::parse($validated['remind_at']);
// При сдвиге remind_at сбрасываем is_sent, чтобы cron смог
// снова отправить уведомление к новому времени.
$update['is_sent'] = false;
$update['sent_at'] = null;
}
if (array_key_exists('assignee_id', $validated)) {
$update['assignee_id'] = $validated['assignee_id'];
}
$reminder->update($update);
return response()->json([
'reminder' => $this->toResource($reminder->fresh('creator')),
]);
});
}
/**
* POST /api/reminders/{id}/complete пометить выполненным.
* Идемпотентно: повторный вызов NO-OP.
*/
public function complete(Request $request, int $id): JsonResponse
{
/** @var User $user */
$user = $request->user();
return DB::transaction(function () use ($user, $id): JsonResponse {
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);
$reminder = Reminder::query()
->where('id', $id)
->where('tenant_id', $user->tenant_id)
->first();
if ($reminder === null) {
return response()->json(['message' => 'Напоминание не найдено.'], 404);
}
if ($reminder->completed_at === null) {
$reminder->update(['completed_at' => Carbon::now()]);
}
return response()->json([
'reminder' => $this->toResource($reminder->fresh('creator')),
]);
});
}
/**
* DELETE /api/reminders/{id}.
*/
public function destroy(Request $request, int $id): JsonResponse
{
/** @var User $user */
$user = $request->user();
return DB::transaction(function () use ($user, $id): JsonResponse {
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);
$deleted = Reminder::query()
->where('id', $id)
->where('tenant_id', $user->tenant_id)
->delete();
if ($deleted === 0) {
return response()->json(['message' => 'Напоминание не найдено.'], 404);
}
return response()->json(['message' => 'Удалено.']);
});
}
/** @return array<string, mixed> */
private function toResource(Reminder $reminder): array
{
$creator = $reminder->creator;
return [
'id' => $reminder->id,
'deal_id' => $reminder->deal_id,
'text' => $reminder->text,
'remind_at' => $reminder->remind_at?->toIso8601String(),
'completed_at' => $reminder->completed_at?->toIso8601String(),
'is_sent' => $reminder->is_sent,
'sent_at' => $reminder->sent_at?->toIso8601String(),
'created_at' => $reminder->created_at?->toIso8601String(),
'created_by' => $reminder->created_by,
'assignee_id' => $reminder->assignee_id,
'creator_name' => $creator
? trim(($creator->first_name ?? '').' '.($creator->last_name ?? '')) ?: $creator->email
: null,
];
}
}
@@ -44,22 +44,9 @@ class SupplierWebhookController extends Controller
/** Audit-fix C2: per-IP rate-limit (DoS-guard), запросов в минуту. */
private const RATE_LIMIT_PER_MINUTE = 600;
public function receive(Request $request, string $secret = ''): JsonResponse
public function receive(Request $request, string $secret): JsonResponse
{
// Аутентификация (аддитивно): URL-секрет (backward-compat) ИЛИ HMAC-подпись
// тела (X-Webhook-Signature = hash_hmac sha256 от raw body на том же
// supplier_webhook_secret). HMAC позволяет поставщику не слать секрет в URL
// — тот течёт в access-логи (P2/E4). verifySecret('') всегда false.
$sig = (string) $request->header('X-Webhook-Signature', '');
$sig = str_starts_with($sig, 'sha256=') ? substr($sig, 7) : $sig;
$secretRow = DB::table('system_settings')->where('key', 'supplier_webhook_secret')->first();
$expectedSecret = $secretRow !== null ? (string) $secretRow->value : '';
$hmacValid = $sig !== ''
&& $expectedSecret !== '__SET_ON_DEPLOY__'
&& strlen($expectedSecret) >= 32
&& hash_equals(hash_hmac('sha256', $request->getContent(), $expectedSecret), $sig);
if (! $this->verifySecret($secret) && ! $hmacValid) {
if (! $this->verifySecret($secret)) {
$this->logSupplierWebhook($request, null, 'rejected_secret');
return response()->json(['message' => 'Not found.'], 404);
@@ -1,55 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Mail\SupportRequestMail;
use App\Models\SupportRequest;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
/**
* G7-A: приём клиентских заявок в техподдержку. Запись в БД основной канал;
* письмо в поддержку best-effort (сбой SMTP не валит запрос, паттерн G1 sendCode).
*/
class SupportRequestController extends Controller
{
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'contact' => 'required|string|max:255',
'message' => 'required|string|max:5000',
]);
/** @var User $user */
$user = $request->user();
$supportRequest = DB::transaction(function () use ($user, $validated): SupportRequest {
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);
return SupportRequest::create([
'tenant_id' => $user->tenant_id,
'user_id' => $user->id,
'name' => $validated['name'],
'contact' => $validated['contact'],
'message' => $validated['message'],
]);
});
// Письмо — best-effort: заявка уже в БД, сбой почты не теряет её и не валит запрос.
try {
Mail::to(config('services.support.email'))->queue(new SupportRequestMail($supportRequest));
} catch (\Throwable $e) {
Log::warning('SupportRequestMail queue failed', ['id' => $supportRequest->id, 'error' => $e->getMessage()]);
}
return response()->json(['ok' => true], 201);
}
}
@@ -1,57 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\LookupInnRequest;
use App\Http\Requests\UpdateRequisitesRequest;
use App\Http\Resources\RequisitesResource;
use App\Models\TenantRequisites;
use App\Services\DaData\PartyLookup;
use App\Services\Requisites\RequisitesService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class TenantRequisitesController extends Controller
{
public function __construct(
private readonly RequisitesService $service,
private readonly PartyLookup $party,
) {}
/** GET /api/tenant/requisites */
public function show(Request $request): JsonResponse
{
$req = TenantRequisites::where('tenant_id', $request->user()->tenant_id)->first();
return response()->json(['data' => $req ? new RequisitesResource($req) : null]);
}
/** PUT /api/tenant/requisites */
public function update(UpdateRequisitesRequest $request): JsonResponse
{
$req = $this->service->upsert($request->user()->tenant, $request->validated());
return response()->json(['data' => new RequisitesResource($req)]);
}
/** POST /api/tenant/requisites/lookup-inn — мягкая подтяжка, ничего не сохраняет */
public function lookupInn(LookupInnRequest $request): JsonResponse
{
$res = $this->party->findByInn($request->validated()['inn']);
if ($res === null) {
return response()->json(['found' => false]);
}
return response()->json([
'found' => true,
'legal_name' => $res->legalName,
'kpp' => $res->kpp,
'ogrn' => $res->ogrn,
'legal_address' => $res->address,
'subject_type_hint' => $res->type === 'INDIVIDUAL' ? 'sole_proprietor' : 'legal_entity',
]);
}
}
@@ -10,7 +10,6 @@ use App\Http\Requests\Auth\UseRecoveryCodeRequest;
use App\Http\Requests\Auth\VerifyTwoFactorRequest;
use App\Models\User;
use App\Models\UserRecoveryCode;
use App\Services\UserSessionTracker;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
@@ -98,7 +97,6 @@ class TwoFactorController extends Controller
$request->session()->forget(['auth.pending_user_id', 'auth.pending_remember']);
$user->update(['last_login_at' => now()]);
app(UserSessionTracker::class)->record($request, $user->id);
$this->logAuthEvent(
'2fa_verify_success',
@@ -202,7 +200,6 @@ class TwoFactorController extends Controller
$request->session()->forget(['auth.pending_user_id', 'auth.pending_remember']);
$user->update(['last_login_at' => now()]);
app(UserSessionTracker::class)->record($request, $user->id);
$this->logAuthEvent(
'2fa_recovery_used',
@@ -1,95 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Deal;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* Публичный read-API сделок тенанта (G6). Аутентификация middleware ApiKeyAuth
* (tenant_id в request->attributes['api_tenant_id']). Только сделки (deals), не
* supplier_leads.
*/
class DealsController extends Controller
{
public function index(Request $request): JsonResponse
{
$tenantId = (int) $request->attributes->get('api_tenant_id');
$limit = max(1, min(500, (int) $request->query('limit', '100')));
$since = trim((string) $request->query('since', ''));
$sinceDt = null;
if ($since !== '') {
try {
$sinceDt = Carbon::parse($since);
} catch (\Throwable) {
return response()->json(['message' => 'Невалидный since.'], 422);
}
}
$cursorRaw = (string) $request->query('cursor', '');
$cursor = null;
if ($cursorRaw !== '') {
$decoded = base64_decode($cursorRaw, true);
$parsed = $decoded === false ? null : json_decode($decoded, true);
if (! is_array($parsed) || ! isset($parsed['r'], $parsed['i'])) {
return response()->json(['message' => 'Невалидный cursor.'], 422);
}
$cursor = ['r' => (string) $parsed['r'], 'i' => (int) $parsed['i']];
}
[$rows, $next] = DB::transaction(function () use ($tenantId, $limit, $sinceDt, $cursor) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
$query = Deal::query()
->where('tenant_id', $tenantId)
->with('project:id,name');
if ($sinceDt !== null) {
$query->where('received_at', '>=', $sinceDt);
}
if ($cursor !== null) {
$query->whereRaw('(received_at, id) < (?, ?)', [$cursor['r'], $cursor['i']]);
}
$rows = $query->orderByDesc('received_at')->orderByDesc('id')
->limit($limit + 1)->get();
$hasNext = $rows->count() > $limit;
if ($hasNext) {
$rows = $rows->slice(0, $limit)->values();
}
$next = null;
if ($hasNext && $rows->isNotEmpty()) {
$last = $rows->last();
$next = base64_encode((string) json_encode([
'r' => $last->received_at->toIso8601String(),
'i' => $last->id,
]));
}
return [$rows, $next];
});
return response()->json([
'data' => $rows->map(fn (Deal $d) => [
'id' => $d->id,
'received_at' => $d->received_at->toIso8601String(),
'phone' => $d->phone,
'contact_name' => $d->contact_name,
'city' => $d->city,
'status' => $d->status,
'project' => $d->project?->name,
])->all(),
'next_cursor' => $next,
]);
}
}
@@ -127,16 +127,15 @@ class WebhookSettingsController extends Controller
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
// SSRF-гард + DNS-rebind пиннинг: ОДИН резолв target_url даёт причину
// блокировки И безопасный IP. Блокируем адреса во внутренней/зарезервированной
// сети (cloud-metadata 169.254.169.254, loopback, RFC1918), которые
// https://-валидация на сохранении не ловит.
$delivery = WebhookUrlGuard::safeDeliveryIp($sub->target_url);
if ($delivery['blockReason'] !== null) {
// SSRF-гард: target_url задаёт админ тенанта; блокируем адреса во
// внутренней/зарезервированной сети (cloud-metadata 169.254.169.254,
// loopback, RFC1918), которые https://-валидация на сохранении не ловит.
$blockReason = WebhookUrlGuard::blockReason($sub->target_url);
if ($blockReason !== null) {
return response()->json([
'ok' => false,
'status' => null,
'message' => $delivery['blockReason'],
'message' => $blockReason,
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
@@ -146,19 +145,9 @@ class WebhookSettingsController extends Controller
'message' => 'Тестовая доставка webhook от Лидерра.',
];
// DNS-rebind пиннинг: подключаемся к УЖЕ проверенному IP, не давая
// HTTP-клиенту резолвить хост повторно (TOCTOU). Host/SNI — исходный хост.
$httpOptions = [];
if ($delivery['ip'] !== null) {
$host = trim((string) parse_url($sub->target_url, PHP_URL_HOST), '[]');
$port = parse_url($sub->target_url, PHP_URL_PORT) ?? 443;
$httpOptions['curl'] = [CURLOPT_RESOLVE => ["{$host}:{$port}:{$delivery['ip']}"]];
}
// Unsigned connectivity-проверка (HMAC-подписанная доставка — отдельный эпик).
try {
$response = Http::withOptions($httpOptions)
->timeout(10)
$response = Http::timeout(10)
->withHeaders(['X-Webhook-Event' => 'webhook.test'])
->post($sub->target_url, $testPayload);
@@ -21,14 +21,6 @@ trait ResolvesAdminUserId
{
protected function resolveAdminUserId(Request $request, string $stubEmail, string $stubName): int
{
// Прод: crm_app_user не имеет прав на saas_admin_users → берём системный
// admin-id из конфига, не обращаясь к таблице (фикс 500 на admin-сохранениях).
// null (dev/test, суперюзер) → fallback на старую логику ниже.
$configured = config('admin.audit_system_user_id');
if ($configured !== null) {
return (int) $configured;
}
$requested = $request->input('admin_user_id');
if (is_int($requested) || (is_string($requested) && ctype_digit($requested))) {
$existing = DB::table('saas_admin_users')->where('id', (int) $requested)->value('id');
-73
View File
@@ -1,73 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Models\ApiKey;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Symfony\Component\HttpFoundation\Response;
/**
* Аутентификация публичного API по ключу тенанта (G6).
*
* Ключ `Authorization: Bearer lpkapi_...`. В БД лежит bcrypt key_hash + key_prefix
* (первые 10 символов). Ищем кандидатов по префиксу через pgsql_supplier (BYPASSRLS
* публичный роут не ставит tenant-GUC, под RLS api_keys вернул бы пусто), затем
* Hash::check. Успех tenant_id в request->attributes (api_tenant_id) + last_used.
*/
class ApiKeyAuth
{
public function handle(Request $request, Closure $next): Response
{
$key = $this->bearer($request);
if ($key === null || $key === '') {
return response()->json(['message' => 'Требуется API-ключ.'], 401);
}
$prefix = substr($key, 0, 10);
$candidates = ApiKey::on('pgsql_supplier')
->where('key_prefix', $prefix)
->where('is_active', true)
->where('expires_at', '>', now())
->get();
$matched = null;
foreach ($candidates as $candidate) {
if (Hash::check($key, (string) $candidate->key_hash)) {
$matched = $candidate;
break;
}
}
if ($matched === null) {
return response()->json(['message' => 'Неверный или неактивный API-ключ.'], 401);
}
if (! in_array('read', (array) $matched->scopes, true)) {
return response()->json(['message' => 'Недостаточно прав ключа.'], 403);
}
ApiKey::on('pgsql_supplier')->whereKey($matched->getKey())->update([
'last_used_at' => now(),
'last_used_ip' => $request->ip(),
]);
$request->attributes->set('api_tenant_id', (int) $matched->tenant_id);
return $next($request);
}
private function bearer(Request $request): ?string
{
$header = (string) $request->header('Authorization', '');
if (str_starts_with($header, 'Bearer ')) {
return trim(substr($header, 7));
}
return null;
}
}
+1 -29
View File
@@ -24,41 +24,13 @@ use Symfony\Component\HttpFoundation\Response;
* admin_user_id для audit-trail по-прежнему резолвится трейтом
* ResolvesAdminUserId (стаб super_admin) это отдельная зона.
*
* G7-B: пока активен impersonation (маркер сессии ИЛИ машинный ключ)
* вход в saas-admin зону запрещён (запрет эскалации к другим тенантам).
*
* M-1 (приёмка 21.06): nginx-дверь дополнена app-слойным fail-closed гейтом по
* REMOTE_USER + config-allowlist. Закрывает обходы front-controller'а
* (/index.php/api/admin, /API/admin), где nginx basic-auth не применяется и
* REMOTE_USER пуст. См. config/admin.php и spec 2026-06-21-m1-admin-gate-fail-closed.
*
* TODO (после Б-1 + DO-4): заменить nginx-дверь на настоящий saas-admin
* guard (Yandex 360 SSO-сессия + роль).
* guard (Yandex 360 SSO-сессия + роль), вернуть проверку в это middleware.
*/
class EnsureSaasAdmin
{
public function handle(Request $request, Closure $next): Response
{
// G7-B: пока активен impersonation (маркер сессии ИЛИ машинный ключ) —
// вход в saas-admin зону запрещён (запрет эскалации к другим тенантам).
$hasMarker = $request->hasSession() && $request->session()->has('impersonation');
$hasBearer = str_starts_with((string) $request->header('Authorization', ''), 'Bearer lpimp_');
if ($hasMarker || $hasBearer) {
abort(403, 'Во время сессии impersonation доступ в админ-зону запрещён.');
}
// M-1 (приёмка 21.06): fail-closed гейт. REMOTE_USER непуст только у запросов,
// прошедших nginx admin-basic-auth (^~ /admin, ^~ /api/admin); обходы через
// front-controller (/index.php/api/admin, /API/admin) попадают в auth_basic off
// → REMOTE_USER пуст → 403. В local/testing гейт выключен (см. config/admin.php).
if (config('admin.basic_auth_gate')) {
$remoteUser = (string) $request->server('REMOTE_USER', '');
$allowlist = (array) config('admin.basic_auth_allowlist', []);
if ($remoteUser === '' || ! in_array($remoteUser, $allowlist, true)) {
abort(403, 'Доступ в админ-зону запрещён.');
}
}
return $next($request);
}
}
@@ -1,56 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Models\ImpersonationToken;
use App\Services\Pd\ImpersonationExpiryService;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
/**
* G7-B: на web-запросах с активным impersonation-маркером проверяет 60-мин
* лимит. Истёк (или токен уже неактивен) завершает токен, шлёт письмо,
* разлогинивает, чистит маркер. No-op, если маркера нет.
*
* Отправка письма делегирована ImpersonationExpiryService (Service-слой)
* middleware не зависит от слоя Mail напрямую (deptrac ruleset).
*/
class ImpersonationContext
{
public function __construct(private readonly ImpersonationExpiryService $expiry) {}
public function handle(Request $request, Closure $next): Response
{
if (! $request->hasSession() || ! $request->session()->has('impersonation')) {
return $next($request);
}
$marker = $request->session()->get('impersonation');
$token = ImpersonationToken::on('pgsql_supplier')->find($marker['token_id'] ?? 0);
if ($token === null || ! $token->isSessionActive()) {
if ($token !== null) {
$this->expiry->endSession($token);
}
Auth::guard('web')->logout();
$request->session()->forget('impersonation');
$request->session()->invalidate();
$request->session()->regenerateToken();
// Завершаем текущий запрос немедленно — auth:sanctum уже мог
// резолвить user'а из сессии, поэтому не передаём $next, а
// возвращаем 401 явно, чтобы клиент знал о разрыве сессии.
if ($request->expectsJson()) {
return response()->json(['message' => 'Сессия impersonation истекла.'], 401);
}
return redirect('/login');
}
return $next($request);
}
}
@@ -1,47 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Account;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Password;
/**
* Валидация POST /api/account/change-password (UI-аудит 21.06.2026, Security).
*
* current_password текущий пароль (проверка совпадения в контроллере через
* Hash::check против колонки password_hash). password новый, min 10 (ТЗ §22.4.1,
* как reset-flow) + confirmed (password_confirmation).
*/
class ChangePasswordRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'current_password' => ['required', 'string'],
'password' => ['required', 'confirmed', Password::min(10)],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'current_password.required' => 'Укажите текущий пароль.',
'password.required' => 'Укажите новый пароль.',
'password.confirmed' => 'Пароли не совпадают.',
'password.min' => 'Пароль должен быть не короче 10 символов.',
];
}
}
@@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Auth;
use Illuminate\Foundation\Http\FormRequest;
/**
* Валидация POST /api/auth/confirm-email подтверждение почты 6-значным кодом.
*/
class ConfirmEmailRequest extends FormRequest
{
/** @return array<string, mixed> */
public function rules(): array
{
return [
'email' => ['required', 'string', 'email', 'max:255'],
'code' => ['required', 'string', 'regex:/^\d{6}$/'],
];
}
/** @return array<string, string> */
public function messages(): array
{
return [
'email.required' => 'Укажите email.',
'code.required' => 'Укажите код из письма.',
'code.regex' => 'Код состоит из 6 цифр.',
];
}
}
+3 -10
View File
@@ -18,21 +18,14 @@ class RegisterRequest extends FormRequest
{
use HasPasswordRules;
/**
* @return array<string, mixed>
*
* NB: уникальность email НЕ через DB-rule её решает RegistrationService
* (активный email 422 email_taken; неподтверждённый перевыпуск кода).
* Капча проверяется на КАЖДОМ register-запросе (это независимый публичный POST).
*/
/** @return array<string, mixed> */
public function rules(): array
{
return [
'email' => ['required', 'string', 'email', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')],
'password' => $this->passwordRules(),
'accept_offer' => ['required', 'accepted'],
'accept_pdn' => ['required', 'accepted'],
'captcha_token' => ['required', 'string'],
];
}
@@ -42,9 +35,9 @@ class RegisterRequest extends FormRequest
return array_merge($this->passwordMessages(), [
'email.required' => 'Укажите email.',
'email.email' => 'Email указан некорректно.',
'email.unique' => 'Аккаунт с таким email уже существует.',
'accept_offer.accepted' => 'Необходимо принять оферту.',
'accept_pdn.accepted' => 'Необходимо согласие на обработку персональных данных.',
'captcha_token.required' => 'Подтвердите, что вы не робот.',
]);
}
}
@@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Auth;
use Illuminate\Foundation\Http\FormRequest;
/**
* Валидация POST /api/auth/resend-code повторная отправка кода подтверждения.
*/
class ResendCodeRequest extends FormRequest
{
/** @return array<string, mixed> */
public function rules(): array
{
return [
'email' => ['required', 'string', 'email', 'max:255'],
];
}
/** @return array<string, string> */
public function messages(): array
{
return [
'email.required' => 'Укажите email.',
'email.email' => 'Email указан некорректно.',
];
}
}
@@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class LookupInnRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'inn' => ['required', 'string', 'regex:/^(\d{10}|\d{12})$/'],
];
}
}
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Http\Requests;
use App\Support\PhoneNormalizer;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@@ -15,33 +14,6 @@ class StoreProjectRequest extends FormRequest
return $this->user() !== null;
}
/**
* Косяк 02: для типа «call» приводим введённый номер к каноничному виду
* 7XXXXXXXXXX тем же нормализатором, что и реквизиты (PhoneNormalizer).
* Источник проекта хранится без ведущего «+» раздача лидов матчит
* signal_identifier как есть (LeadRouter), поэтому «+» срезаем.
* Невалидный мусор оставляем как ввели финальная regex даст ошибку.
*/
protected function prepareForValidation(): void
{
if ($this->input('signal_type') === 'call' && $this->filled('signal_identifier')) {
$normalized = PhoneNormalizer::normalize((string) $this->input('signal_identifier'));
if ($normalized !== null) {
$this->merge(['signal_identifier' => ltrim($normalized, '+')]);
}
}
}
/** @return array<string, string> */
public function messages(): array
{
return match ($this->input('signal_type')) {
'call' => ['signal_identifier.regex' => 'Введите номер в формате 79161234567 — цифра 7 и 10 цифр после неё. Можно вводить с +7, 8, скобками и пробелами — приведём сами.'],
'site' => ['signal_identifier.regex' => 'Введите домен в формате example.ru — без http://, без www и без пути.'],
default => [],
};
}
public function rules(): array
{
$signalType = $this->input('signal_type');
+10 -51
View File
@@ -5,59 +5,15 @@ declare(strict_types=1);
namespace App\Http\Requests;
use App\Models\Project;
use App\Support\PhoneNormalizer;
use Illuminate\Foundation\Http\FormRequest;
class UpdateProjectRequest extends FormRequest
{
private ?Project $resolvedProject = null;
private bool $projectResolved = false;
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* Косяк 02: при редактировании call-проекта нормализуем введённый номер
* к 7XXXXXXXXXX (тот же PhoneNormalizer, что и реквизиты; «+» срезаем
* раздача матчит без него). Тип signal_type immutable берём из проекта.
*/
protected function prepareForValidation(): void
{
if (! $this->filled('signal_identifier')) {
return;
}
if ($this->resolveProject()?->signal_type === 'call') {
$normalized = PhoneNormalizer::normalize((string) $this->input('signal_identifier'));
if ($normalized !== null) {
$this->merge(['signal_identifier' => ltrim($normalized, '+')]);
}
}
}
/** @return array<string, string> */
public function messages(): array
{
return match ($this->resolveProject()?->signal_type) {
'call' => ['signal_identifier.regex' => 'Введите номер в формате 79161234567 — цифра 7 и 10 цифр после неё. Можно вводить с +7, 8, скобками и пробелами — приведём сами.'],
'site' => ['signal_identifier.regex' => 'Введите домен в формате example.ru — без http://, без www и без пути.'],
default => [],
};
}
private function resolveProject(): ?Project
{
if (! $this->projectResolved) {
$projectId = $this->route('id');
$this->resolvedProject = $projectId !== null ? Project::find($projectId) : null;
$this->projectResolved = true;
}
return $this->resolvedProject;
}
public function rules(): array
{
// signal_type immutable: не валидируется в правилах, controller игнорирует поле
@@ -80,14 +36,17 @@ class UpdateProjectRequest extends FormRequest
// 18.05.2026 UX: редактирование источника (signal_identifier) для site/call.
// Регулярки соответствуют StoreProjectRequest (domain + 7\d{10}).
// signal_type immutable — берём из текущего проекта по route id.
$project = $this->resolveProject();
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}$/'];
$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 (см. выше)
}
// sms: signal_identifier меняется через sms_senders/sms_keyword (см. выше)
}
return $rules;
@@ -1,53 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use App\Support\InnValidator;
use App\Support\PhoneNormalizer;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateRequisitesRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
/** @return array<string, mixed> */
public function rules(): array
{
$subjectType = (string) $this->input('subject_type');
return [
'subject_type' => ['required', Rule::in(['individual', 'sole_proprietor', 'legal_entity'])],
'contact_name' => ['required', 'string', 'max:255'],
'contact_phone' => ['required', 'string', function ($attr, $value, $fail) {
if (PhoneNormalizer::normalize((string) $value) === null) {
$fail('Некорректный телефон.');
}
}],
'inn' => [
Rule::requiredIf(in_array($subjectType, ['legal_entity', 'sole_proprietor'], true)),
'nullable', 'string',
function ($attr, $value, $fail) use ($subjectType) {
if (in_array($subjectType, ['legal_entity', 'sole_proprietor'], true)
&& is_string($value) && $value !== ''
&& ! InnValidator::isValid($value, $subjectType)) {
$fail('Некорректный ИНН (контрольная цифра).');
}
},
],
'legal_name' => ['nullable', 'string', 'max:255'],
'kpp' => ['nullable', 'string', 'regex:/^\d{9}$/'],
'ogrn' => ['nullable', 'string', 'regex:/^(\d{13}|\d{15})$/'],
'legal_address' => ['nullable', 'string'],
'bank_name' => ['nullable', 'string', 'max:255'],
'bank_bik' => ['nullable', 'string', 'regex:/^\d{9}$/'],
'bank_account' => ['nullable', 'string', 'regex:/^\d{20}$/'],
'corr_account' => ['nullable', 'string', 'regex:/^\d{20}$/'],
];
}
}
@@ -5,8 +5,6 @@ declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\Project;
use App\Services\Project\ProjectRuleMessages;
use App\Services\Project\SupplierSnapshotGuard;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -15,23 +13,6 @@ class ProjectResource extends JsonResource
{
public function toArray(Request $request): array
{
// Состояние блокировки источника для UI (read-only). hasLinks — из eager-loaded
// supplier_projects_count (анти-N+1); fallback на exists() если count не загружен.
$hasLinks = $this->supplier_projects_count !== null
? (int) $this->supplier_projects_count > 0
: $this->supplierProjects()->exists();
$sourceLock = (new SupplierSnapshotGuard)->lockState(
hasLinks: $hasLinks,
isActive: (bool) $this->is_active,
pausedAt: $this->paused_at,
);
// Эпик 6.3: единый текст правила смены источника (из ProjectRuleMessages) —
// диалог подтверждения на экране тянет его отсюда, не дублирует строку в JS.
$sourceChangeMessage = $sourceLock['locked'] && $sourceLock['unlock_at'] !== null
? (new ProjectRuleMessages)->sourceChanged($sourceLock['unlock_at'])
: null;
return [
'id' => $this->id,
'name' => $this->name,
@@ -50,8 +31,6 @@ class ProjectResource extends JsonResource
'delivery_days_mask' => $this->delivery_days_mask,
'sync_status' => $this->aggregateSyncStatus(),
'last_synced_at' => $this->aggregateLastSyncedAt(),
// H (балансовый блок): проект приостановлен из-за нехватки баланса (read-only для UI).
'balance_blocked' => $this->preflight_blocked_at !== null,
'supplier_links' => $this->when(
$request->routeIs('projects.show'),
fn () => $this->getSupplierLinks(),
@@ -60,12 +39,6 @@ class ProjectResource extends JsonResource
// ProjectService::update() для slepok-sensitive правок. UI показывает
// «изменения вступят в силу с DD.MM HH:MM МСК».
'applies_from' => $this->applies_from?->toIso8601String(),
// Блокировка смены источника (спека 2026-06-22-project-source-edit-lock-ux).
'source_locked' => $sourceLock['locked'],
'source_unlock_at' => $sourceLock['unlock_at']?->toIso8601String(),
'source_unlock_projected' => $sourceLock['projected'],
// Эпик 6.3: единый текст правила (бэкенд — источник истины для строк).
'source_change_message' => $sourceChangeMessage,
];
}
}
@@ -1,33 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\TenantRequisites;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin TenantRequisites */
class RequisitesResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'subject_type' => $this->subject_type,
'contact_name' => $this->contact_name,
'contact_phone' => $this->contact_phone,
'inn' => $this->inn,
'legal_name' => $this->legal_name,
'kpp' => $this->kpp,
'ogrn' => $this->ogrn,
'legal_address' => $this->legal_address,
'bank_name' => $this->bank_name,
'bank_bik' => $this->bank_bik,
'bank_account' => $this->bank_account,
'corr_account' => $this->corr_account,
'requisites_completed_at' => $this->requisites_completed_at,
];
}
}
@@ -8,7 +8,6 @@ use App\Mail\BalanceFrozenFinalMail;
use App\Mail\BalanceFrozenReminderMail;
use App\Models\PricingTier;
use App\Models\Tenant;
use App\Repositories\PricingTierRepository;
use App\Services\Billing\BalancePreflightService;
use App\Services\Billing\PreflightResult;
use Illuminate\Bus\Queueable;
@@ -48,8 +47,7 @@ final class BalanceFrozenReminderJob implements ShouldQueue
public function handle(): void
{
$service = new BalancePreflightService;
// Косяк 01: действующая версия тарифа по дате (как списание/витрина), а не «по-простому».
$tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow'));
$tiers = PricingTier::query()->where('is_active', true)->get();
Tenant::query()
->whereNotNull('frozen_by_balance_at')
@@ -6,12 +6,11 @@ namespace App\Jobs\Billing;
use App\Jobs\SyncSupplierProjectJob;
use App\Mail\BalanceFrozenMail;
use App\Mail\BalanceUnfrozenMail;
use App\Models\PricingTier;
use App\Models\Tenant;
use App\Repositories\PricingTierRepository;
use App\Services\Billing\BalancePreflightService;
use App\Services\Billing\PreflightResult;
use App\Services\Billing\ProjectBlockReleaseService;
use App\Services\Supplier\SupplierExportMode;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -38,8 +37,7 @@ final class BalancePreflightSweepJob implements ShouldQueue
public function handle(): void
{
$service = new BalancePreflightService;
// Косяк 01: действующая версия тарифа по дате (как списание/витрина), а не «по-простому».
$tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow'));
$tiers = PricingTier::query()->where('is_active', true)->get();
Tenant::query()->whereNull('deleted_at')->chunkById(200, function (Collection $tenants) use ($service, $tiers): void {
foreach ($tenants as $tenant) {
@@ -71,34 +69,51 @@ final class BalancePreflightSweepJob implements ShouldQueue
$isFrozen = $tenant->frozen_by_balance_at !== null;
if (! $result->passes) {
// Переход active → frozen (разморозку/снятие блоков здесь НЕ делаем —
// заморозка главнее, см. иерархию J спеки balance-lock-unify-FJ).
if (! $isFrozen) {
$freezeAt = now();
$tenant->frozen_by_balance_at = $freezeAt;
$tenant->save();
// Переход active → frozen.
if (! $result->passes && ! $isFrozen) {
$freezeAt = now();
$tenant->frozen_by_balance_at = $freezeAt;
$tenant->save();
// Stage 3 R-13 (spec §4.3.2): помечаем все непаузнутые проекты
// тенанта моментом заморозки. Это даёт SupplierSnapshotGuard
// зацепку (paused_at свежее grace-периода) — клиент не сможет
// удалить/сменить источник пока хвост слепка ещё может прилететь.
DB::connection('pgsql_supplier')->table('projects')
->where('tenant_id', $tenant->id)
->whereNull('paused_at')
->update(['paused_at' => $freezeAt]);
// Stage 3 R-13 (spec §4.3.2): помечаем все непаузнутые проекты
// тенанта моментом заморозки. Это даёт SupplierSnapshotGuard
// зацепку (paused_at свежее grace-периода) — клиент не сможет
// удалить/сменить источник пока хвост слепка ещё может прилететь.
DB::connection('pgsql_supplier')->table('projects')
->where('tenant_id', $tenant->id)
->whereNull('paused_at')
->update(['paused_at' => $freezeAt]);
$this->logEvent($tenant, 'frozen', 'cutoff_18msk', $result);
Mail::queue(new BalanceFrozenMail($tenant, $result));
$this->dispatchSupplierSyncIfOnline($tenant);
}
$this->logEvent($tenant, 'frozen', 'cutoff_18msk', $result);
Mail::queue(new BalanceFrozenMail($tenant, $result));
$this->dispatchSupplierSyncIfOnline($tenant);
return; // заморожен и не хватает — стабильное состояние, блоки не трогаем.
return;
}
// passes → единый путь разблокировки (D6): разморозить клиента (если был, J)
// + снять блоки всех проектов (F). Идемпотентно: нет замков → no-op.
(new ProjectBlockReleaseService)->releaseForTenant($tenant->id);
// Переход frozen → active.
if ($result->passes && $isFrozen) {
// Stage 3 R-13: фиксируем frozen-moment ДО $tenant->save() — нужно
// для фильтра отката paused_at. Очищаем только те проекты,
// у которых paused_at >= frozen_at_was (== поставленные нами на паузу
// в freeze-блоке). Ручные паузы клиента ДО заморозки имеют
// paused_at < frozen_at_was и сохраняются.
$frozenAtWas = $tenant->frozen_by_balance_at;
$tenant->frozen_by_balance_at = null;
$tenant->save();
DB::connection('pgsql_supplier')->table('projects')
->where('tenant_id', $tenant->id)
->where('paused_at', '>=', $frozenAtWas)
->update(['paused_at' => null]);
$this->logEvent($tenant, 'unfrozen', 'cutoff_18msk', $result);
Mail::queue(new BalanceUnfrozenMail($tenant, $result));
$this->dispatchSupplierSyncIfOnline($tenant);
return;
}
// Иначе состояние не изменилось — ничего не делаем (идемпотентность).
});
}
+9 -139
View File
@@ -11,22 +11,18 @@ use App\Models\Project;
use App\Models\SupplierLead;
use App\Models\Tenant;
use App\Services\Billing\LedgerService;
use App\Services\Dto\RegionResolution;
use App\Services\LeadDistributor;
use App\Services\LeadRegionResolver;
use App\Services\LeadRouter;
use App\Services\NotificationService;
use App\Services\Pd\PdAuditLogger;
use App\Services\RegionTagResolver;
use App\Services\SupplierProjects\SupplierProjectResolver;
use App\Support\RussianRegions;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable as FoundationQueueable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@@ -132,6 +128,7 @@ class RouteSupplierLeadJob implements ShouldQueue
// Capture original error BEFORE update — $lead->update() mutates
// the in-memory model, so $lead->error after update() returns the
// suffixed value, breaking debug logs (review fix).
// быстрый коммит
$originalError = $lead->error;
$lead->update([
'processed_at' => now(),
@@ -151,27 +148,16 @@ class RouteSupplierLeadJob implements ShouldQueue
$supplier = $resolver->resolveOrStub($platform, $signalType, $identifier);
$lead->update(['supplier_project_id' => $supplier->id]);
// Lead region resolution (§3.11): резолв региона ДО routing-цикла, чтобы HTTP-вызов
// DaData (~150мс) не висел внутри tenant-транзакции. Резолвер — из контейнера (не 7-й
// параметр handle(), чтобы не ломать сигнатуру и существующие вызовы тестов).
// RegionTagResolver остаётся в DI-цепочке резолвера (fallback-слой).
$resolution = app(LeadRegionResolver::class)->resolve($lead);
$lead->update([
'resolved_subject_code' => $resolution->subjectCode,
'region_source' => $resolution->source,
'dadata_qc' => $resolution->qc,
'phone_operator' => $resolution->phoneOperator,
]);
$matched = $router->matchEligibleProjects($supplier);
$selected = $distributor->selectRecipients($matched); // cap=3 случайных
// Каскад по региону (§3.9): exact → all-RF → fallback. NULL subject_code → шаг 1 пропуск.
$matched = $router->matchEligibleProjects($supplier, $resolution->subjectCode);
$selected = $distributor->selectRecipients($matched);
$subjectCode = $tagResolver->resolve((string) ($lead->raw_payload['tag'] ?? ''));
$createdCount = 0;
$failures = [];
foreach ($selected as $project) {
try {
if ($this->createDealCopyForProject($lead, $project, $notifier, $ledger, $resolution)) {
if ($this->createDealCopyForProject($lead, $project, $notifier, $ledger, $subjectCode)) {
$createdCount++;
}
} catch (Throwable $e) {
@@ -192,10 +178,6 @@ class RouteSupplierLeadJob implements ShouldQueue
);
}
// Аудит резолва региона — одна строка на лид (§3.10/§7.1). Fail-safe: сбой записи
// аудит-лога НЕ должен ронять доставку лида (revenue-critical, 30k/сутки).
$this->logRegionResolution($lead, $resolution, $selected);
$lead->update([
'processed_at' => now(),
'deals_created_count' => $createdCount,
@@ -258,14 +240,10 @@ class RouteSupplierLeadJob implements ShouldQueue
Project $project,
NotificationService $notifier,
LedgerService $ledger,
RegionResolution $resolution,
?int $subjectCode,
): bool {
// routing_step проставлен LeadRouter'ом на matched-проекте; захватываем ДО
// переназначения $project = $lockedProject (fresh query без этого атрибута).
$routingStep = (int) ($project->routing_step ?? 1);
try {
return DB::transaction(function () use ($lead, $project, $notifier, $ledger, $resolution, $routingStep): bool {
return DB::transaction(function () use ($lead, $project, $notifier, $ledger, $subjectCode): bool {
DB::statement("SET LOCAL app.current_tenant_id = '{$project->tenant_id}'");
/** @var Tenant $tenant */
@@ -376,21 +354,10 @@ class RouteSupplierLeadJob implements ShouldQueue
// INITIALLY DEFERRED не помогает — проверка падает на COMMIT).
// CSV-recovered received_at сохраняем как есть — отличие на минуты
// несущественно, чем риск каскадного DELETE lead_charges.
// §3.12: при merge обновляем регион/оператора, если webhook-резолв из
// источника выше рангом (dadata/rossvyaz), чем tag CSV-восстановления.
// deals не хранит region_source (он на supplier_leads + в журнале), поэтому
// ранг определяем по факту источника: dadata/rossvyaz всегда достовернее
// tag'а, на котором строилась CSV-recovery (RegionResolution::SOURCE_RANK).
$mergeUpdate = ['source_crm_id' => $lead->vid, 'updated_at' => now()];
if (in_array($resolution->source, ['dadata', 'rossvyaz'], true) && $resolution->subjectCode !== null) {
$mergeUpdate['subject_code'] = $resolution->subjectCode;
$mergeUpdate['phone_operator'] = $resolution->phoneOperator;
$mergeUpdate['city'] = RussianRegions::CODE_TO_NAME[$resolution->subjectCode] ?? null;
}
DB::table('deals')
->where('id', $existingMergeable->id)
->where('received_at', $existingMergeable->received_at)
->update($mergeUpdate);
->update(['source_crm_id' => $lead->vid, 'updated_at' => now()]);
Log::info('supplier_lead.merged_into_csv_recovered', [
'supplier_lead_id' => $lead->id,
@@ -427,13 +394,6 @@ class RouteSupplierLeadJob implements ShouldQueue
? array_values(array_map('strval', $payload['phones']))
: [(string) $lead->phone];
// §3.10: на шаге 3 (запасной канал) регион сделки подменяется на регион
// клиента (первый подписанный субъект из snapshot); настоящий регион —
// в lead_region_resolution_log.actual_subject_code. region_substituted флажит подмену.
$dealSubjectCode = $routingStep < 3
? $resolution->subjectCode
: ($this->pickSubstituteRegion((string) ($snapshot->regions ?? '{}')) ?? $resolution->subjectCode);
$deal = Deal::create([
'tenant_id' => $tenant->id,
'source_crm_id' => $lead->vid,
@@ -442,14 +402,7 @@ class RouteSupplierLeadJob implements ShouldQueue
'phones' => $phones,
'status' => 'new',
'received_at' => $receivedAt,
'subject_code' => $dealSubjectCode,
// «Город» (UI deals.city) — человекочитаемое имя НАСТОЯЩЕГО региона лида
// по резолву (даже если subject_code подменён на шаге 3). NULL → колонка пустая.
'city' => $resolution->subjectCode !== null
? (RussianRegions::CODE_TO_NAME[$resolution->subjectCode] ?? null)
: null,
'phone_operator' => $resolution->phoneOperator,
'region_substituted' => $routingStep === 3,
'subject_code' => $subjectCode,
]);
DB::table('supplier_lead_deliveries')
@@ -547,89 +500,6 @@ class RouteSupplierLeadJob implements ShouldQueue
]);
}
/**
* Аудит резолва региона лида одна строка на лид в lead_region_resolution_log (§7.1).
* Fail-safe: сбой записи (например, отсутствие партиции received_at) логируется warning'ом,
* но НЕ прерывает доставку (revenue-critical). INSERT через pgsql_supplier (GRANT INSERT
* у crm_supplier_worker). Телефон маскируется до INSERT сырой номер в лог не пишется.
*
* @param Collection<int, Project> $selected
*/
private function logRegionResolution(SupplierLead $lead, RegionResolution $resolution, Collection $selected): void
{
try {
$first = $selected->first();
$routingStep = $first !== null ? (int) ($first->routing_step ?? 1) : null;
$substituted = ($routingStep === 3 && $first !== null)
? ($this->pickSubstituteRegion((string) ($first->snapshot_regions ?? '{}')) ?? $resolution->subjectCode)
: null;
$tagCode = app(RegionTagResolver::class)->resolve((string) ($lead->raw_payload['tag'] ?? ''));
DB::connection(self::DB_CONNECTION)->table('lead_region_resolution_log')->insert([
'supplier_lead_id' => $lead->id,
'received_at' => $lead->received_at ?? now(),
'phone_masked' => $this->maskPhone((string) $lead->phone),
'subject_code_resolved' => $resolution->subjectCode,
'subject_code_from_tag' => $tagCode,
'region_source' => $resolution->source,
'dadata_qc' => $resolution->qc,
'dadata_provider' => $resolution->phoneOperator,
'dadata_type' => null,
'dadata_response_masked' => $resolution->dadataResponseMasked !== null
? json_encode($resolution->dadataResponseMasked, JSON_UNESCAPED_UNICODE)
: null,
'rossvyaz_matched' => $resolution->rossvyazMatched,
'actual_subject_code' => $resolution->actualSubjectCode,
'substituted_subject_code' => $substituted,
'routing_step' => $routingStep,
'phone_operator' => $resolution->phoneOperator,
'cache_hit' => $resolution->cacheHit,
'duration_ms' => $resolution->durationMs,
]);
} catch (Throwable $e) {
Log::warning('lead_region_resolution.log_write_failed', [
'supplier_lead_id' => $lead->id,
'exception' => $e->getMessage(),
]);
}
}
/**
* Первый код субъекта из PG INT[]-литерала ('{82,83}' 82; '{}' null) регион клиента
* для подмены на запасном канале (§3.10).
*/
private function pickSubstituteRegion(string $regionsLiteral): ?int
{
return $this->parseSubjectCodes($regionsLiteral)[0] ?? null;
}
/**
* @return list<int> '{82,83}' [82,83]; '{}'/'' []
*/
private function parseSubjectCodes(string $regionsLiteral): array
{
$inner = trim($regionsLiteral, '{}');
if ($inner === '') {
return [];
}
return array_values(array_map('intval', explode(',', $inner)));
}
/**
* Маскирование телефона для лога (§7.1): первые 4 + последние 4 цифры (7916***4567).
*/
private function maskPhone(string $phone): string
{
$digits = preg_replace('/\D+/', '', $phone) ?? '';
if (strlen($digits) < 8) {
return '***';
}
return substr($digits, 0, 4).'***'.substr($digits, -4);
}
/**
* Финальный callback после исчерпания всех ретраев ($tries=3).
*
-78
View File
@@ -1,78 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Deal;
use App\Models\Tenant;
use App\Services\NotificationService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
/**
* G2-A: раз в 30 минут (routes/console.php) рассылает дайджест новых сделок.
* Окно последние 30 минут по received_at.
*
* Идемпотентность по СДЕЛКЕ (N-4): окно даёт защиту только при ровно-30-мин
* прогонах; ручной/повторный прогон (R3b велит дёргать вручную) перекрывает окно.
* Поэтому каждая сделка, попавшая в дайджест, помечается в Redis (TTL 1 сутки)
* повторно в дайджест не включается. Пометка ставится ПОСЛЕ успешной отправки
* (mark-after-send): падение джоба до неё оставит сделки непомеченными очередь
* повторит (at-least-once вместо тихой потери). Один воркер гонки нет.
*/
final class SendNewLeadsDigestJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
public function handle(NotificationService $notifier): void
{
Tenant::query()->whereNull('deleted_at')->chunkById(200, function (EloquentCollection $tenants) use ($notifier): void {
foreach ($tenants as $tenant) {
/** @var Tenant $tenant */
$this->digestForTenant($tenant, $notifier);
}
});
}
private function digestForTenant(Tenant $tenant, NotificationService $notifier): void
{
DB::transaction(function () use ($tenant, $notifier): void {
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $tenant->id);
$deals = Deal::query()
->where('tenant_id', $tenant->id)
->where('received_at', '>', now()->subMinutes(30))
->where('is_test', false)
->whereNull('deleted_at')
->orderBy('received_at')
->get();
// N-4: исключаем сделки, уже попавшие в прошлый дайджест (без side-effect).
$fresh = $deals->reject(
fn (Deal $deal): bool => Cache::has('digest_sent:'.$deal->id)
);
if ($fresh->isEmpty()) {
return;
}
$notifier->notifyNewLeadsDigest($tenant, $fresh);
// N-4: помечаем ТОЛЬКО ПОСЛЕ успешного возврата notify. Падение джоба
// до этой точки оставит сделки непомеченными → очередь повторит прогон
// (at-least-once вместо тихой потери дайджеста). Один воркер (см.
// prod-logic-map §18.4) → гонки между прогонами нет. TTL 1 сутки.
foreach ($fresh as $deal) {
Cache::put('digest_sent:'.$deal->id, 1, now()->addDay());
}
});
}
}
+2 -3
View File
@@ -20,7 +20,7 @@ use Illuminate\Support\Facades\Log;
*/
final class SnapshotProjectRoutingJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
use Dispatchable, Queueable, InteractsWithQueue, SerializesModels;
public const DB_CONNECTION = 'pgsql_supplier'; // BYPASSRLS
@@ -38,11 +38,10 @@ final class SnapshotProjectRoutingJob implements ShouldQueue
->exists();
if ($exists) {
Log::info('snapshot.already_exists', ['date' => $snapshotDate]);
return;
}
$count = DB::connection(self::DB_CONNECTION)->insert(<<<'SQL'
$count = DB::connection(self::DB_CONNECTION)->insert(<<<SQL
INSERT INTO project_routing_snapshots (
snapshot_date, project_id, tenant_id,
daily_limit, delivery_days_mask, regions,
+3 -40
View File
@@ -6,12 +6,9 @@ namespace App\Jobs\Supplier;
use App\Jobs\RouteSupplierLeadJob;
use App\Mail\CsvDriftAlertMail;
use App\Mail\SupplierCriticalAlertMail;
use App\Mail\TenantBusinessDriftAlertMail;
use App\Models\SupplierLead;
use App\Services\Supplier\SupplierCsvParser;
use App\Services\Supplier\SupplierPortalClient;
use Carbon\CarbonInterface;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Cache\LockProvider;
use Illuminate\Contracts\Mail\Mailer;
@@ -63,9 +60,6 @@ final class CsvReconcileJob implements ShouldQueue
private const LOCK_TTL_SECONDS = 600;
/** UI-аудит 21.06: не чаще 1 алерта о падении сверки за это окно (анти-спам). */
private const FAILURE_ALERT_THROTTLE_HOURS = 6;
public function handle(
SupplierPortalClient $portal,
SupplierCsvParser $parser,
@@ -218,26 +212,6 @@ final class CsvReconcileJob implements ShouldQueue
$this->detectAndAlertBusinessDrift($mailer, $windowStart, $windowEnd);
} catch (Throwable $e) {
// UI-аудит 21.06: раньше падение сверки писалось в лог status=failed,
// но НИКОГО не уведомляло (алерт слался только на drift) — а heartbeat
// показывал «OK» (Schedule::job меряет постановку в очередь, не результат).
// Из-за этого заход к поставщику падал каждые 30 мин ~3 недели незаметно.
// Теперь: при падении шлём critical-алерт на ops-email, троттл 6ч.
$alertSent = false;
if (! $this->failureAlertRecentlySent()) {
try {
$mailer->to((string) config('services.supplier.alert_email'))
->send(new SupplierCriticalAlertMail(
alertType: 'csv_reconcile_failed',
details: 'Сверка с поставщиком (CsvReconcileJob) падает. Ошибка: '
.substr($e->getMessage(), 0, 500),
));
$alertSent = true;
} catch (Throwable $mailError) {
Log::error('csv_reconcile.failure_alert_send_failed', ['error' => $mailError->getMessage()]);
}
}
// $logId === null — упал сам insertGetId, log-строки нет, обновлять нечего.
if ($logId !== null) {
DB::connection(self::DB_CONNECTION)
@@ -247,7 +221,6 @@ final class CsvReconcileJob implements ShouldQueue
'finished_at' => now(),
'status' => 'failed',
'error_message' => substr($e->getMessage(), 0, 1000),
'alert_email_sent_at' => $alertSent ? now() : null,
]);
}
throw $e;
@@ -264,16 +237,6 @@ final class CsvReconcileJob implements ShouldQueue
return trim($phone).'|'.trim($project);
}
/** Был ли алерт о падении сверки за последнее окно троттла (анти-спам). */
private function failureAlertRecentlySent(): bool
{
return DB::connection(self::DB_CONNECTION)
->table('supplier_csv_reconcile_log')
->whereNotNull('alert_email_sent_at')
->where('alert_email_sent_at', '>=', now()->subHours(self::FAILURE_ALERT_THROTTLE_HOURS))
->exists();
}
/**
* Извлекает platform из имени проекта:
* - `B[123]_<rest>` 'B1' / 'B2' / 'B3';
@@ -311,8 +274,8 @@ final class CsvReconcileJob implements ShouldQueue
private function detectAndAlertBusinessDrift(
Mailer $mailer,
CarbonInterface $windowStart,
CarbonInterface $windowEnd,
\Carbon\CarbonInterface $windowStart,
\Carbon\CarbonInterface $windowEnd,
): void {
$from = $windowStart->toDateString();
$to = $windowEnd->toDateString();
@@ -337,7 +300,7 @@ final class CsvReconcileJob implements ShouldQueue
}
$mailer->to((string) config('services.supplier.alert_email'))
->send(new TenantBusinessDriftAlertMail(
->send(new \App\Mail\TenantBusinessDriftAlertMail(
tenantId: (int) $row->tenant_id,
snapshotDate: (string) $row->snapshot_date,
expected: $expected,
@@ -6,7 +6,6 @@ namespace App\Jobs\Supplier;
use App\Models\SupplierProject;
use App\Services\Supplier\SupplierPortalClient;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@@ -60,20 +59,6 @@ class DeleteSupplierProjectJob implements ShouldQueue
continue;
}
// Task 2.4: пока летит хвост слепка по этому источнику — НЕ удаляем донора.
// Удалив supplier_project, мы оборвём матч хвостового лида (LeadRouter ищет
// snapshot по источнику донора). Откладываем: повтор на следующем
// CleanupInactiveSupplierProjectsJob (02:00), когда хвост уже долетит.
if ($this->hasActiveSnapshotTail($sp)) {
Log::info('supplier.delete_donor_deferred_snapshot_tail', [
'supplier_project_id' => $id,
'signal_type' => $sp->signal_type,
'unique_key' => $sp->unique_key,
]);
continue;
}
if ($sp->supplier_external_id !== null && $sp->supplier_external_id !== '') {
try {
$client->deleteProject((int) $sp->supplier_external_id);
@@ -92,48 +77,4 @@ class DeleteSupplierProjectJob implements ShouldQueue
SyncSupplierProjectsJob::dispatch();
}
}
/**
* Есть ли снимок маршрутизации за активную дату (сегодня/завтра МСК), чей источник
* матчится на этого донора. Зеркалит предикат LeadRouter::queryCandidates (source-ветка):
* sms (sms_senders->>0) || ('+'||keyword) = unique_key;
* site lower(signal_identifier) = lower(unique_key) ИЛИ поддомен '%.'||unique_key;
* call lower(signal_identifier) = lower(unique_key).
*
* Если матч есть хвостовой лид по этому источнику ещё может прийти, донор нужен.
*/
private function hasActiveSnapshotTail(SupplierProject $sp): bool
{
$today = Carbon::today('Europe/Moscow')->toDateString();
$tomorrow = Carbon::tomorrow('Europe/Moscow')->toDateString();
$query = DB::connection(self::DB_CONNECTION)
->table('project_routing_snapshots')
->whereIn('snapshot_date', [$today, $tomorrow]);
if ($sp->signal_type === 'sms') {
$query->whereRaw(
"signal_type = 'sms' AND (
(sms_senders ->> 0)
|| CASE WHEN COALESCE(sms_keyword, '') <> '' THEN '+' || sms_keyword ELSE '' END
) = ?",
[(string) $sp->unique_key],
);
} elseif ($sp->signal_type === 'site') {
$query->whereRaw(
"signal_type = 'site' AND (
LOWER(signal_identifier) = LOWER(?)
OR LOWER(signal_identifier) LIKE '%.' || LOWER(?)
)",
[(string) $sp->unique_key, (string) $sp->unique_key],
);
} else {
$query->whereRaw(
'signal_type = ? AND LOWER(signal_identifier) = LOWER(?)',
[(string) $sp->signal_type, (string) $sp->unique_key],
);
}
return $query->exists();
}
}
@@ -1,58 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Supplier;
use App\Jobs\SyncSupplierProjectJob;
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;
/**
* Досыл отложенной онлайн-очереди (Task 4.2).
*
* Онлайн-правки, попавшие в окно 18:00→00:00 МСК, складываются в supplier_deferred_sync
* (SyncSupplierProjectJob::handle, Task 4.1). Этот джоб в 00:05 МСК (вне окна) для каждого
* отложенного проекта диспатчит SyncSupplierProjectJob тот уже отработает немедленно
* и очищает строку. Запускается ПОСЛЕ сброса счётчиков в 00:00.
*
* Системная очередь под crm_supplier_worker (BYPASSRLS), как все supplier-flow джобы.
*
* Spec: docs/superpowers/specs/2026-06-25-snapshot-source-routing-design.md (Эпик 4).
*/
class FlushDeferredOnlineSyncJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 60;
public const string DB_CONNECTION = 'pgsql_supplier';
public function handle(): void
{
$projectIds = DB::connection(self::DB_CONNECTION)
->table('supplier_deferred_sync')
->orderBy('project_id')
->pluck('project_id');
foreach ($projectIds as $projectId) {
SyncSupplierProjectJob::dispatch((int) $projectId);
DB::connection(self::DB_CONNECTION)
->table('supplier_deferred_sync')
->where('project_id', $projectId)
->delete();
}
if ($projectIds->isNotEmpty()) {
Log::info('FlushDeferredOnlineSyncJob: flushed '.$projectIds->count().' deferred online sync(s)');
}
}
}
+42 -120
View File
@@ -83,14 +83,6 @@ class SyncSupplierProjectsJob implements ShouldQueue
$this->client = app(SupplierPortalClient::class);
$consecutiveTransient = 0;
// Эпик 5: сводка по завершении заливки (для экрана SaaS-admin).
$startedAt = now();
$syncedOk = 0;
$manualQueued = 0;
$deferred = 0;
$failed = 0;
$aborted = false;
// 1. Load active Лидерра-projects via pgsql_supplier (фильтруя frozen, Billing v2 Spec C §3.10).
$projects = $this->collectEligibleProjects();
@@ -135,112 +127,56 @@ class SyncSupplierProjectsJob implements ShouldQueue
}
// 3. Sync each group
try {
foreach ($groups as $group) {
if (now()->timezone('Europe/Moscow')->format('H:i') >= self::TIME_BUDGET_CUTOFF) {
Log::warning('supplier.sync.time_budget_reached', [
'group' => $group['identifier'],
]);
$aborted = true;
foreach ($groups as $group) {
if (now()->timezone('Europe/Moscow')->format('H:i') >= self::TIME_BUDGET_CUTOFF) {
Log::warning('supplier.sync.time_budget_reached', [
'group' => $group['identifier'],
]);
break;
}
try {
$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(
alertType: 'sticky_auth',
details: $e->getMessage(),
));
report($e);
throw $e;
} catch (SupplierTransientException $e) {
$consecutiveTransient++;
$this->logGroupFailure($group, $e);
if ($consecutiveTransient >= self::MASS_FAIL_THRESHOLD) {
Mail::to((string) config('services.supplier.alert_email'))
->queue(new SupplierCriticalAlertMail(
alertType: 'mass_transient',
details: "Aborted after {$consecutiveTransient} consecutive transient failures.",
));
report(new \RuntimeException('Supplier outage suspected: mass transient failures'));
break;
}
try {
$this->syncGroup($group);
$consecutiveTransient = 0;
$syncedOk++;
} catch (TierEscalatedException $e) {
$manualQueued++;
Log::info("SyncSupplierProjectsJob: group {$group['identifier']} escalated to manual queue #{$e->queueRowId}, reason: {$e->reason}");
continue;
} catch (SupplierClientException $e) {
$this->logGroupFailure($group, $e);
report($e);
continue;
} catch (WindowDeferredException) {
$deferred++;
Log::info("SyncSupplierProjectsJob: group {$group['identifier']} deferred by portal window");
continue;
} catch (SupplierAuthException $e) {
$aborted = true;
Mail::to((string) config('services.supplier.alert_email'))
->queue(new SupplierCriticalAlertMail(
alertType: 'sticky_auth',
details: $e->getMessage(),
));
report($e);
throw $e;
} catch (SupplierTransientException $e) {
$consecutiveTransient++;
$failed++;
$this->logGroupFailure($group, $e);
if ($consecutiveTransient >= self::MASS_FAIL_THRESHOLD) {
$aborted = true;
Mail::to((string) config('services.supplier.alert_email'))
->queue(new SupplierCriticalAlertMail(
alertType: 'mass_transient',
details: "Aborted after {$consecutiveTransient} consecutive transient failures.",
));
report(new \RuntimeException('Supplier outage suspected: mass transient failures'));
break;
}
continue;
} catch (SupplierClientException $e) {
$failed++;
$this->logGroupFailure($group, $e);
report($e);
continue;
}
continue;
}
} finally {
$this->recordRunSummary(
startedAt: $startedAt,
groupsTotal: count($groups),
syncedOk: $syncedOk,
manualQueued: $manualQueued,
deferred: $deferred,
failed: $failed,
aborted: $aborted,
);
}
}
/**
* Эпик 5: одна строка-сводка в supplier_sync_runs по завершении заливки.
* Пишется и при раннем abort (time-budget / mass-fail / auth) через finally.
*/
private function recordRunSummary(
Carbon $startedAt,
int $groupsTotal,
int $syncedOk,
int $manualQueued,
int $deferred,
int $failed,
bool $aborted,
): void {
if ($aborted) {
$status = 'aborted';
} elseif ($failed > 0 && $syncedOk === 0) {
$status = 'failed';
} elseif ($failed > 0 || $manualQueued > 0) {
$status = 'partial';
} else {
$status = 'ok';
}
DB::connection(self::DB_CONNECTION)->table('supplier_sync_runs')->insert([
'started_at' => $startedAt,
'finished_at' => now(),
'groups_total' => $groupsTotal,
'synced_ok' => $syncedOk,
'manual_queued' => $manualQueued,
'deferred' => $deferred,
'failed' => $failed,
'status' => $status,
'created_at' => now(),
]);
}
/**
* Собрать eligible Лидерра-проекты для расчёта заказа поставщику.
*
@@ -284,10 +220,6 @@ class SyncSupplierProjectsJob implements ShouldQueue
'snap.daily_limit AS snap_daily_limit',
'snap.delivery_days_mask AS snap_delivery_days_mask',
'snap.regions AS snap_regions',
// Task 2.6: источник тоже из слепка — закрыт рассинхрон 18:02→18:05.
'snap.signal_identifier AS snap_signal_identifier',
'snap.sms_senders AS snap_sms_senders',
'snap.sms_keyword AS snap_sms_keyword',
)
->orderBy('projects.id')
->get();
@@ -299,16 +231,6 @@ class SyncSupplierProjectsJob implements ShouldQueue
$project->daily_limit_target = (int) $project->getAttribute('snap_daily_limit');
$project->delivery_days_mask = (int) $project->getAttribute('snap_delivery_days_mask');
$project->regions = $this->parsePostgresIntArray((string) $project->getAttribute('snap_regions'));
// Task 2.6: источник из слепка (signal_identifier / sms_senders / sms_keyword).
// snap_sms_senders приходит как JSONB-строка ('["Caranga"]') или null —
// декодируем в массив под cast 'array' модели Project.
$project->signal_identifier = $project->getAttribute('snap_signal_identifier');
$project->sms_keyword = $project->getAttribute('snap_sms_keyword');
$rawSenders = $project->getAttribute('snap_sms_senders');
$project->sms_senders = $rawSenders === null
? null
: json_decode((string) $rawSenders, true);
}
return $projects;
-24
View File
@@ -88,36 +88,12 @@ class SyncSupplierProjectJob implements ShouldQueue
}
if (SupplierExportMode::isOnline()) {
// Task 4.1: в окне 18:00→00:00 МСК онлайн НЕ шлёт поставщику немедленно —
// он уже фиксирует заказ слепком в 21:00, мгновенная правка перезаписала бы
// зафиксированное состояние. Откладываем в очередь; FlushDeferredOnlineSyncJob
// (00:05 МСК) досылает вне окна.
if ($this->isInDeferWindow()) {
DB::connection(self::DB_CONNECTION)
->table('supplier_deferred_sync')
->insertOrIgnore([
'project_id' => $project->id,
'created_at' => now(),
]);
Log::info("SyncSupplierProjectJob: project {$project->id} deferred (online window 18:00-00:00 МСК)");
return;
}
$this->handleOnline($project, $channel);
} else {
$this->handleBatch($project, $channel);
}
}
/** Час МСК ≥ 18 (окно 18:00→00:00) — онлайн откладывает отправку до полуночи. */
public const DEFER_WINDOW_START_HOUR_MSK = 18;
private function isInDeferWindow(): bool
{
return Carbon::now('Europe/Moscow')->hour >= self::DEFER_WINDOW_START_HOUR_MSK;
}
// -------------------------------------------------------------------------
// Online mode: per-subject full-param sync
// -------------------------------------------------------------------------
-64
View File
@@ -1,64 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Logging;
use Monolog\LogRecord;
use Monolog\Processor\ProcessorInterface;
/**
* Monolog-процессор: маскирует ПДн в логах перед записью.
*
* Закрывает Medium go-live: laravel.log (LOG_LEVEL=debug) мог сохранить телефон/email
* открытым, если они попадут в текст исключения или контекст. Процессор ловит ВСЕ
* записи каналов, к которым подключён (см. App\Logging\ScrubPii + config/logging.php),
* централизованно надёжнее правки отдельных вызовов Log::.
*/
final class PiiScrubbingProcessor implements ProcessorInterface
{
/**
* Телефоны РФ: 11 цифр в формате 7XXXXXXXXXX / 8XXXXXXXXXX / +7XXXXXXXXXX.
* Lookbehind/lookahead не дают маскировать часть более длинной цифровой строки
* (например 14-значный технический id).
*/
private const PHONE_PATTERN = '/(?<!\d)(?:\+?7|8)\d{10}(?!\d)/';
private const EMAIL_PATTERN = '/[\p{L}0-9._%+\-]+@[\p{L}0-9.\-]+\.\p{L}{2,}/u';
public function __invoke(LogRecord $record): LogRecord
{
return $record->with(
message: $this->scrub($record->message),
context: $this->scrubArray($record->context),
extra: $this->scrubArray($record->extra),
);
}
private function scrub(string $value): string
{
$value = preg_replace(self::PHONE_PATTERN, '[PHONE]', $value) ?? $value;
return preg_replace(self::EMAIL_PATTERN, '[EMAIL]', $value) ?? $value;
}
/**
* @param array<array-key, mixed> $data
* @return array<array-key, mixed>
*/
private function scrubArray(array $data): array
{
$result = [];
foreach ($data as $key => $value) {
if (is_string($value)) {
$result[$key] = $this->scrub($value);
} elseif (is_array($value)) {
$result[$key] = $this->scrubArray($value);
} else {
$result[$key] = $value;
}
}
return $result;
}
}
-25
View File
@@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Logging;
use Illuminate\Log\Logger;
/**
* Tap для config/logging.php: вешает PiiScrubbingProcessor на канал.
*
* Использование: 'tap' => [\App\Logging\ScrubPii::class] в описании канала.
*/
final class ScrubPii
{
public function __invoke(Logger $logger): void
{
// Illuminate\Log\Logger::getLogger() типизирован как PSR LoggerInterface,
// но фактически возвращает Monolog\Logger (у него есть pushProcessor).
$monolog = $logger->getLogger();
if ($monolog instanceof \Monolog\Logger) {
$monolog->pushProcessor(new PiiScrubbingProcessor);
}
}
}
@@ -1,41 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Письмо с 6-значным кодом подтверждения почты при самозаписи (G1/SP1).
*/
final class EmailVerificationCodeMail extends Mailable
{
use Queueable;
use SerializesModels;
public function __construct(
public readonly string $code,
public readonly string $email,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Код подтверждения регистрации в Лидерре',
to: [$this->email],
);
}
public function content(): Content
{
return new Content(
view: 'emails.email_verification_code',
with: ['code' => $this->code],
);
}
}
-36
View File
@@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/** Код-согласие на вход поддержки в кабинет клиента (G7-B / Ю-1). */
final class ImpersonationCodeMail extends Mailable
{
use Queueable;
use SerializesModels;
public function __construct(
public readonly string $code,
public readonly string $email,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Код доступа: запрос входа поддержки в ваш кабинет',
to: [$this->email],
);
}
public function content(): Content
{
return new Content(view: 'emails.impersonation_code', with: ['code' => $this->code]);
}
}
-36
View File
@@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/** Уведомление о завершении сессии поддержки в кабинете клиента (G7-B / Ю-1). */
final class ImpersonationEndedMail extends Mailable
{
use Queueable;
use SerializesModels;
public function __construct(
public readonly string $email,
public readonly ?string $tenantName = null,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Сессия поддержки в вашем кабинете завершена',
to: [$this->email],
);
}
public function content(): Content
{
return new Content(view: 'emails.impersonation_ended', with: ['tenantName' => $this->tenantName]);
}
}
-53
View File
@@ -1,53 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Models\Deal;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;
/**
* Письмо-сводка о новых сделках за окно (G2-A дайджест).
* Заменяет пер-лид NewLeadNotification как email-канал события new_lead.
*
* @property Collection<int, Deal> $deals
*/
class NewLeadsDigestMail extends Mailable
{
use Queueable;
use SerializesModels;
public function __construct(
public User $user,
public Tenant $tenant,
public Collection $deals,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Лидерра. Новые сделки — '.$this->deals->count(),
);
}
public function content(): Content
{
return new Content(
view: 'emails.new_leads_digest',
with: [
'user' => $this->user,
'tenant' => $this->tenant,
'deals' => $this->deals,
'count' => $this->deals->count(),
],
);
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Models\Reminder;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Email-уведомление о наступлении срока напоминания (ТЗ §18.5, событие reminder).
*
* Триггер: cron-команда `reminders:dispatch-due` находит rows с
* `is_sent=false AND completed_at IS NULL AND remind_at <= NOW()`,
* вызывает NotificationService::notifyReminder для каждой,
* затем ставит `is_sent=true, sent_at=NOW()`.
*/
class ReminderDueNotification extends Mailable
{
use Queueable;
use SerializesModels;
public function __construct(
public User $recipient,
public Reminder $reminder,
) {}
public function envelope(): Envelope
{
$shortText = $this->reminder->text
? mb_substr($this->reminder->text, 0, 60)
: 'Срок касания клиента';
return new Envelope(
subject: "Лидерра. Напоминание — {$shortText}",
);
}
public function content(): Content
{
return new Content(
view: 'emails.reminder',
with: [
'recipient' => $this->recipient,
'reminder' => $this->reminder,
],
);
}
}
-38
View File
@@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Models\SupportRequest;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Письмо в техподдержку о новой заявке клиента (G7-A). Адресат config('services.support.email').
*/
class SupportRequestMail extends Mailable
{
use Queueable;
use SerializesModels;
public function __construct(public SupportRequest $request) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Лидерра. Заявка в поддержку #'.$this->request->id,
);
}
public function content(): Content
{
return new Content(
view: 'emails.support_request',
with: ['r' => $this->request],
);
}
}
-4
View File
@@ -61,9 +61,6 @@ class Deal extends Model
'is_test',
'received_at',
'deleted_at',
// Lead region resolution (Session 1, 31.05.2026).
'phone_operator',
'region_substituted',
];
protected function casts(): array
@@ -80,7 +77,6 @@ class Deal extends Model
'lead_score' => 'decimal:2',
'phones' => 'array',
'is_test' => 'boolean',
'region_substituted' => 'boolean',
'assigned_at' => 'datetime',
'received_at' => 'datetime',
'created_at' => 'datetime',
-71
View File
@@ -1,71 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
/**
* Код подтверждения почты при самозаписи клиента (G1/SP1).
*
* 6-значный код, bcrypt-хеш в code_hash, plain уходит письмом. TTL 15 мин,
* 5 попыток. Механика зеркалит ImpersonationToken. Таблица RLS-изолирована
* (через user_id→users.tenant_id) на публичном роуте читается/пишется через
* BYPASSRLS pgsql_supplier.
*
* @property int $id
* @property int $user_id
* @property string $email
* @property string $token
* @property string|null $code_hash
* @property int $failed_attempts
* @property Carbon $expires_at
* @property Carbon|null $verified_at
* @property Carbon $created_at
*/
class EmailVerification extends Model
{
/** schema-таблица не имеет updated_at. */
public const UPDATED_AT = null;
protected $fillable = [
'user_id',
'email',
'token',
'code_hash',
'failed_attempts',
'expires_at',
'verified_at',
];
protected function casts(): array
{
return [
'failed_attempts' => 'integer',
'expires_at' => 'datetime',
'verified_at' => 'datetime',
'created_at' => 'datetime',
];
}
public function isExpired(): bool
{
return $this->expires_at->isPast();
}
public function isUsable(): bool
{
return $this->verified_at === null
&& $this->failed_attempts < 5
&& ! $this->isExpired();
}
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
-16
View File
@@ -32,7 +32,6 @@ use Illuminate\Support\Carbon;
* @property int|null $second_approver_id
* @property Carbon|null $second_approval_at
* @property Carbon $created_at
* @property string|null $session_token_hash
*
* @mixin IdeHelperImpersonationToken
*/
@@ -55,7 +54,6 @@ class ImpersonationToken extends Model
'invalidated_at',
'second_approver_id',
'second_approval_at',
'session_token_hash',
];
protected function casts(): array
@@ -83,20 +81,6 @@ class ImpersonationToken extends Model
&& ! $this->isExpired();
}
/** Сессия impersonation активна: код подтверждён, не завершена, не инвалидирована, в пределах TTL минут. */
public function isSessionActive(int $ttlMinutes = 60): bool
{
return $this->used_at !== null
&& $this->session_ended_at === null
&& $this->invalidated_at === null
&& $this->used_at->copy()->addMinutes($ttlMinutes)->isFuture();
}
public function sessionExpiresAt(int $ttlMinutes = 60): ?Carbon
{
return $this->used_at?->copy()->addMinutes($ttlMinutes);
}
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
-24
View File
@@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* Юрлицо-получатель платежей (schema.sql table legal_entities). Без RLS.
*
* @mixin IdeHelperLegalEntity
*/
class LegalEntity extends Model
{
public $timestamps = false;
protected $fillable = [
'code', 'name', 'short_name', 'legal_form', 'inn', 'kpp', 'ogrn',
'okpo', 'legal_address', 'actual_address', 'bank_name', 'bank_account',
'bank_bik', 'bank_corr', 'director_name', 'director_post',
'director_basis', 'vat_mode',
];
}
-50
View File
@@ -1,50 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Crypt;
/**
* Платёжный шлюз (schema.sql table payment_gateways).
* `config` хранится ЗАШИФРОВАННЫМ (Crypt::encrypt JSON {shop_id, secret_key}). Без RLS.
*
* @mixin IdeHelperPaymentGateway
*/
class PaymentGateway extends Model
{
public $timestamps = false;
protected $fillable = [
'code', 'name', 'driver', 'legal_entity_id', 'config', 'is_active',
'accepts_methods', 'min_amount_rub', 'max_amount_rub', 'sort_order',
];
protected function casts(): array
{
return [
'is_active' => 'boolean',
'accepts_methods' => 'array',
'min_amount_rub' => 'decimal:2',
'max_amount_rub' => 'decimal:2',
];
}
/** Расшифрованные креденшелы шлюза; [] если config пустой/битый. */
public function credentials(): array
{
if ($this->config === null || $this->config === '') {
return [];
}
try {
$decoded = Crypt::decrypt($this->config);
} catch (\Throwable) {
return [];
}
return is_array($decoded) ? $decoded : [];
}
}
-42
View File
@@ -1,42 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\QueryException;
use Laravel\Sanctum\PersonalAccessToken as SanctumPersonalAccessToken;
/**
* Расширение Sanctum PersonalAccessToken.
*
* Перехватывает Bearer-токены с префиксом «lpimp_» (машинные ключи
* impersonation-guard, G7-B) и возвращает null без обращения к БД.
* Это предотвращает crash при отсутствии таблицы personal_access_tokens
* (проект использует SPA cookie-auth, таблица не создаётся).
*/
class PersonalAccessToken extends SanctumPersonalAccessToken
{
/**
* Find the token instance matching the given token.
*
* Returns null immediately for impersonation machine-key tokens so
* Sanctum does not attempt a DB lookup on personal_access_tokens.
*/
public static function findToken($token): ?static
{
if (str_starts_with((string) $token, 'lpimp_')) {
return null;
}
// В проекте нет таблицы personal_access_tokens (SPA cookie-auth, Sanctum
// PAT не используются). Без этого try/catch любой иной Bearer на
// sanctum-роуте ронял бы запрос в 500 (Undefined table) вместо чистого
// 401. Гасим QueryException до null — guard вернёт 401.
try {
return parent::findToken($token);
} catch (QueryException) {
return null;
}
}
}
+88
View File
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Database\Factories\ReminderFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Напоминание на сделке (schema v8.10 §17.5).
*
* Tenant-aware модель с RLS. Композитные индексы:
* - idx_reminders_due (remind_at) WHERE is_sent=FALSE AND completed_at IS NULL cron;
* - idx_reminders_deal (deal_id) UI карточки сделки;
* - idx_reminders_tenant_user_active дашборд «today/last/future».
*
* deal_id БЕЗ FK (deals партиционирована, FK на partitioned-родительскую
* таблицу не поддерживается без partition-key в составе).
*
* MVP: assignee_id всегда NULL паритет с histories[].to оригинала. Поле
* зарезервировано для Post-MVP (multi-assignee).
*
* @mixin IdeHelperReminder
*/
class Reminder extends Model
{
/** @use HasFactory<ReminderFactory> */
use HasFactory;
protected $fillable = [
'tenant_id',
'deal_id',
'text',
'remind_at',
'created_by',
'assignee_id',
'completed_at',
'is_sent',
'sent_at',
];
protected function casts(): array
{
return [
'tenant_id' => 'integer',
'deal_id' => 'integer',
'created_by' => 'integer',
'assignee_id' => 'integer',
'is_sent' => 'boolean',
'remind_at' => 'datetime',
'completed_at' => 'datetime',
'sent_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
}
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
/** @return BelongsTo<User, $this> */
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
/** @return BelongsTo<User, $this> */
public function assignee(): BelongsTo
{
return $this->belongsTo(User::class, 'assignee_id');
}
public function isCompleted(): bool
{
return $this->completed_at !== null;
}
public function isOverdue(): bool
{
return $this->completed_at === null && $this->remind_at < now();
}
}
-45
View File
@@ -1,45 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* Платёж SaaS (schema.sql table saas_transactions). RLS tenant_isolation.
* status {pending, success, failed, refunded}; type {topup, refund, manual_credit, manual_debit}.
*
* @mixin IdeHelperSaasTransaction
*/
class SaasTransaction extends Model
{
public const CREATED_AT = 'created_at';
public const UPDATED_AT = null;
public const STATUS_PENDING = 'pending';
public const STATUS_SUCCESS = 'success';
public const STATUS_FAILED = 'failed';
public const TYPE_TOPUP = 'topup';
protected $fillable = [
'tenant_id', 'type', 'amount_rub', 'balance_rub_after', 'leads_credited',
'gateway_id', 'gateway_code', 'gateway_payment_id', 'gateway_idempotence_key',
'payment_method', 'legal_entity_id', 'invoice_id', 'upd_id', 'status',
'description', 'failure_reason', 'balance_transaction_id', 'created_at', 'completed_at',
];
protected function casts(): array
{
return [
'amount_rub' => 'decimal:2',
'balance_rub_after' => 'decimal:2',
'created_at' => 'datetime',
'completed_at' => 'datetime',
];
}
}
-15
View File
@@ -18,14 +18,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
*
* Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §5.1
*
* Поля резолва региона (lead-region) аннотированы явно ide-helper:models
* не подхватил их в стаб IdeHelperSupplierLead:
*
* @property int|null $dadata_qc
* @property string|null $phone_operator
* @property string|null $region_source
* @property int|null $resolved_subject_code
*
* @mixin IdeHelperSupplierLead
*/
class SupplierLead extends Model
@@ -49,11 +41,6 @@ class SupplierLead extends Model
'recovered_from_csv_at',
'deals_created_count',
'error',
// Lead region resolution (Session 1, 31.05.2026) — persistent idempotency + display.
'resolved_subject_code',
'region_source',
'dadata_qc',
'phone_operator',
];
protected function casts(): array
@@ -65,8 +52,6 @@ class SupplierLead extends Model
'recovered_from_csv_at' => 'datetime',
'vid' => 'integer',
'deals_created_count' => 'integer',
'resolved_subject_code' => 'integer',
'dadata_qc' => 'integer',
];
}
-35
View File
@@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
/**
* Заявка клиента в техподдержку (G7-A). RLS по tenant_id; created_at DB default.
*
* @property int $id
* @property int $tenant_id
* @property int $user_id
* @property string $name
* @property string $contact
* @property string $message
* @property Carbon $created_at
*/
class SupportRequest extends Model
{
protected $table = 'support_requests';
public $timestamps = false; // только created_at (DB DEFAULT now()), updated_at нет
protected $fillable = [
'tenant_id', 'user_id', 'name', 'contact', 'message',
];
protected function casts(): array
{
return ['created_at' => 'datetime'];
}
}
+62 -7
View File
@@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\DB;
/**
* Тенант клиент SaaS-портала Лидерра.
@@ -90,13 +91,67 @@ class Tenant extends Model
*/
public function requiredLeadsForTomorrow(): int
{
// D2 (23.06.2026, спека balance-lock-unify-FJ): единый расчёт по ПОЛНОМУ
// лимиту активных проектов. Откат share-aware (R-19) — решение владельца:
// один расчёт во всех точках (заморозка/разморозка, снятие проектного блока),
// чтобы замки вели себя предсказуемо.
return (int) $this->projects()
->where('is_active', true)
->sum('daily_limit_target');
// R-19 (Stage 4 §4.4.3): share-aware preflight. For each active project
// count the tenant's PROPORTIONAL share of the supplier group order (not
// the raw daily_limit_target), since the supplier caps the group at
// max(max(limits), ceil(Σ/3)) and splits it across all clients sharing
// the same signal_identifier. Legacy projects (signal_type=null —
// webhook-only, no supplier sharing) still count their full limit.
$projects = $this->projects()->where('is_active', true)->get();
if ($projects->isEmpty()) {
return 0;
}
$total = 0;
foreach ($projects as $p) {
// Webhook-only legacy projects don't participate in supplier sharing.
if (! in_array($p->signal_type, ['site', 'call', 'sms'], true)) {
$total += (int) $p->daily_limit_target;
continue;
}
$groupLimits = DB::connection('pgsql_supplier')
->table('projects')
->where('is_active', true)
->where('signal_type', $p->signal_type)
->where(function ($q) use ($p): void {
if (in_array($p->signal_type, ['site', 'call'], true)) {
$q->where('signal_identifier', $p->signal_identifier);
} else {
// sms: agnostic group is (first sender, keyword-or-NULL).
$firstSender = (string) ($p->sms_senders[0] ?? '');
$q->whereJsonContains('sms_senders', $firstSender);
if ($p->sms_keyword !== null && $p->sms_keyword !== '') {
$q->where('sms_keyword', $p->sms_keyword);
} else {
$q->whereNull('sms_keyword');
}
}
})
->pluck('daily_limit_target')
->all();
if ($groupLimits === []) {
// Edge: project not yet visible from pgsql_supplier view (cross-conn race).
// Conservatively count full limit — avoids underestimating preflight.
$total += (int) $p->daily_limit_target;
continue;
}
$intLimits = array_map('intval', $groupLimits);
$sum = (int) array_sum($intLimits);
$max = (int) max($intLimits);
$groupOrder = max($max, (int) ceil($sum / 3));
if ($sum > 0) {
$share = (int) ceil($groupOrder * ((int) $p->daily_limit_target / $sum));
$total += $share;
}
}
return $total;
}
/** @return BelongsTo<TariffPlan, $this> */
-59
View File
@@ -1,59 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
/**
* Реквизиты тенанта (G1/SP2). 1:1 с tenants. RLS по tenant_id.
*
* Свойства аннотированы явно: `ide-helper:models` пропускает эту модель при
* интроспекции (стаб IdeHelperTenantRequisites не генерируется), поэтому
*
* @property-аннотации заданы вручную по db/schema.sql (tenant_requisites).
*
* @property int $id
* @property int $tenant_id
* @property string $subject_type
* @property string $contact_name
* @property string $contact_phone
* @property string|null $inn
* @property string|null $legal_name
* @property string|null $kpp
* @property string|null $ogrn
* @property string|null $legal_address
* @property string|null $bank_name
* @property string|null $bank_bik
* @property string|null $bank_account
* @property string|null $corr_account
* @property array<string, mixed>|null $dadata_raw
* @property Carbon|null $dadata_synced_at
* @property Carbon|null $requisites_completed_at
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
*/
class TenantRequisites extends Model
{
protected $table = 'tenant_requisites';
protected $fillable = [
'tenant_id', 'subject_type', 'contact_name', 'contact_phone',
'inn', 'legal_name', 'kpp', 'ogrn', 'legal_address',
'bank_name', 'bank_bik', 'bank_account', 'corr_account',
'dadata_raw', 'dadata_synced_at', 'requisites_completed_at',
];
protected function casts(): array
{
return [
'dadata_raw' => 'array',
'dadata_synced_at' => 'datetime',
'requisites_completed_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
}
}
+1 -130
View File
@@ -2,32 +2,14 @@
namespace App\Providers;
use App\Models\ImpersonationToken;
use App\Models\PersonalAccessToken;
use App\Models\User;
use App\Services\Billing\Gateway\PaymentGatewayDriver;
use App\Services\Billing\Gateway\PaymentGatewayManager;
use App\Services\Captcha\CaptchaVerifier;
use App\Services\Captcha\NullCaptchaVerifier;
use App\Services\Captcha\YandexSmartCaptchaVerifier;
use App\Services\DaData\DaDataPartyClient;
use App\Services\DaData\NullPartyLookup;
use App\Services\DaData\PartyLookup;
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\Auth\Notifications\ResetPassword;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Laravel\Sanctum\Sanctum;
class AppServiceProvider extends ServiceProvider
{
@@ -52,39 +34,6 @@ class AppServiceProvider extends ServiceProvider
$app->make(Mailer::class),
),
);
// Шов капчи самозаписи (G1/SP1 + M-2). Драйвер по CAPTCHA_DRIVER:
// 'yandex' → реальный Yandex SmartCaptcha; иначе (включая 'null') → Null
// (dev/test). Контроллер/RegistrationService зовут только интерфейс.
$this->app->bind(
CaptchaVerifier::class,
fn () => match (config('services.captcha.driver')) {
'yandex' => new YandexSmartCaptchaVerifier,
default => new NullCaptchaVerifier,
},
);
// Шов подтяжки организации по ИНН (G1/SP2). По флагу party_enabled —
// реальный DaData suggestions; иначе Null (dev/тесты не ходят в сеть).
$this->app->bind(
PartyLookup::class,
fn ($app) => config('services.dadata.party_enabled')
? $app->make(DaDataPartyClient::class)
: $app->make(NullPartyLookup::class),
);
// Шов платёжного драйвера (Task 6, billing-yookassa). Резолвит активный
// шлюз через PaymentGatewayManager и возвращает конкретный драйвер.
// Бросает RuntimeException если шлюз не настроен (fail-fast в тестах).
$this->app->bind(PaymentGatewayDriver::class, function ($app) {
$manager = $app->make(PaymentGatewayManager::class);
$gateway = $manager->activeGateway();
if ($gateway === null) {
throw new \RuntimeException('Нет активного платёжного шлюза');
}
return $manager->driverFor($gateway);
});
}
/**
@@ -92,84 +41,6 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
// FN-RESET (приёмка 22.06.2026): дефолтное Laravel-уведомление ResetPassword
// строит ссылку через route('password.reset'), которого в SPA нет — роут
// сброса объявлен только на фронте (Vue Router /reset/:token, name
// 'reset-password'; backend знает лишь Route::view('/reset','welcome')).
// Без этого toMail() бросает «Route [password.reset] not defined» → письмо
// со ссылкой не уходит никому, сброс пароля сломан для всех.
// Формат ссылки — ровно тот, что разбирает ResetPasswordView.vue:
// {app.url}/reset/{token}?email={urlencoded}.
ResetPassword::createUrlUsing(
fn (User $user, string $token): string => rtrim((string) config('app.url'), '/')
.'/reset/'.$token
.'?email='.urlencode($user->getEmailForPasswordReset()),
);
// P1 go-live: per-IP route-throttle поверх прикладного per-credential
// rate-limit в auth-контроллерах. Именованные лимитеры изолируют счётчики
// login / 2fa / password. Применение — throttle:<name> в routes/web.php.
// 20/мин — стартовое значение (выше максимума тестовых циклов), снижать по
// боевому трафику. Runbook: docs/superpowers/runbooks/2026-06-17-auth-throttle-limits.md
foreach (['auth-login', 'auth-2fa', 'auth-password', 'auth-register'] as $limiterName) {
RateLimiter::for(
$limiterName,
fn (Request $request) => Limit::perMinute(20)->by($request->ip() ?: 'unknown'),
);
}
// apiv1-rate (приёмка 21.06): публичный read-API сделок (/api/v1/deals)
// прикрыт per-источник лимитом 120/мин ПЕРЕД ApiKeyAuth — режет brute/DoS
// по ключам и снимает нагрузку bcrypt/DB до аутентификации. Ключ лимитера —
// сам Bearer-ключ (sha256, «per ключ»); без заголовка — fallback на IP.
RateLimiter::for('api-v1', function (Request $request) {
$header = (string) $request->header('Authorization', '');
$bearer = str_starts_with($header, 'Bearer ')
? trim(substr($header, 7))
: '';
$by = $bearer !== ''
? 'k:'.hash('sha256', $bearer)
: 'ip:'.($request->ip() ?: 'unknown');
return Limit::perMinute(120)->by($by);
});
// G7-B: заменяем Sanctum PersonalAccessToken на расширение, которое
// возвращает null для lpimp_-токенов без запроса к personal_access_tokens.
// Проект использует SPA cookie-auth — таблица personal_access_tokens отсутствует.
Sanctum::usePersonalAccessTokenModel(PersonalAccessToken::class);
// G7-B: кастомный auth-guard «impersonation».
// По Bearer-токену вида lpimp_<id>_<secret> резолвит первого активного
// пользователя тенанта из impersonation_tokens.
// Запросы к БД идут через pgsql_supplier (BYPASSRLS), чтобы не упереться
// в RLS до SetTenantContext (middleware «tenant» применяется позже).
Auth::viaRequest('impersonation', function (Request $request) {
$header = (string) $request->header('Authorization', '');
if (! str_starts_with($header, 'Bearer lpimp_')) {
return null;
}
$raw = trim(substr($header, 7)); // lpimp_<id>_<secret>
$parts = explode('_', $raw, 3);
if (count($parts) !== 3 || $parts[0] !== 'lpimp' || ! ctype_digit($parts[1])) {
return null;
}
$idStr = $parts[1];
$secret = $parts[2];
$token = ImpersonationToken::on('pgsql_supplier')->find((int) $idStr);
if ($token === null || $token->session_token_hash === null || ! $token->isSessionActive()) {
return null;
}
if (! Hash::check($secret, (string) $token->session_token_hash)) {
return null;
}
return User::on('pgsql_supplier')
->where('tenant_id', $token->tenant_id)
->where('is_active', true)
->orderBy('id')
->first();
});
//
}
}
@@ -1,21 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Auth;
use RuntimeException;
/**
* Доменная ошибка самозаписи. reason машинный код для ответа контроллера:
* email_taken | captcha_failed | not_found | expired | too_many_attempts | invalid_code.
*/
final class RegistrationException extends RuntimeException
{
public function __construct(
public readonly string $reason,
public readonly ?int $attemptsRemaining = null,
) {
parent::__construct($reason);
}
}
@@ -1,270 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Auth;
use App\Mail\EmailVerificationCodeMail;
use App\Models\EmailVerification;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Captcha\CaptchaVerifier;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
/**
* Оркестрация самозаписи (G1/SP1): register / confirm / resend.
*
* Все обращения к tenants/users/email_verifications через BYPASSRLS-подключение
* pgsql_supplier: публичные роуты не выставляют app.current_tenant_id, и под RLS
* (роль crm_app_user) SELECT/INSERT по этим таблицам не прошёл бы.
*
* Гонка дублей: в схеме нет глобального UNIQUE(users.email) (только
* UNIQUE(tenant_id,email)), поэтому «проверка-потом-вставка» сериализуется
* advisory-локом по email внутри транзакции два параллельных register на один
* новый email не создадут два тенанта (лок снимается на commit/rollback).
*/
class RegistrationService
{
private const DB_CONNECTION = 'pgsql_supplier';
private const CODE_TTL_MINUTES = 15;
private const MAX_FAILED_ATTEMPTS = 5;
private const START_BALANCE_RUB = '300.00';
public function __construct(private readonly CaptchaVerifier $captcha) {}
/**
* @return array{status:string, user:User, verification:EmailVerification, dev_code:?string}
*/
public function register(string $email, string $password, ?string $captchaToken, ?string $ip): array
{
if (! $this->captcha->verify($captchaToken, $ip)) {
throw new RegistrationException('captcha_failed');
}
$email = mb_strtolower(trim($email));
$conn = DB::connection(self::DB_CONNECTION);
// Сериализация одновременных регистраций одного email (TOCTOU-защита, см. docblock).
// Письмо отправляем ПОСЛЕ commit — не держим SMTP внутри транзакции.
$issued = $this->atomic(function () use ($conn, $email, $password) {
$conn->statement('SELECT pg_advisory_xact_lock(hashtext(?))', ['liderra:self-register:'.$email]);
$existing = User::on(self::DB_CONNECTION)->where('email', $email)->first();
if ($existing && $existing->is_active) {
throw new RegistrationException('email_taken');
}
$user = $existing ?: $this->createPendingTenantOwner($email, $password);
return $this->createCodeRecord($user);
});
$this->sendCode($issued['user']->email, $issued['plain']);
return [
'status' => 'pending_email_confirm',
'user' => $issued['user'],
'verification' => $issued['record'],
'dev_code' => $issued['dev_code'],
];
}
public function confirm(string $email, string $code): User
{
$email = mb_strtolower(trim($email));
$user = User::on(self::DB_CONNECTION)->where('email', $email)->first();
if (! $user) {
throw new RegistrationException('not_found');
}
$record = EmailVerification::on(self::DB_CONNECTION)
->where('user_id', $user->id)
->whereNull('verified_at')
->orderByDesc('id')
->first();
if (! $record || ! $record->isUsable()) {
$reason = $record === null ? 'not_found'
: ($record->isExpired() ? 'expired' : 'too_many_attempts');
throw new RegistrationException($reason);
}
if (! Hash::check($code, (string) $record->code_hash)) {
// increment ВНЕ транзакции: счётчик должен пережить 422 (откат сбросил
// бы failed_attempts и сломал лимит 5 попыток).
$record->increment('failed_attempts');
throw new RegistrationException(
'invalid_code',
max(0, self::MAX_FAILED_ATTEMPTS - $record->failed_attempts),
);
}
// Успех — атомарно: пометка кода + активация владельца + статус/баланс тенанта.
$this->atomic(function () use ($record, $user): void {
$record->update(['verified_at' => now()]);
// FN-3 (приёмка 22.06): email подтверждён кодом из письма — фиксируем
// факт верификации, иначе users.email_verified_at оставался NULL.
// forceFill: email_verified_at вне $fillable (guarded), mass-assign его гасит.
$user->forceFill(['is_active' => true, 'email_verified_at' => now()])->save();
Tenant::on(self::DB_CONNECTION)->where('id', $user->tenant_id)->update([
'status' => 'active',
'balance_rub' => self::START_BALANCE_RUB,
]);
});
return $user->fresh();
}
/** @return ?string dev-код (только local/testing), иначе null. Anti-enumeration: тихо для active/missing. */
public function resend(string $email): ?string
{
$email = mb_strtolower(trim($email));
$conn = DB::connection(self::DB_CONNECTION);
$issued = $this->atomic(function () use ($conn, $email) {
$conn->statement('SELECT pg_advisory_xact_lock(hashtext(?))', ['liderra:self-register:'.$email]);
$user = User::on(self::DB_CONNECTION)->where('email', $email)->first();
if ($user && ! $user->is_active) {
return $this->createCodeRecord($user);
}
return null;
});
if ($issued === null) {
return null;
}
$this->sendCode($issued['user']->email, $issued['plain']);
return $issued['dev_code'];
}
/**
* Выполнить $work атомарно на pgsql_supplier.
*
* Прод-путь: соединение НЕ в транзакции открываем свою (`transaction()`),
* advisory xact-lock держится до commit/rollback корректная сериализация.
*
* Если PDO УЖЕ в транзакции (внешний caller обернул нас ИЛИ тест-харнес
* SharesSupplierPdo делит уже-открытый PDO под DatabaseTransactions)
* участвуем в существующей транзакции без вложенного beginTransaction:
* pgsql_supplier-connection не отслеживает уровень внешней транзакции, и
* `transaction()` попытался бы `PDO::beginTransaction()` поверх открытой
* «There is already an active transaction». Это nested-transaction-safety,
* не тест-специфичная ветка: повторный вызов внутри открытой транзакции
* корректно переиспользует её.
*
* @template T
*
* @param callable():T $work
* @return T
*/
private function atomic(callable $work): mixed
{
$conn = DB::connection(self::DB_CONNECTION);
if ($conn->getPdo()->inTransaction()) {
return $work();
}
return $conn->transaction($work);
}
private function createPendingTenantOwner(string $email, string $password): User
{
$tenant = Tenant::on(self::DB_CONNECTION)->create([
'subdomain' => $this->generateSubdomain($email),
'organization_name' => $email, // плейсхолдер; уточняется в SP2 (реквизиты)
'contact_email' => $email,
'balance_rub' => 0,
'is_trial' => true,
]);
// tenants.status НЕ в $fillable модели Tenant (колонка DEFAULT 'active') —
// выставляем явно, минуя mass-assignment; иначе самозапись активировала бы
// тенанта до подтверждения почты (баг: tenant создавался 'active').
$tenant->status = 'pending_email_confirm';
$tenant->save();
return User::on(self::DB_CONNECTION)->create([
'tenant_id' => $tenant->id,
'email' => $email,
'password_hash' => Hash::make($password),
// §6: не подставляем фейковое «Новый клиент» — приветствие падает на
// нейтральное «коллега», аватар/имя берутся из email; настоящее имя
// клиент укажет в профиле (PATCH /me требует непустое имя).
'first_name' => '',
'last_name' => '',
'is_active' => false,
'totp_enabled' => false,
]);
}
/**
* @return array{user:User, record:EmailVerification, plain:string, dev_code:?string}
*/
private function createCodeRecord(User $user): array
{
// Гасим прежние непогашенные коды этого пользователя (делаем неюзабельными).
EmailVerification::on(self::DB_CONNECTION)
->where('user_id', $user->id)
->whereNull('verified_at')
->update(['failed_attempts' => self::MAX_FAILED_ATTEMPTS]);
$plain = (string) random_int(100_000, 999_999);
$record = EmailVerification::on(self::DB_CONNECTION)->create([
'user_id' => $user->id,
'email' => $user->email,
'token' => (string) Str::uuid(),
'code_hash' => Hash::make($plain),
'failed_attempts' => 0,
'expires_at' => now()->addMinutes(self::CODE_TTL_MINUTES),
]);
return [
'user' => $user,
'record' => $record,
'plain' => $plain,
'dev_code' => app()->environment('local', 'testing') ? $plain : null,
];
}
private function sendCode(string $email, string $plain): void
{
// Письмо ставим в очередь (не держим SMTP в HTTP-пути) и НЕ валим самозапись
// при сбое доставки: запись кода уже создана, клиент может «отправить повторно».
// Email в лог не пишем (ПДн §5.2) — только факт и текст ошибки.
try {
Mail::to($email)->queue(new EmailVerificationCodeMail($plain, $email));
} catch (\Throwable $e) {
Log::warning('register: не удалось поставить письмо с кодом в очередь: '.$e->getMessage());
}
}
private function generateSubdomain(string $email): string
{
$base = Str::of($email)->before('@')->lower()->replaceMatches('/[^a-z0-9]/', '')->value();
if ($base === '') {
$base = 'client';
}
$base = Str::limit($base, 50, '');
$candidate = $base;
$i = 0;
while (Tenant::on(self::DB_CONNECTION)->where('subdomain', $candidate)->exists()) {
$i++;
$candidate = $base.$i;
}
return Str::limit($candidate, 63, '');
}
}
@@ -25,10 +25,6 @@ use App\Models\Tenant;
*/
final class BillingTopupService
{
public function __construct(
private readonly ProjectBlockReleaseService $blockRelease = new ProjectBlockReleaseService,
) {}
/**
* Пополнить рублёвый баланс тенанта.
*
@@ -52,7 +48,7 @@ final class BillingTopupService
$tenant->balance_rub = $newBalanceRub;
$tenant->save();
$tx = BalanceTransaction::create([
return BalanceTransaction::create([
'tenant_id' => $tenant->id,
'type' => BalanceTransaction::TYPE_TOPUP,
'amount_rub' => $amountRub,
@@ -63,11 +59,5 @@ final class BillingTopupService
'user_id' => $userId,
'created_at' => now(),
]);
// E (балансовый блок): после зачисления — авто-снятие preflight_blocked_at по
// политике «всё-или-ничего» (хватает на весь заказ → снять блок со всех + синк).
$this->blockRelease->releaseForTenant($tenant->id);
return $tx;
}
}
@@ -1,14 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Billing\Gateway;
/** Результат создания платежа в шлюзе. */
final class CreatePaymentResult
{
public function __construct(
public readonly string $gatewayPaymentId,
public readonly string $confirmationUrl,
) {}
}
@@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Billing\Gateway;
use App\Models\PaymentGateway;
/** Контракт платёжного драйвера. Реализации: YooKassaDriver (далее — Tinkoff и т.п.). */
interface PaymentGatewayDriver
{
/**
* Создать платёж в шлюзе.
*
* @param string $amountRub DECIMAL-строка scale 2 («500.00»).
* @param string $idempotenceKey UUID защита от двойного создания.
* @param string $returnUrl Куда вернуть клиента после оплаты.
* @param array<string,mixed>|null $receipt Состав чека 54-ФЗ или null.
*/
public function createPayment(
PaymentGateway $gateway,
string $amountRub,
string $idempotenceKey,
string $returnUrl,
?array $receipt,
): CreatePaymentResult;
/**
* Сверить платёж server-to-server по его id (статус из ответа шлюза, не из webhook).
*/
public function verifyPayment(PaymentGateway $gateway, string $gatewayPaymentId): WebhookVerifyResult;
}
@@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Billing\Gateway;
use App\Models\PaymentGateway;
use RuntimeException;
/** Резолвер активного шлюза и его драйвера. */
final class PaymentGatewayManager
{
public function activeGateway(): ?PaymentGateway
{
return PaymentGateway::where('is_active', true)->orderBy('sort_order')->first();
}
public function driverFor(PaymentGateway $gateway): PaymentGatewayDriver
{
return match ($gateway->driver) {
'yookassa' => new YooKassaDriver,
default => throw new RuntimeException('Неизвестный драйвер шлюза: '.$gateway->driver),
};
}
}

Some files were not shown because too many files have changed in this diff Show More