diff --git a/app/Services/PricelistFileProcessService.php b/app/Services/PricelistFileProcessService.php index 34c6ab1..266cca8 100644 --- a/app/Services/PricelistFileProcessService.php +++ b/app/Services/PricelistFileProcessService.php @@ -2,8 +2,10 @@ namespace App\Services; +use App\Enums\DbStatusFieldEnum; use App\Enums\PreProcessErrorCode; use App\Enums\PricelistWorkflowStep; +use App\Models\PriceList; use App\Models\PricelistFile; use App\Enums\PricelistFileStatusEnum; use App\Models\PricelistFileLine; @@ -64,6 +66,41 @@ class PricelistFileProcessService ], ]; + /** + * A végrehajtás mezőleképezése: Excel oszlop indexe -> `products` tábla mezője. + * + * Bővebb, mint a PRODUCT_FIELD_MAP: az csak a diff-számításhoz kell, tehát azokat a + * mezőket tartalmazza, amelyek változását ki akarjuk mutatni. Importáláskor viszont + * olyan mezőket is írunk, amelyeket nem hasonlítunk össze (cikkszám, kiszerelés, + * vevői megnevezés, megjegyzés, KREL, akció) - a legacy importtal azonos körben. + */ + protected const EXECUTION_FIELD_MAP = [ + 'supplierProductNumber' => ['index' => 0, 'type' => 'string'], + 'name' => ['index' => 4, 'type' => 'string'], + 'packing' => ['index' => 5, 'type' => 'int'], + 'unitValue' => ['index' => 6, 'type' => 'float'], + 'productUnit' => ['index' => 7, 'type' => 'string'], + 'sellerUnit' => ['index' => 9, 'type' => 'string'], + 'unitMultiplier' => ['index' => 10, 'type' => 'float'], + 'amountUnit' => ['index' => 11, 'type' => 'string'], + 'vat' => ['index' => 12, 'type' => 'float'], + 'hooreycaId' => ['index' => 16, 'type' => 'string'], + 'HooreycaUnit' => ['index' => 17, 'type' => 'string'], + 'HooreycaMultiplier' => ['index' => 18, 'type' => 'float'], + 'buyerProductName' => ['index' => 19, 'type' => 'string'], + 'note' => ['index' => 20, 'type' => 'string'], + 'krel' => ['index' => 21, 'type' => 'bool'], + 'specialOffer' => ['index' => 22, 'type' => 'bool'], + ]; + + /** + * Chunk méretek a végrehajtáshoz. A termékírás soronként több query-t jelent, + * ezért kisebb köteg; az árak kötegelt insertje elbír nagyobbat. + */ + private const EXECUTION_CHUNK_SIZE = 500; + + private const PRICE_CHUNK_SIZE = 1000; + public function __construct( protected PriceListService $priceListService ) {} @@ -242,7 +279,13 @@ public function updateStepStatus( $updateData['status'] = PricelistFileStatusEnum::inprogress; } } elseif ($status === 'failed') { - $updateData['status'] = PricelistFileStatusEnum::fail; + // A végrehajtás hibája külön státuszt kap: a `fail` a validálásig tartó + // szakaszt jelenti, ahonnan a fájl újratöltése biztonságosan újraindítja a + // láncot - a végrehajtásnál viszont már történhettek termék- és árírások, + // ott csak a Folytatás vagy a Visszavonás megengedett. + $updateData['status'] = $stepEnum === PricelistWorkflowStep::Execution + ? PricelistFileStatusEnum::execution_failed + : PricelistFileStatusEnum::fail; } $pricelistFile->update($updateData); @@ -1269,26 +1312,403 @@ protected function runBusinessValidation(PricelistFile $pricelistFile, bool $has */ public function execute(PricelistFile $pricelistFile): bool { - $this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Execution, 'inprogress', 'Árlista frissítése...', 0); + $this->reportExecutionProgress($pricelistFile, 0, 'Végrehajtás indítása...'); - DB::beginTransaction(); try { - // TODO: Tényleges importálás végrehajtása - // $this->priceListService->importPriceList(...); - sleep(1); // Szimuláció + // Szándékosan NINCS egyetlen, mindent átfogó tranzakció: több ezer sornál a + // termék-insert + pivot-insert + visibility update percekig tartana lockokat, + // és egy timeout minden visszajelzés nélkül dobna el mindent. Helyette + // fázisonként/chunkonként tranzakciózunk, az "atomicitást" pedig az adja, hogy + // az árlista E6-ig `draft` marad - a felhasználói felület és a statisztika + // csak az `active` árlistákat nézi, tehát félbeszakadás esetén sem kerül ki + // félkész ár. A megszakadt futás a kurzoroktól folytatható (idempotens). + $priceList = $this->prepareExecution($pricelistFile); - $this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Execution, 'completed', 'Sikeresen befejezve.', 100); + $this->createMissingProducers($pricelistFile); + $this->createNewProducts($pricelistFile); + $this->updateExistingProducts($pricelistFile); + $this->attachPrices($pricelistFile, $priceList); + $this->finalizeExecution($pricelistFile, $priceList); - DB::commit(); - $pricelistFile->update([ - 'status' => PricelistFileStatusEnum::done, - ]); return true; - } catch (\Exception $e) { - DB::rollBack(); - Log::error('Pricelist execution error: ' . $e->getMessage()); + } catch (\Throwable $e) { + Log::error('Pricelist execution error: ' . $e->getMessage(), [ + 'pricelist_file_id' => $pricelistFile->id, + 'exception' => $e, + ]); + + // A lépés 'failed'-re állítása az updateStepStatus-on keresztül a fájlt + // execution_failed státuszba teszi (lásd ott a leképezést). $this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Execution, 'failed', $e->getMessage()); + return false; } } + + protected function reportExecutionProgress(PricelistFile $pricelistFile, int $percentage, string $message): void + { + $this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Execution, 'inprogress', $message, $percentage); + } + + /** + * E1 - Előkészítés: az árlista fejrekord létrehozása (vagy újraindításkor a + * korábbi továbbhasználata). + */ + protected function prepareExecution(PricelistFile $pricelistFile): PriceList + { + $this->reportExecutionProgress($pricelistFile, 2, 'Árlista rekord előkészítése...'); + + // Újraindításkor NEM hozunk létre új árlistát: a fájlra mentett price_list_id az + // egész folytatás horgonya, enélkül minden újrafuttatás duplikálna. + if ($pricelistFile->price_list_id && $priceList = PriceList::find($pricelistFile->price_list_id)) { + return $priceList; + } + + $priceList = PriceList::create([ + 'supplier_id' => $pricelistFile->supplier_id, + 'available' => $pricelistFile->available_date, + 'note' => $pricelistFile->note, + 'canSee' => 1, + 'status' => DbStatusFieldEnum::draft, + ]); + + $pricelistFile->update(['price_list_id' => $priceList->id]); + + return $priceList; + } + + /** + * E2 - Új gyártók létrehozása. A validálás az ismeretlen gyártójú sorokon + * producer_id nélkül hagyta a rekordot ("új gyártó"), itt pótoljuk őket. + * Kis halmaz, ezért egyetlen tranzakcióban fut. + */ + protected function createMissingProducers(PricelistFile $pricelistFile): void + { + $this->reportExecutionProgress($pricelistFile, 5, 'Új gyártók létrehozása...'); + + $lines = $pricelistFile->lines() + ->whereNull('producer_id') + ->where('status', '!=', PricelistFileLineStatusEnum::error->value) + ->get(); + + if ($lines->isEmpty()) { + return; + } + + DB::transaction(function () use ($lines) { + $createdByName = []; + + foreach ($lines as $line) { + $producerName = trim((string) ($line->payload[PriceListService::EXPECTED_HEADERS[8]] ?? '')); + + if ($producerName === '') { + continue; + } + + // A fájlon belül ugyanaz a gyártó többféle írásmóddal is előfordulhat, + // ezért normalizált kulccsal gyűjtjük, mint a validálás. + $key = $this->normalizeForComparison($producerName); + + $createdByName[$key] ??= \App\Models\Producer::firstOrCreate( + ['name' => $producerName], + ['canSee' => 1, 'status' => DbStatusFieldEnum::active], + )->id; + + $line->update(['producer_id' => $createdByName[$key]]); + } + }); + } + + /** + * E3 - Új termékek létrehozása. Idempotencia: a már létrehozott sorokon beáll a + * product_id, a folytatás csak a product_id nélkülieket veszi. + */ + protected function createNewProducts(PricelistFile $pricelistFile): void + { + $this->reportExecutionProgress($pricelistFile, 10, 'Új termékek létrehozása...'); + + $query = $pricelistFile->lines() + ->where('status', PricelistFileLineStatusEnum::new_product->value) + ->whereNull('product_id'); + + $total = (clone $query)->count(); + + if ($total === 0) { + return; + } + + $groupTypes = $this->getProductGroupTypeMap(); + $processed = 0; + + $query->chunkById(self::EXECUTION_CHUNK_SIZE, function ($lines) use ($pricelistFile, $groupTypes, $total, &$processed) { + DB::transaction(function () use ($lines, $pricelistFile, $groupTypes) { + foreach ($lines as $line) { + $product = \App\Models\Product::create(array_merge( + $this->buildProductData($line, $pricelistFile, $groupTypes), + ['canSee' => 1, 'status' => DbStatusFieldEnum::active], + )); + + $line->update(['product_id' => $product->id]); + } + }); + + $processed += $lines->count(); + $this->reportExecutionProgress( + $pricelistFile, + 10 + (int) floor(30 * $processed / $total), + "Új termékek létrehozása ({$processed}/{$total})...", + ); + }); + } + + /** + * E4 - Meglévő termékek frissítése. Idempotencia és visszavonhatóság: a frissítés + * ELŐTTI tényleges DB-állapot ugyanabban a tranzakcióban a sor applied_snapshot + * mezőjébe kerül, és ennek megléte jelzi, hogy a sor már feldolgozott. + */ + protected function updateExistingProducts(PricelistFile $pricelistFile): void + { + $this->reportExecutionProgress($pricelistFile, 40, 'Termékadatok frissítése...'); + + $query = $pricelistFile->lines() + ->where('status', PricelistFileLineStatusEnum::updated->value) + ->whereNotNull('product_id') + ->whereNull('applied_snapshot'); + + $total = (clone $query)->count(); + + if ($total === 0) { + return; + } + + $groupTypes = $this->getProductGroupTypeMap(); + $processed = 0; + + $query->chunkById(self::EXECUTION_CHUNK_SIZE, function ($lines) use ($pricelistFile, $groupTypes, $total, &$processed) { + DB::transaction(function () use ($lines, $pricelistFile, $groupTypes) { + foreach ($lines as $line) { + $product = \App\Models\Product::find($line->product_id); + + if (! $product) { + // A terméket a validálás óta törölték. A sort kivesszük a + // további feldolgozásból (így árat sem kap), és megjelöljük, + // hogy a felhasználó lássa, mi maradt ki. + $line->addValidationError('product', 'A termék időközben megszűnt, a sor kimaradt a végrehajtásból.'); + $line->product_id = null; + $line->save(); + + continue; + } + + $productData = $this->buildProductData($line, $pricelistFile, $groupTypes); + + // A snapshot a TÉNYLEGES, írás előtti állapot - szándékosan nem a + // validáláskor számolt diff['old'], mert az a jóváhagyás pillanatában + // készült, és egy időközbeni kézi módosítást írna felül a visszavonás. + $snapshot = [ + 'updated_at' => $product->updated_at?->toDateTimeString(), + 'fields' => [], + ]; + + foreach (array_keys($productData) as $field) { + $snapshot['fields'][$field] = $product->getAttribute($field); + } + + $product->update($productData); + $line->update(['applied_snapshot' => $snapshot]); + } + }); + + $processed += $lines->count(); + $this->reportExecutionProgress( + $pricelistFile, + 40 + (int) floor(30 * $processed / $total), + "Termékadatok frissítése ({$processed}/{$total})...", + ); + }); + } + + /** + * E5 - Árak csatolása az árlistához. A price_list_prices táblának nincs `id` + * oszlopa, ezért kötegelt query builder inserttel dolgozunk; a duplikáció ellen a + * már meglévő (price_list_id, product_id) párok kiszűrése véd. + */ + protected function attachPrices(PricelistFile $pricelistFile, PriceList $priceList): void + { + $this->reportExecutionProgress($pricelistFile, 70, 'Árak rögzítése...'); + + // A `warning` sorok is importálódnak: csak az `error` státusz zár ki (ilyen sor + // egyébként sem lehet, mert azzal a fájl nem hagyható jóvá). + $query = $pricelistFile->lines() + ->where('status', '!=', PricelistFileLineStatusEnum::error->value) + ->whereNotNull('product_id') + ->whereNull('executed_at'); + + $total = (clone $query)->count(); + + if ($total === 0) { + return; + } + + $processed = 0; + + $query->chunkById(self::PRICE_CHUNK_SIZE, function ($lines) use ($pricelistFile, $priceList, $total, &$processed) { + DB::transaction(function () use ($lines, $priceList) { + $productIds = $lines->pluck('product_id')->all(); + + // Újraindítás után előfordulhat, hogy egy sor ára már bekerült. + $alreadyPriced = array_flip( + DB::table('price_list_prices') + ->where('price_list_id', $priceList->id) + ->whereIn('product_id', $productIds) + ->pluck('product_id') + ->all(), + ); + + $now = now(); + $rows = []; + + foreach ($lines as $line) { + if (isset($alreadyPriced[$line->product_id])) { + continue; + } + + $rows[] = [ + 'price_list_id' => $priceList->id, + 'product_id' => $line->product_id, + 'price' => $this->castExecutionFloat( + $line->payload[PriceListService::EXPECTED_HEADERS[14]] ?? 0, + ), + 'created_at' => $now, + 'updated_at' => $now, + ]; + + // Ugyanaz a cikkszám kétszer is szerepelhet a fájlban - a pivotnak + // nincs egyedi indexe, ezért itt kell kiszűrni. + $alreadyPriced[$line->product_id] = true; + } + + if ($rows !== []) { + DB::table('price_list_prices')->insert($rows); + } + + PricelistFileLine::whereIn('id', $lines->pluck('id')->all()) + ->update(['executed_at' => $now]); + }); + + $processed += $lines->count(); + $this->reportExecutionProgress( + $pricelistFile, + 70 + (int) floor(20 * $processed / $total), + "Árak rögzítése ({$processed}/{$total})...", + ); + }); + } + + /** + * E6 - Lezárás: az árlista élesítése és az érintett termékek láthatóvá tétele. + * Csak itt válik az árlista `active`-vá, azaz a felhasználók számára láthatóvá. + */ + protected function finalizeExecution(PricelistFile $pricelistFile, PriceList $priceList): void + { + $this->reportExecutionProgress($pricelistFile, 90, 'Árlista lezárása...'); + + $priceList->update(['status' => DbStatusFieldEnum::active]); + + $affected = 0; + + $pricelistFile->lines() + ->whereNotNull('product_id') + ->whereNotNull('executed_at') + ->select(['id', 'product_id']) + ->chunkById(self::PRICE_CHUNK_SIZE, function ($lines) use (&$affected) { + $productIds = $lines->pluck('product_id')->unique()->all(); + \App\Models\Product::whereIn('id', $productIds)->update(['canSee' => true]); + $affected += count($productIds); + }); + + $meta = $pricelistFile->file_meta ?? []; + $meta['execution'] = [ + 'price_list_id' => $priceList->id, + 'affected_products' => $affected, + 'line_status_counts' => $pricelistFile->lineStatusCounts(), + 'finished_at' => now()->toDateTimeString(), + ]; + + $this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Execution, 'completed', 'Sikeresen befejezve.', 100); + + $pricelistFile->update([ + 'status' => PricelistFileStatusEnum::done, + 'file_meta' => $meta, + ]); + } + + /** + * Termék-mezők összeállítása egy sorból (létrehozáshoz és frissítéshez egyaránt). + */ + protected function buildProductData(PricelistFileLine $line, PricelistFile $pricelistFile, array $groupTypes): array + { + $payload = $line->payload ?? []; + $data = []; + + foreach (self::EXECUTION_FIELD_MAP as $field => $config) { + $header = PriceListService::EXPECTED_HEADERS[$config['index']]; + + // Az opcionális oszlopok (pl. "Akció") hiányozhatnak a fájlból. Ilyenkor a + // mezőt NEM írjuk felül: a termék meglévő értéke marad érvényben. + if (! array_key_exists($header, $payload)) { + continue; + } + + $data[$field] = $this->castExecutionValue($payload[$header], $config['type'], $field); + } + + $data['supplier_id'] = $pricelistFile->supplier_id; + $data['producer_id'] = $line->producer_id; + $data['product_group_id'] = $line->product_group_id; + + if (isset($groupTypes[$line->product_group_id])) { + $data['type'] = $groupTypes[$line->product_group_id]; + } + + return $data; + } + + protected function castExecutionValue(mixed $value, string $type, string $field): mixed + { + return match ($type) { + 'float' => $this->castExecutionFloat($value, $field), + 'int' => (int) $this->castExecutionFloat($value, $field), + // booleanCustom: üres érték => false, bármilyen más érték (pl. "X") => true + 'bool' => strlen(trim((string) $value)) > 0, + default => $value === null ? null : trim((string) $value), + }; + } + + protected function castExecutionFloat(mixed $value, string $field = ''): float + { + $number = is_string($value) + ? (float) str_replace([' ', ','], ['', '.'], $value) + : (float) $value; + + // ÁFA: az Excelben 0,27 formában is szerepelhet - a validálás is így számol + if ($field === 'vat') { + if ($number > 0 && $number <= 1) { + $number *= 100; + } + + $number = (float) round($number); + } + + return $number; + } + + /** + * Termékcsoport ID => típus ('F' / 'N' / 'X'). A termék `type` mezője a + * termékcsoportjától öröklődik, ahogy a legacy importban is. + */ + protected function getProductGroupTypeMap(): array + { + return \App\Models\ProductGroup::pluck('type', 'id')->toArray(); + } } diff --git a/tests/Feature/PricelistExecutionTest.php b/tests/Feature/PricelistExecutionTest.php new file mode 100644 index 0000000..a358e11 --- /dev/null +++ b/tests/Feature/PricelistExecutionTest.php @@ -0,0 +1,322 @@ + 'arlista.xlsx', + 'supplier_id' => $supplier->id, + 'available_date' => now()->addWeek()->toDateString(), + 'note' => 'Teszt árlista', + 'status' => PricelistFileStatusEnum::inprogress, + 'workflow_steps' => [ + ['name' => PricelistWorkflowStep::Preprocessing->value, 'label' => 'Előfeldolgozás', 'status' => 'completed'], + ['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'], + ], + ]); +} + +/** + * Sor payload a kanonikus fejlécnevekkel, ahogy a validálás elmenti. + */ +function linePayload(array $overrides = []): array +{ + $h = PriceListService::EXPECTED_HEADERS; + + return array_merge([ + $h[0] => 'SKU-1', + $h[1] => 'Főcsoport', + $h[2] => 'Alcsoport 1', + $h[4] => 'Termék megnevezés', + $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] => '1 250,50', + $h[20] => 'Megjegyzés', + $h[21] => '', + $h[22] => '', + ], $overrides); +} + +function executionLine(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)); +} + +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]); +}); + +test('a végrehajtás létrehozza az árlistát, az új terméket és az árakat', function () { + $file = executableFile($this->supplier); + + executionLine($file, PricelistFileLineStatusEnum::new_product, linePayload([ + PriceListService::EXPECTED_HEADERS[0] => 'SKU-NEW', + PriceListService::EXPECTED_HEADERS[4] => 'Új termék', + PriceListService::EXPECTED_HEADERS[8] => 'Ismeretlen Gyártó', + PriceListService::EXPECTED_HEADERS[14] => '990', + ]), ['product_group_id' => $this->group->id]); + + expect(app(PricelistFileProcessService::class)->execute($file))->toBeTrue(); + + $file->refresh(); + $priceList = PriceList::find($file->price_list_id); + $product = Product::where('supplierProductNumber', 'SKU-NEW')->first(); + + expect($priceList)->not->toBeNull() + ->and($priceList->status)->toBe(DbStatusFieldEnum::active) + ->and($priceList->supplier_id)->toBe($this->supplier->id) + ->and($product)->not->toBeNull() + ->and($product->name)->toBe('Új termék') + ->and($product->supplier_id)->toBe($this->supplier->id) + ->and($product->product_group_id)->toBe($this->group->id) + ->and($product->type)->toBe('F') + ->and((float) $product->vat)->toBe(27.0) // 0,27 -> 27 + ->and((float) $product->unitValue)->toBe(1.5) // vesszős tizedes + ->and((bool) $product->canSee)->toBeTrue() + ->and(DB::table('price_list_prices') + ->where('price_list_id', $priceList->id) + ->where('product_id', $product->id) + ->value('price'))->toBe(990.0) + ->and($file->status)->toBe(PricelistFileStatusEnum::done) + ->and($file->stepStatus(PricelistWorkflowStep::Execution))->toBe('completed') + ->and($file->processing_current_step_percentage)->toBe(100); +}); + +test('az ismeretlen gyártó létrejön és a termékhez kapcsolódik', function () { + $file = executableFile($this->supplier); + + executionLine($file, PricelistFileLineStatusEnum::new_product, linePayload([ + PriceListService::EXPECTED_HEADERS[0] => 'SKU-NEW', + PriceListService::EXPECTED_HEADERS[8] => 'Vadonatúj Gyártó', + ]), ['product_group_id' => $this->group->id]); + + app(PricelistFileProcessService::class)->execute($file); + + $producer = Producer::where('name', 'Vadonatúj Gyártó')->first(); + $product = Product::where('supplierProductNumber', 'SKU-NEW')->first(); + + expect($producer)->not->toBeNull() + ->and($producer->status)->toBe(DbStatusFieldEnum::active) + ->and($product->producer_id)->toBe($producer->id); +}); + +test('a meglévő termék frissül és a snapshot az írás előtti állapotot őrzi', function () { + $file = executableFile($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 = executionLine($file, PricelistFileLineStatusEnum::updated, linePayload([ + PriceListService::EXPECTED_HEADERS[4] => 'Új név', + ]), [ + 'product_id' => $product->id, + 'product_group_id' => $this->group->id, + 'producer_id' => $this->producer->id, + ]); + + app(PricelistFileProcessService::class)->execute($file); + + $product->refresh(); + $line->refresh(); + + expect($product->name)->toBe('Új név') + ->and((float) $product->vat)->toBe(27.0) + ->and($line->applied_snapshot['fields']['name'])->toBe('Régi név') + ->and((float) $line->applied_snapshot['fields']['vat'])->toBe(5.0) + ->and($line->applied_snapshot['updated_at'])->not->toBeNull() + ->and($line->executed_at)->not->toBeNull(); +}); + +test('a hiányzó "Akció" oszlop nem írja felül a termék akciós jelölését', function () { + $file = executableFile($this->supplier); + + $product = Product::create([ + 'name' => 'Akciós termék', + 'supplierProductNumber' => 'SKU-1', + 'supplier_id' => $this->supplier->id, + 'producer_id' => $this->producer->id, + 'product_group_id' => $this->group->id, + 'unitValue' => 1, + 'note' => '', + 'specialOffer' => 1, + 'status' => DbStatusFieldEnum::active, + 'canSee' => 1, + ]); + + $payload = linePayload([PriceListService::EXPECTED_HEADERS[4] => 'Új név']); + unset($payload[PriceListService::EXPECTED_HEADERS[22]]); // az "Akció" oszlop nincs a fájlban + + executionLine($file, PricelistFileLineStatusEnum::updated, $payload, [ + 'product_id' => $product->id, + 'product_group_id' => $this->group->id, + 'producer_id' => $this->producer->id, + ]); + + app(PricelistFileProcessService::class)->execute($file); + + expect((bool) $product->refresh()->specialOffer)->toBeTrue(); +}); + +test('a végrehajtás megismétlése nem duplikál terméket, árlistát és árat', function () { + $file = executableFile($this->supplier); + + executionLine($file, PricelistFileLineStatusEnum::new_product, linePayload([ + PriceListService::EXPECTED_HEADERS[0] => 'SKU-NEW', + ]), ['product_group_id' => $this->group->id]); + + $service = app(PricelistFileProcessService::class); + + $service->execute($file); + $service->execute($file->refresh()); + + expect(Product::where('supplierProductNumber', 'SKU-NEW')->count())->toBe(1) + ->and(PriceList::count())->toBe(1) + ->and(DB::table('price_list_prices')->count())->toBe(1); +}); + +test('hiba esetén a fájl execution_failed lesz és az árlista draft marad', function () { + $file = executableFile($this->supplier); + + executionLine($file, PricelistFileLineStatusEnum::new_product, linePayload([ + PriceListService::EXPECTED_HEADERS[0] => 'SKU-NEW', + ]), ['product_group_id' => $this->group->id]); + + // Az árak fázisában elhaló végrehajtás: a termék ekkor már létrejött. + $service = new class(app(PriceListService::class)) extends PricelistFileProcessService + { + protected function attachPrices(PricelistFile $pricelistFile, PriceList $priceList): void + { + throw new RuntimeException('Szimulált adatbázishiba.'); + } + }; + + expect($service->execute($file))->toBeFalse(); + + $file->refresh(); + + expect($file->status)->toBe(PricelistFileStatusEnum::execution_failed) + ->and($file->stepStatus(PricelistWorkflowStep::Execution))->toBe('failed') + // A draft árlista a felhasználói felületen és a statisztikában sem látszik, + // tehát a félbeszakadt végrehajtás nem hoz nyilvánosságra félkész árat. + ->and(PriceList::find($file->price_list_id)->status)->toBe(DbStatusFieldEnum::draft) + // A termék viszont már létrejött - ezt csak a visszavonás tudja rendbe tenni. + ->and(Product::where('supplierProductNumber', 'SKU-NEW')->exists())->toBeTrue(); +}); + +test('a megszakadt végrehajtás folytatható és nem kezd elölről', function () { + $file = executableFile($this->supplier); + + executionLine($file, PricelistFileLineStatusEnum::new_product, linePayload([ + PriceListService::EXPECTED_HEADERS[0] => 'SKU-NEW', + ]), ['product_group_id' => $this->group->id]); + + $failing = new class(app(PriceListService::class)) extends PricelistFileProcessService + { + protected function attachPrices(PricelistFile $pricelistFile, PriceList $priceList): void + { + throw new RuntimeException('Szimulált adatbázishiba.'); + } + }; + + $failing->execute($file); + + $priceListId = $file->refresh()->price_list_id; + + // Folytatás a rendes service-szel + expect(app(PricelistFileProcessService::class)->execute($file))->toBeTrue(); + + $file->refresh(); + + expect($file->price_list_id)->toBe($priceListId) // ugyanaz az árlista + ->and(PriceList::count())->toBe(1) + ->and(Product::where('supplierProductNumber', 'SKU-NEW')->count())->toBe(1) + ->and(DB::table('price_list_prices')->count())->toBe(1) + ->and($file->status)->toBe(PricelistFileStatusEnum::done); +}); + +test('az időközben törölt termék sora kimarad, a többi lefut', function () { + $file = executableFile($this->supplier); + + $deleted = Product::create([ + 'name' => 'Törölt termék', + '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 = executionLine($file, PricelistFileLineStatusEnum::updated, linePayload(), [ + 'product_id' => $deleted->id, + 'product_group_id' => $this->group->id, + 'producer_id' => $this->producer->id, + ]); + + // A validálás óta törölték a terméket. A Product SoftDeletes-et használ, tehát a + // sor product_id-ja érvényes marad (a FK nem nullázza), a Product::find() viszont + // már nem adja vissza - pontosan ezt az esetet kell a végrehajtásnak kezelnie. + $deleted->delete(); + + executionLine($file, PricelistFileLineStatusEnum::new_product, linePayload([ + PriceListService::EXPECTED_HEADERS[0] => 'SKU-NEW', + ]), ['product_group_id' => $this->group->id, 'row_number' => 6]); + + expect(app(PricelistFileProcessService::class)->execute($file))->toBeTrue(); + + $line->refresh(); + + expect($line->product_id)->toBeNull() + ->and($line->status)->toBe(PricelistFileLineStatusEnum::error) + ->and($line->validation_messages['product'])->toContain('megszűnt') + ->and(DB::table('price_list_prices')->count())->toBe(1) // csak az ép sor kapott árat + ->and($file->refresh()->status)->toBe(PricelistFileStatusEnum::done); +});