25088e4a33
GET /api/admin/supplier-integration/manual-queue — pending список (limit 100).
POST /manual-queue/{id}/resolve — оператор пометил, что вручную создал проект
на портале; reconcile через channel->listProjects() по (platform, signal_type,
unique_key), 409 если не найден.
ОТКЛОНЕНИЕ ОТ plan Step 10.3: план писал portal external_id прямо в
projects.supplier_b*_project_id (FK на local supplier_projects.id) — FK
violation. Resolve делает firstOrCreate local supplier_projects row с
verified external_id, в FK пишет local id.
Routes — в группе saas-admin (web.php, EnsureSaasAdmin стаб). Task 10 of 12.
Tests 4/4 (index pending / exclude resolved / resolve match / resolve 409).
Spec §4.6.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
146 lines
5.4 KiB
PHP
146 lines
5.4 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Http\Controllers\Api;
|
||
|
||
use App\Http\Controllers\Controller;
|
||
use App\Jobs\Supplier\CsvReconcileJob;
|
||
use App\Models\Project;
|
||
use App\Models\SupplierManualSyncQueue;
|
||
use App\Models\SupplierProject;
|
||
use App\Services\Supplier\Channel\SupplierProjectChannel;
|
||
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
|
||
{
|
||
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]);
|
||
}
|
||
|
||
/**
|
||
* Очередь яруса 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,
|
||
]);
|
||
|
||
return response()->json(['resolved' => true, 'external_id' => $found]);
|
||
}
|
||
}
|