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).
191 lines
6.8 KiB
PHP
191 lines
6.8 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Models\Project;
|
||
use App\Models\SupplierProject;
|
||
use App\Models\Tenant;
|
||
use App\Models\User;
|
||
use App\Services\Supplier\SupplierPortalClient;
|
||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||
use Illuminate\Support\Facades\DB;
|
||
|
||
uses(DatabaseTransactions::class);
|
||
|
||
// EnsureSaasAdmin — стаб в testing (см. SupplierManualQueueTest::16): обычный
|
||
// User::factory + actingAs без guard'а.
|
||
|
||
it('GET /admin/supplier-integration/projects returns rows with orderers + last delivery', function (): void {
|
||
$this->actingAs(User::factory()->create());
|
||
$tenant = Tenant::factory()->create(['organization_name' => 'ООО Ромашка']);
|
||
|
||
$sp = SupplierProject::query()->create([
|
||
'platform' => 'B1',
|
||
'signal_type' => 'site',
|
||
'unique_key' => 'okna.ru',
|
||
'subject_code' => 82, // Москва (по конституционному порядку, ст. 65)
|
||
'current_limit' => 5,
|
||
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
|
||
'current_regions' => null,
|
||
'sync_status' => 'ok',
|
||
'supplier_external_id' => '777',
|
||
]);
|
||
|
||
$project = Project::factory()->for($tenant)->create();
|
||
DB::table('project_supplier_links')->insert([
|
||
'project_id' => $project->id,
|
||
'supplier_project_id' => $sp->id,
|
||
'platform' => 'B1',
|
||
'subject_code' => 82,
|
||
]);
|
||
|
||
DB::table('supplier_leads')->insert([
|
||
'supplier_project_id' => $sp->id,
|
||
'platform' => 'B1',
|
||
'raw_payload' => json_encode([]),
|
||
'phone' => '+79991234567',
|
||
'received_at' => '2026-05-19 10:00:00',
|
||
]);
|
||
|
||
$resp = $this->getJson('/api/admin/supplier-integration/projects')
|
||
->assertOk()
|
||
->json();
|
||
|
||
$row = collect($resp['projects'])->firstWhere('id', $sp->id);
|
||
expect($row)->not->toBeNull()
|
||
->and($row['unique_key'])->toBe('okna.ru')
|
||
->and($row['subject_code'])->toBe(82)
|
||
->and($row['subject_name'])->toBe('Москва')
|
||
->and($row['platform'])->toBe('B1')
|
||
->and($row['current_limit'])->toBe(5)
|
||
->and($row['orderers'])->toContain('ООО Ромашка')
|
||
->and($row['last_delivery_at'])->not->toBeNull();
|
||
});
|
||
|
||
it('GET /projects returns subject_name «РФ» for NULL subject_code', function (): void {
|
||
$this->actingAs(User::factory()->create());
|
||
|
||
$sp = SupplierProject::query()->create([
|
||
'platform' => 'B2',
|
||
'signal_type' => 'site',
|
||
'unique_key' => 'all-russia.example',
|
||
'subject_code' => null,
|
||
'current_limit' => 0,
|
||
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
|
||
'current_regions' => null,
|
||
'sync_status' => 'ok',
|
||
'supplier_external_id' => '888',
|
||
]);
|
||
|
||
$resp = $this->getJson('/api/admin/supplier-integration/projects')->assertOk()->json();
|
||
$row = collect($resp['projects'])->firstWhere('id', $sp->id);
|
||
expect($row['subject_code'])->toBeNull()
|
||
->and($row['subject_name'])->toBe('РФ');
|
||
});
|
||
|
||
it('POST /projects/delete deletes on portal + locally (pivot cascades)', function (): void {
|
||
$this->actingAs(User::factory()->create());
|
||
|
||
// Мокаем portal-клиент, чтобы не лезть в Redis-сессию (SupplierPortalClient::loadSession()).
|
||
$deletedExternalIds = [];
|
||
$clientMock = new class($deletedExternalIds) extends SupplierPortalClient
|
||
{
|
||
/** @var array<int, int> */
|
||
public array $calls;
|
||
|
||
public function __construct(array &$calls)
|
||
{
|
||
$this->calls = &$calls;
|
||
}
|
||
|
||
public function deleteProject(int $externalId): void
|
||
{
|
||
$this->calls[] = $externalId;
|
||
}
|
||
};
|
||
app()->instance(SupplierPortalClient::class, $clientMock);
|
||
|
||
$tenant = Tenant::factory()->create();
|
||
$sp = SupplierProject::query()->create([
|
||
'platform' => 'B1',
|
||
'signal_type' => 'site',
|
||
'unique_key' => 'delete-me.ru',
|
||
'subject_code' => 77,
|
||
'current_limit' => 0,
|
||
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
|
||
'current_regions' => null,
|
||
'sync_status' => 'ok',
|
||
'supplier_external_id' => '999',
|
||
]);
|
||
$project = Project::factory()->for($tenant)->create();
|
||
DB::table('project_supplier_links')->insert([
|
||
'project_id' => $project->id,
|
||
'supplier_project_id' => $sp->id,
|
||
'platform' => 'B1',
|
||
'subject_code' => 77,
|
||
]);
|
||
|
||
$this->postJson('/api/admin/supplier-integration/projects/delete', ['ids' => [$sp->id]])
|
||
->assertOk()
|
||
->assertJson(['deleted' => 1, 'failures' => []]);
|
||
|
||
expect(SupplierProject::find($sp->id))->toBeNull();
|
||
expect($clientMock->calls)->toBe([999]);
|
||
expect(DB::table('project_supplier_links')->where('supplier_project_id', $sp->id)->count())->toBe(0);
|
||
});
|
||
|
||
it('POST /projects/delete validates ids array', function (): void {
|
||
$this->actingAs(User::factory()->create());
|
||
|
||
$this->postJson('/api/admin/supplier-integration/projects/delete', ['ids' => []])
|
||
->assertStatus(422);
|
||
|
||
$this->postJson('/api/admin/supplier-integration/projects/delete', [])
|
||
->assertStatus(422);
|
||
});
|
||
|
||
it('POST /projects/delete collects failures without aborting batch', function (): void {
|
||
$this->actingAs(User::factory()->create());
|
||
|
||
$clientMock = new class extends SupplierPortalClient
|
||
{
|
||
public int $callsCount = 0;
|
||
|
||
public function __construct() {}
|
||
|
||
public function deleteProject(int $externalId): void
|
||
{
|
||
$this->callsCount++;
|
||
if ($externalId === 555) {
|
||
throw new RuntimeException('portal said no');
|
||
}
|
||
}
|
||
};
|
||
app()->instance(SupplierPortalClient::class, $clientMock);
|
||
|
||
$spOk = SupplierProject::query()->create([
|
||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'ok.ru',
|
||
'subject_code' => 77, 'current_limit' => 0,
|
||
'current_workdays' => [1, 2, 3, 4, 5, 6, 7], 'current_regions' => null,
|
||
'sync_status' => 'ok', 'supplier_external_id' => '111',
|
||
]);
|
||
$spBad = SupplierProject::query()->create([
|
||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'bad.ru',
|
||
'subject_code' => 77, 'current_limit' => 0,
|
||
'current_workdays' => [1, 2, 3, 4, 5, 6, 7], 'current_regions' => null,
|
||
'sync_status' => 'ok', 'supplier_external_id' => '555',
|
||
]);
|
||
|
||
$resp = $this->postJson('/api/admin/supplier-integration/projects/delete', [
|
||
'ids' => [$spOk->id, $spBad->id],
|
||
])->assertOk()->json();
|
||
|
||
expect($resp['deleted'])->toBe(1)
|
||
->and(count($resp['failures']))->toBe(1)
|
||
->and($resp['failures'][0]['id'])->toBe($spBad->id)
|
||
->and($resp['failures'][0]['error'])->toContain('portal said no');
|
||
|
||
expect(SupplierProject::find($spOk->id))->toBeNull();
|
||
expect(SupplierProject::find($spBad->id))->not->toBeNull(); // bad — не удалён локально
|
||
});
|