Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e6beff6aeb | |||
| 6933ddc538 | |||
| 2a34ee880a | |||
| 1dc696cef6 | |||
| b29bfe2ac6 | |||
| 3fc5501dc5 |
@@ -28,10 +28,9 @@ class DashboardController extends Controller
|
||||
|
||||
public function summary(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = (int) $request->query('tenant_id', '0');
|
||||
if ($tenantId < 1) {
|
||||
return response()->json(['message' => 'Параметр tenant_id обязателен.'], 422);
|
||||
}
|
||||
// Go-live (audit J3): tenant_id из authed-user (auth:sanctum + tenant
|
||||
// middleware), НЕ из параметра запроса — закрывает кросс-tenant утечку KPI.
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
|
||||
$tenant = Tenant::find($tenantId);
|
||||
if ($tenant === null) {
|
||||
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -23,20 +22,18 @@ class ManagerController extends Controller
|
||||
/** GET /api/managers?tenant_id={id} */
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = (int) $request->query('tenant_id', '0');
|
||||
if ($tenantId < 1) {
|
||||
return response()->json(['message' => 'Параметр tenant_id обязателен.'], 422);
|
||||
}
|
||||
|
||||
$tenant = Tenant::find($tenantId);
|
||||
if ($tenant === null) {
|
||||
return response()->json(['message' => 'Тенант не найден.'], 404);
|
||||
}
|
||||
// Go-live: tenant_id из authed-user (auth:sanctum + tenant middleware),
|
||||
// НЕ из параметра запроса — закрывает кросс-tenant утечку списка пользователей.
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
|
||||
$users = DB::transaction(function () use ($tenantId) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
// Явный where(tenant_id) — defense-in-depth поверх RLS: роли с
|
||||
// BYPASSRLS (crm_supplier_worker / dev-superuser) RLS не применяют,
|
||||
// поэтому tenant-scope нельзя оставлять только на SET LOCAL.
|
||||
return User::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNull('deleted_at')
|
||||
->where('is_active', true)
|
||||
->orderBy('first_name')
|
||||
|
||||
@@ -6,11 +6,13 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\OutboundWebhookSubscription;
|
||||
use App\Support\WebhookUrlGuard;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
@@ -53,6 +55,16 @@ class WebhookSettingsController extends Controller
|
||||
'target_url' => ['required', 'string', 'url', 'max:2048', 'starts_with:https://'],
|
||||
]);
|
||||
|
||||
// SSRF-гард на сохранении: не даём записать URL во внутреннюю/служебную
|
||||
// сеть — тогда любой будущий потребитель (test() + будущая outbound-доставка
|
||||
// событий) читает из БД только безопасные адреса. NB: будущая доставка
|
||||
// обязана ВДОБАВОК звать WebhookUrlGuard перед отправкой (защита от
|
||||
// DNS-rebinding: хост сохранён публичным, позже переразрешается в приватный).
|
||||
$blockReason = WebhookUrlGuard::blockReason($validated['target_url']);
|
||||
if ($blockReason !== null) {
|
||||
throw ValidationException::withMessages(['target_url' => [$blockReason]]);
|
||||
}
|
||||
|
||||
$sub = $this->currentSubscription($request);
|
||||
$plainSecret = null;
|
||||
|
||||
@@ -95,14 +107,25 @@ class WebhookSettingsController extends Controller
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
// SSRF-гард: target_url задаёт админ тенанта; блокируем адреса во
|
||||
// внутренней/зарезервированной сети (cloud-metadata 169.254.169.254,
|
||||
// loopback, RFC1918), которые https://-валидация на сохранении не ловит.
|
||||
$blockReason = WebhookUrlGuard::blockReason($sub->target_url);
|
||||
if ($blockReason !== null) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'status' => null,
|
||||
'message' => $blockReason,
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$testPayload = [
|
||||
'event' => 'webhook.test',
|
||||
'sent_at' => now()->toIso8601String(),
|
||||
'message' => 'Тестовая доставка webhook от Лидерра.',
|
||||
];
|
||||
|
||||
// MVP: unsigned connectivity-проверка. SSRF-харднинг (блок приватных
|
||||
// IP) — пост-MVP security-review; URL уже ограничен https:// валидацией.
|
||||
// Unsigned connectivity-проверка (HMAC-подписанная доставка — отдельный эпик).
|
||||
try {
|
||||
$response = Http::timeout(10)
|
||||
->withHeaders(['X-Webhook-Event' => 'webhook.test'])
|
||||
|
||||
@@ -210,6 +210,10 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
$eligibleLimits = array_map(fn (Project $p) => (int) $p->daily_limit_target, $eligible);
|
||||
$order = SupplierQuotaAllocator::computeOrder($eligibleLimits);
|
||||
|
||||
// Split the group order across platforms so Σ per-platform == order. The portal does
|
||||
// NOT divide (verified live 2026-05-21) — the full order on each B = order ×N overspend.
|
||||
$shares = SupplierQuotaAllocator::distributeForPlatform($order, $platforms);
|
||||
|
||||
$workdaysUnion = [];
|
||||
foreach ($eligible as $p) {
|
||||
foreach ($this->bitmaskToList((int) $p->delivery_days_mask, 7) as $d) {
|
||||
@@ -235,24 +239,25 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
->get();
|
||||
|
||||
if ($existingSps->isEmpty()) {
|
||||
// Create path: saveProjectMultiFlag → [platform => external_id]
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: $platforms[0],
|
||||
signalType: $signalType,
|
||||
uniqueKey: $identifier,
|
||||
limit: $order,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: $platforms,
|
||||
);
|
||||
|
||||
$idMap = $this->client->saveProjectMultiFlag($dto);
|
||||
|
||||
// Upsert supplier_projects rows (one per platform)
|
||||
// Create path: one save PER platform with that platform's divided share
|
||||
// (single-flag save → exactly one rt-project, reliable id via listProjects match).
|
||||
// Throws propagate to handle() catch (failover-counter); rows persisted for earlier
|
||||
// platforms before a throw are recovered next run via the missing-set recovery below.
|
||||
foreach ($platforms as $platform) {
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: $platform,
|
||||
signalType: $signalType,
|
||||
uniqueKey: $identifier,
|
||||
limit: $shares[$platform] ?? 0,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: [$platform],
|
||||
);
|
||||
|
||||
$idMap = $this->client->saveProjectMultiFlag($dto);
|
||||
$externalId = $idMap[$platform] ?? null;
|
||||
if ($externalId === null) {
|
||||
continue;
|
||||
@@ -264,7 +269,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
'unique_key' => $identifier,
|
||||
'subject_code' => null,
|
||||
'supplier_external_id' => (string) $externalId,
|
||||
'current_limit' => $order,
|
||||
'current_limit' => $shares[$platform] ?? 0,
|
||||
'current_workdays' => $workdays,
|
||||
'current_regions' => $allRegions,
|
||||
'sync_status' => 'ok',
|
||||
@@ -297,23 +302,21 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
);
|
||||
|
||||
if ($deadSps->isNotEmpty()) {
|
||||
$deadPlatforms = array_values($deadSps->pluck('platform')->all());
|
||||
$recreateDto = new SupplierProjectDto(
|
||||
platform: $deadPlatforms[0],
|
||||
signalType: $signalType,
|
||||
uniqueKey: $identifier,
|
||||
limit: $order,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: $deadPlatforms,
|
||||
);
|
||||
|
||||
$recreatedIdMap = $this->client->saveProjectMultiFlag($recreateDto);
|
||||
|
||||
foreach ($deadSps as $sp) {
|
||||
$recreateDto = new SupplierProjectDto(
|
||||
platform: $sp->platform,
|
||||
signalType: $signalType,
|
||||
uniqueKey: $identifier,
|
||||
limit: $shares[$sp->platform] ?? 0,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: [$sp->platform],
|
||||
);
|
||||
|
||||
$recreatedIdMap = $this->client->saveProjectMultiFlag($recreateDto);
|
||||
$newId = $recreatedIdMap[$sp->platform] ?? null;
|
||||
if ($newId !== null) {
|
||||
$sp->forceFill(['supplier_external_id' => (string) $newId])->save();
|
||||
@@ -335,22 +338,21 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
$missingPlatforms = array_values(array_diff($platforms, $existingPlatforms));
|
||||
|
||||
if ($missingPlatforms !== []) {
|
||||
$missingDto = new SupplierProjectDto(
|
||||
platform: $missingPlatforms[0],
|
||||
signalType: $signalType,
|
||||
uniqueKey: $identifier,
|
||||
limit: $order,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: $missingPlatforms,
|
||||
);
|
||||
|
||||
$missingIdMap = $this->client->saveProjectMultiFlag($missingDto);
|
||||
|
||||
foreach ($missingPlatforms as $platform) {
|
||||
$missingDto = new SupplierProjectDto(
|
||||
platform: $platform,
|
||||
signalType: $signalType,
|
||||
uniqueKey: $identifier,
|
||||
limit: $shares[$platform] ?? 0,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: [$platform],
|
||||
);
|
||||
|
||||
$missingIdMap = $this->client->saveProjectMultiFlag($missingDto);
|
||||
$externalId = $missingIdMap[$platform] ?? null;
|
||||
if ($externalId === null) {
|
||||
continue;
|
||||
@@ -361,7 +363,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
'unique_key' => $identifier,
|
||||
'subject_code' => null,
|
||||
'supplier_external_id' => (string) $externalId,
|
||||
'current_limit' => $order,
|
||||
'current_limit' => $shares[$platform] ?? 0,
|
||||
'current_workdays' => $workdays,
|
||||
'current_regions' => $allRegions,
|
||||
'sync_status' => 'ok',
|
||||
@@ -377,9 +379,9 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
// Fix #2 (review-followup): per-platform DTO в update-loop, чтобы portal получал
|
||||
// правильные srcrt/srcbl/srcmt для конкретной редактируемой строки (не first()
|
||||
// из mixed-platform existing set). R6 one shared limit/regions сохраняется.
|
||||
// per-platform DTO в update-loop: portal получает правильные srcrt/srcbl/srcmt для
|
||||
// конкретной строки + её долю лимита ($shares), чтобы Σ по площадкам == order
|
||||
// (а не order на каждой). Regions/workdays общие для группы.
|
||||
foreach ($existingSps as $sp) {
|
||||
if ($sp->supplier_external_id === null) {
|
||||
continue;
|
||||
@@ -388,7 +390,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
platform: $sp->platform,
|
||||
signalType: $signalType,
|
||||
uniqueKey: $identifier,
|
||||
limit: $order,
|
||||
limit: $shares[$sp->platform] ?? 0,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
@@ -398,7 +400,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
);
|
||||
$this->channel->updateProject((int) $sp->supplier_external_id, $perPlatformDto);
|
||||
$sp->forceFill([
|
||||
'current_limit' => $order,
|
||||
'current_limit' => $shares[$sp->platform] ?? 0,
|
||||
'current_workdays' => $workdays,
|
||||
'current_regions' => $allRegions,
|
||||
'sync_status' => 'ok',
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Services\Supplier\Dto\SupplierProjectDto;
|
||||
use App\Services\Supplier\SupplierExportMode;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
use App\Services\Supplier\SupplierProjectGrouping;
|
||||
use App\Services\Supplier\SupplierQuotaAllocator;
|
||||
use App\Support\RussianRegions;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
@@ -60,11 +61,23 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
/** @var array<int, int> */
|
||||
public array $backoff = [15, 60, 300];
|
||||
|
||||
/**
|
||||
* BYPASSRLS-роль crm_supplier_worker для всех DB-операций (как у всех supplier-flow
|
||||
* джобов: SyncSupplierProjectsJob/DeleteSupplierProjectJob/CsvReconcileJob/…).
|
||||
*
|
||||
* Джоб запускается из очереди, где SetTenantContext-прослойка не отрабатывает и
|
||||
* app.current_tenant_id GUC не установлен. Под обычной ролью crm_app_user первый же
|
||||
* SELECT по projects падает 42704 (unrecognized configuration parameter
|
||||
* "app.current_tenant_id"). На dev не всплывало — там DB_USERNAME=postgres (superuser,
|
||||
* RLS обходится). Plan 3 Task 3 learning.
|
||||
*/
|
||||
public const DB_CONNECTION = 'pgsql_supplier';
|
||||
|
||||
public function __construct(public int $projectId) {}
|
||||
|
||||
public function handle(SupplierProjectChannel $channel): void
|
||||
{
|
||||
$project = Project::find($this->projectId);
|
||||
$project = Project::on(self::DB_CONNECTION)->find($this->projectId);
|
||||
|
||||
if ($project === null) {
|
||||
Log::warning("SyncSupplierProjectJob: project {$this->projectId} not found — skipping");
|
||||
@@ -104,43 +117,22 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
|
||||
$workdays = $this->workdaysFromMask((int) $project->delivery_days_mask);
|
||||
|
||||
// Split the limit across the platforms so Σ per-platform limits == project limit.
|
||||
// The portal does NOT divide (verified live 2026-05-21) — replicating the full limit
|
||||
// to B1/B2/B3 = order ×N (overspend). See SupplierQuotaAllocator::distributeForPlatform.
|
||||
$shares = SupplierQuotaAllocator::distributeForPlatform((int) $project->daily_limit_target, $platforms);
|
||||
|
||||
// Idempotency: find existing by identifier regardless of subject_code (any previous run).
|
||||
$existingSps = SupplierProject::query()
|
||||
$existingSps = SupplierProject::on(self::DB_CONNECTION)
|
||||
->where('unique_key', $identifier)
|
||||
->where('signal_type', (string) $project->signal_type)
|
||||
->whereIn('platform', $platforms)
|
||||
->get();
|
||||
|
||||
if ($existingSps->isEmpty()) {
|
||||
// Create path: saveProjectMultiFlag → [platform => external_id]
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: $platforms[0],
|
||||
signalType: (string) $project->signal_type,
|
||||
uniqueKey: $identifier,
|
||||
limit: (int) $project->daily_limit_target,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: $platforms,
|
||||
);
|
||||
|
||||
try {
|
||||
$idMap = $client->saveProjectMultiFlag($dto);
|
||||
} catch (TierEscalatedException $e) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} escalated to manual queue #{$e->queueRowId}");
|
||||
|
||||
return;
|
||||
} catch (WindowDeferredException) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} deferred by portal window");
|
||||
|
||||
return;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("SyncSupplierProjectJob: online multi-flag save failed for project {$project->id} (".get_class($e).'): '.$e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
// Create path: one save PER platform with that platform's divided share
|
||||
// (single-flag save → exactly one rt-project, reliable id via listProjects match).
|
||||
$idMap = $this->createPerPlatform($client, $project, $identifier, $tag, $workdays, $allRegions, $shares, $platforms);
|
||||
|
||||
foreach ($platforms as $platform) {
|
||||
$externalId = $idMap[$platform] ?? null;
|
||||
@@ -148,13 +140,13 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
continue;
|
||||
}
|
||||
|
||||
$sp = SupplierProject::create([
|
||||
$sp = SupplierProject::on(self::DB_CONNECTION)->create([
|
||||
'platform' => $platform,
|
||||
'signal_type' => (string) $project->signal_type,
|
||||
'unique_key' => $identifier,
|
||||
'subject_code' => null,
|
||||
'supplier_external_id' => (string) $externalId,
|
||||
'current_limit' => (int) $project->daily_limit_target,
|
||||
'current_limit' => $shares[$platform] ?? 0,
|
||||
'current_workdays' => $workdays,
|
||||
'current_regions' => $allRegions,
|
||||
'sync_status' => 'ok',
|
||||
@@ -181,31 +173,7 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
|
||||
if ($deadSps->isNotEmpty()) {
|
||||
$deadPlatforms = array_values($deadSps->pluck('platform')->all());
|
||||
$recreateDto = new SupplierProjectDto(
|
||||
platform: $deadPlatforms[0],
|
||||
signalType: (string) $project->signal_type,
|
||||
uniqueKey: $identifier,
|
||||
limit: (int) $project->daily_limit_target,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: $deadPlatforms,
|
||||
);
|
||||
|
||||
try {
|
||||
$recreatedIdMap = $client->saveProjectMultiFlag($recreateDto);
|
||||
} catch (TierEscalatedException $e) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} dead-donor re-create escalated #{$e->queueRowId}");
|
||||
$recreatedIdMap = [];
|
||||
} catch (WindowDeferredException) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} dead-donor re-create deferred by portal window");
|
||||
$recreatedIdMap = [];
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("SyncSupplierProjectJob: dead-donor re-create failed for project {$project->id}: ".$e->getMessage());
|
||||
$recreatedIdMap = [];
|
||||
}
|
||||
$recreatedIdMap = $this->createPerPlatform($client, $project, $identifier, $tag, $workdays, $allRegions, $shares, $deadPlatforms);
|
||||
|
||||
foreach ($deadSps as $sp) {
|
||||
$newId = $recreatedIdMap[$sp->platform] ?? null;
|
||||
@@ -220,44 +188,20 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
$missingPlatforms = array_values(array_diff($platforms, $existingPlatforms));
|
||||
|
||||
if ($missingPlatforms !== []) {
|
||||
$missingDto = new SupplierProjectDto(
|
||||
platform: $missingPlatforms[0],
|
||||
signalType: (string) $project->signal_type,
|
||||
uniqueKey: $identifier,
|
||||
limit: (int) $project->daily_limit_target,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: $missingPlatforms,
|
||||
);
|
||||
|
||||
try {
|
||||
$missingIdMap = $client->saveProjectMultiFlag($missingDto);
|
||||
} catch (TierEscalatedException $e) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} missing-platform re-attempt escalated #{$e->queueRowId}");
|
||||
$missingIdMap = [];
|
||||
} catch (WindowDeferredException) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} missing-platform deferred by portal window");
|
||||
$missingIdMap = [];
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("SyncSupplierProjectJob: missing-platform multi-flag failed for project {$project->id}: ".$e->getMessage());
|
||||
$missingIdMap = [];
|
||||
}
|
||||
$missingIdMap = $this->createPerPlatform($client, $project, $identifier, $tag, $workdays, $allRegions, $shares, $missingPlatforms);
|
||||
|
||||
foreach ($missingPlatforms as $platform) {
|
||||
$externalId = $missingIdMap[$platform] ?? null;
|
||||
if ($externalId === null) {
|
||||
continue;
|
||||
}
|
||||
$sp = SupplierProject::create([
|
||||
$sp = SupplierProject::on(self::DB_CONNECTION)->create([
|
||||
'platform' => $platform,
|
||||
'signal_type' => (string) $project->signal_type,
|
||||
'unique_key' => $identifier,
|
||||
'subject_code' => null,
|
||||
'supplier_external_id' => (string) $externalId,
|
||||
'current_limit' => (int) $project->daily_limit_target,
|
||||
'current_limit' => $shares[$platform] ?? 0,
|
||||
'current_workdays' => $workdays,
|
||||
'current_regions' => $allRegions,
|
||||
'sync_status' => 'ok',
|
||||
@@ -276,7 +220,7 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
platform: $sp->platform,
|
||||
signalType: (string) $project->signal_type,
|
||||
uniqueKey: $identifier,
|
||||
limit: (int) $project->daily_limit_target,
|
||||
limit: $shares[$sp->platform] ?? 0,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
@@ -286,7 +230,7 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
);
|
||||
$channel->updateProject((int) $sp->supplier_external_id, $perPlatformDto);
|
||||
$sp->forceFill([
|
||||
'current_limit' => (int) $project->daily_limit_target,
|
||||
'current_limit' => $shares[$sp->platform] ?? 0,
|
||||
'current_workdays' => $workdays,
|
||||
'current_regions' => $allRegions,
|
||||
'sync_status' => 'ok',
|
||||
@@ -297,7 +241,7 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
|
||||
// Pivot: project × each supplier_project → ON CONFLICT DO NOTHING
|
||||
foreach ($existingSps as $sp) {
|
||||
DB::table('project_supplier_links')->insertOrIgnore([
|
||||
DB::connection(self::DB_CONNECTION)->table('project_supplier_links')->insertOrIgnore([
|
||||
'project_id' => $project->id,
|
||||
'supplier_project_id' => $sp->id,
|
||||
'platform' => $sp->platform,
|
||||
@@ -329,7 +273,7 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
$column = 'supplier_'.strtolower($platform).'_project_id';
|
||||
|
||||
// Idempotency: local supplier_projects-запись уже есть?
|
||||
$existing = SupplierProject::query()
|
||||
$existing = SupplierProject::on(self::DB_CONNECTION)
|
||||
->where('platform', $platform)
|
||||
->where('signal_type', $project->signal_type)
|
||||
->where('unique_key', $uniqueKey)
|
||||
@@ -366,7 +310,7 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
continue;
|
||||
}
|
||||
|
||||
$sp = SupplierProject::query()->create([
|
||||
$sp = SupplierProject::on(self::DB_CONNECTION)->create([
|
||||
'platform' => $platform,
|
||||
'signal_type' => $project->signal_type,
|
||||
'unique_key' => $uniqueKey,
|
||||
@@ -383,6 +327,68 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
$project->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт проекты на портале ПО ОДНОМУ на платформу с её долей лимита ($shares).
|
||||
*
|
||||
* Один single-flag save = ровно один rt-проект → надёжный id через listProjects-матч.
|
||||
* Так per-platform лимит = доля (Σ == заказу), а не полный лимит на каждой площадке.
|
||||
* Per-platform tolerance: tier-escalation / window-defer / прочая ошибка одной площадки
|
||||
* не валит остальные — пропускаем, следующий run (или ночной батч) подберёт недостающее.
|
||||
*
|
||||
* @param array<string, int> $shares [platform => лимит площадки]
|
||||
* @param list<string> $platformsToCreate
|
||||
* @return array<string, int> [platform => external_id] для успешно созданных
|
||||
*/
|
||||
private function createPerPlatform(
|
||||
SupplierPortalClient $client,
|
||||
Project $project,
|
||||
string $identifier,
|
||||
string $tag,
|
||||
array $workdays,
|
||||
array $allRegions,
|
||||
array $shares,
|
||||
array $platformsToCreate,
|
||||
): array {
|
||||
$idMap = [];
|
||||
|
||||
foreach ($platformsToCreate as $platform) {
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: $platform,
|
||||
signalType: (string) $project->signal_type,
|
||||
uniqueKey: $identifier,
|
||||
limit: $shares[$platform] ?? 0,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: [$platform],
|
||||
);
|
||||
|
||||
try {
|
||||
$result = $client->saveProjectMultiFlag($dto);
|
||||
} catch (TierEscalatedException $e) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} {$platform} escalated to manual queue #{$e->queueRowId}");
|
||||
|
||||
continue;
|
||||
} catch (WindowDeferredException) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} {$platform} deferred by portal window");
|
||||
|
||||
continue;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("SyncSupplierProjectJob: online per-platform save failed for project {$project->id} {$platform} (".get_class($e).'): '.$e->getMessage());
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($result[$platform])) {
|
||||
$idMap[$platform] = $result[$platform];
|
||||
}
|
||||
}
|
||||
|
||||
return $idMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bitmask → ISO weekday list. bit 0 = Mon (ISO 1) … bit 6 = Sun (ISO 7).
|
||||
*
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Exceptions\Supplier\SupplierClientException;
|
||||
use App\Exceptions\Supplier\SupplierTransientException;
|
||||
use App\Jobs\Supplier\RefreshSupplierSessionJob;
|
||||
use App\Services\Supplier\Dto\SupplierProjectDto;
|
||||
use App\Support\SupplierRegions;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\Factory as HttpFactory;
|
||||
@@ -477,7 +478,10 @@ class SupplierPortalClient
|
||||
'srcseg' => false,
|
||||
'limit' => $dto->limit,
|
||||
'workdays' => $workdays,
|
||||
'regions' => $dto->regions,
|
||||
// DTO несёт Лидерра-коды (конституционный порядок); поставщик ждёт
|
||||
// свои коды (ГИБДД). Без перевода уходил чужой регион (Красноярский 29
|
||||
// → Архангельск 29). См. App\Support\SupplierRegions.
|
||||
'regions' => SupplierRegions::mapToSupplier($dto->regions),
|
||||
'regions_reverse' => $dto->regionsReverse,
|
||||
'status' => $dto->status === 'active',
|
||||
'show' => true,
|
||||
|
||||
@@ -11,14 +11,19 @@ use Illuminate\Support\Collection;
|
||||
/**
|
||||
* Pure function: формула заказа у поставщика на (источник × субъект).
|
||||
*
|
||||
* Эпик миграции проектов (Plan 3): platform-split B1/B2/B3 удалён — портал
|
||||
* делит лимит сам (R6). Один лимит на группу eligible-клиентов:
|
||||
* Заказ группы eligible-клиентов:
|
||||
*
|
||||
* order = max(наибольший_лимит, ceil(Σ_лимитов / 3))
|
||||
*
|
||||
* ceil(Σ/3) — ёмкость шаринга (лид продаётся ≤3 раз).
|
||||
* ceil(Σ/3) — ёмкость шаринга (лид продаётся ≤3 раз клиентам Лидерры).
|
||||
* наиб — крупнейший клиент должен иметь шанс добрать.
|
||||
*
|
||||
* Этот `order` затем ДЕЛИТСЯ между площадками B1/B2/B3 через distributeForPlatform()
|
||||
* так, чтобы Σ per-platform лимитов == order. Портал НЕ делит сам: проверено вживую
|
||||
* 2026-05-21 (listProjects) — каждый B-проект честно набирает до своего лимита
|
||||
* независимо, поэтому одинаковый лимит на 3 площадках = заказ ×3 (переплата).
|
||||
* Plan 3 R6 («портал делит, verified 15→5») оказался ложным — split восстановлен.
|
||||
*
|
||||
* `allocate()` оставлен с прежней сигнатурой для временной совместимости
|
||||
* c SyncSupplierProjectsJob — внутри использует computeOrder, возвращает
|
||||
* DTO с одинаковым limit на любую platform/signalType.
|
||||
@@ -76,7 +81,7 @@ final class SupplierQuotaAllocator
|
||||
* ceil(Σ/3) — ёмкость шаринга (лид продаётся ≤3 раз).
|
||||
* наиб — крупнейший клиент должен иметь шанс добрать.
|
||||
*
|
||||
* Один лимит на группу; портал делит на B1/B2/B3 сам (R6 — наш split убран).
|
||||
* Возвращает заказ ГРУППЫ; деление между B1/B2/B3 — distributeForPlatform().
|
||||
*
|
||||
* @param array<int, int> $dailyLimits лимиты eligible-сегодня клиентов группы
|
||||
*/
|
||||
@@ -92,6 +97,40 @@ final class SupplierQuotaAllocator
|
||||
return max($max, (int) ceil($sum / 3));
|
||||
}
|
||||
|
||||
/**
|
||||
* Делит групповой заказ между площадками так, чтобы СУММА per-platform лимитов == order.
|
||||
*
|
||||
* Largest-remainder: каждой площадке floor(order/N), затем по +1 первым (order mod N)
|
||||
* площадкам в порядке списка. Сумма всегда точно равна order — ни переплаты, ни недобора.
|
||||
*
|
||||
* Восстанавливает поведение, удалённое в Plan 3 R6 (ошибочное допущение «портал делит сам»).
|
||||
* Портал НЕ делит — каждый B-проект набирает до своего лимита независимо; одинаковый
|
||||
* лимит на N площадках = заказ ×N (переплата). Verified live 2026-05-21.
|
||||
*
|
||||
* @param list<string> $platforms площадки в каноническом порядке (B1<B2<B3)
|
||||
* @return array<string, int> [platform => лимит этой площадки]
|
||||
*/
|
||||
public static function distributeForPlatform(int $order, array $platforms): array
|
||||
{
|
||||
$count = count($platforms);
|
||||
if ($count === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$order = max(0, $order);
|
||||
$base = intdiv($order, $count);
|
||||
$remainder = $order % $count;
|
||||
|
||||
$shares = [];
|
||||
$i = 0;
|
||||
foreach ($platforms as $platform) {
|
||||
$shares[$platform] = $base + ($i < $remainder ? 1 : 0);
|
||||
$i++;
|
||||
}
|
||||
|
||||
return $shares;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, mixed> $arrays
|
||||
* @return array<int, int>
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Перевод кодов регионов: Лидерра → поставщик crm.bp-gr.ru.
|
||||
*
|
||||
* Лидерра нумерует субъекты РФ по конституционному порядку (ст. 65), 1..89 —
|
||||
* см. {@see RussianRegions}: Красноярский край = 29, Архангельская обл. = 35.
|
||||
* Поставщик нумерует по автомобильным кодам (ГИБДД): Красноярский = 24,
|
||||
* Архангельская = 29. Без перевода Sync отправлял Лидерра-код «как есть»
|
||||
* (`regions => [29]` для Красноярского), а поставщик понимал его как СВОЙ № 29 =
|
||||
* Архангельск → у поставщика выбирался ЧУЖОЙ регион. На dev не всплывало —
|
||||
* проверяли на «вся РФ» (пустой regions).
|
||||
*
|
||||
* Карта построена сверкой имён {@see RussianRegions::CODE_TO_NAME} ↔ live-дерево
|
||||
* регионов формы «Добавить проект» поставщика (recon 2026-05-21: node-key="id",
|
||||
* 79 субъектов-листьев). Все 79 кодов поставщика покрыты (биекция на 79).
|
||||
*
|
||||
* 10 субъектов Лидерры поставщик НЕ предлагает (нет в дереве) — их коды
|
||||
* отбрасываются при переводе (с warning'ом): Московская обл. (56),
|
||||
* Ленинградская обл. (53), Крым (13), Севастополь (84), ДНР (6), ЛНР (14),
|
||||
* Запорожская (43), Херсонская (79), Ненецкий АО (86), Ямало-Ненецкий АО (89).
|
||||
* Если у проекта это был ЕДИНСТВЕННЫЙ регион — у поставщика проект окажется без
|
||||
* георфильтра (вся РФ). Это ограничение покрытия поставщика, не баг перевода.
|
||||
*/
|
||||
final class SupplierRegions
|
||||
{
|
||||
/**
|
||||
* Лидерра-код (конституционный 1..89) => код поставщика (ГИБДД).
|
||||
*
|
||||
* @var array<int, int>
|
||||
*/
|
||||
public const LIDERRA_TO_SUPPLIER = [
|
||||
// Республики
|
||||
1 => 1, // Республика Адыгея
|
||||
2 => 4, // Республика Алтай
|
||||
3 => 2, // Республика Башкортостан
|
||||
4 => 3, // Республика Бурятия
|
||||
5 => 5, // Республика Дагестан
|
||||
7 => 6, // Республика Ингушетия
|
||||
8 => 7, // Кабардино-Балкарская Республика
|
||||
9 => 8, // Республика Калмыкия
|
||||
10 => 9, // Карачаево-Черкесская Республика
|
||||
11 => 10, // Республика Карелия
|
||||
12 => 11, // Республика Коми
|
||||
15 => 12, // Республика Марий Эл
|
||||
16 => 13, // Республика Мордовия
|
||||
17 => 14, // Республика Саха (Якутия)
|
||||
18 => 15, // Республика Северная Осетия — Алания
|
||||
19 => 16, // Республика Татарстан
|
||||
20 => 17, // Республика Тыва
|
||||
21 => 18, // Удмуртская Республика
|
||||
22 => 19, // Республика Хакасия
|
||||
23 => 20, // Чеченская Республика
|
||||
24 => 21, // Чувашская Республика
|
||||
// Края
|
||||
25 => 22, // Алтайский край
|
||||
26 => 75, // Забайкальский край
|
||||
27 => 41, // Камчатский край
|
||||
28 => 23, // Краснодарский край
|
||||
29 => 24, // Красноярский край
|
||||
30 => 59, // Пермский край
|
||||
31 => 25, // Приморский край
|
||||
32 => 26, // Ставропольский край
|
||||
33 => 27, // Хабаровский край
|
||||
// Области
|
||||
34 => 28, // Амурская область
|
||||
35 => 29, // Архангельская область
|
||||
36 => 30, // Астраханская область
|
||||
37 => 31, // Белгородская область
|
||||
38 => 32, // Брянская область
|
||||
39 => 33, // Владимирская область
|
||||
40 => 34, // Волгоградская область
|
||||
41 => 35, // Вологодская область
|
||||
42 => 36, // Воронежская область
|
||||
44 => 37, // Ивановская область
|
||||
45 => 38, // Иркутская область
|
||||
46 => 39, // Калининградская область
|
||||
47 => 40, // Калужская область
|
||||
48 => 42, // Кемеровская область
|
||||
49 => 43, // Кировская область
|
||||
50 => 44, // Костромская область
|
||||
51 => 45, // Курганская область
|
||||
52 => 46, // Курская область
|
||||
54 => 48, // Липецкая область
|
||||
55 => 49, // Магаданская область
|
||||
57 => 51, // Мурманская область
|
||||
58 => 52, // Нижегородская область
|
||||
59 => 53, // Новгородская область
|
||||
60 => 54, // Новосибирская область
|
||||
61 => 55, // Омская область
|
||||
62 => 56, // Оренбургская область
|
||||
63 => 57, // Орловская область
|
||||
64 => 58, // Пензенская область
|
||||
65 => 60, // Псковская область
|
||||
66 => 61, // Ростовская область
|
||||
67 => 62, // Рязанская область
|
||||
68 => 63, // Самарская область
|
||||
69 => 64, // Саратовская область
|
||||
70 => 65, // Сахалинская область
|
||||
71 => 66, // Свердловская область
|
||||
72 => 67, // Смоленская область
|
||||
73 => 68, // Тамбовская область
|
||||
74 => 69, // Тверская область
|
||||
75 => 70, // Томская область
|
||||
76 => 71, // Тульская область
|
||||
77 => 72, // Тюменская область
|
||||
78 => 73, // Ульяновская область
|
||||
80 => 74, // Челябинская область
|
||||
81 => 76, // Ярославская область
|
||||
// Города федерального значения
|
||||
82 => 77, // Москва
|
||||
83 => 78, // Санкт-Петербург
|
||||
// Автономная область / округа
|
||||
85 => 79, // Еврейская автономная область
|
||||
87 => 86, // Ханты-Мансийский автономный округ — Югра
|
||||
88 => 87, // Чукотский автономный округ
|
||||
];
|
||||
|
||||
/**
|
||||
* Переводит Лидерра-коды регионов в коды поставщика. Неизвестные (нет у
|
||||
* поставщика) отбрасываются с warning'ом; sentinel 0 («Вся РФ») игнорируется.
|
||||
* Результат — уникальные коды поставщика по возрастанию.
|
||||
*
|
||||
* @param list<int>|array<int|string, int|string> $liderraCodes
|
||||
* @return list<int>
|
||||
*/
|
||||
public static function mapToSupplier(array $liderraCodes): array
|
||||
{
|
||||
$out = [];
|
||||
$dropped = [];
|
||||
|
||||
foreach ($liderraCodes as $code) {
|
||||
$code = (int) $code;
|
||||
if ($code === 0) {
|
||||
continue; // sentinel «Вся РФ»
|
||||
}
|
||||
if (isset(self::LIDERRA_TO_SUPPLIER[$code])) {
|
||||
$out[self::LIDERRA_TO_SUPPLIER[$code]] = true;
|
||||
} else {
|
||||
$dropped[] = $code;
|
||||
}
|
||||
}
|
||||
|
||||
if ($dropped !== []) {
|
||||
Log::warning('supplier.regions.unmapped', [
|
||||
'liderra_codes' => $dropped,
|
||||
'note' => 'supplier does not offer these subjects — geo-filter dropped for them',
|
||||
]);
|
||||
}
|
||||
|
||||
$codes = array_keys($out);
|
||||
sort($codes);
|
||||
|
||||
return $codes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* SSRF-гард для исходящих webhook-URL.
|
||||
*
|
||||
* Webhook target_url задаёт авторизованный админ тенанта. Без проверки он может
|
||||
* указать внутренний адрес (`https://169.254.169.254/` cloud-metadata,
|
||||
* `https://127.0.0.1/`, `https://10.0.0.0/8`) и через кнопку «тест» получить
|
||||
* ответ внутренней службы (SSRF + info-leak). starts_with:https:// этого не ловит.
|
||||
*
|
||||
* Политика: блокируем, только если хост РАЗРЕШАЕТСЯ в приватный/зарезервированный
|
||||
* IP. Неразрешимый хост (NXDOMAIN) — не SSRF-вектор, пропускаем (реальный запрос
|
||||
* упадёт сам). Проверяются все A/AAAA-записи (защита от hostname→private).
|
||||
*/
|
||||
final class WebhookUrlGuard
|
||||
{
|
||||
/**
|
||||
* @return string|null Причина блокировки (человекочитаемая) или null, если адрес безопасен.
|
||||
*/
|
||||
public static function blockReason(string $url): ?string
|
||||
{
|
||||
$host = parse_url($url, PHP_URL_HOST);
|
||||
if (! is_string($host) || $host === '') {
|
||||
return 'Некорректный URL webhook.';
|
||||
}
|
||||
$host = trim($host, '[]'); // снять скобки IPv6-литерала
|
||||
|
||||
foreach (self::resolve($host) as $ip) {
|
||||
if (! self::isPublicIp($ip)) {
|
||||
return 'URL webhook ведёт во внутреннюю/зарезервированную сеть — запрещено.';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @return list<string> Все IP, в которые разрешается хост (пусто, если не разрешается). */
|
||||
private static function resolve(string $host): array
|
||||
{
|
||||
if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
|
||||
return [$host]; // IP-литерал — без DNS
|
||||
}
|
||||
|
||||
$ips = [];
|
||||
$v4 = gethostbynamel($host);
|
||||
if (is_array($v4)) {
|
||||
$ips = array_merge($ips, $v4);
|
||||
}
|
||||
$aaaa = @dns_get_record($host, DNS_AAAA);
|
||||
if (is_array($aaaa)) {
|
||||
foreach ($aaaa as $rec) {
|
||||
if (isset($rec['ipv6']) && is_string($rec['ipv6'])) {
|
||||
$ips[] = $rec['ipv6'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($ips));
|
||||
}
|
||||
|
||||
private static function isPublicIp(string $ip): bool
|
||||
{
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) {
|
||||
return filter_var(
|
||||
$ip,
|
||||
FILTER_VALIDATE_IP,
|
||||
FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
|
||||
) !== false;
|
||||
}
|
||||
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) {
|
||||
$lower = strtolower($ip);
|
||||
// loopback / unspecified
|
||||
if ($lower === '::1' || $lower === '::') {
|
||||
return false;
|
||||
}
|
||||
// link-local fe80::/10
|
||||
if (preg_match('/^fe[89ab]/', $lower) === 1) {
|
||||
return false;
|
||||
}
|
||||
// unique-local fc00::/7
|
||||
if ($lower[0] === 'f' && in_array($lower[1], ['c', 'd'], true)) {
|
||||
return false;
|
||||
}
|
||||
// IPv4-mapped ::ffff:a.b.c.d — проверить встроенный IPv4
|
||||
if (str_contains($lower, '::ffff:')) {
|
||||
$v4 = substr($lower, (int) strrpos($lower, ':') + 1);
|
||||
if (filter_var($v4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) {
|
||||
return self::isPublicIp($v4);
|
||||
}
|
||||
}
|
||||
|
||||
return filter_var(
|
||||
$ip,
|
||||
FILTER_VALIDATE_IP,
|
||||
FILTER_FLAG_IPV6 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
|
||||
) !== false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+15
-5
@@ -194,9 +194,12 @@ Route::middleware(['auth:sanctum', 'tenant'])->group(function () {
|
||||
Route::post('/api/webhooks/test', 'App\Http\Controllers\Api\WebhookSettingsController@test');
|
||||
});
|
||||
|
||||
// Дашборд — агрегат KPI/баланса/активности/воронки (audit J3). На MVP без
|
||||
// auth-middleware (tenant_id параметром); production: middleware('auth:sanctum','tenant').
|
||||
Route::get('/api/dashboard/summary', 'App\Http\Controllers\Api\DashboardController@summary');
|
||||
// Дашборд — агрегат KPI/баланса/активности/воронки (audit J3). Go-live: auth:sanctum
|
||||
// + tenant; tenant_id из auth()->user()->tenant_id (SetTenantContext), НЕ из параметра
|
||||
// запроса — закрывает кросс-tenant утечку KPI (как DealController J1).
|
||||
Route::middleware(['auth:sanctum', 'tenant'])->group(function () {
|
||||
Route::get('/api/dashboard/summary', 'App\Http\Controllers\Api\DashboardController@summary');
|
||||
});
|
||||
|
||||
// Сделки — single-resource CRUD + bulk + export. J1 (Sprint 3F, audit):
|
||||
// auth:sanctum + tenant. tenant_id берётся из auth()->user()->tenant_id
|
||||
@@ -228,8 +231,15 @@ Route::middleware(['auth:sanctum', 'tenant'])->group(function () {
|
||||
});
|
||||
|
||||
// Lookup endpoints — заполняют v-select'ы (NewDealDialog, smart-filters).
|
||||
Route::get('/api/managers', 'App\Http\Controllers\Api\ManagerController@index');
|
||||
Route::get('/api/lead-statuses', 'App\Http\Controllers\Api\LeadStatusController@index');
|
||||
// Go-live: auth:sanctum. /api/managers — tenant-scoped (tenant_id из authed-user, НЕ из
|
||||
// параметра — закрывает кросс-tenant утечку списка пользователей); /api/lead-statuses —
|
||||
// глобальная таблица (без tenant_id), нужен только auth:sanctum.
|
||||
Route::middleware(['auth:sanctum', 'tenant'])->group(function () {
|
||||
Route::get('/api/managers', 'App\Http\Controllers\Api\ManagerController@index');
|
||||
});
|
||||
Route::middleware('auth:sanctum')->group(function () {
|
||||
Route::get('/api/lead-statuses', 'App\Http\Controllers\Api\LeadStatusController@index');
|
||||
});
|
||||
|
||||
// Plan 5 Task 2: Projects CRUD — расширенный API с auth:sanctum + RLS.
|
||||
// Заменяет старый GET /api/projects?tenant_id={id} (без auth, MVP-версия).
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
@@ -36,12 +37,14 @@ function makeDashboardDeal(
|
||||
]);
|
||||
}
|
||||
|
||||
it('422 без tenant_id', function () {
|
||||
$this->getJson('/api/dashboard/summary')->assertStatus(422);
|
||||
});
|
||||
/** Авторизоваться как пользователь данного тенанта (auth:sanctum + tenant). */
|
||||
function actingForTenant(Tenant $tenant): void
|
||||
{
|
||||
test()->actingAs(User::factory()->for($tenant)->create());
|
||||
}
|
||||
|
||||
it('404 для несуществующего тенанта', function () {
|
||||
$this->getJson('/api/dashboard/summary?tenant_id=999999')->assertStatus(404);
|
||||
it('401 без авторизации', function () {
|
||||
$this->getJson('/api/dashboard/summary')->assertStatus(401);
|
||||
});
|
||||
|
||||
it('возвращает структуру summary с range по умолчанию 7d', function () {
|
||||
@@ -50,7 +53,8 @@ it('возвращает структуру summary с range по умолчан
|
||||
'balance_rub' => '14250.00',
|
||||
'balance_leads' => 285,
|
||||
]);
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
actingForTenant($tenant);
|
||||
$this->getJson('/api/dashboard/summary')
|
||||
->assertOk()
|
||||
->assertJsonPath('range', '7d')
|
||||
->assertJsonPath('balance.amount_rub', '14250.00')
|
||||
@@ -67,6 +71,7 @@ it('возвращает структуру summary с range по умолчан
|
||||
|
||||
it('leads_received считает только сделки окна, без deleted и is_test', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
actingForTenant($tenant);
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
|
||||
// 3 живые сделки в окне 7d + 1 deleted + 1 is_test + 1 вне окна (8 дней назад)
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
@@ -76,30 +81,32 @@ it('leads_received считает только сделки окна, без del
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1), isTest: true);
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(8));
|
||||
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}&range=7d")
|
||||
$this->getJson('/api/dashboard/summary?range=7d')
|
||||
->assertOk()
|
||||
->assertJsonPath('leads_received.value', 3);
|
||||
});
|
||||
|
||||
it('conversion = доля статуса won в окне', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
actingForTenant($tenant);
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
|
||||
makeDashboardDeal($tenant, $project, 'won', now()->subDays(1));
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
// 1 won из 4 → 25.0%; PHP json_encode кодирует 25.0 как 25 (без дроби)
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
$this->getJson('/api/dashboard/summary')
|
||||
->assertOk()
|
||||
->assertJsonPath('conversion.value', 25);
|
||||
});
|
||||
|
||||
it('active_projects считает is_active=true + limit из limits', function () {
|
||||
$tenant = Tenant::factory()->create(['limits' => ['max_projects' => 10]]);
|
||||
actingForTenant($tenant);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => false]);
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
$this->getJson('/api/dashboard/summary')
|
||||
->assertOk()
|
||||
->assertJsonPath('active_projects.active', 2)
|
||||
->assertJsonPath('active_projects.limit', 10);
|
||||
@@ -107,11 +114,12 @@ it('active_projects считает is_active=true + limit из limits', function
|
||||
|
||||
it('funnel группирует живые сделки по статусу', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
actingForTenant($tenant);
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDashboardDeal($tenant, $project, 'won', now()->subDays(1));
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
$this->getJson('/api/dashboard/summary')
|
||||
->assertOk()
|
||||
->assertJsonPath('funnel.new', 2)
|
||||
->assertJsonPath('funnel.won', 1);
|
||||
@@ -119,7 +127,8 @@ it('funnel группирует живые сделки по статусу', fu
|
||||
|
||||
it('activity возвращает 7 точек и 7 меток', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
actingForTenant($tenant);
|
||||
$this->getJson('/api/dashboard/summary')
|
||||
->assertOk()
|
||||
->assertJsonCount(7, 'activity.points')
|
||||
->assertJsonCount(7, 'activity.labels');
|
||||
@@ -129,11 +138,12 @@ it('runway_days использует фикс. 7д-окно независимо
|
||||
// balance_leads = 70; 7 сделок за последние 7 дней → avgDaily=1 → runway=70.
|
||||
// Баг: range=today → $curLeads=1 → avgDaily=1/7≈0.143 → runway≈490 (неверно).
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 70]);
|
||||
actingForTenant($tenant);
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
|
||||
for ($i = 0; $i <= 6; $i++) {
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays($i));
|
||||
}
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}&range=today")
|
||||
$this->getJson('/api/dashboard/summary?range=today')
|
||||
->assertOk()
|
||||
->assertJsonPath('balance.runway_days', 70);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
/**
|
||||
* Go-live security: lookup/дашборд эндпоинты до этого были открыты (без
|
||||
* auth-middleware, tenant_id параметром) — любой неавторизованный мог получить
|
||||
* KPI/список пользователей произвольного тенанта по ?tenant_id={чужой}.
|
||||
*
|
||||
* Закрытие: auth:sanctum + tenant, tenant_id из authed-user (как DealController J1).
|
||||
*/
|
||||
|
||||
// --- 401 без авторизации ---
|
||||
|
||||
test('GET /api/dashboard/summary без авторизации возвращает 401', function () {
|
||||
$this->getJson('/api/dashboard/summary')->assertStatus(401);
|
||||
});
|
||||
|
||||
test('GET /api/managers без авторизации возвращает 401', function () {
|
||||
$this->getJson('/api/managers')->assertStatus(401);
|
||||
});
|
||||
|
||||
test('GET /api/lead-statuses без авторизации возвращает 401', function () {
|
||||
$this->getJson('/api/lead-statuses')->assertStatus(401);
|
||||
});
|
||||
|
||||
// --- cross-tenant: tenant_id из user, параметр чужого тенанта игнорируется ---
|
||||
|
||||
test('dashboard/summary берёт tenant из authed-user, игнорирует ?tenant_id чужого', function () {
|
||||
$mine = Tenant::factory()->create(['balance_rub' => '111.00', 'balance_leads' => 11]);
|
||||
$other = Tenant::factory()->create(['balance_rub' => '999.00', 'balance_leads' => 99]);
|
||||
$this->actingAs(User::factory()->for($mine)->create());
|
||||
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$other->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('balance.amount_rub', '111.00');
|
||||
});
|
||||
|
||||
test('managers берёт tenant из authed-user, не отдаёт пользователей чужого тенанта', function () {
|
||||
$mine = Tenant::factory()->create();
|
||||
$other = Tenant::factory()->create();
|
||||
$me = User::factory()->for($mine)->create(['first_name' => 'Свой', 'last_name' => 'Менеджер', 'is_active' => true]);
|
||||
User::factory()->for($other)->create(['first_name' => 'Чужой', 'last_name' => 'Менеджер', 'is_active' => true]);
|
||||
$this->actingAs($me);
|
||||
|
||||
$names = $this->getJson("/api/managers?tenant_id={$other->id}")
|
||||
->assertOk()
|
||||
->json('managers.*.name');
|
||||
|
||||
expect($names)->toContain('Свой М.');
|
||||
expect($names)->not->toContain('Чужой М.');
|
||||
});
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
@@ -9,11 +11,23 @@ use Illuminate\Support\Facades\DB;
|
||||
* Тесты GET /api/lead-statuses — глобальный lookup статусов воронки.
|
||||
*
|
||||
* Таблица lead_statuses не tenant-aware, seeded в schema.sql (5 системных
|
||||
* статусов воронки: new/viewed/in_progress/won/lost).
|
||||
* статусов воронки: new/viewed/in_progress/won/lost). Go-live: эндпоинт за
|
||||
* auth:sanctum (глобальная таблица — tenant-middleware не нужен).
|
||||
*/
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
/** Авторизоваться любым пользователем (lead-statuses требует только auth:sanctum). */
|
||||
function authLeadStatuses(): void
|
||||
{
|
||||
test()->actingAs(User::factory()->for(Tenant::factory())->create());
|
||||
}
|
||||
|
||||
test('GET /api/lead-statuses без авторизации возвращает 401', function () {
|
||||
$this->getJson('/api/lead-statuses')->assertStatus(401);
|
||||
});
|
||||
|
||||
test('GET /api/lead-statuses возвращает 200 и не пустой список', function () {
|
||||
authLeadStatuses();
|
||||
$r = $this->getJson('/api/lead-statuses');
|
||||
|
||||
$r->assertStatus(200);
|
||||
@@ -22,6 +36,7 @@ test('GET /api/lead-statuses возвращает 200 и не пустой сп
|
||||
});
|
||||
|
||||
test('GET /api/lead-statuses возвращает все 5 системных статусов из seed', function () {
|
||||
authLeadStatuses();
|
||||
$r = $this->getJson('/api/lead-statuses');
|
||||
|
||||
$slugs = collect($r->json('lead_statuses'))->pluck('slug')->all();
|
||||
@@ -32,6 +47,7 @@ test('GET /api/lead-statuses возвращает все 5 системных с
|
||||
});
|
||||
|
||||
test('GET /api/lead-statuses возвращает поля slug, name_ru, color_hex, sort_order, is_system', function () {
|
||||
authLeadStatuses();
|
||||
$r = $this->getJson('/api/lead-statuses');
|
||||
|
||||
$first = $r->json('lead_statuses.0');
|
||||
@@ -42,6 +58,7 @@ test('GET /api/lead-statuses возвращает поля slug, name_ru, color_
|
||||
});
|
||||
|
||||
test('GET /api/lead-statuses сортирует по sort_order', function () {
|
||||
authLeadStatuses();
|
||||
$r = $this->getJson('/api/lead-statuses');
|
||||
|
||||
$sortOrders = collect($r->json('lead_statuses'))->pluck('sort_order')->all();
|
||||
@@ -51,6 +68,7 @@ test('GET /api/lead-statuses сортирует по sort_order', function () {
|
||||
});
|
||||
|
||||
test('GET /api/lead-statuses включает кастомный slug, добавленный после seed', function () {
|
||||
authLeadStatuses();
|
||||
DB::table('lead_statuses')->insert([
|
||||
'slug' => 'custom_test_'.bin2hex(random_bytes(3)),
|
||||
'name_ru' => 'Кастомный тест',
|
||||
|
||||
@@ -15,7 +15,8 @@ beforeEach(function () {
|
||||
|
||||
test('GET /api/managers возвращает active users тенанта', function () {
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
User::factory()->for($this->tenant)->create([
|
||||
// actingAs одного из активных пользователей тенанта — он сам входит в список.
|
||||
$ivan = User::factory()->for($this->tenant)->create([
|
||||
'first_name' => 'Иван', 'last_name' => 'Петров', 'is_active' => true,
|
||||
]);
|
||||
User::factory()->for($this->tenant)->create([
|
||||
@@ -25,7 +26,8 @@ test('GET /api/managers возвращает active users тенанта', funct
|
||||
'first_name' => 'Удалённый', 'is_active' => false,
|
||||
]);
|
||||
|
||||
$r = $this->getJson('/api/managers?tenant_id='.$this->tenant->id);
|
||||
$this->actingAs($ivan);
|
||||
$r = $this->getJson('/api/managers');
|
||||
$r->assertStatus(200);
|
||||
$managers = $r->json('managers');
|
||||
expect($managers)->toHaveCount(2);
|
||||
@@ -35,28 +37,23 @@ test('GET /api/managers возвращает active users тенанта', funct
|
||||
|
||||
test('GET /api/managers возвращает initials с fallback на email', function () {
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
User::factory()->for($this->tenant)->create([
|
||||
$admin = User::factory()->for($this->tenant)->create([
|
||||
'email' => 'admin@example.ru',
|
||||
'first_name' => null,
|
||||
'last_name' => null,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$r = $this->getJson('/api/managers?tenant_id='.$this->tenant->id);
|
||||
$this->actingAs($admin);
|
||||
$r = $this->getJson('/api/managers');
|
||||
$r->assertStatus(200);
|
||||
$manager = $r->json('managers.0');
|
||||
expect($manager['name'])->toBe('admin@example.ru');
|
||||
expect($manager['initials'])->toBe('AD');
|
||||
});
|
||||
|
||||
test('GET /api/managers 422 без tenant_id', function () {
|
||||
$r = $this->getJson('/api/managers');
|
||||
$r->assertStatus(422);
|
||||
});
|
||||
|
||||
test('GET /api/managers 404 unknown tenant', function () {
|
||||
$r = $this->getJson('/api/managers?tenant_id=999999');
|
||||
$r->assertStatus(404);
|
||||
test('GET /api/managers без авторизации возвращает 401', function () {
|
||||
$this->getJson('/api/managers')->assertStatus(401);
|
||||
});
|
||||
|
||||
test('POST /api/deals 422 если manager_id не принадлежит tenant\'у', function () {
|
||||
|
||||
@@ -8,10 +8,13 @@ use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Supplier\Channel\SupplierProjectChannel;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
// TestCase auto-bound via tests/Pest.php (->in('Feature')).
|
||||
// DatabaseTransactions — per-test isolation.
|
||||
uses(DatabaseTransactions::class);
|
||||
// SharesSupplierPdo — SyncSupplierProjectJob теперь пишет через pgsql_supplier (BYPASSRLS);
|
||||
// без шаринга PDO записи джоба не видны default-connection ассертам под DatabaseTransactions.
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||||
|
||||
/**
|
||||
* Хелпер: разрешает SupplierProjectChannel из контейнера и вызывает Job.handle().
|
||||
|
||||
@@ -21,6 +21,7 @@ declare(strict_types=1);
|
||||
*/
|
||||
|
||||
use App\Jobs\RouteSupplierLeadJob;
|
||||
use App\Jobs\SyncSupplierProjectJob;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
@@ -46,6 +47,14 @@ test('RouteSupplierLeadJob declares DB_CONNECTION = pgsql_supplier (Plan 3 Task
|
||||
expect(RouteSupplierLeadJob::DB_CONNECTION)->toBe('pgsql_supplier');
|
||||
});
|
||||
|
||||
test('SyncSupplierProjectJob declares DB_CONNECTION = pgsql_supplier (queue worker has no tenant GUC)', function (): void {
|
||||
// Дублирует RouteSupplierLeadJob: создание/правка проекта тоже запускается из очереди,
|
||||
// где SetTenantContext-прослойка не отработала. Под обычной ролью crm_app_user
|
||||
// SELECT по projects падает 42704 (unrecognized configuration parameter
|
||||
// "app.current_tenant_id"). Все DB-операции джоба обязаны идти через pgsql_supplier (BYPASSRLS).
|
||||
expect(SyncSupplierProjectJob::DB_CONNECTION)->toBe('pgsql_supplier');
|
||||
});
|
||||
|
||||
test('failed_webhook_jobs INSERT с tenant_id=NULL проходит под pgsql_supplier (BLOCKER #6)', function (): void {
|
||||
// Под обычной ролью policy tenant_isolation USING (tenant_id = current_setting('app.current_tenant_id')::bigint)
|
||||
// отвергает NULL (NULL :: bigint = NULL, NULL = '0'::bigint → NULL → false).
|
||||
|
||||
@@ -99,7 +99,9 @@ it('saveProject maps signalType call → type:"calls" and B2 → srcbl=true (sin
|
||||
&& $request['srcrt'] === false
|
||||
&& $request['srcbl'] === true
|
||||
&& $request['srcmt'] === false
|
||||
&& $request['regions'] === [77]
|
||||
// Лидерра-код 77 (Тюменская обл., конституционный порядок) переводится
|
||||
// в код поставщика 72 (ГИБДД). См. App\Support\SupplierRegions.
|
||||
&& $request['regions'] === [72]
|
||||
&& $request['regions_reverse'] === true
|
||||
&& $request['status'] === false;
|
||||
});
|
||||
|
||||
@@ -80,6 +80,56 @@ it('online mode creates single-group supplier_projects with full regions + pivot
|
||||
expect(DB::table('project_supplier_links')->where('project_id', $project->id)->count())->toBe(3);
|
||||
});
|
||||
|
||||
it('online create DIVIDES the limit across B1/B2/B3 so supplier total == project limit (not ×3)', function (): void {
|
||||
// Money-loss regression (owner-reported 2026-05-21, verified live): the limit was
|
||||
// replicated full to all 3 platforms (18 → 18/18/18 = supplier could deliver up to 54).
|
||||
// The portal does NOT divide — each B-project honours its own limit independently.
|
||||
// Fix: split the limit so Σ per-platform == project limit (18 → 6/6/6).
|
||||
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
|
||||
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'call',
|
||||
'signal_identifier' => '79991110000',
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 18,
|
||||
'regions' => [],
|
||||
'delivery_days_mask' => 127,
|
||||
]);
|
||||
|
||||
$capturedLimits = [];
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => function ($request) use (&$capturedLimits) {
|
||||
$body = $request->data();
|
||||
$capturedLimits[] = $body['limit'] ?? null;
|
||||
|
||||
return Http::response(['status' => 'OK', 'message' => '', 'id' => '3000'], 200);
|
||||
},
|
||||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => [
|
||||
['id' => '3001', 'src' => 'rt', 'name' => '79991110000', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79991110000'],
|
||||
['id' => '3002', 'src' => 'bl', 'name' => '79991110000', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79991110000'],
|
||||
['id' => '3003', 'src' => 'mt', 'name' => '79991110000', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79991110000'],
|
||||
]], 200),
|
||||
]);
|
||||
|
||||
(new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class));
|
||||
|
||||
$sps = SupplierProject::where('unique_key', '79991110000')->get();
|
||||
expect($sps)->toHaveCount(3);
|
||||
// Σ per-platform limits == the project limit — the loss-prevention invariant.
|
||||
expect($sps->sum('current_limit'))->toBe(18);
|
||||
foreach ($sps as $sp) {
|
||||
expect($sp->current_limit)->toBe(6); // 18 / 3 platforms
|
||||
}
|
||||
// Every limit pushed to the portal is the divided share, never the full 18.
|
||||
$sent = array_values(array_filter($capturedLimits, fn ($l) => $l !== null));
|
||||
expect($sent)->not->toBeEmpty();
|
||||
foreach ($sent as $l) {
|
||||
expect((int) $l)->toBe(6);
|
||||
}
|
||||
});
|
||||
|
||||
it('online mode passes real workdays from delivery_days_mask (not hardcoded [1..7])', function (): void {
|
||||
// Regression: до фикса хардкодилось [1,2,3,4,5,6,7] независимо от delivery_days_mask.
|
||||
// delivery_days_mask=31 = 0b0011111 = Пн-Пт (ISO дни 1-5). Workdays поставщика должны быть [1,2,3,4,5].
|
||||
@@ -161,6 +211,16 @@ it('online mode update-path: existing supplier_projects.current_workdays is refr
|
||||
]);
|
||||
}
|
||||
|
||||
// listProjects (dead-donor liveness check) must see the seeded donors as alive,
|
||||
// so the update path runs without recreating (and without hitting the real portal).
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => [
|
||||
['id' => '99B1', 'src' => 'rt', 'name' => '79991234567', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79991234567'],
|
||||
['id' => '99B2', 'src' => 'bl', 'name' => '79991234567', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79991234567'],
|
||||
['id' => '99B3', 'src' => 'mt', 'name' => '79991234567', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79991234567'],
|
||||
]], 200),
|
||||
]);
|
||||
|
||||
$this->mock(SupplierProjectChannel::class, function ($mock): void {
|
||||
$mock->shouldReceive('updateProject')->times(3)->andReturn(true);
|
||||
});
|
||||
@@ -169,9 +229,11 @@ it('online mode update-path: existing supplier_projects.current_workdays is refr
|
||||
|
||||
$sps = SupplierProject::where('unique_key', '79991234567')->get();
|
||||
expect($sps)->toHaveCount(3);
|
||||
// 9 split across B1/B2/B3 = 3/3/3 (Σ == 9 = project limit, not 9 on each = 27).
|
||||
expect($sps->sum('current_limit'))->toBe(9);
|
||||
foreach ($sps as $sp) {
|
||||
expect($sp->current_workdays)->toBe([1, 2, 3, 4, 5]);
|
||||
expect($sp->current_limit)->toBe(9);
|
||||
expect($sp->current_limit)->toBe(3);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -347,3 +409,53 @@ it('batch mode keeps каркас (limit=0, sets supplier_b{1,2,3}_project_id, n
|
||||
// Batch: no pivot rows (nightly job fills them)
|
||||
expect(DB::table('project_supplier_links')->where('project_id', $project->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connection: must use pgsql_supplier (BYPASSRLS) — queue worker has no tenant GUC
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('runs every projects query on the pgsql_supplier (BYPASSRLS) connection', function (): void {
|
||||
// Regression: job ran on the default RLS-enforced connection. On a real queue worker
|
||||
// (role crm_app_user, no SetTenantContext middleware → no app.current_tenant_id GUC)
|
||||
// the very first Project::find() dies with SQLSTATE 42704 before any supplier contact,
|
||||
// so the supplier project is never created and the UI sticks on "Sync pending".
|
||||
// Every sibling supplier job (SyncSupplierProjectsJob/DeleteSupplierProjectJob/…) uses
|
||||
// pgsql_supplier; this one must too. On dev (postgres superuser) RLS is bypassed, so we
|
||||
// assert the *connection* the queries run on rather than RLS enforcement.
|
||||
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
|
||||
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'conn-test.example.com',
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 10,
|
||||
'regions' => [],
|
||||
'delivery_days_mask' => 127,
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(['status' => 'OK', 'message' => '', 'id' => '8003'], 200),
|
||||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => [
|
||||
['id' => '8001', 'src' => 'rt', 'name' => 'conn-test.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'conn-test.example.com'],
|
||||
['id' => '8002', 'src' => 'bl', 'name' => 'conn-test.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'conn-test.example.com'],
|
||||
['id' => '8003', 'src' => 'mt', 'name' => 'conn-test.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'conn-test.example.com'],
|
||||
]], 200),
|
||||
]);
|
||||
|
||||
// Listen only during the job run (factory queries above are already done).
|
||||
$projectConnections = [];
|
||||
DB::listen(function ($query) use (&$projectConnections): void {
|
||||
// '"projects"' (quoted table) does NOT match '"supplier_projects"' or
|
||||
// '"project_supplier_links"', so this captures only the projects table.
|
||||
if (str_contains($query->sql, '"projects"')) {
|
||||
$projectConnections[] = $query->connectionName;
|
||||
}
|
||||
});
|
||||
|
||||
(new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class));
|
||||
|
||||
expect($projectConnections)->not->toBeEmpty();
|
||||
expect(array_values(array_unique($projectConnections)))->toBe(['pgsql_supplier']);
|
||||
});
|
||||
|
||||
@@ -159,7 +159,7 @@ test('all-RF pool: regions=[] → 1 group subject_code=null tag=РФ → 3 suppl
|
||||
// Order: 2 projects on one (source × subject) → computeOrder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('order: 2 projects same source×subject → computeOrder(limits=[10,20]) → limit=20', function (): void {
|
||||
test('order: 2 projects same source×subject → computeOrder([10,20])=20 split across B1/B2/B3 = 7/7/6', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
Project::factory()->create([
|
||||
@@ -200,19 +200,49 @@ test('order: 2 projects same source×subject → computeOrder(limits=[10,20])
|
||||
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
|
||||
// computeOrder([10, 20]) = max(20, ceil(30/3)=10) = 20
|
||||
$sp = SupplierProject::on('pgsql_supplier')
|
||||
// computeOrder([10, 20]) = max(20, ceil(30/3)=10) = 20 (the GROUP order), then split
|
||||
// across B1/B2/B3 = 7/7/6 (Σ == 20 — NOT 20 on each = 60, which would be the ×3 overspend).
|
||||
$sps = SupplierProject::on('pgsql_supplier')
|
||||
->where('unique_key', 'order-test.example.com')
|
||||
->where('platform', 'B1')
|
||||
->first();
|
||||
|
||||
expect($sp)->not->toBeNull();
|
||||
expect($sp->current_limit)->toBe(20);
|
||||
->get();
|
||||
|
||||
// Single group → exactly 3 supplier_projects (not 6 as would happen if grouped separately)
|
||||
expect(SupplierProject::on('pgsql_supplier')
|
||||
->where('unique_key', 'order-test.example.com')
|
||||
->count())->toBe(3);
|
||||
expect($sps)->toHaveCount(3);
|
||||
expect($sps->sum('current_limit'))->toBe(20);
|
||||
expect($sps->firstWhere('platform', 'B1')->current_limit)->toBe(7);
|
||||
});
|
||||
|
||||
test('limit is DIVIDED across B1/B2/B3 so supplier total == project limit (owner-reported ×3 bug)', function (): void {
|
||||
// The owner reported (and we verified live 2026-05-21): call limit 18 → 18/18/18 on the
|
||||
// portal = supplier could deliver up to 54. The portal does NOT divide. Fix splits 18 → 6/6/6.
|
||||
$tenant = Tenant::factory()->create();
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'signal_type' => 'call',
|
||||
'signal_identifier' => '79135161263',
|
||||
'daily_limit_target' => 18,
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [],
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(['status' => 'OK', 'message' => '', 'id' => '4000'], 200),
|
||||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => [
|
||||
['id' => '4001', 'src' => 'rt', 'name' => '79135161263', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79135161263'],
|
||||
['id' => '4002', 'src' => 'bl', 'name' => '79135161263', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79135161263'],
|
||||
['id' => '4003', 'src' => 'mt', 'name' => '79135161263', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79135161263'],
|
||||
]], 200),
|
||||
]);
|
||||
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
|
||||
// Assert only THIS group's rows (the nightly job syncs every active project in the DB).
|
||||
$sps = SupplierProject::on('pgsql_supplier')->where('unique_key', '79135161263')->get();
|
||||
expect($sps)->toHaveCount(3);
|
||||
expect($sps->sum('current_limit'))->toBe(18); // Σ == project limit (not 54)
|
||||
expect($sps->sortBy('platform')->pluck('current_limit', 'platform')->all())
|
||||
->toBe(['B1' => 6, 'B2' => 6, 'B3' => 6]); // 18 / 3
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -27,13 +27,13 @@ test('GET webhook-settings возвращает подписку тенанта'
|
||||
OutboundWebhookSubscription::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'target_url' => 'https://crm.example.ru/hook',
|
||||
'target_url' => 'https://93.184.216.34/hook',
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/tenants/me/webhook-settings');
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('data.target_url'))->toBe('https://crm.example.ru/hook');
|
||||
expect($response->json('data.target_url'))->toBe('https://93.184.216.34/hook');
|
||||
expect($response->json('data'))->toHaveKeys(['target_url', 'secret_prefix', 'events', 'is_active']);
|
||||
expect($response->json('data'))->not->toHaveKey('secret_hash');
|
||||
});
|
||||
@@ -55,11 +55,11 @@ test('GET webhook-settings изолирован по тенанту', function (
|
||||
|
||||
test('PUT webhook-settings создаёт подписку и возвращает secret один раз', function () {
|
||||
$response = $this->putJson('/api/tenants/me/webhook-settings', [
|
||||
'target_url' => 'https://crm.example.ru/hook',
|
||||
'target_url' => 'https://93.184.216.34/hook',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('data.target_url'))->toBe('https://crm.example.ru/hook');
|
||||
expect($response->json('data.target_url'))->toBe('https://93.184.216.34/hook');
|
||||
expect($response->json('data.secret'))->toStartWith('whsec_');
|
||||
expect($response->json('data.events'))->toBeArray()->not->toBeEmpty();
|
||||
|
||||
@@ -72,15 +72,15 @@ test('PUT webhook-settings обновляет URL существующей по
|
||||
OutboundWebhookSubscription::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'target_url' => 'https://old.example.ru/hook',
|
||||
'target_url' => 'https://8.8.8.8/hook',
|
||||
]);
|
||||
|
||||
$response = $this->putJson('/api/tenants/me/webhook-settings', [
|
||||
'target_url' => 'https://new.example.ru/hook',
|
||||
'target_url' => 'https://1.1.1.1/hook',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('data.target_url'))->toBe('https://new.example.ru/hook');
|
||||
expect($response->json('data.target_url'))->toBe('https://1.1.1.1/hook');
|
||||
expect($response->json('data'))->not->toHaveKey('secret');
|
||||
expect(OutboundWebhookSubscription::query()->where('tenant_id', $this->tenant->id)->count())->toBe(1);
|
||||
});
|
||||
@@ -91,12 +91,20 @@ test('PUT webhook-settings: 422 при не-https URL', function () {
|
||||
])->assertStatus(422)->assertJsonValidationErrorFor('target_url');
|
||||
});
|
||||
|
||||
test('PUT webhook-settings: 422 для приватного/служебного IP в target_url (SSRF), не сохраняет', function () {
|
||||
$this->putJson('/api/tenants/me/webhook-settings', [
|
||||
'target_url' => 'https://169.254.169.254/hook',
|
||||
])->assertStatus(422)->assertJsonValidationErrorFor('target_url');
|
||||
|
||||
expect(OutboundWebhookSubscription::query()->where('tenant_id', $this->tenant->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('POST webhooks/test отправляет запрос и возвращает результат', function () {
|
||||
Http::fake(['*' => Http::response(['ok' => true], 200)]);
|
||||
OutboundWebhookSubscription::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'target_url' => 'https://crm.example.ru/hook',
|
||||
'target_url' => 'https://93.184.216.34/hook',
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/webhooks/test');
|
||||
@@ -104,7 +112,7 @@ test('POST webhooks/test отправляет запрос и возвращае
|
||||
$response->assertOk();
|
||||
expect($response->json('ok'))->toBeTrue();
|
||||
expect($response->json('status'))->toBe(200);
|
||||
Http::assertSent(fn ($req) => $req->url() === 'https://crm.example.ru/hook');
|
||||
Http::assertSent(fn ($req) => $req->url() === 'https://93.184.216.34/hook');
|
||||
});
|
||||
|
||||
test('POST webhooks/test возвращает ok=false при ошибке endpoint', function () {
|
||||
@@ -112,7 +120,7 @@ test('POST webhooks/test возвращает ok=false при ошибке endpo
|
||||
OutboundWebhookSubscription::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'target_url' => 'https://crm.example.ru/hook',
|
||||
'target_url' => 'https://93.184.216.34/hook',
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/webhooks/test');
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\OutboundWebhookSubscription;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\WebhookUrlGuard;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->user = User::factory()->for($this->tenant)->create();
|
||||
$this->actingAs($this->user);
|
||||
});
|
||||
|
||||
// --- unit: WebhookUrlGuard (IP-литералы, без DNS) ---
|
||||
|
||||
test('WebhookUrlGuard блокирует приватные/зарезервированные/loopback IP', function (string $url) {
|
||||
expect(WebhookUrlGuard::blockReason($url))->not->toBeNull();
|
||||
})->with([
|
||||
'https://127.0.0.1/hook', // loopback
|
||||
'https://10.0.0.1/hook', // private A
|
||||
'https://172.16.0.1/hook', // private B
|
||||
'https://192.168.1.1/hook', // private C
|
||||
'https://169.254.169.254/hook', // link-local / cloud metadata
|
||||
'https://[::1]/hook', // IPv6 loopback
|
||||
]);
|
||||
|
||||
test('WebhookUrlGuard пропускает публичный IP', function () {
|
||||
expect(WebhookUrlGuard::blockReason('https://93.184.216.34/hook'))->toBeNull();
|
||||
});
|
||||
|
||||
test('WebhookUrlGuard отклоняет битый URL', function () {
|
||||
expect(WebhookUrlGuard::blockReason('not-a-url'))->not->toBeNull();
|
||||
});
|
||||
|
||||
// --- endpoint: webhooks/test не должен бить во внутреннюю сеть ---
|
||||
|
||||
test('POST webhooks/test блокирует приватный IP target_url (SSRF) и не шлёт запрос', function () {
|
||||
Http::fake();
|
||||
OutboundWebhookSubscription::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'target_url' => 'https://169.254.169.254/hook',
|
||||
]);
|
||||
|
||||
$this->postJson('/api/webhooks/test')->assertStatus(422);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('POST webhooks/test пропускает публичный target_url', function () {
|
||||
Http::fake(['*' => Http::response(['ok' => true], 200)]);
|
||||
OutboundWebhookSubscription::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'target_url' => 'https://93.184.216.34/hook',
|
||||
]);
|
||||
|
||||
$this->postJson('/api/webhooks/test')
|
||||
->assertOk()
|
||||
->assertJsonPath('ok', true);
|
||||
Http::assertSentCount(1);
|
||||
});
|
||||
@@ -24,6 +24,37 @@ it('computeOrder = max(наибольший лимит, ceil(Σ/3))', function (
|
||||
'empty' => [[], 0],
|
||||
]);
|
||||
|
||||
// distributeForPlatform: split the group order across N supplier platforms so the
|
||||
// SUM of per-platform limits == order (portal does NOT divide — verified live 2026-05-21,
|
||||
// each B1/B2/B3 honors its own limit independently → must split ourselves). Largest-remainder.
|
||||
|
||||
it('distributeForPlatform splits order so per-platform limits sum to the order', function (array $platforms, int $order, array $expected): void {
|
||||
expect(SupplierQuotaAllocator::distributeForPlatform($order, $platforms))->toBe($expected);
|
||||
})->with([
|
||||
// Even split (the common case — the owner reported 18 → 18/18/18 instead of 6/6/6)
|
||||
'call/site 18→6/6/6' => [['B1', 'B2', 'B3'], 18, ['B1' => 6, 'B2' => 6, 'B3' => 6]],
|
||||
'call/site 24→8/8/8' => [['B1', 'B2', 'B3'], 24, ['B1' => 8, 'B2' => 8, 'B3' => 8]],
|
||||
'call/site 3→1/1/1' => [['B1', 'B2', 'B3'], 3, ['B1' => 1, 'B2' => 1, 'B3' => 1]],
|
||||
// Uneven split — largest remainder: leading platforms get the +1, sum stays exact
|
||||
'call/site 10→4/3/3' => [['B1', 'B2', 'B3'], 10, ['B1' => 4, 'B2' => 3, 'B3' => 3]],
|
||||
'call/site 20→7/7/6' => [['B1', 'B2', 'B3'], 20, ['B1' => 7, 'B2' => 7, 'B3' => 6]],
|
||||
// SMS+keyword (2 platforms)
|
||||
'sms+kw 5→3/2' => [['B2', 'B3'], 5, ['B2' => 3, 'B3' => 2]],
|
||||
'sms+kw 2→1/1' => [['B2', 'B3'], 2, ['B2' => 1, 'B3' => 1]],
|
||||
// SMS without keyword (1 platform) — no split, full order
|
||||
'sms 7→7' => [['B3'], 7, ['B3' => 7]],
|
||||
// Edge: zero order
|
||||
'zero' => [['B1', 'B2', 'B3'], 0, ['B1' => 0, 'B2' => 0, 'B3' => 0]],
|
||||
]);
|
||||
|
||||
it('distributeForPlatform always conserves the order (sum invariant)', function (int $order, int $count): void {
|
||||
$platforms = array_slice(['B1', 'B2', 'B3'], 0, $count);
|
||||
$shares = SupplierQuotaAllocator::distributeForPlatform($order, $platforms);
|
||||
expect(array_sum($shares))->toBe($order);
|
||||
})->with([
|
||||
[1, 3], [2, 3], [7, 3], [13, 3], [100, 3], [101, 2], [99, 1], [0, 3],
|
||||
]);
|
||||
|
||||
// Orthogonal smoke tests on allocate() — preserved from pre-T3 coverage; assert
|
||||
// invariants independent of the order formula (workdays/regions union, null-on-no-eligible).
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\SupplierRegions;
|
||||
use Tests\TestCase;
|
||||
|
||||
// Бутстрапим приложение — mapToSupplier() пишет Log::warning при отбросе непереводимых.
|
||||
uses(TestCase::class);
|
||||
|
||||
// Regression: Лидерра нумерует субъекты по конституционному порядку (RussianRegions,
|
||||
// Красноярский=29), поставщик crm.bp-gr.ru — по автокодам ГИБДД (Красноярский=24,
|
||||
// Архангельск=29). Sync слал Лидерра-код как есть → у поставщика выбирался ЧУЖОЙ регион.
|
||||
// SupplierRegions::mapToSupplier переводит Лидерра-код → код поставщика.
|
||||
|
||||
it('translates Liderra constitutional codes to supplier (ГИБДД) codes', function (): void {
|
||||
expect(SupplierRegions::mapToSupplier([29]))->toBe([24]); // Красноярский край
|
||||
expect(SupplierRegions::mapToSupplier([35]))->toBe([29]); // Архангельская обл.
|
||||
expect(SupplierRegions::mapToSupplier([24]))->toBe([21]); // Чувашская Республика
|
||||
expect(SupplierRegions::mapToSupplier([82]))->toBe([77]); // Москва
|
||||
expect(SupplierRegions::mapToSupplier([83]))->toBe([78]); // Санкт-Петербург
|
||||
});
|
||||
|
||||
it('returns empty for all-Russia (no regions)', function (): void {
|
||||
expect(SupplierRegions::mapToSupplier([]))->toBe([]);
|
||||
});
|
||||
|
||||
it('ignores sentinel 0 (Вся РФ)', function (): void {
|
||||
expect(SupplierRegions::mapToSupplier([0]))->toBe([]);
|
||||
});
|
||||
|
||||
it('drops regions the supplier does not offer', function (): void {
|
||||
// Поставщик НЕ предлагает: Московская (56), Ленинградская (53), Крым (13), новые территории.
|
||||
expect(SupplierRegions::mapToSupplier([56]))->toBe([]); // Московская обл.
|
||||
expect(SupplierRegions::mapToSupplier([53]))->toBe([]); // Ленинградская обл.
|
||||
expect(SupplierRegions::mapToSupplier([13]))->toBe([]); // Крым
|
||||
// mixed: оставляем переводимые, отбрасываем непереводимые
|
||||
expect(SupplierRegions::mapToSupplier([29, 56]))->toBe([24]); // Красноярский kept, Московская dropped
|
||||
});
|
||||
|
||||
it('dedupes and sorts supplier codes', function (): void {
|
||||
// 35→29 (Архангельск), 29→24 (Красноярский), дубль 35 → unique+sorted [24,29]
|
||||
expect(SupplierRegions::mapToSupplier([35, 29, 35]))->toBe([24, 29]);
|
||||
});
|
||||
|
||||
it('every map entry points to a distinct supplier code (no collisions)', function (): void {
|
||||
$targets = array_values(SupplierRegions::LIDERRA_TO_SUPPLIER);
|
||||
expect(count($targets))->toBe(count(array_unique($targets)));
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
# Plugin Stack Rules — Superpowers + Frontend Design (v3.20)
|
||||
# Plugin Stack Rules — Superpowers + Frontend Design (v3.21)
|
||||
|
||||
**Дата:** 21.05.2026
|
||||
**Назначение:** свод правил совместного использования плагинов Claude Code в проекте Лидерра — paired-stack ядро `obra/superpowers` (14 skills) + `anthropics/frontend-design`, плюс расширенный пул UI-инструментов `ui-ux-pro-max` (skill, marketplace `nextlevelbuilder/ui-ux-pro-max-skill`) и `21st.dev Magic MCP` (MCP-сервер `magic`), плюс инфраструктурный `claude-md-management` (skills, marketplace `anthropics/claude-plugins-official`), плюс **debug-runtime MCP** `@sentry/mcp-server` + `@modelcontextprotocol/server-redis` (v2.1+, R10.1 Блок 3). **17 правил R0–R16** (R15 off-phase routing введён в v3.14 на освободившийся после v2.0 R15-motion слот; R16 brain evidence loop введён в v3.16).
|
||||
|
||||
**v3.21** — A8 infosec-tooling install-sync: ZAP #68 + Ward #70 установлены портативно 21.05.2026 (без choco) → в R10.1 Блок 1 note (Ward) + Блок 3 (ZAP MCP-row) снят статус PENDING INSTALL. Содержательных изменений R0–R16: 0; счётчики/состав без изменений. Связано: Tooling v2.21, Pravila v1.38, CLAUDE.md v2.25; setup-доки `docs/security/{zap,ward}-setup.md`; план `docs/superpowers/plans/2026-05-21-a8-infosec-tooling.md`.
|
||||
|
||||
**v3.20** — A8 infosec-tooling: R10.1 Блок 1 note +infosec-tooling (#69 Nuclei + #70 Ward — CLI-бинари; #71 pdn-152fz-audit / #72 threat-model / #73 security-go-live — self-authored project-скилы) + Блок 3 +OWASP ZAP MCP (#68, PENDING INSTALL — нет Java). Nuclei установлен+verified (CLI, не MCP); Ward заменил Enlightn (abandoned/L13), PENDING INSTALL — нет Go. Каждый внешний инструмент прошёл провенанс-вет IS9 ДО установки (риск ToxicSkills). Новая 17-я off-phase подкатегория infosec-tooling, раздел A8 карты. Не UI → вне R6.0/R6.1/R14. Содержательных изменений R0–R16: 0. Связано: Tooling v2.20, Pravila v1.37, CLAUDE.md v2.24, ADR-014 (IS1–IS9); план `docs/superpowers/plans/2026-05-21-a8-infosec-tooling.md`.
|
||||
|
||||
**v3.19** — A1 backend-tooling: R10.1 Блок 1 note +backend-tooling (#64 Rector + #65 PHP Insights — Composer dev-deps; #66 laravel-backend-patterns — self-authored project-скил; #67 NightOwl — DEFERRED, MCP при активации). Новая 16-я off-phase подкатегория backend-tooling, раздел A1 карты. R15.6 +backend-tooling в список категорий. Не UI → вне R6.0/R6.1/R14. Содержательных изменений R0–R16: 0. Связано: Tooling v2.19, Pravila v1.35, CLAUDE.md v2.22, ADR-013; план `docs/superpowers/plans/2026-05-20-a1-backend-tooling.md`.
|
||||
@@ -461,7 +463,7 @@ Stack — **головной**. Все плагины вне stack'а — **ин
|
||||
|
||||
**Блок 1 — note (v3.19):** **Rector** (Tooling #64) + **PHP Insights** (Tooling #65) — Composer dev-dependencies (`rector/rector` + `driftingly/rector-laravel`; `nunomaduro/phpinsights`), **не** marketplace-плагины и **не** в `enabledPlugins` (как deptrac #43 / promptfoo #48). CLI-инструменты: Rector — авто-рефакторинг/version-upgrade (`composer rector`/`rector:fix`), manual/CI, dry-run baseline 16 файлов → **не** блокирующий lefthook; PHP Insights — метрики complexity/architecture (`composer insights`), on-demand/CI с порогами → **не** блокирующий (BT9). **laravel-backend-patterns** (Tooling #66) — self-authored project-скил в `.claude/skills/laravel-backend-patterns/`, **линтуется** (LINT1, как billing-audit/process-*). **NightOwl** (Tooling #67) — `laravel/nightwatch` + self-hosted `lemed99/nightowl-agent`, **DEFERRED** (native-Windows нет pcntl/posix; OSS без MCP; hosted 152-ФЗ); при активации (Linux/Б-1) — MCP в Блок 3 или Boost `database-query`. Категория **backend-tooling** (16-я off-phase подкатегория, раздел A1 карты), вне R6.0/R6.1/R14. ADR-013.
|
||||
|
||||
**Блок 1 — note (v3.20):** **Nuclei** (Tooling #69) + **Ward** (Tooling #70) — CLI-бинари (как deptrac #43 / gitleaks / squawk), **не** marketplace-плагины и **не** в `enabledPlugins`. Nuclei (`projectdiscovery/nuclei` v3.8.0, MIT, Go) — `bin/nuclei.exe`, **установлен+verified**; широкое сканирование известных уязвимостей; **CLI, не MCP** (nuclei не говорит на MCP → нет Блока 3 / l1-watcher alias). Ward (`Eljakani/ward`, MIT, Go) — безопасность настроек Laravel; **ЗАМЕНИЛ Enlightn** (abandoned/L13); **PENDING INSTALL** (нет Go; choco отклонён). **pdn-152fz-audit** (#71) + **threat-model** (#72) + **security-go-live** (#73) — self-authored project-скилы в `.claude/skills/`, **линтуются** (LINT1, как billing-audit/process-*). Каждый внешний инструмент прошёл провенанс-вет IS9 (`docs/security/infosec-vet.md`) ДО установки (риск ToxicSkills). Категория **infosec-tooling** (17-я off-phase подкатегория, раздел A8 карты), вне R6.0/R6.1/R14. ADR-014 (IS1–IS9).
|
||||
**Блок 1 — note (v3.20):** **Nuclei** (Tooling #69) + **Ward** (Tooling #70) — CLI-бинари (как deptrac #43 / gitleaks / squawk), **не** marketplace-плагины и **не** в `enabledPlugins`. Nuclei (`projectdiscovery/nuclei` v3.8.0, MIT, Go) — `bin/nuclei.exe`, **установлен+verified**; широкое сканирование известных уязвимостей; **CLI, не MCP** (nuclei не говорит на MCP → нет Блока 3 / l1-watcher alias). Ward (`Eljakani/ward`, MIT, Go) — безопасность настроек Laravel; **ЗАМЕНИЛ Enlightn** (abandoned/L13); **установлен 21.05** портативно (собран portable Go → `bin/ward.exe` v0.4.1, `docs/security/ward-setup.md`). **pdn-152fz-audit** (#71) + **threat-model** (#72) + **security-go-live** (#73) — self-authored project-скилы в `.claude/skills/`, **линтуются** (LINT1, как billing-audit/process-*). Каждый внешний инструмент прошёл провенанс-вет IS9 (`docs/security/infosec-vet.md`) ДО установки (риск ToxicSkills). Категория **infosec-tooling** (17-я off-phase подкатегория, раздел A8 карты), вне R6.0/R6.1/R14. ADR-014 (IS1–IS9).
|
||||
|
||||
**Отмена:** через удаление из `enabledPlugins` в `~/.claude/settings.json` или через live-override `/имя-плагина` (R0.4.B) на одно действие.
|
||||
|
||||
@@ -497,7 +499,7 @@ Stack — **головной**. Все плагины вне stack'а — **ин
|
||||
| **openapi-mcp-server** *(`openapi` сервер, tools `mcp__openapi__*`)* | `.mcp.json` (stdio MCP, env `OPENAPI_SPEC_URL` или локальный файл) | **integration-tooling MCP** — OpenAPI/Swagger-спецификации интеграций (inspect, introspect внешних API). Категория: **integration-tooling** (Tooling §4.22 #47). Раздел A3 карты «Программирование — интеграции (API, вебхуки)». Off-phase | при работе с внешними API-интеграциями (introspection спецификаций). **READ-ONLY introspection** — не мутировать внешние API из Claude. Не trigger'ит R6.0/R6.1 фильтры и не входит в R14 pipeline UI-генераторов. Вне R6/R14 |
|
||||
| **Jupyter MCP** *(`jupyter` сервер)* — **DEFERRED** | `.mcp.json` (stdio MCP) — не установлен, precondition: Python ML-окружение | **ml-ai-tooling MCP** — исполняемые ноутбуки (классический ML: обучение моделей). Категория: **ml-ai-tooling** (Tooling §4.25 #50). Раздел A11 карты «ML / AI-разработка». Off-phase | DEFERRED — на native-Windows машине нет Python ML-рантайма и нет модели для обучения. Зарегистрирован как pending-слот (как Figma MCP); устанавливается отдельной severable-задачей при появлении конкретной модели. Вне R6/R14 |
|
||||
| **n8n-mcp** *(`n8n` сервер)* — **DEFERRED** | `.mcp.json` (stdio MCP) — не установлен, precondition: принятие n8n в стек портала | **business-process MCP** — workflow-движок платформы n8n (построение/запуск автоматизированных workflow). Категория: **business-process** (Tooling §4.29 #54). Раздел C10 карты «Бизнес-процессы (общее)». Off-phase | DEFERRED — стек Лидерры не содержит n8n (движок процессов = очередь Laravel + события/джобы); принятие n8n как инфраструктуры — отдельное архитектурное решение (свой ADR), не выбор инструмента (N8N1). Зарегистрирован как pending-слот (как Figma MCP / Jupyter MCP); устанавливается отдельной severable-задачей. Вне R6/R14 |
|
||||
| **OWASP ZAP MCP** *(`zap` сервер, официальный ZAP «MCP Integration» add-on)* — **PENDING INSTALL** | `.mcp.json` (при установке) — не установлен, precondition: Java 17+ + ZAP add-on (способ choco отклонён) | **infosec-tooling MCP** — глубокая боевая DAST работающего портала (spider + active scan: обход входа, инъекции, XSS). Категория: **infosec-tooling** (Tooling §4.43 #68). Раздел A8 карты. Off-phase | PENDING INSTALL — нужна Java (на native-Windows нет). Цель по умолчанию **локальная копия** (127.0.0.1), бой — только по явной команде (IS8). READ-only сканер. Провенанс OWASP/Checkmarx (IS9-вет). Не trigger'ит R6.0/R6.1 и не входит в R14 pipeline. Вне R6/R14. ADR-014 |
|
||||
| **OWASP ZAP MCP** *(`zap` сервер, официальный ZAP «MCP Integration» add-on)* — **установлен 21.05** | `bin/ZAP_2.17.0/` + MCP-аддон `mcp-alpha-0.0.1` на portable Temurin JRE 17 (`bin/_runtimes/`, без choco); MCP-эндпоинт (SSE) регистрируется в `.mcp.json` при запущенном ZAP-демоне (`docs/security/zap-setup.md`) | **infosec-tooling MCP** — глубокая боевая DAST работающего портала (spider + active scan: обход входа, инъекции, XSS). Категория: **infosec-tooling** (Tooling §4.43 #68). Раздел A8 карты. Off-phase | Установлен (daemon API verified → 2.17.0); MCP-аддон alpha. Цель по умолчанию **локальная копия** (127.0.0.1), бой — только по явной команде (IS8). READ-only сканер. Провенанс OWASP/Checkmarx (IS9-вет). Не trigger'ит R6.0/R6.1 и не входит в R14 pipeline. Вне R6/R14. ADR-014 |
|
||||
|
||||
**Отмена:** через удаление из `~/.claude.json` или `.mcp.json`. Live-override через `/команду` для MCP не предусмотрен — MCP-серверы не имеют slash-интерфейса.
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
# Правила работы Claude в проекте «Лидерра»
|
||||
|
||||
**Версия:** v1.37 (21.05.2026)
|
||||
**Версия:** v1.38 (21.05.2026)
|
||||
**Дата:** 21.05.2026
|
||||
**Назначение:** настройки проекта (Project instructions) — Claude читает этот файл в каждом чате и следует правилам ниже.
|
||||
**Статус документа:** ✅ утверждён. Содержимое скопировано в поле "Project instructions" Claude.ai. Файл хранится в архиве как служебный документ.
|
||||
|
||||
**Что изменилось в v1.38 относительно v1.37:** A8 infosec install-sync — ZAP #68 + Ward #70 установлены портативно 21.05.2026 (без choco, по выбору заказчика «оба портативно») → в §13.2 абзаце «Off-phase infosec-tooling» статус **PENDING INSTALL снят** для обоих (ZAP: ZAP 2.17.0 + MCP-аддон на portable Temurin JRE 17; Ward: собран portable Go → `bin/ward.exe` v0.4.1); setup-доки `docs/security/{zap,ward}-setup.md`. Архитектурных изменений §§1–16: 0. Связано: Tooling v2.21, PSR_v1 v3.21, CLAUDE.md v2.25; план `docs/superpowers/plans/2026-05-21-a8-infosec-tooling.md`.
|
||||
|
||||
**Что изменилось в v1.37 относительно v1.36:** A8 infosec-tooling — §13.2 +абзац «Off-phase infosec-tooling»: #68 OWASP ZAP (MCP DAST, **PENDING INSTALL** — нет Java), #69 Nuclei (CLI, установлен+verified), #70 Ward (CLI, заменил abandoned Enlightn, **PENDING INSTALL** — нет Go), #71 pdn-152fz-audit + #72 threat-model + #73 security-go-live (self-authored project-скилы). 17-я off-phase подкатегория, раздел A8. Провенанс-вет IS9 каждого внешнего ДО установки (риск ToxicSkills). Серверный слой (WAF/DDoS/мониторинг и т.д.) — out of scope, открытые вопросы SEC-1..SEC-7 (Б-1). Не UI → вне R6.0/R6.1/R14. Границы — ADR-014 (IS1–IS9). Архитектурных изменений §§1–12, §14–§16: 0. Связано: Tooling v2.20, PSR_v1 v3.20, CLAUDE.md v2.24; план `docs/superpowers/plans/2026-05-21-a8-infosec-tooling.md`. **NB:** перенумеровано v1.36→v1.37 при ребейзе на origin/main — v1.36 параллельно занят observer missed-activations.
|
||||
|
||||
**Что изменилось в v1.36 относительно v1.35:** §16.4 расширен симметрией missed activation (условное правило): §16.4 заголовок уточнён «(условное)»; тело расширено — поведенческое правило теперь содержит условие «если профильной задачи в эпизодах не было»; добавлено **симметричное правило (missed activation)**: эпизоды с профильной классификацией без активации релевантного non-dormant узла — сигнал, surface в STATUS.md (C5: `missed_activations: N`, ⚠️ при N>0) и в выводе `/brain-retro`, не блок коммита; хранение mapping в `tools/observer-classification-map.json` + `tools/.node-dormancy.json` (двойной сигнал dormant=true ИЛИ DEFERRED в boundaries); DEFERRED-узлы (#17/#44/#50/#54/#67) — в missed activations не учитываются. Архитектурных изменений в §§1–15: 0. Связано: план `docs/superpowers/plans/2026-05-21-observer-missed-activations.md`.
|
||||
@@ -770,7 +772,7 @@ Frontend Design и `obra/superpowers` (v5.1.0, 14 skills) — **парный sta
|
||||
|
||||
**Off-phase backend-tooling (A1, v1.35, 20.05.2026):** Инструменты раздела A1 карты «Программирование — backend» — #64 `Rector` + `rector-laravel` (Tooling §4.39; Composer dev-dependencies `rector/rector` + `driftingly/rector-laravel`, авто-рефакторинг/version-upgrade; конфиг `app/rector.php` deadCode+codeQuality conservative; постура manual/CI `composer rector`/`rector:fix` — dry-run baseline 16 файлов → **не** блокирующий lefthook, прецедент promptfoo ML1), #65 `PHP Insights` (Tooling §4.40; Composer dev-dependency `nunomaduro/phpinsights`; метрики complexity/architecture; конфиг `app/config/insights.php` — SyntaxCheck removed из-за Windows subprocess-краша, style-ось off — владелец Pint, BT4; постура on-demand/CI `composer insights` с порогами → **не** блокирующий, BT9), #66 `laravel-backend-patterns` (Tooling §4.41; self-authored project-скил `.claude/skills/laravel-backend-patterns/` — backend-конвенции Лидерры: слоистость/RLS-aware/bcmath-деньги/идемпотентность/partition-aware; **линтуется**, LINT1), #67 `NightOwl` (Tooling §4.42; `laravel/nightwatch` + self-hosted `lemed99/nightowl-agent` — коррелированный runtime-трейс; **DEFERRED**: native-Windows нет pcntl/posix, OSS без MCP, hosted 152-ФЗ; pending Б-1/Linux). Плюс reuse существующих узлов A1 (Boost #10, Pint #11, Larastan #12). **Шестнадцатая** off-phase подкатегория. Off-phase, не UI → вне R6.0/R6.1/R14 PSR_v1. Rector/PHP Insights **не гейтят коммит** (manual/CI — избегаем дубля с Pint/Larastan/deptrac + авто-мутации кода). Границы — ADR-013 (BT1–BT9). Регулируется PSR_v1 R10.1 Блок 1 note. Установлено 20.05.2026 на ветке `worktree-a1-backend-tooling`; план `docs/superpowers/plans/2026-05-20-a1-backend-tooling.md`.
|
||||
|
||||
**Off-phase infosec-tooling (A8, v1.37, 21.05.2026):** Инструменты раздела A8 карты «Информационная безопасность» — портал готовится к публичному запуску в интернете. #68 `OWASP ZAP` (Tooling §4.43; официальный ZAP «MCP Integration» add-on `zaproxy/zap-extensions`, Apache-2.0; глубокая боевая DAST — обход входа, инъекции, XSS; MCP-сервер; **PENDING INSTALL** — нет Java на native-Windows, способ choco отклонён заказчиком; цель по умолчанию локальная 127.0.0.1, бой только по явной команде — IS8), #69 `Nuclei` (Tooling §4.44; `projectdiscovery/nuclei` v3.8.0 MIT, Go-бинарь `bin/nuclei.exe` — широкая проверка известных уязвимостей/экспозиции/TLS; **CLI, не MCP**; **установлен+verified** на живом портале; квирки native-Windows: цель `127.0.0.1` не `localhost`, низкий rate-limit для однопоточного dev-сервера), #70 `Ward` (Tooling §4.45; `Eljakani/ward` MIT, Go CLI — безопасность настроек Laravel: .env/config/заголовки/cookie/secrets/deps; **ЗАМЕНИЛ Enlightn** — тот abandoned + без поддержки Laravel 13; **PENDING INSTALL** — нет Go), #71 `pdn-152fz-audit` + #72 `threat-model` + #73 `security-go-live` (Tooling §4.46-4.48; self-authored project-скилы `.claude/skills/` — аудит ПДн+соответствие 152-ФЗ / STRIDE-моделирование угроз going-public / go-live security-gate оркестратор; **линтуются**, LINT1). Каждый внешний инструмент прошёл провенанс-вет IS9 (`docs/security/infosec-vet.md`) ДО установки (риск ToxicSkills ≈13% security-скилов с дефектами). **Семнадцатая** off-phase подкатегория. Off-phase, не UI → вне R6.0/R6.1/R14 PSR_v1. Серверный слой защиты (WAF / anti-brute-force / DDoS / мониторинг вторжений / secrets-vault / TLS-HSTS-CSP / бэкапы+IR-runbook) — **out of scope**, открытые вопросы инфраструктуры (привязка к Б-1, SEC-1..SEC-7). Границы — ADR-014 (IS1–IS9). Регулируется PSR_v1 R10.1 Блок 1 note (Nuclei/Ward CLI + 3 скила) + Блок 3 (ZAP MCP). Установлено 21.05.2026 на ветке `worktree-a8-infosec-tooling`; план `docs/superpowers/plans/2026-05-21-a8-infosec-tooling.md`.
|
||||
**Off-phase infosec-tooling (A8, v1.38, 21.05.2026):** Инструменты раздела A8 карты «Информационная безопасность» — портал готовится к публичному запуску в интернете. #68 `OWASP ZAP` (Tooling §4.43; официальный ZAP «MCP Integration» add-on `zaproxy/zap-extensions`, Apache-2.0; глубокая боевая DAST — обход входа, инъекции, XSS; MCP-сервер; **установлен 21.05** портативно — ZAP 2.17.0 + MCP-аддон на portable Temurin JRE 17, без choco, `docs/security/zap-setup.md`; цель по умолчанию локальная 127.0.0.1, бой только по явной команде — IS8), #69 `Nuclei` (Tooling §4.44; `projectdiscovery/nuclei` v3.8.0 MIT, Go-бинарь `bin/nuclei.exe` — широкая проверка известных уязвимостей/экспозиции/TLS; **CLI, не MCP**; **установлен+verified** на живом портале; квирки native-Windows: цель `127.0.0.1` не `localhost`, низкий rate-limit для однопоточного dev-сервера), #70 `Ward` (Tooling §4.45; `Eljakani/ward` MIT, Go CLI — безопасность настроек Laravel: .env/config/заголовки/cookie/secrets/deps; **ЗАМЕНИЛ Enlightn** — тот abandoned + без поддержки Laravel 13; **установлен 21.05** портативно — собран portable Go → `bin/ward.exe` v0.4.1, без choco, `docs/security/ward-setup.md`), #71 `pdn-152fz-audit` + #72 `threat-model` + #73 `security-go-live` (Tooling §4.46-4.48; self-authored project-скилы `.claude/skills/` — аудит ПДн+соответствие 152-ФЗ / STRIDE-моделирование угроз going-public / go-live security-gate оркестратор; **линтуются**, LINT1). Каждый внешний инструмент прошёл провенанс-вет IS9 (`docs/security/infosec-vet.md`) ДО установки (риск ToxicSkills ≈13% security-скилов с дефектами). **Семнадцатая** off-phase подкатегория. Off-phase, не UI → вне R6.0/R6.1/R14 PSR_v1. Серверный слой защиты (WAF / anti-brute-force / DDoS / мониторинг вторжений / secrets-vault / TLS-HSTS-CSP / бэкапы+IR-runbook) — **out of scope**, открытые вопросы инфраструктуры (привязка к Б-1, SEC-1..SEC-7). Границы — ADR-014 (IS1–IS9). Регулируется PSR_v1 R10.1 Блок 1 note (Nuclei/Ward CLI + 3 скила) + Блок 3 (ZAP MCP). Установлено 21.05.2026 на ветке `worktree-a8-infosec-tooling`; план `docs/superpowers/plans/2026-05-21-a8-infosec-tooling.md`.
|
||||
|
||||
### 13.3. Скоуп
|
||||
|
||||
|
||||
+11
-11
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
# ADR-014: A8 infosec-tooling — наполнение раздела карты A8
|
||||
|
||||
**Status:** Accepted
|
||||
**Status:** Accepted (amended 21.05.2026 — ZAP #68 + Ward #70 установлены портативно, статус PENDING INSTALL снят; см. Decision п.1/п.3 + Consequences)
|
||||
**Date:** 2026-05-21
|
||||
**Контекст:** эпик A8 infosec-tooling, spec `docs/superpowers/specs/2026-05-21-a8-infosec-tooling-design.md`, plan `docs/superpowers/plans/2026-05-21-a8-infosec-tooling.md`, провенанс-вет `docs/security/infosec-vet.md`.
|
||||
|
||||
@@ -33,9 +33,9 @@ D3 (audit-security) уже покрывает Anthropic-арсенал (Security
|
||||
Apache-2.0). Глубокая DAST (spider + active scan): обход входа, инъекции, XSS.
|
||||
- **Постура:** on-demand, READ-only сканер, цель по умолчанию **локальная копия**
|
||||
(127.0.0.1), бой — только по явной команде (IS8). MCP-сервер в `.mcp.json`.
|
||||
- **Статус: PENDING INSTALL** — требует Java 17+ (на native-Windows не установлена);
|
||||
add-on alpha (v0.1.0). Установка отложена до решения заказчика по способу (choco
|
||||
отклонён). Прецедент pending-узла: Sentry #34 / NightOwl #67.
|
||||
- **Статус: УСТАНОВЛЕН 21.05.2026** (портативно, без choco) — ZAP cross-platform 2.17.0
|
||||
с MCP-аддоном `mcp-alpha-0.0.1` на portable Temurin JRE 17 (`bin/ZAP_2.17.0/`, gitignored);
|
||||
daemon API verified → 2.17.0. Add-on alpha. Доку: `docs/security/zap-setup.md`.
|
||||
2. **Nuclei (#69)** — `projectdiscovery/nuclei` v3.8.0 (MIT), Go-бинарь `bin/nuclei.exe`.
|
||||
Широкая проверка по YAML-шаблонам (известные CVE, экспозиция, TLS).
|
||||
- **Тип: CLI-инструмент, НЕ MCP-сервер.** Nuclei не говорит на протоколе MCP;
|
||||
@@ -52,8 +52,10 @@ D3 (audit-security) уже покрывает Anthropic-арсенал (Security
|
||||
зависит от версии Laravel** → проблема снята. Заказчик выбрал «подобрать замену».
|
||||
Обоснование — `docs/security/infosec-vet.md` §ПЕРЕСМОТР #70. Pin по commit SHA (релизов нет).
|
||||
- **Тип: CLI-инструмент** (как Nuclei), не MCP, не Composer dev-dep.
|
||||
- **Статус: PENDING INSTALL** — требует Go-тулчейн (не установлен; choco отклонён).
|
||||
- Caveat: молодой (фев 2026), single-maintainer → bus-factor; митигация — SHA-pin + MIT-форк.
|
||||
- **Статус: УСТАНОВЛЕН 21.05.2026** (портативно, без choco) — собран из исходника через
|
||||
portable Go 1.26.3 (`go install github.com/eljakani/ward@v0.4.1`) → `bin/ward.exe` v0.4.1;
|
||||
smoke `app/` → 2 находки (High APP_DEBUG, Medium APP_ENV). Доку: `docs/security/ward-setup.md`.
|
||||
- Caveat: молодой (фев 2026), single-maintainer → bus-factor; митигация — версия-pin + MIT-форк.
|
||||
4. **pdn-152fz-audit (#71)** — self-authored project-скил. Аудит ПДн + соответствие 152-ФЗ
|
||||
(2 режима: техника + закон), заземлён в `db/schema.sql`. Активен.
|
||||
5. **threat-model (#72)** — self-authored project-скил. STRIDE под наш портал, going-public,
|
||||
@@ -91,7 +93,7 @@ secrets vault, TLS/HSTS/CSP, бэкапы + IR-runbook) — **out of scope** э
|
||||
|
||||
**Positive:**
|
||||
|
||||
- A8 непуст: 0 → 6 дедицированных узлов. Активны: Nuclei #69 (verified) + 3 скила #71/#72/#73. Pending install: ZAP #68 (Java), Ward #70 (Go).
|
||||
- A8 непуст: 0 → 6 дедицированных узлов. **Все установлены (21.05.2026):** Nuclei #69 + Ward #70 (CLI в `bin/`) + ZAP #68 (portable JRE 17, daemon verified) + 3 скила #71/#72/#73.
|
||||
- Новая off-phase подкатегория `infosec-tooling` (17-я).
|
||||
- Провенанс-вет (IS9) каждого внешнего инструмента до установки — расширяет ADR-003-дисциплину; чужие security-скилы в чувствительные слоты (#71/#72) не тащим (ToxicSkills).
|
||||
- 152-ФЗ + угрозы-под-наш-портал сделаны своими скилами (РФ-/project-specific), а не generic-готовым.
|
||||
@@ -99,7 +101,7 @@ secrets vault, TLS/HSTS/CSP, бэкапы + IR-runbook) — **out of scope** э
|
||||
|
||||
**Negative:**
|
||||
|
||||
- ZAP #68 (alpha MCP + Java) и Ward #70 (Go) — pending install; capability задокументирована, физическая установка отложена (способ choco отклонён). До установки go-live-gate #73 на этих шагах возвращает PENDING, не GO.
|
||||
- ZAP #68 (alpha MCP + Java) и Ward #70 (Go) — **установлены портативно 21.05.2026** (без choco, по выбору заказчика «оба портативно»; setup-доки `docs/security/{zap,ward}-setup.md`). Footprint ~1.2 ГБ (Go SDK + JRE + ZAP) в `bin/*` gitignored. go-live-gate #73: шаг ZAP возвращает PENDING лишь при незапущенном ZAP-демоне (MCP-режим требует живого демона).
|
||||
- Ward — молодой single-maintainer проект (bus-factor); митигация SHA-pin + MIT-форкабельность.
|
||||
- Nuclei добавляет 126 МБ бинарь в `bin/` (gitignored, машинно-локальный) + 13k шаблонов.
|
||||
- ПДн-скил полагается на pg_anonymizer, который сам DEFERRED (OPEN-И-24, фаза 3) — чек-лист честно помечает «проверить вручную».
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
> **Источник истины.** Tooling §4.X (детальное описание каждого узла), Pravila §13.2
|
||||
> (категоризация off-phase), PSR_v1 R10.1 (3-блочный реестр ролей).
|
||||
>
|
||||
> **Версия.** 1.4 (21.05.2026 — A8 infosec-tooling: +6 строк routing #68-#73 + связка L15 (security go-live chain), ADR-014; #69 Nuclei/#70 Ward — CLI (не MCP), #68 ZAP/#70 Ward pending install. 1.3 (20.05.2026) — A1 backend-tooling: +4 строки routing #64-#67 + связка L14 + scope §4.11→§4.42, ADR-013. v1.2 — finance-tooling: +3 строки routing #61-#63 + связка L13 + scope, ADR-012. v1.1 18.05.2026 вечер — аудит дисциплины R15: +строка «диагностика
|
||||
> **Версия.** 1.5 (21.05.2026 — A8 install-sync: #68 ZAP + #70 Ward установлены портативно → строки routing #68/#70 обновлены, статус pending install снят, setup-доки `docs/security/{zap,ward}-setup.md`). 1.4 (21.05.2026 — A8 infosec-tooling: +6 строк routing #68-#73 + связка L15 (security go-live chain), ADR-014; #69 Nuclei/#70 Ward — CLI (не MCP), #68 ZAP/#70 Ward pending install. 1.3 (20.05.2026) — A1 backend-tooling: +4 строки routing #64-#67 + связка L14 + scope §4.11→§4.42, ADR-013. v1.2 — finance-tooling: +3 строки routing #61-#63 + связка L13 + scope, ADR-012. v1.1 18.05.2026 вечер — аудит дисциплины R15: +строка «диагностика
|
||||
> конверсии» → process-analysis #53 (M3); +note про UI-пул #31/#32 как делегирующие
|
||||
> строки, не R15-routed (M1). v1.0 — Rec3 SYSTEM-аудита). Триггеры — формулировки
|
||||
> заказчика или явные ключевые слова в промпте.
|
||||
@@ -62,9 +62,9 @@
|
||||
| Метрики качества / сложности / архитектуры PHP-кода | **PHP Insights** | #65 | backend-tooling | on-demand/CI (`composer insights`), не блокирующий (BT9, ADR-013) |
|
||||
| Как писать backend в Лидерре (контроллер/сервис/джоб, RLS, деньги, идемпотентность, партиции) | **laravel-backend-patterns** (project-скил) | #66 | backend-tooling | trigger-based; ≠ #38 generic / ≠ #62 audit (ADR-013) |
|
||||
| Коррелированный runtime-трейс request↔job↔query (self-hosted) | **NightOwl** | #67 | backend-tooling | **DEFERRED** — нет pcntl/posix на Windows; pending Б-1 (ADR-013) |
|
||||
| Глубокая «боевая» проверка работающего портала (обход входа, инъекции, XSS) | **OWASP ZAP** (MCP) | #68 | infosec-tooling | DAST; цель по умолч. 127.0.0.1 (IS8); **pending install** (Java); ADR-014 |
|
||||
| Глубокая «боевая» проверка работающего портала (обход входа, инъекции, XSS) | **OWASP ZAP** (MCP) | #68 | infosec-tooling | DAST; цель по умолч. 127.0.0.1 (IS8); установлен портативно (portable JRE 17, `docs/security/zap-setup.md`); ADR-014 |
|
||||
| Известные уязвимости / открытые двери / слабый TLS снаружи | **Nuclei** (CLI) | #69 | infosec-tooling | `bin/nuclei.exe`, цель **127.0.0.1** (не localhost); CLI не MCP; ADR-014 |
|
||||
| Безопасность настроек Laravel (.env/config/заголовки/cookie/secrets/deps) | **Ward** (CLI) | #70 | infosec-tooling | Go-бинарь; заменил Enlightn (abandoned/L13); **pending install** (Go); ADR-014 |
|
||||
| Безопасность настроек Laravel (.env/config/заголовки/cookie/secrets/deps) | **Ward** (CLI) | #70 | infosec-tooling | Go-бинарь `bin/ward.exe` v0.4.1; заменил Enlightn (abandoned/L13); установлен портативно (`docs/security/ward-setup.md`); ADR-014 |
|
||||
| Аудит ПДн / соответствие 152-ФЗ | **pdn-152fz-audit** (project-скил) | #71 | infosec-tooling | 2 режима техника+закон; ≠ pg_anonymizer #29 (IS4) / D2 (IS5) |
|
||||
| Моделирование угроз STRIDE / что защищать перед публикацией | **threat-model** (project-скил) | #72 | infosec-tooling | going-public; ≠ ToB #39 generic (IS6) |
|
||||
| Прогон безопасности перед релизом / go-no-go | **security-go-live** (project-скил) | #73 | infosec-tooling | оркеструет #68-72 + D3; ≠ audit-portal (IS7) |
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# Ward (#70) — установка и использование
|
||||
|
||||
**Узел A8:** #70 — безопасность настроек Laravel (.env / config / заголовки / cookie / secrets / deps).
|
||||
**Источник (IS9-вет принят):** `Eljakani/ward` (MIT, Go), **заменил** Enlightn (abandoned + без поддержки Laravel 13 — см. `infosec-vet.md` §ПЕРЕСМОТР #70).
|
||||
**Тип:** CLI-сканер (Go-бинарь) — **не MCP-сервер, не Composer dev-dep** (как Nuclei #69 / gitleaks #8). Go-бинарь → **не зависит от версии Laravel** (проблема Enlightn снята).
|
||||
|
||||
---
|
||||
|
||||
## Установка (native-Windows, портативно, без choco)
|
||||
|
||||
Готовых бинарей в релизе Ward нет — только `go install`. Go ставится **портативно** (zip, без choco), всё под `bin/` (gitignored).
|
||||
|
||||
```powershell
|
||||
# 1. Portable Go (официальный zip, проверка SHA256)
|
||||
$ProgressPreference='SilentlyContinue'
|
||||
Invoke-WebRequest -Uri 'https://go.dev/dl/go1.26.3.windows-amd64.zip' -OutFile 'bin\_dl\go.zip' -UseBasicParsing
|
||||
# ожидаемый SHA256: 20d2ceafb4ed41b96b879010927b28bc92a5be57a7c1801ce365a9ca51d3224a
|
||||
Expand-Archive 'bin\_dl\go.zip' -DestinationPath 'bin\_runtimes' -Force # → bin\_runtimes\go\
|
||||
|
||||
# 2. Собрать Ward (локальные GOPATH/GOCACHE — всё остаётся под bin/)
|
||||
$root=(Get-Location).Path
|
||||
$env:GOROOT="$root\bin\_runtimes\go"; $env:GOPATH="$root\bin\_runtimes\gopath"; $env:GOCACHE="$root\bin\_runtimes\gocache"
|
||||
$env:PATH="$env:GOROOT\bin;$env:PATH"
|
||||
& "$env:GOROOT\bin\go.exe" install github.com/eljakani/ward@v0.4.1
|
||||
|
||||
# 3. Положить бинарь рядом с прочими security-CLI
|
||||
Copy-Item "$env:GOPATH\bin\ward.exe" 'bin\ward.exe'
|
||||
```
|
||||
|
||||
- **Расположение:** `bin/ward.exe` (рядом с nuclei/gitleaks/lychee/squawk; `bin/*` в `.gitignore` → бинарь машинно-локальный, в репозиторий не коммитится).
|
||||
- **Go SDK** (`bin/_runtimes/go`, ~256 МБ) сохранён для обновлений (`go install ...@latest`); можно удалить — `ward.exe` статичный и работает без Go.
|
||||
- **Verified (2026-05-21):** `bin\ward.exe version` → v0.4.1.
|
||||
|
||||
## Smoke (verified 2026-05-21)
|
||||
|
||||
```powershell
|
||||
bin\ward.exe scan app -o json --no-color
|
||||
```
|
||||
|
||||
Результат: 2 находки в Laravel-приложении `app/` — **[High] APP_DEBUG включён**, **[Medium] APP_ENV = 'local'** (env-scanner: 2, config-scanner: 0, dependency-scanner: 0). Это ожидаемые dev-настройки, и одновременно — те самые go-live-проблемы, которые Ward и должен ловить (перед публикацией нужны `APP_DEBUG=false` + `APP_ENV=production`). Доказывает: Ward устанавливается и реально сканирует проект.
|
||||
|
||||
## Использование
|
||||
|
||||
```powershell
|
||||
# несколько форматов сразу; report-файл(ы) пишутся в текущую папку
|
||||
bin\ward.exe scan app -o json,sarif,html --no-color
|
||||
|
||||
# гейт по severity (exit 1 при находках ≥ уровня) — для CI/go-live
|
||||
bin\ward.exe scan app --fail-on high --no-color
|
||||
|
||||
# подавить известные находки baseline-файлом
|
||||
bin\ward.exe scan app --baseline docs/security/ward-baseline.json --no-color
|
||||
```
|
||||
|
||||
- **TUI по умолчанию** (`-o tui`) — в неинтерактивной оболочке зависнет; всегда задавать `-o json`/`sarif`/`html`/`markdown`.
|
||||
- **Артефакт:** `ward scan ... -o json` пишет `ward-report.json` в CWD — это временный отчёт, не коммитить.
|
||||
- **Сеть:** локальный анализ кода/конфигов; единственный outbound — OSV.dev для проверки CVE в зависимостях (как Enlightn security-checker — функциональный запрос, не телеметрия).
|
||||
|
||||
## Границы (ADR-014)
|
||||
|
||||
IS3 — Ward (misconfig/secrets/deps Laravel) ≠ Larastan #12 (типы) ≠ Semgrep #25 (generic-паттерны кода). Dep-скан Ward ↔ Trivy #26 / Dependabot #27 — информационно, не дублирующий гейт.
|
||||
|
||||
## Caveat
|
||||
|
||||
Молодой проект (фев 2026), single-maintainer → bus-factor. Митигация: pin версии (`@v0.4.1`); MIT → форкабелен при забрасывании.
|
||||
@@ -0,0 +1,57 @@
|
||||
# OWASP ZAP (#68) — установка и использование
|
||||
|
||||
**Узел A8:** #68 — глубокая боевая DAST работающего портала (spider + active scan: обход входа, инъекции, XSS, сессии/CSRF).
|
||||
**Источник (IS9-вет принят):** официальный ZAP «MCP Integration» add-on (`zaproxy/zap-extensions`, `addOns/mcp/`, Apache-2.0; провенанс OWASP/Checkmarx).
|
||||
**Тип:** Java-приложение (ZAP) + **MCP-аддон** (единственный настоящий MCP в наборе A8). Управляется через MCP при запущенном ZAP-демоне.
|
||||
|
||||
---
|
||||
|
||||
## Установка (native-Windows, портативно, без choco)
|
||||
|
||||
ZAP — Java-приложение, требует Java 17+. И Java, и ZAP ставятся **портативно** (zip, без choco), всё под `bin/` (gitignored).
|
||||
|
||||
```powershell
|
||||
$ProgressPreference='SilentlyContinue'
|
||||
|
||||
# 1. Portable Temurin JRE 17 (официальный zip, проверка SHA256)
|
||||
Invoke-WebRequest -Uri 'https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.19%2B10/OpenJDK17U-jre_x64_windows_hotspot_17.0.19_10.zip' -OutFile 'bin\_dl\jre17.zip' -UseBasicParsing
|
||||
# ожидаемый SHA256: 79a598e1fbb4e16582d92c4ee22280a3c4d72fd52606e1e46b1223c0fe53b0da
|
||||
tar.exe -xf 'bin\_dl\jre17.zip' -C 'bin\_runtimes' # → bin\_runtimes\jdk-17.0.19+10-jre\
|
||||
|
||||
# 2. ZAP cross-platform 2.17.0 (официальный GitHub-релиз; размер 286 652 857 Б)
|
||||
Invoke-WebRequest -Uri 'https://github.com/zaproxy/zaproxy/releases/download/v2.17.0/ZAP_2.17.0_Crossplatform.zip' -OutFile 'bin\_dl\zap.zip' -UseBasicParsing
|
||||
tar.exe -xf 'bin\_dl\zap.zip' -C 'bin' # → bin\ZAP_2.17.0\
|
||||
|
||||
# 3. MCP-аддон (+ зависимости) из маркетплейса ZAP
|
||||
$env:JAVA_HOME="$((Get-Location).Path)\bin\_runtimes\jdk-17.0.19+10-jre"
|
||||
& "$env:JAVA_HOME\bin\java.exe" -jar 'bin\ZAP_2.17.0\zap-2.17.0.jar' -cmd -dir 'bin\ZAP_2.17.0\_home' -addoninstall mcp
|
||||
```
|
||||
|
||||
- **Расположение:** `bin/ZAP_2.17.0/` (движок + аддоны в `_home/plugin/`), JRE — `bin/_runtimes/jdk-17.0.19+10-jre/`. `bin/*` в `.gitignore` → машинно-локально, не коммитится.
|
||||
- **Java — портативная**, системная не устанавливается (`JAVA_HOME` задаётся при запуске ZAP).
|
||||
- **Verified (2026-05-21):** `java -jar zap-2.17.0.jar -cmd -version` → `2.17.0`; daemon API `/JSON/core/view/version/` → `2.17.0`; аддон `mcp-alpha-0.0.1.zap` в `_home/plugin/`.
|
||||
|
||||
## Квирки native-Windows (важно)
|
||||
|
||||
1. **`Start-Process -ArgumentList` калечит путь к jar** с пробелами/кириллицей (`Error: Unable to access jarfile`). Запускать через оператор `&` (корректно кавычит) **или** задавать `-WorkingDirectory bin\ZAP_2.17.0` + относительное имя `zap-2.17.0.jar`.
|
||||
2. **Первый daemon-старт тянет полный штатный набор аддонов** (~817 МБ: active/passive scan rules, spider, ajax, openapi, soap, graphql, selenium/webdrivers) — это нормально.
|
||||
3. **Цель сканирования — `127.0.0.1`** (как у Nuclei), не `localhost`.
|
||||
|
||||
## Запуск daemon (для MCP-режима)
|
||||
|
||||
```powershell
|
||||
$root=(Get-Location).Path; $env:JAVA_HOME="$root\bin\_runtimes\jdk-17.0.19+10-jre"
|
||||
Start-Process -FilePath "$env:JAVA_HOME\bin\java.exe" -WorkingDirectory "$root\bin\ZAP_2.17.0" `
|
||||
-ArgumentList @('-jar','zap-2.17.0.jar','-daemon','-dir','_home','-host','127.0.0.1','-port','8092','-config','api.disablekey=true')
|
||||
# проверка готовности: GET http://127.0.0.1:8092/JSON/core/view/version/ → {"version":"2.17.0"}
|
||||
```
|
||||
|
||||
**MCP-интеграция:** при запущенном демоне MCP-аддон отдаёт MCP-эндпоинт; зарегистрировать его SSE-адрес в `.mcp.json` (блок `zap`), затем доступны 15 MCP-инструментов (`ZapStartSpiderTool`, `ZapStartActiveScanTool`, `ZapGetActiveScanStatusTool`, `ZapGenerateReportTool` и т.д.) — все обращаются только к локальному ZAP API. Аддон **alpha** (`mcp-alpha-0.0.1`) — API может меняться.
|
||||
|
||||
## Гард IS8
|
||||
|
||||
Цель по умолчанию — **локальная/тестовая копия** (127.0.0.1). Боевой портал — **только по явной команде** заказчика. Active scan тяжёлый — в smoke не запускать (только spider + passive / проверка связности). READ-only постура.
|
||||
|
||||
## Границы (ADR-014)
|
||||
|
||||
IS1 — ZAP (динамика, бьёт работающий портал) ≠ Semgrep #25 (статика, читает код). IS2 — ZAP (глубина: логика приложения) ≠ Nuclei #69 (широта: известные дыры) — комплементарны.
|
||||
Reference in New Issue
Block a user