'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(); });