d2d.emegrendeles.hu/app/Models/PricelistFile.php
E98Developer e338f68ee8 ADD EV3-357 Árlista feldolgozás phase5/4 visszavonás, elakadás-észlelés, kényszerlezárás
- PricelistExecutionJob + új PricelistRevertJob: $tries=1, $timeout, failed()
  hook. A service catch ága csak PHP exceptiont fog el; worker timeout vagy
  memórialimit esetén a fájl inprogress-ben ragadna, döntési gomb nélkül.
- heartbeat backstop a kill -9 esetére, ahol a failed() sem fut: minden chunk ír
  a rekordba, így az updated_at a szívverés (isExecutionStuck). Ezzel garantált,
  hogy minden kísérlet véges időn belül terminális állapotba jut - enélkül a
  kényszerlezárás sem nyílna ki soha.
- kompenzáló visszaállítás R1-R4: árak törlése, termékek visszaállítása a
  snapshotból, létrehozott termékek kivonása (soft delete, NEM fizikai törlés,
  mert lehet rájuk hivatkozás), árlista + fájl lezárása
- konfliktuskezelés: a végrehajtás óta kézzel módosított terméket kihagyjuk és
  jelentjük, nem írjuk felül vakon
- FIX phase5/3: a snapshot csak az írás ELŐTTI updated_at-et tárolta, amihez
  képest a termék a végrehajtás után mindig eltér - a konfliktus-ellenőrzés így
  minden sort kihagyott volna. Most a saját írásunk utáni updated_at is bekerül.
- kényszerlezárás: csak legalább egy terminális hibába futott visszaállítási
  kísérlet után, flag + developer szerepkör mögött, kötelező indoklással
- döntéstámogató panel: mi történt már meg az importból, hogy a felhasználó ne
  vakon válasszon a Folytatás és a Visszavonás között

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 06:36:21 +02:00

176 lines
6.0 KiB
PHP

<?php
namespace App\Models;
use App\Enums\PricelistFileLineStatusEnum;
use App\Enums\PricelistFileStatusEnum;
use App\Enums\PricelistWorkflowStep;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class PricelistFile extends BaseAuditable
{
use SoftDeletes;
protected $casts = [
'status' => 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();
}
}