Egy beszállítóhoz egyszerre legfeljebb egy nyitott végrehajtás tartozhat: egy execution_failed fájl után a termékek félig frissített állapotban vannak, egy új import erre a kevert alapra rétegződne rá (és a validálás is ehhez számolná a diffeket); két párhuzamos végrehajtás pedig nem determinisztikus eredményt adna. - új PricelistGuard service: egyetlen igazságforrás a blokkoláshoz - mind a NÉGY belépési pont véd: modern Filament create, régebbi PriceListProcessor oldal, legacy Admin\PriceListController import, konzol parancs. A legacy import a legfontosabb - az közvetlenül ír a products táblába, megkerülve a modult, és a beszállítókat ma még nagyrészt ott kezelik. - az `inprogress` önmagában nem blokkol: az előfeldolgozás és a validálás egyetlen terméket sem ír, csak a már elindult végrehajtás számít - a beszállító nem tűnik el a select listából, hanem konkrét magyarázatot kap a felhasználó arról, melyik fájl blokkol és miért - blokkolt beszállítónál másik fájl jóváhagyása sem indítható Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2037 lines
82 KiB
PHP
2037 lines
82 KiB
PHP
<?php
|
|
|
|
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;
|
|
use App\Enums\PricelistFileLineStatusEnum;
|
|
use App\Enums\PricelistUnitEnum;
|
|
use App\Enums\PricelistSellerUnitEnum;
|
|
use App\Enums\PricelistVatEnum;
|
|
use Illuminate\Support\Facades\Bus;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
|
use PhpOffice\PhpSpreadsheet\IOFactory;
|
|
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
|
|
|
class PricelistFileProcessService
|
|
{
|
|
protected const PRODUCT_FIELD_MAP = [
|
|
'name' => [
|
|
'header' => PriceListService::EXPECTED_HEADERS[4], // Termék megnevezése
|
|
'type' => 'string'
|
|
],
|
|
'unitValue' => [
|
|
'header' => PriceListService::EXPECTED_HEADERS[6], // Súly/Űrtartalom (nettó)
|
|
'type' => 'float'
|
|
],
|
|
'productUnit' => [
|
|
'header' => PriceListService::EXPECTED_HEADERS[7], // Mértékegység
|
|
'type' => 'string'
|
|
],
|
|
'sellerUnit' => [
|
|
'header' => PriceListService::EXPECTED_HEADERS[9], // Legkisebb eladási egység
|
|
'type' => 'string'
|
|
],
|
|
'unitMultiplier' => [
|
|
'header' => PriceListService::EXPECTED_HEADERS[10], // Egységszorzó
|
|
'type' => 'float'
|
|
],
|
|
'amountUnit' => [
|
|
'header' => PriceListService::EXPECTED_HEADERS[11], // Mennyiségi egység
|
|
'type' => 'string'
|
|
],
|
|
'vat' => [
|
|
'header' => PriceListService::EXPECTED_HEADERS[12], // ÁFA %
|
|
'type' => 'float'
|
|
],
|
|
'hooreycaId' => [
|
|
'header' => PriceListService::EXPECTED_HEADERS[16], // Fix Hooreyca ID
|
|
'type' => 'string'
|
|
],
|
|
'HooreycaUnit' => [
|
|
'header' => PriceListService::EXPECTED_HEADERS[17], // Hooreyca mértékegység
|
|
'type' => 'string'
|
|
],
|
|
'HooreycaMultiplier' => [
|
|
'header' => PriceListService::EXPECTED_HEADERS[18], // Hooreyca mennyiségi szorzó
|
|
'type' => 'float'
|
|
],
|
|
];
|
|
|
|
/**
|
|
* 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;
|
|
|
|
/**
|
|
* A visszaállításból kihagyott sorok részleteiből ennyit tárolunk a file_meta-ban.
|
|
* A teljes darabszám a `skipped_count`-ban akkor is megmarad, ha ez levág.
|
|
*/
|
|
private const REVERT_SKIPPED_DETAIL_LIMIT = 100;
|
|
|
|
public function __construct(
|
|
protected PriceListService $priceListService
|
|
) {}
|
|
|
|
/**
|
|
* Alaphelyzetbe állítja a workflow-t és az állapotot az újraindításhoz
|
|
*/
|
|
public function resetWorkflow(PricelistFile $pricelistFile): void
|
|
{
|
|
$pricelistFile->update([
|
|
'workflow_steps' => PricelistWorkflowStep::defaultSteps(),
|
|
'status' => PricelistFileStatusEnum::todo,
|
|
'processing_current_step' => null,
|
|
'processing_current_step_percentage' => null,
|
|
'file_meta' => null,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Elindítja az árlista feldolgozás kezdeti láncát (Pre-processing -> Validation)
|
|
*/
|
|
public function dispatchInitialChain(PricelistFile $pricelistFile): void
|
|
{
|
|
$this->resetWorkflow($pricelistFile);
|
|
|
|
Bus::chain([
|
|
PricelistWorkflowStep::Preprocessing->getJob($pricelistFile),
|
|
PricelistWorkflowStep::Validation->getJob($pricelistFile),
|
|
])->dispatch();
|
|
}
|
|
|
|
/**
|
|
* Elindítja a végrehajtási jobot (Approval után)
|
|
*/
|
|
public function dispatchExecutionJob(PricelistFile $pricelistFile): void
|
|
{
|
|
$job = PricelistWorkflowStep::Execution->getJob($pricelistFile);
|
|
if ($job) {
|
|
dispatch($job);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Jóváhagyás: lezárja a manuális Jóváhagyás lépést és elindítja a végrehajtást.
|
|
*
|
|
* @return bool false, ha a fájl közben már nem jóváhagyható állapotba került
|
|
*/
|
|
public function approve(PricelistFile $pricelistFile): bool
|
|
{
|
|
// Újraellenőrzés a friss rekordon: a felület 5 mp-enként pollozik, tehát a
|
|
// gomb megjelenítése és a kattintás között változhatott az állapot.
|
|
$pricelistFile->refresh();
|
|
|
|
if (! $pricelistFile->canBeApproved()) {
|
|
return false;
|
|
}
|
|
|
|
// Egy beszállítóhoz egyszerre csak egy nyitott végrehajtás tartozhat: két
|
|
// párhuzamos import ugyanarra a termékhalmazra nem determinisztikus eredményt
|
|
// adna, egy befejezetlen (execution_failed) végrehajtásra pedig félig frissített
|
|
// állapotra rétegződne rá az új.
|
|
if (app(PricelistGuard::class)->isBlocked($pricelistFile->supplier_id, $pricelistFile->id)) {
|
|
return false;
|
|
}
|
|
|
|
$meta = $pricelistFile->file_meta ?? [];
|
|
$meta['approval'] = [
|
|
'decision' => 'approved',
|
|
'user_id' => auth()->id(),
|
|
'at' => now()->toDateTimeString(),
|
|
];
|
|
$pricelistFile->update(['file_meta' => $meta]);
|
|
|
|
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Approval, 'completed', 'Jóváhagyva.', 100);
|
|
|
|
// A státuszt MÉG A DISPATCH ELŐTT billentjük át (az Execution lépés
|
|
// 'inprogress'-re állítása a fájlt is inprogress-re teszi). Ha ezt a jobra
|
|
// bíznánk, a queue-latency alatt a fájl waiting_for_approval maradna, a
|
|
// Jóváhagyás gomb továbbra is látszana, és egy második kattintás párhuzamos
|
|
// végrehajtást indítana ugyanarra a termékhalmazra.
|
|
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Execution, 'inprogress', 'Végrehajtás előkészítése...', 0);
|
|
|
|
$this->dispatchExecutionJob($pricelistFile);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Elutasítás: a fájl lezárva (`closed`) státuszba kerül, végrehajtás nem indul.
|
|
*
|
|
* @return bool false, ha a fájl közben már nem elutasítható állapotba került
|
|
*/
|
|
public function reject(PricelistFile $pricelistFile, ?string $reason = null): bool
|
|
{
|
|
$pricelistFile->refresh();
|
|
|
|
if (! $pricelistFile->canBeApproved()) {
|
|
return false;
|
|
}
|
|
|
|
$this->updateStepStatus(
|
|
$pricelistFile,
|
|
PricelistWorkflowStep::Approval,
|
|
'rejected',
|
|
$reason ? 'Elutasítva: ' . $reason : 'Elutasítva.',
|
|
);
|
|
|
|
$meta = $pricelistFile->file_meta ?? [];
|
|
$meta['approval'] = [
|
|
'decision' => 'rejected',
|
|
'reason' => $reason,
|
|
'user_id' => auth()->id(),
|
|
'at' => now()->toDateTimeString(),
|
|
];
|
|
|
|
// A 'rejected' lépésstátusz szándékosan nem 'failed': az updateStepStatus a
|
|
// 'failed'-et fail fájlstátuszra fordítaná, az elutasítás viszont nem hiba,
|
|
// hanem szabályos lezárás.
|
|
$pricelistFile->update([
|
|
'status' => PricelistFileStatusEnum::closed,
|
|
'file_meta' => $meta,
|
|
'processing_current_step' => null,
|
|
'processing_current_step_percentage' => null,
|
|
]);
|
|
|
|
return true;
|
|
}
|
|
|
|
public function updateStepStatus(
|
|
PricelistFile $pricelistFile,
|
|
PricelistWorkflowStep $stepEnum,
|
|
string $status,
|
|
?string $message = null,
|
|
?int $percentage = 0
|
|
): void {
|
|
$effectiveMessage = $message ?? $stepEnum->label();
|
|
|
|
// Frissítjük a modellt az adatbázisból, hogy a legfrissebb workflow_steps-szel dolgozzunk
|
|
if ($pricelistFile->exists) {
|
|
$pricelistFile->refresh();
|
|
}
|
|
|
|
$steps = $pricelistFile->workflow_steps ?? [];
|
|
$found = false;
|
|
|
|
foreach ($steps as &$step) {
|
|
if ($step['name'] === $stepEnum->value) {
|
|
$step['status'] = $status;
|
|
$step['message'] = $effectiveMessage;
|
|
$found = true;
|
|
break;
|
|
}
|
|
}
|
|
unset($step);
|
|
|
|
// Ha valamiért nem találtuk meg a lépést (pl. régi rekord), adjuk hozzá a listához
|
|
if (!$found) {
|
|
$steps[] = [
|
|
'name' => $stepEnum->value,
|
|
'label' => $stepEnum->label(),
|
|
'status' => $status,
|
|
'message' => $effectiveMessage,
|
|
];
|
|
}
|
|
|
|
$updateData = [
|
|
'workflow_steps' => $steps,
|
|
'processing_current_step' => mb_strcut(
|
|
($message && $message !== $stepEnum->label())
|
|
? $stepEnum->label() . ': ' . $message
|
|
: $effectiveMessage,
|
|
0,
|
|
255
|
|
),
|
|
'processing_current_step_percentage' => $percentage,
|
|
];
|
|
|
|
if ($status === 'inprogress') {
|
|
if ($stepEnum === PricelistWorkflowStep::Approval) {
|
|
$updateData['status'] = PricelistFileStatusEnum::waiting_for_approval;
|
|
$updateData['processing_current_step'] = null;
|
|
$updateData['processing_current_step_percentage'] = null;
|
|
} else {
|
|
$updateData['status'] = PricelistFileStatusEnum::inprogress;
|
|
}
|
|
} elseif ($status === 'failed') {
|
|
// 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);
|
|
}
|
|
|
|
private const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
private const MIN_FILE_SIZE_BYTES = 1024; // 1 KB
|
|
private const ALLOWED_EXTENSIONS = ['xlsx', 'xls'];
|
|
|
|
/**
|
|
* A szabványosított "A lista" szerkezete:
|
|
* - 1-2. sor: leíró adat (érvénybelépés dátuma, beszállító) - figyelmen kívül hagyjuk,
|
|
* - 3. sor: a fejléc megnevezések (nem változtatható), ez alapján történik az oszlop-leképezés,
|
|
* - 4. sor: egyedi/legacy megnevezéseket tartalmazhat - figyelmen kívül hagyjuk,
|
|
* - 5. sortól: az adatsorok.
|
|
*
|
|
* Az oszlopok sorrendje szabadon felcserélhető: az adott mező oszlopát a 3. sor
|
|
* fejlécneve alapján azonosítjuk (lásd buildColumnMap()).
|
|
*/
|
|
private const HEADER_ROW = 3;
|
|
private const DATA_START_ROW = 5;
|
|
|
|
/**
|
|
* Opcionális (visszafelé kompatibilis) oszlopfejlécek indexei.
|
|
* Ha egy ilyen oszlop hiányzik a 3. sorból, az nem blokkoló hiba, csak figyelmeztetés,
|
|
* és a hozzá tartozó feldolgozás (pl. akciós jelölés) kimarad.
|
|
*/
|
|
private const OPTIONAL_HEADER_INDEXES = [
|
|
22, // Akció (specialOffer)
|
|
];
|
|
|
|
/**
|
|
* Előfeldolgozás (Pre-processing) - Strukturális és technikai ellenőrzés
|
|
*
|
|
* @param PricelistFile $pricelistFile
|
|
* @return bool
|
|
*/
|
|
public function preProcess(PricelistFile $pricelistFile): bool
|
|
{
|
|
$startTime = microtime(true);
|
|
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Preprocessing, 'inprogress', 'Fájl ellenőrzése...', 10);
|
|
|
|
$meta = [
|
|
'completed_at' => null,
|
|
'duration_seconds' => null,
|
|
'file_info' => [],
|
|
'statistics' => [],
|
|
'errors' => [],
|
|
'warnings' => [],
|
|
'is_processable' => true,
|
|
];
|
|
|
|
try {
|
|
// 1. Fájl fizikai ellenőrzése (10-20%)
|
|
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Preprocessing, 'inprogress', 'Fájl fizikai ellenőrzése...', 15);
|
|
$fileCheckResult = $this->checkFilePhysical($pricelistFile);
|
|
$meta['file_info'] = $fileCheckResult['info'];
|
|
$meta['errors'] = array_merge($meta['errors'], $fileCheckResult['errors']);
|
|
$meta['warnings'] = array_merge($meta['warnings'], $fileCheckResult['warnings']);
|
|
|
|
if ($this->hasBlockerError($meta['errors'])) {
|
|
$meta['is_processable'] = false;
|
|
$this->saveMetaAndFail($pricelistFile, $meta, $startTime);
|
|
return false;
|
|
}
|
|
|
|
// 2. Excel megnyitás és munkalap ellenőrzés (20-40%)
|
|
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Preprocessing, 'inprogress', 'Munkalap ellenőrzése...', 30);
|
|
$fullPath = Storage::disk('public')->path($pricelistFile->filename);
|
|
|
|
try {
|
|
$spreadsheet = IOFactory::load($fullPath);
|
|
$worksheet = $spreadsheet->getActiveSheet();
|
|
$meta['file_info']['sheet_name'] = $worksheet->getTitle();
|
|
} catch (\Exception $e) {
|
|
$meta['errors'][] = $this->buildError(PreProcessErrorCode::FILE_CORRUPTED, $e->getMessage());
|
|
$meta['is_processable'] = false;
|
|
$this->saveMetaAndFail($pricelistFile, $meta, $startTime);
|
|
return false;
|
|
}
|
|
|
|
// 3. Oszlopstruktúra validálás (40-70%)
|
|
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Preprocessing, 'inprogress', 'Oszlopstruktúra ellenőrzése...', 50);
|
|
$structureResult = $this->validateColumnStructure($worksheet);
|
|
$meta['errors'] = array_merge($meta['errors'], $structureResult['errors']);
|
|
$meta['warnings'] = array_merge($meta['warnings'], $structureResult['warnings']);
|
|
|
|
if ($this->hasBlockerError($meta['errors'])) {
|
|
$meta['is_processable'] = false;
|
|
$this->saveMetaAndFail($pricelistFile, $meta, $startTime);
|
|
return false;
|
|
}
|
|
|
|
// 4. Statisztika gyűjtés (70-90%)
|
|
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Preprocessing, 'inprogress', 'Statisztikai adatok gyűjtése...', 75);
|
|
$meta['statistics'] = $this->collectStatistics($worksheet, $structureResult['map']);
|
|
|
|
if ($meta['statistics']['data_rows'] === 0) {
|
|
$meta['errors'][] = $this->buildError(PreProcessErrorCode::NO_DATA_ROWS);
|
|
$meta['is_processable'] = false;
|
|
$this->saveMetaAndFail($pricelistFile, $meta, $startTime);
|
|
return false;
|
|
}
|
|
|
|
// 5. Sikeres befejezés (100%)
|
|
$meta['completed_at'] = now()->toDateTimeString();
|
|
$meta['duration_seconds'] = round(microtime(true) - $startTime, 2);
|
|
|
|
$pricelistFile->update(['file_meta' => ['preprocess' => $meta]]);
|
|
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Preprocessing, 'completed', 'Előfeldolgozás sikeres.', 100);
|
|
|
|
return true;
|
|
|
|
} catch (\Exception $e) {
|
|
Log::error('Pricelist pre-process error: ' . $e->getMessage());
|
|
$meta['errors'][] = [
|
|
'code' => 'UNEXPECTED_ERROR',
|
|
'message' => $e->getMessage(),
|
|
'severity' => 'blocker',
|
|
];
|
|
$meta['is_processable'] = false;
|
|
$this->saveMetaAndFail($pricelistFile, $meta, $startTime);
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fájl fizikai ellenőrzése (létezés, kiterjesztés, méret)
|
|
*/
|
|
private function checkFilePhysical(PricelistFile $pricelistFile): array
|
|
{
|
|
$errors = [];
|
|
$warnings = [];
|
|
$info = [
|
|
'original_filename' => $pricelistFile->filename,
|
|
'file_size_bytes' => null,
|
|
'file_size_human' => null,
|
|
'mime_type' => null,
|
|
];
|
|
|
|
$disk = Storage::disk('public');
|
|
|
|
if (!$disk->exists($pricelistFile->filename)) {
|
|
$errors[] = $this->buildError(PreProcessErrorCode::FILE_NOT_FOUND);
|
|
return ['info' => $info, 'errors' => $errors, 'warnings' => $warnings];
|
|
}
|
|
|
|
$fullPath = $disk->path($pricelistFile->filename);
|
|
$extension = strtolower(pathinfo($fullPath, PATHINFO_EXTENSION));
|
|
|
|
if (!in_array($extension, self::ALLOWED_EXTENSIONS)) {
|
|
$errors[] = $this->buildError(
|
|
PreProcessErrorCode::INVALID_EXTENSION,
|
|
"Kiterjesztés: '{$extension}', elfogadott: " . implode(', ', self::ALLOWED_EXTENSIONS)
|
|
);
|
|
return ['info' => $info, 'errors' => $errors, 'warnings' => $warnings];
|
|
}
|
|
|
|
$fileSize = $disk->size($pricelistFile->filename);
|
|
$info['file_size_bytes'] = $fileSize;
|
|
$info['file_size_human'] = $this->humanFileSize($fileSize);
|
|
$info['mime_type'] = $disk->mimeType($pricelistFile->filename);
|
|
|
|
if ($fileSize < self::MIN_FILE_SIZE_BYTES) {
|
|
$errors[] = $this->buildError(PreProcessErrorCode::FILE_EMPTY, "Fájlméret: {$info['file_size_human']}");
|
|
}
|
|
|
|
if ($fileSize > self::MAX_FILE_SIZE_BYTES) {
|
|
$warnings[] = $this->buildWarning(PreProcessErrorCode::FILE_TOO_LARGE, "Fájlméret: {$info['file_size_human']}");
|
|
}
|
|
|
|
return ['info' => $info, 'errors' => $errors, 'warnings' => $warnings];
|
|
}
|
|
|
|
/**
|
|
* Fejléc szöveg normalizálása az összehasonlításhoz (kis/nagybetű-érzéketlen,
|
|
* a "/" és "_" szeparátorok szóközzé alakulnak, a többszörös szóközök összevonódnak).
|
|
*/
|
|
private function normalizeHeader(string $value): string
|
|
{
|
|
$value = trim($value);
|
|
$value = str_replace(['/', '_'], ' ', $value);
|
|
$value = preg_replace('/\s+/u', ' ', $value) ?? $value;
|
|
|
|
return mb_strtolower($value, 'UTF-8');
|
|
}
|
|
|
|
/**
|
|
* A 3. sor fejlécei alapján oszlop-leképezés készítése: melyik elvárt mező (index)
|
|
* melyik tényleges munkalap-oszlopban (1-alapú) található. Az oszlopok sorrendje szabad.
|
|
*
|
|
* @return array{map: array<int, int>, errors: array<int, array>, warnings: array<int, array>}
|
|
*/
|
|
private function buildColumnMap(Worksheet $worksheet): array
|
|
{
|
|
$errors = [];
|
|
$warnings = [];
|
|
$map = [];
|
|
|
|
$highestColumn = $worksheet->getHighestDataColumn();
|
|
$columnCount = Coordinate::columnIndexFromString($highestColumn);
|
|
$expectedHeaders = PriceListService::EXPECTED_HEADERS;
|
|
|
|
// A 3. sor fejléceinek beolvasása: normalizált fejléc => oszlopszám (1-alapú)
|
|
$headerByName = [];
|
|
for ($col = 1; $col <= $columnCount; $col++) {
|
|
$raw = trim((string) $worksheet->getCellByColumnAndRow($col, self::HEADER_ROW)->getValue());
|
|
if ($raw === '') {
|
|
continue;
|
|
}
|
|
$normalized = $this->normalizeHeader($raw);
|
|
// Az első előfordulás nyer, a duplikátumokra figyelmeztetünk
|
|
if (!isset($headerByName[$normalized])) {
|
|
$headerByName[$normalized] = $col;
|
|
} else {
|
|
$warnings[] = $this->buildWarning(
|
|
PreProcessErrorCode::COLUMN_EXTRA,
|
|
"A(z) '{$raw}' fejléc többször szerepel a " . self::HEADER_ROW . ". sorban; az első előfordulás ({$headerByName[$normalized]}. oszlop) kerül felhasználásra."
|
|
);
|
|
}
|
|
}
|
|
|
|
$usedColumns = [];
|
|
foreach ($expectedHeaders as $index => $expectedLabel) {
|
|
$normalizedExpected = $this->normalizeHeader($expectedLabel);
|
|
if (isset($headerByName[$normalizedExpected])) {
|
|
$map[$index] = $headerByName[$normalizedExpected];
|
|
$usedColumns[$headerByName[$normalizedExpected]] = true;
|
|
} elseif (in_array($index, self::OPTIONAL_HEADER_INDEXES, true)) {
|
|
// Opcionális oszlop hiánya nem blokkoló, csak figyelmeztetés (visszafelé kompatibilitás)
|
|
$warnings[] = $this->buildWarning(
|
|
PreProcessErrorCode::COLUMN_MISSING,
|
|
"Hiányzó opcionális oszlopfejléc: '{$expectedLabel}' (a " . self::HEADER_ROW . ". sorban nem található); a feldolgozás e nélkül folytatódik."
|
|
);
|
|
} else {
|
|
// Hiányzó kötelező fejléc: blokkoló hiba
|
|
$errors[] = $this->buildError(
|
|
PreProcessErrorCode::COLUMN_MISSING,
|
|
"Hiányzó oszlopfejléc: '{$expectedLabel}' (a " . self::HEADER_ROW . ". sorban nem található)."
|
|
);
|
|
}
|
|
}
|
|
|
|
// A leképezésben nem használt (ismeretlen) oszlopok csak figyelmeztetést adnak
|
|
for ($col = 1; $col <= $columnCount; $col++) {
|
|
if (!isset($usedColumns[$col])) {
|
|
$raw = trim((string) $worksheet->getCellByColumnAndRow($col, self::HEADER_ROW)->getValue());
|
|
if ($raw !== '') {
|
|
$warnings[] = $this->buildWarning(
|
|
PreProcessErrorCode::COLUMN_EXTRA,
|
|
"Ismeretlen oszlop a {$col}. pozícióban ('{$raw}'), a feldolgozás során figyelmen kívül hagyjuk."
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return ['map' => $map, 'errors' => $errors, 'warnings' => $warnings];
|
|
}
|
|
|
|
/**
|
|
* Oszlopstruktúra és fejléc validálása a 3. sor fejlécnevei alapján (sorrend-független).
|
|
*
|
|
* @return array{map: array<int, int>, errors: array<int, array>, warnings: array<int, array>}
|
|
*/
|
|
private function validateColumnStructure(Worksheet $worksheet): array
|
|
{
|
|
return $this->buildColumnMap($worksheet);
|
|
}
|
|
|
|
/**
|
|
* Statisztikai adatok gyűjtése a munkalapról
|
|
*
|
|
* @param array<int, int> $columnMap elvárt mező index => oszlopszám (1-alapú)
|
|
*/
|
|
private function collectStatistics(Worksheet $worksheet, array $columnMap = []): array
|
|
{
|
|
$highestRow = $worksheet->getHighestDataRow();
|
|
$highestColumn = $worksheet->getHighestDataColumn();
|
|
$columnCount = Coordinate::columnIndexFromString($highestColumn);
|
|
|
|
// A szállítói cikkszám (0. mező) oszlopa a leképezés alapján, fallback az 1. oszlop
|
|
$skuColumn = $columnMap[0] ?? 1;
|
|
|
|
$dataRows = 0;
|
|
$emptyRows = 0;
|
|
|
|
// A 3. sor a fejléc, a 4. sort figyelmen kívül hagyjuk, az adat az 5. sortól indul
|
|
for ($row = self::DATA_START_ROW; $row <= $highestRow; $row++) {
|
|
$supplierProductNumber = $worksheet->getCellByColumnAndRow($skuColumn, $row)->getValue();
|
|
if (!empty(trim((string) $supplierProductNumber))) {
|
|
$dataRows++;
|
|
} else {
|
|
$emptyRows++;
|
|
}
|
|
}
|
|
|
|
return [
|
|
'total_rows' => max(0, $highestRow - (self::DATA_START_ROW - 1)), // leíró sorok + fejléc után
|
|
'data_rows' => $dataRows,
|
|
'empty_rows' => $emptyRows,
|
|
'column_count' => $columnCount,
|
|
'expected_column_count' => count($this->priceListService->getExcelFieldPointer()),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Hiba rekord összeállítása
|
|
*/
|
|
private function buildError(PreProcessErrorCode $code, ?string $customMessage = null): array
|
|
{
|
|
return [
|
|
'code' => $code->value,
|
|
'message' => $customMessage ?? $code->label(),
|
|
'severity' => $code->severity(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Figyelmeztetés rekord összeállítása
|
|
*/
|
|
private function buildWarning(PreProcessErrorCode $code, ?string $customMessage = null, array $extra = []): array
|
|
{
|
|
return array_merge([
|
|
'code' => $code->value,
|
|
'message' => $customMessage ?? $code->label(),
|
|
'severity' => $code->severity(),
|
|
], $extra);
|
|
}
|
|
|
|
/**
|
|
* Blocker típusú hiba keresése a hibalistában
|
|
*/
|
|
private function hasBlockerError(array $errors): bool
|
|
{
|
|
foreach ($errors as $error) {
|
|
if (($error['severity'] ?? '') === 'blocker') {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Meta adatok mentése és a workflow lépés hibásra állítása
|
|
*/
|
|
private function saveMetaAndFail(PricelistFile $pricelistFile, array $meta, float $startTime): void
|
|
{
|
|
$meta['completed_at'] = now()->toDateTimeString();
|
|
$meta['duration_seconds'] = round(microtime(true) - $startTime, 2);
|
|
|
|
$pricelistFile->update(['file_meta' => ['preprocess' => $meta]]);
|
|
|
|
$firstBlocker = collect($meta['errors'])->firstWhere('severity', 'blocker');
|
|
$failMessage = $firstBlocker['message'] ?? 'Előfeldolgozás sikertelen.';
|
|
|
|
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Preprocessing, 'failed', $failMessage);
|
|
}
|
|
|
|
/**
|
|
* Fájlméret olvasható formátumban
|
|
*/
|
|
private function humanFileSize(int $bytes): string
|
|
{
|
|
$units = ['B', 'KB', 'MB', 'GB'];
|
|
$i = 0;
|
|
$size = (float) $bytes;
|
|
|
|
while ($size >= 1024 && $i < count($units) - 1) {
|
|
$size /= 1024;
|
|
$i++;
|
|
}
|
|
|
|
return round($size, 1) . ' ' . $units[$i];
|
|
}
|
|
|
|
/**
|
|
* Validálás (Validation) - Üzleti szabályok ellenőrzése és sorok rögzítése
|
|
*
|
|
* @param PricelistFile $pricelistFile
|
|
* @return bool
|
|
*/
|
|
public function validate(PricelistFile $pricelistFile): bool
|
|
{
|
|
$startTime = microtime(true);
|
|
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Validation, 'inprogress', 'Sorok beolvasása és mentése...', 0);
|
|
|
|
try {
|
|
// Korábbi sorok törlése az újraindíthatóság érdekében
|
|
$pricelistFile->lines()->delete();
|
|
|
|
$fullPath = Storage::disk('public')->path($pricelistFile->filename);
|
|
$spreadsheet = IOFactory::load($fullPath);
|
|
$worksheet = $spreadsheet->getActiveSheet();
|
|
$highestRow = $worksheet->getHighestDataRow();
|
|
$expectedHeaders = PriceListService::EXPECTED_HEADERS;
|
|
|
|
// A 3. sor fejlécei alapján a tényleges oszlopok leképezése (sorrend-független)
|
|
$columnMap = $this->buildColumnMap($worksheet)['map'];
|
|
|
|
$batchSize = 50;
|
|
$dataToInsert = [];
|
|
$processedCount = 0;
|
|
$totalDataRows = $highestRow >= self::DATA_START_ROW ? ($highestRow - (self::DATA_START_ROW - 1)) : 0;
|
|
|
|
if ($totalDataRows === 0) {
|
|
throw new \Exception('A fájl nem tartalmaz feldolgozható adatsort a ' . self::DATA_START_ROW . '. sortól.');
|
|
}
|
|
|
|
for ($row = self::DATA_START_ROW; $row <= $highestRow; $row++) {
|
|
$rowData = [];
|
|
$hasData = false;
|
|
|
|
foreach ($expectedHeaders as $index => $label) {
|
|
// Az oszlopok sorrendje szabad: a mező tényleges oszlopát a leképezés adja
|
|
$column = $columnMap[$index] ?? null;
|
|
if ($column === null) {
|
|
$rowData[$label] = null;
|
|
continue;
|
|
}
|
|
|
|
$cell = $worksheet->getCellByColumnAndRow($column, $row);
|
|
$value = $cell->getValue();
|
|
|
|
// Ha képlet, próbáljuk kiszámolni a megjelenített értéket
|
|
if (str_starts_with((string)$value, "=")) {
|
|
try {
|
|
// Megpróbáljuk kiszámítani a képletet
|
|
$value = $cell->getCalculatedValue();
|
|
} catch (\Exception $e) {
|
|
try {
|
|
// Ha a számítás nem sikerül (pl. hiányzó külső hivatkozás),
|
|
// próbáljuk a fájlban utoljára tárolt értéket (cache) lekérni
|
|
$value = $cell->getOldCalculatedValue();
|
|
} catch (\Exception $e2) {
|
|
// Ha nincs korábbi érték sem, marad a képlet szövege fallback-ként
|
|
\Illuminate\Support\Facades\Log::warning("Képlet számítási hiba a(z) {$row}. sorban: " . $e->getMessage());
|
|
}
|
|
}
|
|
}
|
|
|
|
$rowData[$label] = $value;
|
|
if (!empty(trim((string)$value))) {
|
|
$hasData = true;
|
|
}
|
|
}
|
|
|
|
// Csak akkor mentjük, ha a sor nem teljesen üres
|
|
if ($hasData) {
|
|
$dataToInsert[] = [
|
|
'pricelist_file_id' => $pricelistFile->id,
|
|
'row_number' => $row,
|
|
'status' => PricelistFileLineStatusEnum::ok->value,
|
|
'payload' => json_encode($rowData),
|
|
'created_at' => now(),
|
|
'updated_at' => now(),
|
|
// A BaseAuditable trait miatt ezeket is érdemes lehet kitölteni,
|
|
// de a tömeges beszúrásnál (insert) nem futnak le a boot események.
|
|
'created_by' => auth()->id() ?? 1,
|
|
];
|
|
}
|
|
|
|
$processedCount++;
|
|
|
|
if (count($dataToInsert) >= $batchSize) {
|
|
PricelistFileLine::insert($dataToInsert);
|
|
$dataToInsert = [];
|
|
|
|
$percentage = round(($processedCount / $totalDataRows) * 100);
|
|
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Validation, 'inprogress', "Sorok mentése ({$processedCount}/{$totalDataRows})...", $percentage);
|
|
}
|
|
}
|
|
|
|
if (!empty($dataToInsert)) {
|
|
PricelistFileLine::insert($dataToInsert);
|
|
}
|
|
|
|
// Az "Akció" (specialOffer) opcionális oszlop: csak akkor kezeljük, ha ténylegesen szerepel a fájlban
|
|
$hasSpecialOfferColumn = isset($columnMap[22]);
|
|
$this->runBusinessValidation($pricelistFile, $hasSpecialOfferColumn);
|
|
|
|
$duration = round(microtime(true) - $startTime, 2);
|
|
|
|
// Ellenőrizzük, vannak-e hibás sorok
|
|
$hasErrors = $pricelistFile->lines()->where('status', PricelistFileLineStatusEnum::error)->exists();
|
|
|
|
if ($hasErrors) {
|
|
$this->updateStepStatus(
|
|
$pricelistFile,
|
|
PricelistWorkflowStep::Validation,
|
|
'failed',
|
|
"befejeződött, de hibás sorokat találtunk. Kérjük, javítsa a hibákat a továbblépéshez!",
|
|
100
|
|
);
|
|
return false;
|
|
}
|
|
|
|
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Validation, 'completed', "Sikeres validálás ({$processedCount} sor, {$duration} mp).", 100);
|
|
|
|
// A validálás végén átadjuk a lépést a jóváhagyásnak (Approval)
|
|
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Approval, 'inprogress');
|
|
|
|
return true;
|
|
} catch (\Exception $e) {
|
|
Log::error('Pricelist validation error: ' . $e->getMessage());
|
|
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Validation, 'failed', $e->getMessage());
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Szöveg normalizálása az összehasonlításhoz (mb_strtoupper, trim, speciális karakterek kezelése)
|
|
*/
|
|
protected function normalizeForComparison(string $text): string
|
|
{
|
|
// 1. Kis/Nagybetű érzéketlenség UTF-8 támogatással
|
|
$text = mb_strtoupper(trim($text), 'UTF-8');
|
|
|
|
// 2. Szeparátorok egységesítése (perjel és alulvonás cseréje)
|
|
// A hibaüzenetben látható alulvonás és a felhasználó által említett perjel miatt
|
|
$text = str_replace(['/', '_'], ' ', $text);
|
|
|
|
// 3. Felesleges szóközök eltávolítása (ha a csere után több szóköz maradt)
|
|
$text = preg_replace('/\s+/', ' ', $text);
|
|
|
|
return trim($text);
|
|
}
|
|
|
|
/**
|
|
* Termékcsoport lookup tábla felépítése az adatbázisból (hierarchia útvonala -> id)
|
|
*/
|
|
protected function getProductGroupLookupMap(): array
|
|
{
|
|
$allGroups = (new \App\Models\ProductGroup)->allWithPath(3);
|
|
$groupLookup = [];
|
|
|
|
foreach ($allGroups as $group) {
|
|
$parentsNames = $group['parentsNames'] ?? [];
|
|
|
|
// Technikai gyökér elhagyása (0. szint, pl. "Főcsoportok")
|
|
if (isset($group['depth']) && $group['depth'] > 0 && !empty($parentsNames)) {
|
|
array_shift($parentsNames);
|
|
}
|
|
|
|
$pathParts = array_merge($parentsNames, [$group['name']]);
|
|
$normalizedParts = array_map(fn($v) => $this->normalizeForComparison((string)$v), $pathParts);
|
|
$pathKey = implode('|', $normalizedParts);
|
|
$groupLookup[$pathKey] = $group['id'];
|
|
}
|
|
|
|
return $groupLookup;
|
|
}
|
|
|
|
/**
|
|
* Gyártó lookup tábla felépítése (név -> id)
|
|
*/
|
|
protected function getProducerLookupMap(): array
|
|
{
|
|
return \App\Models\Producer::all()
|
|
->keyBy(fn($p) => $this->normalizeForComparison($p->name))
|
|
->map->id
|
|
->toArray();
|
|
}
|
|
|
|
/**
|
|
* Gyártó ID -> név mapping
|
|
*/
|
|
protected function getProducerIdMap(): array
|
|
{
|
|
return \App\Models\Producer::pluck('name', 'id')->toArray();
|
|
}
|
|
|
|
/**
|
|
* Termékcsoport ID -> útvonal mapping
|
|
*/
|
|
protected function getProductGroupIdMap(): array
|
|
{
|
|
$allGroups = (new \App\Models\ProductGroup)->allWithPath();
|
|
$idMap = [];
|
|
|
|
foreach ($allGroups as $group) {
|
|
$parentsNames = $group['parentsNames'] ?? [];
|
|
|
|
if (isset($group['depth']) && $group['depth'] > 0 && !empty($parentsNames)) {
|
|
array_shift($parentsNames);
|
|
}
|
|
|
|
$pathParts = array_merge($parentsNames, [$group['name']]);
|
|
$idMap[$group['id']] = implode(' > ', $pathParts);
|
|
}
|
|
|
|
return $idMap;
|
|
}
|
|
|
|
/**
|
|
* Termék lookup tábla felépítése a beszállítóhoz (cikkszám -> id)
|
|
*/
|
|
protected function getProductLookupMap(int $supplierId): array
|
|
{
|
|
return \App\Models\Product::where('supplier_id', $supplierId)
|
|
->whereNotNull('supplierProductNumber')
|
|
->get()
|
|
->keyBy('supplierProductNumber')
|
|
->toArray();
|
|
}
|
|
|
|
/**
|
|
* Termékcsoport útvonal összeállítása a payload-ból
|
|
*/
|
|
protected function getRowPathFromPayload(array $payload): string
|
|
{
|
|
$pathComponents = [
|
|
$payload[PriceListService::EXPECTED_HEADERS[1]] ?? null,
|
|
$payload[PriceListService::EXPECTED_HEADERS[2]] ?? null,
|
|
$payload[PriceListService::EXPECTED_HEADERS[3]] ?? null
|
|
];
|
|
|
|
return collect($pathComponents)
|
|
->filter(fn($v) => !empty(trim((string)$v)))
|
|
->map(fn($v) => $this->normalizeForComparison((string)$v))
|
|
->implode('|');
|
|
}
|
|
|
|
/**
|
|
* Termékcsoport validáció egy sorra
|
|
*/
|
|
protected function validateProductGroup(string $rowPath, array $groupLookup): ?string
|
|
{
|
|
if (empty($rowPath)) {
|
|
return "Hiányzó termékcsoport adatok.";
|
|
}
|
|
|
|
if (!isset($groupLookup[$rowPath])) {
|
|
return "Ismeretlen termékcsoport útvonal: $rowPath";
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Mértékegység validáció egy sorra
|
|
*/
|
|
protected function validateUnit(array $payload): ?string
|
|
{
|
|
$unitValue = $payload[PriceListService::EXPECTED_HEADERS[7]] ?? null;
|
|
|
|
if (empty(trim((string)$unitValue))) {
|
|
return "Hiányzó mértékegység.";
|
|
}
|
|
|
|
$normalizedUnit = mb_strtolower(trim((string)$unitValue));
|
|
if (!PricelistUnitEnum::tryFrom($normalizedUnit)) {
|
|
return "Érvénytelen mértékegység: $unitValue. Elfogadott: " . implode(', ', PricelistUnitEnum::values());
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Legkisebb eladási egység validáció egy sorra
|
|
*/
|
|
protected function validateSellerUnit(array $payload): ?string
|
|
{
|
|
$sellerUnitValue = $payload[PriceListService::EXPECTED_HEADERS[9]] ?? null;
|
|
|
|
if (empty(trim((string)$sellerUnitValue))) {
|
|
return "Hiányzó legkisebb eladási egység.";
|
|
}
|
|
|
|
$normalizedUnit = mb_strtolower(trim((string)$sellerUnitValue));
|
|
if (!PricelistSellerUnitEnum::tryFrom($normalizedUnit)) {
|
|
return "Érvénytelen legkisebb eladási egység: $sellerUnitValue. Elfogadott: " . implode(', ', PricelistSellerUnitEnum::values());
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Mennyiségi egység validáció egy sorra
|
|
*/
|
|
protected function validateAmountUnit(array $payload): ?string
|
|
{
|
|
$amountUnitValue = $payload[PriceListService::EXPECTED_HEADERS[11]] ?? null;
|
|
|
|
if (empty(trim((string)$amountUnitValue))) {
|
|
return "Hiányzó mennyiségi egység.";
|
|
}
|
|
|
|
$normalizedUnit = mb_strtolower(trim((string)$amountUnitValue));
|
|
if (!PricelistSellerUnitEnum::tryFrom($normalizedUnit)) {
|
|
return "Érvénytelen mennyiségi egység: $amountUnitValue. Elfogadott: " . implode(', ', PricelistSellerUnitEnum::values());
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* ÁFA % validáció egy sorra
|
|
*/
|
|
protected function validateVat(array $payload): ?string
|
|
{
|
|
$vatValue = $payload[PriceListService::EXPECTED_HEADERS[12]] ?? null;
|
|
|
|
if ($vatValue === null || (is_string($vatValue) && trim($vatValue) === '')) {
|
|
return "Hiányzó ÁFA %.";
|
|
}
|
|
|
|
// Kinyerjük a numerikus értéket, kezelve a százalékjelet, szóközöket és a tizedesvesszőt
|
|
$numericValue = (float) str_replace(['%', ' ', ','], ['', '', '.'], (string)$vatValue);
|
|
|
|
// Ha az érték 0 és 1 közé esik (pl. 0.05, 0.27), akkor valószínűleg Excel százalék formátum
|
|
// Ebben az esetben megszorozzuk 100-zal, hogy megkapjuk az egész értéket (pl. 5, 27)
|
|
if ($numericValue > 0 && $numericValue <= 1) {
|
|
$numericValue *= 100;
|
|
}
|
|
|
|
$finalVat = (int) round($numericValue);
|
|
|
|
if (!PricelistVatEnum::tryFrom($finalVat)) {
|
|
return "Érvénytelen ÁFA %: $vatValue. Elfogadott: " . implode(', ', PricelistVatEnum::labels());
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Gyártó validáció egy sorra
|
|
*/
|
|
protected function validateProducer(array $payload, array $producerLookup): ?string
|
|
{
|
|
$producerName = $payload[PriceListService::EXPECTED_HEADERS[8]] ?? null;
|
|
|
|
if (empty(trim((string)$producerName))) {
|
|
return "Hiányzó gyártó megnevezés.";
|
|
}
|
|
|
|
$normalizedName = $this->normalizeForComparison((string)$producerName);
|
|
|
|
if (!isset($producerLookup[$normalizedName])) {
|
|
return "NEW_PRODUCER";
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Üzleti validáció futtatása a mentett sorokon (Batch feldolgozás)
|
|
*
|
|
* @param PricelistFile $pricelistFile
|
|
*/
|
|
protected function runBusinessValidation(PricelistFile $pricelistFile, bool $hasSpecialOfferColumn = false): void
|
|
{
|
|
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Validation, 'inprogress', 'Üzleti validálás...', 100);
|
|
|
|
$groupLookup = $this->getProductGroupLookupMap();
|
|
$producerLookup = $this->getProducerLookupMap();
|
|
$productLookup = $this->getProductLookupMap($pricelistFile->supplier_id);
|
|
|
|
$producerIdToName = $this->getProducerIdMap();
|
|
$groupIdToPath = $this->getProductGroupIdMap();
|
|
|
|
$pricelistFile->lines()->chunk(1000, function ($lines) use ($groupLookup, $producerLookup, $productLookup, $producerIdToName, $groupIdToPath, $hasSpecialOfferColumn) {
|
|
$updates = [];
|
|
|
|
foreach ($lines as $line) {
|
|
$payload = $line->payload;
|
|
$validationMessages = [];
|
|
|
|
// 1. Kötelező mezők ellenőrzése (0-2, 4-12 és 14)
|
|
// Megjegyzés: az "Alcsoport 2" (3. index) NEM kötelező, mivel vannak
|
|
// olyan termékek, amelyek csak az "Alcsoport 1" szinthez vannak rendelve.
|
|
$mandatoryIndexes = [0, 1, 2, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14];
|
|
foreach ($mandatoryIndexes as $mIndex) {
|
|
$mLabel = PriceListService::EXPECTED_HEADERS[$mIndex];
|
|
$mValue = $payload[$mLabel] ?? null;
|
|
if ($mValue === null || (is_string($mValue) && trim($mValue) === '')) {
|
|
// Megfelelő kulcsot választunk, hogy a specifikus validátorok felülírhassák
|
|
$mKey = match($mIndex) {
|
|
7 => 'product_unit',
|
|
8 => 'producer',
|
|
9 => 'seller_unit',
|
|
11 => 'amount_unit',
|
|
12 => 'vat',
|
|
default => 'mandatory_' . $mIndex
|
|
};
|
|
$validationMessages[$mKey] = "Hiányzó kötelező mező: $mLabel";
|
|
}
|
|
}
|
|
|
|
// 2. Hooreyca specifikus mezők függősége (16, 17, 18, 19)
|
|
$hooreycaIndexes = [16, 17, 18, 19];
|
|
$hasAnyHooreyca = false;
|
|
$hooreycaData = [];
|
|
foreach ($hooreycaIndexes as $hIndex) {
|
|
$hLabel = PriceListService::EXPECTED_HEADERS[$hIndex];
|
|
$hValue = $payload[$hLabel] ?? null;
|
|
if ($hValue !== null && !(is_string($hValue) && trim($hValue) === '')) {
|
|
$hasAnyHooreyca = true;
|
|
$hooreycaData[$hIndex] = $hValue;
|
|
} else {
|
|
$hooreycaData[$hIndex] = null;
|
|
}
|
|
}
|
|
|
|
if ($hasAnyHooreyca) {
|
|
foreach ($hooreycaIndexes as $hIndex) {
|
|
if ($hooreycaData[$hIndex] === null) {
|
|
$hLabel = PriceListService::EXPECTED_HEADERS[$hIndex];
|
|
$validationMessages['hooreyca_' . $hIndex] = "Hiányzó kötelező mező (Hooreyca adatok jelenléte miatt): $hLabel";
|
|
}
|
|
}
|
|
}
|
|
|
|
// Termék ID kinyerése (cikkszám alapján)
|
|
$sku = (string)($payload[PriceListService::EXPECTED_HEADERS[0]] ?? '');
|
|
$existingProduct = $productLookup[$sku] ?? null;
|
|
$productId = $existingProduct['id'] ?? null;
|
|
|
|
// Termékcsoport útvonal és ID kinyerése
|
|
$rowPath = $this->getRowPathFromPayload($payload);
|
|
$groupId = $groupLookup[$rowPath] ?? null;
|
|
|
|
// Gyártó ID kinyerése
|
|
$producerName = $payload[PriceListService::EXPECTED_HEADERS[8]] ?? null;
|
|
$producerId = $producerLookup[$this->normalizeForComparison((string)$producerName)] ?? null;
|
|
|
|
// Különálló validációk meghívása
|
|
if ($groupError = $this->validateProductGroup($rowPath, $groupLookup)) {
|
|
$validationMessages['product_group'] = $groupError;
|
|
}
|
|
|
|
$hasNewProducer = false;
|
|
if ($producerError = $this->validateProducer($payload, $producerLookup)) {
|
|
if ($producerError === 'NEW_PRODUCER') {
|
|
$validationMessages['producer'] = "Új gyártó: " . ($payload[PriceListService::EXPECTED_HEADERS[8]] ?? '');
|
|
$hasNewProducer = true;
|
|
} else {
|
|
$validationMessages['producer'] = $producerError;
|
|
}
|
|
}
|
|
|
|
if ($unitError = $this->validateUnit($payload)) {
|
|
$validationMessages['product_unit'] = $unitError;
|
|
}
|
|
|
|
if ($sellerUnitError = $this->validateSellerUnit($payload)) {
|
|
$validationMessages['seller_unit'] = $sellerUnitError;
|
|
}
|
|
|
|
if ($amountUnitError = $this->validateAmountUnit($payload)) {
|
|
$validationMessages['amount_unit'] = $amountUnitError;
|
|
}
|
|
|
|
if ($vatError = $this->validateVat($payload)) {
|
|
$validationMessages['vat'] = $vatError;
|
|
}
|
|
|
|
// Státusz meghatározása
|
|
$hasError = false;
|
|
|
|
foreach ($validationMessages as $key => $msg) {
|
|
if (str_contains($msg, 'Hiányzó') || str_contains($msg, 'Ismeretlen') || str_contains($msg, 'Érvénytelen')) {
|
|
$hasError = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
$status = PricelistFileLineStatusEnum::ok;
|
|
$diff = [];
|
|
|
|
if ($hasError) {
|
|
$status = PricelistFileLineStatusEnum::error;
|
|
} elseif ($productId === null) {
|
|
// Ha nincs meglévő termék -> Új termék
|
|
$status = PricelistFileLineStatusEnum::new_product;
|
|
} else {
|
|
// Meglévő termék esetén nézzük a módosulásokat
|
|
$isUpdated = false;
|
|
|
|
// 1. Gyártó módosulás
|
|
$oldProducerId = (int)($existingProduct['producer_id'] ?? 0);
|
|
if ($hasNewProducer) {
|
|
$isUpdated = true;
|
|
$diff['producer_id'] = [
|
|
'old' => $oldProducerId,
|
|
'new' => $producerId,
|
|
'old_label' => $producerIdToName[$oldProducerId] ?? ($oldProducerId > 0 ? "Ismeretlen gyártó ($oldProducerId)" : 'N/A'),
|
|
'new_label' => $producerName,
|
|
'label' => PriceListService::EXPECTED_HEADERS[8]
|
|
];
|
|
} elseif ($producerId !== null && $oldProducerId !== (int)$producerId) {
|
|
$isUpdated = true;
|
|
$diff['producer_id'] = [
|
|
'old' => $oldProducerId,
|
|
'new' => (int)$producerId,
|
|
'old_label' => $producerIdToName[$oldProducerId] ?? ($oldProducerId > 0 ? "Ismeretlen gyártó ($oldProducerId)" : 'N/A'),
|
|
'new_label' => $producerIdToName[(int)$producerId] ?? ($producerId > 0 ? "Ismeretlen gyártó ($producerId)" : 'N/A'),
|
|
'label' => PriceListService::EXPECTED_HEADERS[8]
|
|
];
|
|
}
|
|
|
|
// 2. Termékcsoport módosulás
|
|
$oldGroupId = (int)($existingProduct['product_group_id'] ?? 0);
|
|
if ($groupId !== null && $oldGroupId !== (int)$groupId) {
|
|
$isUpdated = true;
|
|
$oldPath = $groupIdToPath[$oldGroupId] ?? null;
|
|
$newPath = $groupIdToPath[(int)$groupId] ?? null;
|
|
|
|
$diff['product_group_id'] = [
|
|
'old' => $oldGroupId,
|
|
'new' => (int)$groupId,
|
|
'old_label' => $oldPath ?? ($oldGroupId > 0 ? "Ismeretlen csoport ($oldGroupId)" : 'N/A'),
|
|
'new_label' => $newPath ?? ($groupId > 0 ? "Ismeretlen csoport ($groupId)" : 'N/A'),
|
|
'label' => PriceListService::EXPECTED_HEADERS[1]
|
|
];
|
|
}
|
|
|
|
// 3. Általános mezők összehasonlítása
|
|
foreach (self::PRODUCT_FIELD_MAP as $modelField => $config) {
|
|
$excelValue = $payload[$config['header']] ?? null;
|
|
$currentValue = $existingProduct[$modelField] ?? null;
|
|
|
|
// Típuskonverzió és normalizálás az összehasonlításhoz
|
|
if ($config['type'] === 'float') {
|
|
$excelValue = is_string($excelValue)
|
|
? (float)str_replace([' ', ','], ['', '.'], $excelValue)
|
|
: (float)$excelValue;
|
|
|
|
// ÁFA speciális kezelése: 0.05 -> 5
|
|
if ($modelField === 'vat') {
|
|
if ($excelValue > 0 && $excelValue <= 1) {
|
|
$excelValue *= 100;
|
|
}
|
|
$excelValue = (float) round($excelValue);
|
|
}
|
|
|
|
$currentValue = (float)$currentValue;
|
|
}
|
|
|
|
if ($excelValue != $currentValue) {
|
|
$diff[$modelField] = [
|
|
'old' => $currentValue,
|
|
'new' => $excelValue,
|
|
'label' => $config['header']
|
|
];
|
|
$isUpdated = true;
|
|
}
|
|
}
|
|
|
|
if ($isUpdated) {
|
|
$status = PricelistFileLineStatusEnum::updated;
|
|
}
|
|
|
|
// 4. Ár módosulás (mindig bekerül a diff-be ha van változás, de nem feltétlenül váltja ki az 'updated' státuszt, ha az árat külön kezeljük)
|
|
// Megjegyzés: Az áráltozás önmagában is lehet 'updated', de sokszor az árlista import lényege az árváltozás.
|
|
$oldPriceExcel = $payload[PriceListService::EXPECTED_HEADERS[13]] ?? null;
|
|
$newPriceExcel = $payload[PriceListService::EXPECTED_HEADERS[14]] ?? null;
|
|
|
|
if ($oldPriceExcel !== null && $newPriceExcel !== null) {
|
|
// Szóközök és vesszők egységes kezelése string típus esetén
|
|
$oldPrice = (float)str_replace([' ', ','], ['', '.'], (string)$oldPriceExcel);
|
|
$newPrice = (float)str_replace([' ', ','], ['', '.'], (string)$newPriceExcel);
|
|
|
|
if ($oldPrice != $newPrice) {
|
|
$diff['price'] = [
|
|
'old' => $oldPrice,
|
|
'new' => $newPrice,
|
|
'label' => PriceListService::EXPECTED_HEADERS[14]
|
|
];
|
|
}
|
|
}
|
|
|
|
// 5. Akciós (specialOffer) módosulás - csak ha az "Akció" oszlop szerepel a fájlban.
|
|
// booleanCustom logika: üres érték => false, bármilyen más érték => true.
|
|
if ($hasSpecialOfferColumn) {
|
|
$specialOfferRaw = $payload[PriceListService::EXPECTED_HEADERS[22]] ?? null;
|
|
$newSpecialOffer = strlen(trim((string)$specialOfferRaw)) > 0;
|
|
$oldSpecialOffer = (bool)($existingProduct['specialOffer'] ?? false);
|
|
|
|
if ($newSpecialOffer !== $oldSpecialOffer) {
|
|
$isUpdated = true;
|
|
$diff['specialOffer'] = [
|
|
'old' => (int)$oldSpecialOffer,
|
|
'new' => (int)$newSpecialOffer,
|
|
'old_label' => $oldSpecialOffer ? 'Akciós' : 'Nem akciós',
|
|
'new_label' => $newSpecialOffer ? 'Akciós' : 'Nem akciós',
|
|
'label' => PriceListService::EXPECTED_HEADERS[22],
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
$updates[] = [
|
|
'id' => $line->id,
|
|
'pricelist_file_id' => $line->pricelist_file_id,
|
|
'row_number' => $line->row_number,
|
|
'status' => $status->value,
|
|
'product_id' => $productId,
|
|
'product_group_id' => $groupId,
|
|
'producer_id' => $producerId,
|
|
'payload' => json_encode($line->payload),
|
|
'validation_messages' => json_encode($validationMessages),
|
|
'diff' => json_encode($diff),
|
|
'created_at' => $line->created_at->toDateTimeString(),
|
|
'updated_at' => now()->toDateTimeString(),
|
|
];
|
|
}
|
|
|
|
// Kötegelt frissítés (upsert) a teljesítmény optimalizálásához
|
|
if (!empty($updates)) {
|
|
PricelistFileLine::upsert(
|
|
$updates,
|
|
['id'], // Egyedi azonosító
|
|
['status', 'validation_messages', 'diff', 'product_id', 'product_group_id', 'producer_id', 'updated_at'] // Frissítendő mezők
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Végrehajtás (Execution) - Módosítások tényleges alkalmazása tranzakcióban
|
|
*
|
|
* @param PricelistFile $pricelistFile
|
|
* @return bool
|
|
*/
|
|
public function execute(PricelistFile $pricelistFile): bool
|
|
{
|
|
$this->reportExecutionProgress($pricelistFile, 0, 'Végrehajtás indítása...');
|
|
|
|
try {
|
|
// 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->createMissingProducers($pricelistFile);
|
|
$this->createNewProducts($pricelistFile);
|
|
$this->updateExistingProducts($pricelistFile);
|
|
$this->attachPrices($pricelistFile, $priceList);
|
|
$this->finalizeExecution($pricelistFile, $priceList);
|
|
|
|
return true;
|
|
} 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);
|
|
|
|
// A MI írásunk utáni updated_at: a visszavonás ehhez hasonlítja a
|
|
// termék akkori állapotát, és eltérés esetén kihagyja a sort, mert az
|
|
// azt jelenti, hogy azóta valaki kézzel módosította. (A snapshot
|
|
// 'updated_at' mezője az írás ELŐTTI érték, arra ez a check nem jó.)
|
|
$snapshot['applied_updated_at'] = $product->refresh()->updated_at?->toDateTimeString();
|
|
|
|
$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();
|
|
}
|
|
|
|
/**
|
|
* Elindítja a visszaállítási jobot.
|
|
*/
|
|
public function dispatchRevertJob(PricelistFile $pricelistFile): void
|
|
{
|
|
dispatch(new \App\Jobs\PricelistRevertJob($pricelistFile));
|
|
}
|
|
|
|
/**
|
|
* Kompenzáló visszaállítás: a végrehajtás által írt adatok visszavonása.
|
|
*
|
|
* Nem "rollback" a szó adatbázis-értelmében - a tranzakciók már lezárultak -, hanem
|
|
* a snapshotokból és a saját artefaktumokból vezetett fordított műveletsor. Ugyanúgy
|
|
* chunkolt és idempotens, mint a végrehajtás, tehát megszakadás után folytatható.
|
|
*/
|
|
public function revert(PricelistFile $pricelistFile): bool
|
|
{
|
|
$this->startRevertAttempt($pricelistFile);
|
|
|
|
try {
|
|
$priceList = $pricelistFile->price_list_id
|
|
? PriceList::find($pricelistFile->price_list_id)
|
|
: null;
|
|
|
|
$this->revertPrices($pricelistFile, $priceList);
|
|
$skipped = $this->revertUpdatedProducts($pricelistFile);
|
|
$this->revertCreatedProducts($pricelistFile);
|
|
$this->finalizeRevert($pricelistFile, $priceList, $skipped);
|
|
|
|
return true;
|
|
} catch (\Throwable $e) {
|
|
Log::error('Pricelist revert error: ' . $e->getMessage(), [
|
|
'pricelist_file_id' => $pricelistFile->id,
|
|
'exception' => $e,
|
|
]);
|
|
|
|
$this->failRevert($pricelistFile, $e->getMessage());
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
protected function startRevertAttempt(PricelistFile $pricelistFile): void
|
|
{
|
|
$pricelistFile->refresh();
|
|
|
|
$meta = $pricelistFile->file_meta ?? [];
|
|
$revert = $meta['revert'] ?? [];
|
|
|
|
$meta['revert'] = array_merge($revert, [
|
|
'attempts' => (int) ($revert['attempts'] ?? 0) + 1,
|
|
'last_status' => 'inprogress',
|
|
'last_error' => null,
|
|
'last_attempt_at' => now()->toDateTimeString(),
|
|
'user_id' => auth()->id(),
|
|
]);
|
|
|
|
// A fájl a visszaállítás idejére inprogress: ez rejti el a döntési gombokat,
|
|
// tehát egy második kattintás nem indíthat párhuzamos visszaállítást.
|
|
$pricelistFile->update([
|
|
'status' => PricelistFileStatusEnum::inprogress,
|
|
'file_meta' => $meta,
|
|
'processing_current_step' => 'Visszaállítás indítása...',
|
|
'processing_current_step_percentage' => 0,
|
|
]);
|
|
}
|
|
|
|
protected function reportRevertProgress(PricelistFile $pricelistFile, int $percentage, string $message): void
|
|
{
|
|
$pricelistFile->update([
|
|
'processing_current_step' => mb_strcut($message, 0, 255),
|
|
'processing_current_step_percentage' => $percentage,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* R1 - Árak eltávolítása. Az egész árlista a miénk, ezért egyetlen törléssel megy.
|
|
*/
|
|
protected function revertPrices(PricelistFile $pricelistFile, ?PriceList $priceList): void
|
|
{
|
|
$this->reportRevertProgress($pricelistFile, 10, 'Árak eltávolítása...');
|
|
|
|
if (! $priceList) {
|
|
return;
|
|
}
|
|
|
|
DB::table('price_list_prices')->where('price_list_id', $priceList->id)->delete();
|
|
}
|
|
|
|
/**
|
|
* R2 - Frissített termékek visszaállítása a snapshotból.
|
|
*
|
|
* @return array a kihagyott (időközben kézzel módosított) sorok leírása
|
|
*/
|
|
protected function revertUpdatedProducts(PricelistFile $pricelistFile): array
|
|
{
|
|
$this->reportRevertProgress($pricelistFile, 30, 'Termékadatok visszaállítása...');
|
|
|
|
$query = $pricelistFile->lines()->whereNotNull('applied_snapshot');
|
|
|
|
$total = (clone $query)->count();
|
|
|
|
if ($total === 0) {
|
|
return [];
|
|
}
|
|
|
|
$skipped = [];
|
|
$processed = 0;
|
|
|
|
$query->chunkById(self::EXECUTION_CHUNK_SIZE, function ($lines) use ($pricelistFile, $total, &$processed, &$skipped) {
|
|
DB::transaction(function () use ($lines, &$skipped) {
|
|
foreach ($lines as $line) {
|
|
$snapshot = $line->applied_snapshot ?? [];
|
|
$product = \App\Models\Product::find($line->product_id);
|
|
|
|
if (! $product) {
|
|
// Nincs mit visszaállítani; a jelölőt elfogyasztjuk.
|
|
$line->update(['applied_snapshot' => null]);
|
|
|
|
continue;
|
|
}
|
|
|
|
$appliedAt = $snapshot['applied_updated_at'] ?? null;
|
|
$currentAt = $product->updated_at?->toDateTimeString();
|
|
|
|
if ($appliedAt !== null && $appliedAt !== $currentAt) {
|
|
// A terméket a végrehajtásunk óta valaki módosította. Vakon
|
|
// visszaírni azt jelentené, hogy elvesszük a kézi módosítást,
|
|
// ezért kihagyjuk és jelentjük.
|
|
$skipped[] = [
|
|
'row_number' => $line->row_number,
|
|
'product_id' => $product->id,
|
|
'fields' => $snapshot['fields'] ?? [],
|
|
];
|
|
|
|
$line->addValidationError('revert', 'A termék a végrehajtás óta módosult, ezért a visszaállításból kimaradt.');
|
|
$line->applied_snapshot = null;
|
|
$line->save();
|
|
|
|
continue;
|
|
}
|
|
|
|
$product->update($snapshot['fields'] ?? []);
|
|
$line->update(['applied_snapshot' => null]);
|
|
}
|
|
});
|
|
|
|
$processed += $lines->count();
|
|
$this->reportRevertProgress(
|
|
$pricelistFile,
|
|
30 + (int) floor(30 * $processed / $total),
|
|
"Termékadatok visszaállítása ({$processed}/{$total})...",
|
|
);
|
|
});
|
|
|
|
return $skipped;
|
|
}
|
|
|
|
/**
|
|
* R3 - A végrehajtás által létrehozott termékek kivonása a forgalomból.
|
|
*
|
|
* Szándékosan NEM fizikai törlés: a termékre időközben hivatkozhat megrendelés vagy
|
|
* más rekord. A soft delete egyben a jelölő is: egy folytatott visszaállítás a már
|
|
* kezelt termékeket nem találja meg újra.
|
|
*/
|
|
protected function revertCreatedProducts(PricelistFile $pricelistFile): void
|
|
{
|
|
$this->reportRevertProgress($pricelistFile, 60, 'Létrehozott termékek visszavonása...');
|
|
|
|
$query = $pricelistFile->lines()
|
|
->where('status', PricelistFileLineStatusEnum::new_product->value)
|
|
->whereNotNull('product_id');
|
|
|
|
$total = (clone $query)->count();
|
|
|
|
if ($total === 0) {
|
|
return;
|
|
}
|
|
|
|
$processed = 0;
|
|
|
|
$query->chunkById(self::EXECUTION_CHUNK_SIZE, function ($lines) use ($pricelistFile, $total, &$processed) {
|
|
DB::transaction(function () use ($lines) {
|
|
foreach ($lines as $line) {
|
|
$product = \App\Models\Product::find($line->product_id);
|
|
|
|
if (! $product) {
|
|
continue; // már visszavonva (soft delete) - a folytatás átugorja
|
|
}
|
|
|
|
$product->update([
|
|
'status' => DbStatusFieldEnum::deleted,
|
|
'canSee' => false,
|
|
]);
|
|
|
|
$product->delete();
|
|
}
|
|
});
|
|
|
|
$processed += $lines->count();
|
|
$this->reportRevertProgress(
|
|
$pricelistFile,
|
|
60 + (int) floor(30 * $processed / $total),
|
|
"Létrehozott termékek visszavonása ({$processed}/{$total})...",
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* R4 - Lezárás: az árlista törlése és a fájl végleges lezárása.
|
|
*/
|
|
protected function finalizeRevert(PricelistFile $pricelistFile, ?PriceList $priceList, array $skipped): void
|
|
{
|
|
$this->reportRevertProgress($pricelistFile, 95, 'Visszaállítás lezárása...');
|
|
|
|
$priceList?->update(['status' => DbStatusFieldEnum::deleted]);
|
|
|
|
$pricelistFile->refresh();
|
|
|
|
$meta = $pricelistFile->file_meta ?? [];
|
|
$meta['revert'] = array_merge($meta['revert'] ?? [], [
|
|
'last_status' => 'completed',
|
|
'last_error' => null,
|
|
'finished_at' => now()->toDateTimeString(),
|
|
'skipped_count' => count($skipped),
|
|
// A részleteket korlátozottan tároljuk, hogy a file_meta ne hízzon el; a
|
|
// teljes darabszám a skipped_count-ban akkor is megmarad.
|
|
'skipped' => array_slice($skipped, 0, self::REVERT_SKIPPED_DETAIL_LIMIT),
|
|
'skipped_detail_truncated' => count($skipped) > self::REVERT_SKIPPED_DETAIL_LIMIT,
|
|
]);
|
|
|
|
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Execution, 'reverted', 'A végrehajtás visszavonva.');
|
|
|
|
$pricelistFile->update([
|
|
'status' => PricelistFileStatusEnum::closed,
|
|
'file_meta' => $meta,
|
|
'processing_current_step' => null,
|
|
'processing_current_step_percentage' => null,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* A visszaállítás hibára futott. A fájl visszakerül execution_failed-be, és mostantól
|
|
* a kényszerlezárás is elérhető - ez a "legalább egy terminális hibába futott
|
|
* kísérlet" feltétel teljesülése.
|
|
*/
|
|
public function failRevert(PricelistFile $pricelistFile, string $message): void
|
|
{
|
|
$pricelistFile->refresh();
|
|
|
|
$meta = $pricelistFile->file_meta ?? [];
|
|
$meta['revert'] = array_merge($meta['revert'] ?? [], [
|
|
'last_status' => 'failed',
|
|
'last_error' => $message,
|
|
'finished_at' => now()->toDateTimeString(),
|
|
]);
|
|
|
|
$pricelistFile->update([
|
|
'status' => PricelistFileStatusEnum::execution_failed,
|
|
'file_meta' => $meta,
|
|
'processing_current_step' => mb_strcut('Visszaállítási hiba: ' . $message, 0, 255),
|
|
'processing_current_step_percentage' => null,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Felügyelt kényszerlezárás: a fájlt lezárja anélkül, hogy bármit visszaállítana.
|
|
*
|
|
* Vészkijárat arra az esetre, amikor a visszaállítás sem tud lefutni - enélkül egy
|
|
* ilyen fájl véglegesen blokkolná a beszállítót az árlistázásból. Nem javítja meg az
|
|
* adatokat, csak a döntést és a felelősséget teszi explicitté.
|
|
*
|
|
* @return bool false, ha a fájl nem kényszerlezárható (pl. még nem volt sikertelen
|
|
* visszaállítási kísérlet)
|
|
*/
|
|
public function forceClose(PricelistFile $pricelistFile, string $reason): bool
|
|
{
|
|
$pricelistFile->refresh();
|
|
|
|
if (! $pricelistFile->canBeForceClosed()) {
|
|
return false;
|
|
}
|
|
|
|
$meta = $pricelistFile->file_meta ?? [];
|
|
$meta['force_close'] = [
|
|
'reason' => $reason,
|
|
'revert_error' => $meta['revert']['last_error'] ?? null,
|
|
'user_id' => auth()->id(),
|
|
'at' => now()->toDateTimeString(),
|
|
];
|
|
|
|
$pricelistFile->update([
|
|
'status' => PricelistFileStatusEnum::closed,
|
|
'file_meta' => $meta,
|
|
'processing_current_step' => null,
|
|
'processing_current_step_percentage' => null,
|
|
]);
|
|
|
|
return true;
|
|
}
|
|
}
|