ADD EV3-357 Árlista feldolgozás phase5/3 chunkolt végrehajtás (E1-E6)

A sleep(1) szimuláció helyén valódi import: árlista fejrekord -> új gyártók ->
új termékek -> termékfrissítés -> árak -> lezárás.

- szándékosan nincs egyetlen átfogó tranzakció: fázisonként/chunkonként
  tranzakciózunk, az atomicitást az adja, hogy az árlista E6-ig draft marad,
  tehát félbeszakadáskor sem kerül ki félkész ár a felhasználók felé
- minden fázis idempotencia-jelölője az az artefaktum, amit maga hoz létre
  (price_list_id / producer_id / product_id / applied_snapshot / pivot sor),
  így a megszakadt futás folytatható, nem kezd elölről
- E4 a termék TÉNYLEGES, írás előtti állapotát menti az applied_snapshot-ba,
  ugyanabban a tranzakcióban - ebből áll majd vissza a Visszavonás
- új EXECUTION_FIELD_MAP: a PRODUCT_FIELD_MAP csak a diff-számításhoz kellett,
  az import a legacy importPriceList()-tel azonos, bővebb mezőkört ír
- hiányzó opcionális oszlop (pl. Akció) nem írja felül a termék meglévő értékét
- updateStepStatus: az Execution lépés 'failed' státusza execution_failed
  fájlstátuszra képződik le, a többi lépésé marad fail

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
E98Developer 2026-08-10 15:19:50 +02:00
parent eee27aa659
commit 93111ef2e0
2 changed files with 756 additions and 14 deletions

View File

@ -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`-, 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();
}
}

View File

@ -0,0 +1,322 @@
<?php
use App\Enums\DbStatusFieldEnum;
use App\Enums\PricelistFileLineStatusEnum;
use App\Enums\PricelistFileStatusEnum;
use App\Enums\PricelistWorkflowStep;
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);
/**
* Egy jóváhagyott állapotú fájl, amelyen a végrehajtás elindulhat.
*/
function executableFile(Supplier $supplier): PricelistFile
{
return PricelistFile::create([
'filename' => '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);
});