PricelistFileStatusEnum::class, 'processing_current_step_percentage' => 'integer', 'available_date' => 'date', 'workflow_steps' => 'array', 'file_meta' => 'array', ]; protected static function booted() { static::creating(function ($pricelistFile) { if (empty($pricelistFile->workflow_steps)) { $pricelistFile->workflow_steps = \App\Enums\PricelistWorkflowStep::defaultSteps(); } }); } public function supplier(): BelongsTo { return $this->belongsTo(Supplier::class); } public function priceList(): BelongsTo { return $this->belongsTo(PriceList::class); } public function lines(): HasMany { return $this->hasMany(PricelistFileLine::class); } /** * Egy workflow lépés aktuális státusza a `workflow_steps` tömbből * ('pending' / 'inprogress' / 'completed' / 'failed'), vagy null, ha a lépés * nem szerepel a rekordban (régi fájloknál előfordulhat). */ public function stepStatus(PricelistWorkflowStep $step): ?string { foreach ($this->workflow_steps ?? [] as $workflowStep) { if (($workflowStep['name'] ?? null) === $step->value) { return $workflowStep['status'] ?? null; } } return null; } /** * Van-e a fájlban blokkoló (hibás) sor. Ez a jóváhagyás egyik feltétele: * hibás sorral nem indítható végrehajtás. */ public function hasErrorLines(): bool { return $this->lines() ->where('status', PricelistFileLineStatusEnum::error->value) ->exists(); } /** * Jóváhagyható-e a fájl. Egyetlen igazságforrás: ezt nézi a felületi gomb * láthatósága ÉS a végrehajtási job is induláskor - a felület 5 mp-enként * pollozik, így a gomb megjelenítése és a kattintás között változhat az állapot. */ public function canBeApproved(): bool { return $this->status === PricelistFileStatusEnum::waiting_for_approval && $this->stepStatus(PricelistWorkflowStep::Validation) === 'completed' && ! $this->hasErrorLines(); } /** * Elindult-e már a végrehajtás. Ha igen, a fájl újratöltése (Edit -> a teljes * lánc újraindítása) TILOS: addigra már létrejöhettek termékek és árak, egy * nulláról induló újrafuttatás duplikálna. Helyette a Folytatás vagy a * Visszavonás akció használható. */ public function hasExecutionStarted(): bool { return ! in_array($this->stepStatus(PricelistWorkflowStep::Execution), ['pending', null], true); } /** * Ennyi tétlenség után tekintjük elakadtnak a futó végrehajtást/visszaállítást. * A job failed() hookja a hibák többségét lefedi, de egy `kill -9`-elt worker után * az sem fut le - ilyenkor a fájl inprogress-ben ragadna, egyetlen elérhető döntési * gomb nélkül. Minden chunk ír a rekordba, tehát az updated_at a heartbeat. */ public const STUCK_AFTER_MINUTES = 15; public function isExecutionStuck(?int $minutes = null): bool { if ($this->status !== PricelistFileStatusEnum::inprogress || ! $this->hasExecutionStarted()) { return false; } return (bool) $this->updated_at?->lt(now()->subMinutes($minutes ?? self::STUCK_AFTER_MINUTES)); } public function isRevertRunning(): bool { return $this->status === PricelistFileStatusEnum::inprogress && ($this->file_meta['revert']['last_status'] ?? null) === 'inprogress'; } /** * Vár-e a fájl felhasználói döntésre (folytatás vagy visszaállítás). */ public function needsExecutionDecision(): bool { return $this->status === PricelistFileStatusEnum::execution_failed || $this->isExecutionStuck(); } /** * Kényszerlezárható-e. Feltétel: volt legalább egy visszaállítási kísérlet, amely * TERMINÁLIS hibaállapotba jutott. Így a kényszerlezárás nem alternatív útvonal a * visszaállítás mellett, hanem csak annak kudarca után elérhető vészkijárat. */ public function canBeForceClosed(): bool { $revert = $this->file_meta['revert'] ?? []; return $this->needsExecutionDecision() && (int) ($revert['attempts'] ?? 0) >= 1 && ($revert['last_status'] ?? null) === 'failed'; } /** * Mi történt már a végrehajtásból. A felhasználó ez alapján tud dönteni a folytatás * és a visszaállítás között - enélkül vakon választana a két gomb közül. */ public function executionSummary(): array { return [ 'created_products' => $this->lines() ->where('status', PricelistFileLineStatusEnum::new_product->value) ->whereNotNull('product_id') ->count(), 'updated_products' => $this->lines()->whereNotNull('applied_snapshot')->count(), 'priced_lines' => $this->lines()->whereNotNull('executed_at')->count(), 'pending_lines' => $this->lines() ->where('status', '!=', PricelistFileLineStatusEnum::error->value) ->whereNull('executed_at') ->count(), ]; } /** * Soronkénti státuszok darabszáma (státusz => darab), a jóváhagyás előtti * összegző modalhoz. */ public function lineStatusCounts(): array { return $this->lines() ->selectRaw('status, COUNT(*) as total') ->groupBy('status') ->pluck('total', 'status') ->all(); } }