- 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>
293 lines
11 KiB
PHP
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();
|
|
});
|