Files
portal/app/app/Http/Controllers/Api/AdminSupplierIntegrationController.php
T
Дмитрий f9f86ca05f
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
feat/admin: тумблер разблокировки смены источника на экране интеграции с поставщиком
Дружелюбный переключатель ВКЛ/ВЫКЛ флага routing_match_by_snapshot для владельца — без правки БД и без 30-символьного основания общего edit-flow. GET/POST source-edit-flag в AdminSupplierIntegrationController пишут в system_settings type=bool + audit-журнал. На экране карточка с VSwitch и диалогом подтверждения, бамп ключа возвращает тумблер к факту при отмене. TDD: 5 эндпоинт-тестов + фронт-спек. Larastan чист, baseline дополнен Pest-шумом. Проверено глазами через Playwright.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 04:27:32 +03:00

371 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Concerns\ResolvesAdminUserId;
use App\Http\Controllers\Controller;
use App\Jobs\Supplier\CsvReconcileJob;
use App\Models\Project;
use App\Models\SaasAdminAuditLog;
use App\Models\SupplierManualSyncQueue;
use App\Models\SupplierProject;
use App\Services\Supplier\Channel\SupplierProjectChannel;
use App\Services\Supplier\SupplierExportMode;
use App\Services\Supplier\SupplierPortalClient;
use App\Support\RussianRegions;
use App\Support\SystemSettings;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* SaaS-admin → Интеграция с поставщиком: здоровье резервного CSV-канала (Путь 2).
*
* Spec: docs/superpowers/specs/2026-05-18-supplier-csv-reconcile-channel-design.md §4.4
*/
final class AdminSupplierIntegrationController extends Controller
{
use ResolvesAdminUserId;
private const HISTORY_LIMIT = 20;
public function index(): JsonResponse
{
$rows = DB::connection('pgsql_supplier')
->table('supplier_csv_reconcile_log')
->orderByDesc('id')
->limit(self::HISTORY_LIMIT)
->get();
$last = $rows->first();
$webhookState = ($last !== null && $last->status === 'drift_alert') ? 'down' : 'live';
return response()->json([
'health' => [
'last_run_at' => $last !== null ? ($last->finished_at ?? $last->started_at) : null,
'last_status' => $last?->status,
'drift_ratio' => $last !== null ? (float) $last->drift_ratio : null,
'webhook_state' => $webhookState,
],
'history' => $rows->map(fn ($r): array => [
'started_at' => $r->started_at,
'finished_at' => $r->finished_at,
'window_start' => $r->window_start,
'window_end' => $r->window_end,
'status' => $r->status,
'total_csv_rows' => (int) $r->total_csv_rows,
'matched_count' => (int) $r->matched_count,
'recovered_count' => (int) $r->recovered_count,
'drift_ratio' => (float) $r->drift_ratio,
])->all(),
]);
}
public function reconcile(): JsonResponse
{
CsvReconcileJob::dispatch();
return response()->json(['dispatched' => true]);
}
/**
* Эпик 5: история вечерних заливок проектов поставщику (supplier_sync_runs).
* SaaS-admin сверяет глазами, что заливка прошла ровно — от этого зависят
* заказанные у поставщика лиды.
*/
public function syncRuns(): JsonResponse
{
$rows = DB::connection('pgsql_supplier')
->table('supplier_sync_runs')
->orderByDesc('id')
->limit(self::HISTORY_LIMIT)
->get();
return response()->json([
'runs' => $rows->map(fn ($r): array => [
'started_at' => $r->started_at,
'finished_at' => $r->finished_at,
'groups_total' => (int) $r->groups_total,
'synced_ok' => (int) $r->synced_ok,
'manual_queued' => (int) $r->manual_queued,
'deferred' => (int) $r->deferred,
'failed' => (int) $r->failed,
'status' => $r->status,
])->all(),
]);
}
/**
* Очередь яруса 3 резерва канала миграции проектов — pending-список для
* оператора админ-экрана. Spec §4.6.
*/
public function manualQueueIndex(): JsonResponse
{
$rows = SupplierManualSyncQueue::where('status', 'pending')
->orderByDesc('id')
->limit(100)
->get(['id', 'project_id', 'platform', 'operation', 'external_id', 'payload_snapshot', 'failure_reason', 'created_at']);
return response()->json(['queue' => $rows]);
}
/**
* Оператор вручную создал проект на портале → reconcile: сверяем через
* listProjects(), ставим FK supplier_b{1,2,3}_project_id, помечаем resolved.
* 409 если проект на портале не найден (оператор не создал / другие параметры).
* Spec §4.6.
*/
public function manualQueueResolve(int $id, Request $request, SupplierProjectChannel $channel): JsonResponse
{
$row = SupplierManualSyncQueue::findOrFail($id);
if ($row->status !== 'pending') {
return response()->json(['message' => 'already resolved or cancelled'], 409);
}
$payload = $row->payload_snapshot;
$signalType = (string) ($payload['signal_type'] ?? '');
$uniqueKey = (string) ($payload['unique_key'] ?? '');
$found = null;
foreach ($channel->listProjects() as $r) {
if (
($r['platform'] ?? null) === $row->platform
&& ($r['signal_type'] ?? null) === $signalType
&& ($r['unique_key'] ?? null) === $uniqueKey
) {
$found = (int) ($r['id'] ?? 0);
break;
}
}
if ($found === null) {
return response()->json([
'message' => 'Проект не найден на портале поставщика. Проверьте, что вы действительно его создали с теми же параметрами.',
], 409);
}
// FK projects.supplier_b{1,2,3}_project_id ведёт на local supplier_projects.id,
// не на portal external_id. Find-or-create local row с verified external_id.
$sp = SupplierProject::firstOrCreate(
[
'platform' => $row->platform,
'signal_type' => $signalType,
'unique_key' => $uniqueKey,
],
[
'supplier_external_id' => (string) $found,
'current_limit' => 0,
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
'current_regions' => null,
'sync_status' => 'ok',
],
);
Project::where('id', $row->project_id)->update([
'supplier_'.strtolower($row->platform).'_project_id' => $sp->id,
]);
$row->update([
'status' => 'resolved',
'resolved_by_user_id' => $request->user()->id,
'resolved_at' => now(),
'external_id' => (string) $found,
]);
SaasAdminAuditLog::create([
'admin_user_id' => $this->resolveAdminUserId($request, 'supplier-integration@system.stub', 'Supplier Integration Stub'),
'action' => 'supplier_integration.manual_queue_resolved',
'target_type' => 'manual_queue_item',
'target_id' => $row->id,
'payload_after' => ['external_id' => $found, 'platform' => $row->platform],
'ip_address' => $request->ip() ?? '127.0.0.1',
'user_agent' => $request->userAgent(),
'requires_approval' => false,
]);
return response()->json(['resolved' => true, 'external_id' => $found]);
}
/**
* Глобальный режим экспорта проектов поставщику (Plan 4 Task 1).
* Spec: docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.1.
*/
public function getExportMode(): JsonResponse
{
return response()->json(['mode' => SupplierExportMode::current()]);
}
public function setExportMode(Request $request): JsonResponse
{
$data = $request->validate([
'mode' => ['required', 'in:online,batch'],
]);
$prevMode = DB::table('system_settings')->where('key', 'supplier_export_mode')->value('value');
DB::table('system_settings')->updateOrInsert(
['key' => 'supplier_export_mode'],
['value' => $data['mode'], 'type' => 'string', 'updated_at' => now()],
);
SaasAdminAuditLog::create([
'admin_user_id' => $this->resolveAdminUserId($request, 'supplier-integration@system.stub', 'Supplier Integration Stub'),
'action' => 'supplier_integration.export_mode_set',
'target_type' => 'system_setting',
'target_id' => null,
'payload_before' => $prevMode !== null ? ['mode' => $prevMode] : null,
'payload_after' => ['mode' => $data['mode']],
'ip_address' => $request->ip() ?? '127.0.0.1',
'user_agent' => $request->userAgent(),
'requires_approval' => false,
]);
return response()->json(['mode' => $data['mode']]);
}
/**
* Тумблер «Разблокировка смены источника» (флаг routing_match_by_snapshot).
* GET — текущее состояние ВКЛ/ВЫКЛ для переключателя в админке.
*/
public function getSourceEditFlag(): JsonResponse
{
return response()->json(['enabled' => SystemSettings::bool('routing_match_by_snapshot', false)]);
}
/**
* POST — включить/выключить разблокировку смены источника (матч по слепку).
* Пишет в system_settings (type=bool) + audit-журнал; основание не требуется
* (дружелюбный тумблер для владельца, в отличие от общего edit-flow §settings).
*/
public function setSourceEditFlag(Request $request): JsonResponse
{
$data = $request->validate([
'enabled' => ['required', 'boolean'],
]);
$enabled = (bool) $data['enabled'];
$prev = DB::table('system_settings')->where('key', 'routing_match_by_snapshot')->value('value');
DB::table('system_settings')->updateOrInsert(
['key' => 'routing_match_by_snapshot'],
['value' => $enabled ? 'true' : 'false', 'type' => 'bool', 'updated_at' => now()],
);
SaasAdminAuditLog::create([
'admin_user_id' => $this->resolveAdminUserId($request, 'supplier-integration@system.stub', 'Supplier Integration Stub'),
'action' => 'supplier_integration.source_edit_flag_set',
'target_type' => 'system_setting',
'target_id' => null,
'payload_before' => $prev !== null ? ['enabled' => $prev] : null,
'payload_after' => ['enabled' => $enabled ? 'true' : 'false'],
'ip_address' => $request->ip() ?? '127.0.0.1',
'user_agent' => $request->userAgent(),
'requires_approval' => false,
]);
return response()->json(['enabled' => $enabled]);
}
/**
* Plan 4 Task 2: список supplier_projects + кто заказывал (через pivot →
* projects → tenants) + дата последней поставки лида.
*/
public function projectsIndex(): JsonResponse
{
$rows = DB::table('supplier_projects as sp')
->select([
'sp.id',
'sp.platform',
'sp.signal_type',
'sp.unique_key',
'sp.subject_code',
'sp.supplier_external_id',
'sp.current_limit',
'sp.inactive_since',
])
->orderBy('sp.unique_key')
->orderBy('sp.subject_code')
->orderBy('sp.platform')
->get();
$projects = $rows->map(function ($sp): array {
$orderers = DB::table('project_supplier_links as psl')
->join('projects as p', 'p.id', '=', 'psl.project_id')
->join('tenants as t', 't.id', '=', 'p.tenant_id')
->where('psl.supplier_project_id', $sp->id)
->distinct()
->pluck('t.organization_name')
->all();
$lastDelivery = DB::table('supplier_leads')
->where('supplier_project_id', $sp->id)
->max('received_at');
$subjectCode = $sp->subject_code !== null ? (int) $sp->subject_code : null;
return [
'id' => (int) $sp->id,
'platform' => $sp->platform,
'signal_type' => $sp->signal_type,
'unique_key' => $sp->unique_key,
'subject_code' => $subjectCode,
'subject_name' => $subjectCode !== null
? (RussianRegions::CODE_TO_NAME[$subjectCode] ?? null)
: 'РФ',
'current_limit' => (int) $sp->current_limit,
'supplier_external_id' => $sp->supplier_external_id,
'inactive_since' => $sp->inactive_since,
'orderers' => $orderers,
'last_delivery_at' => $lastDelivery,
];
});
return response()->json(['projects' => $projects->all()]);
}
/**
* Plan 4 Task 2: bulk-delete выбранных supplier_projects.
* Сначала на портале (deleteProject), затем локально (pivot снимается CASCADE).
* Сбой по строке — не прерывает batch, копится в failures[].
*/
public function projectsDestroy(Request $request, SupplierPortalClient $client): JsonResponse
{
$data = $request->validate([
'ids' => ['required', 'array', 'min:1'],
'ids.*' => ['integer'],
]);
$deleted = 0;
$failures = [];
foreach (SupplierProject::whereIn('id', $data['ids'])->get() as $sp) {
try {
if ($sp->supplier_external_id !== null) {
$client->deleteProject((int) $sp->supplier_external_id);
}
$sp->delete();
$deleted++;
} catch (\Throwable $e) {
$failures[] = ['id' => $sp->id, 'error' => $e->getMessage()];
}
}
SaasAdminAuditLog::create([
'admin_user_id' => $this->resolveAdminUserId($request, 'supplier-integration@system.stub', 'Supplier Integration Stub'),
'action' => 'supplier_integration.projects_destroyed',
'target_type' => 'supplier_projects_bulk',
'target_id' => null,
'payload_before' => ['ids' => $data['ids']],
'payload_after' => ['deleted' => $deleted, 'failures_count' => count($failures)],
'ip_address' => $request->ip() ?? '127.0.0.1',
'user_agent' => $request->userAgent(),
'requires_approval' => false,
]);
return response()->json(['deleted' => $deleted, 'failures' => $failures]);
}
}