d0eecbbf79
План 4 Task 2 эпика project-migration-redesign. - AdminSupplierIntegrationController +projectsIndex (список supplier_projects + кто заказывал через pivot project_supplier_links -> projects -> tenants organization_name + дата последней поставки = max supplier_leads.received_at + subject_name из RussianRegions::CODE_TO_NAME, «РФ» при NULL subject_code). - +projectsDestroy (bulk-delete: deleteProject на портале, затем локально; pivot снимается CASCADE; сбой строки не прерывает batch -> failures[]). - Routes: GET /projects, POST /projects/delete в admin-группе. - Pest 5/5 (26 assertions). phpstan-baseline +9 ignore (Pest TestCall).
259 lines
9.6 KiB
PHP
259 lines
9.6 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 App\Services\Supplier\SupplierExportMode;
|
||
use App\Services\Supplier\SupplierPortalClient;
|
||
use App\Support\RussianRegions;
|
||
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]);
|
||
}
|
||
|
||
/**
|
||
* Глобальный режим экспорта проектов поставщику (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'],
|
||
]);
|
||
|
||
DB::table('system_settings')->updateOrInsert(
|
||
['key' => 'supplier_export_mode'],
|
||
['value' => $data['mode'], 'type' => 'string', 'updated_at' => now()],
|
||
);
|
||
|
||
return response()->json(['mode' => $data['mode']]);
|
||
}
|
||
|
||
/**
|
||
* 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()];
|
||
}
|
||
}
|
||
|
||
return response()->json(['deleted' => $deleted, 'failures' => $failures]);
|
||
}
|
||
}
|