feat(supplier-import): buildPlan — site/call группировка B1/B2/B3, лимит=сумма

This commit is contained in:
Дмитрий
2026-05-22 09:32:37 +03:00
parent 16edd922ed
commit 9cabe8ded4
2 changed files with 208 additions and 0 deletions
@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace App\Services\Supplier\Import;
use App\Services\Supplier\SupplierPortalClient;
use App\Support\SupplierRegions;
/**
* Усыновление активных проектов поставщика (аккаунт lkomega) как проектов
* Лидерры. Читает listProjects (read-only), группирует площадки B1/B2/B3 в один
* проект, реверс-маппит регионы, считает лимит как сумму площадок.
*
* Spec: docs/superpowers/specs/2026-05-22-supplier-projects-import-lkomega-design.md
*/
class SupplierProjectImporter
{
public function __construct(
private readonly SupplierPortalClient $client,
) {}
/**
* @return array{planned: list<array<string, mixed>>, skipped: list<array{reason: string, label: string}>}
*/
public function buildPlan(int $tenantId): array
{
$rows = $this->client->listProjects();
/** @var list<array{reason: string, label: string}> $skipped */
$skipped = [];
/** @var array<string, array<string, mixed>> $groups */
$groups = [];
foreach ($rows as $row) {
if (($row['status'] ?? false) !== true) {
continue;
}
$platform = SupplierImportMapper::platformFromSrc((string) ($row['src'] ?? ''));
if ($platform === null) {
$skipped[] = ['reason' => 'unsupported_source', 'label' => (string) ($row['name'] ?? $row['content'] ?? '?')];
continue;
}
$signalType = SupplierImportMapper::signalTypeFromType((string) ($row['type'] ?? ''));
if ($signalType === null) {
$skipped[] = ['reason' => 'unsupported_type', 'label' => (string) ($row['name'] ?? '?')];
continue;
}
if ($signalType === 'sms') {
continue;
}
$identifier = (string) ($row['content'] ?? '');
$key = $signalType.'|'.$identifier;
if (! isset($groups[$key])) {
$groups[$key] = [
'signal_type' => $signalType,
'signal_identifier' => $identifier,
'sms_senders' => [],
'sms_keyword' => null,
'tag' => '',
'regions' => [],
'has_all_russia' => false,
'workdays_mask' => 0,
'daily_limit_target' => 0,
'platforms' => [],
];
}
$this->accumulateRow($groups[$key], $row, $platform);
}
$planned = [];
foreach ($groups as $g) {
unset($g['has_all_russia']);
$g['delivery_days_mask'] = $g['workdays_mask'] === 0 ? 127 : $g['workdays_mask'];
unset($g['workdays_mask']);
if ($g['tag'] === '') {
$g['tag'] = 'РФ';
}
$g['name'] = $this->deriveName($g);
$planned[] = $g;
}
return ['planned' => array_values($planned), 'skipped' => $skipped];
}
/**
* @param array<string, mixed> $group
* @param array<string, mixed> $row
*/
private function accumulateRow(array &$group, array $row, string $platform): void
{
$lim = (int) ($row['lim'] ?? 0);
$group['daily_limit_target'] += $lim;
$group['platforms'][] = [
'platform' => $platform,
'external_id' => (int) ($row['id'] ?? 0),
'lim' => $lim,
];
$rowTag = trim((string) ($row['tag'] ?? ''));
if ($group['tag'] === '' && $rowTag !== '' && $rowTag !== 'РФ') {
$group['tag'] = $rowTag;
}
$group['workdays_mask'] |= SupplierImportMapper::workdaysToMask((array) ($row['workdays'] ?? []));
if (! $group['has_all_russia']) {
$gibdd = SupplierImportMapper::parseGibddRegions(
is_string($row['regions'] ?? null) ? $row['regions'] : ''
);
if ($gibdd === []) {
$group['has_all_russia'] = true;
$group['regions'] = [];
} else {
$liderra = SupplierRegions::mapFromSupplier($gibdd);
$group['regions'] = array_values(array_unique(array_merge($group['regions'], $liderra)));
sort($group['regions']);
}
}
}
/**
* @param array<string, mixed> $group
*/
private function deriveName(array $group): string
{
$tag = (string) $group['tag'];
$base = ($tag !== '' && $tag !== 'РФ')
? $tag
: (string) ($group['signal_identifier'] ?? 'проект');
return mb_substr($base, 0, 255);
}
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Services\Supplier\Import\SupplierProjectImporter;
use App\Services\Supplier\SupplierPortalClient;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
/**
* @param list<array<string, mixed>> $rows
*/
function importerWithRows(array $rows): SupplierProjectImporter
{
$client = Mockery::mock(SupplierPortalClient::class);
$client->shouldReceive('listProjects')->andReturn($rows);
return new SupplierProjectImporter($client);
}
test('buildPlan groups B1/B2/B3 call rows into one planned project, limit = sum', function (): void {
$tenant = Tenant::factory()->create();
$plan = importerWithRows([
['id' => '4001', 'src' => 'rt', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '', 'workdays' => ['1', '2', '3', '4', '5']],
['id' => '4002', 'src' => 'bl', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '', 'workdays' => ['1', '2', '3', '4', '5']],
['id' => '4003', 'src' => 'mt', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '', 'workdays' => ['1', '2', '3', '4', '5']],
])->buildPlan($tenant->id);
expect($plan['planned'])->toHaveCount(1);
$p = $plan['planned'][0];
expect($p['signal_type'])->toBe('call');
expect($p['signal_identifier'])->toBe('79991112233');
expect($p['daily_limit_target'])->toBe(18);
expect($p['delivery_days_mask'])->toBe(31);
expect($p['tag'])->toBe('Каранга');
expect($p['regions'])->toBe([]);
expect(collect($p['platforms'])->pluck('platform')->sort()->values()->all())->toBe(['B1', 'B2', 'B3']);
expect(collect($p['platforms'])->firstWhere('platform', 'B1')['external_id'])->toBe(4001);
});
test('buildPlan skips inactive rows (status=false)', function (): void {
$tenant = Tenant::factory()->create();
$plan = importerWithRows([
['id' => '5001', 'src' => 'rt', 'type' => 'calls', 'content' => '79995550000', 'tag' => 'X', 'lim' => '5', 'status' => false, 'regions' => '', 'workdays' => []],
])->buildPlan($tenant->id);
expect($plan['planned'])->toHaveCount(0);
});
test('buildPlan skips dop2 (unsupported source) and reports it', function (): void {
$tenant = Tenant::factory()->create();
$plan = importerWithRows([
['id' => '6001', 'src' => 'dop2', 'type' => 'calls', 'content' => '79996660000', 'tag' => 'X', 'lim' => '5', 'status' => true, 'regions' => '', 'workdays' => []],
])->buildPlan($tenant->id);
expect($plan['planned'])->toHaveCount(0);
expect(collect($plan['skipped'])->pluck('reason'))->toContain('unsupported_source');
});