Files
portal/app/app/Http/Controllers/Api/AdminSupplierIntegrationController.php
T
Дмитрий 25088e4a33 feat(supplier): admin endpoints for Tier 3 manual queue
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>
2026-05-19 12:55:09 +03:00

146 lines
5.4 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\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]);
}
}