feat(supplier): SyncSupplierProjectsJob per-subject grouping + pivot + order
This commit is contained in:
@@ -14,47 +14,46 @@ use App\Models\SupplierProject;
|
||||
use App\Models\SupplierSyncLog;
|
||||
use App\Services\Supplier\Channel\Exceptions\TierEscalatedException;
|
||||
use App\Services\Supplier\Channel\Exceptions\WindowDeferredException;
|
||||
use App\Services\Supplier\Channel\FailoverProjectChannel;
|
||||
use App\Services\Supplier\Channel\SupplierProjectChannel;
|
||||
use App\Services\Supplier\Dto\SupplierProjectDto;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
use App\Services\Supplier\SupplierQuotaAllocator;
|
||||
use App\Support\RussianRegions;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use stdClass;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Daily 20:30 МСК cron-job: синхронизирует supplier_projects с поставщиком crm.bp-gr.ru.
|
||||
*
|
||||
* Алгоритм (per spec §4.3):
|
||||
* 1. Итерация по всем активным (inactive_since IS NULL) supplier_projects.
|
||||
* 2. Для каждого:
|
||||
* a. Подтянуть активные Лидерра-projects через FK supplier_b{1,2,3}_project_id.
|
||||
* b. Адаптировать в plain stdClass с полями daily_limit/workdays/regions.
|
||||
* c. Вызвать SupplierQuotaAllocator::allocate() — pure distribution.
|
||||
* d. Сравнить с current state через SupplierProjectDto::equals(); skip if no diff.
|
||||
* e. saveProject() при supplier_external_id=null, иначе updateProject().
|
||||
* f. Записать audit row в supplier_sync_log.
|
||||
* 3. Failure-handling:
|
||||
* - SupplierAuthException → SupplierCriticalAlertMail('sticky_auth') + Sentry + throw.
|
||||
* - SupplierTransientException → log + continue. После 50 подряд → mass_transient alert + break.
|
||||
* - SupplierClientException → log + continue.
|
||||
* 4. Time budget cutoff: после 20:55 МСК прервать loop (буфер 5 мин до 21:00).
|
||||
* Алгоритм (Plan 3 Task 5 — per-subject grouping):
|
||||
* 1. Загрузить активные Лидерра-projects (is_active=true, archived_at IS NULL).
|
||||
* 2. Развернуть каждый в группы (signal_type, identifier, subject_code):
|
||||
* - subjects = project.regions (1..89); пусто → одна группа subject_code=null («Вся РФ»).
|
||||
* - identifier = buildUniqueKey() (site/call → signal_identifier; sms B2 → sender+keyword; B3 → sender).
|
||||
* - platforms = resolvePlatforms() (site/call → B1+B2+B3; sms+keyword → B2+B3; sms → B3).
|
||||
* 3. Для каждой группы:
|
||||
* - eligible-today проекты группы (workday-маска на завтра).
|
||||
* - order = computeOrder($eligibleLimits); workdays = union; tag / regions из subject.
|
||||
* - Найти существующие supplier_projects (unique_key, subject_code):
|
||||
* - Нет → saveProjectMultiFlag → 3 id → upsert supplier_projects.
|
||||
* - Есть → updateProject каждого (R6: один лимит).
|
||||
* - Pivot: для каждого Лидерра-проекта × каждого supplier_project → INSERT ... ON CONFLICT DO NOTHING.
|
||||
* 4. Failure-handling (Auth/Transient/Client/Window/TierEscalated), time-budget cutoff — сохранены.
|
||||
*
|
||||
* NOTE про connection: Job's $connection — это queue connection, не DB. Используем
|
||||
* Eloquent::on('pgsql_supplier') для cross-tenant видимости (Plan 3 Task 3 learning).
|
||||
* NOTE про connection: Eloquent::on('pgsql_supplier') для cross-tenant видимости.
|
||||
*
|
||||
* Spec:
|
||||
* - docs/superpowers/specs/2026-05-10-supplier-integration-design.md §4.3-§4.4
|
||||
* - docs/superpowers/specs/2026-05-11-plan3-supplier-sync-design.md §4
|
||||
* - docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.3
|
||||
* - docs/superpowers/plans/2026-05-20-project-migration-redesign-plan-3-export.md Task 5
|
||||
*/
|
||||
class SyncSupplierProjectsJob implements ShouldQueue
|
||||
{
|
||||
@@ -68,33 +67,71 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
|
||||
private SupplierProjectChannel $channel;
|
||||
|
||||
private SupplierPortalClient $client;
|
||||
|
||||
public function handle(?SupplierProjectChannel $channel = null): void
|
||||
{
|
||||
$this->channel = $channel ?? app(SupplierProjectChannel::class);
|
||||
$this->client = app(SupplierPortalClient::class);
|
||||
$consecutiveTransient = 0;
|
||||
|
||||
$projects = SupplierProject::on(self::DB_CONNECTION)
|
||||
->whereNull('inactive_since')
|
||||
// 1. Load active Лидерра-projects via pgsql_supplier
|
||||
/** @var Collection<int, Project> $projects */
|
||||
$projects = Project::on(self::DB_CONNECTION)
|
||||
->where('is_active', true)
|
||||
->whereNull('archived_at')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
foreach ($projects as $sp) {
|
||||
// 2. Expand into groups (signal_type, identifier, subject_code)
|
||||
// group key => [ 'signal_type', 'identifier', 'subject_code', 'platforms', 'projects' => [...] ]
|
||||
/** @var array<string, array{signal_type: string, identifier: string, subject_code: int|null, platforms: list<string>, projects: list<Project>}> $groups */
|
||||
$groups = [];
|
||||
|
||||
foreach ($projects as $project) {
|
||||
$platforms = $this->resolvePlatforms($project);
|
||||
if ($platforms === []) {
|
||||
continue;
|
||||
}
|
||||
// For sms, identifier depends on whether B2 is in platforms (keyword-aware)
|
||||
// We use the B2 key as identifier when B2 is present (sms+keyword), else B3 key (sender only)
|
||||
$identifier = $this->buildUniqueKey($project);
|
||||
|
||||
$subjects = $this->subjectsOf($project);
|
||||
|
||||
foreach ($subjects as $subjectCode) {
|
||||
$key = $project->signal_type.'|'.$identifier.'|'.($subjectCode ?? 'null');
|
||||
if (! isset($groups[$key])) {
|
||||
$groups[$key] = [
|
||||
'signal_type' => (string) $project->signal_type,
|
||||
'identifier' => $identifier,
|
||||
'subject_code' => $subjectCode,
|
||||
'platforms' => $platforms,
|
||||
'projects' => [],
|
||||
];
|
||||
}
|
||||
$groups[$key]['projects'][] = $project;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Sync each group
|
||||
foreach ($groups as $group) {
|
||||
if (now()->timezone('Europe/Moscow')->format('H:i') >= self::TIME_BUDGET_CUTOFF) {
|
||||
Log::warning('supplier.sync.time_budget_reached', [
|
||||
'processed_until' => $sp->id,
|
||||
'group' => $group['identifier'],
|
||||
]);
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->syncOne($sp);
|
||||
$this->syncGroup($group);
|
||||
$consecutiveTransient = 0;
|
||||
} catch (TierEscalatedException $e) {
|
||||
Log::info("SyncSupplierProjectsJob: sp #{$sp->id} escalated to manual queue #{$e->queueRowId}, reason: {$e->reason}");
|
||||
Log::info("SyncSupplierProjectsJob: group {$group['identifier']} escalated to manual queue #{$e->queueRowId}, reason: {$e->reason}");
|
||||
|
||||
continue;
|
||||
} catch (WindowDeferredException) {
|
||||
Log::info("SyncSupplierProjectsJob: sp #{$sp->id} deferred by portal window");
|
||||
Log::info("SyncSupplierProjectsJob: group {$group['identifier']} deferred by portal window");
|
||||
|
||||
continue;
|
||||
} catch (SupplierAuthException $e) {
|
||||
@@ -107,7 +144,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
throw $e;
|
||||
} catch (SupplierTransientException $e) {
|
||||
$consecutiveTransient++;
|
||||
$this->logSyncFailure($sp, $e);
|
||||
$this->logGroupFailure($group, $e);
|
||||
if ($consecutiveTransient >= self::MASS_FAIL_THRESHOLD) {
|
||||
Mail::to((string) config('services.supplier.alert_email'))
|
||||
->queue(new SupplierCriticalAlertMail(
|
||||
@@ -120,7 +157,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
|
||||
continue;
|
||||
} catch (SupplierClientException $e) {
|
||||
$this->logSyncFailure($sp, $e);
|
||||
$this->logGroupFailure($group, $e);
|
||||
report($e);
|
||||
|
||||
continue;
|
||||
@@ -128,131 +165,254 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
private function syncOne(SupplierProject $sp): void
|
||||
/**
|
||||
* @param array{signal_type: string, identifier: string, subject_code: int|null, platforms: list<string>, projects: list<Project>} $group
|
||||
*/
|
||||
private function syncGroup(array $group): void
|
||||
{
|
||||
$fkColumn = $this->fkColumnForPlatform($sp->platform);
|
||||
$signalType = $group['signal_type'];
|
||||
$identifier = $group['identifier'];
|
||||
$subjectCode = $group['subject_code'];
|
||||
$platforms = $group['platforms'];
|
||||
|
||||
/** @var EloquentCollection<int, Project> $liderraProjects */
|
||||
$liderraProjects = Project::on(self::DB_CONNECTION)
|
||||
->where($fkColumn, $sp->id)
|
||||
->where('is_active', true)
|
||||
/** @var list<Project> $groupProjects */
|
||||
$groupProjects = $group['projects'];
|
||||
|
||||
// Eligible-today: workday-mask for tomorrow
|
||||
$targetDate = Carbon::tomorrow('Europe/Moscow');
|
||||
$targetWeekday = $targetDate->isoWeekday();
|
||||
|
||||
/** @var list<Project> $eligible */
|
||||
$eligible = array_values(array_filter(
|
||||
$groupProjects,
|
||||
fn (Project $p) => ($p->delivery_days_mask & (1 << ($targetWeekday - 1))) !== 0
|
||||
));
|
||||
|
||||
if ($eligible === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute order and union workdays
|
||||
$eligibleLimits = array_map(fn (Project $p) => (int) $p->daily_limit_target, $eligible);
|
||||
$order = SupplierQuotaAllocator::computeOrder($eligibleLimits);
|
||||
|
||||
$workdaysUnion = [];
|
||||
foreach ($eligible as $p) {
|
||||
foreach ($this->bitmaskToList((int) $p->delivery_days_mask, 7) as $d) {
|
||||
$workdaysUnion[$d] = $d;
|
||||
}
|
||||
}
|
||||
sort($workdaysUnion);
|
||||
$workdays = $workdaysUnion;
|
||||
|
||||
// Tag and regions from subject
|
||||
$tag = $subjectCode !== null ? (RussianRegions::CODE_TO_NAME[$subjectCode] ?? (string) $subjectCode) : 'РФ';
|
||||
$regions = $subjectCode !== null ? [$subjectCode] : [];
|
||||
|
||||
// Find existing supplier_projects for this group
|
||||
$existingSps = SupplierProject::on(self::DB_CONNECTION)
|
||||
->where('unique_key', $identifier)
|
||||
->where('signal_type', $signalType)
|
||||
->when(
|
||||
$subjectCode !== null,
|
||||
fn ($q) => $q->where('subject_code', $subjectCode),
|
||||
fn ($q) => $q->whereNull('subject_code'),
|
||||
)
|
||||
->whereIn('platform', $platforms)
|
||||
->get();
|
||||
|
||||
if ($liderraProjects->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if ($existingSps->isEmpty()) {
|
||||
// Create path: saveProjectMultiFlag → [platform => external_id]
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: $platforms[0],
|
||||
signalType: $signalType,
|
||||
uniqueKey: $identifier,
|
||||
limit: $order,
|
||||
workdays: $workdays,
|
||||
regions: $regions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: $platforms,
|
||||
);
|
||||
|
||||
$adapted = $this->adaptProjectsForAllocator($liderraProjects);
|
||||
$idMap = $this->client->saveProjectMultiFlag($dto);
|
||||
|
||||
$allocation = SupplierQuotaAllocator::allocate(
|
||||
platform: $sp->platform,
|
||||
signalType: $sp->signal_type,
|
||||
uniqueKey: $sp->unique_key,
|
||||
activeLiderraProjects: $adapted,
|
||||
targetDate: Carbon::tomorrow('Europe/Moscow'),
|
||||
);
|
||||
// Upsert supplier_projects rows (one per platform)
|
||||
foreach ($platforms as $platform) {
|
||||
$externalId = $idMap[$platform] ?? null;
|
||||
if ($externalId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($allocation === null) {
|
||||
return;
|
||||
}
|
||||
$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(),
|
||||
]);
|
||||
|
||||
$current = SupplierProjectDto::fromModel($sp);
|
||||
if ($allocation->equals($current)) {
|
||||
return;
|
||||
}
|
||||
SupplierSyncLog::on(self::DB_CONNECTION)->create([
|
||||
'supplier_project_id' => $sp->id,
|
||||
'action' => 'create',
|
||||
'http_status' => 200,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$isCreate = $sp->supplier_external_id === null;
|
||||
|
||||
// NOTE: НЕ оборачиваем в DB::transaction() — HTTP-call к supplier выходит за
|
||||
// границы транзакционного контекста, атомарности всё равно нет. Два DB-write
|
||||
// (supplier_project update + supplier_sync_log insert) на одной connection
|
||||
// выполняются последовательно; ошибка между ними — recoverable through retry
|
||||
// на следующем cron-tick'е (supplier_external_id уже записан, скип через equals()).
|
||||
// Context-project для project_id в очереди яруса 3 при эскалации.
|
||||
$contextProject = $liderraProjects->first();
|
||||
|
||||
if ($isCreate) {
|
||||
$externalId = $this->channel instanceof FailoverProjectChannel
|
||||
? $this->channel->createProjectForLiderra($contextProject, $allocation)
|
||||
: $this->channel->createProject($allocation);
|
||||
$sp->forceFill([
|
||||
'supplier_external_id' => (string) $externalId,
|
||||
'current_limit' => $allocation->limit,
|
||||
'current_workdays' => $allocation->workdays,
|
||||
'current_regions' => $allocation->regions,
|
||||
'sync_status' => 'ok',
|
||||
'last_synced_at' => now(),
|
||||
])->save();
|
||||
} else {
|
||||
if ($this->channel instanceof FailoverProjectChannel) {
|
||||
$this->channel->updateProjectForLiderra($contextProject, (int) $sp->supplier_external_id, $allocation);
|
||||
} else {
|
||||
$this->channel->updateProject((int) $sp->supplier_external_id, $allocation);
|
||||
$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,
|
||||
);
|
||||
|
||||
foreach ($existingSps as $sp) {
|
||||
if ($sp->supplier_external_id === null) {
|
||||
continue;
|
||||
}
|
||||
$this->channel->updateProject((int) $sp->supplier_external_id, $dto);
|
||||
$sp->forceFill([
|
||||
'current_limit' => $order,
|
||||
'current_workdays' => $workdays,
|
||||
'current_regions' => $regions,
|
||||
'sync_status' => 'ok',
|
||||
'last_synced_at' => now(),
|
||||
])->save();
|
||||
|
||||
SupplierSyncLog::on(self::DB_CONNECTION)->create([
|
||||
'supplier_project_id' => $sp->id,
|
||||
'action' => 'update',
|
||||
'http_status' => 200,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
$sp->forceFill([
|
||||
'current_limit' => $allocation->limit,
|
||||
'current_workdays' => $allocation->workdays,
|
||||
'current_regions' => $allocation->regions,
|
||||
'sync_status' => 'ok',
|
||||
'last_synced_at' => now(),
|
||||
])->save();
|
||||
}
|
||||
|
||||
SupplierSyncLog::on(self::DB_CONNECTION)->create([
|
||||
'supplier_project_id' => $sp->id,
|
||||
'action' => $isCreate ? 'create' : 'update',
|
||||
'http_status' => 200,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
// Pivot: for each contributing Лидерра-project × each supplier_project → ON CONFLICT DO NOTHING
|
||||
foreach ($groupProjects as $lp) {
|
||||
foreach ($existingSps as $sp) {
|
||||
DB::connection(self::DB_CONNECTION)->table('project_supplier_links')->insertOrIgnore([
|
||||
'project_id' => $lp->id,
|
||||
'supplier_project_id' => $sp->id,
|
||||
'platform' => $sp->platform,
|
||||
// @phpstan-ignore-next-line property.notFound — subject_code is in $fillable/casts, IDE stubs lag
|
||||
'subject_code' => $sp->subject_code,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function logSyncFailure(SupplierProject $sp, Throwable $e): void
|
||||
/**
|
||||
* Log failure for a group (before any supplier_project is created/updated we don't have sp id,
|
||||
* so we look up existing or skip — best-effort audit).
|
||||
*
|
||||
* @param array{signal_type: string, identifier: string, subject_code: int|null, platforms: list<string>, projects: list<Project>} $group
|
||||
*/
|
||||
private function logGroupFailure(array $group, Throwable $e): void
|
||||
{
|
||||
$httpStatus = null;
|
||||
if ($e instanceof SupplierException) {
|
||||
$httpStatus = $e->httpStatus;
|
||||
}
|
||||
|
||||
SupplierSyncLog::on(self::DB_CONNECTION)->create([
|
||||
'supplier_project_id' => $sp->id,
|
||||
'action' => $sp->supplier_external_id === null ? 'create' : 'update',
|
||||
'http_status' => $httpStatus,
|
||||
'error_message' => substr($e->getMessage(), 0, 1000),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
// Find any existing sp row for the group to link log entry
|
||||
$sp = SupplierProject::on(self::DB_CONNECTION)
|
||||
->where('unique_key', $group['identifier'])
|
||||
->where('signal_type', $group['signal_type'])
|
||||
->when(
|
||||
$group['subject_code'] !== null,
|
||||
fn ($q) => $q->where('subject_code', $group['subject_code']),
|
||||
fn ($q) => $q->whereNull('subject_code'),
|
||||
)
|
||||
->first();
|
||||
|
||||
if ($sp !== null) {
|
||||
SupplierSyncLog::on(self::DB_CONNECTION)->create([
|
||||
'supplier_project_id' => $sp->id,
|
||||
'action' => $sp->supplier_external_id === null ? 'create' : 'update',
|
||||
'http_status' => $httpStatus,
|
||||
'error_message' => substr($e->getMessage(), 0, 1000),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Адаптер Eloquent Project → stdClass с полями daily_limit/workdays/regions,
|
||||
* которые ожидает SupplierQuotaAllocator (pure function, не вяжется к Eloquent).
|
||||
* Returns subjects (region codes 1..89) for a project.
|
||||
* Empty regions → [null] (one group, "Вся РФ" pool).
|
||||
*
|
||||
* Маппинг:
|
||||
* daily_limit ← daily_limit_target
|
||||
* workdays ← биты delivery_days_mask (bit 0=Пн, …, bit 6=Вс) → ISO 1..7
|
||||
* regions ← projects.regions INT[] (subject codes 1..89) direct copy
|
||||
*
|
||||
* @param EloquentCollection<int, Project> $projects
|
||||
* @return Collection<int, stdClass>
|
||||
* @return list<int|null>
|
||||
*/
|
||||
private function adaptProjectsForAllocator(EloquentCollection $projects): Collection
|
||||
private function subjectsOf(Project $project): array
|
||||
{
|
||||
return $projects->map(function (Project $p): stdClass {
|
||||
$obj = new stdClass;
|
||||
$obj->daily_limit = (int) $p->daily_limit_target;
|
||||
$obj->workdays = $this->bitmaskToList((int) $p->delivery_days_mask, 7);
|
||||
$regions = array_values((array) $project->regions);
|
||||
// @phpstan-ignore-next-line identical.alwaysFalse — PostgresIntArray PHPDoc non-empty, runtime can be empty
|
||||
if (count($regions) === 0) {
|
||||
return [null];
|
||||
}
|
||||
|
||||
// Plan 6: projects.regions[] напрямую копируется в supplier_projects.current_regions.
|
||||
// Empty array = "вся РФ" (паритет с supplier API semantics).
|
||||
// Legacy region_mask/region_mode игнорируются — они dual-write для PhonePrefixService,
|
||||
// outbound к supplier использует только regions[]. Cleanup в Plan 6.5.
|
||||
$obj->regions = array_values((array) $p->regions);
|
||||
|
||||
return $obj;
|
||||
})->values();
|
||||
return array_map(fn ($r) => (int) $r, $regions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bitmask → ordered list 1..maxBits для bits, выставленных в 1.
|
||||
* Unique identifier key for this project (platform-agnostic for site/call;
|
||||
* uses B2 key when sms+keyword present, else B3 key).
|
||||
*
|
||||
* Task 6 will extract this to SupplierProjectGrouping — for now inline.
|
||||
*/
|
||||
private function buildUniqueKey(Project $project): string
|
||||
{
|
||||
if (in_array($project->signal_type, ['site', 'call'], true)) {
|
||||
return (string) $project->signal_identifier;
|
||||
}
|
||||
|
||||
// sms: use B2 key (sender+keyword) when keyword present, else B3 key (sender)
|
||||
$sender = (string) ($project->sms_senders[0] ?? '');
|
||||
if ($project->sms_keyword !== null && $project->sms_keyword !== '') {
|
||||
return $sender.'+'.$project->sms_keyword;
|
||||
}
|
||||
|
||||
return $sender;
|
||||
}
|
||||
|
||||
/**
|
||||
* Platforms for this project's signal_type.
|
||||
* Task 6 will extract to SupplierProjectGrouping.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function resolvePlatforms(Project $project): array
|
||||
{
|
||||
if (in_array($project->signal_type, ['site', 'call'], true)) {
|
||||
return ['B1', 'B2', 'B3'];
|
||||
}
|
||||
|
||||
if ($project->signal_type === 'sms') {
|
||||
return ($project->sms_keyword !== null && $project->sms_keyword !== '')
|
||||
? ['B2', 'B3']
|
||||
: ['B3'];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Bitmask → ordered list 1..maxBits.
|
||||
*
|
||||
* @return array<int, int>
|
||||
*/
|
||||
@@ -267,14 +427,4 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function fkColumnForPlatform(string $platform): string
|
||||
{
|
||||
return match ($platform) {
|
||||
'B1' => 'supplier_b1_project_id',
|
||||
'B2' => 'supplier_b2_project_id',
|
||||
'B3' => 'supplier_b3_project_id',
|
||||
default => throw new \InvalidArgumentException("Unknown supplier platform: {$platform}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,10 +12,10 @@ use App\Models\SupplierSyncLog;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Supplier\Channel\AjaxProjectChannel;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
@@ -41,279 +41,356 @@ afterEach(function (): void {
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('creates supplier_project at supplier when supplier_external_id is null', function (): void {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-subject grouping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Project regions=[82,83] site → 2 groups (Москва, СПб) →
|
||||
* 2 multi-flag saves → 6 supplier_projects (2 subjects × 3 platforms B1/B2/B3)
|
||||
* with correct subject_code/tag; pivot — 6 links for the project.
|
||||
*/
|
||||
test('per-subject: regions=[82,83] site → 6 supplier_projects + 6 pivot links', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$sp = SupplierProject::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'create-flow.example.com',
|
||||
'supplier_external_id' => null,
|
||||
'current_limit' => 0,
|
||||
'current_workdays' => [],
|
||||
'current_regions' => [],
|
||||
]);
|
||||
Project::factory()->create([
|
||||
|
||||
/** @var Project $project */
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'create-flow.example.com',
|
||||
'supplier_b1_project_id' => $sp->id,
|
||||
'signal_identifier' => 'persubject.example.com',
|
||||
'daily_limit_target' => 9,
|
||||
'delivery_days_mask' => 127, // all days
|
||||
'regions' => [82, 83],
|
||||
]);
|
||||
|
||||
// saveProjectMultiFlag calls rt-project-save once per subject, then listProjects to get ids
|
||||
Http::fake([
|
||||
// first save (subject 82 = Москва)
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::sequence()
|
||||
->push(['status' => 'OK', 'message' => '', 'result' => null, 'id' => '1001'], 200)
|
||||
// second save (subject 83 = Санкт-Петербург)
|
||||
->push(['status' => 'OK', 'message' => '', 'result' => null, 'id' => '2001'], 200),
|
||||
// listProjects called after each save — return 3 rows per group
|
||||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::sequence()
|
||||
// After first save (Москва tag)
|
||||
->push(['projects' => [
|
||||
['id' => '1001', 'src' => 'rt', 'name' => 'persubject.example.com', 'tag' => 'Москва', 'type' => 'hosts', 'content' => 'persubject.example.com'],
|
||||
['id' => '1002', 'src' => 'bl', 'name' => 'persubject.example.com', 'tag' => 'Москва', 'type' => 'hosts', 'content' => 'persubject.example.com'],
|
||||
['id' => '1003', 'src' => 'mt', 'name' => 'persubject.example.com', 'tag' => 'Москва', 'type' => 'hosts', 'content' => 'persubject.example.com'],
|
||||
]], 200)
|
||||
// After second save (СПб tag)
|
||||
->push(['projects' => [
|
||||
['id' => '1001', 'src' => 'rt', 'name' => 'persubject.example.com', 'tag' => 'Москва', 'type' => 'hosts', 'content' => 'persubject.example.com'],
|
||||
['id' => '1002', 'src' => 'bl', 'name' => 'persubject.example.com', 'tag' => 'Москва', 'type' => 'hosts', 'content' => 'persubject.example.com'],
|
||||
['id' => '1003', 'src' => 'mt', 'name' => 'persubject.example.com', 'tag' => 'Москва', 'type' => 'hosts', 'content' => 'persubject.example.com'],
|
||||
['id' => '2001', 'src' => 'rt', 'name' => 'persubject.example.com', 'tag' => 'Санкт-Петербург', 'type' => 'hosts', 'content' => 'persubject.example.com'],
|
||||
['id' => '2002', 'src' => 'bl', 'name' => 'persubject.example.com', 'tag' => 'Санкт-Петербург', 'type' => 'hosts', 'content' => 'persubject.example.com'],
|
||||
['id' => '2003', 'src' => 'mt', 'name' => 'persubject.example.com', 'tag' => 'Санкт-Петербург', 'type' => 'hosts', 'content' => 'persubject.example.com'],
|
||||
]], 200),
|
||||
]);
|
||||
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
|
||||
// 6 supplier_projects created: 2 subjects × 3 platforms
|
||||
$sps = SupplierProject::on('pgsql_supplier')
|
||||
->where('unique_key', 'persubject.example.com')
|
||||
->where('signal_type', 'site')
|
||||
->get();
|
||||
|
||||
expect($sps)->toHaveCount(6);
|
||||
|
||||
// subject_code 82 → 3 rows (B1/B2/B3)
|
||||
$m = $sps->where('subject_code', 82);
|
||||
expect($m)->toHaveCount(3);
|
||||
expect($m->pluck('platform')->sort()->values()->all())->toBe(['B1', 'B2', 'B3']);
|
||||
|
||||
// subject_code 83 → 3 rows
|
||||
$spb = $sps->where('subject_code', 83);
|
||||
expect($spb)->toHaveCount(3);
|
||||
|
||||
// pivot: 6 links for this project
|
||||
$pivotCount = DB::table('project_supplier_links')
|
||||
->where('project_id', $project->id)
|
||||
->count();
|
||||
expect($pivotCount)->toBe(6);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// All-RF pool
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('all-RF pool: regions=[] → 1 group subject_code=null tag=РФ → 3 supplier_projects', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
/** @var Project $project */
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'rf-pool.example.com',
|
||||
'daily_limit_target' => 6,
|
||||
'delivery_days_mask' => 127,
|
||||
'region_mask' => 255,
|
||||
'region_mode' => 'include',
|
||||
'regions' => [],
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '555'],
|
||||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '500'],
|
||||
200,
|
||||
),
|
||||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(
|
||||
['projects' => [
|
||||
['id' => '500', 'src' => 'rt', 'name' => 'rf-pool.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'rf-pool.example.com'],
|
||||
['id' => '501', 'src' => 'bl', 'name' => 'rf-pool.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'rf-pool.example.com'],
|
||||
['id' => '502', 'src' => 'mt', 'name' => 'rf-pool.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'rf-pool.example.com'],
|
||||
]],
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
|
||||
$sp->refresh();
|
||||
expect($sp->supplier_external_id)->toBe('555')
|
||||
->and($sp->sync_status)->toBe('ok')
|
||||
->and($sp->current_limit)->toBe(3);
|
||||
$sps = SupplierProject::on('pgsql_supplier')
|
||||
->where('unique_key', 'rf-pool.example.com')
|
||||
->where('signal_type', 'site')
|
||||
->get();
|
||||
|
||||
Http::assertSent(fn ($r) => str_ends_with($r->url(), '/admin/visit/rt-project-save'));
|
||||
expect($sps)->toHaveCount(3);
|
||||
expect($sps->pluck('subject_code')->unique()->all())->toContain(null);
|
||||
expect($sps->pluck('current_regions')->first())->toBe([]);
|
||||
|
||||
// pivot
|
||||
$pivotCount = DB::table('project_supplier_links')
|
||||
->where('project_id', $project->id)
|
||||
->count();
|
||||
expect($pivotCount)->toBe(3);
|
||||
});
|
||||
|
||||
test('updates when diff detected', function (): void {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Order: 2 projects on one (source × subject) → computeOrder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('order: 2 projects same source×subject → computeOrder(limits=[10,20]) → limit=20', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$sp = SupplierProject::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'update-flow.example.com',
|
||||
'supplier_external_id' => '12345',
|
||||
'current_limit' => 1,
|
||||
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
|
||||
'current_regions' => [],
|
||||
]);
|
||||
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'update-flow.example.com',
|
||||
'supplier_b1_project_id' => $sp->id,
|
||||
'daily_limit_target' => 30,
|
||||
'signal_identifier' => 'order-test.example.com',
|
||||
'daily_limit_target' => 10,
|
||||
'delivery_days_mask' => 127,
|
||||
'region_mask' => 255,
|
||||
'region_mode' => 'include',
|
||||
'regions' => [],
|
||||
]);
|
||||
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'order-test.example.com',
|
||||
'daily_limit_target' => 20,
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [],
|
||||
]);
|
||||
|
||||
// saveProjectMultiFlag called once (both projects share same group)
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '12345'],
|
||||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '600'],
|
||||
200,
|
||||
),
|
||||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(
|
||||
['projects' => [
|
||||
['id' => '600', 'src' => 'rt', 'name' => 'order-test.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'order-test.example.com'],
|
||||
['id' => '601', 'src' => 'bl', 'name' => 'order-test.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'order-test.example.com'],
|
||||
['id' => '602', 'src' => 'mt', 'name' => 'order-test.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'order-test.example.com'],
|
||||
]],
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
|
||||
$sp->refresh();
|
||||
expect($sp->current_limit)->toBe(10)
|
||||
->and($sp->sync_status)->toBe('ok');
|
||||
|
||||
// Update теперь идёт на тот же endpoint что и save (verified 2026-05-19 — Task 1 recon),
|
||||
// с id:N в body вместо id:0.
|
||||
Http::assertSent(fn ($r) => str_ends_with($r->url(), '/admin/visit/rt-project-save') && $r['id'] === 12345);
|
||||
});
|
||||
|
||||
test('skips when no diff between current and computed allocation', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$sp = SupplierProject::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'no-diff.example.com',
|
||||
'supplier_external_id' => '999',
|
||||
'current_limit' => 9,
|
||||
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
|
||||
'current_regions' => [],
|
||||
'sync_status' => 'ok',
|
||||
]);
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'no-diff.example.com',
|
||||
'supplier_b1_project_id' => $sp->id,
|
||||
'daily_limit_target' => 27,
|
||||
'delivery_days_mask' => 127,
|
||||
'region_mask' => 255,
|
||||
'region_mode' => 'include',
|
||||
]);
|
||||
|
||||
Http::fake();
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('isolates failure: one bad supplier_project does not stop others', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$bad = SupplierProject::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'bad.example.com',
|
||||
'supplier_external_id' => null,
|
||||
'current_limit' => 0,
|
||||
'current_workdays' => [],
|
||||
'current_regions' => [],
|
||||
]);
|
||||
$good = SupplierProject::factory()->create([
|
||||
'platform' => 'B2',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'good.example.com',
|
||||
'supplier_external_id' => null,
|
||||
'current_limit' => 0,
|
||||
'current_workdays' => [],
|
||||
'current_regions' => [],
|
||||
]);
|
||||
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'bad.example.com',
|
||||
'supplier_b1_project_id' => $bad->id,
|
||||
'daily_limit_target' => 9,
|
||||
'delivery_days_mask' => 127,
|
||||
'region_mask' => 255,
|
||||
'region_mode' => 'include',
|
||||
]);
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'good.example.com',
|
||||
'supplier_b2_project_id' => $good->id,
|
||||
'daily_limit_target' => 9,
|
||||
'delivery_days_mask' => 127,
|
||||
'region_mask' => 255,
|
||||
'region_mode' => 'include',
|
||||
]);
|
||||
|
||||
Http::fakeSequence('crm.bp-gr.ru/admin/visit/rt-project-save')
|
||||
->push('bad request', 422)
|
||||
->push(['status' => 'OK', 'message' => '', 'result' => null, 'id' => '777'], 200);
|
||||
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
|
||||
expect(
|
||||
SupplierSyncLog::on('pgsql_supplier')
|
||||
->where('supplier_project_id', $bad->id)
|
||||
->whereNotNull('error_message')
|
||||
->exists()
|
||||
)->toBeTrue();
|
||||
|
||||
expect($good->fresh()->supplier_external_id)->toBe('777');
|
||||
});
|
||||
|
||||
test('aborts after 50 consecutive transient failures and sends alert', function (): void {
|
||||
Mail::fake();
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
for ($i = 1; $i <= 60; $i++) {
|
||||
$sp = SupplierProject::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => "host{$i}.example.com",
|
||||
'supplier_external_id' => null,
|
||||
'current_limit' => 0,
|
||||
'current_workdays' => [],
|
||||
'current_regions' => [],
|
||||
]);
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => "host{$i}.example.com",
|
||||
'supplier_b1_project_id' => $sp->id,
|
||||
'daily_limit_target' => 9,
|
||||
'delivery_days_mask' => 127,
|
||||
'region_mask' => 255,
|
||||
'region_mode' => 'include',
|
||||
]);
|
||||
}
|
||||
|
||||
Http::fake(['crm.bp-gr.ru/*' => Http::response('upstream', 503)]);
|
||||
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
|
||||
Mail::assertQueued(SupplierCriticalAlertMail::class, function (SupplierCriticalAlertMail $mail): bool {
|
||||
return $mail->alertType === 'mass_transient';
|
||||
});
|
||||
});
|
||||
|
||||
test('writes supplier_sync_log row for each successful action', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$sp = SupplierProject::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'audit-log.example.com',
|
||||
'supplier_external_id' => null,
|
||||
'current_limit' => 0,
|
||||
'current_workdays' => [],
|
||||
'current_regions' => [],
|
||||
]);
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'audit-log.example.com',
|
||||
'supplier_b1_project_id' => $sp->id,
|
||||
'daily_limit_target' => 9,
|
||||
'delivery_days_mask' => 127,
|
||||
'region_mask' => 255,
|
||||
'region_mode' => 'include',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '555'],
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
|
||||
$log = SupplierSyncLog::on('pgsql_supplier')
|
||||
->where('supplier_project_id', $sp->id)
|
||||
// computeOrder([10, 20]) = max(20, ceil(30/3)=10) = 20
|
||||
$sp = SupplierProject::on('pgsql_supplier')
|
||||
->where('unique_key', 'order-test.example.com')
|
||||
->where('platform', 'B1')
|
||||
->first();
|
||||
|
||||
expect($log)->not->toBeNull()
|
||||
->and($log->action)->toBe('create')
|
||||
->and($log->http_status)->toBe(200)
|
||||
->and($log->error_message)->toBeNull();
|
||||
expect($sp)->not->toBeNull();
|
||||
expect($sp->current_limit)->toBe(20);
|
||||
|
||||
// Only one save call (single group) — not 2
|
||||
Http::assertSentCount(2); // 1 save + 1 listProjects
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SMS platforms
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('sms+keyword → platforms B2+B3 (2 supplier_projects per subject)', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'sms',
|
||||
'signal_identifier' => null,
|
||||
'sms_senders' => ['79001234567'],
|
||||
'sms_keyword' => 'KVARTIRA',
|
||||
'daily_limit_target' => 5,
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [],
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '700'],
|
||||
200,
|
||||
),
|
||||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(
|
||||
['projects' => [
|
||||
['id' => '700', 'src' => 'bl', 'name' => '79001234567+KVARTIRA', 'tag' => 'РФ', 'type' => 'sms', 'content' => '79001234567+KVARTIRA'],
|
||||
['id' => '701', 'src' => 'mt', 'name' => '79001234567+KVARTIRA', 'tag' => 'РФ', 'type' => 'sms', 'content' => '79001234567+KVARTIRA'],
|
||||
]],
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
|
||||
$sps = SupplierProject::on('pgsql_supplier')
|
||||
->where('signal_type', 'sms')
|
||||
->get();
|
||||
|
||||
// sms+keyword → B2+B3 only
|
||||
expect($sps)->toHaveCount(2);
|
||||
expect($sps->pluck('platform')->sort()->values()->all())->toBe(['B2', 'B3']);
|
||||
expect($sps->where('platform', 'B1')->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('sms without keyword → platform B3 only (1 supplier_project)', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'sms',
|
||||
'signal_identifier' => null,
|
||||
'sms_senders' => ['79009876543'],
|
||||
'sms_keyword' => null,
|
||||
'daily_limit_target' => 5,
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [],
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '800'],
|
||||
200,
|
||||
),
|
||||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(
|
||||
['projects' => [
|
||||
['id' => '800', 'src' => 'mt', 'name' => '79009876543', 'tag' => 'РФ', 'type' => 'sms', 'content' => '79009876543'],
|
||||
]],
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
|
||||
$sps = SupplierProject::on('pgsql_supplier')
|
||||
->where('signal_type', 'sms')
|
||||
->get();
|
||||
|
||||
expect($sps)->toHaveCount(1);
|
||||
expect($sps->first()->platform)->toBe('B3');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Idempotent: repeat run → updateProject (no duplicate supplier_projects/pivot)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('idempotent: repeat run with no changes → updateProject not duplicate', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'idempotent.example.com',
|
||||
'daily_limit_target' => 9,
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [],
|
||||
]);
|
||||
|
||||
// First run: create
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '900'],
|
||||
200,
|
||||
),
|
||||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(
|
||||
['projects' => [
|
||||
['id' => '900', 'src' => 'rt', 'name' => 'idempotent.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'idempotent.example.com'],
|
||||
['id' => '901', 'src' => 'bl', 'name' => 'idempotent.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'idempotent.example.com'],
|
||||
['id' => '902', 'src' => 'mt', 'name' => 'idempotent.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'idempotent.example.com'],
|
||||
]],
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
|
||||
expect(SupplierProject::on('pgsql_supplier')
|
||||
->where('unique_key', 'idempotent.example.com')
|
||||
->count())->toBe(3);
|
||||
|
||||
// Second run: no changes → updateProject calls (rt-project-save with id != 0)
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '900'],
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
|
||||
// Still 3 (no duplicates)
|
||||
expect(SupplierProject::on('pgsql_supplier')
|
||||
->where('unique_key', 'idempotent.example.com')
|
||||
->count())->toBe(3);
|
||||
|
||||
// updateProject sends id != 0
|
||||
Http::assertSent(fn ($r) => str_ends_with($r->url(), '/admin/visit/rt-project-save')
|
||||
&& (int) ($r['id'] ?? 0) !== 0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Orthogonal: time budget, auth, abort-50, sync_log
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('respects time budget by stopping at 20:55 МСК', function (): void {
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-12 20:56:00', 'Europe/Moscow'));
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$sp = SupplierProject::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'time-budget.example.com',
|
||||
'supplier_external_id' => null,
|
||||
'current_limit' => 0,
|
||||
'current_workdays' => [],
|
||||
'current_regions' => [],
|
||||
]);
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'time-budget.example.com',
|
||||
'supplier_b1_project_id' => $sp->id,
|
||||
'daily_limit_target' => 9,
|
||||
'delivery_days_mask' => 127,
|
||||
'region_mask' => 255,
|
||||
'region_mode' => 'include',
|
||||
'regions' => [],
|
||||
]);
|
||||
|
||||
Http::fake();
|
||||
@@ -322,60 +399,20 @@ test('respects time budget by stopping at 20:55 МСК', function (): void {
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('passes regions directly to allocator without bitmask conversion', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'regions' => [82, 83],
|
||||
'region_mask' => 255,
|
||||
]);
|
||||
|
||||
$job = new SyncSupplierProjectsJob;
|
||||
$projects = Project::where('tenant_id', $tenant->id)->get();
|
||||
$adapted = (fn (Collection $p) => $this->adaptProjectsForAllocator($p))->call($job, $projects);
|
||||
|
||||
expect($adapted->first()->regions)->toBe([82, 83]);
|
||||
});
|
||||
|
||||
test('passes empty array to allocator when project has regions=[]', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'regions' => [],
|
||||
'region_mask' => 255,
|
||||
]);
|
||||
|
||||
$job = new SyncSupplierProjectsJob;
|
||||
$projects = Project::where('tenant_id', $tenant->id)->get();
|
||||
$adapted = (fn (Collection $p) => $this->adaptProjectsForAllocator($p))->call($job, $projects);
|
||||
|
||||
expect($adapted->first()->regions)->toBe([]);
|
||||
});
|
||||
|
||||
test('sticky auth error throws and sends critical alert email', function (): void {
|
||||
Mail::fake();
|
||||
Bus::fake([RefreshSupplierSessionJob::class]);
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$sp = SupplierProject::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'auth-fail.example.com',
|
||||
'supplier_external_id' => null,
|
||||
'current_limit' => 0,
|
||||
'current_workdays' => [],
|
||||
'current_regions' => [],
|
||||
]);
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'auth-fail.example.com',
|
||||
'supplier_b1_project_id' => $sp->id,
|
||||
'daily_limit_target' => 9,
|
||||
'delivery_days_mask' => 127,
|
||||
'region_mask' => 255,
|
||||
'region_mode' => 'include',
|
||||
'regions' => [],
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
@@ -390,40 +427,77 @@ test('sticky auth error throws and sends critical alert email', function (): voi
|
||||
});
|
||||
});
|
||||
|
||||
test('outbound: copies project regions[] into supplier_project current_regions via full handle()', function (): void {
|
||||
test('aborts after 50 consecutive transient failures and sends alert', function (): void {
|
||||
Mail::fake();
|
||||
$tenant = Tenant::factory()->create();
|
||||
$sp = SupplierProject::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'regions-flow.example.com',
|
||||
'supplier_external_id' => null,
|
||||
'current_limit' => 0,
|
||||
'current_workdays' => [],
|
||||
'current_regions' => [],
|
||||
]);
|
||||
|
||||
for ($i = 1; $i <= 60; $i++) {
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => "host{$i}.abort.com",
|
||||
'daily_limit_target' => 9,
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
Http::fake(['crm.bp-gr.ru/*' => Http::response('upstream', 503)]);
|
||||
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
|
||||
Mail::assertQueued(SupplierCriticalAlertMail::class, function (SupplierCriticalAlertMail $mail): bool {
|
||||
return $mail->alertType === 'mass_transient';
|
||||
});
|
||||
});
|
||||
|
||||
test('writes supplier_sync_log row for each successful action', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'regions-flow.example.com',
|
||||
'supplier_b1_project_id' => $sp->id,
|
||||
'signal_identifier' => 'audit-log.example.com',
|
||||
'daily_limit_target' => 9,
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [82, 83],
|
||||
'region_mask' => 255,
|
||||
'region_mode' => 'include',
|
||||
'regions' => [],
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '556'],
|
||||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '555'],
|
||||
200,
|
||||
),
|
||||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(
|
||||
['projects' => [
|
||||
['id' => '555', 'src' => 'rt', 'name' => 'audit-log.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'audit-log.example.com'],
|
||||
['id' => '556', 'src' => 'bl', 'name' => 'audit-log.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'audit-log.example.com'],
|
||||
['id' => '557', 'src' => 'mt', 'name' => 'audit-log.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'audit-log.example.com'],
|
||||
]],
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
|
||||
$sp->refresh();
|
||||
expect($sp->current_regions)->toBe([82, 83])
|
||||
->and($sp->supplier_external_id)->toBe('556');
|
||||
// 3 supplier_projects created → 3 log rows (one per platform)
|
||||
$sp = SupplierProject::on('pgsql_supplier')
|
||||
->where('unique_key', 'audit-log.example.com')
|
||||
->where('platform', 'B1')
|
||||
->first();
|
||||
|
||||
expect($sp)->not->toBeNull();
|
||||
|
||||
$log = SupplierSyncLog::on('pgsql_supplier')
|
||||
->where('supplier_project_id', $sp->id)
|
||||
->first();
|
||||
|
||||
expect($log)->not->toBeNull()
|
||||
->and($log->action)->toBe('create')
|
||||
->and($log->http_status)->toBe(200)
|
||||
->and($log->error_message)->toBeNull();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user