feat(supplier-import): buildPlan — site/call группировка B1/B2/B3, лимит=сумма
This commit is contained in:
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user