Files
compras/app/Services/WarehousePilotReadiness.php
T
2026-11-03 11:51:44 +02:00

117 lines
9.3 KiB
PHP

<?php
namespace App\Services;
use App\Models\CostCenter;
use App\Models\StockItem;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Warehouse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/** A diagnostic, never an activation, grant, business command or production certificate. */
class WarehousePilotReadiness
{
/** @return array<string,mixed> */
public function inspect(User $actor): array
{
abort_unless(auth()->id() === $actor->id && $actor->hasFunctions(['admin']), 403);
$users = User::query()->where('tenant_id', $actor->tenant_id)->orderBy('name')->get(['id', 'tenant_id', 'name', 'department_id', 'purchasing_role']);
$centers = CostCenter::query()->get(['id', 'department_id']);
$accessReady = Schema::hasTable('organization_memberships') && Schema::hasTable('organization_profiles');
$groups = ['prepare' => [], 'review' => [], 'deliver' => [], 'purchase_requester' => [], 'supply_prepare' => [], 'supply_review' => []];
$permanentAdmins = 0;
foreach ($users as $user) {
$grants = $accessReady ? DB::table('organization_memberships as m')->join('organization_profiles as p', 'p.id', '=', 'm.profile_id')->where('m.tenant_id', $actor->tenant_id)->where('p.tenant_id', $actor->tenant_id)->where('m.user_id', $user->id)->where('m.maintenance', false)->whereNull('m.expires_at')->pluck('p.permissions') : collect();
if ($grants->contains(fn ($json) => in_array('admin', json_decode($json, true, 32, JSON_THROW_ON_ERROR), true))) {
$permanentAdmins++;
}
if (app(OrganizationAccess::class)->maintenanceOnly($user)) {
continue;
}
$context = app(WorkContext::class)->enforced($user) ? 'procurement.internal' : null;
if ($context !== null && ! in_array($context, app(WorkContext::class)->available($user), true)) {
continue;
}
$permissions = app(OrganizationAccess::class)->permissions($user, $context);
$person = $user->only(['id', 'name', 'department_id']) + ['has_cost_center' => $user->department_id !== null && $centers->contains('department_id', $user->department_id)];
foreach (['prepare' => 'warehouse_request_prepare', 'review' => 'warehouse_request_review', 'purchase_requester' => 'requester', 'supply_prepare' => 'supply_prepare', 'supply_review' => 'supply_review'] as $key => $permission) {
if (in_array($permission, $permissions, true)) {
$groups[$key][] = $person;
}
}
if (in_array('warehouse_request_deliver', $permissions, true) && in_array('warehouse', $permissions, true)) {
$groups['deliver'][] = $person;
}
}
$independent = false;
$deliveryIds = array_fill_keys(array_column($groups['deliver'], 'id'), true);
foreach ($groups['prepare'] as $requester) {
foreach ($groups['review'] as $reviewer) {
if ($requester['id'] === $reviewer['id'] || $requester['department_id'] === null || $requester['department_id'] !== $reviewer['department_id'] || ! $requester['has_cost_center']) {
continue;
}
if (count($deliveryIds) > (isset($deliveryIds[$requester['id']]) ? 1 : 0) + (isset($deliveryIds[$reviewer['id']]) ? 1 : 0)) {
$independent = true;
break 2;
}
}
}
$tables = [];
foreach (['warehouse_requests', 'warehouse_request_events', 'warehouse_reservations', 'supply_records', 'replenishment_work', 'requisition_catalogue', 'requisition_commands'] as $table) {
$tables[$table] = Schema::hasTable($table);
}
$tables['requisition_process_version'] = Schema::hasColumn('requisitions', 'process_version');
$warehouses = Warehouse::query()->count();
$items = StockItem::query()->get(['id', 'code', 'name', 'unit'])->keyBy('id');
$published = 0;
$stale = 0;
if ($tables['requisition_catalogue']) {
foreach (DB::table('requisition_catalogue')->where('tenant_id', $actor->tenant_id)->where('published', true)->get(['stock_item_id', 'source_snapshot']) as $entry) {
$item = $items->get($entry->stock_item_id);
if ($item !== null && app(RequisitionCatalogue::class)->source($item) === json_decode($entry->source_snapshot, true, 32, JSON_THROW_ON_ERROR)) {
$published++;
} else {
$stale++;
}
}
}
$planning = app(SupplyPlanning::class);
$policies = $tables['supply_records'] ? $planning->latest('policy')->where('state', 'approved')->where('data->purpose', 'internal')->count() : 0;
$validRules = 0;
$invalidRules = 0;
if (! in_array(false, $tables, true)) {
foreach ($planning->latest('auto_rule')->where('state', 'approved')->cursor() as $rule) {
$status = app(AutomaticPurchases::class)->inspectRule($actor, $rule);
if ($status['valid']) {
$validRules++;
} else {
$invalidRules++;
}
}
}
$runtime = app(ReplenishmentQueue::class)->status($actor);
$pending = (int) ($runtime['work']->pending ?? 0);
$failed = (int) ($runtime['work']->failed ?? 0);
$recoveryClear = $runtime['recoveryReady'] && $pending === 0 && $failed === 0;
$active = app(OrganizationAccess::class)->active($actor->tenant_id);
$company = Tenant::query()->findOrFail($actor->tenant_id);
$internalConfigured = $company->work_context_policy !== null && in_array('procurement.internal', $company->work_context_policy['enabled'] ?? [], true) && in_array('procurement.internal', config('work_context.entitlements.'.$actor->tenant_id, []), true);
$checks = [
['key' => 'installation', 'label' => 'Instalação dos percursos', 'ok' => ! in_array(false, $tables, true), 'detail' => in_array(false, $tables, true) ? 'Existem componentes por instalar. A submissão com recuperação segura exige recibos de comandos e controlo de versão da requisição.' : 'Requisições, versões, recibos de comandos, reservas, catálogo e recuperação durável instalados.', 'path' => null],
['key' => 'participants', 'label' => 'Tarefas e independência no armazém', 'ok' => $independent, 'detail' => $independent ? 'Há combinação elegível de requisitante, autorizador e entregador distintos, com centro de custo do departamento.' : 'Faltam tarefas, centro de custo ou uma combinação de três intervenientes independentes. Não são atribuídas competências nesta consulta.', 'path' => '/administracao/parametrizacao'],
['key' => 'catalogue', 'label' => 'Artigos e armazéns', 'ok' => $warehouses > 0 && $published > 0, 'detail' => $warehouses.' armazém(ns), '.$published.' artigo(s) publicado(s) com origem actual e '.$stale.' publicação(ões) a rever.', 'path' => '/stock/catalogo'],
['key' => 'replenishment', 'label' => 'Reposição parametrizada e válida', 'ok' => $validRules > 0, 'detail' => $policies.' política(s) interna(s) aprovada(s), '.$validRules.' regra(s) activa(s) válida(s) e '.$invalidRules.' a rever. Falta de stock não é, por si, erro de configuração.', 'path' => '/stock/reposicao'],
['key' => 'scheduler', 'label' => 'Verificação periódica observada', 'ok' => $runtime['recoveryReady'] && $runtime['schedulerRecent'] && ($runtime['schedulerStatus']['result'] ?? null) === 'passed', 'detail' => $runtime['schedulerRecent'] ? 'Há uma passagem recente conhecida do serviço.' : 'Sem passagem recente confirmada nesta interface; instalação não prova execução.', 'path' => '/stock/reposicao'],
['key' => 'administration', 'label' => 'Configuração própria da empresa', 'ok' => $active && $permanentAdmins >= 2 && $internalConfigured, 'detail' => $active ? 'Perfis próprios activos; '.$permanentAdmins.' administrador(es) permanente(s). Conferir também o contexto interno contratado/activado.' : 'Acesso ainda em compatibilidade com funções de desenvolvimento. Isto não é configuração de cliente concluída.', 'path' => '/administracao/parametrizacao'],
['key' => 'recovery', 'label' => 'Trabalhos técnicos de reposição', 'ok' => $recoveryClear, 'detail' => $pending.' sinal(is) por processar e '.$failed.' trabalho(s) com recuperação pendente. Necessidades funcionais sem configuração válida continuam visíveis na reposição.', 'path' => '/stock/reposicao'],
];
return ['checked_at' => now()->toIso8601String(), 'checks' => $checks, 'participants' => $groups, 'registered' => $users->map(fn ($u) => $u->only(['id', 'name', 'department_id']))->all(), 'tables' => $tables, 'runtime' => $runtime,
'warehouse_scenario_ready' => ! in_array(false, $tables, true) && $independent && $warehouses > 0 && $published > 0,
'replenishment_scenario_ready' => ! in_array(false, $tables, true) && $recoveryClear && $validRules > 0 && $runtime['schedulerRecent'] && ($runtime['schedulerStatus']['result'] ?? null) === 'passed',
'client_configuration_complete' => $active && $permanentAdmins >= 2 && $internalConfigured];
}
}