d2d.emegrendeles.hu/tests/Feature/PricelistRevertTest.php
E98Developer e338f68ee8 ADD EV3-357 Árlista feldolgozás phase5/4 visszavonás, elakadás-észlelés, kényszerlezárás
- PricelistExecutionJob + új PricelistRevertJob: $tries=1, $timeout, failed()
  hook. A service catch ága csak PHP exceptiont fog el; worker timeout vagy
  memórialimit esetén a fájl inprogress-ben ragadna, döntési gomb nélkül.
- heartbeat backstop a kill -9 esetére, ahol a failed() sem fut: minden chunk ír
  a rekordba, így az updated_at a szívverés (isExecutionStuck). Ezzel garantált,
  hogy minden kísérlet véges időn belül terminális állapotba jut - enélkül a
  kényszerlezárás sem nyílna ki soha.
- kompenzáló visszaállítás R1-R4: árak törlése, termékek visszaállítása a
  snapshotból, létrehozott termékek kivonása (soft delete, NEM fizikai törlés,
  mert lehet rájuk hivatkozás), árlista + fájl lezárása
- konfliktuskezelés: a végrehajtás óta kézzel módosított terméket kihagyjuk és
  jelentjük, nem írjuk felül vakon
- FIX phase5/3: a snapshot csak az írás ELŐTTI updated_at-et tárolta, amihez
  képest a termék a végrehajtás után mindig eltér - a konfliktus-ellenőrzés így
  minden sort kihagyott volna. Most a saját írásunk utáni updated_at is bekerül.
- kényszerlezárás: csak legalább egy terminális hibába futott visszaállítási
  kísérlet után, flag + developer szerepkör mögött, kötelező indoklással
- döntéstámogató panel: mi történt már meg az importból, hogy a felhasználó ne
  vakon válasszon a Folytatás és a Visszavonás között

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 06:36:21 +02:00

293 lines
11 KiB
PHP

<?php
use App\Enums\DbStatusFieldEnum;
use App\Enums\PricelistFileLineStatusEnum;
use App\Enums\PricelistFileStatusEnum;
use App\Enums\PricelistWorkflowStep;
use App\Jobs\PricelistExecutionJob;
use App\Models\PriceList;
use App\Models\PricelistFile;
use App\Models\PricelistFileLine;
use App\Models\Producer;
use App\Models\Product;
use App\Models\ProductGroup;
use App\Models\Supplier;
use App\Services\PriceListService;
use App\Services\PricelistFileProcessService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function revertTestFile(Supplier $supplier): PricelistFile
{
return PricelistFile::create([
'filename' => 'arlista.xlsx',
'supplier_id' => $supplier->id,
'available_date' => now()->addWeek()->toDateString(),
'status' => PricelistFileStatusEnum::inprogress,
'workflow_steps' => [
['name' => PricelistWorkflowStep::Validation->value, 'label' => 'Validálás', 'status' => 'completed'],
['name' => PricelistWorkflowStep::Approval->value, 'label' => 'Jóváhagyás', 'status' => 'completed'],
['name' => PricelistWorkflowStep::Execution->value, 'label' => 'Végrehajtás', 'status' => 'inprogress'],
],
]);
}
function revertTestPayload(array $overrides = []): array
{
$h = PriceListService::EXPECTED_HEADERS;
return array_merge([
$h[0] => 'SKU-1',
$h[1] => 'Főcsoport',
$h[2] => 'Alcsoport 1',
$h[4] => 'Új név',
$h[5] => '6',
$h[6] => '1,5',
$h[7] => 'l',
$h[8] => 'Teszt Gyártó',
$h[9] => 'kart',
$h[10] => '6',
$h[11] => 'db',
$h[12] => '0,27',
$h[14] => '990',
$h[20] => 'Megjegyzés',
$h[21] => '',
$h[22] => '',
], $overrides);
}
function revertTestLine(PricelistFile $file, PricelistFileLineStatusEnum $status, array $payload, array $attributes = []): PricelistFileLine
{
return PricelistFileLine::create(array_merge([
'pricelist_file_id' => $file->id,
'row_number' => 5,
'status' => $status,
'payload' => $payload,
], $attributes));
}
/**
* Olyan service, amelynek a visszaállítása a létrehozott termékek fázisában elhal -
* az árak és a frissítések ekkor már visszaálltak.
*/
function failingRevertService(): PricelistFileProcessService
{
return new class(app(PriceListService::class)) extends PricelistFileProcessService
{
protected function revertCreatedProducts(PricelistFile $pricelistFile): void
{
throw new RuntimeException('Szimulált visszaállítási hiba.');
}
};
}
beforeEach(function () {
$this->supplier = Supplier::factory()->create();
$this->group = ProductGroup::create(['name' => 'Alcsoport 1', 'type' => 'F', 'status' => DbStatusFieldEnum::active, 'canSee' => 1]);
$this->producer = Producer::create(['name' => 'Teszt Gyártó', 'status' => DbStatusFieldEnum::active, 'canSee' => 1]);
$this->service = app(PricelistFileProcessService::class);
});
test('a visszavonás visszaállítja a frissített terméket a snapshotból', function () {
$file = revertTestFile($this->supplier);
$product = Product::create([
'name' => 'Régi név',
'supplierProductNumber' => 'SKU-1',
'supplier_id' => $this->supplier->id,
'producer_id' => $this->producer->id,
'product_group_id' => $this->group->id,
'unitValue' => 1,
'note' => '',
'vat' => 5,
'status' => DbStatusFieldEnum::active,
'canSee' => 1,
]);
$line = revertTestLine($file, PricelistFileLineStatusEnum::updated, revertTestPayload(), [
'product_id' => $product->id,
'product_group_id' => $this->group->id,
'producer_id' => $this->producer->id,
]);
$this->service->execute($file);
expect($product->refresh()->name)->toBe('Új név');
expect($this->service->revert($file->refresh()))->toBeTrue();
$file->refresh();
expect($product->refresh()->name)->toBe('Régi név')
->and((float) $product->vat)->toBe(5.0)
->and($line->refresh()->applied_snapshot)->toBeNull()
->and($file->status)->toBe(PricelistFileStatusEnum::closed)
->and($file->stepStatus(PricelistWorkflowStep::Execution))->toBe('reverted')
->and(PriceList::find($file->price_list_id)->status)->toBe(DbStatusFieldEnum::deleted)
->and(DB::table('price_list_prices')->count())->toBe(0);
});
test('a visszavonás kivonja a forgalomból a létrehozott terméket', function () {
$file = revertTestFile($this->supplier);
revertTestLine($file, PricelistFileLineStatusEnum::new_product, revertTestPayload([
PriceListService::EXPECTED_HEADERS[0] => 'SKU-NEW',
]), ['product_group_id' => $this->group->id, 'producer_id' => $this->producer->id]);
$this->service->execute($file);
$productId = Product::where('supplierProductNumber', 'SKU-NEW')->value('id');
$this->service->revert($file->refresh());
$product = Product::withTrashed()->find($productId);
expect($product)->not->toBeNull() // nem fizikai törlés
->and($product->trashed())->toBeTrue()
->and($product->status)->toBe(DbStatusFieldEnum::deleted)
->and((bool) $product->canSee)->toBeFalse();
});
test('a végrehajtás óta kézzel módosított terméket kihagyja és jelenti', function () {
$file = revertTestFile($this->supplier);
$product = Product::create([
'name' => 'Régi név',
'supplierProductNumber' => 'SKU-1',
'supplier_id' => $this->supplier->id,
'producer_id' => $this->producer->id,
'product_group_id' => $this->group->id,
'unitValue' => 1,
'note' => '',
'status' => DbStatusFieldEnum::active,
'canSee' => 1,
]);
$line = revertTestLine($file, PricelistFileLineStatusEnum::updated, revertTestPayload(), [
'product_id' => $product->id,
'product_group_id' => $this->group->id,
'producer_id' => $this->producer->id,
]);
$this->service->execute($file);
// Kézi módosítás a végrehajtás után. Az updated_at-et explicit állítjuk, mert a
// MySQL timestamp másodperc pontosságú, és a teszt ennél gyorsabban futna le.
$product->update(['name' => 'Kézzel átírt név', 'updated_at' => now()->addMinute()]);
$this->service->revert($file->refresh());
$file->refresh();
expect($product->refresh()->name)->toBe('Kézzel átírt név') // NEM írtuk felül
->and($file->file_meta['revert']['skipped_count'])->toBe(1)
->and($file->file_meta['revert']['skipped'][0]['row_number'])->toBe(5)
->and($line->refresh()->validation_messages['revert'])->toContain('módosult')
->and($file->status)->toBe(PricelistFileStatusEnum::closed);
});
test('a visszavonás megismételhető, nem borul fel az állapot', function () {
$file = revertTestFile($this->supplier);
revertTestLine($file, PricelistFileLineStatusEnum::new_product, revertTestPayload([
PriceListService::EXPECTED_HEADERS[0] => 'SKU-NEW',
]), ['product_group_id' => $this->group->id, 'producer_id' => $this->producer->id]);
$this->service->execute($file);
$this->service->revert($file->refresh());
$this->service->revert($file->refresh());
expect(Product::withTrashed()->where('supplierProductNumber', 'SKU-NEW')->count())->toBe(1)
->and(DB::table('price_list_prices')->count())->toBe(0)
->and($file->refresh()->status)->toBe(PricelistFileStatusEnum::closed);
});
test('a sikertelen visszaállítás execution_failed-be tesz és megnyitja a kényszerlezárást', function () {
$file = revertTestFile($this->supplier);
revertTestLine($file, PricelistFileLineStatusEnum::new_product, revertTestPayload([
PriceListService::EXPECTED_HEADERS[0] => 'SKU-NEW',
]), ['product_group_id' => $this->group->id, 'producer_id' => $this->producer->id]);
$this->service->execute($file);
expect(failingRevertService()->revert($file->refresh()))->toBeFalse();
$file->refresh();
expect($file->status)->toBe(PricelistFileStatusEnum::execution_failed)
->and($file->file_meta['revert']['attempts'])->toBe(1)
->and($file->file_meta['revert']['last_status'])->toBe('failed')
->and($file->file_meta['revert']['last_error'])->toContain('Szimulált')
->and($file->canBeForceClosed())->toBeTrue();
});
test('kényszerlezárás nem indítható sikertelen visszaállítási kísérlet nélkül', function () {
$file = revertTestFile($this->supplier);
revertTestLine($file, PricelistFileLineStatusEnum::new_product, revertTestPayload([
PriceListService::EXPECTED_HEADERS[0] => 'SKU-NEW',
]), ['product_group_id' => $this->group->id, 'producer_id' => $this->producer->id]);
$this->service->execute($file);
$this->service->updateStepStatus($file, PricelistWorkflowStep::Execution, 'failed', 'Hiba.');
$file->refresh();
expect($file->status)->toBe(PricelistFileStatusEnum::execution_failed)
->and($file->canBeForceClosed())->toBeFalse()
->and($this->service->forceClose($file, 'Csak úgy.'))->toBeFalse()
->and($file->refresh()->status)->toBe(PricelistFileStatusEnum::execution_failed);
});
test('a kényszerlezárás rögzíti az indoklást és felszabadítja a fájlt', function () {
$file = revertTestFile($this->supplier);
revertTestLine($file, PricelistFileLineStatusEnum::new_product, revertTestPayload([
PriceListService::EXPECTED_HEADERS[0] => 'SKU-NEW',
]), ['product_group_id' => $this->group->id, 'producer_id' => $this->producer->id]);
$this->service->execute($file);
failingRevertService()->revert($file->refresh());
expect($this->service->forceClose($file->refresh(), 'Kézzel rendezzük az adatokat.'))->toBeTrue();
$file->refresh();
expect($file->status)->toBe(PricelistFileStatusEnum::closed)
->and($file->file_meta['force_close']['reason'])->toBe('Kézzel rendezzük az adatokat.')
->and($file->file_meta['force_close']['revert_error'])->toContain('Szimulált');
});
test('az elakadt végrehajtás észlelhető és döntést kér', function () {
$file = revertTestFile($this->supplier);
expect($file->isExecutionStuck())->toBeFalse();
// A heartbeat (updated_at) nyers frissítése: az Eloquent felülírná a mostani időre.
DB::table('pricelist_files')
->where('id', $file->id)
->update(['updated_at' => now()->subMinutes(PricelistFile::STUCK_AFTER_MINUTES + 5)]);
$file->refresh();
expect($file->isExecutionStuck())->toBeTrue()
->and($file->needsExecutionDecision())->toBeTrue();
});
test('a job failed() hookja akkor is execution_failed-be teszi a fájlt, ha a catch nem futott le', function () {
$file = revertTestFile($this->supplier);
// Worker timeout / memórialimit szimulálása: a handle() catch ága ilyenkor nem fut.
(new PricelistExecutionJob($file))->failed(new RuntimeException('Job timeout.'));
$file->refresh();
expect($file->status)->toBe(PricelistFileStatusEnum::execution_failed)
->and($file->stepStatus(PricelistWorkflowStep::Execution))->toBe('failed')
->and($file->needsExecutionDecision())->toBeTrue();
});