190 lines
10 KiB
PHP
190 lines
10 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\StockItem;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Models\Warehouse;
|
|
use Carbon\CarbonImmutable;
|
|
use Carbon\Exceptions\InvalidFormatException;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Throwable;
|
|
|
|
/** One durable, coalescing work item per tenant/article/warehouse. No leased human identity. */
|
|
class ReplenishmentQueue
|
|
{
|
|
/** Read-only shared status for the workdesk and pilot diagnostics.
|
|
* @return array<string,mixed>
|
|
*/
|
|
public function status(User $actor): array
|
|
{
|
|
abort_unless(auth()->id() === $actor->id, 403);
|
|
$scheduler = null;
|
|
$recent = false;
|
|
$path = storage_path('framework/replenishment-status.json');
|
|
if (app()->environment('payable-testing') && is_file($path)) {
|
|
$text = file_get_contents($path);
|
|
$data = $text === false ? null : json_decode($text, true);
|
|
if (is_array($data) && is_string($data['finished_at'] ?? null) && in_array($data['result'] ?? null, ['passed', 'retry_pending'], true)) {
|
|
try {
|
|
$finished = CarbonImmutable::parse($data['finished_at']);
|
|
$recent = $finished->greaterThanOrEqualTo(now()->subMinutes(3)) && $finished->lessThanOrEqualTo(now()->addMinute());
|
|
$scheduler = array_intersect_key($data, array_flip(['started_at', 'finished_at', 'pid', 'scope', 'result', 'exit_code']));
|
|
} catch (InvalidFormatException) {
|
|
} // Optional telemetry must not prevent operational consultation.
|
|
}
|
|
}
|
|
|
|
return ['recoveryReady' => $this->ready(), 'scanMinutes' => config('replenishment.scan_minutes'), 'schedulerStatus' => $scheduler, 'schedulerRecent' => $recent,
|
|
'work' => $this->ready() ? DB::table('replenishment_work')->where('tenant_id', $actor->tenant_id)->selectRaw('COUNT(*) as total, SUM(CASE WHEN generation > processed_generation THEN 1 ELSE 0 END) as pending, SUM(CASE WHEN failures > 0 THEN 1 ELSE 0 END) as failed, MAX(last_run_at) as last_run_at')->first() : null];
|
|
}
|
|
|
|
public function ready(): bool
|
|
{
|
|
return config('work_context.composition') === 'sgc' && Schema::hasTable('replenishment_work');
|
|
}
|
|
|
|
public function enqueue(User $actor, int $item, int $warehouse): int
|
|
{
|
|
return DB::transaction(function () use ($actor, $item, $warehouse) {
|
|
app(ConflictGuard::class)->lockCompany($actor);
|
|
$actor = app(CurrentActor::class)->lock($actor);
|
|
StockItem::query()->whereKey($item)->firstOrFail();
|
|
Warehouse::query()->whereKey($warehouse)->firstOrFail();
|
|
|
|
return $this->touch($actor->tenant_id, $item, $warehouse, $actor->id, true);
|
|
});
|
|
}
|
|
|
|
private function touch(int $tenant, int $item, int $warehouse, int $responsible, bool $signal): int
|
|
{
|
|
$row = DB::table('replenishment_work')->where('tenant_id', $tenant)->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->first();
|
|
if ($row === null) {
|
|
return DB::table('replenishment_work')->insertGetId(['tenant_id' => $tenant, 'stock_item_id' => $item, 'warehouse_id' => $warehouse, 'responsible_id' => $responsible, 'generation' => 1, 'processed_generation' => 0, 'due_at' => now(), 'created_at' => now(), 'updated_at' => now()]);
|
|
}
|
|
if ($signal) {
|
|
DB::table('replenishment_work')->where('id', $row->id)->update(['generation' => $row->generation + 1, 'responsible_id' => $responsible, 'due_at' => now(), 'updated_at' => now()]);
|
|
}
|
|
|
|
return $row->id;
|
|
}
|
|
|
|
/** CLI discovery recovers pre-installation interruptions and adds approved policies without stock events. */
|
|
public function discover(): void
|
|
{
|
|
abort_unless(app()->runningInConsole() && ! auth()->check() && ! request()->hasSession(), 403);
|
|
if (! $this->ready()) {
|
|
return;
|
|
}
|
|
$latest = DB::table('supply_records')->selectRaw('MAX(id)')->whereIn('kind', ['policy', 'auto_rule', 'auto_need'])->groupBy('tenant_id', 'kind', 'record_key');
|
|
DB::table('supply_records')->whereIn('id', $latest)->whereNotNull('stock_item_id')->whereNotNull('warehouse_id')->orderBy('id')->chunkById(100, function ($rows) {
|
|
foreach ($rows as $row) {
|
|
$data = json_decode($row->data, true, 64, JSON_THROW_ON_ERROR);
|
|
if ($row->kind === 'policy' && ($row->state !== 'approved' || ($data['purpose'] ?? null) !== 'internal')) {
|
|
continue;
|
|
}
|
|
if ($row->kind === 'auto_rule' && $row->state !== 'approved') {
|
|
continue;
|
|
}
|
|
$this->discoverPair($row->tenant_id, $row->stock_item_id, $row->warehouse_id, $row->created_by);
|
|
}
|
|
});
|
|
DB::table('warehouse_requests')->where('work_context_key', 'procurement.internal')->orderBy('id')->chunkById(100, function ($rows) {
|
|
foreach ($rows as $row) {
|
|
foreach (json_decode($row->snapshot, true, 64, JSON_THROW_ON_ERROR)['lines'] as $line) {
|
|
$this->discoverPair($row->tenant_id, $line['item']['id'], $row->warehouse_id, $row->created_by);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
private function enabled(int $tenant): bool
|
|
{
|
|
$company = Tenant::query()->findOrFail($tenant);
|
|
|
|
return $company->work_context_policy === null || (in_array('procurement.internal', $company->work_context_policy['enabled'] ?? [], true) && in_array('procurement.internal', config('work_context.entitlements.'.$tenant, []), true));
|
|
}
|
|
|
|
private function discoverPair(int $tenant, int $item, int $warehouse, int $responsible): void
|
|
{
|
|
DB::transaction(function () use ($tenant, $item, $warehouse, $responsible) {
|
|
Tenant::query()->whereKey($tenant)->lockForUpdate()->firstOrFail();
|
|
if (! $this->enabled($tenant)) {
|
|
return;
|
|
}
|
|
if (! DB::table('users')->where('tenant_id', $tenant)->where('id', $responsible)->exists() || ! DB::table('stock_items')->where('tenant_id', $tenant)->where('id', $item)->exists() || ! DB::table('warehouses')->where('tenant_id', $tenant)->where('id', $warehouse)->exists()) {
|
|
return;
|
|
}
|
|
$this->touch($tenant, $item, $warehouse, $responsible, false);
|
|
});
|
|
}
|
|
|
|
public function interactive(User $actor, int $id): bool
|
|
{
|
|
return $this->process($id, $actor);
|
|
}
|
|
|
|
public function scheduled(int $id): bool
|
|
{
|
|
return app(ReplenishmentRun::class)->execute($id, fn () => $this->process($id, null));
|
|
}
|
|
|
|
private function process(int $id, ?User $actor): bool
|
|
{
|
|
$work = DB::table('replenishment_work')->where('id', $id)->firstOrFail();
|
|
if ($actor !== null) {
|
|
abort_unless($actor->tenant_id === $work->tenant_id && auth()->id() === $actor->id, 403);
|
|
}
|
|
$generation = null;
|
|
try {
|
|
DB::transaction(function () use ($id, $actor, &$generation) {
|
|
$first = DB::table('replenishment_work')->where('id', $id)->firstOrFail();
|
|
Tenant::query()->whereKey($first->tenant_id)->lockForUpdate()->firstOrFail();
|
|
$row = DB::table('replenishment_work')->where('id', $id)->lockForUpdate()->firstOrFail();
|
|
if (CarbonImmutable::parse($row->due_at)->isFuture() && $row->generation === $row->processed_generation) {
|
|
return;
|
|
}
|
|
$generation = $row->generation;
|
|
if ($actor === null) {
|
|
$actor = User::query()->where('tenant_id', $row->tenant_id)->whereKey($row->responsible_id)->firstOrFail();
|
|
}
|
|
if ($this->enabled($row->tenant_id)) {
|
|
app(AutomaticPurchases::class)->evaluate($actor, $row->stock_item_id, $row->warehouse_id);
|
|
}
|
|
$latest = app(SupplyPlanning::class)->latest('auto_need')->where('stock_item_id', $row->stock_item_id)->where('warehouse_id', $row->warehouse_id)->first();
|
|
DB::table('replenishment_work')->where('id', $id)->update(['processed_generation' => $generation, 'attempts' => $row->attempts + 1, 'failures' => 0, 'last_outcome' => $this->enabled($row->tenant_id) ? ($latest->state ?? 'no_need') : 'context_disabled', 'last_error' => null, 'last_run_at' => now(), 'due_at' => now()->addMinutes(config('replenishment.scan_minutes')), 'updated_at' => now()]);
|
|
});
|
|
|
|
return true;
|
|
} catch (Throwable $error) {
|
|
report($error);
|
|
// The failed transaction rolled back both draft and checkpoint. Keep the durable signal.
|
|
DB::transaction(function () use ($id, $work, $generation) {
|
|
Tenant::query()->whereKey($work->tenant_id)->lockForUpdate()->firstOrFail();
|
|
$row = DB::table('replenishment_work')->where('id', $id)->lockForUpdate()->firstOrFail();
|
|
$fields = ['attempts' => $row->attempts + 1, 'failures' => $row->failures + 1, 'last_outcome' => 'error', 'last_error' => 'Avaliação interrompida. Recuperação automática pendente; consulte o registo técnico.', 'updated_at' => now()];
|
|
if ($generation !== null && $generation === $row->generation) {
|
|
$fields['due_at'] = now()->addSeconds(min(3600, 60 * (2 ** min($row->failures, 6))));
|
|
}
|
|
DB::table('replenishment_work')->where('id', $id)->update($fields);
|
|
});
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** @return array{processed:int,failed:int} */
|
|
public function drain(int $limit): array
|
|
{
|
|
abort_unless(app()->runningInConsole() && ! auth()->check() && ! request()->hasSession(), 403);
|
|
$result = ['processed' => 0, 'failed' => 0];
|
|
foreach (DB::table('replenishment_work')->where('due_at', '<=', now())->orderBy('due_at')->orderBy('id')->limit($limit)->pluck('id') as $id) {
|
|
$result[$this->scheduled($id) ? 'processed' : 'failed']++;
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
}
|