- 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>
176 lines
6.0 KiB
PHP
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();
|
|
}
|
|
}
|