From 711c1927a45bdf3c6e5e3e1ab89a980caccc69f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Wed, 20 May 2026 13:29:08 +0300 Subject: [PATCH] =?UTF-8?q?fix(supplier):=20T6=20online-mode=203=20review-?= =?UTF-8?q?Important=20=E2=80=94=20tier-1-only=20docblock,=20partial-set?= =?UTF-8?q?=20re-attempt,=20per-platform=20DTO=20update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Jobs/Supplier/SyncSupplierProjectsJob.php | 81 +++++++++++--- app/app/Jobs/SyncSupplierProjectJob.php | 104 +++++++++++++++--- 2 files changed, 154 insertions(+), 31 deletions(-) diff --git a/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php b/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php index c9ccd7fc..9b1f50bb 100644 --- a/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php +++ b/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php @@ -33,7 +33,8 @@ use Illuminate\Support\Facades\Mail; use Throwable; /** - * Daily 20:30 МСК cron-job: синхронизирует supplier_projects с поставщиком crm.bp-gr.ru. + * Daily 18:00 МСК cron-job: синхронизирует supplier_projects с поставщиком crm.bp-gr.ru + * (расписание перенесено 20:30 → 18:00, см. routes/console.php). * * Алгоритм (Plan 3 Task 5 — per-subject grouping): * 1. Загрузить активные Лидерра-projects (is_active=true, archived_at IS NULL). @@ -269,24 +270,76 @@ class SyncSupplierProjectsJob implements ShouldQueue $existingSps->push($sp); } } else { - // Update path: updateProject each external_id (R6: one shared limit) - $dto = new SupplierProjectDto( - platform: $existingSps->first()->platform, - signalType: $signalType, - uniqueKey: $identifier, - limit: $order, - workdays: $workdays, - regions: $regions, - regionsReverse: false, - status: 'active', - tag: $tag, - ); + // Fix #3 (review-followup): partial-set recovery — если предыдущий run создал + // не все platforms (e.g. B1+B2 OK, B3 escalated), re-attempt missing via multi-flag + // save с platforms=$missingPlatforms. Throws пропагируют в outer handle() catch + // (SupplierAuth/Transient/Client) — full failover-counter semantics сохраняется. + $existingPlatforms = $existingSps->pluck('platform')->all(); + $missingPlatforms = array_values(array_diff($platforms, $existingPlatforms)); + if ($missingPlatforms !== []) { + $missingDto = new SupplierProjectDto( + platform: $missingPlatforms[0], + signalType: $signalType, + uniqueKey: $identifier, + limit: $order, + workdays: $workdays, + regions: $regions, + regionsReverse: false, + status: 'active', + tag: $tag, + platforms: $missingPlatforms, + ); + + $missingIdMap = $this->client->saveProjectMultiFlag($missingDto); + + foreach ($missingPlatforms as $platform) { + $externalId = $missingIdMap[$platform] ?? null; + if ($externalId === null) { + continue; + } + $sp = SupplierProject::on(self::DB_CONNECTION)->forceCreate([ + 'platform' => $platform, + 'signal_type' => $signalType, + 'unique_key' => $identifier, + 'subject_code' => $subjectCode, + 'supplier_external_id' => (string) $externalId, + 'current_limit' => $order, + 'current_workdays' => $workdays, + 'current_regions' => $regions, + 'sync_status' => 'ok', + 'last_synced_at' => now(), + ]); + SupplierSyncLog::on(self::DB_CONNECTION)->create([ + 'supplier_project_id' => $sp->id, + 'action' => 'create', + 'http_status' => 200, + 'created_at' => now(), + ]); + $existingSps->push($sp); + } + } + + // Fix #2 (review-followup): per-platform DTO в update-loop, чтобы portal получал + // правильные srcrt/srcbl/srcmt для конкретной редактируемой строки (не first() + // из mixed-platform existing set). R6 one shared limit/regions сохраняется. foreach ($existingSps as $sp) { if ($sp->supplier_external_id === null) { continue; } - $this->channel->updateProject((int) $sp->supplier_external_id, $dto); + $perPlatformDto = new SupplierProjectDto( + platform: $sp->platform, + signalType: $signalType, + uniqueKey: $identifier, + limit: $order, + workdays: $workdays, + regions: $regions, + regionsReverse: false, + status: 'active', + tag: $tag, + platforms: [$sp->platform], + ); + $this->channel->updateProject((int) $sp->supplier_external_id, $perPlatformDto); $sp->forceFill([ 'current_limit' => $order, 'current_workdays' => $workdays, diff --git a/app/app/Jobs/SyncSupplierProjectJob.php b/app/app/Jobs/SyncSupplierProjectJob.php index 0e32346b..27b133e7 100644 --- a/app/app/Jobs/SyncSupplierProjectJob.php +++ b/app/app/Jobs/SyncSupplierProjectJob.php @@ -34,10 +34,17 @@ use Illuminate\Support\Facades\Log; * batch → «каркас»: создаёт supplier_projects с limit=0, без регионов * (старый путь); ночной SyncSupplierProjectsJob дольёт полные параметры. * - * Канал миграции — SupplierProjectChannel (резолвится в FailoverProjectChannel: - * ярус 1 AJAX → ярус 2 browser-form → ярус 3 manual queue). При эскалации на - * ярус 3 / переносе по окну портала — platform пропускается (FK остаётся NULL, - * ночной SyncSupplierProjectsJob подберёт после ручного вмешательства). + * Канал миграции: + * batch mode — SupplierProjectChannel (FailoverProjectChannel: ярус 1 AJAX + * → ярус 2 browser-form → ярус 3 manual queue) для createProject. + * online mode — multi-flag save идёт напрямую через SupplierPortalClient + * (tier-1 AJAX only — multi-flag нет в tier-2 form по архитектуре + * портала). При любом transient/auth fail → log warning + skip + * subject; Laravel retry (tries=3 backoff [15s,60s,300s]) → ночной + * SyncSupplierProjectsJob подберёт с полным failover каналом. + * updateProject в online остаётся через $channel (полная схема failover). + * При эскалации на ярус 3 / переносе по окну портала — platform/subject пропускается + * (FK/pivot остаётся пустым; ночной SyncSupplierProjectsJob восстанавливает). * * Retry: 3 попытки с backoff [15s, 60s, 300s]. * @@ -132,6 +139,13 @@ class SyncSupplierProjectJob implements ShouldQueue } catch (WindowDeferredException) { Log::info("SyncSupplierProjectJob: project {$project->id} subject={$subject} deferred by portal window"); + continue; + } catch (\Throwable $e) { + // Online multi-flag save bypasses FailoverProjectChannel (tier-1 only by design, + // см. class docblock). При transient/auth/client/network fail — log+skip; следующий + // tries-retry (15s, 60s, 300s) или ночной SyncSupplierProjectsJob подберёт. + Log::warning("SyncSupplierProjectJob: online multi-flag save failed for project {$project->id} subject={$subject} (".get_class($e).'): '.$e->getMessage()); + continue; } @@ -157,24 +171,80 @@ class SyncSupplierProjectJob implements ShouldQueue $existingSps->push($sp); } } else { - // Update path: updateProject each (R6: one shared limit) - $dto = new SupplierProjectDto( - platform: $existingSps->first()->platform, - signalType: (string) $project->signal_type, - uniqueKey: $identifier, - limit: (int) $project->daily_limit_target, - workdays: [1, 2, 3, 4, 5, 6, 7], - regions: $regions, - regionsReverse: false, - status: 'active', - tag: $tag, - ); + // Fix #3 (review-followup): partial-set recovery — если предыдущий run создал + // не все platforms (e.g. B1+B2 OK, B3 escalated), re-attempt missing via + // multi-flag save с platforms=$missingPlatforms (srcrt/srcbl/srcmt только missing). + $existingPlatforms = $existingSps->pluck('platform')->all(); + $missingPlatforms = array_values(array_diff($platforms, $existingPlatforms)); + if ($missingPlatforms !== []) { + $missingDto = new SupplierProjectDto( + platform: $missingPlatforms[0], + signalType: (string) $project->signal_type, + uniqueKey: $identifier, + limit: (int) $project->daily_limit_target, + workdays: [1, 2, 3, 4, 5, 6, 7], + regions: $regions, + regionsReverse: false, + status: 'active', + tag: $tag, + platforms: $missingPlatforms, + ); + + try { + $missingIdMap = $client->saveProjectMultiFlag($missingDto); + } catch (TierEscalatedException $e) { + Log::info("SyncSupplierProjectJob: project {$project->id} subject={$subject} missing-platform re-attempt escalated #{$e->queueRowId}"); + $missingIdMap = []; + } catch (WindowDeferredException) { + Log::info("SyncSupplierProjectJob: project {$project->id} subject={$subject} missing-platform deferred by portal window"); + $missingIdMap = []; + } catch (\Throwable $e) { + Log::warning("SyncSupplierProjectJob: missing-platform multi-flag failed for project {$project->id} subject={$subject}: ".$e->getMessage()); + $missingIdMap = []; + } + + foreach ($missingPlatforms as $platform) { + $externalId = $missingIdMap[$platform] ?? null; + if ($externalId === null) { + continue; + } + $sp = SupplierProject::create([ + 'platform' => $platform, + 'signal_type' => (string) $project->signal_type, + 'unique_key' => $identifier, + 'subject_code' => $subject, + 'supplier_external_id' => (string) $externalId, + 'current_limit' => (int) $project->daily_limit_target, + 'current_workdays' => [1, 2, 3, 4, 5, 6, 7], + 'current_regions' => $regions, + 'sync_status' => 'ok', + 'last_synced_at' => now(), + ]); + $existingSps->push($sp); + } + } + + // Fix #2 (review-followup): per-platform DTO в update-loop, чтобы portal + // получал корректные srcrt/srcbl/srcmt флаги для конкретной редактируемой строки + // (не первой из mixed-platform existing set). R6 one shared limit/regions сохраняется. foreach ($existingSps as $sp) { if ($sp->supplier_external_id === null) { continue; } - $channel->updateProject((int) $sp->supplier_external_id, $dto); + $perPlatformDto = new SupplierProjectDto( + platform: $sp->platform, + signalType: (string) $project->signal_type, + uniqueKey: $identifier, + limit: (int) $project->daily_limit_target, + workdays: [1, 2, 3, 4, 5, 6, 7], + regions: $regions, + regionsReverse: false, + status: 'active', + tag: $tag, + platforms: [$sp->platform], + ); + $channel->updateProject((int) $sp->supplier_external_id, $perPlatformDto); $sp->forceFill([ 'current_limit' => (int) $project->daily_limit_target, 'current_regions' => $regions,