f9f86ca05f
Дружелюбный переключатель ВКЛ/ВЫКЛ флага 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>
371 lines
15 KiB
PHP
371 lines
15 KiB
PHP
<?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]);
|
||
}
|
||
}
|